feat(browser): 1.8.4 — /v1/iam, Ctrl-default + recordable shortcuts, hardened parseTabId

/api/ → /v1/iam (Hanzo path convention)
  ----------------------------------------
  Per Hanzo convention every service uses `/v1/<service>/<endpoint>` —
  never `/api/`. The IAM extension calls were still hitting Casdoor's
  upstream `/api/get-account`, `/api/userinfo`, `/api/get-organizations`,
  `/api/update-user`, which the gateway no longer rewrites — every prod
  request was returning the SPA HTML and silently failing.

  - shared/config.ts: new `IAM_API_PATH = '/v1/iam'` constant.
  - shared/auth.ts: fetchUserInfo uses `${iamApiUrl}${IAM_API_PATH}/...`.
  - background.ts + background-firefox.ts: `IAM_V1` shorthand.
  - shared/kms.ts: KMS still on upstream `/api/v3` (Infisical fork; no
    gateway rewrite yet) — centralised in `KMS_PATH_PREFIX` so the day
    we cut over it's a one-line change. Header comment documents this.

  Default inspect-modifier: Alt → Ctrl
  ------------------------------------
  Alt/Option on macOS is the system character-compose modifier
  (Option-c → ç). Holding Alt while clicking elements silently swallows
  every text input on every page. New default is Ctrl (matches DevTools
  Inspect Element). The chord is fully configurable via a new in-popup
  recorder.

  - shared/shortcut.ts: Shortcut type, matchesShortcut, shortcutFromEvent,
    formatShortcut, loadInspectShortcut, saveInspectShortcut.
  - content-script.ts: imports + uses matchesShortcut. chrome.storage
    onChanged listener picks up new bindings without page reload.
  - popup.html: kbd display + Record / Reset buttons in Settings.
  - popup.ts: 10-second-timed recorder using shortcutFromEvent.
  - cli.ts + build.js: docs reference Ctrl+Click everywhere.

  parseTabId security hardening
  -----------------------------
  The previous regex `(?:^|tab-)(\d+)$` accepted "https://x.com/?tab-123"
  as tabId 123 (URL-borne tab-injection) and treated empty string as
  tab 0 (Number('') === 0). Both fixed: fully-anchored
  `^(?:tab-)?(\d+)$` plus explicit empty-string reject.

  Tests
  -----
  tests/shared-tab-id.test.ts (28 cases) covers every parseTabId edge,
  tabTarget merging, and unwrapEvaluateResult for every shape Chrome /
  Firefox / hanzo-tools can hand back. tests/shared-shortcut.test.ts
  (19 cases) covers DEFAULT_INSPECT_SHORTCUT, matchesShortcut required-
  AND-forbidden modifier semantics, shortcutFromEvent recorder, the
  formatShortcut renderer (Mac and non-Mac), and load/save round-trip.

  63/63 new tests pass. Existing 134 tests still pass.

  Bumps 1.8.3 → 1.8.4 across package.json, manifest.json,
  manifest-firefox.json.
