@kontextmind/kxm 0.7.132 → 0.7.133
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +2 -0
- package/docs/README.md +4 -3
- package/docs/adr/ADR-0002-browser-automation-steel-doks.md +1 -1
- package/docs/adr/ADR-0005-obscura-default-playwright.md +85 -0
- package/docs/adr/README.md +1 -0
- package/docs/contributing/ci-and-release.md +1 -0
- package/docs/contributing/development.md +1 -0
- package/docs/contributing/test-matrix.md +2 -0
- package/docs/guides/agent-skills.md +2 -2
- package/docs/guides/browser-automation.md +23 -7
- package/docs/kb/how-to-connect-playwright-to-obscura.md +69 -0
- package/docs/kb/how-to-connect-playwright-to-steel.md +5 -3
- package/docs/kb/why-automation-opened-different-browser.md +14 -11
- package/docs/prompts/browser-repro-fix.md +8 -8
- package/docs/reference/configuration.md +17 -1
- package/package.json +3 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime.js +37 -0
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-browser-session/SKILL.md +4 -2
- package/plugins/kxm/skills/kxm-browser-verify/SKILL.md +40 -30
- package/plugins/kxm/src/browser.ts +53 -2
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/scripts/obscura.mjs +424 -0
package/plugins/kxm/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kxm-browser-session
|
|
3
|
-
description: Start, attach to, inspect, and release self-hosted Steel browser sessions with lifecycle safety and timeout controls.
|
|
3
|
+
description: Start, attach to, inspect, and release self-hosted Steel browser sessions with lifecycle safety and timeout controls. Playwright testing uses Obscura unless KXM_BROWSER=steel.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# KXM Browser Session Management
|
|
7
7
|
|
|
8
8
|
Use this skill to create, inspect, attach automation tools to, and release isolated browser sessions running on your self-hosted Steel deployment. Set `STEEL_API_URL` (and optionally `STEEL_UI_URL`) to your deployment; KXM does not provide one.
|
|
9
9
|
|
|
10
|
+
Playwright testing and verification use Obscura by default (`resolveBrowserCdpEndpoint()`, or `npm run e2e`). Use this skill's Steel session for human takeover, MFA, and the live session viewer. Attach Playwright to that session only when `KXM_BROWSER=steel`.
|
|
11
|
+
|
|
10
12
|
## Purpose & Scope
|
|
11
13
|
|
|
12
14
|
- Provide isolated, remote Chrome browser execution for AI agents and human operators.
|
|
@@ -64,7 +66,7 @@ printf 'x-steel-api-key: %s\n' "$STEEL_API_KEY" | curl -sS -X POST "$STEEL_API_U
|
|
|
64
66
|
|
|
65
67
|
### 2. Attaching Automation Clients
|
|
66
68
|
|
|
67
|
-
- **Playwright**:
|
|
69
|
+
- **Playwright**: For tests, connect to Obscura with `chromium.connectOverCDP()` through the worker-scoped `browser` fixture. For a Steel takeover session, set `KXM_BROWSER=steel` and connect to the URL from `resolveBrowserCdpEndpoint(session)`.
|
|
68
70
|
- **agent-browser**: Connect using `agent-browser --cdp "<cdpUrl>"`.
|
|
69
71
|
|
|
70
72
|
### 3. Inspecting Session State
|
|
@@ -1,71 +1,81 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kxm-browser-verify
|
|
3
|
-
description: Reproduce UI bugs, collect diagnostic evidence, and create permanent Playwright regression tests
|
|
3
|
+
description: Reproduce UI bugs, collect diagnostic evidence, and create permanent Playwright regression tests. Obscura is the default browser. Steel is only for human takeover, MFA, and the live session viewer.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# KXM Playwright Reproduction and Verification
|
|
7
7
|
|
|
8
8
|
Use this skill to systematically reproduce UI issues, collect diagnostic evidence, create durable Playwright tests, verify failures before fixes, and confirm green assertions afterward.
|
|
9
9
|
|
|
10
|
+
Obscura is the default for every Playwright test and verification run. Steel remains for human takeover, MFA, and the live session viewer. Set `KXM_BROWSER=steel` only for that Steel path.
|
|
11
|
+
|
|
10
12
|
## Purpose & Scope
|
|
11
13
|
|
|
12
14
|
- Support the standard KXM verification loop:
|
|
13
15
|
`Request -> Reproduce -> Collect Diagnostic Evidence -> Create Playwright Test -> Demonstrate Failure -> Implement Fix -> Demonstrate Success`.
|
|
14
|
-
- Connect Playwright
|
|
15
|
-
- Produce deterministic
|
|
16
|
+
- Connect Playwright through the worker-scoped `browser` fixture and `chromium.connectOverCDP()`. Obscura does not speak Playwright's own protocol (`chromium.connect`, `use.connectOptions`).
|
|
17
|
+
- Produce deterministic tests and sanitized evidence. Leave video recording off. Obscura does not support it.
|
|
16
18
|
|
|
17
19
|
## Test Lifecycle & Workflow
|
|
18
20
|
|
|
19
21
|
```text
|
|
20
22
|
1. REPRODUCE
|
|
21
|
-
└─ Run
|
|
23
|
+
└─ Run the flow against Obscura (KXM_BROWSER=steel only when the bug is inside a Steel takeover session).
|
|
22
24
|
|
|
23
25
|
2. COLLECT DIAGNOSTIC EVIDENCE
|
|
24
|
-
└─ Capture network logs, console errors, and before-state screenshot.
|
|
26
|
+
└─ Capture network logs, console errors, and a before-state screenshot.
|
|
25
27
|
|
|
26
28
|
3. WRITE PLAYWRIGHT TEST
|
|
27
|
-
└─ Author durable test with explicit assertions against semantic locators.
|
|
29
|
+
└─ Author a durable test with explicit assertions against semantic locators.
|
|
28
30
|
|
|
29
31
|
4. DEMONSTRATE FAILURE (RED)
|
|
30
|
-
└─ Run test against unfixed application
|
|
32
|
+
└─ Run the test against the unfixed application and confirm the failure matches the report.
|
|
31
33
|
|
|
32
34
|
5. IMPLEMENT FIX
|
|
33
35
|
└─ Apply code modifications within repository scope.
|
|
34
36
|
|
|
35
37
|
6. DEMONSTRATE SUCCESS (GREEN)
|
|
36
|
-
└─ Re-run Playwright test
|
|
38
|
+
└─ Re-run the Playwright test and confirm the assertions pass.
|
|
37
39
|
```
|
|
38
40
|
|
|
39
|
-
## Connecting Playwright to
|
|
40
|
-
|
|
41
|
-
```typescript
|
|
42
|
-
import { test, expect, chromium } from "@playwright/test";
|
|
41
|
+
## Connecting Playwright to Obscura
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
const cdpUrl = process.env.STEEL_CDP_URL;
|
|
46
|
-
if (!cdpUrl) {
|
|
47
|
-
throw new Error("STEEL_CDP_URL environment variable is required");
|
|
48
|
-
}
|
|
43
|
+
Start Obscura, then run Playwright. Do not run `playwright install`.
|
|
49
44
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
```bash
|
|
46
|
+
node scripts/obscura.mjs --ensure
|
|
47
|
+
npm run e2e
|
|
48
|
+
```
|
|
54
49
|
|
|
55
|
-
|
|
56
|
-
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
|
50
|
+
`npm run e2e` runs the launcher and then `playwright test`. The endpoint comes from `resolveObscuraCdpEndpoint()` (`OBSCURA_CDP_URL`, or `http://127.0.0.1:${OBSCURA_PORT:-9222}`).
|
|
57
51
|
|
|
58
|
-
|
|
59
|
-
await page.getByRole("button", { name: "Save Changes" }).click();
|
|
60
|
-
await expect(page.getByText("Changes saved successfully")).toBeVisible();
|
|
52
|
+
Override the worker-scoped `browser` fixture:
|
|
61
53
|
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
```typescript
|
|
55
|
+
import { test as base, chromium, type Browser } from "@playwright/test";
|
|
56
|
+
import { resolveObscuraCdpEndpoint } from "@kontextmind/kxm/runtime";
|
|
57
|
+
|
|
58
|
+
export const test = base.extend<{}, { browser: Browser }>({
|
|
59
|
+
browser: [async ({}, use) => {
|
|
60
|
+
const browser = await chromium.connectOverCDP(resolveObscuraCdpEndpoint());
|
|
61
|
+
await use(browser);
|
|
62
|
+
await browser.close();
|
|
63
|
+
}, { scope: "worker" }],
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
export { expect } from "@playwright/test";
|
|
65
67
|
```
|
|
66
68
|
|
|
69
|
+
In `playwright.config.ts`, set `video: "off"` and `trace: "retain-on-failure"`. `newContext()` isolation works. `storageState` is limited. The browser timezone defaults to `Europe/Berlin` unless the Obscura process has `OBSCURA_TIMEZONE` set. Obscura ignores `HTTP_PROXY` and `HTTPS_PROXY`.
|
|
70
|
+
|
|
71
|
+
Local pages require the launcher flag `--allow-private-network` (the launcher always passes it). Without that flag, `page.goto("http://127.0.0.1:...")` fails with `Access to private/internal IP address`.
|
|
72
|
+
|
|
73
|
+
## Steel, only for takeover
|
|
74
|
+
|
|
75
|
+
When the case is human takeover, MFA, or the live session viewer, set `KXM_BROWSER=steel` and attach to the Steel session CDP URL from `resolveBrowserCdpEndpoint(session)`. That path is `formatCDPEndpoint()`. Closing the Playwright browser disconnects the client and does not release the Steel session.
|
|
76
|
+
|
|
67
77
|
## Artifact Retention & Sanitization
|
|
68
78
|
|
|
69
79
|
- Save test traces to `.kxm/artifacts/browser/trace-<runId>.zip`.
|
|
70
|
-
- Sanitize recorded traces and screenshots:
|
|
71
|
-
-
|
|
80
|
+
- Sanitize recorded traces and screenshots: mask password fields, authorization headers, and personal data.
|
|
81
|
+
- Keep permanent regression tests under `test/e2e/`. Scratch reproduction scripts stay out of that directory.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* KXM Browser Automation & Steel Session Client
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Playwright testing and verification use Obscura by default
|
|
5
|
+
* (`resolveBrowserCdpEndpoint()`). Steel remains the client for human
|
|
6
|
+
* takeover, MFA, and the live session viewer (`KXM_BROWSER=steel`).
|
|
7
|
+
* Manages remote Steel sessions, CDP endpoints, human takeover handoffs,
|
|
6
8
|
* pass-cli credential references, and automated cleanup without leaking secrets.
|
|
7
9
|
*/
|
|
8
10
|
|
|
@@ -218,6 +220,55 @@ export function formatCDPEndpoint(session: Pick<SteelSession, "id" | "websocketU
|
|
|
218
220
|
return `${wsProtocol}//${host}/v1/devtools?${searchParams.toString()}`;
|
|
219
221
|
}
|
|
220
222
|
|
|
223
|
+
export const DEFAULT_OBSCURA_CDP_URL = "http://127.0.0.1:9222";
|
|
224
|
+
const DEFAULT_OBSCURA_PORT = 9222;
|
|
225
|
+
|
|
226
|
+
function obscuraListenPort(): number {
|
|
227
|
+
const raw = process.env.OBSCURA_PORT?.trim() ?? "";
|
|
228
|
+
if (raw === "") return DEFAULT_OBSCURA_PORT;
|
|
229
|
+
if (!/^[0-9]+$/.test(raw)) {
|
|
230
|
+
throw new Error(`OBSCURA_PORT must be an integer from 1 to 65535 (received ${JSON.stringify(process.env.OBSCURA_PORT)})`);
|
|
231
|
+
}
|
|
232
|
+
const port = Number(raw);
|
|
233
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
234
|
+
throw new Error(`OBSCURA_PORT must be an integer from 1 to 65535 (received ${JSON.stringify(process.env.OBSCURA_PORT)})`);
|
|
235
|
+
}
|
|
236
|
+
return port;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* CDP URL for a local Obscura browser.
|
|
241
|
+
* `OBSCURA_CDP_URL` wins. Otherwise `http://127.0.0.1:${OBSCURA_PORT:-9222}`.
|
|
242
|
+
*/
|
|
243
|
+
export function resolveObscuraCdpEndpoint(): string {
|
|
244
|
+
const explicit = process.env.OBSCURA_CDP_URL?.trim() ?? "";
|
|
245
|
+
if (explicit !== "") return explicit;
|
|
246
|
+
const port = obscuraListenPort();
|
|
247
|
+
if (port === DEFAULT_OBSCURA_PORT) return DEFAULT_OBSCURA_CDP_URL;
|
|
248
|
+
return `http://127.0.0.1:${port}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Playwright CDP endpoint.
|
|
253
|
+
* Obscura by default. `KXM_BROWSER=steel` uses {@link formatCDPEndpoint} for `session`.
|
|
254
|
+
*/
|
|
255
|
+
export function resolveBrowserCdpEndpoint(
|
|
256
|
+
session?: Pick<SteelSession, "id" | "websocketUrl">,
|
|
257
|
+
config?: SteelConfig,
|
|
258
|
+
): string {
|
|
259
|
+
const browser = (process.env.KXM_BROWSER ?? "").trim().toLowerCase();
|
|
260
|
+
if (browser === "" || browser === "obscura") {
|
|
261
|
+
return resolveObscuraCdpEndpoint();
|
|
262
|
+
}
|
|
263
|
+
if (browser === "steel") {
|
|
264
|
+
if (!session?.id) {
|
|
265
|
+
throw new Error("KXM_BROWSER=steel requires a Steel session id");
|
|
266
|
+
}
|
|
267
|
+
return formatCDPEndpoint(session, config ?? resolveSteelConfig());
|
|
268
|
+
}
|
|
269
|
+
throw new Error(`Unsupported KXM_BROWSER value ${JSON.stringify(process.env.KXM_BROWSER)}; expected "obscura" or "steel"`);
|
|
270
|
+
}
|
|
271
|
+
|
|
221
272
|
/**
|
|
222
273
|
* Redact sensitive API keys and tokens from URLs and objects for logging.
|
|
223
274
|
*/
|
|
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
|
|
|
11
11
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
12
12
|
import { sessionTokenFixHint } from "./session-token-hint.ts";
|
|
13
13
|
|
|
14
|
-
const VERSION = "0.7.
|
|
14
|
+
const VERSION = "0.7.133";
|
|
15
15
|
const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
16
16
|
const inbox = new Map<string, MessageRecord>();
|
|
17
17
|
const notifiedInbox = new Set<string>();
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Download pinned Obscura v0.2.3 and serve CDP for Playwright.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/obscura.mjs foreground serve (download if needed)
|
|
6
|
+
* node scripts/obscura.mjs --ensure exit 0 once CDP /json/version answers
|
|
7
|
+
* node scripts/obscura.mjs --stop stop the process recorded in the pidfile
|
|
8
|
+
*
|
|
9
|
+
* Binaries land in `.kxm/bin/` (gitignored) and stay side by side.
|
|
10
|
+
* The pidfile and log live in `.kxm/run/`. Playwright browser downloads
|
|
11
|
+
* stay out of this script.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { closeSync, createReadStream, createWriteStream, openSync } from "node:fs";
|
|
17
|
+
import { chmod, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
18
|
+
import { arch, platform } from "node:os";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { Readable } from "node:stream";
|
|
21
|
+
import { pipeline } from "node:stream/promises";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
const OBSCURA_VERSION = "v0.2.3";
|
|
25
|
+
const READY_TIMEOUT_MS = 30_000;
|
|
26
|
+
const DOWNLOAD_TIMEOUT_MS = 300_000;
|
|
27
|
+
|
|
28
|
+
/** Unsuffixed release assets include rendering (needed for screenshots). */
|
|
29
|
+
const ASSETS = {
|
|
30
|
+
"linux-x64": {
|
|
31
|
+
name: "obscura-x86_64-linux.tar.gz",
|
|
32
|
+
sha256: "1534d1e6ddaf3d080ec4091eb41d0a4d8cc042a48b607d3c410fc13b482a9eec",
|
|
33
|
+
},
|
|
34
|
+
"linux-arm64": {
|
|
35
|
+
name: "obscura-aarch64-linux.tar.gz",
|
|
36
|
+
sha256: "5ecf980bca3060236a7a86ec7ed83d943e6598ee87caa46d20325d90bc75f979",
|
|
37
|
+
},
|
|
38
|
+
"darwin-x64": {
|
|
39
|
+
name: "obscura-x86_64-macos.tar.gz",
|
|
40
|
+
sha256: "d7c48122debc2ad9b24842df44560860dba765ea928b3f636b7f053225245116",
|
|
41
|
+
},
|
|
42
|
+
"darwin-arm64": {
|
|
43
|
+
name: "obscura-aarch64-macos.tar.gz",
|
|
44
|
+
sha256: "45653cfad226f1c9b415603a2ed59477fcbd6335c742338ce133c05de0bdd056",
|
|
45
|
+
},
|
|
46
|
+
"win32-x64": {
|
|
47
|
+
name: "obscura-x86_64-windows.zip",
|
|
48
|
+
sha256: "781a1b8bd12b65ec5aba95842e75e6f56b3101d360397506c0e35fe3f78536e8",
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
53
|
+
const binDir = join(repoRoot, ".kxm", "bin");
|
|
54
|
+
const runDir = join(repoRoot, ".kxm", "run");
|
|
55
|
+
const pidPath = join(runDir, "obscura.pid");
|
|
56
|
+
const logPath = join(runDir, "obscura.log");
|
|
57
|
+
const stampPath = join(binDir, "obscura-release");
|
|
58
|
+
|
|
59
|
+
function executableNames() {
|
|
60
|
+
if (platform() === "win32") return { main: "obscura.exe", worker: "obscura-worker.exe" };
|
|
61
|
+
return { main: "obscura", worker: "obscura-worker" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assetForThisMachine() {
|
|
65
|
+
const key = `${platform()}-${arch()}`;
|
|
66
|
+
const asset = ASSETS[key];
|
|
67
|
+
if (!asset) {
|
|
68
|
+
const known = Object.keys(ASSETS).join(", ");
|
|
69
|
+
throw new Error(`Obscura ${OBSCURA_VERSION} has no build for ${key}. Known targets: ${known}`);
|
|
70
|
+
}
|
|
71
|
+
return asset;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parsePort(raw, label) {
|
|
75
|
+
if (!/^[0-9]+$/.test(raw)) {
|
|
76
|
+
throw new Error(`${label} must be an integer from 1 to 65535 (received ${JSON.stringify(raw)})`);
|
|
77
|
+
}
|
|
78
|
+
const port = Number(raw);
|
|
79
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
80
|
+
throw new Error(`${label} must be an integer from 1 to 65535 (received ${JSON.stringify(raw)})`);
|
|
81
|
+
}
|
|
82
|
+
return port;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isLoopback(hostname) {
|
|
86
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
87
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function versionUrlFrom(cdpUrl) {
|
|
91
|
+
const url = new URL(cdpUrl);
|
|
92
|
+
if (url.protocol === "ws:") url.protocol = "http:";
|
|
93
|
+
else if (url.protocol === "wss:") url.protocol = "https:";
|
|
94
|
+
else if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
95
|
+
throw new Error(`Unsupported OBSCURA_CDP_URL protocol ${url.protocol}`);
|
|
96
|
+
}
|
|
97
|
+
const port = url.port
|
|
98
|
+
? parsePort(url.port, "OBSCURA_CDP_URL port")
|
|
99
|
+
: url.protocol === "https:" ? 443 : 80;
|
|
100
|
+
url.pathname = "/json/version";
|
|
101
|
+
url.search = "";
|
|
102
|
+
url.hash = "";
|
|
103
|
+
return { versionUrl: url.href, port, loopback: isLoopback(url.hostname) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveLaunch() {
|
|
107
|
+
const explicit = process.env.OBSCURA_CDP_URL?.trim() ?? "";
|
|
108
|
+
const portRaw = process.env.OBSCURA_PORT?.trim() ?? "";
|
|
109
|
+
const envPort = portRaw === "" ? null : parsePort(portRaw, "OBSCURA_PORT");
|
|
110
|
+
if (explicit !== "") {
|
|
111
|
+
const parsed = versionUrlFrom(explicit);
|
|
112
|
+
if (envPort !== null && envPort !== parsed.port) {
|
|
113
|
+
throw new Error(`OBSCURA_PORT=${envPort} does not match the port in OBSCURA_CDP_URL (${parsed.port})`);
|
|
114
|
+
}
|
|
115
|
+
return { cdpUrl: explicit, ...parsed };
|
|
116
|
+
}
|
|
117
|
+
const port = envPort ?? 9222;
|
|
118
|
+
const cdpUrl = `http://127.0.0.1:${port}`;
|
|
119
|
+
return { cdpUrl, versionUrl: `${cdpUrl}/json/version`, port, loopback: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function assertGlibc() {
|
|
123
|
+
if (platform() !== "linux") return;
|
|
124
|
+
const result = spawnSync("ldd", ["--version"], { encoding: "utf8" });
|
|
125
|
+
const text = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
126
|
+
const match = text.match(/(\d+)\.(\d+)/);
|
|
127
|
+
if (!match) return;
|
|
128
|
+
const major = Number(match[1]);
|
|
129
|
+
const minor = Number(match[2]);
|
|
130
|
+
if (major < 2 || (major === 2 && minor < 35)) {
|
|
131
|
+
throw new Error(`Obscura ${OBSCURA_VERSION} needs glibc >= 2.35; this machine reports ${major}.${minor}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function sha256File(path) {
|
|
136
|
+
const hash = createHash("sha256");
|
|
137
|
+
await pipeline(createReadStream(path), hash);
|
|
138
|
+
return hash.digest("hex");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function download(url, dest) {
|
|
142
|
+
const response = await fetch(url, {
|
|
143
|
+
redirect: "follow",
|
|
144
|
+
headers: { "user-agent": "kxm-obscura-launcher" },
|
|
145
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
146
|
+
});
|
|
147
|
+
if (!response.ok || !response.body) {
|
|
148
|
+
throw new Error(`Obscura download failed: HTTP ${response.status} for ${url}`);
|
|
149
|
+
}
|
|
150
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(dest));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function findNamed(dir, name, depth = 0) {
|
|
154
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const path = join(dir, entry.name);
|
|
157
|
+
if (entry.isFile() && entry.name === name) return path;
|
|
158
|
+
if (entry.isDirectory() && depth < 2 && entry.name !== ".staging") {
|
|
159
|
+
const found = await findNamed(path, name, depth + 1);
|
|
160
|
+
if (found) return found;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function ensureBinaries() {
|
|
167
|
+
const asset = assetForThisMachine();
|
|
168
|
+
const names = executableNames();
|
|
169
|
+
const mainPath = join(binDir, names.main);
|
|
170
|
+
const workerPath = join(binDir, names.worker);
|
|
171
|
+
const stamp = `${OBSCURA_VERSION} ${asset.name}\n`;
|
|
172
|
+
let current = "";
|
|
173
|
+
try {
|
|
174
|
+
current = await readFile(stampPath, "utf8");
|
|
175
|
+
} catch {
|
|
176
|
+
current = "";
|
|
177
|
+
}
|
|
178
|
+
if (current === stamp) {
|
|
179
|
+
try {
|
|
180
|
+
const mainStat = await stat(mainPath);
|
|
181
|
+
const workerStat = await stat(workerPath);
|
|
182
|
+
if (mainStat.size > 0 && workerStat.size > 0) {
|
|
183
|
+
process.stderr.write(`Obscura ${OBSCURA_VERSION} already present in ${binDir}\n`);
|
|
184
|
+
return mainPath;
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Re-download when a binary is missing.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
assertGlibc();
|
|
192
|
+
await mkdir(binDir, { recursive: true });
|
|
193
|
+
const staging = join(binDir, ".staging");
|
|
194
|
+
await rm(staging, { recursive: true, force: true });
|
|
195
|
+
await mkdir(staging, { recursive: true });
|
|
196
|
+
const archivePath = join(staging, asset.name);
|
|
197
|
+
const url = `https://github.com/h4ckf0r0day/obscura/releases/download/${OBSCURA_VERSION}/${asset.name}`;
|
|
198
|
+
process.stderr.write(`Downloading Obscura ${OBSCURA_VERSION} (${asset.name})\n`);
|
|
199
|
+
try {
|
|
200
|
+
await download(url, archivePath);
|
|
201
|
+
const digest = await sha256File(archivePath);
|
|
202
|
+
if (digest !== asset.sha256) {
|
|
203
|
+
throw new Error(`Obscura archive checksum mismatch for ${asset.name}: expected ${asset.sha256}, got ${digest}`);
|
|
204
|
+
}
|
|
205
|
+
const tarArgs = asset.name.endsWith(".zip")
|
|
206
|
+
? ["-xf", archivePath, "-C", staging]
|
|
207
|
+
: ["-xzf", archivePath, "-C", staging];
|
|
208
|
+
const extracted = spawnSync("tar", tarArgs, { encoding: "utf8" });
|
|
209
|
+
if (extracted.status !== 0) {
|
|
210
|
+
throw new Error(`tar failed to extract ${asset.name}: ${(extracted.stderr || extracted.stdout || "").trim()}`);
|
|
211
|
+
}
|
|
212
|
+
const mainFound = await findNamed(staging, names.main);
|
|
213
|
+
const workerFound = await findNamed(staging, names.worker);
|
|
214
|
+
if (!mainFound || !workerFound) {
|
|
215
|
+
throw new Error(`Archive ${asset.name} did not contain ${names.main} and ${names.worker} together`);
|
|
216
|
+
}
|
|
217
|
+
await copyFile(mainFound, mainPath);
|
|
218
|
+
await copyFile(workerFound, workerPath);
|
|
219
|
+
if (platform() !== "win32") {
|
|
220
|
+
await chmod(mainPath, 0o755);
|
|
221
|
+
await chmod(workerPath, 0o755);
|
|
222
|
+
}
|
|
223
|
+
await writeFile(stampPath, stamp);
|
|
224
|
+
} finally {
|
|
225
|
+
await rm(staging, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
process.stderr.write(`Installed Obscura ${OBSCURA_VERSION} into ${binDir}\n`);
|
|
228
|
+
return mainPath;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function isReady(versionUrl) {
|
|
232
|
+
try {
|
|
233
|
+
const response = await fetch(versionUrl, { signal: AbortSignal.timeout(1000) });
|
|
234
|
+
if (!response.ok) return false;
|
|
235
|
+
const text = await response.text();
|
|
236
|
+
return text.includes("webSocketDebuggerUrl") || text.includes("Browser");
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function readPid() {
|
|
243
|
+
try {
|
|
244
|
+
const pid = Number((await readFile(pidPath, "utf8")).trim());
|
|
245
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
246
|
+
return pid;
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function alive(pid) {
|
|
253
|
+
try {
|
|
254
|
+
process.kill(pid, 0);
|
|
255
|
+
return true;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function clearPid(pid) {
|
|
262
|
+
const current = await readPid();
|
|
263
|
+
if (current === pid) await rm(pidPath, { force: true });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function sleep(ms) {
|
|
267
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function waitUntilReady(versionUrl, shouldStop = () => false) {
|
|
271
|
+
const start = Date.now();
|
|
272
|
+
while (!shouldStop() && Date.now() - start < READY_TIMEOUT_MS) {
|
|
273
|
+
if (await isReady(versionUrl)) return true;
|
|
274
|
+
await sleep(200);
|
|
275
|
+
}
|
|
276
|
+
if (shouldStop()) return false;
|
|
277
|
+
let tail = "";
|
|
278
|
+
try {
|
|
279
|
+
const log = await readFile(logPath, "utf8");
|
|
280
|
+
tail = log.split("\n").slice(-40).join("\n");
|
|
281
|
+
} catch {
|
|
282
|
+
tail = "(no Obscura log)";
|
|
283
|
+
}
|
|
284
|
+
throw new Error(`Obscura did not become ready at ${versionUrl} within ${READY_TIMEOUT_MS}ms.\n${tail}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function stop() {
|
|
288
|
+
const pid = await readPid();
|
|
289
|
+
if (!pid || !alive(pid)) {
|
|
290
|
+
await rm(pidPath, { force: true });
|
|
291
|
+
process.stdout.write("Obscura is not running\n");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
process.kill(pid, "SIGTERM");
|
|
295
|
+
const deadline = Date.now() + 5000;
|
|
296
|
+
while (Date.now() < deadline && alive(pid)) await sleep(100);
|
|
297
|
+
if (alive(pid)) process.kill(pid, "SIGKILL");
|
|
298
|
+
await rm(pidPath, { force: true });
|
|
299
|
+
process.stdout.write(`Stopped Obscura process ${pid}\n`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function serveArgs(port) {
|
|
303
|
+
return ["serve", "--port", String(port), "--allow-private-network"];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function startDetached(binary, port) {
|
|
307
|
+
await mkdir(runDir, { recursive: true });
|
|
308
|
+
const logFd = openSync(logPath, "w");
|
|
309
|
+
const child = spawn(binary, serveArgs(port), {
|
|
310
|
+
cwd: binDir,
|
|
311
|
+
detached: true,
|
|
312
|
+
stdio: ["ignore", logFd, logFd],
|
|
313
|
+
});
|
|
314
|
+
child.unref();
|
|
315
|
+
closeSync(logFd);
|
|
316
|
+
if (!child.pid) throw new Error("Failed to start Obscura (no pid)");
|
|
317
|
+
await writeFile(pidPath, `${child.pid}\n`);
|
|
318
|
+
return child.pid;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function startForeground(binary, port, versionUrl) {
|
|
322
|
+
await mkdir(runDir, { recursive: true });
|
|
323
|
+
const child = spawn(binary, serveArgs(port), {
|
|
324
|
+
cwd: binDir,
|
|
325
|
+
stdio: "inherit",
|
|
326
|
+
});
|
|
327
|
+
if (!child.pid) throw new Error("Failed to start Obscura (no pid)");
|
|
328
|
+
await writeFile(pidPath, `${child.pid}\n`);
|
|
329
|
+
let childDone = false;
|
|
330
|
+
const exitPromise = new Promise((resolve) => {
|
|
331
|
+
child.once("exit", (code, signal) => {
|
|
332
|
+
childDone = true;
|
|
333
|
+
resolve({ code, signal });
|
|
334
|
+
});
|
|
335
|
+
child.once("error", (error) => {
|
|
336
|
+
childDone = true;
|
|
337
|
+
resolve({ error });
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
const ready = await waitUntilReady(versionUrl, () => childDone);
|
|
341
|
+
if (!ready) {
|
|
342
|
+
const result = await exitPromise;
|
|
343
|
+
await clearPid(child.pid);
|
|
344
|
+
const detail = result.error ? result.error.message : `code ${result.code ?? "null"} signal ${result.signal ?? "null"}`;
|
|
345
|
+
throw new Error(`Obscura exited before ${versionUrl} was ready (${detail})`);
|
|
346
|
+
}
|
|
347
|
+
process.stderr.write(`Obscura CDP ready at ${versionUrl}\n`);
|
|
348
|
+
const shutdown = (signal) => {
|
|
349
|
+
if (alive(child.pid)) {
|
|
350
|
+
try { process.kill(child.pid, signal); } catch { /* already gone */ }
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
354
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
355
|
+
const result = await exitPromise;
|
|
356
|
+
await clearPid(child.pid);
|
|
357
|
+
if (result.error) throw result.error;
|
|
358
|
+
process.exit(result.code ?? (result.signal ? 1 : 0));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function usage() {
|
|
362
|
+
process.stdout.write(
|
|
363
|
+
`Usage: node scripts/obscura.mjs [--ensure | --stop]\n` +
|
|
364
|
+
` (no flag) download pinned ${OBSCURA_VERSION} if needed and serve in the foreground\n` +
|
|
365
|
+
` --ensure start a detached server when CDP is not ready, then exit 0\n` +
|
|
366
|
+
` --stop stop the pidfile process\n`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function main() {
|
|
371
|
+
const args = process.argv.slice(2);
|
|
372
|
+
if (args.length > 1 || (args.length === 1 && !["--ensure", "--stop", "--help", "-h"].includes(args[0]))) {
|
|
373
|
+
usage();
|
|
374
|
+
throw new Error(`Unexpected argument ${JSON.stringify(args.join(" "))}`);
|
|
375
|
+
}
|
|
376
|
+
const mode = args[0] ?? "foreground";
|
|
377
|
+
if (mode === "--help" || mode === "-h") {
|
|
378
|
+
usage();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (mode === "--stop") {
|
|
382
|
+
await stop();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const launch = resolveLaunch();
|
|
387
|
+
if (await isReady(launch.versionUrl)) {
|
|
388
|
+
process.stdout.write(`Obscura CDP is ready at ${launch.versionUrl}\n`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (!launch.loopback) {
|
|
392
|
+
throw new Error(`${launch.cdpUrl} is not ready and is not a loopback address, so this launcher will not start a local Obscura`);
|
|
393
|
+
}
|
|
394
|
+
const existing = await readPid();
|
|
395
|
+
if (existing && alive(existing)) {
|
|
396
|
+
await waitUntilReady(launch.versionUrl);
|
|
397
|
+
process.stdout.write(`Obscura CDP is ready at ${launch.versionUrl}\n`);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
await clearPid(existing ?? -1);
|
|
401
|
+
|
|
402
|
+
const binary = await ensureBinaries();
|
|
403
|
+
if (mode === "--ensure") {
|
|
404
|
+
const pid = await startDetached(binary, launch.port);
|
|
405
|
+
try {
|
|
406
|
+
await waitUntilReady(launch.versionUrl);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (alive(pid)) {
|
|
409
|
+
try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
|
|
410
|
+
}
|
|
411
|
+
await clearPid(pid);
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
process.stdout.write(`Obscura CDP is ready at ${launch.versionUrl} (pid ${pid})\n`);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
await startForeground(binary, launch.port, launch.versionUrl);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
main().catch((error) => {
|
|
421
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
422
|
+
process.stderr.write(`${message}\n`);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
});
|