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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL Safety Checks
|
|
3
|
+
*
|
|
4
|
+
* Prevents SSRF (Server-Side Request Forgery) and secret exfiltration
|
|
5
|
+
* by validating URLs before navigation.
|
|
6
|
+
*
|
|
7
|
+
* Patterns from hermes-agent's url_safety.py.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ─── Blocked hostnames ───────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/** Internal/private network ranges that should never be accessed */
|
|
13
|
+
const BLOCKED_HOSTNAMES = [
|
|
14
|
+
"localhost",
|
|
15
|
+
"127.0.0.1",
|
|
16
|
+
"0.0.0.0",
|
|
17
|
+
"::1",
|
|
18
|
+
"169.254.169.254", // AWS/GCP metadata endpoint
|
|
19
|
+
"metadata.google.internal",
|
|
20
|
+
"100.100.100.200", // Alibaba Cloud metadata
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Regex for private IP ranges (10.x, 172.16-31.x, 192.168.x) */
|
|
24
|
+
const PRIVATE_IP_REGEX = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
|
25
|
+
|
|
26
|
+
// ─── Blocked URL patterns ─────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** File-like schemes that shouldn't be followed */
|
|
29
|
+
const BLOCKED_SCHEMES = ["file:", "ftp:", "data:", "javascript:", "vbscript:"];
|
|
30
|
+
|
|
31
|
+
// ─── Secret detection ────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Patterns that look like API keys, tokens, or passwords in URLs.
|
|
35
|
+
* Follows hermes-agent's approach: checks for known key prefixes rather
|
|
36
|
+
* than generic length-based patterns, reducing false positives.
|
|
37
|
+
* Also checks the percent-decoded URL for encoded secrets.
|
|
38
|
+
*/
|
|
39
|
+
const SECRET_PATTERNS = [
|
|
40
|
+
// Generic API keys in query params (specific param names)
|
|
41
|
+
/[?&](api[_-]?key|token|secret|password|passwd|auth|credential|private[_-]?key|access[_-]?key)=/i,
|
|
42
|
+
// Bearer tokens in headers (unlikely in URLs but check anyway)
|
|
43
|
+
/Bearer\s+[A-Za-z0-9\-._~+/]{20,}=*/i,
|
|
44
|
+
// GitHub tokens: ghp_, ghs_, ghg_, gho_, ghu_
|
|
45
|
+
/gh[psogu]_[A-Za-z0-9_]{36}/,
|
|
46
|
+
// Slack tokens: xoxb-, xoxp-, xoxa-, xoxs-, xoxr-
|
|
47
|
+
/xox[bpras]-[A-Za-z0-9-]+/,
|
|
48
|
+
// Stripe: sk_live_, sk_test_, pk_live_, pk_test_, whsec_
|
|
49
|
+
/(sk|pk)_(live|test)_[A-Za-z0-9]+/,
|
|
50
|
+
/whsec_[A-Za-z0-9]+/,
|
|
51
|
+
// AWS access key ID (starts with AKIA)
|
|
52
|
+
/AKIA[A-Z0-9]{16}/,
|
|
53
|
+
// Google API keys (AIza prefix)
|
|
54
|
+
/AIza[0-9A-Za-z\-_]{35}/,
|
|
55
|
+
// Discord bot tokens
|
|
56
|
+
/[MN][A-Za-z\d]{23}\.[XZ][A-Za-z\d]{6}\.[A-Za-z\d]{27}/,
|
|
57
|
+
// SendGrid API keys (SG. prefix)
|
|
58
|
+
/SG\.[A-Za-z0-9-_]{22}\.[A-Za-z0-9-_]{43}/,
|
|
59
|
+
// HubSpot API keys
|
|
60
|
+
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/,
|
|
61
|
+
// Generic secret in URL path segment (e.g., "secret=..." in path)
|
|
62
|
+
/\/secret(?:s)?\//i,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// ─── Types ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export interface UrlSafetyResult {
|
|
68
|
+
/** Whether the URL is safe to navigate to */
|
|
69
|
+
safe: boolean;
|
|
70
|
+
/** Human-readable reason if not safe */
|
|
71
|
+
reason?: string;
|
|
72
|
+
/** The specific issue category */
|
|
73
|
+
category?: "ssrf" | "scheme" | "secret" | "malformed";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Validation ───────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export function validateUrl(rawUrl: string): UrlSafetyResult {
|
|
79
|
+
let parsed: URL;
|
|
80
|
+
try {
|
|
81
|
+
parsed = new URL(rawUrl);
|
|
82
|
+
} catch {
|
|
83
|
+
return { safe: false, reason: "Malformed URL", category: "malformed" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check scheme
|
|
87
|
+
const scheme = parsed.protocol.toLowerCase();
|
|
88
|
+
if (BLOCKED_SCHEMES.includes(scheme)) {
|
|
89
|
+
return {
|
|
90
|
+
safe: false,
|
|
91
|
+
reason: `Blocked scheme: ${scheme}`,
|
|
92
|
+
category: "scheme",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (scheme !== "http:" && scheme !== "https:") {
|
|
97
|
+
return {
|
|
98
|
+
safe: false,
|
|
99
|
+
reason: `Unsupported scheme: ${scheme}. Only http and https are allowed.`,
|
|
100
|
+
category: "scheme",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check hostname against blocked list
|
|
105
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
106
|
+
for (const blocked of BLOCKED_HOSTNAMES) {
|
|
107
|
+
if (hostname === blocked || hostname.endsWith("." + blocked)) {
|
|
108
|
+
return {
|
|
109
|
+
safe: false,
|
|
110
|
+
reason: `Blocked hostname: ${hostname} (private/internal network)`,
|
|
111
|
+
category: "ssrf",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check for private IP ranges
|
|
117
|
+
if (PRIVATE_IP_REGEX.test(hostname)) {
|
|
118
|
+
return {
|
|
119
|
+
safe: false,
|
|
120
|
+
reason: `Blocked IP range: ${hostname} (private network)`,
|
|
121
|
+
category: "ssrf",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check for IPv6 loopback
|
|
126
|
+
if (
|
|
127
|
+
hostname.startsWith("[") &&
|
|
128
|
+
(hostname.includes("::1") || hostname.includes("::ffff:127"))
|
|
129
|
+
) {
|
|
130
|
+
return {
|
|
131
|
+
safe: false,
|
|
132
|
+
reason: `Blocked IPv6 address: ${hostname} (loopback)`,
|
|
133
|
+
category: "ssrf",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check for secrets in URL (raw and percent-decoded)
|
|
138
|
+
const urlStr = parsed.toString();
|
|
139
|
+
try {
|
|
140
|
+
const decodedUrl = decodeURIComponent(urlStr);
|
|
141
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
142
|
+
if (pattern.test(urlStr) || pattern.test(decodedUrl)) {
|
|
143
|
+
return {
|
|
144
|
+
safe: false,
|
|
145
|
+
reason: `Potential secret detected in URL. Remove sensitive parameters before navigating.`,
|
|
146
|
+
category: "secret",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// If decoding fails, just check the raw URL
|
|
152
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
153
|
+
if (pattern.test(urlStr)) {
|
|
154
|
+
return {
|
|
155
|
+
safe: false,
|
|
156
|
+
reason: `Potential secret detected in URL. Remove sensitive parameters before navigating.`,
|
|
157
|
+
category: "secret",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { safe: true };
|
|
164
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as router from "./core/router.js";
|
|
3
|
+
|
|
4
|
+
import { cleanupFetchTempFiles } from "./core/fetch-backend.js";
|
|
5
|
+
import { pluginRegistry } from "./core/plugin-registry.js";
|
|
6
|
+
import {
|
|
7
|
+
loadPluginConfig,
|
|
8
|
+
detectPluginType,
|
|
9
|
+
DEFAULT_BACKENDS_ROOT,
|
|
10
|
+
invalidateConfigCache,
|
|
11
|
+
} from "./core/plugin-config.js";
|
|
12
|
+
import { ChromiumPlugin } from "./backends/chromium/index.js";
|
|
13
|
+
import { PythonPluginAdapter } from "./backends/python-adapter.js";
|
|
14
|
+
import type { PythonBridgeConfig } from "./backends/python-adapter.js";
|
|
15
|
+
|
|
16
|
+
import { sessionManager } from "./core/shared/session-manager.js";
|
|
17
|
+
import { removeAllSnapshotFiles } from "./core/shared/snapshot-cache.js";
|
|
18
|
+
import initBrowserToggle from "./browser-toggle.js";
|
|
19
|
+
import { updateFooterStatus, getLastCtx, setLastCtx } from "./tools/utils.js";
|
|
20
|
+
import { deleteSessionKey, resetTaskIds } from "./core/shared/task-id.js";
|
|
21
|
+
import { resetToggleModuleState } from "./browser-toggle.js";
|
|
22
|
+
|
|
23
|
+
// ─── Tool definitions ────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
browserNavigateTool,
|
|
27
|
+
browserSnapshotTool,
|
|
28
|
+
browserClickTool,
|
|
29
|
+
browserTypeTool,
|
|
30
|
+
browserScrollTool,
|
|
31
|
+
browserBackTool,
|
|
32
|
+
browserPressTool,
|
|
33
|
+
browserConsoleTool,
|
|
34
|
+
browserInspectTool,
|
|
35
|
+
webFetchTool,
|
|
36
|
+
webGuideTool,
|
|
37
|
+
webLearnTool,
|
|
38
|
+
} from "./tools/index.js";
|
|
39
|
+
|
|
40
|
+
// ============================================================
|
|
41
|
+
// Extension entry point
|
|
42
|
+
// ============================================================
|
|
43
|
+
export default function (pi: ExtensionAPI) {
|
|
44
|
+
// --- Ensure idempotent re-invocation ----------------------------
|
|
45
|
+
// pi reuses the cached extension factory on /resume (same cwd),
|
|
46
|
+
// which re-invokes this function with the same module-level
|
|
47
|
+
// singletons. Reset them here so the second load is safe.
|
|
48
|
+
pluginRegistry.clear();
|
|
49
|
+
invalidateConfigCache();
|
|
50
|
+
resetTaskIds();
|
|
51
|
+
resetToggleModuleState();
|
|
52
|
+
|
|
53
|
+
// --- Plugin registration ----------------------------------------
|
|
54
|
+
const { plugins: pluginConfigs, errors: configErrors } = loadPluginConfig();
|
|
55
|
+
|
|
56
|
+
// Log config errors
|
|
57
|
+
for (const err of configErrors) {
|
|
58
|
+
console.warn(`[pi-lean-portal] Plugin config error: ${err}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── First pass: collect and validate all configs ──────────────
|
|
62
|
+
// (synchronous — ensures config array order is captured before any async work)
|
|
63
|
+
const validConfigs: Array<{
|
|
64
|
+
config: (typeof pluginConfigs)[number];
|
|
65
|
+
detection: import("./core/plugin-config.js").PluginDetection;
|
|
66
|
+
}> = [];
|
|
67
|
+
|
|
68
|
+
for (const config of pluginConfigs) {
|
|
69
|
+
let detection;
|
|
70
|
+
try {
|
|
71
|
+
detection = detectPluginType(config.dir, DEFAULT_BACKENDS_ROOT);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(
|
|
74
|
+
`[pi-lean-portal] Plugin '${config.name}' (dir: '${config.dir}'): ${err instanceof Error ? err.message : String(err)}`,
|
|
75
|
+
);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
validConfigs.push({ config, detection });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Seed registry with config array order ────────────────────
|
|
82
|
+
// This preserves the user's declared priority even when some
|
|
83
|
+
// plugins load asynchronously (Node via dynamic import) while
|
|
84
|
+
// others register synchronously (Python adapter).
|
|
85
|
+
if (validConfigs.length > 0) {
|
|
86
|
+
pluginRegistry.seedOrder(validConfigs.map(({ config }) => config.name));
|
|
87
|
+
} else {
|
|
88
|
+
// Fallback: no valid configs → register default Chromium plugin
|
|
89
|
+
const plugin = new ChromiumPlugin();
|
|
90
|
+
try {
|
|
91
|
+
pluginRegistry.register(plugin, {
|
|
92
|
+
name: "chromium",
|
|
93
|
+
dir: "chromium",
|
|
94
|
+
enabled: true,
|
|
95
|
+
config: {},
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(
|
|
99
|
+
"[pi-lean-portal] Failed to register default Chromium plugin:",
|
|
100
|
+
err,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
plugin.init({}).catch((err: unknown) => {
|
|
104
|
+
console.error(
|
|
105
|
+
"[pi-lean-portal] Failed to init default Chromium plugin:",
|
|
106
|
+
err,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Second pass: load and register plugins ───────────────────
|
|
112
|
+
// Node plugins register asynchronously (after dynamic import resolves).
|
|
113
|
+
// Python plugins register synchronously here.
|
|
114
|
+
// The pre-seeded ordering ensures all plugins get the correct priority level
|
|
115
|
+
// regardless of when register() is called.
|
|
116
|
+
for (const { config, detection } of validConfigs) {
|
|
117
|
+
if (detection.type === "node") {
|
|
118
|
+
// Node-based backend — dynamically import the detected plugin
|
|
119
|
+
(async () => {
|
|
120
|
+
try {
|
|
121
|
+
const mod: any = await import(detection.entryPoint);
|
|
122
|
+
const PluginClass: new () => import("./core/plugin-api.js").BrowserPlugin =
|
|
123
|
+
mod.default;
|
|
124
|
+
if (!PluginClass || typeof PluginClass !== "function") {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Plugin '${config.name}' (${detection.entryPoint}) must export a default class implementing BrowserPlugin`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const plugin = new PluginClass();
|
|
130
|
+
pluginRegistry.register(plugin, config);
|
|
131
|
+
plugin.init?.(config.config).catch((err: unknown) => {
|
|
132
|
+
console.error(
|
|
133
|
+
`[pi-lean-portal] Failed to init plugin '${config.name}':`,
|
|
134
|
+
err,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
} catch (err: unknown) {
|
|
138
|
+
console.error(
|
|
139
|
+
`[pi-lean-portal] Failed to load Node plugin '${config.name}' (dir: '${config.dir}'): ${err instanceof Error ? err.message : String(err)}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
})();
|
|
143
|
+
} else if (detection.type === "python") {
|
|
144
|
+
// Python-based backend via JSON-RPC bridge
|
|
145
|
+
const bridgeConfig: PythonBridgeConfig = {
|
|
146
|
+
bridgeScript: detection.entryPoint,
|
|
147
|
+
};
|
|
148
|
+
// Merge any user-provided config overrides
|
|
149
|
+
if (config.config) {
|
|
150
|
+
const userConfig = config.config as Partial<PythonBridgeConfig>;
|
|
151
|
+
if (userConfig.pythonPath)
|
|
152
|
+
bridgeConfig.pythonPath = userConfig.pythonPath;
|
|
153
|
+
if (userConfig.pythonArgs)
|
|
154
|
+
bridgeConfig.pythonArgs = userConfig.pythonArgs;
|
|
155
|
+
if (userConfig.capabilities)
|
|
156
|
+
bridgeConfig.capabilities = userConfig.capabilities;
|
|
157
|
+
if (userConfig.transportTimeoutMs)
|
|
158
|
+
bridgeConfig.transportTimeoutMs = userConfig.transportTimeoutMs;
|
|
159
|
+
}
|
|
160
|
+
const adapter = new PythonPluginAdapter(config.name, bridgeConfig);
|
|
161
|
+
try {
|
|
162
|
+
pluginRegistry.register(adapter, config);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(
|
|
165
|
+
`[pi-lean-portal] Failed to register Python plugin '${config.name}':`,
|
|
166
|
+
err,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
adapter.init(config.config).catch((err: unknown) => {
|
|
170
|
+
console.error(
|
|
171
|
+
`[pi-lean-portal] Failed to init Python plugin '${config.name}':`,
|
|
172
|
+
err,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
// Exhaustiveness guard — PluginType is currently "node" | "python"
|
|
177
|
+
const _exhaustive: never = detection.type;
|
|
178
|
+
console.warn(
|
|
179
|
+
`[pi-lean-portal] Plugin '${config.name}' has unknown type '${_exhaustive as string}'.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Register tools ---------------------------------------------
|
|
185
|
+
pi.registerTool(webFetchTool);
|
|
186
|
+
pi.registerTool(browserNavigateTool);
|
|
187
|
+
pi.registerTool(browserSnapshotTool);
|
|
188
|
+
pi.registerTool(browserClickTool);
|
|
189
|
+
pi.registerTool(browserTypeTool);
|
|
190
|
+
pi.registerTool(browserScrollTool);
|
|
191
|
+
pi.registerTool(browserBackTool);
|
|
192
|
+
pi.registerTool(browserPressTool);
|
|
193
|
+
pi.registerTool(browserConsoleTool);
|
|
194
|
+
pi.registerTool(browserInspectTool);
|
|
195
|
+
pi.registerTool(webGuideTool);
|
|
196
|
+
pi.registerTool(webLearnTool);
|
|
197
|
+
|
|
198
|
+
// --- Register commands ------------------------------------------
|
|
199
|
+
initBrowserToggle(pi);
|
|
200
|
+
|
|
201
|
+
// --- Profile change callback for TUI status updates ------------
|
|
202
|
+
router.setOnProfileChange((taskId, profileName, profileMode) => {
|
|
203
|
+
// Update TUI status bar on any profile lifecycle event
|
|
204
|
+
const lastCtx = getLastCtx();
|
|
205
|
+
if (lastCtx) {
|
|
206
|
+
updateFooterStatus(lastCtx);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Debug logging when BROWSER_DEBUG is set
|
|
210
|
+
if (process.env.BROWSER_DEBUG) {
|
|
211
|
+
const parts = [`[browser] profile_changed: task=${taskId}`];
|
|
212
|
+
if (profileName) parts.push(`profile=${profileName}`);
|
|
213
|
+
if (profileMode) parts.push(`mode=${profileMode}`);
|
|
214
|
+
console.error(parts.join(" "));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// --- Startup ----------------------------------------------------
|
|
219
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
220
|
+
const pluginNames = pluginRegistry.available().join(", ");
|
|
221
|
+
ctx.ui.notify(
|
|
222
|
+
`🌐 Browser extension loaded (plugins: ${pluginNames}). Try: web-fetch for static pages or browser-navigate for interactive browsing.`,
|
|
223
|
+
"info",
|
|
224
|
+
);
|
|
225
|
+
updateFooterStatus(ctx);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// --- Cleanup ----------------------------------------------------
|
|
229
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
230
|
+
setLastCtx(null);
|
|
231
|
+
const piSessionId = (ctx as any)?.sessionManager?.getSessionId?.();
|
|
232
|
+
if (piSessionId) {
|
|
233
|
+
deleteSessionKey(piSessionId);
|
|
234
|
+
// Per-conversation fetch cleanup — prevents cross-conversation eviction
|
|
235
|
+
const tid = sessionManager.getTaskIdForPiSessionId(piSessionId);
|
|
236
|
+
if (tid) cleanupFetchTempFiles(tid);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Clean up all registered plugins
|
|
240
|
+
const ordered = pluginRegistry.getOrdered();
|
|
241
|
+
for (const { plugin } of ordered) {
|
|
242
|
+
await plugin.cleanupAll().catch(() => {});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await sessionManager.removeAll();
|
|
246
|
+
removeAllSnapshotFiles();
|
|
247
|
+
try {
|
|
248
|
+
ctx?.ui?.setStatus?.("browser", "");
|
|
249
|
+
} catch {
|
|
250
|
+
// ctx.ui may not be available during shutdown
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-lean-portal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension. Interactive web browsing for Pi — Playwright Chromium/Firefox, accessibility-tree snapshots, profiles, cookies, guides. Owns the /web command.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"browser",
|
|
9
|
+
"playwright",
|
|
10
|
+
"web"
|
|
11
|
+
],
|
|
12
|
+
"license": "AGPL-3.0-only",
|
|
13
|
+
"author": "",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/coreyryanhanson/pi-lean-dimension.git",
|
|
18
|
+
"directory": "packages/pi-lean-portal"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"index.ts",
|
|
25
|
+
"browser-toggle.ts",
|
|
26
|
+
"browser-profile.ts",
|
|
27
|
+
"browser-cookies.ts",
|
|
28
|
+
"browser-status.ts",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"backends/",
|
|
31
|
+
"!backends/python-base/.venv/",
|
|
32
|
+
"!backends/python-base/**/__pycache__/",
|
|
33
|
+
"!backends/python-base/**/*.pyc",
|
|
34
|
+
"!backends/python-base/tests/",
|
|
35
|
+
"!backends/python-base/**/*.egg-info/",
|
|
36
|
+
"core/",
|
|
37
|
+
"tools/",
|
|
38
|
+
"verify-ship-manifest.ts",
|
|
39
|
+
".npmrc",
|
|
40
|
+
"README.md",
|
|
41
|
+
"ship-manifest.test.ts"
|
|
42
|
+
],
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./index.ts"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"prepack": "cp ../../LICENSE ./LICENSE",
|
|
50
|
+
"test": "vitest run"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"node-html-parser": "^6.1.0",
|
|
54
|
+
"playwright": "^1.60.0",
|
|
55
|
+
"turndown": "^7.2.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@earendil-works/pi-ai": "*",
|
|
59
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
60
|
+
"@earendil-works/pi-tui": "*",
|
|
61
|
+
"typebox": "*"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { verifyShipManifest } from "./verify-ship-manifest.js";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
describe("publish manifest", () => {
|
|
5
|
+
it("`package.json` `files` array covers every production .ts module across the tree", () => {
|
|
6
|
+
expect(verifyShipManifest(import.meta.url).missing).toEqual([]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("every `files` entry points at something on disk — a stale entry ships nothing", () => {
|
|
10
|
+
expect(verifyShipManifest(import.meta.url).stale).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-back tool definition.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
7
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import * as router from "../core/router.js";
|
|
9
|
+
import { executeInteractionTool } from "./utils.js";
|
|
10
|
+
|
|
11
|
+
export const browserBackTool = defineTool({
|
|
12
|
+
name: "browser-back",
|
|
13
|
+
label: "Go Back",
|
|
14
|
+
description: "Navigate back in browser history.",
|
|
15
|
+
parameters: Type.Object({}),
|
|
16
|
+
|
|
17
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
18
|
+
return executeInteractionTool(
|
|
19
|
+
ctx,
|
|
20
|
+
"Go back",
|
|
21
|
+
(tid) => router.goBack(tid),
|
|
22
|
+
(result) => ({
|
|
23
|
+
message: `Went back to: ${result.newUrl || "?"}`,
|
|
24
|
+
details: {
|
|
25
|
+
newUrl: result.newUrl,
|
|
26
|
+
newTitle: result.newTitle,
|
|
27
|
+
elementCount: result.elementCount,
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
renderCall(_args, theme, _context) {
|
|
34
|
+
return new Text(theme.fg("toolTitle", theme.bold("browser-back")), 0, 0);
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
renderResult(result, _options, theme, _context) {
|
|
38
|
+
const d = result.details as Record<string, unknown> | undefined;
|
|
39
|
+
if (d?.error) return new Text(theme.fg("error", "Go back failed"), 0, 0);
|
|
40
|
+
const ec = d?.elementCount as number | undefined;
|
|
41
|
+
return new Text(
|
|
42
|
+
theme.fg(
|
|
43
|
+
"dim",
|
|
44
|
+
`← ${(d?.newUrl as string) || ""}${ec !== undefined ? ` · ${ec} elements` : ""}`,
|
|
45
|
+
),
|
|
46
|
+
0,
|
|
47
|
+
0,
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-click tool definition.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
7
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import * as router from "../core/router.js";
|
|
9
|
+
import { executeInteractionTool } from "./utils.js";
|
|
10
|
+
|
|
11
|
+
export const browserClickTool = defineTool({
|
|
12
|
+
name: "browser-click",
|
|
13
|
+
label: "Click Element",
|
|
14
|
+
description:
|
|
15
|
+
"Click an element on the page by its @e reference ID (e.g., @e5). " +
|
|
16
|
+
"Use element references from browser-navigate or browser-snapshot output.",
|
|
17
|
+
parameters: Type.Object({
|
|
18
|
+
ref: Type.String({
|
|
19
|
+
description: "Element reference like @e5 (from the accessibility tree)",
|
|
20
|
+
}),
|
|
21
|
+
}),
|
|
22
|
+
|
|
23
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
24
|
+
const { ref } = params as { ref: string };
|
|
25
|
+
return executeInteractionTool(
|
|
26
|
+
ctx,
|
|
27
|
+
"Click",
|
|
28
|
+
(tid) => router.click(tid, ref),
|
|
29
|
+
(result) => ({
|
|
30
|
+
message: [
|
|
31
|
+
`Clicked ${ref}`,
|
|
32
|
+
result.newUrl ? `URL: ${result.newUrl}` : "",
|
|
33
|
+
result.newTitle ? `Title: ${result.newTitle}` : "",
|
|
34
|
+
]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join("\n"),
|
|
37
|
+
details: {
|
|
38
|
+
newUrl: result.newUrl,
|
|
39
|
+
newTitle: result.newTitle,
|
|
40
|
+
elementCount: result.elementCount,
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
renderCall(args, theme, _context) {
|
|
47
|
+
return new Text(
|
|
48
|
+
`${theme.fg("toolTitle", theme.bold("browser-click"))} ${theme.fg("accent", args.ref)}`,
|
|
49
|
+
0,
|
|
50
|
+
0,
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
renderResult(result, _options, theme, _context) {
|
|
55
|
+
const d = result.details as Record<string, unknown> | undefined;
|
|
56
|
+
if (d?.error)
|
|
57
|
+
return new Text(theme.fg("error", `Click failed: ${d.error}`), 0, 0);
|
|
58
|
+
const newUrl = d?.newUrl as string | undefined;
|
|
59
|
+
const ec = d?.elementCount as number | undefined;
|
|
60
|
+
if (newUrl) {
|
|
61
|
+
let text = theme.fg("success", `✅ → ${newUrl}`);
|
|
62
|
+
if (ec !== undefined) text += ` · ${ec} elements`;
|
|
63
|
+
return new Text(text, 0, 0);
|
|
64
|
+
}
|
|
65
|
+
return new Text(
|
|
66
|
+
theme.fg(
|
|
67
|
+
"success",
|
|
68
|
+
`✅ clicked${ec !== undefined ? ` · ${ec} elements` : ""}`,
|
|
69
|
+
),
|
|
70
|
+
0,
|
|
71
|
+
0,
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
});
|