Sidebar tab awareness: tabs.json has empty url/title on non-localhost sites since v1.14 (manifest permission gap)
- Dominant language
- TypeScript
- Stars
- 133k
- Forks
- 19.9k
- Avg merge
- 18h 46m
- Merged PRs (30d)
- 26
Description
## Summary
Since `v1.14.0.0` (`ed1e4be2`), the live tab awareness feature ships with a manifest permission gap: the extension can't read `tab.url` / `tab.title` for any site that isn't `127.0.0.1`, so `tabs.json` is written with empty strings and `active-tab.json` stops updating after the first navigation away from `/welcome`.
Net effect: the sidebar Claude Code REPL can't see what tab the user is actually on, and falls back to stale state from initial launch.
## Repro
1. `gstack browse connect` — Chromium opens at `127.0.0.1:34567/welcome`
2. Manually navigate (URL bar or click a link) to any non-localhost page, e.g. `https://news.ycombinator.com`
3. Read state files:
```bash
cat /.gstack/tabs.json
cat /.gstack/active-tab.json
```
### Actual
`tabs.json` updates timestamp + `tabId`, but `url` and `title` are empty:
```json
{
"updatedAt": "2026-04-28T06:59:36.945Z",
"reason": "updated",
"tabs": [{
"tabId": 1927305561,
"url": "",
"title": "",
"active": true,
"windowId": 1927305560,
...
}]
}
```
`active-tab.json` is stale, frozen at the welcome page:
```json
{"tabId":1927305561,"url":"http://127.0.0.1:34567/welcome","title":"GStack Browser"}
```
The Side Panel REPL then reports the user is still on the welcome page, even though the visible Chromium window is on a different site.
### Expected
`tabs.json` and `active-tab.json` should reflect the current real URL / title.
(`browse status` from CLI works fine — the daemon sees the live URL — so this is isolated to the extension → state-file pipeline.)
## Root cause
Permissions, not code. The code path is correct end-to-end:
- `extension/background.js:521` `snapshotTabs()` calls `chrome.tabs.query({})` and uses `t.url || ''` as a fallback.
- `extension/background.js:571` `chrome.tabs.onUpdated` listener fires `pushTabState('updated')`.
- `browse/src/terminal-agent.ts:441` writes `tabs.json` atomically.
- `browse/src/terminal-agent.ts:457` writes `active-tab.json` only if `active.url` is truthy and not `chrome://` / `chrome-extension://`.
The break is at the Chrome boundary: with the current `manifest.json`,
```json
\"permissions\": [\"sidePanel\", \"storage\", \"activeTab\", \"scripting\"],
\"host_permissions\": [\"http://127.0.0.1:*/\", \"ws://127.0.0.1:*/\"]
```
Chrome MV3 returns `tab.url` / `tab.title` only when one of:
- `\"tabs\"` permission is granted (broad), or
- `host_permissions` matches the tab's URL, or
- `activeTab` is granted **and** the user just clicked the extension action on that tab (does not apply to background polling).
For any site outside `127.0.0.1`, none of these hold, so Chrome returns `undefined` for both fields. `snapshotTabs()`'s `|| ''` fallback then writes empty strings, and the active-tab gate (`if (active && active.url && ...)`) silently skips the write.
This means **the feature has never worked outside localhost** since it shipped — not a regression from a later version.
## Git timeline
- `7665adf4` (v0.12.0) — extension introduced; only listened to extension's own messaging, didn't read arbitrary tab URLs.
- `dc0bae82` (v0.12.6.0) — \"sidebar agent uses real tab URL instead of stale Playwright URL\" (related but different code path).
- `ed1e4be2` (v1.14.0.0) — added the `chrome.tabs.onUpdated` listener + `tabs.json` writer for live tab awareness, but the manifest diff only added `\"ws://127.0.0.1:*/\"` for the new PTY channel; tab-related permissions weren't broadened.
## Proposed fix
One line in `extension/manifest.json`. Two reasonable options:
**Option A (minimal scope, recommended)** — add `\"tabs\"` permission:
\`\`\`diff
- \"permissions\": [\"sidePanel\", \"storage\", \"activeTab\", \"scripting\"],
+ \"permissions\": [\"sidePanel\", \"storage\", \"activeTab\", \"scripting\", \"tabs\"],
\`\`\`
The `\"tabs\"` permission is a no-warning install-time grant in MV3 (it's a non-host permission). It exposes `tab.url` / `tab.title` / `tab.favIconUrl` to `chrome.tabs.query()` etc. across all tabs, but does **not** grant content-script injection or cross-origin fetch — so it's a tight scope match for what `snapshotTabs()` actually needs.
**Option B** — broaden host permissions:
\`\`\`diff
- \"host_permissions\": [\"http://127.0.0.1:*/\", \"ws://127.0.0.1:*/\"],
+ \"host_permissions\": [\"http://127.0.0.1:*/\", \"ws://127.0.0.1:*/\", \"\"],
\`\`\`
This works but triggers the install-time \"Read your browsing history on all websites\" warning. Heavier than necessary.
Recommend Option A.
## Test idea
Add an e2e test in the headed-mode harness that:
1. Navigates the Playwright-controlled Chromium to a non-localhost URL (e.g., `https://example.com`).
2. Reads `tabs.json` after a 1s settle.
3. Asserts `tabs[0].url === 'https://example.com/'` and `tabs[0].title` is non-empty.
Without the manifest fix, this test fails with empty strings — guards against the same regression on future MV3 changes.
## Environment
- gstack `v1.15.0.0` (also reproduces by inspection of v1.14.x manifest)
- macOS 25.4.0 (Darwin)
- Chromium launched via `gstack browse connect` (Playwright-managed, headed)
---
Found by reading state files + `extension/manifest.json` + `background.js` + `terminal-agent.ts` after a Side Panel session reported \"can't see current tab.\" Happy to PR if useful.
Contributor guide
Research direction
Start with extension/manifest.json and the snapshotTabs() and chrome.tabs.onUpdated paths in extension/background.js; review how browse/src/terminal-agent.ts writes tabs.json and active-tab.json. Verify non-localhost navigation produces the real URL and title, then run or add the headed-mode e2e check described in the issue and confirm both state files update correctly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- devtools
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100