feat!: harden raw browser control and packaging
Testing / remote-protocol-compat (0.9.3) (push) Successful in 40s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 38s
Testing / test (push) Failing after 1m3s
Package Extension / package-extension (push) Successful in 29s
Build & Publish Package / publish (push) Successful in 33s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 40s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 38s
Testing / test (push) Failing after 1m3s
Package Extension / package-extension (push) Successful in 29s
Build & Publish Package / publish (push) Successful in 33s
- Add safe-by-default policy gates for raw command surfaces: command, script, and serve-http /command. - Require explicit opt-ins for page reads, browser control, and high-risk commands such as dom.eval, storage.*, and screenshots. - Remove all cookies support from CLI, SDK, extension commands, permissions, constants, docs, and tests. - Add diagnostic, events, watch, workspace, remote, raw command, script, HTTP gateway, tree-view, session import/export, and extension info/capability commands. - Add Chrome Web Store packaging that strips manifest.key while keeping local packages with a stable native-messaging extension ID. - Bump browser-cli and extension version to 0.14.1 and cover the new behavior with pytest and extension packaging tests. BREAKING CHANGE: cookies commands and the b.cookies SDK namespace have been removed; generic raw command execution now blocks non-safe commands unless explicitly allowed.
This commit is contained in:
@@ -11,7 +11,7 @@ export interface CommandContext { jobs: JobManager; }
|
||||
/**
|
||||
* A command group bundles a set of related subcommands. `commands` is keyed by
|
||||
* the FULL command id (e.g. "tabs.close") so groups spanning multiple
|
||||
* namespaces (dom/extract/page, storage/cookies, session/clients) register
|
||||
* namespaces (dom/extract/page, storage, session/clients) register
|
||||
* uniformly. `namespace` is documentation/grouping only.
|
||||
*/
|
||||
export abstract class CommandGroup {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { executeScript, isScriptableUrl, resolveTabUrl } from '../core';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { Json, StorageGetArgs, StorageSetArgs, CookiesListArgs, CookiesGetArgs, CookiesSetArgs } from '../types';
|
||||
import type { Json, StorageGetArgs, StorageSetArgs } from '../types';
|
||||
|
||||
export class BrowserDataCommands extends CommandGroup {
|
||||
readonly namespace = "storage";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"storage.get": (a: StorageGetArgs) => this.storageGet(a),
|
||||
"storage.set": (a: StorageSetArgs) => this.storageSet(a),
|
||||
"cookies.list": (a: CookiesListArgs) => this.cookiesList(a),
|
||||
"cookies.get": (a: CookiesGetArgs) => this.cookiesGet(a),
|
||||
"cookies.set": (a: CookiesSetArgs) => this.cookiesSet(a),
|
||||
};
|
||||
|
||||
private async storageGet({ key, type = "local", tabId }: StorageGetArgs = {}) {
|
||||
@@ -49,26 +46,4 @@ export class BrowserDataCommands extends CommandGroup {
|
||||
return results[0]?.result ?? false;
|
||||
}
|
||||
|
||||
private async cookiesList({ url, domain, name }: CookiesListArgs = {}) {
|
||||
const details: chrome.cookies.GetAllDetails = {};
|
||||
if (url) details.url = url;
|
||||
if (domain) details.domain = domain;
|
||||
if (name) details.name = name;
|
||||
return await chrome.cookies.getAll(details);
|
||||
}
|
||||
|
||||
private async cookiesGet({ url, name }: CookiesGetArgs) {
|
||||
return await chrome.cookies.get({ url, name });
|
||||
}
|
||||
|
||||
private async cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite }: CookiesSetArgs = {}) {
|
||||
const details: chrome.cookies.SetDetails = { url, name, value };
|
||||
if (domain != null) details.domain = domain;
|
||||
if (path != null) details.path = path;
|
||||
if (secure != null) details.secure = secure;
|
||||
if (httpOnly != null) details.httpOnly = httpOnly;
|
||||
if (expirationDate != null) details.expirationDate = expirationDate;
|
||||
if (sameSite != null) details.sameSite = sameSite;
|
||||
return await chrome.cookies.set(details);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,36 @@ export class ExtensionCommands extends CommandGroup {
|
||||
setTimeout(() => chrome.runtime.reload(), 200);
|
||||
return { reloading: true };
|
||||
},
|
||||
"extension.info": () => this.extensionInfo(),
|
||||
"extension.capabilities": () => this.capabilities(),
|
||||
};
|
||||
|
||||
private capabilities() {
|
||||
return [
|
||||
"extension.info",
|
||||
"extension.capabilities",
|
||||
"navigate.open.focus",
|
||||
"navigate.open.background",
|
||||
"tabs.close.tabIds",
|
||||
"tabs.merge_windows.audibleAware",
|
||||
"session.export",
|
||||
"session.import",
|
||||
"jobs.progress",
|
||||
"jobs.cancel",
|
||||
"content-dispatch.bundle",
|
||||
];
|
||||
}
|
||||
|
||||
private extensionInfo() {
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
return {
|
||||
id: chrome.runtime.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
manifestVersion: manifest.manifest_version,
|
||||
browser: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
capabilities: this.capabilities(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CommandGroup } from '../classes/CommandGroup';
|
||||
import { AutoSaveManager } from './autosave';
|
||||
import { captureCurrentSession } from './session-snapshot';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { LazySessionMap, SessionSaveArgs, SessionRemoveArgs, SessionDiffArgs, SessionLoadArgs, SessionAutoSaveArgs, ClientsRenameProfileArgs } from '../types';
|
||||
import type { Json, LazySessionMap, SessionSaveArgs, SessionRemoveArgs, SessionDiffArgs, SessionImportArgs, SessionLoadArgs, SessionAutoSaveArgs, ClientsRenameProfileArgs, StoredSession } from '../types';
|
||||
|
||||
function lazyPlaceholderUrl(url: string) {
|
||||
const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch]));
|
||||
@@ -19,6 +19,8 @@ export class SessionCommands extends CommandGroup {
|
||||
"session.list": () => this.sessionList(),
|
||||
"session.remove": (a: SessionRemoveArgs) => this.sessionRemove(a),
|
||||
"session.diff": (a: SessionDiffArgs) => this.sessionDiff(a),
|
||||
"session.export": (a: SessionSaveArgs) => this.sessionExport(a),
|
||||
"session.import": (a: SessionImportArgs) => this.sessionImport(a),
|
||||
"session.auto_save": (a: SessionAutoSaveArgs) => this.autoSaveManager.setEnabled(Boolean(a.enabled)),
|
||||
"clients.list": () => this.clientsList(),
|
||||
"clients.rename_profile": (a: ClientsRenameProfileArgs) => this.clientsRenameProfile(a),
|
||||
@@ -131,6 +133,31 @@ export class SessionCommands extends CommandGroup {
|
||||
};
|
||||
}
|
||||
|
||||
private async sessionExport({ name }: SessionSaveArgs) {
|
||||
const sessions = await getSessions();
|
||||
if (!name) return { sessions };
|
||||
const session = sessions[name];
|
||||
if (!session) throw new Error(`Session '${name}' not found`);
|
||||
return { name, session };
|
||||
}
|
||||
|
||||
private isSession(value: Json | undefined): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const candidate = value as object as StoredSession;
|
||||
return Array.isArray(candidate.tabs) || Array.isArray(candidate.urls);
|
||||
}
|
||||
|
||||
private async sessionImport({ name, session, overwrite = false }: SessionImportArgs) {
|
||||
if (!name) throw new Error("Session name is required");
|
||||
if (!this.isSession(session)) throw new Error("Session payload must contain tabs or urls");
|
||||
const sessions = await getSessions();
|
||||
if (!overwrite && sessions[name]) throw new Error(`Session '${name}' already exists`);
|
||||
const stored = session as object as StoredSession;
|
||||
sessions[name] = { ...stored, savedAt: Number(stored.savedAt) || Date.now() };
|
||||
await chrome.storage.local.set({ sessions });
|
||||
return { name, tabs: getSessionTabs(sessions[name]).length };
|
||||
}
|
||||
|
||||
private async clientsList() {
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const alias = await getProfileAlias();
|
||||
|
||||
@@ -72,24 +72,11 @@ export type DomArgs = ContentArgs & { tabId?: number };
|
||||
// ── Browser data ────────────────────────────────────────────────────────────────
|
||||
export interface StorageGetArgs { key?: string; type?: string; tabId?: number; }
|
||||
export interface StorageSetArgs { key?: string; value?: Json; type?: string; tabId?: number; }
|
||||
export interface CookiesListArgs { url?: string; domain?: string; name?: string; }
|
||||
export interface CookiesGetArgs { url?: string; name?: string; }
|
||||
export interface CookiesSetArgs {
|
||||
url?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
expirationDate?: number;
|
||||
sameSite?: `${chrome.cookies.SameSiteStatus}`;
|
||||
}
|
||||
|
||||
// ── Session ─────────────────────────────────────────────────────────────────────
|
||||
export interface SessionSaveArgs { name?: string; }
|
||||
export interface SessionRemoveArgs { name?: string; }
|
||||
export interface SessionDiffArgs { nameA?: string; nameB?: string; }
|
||||
export interface SessionImportArgs { name?: string; session?: Json; overwrite?: boolean; }
|
||||
export interface SessionLoadArgs {
|
||||
name?: string;
|
||||
gentleMode?: string;
|
||||
|
||||
Reference in New Issue
Block a user