Skip to content

fix(extension): add "tabs" permission so live tab awareness works on non-localhost sites#1257

Open
fredchu wants to merge 1 commit intogarrytan:mainfrom
fredchu:fix/manifest-tabs-permission
Open

fix(extension): add "tabs" permission so live tab awareness works on non-localhost sites#1257
fredchu wants to merge 1 commit intogarrytan:mainfrom
fredchu:fix/manifest-tabs-permission

Conversation

@fredchu
Copy link
Copy Markdown

@fredchu fredchu commented Apr 28, 2026

Summary

  • Adds the \"tabs\" permission to extension/manifest.json so chrome.tabs.query() returns real url / title for tabs outside 127.0.0.1.
  • Adds a manifest source-level regression test in browse/test/sidebar-tabs.test.ts.

Closes #1256.

Why

Since v1.14.0.0 (ed1e4be2 introduced live tab awareness), tabs.json has been written with empty url / title strings for any user-visited site outside localhost, and active-tab.json has stopped updating after the first non-localhost navigation. The sidebar Claude Code REPL can't see what tab the user is actually on, and falls back to stale state.

Repro:

  1. gstack browse connect
  2. Navigate (URL bar or click) to any non-localhost page, e.g. https://news.ycombinator.com
  3. cat <repo>/.gstack/tabs.jsonurl / title are empty
  4. cat <repo>/.gstack/active-tab.json — still pinned to /welcome

(browse status from the CLI works fine — the daemon sees the live URL — so the break is isolated to the extension → state-file pipeline.)

Root cause

The code path is correct end-to-end. The break is at the Chrome boundary.

extension/background.js:

  • snapshotTabs() (L521) — calls chrome.tabs.query({}) and tolerates missing fields with t.url || '' / t.title || ''.
  • chrome.tabs.onUpdated listener (L571) — fires pushTabState('updated') on URL/title changes.

browse/src/terminal-agent.ts:

  • L441 — atomic tabs.json write.
  • L457 — active-tab.json write only if active.url is truthy and not chrome:// / chrome-extension://.

In Manifest V3, chrome.tabs.query() returns tab.url / tab.title only when one of:

  • \"tabs\" permission is granted, 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).

Current manifest.json:

```json
"permissions": ["sidePanel", "storage", "activeTab", "scripting"],
"host_permissions": ["http://127.0.0.1:/", "ws://127.0.0.1:/"]
```

For any site outside 127.0.0.1, none of these hold → Chrome returns undefined for both fields → snapshotTabs() writes empty strings → active-tab.json gate skips silently.

Why \"tabs\" and not \"<all_urls>\" host permission

Both fix the symptom, but \"tabs\" is the tighter scope match:

\"tabs\" permission \"<all_urls>\" host permission
Install-time warning None (non-host permission) "Read your browsing history on all websites"
Grants tab.url / tab.title to chrome.tabs.*
Grants content-script injection
Grants cross-origin fetch

snapshotTabs() only needs the metadata, not injection / fetch — \"tabs\" is the minimum sufficient grant. No new install warnings, no cross-origin attack surface.

Tests

```
$ bun test browse/test/sidebar-tabs.test.ts
28 pass
0 fail
```

Without the manifest fix, the new test fails:
```
expect(received).toContain(expected)
Expected to contain: "tabs"
Received: ["sidePanel", "storage", "activeTab", "scripting"]
```

Test plan

  • Existing browse/test/sidebar-tabs.test.ts suite still passes (28/28).
  • New manifest test fails on main (without the permission), passes on this branch.
  • Manual e2e (recommended for reviewer): gstack browse connect, navigate to a non-localhost URL, confirm tabs.json shows the real URL and active-tab.json updates.

🤖 Generated with Claude Code


View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

…non-localhost sites