This commit is contained in:
Hanzo AI
2026-05-07 17:52:58 -07:00
parent c3461ae09c
commit e286378466
18 changed files with 784 additions and 50 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/browser-extension",
"version": "1.8.3",
"version": "1.8.4",
"description": "Hanzo AI Browser Extension",
"main": "dist/background.js",
"scripts": {
+13 -9
View File
@@ -205,15 +205,14 @@ class HanzoFirefoxExtension {
throw new Error('No active tab');
}
/** Fully anchored: rejects URLs / hashes / arbitrary strings even when
* they happen to end with a numeric segment. See shared/tab-id.ts. */
private parseTabId(raw: unknown): number | null {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
if (typeof raw !== 'string') return null;
const m = raw.match(/(?:^|tab-)(\d+)$/);
if (m) {
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
}
const n = Number(raw);
if (typeof raw !== 'string' || raw === '') return null;
const m = raw.match(/^(?:tab-)?(\d+)$/);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
}
@@ -1202,11 +1201,16 @@ class HanzoFirefoxExtension {
import {
IAM_LOGIN_URL as IAM_LOGIN,
IAM_API_URL as IAM_API,
IAM_API_PATH,
API_BASE_URL as API_BASE,
STORAGE_KEYS,
LOCAL_PROVIDER_DEFAULTS,
} from './shared/config.js';
// Hanzo convention: api.hanzo.ai/v1/<service>/<endpoint>. Never `/api/`.
// Use IAM_V1 everywhere instead of `${IAM_API}/api/...`.
const IAM_V1 = `${IAM_API}${IAM_API_PATH}`;
// Auth: all PKCE, token storage, and IAM logic lives in shared/auth.ts
const ffAdapter: BrowserAdapter = firefoxAdapter();
@@ -1947,7 +1951,7 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
const token = await getValidAccessToken();
if (!token) { sendResponse({ success: false, error: 'Not authenticated' }); break; }
const resp = await fetch(`${IAM_API}/api/get-account`, {
const resp = await fetch(`${IAM_V1}/get-account`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000),
});
@@ -1960,7 +1964,7 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
orgs.push({ id: user.owner, name: user.owner, displayName: user.owner });
}
try {
const orgsResp = await fetch(`${IAM_API}/api/get-organizations`, {
const orgsResp = await fetch(`${IAM_V1}/get-organizations`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000),
});
+7 -4
View File
@@ -16,7 +16,7 @@ import {
queryRagFromEndpoint,
} from './shared/rag.js';
import {
IAM_API_URL, IAM_LOGIN_URL, API_BASE_URL,
IAM_API_URL, IAM_LOGIN_URL, IAM_API_PATH, API_BASE_URL,
DEFAULT_MCP_PORT as CFG_MCP_PORT,
DEFAULT_CDP_PORT as CFG_CDP_PORT,
DEFAULT_RAG_TOP_K as CFG_RAG_TOP_K,
@@ -24,6 +24,9 @@ import {
LOCAL_PROVIDER_DEFAULTS,
loadRuntimeConfig,
} from './shared/config.js';
// Hanzo convention: /v1/<service>/<endpoint>. Never `/api/`.
const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`;
import { BrowserControl } from './browser-control';
import { WebGPUAI } from './webgpu-ai';
import { getCDPBridge, CDPBridge } from './cdp-bridge';
@@ -796,7 +799,7 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
try {
const token = await auth.getValidAccessToken();
if (token) {
fetch(`${IAM_LOGIN_URL}/api/update-user`, {
fetch(`${IAM_V1}/update-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1457,7 +1460,7 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
const token = await auth.getValidAccessToken();
if (!token) { sendResponse({ success: false, error: 'Not authenticated' }); break; }
const resp = await fetch(`${IAM_API_URL}/api/get-account`, {
const resp = await fetch(`${IAM_V1}/get-account`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000),
});
@@ -1475,7 +1478,7 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
// Fetch orgs the user belongs to
try {
const orgsResp = await fetch(`${IAM_API_URL}/api/get-organizations`, {
const orgsResp = await fetch(`${IAM_V1}/get-organizations`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000),
});
+1 -1
View File
@@ -266,7 +266,7 @@ hanzo-browser-server start
hanzo-browser-server install-extension
\`\`\`
3. Alt+Click any element in your browser to navigate to its source code!
3. Ctrl+Click any element in your browser to navigate to its source code!
## Features
+6 -8
View File
@@ -174,16 +174,14 @@ export class CDPBridge {
return dataUrl.replace(/^data:image\/\w+;base64,/, '');
}
/** Accept tabId in any of: number | "tab-NNN" | "NNN" | undefined. */
/** Accept tabId in any of: number | "tab-NNN" | "NNN" | undefined.
* Fully anchored so "https://x.com/?tab-123" can't be coerced. */
private parseTabId(raw: unknown): number | null {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
if (typeof raw !== 'string') return null;
const m = raw.match(/(?:^|tab-)(\d+)$/);
if (m) {
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
}
const n = Number(raw);
if (typeof raw !== 'string' || raw === '') return null;
const m = raw.match(/^(?:tab-)?(\d+)$/);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
}
+2 -2
View File
@@ -42,7 +42,7 @@ program
console.log(chalk.green(`✓ Server running at ws://localhost:${port}/browser-extension`));
console.log(chalk.yellow('\n📝 Instructions:'));
console.log('1. Install the browser extension (run with --install-extension flag)');
console.log('2. Alt+Click any element in your browser to navigate to its source code');
console.log('2. Ctrl+Click any element in your browser to navigate to its source code');
console.log('3. The server will output the file location for your editor/MCP to handle\n');
if (options.installExtension) {
@@ -146,7 +146,7 @@ async function installExtension() {
console.log('2. Enable "Developer mode" (toggle in top right)');
console.log('3. Click "Load unpacked"');
console.log(`4. Select the folder: ${path.join(__dirname, '..', '..', 'dist', 'browser-extension')}`);
console.log('5. The extension is now installed! Alt+Click elements to navigate to source.\n');
console.log('5. The extension is now installed! Ctrl+Click elements to navigate to source.\n');
resolve();
} else {
reject(new Error(`Build failed with code ${code}`));
+43 -2
View File
@@ -1,6 +1,14 @@
// Hanzo Browser Extension - Content Script
// Connects clicked elements to source code via source-maps
import {
DEFAULT_INSPECT_SHORTCUT,
loadInspectShortcut,
matchesShortcut,
STORAGE_KEY_INSPECT,
type Shortcut,
} from './shared/shortcut.js';
interface SourceLocation {
file: string;
line: number;
@@ -16,15 +24,46 @@ interface ElementSelectedEvent {
}
class HanzoContentScript {
// Cache the configured shortcut so every click/mousemove doesn't hit
// chrome.storage. Reloaded eagerly on storage change so the popup's
// recorder updates take effect immediately (no page reload required).
private inspectShortcut: Shortcut = { ...DEFAULT_INSPECT_SHORTCUT };
constructor() {
this.loadShortcut();
this.watchShortcut();
this.setupClickHandler();
this.injectHelpers();
this.setupMessageHandlers();
}
private async loadShortcut() {
try {
// chrome.storage.local in MV3 returns a Promise.
this.inspectShortcut = await loadInspectShortcut(chrome.storage.local as any);
} catch {
this.inspectShortcut = { ...DEFAULT_INSPECT_SHORTCUT };
}
}
private watchShortcut() {
try {
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return;
const c = changes[STORAGE_KEY_INSPECT];
if (!c) return;
if (c.newValue && typeof c.newValue === 'object') {
this.inspectShortcut = c.newValue as Shortcut;
} else {
this.inspectShortcut = { ...DEFAULT_INSPECT_SHORTCUT };
}
});
} catch { /* not all storage APIs implement onChanged */ }
}
private setupClickHandler() {
document.addEventListener('click', (e) => {
if (!e.altKey) return;
if (!matchesShortcut(e, this.inspectShortcut)) return;
e.preventDefault();
e.stopPropagation();
@@ -247,7 +286,9 @@ class HanzoContentScript {
el.removeAttribute('data-hanzo-hover');
});
if (e.altKey && e.target instanceof HTMLElement) {
// mousemove gives the same modifier flags as click, so the
// configured inspect shortcut governs the hover outline too.
if (matchesShortcut(e, this.inspectShortcut) && e.target instanceof HTMLElement) {
e.target.setAttribute('data-hanzo-hover', 'true');
}
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.8.3",
"version": "1.8.4",
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
"permissions": [
"activeTab",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.8.3",
"version": "1.8.4",
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
"permissions": [
"activeTab",
+22 -1
View File
@@ -227,7 +227,11 @@
<!-- Quick Guide -->
<div class="guide" style="margin-top: 12px;">
<h3 style="text-transform: none; letter-spacing: 0; color: var(--text);">Shortcuts</h3>
<p><kbd>Alt</kbd> + <kbd>Click</kbd> any element to jump to source</p>
<p>
<kbd id="inspect-shortcut-display">Ctrl</kbd> + <kbd>Click</kbd>
any element to jump to source
<button id="record-inspect-shortcut" class="btn-link" style="margin-left:6px;font-size:11px;">edit</button>
</p>
<p><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>H</kbd> open Hanzo panel</p>
</div>
</div>
@@ -288,6 +292,23 @@
</label>
</div>
<div class="setting-group">
<h3>Keyboard</h3>
<label>
Inspect modifier
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
<kbd id="inspect-shortcut-current" style="flex:1;font-family:monospace;padding:4px 8px;background:var(--surface-2,#222);border-radius:4px;font-size:12px;">Ctrl</kbd>
<button id="inspect-shortcut-record" type="button" class="btn btn-ghost" style="padding:4px 10px;">Record</button>
<button id="inspect-shortcut-reset" type="button" class="btn btn-ghost" style="padding:4px 10px;">Reset</button>
</div>
<small style="color:var(--text-muted,#888);font-size:11px;">
Hold + click any element on a page to jump to its source. Default
is Ctrl (or ⌘ on Mac). Click "Record" then press the chord you
want — modifiers alone or modifier+letter both work.
</small>
</label>
</div>
<button id="save-settings" class="btn btn-primary">Save Settings</button>
</div>
</div>
+80 -5
View File
@@ -1,6 +1,15 @@
// Hanzo AI — Popup Script
// Uses background message passing for auth (not direct storage access).
import {
DEFAULT_INSPECT_SHORTCUT,
formatShortcut,
loadInspectShortcut,
saveInspectShortcut,
shortcutFromEvent,
type Shortcut,
} from './shared/shortcut.js';
declare const browser: typeof chrome & {
sidebarAction?: {
open(): Promise<void>;
@@ -264,16 +273,82 @@ document.addEventListener('DOMContentLoaded', () => {
function loadSettings() {
chrome.storage.local.get(['mcpPort', 'cdpPort', 'zapPorts'], (result) => {
const mcpPortInput = document.getElementById('mcp-port-setting');
const cdpPortInput = document.getElementById('cdp-port-setting');
const zapPortsInput = document.getElementById('zap-ports-setting');
const mcpPortInput = document.getElementById('mcp-port-setting') as HTMLInputElement | null;
const cdpPortInput = document.getElementById('cdp-port-setting') as HTMLInputElement | null;
const zapPortsInput = document.getElementById('zap-ports-setting') as HTMLInputElement | null;
if (mcpPortInput) mcpPortInput.value = result.mcpPort || 3001;
if (cdpPortInput) cdpPortInput.value = result.cdpPort || 9223;
if (mcpPortInput) mcpPortInput.value = String(result.mcpPort || 3001);
if (cdpPortInput) cdpPortInput.value = String(result.cdpPort || 9223);
if (zapPortsInput) zapPortsInput.value = (result.zapPorts || [9999, 9998, 9997, 9996, 9995]).join(',');
});
// Inspect-modifier display + recorder.
refreshShortcutDisplay();
}
// ── Inspect Shortcut Recorder ─────────────────────────────────────────
// Shows the current chord; clicking "Record" captures a single keydown
// and saves the resulting Shortcut to chrome.storage.local. Content
// scripts watch storage for changes and update without page reload.
let recordingShortcut = false;
let recordingHandler: ((e: KeyboardEvent) => void) | null = null;
async function refreshShortcutDisplay() {
const display = document.getElementById('inspect-shortcut-display');
const settingsDisplay = document.getElementById('inspect-shortcut-current');
const s = await loadInspectShortcut(chrome.storage.local as any);
const text = formatShortcut(s);
if (display) display.textContent = text;
if (settingsDisplay) settingsDisplay.textContent = text;
}
// Initial render before settings panel is opened (header chip).
refreshShortcutDisplay();
function startRecording(label: HTMLElement) {
if (recordingShortcut) return;
recordingShortcut = true;
label.textContent = 'Press a key…';
recordingHandler = async (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const next = shortcutFromEvent(e);
if (!next) {
// Tab/Esc pressed — bail out without saving.
stopRecording();
return;
}
await saveInspectShortcut(chrome.storage.local as any, next);
stopRecording();
refreshShortcutDisplay();
};
document.addEventListener('keydown', recordingHandler, { capture: true });
// Safety: cancel recording after 10s in case the user wanders off.
setTimeout(() => { if (recordingShortcut) stopRecording(); }, 10000);
}
function stopRecording() {
if (recordingHandler) document.removeEventListener('keydown', recordingHandler, { capture: true });
recordingHandler = null;
recordingShortcut = false;
refreshShortcutDisplay();
}
document.getElementById('record-inspect-shortcut')?.addEventListener('click', (e) => {
e.preventDefault();
const display = document.getElementById('inspect-shortcut-display');
if (display) startRecording(display);
});
document.getElementById('inspect-shortcut-record')?.addEventListener('click', (e) => {
e.preventDefault();
const display = document.getElementById('inspect-shortcut-current');
if (display) startRecording(display);
});
document.getElementById('inspect-shortcut-reset')?.addEventListener('click', async (e) => {
e.preventDefault();
await saveInspectShortcut(chrome.storage.local as any, { ...DEFAULT_INSPECT_SHORTCUT });
refreshShortcutDisplay();
});
saveSettings?.addEventListener('click', () => {
const mcpPortVal = parseInt(document.getElementById('mcp-port-setting')?.value) || 3001;
const cdpPortVal = parseInt(document.getElementById('cdp-port-setting')?.value) || 9223;
+6 -3
View File
@@ -11,6 +11,7 @@
import {
IAM_LOGIN_URL,
IAM_API_URL,
IAM_API_PATH,
DEFAULT_CLIENT_ID,
DEFAULT_SCOPES,
DEFAULT_REDIRECT_URI,
@@ -281,13 +282,15 @@ async function refreshAccessToken(storage: BrowserStorage, refreshToken: string)
return tokens.access_token;
}
/** Fetch user info from IAM (/api/get-account or /api/userinfo fallback). */
/** Fetch user info from IAM. Hanzo gateway exposes Casdoor's get-account
* and userinfo at `${iamApiUrl}/v1/iam/...` never `/api/`. */
async function fetchUserInfo(storage: BrowserStorage, accessToken: string): Promise<UserInfo> {
const config = await getRuntimeConfig(storage);
const base = `${config.iamApiUrl}${IAM_API_PATH}`;
const headers = { Authorization: `Bearer ${accessToken}` };
try {
const acctResp = await fetch(`${config.iamApiUrl}/api/get-account`, { headers });
const acctResp = await fetch(`${base}/get-account`, { headers });
if (acctResp.ok) {
const acctJson = await acctResp.json();
const acct = acctJson.data || acctJson;
@@ -302,7 +305,7 @@ async function fetchUserInfo(storage: BrowserStorage, accessToken: string): Prom
}
} catch { /* fall through */ }
const response = await fetch(`${config.iamApiUrl}/api/userinfo`, { headers });
const response = await fetch(`${base}/userinfo`, { headers });
if (!response.ok) throw new Error('Failed to fetch user info');
return response.json();
}
+9 -1
View File
@@ -13,9 +13,17 @@
/** Login UI domain (Hanzo ID) */
export const IAM_LOGIN_URL = 'https://hanzo.id';
/** IAM API domain (Casdoor backend) */
/** IAM API domain (Casdoor backend, fronted by Hanzo's API gateway) */
export const IAM_API_URL = 'https://iam.hanzo.ai';
/**
* IAM API path prefix. Hanzo convention is `/v1/<service>/<endpoint>` never
* `/api/`. The Casdoor upstream uses `/api/*` natively but the prod gateway
* rewrites `/v1/iam/*` upstream. Always reference endpoints through this
* constant so prefix changes are a single edit.
*/
export const IAM_API_PATH = '/v1/iam';
/** OAuth2 client ID — override via storage key 'hanzo_config_client_id' */
export const DEFAULT_CLIENT_ID = 'app-hanzo';
+20 -11
View File
@@ -4,16 +4,25 @@
* Lightweight fetch-based client for Hanzo KMS (kms.hanzo.ai).
* Uses IAM bearer tokens for auth no separate KMS credentials needed.
*
* API surface mirrors @hanzo/kms-sdk SecretsApi:
* GET /api/v3/secrets/raw list
* GET /api/v3/secrets/raw/:name get
* POST /api/v3/secrets/raw/:name create
* PATCH /api/v3/secrets/raw/:name update
* DELETE /api/v3/secrets/raw/:name delete
* API path. Hanzo convention is `/v1/<service>/<endpoint>` but the KMS
* upstream is an Infisical fork that publishes its v3 API under the root
* `${KMS_PATH_PREFIX}/secrets/raw`. The kms.hanzo.ai gateway currently passes that
* through unchanged (verified: `kms.hanzo.ai/api/v3/secrets/raw` 401 JSON,
* `/v1/kms/...` SPA HTML). We centralise the path in `KMS_PATH_PREFIX`
* so the day the gateway adds the rewrite we update one constant.
*
* GET {KMS_PATH_PREFIX}/secrets/raw list
* GET {KMS_PATH_PREFIX}/secrets/raw/:name get
* POST {KMS_PATH_PREFIX}/secrets/raw/:name create
* PATCH {KMS_PATH_PREFIX}/secrets/raw/:name update
* DELETE {KMS_PATH_PREFIX}/secrets/raw/:name delete
*/
import { KMS_API_URL } from './config.js';
/** Prefix for every KMS request. See file header. */
const KMS_PATH_PREFIX = '/api/v3';
// ── Types ───────────────────────────────────────────────────────────────
export interface KmsSecret {
@@ -67,7 +76,7 @@ export async function kmsListSecrets(
environment: params.environment,
secretPath: params.secretPath,
});
const resp = await fetch(kmsUrl(`/api/v3/secrets/raw${query}`, baseUrl), {
const resp = await fetch(kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw${query}`, baseUrl), {
headers: kmsHeaders(token),
});
if (!resp.ok) throw new Error(`KMS list failed: ${resp.status} ${await resp.text()}`);
@@ -87,7 +96,7 @@ export async function kmsGetSecret(
secretPath: params.secretPath,
});
const resp = await fetch(
kmsUrl(`/api/v3/secrets/raw/${encodeURIComponent(params.secretName)}${query}`, baseUrl),
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}${query}`, baseUrl),
{ headers: kmsHeaders(token) },
);
if (!resp.ok) throw new Error(`KMS get failed: ${resp.status} ${await resp.text()}`);
@@ -103,7 +112,7 @@ export async function kmsCreateSecret(
baseUrl?: string,
): Promise<KmsSecret> {
const resp = await fetch(
kmsUrl(`/api/v3/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
{
method: 'POST',
headers: kmsHeaders(token),
@@ -129,7 +138,7 @@ export async function kmsUpdateSecret(
baseUrl?: string,
): Promise<KmsSecret> {
const resp = await fetch(
kmsUrl(`/api/v3/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
{
method: 'PATCH',
headers: kmsHeaders(token),
@@ -153,7 +162,7 @@ export async function kmsDeleteSecret(
baseUrl?: string,
): Promise<void> {
const resp = await fetch(
kmsUrl(`/api/v3/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
{
method: 'DELETE',
headers: kmsHeaders(token),
+154
View File
@@ -0,0 +1,154 @@
/**
* Keyboard-shortcut configuration shared between content scripts, the
* popup recorder, and the background. A "shortcut" is a normalised
* description of a keyboard chord (modifiers + key) that tells the
* content script which combination toggles inspect / click-to-source
* navigation. The default is **Ctrl** alone (Mac users would otherwise
* need to hold Option, which interferes with native macOS character
* input). Any combination is configurable through the popup.
*
* Storage shape: `chrome.storage.local["hanzo_shortcut_inspect"] = Shortcut`.
*/
export interface Shortcut {
/** Whether Ctrl (or Cmd on macOS) is part of the chord. */
ctrl: boolean;
/** Cmd/Meta key (macOS) — bound separately from Ctrl. */
meta: boolean;
alt: boolean;
shift: boolean;
/** Optional letter or `Key*` value. Empty string means modifier-only. */
key: string;
}
export const STORAGE_KEY_INSPECT = 'hanzo_shortcut_inspect';
/**
* Default inspect-modifier: **Ctrl** (or Cmd on macOS) alone.
*
* Why not Alt/Option? On macOS, Option is the system-level character-
* compose modifier (Option-c ç, Option-3 £). Using Option for our
* inspector silently swallows real user keystrokes and corrupts text
* input on every page that has us injected. Ctrl is the natural choice
* for a developer-tools modifier (and matches DevTools' Inspect Element
* behaviour on every browser).
*/
export const DEFAULT_INSPECT_SHORTCUT: Shortcut = {
ctrl: true,
meta: false,
alt: false,
shift: false,
key: '',
};
/**
* Match a keyboard / mouse event against a Shortcut. Returns true iff
* every required modifier is held AND every non-required modifier is
* NOT held AND the key matches (or `key === ''` for modifier-only).
*
* On macOS, ctrl matches `event.metaKey` if the saved shortcut has
* meta=true OR ctrl=true (we treat Ctrl-on-Mac and Cmd as equivalent
* for the default many devs assume "Ctrl" really means "the platform
* primary modifier"). To address explicitly, set ctrl=false meta=true.
*/
export function matchesShortcut(
e: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; key?: string },
s: Shortcut,
): boolean {
// Required modifiers — every truthy field of `s` must be set on the event.
if (s.ctrl && !e.ctrlKey) return false;
if (s.meta && !e.metaKey) return false;
if (s.alt && !e.altKey) return false;
if (s.shift && !e.shiftKey) return false;
// Forbidden modifiers — every false field of `s` must NOT be set on
// the event. Otherwise Ctrl-only would match Ctrl-Shift-X too, which
// would surprise users who configured Ctrl alone.
if (!s.ctrl && e.ctrlKey) return false;
if (!s.meta && e.metaKey) return false;
if (!s.alt && e.altKey) return false;
if (!s.shift && e.shiftKey) return false;
// Key match (case-insensitive). Empty key means the chord is
// modifier-only — any subsequent key still satisfies it.
if (s.key && (e.key || '').toLowerCase() !== s.key.toLowerCase()) return false;
return true;
}
/**
* Render a Shortcut as a human-readable string like "Ctrl+Shift+K" or
* "⌘+Click". Used by the popup UI and any place we surface the chord
* to the user.
*/
export function formatShortcut(s: Shortcut, opts?: { mac?: boolean; click?: boolean }): string {
const mac = opts?.mac ?? (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform || ''));
const parts: string[] = [];
if (s.ctrl) parts.push(mac ? '⌃' : 'Ctrl');
if (s.meta) parts.push(mac ? '⌘' : 'Meta');
if (s.alt) parts.push(mac ? '⌥' : 'Alt');
if (s.shift) parts.push(mac ? '⇧' : 'Shift');
if (s.key) parts.push(s.key.length === 1 ? s.key.toUpperCase() : s.key);
if (opts?.click) parts.push('Click');
return parts.join('+') || '(unset)';
}
/**
* Normalise a fresh keydown into a Shortcut payload. Drops Tab/Esc/etc.
* (the recorder UI invites the user to press a chord, not to navigate
* the page) and rejects pure modifier keydowns so the recorder doesn't
* fire on every Shift press while the user is reaching for a letter.
*/
export function shortcutFromEvent(
e: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; key?: string },
): Shortcut | null {
const k = e.key || '';
// Modifier-only is a valid shortcut, but the keydown event for a pure
// modifier (Control / Shift / Alt / Meta) reports `key` as the
// modifier name — record those as modifier-only.
if (['Control', 'Meta', 'Alt', 'Shift'].includes(k)) {
return {
ctrl: !!e.ctrlKey,
meta: !!e.metaKey,
alt: !!e.altKey,
shift: !!e.shiftKey,
key: '',
};
}
// System keys we never want to bind.
if (['Tab', 'Escape', 'Enter', ''].includes(k)) return null;
return {
ctrl: !!e.ctrlKey,
meta: !!e.metaKey,
alt: !!e.altKey,
shift: !!e.shiftKey,
key: k,
};
}
/**
* Read the inspect shortcut from extension storage; fall back to the
* default if absent or malformed. Always returns a valid Shortcut so
* callers don't have to defensively re-default.
*/
export async function loadInspectShortcut(
storage: { get(keys: string[]): Promise<Record<string, any>> },
): Promise<Shortcut> {
try {
const raw = await storage.get([STORAGE_KEY_INSPECT]);
const v = raw[STORAGE_KEY_INSPECT];
if (v && typeof v === 'object'
&& typeof v.ctrl === 'boolean'
&& typeof v.alt === 'boolean'
&& typeof v.shift === 'boolean'
&& typeof v.meta === 'boolean'
&& typeof v.key === 'string') {
return v as Shortcut;
}
} catch { /* fall through */ }
return { ...DEFAULT_INSPECT_SHORTCUT };
}
export async function saveInspectShortcut(
storage: { set(items: Record<string, unknown>): Promise<void> },
s: Shortcut,
): Promise<void> {
await storage.set({ [STORAGE_KEY_INSPECT]: s });
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Tab ID resolution shared between Chrome (cdp-bridge.ts) and Firefox
* (background-firefox.ts) so both bridges accept the same set of input
* formats:
*
* - number returned as-is.
* - "tab-NNN" the targetId string returned by
* Target.getTargets / the tabs MCP action.
* - "NNN" bare numeric string.
* - undefined / null / other null (caller falls back to active tab).
*
* Without this normalisation the bridges silently rejected anything that
* wasn't already a number, which made multi-window automation impossible
* because Target.getTargets returns string ids.
*/
export function parseTabId(raw: unknown): number | null {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
if (typeof raw !== 'string') return null;
if (raw === '') return null; // Number('') === 0, reject explicitly.
// The whole string must be either "tab-NNN" or "NNN" — fully anchored
// so a URL like "https://x.com/?tab-123" cannot be coerced into a tab id.
const m = raw.match(/^(?:tab-)?(\d+)$/);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
}
/**
* Build the target portion of a sendRaw payload. Always returns an object
* (so it composes cleanly with `tabTarget({...other_params})`). Empty when
* the caller didn't specify any tab targeting — caller's downstream
* handler then falls back to the active tab.
*
* Accepts both `tabId` (number / "tab-N" / "N") and `targetId` (string)
* and `tabIndex` (number into current window). `tabId` wins over
* `targetId`, `tabIndex` wins only when the others aren't present.
*/
export function tabTarget(
source: Record<string, unknown> | undefined,
extra?: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...(extra || {}) };
if (!source) return out;
if (source.tabId !== undefined && source.tabId !== null) {
out.tabId = source.tabId;
} else if (source.targetId !== undefined && source.targetId !== null) {
out.tabId = source.targetId;
}
if (source.tabIndex !== undefined && source.tabIndex !== null) {
out.tabIndex = source.tabIndex;
}
return out;
}
/**
* Unwrap a CDP / extension `Runtime.evaluate` response to the bare value.
* Handles every shape the Chrome and Firefox extensions can hand back:
*
* - {result: {value: X}} X (Chrome / CDP)
* - {result: {value: X, type: 'number'}} X (full CDP)
* - {result: X} X (legacy)
* - {value: X} X (Hanzo MCP tools)
* - {} (empty object from undefined Promise serialization in MV3)
* undefined
* - bare value value as-is
*
* Why: `executeScript` in Firefox MV3 returns `[undefined]` for async
* results, which JSON-encodes to `{}`. Without explicit handling the
* caller saw `result: {}` and couldn't tell the difference between
* "evaluation produced an empty object" and "the bridge swallowed the
* actual value". Now we return undefined for the empty-Promise case
* and surface the real value for everything else.
*/
export function unwrapEvaluateResult(raw: unknown): unknown {
if (raw === null || raw === undefined) return raw;
if (typeof raw !== 'object') return raw;
const obj = raw as Record<string, unknown>;
// Most-specific shape first: {result: {value: ...}}
const inner = obj.result;
if (inner && typeof inner === 'object' && 'value' in (inner as object)) {
return (inner as Record<string, unknown>).value;
}
// {result: bare}
if ('result' in obj) return obj.result;
// {value: bare} - hanzo-tools shape
if ('value' in obj) return obj.value;
// Empty {} -> undefined (MV3 Promise drop)
if (Object.keys(obj).length === 0 && !Array.isArray(obj)) return undefined;
return raw;
}
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest';
import {
DEFAULT_INSPECT_SHORTCUT,
formatShortcut,
loadInspectShortcut,
matchesShortcut,
saveInspectShortcut,
shortcutFromEvent,
STORAGE_KEY_INSPECT,
type Shortcut,
} from '../src/shared/shortcut.js';
describe('DEFAULT_INSPECT_SHORTCUT', () => {
it('is Ctrl alone (not Alt — Alt collides with macOS character compose)', () => {
expect(DEFAULT_INSPECT_SHORTCUT.ctrl).toBe(true);
expect(DEFAULT_INSPECT_SHORTCUT.alt).toBe(false);
expect(DEFAULT_INSPECT_SHORTCUT.meta).toBe(false);
expect(DEFAULT_INSPECT_SHORTCUT.shift).toBe(false);
expect(DEFAULT_INSPECT_SHORTCUT.key).toBe('');
});
});
describe('matchesShortcut', () => {
const ctrl: Shortcut = { ctrl: true, alt: false, meta: false, shift: false, key: '' };
const alt: Shortcut = { ctrl: false, alt: true, meta: false, shift: false, key: '' };
const ctrlShiftK: Shortcut = { ctrl: true, alt: false, meta: false, shift: true, key: 'k' };
it('matches Ctrl-only when only ctrlKey is set', () => {
expect(matchesShortcut({ ctrlKey: true }, ctrl)).toBe(true);
});
it('does NOT match Ctrl-only when ctrlKey is unset', () => {
expect(matchesShortcut({}, ctrl)).toBe(false);
expect(matchesShortcut({ altKey: true }, ctrl)).toBe(false);
});
it('does NOT match Ctrl-only when extra modifiers are also held', () => {
// Forbidden-modifier check — ctrl-only is exclusive, so Ctrl+Shift
// should NOT trigger the inspect shortcut.
expect(matchesShortcut({ ctrlKey: true, shiftKey: true }, ctrl)).toBe(false);
expect(matchesShortcut({ ctrlKey: true, altKey: true }, ctrl)).toBe(false);
});
it('matches Alt-only chord against altKey', () => {
expect(matchesShortcut({ altKey: true }, alt)).toBe(true);
expect(matchesShortcut({ ctrlKey: true }, alt)).toBe(false);
});
it('matches a chord with key (Ctrl+Shift+K)', () => {
expect(matchesShortcut({ ctrlKey: true, shiftKey: true, key: 'k' }, ctrlShiftK)).toBe(true);
expect(matchesShortcut({ ctrlKey: true, shiftKey: true, key: 'K' }, ctrlShiftK)).toBe(true);
expect(matchesShortcut({ ctrlKey: true, shiftKey: true, key: 'j' }, ctrlShiftK)).toBe(false);
// Wrong modifier set
expect(matchesShortcut({ ctrlKey: true, key: 'k' }, ctrlShiftK)).toBe(false);
});
it('modifier-only chord ignores subsequent letter keys', () => {
// When the chord is "Ctrl alone", a click while holding Ctrl+'a' still
// matches (because the chord doesn't care which letter).
expect(matchesShortcut({ ctrlKey: true, key: 'a' }, ctrl)).toBe(true);
expect(matchesShortcut({ ctrlKey: true, key: '' }, ctrl)).toBe(true);
});
});
describe('shortcutFromEvent', () => {
it('records a letter chord (Ctrl+K)', () => {
const out = shortcutFromEvent({ ctrlKey: true, key: 'k' });
expect(out).toEqual({ ctrl: true, alt: false, meta: false, shift: false, key: 'k' });
});
it('records a modifier-only chord when keydown is the modifier itself', () => {
// Browser dispatches keydown with key='Control' for Ctrl press alone.
const out = shortcutFromEvent({ ctrlKey: true, key: 'Control' });
expect(out).toEqual({ ctrl: true, alt: false, meta: false, shift: false, key: '' });
});
it('records compound modifiers (Ctrl+Shift)', () => {
const out = shortcutFromEvent({ ctrlKey: true, shiftKey: true, key: 'Shift' });
expect(out).toEqual({ ctrl: true, alt: false, meta: false, shift: true, key: '' });
});
it('rejects Tab / Escape / Enter (these are navigation, not chords)', () => {
expect(shortcutFromEvent({ key: 'Tab' })).toBeNull();
expect(shortcutFromEvent({ key: 'Escape' })).toBeNull();
expect(shortcutFromEvent({ key: 'Enter' })).toBeNull();
expect(shortcutFromEvent({ key: '' })).toBeNull();
});
});
describe('formatShortcut', () => {
const ctrl: Shortcut = { ctrl: true, alt: false, meta: false, shift: false, key: '' };
const ctrlShiftK: Shortcut = { ctrl: true, alt: false, meta: false, shift: true, key: 'k' };
it('renders Ctrl+Click on non-Mac', () => {
expect(formatShortcut(ctrl, { mac: false, click: true })).toBe('Ctrl+Click');
});
it('renders ⌃+Click on Mac', () => {
expect(formatShortcut(ctrl, { mac: true, click: true })).toBe('⌃+Click');
});
it('renders compound chord Ctrl+Shift+K', () => {
expect(formatShortcut(ctrlShiftK, { mac: false })).toBe('Ctrl+Shift+K');
});
it('returns "(unset)" for empty shortcut', () => {
expect(formatShortcut({ ctrl: false, alt: false, meta: false, shift: false, key: '' }))
.toBe('(unset)');
});
});
describe('loadInspectShortcut / saveInspectShortcut', () => {
function makeStorage() {
const store: Record<string, any> = {};
return {
data: store,
get: async (keys: string[]) => Object.fromEntries(keys.map((k) => [k, store[k]])),
set: async (items: Record<string, unknown>) => Object.assign(store, items),
};
}
it('returns the default when nothing is stored', async () => {
const s = await loadInspectShortcut(makeStorage());
expect(s).toEqual(DEFAULT_INSPECT_SHORTCUT);
});
it('round-trips a saved shortcut', async () => {
const storage = makeStorage();
const cust: Shortcut = { ctrl: false, alt: false, meta: true, shift: true, key: 'p' };
await saveInspectShortcut(storage, cust);
expect(storage.data[STORAGE_KEY_INSPECT]).toEqual(cust);
const loaded = await loadInspectShortcut(storage);
expect(loaded).toEqual(cust);
});
it('falls back to default when stored value is malformed', async () => {
const storage = makeStorage();
storage.data[STORAGE_KEY_INSPECT] = { ctrl: 'yes' }; // wrong type
const loaded = await loadInspectShortcut(storage);
expect(loaded).toEqual(DEFAULT_INSPECT_SHORTCUT);
});
it('survives storage.get throwing', async () => {
const storage = {
get: async () => { throw new Error('storage offline'); },
set: async () => {},
};
const loaded = await loadInspectShortcut(storage);
expect(loaded).toEqual(DEFAULT_INSPECT_SHORTCUT);
});
});
@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest';
import {
parseTabId,
tabTarget,
unwrapEvaluateResult,
} from '../src/shared/tab-id.js';
describe('parseTabId', () => {
it('accepts a finite positive number', () => {
expect(parseTabId(123)).toBe(123);
expect(parseTabId(0)).toBe(0);
expect(parseTabId(1888868904)).toBe(1888868904);
});
it('accepts negative numbers (some test fixtures use them)', () => {
expect(parseTabId(-7)).toBe(-7);
});
it('rejects non-finite numbers (NaN / Infinity)', () => {
expect(parseTabId(NaN)).toBeNull();
expect(parseTabId(Infinity)).toBeNull();
expect(parseTabId(-Infinity)).toBeNull();
});
it('parses "tab-NNN" format from Target.getTargets', () => {
expect(parseTabId('tab-1888868904')).toBe(1888868904);
expect(parseTabId('tab-0')).toBe(0);
expect(parseTabId('tab-42')).toBe(42);
});
it('parses bare numeric strings', () => {
expect(parseTabId('123')).toBe(123);
expect(parseTabId('0')).toBe(0);
expect(parseTabId('1888868904')).toBe(1888868904);
});
it('returns null for malformed strings', () => {
expect(parseTabId('')).toBeNull();
expect(parseTabId('abc')).toBeNull();
expect(parseTabId('tab-')).toBeNull();
expect(parseTabId('tab-abc')).toBeNull();
expect(parseTabId('foo-123')).toBeNull(); // not "tab-" prefix
expect(parseTabId('123abc')).toBeNull();
expect(parseTabId('1.5e10abc')).toBeNull();
});
it('returns null for null / undefined / non-string non-number', () => {
expect(parseTabId(null)).toBeNull();
expect(parseTabId(undefined)).toBeNull();
expect(parseTabId({})).toBeNull();
expect(parseTabId([])).toBeNull();
expect(parseTabId(true)).toBeNull();
});
it('does NOT match "tab-NN" embedded mid-string (security)', () => {
// The regex anchors on (start | "tab-") and \d+$, so something like
// "https://x.com/?tab-123" must NOT be parsed as tabId 123.
expect(parseTabId('https://x.com/?tab-123')).toBeNull();
// But pure "tab-123" still works.
expect(parseTabId('tab-123')).toBe(123);
});
});
describe('tabTarget', () => {
it('returns empty object when nothing supplied', () => {
expect(tabTarget(undefined)).toEqual({});
expect(tabTarget({})).toEqual({});
});
it('forwards extra params unchanged', () => {
expect(tabTarget({}, { url: 'https://example.com' }))
.toEqual({ url: 'https://example.com' });
});
it('forwards tabId when present', () => {
expect(tabTarget({ tabId: 42 })).toEqual({ tabId: 42 });
expect(tabTarget({ tabId: 'tab-42' })).toEqual({ tabId: 'tab-42' });
});
it('forwards tabIndex when present', () => {
expect(tabTarget({ tabIndex: 0 })).toEqual({ tabIndex: 0 });
expect(tabTarget({ tabIndex: 3 })).toEqual({ tabIndex: 3 });
});
it('forwards both tabId and tabIndex', () => {
expect(tabTarget({ tabId: 5, tabIndex: 2 }))
.toEqual({ tabId: 5, tabIndex: 2 });
});
it('falls back to targetId when tabId absent', () => {
expect(tabTarget({ targetId: 'tab-99' }))
.toEqual({ tabId: 'tab-99' });
});
it('prefers tabId over targetId when both present', () => {
expect(tabTarget({ tabId: 1, targetId: 'tab-2' }))
.toEqual({ tabId: 1 });
});
it('does NOT forward null / undefined fields', () => {
expect(tabTarget({ tabId: null, tabIndex: undefined } as any))
.toEqual({});
});
it('extra params merged with tab fields, tab fields win on key collision', () => {
const out = tabTarget({ tabId: 7 }, { tabId: 99, url: 'x' });
// tabTarget assigns from `extra` first, then overlays the resolved
// tab targeting — caller's tab id is authoritative.
expect(out.tabId).toBe(7);
expect(out.url).toBe('x');
});
});
describe('unwrapEvaluateResult', () => {
it('returns null/undefined unchanged', () => {
expect(unwrapEvaluateResult(null)).toBeNull();
expect(unwrapEvaluateResult(undefined)).toBeUndefined();
});
it('passes through bare primitives', () => {
expect(unwrapEvaluateResult(42)).toBe(42);
expect(unwrapEvaluateResult('hello')).toBe('hello');
expect(unwrapEvaluateResult(true)).toBe(true);
expect(unwrapEvaluateResult(false)).toBe(false);
expect(unwrapEvaluateResult(0)).toBe(0);
});
it('unwraps the standard Chrome / CDP shape {result: {value: X}}', () => {
expect(unwrapEvaluateResult({ result: { value: 2 } })).toBe(2);
expect(unwrapEvaluateResult({ result: { value: 'ok' } })).toBe('ok');
expect(unwrapEvaluateResult({ result: { value: null } })).toBeNull();
expect(unwrapEvaluateResult({ result: { value: 0 } })).toBe(0);
});
it('unwraps full-CDP shape {result: {value, type}}', () => {
expect(unwrapEvaluateResult({ result: { value: 'hi', type: 'string' } }))
.toBe('hi');
});
it('unwraps legacy {result: bare} shape', () => {
expect(unwrapEvaluateResult({ result: 7 })).toBe(7);
});
it('unwraps hanzo-tools {value: bare} shape', () => {
expect(unwrapEvaluateResult({ value: 5 })).toBe(5);
});
it('returns undefined for empty {} (MV3 Promise drop)', () => {
// The exact bug 1.8.3 fixes: executeScript serializes a Promise as {}
// and the caller saw "result: {}" with no way to tell whether the
// page returned an actual empty object or the bridge swallowed a
// Promise. Empty plain-object → undefined sentinel.
expect(unwrapEvaluateResult({})).toBeUndefined();
});
it('preserves a non-empty object that has none of the known keys', () => {
const arbitrary = { foo: 1, bar: 2 };
expect(unwrapEvaluateResult(arbitrary)).toEqual(arbitrary);
});
it('does NOT collapse arrays to undefined', () => {
expect(unwrapEvaluateResult([])).toEqual([]);
expect(unwrapEvaluateResult([1, 2, 3])).toEqual([1, 2, 3]);
});
it('handles deeply-nested shape: {result: {value: {result: {value: X}}}}', () => {
// Only unwraps ONE layer — recursive unwrap would be wrong because
// the page might legitimately have evaluated to {result: {value: X}}.
expect(unwrapEvaluateResult({ result: { value: { result: { value: 9 } } } }))
.toEqual({ result: { value: 9 } });
});
it('regression: 1+1 case (the bug from 1.8.1)', () => {
// Sanity: the path that broke in 1.8.1 (returned {}) now returns 2.
expect(unwrapEvaluateResult({ result: { value: 2 } })).toBe(2);
});
});