@ricsam/r5d-browser 0.0.137 → 0.0.139
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/README.md +7 -33
- package/dist/cjs/package.json +1 -1
- package/dist/mjs/browser-input.mjs +56 -1
- package/dist/mjs/cli-options.mjs +1 -0
- package/dist/mjs/main.mjs +25 -396
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/personal-diagnostics.mjs +24 -0
- package/dist/mjs/personal-main.mjs +159 -0
- package/dist/mjs/personal-port-forward.mjs +183 -0
- package/dist/mjs/personal-relay-client.mjs +33 -0
- package/dist/mjs/personal-relay-shutdown.mjs +46 -0
- package/dist/mjs/runtime/browser-executor.mjs +11 -0
- package/dist/mjs/runtime/chromium-factory.mjs +101 -0
- package/dist/mjs/runtime/ipc.mjs +128 -0
- package/dist/mjs/runtime/isolated-root.mjs +53 -0
- package/dist/mjs/runtime/keeper.mjs +169 -0
- package/dist/mjs/runtime/ledger.mjs +193 -0
- package/dist/mjs/runtime/main.mjs +105 -0
- package/dist/mjs/runtime/private-paths.mjs +48 -0
- package/dist/mjs/runtime/protocol.mjs +72 -0
- package/dist/types/browser-input.d.ts +4 -0
- package/dist/types/cli-options.d.ts +1 -0
- package/dist/types/personal-diagnostics.d.ts +13 -0
- package/dist/types/personal-main.d.ts +9 -0
- package/dist/types/personal-port-forward.d.ts +45 -0
- package/dist/types/personal-relay-client.d.ts +51 -0
- package/dist/types/personal-relay-shutdown.d.ts +19 -0
- package/dist/types/runtime/chromium-factory.d.ts +25 -0
- package/dist/types/runtime/fixtures/counter-main.d.ts +1 -0
- package/dist/types/runtime/fixtures/counter-runtime.d.ts +6 -2
- package/dist/types/runtime/isolated-root.d.ts +12 -0
- package/dist/types/runtime/main.d.ts +20 -13
- package/dist/types/runtime/private-paths.d.ts +4 -0
- package/package.json +6 -5
- package/dist/cjs/browser-input.cjs +0 -87
- package/dist/cjs/browser-runtime.cjs +0 -708
- package/dist/cjs/chrome-launcher.cjs +0 -701
- package/dist/cjs/cli-options.cjs +0 -95
- package/dist/cjs/main.cjs +0 -427
- package/dist/cjs/password-safe-snapshot.cjs +0 -150
- package/dist/cjs/port-forward-manager.cjs +0 -233
- package/dist/cjs/upload-store.cjs +0 -136
- package/dist/mjs/port-forward-manager.mjs +0 -199
- package/dist/types/port-forward-manager.d.ts +0 -23
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { PersonalPortForwardManager, PersonalForwardResponseError } from "./personal-port-forward.mjs";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { BrowserRuntime } from "./browser-runtime.mjs";
|
|
8
|
+
import { launchStableChrome } from "./chrome-launcher.mjs";
|
|
9
|
+
import { BrowserKeeper } from "./runtime/keeper.mjs";
|
|
10
|
+
import { browserRuntimeExecutor } from "./runtime/browser-executor.mjs";
|
|
11
|
+
import { PersonalBrowserProcessor } from "./personal-relay-client.mjs";
|
|
12
|
+
import { assertPrivatePath, readPrivateFile } from "./runtime/private-paths.mjs";
|
|
13
|
+
import { PersonalBrowserRelayShutdown } from "./personal-relay-shutdown.mjs";
|
|
14
|
+
import { browserErrorCategory, personalBrowserDiagnosticObserver } from "./personal-diagnostics.mjs";
|
|
15
|
+
const Binding = z.object({ origin: z.string(), installationId: z.string(), userId: z.string(), resourceId: z.string(), instanceId: z.string() }).strict();
|
|
16
|
+
function atomicJson(filename, value) {
|
|
17
|
+
const temporary = `${filename}.${randomUUID()}.next`;
|
|
18
|
+
const fd = fs.openSync(temporary, "wx", 384);
|
|
19
|
+
try {
|
|
20
|
+
fs.writeFileSync(fd, JSON.stringify(value));
|
|
21
|
+
fs.fsyncSync(fd);
|
|
22
|
+
} finally {
|
|
23
|
+
fs.closeSync(fd);
|
|
24
|
+
}
|
|
25
|
+
fs.renameSync(temporary, filename);
|
|
26
|
+
const directory = fs.openSync(path.dirname(filename), "r");
|
|
27
|
+
try {
|
|
28
|
+
fs.fsyncSync(directory);
|
|
29
|
+
} finally {
|
|
30
|
+
fs.closeSync(directory);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function personalBrowserConnection(options) {
|
|
34
|
+
const config = fs.existsSync(options.configPath) ? JSON.parse(readPrivateFile(fs.realpathSync(options.configPath), 128e3).toString()) : {};
|
|
35
|
+
const value = options.baseUrl ?? process.env.R5D_BASE_URL ?? process.env.R5DCTL_BASE_URL ?? config.baseUrl ?? "https://r5d.dev";
|
|
36
|
+
const url = new URL(value);
|
|
37
|
+
if (url.protocol !== "https:" || url.origin !== String(value).replace(/\/+$/, "") || url.username || url.password) throw new Error("Personal browser requires an HTTPS server origin");
|
|
38
|
+
const token = options.token ?? process.env.R5D_BROWSER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.R5DCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.R5DCTL_API_KEY ?? config.apiKey;
|
|
39
|
+
if (typeof token !== "string" || !token) throw new Error("Run r5dctl auth login or supply --token/--api-key");
|
|
40
|
+
return { origin: url.origin, token };
|
|
41
|
+
}
|
|
42
|
+
function personalBrowserStateDirectory(origin, rootPath) {
|
|
43
|
+
const base = rootPath === void 0 ? path.join(fs.realpathSync(os.homedir()), ".r5d", "personal-browser") : path.resolve(rootPath);
|
|
44
|
+
const root = path.join(base, createHash("sha256").update(origin).digest("hex").slice(0, 24));
|
|
45
|
+
fs.mkdirSync(root, { recursive: true, mode: 448 });
|
|
46
|
+
assertPrivatePath(root, "directory");
|
|
47
|
+
return root;
|
|
48
|
+
}
|
|
49
|
+
async function startPersonalBrowser(options, version) {
|
|
50
|
+
const diagnostic = personalBrowserDiagnosticObserver(process.env.R5D_BROWSER_DIAGNOSTICS === "1");
|
|
51
|
+
const { origin, token } = personalBrowserConnection(options);
|
|
52
|
+
const root = personalBrowserStateDirectory(origin, options.rootPath);
|
|
53
|
+
const bindingPath = path.join(root, "binding.json");
|
|
54
|
+
const previous = fs.existsSync(bindingPath) ? Binding.parse(JSON.parse(readPrivateFile(bindingPath, 128e3).toString())) : void 0;
|
|
55
|
+
const resourceId = previous?.resourceId ?? randomUUID(), instanceId = previous?.instanceId ?? randomUUID();
|
|
56
|
+
const hostInfo = { hostname: os.hostname(), platform: process.platform, arch: process.arch, version, capabilities: { readPage: true, passwordRedaction: true, refActions: true, waitFor: true, formInput: true, typedKeys: true, screenshotFullPage: true, viewportSizing: true, fileUploads: true, runJsArtifacts: true } };
|
|
57
|
+
const request = async (route2, body, pollSignal) => {
|
|
58
|
+
const timeout = AbortSignal.timeout(3e4);
|
|
59
|
+
const response = await fetch(`${origin}/api/personal/resources${route2}`, { method: "POST", redirect: "error", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body), signal: pollSignal ? AbortSignal.any([timeout, pollSignal]) : timeout });
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const error = await response.json().catch(() => null);
|
|
62
|
+
const code = typeof error?.error === "string" && /^[a-z_]{1,100}$/.test(error.error) ? error.error : void 0;
|
|
63
|
+
throw new PersonalForwardResponseError(response.status, response.status === 401 || response.status === 403 ? "Personal browser credential is no longer authorized" : `Personal browser relay returned ${response.status}${code ? ` (${code})` : ""}`, code);
|
|
64
|
+
}
|
|
65
|
+
return await response.json();
|
|
66
|
+
};
|
|
67
|
+
const enrolled = await request("/register", { kind: "browser", resourceId, instanceId, label: `browser-${resourceId.slice(0, 12)}`, metadata: { hostInfo, tabs: [], version } });
|
|
68
|
+
const binding = Binding.parse({ origin, installationId: enrolled.installationId, userId: enrolled.userId, resourceId, instanceId });
|
|
69
|
+
if (previous && JSON.stringify(binding) !== JSON.stringify(previous)) throw new Error("Browser profile belongs to a different account or environment");
|
|
70
|
+
atomicJson(bindingPath, binding);
|
|
71
|
+
const downloads = path.join(root, "downloads"), uploads = path.join(root, "uploads");
|
|
72
|
+
fs.mkdirSync(downloads, { recursive: true, mode: 448 });
|
|
73
|
+
fs.mkdirSync(uploads, { recursive: true, mode: 448 });
|
|
74
|
+
const launch = await launchStableChrome({ profilePath: path.resolve(options.profilePath ?? path.join(root, "chrome-profile")), chromePath: options.chromePath, playwrightDownloadsPath: path.join(downloads, ".playwright"), lockPath: path.join(root, "run.lock") });
|
|
75
|
+
const runtime = new BrowserRuntime(launch.context, downloads, uploads);
|
|
76
|
+
let keeper;
|
|
77
|
+
const shutdown = new PersonalBrowserRelayShutdown();
|
|
78
|
+
let heartbeatBusy = false;
|
|
79
|
+
const route = `/browser/${resourceId}`;
|
|
80
|
+
const forwards = new PersonalPortForwardManager(async (value) => (await request(`${route}/forward`, { instanceId, request: value })).result, diagnostic);
|
|
81
|
+
const stop = () => shutdown.stop();
|
|
82
|
+
const events = process;
|
|
83
|
+
events.on("SIGINT", stop);
|
|
84
|
+
events.on("SIGTERM", stop);
|
|
85
|
+
let metadata = { hostInfo: { ...hostInfo, chromiumVersion: launch.chromeVersion }, version, tabs: await runtime.listTabs() };
|
|
86
|
+
let pollSequence = 0, appliedPollSequence = 0;
|
|
87
|
+
const poll = async () => {
|
|
88
|
+
const sequence = ++pollSequence;
|
|
89
|
+
const result = await request(`${route}/poll`, { instanceId, metadata: { ...metadata, portForwards: forwards.list() } }, shutdown.pollSignal);
|
|
90
|
+
if (result.installationId !== binding.installationId || result.userId !== binding.userId || result.instanceId !== instanceId || result.resourceId !== resourceId) throw new Error("Browser relay identity mismatch");
|
|
91
|
+
if (!shutdown.stopping && sequence > appliedPollSequence) {
|
|
92
|
+
appliedPollSequence = sequence;
|
|
93
|
+
await forwards.reconcile(result.forwards ?? []);
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
};
|
|
97
|
+
const heartbeat = setInterval(() => {
|
|
98
|
+
if (heartbeatBusy || shutdown.stopping) return;
|
|
99
|
+
heartbeatBusy = true;
|
|
100
|
+
void poll().catch(() => {
|
|
101
|
+
}).finally(() => {
|
|
102
|
+
heartbeatBusy = false;
|
|
103
|
+
});
|
|
104
|
+
}, 1e4);
|
|
105
|
+
void launch.disconnected.then(stop);
|
|
106
|
+
void launch.exited.then(stop);
|
|
107
|
+
try {
|
|
108
|
+
const executor = browserRuntimeExecutor(runtime);
|
|
109
|
+
keeper = new BrowserKeeper({ directory: path.join(root, "ledger"), installationId: binding.installationId, userId: binding.userId, instanceId, releaseId: "personal-browser-keeper", executor: {
|
|
110
|
+
execute: async (operation) => {
|
|
111
|
+
try {
|
|
112
|
+
return await executor.execute(operation);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
diagnostic({ phase: "action-error", action: operation.kind, category: browserErrorCategory(error) });
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} });
|
|
119
|
+
const counterPath = path.join(root, "epoch.json");
|
|
120
|
+
let epoch = fs.existsSync(counterPath) ? z.number().int().nonnegative().parse(JSON.parse(readPrivateFile(counterPath, 100).toString())) : 0;
|
|
121
|
+
const processor = new PersonalBrowserProcessor(keeper, binding, () => {
|
|
122
|
+
epoch = Math.max(epoch + 1, Date.now());
|
|
123
|
+
if (!Number.isSafeInteger(epoch)) throw new Error("Browser epoch exhausted");
|
|
124
|
+
atomicJson(counterPath, epoch);
|
|
125
|
+
return epoch;
|
|
126
|
+
});
|
|
127
|
+
process.stdout.write(`[r5d-browser] Connected to ${origin}; Chrome and action receipts stay on this machine.
|
|
128
|
+
`);
|
|
129
|
+
while (!shutdown.stopping) {
|
|
130
|
+
try {
|
|
131
|
+
metadata = { ...metadata, tabs: await runtime.listTabs(), portForwards: forwards.list() };
|
|
132
|
+
const polled = await poll();
|
|
133
|
+
if (polled.installationId !== binding.installationId || polled.userId !== binding.userId || polled.instanceId !== instanceId || polled.resourceId !== resourceId) throw new Error("Browser relay identity mismatch");
|
|
134
|
+
await shutdown.dispatch(polled.actions ?? [], (action) => processor.execute(action), (receipt) => request(`${route}/ack`, { instanceId, ...receipt }));
|
|
135
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
136
|
+
} catch {
|
|
137
|
+
if (!shutdown.stopping) await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
shutdown.stop();
|
|
142
|
+
clearInterval(heartbeat);
|
|
143
|
+
try {
|
|
144
|
+
await forwards.close();
|
|
145
|
+
await keeper?.close();
|
|
146
|
+
runtime.closeUploads();
|
|
147
|
+
await runtime.drainDownloads();
|
|
148
|
+
await launch.close();
|
|
149
|
+
} finally {
|
|
150
|
+
events.off("SIGINT", stop);
|
|
151
|
+
events.off("SIGTERM", stop);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export {
|
|
156
|
+
personalBrowserConnection,
|
|
157
|
+
personalBrowserStateDirectory,
|
|
158
|
+
startPersonalBrowser
|
|
159
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import net, {} from "node:net";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
const Definition = z.object({ forwardId: z.string().min(1).max(160), browserPort: z.number().int().min(1).max(65535) }).strict();
|
|
5
|
+
class PersonalForwardResponseError extends Error {
|
|
6
|
+
constructor(status, message, code) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
status;
|
|
12
|
+
code;
|
|
13
|
+
}
|
|
14
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
class PersonalPortForwardManager {
|
|
16
|
+
constructor(call, observer) {
|
|
17
|
+
this.call = call;
|
|
18
|
+
this.observer = observer;
|
|
19
|
+
}
|
|
20
|
+
call;
|
|
21
|
+
observer;
|
|
22
|
+
active = /* @__PURE__ */ new Map();
|
|
23
|
+
failures = /* @__PURE__ */ new Map();
|
|
24
|
+
queue = Promise.resolve();
|
|
25
|
+
stopping = false;
|
|
26
|
+
observe(event) {
|
|
27
|
+
try {
|
|
28
|
+
this.observer?.(event);
|
|
29
|
+
} catch {
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
list() {
|
|
33
|
+
return [...this.active.values()].map((value) => ({ forwardId: value.definition.forwardId, ready: true, activeConnections: value.streams.size, lastError: value.lastError })).concat([...this.failures].map(([forwardId, lastError]) => ({ forwardId, ready: false, activeConnections: 0, lastError })));
|
|
34
|
+
}
|
|
35
|
+
reconcile(raw) {
|
|
36
|
+
const definitions = z.array(Definition).max(32).parse(raw);
|
|
37
|
+
const next = this.queue.then(async () => {
|
|
38
|
+
for (const [id, value] of this.active) {
|
|
39
|
+
const expected = definitions.find((definition) => definition.forwardId === id);
|
|
40
|
+
if (!expected || expected.browserPort !== value.definition.browserPort) await this.stop(value);
|
|
41
|
+
}
|
|
42
|
+
for (const id of this.failures.keys()) if (!definitions.some((row) => row.forwardId === id)) this.failures.delete(id);
|
|
43
|
+
if (this.stopping) return;
|
|
44
|
+
for (const definition of definitions) {
|
|
45
|
+
if (this.active.has(definition.forwardId) || this.failures.has(definition.forwardId)) continue;
|
|
46
|
+
try {
|
|
47
|
+
await this.start(definition);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
this.failures.set(definition.forwardId, { error: error instanceof Error ? error.message : "Could not bind browser loopback port", at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
this.queue = next.catch(() => {
|
|
54
|
+
});
|
|
55
|
+
return next;
|
|
56
|
+
}
|
|
57
|
+
async start(definition) {
|
|
58
|
+
const active = { definition, servers: [], streams: /* @__PURE__ */ new Set(), lastError: null };
|
|
59
|
+
try {
|
|
60
|
+
for (const host of ["127.0.0.1", "::1"]) {
|
|
61
|
+
const server = net.createServer({ allowHalfOpen: true }, (socket) => this.accept(active, socket));
|
|
62
|
+
active.servers.push(server);
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
server.once("error", reject);
|
|
65
|
+
server.listen({ host, port: definition.browserPort, ipv6Only: host === "::1", exclusive: true }, () => {
|
|
66
|
+
server.off("error", reject);
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
server.on("error", (error) => {
|
|
71
|
+
active.lastError = { error: error.message, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
this.active.set(definition.forwardId, active);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
await this.stop(active);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async stop(active) {
|
|
81
|
+
this.active.delete(active.definition.forwardId);
|
|
82
|
+
for (const stream of active.streams) {
|
|
83
|
+
stream.closed = true;
|
|
84
|
+
stream.socket.destroy();
|
|
85
|
+
}
|
|
86
|
+
await Promise.all(active.servers.map((server) => new Promise((resolve) => {
|
|
87
|
+
if (!server.listening) resolve();
|
|
88
|
+
else server.close(() => resolve());
|
|
89
|
+
})));
|
|
90
|
+
}
|
|
91
|
+
accept(active, socket) {
|
|
92
|
+
if (active.streams.size >= 32 || this.stopping) {
|
|
93
|
+
socket.destroy();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.observe({ phase: "accepted" });
|
|
97
|
+
const stream = { id: randomUUID(), socket, closed: false, closeSent: false };
|
|
98
|
+
active.streams.add(stream);
|
|
99
|
+
socket.pause();
|
|
100
|
+
socket.setNoDelay(true);
|
|
101
|
+
let writeOffset = 0, readOffset = 0, sequence = 0;
|
|
102
|
+
const action = async (command, closing = false) => {
|
|
103
|
+
const request = { forwardId: active.definition.forwardId, streamId: stream.id, requestId: `${stream.id}-${++sequence}`, command };
|
|
104
|
+
for (; ; ) {
|
|
105
|
+
if (this.stopping || stream.closed && !closing) throw new Error("Browser stream closed");
|
|
106
|
+
try {
|
|
107
|
+
this.observe({ phase: "request", method: command.method, ...command.method === "write" ? { bytes: Buffer.byteLength(command.base64 ?? "", "base64") } : {} });
|
|
108
|
+
const result = await this.call(request);
|
|
109
|
+
this.observe({ phase: "response", method: command.method, ...command.method === "read" && typeof result?.base64 === "string" ? { bytes: Buffer.byteLength(result.base64, "base64") } : {} });
|
|
110
|
+
return result;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.observe({ phase: "failure", method: command.method, ...error instanceof PersonalForwardResponseError ? { status: error.status, code: error.code } : {} });
|
|
113
|
+
active.lastError = { error: error instanceof PersonalForwardResponseError ? error.message : "Personal forwarding request lost its acknowledgement", at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
114
|
+
if (error instanceof PersonalForwardResponseError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status)) throw error;
|
|
115
|
+
await delay(200);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const cleanup = () => {
|
|
120
|
+
if (stream.closeSent) return;
|
|
121
|
+
stream.closed = true;
|
|
122
|
+
stream.closeSent = true;
|
|
123
|
+
active.streams.delete(stream);
|
|
124
|
+
socket.destroy();
|
|
125
|
+
this.observe({ phase: "closed" });
|
|
126
|
+
if (!this.stopping) void action({ method: "close" }, true).catch(() => {
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
const fail = (error) => {
|
|
130
|
+
active.lastError = { error: error instanceof Error ? error.message : "Browser forwarding failed", at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
131
|
+
cleanup();
|
|
132
|
+
};
|
|
133
|
+
const connected = action({ method: "connect" }).then((result) => {
|
|
134
|
+
if (result?.connected !== true) throw new Error("Worker did not acknowledge the TCP connection");
|
|
135
|
+
});
|
|
136
|
+
let writes = connected;
|
|
137
|
+
const write = (bytes, end = false) => {
|
|
138
|
+
socket.pause();
|
|
139
|
+
writes = writes.then(async () => {
|
|
140
|
+
for (let start = 0; start < bytes.length || end && start === 0; start += 32 * 1024) {
|
|
141
|
+
const chunk = bytes.subarray(start, start + 32 * 1024);
|
|
142
|
+
const result = await action({ method: "write", offset: writeOffset, base64: chunk.toString("base64"), ...end ? { end: true } : {} });
|
|
143
|
+
if (result?.nextOffset !== writeOffset + chunk.length) throw new Error("Worker TCP write cursor mismatch");
|
|
144
|
+
writeOffset = result.nextOffset;
|
|
145
|
+
}
|
|
146
|
+
if (!stream.closed && !end) socket.resume();
|
|
147
|
+
}).catch(fail);
|
|
148
|
+
};
|
|
149
|
+
socket.on("data", (chunk) => {
|
|
150
|
+
this.observe({ phase: "received", bytes: chunk.length });
|
|
151
|
+
write(Buffer.from(chunk));
|
|
152
|
+
});
|
|
153
|
+
socket.on("end", () => write(Buffer.alloc(0), true));
|
|
154
|
+
socket.on("error", fail);
|
|
155
|
+
socket.on("close", cleanup);
|
|
156
|
+
socket.pause();
|
|
157
|
+
void connected.then(async () => {
|
|
158
|
+
socket.resume();
|
|
159
|
+
while (!stream.closed && !this.stopping) {
|
|
160
|
+
const result = await action({ method: "read", after: readOffset });
|
|
161
|
+
const parsed = z.object({ base64: z.string().max(9e4), nextOffset: z.number().int().nonnegative(), eof: z.boolean() }).parse(result);
|
|
162
|
+
const bytes = Buffer.from(parsed.base64, "base64");
|
|
163
|
+
if (parsed.nextOffset !== readOffset + bytes.length) throw new Error("Worker TCP read cursor mismatch");
|
|
164
|
+
if (bytes.length) await new Promise((resolve, reject) => socket.write(bytes, (error) => error ? reject(error) : resolve()));
|
|
165
|
+
readOffset = parsed.nextOffset;
|
|
166
|
+
if (parsed.eof) {
|
|
167
|
+
socket.end();
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
if (!bytes.length) await delay(100);
|
|
171
|
+
}
|
|
172
|
+
}).catch(fail);
|
|
173
|
+
}
|
|
174
|
+
async close() {
|
|
175
|
+
this.stopping = true;
|
|
176
|
+
await this.queue;
|
|
177
|
+
await Promise.all([...this.active.values()].map((active) => this.stop(active)));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export {
|
|
181
|
+
PersonalForwardResponseError,
|
|
182
|
+
PersonalPortForwardManager
|
|
183
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { BrowserKeeper } from "./runtime/keeper.mjs";
|
|
2
|
+
import { BrowserAction, BROWSER_KEEPER_CAPABILITY } from "./runtime/protocol.mjs";
|
|
3
|
+
import { canonicalJson } from "@ricsam/r5d-api/runtime-protocol";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
const PersonalBrowserAction = z.object({ id: z.string(), requestHash: z.string().regex(/^[a-f0-9]{64}$/), request: z.object({ action: BrowserAction, input: z.record(z.string(), z.unknown()) }).strict() }).strict();
|
|
7
|
+
class PersonalBrowserProcessor {
|
|
8
|
+
constructor(keeper, identity, nextEpoch) {
|
|
9
|
+
this.keeper = keeper;
|
|
10
|
+
this.identity = identity;
|
|
11
|
+
this.nextEpoch = nextEpoch;
|
|
12
|
+
}
|
|
13
|
+
keeper;
|
|
14
|
+
identity;
|
|
15
|
+
nextEpoch;
|
|
16
|
+
epoch = Date.now();
|
|
17
|
+
async execute(raw) {
|
|
18
|
+
const message = PersonalBrowserAction.parse(raw);
|
|
19
|
+
const hash = createHash("sha256").update(canonicalJson(message.request)).digest("hex");
|
|
20
|
+
if (hash !== message.requestHash) throw new Error("Browser request hash mismatch");
|
|
21
|
+
const { installationId, userId, instanceId } = this.identity;
|
|
22
|
+
const hello = { installationId, instanceId, releaseId: "personal-browser-client", role: "browser-adapter", protocol: { min: 1, max: 1 }, capabilities: [BROWSER_KEEPER_CAPABILITY] };
|
|
23
|
+
const userAction = ["list_tabs", "list_downloads", "get_download", "open_tab"].includes(message.request.action);
|
|
24
|
+
const operation = { protocol: 1, installationId, userId, operationId: message.id, kind: message.request.action, payload: message.request.input, fence: { installationId, resourceType: userAction ? "browser-user" : "browser-tab", resourceId: userAction ? userId : z.string().min(1).parse(message.request.input.tabId), ownerId: instanceId, epoch: this.nextEpoch ? this.nextEpoch() : this.epoch = Math.max(this.epoch + 1, Date.now()) } };
|
|
25
|
+
const principal = { installationId, userId, ownerId: instanceId };
|
|
26
|
+
await this.keeper.handle({ hello, request: { method: "claim", userId, fence: operation.fence, expiresAt: Date.now() + 12e4 } }, principal);
|
|
27
|
+
return await this.keeper.handle({ hello, request: { method: "execute", operation } }, principal);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export {
|
|
31
|
+
PersonalBrowserAction,
|
|
32
|
+
PersonalBrowserProcessor
|
|
33
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
class PersonalBrowserRelayShutdown {
|
|
2
|
+
polls = new AbortController();
|
|
3
|
+
get stopping() {
|
|
4
|
+
return this.polls.signal.aborted;
|
|
5
|
+
}
|
|
6
|
+
get pollSignal() {
|
|
7
|
+
return this.polls.signal;
|
|
8
|
+
}
|
|
9
|
+
stop() {
|
|
10
|
+
this.polls.abort();
|
|
11
|
+
}
|
|
12
|
+
async dispatch(actions, execute, acknowledge) {
|
|
13
|
+
for (const action of actions) {
|
|
14
|
+
if (this.stopping) break;
|
|
15
|
+
let state = "completed", result;
|
|
16
|
+
try {
|
|
17
|
+
result = await execute(action);
|
|
18
|
+
} catch {
|
|
19
|
+
state = "unknown";
|
|
20
|
+
result = { operationId: action.id, state: "unknown", reason: "browser_boundary_failed" };
|
|
21
|
+
}
|
|
22
|
+
const receipt = { id: action.id, requestHash: action.requestHash, state, result };
|
|
23
|
+
for (; ; ) {
|
|
24
|
+
try {
|
|
25
|
+
await acknowledge(receipt);
|
|
26
|
+
break;
|
|
27
|
+
} catch {
|
|
28
|
+
if (this.stopping) break;
|
|
29
|
+
await new Promise((resolve) => {
|
|
30
|
+
const done = () => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
this.pollSignal.removeEventListener("abort", done);
|
|
33
|
+
resolve();
|
|
34
|
+
};
|
|
35
|
+
const timer = setTimeout(done, 1500);
|
|
36
|
+
this.pollSignal.addEventListener("abort", done, { once: true });
|
|
37
|
+
});
|
|
38
|
+
if (this.stopping) break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export {
|
|
45
|
+
PersonalBrowserRelayShutdown
|
|
46
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
function browserRuntimeExecutor(runtime) {
|
|
2
|
+
return {
|
|
3
|
+
execute: async (operation) => {
|
|
4
|
+
const result = await runtime.execute(operation.kind, operation.payload);
|
|
5
|
+
return JSON.parse(JSON.stringify(result ?? null));
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export {
|
|
10
|
+
browserRuntimeExecutor
|
|
11
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { chromium } from "playwright-core";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { BrowserRuntime } from "../browser-runtime.mjs";
|
|
7
|
+
import { browserRuntimeExecutor } from "./browser-executor.mjs";
|
|
8
|
+
import { freshBrowserRun } from "./isolated-root.mjs";
|
|
9
|
+
import { readPrivateFile } from "./private-paths.mjs";
|
|
10
|
+
import { BrowserKeeperError } from "./protocol.mjs";
|
|
11
|
+
const origin = z.string().refine((value) => {
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(value);
|
|
14
|
+
return url.origin === value && url.protocol === "http:" && url.hostname === "127.0.0.1" && !!url.port;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}, "Only explicit loopback fake-site HTTP origins are supported");
|
|
19
|
+
const ChromiumFactoryApproval = z.object({
|
|
20
|
+
kind: z.literal("isolated-chromium-v1"),
|
|
21
|
+
executablePath: z.string().refine(path.isAbsolute),
|
|
22
|
+
allowedOrigins: z.array(origin).min(1).max(16)
|
|
23
|
+
}).strict();
|
|
24
|
+
function loadApprovedChromiumFactory(filename, sha256) {
|
|
25
|
+
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new BrowserKeeperError("invalid_factory_digest");
|
|
26
|
+
const bytes = readPrivateFile(filename, 16 * 1024, true);
|
|
27
|
+
if (createHash("sha256").update(bytes).digest("hex") !== sha256) throw new BrowserKeeperError("factory_digest_mismatch");
|
|
28
|
+
const parsed = ChromiumFactoryApproval.safeParse(JSON.parse(bytes.toString("utf8")));
|
|
29
|
+
if (!parsed.success) throw new BrowserKeeperError("unapproved_browser_factory");
|
|
30
|
+
const stat = fs.lstatSync(parsed.data.executablePath);
|
|
31
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 18) !== 0 || (stat.mode & 73) === 0 || fs.realpathSync(parsed.data.executablePath) !== parsed.data.executablePath)
|
|
32
|
+
throw new BrowserKeeperError("unsafe_chromium_executable");
|
|
33
|
+
return parsed.data;
|
|
34
|
+
}
|
|
35
|
+
async function createIsolatedChromiumRuntime(input) {
|
|
36
|
+
const approval = ChromiumFactoryApproval.parse(input.approval);
|
|
37
|
+
const run = freshBrowserRun(input.runs);
|
|
38
|
+
const context = await chromium.launchPersistentContext(run.profile, {
|
|
39
|
+
executablePath: approval.executablePath,
|
|
40
|
+
headless: true,
|
|
41
|
+
acceptDownloads: true,
|
|
42
|
+
downloadsPath: run.downloads,
|
|
43
|
+
serviceWorkers: "block",
|
|
44
|
+
viewport: null,
|
|
45
|
+
// Never inherit account credentials, personal HOME, DISPLAY, proxy, or Chrome environment overrides.
|
|
46
|
+
env: { PATH: "/usr/bin:/bin", HOME: run.home, TMPDIR: path.dirname(input.runs), LANG: "C.UTF-8" },
|
|
47
|
+
args: [
|
|
48
|
+
"--disable-background-networking",
|
|
49
|
+
"--disable-component-update",
|
|
50
|
+
"--disable-sync",
|
|
51
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp"
|
|
52
|
+
]
|
|
53
|
+
});
|
|
54
|
+
let runtime;
|
|
55
|
+
try {
|
|
56
|
+
const allowed = new Set(approval.allowedOrigins);
|
|
57
|
+
await context.route("**/*", (route) => {
|
|
58
|
+
return allowed.has(new URL(route.request().url()).origin) ? route.continue() : route.abort("blockedbyclient");
|
|
59
|
+
});
|
|
60
|
+
await context.routeWebSocket("**/*", (socket) => socket.close());
|
|
61
|
+
const browser = context.browser();
|
|
62
|
+
if (!browser) throw new BrowserKeeperError("chromium_process_unavailable");
|
|
63
|
+
const cdp = await browser.newBrowserCDPSession();
|
|
64
|
+
const info = await cdp.send("SystemInfo.getProcessInfo");
|
|
65
|
+
await cdp.detach();
|
|
66
|
+
const pid = info.processInfo.find((entry) => entry.type === "browser")?.id;
|
|
67
|
+
if (!pid || !Number.isSafeInteger(pid)) throw new BrowserKeeperError("chromium_process_unavailable");
|
|
68
|
+
runtime = new BrowserRuntime(context, run.downloads, run.uploads);
|
|
69
|
+
const executor = browserRuntimeExecutor(runtime);
|
|
70
|
+
const identity = {
|
|
71
|
+
runId: run.id,
|
|
72
|
+
browserPid: pid,
|
|
73
|
+
keeperPid: process.pid,
|
|
74
|
+
profile: run.profile,
|
|
75
|
+
downloads: run.downloads,
|
|
76
|
+
uploads: run.uploads
|
|
77
|
+
};
|
|
78
|
+
fs.writeFileSync(path.join(run.directory, "identity.json"), JSON.stringify(identity), { flag: "wx", mode: 384 });
|
|
79
|
+
return {
|
|
80
|
+
identity,
|
|
81
|
+
executor: {
|
|
82
|
+
execute: executor.execute,
|
|
83
|
+
dispose: async () => {
|
|
84
|
+
await context.close();
|
|
85
|
+
runtime?.closeUploads();
|
|
86
|
+
fs.rmSync(run.directory, { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
} catch (error) {
|
|
91
|
+
await context.close();
|
|
92
|
+
runtime?.closeUploads();
|
|
93
|
+
fs.rmSync(run.directory, { recursive: true });
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
ChromiumFactoryApproval,
|
|
99
|
+
createIsolatedChromiumRuntime,
|
|
100
|
+
loadApprovedChromiumFactory
|
|
101
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { BrowserKeeper } from "./keeper.mjs";
|
|
5
|
+
import { BrowserKeeperError } from "./protocol.mjs";
|
|
6
|
+
async function serveBrowserKeeper(options) {
|
|
7
|
+
const { keeper, socketPath, authenticate } = options;
|
|
8
|
+
const directory = fs.lstatSync(path.dirname(socketPath));
|
|
9
|
+
if (!directory.isDirectory() || directory.isSymbolicLink() || (directory.mode & 63) !== 0 || directory.uid !== process.getuid?.()) {
|
|
10
|
+
throw new BrowserKeeperError("unsafe_socket_directory");
|
|
11
|
+
}
|
|
12
|
+
const maxFrameBytes = options.maxFrameBytes ?? 3 * 1024 * 1024;
|
|
13
|
+
const maxConnections = options.maxConnections ?? 64;
|
|
14
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
15
|
+
const server = net.createServer((socket) => {
|
|
16
|
+
if (sockets.size >= maxConnections) {
|
|
17
|
+
socket.destroy();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
sockets.add(socket);
|
|
21
|
+
socket.on("error", () => {
|
|
22
|
+
});
|
|
23
|
+
socket.once("close", () => sockets.delete(socket));
|
|
24
|
+
socket.setTimeout(6e4, () => socket.destroy());
|
|
25
|
+
let buffer = Buffer.alloc(0);
|
|
26
|
+
let handled = false;
|
|
27
|
+
const reply = (data) => {
|
|
28
|
+
if (!socket.destroyed) socket.end(`${JSON.stringify(data)}
|
|
29
|
+
`);
|
|
30
|
+
};
|
|
31
|
+
socket.on("data", (chunk) => {
|
|
32
|
+
if (handled) {
|
|
33
|
+
socket.destroy();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (buffer.byteLength + chunk.byteLength > maxFrameBytes) {
|
|
37
|
+
socket.destroy();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
41
|
+
const end = buffer.indexOf(10);
|
|
42
|
+
if (end === -1) return;
|
|
43
|
+
handled = true;
|
|
44
|
+
if (end !== buffer.byteLength - 1) {
|
|
45
|
+
socket.destroy();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let principal;
|
|
49
|
+
let message;
|
|
50
|
+
try {
|
|
51
|
+
const value = JSON.parse(buffer.subarray(0, end).toString("utf8"));
|
|
52
|
+
buffer = Buffer.alloc(0);
|
|
53
|
+
if (!value || typeof value !== "object" || Object.keys(value).sort().join(",") !== "message,token" || typeof value.token !== "string" || value.token.length < 16 || value.token.length > 4096)
|
|
54
|
+
throw new Error();
|
|
55
|
+
principal = authenticate(value.token);
|
|
56
|
+
message = value.message;
|
|
57
|
+
} catch {
|
|
58
|
+
reply({ ok: false, code: "invalid_request" });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!principal) {
|
|
62
|
+
reply({ ok: false, code: "unauthorized_peer" });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
void keeper.handle(message, principal).then(
|
|
66
|
+
(result) => reply({ ok: true, result }),
|
|
67
|
+
(error) => reply({ ok: false, code: error instanceof BrowserKeeperError ? error.code : "keeper_error" })
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
await new Promise((resolve, reject) => {
|
|
72
|
+
server.once("error", reject);
|
|
73
|
+
server.listen(socketPath, () => {
|
|
74
|
+
try {
|
|
75
|
+
fs.chmodSync(socketPath, 384);
|
|
76
|
+
resolve();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
server.close();
|
|
79
|
+
reject(error);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
async close() {
|
|
85
|
+
for (const socket of sockets) socket.destroy();
|
|
86
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function callBrowserKeeper(options) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const socket = net.createConnection(options.socketPath);
|
|
93
|
+
let buffer = Buffer.alloc(0);
|
|
94
|
+
let settled = false;
|
|
95
|
+
const finish = (error, result) => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
socket.destroy();
|
|
99
|
+
if (error) reject(error);
|
|
100
|
+
else resolve(result);
|
|
101
|
+
};
|
|
102
|
+
socket.setTimeout(options.timeoutMs ?? 6e4, () => finish(new BrowserKeeperError("response_lost")));
|
|
103
|
+
socket.once("error", () => finish(new BrowserKeeperError("transport_unavailable")));
|
|
104
|
+
socket.once("close", () => finish(new BrowserKeeperError("response_lost")));
|
|
105
|
+
socket.once("connect", () => socket.write(`${JSON.stringify({ token: options.token, message: options.message })}
|
|
106
|
+
`));
|
|
107
|
+
socket.on("data", (chunk) => {
|
|
108
|
+
if (buffer.byteLength + chunk.byteLength > (options.maxReplyBytes ?? 17 * 1024 * 1024)) {
|
|
109
|
+
finish(new BrowserKeeperError("reply_too_large"));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
113
|
+
const end = buffer.indexOf(10);
|
|
114
|
+
if (end === -1) return;
|
|
115
|
+
try {
|
|
116
|
+
const reply = JSON.parse(buffer.subarray(0, end).toString("utf8"));
|
|
117
|
+
if (reply.ok === true) finish(null, reply.result);
|
|
118
|
+
else finish(new BrowserKeeperError(typeof reply.code === "string" ? reply.code : "invalid_reply"));
|
|
119
|
+
} catch {
|
|
120
|
+
finish(new BrowserKeeperError("invalid_reply"));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export {
|
|
126
|
+
callBrowserKeeper,
|
|
127
|
+
serveBrowserKeeper
|
|
128
|
+
};
|