Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ the implementation, and where should a change land first.
- `automation.toml` packages and configures a task snapshot; it must not duplicate task logic.
- `browser-cli task` is the first-class local authoring UX.
- `browser-cli automation publish` creates immutable snapshots under the Browser CLI home and auto-imports them into the automation service.
- Published automation snapshot truth lives under `~/.browser-cli/automations/<automation-id>/versions/<version>/automation.toml`; current service state is a separate live view.
- If source `automation.toml` exists, `browser-cli automation publish` must preserve its supported fields in the published snapshot; generated defaults are allowed only when the source manifest is absent and must be reported explicitly.
- Do not introduce a public `browser-cli explore` surface or a second browser runtime. Exploration remains an agent activity layered on top of Browser CLI.
- The extension popup is a human-facing runtime observer and light recovery surface. Agent feedback still flows through command responses and `runtime-status`.

Expand Down Expand Up @@ -181,8 +183,16 @@ the implementation, and where should a change land first.
start at `src/browser_cli/automation/service/*`, `src/browser_cli/automation/persistence/*`, `src/browser_cli/automation/scheduler/*`, and `src/browser_cli/automation/api/*`.
- If the user reports confusion about what publish created or which version is active:
inspect `src/browser_cli/commands/automation.py`, `src/browser_cli/automation/publisher.py`, `src/browser_cli/automation/api/server.py`, and the snapshot directories under `~/.browser-cli/automations/<automation-id>/versions/`.
- If the user reports publish/config drift between a version and current service state:
inspect the snapshot manifest under `~/.browser-cli/automations/<automation-id>/versions/<version>/automation.toml` first, then compare it with `browser_cli.commands.automation` live inspect payload assembly and `browser_cli.automation.api.server` persisted automation serialization.
- If the user reports that `browser-cli read` stopped explaining fallback profile use:
inspect `src/browser_cli/daemon/browser_service.py::read_page`, `src/browser_cli/task_runtime/read.py`, and `src/browser_cli/commands/read.py`; fallback metadata is only guaranteed on the read path.
- If the user mentions extension capability gaps, artifacts, or real Chrome behavior:
inspect both `src/browser_cli/extension/*` and `browser-cli-extension/src/*`; many bugs live in protocol drift between Python and extension JS.
Symptom -> root cause -> where to inspect:
`Tab <id> is not debuggable: unknown` during extension-mode `read` or `open-tab`
-> extension tried to attach Chrome debugger before the new tab reported a debuggable URL
-> inspect `browser-cli-extension/src/background.js::openTab` and `browser-cli-extension/src/debugger.js::ensureAttached`
- If the user reports popup/runtime observer drift:
start at `src/browser_cli/daemon/runtime_presentation.py`, then `src/browser_cli/extension/session.py`, then `browser-cli-extension/src/background.js`, `browser-cli-extension/src/popup_view.js`, and `browser-cli-extension/src/popup.js`.
- If the user reports long-run stability drift across repeated reloads, reconnects, or artifact runs:
Expand Down Expand Up @@ -235,6 +245,8 @@ public interactive commands.
- `task.py` contains reusable automation logic. `automation.toml` packages and configures that logic; it should not become a second implementation surface.
- Runtime automation state belongs to the automation service persistence layer, not `automation.toml`. `automation.toml` remains an import/export and reviewable packaging artifact.
- Published automations are immutable snapshots. Versioning should append a new snapshot rather than mutating an older one.
- `browser-cli automation inspect --version <n>` must render snapshot config and live config separately; do not collapse them into one synthesized automation view.
- `runtime.timeout_seconds` is enforced only by the automation service and applies to the total wall-clock budget for one automation run; timeout failures must not auto-retry.
- When adapting third-party logic from `third_party/bridgic-browser`, keep provenance clear and the adapted code understandable. Do not add a runtime dependency on the external package just to shortcut implementation.

