pi-chrome 0.15.53 → 0.15.55
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/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/docs/FAQ.md +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +344 -54
- package/extensions/chrome-profile-bridge/browser-extension/snapshot_injected.js +1 -9
- package/extensions/chrome-profile-bridge/index.ts +115 -29
- package/package.json +6 -2
- package/test-suite/unit/background-policy.test.mjs +2 -1
- package/test-suite/unit/console-capture.test.mjs +122 -0
- package/test-suite/unit/hidden-input.test.mjs +279 -0
- package/test-suite/unit/input-reliability.test.mjs +39 -6
- package/test-suite/unit/prompt-section.test.mjs +52 -0
- package/test-suite/unit/type-evidence.test.mjs +23 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
2
3
|
import { Type } from "typebox";
|
|
3
4
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
|
5
6
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
6
7
|
import { dirname, join, resolve } from "node:path";
|
|
7
8
|
|
|
@@ -236,6 +237,58 @@ function formatCdpResult(method: string, value: unknown): ToolTextResult {
|
|
|
236
237
|
return { content: [{ type: "text", text: truncateText(text) }], details: { value } };
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
// chrome_screenshot writes a timestamped file per capture (plus -tileN files and a .json manifest for
|
|
241
|
+
// full-page captures) into .pi/chrome-screenshots. Prune that folder at capture time so it stays
|
|
242
|
+
// bounded. Only files matching the tool's own naming are eligible; the newest `keep` captures are always
|
|
243
|
+
// kept, and nothing inside the retention window is removed. Best-effort: errors never fail a capture.
|
|
244
|
+
const SCREENSHOT_NAME_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)(?:-tile\d+)?\.(?:png|jpeg)(?:\.json)?$/;
|
|
245
|
+
const SCREENSHOT_KEEP_NEWEST = 20;
|
|
246
|
+
const SCREENSHOT_RETENTION_DAYS = 7;
|
|
247
|
+
|
|
248
|
+
function screenshotCaptureTime(stamp: string): number {
|
|
249
|
+
const iso = stamp.replace(/^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "$1:$2:$3.$4Z");
|
|
250
|
+
return Date.parse(iso);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function screenshotFilesToPrune(names: string[], now: number, retentionDays = SCREENSHOT_RETENTION_DAYS, keep = SCREENSHOT_KEEP_NEWEST): string[] {
|
|
254
|
+
if (!(retentionDays > 0)) return [];
|
|
255
|
+
const captures = new Map<string, string[]>();
|
|
256
|
+
for (const name of names) {
|
|
257
|
+
const match = SCREENSHOT_NAME_RE.exec(name);
|
|
258
|
+
if (!match) continue;
|
|
259
|
+
const files = captures.get(match[1]) ?? [];
|
|
260
|
+
files.push(name);
|
|
261
|
+
captures.set(match[1], files);
|
|
262
|
+
}
|
|
263
|
+
const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
|
|
264
|
+
const newestFirst = [...captures.keys()].sort().reverse();
|
|
265
|
+
const prune: string[] = [];
|
|
266
|
+
for (const stamp of newestFirst.slice(keep)) {
|
|
267
|
+
const takenAt = screenshotCaptureTime(stamp);
|
|
268
|
+
if (Number.isFinite(takenAt) && takenAt < cutoff) prune.push(...(captures.get(stamp) ?? []));
|
|
269
|
+
}
|
|
270
|
+
return prune;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function pruneScreenshotDir(dir: string, retentionDays?: number): Promise<number> {
|
|
274
|
+
try {
|
|
275
|
+
const names = await readdir(dir);
|
|
276
|
+
let removed = 0;
|
|
277
|
+
for (const name of screenshotFilesToPrune(names, Date.now(), retentionDays)) {
|
|
278
|
+
try {
|
|
279
|
+
await unlink(join(dir, name));
|
|
280
|
+
removed++;
|
|
281
|
+
} catch {
|
|
282
|
+
// Locked or already gone: keep going.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return removed;
|
|
286
|
+
} catch {
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// End screenshot pruning.
|
|
291
|
+
|
|
239
292
|
function formatChromeInspect(inspect: any): string {
|
|
240
293
|
if (!inspect || typeof inspect !== "object") return safeJson(inspect);
|
|
241
294
|
const t = inspect.target || {};
|
|
@@ -749,6 +802,14 @@ const CHROME_TOOL_NAMES = [
|
|
|
749
802
|
"chrome_cdp_targets",
|
|
750
803
|
] as const;
|
|
751
804
|
const CHROME_TOOL_NAME_SET = new Set<string>(CHROME_TOOL_NAMES);
|
|
805
|
+
const CHROME_TOOL_CHANGE_ENTRY = "pi-chrome-tool-change";
|
|
806
|
+
type ChromeToolChangeEntry = {
|
|
807
|
+
content: string;
|
|
808
|
+
action: "authorized" | "reauthorized" | "revoked" | "expired";
|
|
809
|
+
tools: string[];
|
|
810
|
+
authorizedUntil?: number | "indefinite";
|
|
811
|
+
at: number;
|
|
812
|
+
};
|
|
752
813
|
|
|
753
814
|
function StringEnum<T extends readonly [string, ...string[]]>(values: T) {
|
|
754
815
|
return Type.Union(values.map((value) => Type.Literal(value)) as [ReturnType<typeof Type.Literal>, ...ReturnType<typeof Type.Literal>[]]);
|
|
@@ -838,17 +899,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
838
899
|
: action === "expired"
|
|
839
900
|
? "Chrome tools disabled because /chrome authorize grant expired."
|
|
840
901
|
: "Chrome tools disabled by /chrome revoke.";
|
|
841
|
-
|
|
842
|
-
|
|
902
|
+
// Session-history log only (custom entry, not sent to the model). The model already learns about
|
|
903
|
+
// the change from pi's tool/prompt-section delta, so a model-facing message would be redundant.
|
|
904
|
+
pi.appendEntry<ChromeToolChangeEntry>(CHROME_TOOL_CHANGE_ENTRY, {
|
|
843
905
|
content,
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
at: Date.now(),
|
|
850
|
-
},
|
|
851
|
-
}, { triggerTurn: false });
|
|
906
|
+
action,
|
|
907
|
+
tools: [...CHROME_TOOL_NAMES],
|
|
908
|
+
authorizedUntil: options.authorizedUntil,
|
|
909
|
+
at: Date.now(),
|
|
910
|
+
});
|
|
852
911
|
};
|
|
853
912
|
|
|
854
913
|
// Bound the entire request, including shared-owner forwarding/takeover. Revoke can launch
|
|
@@ -1036,6 +1095,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1036
1095
|
updateChromeStatus(ctx);
|
|
1037
1096
|
});
|
|
1038
1097
|
|
|
1098
|
+
// Render authorization log entries in the transcript. Custom entries never reach the model.
|
|
1099
|
+
pi.registerEntryRenderer?.<ChromeToolChangeEntry>(CHROME_TOOL_CHANGE_ENTRY, (entry, _options, theme) => {
|
|
1100
|
+
const content = entry.data?.content ?? "Chrome authorization changed.";
|
|
1101
|
+
return new Text(theme.fg("dim", `[pi-chrome] ${content}`), 1, 0);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1039
1104
|
pi.on("session_shutdown", async (event) => {
|
|
1040
1105
|
clearAuthExpiryTimer();
|
|
1041
1106
|
clearCountdownInterval();
|
|
@@ -1048,13 +1113,26 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1048
1113
|
}
|
|
1049
1114
|
});
|
|
1050
1115
|
|
|
1116
|
+
// Contribute the primer as a named prompt section instead of returning `systemPrompt`.
|
|
1117
|
+
// Returning `systemPrompt` forces a whole-prompt override, which makes pi collapse every system
|
|
1118
|
+
// message into a new leading prompt each run (busting the provider prompt cache and disabling
|
|
1119
|
+
// pi's append-only section/tool deltas for all extensions). Mutating `systemPromptOptions.sections`
|
|
1120
|
+
// lets pi diff the section and append a small system-message delta when it appears/disappears.
|
|
1051
1121
|
pi.on("before_agent_start", (event) => {
|
|
1052
|
-
|
|
1053
|
-
|
|
1122
|
+
const active = chromeToolsRegistered && chromeControlAuthorized();
|
|
1123
|
+
const sections = (event as { systemPromptOptions?: { sections?: Record<string, string> } }).systemPromptOptions?.sections;
|
|
1124
|
+
if (sections) {
|
|
1125
|
+
if (active) sections[CHROME_PRIMER_SECTION] = CHROME_PRIMER;
|
|
1126
|
+
else delete sections[CHROME_PRIMER_SECTION];
|
|
1127
|
+
return undefined;
|
|
1054
1128
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1129
|
+
// Legacy pi without structured prompt sections: append the primer to the rendered prompt.
|
|
1130
|
+
if (!active) return undefined;
|
|
1131
|
+
return { systemPrompt: `${event.systemPrompt}\n<${CHROME_PRIMER_SECTION}>\n${CHROME_PRIMER}\n</${CHROME_PRIMER_SECTION}>` };
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
const CHROME_PRIMER_SECTION = "chrome-profile-bridge";
|
|
1135
|
+
const CHROME_PRIMER = `Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile.
|
|
1058
1136
|
|
|
1059
1137
|
Tab/window isolation (important):
|
|
1060
1138
|
- pi-chrome owns a dedicated automation window/tab. When a chrome_* tool runs with no explicit target, it acts on that pi-chrome-owned target — it never reuses or overwrites the user's currently active tab. The dedicated target is created on first use and reused afterward.
|
|
@@ -1066,7 +1144,8 @@ Capability model (important):
|
|
|
1066
1144
|
- Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached.
|
|
1067
1145
|
- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via **CDP \`Runtime.evaluate\`**, which is not subject to the page's Content-Security-Policy. They work even on strict-CSP pages (e.g. github.com, many bank/SaaS apps) that block \`'unsafe-eval'\`. \`chrome_navigate initScript\` likewise injects at document_start via CDP and bypasses CSP. \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input also work under any CSP.
|
|
1068
1146
|
- Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly. If the action started a navigation, the snapshot waits (up to 5s) for the new page.
|
|
1069
|
-
- \`chrome_type\`
|
|
1147
|
+
- \`chrome_type\` never replaces: with a uid/selector it appends at the end of the field; without one it types at the current caret. Check its before/after report. Use \`chrome_fill\` to replace a field.
|
|
1148
|
+
- Chrome ignores real input to hidden pages (inactive tabs, minimized windows, windows behind a macOS full-screen window). Input tools report this instead of silently doing nothing; retry with background:false only when bringing the tab forward is acceptable.
|
|
1070
1149
|
- \`chrome_cdp\` runs any raw CDP method on a tab (emulation, cookies, PDF, accessibility tree, etc.) when no dedicated chrome_* tool fits; \`chrome_cdp_targets\` diagnoses debugger/overlay conflicts.
|
|
1071
1150
|
|
|
1072
1151
|
Usage rules:
|
|
@@ -1077,10 +1156,7 @@ Usage rules:
|
|
|
1077
1156
|
5. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
|
|
1078
1157
|
6. /chrome background on (default) is a hard policy: per-call \`background=false\` cannot override it, new tabs stay inactive, and \`chrome_tab activate\` is blocked. Ask the user to run /chrome background off when they want foreground/watch mode. With background off, per-call \`background=true\` still avoids explicit focus/tab activation. Screenshots use CDP without activating background tabs; debugger failures never fall back to tab activation. Page scripts, trusted input, native prompts, and Chrome/OS behavior can still affect focus.
|
|
1079
1158
|
7. If you hit a native file-picker or privileged browser prompt gate, tell the user; generic clicks/typing/CSP gates are handled by Chrome input.
|
|
1080
|
-
8. Run /chrome doctor when in doubt about connectivity or capabilities
|
|
1081
|
-
</chrome-profile-bridge>`;
|
|
1082
|
-
return { systemPrompt: event.systemPrompt + primer };
|
|
1083
|
-
});
|
|
1159
|
+
8. Run /chrome doctor when in doubt about connectivity or capabilities.`;
|
|
1084
1160
|
|
|
1085
1161
|
// Shared handlers, dispatched by the unified /chrome command below.
|
|
1086
1162
|
const doctorHandler = async (ctx: ExtensionContext) => {
|
|
@@ -1630,7 +1706,7 @@ Usage rules:
|
|
|
1630
1706
|
selector: Type.Optional(Type.String({ description: "CSS selector to click. Prefer uid from chrome_snapshot when available." })),
|
|
1631
1707
|
x: Type.Optional(Type.Number({ description: "Viewport x coordinate if uid/selector is omitted." })),
|
|
1632
1708
|
y: Type.Optional(Type.Number({ description: "Viewport y coordinate if uid/selector is omitted." })),
|
|
1633
|
-
domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked
|
|
1709
|
+
domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked (extension overlay, debugger failure, or a hidden tab that ignores trusted input)." })),
|
|
1634
1710
|
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the click." })),
|
|
1635
1711
|
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
|
1636
1712
|
targetId: Type.Optional(Type.String()),
|
|
@@ -1654,8 +1730,8 @@ Usage rules:
|
|
|
1654
1730
|
name: "chrome_type",
|
|
1655
1731
|
label: "Chrome Type",
|
|
1656
1732
|
description:
|
|
1657
|
-
"
|
|
1658
|
-
promptSnippet: "Type text at the
|
|
1733
|
+
"Type using Chrome's real input. With a snapshot uid or CSS selector, chrome_type clicks the field and moves the caret to the end, so text is appended to existing content; without one it types at the current caret of the focused element. It does NOT replace existing text. Use chrome_fill (or replace=true) to replace a field's value. Contenteditables use one native text insertion; other fields use key events. Set perCharacter=true for editors needing individual keydown events. The result reports the field value before/after and insertedAt (empty|caret-end|caret-middle|replaced-selection|replaced-all), with a warning when text was spliced into existing content. Pass includeSnapshot=true to verify after typing; if the action starts a navigation, the snapshot waits (up to 5s) for it to load.",
|
|
1734
|
+
promptSnippet: "Type text in Chrome; a uid/selector target appends at the end (does not replace existing text; use chrome_fill or replace=true).",
|
|
1659
1735
|
parameters: Type.Object({
|
|
1660
1736
|
text: Type.String(),
|
|
1661
1737
|
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
|
|
@@ -1695,7 +1771,7 @@ Usage rules:
|
|
|
1695
1771
|
selector: Type.Optional(Type.String({ description: "CSS selector to fill if uid is omitted." })),
|
|
1696
1772
|
perCharacter: Type.Optional(Type.Boolean({ default: false, description: "Send individual key events even in contenteditables. Default: one native text insertion for contenteditables; key events for other fields." })),
|
|
1697
1773
|
submit: Type.Optional(Type.Boolean({ description: "If true, press Enter after filling." })),
|
|
1698
|
-
domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked
|
|
1774
|
+
domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked (extension overlay, debugger failure, or a hidden tab that ignores trusted input)." })),
|
|
1699
1775
|
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after filling." })),
|
|
1700
1776
|
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
|
1701
1777
|
targetId: Type.Optional(Type.String()),
|
|
@@ -1845,6 +1921,7 @@ Usage rules:
|
|
|
1845
1921
|
format: Type.Optional(StringEnum(imageFormatValues)),
|
|
1846
1922
|
quality: Type.Optional(Type.Number({ minimum: 0, maximum: 100, description: "JPEG quality 0-100." })),
|
|
1847
1923
|
fullPage: Type.Optional(Type.Boolean({ description: "Capture full-page tiles plus a JSON manifest. Temporarily scrolls the target page; does not activate background tabs." })),
|
|
1924
|
+
retentionDays: Type.Optional(Type.Number({ minimum: 0, description: "Default-folder captures older than this many days are pruned (the newest 20 are always kept). Default 7; 0 disables pruning. Explicit paths are never pruned." })),
|
|
1848
1925
|
targetId: Type.Optional(Type.String()),
|
|
1849
1926
|
urlIncludes: Type.Optional(Type.String()),
|
|
1850
1927
|
titleIncludes: Type.Optional(Type.String()),
|
|
@@ -1855,8 +1932,10 @@ Usage rules:
|
|
|
1855
1932
|
async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<ToolTextResult> {
|
|
1856
1933
|
const format = params.format ?? "png";
|
|
1857
1934
|
const cwd = workspaceCwd(ctx);
|
|
1858
|
-
const
|
|
1935
|
+
const screenshotDir = join(cwd, ".pi", "chrome-screenshots");
|
|
1936
|
+
const defaultPath = join(screenshotDir, `${new Date().toISOString().replace(/[:.]/g, "-")}.${format}`);
|
|
1859
1937
|
const outputPath = params.path ? resolve(cwd, params.path) : defaultPath;
|
|
1938
|
+
const prune = () => (params.path ? Promise.resolve(0) : pruneScreenshotDir(screenshotDir, params.retentionDays));
|
|
1860
1939
|
const result = (await authorizedBridgeSend("page.screenshot", params, params.fullPage ? 120_000 : DEFAULT_TIMEOUT_MS, signal)) as {
|
|
1861
1940
|
dataUrl?: string;
|
|
1862
1941
|
method?: string;
|
|
@@ -1880,6 +1959,7 @@ Usage rules:
|
|
|
1880
1959
|
manifest.push({ path: tilePath, y: tile.y });
|
|
1881
1960
|
}
|
|
1882
1961
|
await writeFile(outputPath + ".json", JSON.stringify({ width, height, viewportHeight, dpr, tiles: manifest }, null, 2));
|
|
1962
|
+
await prune();
|
|
1883
1963
|
return {
|
|
1884
1964
|
content: [{ type: "text", text: `Saved ${result.tiles.length} full-page tile(s) for ${width}×${height}px page. Manifest: ${outputPath}.json` }],
|
|
1885
1965
|
details: { manifest: outputPath + ".json", tiles: manifest, dimensions: result.dimensions, tab: result.tab, method: result.method } as unknown as Record<string, unknown>,
|
|
@@ -1888,6 +1968,7 @@ Usage rules:
|
|
|
1888
1968
|
if (!result.dataUrl) throw new Error("Screenshot returned no dataUrl");
|
|
1889
1969
|
const base64 = result.dataUrl.replace(/^data:image\/(?:png|jpeg);base64,/, "");
|
|
1890
1970
|
await writeFile(outputPath, Buffer.from(base64, "base64"));
|
|
1971
|
+
await prune();
|
|
1891
1972
|
return { content: [{ type: "text", text: `Saved Chrome screenshot to ${outputPath}` }], details: { path: outputPath, format, tab: result.tab, method: result.method } };
|
|
1892
1973
|
},
|
|
1893
1974
|
});
|
|
@@ -1987,12 +2068,13 @@ Usage rules:
|
|
|
1987
2068
|
pi.registerTool({
|
|
1988
2069
|
name: "chrome_upload_file",
|
|
1989
2070
|
label: "Chrome Upload File",
|
|
1990
|
-
description: "Attach local files
|
|
2071
|
+
description: "Attach local files using Chrome DevTools file-input control without opening a native file picker. Target the <input type=file>, a label/wrapper containing one, or an upload button that opens the picker (the chooser is intercepted). Selectors also search same-origin iframes. Works with React/Vue/Angular controlled inputs.",
|
|
1991
2072
|
promptSnippet: "Attach local files to a Chrome <input type=file> without opening the native file picker.",
|
|
1992
2073
|
parameters: Type.Object({
|
|
1993
2074
|
uid: Type.Optional(Type.String()),
|
|
1994
2075
|
selector: Type.Optional(Type.String()),
|
|
1995
2076
|
paths: Type.Array(Type.String(), { description: "Local absolute file paths to upload." }),
|
|
2077
|
+
domFallback: Type.Optional(Type.Boolean({ description: "Upload buttons need a click to open the (intercepted) file chooser. If true (default) and the tab is hidden, use a page-level element.click() with a user gesture instead of trusted input. Set false to require trusted input." })),
|
|
1996
2078
|
targetId: Type.Optional(Type.String()),
|
|
1997
2079
|
urlIncludes: Type.Optional(Type.String()),
|
|
1998
2080
|
titleIncludes: Type.Optional(Type.String()),
|
|
@@ -2001,8 +2083,12 @@ Usage rules:
|
|
|
2001
2083
|
async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
|
|
2002
2084
|
const cwd = workspaceCwd(ctx);
|
|
2003
2085
|
const paths = params.paths.map((p) => resolve(cwd, p));
|
|
2004
|
-
const result = await authorizedBridgeSend("page.upload", { ...params, paths }, DEFAULT_TIMEOUT_MS, signal);
|
|
2005
|
-
|
|
2086
|
+
const result = (await authorizedBridgeSend("page.upload", { ...params, paths }, DEFAULT_TIMEOUT_MS, signal)) as { mode?: string; trigger?: string; inFrame?: boolean } | undefined;
|
|
2087
|
+
const via = result?.mode === "file-chooser"
|
|
2088
|
+
? ` via intercepted file chooser (${result.trigger === "activation-click" ? "page-level click because the tab is hidden" : "trusted click"})`
|
|
2089
|
+
: "";
|
|
2090
|
+
const frame = result?.inFrame ? " inside an iframe" : "";
|
|
2091
|
+
return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}${frame}${via}` }], details: { result: result as Json } };
|
|
2006
2092
|
},
|
|
2007
2093
|
});
|
|
2008
2094
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-chrome",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.55",
|
|
4
4
|
"scripts": {
|
|
5
|
-
"test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs",
|
|
5
|
+
"test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs && node test-suite/unit/prompt-section.test.mjs",
|
|
6
6
|
"version": "node scripts/sync-manifest-version.js",
|
|
7
7
|
"prepublishOnly": "node scripts/sync-manifest-version.js"
|
|
8
8
|
},
|
|
@@ -67,12 +67,16 @@
|
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"@earendil-works/pi-coding-agent": "*",
|
|
70
|
+
"@earendil-works/pi-tui": "*",
|
|
70
71
|
"typebox": "*"
|
|
71
72
|
},
|
|
72
73
|
"peerDependenciesMeta": {
|
|
73
74
|
"@earendil-works/pi-coding-agent": {
|
|
74
75
|
"optional": true
|
|
75
76
|
},
|
|
77
|
+
"@earendil-works/pi-tui": {
|
|
78
|
+
"optional": true
|
|
79
|
+
},
|
|
76
80
|
"typebox": {
|
|
77
81
|
"optional": true
|
|
78
82
|
}
|
|
@@ -50,7 +50,7 @@ function piHarness({ session = "alpha", send } = {}) {
|
|
|
50
50
|
formatChromeInspect: JSON.stringify, summarizeActionResult: () => "", formatIncludedSnapshotText: (_r, text) => text,
|
|
51
51
|
describeTypeEvidence: () => [], formatCdpResult: (_method, value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], details: { value } }),
|
|
52
52
|
workspaceCwd: () => ctx.cwd, ...path,
|
|
53
|
-
mkdir: async () => {}, writeFile: async (...args) => writes.push(args),
|
|
53
|
+
mkdir: async () => {}, writeFile: async (...args) => writes.push(args), pruneScreenshotDir: async () => 0,
|
|
54
54
|
};
|
|
55
55
|
const registrations = indexSource.slice(indexSource.indexOf("function registerChromeTools(pi:"), indexSource.lastIndexOf("\n}"));
|
|
56
56
|
vm.runInNewContext(stripTypeScriptTypes([
|
|
@@ -292,6 +292,7 @@ function workerHarness({ withWindows = true } = {}) {
|
|
|
292
292
|
w, chrome, calls, tabs, focusedWindow: () => focusedWindow,
|
|
293
293
|
cdpResult: (method) => method === "Page.captureScreenshot" ? { data: "c2NyZWVuc2hvdA==" }
|
|
294
294
|
: method === "DOM.requestNode" ? { nodeId: 1 }
|
|
295
|
+
: method === "Runtime.callFunctionOn" ? { result: { type: "object", value: { tag: "INPUT", isFile: true } } }
|
|
295
296
|
: { result: { type: "boolean", value: true, objectId: "file-input" } },
|
|
296
297
|
};
|
|
297
298
|
return h;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Console capture must not trip anti-DevTools checks (issue #9) and must only touch Pi's tabs.
|
|
2
|
+
// Sites detect DevTools by logging an object whose getter/toString fires when something inspects
|
|
3
|
+
// it. Closed DevTools never does; our capture used to serialize every argument immediately.
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import vm from "node:vm";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
|
|
9
|
+
const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8");
|
|
10
|
+
const snapshotSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/snapshot_injected.js", import.meta.url), "utf8");
|
|
11
|
+
|
|
12
|
+
function loadWorker() {
|
|
13
|
+
const navListeners = [];
|
|
14
|
+
const injected = [];
|
|
15
|
+
const noop = { addListener() {}, removeListener() {} };
|
|
16
|
+
const chrome = {
|
|
17
|
+
runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: noop, onStartup: noop },
|
|
18
|
+
alarms: { create() {}, onAlarm: noop }, action: { onClicked: noop },
|
|
19
|
+
webNavigation: { onCommitted: { addListener: (fn) => navListeners.push(fn) } },
|
|
20
|
+
debugger: { onDetach: noop, onEvent: noop },
|
|
21
|
+
storage: { session: { get: async () => ({}), set: async () => {} } },
|
|
22
|
+
scripting: { executeScript: async (options) => { injected.push(options); return []; } },
|
|
23
|
+
tabs: { get: async () => null },
|
|
24
|
+
};
|
|
25
|
+
const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } };
|
|
26
|
+
w.self = w;
|
|
27
|
+
vm.runInNewContext(workerSource, w);
|
|
28
|
+
const commit = async (tabId, frameId = 0) => {
|
|
29
|
+
for (const fn of navListeners) await fn({ tabId, frameId, url: "https://site.test/" });
|
|
30
|
+
};
|
|
31
|
+
return { w, injected, commit };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A page world whose console records native calls and a DevTools trap object.
|
|
35
|
+
function pageWorld() {
|
|
36
|
+
const nativeCalls = [];
|
|
37
|
+
const trap = { tripped: 0 };
|
|
38
|
+
const page = vm.createContext({
|
|
39
|
+
console: Object.fromEntries(["debug", "log", "info", "warn", "error"].map((l) => [l, (...a) => nativeCalls.push([l, ...a])])),
|
|
40
|
+
location: { href: "https://site.test/" },
|
|
41
|
+
addEventListener() {},
|
|
42
|
+
fetch: undefined, XMLHttpRequest: undefined,
|
|
43
|
+
});
|
|
44
|
+
page.window = page;
|
|
45
|
+
page.trip = () => { trap.tripped++; };
|
|
46
|
+
vm.runInContext(`
|
|
47
|
+
var bait = { name: "bait" };
|
|
48
|
+
Object.defineProperty(bait, "id", { enumerable: true, get() { trip(); return "x"; } });
|
|
49
|
+
bait.toString = function toString() { trip(); return "bait"; };
|
|
50
|
+
var trapError = new Error("trap");
|
|
51
|
+
Object.defineProperty(trapError, "message", { get() { trip(); return "m"; } });
|
|
52
|
+
var trapRegex = /a+/g;
|
|
53
|
+
trapRegex.toString = function () { trip(); return "re"; };
|
|
54
|
+
Object.defineProperty(trapRegex, "source", { get() { trip(); return "x"; } });
|
|
55
|
+
var trapDate = new Date(0);
|
|
56
|
+
trapDate.toString = function () { trip(); return "d"; };
|
|
57
|
+
`, page);
|
|
58
|
+
return { page, trap, nativeCalls };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const fnSource = (name) => {
|
|
62
|
+
const start = workerSource.indexOf(`function ${name}(`);
|
|
63
|
+
let depth = 0;
|
|
64
|
+
for (let i = workerSource.indexOf("{", start); i < workerSource.length; i++) {
|
|
65
|
+
if (workerSource[i] === "{") depth++;
|
|
66
|
+
else if (workerSource[i] === "}" && --depth === 0) return workerSource.slice(start, i + 1);
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`missing ${name}`);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
for (const [label, install] of [
|
|
72
|
+
["early capture", (page) => vm.runInContext(`(${fnSource("installEarlyCapture")})()`, page)],
|
|
73
|
+
["post-hoc instrumentation", (page) => vm.runInContext(`${fnSource("getPiChromeState")}\n(${fnSource("installPiChromeInstrumentation")})()`, page)],
|
|
74
|
+
["snapshot_injected instrumentation", (page) => { vm.runInContext(snapshotSource, page); }],
|
|
75
|
+
]) {
|
|
76
|
+
test(`${label}: logging an object does not read it until Pi lists messages`, () => {
|
|
77
|
+
const { page, trap, nativeCalls } = pageWorld();
|
|
78
|
+
install(page);
|
|
79
|
+
if (label.startsWith("snapshot")) {
|
|
80
|
+
// snapshot_injected installs lazily through its snapshot entry point; force the install.
|
|
81
|
+
assert.equal(typeof page.__piChromeSnapshotPage, "function");
|
|
82
|
+
try { page.__piChromeSnapshotPage({}); } catch {}
|
|
83
|
+
}
|
|
84
|
+
vm.runInContext("console.log('hello', bait); console.error(new Error('boom')); console.log(trapError, trapRegex, trapDate)", page);
|
|
85
|
+
assert.equal(trap.tripped, 0, "no getter/toString fired while the page runs");
|
|
86
|
+
assert.equal(nativeCalls.length, 3, "native console still receives the calls");
|
|
87
|
+
const listed = vm.runInContext(`${fnSource("getPiChromeState")}\n${fnSource("installPiChromeInstrumentation")}\n(${fnSource("listConsoleMessages")})(false)`, page);
|
|
88
|
+
assert.equal(trap.tripped, 0, "listing does not fire page getters or toString either");
|
|
89
|
+
assert.equal(listed.count, 3);
|
|
90
|
+
assert.equal(listed.messages[0].args[0], "hello");
|
|
91
|
+
assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[0].args[1])), { name: "bait", id: "[getter]", toString: "[function toString]" });
|
|
92
|
+
assert.equal(listed.messages[1].args[0].message, "boom");
|
|
93
|
+
assert.match(listed.messages[1].args[0].stack, /boom/);
|
|
94
|
+
const [err, re, date] = listed.messages[2].args;
|
|
95
|
+
assert.equal(err.message, "[getter]");
|
|
96
|
+
assert.equal(re, "/a+/g", "regex described through native getters");
|
|
97
|
+
assert.equal(date, "1970-01-01T00:00:00.000Z");
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test("listing survives values that cannot be serialized", () => {
|
|
102
|
+
const { page } = pageWorld();
|
|
103
|
+
vm.runInContext(`(${fnSource("installEarlyCapture")})()`, page);
|
|
104
|
+
vm.runInContext("const loop = { a: [1, 2n, undefined, Symbol('s')] }; loop.self = loop; console.log(loop); console.log(new Proxy({}, { ownKeys() { throw new Error('no'); } }), new Map([[1, 2]]), function named() {})", page);
|
|
105
|
+
const listed = vm.runInContext(`${fnSource("getPiChromeState")}\n${fnSource("installPiChromeInstrumentation")}\n(${fnSource("listConsoleMessages")})(true)`, page);
|
|
106
|
+
assert.equal(listed.count, 2);
|
|
107
|
+
assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[0].args[0])), { a: [1, "2n", "undefined", "Symbol(s)"], self: "[circular]" });
|
|
108
|
+
assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[1].args)), ["[unserializable]", "[Map(1)]", "[function named]"]);
|
|
109
|
+
assert.equal(vm.runInContext("window.__PI_CHROME_STATE__.console.length", page), 0, "clear still empties the buffer");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("early capture is injected only into tabs Pi owns or tracks", async () => {
|
|
113
|
+
const h = loadWorker();
|
|
114
|
+
await h.commit(10);
|
|
115
|
+
assert.equal(h.injected.length, 0, "a user tab is never patched");
|
|
116
|
+
vm.runInContext("automationTargets.set('s', { tabId: 11 }); sessionTabs.set('s', new Map([[12, { created: true }]]));", h.w);
|
|
117
|
+
await h.commit(11);
|
|
118
|
+
await h.commit(12);
|
|
119
|
+
await h.commit(11, 3); // subframe
|
|
120
|
+
assert.deepEqual(h.injected.map((o) => o.target.tabId), [11, 12]);
|
|
121
|
+
assert.ok(h.injected.every((o) => o.func.name === "installEarlyCapture" && o.injectImmediately === true));
|
|
122
|
+
});
|