Merge remote-tracking branch 'origin/main' into garrytan/gbrain-code-smell-audit

# Conflicts:
#	CHANGELOG.md
#	browse/test/dual-listener.test.ts
#	browse/test/fixtures/security-bench-haiku-responses.json
#	browse/test/sidebar-tabs.test.ts
#	browse/test/sidebar-ux.test.ts
#	browse/test/terminal-agent.test.ts
#	claude/SKILL.md.tmpl
#	scripts/gen-skill-docs.ts
#	scripts/proactive-suggestions.json
#	spec/SKILL.md
#	test/gen-skill-docs.test.ts
#	test/host-config.test.ts
This commit is contained in:
Garry Tan
2026-08-15 07:31:02 -07:00
246 changed files with 8802 additions and 1257 deletions
+23 -8
View File
@@ -5,8 +5,14 @@
* Fetches /refs on snapshot completion, relays to content script.
* Proxies commands from sidebar → browse server.
* Updates badge: amber (connected), gray (disconnected).
* Denies token/port reads to content-script and foreign senders.
*/
// Sender authorization for privileged message types (the token/port surface).
// Classic (non-module) service worker: importScripts puts gstackSenderAuth on
// the worker global. The same file is require()-able from bun tests.
importScripts('sender-auth.js');
const DEFAULT_PORT = 34567; // Well-known port used by `$B connect`
let serverPort = null;
let authToken = null;
@@ -308,6 +314,19 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return;
}
// Privileged types — anything that returns or spends the auth token or
// server port, or dumps the full tab list — are for this extension's own
// pages only (sidepanel/popup). Content scripts run inside web pages and
// can be influenced by page content; foreign extensions are foreign. Both
// get { error: 'unauthorized' } and nothing else — never the token, never
// the port. Policy + type list live in sender-auth.js.
const denial = gstackSenderAuth.denialFor(msg.type, sender, chrome.runtime.id);
if (denial) {
console.warn('[gstack] Rejected privileged message from unauthorized sender:', msg.type, sender.url || '(no sender url)');
sendResponse(denial);
return true;
}
if (msg.type === 'getPort') {
sendResponse({ port: serverPort, connected: isConnected, token: authToken });
return true;
@@ -332,15 +351,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
// Token delivered via targeted sendResponse, not broadcast — limits exposure.
// Only respond to extension pages (sidepanel/popup) — content scripts have
// sender.tab set, so reject those to prevent token access from injected contexts.
// Only this extension's own pages reach here: the sender-auth gate above
// denies content scripts (sender.tab set) and foreign senders before any
// privileged handler runs.
if (msg.type === 'getToken') {
if (sender.tab) {
console.warn('[gstack] Rejected getToken from content script context');
sendResponse({ token: null });
} else {
sendResponse({ token: authToken });
}
sendResponse({ token: authToken });
return true;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* gstack browse — sender authorization for privileged extension messages
*
* Single decision point for which chrome.runtime.onMessage senders may read
* or spend the browse server's auth token and port. Loaded into the
* background service worker via importScripts() (classic worker — see
* manifest.json) and require()-able from bun tests
* (browse/test/extension-sender-auth.test.ts).
*
* Policy: privileged types are for this extension's own pages only
* (sidepanel / popup — sender.url is chrome-extension://<own id>/...).
* Content scripts run in web-page context (sender.tab is set, sender.url is
* the page URL) and can be influenced by page content; foreign extensions
* have a different sender.id. Both are denied, and a denied sender gets
* { error: 'unauthorized' } with no other fields — never the token, never
* the port. This mirrors the server side of the v1.63 token model: the
* browse server releases AUTH_TOKEN only to the pinned extension Origin via
* POST /extension-token, so the extension must not re-leak it to contexts
* the server would never have trusted.
*/
(function (root) {
'use strict';
// Message types that return or spend the auth token / server port, or leak
// privileged browser state. Every other type in background.js's allowlist
// stays reachable from content scripts — the inspector flow (elementPicked,
// pickerCancelled, inspectResult) and openSidePanel are content-script-
// originated by design.
const PRIVILEGED_TYPES = new Set([
'getPort', // response carries port + connected state + token
'setPort', // repoints the token-bearing client at another port
'getServerUrl', // response carries the server URL (port)
'getToken', // response carries the token
'fetchRefs', // spends the token on an authorized /refs fetch
'command', // spends the token on an arbitrary browse command
'sidebar-command', // spends the token on a server POST
'getTabState', // response carries every open tab's URL + title
]);
// Extension-page senders only: popup / sidepanel / options. A content
// script has sender.tab set and a web-page sender.url; a foreign extension
// has a different sender.id; a sender with no URL has no provenance at all.
// All three are denied.
function isExtensionPageSender(sender, ownExtensionId) {
if (!sender || !ownExtensionId) return false;
if (sender.id !== ownExtensionId) return false;
if (sender.tab) return false;
if (typeof sender.url !== 'string') return false;
return sender.url.startsWith('chrome-extension://' + ownExtensionId + '/');
}
// Returns null when the message may proceed, or the exact response a
// denied sender receives: { error: 'unauthorized' } and nothing else.
function denialFor(msgType, sender, ownExtensionId) {
if (!PRIVILEGED_TYPES.has(msgType)) return null;
if (isExtensionPageSender(sender, ownExtensionId)) return null;
return { error: 'unauthorized' };
}
const api = { PRIVILEGED_TYPES, isExtensionPageSender, denialFor };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api; // bun test (CommonJS require)
}
root.gstackSenderAuth = api; // importScripts() in the service worker
})(typeof self !== 'undefined' ? self : globalThis);
+7 -17
View File
@@ -433,25 +433,15 @@
});
ro.observe(els.mount);
// IME composition handling for Korean/CJK input (issue #1272).
// Suppress partial jamo during composition; only send the final
// composed string on compositionend. Without this, Korean IME
// sends fragmented input or doubles characters.
let composing = false;
const ta = term.textarea;
if (ta) {
ta.addEventListener('compositionstart', () => { composing = true; });
ta.addEventListener('compositionend', (e) => {
composing = false;
if (e.data && ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(e.data));
}
});
}
// IME composition (Korean/CJK, issue #1272) is handled by xterm.js
// itself: partial jamo are suppressed while _isComposing, and the final
// composed string is emitted through onData once, asynchronously
// (setTimeout in _finalizeComposition). A previous local workaround
// sent e.data manually on compositionend — but xterm emits the same
// string one macrotask later, so every composed syllable went out
// TWICE. Do not re-add a manual compositionend send.
term.onData((data) => {
if (composing) return; // suppress partial input events during IME composition
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}