## Testing And Validation
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ browser-cli automation status
browser-cli automation ui
```

Publication semantics:

- `automation publish` snapshots `task.py`, `task.meta.json`, and `automation.toml` together under `~/.browser-cli/automations/<automation-id>/versions/<version>/`
- if source `automation.toml` exists, Browser CLI uses it as the publish-time configuration truth
- if source `automation.toml` is absent, Browser CLI publishes generated defaults and reports that explicitly via `manifest_source`

Export a persisted automation back to `automation.toml`:

```bash
Expand All @@ -206,6 +212,12 @@ browser-cli automation inspect douyin_video_download
browser-cli automation status
```

Inspect semantics:

- `browser-cli automation inspect <automation-id>` shows the current live automation-service configuration
- `browser-cli automation inspect <automation-id> --version <n>` shows `snapshot_config` for the immutable published version and `live_config` for the current service state
- `latest_run` remains a separate operational view

## Output Contracts

### `read`
Expand Down Expand Up @@ -241,6 +253,7 @@ Exit codes:
- Managed profile mode is the default backend.
- Extension mode is the preferred real-Chrome backend when connected and healthy.
- Driver rebinding happens only at safe idle points and is reported as `state_reset`.
- `runtime.timeout_seconds` is the total wall-clock timeout for one automation run in the automation service.

## Documentation

Expand Down
70 changes: 66 additions & 4 deletions browser-cli-extension/src/debugger.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const INLINE_TEXT_MAX_BYTES = 128 * 1024;
const INLINE_BINARY_MAX_BYTES = 64 * 1024;
const MAX_CAPTURE_BODY_BYTES = 8 * 1024 * 1024;
const STATIC_RESOURCE_TYPES = new Set(['image', 'stylesheet', 'script', 'font', 'media']);
const DEFAULT_ATTACH_WAIT_TIMEOUT_MS = 1500;
const DEFAULT_ATTACH_POLL_INTERVAL_MS = 50;
const DEFAULT_ATTACH_RETRY_COUNT = 2;
const DEFAULT_ATTACH_RETRY_DELAY_MS = 100;

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
Expand All @@ -18,6 +22,26 @@ function isDebuggableUrl(url = '') {
return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url === 'about:blank';
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function waitForDebuggableTab(
tabId,
{
timeoutMs = DEFAULT_ATTACH_WAIT_TIMEOUT_MS,
pollIntervalMs = DEFAULT_ATTACH_POLL_INTERVAL_MS,
} = {},
) {
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
let tab = await chrome.tabs.get(tabId);
while (!isDebuggableUrl(tab.url || '') && Date.now() < deadline) {
await sleep(Math.max(0, Number(pollIntervalMs) || 0));
tab = await chrome.tabs.get(tabId);
}
return tab;
}

function encodeUtf8(value = '') {
return textEncoder.encode(String(value || ''));
}
Expand Down Expand Up @@ -401,16 +425,54 @@ function finalizeFailedRequest(tabId, requestId, failureReason) {
);
}

export async function ensureAttached(tabId) {
const tab = await chrome.tabs.get(tabId);
export async function ensureAttached(
tabId,
{
waitTimeoutMs = DEFAULT_ATTACH_WAIT_TIMEOUT_MS,
pollIntervalMs = DEFAULT_ATTACH_POLL_INTERVAL_MS,
retryCount = DEFAULT_ATTACH_RETRY_COUNT,
retryDelayMs = DEFAULT_ATTACH_RETRY_DELAY_MS,
} = {},
) {
const tab = await waitForDebuggableTab(tabId, {
timeoutMs: waitTimeoutMs,
pollIntervalMs,
});
if (!isDebuggableUrl(tab.url || '')) {
throw new Error(`Tab ${tabId} is not debuggable: ${tab.url || 'unknown'}`);
}
if (attached.has(tabId)) {
return;
}
await chrome.debugger.attach({ tabId }, '1.3');
attached.add(tabId);
const attempts = Math.max(1, Number(retryCount) || 0);
let attachError = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
if (attached.has(tabId)) {
return;
}
try {
await chrome.debugger.attach({ tabId }, '1.3');
attached.add(tabId);
return;
} catch (error) {
if (attached.has(tabId)) {
return;
}
attachError = error;
if (attempt >= attempts) {
break;
}
await sleep(Math.max(0, Number(retryDelayMs) || 0));
const refreshed = await waitForDebuggableTab(tabId, {
timeoutMs: waitTimeoutMs,
pollIntervalMs,
});
if (!isDebuggableUrl(refreshed.url || '')) {
throw new Error(`Tab ${tabId} is not debuggable: ${refreshed.url || 'unknown'}`);
}
}
}
throw attachError;
}

export async function sendCommand(tabId, method, params = {}) {
Expand Down
99 changes: 99 additions & 0 deletions browser-cli-extension/tests/debugger.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import test from 'node:test'
import assert from 'node:assert/strict'

function installChromeStub({
urls,
attachImpl = async () => {},
}) {
const sequence = Array.isArray(urls) && urls.length ? [...urls] : ['about:blank']
let getCallCount = 0
const attachCalls = []

globalThis.chrome = {
tabs: {
async get(tabId) {
const index = Math.min(getCallCount, sequence.length - 1)
const url = sequence[index]
getCallCount += 1
return { id: tabId, url }
},
},
debugger: {
async attach(target, version) {
attachCalls.push({ target, version })
return await attachImpl(target, version)
},
},
}

return {
attachCalls,
getCallCount: () => getCallCount,
}
}

async function loadDebuggerModule() {
const cacheBust = `${Date.now()}-${Math.random()}`
return await import(new URL(`../src/debugger.js?test=${cacheBust}`, import.meta.url))
}

test('ensureAttached waits for an initially blank tab URL before attaching', async (t) => {
const chromeStub = installChromeStub({ urls: ['', '', 'about:blank'] })
t.after(() => {
delete globalThis.chrome
})

const { ensureAttached } = await loadDebuggerModule()
await ensureAttached(17, {
waitTimeoutMs: 25,
pollIntervalMs: 0,
retryCount: 1,
})

assert.equal(chromeStub.attachCalls.length, 1)
assert.equal(chromeStub.attachCalls[0].target.tabId, 17)
assert.equal(chromeStub.getCallCount(), 3)
})

test('ensureAttached retries a transient debugger attach failure', async (t) => {
let attachAttempts = 0
installChromeStub({
urls: ['about:blank'],
attachImpl: async () => {
attachAttempts += 1
if (attachAttempts === 1) {
throw new Error('transient attach failure')
}
},
})
t.after(() => {
delete globalThis.chrome
})

const { ensureAttached } = await loadDebuggerModule()
await ensureAttached(23, {
waitTimeoutMs: 0,
pollIntervalMs: 0,
retryCount: 2,
retryDelayMs: 0,
})

assert.equal(attachAttempts, 2)
})

test('ensureAttached still rejects truly non-debuggable tabs', async (t) => {
installChromeStub({ urls: ['', 'chrome://newtab/'] })
t.after(() => {
delete globalThis.chrome
})

const { ensureAttached } = await loadDebuggerModule()
await assert.rejects(
ensureAttached(29, {
waitTimeoutMs: 1,
pollIntervalMs: 0,
retryCount: 1,
}),
/Tab 29 is not debuggable: chrome:\/\/newtab\//,
)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Browser CLI Extension Attach Race Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Prevent extension-mode `open-tab` from failing with `Tab <id> is not debuggable: unknown` when Chrome has created the tab but has not populated a debuggable URL yet.

**Architecture:** Keep the existing extension-mode workflow and network-capture timing, but harden `ensureAttached()` so it waits briefly for a debuggable URL and retries one transient attach failure before surfacing an error. Cover the behavior with Node-based extension unit tests and keep repository lint wired to run all extension tests.

**Tech Stack:** Browser extension JavaScript, Node `node:test`, repository shell lint/test/guard scripts

---

### Task 1: Harden Extension Attach Timing

**Files:**
- Modify: `browser-cli-extension/src/debugger.js`

- [ ] Add a short polling helper that waits for `chrome.tabs.get(tabId).url` to become `about:blank`, `http(s)`, or `file`.
- [ ] Update `ensureAttached()` to use the helper before `chrome.debugger.attach()`.
- [ ] Retry one transient attach failure after a short delay without changing the public error wording for truly non-debuggable tabs.

### Task 2: Add Regression Tests

**Files:**
- Create: `browser-cli-extension/tests/debugger.test.js`
- Modify: `scripts/lint.sh`

- [ ] Add a Node test that proves `ensureAttached()` waits through an empty tab URL and then attaches once the URL becomes `about:blank`.
- [ ] Add a Node test that proves `ensureAttached()` retries a transient attach failure and succeeds on the second attempt.
- [ ] Add a Node test that proves truly non-debuggable URLs still fail with a clear message.
- [ ] Update lint to run all extension `*.test.js` files, not just the popup test.

### Task 3: Capture Durable Repo Guidance

**Files:**
- Modify: `AGENTS.md`

- [ ] Add a concise failure-driven note pointing future agents to the extension `openTab` and `ensureAttached` path when they see `Tab <id> is not debuggable: unknown`.

### Task 4: Validate

**Files:**
- Modify: none

- [ ] Run `node --test browser-cli-extension/tests/*.test.js`.
- [ ] Run `scripts/lint.sh`.
- [ ] Run `scripts/test.sh`.
- [ ] Run `scripts/guard.sh`.
Loading
Loading