diff --git a/README.ja.md b/README.ja.md index 5af6a28..ee9b657 100644 --- a/README.ja.md +++ b/README.ja.md @@ -121,6 +121,45 @@ cp target/release/autocli /usr/local/bin/ # macOS / Linux > Public モードのコマンド(hackernews、devto、lobsters など)は拡張機能なしで使用できます。 +### 任意: Chrome Tab Group 厳格バックグラウンドモード + +ブラウザコマンドは、デフォルトでは従来どおり automation window を使用します。新しい可視 Chrome ウィンドウを作りたくない場合は、Tab Group 厳格バックグラウンドモードを明示的に有効化できます。 + +```json +{ + "browser": { + "carrier": "tab-group-background", + "groupName": "work", + "groupIdleTimeoutSeconds": 30 + } +} +``` + +環境変数でも上書きできます。 + +```bash +AUTOCLI_BROWSER_CARRIER=tab-group-background +AUTOCLI_BROWSER_GROUP_NAME=work +AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT=30 +``` + +実際のグループ名は `AutoCLI-${groupName}` になり、Chrome プロファイル内ではタイトルが完全一致する Tab Group をグローバルに一つだけ再利用します。`groupName` が未指定または空白の場合は `default` になり、`AutoCLI-default` を使用します。同じタイトルの開いているグループが複数ある場合、どのグループまたはタブを管理するか推測せず、コマンドは失敗します。このモードでは、新しい可視 Chrome ウィンドウを作らず、AutoCLI タブへ切り替えず、現在のアクティブタブを奪いません。Chrome、拡張機能、または既存の通常 Chrome ウィンドウが利用できない場合は、新規ウィンドウやアクティブ化へのフォールバックをせず、明示的に失敗します。`tabs select` はこのモードでは使用できません。 + +`workspace` は Web サイトではなく、グループ名にも参加しません。共有グループ内の論理的な呼び出し元と、その `tabId` の所有権を識別するためだけに使われます。同じ `groupName` の呼び出しは、異なるサイトにアクセスする場合でも一つの物理グループを共有し、自分で作成したタブだけを操作またはクリーンアップします。他の呼び出し元やユーザー既存のタブを選択・削除することはありません。 + +コマンド終了後は、連続コマンドのために同じグループを短時間保持します。strict-background モードでは、`groupIdleTimeoutSeconds` は AutoCLI が作成した各タブのアイドル TTL です。拡張機能は各 `tabId` の最後に成功した AutoCLI 操作時刻を記録し、そのタブ自身がタイムアウトした場合だけ閉じます。別のタブの操作や `tabs list` では更新されません。グループごとに最も早く期限切れになる AutoCLI タブを処理するスケジューラを 1 つだけ持ち、AutoCLI 所有のタブがなくなったときだけセッション記録を解放します。所有権と操作時刻は `chrome.storage.session` に保存されるため、MV3 service worker の停止をまたいで保持されます。ただし、拡張機能の再読み込み・更新、またはブラウザ再起動後には意図的に破棄されます。Chrome の ID は現在のブラウザセッション内でのみ信頼できるため、その境界を越えた後に AutoCLI が所有権を推測したり、既存のタブを削除したりすることはありません。元に戻すには、これらの環境変数を削除するか、`carrier` を `automation-window` に戻してください。 + +カスタムローカル adapter でも、コマンドごとにブラウザ carrier を指定できます。この設定はグローバル設定と環境変数を上書きします。 + +```yaml +browser: + carrier: tab-group-background + groupName: work + groupIdleTimeoutSeconds: 30 +``` + +既存の `browser: true` / `browser: false` 構文は引き続き互換です。adapter またはグローバル設定で `tab-group-background` を明示的に選び、有効な `groupName` がない場合は `default` を使用します。 + ## Skill インストール ワンクリックで AI Agent に autocli skill をインストール: diff --git a/README.md b/README.md index ba3a824..8f3140f 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,45 @@ Simply re-run the install command or download the latest release to overwrite th > Public mode commands (hackernews, devto, lobsters, etc.) work without the extension. +### Optional: Chrome Tab Group Strict Background Mode + +Browser commands still use the existing automation window by default. To avoid creating a new visible Chrome window, explicitly enable Tab Group strict background mode: + +```json +{ + "browser": { + "carrier": "tab-group-background", + "groupName": "work", + "groupIdleTimeoutSeconds": 30 + } +} +``` + +Environment variables can override the file config: + +```bash +AUTOCLI_BROWSER_CARRIER=tab-group-background +AUTOCLI_BROWSER_GROUP_NAME=work +AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT=30 +``` + +The actual group title is `AutoCLI-${groupName}`; one exact-title Tab Group is reused globally within the Chrome profile. If `groupName` is omitted or blank, it defaults to `default`, producing `AutoCLI-default`. If several open groups have the same title, the command fails rather than guessing which group or tabs to manage. This mode strictly avoids new visible Chrome windows, avoids switching to AutoCLI tabs, and avoids stealing the current active tab. If Chrome, the extension, or an existing normal Chrome window is unavailable, the command fails instead of falling back to a new window or an activated tab. `tabs select` is unsupported in this mode. + +`workspace` is not a website and does not participate in group naming. It identifies a logical caller and its `tabId` ownership inside the shared group. Calls with the same `groupName`, even when they visit different sites, use that one physical group and can operate on or clean up only the tabs they created; they never select or remove another caller's or the user's existing tabs. + +After a command ends, the same group is kept briefly for consecutive commands. In strict-background mode, `groupIdleTimeoutSeconds` is the idle TTL for each AutoCLI-created tab. The extension records the last successful AutoCLI operation for every `tabId` and closes that tab only after its own timeout; activity on another tab or `tabs list` does not renew it. One group-level scheduler always handles the earliest-expiring AutoCLI tab, and the session record is released only when no AutoCLI-owned tabs remain. Ownership and activity are kept in `chrome.storage.session`, so they survive MV3 service-worker suspension but are intentionally discarded after an extension reload, update, or browser restart: Chrome IDs are only safe within that browser session, and AutoCLI will not guess ownership or remove existing tabs after that boundary. To roll back, unset these environment variables or set `carrier` back to `automation-window`. + +Custom local adapters can also choose the browser carrier per command, overriding global config and environment variables: + +```yaml +browser: + carrier: tab-group-background + groupName: work + groupIdleTimeoutSeconds: 30 +``` + +The existing `browser: true` / `browser: false` syntax remains compatible. If an adapter or global config explicitly selects `tab-group-background` without a valid `groupName`, AutoCLI uses `default`. + ## Skill Install One-click install autocli skill for your AI Agent: diff --git a/README.zh.md b/README.zh.md index 123c338..324a93f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -121,6 +121,45 @@ cp target/release/autocli /usr/local/bin/ # macOS / Linux > Public 模式命令(hackernews、devto、lobsters 等)无需安装扩展即可使用。 +### 可选:Chrome Tab Group 严格后台模式 + +浏览器命令默认仍使用原有的 automation window。需要避免新建可见 Chrome 窗口时,可以显式启用 Tab Group 严格后台模式: + +```json +{ + "browser": { + "carrier": "tab-group-background", + "groupName": "work", + "groupIdleTimeoutSeconds": 30 + } +} +``` + +也可以用环境变量覆盖: + +```bash +AUTOCLI_BROWSER_CARRIER=tab-group-background +AUTOCLI_BROWSER_GROUP_NAME=work +AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT=30 +``` + +启用后实际组名为 `AutoCLI-${groupName}`;在同一 Chrome 配置文件中,全局复用唯一一个完全同名的 Tab Group。未设置或留空 `groupName` 时默认为 `default`,即 `AutoCLI-default`。如果同时存在多个同名的开放分组,命令会直接报错,而不会猜测应管理哪个分组或哪些标签。该模式严格不创建新的可见 Chrome 窗口、不切换到 AutoCLI 标签、不抢当前活动标签;如果 Chrome、扩展或现有普通 Chrome 窗口不可用,会直接报错,不会自动回退到新窗口或激活标签。`tabs select` 在该模式下不可用。 + +`workspace` 不是网站,也不参与分组命名;它只用于共享分组内的逻辑调用者和 `tabId` 所有权追踪。相同 `groupName` 的不同调用即使访问不同网站,也只使用这一个物理分组,并且只能操作或清理各自创建的标签,绝不会任选或删除其他调用及用户原有的标签。 + +命令结束时会短暂保留同一组用于连续命令;在严格后台模式下,`groupIdleTimeoutSeconds` 是每个 AutoCLI 创建标签的空闲 TTL。扩展会为每个 `tabId` 记录最后一次成功的 AutoCLI 操作时间,并在该标签自身超时后关闭它;对另一个标签的操作或 `tabs list` 不会续期它。一个组只保留一个调度器,始终处理最早到期的 AutoCLI 标签;只有没有剩余 AutoCLI 标签时才释放该组的会话记录。所有权和活动记录保存在 `chrome.storage.session` 中,因此可跨 MV3 service worker 挂起保留;但扩展重载、更新或浏览器重启后会有意丢弃这些记录:Chrome ID 仅在当前浏览器会话中可靠,跨越该边界后 AutoCLI 不会猜测所有权或删除已有标签。回滚到旧行为时,删除这些环境变量或将 `carrier` 改回 `automation-window`。 + +自定义本地 adapter 也可以单独指定浏览器承载方式,且会覆盖全局配置和环境变量: + +```yaml +browser: + carrier: tab-group-background + groupName: work + groupIdleTimeoutSeconds: 30 +``` + +现有写法 `browser: true` / `browser: false` 仍然兼容。若 adapter 或全局配置显式选择 `tab-group-background` 但没有提供有效 `groupName`,默认使用 `default`。 + ## Skill 安装 一键为你的 AI Agent 安装 autocli skill: diff --git a/crates/autocli-browser/src/bridge.rs b/crates/autocli-browser/src/bridge.rs index dc40af4..d83c46b 100644 --- a/crates/autocli-browser/src/bridge.rs +++ b/crates/autocli-browser/src/bridge.rs @@ -1,8 +1,10 @@ -use autocli_core::{CliError, IPage}; +use autocli_core::{BrowserOptions, CliError, IPage}; use std::sync::Arc; use std::time::Duration; use tracing::{debug, info, warn}; +use uuid::Uuid; +use crate::config::{load_browser_config_with_options, BrowserCarrier, BrowserConfig}; use crate::daemon_client::DaemonClient; use crate::page::DaemonPage; @@ -17,11 +19,33 @@ const EXTENSION_POLL_INTERVAL: Duration = Duration::from_millis(500); /// The daemon runs as a detached background process with its own idle-shutdown lifecycle. pub struct BrowserBridge { port: u16, + browser_config: Option, + browser_options: Option, + strict_background_workspace: String, } impl BrowserBridge { pub fn new(port: u16) -> Self { - Self { port } + Self { + port, + browser_config: None, + browser_options: None, + strict_background_workspace: Uuid::new_v4().to_string(), + } + } + + pub fn with_browser_config(port: u16, browser_config: BrowserConfig) -> Self { + Self { + port, + browser_config: Some(browser_config), + browser_options: None, + strict_background_workspace: Uuid::new_v4().to_string(), + } + } + + pub fn with_browser_options(mut self, browser_options: Option) -> Self { + self.browser_options = browser_options; + self } /// Create a bridge using the default port. @@ -29,6 +53,20 @@ impl BrowserBridge { Self::new(DEFAULT_PORT) } + fn should_wake_chrome_for_extension_recovery(&self) -> bool { + !matches!( + self.browser_config.as_ref().map(|cfg| &cfg.carrier), + Some(BrowserCarrier::TabGroupBackground) + ) + } + + fn workspace_for(&self, browser_config: &BrowserConfig) -> &str { + match browser_config.carrier { + BrowserCarrier::TabGroupBackground => &self.strict_background_workspace, + BrowserCarrier::AutomationWindow => "default", + } + } + /// Connect to the daemon, starting it if necessary, and return a trait-object page. pub async fn connect(&mut self) -> Result, CliError> { Ok(self.connect_daemon_page().await?) @@ -37,6 +75,7 @@ impl BrowserBridge { /// Connect and return the concrete `DaemonPage` so callers can use /// daemon-specific methods (e.g. `read_article`) not on the `IPage` trait. pub async fn connect_daemon_page(&mut self) -> Result, CliError> { + let browser_config = self.browser_config()?; let client = Arc::new(DaemonClient::new(self.port)); // Step 1: Check Chrome is running @@ -61,8 +100,27 @@ impl BrowserBridge { } // Step 3: Wait up to 5s for extension to connect - if self.poll_extension(&client, EXTENSION_INITIAL_WAIT, false).await { - return Ok(Arc::new(DaemonPage::new(client, "default"))); + if self + .poll_extension(&client, EXTENSION_INITIAL_WAIT, false) + .await + { + return Ok(Arc::new(DaemonPage::new_with_browser_config( + client, + self.workspace_for(&browser_config), + browser_config.clone(), + ))); + } + + if !self.should_wake_chrome_for_extension_recovery() { + return Err(CliError::BrowserConnect { + message: "Chrome extension not connected in strict-background mode".into(), + suggestions: vec![ + "Open an existing normal Chrome window with the AutoCLI extension already enabled".into(), + "Strict background mode will not open Chrome, create a new window, or wake the extension with about:blank".into(), + format!("The daemon is listening on port {}", self.port), + ], + source: None, + }); } // Step 4: Extension not connected — try to wake up Chrome @@ -71,8 +129,15 @@ impl BrowserBridge { wake_chrome(); // Step 5: Wait remaining 25s with progress - if self.poll_extension(&client, EXTENSION_REMAINING_WAIT, true).await { - return Ok(Arc::new(DaemonPage::new(client, "default"))); + if self + .poll_extension(&client, EXTENSION_REMAINING_WAIT, true) + .await + { + return Ok(Arc::new(DaemonPage::new_with_browser_config( + client, + self.workspace_for(&browser_config), + browser_config.clone(), + ))); } warn!("Chrome extension is not connected to the daemon"); @@ -165,6 +230,18 @@ impl BrowserBridge { READY_TIMEOUT.as_secs() ))) } + + fn browser_config(&mut self) -> Result { + if self.browser_config.is_none() { + self.browser_config = Some(load_browser_config_with_options( + self.browser_options.as_ref(), + )?); + } + Ok(self + .browser_config + .clone() + .unwrap_or_else(BrowserConfig::default)) + } } /// Check if Chrome/Chromium is running as a process. @@ -243,4 +320,44 @@ mod tests { let bridge = BrowserBridge::default_port(); assert_eq!(bridge.port, DEFAULT_PORT); } + + #[test] + fn tab_group_background_does_not_use_chrome_wake_path() { + let bridge = BrowserBridge::with_browser_config( + 19925, + BrowserConfig { + carrier: BrowserCarrier::TabGroupBackground, + group_name: Some("work".to_string()), + group_idle_timeout_seconds: Some(30), + }, + ); + + assert!(!bridge.should_wake_chrome_for_extension_recovery()); + } + + #[test] + fn automation_window_keeps_chrome_wake_path() { + let bridge = BrowserBridge::new(19925); + assert!(bridge.should_wake_chrome_for_extension_recovery()); + } + + #[test] + fn tab_group_background_uses_a_unique_stable_workspace() { + let config = BrowserConfig { + carrier: BrowserCarrier::TabGroupBackground, + group_name: Some("work".to_string()), + group_idle_timeout_seconds: Some(30), + }; + let first = BrowserBridge::with_browser_config(19925, config.clone()); + let second = BrowserBridge::with_browser_config(19925, config.clone()); + + assert_eq!(first.workspace_for(&config), first.workspace_for(&config)); + assert_ne!(first.workspace_for(&config), second.workspace_for(&config)); + } + + #[test] + fn automation_window_uses_default_workspace() { + let bridge = BrowserBridge::new(19925); + assert_eq!(bridge.workspace_for(&BrowserConfig::default()), "default"); + } } diff --git a/crates/autocli-browser/src/cdp.rs b/crates/autocli-browser/src/cdp.rs index 3ede670..d0ff737 100644 --- a/crates/autocli-browser/src/cdp.rs +++ b/crates/autocli-browser/src/cdp.rs @@ -1,9 +1,9 @@ use async_trait::async_trait; -use futures::{SinkExt, StreamExt}; use autocli_core::{ AutoScrollOptions, CliError, Cookie, CookieOptions, GotoOptions, IPage, InterceptedRequest, NetworkRequest, ScreenshotOptions, SnapshotOptions, TabInfo, WaitOptions, }; +use futures::{SinkExt, StreamExt}; use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -15,8 +15,10 @@ use tracing::{debug, error}; use crate::dom_helpers; -type WsSink = - futures::stream::SplitSink>, Message>; +type WsSink = futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream>, + Message, +>; /// Direct Chrome DevTools Protocol page client via WebSocket. /// @@ -46,9 +48,7 @@ impl CdpPage { Ok(Message::Text(text)) => { if let Ok(json) = serde_json::from_str::(&text) { if let Some(id) = json.get("id").and_then(|v| v.as_u64()) { - if let Some(tx) = - reader_pending.write().await.remove(&id) - { + if let Some(tx) = reader_pending.write().await.remove(&id) { let _ = tx.send(json); } } else { @@ -215,9 +215,7 @@ impl IPage for CdpPage { } async fn cookies(&self, _options: Option) -> Result, CliError> { - let result = self - .send_cdp("Network.getCookies", json!({})) - .await?; + let result = self.send_cdp("Network.getCookies", json!({})).await?; let cookies_val = result.get("cookies").cloned().unwrap_or(json!([])); let cookies: Vec = serde_json::from_value(cookies_val).unwrap_or_default(); Ok(cookies) @@ -267,10 +265,7 @@ impl IPage for CdpPage { async fn tabs(&self) -> Result, CliError> { let result = self.send_cdp("Target.getTargets", json!({})).await?; - let targets = result - .get("targetInfos") - .cloned() - .unwrap_or(json!([])); + let targets = result.get("targetInfos").cloned().unwrap_or(json!([])); let mut tabs = Vec::new(); if let Some(arr) = targets.as_array() { for t in arr { diff --git a/crates/autocli-browser/src/config.rs b/crates/autocli-browser/src/config.rs new file mode 100644 index 0000000..6f420bb --- /dev/null +++ b/crates/autocli-browser/src/config.rs @@ -0,0 +1,424 @@ +use autocli_core::{BrowserOptions, CliError}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BrowserCarrier { + AutomationWindow, + TabGroupBackground, +} + +impl Default for BrowserCarrier { + fn default() -> Self { + Self::AutomationWindow + } +} + +impl BrowserCarrier { + pub fn as_str(&self) -> &'static str { + match self { + Self::AutomationWindow => "automation-window", + Self::TabGroupBackground => "tab-group-background", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserConfig { + pub carrier: BrowserCarrier, + pub group_name: Option, + pub group_idle_timeout_seconds: Option, +} + +impl Default for BrowserConfig { + fn default() -> Self { + Self { + carrier: BrowserCarrier::AutomationWindow, + group_name: None, + group_idle_timeout_seconds: None, + } + } +} + +pub fn load_browser_config() -> Result { + let file_json = read_default_config_file()?; + let env_pairs = browser_env_pairs(); + resolve_browser_config(file_json.as_deref(), &env_pairs) +} + +pub fn load_browser_config_with_options( + options: Option<&BrowserOptions>, +) -> Result { + let file_json = read_default_config_file()?; + let env_pairs = browser_env_pairs(); + resolve_browser_config_with_options(file_json.as_deref(), &env_pairs, options) +} + +pub fn resolve_browser_config( + file_json: Option<&str>, + env: &[(K, V)], +) -> Result +where + K: AsRef, + V: AsRef, +{ + resolve_browser_config_with_options(file_json, env, None) +} + +pub fn resolve_browser_config_with_options( + file_json: Option<&str>, + env: &[(K, V)], + options: Option<&BrowserOptions>, +) -> Result +where + K: AsRef, + V: AsRef, +{ + let mut cfg = match file_json { + Some(raw) if !raw.trim().is_empty() => parse_file_config(raw)?, + _ => BrowserConfig::default(), + }; + + for (key, value) in env { + apply_env_override(&mut cfg, key.as_ref(), value.as_ref())?; + } + + if let Some(options) = options { + apply_command_options(&mut cfg, options)?; + } + + validate_browser_config(cfg) +} + +fn parse_file_config(raw: &str) -> Result { + let root: Value = serde_json::from_str(raw).map_err(|e| { + CliError::config(format!( + "Invalid ~/.autocli/config.json browser config JSON: {e}" + )) + })?; + let Some(browser) = root.get("browser") else { + return Ok(BrowserConfig::default()); + }; + let Some(browser) = browser.as_object() else { + return Err(CliError::config("browser config must be an object")); + }; + + let carrier = match browser.get("carrier").and_then(Value::as_str) { + Some(value) => parse_carrier(value)?, + None => BrowserCarrier::AutomationWindow, + }; + let group_name = browser + .get("groupName") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let group_idle_timeout_seconds = match browser.get("groupIdleTimeoutSeconds") { + Some(Value::Number(n)) => Some(n.as_u64().filter(|value| *value > 0).ok_or_else(|| { + CliError::config("browser group idle timeout must be a positive integer") + })?), + Some(Value::String(s)) => Some(parse_timeout(s)?), + Some(_) => { + return Err(CliError::config( + "browser.groupIdleTimeoutSeconds must be a positive integer timeout", + )); + } + None => None, + }; + + Ok(BrowserConfig { + carrier, + group_name, + group_idle_timeout_seconds, + }) +} + +fn apply_env_override(cfg: &mut BrowserConfig, key: &str, value: &str) -> Result<(), CliError> { + match key { + "AUTOCLI_BROWSER_CARRIER" => cfg.carrier = parse_carrier(value)?, + "AUTOCLI_BROWSER_GROUP_NAME" => cfg.group_name = Some(value.to_string()), + "AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT" => { + cfg.group_idle_timeout_seconds = Some(parse_timeout(value)?); + } + _ => {} + } + Ok(()) +} + +fn apply_command_options( + cfg: &mut BrowserConfig, + options: &BrowserOptions, +) -> Result<(), CliError> { + if let Some(carrier) = &options.carrier { + cfg.carrier = parse_carrier(carrier)?; + } + if let Some(group_name) = &options.group_name { + cfg.group_name = Some(group_name.clone()); + } + if let Some(timeout) = options.group_idle_timeout_seconds { + cfg.group_idle_timeout_seconds = Some(timeout); + } + Ok(()) +} + +fn validate_browser_config(mut cfg: BrowserConfig) -> Result { + if let Some(name) = cfg.group_name.take() { + let trimmed = name.trim(); + if trimmed.is_empty() { + cfg.group_name = None; + } else if trimmed.contains('\n') || trimmed.contains('\r') { + return Err(CliError::config( + "browser group name cannot contain newlines", + )); + } else { + cfg.group_name = Some(trimmed.to_string()); + } + } + + if cfg.carrier == BrowserCarrier::TabGroupBackground && cfg.group_name.is_none() { + cfg.group_name = Some("default".to_string()); + } + + if cfg.group_idle_timeout_seconds == Some(0) { + return Err(CliError::config( + "browser group idle timeout must be a positive integer", + )); + } + + Ok(cfg) +} + +fn parse_carrier(value: &str) -> Result { + match value.trim() { + "" | "automation-window" => Ok(BrowserCarrier::AutomationWindow), + "tab-group-background" => Ok(BrowserCarrier::TabGroupBackground), + other => Err(CliError::config(format!( + "Unknown browser carrier '{other}'. Expected automation-window or tab-group-background" + ))), + } +} + +fn parse_timeout(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| CliError::config("browser group idle timeout must be a positive integer"))?; + if parsed == 0 { + return Err(CliError::config( + "browser group idle timeout must be a positive integer", + )); + } + Ok(parsed) +} + +fn read_default_config_file() -> Result, CliError> { + let home = match std::env::var("HOME") { + Ok(home) => home, + Err(_) => return Ok(None), + }; + let path = Path::new(&home).join(".autocli").join("config.json"); + match std::fs::read_to_string(path) { + Ok(raw) => Ok(Some(raw)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(CliError::config(format!( + "Failed to read ~/.autocli/config.json: {e}" + ))), + } +} + +fn browser_env_pairs() -> Vec<(String, String)> { + [ + "AUTOCLI_BROWSER_CARRIER", + "AUTOCLI_BROWSER_GROUP_NAME", + "AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT", + ] + .iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| ((*key).to_string(), value)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resolve(file_json: Option<&str>, env: &[(&str, &str)]) -> Result { + resolve_browser_config(file_json, env) + } + + fn resolve_with_options( + file_json: Option<&str>, + env: &[(&str, &str)], + options: Option<&BrowserOptions>, + ) -> Result { + resolve_browser_config_with_options(file_json, env, options) + } + + #[test] + fn defaults_to_automation_window_when_config_is_absent() { + let cfg = resolve(None, &[]).unwrap(); + assert_eq!(cfg.carrier, BrowserCarrier::AutomationWindow); + assert_eq!(cfg.group_name, None); + assert_eq!(cfg.group_idle_timeout_seconds, None); + } + + #[test] + fn reads_browser_group_config_from_existing_config_file() { + let cfg = resolve( + Some( + r#"{ + "llm": { "provider": "openai" }, + "browser": { + "carrier": "tab-group-background", + "groupName": "work", + "groupIdleTimeoutSeconds": 45 + } +}"#, + ), + &[], + ) + .unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("work")); + assert_eq!(cfg.group_idle_timeout_seconds, Some(45)); + } + + #[test] + fn env_overrides_file_config() { + let cfg = resolve( + Some( + r#"{ + "browser": { + "carrier": "automation-window", + "groupName": "file", + "groupIdleTimeoutSeconds": 10 + } +}"#, + ), + &[ + ("AUTOCLI_BROWSER_CARRIER", "tab-group-background"), + ("AUTOCLI_BROWSER_GROUP_NAME", "env"), + ("AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT", "20"), + ], + ) + .unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("env")); + assert_eq!(cfg.group_idle_timeout_seconds, Some(20)); + } + + #[test] + fn command_options_override_env_and_file_config() { + let options = BrowserOptions { + carrier: Some("tab-group-background".to_string()), + group_name: Some("adapter".to_string()), + group_idle_timeout_seconds: Some(30), + }; + let cfg = resolve_with_options( + Some( + r#"{ + "browser": { + "carrier": "automation-window", + "groupName": "file", + "groupIdleTimeoutSeconds": 10 + } +}"#, + ), + &[ + ("AUTOCLI_BROWSER_CARRIER", "automation-window"), + ("AUTOCLI_BROWSER_GROUP_NAME", "env"), + ("AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT", "20"), + ], + Some(&options), + ) + .unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("adapter")); + assert_eq!(cfg.group_idle_timeout_seconds, Some(30)); + } + + #[test] + fn tab_group_mode_defaults_missing_command_group_name() { + let options = BrowserOptions { + carrier: Some("tab-group-background".to_string()), + group_name: None, + group_idle_timeout_seconds: None, + }; + let cfg = resolve_with_options(None, &[], Some(&options)).unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("default")); + } + + #[test] + fn tab_group_mode_defaults_empty_group_name() { + let cfg = resolve( + Some(r#"{ "browser": { "carrier": "tab-group-background", "groupName": " " } }"#), + &[], + ) + .unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("default")); + } + + #[test] + fn tab_group_mode_defaults_missing_file_group_name() { + let cfg = resolve( + Some(r#"{ "browser": { "carrier": "tab-group-background" } }"#), + &[], + ) + .unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("default")); + } + + #[test] + fn rejects_unknown_carrier_without_fallback() { + let err = resolve( + Some(r#"{ "browser": { "carrier": "hidden-window" } }"#), + &[], + ) + .unwrap_err(); + + assert_eq!(err.code(), "CONFIG"); + assert!(err.to_string().contains("carrier")); + } + + #[test] + fn rejects_invalid_idle_timeout_without_fallback() { + let err = resolve( + None, + &[ + ("AUTOCLI_BROWSER_CARRIER", "tab-group-background"), + ("AUTOCLI_BROWSER_GROUP_NAME", "work"), + ("AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT", "0"), + ], + ) + .unwrap_err(); + + assert_eq!(err.code(), "CONFIG"); + assert!(err.to_string().contains("timeout")); + } + + #[test] + fn rejects_non_positive_numeric_idle_timeout_without_fallback() { + let err = resolve( + Some( + r#"{ "browser": { "carrier": "tab-group-background", "groupName": "work", "groupIdleTimeoutSeconds": -1 } }"#, + ), + &[], + ) + .unwrap_err(); + + assert_eq!(err.code(), "CONFIG"); + assert!(err.to_string().contains("timeout")); + } +} diff --git a/crates/autocli-browser/src/daemon_client.rs b/crates/autocli-browser/src/daemon_client.rs index be82789..484e017 100644 --- a/crates/autocli-browser/src/daemon_client.rs +++ b/crates/autocli-browser/src/daemon_client.rs @@ -14,6 +14,25 @@ pub struct DaemonClient { /// Retry delays for exponential backoff. const RETRY_DELAYS_MS: [u64; 4] = [200, 500, 1000, 2000]; +/// Preserve structured extension failures returned with HTTP 422. +/// +/// The daemon uses 422 for command failures, even though the JSON body still +/// follows the normal `DaemonResult` envelope. Callers rely on this error text +/// to recognize strict-background stale-tab failures and retry with a fresh tab. +fn daemon_failure_from_unprocessable_entity(body: &str) -> Option { + let daemon_result = serde_json::from_str::(body).ok()?; + if daemon_result.ok { + return None; + } + + let err_msg = daemon_result + .error + .unwrap_or_else(|| "Unknown daemon error".into()); + Some(CliError::command_execution(format!( + "Daemon command failed: {err_msg}" + ))) +} + impl DaemonClient { /// Create a new client pointing at the given port on localhost. pub fn new(port: u16) -> Self { @@ -51,7 +70,9 @@ impl DaemonClient { if status.is_success() { let daemon_result: DaemonResult = resp.json().await.map_err(|e| { - CliError::browser_connect(format!("Failed to parse daemon response: {e}")) + CliError::browser_connect(format!( + "Failed to parse daemon response: {e}" + )) })?; if daemon_result.ok { return Ok(daemon_result.data.unwrap_or(Value::Null)); @@ -68,6 +89,11 @@ impl DaemonClient { // 4xx errors are client/business errors — don't retry if status.is_client_error() { let body = resp.text().await.unwrap_or_default(); + if status.as_u16() == 422 { + if let Some(error) = daemon_failure_from_unprocessable_entity(&body) { + return Err(error); + } + } return Err(CliError::command_execution(format!( "Command error (HTTP {status}): {body}" ))); @@ -163,4 +189,26 @@ mod tests { let client = DaemonClient::new(19999); assert!(!client.is_extension_connected().await); } + + #[test] + fn parses_structured_422_daemon_failure() { + let error = daemon_failure_from_unprocessable_entity( + r#"{"id":"request-1","ok":false,"error":"strict-background-stale-tab: tab 42 is no longer available"}"#, + ) + .expect("structured daemon failure should parse"); + + assert_eq!( + error.to_string(), + "[command] Daemon command failed: strict-background-stale-tab: tab 42 is no longer available" + ); + } + + #[test] + fn leaves_non_failure_or_non_daemon_422_bodies_for_generic_handling() { + assert!(daemon_failure_from_unprocessable_entity("forbidden").is_none()); + assert!(daemon_failure_from_unprocessable_entity( + r#"{"id":"request-1","ok":true,"data":{}}"# + ) + .is_none()); + } } diff --git a/crates/autocli-browser/src/lib.rs b/crates/autocli-browser/src/lib.rs index 004ac3b..c5dc445 100644 --- a/crates/autocli-browser/src/lib.rs +++ b/crates/autocli-browser/src/lib.rs @@ -1,18 +1,20 @@ // Architecture and protocol design derived from OpenCLI // (https://github.com/jackwener/opencli) by jackwener, Apache-2.0 -pub mod types; +pub mod bridge; +pub mod cdp; +pub mod config; +pub mod daemon; pub mod daemon_client; -pub mod page; pub mod dom_helpers; +pub mod page; pub mod stealth; -pub mod daemon; -pub mod bridge; -pub mod cdp; +pub mod types; pub use bridge::BrowserBridge; -pub use page::DaemonPage; pub use cdp::CdpPage; +pub use config::{BrowserCarrier, BrowserConfig}; pub use daemon::Daemon; pub use daemon_client::DaemonClient; +pub use page::DaemonPage; pub use types::{DaemonCommand, DaemonResult, ReadArticle}; diff --git a/crates/autocli-browser/src/page.rs b/crates/autocli-browser/src/page.rs index 84053c4..2152722 100644 --- a/crates/autocli-browser/src/page.rs +++ b/crates/autocli-browser/src/page.rs @@ -7,6 +7,7 @@ use serde_json::Value; use std::sync::Arc; use tokio::sync::RwLock; +use crate::config::{BrowserCarrier, BrowserConfig}; use crate::daemon_client::DaemonClient; use crate::dom_helpers; use crate::types::{DaemonCommand, ReadArticle}; @@ -15,6 +16,7 @@ use crate::types::{DaemonCommand, ReadArticle}; pub struct DaemonPage { client: Arc, workspace: String, + browser_config: BrowserConfig, tab_id: RwLock>, } @@ -23,6 +25,20 @@ impl DaemonPage { Self { client, workspace: workspace.into(), + browser_config: BrowserConfig::default(), + tab_id: RwLock::new(None), + } + } + + pub fn new_with_browser_config( + client: Arc, + workspace: impl Into, + browser_config: BrowserConfig, + ) -> Self { + Self { + client, + workspace: workspace.into(), + browser_config, tab_id: RwLock::new(None), } } @@ -30,6 +46,18 @@ impl DaemonPage { /// Build a command with workspace and optional tab_id pre-filled. async fn cmd(&self, action: &str) -> DaemonCommand { let mut c = DaemonCommand::new(action).with_workspace(self.workspace.clone()); + c = c.with_carrier(self.browser_config.carrier.as_str()); + if matches!( + self.browser_config.carrier, + BrowserCarrier::TabGroupBackground + ) { + if let Some(group_name) = &self.browser_config.group_name { + c = c.with_group_name(group_name.clone()); + } + if let Some(timeout) = self.browser_config.group_idle_timeout_seconds { + c = c.with_group_idle_timeout_seconds(timeout); + } + } if let Some(tid) = *self.tab_id.read().await { c = c.with_tab_id(tid); } @@ -38,7 +66,51 @@ impl DaemonPage { /// Send a command via the daemon client. async fn send(&self, cmd: DaemonCommand) -> Result { - self.client.send_command(cmd).await + match self.client.send_command(cmd.clone()).await { + Ok(response) => Ok(response), + Err(error) if self.should_retry_stale_tab(&cmd, &error).await => { + let mut retry = cmd; + retry.id = uuid::Uuid::new_v4().to_string(); + retry.tab_id = None; + self.client.send_command(retry).await + } + Err(error) => Err(error), + } + } + + /// Returns true after atomically dropping the cached tab only when `cmd` + /// still points at that cache entry. A stale-tab retry must not discard a + /// newer tab selected by another operation on the same page. + async fn should_retry_stale_tab(&self, cmd: &DaemonCommand, error: &CliError) -> bool { + if !matches!(cmd.action.as_str(), "navigate" | "read-article") + || self.browser_config.carrier != BrowserCarrier::TabGroupBackground + || !is_strict_background_stale_tab_error(error) + { + return false; + } + + let Some(tab_id) = cmd.tab_id else { + return false; + }; + + let mut cached_tab_id = self.tab_id.write().await; + if *cached_tab_id != Some(tab_id) { + return false; + } + + *cached_tab_id = None; + true + } + + fn navigate_tab_id(response: &Value) -> Result { + response + .get("tabId") + .and_then(Value::as_u64) + .ok_or_else(|| { + CliError::command_execution( + "Navigate response did not include a tabId; refusing to use an implicit tab", + ) + }) } /// Evaluate JS on the current page via the daemon. @@ -54,9 +126,8 @@ impl DaemonPage { pub async fn read_article(&self, url: &str) -> Result { let cmd = self.cmd("read-article").await.with_url(url); let val = self.send(cmd).await?; - serde_json::from_value::(val).map_err(|e| { - CliError::argument(format!("Failed to parse article payload: {e}")) - }) + serde_json::from_value::(val) + .map_err(|e| CliError::argument(format!("Failed to parse article payload: {e}"))) } } @@ -64,7 +135,8 @@ impl DaemonPage { impl IPage for DaemonPage { async fn goto(&self, url: &str, _options: Option) -> Result<(), CliError> { let cmd = self.cmd("navigate").await.with_url(url); - self.send(cmd).await?; + let response = self.send(cmd).await?; + *self.tab_id.write().await = Some(Self::navigate_tab_id(&response)?); // The Chrome extension's handleNavigate already waits for the page to // fully load (URL change + status=complete, up to 15s). No additional // DOM stability check is needed here. @@ -164,8 +236,7 @@ impl IPage for DaemonPage { async fn snapshot(&self, options: Option) -> Result { let opts = options.unwrap_or_default(); - let js = - dom_helpers::snapshot_js(opts.selector.as_deref(), opts.include_hidden); + let js = dom_helpers::snapshot_js(opts.selector.as_deref(), opts.include_hidden); self.eval_js(&js).await } @@ -195,21 +266,30 @@ impl IPage for DaemonPage { } async fn tabs(&self) -> Result, CliError> { - let cmd = self.cmd("tabs").await; + let cmd = self.cmd("tabs").await.with_op("list"); let val = self.send(cmd).await?; - let tabs: Vec = serde_json::from_value(val).unwrap_or_default(); - Ok(tabs) + Ok(parse_daemon_tabs(&val)) } async fn switch_tab(&self, tab_id: &str) -> Result<(), CliError> { let tid: u64 = tab_id .parse() .map_err(|_| CliError::argument(format!("Invalid tab id: {tab_id}")))?; - *self.tab_id.write().await = Some(tid); - let mut cmd = self.cmd("tabs").await; - cmd.url = Some("switch".to_string()); - cmd.tab_id = Some(tid); + + if switches_tab_locally(&self.browser_config.carrier) { + let tabs = self.tabs().await?; + if !contains_tab_id(&tabs, tid) { + return Err(CliError::command_execution(format!( + "Tab {tid} is not available in the current strict-background workspace" + ))); + } + *self.tab_id.write().await = Some(tid); + return Ok(()); + } + + let cmd = self.cmd("tabs").await.with_op("select").with_tab_id(tid); self.send(cmd).await?; + *self.tab_id.write().await = Some(tid); Ok(()) } @@ -240,6 +320,169 @@ impl IPage for DaemonPage { } } +const STRICT_BACKGROUND_STALE_TAB_ERROR: &str = "strict-background-stale-tab:"; + +fn switches_tab_locally(carrier: &BrowserCarrier) -> bool { + *carrier == BrowserCarrier::TabGroupBackground +} + +fn contains_tab_id(tabs: &[TabInfo], tab_id: u64) -> bool { + tabs.iter().any(|tab| tab.id == tab_id.to_string()) +} + +fn parse_daemon_tabs(value: &Value) -> Vec { + let Some(tabs) = value.as_array() else { + return Vec::new(); + }; + + tabs.iter() + .filter_map(|tab| { + let id = tab + .get("tabId") + .or_else(|| tab.get("id")) + .and_then(|id| match id { + Value::String(id) => Some(id.clone()), + Value::Number(id) => Some(id.to_string()), + _ => None, + })?; + Some(TabInfo { + id, + url: tab + .get("url") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + title: tab + .get("title") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + }) + }) + .collect() +} + +/// The daemon wraps extension errors before returning them to the browser +/// client. Only the explicit stale-tab marker permits a retry; errors about a +/// tab owned by another workspace and all unrelated failures remain terminal. +fn is_strict_background_stale_tab_error(error: &CliError) -> bool { + let CliError::CommandExecution { message, .. } = error else { + return false; + }; + + message.starts_with(STRICT_BACKGROUND_STALE_TAB_ERROR) + || message + .strip_prefix("Daemon command failed: ") + .is_some_and(|message| message.starts_with(STRICT_BACKGROUND_STALE_TAB_ERROR)) +} + +#[cfg(test)] +mod tests { + use super::{ + contains_tab_id, is_strict_background_stale_tab_error, parse_daemon_tabs, + switches_tab_locally, DaemonPage, + }; + use autocli_core::CliError; + use serde_json::json; + use std::sync::Arc; + + use crate::{ + config::{BrowserCarrier, BrowserConfig}, + daemon_client::DaemonClient, + types::DaemonCommand, + }; + + #[test] + fn navigate_tab_id_reads_the_extension_response() { + assert_eq!( + DaemonPage::navigate_tab_id(&json!({ "tabId": 42 })).unwrap(), + 42 + ); + } + + #[test] + fn navigate_tab_id_rejects_an_implicit_target() { + assert!(DaemonPage::navigate_tab_id(&json!({ "url": "https://example.com" })).is_err()); + } + + #[test] + fn recognizes_only_the_explicit_strict_background_stale_tab_error() { + let stale = CliError::command_execution( + "Daemon command failed: strict-background-stale-tab: tab 42 is no longer available", + ); + let foreign_owner = CliError::command_execution( + "Daemon command failed: strict-background-foreign-tab: tab 42 belongs to another workspace", + ); + let unrelated = CliError::command_execution("Daemon command failed: navigation timed out"); + + assert!(is_strict_background_stale_tab_error(&stale)); + assert!(!is_strict_background_stale_tab_error(&foreign_owner)); + assert!(!is_strict_background_stale_tab_error(&unrelated)); + } + + #[test] + fn does_not_classify_non_command_errors_as_stale_tab_errors() { + let error = CliError::browser_connect("strict-background-stale-tab: disconnected"); + assert!(!is_strict_background_stale_tab_error(&error)); + } + + #[test] + fn strict_background_switches_tabs_only_in_local_page_state() { + assert!(switches_tab_locally(&BrowserCarrier::TabGroupBackground)); + assert!(!switches_tab_locally(&BrowserCarrier::AutomationWindow)); + } + + #[test] + fn parses_extension_tab_list_and_validates_tab_ownership() { + let tabs = parse_daemon_tabs(&json!([ + { "tabId": 42, "url": "https://example.com", "title": "Example" }, + { "tabId": 84, "url": "https://example.org" } + ])); + + assert_eq!(tabs.len(), 2); + assert_eq!(tabs[0].id, "42"); + assert!(contains_tab_id(&tabs, 42)); + assert!(!contains_tab_id(&tabs, 7)); + } + + #[tokio::test] + async fn stale_tab_retry_requires_the_current_cached_tab_and_a_retryable_action() { + let page = DaemonPage::new_with_browser_config( + Arc::new(DaemonClient::new(0)), + "workspace", + BrowserConfig { + carrier: BrowserCarrier::TabGroupBackground, + group_name: Some("work".to_string()), + group_idle_timeout_seconds: None, + }, + ); + let stale = CliError::command_execution( + "Daemon command failed: strict-background-stale-tab: tab 42 is no longer available", + ); + *page.tab_id.write().await = Some(42); + + assert!( + page.should_retry_stale_tab(&DaemonCommand::new("navigate").with_tab_id(42), &stale) + .await + ); + assert_eq!(*page.tab_id.read().await, None); + + *page.tab_id.write().await = Some(42); + assert!( + !page + .should_retry_stale_tab(&DaemonCommand::new("exec").with_tab_id(42), &stale) + .await + ); + assert_eq!(*page.tab_id.read().await, Some(42)); + + assert!( + !page + .should_retry_stale_tab(&DaemonCommand::new("navigate").with_tab_id(7), &stale) + .await + ); + assert_eq!(*page.tab_id.read().await, Some(42)); + } +} + /// Simple base64 decoder (avoiding an extra dependency). Public for reuse by cdp module. pub(crate) fn base64_decode_simple(input: &str) -> Vec { const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -256,7 +499,10 @@ pub(crate) fn base64_decode_simple(input: &str) -> Vec { } let _ = TABLE; // suppress unused warning - let bytes: Vec = input.bytes().filter(|&b| b != b'=' && b != b'\n' && b != b'\r').collect(); + let bytes: Vec = input + .bytes() + .filter(|&b| b != b'=' && b != b'\n' && b != b'\r') + .collect(); let mut out = Vec::with_capacity(bytes.len() * 3 / 4); for chunk in bytes.chunks(4) { let n = chunk.len(); diff --git a/crates/autocli-browser/src/types.rs b/crates/autocli-browser/src/types.rs index 996722e..f696f16 100644 --- a/crates/autocli-browser/src/types.rs +++ b/crates/autocli-browser/src/types.rs @@ -10,11 +10,22 @@ pub struct DaemonCommand { #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub workspace: Option, + pub op: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub workspace: Option, + #[serde(rename = "tabId", skip_serializing_if = "Option::is_none")] pub tab_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub carrier: Option, + #[serde(rename = "groupName", skip_serializing_if = "Option::is_none")] + pub group_name: Option, + #[serde( + rename = "groupIdleTimeoutSeconds", + skip_serializing_if = "Option::is_none" + )] + pub group_idle_timeout_seconds: Option, } impl DaemonCommand { @@ -24,9 +35,13 @@ impl DaemonCommand { action: action.into(), code: None, url: None, + op: None, workspace: None, tab_id: None, format: None, + carrier: None, + group_name: None, + group_idle_timeout_seconds: None, } } @@ -40,6 +55,11 @@ impl DaemonCommand { self } + pub fn with_op(mut self, op: impl Into) -> Self { + self.op = Some(op.into()); + self + } + pub fn with_workspace(mut self, workspace: impl Into) -> Self { self.workspace = Some(workspace.into()); self @@ -54,6 +74,21 @@ impl DaemonCommand { self.format = Some(format.into()); self } + + pub fn with_carrier(mut self, carrier: impl Into) -> Self { + self.carrier = Some(carrier.into()); + self + } + + pub fn with_group_name(mut self, group_name: impl Into) -> Self { + self.group_name = Some(group_name.into()); + self + } + + pub fn with_group_idle_timeout_seconds(mut self, seconds: u64) -> Self { + self.group_idle_timeout_seconds = Some(seconds); + self + } } /// Article payload returned by the extension's read-article action. @@ -120,3 +155,35 @@ impl DaemonResult { } } } + +#[cfg(test)] +mod tests { + use super::DaemonCommand; + + #[test] + fn daemon_command_serializes_and_deserializes_extension_fields() { + let command = DaemonCommand::new("tabs").with_op("new").with_tab_id(42); + let value = serde_json::to_value(&command).expect("command should serialize"); + + assert_eq!( + value.get("op").and_then(serde_json::Value::as_str), + Some("new") + ); + assert_eq!( + value.get("tabId").and_then(serde_json::Value::as_u64), + Some(42) + ); + assert!(value.get("tab_id").is_none()); + + let decoded: DaemonCommand = serde_json::from_value(serde_json::json!({ + "id": "request-1", + "action": "tabs", + "op": "select", + "tabId": 42, + })) + .expect("camelCase tabId should deserialize"); + + assert_eq!(decoded.tab_id, Some(42)); + assert_eq!(decoded.op.as_deref(), Some("select")); + } +} diff --git a/crates/autocli-cli/src/execution.rs b/crates/autocli-cli/src/execution.rs index e326cfe..e8e0a72 100644 --- a/crates/autocli-cli/src/execution.rs +++ b/crates/autocli-cli/src/execution.rs @@ -1,9 +1,9 @@ +use autocli_browser::BrowserBridge; use autocli_core::{CliCommand, CliError, IPage}; use autocli_pipeline::{execute_pipeline, steps::register_all_steps, StepRegistry}; -use autocli_browser::BrowserBridge; use serde_json::Value; -use std::sync::Arc; use std::collections::HashMap; +use std::sync::Arc; /// Get daemon port from env or default fn daemon_port() -> u16 { @@ -56,12 +56,15 @@ async fn execute_command_inner( if cmd.needs_browser() { // Browser session - let mut bridge = BrowserBridge::new(daemon_port()); + let mut bridge = + BrowserBridge::new(daemon_port()).with_browser_options(cmd.browser_options.clone()); let page = bridge.connect().await?; // Pre-navigate to domain if set, but ONLY if the pipeline doesn't // start with its own navigate step (to avoid double navigation). - let pipeline_starts_with_navigate = cmd.pipeline.as_ref() + let pipeline_starts_with_navigate = cmd + .pipeline + .as_ref() .and_then(|steps| steps.first()) .and_then(|step| step.as_object()) .map_or(false, |obj| obj.contains_key("navigate")); @@ -95,7 +98,6 @@ async fn execute_command_inner( } } - async fn run_command( cmd: &CliCommand, page: Option>, diff --git a/crates/autocli-core/src/command.rs b/crates/autocli-core/src/command.rs index 58bc44f..930fac2 100644 --- a/crates/autocli-core/src/command.rs +++ b/crates/autocli-core/src/command.rs @@ -30,6 +30,13 @@ impl Default for NavigateBefore { } } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserOptions { + pub carrier: Option, + pub group_name: Option, + pub group_idle_timeout_seconds: Option, +} + #[derive(Clone)] pub struct CliCommand { pub site: String, @@ -38,6 +45,7 @@ pub struct CliCommand { pub domain: Option, pub strategy: Strategy, pub browser: bool, + pub browser_options: Option, pub args: Vec, pub columns: Vec, pub pipeline: Option>, @@ -58,8 +66,16 @@ impl CliCommand { // Check if pipeline contains browser steps if let Some(ref pipeline) = self.pipeline { const BROWSER_STEPS: &[&str] = &[ - "navigate", "click", "type", "wait", "press", - "evaluate", "snapshot", "screenshot", "intercept", "tap", + "navigate", + "click", + "type", + "wait", + "press", + "evaluate", + "snapshot", + "screenshot", + "intercept", + "tap", ]; for step in pipeline { if let Some(obj) = step.as_object() { diff --git a/crates/autocli-core/src/lib.rs b/crates/autocli-core/src/lib.rs index f07697f..7d97428 100644 --- a/crates/autocli-core/src/lib.rs +++ b/crates/autocli-core/src/lib.rs @@ -1,21 +1,21 @@ // Architecture and protocol design derived from OpenCLI // (https://github.com/jackwener/opencli) by jackwener, Apache-2.0 -mod strategy; mod args; mod command; -mod registry; mod error; mod page; +mod registry; +mod strategy; mod value_ext; -pub use strategy::Strategy; pub use args::{ArgDef, ArgType}; -pub use command::{AdapterFunc, CliCommand, CommandArgs, NavigateBefore}; -pub use registry::Registry; +pub use command::{AdapterFunc, BrowserOptions, CliCommand, CommandArgs, NavigateBefore}; pub use error::CliError; pub use page::{ AutoScrollOptions, Cookie, CookieOptions, GotoOptions, IPage, InterceptedRequest, NetworkRequest, ScreenshotOptions, ScrollDirection, SnapshotOptions, TabInfo, WaitOptions, }; +pub use registry::Registry; +pub use strategy::Strategy; pub use value_ext::ValueExt; diff --git a/crates/autocli-core/src/registry.rs b/crates/autocli-core/src/registry.rs index 6d9c6b9..28b65d8 100644 --- a/crates/autocli-core/src/registry.rs +++ b/crates/autocli-core/src/registry.rs @@ -40,8 +40,7 @@ impl Registry { } pub fn all_commands(&self) -> Vec<&CliCommand> { - let mut cmds: Vec<&CliCommand> = - self.commands.values().flat_map(|s| s.values()).collect(); + let mut cmds: Vec<&CliCommand> = self.commands.values().flat_map(|s| s.values()).collect(); cmds.sort_by(|a, b| (&a.site, &a.name).cmp(&(&b.site, &b.name))); cmds } @@ -68,6 +67,7 @@ mod tests { domain: None, strategy: Strategy::Public, browser: false, + browser_options: None, args: vec![], columns: vec![], pipeline: None, diff --git a/crates/autocli-discovery/src/yaml_parser.rs b/crates/autocli-discovery/src/yaml_parser.rs index c71eacb..7bd9765 100644 --- a/crates/autocli-discovery/src/yaml_parser.rs +++ b/crates/autocli-discovery/src/yaml_parser.rs @@ -1,4 +1,6 @@ -use autocli_core::{ArgDef, ArgType, CliCommand, CliError, NavigateBefore, Strategy}; +use autocli_core::{ + ArgDef, ArgType, BrowserOptions, CliCommand, CliError, NavigateBefore, Strategy, +}; use serde_json::Value; /// Parse a YAML adapter file content into a CliCommand. @@ -31,9 +33,7 @@ pub fn parse_yaml_adapter(content: &str) -> Result { // Parse strategy (default: public) let strategy = match raw.get("strategy").and_then(|v| v.as_str()) { - Some(s) => { - serde_json::from_value(Value::String(s.to_string())).unwrap_or(Strategy::Public) - } + Some(s) => serde_json::from_value(Value::String(s.to_string())).unwrap_or(Strategy::Public), None => Strategy::Public, }; @@ -52,10 +52,9 @@ pub fn parse_yaml_adapter(content: &str) -> Result { .unwrap_or_default(); // Pipeline is stored as-is (Vec) - let pipeline = raw - .get("pipeline") - .and_then(|v| v.as_array()) - .cloned(); + let pipeline = raw.get("pipeline").and_then(|v| v.as_array()).cloned(); + + let (browser, browser_options) = parse_browser_config(&raw, strategy.requires_browser())?; Ok(CliCommand { site, @@ -65,15 +64,10 @@ pub fn parse_yaml_adapter(content: &str) -> Result { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), - domain: raw - .get("domain") - .and_then(|v| v.as_str()) - .map(String::from), + domain: raw.get("domain").and_then(|v| v.as_str()).map(String::from), strategy, - browser: raw - .get("browser") - .and_then(|v| v.as_bool()) - .unwrap_or(strategy.requires_browser()), + browser, + browser_options, args, columns, pipeline, @@ -83,6 +77,76 @@ pub fn parse_yaml_adapter(content: &str) -> Result { }) } +fn parse_browser_config( + raw: &Value, + strategy_requires_browser: bool, +) -> Result<(bool, Option), CliError> { + let Some(browser) = raw.get("browser") else { + return Ok((strategy_requires_browser, None)); + }; + + if let Some(enabled) = browser.as_bool() { + return Ok((enabled, None)); + } + + let Some(obj) = browser.as_object() else { + return Err(CliError::AdapterLoad { + message: "browser must be a boolean or an object".into(), + suggestions: vec![], + source: None, + }); + }; + + let enabled = obj.get("enabled").and_then(Value::as_bool).unwrap_or(true); + if !enabled { + return Ok((false, None)); + } + + let group_idle_timeout_seconds = + match obj.get("groupIdleTimeoutSeconds") { + Some(Value::Number(n)) => { + Some(n.as_u64().filter(|value| *value > 0).ok_or_else(|| { + CliError::AdapterLoad { + message: "browser.groupIdleTimeoutSeconds must be a positive integer" + .into(), + suggestions: vec![], + source: None, + } + })?) + } + Some(Value::String(s)) => { + Some(s.trim().parse::().map_err(|_| CliError::AdapterLoad { + message: "browser.groupIdleTimeoutSeconds must be a positive integer".into(), + suggestions: vec![], + source: None, + })?) + } + Some(_) => { + return Err(CliError::AdapterLoad { + message: "browser.groupIdleTimeoutSeconds must be a positive integer".into(), + suggestions: vec![], + source: None, + }); + } + None => None, + }; + + Ok(( + true, + Some(BrowserOptions { + carrier: obj + .get("carrier") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + group_name: obj + .get("groupName") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + group_idle_timeout_seconds, + }), + )) +} + /// Parse args from YAML map format to Vec fn parse_args(raw: &Value) -> Result, CliError> { let args_val = match raw.get("args") { @@ -157,6 +221,7 @@ pipeline: assert_eq!(cmd.name, "top"); assert_eq!(cmd.strategy, Strategy::Public); assert!(!cmd.browser); + assert_eq!(cmd.browser_options, None); assert_eq!(cmd.args.len(), 1); assert_eq!(cmd.args[0].name, "limit"); assert_eq!(cmd.args[0].arg_type, ArgType::Int); @@ -180,6 +245,40 @@ domain: www.bilibili.com assert_eq!(cmd.domain, Some("www.bilibili.com".to_string())); } + #[test] + fn test_parse_browser_object_config() { + let yaml = r#" +site: example +name: read +description: Read with background group +strategy: public +browser: + carrier: tab-group-background + groupName: work + groupIdleTimeoutSeconds: 30 +"#; + let cmd = parse_yaml_adapter(yaml).unwrap(); + assert!(cmd.browser); + let options = cmd.browser_options.unwrap(); + assert_eq!(options.carrier.as_deref(), Some("tab-group-background")); + assert_eq!(options.group_name.as_deref(), Some("work")); + assert_eq!(options.group_idle_timeout_seconds, Some(30)); + } + + #[test] + fn test_browser_object_can_disable_browser() { + let yaml = r#" +site: example +name: public +description: Public command +browser: + enabled: false +"#; + let cmd = parse_yaml_adapter(yaml).unwrap(); + assert!(!cmd.browser); + assert_eq!(cmd.browser_options, None); + } + #[test] fn test_parse_missing_site_errors() { let yaml = "name: test\n"; diff --git a/extension/manifest.json b/extension/manifest.json index 236b161..aaa594e 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -9,7 +9,9 @@ "tabs", "cookies", "activeTab", - "alarms" + "alarms", + "storage", + "tabGroups" ], "host_permissions": [ "" diff --git a/extension/package-lock.json b/extension/package-lock.json index a511312..2e81adb 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,19 +1,20 @@ { "name": "autocli-extension", - "version": "1.5.5", + "version": "1.5.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "autocli-extension", - "version": "1.5.5", + "version": "1.5.6", "dependencies": { "@mozilla/readability": "^0.6.0" }, "devDependencies": { "@types/chrome": "^0.0.287", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.9" } }, "node_modules/@esbuild/aix-ppc64": { @@ -458,6 +459,13 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@mozilla/readability": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", @@ -817,6 +825,24 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/chrome": { "version": "0.0.287", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", @@ -828,6 +854,13 @@ "@types/har-format": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -859,6 +892,153 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -901,6 +1081,26 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -934,6 +1134,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -953,6 +1163,27 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1047,6 +1278,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1057,6 +1295,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1074,6 +1343,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1162,6 +1441,113 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/extension/package.json b/extension/package.json index 3dcbdfd..6d77e32 100644 --- a/extension/package.json +++ b/extension/package.json @@ -12,7 +12,8 @@ "devDependencies": { "@types/chrome": "^0.0.287", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.9" }, "dependencies": { "@mozilla/readability": "^0.6.0" diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index d05f083..a18f3fe 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -7,6 +7,7 @@ type Listener void> = { addListener: (fn: T) => vo type MockTab = { id: number; windowId: number; + groupId?: number; url?: string; title?: string; active?: boolean; @@ -31,14 +32,22 @@ class MockWebSocket { function createChromeMock() { let nextTabId = 10; + let nextGroupId = 100; + const groups: Array<{ id: number; title?: string; windowId: number; collapsed?: boolean; color?: string }> = []; + const alarmListeners: Array<(alarm: { name: string }) => void> = []; + const tabRemovedListeners: Array<(tabId: number) => void> = []; + const sessionStorage: Record = {}; const tabs: MockTab[] = [ { id: 1, windowId: 1, url: 'https://automation.example', title: 'automation', active: true, status: 'complete' }, { id: 2, windowId: 2, url: 'https://user.example', title: 'user', active: true, status: 'complete' }, { id: 3, windowId: 1, url: 'chrome://extensions', title: 'chrome', active: false, status: 'complete' }, ]; - const query = vi.fn(async (queryInfo: { windowId?: number } = {}) => { - return tabs.filter((tab) => queryInfo.windowId === undefined || tab.windowId === queryInfo.windowId); + const query = vi.fn(async (queryInfo: { windowId?: number; groupId?: number } = {}) => { + return tabs.filter((tab) => + (queryInfo.windowId === undefined || tab.windowId === queryInfo.windowId) && + (queryInfo.groupId === undefined || tab.groupId === queryInfo.groupId) + ); }); const create = vi.fn(async ({ windowId, url, active }: { windowId?: number; url?: string; active?: boolean }) => { const tab: MockTab = { @@ -52,6 +61,17 @@ function createChromeMock() { tabs.push(tab); return tab; }); + const group = vi.fn(async ({ groupId, tabIds }: { groupId?: number; tabIds: number[] }) => { + const first = tabs.find((entry) => entry.id === tabIds[0]); + if (!first) throw new Error('Cannot group unknown tab'); + const resolvedGroupId = groupId ?? nextGroupId++; + if (groupId === undefined) groups.push({ id: resolvedGroupId, windowId: first.windowId }); + for (const tabId of tabIds) { + const tab = tabs.find((entry) => entry.id === tabId); + if (tab) tab.groupId = resolvedGroupId; + } + return resolvedGroupId; + }); const update = vi.fn(async (tabId: number, updates: { active?: boolean; url?: string }) => { const tab = tabs.find((entry) => entry.id === tabId); if (!tab) throw new Error(`Unknown tab ${tabId}`); @@ -64,35 +84,118 @@ function createChromeMock() { tabs: { query, create, + group, update, - remove: vi.fn(async (_tabId: number) => {}), + remove: vi.fn(async (tabIdOrIds: number | number[]) => { + const ids = Array.isArray(tabIdOrIds) ? tabIdOrIds : [tabIdOrIds]; + for (const tabId of ids) { + const index = tabs.findIndex((entry) => entry.id === tabId); + if (index < 0) throw new Error(`Unknown tab ${tabId}`); + const [tab] = tabs.splice(index, 1); + tabRemovedListeners.forEach((listener) => listener(tabId)); + if (tab.groupId !== undefined && !tabs.some((entry) => entry.groupId === tab.groupId)) { + const groupIndex = groups.findIndex((entry) => entry.id === tab.groupId); + if (groupIndex >= 0) groups.splice(groupIndex, 1); + } + } + }), get: vi.fn(async (tabId: number) => { const tab = tabs.find((entry) => entry.id === tabId); if (!tab) throw new Error(`Unknown tab ${tabId}`); return tab; }), onUpdated: { addListener: vi.fn(), removeListener: vi.fn() } as Listener<(id: number, info: chrome.tabs.TabChangeInfo) => void>, + onRemoved: { + addListener: vi.fn((listener: (tabId: number) => void) => tabRemovedListeners.push(listener)), + removeListener: vi.fn(), + } as Listener<(tabId: number) => void>, }, windows: { get: vi.fn(async (windowId: number) => ({ id: windowId })), create: vi.fn(async ({ url, focused, width, height, type }: any) => ({ id: 1, url, focused, width, height, type })), + getLastFocused: vi.fn(async () => ({ id: 2, type: 'normal' })), + getAll: vi.fn(async () => [{ id: 2, type: 'normal' }]), remove: vi.fn(async (_windowId: number) => {}), onRemoved: { addListener: vi.fn() } as Listener<(windowId: number) => void>, }, + tabGroups: { + get: vi.fn(async (groupId: number) => { + const groupEntry = groups.find((entry) => entry.id === groupId); + if (!groupEntry) throw new Error(`Unknown group ${groupId}`); + return groupEntry; + }), + query: vi.fn(async ({ title }: { title?: string } = {}) => + groups.filter((entry) => title === undefined || entry.title === title) + ), + update: vi.fn(async (groupId: number, updates: { title?: string; collapsed?: boolean; color?: string }) => { + const groupEntry = groups.find((entry) => entry.id === groupId); + if (!groupEntry) throw new Error(`Unknown group ${groupId}`); + Object.assign(groupEntry, updates); + return groupEntry; + }), + }, alarms: { create: vi.fn(), - onAlarm: { addListener: vi.fn() } as Listener<(alarm: { name: string }) => void>, + clear: vi.fn(async () => true), + onAlarm: { + addListener: vi.fn((listener: (alarm: { name: string }) => void) => alarmListeners.push(listener)), + removeListener: vi.fn(), + } as Listener<(alarm: { name: string }) => void>, + }, + storage: { + session: { + get: vi.fn(async (keys?: string | string[] | Record) => { + if (keys === undefined) return { ...sessionStorage }; + const requested = typeof keys === 'string' ? [keys] : Array.isArray(keys) ? keys : Object.keys(keys); + return Object.fromEntries(requested.flatMap((key) => + Object.prototype.hasOwnProperty.call(sessionStorage, key) ? [[key, sessionStorage[key]]] : [] + )); + }), + set: vi.fn(async (items: Record) => { + Object.assign(sessionStorage, items); + }), + remove: vi.fn(async (keys: string | string[]) => { + for (const key of (Array.isArray(keys) ? keys : [keys])) delete sessionStorage[key]; + }), + }, }, runtime: { onInstalled: { addListener: vi.fn() } as Listener<() => void>, onStartup: { addListener: vi.fn() } as Listener<() => void>, + onMessage: { addListener: vi.fn() } as Listener<(msg: unknown, sender: unknown, sendResponse: (response?: unknown) => void) => void>, + onConnect: { addListener: vi.fn() } as Listener<(port: chrome.runtime.Port) => void>, + getManifest: vi.fn(() => ({ version: '1.5.6' })), + }, + action: { + onClicked: { addListener: vi.fn() } as Listener<(tab: chrome.tabs.Tab) => void>, + }, + scripting: { + executeScript: vi.fn(), }, cookies: { getAll: vi.fn(async () => []), }, }; - return { chrome, tabs, query, create, update }; + return { + chrome, + tabs, + groups, + query, + create, + group, + update, + sessionStorage, + emitAlarm: async (name: string) => { + for (const listener of [...alarmListeners]) listener({ name }); + await Promise.resolve(); + await Promise.resolve(); + }, + resetWorkerListeners: () => { + alarmListeners.length = 0; + tabRemovedListeners.length = 0; + }, + }; } describe('background tab isolation', () => { @@ -150,4 +253,1991 @@ describe('background tab isolation', () => { expect.objectContaining({ workspace: 'site:zhihu', windowId: 2 }), ])); }); + + it('creates strict-background tab group tabs without activating tabs or creating windows', async () => { + const { chrome, create, group } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + + const result = await mod.__test__.handleTabs({ + id: '4', + action: 'tabs', + op: 'new', + url: 'https://new.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + expect(result.ok).toBe(true); + expect(chrome.windows.create).not.toHaveBeenCalled(); + expect(create).toHaveBeenCalledWith({ windowId: 2, url: 'about:blank', active: false }); + expect(create).toHaveBeenCalledWith({ windowId: 2, url: 'https://new.example', active: false }); + expect(group).toHaveBeenCalled(); + expect(chrome.tabGroups.update).toHaveBeenCalledWith(expect.any(Number), expect.objectContaining({ + title: 'AutoCLI-work', + collapsed: true, + })); + }); + + it('rejects strict-background tab selection without activating any tab', async () => { + const { chrome, update } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleTabs({ + id: '5', + action: 'tabs', + op: 'select', + tabId: 2, + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + expect(result.ok).toBe(false); + expect(result.error).toContain('unsupported'); + expect(update).not.toHaveBeenCalledWith(expect.any(Number), expect.objectContaining({ active: true })); + }); + + it('does not navigate or list user tabs already present in a reused strict-background group', async () => { + const { chrome, tabs, groups, create, update } = createChromeMock(); + groups.push({ id: 123, title: 'AutoCLI-work', windowId: 2, collapsed: true, color: 'blue' }); + tabs.push({ + id: 9, + windowId: 2, + groupId: 123, + url: 'https://user-in-group.example', + title: 'user group tab', + active: true, + status: 'complete', + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const navigate = await mod.__test__.handleNavigate({ + id: '8', + action: 'navigate', + url: 'https://new.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + const list = await mod.__test__.handleTabs({ + id: '9', + action: 'tabs', + op: 'list', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + expect(navigate.ok).toBe(true); + expect(tabs.find((tab) => tab.id === 9)?.url).toBe('https://user-in-group.example'); + expect(create).toHaveBeenCalledWith({ windowId: 2, url: 'https://new.example', active: false }); + expect(update).not.toHaveBeenCalledWith(9, expect.anything()); + const listedTabs = list.data as Array<{ tabId: number }>; + expect(listedTabs).toHaveLength(1); + expect(listedTabs[0].tabId).not.toBe(9); + }); + + it('returns a clear error for invalid strict-background idle timeout', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + await mod.__test__.handleTabs({ + id: '10', + action: 'tabs', + op: 'new', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + const result = await mod.__test__.handleCommand({ + id: '11', + action: 'tabs', + op: 'list', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: -1, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('idle timeout'); + }); + + it('rejects malformed strict-background close-window without an existing session', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const missingGroup = await mod.__test__.handleCommand({ + id: '12', + action: 'close-window', + workspace: 'site:twitter', + carrier: 'tab-group-background', + }); + const invalidTimeout = await mod.__test__.handleCommand({ + id: '13', + action: 'close-window', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: -1, + }); + + expect(missingGroup.ok).toBe(false); + expect(missingGroup.error).toContain('group name'); + expect(invalidTimeout.ok).toBe(false); + expect(invalidTimeout.error).toContain('idle timeout'); + expect(chrome.windows.remove).not.toHaveBeenCalled(); + }); + + it('rejects unknown carriers instead of falling back to automation-window behavior', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleCommand({ + id: '14', + action: 'tabs', + op: 'new', + workspace: 'site:twitter', + carrier: 'hidden-window', + } as any); + + expect(result.ok).toBe(false); + expect(result.error).toContain('Unknown browser carrier'); + expect(chrome.windows.create).not.toHaveBeenCalled(); + }); + + it('releases strict-background sessions without removing the host window', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + await mod.__test__.handleTabs({ + id: '6', + action: 'tabs', + op: 'new', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + const result = await mod.__test__.handleCloseWindow({ + id: '7', + action: 'close-window', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ released: true }); + expect(chrome.windows.remove).not.toHaveBeenCalled(); + }); + + it('shares one strict-background tab group for concurrent commands with the same group name across workspaces', async () => { + vi.useFakeTimers(); + try { + const { chrome, tabs, groups, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const first = mod.__test__.handleTabs({ + id: '15', + action: 'tabs', + op: 'new', + url: 'https://first.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 1, + }, 'site:twitter'); + const second = mod.__test__.handleTabs({ + id: '16', + action: 'tabs', + op: 'new', + url: 'https://second.example', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 1, + }, 'site:zhihu'); + + const results = await Promise.all([first, second]); + + expect(results.every((result) => result.ok)).toBe(true); + expect(chrome.windows.create).not.toHaveBeenCalled(); + const autocliGroups = groups.filter((group) => group.title === 'AutoCLI-work'); + expect(autocliGroups).toHaveLength(1); + const groupTabs = tabs.filter((tab) => tab.groupId === autocliGroups[0].id); + expect(groupTabs.map((tab) => tab.url).sort()).toEqual([ + 'about:blank', + 'https://first.example', + 'https://second.example', + ]); + expect(Object.keys(sessionStorage)).toEqual([ + `autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`, + ]); + + const release = await mod.__test__.handleCloseWindow({ + id: '17', + action: 'close-window', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 1, + }, 'site:twitter'); + expect(release.ok).toBe(true); + + await vi.advanceTimersByTimeAsync(1000); + + expect(tabs.some((tab) => tab.groupId === autocliGroups[0].id)).toBe(false); + expect(chrome.windows.remove).not.toHaveBeenCalled(); + expect(sessionStorage).toEqual({}); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps implicit navigation tabs isolated by workspace inside one shared group', async () => { + const { chrome, tabs, groups, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const twitter = await mod.__test__.handleNavigate({ + id: 'workspace-nav-1', + action: 'navigate', + url: 'https://twitter-target.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + const zhihu = await mod.__test__.handleNavigate({ + id: 'workspace-nav-2', + action: 'navigate', + url: 'https://zhihu-target.example', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:zhihu'); + + expect(twitter.ok).toBe(true); + expect(zhihu.ok).toBe(true); + const twitterTabId = (twitter.data as { tabId: number }).tabId; + const zhihuTabId = (zhihu.data as { tabId: number }).tabId; + expect(twitterTabId).not.toBe(zhihuTabId); + expect(groups.filter((group) => group.title === 'AutoCLI-work')).toHaveLength(1); + expect(tabs.find((tab) => tab.id === twitterTabId)?.url).toBe('https://twitter-target.example'); + expect(tabs.find((tab) => tab.id === zhihuTabId)?.url).toBe('https://zhihu-target.example'); + + const twitterFollowUp = await mod.__test__.handleNavigate({ + id: 'workspace-nav-3', + action: 'navigate', + url: 'https://twitter-follow-up.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + expect(twitterFollowUp.ok).toBe(true); + expect((twitterFollowUp.data as { tabId: number }).tabId).toBe(twitterTabId); + expect(tabs.find((tab) => tab.id === twitterTabId)?.url).toBe('https://twitter-follow-up.example'); + expect(tabs.find((tab) => tab.id === zhihuTabId)?.url).toBe('https://zhihu-target.example'); + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + workspaceTabIds: Array<[string, number]>; + }; + expect(stored.workspaceTabIds).toEqual(expect.arrayContaining([ + ['site:twitter', twitterTabId], + ['site:zhihu', zhihuTabId], + ])); + }); + + it('isolates shared-group lists and refuses explicit foreign tabs', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + const mod = await import('./background'); + const base = { action: 'tabs' as const, carrier: 'tab-group-background' as const, groupName: 'work' }; + + const twitter = await mod.__test__.handleCommand({ + id: 'owner-twitter', ...base, op: 'new', url: 'https://twitter.example', workspace: 'site:twitter', + }); + const zhihu = await mod.__test__.handleCommand({ + id: 'owner-zhihu', ...base, op: 'new', url: 'https://zhihu.example', workspace: 'site:zhihu', + }); + const twitterTabId = (twitter.data as { tabId: number }).tabId; + const zhihuTabId = (zhihu.data as { tabId: number }).tabId; + + const twitterList = await mod.__test__.handleCommand({ + id: 'owner-list-twitter', ...base, op: 'list', workspace: 'site:twitter', + }); + const zhihuList = await mod.__test__.handleCommand({ + id: 'owner-list-zhihu', ...base, op: 'list', workspace: 'site:zhihu', + }); + expect((twitterList.data as Array<{ tabId: number }>).map((tab) => tab.tabId)).toContain(twitterTabId); + expect((twitterList.data as Array<{ tabId: number }>).map((tab) => tab.tabId)).not.toContain(zhihuTabId); + expect((zhihuList.data as Array<{ tabId: number }>).map((tab) => tab.tabId)).toEqual([zhihuTabId]); + + const foreignNavigate = await mod.__test__.handleCommand({ + id: 'owner-foreign-navigate', action: 'navigate', url: 'https://blocked.example', tabId: twitterTabId, + workspace: 'site:zhihu', carrier: 'tab-group-background', groupName: 'work', + }); + const foreignClose = await mod.__test__.handleCommand({ + id: 'owner-foreign-close', ...base, op: 'close', tabId: twitterTabId, workspace: 'site:zhihu', + }); + expect(foreignNavigate).toEqual(expect.objectContaining({ ok: false, error: expect.stringContaining('strict-background-tab-not-owned:') })); + expect(foreignClose).toEqual(expect.objectContaining({ ok: false, error: expect.stringContaining('strict-background-tab-not-owned:') })); + }); + + it('does not create a strict-background group for list or close without an owned tab', async () => { + const { chrome, groups, create } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + const mod = await import('./background'); + const base = { + action: 'tabs' as const, carrier: 'tab-group-background' as const, groupName: 'work', workspace: 'site:twitter', + }; + + const listed = await mod.__test__.handleCommand({ id: 'empty-list', ...base, op: 'list' }); + const closed = await mod.__test__.handleCommand({ id: 'empty-close', ...base, op: 'close' }); + expect(listed).toEqual(expect.objectContaining({ ok: true, data: [] })); + expect(closed).toEqual(expect.objectContaining({ ok: false, error: expect.stringContaining('No strict-background tab') })); + expect(groups).toEqual([]); + expect(create).not.toHaveBeenCalled(); + }); + + it('keeps multiple tabs of one workspace listable and closable by that workspace only', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + const mod = await import('./background'); + const base = { action: 'tabs' as const, carrier: 'tab-group-background' as const, groupName: 'work', workspace: 'site:twitter' }; + const first = await mod.__test__.handleCommand({ id: 'multi-first', ...base, op: 'new', url: 'https://one.example' }); + const second = await mod.__test__.handleCommand({ id: 'multi-second', ...base, op: 'new', url: 'https://two.example' }); + const firstTabId = (first.data as { tabId: number }).tabId; + const secondTabId = (second.data as { tabId: number }).tabId; + const listed = await mod.__test__.handleCommand({ id: 'multi-list', ...base, op: 'list' }); + const listedIds = (listed.data as Array<{ tabId: number }>).map((tab) => tab.tabId); + expect(listedIds).toEqual(expect.arrayContaining([firstTabId, secondTabId])); + + const index = listedIds.indexOf(firstTabId); + const closed = await mod.__test__.handleCommand({ id: 'multi-close', ...base, op: 'close', index }); + expect(closed).toEqual(expect.objectContaining({ ok: true, data: { closed: firstTabId } })); + const afterClose = await mod.__test__.handleCommand({ id: 'multi-list-after', ...base, op: 'list' }); + expect((afterClose.data as Array<{ tabId: number }>).map((tab) => tab.tabId)).toContain(secondTabId); + expect((afterClose.data as Array<{ tabId: number }>).map((tab) => tab.tabId)).not.toContain(firstTabId); + }); + + it('returns a retryable stale-tab error and prunes its ownership ledger', async () => { + const { chrome, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + const mod = await import('./background'); + const created = await mod.__test__.handleCommand({ + id: 'stale-create', action: 'tabs', op: 'new', url: 'https://stale.example', workspace: 'site:twitter', + carrier: 'tab-group-background', groupName: 'work', + }); + const tabId = (created.data as { tabId: number }).tabId; + await chrome.tabs.remove(tabId); + + const stale = await mod.__test__.handleCommand({ + id: 'stale-use', action: 'navigate', url: 'https://retry.example', tabId, workspace: 'site:twitter', + carrier: 'tab-group-background', groupName: 'work', + }); + expect(stale).toEqual(expect.objectContaining({ ok: false, error: expect.stringContaining('strict-background-stale-tab:') })); + await vi.waitFor(() => { + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + ownedTabIds: number[]; tabWorkspaceOwners: Array<[number, string]>; + }; + expect(stored.ownedTabIds).not.toContain(tabId); + expect(stored.tabWorkspaceOwners).not.toContainEqual([tabId, 'site:twitter']); + }); + }); + + it('prunes stale owned tab IDs when a later command uses the same group from another workspace', async () => { + const { chrome, tabs, groups, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleTabs({ + id: '27', + action: 'tabs', + op: 'new', + url: 'https://live.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + expect(created.ok).toBe(true); + + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + const staleTabIndex = tabs.findIndex((tab) => tab.groupId === groupId && tab.url === 'about:blank'); + const liveTabId = (created.data as { tabId: number }).tabId; + expect(staleTabIndex).toBeGreaterThanOrEqual(0); + tabs.splice(staleTabIndex, 1); + + const listed = await mod.__test__.handleTabs({ + id: '28', + action: 'tabs', + op: 'list', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:zhihu'); + + expect(listed.ok).toBe(true); + // A different workspace shares the physical group but must not see the + // remaining AutoCLI tab owned by site:twitter. + expect(listed.data).toEqual([]); + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + ownedTabIds: number[]; + }; + expect(stored.ownedTabIds).toEqual([liveTabId]); + }); + + it('keeps ownership metadata when a live tab query is temporarily unavailable', async () => { + const { chrome, tabs, groups, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleTabs({ + id: '29', + action: 'tabs', + op: 'new', + url: 'https://temporary-failure.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + expect(created.ok).toBe(true); + + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + const ownedBeforeFailure = tabs.filter((tab) => tab.groupId === groupId).map((tab) => tab.id).sort(); + chrome.tabs.query.mockRejectedValueOnce(new Error('temporary tabs query failure')); + + const listed = await mod.__test__.handleTabs({ + id: '30', + action: 'tabs', + op: 'list', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:zhihu'); + + expect(listed.ok).toBe(true); + expect(groups.filter((group) => group.title === 'AutoCLI-work')).toHaveLength(1); + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + ownedTabIds: number[]; + }; + expect([...stored.ownedTabIds].sort()).toEqual(ownedBeforeFailure); + }); + + it('expires owned tabs independently without extending sibling tabs in the same group', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const startedAt = 1_000_000; + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(startedAt); + try { + const mod = await import('./background'); + const first = await mod.__test__.handleCommand({ + id: 'per-tab-1', + action: 'tabs', + op: 'new', + url: 'https://first.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 5, + }); + expect(first.ok).toBe(true); + const firstTabId = (first.data as { tabId: number }).tabId; + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + expect(groupId).toBeDefined(); + tabs.push({ id: 99, windowId: 2, groupId, url: 'https://user.example', title: 'user', active: false }); + + dateNow.mockReturnValue(startedAt + 1_000); + const second = await mod.__test__.handleCommand({ + id: 'per-tab-2', + action: 'tabs', + op: 'new', + url: 'https://second.example', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 5, + }); + expect(second.ok).toBe(true); + const secondTabId = (second.data as { tabId: number }).tabId; + + dateNow.mockReturnValue(startedAt + 2_000); + const listed = await mod.__test__.handleCommand({ + id: 'per-tab-list', + action: 'tabs', + op: 'list', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 5, + }); + expect(listed.ok).toBe(true); + + const storageKey = `autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`; + const beforeExpiry = sessionStorage[storageKey] as { + idleDeadlineAt: number; + tabActivities: Array<[number, { lastOperationAt: number; expiresAt: number }]>; + }; + expect(beforeExpiry.tabActivities.find(([tabId]) => tabId === firstTabId)?.[1]).toEqual({ + lastOperationAt: startedAt, + expiresAt: startedAt + 5_000, + }); + expect(beforeExpiry.tabActivities.find(([tabId]) => tabId === secondTabId)?.[1]).toEqual({ + lastOperationAt: startedAt + 1_000, + expiresAt: startedAt + 6_000, + }); + expect(beforeExpiry.idleDeadlineAt).toBe(startedAt + 5_000); + + dateNow.mockReturnValue(startedAt + 5_001); + await emitAlarm(`autocli-tab-group-cleanup:${encodeURIComponent('AutoCLI-work')}`); + await vi.waitFor(() => { + expect(tabs.some((tab) => tab.id === firstTabId)).toBe(false); + }); + expect(tabs.some((tab) => tab.id === secondTabId)).toBe(true); + expect(tabs.some((tab) => tab.id === 99)).toBe(true); + + const afterFirstExpiry = sessionStorage[storageKey] as { + ownedTabIds: number[]; + idleDeadlineAt: number; + tabActivities: Array<[number, { lastOperationAt: number; expiresAt: number }]>; + }; + expect(afterFirstExpiry.ownedTabIds).toEqual([secondTabId]); + expect(afterFirstExpiry.tabActivities).toEqual([[ + secondTabId, + { lastOperationAt: startedAt + 1_000, expiresAt: startedAt + 6_000 }, + ]]); + expect(afterFirstExpiry.idleDeadlineAt).toBe(startedAt + 6_000); + + dateNow.mockReturnValue(startedAt + 6_001); + await emitAlarm(`autocli-tab-group-cleanup:${encodeURIComponent('AutoCLI-work')}`); + await vi.waitFor(() => { + expect(tabs.some((tab) => tab.id === secondTabId)).toBe(false); + }); + expect(tabs.some((tab) => tab.id === 99)).toBe(true); + expect(groups.filter((group) => group.title === 'AutoCLI-work')).toHaveLength(1); + expect(sessionStorage).toEqual({}); + } finally { + dateNow.mockRestore(); + } + }); + + it('refreshes only the tab with a successful strict-background operation', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const startedAt = 2_000_000; + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(startedAt); + const executor = await import('./cdp'); + const screenshotSpy = vi.spyOn(executor, 'screenshot').mockResolvedValue('screenshot-data'); + try { + const mod = await import('./background'); + const first = await mod.__test__.handleCommand({ + id: 'target-touch-1', + action: 'tabs', + op: 'new', + url: 'https://first-target.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 5, + }); + const second = await mod.__test__.handleCommand({ + id: 'target-touch-2', + action: 'tabs', + op: 'new', + url: 'https://second-target.example', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 5, + }); + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + const firstTabId = (first.data as { tabId: number }).tabId; + const secondTabId = (second.data as { tabId: number }).tabId; + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + tabs.push({ id: 99, windowId: 2, groupId, url: 'https://user.example', title: 'user', active: false }); + + dateNow.mockReturnValue(startedAt + 1_000); + const screenshot = await mod.__test__.handleCommand({ + id: 'target-touch-3', + action: 'screenshot', + tabId: secondTabId, + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 5, + }); + expect(screenshot).toEqual({ id: 'target-touch-3', ok: true, data: 'screenshot-data' }); + + const storageKey = `autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`; + const stored = sessionStorage[storageKey] as { + tabActivities: Array<[number, { lastOperationAt: number; expiresAt: number }]>; + }; + expect(stored.tabActivities.find(([tabId]) => tabId === firstTabId)?.[1]).toEqual({ + lastOperationAt: startedAt, + expiresAt: startedAt + 5_000, + }); + expect(stored.tabActivities.find(([tabId]) => tabId === secondTabId)?.[1]).toEqual({ + lastOperationAt: startedAt + 1_000, + expiresAt: startedAt + 6_000, + }); + + dateNow.mockReturnValue(startedAt + 5_001); + await emitAlarm(`autocli-tab-group-cleanup:${encodeURIComponent('AutoCLI-work')}`); + await vi.waitFor(() => { + expect(tabs.some((tab) => tab.id === firstTabId)).toBe(false); + }); + expect(tabs.some((tab) => tab.id === secondTabId)).toBe(true); + expect(tabs.some((tab) => tab.id === 99)).toBe(true); + + // Drain the remaining tab before restoring the real clock so this test's + // in-memory scheduler cannot outlive its mocked Chrome instance. + dateNow.mockReturnValue(startedAt + 6_001); + await emitAlarm(`autocli-tab-group-cleanup:${encodeURIComponent('AutoCLI-work')}`); + await vi.waitFor(() => { + expect(tabs.some((tab) => tab.id === secondTabId)).toBe(false); + }); + } finally { + screenshotSpy.mockRestore(); + dateNow.mockRestore(); + } + }); + + it('does not refresh a tab when its strict-background operation fails', async () => { + const { chrome, tabs, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const startedAt = 3_000_000; + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(startedAt); + const executor = await import('./cdp'); + const screenshotSpy = vi.spyOn(executor, 'screenshot').mockRejectedValue(new Error('screenshot failed')); + try { + const mod = await import('./background'); + const created = await mod.__test__.handleCommand({ + id: 'failed-touch-1', + action: 'tabs', + op: 'new', + url: 'https://failed-touch.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 5, + }); + expect(created.ok).toBe(true); + const tabId = (created.data as { tabId: number }).tabId; + + dateNow.mockReturnValue(startedAt + 1_000); + const screenshot = await mod.__test__.handleCommand({ + id: 'failed-touch-2', + action: 'screenshot', + tabId, + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 5, + }); + expect(screenshot.ok).toBe(false); + expect(screenshot.error).toContain('screenshot failed'); + + const storageKey = `autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`; + const stored = sessionStorage[storageKey] as { + tabActivities: Array<[number, { lastOperationAt: number; expiresAt: number }]>; + }; + expect(stored.tabActivities.find(([storedTabId]) => storedTabId === tabId)?.[1]).toEqual({ + lastOperationAt: startedAt, + expiresAt: startedAt + 5_000, + }); + + dateNow.mockReturnValue(startedAt + 5_001); + await emitAlarm(`autocli-tab-group-cleanup:${encodeURIComponent('AutoCLI-work')}`); + await vi.waitFor(() => { + expect(tabs.some((tab) => tab.id === tabId)).toBe(false); + }); + } finally { + screenshotSpy.mockRestore(); + dateNow.mockRestore(); + } + }); + + it('migrates a legacy session record to per-tab activity without extending its deadline', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const legacyDeadlineAt = 4_005_000; + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(4_000_000); + const storageKey = `autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`; + groups.push({ id: 200, title: 'AutoCLI-work', windowId: 2, collapsed: true, color: 'blue' }); + tabs.push( + { id: 10, windowId: 2, groupId: 200, url: 'https://legacy-owned.example', title: 'legacy owned', active: false }, + { id: 99, windowId: 2, groupId: 200, url: 'https://user.example', title: 'user', active: false }, + ); + sessionStorage[storageKey] = { + groupId: 200, + windowId: 2, + title: 'AutoCLI-work', + ownedTabIds: [10], + workspaceTabIds: [['site:twitter', 10]], + tabWorkspaceOwners: [[10, 'site:twitter']], + leaseId: 'legacy-session', + idleDeadlineAt: legacyDeadlineAt, + }; + + try { + await import('./background'); + await vi.waitFor(() => { + const migrated = sessionStorage[storageKey] as { + tabActivities?: Array<[number, { lastOperationAt: number; expiresAt: number }]>; + }; + expect(migrated.tabActivities).toEqual([[ + 10, + { lastOperationAt: legacyDeadlineAt, expiresAt: legacyDeadlineAt }, + ]]); + }); + expect(chrome.alarms.create).toHaveBeenCalledWith( + `autocli-tab-group-cleanup:${encodeURIComponent('AutoCLI-work')}`, + { when: legacyDeadlineAt }, + ); + + dateNow.mockReturnValue(legacyDeadlineAt + 1); + await emitAlarm(`autocli-tab-group-cleanup:${encodeURIComponent('AutoCLI-work')}`); + await vi.waitFor(() => { + expect(tabs.some((tab) => tab.id === 10)).toBe(false); + }); + expect(tabs.some((tab) => tab.id === 99)).toBe(true); + expect(sessionStorage).toEqual({}); + } finally { + dateNow.mockRestore(); + } + }); + + it('keeps a renewed session record when idle cleanup overlaps a new command', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleTabs({ + id: '31', + action: 'tabs', + op: 'new', + url: 'https://old.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }, 'site:twitter'); + expect(created.ok).toBe(true); + + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + expect(groupId).toBeDefined(); + tabs.push({ id: 99, windowId: 2, groupId, url: 'https://user.example', title: 'user', active: false }); + const cleanupAlarm = chrome.alarms.create.mock.calls.find(([name]) => + typeof name === 'string' && name.startsWith('autocli-tab-group-cleanup:') + ); + const originalSession = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + idleDeadlineAt: number; + }; + expect(cleanupAlarm).toBeDefined(); + expect(originalSession).toBeDefined(); + + const defaultStorageRemove = chrome.storage.session.remove.getMockImplementation(); + expect(defaultStorageRemove).toBeDefined(); + let releaseStorageRemove: (() => void) | undefined; + let storageRemoveStarted = false; + chrome.storage.session.remove.mockImplementation(async (keys: string | string[]) => { + if (!storageRemoveStarted) { + storageRemoveStarted = true; + await new Promise((resolve) => { + releaseStorageRemove = resolve; + }); + } + return defaultStorageRemove!(keys); + }); + + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(originalSession.idleDeadlineAt + 1); + try { + await emitAlarm(cleanupAlarm![0]); + await vi.waitFor(() => { + expect(storageRemoveStarted).toBe(true); + }); + + const renewed = mod.__test__.handleCommand({ + id: '32', + action: 'tabs', + op: 'new', + url: 'https://new.example', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }); + + releaseStorageRemove!(); + const renewedResult = await renewed; + expect(renewedResult.ok).toBe(true); + const newTabId = (renewedResult.data as { tabId: number }).tabId; + + await vi.waitFor(() => { + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + ownedTabIds: number[]; + } | undefined; + expect(stored?.ownedTabIds).toContain(newTabId); + }); + expect(tabs.some((tab) => tab.id === 99)).toBe(true); + + const renewedSession = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + idleDeadlineAt: number; + }; + dateNow.mockReturnValue(renewedSession.idleDeadlineAt + 1); + await emitAlarm(cleanupAlarm![0]); + await vi.waitFor(() => { + expect(tabs.some((tab) => tab.id === newTabId)).toBe(false); + }); + expect(tabs.some((tab) => tab.id === 99)).toBe(true); + expect(sessionStorage).toEqual({}); + } finally { + dateNow.mockRestore(); + } + }); + + it('removes persisted owned tabs when the restarted worker receives the cleanup alarm', async () => { + const { chrome, tabs, groups, emitAlarm, resetWorkerListeners, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const firstWorker = await import('./background'); + const created = await firstWorker.__test__.handleTabs({ + id: '20', + action: 'tabs', + op: 'new', + url: 'https://restart.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }, 'site:twitter'); + + expect(created.ok).toBe(true); + const groupId = groups.find((entry) => entry.title === 'AutoCLI-work')?.id; + expect(groupId).toBeDefined(); + const cleanupAlarm = chrome.alarms.create.mock.calls.find(([name]) => + typeof name === 'string' && name.startsWith('autocli-tab-group-cleanup:') + ); + expect(cleanupAlarm).toBeDefined(); + const alarmCountBeforeRestart = chrome.alarms.create.mock.calls.length; + const storedSession = Object.values(sessionStorage).find((value): value is { idleDeadlineAt: number } => + typeof value === 'object' + && value !== null + && 'idleDeadlineAt' in value + && typeof value.idleDeadlineAt === 'number' + ); + expect(storedSession).toBeDefined(); + + // A restarted MV3 worker has no in-memory session or timer from the first worker. + resetWorkerListeners(); + vi.resetModules(); + await import('./background'); + await vi.waitFor(() => { + expect(chrome.alarms.create.mock.calls.length).toBeGreaterThan(alarmCountBeforeRestart); + }); + expect(tabs.some((tab) => tab.groupId === groupId)).toBe(true); + + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(storedSession!.idleDeadlineAt + 1); + try { + await emitAlarm(cleanupAlarm![0]); + await vi.waitFor(() => { + expect(tabs.some((tab) => tab.groupId === groupId)).toBe(false); + }); + } finally { + dateNow.mockRestore(); + } + + expect(groups.some((group) => group.id === groupId)).toBe(false); + }); + + it('prunes stale owned tab IDs while restoring a title-only session', async () => { + const { chrome, tabs, groups, resetWorkerListeners, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const firstWorker = await import('./background'); + const created = await firstWorker.__test__.handleTabs({ + id: '33', + action: 'tabs', + op: 'new', + url: 'https://restore-live.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }, 'site:twitter'); + expect(created.ok).toBe(true); + + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + const liveTabId = (created.data as { tabId: number }).tabId; + const staleTabIndex = tabs.findIndex((tab) => tab.groupId === groupId && tab.url === 'about:blank'); + expect(staleTabIndex).toBeGreaterThanOrEqual(0); + tabs.splice(staleTabIndex, 1); + + resetWorkerListeners(); + vi.resetModules(); + await import('./background'); + + await vi.waitFor(() => { + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + ownedTabIds: number[]; + } | undefined; + expect(stored?.ownedTabIds).toEqual([liveTabId]); + }); + }); + + it('refreshes only the released workspace tab after a worker restart', async () => { + const { chrome, sessionStorage, resetWorkerListeners } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const firstWorker = await import('./background'); + await firstWorker.__test__.handleTabs({ + id: '23', + action: 'tabs', + op: 'new', + url: 'https://restart-release.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }, 'site:twitter'); + const initialSession = Object.values(sessionStorage).find((value): value is { idleDeadlineAt: number } => + typeof value === 'object' + && value !== null + && 'idleDeadlineAt' in value + && typeof value.idleDeadlineAt === 'number' + ); + expect(initialSession).toBeDefined(); + + resetWorkerListeners(); + vi.resetModules(); + const restartAt = initialSession!.idleDeadlineAt - 60_000; + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(restartAt); + try { + const restartedWorker = await import('./background'); + const released = await restartedWorker.__test__.handleCloseWindow({ + id: '24', + action: 'close-window', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }, 'site:twitter'); + + expect(released.ok).toBe(true); + const renewedSession = Object.values(sessionStorage).find((value): value is { + idleDeadlineAt: number; + workspaceTabIds: Array<[string, number]>; + tabActivities: Array<[number, { lastOperationAt: number; expiresAt: number }]>; + } => + typeof value === 'object' + && value !== null + && 'idleDeadlineAt' in value + && typeof value.idleDeadlineAt === 'number' + ); + expect(renewedSession).toBeDefined(); + const releasedTabId = renewedSession!.workspaceTabIds.find(([workspace]) => workspace === 'site:twitter')?.[1]; + expect(releasedTabId).toBeDefined(); + const releasedActivity = renewedSession!.tabActivities.find(([tabId]) => tabId === releasedTabId)?.[1]; + expect(releasedActivity).toEqual({ + lastOperationAt: restartAt, + expiresAt: restartAt + 120_000, + }); + // The seed tab was not released, so it remains the group's earliest + // per-tab expiry rather than being extended by another tab's close. + expect(renewedSession!.idleDeadlineAt).toBe(initialSession!.idleDeadlineAt); + } finally { + dateNow.mockRestore(); + } + }); + + it('stops idle cleanup after a new command renews the group during tab removal', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleTabs({ + id: '25', + action: 'tabs', + op: 'new', + url: 'https://renew-during-cleanup.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }, 'site:twitter'); + expect(created.ok).toBe(true); + const groupId = groups.find((entry) => entry.title === 'AutoCLI-work')?.id; + const createdTabId = (created.data as { tabId: number }).tabId; + const cleanupAlarm = chrome.alarms.create.mock.calls.find(([name]) => + typeof name === 'string' && name.startsWith('autocli-tab-group-cleanup:') + ); + const storedSession = Object.values(sessionStorage).find((value): value is { idleDeadlineAt: number } => + typeof value === 'object' + && value !== null + && 'idleDeadlineAt' in value + && typeof value.idleDeadlineAt === 'number' + ); + expect(groupId).toBeDefined(); + expect(cleanupAlarm).toBeDefined(); + expect(storedSession).toBeDefined(); + + const remove = chrome.tabs.remove; + const defaultRemove = remove.getMockImplementation(); + expect(defaultRemove).toBeDefined(); + let releaseFirstRemoval: (() => void) | undefined; + let firstRemovalStarted = false; + let secondRemovalStarted = false; + remove.mockImplementation(async (tabIdOrIds: number | number[]) => { + if (!firstRemovalStarted) { + firstRemovalStarted = true; + await new Promise((resolve) => { + releaseFirstRemoval = resolve; + }); + } else { + secondRemovalStarted = true; + } + return defaultRemove!(tabIdOrIds); + }); + + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(storedSession!.idleDeadlineAt + 1); + try { + await emitAlarm(cleanupAlarm![0]); + await vi.waitFor(() => { + expect(firstRemovalStarted).toBe(true); + }); + + const released = await mod.__test__.handleCloseWindow({ + id: '26', + action: 'close-window', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }, 'site:twitter'); + expect(released.ok).toBe(true); + + releaseFirstRemoval!(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(secondRemovalStarted).toBe(false); + expect(tabs.some((tab) => tab.id === createdTabId)).toBe(true); + } finally { + dateNow.mockRestore(); + } + }); + + it('closes remaining owned tabs when an earlier owned tab is already gone', async () => { + vi.useFakeTimers(); + try { + const { chrome, tabs, groups } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleTabs({ + id: '21', + action: 'tabs', + op: 'new', + url: 'https://stale-tab.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 1, + }, 'site:twitter'); + + expect(created.ok).toBe(true); + const groupId = groups.find((entry) => entry.title === 'AutoCLI-work')?.id; + expect(groupId).toBeDefined(); + const seedIndex = tabs.findIndex((tab) => tab.groupId === groupId && tab.url === 'about:blank'); + expect(seedIndex).toBeGreaterThanOrEqual(0); + tabs.splice(seedIndex, 1); + + await mod.__test__.handleCloseWindow({ + id: '22', + action: 'close-window', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 1, + }, 'site:twitter'); + await vi.advanceTimersByTimeAsync(1000); + + expect(tabs.some((tab) => tab.groupId === groupId)).toBe(false); + expect(groups.some((group) => group.id === groupId)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps strict-background session locks scoped by group name', async () => { + const { chrome, groups } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const results = await Promise.all([ + mod.__test__.handleTabs({ + id: '18', + action: 'tabs', + op: 'new', + url: 'https://work.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'), + mod.__test__.handleTabs({ + id: '19', + action: 'tabs', + op: 'new', + url: 'https://dev.example', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'dev', + }, 'site:zhihu'), + ]); + + expect(results.every((result) => result.ok)).toBe(true); + expect(groups.filter((group) => group.title === 'AutoCLI-work')).toHaveLength(1); + expect(groups.filter((group) => group.title === 'AutoCLI-dev')).toHaveLength(1); + }); + + it('retries a transient session-storage write before returning strict-background success', async () => { + const { chrome, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + chrome.storage.session.set.mockRejectedValueOnce(new Error('temporary storage failure')); + + const mod = await import('./background'); + const result = await mod.__test__.handleCommand({ + id: '34', + action: 'tabs', + op: 'new', + url: 'https://storage-retry.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }); + + expect(result.ok).toBe(true); + expect(chrome.storage.session.set.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`]).toEqual( + expect.objectContaining({ + title: 'AutoCLI-work', + ownedTabIds: expect.arrayContaining([10, 11]), + }), + ); + }); + + it('rolls back a newly created group when its first ownership record cannot be persisted', async () => { + const { chrome, tabs, groups, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + chrome.storage.session.set.mockRejectedValue(new Error('storage offline')); + + const mod = await import('./background'); + const result = await mod.__test__.handleCommand({ + id: '35', + action: 'tabs', + op: 'new', + url: 'https://storage-offline.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }); + + expect(result.ok).toBe(false); + expect(groups.filter((group) => group.title === 'AutoCLI-work')).toHaveLength(0); + expect(tabs.some((tab) => tab.url === 'about:blank' || tab.url === 'https://storage-offline.example')).toBe(false); + expect(sessionStorage).toEqual({}); + expect(chrome.windows.create).not.toHaveBeenCalled(); + }); + + it('rolls back only a new tab when an existing group cannot persist its updated ownership', async () => { + const { chrome, tabs, groups, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleTabs({ + id: '36', + action: 'tabs', + op: 'new', + url: 'https://stored.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + expect(created.ok).toBe(true); + + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + expect(groupId).toBeDefined(); + tabs.push({ id: 99, windowId: 2, groupId, url: 'https://user.example', title: 'user', active: false }); + + chrome.storage.session.set.mockImplementation(async (_items: Record) => { + throw new Error('storage offline'); + }); + + await expect(mod.__test__.handleTabs({ + id: '37', + action: 'tabs', + op: 'new', + url: 'https://rollback-only-this-tab.example', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:zhihu')).rejects.toThrow('storage offline'); + + expect(tabs.some((tab) => tab.url === 'https://rollback-only-this-tab.example')).toBe(false); + expect(tabs.some((tab) => tab.id === 99)).toBe(true); + expect(groups.filter((group) => group.title === 'AutoCLI-work')).toHaveLength(1); + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + ownedTabIds: number[]; + }; + expect(stored.ownedTabIds).toEqual(expect.arrayContaining([10, 11])); + expect(stored.ownedTabIds).not.toContain(12); + }); + + it('retries storage restoration and keyed cleanup instead of treating an unavailable record as absent', async () => { + const { chrome, emitAlarm } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + chrome.storage.session.get.mockRejectedValue(new Error('storage offline')); + + await import('./background'); + await vi.waitFor(() => { + expect(chrome.alarms.create).toHaveBeenCalledWith('autocli-tab-group-restore', expect.objectContaining({ when: expect.any(Number) })); + }); + + await emitAlarm(`autocli-tab-group-cleanup:${encodeURIComponent('AutoCLI-work')}`); + + await vi.waitFor(() => { + expect(chrome.alarms.create).toHaveBeenCalledWith( + `autocli-tab-group-cleanup:${encodeURIComponent('AutoCLI-work')}`, + expect.objectContaining({ when: expect.any(Number) }), + ); + }); + expect(chrome.alarms.clear).not.toHaveBeenCalled(); + }); + + it('does not let restore delete a valid session written after its stale invalid snapshot', async () => { + const { chrome, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const storageKey = `autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`; + sessionStorage[storageKey] = { malformed: true }; + const defaultGet = chrome.storage.session.get.getMockImplementation(); + expect(defaultGet).toBeDefined(); + let snapshotReadStarted = false; + let releaseSnapshotRead: (() => void) | undefined; + chrome.storage.session.get.mockImplementation(async (keys?: string | string[] | Record) => { + if (keys === undefined && !snapshotReadStarted) { + snapshotReadStarted = true; + const staleSnapshot = { ...sessionStorage }; + await new Promise((resolve) => { + releaseSnapshotRead = resolve; + }); + return staleSnapshot; + } + return defaultGet!(keys); + }); + + const mod = await import('./background'); + await vi.waitFor(() => { + expect(snapshotReadStarted).toBe(true); + }); + + const created = await mod.__test__.handleCommand({ + id: 'restore-race-1', + action: 'tabs', + op: 'new', + url: 'https://restore-race.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }); + expect(created.ok).toBe(true); + const newTabId = (created.data as { tabId: number }).tabId; + const recordBeforeRestore = sessionStorage[storageKey] as { + leaseId: string; + ownedTabIds: number[]; + }; + expect(recordBeforeRestore.ownedTabIds).toContain(newTabId); + expect(recordBeforeRestore.leaseId).toEqual(expect.any(String)); + + releaseSnapshotRead!(); + await vi.waitFor(() => { + const finalRecord = sessionStorage[storageKey] as { + leaseId: string; + ownedTabIds: number[]; + } | undefined; + expect(finalRecord?.leaseId).toBe(recordBeforeRestore.leaseId); + expect(finalRecord?.ownedTabIds).toContain(newTabId); + }); + }); + + it('keeps user tabs and schedules another cleanup when stale-ID pruning cannot be persisted', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleTabs({ + id: '38', + action: 'tabs', + op: 'new', + url: 'https://cleanup-storage-failure.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }, 'site:twitter'); + expect(created.ok).toBe(true); + + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + const cleanupAlarm = chrome.alarms.create.mock.calls.find(([name]) => + typeof name === 'string' && name.startsWith('autocli-tab-group-cleanup:') + ); + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + idleDeadlineAt: number; + }; + expect(groupId).toBeDefined(); + expect(cleanupAlarm).toBeDefined(); + expect(stored).toBeDefined(); + tabs.push({ id: 99, windowId: 2, groupId, url: 'https://user.example', title: 'user', active: false }); + + chrome.storage.session.set.mockRejectedValue(new Error('storage offline')); + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(stored.idleDeadlineAt + 1); + try { + await emitAlarm(cleanupAlarm![0]); + await vi.waitFor(() => { + expect(tabs.some((tab) => tab.id === 99)).toBe(true); + expect(tabs.some((tab) => tab.groupId === groupId && tab.id !== 99)).toBe(false); + }); + } finally { + dateNow.mockRestore(); + } + + expect(chrome.storage.session.remove).not.toHaveBeenCalled(); + expect(chrome.alarms.create).toHaveBeenCalledWith( + cleanupAlarm![0], + expect.objectContaining({ when: expect.any(Number) }), + ); + }); + + it('does not remove an explicit target tab after a same-group command reaches the activity fence', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleCommand({ + id: '39', + action: 'tabs', + op: 'new', + url: 'https://activity-fence.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }); + expect(created.ok).toBe(true); + + const targetTabId = (created.data as { tabId: number }).tabId; + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + const cleanupAlarm = chrome.alarms.create.mock.calls.find(([name]) => + typeof name === 'string' && name.startsWith('autocli-tab-group-cleanup:') + ); + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + idleDeadlineAt: number; + }; + expect(groupId).toBeDefined(); + expect(cleanupAlarm).toBeDefined(); + expect(stored).toBeDefined(); + + const defaultQuery = chrome.tabs.query.getMockImplementation(); + expect(defaultQuery).toBeDefined(); + let groupQueryCount = 0; + let cleanupQueryBlocked = false; + let releaseCleanupQuery: (() => void) | undefined; + chrome.tabs.query.mockImplementation(async (queryInfo: { windowId?: number; groupId?: number } = {}) => { + if (queryInfo.groupId === groupId) { + groupQueryCount++; + if (groupQueryCount === 3) { + cleanupQueryBlocked = true; + await new Promise((resolve) => { + releaseCleanupQuery = resolve; + }); + } + } + return defaultQuery!(queryInfo); + }); + + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(stored.idleDeadlineAt + 1); + try { + await emitAlarm(cleanupAlarm![0]); + await vi.waitFor(() => { + expect(cleanupQueryBlocked).toBe(true); + }); + + const command = mod.__test__.handleCommand({ + id: '40', + action: 'screenshot', + tabId: targetTabId, + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }); + releaseCleanupQuery!(); + await command; + + expect(tabs.some((tab) => tab.id === targetTabId)).toBe(true); + expect(chrome.tabs.remove).not.toHaveBeenCalledWith(targetTabId); + } finally { + dateNow.mockRestore(); + } + }); + + it('waits for an in-flight cleanup removal before a later same-group command resolves a tab', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage, create } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleCommand({ + id: 'cleanup-lease-1', + action: 'tabs', + op: 'new', + url: 'https://old-tab.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }); + expect(created.ok).toBe(true); + + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + const cleanupAlarm = chrome.alarms.create.mock.calls.find(([name]) => + typeof name === 'string' && name.startsWith('autocli-tab-group-cleanup:') + ); + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + idleDeadlineAt: number; + }; + expect(groupId).toBeDefined(); + expect(cleanupAlarm).toBeDefined(); + tabs.push({ id: 94, windowId: 2, groupId, url: 'https://user-tab.example', title: 'user', active: false }); + + const defaultRemove = chrome.tabs.remove.getMockImplementation(); + expect(defaultRemove).toBeDefined(); + let removalStarted = false; + let releaseRemoval: (() => void) | undefined; + chrome.tabs.remove.mockImplementation(async (tabIdOrIds: number | number[]) => { + if (!removalStarted) { + removalStarted = true; + await new Promise((resolve) => { + releaseRemoval = resolve; + }); + } + return defaultRemove!(tabIdOrIds); + }); + + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(stored.idleDeadlineAt + 1); + try { + await emitAlarm(cleanupAlarm![0]); + await vi.waitFor(() => { + expect(removalStarted).toBe(true); + }); + + const createCallsBeforeCommand = create.mock.calls.length; + const command = mod.__test__.handleCommand({ + id: 'cleanup-lease-2', + action: 'tabs', + op: 'new', + url: 'https://new-tab.example', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }); + await Promise.resolve(); + await Promise.resolve(); + + // The later command has declared activity, but cannot resolve/create a + // tab until the earlier cleanup's Chrome removal settles. + expect(create).toHaveBeenCalledTimes(createCallsBeforeCommand); + + releaseRemoval!(); + const result = await command; + expect(result.ok).toBe(true); + const newTabId = (result.data as { tabId: number }).tabId; + expect(tabs.some((tab) => tab.id === newTabId)).toBe(true); + expect(chrome.tabs.remove).not.toHaveBeenCalledWith(newTabId); + } finally { + dateNow.mockRestore(); + } + }); + + it('does not leave a durable orphan-close alarm when persistence cannot prove ownership', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleTabs({ + id: '41', + action: 'tabs', + op: 'new', + url: 'https://registered.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + expect(created.ok).toBe(true); + + const defaultRemove = chrome.tabs.remove.getMockImplementation(); + expect(defaultRemove).toBeDefined(); + chrome.storage.session.set.mockImplementation(async (_items: Record) => { + throw new Error('storage offline'); + }); + chrome.tabs.remove.mockRejectedValue(new Error('temporary tab close failure')); + + await expect(mod.__test__.handleTabs({ + id: '42', + action: 'tabs', + op: 'new', + url: 'https://alarm-close.example', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:zhihu')).rejects.toThrow('storage offline'); + + const alarmCloseTab = tabs.find((tab) => tab.url === 'https://alarm-close.example'); + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + expect(alarmCloseTab?.id).toBeDefined(); + expect(groupId).toBeDefined(); + expect(chrome.alarms.create.mock.calls.some(([name]) => + typeof name === 'string' && name.startsWith('autocli-tab-group-orphan-tab:') + )).toBe(false); + + chrome.tabs.remove.mockImplementation(defaultRemove!); + expect(tabs.some((tab) => tab.id === alarmCloseTab!.id)).toBe(true); + expect(groups.filter((group) => group.title === 'AutoCLI-work')).toHaveLength(1); + }); + + it('does not create an orphan-close alarm for an unpersisted seed tab', async () => { + const { chrome, tabs, groups, emitAlarm } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + chrome.storage.session.set.mockRejectedValue(new Error('storage offline')); + const defaultRemove = chrome.tabs.remove.getMockImplementation(); + expect(defaultRemove).toBeDefined(); + chrome.tabs.remove.mockRejectedValue(new Error('temporary tab close failure')); + + const mod = await import('./background'); + const result = await mod.__test__.handleCommand({ + id: '43', + action: 'tabs', + op: 'new', + url: 'https://seed-alarm-close.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }); + expect(result.ok).toBe(false); + + const seedTab = tabs.find((tab) => tab.url === 'about:blank'); + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + expect(seedTab?.id).toBeDefined(); + expect(groupId).toBeDefined(); + expect(chrome.alarms.create.mock.calls.some(([name]) => + typeof name === 'string' && name.startsWith('autocli-tab-group-orphan-tab:') + )).toBe(false); + chrome.tabs.remove.mockImplementation(defaultRemove!); + expect(tabs.some((tab) => tab.id === seedTab!.id)).toBe(true); + }); + + it('does not close an orphan alarm tab after Chrome moves it out of the expected group', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleTabs({ + id: '49', + action: 'tabs', + op: 'new', + url: 'https://registered-for-reuse.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + expect(created.ok).toBe(true); + + const defaultRemove = chrome.tabs.remove.getMockImplementation(); + expect(defaultRemove).toBeDefined(); + chrome.storage.session.set.mockImplementation(async (_items: Record) => { + throw new Error('storage offline'); + }); + chrome.tabs.remove.mockRejectedValue(new Error('temporary tab close failure')); + + await expect(mod.__test__.handleTabs({ + id: '50', + action: 'tabs', + op: 'new', + url: 'https://reused-id.example', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:zhihu')).rejects.toThrow('storage offline'); + + const orphanedTab = tabs.find((tab) => tab.url === 'https://reused-id.example'); + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + expect(orphanedTab?.id).toBeDefined(); + expect(groupId).toBeDefined(); + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + leaseId: string; + }; + const orphanAlarm = `autocli-tab-group-orphan-tab:${encodeURIComponent('AutoCLI-work')}:${stored.leaseId}:${groupId}:${orphanedTab!.id}`; + + // A tab ID can be reused or moved before an alarm runs. The alarm must not + // remove it unless it is still in the exact AutoCLI group it was created in. + orphanedTab!.groupId = undefined; + chrome.tabs.remove.mockImplementation(defaultRemove!); + const removeCallCountBeforeAlarm = chrome.tabs.remove.mock.calls.length; + await emitAlarm(orphanAlarm); + + expect(tabs.some((tab) => tab.id === orphanedTab!.id)).toBe(true); + expect(chrome.tabs.remove).toHaveBeenCalledTimes(removeCallCountBeforeAlarm); + expect(chrome.alarms.clear).toHaveBeenCalledWith(orphanAlarm); + }); + + it('refuses to choose among pre-existing duplicate tab groups without ownership metadata', async () => { + const { chrome, tabs, groups } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + groups.push( + { id: 100, title: 'AutoCLI-work', windowId: 2 }, + { id: 101, title: 'AutoCLI-work', windowId: 2 }, + ); + tabs.push( + { id: 90, windowId: 2, groupId: 100, url: 'https://user-one.example', title: 'user one', active: false }, + { id: 91, windowId: 2, groupId: 101, url: 'https://user-two.example', title: 'user two', active: false }, + ); + + const mod = await import('./background'); + const result = await mod.__test__.handleCommand({ + id: '44', + action: 'tabs', + op: 'new', + url: 'https://should-not-create.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('multiple open tab groups'); + expect(tabs.some((tab) => tab.url === 'https://should-not-create.example')).toBe(false); + expect(groups.filter((group) => group.title === 'AutoCLI-work')).toHaveLength(2); + }); + + it('refuses a command when an owned session has an additional same-title group', async () => { + const { chrome, tabs, groups, create } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleCommand({ + id: '45', + action: 'tabs', + op: 'new', + url: 'https://owned-session.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }); + expect(created.ok).toBe(true); + + groups.push({ id: 101, title: 'AutoCLI-work', windowId: 2 }); + tabs.push({ + id: 90, + windowId: 2, + groupId: 101, + url: 'https://user-in-duplicate.example', + title: 'user duplicate', + active: false, + }); + const createCountBeforeRejectedCommand = create.mock.calls.length; + + const result = await mod.__test__.handleCommand({ + id: '46', + action: 'tabs', + op: 'new', + url: 'https://must-not-create.example', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('multiple open tab groups'); + expect(create).toHaveBeenCalledTimes(createCountBeforeRejectedCommand); + expect(tabs.some((tab) => tab.url === 'https://must-not-create.example')).toBe(false); + expect(tabs.some((tab) => tab.id === 90)).toBe(true); + expect(groups.filter((group) => group.title === 'AutoCLI-work')).toHaveLength(2); + }); + + it('refuses list and close-window when an owned session has an additional same-title group', async () => { + const { chrome, tabs, groups } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleCommand({ + id: '46-list-setup', + action: 'tabs', + op: 'new', + url: 'https://owned-session.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }); + expect(created.ok).toBe(true); + + groups.push({ id: 101, title: 'AutoCLI-work', windowId: 2 }); + tabs.push({ + id: 90, + windowId: 2, + groupId: 101, + url: 'https://user-in-duplicate.example', + title: 'user duplicate', + active: false, + }); + + for (const command of [ + { id: '46-list', action: 'tabs' as const, op: 'list' as const }, + { id: '46-close', action: 'close-window' as const }, + ]) { + const result = await mod.__test__.handleCommand({ + ...command, + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('multiple open tab groups'); + } + }); + + it('keeps a new session when idle cleanup is blocked deleting its persisted record', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleCommand({ + id: '47', + action: 'tabs', + op: 'new', + url: 'https://old-session.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }); + expect(created.ok).toBe(true); + + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + const cleanupAlarm = chrome.alarms.create.mock.calls.find(([name]) => + typeof name === 'string' && name.startsWith('autocli-tab-group-cleanup:') + ); + const storedSession = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + idleDeadlineAt: number; + }; + expect(groupId).toBeDefined(); + expect(cleanupAlarm).toBeDefined(); + expect(storedSession).toBeDefined(); + + // Keep the group live after AutoCLI's old tabs are closed, so cleanup reaches + // the final persisted-session discard path rather than the missing-group path. + tabs.push({ id: 91, windowId: 2, groupId, url: 'https://user-tab.example', title: 'user', active: false }); + + const defaultStorageRemove = chrome.storage.session.remove.getMockImplementation(); + expect(defaultStorageRemove).toBeDefined(); + let storageRemoveStarted = false; + let releaseStorageRemove: (() => void) | undefined; + chrome.storage.session.remove.mockImplementation(async (keys: string | string[]) => { + if (!storageRemoveStarted) { + storageRemoveStarted = true; + await new Promise((resolve) => { + releaseStorageRemove = resolve; + }); + } + return defaultStorageRemove!(keys); + }); + + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(storedSession.idleDeadlineAt + 1); + try { + await emitAlarm(cleanupAlarm![0]); + await vi.waitFor(() => { + expect(storageRemoveStarted).toBe(true); + }); + + const command = mod.__test__.handleCommand({ + id: '48', + action: 'tabs', + op: 'new', + url: 'https://new-session.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 120, + }); + releaseStorageRemove!(); + + const result = await command; + expect(result.ok).toBe(true); + const newTabId = (result.data as { tabId: number }).tabId; + await vi.waitFor(() => { + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + ownedTabIds: number[]; + } | undefined; + expect(stored?.ownedTabIds).toContain(newTabId); + }); + + expect(groups.filter((group) => group.title === 'AutoCLI-work')).toHaveLength(1); + expect(tabs.some((tab) => tab.id === newTabId)).toBe(true); + expect(tabs.some((tab) => tab.id === 91)).toBe(true); + expect(chrome.windows.create).not.toHaveBeenCalled(); + } finally { + dateNow.mockRestore(); + } + }); + + it('defers idle cleanup until a long-running strict-background command releases its lease', async () => { + const { chrome, tabs, groups, emitAlarm, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const startedAt = 1_000_000; + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(startedAt); + let screenshotStarted = false; + let releaseScreenshot: (() => void) | undefined; + const executor = await import('./cdp'); + const screenshotSpy = vi.spyOn(executor, 'screenshot').mockImplementation(async () => { + screenshotStarted = true; + await new Promise((resolve) => { + releaseScreenshot = resolve; + }); + return 'screenshot-data'; + }); + + try { + const mod = await import('./background'); + const created = await mod.__test__.handleCommand({ + id: '51', + action: 'tabs', + op: 'new', + url: 'https://lease-target.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 1, + }); + expect(created.ok).toBe(true); + + const targetTabId = (created.data as { tabId: number }).tabId; + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + const cleanupAlarm = chrome.alarms.create.mock.calls.find(([name]) => + typeof name === 'string' && name.startsWith('autocli-tab-group-cleanup:') + ); + expect(groupId).toBeDefined(); + expect(cleanupAlarm).toBeDefined(); + tabs.push({ id: 92, windowId: 2, groupId, url: 'https://user-tab.example', title: 'user', active: false }); + + const screenshot = mod.__test__.handleCommand({ + id: '52', + action: 'screenshot', + tabId: targetTabId, + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: 1, + }); + await vi.waitFor(() => { + expect(screenshotStarted).toBe(true); + }); + + dateNow.mockReturnValue(startedAt + 1_001); + await emitAlarm(cleanupAlarm![0]); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(tabs.some((tab) => tab.id === targetTabId)).toBe(true); + expect(chrome.tabs.remove).not.toHaveBeenCalledWith(targetTabId); + + releaseScreenshot!(); + await expect(screenshot).resolves.toEqual({ id: '52', ok: true, data: 'screenshot-data' }); + + await vi.waitFor(() => { + expect(tabs.some((tab) => tab.id === 10)).toBe(false); + }); + const renewedSession = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + idleDeadlineAt: number; + }; + expect(renewedSession.idleDeadlineAt).toBe(startedAt + 2_001); + + dateNow.mockReturnValue(startedAt + 2_002); + await emitAlarm(cleanupAlarm![0]); + await vi.waitFor(() => { + expect(tabs.some((tab) => tab.id === targetTabId)).toBe(false); + }); + expect(tabs.some((tab) => tab.id === 92)).toBe(true); + } finally { + screenshotSpy.mockRestore(); + dateNow.mockRestore(); + } + }); + + it('prunes an externally closed owned tab from the title-only session immediately', async () => { + const { chrome, tabs, groups, sessionStorage } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const created = await mod.__test__.handleCommand({ + id: '53', + action: 'tabs', + op: 'new', + url: 'https://closed-by-chrome.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }); + expect(created.ok).toBe(true); + + const closedTabId = (created.data as { tabId: number }).tabId; + const groupId = groups.find((group) => group.title === 'AutoCLI-work')?.id; + expect(groupId).toBeDefined(); + tabs.push({ id: 93, windowId: 2, groupId, url: 'https://user-tab.example', title: 'user', active: false }); + + await chrome.tabs.remove(closedTabId); + await vi.waitFor(() => { + const stored = sessionStorage[`autocli.tabGroupSession.${encodeURIComponent('AutoCLI-work')}`] as { + ownedTabIds: number[]; + workspaceTabIds: Array<[string, number]>; + }; + expect(stored.ownedTabIds).not.toContain(closedTabId); + expect(stored.workspaceTabIds).not.toContainEqual(['site:twitter', closedTabId]); + }); + + const listedFromAnotherWorkspace = await mod.__test__.handleCommand({ + id: '54', + action: 'tabs', + op: 'list', + workspace: 'site:zhihu', + carrier: 'tab-group-background', + groupName: 'work', + }); + expect(listedFromAnotherWorkspace.ok).toBe(true); + expect(groups.filter((group) => group.title === 'AutoCLI-work')).toHaveLength(1); + expect(tabs.some((tab) => tab.id === 93)).toBe(true); + }); }); diff --git a/extension/src/background.ts b/extension/src/background.ts index 978f354..0da35a8 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -7,10 +7,12 @@ * dispatches them to Chrome APIs (debugger/tabs/cookies), returns results. */ -import type { Command, Result } from './protocol'; +import type { BrowserCarrier, Command, Result } from './protocol'; import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; +type SelectorWindow = Window & typeof globalThis & { __autocliSelectorActive?: boolean }; + let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempts = 0; @@ -144,13 +146,354 @@ type AutomationSession = { idleDeadlineAt: number; }; +type TabGroupSession = { + groupId: number; + windowId: number; + title: string; + ownedTabIds: Set; + // A group is global, but each logical caller needs a stable default tab in + // that shared group. This map never grants ownership by itself: every value + // must also be present in ownedTabIds before it can be used. + workspaceTabIds: Map; + // Every AutoCLI-created tab has one logical owner. The physical Chrome tab + // group is shared by title, but ownership is never inferred from membership. + tabWorkspaceOwners: Map; + // Activity is tracked per owned tab. Chrome's Tab.lastAccessed only changes + // when a tab becomes active, which strict background tabs intentionally do + // not do, so this records successful AutoCLI operations instead. + tabActivities: Map; + leaseId: string; + idleTimer: ReturnType | null; + // This is the scheduler's earliest per-tab expiry, not a group-wide last + // activity time. It is retained for storage compatibility with prior builds. + idleDeadlineAt: number; +}; + +type TabGroupTabActivity = { + lastOperationAt: number; + expiresAt: number; +}; + +type StoredTabGroupSession = { + groupId: number; + windowId: number; + title: string; + ownedTabIds: number[]; + workspaceTabIds?: Array<[string, number]>; + tabWorkspaceOwners?: Array<[number, string]>; + tabActivities?: Array<[number, TabGroupTabActivity]>; + leaseId?: string; + idleDeadlineAt: number; +}; + const automationSessions = new Map(); +const tabGroupSessions = new Map(); +const tabGroupSessionLocks = new Map>(); +const tabGroupSessionMutations = new Map>(); +const tabGroupSessionStorageWrites = new Map>(); +const tabGroupCleanupLeases = new Map>(); +const tabGroupActivityGenerations = new Map(); +const tabGroupActiveCommandCounts = new Map(); const WINDOW_IDLE_TIMEOUT = 30000; // 30s — quick cleanup after command finishes +const TAB_GROUP_SESSION_STORAGE_PREFIX = 'autocli.tabGroupSession.'; +const TAB_GROUP_CLEANUP_ALARM_PREFIX = 'autocli-tab-group-cleanup:'; +const TAB_GROUP_ORPHAN_TAB_CLOSE_ALARM_PREFIX = 'autocli-tab-group-orphan-tab:'; +const TAB_GROUP_CLEANUP_RETRY_DELAY = 30_000; +const TAB_GROUP_RESTORE_RETRY_ALARM = 'autocli-tab-group-restore'; +const TAB_GROUP_STORAGE_RETRY_ATTEMPTS = 3; function getWorkspaceKey(workspace?: string): string { return workspace?.trim() || 'default'; } +function getCarrier(cmd: Command): BrowserCarrier { + const carrier = cmd.carrier ?? 'automation-window'; + if (carrier !== 'automation-window' && carrier !== 'tab-group-background') { + throw new Error(`Unknown browser carrier: ${carrier}`); + } + return carrier; +} + +function isTabGroupCarrier(cmd: Command): boolean { + return getCarrier(cmd) === 'tab-group-background'; +} + +function getGroupTitle(cmd: Command): string { + const name = cmd.groupName?.trim(); + if (!name) throw new Error('strict-background group name is required'); + if (name.includes('\n') || name.includes('\r')) throw new Error('strict-background group name cannot contain newlines'); + return `AutoCLI-${name}`; +} + +function getTabGroupSessionKey(title: string): string { + // Tab Groups are global to the Chrome profile, so their session identity must + // match the user-visible title rather than the daemon's legacy workspace. + return title; +} + +function getTabGroupSessionStorageKey(key: string): string { + return `${TAB_GROUP_SESSION_STORAGE_PREFIX}${encodeURIComponent(key)}`; +} + +function getTabGroupCleanupAlarmName(key: string): string { + return `${TAB_GROUP_CLEANUP_ALARM_PREFIX}${encodeURIComponent(key)}`; +} + +function getTabGroupOrphanTabCloseAlarmName(key: string, leaseId: string, groupId: number, tabId: number): string { + return `${TAB_GROUP_ORPHAN_TAB_CLOSE_ALARM_PREFIX}${encodeURIComponent(key)}:${leaseId}:${groupId}:${tabId}`; +} + +function getTabGroupSessionKeyFromAlarmName(name: string): string | null { + if (!name.startsWith(TAB_GROUP_CLEANUP_ALARM_PREFIX)) return null; + try { + return decodeURIComponent(name.slice(TAB_GROUP_CLEANUP_ALARM_PREFIX.length)); + } catch { + return null; + } +} + +function getTabGroupOrphanTabCloseAlarm(name: string): { key: string; leaseId: string; groupId: number; tabId: number } | null { + if (!name.startsWith(TAB_GROUP_ORPHAN_TAB_CLOSE_ALARM_PREFIX)) return null; + const encoded = name.slice(TAB_GROUP_ORPHAN_TAB_CLOSE_ALARM_PREFIX.length); + const tabSeparator = encoded.lastIndexOf(':'); + const groupSeparator = encoded.lastIndexOf(':', tabSeparator - 1); + const leaseSeparator = encoded.lastIndexOf(':', groupSeparator - 1); + if (leaseSeparator < 1 || groupSeparator <= leaseSeparator + 1 || tabSeparator <= groupSeparator + 1) return null; + const leaseId = encoded.slice(leaseSeparator + 1, groupSeparator); + const groupId = Number(encoded.slice(groupSeparator + 1, tabSeparator)); + const tabId = Number(encoded.slice(tabSeparator + 1)); + if (!leaseId || !Number.isInteger(groupId) || groupId < 0 || !Number.isInteger(tabId) || tabId < 0) return null; + try { + return { key: decodeURIComponent(encoded.slice(0, leaseSeparator)), leaseId, groupId, tabId }; + } catch { + return null; + } +} + +function beginTabGroupActivity(key: string): void { + tabGroupActivityGenerations.set(key, (tabGroupActivityGenerations.get(key) ?? 0) + 1); +} + +function beginTabGroupCommand(key: string): void { + beginTabGroupActivity(key); + tabGroupActiveCommandCounts.set(key, (tabGroupActiveCommandCounts.get(key) ?? 0) + 1); +} + +function endTabGroupCommand(key: string): void { + const count = tabGroupActiveCommandCounts.get(key) ?? 0; + if (count <= 1) { + tabGroupActiveCommandCounts.delete(key); + } else { + tabGroupActiveCommandCounts.set(key, count - 1); + } +} + +function isTabGroupCommandActive(key: string): boolean { + return (tabGroupActiveCommandCounts.get(key) ?? 0) > 0; +} + +function isTabGroupActivityCurrent(key: string, generation: number): boolean { + return (tabGroupActivityGenerations.get(key) ?? 0) === generation; +} + +function createTabGroupSessionLeaseId(): string { + return crypto.randomUUID(); +} + +function hasValidWorkspaceTabBindings(value: unknown): value is Array<[string, number]> { + if (!Array.isArray(value)) return false; + const workspaces = new Set(); + return value.every((entry) => { + if (!Array.isArray(entry) || entry.length !== 2) return false; + const [workspace, tabId] = entry; + if (typeof workspace !== 'string' || !workspace || !Number.isInteger(tabId) || tabId < 0) return false; + if (workspaces.has(workspace)) return false; + workspaces.add(workspace); + return true; + }); +} + +function hasValidTabWorkspaceOwners(value: unknown): value is Array<[number, string]> { + if (!Array.isArray(value)) return false; + const tabIds = new Set(); + return value.every((entry) => { + if (!Array.isArray(entry) || entry.length !== 2) return false; + const [tabId, workspace] = entry; + if (!Number.isInteger(tabId) || tabId < 0 || typeof workspace !== 'string' || !workspace) return false; + if (tabIds.has(tabId)) return false; + tabIds.add(tabId); + return true; + }); +} + +function hasValidTabActivities(value: unknown): value is Array<[number, TabGroupTabActivity]> { + if (!Array.isArray(value)) return false; + const tabIds = new Set(); + return value.every((entry) => { + if (!Array.isArray(entry) || entry.length !== 2) return false; + const [tabId, activity] = entry; + if (!Number.isInteger(tabId) || tabId < 0 || !activity || typeof activity !== 'object') return false; + const { lastOperationAt, expiresAt } = activity as Partial; + if ( + typeof lastOperationAt !== 'number' + || typeof expiresAt !== 'number' + || !Number.isFinite(lastOperationAt) + || !Number.isFinite(expiresAt) + || expiresAt < lastOperationAt + ) return false; + if (tabIds.has(tabId)) return false; + tabIds.add(tabId); + return true; + }); +} + +function serializeTabGroupSession(session: TabGroupSession): StoredTabGroupSession { + return { + groupId: session.groupId, + windowId: session.windowId, + title: session.title, + ownedTabIds: [...session.ownedTabIds], + workspaceTabIds: [...session.workspaceTabIds.entries()], + tabWorkspaceOwners: [...session.tabWorkspaceOwners.entries()], + tabActivities: [...session.tabActivities.entries()].map(([tabId, activity]) => [tabId, { ...activity }]), + leaseId: session.leaseId, + idleDeadlineAt: session.idleDeadlineAt, + }; +} + +function isStoredTabGroupSession(value: unknown): value is StoredTabGroupSession { + if (!value || typeof value !== 'object') return false; + const session = value as Partial; + return Number.isInteger(session.groupId) + && Number.isInteger(session.windowId) + && typeof session.title === 'string' + && Array.isArray(session.ownedTabIds) + && session.ownedTabIds.every((tabId) => Number.isInteger(tabId)) + && (session.workspaceTabIds === undefined || hasValidWorkspaceTabBindings(session.workspaceTabIds)) + && (session.tabWorkspaceOwners === undefined || hasValidTabWorkspaceOwners(session.tabWorkspaceOwners)) + && (session.tabActivities === undefined || hasValidTabActivities(session.tabActivities)) + && (session.leaseId === undefined || (typeof session.leaseId === 'string' && session.leaseId.length > 0)) + && Number.isFinite(session.idleDeadlineAt); +} + +function enqueueTabGroupSessionStorageWrite(key: string, operation: () => Promise): Promise { + const previous = tabGroupSessionStorageWrites.get(key) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(operation); + const settled = next.then( + () => undefined, + () => undefined, + ); + tabGroupSessionStorageWrites.set(key, settled); + void settled.then(() => { + if (tabGroupSessionStorageWrites.get(key) === settled) tabGroupSessionStorageWrites.delete(key); + }); + return next; +} + +function enqueueTabGroupSessionMutation(key: string, operation: () => Promise): Promise { + const previous = tabGroupSessionMutations.get(key) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(operation); + const settled = next.then( + () => undefined, + () => undefined, + ); + tabGroupSessionMutations.set(key, settled); + void settled.then(() => { + if (tabGroupSessionMutations.get(key) === settled) tabGroupSessionMutations.delete(key); + }); + return next; +} + +function withTabGroupCleanupLease(key: string, operation: () => Promise): Promise { + const previous = tabGroupCleanupLeases.get(key) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(operation); + const settled = next.then( + () => undefined, + () => undefined, + ); + tabGroupCleanupLeases.set(key, settled); + void settled.then(() => { + if (tabGroupCleanupLeases.get(key) === settled) tabGroupCleanupLeases.delete(key); + }); + return next; +} + +async function waitForTabGroupCleanupLease(key: string): Promise { + const pending = tabGroupCleanupLeases.get(key); + if (pending) await pending; +} + +async function retryTabGroupStorageOperation(operation: () => Promise): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < TAB_GROUP_STORAGE_RETRY_ATTEMPTS; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + // Let Chrome finish a transient internal storage operation before retrying. + if (attempt + 1 < TAB_GROUP_STORAGE_RETRY_ATTEMPTS) await Promise.resolve(); + } + } + throw lastError; +} + +function persistTabGroupSession(key: string, session: TabGroupSession): Promise { + return enqueueTabGroupSessionStorageWrite(key, async () => { + if (tabGroupSessions.get(key) !== session) return; + await retryTabGroupStorageOperation(() => chrome.storage.session.set({ + [getTabGroupSessionStorageKey(key)]: serializeTabGroupSession(session), + })); + }); +} + +type PersistedTabGroupSessionRemoval = 'removed' | 'missing' | 'replaced'; + +function removePersistedTabGroupSession( + key: string, + leaseId: string, +): Promise { + return enqueueTabGroupSessionStorageWrite(key, async () => { + const storageKey = getTabGroupSessionStorageKey(key); + const current = (await retryTabGroupStorageOperation(() => chrome.storage.session.get(storageKey)))[storageKey]; + if (current === undefined) return 'missing'; + if (!isStoredTabGroupSession(current) || current.leaseId !== leaseId) return 'replaced'; + await retryTabGroupStorageOperation(() => chrome.storage.session.remove(storageKey)); + return 'removed'; + }); +} + +async function readPersistedTabGroupSession(key: string): Promise { + const storageKey = getTabGroupSessionStorageKey(key); + const stored = (await retryTabGroupStorageOperation(() => chrome.storage.session.get(storageKey)))[storageKey]; + return isStoredTabGroupSession(stored) ? stored : null; +} + +async function removeStoredTabGroupRecordIfStillInvalid(storageKey: string, key?: string): Promise { + await enqueueTabGroupSessionStorageWrite(key ?? storageKey, async () => { + const current = (await retryTabGroupStorageOperation(() => chrome.storage.session.get(storageKey)))[storageKey]; + const isCurrentRecordValid = key !== undefined + && isStoredTabGroupSession(current) + && current.title === key + && isCanonicalTabGroupSessionKey(key); + if (current !== undefined && !isCurrentRecordValid) { + await retryTabGroupStorageOperation(() => chrome.storage.session.remove(storageKey)); + } + }); +} + +function getTabGroupIdleTimeout(cmd: Command): number { + const seconds = cmd.groupIdleTimeoutSeconds; + if (seconds === undefined) return WINDOW_IDLE_TIMEOUT; + if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('strict-background group idle timeout must be positive'); + return seconds * 1000; +} + +function validateStrictBackgroundCommand(cmd: Command): void { + if (!isTabGroupCarrier(cmd)) return; + getGroupTitle(cmd); + getTabGroupIdleTimeout(cmd); +} + function resetWindowIdleTimer(workspace: string): void { const session = automationSessions.get(workspace); if (!session) return; @@ -169,6 +512,134 @@ function resetWindowIdleTimer(workspace: string): void { }, WINDOW_IDLE_TIMEOUT); } +function armTabGroupCleanup(key: string, session: TabGroupSession, deadlineAt: number): void { + if (session.idleTimer) clearTimeout(session.idleTimer); + session.idleDeadlineAt = deadlineAt; + chrome.alarms.create(getTabGroupCleanupAlarmName(key), { when: deadlineAt }); + session.idleTimer = setTimeout(() => { + void cleanupTabGroupSession(key, deadlineAt); + }, Math.max(0, deadlineAt - Date.now())); +} + +function getNextTabGroupCleanupDeadline(session: TabGroupSession): number | null { + let nextDeadlineAt: number | null = null; + for (const tabId of session.ownedTabIds) { + const deadlineAt = session.tabActivities.get(tabId)?.expiresAt; + if (deadlineAt === undefined) continue; + if (nextDeadlineAt === null || deadlineAt < nextDeadlineAt) nextDeadlineAt = deadlineAt; + } + return nextDeadlineAt; +} + +function armTabGroupCleanupForOwnedTabs(key: string, session: TabGroupSession): number | null { + const nextDeadlineAt = getNextTabGroupCleanupDeadline(session); + if (nextDeadlineAt !== null) armTabGroupCleanup(key, session, nextDeadlineAt); + return nextDeadlineAt; +} + +function recordTabGroupTabActivity(session: TabGroupSession, tabId: number, cmd: Command): void { + const lastOperationAt = Date.now(); + session.tabActivities.set(tabId, { + lastOperationAt, + expiresAt: lastOperationAt + getTabGroupIdleTimeout(cmd), + }); +} + +function scheduleTabGroupCleanupRetry(key: string, session?: TabGroupSession): void { + const retryAt = Date.now() + TAB_GROUP_CLEANUP_RETRY_DELAY; + chrome.alarms.create(getTabGroupCleanupAlarmName(key), { when: retryAt }); + if (!session) return; + + if (session.idleTimer) clearTimeout(session.idleTimer); + session.idleTimer = setTimeout(() => { + void cleanupTabGroupSession(key); + }, Math.max(0, retryAt - Date.now())); +} + +function scheduleOrphanTabClose( + key: string, + session: Pick, + tabId: number, +): void { + chrome.alarms.create(getTabGroupOrphanTabCloseAlarmName(key, session.leaseId, session.groupId, tabId), { + when: Date.now() + TAB_GROUP_CLEANUP_RETRY_DELAY, + }); +} + +async function clearOrphanTabCloseAlarm(alarmName: string): Promise { + try { + await chrome.alarms.clear(alarmName); + } catch { + // The one-shot alarm may already be gone. + } +} + +async function closeOrphanedTab(key: string, leaseId: string, groupId: number, tabId: number): Promise { + const alarmName = getTabGroupOrphanTabCloseAlarmName(key, leaseId, groupId, tabId); + let stored: StoredTabGroupSession | null; + try { + stored = await readPersistedTabGroupSession(key); + } catch { + // No durable ownership proof means the alarm cannot safely delete a tab. + await clearOrphanTabCloseAlarm(alarmName); + return; + } + if ( + !stored + || stored.leaseId !== leaseId + || stored.groupId !== groupId + || !stored.ownedTabIds.includes(tabId) + || !stored.tabWorkspaceOwners?.some(([ownedTabId]) => ownedTabId === tabId) + ) { + await clearOrphanTabCloseAlarm(alarmName); + return; + } + let tab: chrome.tabs.Tab; + try { + tab = await chrome.tabs.get(tabId); + } catch { + await clearOrphanTabCloseAlarm(alarmName); + return; + } + if (tab.groupId !== groupId) { + await clearOrphanTabCloseAlarm(alarmName); + return; + } + + let groupState: TabGroupSessionState; + try { + groupState = await getTabGroupSessionState({ + groupId, + windowId: tab.windowId, + title: key, + ownedTabIds: new Set([tabId]), + workspaceTabIds: new Map(), + tabWorkspaceOwners: new Map(), + tabActivities: new Map(), + leaseId: '', + idleTimer: null, + idleDeadlineAt: 0, + }); + } catch { + groupState = 'unavailable'; + } + if (groupState === 'missing') { + await clearOrphanTabCloseAlarm(alarmName); + return; + } + if (groupState === 'unavailable') { + await clearOrphanTabCloseAlarm(alarmName); + return; + } + + try { + await chrome.tabs.remove(tabId); + await clearOrphanTabCloseAlarm(alarmName); + } catch { + scheduleOrphanTabClose(key, { groupId, leaseId }, tabId); + } +} + /** Get or create the dedicated automation window. * @param initialUrl — if provided (http/https), used as the initial page instead of about:blank. * This avoids an extra blank-page→target-domain navigation on first command. @@ -241,6 +712,27 @@ chrome.windows.onRemoved.addListener((windowId) => { } }); +// Keep the ownership ledger small when a user or Chrome closes one of our +// tabs before the next command or idle cleanup reconciliation runs. +chrome.tabs.onRemoved.addListener((tabId) => { + for (const [key, session] of tabGroupSessions.entries()) { + if (!session.ownedTabIds.has(tabId)) continue; + void enqueueTabGroupSessionMutation(key, async () => { + if (tabGroupSessions.get(key) !== session || !session.ownedTabIds.delete(tabId)) return; + removeTabGroupTabMetadata(session, tabId); + const nextDeadlineAt = armTabGroupCleanupForOwnedTabs(key, session); + if (nextDeadlineAt === null) armTabGroupCleanup(key, session, Date.now()); + try { + await persistTabGroupSession(key, session); + } catch { + // A later command or cleanup reconciliation retries without inferring + // ownership from the other tabs in the group. + scheduleTabGroupCleanupRetry(key, session); + } + }); + } +}); + // ─── Lifecycle events ──────────────────────────────────────────────── let initialized = false; @@ -263,9 +755,25 @@ chrome.runtime.onStartup.addListener(() => { }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === 'keepalive') void connect(); + if (alarm.name === 'keepalive') { + void connect(); + return; + } + if (alarm.name === TAB_GROUP_RESTORE_RETRY_ALARM) { + void restoreTabGroupCleanupAlarms(); + return; + } + const orphanTab = getTabGroupOrphanTabCloseAlarm(alarm.name); + if (orphanTab) { + void closeOrphanedTab(orphanTab.key, orphanTab.leaseId, orphanTab.groupId, orphanTab.tabId); + return; + } + const sessionKey = getTabGroupSessionKeyFromAlarmName(alarm.name); + if (sessionKey) void cleanupTabGroupSession(sessionKey); }); +void restoreTabGroupCleanupAlarms(); + // ─── Popup status API ─────────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { @@ -282,9 +790,31 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { async function handleCommand(cmd: Command): Promise { const workspace = getWorkspaceKey(cmd.workspace); - // Reset idle timer on every command (window stays alive while active) - resetWindowIdleTimer(workspace); + let tabGroupActivityKey: string | undefined; try { + validateStrictBackgroundCommand(cmd); + + // Strict-background cleanup is still fenced per group while a command runs, + // but timestamps are updated only for the tab that the command actually + // operates on. A list or another workspace's command must not extend every + // tab in the shared physical group. + if (isTabGroupCarrier(cmd)) { + // This synchronous fence is visible to an in-flight cleanup before it can + // issue the next tabs.remove call, even while renewal is queued behind it. + tabGroupActivityKey = getTabGroupSessionKey(getGroupTitle(cmd)); + beginTabGroupCommand(tabGroupActivityKey); + // A cleanup that already submitted chrome.tabs.remove cannot be cancelled. + // Wait for that same-title cleanup to finish before resolving a tab, so a + // later command never starts using a tab Chrome is concurrently deleting. + await waitForTabGroupCleanupLease(tabGroupActivityKey); + // Enforce the title's single-group invariant for every strict command, + // including read-only list and close-window paths that do not create a + // session themselves. + await findUniqueTabGroupsByTitle(getGroupTitle(cmd)); + } else { + resetWindowIdleTimer(workspace); + } + switch (cmd.action) { case 'exec': return await handleExec(cmd, workspace); @@ -315,6 +845,14 @@ async function handleCommand(cmd: Command): Promise { ok: false, error: err instanceof Error ? err.message : String(err), }; + } finally { + if (tabGroupActivityKey) { + endTabGroupCommand(tabGroupActivityKey); + // An alarm may have fired while this command held the cleanup fence. Run + // the per-tab expiry sweep once the command releases it instead of + // renewing unrelated tabs in the same group. + cleanupExpiredTabGroupTabsAfterCommand(tabGroupActivityKey); + } } } @@ -365,11 +903,910 @@ function setWorkspaceSession(workspace: string, session: Pick { + try { + const lastFocused = await chrome.windows.getLastFocused({ windowTypes: ['normal'] }); + if (lastFocused.id !== undefined) return lastFocused.id; + } catch { + // Fall through to getAll. + } + const windows = await chrome.windows.getAll({ windowTypes: ['normal'] }); + const host = windows.find((win) => win.id !== undefined); + if (host?.id === undefined) { + throw new Error('strict-background mode requires an existing normal Chrome window'); + } + return host.id; +} + +type TabGroupSessionState = 'live' | 'missing' | 'unavailable'; + +function isCanonicalTabGroupSessionKey(key: string): boolean { + return key.startsWith('AutoCLI-') && !key.includes('\n') && !key.includes('\r'); +} + +function isCurrentTabGroupCleanup(key: string, session: TabGroupSession, deadlineAt: number): boolean { + return tabGroupSessions.get(key) === session && session.idleDeadlineAt === deadlineAt; +} + +function isCurrentTabGroupCleanupAttempt( + key: string, + session: TabGroupSession, + deadlineAt: number, + activityGeneration: number, +): boolean { + return isCurrentTabGroupCleanup(key, session, deadlineAt) + && isTabGroupActivityCurrent(key, activityGeneration) + && !isTabGroupCommandActive(key); +} + +function getLiveOwnedTabIds(tabs: chrome.tabs.Tab[], ownedTabIds: Set): number[] { + return tabs + .map((tab) => tab.id) + .filter((tabId): tabId is number => tabId !== undefined && ownedTabIds.has(tabId)); +} + +function removeWorkspaceTabBindingsForTab(session: TabGroupSession, tabId: number): boolean { + let changed = false; + for (const [workspace, boundTabId] of session.workspaceTabIds.entries()) { + if (boundTabId !== tabId) continue; + session.workspaceTabIds.delete(workspace); + changed = true; + } + return changed; +} + +function removeTabWorkspaceOwner(session: TabGroupSession, tabId: number): boolean { + const removed = session.tabWorkspaceOwners.delete(tabId); + return removeWorkspaceTabBindingsForTab(session, tabId) || removed; +} + +function removeTabGroupTabMetadata(session: TabGroupSession, tabId: number): boolean { + const removedActivity = session.tabActivities.delete(tabId); + return removeTabWorkspaceOwner(session, tabId) || removedActivity; +} + +function createLegacyTabActivity(expiresAt: number): TabGroupTabActivity { + // Older records only had a group-level deadline. Preserve that deadline as + // the migration boundary instead of inventing a newer last-operation time. + return { lastOperationAt: expiresAt, expiresAt }; +} + +function ensureOwnedTabActivities(session: TabGroupSession): boolean { + let changed = false; + for (const tabId of session.ownedTabIds) { + if (session.tabActivities.has(tabId)) continue; + session.tabActivities.set(tabId, createLegacyTabActivity(session.idleDeadlineAt)); + changed = true; + } + for (const tabId of session.tabActivities.keys()) { + if (session.ownedTabIds.has(tabId)) continue; + session.tabActivities.delete(tabId); + changed = true; + } + return changed; +} + +function pruneWorkspaceTabBindings(session: TabGroupSession, liveOwnedTabIds: Set): boolean { + let changed = false; + for (const [workspace, tabId] of session.workspaceTabIds.entries()) { + if (liveOwnedTabIds.has(tabId)) continue; + session.workspaceTabIds.delete(workspace); + changed = true; + } + return changed; +} + +function pruneTabWorkspaceOwners(session: TabGroupSession, liveOwnedTabIds: Set): boolean { + let changed = false; + for (const tabId of session.tabWorkspaceOwners.keys()) { + if (liveOwnedTabIds.has(tabId)) continue; + session.tabWorkspaceOwners.delete(tabId); + changed = true; + } + return changed; +} + +function pruneTabActivities(session: TabGroupSession, liveOwnedTabIds: Set): boolean { + let changed = false; + for (const tabId of session.tabActivities.keys()) { + if (liveOwnedTabIds.has(tabId)) continue; + session.tabActivities.delete(tabId); + changed = true; + } + return changed; +} + +async function getTabGroupSessionState(session: TabGroupSession): Promise { + try { + const group = await chrome.tabGroups.get(session.groupId); + return group.title === session.title ? 'live' : 'missing'; + } catch { + try { + const groups = await chrome.tabGroups.query({ title: session.title }); + return groups.some((group) => group.id === session.groupId && group.title === session.title) + ? 'live' + : 'missing'; + } catch { + return 'unavailable'; + } + } +} + +async function reconcileTabGroupSession(key: string, session: TabGroupSession): Promise { + const groupState = await getTabGroupSessionState(session); + if (groupState !== 'live') return groupState; + + const knownOwnedTabIds = new Set(session.ownedTabIds); + let groupTabs: chrome.tabs.Tab[]; + try { + groupTabs = await chrome.tabs.query({ groupId: session.groupId }); + } catch { + return 'unavailable'; + } + + const liveOwnedTabIds = new Set(getLiveOwnedTabIds(groupTabs, knownOwnedTabIds)); + const staleOwnedTabIds = [...knownOwnedTabIds].filter((tabId) => !liveOwnedTabIds.has(tabId)); + const prunedWorkspaceBindings = pruneWorkspaceTabBindings(session, liveOwnedTabIds); + const prunedTabOwners = pruneTabWorkspaceOwners(session, liveOwnedTabIds); + const prunedTabActivities = pruneTabActivities(session, liveOwnedTabIds); + if (staleOwnedTabIds.length > 0) { + for (const tabId of staleOwnedTabIds) session.ownedTabIds.delete(tabId); + } + const restoredMissingActivities = ensureOwnedTabActivities(session); + const nextDeadlineAt = getNextTabGroupCleanupDeadline(session); + const deadlineChanged = nextDeadlineAt !== null && nextDeadlineAt !== session.idleDeadlineAt; + if (deadlineChanged && nextDeadlineAt !== null) { + armTabGroupCleanup(key, session, nextDeadlineAt); + } + if ( + staleOwnedTabIds.length > 0 + || prunedWorkspaceBindings + || prunedTabOwners + || prunedTabActivities + || restoredMissingActivities + || deadlineChanged + ) { + await persistTabGroupSession(key, session); + } + return 'live'; +} + +async function retryTabGroupCleanup( + key: string, + session: TabGroupSession, + expectedDeadlineAt?: number, +): Promise { + if (expectedDeadlineAt !== undefined && !isCurrentTabGroupCleanup(key, session, expectedDeadlineAt)) return; + // A retry does not change the durable deadline. A storage outage must not + // turn into an untracked session or grant cleanup permission over user tabs. + scheduleTabGroupCleanupRetry(key, session); + console.warn(`[autocli] Retrying cleanup of tab group ${session.title}`); +} + +async function restoreTabGroupSessionAfterDiscardRace( + key: string, + session: TabGroupSession, +): Promise { + if (tabGroupSessions.get(key) !== session) return; + try { + await persistTabGroupSession(key, session); + } catch { + scheduleTabGroupCleanupRetry(key, session); + return; + } + if (tabGroupSessions.get(key) !== session) return; + + const nextDeadlineAt = getNextTabGroupCleanupDeadline(session); + if (nextDeadlineAt !== null && nextDeadlineAt > Date.now()) { + armTabGroupCleanup(key, session, nextDeadlineAt); + } else { + scheduleTabGroupCleanupRetry(key, session); + } +} + +async function discardTabGroupSession( + key: string, + session?: TabGroupSession, + expectedDeadlineAt?: number, + expectedActivityGeneration?: number, +): Promise { + const activityChanged = (): boolean => expectedActivityGeneration !== undefined + && !isTabGroupActivityCurrent(key, expectedActivityGeneration); + const current = tabGroupSessions.get(key); + if (!session) return false; + if (session && current !== session) return false; + if (expectedDeadlineAt !== undefined && (!session || !isCurrentTabGroupCleanup(key, session, expectedDeadlineAt))) { + return false; + } + if (activityChanged()) return false; + if (current?.idleTimer) { + clearTimeout(current.idleTimer); + current.idleTimer = null; + } + + try { + const removal = await removePersistedTabGroupSession(key, session.leaseId); + if (removal === 'replaced') { + if (tabGroupSessions.get(key) === session) await restoreTabGroupSessionAfterDiscardRace(key, session); + return false; + } + } catch { + if (activityChanged()) return false; + if (session && tabGroupSessions.get(key) === session) { + await retryTabGroupCleanup(key, session, expectedDeadlineAt); + } + return false; + } + + if (activityChanged()) { + if (session) await restoreTabGroupSessionAfterDiscardRace(key, session); + return false; + } + if (session && tabGroupSessions.get(key) !== session) return false; + if (expectedDeadlineAt !== undefined && (!session || !isCurrentTabGroupCleanup(key, session, expectedDeadlineAt))) { + return false; + } + + try { + await chrome.alarms.clear(getTabGroupCleanupAlarmName(key)); + } catch { + // The alarm may already be gone. + } + + if (activityChanged()) { + if (session) await restoreTabGroupSessionAfterDiscardRace(key, session); + return false; + } + if (session && tabGroupSessions.get(key) !== session) return false; + if (expectedDeadlineAt !== undefined && (!session || !isCurrentTabGroupCleanup(key, session, expectedDeadlineAt))) { + const latest = tabGroupSessions.get(key); + if (latest) armTabGroupCleanupForOwnedTabs(key, latest); + return false; + } + + if (tabGroupSessions.get(key) === session) { + tabGroupSessions.delete(key); + tabGroupActivityGenerations.delete(key); + } + return true; +} + +async function getExistingTabGroupSession( + key: string, + expectedActivityGeneration?: number, +): Promise { + const existing = tabGroupSessions.get(key); + if (existing) { + const state = await reconcileTabGroupSession(key, existing); + if (state === 'missing') { + await discardTabGroupSession(key, existing, existing.idleDeadlineAt, expectedActivityGeneration); + return null; + } + return existing; + } + + const stored = await readPersistedTabGroupSession(key); + if (!stored || stored.title !== key || !isCanonicalTabGroupSessionKey(key)) return null; + + const concurrent = tabGroupSessions.get(key); + if (concurrent) return concurrent; + const restored: TabGroupSession = { + groupId: stored.groupId, + windowId: stored.windowId, + title: stored.title, + ownedTabIds: new Set(stored.ownedTabIds), + workspaceTabIds: new Map(stored.workspaceTabIds ?? []), + // Older ledgers cannot establish a safe owner for their tabs. Leave them + // unowned rather than adopting them from the shared group. + tabWorkspaceOwners: new Map(stored.tabWorkspaceOwners ?? []), + tabActivities: new Map(stored.tabActivities ?? stored.ownedTabIds.map((tabId) => [ + tabId, + createLegacyTabActivity(stored.idleDeadlineAt), + ])), + leaseId: stored.leaseId ?? createTabGroupSessionLeaseId(), + idleTimer: null, + idleDeadlineAt: stored.idleDeadlineAt, + }; + tabGroupSessions.set(key, restored); + + const state = await reconcileTabGroupSession(key, restored); + if (state === 'missing') { + await discardTabGroupSession(key, restored, restored.idleDeadlineAt, expectedActivityGeneration); + return null; + } + if ( + stored.workspaceTabIds === undefined + || stored.tabWorkspaceOwners === undefined + || stored.tabActivities === undefined + || stored.leaseId === undefined + ) { + await persistTabGroupSession(key, restored); + } + return restored; +} + +async function touchTabGroupTab(cmd: Command, workspace: string, tabId: number): Promise { + if (!isTabGroupCarrier(cmd)) return; + const key = getTabGroupSessionKey(getGroupTitle(cmd)); + await enqueueTabGroupSessionMutation(key, async () => { + const session = tabGroupSessions.get(key) ?? await getExistingTabGroupSession(key); + if ( + !session + || !session.ownedTabIds.has(tabId) + || session.tabWorkspaceOwners.get(tabId) !== workspace + ) { + throw new Error(`strict-background-stale-tab: Tab ${tabId} is no longer owned by workspace ${workspace}`); + } + + const previousDeadlineAt = session.idleDeadlineAt; + const previousActivity = session.tabActivities.get(tabId); + recordTabGroupTabActivity(session, tabId, cmd); + beginTabGroupActivity(key); + armTabGroupCleanupForOwnedTabs(key, session); + try { + await persistTabGroupSession(key, session); + } catch (error) { + if (previousActivity) { + session.tabActivities.set(tabId, previousActivity); + } else { + session.tabActivities.delete(tabId); + } + armTabGroupCleanup(key, session, previousDeadlineAt); + throw error; + } + }); +} + +async function touchWorkspaceTabOnStrictBackgroundRelease(cmd: Command, workspace: string): Promise { + const key = getTabGroupSessionKey(getGroupTitle(cmd)); + const session = await getExistingTabGroupSession(key); + if (!session) return; + const tabId = cmd.tabId ?? session.workspaceTabIds.get(workspace); + if (tabId === undefined || session.tabWorkspaceOwners.get(tabId) !== workspace) return; + await touchTabGroupTab(cmd, workspace, tabId); +} + +function cleanupExpiredTabGroupTabsAfterCommand(key: string): void { + const session = tabGroupSessions.get(key); + if (!session) return; + const nextDeadlineAt = getNextTabGroupCleanupDeadline(session); + if (nextDeadlineAt === null) return; + if (nextDeadlineAt <= Date.now()) { + void cleanupTabGroupSession(key); + } else { + armTabGroupCleanup(key, session, nextDeadlineAt); + } +} + +async function cleanupTabGroupSession(key: string, expectedDeadlineAt?: number): Promise { + return withTabGroupCleanupLease(key, () => cleanupTabGroupSessionLocked(key, expectedDeadlineAt)); +} + +async function cleanupTabGroupSessionLocked(key: string, expectedDeadlineAt?: number): Promise { + const activityGeneration = tabGroupActivityGenerations.get(key) ?? 0; + if (isTabGroupCommandActive(key)) { + const activeSession = tabGroupSessions.get(key); + if (activeSession) scheduleTabGroupCleanupRetry(key, activeSession); + return; + } + let current: TabGroupSession | null; + try { + current = await getExistingTabGroupSession(key, activityGeneration); + } catch { + scheduleTabGroupCleanupRetry(key, tabGroupSessions.get(key)); + return; + } + if (!isTabGroupActivityCurrent(key, activityGeneration)) { + if (isTabGroupCommandActive(key) && current) scheduleTabGroupCleanupRetry(key, current); + return; + } + if (!current) { + try { + await chrome.alarms.clear(getTabGroupCleanupAlarmName(key)); + } catch { + // The alarm may already be gone. + } + return; + } + + const deadlineAt = current.idleDeadlineAt; + if (expectedDeadlineAt !== undefined && expectedDeadlineAt !== deadlineAt) return; + if (!isCurrentTabGroupCleanupAttempt(key, current, deadlineAt, activityGeneration)) return; + if (Date.now() < deadlineAt) { + armTabGroupCleanup(key, current, deadlineAt); + try { + await persistTabGroupSession(key, current); + } catch { + // The existing durable deadline is unchanged, and the alarm remains armed. + } + return; + } + + let state: TabGroupSessionState; + try { + state = await reconcileTabGroupSession(key, current); + } catch { + if (!isCurrentTabGroupCleanupAttempt(key, current, deadlineAt, activityGeneration)) return; + await retryTabGroupCleanup(key, current, deadlineAt); + return; + } + if (!isCurrentTabGroupCleanupAttempt(key, current, deadlineAt, activityGeneration)) return; + if (state === 'missing') { + await discardTabGroupSession(key, current, deadlineAt, activityGeneration); + return; + } + if (state === 'unavailable') { + await retryTabGroupCleanup(key, current, deadlineAt); + return; + } + if (!isCurrentTabGroupCleanup(key, current, deadlineAt)) return; + + if (current.ownedTabIds.size === 0) { + if (await discardTabGroupSession(key, current, deadlineAt, activityGeneration)) { + console.log(`[autocli] Tab group ${current.title} released (no owned tabs)`); + } + return; + } + + let groupTabs: chrome.tabs.Tab[]; + try { + groupTabs = await chrome.tabs.query({ groupId: current.groupId }); + } catch { + if (!isCurrentTabGroupCleanupAttempt(key, current, deadlineAt, activityGeneration)) return; + await retryTabGroupCleanup(key, current, deadlineAt); + return; + } + if (!isCurrentTabGroupCleanupAttempt(key, current, deadlineAt, activityGeneration)) return; + + const now = Date.now(); + const expiredTabIds = getLiveOwnedTabIds(groupTabs, current.ownedTabIds) + .filter((tabId) => current!.tabActivities.get(tabId)?.expiresAt !== undefined + && current!.tabActivities.get(tabId)!.expiresAt <= now); + + if (expiredTabIds.length === 0) { + // An old alarm can race a per-tab touch or stale-ledger reconciliation. + // Recompute from the individual tab deadlines instead of deleting a tab + // merely because the previous group-level scheduler deadline elapsed. + const nextDeadlineAt = armTabGroupCleanupForOwnedTabs(key, current); + if (nextDeadlineAt !== null) { + try { + await persistTabGroupSession(key, current); + } catch { + await retryTabGroupCleanup(key, current, deadlineAt); + } + } + return; + } + + for (const tabId of expiredTabIds) { + if (!isCurrentTabGroupCleanupAttempt(key, current, deadlineAt, activityGeneration)) return; + try { + // Recheck membership immediately before removal. A user may have moved + // this tab out of the group after the group query; in that case the + // following reconciliation only drops the AutoCLI ledger entry. + const tab = await chrome.tabs.get(tabId); + if (tab.groupId !== current.groupId) continue; + await chrome.tabs.remove(tabId); + } catch { + // The tab may have been closed after the live-tab query. + } + } + + let remainingState: TabGroupSessionState; + try { + remainingState = await reconcileTabGroupSession(key, current); + } catch { + if (!isCurrentTabGroupCleanupAttempt(key, current, deadlineAt, activityGeneration)) return; + await retryTabGroupCleanup(key, current, deadlineAt); + return; + } + if (!isCurrentTabGroupCleanupAttempt(key, current, deadlineAt, activityGeneration)) return; + if (remainingState === 'missing') { + await discardTabGroupSession(key, current, deadlineAt, activityGeneration); + return; + } + if (remainingState === 'unavailable') { + await retryTabGroupCleanup(key, current, deadlineAt); + return; + } + if (!isCurrentTabGroupCleanupAttempt(key, current, deadlineAt, activityGeneration)) return; + + if (current.ownedTabIds.size > 0) { + const nextDeadlineAt = getNextTabGroupCleanupDeadline(current); + if (nextDeadlineAt !== null && nextDeadlineAt > Date.now()) { + armTabGroupCleanup(key, current, nextDeadlineAt); + try { + await persistTabGroupSession(key, current); + } catch { + await retryTabGroupCleanup(key, current, deadlineAt); + } + } else { + await retryTabGroupCleanup(key, current, deadlineAt); + } + return; + } + + if (await discardTabGroupSession(key, current, deadlineAt, activityGeneration)) { + console.log(`[autocli] Tab group ${current.title} released (idle timeout)`); + } +} + +async function restoreTabGroupCleanupAlarms(): Promise { + try { + const records = await retryTabGroupStorageOperation(() => chrome.storage.session.get()); + for (const [storageKey, value] of Object.entries(records)) { + if (!storageKey.startsWith(TAB_GROUP_SESSION_STORAGE_PREFIX)) continue; + let key: string; + try { + key = decodeURIComponent(storageKey.slice(TAB_GROUP_SESSION_STORAGE_PREFIX.length)); + } catch { + await removeStoredTabGroupRecordIfStillInvalid(storageKey); + continue; + } + if (!isStoredTabGroupSession(value) || value.title !== key || !isCanonicalTabGroupSessionKey(key)) { + await removeStoredTabGroupRecordIfStillInvalid(storageKey, key); + continue; + } + + const session = await getExistingTabGroupSession(key); + if (!session) continue; + if (session.idleDeadlineAt <= Date.now()) { + void cleanupTabGroupSession(key); + } else { + armTabGroupCleanup(key, session, session.idleDeadlineAt); + } + } + } catch { + // Storage can be temporarily unavailable while Chrome starts the extension. + chrome.alarms.create(TAB_GROUP_RESTORE_RETRY_ALARM, { + when: Date.now() + TAB_GROUP_CLEANUP_RETRY_DELAY, + }); + } +} + +async function abandonUnpersistedTabGroupSession(key: string, session: TabGroupSession): Promise { + if (tabGroupSessions.get(key) !== session) return; + if (session.idleTimer) { + clearTimeout(session.idleTimer); + session.idleTimer = null; + } + tabGroupSessions.delete(key); + tabGroupActivityGenerations.delete(key); + try { + await chrome.alarms.clear(getTabGroupCleanupAlarmName(key)); + } catch { + // The alarm may already be gone. + } + try { + await removePersistedTabGroupSession(key, session.leaseId); + } catch { + // If a failed set actually reached Chrome, a later reconciliation removes + // the stale ID after this command's created tab has been closed. + } +} + +async function rollbackNewOwnedTabRegistration( + key: string, + session: TabGroupSession, + tabId: number, +): Promise { + try { + await chrome.tabs.remove(tabId); + } catch { + // Retain the ID when the tab cannot be closed. A retry alarm is only safe + // after the failed tab's ownership has been durably recorded. + try { + await persistTabGroupSession(key, session); + scheduleOrphanTabClose(key, session, tabId); + } catch { + // The command still fails; we never infer ownership from group membership. + } + return; + } + + session.ownedTabIds.delete(tabId); + removeTabGroupTabMetadata(session, tabId); + const nextDeadlineAt = armTabGroupCleanupForOwnedTabs(key, session); + if (nextDeadlineAt === null) armTabGroupCleanup(key, session, Date.now()); + try { + await persistTabGroupSession(key, session); + } catch { + // A stale stored ID is harmless: reconciliation only removes IDs that were + // already recorded as AutoCLI-owned and are no longer in the live group. + } +} + +async function registerNewOwnedTab( + key: string, + session: TabGroupSession, + tabId: number, + cmd: Command, + workspace?: string, +): Promise { + await enqueueTabGroupSessionMutation(key, async () => { + if (tabGroupSessions.get(key) !== session) { + try { + await chrome.tabs.remove(tabId); + } catch { + // No current session can authorize a cross-restart retry here. + } + throw new Error(`strict-background group ${session.title} was released before the new tab could be registered`); + } + + session.ownedTabIds.add(tabId); + if (workspace) { + session.workspaceTabIds.set(workspace, tabId); + session.tabWorkspaceOwners.set(tabId, workspace); + } + const previousDeadlineAt = session.idleDeadlineAt; + const previousActivity = session.tabActivities.get(tabId); + recordTabGroupTabActivity(session, tabId, cmd); + beginTabGroupActivity(key); + armTabGroupCleanupForOwnedTabs(key, session); + try { + await persistTabGroupSession(key, session); + } catch (error) { + if (previousActivity) { + session.tabActivities.set(tabId, previousActivity); + } else { + session.tabActivities.delete(tabId); + } + armTabGroupCleanup(key, session, previousDeadlineAt); + await rollbackNewOwnedTabRegistration(key, session, tabId); + throw error; + } + }); +} + +async function findUniqueTabGroupsByTitle(title: string) { + const exactGroups = (await chrome.tabGroups.query({ title })) + .filter((group) => group.id !== undefined && group.title === title); + if (exactGroups.length > 1) { + throw new Error( + `strict-background found multiple open tab groups named ${title}; refusing to choose one`, + ); + } + return exactGroups; +} + +async function createTabGroupSession(title: string, key: string, cmd: Command): Promise { + const existing = await getExistingTabGroupSession(key); + if (existing) { + await findUniqueTabGroupsByTitle(title); + armTabGroupCleanupForOwnedTabs(key, existing); + return existing; + } + + const exactGroups = await findUniqueTabGroupsByTitle(title); + const reusable = exactGroups[0]; + if (reusable?.id !== undefined) { + const groupTabs = await chrome.tabs.query({ groupId: reusable.id }); + const windowId = reusable.windowId ?? groupTabs[0]?.windowId; + if (windowId === undefined) { + throw new Error(`strict-background group ${title} has no normal host window`); + } + const session: TabGroupSession = { + groupId: reusable.id, + windowId, + title, + ownedTabIds: new Set(), + workspaceTabIds: new Map(), + tabWorkspaceOwners: new Map(), + tabActivities: new Map(), + leaseId: createTabGroupSessionLeaseId(), + idleTimer: null, + idleDeadlineAt: Date.now() + getTabGroupIdleTimeout(cmd), + }; + tabGroupSessions.set(key, session); + try { + armTabGroupCleanup(key, session, session.idleDeadlineAt); + await persistTabGroupSession(key, session); + } catch (error) { + await abandonUnpersistedTabGroupSession(key, session); + throw error; + } + return session; + } + + const windowId = await getHostWindowId(); + const seedTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: false }); + if (!seedTab.id) throw new Error('Failed to create strict-background seed tab'); + let groupId: number | undefined; + let session: TabGroupSession | undefined; + try { + groupId = await chrome.tabs.group({ tabIds: [seedTab.id] }); + await chrome.tabGroups.update(groupId, { title, color: 'blue', collapsed: true }); + const confirmedGroups = await findUniqueTabGroupsByTitle(title); + if (!confirmedGroups.some((group) => group.id === groupId)) { + throw new Error(`strict-background group ${title} disappeared while it was being created`); + } + const lastOperationAt = Date.now(); + session = { + groupId, + windowId, + title, + ownedTabIds: new Set([seedTab.id]), + workspaceTabIds: new Map([[getWorkspaceKey(cmd.workspace), seedTab.id]]), + tabWorkspaceOwners: new Map([[seedTab.id, getWorkspaceKey(cmd.workspace)]]), + tabActivities: new Map([[seedTab.id, { + lastOperationAt, + expiresAt: lastOperationAt + getTabGroupIdleTimeout(cmd), + }]]), + leaseId: createTabGroupSessionLeaseId(), + idleTimer: null, + idleDeadlineAt: lastOperationAt + getTabGroupIdleTimeout(cmd), + }; + tabGroupSessions.set(key, session); + beginTabGroupActivity(key); + armTabGroupCleanupForOwnedTabs(key, session); + await persistTabGroupSession(key, session); + return session; + } catch (error) { + if (session) await abandonUnpersistedTabGroupSession(key, session); + try { + await chrome.tabs.remove(seedTab.id); + } catch { + // Only retry when Chrome confirms this newly created tab belongs to the + // expected AutoCLI group; otherwise a reused tab ID must never be closed. + // No persisted session exists yet, so a durable retry alarm would not + // have authority to close this tab after a restart. + } + throw error; + } +} + +async function getTabGroupSession(cmd: Command): Promise { + const title = getGroupTitle(cmd); + const key = getTabGroupSessionKey(title); + const existing = await getExistingTabGroupSession(key); + if (existing) { + await findUniqueTabGroupsByTitle(title); + armTabGroupCleanupForOwnedTabs(key, existing); + return existing; + } + + const pending = tabGroupSessionLocks.get(key); + if (pending) { + const session = await pending; + await findUniqueTabGroupsByTitle(title); + armTabGroupCleanupForOwnedTabs(key, session); + return session; + } + + const creation = createTabGroupSession(title, key, cmd); + tabGroupSessionLocks.set(key, creation); + try { + return await creation; + } finally { + if (tabGroupSessionLocks.get(key) === creation) { + tabGroupSessionLocks.delete(key); + } + } +} + +async function clearWorkspaceTabBinding( + key: string, + session: TabGroupSession, + workspace: string, + tabId: number, +): Promise { + if (session.workspaceTabIds.get(workspace) !== tabId) return; + await enqueueTabGroupSessionMutation(key, async () => { + if (tabGroupSessions.get(key) !== session || session.workspaceTabIds.get(workspace) !== tabId) return; + session.workspaceTabIds.delete(workspace); + await persistTabGroupSession(key, session); + }); +} + +async function pruneStaleTabGroupTab( + key: string, + session: TabGroupSession, + tabId: number, +): Promise { + await enqueueTabGroupSessionMutation(key, async () => { + if (tabGroupSessions.get(key) !== session) return; + const removed = session.ownedTabIds.delete(tabId); + const removedMetadata = removeTabGroupTabMetadata(session, tabId); + if (!removed && !removedMetadata) return; + const nextDeadlineAt = armTabGroupCleanupForOwnedTabs(key, session); + if (nextDeadlineAt === null) armTabGroupCleanup(key, session, Date.now()); + await persistTabGroupSession(key, session); + }); +} + +async function resolveTabGroupTab( + tabId: number | undefined, + workspace: string, + cmd: Command, + initialUrl?: string, +): Promise { + const key = getTabGroupSessionKey(getGroupTitle(cmd)); + const session = await getTabGroupSession(cmd); + + if (tabId !== undefined) { + const owner = session.tabWorkspaceOwners.get(tabId); + if (owner !== workspace) { + if (owner !== undefined) { + throw new Error(`strict-background-tab-not-owned: Tab ${tabId} belongs to workspace ${owner}`); + } + throw new Error(`strict-background-stale-tab: Tab ${tabId} is not owned by workspace ${workspace}`); + } + + let tab: chrome.tabs.Tab; + try { + tab = await chrome.tabs.get(tabId); + } catch { + await pruneStaleTabGroupTab(key, session, tabId); + throw new Error(`strict-background-stale-tab: Tab ${tabId} no longer exists`); + } + if (tab.groupId !== session.groupId) { + await pruneStaleTabGroupTab(key, session, tabId); + throw new Error(`strict-background-stale-tab: Tab ${tabId} is no longer in group ${session.title}`); + } + if (!session.ownedTabIds.has(tabId)) { + await pruneStaleTabGroupTab(key, session, tabId); + throw new Error(`strict-background-stale-tab: Tab ${tabId} is no longer owned by AutoCLI`); + } + if (!isDebuggableUrl(tab.url)) { + throw new Error(`Tab ${tabId} URL is not debuggable (${tab.url})`); + } + return { tabId, tab }; + } + + const boundTabId = session.workspaceTabIds.get(workspace); + if (boundTabId !== undefined) { + try { + const boundTab = await chrome.tabs.get(boundTabId); + if ( + boundTab.groupId === session.groupId + && session.ownedTabIds.has(boundTabId) + && session.tabWorkspaceOwners.get(boundTabId) === workspace + && isDebuggableUrl(boundTab.url) + ) { + return { tabId: boundTabId, tab: boundTab }; + } + if (boundTab.groupId !== session.groupId) { + await pruneStaleTabGroupTab(key, session, boundTabId); + } else { + await clearWorkspaceTabBinding(key, session, workspace, boundTabId); + } + } catch { + // The tab can disappear after reconciliation but before this lookup. + // Prune the whole ownership record so the current command can recreate + // the workspace's background tab instead of surfacing a stale-ID error. + await pruneStaleTabGroupTab(key, session, boundTabId); + } + } + + // Never pick an arbitrary owned tab from a shared group. A different logical + // caller may own it, so an unbound workspace always gets a fresh background tab. + const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + const tab = await chrome.tabs.create({ windowId: session.windowId, url: startUrl, active: false }); + if (!tab.id) throw new Error('Failed to create strict-background tab'); + try { + await chrome.tabs.group({ groupId: session.groupId, tabIds: [tab.id] }); + } catch (error) { + try { + await chrome.tabs.remove(tab.id); + } catch { + // This tab was not registered, so it has no durable ownership proof for + // a cross-restart retry alarm. + } + throw error; + } + await registerNewOwnedTab(key, session, tab.id, cmd, workspace); + return { tabId: tab.id, tab }; +} + /** * Resolve target tab in the automation window, returning both the tabId and * the Tab object (when available) so callers can skip a redundant chrome.tabs.get(). */ -async function resolveTab(tabId: number | undefined, workspace: string, initialUrl?: string): Promise { +async function resolveTab(tabId: number | undefined, workspace: string, initialUrl?: string, cmd?: Command): Promise { + if (cmd && isTabGroupCarrier(cmd)) { + return resolveTabGroupTab(tabId, workspace, cmd, initialUrl); + } + // Even when an explicit tabId is provided, validate it is still debuggable. if (tabId !== undefined) { try { @@ -427,8 +1864,8 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU } /** Convenience wrapper returning just the tabId (used by most handlers) */ -async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string): Promise { - const resolved = await resolveTab(tabId, workspace, initialUrl); +async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string, cmd?: Command): Promise { + const resolved = await resolveTab(tabId, workspace, initialUrl, cmd); return resolved.tabId; } @@ -448,12 +1885,43 @@ async function listAutomationWebTabs(workspace: string): Promise isDebuggableUrl(tab.url)); } +async function listTabGroupWebTabs(cmd: Command, workspace: string): Promise { + const key = getTabGroupSessionKey(getGroupTitle(cmd)); + const session = await getExistingTabGroupSession(key); + if (!session) return []; + const tabs = await chrome.tabs.query({ groupId: session.groupId }); + return tabs.filter((tab) => + tab.id !== undefined + && session.ownedTabIds.has(tab.id) + && session.tabWorkspaceOwners.get(tab.id) === workspace + && isDebuggableUrl(tab.url) + ); +} + +async function removeOwnedTab(cmd: Command, workspace: string, tabId: number): Promise { + if (!isTabGroupCarrier(cmd)) return; + const title = getGroupTitle(cmd); + const key = getTabGroupSessionKey(title); + await enqueueTabGroupSessionMutation(key, async () => { + const session = tabGroupSessions.get(key); + if (!session) return; + if (session.tabWorkspaceOwners.get(tabId) !== workspace) return; + const removed = session.ownedTabIds.delete(tabId); + const removedMetadata = removeTabGroupTabMetadata(session, tabId); + if (!removed && !removedMetadata) return; + const nextDeadlineAt = armTabGroupCleanupForOwnedTabs(key, session); + if (nextDeadlineAt === null) armTabGroupCleanup(key, session, Date.now()); + await persistTabGroupSession(key, session); + }); +} + async function handleExec(cmd: Command, workspace: string): Promise { if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' }; - const tabId = await resolveTabId(cmd.tabId, workspace); + const tabId = await resolveTabId(cmd.tabId, workspace, undefined, cmd); try { const aggressive = workspace.startsWith('operate:'); const data = await executor.evaluateAsync(tabId, cmd.code, aggressive); + await touchTabGroupTab(cmd, workspace, tabId); return { id: cmd.id, ok: true, data }; } catch (err) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; @@ -466,7 +1934,7 @@ async function handleNavigate(cmd: Command, workspace: string): Promise return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' }; } // Pass target URL so that first-time window creation can start on the right domain - const resolved = await resolveTab(cmd.tabId, workspace, cmd.url); + const resolved = await resolveTab(cmd.tabId, workspace, cmd.url, cmd); const tabId = resolved.tabId; const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); @@ -475,6 +1943,7 @@ async function handleNavigate(cmd: Command, workspace: string): Promise // Fast-path: tab is already at the target URL and fully loaded. if (beforeTab.status === 'complete' && isTargetUrl(beforeTab.url, targetUrl)) { + await touchTabGroupTab(cmd, workspace, tabId); return { id: cmd.id, ok: true, @@ -546,7 +2015,7 @@ async function handleNavigate(cmd: Command, workspace: string): Promise // during navigation (e.g. a tab-management extension regrouped it), // try to move it back to maintain session isolation. const session = automationSessions.get(workspace); - if (session && tab.windowId !== session.windowId) { + if (!isTabGroupCarrier(cmd) && session && tab.windowId !== session.windowId) { console.warn(`[autocli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); try { await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); @@ -556,6 +2025,8 @@ async function handleNavigate(cmd: Command, workspace: string): Promise } } + await touchTabGroupTab(cmd, workspace, tabId); + return { id: cmd.id, ok: true, @@ -566,7 +2037,9 @@ async function handleNavigate(cmd: Command, workspace: string): Promise async function handleTabs(cmd: Command, workspace: string): Promise { switch (cmd.op) { case 'list': { - const tabs = await listAutomationWebTabs(workspace); + const tabs = isTabGroupCarrier(cmd) + ? await listTabGroupWebTabs(cmd, workspace) + : await listAutomationWebTabs(workspace); const data = tabs .map((t, i) => ({ index: i, @@ -581,25 +2054,59 @@ async function handleTabs(cmd: Command, workspace: string): Promise { if (cmd.url && !isSafeNavigationUrl(cmd.url)) { return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' }; } + if (isTabGroupCarrier(cmd)) { + const session = await getTabGroupSession(cmd); + const key = getTabGroupSessionKey(getGroupTitle(cmd)); + const tab = await chrome.tabs.create({ windowId: session.windowId, url: cmd.url ?? BLANK_PAGE, active: false }); + if (!tab.id) return { id: cmd.id, ok: false, error: 'Failed to create strict-background tab' }; + try { + await chrome.tabs.group({ groupId: session.groupId, tabIds: [tab.id] }); + } catch (error) { + try { + await chrome.tabs.remove(tab.id); + } catch { + // This tab was not registered, so it cannot be retried safely + // after an extension restart. + } + throw error; + } + await registerNewOwnedTab(key, session, tab.id, cmd, workspace); + return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; + } const windowId = await getAutomationWindow(workspace); const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; } case 'close': { if (cmd.index !== undefined) { - const tabs = await listAutomationWebTabs(workspace); + const tabs = isTabGroupCarrier(cmd) + ? await listTabGroupWebTabs(cmd, workspace) + : await listAutomationWebTabs(workspace); const target = tabs[cmd.index]; if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; await chrome.tabs.remove(target.id); await executor.detach(target.id); + await removeOwnedTab(cmd, workspace, target.id); return { id: cmd.id, ok: true, data: { closed: target.id } }; } - const tabId = await resolveTabId(cmd.tabId, workspace); + if (isTabGroupCarrier(cmd) && cmd.tabId === undefined) { + const boundTabId = (await getExistingTabGroupSession(getTabGroupSessionKey(getGroupTitle(cmd)))) + ?.workspaceTabIds.get(workspace); + if (boundTabId === undefined) { + return { id: cmd.id, ok: false, error: `No strict-background tab is bound to workspace ${workspace}` }; + } + cmd = { ...cmd, tabId: boundTabId }; + } + const tabId = await resolveTabId(cmd.tabId, workspace, undefined, cmd); await chrome.tabs.remove(tabId); await executor.detach(tabId); + await removeOwnedTab(cmd, workspace, tabId); return { id: cmd.id, ok: true, data: { closed: tabId } }; } case 'select': { + if (isTabGroupCarrier(cmd)) { + return { id: cmd.id, ok: false, error: 'unsupported-operation: tabs select is disabled in strict-background mode' }; + } if (cmd.index === undefined && cmd.tabId === undefined) return { id: cmd.id, ok: false, error: 'Missing index or tabId' }; if (cmd.tabId !== undefined) { @@ -648,13 +2155,14 @@ async function handleCookies(cmd: Command): Promise { } async function handleScreenshot(cmd: Command, workspace: string): Promise { - const tabId = await resolveTabId(cmd.tabId, workspace); + const tabId = await resolveTabId(cmd.tabId, workspace, undefined, cmd); try { const data = await executor.screenshot(tabId, { format: cmd.format, quality: cmd.quality, fullPage: cmd.fullPage, }); + await touchTabGroupTab(cmd, workspace, tabId); return { id: cmd.id, ok: true, data }; } catch (err) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; @@ -690,7 +2198,7 @@ async function handleCdp(cmd: Command, workspace: string): Promise { if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; } - const tabId = await resolveTabId(cmd.tabId, workspace); + const tabId = await resolveTabId(cmd.tabId, workspace, undefined, cmd); try { const aggressive = workspace.startsWith('operate:'); await executor.ensureAttached(tabId, aggressive); @@ -699,6 +2207,7 @@ async function handleCdp(cmd: Command, workspace: string): Promise { cmd.cdpMethod, cmd.cdpParams ?? {}, ); + await touchTabGroupTab(cmd, workspace, tabId); return { id: cmd.id, ok: true, data }; } catch (err) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; @@ -706,6 +2215,11 @@ async function handleCdp(cmd: Command, workspace: string): Promise { } async function handleCloseWindow(cmd: Command, workspace: string): Promise { + if (isTabGroupCarrier(cmd)) { + validateStrictBackgroundCommand(cmd); + await touchWorkspaceTabOnStrictBackgroundRelease(cmd, workspace); + return { id: cmd.id, ok: true, data: { released: true } }; + } const session = automationSessions.get(workspace); if (session) { try { @@ -723,9 +2237,10 @@ async function handleSetFileInput(cmd: Command, workspace: string): Promise { export const __test__ = { handleNavigate, + handleCommand, isTargetUrl, handleTabs, + handleCloseWindow, handleSessions, resolveTabId, resetWindowIdleTimer, @@ -942,7 +2460,7 @@ chrome.action.onClicked.addListener(async (tab) => { // Check if already injected — if so, just toggle const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, - func: () => !!window.__autocliSelectorActive, + func: () => !!(window as SelectorWindow).__autocliSelectorActive, }); if (result?.result) { // Already injected, re-run content.js to toggle diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index 450c173..274769f 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -64,6 +64,7 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f lastError = e instanceof Error ? e.message : String(e); if (attempt < MAX_ATTACH_RETRIES) { console.warn(`[autocli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); + await cleanupBeforeAttachRetry(tabId); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); // Re-verify tab URL before retrying (it may have changed) try { @@ -105,6 +106,21 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f } } +async function cleanupBeforeAttachRetry(tabId: number): Promise { + try { + await chrome.scripting.executeScript({ + target: { tabId }, + func: () => { + const roots = document.querySelectorAll('[data-autocli-transient="true"]'); + roots.forEach((node) => node.remove()); + return { removed: roots.length }; + }, + }); + } catch { + // Cleanup is best-effort; retry attach remains the real gate. + } +} + export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise { // Retry the entire evaluate (attach + command). // Normal: 2 retries. Operate: 3 retries (tolerates extension interference). diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 4a68815..7f9a037 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -8,6 +8,7 @@ */ export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp' | 'read-article'; +export type BrowserCarrier = 'automation-window' | 'tab-group-background'; export interface Command { /** Unique request ID */ @@ -20,6 +21,12 @@ export interface Command { code?: string; /** Logical workspace for automation session reuse */ workspace?: string; + /** Browser carrier mode. Defaults to automation-window for protocol compatibility. */ + carrier?: BrowserCarrier; + /** User configured group suffix. Effective title is AutoCLI-${groupName}. */ + groupName?: string; + /** Idle cleanup timeout for strict background group sessions. */ + groupIdleTimeoutSeconds?: number; /** URL to navigate to (navigate action) */ url?: string; /** Sub-operation for tabs: list, new, close, select */