Without "tabs", chrome.tabs.query() returns tab objects with undefined
url/title for any site outside host_permissions (everything except
127.0.0.1). snapshotTabs() in background.js then writes empty strings
into tabs.json, and the active-tab.json gate (`if (active && active.url
&& ...)`) silently skips the write. The sidebar agent loses track of
what page the user is actually on.

The "tabs" permission is a no-warning install-time grant in MV3 (not a
host permission). It exposes tab.url / tab.title / tab.favIconUrl across
all tabs, but does not grant content-script injection or cross-origin
fetch — a tight scope match for what snapshotTabs() actually needs.
activeTab is too narrow (only after a user gesture on the extension
action) for background polling.

Reproduces on every gstack version since v1.14.0.0 (ed1e4be), where the
chrome.tabs.onUpdated listener was added but the manifest only gained
`ws://127.0.0.1:*/` for the new PTY channel — tab-related permissions
were never broadened.

Adds a manifest source-level test in sidebar-tabs.test.ts that asserts
`permissions` contains "tabs", to guard against future regressions.

Closes garrytan#1256
@fredchu
Copy link
Copy Markdown
Author

fredchu commented Apr 28, 2026

End-to-end verification on a real install

Verified the fix on a v1.15.0.0 install (live extension at ~/.claude/skills/gstack/extension/).

Red phase (manifest reverted to upstream/main)

```
$ git show upstream/main:extension/manifest.json > extension/manifest.json
$ bun test browse/test/sidebar-tabs.test.ts

(fail) manifest: live tab awareness needs "tabs" permission > permissions includes "tabs"
expect(received).toContain(expected)
Expected to contain: "tabs"
Received: [ "sidePanel", "storage", "activeTab", "scripting" ]

27 pass
1 fail
```

Green phase (fix restored)

```
$ bun test browse/test/sidebar-tabs.test.ts

28 pass
0 fail
```

Live browser e2e (the part that matters)

Before the fix, with browser on https://talkie-lm.com/introducing-talkie:

```json
// .gstack/tabs.json
{
"updatedAt": "2026-04-28T06:59:36.945Z",
"reason": "updated",
"tabs": [{
"tabId": 1927305561,
"url": "",
"title": "",
...
}]
}

// .gstack/active-tab.json — stale, never updated past welcome
{"tabId":1927305561,"url":"http://127.0.0.1:34567/welcome\",\"title\":\"GStack Browser"}
```

After applying the manifest fix + gstack browse disconnect && connect, with Side Panel Terminal tab open:

getTabState (initial WS open) on https://news.ycombinator.com/:
```json
// .gstack/tabs.json
{
"updatedAt": "2026-04-28T07:20:18.455Z",
"reason": "initial",
"tabs": [{
"tabId": 1927305613,
"url": "https://news.ycombinator.com/",
"title": "Hacker News",
...
}]
}

// .gstack/active-tab.json — gate now passes because url is truthy
{"tabId":1927305613,"url":"https://news.ycombinator.com/\",\"title\":\"Hacker News"}
```

chrome.tabs.onUpdated (after gstack browse goto example.com):
```json
{
"updatedAt": "2026-04-28T07:21:02.327Z",
"reason": "updated",
"tabs": [{
"tabId": 1927305613,
"url": "https://example.com/",
"title": "Example Domain",
...
}]
}
```

Both the initial-state path and the live onUpdated path produce real url / title after the manifest change. The active-tab.json write gate (if (active && active.url && ...) in terminal-agent.ts:457) now passes too.

Note for reviewer

State files are written only when Side Panel + Terminal tab are open — the relay chain is background.jssidepanel.js (DOM event) → sidepanel-terminal.js (WebSocket) → terminal-agent.ts (atomic write). Manual e2e: open the Side Panel, switch to the Terminal tab, then verify state files. Without the Terminal tab the WebSocket isn't open and nothing gets written, regardless of permissions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sidebar tab awareness: tabs.json has empty url/title on non-localhost sites since v1.14 (manifest permission gap)

1 participant