patchrome 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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/bin/patchrome.js +10 -0
  4. package/dist/build-id.d.ts +2 -0
  5. package/dist/build-id.js +21 -0
  6. package/dist/challenges.d.ts +22 -0
  7. package/dist/challenges.js +97 -0
  8. package/dist/chrome-profiles.d.ts +17 -0
  9. package/dist/chrome-profiles.js +141 -0
  10. package/dist/cli-options.d.ts +131 -0
  11. package/dist/cli-options.js +43 -0
  12. package/dist/cli.d.ts +48 -0
  13. package/dist/cli.js +572 -0
  14. package/dist/client.d.ts +16 -0
  15. package/dist/client.js +210 -0
  16. package/dist/commands.d.ts +58 -0
  17. package/dist/commands.js +1076 -0
  18. package/dist/completions.d.ts +1 -0
  19. package/dist/completions.js +114 -0
  20. package/dist/copy-guard.d.ts +75 -0
  21. package/dist/copy-guard.js +167 -0
  22. package/dist/daemon.d.ts +7 -0
  23. package/dist/daemon.js +313 -0
  24. package/dist/diagnostics.d.ts +44 -0
  25. package/dist/diagnostics.js +117 -0
  26. package/dist/engine.d.ts +51 -0
  27. package/dist/engine.js +257 -0
  28. package/dist/events.d.ts +41 -0
  29. package/dist/events.js +106 -0
  30. package/dist/extract.d.ts +27 -0
  31. package/dist/extract.js +62 -0
  32. package/dist/focus.d.ts +1 -0
  33. package/dist/focus.js +44 -0
  34. package/dist/glob.d.ts +4 -0
  35. package/dist/glob.js +63 -0
  36. package/dist/har.d.ts +105 -0
  37. package/dist/har.js +88 -0
  38. package/dist/history.d.ts +35 -0
  39. package/dist/history.js +277 -0
  40. package/dist/host-platform.d.ts +5 -0
  41. package/dist/host-platform.js +19 -0
  42. package/dist/host-prompts-macos.d.ts +2 -0
  43. package/dist/host-prompts-macos.js +102 -0
  44. package/dist/host-prompts-wsl.d.ts +6 -0
  45. package/dist/host-prompts-wsl.js +64 -0
  46. package/dist/host-prompts.d.ts +3 -0
  47. package/dist/host-prompts.js +25 -0
  48. package/dist/index.d.ts +17 -0
  49. package/dist/index.js +47 -0
  50. package/dist/network.d.ts +54 -0
  51. package/dist/network.js +204 -0
  52. package/dist/origin-storage.d.ts +31 -0
  53. package/dist/origin-storage.js +82 -0
  54. package/dist/paths.d.ts +17 -0
  55. package/dist/paths.js +52 -0
  56. package/dist/pipe.d.ts +9 -0
  57. package/dist/pipe.js +73 -0
  58. package/dist/profile-mode.d.ts +10 -0
  59. package/dist/profile-mode.js +42 -0
  60. package/dist/protocol-help.d.ts +34 -0
  61. package/dist/protocol-help.js +66 -0
  62. package/dist/protocol.d.ts +49 -0
  63. package/dist/protocol.js +89 -0
  64. package/dist/refs.d.ts +9 -0
  65. package/dist/refs.js +46 -0
  66. package/dist/routes.d.ts +20 -0
  67. package/dist/routes.js +106 -0
  68. package/dist/runner.d.ts +20 -0
  69. package/dist/runner.js +81 -0
  70. package/dist/session-name.d.ts +9 -0
  71. package/dist/session-name.js +50 -0
  72. package/dist/session-store.d.ts +5 -0
  73. package/dist/session-store.js +58 -0
  74. package/dist/sessions.d.ts +47 -0
  75. package/dist/sessions.js +171 -0
  76. package/dist/tab-groups.d.ts +9 -0
  77. package/dist/tab-groups.js +13 -0
  78. package/dist/targets.d.ts +43 -0
  79. package/dist/targets.js +229 -0
  80. package/dist/validate.d.ts +3 -0
  81. package/dist/validate.js +31 -0
  82. package/dist/wait.d.ts +24 -0
  83. package/dist/wait.js +88 -0
  84. package/examples/go/go.mod +3 -0
  85. package/examples/go/main.go +104 -0
  86. package/examples/hn-front-page.sh +18 -0
  87. package/examples/hn-front-page.ts +24 -0
  88. package/examples/hn_front_page.py +56 -0
  89. package/extension/tab-groups/manifest.json +8 -0
  90. package/extension/tab-groups/service-worker.js +41 -0
  91. package/package.json +60 -0
  92. package/skills/patchrome/SKILL.md +74 -0
  93. package/skills/patchrome/references/commands.md +130 -0
  94. package/skills/patchrome/references/debugging.md +20 -0
  95. package/skills/patchrome/references/hard-pages.md +49 -0
  96. package/skills/patchrome/references/logins.md +46 -0
  97. package/skills/patchrome/references/scraping.md +51 -0
  98. package/skills/patchrome/references/scripting.md +79 -0
