pi-oracle 0.7.4 → 0.7.6
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 +32 -0
- package/README.md +53 -18
- package/docs/ORACLE_DESIGN.md +16 -8
- package/docs/platform-smoke.md +156 -0
- package/extensions/oracle/index.ts +10 -4
- package/extensions/oracle/lib/config.ts +53 -27
- package/extensions/oracle/lib/jobs.ts +9 -5
- package/extensions/oracle/lib/poller.ts +1 -0
- package/extensions/oracle/lib/runtime.ts +107 -32
- package/extensions/oracle/lib/tools.ts +138 -12
- package/extensions/oracle/shared/browser-profile-helpers.d.mts +59 -0
- package/extensions/oracle/shared/browser-profile-helpers.mjs +395 -0
- package/extensions/oracle/shared/process-helpers.mjs +12 -1
- package/extensions/oracle/shared/state-coordination-helpers.mjs +8 -2
- package/extensions/oracle/worker/auth-bootstrap.mjs +39 -10
- package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +2 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +157 -1
- package/extensions/oracle/worker/chromium-cookie-source.mjs +2 -1
- package/extensions/oracle/worker/run-job.mjs +107 -25
- package/package.json +30 -9
- package/platform-smoke.config.mjs +66 -0
- package/scripts/oracle-real-smoke.mjs +500 -0
- package/scripts/platform-smoke/Dockerfile.ubuntu +8 -0
- package/scripts/platform-smoke/artifacts.mjs +87 -0
- package/scripts/platform-smoke/assertions.mjs +34 -0
- package/scripts/platform-smoke/crabbox-runner.mjs +135 -0
- package/scripts/platform-smoke/doctor.mjs +239 -0
- package/scripts/platform-smoke/invariants.mjs +124 -0
- package/scripts/platform-smoke/platform-build-windows.ps1 +168 -0
- package/scripts/platform-smoke/targets.mjs +434 -0
- package/scripts/platform-smoke.mjs +152 -0
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
// Usage: Imported by the oracle extension entrypoint and sanity tests to register tools against the pi API.
|
|
5
5
|
// Invariants/Assumptions: The pi runtime validates TypeBox schemas before execute, while execute owns semantic normalization.
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
-
import {
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { once } from "node:events";
|
|
9
|
+
import { createReadStream } from "node:fs";
|
|
10
|
+
import { lstat, mkdtemp, readdir, readlink, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
8
11
|
import { tmpdir } from "node:os";
|
|
9
|
-
import { basename, join, posix } from "node:path";
|
|
12
|
+
import { basename, dirname, join, posix } from "node:path";
|
|
13
|
+
import { sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
|
|
10
14
|
import { runOracleAuthBootstrap } from "./auth.js";
|
|
11
15
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
16
|
import { Type } from "typebox";
|
|
@@ -125,6 +129,8 @@ const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
|
|
|
125
129
|
".pi",
|
|
126
130
|
".oracle-context",
|
|
127
131
|
".cursor",
|
|
132
|
+
".artifacts",
|
|
133
|
+
".crabbox",
|
|
128
134
|
"node_modules",
|
|
129
135
|
"target",
|
|
130
136
|
".venv",
|
|
@@ -386,6 +392,99 @@ function formatArchiveOversizeError(args: {
|
|
|
386
392
|
.join("\n");
|
|
387
393
|
}
|
|
388
394
|
|
|
395
|
+
function writeOctal(value: number, width: number): Buffer {
|
|
396
|
+
const text = Math.max(0, Math.floor(value)).toString(8).slice(-(width - 1)).padStart(width - 1, "0") + "\0";
|
|
397
|
+
return Buffer.from(text, "ascii");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function writeTarName(header: Buffer, name: string): void {
|
|
401
|
+
const normalized = name.replaceAll("\\", "/");
|
|
402
|
+
const nameBytes = Buffer.byteLength(normalized);
|
|
403
|
+
if (nameBytes <= 100) {
|
|
404
|
+
header.write(normalized, 0, 100, "utf8");
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const parts = normalized.split("/");
|
|
408
|
+
const fileName = parts.pop() || "";
|
|
409
|
+
const prefix = parts.join("/");
|
|
410
|
+
if (Buffer.byteLength(fileName) > 100 || Buffer.byteLength(prefix) > 155) {
|
|
411
|
+
throw new Error(`archive path is too long for portable tar header: ${normalized}`);
|
|
412
|
+
}
|
|
413
|
+
header.write(fileName, 0, 100, "utf8");
|
|
414
|
+
header.write(prefix, 345, 155, "utf8");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function buildTarHeader(name: string, options: { mode: number; size: number; mtimeMs: number; type: "file" | "directory" | "symlink"; linkName?: string }): Buffer {
|
|
418
|
+
const header = Buffer.alloc(512);
|
|
419
|
+
writeTarName(header, options.type === "directory" && !name.endsWith("/") ? `${name}/` : name);
|
|
420
|
+
writeOctal(options.mode & 0o7777, 8).copy(header, 100);
|
|
421
|
+
writeOctal(0, 8).copy(header, 108);
|
|
422
|
+
writeOctal(0, 8).copy(header, 116);
|
|
423
|
+
writeOctal(options.size, 12).copy(header, 124);
|
|
424
|
+
writeOctal(Math.floor(options.mtimeMs / 1000), 12).copy(header, 136);
|
|
425
|
+
Buffer.from(" ", "ascii").copy(header, 148);
|
|
426
|
+
header[156] = options.type === "directory" ? 53 : options.type === "symlink" ? 50 : 48;
|
|
427
|
+
if (options.linkName) header.write(options.linkName.replaceAll("\\", "/"), 157, 100, "utf8");
|
|
428
|
+
header.write("ustar", 257, 6, "ascii");
|
|
429
|
+
header.write("00", 263, 2, "ascii");
|
|
430
|
+
let checksum = 0;
|
|
431
|
+
for (const byte of header) checksum += byte;
|
|
432
|
+
const checksumText = checksum.toString(8).padStart(6, "0");
|
|
433
|
+
header.write(`${checksumText}\0 `, 148, 8, "ascii");
|
|
434
|
+
return header;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function writeChunk(stream: NodeJS.WritableStream, chunk: Buffer): Promise<void> {
|
|
438
|
+
if (!stream.write(chunk)) await once(stream, "drain");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function writeWindowsTarArchiveToZstd(cwd: string, entries: string[], archivePath: string, timeout: AbortSignal): Promise<void> {
|
|
442
|
+
const scrubbedEnv = sweetCookieSafeStoragePasswordScrubbedEnv(process.env);
|
|
443
|
+
const zstd = spawn("zstd", ["-19", "-T0", "-f", "-o", archivePath], {
|
|
444
|
+
cwd,
|
|
445
|
+
env: scrubbedEnv,
|
|
446
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
447
|
+
signal: timeout,
|
|
448
|
+
});
|
|
449
|
+
let stderr = "";
|
|
450
|
+
zstd.stderr?.on("data", (chunk) => {
|
|
451
|
+
stderr += String(chunk);
|
|
452
|
+
});
|
|
453
|
+
try {
|
|
454
|
+
for (const entry of entries) {
|
|
455
|
+
const normalizedEntry = entry.replaceAll("\\", "/");
|
|
456
|
+
const absolutePath = join(cwd, normalizedEntry);
|
|
457
|
+
const info = await lstat(absolutePath);
|
|
458
|
+
if (info.isSymbolicLink()) {
|
|
459
|
+
await writeChunk(zstd.stdin, buildTarHeader(normalizedEntry, { mode: info.mode, size: 0, mtimeMs: info.mtimeMs, type: "symlink", linkName: await readlink(absolutePath) }));
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (info.isDirectory()) {
|
|
463
|
+
await writeChunk(zstd.stdin, buildTarHeader(normalizedEntry, { mode: info.mode, size: 0, mtimeMs: info.mtimeMs, type: "directory" }));
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (!info.isFile()) continue;
|
|
467
|
+
await writeChunk(zstd.stdin, buildTarHeader(normalizedEntry, { mode: info.mode, size: info.size, mtimeMs: info.mtimeMs, type: "file" }));
|
|
468
|
+
for await (const chunk of createReadStream(absolutePath)) {
|
|
469
|
+
await writeChunk(zstd.stdin, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
470
|
+
}
|
|
471
|
+
const padding = info.size % 512 === 0 ? 0 : 512 - (info.size % 512);
|
|
472
|
+
if (padding > 0) await writeChunk(zstd.stdin, Buffer.alloc(padding));
|
|
473
|
+
}
|
|
474
|
+
await writeChunk(zstd.stdin, Buffer.alloc(1024));
|
|
475
|
+
zstd.stdin.end();
|
|
476
|
+
} catch (error) {
|
|
477
|
+
zstd.stdin.destroy();
|
|
478
|
+
zstd.kill();
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
const code = await new Promise<number | null>((resolve, reject) => {
|
|
482
|
+
zstd.once("error", reject);
|
|
483
|
+
zstd.once("close", resolve);
|
|
484
|
+
});
|
|
485
|
+
if (code !== 0) throw new Error(`zstd archive compression failed with status ${code}: ${stderr.trim()}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
389
488
|
async function writeArchiveFile(
|
|
390
489
|
cwd: string,
|
|
391
490
|
entries: string[],
|
|
@@ -396,13 +495,32 @@ async function writeArchiveFile(
|
|
|
396
495
|
await writeFile(listPath, Buffer.from(`${entries.join("\0")}\0`), { mode: 0o600 });
|
|
397
496
|
await rm(archivePath, { force: true }).catch(() => undefined);
|
|
398
497
|
|
|
399
|
-
|
|
498
|
+
if (process.platform === "win32") {
|
|
499
|
+
const timeoutController = new AbortController();
|
|
500
|
+
const timeout = setTimeout(() => timeoutController.abort(), options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS);
|
|
501
|
+
try {
|
|
502
|
+
await writeWindowsTarArchiveToZstd(cwd, entries, archivePath, timeoutController.signal);
|
|
503
|
+
return (await stat(archivePath)).size;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (timeoutController.signal.aborted) {
|
|
506
|
+
throw new Error(`Oracle archive subprocess timed out after ${options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS}ms`);
|
|
507
|
+
}
|
|
508
|
+
throw error;
|
|
509
|
+
} finally {
|
|
510
|
+
clearTimeout(timeout);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
400
514
|
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
401
|
-
const
|
|
402
|
-
|
|
515
|
+
const scrubbedEnv = sweetCookieSafeStoragePasswordScrubbedEnv();
|
|
516
|
+
const tarArgs = ["--null", "-cf", "-", "-C", cwd, "-T", basename(listPath)];
|
|
517
|
+
const tar = spawn(process.env.PI_ORACLE_TEST_TAR_BIN ?? "tar", tarArgs, {
|
|
518
|
+
cwd: dirname(listPath),
|
|
519
|
+
env: scrubbedEnv,
|
|
403
520
|
stdio: ["ignore", "pipe", "pipe"],
|
|
404
521
|
});
|
|
405
|
-
const zstd = spawn("zstd", ["-19", "-T0", "-f", "-o", archivePath], {
|
|
522
|
+
const zstd = spawn(process.env.PI_ORACLE_TEST_ZSTD_BIN ?? "zstd", ["-19", "-T0", "-f", "-o", archivePath], {
|
|
523
|
+
env: scrubbedEnv,
|
|
406
524
|
stdio: ["pipe", "ignore", "pipe"],
|
|
407
525
|
});
|
|
408
526
|
|
|
@@ -832,6 +950,14 @@ function buildOracleToolErrorDetails(toolName: OracleToolErrorSource, error: unk
|
|
|
832
950
|
};
|
|
833
951
|
}
|
|
834
952
|
|
|
953
|
+
if (/^Oracle (auth seed profile|runtime profile|runtime profiles) path is unsafe: /.test(message)) {
|
|
954
|
+
return {
|
|
955
|
+
code: "oracle_profile_path_unsafe",
|
|
956
|
+
message,
|
|
957
|
+
suggestedNextStep: "Move browser.authSeedProfileDir/browser.runtimeProfilesDir outside real browser profile directories and retry.",
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
835
961
|
if (message.startsWith("Oracle runtime profiles directory is not writable: ")) {
|
|
836
962
|
return {
|
|
837
963
|
code: "runtime_profiles_dir_unwritable",
|
|
@@ -964,10 +1090,10 @@ function buildOracleToolErrorDetails(toolName: OracleToolErrorSource, error: unk
|
|
|
964
1090
|
function buildOracleToolErrorResult(
|
|
965
1091
|
toolName: OracleToolName,
|
|
966
1092
|
error: unknown,
|
|
967
|
-
params:
|
|
1093
|
+
params: unknown,
|
|
968
1094
|
options?: { job?: NonNullable<ReturnType<typeof readJob>>; jobDetails?: OracleToolJobDetailsOptions },
|
|
969
1095
|
) {
|
|
970
|
-
const errorDetails = buildOracleToolErrorDetails(toolName, error, params);
|
|
1096
|
+
const errorDetails = buildOracleToolErrorDetails(toolName, error, asRecord(params) ?? {});
|
|
971
1097
|
return {
|
|
972
1098
|
content: [{
|
|
973
1099
|
type: "text" as const,
|
|
@@ -1430,7 +1556,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
|
|
|
1430
1556
|
await appendCleanupWarnings(job.id, cleanupReport.warnings).catch(() => undefined);
|
|
1431
1557
|
}
|
|
1432
1558
|
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
1433
|
-
return buildOracleToolErrorResult("oracle_submit", error, params
|
|
1559
|
+
return buildOracleToolErrorResult("oracle_submit", error, params, {
|
|
1434
1560
|
job: latest ?? job,
|
|
1435
1561
|
jobDetails: {
|
|
1436
1562
|
queue: latest ? buildOracleQueueSnapshot(latest, latest.status === "queued" ? getQueuePosition(latest.id) : undefined) : undefined,
|
|
@@ -1443,7 +1569,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
|
|
|
1443
1569
|
await rm(tempArchivePath, { force: true }).catch(() => undefined);
|
|
1444
1570
|
}
|
|
1445
1571
|
} catch (error) {
|
|
1446
|
-
return buildOracleToolErrorResult("oracle_submit", error, params
|
|
1572
|
+
return buildOracleToolErrorResult("oracle_submit", error, params);
|
|
1447
1573
|
}
|
|
1448
1574
|
},
|
|
1449
1575
|
});
|
|
@@ -1503,7 +1629,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
|
|
|
1503
1629
|
},
|
|
1504
1630
|
};
|
|
1505
1631
|
} catch (error) {
|
|
1506
|
-
return buildOracleToolErrorResult("oracle_read", error, params
|
|
1632
|
+
return buildOracleToolErrorResult("oracle_read", error, params);
|
|
1507
1633
|
}
|
|
1508
1634
|
},
|
|
1509
1635
|
});
|
|
@@ -1538,7 +1664,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
|
|
|
1538
1664
|
details: { job: redactJobDetails(cancelled, { queue: buildOracleQueueSnapshot(cancelled, cancelled.status === "queued" ? getQueuePosition(cancelled.id) : undefined) }) },
|
|
1539
1665
|
};
|
|
1540
1666
|
} catch (error) {
|
|
1541
|
-
return buildOracleToolErrorResult("oracle_cancel", error, params
|
|
1667
|
+
return buildOracleToolErrorResult("oracle_cancel", error, params);
|
|
1542
1668
|
}
|
|
1543
1669
|
},
|
|
1544
1670
|
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type OraclePlatform = NodeJS.Platform;
|
|
2
|
+
|
|
3
|
+
export interface BrowserPathOptions {
|
|
4
|
+
env?: NodeJS.ProcessEnv;
|
|
5
|
+
homeDir?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ExecutableSearchOptions {
|
|
9
|
+
pathValue?: string;
|
|
10
|
+
pathDelimiter?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const SWEET_COOKIE_SAFE_STORAGE_PASSWORD_ENV_NAMES: readonly [
|
|
14
|
+
"SWEET_COOKIE_CHROME_SAFE_STORAGE_PASSWORD",
|
|
15
|
+
"SWEET_COOKIE_BRAVE_SAFE_STORAGE_PASSWORD",
|
|
16
|
+
"SWEET_COOKIE_EDGE_SAFE_STORAGE_PASSWORD",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function expandHomePath(value: string, homeDir?: string): string;
|
|
20
|
+
export function normalizedAbsolutePath(value: string, options?: BrowserPathOptions): string;
|
|
21
|
+
export function linuxConfigHome(options?: BrowserPathOptions): string;
|
|
22
|
+
export function linuxChromiumCookieImportUserDataDirs(options?: BrowserPathOptions): string[];
|
|
23
|
+
export function linuxBrowserSafetyUserDataDirs(options?: BrowserPathOptions): string[];
|
|
24
|
+
export function browserUserDataDirsForPlatform(
|
|
25
|
+
platform?: OraclePlatform,
|
|
26
|
+
options?: BrowserPathOptions & { includeUnsupported?: boolean },
|
|
27
|
+
): string[];
|
|
28
|
+
export function defaultCloneStrategyForPlatform(platform?: OraclePlatform): "apfs-clone" | "copy";
|
|
29
|
+
export function chromiumKeychainSupportedOnPlatform(platform?: OraclePlatform): boolean;
|
|
30
|
+
export function chromeUserAgentPlatformToken(platform?: OraclePlatform): string | undefined;
|
|
31
|
+
export function pathInsideOrEqual(childPath: string, parentPath: string): boolean;
|
|
32
|
+
export function resolvePathThroughExistingAncestorsSync(pathValue: string): string | undefined;
|
|
33
|
+
export function protectedCookieSourcePaths(cookieSources?: { chromeProfile?: string; chromeCookiePath?: string }): string[];
|
|
34
|
+
export function knownBrowserUserDataPathMatch(
|
|
35
|
+
pathValue: string,
|
|
36
|
+
options?: BrowserPathOptions & {
|
|
37
|
+
platform?: OraclePlatform;
|
|
38
|
+
includeUnsupported?: boolean;
|
|
39
|
+
extraProtectedPaths?: string[];
|
|
40
|
+
cookieSources?: { chromeProfile?: string; chromeCookiePath?: string };
|
|
41
|
+
},
|
|
42
|
+
): string | undefined;
|
|
43
|
+
export function assertNotKnownBrowserUserDataPath(
|
|
44
|
+
pathValue: string,
|
|
45
|
+
label: string,
|
|
46
|
+
options?: BrowserPathOptions & {
|
|
47
|
+
platform?: OraclePlatform;
|
|
48
|
+
includeUnsupported?: boolean;
|
|
49
|
+
extraProtectedPaths?: string[];
|
|
50
|
+
cookieSources?: { chromeProfile?: string; chromeCookiePath?: string };
|
|
51
|
+
},
|
|
52
|
+
): void;
|
|
53
|
+
export function isExecutableFileSync(pathValue: string): boolean;
|
|
54
|
+
export function findExecutableOnPathSync(names: readonly string[], options?: ExecutableSearchOptions): string | undefined;
|
|
55
|
+
export function detectDefaultLinuxChromeExecutablePath(options?: ExecutableSearchOptions): string | undefined;
|
|
56
|
+
export function detectDefaultLinuxCookieProfileSource(options?: BrowserPathOptions): string | undefined;
|
|
57
|
+
export function detectDefaultBrowserProfileSource(platform?: OraclePlatform, options?: BrowserPathOptions): string;
|
|
58
|
+
export function scrubSweetCookieSafeStoragePasswordEnv(env?: NodeJS.ProcessEnv): void;
|
|
59
|
+
export function sweetCookieSafeStoragePasswordScrubbedEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// Purpose: Centralize platform-specific browser profile paths, executable discovery, and profile-safety checks for oracle auth/runtime code.
|
|
2
|
+
// Responsibilities: Resolve Chromium-family user-data roots, choose platform defaults, find executable files safely, and block oracle profile paths that point into real browser data.
|
|
3
|
+
// Scope: Local filesystem/path policy only; cookie import, browser automation, and config loading stay in higher-level modules.
|
|
4
|
+
// Usage: Imported by config.ts, runtime.ts, auth-bootstrap.mjs, run-job.mjs, and sanity tests.
|
|
5
|
+
// Invariants/Assumptions: Real browser profile roots must never be used as oracle seed/runtime profile destinations, even through symlinked ancestors.
|
|
6
|
+
|
|
7
|
+
import { accessSync, constants as fsConstants, existsSync, realpathSync, readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { basename, delimiter, dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** @typedef {import("./browser-profile-helpers.d.mts").OraclePlatform} OraclePlatform */
|
|
12
|
+
/** @typedef {import("./browser-profile-helpers.d.mts").BrowserPathOptions} BrowserPathOptions */
|
|
13
|
+
/** @typedef {import("./browser-profile-helpers.d.mts").ExecutableSearchOptions} ExecutableSearchOptions */
|
|
14
|
+
|
|
15
|
+
export const SWEET_COOKIE_SAFE_STORAGE_PASSWORD_ENV_NAMES = Object.freeze([
|
|
16
|
+
"SWEET_COOKIE_CHROME_SAFE_STORAGE_PASSWORD",
|
|
17
|
+
"SWEET_COOKIE_BRAVE_SAFE_STORAGE_PASSWORD",
|
|
18
|
+
"SWEET_COOKIE_EDGE_SAFE_STORAGE_PASSWORD",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const LINUX_COOKIE_IMPORT_USER_DATA_RELATIVE_DIRS = Object.freeze([
|
|
22
|
+
["google-chrome"],
|
|
23
|
+
["chromium"],
|
|
24
|
+
["chromium-browser"],
|
|
25
|
+
["BraveSoftware", "Brave-Browser"],
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const LINUX_SAFETY_EXTRA_USER_DATA_RELATIVE_DIRS = Object.freeze([
|
|
29
|
+
["google-chrome-beta"],
|
|
30
|
+
["google-chrome-unstable"],
|
|
31
|
+
["microsoft-edge"],
|
|
32
|
+
["microsoft-edge-beta"],
|
|
33
|
+
["microsoft-edge-dev"],
|
|
34
|
+
["vivaldi"],
|
|
35
|
+
["opera"],
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const MAC_CHROMIUM_USER_DATA_RELATIVE_DIRS = Object.freeze([
|
|
39
|
+
["Library", "Application Support", "Google", "Chrome"],
|
|
40
|
+
["Library", "Application Support", "Chromium"],
|
|
41
|
+
["Library", "Application Support", "BraveSoftware", "Brave-Browser"],
|
|
42
|
+
["Library", "Application Support", "Microsoft Edge"],
|
|
43
|
+
["Library", "Application Support", "Arc", "User Data"],
|
|
44
|
+
["Library", "Application Support", "Vivaldi"],
|
|
45
|
+
["Library", "Application Support", "com.operasoftware.Opera"],
|
|
46
|
+
["Library", "Application Support", "Google", "Chrome for Testing"],
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const WINDOWS_CHROMIUM_USER_DATA_RELATIVE_DIRS = Object.freeze([
|
|
50
|
+
["AppData", "Local", "Google", "Chrome", "User Data"],
|
|
51
|
+
["AppData", "Local", "Chromium", "User Data"],
|
|
52
|
+
["AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data"],
|
|
53
|
+
["AppData", "Local", "Microsoft", "Edge", "User Data"],
|
|
54
|
+
["AppData", "Local", "Vivaldi", "User Data"],
|
|
55
|
+
["AppData", "Roaming", "Opera Software", "Opera Stable"],
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const LINUX_CHROME_EXECUTABLE_NAMES = Object.freeze(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "brave-browser", "brave"]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} value
|
|
62
|
+
* @param {string} [homeDir]
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function expandHomePath(value, homeDir = homedir()) {
|
|
66
|
+
if (value === "~") return homeDir;
|
|
67
|
+
if (value.startsWith("~/")) return join(homeDir, value.slice(2));
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} value
|
|
73
|
+
* @param {BrowserPathOptions} [options]
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function normalizedAbsolutePath(value, options = {}) {
|
|
77
|
+
const expanded = expandHomePath(value, options.homeDir ?? homedir());
|
|
78
|
+
return normalize(isAbsolute(expanded) ? expanded : resolve(expanded));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {BrowserPathOptions} [options]
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function linuxConfigHome(options = {}) {
|
|
86
|
+
const env = options.env ?? process.env;
|
|
87
|
+
const configured = env.XDG_CONFIG_HOME?.trim();
|
|
88
|
+
return configured ? normalizedAbsolutePath(configured, options) : join(options.homeDir ?? homedir(), ".config");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {BrowserPathOptions} [options]
|
|
93
|
+
* @returns {string[]}
|
|
94
|
+
*/
|
|
95
|
+
export function linuxChromiumCookieImportUserDataDirs(options = {}) {
|
|
96
|
+
const configHome = linuxConfigHome(options);
|
|
97
|
+
return LINUX_COOKIE_IMPORT_USER_DATA_RELATIVE_DIRS.map((segments) => join(configHome, ...segments));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {BrowserPathOptions} [options]
|
|
102
|
+
* @returns {string[]}
|
|
103
|
+
*/
|
|
104
|
+
export function linuxBrowserSafetyUserDataDirs(options = {}) {
|
|
105
|
+
const configHome = linuxConfigHome(options);
|
|
106
|
+
return [
|
|
107
|
+
...LINUX_COOKIE_IMPORT_USER_DATA_RELATIVE_DIRS,
|
|
108
|
+
...LINUX_SAFETY_EXTRA_USER_DATA_RELATIVE_DIRS,
|
|
109
|
+
].map((segments) => join(configHome, ...segments));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {OraclePlatform} [platform]
|
|
114
|
+
* @param {BrowserPathOptions & { includeUnsupported?: boolean }} [options]
|
|
115
|
+
* @returns {string[]}
|
|
116
|
+
*/
|
|
117
|
+
export function browserUserDataDirsForPlatform(platform = process.platform, options = {}) {
|
|
118
|
+
const homeDir = options.homeDir ?? homedir();
|
|
119
|
+
if (platform === "darwin") return MAC_CHROMIUM_USER_DATA_RELATIVE_DIRS.map((segments) => join(homeDir, ...segments));
|
|
120
|
+
if (platform === "linux") return options.includeUnsupported === false ? linuxChromiumCookieImportUserDataDirs({ ...options, homeDir }) : linuxBrowserSafetyUserDataDirs({ ...options, homeDir });
|
|
121
|
+
if (platform === "win32") return WINDOWS_CHROMIUM_USER_DATA_RELATIVE_DIRS.map((segments) => join(homeDir, ...segments));
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {OraclePlatform} [platform]
|
|
127
|
+
* @returns {"apfs-clone" | "copy"}
|
|
128
|
+
*/
|
|
129
|
+
export function defaultCloneStrategyForPlatform(platform = process.platform) {
|
|
130
|
+
return platform === "darwin" ? "apfs-clone" : "copy";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {OraclePlatform} [platform]
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function chromiumKeychainSupportedOnPlatform(platform = process.platform) {
|
|
138
|
+
return platform === "darwin";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {OraclePlatform} [platform]
|
|
143
|
+
* @returns {string | undefined}
|
|
144
|
+
*/
|
|
145
|
+
export function chromeUserAgentPlatformToken(platform = process.platform) {
|
|
146
|
+
if (platform === "darwin") return "Macintosh; Intel Mac OS X 10_15_7";
|
|
147
|
+
if (platform === "linux") return "X11; Linux x86_64";
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} childPath
|
|
153
|
+
* @param {string} parentPath
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
export function pathInsideOrEqual(childPath, parentPath) {
|
|
157
|
+
const child = normalize(childPath);
|
|
158
|
+
const parent = normalize(parentPath);
|
|
159
|
+
if (child === parent) return true;
|
|
160
|
+
if (!parent) return false;
|
|
161
|
+
const parentWithSeparator = /[/\\]$/.test(parent) ? parent : `${parent}/`;
|
|
162
|
+
const alternateParentWithSeparator = parentWithSeparator.includes("/")
|
|
163
|
+
? parentWithSeparator.replaceAll("/", "\\")
|
|
164
|
+
: parentWithSeparator.replaceAll("\\", "/");
|
|
165
|
+
return child.startsWith(parentWithSeparator) || child.startsWith(alternateParentWithSeparator);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve a path as far as its existing ancestors allow. If the final path does
|
|
170
|
+
* not exist yet, any non-existing suffix is appended to the nearest existing
|
|
171
|
+
* ancestor's realpath so symlinked ancestors are still accounted for.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} pathValue
|
|
174
|
+
* @returns {string | undefined}
|
|
175
|
+
*/
|
|
176
|
+
export function resolvePathThroughExistingAncestorsSync(pathValue) {
|
|
177
|
+
const absolute = normalizedAbsolutePath(pathValue);
|
|
178
|
+
const suffix = [];
|
|
179
|
+
let current = absolute;
|
|
180
|
+
while (true) {
|
|
181
|
+
if (existsSync(current)) {
|
|
182
|
+
try {
|
|
183
|
+
return normalize(join(realpathSync(current), ...suffix.reverse()));
|
|
184
|
+
} catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const parent = dirname(current);
|
|
189
|
+
if (parent === current) return undefined;
|
|
190
|
+
suffix.push(basename(current));
|
|
191
|
+
current = parent;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {string} pathValue
|
|
197
|
+
* @returns {boolean}
|
|
198
|
+
*/
|
|
199
|
+
function looksLikeFilesystemPath(pathValue) {
|
|
200
|
+
return pathValue.startsWith("/") || pathValue.startsWith("~/") || pathValue === "~" || pathValue.startsWith(".");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} pathValue
|
|
205
|
+
* @returns {boolean}
|
|
206
|
+
*/
|
|
207
|
+
function isCookiesDbPath(pathValue) {
|
|
208
|
+
return basename(pathValue) === "Cookies";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {string} cookiePath
|
|
213
|
+
* @returns {string[]}
|
|
214
|
+
*/
|
|
215
|
+
function protectedPathsForCookieDb(cookiePath) {
|
|
216
|
+
const normalized = normalizedAbsolutePath(cookiePath);
|
|
217
|
+
const cookieParent = dirname(normalized);
|
|
218
|
+
const profileDir = basename(cookieParent) === "Network" ? dirname(cookieParent) : cookieParent;
|
|
219
|
+
return [profileDir, dirname(profileDir)];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {{ chromeProfile?: string; chromeCookiePath?: string } | undefined} cookieSources
|
|
224
|
+
* @returns {string[]}
|
|
225
|
+
*/
|
|
226
|
+
export function protectedCookieSourcePaths(cookieSources) {
|
|
227
|
+
if (!cookieSources) return [];
|
|
228
|
+
const roots = [];
|
|
229
|
+
const cookiePath = typeof cookieSources.chromeCookiePath === "string" && cookieSources.chromeCookiePath.trim()
|
|
230
|
+
? cookieSources.chromeCookiePath.trim()
|
|
231
|
+
: undefined;
|
|
232
|
+
if (cookiePath) roots.push(...protectedPathsForCookieDb(cookiePath));
|
|
233
|
+
|
|
234
|
+
const profile = typeof cookieSources.chromeProfile === "string" && cookieSources.chromeProfile.trim()
|
|
235
|
+
? cookieSources.chromeProfile.trim()
|
|
236
|
+
: undefined;
|
|
237
|
+
if (profile && looksLikeFilesystemPath(profile)) {
|
|
238
|
+
const normalizedProfile = normalizedAbsolutePath(profile);
|
|
239
|
+
if (isCookiesDbPath(normalizedProfile)) roots.push(...protectedPathsForCookieDb(normalizedProfile));
|
|
240
|
+
else roots.push(normalizedProfile, dirname(normalizedProfile));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return [...new Set(roots.map((root) => normalize(root)))];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {string} pathValue
|
|
248
|
+
* @param {BrowserPathOptions & { platform?: OraclePlatform; includeUnsupported?: boolean; extraProtectedPaths?: string[]; cookieSources?: { chromeProfile?: string; chromeCookiePath?: string } }} [options]
|
|
249
|
+
* @returns {string | undefined}
|
|
250
|
+
*/
|
|
251
|
+
export function knownBrowserUserDataPathMatch(pathValue, options = {}) {
|
|
252
|
+
const platform = options.platform ?? process.platform;
|
|
253
|
+
const normalizedPath = normalizedAbsolutePath(pathValue, options);
|
|
254
|
+
const resolvedPath = resolvePathThroughExistingAncestorsSync(normalizedPath);
|
|
255
|
+
const roots = [
|
|
256
|
+
...browserUserDataDirsForPlatform(platform, { ...options, includeUnsupported: options.includeUnsupported ?? true }),
|
|
257
|
+
...protectedCookieSourcePaths(options.cookieSources),
|
|
258
|
+
...((options.extraProtectedPaths ?? [])),
|
|
259
|
+
];
|
|
260
|
+
for (const root of roots) {
|
|
261
|
+
const normalizedRoot = normalizedAbsolutePath(root, options);
|
|
262
|
+
if (pathInsideOrEqual(normalizedPath, normalizedRoot)) return normalizedRoot;
|
|
263
|
+
const resolvedRoot = resolvePathThroughExistingAncestorsSync(normalizedRoot) ?? normalizedRoot;
|
|
264
|
+
if (resolvedPath && pathInsideOrEqual(resolvedPath, resolvedRoot)) return resolvedRoot;
|
|
265
|
+
}
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @param {string} pathValue
|
|
271
|
+
* @param {string} label
|
|
272
|
+
* @param {BrowserPathOptions & { platform?: OraclePlatform; includeUnsupported?: boolean; extraProtectedPaths?: string[]; cookieSources?: { chromeProfile?: string; chromeCookiePath?: string } }} [options]
|
|
273
|
+
* @returns {void}
|
|
274
|
+
*/
|
|
275
|
+
export function assertNotKnownBrowserUserDataPath(pathValue, label, options = {}) {
|
|
276
|
+
const match = knownBrowserUserDataPathMatch(pathValue, options);
|
|
277
|
+
if (match) {
|
|
278
|
+
throw new Error(`${label} must not point into a real browser user-data directory (${match}): ${pathValue}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param {string} pathValue
|
|
284
|
+
* @returns {boolean}
|
|
285
|
+
*/
|
|
286
|
+
export function isExecutableFileSync(pathValue) {
|
|
287
|
+
try {
|
|
288
|
+
const stats = statSync(pathValue);
|
|
289
|
+
if (!stats.isFile()) return false;
|
|
290
|
+
accessSync(pathValue, fsConstants.X_OK);
|
|
291
|
+
return true;
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @param {readonly string[]} names
|
|
299
|
+
* @param {ExecutableSearchOptions} [options]
|
|
300
|
+
* @returns {string | undefined}
|
|
301
|
+
*/
|
|
302
|
+
export function findExecutableOnPathSync(names, options = {}) {
|
|
303
|
+
const pathValue = options.pathValue ?? process.env.PATH ?? "";
|
|
304
|
+
const pathDelimiter = options.pathDelimiter ?? delimiter;
|
|
305
|
+
for (const name of names) {
|
|
306
|
+
for (const dir of pathValue.split(pathDelimiter).filter(Boolean)) {
|
|
307
|
+
const candidate = join(dir, name);
|
|
308
|
+
if (isExecutableFileSync(candidate)) return candidate;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* @param {ExecutableSearchOptions} [options]
|
|
316
|
+
* @returns {string | undefined}
|
|
317
|
+
*/
|
|
318
|
+
export function detectDefaultLinuxChromeExecutablePath(options = {}) {
|
|
319
|
+
return findExecutableOnPathSync(LINUX_CHROME_EXECUTABLE_NAMES, options);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {string} userDataDir
|
|
324
|
+
* @returns {string | undefined}
|
|
325
|
+
*/
|
|
326
|
+
function readLastUsedProfileName(userDataDir) {
|
|
327
|
+
const localStatePath = join(userDataDir, "Local State");
|
|
328
|
+
if (!existsSync(localStatePath)) return undefined;
|
|
329
|
+
try {
|
|
330
|
+
const localState = JSON.parse(readFileSync(localStatePath, "utf8"));
|
|
331
|
+
const lastUsed = localState?.profile?.last_used;
|
|
332
|
+
if (typeof lastUsed !== "string") return undefined;
|
|
333
|
+
const trimmed = lastUsed.trim();
|
|
334
|
+
if (!trimmed || trimmed === "." || trimmed === ".." || trimmed.includes("/") || trimmed.includes("\\")) return undefined;
|
|
335
|
+
return trimmed;
|
|
336
|
+
} catch {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Return an absolute Linux Chrome/Chromium-family profile directory for the
|
|
343
|
+
* default cookie importer. Sweet Cookie's Linux `chrome` backend only resolves
|
|
344
|
+
* non-path profile names under google-chrome, so non-Google roots must be
|
|
345
|
+
* passed as absolute paths.
|
|
346
|
+
*
|
|
347
|
+
* @param {BrowserPathOptions} [options]
|
|
348
|
+
* @returns {string | undefined}
|
|
349
|
+
*/
|
|
350
|
+
export function detectDefaultLinuxCookieProfileSource(options = {}) {
|
|
351
|
+
for (const userDataDir of linuxChromiumCookieImportUserDataDirs(options)) {
|
|
352
|
+
const lastUsed = readLastUsedProfileName(userDataDir);
|
|
353
|
+
if (lastUsed) {
|
|
354
|
+
const profilePath = join(userDataDir, lastUsed);
|
|
355
|
+
if (pathInsideOrEqual(profilePath, userDataDir) && existsSync(profilePath)) return profilePath;
|
|
356
|
+
}
|
|
357
|
+
const defaultProfile = join(userDataDir, "Default");
|
|
358
|
+
if (existsSync(defaultProfile)) return defaultProfile;
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* @param {OraclePlatform} [platform]
|
|
365
|
+
* @param {BrowserPathOptions} [options]
|
|
366
|
+
* @returns {string}
|
|
367
|
+
*/
|
|
368
|
+
export function detectDefaultBrowserProfileSource(platform = process.platform, options = {}) {
|
|
369
|
+
if (platform === "linux") return detectDefaultLinuxCookieProfileSource(options) ?? "Default";
|
|
370
|
+
if (platform === "darwin") {
|
|
371
|
+
const userDataDir = browserUserDataDirsForPlatform("darwin", options)[0];
|
|
372
|
+
return readLastUsedProfileName(userDataDir) ?? "Default";
|
|
373
|
+
}
|
|
374
|
+
return "Default";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
379
|
+
* @returns {void}
|
|
380
|
+
*/
|
|
381
|
+
export function scrubSweetCookieSafeStoragePasswordEnv(env = process.env) {
|
|
382
|
+
for (const name of SWEET_COOKIE_SAFE_STORAGE_PASSWORD_ENV_NAMES) {
|
|
383
|
+
delete env[name];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
389
|
+
* @returns {NodeJS.ProcessEnv}
|
|
390
|
+
*/
|
|
391
|
+
export function sweetCookieSafeStoragePasswordScrubbedEnv(env = process.env) {
|
|
392
|
+
const childEnv = { ...env };
|
|
393
|
+
scrubSweetCookieSafeStoragePasswordEnv(childEnv);
|
|
394
|
+
return childEnv;
|
|
395
|
+
}
|