-
Notifications
You must be signed in to change notification settings - Fork 337
Expand file tree
/
Copy pathclipboard.ts
More file actions
451 lines (408 loc) · 15.6 KB
/
Copy pathclipboard.ts
File metadata and controls
451 lines (408 loc) · 15.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { sleep } from "./sleep.js";
export const HISTORY_FILE_NAME = "history.jsonl";
export const NO_READER_MESSAGE =
"no clipboard reader available. Linux: install xclip or wl-clipboard. macOS: install Xcode CLI tools (swiftc) or rely on pbpaste. Windows: ensure PowerShell is on PATH.";
// Native readers (read-clipboard.swift / .ps1) are copied next to the compiled
// CLI at build time (see scripts/copy-native-readers.mjs).
const READERS_DIR = path.dirname(fileURLToPath(import.meta.url));
const READ_TIMEOUT_MS = 2500;
const MAX_CLIPBOARD_BYTES = 64 * 1024 * 1024;
const ID_RADIX = 36;
const HASH_LENGTH = 12;
const PICKLE_HEADER_BYTES = 4;
const PICKLE_ALIGN_BYTES = 4;
const SIGNATURE_SCAN_CHARS = 32 * 1024;
export const GRAB_MIME = "application/x-react-grab";
const CHROMIUM_CUSTOM_FORMAT = "chromium/x-web-custom-data";
// React Grab plain-text payloads always carry a component-stack frame such as
// "in LoginForm (at components/login-form.tsx:46:19)". Used to recognize a grab
// when the structured custom clipboard format is unavailable (text fallback).
// `[^\n]{1,400}?` allows parentheses in paths (e.g. Next.js route groups
// `(auth)`) while bounding backtracking on hostile clipboard text.
const GRAB_TEXT_SIGNATURE = /\bin\s+\S+\s+\(at\s+[^\n]{1,400}?:\d+:\d+\)/;
interface GrabEntry {
tagName?: string;
componentName?: string;
content?: string;
commentText?: string;
source?: {
filePath: string;
lineNumber: number | null;
columnNumber?: number | null;
componentName: string | null;
} | null;
stackContext?: string;
frames?: Array<{
functionName?: string;
fileName?: string;
lineNumber?: number;
columnNumber?: number;
isServer?: boolean;
isSymbolicated?: boolean;
}>;
}
interface GrabRecord {
source: "custom" | "text";
timestamp: number;
version?: string;
content: string;
entries: GrabEntry[];
prompt?: string;
}
interface CapturedGrab extends GrabRecord {
id: string;
receivedAt: number;
}
// What a platform reader returns each poll. `grab` is the resolved
// application/x-react-grab JSON string when present.
interface ClipboardSnapshot {
changeCount: number | null;
text?: string;
grab?: string;
}
// What the native helpers (Swift/PowerShell) emit; the watcher resolves `grab`
// from `grab` directly or by decoding `pickleBase64`.
interface NativeRead {
changeCount?: number | null;
text?: string;
grab?: string;
pickleBase64?: string;
}
interface ClipboardReader {
mode: string;
read: () => ClipboardSnapshot | null;
}
interface LinuxTool {
name: string;
readText: () => string | null;
readCustom: (() => Buffer | null) | null;
}
interface CreateReaderOptions {
textOnly: boolean;
// Writable directory for the compiled native binary (e.g. macOS `pbread`).
workDir: string;
}
const shortHash = (text: string): string =>
createHash("sha1").update(text).digest("hex").slice(0, HASH_LENGTH);
// Chromium serializes web custom data as a base::Pickle on every platform, only
// the clipboard format name differs. Layout (little-endian):
// [payloadSize u32][pairCount u32] then per pair:
// [len u32 in UTF-16 code units][utf16le string, padded to 4 bytes] x2 (format, data).
const alignUp = (value: number): number =>
(value + PICKLE_ALIGN_BYTES - 1) & ~(PICKLE_ALIGN_BYTES - 1);
export const parseChromiumPickle = (buffer: Buffer | null | undefined): Record<string, string> => {
const formats: Record<string, string> = {};
if (!buffer || buffer.length < PICKLE_HEADER_BYTES + 4) return formats;
let offset = PICKLE_HEADER_BYTES;
const pairCount = buffer.readUInt32LE(offset);
offset += 4;
for (let pairIndex = 0; pairIndex < pairCount; pairIndex += 1) {
if (offset + 4 > buffer.length) break;
const formatCodeUnits = buffer.readUInt32LE(offset);
offset += 4;
if (offset + formatCodeUnits * 2 > buffer.length) break;
const format = buffer.toString("utf16le", offset, offset + formatCodeUnits * 2);
offset = alignUp(offset + formatCodeUnits * 2);
if (offset + 4 > buffer.length) break;
const dataCodeUnits = buffer.readUInt32LE(offset);
offset += 4;
if (offset + dataCodeUnits * 2 > buffer.length) break;
const value = buffer.toString("utf16le", offset, offset + dataCodeUnits * 2);
offset = alignUp(offset + dataCodeUnits * 2);
formats[format] = value;
}
return formats;
};
export const extractGrab = (raw: NativeRead): string | undefined => {
if (raw.grab) return raw.grab;
if (raw.pickleBase64)
return parseChromiumPickle(Buffer.from(raw.pickleBase64, "base64"))[GRAB_MIME];
return undefined;
};
export const isGrabText = (text: string): boolean =>
GRAB_TEXT_SIGNATURE.test(
text.length > SIGNATURE_SCAN_CHARS ? text.slice(0, SIGNATURE_SCAN_CHARS) : text,
);
// A grab can carry the user's own instruction (React Grab prompt mode). It lives
// in entries[].commentText when structured, otherwise it is prepended above the
// bracketed element references in `content`. Returns that comment, or undefined.
export const extractPrompt = (record: {
entries?: unknown;
content?: unknown;
}): string | undefined => {
const entries = Array.isArray(record.entries) ? (record.entries as GrabEntry[]) : [];
const comments = entries.map((entry) => entry?.commentText?.trim?.()).filter(Boolean);
if (comments.length > 0) return comments.join("\n");
const content = typeof record.content === "string" ? record.content : "";
const lines = content.split("\n");
const firstReferenceLine = lines.findIndex((line) => line.startsWith("["));
if (firstReferenceLine <= 0) return undefined;
return lines.slice(0, firstReferenceLine).join("\n").trim() || undefined;
};
const hasCommand = (name: string): boolean => {
const probe = process.platform === "win32" ? "where" : "which";
return spawnSync(probe, [name], { stdio: "ignore" }).status === 0;
};
const runText = (command: string, args: string[]): string | null => {
const output = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: MAX_CLIPBOARD_BYTES,
timeout: READ_TIMEOUT_MS,
});
return output.status === 0 ? output.stdout : null;
};
const runBuffer = (command: string, args: string[]): Buffer | null => {
const output = spawnSync(command, args, {
maxBuffer: MAX_CLIPBOARD_BYTES,
timeout: READ_TIMEOUT_MS,
});
return output.status === 0 && output.stdout?.length ? output.stdout : null;
};
const runJson = (command: string, args: string[]): NativeRead | null => {
const output = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: MAX_CLIPBOARD_BYTES,
timeout: READ_TIMEOUT_MS,
});
if (output.status !== 0 || !output.stdout) return null;
try {
return JSON.parse(output.stdout) as NativeRead;
} catch {
return null;
}
};
const compileSwiftReader = (readersDir: string, workDir: string): string | null => {
if (!hasCommand("swiftc")) return null;
const source = path.join(readersDir, "read-clipboard.swift");
if (!fs.existsSync(source)) return null;
const binary = path.join(workDir, "pbread");
const isStale =
!fs.existsSync(binary) || fs.statSync(source).mtimeMs > fs.statSync(binary).mtimeMs;
if (isStale && spawnSync("swiftc", ["-O", source, "-o", binary]).status !== 0) return null;
return binary;
};
const createDarwinReader = (options: CreateReaderOptions): ClipboardReader | null => {
const binary = options.textOnly ? null : compileSwiftReader(READERS_DIR, options.workDir);
if (binary) {
return {
mode: "darwin-native",
read: () => {
const raw = runJson(binary, []);
if (!raw) return null;
return { changeCount: raw.changeCount ?? null, text: raw.text, grab: extractGrab(raw) };
},
};
}
if (!hasCommand("pbpaste")) return null;
return {
mode: "darwin-text",
read: () => {
const text = runText("pbpaste", []);
return text == null ? null : { changeCount: null, text, grab: undefined };
},
};
};
export const detectLinuxTool = (): LinuxTool | null => {
const preferWayland = Boolean(process.env.WAYLAND_DISPLAY) && hasCommand("wl-paste");
if (preferWayland || (hasCommand("wl-paste") && !hasCommand("xclip"))) {
return {
name: "wl-paste",
readText: () =>
runText("wl-paste", ["-n", "-t", "text/plain"]) ?? runText("wl-paste", ["-n"]),
readCustom: () => runBuffer("wl-paste", ["-n", "-t", CHROMIUM_CUSTOM_FORMAT]),
};
}
if (hasCommand("xclip")) {
return {
name: "xclip",
readText: () => runText("xclip", ["-selection", "clipboard", "-o"]),
readCustom: () =>
runBuffer("xclip", ["-selection", "clipboard", "-t", CHROMIUM_CUSTOM_FORMAT, "-o"]),
};
}
if (hasCommand("xsel")) {
return {
name: "xsel",
readText: () => runText("xsel", ["--clipboard", "--output"]),
readCustom: null,
};
}
return null;
};
const createLinuxReader = (options: CreateReaderOptions): ClipboardReader | null => {
const tool = detectLinuxTool();
if (!tool) return null;
const useCustom = !options.textOnly && Boolean(tool.readCustom);
return {
mode: useCustom ? `linux-${tool.name}` : `linux-${tool.name}-text`,
read: () => {
const text = tool.readText();
if (text == null) return null;
// No clipboard sequence number on X11/Wayland, so always read the custom
// blob; its timestamp dedups re-grabs of the identical element.
const grab =
useCustom && tool.readCustom
? parseChromiumPickle(tool.readCustom())[GRAB_MIME]
: undefined;
return { changeCount: null, text, grab };
},
};
};
const detectPowershell = (): string | null =>
hasCommand("pwsh") ? "pwsh" : hasCommand("powershell") ? "powershell" : null;
const createWindowsReader = (options: CreateReaderOptions): ClipboardReader | null => {
const shell = detectPowershell();
if (!shell) return null;
const scriptPath = path.join(READERS_DIR, "read-clipboard.ps1");
if (options.textOnly || !fs.existsSync(scriptPath)) {
return {
mode: "win-text",
read: () => {
const text = runText(shell, ["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
return text == null ? null : { changeCount: null, text, grab: undefined };
},
};
}
const args = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath];
// Warm the cached C# compile once (untimed) so the first timed poll does not
// race the first-run compile and kill it.
spawnSync(shell, args, { stdio: "ignore", maxBuffer: MAX_CLIPBOARD_BYTES });
return {
mode: "win-native",
read: () => {
const raw = runJson(shell, args);
if (!raw) return null;
return {
changeCount: raw.changeCount ?? null,
text: raw.text ?? undefined,
grab: extractGrab(raw),
};
},
};
};
export const createReader = (options: CreateReaderOptions): ClipboardReader | null => {
if (process.platform === "darwin") return createDarwinReader(options);
if (process.platform === "linux") return createLinuxReader(options);
if (process.platform === "win32") return createWindowsReader(options);
return null;
};
// On a shared host an attacker could pre-create the work dir (or its log as a
// symlink) to redirect appends; refuse anything we do not own.
const ensureSafeDir = (dir: string): void => {
let stats: fs.Stats;
try {
stats = fs.lstatSync(dir);
} catch {
return;
}
if (stats.isSymbolicLink()) throw new Error(`Refusing to use ${dir}: it is a symlink.`);
if (process.getuid && stats.uid !== process.getuid()) {
throw new Error(`Refusing to use ${dir}: owned by another user.`);
}
};
// Creates the work dir and drops a self-ignoring .gitignore so the captured
// history never lands in the user's repo.
export const prepareWorkDir = (dir: string): void => {
ensureSafeDir(dir);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const gitignore = path.join(dir, ".gitignore");
if (!fs.existsSync(gitignore)) fs.writeFileSync(gitignore, "*\n");
};
interface RunWatchLoopOptions {
reader: ClipboardReader;
dir: string;
intervalMs: number;
replayLast: boolean;
onWarn?: (message: string) => void;
}
// Baselines the current clipboard first so a grab already sitting there is not
// replayed (unless replayLast), then appends each newly captured grab forever.
export const runWatchLoop = async (options: RunWatchLoopOptions): Promise<void> => {
const { reader, dir, intervalMs, replayLast, onWarn } = options;
const logPath = path.join(dir, HISTORY_FILE_NAME);
const { read } = reader;
let lastChangeCount: number | null = null;
let lastTimestamp = 0;
let lastTextHash = "";
let lastErrorMessage = "";
let sequence = 0;
const initial = read();
if (initial && !replayLast) {
lastChangeCount = initial.changeCount;
if (initial.text) lastTextHash = shortHash(initial.text);
if (initial.grab) {
try {
lastTimestamp = (JSON.parse(initial.grab) as { timestamp?: number }).timestamp ?? 0;
} catch {}
}
}
while (true) {
await sleep(intervalMs);
// Clipboard payloads are untrusted (any web page can forge one), so a single
// malformed/hostile grab must never crash the loop. Distinct errors surface
// via onWarn so a persistent failure is diagnosable rather than silent.
try {
const snapshot = read();
if (!snapshot) continue;
if (snapshot.changeCount !== null && snapshot.changeCount === lastChangeCount) continue;
const textHash = snapshot.text ? shortHash(snapshot.text) : "";
const didTextChange = textHash !== lastTextHash;
let record: GrabRecord | null = null;
let nextTimestamp = lastTimestamp;
if (snapshot.grab) {
let parsed: {
timestamp?: number;
version?: string;
content?: unknown;
entries?: unknown;
} | null = null;
try {
parsed = JSON.parse(snapshot.grab);
} catch {}
if (parsed && typeof parsed.timestamp === "number" && parsed.timestamp > lastTimestamp) {
nextTimestamp = parsed.timestamp;
record = {
source: "custom",
timestamp: parsed.timestamp,
version: typeof parsed.version === "string" ? parsed.version : undefined,
content: typeof parsed.content === "string" ? parsed.content : "",
entries: Array.isArray(parsed.entries) ? (parsed.entries as GrabEntry[]) : [],
};
}
} else if (didTextChange && snapshot.text && isGrabText(snapshot.text)) {
record = { source: "text", timestamp: Date.now(), content: snapshot.text, entries: [] };
}
if (!record) {
// The clipboard changed but carried no new grab; advance the dedup
// state so the same content is not re-scanned every poll.
lastChangeCount = snapshot.changeCount;
lastTextHash = textHash;
continue;
}
const prompt = extractPrompt(record);
if (prompt) record.prompt = prompt;
const captured: CapturedGrab = {
id: `${record.timestamp}-${(sequence += 1).toString(ID_RADIX)}`,
receivedAt: Date.now(),
...record,
};
// Commit the dedup state only after the write succeeds; a failed append
// must leave the grab eligible for retry on the next poll, not lost.
fs.appendFileSync(logPath, `${JSON.stringify(captured)}\n`);
lastChangeCount = snapshot.changeCount;
lastTextHash = textHash;
lastTimestamp = nextTimestamp;
} catch (error) {
const message = String((error as Error)?.message ?? error);
if (message !== lastErrorMessage) {
lastErrorMessage = message;
onWarn?.(message);
}
}
}
};