fix: stale auth token causes Unauthorized + invisible error text

background.js checkHealth() never refreshed authToken from /health responses,
so when the browse server restarted with a new token, all sidebar-command
requests got 401 Unauthorized forever.

Also: error placeholder text was #3f3f46 on #0C0C0C (nearly invisible).
Now shows in red to match the error border.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-02 20:03:59 -07:00
parent 629a618736
commit 8a6222f7f5
2 changed files with 72 additions and 23 deletions
+68 -23
View File
@@ -45,7 +45,9 @@ async function loadAuthToken() {
const data = await resp.json();
if (data.token) authToken = data.token;
}
} catch {}
} catch (err) {
console.error('[gstack bg] Failed to load auth token:', err.message);
}
}
// ─── Health Polling ────────────────────────────────────────────
@@ -65,12 +67,16 @@ async function checkHealth() {
if (!resp.ok) { setDisconnected(); return; }
const data = await resp.json();
if (data.status === 'healthy') {
// Always refresh auth token from /health — the server generates a new
// token on each restart, so the old one becomes stale.
if (data.token) authToken = data.token;
// Forward chatEnabled so sidepanel can show/hide chat tab
setConnected({ ...data, chatEnabled: !!data.chatEnabled });
} else {
setDisconnected();
}
} catch {
} catch (err) {
console.error('[gstack bg] Health check failed:', err.message);
setDisconnected();
}
}
@@ -82,7 +88,9 @@ function setConnected(healthData) {
chrome.action.setBadgeText({ text: ' ' });
// Broadcast health to popup and side panel (include token for sidepanel auth)
chrome.runtime.sendMessage({ type: 'health', data: { ...healthData, token: authToken } }).catch(() => {});
chrome.runtime.sendMessage({ type: 'health', data: { ...healthData, token: authToken } }).catch((err) => {
console.debug('[gstack bg] No listener for health broadcast:', err.message);
});
// Notify content scripts on connection change
if (wasDisconnected) {
@@ -96,7 +104,9 @@ function setDisconnected() {
// Keep authToken — it persists across reconnections
chrome.action.setBadgeText({ text: '' });
chrome.runtime.sendMessage({ type: 'health', data: null }).catch(() => {});
chrome.runtime.sendMessage({ type: 'health', data: null }).catch((err) => {
console.debug('[gstack bg] No listener for disconnect broadcast:', err.message);
});
// Notify content scripts on disconnection
if (wasConnected) {
@@ -109,10 +119,14 @@ async function notifyContentScripts(type) {
const tabs = await chrome.tabs.query({});
for (const tab of tabs) {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, { type }).catch(() => {});
chrome.tabs.sendMessage(tab.id, { type }).catch(() => {
// Expected: tabs without content script
});
}
}
} catch {}
} catch (err) {
console.error('[gstack bg] Failed to query tabs for notification:', err.message);
}
}
// ─── Command Proxy ─────────────────────────────────────────────
@@ -150,17 +164,24 @@ async function fetchAndRelayRefs() {
const headers = {};
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
const resp = await fetch(`${base}/refs`, { signal: AbortSignal.timeout(3000), headers });
if (!resp.ok) return;
if (!resp.ok) {
console.warn(`[gstack bg] Refs endpoint returned ${resp.status}`);
return;
}
const data = await resp.json();
// Send to all tabs' content scripts
const tabs = await chrome.tabs.query({});
for (const tab of tabs) {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, { type: 'refs', data }).catch(() => {});
chrome.tabs.sendMessage(tab.id, { type: 'refs', data }).catch(() => {
// Expected: tabs without content script
});
}
}
} catch {}
} catch (err) {
console.error('[gstack bg] Failed to fetch/relay refs:', err.message);
}
}
// ─── Inspector ──────────────────────────────────────────────────
@@ -181,21 +202,26 @@ async function injectInspector(tabId) {
target: { tabId, allFrames: true },
files: ['inspector.css'],
});
} catch {}
} catch (err) {
console.debug('[gstack bg] Inspector CSS injection failed (non-fatal):', err.message);
}
// Send startPicker to the injected inspector.js
try {
await chrome.tabs.sendMessage(tabId, { type: 'startPicker' });
} catch {}
} catch (err) {
console.warn('[gstack bg] Failed to send startPicker:', err.message);
}
inspectorMode = 'full';
return { ok: true, mode: 'full' };
} catch {
} catch (err) {
// Script injection failed (CSP, chrome:// page, etc.)
// Fall back to content.js basic picker (loaded by manifest on most pages)
try {
await chrome.tabs.sendMessage(tabId, { type: 'startBasicPicker' });
inspectorMode = 'basic';
return { ok: true, mode: 'basic' };
} catch {
} catch (err2) {
console.error('[gstack bg] Inspector injection failed completely:', err.message, '| Basic fallback:', err2.message);
inspectorMode = 'full';
return { error: 'Cannot inspect this page' };
}
@@ -205,7 +231,9 @@ async function injectInspector(tabId) {
async function stopInspector(tabId) {
try {
await chrome.tabs.sendMessage(tabId, { type: 'stopPicker' });
} catch {}
} catch (err) {
console.debug('[gstack bg] Failed to stop picker on tab', tabId, ':', err.message);
}
return { ok: true };
}
@@ -232,8 +260,8 @@ async function postInspectorPick(selector, frameInfo, basicData, activeTabUrl) {
}
const data = await resp.json();
return { mode: 'cdp', ...data };
} catch {
// No server or timeout — fall back to basic mode
} catch (err) {
console.debug('[gstack bg] Inspector pick server unavailable, using basic mode:', err.message);
return { mode: 'basic', selector, basicData, frameInfo };
}
}
@@ -297,7 +325,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Open side panel from content script pill click
if (msg.type === 'openSidePanel') {
if (chrome.sidePanel?.open && sender.tab) {
chrome.sidePanel.open({ tabId: sender.tab.id }).catch(() => {});
chrome.sidePanel.open({ tabId: sender.tab.id }).catch((err) => {
console.warn('[gstack bg] Failed to open side panel:', err.message);
});
}
return;
}
@@ -342,7 +372,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
basicData: msg.basicData,
frameInfo,
},
}).catch(() => {});
}).catch((err) => {
console.warn('[gstack bg] Failed to forward inspectResult to sidepanel:', err.message);
});
sendResponse({ ok: true });
});
});
@@ -351,7 +383,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Inspector: picker cancelled
if (msg.type === 'pickerCancelled') {
chrome.runtime.sendMessage({ type: 'pickerCancelled' }).catch(() => {});
chrome.runtime.sendMessage({ type: 'pickerCancelled' }).catch((err) => {
console.debug('[gstack bg] No listener for pickerCancelled:', err.message);
});
return;
}
@@ -391,9 +425,18 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
},
body: JSON.stringify({ message: msg.message, activeTabUrl }),
})
.then(r => r.json())
.then(r => {
if (!r.ok) {
console.error(`[gstack bg] sidebar-command failed: ${r.status} ${r.statusText}`);
return r.json().catch(() => ({ error: `Server returned ${r.status}` }));
}
return r.json();
})
.then(data => sendResponse(data))
.catch(err => sendResponse({ error: err.message }));
.catch(err => {
console.error('[gstack bg] sidebar-command error:', err.message);
sendResponse({ error: err.message });
});
});
return true;
}
@@ -403,7 +446,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Click extension icon → open side panel directly (no popup)
if (chrome.sidePanel && chrome.sidePanel.setPanelBehavior) {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch((err) => {
console.warn('[gstack bg] Failed to set panel behavior:', err.message);
});
}
// Auto-open side panel with retry. chrome.sidePanel.open() can fail silently
@@ -448,7 +493,7 @@ chrome.tabs.onActivated.addListener((activeInfo) => {
tabId: activeInfo.tabId,
url: tab.url || '',
title: tab.title || '',
}).catch(() => {}); // sidepanel may not be open
}).catch(() => {}); // expected: sidepanel may not be open
});
});
+4
View File
@@ -689,6 +689,10 @@ body::after {
border-color: var(--error);
animation: shake 300ms ease;
}
.command-input.error::placeholder {
color: var(--error);
opacity: 0.8;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }