@rynx-ai/daemon 0.1.0 → 0.1.9
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/dist/browser-cli-args.d.ts +20 -0
- package/dist/browser-cli-args.js +100 -0
- package/dist/browser-runner-session-context.d.ts +24 -0
- package/dist/browser-runner-session-context.js +113 -0
- package/dist/cdp-keyboard-input.d.ts +6 -0
- package/dist/cdp-keyboard-input.js +209 -0
- package/dist/channel-store.d.ts +0 -6
- package/dist/channel-store.js +2 -18
- package/dist/chrome-for-testing-release-resolver.d.ts +34 -0
- package/dist/chrome-for-testing-release-resolver.js +214 -0
- package/dist/chrome-for-testing-store.d.ts +85 -0
- package/dist/chrome-for-testing-store.js +836 -0
- package/dist/cli.js +467 -30
- package/dist/control-client.d.ts +113 -0
- package/dist/control-client.js +792 -0
- package/dist/control-deps.js +37 -6
- package/dist/control-endpoint.d.ts +14 -0
- package/dist/control-endpoint.js +124 -0
- package/dist/control-link-store.d.ts +16 -0
- package/dist/control-link-store.js +53 -0
- package/dist/daemon-owner.d.ts +14 -0
- package/dist/daemon-owner.js +104 -0
- package/dist/daemon-server.d.ts +12 -2
- package/dist/daemon-server.js +559 -28
- package/dist/db.js +140 -10
- package/dist/desktop-browser-host-client.d.ts +34 -0
- package/dist/desktop-browser-host-client.js +413 -0
- package/dist/direct-runtime-authenticator.d.ts +13 -0
- package/dist/direct-runtime-authenticator.js +127 -0
- package/dist/direct-runtime-config.d.ts +20 -0
- package/dist/direct-runtime-config.js +101 -0
- package/dist/embedded-browser-profile-cleanup.d.ts +58 -0
- package/dist/embedded-browser-profile-cleanup.js +338 -0
- package/dist/headless-browser-host.d.ts +14 -0
- package/dist/headless-browser-host.js +2156 -0
- package/dist/index-daemon.js +28 -5
- package/dist/installation-id.d.ts +33 -0
- package/dist/installation-id.js +692 -0
- package/dist/migrations/cleanup-legacy-sessions.d.ts +0 -3
- package/dist/migrations/cleanup-legacy-sessions.js +10 -28
- package/dist/plugin-cli.d.ts +30 -0
- package/dist/plugin-cli.js +270 -0
- package/dist/plugin-host-rpc.d.ts +85 -0
- package/dist/plugin-host-rpc.js +878 -0
- package/dist/plugin-install-restart.d.ts +13 -0
- package/dist/plugin-install-restart.js +24 -0
- package/dist/plugin-installer.d.ts +36 -15
- package/dist/plugin-installer.js +420 -155
- package/dist/plugin-package.d.ts +38 -0
- package/dist/plugin-package.js +553 -0
- package/dist/plugin-runtime-control.d.ts +2 -0
- package/dist/plugin-runtime-control.js +37 -0
- package/dist/plugin-store.d.ts +37 -8
- package/dist/plugin-store.js +80 -15
- package/dist/plugin-supervisor.d.ts +160 -0
- package/dist/plugin-supervisor.js +1143 -0
- package/dist/pm2.d.ts +3 -1
- package/dist/pm2.js +55 -13
- package/dist/provider-cli-service.d.ts +46 -0
- package/dist/provider-cli-service.js +510 -0
- package/dist/registry.d.ts +6 -19
- package/dist/registry.js +22 -87
- package/dist/remote-runtime-access-store.d.ts +61 -0
- package/dist/remote-runtime-access-store.js +386 -0
- package/dist/remote-runtime-admin.d.ts +32 -0
- package/dist/remote-runtime-admin.js +107 -0
- package/dist/remote-runtime-connection-manager.d.ts +92 -0
- package/dist/remote-runtime-connection-manager.js +1409 -0
- package/dist/remote-runtime-credential-store.d.ts +45 -0
- package/dist/remote-runtime-credential-store.js +639 -0
- package/dist/remote-runtime-target-control.d.ts +47 -0
- package/dist/remote-runtime-target-control.js +433 -0
- package/dist/remote-runtime-target-store.d.ts +53 -0
- package/dist/remote-runtime-target-store.js +307 -0
- package/dist/runtime-share-addresses.d.ts +21 -0
- package/dist/runtime-share-addresses.js +50 -0
- package/dist/session-browser-capability-store.d.ts +13 -0
- package/dist/session-browser-capability-store.js +58 -0
- package/dist/session-emulator-binding-store.d.ts +10 -0
- package/dist/session-emulator-binding-store.js +61 -0
- package/dist/session-launch-operations.d.ts +41 -0
- package/dist/session-launch-operations.js +124 -0
- package/dist/session-meta-store.d.ts +3 -2
- package/dist/session-meta-store.js +57 -7
- package/dist/setup.js +9 -4
- package/dist/skills-catalog.js +8 -4
- package/dist/update.d.ts +11 -2
- package/dist/update.js +0 -27
- package/package.json +18 -10
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immutable Chrome for Testing artifact storage for the Runtime Browser.
|
|
3
|
+
*
|
|
4
|
+
* This module never resolves a rolling release, searches PATH, discovers a
|
|
5
|
+
* system browser, or launches the installed executable. It accepts either an
|
|
6
|
+
* explicitly pinned build or an already-resolved official release.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { accessSync, chmodSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync, } from "node:fs";
|
|
10
|
+
import { constants as fsConstants } from "node:fs";
|
|
11
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
12
|
+
import extractZipArchive from "@electron-internal/extract-zip";
|
|
13
|
+
import { rynxHome } from "@rynx-ai/core";
|
|
14
|
+
import Database from "better-sqlite3";
|
|
15
|
+
const BUILD_OWNER_FILE = ".rynx-cft-owner.json";
|
|
16
|
+
const STAGING_OWNER_FILE = ".rynx-cft-staging-owner.json";
|
|
17
|
+
const ACTIVE_MANIFEST_FILE = "active.json";
|
|
18
|
+
const STORE_LOCK_FILE = ".rynx-cft-store.sqlite";
|
|
19
|
+
const MAX_METADATA_BYTES = 8 * 1024;
|
|
20
|
+
const DEFAULT_MAX_ARCHIVE_BYTES = 1_024 * 1_024 * 1_024;
|
|
21
|
+
const DEFAULT_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
|
|
22
|
+
const STAGING_NAME_PATTERN = /^install-([A-Za-z0-9]{6})$/;
|
|
23
|
+
const HEADLESS_PROFILE_NAME_PATTERN = /^session-([a-f0-9]{64})$/;
|
|
24
|
+
const VERSION_PATTERN = /^\d{1,6}\.\d{1,6}\.\d{1,6}\.\d{1,6}$/;
|
|
25
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
26
|
+
const HEADLESS_PROFILE_OWNER_FILE = ".rynx-headless-browser-profile.json";
|
|
27
|
+
const HEADLESS_PROCESS_LEASE_FILE = ".rynx-headless-browser-process.json";
|
|
28
|
+
const MAX_HEADLESS_METADATA_BYTES = 4 * 1_024;
|
|
29
|
+
/** The support matrix is deliberately explicit; adding a host requires tests and a known layout. */
|
|
30
|
+
export function resolveChromeForTestingPlatform(platform = process.platform, arch = process.arch) {
|
|
31
|
+
if (platform === "darwin" && arch === "arm64") {
|
|
32
|
+
return {
|
|
33
|
+
hostPlatform: "darwin-arm64",
|
|
34
|
+
artifactPlatform: "mac-arm64",
|
|
35
|
+
executableRelativePath: join("chrome-mac-arm64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (platform === "darwin" && arch === "x64") {
|
|
39
|
+
return {
|
|
40
|
+
hostPlatform: "darwin-x64",
|
|
41
|
+
artifactPlatform: "mac-x64",
|
|
42
|
+
executableRelativePath: join("chrome-mac-x64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (platform === "linux" && arch === "x64") {
|
|
46
|
+
return {
|
|
47
|
+
hostPlatform: "linux-x64",
|
|
48
|
+
artifactPlatform: "linux64",
|
|
49
|
+
executableRelativePath: join("chrome-linux64", "chrome"),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`unsupported Chrome for Testing host platform: ${platform}-${arch}`);
|
|
53
|
+
}
|
|
54
|
+
export function isChromeForTestingHostSupported(platform = process.platform, arch = process.arch) {
|
|
55
|
+
try {
|
|
56
|
+
resolveChromeForTestingPlatform(platform, arch);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function createChromeForTestingArtifactStore(options = {}) {
|
|
64
|
+
const platform = resolveChromeForTestingPlatform(options.platform ?? process.platform, options.arch ?? process.arch);
|
|
65
|
+
const rootDir = resolve(options.rootDir ?? join(rynxHome(), "cache", "browser", "chrome-for-testing"));
|
|
66
|
+
const fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact;
|
|
67
|
+
const extractZip = options.extractZip ?? defaultExtractZip;
|
|
68
|
+
const maxArchiveBytes = options.maxArchiveBytes ?? DEFAULT_MAX_ARCHIVE_BYTES;
|
|
69
|
+
const now = options.now ?? (() => new Date());
|
|
70
|
+
const headlessProfileRoot = resolve(options.headlessProfileRoot ?? join(rynxHome(), "browser", "profiles", "headless-chromium"));
|
|
71
|
+
const isProcessLive = options.isProcessLive ?? defaultIsProcessLive;
|
|
72
|
+
let launchReservationTail = Promise.resolve();
|
|
73
|
+
if (!Number.isSafeInteger(maxArchiveBytes) || maxArchiveBytes <= 0) {
|
|
74
|
+
throw new Error("Chrome for Testing maxArchiveBytes must be a positive safe integer");
|
|
75
|
+
}
|
|
76
|
+
const readActiveBuild = () => {
|
|
77
|
+
const paths = ensureStorePaths(rootDir, platform.hostPlatform);
|
|
78
|
+
const activePath = join(paths.root, ACTIVE_MANIFEST_FILE);
|
|
79
|
+
if (!existsSync(activePath))
|
|
80
|
+
return undefined;
|
|
81
|
+
const active = parseActiveManifest(readSmallOwnedJson(activePath, "Chrome for Testing active manifest"), platform);
|
|
82
|
+
return requireInstalledBuild(buildPath(paths.buildsRoot, active.version, active.sha256), paths.buildsRoot, platform, {
|
|
83
|
+
version: active.version,
|
|
84
|
+
platform: active.artifactPlatform,
|
|
85
|
+
sha256: active.sha256,
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
const installDownloadedBuild = async (release, expectedSha256, installOptions) => {
|
|
89
|
+
const paths = ensureStorePaths(rootDir, platform.hostPlatform);
|
|
90
|
+
if (expectedSha256 !== undefined) {
|
|
91
|
+
const finalPath = buildPath(paths.buildsRoot, release.version, expectedSha256);
|
|
92
|
+
const existing = readInstalledBuild(finalPath, paths.buildsRoot, platform, {
|
|
93
|
+
...release,
|
|
94
|
+
sha256: expectedSha256,
|
|
95
|
+
});
|
|
96
|
+
if (existing) {
|
|
97
|
+
writeActiveManifest(paths.root, existing);
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// Reusing an active locally pinned build requires no second download. Its
|
|
103
|
+
// marker and content-addressed path are revalidated by readActiveBuild().
|
|
104
|
+
const active = readActiveBuild();
|
|
105
|
+
if (active?.version === release.version &&
|
|
106
|
+
active.artifactPlatform === release.platform &&
|
|
107
|
+
active.sourceUrl === release.url) {
|
|
108
|
+
return active;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throwIfAborted(installOptions.signal);
|
|
112
|
+
const transactionDir = mkdtempSync(join(paths.stagingRoot, "install-"));
|
|
113
|
+
const transactionName = basename(transactionDir);
|
|
114
|
+
const transactionId = parseStagingName(transactionName);
|
|
115
|
+
writeExclusiveJson(join(transactionDir, STAGING_OWNER_FILE), {
|
|
116
|
+
schemaVersion: 1,
|
|
117
|
+
kind: "rynx-chrome-for-testing-staging",
|
|
118
|
+
transactionId,
|
|
119
|
+
});
|
|
120
|
+
const archivePath = join(transactionDir, "artifact.zip");
|
|
121
|
+
const payloadPath = join(transactionDir, "payload");
|
|
122
|
+
try {
|
|
123
|
+
installOptions.onProgress?.(`downloading Chrome for Testing ${release.version} (${release.platform})`);
|
|
124
|
+
const sha256 = await downloadArchive({
|
|
125
|
+
url: release.url,
|
|
126
|
+
expectedSha256,
|
|
127
|
+
destination: archivePath,
|
|
128
|
+
fetchArtifact,
|
|
129
|
+
maxArchiveBytes,
|
|
130
|
+
signal: installOptions.signal,
|
|
131
|
+
});
|
|
132
|
+
throwIfAborted(installOptions.signal);
|
|
133
|
+
const build = { ...release, sha256 };
|
|
134
|
+
const finalPath = buildPath(paths.buildsRoot, build.version, build.sha256);
|
|
135
|
+
const existing = readInstalledBuild(finalPath, paths.buildsRoot, platform, build);
|
|
136
|
+
if (existing) {
|
|
137
|
+
writeActiveManifest(paths.root, existing);
|
|
138
|
+
return existing;
|
|
139
|
+
}
|
|
140
|
+
mkdirOwnedDirectory(payloadPath, paths.root);
|
|
141
|
+
await extractZip(archivePath, payloadPath);
|
|
142
|
+
throwIfAborted(installOptions.signal);
|
|
143
|
+
validateExecutable(payloadPath, platform.executableRelativePath);
|
|
144
|
+
const marker = {
|
|
145
|
+
schemaVersion: 1,
|
|
146
|
+
kind: "rynx-chrome-for-testing-build",
|
|
147
|
+
hostPlatform: platform.hostPlatform,
|
|
148
|
+
artifactPlatform: platform.artifactPlatform,
|
|
149
|
+
version: build.version,
|
|
150
|
+
sha256: build.sha256,
|
|
151
|
+
sourceUrl: build.url,
|
|
152
|
+
installedAt: now().toISOString(),
|
|
153
|
+
};
|
|
154
|
+
writeExclusiveJson(join(payloadPath, BUILD_OWNER_FILE), marker);
|
|
155
|
+
mkdirOwnedDirectory(dirname(finalPath), paths.root);
|
|
156
|
+
try {
|
|
157
|
+
renameSync(payloadPath, finalPath);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
// A concurrent installer may have promoted the same content-addressed
|
|
161
|
+
// build. Only accept it after the complete marker/executable check.
|
|
162
|
+
const concurrent = readInstalledBuild(finalPath, paths.buildsRoot, platform, build);
|
|
163
|
+
if (!concurrent)
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
const installed = requireInstalledBuild(finalPath, paths.buildsRoot, platform, build);
|
|
167
|
+
writeActiveManifest(paths.root, installed);
|
|
168
|
+
installOptions.onProgress?.(`installed Chrome for Testing ${build.version} (${build.sha256})`);
|
|
169
|
+
return installed;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
// Cleanup is marker-gated. If the directory or marker was replaced,
|
|
173
|
+
// leave it for inspection instead of recursively deleting an unknown tree.
|
|
174
|
+
removeOwnedStaging(paths.stagingRoot, transactionDir, transactionId);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
async install(input, installOptions = {}) {
|
|
179
|
+
const build = validateBuildMetadata(input, platform);
|
|
180
|
+
return await withStoreLock(rootDir, () => installDownloadedBuild(build, build.sha256, installOptions));
|
|
181
|
+
},
|
|
182
|
+
async installResolved(input, installOptions = {}) {
|
|
183
|
+
const release = validateResolvedBuildMetadata(input, platform);
|
|
184
|
+
return await withStoreLock(rootDir, () => installDownloadedBuild(release, undefined, installOptions));
|
|
185
|
+
},
|
|
186
|
+
readActive() {
|
|
187
|
+
return readActiveBuild();
|
|
188
|
+
},
|
|
189
|
+
async reserveActiveForLaunch() {
|
|
190
|
+
let advanceQueue;
|
|
191
|
+
const previousReservation = launchReservationTail;
|
|
192
|
+
launchReservationTail = new Promise((resolveReservation) => {
|
|
193
|
+
advanceQueue = resolveReservation;
|
|
194
|
+
});
|
|
195
|
+
await previousReservation;
|
|
196
|
+
let lock;
|
|
197
|
+
try {
|
|
198
|
+
lock = acquireStoreLock(rootDir);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
advanceQueue();
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
let build;
|
|
205
|
+
try {
|
|
206
|
+
build = readActiveBuild();
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
try {
|
|
210
|
+
releaseStoreLock(lock);
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
advanceQueue();
|
|
214
|
+
}
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
if (!build) {
|
|
218
|
+
try {
|
|
219
|
+
releaseStoreLock(lock);
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
advanceQueue();
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
let released = false;
|
|
227
|
+
return {
|
|
228
|
+
build,
|
|
229
|
+
release() {
|
|
230
|
+
if (released)
|
|
231
|
+
return;
|
|
232
|
+
released = true;
|
|
233
|
+
try {
|
|
234
|
+
releaseStoreLock(lock);
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
advanceQueue();
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
pruneBuilds() {
|
|
243
|
+
const lock = acquireStoreLock(rootDir);
|
|
244
|
+
try {
|
|
245
|
+
const paths = ensureStorePaths(rootDir, platform.hostPlatform);
|
|
246
|
+
const builds = listInstalledBuilds(paths.buildsRoot, platform);
|
|
247
|
+
const active = readActiveBuild();
|
|
248
|
+
if (!active) {
|
|
249
|
+
if (builds.length > 0) {
|
|
250
|
+
throw new Error("Chrome for Testing builds cannot be pruned without a valid active build");
|
|
251
|
+
}
|
|
252
|
+
return { removedBuilds: [], retainedBuilds: [] };
|
|
253
|
+
}
|
|
254
|
+
const activeKey = installedBuildKey(active);
|
|
255
|
+
if (!builds.some((build) => installedBuildKey(build) === activeKey)) {
|
|
256
|
+
throw new Error("Chrome for Testing active build is missing from its owned store");
|
|
257
|
+
}
|
|
258
|
+
// The most recently installed valid inactive build is the rollback
|
|
259
|
+
// build. Ties are deterministic so repeated cleans retain the same one.
|
|
260
|
+
const inactiveNewestFirst = builds
|
|
261
|
+
.filter((build) => installedBuildKey(build) !== activeKey)
|
|
262
|
+
.sort(compareInstalledNewestFirst);
|
|
263
|
+
const rollback = inactiveNewestFirst[0];
|
|
264
|
+
const retainedKeys = new Set([
|
|
265
|
+
activeKey,
|
|
266
|
+
...(rollback ? [installedBuildKey(rollback)] : []),
|
|
267
|
+
...liveHeadlessBuildKeys(headlessProfileRoot, paths.buildsRoot, builds, isProcessLive),
|
|
268
|
+
]);
|
|
269
|
+
const candidates = builds
|
|
270
|
+
.filter((build) => !retainedKeys.has(installedBuildKey(build)))
|
|
271
|
+
.sort(compareInstalledOldestFirst);
|
|
272
|
+
const removedBuilds = [];
|
|
273
|
+
for (const candidate of candidates) {
|
|
274
|
+
// Re-read both mutable roots immediately before deletion. If another
|
|
275
|
+
// process activates a build, or a Browser starts from an older build,
|
|
276
|
+
// this clean pass loses authority to remove it.
|
|
277
|
+
const currentActive = readActiveBuild();
|
|
278
|
+
if (!currentActive || installedBuildKey(currentActive) !== activeKey) {
|
|
279
|
+
throw new Error("Chrome for Testing active build changed while pruning");
|
|
280
|
+
}
|
|
281
|
+
const currentLive = liveHeadlessBuildKeys(headlessProfileRoot, paths.buildsRoot, builds, isProcessLive);
|
|
282
|
+
if (currentLive.has(installedBuildKey(candidate)))
|
|
283
|
+
continue;
|
|
284
|
+
removeOwnedBuild(paths.buildsRoot, platform, candidate);
|
|
285
|
+
removedBuilds.push(candidate);
|
|
286
|
+
}
|
|
287
|
+
const removedKeys = new Set(removedBuilds.map(installedBuildKey));
|
|
288
|
+
return {
|
|
289
|
+
removedBuilds,
|
|
290
|
+
retainedBuilds: builds.filter((build) => !removedKeys.has(installedBuildKey(build))),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
releaseStoreLock(lock);
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
pruneStaging(pruneOptions = {}) {
|
|
298
|
+
const lock = acquireStoreLock(rootDir);
|
|
299
|
+
try {
|
|
300
|
+
const paths = ensureStorePaths(rootDir, platform.hostPlatform);
|
|
301
|
+
const olderThanMs = pruneOptions.olderThanMs ?? DEFAULT_STAGING_MAX_AGE_MS;
|
|
302
|
+
if (!Number.isSafeInteger(olderThanMs) || olderThanMs < 0) {
|
|
303
|
+
throw new Error("Chrome for Testing staging age must be a non-negative safe integer");
|
|
304
|
+
}
|
|
305
|
+
const cutoff = now().getTime() - olderThanMs;
|
|
306
|
+
let removed = 0;
|
|
307
|
+
for (const entry of readdirSync(paths.stagingRoot)) {
|
|
308
|
+
let transactionId;
|
|
309
|
+
try {
|
|
310
|
+
transactionId = parseStagingName(entry);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const candidate = join(paths.stagingRoot, entry);
|
|
316
|
+
let stat;
|
|
317
|
+
try {
|
|
318
|
+
stat = lstatSync(candidate);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.mtimeMs > cutoff)
|
|
324
|
+
continue;
|
|
325
|
+
if (removeOwnedStaging(paths.stagingRoot, candidate, transactionId))
|
|
326
|
+
removed += 1;
|
|
327
|
+
}
|
|
328
|
+
return removed;
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
releaseStoreLock(lock);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
async function withStoreLock(rootDir, operation) {
|
|
337
|
+
const lock = acquireStoreLock(rootDir);
|
|
338
|
+
try {
|
|
339
|
+
return await operation();
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
releaseStoreLock(lock);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function acquireStoreLock(rootDir) {
|
|
346
|
+
mkdirOwnedDirectory(rootDir, rootDir);
|
|
347
|
+
const lockPath = join(rootDir, STORE_LOCK_FILE);
|
|
348
|
+
if (existsSync(lockPath)) {
|
|
349
|
+
const stat = lstatSync(lockPath);
|
|
350
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
351
|
+
throw new Error("Chrome for Testing store lock is not a direct regular file");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const connection = new Database(lockPath, { timeout: 0 });
|
|
355
|
+
try {
|
|
356
|
+
const stat = lstatSync(lockPath);
|
|
357
|
+
if (!stat.isFile() ||
|
|
358
|
+
stat.isSymbolicLink() ||
|
|
359
|
+
!isWithin(realpathSync(rootDir), realpathSync(lockPath))) {
|
|
360
|
+
throw new Error("Chrome for Testing store lock escapes its owned root");
|
|
361
|
+
}
|
|
362
|
+
chmodSync(lockPath, 0o600);
|
|
363
|
+
connection.pragma("busy_timeout = 0");
|
|
364
|
+
connection.exec("BEGIN EXCLUSIVE");
|
|
365
|
+
connection.exec("CREATE TABLE IF NOT EXISTS cft_store_guard (id INTEGER PRIMARY KEY)");
|
|
366
|
+
return connection;
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
connection.close();
|
|
370
|
+
if (hasErrorCode(error, "SQLITE_BUSY") || hasErrorCode(error, "SQLITE_LOCKED")) {
|
|
371
|
+
throw new Error("Chrome for Testing store is busy in another process", { cause: error });
|
|
372
|
+
}
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function releaseStoreLock(connection) {
|
|
377
|
+
try {
|
|
378
|
+
connection.exec("ROLLBACK");
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
connection.close();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function ensureStorePaths(root, hostPlatform) {
|
|
385
|
+
mkdirOwnedDirectory(root, root);
|
|
386
|
+
const stagingRoot = join(root, "staging");
|
|
387
|
+
const buildsRoot = join(root, "builds", hostPlatform);
|
|
388
|
+
mkdirOwnedDirectory(stagingRoot, root);
|
|
389
|
+
mkdirOwnedDirectory(buildsRoot, root);
|
|
390
|
+
return { root, stagingRoot, buildsRoot };
|
|
391
|
+
}
|
|
392
|
+
function mkdirOwnedDirectory(path, containmentRoot) {
|
|
393
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
394
|
+
const stat = lstatSync(path);
|
|
395
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
396
|
+
throw new Error(`Chrome for Testing storage path is not a direct directory: ${path}`);
|
|
397
|
+
}
|
|
398
|
+
chmodSync(path, 0o700);
|
|
399
|
+
const rootReal = realpathSync(containmentRoot);
|
|
400
|
+
const pathReal = realpathSync(path);
|
|
401
|
+
if (!isWithin(rootReal, pathReal)) {
|
|
402
|
+
throw new Error(`Chrome for Testing storage path escapes its root: ${path}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function validateBuildMetadata(input, platform) {
|
|
406
|
+
const download = validateDownloadMetadata(input, platform);
|
|
407
|
+
if (!SHA256_PATTERN.test(input.sha256)) {
|
|
408
|
+
throw new Error("Chrome for Testing SHA-256 must be 64 hexadecimal lowercase characters");
|
|
409
|
+
}
|
|
410
|
+
return { ...download, sha256: input.sha256 };
|
|
411
|
+
}
|
|
412
|
+
function validateResolvedBuildMetadata(input, platform) {
|
|
413
|
+
const release = validateDownloadMetadata(input, platform);
|
|
414
|
+
const expectedUrl = `https://storage.googleapis.com/chrome-for-testing-public/${release.version}/${release.platform}/chrome-${release.platform}.zip`;
|
|
415
|
+
if (release.url !== expectedUrl) {
|
|
416
|
+
throw new Error("resolved Chrome for Testing artifact URL must be an allowed official URL");
|
|
417
|
+
}
|
|
418
|
+
return release;
|
|
419
|
+
}
|
|
420
|
+
function validateDownloadMetadata(input, platform) {
|
|
421
|
+
if (!VERSION_PATTERN.test(input.version)) {
|
|
422
|
+
throw new Error("Chrome for Testing version must be an explicit four-part build version");
|
|
423
|
+
}
|
|
424
|
+
if (input.platform !== platform.artifactPlatform) {
|
|
425
|
+
throw new Error(`Chrome for Testing artifact platform ${input.platform} does not match host ${platform.hostPlatform}`);
|
|
426
|
+
}
|
|
427
|
+
let url;
|
|
428
|
+
try {
|
|
429
|
+
url = new URL(input.url);
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
throw new Error("Chrome for Testing artifact URL must be a valid HTTPS URL");
|
|
433
|
+
}
|
|
434
|
+
if (url.protocol !== "https:" || url.username || url.password) {
|
|
435
|
+
throw new Error("Chrome for Testing artifact URL must be a credential-free HTTPS URL");
|
|
436
|
+
}
|
|
437
|
+
return { version: input.version, platform: input.platform, url: url.href };
|
|
438
|
+
}
|
|
439
|
+
async function downloadArchive(options) {
|
|
440
|
+
const response = await options.fetchArtifact(options.url, { signal: options.signal });
|
|
441
|
+
if (!response.ok) {
|
|
442
|
+
throw new Error(`Chrome for Testing download failed: HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`);
|
|
443
|
+
}
|
|
444
|
+
if (!response.body)
|
|
445
|
+
throw new Error("Chrome for Testing download returned no response body");
|
|
446
|
+
const length = parseContentLength(response.headers.get("content-length"));
|
|
447
|
+
if (length !== undefined && length > options.maxArchiveBytes) {
|
|
448
|
+
throw new Error(`Chrome for Testing archive exceeds ${options.maxArchiveBytes} bytes`);
|
|
449
|
+
}
|
|
450
|
+
const hash = createHash("sha256");
|
|
451
|
+
const descriptor = openSync(options.destination, "wx", 0o600);
|
|
452
|
+
let bytes = 0;
|
|
453
|
+
const reader = response.body.getReader();
|
|
454
|
+
try {
|
|
455
|
+
while (true) {
|
|
456
|
+
throwIfAborted(options.signal);
|
|
457
|
+
const next = await reader.read();
|
|
458
|
+
if (next.done)
|
|
459
|
+
break;
|
|
460
|
+
const chunk = Buffer.from(next.value);
|
|
461
|
+
bytes += chunk.byteLength;
|
|
462
|
+
if (bytes > options.maxArchiveBytes) {
|
|
463
|
+
throw new Error(`Chrome for Testing archive exceeds ${options.maxArchiveBytes} bytes`);
|
|
464
|
+
}
|
|
465
|
+
hash.update(chunk);
|
|
466
|
+
writeAll(descriptor, chunk);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
closeSync(descriptor);
|
|
471
|
+
reader.releaseLock();
|
|
472
|
+
}
|
|
473
|
+
const actual = hash.digest("hex");
|
|
474
|
+
if (options.expectedSha256 !== undefined && actual !== options.expectedSha256) {
|
|
475
|
+
throw new Error(`Chrome for Testing SHA-256 mismatch: expected ${options.expectedSha256}, received ${actual}`);
|
|
476
|
+
}
|
|
477
|
+
return actual;
|
|
478
|
+
}
|
|
479
|
+
function writeAll(descriptor, value) {
|
|
480
|
+
let offset = 0;
|
|
481
|
+
while (offset < value.byteLength) {
|
|
482
|
+
const written = writeSync(descriptor, value, offset, value.byteLength - offset);
|
|
483
|
+
if (written <= 0)
|
|
484
|
+
throw new Error("Chrome for Testing archive write made no progress");
|
|
485
|
+
offset += written;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function validateExecutable(installPath, executableRelativePath) {
|
|
489
|
+
const executablePath = join(installPath, executableRelativePath);
|
|
490
|
+
let stat;
|
|
491
|
+
try {
|
|
492
|
+
stat = lstatSync(executablePath);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
throw new Error(`Chrome for Testing archive is missing its executable: ${executableRelativePath}`);
|
|
496
|
+
}
|
|
497
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
498
|
+
throw new Error("Chrome for Testing executable must be a regular file");
|
|
499
|
+
}
|
|
500
|
+
const rootReal = realpathSync(installPath);
|
|
501
|
+
const executableReal = realpathSync(executablePath);
|
|
502
|
+
if (!isWithin(rootReal, executableReal)) {
|
|
503
|
+
throw new Error("Chrome for Testing executable resolves outside its owned build");
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
accessSync(executablePath, fsConstants.X_OK);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
throw new Error("Chrome for Testing executable is not executable");
|
|
510
|
+
}
|
|
511
|
+
return executablePath;
|
|
512
|
+
}
|
|
513
|
+
function readInstalledBuild(installPath, buildsRoot, platform, expected) {
|
|
514
|
+
if (!existsSync(installPath))
|
|
515
|
+
return undefined;
|
|
516
|
+
const stat = lstatSync(installPath);
|
|
517
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
518
|
+
throw new Error(`Chrome for Testing build path is not an owned directory: ${installPath}`);
|
|
519
|
+
}
|
|
520
|
+
if (!isWithin(realpathSync(buildsRoot), realpathSync(installPath))) {
|
|
521
|
+
throw new Error("Chrome for Testing build path resolves outside its owned store");
|
|
522
|
+
}
|
|
523
|
+
const marker = parseBuildOwnerMarker(readSmallOwnedJson(join(installPath, BUILD_OWNER_FILE), "Chrome for Testing build ownership marker"), platform);
|
|
524
|
+
if (marker.version !== expected.version ||
|
|
525
|
+
marker.artifactPlatform !== expected.platform ||
|
|
526
|
+
marker.sha256 !== expected.sha256 ||
|
|
527
|
+
(expected.url !== undefined && marker.sourceUrl !== expected.url)) {
|
|
528
|
+
throw new Error("Chrome for Testing build ownership marker does not match pinned metadata");
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
version: marker.version,
|
|
532
|
+
hostPlatform: marker.hostPlatform,
|
|
533
|
+
artifactPlatform: marker.artifactPlatform,
|
|
534
|
+
sha256: marker.sha256,
|
|
535
|
+
sourceUrl: marker.sourceUrl,
|
|
536
|
+
installedAt: marker.installedAt,
|
|
537
|
+
installPath,
|
|
538
|
+
executablePath: validateExecutable(installPath, platform.executableRelativePath),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function requireInstalledBuild(installPath, buildsRoot, platform, expected) {
|
|
542
|
+
const installed = readInstalledBuild(installPath, buildsRoot, platform, expected);
|
|
543
|
+
if (!installed)
|
|
544
|
+
throw new Error("Chrome for Testing active build is not installed");
|
|
545
|
+
return installed;
|
|
546
|
+
}
|
|
547
|
+
function listInstalledBuilds(buildsRoot, platform) {
|
|
548
|
+
const builds = [];
|
|
549
|
+
for (const versionEntry of readdirSync(buildsRoot, { withFileTypes: true })) {
|
|
550
|
+
if (!VERSION_PATTERN.test(versionEntry.name))
|
|
551
|
+
continue;
|
|
552
|
+
const versionPath = join(buildsRoot, versionEntry.name);
|
|
553
|
+
requireDirectChildDirectory(buildsRoot, versionPath, "version");
|
|
554
|
+
for (const digestEntry of readdirSync(versionPath, { withFileTypes: true })) {
|
|
555
|
+
if (!SHA256_PATTERN.test(digestEntry.name))
|
|
556
|
+
continue;
|
|
557
|
+
const installPath = join(versionPath, digestEntry.name);
|
|
558
|
+
requireDirectChildDirectory(versionPath, installPath, "build");
|
|
559
|
+
builds.push(requireInstalledBuild(installPath, buildsRoot, platform, {
|
|
560
|
+
version: versionEntry.name,
|
|
561
|
+
platform: platform.artifactPlatform,
|
|
562
|
+
sha256: digestEntry.name,
|
|
563
|
+
}));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return builds;
|
|
567
|
+
}
|
|
568
|
+
function liveHeadlessBuildKeys(profileRoot, buildsRoot, builds, isProcessLive) {
|
|
569
|
+
const live = new Set();
|
|
570
|
+
if (!existsSync(profileRoot))
|
|
571
|
+
return live;
|
|
572
|
+
const rootStat = lstatSync(profileRoot);
|
|
573
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
574
|
+
throw new Error("Headless Browser profile root is not a direct directory");
|
|
575
|
+
}
|
|
576
|
+
const rootReal = realpathSync(profileRoot);
|
|
577
|
+
const buildsByExecutable = new Map(builds.map((build) => [resolve(build.executablePath), build]));
|
|
578
|
+
for (const entry of readdirSync(profileRoot, { withFileTypes: true })) {
|
|
579
|
+
const match = HEADLESS_PROFILE_NAME_PATTERN.exec(entry.name);
|
|
580
|
+
if (!match)
|
|
581
|
+
continue;
|
|
582
|
+
const profileDir = join(profileRoot, entry.name);
|
|
583
|
+
requireDirectChildDirectory(profileRoot, profileDir, "Headless Browser profile");
|
|
584
|
+
if (!isWithin(rootReal, realpathSync(profileDir))) {
|
|
585
|
+
throw new Error("Headless Browser profile escapes its root");
|
|
586
|
+
}
|
|
587
|
+
const marker = readSmallOwnedJson(join(profileDir, HEADLESS_PROFILE_OWNER_FILE), "Headless Browser profile ownership marker", MAX_HEADLESS_METADATA_BYTES);
|
|
588
|
+
if (!isRecord(marker) ||
|
|
589
|
+
marker.schemaVersion !== 1 ||
|
|
590
|
+
marker.kind !== "rynx-headless-browser-profile" ||
|
|
591
|
+
marker.executionBackend !== "headless-chromium" ||
|
|
592
|
+
typeof marker.sessionId !== "string" ||
|
|
593
|
+
marker.sessionId.length === 0 ||
|
|
594
|
+
createHash("sha256").update(marker.sessionId, "utf8").digest("hex") !== match[1]) {
|
|
595
|
+
throw new Error("invalid Headless Browser profile ownership marker");
|
|
596
|
+
}
|
|
597
|
+
const leasePath = join(profileDir, HEADLESS_PROCESS_LEASE_FILE);
|
|
598
|
+
if (!existsSync(leasePath))
|
|
599
|
+
continue;
|
|
600
|
+
const lease = parseHeadlessProcessLease(readSmallOwnedJson(leasePath, "Headless Browser process lease", MAX_HEADLESS_METADATA_BYTES));
|
|
601
|
+
if (lease.profileDir !== resolve(profileDir)) {
|
|
602
|
+
throw new Error("Headless Browser process lease targets another profile");
|
|
603
|
+
}
|
|
604
|
+
// Process existence is intentionally conservative here. PID reuse may
|
|
605
|
+
// retain an extra build, but it can never authorize deleting one still in
|
|
606
|
+
// use by the process recorded in a valid owned lease.
|
|
607
|
+
if (!isProcessLive(lease.pid))
|
|
608
|
+
continue;
|
|
609
|
+
const build = buildsByExecutable.get(resolve(lease.executablePath));
|
|
610
|
+
if (!build || !isWithin(realpathSync(buildsRoot), realpathSync(build.installPath))) {
|
|
611
|
+
throw new Error("live Headless Browser process lease targets an unowned build");
|
|
612
|
+
}
|
|
613
|
+
live.add(installedBuildKey(build));
|
|
614
|
+
}
|
|
615
|
+
return live;
|
|
616
|
+
}
|
|
617
|
+
function parseHeadlessProcessLease(value) {
|
|
618
|
+
if (!isRecord(value) ||
|
|
619
|
+
value.schemaVersion !== 1 ||
|
|
620
|
+
value.kind !== "rynx-headless-browser-process" ||
|
|
621
|
+
!Number.isSafeInteger(value.pid) ||
|
|
622
|
+
value.pid < 2 ||
|
|
623
|
+
value.processGroupId !== value.pid ||
|
|
624
|
+
typeof value.ownerToken !== "string" ||
|
|
625
|
+
!SHA256_PATTERN.test(value.ownerToken) ||
|
|
626
|
+
typeof value.executablePath !== "string" ||
|
|
627
|
+
!isAbsolute(value.executablePath) ||
|
|
628
|
+
typeof value.profileDir !== "string" ||
|
|
629
|
+
!isAbsolute(value.profileDir) ||
|
|
630
|
+
typeof value.startedAt !== "string" ||
|
|
631
|
+
!Number.isFinite(Date.parse(value.startedAt))) {
|
|
632
|
+
throw new Error("invalid Headless Browser process lease");
|
|
633
|
+
}
|
|
634
|
+
return value;
|
|
635
|
+
}
|
|
636
|
+
function removeOwnedBuild(buildsRoot, platform, build) {
|
|
637
|
+
const expectedPath = buildPath(buildsRoot, build.version, build.sha256);
|
|
638
|
+
if (resolve(build.installPath) !== resolve(expectedPath)) {
|
|
639
|
+
throw new Error("Chrome for Testing build path does not match its owned identity");
|
|
640
|
+
}
|
|
641
|
+
requireDirectChildDirectory(buildsRoot, dirname(expectedPath), "version");
|
|
642
|
+
requireDirectChildDirectory(dirname(expectedPath), expectedPath, "build");
|
|
643
|
+
requireInstalledBuild(expectedPath, buildsRoot, platform, {
|
|
644
|
+
version: build.version,
|
|
645
|
+
platform: build.artifactPlatform,
|
|
646
|
+
sha256: build.sha256,
|
|
647
|
+
url: build.sourceUrl,
|
|
648
|
+
});
|
|
649
|
+
rmSync(expectedPath, { recursive: true });
|
|
650
|
+
}
|
|
651
|
+
function requireDirectChildDirectory(parent, candidate, label) {
|
|
652
|
+
const stat = lstatSync(candidate);
|
|
653
|
+
if (dirname(resolve(candidate)) !== resolve(parent) ||
|
|
654
|
+
!stat.isDirectory() ||
|
|
655
|
+
stat.isSymbolicLink() ||
|
|
656
|
+
!isWithin(realpathSync(parent), realpathSync(candidate))) {
|
|
657
|
+
throw new Error(`Chrome for Testing ${label} path is not a direct owned directory`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function installedBuildKey(build) {
|
|
661
|
+
return `${build.version}/${build.sha256}`;
|
|
662
|
+
}
|
|
663
|
+
function compareInstalledNewestFirst(left, right) {
|
|
664
|
+
const byTime = Date.parse(right.installedAt) - Date.parse(left.installedAt);
|
|
665
|
+
if (byTime !== 0)
|
|
666
|
+
return byTime;
|
|
667
|
+
return compareText(installedBuildKey(right), installedBuildKey(left));
|
|
668
|
+
}
|
|
669
|
+
function compareInstalledOldestFirst(left, right) {
|
|
670
|
+
return compareInstalledNewestFirst(right, left);
|
|
671
|
+
}
|
|
672
|
+
function compareText(left, right) {
|
|
673
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
674
|
+
}
|
|
675
|
+
function parseBuildOwnerMarker(value, platform) {
|
|
676
|
+
if (!isRecord(value))
|
|
677
|
+
throw new Error("invalid Chrome for Testing build ownership marker");
|
|
678
|
+
if (value.schemaVersion !== 1 ||
|
|
679
|
+
value.kind !== "rynx-chrome-for-testing-build" ||
|
|
680
|
+
value.hostPlatform !== platform.hostPlatform ||
|
|
681
|
+
value.artifactPlatform !== platform.artifactPlatform ||
|
|
682
|
+
typeof value.version !== "string" ||
|
|
683
|
+
!VERSION_PATTERN.test(value.version) ||
|
|
684
|
+
typeof value.sha256 !== "string" ||
|
|
685
|
+
!SHA256_PATTERN.test(value.sha256) ||
|
|
686
|
+
typeof value.sourceUrl !== "string" ||
|
|
687
|
+
typeof value.installedAt !== "string" ||
|
|
688
|
+
!Number.isFinite(Date.parse(value.installedAt))) {
|
|
689
|
+
throw new Error("invalid Chrome for Testing build ownership marker");
|
|
690
|
+
}
|
|
691
|
+
const sourceUrl = validateBuildMetadata({
|
|
692
|
+
version: value.version,
|
|
693
|
+
platform: platform.artifactPlatform,
|
|
694
|
+
url: value.sourceUrl,
|
|
695
|
+
sha256: value.sha256,
|
|
696
|
+
}, platform).url;
|
|
697
|
+
return {
|
|
698
|
+
schemaVersion: 1,
|
|
699
|
+
kind: "rynx-chrome-for-testing-build",
|
|
700
|
+
hostPlatform: platform.hostPlatform,
|
|
701
|
+
artifactPlatform: platform.artifactPlatform,
|
|
702
|
+
version: value.version,
|
|
703
|
+
sha256: value.sha256,
|
|
704
|
+
sourceUrl,
|
|
705
|
+
installedAt: value.installedAt,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function writeActiveManifest(root, installed) {
|
|
709
|
+
const manifest = {
|
|
710
|
+
schemaVersion: 1,
|
|
711
|
+
kind: "rynx-chrome-for-testing-active",
|
|
712
|
+
hostPlatform: installed.hostPlatform,
|
|
713
|
+
artifactPlatform: installed.artifactPlatform,
|
|
714
|
+
version: installed.version,
|
|
715
|
+
sha256: installed.sha256,
|
|
716
|
+
};
|
|
717
|
+
const tempPath = join(root, `.active-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
|
|
718
|
+
try {
|
|
719
|
+
writeExclusiveJson(tempPath, manifest);
|
|
720
|
+
renameSync(tempPath, join(root, ACTIVE_MANIFEST_FILE));
|
|
721
|
+
}
|
|
722
|
+
finally {
|
|
723
|
+
rmSync(tempPath, { force: true });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function parseActiveManifest(value, platform) {
|
|
727
|
+
if (!isRecord(value) ||
|
|
728
|
+
value.schemaVersion !== 1 ||
|
|
729
|
+
value.kind !== "rynx-chrome-for-testing-active" ||
|
|
730
|
+
value.hostPlatform !== platform.hostPlatform ||
|
|
731
|
+
value.artifactPlatform !== platform.artifactPlatform ||
|
|
732
|
+
typeof value.version !== "string" ||
|
|
733
|
+
!VERSION_PATTERN.test(value.version) ||
|
|
734
|
+
typeof value.sha256 !== "string" ||
|
|
735
|
+
!SHA256_PATTERN.test(value.sha256)) {
|
|
736
|
+
throw new Error("invalid Chrome for Testing active manifest");
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
schemaVersion: 1,
|
|
740
|
+
kind: "rynx-chrome-for-testing-active",
|
|
741
|
+
hostPlatform: platform.hostPlatform,
|
|
742
|
+
artifactPlatform: platform.artifactPlatform,
|
|
743
|
+
version: value.version,
|
|
744
|
+
sha256: value.sha256,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
function readSmallOwnedJson(path, label, maxBytes = MAX_METADATA_BYTES) {
|
|
748
|
+
let stat;
|
|
749
|
+
try {
|
|
750
|
+
stat = lstatSync(path);
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
throw new Error(`${label} is missing`);
|
|
754
|
+
}
|
|
755
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes) {
|
|
756
|
+
throw new Error(`${label} is not a bounded regular file`);
|
|
757
|
+
}
|
|
758
|
+
try {
|
|
759
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
throw new Error(`${label} is invalid JSON`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
function writeExclusiveJson(path, value) {
|
|
766
|
+
writeFileSync(path, `${JSON.stringify(value)}\n`, { flag: "wx", mode: 0o600 });
|
|
767
|
+
chmodSync(path, 0o600);
|
|
768
|
+
}
|
|
769
|
+
function removeOwnedStaging(stagingRoot, candidate, expectedTransactionId) {
|
|
770
|
+
try {
|
|
771
|
+
const stat = lstatSync(candidate);
|
|
772
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
773
|
+
return false;
|
|
774
|
+
const rootReal = realpathSync(stagingRoot);
|
|
775
|
+
const candidateReal = realpathSync(candidate);
|
|
776
|
+
if (dirname(resolve(candidate)) !== resolve(stagingRoot) || !isWithin(rootReal, candidateReal)) {
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
const marker = readSmallOwnedJson(join(candidate, STAGING_OWNER_FILE), "Chrome for Testing staging ownership marker");
|
|
780
|
+
if (!isRecord(marker) ||
|
|
781
|
+
marker.schemaVersion !== 1 ||
|
|
782
|
+
marker.kind !== "rynx-chrome-for-testing-staging" ||
|
|
783
|
+
marker.transactionId !== expectedTransactionId) {
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
rmSync(candidate, { recursive: true });
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function parseStagingName(name) {
|
|
794
|
+
const match = STAGING_NAME_PATTERN.exec(name);
|
|
795
|
+
if (!match?.[1])
|
|
796
|
+
throw new Error("invalid Chrome for Testing staging transaction name");
|
|
797
|
+
return match[1];
|
|
798
|
+
}
|
|
799
|
+
function buildPath(buildsRoot, version, sha256) {
|
|
800
|
+
return join(buildsRoot, version, sha256);
|
|
801
|
+
}
|
|
802
|
+
function parseContentLength(raw) {
|
|
803
|
+
if (raw === null || !/^\d+$/.test(raw))
|
|
804
|
+
return undefined;
|
|
805
|
+
const value = Number(raw);
|
|
806
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
807
|
+
}
|
|
808
|
+
function throwIfAborted(signal) {
|
|
809
|
+
if (signal?.aborted)
|
|
810
|
+
throw new Error("Chrome for Testing installation aborted");
|
|
811
|
+
}
|
|
812
|
+
async function defaultFetchArtifact(url, init) {
|
|
813
|
+
return fetch(url, { signal: init.signal, redirect: "follow" });
|
|
814
|
+
}
|
|
815
|
+
async function defaultExtractZip(archivePath, destination) {
|
|
816
|
+
await extractZipArchive(archivePath, { dir: destination });
|
|
817
|
+
}
|
|
818
|
+
function defaultIsProcessLive(pid) {
|
|
819
|
+
try {
|
|
820
|
+
process.kill(pid, 0);
|
|
821
|
+
return true;
|
|
822
|
+
}
|
|
823
|
+
catch (error) {
|
|
824
|
+
return !(error && typeof error === "object" && "code" in error && error.code === "ESRCH");
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
function hasErrorCode(error, code) {
|
|
828
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
829
|
+
}
|
|
830
|
+
function isWithin(root, candidate) {
|
|
831
|
+
const rel = relative(root, candidate);
|
|
832
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
833
|
+
}
|
|
834
|
+
function isRecord(value) {
|
|
835
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
836
|
+
}
|