pi-lean-portal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. package/verify-ship-manifest.ts +126 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Plugin Architecture v2 — Core Interface & Unified Result Types
3
+ *
4
+ * This module defines the contract that every interactive browser backend
5
+ * must satisfy. The 12 required operations map to the registered tools
6
+ * (screenshot is required internally for auto-capture). Lifecycle hooks
7
+ * (init, cleanupAll) are called by the framework.
8
+ *
9
+ * Plugins return **raw results**. The router is responsible for
10
+ * cross-cutting transformations (truncation, count fields, botDetected
11
+ * warning injection, etc.).
12
+ */
13
+
14
+ import type { AriaCachedNode } from "./shared/accessibility-tree.js";
15
+
16
+ // ─── Capabilities ────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Read-only capabilities that a plugin advertises. The router uses
20
+ * these to adapt behaviour (e.g. fall back from fullPage to viewport
21
+ * screenshot when unsupported).
22
+ */
23
+ export interface PluginCapabilities {
24
+ /** Can take full-page screenshots */
25
+ supportsFullPageScreenshot: boolean;
26
+ /** Can capture console messages via CDP or equivalent */
27
+ supportsConsoleCapture: boolean;
28
+ /** Can evaluate arbitrary JavaScript in the page */
29
+ supportsJavaScriptEvaluate: boolean;
30
+ /** Can detect bot/anti-automation signals (Cloudflare, CAPTCHA, etc.) */
31
+ supportsBotDetection: boolean;
32
+ /** Can auto-dismiss JS dialogs (alert/confirm/prompt) */
33
+ supportsDialogAutoDismissal: boolean;
34
+ /** Can accept an AbortSignal for long-running navigations */
35
+ supportsAbortSignal: boolean;
36
+ /** Browser engine (used for display/debugging only) */
37
+ engine: "chromium" | "firefox" | "webkit" | string;
38
+ }
39
+
40
+ /** Default capabilities matching a full-featured Chromium backend. */
41
+ export const DEFAULT_CAPABILITIES: PluginCapabilities = {
42
+ supportsFullPageScreenshot: true,
43
+ supportsConsoleCapture: true,
44
+ supportsJavaScriptEvaluate: true,
45
+ supportsBotDetection: true,
46
+ supportsDialogAutoDismissal: true,
47
+ supportsAbortSignal: true,
48
+ engine: "chromium",
49
+ };
50
+
51
+ // ─── Unified Result Types ─────────────────────────────────────────
52
+
53
+ /**
54
+ * A JavaScript dialog event (alert, confirm, prompt, beforeunload) that was
55
+ * auto-dismissed by the browser plugin. Returned as part of navigate,
56
+ * snapshot, and interaction results so the router can surface them to
57
+ * the agent without polluting the accessibility tree text.
58
+ */
59
+ export interface DialogEvent {
60
+ /** Dialog type as reported by the browser */
61
+ type: string;
62
+ /** Dialog message text */
63
+ message: string;
64
+ /** How the dialog was handled (always "accepted" for auto-dismiss) */
65
+ handledAs: "accepted" | "dismissed";
66
+ }
67
+
68
+ /**
69
+ * Base shape shared by all result types.
70
+ * Operations return `{ success: false, error }` for expected failures.
71
+ * They **may throw** for infrastructure failures (process crash, OOM).
72
+ * The router catches throws and normalises them.
73
+ */
74
+ export interface ResultBase {
75
+ success: boolean;
76
+ error?: string;
77
+ }
78
+
79
+ /** Result from browser-navigate */
80
+ export interface NavigateResult extends ResultBase {
81
+ url: string;
82
+ title: string;
83
+ /** Accessibility-tree text with @e refs */
84
+ snapshot: string;
85
+ /** Number of interactive elements found */
86
+ elementCount: number;
87
+ /** Plugin-internal signal: page may be blocked by bot detection */
88
+ botDetected?: boolean;
89
+ /** Whether a dialog (role="dialog" or role="alertdialog") was detected in the parsed element cache */
90
+ dialogDetected?: boolean;
91
+ /** Auto-dismissed JavaScript dialogs (alert/confirm/prompt) since last navigate */
92
+ dialogEvents?: DialogEvent[];
93
+ /** The active profile mode for this session. */
94
+ profileMode?: "none" | "session" | "named";
95
+ /** Which profile was loaded for this session (if any) */
96
+ profileName?: string;
97
+ }
98
+
99
+ /** Result from browser-snapshot */
100
+ export interface SnapshotResult extends ResultBase {
101
+ /** Accessibility-tree text with @e refs */
102
+ snapshot: string;
103
+ /** Number of interactive elements found */
104
+ elementCount: number;
105
+ /** Auto-dismissed JavaScript dialogs (alert/confirm/prompt) since last snapshot */
106
+ dialogEvents?: DialogEvent[];
107
+ }
108
+
109
+ /** Result from interaction tools (click, type, scroll, goBack, press) */
110
+ export interface InteractionResult extends ResultBase {
111
+ /** URL after the interaction (if navigation occurred) */
112
+ newUrl?: string;
113
+ /** Title after the interaction (if navigation occurred) */
114
+ newTitle?: string;
115
+ /** Auto-captured snapshot after the interaction */
116
+ snapshot?: string;
117
+ /** Number of interactive elements in the auto-snapshot */
118
+ elementCount?: number;
119
+ /** Auto-dismissed JavaScript dialogs since the interaction */
120
+ dialogEvents?: DialogEvent[];
121
+ }
122
+
123
+ /** Result from plugin.screenshot() */
124
+ export interface ScreenshotResult extends ResultBase {
125
+ /** JPEG data URI of the screenshot */
126
+ dataUri: string;
127
+ }
128
+
129
+ /** Result from getConsoleMessages */
130
+ export interface ConsoleMessagesResult extends ResultBase {
131
+ messages: Array<{ type: string; text: string }>;
132
+ }
133
+
134
+ /** Result from evaluate (browser-console JS eval) */
135
+ export interface EvaluateResult extends ResultBase {
136
+ result?: unknown;
137
+ }
138
+
139
+ // ─── Cookie & Storage State Types ─────────────────────────────────
140
+
141
+ /**
142
+ * A single browser cookie, matching Playwright's Cookie shape.
143
+ * Fields are optional to accommodate both directions:
144
+ * - Reading (cookies() output): all fields are always populated
145
+ * - Writing (addCookies() input): domain and path are effectively required
146
+ * at runtime by Playwright; sameSite defaults to browser policy (Lax).
147
+ */
148
+ export interface Cookie {
149
+ name: string;
150
+ value: string;
151
+ domain?: string;
152
+ path?: string;
153
+ /** Unix timestamp in seconds; -1 for session cookies */
154
+ expires?: number;
155
+ httpOnly?: boolean;
156
+ secure?: boolean;
157
+ sameSite?: "Strict" | "Lax" | "None";
158
+ }
159
+
160
+ /** Result from browser-getCookies */
161
+ export interface CookieResult extends ResultBase {
162
+ cookies: Cookie[];
163
+ }
164
+
165
+ /** Options for browser-clearCookies — all fields optional (omit all to clear everything) */
166
+ export interface ClearCookiesOptions {
167
+ name?: string;
168
+ domain?: string;
169
+ path?: string;
170
+ }
171
+
172
+ /** Result from browser-getStorageState */
173
+ export interface StorageStateResult extends ResultBase {
174
+ cookies: Cookie[];
175
+ origins: Array<{
176
+ origin: string;
177
+ localStorage: Array<{ name: string; value: string }>;
178
+ }>;
179
+ }
180
+
181
+ // ─── BrowserPlugin Interface ──────────────────────────────────────
182
+
183
+ /**
184
+ * The contract every interactive browser backend must implement.
185
+ *
186
+ * The 12 required operations (plus getElementCache and cookie/storage
187
+ * methods) make up the full contract. Lifecycle hooks are called by
188
+ * the framework, not the agent.
189
+ */
190
+ export interface BrowserPlugin {
191
+ // ── Identity ───────────────────────────────────────────────
192
+ /** Unique stable identifier (e.g. "chromium", "camoufox") */
193
+ readonly name: string;
194
+
195
+ /** Advertised capabilities — read by the router for adaptation */
196
+ readonly capabilities: PluginCapabilities;
197
+
198
+ // ── Lifecycle hooks (framework-triggered) ─────────────────
199
+
200
+ /**
201
+ * Optional one-time initialisation. Called once at plugin
202
+ * registration (extension startup). Receives the `config` bag
203
+ * from `settings.json`.
204
+ */
205
+ init?(config?: Record<string, unknown>): Promise<void>;
206
+
207
+ /**
208
+ * Required shutdown hook. Called on extension shutdown.
209
+ * Cleans up ALL sessions and resources (browsers, subprocesses, etc.).
210
+ */
211
+ cleanupAll(): Promise<void>;
212
+
213
+ // ── Navigation & state ────────────────────────────────────
214
+
215
+ navigate(
216
+ url: string,
217
+ taskId: string,
218
+ timeoutMs: number,
219
+ options?: {
220
+ signal?: AbortSignal;
221
+ /** Playwright storage state for profile-based session restoration */
222
+ storageState?: unknown;
223
+ /** Profile name for shared-context resolution */
224
+ profileName?: string;
225
+ /** Profile mode for shared-context resolution */
226
+ profileMode?: "none" | "session" | "named";
227
+ },
228
+ ): Promise<NavigateResult>;
229
+
230
+ snapshot(taskId: string): Promise<SnapshotResult>;
231
+
232
+ // ── Cookies & storage state ────────────────────────────────
233
+
234
+ /**
235
+ * Get all browser cookies for the session, optionally filtered by URL.
236
+ */
237
+ getCookies(taskId: string, urls?: string[]): Promise<CookieResult>;
238
+
239
+ /**
240
+ * Add cookies to the browser context.
241
+ * Playwright requires name, value, domain, and path at runtime.
242
+ */
243
+ addCookies(taskId: string, cookies: Cookie[]): Promise<ResultBase>;
244
+
245
+ /**
246
+ * Clear cookies from the browser context.
247
+ * With no options, ALL cookies are cleared (Playwright default).
248
+ */
249
+ clearCookies(
250
+ taskId: string,
251
+ options?: ClearCookiesOptions,
252
+ ): Promise<ResultBase>;
253
+
254
+ /**
255
+ * Get the full storage state (cookies + localStorage + IndexedDB).
256
+ * Primarily used by storage-state persistence.
257
+ */
258
+ getStorageState(taskId: string): Promise<StorageStateResult>;
259
+
260
+ // ── Interaction ───────────────────────────────────────────
261
+
262
+ click(taskId: string, ref: string): Promise<InteractionResult>;
263
+
264
+ type(taskId: string, ref: string, text: string): Promise<InteractionResult>;
265
+
266
+ scroll(taskId: string, direction: "up" | "down"): Promise<InteractionResult>;
267
+
268
+ goBack(taskId: string): Promise<InteractionResult>;
269
+
270
+ press(taskId: string, key: string): Promise<InteractionResult>;
271
+
272
+ // ── Media ─────────────────────────────────────────────────
273
+
274
+ screenshot(
275
+ taskId: string,
276
+ options?: { fullPage?: boolean },
277
+ ): Promise<ScreenshotResult>;
278
+
279
+ // ── Console & eval ────────────────────────────────────────
280
+
281
+ getConsoleMessages(taskId: string): Promise<ConsoleMessagesResult>;
282
+
283
+ clearConsole(taskId: string): Promise<void>;
284
+
285
+ evaluate(taskId: string, expression: string): Promise<EvaluateResult>;
286
+
287
+ // ── Element cache access ─────────────────────────────────
288
+
289
+ /**
290
+ * Return the plugin's element cache for the given task.
291
+ * Returns null if the session/task has no cache (navigate or snapshot
292
+ * has not been called yet).
293
+ */
294
+ getElementCache(taskId: string): Map<string, AriaCachedNode> | null;
295
+
296
+ // ── Per-task cleanup ──────────────────────────────────────
297
+
298
+ /**
299
+ * Clean up resources for a specific task (browser context, page, etc.).
300
+ */
301
+ cleanup(taskId: string): Promise<void>;
302
+ }
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Plugin Config Loader — reads browser.plugins from settings.json,
3
+ * validates entries, detects plugin type, and provides typed PluginConfig[].
4
+ *
5
+ * Config is read once at startup. Hot-reload is future work.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ // ─── Plugin Config types ──────────────────────────────────────────
11
+
12
+ /** A single plugin entry from the user's settings.json */
13
+ export interface PluginConfig {
14
+ /** Stable identifier used in strategy param, session tracking, errors */
15
+ name: string;
16
+ /** Directory name under backends/ containing the plugin code */
17
+ dir: string;
18
+ /** Whether this plugin is active (default: true) */
19
+ enabled: boolean;
20
+ /** Plugin-specific overrides passed to init() */
21
+ config: Record<string, unknown>;
22
+ }
23
+
24
+ /** Plugin type — determines how the plugin is loaded and run */
25
+ export type PluginType = "node" | "python";
26
+
27
+ /** Result of inspecting a plugin directory for type detection */
28
+ export interface PluginDetection {
29
+ type: PluginType;
30
+ /** Absolute or relative path to the entry point */
31
+ entryPoint: string;
32
+ }
33
+ import { sanitizeProfileName } from "./shared/storage-state.js";
34
+ import { readMergedSettings } from "./shared/settings-reader.js";
35
+
36
+ // ─── Config types & loading ────────────────────────────────────────
37
+
38
+ /**
39
+ * Parsed browser configuration from settings.json.
40
+ * Provides defaults for all fields — every field is always present.
41
+ */
42
+ export interface BrowserConfig {
43
+ /**
44
+ * Default profile mode or named profile when `browser-navigate` omits the `profile` parameter.
45
+ * - "none": clean slate, no persistence
46
+ * - "session": persist for this conversation
47
+ * - A named profile string (e.g. "shopping", "work")
48
+ */
49
+ defaultProfile: "none" | "session" | string;
50
+ }
51
+
52
+ /** Raw plugin entry from settings.json (before validation) */
53
+ interface RawPluginEntry {
54
+ name?: unknown;
55
+ dir?: unknown;
56
+ enabled?: unknown;
57
+ config?: unknown;
58
+ }
59
+
60
+ /** Result of loading and validating the plugin config */
61
+ export interface PluginConfigLoadResult {
62
+ /** Validated plugin configs in order */
63
+ plugins: PluginConfig[];
64
+ /** Validation errors (non-fatal — logged but not thrown) */
65
+ errors: string[];
66
+ }
67
+
68
+ /**
69
+ * Unified full configuration result — includes both browser and plugin config.
70
+ */
71
+ export interface FullConfig {
72
+ browser: BrowserConfig;
73
+ plugins: PluginConfigLoadResult;
74
+ }
75
+
76
+ /** Internal cache for loadFullConfig() — invalidated via invalidateConfigCache() */
77
+ let _fullConfigCache: FullConfig | null = null;
78
+
79
+ /**
80
+ * Parse the browser config section from settings JSON.
81
+ * Extracts and validates defaultProfile.
82
+ */
83
+ function parseBrowserConfig(
84
+ raw: Record<string, unknown> | undefined,
85
+ ): BrowserConfig {
86
+ const errors: string[] = [];
87
+
88
+ // Defaults
89
+ const config: BrowserConfig = {
90
+ defaultProfile: "session",
91
+ };
92
+
93
+ if (!raw) return config;
94
+
95
+ // ── defaultProfile ───────────────────────────────────────────
96
+ if (raw.defaultProfile !== undefined) {
97
+ if (typeof raw.defaultProfile === "string" && raw.defaultProfile.trim()) {
98
+ const v = raw.defaultProfile.trim();
99
+ // Validate: "none", "session", or a named profile
100
+ if (v === "none" || v === "session") {
101
+ config.defaultProfile = v;
102
+ } else {
103
+ try {
104
+ config.defaultProfile = sanitizeProfileName(v);
105
+ } catch (err) {
106
+ errors.push(
107
+ `browser.defaultProfile: ${err instanceof Error ? err.message : String(err)}`,
108
+ );
109
+ }
110
+ }
111
+ } else {
112
+ errors.push(
113
+ `browser.defaultProfile: expected a non-empty string, got ${typeof raw.defaultProfile}`,
114
+ );
115
+ }
116
+ }
117
+
118
+ // Log validation errors (non-fatal, but surfaced in return)
119
+ for (const err of errors) {
120
+ console.warn(`[pi-lean-portal] Config warning: ${err}`);
121
+ }
122
+
123
+ return config;
124
+ }
125
+
126
+ /**
127
+ * Parse and validate the plugin config section from settings JSON.
128
+ *
129
+ * Extracts and validates the `browser.plugins` array.
130
+ * If no plugins are configured, returns a single default Chromium plugin.
131
+ */
132
+ function parsePluginConfig(
133
+ raw: Record<string, unknown> | undefined,
134
+ backendsRoot: string,
135
+ ): PluginConfigLoadResult {
136
+ const errors: string[] = [];
137
+
138
+ // Extract plugins from the raw browser config section
139
+ const rawPlugins = raw?.["plugins"];
140
+
141
+ // Default fallback: chromium + firefox enabled, python backends disabled
142
+ if (!Array.isArray(rawPlugins)) {
143
+ return {
144
+ plugins: [
145
+ {
146
+ name: "chromium",
147
+ dir: "chromium",
148
+ enabled: true,
149
+ config: {},
150
+ },
151
+ {
152
+ name: "firefox",
153
+ dir: "firefox",
154
+ enabled: true,
155
+ config: {},
156
+ },
157
+ {
158
+ name: "chromium-py",
159
+ dir: "chromium-py",
160
+ enabled: false,
161
+ config: {},
162
+ },
163
+ {
164
+ name: "firefox-py",
165
+ dir: "firefox-py",
166
+ enabled: false,
167
+ config: {},
168
+ },
169
+ ],
170
+ errors: [],
171
+ };
172
+ }
173
+
174
+ // Validate each entry
175
+ const seenNames = new Set<string>();
176
+ const plugins: PluginConfig[] = [];
177
+
178
+ for (let i = 0; i < rawPlugins.length; i++) {
179
+ const validated = validateEntry(rawPlugins[i], i, errors, seenNames);
180
+ if (validated) {
181
+ // Also validate that the directory exists and is unambiguous
182
+ try {
183
+ detectPluginType(validated.dir, backendsRoot);
184
+ } catch (err) {
185
+ errors.push(
186
+ `plugins[${i}] ('${validated.name}'): ${err instanceof Error ? err.message : String(err)}`,
187
+ );
188
+ continue; // Skip this plugin
189
+ }
190
+ plugins.push(validated);
191
+ }
192
+ }
193
+
194
+ return { plugins, errors };
195
+ }
196
+
197
+ /**
198
+ * Read the merged browser config object from settings.json.
199
+ *
200
+ * Looks in:
201
+ * 1. `~/.pi/agent/settings.json` (global)
202
+ * 2. `.pi/settings.json` (project-local, overrides global)
203
+ *
204
+ * Returns the browser config object, or undefined if not present or invalid.
205
+ */
206
+ function readBrowserConfigRaw(): Record<string, unknown> | undefined {
207
+ const merged = readMergedSettings();
208
+ const browserConfig = merged["browser"];
209
+
210
+ if (
211
+ !browserConfig ||
212
+ typeof browserConfig !== "object" ||
213
+ Array.isArray(browserConfig)
214
+ ) {
215
+ return undefined;
216
+ }
217
+
218
+ return browserConfig as Record<string, unknown>;
219
+ }
220
+
221
+ /**
222
+ * Load and cache the full browser configuration from settings.json.
223
+ *
224
+ * Reads settings.json once on first call and caches the result for
225
+ * subsequent calls. Both `loadBrowserConfig()` and `loadPluginConfig()`
226
+ * delegate to this function.
227
+ */
228
+ export function loadFullConfig(backendsRoot?: string): FullConfig {
229
+ if (_fullConfigCache) return _fullConfigCache;
230
+
231
+ const raw = readBrowserConfigRaw();
232
+
233
+ _fullConfigCache = {
234
+ browser: parseBrowserConfig(raw),
235
+ plugins: parsePluginConfig(raw, backendsRoot ?? DEFAULT_BACKENDS_ROOT),
236
+ };
237
+
238
+ return _fullConfigCache;
239
+ }
240
+
241
+ /**
242
+ * Invalidate the config cache — forces the next call to re-read from disk.
243
+ * Used in tests to reset state between test cases.
244
+ */
245
+ export function invalidateConfigCache(): void {
246
+ _fullConfigCache = null;
247
+ }
248
+
249
+ /**
250
+ * Convenience wrapper — returns the `defaultProfile` setting from the
251
+ * cached browser configuration. Delegates to `loadFullConfig()` which
252
+ * reads `settings.json` once and caches the result.
253
+ *
254
+ * The `browser.defaultProfile` field controls what happens when
255
+ * `browser-navigate` is called without an explicit `profile` parameter.
256
+ * See `BrowserConfig` for valid values.
257
+ */
258
+ export function loadBrowserConfig(): BrowserConfig {
259
+ return loadFullConfig().browser;
260
+ }
261
+
262
+ // ─── Validation ───────────────────────────────────────────────────
263
+
264
+ /**
265
+ * Validate a single raw plugin entry from settings.json.
266
+ * Returns a PluginConfig if valid, or adds errors to the error list.
267
+ */
268
+ function validateEntry(
269
+ raw: unknown,
270
+ index: number,
271
+ errors: string[],
272
+ seenNames: Set<string>,
273
+ ): PluginConfig | null {
274
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
275
+ errors.push(
276
+ `plugins[${index}]: Entry must be an object, got ${typeof raw}`,
277
+ );
278
+ return null;
279
+ }
280
+
281
+ const entry = raw as RawPluginEntry;
282
+
283
+ // name is required
284
+ if (typeof entry.name !== "string" || !entry.name.trim()) {
285
+ errors.push(
286
+ `plugins[${index}]: 'name' is required and must be a non-empty string`,
287
+ );
288
+ return null;
289
+ }
290
+
291
+ const name = entry.name.trim();
292
+
293
+ // Check for duplicate names
294
+ if (seenNames.has(name)) {
295
+ errors.push(`plugins[${index}]: Duplicate plugin name '${name}'`);
296
+ return null;
297
+ }
298
+ seenNames.add(name);
299
+
300
+ // dir is required
301
+ if (typeof entry.dir !== "string" || !entry.dir.trim()) {
302
+ errors.push(
303
+ `plugins[${index}]: 'dir' is required and must be a non-empty string`,
304
+ );
305
+ return null;
306
+ }
307
+
308
+ const dir = entry.dir.trim();
309
+
310
+ // enabled defaults to true
311
+ const enabled = typeof entry.enabled === "boolean" ? entry.enabled : true;
312
+
313
+ // config must be an object if provided
314
+ let config: Record<string, unknown> = {};
315
+ if (entry.config !== undefined) {
316
+ if (
317
+ typeof entry.config !== "object" ||
318
+ Array.isArray(entry.config) ||
319
+ entry.config === null
320
+ ) {
321
+ errors.push(`plugins[${index}]: 'config' must be an object if provided`);
322
+ } else {
323
+ config = entry.config as Record<string, unknown>;
324
+ }
325
+ }
326
+
327
+ return { name, dir, enabled, config };
328
+ }
329
+
330
+ // ─── Plugin type detection ────────────────────────────────────────
331
+
332
+ /**
333
+ * Detect the plugin type from the directory contents.
334
+ *
335
+ * - `backends/<dir>/index.ts` exists → Node plugin
336
+ * - `backends/<dir>/bridge.py` exists → Python plugin
337
+ * - Both exist → error (ambiguous)
338
+ * - Neither exists → error
339
+ */
340
+ export function detectPluginType(
341
+ dir: string,
342
+ backendsRoot: string,
343
+ ): PluginDetection {
344
+ const dirPath = join(backendsRoot, dir);
345
+ const indexPath = join(dirPath, "index.ts");
346
+ const bridgePath = join(dirPath, "bridge.py");
347
+
348
+ const hasIndex = existsSync(indexPath);
349
+ const hasBridge = existsSync(bridgePath);
350
+
351
+ if (hasIndex && hasBridge) {
352
+ throw new Error(
353
+ `Plugin dir '${dir}' is ambiguous: both index.ts and bridge.py found. Remove one.`,
354
+ );
355
+ }
356
+
357
+ if (hasIndex) {
358
+ return { type: "node", entryPoint: indexPath };
359
+ }
360
+
361
+ if (hasBridge) {
362
+ return { type: "python", entryPoint: bridgePath };
363
+ }
364
+
365
+ throw new Error(
366
+ `Plugin dir '${dir}' has no entry point. Expected index.ts (Node) or bridge.py (Python).`,
367
+ );
368
+ }
369
+
370
+ // ─── Main loader ───────────────────────────────────────────────────
371
+
372
+ /**
373
+ * Default backends root — relative to this file.
374
+ * core/plugin-config.ts → backends/ is ../../backends/
375
+ */
376
+ export const DEFAULT_BACKENDS_ROOT = join(__dirname, "..", "backends");
377
+
378
+ /**
379
+ * Load and validate the plugin configuration.
380
+ *
381
+ * If no `browser.plugins` config exists, returns the default fallback
382
+ * (chromium + firefox enabled, chromium-py + firefox-py disabled).
383
+ */
384
+ export function loadPluginConfig(
385
+ backendsRoot: string = DEFAULT_BACKENDS_ROOT,
386
+ ): PluginConfigLoadResult {
387
+ return loadFullConfig(backendsRoot).plugins;
388
+ }