package/dist/har.d.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { NetworkEntry, RequestTiming } from "./network.ts";
2
+ export interface HarBody {
3
+ text: string;
4
+ encoding: "base64" | undefined;
5
+ }
6
+ interface HarHeader {
7
+ name: string;
8
+ value: string;
9
+ }
10
+ export declare function buildHar(entries: NetworkEntry[], bodies: Map<string, HarBody>, creatorVersion: string): {
11
+ log: {
12
+ version: string;
13
+ creator: {
14
+ name: string;
15
+ version: string;
16
+ };
17
+ pages: {
18
+ startedDateTime: string;
19
+ id: string;
20
+ title: string;
21
+ pageTimings: {};
22
+ }[];
23
+ entries: {
24
+ pageref: string;
25
+ startedDateTime: string;
26
+ time: number;
27
+ request: {
28
+ method: string;
29
+ url: string;
30
+ httpVersion: string;
31
+ cookies: never[];
32
+ headers: HarHeader[];
33
+ queryString: {
34
+ name: string;
35
+ value: string;
36
+ }[];
37
+ postData?: {
38
+ mimeType: string;
39
+ text: string;
40
+ } | undefined;
41
+ headersSize: number;
42
+ bodySize: number;
43
+ };
44
+ response: {
45
+ status: number;
46
+ statusText: string;
47
+ httpVersion: string;
48
+ cookies: never[];
49
+ headers: HarHeader[];
50
+ content: {
51
+ size: number;
52
+ mimeType: string;
53
+ text?: string | undefined;
54
+ encoding?: "base64" | undefined;
55
+ };
56
+ redirectURL: string;
57
+ headersSize: number;
58
+ bodySize: number;
59
+ };
60
+ cache: {};
61
+ timings: {
62
+ blocked: number;
63
+ dns: number;
64
+ connect: number;
65
+ ssl: number;
66
+ send: number;
67
+ wait: number;
68
+ receive: number;
69
+ } | {
70
+ blocked: number;
71
+ dns: number;
72
+ connect: number;
73
+ ssl: number;
74
+ send: number;
75
+ wait: number;
76
+ receive: number;
77
+ };
78
+ _resourceType: string;
79
+ }[];
80
+ };
81
+ };
82
+ export declare function harTimings(timing: RequestTiming | undefined): {
83
+ phases: {
84
+ blocked: number;
85
+ dns: number;
86
+ connect: number;
87
+ ssl: number;
88
+ send: number;
89
+ wait: number;
90
+ receive: number;
91
+ };
92
+ total: undefined;
93
+ } | {
94
+ phases: {
95
+ blocked: number;
96
+ dns: number;
97
+ connect: number;
98
+ ssl: number;
99
+ send: number;
100
+ wait: number;
101
+ receive: number;
102
+ };
103
+ total: number;
104
+ };
105
+ export {};
package/dist/har.js ADDED
@@ -0,0 +1,88 @@
1
+ // HAR 1.2, the subset Chrome DevTools and har-analyzer tools read. Pending requests are left out:
2
+ // a HAR entry needs a response.
3
+ export function buildHar(entries, bodies, creatorVersion) {
4
+ const pages = [...new Map(entries.map((entry) => [entry.tabId, entry])).values()].map((first) => ({
5
+ startedDateTime: new Date(first.startedAtMs).toISOString(),
6
+ id: first.tabId,
7
+ title: first.tabId,
8
+ pageTimings: {},
9
+ }));
10
+ return {
11
+ log: {
12
+ version: "1.2",
13
+ creator: { name: "patchrome", version: creatorVersion },
14
+ pages,
15
+ entries: entries
16
+ .filter((entry) => entry.state !== "pending")
17
+ .map((entry) => {
18
+ const timings = harTimings(entry.timing);
19
+ const body = bodies.get(entry.id);
20
+ const url = new URL(entry.url);
21
+ return {
22
+ pageref: entry.tabId,
23
+ startedDateTime: new Date(entry.startedAtMs).toISOString(),
24
+ time: timings.total ?? entry.durationMs ?? 0,
25
+ request: {
26
+ method: entry.method,
27
+ url: entry.url,
28
+ httpVersion: "",
29
+ cookies: [],
30
+ headers: toHeaders(entry.requestHeaders),
31
+ queryString: [...url.searchParams].map(([name, value]) => ({ name, value })),
32
+ ...(entry.postData === undefined
33
+ ? {}
34
+ : { postData: { mimeType: entry.requestHeaders["content-type"] ?? "", text: entry.postData } }),
35
+ headersSize: -1,
36
+ bodySize: entry.postData === undefined ? 0 : Buffer.byteLength(entry.postData),
37
+ },
38
+ response: {
39
+ status: entry.status ?? 0,
40
+ statusText: entry.failure ?? entry.statusText,
41
+ httpVersion: "",
42
+ cookies: [],
43
+ headers: toHeaders(entry.responseHeaders),
44
+ content: {
45
+ size: body === undefined
46
+ ? -1
47
+ : body.encoding === "base64"
48
+ ? Buffer.from(body.text, "base64").byteLength
49
+ : Buffer.byteLength(body.text),
50
+ mimeType: entry.responseHeaders["content-type"] ?? "",
51
+ ...(body === undefined ? {} : { text: body.text }),
52
+ ...(body?.encoding === undefined ? {} : { encoding: body.encoding }),
53
+ },
54
+ redirectURL: entry.responseHeaders.location ?? "",
55
+ headersSize: -1,
56
+ bodySize: -1,
57
+ },
58
+ cache: {},
59
+ timings: timings.phases,
60
+ _resourceType: entry.resourceType,
61
+ };
62
+ }),
63
+ },
64
+ };
65
+ }
66
+ function toHeaders(headers) {
67
+ return Object.entries(headers).map(([name, value]) => ({ name, value }));
68
+ }
69
+ // Playwright reports phase boundaries in ms relative to startTime, with -1 for phases that did not happen.
70
+ export function harTimings(timing) {
71
+ const span = (start, end) => (start >= 0 && end >= start ? end - start : -1);
72
+ if (!timing)
73
+ return { phases: { blocked: -1, dns: -1, connect: -1, ssl: -1, send: 0, wait: 0, receive: 0 }, total: undefined };
74
+ const phases = {
75
+ blocked: -1,
76
+ dns: span(timing.domainLookupStart, timing.domainLookupEnd),
77
+ connect: span(timing.connectStart, timing.connectEnd),
78
+ ssl: span(timing.secureConnectionStart, timing.connectEnd),
79
+ send: 0,
80
+ wait: Math.max(0, span(timing.requestStart, timing.responseStart)),
81
+ receive: Math.max(0, span(timing.responseStart, timing.responseEnd)),
82
+ };
83
+ // HAR counts ssl inside connect, so it is not added twice.
84
+ const total = [phases.dns, phases.connect, phases.send, phases.wait, phases.receive]
85
+ .filter((ms) => ms > 0)
86
+ .reduce((sum, ms) => sum + ms, 0);
87
+ return { phases, total };
88
+ }
@@ -0,0 +1,35 @@
1
+ import { type CommandName } from "./protocol.ts";
2
+ export declare const historyFormats: readonly ["sh", "jsonl"];
3
+ export type HistoryFormat = (typeof historyFormats)[number];
4
+ export declare function isHistoryFormat(value: string): value is HistoryFormat;
5
+ export interface HistoryStep {
6
+ atMs: number;
7
+ argv: string[];
8
+ notes: string[];
9
+ }
10
+ export interface ReplayHint {
11
+ refLocator?: {
12
+ flags: string[];
13
+ notes: string[];
14
+ };
15
+ requestUrlGlob?: string;
16
+ isSecretText?: boolean;
17
+ }
18
+ export declare const secretPlaceholder = "<secret>";
19
+ export declare const historyFileName = "history.jsonl";
20
+ export declare function isReplayable(command: CommandName, args: Record<string, unknown>): boolean;
21
+ export declare function stepArgv(argv: string[]): string[];
22
+ export declare function replayStep(command: CommandName, argv: string[], hint: ReplayHint | undefined, atMs: number): HistoryStep;
23
+ export declare function requestUrlGlob(url: string): string;
24
+ export declare function locatorForRef(snapshot: string, ref: string): {
25
+ flags: string[];
26
+ notes: string[];
27
+ } | undefined;
28
+ export declare function appendHistoryStep(path: string, step: HistoryStep): Promise<void>;
29
+ export declare function readHistory(path: string): Promise<HistoryStep[]>;
30
+ export declare function clearHistory(path: string): Promise<void>;
31
+ export declare function formatHistory(steps: HistoryStep[], format: HistoryFormat, { session, profile }: {
32
+ session: string;
33
+ profile: string;
34
+ }): string;
35
+ export declare function shellWord(word: string): string;
@@ -0,0 +1,277 @@
1
+ import { appendFile, mkdir, readFile, rm } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { z } from "zod";
5
+ import { cliOptions } from "./cli-options.js";
6
+ import { CommandError } from "./protocol.js";
7
+ // A session's history is the commands that worked, as a person would type them again. An agent explores a
8
+ // site with refs, which die with the page; the daemon rewrites each ref into a locator taken from the
9
+ // snapshot the ref came from, so the exported script finds the same element on the next run.
10
+ export const historyFormats = ["sh", "jsonl"];
11
+ export function isHistoryFormat(value) {
12
+ return historyFormats.includes(value);
13
+ }
14
+ export const secretPlaceholder = "<secret>";
15
+ // Lives in the session folder, next to its snapshots.
16
+ export const historyFileName = "history.jsonl";
17
+ // Commands worth replaying. Reading the page layout (snapshot, tabs) and managing sessions or the daemon
18
+ // is exploration, not part of the flow.
19
+ export function isReplayable(command, args) {
20
+ switch (command) {
21
+ case "open":
22
+ case "goto":
23
+ case "switch":
24
+ case "close":
25
+ case "click":
26
+ case "fill":
27
+ case "type":
28
+ case "press":
29
+ case "wait":
30
+ case "text":
31
+ case "eval":
32
+ case "extract":
33
+ case "screenshot":
34
+ case "network-list":
35
+ case "network-get":
36
+ case "network-har-start":
37
+ case "network-har-stop":
38
+ case "route-block":
39
+ case "route-mock":
40
+ case "route-clear":
41
+ case "login":
42
+ case "cookies":
43
+ case "state-save":
44
+ case "state-load":
45
+ case "state-import":
46
+ return true;
47
+ case "challenge":
48
+ return args.handoff === true;
49
+ case "tabs":
50
+ case "snapshot":
51
+ case "watch":
52
+ case "route-list":
53
+ case "console":
54
+ case "errors":
55
+ case "trace-start":
56
+ case "trace-stop":
57
+ case "cdp":
58
+ case "cdp-help":
59
+ case "devtools-url":
60
+ case "session":
61
+ case "sessions":
62
+ case "session-close":
63
+ case "session-label":
64
+ case "daemon-status":
65
+ case "daemon-stop":
66
+ return false;
67
+ }
68
+ }
69
+ // Flags that belong to the caller, not to the step: the session and profile come from the replay's own
70
+ // environment, and --json from how the replay reads output.
71
+ const callerFlags = new Set(["json", "session", "profile"]);
72
+ export function stepArgv(argv) {
73
+ const { tokens } = parseArgs({
74
+ args: argv,
75
+ allowPositionals: true,
76
+ strict: false,
77
+ tokens: true,
78
+ options: cliOptions,
79
+ });
80
+ const dropped = new Set();
81
+ for (const token of tokens) {
82
+ if (token.kind !== "option" || !callerFlags.has(token.name))
83
+ continue;
84
+ dropped.add(token.index);
85
+ if (token.value !== undefined && !token.inlineValue)
86
+ dropped.add(token.index + 1);
87
+ }
88
+ return argv.filter((_, index) => !dropped.has(index));
89
+ }
90
+ // Replaces the ref, request id and secret text in a step's words with what replays.
91
+ export function replayStep(command, argv, hint, atMs) {
92
+ const words = stepArgv(argv);
93
+ const notes = [];
94
+ const { tokens } = parseArgs({
95
+ args: words,
96
+ allowPositionals: true,
97
+ strict: false,
98
+ tokens: true,
99
+ options: cliOptions,
100
+ });
101
+ const positionals = tokens.filter((token) => token.kind === "positional");
102
+ const valueOf = (name) => tokens.find((token) => token.kind === "option" && token.name === name);
103
+ const replacements = new Map();
104
+ if (hint?.refLocator !== undefined) {
105
+ const refOption = valueOf("ref");
106
+ const refPositional = positionals.find((token) => /^@?(?:f\d+)?e\d+$/.test(token.value));
107
+ if (refOption !== undefined && refOption.kind === "option" && refOption.value !== undefined) {
108
+ replacements.set(refOption.index, hint.refLocator.flags);
109
+ if (!refOption.inlineValue)
110
+ replacements.set(refOption.index + 1, []);
111
+ }
112
+ else if (refPositional !== undefined) {
113
+ replacements.set(refPositional.index, hint.refLocator.flags);
114
+ }
115
+ notes.push(...hint.refLocator.notes);
116
+ }
117
+ if (hint?.requestUrlGlob !== undefined) {
118
+ // `network get <id>`: the id is the positional after `get`.
119
+ const id = positionals.find((token) => /^n\d+$/.test(token.value));
120
+ if (id !== undefined)
121
+ replacements.set(id.index, ["--url", hint.requestUrlGlob]);
122
+ }
123
+ if (hint?.isSecretText === true) {
124
+ const text = positionals.at(-1);
125
+ if (text !== undefined)
126
+ replacements.set(text.index, [secretPlaceholder]);
127
+ notes.push(`the text went into a password field and was not recorded: the sh script reads $PATCHROME_SECRET, jsonl carries ${secretPlaceholder} to replace`);
128
+ }
129
+ switch (command) {
130
+ case "switch":
131
+ case "close":
132
+ if (positionals.some((token) => /^t\d+$/.test(token.value)))
133
+ notes.push("tab ids from the recording can differ on replay");
134
+ break;
135
+ case "login":
136
+ notes.push("waits for a person to sign in");
137
+ break;
138
+ case "challenge":
139
+ notes.push("waits for a person to solve a CAPTCHA");
140
+ break;
141
+ case "state-import":
142
+ case "state-load":
143
+ notes.push("waits for the person to approve a login copy");
144
+ break;
145
+ default:
146
+ break;
147
+ }
148
+ return { atMs, argv: words.flatMap((word, index) => replacements.get(index) ?? [word]), notes };
149
+ }
150
+ // Glob for a recorded request: its URL without the query, since ids, timestamps and cursors change per run.
151
+ // `*` and `?` in the URL itself become `?`, the one-character wildcard, as the glob has no escape.
152
+ export function requestUrlGlob(url) {
153
+ let base = url;
154
+ try {
155
+ const parsed = new URL(url);
156
+ base = `${parsed.origin}${parsed.pathname}`;
157
+ }
158
+ catch {
159
+ // A URL Chrome reported but Node cannot parse is used as is.
160
+ }
161
+ return `${base.replace(/[*?]/g, "?")}*`;
162
+ }
163
+ const snapshotLinePattern = /^\s*- ([a-z]+)(?: "((?:[^"\\]|\\.)*)")?/;
164
+ // Finds the snapshot line that carries the ref and turns its role and name into locator flags. Refs inside
165
+ // an iframe have no page-level locator, and nameless or repeated nodes need a position; both get a note.
166
+ export function locatorForRef(snapshot, ref) {
167
+ const nodes = snapshot.split("\n").flatMap((line) => {
168
+ const refs = [...line.matchAll(/\[ref=((?:f\d+)?e\d+)\]/g)].map((match) => match[1] ?? "");
169
+ const match = line.match(snapshotLinePattern);
170
+ if (refs.length === 0 || !match?.[1])
171
+ return [];
172
+ return [
173
+ {
174
+ ref: refs[0] ?? "",
175
+ frame: frameOfRef(refs[0] ?? ""),
176
+ role: match[1],
177
+ name: match[2] === undefined ? undefined : unescapeName(match[2]),
178
+ },
179
+ ];
180
+ });
181
+ const node = nodes.find((candidate) => candidate.ref === ref);
182
+ if (node === undefined)
183
+ return undefined;
184
+ const sameFrame = nodes.filter((candidate) => candidate.frame === node.frame);
185
+ const notes = [];
186
+ const flags = ["--role", node.role];
187
+ // Without a name, getByRole matches every node of the role, named or not.
188
+ const peers = node.name === undefined
189
+ ? sameFrame.filter((candidate) => candidate.role === node.role)
190
+ : sameFrame.filter((candidate) => candidate.role === node.role && candidate.name === node.name);
191
+ if (node.name !== undefined)
192
+ flags.push("--name", node.name, "--exact");
193
+ if (peers.length > 1) {
194
+ const nth = peers.findIndex((candidate) => candidate.ref === ref);
195
+ flags.push("--nth", String(nth));
196
+ notes.push(`${peers.length} elements matched ${node.role}${node.name === undefined ? "" : ` "${node.name}"`}; --nth ${nth} is its position in the recorded page`);
197
+ }
198
+ else if (node.name === undefined) {
199
+ notes.push(`${node.role} has no accessible name; the locator matches the only ${node.role} on the recorded page`);
200
+ }
201
+ // The snapshot starts at the main frame's root, so its frame prefix, often f1 and sometimes none, marks the
202
+ // page itself; any other prefix is an iframe.
203
+ if (node.frame !== nodes[0]?.frame)
204
+ notes.push("the element is inside an iframe; add --frame <iframe-css>");
205
+ return { flags, notes };
206
+ }
207
+ function frameOfRef(ref) {
208
+ return ref.match(/^(f\d+)e\d+$/)?.[1] ?? "";
209
+ }
210
+ function unescapeName(raw) {
211
+ try {
212
+ return JSON.parse(`"${raw}"`);
213
+ }
214
+ catch {
215
+ return raw.replace(/\\(.)/g, "$1");
216
+ }
217
+ }
218
+ const stepSchema = z.object({ atMs: z.number(), argv: z.array(z.string()), notes: z.array(z.string()) });
219
+ export async function appendHistoryStep(path, step) {
220
+ await mkdir(dirname(path), { recursive: true });
221
+ await appendFile(path, `${JSON.stringify(step)}\n`, { mode: 0o600 });
222
+ }
223
+ // A line that does not parse is skipped, so one torn write cannot hide the rest of the flow.
224
+ export async function readHistory(path) {
225
+ const text = await readFile(path, "utf8").catch((err) => {
226
+ if (err.code === "ENOENT")
227
+ return "";
228
+ throw new CommandError("bad_args", `cannot read history ${path}: ${err.message}`);
229
+ });
230
+ return text.split("\n").flatMap((line) => {
231
+ if (line.trim() === "")
232
+ return [];
233
+ try {
234
+ const parsed = stepSchema.safeParse(JSON.parse(line));
235
+ return parsed.success ? [parsed.data] : [];
236
+ }
237
+ catch {
238
+ return [];
239
+ }
240
+ });
241
+ }
242
+ export async function clearHistory(path) {
243
+ await rm(path, { force: true });
244
+ }
245
+ export function formatHistory(steps, format, { session, profile }) {
246
+ switch (format) {
247
+ case "jsonl":
248
+ return steps
249
+ .map((step) => JSON.stringify(step.notes.length === 0 ? { argv: step.argv } : { argv: step.argv, notes: step.notes }))
250
+ .join("\n");
251
+ case "sh": {
252
+ const first = steps[0];
253
+ const last = steps.at(-1);
254
+ const header = [
255
+ "#!/bin/sh",
256
+ first === undefined || last === undefined
257
+ ? `# patchrome session ${session}: no steps recorded`
258
+ : `# patchrome session ${session}: ${steps.length} steps, ${new Date(first.atMs).toISOString()} to ${new Date(last.atMs).toISOString()}`,
259
+ "# Lines starting with `# check:` need a look before this runs unattended.",
260
+ "set -eu",
261
+ `export PATCHROME_SESSION="\${PATCHROME_SESSION:-replay-$$}"`,
262
+ ...(profile === "stealth" ? [] : [`export PATCHROME_PROFILE="\${PATCHROME_PROFILE:-${profile}}"`]),
263
+ "",
264
+ ];
265
+ const body = steps.flatMap((step) => [
266
+ ...step.notes.map((note) => `# check: ${note}`),
267
+ ["patchrome", ...step.argv].map(shellWord).join(" "),
268
+ ]);
269
+ return [...header, ...body, "patchrome session close"].join("\n");
270
+ }
271
+ }
272
+ }
273
+ export function shellWord(word) {
274
+ if (word === secretPlaceholder)
275
+ return `"$PATCHROME_SECRET"`;
276
+ return /^[A-Za-z0-9@%+=:,./_-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
277
+ }
@@ -0,0 +1,5 @@
1
+ export declare const hostPlatforms: readonly ["macos", "wsl", "linux", "unsupported"];
2
+ export type HostPlatform = (typeof hostPlatforms)[number];
3
+ export declare function detectHostPlatform(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, procVersion?: typeof readProcVersion): HostPlatform;
4
+ declare function readProcVersion(): string;
5
+ export {};
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from "node:fs";
2
+ // The desktop a person sits at decides how patchrome asks them anything. WSL runs Linux, but its person
3
+ // sits at Windows, so prompts and notifications go through powershell.exe.
4
+ export const hostPlatforms = ["macos", "wsl", "linux", "unsupported"];
5
+ export function detectHostPlatform(platform = process.platform, env = process.env, procVersion = readProcVersion) {
6
+ if (platform === "darwin")
7
+ return "macos";
8
+ if (platform !== "linux")
9
+ return "unsupported";
10
+ return env.WSL_DISTRO_NAME !== undefined || /microsoft/i.test(procVersion()) ? "wsl" : "linux";
11
+ }
12
+ function readProcVersion() {
13
+ try {
14
+ return readFileSync("/proc/version", "utf8");
15
+ }
16
+ catch {
17
+ return "";
18
+ }
19
+ }
@@ -0,0 +1,2 @@
1
+ import { type HostPrompts } from "./copy-guard.ts";
2
+ export declare function macosPrompts(bundlesDir: string, log: (message: string) => void): HostPrompts;
@@ -0,0 +1,102 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFile, mkdir, mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { helperFailure, parseApprovalAnswer } from "./copy-guard.js";
7
+ const run = promisify(execFile);
8
+ const systemOsascript = "/usr/bin/osascript";
9
+ // JXA reaches LocalAuthentication through the ObjC bridge. Policy 2 is LAPolicyDeviceOwnerAuthentication: Touch ID,
10
+ // or the account password on a Mac without it. A script cannot answer that dialog, where it could click an
11
+ // ordinary `display dialog` through System Events. The bridge hands error codes over as strings, hence Number().
12
+ const approvalScript = `
13
+ ObjC.import("LocalAuthentication");
14
+ ObjC.import("Foundation");
15
+ function spin(untilDate, isDone) {
16
+ while (!isDone() && $.NSDate.date.compare(untilDate) < 0) {
17
+ $.NSRunLoop.currentRunLoop.runModeBeforeDate($.NSDefaultRunLoopMode, $.NSDate.dateWithTimeIntervalSinceNow(0.2));
18
+ }
19
+ }
20
+ function run(argv) {
21
+ const policy = 2;
22
+ const context = $.LAContext.alloc.init;
23
+ const error = Ref();
24
+ if (!context.canEvaluatePolicyError(policy, error)) return "unavailable " + (error[0] ? error[0].localizedDescription.js : "no Touch ID or password");
25
+ let answer;
26
+ context.evaluatePolicyLocalizedReasonReply(policy, argv[0], (isApproved, failure) => {
27
+ const code = isApproved ? 0 : Number(failure.code);
28
+ if (isApproved) answer = "approved";
29
+ else if (code === -9) answer = "timed_out";
30
+ else if (code === -1 || code === -2 || code === -4) answer = "denied";
31
+ else answer = "unavailable " + failure.localizedDescription.js;
32
+ });
33
+ spin($.NSDate.dateWithTimeIntervalSinceNow(Number(argv[1])), () => answer !== undefined);
34
+ if (answer === undefined) {
35
+ context.invalidate;
36
+ spin($.NSDate.dateWithTimeIntervalSinceNow(2), () => answer !== undefined);
37
+ }
38
+ return answer === undefined ? "timed_out" : answer;
39
+ }`;
40
+ const notifyScript = `on run argv
41
+ display notification (item 2 of argv) with title (item 1 of argv)
42
+ end run`;
43
+ const infoPlist = `<?xml version="1.0" encoding="UTF-8"?>
44
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
45
+ <plist version="1.0"><dict>
46
+ <key>CFBundleExecutable</key><string>patchrome</string>
47
+ <key>CFBundleIdentifier</key><string>io.patchrome.copy-approval</string>
48
+ <key>CFBundleName</key><string>patchrome</string>
49
+ <key>CFBundlePackageType</key><string>APPL</string>
50
+ </dict></plist>
51
+ `;
52
+ // macOS names the asking app in the dialog, so plain osascript reads "osascript is trying to ...". A copy of
53
+ // osascript inside a patchrome.app bundle reads "patchrome is trying to ...". An osacompile applet would
54
+ // be simpler, but its runtime aborts when LocalAuthentication replies on a background thread. The folder name
55
+ // hashes the plist and the system osascript's size and mtime, so an OS update builds a fresh bundle.
56
+ async function renamedOsascript(bundlesDir) {
57
+ const system = await stat(systemOsascript);
58
+ const hash = createHash("sha256").update(`${infoPlist}${system.size}:${system.mtimeMs}`).digest("hex").slice(0, 12);
59
+ const executable = join(bundlesDir, hash, "patchrome.app", "Contents", "MacOS", "patchrome");
60
+ if (await stat(executable).then(() => true, () => false))
61
+ return executable;
62
+ await mkdir(bundlesDir, { recursive: true });
63
+ const building = await mkdtemp(join(bundlesDir, "building-"));
64
+ try {
65
+ const app = join(building, "patchrome.app");
66
+ await mkdir(join(app, "Contents", "MacOS"), { recursive: true });
67
+ await writeFile(join(app, "Contents", "Info.plist"), infoPlist);
68
+ await copyFile(systemOsascript, join(app, "Contents", "MacOS", "patchrome"));
69
+ // The copy loses Apple's signature for the bundle; an ad-hoc signature lets it run as patchrome.app.
70
+ await run("codesign", ["--force", "--sign", "-", app], { timeout: 30_000 });
71
+ // Two daemons may build at once; the first rename wins and the other bundle is dropped.
72
+ await rename(building, join(bundlesDir, hash)).catch((err) => {
73
+ if (!["EEXIST", "ENOTEMPTY"].includes(err.code ?? ""))
74
+ throw err;
75
+ });
76
+ }
77
+ finally {
78
+ await rm(building, { recursive: true, force: true });
79
+ }
80
+ return executable;
81
+ }
82
+ export function macosPrompts(bundlesDir, log) {
83
+ return {
84
+ async askApproval(reason, timeoutMs) {
85
+ // A failed build still asks, under the name osascript, rather than refusing every copy.
86
+ const osascript = await renamedOsascript(bundlesDir).catch((err) => {
87
+ log(`building patchrome.app failed, asking through osascript: ${String(err).split("\n")[0]}`);
88
+ return systemOsascript;
89
+ });
90
+ try {
91
+ const { stdout } = await run(osascript, ["-l", "JavaScript", "-e", approvalScript, reason, String(Math.ceil(timeoutMs / 1000))], { timeout: timeoutMs + 10_000 });
92
+ return parseApprovalAnswer(stdout);
93
+ }
94
+ catch (err) {
95
+ return { answer: "unavailable", detail: helperFailure("osascript", err) };
96
+ }
97
+ },
98
+ async notify(title, body) {
99
+ await run(systemOsascript, ["-e", notifyScript, title, body], { timeout: 10_000 });
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,6 @@
1
+ import { type HostPrompts } from "./copy-guard.ts";
2
+ export declare function encodedPowerShell(script: string): string;
3
+ export declare function powerShellString(text: string): string;
4
+ export declare function approvalScript(reason: string, timeoutMs: number): string;
5
+ export declare function notifyScript(title: string, body: string): string;
6
+ export declare const wslPrompts: HostPrompts;