clearcote 0.39.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-commands.d.ts +66 -1
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +265 -17
- package/dist/cli-commands.js.map +1 -1
- package/dist/devicelogin.d.ts +77 -0
- package/dist/devicelogin.d.ts.map +1 -0
- package/dist/devicelogin.js +215 -0
- package/dist/devicelogin.js.map +1 -0
- package/dist/docker.d.ts +153 -0
- package/dist/docker.d.ts.map +1 -0
- package/dist/docker.js +814 -0
- package/dist/docker.js.map +1 -0
- package/dist/download.d.ts +3 -2
- package/dist/download.d.ts.map +1 -1
- package/dist/download.js +3 -3
- package/dist/download.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/license.d.ts +24 -1
- package/dist/license.d.ts.map +1 -1
- package/dist/license.js +71 -1
- package/dist/license.js.map +1 -1
- package/dist/net.d.ts +5 -0
- package/dist/net.d.ts.map +1 -1
- package/dist/net.js +9 -4
- package/dist/net.js.map +1 -1
- package/package.json +1 -1
package/dist/docker.js
ADDED
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
// launch() on macOS: run the Clearcote Docker image and connect to it.
|
|
2
|
+
//
|
|
3
|
+
// There is no native Clearcote build for macOS. Rather than fail there, launch() starts the published
|
|
4
|
+
// Clearcote image (teamflatearth/clearcote:sdk-<this SDK's version>, a linux/amd64 image Docker Desktop
|
|
5
|
+
// runs on Intel and Apple silicon alike), waits for its CDP endpoint, connects with Playwright and
|
|
6
|
+
// resolves to the same Playwright Browser a local launch does. close() disconnects and stops the
|
|
7
|
+
// container; nothing is left running.
|
|
8
|
+
//
|
|
9
|
+
// When it applies:
|
|
10
|
+
// - `docker: false` (or CLEARCOTE_DOCKER=0) turns it off: launch() behaves as before, which on macOS
|
|
11
|
+
// means "pass executablePath to a compatible binary".
|
|
12
|
+
// - `docker: true` (or CLEARCOTE_DOCKER=1) turns it on on any OS that has Docker.
|
|
13
|
+
// - Unset: on for macOS, unless the caller named a binary (executablePath / CLEARCOTE_BINARY).
|
|
14
|
+
// - CLEARCOTE_TEST_ONLY_ASSUME_MACOS=1 makes this module treat the host as macOS, so the real Docker
|
|
15
|
+
// path can be exercised end to end on a Linux or Windows machine. It is not an option.
|
|
16
|
+
//
|
|
17
|
+
// The container is configured through the image's own CC_* variables (docker/serve.py), so only the
|
|
18
|
+
// options the image understands are accepted; anything else throws naming it. Values travel in the
|
|
19
|
+
// docker CLI's environment (`-e NAME` without a value), never on its command line. The licence key and
|
|
20
|
+
// the proxy URL are not passed as variables at all: they are copied in as a file serve.py reads once and
|
|
21
|
+
// deletes, so `docker inspect` does not show them (anyone who can reach the Docker daemon can still read a
|
|
22
|
+
// running container's files and memory). The CDP port is published on 127.0.0.1 only.
|
|
23
|
+
//
|
|
24
|
+
// What an image understands is read from its serve-protocol label before a container is created. An image
|
|
25
|
+
// older than sdk-0.40.0 has none: it gets the key and proxy as plain variables, with a warning. Whatever the
|
|
26
|
+
// image, launch() checks the container's log once the browser answers and refuses -- stopping and removing the
|
|
27
|
+
// container -- unless it runs the licensed engine when a key was given and the proxy when one was given.
|
|
28
|
+
//
|
|
29
|
+
// Nothing outlives its owner: the container is created with --rm, stops itself once no CDP client has been
|
|
30
|
+
// connected for 30 s after its CDP first answers (CLEARCOTE_DOCKER_IDLE_EXIT), and the next launch removes
|
|
31
|
+
// any whose owner process is certainly gone (sweepStale). close() stops it at once.
|
|
32
|
+
// Mirrors _docker.py in the Python SDK.
|
|
33
|
+
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
34
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
35
|
+
import http from "node:http";
|
|
36
|
+
import { randomBytes } from "node:crypto";
|
|
37
|
+
import { homedir, hostname } from "node:os";
|
|
38
|
+
import { delimiter, join } from "node:path";
|
|
39
|
+
import { chromium } from "playwright-core";
|
|
40
|
+
import { installHumanize, installHumanizeOnContext } from "./humanize.js";
|
|
41
|
+
import { resolveLicenseKey } from "./license.js";
|
|
42
|
+
export const DEFAULT_REPOSITORY = "teamflatearth/clearcote";
|
|
43
|
+
export const TEST_ONLY_ASSUME_MACOS = "CLEARCOTE_TEST_ONLY_ASSUME_MACOS";
|
|
44
|
+
const LABEL = "com.clearcotelabs.sdk-launch=1";
|
|
45
|
+
// Who started a container: sweepStale() removes the ones whose owner process is gone.
|
|
46
|
+
const OWNER_HOST_LABEL = "com.clearcotelabs.owner-host";
|
|
47
|
+
const OWNER_TOKEN_LABEL = "com.clearcotelabs.owner-token";
|
|
48
|
+
// The image's serve protocol (docker/serve.py's SERVE_PROTOCOL, carried as this image label). 2: takes
|
|
49
|
+
// CC_SECRETS_FILE and CC_IDLE_EXIT_SECONDS and logs a serve-state line. An image without the label is older.
|
|
50
|
+
const PROTOCOL_LABEL = "com.clearcotelabs.serve-protocol";
|
|
51
|
+
export const SERVE_PROTOCOL = 2;
|
|
52
|
+
const FIRST_PROTOCOL_TAG = "sdk-0.40.0"; // the first published image that speaks it
|
|
53
|
+
// A container stops itself once no CDP client has been connected for this long (serve.py's
|
|
54
|
+
// CC_IDLE_EXIT_SECONDS). CLEARCOTE_DOCKER_IDLE_EXIT overrides it; 0 turns it off.
|
|
55
|
+
const DEFAULT_IDLE_EXIT_S = 30;
|
|
56
|
+
const DEFAULT_CACHE_VOLUME = "clearcote-cache";
|
|
57
|
+
// The licence key and the proxy URL (it may carry a password) reach the container as this file, copied in
|
|
58
|
+
// with `docker cp` and deleted by serve.py once read, not as -e variables `docker inspect` would show.
|
|
59
|
+
const SECRET_ENV = ["CLEARCOTE_LICENSE_KEY", "CC_PROXY"];
|
|
60
|
+
const SECRETS_FILE = "/tmp/clearcote-secrets.json";
|
|
61
|
+
const IMAGE_UID = 10001; // the image's user (cc)
|
|
62
|
+
const TRUTHY = ["1", "true", "yes", "on"];
|
|
63
|
+
const FALSY = ["0", "false", "no", "off"];
|
|
64
|
+
// The image starts a virtual display, Chromium and (with a licence) fetches the licensed engine on its
|
|
65
|
+
// first run: well past Playwright's 30 s connect default, so the wait for CDP gets its own budget.
|
|
66
|
+
const READY_TIMEOUT_MS = 180_000;
|
|
67
|
+
const CONNECT_TIMEOUT_MS = 120_000;
|
|
68
|
+
const INSTALL_URL = "https://docs.docker.com/desktop/setup/install/mac-install/";
|
|
69
|
+
const OFF_HINT = "To launch without Docker, pass docker: false (or set CLEARCOTE_DOCKER=0) and executablePath to a compatible Clearcote binary.";
|
|
70
|
+
const SDK_VERSION = (() => {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return "unknown";
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
/** launch() needs Docker on this host (macOS has no native Clearcote build) and it is missing or not running. */
|
|
79
|
+
export class DockerUnavailableError extends Error {
|
|
80
|
+
constructor(message) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "DockerUnavailableError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// launch() option -> the image's CC_* variable.
|
|
86
|
+
const STR_ENV = {
|
|
87
|
+
fingerprint: "CC_FINGERPRINT", platform: "CC_PLATFORM", platformVersion: "CC_PLATFORM_VERSION",
|
|
88
|
+
brand: "CC_BRAND", brandVersion: "CC_BRAND_VERSION", gpuVendor: "CC_GPU_VENDOR", gpuRenderer: "CC_GPU_RENDERER",
|
|
89
|
+
timezone: "CC_TIMEZONE", acceptLanguage: "CC_ACCEPT_LANGUAGE", webrtcIp: "CC_WEBRTC_IP", webrtcMdns: "CC_WEBRTC_MDNS",
|
|
90
|
+
tlsProfile: "CC_TLS_PROFILE", version: "CC_VERSION",
|
|
91
|
+
};
|
|
92
|
+
const INT_ENV = {
|
|
93
|
+
hardwareConcurrency: "CC_HARDWARE_CONCURRENCY", deviceMemory: "CC_DEVICE_MEMORY", colorDepth: "CC_COLOR_DEPTH",
|
|
94
|
+
maxTouchPoints: "CC_MAX_TOUCH_POINTS", storageQuota: "CC_STORAGE_QUOTA",
|
|
95
|
+
};
|
|
96
|
+
const FLOAT_ENV = { devicePixelRatio: "CC_DEVICE_PIXEL_RATIO" };
|
|
97
|
+
const BOOL_ENV = {
|
|
98
|
+
lightStealth: "CC_LIGHT_STEALTH", disableGpuFingerprint: "CC_DISABLE_GPU_FINGERPRINT",
|
|
99
|
+
fingerprintNoise: "CC_FINGERPRINT_NOISE", canvasNoise: "CC_CANVAS_NOISE", gpuStringSpoof: "CC_GPU_STRING_SPOOF",
|
|
100
|
+
};
|
|
101
|
+
const SPECIAL = ["location", "fingerprintProfile", "headless", "proxy", "args", "licenseKey", "licenseApiBase"];
|
|
102
|
+
const SDK_SIDE = ["timeout", "slowMo", "humanize", "showCursor", "quiet", "docker", "dockerImage", "ephemeralProfile"];
|
|
103
|
+
export const DOCKER_ACCEPTED = [
|
|
104
|
+
...Object.keys(STR_ENV), ...Object.keys(INT_ENV), ...Object.keys(FLOAT_ENV), ...Object.keys(BOOL_ENV), ...SPECIAL, ...SDK_SIDE,
|
|
105
|
+
];
|
|
106
|
+
export function hostIsMacos() {
|
|
107
|
+
return process.platform === "darwin" || (process.env[TEST_ONLY_ASSUME_MACOS] ?? "").trim() === "1";
|
|
108
|
+
}
|
|
109
|
+
/** Whether this launch runs in the Clearcote Docker image (see the header). */
|
|
110
|
+
export function dockerRequested(docker, options = {}) {
|
|
111
|
+
if (docker !== undefined && docker !== null) {
|
|
112
|
+
if (typeof docker === "string")
|
|
113
|
+
return TRUTHY.includes(docker.trim().toLowerCase()); // "0" from a config file is off
|
|
114
|
+
return !!docker;
|
|
115
|
+
}
|
|
116
|
+
if (options.executablePath)
|
|
117
|
+
return false; // the caller named a binary: theirs to run
|
|
118
|
+
const env = (process.env.CLEARCOTE_DOCKER ?? "").trim().toLowerCase();
|
|
119
|
+
if (FALSY.includes(env))
|
|
120
|
+
return false;
|
|
121
|
+
if (TRUTHY.includes(env))
|
|
122
|
+
return true;
|
|
123
|
+
if (process.env.CLEARCOTE_BINARY)
|
|
124
|
+
return false;
|
|
125
|
+
return hostIsMacos();
|
|
126
|
+
}
|
|
127
|
+
export function defaultImage() {
|
|
128
|
+
return process.env.CLEARCOTE_DOCKER_IMAGE || `${DEFAULT_REPOSITORY}:sdk-${SDK_VERSION}`;
|
|
129
|
+
}
|
|
130
|
+
function proxyUrl(proxy) {
|
|
131
|
+
if (typeof proxy === "string")
|
|
132
|
+
return proxy;
|
|
133
|
+
const p = proxy;
|
|
134
|
+
if (!p || !p.server)
|
|
135
|
+
throw new TypeError("proxy must be a URL or { server, username?, password? }");
|
|
136
|
+
const i = p.server.indexOf("://");
|
|
137
|
+
const scheme = i < 0 ? "http" : p.server.slice(0, i);
|
|
138
|
+
let rest = i < 0 ? p.server : p.server.slice(i + 3);
|
|
139
|
+
if (p.username || p.password)
|
|
140
|
+
rest = `${encodeURIComponent(p.username ?? "")}:${encodeURIComponent(p.password ?? "")}@${rest}`;
|
|
141
|
+
return `${scheme}://${rest}`;
|
|
142
|
+
}
|
|
143
|
+
/** The CC_* (and licence) variables the image is started with. Throws naming the first option the
|
|
144
|
+
* image cannot take. */
|
|
145
|
+
export function containerEnv(options) {
|
|
146
|
+
const unknown = Object.keys(options).filter((k) => !DOCKER_ACCEPTED.includes(k) && options[k] !== undefined && options[k] !== null).sort();
|
|
147
|
+
if (unknown.length) {
|
|
148
|
+
const takes = DOCKER_ACCEPTED.filter((k) => !SDK_SIDE.includes(k)).sort().join(", ");
|
|
149
|
+
throw new TypeError(`${unknown[0]} is not available when launch() runs Clearcote in Docker (there is no native macOS build). The container takes: ${takes}. ${OFF_HINT}`);
|
|
150
|
+
}
|
|
151
|
+
const env = {};
|
|
152
|
+
const has = (k) => options[k] !== undefined && options[k] !== null;
|
|
153
|
+
for (const [k, name] of Object.entries(STR_ENV)) {
|
|
154
|
+
if (!has(k))
|
|
155
|
+
continue;
|
|
156
|
+
if (typeof options[k] === "boolean")
|
|
157
|
+
throw new TypeError(`${k}: ${options[k]} is not available when launch() runs Clearcote in Docker`);
|
|
158
|
+
env[name] = String(options[k]);
|
|
159
|
+
}
|
|
160
|
+
for (const [k, name] of Object.entries(INT_ENV))
|
|
161
|
+
if (has(k))
|
|
162
|
+
env[name] = String(Math.trunc(Number(options[k])));
|
|
163
|
+
for (const [k, name] of Object.entries(FLOAT_ENV))
|
|
164
|
+
if (has(k))
|
|
165
|
+
env[name] = String(Number(options[k]));
|
|
166
|
+
for (const [k, name] of Object.entries(BOOL_ENV))
|
|
167
|
+
if (has(k))
|
|
168
|
+
env[name] = options[k] ? "1" : "0"; // false is a value
|
|
169
|
+
if (has("location"))
|
|
170
|
+
env.CC_LOCATION = Array.isArray(options.location) ? options.location.join(",") : String(options.location);
|
|
171
|
+
if (has("fingerprintProfile")) {
|
|
172
|
+
const prof = options.fingerprintProfile;
|
|
173
|
+
if (typeof prof === "object")
|
|
174
|
+
env.CC_FINGERPRINT_PROFILE = JSON.stringify(prof);
|
|
175
|
+
else if (typeof prof === "string" && existsSync(prof) && statSync(prof).isFile())
|
|
176
|
+
env.CC_FINGERPRINT_PROFILE = readFileSync(prof, "utf8"); // a path on THIS machine
|
|
177
|
+
else
|
|
178
|
+
env.CC_FINGERPRINT_PROFILE = String(prof);
|
|
179
|
+
}
|
|
180
|
+
if (options.headless === true)
|
|
181
|
+
env.CC_HEADLESS = "1"; // unset or false: the image's default, headed on its own display
|
|
182
|
+
if (options.proxy)
|
|
183
|
+
env.CC_PROXY = proxyUrl(options.proxy);
|
|
184
|
+
const args = options.args;
|
|
185
|
+
if (args?.length) {
|
|
186
|
+
const bad = args.find((a) => !String(a) || /\s/.test(String(a)));
|
|
187
|
+
if (bad !== undefined)
|
|
188
|
+
throw new TypeError(`args ${JSON.stringify(bad)}: an argument with whitespace cannot be passed to the Docker image`);
|
|
189
|
+
env.CC_EXTRA_ARGS = args.join(" ");
|
|
190
|
+
}
|
|
191
|
+
const key = resolveLicenseKey(options.licenseKey); // as a local launch: option > env > saved key
|
|
192
|
+
if (key)
|
|
193
|
+
env.CLEARCOTE_LICENSE_KEY = key;
|
|
194
|
+
if (options.licenseApiBase)
|
|
195
|
+
env.CLEARCOTE_LICENSE_API = String(options.licenseApiBase);
|
|
196
|
+
return env;
|
|
197
|
+
}
|
|
198
|
+
const MARK = /\[clearcote\] (serve-state |engine: |proxy: )/;
|
|
199
|
+
function findOnPath(name) {
|
|
200
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
201
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
202
|
+
if (!dir)
|
|
203
|
+
continue;
|
|
204
|
+
for (const ext of exts) {
|
|
205
|
+
const p = join(dir, name + ext.toLowerCase());
|
|
206
|
+
try {
|
|
207
|
+
if (statSync(p).isFile())
|
|
208
|
+
return p;
|
|
209
|
+
}
|
|
210
|
+
catch { /* not here */ }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
function followLogs(exe, id) {
|
|
216
|
+
const lines = [];
|
|
217
|
+
const marks = [];
|
|
218
|
+
let buf = "";
|
|
219
|
+
let child = null;
|
|
220
|
+
let done = Promise.resolve();
|
|
221
|
+
try {
|
|
222
|
+
child = spawn(exe, ["logs", "--follow", id], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
223
|
+
const onData = (c) => {
|
|
224
|
+
buf += c.toString("utf8");
|
|
225
|
+
const parts = buf.split(/\r?\n/);
|
|
226
|
+
buf = parts.pop() ?? "";
|
|
227
|
+
lines.push(...parts);
|
|
228
|
+
marks.push(...parts.filter((l) => MARK.test(l)));
|
|
229
|
+
if (lines.length > 40)
|
|
230
|
+
lines.splice(0, lines.length - 40);
|
|
231
|
+
};
|
|
232
|
+
child.stdout.on("data", onData);
|
|
233
|
+
child.stderr.on("data", onData);
|
|
234
|
+
done = new Promise((r) => { child.once("close", () => r()); child.once("error", () => r()); });
|
|
235
|
+
}
|
|
236
|
+
catch { /* no log, then */ }
|
|
237
|
+
return {
|
|
238
|
+
async text(waitMs = 3000) {
|
|
239
|
+
// the container is gone: the follower ends with its last line
|
|
240
|
+
await Promise.race([done, new Promise((r) => setTimeout(r, waitMs))]);
|
|
241
|
+
return [...lines, buf].filter(Boolean).join("\n");
|
|
242
|
+
},
|
|
243
|
+
async waitMarks(pattern, waitMs = 10_000) {
|
|
244
|
+
const deadline = Date.now() + waitMs;
|
|
245
|
+
while (Date.now() < deadline && !marks.some((m) => pattern.test(m)))
|
|
246
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
247
|
+
return [...marks];
|
|
248
|
+
},
|
|
249
|
+
stop() { if (child && child.exitCode === null)
|
|
250
|
+
child.kill(); },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** The docker CLI. Seams: tests replace `which`, `run` and `followLogs`. */
|
|
254
|
+
export const dockerCli = {
|
|
255
|
+
which: () => findOnPath("docker"),
|
|
256
|
+
run: (argv, env, timeoutMs = 120_000, input) => new Promise((resolve) => {
|
|
257
|
+
const child = execFile(argv[0], argv.slice(1), { env: env ? { ...process.env, ...env } : process.env, timeout: timeoutMs, maxBuffer: 16 << 20, windowsHide: true }, (error, stdout, stderr) => {
|
|
258
|
+
const e = error;
|
|
259
|
+
const code = !e ? 0 : typeof e.code === "number" ? e.code : e.killed ? 124 : 127;
|
|
260
|
+
resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") || (e && !stderr ? e.message : "") });
|
|
261
|
+
});
|
|
262
|
+
if (input !== undefined)
|
|
263
|
+
child.stdin?.end(input);
|
|
264
|
+
}),
|
|
265
|
+
followLogs,
|
|
266
|
+
};
|
|
267
|
+
const firstLine = (t) => (t ?? "").split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
|
|
268
|
+
/** The docker executable, or DockerUnavailableError saying what to do. */
|
|
269
|
+
export async function checkDocker() {
|
|
270
|
+
const exe = dockerCli.which();
|
|
271
|
+
if (!exe) {
|
|
272
|
+
throw new DockerUnavailableError(`Clearcote has no native macOS build, so on macOS launch() runs it in Docker, but the \`docker\` command was not found. Install Docker Desktop (${INSTALL_URL}), start it, and try again. ${OFF_HINT}`);
|
|
273
|
+
}
|
|
274
|
+
const r = await dockerCli.run([exe, "info", "--format", "{{.ServerVersion}}"], undefined, 30_000);
|
|
275
|
+
if (r.code !== 0) {
|
|
276
|
+
throw new DockerUnavailableError(`Clearcote has no native macOS build, so on macOS launch() runs it in Docker, but Docker is not running (\`docker info\`: ${firstLine(r.stderr) || `exit ${r.code}`}). Start Docker Desktop and try again. ${OFF_HINT}`);
|
|
277
|
+
}
|
|
278
|
+
return exe;
|
|
279
|
+
}
|
|
280
|
+
/** How long a launched container waits with no CDP client before it stops itself: CLEARCOTE_DOCKER_IDLE_EXIT
|
|
281
|
+
* (seconds, 0 = never), default 30. */
|
|
282
|
+
export function idleExitSeconds() {
|
|
283
|
+
const raw = (process.env.CLEARCOTE_DOCKER_IDLE_EXIT ?? "").trim();
|
|
284
|
+
if (!raw)
|
|
285
|
+
return DEFAULT_IDLE_EXIT_S;
|
|
286
|
+
if (!/^\d+$/.test(raw))
|
|
287
|
+
throw new TypeError(`CLEARCOTE_DOCKER_IDLE_EXIT=${JSON.stringify(raw)} is not a whole number of seconds`);
|
|
288
|
+
return Number(raw);
|
|
289
|
+
}
|
|
290
|
+
/** The named volume a licensed container keeps its engine in (shared, so it downloads once):
|
|
291
|
+
* CLEARCOTE_DOCKER_CACHE_VOLUME, default clearcote-cache. */
|
|
292
|
+
export function cacheVolume() {
|
|
293
|
+
return (process.env.CLEARCOTE_DOCKER_CACHE_VOLUME ?? "").trim() || DEFAULT_CACHE_VOLUME;
|
|
294
|
+
}
|
|
295
|
+
export function pidAlive(pid) {
|
|
296
|
+
if (!(pid > 0))
|
|
297
|
+
return false;
|
|
298
|
+
try {
|
|
299
|
+
process.kill(pid, 0);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
return e.code === "EPERM"; // exists, but not ours to signal
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// ── who owns a container ───────────────────────────────────────────────────────────────────────────
|
|
307
|
+
// A container carries a random per-process token (OWNER_TOKEN_LABEL); the owner writes a record under
|
|
308
|
+
// ~/.clearcote/docker-owners/<token>.json saying which process it is: pid, a start marker that changes when
|
|
309
|
+
// the pid is reused, its host name and (Linux) its PID namespace and boot id. A sweeper only judges a record it
|
|
310
|
+
// can verify completely: one in its own home, written on this host, from this boot and PID namespace -- every
|
|
311
|
+
// one of those fields as this process would write it, so a record written on another OS into a shared home (no
|
|
312
|
+
// namespace or boot id), in a container given the Docker socket (another namespace), next to Windows in WSL2,
|
|
313
|
+
// or on another machine is left alone, and so is a pid that is not a positive number. When in doubt, nothing is
|
|
314
|
+
// removed: the container's idle exit still stops it. The record format is shared by the Python, Node and .NET
|
|
315
|
+
// SDKs, so any of them can sweep any other's containers.
|
|
316
|
+
const readText = (path) => {
|
|
317
|
+
try {
|
|
318
|
+
return readFileSync(path, "utf8").trim();
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
/** What this host says about processes: /proc where there is one, `ps` elsewhere. Seams: tests replace members. */
|
|
325
|
+
export const hostProbe = {
|
|
326
|
+
platform: () => process.platform,
|
|
327
|
+
readText,
|
|
328
|
+
bootId: () => readText("/proc/sys/kernel/random/boot_id"),
|
|
329
|
+
pidNamespace: () => {
|
|
330
|
+
try {
|
|
331
|
+
return readlinkSync("/proc/self/ns/pid");
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
spawnSync: spawnSync,
|
|
338
|
+
};
|
|
339
|
+
const bootId = () => hostProbe.bootId();
|
|
340
|
+
const pidNamespace = () => hostProbe.pidNamespace();
|
|
341
|
+
/** `ps -o lstart=` prints local time, in the locale's words. Pinned, so an owner and a sweeper that run with
|
|
342
|
+
* different TZ / LANG settings (or one script that changes TZ between launches) read the same string for the
|
|
343
|
+
* same process. Without it a sweeper in another time zone took a live owner for a reused pid. */
|
|
344
|
+
export const PS_ENV = { TZ: "UTC0", LC_ALL: "C" };
|
|
345
|
+
/** A marker that differs once `pid` belongs to another process (it was reused), or null when it cannot be
|
|
346
|
+
* read here. Linux: the start time from /proc; macOS: `ps -o lstart=` in UTC and the C locale; Windows: not
|
|
347
|
+
* readable from Node (null: the pid alone is then trusted, which only ever keeps a container). The same
|
|
348
|
+
* strings in all three SDKs. */
|
|
349
|
+
export function processStart(pid) {
|
|
350
|
+
const stat = hostProbe.readText(`/proc/${pid}/stat`);
|
|
351
|
+
if (stat) {
|
|
352
|
+
const f = stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/);
|
|
353
|
+
return f[19] ? `linux:${f[19]}` : null;
|
|
354
|
+
}
|
|
355
|
+
if (hostProbe.platform() === "win32")
|
|
356
|
+
return null;
|
|
357
|
+
try {
|
|
358
|
+
const r = hostProbe.spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 10_000, env: { ...process.env, ...PS_ENV } });
|
|
359
|
+
const out = String(r.stdout ?? "").split(/\s+/).filter(Boolean).join(" ");
|
|
360
|
+
return r.status === 0 && out ? `ps-utc:${out}` : null;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const ownersDir = () => join(homedir(), ".clearcote", "docker-owners");
|
|
367
|
+
let ownerTokenValue = null;
|
|
368
|
+
/** This process's owner token, recording it (once) under ~/.clearcote/docker-owners/. */
|
|
369
|
+
export function ownerToken() {
|
|
370
|
+
if (ownerTokenValue)
|
|
371
|
+
return ownerTokenValue;
|
|
372
|
+
const token = randomBytes(16).toString("hex");
|
|
373
|
+
const record = { token, pid: process.pid, start: processStart(process.pid), pidns: pidNamespace(), boot: bootId(), host: hostname(), sdk: "node" };
|
|
374
|
+
const path = join(ownersDir(), `${token}.json`);
|
|
375
|
+
try {
|
|
376
|
+
mkdirSync(ownersDir(), { recursive: true });
|
|
377
|
+
writeFileSync(path, JSON.stringify(record));
|
|
378
|
+
process.once("exit", () => { try {
|
|
379
|
+
rmSync(path, { force: true });
|
|
380
|
+
}
|
|
381
|
+
catch { /* best-effort */ } });
|
|
382
|
+
}
|
|
383
|
+
catch { /* no record: other launches leave its containers alone, and idle exit still ends them */ }
|
|
384
|
+
ownerTokenValue = token;
|
|
385
|
+
return token;
|
|
386
|
+
}
|
|
387
|
+
/** Forget this process's token (tests: a new HOME gets a new record). */
|
|
388
|
+
export function resetOwnerToken() { ownerTokenValue = null; }
|
|
389
|
+
/** The fields an owner record from this process would carry besides its pid and start marker. */
|
|
390
|
+
export const ownerHere = () => ({ host: hostname(), boot: bootId(), pidns: pidNamespace() });
|
|
391
|
+
const markerKind = (m) => (typeof m === "string" && m.includes(":") ? m.slice(0, m.indexOf(":")) : null);
|
|
392
|
+
/** true or false for the owner a record describes, or null when that cannot be told for certain here: a record
|
|
393
|
+
* whose host, boot id or PID namespace is not exactly what this process would record (missing ones included),
|
|
394
|
+
* or whose pid is not a positive integer. A pid that is alive with a start marker of the same kind but another
|
|
395
|
+
* value was reused (false); one whose marker cannot be compared counts as alive. */
|
|
396
|
+
export function ownerAlive(rec) {
|
|
397
|
+
const here = ownerHere();
|
|
398
|
+
if (!here.host || typeof rec.host !== "string" || rec.host.toLowerCase() !== here.host.toLowerCase())
|
|
399
|
+
return null;
|
|
400
|
+
if ((rec.boot ?? null) !== here.boot || (rec.pidns ?? null) !== here.pidns)
|
|
401
|
+
return null;
|
|
402
|
+
if (typeof rec.pid !== "number" || !Number.isInteger(rec.pid) || rec.pid <= 0)
|
|
403
|
+
return null;
|
|
404
|
+
if (!pidAlive(rec.pid))
|
|
405
|
+
return false;
|
|
406
|
+
if (rec.start) {
|
|
407
|
+
const now = processStart(rec.pid);
|
|
408
|
+
if (now && markerKind(now) === markerKind(rec.start) && now !== rec.start)
|
|
409
|
+
return false; // the pid was reused
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
function loadRecord(path) {
|
|
414
|
+
try {
|
|
415
|
+
const rec = JSON.parse(readFileSync(path, "utf8"));
|
|
416
|
+
return rec && typeof rec === "object" ? rec : null;
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Stop and remove containers an earlier launch left behind whose owner process is certainly gone (killed,
|
|
424
|
+
* crashed): their token's record is in this home, from this boot and PID namespace, and its process no longer
|
|
425
|
+
* exists. They would stop on their own after the idle period; this frees their licence seat now. Anything else
|
|
426
|
+
* -- live owners, owners in another namespace or on another machine, containers without a token -- is left
|
|
427
|
+
* alone. Resolves to the ids removed.
|
|
428
|
+
*/
|
|
429
|
+
export async function sweepStale(exe) {
|
|
430
|
+
const fmt = `{{.ID}}\t{{.Label "${OWNER_TOKEN_LABEL}"}}`;
|
|
431
|
+
const r = await dockerCli.run([exe, "ps", "-a", "--filter", `label=${LABEL}`, "--format", fmt], undefined, 30_000);
|
|
432
|
+
const stale = [];
|
|
433
|
+
const dead = new Set();
|
|
434
|
+
for (const line of r.code === 0 ? r.stdout.split(/\r?\n/) : []) {
|
|
435
|
+
const parts = line.trim().split("\t");
|
|
436
|
+
if (parts.length !== 2 || !/^[0-9a-f]{32}$/.test(parts[1]))
|
|
437
|
+
continue; // no owner token (an older SDK): not ours to judge
|
|
438
|
+
const path = join(ownersDir(), `${parts[1]}.json`);
|
|
439
|
+
const rec = loadRecord(path);
|
|
440
|
+
if (rec && ownerAlive(rec) === false) {
|
|
441
|
+
stale.push(parts[0]);
|
|
442
|
+
dead.add(path);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (stale.length) {
|
|
446
|
+
await dockerCli.run([exe, "stop", "--time", "10", ...stale], undefined, 120_000);
|
|
447
|
+
await dockerCli.run([exe, "rm", "-f", "-v", ...stale], undefined, 60_000);
|
|
448
|
+
}
|
|
449
|
+
try { // the records of owners that are certainly gone
|
|
450
|
+
for (const name of readdirSync(ownersDir())) {
|
|
451
|
+
const path = join(ownersDir(), name);
|
|
452
|
+
const rec = name.endsWith(".json") ? loadRecord(path) : null;
|
|
453
|
+
if (dead.has(path) || (rec && ownerAlive(rec) === false))
|
|
454
|
+
rmSync(path, { force: true });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catch { /* no records */ }
|
|
458
|
+
return stale;
|
|
459
|
+
}
|
|
460
|
+
const live = new Map(); // container id -> docker executable, for the exit-time sweep
|
|
461
|
+
let sweepInstalled = false;
|
|
462
|
+
async function removeContainer(exe, id) {
|
|
463
|
+
// stop first: SIGTERM lets the image release a licence seat. The container was created with --rm, so
|
|
464
|
+
// stopping it removes it and its anonymous volume (the image declares VOLUME /opt/xdg-cache: ~0.5 GB);
|
|
465
|
+
// rm -f -v is for a container that did not go away. A named volume is never removed.
|
|
466
|
+
await dockerCli.run([exe, "stop", "--time", "10", id], undefined, 60_000);
|
|
467
|
+
await dockerCli.run([exe, "rm", "-f", "-v", id], undefined, 60_000);
|
|
468
|
+
live.delete(id);
|
|
469
|
+
}
|
|
470
|
+
function installSweep() {
|
|
471
|
+
if (sweepInstalled)
|
|
472
|
+
return;
|
|
473
|
+
sweepInstalled = true;
|
|
474
|
+
// A normal exit removes what a caller never closed ("exit" handlers must be synchronous). Ctrl-C, a kill
|
|
475
|
+
// or a crash never runs this: the container's idle exit and the next launch's sweepStale cover those.
|
|
476
|
+
process.once("exit", () => {
|
|
477
|
+
for (const [id, exe] of live) {
|
|
478
|
+
try {
|
|
479
|
+
spawnSync(exe, ["rm", "-f", "-v", id], { timeout: 30_000, windowsHide: true });
|
|
480
|
+
}
|
|
481
|
+
catch { /* best-effort */ }
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
function cdpReady(port) {
|
|
486
|
+
return new Promise((resolve) => {
|
|
487
|
+
const req = http.get({ host: "127.0.0.1", port, path: "/json/version", timeout: 2000 }, (res) => {
|
|
488
|
+
let body = "";
|
|
489
|
+
res.on("data", (c) => (body += c));
|
|
490
|
+
res.on("end", () => resolve(res.statusCode === 200 && body.includes("webSocketDebuggerUrl")));
|
|
491
|
+
});
|
|
492
|
+
req.on("timeout", () => req.destroy());
|
|
493
|
+
req.on("error", () => resolve(false));
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
async function publishedPort(exe, id) {
|
|
497
|
+
const r = await dockerCli.run([exe, "port", id, "9222/tcp"], undefined, 30_000);
|
|
498
|
+
for (const line of r.stdout.split(/\r?\n/)) {
|
|
499
|
+
const m = /:(\d+)\s*$/.exec(line.trim());
|
|
500
|
+
if (m)
|
|
501
|
+
return Number(m[1]);
|
|
502
|
+
}
|
|
503
|
+
throw new Error(`could not read the container's published CDP port (${firstLine(r.stderr) || JSON.stringify(r.stdout)})`);
|
|
504
|
+
}
|
|
505
|
+
async function waitReady(exe, id, port, budgetMs, logs) {
|
|
506
|
+
const deadline = Date.now() + budgetMs;
|
|
507
|
+
for (;;) {
|
|
508
|
+
if (await cdpReady(port))
|
|
509
|
+
return;
|
|
510
|
+
const st = await dockerCli.run([exe, "inspect", "-f", "{{.State.Running}}", id], undefined, 30_000);
|
|
511
|
+
if (st.code !== 0 || st.stdout.trim() !== "true") { // stopped (and, with --rm, already gone)
|
|
512
|
+
throw new Error(`the Clearcote container stopped before its browser came up:\n${(await logs.text()).trim() || "(it left no log)"}`);
|
|
513
|
+
}
|
|
514
|
+
if (Date.now() >= deadline)
|
|
515
|
+
throw new Error(`the Clearcote container's CDP endpoint did not answer within ${Math.round(budgetMs / 1000)} s`);
|
|
516
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const VOLUME_INIT_RACE = /volumes\/[^/\s]+\/_data\S*: (?:file exists|no such file)/i;
|
|
520
|
+
// A tag that is not there: Docker 29 says `failed to resolve reference "docker.io/...:tag": ...: not found`;
|
|
521
|
+
// older ones `manifest unknown` / `manifest for ... not found`. Docker 29 reports a registry it cannot reach with
|
|
522
|
+
// the same "failed to resolve reference" opening, so that phrase alone proves nothing: the network wordings are
|
|
523
|
+
// looked for first.
|
|
524
|
+
const NOT_PUBLISHED = /manifest unknown|manifest for .* not found|not found: manifest|pull access denied|repository does not exist|: not found\s*$/im;
|
|
525
|
+
const REGISTRY_UNREACHABLE = /dial tcp|no such host|i\/o timeout|connection refused|connection reset|network is unreachable|no route to host|TLS handshake timeout|context deadline exceeded|failed to do request|Client\.Timeout|temporary failure in name resolution|server misbehaving/i;
|
|
526
|
+
function imageFailed(image, code, stderr, what = "docker pull") {
|
|
527
|
+
const detail = stderr.trim() || `exit ${code}`;
|
|
528
|
+
if (REGISTRY_UNREACHABLE.test(detail)) {
|
|
529
|
+
return new Error(`could not download the Clearcote image ${image}: the registry did not answer (${firstLine(detail)}). Check this machine's network (and any proxy Docker itself needs), or set CLEARCOTE_DOCKER_IMAGE / dockerImage to an image that is already here (\`docker images ${DEFAULT_REPOSITORY}\`).`);
|
|
530
|
+
}
|
|
531
|
+
if (NOT_PUBLISHED.test(detail)) {
|
|
532
|
+
const why = image === `${DEFAULT_REPOSITORY}:sdk-${SDK_VERSION}`
|
|
533
|
+
? `Each image is published a few minutes after its SDK release (it is built from the PyPI package of the same version), so a brand-new SDK can be ahead of its image: try again in a few minutes. To launch meanwhile, set CLEARCOTE_DOCKER_IMAGE to an earlier tag of ${DEFAULT_REPOSITORY} (${DEFAULT_REPOSITORY}:latest is the newest published one). An image older than ${FIRST_PROTOCOL_TAG} still works, but the licence key and the proxy reach it as container variables that \`docker inspect\` shows, and it does not stop on its own if this program dies; launch() says so when that happens.`
|
|
534
|
+
: "Check the name, or unset CLEARCOTE_DOCKER_IMAGE / dockerImage to use the default.";
|
|
535
|
+
return new Error(`the Clearcote image ${image} is not available (${firstLine(detail)}). ${why}`);
|
|
536
|
+
}
|
|
537
|
+
return new Error(`\`${what} ${image}\` failed: ${detail}\n(set CLEARCOTE_DOCKER_IMAGE or dockerImage to use another image)`);
|
|
538
|
+
}
|
|
539
|
+
/** The serve protocol `image` speaks (its PROTOCOL_LABEL; 0 for an image without it), pulling the image first
|
|
540
|
+
* when it is not here. Known before a container exists, so the settings a container gets are always ones its
|
|
541
|
+
* entrypoint understands. */
|
|
542
|
+
export async function imageProtocol(exe, image, quiet = false) {
|
|
543
|
+
const fmt = "{{json .Config.Labels}}";
|
|
544
|
+
let r = await dockerCli.run([exe, "image", "inspect", "--format", fmt, image], undefined, 60_000);
|
|
545
|
+
if (r.code !== 0) {
|
|
546
|
+
if (!quiet)
|
|
547
|
+
process.stderr.write(`[clearcote] downloading the Clearcote Docker image ${image}\n`);
|
|
548
|
+
const pull = await dockerCli.run([exe, "pull", "--platform", "linux/amd64", image], undefined, 1_800_000);
|
|
549
|
+
if (pull.code !== 0)
|
|
550
|
+
throw imageFailed(image, pull.code, pull.stderr);
|
|
551
|
+
r = await dockerCli.run([exe, "image", "inspect", "--format", fmt, image], undefined, 60_000);
|
|
552
|
+
if (r.code !== 0)
|
|
553
|
+
throw imageFailed(image, r.code, r.stderr, "docker image inspect");
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const labels = JSON.parse(r.stdout.trim() || "null") ?? {};
|
|
557
|
+
const n = parseInt(String(labels[PROTOCOL_LABEL] ?? "0"), 10);
|
|
558
|
+
return Number.isFinite(n) ? n : 0;
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return 0;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function proxyHostPort(url) {
|
|
565
|
+
if (!url)
|
|
566
|
+
return [null, null];
|
|
567
|
+
try {
|
|
568
|
+
const u = new URL(url.includes("://") ? url : `http://${url}`);
|
|
569
|
+
return [u.hostname.toLowerCase().replace(/^\[|\]$/g, ""), u.port ? Number(u.port) : null];
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
return [null, null];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/** [scheme, whether the proxy URL carries a username or password, whether they are percent-escaped]. */
|
|
576
|
+
function proxyLogin(url) {
|
|
577
|
+
try {
|
|
578
|
+
const u = new URL(url.includes("://") ? url : `http://${url}`);
|
|
579
|
+
return [u.protocol.replace(/:$/, "").toLowerCase(), !!(u.username || u.password), `${u.username}:${u.password}`.includes("%")];
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
return ["http", url.includes("@"), url.includes("%")];
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/** Why an image older than FIRST_PROTOCOL_TAG cannot take this proxy, or null. Its entrypoint drops the password
|
|
586
|
+
* of an http(s) proxy (Chrome is then challenged and nothing answers: every request fails), and hands a SOCKS5
|
|
587
|
+
* password to an engine switch only the licensed engine has -- as written in the URL, without decoding it, so a
|
|
588
|
+
* username or password with characters that have to be escaped there arrives wrong. */
|
|
589
|
+
export function legacyProxyRefusal(image, proxyUrlValue, licensed) {
|
|
590
|
+
if (!proxyUrlValue)
|
|
591
|
+
return null;
|
|
592
|
+
const [scheme, creds, escaped] = proxyLogin(proxyUrlValue);
|
|
593
|
+
if (!creds || (scheme.startsWith("socks5") && licensed && !escaped))
|
|
594
|
+
return null;
|
|
595
|
+
const what = scheme.startsWith("http") ? "an HTTP proxy that needs a password"
|
|
596
|
+
: !licensed ? "a SOCKS5 proxy that needs a password without a licence key (its open engine cannot log in)"
|
|
597
|
+
: "a SOCKS5 proxy that needs a password with characters a URL has to escape (it would send them escaped)";
|
|
598
|
+
return `the Clearcote image ${image} predates ${FIRST_PROTOCOL_TAG} and cannot use ${what}: every request through it would fail. Use ${DEFAULT_REPOSITORY}:${FIRST_PROTOCOL_TAG} or newer (the default image is), or a proxy that does not need a password.`;
|
|
599
|
+
}
|
|
600
|
+
/** What the container did not apply that was asked for (empty when all is well), from the lines its
|
|
601
|
+
* entrypoint logged: serve-state (protocol 2) or, for an older image, its engine and proxy lines. */
|
|
602
|
+
export function verifyApplied(marks, protocol, expect) {
|
|
603
|
+
const problems = [];
|
|
604
|
+
let engine;
|
|
605
|
+
let proxy;
|
|
606
|
+
let proxyAuth = null;
|
|
607
|
+
if (protocol >= 2) {
|
|
608
|
+
let state = null;
|
|
609
|
+
for (const line of marks) {
|
|
610
|
+
const i = line.indexOf("serve-state ");
|
|
611
|
+
if (i >= 0) {
|
|
612
|
+
try {
|
|
613
|
+
state = JSON.parse(line.slice(i + "serve-state ".length));
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
state = null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (!state || typeof state !== "object")
|
|
621
|
+
return ["it did not report what it applied (no serve-state line)"];
|
|
622
|
+
engine = state.engine ?? null;
|
|
623
|
+
proxy = state.proxy ?? null;
|
|
624
|
+
proxyAuth = state.proxy_auth ?? null;
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
const engineLine = marks.find((m) => m.includes("] engine: ")) ?? "";
|
|
628
|
+
engine = engineLine.includes("(licensed)") && engineLine.includes("/pro-") ? "licensed" : engineLine ? "open" : null;
|
|
629
|
+
const proxyLine = marks.find((m) => m.includes("] proxy: "));
|
|
630
|
+
proxy = proxyLine ? proxyLine.slice(proxyLine.indexOf("] proxy: ") + "] proxy: ".length).trim() : null;
|
|
631
|
+
}
|
|
632
|
+
if (expect.licensed && engine !== "licensed") {
|
|
633
|
+
problems.push(engine ? "a licence key was given, but it runs the open engine" : "a licence key was given, but it did not say which engine it runs");
|
|
634
|
+
}
|
|
635
|
+
if (expect.proxy) {
|
|
636
|
+
const [wh, wp] = proxyHostPort(expect.proxy);
|
|
637
|
+
const [gh, gp] = proxyHostPort(proxy);
|
|
638
|
+
if (!proxy || gh !== wh || (wp && gp && wp !== gp)) {
|
|
639
|
+
problems.push(proxy ? `it applied a different proxy (${proxy})` : "a proxy was given, but it did not apply it (its traffic would leave from the container's own address)");
|
|
640
|
+
}
|
|
641
|
+
else if (protocol >= 2 && proxyLogin(expect.proxy)[1] && proxyAuth !== "engine" && proxyAuth !== "relay") {
|
|
642
|
+
problems.push("the proxy needs a password, but it did not say it can log in to it (every request through it would fail)");
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return problems;
|
|
646
|
+
}
|
|
647
|
+
/** A ustar archive of one file, as `docker cp -` reads it. */
|
|
648
|
+
export function tarOneFile(name, data, opts) {
|
|
649
|
+
const header = Buffer.alloc(512, 0);
|
|
650
|
+
const put = (s, off, len) => header.write(s, off, len, "ascii");
|
|
651
|
+
const oct = (n, len) => n.toString(8).padStart(len - 1, "0") + "\0";
|
|
652
|
+
put(name, 0, 100);
|
|
653
|
+
put(oct(opts.mode, 8), 100, 8);
|
|
654
|
+
put(oct(opts.uid, 8), 108, 8);
|
|
655
|
+
put(oct(opts.gid, 8), 116, 8);
|
|
656
|
+
put(oct(data.length, 12), 124, 12);
|
|
657
|
+
put(oct(Math.floor(Date.now() / 1000), 12), 136, 12);
|
|
658
|
+
put(" ", 148, 8); // checksum is computed with this field as spaces
|
|
659
|
+
put("0", 156, 1); // a regular file
|
|
660
|
+
put("ustar\0", 257, 6);
|
|
661
|
+
put("00", 263, 2);
|
|
662
|
+
let sum = 0;
|
|
663
|
+
for (const b of header)
|
|
664
|
+
sum += b;
|
|
665
|
+
put(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8);
|
|
666
|
+
const pad = Buffer.alloc((512 - (data.length % 512)) % 512, 0);
|
|
667
|
+
return Buffer.concat([header, data, pad, Buffer.alloc(1024, 0)]);
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Start the image and wait for its CDP endpoint. Nothing is left running when this rejects.
|
|
671
|
+
*
|
|
672
|
+
* What the image does with its settings is known first (imageProtocol). An image of this SDK's protocol gets
|
|
673
|
+
* --rm, an idle exit (it stops once no CDP client has been connected for idleExitSeconds(), counted from when
|
|
674
|
+
* its CDP answers) and the licence key and proxy URL as a file copied in, not as -e variables `docker inspect`
|
|
675
|
+
* would show. An older image gets plain variables -- they still work -- and a warning that they are visible
|
|
676
|
+
* and that it will not stop on its own -- and is refused, before it starts, a proxy password it cannot answer
|
|
677
|
+
* (legacyProxyRefusal). Either way, once the browser answers, the container's own log must show the licensed
|
|
678
|
+
* engine when a key was given and the proxy when one was given, logged in to when it needs a password
|
|
679
|
+
* (verifyApplied); otherwise it is stopped and removed and launch() rejects: a browser on the open engine, one
|
|
680
|
+
* sending traffic direct, or one whose every request the proxy turns away is never handed back in their place. The container carries this process's owner token for sweepStale().
|
|
681
|
+
*/
|
|
682
|
+
export async function startContainer(options) {
|
|
683
|
+
const env = containerEnv(options);
|
|
684
|
+
const idle = idleExitSeconds();
|
|
685
|
+
const exe = await checkDocker();
|
|
686
|
+
await sweepStale(exe);
|
|
687
|
+
const image = options.dockerImage || defaultImage();
|
|
688
|
+
const protocol = await imageProtocol(exe, image, !!options.quiet);
|
|
689
|
+
const secrets = {};
|
|
690
|
+
for (const k of SECRET_ENV)
|
|
691
|
+
if (env[k] !== undefined) {
|
|
692
|
+
secrets[k] = env[k];
|
|
693
|
+
delete env[k];
|
|
694
|
+
}
|
|
695
|
+
const expect = { licensed: secrets.CLEARCOTE_LICENSE_KEY !== undefined, proxy: secrets.CC_PROXY ?? null };
|
|
696
|
+
if (protocol >= SERVE_PROTOCOL) {
|
|
697
|
+
if (idle)
|
|
698
|
+
env.CC_IDLE_EXIT_SECONDS = String(idle);
|
|
699
|
+
if (Object.keys(secrets).length)
|
|
700
|
+
env.CC_SECRETS_FILE = SECRETS_FILE;
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
const refusal = legacyProxyRefusal(image, expect.proxy, expect.licensed);
|
|
704
|
+
if (refusal)
|
|
705
|
+
throw new Error(refusal);
|
|
706
|
+
if (!options.quiet) {
|
|
707
|
+
process.stderr.write(`[clearcote] warning: the image ${image} predates ${FIRST_PROTOCOL_TAG}: `
|
|
708
|
+
+ (Object.keys(secrets).length ? "its licence key and proxy are passed as container variables, which `docker inspect` shows, and " : "")
|
|
709
|
+
+ `it will not stop on its own if this program dies (close() still stops it). Use ${DEFAULT_REPOSITORY}:${FIRST_PROTOCOL_TAG} or newer.\n`);
|
|
710
|
+
}
|
|
711
|
+
Object.assign(env, secrets); // plain variables: names on the command line, values in the CLI's environment
|
|
712
|
+
for (const k of Object.keys(secrets))
|
|
713
|
+
delete secrets[k];
|
|
714
|
+
}
|
|
715
|
+
const argv = [exe, "create", "--rm", "--platform", "linux/amd64", "--shm-size", "1g", "-p", "127.0.0.1::9222",
|
|
716
|
+
"--label", LABEL, "--label", `${OWNER_HOST_LABEL}=${hostname()}`, "--label", `${OWNER_TOKEN_LABEL}=${ownerToken()}`];
|
|
717
|
+
for (const name of Object.keys(env).sort())
|
|
718
|
+
argv.push("-e", name); // the value comes from the CLI's environment
|
|
719
|
+
if (expect.licensed)
|
|
720
|
+
argv.push("-v", `${cacheVolume()}:/opt/xdg-cache`); // the licensed engine downloads once
|
|
721
|
+
argv.push(image);
|
|
722
|
+
if (!options.quiet)
|
|
723
|
+
process.stderr.write(`[clearcote] no native macOS build: starting the Clearcote Docker image ${image}\n`);
|
|
724
|
+
let r = await dockerCli.run(argv, env, 1_800_000);
|
|
725
|
+
// Two launches creating their first container on a NEW shared volume at once collide in Docker's own volume
|
|
726
|
+
// initialisation ("failed to mkdir .../volumes/<name>/_data/...: file exists"); the loser succeeds a moment
|
|
727
|
+
// later, once the volume has been filled.
|
|
728
|
+
for (let attempt = 1; attempt < 4 && r.code !== 0 && VOLUME_INIT_RACE.test(r.stderr); attempt++) {
|
|
729
|
+
await new Promise((res) => setTimeout(res, 1000 * attempt));
|
|
730
|
+
r = await dockerCli.run(argv, env, 1_800_000);
|
|
731
|
+
}
|
|
732
|
+
const id = r.stdout.trim().split(/\r?\n/).pop()?.trim() ?? "";
|
|
733
|
+
if (r.code !== 0 || !id)
|
|
734
|
+
throw imageFailed(image, r.code, r.stderr, "docker create");
|
|
735
|
+
live.set(id, exe);
|
|
736
|
+
installSweep();
|
|
737
|
+
let logs = null;
|
|
738
|
+
try {
|
|
739
|
+
if (Object.keys(secrets).length) {
|
|
740
|
+
const tar = tarOneFile(SECRETS_FILE.split("/").pop(), Buffer.from(JSON.stringify(secrets), "utf8"), { uid: IMAGE_UID, gid: IMAGE_UID, mode: 0o600 });
|
|
741
|
+
const cp = await dockerCli.run([exe, "cp", "-", `${id}:${SECRETS_FILE.slice(0, SECRETS_FILE.lastIndexOf("/"))}`], undefined, 60_000, tar);
|
|
742
|
+
if (cp.code !== 0)
|
|
743
|
+
throw new Error(`could not copy the licence/proxy settings into the container: ${firstLine(cp.stderr)}`);
|
|
744
|
+
}
|
|
745
|
+
const st = await dockerCli.run([exe, "start", id], undefined, 120_000);
|
|
746
|
+
if (st.code !== 0)
|
|
747
|
+
throw new Error(`\`docker start\` failed: ${st.stderr.trim() || `exit ${st.code}`}`);
|
|
748
|
+
logs = dockerCli.followLogs(exe, id);
|
|
749
|
+
const port = await publishedPort(exe, id);
|
|
750
|
+
const timeout = options.timeout;
|
|
751
|
+
await waitReady(exe, id, port, timeout ? Math.max(timeout, 1000) : READY_TIMEOUT_MS, logs);
|
|
752
|
+
if (protocol >= SERVE_PROTOCOL || expect.licensed || expect.proxy) {
|
|
753
|
+
const wanted = protocol >= SERVE_PROTOCOL ? /serve-state / : expect.proxy ? /\] proxy: / : /\] engine: /;
|
|
754
|
+
const problems = verifyApplied(await logs.waitMarks(wanted), protocol, expect);
|
|
755
|
+
if (problems.length) {
|
|
756
|
+
throw new Error(`the Clearcote container (${image}) did not apply what launch() asked for: ${problems.join("; ")}. It was stopped and removed.`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return { id, image, endpoint: `http://127.0.0.1:${port}`, serveProtocol: protocol, exe };
|
|
760
|
+
}
|
|
761
|
+
catch (e) {
|
|
762
|
+
await removeContainer(exe, id);
|
|
763
|
+
throw e;
|
|
764
|
+
}
|
|
765
|
+
finally {
|
|
766
|
+
logs?.stop();
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
function defaultNoViewport(browser) {
|
|
770
|
+
const origNewPage = browser.newPage.bind(browser);
|
|
771
|
+
browser.newPage = (o = {}) => origNewPage(("viewport" in o ? o : { ...o, viewport: null }));
|
|
772
|
+
const origNewContext = browser.newContext.bind(browser);
|
|
773
|
+
browser.newContext = (o = {}) => origNewContext(("viewport" in o ? o : { ...o, viewport: null }));
|
|
774
|
+
}
|
|
775
|
+
/** `launch()` on macOS (see the header): the Playwright Browser of a Clearcote container, which also
|
|
776
|
+
* carries `dockerContainer` ({ id, image, endpoint }). close() disconnects and stops the container. */
|
|
777
|
+
export async function launchDocker(options) {
|
|
778
|
+
const container = await startContainer(options);
|
|
779
|
+
let disconnect = null;
|
|
780
|
+
try {
|
|
781
|
+
const connect = { timeout: CONNECT_TIMEOUT_MS };
|
|
782
|
+
for (const k of ["timeout", "slowMo"])
|
|
783
|
+
if (options[k] != null)
|
|
784
|
+
connect[k] = options[k];
|
|
785
|
+
const browser = await chromium.connectOverCDP(container.endpoint, connect);
|
|
786
|
+
const origClose = browser.close.bind(browser);
|
|
787
|
+
disconnect = () => origClose();
|
|
788
|
+
const info = { id: container.id, image: container.image, endpoint: container.endpoint, serveProtocol: container.serveProtocol };
|
|
789
|
+
Object.defineProperty(browser, "dockerContainer", { value: info, enumerable: false, configurable: true });
|
|
790
|
+
browser.close = async (...args) => {
|
|
791
|
+
try {
|
|
792
|
+
return await origClose(...args);
|
|
793
|
+
}
|
|
794
|
+
finally {
|
|
795
|
+
await removeContainer(container.exe, container.id);
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
// The window is real (on the image's virtual display, sized to the persona's screen): an emulated
|
|
799
|
+
// viewport on top of it is the impossible-window tell a local launch avoids.
|
|
800
|
+
defaultNoViewport(browser);
|
|
801
|
+
const humanize = { humanize: options.humanize, showCursor: options.showCursor, seed: options.fingerprint };
|
|
802
|
+
for (const ctx of browser.contexts())
|
|
803
|
+
installHumanizeOnContext(ctx, humanize, browser);
|
|
804
|
+
installHumanize(browser, humanize);
|
|
805
|
+
return browser;
|
|
806
|
+
}
|
|
807
|
+
catch (e) {
|
|
808
|
+
if (disconnect)
|
|
809
|
+
await disconnect().catch(() => { });
|
|
810
|
+
await removeContainer(container.exe, container.id);
|
|
811
|
+
throw e;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
//# sourceMappingURL=docker.js.map
|