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.
- package/LICENSE +661 -0
- package/README.md +608 -0
- package/backends/chromium/index.ts +50 -0
- package/backends/chromium-py/bridge.py +67 -0
- package/backends/firefox/index.ts +60 -0
- package/backends/firefox-py/bridge.py +64 -0
- package/backends/playwright-base/playwright-plugin.ts +1294 -0
- package/backends/python-adapter.ts +1141 -0
- package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
- package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
- package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
- package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
- package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
- package/backends/python-base/pi_browser_bridge/transport.py +167 -0
- package/backends/python-base/pyproject.toml +15 -0
- package/browser-cookies.ts +88 -0
- package/browser-profile.ts +260 -0
- package/browser-status.ts +84 -0
- package/browser-toggle.ts +527 -0
- package/core/fetch-backend.ts +466 -0
- package/core/guides.ts +467 -0
- package/core/plugin-api.ts +302 -0
- package/core/plugin-config.ts +388 -0
- package/core/plugin-registry.ts +263 -0
- package/core/router.ts +1186 -0
- package/core/shared/accessibility-tree.ts +408 -0
- package/core/shared/bot-detection.ts +187 -0
- package/core/shared/browser-events.ts +111 -0
- package/core/shared/dom-extractor.ts +550 -0
- package/core/shared/nav-settle.ts +187 -0
- package/core/shared/paths.ts +56 -0
- package/core/shared/session-manager.ts +258 -0
- package/core/shared/settings-reader.ts +63 -0
- package/core/shared/snapshot-cache.ts +231 -0
- package/core/shared/storage-state.ts +560 -0
- package/core/shared/task-id.ts +77 -0
- package/core/shared/url-safety.ts +164 -0
- package/index.ts +253 -0
- package/package.json +63 -0
- package/ship-manifest.test.ts +12 -0
- package/tools/browser-back.ts +50 -0
- package/tools/browser-click.ts +74 -0
- package/tools/browser-console.ts +160 -0
- package/tools/browser-inspect.ts +136 -0
- package/tools/browser-navigate.ts +254 -0
- package/tools/browser-press.ts +80 -0
- package/tools/browser-scroll.ts +56 -0
- package/tools/browser-snapshot.ts +90 -0
- package/tools/browser-type.ts +60 -0
- package/tools/index.ts +19 -0
- package/tools/utils.ts +157 -0
- package/tools/web-fetch.ts +147 -0
- package/tools/web-guide.ts +55 -0
- package/tools/web-learn.ts +128 -0
- 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
|
+
}
|