diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..a58e81d
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+# Renderer manifests authenticate exact source bytes. Keep embedded text assets
+# on LF across every checkout so Windows builds verify the same bytes.
+crates/opsail-refit-codex/assets/*.css text eol=lf
+crates/opsail-refit-codex/assets/*.js text eol=lf
+crates/opsail-refit-codex/assets/*.json text eol=lf
diff --git a/.github/scripts/refit-codex-smoke.mjs b/.github/scripts/refit-codex-smoke.mjs
new file mode 100644
index 0000000..10a1c46
--- /dev/null
+++ b/.github/scripts/refit-codex-smoke.mjs
@@ -0,0 +1,133 @@
+import { spawnSync } from "node:child_process";
+import process from "node:process";
+
+const binary = process.env.OPSAIL_BINARY;
+if (!binary) {
+ throw new Error("OPSAIL_BINARY must point to the native opsail executable");
+}
+
+function run(args) {
+ const result = spawnSync(binary, args, {
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ NO_COLOR: "1",
+ RUST_BACKTRACE: "0",
+ },
+ });
+
+ if (result.error) {
+ throw result.error;
+ }
+
+ return result;
+}
+
+function requireCondition(condition, message, result) {
+ if (condition) {
+ return;
+ }
+
+ const details = result
+ ? `\nexit: ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
+ : "";
+ throw new Error(`${message}${details}`);
+}
+
+function parseDoctorReport(result) {
+ requireCondition(result.status === 0, "doctor did not return a report", result);
+ try {
+ return JSON.parse(result.stdout);
+ } catch (error) {
+ throw new Error(
+ `doctor stdout is not JSON: ${error.message}\nstdout:\n${result.stdout}`,
+ );
+ }
+}
+
+function check(report, name) {
+ return report.checks?.find((entry) => entry.name === name);
+}
+
+const help = run(["refit", "codex", "--help"]);
+requireCondition(help.status === 0, "refit codex --help failed", help);
+for (const command of ["enable", "disable", "status", "doctor", "update"]) {
+ requireCondition(
+ help.stdout.includes(command),
+ `refit codex --help does not list ${command}`,
+ help,
+ );
+}
+
+const doctor = run(["refit", "codex", "doctor"]);
+
+if (process.platform === "win32") {
+ // GitHub's hosted Windows image does not contain the Microsoft Store app. The
+ // command must still initialize the Windows adapter and report that absence as
+ // an application check, rather than falling back to the unsupported platform.
+ const report = parseDoctorReport(doctor);
+ const platform = check(report, "platform");
+ const application = check(report, "application");
+
+ requireCondition(
+ report.supported === true,
+ "Windows was reported as unsupported",
+ doctor,
+ );
+ requireCondition(
+ report.ready === false,
+ "doctor was unexpectedly ready without ChatGPT",
+ doctor,
+ );
+ requireCondition(
+ platform?.state === "pass",
+ "Windows platform check did not pass",
+ doctor,
+ );
+ requireCondition(
+ application?.state === "fail",
+ "missing Store application was not reported by the application check",
+ doctor,
+ );
+ requireCondition(
+ application.message?.startsWith("target-not-found:"),
+ "missing Store application did not use target-not-found",
+ doctor,
+ );
+} else if (process.platform === "darwin") {
+ const report = parseDoctorReport(doctor);
+ requireCondition(
+ report.supported === true,
+ "macOS was reported as unsupported",
+ doctor,
+ );
+ requireCondition(
+ check(report, "platform")?.state === "pass",
+ "macOS platform check did not pass",
+ doctor,
+ );
+} else if (process.platform === "linux") {
+ if (doctor.status === 0) {
+ const report = parseDoctorReport(doctor);
+ requireCondition(
+ report.supported === false,
+ "Linux was unexpectedly reported as supported",
+ doctor,
+ );
+ requireCondition(
+ check(report, "platform")?.state === "fail",
+ "Linux platform check did not fail",
+ doctor,
+ );
+ } else {
+ requireCondition(
+ doctor.stderr.includes("[opsail-refit-codex:unsupported]"),
+ "Linux doctor did not return the bounded unsupported diagnostic",
+ doctor,
+ );
+ }
+} else {
+ throw new Error(`unrecognized CI platform: ${process.platform}`);
+}
+
+console.log(`refit-codex native smoke passed on ${process.platform}`);
diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml
index 9a5c5e4..1ec7b3b 100644
--- a/.github/workflows/installers.yml
+++ b/.github/workflows/installers.yml
@@ -4,15 +4,15 @@ on:
pull_request:
paths:
- ".github/workflows/installers.yml"
- - "scripts/install.ps1"
- - "scripts/install.sh"
+ - "skills/bootstrap-opsail/scripts/install.ps1"
+ - "skills/bootstrap-opsail/scripts/install.sh"
push:
branches:
- main
paths:
- ".github/workflows/installers.yml"
- - "scripts/install.ps1"
- - "scripts/install.sh"
+ - "skills/bootstrap-opsail/scripts/install.ps1"
+ - "skills/bootstrap-opsail/scripts/install.sh"
permissions:
contents: read
@@ -41,17 +41,41 @@ jobs:
persist-credentials: false
- name: Check shell syntax
- run: sh -n scripts/install.sh
+ run: sh -n skills/bootstrap-opsail/scripts/install.sh
- - name: Install latest release
+ - name: Install latest and exact release
env:
OPSAIL_INSTALL_DIR: ${{ runner.temp }}/opsail-bin
shell: sh
run: |
- scripts/install.sh
- "$OPSAIL_INSTALL_DIR/opsail" --version
+ set -eu
+ skills/bootstrap-opsail/scripts/install.sh
+ latest_version_output="$("$OPSAIL_INSTALL_DIR/opsail" --version)"
+ case "$latest_version_output" in
+ "opsail "?*) resolved_version="${latest_version_output#opsail }" ;;
+ *) echo "unexpected version output: $latest_version_output" >&2; exit 1 ;;
+ esac
+ case "$resolved_version" in
+ *[!0-9A-Za-z.+_-]*) echo "unexpected version output: $latest_version_output" >&2; exit 1 ;;
+ esac
"$OPSAIL_INSTALL_DIR/opsail" read --help >/dev/null
+ exact_install_dir="$RUNNER_TEMP/opsail-bin-exact"
+ (
+ unset HOME
+ OPSAIL_INSTALL_DIR="$exact_install_dir"
+ OPSAIL_VERSION="$resolved_version"
+ export OPSAIL_INSTALL_DIR OPSAIL_VERSION
+ skills/bootstrap-opsail/scripts/install.sh
+ )
+
+ exact_version_output="$("$exact_install_dir/opsail" --version)"
+ if [ "$exact_version_output" != "$latest_version_output" ]; then
+ echo "exact install version mismatch: $exact_version_output" >&2
+ exit 1
+ fi
+ "$exact_install_dir/opsail" read --help >/dev/null
+
windows:
name: Install on windows-2025
runs-on: windows-2025
@@ -65,16 +89,20 @@ jobs:
- name: Check PowerShell syntax
shell: powershell
run: |
- $tokens = $null
- $errors = $null
- [System.Management.Automation.Language.Parser]::ParseFile(
- (Resolve-Path "scripts/install.ps1"),
- [ref] $tokens,
- [ref] $errors
- ) | Out-Null
-
- if ($errors.Count -gt 0) {
- $errors | ForEach-Object { Write-Error $_.Message }
+ $allErrors = @()
+ foreach ($scriptPath in @("skills/bootstrap-opsail/scripts/install.ps1")) {
+ $tokens = $null
+ $parseErrors = $null
+ [System.Management.Automation.Language.Parser]::ParseFile(
+ (Resolve-Path $scriptPath),
+ [ref] $tokens,
+ [ref] $parseErrors
+ ) | Out-Null
+ $allErrors += $parseErrors
+ }
+
+ if ($allErrors.Count -gt 0) {
+ $allErrors | ForEach-Object { Write-Error $_.Message }
exit 1
}
@@ -85,17 +113,32 @@ jobs:
run: |
Set-StrictMode -Off
$ErrorActionPreference = "Continue"
-
- Get-Content -Raw "scripts/install.ps1" | Invoke-Expression
-
- if ($ErrorActionPreference -ne "Continue") {
- throw "installer leaked ErrorActionPreference"
- }
+ $originalUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
+ $originalProcessPath = $env:Path
+ $originalUpdatePath = $env:OPSAIL_UPDATE_PATH
try {
- $null = $opsailUndefinedStrictModeProbe
- } catch {
- throw "installer leaked strict mode"
+ $env:OPSAIL_UPDATE_PATH = $null
+ Get-Content -Raw "skills/bootstrap-opsail/scripts/install.ps1" | Invoke-Expression
+
+ $currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
+ if ($currentUserPath -cne $originalUserPath) {
+ throw "installer changed the user PATH without OPSAIL_UPDATE_PATH=1"
+ }
+
+ if ($ErrorActionPreference -ne "Continue") {
+ throw "installer leaked ErrorActionPreference"
+ }
+
+ try {
+ $null = $opsailUndefinedStrictModeProbe
+ } catch {
+ throw "installer leaked strict mode"
+ }
+ } finally {
+ [Environment]::SetEnvironmentVariable("Path", $originalUserPath, "User")
+ $env:Path = $originalProcessPath
+ $env:OPSAIL_UPDATE_PATH = $originalUpdatePath
}
$ErrorActionPreference = "Stop"
@@ -111,17 +154,74 @@ jobs:
run: |
Set-StrictMode -Off
$ErrorActionPreference = "Continue"
-
- Get-Content -Raw "scripts/install.ps1" | Invoke-Expression
-
- if ($ErrorActionPreference -ne "Continue") {
- throw "installer leaked ErrorActionPreference"
- }
+ $originalUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
+ $originalProcessPath = $env:Path
+ $originalOpsailVersion = $env:OPSAIL_VERSION
+ $originalUpdatePath = $env:OPSAIL_UPDATE_PATH
try {
- $null = $opsailUndefinedStrictModeProbe
- } catch {
- throw "installer leaked strict mode"
+ $env:OPSAIL_UPDATE_PATH = $null
+ Get-Content -Raw "skills/bootstrap-opsail/scripts/install.ps1" | Invoke-Expression
+
+ $userPathAfterDefaultInstall = [Environment]::GetEnvironmentVariable("Path", "User")
+ if ($userPathAfterDefaultInstall -cne $originalUserPath) {
+ throw "installer changed the user PATH without OPSAIL_UPDATE_PATH=1"
+ }
+
+ $versionOutput = (& "$env:OPSAIL_INSTALL_DIR\opsail.exe" --version | Out-String).Trim()
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ if ($versionOutput -notmatch '^opsail ([0-9A-Za-z.+_-]+)$') {
+ throw "unexpected version output: $versionOutput"
+ }
+ $resolvedVersion = $Matches[1]
+
+ $seededUserPath = @(
+ $originalUserPath
+ "$env:OPSAIL_INSTALL_DIR\"
+ $env:OPSAIL_INSTALL_DIR.ToUpperInvariant()
+ ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
+ [Environment]::SetEnvironmentVariable("Path", ($seededUserPath -join ";"), "User")
+ $env:Path = "$env:Path;$env:OPSAIL_INSTALL_DIR\;$($env:OPSAIL_INSTALL_DIR.ToUpperInvariant())"
+
+ & "skills/bootstrap-opsail/scripts/install.ps1" `
+ -Version $resolvedVersion `
+ -InstallDir $env:OPSAIL_INSTALL_DIR `
+ -UpdatePath
+
+ $normalizedInstallDir = $env:OPSAIL_INSTALL_DIR.TrimEnd([char[]]"\/")
+ $matchingUserEntries = @(
+ [Environment]::GetEnvironmentVariable("Path", "User") -split ";" |
+ Where-Object { $_.Trim().TrimEnd([char[]]"\/") -ieq $normalizedInstallDir }
+ )
+ if ($matchingUserEntries.Count -ne 1) {
+ throw "installer did not normalize and deduplicate the user PATH entry"
+ }
+ if ($matchingUserEntries[0] -cne $env:OPSAIL_INSTALL_DIR) {
+ throw "installer did not preserve the canonical install directory in user PATH"
+ }
+
+ $matchingProcessEntries = @(
+ $env:Path -split ";" |
+ Where-Object { $_.Trim().TrimEnd([char[]]"\/") -ieq $normalizedInstallDir }
+ )
+ if ($matchingProcessEntries.Count -ne 1) {
+ throw "installer did not normalize and deduplicate the process PATH entry"
+ }
+
+ if ($ErrorActionPreference -ne "Continue") {
+ throw "installer leaked ErrorActionPreference"
+ }
+
+ try {
+ $null = $opsailUndefinedStrictModeProbe
+ } catch {
+ throw "installer leaked strict mode"
+ }
+ } finally {
+ [Environment]::SetEnvironmentVariable("Path", $originalUserPath, "User")
+ $env:Path = $originalProcessPath
+ $env:OPSAIL_VERSION = $originalOpsailVersion
+ $env:OPSAIL_UPDATE_PATH = $originalUpdatePath
}
$ErrorActionPreference = "Stop"
diff --git a/.github/workflows/refit-codex.yml b/.github/workflows/refit-codex.yml
new file mode 100644
index 0000000..514bfe8
--- /dev/null
+++ b/.github/workflows/refit-codex.yml
@@ -0,0 +1,78 @@
+name: Refit Codex native checks
+
+on:
+ pull_request:
+ paths:
+ - ".github/scripts/refit-codex-smoke.mjs"
+ - ".github/workflows/refit-codex.yml"
+ - "Cargo.lock"
+ - "Cargo.toml"
+ - "crates/opsail/**"
+ - "crates/opsail-refit-codex/**"
+ push:
+ branches:
+ - main
+ paths:
+ - ".github/scripts/refit-codex-smoke.mjs"
+ - ".github/workflows/refit-codex.yml"
+ - "Cargo.lock"
+ - "Cargo.toml"
+ - "crates/opsail/**"
+ - "crates/opsail-refit-codex/**"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: refit-codex-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ native:
+ name: Native checks on ${{ matrix.runner }}
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ runner:
+ - macos-15
+ - ubuntu-24.04
+ - windows-11-arm
+ - windows-2025
+ steps:
+ - name: Check out source
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Install Rust
+ shell: bash
+ run: |
+ set -euo pipefail
+ rust_version="$(sed -n 's/^rust-version = "\([^"]*\)"/\1/p' Cargo.toml)"
+ if [[ -z "$rust_version" ]]; then
+ echo "::error::Unable to read the workspace Rust version"
+ exit 1
+ fi
+ rustup toolchain install "$rust_version" --profile minimal --no-self-update
+ rustup default "$rust_version"
+ rustup component add clippy
+
+ - name: Test refit and CLI
+ run: cargo test --locked --package opsail-refit-codex --package opsail
+
+ - name: Lint refit and CLI
+ run: cargo clippy --locked --package opsail-refit-codex --package opsail --all-targets --all-features -- -D warnings
+
+ - name: Build native CLI
+ run: cargo build --locked --package opsail
+
+ - name: Smoke test refit CLI
+ env:
+ OPSAIL_BINARY: ${{ github.workspace }}/target/debug/opsail${{ runner.os == 'Windows' && '.exe' || '' }}
+ run: node .github/scripts/refit-codex-smoke.mjs
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7aaa773..3e4df83 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,7 +9,8 @@ permissions:
contents: read
concurrency:
- group: release-${{ github.ref }}
+ # Serialize tag workflows so release-state checks and publication cannot race.
+ group: opsail-release
cancel-in-progress: false
env:
@@ -22,6 +23,7 @@ jobs:
timeout-minutes: 20
outputs:
rust-version: ${{ steps.versions.outputs.rust-version }}
+ build-matrix: ${{ steps.targets.outputs.build-matrix }}
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -36,6 +38,7 @@ jobs:
metadata="$(cargo metadata --locked --no-deps --format-version 1)"
package_version="$(jq -r '.packages[] | select(.name == "opsail") | .version' <<<"$metadata")"
+ node_version="$(jq -r '.version' packages/node/package.json)"
rust_version="$(jq -r '.packages[] | select(.name == "opsail") | .rust_version' <<<"$metadata")"
if [[ -z "$package_version" || "$package_version" == "null" ]]; then
@@ -48,6 +51,11 @@ jobs:
exit 1
fi
+ if [[ "$node_version" != "$package_version" ]]; then
+ echo "::error::The opsail CLI and npm facade versions must match ($package_version, $node_version)"
+ exit 1
+ fi
+
expected_tag="v$package_version"
if [[ "$GITHUB_REF_NAME" != "$expected_tag" ]]; then
echo "::error::Tag $GITHUB_REF_NAME does not match package version $expected_tag"
@@ -75,28 +83,30 @@ jobs:
- name: Test
run: cargo test --workspace --locked
+ - name: Install Node.js
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: 20
+
+ - name: Generate release target matrix
+ id: targets
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo "build-matrix=$(node packages/node/scripts/print-release-matrix.js)" >>"$GITHUB_OUTPUT"
+
+ - name: Test Node adapter
+ run: npm test --prefix packages/node
+
+ - name: Verify npm package contents
+ run: npm run pack:check --prefix packages/node
+
build:
name: Build ${{ matrix.target }}
needs: verify
strategy:
fail-fast: false
- matrix:
- include:
- - runner: macos-15
- target: aarch64-apple-darwin
- asset: opsail-aarch64-apple-darwin.tar.gz
- - runner: macos-15-intel
- target: x86_64-apple-darwin
- asset: opsail-x86_64-apple-darwin.tar.gz
- - runner: ubuntu-24.04
- target: x86_64-unknown-linux-musl
- asset: opsail-x86_64-unknown-linux-musl.tar.gz
- - runner: ubuntu-24.04-arm
- target: aarch64-unknown-linux-musl
- asset: opsail-aarch64-unknown-linux-musl.tar.gz
- - runner: windows-2025
- target: x86_64-pc-windows-msvc
- asset: opsail-x86_64-pc-windows-msvc.zip
+ matrix: ${{ fromJSON(needs.verify.outputs.build-matrix) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
steps:
@@ -116,6 +126,11 @@ jobs:
rustup default "$RUST_VERSION"
rustup target add "$TARGET"
+ - name: Install Node.js
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: 20
+
- name: Install musl toolchain
if: runner.os == 'Linux'
shell: bash
@@ -145,6 +160,187 @@ jobs:
& "target/${{ matrix.target }}/release/opsail.exe" --version
& "target/${{ matrix.target }}/release/opsail.exe" read --help | Out-Null
+ # The hosted Ubuntu and Windows arm64 images do not guarantee Chrome.
+ # Their native Opsail binaries are still exercised by the smoke step
+ # above, without downloading a browser at release time.
+ - name: Smoke test Chrome launch
+ if: matrix.target != 'aarch64-unknown-linux-musl' && matrix.target != 'aarch64-pc-windows-msvc'
+ env:
+ OPSAIL_BINARY: target/${{ matrix.target }}/release/${{ matrix.binary }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ node <<'NODE'
+ const fs = require("node:fs");
+ const http = require("node:http");
+ const os = require("node:os");
+ const path = require("node:path");
+ const { spawn } = require("node:child_process");
+
+ const html = `
+
+
+
+ Pending browser launch
+
+
+
+ Opsail launch smoke
+ This local fixture verifies that the release binary can discover a preinstalled browser, launch an isolated headless Chrome process, navigate to a loopback URL, and return the rendered document through the read pipeline.
+ The page deliberately contains enough ordinary prose for article extraction while remaining deterministic, private to the runner, and independent of external network availability or third-party content.
+ The expected title is checked after capture so the smoke test covers browser startup, DevTools communication, navigation, HTML capture, extraction, and orderly process cleanup.
+
+ `;
+ const server = http.createServer((request, response) => {
+ if (request.url !== "/article") {
+ response.writeHead(404).end();
+ return;
+ }
+ response.writeHead(200, {
+ "connection": "close",
+ "content-type": "text/html; charset=utf-8",
+ });
+ response.end(html);
+ });
+
+ function chromeCandidates() {
+ if (process.platform === "darwin") {
+ return [
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
+ ];
+ }
+ if (process.platform === "linux") {
+ return [
+ "/usr/bin/google-chrome",
+ "/usr/bin/google-chrome-stable",
+ "/usr/bin/chromium",
+ "/usr/bin/chromium-browser",
+ ];
+ }
+ if (process.platform === "win32") {
+ return [process.env.ProgramFiles, process.env["ProgramFiles(x86)"], process.env.LOCALAPPDATA]
+ .filter(Boolean)
+ .map((root) => path.join(root, "Google", "Chrome", "Application", "chrome.exe"));
+ }
+ return [];
+ }
+
+ function findChrome() {
+ return chromeCandidates().find((candidate) => {
+ try {
+ fs.accessSync(candidate, fs.constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+ });
+ }
+
+ function runOpsail(binary, url, chromeTempRoot) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(
+ binary,
+ ["read", url, "--launch", "--property", "title"],
+ {
+ env: { ...process.env, OPSAIL_CHROME_TEMP_ROOT: chromeTempRoot },
+ stdio: ["ignore", "pipe", "pipe"],
+ },
+ );
+ let stdout = "";
+ let stderr = "";
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk;
+ });
+ child.once("error", reject);
+ child.once("close", (code, signal) => resolve({ code, signal, stdout, stderr }));
+ });
+ }
+
+ function assertNoChromeProfiles(chromeTempRoot) {
+ const profiles = fs
+ .readdirSync(chromeTempRoot)
+ .filter((name) => name.startsWith("opsail-chrome-"));
+ if (profiles.length > 0) {
+ throw new Error(`Chrome launch left temporary profiles behind: ${profiles.join(", ")}`);
+ }
+ }
+
+ async function main() {
+ const chromePath = findChrome();
+ if (!chromePath) {
+ throw new Error("No preinstalled Chrome executable found in a standard location");
+ }
+ console.log(`Using the runner Chrome installation at ${chromePath}`);
+
+ const chromeTempRoot = fs.mkdtempSync(
+ path.join(os.tmpdir(), "opsail-launch-smoke-"),
+ );
+ try {
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", resolve);
+ });
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ throw new Error("Local smoke-test server did not expose a TCP port");
+ }
+ const result = await runOpsail(
+ process.env.OPSAIL_BINARY,
+ `http://127.0.0.1:${address.port}/article`,
+ chromeTempRoot,
+ );
+ if (result.stderr) {
+ process.stderr.write(result.stderr);
+ }
+ assertNoChromeProfiles(chromeTempRoot);
+ if (result.code !== 0) {
+ throw new Error(
+ `Chrome launch smoke test exited with code ${result.code ?? `signal ${result.signal}`}`,
+ );
+ }
+ const title = result.stdout.trim();
+ if (title !== "Opsail launch smoke") {
+ throw new Error(`Unexpected title returned by Chrome launch smoke test: ${title}`);
+ }
+ } finally {
+ try {
+ if (server.listening) {
+ await new Promise((resolve) => server.close(resolve));
+ }
+ } finally {
+ fs.rmSync(chromeTempRoot, {
+ force: true,
+ maxRetries: 5,
+ recursive: true,
+ retryDelay: 100,
+ });
+ }
+ }
+ }
+
+ main().catch((error) => {
+ console.error(`::error::${error.message}`);
+ process.exitCode = 1;
+ });
+ NODE
+
+ - name: Verify static Linux binary
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ set -euo pipefail
+ readelf -l "target/${{ matrix.target }}/release/${{ matrix.binary }}" >program-headers.txt
+ if grep -q "Requesting program interpreter" program-headers.txt; then
+ echo "::error::Linux npm binaries must be statically linked"
+ exit 1
+ fi
+
- name: Package Unix archive
if: runner.os != 'Windows'
shell: bash
@@ -166,6 +362,20 @@ jobs:
Copy-Item LICENSE "dist/$archiveRoot/"
Compress-Archive -Path "dist/$archiveRoot" -DestinationPath "dist/${{ matrix.asset }}" -CompressionLevel Optimal
+ - name: Build npm platform package
+ env:
+ BINARY: target/${{ matrix.target }}/release/${{ matrix.binary }}
+ TARGET: ${{ matrix.target }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ node packages/node/scripts/build-platform-package.js \
+ --target "$TARGET" \
+ --binary "$BINARY" \
+ --output dist/npm-package
+ mkdir -p dist/npm-tarballs
+ npm pack ./dist/npm-package --pack-destination dist/npm-tarballs
+
- name: Upload archive
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -173,11 +383,66 @@ jobs:
archive: false
if-no-files-found: error
+ - name: Upload npm platform package
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: npm-${{ matrix.target }}
+ path: dist/npm-tarballs/*.tgz
+ if-no-files-found: error
+
+ npm-bundle:
+ name: Assemble npm release bundle
+ needs:
+ - verify
+ - build
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ steps:
+ - name: Check out source
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Install Node.js
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: 20
+
+ - name: Download npm platform packages
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: npm-*
+ path: dist/npm-platforms
+ merge-multiple: true
+
+ - name: Pack and verify the public npm package
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p dist/npm-release
+ cp dist/npm-platforms/*.tgz dist/npm-release/
+ npm pack ./packages/node --pack-destination dist/npm-release
+
+ expected_count="$(node -e 'import("./packages/node/src/platforms.js").then(({ PLATFORM_TARGETS }) => process.stdout.write(String(PLATFORM_TARGETS.length + 1)))')"
+ actual_count="$(find dist/npm-release -maxdepth 1 -name '*.tgz' -type f | wc -l | tr -d ' ')"
+ if [[ "$actual_count" != "$expected_count" ]]; then
+ echo "::error::Expected $expected_count npm packages, found $actual_count"
+ exit 1
+ fi
+
+ - name: Upload npm release bundle
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: npm-release-bundle-${{ github.ref_name }}
+ path: dist/npm-release/*.tgz
+ if-no-files-found: error
+
release:
name: Publish GitHub Release
needs:
- verify
- build
+ - npm-bundle
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
@@ -214,14 +479,70 @@ jobs:
)
publish_args=(--draft=false)
+ if ! existing_release="$(
+ gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
+ --jq ".[] | select(.tag_name == \"$GITHUB_REF_NAME\") | [.draft, .prerelease] | @tsv"
+ )"; then
+ echo "::error::Unable to inspect existing GitHub releases"
+ exit 1
+ fi
+
+ existing_release_count="$(printf '%s\n' "$existing_release" | sed '/^$/d' | wc -l | tr -d ' ')"
+ if [[ "$existing_release_count" -gt 1 ]]; then
+ echo "::error::Multiple GitHub releases exist for tag $GITHUB_REF_NAME"
+ exit 1
+ fi
+
+ repair_draft=false
+ if [[ "$existing_release_count" == "1" ]]; then
+ IFS=$'\t' read -r existing_is_draft _ <<<"$existing_release"
+ if [[ "$existing_is_draft" != "true" ]]; then
+ echo "::error::Release $GITHUB_REF_NAME is already published; refusing to replace its assets or metadata"
+ exit 1
+ fi
+ repair_draft=true
+ fi
+
if [[ "$GITHUB_REF_NAME" == *-* ]]; then
create_args+=(--prerelease)
publish_args+=(--prerelease --latest=false)
else
- publish_args+=(--latest)
+ if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "::error::Stable releases require a vMAJOR.MINOR.PATCH tag: $GITHUB_REF_NAME"
+ exit 1
+ fi
+
+ if ! published_stable_tags="$(
+ gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
+ --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name'
+ )"; then
+ echo "::error::Unable to inspect published stable GitHub releases"
+ exit 1
+ fi
+
+ while IFS= read -r published_tag; do
+ [[ -z "$published_tag" ]] && continue
+ if [[ ! "$published_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "::error::Published stable release has an unsupported tag: $published_tag"
+ exit 1
+ fi
+ done <<<"$published_stable_tags"
+
+ highest_tag="$(
+ printf '%s\n' "$published_stable_tags" "$GITHUB_REF_NAME" \
+ | sed '/^$/d' \
+ | LC_ALL=C sort -V \
+ | tail -n 1
+ )"
+ publish_args+=(--prerelease=false)
+ if [[ "$highest_tag" == "$GITHUB_REF_NAME" ]]; then
+ publish_args+=(--latest)
+ else
+ publish_args+=(--latest=false)
+ fi
fi
- if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
+ if [[ "$repair_draft" == "true" ]]; then
gh release upload "$GITHUB_REF_NAME" dist/* --clobber --repo "$GITHUB_REPOSITORY"
else
gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" "${create_args[@]}"
diff --git a/.github/workflows/skills.yml b/.github/workflows/skills.yml
new file mode 100644
index 0000000..d1538bd
--- /dev/null
+++ b/.github/workflows/skills.yml
@@ -0,0 +1,55 @@
+name: Skill contract tests
+
+on:
+ pull_request:
+ paths:
+ - ".github/workflows/release.yml"
+ - ".github/workflows/skills.yml"
+ - "CONTRIBUTING.md"
+ - "packages/node/package.json"
+ - "packages/node/test/skill.test.js"
+ - "README.md"
+ - "scripts/install.ps1"
+ - "scripts/install.sh"
+ - "skills/bootstrap-opsail/**"
+ - "skills/opsail/**"
+ push:
+ branches:
+ - main
+ paths:
+ - ".github/workflows/release.yml"
+ - ".github/workflows/skills.yml"
+ - "CONTRIBUTING.md"
+ - "packages/node/package.json"
+ - "packages/node/test/skill.test.js"
+ - "README.md"
+ - "scripts/install.ps1"
+ - "scripts/install.sh"
+ - "skills/bootstrap-opsail/**"
+ - "skills/opsail/**"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: skills-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ contract:
+ name: Verify Skill contracts
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ steps:
+ - name: Check out source
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Install Node.js
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: 20
+
+ - name: Test Skill contracts
+ run: node --test packages/node/test/skill.test.js
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0dd27d4..2778383 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,20 +12,36 @@ Thank you for helping improve Opsail. Keep changes focused on one observable beh
## Workspace boundaries
```text
-crates/opsail CLI parsing, output routing, diagnostics, and exit behavior
-crates/opsail-read HTML acquisition, extraction, sanitization, and result schema
+crates/opsail Native CLI parsing, protocol routing, diagnostics, and exit behavior
+crates/opsail-chrome Chrome executable discovery, owned lifecycle, CDP, and DOM capture
+crates/opsail-read Source orchestration, HTML acquisition, extraction, sanitization, and result schema
+crates/opsail-refit-codex Codex refit lifecycle, target safety, and renderer integration
+packages/node Public `opsail` npm facade and native binary resolution
+skills/bootstrap-opsail Transient agent-facing installation control plane
+skills/opsail Unified Agent Skill for runtime Opsail capabilities
```
-The `opsail` package is a thin process adapter. Extraction heuristics, networking, sanitization, and result models belong in `opsail-read`. A future action should become a sibling `opsail-` crate once it has a cohesive typed API and independent tests. Do not introduce a plugin ABI or shared framework before implemented modules demonstrate that need.
+The native `opsail` crate owns the unified command entry point, while the public `opsail` npm package is a thin process adapter. Generated `@opsail/-` packages are implementation-only binary carriers, not additional APIs. `opsail-chrome` owns all Chrome-specific mechanics: cross-platform executable discovery, isolated process launch and cleanup, borrowed CDP connections, target lifecycle, navigation waits, and rendered DOM capture. It does not extract or sanitize content. `opsail-read` selects and validates sources, acquires non-browser HTML, delegates browser capture to `opsail-chrome`, and owns extraction, sanitization, and `ReadResult`. `opsail-refit-codex` owns the Codex-specific application identity, process and loopback CDP validation, renderer bridge, selectors, quota semantics, localization assets, and UI payload. Its refit lifecycle stays internal until a second adapter demonstrates a stable shared contract. A future action should become a sibling `opsail-` crate once it has a cohesive typed API and independent tests, then be exposed through the existing CLI, npm facade, and unified runtime skill. Do not introduce a plugin ABI or shared framework before implemented modules demonstrate that need.
## Library entry points
+`opsail-chrome` exposes two ownership-specific entry points:
+
+- `capture_chrome(&ChromeSource, &CaptureOptions)` discovers or uses a configured executable, launches an isolated temporary profile, captures one page, and stops the owned browser.
+- `capture_cdp(&CdpSource, &CaptureOptions)` borrows a caller-managed endpoint and never owns that browser or its existing targets.
+
+Executable resolution must remain explicit path, then `OPSAIL_CHROME_PATH`, then platform candidates and `PATH`. Owned launch supports macOS, Linux, and Windows, uses a dynamically assigned loopback debugging port, never reuses a user profile, and must not silently add `--no-sandbox`.
+
+Borrowed CDP cleanup must close only Opsail-created targets. Detach and target cleanup are expected on normal completion, but remain best-effort when a capture future is abruptly cancelled or the process is terminated; the caller always retains ownership of that browser.
+
`opsail-read` exposes:
-- `read(Input, &ReadOptions)` for asynchronous URL, file, or stdin acquisition.
+- `read(ReadSource, &ReadOptions)` for asynchronous URL, file, stdin, captured HTML, borrowed CDP, or owned Chrome acquisition.
- `extract_html(html, base_url)` for synchronous in-memory extraction.
-Both return the versioned `ReadResult` model used by CLI JSON output.
+Both `opsail-read` entry points return the versioned `ReadResult` model used by CLI JSON output. Browser captures retain distinct provenance: `SourceKind::Chrome` for owned launch and `SourceKind::Cdp` for a borrowed endpoint.
+
+`opsail-refit-codex` exposes `CodexRefit`, configured through `CodexRefitConfig`, with asynchronous `enable_usage`, `disable_usage`, `status`, and read-only `doctor` operations. The adapter supports the validated macOS application at `/Applications/ChatGPT.app` and the current user's validated `OpenAI.Codex` Microsoft Store package on the Windows x64 and ARM64 release targets; Linux and 32-bit Windows releases are unsupported. Enable is attach-only unless its typed launch policy is explicitly `LaunchIfStopped`; that policy may start the application once through the platform's validated launch mechanism but must never quit, kill, restart, reload, modify, or re-sign it. `doctor`, `status`, and `disable` never launch. Connections must use only `127.0.0.1` and fail closed unless the platform application identity, process ownership, renderer URL and shell, sidebar, and expected local bridge all validate. Codex protocol names, selectors, quota semantics, localization JSON, and UI copy belong in this crate, not in a shared module.
## Development workflow
@@ -36,6 +52,8 @@ cargo fmt --all -- --check
cargo test --workspace --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo build --release --workspace --locked
+npm test --prefix packages/node
+npm run pack:check --prefix packages/node
```
## Change expectations
@@ -43,7 +61,11 @@ cargo build --release --workspace --locked
- Add or update the narrowest test that proves a behavior change.
- Keep extraction fixtures self-contained. Review full Markdown golden changes manually; tests must never update them automatically.
- Keep default tests offline. HTTP behavior belongs in local mock-server tests.
+- Keep an explicit version in each Rust crate. Crates version independently; bump only a crate whose release contract changes, then update dependent version requirements deliberately.
- Preserve stdout as the data channel and stderr as the diagnostics channel.
- Keep JSON schema evolution additive unless `schemaVersion` changes.
- Treat acquired HTML, metadata, links, and extracted text as untrusted input.
- Document unsupported behavior and new trust boundaries.
+- Version the transient `bootstrap-opsail` procedure independently of the CLI and npm package; bump its `metadata.version` when bootstrap behavior changes.
+- Update the pinned Opsail version in `skills/opsail/SKILL.md` (`compatibility` and `metadata`) on `main` before tagging a CLI release; `bootstrap-opsail` installs the CLI from the latest release but the runtime Skill from `main`.
+- Treat `metadata.openclaw` and `metadata.hermes` as intentional host extensions. Strict Agent Skills metadata portability requires generated host projections; do not stringify or remove these objects without replacing their gating, installer, and discovery behavior.
diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md
index c9f8920..1498ee2 100644
--- a/CONTRIBUTING.zh-CN.md
+++ b/CONTRIBUTING.zh-CN.md
@@ -12,20 +12,36 @@
## Workspace 边界
```text
-crates/opsail CLI 解析、输出路由、诊断和退出行为
-crates/opsail-read HTML 获取、正文提取、清洗和结果 schema
+crates/opsail 原生 CLI 解析、协议路由、诊断和退出行为
+crates/opsail-chrome Chrome 可执行文件查找、自有生命周期、CDP 与 DOM 捕获
+crates/opsail-read 来源编排、HTML 获取、正文提取、清洗和结果 schema
+crates/opsail-refit-codex Codex refit 生命周期、目标安全校验与 renderer 集成
+packages/node 公开 `opsail` npm facade 与原生二进制解析
+skills/bootstrap-opsail 面向 Agent 的临时安装控制面
+skills/opsail 统一的 Opsail 运行时 Agent Skill
```
-`opsail` package 是轻量的进程适配层。提取规则、网络、清洗和结果模型属于 `opsail-read`。未来行动在具备内聚的类型化 API 和独立测试后,应成为同级 `opsail-` crate。在多个已实现模块证明有实际需要之前,不引入插件 ABI 或共享框架。
+原生 `opsail` crate 负责统一命令入口,公开的 `opsail` npm package 是轻量进程适配层。生成的 `@opsail/-` package 仅承载二进制实现,不是额外 API。`opsail-chrome` 负责所有 Chrome 专属机制:跨平台可执行文件查找、隔离进程的启动与清理、借用 CDP 连接、target 生命周期、导航等待和渲染后 DOM 捕获;它不负责正文提取或清洗。`opsail-read` 选择并校验来源,获取非浏览器 HTML,将浏览器捕获委托给 `opsail-chrome`,并负责提取、清洗和 `ReadResult`。`opsail-refit-codex` 负责 Codex 专属的应用身份、进程与 loopback CDP 校验、renderer bridge、选择器、额度语义、本地化资源和 UI payload;在第二个适配器证明存在稳定共享契约前,refit 生命周期保持为其内部模块。未来行动在具备内聚的类型化 API 和独立测试后,应成为同级 `opsail-` crate,再通过现有 CLI、npm facade 与统一运行时 Skill 暴露。在多个已实现模块证明有实际需要之前,不引入插件 ABI 或共享框架。
## 库入口
+`opsail-chrome` 提供两个所有权明确的入口:
+
+- `capture_chrome(&ChromeSource, &CaptureOptions)`:查找或使用已配置的可执行文件,以隔离的临时 profile 启动 Chrome,捕获一个页面,再停止自有浏览器。
+- `capture_cdp(&CdpSource, &CaptureOptions)`:借用调用方管理的 endpoint,不拥有该浏览器或其现有 target。
+
+可执行文件解析顺序必须保持为:显式路径、`OPSAIL_CHROME_PATH`、平台候选位置与 `PATH`。自有启动支持 macOS、Linux 与 Windows,使用 loopback 上动态分配的调试端口,不复用用户 profile,也不得静默添加 `--no-sandbox`。
+
+借用 CDP 的清理只能关闭 Opsail 自己创建的 target。正常完成时应 detach 并清理 target;若捕获 future 被突然取消或进程被终止,清理只能 best-effort,调用方始终保留该浏览器的所有权。
+
`opsail-read` 提供:
-- `read(Input, &ReadOptions)`:异步获取 URL、文件或 stdin 输入。
+- `read(ReadSource, &ReadOptions)`:异步获取 URL、文件、stdin、已捕获 HTML、借用 CDP 或自有 Chrome 输入。
- `extract_html(html, base_url)`:同步提取内存中的 HTML。
-两者都返回 CLI JSON 输出所使用的带版本号 `ReadResult` 模型。
+`opsail-read` 的两个入口都返回 CLI JSON 输出所使用的带版本号 `ReadResult` 模型。浏览器捕获保留不同的来源信息:自有启动使用 `SourceKind::Chrome`,借用 endpoint 使用 `SourceKind::Cdp`。
+
+`opsail-refit-codex` 暴露通过 `CodexRefitConfig` 配置的 `CodexRefit`,提供异步的 `enable_usage`、`disable_usage`、`status` 与只读 `doctor` 操作。适配器支持经过校验的 macOS 应用 `/Applications/ChatGPT.app`,以及 Windows x64 和 ARM64 发布目标上当前用户已校验的 `OpenAI.Codex` Microsoft Store 包;Linux 和 32 位 Windows 发布不受支持。Enable 默认为只附加;只有显式使用类型化 `LaunchIfStopped` 策略时,才可通过平台校验后的启动机制启动一次应用,但不得退出、kill、重启、重载、修改或重新签名它。`doctor`、`status` 与 `disable` 绝不启动应用。连接必须只使用 `127.0.0.1`,并且只有平台应用身份、进程归属、renderer URL 与 shell、侧栏以及预期本机 bridge 全部通过校验后才能继续。Codex 协议名、选择器、额度语义、本地化 JSON 和 UI 文案均属于此 crate,不进入共享模块。
## 开发流程
@@ -36,6 +52,8 @@ cargo fmt --all -- --check
cargo test --workspace --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo build --release --workspace --locked
+npm test --prefix packages/node
+npm run pack:check --prefix packages/node
```
## 变更要求
@@ -43,7 +61,11 @@ cargo build --release --workspace --locked
- 新增或更新能够证明行为变化的最小测试。
- 提取 fixture 必须自包含。完整 Markdown golden 的变化需要人工审阅,测试不得自动更新它们。
- 默认测试保持离线;HTTP 行为使用本地 mock server 测试。
+- 每个 Rust crate 都应显式声明版本并独立管理;仅在该 crate 的发布契约发生变化时升级,同时明确更新依赖方的版本约束。
- stdout 始终作为数据通道,stderr 始终作为诊断通道。
- 除非修改 `schemaVersion`,JSON schema 只做兼容性字段扩展。
- 获取的 HTML、元数据、链接和提取文本均视为不可信输入。
- 记录不支持的行为和新增的信任边界。
+- `bootstrap-opsail` 临时安装流程独立于 CLI 与 npm 包进行版本管理;bootstrap 行为发生变化时更新其 `metadata.version`。
+- 为 CLI 打 release tag 之前,先在 `main` 上更新 `skills/opsail/SKILL.md` 中固定的 Opsail 版本(`compatibility` 与 `metadata`);`bootstrap-opsail` 从最新 Release 安装 CLI,而 runtime Skill 取自 `main`。
+- 将 `metadata.openclaw` 与 `metadata.hermes` 视为有意保留的宿主扩展。严格的 Agent Skills 元数据兼容需要生成宿主投影;在替代其 gating、安装器与发现行为之前,不要将这些对象字符串化或移除。
diff --git a/Cargo.lock b/Cargo.lock
index c0364e5..57ff42f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -170,6 +170,15 @@ version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "brotli"
version = "8.0.4"
@@ -232,6 +241,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
[[package]]
name = "clap"
version = "4.6.1"
@@ -308,6 +323,18 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
+[[package]]
+name = "console"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "unicode-width 0.2.2",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -334,6 +361,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -343,6 +379,16 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
[[package]]
name = "cssparser"
version = "0.37.0"
@@ -366,6 +412,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
[[package]]
name = "deadpool"
version = "0.12.3"
@@ -411,6 +463,16 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
[[package]]
name = "displaydoc"
version = "0.2.6"
@@ -472,6 +534,12 @@ dependencies = [
"dtoa",
]
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -643,6 +711,16 @@ dependencies = [
"slab",
]
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -654,6 +732,18 @@ dependencies = [
"wasi",
]
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -662,7 +752,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 6.0.0",
]
[[package]]
@@ -944,6 +1034,19 @@ dependencies = [
"hashbrown",
]
+[[package]]
+name = "indicatif"
+version = "0.18.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
+dependencies = [
+ "console",
+ "portable-atomic",
+ "unicode-width 0.2.2",
+ "unit-prefix",
+ "web-time",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -1170,6 +1273,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nom"
version = "8.0.0"
@@ -1233,13 +1348,17 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "opsail"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"assert_cmd",
"clap",
+ "indicatif",
+ "libc",
"miette",
"opsail-read",
+ "opsail-refit-codex",
"predicates",
+ "serde",
"serde_json",
"tempfile",
"tokio",
@@ -1249,14 +1368,32 @@ dependencies = [
]
[[package]]
-name = "opsail-read"
+name = "opsail-chrome"
version = "0.1.0"
+dependencies = [
+ "futures-util",
+ "process-wrap",
+ "reqwest",
+ "rustls",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror",
+ "tokio",
+ "tokio-tungstenite",
+ "url",
+]
+
+[[package]]
+name = "opsail-read"
+version = "0.2.0"
dependencies = [
"ammonia",
"dom_query",
"dom_smoothie",
"encoding_rs",
"futures-util",
+ "opsail-chrome",
"reqwest",
"rustls",
"serde",
@@ -1264,12 +1401,33 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
+ "tokio-tungstenite",
"tracing",
"unicode-segmentation",
"url",
"wiremock",
]
+[[package]]
+name = "opsail-refit-codex"
+version = "0.1.0"
+dependencies = [
+ "futures-util",
+ "reqwest",
+ "ring",
+ "rustls",
+ "semver",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror",
+ "tokio",
+ "tokio-tungstenite",
+ "tracing",
+ "url",
+ "windows",
+]
+
[[package]]
name = "owo-colors"
version = "4.3.0"
@@ -1370,6 +1528,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+[[package]]
+name = "portable-atomic"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -1379,6 +1543,15 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -1424,6 +1597,19 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "process-wrap"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55"
+dependencies = [
+ "futures",
+ "indexmap",
+ "nix",
+ "tokio",
+ "windows",
+]
+
[[package]]
name = "quote"
version = "1.0.46"
@@ -1433,12 +1619,47 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1762,6 +1983,17 @@ dependencies = [
"stable_deref_trait",
]
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -1777,6 +2009,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -2039,6 +2281,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
+ "signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
@@ -2065,6 +2308,22 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
+dependencies = [
+ "futures-util",
+ "log",
+ "rustls",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tungstenite",
+ "webpki-roots 0.26.11",
+]
+
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -2195,6 +2454,30 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand",
+ "rustls",
+ "rustls-pki-types",
+ "sha1",
+ "thiserror",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -2225,6 +2508,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+[[package]]
+name = "unit-prefix"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -2262,6 +2551,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "wait-timeout"
version = "0.2.1"
@@ -2296,6 +2591,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
@@ -2374,6 +2678,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "web_atoms"
version = "0.2.5"
@@ -2395,6 +2709,24 @@ dependencies = [
"rustls-pki-types",
]
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.8",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -2404,12 +2736,89 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
[[package]]
name = "windows-registry"
version = "0.6.1"
@@ -2473,6 +2882,15 @@ dependencies = [
"windows_x86_64_msvc",
]
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -2544,6 +2962,12 @@ dependencies = [
"url",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
[[package]]
name = "writeable"
version = "0.6.3"
@@ -2573,6 +2997,26 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zerocopy"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "zerofrom"
version = "0.1.8"
diff --git a/Cargo.toml b/Cargo.toml
index 70d0931..0c67471 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,13 +1,17 @@
[workspace]
-members = ["crates/opsail", "crates/opsail-read"]
+members = [
+ "crates/opsail",
+ "crates/opsail-chrome",
+ "crates/opsail-read",
+ "crates/opsail-refit-codex",
+]
resolver = "3"
[workspace.package]
-version = "0.1.0"
edition = "2024"
rust-version = "1.97"
license = "Apache-2.0"
-authors = ["Opsail contributors"]
+authors = ["lencx "]
readme = "README.md"
repository = "https://github.com/lencx/opsail"
@@ -19,20 +23,27 @@ dom_query = { version = "0.28.0", default-features = false, features = ["markdow
dom_smoothie = { version = "0.18.0", features = ["serde"] }
encoding_rs = "0.8.35"
futures-util = { version = "0.3.32", default-features = false, features = ["std"] }
+indicatif = "0.18.6"
miette = { version = "7.6.0", features = ["fancy-no-backtrace"] }
+libc = "0.2.183"
predicates = "3.1.4"
+process-wrap = { version = "9.1.0", default-features = false, features = ["job-object", "kill-on-drop", "tokio1"] }
reqwest = { version = "0.13.4", default-features = false, features = ["brotli", "charset", "gzip", "http2", "rustls-no-provider", "stream", "system-proxy", "zstd"] }
+ring = "0.17.14"
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
+semver = "1.0.28"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
tempfile = "3.27.0"
thiserror = "2.0.18"
-tokio = { version = "1.52.3", features = ["fs", "io-std", "io-util", "macros", "rt-multi-thread", "time"] }
+tokio = { version = "1.52.3", features = ["fs", "io-std", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
+tokio-tungstenite = { version = "0.29.0", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] }
unicode-segmentation = "1.13.3"
url = { version = "2.5.8", features = ["serde"] }
wiremock = "0.6.5"
+windows = "0.62.2"
[workspace.lints.rust]
unsafe_code = "forbid"
diff --git a/README.md b/README.md
index 611922b..e1044e0 100644
--- a/README.md
+++ b/README.md
@@ -4,147 +4,88 @@
Opsail
+Native tools that agents can rely on.
+
English | 简体中文
-Opsail is a modular Rust CLI for small, composable actions used by software agents. Its first action, `read`, turns static HTML from an HTTP(S) URL, a local file, or standard input into readable Markdown, sanitized HTML, or versioned JSON.
-
-Opsail extracts the HTML it receives; it does not execute JavaScript, maintain a browser session, authenticate to sites, crawl links, or interact with pages.
-
-
-
-
- Crate
- Version
- Description
-
-
-
-
- opsail
-
- Agent action CLI and unified command entry point
-
-
- opsail-read
-
- Extracts clean Markdown, sanitized HTML, and structured JSON from static HTML
-
-
-
-
-## Installation
-
-### Prebuilt binaries
-
-On macOS or Linux:
-
-```sh
-curl --proto '=https' --proto-redir '=https' --tlsv1.2 -LsSf https://raw.githubusercontent.com/lencx/opsail/main/scripts/install.sh | sh
-```
-
-On Windows, run in PowerShell:
+
-```powershell
-irm -UseBasicParsing https://raw.githubusercontent.com/lencx/opsail/main/scripts/install.ps1 | iex
-```
+Opsail is a modular native toolkit that gives software agents small, composable, and reliable capabilities through one command-line entry point. Its Rust crates keep acquisition, browser control, content extraction, and application-specific refits behind explicit boundaries, while the Node.js package makes the same native runtime easy to embed.
-The installers detect the platform, verify the SHA-256 checksum, and install to `~/.local/bin`. Windows adds that directory to the user `PATH`; on macOS and Linux, the installer prints PATH setup guidance when needed.
+## Core characteristics
-Manual downloads:
+- **Native and predictable.** Long-running work, process ownership, transport, validation, and cleanup are implemented in Rust rather than shell scripts or proxy services.
+- **Small composable capabilities.** Each package owns one clear boundary and can be used independently or through the `opsail` CLI.
+- **Agent-ready contracts.** Commands expose stable output, structured diagnostics, bounded resource use, and quiet failure modes suitable for automation.
+- **Explicit trust boundaries.** Borrowed browsers, owned processes, remote content, and application refits are validated according to their actual ownership and security model.
+- **Reversible by design.** Refit features are target-validated, idempotent, and removable without modifying the target application bundle.
-- macOS: [Apple Silicon](https://github.com/lencx/opsail/releases/latest/download/opsail-aarch64-apple-darwin.tar.gz) · [Intel](https://github.com/lencx/opsail/releases/latest/download/opsail-x86_64-apple-darwin.tar.gz)
-- Linux: [x86_64](https://github.com/lencx/opsail/releases/latest/download/opsail-x86_64-unknown-linux-musl.tar.gz) · [ARM64](https://github.com/lencx/opsail/releases/latest/download/opsail-aarch64-unknown-linux-musl.tar.gz)
-- Windows: [x86_64](https://github.com/lencx/opsail/releases/latest/download/opsail-x86_64-pc-windows-msvc.zip)
-- [SHA-256 checksums](https://github.com/lencx/opsail/releases/latest/download/SHA256SUMS)
+## Core capabilities
-### Cargo
+### Read HTML
-Opsail requires Rust 1.97 or newer when installed from crates.io:
+`opsail read` turns static HTML or a browser-rendered DOM into readable Markdown, sanitized HTML, or versioned JSON. It accepts URLs, files, stdin, an Opsail-owned isolated Chrome process, or an explicitly borrowed CDP endpoint.
```sh
-cargo install opsail
+opsail read https://example.com/article
+opsail read https://example.com/app --launch
```
-Verify the installation:
+See [`opsail-read`](crates/opsail-read/README.md) for acquisition, extraction, result contracts, and Rust APIs. See [`opsail-chrome`](crates/opsail-chrome/README.md) for Chrome discovery, owned launch, borrowed CDP, navigation, and rendered DOM capture.
-```sh
-opsail --version
-```
+### Refit Codex
-## Read HTML
+`opsail refit codex` provides a reversible, target-validated Codex adapter. Its first feature adds localized remaining-usage information to the Codex sidebar using the renderer's existing local bridge, without model calls or changes to the application bundle.
-Markdown is the default output:
+
-```sh
-opsail read https://example.com/article
-opsail read ./article.html
-opsail read - < article.html
-```
-
-Choose another representation, resolve relative links for non-URL input, project one field, or write the result to a file:
+The refit target is implemented for the signed macOS application and the current-user Microsoft Store application on Windows; Linux is not supported. Windows release targets are x64 and ARM64; no 32-bit x86/ia32 artifact is provided. Opsail resolves the exact package family and AUMID, derives the application executable from the installed signed manifest (currently `app\ChatGPT.exe`), and protects its Local AppData state with an explicit current-user-and-SYSTEM DACL. Native CI and npm packaging targets are configured for both Windows architectures. A Windows 11 ARM64 canary against the installed Store application validates package activation, listener ownership, renderer discovery, bridge injection, persistence, and cleanup; a real installed-application x64 canary remains pending, while hosted CI covers the no-installed-package path.
```sh
-opsail read ./article.html --format html --output cleaned.html
-opsail read - --base-url https://example.com/articles/ < article.html
-opsail read ./article.html --format json
-opsail read ./article.html --property title
+opsail refit codex enable usage --launch
```
-`extract` is a visible alias for `read`. Run `opsail read --help` for request headers, timeout, byte-limit, and output options.
+Persistent mode starts a validated background manager and returns after its health report; `--once` remains ephemeral and `--foreground` is available for diagnostics.
-### Output contract
+Interactive waits show their current validated lifecycle stage on `stderr`, while the final machine-readable JSON remains isolated on `stdout`.
-Data is written to stdout, or to `--output PATH`. Diagnostics and extraction warnings are written to stderr, so stdout remains safe to pipe. Every successful representation ends with a newline. A downstream closed pipe is treated as a successful termination.
+See [`opsail-refit-codex`](crates/opsail-refit-codex/README.md) for supported targets, attach and launch modes, lifecycle semantics, renderer updates, localization, security checks, and library APIs.
-| Exit code | Meaning |
-| --- | --- |
-| `0` | Successful command, help, or version output |
-| `1` | Acquisition, extraction, serialization, or write failure |
-| `2` | Invalid command-line usage |
+## Packages
-`--format json` emits schema version `1` with these top-level fields:
-
-```text
-schemaVersion
-content
-contentHtml
-metadata
-source
-extraction
-quality
-warnings
-```
+| Package | Responsibility | Documentation |
+| --- | --- | --- |
+| [`opsail`](https://crates.io/crates/opsail) | Native CLI and unified command entry point | Run `opsail --help` |
+| [`opsail-read`](https://crates.io/crates/opsail-read) | Content acquisition, extraction, sanitization, and result contracts | [README](crates/opsail-read/README.md) |
+| [`opsail-chrome`](https://crates.io/crates/opsail-chrome) | Cross-platform Chrome lifecycle, CDP transport, and rendered capture | [README](crates/opsail-chrome/README.md) |
+| [`opsail-refit-codex`](https://crates.io/crates/opsail-refit-codex) | Validated Codex refit lifecycle, usage semantics, localization, and UI payload | [README](crates/opsail-refit-codex/README.md) |
+| [`opsail`](https://www.npmjs.com/package/opsail) for Node.js | ESM API and native binary distribution | [README](packages/node/README.md) |
-`content` is Markdown and `contentHtml` is sanitized HTML. Metadata includes the title and, when available, author, description, site, publication timestamps, image, favicon, language, direction, canonical URL, and domain. Source, extraction, and quality objects record provenance and useful confidence signals.
+## Install
-`--property` accepts:
+Install the CLI from crates.io:
-```text
-content, markdown, contentHtml, html, title, author, description, site,
-published, modified, image, favicon, language, direction, url, canonicalUrl, domain,
-wordCount, quality, source, extraction
+```sh
+cargo install opsail
```
-With `--format json`, a projected property is valid JSON. With Markdown or HTML format, scalar properties are plain text and structured properties are pretty-printed JSON.
-
-### Defaults and limits
+Install the Node.js API and CLI from npm:
-- Maximum input: 5 MiB; override with a positive `--max-bytes` value.
-- Maximum parsed DOM: 50,000 elements and 256 nesting levels.
-- HTTP(S) timeouts: 5 seconds to connect and 15 seconds overall; `--timeout` overrides the overall timeout.
-- Redirect limit: 10.
-- URL input and `--base-url` must use HTTP(S) and cannot contain embedded username/password credentials.
-- Character decoding considers a BOM, HTTP charset, HTML metadata, UTF-8 validity, then a Windows-1252 fallback.
-- A fetched body must look like HTML. If a media type is declared, it must be HTML or a tolerated generic text/binary type.
-- File input must be a regular file. Its links remain relative unless `--base-url` is supplied. URL input resolves links against the final response URL.
+```sh
+npm install opsail
+```
-The byte and DOM limits bound common resource-exhaustion paths; they are not a security sandbox. URL fetching can reach destinations allowed by the host network and honors the system proxy. Treat extracted text and links as untrusted, and enforce network, filesystem, and downstream execution policy in the embedding agent.
+Prebuilt native binaries are available from [GitHub Releases](https://github.com/lencx/opsail/releases/latest). Agent hosts can use the reviewed [`bootstrap-opsail` Skill](skills/bootstrap-opsail/SKILL.md) to reconcile the CLI and runtime Skill with explicit approval.
-## Contributing
+## Project documentation
-Development setup, module boundaries, testing rules, and verification commands are documented in [CONTRIBUTING.md](https://github.com/lencx/opsail/blob/main/CONTRIBUTING.md).
+- [Content extraction and result model](crates/opsail-read/README.md)
+- [Chrome and CDP integration](crates/opsail-chrome/README.md)
+- [Codex sidebar refit](crates/opsail-refit-codex/README.md)
+- [Node.js API and packaging](packages/node/README.md)
+- [Development and contribution guide](CONTRIBUTING.md)
## License
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 56c6c1e..c502cc2 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,150 +1,91 @@
-
+
Opsail
+让 Agent 可以信赖的原生工具。
+
English | 简体中文
-Opsail 是一个模块化 Rust CLI,为软件 Agent 提供小型、可组合的行动能力。首个行动 `read` 可将 HTTP(S) URL、本地文件或标准输入中的静态 HTML 转换为可阅读的 Markdown、清洗后的 HTML 或带版本号的 JSON。
-
-Opsail 只提取接收到的 HTML;它不会执行 JavaScript、维护浏览器会话、登录网站、抓取链接或与页面交互。
-
-
-
-
- Crate
- 版本
- 描述
-
-
-
-
- opsail
-
- 面向 Agent 行动的 CLI 与统一命令入口
-
-
- opsail-read
-
- 从静态 HTML 中提取干净的 Markdown、清洗后的 HTML 和结构化 JSON
-
-
-
+
-## 安装
+Opsail 是一个模块化原生工具集,通过统一的命令行入口,为软件 Agent 提供小而可组合、行为可靠的能力。它使用职责清晰的 Rust crate 隔离内容获取、浏览器控制、正文提取和应用适配,并通过 Node.js 包方便地嵌入同一套原生运行时。
-### 预编译二进制
+## 核心特色
-在 macOS 或 Linux 上运行:
+- **原生且可预测**:长期运行、进程归属、传输、校验和清理均由 Rust 实现,不依赖 shell 脚本或代理服务作为主引擎。
+- **能力小而可组合**:每个包只负责一个清晰边界,既可以独立使用,也可以通过 `opsail` CLI 统一调用。
+- **面向 Agent 的契约**:命令提供稳定输出、结构化诊断、受控资源占用和适合自动化的安静失败模式。
+- **明确的信任边界**:对借用的浏览器、自有进程、远程内容和应用适配,按照各自真实的所有权与安全模型进行校验。
+- **默认可逆**:Refit 功能会验证目标,支持幂等执行和完整移除,不修改目标应用包。
-```sh
-curl --proto '=https' --proto-redir '=https' --tlsv1.2 -LsSf https://raw.githubusercontent.com/lencx/opsail/main/scripts/install.sh | sh
-```
-
-在 Windows 上,使用 PowerShell 运行:
-
-```powershell
-irm -UseBasicParsing https://raw.githubusercontent.com/lencx/opsail/main/scripts/install.ps1 | iex
-```
+## 核心能力
-安装器会自动识别平台、验证 SHA-256 校验和,并安装到 `~/.local/bin`。Windows 会自动将该目录加入用户 `PATH`;macOS 和 Linux 在需要时会输出 PATH 配置提示。
+### 读取 HTML
-手动下载:
-
-- macOS:[Apple Silicon](https://github.com/lencx/opsail/releases/latest/download/opsail-aarch64-apple-darwin.tar.gz) · [Intel](https://github.com/lencx/opsail/releases/latest/download/opsail-x86_64-apple-darwin.tar.gz)
-- Linux:[x86_64](https://github.com/lencx/opsail/releases/latest/download/opsail-x86_64-unknown-linux-musl.tar.gz) · [ARM64](https://github.com/lencx/opsail/releases/latest/download/opsail-aarch64-unknown-linux-musl.tar.gz)
-- Windows:[x86_64](https://github.com/lencx/opsail/releases/latest/download/opsail-x86_64-pc-windows-msvc.zip)
-- [SHA-256 校验文件](https://github.com/lencx/opsail/releases/latest/download/SHA256SUMS)
-
-### Cargo
-
-通过 crates.io 安装时,Opsail 需要 Rust 1.97 或更高版本:
+`opsail read` 可以将静态 HTML 或浏览器渲染后的 DOM 转换为易读的 Markdown、经过清理的 HTML 或带版本的 JSON。输入可以来自 URL、文件、stdin、由 Opsail 启动的隔离 Chrome,或显式借用的 CDP 端点。
```sh
-cargo install opsail
+opsail read https://example.com/article
+opsail read https://example.com/app --launch
```
-验证安装:
-
-```sh
-opsail --version
-```
+内容获取、正文提取、结果契约和 Rust API 请参阅 [`opsail-read`](crates/opsail-read/README.md);Chrome 发现、自有启动、借用 CDP、页面导航和渲染 DOM 捕获请参阅 [`opsail-chrome`](crates/opsail-chrome/README.md)。
-## 读取 HTML
+### Codex Refit
-默认输出 Markdown:
+`opsail refit codex` 提供可逆且经过目标校验的 Codex 适配器。它的首个功能通过 renderer 已有的本地 bridge,在 Codex 左侧栏显示本地化的剩余额度信息,不调用模型,也不修改应用包。
-```sh
-opsail read https://example.com/article
-opsail read ./article.html
-opsail read - < article.html
-```
+
-可以选择其他输出形式、为非 URL 输入解析相对链接、投影单个字段,或将结果写入文件:
+Refit 目标已实现对签名 macOS 应用和 Windows 当前用户 Microsoft Store 应用的支持;Linux 不支持。Windows 发布目标为 x64 和 ARM64,不提供 32 位 x86/ia32 产物。Windows 实现使用精确的包家族名和 AUMID 定位应用,从已安装的签名 manifest 中解析可执行文件(当前为 `app\ChatGPT.exe`),并使用仅授权当前用户和 SYSTEM 的显式 DACL 保护 Local AppData 状态。Windows x64 和 ARM64 都已配置原生 CI 与 npm 打包目标;已在 Windows 11 ARM64 的 Store 应用上完成包激活、端口归属、renderer 发现、bridge 注入、持久模式与清理的端到端验证,真实 x64 Store 应用 canary 仍待完成,托管 CI 覆盖未安装目标包的路径。
```sh
-opsail read ./article.html --format html --output cleaned.html
-opsail read - --base-url https://example.com/articles/ < article.html
-opsail read ./article.html --format json
-opsail read ./article.html --property title
+opsail refit codex enable usage --launch
```
-`extract` 是 `read` 的可见别名。运行 `opsail read --help` 可查看请求头、超时、字节限制和输出选项。
-
-### 输出契约
+默认 persistent 模式会启动经过校验的后台 manager,并在输出健康报告后返回;`--once` 仍是单次注入,诊断时可显式使用 `--foreground`。
-数据写入 stdout,或写入 `--output PATH` 指定的文件。诊断和提取警告写入 stderr,因此 stdout 可以安全地通过管道传递。每个成功结果都以换行符结尾;下游提前关闭管道会被视为正常结束。
+交互式等待会在 `stderr` 中显示当前经过校验的生命周期阶段,最终供程序读取的 JSON 仍只写入 `stdout`。
-| 退出码 | 含义 |
-| --- | --- |
-| `0` | 命令成功,或成功输出帮助/版本信息 |
-| `1` | 获取、提取、序列化或写入失败 |
-| `2` | 命令行用法无效 |
+支持目标、附加与启动模式、生命周期语义、renderer 更新、多语言、安全校验和库 API 请参阅 [`opsail-refit-codex`](crates/opsail-refit-codex/README.md)。
-`--format json` 输出 schema 版本 `1`,包含以下顶层字段:
+## 包结构
-```text
-schemaVersion
-content
-contentHtml
-metadata
-source
-extraction
-quality
-warnings
-```
+| 包 | 职责 | 文档 |
+| --- | --- | --- |
+| [`opsail`](https://crates.io/crates/opsail) | 原生 CLI 与统一命令入口 | 运行 `opsail --help` |
+| [`opsail-read`](https://crates.io/crates/opsail-read) | 内容获取、正文提取、清理和结果契约 | [README](crates/opsail-read/README.md) |
+| [`opsail-chrome`](https://crates.io/crates/opsail-chrome) | 跨平台 Chrome 生命周期、CDP 传输和渲染捕获 | [README](crates/opsail-chrome/README.md) |
+| [`opsail-refit-codex`](https://crates.io/crates/opsail-refit-codex) | Codex 适配生命周期、额度语义、多语言和 UI payload | [README](crates/opsail-refit-codex/README.md) |
+| Node.js [`opsail`](https://www.npmjs.com/package/opsail) | ESM API 与原生二进制分发 | [README](packages/node/README.md) |
-`content` 是 Markdown,`contentHtml` 是清洗后的 HTML。元数据包含标题,以及可用时的作者、描述、站点、发布时间、图片、图标、语言、文字方向、规范 URL 和域名。`source`、`extraction` 和 `quality` 对象记录来源、提取过程和质量信号。
+## 安装
-`--property` 接受:
+从 crates.io 安装 CLI:
-```text
-content, markdown, contentHtml, html, title, author, description, site,
-published, modified, image, favicon, language, direction, url, canonicalUrl, domain,
-wordCount, quality, source, extraction
+```sh
+cargo install opsail
```
-使用 `--format json` 时,投影字段输出合法 JSON;使用 Markdown 或 HTML 格式时,标量字段输出纯文本,结构化字段输出格式化 JSON。
+从 npm 安装 Node.js API 和 CLI:
-### 默认值与限制
-
-- 最大输入为 5 MiB,可通过正数 `--max-bytes` 覆盖。
-- 解析后的 DOM 最多包含 50,000 个元素,嵌套深度最多为 256 层。
-- HTTP(S) 连接超时为 5 秒,总超时为 15 秒;`--timeout` 可覆盖总超时。
-- 最多跟随 10 次重定向。
-- URL 输入和 `--base-url` 必须使用 HTTP(S),且不能包含用户名/密码凭据。
-- 字符解码依次考虑 BOM、HTTP charset、HTML 元数据、UTF-8 有效性,最后回退到 Windows-1252。
-- 获取的响应体必须表现为 HTML;如果声明了媒体类型,则必须是 HTML 或允许的通用文本/二进制类型。
-- 文件输入必须是普通文件。除非提供 `--base-url`,文件中的链接会保持相对形式;URL 输入则以重定向后的最终 URL 解析链接。
+```sh
+npm install opsail
+```
-字节和 DOM 限制可约束常见的资源耗尽路径,但它们不是安全沙箱。URL 获取能够访问宿主网络允许的目标,并遵循系统代理设置。提取出的文本和链接都应视为不可信数据;嵌入 Agent 时,应另行实施网络、文件系统和下游执行策略。
+预编译原生二进制可从 [GitHub Releases](https://github.com/lencx/opsail/releases/latest) 下载。Agent 宿主可以在明确授权后,使用经过审阅的 [`bootstrap-opsail` Skill](skills/bootstrap-opsail/SKILL.md) 同步 CLI 和运行时 Skill。
-## 参与贡献
+## 项目文档
-开发环境、模块边界、测试规则与验证命令见 [CONTRIBUTING.zh-CN.md](https://github.com/lencx/opsail/blob/main/CONTRIBUTING.zh-CN.md)。
+- [内容提取与结果模型](crates/opsail-read/README.md)
+- [Chrome 与 CDP 集成](crates/opsail-chrome/README.md)
+- [Codex 左侧栏 Refit](crates/opsail-refit-codex/README.md)
+- [Node.js API 与打包](packages/node/README.md)
+- [开发与贡献指南](CONTRIBUTING.md)
## 许可证
diff --git a/assets/refit-codex.png b/assets/refit-codex.png
new file mode 100644
index 0000000..4ad5626
Binary files /dev/null and b/assets/refit-codex.png differ
diff --git a/crates/opsail-chrome/Cargo.toml b/crates/opsail-chrome/Cargo.toml
new file mode 100644
index 0000000..ca437c8
--- /dev/null
+++ b/crates/opsail-chrome/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "opsail-chrome"
+description = "Chrome lifecycle and CDP capture for Opsail"
+version = "0.1.0"
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+readme = "README.md"
+repository.workspace = true
+
+[dependencies]
+futures-util.workspace = true
+reqwest.workspace = true
+rustls.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+tempfile.workspace = true
+thiserror.workspace = true
+tokio.workspace = true
+tokio-tungstenite.workspace = true
+url.workspace = true
+
+[target.'cfg(windows)'.dependencies]
+process-wrap.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/opsail-chrome/README.md b/crates/opsail-chrome/README.md
new file mode 100644
index 0000000..998a8ad
--- /dev/null
+++ b/crates/opsail-chrome/README.md
@@ -0,0 +1,166 @@
+# opsail-chrome
+
+`opsail-chrome` owns Opsail's Chrome-specific boundary: cross-platform local
+executable discovery and launch, caller-managed CDP connections, target
+lifecycle, navigation waits, and rendered DOM capture.
+
+```mermaid
+flowchart LR
+ O["ChromeSource owned"] --> L["Discover executable isolated launch"]
+ B["CdpSource borrowed"] --> C["CDP connection"]
+ L --> C
+ C --> P["Target / navigate / wait capture rendered DOM"]
+ P --> R["CapturedPage HTML + final URL bounded main response"]
+ R --> X["opsail-read extract / sanitize / normalize"]
+```
+
+The crate supports two explicit ownership modes:
+
+- `capture_cdp` borrows a caller-managed Chrome endpoint and never closes the
+ browser or a caller-owned target.
+- `capture_chrome` discovers or uses an explicitly configured executable,
+ launches an isolated temporary Chrome profile, captures one page, and stops
+ only that owned process.
+
+Both modes return a `CapturedPage` containing HTML, the final URL, and optional
+privacy-bounded top-level response metadata. Content extraction, sanitization,
+verification classification, and `ReadResult` belong to `opsail-read`, not this
+crate.
+
+## Rust API
+
+```toml
+[dependencies]
+opsail-chrome = "0.1"
+tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+```
+
+```rust
+use opsail_chrome::{
+ CaptureOptions, CdpSource, ChromeSource, capture_cdp, capture_chrome,
+};
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let options = CaptureOptions::default();
+
+ let owned_source = ChromeSource::new("https://example.com/app".parse()?);
+ let owned_page = capture_chrome(&owned_source, &options).await?;
+ println!("{}", owned_page.final_url);
+
+ let mut borrowed_source = CdpSource::new("http://127.0.0.1:9222");
+ borrowed_source.url = Some("https://example.com/app".parse()?);
+ let borrowed_page = capture_cdp(&borrowed_source, &options).await?;
+ println!("{}", borrowed_page.final_url);
+
+ Ok(())
+}
+```
+
+## Owned local launch
+
+`capture_chrome(&ChromeSource, &CaptureOptions)` uses Chrome's
+`--remote-debugging-port=0` and reads the generated `DevToolsActivePort` file,
+so no fixed port or macOS-only executable path is embedded in the protocol. It
+starts headless Chrome with a fresh temporary `--user-data-dir`, captures one
+page, requests browser shutdown, terminates the process if needed, and removes
+the temporary profile.
+
+The launch contract follows Chrome's current automation guidance:
+
+```text
+chrome --headless \
+ --remote-debugging-address=127.0.0.1 \
+ --remote-debugging-port=0 \
+ --user-data-dir= \
+ about:blank
+```
+
+- [`--headless`](https://developer.chrome.com/docs/chromium/headless) selects
+ Chrome's unified headless implementation. A zero debugging port lets Chrome
+ allocate a collision-free port.
+- [Chrome 136 and later](https://developer.chrome.com/blog/remote-debugging-port)
+ require remote-debugging switches to use a non-default data directory.
+ Opsail always creates a fresh profile and never weakens this requirement.
+- Chrome recommends Chrome for Testing for reproducible browser automation. An
+ installed macOS Chrome for Testing is preferred during discovery; on every
+ platform it can be selected explicitly with `ChromeSource::executable_path`,
+ `--chrome-path`, or `OPSAIL_CHROME_PATH`.
+
+The loopback address is explicit rather than relying on a browser default.
+Opsail consumes the endpoint from the isolated profile instead of parsing
+human-readable process output.
+
+Executable resolution is deterministic on macOS, Linux, and Windows:
+
+1. `ChromeSource::executable_path` (the CLI's `--chrome-path` value).
+2. `OPSAIL_CHROME_PATH`.
+3. Supported platform locations, then executable names found through `PATH`.
+
+Owned launch never points at or copies the user's normal Chrome profile, so it
+does not inherit that profile's cookies or authenticated sessions. It also does
+not add `--no-sandbox`; sandbox policy remains an explicit responsibility of
+the host environment.
+
+When `CaptureOptions::user_agent` is `None`, owned launch asks that Chrome
+process for its actual User-Agent and changes only the
+`HeadlessChrome/` product token to `Chrome/`. The browser
+version and every other product token remain browser-derived. An explicit
+User-Agent is applied unchanged and always takes precedence.
+
+## Borrowed CDP
+
+`capture_cdp(&CdpSource, &CaptureOptions)` accepts a local port, an HTTP(S)
+discovery endpoint, or a browser/page WebSocket URL. It never closes the
+caller-managed browser or a caller-owned target. A target created by Opsail for
+one navigation is closed during normal completion and cleanup is attempted on
+bounded failures. If the capture future is abruptly cancelled or the process
+is terminated, detaching and target cleanup are best-effort; the borrowed
+browser remains the caller's responsibility.
+
+Treat a borrowed endpoint as high-trust configuration because it may expose
+authenticated pages. Endpoint URLs and query parameters must not be included
+in captured results or public diagnostics.
+
+Borrowed CDP preserves the caller-managed browser's User-Agent when
+`CaptureOptions::user_agent` is `None`. An explicit value is applied unchanged
+before navigation. This differs intentionally from the owned-launch policy:
+Opsail does not rewrite the identity of a browser it does not own.
+
+## Main-document response metadata
+
+When Opsail performs a navigation, `CapturedPage::response()` may expose a
+`CapturedResponse` for the top-level document. It contains the HTTP status and
+normalized indicators derived from an allowlist of only two case-insensitive
+headers: `cf-mitigated` and `x-amzn-waf-action`. Raw header values are not
+retained. These provider-declared signals let `opsail-read` classify Cloudflare
+and AWS WAF verification without relying on page text.
+
+The response is optional because an existing target may not have navigated
+through this capture, and a CDP endpoint may not expose the necessary Network
+events. Evidence is associated only when its frame, loader, and response URL
+match the captured final main document. Cookies, authorization data, raw
+allowlisted values, and arbitrary headers are never retained in `CapturedPage`.
+`opsail-chrome` captures this bounded evidence but does not decide whether a
+page is usable content or attempt to solve a verification challenge.
+
+## Rendered page evidence
+
+`capture_cdp_with_probes` and `capture_chrome_with_probes` accept at most 16
+bounded CSS selectors. Selectors are encoded as `Runtime.callFunctionOn`
+arguments to a fixed function in an isolated world; they are never executable
+source. `CapturedPage::rendered_evidence()` exposes only match counts,
+visibility and stability booleans, and viewport/paint-hit coverage ratios.
+It does not expose text, attributes, rectangles, screenshots, or raw selectors.
+
+The observer accounts for computed ancestor visibility, clipping, opacity,
+viewport intersection, and frontmost paint ownership on a fixed 5 by 5 grid.
+The root frame, loader, and final URL are read before and after observation. A
+navigation race, unsupported CDP command, invalid result, or observation timeout
+removes the rendered evidence without failing an otherwise valid HTML capture.
+Provider identity and verification policy remain the responsibility of
+`opsail-read`.
+
+## License
+
+Apache-2.0
diff --git a/crates/opsail-chrome/src/cdp.rs b/crates/opsail-chrome/src/cdp.rs
new file mode 100644
index 0000000..7746a1e
--- /dev/null
+++ b/crates/opsail-chrome/src/cdp.rs
@@ -0,0 +1,1473 @@
+use std::collections::VecDeque;
+use std::sync::Once;
+use std::time::Duration;
+
+use futures_util::{SinkExt, StreamExt};
+use reqwest::redirect::Policy;
+use serde_json::{Value, json};
+use tokio::net::TcpStream;
+use tokio::time::{Instant, timeout_at};
+use tokio_tungstenite::tungstenite::Message;
+use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
+use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async_with_config};
+use url::Url;
+
+use crate::rendered;
+use crate::{
+ CaptureOptions, CapturedPage, CapturedResponse, CdpSource, CdpWaitUntil, ChromeError,
+ RenderedProbe,
+};
+
+const DISCOVERY_MAX_BYTES: usize = 1024 * 1024;
+const MAX_CDP_CAPTURE_BYTES: usize = 16 * 1024 * 1024;
+const MAX_CDP_MESSAGE_BYTES: usize = 128 * 1024 * 1024;
+const MAX_EVENT_FIELD_BYTES: usize = 512;
+const MAX_EVENT_URL_BYTES: usize = 16 * 1024;
+const MAX_QUEUED_EVENTS: usize = 4_096;
+const CLEANUP_TIMEOUT: Duration = Duration::from_millis(500);
+const CAPTURE_EXPRESSION: &str = r#"(() => ({
+ html: document.documentElement?.outerHTML ?? "",
+ finalUrl: location.href
+}))()"#;
+const RENDER_OBSERVER_WORLD: &str = "opsail-render-observer";
+const RENDER_OBSERVER_FUNCTION: &str = r#"function(probes) {
+ const MAX_ELEMENTS_PER_PROBE = 32;
+ const GRID_SIZE = 5;
+ const visual = window.visualViewport;
+ const viewport = {
+ left: visual ? visual.offsetLeft : 0,
+ top: visual ? visual.offsetTop : 0,
+ width: visual ? visual.width : window.innerWidth,
+ height: visual ? visual.height : window.innerHeight
+ };
+ const clampPerMille = value => Math.max(0, Math.min(1000, Math.round(value * 1000)));
+ const intersect = (a, b) => {
+ const left = Math.max(a.left, b.left);
+ const top = Math.max(a.top, b.top);
+ const right = Math.min(a.right, b.right);
+ const bottom = Math.min(a.bottom, b.bottom);
+ return { left, top, right, bottom, width: Math.max(0, right - left), height: Math.max(0, bottom - top) };
+ };
+ const viewportRect = {
+ left: viewport.left,
+ top: viewport.top,
+ right: viewport.left + viewport.width,
+ bottom: viewport.top + viewport.height,
+ width: viewport.width,
+ height: viewport.height
+ };
+ const measurement = element => {
+ if (!(element instanceof Element) || !element.isConnected || viewport.width <= 0 || viewport.height <= 0) {
+ return { visible: false, x: 0, y: 0, width: 0, height: 0, viewportCoverage: 0, hitCoverage: 0, position: "" };
+ }
+ let opacity = 1;
+ for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
+ const style = getComputedStyle(ancestor);
+ if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.contentVisibility === "hidden") {
+ return { visible: false, x: 0, y: 0, width: 0, height: 0, viewportCoverage: 0, hitCoverage: 0, position: "" };
+ }
+ const value = Number.parseFloat(style.opacity);
+ if (Number.isFinite(value)) opacity *= value;
+ if (opacity <= 0.01) {
+ return { visible: false, x: 0, y: 0, width: 0, height: 0, viewportCoverage: 0, hitCoverage: 0, position: "" };
+ }
+ }
+ const raw = element.getBoundingClientRect();
+ let clipped = intersect(
+ { left: raw.left, top: raw.top, right: raw.right, bottom: raw.bottom, width: raw.width, height: raw.height },
+ viewportRect
+ );
+ for (let ancestor = element.parentElement; ancestor && clipped.width > 0 && clipped.height > 0; ancestor = ancestor.parentElement) {
+ const style = getComputedStyle(ancestor);
+ const clipsX = ["hidden", "clip", "scroll", "auto"].includes(style.overflowX);
+ const clipsY = ["hidden", "clip", "scroll", "auto"].includes(style.overflowY);
+ if (clipsX || clipsY) {
+ const box = ancestor.getBoundingClientRect();
+ const clip = {
+ left: clipsX ? box.left : viewportRect.left,
+ right: clipsX ? box.right : viewportRect.right,
+ top: clipsY ? box.top : viewportRect.top,
+ bottom: clipsY ? box.bottom : viewportRect.bottom
+ };
+ clipped = intersect(clipped, {
+ ...clip,
+ width: Math.max(0, clip.right - clip.left),
+ height: Math.max(0, clip.bottom - clip.top)
+ });
+ }
+ }
+ const visible = raw.width > 0 && raw.height > 0 && clipped.width > 0 && clipped.height > 0;
+ if (!visible) {
+ return { visible: false, x: raw.left, y: raw.top, width: raw.width, height: raw.height, viewportCoverage: 0, hitCoverage: 0, position: getComputedStyle(element).position };
+ }
+ const viewportArea = viewport.width * viewport.height;
+ let owned = 0;
+ const samples = GRID_SIZE * GRID_SIZE;
+ for (let row = 0; row < GRID_SIZE; row += 1) {
+ for (let column = 0; column < GRID_SIZE; column += 1) {
+ const x = viewport.left + viewport.width * (column + 0.5) / GRID_SIZE;
+ const y = viewport.top + viewport.height * (row + 0.5) / GRID_SIZE;
+ const hit = document.elementFromPoint(x, y);
+ if (hit && (hit === element || element.contains(hit))) owned += 1;
+ }
+ }
+ return {
+ visible: true,
+ x: raw.left,
+ y: raw.top,
+ width: raw.width,
+ height: raw.height,
+ viewportCoverage: clampPerMille((clipped.width * clipped.height) / viewportArea),
+ hitCoverage: clampPerMille(owned / samples),
+ position: getComputedStyle(element).position
+ };
+ };
+ const score = surface => (surface.visible ? 1000000 : 0) + surface.viewportCoverage * 1000 + surface.hitCoverage;
+ const sampleProbe = probe => {
+ let matches = [];
+ try { matches = Array.from(document.querySelectorAll(probe.selector)); } catch (_) { matches = []; }
+ const bounded = matches.slice(0, MAX_ELEMENTS_PER_PROBE);
+ let markerElement = null;
+ let marker = null;
+ let takeoverElement = null;
+ let takeover = null;
+ for (const element of bounded) {
+ const measured = measurement(element);
+ if (!marker || score(measured) > score(marker)) {
+ markerElement = element;
+ marker = measured;
+ }
+ for (let surface = element; surface && surface !== document.body && surface !== document.documentElement; surface = surface.parentElement) {
+ const candidate = measurement(surface);
+ const eligible = candidate.visible && (candidate.position === "fixed" || (candidate.position === "absolute" && candidate.viewportCoverage >= 800));
+ if (eligible && (!takeover || score(candidate) > score(takeover))) {
+ takeoverElement = surface;
+ takeover = candidate;
+ }
+ }
+ }
+ return {
+ id: probe.id,
+ matches: Math.min(matches.length, 65535),
+ markerElement,
+ marker,
+ takeoverElement,
+ takeover
+ };
+ };
+ const sample = () => probes.map(sampleProbe);
+ const firstUrl = location.href;
+ const first = sample();
+ const stableSurface = (beforeElement, before, afterElement, after) => {
+ if (!before || !after) return null;
+ const stable = beforeElement === afterElement && firstUrl === location.href &&
+ before.visible === after.visible && Math.abs(before.x - after.x) <= 1 &&
+ Math.abs(before.y - after.y) <= 1 && Math.abs(before.width - after.width) <= 1 &&
+ Math.abs(before.height - after.height) <= 1 &&
+ Math.abs(before.hitCoverage - after.hitCoverage) <= 40;
+ return {
+ visible: after.visible,
+ stable,
+ viewportCoverage: after.viewportCoverage,
+ hitCoverage: after.hitCoverage
+ };
+ };
+ return new Promise(resolve => {
+ let completed = false;
+ const finish = timedOut => {
+ if (completed) return;
+ completed = true;
+ const second = sample();
+ const results = second.map((current, index) => {
+ const previous = first[index];
+ return {
+ id: current.id,
+ matches: current.matches,
+ marker: stableSurface(previous.markerElement, previous.marker, current.markerElement, current.marker),
+ takeover: stableSurface(previous.takeoverElement, previous.takeover, current.takeoverElement, current.takeover)
+ };
+ });
+ resolve({
+ html: document.documentElement ? document.documentElement.outerHTML : "",
+ finalUrl: location.href,
+ renderedEvidence: { timedOut, results }
+ });
+ };
+ const timer = setTimeout(() => finish(true), 250);
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ clearTimeout(timer);
+ finish(false);
+ }));
+ });
+}"#;
+static INSTALL_TLS_PROVIDER: Once = Once::new();
+
+struct ResolvedEndpoint {
+ url: Url,
+ direct_page: bool,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum UserAgentPolicy {
+ Preserve,
+ BrowserCompatible,
+}
+
+struct AttachedPage {
+ session_id: Option,
+ target_id: Option,
+ owned_target: bool,
+ owned_cleanup: Option,
+}
+
+struct OwnedTargetCleanup {
+ endpoint: Url,
+ target_id: Option,
+}
+
+impl OwnedTargetCleanup {
+ fn new(endpoint: Url, target_id: String) -> Self {
+ Self {
+ endpoint,
+ target_id: Some(target_id),
+ }
+ }
+
+ fn disarm(&mut self) {
+ self.target_id = None;
+ }
+}
+
+impl Drop for OwnedTargetCleanup {
+ fn drop(&mut self) {
+ let Some(target_id) = self.target_id.take() else {
+ return;
+ };
+ let endpoint = self.endpoint.clone();
+ let Ok(runtime) = tokio::runtime::Handle::try_current() else {
+ return;
+ };
+ runtime.spawn(async move {
+ close_target_at_endpoint(endpoint, target_id).await;
+ });
+ }
+}
+
+enum CdpEvent {
+ Lifecycle(LifecycleEvent),
+ DocumentResponse(DocumentResponseEvent),
+}
+
+struct LifecycleEvent {
+ session_id: Option,
+ name: String,
+ loader_id: String,
+}
+
+struct DocumentResponseEvent {
+ session_id: Option,
+ loader_id: String,
+ frame_id: String,
+ url: Url,
+ response: CapturedResponse,
+}
+
+#[derive(Clone, Eq, PartialEq)]
+struct MainDocumentIdentity {
+ frame_id: String,
+ loader_id: String,
+ url: Url,
+}
+
+type CdpSocket = WebSocketStream>;
+
+struct CdpConnection {
+ socket: CdpSocket,
+ next_id: u64,
+ events: VecDeque,
+ deadline: Instant,
+}
+
+pub(crate) async fn capture(
+ source: &CdpSource,
+ options: &CaptureOptions,
+ user_agent_policy: UserAgentPolicy,
+ probes: &[RenderedProbe],
+) -> Result {
+ let deadline = Instant::now()
+ .checked_add(options.timeout)
+ .ok_or(ChromeError::CdpTimeout)?;
+ install_tls_provider();
+ let endpoint = resolve_endpoint(source, options, deadline).await?;
+ if endpoint.direct_page && source.target_id.is_some() {
+ return Err(ChromeError::CdpTargetNotFound);
+ }
+ let capture_limit = options.max_bytes.min(MAX_CDP_CAPTURE_BYTES);
+ let max_message_size = capture_limit
+ .saturating_mul(6)
+ .saturating_add(DISCOVERY_MAX_BYTES)
+ .min(MAX_CDP_MESSAGE_BYTES);
+ let mut connection = CdpConnection::connect(
+ &endpoint.url,
+ max_message_size,
+ options.connect_timeout,
+ deadline,
+ )
+ .await?;
+
+ let (mut page, result) = if endpoint.direct_page {
+ let page = AttachedPage {
+ session_id: None,
+ target_id: None,
+ owned_target: false,
+ owned_cleanup: None,
+ };
+ let result = capture_page(
+ &mut connection,
+ source,
+ options,
+ user_agent_policy,
+ None,
+ false,
+ probes,
+ )
+ .await;
+ (page, result)
+ } else {
+ connection.command("Browser.getVersion", None, None).await?;
+ let page = attach_page(
+ &mut connection,
+ source,
+ &endpoint.url,
+ user_agent_policy == UserAgentPolicy::Preserve,
+ )
+ .await?;
+ let result = capture_page(
+ &mut connection,
+ source,
+ options,
+ user_agent_policy,
+ page.session_id.as_deref(),
+ page.owned_target,
+ probes,
+ )
+ .await;
+ (page, result)
+ };
+
+ connection.deadline = Instant::now() + CLEANUP_TIMEOUT;
+ cleanup_page(&mut connection, &mut page).await;
+ let _ = timeout_at(connection.deadline, connection.socket.close(None)).await;
+
+ let captured = result?;
+ if captured.html.len() > capture_limit {
+ return Err(ChromeError::CaptureTooLarge {
+ limit: capture_limit,
+ });
+ }
+ Ok(captured)
+}
+
+pub(crate) async fn close_browser(endpoint: &str, timeout: Duration) {
+ let Ok(endpoint) = Url::parse(endpoint) else {
+ return;
+ };
+ let Some(deadline) = Instant::now().checked_add(timeout) else {
+ return;
+ };
+ let Ok(mut connection) =
+ CdpConnection::connect(&endpoint, DISCOVERY_MAX_BYTES, timeout, deadline).await
+ else {
+ return;
+ };
+ let _ = connection.command("Browser.close", None, None).await;
+ let _ = timeout_at(deadline, connection.socket.close(None)).await;
+}
+
+async fn attach_page(
+ connection: &mut CdpConnection,
+ source: &CdpSource,
+ endpoint: &Url,
+ create_in_background: bool,
+) -> Result {
+ let (target_id, owned_target) = if source.url.is_some() && source.target_id.is_none() {
+ let result = connection
+ .command(
+ "Target.createTarget",
+ Some(json!({
+ "url": "about:blank",
+ "background": create_in_background
+ })),
+ None,
+ )
+ .await?;
+ (
+ required_string(&result, "targetId", "Target.createTarget")?,
+ true,
+ )
+ } else {
+ (
+ select_target(connection, source.target_id.as_deref()).await?,
+ false,
+ )
+ };
+ let mut owned_cleanup =
+ owned_target.then(|| OwnedTargetCleanup::new(endpoint.clone(), target_id.clone()));
+
+ let result = connection
+ .command(
+ "Target.attachToTarget",
+ Some(json!({ "targetId": target_id, "flatten": true })),
+ None,
+ )
+ .await;
+
+ match result {
+ Ok(result) => match required_string(&result, "sessionId", "Target.attachToTarget") {
+ Ok(session_id) => Ok(AttachedPage {
+ session_id: Some(session_id),
+ target_id: Some(target_id),
+ owned_target,
+ owned_cleanup,
+ }),
+ Err(error) => {
+ if owned_target {
+ close_owned_target(connection, &target_id, &mut owned_cleanup).await;
+ }
+ Err(error)
+ }
+ },
+ Err(error) => {
+ if owned_target {
+ close_owned_target(connection, &target_id, &mut owned_cleanup).await;
+ }
+ Err(error)
+ }
+ }
+}
+
+async fn select_target(
+ connection: &mut CdpConnection,
+ requested_target_id: Option<&str>,
+) -> Result {
+ let result = connection.command("Target.getTargets", None, None).await?;
+ let targets = result
+ .get("targetInfos")
+ .and_then(Value::as_array)
+ .ok_or_else(|| command_shape_error("Target.getTargets"))?;
+
+ let is_page = |target: &&Value| {
+ matches!(
+ target.get("type").and_then(Value::as_str),
+ Some("page" | "webview")
+ )
+ };
+ let selected = match requested_target_id {
+ Some(target_id) => targets
+ .iter()
+ .find(|target| {
+ is_page(target) && target.get("targetId").and_then(Value::as_str) == Some(target_id)
+ })
+ .ok_or(ChromeError::CdpTargetNotFound)?,
+ None => {
+ let mut pages = targets.iter().filter(is_page);
+ let selected = pages.next().ok_or(ChromeError::CdpTargetNotFound)?;
+ if pages.next().is_some() {
+ return Err(ChromeError::CdpTargetAmbiguous);
+ }
+ selected
+ }
+ };
+
+ selected
+ .get("targetId")
+ .and_then(Value::as_str)
+ .map(str::to_owned)
+ .ok_or_else(|| command_shape_error("Target.getTargets"))
+}
+
+async fn capture_page(
+ connection: &mut CdpConnection,
+ source: &CdpSource,
+ options: &CaptureOptions,
+ user_agent_policy: UserAgentPolicy,
+ session_id: Option<&str>,
+ resume_if_waiting: bool,
+ probes: &[RenderedProbe],
+) -> Result {
+ connection.command("Page.enable", None, session_id).await?;
+ connection
+ .command("Runtime.enable", None, session_id)
+ .await?;
+ let mut network_enabled_for_navigation = false;
+ if source.url.is_some() && source.wait_until != CdpWaitUntil::None {
+ connection
+ .command(
+ "Page.setLifecycleEventsEnabled",
+ Some(json!({ "enabled": true })),
+ session_id,
+ )
+ .await?;
+ }
+ if resume_if_waiting {
+ let _ = connection
+ .command("Runtime.runIfWaitingForDebugger", None, session_id)
+ .await;
+ }
+
+ if let Some(url) = source.url.as_ref() {
+ // Some hosted CDP endpoints intentionally restrict the Network domain.
+ // Capture remains useful without it, so a rejected enable command only
+ // disables authoritative response metadata for this navigation.
+ let network_enabled = connection
+ .command("Network.enable", None, session_id)
+ .await
+ .is_ok();
+ network_enabled_for_navigation = network_enabled;
+ apply_request_profile(connection, options, user_agent_policy, session_id).await?;
+ connection.events.clear();
+ let result = connection
+ .command(
+ "Page.navigate",
+ Some(json!({ "url": url.as_str() })),
+ session_id,
+ )
+ .await?;
+ if result.get("errorText").and_then(Value::as_str).is_some() {
+ return Err(ChromeError::CdpNavigation(
+ "the browser reported a navigation error".to_owned(),
+ ));
+ }
+ if source.wait_until != CdpWaitUntil::None
+ && let Some(loader_id) = result
+ .get("loaderId")
+ .and_then(Value::as_str)
+ .and_then(bounded_event_field)
+ {
+ wait_for_page(connection, source.wait_until, &loader_id, session_id).await?;
+ }
+ }
+
+ let observed = if probes.is_empty() {
+ None
+ } else {
+ capture_observed_consistently(connection, session_id, probes)
+ .await
+ .ok()
+ .flatten()
+ };
+ let (mut captured, observed_identity) = match observed {
+ Some(observed) => (observed.page, Some(observed.identity)),
+ None => {
+ let captured = capture_page_html(connection, session_id).await?;
+ (captured, None)
+ }
+ };
+ if network_enabled_for_navigation && connection.has_document_response() {
+ let identity = match observed_identity {
+ Some(identity) => Some(identity),
+ None => current_main_document(connection, session_id).await.ok(),
+ };
+ if let Some(identity) = identity
+ && urls_equal_ignoring_fragment(&identity.url, &captured.final_url)
+ {
+ captured.response = connection.take_document_response(&identity, session_id);
+ }
+ }
+ Ok(captured)
+}
+
+struct ObservedCapture {
+ page: CapturedPage,
+ identity: MainDocumentIdentity,
+}
+
+async fn capture_observed_consistently(
+ connection: &mut CdpConnection,
+ session_id: Option<&str>,
+ probes: &[RenderedProbe],
+) -> Result, ChromeError> {
+ for _ in 0..2 {
+ let before = current_main_document(connection, session_id).await?;
+ let page =
+ capture_runtime_with_probes(connection, session_id, &before.frame_id, probes).await?;
+ let after = current_main_document(connection, session_id).await?;
+ if before == after && urls_equal_ignoring_fragment(&after.url, &page.final_url) {
+ return Ok(Some(ObservedCapture {
+ page,
+ identity: after,
+ }));
+ }
+ }
+ Ok(None)
+}
+
+async fn capture_page_html(
+ connection: &mut CdpConnection,
+ session_id: Option<&str>,
+) -> Result {
+ match capture_runtime(connection, session_id).await {
+ Ok(captured) => Ok(captured),
+ Err(ChromeError::InvalidCdpCapture | ChromeError::CdpCommand { .. }) => {
+ capture_dom(connection, session_id).await
+ }
+ Err(error) => Err(error),
+ }
+}
+
+async fn current_main_document(
+ connection: &mut CdpConnection,
+ session_id: Option<&str>,
+) -> Result {
+ let frame_tree = connection
+ .command("Page.getFrameTree", None, session_id)
+ .await?;
+ let frame = frame_tree
+ .pointer("/frameTree/frame")
+ .ok_or_else(|| command_shape_error("Page.getFrameTree"))?;
+ let frame_id = required_string(frame, "id", "Page.getFrameTree")?;
+ let loader_id = required_string(frame, "loaderId", "Page.getFrameTree")?;
+ let url = required_string(frame, "url", "Page.getFrameTree")?;
+ if frame_id.len() > MAX_EVENT_FIELD_BYTES
+ || loader_id.len() > MAX_EVENT_FIELD_BYTES
+ || url.len() > MAX_EVENT_URL_BYTES
+ {
+ return Err(command_shape_error("Page.getFrameTree"));
+ }
+ let url = Url::parse(&url).map_err(|_| command_shape_error("Page.getFrameTree"))?;
+ Ok(MainDocumentIdentity {
+ frame_id,
+ loader_id,
+ url,
+ })
+}
+
+async fn apply_request_profile(
+ connection: &mut CdpConnection,
+ options: &CaptureOptions,
+ user_agent_policy: UserAgentPolicy,
+ session_id: Option<&str>,
+) -> Result<(), ChromeError> {
+ if options.user_agent.is_none()
+ && options.accept_language.is_none()
+ && user_agent_policy == UserAgentPolicy::Preserve
+ {
+ return Ok(());
+ }
+
+ let user_agent = match options.user_agent.as_deref() {
+ Some(value) => Some(value.to_owned()),
+ None => {
+ let result = connection.command("Browser.getVersion", None, None).await?;
+ let current = required_string(&result, "userAgent", "Browser.getVersion")?;
+ match user_agent_policy {
+ UserAgentPolicy::Preserve => Some(current),
+ UserAgentPolicy::BrowserCompatible => browser_compatible_user_agent(¤t)
+ .or_else(|| options.accept_language.is_some().then_some(current)),
+ }
+ }
+ };
+ let Some(user_agent) = user_agent else {
+ return Ok(());
+ };
+ let mut params = json!({ "userAgent": user_agent });
+ if let Some(language) = options.accept_language.as_deref() {
+ params["acceptLanguage"] = Value::String(language.to_owned());
+ }
+ connection
+ .command("Emulation.setUserAgentOverride", Some(params), session_id)
+ .await?;
+ Ok(())
+}
+
+fn browser_compatible_user_agent(user_agent: &str) -> Option {
+ let mut changed = false;
+ let mut normalized = String::with_capacity(user_agent.len());
+ for (index, product) in user_agent.split(' ').enumerate() {
+ if index > 0 {
+ normalized.push(' ');
+ }
+ if let Some(version) = product.strip_prefix("HeadlessChrome/")
+ && !version.is_empty()
+ {
+ normalized.push_str("Chrome/");
+ normalized.push_str(version);
+ changed = true;
+ } else {
+ normalized.push_str(product);
+ }
+ }
+ changed.then_some(normalized)
+}
+
+async fn wait_for_page(
+ connection: &mut CdpConnection,
+ wait_until: CdpWaitUntil,
+ loader_id: &str,
+ session_id: Option<&str>,
+) -> Result<(), ChromeError> {
+ let name = match wait_until {
+ CdpWaitUntil::None => return Ok(()),
+ CdpWaitUntil::DomContentLoaded => "DOMContentLoaded",
+ CdpWaitUntil::Load => "load",
+ CdpWaitUntil::NetworkIdle => "networkIdle",
+ };
+ connection
+ .wait_for_lifecycle(name, loader_id, session_id)
+ .await
+}
+
+async fn capture_runtime(
+ connection: &mut CdpConnection,
+ session_id: Option<&str>,
+) -> Result {
+ let result = connection
+ .command(
+ "Runtime.evaluate",
+ Some(json!({
+ "expression": CAPTURE_EXPRESSION,
+ "returnByValue": true,
+ "awaitPromise": true
+ })),
+ session_id,
+ )
+ .await?;
+ if result.get("exceptionDetails").is_some() {
+ return Err(ChromeError::InvalidCdpCapture);
+ }
+ let value = result
+ .pointer("/result/value")
+ .ok_or(ChromeError::InvalidCdpCapture)?;
+ capture_from_values(
+ value.get("html").and_then(Value::as_str),
+ value.get("finalUrl").and_then(Value::as_str),
+ )
+}
+
+async fn capture_runtime_with_probes(
+ connection: &mut CdpConnection,
+ session_id: Option<&str>,
+ frame_id: &str,
+ probes: &[RenderedProbe],
+) -> Result {
+ let world = connection
+ .command(
+ "Page.createIsolatedWorld",
+ Some(json!({
+ "frameId": frame_id,
+ "worldName": RENDER_OBSERVER_WORLD,
+ "grantUniveralAccess": false
+ })),
+ session_id,
+ )
+ .await?;
+ let context_id = world
+ .get("executionContextId")
+ .and_then(Value::as_u64)
+ .ok_or_else(|| command_shape_error("Page.createIsolatedWorld"))?;
+ // A caller-managed browser may keep Opsail's temporary target in the
+ // background, where requestAnimationFrame is suspended. Focus emulation
+ // makes the document active without bringing the user's browser window to
+ // the foreground. Older/restricted endpoints may reject this experimental
+ // command, so it is deliberately best effort.
+ let _ = connection
+ .command(
+ "Emulation.setFocusEmulationEnabled",
+ Some(json!({ "enabled": true })),
+ session_id,
+ )
+ .await;
+ let requested = probes
+ .iter()
+ .map(|probe| json!({ "id": probe.id(), "selector": probe.selector() }))
+ .collect::>();
+ let result = connection
+ .command(
+ "Runtime.callFunctionOn",
+ Some(json!({
+ "functionDeclaration": RENDER_OBSERVER_FUNCTION,
+ "executionContextId": context_id,
+ "arguments": [{ "value": requested }],
+ "returnByValue": true,
+ "awaitPromise": true
+ })),
+ session_id,
+ )
+ .await?;
+ if result.get("exceptionDetails").is_some() {
+ return Err(ChromeError::InvalidCdpCapture);
+ }
+ let value = result
+ .pointer("/result/value")
+ .ok_or(ChromeError::InvalidCdpCapture)?;
+ let mut captured = capture_from_values(
+ value.get("html").and_then(Value::as_str),
+ value.get("finalUrl").and_then(Value::as_str),
+ )?;
+ captured.rendered_evidence = value
+ .get("renderedEvidence")
+ .and_then(|value| rendered::parse_evidence(value, probes));
+ Ok(captured)
+}
+
+async fn capture_dom(
+ connection: &mut CdpConnection,
+ session_id: Option<&str>,
+) -> Result {
+ let document = connection
+ .command(
+ "DOM.getDocument",
+ Some(json!({ "depth": 0, "pierce": false })),
+ session_id,
+ )
+ .await?;
+ let node_id = document
+ .pointer("/root/nodeId")
+ .and_then(Value::as_u64)
+ .ok_or(ChromeError::InvalidCdpCapture)?;
+ let outer = connection
+ .command(
+ "DOM.getOuterHTML",
+ Some(json!({ "nodeId": node_id })),
+ session_id,
+ )
+ .await?;
+ let history = connection
+ .command("Page.getNavigationHistory", None, session_id)
+ .await?;
+ let index = history
+ .get("currentIndex")
+ .and_then(Value::as_u64)
+ .and_then(|value| usize::try_from(value).ok())
+ .ok_or(ChromeError::InvalidCdpCapture)?;
+ let final_url = history
+ .get("entries")
+ .and_then(Value::as_array)
+ .and_then(|entries| entries.get(index))
+ .and_then(|entry| entry.get("url"))
+ .and_then(Value::as_str);
+ capture_from_values(outer.get("outerHTML").and_then(Value::as_str), final_url)
+}
+
+fn capture_from_values(
+ html: Option<&str>,
+ final_url: Option<&str>,
+) -> Result {
+ let html = html
+ .filter(|value| !value.is_empty())
+ .ok_or(ChromeError::InvalidCdpCapture)?;
+ let final_url = final_url
+ .and_then(|value| Url::parse(value).ok())
+ .ok_or(ChromeError::InvalidCdpCapture)?;
+ Ok(CapturedPage {
+ html: html.to_owned(),
+ final_url,
+ response: None,
+ rendered_evidence: None,
+ })
+}
+
+async fn cleanup_page(connection: &mut CdpConnection, page: &mut AttachedPage) {
+ if page.owned_target {
+ if let Some(target_id) = page.target_id.as_deref() {
+ close_owned_target(connection, target_id, &mut page.owned_cleanup).await;
+ }
+ } else if let Some(session_id) = page.session_id.as_deref() {
+ let _ = connection
+ .command(
+ "Target.detachFromTarget",
+ Some(json!({ "sessionId": session_id })),
+ None,
+ )
+ .await;
+ }
+}
+
+async fn close_owned_target(
+ connection: &mut CdpConnection,
+ target_id: &str,
+ cleanup: &mut Option,
+) {
+ if best_effort_close_target(connection, target_id).await
+ && let Some(cleanup) = cleanup
+ {
+ cleanup.disarm();
+ }
+}
+
+async fn best_effort_close_target(connection: &mut CdpConnection, target_id: &str) -> bool {
+ connection.deadline = Instant::now() + CLEANUP_TIMEOUT;
+ let Ok(result) = connection
+ .command(
+ "Target.closeTarget",
+ Some(json!({ "targetId": target_id })),
+ None,
+ )
+ .await
+ else {
+ return false;
+ };
+ result.get("success").and_then(Value::as_bool) == Some(true)
+}
+
+async fn close_target_at_endpoint(endpoint: Url, target_id: String) {
+ let Some(deadline) = Instant::now().checked_add(CLEANUP_TIMEOUT) else {
+ return;
+ };
+ let Ok(mut connection) =
+ CdpConnection::connect(&endpoint, DISCOVERY_MAX_BYTES, CLEANUP_TIMEOUT, deadline).await
+ else {
+ return;
+ };
+ let _ = connection
+ .command(
+ "Target.closeTarget",
+ Some(json!({ "targetId": target_id })),
+ None,
+ )
+ .await;
+ let _ = timeout_at(deadline, connection.socket.close(None)).await;
+}
+
+impl CdpConnection {
+ async fn connect(
+ endpoint: &Url,
+ max_message_size: usize,
+ connect_timeout: Duration,
+ deadline: Instant,
+ ) -> Result {
+ let mut config = WebSocketConfig::default();
+ config.max_message_size = Some(max_message_size);
+ config.max_frame_size = Some(max_message_size);
+ let connect = connect_async_with_config(endpoint.as_str(), Some(config), false);
+ let connect_deadline = Instant::now()
+ .checked_add(connect_timeout)
+ .map_or(deadline, |value| value.min(deadline));
+ let (socket, _) = timeout_at(connect_deadline, connect)
+ .await
+ .map_err(|_| ChromeError::CdpTimeout)?
+ .map_err(|_| ChromeError::CdpConnection)?;
+ Ok(Self {
+ socket,
+ next_id: 1,
+ events: VecDeque::new(),
+ deadline,
+ })
+ }
+
+ async fn command(
+ &mut self,
+ method: &'static str,
+ params: Option,
+ session_id: Option<&str>,
+ ) -> Result {
+ let id = self.next_id;
+ self.next_id = self.next_id.saturating_add(1);
+ let mut command = json!({ "id": id, "method": method });
+ if let Some(params) = params {
+ command["params"] = params;
+ }
+ if let Some(session_id) = session_id {
+ command["sessionId"] = Value::String(session_id.to_owned());
+ }
+ let message = serde_json::to_string(&command).map_err(|_| ChromeError::CdpCommand {
+ method,
+ message: "could not serialize the command".to_owned(),
+ })?;
+ timeout_at(
+ self.deadline,
+ self.socket.send(Message::Text(message.into())),
+ )
+ .await
+ .map_err(|_| ChromeError::CdpTimeout)?
+ .map_err(|_| ChromeError::CdpConnection)?;
+
+ loop {
+ let value = self.next_json().await?;
+ if value.get("id").and_then(Value::as_u64) == Some(id) {
+ if value.get("error").is_some() {
+ return Err(ChromeError::CdpCommand {
+ method,
+ message: "the endpoint rejected the command".to_owned(),
+ });
+ }
+ return Ok(value.get("result").cloned().unwrap_or(Value::Null));
+ }
+ if let Some(event) = parse_event(&value) {
+ self.push_event(event);
+ }
+ }
+ }
+
+ async fn wait_for_lifecycle(
+ &mut self,
+ name: &str,
+ loader_id: &str,
+ session_id: Option<&str>,
+ ) -> Result<(), ChromeError> {
+ loop {
+ if let Some(index) = self
+ .events
+ .iter()
+ .position(|event| lifecycle_matches(event, name, loader_id, session_id))
+ {
+ self.events.remove(index);
+ return Ok(());
+ }
+ let event = self.next_event().await?;
+ if lifecycle_matches(&event, name, loader_id, session_id) {
+ return Ok(());
+ }
+ self.push_event(event);
+ }
+ }
+
+ fn take_document_response(
+ &mut self,
+ identity: &MainDocumentIdentity,
+ session_id: Option<&str>,
+ ) -> Option {
+ let index = self.events.iter().rposition(|event| {
+ matches!(
+ event,
+ CdpEvent::DocumentResponse(event)
+ if session_matches(session_id, event.session_id.as_deref())
+ && response_belongs_to_capture(event, identity)
+ )
+ })?;
+ match self.events.remove(index)? {
+ CdpEvent::DocumentResponse(event) => Some(event.response),
+ CdpEvent::Lifecycle(_) => None,
+ }
+ }
+
+ fn has_document_response(&self) -> bool {
+ self.events
+ .iter()
+ .any(|event| matches!(event, CdpEvent::DocumentResponse(_)))
+ }
+
+ async fn next_event(&mut self) -> Result {
+ loop {
+ let value = self.next_json().await?;
+ if let Some(event) = parse_event(&value) {
+ return Ok(event);
+ }
+ }
+ }
+
+ async fn next_json(&mut self) -> Result {
+ loop {
+ let next = timeout_at(self.deadline, self.socket.next())
+ .await
+ .map_err(|_| ChromeError::CdpTimeout)?
+ .ok_or(ChromeError::CdpConnection)?
+ .map_err(|_| ChromeError::CdpConnection)?;
+ let parsed = match next {
+ Message::Text(text) => serde_json::from_str(text.as_ref()).ok(),
+ Message::Binary(bytes) => serde_json::from_slice(bytes.as_ref()).ok(),
+ Message::Ping(payload) => {
+ timeout_at(self.deadline, self.socket.send(Message::Pong(payload)))
+ .await
+ .map_err(|_| ChromeError::CdpTimeout)?
+ .map_err(|_| ChromeError::CdpConnection)?;
+ None
+ }
+ Message::Close(_) => return Err(ChromeError::CdpConnection),
+ Message::Pong(_) | Message::Frame(_) => None,
+ };
+ if let Some(value) = parsed {
+ return Ok(value);
+ }
+ }
+ }
+
+ fn push_event(&mut self, event: CdpEvent) {
+ if self.events.len() == MAX_QUEUED_EVENTS {
+ self.events.pop_front();
+ }
+ self.events.push_back(event);
+ }
+}
+
+fn response_belongs_to_capture(
+ event: &DocumentResponseEvent,
+ identity: &MainDocumentIdentity,
+) -> bool {
+ event.frame_id == identity.frame_id
+ && event.loader_id == identity.loader_id
+ && urls_equal_ignoring_fragment(&event.url, &identity.url)
+}
+
+fn urls_equal_ignoring_fragment(left: &Url, right: &Url) -> bool {
+ let mut left = left.clone();
+ let mut right = right.clone();
+ left.set_fragment(None);
+ right.set_fragment(None);
+ left == right
+}
+
+fn parse_event(value: &Value) -> Option {
+ let method = value.get("method")?.as_str()?;
+ let session_id = value
+ .get("sessionId")
+ .and_then(Value::as_str)
+ .and_then(bounded_event_field);
+ match method {
+ "Page.lifecycleEvent" => Some(CdpEvent::Lifecycle(LifecycleEvent {
+ session_id,
+ name: value
+ .pointer("/params/name")
+ .and_then(Value::as_str)
+ .and_then(bounded_event_field)?,
+ loader_id: value
+ .pointer("/params/loaderId")
+ .and_then(Value::as_str)
+ .and_then(bounded_event_field)?,
+ })),
+ "Network.responseReceived"
+ if value.pointer("/params/type").and_then(Value::as_str) == Some("Document") =>
+ {
+ let response = value.pointer("/params/response")?;
+ let status = response.get("status").and_then(cdp_status_code)?;
+ let headers = response.get("headers").and_then(Value::as_object);
+ Some(CdpEvent::DocumentResponse(DocumentResponseEvent {
+ session_id,
+ loader_id: value
+ .pointer("/params/loaderId")
+ .and_then(Value::as_str)
+ .and_then(bounded_event_field)?,
+ frame_id: value
+ .pointer("/params/frameId")
+ .and_then(Value::as_str)
+ .and_then(bounded_event_field)?,
+ url: response
+ .get("url")
+ .and_then(Value::as_str)
+ .filter(|url| url.len() <= MAX_EVENT_URL_BYTES)
+ .and_then(|url| Url::parse(url).ok())?,
+ response: CapturedResponse::new(
+ status,
+ retained_header(headers, "cf-mitigated"),
+ retained_header(headers, "x-amzn-waf-action"),
+ ),
+ }))
+ }
+ _ => None,
+ }
+}
+
+fn lifecycle_matches(
+ event: &CdpEvent,
+ name: &str,
+ loader_id: &str,
+ session_id: Option<&str>,
+) -> bool {
+ matches!(
+ event,
+ CdpEvent::Lifecycle(event)
+ if event.name == name
+ && event.loader_id == loader_id
+ && session_matches(session_id, event.session_id.as_deref())
+ )
+}
+
+fn cdp_status_code(value: &Value) -> Option {
+ if let Some(status) = value.as_u64() {
+ return u16::try_from(status).ok();
+ }
+ let status = value.as_f64()?;
+ (status.is_finite() && status.fract() == 0.0 && status >= 0.0 && status <= u16::MAX as f64)
+ .then_some(status as u16)
+}
+
+fn retained_header(
+ headers: Option<&serde_json::Map>,
+ requested: &str,
+) -> Option {
+ let value = headers?.iter().find_map(|(name, value)| {
+ name.eq_ignore_ascii_case(requested)
+ .then(|| value.as_str())
+ .flatten()
+ })?;
+ (value.len() <= MAX_EVENT_FIELD_BYTES).then(|| value.to_owned())
+}
+
+fn bounded_event_field(value: &str) -> Option {
+ (value.len() <= MAX_EVENT_FIELD_BYTES).then(|| value.to_owned())
+}
+
+fn session_matches(expected: Option<&str>, actual: Option<&str>) -> bool {
+ match expected {
+ Some(expected) => actual == Some(expected),
+ None => actual.is_none(),
+ }
+}
+
+async fn resolve_endpoint(
+ source: &CdpSource,
+ options: &CaptureOptions,
+ deadline: Instant,
+) -> Result {
+ if let Ok(port) = source.endpoint.parse::() {
+ if port == 0 {
+ return Err(ChromeError::InvalidCdpEndpoint);
+ }
+ let url = Url::parse(&format!("http://127.0.0.1:{port}/"))
+ .map_err(|_| ChromeError::InvalidCdpEndpoint)?;
+ return discover_endpoint(url, source.direct_page, options, deadline).await;
+ }
+
+ let mut endpoint = Url::parse(&source.endpoint).map_err(|_| ChromeError::InvalidCdpEndpoint)?;
+ if endpoint.host_str().is_none()
+ || !endpoint.username().is_empty()
+ || endpoint.password().is_some()
+ || endpoint.fragment().is_some()
+ {
+ return Err(ChromeError::InvalidCdpEndpoint);
+ }
+
+ match endpoint.scheme() {
+ "ws" | "wss" => {
+ let direct_page = source.direct_page || is_page_endpoint(&endpoint);
+ Ok(ResolvedEndpoint {
+ url: endpoint,
+ direct_page,
+ })
+ }
+ "http" | "https" if endpoint.path().contains("/devtools/") => {
+ let scheme = if endpoint.scheme() == "https" {
+ "wss"
+ } else {
+ "ws"
+ };
+ endpoint
+ .set_scheme(scheme)
+ .map_err(|()| ChromeError::InvalidCdpEndpoint)?;
+ let direct_page = source.direct_page || is_page_endpoint(&endpoint);
+ Ok(ResolvedEndpoint {
+ url: endpoint,
+ direct_page,
+ })
+ }
+ "http" | "https" => {
+ discover_endpoint(endpoint, source.direct_page, options, deadline).await
+ }
+ _ => Err(ChromeError::InvalidCdpEndpoint),
+ }
+}
+
+async fn discover_endpoint(
+ endpoint: Url,
+ direct_page: bool,
+ options: &CaptureOptions,
+ deadline: Instant,
+) -> Result {
+ let client = reqwest::Client::builder()
+ .connect_timeout(options.connect_timeout)
+ .redirect(Policy::none())
+ .build()
+ .map_err(|_| ChromeError::CdpDiscovery)?;
+
+ let mut version_url = endpoint.clone();
+ version_url.set_path("/json/version");
+ let mut discovered = match fetch_discovery_json(&client, version_url, deadline).await {
+ Ok(value) => value
+ .get("webSocketDebuggerUrl")
+ .and_then(Value::as_str)
+ .map(str::to_owned),
+ Err(ChromeError::CdpTimeout) => return Err(ChromeError::CdpTimeout),
+ Err(_) => None,
+ };
+
+ if discovered.is_none() {
+ let mut list_url = endpoint.clone();
+ list_url.set_path("/json/list");
+ discovered = match fetch_discovery_json(&client, list_url, deadline).await {
+ Ok(value) => value.as_array().and_then(|targets| {
+ targets
+ .iter()
+ .find(|target| target.get("type").and_then(Value::as_str) == Some("browser"))
+ .and_then(|target| target.get("webSocketDebuggerUrl"))
+ .and_then(Value::as_str)
+ .map(str::to_owned)
+ }),
+ Err(ChromeError::CdpTimeout) => return Err(ChromeError::CdpTimeout),
+ Err(_) => None,
+ };
+ }
+
+ let discovered = discovered.ok_or(ChromeError::CdpDiscovery)?;
+ let url = normalize_discovered_url(&discovered, &endpoint)?;
+ Ok(ResolvedEndpoint {
+ direct_page: direct_page || is_page_endpoint(&url),
+ url,
+ })
+}
+
+async fn fetch_discovery_json(
+ client: &reqwest::Client,
+ url: Url,
+ deadline: Instant,
+) -> Result {
+ let response = timeout_at(deadline, client.get(url).send())
+ .await
+ .map_err(|_| ChromeError::CdpTimeout)?
+ .map_err(|_| ChromeError::CdpDiscovery)?;
+ if !response.status().is_success() {
+ return Err(ChromeError::CdpDiscovery);
+ }
+ let mut bytes = Vec::new();
+ let mut stream = response.bytes_stream();
+ while let Some(chunk) = timeout_at(deadline, stream.next())
+ .await
+ .map_err(|_| ChromeError::CdpTimeout)?
+ {
+ let chunk = chunk.map_err(|_| ChromeError::CdpDiscovery)?;
+ if bytes.len().saturating_add(chunk.len()) > DISCOVERY_MAX_BYTES {
+ return Err(ChromeError::CdpDiscovery);
+ }
+ bytes.extend_from_slice(&chunk);
+ }
+ serde_json::from_slice(&bytes).map_err(|_| ChromeError::CdpDiscovery)
+}
+
+fn normalize_discovered_url(value: &str, endpoint: &Url) -> Result {
+ let mut url = Url::parse(value).map_err(|_| ChromeError::CdpDiscovery)?;
+ if !matches!(url.scheme(), "ws" | "wss")
+ || !url.username().is_empty()
+ || url.password().is_some()
+ || url.fragment().is_some()
+ {
+ return Err(ChromeError::CdpDiscovery);
+ }
+ let host = endpoint.host_str().ok_or(ChromeError::CdpDiscovery)?;
+ url.set_host(Some(host))
+ .map_err(|_| ChromeError::CdpDiscovery)?;
+ url.set_port(endpoint.port())
+ .map_err(|()| ChromeError::CdpDiscovery)?;
+ url.set_scheme(if endpoint.scheme() == "https" {
+ "wss"
+ } else {
+ "ws"
+ })
+ .map_err(|()| ChromeError::CdpDiscovery)?;
+ if url.query().is_none()
+ && let Some(query) = endpoint.query()
+ {
+ url.set_query(Some(query));
+ } else if let Some(query) = endpoint.query()
+ && url.query() != Some(query)
+ {
+ let pairs = url::form_urlencoded::parse(query.as_bytes());
+ url.query_pairs_mut().extend_pairs(pairs);
+ }
+ Ok(url)
+}
+
+fn is_page_endpoint(url: &Url) -> bool {
+ url.path().contains("/devtools/page/")
+}
+
+fn required_string(
+ value: &Value,
+ field: &str,
+ method: &'static str,
+) -> Result {
+ value
+ .get(field)
+ .and_then(Value::as_str)
+ .map(str::to_owned)
+ .ok_or_else(|| command_shape_error(method))
+}
+
+fn command_shape_error(method: &'static str) -> ChromeError {
+ ChromeError::CdpCommand {
+ method,
+ message: "the endpoint returned an invalid response".to_owned(),
+ }
+}
+
+fn install_tls_provider() {
+ INSTALL_TLS_PROVIDER.call_once(|| {
+ let _ = rustls::crypto::ring::default_provider().install_default();
+ });
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn normalizes_only_the_headless_chrome_product_in_an_automatic_user_agent() {
+ assert_eq!(
+ browser_compatible_user_agent(
+ "Mozilla/5.0 AppleWebKit/537.36 HeadlessChrome/150.0.0.0 Safari/537.36"
+ ),
+ Some("Mozilla/5.0 AppleWebKit/537.36 Chrome/150.0.0.0 Safari/537.36".to_owned())
+ );
+ assert_eq!(
+ browser_compatible_user_agent(
+ "Mozilla/5.0 AppleWebKit/537.36 Chrome/150.0.0.0 Safari/537.36"
+ ),
+ None
+ );
+ assert_eq!(
+ browser_compatible_user_agent("Mozilla/5.0 NotHeadlessChrome/150.0.0.0 Safari/537.36"),
+ None
+ );
+ }
+
+ #[test]
+ fn parses_only_privacy_bounded_main_document_response_metadata() {
+ let event = parse_event(&json!({
+ "method": "Network.responseReceived",
+ "sessionId": "session-1",
+ "params": {
+ "loaderId": "loader-1",
+ "frameId": "frame-1",
+ "type": "Document",
+ "response": {
+ "status": 403,
+ "url": "https://example.test/protected",
+ "headers": {
+ "CF-Mitigated": "challenge",
+ "x-amzn-waf-action": "captcha",
+ "set-cookie": "session=must-not-be-retained"
+ }
+ }
+ }
+ }))
+ .expect("document response should be recognized");
+
+ let CdpEvent::DocumentResponse(event) = event else {
+ panic!("expected document response event");
+ };
+ assert_eq!(event.loader_id, "loader-1");
+ assert_eq!(event.frame_id, "frame-1");
+ assert_eq!(event.url.as_str(), "https://example.test/protected");
+ assert_eq!(event.response.status(), 403);
+ assert_eq!(event.response.header("cf-mitigated"), Some("challenge"));
+ assert_eq!(event.response.header("X-Amzn-Waf-Action"), Some("captcha"));
+ assert_eq!(event.response.header("set-cookie"), None);
+ assert!(!format!("{:?}", event.response).contains("session="));
+ }
+
+ #[test]
+ fn ignores_subresource_response_metadata() {
+ assert!(
+ parse_event(&json!({
+ "method": "Network.responseReceived",
+ "params": {
+ "loaderId": "loader-1",
+ "type": "Script",
+ "response": {
+ "status": 403,
+ "headers": { "cf-mitigated": "challenge" }
+ }
+ }
+ }))
+ .is_none()
+ );
+ }
+}
diff --git a/crates/opsail-chrome/src/launcher.rs b/crates/opsail-chrome/src/launcher.rs
new file mode 100644
index 0000000..fc0fdfa
--- /dev/null
+++ b/crates/opsail-chrome/src/launcher.rs
@@ -0,0 +1,530 @@
+use std::env;
+use std::ffi::{OsStr, OsString};
+use std::path::{Path, PathBuf};
+use std::process::Stdio;
+use std::time::Duration;
+
+use tempfile::TempDir;
+use tokio::process::Command;
+use tokio::time::{sleep, timeout};
+
+use crate::ChromeError;
+
+#[cfg(not(windows))]
+type ChromeChild = tokio::process::Child;
+#[cfg(windows)]
+type ChromeChild = Box;
+
+const DEVTOOLS_ACTIVE_PORT: &str = "DevToolsActivePort";
+const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(50);
+const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(750);
+const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
+
+pub(crate) struct LaunchedChrome {
+ child: Option,
+ endpoint: Option,
+ profile: Option,
+}
+
+impl LaunchedChrome {
+ pub(crate) fn endpoint(&self) -> &str {
+ self.endpoint
+ .as_deref()
+ .expect("a launched Chrome always has a DevTools endpoint")
+ }
+
+ pub(crate) async fn shutdown(mut self) -> Result<(), ChromeError> {
+ let mut stopped = true;
+ if let Some(mut child) = self.child.take() {
+ stopped = if matches!(
+ timeout(GRACEFUL_SHUTDOWN_TIMEOUT, child.wait()).await,
+ Ok(Ok(_))
+ ) {
+ true
+ } else {
+ terminate_and_wait(&mut child).await
+ };
+ }
+ let removed = self
+ .profile
+ .take()
+ .is_none_or(|profile| profile.close().is_ok());
+ (stopped && removed)
+ .then_some(())
+ .ok_or(ChromeError::ChromeCleanup)
+ }
+}
+
+impl Drop for LaunchedChrome {
+ fn drop(&mut self) {
+ if let Some(mut child) = self.child.take() {
+ terminate_and_wait_blocking(&mut child);
+ }
+ }
+}
+
+pub(crate) async fn launch(
+ explicit: Option<&Path>,
+ startup_timeout: Duration,
+) -> Result {
+ let executable = find_chrome(explicit).ok_or(ChromeError::ChromeNotFound)?;
+ let profile = create_profile()?;
+
+ let args = chrome_args(profile.path());
+
+ let mut command = Command::new(executable);
+ command
+ .args(args)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+
+ let child = spawn_chrome(command)?;
+ let mut chrome = LaunchedChrome {
+ child: Some(child),
+ endpoint: None,
+ profile: Some(profile),
+ };
+ let endpoint = wait_for_devtools_endpoint(
+ chrome
+ .child
+ .as_mut()
+ .expect("a pending Chrome owns its child process"),
+ chrome
+ .profile
+ .as_ref()
+ .expect("a pending Chrome owns its profile")
+ .path(),
+ startup_timeout,
+ )
+ .await;
+ match endpoint {
+ Ok(endpoint) => {
+ chrome.endpoint = Some(endpoint);
+ Ok(chrome)
+ }
+ Err(error) => {
+ let _ = chrome.shutdown().await;
+ Err(error)
+ }
+ }
+}
+
+fn chrome_args(user_data_dir_path: &Path) -> [OsString; 8] {
+ let mut user_data_dir = OsString::from("--user-data-dir=");
+ user_data_dir.push(user_data_dir_path);
+ [
+ OsString::from("--headless"),
+ OsString::from("--remote-debugging-address=127.0.0.1"),
+ OsString::from("--remote-debugging-port=0"),
+ user_data_dir,
+ OsString::from("--no-first-run"),
+ OsString::from("--no-default-browser-check"),
+ OsString::from("--disable-background-networking"),
+ OsString::from("about:blank"),
+ ]
+}
+
+async fn wait_for_devtools_endpoint(
+ child: &mut ChromeChild,
+ user_data_dir: &Path,
+ startup_timeout: Duration,
+) -> Result {
+ let active_port_path = user_data_dir.join(DEVTOOLS_ACTIVE_PORT);
+ let wait = async {
+ loop {
+ match child.try_wait() {
+ Ok(Some(_)) => return Err(ChromeError::ChromeExited),
+ Ok(None) => {}
+ Err(_) => return Err(ChromeError::ChromeLaunch),
+ }
+
+ match tokio::fs::read_to_string(&active_port_path).await {
+ Ok(contents) => {
+ if let Some(endpoint) = parse_devtools_active_port(&contents) {
+ return Ok(endpoint);
+ }
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(_) => return Err(ChromeError::ChromeLaunch),
+ }
+
+ sleep(STARTUP_POLL_INTERVAL).await;
+ }
+ };
+
+ timeout(startup_timeout, wait)
+ .await
+ .map_err(|_| ChromeError::ChromeStartupTimeout)?
+}
+
+fn parse_devtools_active_port(contents: &str) -> Option {
+ let mut lines = contents.lines();
+ let port = lines.next()?.trim().parse::().ok()?;
+ if port == 0 {
+ return None;
+ }
+
+ let websocket_path = lines.next()?.trim();
+ if !websocket_path.starts_with("/devtools/") {
+ return None;
+ }
+
+ Some(format!("ws://127.0.0.1:{port}{websocket_path}"))
+}
+
+async fn terminate_and_wait(child: &mut ChromeChild) -> bool {
+ if matches!(child.try_wait(), Ok(Some(_))) {
+ return true;
+ }
+
+ let _ = child.start_kill();
+ matches!(timeout(SHUTDOWN_TIMEOUT, child.wait()).await, Ok(Ok(_)))
+}
+
+fn terminate_and_wait_blocking(child: &mut ChromeChild) {
+ if matches!(child.try_wait(), Ok(Some(_))) {
+ return;
+ }
+
+ let _ = child.start_kill();
+ let deadline = std::time::Instant::now() + SHUTDOWN_TIMEOUT;
+ while std::time::Instant::now() < deadline {
+ match child.try_wait() {
+ Ok(Some(_)) => return,
+ Ok(None) => std::thread::sleep(Duration::from_millis(25)),
+ Err(_) => return,
+ }
+ }
+}
+
+fn create_profile() -> Result {
+ let mut builder = tempfile::Builder::new();
+ builder.prefix("opsail-chrome-");
+ match env::var_os("OPSAIL_CHROME_TEMP_ROOT").filter(|value| !value.is_empty()) {
+ Some(root) => builder.tempdir_in(root),
+ None => builder.tempdir(),
+ }
+ .map_err(|_| ChromeError::ChromeLaunch)
+}
+
+#[cfg(not(windows))]
+fn spawn_chrome(mut command: Command) -> Result {
+ command.kill_on_drop(true);
+ command.spawn().map_err(|_| ChromeError::ChromeLaunch)
+}
+
+#[cfg(windows)]
+fn spawn_chrome(command: Command) -> Result {
+ use process_wrap::tokio::{CommandWrap, JobObject, KillOnDrop};
+
+ let mut command = CommandWrap::from(command);
+ command.wrap(KillOnDrop);
+ command.wrap(JobObject);
+ command.spawn().map_err(|_| ChromeError::ChromeLaunch)
+}
+
+fn find_chrome(explicit: Option<&Path>) -> Option {
+ let override_path = env::var_os("OPSAIL_CHROME_PATH");
+ let search_path = env::var_os("PATH");
+ let system_candidates = system_chrome_candidates();
+ discover_executable(
+ explicit,
+ override_path.as_deref(),
+ &system_candidates,
+ search_path.as_deref(),
+ path_executable_names(),
+ )
+}
+
+fn discover_executable(
+ explicit: Option<&Path>,
+ override_path: Option<&OsStr>,
+ system_candidates: &[PathBuf],
+ search_path: Option<&OsStr>,
+ executable_names: &[&str],
+) -> Option {
+ if let Some(path) = explicit {
+ return is_executable(path).then(|| path.to_path_buf());
+ }
+
+ if let Some(path) = override_path.filter(|path| !path.is_empty()) {
+ let path = PathBuf::from(path);
+ return is_executable(&path).then_some(path);
+ }
+
+ if let Some(path) = system_candidates.iter().find(|path| is_executable(path)) {
+ return Some(path.clone());
+ }
+
+ search_path.and_then(|path| {
+ env::split_paths(path)
+ .flat_map(|directory| {
+ executable_names
+ .iter()
+ .map(move |name| directory.join(name))
+ })
+ .find(|candidate| is_executable(candidate))
+ })
+}
+
+fn is_executable(path: &Path) -> bool {
+ let Ok(metadata) = path.metadata() else {
+ return false;
+ };
+ if !metadata.is_file() {
+ return false;
+ }
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ metadata.permissions().mode() & 0o111 != 0
+ }
+ #[cfg(not(unix))]
+ {
+ true
+ }
+}
+
+#[cfg(target_os = "macos")]
+fn system_chrome_candidates() -> Vec {
+ let names = [
+ "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
+ "/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ ];
+ let mut candidates = names.into_iter().map(PathBuf::from).collect::>();
+ if let Some(home) = env::var_os("HOME") {
+ let applications = PathBuf::from(home).join("Applications");
+ if applications.is_absolute() {
+ candidates.extend([
+ applications
+ .join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
+ applications.join("Google Chrome.app/Contents/MacOS/Google Chrome"),
+ applications.join("Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"),
+ applications.join("Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev"),
+ applications.join("Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"),
+ applications.join("Chromium.app/Contents/MacOS/Chromium"),
+ ]);
+ }
+ }
+ candidates
+}
+
+#[cfg(target_os = "linux")]
+fn system_chrome_candidates() -> Vec {
+ [
+ "/usr/bin/google-chrome",
+ "/usr/bin/google-chrome-stable",
+ "/usr/bin/google-chrome-beta",
+ "/usr/bin/google-chrome-unstable",
+ "/usr/bin/chromium",
+ "/usr/bin/chromium-browser",
+ "/opt/google/chrome/chrome",
+ "/snap/bin/chromium",
+ ]
+ .into_iter()
+ .map(PathBuf::from)
+ .collect()
+}
+
+#[cfg(target_os = "windows")]
+fn system_chrome_candidates() -> Vec {
+ let mut candidates = Vec::new();
+ for variable in ["PROGRAMFILES", "PROGRAMFILES(X86)"] {
+ if let Some(root) = env::var_os(variable) {
+ let root = PathBuf::from(root);
+ candidates.push(root.join(r"Google\Chrome\Application\chrome.exe"));
+ candidates.push(root.join(r"Google\Chrome Beta\Application\chrome.exe"));
+ candidates.push(root.join(r"Google\Chrome Dev\Application\chrome.exe"));
+ candidates.push(root.join(r"Chromium\Application\chrome.exe"));
+ }
+ }
+ if let Some(root) = env::var_os("LOCALAPPDATA") {
+ let root = PathBuf::from(root);
+ candidates.push(root.join(r"Google\Chrome\Application\chrome.exe"));
+ candidates.push(root.join(r"Google\Chrome SxS\Application\chrome.exe"));
+ candidates.push(root.join(r"Chromium\Application\chrome.exe"));
+ }
+ candidates
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
+fn system_chrome_candidates() -> Vec {
+ Vec::new()
+}
+
+#[cfg(target_os = "windows")]
+fn path_executable_names() -> &'static [&'static str] {
+ &["chrome.exe", "chromium.exe"]
+}
+
+#[cfg(not(target_os = "windows"))]
+fn path_executable_names() -> &'static [&'static str] {
+ &[
+ "google-chrome",
+ "google-chrome-stable",
+ "google-chrome-beta",
+ "google-chrome-unstable",
+ "chromium",
+ "chromium-browser",
+ ]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn create_file(path: &Path) {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).unwrap();
+ }
+ std::fs::write(path, b"test").unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
+ }
+ }
+
+ #[test]
+ fn parses_devtools_active_port() {
+ assert_eq!(
+ parse_devtools_active_port("49222\n/devtools/browser/test-id\n"),
+ Some("ws://127.0.0.1:49222/devtools/browser/test-id".to_owned())
+ );
+ assert_eq!(
+ parse_devtools_active_port("49222\r\n/devtools/browser/test-id\r\n"),
+ Some("ws://127.0.0.1:49222/devtools/browser/test-id".to_owned())
+ );
+ }
+
+ #[test]
+ fn launch_arguments_follow_chrome_remote_debugging_contract() {
+ let profile = Path::new("isolated-profile");
+ let args = chrome_args(profile);
+ let args = args
+ .iter()
+ .map(|argument| argument.to_string_lossy())
+ .collect::>();
+
+ assert!(args.iter().any(|argument| argument == "--headless"));
+ assert!(
+ args.iter()
+ .any(|argument| argument == "--remote-debugging-address=127.0.0.1")
+ );
+ assert!(
+ args.iter()
+ .any(|argument| argument == "--remote-debugging-port=0")
+ );
+ assert!(
+ args.iter()
+ .any(|argument| argument == "--user-data-dir=isolated-profile")
+ );
+ assert!(!args.iter().any(|argument| argument == "--no-sandbox"));
+ }
+
+ #[test]
+ fn rejects_incomplete_or_invalid_devtools_active_port() {
+ assert_eq!(parse_devtools_active_port(""), None);
+ assert_eq!(
+ parse_devtools_active_port("not-a-port\n/devtools/browser/id"),
+ None
+ );
+ assert_eq!(parse_devtools_active_port("0\n/devtools/browser/id"), None);
+ assert_eq!(parse_devtools_active_port("9222"), None);
+ assert_eq!(parse_devtools_active_port("9222\n/not-devtools/id"), None);
+ }
+
+ #[test]
+ fn executable_discovery_uses_documented_priority() {
+ let temp = tempfile::tempdir().unwrap();
+ let explicit = temp.path().join("explicit-chrome");
+ let override_path = temp.path().join("override-chrome");
+ let system = temp.path().join("system-chrome");
+ let path_directory = temp.path().join("bin");
+ let path_candidate = path_directory.join("test-chrome");
+ for path in [&explicit, &override_path, &system, &path_candidate] {
+ create_file(path);
+ }
+ let search_path = env::join_paths([&path_directory]).unwrap();
+ let system_candidates = [system.clone()];
+ let names = ["test-chrome"];
+
+ assert_eq!(
+ discover_executable(
+ Some(&explicit),
+ Some(override_path.as_os_str()),
+ &system_candidates,
+ Some(search_path.as_os_str()),
+ &names,
+ ),
+ Some(explicit)
+ );
+ assert_eq!(
+ discover_executable(
+ None,
+ Some(override_path.as_os_str()),
+ &system_candidates,
+ Some(search_path.as_os_str()),
+ &names,
+ ),
+ Some(override_path)
+ );
+ assert_eq!(
+ discover_executable(
+ None,
+ None,
+ &system_candidates,
+ Some(search_path.as_os_str()),
+ &names,
+ ),
+ Some(system)
+ );
+ assert_eq!(
+ discover_executable(None, None, &[], Some(search_path.as_os_str()), &names,),
+ Some(path_candidate)
+ );
+ }
+
+ #[test]
+ fn platform_candidates_are_absolute_without_probing_the_host() {
+ assert!(
+ system_chrome_candidates()
+ .iter()
+ .all(|candidate| candidate.is_absolute())
+ );
+ assert!(!path_executable_names().is_empty());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn path_discovery_skips_non_executable_files() {
+ let first = tempfile::tempdir().unwrap();
+ let second = tempfile::tempdir().unwrap();
+ let blocked = first.path().join("test-chrome");
+ let executable = second.path().join("test-chrome");
+ std::fs::write(&blocked, b"test").unwrap();
+ create_file(&executable);
+ let search_path = env::join_paths([first.path(), second.path()]).unwrap();
+
+ assert_eq!(
+ discover_executable(
+ None,
+ None,
+ &[],
+ Some(search_path.as_os_str()),
+ &["test-chrome"],
+ ),
+ Some(executable)
+ );
+ assert_eq!(
+ discover_executable(Some(&blocked), None, &[], None, &["test-chrome"]),
+ None
+ );
+ }
+}
diff --git a/crates/opsail-chrome/src/lib.rs b/crates/opsail-chrome/src/lib.rs
new file mode 100644
index 0000000..75bfc88
--- /dev/null
+++ b/crates/opsail-chrome/src/lib.rs
@@ -0,0 +1,380 @@
+//! Cross-platform Chrome lifecycle management and CDP page capture.
+
+mod cdp;
+mod launcher;
+mod rendered;
+
+use std::fmt;
+use std::path::PathBuf;
+use std::time::Duration;
+
+use serde::Deserialize;
+use thiserror::Error;
+use tokio::time::Instant;
+use url::Url;
+
+pub use rendered::{RenderedPageEvidence, RenderedProbe, RenderedProbeResult, RenderedSurface};
+
+pub const DEFAULT_MAX_BYTES: usize = 5 * 1024 * 1024;
+pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
+pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
+const BROWSER_CLOSE_TIMEOUT: Duration = Duration::from_millis(750);
+
+/// Browser lifecycle event to await after CDP navigation.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum CdpWaitUntil {
+ None,
+ DomContentLoaded,
+ #[default]
+ Load,
+ NetworkIdle,
+}
+
+/// A caller-managed Chrome DevTools Protocol source.
+#[derive(Clone)]
+pub struct CdpSource {
+ /// A Chrome discovery URL, browser/page WebSocket URL, or local port.
+ pub endpoint: String,
+ /// Navigate to this URL before capture. When omitted, capture an existing page.
+ pub url: Option,
+ /// Capture or navigate an existing target instead of selecting/creating one.
+ pub target_id: Option,
+ /// Treat the endpoint as a page-scoped provider WebSocket.
+ pub direct_page: bool,
+ pub wait_until: CdpWaitUntil,
+}
+
+impl CdpSource {
+ pub fn new(endpoint: impl Into) -> Self {
+ Self {
+ endpoint: endpoint.into(),
+ url: None,
+ target_id: None,
+ direct_page: false,
+ wait_until: CdpWaitUntil::default(),
+ }
+ }
+}
+
+impl fmt::Debug for CdpSource {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("CdpSource")
+ .field("endpoint", &"")
+ .field("has_url", &self.url.is_some())
+ .field("has_target_id", &self.target_id.is_some())
+ .field("direct_page", &self.direct_page)
+ .field("wait_until", &self.wait_until)
+ .finish()
+ }
+}
+
+/// A page acquired through an Opsail-owned local Chrome process.
+#[derive(Clone)]
+pub struct ChromeSource {
+ pub url: Url,
+ /// Explicit Chrome/Chromium executable. Automatic discovery is used when omitted.
+ pub executable_path: Option,
+ pub wait_until: CdpWaitUntil,
+}
+
+impl ChromeSource {
+ pub fn new(url: Url) -> Self {
+ Self {
+ url,
+ executable_path: None,
+ wait_until: CdpWaitUntil::default(),
+ }
+ }
+}
+
+impl fmt::Debug for ChromeSource {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("ChromeSource")
+ .field("url", &"")
+ .field("has_executable_path", &self.executable_path.is_some())
+ .field("wait_until", &self.wait_until)
+ .finish()
+ }
+}
+
+/// Limits and navigation profile shared by borrowed and launched Chrome capture.
+#[derive(Debug, Clone)]
+pub struct CaptureOptions {
+ /// End-to-end acquisition deadline. Bounded target/browser cleanup may run afterward.
+ pub timeout: Duration,
+ /// Maximum time for discovery HTTP requests and each initial WebSocket connection.
+ pub connect_timeout: Duration,
+ /// Maximum captured HTML size in bytes.
+ pub max_bytes: usize,
+ /// Optional browser User-Agent override applied before navigation.
+ pub user_agent: Option,
+ /// Optional browser Accept-Language override applied before navigation.
+ pub accept_language: Option,
+}
+
+impl Default for CaptureOptions {
+ fn default() -> Self {
+ Self {
+ timeout: DEFAULT_TIMEOUT,
+ connect_timeout: DEFAULT_CONNECT_TIMEOUT,
+ max_bytes: DEFAULT_MAX_BYTES,
+ user_agent: None,
+ accept_language: None,
+ }
+ }
+}
+
+/// Rendered page data captured from Chrome.
+#[derive(Clone)]
+pub struct CapturedPage {
+ pub html: String,
+ pub final_url: Url,
+ response: Option,
+ rendered_evidence: Option,
+}
+
+impl CapturedPage {
+ /// Metadata retained from the top-level document response, when Opsail
+ /// navigated the page and the endpoint exposed the CDP Network domain.
+ pub fn response(&self) -> Option<&CapturedResponse> {
+ self.response.as_ref()
+ }
+
+ /// Compact live-layout evidence requested by a `*_with_probes` capture.
+ pub fn rendered_evidence(&self) -> Option<&RenderedPageEvidence> {
+ self.rendered_evidence.as_ref()
+ }
+}
+
+impl fmt::Debug for CapturedPage {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("CapturedPage")
+ .field("html_bytes", &self.html.len())
+ .field("final_url", &"")
+ .field("has_response", &self.response.is_some())
+ .field("has_rendered_evidence", &self.rendered_evidence.is_some())
+ .finish()
+ }
+}
+
+/// A privacy-bounded view of the top-level document response.
+///
+/// Opsail retains the status and normalized indicators derived from a small
+/// response-header allowlist. Raw values, cookies, authorization data, and
+/// arbitrary response headers are never stored here.
+#[derive(Clone)]
+pub struct CapturedResponse {
+ status: u16,
+ headers: CapturedResponseHeaders,
+}
+
+impl CapturedResponse {
+ pub fn status(&self) -> u16 {
+ self.status
+ }
+
+ /// Return a normalized indicator by its case-insensitive header name.
+ ///
+ /// Only exact recognized values for `cf-mitigated` and
+ /// `x-amzn-waf-action` are represented; this never returns raw header data.
+ pub fn header(&self, name: &str) -> Option<&str> {
+ if name.eq_ignore_ascii_case("cf-mitigated") {
+ self.headers.cf_mitigated_challenge.then_some("challenge")
+ } else if name.eq_ignore_ascii_case("x-amzn-waf-action") {
+ match self.headers.aws_waf_action {
+ Some(CapturedAwsWafAction::Challenge) => Some("challenge"),
+ Some(CapturedAwsWafAction::Captcha) => Some("captcha"),
+ None => None,
+ }
+ } else {
+ None
+ }
+ }
+
+ pub(crate) fn new(
+ status: u16,
+ cf_mitigated: Option,
+ aws_waf_action: Option,
+ ) -> Self {
+ Self {
+ status,
+ headers: CapturedResponseHeaders {
+ cf_mitigated_challenge: header_value_is(cf_mitigated.as_deref(), "challenge"),
+ aws_waf_action: if header_value_is(aws_waf_action.as_deref(), "challenge") {
+ Some(CapturedAwsWafAction::Challenge)
+ } else if header_value_is(aws_waf_action.as_deref(), "captcha") {
+ Some(CapturedAwsWafAction::Captcha)
+ } else {
+ None
+ },
+ },
+ }
+ }
+}
+
+impl fmt::Debug for CapturedResponse {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("CapturedResponse")
+ .field("status", &self.status)
+ .field("has_cf_mitigated", &self.headers.cf_mitigated_challenge)
+ .field(
+ "has_x_amzn_waf_action",
+ &self.headers.aws_waf_action.is_some(),
+ )
+ .finish()
+ }
+}
+
+#[derive(Clone, Default)]
+struct CapturedResponseHeaders {
+ cf_mitigated_challenge: bool,
+ aws_waf_action: Option,
+}
+
+#[derive(Clone, Copy)]
+enum CapturedAwsWafAction {
+ Challenge,
+ Captcha,
+}
+
+fn header_value_is(actual: Option<&str>, expected: &str) -> bool {
+ actual.is_some_and(|value| value.trim().eq_ignore_ascii_case(expected))
+}
+
+#[derive(Debug, Error)]
+pub enum ChromeError {
+ #[error(
+ "invalid CDP endpoint; expected an HTTP(S) discovery URL, a WebSocket URL, or a local port"
+ )]
+ InvalidCdpEndpoint,
+
+ #[error("failed to discover a Chrome DevTools Protocol endpoint")]
+ CdpDiscovery,
+
+ #[error("failed to connect to the Chrome DevTools Protocol endpoint")]
+ CdpConnection,
+
+ #[error("the requested Chrome DevTools Protocol page target was not found")]
+ CdpTargetNotFound,
+
+ #[error("multiple Chrome page targets are available; select one explicitly")]
+ CdpTargetAmbiguous,
+
+ #[error("Chrome DevTools Protocol command `{method}` failed: {message}")]
+ CdpCommand {
+ method: &'static str,
+ message: String,
+ },
+
+ #[error("Chrome DevTools Protocol navigation failed: {0}")]
+ CdpNavigation(String),
+
+ #[error("Chrome DevTools Protocol acquisition timed out")]
+ CdpTimeout,
+
+ #[error("Chrome DevTools Protocol returned an invalid page capture")]
+ InvalidCdpCapture,
+
+ #[error("Chrome capture exceeds the {limit} byte limit")]
+ CaptureTooLarge { limit: usize },
+
+ #[error("invalid rendered-page probe; selectors must be bounded and IDs must be unique")]
+ InvalidRenderedProbe,
+
+ #[error("Chrome or Chromium could not be found; set an explicit executable path")]
+ ChromeNotFound,
+
+ #[error("failed to launch Chrome")]
+ ChromeLaunch,
+
+ #[error("Chrome exited before exposing its DevTools endpoint")]
+ ChromeExited,
+
+ #[error("Chrome did not expose its DevTools endpoint before the startup timeout")]
+ ChromeStartupTimeout,
+
+ #[error("failed to fully stop Chrome or remove its temporary profile")]
+ ChromeCleanup,
+}
+
+/// Capture through a caller-managed CDP endpoint.
+pub async fn capture_cdp(
+ source: &CdpSource,
+ options: &CaptureOptions,
+) -> Result {
+ cdp::capture(source, options, cdp::UserAgentPolicy::Preserve, &[]).await
+}
+
+/// Capture through caller-managed CDP and request privacy-bounded live layout
+/// evidence for the supplied CSS selectors.
+pub async fn capture_cdp_with_probes(
+ source: &CdpSource,
+ options: &CaptureOptions,
+ probes: &[RenderedProbe],
+) -> Result {
+ rendered::validate_probes(probes)?;
+ cdp::capture(source, options, cdp::UserAgentPolicy::Preserve, probes).await
+}
+
+/// Discover, launch, capture through, and stop an Opsail-owned Chrome process.
+pub async fn capture_chrome(
+ source: &ChromeSource,
+ options: &CaptureOptions,
+) -> Result {
+ capture_chrome_impl(source, options, &[]).await
+}
+
+/// Launch an Opsail-owned Chrome process and request privacy-bounded live
+/// layout evidence for the supplied CSS selectors.
+pub async fn capture_chrome_with_probes(
+ source: &ChromeSource,
+ options: &CaptureOptions,
+ probes: &[RenderedProbe],
+) -> Result {
+ rendered::validate_probes(probes)?;
+ capture_chrome_impl(source, options, probes).await
+}
+
+async fn capture_chrome_impl(
+ source: &ChromeSource,
+ options: &CaptureOptions,
+ probes: &[RenderedProbe],
+) -> Result {
+ let started = Instant::now();
+ let chrome = launcher::launch(source.executable_path.as_deref(), options.timeout).await?;
+ let elapsed = started.elapsed();
+ let Some(remaining) = options.timeout.checked_sub(elapsed) else {
+ let _ = chrome.shutdown().await;
+ return Err(ChromeError::CdpTimeout);
+ };
+
+ let cdp_source = CdpSource {
+ endpoint: chrome.endpoint().to_owned(),
+ url: Some(source.url.clone()),
+ target_id: None,
+ direct_page: false,
+ wait_until: source.wait_until,
+ };
+ let capture_options = CaptureOptions {
+ timeout: remaining,
+ ..options.clone()
+ };
+ let result = cdp::capture(
+ &cdp_source,
+ &capture_options,
+ cdp::UserAgentPolicy::BrowserCompatible,
+ probes,
+ )
+ .await;
+ cdp::close_browser(chrome.endpoint(), BROWSER_CLOSE_TIMEOUT).await;
+ let cleanup = chrome.shutdown().await;
+ match result {
+ Ok(captured) => cleanup.map(|()| captured),
+ Err(error) => Err(error),
+ }
+}
diff --git a/crates/opsail-chrome/src/rendered.rs b/crates/opsail-chrome/src/rendered.rs
new file mode 100644
index 0000000..b7cd246
--- /dev/null
+++ b/crates/opsail-chrome/src/rendered.rs
@@ -0,0 +1,287 @@
+use std::collections::HashSet;
+use std::fmt;
+
+use serde_json::Value;
+
+use crate::ChromeError;
+
+pub(crate) const MAX_RENDERED_PROBES: usize = 16;
+const MAX_SELECTOR_BYTES: usize = 256;
+const MAX_MATCHES: u64 = u16::MAX as u64;
+const MAX_PER_MILLE: u64 = 1_000;
+
+/// A bounded CSS selector that Chrome can observe without executing caller
+/// supplied JavaScript.
+#[derive(Clone)]
+pub struct RenderedProbe {
+ id: u16,
+ selector: String,
+}
+
+impl RenderedProbe {
+ pub fn new(id: u16, selector: impl Into) -> Result {
+ let selector = selector.into();
+ if selector.trim().is_empty() || selector.len() > MAX_SELECTOR_BYTES {
+ return Err(ChromeError::InvalidRenderedProbe);
+ }
+ Ok(Self { id, selector })
+ }
+
+ pub fn id(&self) -> u16 {
+ self.id
+ }
+
+ pub(crate) fn selector(&self) -> &str {
+ &self.selector
+ }
+}
+
+impl fmt::Debug for RenderedProbe {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RenderedProbe")
+ .field("id", &self.id)
+ .field("selector_bytes", &self.selector.len())
+ .finish()
+ }
+}
+
+/// Privacy-bounded live layout evidence for a captured document.
+#[derive(Clone, Default)]
+pub struct RenderedPageEvidence {
+ results: Vec,
+}
+
+impl RenderedPageEvidence {
+ pub fn result(&self, id: u16) -> Option<&RenderedProbeResult> {
+ self.results.iter().find(|result| result.id == id)
+ }
+
+ pub fn len(&self) -> usize {
+ self.results.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.results.is_empty()
+ }
+}
+
+impl fmt::Debug for RenderedPageEvidence {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RenderedPageEvidence")
+ .field("results", &self.results.len())
+ .finish()
+ }
+}
+
+#[derive(Clone)]
+pub struct RenderedProbeResult {
+ id: u16,
+ matches: u16,
+ marker: Option,
+ takeover: Option,
+}
+
+impl RenderedProbeResult {
+ pub fn id(&self) -> u16 {
+ self.id
+ }
+
+ pub fn matches(&self) -> u16 {
+ self.matches
+ }
+
+ pub fn marker(&self) -> Option<&RenderedSurface> {
+ self.marker.as_ref()
+ }
+
+ pub fn takeover(&self) -> Option<&RenderedSurface> {
+ self.takeover.as_ref()
+ }
+}
+
+impl fmt::Debug for RenderedProbeResult {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RenderedProbeResult")
+ .field("id", &self.id)
+ .field("matches", &self.matches)
+ .field("has_marker", &self.marker.is_some())
+ .field("has_takeover", &self.takeover.is_some())
+ .finish()
+ }
+}
+
+/// Geometry and paint ownership for one live DOM surface.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct RenderedSurface {
+ visible: bool,
+ stable: bool,
+ viewport_coverage_per_mille: u16,
+ hit_coverage_per_mille: u16,
+}
+
+impl RenderedSurface {
+ pub fn visible(&self) -> bool {
+ self.visible
+ }
+
+ pub fn stable(&self) -> bool {
+ self.stable
+ }
+
+ pub fn viewport_coverage_per_mille(&self) -> u16 {
+ self.viewport_coverage_per_mille
+ }
+
+ pub fn hit_coverage_per_mille(&self) -> u16 {
+ self.hit_coverage_per_mille
+ }
+}
+
+pub(crate) fn validate_probes(probes: &[RenderedProbe]) -> Result<(), ChromeError> {
+ if probes.len() > MAX_RENDERED_PROBES {
+ return Err(ChromeError::InvalidRenderedProbe);
+ }
+ let mut ids = HashSet::with_capacity(probes.len());
+ if probes.iter().any(|probe| !ids.insert(probe.id)) {
+ return Err(ChromeError::InvalidRenderedProbe);
+ }
+ Ok(())
+}
+
+pub(crate) fn parse_evidence(
+ value: &Value,
+ probes: &[RenderedProbe],
+) -> Option {
+ if value.get("timedOut").and_then(Value::as_bool) == Some(true) {
+ return None;
+ }
+ let raw_results = value.get("results")?.as_array()?;
+ if raw_results.len() > probes.len() {
+ return None;
+ }
+
+ let expected_ids = probes.iter().map(RenderedProbe::id).collect::>();
+ let mut seen_ids = HashSet::with_capacity(raw_results.len());
+ let mut results = Vec::with_capacity(raw_results.len());
+ for raw in raw_results {
+ let id = raw
+ .get("id")
+ .and_then(Value::as_u64)
+ .and_then(|id| u16::try_from(id).ok())?;
+ if !expected_ids.contains(&id) || !seen_ids.insert(id) {
+ return None;
+ }
+ let matches = raw.get("matches").and_then(Value::as_u64)?;
+ if matches > MAX_MATCHES {
+ return None;
+ }
+ results.push(RenderedProbeResult {
+ id,
+ matches: matches as u16,
+ marker: parse_surface(raw.get("marker"))?,
+ takeover: parse_surface(raw.get("takeover"))?,
+ });
+ }
+ Some(RenderedPageEvidence { results })
+}
+
+fn parse_surface(value: Option<&Value>) -> Option> {
+ let value = value?;
+ if value.is_null() {
+ return Some(None);
+ }
+ let viewport = value.get("viewportCoverage").and_then(Value::as_u64)?;
+ let hit = value.get("hitCoverage").and_then(Value::as_u64)?;
+ if viewport > MAX_PER_MILLE || hit > MAX_PER_MILLE {
+ return None;
+ }
+ Some(Some(RenderedSurface {
+ visible: value.get("visible").and_then(Value::as_bool)?,
+ stable: value.get("stable").and_then(Value::as_bool)?,
+ viewport_coverage_per_mille: viewport as u16,
+ hit_coverage_per_mille: hit as u16,
+ }))
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::*;
+
+ #[test]
+ fn validates_probe_bounds_and_unique_ids() {
+ assert!(RenderedProbe::new(1, "#gate").is_ok());
+ assert!(RenderedProbe::new(1, " ").is_err());
+ assert!(RenderedProbe::new(1, "x".repeat(MAX_SELECTOR_BYTES + 1)).is_err());
+
+ let duplicate = [
+ RenderedProbe::new(1, "#one").unwrap(),
+ RenderedProbe::new(1, "#two").unwrap(),
+ ];
+ assert!(validate_probes(&duplicate).is_err());
+ }
+
+ #[test]
+ fn parses_only_bounded_results_for_requested_probe_ids() {
+ let probes = [RenderedProbe::new(7, "#gate").unwrap()];
+ let value = json!({
+ "results": [{
+ "id": 7,
+ "matches": 1,
+ "marker": {
+ "visible": true,
+ "stable": true,
+ "viewportCoverage": 250,
+ "hitCoverage": 200
+ },
+ "takeover": {
+ "visible": true,
+ "stable": true,
+ "viewportCoverage": 900,
+ "hitCoverage": 800
+ }
+ }]
+ });
+
+ let evidence = parse_evidence(&value, &probes).unwrap();
+ let result = evidence.result(7).unwrap();
+ assert_eq!(result.matches(), 1);
+ assert_eq!(result.marker().unwrap().viewport_coverage_per_mille(), 250);
+ assert_eq!(result.takeover().unwrap().hit_coverage_per_mille(), 800);
+
+ let wrong_id = json!({ "results": [{
+ "id": 8,
+ "matches": 0,
+ "marker": null,
+ "takeover": null
+ }] });
+ assert!(parse_evidence(&wrong_id, &probes).is_none());
+ }
+
+ #[test]
+ fn discards_timed_out_or_out_of_range_evidence() {
+ let probes = [RenderedProbe::new(1, "#gate").unwrap()];
+ assert!(parse_evidence(&json!({ "timedOut": true, "results": [] }), &probes).is_none());
+ assert!(
+ parse_evidence(
+ &json!({ "results": [{
+ "id": 1,
+ "matches": 1,
+ "marker": {
+ "visible": true,
+ "stable": true,
+ "viewportCoverage": 1001,
+ "hitCoverage": 0
+ },
+ "takeover": null
+ }] }),
+ &probes
+ )
+ .is_none()
+ );
+ }
+}
diff --git a/crates/opsail-chrome/tests/cdp.rs b/crates/opsail-chrome/tests/cdp.rs
new file mode 100644
index 0000000..dd74d06
--- /dev/null
+++ b/crates/opsail-chrome/tests/cdp.rs
@@ -0,0 +1,1148 @@
+use std::future::Future;
+use std::time::Duration;
+
+use futures_util::{SinkExt, StreamExt};
+use opsail_chrome::{
+ CaptureOptions, CapturedPage, CdpSource, CdpWaitUntil, ChromeError, RenderedProbe, capture_cdp,
+ capture_cdp_with_probes,
+};
+use serde_json::{Value, json};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::{TcpListener, TcpStream};
+use tokio::sync::oneshot;
+use tokio::task::JoinHandle;
+use tokio::time::timeout;
+use tokio_tungstenite::WebSocketStream;
+use tokio_tungstenite::accept_async;
+use tokio_tungstenite::tungstenite::Message;
+use url::Url;
+
+type TestResult = Result<(), String>;
+type TestSocket = WebSocketStream;
+
+async fn websocket_server(handler: F) -> (String, JoinHandle)
+where
+ F: FnOnce(TcpStream) -> Fut + Send + 'static,
+ Fut: Future + Send + 'static,
+{
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let task = tokio::spawn(async move {
+ let (stream, _) = listener.accept().await.map_err(|error| error.to_string())?;
+ handler(stream).await
+ });
+ (format!("ws://{address}"), task)
+}
+
+async fn next_command(socket: &mut TestSocket) -> Result {
+ loop {
+ let message = socket
+ .next()
+ .await
+ .ok_or_else(|| "CDP client disconnected".to_owned())?
+ .map_err(|error| error.to_string())?;
+ match message {
+ Message::Text(text) => {
+ return serde_json::from_str(text.as_ref()).map_err(|error| error.to_string());
+ }
+ Message::Binary(bytes) => {
+ return serde_json::from_slice(bytes.as_ref()).map_err(|error| error.to_string());
+ }
+ Message::Close(_) => return Err("CDP client closed the connection".to_owned()),
+ Message::Ping(payload) => socket
+ .send(Message::Pong(payload))
+ .await
+ .map_err(|error| error.to_string())?,
+ Message::Pong(_) | Message::Frame(_) => {}
+ }
+ }
+}
+
+async fn respond(socket: &mut TestSocket, command: &Value, result: Value) -> TestResult {
+ socket
+ .send(Message::Text(
+ json!({ "id": command["id"], "result": result })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .map_err(|error| error.to_string())
+}
+
+async fn respond_with_error(socket: &mut TestSocket, command: &Value, message: &str) -> TestResult {
+ socket
+ .send(Message::Text(
+ json!({
+ "id": command["id"],
+ "error": {
+ "code": -32000,
+ "message": message,
+ "data": { "description": message }
+ }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .map_err(|error| error.to_string())
+}
+
+fn assert_capture(capture: &CapturedPage, html: &str, final_url: &str) {
+ assert_eq!(capture.html, html);
+ assert_eq!(capture.final_url.as_str(), final_url);
+}
+
+#[derive(Clone, Copy)]
+struct MockMainDocument {
+ frame_id: &'static str,
+ loader_id: &'static str,
+ url: &'static str,
+}
+
+fn frame_tree(document: MockMainDocument) -> Value {
+ json!({
+ "frameTree": {
+ "frame": {
+ "id": document.frame_id,
+ "loaderId": document.loader_id,
+ "url": document.url
+ }
+ }
+ })
+}
+
+async fn assert_unstable_probe_identity_is_discarded(
+ case: &'static str,
+ snapshots: [MockMainDocument; 4],
+ observed_urls: [&'static str; 2],
+) {
+ const FALLBACK_HTML: &str = "identity fallback";
+ const FALLBACK_URL: &str = "https://example.test/identity-fallback";
+
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let mut frame_tree_calls = 0;
+ let mut probe_calls = 0;
+ let mut saw_fallback = false;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.getFrameTree" => {
+ let document = snapshots
+ .get(frame_tree_calls)
+ .copied()
+ .ok_or_else(|| format!("{case}: unexpected extra frame-tree request"))?;
+ frame_tree_calls += 1;
+ respond(&mut socket, &command, frame_tree(document)).await?;
+ }
+ "Page.createIsolatedWorld" => {
+ let before = snapshots
+ .get(probe_calls * 2)
+ .ok_or_else(|| format!("{case}: missing before identity"))?;
+ assert_eq!(command["params"]["frameId"], before.frame_id, "{case}");
+ respond(
+ &mut socket,
+ &command,
+ json!({ "executionContextId": 100 + probe_calls }),
+ )
+ .await?;
+ }
+ "Emulation.setFocusEmulationEnabled" => {
+ assert_eq!(command["params"]["enabled"], true, "{case}");
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.callFunctionOn" => {
+ let final_url = observed_urls
+ .get(probe_calls)
+ .ok_or_else(|| format!("{case}: unexpected extra probe call"))?;
+ probe_calls += 1;
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": "unstable probe",
+ "finalUrl": final_url,
+ "renderedEvidence": {
+ "timedOut": false,
+ "results": [{
+ "id": 11,
+ "matches": 1,
+ "marker": {
+ "visible": true,
+ "stable": true,
+ "viewportCoverage": 100,
+ "hitCoverage": 80
+ },
+ "takeover": null
+ }]
+ }
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ "Runtime.evaluate" => {
+ assert_eq!(frame_tree_calls, 4, "{case}");
+ assert_eq!(probe_calls, 2, "{case}");
+ saw_fallback = true;
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": FALLBACK_HTML,
+ "finalUrl": FALLBACK_URL
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ other => return Err(format!("{case}: unexpected CDP command: {other}")),
+ }
+ }
+
+ assert_eq!(frame_tree_calls, 4, "{case}");
+ assert_eq!(probe_calls, 2, "{case}");
+ assert!(saw_fallback, "{case}");
+ Ok(())
+ })
+ .await;
+
+ let source = CdpSource::new(format!("{base_endpoint}/devtools/page/current"));
+ let probes = [RenderedProbe::new(11, "#verification-gate").unwrap()];
+ let capture = capture_cdp_with_probes(&source, &CaptureOptions::default(), &probes)
+ .await
+ .unwrap();
+
+ assert_capture(&capture, FALLBACK_HTML, FALLBACK_URL);
+ assert!(capture.rendered_evidence().is_none(), "{case}");
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn rendered_probes_are_data_only_and_expose_bounded_evidence() {
+ let expected_html = "rendered evidence";
+ let final_url = "https://example.test/rendered-evidence";
+ let gate_selector = r#"[data-opsail-probe="selector-must-remain-data"]"#;
+ let iframe_selector = r#"iframe[src*="captcha.example"]"#;
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let mut frame_tree_calls = 0;
+ let mut saw_probe_call = false;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.getFrameTree" => {
+ frame_tree_calls += 1;
+ respond(
+ &mut socket,
+ &command,
+ frame_tree(MockMainDocument {
+ frame_id: "main-frame",
+ loader_id: "main-loader",
+ url: final_url,
+ }),
+ )
+ .await?;
+ }
+ "Page.createIsolatedWorld" => {
+ assert_eq!(command["params"]["frameId"], "main-frame");
+ assert_eq!(command["params"]["worldName"], "opsail-render-observer");
+ respond(&mut socket, &command, json!({ "executionContextId": 77 })).await?;
+ }
+ "Emulation.setFocusEmulationEnabled" => {
+ assert_eq!(command["params"]["enabled"], true);
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.callFunctionOn" => {
+ let declaration = command["params"]["functionDeclaration"]
+ .as_str()
+ .ok_or_else(|| "missing function declaration".to_owned())?;
+ assert!(!declaration.contains(gate_selector));
+ assert!(!declaration.contains(iframe_selector));
+ assert_eq!(
+ command["params"]["arguments"],
+ json!([{ "value": [
+ { "id": 7, "selector": gate_selector },
+ { "id": 9, "selector": iframe_selector }
+ ] }])
+ );
+ assert_eq!(command["params"]["executionContextId"], 77);
+ saw_probe_call = true;
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": expected_html,
+ "finalUrl": final_url,
+ "renderedEvidence": {
+ "timedOut": false,
+ "results": [
+ {
+ "id": 7,
+ "matches": 2,
+ "marker": {
+ "visible": true,
+ "stable": true,
+ "viewportCoverage": 125,
+ "hitCoverage": 80
+ },
+ "takeover": {
+ "visible": true,
+ "stable": false,
+ "viewportCoverage": 900,
+ "hitCoverage": 840
+ }
+ },
+ {
+ "id": 9,
+ "matches": 0,
+ "marker": null,
+ "takeover": null
+ }
+ ]
+ }
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+
+ assert_eq!(frame_tree_calls, 2);
+ assert!(saw_probe_call);
+ Ok(())
+ })
+ .await;
+
+ let source = CdpSource::new(format!("{base_endpoint}/devtools/page/current"));
+ let probes = [
+ RenderedProbe::new(7, gate_selector).unwrap(),
+ RenderedProbe::new(9, iframe_selector).unwrap(),
+ ];
+ let capture = capture_cdp_with_probes(&source, &CaptureOptions::default(), &probes)
+ .await
+ .unwrap();
+
+ assert_capture(&capture, expected_html, final_url);
+ let evidence = capture.rendered_evidence().expect("rendered evidence");
+ assert_eq!(evidence.len(), 2);
+ assert!(!evidence.is_empty());
+
+ let gate = evidence.result(7).expect("gate probe result");
+ assert_eq!(gate.id(), 7);
+ assert_eq!(gate.matches(), 2);
+ let marker = gate.marker().expect("gate marker surface");
+ assert!(marker.visible());
+ assert!(marker.stable());
+ assert_eq!(marker.viewport_coverage_per_mille(), 125);
+ assert_eq!(marker.hit_coverage_per_mille(), 80);
+ let takeover = gate.takeover().expect("gate takeover surface");
+ assert!(takeover.visible());
+ assert!(!takeover.stable());
+ assert_eq!(takeover.viewport_coverage_per_mille(), 900);
+ assert_eq!(takeover.hit_coverage_per_mille(), 840);
+
+ let iframe = evidence.result(9).expect("iframe probe result");
+ assert_eq!(iframe.matches(), 0);
+ assert!(iframe.marker().is_none());
+ assert!(iframe.takeover().is_none());
+ assert!(evidence.result(10).is_none());
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn rendered_evidence_requires_a_stable_main_document_identity() {
+ const BASE: MockMainDocument = MockMainDocument {
+ frame_id: "frame-a",
+ loader_id: "loader-a",
+ url: "https://example.test/stable",
+ };
+ const CHANGED_FRAME: MockMainDocument = MockMainDocument {
+ frame_id: "frame-b",
+ ..BASE
+ };
+ const CHANGED_LOADER: MockMainDocument = MockMainDocument {
+ loader_id: "loader-b",
+ ..BASE
+ };
+ const CHANGED_URL: MockMainDocument = MockMainDocument {
+ url: "https://example.test/navigated",
+ ..BASE
+ };
+
+ for (case, snapshots, observed_urls) in [
+ (
+ "frame changed",
+ [BASE, CHANGED_FRAME, CHANGED_FRAME, BASE],
+ [BASE.url, CHANGED_FRAME.url],
+ ),
+ (
+ "loader changed",
+ [BASE, CHANGED_LOADER, CHANGED_LOADER, BASE],
+ [BASE.url, CHANGED_LOADER.url],
+ ),
+ (
+ "URL changed",
+ [BASE, CHANGED_URL, CHANGED_URL, BASE],
+ [BASE.url, CHANGED_URL.url],
+ ),
+ (
+ "captured URL differs from the current main document",
+ [BASE, BASE, BASE, BASE],
+ [
+ "https://example.test/not-the-main-document",
+ "https://example.test/not-the-main-document",
+ ],
+ ),
+ ] {
+ assert_unstable_probe_identity_is_discarded(case, snapshots, observed_urls).await;
+ }
+}
+
+#[tokio::test]
+async fn duplicate_rendered_probe_ids_are_rejected_before_endpoint_resolution() {
+ let source = CdpSource::new("://this-endpoint-must-never-be-resolved");
+ let probes = [
+ RenderedProbe::new(3, "#first").unwrap(),
+ RenderedProbe::new(3, "#second").unwrap(),
+ ];
+
+ let error = capture_cdp_with_probes(&source, &CaptureOptions::default(), &probes)
+ .await
+ .unwrap_err();
+
+ assert!(matches!(error, ChromeError::InvalidRenderedProbe));
+}
+
+#[tokio::test]
+async fn browser_endpoint_navigates_with_request_profile_and_closes_owned_target() {
+ let expected_html = "browser endpoint";
+ let final_url = "https://example.test/rendered?final=1";
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let mut saw_profile = false;
+ let mut saw_navigation = false;
+ let mut saw_close = false;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Browser.getVersion" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({ "userAgent": "MockChrome/1.0" }),
+ )
+ .await?;
+ }
+ "Target.createTarget" => {
+ assert_eq!(command["params"]["url"], "about:blank");
+ assert_eq!(command["params"]["background"], true);
+ respond(&mut socket, &command, json!({ "targetId": "owned-target" })).await?;
+ }
+ "Target.attachToTarget" => {
+ assert_eq!(command["params"]["targetId"], "owned-target");
+ assert_eq!(command["params"]["flatten"], true);
+ respond(&mut socket, &command, json!({ "sessionId": "session-1" })).await?;
+ }
+ "Page.enable"
+ | "Runtime.enable"
+ | "Runtime.runIfWaitingForDebugger"
+ | "Network.enable" => {
+ assert_eq!(command["sessionId"], "session-1");
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.setLifecycleEventsEnabled" => {
+ assert_eq!(command["sessionId"], "session-1");
+ assert_eq!(command["params"]["enabled"], true);
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Emulation.setUserAgentOverride" => {
+ assert_eq!(command["sessionId"], "session-1");
+ assert_eq!(command["params"]["userAgent"], "opsail-contract/1");
+ assert_eq!(command["params"]["acceptLanguage"], "zh-CN,en-US;q=0.8");
+ saw_profile = true;
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.navigate" => {
+ assert_eq!(command["sessionId"], "session-1");
+ assert_eq!(command["params"]["url"], "https://example.test/requested");
+ saw_navigation = true;
+ socket
+ .send(Message::Text(
+ json!({
+ "method": "Network.responseReceived",
+ "sessionId": "session-1",
+ "params": {
+ "loaderId": "loader-1",
+ "frameId": "frame-1",
+ "type": "Document",
+ "response": {
+ "status": 403,
+ "url": final_url,
+ "headers": {
+ "CF-Mitigated": "challenge",
+ "set-cookie": "session=must-not-be-retained"
+ }
+ }
+ }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .map_err(|error| error.to_string())?;
+ socket
+ .send(Message::Text(
+ json!({
+ "method": "Page.lifecycleEvent",
+ "sessionId": "session-1",
+ "params": {
+ "name": "load",
+ "loaderId": "loader-1",
+ "timestamp": 1
+ }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .map_err(|error| error.to_string())?;
+ respond(
+ &mut socket,
+ &command,
+ json!({ "loaderId": "loader-1", "frameId": "frame-1" }),
+ )
+ .await?;
+ }
+ "Runtime.evaluate" => {
+ assert_eq!(command["sessionId"], "session-1");
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": expected_html,
+ "finalUrl": final_url
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ "Page.getFrameTree" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "frameTree": {
+ "frame": {
+ "id": "frame-1",
+ "loaderId": "loader-1",
+ "url": final_url
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ "Target.closeTarget" => {
+ assert_eq!(command["params"]["targetId"], "owned-target");
+ saw_close = true;
+ respond(&mut socket, &command, json!({ "success": true })).await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+
+ assert!(saw_profile);
+ assert!(saw_navigation);
+ assert!(saw_close);
+ Ok(())
+ })
+ .await;
+
+ let mut source = CdpSource::new(format!("{base_endpoint}/devtools/browser/mock"));
+ source.url = Some(Url::parse("https://example.test/requested").unwrap());
+ source.wait_until = CdpWaitUntil::Load;
+ let options = CaptureOptions {
+ user_agent: Some("opsail-contract/1".to_owned()),
+ accept_language: Some("zh-CN,en-US;q=0.8".to_owned()),
+ ..CaptureOptions::default()
+ };
+
+ let capture = capture_cdp(&source, &options).await.unwrap();
+ assert_capture(&capture, expected_html, final_url);
+ let response = capture.response().expect("main response metadata");
+ assert_eq!(response.status(), 403);
+ assert_eq!(response.header("cf-mitigated"), Some("challenge"));
+ assert_eq!(response.header("set-cookie"), None);
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn borrowed_direct_page_preserves_its_user_agent_without_profile_options() {
+ let expected_html = "preserved profile";
+ let final_url = "https://example.test/preserved";
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let mut saw_navigation = false;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ assert!(command.get("sessionId").is_none());
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" | "Network.enable" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.navigate" => {
+ assert_eq!(command["params"]["url"], "https://example.test/requested");
+ saw_navigation = true;
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.evaluate" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": expected_html,
+ "finalUrl": final_url
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+
+ assert!(saw_navigation);
+ Ok(())
+ })
+ .await;
+
+ let mut source = CdpSource::new(format!("{base_endpoint}/devtools/page/current"));
+ source.url = Some(Url::parse("https://example.test/requested").unwrap());
+ source.wait_until = CdpWaitUntil::None;
+
+ let capture = capture_cdp(&source, &CaptureOptions::default())
+ .await
+ .unwrap();
+ assert_capture(&capture, expected_html, final_url);
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn borrowed_direct_page_uses_its_current_user_agent_for_accept_language_override() {
+ let browser_user_agent =
+ "Mozilla/5.0 AppleWebKit/537.36 HeadlessChrome/150.0.0.0 Safari/537.36";
+ let expected_html = "language profile";
+ let final_url = "https://example.test/language";
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let mut saw_profile = false;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ assert!(command.get("sessionId").is_none());
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" | "Network.enable" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Browser.getVersion" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({ "userAgent": browser_user_agent }),
+ )
+ .await?;
+ }
+ "Emulation.setUserAgentOverride" => {
+ assert_eq!(command["params"]["userAgent"], browser_user_agent);
+ assert_eq!(command["params"]["acceptLanguage"], "fr-FR,fr;q=0.9");
+ saw_profile = true;
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.navigate" => {
+ assert_eq!(command["params"]["url"], "https://example.test/requested");
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.evaluate" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": expected_html,
+ "finalUrl": final_url
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+
+ assert!(saw_profile);
+ Ok(())
+ })
+ .await;
+
+ let mut source = CdpSource::new(format!("{base_endpoint}/devtools/page/current"));
+ source.url = Some(Url::parse("https://example.test/requested").unwrap());
+ source.wait_until = CdpWaitUntil::None;
+ let options = CaptureOptions {
+ accept_language: Some("fr-FR,fr;q=0.9".to_owned()),
+ ..CaptureOptions::default()
+ };
+
+ let capture = capture_cdp(&source, &options).await.unwrap();
+ assert_capture(&capture, expected_html, final_url);
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn existing_caller_owned_target_is_detached_but_never_closed() {
+ let expected_html = "caller owned";
+ let final_url = "https://example.test/existing";
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let mut saw_detach = false;
+ let mut saw_close = false;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Browser.getVersion" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({ "userAgent": "MockChrome/1.0" }),
+ )
+ .await?;
+ }
+ "Target.getTargets" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "targetInfos": [{
+ "targetId": "caller-target",
+ "type": "page",
+ "url": final_url
+ }]
+ }),
+ )
+ .await?;
+ }
+ "Target.attachToTarget" => {
+ assert_eq!(command["params"]["targetId"], "caller-target");
+ respond(
+ &mut socket,
+ &command,
+ json!({ "sessionId": "caller-session" }),
+ )
+ .await?;
+ }
+ "Page.enable" | "Runtime.enable" => {
+ assert_eq!(command["sessionId"], "caller-session");
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.evaluate" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": expected_html,
+ "finalUrl": final_url
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ "Target.detachFromTarget" => {
+ assert_eq!(command["params"]["sessionId"], "caller-session");
+ assert!(command.get("sessionId").is_none());
+ saw_detach = true;
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Target.closeTarget" => {
+ saw_close = true;
+ respond(&mut socket, &command, json!({ "success": true })).await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+
+ assert!(saw_detach);
+ assert!(!saw_close);
+ Ok(())
+ })
+ .await;
+
+ let mut source = CdpSource::new(format!("{base_endpoint}/devtools/browser/mock"));
+ source.target_id = Some("caller-target".to_owned());
+ let capture = capture_cdp(&source, &CaptureOptions::default())
+ .await
+ .unwrap();
+
+ assert_capture(&capture, expected_html, final_url);
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn direct_page_falls_back_to_dom_when_runtime_evaluation_is_rejected() {
+ let expected_html = "DOM fallback";
+ let final_url = "https://example.test/dom-fallback";
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ assert!(command.get("sessionId").is_none());
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.evaluate" => {
+ respond_with_error(&mut socket, &command, "Runtime domain unavailable").await?;
+ }
+ "DOM.getDocument" => {
+ respond(&mut socket, &command, json!({ "root": { "nodeId": 42 } })).await?;
+ }
+ "DOM.getOuterHTML" => {
+ assert_eq!(command["params"]["nodeId"], 42);
+ respond(&mut socket, &command, json!({ "outerHTML": expected_html })).await?;
+ }
+ "Page.getNavigationHistory" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "currentIndex": 0,
+ "entries": [{ "url": final_url }]
+ }),
+ )
+ .await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+ Ok(())
+ })
+ .await;
+
+ let source = CdpSource::new(format!("{base_endpoint}/devtools/page/current"));
+ let capture = capture_cdp(&source, &CaptureOptions::default())
+ .await
+ .unwrap();
+
+ assert_capture(&capture, expected_html, final_url);
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn discovery_does_not_select_an_arbitrary_page_from_json_list() {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let server = tokio::spawn(async move {
+ for (expected_path, status, body) in [
+ ("/json/version", "404 Not Found", ""),
+ (
+ "/json/list",
+ "200 OK",
+ r#"[{"id":"page-1","type":"page","webSocketDebuggerUrl":"ws://127.0.0.1:1/devtools/page/page-1"},{"id":"page-2","type":"page","webSocketDebuggerUrl":"ws://127.0.0.1:1/devtools/page/page-2"}]"#,
+ ),
+ ] {
+ let (mut stream, _) = listener.accept().await.map_err(|error| error.to_string())?;
+ let mut request = vec![0; 4096];
+ let read = stream
+ .read(&mut request)
+ .await
+ .map_err(|error| error.to_string())?;
+ let request = String::from_utf8_lossy(&request[..read]);
+ assert!(request.starts_with(&format!("GET {expected_path} HTTP/1.1")));
+ stream
+ .write_all(
+ format!(
+ "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ body.len()
+ )
+ .as_bytes(),
+ )
+ .await
+ .map_err(|error| error.to_string())?;
+ }
+ Ok::<(), String>(())
+ });
+
+ let source = CdpSource::new(format!("http://{address}"));
+ let error = capture_cdp(&source, &CaptureOptions::default())
+ .await
+ .unwrap_err();
+
+ assert!(matches!(error, ChromeError::CdpDiscovery));
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn public_error_redacts_remote_urls_and_tokens() {
+ const REMOTE_SECRET: &str = "remote-token-do-not-publish";
+ const ENDPOINT_SECRET: &str = "endpoint-token-do-not-publish";
+ const PRIVATE_URL: &str = "https://private.example.test/account";
+
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let command = next_command(&mut socket).await?;
+ assert_eq!(command["method"], "Page.enable");
+ respond_with_error(
+ &mut socket,
+ &command,
+ &format!("request to {PRIVATE_URL}?token={REMOTE_SECRET} was rejected"),
+ )
+ .await
+ })
+ .await;
+
+ let source = CdpSource::new(format!(
+ "{base_endpoint}/devtools/page/current?token={ENDPOINT_SECRET}"
+ ));
+ let error = capture_cdp(&source, &CaptureOptions::default())
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ &error,
+ ChromeError::CdpCommand {
+ method: "Page.enable",
+ ..
+ }
+ ));
+ let public_text = format!("{error}\n{error:?}");
+ assert!(!public_text.contains(REMOTE_SECRET));
+ assert!(!public_text.contains(ENDPOINT_SECRET));
+ assert!(!public_text.contains(PRIVATE_URL));
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn false_close_response_keeps_owned_target_cleanup_armed() {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+
+ let server = tokio::spawn(async move {
+ let (stream, _) = listener.accept().await.map_err(|error| error.to_string())?;
+ let mut capture_socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+
+ while let Ok(command) = next_command(&mut capture_socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Browser.getVersion" => {
+ respond(
+ &mut capture_socket,
+ &command,
+ json!({ "userAgent": "MockChrome/1.0" }),
+ )
+ .await?;
+ }
+ "Target.createTarget" => {
+ respond(
+ &mut capture_socket,
+ &command,
+ json!({ "targetId": "retry-close-target" }),
+ )
+ .await?;
+ }
+ "Target.attachToTarget" => {
+ respond(
+ &mut capture_socket,
+ &command,
+ json!({ "sessionId": "retry-close-session" }),
+ )
+ .await?;
+ }
+ "Page.enable"
+ | "Runtime.enable"
+ | "Runtime.runIfWaitingForDebugger"
+ | "Network.enable" => {
+ respond(&mut capture_socket, &command, json!({})).await?;
+ }
+ "Page.navigate" => {
+ respond(&mut capture_socket, &command, json!({})).await?;
+ }
+ "Runtime.evaluate" => {
+ respond(
+ &mut capture_socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": "retry close",
+ "finalUrl": "https://example.test/retry-close"
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ "Target.closeTarget" => {
+ assert_eq!(command["params"]["targetId"], "retry-close-target");
+ respond(&mut capture_socket, &command, json!({ "success": false })).await?;
+ break;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+ drop(capture_socket);
+
+ let (cleanup_stream, _) = timeout(Duration::from_secs(2), listener.accept())
+ .await
+ .map_err(|_| "cleanup guard did not retry after closeTarget returned false".to_owned())?
+ .map_err(|error| error.to_string())?;
+ let mut cleanup_socket = accept_async(cleanup_stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let command = next_command(&mut cleanup_socket).await?;
+ assert_eq!(command["method"], "Target.closeTarget");
+ assert_eq!(command["params"]["targetId"], "retry-close-target");
+ respond(&mut cleanup_socket, &command, json!({ "success": true })).await?;
+ Ok::<(), String>(())
+ });
+
+ let mut source = CdpSource::new(format!("ws://{address}/devtools/browser/mock"));
+ source.url = Some(Url::parse("https://example.test/retry-close").unwrap());
+ source.wait_until = CdpWaitUntil::None;
+
+ let capture = capture_cdp(&source, &CaptureOptions::default())
+ .await
+ .unwrap();
+ assert_capture(
+ &capture,
+ "retry close",
+ "https://example.test/retry-close",
+ );
+ timeout(Duration::from_secs(3), server)
+ .await
+ .expect("cleanup retry server timed out")
+ .unwrap()
+ .unwrap();
+}
+
+#[tokio::test]
+async fn abort_after_target_creation_reconnects_and_closes_the_owned_target() {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let (attach_seen_tx, attach_seen_rx) = oneshot::channel();
+
+ let server = tokio::spawn(async move {
+ let (stream, _) = listener.accept().await.map_err(|error| error.to_string())?;
+ let mut capture_socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+
+ let command = next_command(&mut capture_socket).await?;
+ assert_eq!(command["method"], "Browser.getVersion");
+ respond(
+ &mut capture_socket,
+ &command,
+ json!({ "userAgent": "MockChrome/1.0" }),
+ )
+ .await?;
+
+ let command = next_command(&mut capture_socket).await?;
+ assert_eq!(command["method"], "Target.createTarget");
+ respond(
+ &mut capture_socket,
+ &command,
+ json!({ "targetId": "aborted-owned-target" }),
+ )
+ .await?;
+
+ let command = next_command(&mut capture_socket).await?;
+ assert_eq!(command["method"], "Target.attachToTarget");
+ assert_eq!(command["params"]["targetId"], "aborted-owned-target");
+ attach_seen_tx
+ .send(())
+ .map_err(|()| "capture task disappeared before attach was observed".to_owned())?;
+
+ // Keep the original connection and attach command pending. Dropping the capture future
+ // must use the owned-target guard to establish a fresh connection for cleanup.
+ let (cleanup_stream, _) = timeout(Duration::from_secs(2), listener.accept())
+ .await
+ .map_err(|_| "cleanup guard did not reconnect".to_owned())?
+ .map_err(|error| error.to_string())?;
+ let mut cleanup_socket = accept_async(cleanup_stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let command = next_command(&mut cleanup_socket).await?;
+ assert_eq!(command["method"], "Target.closeTarget");
+ assert_eq!(command["params"]["targetId"], "aborted-owned-target");
+ respond(&mut cleanup_socket, &command, json!({ "success": true })).await?;
+ Ok::<(), String>(())
+ });
+
+ let mut source = CdpSource::new(format!("ws://{address}/devtools/browser/mock"));
+ source.url = Some(Url::parse("https://example.test/abort-after-create").unwrap());
+ source.wait_until = CdpWaitUntil::None;
+ let capture_task =
+ tokio::spawn(async move { capture_cdp(&source, &CaptureOptions::default()).await });
+
+ timeout(Duration::from_secs(2), attach_seen_rx)
+ .await
+ .expect("server did not observe Target.attachToTarget")
+ .expect("server exited before observing Target.attachToTarget");
+ capture_task.abort();
+ assert!(capture_task.await.unwrap_err().is_cancelled());
+ timeout(Duration::from_secs(3), server)
+ .await
+ .expect("cleanup server timed out")
+ .unwrap()
+ .unwrap();
+}
diff --git a/crates/opsail-read/Cargo.toml b/crates/opsail-read/Cargo.toml
index 1d0dd14..527bb9f 100644
--- a/crates/opsail-read/Cargo.toml
+++ b/crates/opsail-read/Cargo.toml
@@ -1,11 +1,12 @@
[package]
name = "opsail-read"
description = "Agent-ready web content extraction for Opsail"
-version.workspace = true
+version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
+readme = "README.md"
repository.workspace = true
[dependencies]
@@ -14,6 +15,7 @@ dom_query.workspace = true
dom_smoothie.workspace = true
encoding_rs.workspace = true
futures-util.workspace = true
+opsail-chrome = { path = "../opsail-chrome", version = "0.1.0" }
reqwest.workspace = true
rustls.workspace = true
serde.workspace = true
@@ -27,6 +29,7 @@ url.workspace = true
[dev-dependencies]
tempfile.workspace = true
tokio.workspace = true
+tokio-tungstenite.workspace = true
wiremock.workspace = true
[lints]
diff --git a/crates/opsail-read/README.md b/crates/opsail-read/README.md
new file mode 100644
index 0000000..d2abaf5
--- /dev/null
+++ b/crates/opsail-read/README.md
@@ -0,0 +1,239 @@
+# opsail-read
+
+`opsail-read` is the Rust library behind
+[`opsail read`](https://github.com/lencx/opsail#read-html). It acquires static HTML
+or delegates rendered DOM capture to `opsail-chrome`, extracts the primary
+document, sanitizes the result, and returns a versioned `ReadResult` suitable
+for agents and other programmatic callers.
+
+The extraction pipeline is browser-independent. `opsail-read` owns source
+validation, non-browser acquisition, extraction, sanitization, and result
+provenance. Browser executable discovery, owned process lifecycle, CDP target
+management, waits, and DOM capture belong to `opsail-chrome`. Callers that
+already have rendered HTML should provide it directly instead of using either
+browser path.
+
+## Capabilities
+
+- Acquire HTML from HTTP(S), regular files, caller-provided stdin bytes, or an
+ already-decoded captured document.
+- Connect to caller-managed Chrome through an HTTP(S) discovery endpoint or
+ browser/page WebSocket, optionally navigate, wait, and capture the current DOM.
+- Launch a local Chrome or Chromium process with an isolated temporary profile,
+ capture one page, and clean up the owned process and profile.
+- Resolve relative links and assets against a validated HTTP(S) base URL.
+- Extract readable Markdown and sanitized HTML with structured metadata.
+- Report source, extraction method, quality signals, and warnings through one
+ stable result model.
+- Enforce byte, DOM element, nesting-depth, redirect, and timeout limits.
+- Reject active content, unsafe resource URLs, embedded URL credentials, and
+ high-confidence full-page browser verification interstitials instead of
+ publishing them as content.
+
+## Installation
+
+```toml
+[dependencies]
+opsail-read = "0.2"
+tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+```
+
+## Acquire and read a URL
+
+```rust
+use opsail_read::{ReadOptions, ReadSource, read};
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let source = ReadSource::Url("https://example.com/article".parse()?);
+ let result = read(source, &ReadOptions::default()).await?;
+
+ println!("{}", result.metadata.title);
+ println!("{}", result.content);
+ Ok(())
+}
+```
+
+`ReadOptions` controls the base URL, request and connection timeouts, maximum
+input size, `User-Agent`, and `Accept-Language` header. For direct HTTP
+acquisition, leaving `user_agent` as `None` sends `opsail/`; WeChat
+article URLs retain their browser-compatible automatic HTTP profile with an
+`opsail/` product token. An explicit value always wins.
+
+## Process caller-captured HTML
+
+Browser hosts should capture the rendered HTML and final page URL themselves,
+then provide both to `opsail-read`:
+
+```rust
+use opsail_read::{CapturedDocument, ReadOptions, ReadSource, read};
+
+async fn process(html: String) -> Result<(), Box> {
+ let document = CapturedDocument::new(
+ html,
+ Some("https://example.com/final-article-url".parse()?),
+ );
+ let result = read(ReadSource::Html(document), &ReadOptions::default()).await?;
+ println!("{}", result.content);
+ Ok(())
+}
+```
+
+`CapturedDocument` accepts an already-decoded Rust `String`. Its bytes are
+treated as UTF-8; a legacy ` ` inside the document does not
+reinterpret the Unicode text supplied by the caller.
+
+For synchronous extraction with the default input-size limit, use
+`extract_html(html, base_url)` instead.
+
+## Capture through `opsail-chrome`
+
+`ReadSource::Chrome` is the owned mode. It discovers or uses an explicitly
+configured executable, starts headless Chrome with an isolated temporary
+profile and a dynamically assigned loopback debugging port, captures one URL,
+then stops the process and removes the profile:
+
+```rust
+use opsail_read::{ChromeSource, ReadOptions, ReadSource, read};
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let chrome = ChromeSource::new("https://example.com/app".parse()?);
+ let result = read(ReadSource::Chrome(chrome), &ReadOptions::default()).await?;
+ println!("{}", result.content);
+ Ok(())
+}
+```
+
+Executable resolution supports macOS, Linux, and Windows in this order: the
+`ChromeSource::executable_path` value, `OPSAIL_CHROME_PATH`, then supported
+platform locations and `PATH`. Owned launch never reuses the user's Chrome
+profile and does not add `--no-sandbox` automatically.
+
+With no explicit `ReadOptions::user_agent`, owned launch derives the actual
+User-Agent from the selected Chrome process and changes only its
+`HeadlessChrome/` product token to `Chrome/`. It does not
+hard-code a Chrome version. An explicit User-Agent is applied unchanged and
+always takes precedence.
+
+`ReadSource::Cdp` is the borrowed mode. The caller starts Chrome, exposes its
+debugging endpoint, and owns the browser lifecycle. Opsail connects as a
+short-lived client; it does not run an adapter server or background daemon.
+When a navigation URL is supplied without a target ID, Opsail creates a
+temporary `about:blank` target inside that browser, applies any explicit
+User-Agent and language before navigation, captures the rendered DOM, and
+closes only that temporary target:
+
+```rust
+use opsail_read::{CdpSource, CdpWaitUntil, ReadOptions, ReadSource, read};
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let mut chrome = CdpSource::new("http://127.0.0.1:9222");
+ chrome.url = Some("https://example.com/app".parse()?);
+ chrome.wait_until = CdpWaitUntil::NetworkIdle;
+
+ let result = read(ReadSource::Cdp(chrome), &ReadOptions::default()).await?;
+ println!("{}", result.content);
+ Ok(())
+}
+```
+
+CDP capture first uses one `Runtime.evaluate` call to obtain HTML and the final
+URL atomically. If `Runtime.evaluate` fails, it falls back to
+`DOM.getOuterHTML` plus page navigation history. The current DOM does not expose
+closed shadow roots, canvas pixels, or inaccessible cross-origin frame
+documents.
+
+When a browser endpoint is used without a navigation URL or `target_id`, Opsail
+attaches only if exactly one eligible page target exists. Multiple pages return
+an error instead of selecting an arbitrary page; callers must set `target_id`
+explicitly. `direct_page` is valid only for a page-scoped WebSocket endpoint and
+cannot be combined with `target_id`; the final URL of any existing page must use
+HTTP(S). Captured HTML is limited by `ReadOptions::max_bytes` and an absolute 16
+MiB CDP capture ceiling.
+
+When `ReadOptions::user_agent` is `None`, borrowed CDP preserves the
+caller-managed browser's User-Agent. An explicit value is applied unchanged
+before navigation. Opsail deliberately does not normalize the identity of a
+browser it does not own.
+
+Both paths return the same captured-page shape to `opsail-read`, but provenance
+remains explicit: owned launch produces `SourceKind::Chrome`, while borrowed
+CDP produces `SourceKind::Cdp`. Cleanup of borrowed attachments and temporary
+targets is guaranteed on normal completion and attempted on bounded failures;
+if the operation is abruptly cancelled or the process is terminated, cleanup
+is best-effort and the borrowed browser remains the caller's responsibility.
+
+## Browser verification
+
+Before extraction, `opsail-read` rejects high-confidence, full-page browser
+verification interstitials with `ReadError::VerificationRequired`. The detector
+uses structured and conjunctive evidence rather than regexes or generic page
+wording:
+
+- Cloudflare and AWS WAF use their published top-level response contracts:
+ `cf-mitigated: challenge`, or the documented status plus
+ `x-amzn-waf-action` combinations.
+- WeChat, Cloudflare fallback pages, Google `/sorry/`, and top-level DataDome
+ pages require multiple matching facts from the parsed DOM, trusted resource
+ or form URLs, final-page URL constraints where applicable, and the absence of
+ a substantive semantic content surface.
+
+For Chrome/CDP sources, those DOM profiles also require a stable, visible live
+marker from `opsail-chrome`'s privacy-bounded rendered observer. It measures
+computed visibility, viewport intersection, paint-hit ownership, and animation-
+frame stability. The observation is retained only when the root frame, loader,
+and final URL remain the same. Missing, timed-out, or inconsistent evidence is
+never treated as a positive. Direct HTTP and supplied HTML use conservative
+static profiles because no live layout is available.
+
+An embedded reCAPTCHA, hCaptcha, Turnstile, HUMAN/PerimeterX, or Arkose widget
+is not sufficient evidence, and ordinary login pages are outside this
+classification. Without an authoritative response contract or provider-owned
+top-level route, rendered visibility and page-takeover evidence is required;
+ambiguous static markup remains unclassified. The vendor set is conservative
+rather than exhaustive. Opsail reports that verification is required; it does
+not solve CAPTCHAs, complete third-party authentication, or bypass access
+controls.
+
+For Chrome sources, the response detector consumes only the optional
+privacy-bounded main-document metadata exposed by `opsail-chrome`: status plus
+normalized indicators derived only from `cf-mitigated` and
+`x-amzn-waf-action`. Raw header values, cookies, authorization data, and
+arbitrary response headers never enter this detection path. Frame, loader, and
+response URL must also match the captured final main document.
+
+## Result contract
+
+Every successful entry point returns `ReadResult`, which contains:
+
+- `schema_version`: version of the serialized result contract.
+- `content` and `content_html`: readable Markdown and sanitized HTML.
+- `metadata`: title, author, dates, canonical URL, language, and related fields.
+- `source`: input kind, requested and resolved locations, charset, media type,
+ and byte count.
+- `extraction`: selected extraction method and duration.
+- `quality`: readability and content-size signals.
+- `warnings`: non-fatal conditions such as unusually short extracted content.
+
+Serialized fields use camel case, including `schemaVersion` and `contentHtml`.
+Callers should branch on structured fields rather than warning or error text.
+
+## Trust boundary
+
+Treat source HTML and all extracted metadata as untrusted input. `opsail-read`
+sanitizes its published HTML and filters unsafe URLs, but callers remain
+responsible for safely rendering Markdown, escaping terminal output, and
+applying any application-specific URL or content policy.
+
+A borrowed CDP endpoint grants control over its Chrome session and may expose
+cookies or authenticated pages. Accept it only from trusted caller
+configuration. Endpoint URLs and query parameters are intentionally excluded
+from `ReadResult` and public acquisition errors. Owned launch uses a fresh
+temporary profile and therefore does not inherit the user's authenticated
+browser state.
+
+## License
+
+Apache-2.0
diff --git a/crates/opsail-read/src/error.rs b/crates/opsail-read/src/error.rs
index 9dbe6cd..62d79d7 100644
--- a/crates/opsail-read/src/error.rs
+++ b/crates/opsail-read/src/error.rs
@@ -58,6 +58,12 @@ pub enum ReadError {
#[error("request for `{url}` returned HTTP {status}")]
HttpStatus { url: String, status: u16 },
+ #[error("request for `{url}` returned an interactive verification page")]
+ VerificationRequired { url: String },
+
+ #[error(transparent)]
+ Chrome(#[from] opsail_chrome::ChromeError),
+
#[error("failed while reading the response from `{url}`")]
ReadResponse {
url: String,
diff --git a/crates/opsail-read/src/extract.rs b/crates/opsail-read/src/extract.rs
index 664d69b..8001fe3 100644
--- a/crates/opsail-read/src/extract.rs
+++ b/crates/opsail-read/src/extract.rs
@@ -1,12 +1,18 @@
use ammonia::{Builder, UrlRelative};
-use dom_query::Document;
+use dom_query::{Document, Selection};
use dom_smoothie::{Article, CandidateSelectMode, Config, Metadata, Readability, TextMode};
+use unicode_segmentation::UnicodeSegmentation;
use url::Url;
use crate::error::ReadError;
use crate::model::{DEFAULT_MAX_DEPTH, DEFAULT_MAX_ELEMENTS, DocumentMetadata, ExtractionMethod};
+use crate::standardize::standardize_document;
const SEMANTIC_FALLBACK_THRESHOLD: usize = 120;
+const SEMANTIC_FALLBACK_MIN_GAIN: usize = 120;
+const HIDDEN_FALLBACK_WORD_THRESHOLD: usize = 50;
+const HIDDEN_FALLBACK_MIN_WORDS: usize = 30;
+const HIDDEN_FALLBACK_MAX_LINK_PERCENT: usize = 35;
pub(crate) struct Extracted {
pub content: String,
@@ -27,7 +33,7 @@ struct Candidate {
}
pub(crate) fn extract(html: &str, base_url: Option<&Url>) -> Result {
- let prepared_html = prepare_for_extraction(html)?;
+ let prepared_html = prepare_for_extraction(html, base_url)?;
let fallback_metadata = read_metadata(html, base_url)?;
let primary = read_candidate(
&prepared_html,
@@ -55,22 +61,53 @@ pub(crate) fn extract(html: &str, base_url: Option<&Url>) -> Result visible_characters(&candidate.text)
- {
- candidate = semantic;
+ let mut semantic_was_substantially_richer = false;
+ let candidate_characters = visible_characters(&candidate.text);
+ if let Some(semantic) = semantic_candidate(&prepared_html, fallback_metadata.clone()) {
+ let semantic_characters = visible_characters(&semantic.text);
+ let preserves_more_media = semantic_preserves_substantially_more_media(
+ &candidate,
+ candidate_characters,
+ &semantic,
+ semantic_characters,
+ );
+ if candidate_characters < SEMANTIC_FALLBACK_THRESHOLD
+ && semantic_characters > candidate_characters
+ {
+ candidate = semantic;
+ } else if semantic_is_substantially_richer(candidate_characters, semantic_characters)
+ || preserves_more_media
+ {
+ candidate = semantic;
+ semantic_was_substantially_richer = true;
+ }
}
let mut extracted = finish_candidate(candidate, base_url)?;
if extracted.method == ExtractionMethod::Semantic {
- extracted.warnings.push(
- "used semantic fallback because article scoring returned thin content".to_owned(),
- );
+ let warning = if semantic_was_substantially_richer {
+ "used semantic fallback because it preserved substantially more content"
+ } else {
+ "used semantic fallback because article scoring returned thin content"
+ };
+ extracted.warnings.push(warning.to_owned());
if let Some(error) = first_error {
tracing::debug!(error, "article scoring failed before semantic fallback");
}
}
+
+ if should_try_hidden_fallback(&extracted)
+ && let Some(hidden_candidate) =
+ hidden_semantic_candidate(html, base_url, fallback_metadata)?
+ && let Ok(mut recovered) = finish_candidate(hidden_candidate, base_url)
+ && hidden_fallback_is_substantially_richer(&extracted, &recovered)
+ {
+ recovered.warnings.push(
+ "used hidden-content fallback because visible extraction was unusually short"
+ .to_owned(),
+ );
+ return Ok(recovered);
+ }
Ok(extracted)
}
@@ -108,9 +145,11 @@ fn config(mode: CandidateSelectMode) -> Config {
}
}
-fn prepare_for_extraction(html: &str) -> Result {
+fn prepare_for_extraction(html: &str, base_url: Option<&Url>) -> Result {
let document = Document::from(html);
validate_document(&document)?;
+ standardize_document(&document, base_url);
+ validate_document(&document)?;
for selector in [
"script",
"style",
@@ -163,14 +202,9 @@ fn remove_hidden_elements(document: &Document) {
}
}
for selection in document.select("[style]").iter() {
- let hidden = selection.attr("style").is_some_and(|value| {
- let style: String = value
- .chars()
- .filter(|character| !character.is_ascii_whitespace())
- .flat_map(char::to_lowercase)
- .collect();
- style.contains("display:none") || style.contains("visibility:hidden")
- });
+ let hidden = selection
+ .attr("style")
+ .is_some_and(|value| style_hides_element(value.as_ref()));
if hidden {
selection.remove();
}
@@ -190,6 +224,148 @@ fn remove_hidden_elements(document: &Document) {
}
}
+fn style_hides_element(style: &str) -> bool {
+ style.split(';').any(|declaration| {
+ let Some((property, value)) = declaration.split_once(':') else {
+ return false;
+ };
+ let property = property.trim();
+ let value = value
+ .split_once('!')
+ .map_or(value, |(value, _)| value)
+ .trim();
+ (property.eq_ignore_ascii_case("display") && value.eq_ignore_ascii_case("none"))
+ || (property.eq_ignore_ascii_case("visibility") && value.eq_ignore_ascii_case("hidden"))
+ })
+}
+
+fn should_try_hidden_fallback(extracted: &Extracted) -> bool {
+ visible_characters(&extracted.text) < SEMANTIC_FALLBACK_THRESHOLD
+ || extracted.text.unicode_words().count() < HIDDEN_FALLBACK_WORD_THRESHOLD
+}
+
+fn hidden_semantic_candidate(
+ html: &str,
+ base_url: Option<&Url>,
+ metadata: Metadata,
+) -> Result, ReadError> {
+ let document = Document::from(html);
+ validate_document(&document)?;
+ standardize_document(&document, base_url);
+ validate_document(&document)?;
+
+ let best = document
+ .select("[hidden], [aria-hidden], [class]")
+ .iter()
+ .filter(is_hidden_fallback_root)
+ .filter(|candidate| hidden_candidate_has_article_structure(candidate))
+ .filter(|candidate| !hidden_candidate_has_clutter_context(candidate))
+ .filter(|candidate| {
+ hidden_candidate_link_density(candidate) <= HIDDEN_FALLBACK_MAX_LINK_PERCENT
+ })
+ .map(|candidate| {
+ let score = visible_characters(candidate.text().as_ref());
+ (score, candidate)
+ })
+ .filter(|(characters, candidate)| {
+ *characters >= SEMANTIC_FALLBACK_THRESHOLD
+ && candidate.text().unicode_words().count() >= HIDDEN_FALLBACK_MIN_WORDS
+ })
+ .max_by_key(|(score, _)| *score)
+ .map(|(_, candidate)| candidate);
+
+ let Some(candidate) = best else {
+ return Ok(None);
+ };
+ let prepared = prepare_for_extraction(candidate.inner_html().as_ref(), base_url)?;
+ Ok(semantic_candidate(&prepared, metadata))
+}
+
+fn is_hidden_fallback_root(candidate: &Selection<'_>) -> bool {
+ candidate.has_attr("hidden")
+ || candidate
+ .attr("aria-hidden")
+ .is_some_and(|value| value.as_ref().eq_ignore_ascii_case("true"))
+ || candidate.attr("class").is_some_and(|classes| {
+ classes
+ .split_ascii_whitespace()
+ .any(|class| matches!(class.to_ascii_lowercase().as_str(), "hidden" | "invisible"))
+ })
+}
+
+fn hidden_candidate_has_article_structure(candidate: &Selection<'_>) -> bool {
+ let has_semantic_root = candidate.is("article, main, [role='main']")
+ || candidate.select("article, main, [role='main']").exists();
+ let has_heading = candidate
+ .select("h1, h2, h3")
+ .iter()
+ .any(|heading| visible_characters(heading.text().as_ref()) > 1);
+ let meaningful_blocks = candidate
+ .select("p, li, pre, blockquote, td")
+ .iter()
+ .filter(|block| visible_characters(block.text().as_ref()) >= 20)
+ .take(2)
+ .count();
+ has_semantic_root && has_heading && meaningful_blocks >= 2
+}
+
+fn hidden_candidate_has_clutter_context(candidate: &Selection<'_>) -> bool {
+ std::iter::once(candidate.clone())
+ .chain(candidate.ancestors(Some(32)).iter())
+ .any(|node| {
+ node.is(
+ "nav, aside, footer, header, form, template, svg, math, mjx-container, \
+ .katex-html, .MathJax, [role='menu'], [role='navigation'], \
+ [role='tooltip'], [role='status'], [role='alert']",
+ ) || ["class", "id"]
+ .iter()
+ .filter_map(|attribute| node.attr(attribute))
+ .flat_map(|value| {
+ value
+ .split(|character: char| !character.is_ascii_alphanumeric())
+ .map(str::to_ascii_lowercase)
+ .collect::>()
+ })
+ .any(|token| {
+ matches!(
+ token.as_str(),
+ "ad" | "ads"
+ | "advert"
+ | "advertisement"
+ | "cookie"
+ | "modal"
+ | "newsletter"
+ | "popover"
+ | "promo"
+ | "promoted"
+ | "sidebar"
+ | "social"
+ )
+ })
+ })
+}
+
+fn hidden_candidate_link_density(candidate: &Selection<'_>) -> usize {
+ let characters = visible_characters(candidate.text().as_ref()).max(1);
+ let link_characters = candidate
+ .select("a")
+ .iter()
+ .map(|link| visible_characters(link.text().as_ref()))
+ .sum::();
+ link_characters.saturating_mul(100) / characters
+}
+
+fn hidden_fallback_is_substantially_richer(current: &Extracted, alternative: &Extracted) -> bool {
+ let current_characters = visible_characters(¤t.text);
+ let alternative_characters = visible_characters(&alternative.text);
+ let current_words = current.text.unicode_words().count();
+ let alternative_words = alternative.text.unicode_words().count();
+ alternative_characters >= SEMANTIC_FALLBACK_THRESHOLD
+ && alternative_words >= HIDDEN_FALLBACK_MIN_WORDS
+ && alternative_characters > current_characters.saturating_mul(2)
+ && alternative_words > current_words.saturating_mul(2)
+}
+
fn candidate_from_article(
article: Article,
method: ExtractionMethod,
@@ -339,22 +515,22 @@ fn sanitize_and_convert(html: &str, base_url: Option<&Url>) -> (String, String)
let cells: Vec = row
.select("th, td")
.iter()
- .map(|cell| {
- cell.formatted_text()
- .split_whitespace()
- .collect::>()
- .join(" ")
+ .filter_map(|cell| {
+ for line_break in cell.select("br").iter() {
+ line_break.replace_with_html(" ");
+ }
+ let has_text = cell.formatted_text().split_whitespace().next().is_some();
+ has_text.then(|| cell.inner_html().to_string())
})
- .filter(|cell| !cell.is_empty())
.collect();
if cells.is_empty() {
continue;
}
- let row_text = escape_html(&cells.join(" · "));
+ let row_html = cells.join(" · ");
if is_header {
- readable_rows.push_str(&format!("{row_text}
"));
+ readable_rows.push_str(&format!("{row_html}
"));
} else {
- readable_rows.push_str(&format!("{row_text}
"));
+ readable_rows.push_str(&format!("{row_html}
"));
}
}
}
@@ -645,6 +821,50 @@ fn visible_characters(value: &str) -> usize {
.count()
}
+fn semantic_is_substantially_richer(current: usize, alternative: usize) -> bool {
+ let required_gain = SEMANTIC_FALLBACK_MIN_GAIN.max(current.saturating_mul(3) / 4);
+ alternative > current.saturating_add(required_gain)
+}
+
+fn semantic_preserves_substantially_more_media(
+ current: &Candidate,
+ current_characters: usize,
+ alternative: &Candidate,
+ alternative_characters: usize,
+) -> bool {
+ alternative_characters >= current_characters
+ && meaningful_image_count(&alternative.content_html)
+ >= meaningful_image_count(¤t.content_html).saturating_add(2)
+}
+
+fn meaningful_image_count(html: &str) -> usize {
+ Document::from(html)
+ .select("img[src][alt]")
+ .iter()
+ .filter(|image| {
+ image.attr("alt").is_some_and(|alt| !alt.trim().is_empty())
+ && image
+ .attr("src")
+ .is_some_and(|source| is_safe_image_source(source.as_ref()))
+ })
+ .count()
+}
+
+fn is_safe_image_source(value: &str) -> bool {
+ let value = value.trim();
+ if value.is_empty() || value.chars().any(char::is_control) {
+ return false;
+ }
+ if value.starts_with("//") {
+ return Url::parse(&format!("https:{value}")).is_ok_and(|url| !url_has_credentials(&url));
+ }
+ match Url::parse(value) {
+ Ok(url) => matches!(url.scheme(), "http" | "https") && !url_has_credentials(&url),
+ Err(url::ParseError::RelativeUrlWithoutBase) => true,
+ Err(_) => false,
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -776,6 +996,32 @@ mod tests {
assert!(!html.contains("
+
+ 1.
+ Linked story
+
+
+ 4 comments
+
+ "#,
+ Some(&base),
+ );
+
+ assert!(
+ markdown.contains("[Linked story](https://stories.example.test/one)"),
+ "story destination was discarded: {markdown}"
+ );
+ assert!(
+ markdown.contains("[4 comments](https://news.example.test/item?id=1)"),
+ "comment destination was discarded: {markdown}"
+ );
+ }
+
#[test]
fn keeps_an_existing_later_h1_when_metadata_title_is_empty() {
assert_eq!(
@@ -783,4 +1029,50 @@ mod tests {
"Lead.\n\n# Existing title"
);
}
+
+ #[test]
+ fn requires_a_large_gain_before_replacing_a_scored_candidate() {
+ assert!(!semantic_is_substantially_richer(200, 320));
+ assert!(!semantic_is_substantially_richer(200, 350));
+ assert!(semantic_is_substantially_richer(200, 351));
+ assert!(semantic_is_substantially_richer(1_000, 1_751));
+ }
+
+ #[test]
+ fn counts_only_publishable_images_with_meaningful_alt_text() {
+ assert_eq!(
+ meaningful_image_count(
+ r#""#
+ ),
+ 2
+ );
+ }
+
+ #[test]
+ fn hidden_style_detection_requires_exact_css_declarations() {
+ for hidden in [
+ "display: none",
+ "DISPLAY : none !important",
+ "color: red; visibility: HIDDEN",
+ ] {
+ assert!(style_hides_element(hidden), "should be hidden: {hidden}");
+ }
+ for visible in [
+ "--footer-display: none",
+ "--panel-visibility: hidden",
+ "display: block",
+ "content: 'display:none'",
+ ] {
+ assert!(
+ !style_hides_element(visible),
+ "should be visible: {visible}"
+ );
+ }
+ }
}
diff --git a/crates/opsail-read/src/lib.rs b/crates/opsail-read/src/lib.rs
index d71f963..34436eb 100644
--- a/crates/opsail-read/src/lib.rs
+++ b/crates/opsail-read/src/lib.rs
@@ -4,6 +4,8 @@ mod error;
mod extract;
mod model;
mod source;
+mod standardize;
+mod verification;
use std::time::Instant;
@@ -12,14 +14,15 @@ use url::Url;
pub use error::ReadError;
pub use model::{
- DEFAULT_CONNECT_TIMEOUT, DEFAULT_MAX_BYTES, DEFAULT_MAX_DEPTH, DEFAULT_MAX_ELEMENTS,
- DEFAULT_TIMEOUT, DocumentMetadata, ExtractionInfo, ExtractionMethod, Input, QualityGrade,
- QualityInfo, ReadOptions, ReadResult, SourceInfo, SourceKind,
+ CapturedDocument, DEFAULT_CONNECT_TIMEOUT, DEFAULT_MAX_BYTES, DEFAULT_MAX_DEPTH,
+ DEFAULT_MAX_ELEMENTS, DEFAULT_TIMEOUT, DocumentMetadata, ExtractionInfo, ExtractionMethod,
+ Input, QualityGrade, QualityInfo, ReadOptions, ReadResult, ReadSource, SourceInfo, SourceKind,
};
+pub use opsail_chrome::{CdpSource, CdpWaitUntil, ChromeError, ChromeSource};
/// Acquire and extract one HTML document.
-pub async fn read(input: Input, options: &ReadOptions) -> Result {
- let loaded = source::load(input, options).await?;
+pub async fn read(source: ReadSource, options: &ReadOptions) -> Result {
+ let loaded = source::load(source, options).await?;
build_result(
&loaded.html,
loaded.base_url.as_ref(),
@@ -30,23 +33,18 @@ pub async fn read(input: Input, options: &ReadOptions) -> Result) -> Result {
- if let Some(base_url) = base_url {
- source::validate_web_url(base_url)?;
- }
- if html.len() > DEFAULT_MAX_BYTES {
- return Err(ReadError::InputTooLarge {
- limit: DEFAULT_MAX_BYTES,
- });
- }
- let source = SourceInfo {
- kind: SourceKind::Memory,
- requested: base_url.map_or_else(|| "".to_owned(), ToString::to_string),
- resolved_url: base_url.cloned(),
- content_type: Some("text/html".to_owned()),
- charset: "utf-8".to_owned(),
- bytes: html.len(),
- };
- build_result(html, base_url, source, Vec::new())
+ let options = ReadOptions::default();
+ let loaded = source::load_captured(
+ CapturedDocument::with_urls(html, base_url.cloned(), None),
+ &options,
+ )?;
+ source::validate_loaded_document(&loaded)?;
+ build_result(
+ &loaded.html,
+ loaded.base_url.as_ref(),
+ loaded.source,
+ loaded.warnings,
+ )
}
fn build_result(
@@ -60,13 +58,13 @@ fn build_result(
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
if extracted.metadata.canonical_url.is_none() {
- extracted.metadata.canonical_url = base_url
+ extracted.metadata.canonical_url = source
+ .resolved_url
+ .as_ref()
.filter(|url| matches!(url.scheme(), "http" | "https"))
.map(ToString::to_string)
.or_else(|| {
- source
- .resolved_url
- .as_ref()
+ base_url
.filter(|url| matches!(url.scheme(), "http" | "https"))
.map(ToString::to_string)
});
@@ -140,7 +138,129 @@ mod tests {
assert_eq!(result.metadata.title, "Example");
assert!(result.content.contains("Readable text."));
- assert_eq!(result.source.kind, SourceKind::Memory);
+ assert_eq!(result.source.kind, SourceKind::Html);
+ }
+
+ #[tokio::test]
+ async fn preserves_unicode_in_already_decoded_memory_input() {
+ let html = "Café Café déjà vu.
";
+ let result = read(Input::Memory(html.to_owned()), &ReadOptions::default())
+ .await
+ .unwrap();
+
+ assert_eq!(result.metadata.title, "Café");
+ assert!(result.content.contains("Café déjà vu."));
+ assert_eq!(result.source.charset, "utf-8");
+ assert_eq!(result.source.bytes, html.len());
+ }
+
+ #[tokio::test]
+ async fn records_caller_captured_html_as_an_html_source() {
+ let final_url = Url::parse("https://example.test/rendered/article").unwrap();
+ let document = CapturedDocument::new(
+ "Captured Rendered article text.
",
+ Some(final_url.clone()),
+ );
+ let result = read(ReadSource::Html(document), &ReadOptions::default())
+ .await
+ .unwrap();
+
+ assert_eq!(result.source.kind, SourceKind::Html);
+ assert_eq!(result.source.resolved_url, Some(final_url));
+ assert_eq!(result.metadata.title, "Captured");
+ }
+
+ #[tokio::test]
+ async fn separates_captured_link_resolution_from_final_url_provenance() {
+ let base_url = Url::parse("https://static.example.test/articles/current/").unwrap();
+ let final_url = Url::parse("https://reader.example.test/final/article").unwrap();
+ let document = CapturedDocument::with_urls(
+ "Captured Rendered article text.
Related ",
+ Some(base_url),
+ Some(final_url.clone()),
+ );
+ let result = read(ReadSource::Html(document), &ReadOptions::default())
+ .await
+ .unwrap();
+
+ assert_eq!(result.source.resolved_url, Some(final_url.clone()));
+ assert_eq!(
+ result.metadata.canonical_url.as_deref(),
+ Some(final_url.as_str())
+ );
+ assert!(
+ result
+ .content_html
+ .contains("https://static.example.test/articles/related")
+ );
+ }
+
+ #[test]
+ fn debug_output_redacts_browser_endpoints_urls_paths_and_html() {
+ let mut cdp =
+ CdpSource::new("wss://provider.example.test/devtools/browser/id?token=secret");
+ cdp.url = Some(Url::parse("https://private.example.test/account?key=secret").unwrap());
+ let cdp_debug = format!("{:?}", ReadSource::Cdp(cdp));
+ assert!(!cdp_debug.contains("provider.example.test"));
+ assert!(!cdp_debug.contains("private.example.test"));
+ assert!(!cdp_debug.contains("secret"));
+
+ let mut chrome = ChromeSource::new(
+ Url::parse("https://private.example.test/launch?key=secret").unwrap(),
+ );
+ chrome.executable_path = Some("/private/path/to/chrome".into());
+ let chrome_debug = format!("{:?}", ReadSource::Chrome(chrome));
+ assert!(!chrome_debug.contains("private.example.test"));
+ assert!(!chrome_debug.contains("/private/path/to/chrome"));
+ assert!(!chrome_debug.contains("secret"));
+
+ let html_debug = format!(
+ "{:?}",
+ ReadSource::Html(CapturedDocument::new(
+ "sensitive document text",
+ None,
+ ))
+ );
+ assert!(!html_debug.contains("sensitive document text"));
+ assert!(html_debug.contains("html_bytes"));
+ }
+
+ #[tokio::test]
+ async fn rejects_a_verification_page_from_a_browser_capture() {
+ let document = CapturedDocument::new(
+ r#"
+
+
+
+ 环境异常 完成验证后即可继续访问。
+ "#,
+ Some(Url::parse("https://mp.weixin.qq.com/s/example").unwrap()),
+ );
+
+ assert!(matches!(
+ read(ReadSource::Html(document), &ReadOptions::default()).await,
+ Err(ReadError::VerificationRequired { .. })
+ ));
+ }
+
+ #[tokio::test]
+ async fn validates_the_base_url_for_memory_input() {
+ let html = "Readable text.
";
+ let mut options = ReadOptions {
+ base_url: Some(Url::parse("file:///tmp/article.html").unwrap()),
+ ..ReadOptions::default()
+ };
+
+ assert!(matches!(
+ read(Input::Memory(html.to_owned()), &options).await,
+ Err(ReadError::UnsupportedScheme(scheme)) if scheme == "file"
+ ));
+
+ options.base_url = Some(Url::parse("https://user:secret@example.test/article").unwrap());
+ assert!(matches!(
+ read(Input::Memory(html.to_owned()), &options).await,
+ Err(ReadError::UrlContainsCredentials)
+ ));
}
#[test]
diff --git a/crates/opsail-read/src/model.rs b/crates/opsail-read/src/model.rs
index 9ca6434..15ee36c 100644
--- a/crates/opsail-read/src/model.rs
+++ b/crates/opsail-read/src/model.rs
@@ -1,6 +1,8 @@
+use std::fmt;
use std::path::PathBuf;
use std::time::Duration;
+use opsail_chrome::{CdpSource, ChromeSource};
use serde::Serialize;
use serde_json::{Value, json};
use url::Url;
@@ -10,15 +12,85 @@ pub const DEFAULT_MAX_ELEMENTS: usize = 50_000;
pub const DEFAULT_MAX_DEPTH: usize = 256;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
+pub(crate) const DEFAULT_USER_AGENT: &str = concat!("opsail/", env!("CARGO_PKG_VERSION"));
-/// The source to read.
-#[derive(Debug, Clone)]
-pub enum Input {
+/// A caller-managed document captured from a browser or another rendered host.
+#[derive(Clone)]
+pub struct CapturedDocument {
+ pub html: String,
+ pub base_url: Option,
+ pub final_url: Option,
+}
+
+impl CapturedDocument {
+ pub fn new(html: impl Into, final_url: Option) -> Self {
+ Self {
+ html: html.into(),
+ base_url: final_url.clone(),
+ final_url,
+ }
+ }
+
+ pub fn with_urls(
+ html: impl Into,
+ base_url: Option,
+ final_url: Option,
+ ) -> Self {
+ Self {
+ html: html.into(),
+ base_url,
+ final_url,
+ }
+ }
+}
+
+impl fmt::Debug for CapturedDocument {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("CapturedDocument")
+ .field("html_bytes", &self.html.len())
+ .field("has_base_url", &self.base_url.is_some())
+ .field("has_final_url", &self.final_url.is_some())
+ .finish()
+ }
+}
+
+/// The source to acquire and read.
+#[derive(Clone)]
+pub enum ReadSource {
Url(Url),
File(PathBuf),
Stdin(Vec),
+ Html(CapturedDocument),
+ Cdp(CdpSource),
+ Chrome(ChromeSource),
+ /// Compatibility input for callers that supplied HTML through `ReadOptions::base_url`.
+ Memory(String),
}
+impl fmt::Debug for ReadSource {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Url(_) => formatter.debug_tuple("Url").field(&"").finish(),
+ Self::File(_) => formatter.debug_tuple("File").field(&"").finish(),
+ Self::Stdin(bytes) => formatter
+ .debug_struct("Stdin")
+ .field("bytes", &bytes.len())
+ .finish(),
+ Self::Html(document) => formatter.debug_tuple("Html").field(document).finish(),
+ Self::Cdp(source) => formatter.debug_tuple("Cdp").field(source).finish(),
+ Self::Chrome(source) => formatter.debug_tuple("Chrome").field(source).finish(),
+ Self::Memory(html) => formatter
+ .debug_struct("Memory")
+ .field("html_bytes", &html.len())
+ .finish(),
+ }
+ }
+}
+
+/// Backwards-compatible name for [`ReadSource`].
+pub type Input = ReadSource;
+
/// Limits and request settings used while acquiring a document.
#[derive(Debug, Clone)]
pub struct ReadOptions {
@@ -26,7 +98,8 @@ pub struct ReadOptions {
pub timeout: Duration,
pub connect_timeout: Duration,
pub max_bytes: usize,
- pub user_agent: String,
+ /// An exact User-Agent value, or `None` to select the automatic profile.
+ pub user_agent: Option,
pub accept_language: Option,
}
@@ -37,7 +110,7 @@ impl Default for ReadOptions {
timeout: DEFAULT_TIMEOUT,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
max_bytes: DEFAULT_MAX_BYTES,
- user_agent: format!("opsail/{}", env!("CARGO_PKG_VERSION")),
+ user_agent: None,
accept_language: None,
}
}
@@ -49,6 +122,9 @@ pub enum SourceKind {
Url,
File,
Stdin,
+ Html,
+ Cdp,
+ Chrome,
Memory,
}
diff --git a/crates/opsail-read/src/source.rs b/crates/opsail-read/src/source.rs
index 9d8d165..780e3a5 100644
--- a/crates/opsail-read/src/source.rs
+++ b/crates/opsail-read/src/source.rs
@@ -2,14 +2,28 @@ use std::sync::Once;
use encoding_rs::{Encoding, UTF_8, WINDOWS_1252};
use futures_util::StreamExt;
+use opsail_chrome::{
+ CaptureOptions, CapturedPage, CdpSource, ChromeError, ChromeSource, RenderedPageEvidence,
+ capture_cdp_with_probes, capture_chrome_with_probes,
+};
use reqwest::header::{ACCEPT, ACCEPT_LANGUAGE, CONTENT_LENGTH, CONTENT_TYPE};
use tokio::io::AsyncReadExt;
use url::Url;
use crate::error::ReadError;
-use crate::model::{Input, ReadOptions, SourceInfo, SourceKind};
+use crate::model::{
+ CapturedDocument, DEFAULT_USER_AGENT, Input, ReadOptions, SourceInfo, SourceKind,
+};
+use crate::verification;
const ACCEPT_VALUE: &str = "text/html, application/xhtml+xml;q=0.9, */*;q=0.1";
+const MAX_ERROR_HTML_PROBE_BYTES: usize = 512 * 1024;
+const WECHAT_BROWSER_USER_AGENT: &str = concat!(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ",
+ "AppleWebKit/537.36 (KHTML, like Gecko) ",
+ "Chrome/138.0.0.0 Safari/537.36 opsail/",
+ env!("CARGO_PKG_VERSION")
+);
static INSTALL_TLS_PROVIDER: Once = Once::new();
pub(crate) struct LoadedDocument {
@@ -17,6 +31,12 @@ pub(crate) struct LoadedDocument {
pub base_url: Option,
pub source: SourceInfo,
pub warnings: Vec,
+ verification_context: VerificationContext,
+}
+
+enum VerificationContext {
+ Static,
+ Browser(Option),
}
pub(crate) async fn load(input: Input, options: &ReadOptions) -> Result {
@@ -24,6 +44,12 @@ pub(crate) async fn load(input: Input, options: &ReadOptions) -> Result Result {
match input {
Input::Url(url) => load_url(url, options).await,
Input::File(path) => {
@@ -109,7 +135,139 @@ pub(crate) async fn load(input: Input, options: &ReadOptions) -> Result load_captured(document, options),
+ Input::Cdp(source) => load_cdp(source, options).await,
+ Input::Chrome(source) => load_chrome(source, options).await,
+ Input::Memory(html) => load_memory(html, options),
+ }
+}
+
+pub(crate) fn load_captured(
+ document: CapturedDocument,
+ options: &ReadOptions,
+) -> Result {
+ let final_url = document.final_url;
+ let base_url = document
+ .base_url
+ .or_else(|| final_url.clone())
+ .or_else(|| options.base_url.clone());
+ let requested = final_url
+ .as_ref()
+ .or(base_url.as_ref())
+ .map_or_else(|| "".to_owned(), ToString::to_string);
+ load_utf8_html(
+ document.html,
+ SourceKind::Html,
+ requested,
+ base_url,
+ final_url,
+ options.max_bytes,
+ )
+}
+
+pub(crate) fn validate_loaded_document(loaded: &LoadedDocument) -> Result<(), ReadError> {
+ let (rendered, allow_static_profile) = match &loaded.verification_context {
+ VerificationContext::Static => (None, true),
+ VerificationContext::Browser(rendered) => (rendered.as_ref(), false),
+ };
+ reject_verification_page_with_context(
+ &loaded.html,
+ loaded
+ .source
+ .resolved_url
+ .as_ref()
+ .or(loaded.base_url.as_ref()),
+ &loaded.source.requested,
+ rendered,
+ allow_static_profile,
+ )
+}
+
+async fn load_cdp(source: CdpSource, options: &ReadOptions) -> Result {
+ if let Some(url) = source.url.as_ref() {
+ validate_web_url(url)?;
}
+ let requested = source.url.as_ref().map(ToString::to_string);
+ let probes = verification::rendered_probes().map_err(ReadError::Chrome)?;
+ let captured = capture_cdp_with_probes(&source, &capture_options(options), &probes)
+ .await
+ .map_err(map_chrome_error)?;
+ let requested = requested.unwrap_or_else(|| captured.final_url.to_string());
+ load_browser_capture(captured, SourceKind::Cdp, requested, options.max_bytes)
+}
+
+async fn load_chrome(
+ source: ChromeSource,
+ options: &ReadOptions,
+) -> Result {
+ validate_web_url(&source.url)?;
+ let requested = source.url.to_string();
+ let probes = verification::rendered_probes().map_err(ReadError::Chrome)?;
+ let captured = capture_chrome_with_probes(&source, &capture_options(options), &probes)
+ .await
+ .map_err(map_chrome_error)?;
+ load_browser_capture(captured, SourceKind::Chrome, requested, options.max_bytes)
+}
+
+fn load_browser_capture(
+ captured: CapturedPage,
+ kind: SourceKind,
+ requested: String,
+ max_bytes: usize,
+) -> Result {
+ validate_web_url(&captured.final_url)?;
+ if let Some(response) = captured.response() {
+ reject_verification_response(
+ response.status(),
+ response.header("cf-mitigated"),
+ response.header("x-amzn-waf-action"),
+ &requested,
+ )?;
+ }
+ let rendered_evidence = captured.rendered_evidence().cloned();
+ let final_url = captured.final_url;
+ let mut loaded = load_utf8_html(
+ captured.html,
+ kind,
+ requested,
+ Some(final_url.clone()),
+ Some(final_url),
+ max_bytes,
+ )?;
+ loaded.verification_context = VerificationContext::Browser(rendered_evidence);
+ Ok(loaded)
+}
+
+fn capture_options(options: &ReadOptions) -> CaptureOptions {
+ CaptureOptions {
+ timeout: options.timeout,
+ connect_timeout: options.connect_timeout,
+ max_bytes: options.max_bytes,
+ user_agent: options.user_agent.clone(),
+ accept_language: options.accept_language.clone(),
+ }
+}
+
+fn map_chrome_error(error: ChromeError) -> ReadError {
+ match error {
+ ChromeError::CaptureTooLarge { limit } => ReadError::InputTooLarge { limit },
+ error => ReadError::Chrome(error),
+ }
+}
+
+fn load_memory(html: String, options: &ReadOptions) -> Result {
+ let requested = options
+ .base_url
+ .as_ref()
+ .map_or_else(|| "".to_owned(), ToString::to_string);
+ load_utf8_html(
+ html,
+ SourceKind::Memory,
+ requested,
+ options.base_url.clone(),
+ options.base_url.clone(),
+ options.max_bytes,
+ )
}
async fn load_url(url: Url, options: &ReadOptions) -> Result {
@@ -119,7 +277,7 @@ async fn load_url(url: Url, options: &ReadOptions) -> Result Result().ok())
- .is_some_and(|length| length > options.max_bytes)
- {
- return Err(ReadError::InputTooLarge {
- limit: options.max_bytes,
- });
- }
+ .is_some_and(|length| length > body_limit);
let content_type = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
- if let Some(content_type) = &content_type
- && !content_type_is_html(content_type)
- && !content_type_is_tolerated_generic(content_type)
- {
- return Err(ReadError::UnsupportedContentType(content_type.clone()));
+ let unsupported_content_type = content_type.as_ref().is_some_and(|content_type| {
+ !content_type_is_html(content_type) && !content_type_is_tolerated_generic(content_type)
+ });
+ if !status.is_success() && (declared_too_large || unsupported_content_type) {
+ return Err(http_status_error(&final_url, status.as_u16()));
+ }
+ if declared_too_large {
+ return Err(ReadError::InputTooLarge {
+ limit: options.max_bytes,
+ });
+ }
+ if unsupported_content_type {
+ return Err(ReadError::UnsupportedContentType(
+ content_type.expect("unsupported content type is present"),
+ ));
}
let mut bytes = Vec::new();
@@ -176,7 +349,10 @@ async fn load_url(url: Url, options: &ReadOptions) -> Result options.max_bytes {
+ if bytes.len().saturating_add(chunk.len()) > body_limit {
+ if !status.is_success() {
+ return Err(http_status_error(&final_url, status.as_u16()));
+ }
return Err(ReadError::InputTooLarge {
limit: options.max_bytes,
});
@@ -184,15 +360,83 @@ async fn load_url(url: Url, options: &ReadOptions) -> Result ReadError {
+ ReadError::HttpStatus {
+ url: url.to_string(),
+ status,
+ }
+}
+
+fn request_user_agent<'a>(url: &Url, configured: Option<&'a str>) -> &'a str {
+ match configured {
+ Some(user_agent) => user_agent,
+ None if is_wechat_url(url) => WECHAT_BROWSER_USER_AGENT,
+ None => DEFAULT_USER_AGENT,
+ }
+}
+
+fn reject_verification_page(
+ html: &str,
+ resolved_url: Option<&Url>,
+ requested_url: &str,
+) -> Result<(), ReadError> {
+ reject_verification_page_with_context(html, resolved_url, requested_url, None, true)
+}
+
+fn reject_verification_page_with_context(
+ html: &str,
+ resolved_url: Option<&Url>,
+ requested_url: &str,
+ rendered: Option<&RenderedPageEvidence>,
+ allow_static_profile: bool,
+) -> Result<(), ReadError> {
+ if verification::detect_document(html, resolved_url, rendered, allow_static_profile).is_some() {
+ return Err(ReadError::VerificationRequired {
+ url: verification::redacted_url(requested_url),
+ });
+ }
+ Ok(())
+}
+
+fn reject_verification_response(
+ status: u16,
+ cf_mitigated: Option<&str>,
+ aws_waf_action: Option<&str>,
+ requested_url: &str,
+) -> Result<(), ReadError> {
+ if verification::detect_response(status, cf_mitigated, aws_waf_action).is_some() {
+ return Err(ReadError::VerificationRequired {
+ url: verification::redacted_url(requested_url),
+ });
+ }
+ Ok(())
+}
+
+fn is_wechat_url(url: &Url) -> bool {
+ url.host_str()
+ .is_some_and(|host| host.eq_ignore_ascii_case("mp.weixin.qq.com"))
}
pub(crate) fn validate_web_url(url: &Url) -> Result<(), ReadError> {
@@ -275,6 +519,49 @@ fn decode_loaded(
bytes: bytes.len(),
},
warnings,
+ verification_context: VerificationContext::Static,
+ })
+}
+
+fn load_utf8_html(
+ html: String,
+ kind: SourceKind,
+ requested: String,
+ base_url: Option,
+ resolved_url: Option,
+ max_bytes: usize,
+) -> Result {
+ if let Some(base_url) = base_url.as_ref() {
+ validate_web_url(base_url)?;
+ }
+ if let Some(resolved_url) = resolved_url.as_ref() {
+ validate_web_url(resolved_url)?;
+ }
+
+ let bytes = html.len();
+ if bytes > max_bytes {
+ return Err(ReadError::InputTooLarge { limit: max_bytes });
+ }
+ if html.is_empty() {
+ return Err(ReadError::EmptyInput);
+ }
+ if html.as_bytes().iter().take(4096).any(|byte| *byte == 0) || !looks_like_html(&html) {
+ return Err(ReadError::NotHtml);
+ }
+
+ Ok(LoadedDocument {
+ html,
+ base_url,
+ source: SourceInfo {
+ kind,
+ requested,
+ resolved_url,
+ content_type: Some("text/html".to_owned()),
+ charset: "utf-8".to_owned(),
+ bytes,
+ },
+ warnings: Vec::new(),
+ verification_context: VerificationContext::Static,
})
}
@@ -390,4 +677,126 @@ mod tests {
fn accepts_html_fragments() {
assert!(looks_like_html(" Readable
"));
}
+
+ #[test]
+ fn uses_a_browser_compatible_default_user_agent_for_wechat() {
+ let url = Url::parse("https://mp.weixin.qq.com/s/example").unwrap();
+ let user_agent = request_user_agent(&url, None);
+
+ assert!(user_agent.starts_with("Mozilla/5.0 "));
+ assert!(user_agent.contains("Safari/537.36"));
+ assert!(user_agent.contains("opsail/"));
+ }
+
+ #[test]
+ fn preserves_an_explicit_user_agent_for_wechat() {
+ let url = Url::parse("https://mp.weixin.qq.com/s/example").unwrap();
+ assert_eq!(
+ request_user_agent(&url, Some("research-reader/42")),
+ "research-reader/42"
+ );
+ assert_eq!(
+ request_user_agent(&url, Some(DEFAULT_USER_AGENT)),
+ DEFAULT_USER_AGENT
+ );
+
+ let unrelated = Url::parse("https://example.test/article").unwrap();
+ assert_eq!(request_user_agent(&unrelated, None), DEFAULT_USER_AGENT);
+ }
+
+ #[test]
+ fn rejects_a_high_confidence_wechat_verification_page() {
+ let requested_url =
+ "https://mp.weixin.qq.com/s/challenge-fixture?poc_token=request-secret#fragment";
+ let resolved_url = Url::parse(
+ "https://mp.weixin.qq.com/mp/wappoc_appmsgcaptcha?poc_token=redirect-secret",
+ )
+ .unwrap();
+ let html = r#"
+
+
+
+
+
+ 当前环境异常 去验证
+
+ "#;
+
+ assert!(matches!(
+ reject_verification_page(html, Some(&resolved_url), requested_url),
+ Err(ReadError::VerificationRequired { url: rejected })
+ if rejected == "https://mp.weixin.qq.com/s/challenge-fixture"
+ && !rejected.contains("request-secret")
+ ));
+ }
+
+ #[test]
+ fn does_not_reject_articles_or_non_wechat_pages_with_verification_markers() {
+ let wechat = Url::parse("https://mp.weixin.qq.com/s/article").unwrap();
+ let unrelated = Url::parse("https://example.test/copied-page").unwrap();
+ let article = r#"
+
+
+
Quoted interface markup.
+
+ "#;
+ let copied_challenge = r#"
+
+ Copied verification page.
+ "#;
+
+ assert!(reject_verification_page(article, Some(&wechat), wechat.as_str()).is_ok());
+ assert!(
+ reject_verification_page(copied_challenge, Some(&unrelated), unrelated.as_str())
+ .is_ok()
+ );
+ }
+
+ #[test]
+ fn rejects_a_high_confidence_cloudflare_challenge_page() {
+ let requested_url = "https://www.npmjs.com/package/opsail";
+ let resolved_url = Url::parse(requested_url).unwrap();
+ let html = r#"
+
+ Just a moment...
+
+
+
+
+ Enable JavaScript and cookies to continue
+
+
+
+
+ "#;
+
+ assert!(matches!(
+ reject_verification_page(html, Some(&resolved_url), requested_url),
+ Err(ReadError::VerificationRequired { .. })
+ ));
+ }
+
+ #[test]
+ fn does_not_reject_an_article_discussing_cloudflare_challenges() {
+ let url = Url::parse("https://example.test/cloudflare-challenge-guide").unwrap();
+ let article = r#"
+ Understanding Cloudflare challenge pages
+
+ Diagnosing a Cloudflare challenge
+ A visitor may briefly see “Just a moment...” while checks run.
+ Diagnostic terms include _cf_chl_opt, cf-ray,
+ challenge-form, and /cdn-cgi/challenge-platform.
+ This article explains those indicators and is not itself a verification page.
+ "#;
+
+ assert!(reject_verification_page(article, Some(&url), url.as_str()).is_ok());
+ }
}
diff --git a/crates/opsail-read/src/standardize.rs b/crates/opsail-read/src/standardize.rs
new file mode 100644
index 0000000..9d8cac3
--- /dev/null
+++ b/crates/opsail-read/src/standardize.rs
@@ -0,0 +1,420 @@
+use dom_query::{Document, Selection};
+use url::Url;
+
+/// Normalize browser-oriented markup before article scoring sees it.
+pub(crate) fn standardize_document(document: &Document, base_url: Option<&Url>) {
+ reveal_wechat_article(document, base_url);
+ recover_noscript_images(document);
+ normalize_images(document);
+ normalize_code_blocks(document);
+}
+
+fn reveal_wechat_article(document: &Document, base_url: Option<&Url>) {
+ let is_wechat = base_url
+ .and_then(Url::host_str)
+ .is_some_and(|host| host.eq_ignore_ascii_case("mp.weixin.qq.com"));
+ if !is_wechat || !document.select("#js_article").exists() {
+ return;
+ }
+
+ // WeChat ships the complete article in this node, initially hides it,
+ // then removes the style from page JavaScript. Static readers must apply
+ // that one initialization step before generic hidden-content filtering.
+ document
+ .select("#js_content.rich_media_content")
+ .remove_attrs(&["hidden", "aria-hidden", "style"]);
+}
+
+fn recover_noscript_images(document: &Document) {
+ for noscript in document.select("noscript").iter() {
+ let scope = noscript.clone();
+ let mut images = scope
+ .select("img")
+ .iter()
+ .filter(has_image_source)
+ .map(|image| image.html().to_string())
+ .collect::>();
+
+ // With scripting enabled, HTML parsers represent a noscript body as a
+ // text node. Parse only that inert text and copy image elements out.
+ if images.is_empty() {
+ let fallback = Document::fragment(noscript.text());
+ images = fallback
+ .select("img")
+ .iter()
+ .filter(has_image_source)
+ .map(|image| image.html().to_string())
+ .collect();
+ }
+
+ if !images.is_empty() {
+ noscript.replace_with_html(images.join(""));
+ }
+ }
+}
+
+fn has_image_source(image: &Selection<'_>) -> bool {
+ [
+ "src",
+ "srcset",
+ "data-src",
+ "data-srcset",
+ "data-original",
+ "data-lazy-src",
+ ]
+ .iter()
+ .any(|attribute| {
+ image
+ .attr(attribute)
+ .is_some_and(|value| !value.trim().is_empty())
+ })
+}
+
+fn normalize_images(document: &Document) {
+ for picture in document.select("picture").iter() {
+ let scope = picture.clone();
+ let Some(image) = scope.select("img").iter().next() else {
+ continue;
+ };
+ let current = image.attr("src").map(|value| value.to_string());
+ if current.as_deref().is_some_and(is_usable_image_source) {
+ continue;
+ }
+
+ let candidate = scope.select("source[srcset]").iter().find_map(|source| {
+ source
+ .attr("srcset")
+ .and_then(|srcset| best_srcset_candidate(srcset.as_ref()))
+ });
+ if let Some(candidate) = candidate {
+ image.set_attr("src", &candidate);
+ }
+ }
+
+ for image in document.select("img").iter() {
+ let srcset_candidate = ["data-srcset", "srcset"].iter().find_map(|attribute| {
+ image
+ .attr(attribute)
+ .and_then(|srcset| best_srcset_candidate(srcset.as_ref()))
+ });
+ let direct_candidate = [
+ "data-src",
+ "data-original",
+ "data-lazy-src",
+ "data-original-src",
+ "data-url",
+ ]
+ .iter()
+ .find_map(|attribute| {
+ image
+ .attr(attribute)
+ .map(|value| value.trim().to_owned())
+ .filter(|value| is_safe_resource_reference(value))
+ });
+ let current = image.attr("src").map(|value| value.to_string());
+
+ if let Some(candidate) = srcset_candidate.or(direct_candidate)
+ && (current
+ .as_deref()
+ .is_none_or(|value| !is_usable_image_source(value))
+ || image.has_attr("srcset")
+ || image.has_attr("data-srcset"))
+ {
+ image.set_attr("src", &candidate);
+ }
+
+ image.remove_attrs(&[
+ "data-src",
+ "data-srcset",
+ "data-original",
+ "data-lazy-src",
+ "data-original-src",
+ "data-url",
+ "data-ll-status",
+ ]);
+ }
+}
+
+fn best_srcset_candidate(srcset: &str) -> Option {
+ split_srcset_candidates(srcset)
+ .into_iter()
+ .filter_map(|candidate| {
+ let mut parts = candidate.split_ascii_whitespace();
+ let url = parts.next()?.trim();
+ if !is_safe_resource_reference(url) {
+ return None;
+ }
+ let score = parts
+ .next()
+ .and_then(srcset_descriptor_score)
+ .unwrap_or(1.0);
+ Some((score, url.to_owned()))
+ })
+ .max_by(|left, right| left.0.total_cmp(&right.0))
+ .map(|(_, url)| url)
+}
+
+fn split_srcset_candidates(srcset: &str) -> Vec<&str> {
+ let protect_data_url_commas = srcset.to_ascii_lowercase().contains("data:");
+ let mut candidates = Vec::new();
+ let mut start = 0;
+ for (index, character) in srcset.char_indices() {
+ if character != ',' {
+ continue;
+ }
+ let next_is_whitespace = srcset
+ .get(index + 1..)
+ .and_then(|rest| rest.chars().next())
+ .is_some_and(char::is_whitespace);
+ if !protect_data_url_commas || next_is_whitespace {
+ candidates.push(&srcset[start..index]);
+ start = index + 1;
+ }
+ }
+ candidates.push(&srcset[start..]);
+ candidates
+}
+
+fn srcset_descriptor_score(descriptor: &str) -> Option {
+ let descriptor = descriptor.trim();
+ if let Some(value) = descriptor.strip_suffix('w') {
+ return value.parse::().ok();
+ }
+ if let Some(value) = descriptor.strip_suffix('x') {
+ return value.parse::().ok().map(|density| density * 1_000.0);
+ }
+ None
+}
+
+fn is_usable_image_source(value: &str) -> bool {
+ !value
+ .trim_start()
+ .to_ascii_lowercase()
+ .starts_with("data:image/")
+ && is_safe_resource_reference(value)
+}
+
+fn is_safe_resource_reference(value: &str) -> bool {
+ let value = value.trim();
+ if value.is_empty() || value.chars().any(char::is_control) {
+ return false;
+ }
+
+ if value.starts_with("//") {
+ return Url::parse(&format!("https:{value}")).is_ok_and(|url| !url_has_credentials(&url));
+ }
+
+ match Url::parse(value) {
+ Ok(url) => matches!(url.scheme(), "http" | "https") && !url_has_credentials(&url),
+ Err(url::ParseError::RelativeUrlWithoutBase) => true,
+ Err(_) => false,
+ }
+}
+
+fn url_has_credentials(url: &Url) -> bool {
+ !url.username().is_empty() || url.password().is_some()
+}
+
+fn normalize_code_blocks(document: &Document) {
+ for selector in [
+ "pre .lnt",
+ "pre .lineno",
+ "pre .react-syntax-highlighter-line-number",
+ "pre [data-line-number]",
+ "pre button",
+ "pre [class*='codeblock-button']",
+ "pre [class*='toolbar']",
+ "pre [class*='code__header']",
+ ] {
+ let chrome = document.select(selector);
+ for element in chrome.iter() {
+ mark_enclosing_pre(&element);
+ }
+ chrome.remove();
+ }
+
+ for span in document.select("pre span[style]").iter() {
+ let style = span
+ .attr("style")
+ .map(|value| {
+ value
+ .chars()
+ .filter(|character| !character.is_ascii_whitespace())
+ .flat_map(char::to_lowercase)
+ .collect::()
+ })
+ .unwrap_or_default();
+ let text = span.text();
+ if style.contains("user-select:none")
+ && !text.trim().is_empty()
+ && text
+ .trim()
+ .chars()
+ .all(|character| character.is_ascii_digit())
+ {
+ mark_enclosing_pre(&span);
+ span.remove();
+ }
+ }
+
+ for table in document
+ .select("table.lntable, table.rouge-table, table.highlighttable")
+ .iter()
+ {
+ let scope = table.clone();
+ let Some(code) = find_table_code(&scope) else {
+ continue;
+ };
+ let text = trim_code_boundaries(code.text().as_ref());
+ if text.trim().is_empty() {
+ continue;
+ }
+
+ let language = code_language(&code).or_else(|| code_language(&table));
+ let language_attributes = language.map_or_else(String::new, |language| {
+ format!(" class=\"language-{language}\" data-lang=\"{language}\"")
+ });
+ let replacement_target = table
+ .ancestors(Some(4))
+ .filter("code")
+ .iter()
+ .find(|ancestor| {
+ ancestor.select("table").length() == 1
+ && ancestor.text().trim() == table.text().trim()
+ })
+ .unwrap_or_else(|| table.clone());
+ replacement_target.replace_with_html(format!(
+ "{} ",
+ escape_html(&text)
+ ));
+ }
+
+ for pre in document.select("pre[data-opsail-normalize-code]").iter() {
+ if let Some(code) = pre.select("code").iter().next() {
+ code.set_text(&trim_code_boundaries(code.text().as_ref()));
+ }
+ pre.remove_attr("data-opsail-normalize-code");
+ }
+}
+
+fn mark_enclosing_pre(element: &Selection<'_>) {
+ if let Some(pre) = element.ancestors(Some(12)).filter("pre").iter().next() {
+ pre.set_attr("data-opsail-normalize-code", "true");
+ }
+}
+
+fn find_table_code<'a>(scope: &Selection<'a>) -> Option> {
+ for selector in [
+ "td.rouge-code pre",
+ "td.code pre",
+ "code[data-lang]",
+ "code[data-language]",
+ "code[class*='language-']",
+ ] {
+ if let Some(code) = scope
+ .select(selector)
+ .iter()
+ .find(|node| meaningful_code_score(node.text().as_ref()) > 0)
+ {
+ return Some(code);
+ }
+ }
+
+ scope
+ .select("pre")
+ .iter()
+ .max_by_key(|node| meaningful_code_score(node.text().as_ref()))
+ .filter(|node| meaningful_code_score(node.text().as_ref()) > 0)
+}
+
+fn meaningful_code_score(value: &str) -> usize {
+ let visible = value
+ .chars()
+ .filter(|character| !character.is_whitespace())
+ .collect::();
+ if !visible.is_empty() && !visible.chars().all(|character| character.is_ascii_digit()) {
+ visible.chars().count()
+ } else {
+ 0
+ }
+}
+
+fn code_language(node: &Selection<'_>) -> Option {
+ std::iter::once(node.clone())
+ .chain(node.ancestors(Some(10)).iter())
+ .find_map(|element| {
+ for attribute in ["data-lang", "data-language", "lang"] {
+ if let Some(language) = element
+ .attr(attribute)
+ .and_then(|value| sanitize_language(value.as_ref()))
+ {
+ return Some(language);
+ }
+ }
+
+ element.attr("class").and_then(|classes| {
+ classes.split_ascii_whitespace().find_map(|class| {
+ ["language-", "lang-"]
+ .iter()
+ .find_map(|prefix| class.strip_prefix(prefix))
+ .and_then(sanitize_language)
+ })
+ })
+ })
+}
+
+fn sanitize_language(value: &str) -> Option {
+ let value = value.trim().to_ascii_lowercase();
+ (!value.is_empty()
+ && value.len() <= 32
+ && value.chars().all(|character| {
+ character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '#' | '_')
+ }))
+ .then_some(value)
+}
+
+fn trim_code_boundaries(value: &str) -> String {
+ value
+ .trim_matches(|character| matches!(character, '\n' | '\r'))
+ .to_owned()
+}
+
+fn escape_html(value: &str) -> String {
+ value
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn selects_the_largest_safe_srcset_candidate() {
+ assert_eq!(
+ best_srcset_candidate("small.png 320w, large.png 1280w").as_deref(),
+ Some("large.png")
+ );
+ assert_eq!(
+ best_srcset_candidate("javascript:alert(1) 2x, safe.png 1x").as_deref(),
+ Some("safe.png")
+ );
+ assert_eq!(
+ best_srcset_candidate("data:image/gif;base64,AAAA 2x, safe.png 1x").as_deref(),
+ Some("safe.png")
+ );
+ }
+
+ #[test]
+ fn rejects_active_or_credentialed_image_references() {
+ assert!(!is_safe_resource_reference("data:text/html,bad"));
+ assert!(!is_safe_resource_reference("javascript:alert(1)"));
+ assert!(!is_safe_resource_reference(
+ "https://reader:secret@example.test/image.png"
+ ));
+ assert!(is_safe_resource_reference("../images/diagram.png"));
+ }
+}
diff --git a/crates/opsail-read/src/verification.rs b/crates/opsail-read/src/verification.rs
new file mode 100644
index 0000000..5f21e5f
--- /dev/null
+++ b/crates/opsail-read/src/verification.rs
@@ -0,0 +1,713 @@
+use dom_query::Document;
+use opsail_chrome::{ChromeError, RenderedPageEvidence, RenderedProbe};
+use url::Url;
+
+const ARTICLE_SURFACE_SELECTOR: &str = concat!(
+ "article, [role='article'], [itemprop='articleBody'], [property='articleBody'], ",
+ "#js_article, #js_content, .rich_media_content"
+);
+const MAIN_SURFACE_SELECTOR: &str = "main, [role='main']";
+const RELATIVE_URL_BASE: &str = "https://opsail.invalid/";
+const PROBE_WECHAT: u16 = 1;
+const PROBE_CLOUDFLARE: u16 = 2;
+const PROBE_GOOGLE: u16 = 3;
+const PROBE_DATADOME: u16 = 4;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum VerificationProvider {
+ WeChat,
+ Cloudflare,
+ AwsWaf,
+ Google,
+ DataDome,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) struct VerificationSignal {
+ pub(crate) provider: VerificationProvider,
+}
+
+struct DocumentEvidence<'a> {
+ document: Document,
+ resolved_url: Option<&'a Url>,
+ has_substantive_content: bool,
+ rendered: Option<&'a RenderedPageEvidence>,
+ allow_static_profile: bool,
+}
+
+impl<'a> DocumentEvidence<'a> {
+ fn new(
+ html: &str,
+ resolved_url: Option<&'a Url>,
+ rendered: Option<&'a RenderedPageEvidence>,
+ allow_static_profile: bool,
+ ) -> Self {
+ let document = Document::from(html);
+ let has_substantive_content = has_substantive_content(&document);
+ Self {
+ document,
+ resolved_url,
+ has_substantive_content,
+ rendered,
+ allow_static_profile,
+ }
+ }
+
+ fn profile_is_active(&self, probe_id: u16) -> bool {
+ match self.rendered {
+ Some(rendered) => rendered.result(probe_id).is_some_and(|result| {
+ result.matches() > 0
+ && result
+ .marker()
+ .is_some_and(|marker| marker.visible() && marker.stable())
+ }),
+ None => self.allow_static_profile,
+ }
+ }
+}
+
+type Detector = for<'a> fn(&DocumentEvidence<'a>) -> bool;
+
+const DETECTORS: &[(VerificationProvider, Detector)] = &[
+ (VerificationProvider::WeChat, detects_wechat),
+ (VerificationProvider::Cloudflare, detects_cloudflare),
+ (VerificationProvider::Google, detects_google),
+ (VerificationProvider::DataDome, detects_datadome),
+];
+
+/// Detects only full-page bot-verification interstitials backed by multiple
+/// independent pieces of DOM and URL evidence.
+///
+/// Ordinary login pages and embedded CAPTCHA widgets are intentionally outside
+/// this detector's scope.
+pub(crate) fn detect_document(
+ html: &str,
+ resolved_url: Option<&Url>,
+ rendered: Option<&RenderedPageEvidence>,
+ allow_static_profile: bool,
+) -> Option {
+ let evidence = DocumentEvidence::new(html, resolved_url, rendered, allow_static_profile);
+ DETECTORS.iter().find_map(|(provider, detector)| {
+ detector(&evidence).then_some(VerificationSignal {
+ provider: *provider,
+ })
+ })
+}
+
+/// Neutral live-layout probes executed by `opsail-chrome`. Provider identity
+/// and the decision rules remain private to this module.
+pub(crate) fn rendered_probes() -> Result, ChromeError> {
+ [
+ (PROBE_WECHAT, "#js_verify.weui-msg, #js_verify .weui-msg"),
+ (
+ PROBE_CLOUDFLARE,
+ "form#challenge-form[action], #challenge-stage, #challenge-running",
+ ),
+ (
+ PROBE_GOOGLE,
+ "form#captcha-form, form[action] .g-recaptcha[data-sitekey]",
+ ),
+ (
+ PROBE_DATADOME,
+ "#captcha-container, #datadome-captcha, .captcha-container",
+ ),
+ ]
+ .into_iter()
+ .map(|(id, selector)| RenderedProbe::new(id, selector))
+ .collect()
+}
+
+/// Detect provider-declared verification from a top-level document response.
+///
+/// These are exact transport contracts published by the providers. Status and
+/// header values are evaluated together where the provider requires it; no
+/// body text or generic error status participates in this decision.
+pub(crate) fn detect_response(
+ status: u16,
+ cf_mitigated: Option<&str>,
+ aws_waf_action: Option<&str>,
+) -> Option {
+ if header_value_is(cf_mitigated, "challenge") {
+ return Some(VerificationSignal {
+ provider: VerificationProvider::Cloudflare,
+ });
+ }
+
+ let is_aws_gate = (status == 202 && header_value_is(aws_waf_action, "challenge"))
+ || (status == 405 && header_value_is(aws_waf_action, "captcha"));
+ is_aws_gate.then_some(VerificationSignal {
+ provider: VerificationProvider::AwsWaf,
+ })
+}
+
+pub(crate) fn redacted_url(value: &str) -> String {
+ let Ok(mut url) = Url::parse(value) else {
+ return "".to_owned();
+ };
+ let _ = url.set_username("");
+ let _ = url.set_password(None);
+ url.set_query(None);
+ url.set_fragment(None);
+ url.to_string()
+}
+
+fn detects_wechat(evidence: &DocumentEvidence<'_>) -> bool {
+ evidence
+ .resolved_url
+ .is_some_and(|url| host_is_or_subdomain(url, "mp.weixin.qq.com"))
+ && !evidence.has_substantive_content
+ && evidence
+ .document
+ .select("#js_verify.weui-msg, #js_verify .weui-msg")
+ .exists()
+ && has_wechat_verification_resource(evidence)
+ && evidence.profile_is_active(PROBE_WECHAT)
+}
+
+fn has_wechat_verification_resource(evidence: &DocumentEvidence<'_>) -> bool {
+ has_inline_script_token(&evidence.document, "secitptpage/verify")
+ || evidence
+ .document
+ .select("link[href], script[src]")
+ .iter()
+ .any(|resource| {
+ let value = resource.attr("href").or_else(|| resource.attr("src"));
+ value
+ .as_deref()
+ .and_then(|value| parse_dom_url(value, evidence.resolved_url))
+ .is_some_and(|url| url.path().contains("/secitptpage/verify"))
+ })
+}
+
+fn detects_cloudflare(evidence: &DocumentEvidence<'_>) -> bool {
+ if evidence.has_substantive_content {
+ return false;
+ }
+
+ let has_challenge_form = evidence
+ .document
+ .select("form#challenge-form[action]")
+ .iter()
+ .any(|form| {
+ form.attr("action")
+ .as_deref()
+ .and_then(|action| parse_dom_url(action, evidence.resolved_url))
+ .is_some_and(|url| {
+ same_origin_or_unknown(&url, evidence.resolved_url)
+ && is_cloudflare_challenge_endpoint(&url)
+ })
+ });
+ if !has_challenge_form {
+ return false;
+ }
+
+ let has_runtime = has_inline_script_token(&evidence.document, "_cf_chl_opt")
+ || evidence
+ .document
+ .select("script[src]")
+ .iter()
+ .any(|script| {
+ script
+ .attr("src")
+ .as_deref()
+ .and_then(|source| parse_dom_url(source, evidence.resolved_url))
+ .is_some_and(|url| {
+ same_origin_or_unknown(&url, evidence.resolved_url)
+ && is_cloudflare_challenge_resource(&url)
+ })
+ });
+ has_runtime && evidence.profile_is_active(PROBE_CLOUDFLARE)
+}
+
+fn is_cloudflare_challenge_endpoint(url: &Url) -> bool {
+ is_cloudflare_challenge_resource(url)
+ || url
+ .path_segments()
+ .is_some_and(|mut segments| segments.any(|segment| segment.starts_with("__cf_chl_")))
+ || url
+ .query_pairs()
+ .any(|(name, _)| name.starts_with("__cf_chl_"))
+}
+
+fn is_cloudflare_challenge_resource(url: &Url) -> bool {
+ url.path().starts_with("/cdn-cgi/challenge-platform/")
+}
+
+fn detects_google(evidence: &DocumentEvidence<'_>) -> bool {
+ let Some(resolved_url) = evidence.resolved_url else {
+ return false;
+ };
+ if evidence.has_substantive_content
+ || !host_is_or_subdomain(resolved_url, "google.com")
+ || !is_google_sorry_path(resolved_url.path())
+ || !has_google_recaptcha_runtime(evidence)
+ {
+ return false;
+ }
+
+ let has_gate = evidence.document.select("form[action]").iter().any(|form| {
+ let recognized_form = form
+ .attr("id")
+ .is_some_and(|id| id.eq_ignore_ascii_case("captcha-form"))
+ || form
+ .attr("action")
+ .as_deref()
+ .and_then(|action| parse_dom_url(action, Some(resolved_url)))
+ .is_some_and(|url| {
+ host_is_or_subdomain(&url, "google.com") && is_google_sorry_path(url.path())
+ });
+ let has_continuation = form
+ .select("input[name='q'], input[name='continue']")
+ .exists();
+ let has_widget = form.select(".g-recaptcha[data-sitekey]").exists()
+ || form.select("iframe[src]").iter().any(|iframe| {
+ iframe
+ .attr("src")
+ .as_deref()
+ .and_then(|source| parse_dom_url(source, Some(resolved_url)))
+ .is_some_and(|url| is_google_recaptcha_url(&url))
+ });
+ recognized_form && has_continuation && has_widget
+ });
+ has_gate && evidence.profile_is_active(PROBE_GOOGLE)
+}
+
+fn is_google_sorry_path(path: &str) -> bool {
+ path.eq_ignore_ascii_case("/sorry")
+ || path
+ .get(..7)
+ .is_some_and(|prefix| prefix.eq_ignore_ascii_case("/sorry/"))
+}
+
+fn has_google_recaptcha_runtime(evidence: &DocumentEvidence<'_>) -> bool {
+ evidence
+ .document
+ .select("script[src]")
+ .iter()
+ .any(|script| {
+ script
+ .attr("src")
+ .as_deref()
+ .and_then(|source| parse_dom_url(source, evidence.resolved_url))
+ .is_some_and(|url| is_google_recaptcha_url(&url))
+ })
+}
+
+fn is_google_recaptcha_url(url: &Url) -> bool {
+ (host_is_or_subdomain(url, "google.com") || host_is_or_subdomain(url, "recaptcha.net"))
+ && url.path().starts_with("/recaptcha/")
+}
+
+fn detects_datadome(evidence: &DocumentEvidence<'_>) -> bool {
+ let Some(resolved_url) = evidence.resolved_url else {
+ return false;
+ };
+ if evidence.has_substantive_content
+ || !host_is_or_subdomain(resolved_url, "captcha-delivery.com")
+ || !is_captcha_path(resolved_url.path())
+ || !evidence
+ .document
+ .select("#captcha-container, #datadome-captcha, .captcha-container")
+ .exists()
+ {
+ return false;
+ }
+
+ let has_provider_resource = evidence
+ .document
+ .select("iframe[src], script[src], form[action]")
+ .iter()
+ .any(|resource| {
+ let value = resource.attr("src").or_else(|| resource.attr("action"));
+ value
+ .as_deref()
+ .and_then(|value| parse_dom_url(value, Some(resolved_url)))
+ .is_some_and(|url| host_is_or_subdomain(&url, "captcha-delivery.com"))
+ });
+ has_provider_resource && evidence.profile_is_active(PROBE_DATADOME)
+}
+
+fn is_captcha_path(path: &str) -> bool {
+ path.eq_ignore_ascii_case("/captcha")
+ || path
+ .get(..9)
+ .is_some_and(|prefix| prefix.eq_ignore_ascii_case("/captcha/"))
+}
+
+fn has_inline_script_token(document: &Document, token: &str) -> bool {
+ document.select("script").iter().any(|script| {
+ script.attr("src").is_none()
+ && script_is_executable(&script)
+ && script.inner_html().as_ref().contains(token)
+ })
+}
+
+fn script_is_executable(script: &dom_query::Selection<'_>) -> bool {
+ let Some(script_type) = script.attr("type") else {
+ return true;
+ };
+ let script_type = script_type.split(';').next().unwrap_or_default().trim();
+ script_type.is_empty()
+ || [
+ "module",
+ "text/javascript",
+ "application/javascript",
+ "text/ecmascript",
+ "application/ecmascript",
+ ]
+ .iter()
+ .any(|executable| script_type.eq_ignore_ascii_case(executable))
+}
+
+fn has_substantive_content(document: &Document) -> bool {
+ document
+ .select(ARTICLE_SURFACE_SELECTOR)
+ .iter()
+ .any(|surface| {
+ !is_statically_hidden(&surface)
+ && (surface.text().trim().chars().count() >= 1
+ || surface
+ .select("h1, h2, h3, p, li, pre, blockquote")
+ .exists())
+ })
+ || document
+ .select(MAIN_SURFACE_SELECTOR)
+ .iter()
+ .any(|surface| {
+ if is_statically_hidden(&surface) {
+ return false;
+ }
+ let text_chars = surface.text().trim().chars().count();
+ let blocks = surface.select("p, li, pre, blockquote, td").length();
+ text_chars >= 48
+ && ((surface.select("h1, h2, h3").exists() && blocks >= 1) || blocks >= 2)
+ })
+}
+
+fn is_statically_hidden(element: &dom_query::Selection<'_>) -> bool {
+ element.has_attr("hidden")
+ || element
+ .attr("aria-hidden")
+ .is_some_and(|value| value.trim().eq_ignore_ascii_case("true"))
+ || element.attr("style").is_some_and(|style| {
+ let compact = style
+ .chars()
+ .filter(|character| !character.is_ascii_whitespace())
+ .flat_map(char::to_lowercase)
+ .collect::();
+ compact
+ .split(';')
+ .any(|declaration| matches!(declaration, "display:none" | "visibility:hidden"))
+ })
+}
+
+fn parse_dom_url(value: &str, base_url: Option<&Url>) -> Option {
+ if let Ok(url) = Url::parse(value) {
+ return Some(url);
+ }
+ if let Some(base_url) = base_url
+ && let Ok(url) = base_url.join(value)
+ {
+ return Some(url);
+ }
+ Url::parse(RELATIVE_URL_BASE).ok()?.join(value).ok()
+}
+
+fn same_origin_or_unknown(url: &Url, base_url: Option<&Url>) -> bool {
+ base_url.is_none_or(|base_url| url.origin() == base_url.origin())
+}
+
+fn host_is_or_subdomain(url: &Url, domain: &str) -> bool {
+ url.host_str().is_some_and(|host| {
+ host.eq_ignore_ascii_case(domain)
+ || host
+ .strip_suffix(domain)
+ .is_some_and(|prefix| prefix.ends_with('.'))
+ })
+}
+
+fn header_value_is(actual: Option<&str>, expected: &str) -> bool {
+ actual.is_some_and(|value| value.trim().eq_ignore_ascii_case(expected))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn detected_provider(html: &str, url: &str) -> Option {
+ let url = Url::parse(url).unwrap();
+ detect_document(html, Some(&url), None, true).map(|signal| signal.provider)
+ }
+
+ #[test]
+ fn detects_a_structural_wechat_verification_gate() {
+ let html = r#"
+
+
+ verify
+ "#;
+
+ assert_eq!(
+ detected_provider(
+ html,
+ "https://mp.weixin.qq.com/mp/wappoc_appmsgcaptcha?token=secret"
+ ),
+ Some(VerificationProvider::WeChat)
+ );
+ }
+
+ #[test]
+ fn wechat_markers_need_the_provider_origin_and_no_article() {
+ let copied_gate = r#"
+
+ "#;
+ let article = r#"
+
+ "#;
+
+ assert_eq!(
+ detected_provider(copied_gate, "https://example.test/copied"),
+ None
+ );
+ assert_eq!(
+ detected_provider(article, "https://mp.weixin.qq.com/s/article"),
+ None
+ );
+ }
+
+ #[test]
+ fn detects_cloudflare_from_a_challenge_form_and_runtime_evidence() {
+ let html = r#"Unrelated localized title
+
+
+ "#;
+
+ assert_eq!(
+ detected_provider(html, "https://www.npmjs.com/package/opsail"),
+ Some(VerificationProvider::Cloudflare)
+ );
+ }
+
+ #[test]
+ fn detects_cloudflare_from_a_provider_resource_without_copy_matching_text() {
+ let html = r#"
+
+
+ "#;
+
+ assert_eq!(
+ detected_provider(html, "https://protected.example.test/path"),
+ Some(VerificationProvider::Cloudflare)
+ );
+ }
+
+ #[test]
+ fn cloudflare_detection_requires_all_structural_evidence() {
+ let form_only = r#"
+
+ "#;
+ let runtime_only = r#"
+
+ "#;
+ let article_with_every_marker = r#"
+ Cloudflare challenge integration notes
+
+
+ "#;
+
+ for html in [form_only, runtime_only, article_with_every_marker] {
+ assert_eq!(detected_provider(html, "https://example.test/guide"), None);
+ }
+ }
+
+ #[test]
+ fn cloudflare_ignores_inert_example_data_and_substantive_main_content() {
+ let integration_guide = r#"
+ Perimeter integration guide
+ This page documents how a challenge form and its runtime configuration work.
+
+
+ "#;
+
+ assert_eq!(
+ detected_provider(integration_guide, "https://example.test/integration-docs"),
+ None
+ );
+ }
+
+ #[test]
+ fn hidden_or_empty_article_shell_does_not_mask_a_cloudflare_gate() {
+ let html = r#"
+
+
+
+ "#;
+
+ assert_eq!(
+ detected_provider(html, "https://protected.example.test/path"),
+ Some(VerificationProvider::Cloudflare)
+ );
+ }
+
+ #[test]
+ fn detects_google_unusual_traffic_from_origin_route_form_and_recaptcha() {
+ let html = r#"
+
+
+ "#;
+
+ assert_eq!(
+ detected_provider(
+ html,
+ "https://www.google.com/sorry/index?continue=https%3A%2F%2Fgoogle.com"
+ ),
+ Some(VerificationProvider::Google)
+ );
+ }
+
+ #[test]
+ fn google_detection_rejects_lookalike_origins_and_non_sorry_routes() {
+ let html = r#"
+
+
+ "#;
+
+ assert_eq!(
+ detected_provider(html, "https://google.com.evil.example/sorry/index"),
+ None
+ );
+ assert_eq!(
+ detected_provider(html, "https://www.google.com/account/security"),
+ None
+ );
+ }
+
+ #[test]
+ fn detects_a_top_level_datadome_captcha_delivery_gate() {
+ let html = r#"
+
+
+
+ "#;
+
+ assert_eq!(
+ detected_provider(
+ html,
+ "https://geo.captcha-delivery.com/captcha/?initialCid=secret"
+ ),
+ Some(VerificationProvider::DataDome)
+ );
+ }
+
+ #[test]
+ fn datadome_widgets_are_not_top_level_gate_evidence() {
+ let embedded = r#"Shop
+
+
+
+ "#;
+ let provider_article = r#"
+ DataDome documentation
+
+ "#;
+
+ assert_eq!(
+ detected_provider(embedded, "https://shop.example.test/product"),
+ None
+ );
+ assert_eq!(
+ detected_provider(
+ provider_article,
+ "https://geo.captcha-delivery.com/captcha/docs"
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn human_markup_without_rendered_takeover_evidence_is_not_classified() {
+ let root_only = r#"
"#;
+ let root_and_runtime = r#"
+
+
+ "#;
+ let normal_spa = r#"
+ Account dashboard The requested account content is available.
+
+
+ "#;
+ let inert_configuration = r#"
+ Integration guide This page contains inert configuration examples.
+
+
+ "#;
+
+ for html in [root_only, root_and_runtime, normal_spa, inert_configuration] {
+ assert_eq!(
+ detected_provider(html, "https://example.test/article"),
+ None
+ );
+ }
+ }
+
+ #[test]
+ fn embedded_captcha_widgets_are_not_verification_pages() {
+ let html = r#"
+
+
+
+
+ "#;
+
+ assert_eq!(
+ detected_provider(html, "https://example.test/contact"),
+ None
+ );
+ }
+
+ #[test]
+ fn redacts_query_fragment_and_credentials_from_reported_urls() {
+ assert_eq!(
+ redacted_url("https://reader:secret@example.test/path?token=secret#fragment"),
+ "https://example.test/path"
+ );
+ assert_eq!(redacted_url(""), "");
+ }
+
+ #[test]
+ fn detects_only_provider_declared_main_response_challenges() {
+ assert_eq!(
+ detect_response(403, Some(" challenge "), None).map(|signal| signal.provider),
+ Some(VerificationProvider::Cloudflare)
+ );
+ assert_eq!(
+ detect_response(202, None, Some("Challenge")).map(|signal| signal.provider),
+ Some(VerificationProvider::AwsWaf)
+ );
+ assert_eq!(
+ detect_response(405, None, Some("CAPTCHA")).map(|signal| signal.provider),
+ Some(VerificationProvider::AwsWaf)
+ );
+
+ for (status, cf_mitigated, aws_waf_action) in [
+ (403, None, None),
+ (403, Some("managed"), None),
+ (403, None, Some("challenge")),
+ (202, None, Some("captcha")),
+ (405, None, Some("challenge")),
+ ] {
+ assert!(detect_response(status, cf_mitigated, aws_waf_action).is_none());
+ }
+ }
+}
diff --git a/crates/opsail-read/tests/acquisition.rs b/crates/opsail-read/tests/acquisition.rs
index ce660e2..9a4486f 100644
--- a/crates/opsail-read/tests/acquisition.rs
+++ b/crates/opsail-read/tests/acquisition.rs
@@ -3,7 +3,7 @@ use std::time::Duration;
use opsail_read::{Input, ReadError, ReadOptions, SourceKind, read};
use tempfile::tempdir;
use url::Url;
-use wiremock::matchers::{method, path};
+use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const HTML: &str = "Local note A readable local note for acquisition tests.
";
@@ -164,6 +164,31 @@ async fn follows_redirects_and_decodes_declared_charsets() {
assert!(result.content.contains("Café report"));
}
+#[tokio::test]
+async fn sends_the_configured_user_agent() {
+ let server = MockServer::start().await;
+ let user_agent = "opsail-integration-test/1.0";
+ Mock::given(method("GET"))
+ .and(path("/article"))
+ .and(header("user-agent", user_agent))
+ .respond_with(
+ ResponseTemplate::new(200)
+ .insert_header("content-type", "text/html")
+ .set_body_string(HTML),
+ )
+ .mount(&server)
+ .await;
+ let options = ReadOptions {
+ user_agent: Some(user_agent.to_owned()),
+ ..ReadOptions::default()
+ };
+
+ let url = Url::parse(&format!("{}/article", server.uri())).unwrap();
+ let result = read(Input::Url(url), &options).await.unwrap();
+
+ assert!(result.content.contains("A readable local note"));
+}
+
#[tokio::test]
async fn rejects_non_html_content_types_before_extraction() {
let server = MockServer::start().await;
@@ -282,3 +307,191 @@ async fn rejects_redirect_targets_with_embedded_credentials_without_leaking_them
assert!(!diagnostic.contains("reader"));
assert!(!diagnostic.contains("redirect-secret"));
}
+
+#[tokio::test]
+async fn maps_a_cloudflare_challenge_response_to_verification_required() {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/cloudflare-challenge"))
+ .respond_with(
+ ResponseTemplate::new(403)
+ .insert_header("cf-mitigated", "challenge")
+ .insert_header("content-type", "text/html")
+ .set_body_string("Cloudflare challenge"),
+ )
+ .mount(&server)
+ .await;
+
+ let requested = Url::parse(&format!(
+ "{}/cloudflare-challenge?token=request-secret#verification",
+ server.uri()
+ ))
+ .unwrap();
+ let expected_url = format!("{}/cloudflare-challenge", server.uri());
+
+ let error = read(Input::Url(requested), &ReadOptions::default())
+ .await
+ .unwrap_err();
+ let diagnostic = format!("{error:?}");
+
+ assert!(matches!(
+ &error,
+ ReadError::VerificationRequired { url } if url == &expected_url
+ ));
+ assert!(!diagnostic.contains("request-secret"));
+ assert!(!diagnostic.contains("#verification"));
+}
+
+#[tokio::test]
+async fn maps_an_aws_waf_challenge_response_to_verification_required() {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/aws-waf-challenge"))
+ .respond_with(
+ ResponseTemplate::new(202)
+ .insert_header("x-amzn-waf-action", "challenge")
+ .insert_header("content-type", "text/html")
+ .set_body_string(HTML),
+ )
+ .mount(&server)
+ .await;
+
+ let requested = Url::parse(&format!("{}/aws-waf-challenge", server.uri())).unwrap();
+ let error = read(Input::Url(requested), &ReadOptions::default())
+ .await
+ .unwrap_err();
+
+ assert!(matches!(error, ReadError::VerificationRequired { .. }));
+}
+
+#[tokio::test]
+async fn maps_an_aws_waf_captcha_response_to_verification_required() {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/aws-waf-captcha"))
+ .respond_with(
+ ResponseTemplate::new(405)
+ .insert_header("x-amzn-waf-action", "captcha")
+ .insert_header("content-type", "text/html")
+ .set_body_string("AWS WAF CAPTCHA"),
+ )
+ .mount(&server)
+ .await;
+
+ let requested = Url::parse(&format!("{}/aws-waf-captcha", server.uri())).unwrap();
+ let error = read(Input::Url(requested), &ReadOptions::default())
+ .await
+ .unwrap_err();
+
+ assert!(matches!(error, ReadError::VerificationRequired { .. }));
+}
+
+#[tokio::test]
+async fn does_not_treat_an_aws_waf_header_with_the_wrong_status_as_verification() {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/aws-waf-mismatched-status"))
+ .respond_with(
+ ResponseTemplate::new(403)
+ .insert_header("x-amzn-waf-action", "challenge")
+ .insert_header("content-type", "text/html")
+ .set_body_string("Forbidden"),
+ )
+ .mount(&server)
+ .await;
+
+ let requested = Url::parse(&format!("{}/aws-waf-mismatched-status", server.uri())).unwrap();
+ let expected_url = requested.to_string();
+ let error = read(Input::Url(requested), &ReadOptions::default())
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ ReadError::HttpStatus { url, status } if url == expected_url && status == 403
+ ));
+}
+
+#[tokio::test]
+async fn keeps_an_ordinary_forbidden_response_as_an_http_status_error() {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/forbidden"))
+ .respond_with(
+ ResponseTemplate::new(403)
+ .insert_header("content-type", "text/html")
+ .set_body_string("Forbidden"),
+ )
+ .mount(&server)
+ .await;
+
+ let requested = Url::parse(&format!("{}/forbidden", server.uri())).unwrap();
+ let expected_url = requested.to_string();
+ let error = read(Input::Url(requested), &ReadOptions::default())
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ ReadError::HttpStatus { url, status } if url == expected_url && status == 403
+ ));
+}
+
+#[tokio::test]
+async fn inspects_a_bounded_html_error_body_for_structural_verification() {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/structural-challenge"))
+ .respond_with(
+ ResponseTemplate::new(403)
+ .insert_header("content-type", "text/html")
+ .set_body_string(
+ r#"
+
+
+ "#,
+ ),
+ )
+ .mount(&server)
+ .await;
+
+ let requested = Url::parse(&format!("{}/structural-challenge", server.uri())).unwrap();
+ let error = read(Input::Url(requested), &ReadOptions::default())
+ .await
+ .unwrap_err();
+
+ assert!(matches!(error, ReadError::VerificationRequired { .. }));
+}
+
+#[tokio::test]
+async fn embedded_captcha_on_an_ordinary_error_page_remains_an_http_status() {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/login"))
+ .respond_with(
+ ResponseTemplate::new(403)
+ .insert_header("content-type", "text/html")
+ .set_body_string(
+ r#"
+ Sign in Use the form below to access your account.
+
+
+ "#,
+ ),
+ )
+ .mount(&server)
+ .await;
+
+ let requested = Url::parse(&format!("{}/login", server.uri())).unwrap();
+ let expected_url = requested.to_string();
+ let error = read(Input::Url(requested), &ReadOptions::default())
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ ReadError::HttpStatus { url, status } if url == expected_url && status == 403
+ ));
+}
diff --git a/crates/opsail-read/tests/cdp.rs b/crates/opsail-read/tests/cdp.rs
new file mode 100644
index 0000000..37df2d5
--- /dev/null
+++ b/crates/opsail-read/tests/cdp.rs
@@ -0,0 +1,1199 @@
+use std::future::Future;
+
+use futures_util::{SinkExt, StreamExt};
+use opsail_read::{
+ CdpSource, CdpWaitUntil, ChromeError, ReadError, ReadOptions, ReadSource, SourceKind, read,
+};
+use serde_json::{Value, json};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::{TcpListener, TcpStream};
+use tokio::task::JoinHandle;
+use tokio_tungstenite::accept_async;
+use tokio_tungstenite::tungstenite::Message;
+use url::Url;
+
+fn article_html(title: &str) -> String {
+ let words = (0..140)
+ .map(|index| format!("browser{index}"))
+ .collect::>()
+ .join(" ");
+ format!(
+ "{title} {words}
"
+ )
+}
+
+async fn websocket_server(handler: F) -> (String, JoinHandle>)
+where
+ F: FnOnce(TcpStream) -> Fut + Send + 'static,
+ Fut: Future> + Send + 'static,
+{
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let task = tokio::spawn(async move {
+ let (stream, _) = listener.accept().await.map_err(|error| error.to_string())?;
+ handler(stream).await
+ });
+ (format!("ws://{address}"), task)
+}
+
+async fn next_command(
+ socket: &mut tokio_tungstenite::WebSocketStream,
+) -> Result {
+ loop {
+ let message = socket
+ .next()
+ .await
+ .ok_or_else(|| "CDP client disconnected".to_owned())?
+ .map_err(|error| error.to_string())?;
+ match message {
+ Message::Text(text) => {
+ return serde_json::from_str(text.as_ref()).map_err(|error| error.to_string());
+ }
+ Message::Binary(bytes) => {
+ return serde_json::from_slice(bytes.as_ref()).map_err(|error| error.to_string());
+ }
+ Message::Close(_) => return Err("CDP client closed the connection".to_owned()),
+ Message::Ping(payload) => socket
+ .send(Message::Pong(payload))
+ .await
+ .map_err(|error| error.to_string())?,
+ Message::Pong(_) | Message::Frame(_) => {}
+ }
+ }
+}
+
+async fn respond(
+ socket: &mut tokio_tungstenite::WebSocketStream,
+ command: &Value,
+ result: Value,
+) -> Result<(), String> {
+ socket
+ .send(Message::Text(
+ json!({ "id": command["id"], "result": result })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .map_err(|error| error.to_string())
+}
+
+fn frame_tree(frame_id: &str, loader_id: &str, url: &str) -> Value {
+ json!({
+ "frameTree": {
+ "frame": {
+ "id": frame_id,
+ "loaderId": loader_id,
+ "url": url
+ }
+ }
+ })
+}
+
+fn rendered_capture(html: &str, final_url: &str, results: Value) -> Value {
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": html,
+ "finalUrl": final_url,
+ "renderedEvidence": {
+ "timedOut": false,
+ "results": results
+ }
+ }
+ }
+ })
+}
+
+async fn reject_command(
+ socket: &mut tokio_tungstenite::WebSocketStream,
+ command: &Value,
+) -> Result<(), String> {
+ socket
+ .send(Message::Text(
+ json!({
+ "id": command["id"],
+ "error": { "code": -32601, "message": "unsupported in this fixture" }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .map_err(|error| error.to_string())
+}
+
+#[tokio::test]
+async fn navigates_and_captures_through_a_browser_cdp_endpoint() {
+ let html = article_html("Rendered through CDP");
+ let final_url = "https://example.test/rendered?final=1";
+ let expected_html = html.clone();
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let mut saw_profile = false;
+ let mut saw_navigation = false;
+ let mut saw_close = false;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ let method = command["method"].as_str().unwrap_or_default();
+ match method {
+ "Browser.getVersion" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({ "userAgent": "MockChrome/1.0" }),
+ )
+ .await?;
+ }
+ "Target.createTarget" => {
+ assert_eq!(command["params"]["url"], "about:blank");
+ assert_eq!(command["params"]["background"], true);
+ respond(&mut socket, &command, json!({ "targetId": "target-1" })).await?;
+ }
+ "Target.attachToTarget" => {
+ assert_eq!(command["params"]["targetId"], "target-1");
+ assert_eq!(command["params"]["flatten"], true);
+ respond(&mut socket, &command, json!({ "sessionId": "session-1" })).await?;
+ }
+ "Page.enable"
+ | "Runtime.enable"
+ | "Runtime.runIfWaitingForDebugger"
+ | "Network.enable" => {
+ assert_eq!(command["sessionId"], "session-1");
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.setLifecycleEventsEnabled" => {
+ assert_eq!(command["sessionId"], "session-1");
+ assert_eq!(command["params"]["enabled"], true);
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Emulation.setUserAgentOverride" => {
+ assert_eq!(command["params"]["userAgent"], "opsail-test/1");
+ assert_eq!(command["params"]["acceptLanguage"], "en-US");
+ saw_profile = true;
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.navigate" => {
+ assert_eq!(command["params"]["url"], "https://example.test/requested");
+ saw_navigation = true;
+ socket
+ .send(Message::Text(
+ json!({
+ "method": "Page.lifecycleEvent",
+ "sessionId": "session-1",
+ "params": {
+ "name": "load",
+ "loaderId": "previous-loader",
+ "timestamp": 1
+ }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .map_err(|error| error.to_string())?;
+ socket
+ .send(Message::Text(
+ json!({
+ "method": "Page.lifecycleEvent",
+ "sessionId": "session-1",
+ "params": {
+ "name": "load",
+ "loaderId": "loader-1",
+ "timestamp": 2
+ }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .map_err(|error| error.to_string())?;
+ respond(&mut socket, &command, json!({ "loaderId": "loader-1" })).await?;
+ }
+ "Page.getFrameTree" => {
+ respond(
+ &mut socket,
+ &command,
+ frame_tree("main-frame", "loader-1", final_url),
+ )
+ .await?;
+ }
+ "Page.createIsolatedWorld" => {
+ assert_eq!(command["params"]["frameId"], "main-frame");
+ assert_eq!(command["params"]["grantUniveralAccess"], false);
+ respond(&mut socket, &command, json!({ "executionContextId": 17 })).await?;
+ }
+ "Emulation.setFocusEmulationEnabled" => {
+ assert_eq!(command["params"]["enabled"], true);
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.callFunctionOn" => {
+ assert_eq!(command["params"]["executionContextId"], 17);
+ assert_eq!(
+ command["params"]["arguments"][0]["value"]
+ .as_array()
+ .map(Vec::len),
+ Some(4)
+ );
+ respond(
+ &mut socket,
+ &command,
+ rendered_capture(&expected_html, final_url, json!([])),
+ )
+ .await?;
+ }
+ "Runtime.evaluate" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": { "html": expected_html, "finalUrl": final_url }
+ }
+ }),
+ )
+ .await?;
+ }
+ "Target.closeTarget" => {
+ assert_eq!(command["params"]["targetId"], "target-1");
+ saw_close = true;
+ respond(&mut socket, &command, json!({ "success": true })).await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+
+ assert!(saw_profile);
+ assert!(saw_navigation);
+ assert!(saw_close);
+ Ok(())
+ })
+ .await;
+
+ let mut source = CdpSource::new(format!("{base_endpoint}/devtools/browser/mock"));
+ source.url = Some(Url::parse("https://example.test/requested").unwrap());
+ source.wait_until = CdpWaitUntil::Load;
+ let options = ReadOptions {
+ user_agent: Some("opsail-test/1".to_owned()),
+ accept_language: Some("en-US".to_owned()),
+ ..ReadOptions::default()
+ };
+
+ let result = read(ReadSource::Cdp(source), &options).await.unwrap();
+ assert_eq!(result.source.kind, SourceKind::Cdp);
+ assert_eq!(result.source.requested, "https://example.test/requested");
+ assert_eq!(
+ result.source.resolved_url.as_ref().map(Url::as_str),
+ Some(final_url)
+ );
+ assert_eq!(result.metadata.title, "Rendered through CDP");
+ assert!(result.content.contains("browser139"));
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn maps_a_cdp_main_document_challenge_header_to_verification_required() {
+ let html = article_html("Response header must win over article-like markup");
+ let expected_html = html.clone();
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ assert!(command.get("sessionId").is_none());
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" | "Network.enable" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.navigate" => {
+ socket
+ .send(Message::Text(
+ json!({
+ "method": "Network.responseReceived",
+ "params": {
+ "loaderId": "challenge-loader",
+ "frameId": "main-frame",
+ "type": "Document",
+ "response": {
+ "status": 403,
+ "url": "https://example.test/requested?token=request-secret",
+ "headers": {
+ "cf-mitigated": "challenge",
+ "set-cookie": "private=must-not-be-retained"
+ }
+ }
+ }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .map_err(|error| error.to_string())?;
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "loaderId": "challenge-loader",
+ "frameId": "main-frame"
+ }),
+ )
+ .await?;
+ }
+ "Runtime.evaluate" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": expected_html,
+ "finalUrl": "https://example.test/requested?token=request-secret"
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ "Page.createIsolatedWorld" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({ "executionContextId": 21 }),
+ )
+ .await?;
+ }
+ "Emulation.setFocusEmulationEnabled" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.callFunctionOn" => {
+ respond(
+ &mut socket,
+ &command,
+ rendered_capture(
+ &expected_html,
+ "https://example.test/requested?token=request-secret",
+ json!([]),
+ ),
+ )
+ .await?;
+ }
+ "Page.getFrameTree" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "frameTree": {
+ "frame": {
+ "id": "main-frame",
+ "loaderId": "challenge-loader",
+ "url": "https://example.test/requested?token=request-secret"
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+ Ok(())
+ })
+ .await;
+
+ let mut source = CdpSource::new(format!("{base_endpoint}/devtools/page/current"));
+ source.url =
+ Some(Url::parse("https://example.test/requested?token=request-secret#fragment").unwrap());
+ source.wait_until = CdpWaitUntil::None;
+
+ let error = read(ReadSource::Cdp(source), &ReadOptions::default())
+ .await
+ .unwrap_err();
+ let diagnostic = format!("{error:?}");
+ assert!(matches!(
+ error,
+ ReadError::VerificationRequired { url }
+ if url == "https://example.test/requested"
+ ));
+ assert!(!diagnostic.contains("request-secret"));
+ assert!(!diagnostic.contains("must-not-be-retained"));
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn requires_live_rendered_evidence_for_a_browser_dom_fallback() {
+ let html = r#"
+
+
+ "#;
+ let expected_html = html.to_owned();
+ let final_url = "https://protected.example.test/article?token=request-secret";
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ while let Ok(command) = next_command(&mut socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" | "Network.enable" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.navigate" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({ "frameId": "main-frame", "loaderId": "gate-loader" }),
+ )
+ .await?;
+ }
+ "Page.getFrameTree" => {
+ respond(
+ &mut socket,
+ &command,
+ frame_tree("main-frame", "gate-loader", final_url),
+ )
+ .await?;
+ }
+ "Page.createIsolatedWorld" => {
+ respond(&mut socket, &command, json!({ "executionContextId": 31 })).await?;
+ }
+ "Emulation.setFocusEmulationEnabled" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.callFunctionOn" => {
+ let declaration = command["params"]["functionDeclaration"]
+ .as_str()
+ .unwrap_or_default();
+ assert!(!declaration.contains("challenge-form"));
+ assert!(
+ command["params"]["arguments"][0]["value"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .any(|probe| {
+ probe["selector"].as_str().is_some_and(|selector| {
+ selector.starts_with("form#challenge-form[action]")
+ })
+ })
+ );
+ respond(
+ &mut socket,
+ &command,
+ rendered_capture(
+ &expected_html,
+ final_url,
+ json!([{
+ "id": 2,
+ "matches": 1,
+ "marker": {
+ "visible": true,
+ "stable": true,
+ "viewportCoverage": 160,
+ "hitCoverage": 120
+ },
+ "takeover": {
+ "visible": true,
+ "stable": true,
+ "viewportCoverage": 920,
+ "hitCoverage": 840
+ }
+ }]),
+ ),
+ )
+ .await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+ Ok(())
+ })
+ .await;
+
+ let mut source = CdpSource::new(format!("{base_endpoint}/devtools/page/current"));
+ source.url = Some(Url::parse(final_url).unwrap());
+ source.wait_until = CdpWaitUntil::None;
+
+ let error = read(ReadSource::Cdp(source), &ReadOptions::default())
+ .await
+ .unwrap_err();
+ assert!(matches!(
+ error,
+ ReadError::VerificationRequired { url }
+ if url == "https://protected.example.test/article"
+ ));
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn hidden_browser_markers_do_not_become_verification_errors() {
+ let prose = (0..80)
+ .map(|index| format!("content{index}"))
+ .collect::>()
+ .join(" ");
+ let html = format!(
+ r#"Normal page
+
+
+
+ "#
+ );
+ let expected_html = html.clone();
+ let final_url = "https://example.test/normal";
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ while let Ok(command) = next_command(&mut socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" | "Network.enable" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.navigate" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({ "frameId": "main-frame", "loaderId": "normal-loader" }),
+ )
+ .await?;
+ }
+ "Page.getFrameTree" => {
+ respond(
+ &mut socket,
+ &command,
+ frame_tree("main-frame", "normal-loader", final_url),
+ )
+ .await?;
+ }
+ "Page.createIsolatedWorld" => {
+ respond(&mut socket, &command, json!({ "executionContextId": 32 })).await?;
+ }
+ "Emulation.setFocusEmulationEnabled" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.callFunctionOn" => {
+ respond(
+ &mut socket,
+ &command,
+ rendered_capture(
+ &expected_html,
+ final_url,
+ json!([{
+ "id": 2,
+ "matches": 1,
+ "marker": {
+ "visible": false,
+ "stable": true,
+ "viewportCoverage": 0,
+ "hitCoverage": 0
+ },
+ "takeover": null
+ }]),
+ ),
+ )
+ .await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+ Ok(())
+ })
+ .await;
+
+ let mut source = CdpSource::new(format!("{base_endpoint}/devtools/page/current"));
+ source.url = Some(Url::parse(final_url).unwrap());
+ source.wait_until = CdpWaitUntil::None;
+
+ let result = read(ReadSource::Cdp(source), &ReadOptions::default())
+ .await
+ .unwrap();
+ assert_eq!(result.metadata.title, "Normal page");
+ assert!(result.content.contains("content79"));
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn discards_rendered_gate_evidence_from_a_superseded_document() {
+ let gate_html = r#"
+
+
+ "#;
+ let clean_html = article_html("Client navigation finished");
+ let expected_gate = gate_html.to_owned();
+ let expected_clean = clean_html.clone();
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let mut frame_calls = 0;
+ let mut probe_calls = 0;
+ while let Ok(command) = next_command(&mut socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" | "Network.enable" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.navigate" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({ "frameId": "main-frame", "loaderId": "gate-loader" }),
+ )
+ .await?;
+ }
+ "Page.getFrameTree" => {
+ frame_calls += 1;
+ let (loader, url) = if frame_calls == 1 {
+ ("gate-loader", "https://example.test/gate")
+ } else {
+ ("article-loader", "https://example.test/final")
+ };
+ respond(&mut socket, &command, frame_tree("main-frame", loader, url)).await?;
+ }
+ "Page.createIsolatedWorld" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({ "executionContextId": 40 + probe_calls }),
+ )
+ .await?;
+ }
+ "Emulation.setFocusEmulationEnabled" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.callFunctionOn" => {
+ probe_calls += 1;
+ let capture = if probe_calls == 1 {
+ rendered_capture(
+ &expected_gate,
+ "https://example.test/gate",
+ json!([{
+ "id": 2,
+ "matches": 1,
+ "marker": {
+ "visible": true,
+ "stable": true,
+ "viewportCoverage": 200,
+ "hitCoverage": 160
+ },
+ "takeover": {
+ "visible": true,
+ "stable": true,
+ "viewportCoverage": 900,
+ "hitCoverage": 800
+ }
+ }]),
+ )
+ } else {
+ rendered_capture(&expected_clean, "https://example.test/final", json!([]))
+ };
+ respond(&mut socket, &command, capture).await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+ assert_eq!(probe_calls, 2);
+ Ok(())
+ })
+ .await;
+
+ let mut source = CdpSource::new(format!("{base_endpoint}/devtools/page/current"));
+ source.url = Some(Url::parse("https://example.test/start").unwrap());
+ source.wait_until = CdpWaitUntil::None;
+
+ let result = read(ReadSource::Cdp(source), &ReadOptions::default())
+ .await
+ .unwrap();
+ assert_eq!(result.metadata.title, "Client navigation finished");
+ assert_eq!(
+ result.source.resolved_url.as_ref().map(Url::as_str),
+ Some("https://example.test/final")
+ );
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn binds_cdp_response_evidence_to_the_captured_final_main_document() {
+ let html = article_html("Clean final document");
+ let expected_html = html.clone();
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ assert!(command.get("sessionId").is_none());
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" | "Network.enable" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.navigate" => {
+ for event in [
+ json!({
+ "method": "Network.responseReceived",
+ "params": {
+ "loaderId": "initial-loader",
+ "frameId": "main-frame",
+ "type": "Document",
+ "response": {
+ "status": 403,
+ "url": "https://example.test/start",
+ "headers": { "cf-mitigated": "challenge" }
+ }
+ }
+ }),
+ json!({
+ "method": "Network.responseReceived",
+ "params": {
+ "loaderId": "iframe-loader",
+ "frameId": "child-frame",
+ "type": "Document",
+ "response": {
+ "status": 403,
+ "url": "https://example.test/final",
+ "headers": { "cf-mitigated": "challenge" }
+ }
+ }
+ }),
+ json!({
+ "method": "Network.responseReceived",
+ "params": {
+ "loaderId": "final-loader",
+ "frameId": "main-frame",
+ "type": "Document",
+ "response": {
+ "status": 200,
+ "url": "https://example.test/final",
+ "headers": {}
+ }
+ }
+ }),
+ ] {
+ socket
+ .send(Message::Text(event.to_string().into()))
+ .await
+ .map_err(|error| error.to_string())?;
+ }
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "loaderId": "initial-loader",
+ "frameId": "main-frame"
+ }),
+ )
+ .await?;
+ }
+ "Runtime.evaluate" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": expected_html,
+ "finalUrl": "https://example.test/final"
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ "Page.createIsolatedWorld" => {
+ respond(&mut socket, &command, json!({ "executionContextId": 22 })).await?;
+ }
+ "Emulation.setFocusEmulationEnabled" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.callFunctionOn" => {
+ respond(
+ &mut socket,
+ &command,
+ rendered_capture(&expected_html, "https://example.test/final", json!([])),
+ )
+ .await?;
+ }
+ "Page.getFrameTree" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "frameTree": {
+ "frame": {
+ "id": "main-frame",
+ "loaderId": "final-loader",
+ "url": "https://example.test/final"
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+ Ok(())
+ })
+ .await;
+
+ let mut source = CdpSource::new(format!("{base_endpoint}/devtools/page/current"));
+ source.url = Some(Url::parse("https://example.test/start").unwrap());
+ source.wait_until = CdpWaitUntil::None;
+
+ let result = read(ReadSource::Cdp(source), &ReadOptions::default())
+ .await
+ .unwrap();
+ assert_eq!(result.metadata.title, "Clean final document");
+ assert_eq!(
+ result.source.resolved_url.as_ref().map(Url::as_str),
+ Some("https://example.test/final")
+ );
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn captures_a_direct_page_and_falls_back_to_the_dom_domain() {
+ let html = article_html("DOM fallback");
+ let expected_html = html.clone();
+ let (base_endpoint, server) = websocket_server(move |stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ while let Ok(command) = next_command(&mut socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" => {
+ assert!(command.get("sessionId").is_none());
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.getFrameTree" => {
+ respond(
+ &mut socket,
+ &command,
+ frame_tree(
+ "existing-frame",
+ "existing-loader",
+ "https://example.test/existing",
+ ),
+ )
+ .await?;
+ }
+ "Page.createIsolatedWorld" => reject_command(&mut socket, &command).await?,
+ "Runtime.evaluate" => {
+ socket
+ .send(Message::Text(
+ json!({
+ "id": command["id"],
+ "error": { "code": -32601, "message": "Runtime unavailable" }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .map_err(|error| error.to_string())?;
+ }
+ "DOM.getDocument" => {
+ respond(&mut socket, &command, json!({ "root": { "nodeId": 42 } })).await?;
+ }
+ "DOM.getOuterHTML" => {
+ assert_eq!(command["params"]["nodeId"], 42);
+ respond(&mut socket, &command, json!({ "outerHTML": expected_html })).await?;
+ }
+ "Page.getNavigationHistory" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "currentIndex": 0,
+ "entries": [{ "url": "https://example.test/existing" }]
+ }),
+ )
+ .await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+ Ok(())
+ })
+ .await;
+
+ let source = CdpSource::new(format!("{base_endpoint}/devtools/page/current"));
+ let result = read(ReadSource::Cdp(source), &ReadOptions::default())
+ .await
+ .unwrap();
+
+ assert_eq!(result.source.kind, SourceKind::Cdp);
+ assert_eq!(result.metadata.title, "DOM fallback");
+ assert_eq!(
+ result.source.resolved_url.as_ref().map(Url::as_str),
+ Some("https://example.test/existing")
+ );
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn discovers_a_chrome_page_endpoint_without_publishing_endpoint_secrets() {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let html = article_html("Discovered Chrome page");
+ let expected_html = html.clone();
+ let server = tokio::spawn(async move {
+ let (mut discovery, _) = listener.accept().await.map_err(|error| error.to_string())?;
+ let mut request = vec![0; 4096];
+ let read = discovery
+ .read(&mut request)
+ .await
+ .map_err(|error| error.to_string())?;
+ let request = String::from_utf8_lossy(&request[..read]);
+ assert!(request.starts_with("GET /json/version?token=endpoint-secret HTTP/1.1"));
+ let body = r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1/devtools/page/discovered"}"#;
+ discovery
+ .write_all(
+ format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ body.len()
+ )
+ .as_bytes(),
+ )
+ .await
+ .map_err(|error| error.to_string())?;
+ discovery
+ .shutdown()
+ .await
+ .map_err(|error| error.to_string())?;
+
+ let (stream, _) = listener.accept().await.map_err(|error| error.to_string())?;
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ while let Ok(command) = next_command(&mut socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Page.enable" | "Runtime.enable" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Page.getFrameTree" => {
+ respond(
+ &mut socket,
+ &command,
+ frame_tree(
+ "discovered-frame",
+ "discovered-loader",
+ "https://example.test/discovered",
+ ),
+ )
+ .await?;
+ }
+ "Page.createIsolatedWorld" => {
+ respond(&mut socket, &command, json!({ "executionContextId": 23 })).await?;
+ }
+ "Emulation.setFocusEmulationEnabled" => {
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Runtime.callFunctionOn" => {
+ respond(
+ &mut socket,
+ &command,
+ rendered_capture(
+ &expected_html,
+ "https://example.test/discovered",
+ json!([]),
+ ),
+ )
+ .await?;
+ }
+ "Runtime.evaluate" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "result": {
+ "type": "object",
+ "value": {
+ "html": expected_html,
+ "finalUrl": "https://example.test/discovered"
+ }
+ }
+ }),
+ )
+ .await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+ Ok::<(), String>(())
+ });
+
+ let source = CdpSource::new(format!("http://{address}?token=endpoint-secret"));
+ let result = read(ReadSource::Cdp(source), &ReadOptions::default())
+ .await
+ .unwrap();
+
+ assert_eq!(result.metadata.title, "Discovered Chrome page");
+ assert!(
+ !serde_json::to_string(&result)
+ .unwrap()
+ .contains("endpoint-secret")
+ );
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn discovery_never_uses_an_arbitrary_page_from_json_list() {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let server = tokio::spawn(async move {
+ for (expected_path, status, body) in [
+ ("/json/version", "404 Not Found", ""),
+ (
+ "/json/list",
+ "200 OK",
+ r#"[{"id":"page-1","type":"page","webSocketDebuggerUrl":"ws://127.0.0.1:1/devtools/page/page-1"},{"id":"page-2","type":"page","webSocketDebuggerUrl":"ws://127.0.0.1:1/devtools/page/page-2"}]"#,
+ ),
+ ] {
+ let (mut stream, _) = listener.accept().await.map_err(|error| error.to_string())?;
+ let mut request = vec![0; 4096];
+ let read = stream
+ .read(&mut request)
+ .await
+ .map_err(|error| error.to_string())?;
+ let request = String::from_utf8_lossy(&request[..read]);
+ assert!(request.starts_with(&format!("GET {expected_path} HTTP/1.1")));
+ stream
+ .write_all(
+ format!(
+ "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ body.len()
+ )
+ .as_bytes(),
+ )
+ .await
+ .map_err(|error| error.to_string())?;
+ }
+ Ok::<(), String>(())
+ });
+
+ let source = CdpSource::new(format!("http://{address}"));
+ let error = read(ReadSource::Cdp(source), &ReadOptions::default())
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ ReadError::Chrome(ChromeError::CdpDiscovery)
+ ));
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn rejects_ambiguous_browser_page_selection_without_a_target_id() {
+ let (base_endpoint, server) = websocket_server(|stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Browser.getVersion" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({ "userAgent": "MockChrome/1.0" }),
+ )
+ .await?;
+ }
+ "Target.getTargets" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({
+ "targetInfos": [
+ {
+ "targetId": "page-1",
+ "type": "page",
+ "url": "https://example.test/one"
+ },
+ {
+ "targetId": "page-2",
+ "type": "page",
+ "url": "https://example.test/two"
+ }
+ ]
+ }),
+ )
+ .await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+ Ok(())
+ })
+ .await;
+
+ let source = CdpSource::new(format!("{base_endpoint}/devtools/browser/mock"));
+ let error = read(ReadSource::Cdp(source), &ReadOptions::default())
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ ReadError::Chrome(ChromeError::CdpTargetAmbiguous)
+ ));
+ server.await.unwrap().unwrap();
+}
+
+#[tokio::test]
+async fn closes_an_owned_target_when_attach_response_has_no_session_id() {
+ let (base_endpoint, server) = websocket_server(|stream| async move {
+ let mut socket = accept_async(stream)
+ .await
+ .map_err(|error| error.to_string())?;
+ let mut saw_close = false;
+
+ while let Ok(command) = next_command(&mut socket).await {
+ match command["method"].as_str().unwrap_or_default() {
+ "Browser.getVersion" => {
+ respond(
+ &mut socket,
+ &command,
+ json!({ "userAgent": "MockChrome/1.0" }),
+ )
+ .await?;
+ }
+ "Target.createTarget" => {
+ assert_eq!(command["params"]["background"], true);
+ respond(&mut socket, &command, json!({ "targetId": "owned-target" })).await?;
+ }
+ "Target.attachToTarget" => {
+ assert_eq!(command["params"]["targetId"], "owned-target");
+ respond(&mut socket, &command, json!({})).await?;
+ }
+ "Target.closeTarget" => {
+ assert_eq!(command["params"]["targetId"], "owned-target");
+ saw_close = true;
+ respond(&mut socket, &command, json!({ "success": true })).await?;
+ }
+ other => return Err(format!("unexpected CDP command: {other}")),
+ }
+ }
+
+ assert!(saw_close);
+ Ok(())
+ })
+ .await;
+
+ let mut source = CdpSource::new(format!("{base_endpoint}/devtools/browser/mock"));
+ source.url = Some(Url::parse("https://example.test/requested").unwrap());
+ source.wait_until = CdpWaitUntil::None;
+ let error = read(ReadSource::Cdp(source), &ReadOptions::default())
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ ReadError::Chrome(ChromeError::CdpCommand {
+ method: "Target.attachToTarget",
+ ..
+ })
+ ));
+ server.await.unwrap().unwrap();
+}
diff --git a/crates/opsail-read/tests/expected/07-lazy-images.md b/crates/opsail-read/tests/expected/07-lazy-images.md
new file mode 100644
index 0000000..4af9835
--- /dev/null
+++ b/crates/opsail-read/tests/expected/07-lazy-images.md
@@ -0,0 +1,19 @@
+# Recovering Deferred Images
+
+Documentation pages often defer large diagrams until they approach the viewport. A text reader still needs the real resource URL, alternative text, and caption even though it never executes the page's JavaScript.
+
+ The document processing pipeline.
+
+Some loaders keep the destination in a data attribute instead of a source set. The extracted document should publish that destination as an ordinary image without retaining loader-specific attributes.
+
+ A map loaded from a data attribute.
+
+Responsive galleries often wrap image-only cards in layout containers. Those cards remain meaningful even when they add little visible text to the article scoring model.
+
+ First gallery diagram.
+
+ Second gallery diagram.
+
+Server-rendered applications may place the only usable image inside a noscript fallback beside a transparent placeholder. Static extraction must recover the fallback before removing the noscript wrapper.
+
+ A server-rendered fallback diagram.
diff --git a/crates/opsail-read/tests/expected/08-highlighted-code.md b/crates/opsail-read/tests/expected/08-highlighted-code.md
new file mode 100644
index 0000000..bf927a2
--- /dev/null
+++ b/crates/opsail-read/tests/expected/08-highlighted-code.md
@@ -0,0 +1,22 @@
+# Readable Highlighted Code
+
+Syntax highlighters frequently render a visual gutter beside the program. The gutter is presentation, while indentation, punctuation, and language metadata belong to the readable document.
+
+## Table gutter
+
+```rust
+fn total(values: &[u32]) -> u32 {
+ values.iter().sum()
+}
+```
+
+Inline gutters are another common representation. Their digits must not become part of identifiers or change the program copied by an agent.
+
+## Inline gutter
+
+```python
+def greet(name):
+ return f"Hello, {name}"
+```
+
+Prose after the examples is retained so a layout table cannot replace the surrounding explanation as the page's main extraction candidate.
diff --git a/crates/opsail-read/tests/expected/09-multiple-code-blocks.md b/crates/opsail-read/tests/expected/09-multiple-code-blocks.md
new file mode 100644
index 0000000..684c97a
--- /dev/null
+++ b/crates/opsail-read/tests/expected/09-multiple-code-blocks.md
@@ -0,0 +1,30 @@
+# Two Compiler Examples
+
+### A source example
+
+First, inspect the source function:
+
+```cpp
+auto process_values(const std::vector &values)
+{
+ return std::count_if(
+ values.begin(),
+ values.end(),
+ [](uint8_t value) { return value % 2 == 0; }
+ );
+}
+```
+
+Next, compare the generated assembly:
+
+```nasm
+process_values:
+ vpbroadcastb xmm1, byte ptr [rip + .mask]
+ vmovd xmm2, dword ptr [rsi + rax]
+ vpandn xmm2, xmm2, xmm1
+ add rax, 4
+ cmp r8, rax
+ jne .loop
+```
+
+The two listings describe the same operation at different levels.
diff --git a/crates/opsail-read/tests/expected/10-wechat-hidden-content.md b/crates/opsail-read/tests/expected/10-wechat-hidden-content.md
new file mode 100644
index 0000000..c06356b
--- /dev/null
+++ b/crates/opsail-read/tests/expected/10-wechat-hidden-content.md
@@ -0,0 +1,15 @@
+# 随机片段汇总
+
+**占位作者**
+
+桌面上有一只蓝色杯子、三张空白卡片和一枚木制纽扣。
+
+窗边的纸风车转了两圈又停下,七号箱被放在八号架旁,钟面正好指向四点。
+
+## 另一组片段
+
+清单里依次写着橘子、折尺、旧信封和一小段紫色线,这些项目彼此没有关联。
+
+最后一页留下潮湿、方格、慢速和北边四个词,不用于表达任何观点或结论。
+
+
diff --git a/crates/opsail-read/tests/expected/11-hidden-semantic-root.md b/crates/opsail-read/tests/expected/11-hidden-semantic-root.md
new file mode 100644
index 0000000..658004b
--- /dev/null
+++ b/crates/opsail-read/tests/expected/11-hidden-semantic-root.md
@@ -0,0 +1,5 @@
+# 随机门牌记录
+
+桌角放着一枚绿色棋子、半张方格纸和一把没有标签的钥匙。窗帘晃动三次以后,纸上的六个圆点仍然保持原来的间距,没有组成任何图案。
+
+十二号抽屉里依次装着玻璃珠、灰色线轴、空白票据和一小块软木。旁边的卡片只写了缓慢、东侧、折叠和七月四个互不相关的词。
diff --git a/crates/opsail-read/tests/fixtures.rs b/crates/opsail-read/tests/fixtures.rs
index bac9b89..5d355db 100644
--- a/crates/opsail-read/tests/fixtures.rs
+++ b/crates/opsail-read/tests/fixtures.rs
@@ -221,3 +221,206 @@ fn keeps_short_cjk_content_via_semantic_fallback() {
assert_eq!(result.content.lines().next(), Some("# 山谷雨讯"));
assert_markdown_matches_golden(&result, "06-short-cjk-main.md");
}
+
+#[test]
+fn recovers_lazy_and_noscript_images() {
+ let result = fixture(
+ "07-lazy-images.html",
+ "https://example.test/guides/read/index.html",
+ );
+
+ for image in [
+ "https://example.test/guides/read/images/pipeline-large.webp",
+ "https://example.test/guides/media/deferred-map.png",
+ "https://example.test/gallery/first.jpg",
+ "https://example.test/gallery/second.jpg",
+ "https://example.test/assets/static-fallback.svg",
+ ] {
+ assert!(
+ result.content.contains(image),
+ "missing recovered image: {image}"
+ );
+ }
+ for loader_detail in ["data-src", "data:image", "Visible article
+
+ Visible article
+ The visible article contains enough ordinary prose to be selected without consulting hidden alternatives. It describes a wooden tray, two paper clips, a folded map, and a numbered card on a quiet desk.
+ A second visible paragraph adds enough structure for a normal article while remaining unrelated to the concealed test material beside it.
+
+ HIDDEN_PROMPT_MUST_NOT_APPEAR
+ HIDDEN_PROMPT_MUST_NOT_APPEAR repeated inside a longer concealed article-shaped block that must never replace an already substantial visible result.
+ HIDDEN_PROMPT_MUST_NOT_APPEAR remains excluded even when this alternative contains more raw characters than the ordinary visible article.
+
+ "#;
+
+ let result = extract_html(html, Some(&base_url)).unwrap();
+
+ assert!(result.content.contains("wooden tray"));
+ assert!(!result.content.contains("HIDDEN_PROMPT_MUST_NOT_APPEAR"));
+}
+
+#[test]
+fn does_not_recover_hidden_promotional_content_from_a_thin_page() {
+ let base_url = Url::parse("https://example.test/thin-note").unwrap();
+ let html = r#"Thin note
+ A short visible note.
+
+ HIDDEN_ADVERTISEMENT_MUST_NOT_APPEAR
+ This concealed promotional block deliberately contains long article-shaped prose, several ordinary sentences, and enough characters to look richer than the visible note.
+ It must remain excluded because an advertisement marker is stronger evidence than its length or paragraph structure.
+
+
+ HIDDEN_MODAL_MUST_NOT_APPEAR
+ This concealed modal also contains long article-shaped prose and multiple meaningful blocks, but its user-interface role makes it an unsafe recovery candidate.
+ It must remain excluded even when the ordinary visible result is short and no advertisement candidate is eligible.
+
+ "#;
+
+ let result = extract_html(html, Some(&base_url)).unwrap();
+
+ assert!(result.content.contains("A short visible note"));
+ assert!(
+ !result
+ .content
+ .contains("HIDDEN_ADVERTISEMENT_MUST_NOT_APPEAR")
+ );
+ assert!(!result.content.contains("HIDDEN_MODAL_MUST_NOT_APPEAR"));
+}
+
+#[test]
+fn css_custom_properties_do_not_hide_visible_content() {
+ let base_url = Url::parse("https://example.test/custom-properties").unwrap();
+ let html = r#"Custom properties
+
+ Custom properties
+ VISIBLE_CUSTOM_PROPERTY_CONTENT remains readable because CSS custom property names are not the display or visibility properties themselves.
+
+ "#;
+
+ let result = extract_html(html, Some(&base_url)).unwrap();
+
+ assert!(
+ result
+ .content
+ .contains("remains readable because CSS custom property"),
+ "unexpected content: {}",
+ result.content
+ );
+}
diff --git a/crates/opsail-read/tests/fixtures/07-lazy-images.html b/crates/opsail-read/tests/fixtures/07-lazy-images.html
new file mode 100644
index 0000000..f8cae99
--- /dev/null
+++ b/crates/opsail-read/tests/fixtures/07-lazy-images.html
@@ -0,0 +1,82 @@
+
+
+
+
+
+ Recovering Deferred Images
+
+
+
+
+ Recovering Deferred Images
+
+ Documentation pages often defer large diagrams until they approach the
+ viewport. A text reader still needs the real resource URL, alternative
+ text, and caption even though it never executes the page's JavaScript.
+
+
+
+
+
+
+
+ The document processing pipeline.
+
+
+
+ Some loaders keep the destination in a data attribute instead of a
+ source set. The extracted document should publish that destination as
+ an ordinary image without retaining loader-specific attributes.
+
+
+
+ A map loaded from a data attribute.
+
+
+
+ Responsive galleries often wrap image-only cards in layout containers.
+ Those cards remain meaningful even when they add little visible text to
+ the article scoring model.
+
+
+
+
+
+
+
+ First gallery diagram.
+
+
+
+
+
+
+ Second gallery diagram.
+
+
+
+
+
+
+ Server-rendered applications may place the only usable image inside a
+ noscript fallback beside a transparent placeholder. Static extraction
+ must recover the fallback before removing the noscript wrapper.
+
+
+
+
+
+
+ A server-rendered fallback diagram.
+
+
+
+
+
diff --git a/crates/opsail-read/tests/fixtures/08-highlighted-code.html b/crates/opsail-read/tests/fixtures/08-highlighted-code.html
new file mode 100644
index 0000000..b85a791
--- /dev/null
+++ b/crates/opsail-read/tests/fixtures/08-highlighted-code.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+ Readable Highlighted Code
+
+
+
+ Readable Highlighted Code
+
+ Syntax highlighters frequently render a visual gutter beside the program.
+ The gutter is presentation, while indentation, punctuation, and language
+ metadata belong to the readable document.
+
+
+ Table gutter
+
+
+ 1
+ 2
+ 3
+
+ fn total(values: &[u32]) -> u32 {
+ values.iter().sum()
+ }
+
+
+
+
+
+ Inline gutters are another common representation. Their digits must not
+ become part of identifiers or change the program copied by an agent.
+
+ Inline gutter
+
+ 10
+ 11
+
+ 10 def greet(name):
+11 return f"Hello, {name}"
+
+
+
+
+ Prose after the examples is retained so a layout table cannot replace the
+ surrounding explanation as the page's main extraction candidate.
+
+
+
+
diff --git a/crates/opsail-read/tests/fixtures/09-multiple-code-blocks.html b/crates/opsail-read/tests/fixtures/09-multiple-code-blocks.html
new file mode 100644
index 0000000..52dd736
--- /dev/null
+++ b/crates/opsail-read/tests/fixtures/09-multiple-code-blocks.html
@@ -0,0 +1,56 @@
+
+
+
+ Two Compiler Examples
+
+
+ A source example
+ First, inspect the source function:
+
+
+ 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+
+ auto process_values(const std::vector<uint8_t> &values)
+ {
+ return std::count_if(
+ values.begin(),
+ values.end(),
+ [](uint8_t value) { return value % 2 == 0; }
+ );
+ }
+
+
+
+
+ Next, compare the generated assembly:
+
+
+ 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+
+ process_values:
+ vpbroadcastb xmm1, byte ptr [rip + .mask]
+ vmovd xmm2, dword ptr [rsi + rax]
+ vpandn xmm2, xmm2, xmm1
+ add rax, 4
+ cmp r8, rax
+ jne .loop
+
+
+
+
+ The two listings describe the same operation at different levels.
+
+
+
diff --git a/crates/opsail-read/tests/fixtures/10-wechat-hidden-content.html b/crates/opsail-read/tests/fixtures/10-wechat-hidden-content.html
new file mode 100644
index 0000000..e44bccf
--- /dev/null
+++ b/crates/opsail-read/tests/fixtures/10-wechat-hidden-content.html
@@ -0,0 +1,26 @@
+
+
+
+ 随机片段汇总
+
+
+
+
+
随机片段汇总
+
+
+
占位作者
+
桌面上有一只蓝色杯子、三张空白卡片和一枚木制纽扣。
+
窗边的纸风车转了两圈又停下,七号箱被放在八号架旁,钟面正好指向四点。
+
另一组片段
+
清单里依次写着橘子、折尺、旧信封和一小段紫色线,这些项目彼此没有关联。
+
最后一页留下潮湿、方格、慢速和北边四个词,不用于表达任何观点或结论。
+
+
+
阅读原文
+
+
+
diff --git a/crates/opsail-read/tests/fixtures/11-hidden-semantic-root.html b/crates/opsail-read/tests/fixtures/11-hidden-semantic-root.html
new file mode 100644
index 0000000..cceb754
--- /dev/null
+++ b/crates/opsail-read/tests/fixtures/11-hidden-semantic-root.html
@@ -0,0 +1,26 @@
+
+
+
+
+ 随机门牌记录
+
+
+
+
+
+
+ 随机门牌记录
+ 桌角放着一枚绿色棋子、半张方格纸和一把没有标签的钥匙。窗帘晃动三次以后,纸上的六个圆点仍然保持原来的间距,没有组成任何图案。
+ NESTED_HIDDEN_TEXT_MUST_NOT_APPEAR,即使外层正文被恢复,这个内层隐藏副本也必须继续删除。
+
+
+ 十二号抽屉里依次装着玻璃珠、灰色线轴、空白票据和一小块软木。旁边的卡片只写了缓慢、东侧、折叠和七月四个互不相关的词。
+
+
+
+
+ FPS: --
+
+
+
+
diff --git a/crates/opsail-refit-codex/CONTEXT.md b/crates/opsail-refit-codex/CONTEXT.md
new file mode 100644
index 0000000..c5db889
--- /dev/null
+++ b/crates/opsail-refit-codex/CONTEXT.md
@@ -0,0 +1,9 @@
+# Opsail Refit Codex
+
+This crate adapts verified local Codex renderer capabilities without owning ChatGPT's native account data lifecycle.
+
+## Language
+
+**Reset-credit availability**:
+Confirmed knowledge about whether an account currently has usable reset credits. Until a valid source explicitly reports a list, availability is not observed; lack of an observation is not evidence that the account has no credits.
+_Avoid_: Loading, failed, awaiting native data
diff --git a/crates/opsail-refit-codex/Cargo.toml b/crates/opsail-refit-codex/Cargo.toml
new file mode 100644
index 0000000..a7667d8
--- /dev/null
+++ b/crates/opsail-refit-codex/Cargo.toml
@@ -0,0 +1,59 @@
+[package]
+name = "opsail-refit-codex"
+description = "Safe Codex renderer refits for Opsail"
+version = "0.1.0"
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+readme = "README.md"
+repository.workspace = true
+
+[dependencies]
+futures-util.workspace = true
+reqwest.workspace = true
+ring.workspace = true
+rustls.workspace = true
+semver.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+thiserror.workspace = true
+tokio.workspace = true
+tokio-tungstenite.workspace = true
+tracing.workspace = true
+url.workspace = true
+
+[target.'cfg(windows)'.dependencies]
+windows = { workspace = true, features = [
+ "ApplicationModel",
+ "ApplicationModel_Core",
+ "Foundation",
+ "Foundation_Collections",
+ "Management_Deployment",
+ "Storage",
+ "Storage_Search",
+ "Win32_Foundation",
+ "Win32_NetworkManagement_IpHelper",
+ "Win32_Networking_WinSock",
+ "Win32_Security",
+ "Win32_Security_Authorization",
+ "Win32_Storage_FileSystem",
+ "Win32_Storage_Packaging_Appx",
+ "Win32_System_Com",
+ "Win32_System_Diagnostics_ToolHelp",
+ "Win32_System_Threading",
+ "Win32_System_WinRT",
+ "Win32_UI_Shell",
+] }
+
+[dev-dependencies]
+tempfile.workspace = true
+
+[lints.rust]
+unsafe_code = "deny"
+unsafe_op_in_unsafe_fn = "deny"
+
+[lints.clippy]
+all = { level = "warn", priority = -1 }
+dbg_macro = "deny"
+todo = "deny"
diff --git a/crates/opsail-refit-codex/README.md b/crates/opsail-refit-codex/README.md
new file mode 100644
index 0000000..0bbe140
--- /dev/null
+++ b/crates/opsail-refit-codex/README.md
@@ -0,0 +1,195 @@
+# opsail-refit-codex
+
+`opsail-refit-codex` is Opsail's target-validated Codex renderer adapter. Its first feature adds a small remaining-usage capsule to the account row at the bottom of the Codex sidebar.
+
+The crate owns the complete adapter boundary:
+
+- an internal, reusable refit lifecycle with idempotent enable, disable, status, rollback, cleanup, and health checks;
+- platform-specific application identity, process ownership, loopback listener, and renderer validation for macOS and Windows;
+- bounded Chrome DevTools Protocol discovery and transport;
+- Codex renderer bridge methods, a versioned DOM adapter, rate-limit normalization, partial-update merging, refresh coordination, and UI payloads;
+- embedded locale JSON and theme-token-only CSS;
+- explicit, versioned renderer JavaScript updates with fixed GitHub origin, SHA-256 validation, and atomic local activation.
+
+The lifecycle remains an internal module while Codex is the only refit adapter. A shared crate should be extracted only after another adapter demonstrates a stable duplicated contract.
+
+## Supported targets
+
+| Platform | Validated application contract | Status |
+| --- | --- | --- |
+| macOS | `/Applications/ChatGPT.app`, bundle identifier `com.openai.codex`, signing team `2DC432GLL2`, and its signed executable/process tree | Implemented |
+| Windows | The current user's Store-signed, non-development `OpenAI.Codex` package with exact PFN `OpenAI.Codex_2p2nqsd0c76g0` and AUMID `OpenAI.Codex_2p2nqsd0c76g0!App`; the executable is derived from the installed signed manifest (currently `app\ChatGPT.exe`) | Implemented for the x64 and ARM64 release targets; native compile, unit, and missing-application CI configured; installed-application end-to-end canary passed on Windows 11 ARM64; real x64 Store canary pending; no 32-bit x86/ia32 release target |
+| Linux | No official application identity is defined | Unsupported |
+
+The Windows backend and its native API boundary are implemented without depending on the reference PowerShell project. It queries the current user's packages by exact PFN and AUMID instead of matching a versioned `WindowsApps` directory, then reads the application executable from the installed signed `AppxManifest.xml`. The manifest path is accepted only when it is relative, resolves to a regular file, and remains canonically contained by the package root. Native Windows CI and npm packaging targets are configured for x64 and ARM64. A Windows 11 ARM64 canary against an installed Store application validates package activation, live listener ownership, renderer discovery, bridge injection, persistent mode, and cleanup. A real installed-application x64 canary remains pending; hosted CI covers the no-installed-package path.
+
+Normal enable is attach-only. Explicit `--launch` may start a confirmed-stopped application once through the platform's validated launch mechanism. Opsail never quits, kills, restarts, reloads, modifies, re-signs, or writes into the application. It accepts only a debugging endpoint bound to `127.0.0.1`, validates that the listener belongs to the expected platform-validated application process, and requires an `app://` renderer with the expected application shell, sidebar, and local bridge.
+
+## Launch and attach CLI
+
+The supported entry point that does not require a manual application command is:
+
+```sh
+opsail refit codex enable usage --launch
+opsail refit codex enable usage --launch --once
+```
+
+Interactive lifecycle commands show the current bounded milestone with a terminal spinner, including application validation, endpoint inspection, launch preflight, application startup, CDP readiness, renderer/bridge validation, injection, and health confirmation. Background startup changes the visible message only after a stage remains current for about 120ms; rapid milestones are coalesced instead of flashing completed work, without delaying the operation itself. The spinner writes only to `stderr`; structured results remain the only content on `stdout`. Redirected commands, automation without a terminal, and the background manager itself stay quiet. The foreground and background startup paths use the same structured stage model, so background startup does not fall back to an unexplained static wait.
+
+`--launch` maps to the crate's `LaunchPolicy::LaunchIfStopped`. Enable first attempts to attach to an existing validated endpoint. If none exists, Opsail validates the platform application identity, confirms that ChatGPT is stopped and the selected port is free, and starts exactly one application instance.
+
+On macOS, it validates the bundle identifier, signing team, code signature, and executable before spawning:
+
+```sh
+/Applications/ChatGPT.app/Contents/MacOS/ChatGPT \
+ --remote-debugging-address=127.0.0.1 \
+ --remote-debugging-port=55321
+```
+
+There is no shell wrapper and no `open -a` call. Standard streams and the process group are detached from the Opsail session.
+
+On Windows, Opsail validates the current user's registered Store package, Store signature kind, non-development status, exact PFN and AUMID, signed-manifest-derived executable, and user SID. The executable is currently `app\ChatGPT.exe`, but its versioned installation path and filename are not discovered with a prefix or regular-expression scan. Opsail then passes the same two CDP arguments through the Windows application activation API. It does not execute the protected WindowsApps executable directly or invoke PowerShell. The activated PID, creation time, package identity, executable file identity, and user SID are checked again around listener discovery.
+
+A once command or stopped persistent manager never takes ChatGPT down with it. Endpoint startup has a bounded timeout. After discovery, Opsail revalidates the platform process and listener identity, then performs renderer and bridge validation before injection.
+
+When `--launch` actually starts ChatGPT and the injected runtime passes its health check, the renderer shows one short, localized notice confirming that Opsail mode is active. It is horizontally centered near 30% of the viewport height, above the visual midpoint without hugging the window edge. Its background and high-contrast foreground use Codex's paired activity-badge theme tokens, with the themed progress accent and foreground as semantic fallbacks. Attaching to an already-running validated endpoint, reconnecting after a renderer reload, and repeated idempotent enable do not produce that launch notice. Notice failure is diagnostic-only and never replaces or rolls back a healthy usage capsule.
+
+An already-valid endpoint is attached without starting another process. If ChatGPT is already running without the selected CDP endpoint, enable returns `restart-required` and never quits or restarts it. A conflicting listener returns `port-unavailable`; a spawn or endpoint timeout returns `launch-failed`. `doctor`, `status`, and `disable` are always attach-only and never start the application.
+
+The public default is `55321`, and `--port PORT` or `-p PORT` explicitly overrides it. `--launch` also has `-l`, `--once` has `-o`, and `--foreground` has `-F`. Discovery, preflight, and launch always use `127.0.0.1`; `localhost`, IPv6 addresses, `0.0.0.0`, and non-loopback listeners are rejected. The current implementation does not automatically choose another port if `55321` is occupied. Selecting a free port from `49152..65535` and persisting that choice is a future consideration, not a current capability.
+
+On macOS, a user-managed endpoint can start the application manually with the same address and selected port before attach-only enable:
+
+```sh
+/Applications/ChatGPT.app/Contents/MacOS/ChatGPT \
+ --remote-debugging-address=127.0.0.1 \
+ --remote-debugging-port=55321
+
+opsail refit codex enable usage
+opsail refit codex enable usage --once
+```
+
+On Windows, do not run the executable inside WindowsApps directly. `--launch` uses the implemented AUMID package-activation route; attach-only commands may reuse an endpoint that is already running and passes the full Windows identity checks. The executable path is used for identity validation, not direct launch.
+
+The read-only diagnostic command reports why an existing target is or is not ready, but does not launch it:
+
+```sh
+opsail refit codex doctor
+```
+
+`persistent` (managed) is the default. Opsail starts a background manager, waits for its validated health report, prints that JSON, and returns control to the terminal. The manager holds one WebSocket per validated renderer; it does not create an Opsail HTTP service, proxy, or additional listening port. A dedicated async reader drains responses, control frames, and unrelated CDP events while stable idle work blocks on the socket, and Rust does not duplicate the renderer's usage polling.
+
+The CDP socket is also the primary application-lifetime signal. For an application started by Opsail, the background manager additionally owns a process-exit receiver backed by the child wait primitive; either the socket close or the process-exit event wakes the blocked supervisor. For an application attached after external startup, socket close triggers one process-identity check. If ChatGPT has exited, the manager removes its local target markers, releases its lock, and terminates. If ChatGPT is still running, the disconnect is treated as a renderer reload and target rediscovery uses exponential backoff from 250ms up to 30 seconds. Process checks occur only before and after those disconnected recovery waits; there is no timer or process polling while the socket is healthy. A successful renderer connection resets the backoff.
+
+For diagnostics, keep the manager attached to the terminal explicitly:
+
+```sh
+opsail refit codex enable usage --foreground
+opsail refit codex enable usage --launch --foreground
+```
+
+Foreground mode has the same managed lifecycle. macOS accepts `Ctrl+C`, `Ctrl+Z`, `SIGTERM`, and `SIGHUP` as shutdown requests; Windows currently handles `Ctrl+C`.
+
+Disable validates and stops an active Opsail manager when necessary, reconnects temporarily, and removes the current DOM, styles, listeners, observers, timers, and managed marker. It never stops ChatGPT:
+
+```sh
+opsail refit codex status
+opsail refit codex disable usage
+```
+
+For a current-document injection that exits immediately, use:
+
+```sh
+opsail refit codex enable usage --once
+```
+
+`once` (ephemeral) performs the same application, process, loopback listener, renderer URL, shell, sidebar, and bridge validation. It evaluates the payload, confirms current-document health, closes the CDP WebSocket, and stores no early-script identifier. It never calls `Page.addScriptToEvaluateOnNewDocument`. Once does not survive a hard reload, renderer reconstruction, or application restart; disappearance after any of those events is the documented trade-off, not a persistent-mode failure. Repeated once installation remains renderer-idempotent.
+
+`status` and `doctor` report `once`/`ephemeral` separately from `persistent`/`managed`. A persistent renderer whose manager is gone is stale rather than healthy managed state. A once runtime that disappeared after reload is simply not installed. Disable after once performs only idempotent current-renderer cleanup and never tries to remove an identifier from the earlier CDP session.
+
+Use the same explicit `--port PORT` on later `status`, `doctor`, and `disable` commands when the endpoint does not use `55321`.
+
+## CDP cost and security boundaries
+
+`--remote-debugging-port` exposes Chromium's DevTools HTTP/WebSocket entry point; it does not turn a release application into a debug build. An enabled loopback listener with no client usually has very low, but not zero, cost. Persistent mode adds one resident CDP session per validated renderer. Once removes that session cost after health confirmation, while the debug listener remains present for the lifetime of the ChatGPT process.
+
+The renderer runtime is a separate cost boundary. Its longer-lived work is more important to constrain than an idle socket: mutation observations are filtered to relevant sidebar changes and coalesced with animation frames, geometry follows `ResizeObserver`, hidden pages pause fallback refreshes, and all listeners, observers, and timers have deterministic cleanup. Stable state performs no high-frequency polling.
+
+The listener's security exposure matters more than its idle performance. Opsail accepts only the literal `127.0.0.1` and revalidates process and platform application ownership around discovery. Discovery admits only a bounded, credential-free local `app:` renderer candidate on that verified endpoint; Opsail then separately probes the expected shell, sidebar, and bridge before any injection. This layered check avoids treating one private packaged URL such as `app://-/index.html` as a permanent product contract while still failing closed when the renderer identity changes. Prefer an available randomized high port and pass that exact value with `--port`; never expose the endpoint on another interface. These controls bound the listener, CDP session, and renderer runtime separately rather than claiming zero overhead.
+
+An Apple Events-based read-only probe is an unverified future consideration only. It is not implemented, enabled, or included in current capability and health decisions, and Opsail does not change application or system preferences for it.
+
+## Usage data and refresh behavior
+
+The renderer reads through the existing local account bridge using `account/rateLimits/read` and listens for `account/rateLimits/updated`. Those local payloads can optionally include `rateLimitResetCredits`; the field is not guaranteed to be present. The native application owns its separate reset-credit loading path. The refit never calls that private remote endpoint, a consume method, or a model, so a refresh does not consume model tokens.
+
+The UI derives window labels from each valid `windowDurationMins`, sorts shorter windows first, clamps finite `usedPercent` values to `0..100`, and displays rounded remaining percentages. It merges partial notifications by field presence and hides completely when no valid window exists. Each valid window reset renders a conservative remaining-time countdown plus an exact system-local timestamp in `YYYY-MM-DD HH:mm:ss` form. Available reset credits with a future finite `expiresAt` are sorted by expiration and rendered in a compact two-column table using the same exact timestamp and countdown. A localized footer states that every displayed timestamp uses local time and a 24-hour clock, avoiding duplicated AM/PM or time-zone labels on each row. Subtle row separators preserve scanning without adding boxed cells. Full localized time-zone-aware values remain available to assistive technology. Opaque identifiers, titles, redeemed or expired entries, counts, and consume actions are not retained or rendered.
+
+One read runs after injection. Persistent early bootstrap waits for both the document root and Electron preload bridge before installing observers and issuing local reads; the window `load` transition performs an immediate retry instead of leaving a partially installed runtime. Rate-limit windows and reset credits are merged independently from the same local bridge payload, so a response or notification that contains only one field cannot erase or block the other. A structurally present response is not considered ready unless it contains at least one displayable rate-limit window. If startup returns no such window, Opsail performs one bounded calibration read after 1.2 seconds before remaining quietly hidden.
+
+Reset-credit availability has three conservative observation states. `not-observed` means no structurally valid list has arrived and makes no claim about loading, failure, or account entitlement. `empty` means a valid list explicitly contains no currently usable future credit. `available` means at least one usable future credit is present. Only `available` renders the reset-credit section; both other states leave it hidden. Missing, `null`, and malformed fields preserve the last confirmed observation, while an explicitly empty or no-longer-usable valid list clears the rendered rows. Opsail performs no dedicated reset-credit retry or polling. Status exposes the observation state and usable count as structured fields without adding a warning or user-facing status sentence.
+
+Notifications update both fields immediately and schedule one debounced rate-limit calibration read after 1.2 seconds. Focus refreshes are gated to 60 seconds, and a visible page receives a fallback refresh every 15 minutes. Requests are deduplicated and time out after 15 seconds. A failed rate-limit refresh keeps the last successful snapshot with a quiet stale indicator.
+
+## Localization and renderer behavior
+
+All user-facing copy, compact summary labels, reset wording, duration labels, and the known Codex locale registry are loaded from the embedded `assets/locales.json` bundle. The runtime reads Codex's local `config.desktop.localeOverride` value through the existing renderer `config/read` bridge and treats it as authoritative. `document.documentElement.lang` and browser languages are fallbacks only when that override is absent or invalid. The locale is read once after injection, recalibrated when the account row returns after Settings or a route transition, and checked on window focus with in-flight deduplication and a short gate. There is no configuration polling, filesystem access, remote request, or model call; only the validated locale string is retained, while the rest of the configuration response is neither stored nor logged. The bundle recognizes the 65 locales currently exposed by Codex, provides native copy for the major language families, and uses English copy with the requested locale's `Intl` date/number conventions for the remaining languages. This intentionally follows language families rather than duplicating every regional translation. Simplified and Traditional Chinese are distinct, and Chinese copy inserts typographic spacing between Han text and Latin letters or numbers.
+
+Date wording follows the selected Codex locale while the time zone remains the system-local zone. A fallback `lang` change redraws the installed UI without reinjection when no valid configured override is present. Reset-credit countdowns conservatively floor the complete remaining hours or minutes, so they never promise more time than remains; the final partial minute displays as `0m`. Opening the details by pointer hover or keyboard focus immediately recalculates the countdown from the current local clock without reading quota data. One bounded timeout updates at the next displayed-unit boundary while the details remain installed. Hidden pages do not keep rescheduling the countdown, window focus recalibrates it, and cleanup releases the timeout.
+
+The capsule prefers a real position in the native account-row layout. A measured, fixed-position fallback is used only when a reliable row cannot be identified. Resize and relevant account-row mutations trigger coalesced remeasurement; after initial discovery, remeasurement queries only the cached account row and falls back to a sidebar-wide discovery scan only when that cache becomes invalid. Insufficient space hides the capsule instead of covering native controls. Stable observation is limited to the account row plus direct child-list changes along its structural ancestor path. When Settings removes only that row, observation temporarily falls back to the nearest still-connected structural ancestors without observing their subtrees, so session loading and search-result churn stay outside the callback. Before initial discovery or while a route has removed the entire sidebar, observation temporarily covers the document body subtree but filters mutations to newly added or removed nodes that match or contain the sidebar shape. This catches a sidebar mounted later inside a new route wrapper without scheduling layout for unrelated page churn. A returned account row is rediscovered and immediately becomes the narrow observation scope again. Hover/focus details are portaled to `document.body`; they include used quota, one localized window-reset line, progress for each real window, and the optional reset-credit expiration list.
+
+The details layer stays fully inside the sidebar with an approximately 16px horizontal inset whenever the sidebar is wide enough. Its horizontal range is also kept attached to the capsule: a capsule farther to the right pulls the layer toward it instead of leaving an unrelated panel at the sidebar edge. Only a sidebar narrower than the readable layer may cause right-side overflow. The final rectangle is always clamped inside the application viewport, so its left edge cannot be clipped by the window.
+
+Renderer colors, borders, backgrounds, text, and progress styling use application theme tokens with semantic fallbacks. The feature supports keyboard focus, localized accessible labels, tooltip association, progressbar semantics, and reduced-motion preferences.
+
+## Renderer asset updates and versioning
+
+All Codex-native DOM selectors and geometry knowledge remain centralized in `assets/opsail-refit-codex-dom-adapter.js`. The renderer bundle has four allowlisted JavaScript files: that DOM adapter, one shared CDP control entry for probe/early bootstrap/status/cleanup, a testable usage data model, and the usage runtime. Rust assembles operation-specific payloads from those boundaries. CSS and locale JSON remain compile-time embedded. The executable also embeds the complete JavaScript bundle as the safe initial version and fallback.
+
+Update the JavaScript bundle explicitly with:
+
+```sh
+opsail refit codex update
+```
+
+`update` is a separate execution path: it does not inspect a CDP port, discover a renderer, connect to a WebSocket, or start, stop, or restart ChatGPT. It first downloads only the version manifest from the fixed `raw.githubusercontent.com/lencx/opsail/refs/heads/main` repository path. An unchanged version returns without downloading JavaScript, and a default SHA-change rejection also stops at the manifest. Only an accepted installation downloads the four allowlisted JavaScript files concurrently from the same fixed path. It does not use the GitHub REST API. The manifest carries its schema version, renderer asset semantic version, payload API version, exact filenames, byte counts, and SHA-256 values. Payload compatibility is owned solely by `apiVersion`; it is not duplicated through a minimum CLI package version. Unknown, missing, reordered, oversized, incompatible, non-UTF-8, network-capable, or hash-mismatched content is rejected.
+
+The default command is deliberately conservative. If any JavaScript content SHA-256 differs from the active bundle, it performs no write and asks for explicit confirmation:
+
+```sh
+opsail refit codex update --force
+opsail refit codex update -f
+```
+
+When every JavaScript SHA-256 is unchanged, the normal `update` command succeeds without `--force`. This includes a manifest-only version advance; `--force` is required only to accept an actual JavaScript content change.
+
+Force confirms the validated content change only. It cannot override the fixed GitHub origin, file allowlist, size limits, manifest/file hashes, payload compatibility, or downgrade protection. Repository control is the update trust root; SHA-256 and byte counts provide content integrity and consistency, not an independent publisher signature. Fetching the mutable `main` manifest and files can race with a repository update; those checks make that race fail safely instead of mixing versions. Retrying later obtains one coherent publication.
+
+Validated versions are staged under Opsail's platform state directory, then activated through a small `current.json` pointer. Version directories are immutable and retained; symlinks, Windows reparse points, and non-regular files are rejected where applicable. Nothing is written to a Codex configuration directory, `ChatGPT.app`, `app.asar`, the Windows Store package directory, or any other application installation tree. `status`, lifecycle reports, and `doctor` expose the selected renderer asset version and whether it came from `embedded` or `github`. A malformed installed pointer or bundle falls back to the embedded version and produces a bounded doctor warning.
+
+An installed update activates when the next `CodexRefit` session is constructed. It does not mutate a currently running persistent manager or renderer in place. Run `opsail refit codex disable usage`, update, then enable again when immediate activation is desired; the update command itself never stops anything.
+
+A published renderer update must use an `assetVersion` higher than the previously published bundle whenever JavaScript content changes. Before the first release, development changes keep the initial `1.0.0` asset version and refresh only byte counts and SHA-256 values; the version is not incremented for each local edit. The embedded-manifest test makes stale metadata fail the build. Run the focused publication checks after changing renderer assets:
+
+```sh
+node --test packages/node/test/opsail-refit-codex-usage.test.js
+cargo test --locked -p opsail-refit-codex
+cargo clippy --locked -p opsail-refit-codex --all-targets -- -D warnings
+```
+
+Build the executable that carries the embedded fallback and updater with:
+
+```sh
+cargo build --locked --release -p opsail
+```
+
+The resulting CLI is `target/release/opsail`. To verify the standalone library crate package, including the manifest and every embedded fallback asset, use `cargo package --locked -p opsail-refit-codex --list` and then `cargo package --locked -p opsail-refit-codex`. The checked-in manifest must reach the fixed GitHub path before remote update clients can see that version.
+
+## Library API
+
+Embedders can construct `CodexRefit` with `CodexRefitConfig` and call `enable_usage(SessionMode, LaunchPolicy)`, `disable_usage`, `status`, the read-only `doctor`, or `update_renderer_assets(RendererAssetUpdatePolicy)`. `CodexRefitConfig::with_progress_handler` exposes the same infrequent `CodexRefitStage` milestones used by the CLI without coupling the adapter crate to terminal rendering. Handlers are synchronous and should return promptly. `RendererAssetUpdatePolicy::RequireUnchanged` is the safe update default; `Force` explicitly accepts validated JavaScript hash changes. `LaunchPolicy::AttachOnly` is the default behavior; `LaunchPolicy::LaunchIfStopped` is the explicit launch capability. `enable_usage` returns a `CodexUsageSession`; inspect its initial report, then await `run()` to keep a persistent session managed. `run()` returns immediately for once mode. Lifecycle reports include the actual port, session mode, launch policy, renderer asset version/source, and whether this invocation launched ChatGPT. Update reports include the previous and installed versions, whether content was forced, activation timing, and file count. Diagnostics distinguish unsupported targets, target validation failures, bridge unavailability, restart requirements, port conflicts, launch failures, injection, cleanup, update, stale-state, and local-state errors without including account payloads or credentials.
+
+A `stale` target with `healthy: true` means the installed runtime is structurally healthy but its local account snapshot is unavailable or stale; enable remains idempotent and does not reinject in that case. A `stale` target with `healthy: false` means renderer artifacts and the session lifecycle marker require reconciliation.
+
+Local managed markers, versioned renderer JavaScript, and advisory locks live under `~/Library/Application Support/opsail/refit/codex` on macOS and `%LOCALAPPDATA%\opsail\refit\codex` on Windows by default. macOS applies owner-only permission bits. Windows rejects symlinks and reparse points and replaces inherited access with a protected DACL granting full control only to the current user and SYSTEM; directory entries inherit that policy to descendants. Markers contain only bounded target identifiers, payload revisions, debugging ports, the persistent mode, non-secret installation tokens, and the owning Opsail manager PID plus its creation-time identity on Windows. CDP early-script identifiers remain only in the live session that owns them and are never treated as cross-connection receipts. CSS and locale JSON stay embedded; updated JavaScript is stored only in Opsail's state tree and never in a Codex configuration directory or the application installation tree.
diff --git a/crates/opsail-refit-codex/assets/locales.json b/crates/opsail-refit-codex/assets/locales.json
new file mode 100644
index 0000000..dd836e8
--- /dev/null
+++ b/crates/opsail-refit-codex/assets/locales.json
@@ -0,0 +1,770 @@
+{
+ "defaultLocale": "en-US",
+ "supportedLocales": [
+ "en-US",
+ "am",
+ "ar",
+ "bg-BG",
+ "bn-BD",
+ "bs-BA",
+ "ca-ES",
+ "cs-CZ",
+ "da-DK",
+ "de-DE",
+ "el-GR",
+ "es-419",
+ "es-ES",
+ "et-EE",
+ "fa",
+ "fi-FI",
+ "fr-CA",
+ "fr-FR",
+ "gu-IN",
+ "hi-IN",
+ "hr-HR",
+ "hu-HU",
+ "hy-AM",
+ "id-ID",
+ "is-IS",
+ "it-IT",
+ "ja-JP",
+ "ka-GE",
+ "kk",
+ "kn-IN",
+ "ko-KR",
+ "lt",
+ "lv-LV",
+ "mk-MK",
+ "ml",
+ "mn",
+ "mr-IN",
+ "ms-MY",
+ "my-MM",
+ "nb-NO",
+ "nl-NL",
+ "pa",
+ "pl-PL",
+ "pt-BR",
+ "pt-PT",
+ "ro-RO",
+ "ru-RU",
+ "sk-SK",
+ "sl-SI",
+ "so-SO",
+ "sq-AL",
+ "sr-RS",
+ "sv-SE",
+ "sw-TZ",
+ "ta-IN",
+ "te-IN",
+ "th-TH",
+ "tl",
+ "tr-TR",
+ "uk-UA",
+ "ur",
+ "vi-VN",
+ "zh-CN",
+ "zh-HK",
+ "zh-TW"
+ ],
+ "locales": {
+ "en-US": {
+ "locale": "en-US",
+ "usageTitle": "Usage limits",
+ "launchNoticeTitle": "Opsail mode enabled",
+ "launchNoticeMessage": "Usage display is ready.",
+ "summaryItem": "{label} {remaining}%",
+ "remaining": "{remaining}% remaining",
+ "used": "{used}% used",
+ "windowResetCountdown": "Resets in {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Usage limit resets",
+ "timeFormatNote": "Times use local time (24-hour clock).",
+ "resetCreditExpires": "Expires {dateTime}",
+ "resetCreditCountdown": "{countdown} remaining",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "resetCreditCountdownUnits": {
+ "day": "d",
+ "hour": "h",
+ "minute": "m",
+ "separator": " "
+ },
+ "ariaSummary": "Usage limits: {summary}",
+ "ariaProgress": "{label}: {remaining}% remaining",
+ "ariaMeta": "{used}% used. Resets {time}",
+ "stale": "Refresh failed; showing the last successful values",
+ "windowLabels": {
+ "fiveHours": "5h",
+ "weekly": "weekly",
+ "daily": "daily",
+ "monthly": "monthly",
+ "hours": "{value}h",
+ "minutes": "{value}m",
+ "generic": "usage"
+ },
+ "summaryWindowLabels": {
+ "fiveHours": "5h",
+ "weekly": "weekly",
+ "daily": "daily",
+ "monthly": "monthly",
+ "hours": "{value}h",
+ "minutes": "{value}m",
+ "generic": "usage"
+ }
+ },
+ "zh-CN": {
+ "locale": "zh-CN",
+ "usageTitle": "使用额度",
+ "launchNoticeTitle": "已进入 Opsail 模式",
+ "launchNoticeMessage": "额度显示已成功注入",
+ "summaryItem": "{label}剩余 {remaining}%",
+ "remaining": "剩余 {remaining}%",
+ "used": "已用 {used}%",
+ "windowResetCountdown": "距重置还有 {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "可用重置",
+ "timeFormatNote": "时间均为本地时间(24 小时制)",
+ "resetCreditExpires": "{dateTime} 过期",
+ "resetCreditCountdown": "剩余 {countdown}",
+ "resetCreditAria": "{expiry};{countdown}",
+ "resetCreditCountdownUnits": {
+ "day": " 天",
+ "hour": " 小时",
+ "minute": " 分钟",
+ "separator": " "
+ },
+ "ariaSummary": "使用额度:{summary}",
+ "ariaProgress": "{label}剩余 {remaining}%",
+ "ariaMeta": "已用 {used}%。{time} 重置",
+ "stale": "刷新失败,当前显示上次成功读取的额度",
+ "windowLabels": {
+ "fiveHours": "5 小时",
+ "weekly": "每周",
+ "daily": "每日",
+ "monthly": "每月",
+ "hours": "{value} 小时",
+ "minutes": "{value} 分钟",
+ "generic": "额度"
+ },
+ "summaryWindowLabels": {
+ "fiveHours": "5 小时",
+ "weekly": "周",
+ "daily": "日",
+ "monthly": "月",
+ "hours": "{value} 小时",
+ "minutes": "{value} 分钟",
+ "generic": "额度"
+ }
+ },
+ "zh-HK": {
+ "locale": "zh-HK",
+ "usageTitle": "使用額度",
+ "launchNoticeTitle": "已進入 Opsail 模式",
+ "launchNoticeMessage": "額度顯示已成功注入",
+ "summaryItem": "{label}剩餘 {remaining}%",
+ "remaining": "剩餘 {remaining}%",
+ "used": "已用 {used}%",
+ "windowResetCountdown": "距離重設尚有 {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "可用重設",
+ "timeFormatNote": "時間均為本地時間(24 小時制)",
+ "resetCreditExpires": "{dateTime} 到期",
+ "resetCreditCountdown": "剩餘 {countdown}",
+ "resetCreditAria": "{expiry};{countdown}",
+ "resetCreditCountdownUnits": {
+ "day": " 天",
+ "hour": " 小時",
+ "minute": " 分鐘",
+ "separator": " "
+ },
+ "ariaSummary": "使用額度:{summary}",
+ "ariaProgress": "{label}剩餘 {remaining}%",
+ "ariaMeta": "已用 {used}%。{time} 重設",
+ "stale": "重新整理失敗,目前顯示上次成功讀取的額度",
+ "windowLabels": {
+ "fiveHours": "5 小時",
+ "weekly": "每週",
+ "daily": "每日",
+ "monthly": "每月",
+ "hours": "{value} 小時",
+ "minutes": "{value} 分鐘",
+ "generic": "額度"
+ },
+ "summaryWindowLabels": {
+ "fiveHours": "5 小時",
+ "weekly": "週",
+ "daily": "日",
+ "monthly": "月",
+ "hours": "{value} 小時",
+ "minutes": "{value} 分鐘",
+ "generic": "額度"
+ }
+ },
+ "zh-TW": {
+ "locale": "zh-TW",
+ "usageTitle": "使用額度",
+ "launchNoticeTitle": "已進入 Opsail 模式",
+ "launchNoticeMessage": "額度顯示已成功注入",
+ "summaryItem": "{label}剩餘 {remaining}%",
+ "remaining": "剩餘 {remaining}%",
+ "used": "已用 {used}%",
+ "windowResetCountdown": "距離重設尚有 {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "可用重設",
+ "timeFormatNote": "時間均為本地時間(24 小時制)",
+ "resetCreditExpires": "{dateTime} 到期",
+ "resetCreditCountdown": "剩餘 {countdown}",
+ "resetCreditAria": "{expiry};{countdown}",
+ "resetCreditCountdownUnits": {
+ "day": " 天",
+ "hour": " 小時",
+ "minute": " 分鐘",
+ "separator": " "
+ },
+ "ariaSummary": "使用額度:{summary}",
+ "ariaProgress": "{label}剩餘 {remaining}%",
+ "ariaMeta": "已用 {used}%。{time} 重設",
+ "stale": "重新整理失敗,目前顯示上次成功讀取的額度",
+ "windowLabels": {
+ "fiveHours": "5 小時",
+ "weekly": "每週",
+ "daily": "每日",
+ "monthly": "每月",
+ "hours": "{value} 小時",
+ "minutes": "{value} 分鐘",
+ "generic": "額度"
+ },
+ "summaryWindowLabels": {
+ "fiveHours": "5 小時",
+ "weekly": "週",
+ "daily": "日",
+ "monthly": "月",
+ "hours": "{value} 小時",
+ "minutes": "{value} 分鐘",
+ "generic": "額度"
+ }
+ },
+ "es-ES": {
+ "locale": "es-ES",
+ "usageTitle": "Límites de uso",
+ "launchNoticeTitle": "Modo Opsail activado",
+ "launchNoticeMessage": "La visualización de uso está lista.",
+ "summaryItem": "{label} {remaining}% restante",
+ "remaining": "{remaining}% restante",
+ "used": "{used}% usado",
+ "windowResetCountdown": "Se restablece en {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Restablecimientos del límite de uso",
+ "timeFormatNote": "Las horas se muestran en hora local (formato de 24 horas).",
+ "resetCreditExpires": "Caduca el {dateTime}",
+ "resetCreditCountdown": "Quedan {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Límites de uso: {summary}",
+ "ariaProgress": "{label}: {remaining}% restante",
+ "ariaMeta": "{used}% usado. Se restablece {time}",
+ "stale": "No se pudo actualizar; se muestran los últimos valores disponibles",
+ "windowLabels": {
+ "fiveHours": "5 h",
+ "weekly": "semanal",
+ "daily": "diario",
+ "monthly": "mensual",
+ "hours": "{value} h",
+ "minutes": "{value} min",
+ "generic": "uso"
+ }
+ },
+ "fr-FR": {
+ "locale": "fr-FR",
+ "usageTitle": "Limites d’utilisation",
+ "launchNoticeTitle": "Mode Opsail activé",
+ "launchNoticeMessage": "L’affichage de l’utilisation est prêt.",
+ "summaryItem": "{label} : {remaining} % restants",
+ "remaining": "{remaining} % restants",
+ "used": "{used} % utilisés",
+ "windowResetCountdown": "Réinitialisation dans {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Réinitialisations de la limite d’utilisation",
+ "timeFormatNote": "Les heures sont affichées en heure locale (format 24 heures).",
+ "resetCreditExpires": "Expire le {dateTime}",
+ "resetCreditCountdown": "Encore {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Limites d’utilisation : {summary}",
+ "ariaProgress": "{label} : {remaining} % restants",
+ "ariaMeta": "{used} % utilisés. Réinitialisation le {time}",
+ "stale": "Échec de l’actualisation ; affichage des dernières valeurs disponibles",
+ "windowLabels": {
+ "fiveHours": "5 h",
+ "weekly": "hebdomadaire",
+ "daily": "quotidien",
+ "monthly": "mensuel",
+ "hours": "{value} h",
+ "minutes": "{value} min",
+ "generic": "utilisation"
+ }
+ },
+ "de-DE": {
+ "locale": "de-DE",
+ "usageTitle": "Nutzungslimits",
+ "launchNoticeTitle": "Opsail-Modus aktiviert",
+ "launchNoticeMessage": "Die Nutzungsanzeige ist bereit.",
+ "summaryItem": "{label} {remaining} % übrig",
+ "remaining": "{remaining} % übrig",
+ "used": "{used} % verwendet",
+ "windowResetCountdown": "Zurücksetzung in {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Zurücksetzungen des Nutzungslimits",
+ "timeFormatNote": "Alle Zeiten sind Ortszeiten (24-Stunden-Format).",
+ "resetCreditExpires": "Läuft am {dateTime} ab",
+ "resetCreditCountdown": "Noch {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Nutzungslimits: {summary}",
+ "ariaProgress": "{label}: {remaining} % übrig",
+ "ariaMeta": "{used} % verwendet. Zurücksetzung am {time}",
+ "stale": "Aktualisierung fehlgeschlagen; die letzten Werte werden angezeigt",
+ "windowLabels": {
+ "fiveHours": "5 Std.",
+ "weekly": "wöchentlich",
+ "daily": "täglich",
+ "monthly": "monatlich",
+ "hours": "{value} Std.",
+ "minutes": "{value} Min.",
+ "generic": "Nutzung"
+ }
+ },
+ "ja-JP": {
+ "locale": "ja-JP",
+ "usageTitle": "使用上限",
+ "launchNoticeTitle": "Opsail モードが有効になりました",
+ "launchNoticeMessage": "使用量表示の準備ができました。",
+ "summaryItem": "{label} 残り {remaining}%",
+ "remaining": "残り {remaining}%",
+ "used": "{used}% 使用済み",
+ "windowResetCountdown": "{countdown}後にリセット",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "使用上限のリセット",
+ "timeFormatNote": "時刻は現地時間(24 時間表記)です。",
+ "resetCreditExpires": "{dateTime} に期限切れ",
+ "resetCreditCountdown": "残り {countdown}",
+ "resetCreditAria": "{expiry}。{countdown}",
+ "ariaSummary": "使用上限:{summary}",
+ "ariaProgress": "{label}:残り {remaining}%",
+ "ariaMeta": "{used}% 使用済み。{time} にリセット",
+ "stale": "更新に失敗したため、最後に取得した値を表示しています",
+ "windowLabels": {
+ "fiveHours": "5 時間",
+ "weekly": "週間",
+ "daily": "日次",
+ "monthly": "月間",
+ "hours": "{value} 時間",
+ "minutes": "{value} 分",
+ "generic": "使用量"
+ }
+ },
+ "ko-KR": {
+ "locale": "ko-KR",
+ "usageTitle": "사용 한도",
+ "launchNoticeTitle": "Opsail 모드 활성화됨",
+ "launchNoticeMessage": "사용량 표시가 준비되었습니다.",
+ "summaryItem": "{label} {remaining}% 남음",
+ "remaining": "{remaining}% 남음",
+ "used": "{used}% 사용",
+ "windowResetCountdown": "{countdown} 후 재설정",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "사용 한도 재설정",
+ "timeFormatNote": "모든 시간은 현지 시간(24시간제)입니다.",
+ "resetCreditExpires": "{dateTime} 만료",
+ "resetCreditCountdown": "{countdown} 남음",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "사용 한도: {summary}",
+ "ariaProgress": "{label}: {remaining}% 남음",
+ "ariaMeta": "{used}% 사용. {time} 재설정",
+ "stale": "새로 고치지 못해 마지막으로 확인된 값을 표시합니다",
+ "windowLabels": {
+ "fiveHours": "5시간",
+ "weekly": "주간",
+ "daily": "일간",
+ "monthly": "월간",
+ "hours": "{value}시간",
+ "minutes": "{value}분",
+ "generic": "사용량"
+ }
+ },
+ "pt-BR": {
+ "locale": "pt-BR",
+ "usageTitle": "Limites de uso",
+ "launchNoticeTitle": "Modo Opsail ativado",
+ "launchNoticeMessage": "A exibição de uso está pronta.",
+ "summaryItem": "{label} {remaining}% restante",
+ "remaining": "{remaining}% restante",
+ "used": "{used}% usado",
+ "windowResetCountdown": "Redefinição em {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Redefinições do limite de uso",
+ "timeFormatNote": "Os horários usam a hora local (formato de 24 horas).",
+ "resetCreditExpires": "Expira em {dateTime}",
+ "resetCreditCountdown": "Restam {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Limites de uso: {summary}",
+ "ariaProgress": "{label}: {remaining}% restante",
+ "ariaMeta": "{used}% usado. Redefinição em {time}",
+ "stale": "Falha ao atualizar; mostrando os últimos valores disponíveis",
+ "windowLabels": {
+ "fiveHours": "5 h",
+ "weekly": "semanal",
+ "daily": "diário",
+ "monthly": "mensal",
+ "hours": "{value} h",
+ "minutes": "{value} min",
+ "generic": "uso"
+ }
+ },
+ "pt-PT": {
+ "locale": "pt-PT",
+ "usageTitle": "Limites de utilização",
+ "launchNoticeTitle": "Modo Opsail ativado",
+ "launchNoticeMessage": "A apresentação da utilização está pronta.",
+ "summaryItem": "{label} {remaining}% restante",
+ "remaining": "{remaining}% restante",
+ "used": "{used}% utilizado",
+ "windowResetCountdown": "Reposição dentro de {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Reposições do limite de utilização",
+ "timeFormatNote": "As horas usam a hora local (formato de 24 horas).",
+ "resetCreditExpires": "Expira em {dateTime}",
+ "resetCreditCountdown": "Restam {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Limites de utilização: {summary}",
+ "ariaProgress": "{label}: {remaining}% restante",
+ "ariaMeta": "{used}% utilizado. Reposição em {time}",
+ "stale": "Falha ao atualizar; são apresentados os últimos valores disponíveis",
+ "windowLabels": {
+ "fiveHours": "5 h",
+ "weekly": "semanal",
+ "daily": "diário",
+ "monthly": "mensal",
+ "hours": "{value} h",
+ "minutes": "{value} min",
+ "generic": "utilização"
+ }
+ },
+ "it-IT": {
+ "locale": "it-IT",
+ "usageTitle": "Limiti di utilizzo",
+ "launchNoticeTitle": "Modalità Opsail attivata",
+ "launchNoticeMessage": "La visualizzazione dell’utilizzo è pronta.",
+ "summaryItem": "{label} {remaining}% rimanente",
+ "remaining": "{remaining}% rimanente",
+ "used": "{used}% utilizzato",
+ "windowResetCountdown": "Reimpostazione tra {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Reimpostazioni del limite di utilizzo",
+ "timeFormatNote": "Gli orari sono in ora locale (formato 24 ore).",
+ "resetCreditExpires": "Scade il {dateTime}",
+ "resetCreditCountdown": "Mancano {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Limiti di utilizzo: {summary}",
+ "ariaProgress": "{label}: {remaining}% rimanente",
+ "ariaMeta": "{used}% utilizzato. Reimpostazione il {time}",
+ "stale": "Aggiornamento non riuscito; vengono mostrati gli ultimi valori disponibili",
+ "windowLabels": {
+ "fiveHours": "5 h",
+ "weekly": "settimanale",
+ "daily": "giornaliero",
+ "monthly": "mensile",
+ "hours": "{value} h",
+ "minutes": "{value} min",
+ "generic": "utilizzo"
+ }
+ },
+ "ru-RU": {
+ "locale": "ru-RU",
+ "usageTitle": "Лимиты использования",
+ "launchNoticeTitle": "Режим Opsail включён",
+ "launchNoticeMessage": "Отображение лимитов готово.",
+ "summaryItem": "{label}: осталось {remaining}%",
+ "remaining": "Осталось {remaining}%",
+ "used": "Использовано {used}%",
+ "windowResetCountdown": "Сброс через {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Сбросы лимита использования",
+ "timeFormatNote": "Время указано по местному времени (24-часовой формат).",
+ "resetCreditExpires": "Истекает {dateTime}",
+ "resetCreditCountdown": "Осталось {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Лимиты использования: {summary}",
+ "ariaProgress": "{label}: осталось {remaining}%",
+ "ariaMeta": "Использовано {used}%. Сброс {time}",
+ "stale": "Не удалось обновить; показаны последние доступные значения",
+ "windowLabels": {
+ "fiveHours": "5 ч",
+ "weekly": "неделя",
+ "daily": "день",
+ "monthly": "месяц",
+ "hours": "{value} ч",
+ "minutes": "{value} мин",
+ "generic": "лимит"
+ }
+ },
+ "ar": {
+ "locale": "ar",
+ "usageTitle": "حدود الاستخدام",
+ "launchNoticeTitle": "تم تفعيل وضع Opsail",
+ "launchNoticeMessage": "عرض الاستخدام جاهز.",
+ "summaryItem": "{label} متبقٍ {remaining}%",
+ "remaining": "متبقٍ {remaining}%",
+ "used": "تم استخدام {used}%",
+ "windowResetCountdown": "إعادة التعيين خلال {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "عمليات إعادة تعيين حد الاستخدام",
+ "timeFormatNote": "تُعرض الأوقات بالتوقيت المحلي (نظام 24 ساعة).",
+ "resetCreditExpires": "تنتهي في {dateTime}",
+ "resetCreditCountdown": "متبقي {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "حدود الاستخدام: {summary}",
+ "ariaProgress": "{label}: متبقٍ {remaining}%",
+ "ariaMeta": "تم استخدام {used}%. إعادة التعيين في {time}",
+ "stale": "تعذر التحديث؛ يتم عرض آخر قيم متاحة",
+ "windowLabels": {
+ "fiveHours": "5 س",
+ "weekly": "أسبوعي",
+ "daily": "يومي",
+ "monthly": "شهري",
+ "hours": "{value} س",
+ "minutes": "{value} د",
+ "generic": "الاستخدام"
+ }
+ },
+ "hi-IN": {
+ "locale": "hi-IN",
+ "usageTitle": "उपयोग सीमाएँ",
+ "launchNoticeTitle": "Opsail मोड चालू है",
+ "launchNoticeMessage": "उपयोग प्रदर्शन तैयार है।",
+ "summaryItem": "{label} {remaining}% शेष",
+ "remaining": "{remaining}% शेष",
+ "used": "{used}% उपयोग",
+ "windowResetCountdown": "{countdown} में रीसेट",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "उपयोग सीमा रीसेट",
+ "timeFormatNote": "सभी समय स्थानीय समय (24-घंटे प्रारूप) में हैं।",
+ "resetCreditExpires": "समाप्ति {dateTime}",
+ "resetCreditCountdown": "{countdown} शेष",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "उपयोग सीमाएँ: {summary}",
+ "ariaProgress": "{label}: {remaining}% शेष",
+ "ariaMeta": "{used}% उपयोग। रीसेट {time}",
+ "stale": "रीफ़्रेश विफल; अंतिम उपलब्ध मान दिखाए जा रहे हैं",
+ "windowLabels": {
+ "fiveHours": "5 घंटे",
+ "weekly": "साप्ताहिक",
+ "daily": "दैनिक",
+ "monthly": "मासिक",
+ "hours": "{value} घंटे",
+ "minutes": "{value} मिनट",
+ "generic": "उपयोग"
+ }
+ },
+ "tr-TR": {
+ "locale": "tr-TR",
+ "usageTitle": "Kullanım sınırları",
+ "launchNoticeTitle": "Opsail modu etkin",
+ "launchNoticeMessage": "Kullanım göstergesi hazır.",
+ "summaryItem": "{label} %{remaining} kaldı",
+ "remaining": "%{remaining} kaldı",
+ "used": "%{used} kullanıldı",
+ "windowResetCountdown": "{countdown} içinde sıfırlanır",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Kullanım sınırı sıfırlamaları",
+ "timeFormatNote": "Saatler yerel saati kullanır (24 saat biçimi).",
+ "resetCreditExpires": "Bitiş: {dateTime}",
+ "resetCreditCountdown": "{countdown} kaldı",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Kullanım sınırları: {summary}",
+ "ariaProgress": "{label}: %{remaining} kaldı",
+ "ariaMeta": "%{used} kullanıldı. {time} sıfırlanır",
+ "stale": "Yenileme başarısız; son kullanılabilir değerler gösteriliyor",
+ "windowLabels": {
+ "fiveHours": "5 sa",
+ "weekly": "haftalık",
+ "daily": "günlük",
+ "monthly": "aylık",
+ "hours": "{value} sa",
+ "minutes": "{value} dk",
+ "generic": "kullanım"
+ }
+ },
+ "vi-VN": {
+ "locale": "vi-VN",
+ "usageTitle": "Giới hạn sử dụng",
+ "launchNoticeTitle": "Đã bật chế độ Opsail",
+ "launchNoticeMessage": "Hiển thị mức dùng đã sẵn sàng.",
+ "summaryItem": "{label} còn {remaining}%",
+ "remaining": "Còn {remaining}%",
+ "used": "Đã dùng {used}%",
+ "windowResetCountdown": "Đặt lại sau {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Lượt đặt lại giới hạn sử dụng",
+ "timeFormatNote": "Thời gian dùng giờ địa phương (định dạng 24 giờ).",
+ "resetCreditExpires": "Hết hạn {dateTime}",
+ "resetCreditCountdown": "Còn {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Giới hạn sử dụng: {summary}",
+ "ariaProgress": "{label}: còn {remaining}%",
+ "ariaMeta": "Đã dùng {used}%. Đặt lại {time}",
+ "stale": "Làm mới không thành công; đang hiển thị các giá trị gần nhất",
+ "windowLabels": {
+ "fiveHours": "5 giờ",
+ "weekly": "hàng tuần",
+ "daily": "hàng ngày",
+ "monthly": "hàng tháng",
+ "hours": "{value} giờ",
+ "minutes": "{value} phút",
+ "generic": "mức dùng"
+ }
+ },
+ "id-ID": {
+ "locale": "id-ID",
+ "usageTitle": "Batas penggunaan",
+ "launchNoticeTitle": "Mode Opsail aktif",
+ "launchNoticeMessage": "Tampilan penggunaan siap.",
+ "summaryItem": "{label} tersisa {remaining}%",
+ "remaining": "Tersisa {remaining}%",
+ "used": "Terpakai {used}%",
+ "windowResetCountdown": "Direset dalam {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Reset batas penggunaan",
+ "timeFormatNote": "Waktu menggunakan waktu lokal (format 24 jam).",
+ "resetCreditExpires": "Berakhir {dateTime}",
+ "resetCreditCountdown": "Tersisa {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Batas penggunaan: {summary}",
+ "ariaProgress": "{label}: tersisa {remaining}%",
+ "ariaMeta": "Terpakai {used}%. Direset {time}",
+ "stale": "Penyegaran gagal; menampilkan nilai terakhir yang tersedia",
+ "windowLabels": {
+ "fiveHours": "5 jam",
+ "weekly": "mingguan",
+ "daily": "harian",
+ "monthly": "bulanan",
+ "hours": "{value} jam",
+ "minutes": "{value} mnt",
+ "generic": "penggunaan"
+ }
+ },
+ "nl-NL": {
+ "locale": "nl-NL",
+ "usageTitle": "Gebruikslimieten",
+ "launchNoticeTitle": "Opsail-modus ingeschakeld",
+ "launchNoticeMessage": "De gebruiksweergave is gereed.",
+ "summaryItem": "{label} {remaining}% over",
+ "remaining": "{remaining}% over",
+ "used": "{used}% gebruikt",
+ "windowResetCountdown": "Reset over {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Resets van gebruikslimiet",
+ "timeFormatNote": "Tijden worden weergegeven in lokale tijd (24-uursnotatie).",
+ "resetCreditExpires": "Verloopt op {dateTime}",
+ "resetCreditCountdown": "Nog {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Gebruikslimieten: {summary}",
+ "ariaProgress": "{label}: {remaining}% over",
+ "ariaMeta": "{used}% gebruikt. Reset op {time}",
+ "stale": "Vernieuwen mislukt; de laatst beschikbare waarden worden getoond",
+ "windowLabels": {
+ "fiveHours": "5 u",
+ "weekly": "wekelijks",
+ "daily": "dagelijks",
+ "monthly": "maandelijks",
+ "hours": "{value} u",
+ "minutes": "{value} min",
+ "generic": "gebruik"
+ }
+ },
+ "pl-PL": {
+ "locale": "pl-PL",
+ "usageTitle": "Limity użycia",
+ "launchNoticeTitle": "Tryb Opsail włączony",
+ "launchNoticeMessage": "Widok użycia jest gotowy.",
+ "summaryItem": "{label}: pozostało {remaining}%",
+ "remaining": "Pozostało {remaining}%",
+ "used": "Wykorzystano {used}%",
+ "windowResetCountdown": "Reset za {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Resety limitu użycia",
+ "timeFormatNote": "Godziny podano w czasie lokalnym (format 24-godzinny).",
+ "resetCreditExpires": "Wygasa {dateTime}",
+ "resetCreditCountdown": "Pozostało {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Limity użycia: {summary}",
+ "ariaProgress": "{label}: pozostało {remaining}%",
+ "ariaMeta": "Wykorzystano {used}%. Reset {time}",
+ "stale": "Odświeżenie nie powiodło się; pokazano ostatnie dostępne wartości",
+ "windowLabels": {
+ "fiveHours": "5 godz.",
+ "weekly": "tydzień",
+ "daily": "dzień",
+ "monthly": "miesiąc",
+ "hours": "{value} godz.",
+ "minutes": "{value} min",
+ "generic": "użycie"
+ }
+ },
+ "uk-UA": {
+ "locale": "uk-UA",
+ "usageTitle": "Ліміти використання",
+ "launchNoticeTitle": "Режим Opsail увімкнено",
+ "launchNoticeMessage": "Відображення лімітів готове.",
+ "summaryItem": "{label}: залишилося {remaining}%",
+ "remaining": "Залишилося {remaining}%",
+ "used": "Використано {used}%",
+ "windowResetCountdown": "Скидання через {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "Скидання ліміту використання",
+ "timeFormatNote": "Час указано за місцевим часом (24-годинний формат).",
+ "resetCreditExpires": "Спливає {dateTime}",
+ "resetCreditCountdown": "Залишилося {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "Ліміти використання: {summary}",
+ "ariaProgress": "{label}: залишилося {remaining}%",
+ "ariaMeta": "Використано {used}%. Скидання {time}",
+ "stale": "Не вдалося оновити; показано останні доступні значення",
+ "windowLabels": {
+ "fiveHours": "5 год",
+ "weekly": "тиждень",
+ "daily": "день",
+ "monthly": "місяць",
+ "hours": "{value} год",
+ "minutes": "{value} хв",
+ "generic": "використання"
+ }
+ },
+ "th-TH": {
+ "locale": "th-TH",
+ "usageTitle": "ขีดจำกัดการใช้งาน",
+ "launchNoticeTitle": "เปิดโหมด Opsail แล้ว",
+ "launchNoticeMessage": "การแสดงการใช้งานพร้อมแล้ว",
+ "summaryItem": "{label} เหลือ {remaining}%",
+ "remaining": "เหลือ {remaining}%",
+ "used": "ใช้แล้ว {used}%",
+ "windowResetCountdown": "รีเซ็ตใน {countdown}",
+ "windowReset": "{dateTime}",
+ "resetCreditsTitle": "การรีเซ็ตขีดจำกัดการใช้งาน",
+ "timeFormatNote": "เวลาแสดงตามเวลาท้องถิ่น (รูปแบบ 24 ชั่วโมง)",
+ "resetCreditExpires": "หมดอายุ {dateTime}",
+ "resetCreditCountdown": "เหลือ {countdown}",
+ "resetCreditAria": "{expiry}. {countdown}",
+ "ariaSummary": "ขีดจำกัดการใช้งาน: {summary}",
+ "ariaProgress": "{label}: เหลือ {remaining}%",
+ "ariaMeta": "ใช้แล้ว {used}%. รีเซ็ต {time}",
+ "stale": "รีเฟรชไม่สำเร็จ กำลังแสดงค่าล่าสุดที่มี",
+ "windowLabels": {
+ "fiveHours": "5 ชม.",
+ "weekly": "รายสัปดาห์",
+ "daily": "รายวัน",
+ "monthly": "รายเดือน",
+ "hours": "{value} ชม.",
+ "minutes": "{value} นาที",
+ "generic": "การใช้งาน"
+ }
+ }
+ }
+}
diff --git a/crates/opsail-refit-codex/assets/opsail-refit-codex-dom-adapter.js b/crates/opsail-refit-codex/assets/opsail-refit-codex-dom-adapter.js
new file mode 100644
index 0000000..6107af4
--- /dev/null
+++ b/crates/opsail-refit-codex/assets/opsail-refit-codex-dom-adapter.js
@@ -0,0 +1,209 @@
+const createOpsailRefitCodexDomAdapter = () => {
+ const VERSION = 1;
+ const SELECTORS = Object.freeze({
+ shell: "main.main-surface",
+ sidebar: "aside.app-shell-left-panel, aside[data-testid='app-shell-floating-left-panel']",
+ avatar: "img, [data-testid*='avatar' i], [class*='avatar' i]",
+ action: "button, [role='button']",
+ accountControl: "button, [role='button'], a",
+ });
+
+ const queryOne = (root, selector) => {
+ try {
+ return root?.querySelector?.(selector) || null;
+ } catch {
+ return null;
+ }
+ };
+
+ const findShell = (root = document) => queryOne(root, SELECTORS.shell);
+ const findSidebar = (root = document) => queryOne(root, SELECTORS.sidebar);
+
+ const nodeMayContainSidebar = (node) => {
+ if (!node || typeof node !== "object") return false;
+ try {
+ return Boolean(
+ node.matches?.(SELECTORS.sidebar)
+ || node.querySelector?.(SELECTORS.sidebar),
+ );
+ } catch {
+ return true;
+ }
+ };
+
+ const bridgeAvailable = () => (
+ typeof window.electronBridge?.sendMessageFromView === "function"
+ );
+
+ const languageCandidates = () => {
+ const systemLanguages = typeof navigator === "object"
+ ? [...(navigator.languages || []), navigator.language]
+ : [];
+ return [...new Set([
+ document.documentElement?.lang,
+ ...systemLanguages,
+ ].map((value) => String(value || "").trim()).filter(Boolean))];
+ };
+
+ const probeRenderer = () => ({
+ appProtocol: typeof location === "object" && location.protocol === "app:",
+ shell: Boolean(findShell()),
+ sidebar: Boolean(findSidebar()),
+ bridge: bridgeAvailable(),
+ domAdapterVersion: VERSION,
+ });
+
+ const elementRect = (element) => {
+ try {
+ const rect = element?.getBoundingClientRect?.();
+ if (!rect) return null;
+ const values = [rect.left, rect.top, rect.right, rect.bottom, rect.width, rect.height].map(Number);
+ if (!values.every(Number.isFinite)) return null;
+ return {
+ left: values[0],
+ top: values[1],
+ right: values[2],
+ bottom: values[3],
+ width: values[4],
+ height: values[5],
+ centerX: values[0] + values[4] / 2,
+ centerY: values[1] + values[5] / 2,
+ };
+ } catch {
+ return null;
+ }
+ };
+
+ const queryRects = (root, selector) => {
+ try {
+ return [...(root?.querySelectorAll?.(selector) || [])]
+ .map((element) => ({ element, rect: elementRect(element) }))
+ .filter((entry) => entry.rect);
+ } catch {
+ return [];
+ }
+ };
+
+ const nearestCommonAncestor = (left, right) => {
+ if (!left || !right) return null;
+ const ancestors = new Set();
+ for (let current = left; current; current = current.parentElement) ancestors.add(current);
+ for (let current = right; current; current = current.parentElement) {
+ if (ancestors.has(current)) return current;
+ }
+ return null;
+ };
+
+ const directChildContaining = (ancestor, descendant) => {
+ if (!ancestor || !descendant) return null;
+ let current = descendant;
+ while (current?.parentElement && current.parentElement !== ancestor) current = current.parentElement;
+ return current?.parentElement === ancestor ? current : null;
+ };
+
+ const closestAccountControl = (element) => {
+ try {
+ return element?.closest?.(SELECTORS.accountControl) || null;
+ } catch {
+ return null;
+ }
+ };
+
+ const measureNativeLayout = (sidebar, preferredRow = null) => {
+ const sidebarRect = elementRect(sidebar);
+ if (!sidebarRect) return null;
+ const footerTop = sidebarRect.bottom - Math.min(112, sidebarRect.height * 0.3);
+ const measureWithin = (root) => {
+ const avatars = queryRects(root, SELECTORS.avatar)
+ .filter(({ rect }) => rect.width >= 16 && rect.width <= 48
+ && rect.height >= 16 && rect.height <= 48
+ && Math.abs(rect.width - rect.height) <= 8
+ && rect.centerY >= footerTop
+ && rect.centerY <= sidebarRect.bottom
+ && rect.centerX >= sidebarRect.left
+ && rect.centerX <= sidebarRect.left + sidebarRect.width * 0.55)
+ .sort((left, right) => right.rect.centerY - left.rect.centerY);
+ const avatar = avatars[0] || null;
+ const actions = queryRects(root, SELECTORS.action)
+ .filter(({ rect }) => rect.width >= 20 && rect.width <= 48
+ && rect.height >= 20 && rect.height <= 48
+ && rect.centerY >= footerTop
+ && rect.centerY <= sidebarRect.bottom
+ && rect.centerX >= sidebarRect.left + sidebarRect.width * 0.5
+ && rect.centerX <= sidebarRect.right)
+ .sort((left, right) => {
+ const avatarCenterY = avatar?.rect.centerY ?? sidebarRect.bottom;
+ return Math.abs(left.rect.centerY - avatarCenterY)
+ - Math.abs(right.rect.centerY - avatarCenterY)
+ || right.rect.width * right.rect.height - left.rect.width * left.rect.height
+ || right.rect.centerY - left.rect.centerY
+ || right.rect.centerX - left.rect.centerX;
+ });
+ const accountControlElement = closestAccountControl(avatar?.element);
+ const accountControlRect = elementRect(accountControlElement);
+ const accountControl = accountControlElement && accountControlRect
+ ? { element: accountControlElement, rect: accountControlRect }
+ : null;
+ const layoutForAction = (trailingAction) => {
+ const row = nearestCommonAncestor(accountControlElement, trailingAction?.element);
+ const accountSlot = directChildContaining(row, accountControlElement);
+ const trailingSlot = directChildContaining(row, trailingAction?.element);
+ const rowRect = elementRect(row);
+ const inline = Boolean(
+ row && row !== sidebar && accountSlot && trailingSlot && accountSlot !== trailingSlot
+ && rowRect && rowRect.width >= 120 && rowRect.height >= 28 && rowRect.height <= 72
+ && Math.abs((avatar?.rect.centerY || 0) - (trailingAction?.rect.centerY || 0)) <= 16,
+ );
+ return { accountSlot, inline, row, trailingAction, trailingSlot };
+ };
+ const layouts = actions.map(layoutForAction);
+ const selected = layouts.find(({ inline }) => inline) || layouts[0] || layoutForAction(null);
+ return {
+ sidebarRect,
+ avatar,
+ accountControl,
+ trailingAction: selected.trailingAction,
+ row: selected.inline ? selected.row : null,
+ accountSlot: selected.inline ? selected.accountSlot : null,
+ trailingSlot: selected.inline ? selected.trailingSlot : null,
+ };
+ };
+ const preferredAvailable = preferredRow
+ && preferredRow !== sidebar
+ && preferredRow.isConnected
+ && sidebar.contains?.(preferredRow);
+ if (preferredAvailable) {
+ const measured = measureWithin(preferredRow);
+ if (measured.row) return measured;
+ }
+ return measureWithin(sidebar);
+ };
+
+ const nodeMayAffectLayout = (node) => {
+ if (!node || typeof node !== "object") return false;
+ try {
+ return Boolean(
+ node.matches?.(SELECTORS.avatar)
+ || node.matches?.(SELECTORS.action)
+ || node.querySelector?.(SELECTORS.avatar)
+ || node.querySelector?.(SELECTORS.action),
+ );
+ } catch {
+ return true;
+ }
+ };
+
+ return Object.freeze({
+ VERSION,
+ SELECTORS,
+ bridgeAvailable,
+ elementRect,
+ findShell,
+ findSidebar,
+ languageCandidates,
+ measureNativeLayout,
+ nodeMayAffectLayout,
+ nodeMayContainSidebar,
+ probeRenderer,
+ });
+};
diff --git a/crates/opsail-refit-codex/assets/opsail-refit-codex-renderer-control.js b/crates/opsail-refit-codex/assets/opsail-refit-codex-renderer-control.js
new file mode 100644
index 0000000..f04feaf
--- /dev/null
+++ b/crates/opsail-refit-codex/assets/opsail-refit-codex-renderer-control.js
@@ -0,0 +1,95 @@
+(() => {
+ const operation = __OPSAIL_REFIT_CODEX_OPERATION_JSON__;
+
+ if (operation === "probe") {
+ __OPSAIL_REFIT_CODEX_DOM_ADAPTER_SOURCE__
+ return createOpsailRefitCodexDomAdapter().probeRenderer();
+ }
+
+ if (operation === "early") {
+ __OPSAIL_REFIT_CODEX_DOM_ADAPTER_SOURCE__
+
+ const STATE_KEY = "__OPSAIL_REFIT_CODEX_EARLY_STATE__";
+ const GENERATION_KEY = "__OPSAIL_REFIT_CODEX_EARLY_GENERATION__";
+ const generation = __OPSAIL_REFIT_CODEX_EARLY_REVISION_JSON__;
+ const installToken = {};
+ const codexDom = createOpsailRefitCodexDomAdapter();
+ try { window[STATE_KEY]?.cleanup?.(); } catch {}
+ window[GENERATION_KEY] = generation;
+ let observer = null;
+ let timeout = null;
+ const cleanup = () => {
+ if (window[STATE_KEY]?.installToken !== installToken) return false;
+ observer?.disconnect();
+ observer = null;
+ if (timeout !== null) clearTimeout(timeout);
+ timeout = null;
+ delete window[STATE_KEY];
+ return true;
+ };
+ const install = () => {
+ if (window[GENERATION_KEY] !== generation) { cleanup(); return true; }
+ if (!document.documentElement) return false;
+ const probe = codexDom.probeRenderer();
+ if (!probe.appProtocol) { cleanup(); return true; }
+ if (!probe.bridge || !probe.shell || !probe.sidebar) return false;
+ cleanup();
+ __OPSAIL_REFIT_CODEX_CURRENT_PAYLOAD__;
+ return true;
+ };
+ window[STATE_KEY] = { cleanup, installToken };
+ if (!install()) {
+ if (typeof MutationObserver === "function" && document.documentElement) {
+ observer = new MutationObserver(install);
+ observer.observe(document.documentElement, { childList: true, subtree: true });
+ }
+ timeout = setTimeout(cleanup, 30000);
+ }
+ return undefined;
+ }
+
+ if (operation === "status") {
+ const runtime = window.__OPSAIL_REFIT_CODEX_STATE__;
+ let diagnostics = null;
+ try { diagnostics = runtime?.diagnostics?.() ?? null; } catch {}
+ return {
+ installed: Boolean(runtime && runtime.mode === "usage"),
+ revision: runtime?.revision ?? null,
+ expectedRevision: __OPSAIL_REFIT_CODEX_STATUS_REVISION_JSON__,
+ diagnostics,
+ hostCount: document.querySelectorAll("#opsail-refit-codex-usage").length,
+ styleCount: document.querySelectorAll("#opsail-refit-codex-usage-style").length,
+ detailsCount: document.querySelectorAll("#opsail-refit-codex-usage-details").length,
+ };
+ }
+
+ if (operation === "launch-notice") {
+ const runtime = window.__OPSAIL_REFIT_CODEX_STATE__;
+ let shown = false;
+ try { shown = Boolean(runtime?.showLaunchNotice?.()); } catch {}
+ return { shown };
+ }
+
+ if (operation === "disable") {
+ window.__OPSAIL_REFIT_CODEX_DISABLED__ = true;
+ window.__OPSAIL_REFIT_CODEX_EARLY_GENERATION__ = `disabled:${Date.now()}`;
+ try { window.__OPSAIL_REFIT_CODEX_EARLY_STATE__?.cleanup?.(); } catch {}
+ try { window.__OPSAIL_REFIT_CODEX_STATE__?.cleanup?.(); } catch {}
+ document.getElementById("opsail-refit-codex-usage")?.remove();
+ document.getElementById("opsail-refit-codex-usage-details")?.remove();
+ document.getElementById("opsail-refit-codex-usage-style")?.remove();
+ document.getElementById("opsail-refit-codex-launch-notice")?.remove();
+ document.documentElement?.classList.remove("opsail-refit-codex-usage-enabled");
+ delete window.__OPSAIL_REFIT_CODEX_STATE__;
+ delete window.__OPSAIL_REFIT_CODEX_EARLY_STATE__;
+ return {
+ clean: !document.getElementById("opsail-refit-codex-usage")
+ && !document.getElementById("opsail-refit-codex-usage-details")
+ && !document.getElementById("opsail-refit-codex-usage-style")
+ && !document.getElementById("opsail-refit-codex-launch-notice")
+ && !window.__OPSAIL_REFIT_CODEX_STATE__,
+ };
+ }
+
+ throw new Error("unsupported opsail renderer operation");
+})()
diff --git a/crates/opsail-refit-codex/assets/opsail-refit-codex-update.json b/crates/opsail-refit-codex/assets/opsail-refit-codex-update.json
new file mode 100644
index 0000000..c4bade0
--- /dev/null
+++ b/crates/opsail-refit-codex/assets/opsail-refit-codex-update.json
@@ -0,0 +1,27 @@
+{
+ "schemaVersion": 1,
+ "assetVersion": "1.0.0",
+ "apiVersion": 1,
+ "files": [
+ {
+ "name": "opsail-refit-codex-dom-adapter.js",
+ "sha256": "dccb687965575e4b1bbda76aed9c20699e4ca62a4c849677194be2ed3a64ce61",
+ "bytes": 7530
+ },
+ {
+ "name": "opsail-refit-codex-renderer-control.js",
+ "sha256": "d6612f795610c4ee8b3ba2d6614f9020355eb29eb984d72f50c98a3d0a2ccaa1",
+ "bytes": 3886
+ },
+ {
+ "name": "opsail-refit-codex-usage-model.js",
+ "sha256": "eb7cf0358ffb81fe6c2d8c59113fb2db807b4f74c85ce86aa99ca908100dd0fb",
+ "bytes": 21156
+ },
+ {
+ "name": "opsail-refit-codex-usage-runtime.js",
+ "sha256": "b3362d11793d6e54f6ccee628e921f74bdb51d09e98630aa5936f111d8970083",
+ "bytes": 39728
+ }
+ ]
+}
diff --git a/crates/opsail-refit-codex/assets/opsail-refit-codex-usage-model.js b/crates/opsail-refit-codex/assets/opsail-refit-codex-usage-model.js
new file mode 100644
index 0000000..b03e62f
--- /dev/null
+++ b/crates/opsail-refit-codex/assets/opsail-refit-codex-usage-model.js
@@ -0,0 +1,558 @@
+const createOpsailRefitCodexUsageModel = (localeBundle) => {
+ const REQUEST_TIMEOUT_MS = 15 * 1000;
+ const FOCUS_REFRESH_MIN_MS = 60 * 1000;
+ const NOTIFICATION_CALIBRATION_MS = 1200;
+ const VISIBLE_REFRESH_MS = 15 * 60 * 1000;
+ const REQUEST_ID_PREFIX = "opsail-refit-codex-rate-limits";
+ const MIN_INLINE_CAPSULE_WIDTH = 36;
+ const MIN_ACCOUNT_SLOT_WIDTH = 32;
+
+ const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value));
+ const normalizedLanguage = (value) => String(value || "").trim().replaceAll("_", "-").toLowerCase();
+ const formatLocaleTypography = (value, locale) => {
+ const text = String(value || "");
+ if (!normalizedLanguage(locale).startsWith("zh")) return text;
+ return text
+ .replace(/(\p{Script=Han})([A-Za-z0-9])/gu, "$1 $2")
+ .replace(/([A-Za-z0-9])(\p{Script=Han})/gu, "$1 $2")
+ .replace(/[ \t]+/g, " ")
+ .trim();
+ };
+ const formatMessage = (template, values = {}) => String(template || "").replace(
+ /\{([A-Za-z][A-Za-z0-9]*)\}/g,
+ (_match, key) => Object.hasOwn(values, key) ? String(values[key]) : "",
+ );
+
+ const localeCache = new Map();
+ const selectLocale = (...languageCandidates) => {
+ const locales = localeBundle?.locales && typeof localeBundle.locales === "object"
+ ? localeBundle.locales
+ : {};
+ const entries = Object.entries(locales);
+ const defaultKey = localeBundle?.defaultLocale && locales[localeBundle.defaultLocale]
+ ? localeBundle.defaultLocale
+ : entries[0]?.[0];
+ const defaultCopy = locales[defaultKey] || {};
+ const supported = new Map(
+ (Array.isArray(localeBundle?.supportedLocales)
+ ? localeBundle.supportedLocales
+ : Object.keys(locales))
+ .map((locale) => [normalizedLanguage(locale), locale]),
+ );
+ const mergedCopy = (entryKey, displayLocale) => {
+ const cacheKey = `${entryKey || defaultKey || ""}:${displayLocale || ""}`;
+ if (localeCache.has(cacheKey)) return localeCache.get(cacheKey);
+ const override = locales[entryKey] || {};
+ const copy = {
+ ...defaultCopy,
+ ...override,
+ locale: displayLocale || override.locale || defaultCopy.locale || defaultKey,
+ resetCreditCountdownUnits: {
+ ...(defaultCopy.resetCreditCountdownUnits || {}),
+ ...(override.resetCreditCountdownUnits || {}),
+ },
+ windowLabels: {
+ ...(defaultCopy.windowLabels || {}),
+ ...(override.windowLabels || {}),
+ },
+ summaryWindowLabels: {
+ ...(defaultCopy.summaryWindowLabels || {}),
+ ...(override.windowLabels || {}),
+ ...(override.summaryWindowLabels || {}),
+ },
+ };
+ localeCache.set(cacheKey, copy);
+ return copy;
+ };
+ for (const candidate of languageCandidates) {
+ const normalized = normalizedLanguage(candidate);
+ if (!normalized) continue;
+ const exact = entries.find(([key, value]) =>
+ normalizedLanguage(key) === normalized || normalizedLanguage(value?.locale) === normalized);
+ const displayLocale = supported.get(normalized) || String(candidate).replaceAll("_", "-");
+ if (exact) return mergedCopy(exact[0], displayLocale);
+ const base = normalized.split("-")[0];
+ const baseMatch = entries.find(([key, value]) =>
+ normalizedLanguage(key).split("-")[0] === base
+ || normalizedLanguage(value?.locale).split("-")[0] === base);
+ if (baseMatch) return mergedCopy(baseMatch[0], displayLocale);
+ if (supported.has(normalized)) return mergedCopy(defaultKey, displayLocale);
+ }
+ return mergedCopy(defaultKey, defaultCopy.locale || defaultKey);
+ };
+
+ const normalizeWindow = (value) => {
+ if (!value || typeof value !== "object") return null;
+ const presence = {
+ usedPercent: Object.hasOwn(value, "usedPercent"),
+ windowDurationMins: Object.hasOwn(value, "windowDurationMins"),
+ resetsAt: Object.hasOwn(value, "resetsAt"),
+ };
+ const usedPercent = value.usedPercent;
+ const duration = value.windowDurationMins;
+ const resetsAt = value.resetsAt;
+ return {
+ usedPercent: presence.usedPercent
+ && typeof usedPercent === "number"
+ && Number.isFinite(usedPercent)
+ ? clamp(usedPercent, 0, 100)
+ : null,
+ windowDurationMins: presence.windowDurationMins
+ && typeof duration === "number"
+ && Number.isFinite(duration)
+ && duration > 0
+ ? duration
+ : null,
+ resetsAt: presence.resetsAt
+ && typeof resetsAt === "number"
+ && Number.isFinite(resetsAt)
+ && resetsAt > 0
+ ? resetsAt
+ : null,
+ presence,
+ };
+ };
+
+ const normalizeSnapshot = (value) => {
+ if (!value || typeof value !== "object") return null;
+ const presence = {
+ primary: Object.hasOwn(value, "primary"),
+ secondary: Object.hasOwn(value, "secondary"),
+ };
+ if (!presence.primary && !presence.secondary) return null;
+ return {
+ primary: presence.primary ? normalizeWindow(value.primary) : null,
+ secondary: presence.secondary ? normalizeWindow(value.secondary) : null,
+ presence,
+ };
+ };
+
+ const mergeSnapshot = (current, incoming) => {
+ if (!incoming) return current || null;
+ if (!current) return incoming;
+ const mergeWindow = (currentWindow, incomingWindow, incomingPresent) => {
+ if (!incomingPresent) return currentWindow;
+ if (!incomingWindow) return null;
+ const currentPresence = currentWindow?.presence || {};
+ return {
+ usedPercent: incomingWindow.presence.usedPercent
+ ? incomingWindow.usedPercent
+ : currentWindow?.usedPercent ?? null,
+ windowDurationMins: incomingWindow.presence.windowDurationMins
+ ? incomingWindow.windowDurationMins
+ : currentWindow?.windowDurationMins ?? null,
+ resetsAt: incomingWindow.presence.resetsAt
+ ? incomingWindow.resetsAt
+ : currentWindow?.resetsAt ?? null,
+ presence: {
+ usedPercent: Boolean(
+ currentPresence.usedPercent || incomingWindow.presence.usedPercent
+ ),
+ windowDurationMins: Boolean(
+ currentPresence.windowDurationMins || incomingWindow.presence.windowDurationMins
+ ),
+ resetsAt: Boolean(currentPresence.resetsAt || incomingWindow.presence.resetsAt),
+ },
+ };
+ };
+ return {
+ primary: mergeWindow(current.primary, incoming.primary, incoming.presence.primary),
+ secondary: mergeWindow(current.secondary, incoming.secondary, incoming.presence.secondary),
+ presence: {
+ primary: Boolean(current.presence.primary || incoming.presence.primary),
+ secondary: Boolean(current.presence.secondary || incoming.presence.secondary),
+ },
+ };
+ };
+
+ const labelForDuration = (duration, copy, collection = "windowLabels") => {
+ const labels = copy?.[collection] || copy?.windowLabels || {};
+ if (duration !== null && Math.abs(duration - 300) <= 15) return labels.fiveHours;
+ if (duration !== null && Math.abs(duration - 10080) <= 504) return labels.weekly;
+ if (duration !== null && Math.abs(duration - 1440) <= 72) return labels.daily;
+ if (duration !== null && Math.abs(duration - 43200) <= 2160) return labels.monthly;
+ if (duration !== null && duration >= 60 && Number.isInteger(duration / 60)) {
+ return formatMessage(labels.hours, { value: duration / 60 });
+ }
+ if (duration !== null) return formatMessage(labels.minutes, { value: Math.round(duration) });
+ return labels.generic;
+ };
+
+ const formatReset = (resetsAt, copy, displayLocale, nowMs = Date.now()) => {
+ if (resetsAt === null) return null;
+ const value = new Date(resetsAt * 1000);
+ if (!Number.isFinite(value.getTime())) return null;
+ const locale = normalizedLanguage(displayLocale) ? displayLocale : undefined;
+ try {
+ const full = formatLocaleTypography(new Intl.DateTimeFormat(locale, {
+ dateStyle: "full",
+ timeStyle: "long",
+ }).format(value), locale);
+ const dateTime = formatLocalDateTime(value);
+ const countdown = formatResetCreditCountdown(
+ value.getTime() - (Number.isFinite(nowMs) ? nowMs : Date.now()),
+ copy,
+ );
+ return { dateTime, display: dateTime, full, ...(countdown || {}) };
+ } catch {
+ try {
+ const full = formatLocaleTypography(value.toLocaleString(locale), locale);
+ const dateTime = formatLocalDateTime(value);
+ const countdown = formatResetCreditCountdown(
+ value.getTime() - (Number.isFinite(nowMs) ? nowMs : Date.now()),
+ copy,
+ );
+ return { dateTime, display: dateTime, full, ...(countdown || {}) };
+ } catch {
+ return null;
+ }
+ }
+ };
+
+ const isPresentableWindow = (windowValue) => windowValue
+ && typeof windowValue.usedPercent === "number"
+ && Number.isFinite(windowValue.usedPercent);
+
+ const hasPresentableWindows = (snapshot) => [
+ snapshot?.primary,
+ snapshot?.secondary,
+ ].some(isPresentableWindow);
+
+ const presentWindows = (snapshot, copy, displayLocale, nowMs = Date.now()) => [
+ snapshot?.primary,
+ snapshot?.secondary,
+ ]
+ .filter(isPresentableWindow)
+ .sort((left, right) =>
+ (left.windowDurationMins ?? Number.MAX_SAFE_INTEGER)
+ - (right.windowDurationMins ?? Number.MAX_SAFE_INTEGER))
+ .map((windowValue) => {
+ const used = Math.round(windowValue.usedPercent);
+ const remaining = Math.round(100 - windowValue.usedPercent);
+ return {
+ label: labelForDuration(windowValue.windowDurationMins, copy),
+ summaryLabel: labelForDuration(
+ windowValue.windowDurationMins,
+ copy,
+ "summaryWindowLabels",
+ ),
+ used,
+ remaining,
+ reset: formatReset(windowValue.resetsAt, copy, displayLocale, nowMs),
+ };
+ });
+
+ const normalizeResetCredits = (value) => {
+ if (!value || typeof value !== "object" || !Array.isArray(value.credits)) return [];
+ return value.credits
+ .filter((credit) => credit
+ && typeof credit === "object"
+ && credit.status === "available"
+ && typeof credit.expiresAt === "number"
+ && Number.isFinite(credit.expiresAt)
+ && credit.expiresAt > 0)
+ .map((credit) => ({ expiresAt: credit.expiresAt }))
+ .sort((left, right) => left.expiresAt - right.expiresAt);
+ };
+
+ const normalizeResetCreditsUpdate = (value) => (
+ value && typeof value === "object" && Array.isArray(value.credits)
+ ? normalizeResetCredits(value)
+ : null
+ );
+
+ const formatResetCreditCountdown = (remainingMilliseconds, copy) => {
+ const units = copy?.resetCreditCountdownUnits;
+ if (!units || ![units.day, units.hour, units.minute, units.separator]
+ .every((value) => typeof value === "string")) return null;
+ if (!Number.isFinite(remainingMilliseconds) || remainingMilliseconds <= 0) return null;
+ const minuteMilliseconds = 60 * 1000;
+ const hourMilliseconds = 60 * minuteMilliseconds;
+ if (remainingMilliseconds < hourMilliseconds) {
+ const totalMinutes = Math.floor(remainingMilliseconds / minuteMilliseconds);
+ return {
+ countdown: `${totalMinutes}${units.minute}`,
+ nextUpdateMs: totalMinutes === 0
+ ? Math.max(1000, remainingMilliseconds)
+ : Math.max(1000, remainingMilliseconds - totalMinutes * minuteMilliseconds + 1),
+ };
+ }
+ const totalHours = Math.floor(remainingMilliseconds / hourMilliseconds);
+ const days = Math.floor(totalHours / 24);
+ const hours = totalHours % 24;
+ const parts = [];
+ if (days > 0) parts.push(`${days}${units.day}`);
+ if (hours > 0 || days === 0) parts.push(`${hours}${units.hour}`);
+ return {
+ countdown: parts.join(units.separator),
+ nextUpdateMs: Math.max(
+ 1000,
+ remainingMilliseconds - totalHours * hourMilliseconds + 1,
+ ),
+ };
+ };
+
+ const formatLocalDateTime = (value) => {
+ const part = (number, width = 2) => String(number).padStart(width, "0");
+ return `${part(value.getFullYear(), 4)}-${part(value.getMonth() + 1)}-${part(value.getDate())}`
+ + ` ${part(value.getHours())}:${part(value.getMinutes())}:${part(value.getSeconds())}`;
+ };
+
+ const presentResetCredits = (credits, copy, displayLocale, nowMs = Date.now()) => {
+ const locale = normalizedLanguage(displayLocale) ? displayLocale : undefined;
+ return (Array.isArray(credits) ? credits : []).flatMap((credit) => {
+ const expiresAt = credit?.expiresAt;
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) return [];
+ const value = new Date(expiresAt * 1000);
+ if (!Number.isFinite(value.getTime())) return [];
+ const countdown = formatResetCreditCountdown(
+ value.getTime() - (Number.isFinite(nowMs) ? nowMs : Date.now()),
+ copy,
+ );
+ if (!countdown) return [];
+ const dateTime = formatLocalDateTime(value);
+ let full = dateTime;
+ try {
+ full = formatLocaleTypography(new Intl.DateTimeFormat(locale, {
+ dateStyle: "full",
+ timeStyle: "long",
+ }).format(value), locale);
+ } catch {
+ try {
+ full = formatLocaleTypography(value.toLocaleString(locale), locale);
+ } catch {}
+ }
+ return [{ expiresAt, dateTime, full, ...countdown }];
+ });
+ };
+
+ const summaryFor = (windows, copy) => windows
+ .map((windowValue) => formatMessage(copy?.summaryItem, {
+ ...windowValue,
+ label: windowValue.summaryLabel || windowValue.label,
+ }))
+ .join(" / ");
+
+ const finiteRect = (rect) => {
+ if (!rect || ![rect.left, rect.top, rect.right, rect.bottom].every(Number.isFinite)) return null;
+ const width = Number.isFinite(rect.width) ? rect.width : rect.right - rect.left;
+ const height = Number.isFinite(rect.height) ? rect.height : rect.bottom - rect.top;
+ if (width < 0 || height < 0) return null;
+ return { ...rect, width, height };
+ };
+
+ const computeTooltipPlacement = ({ anchor, sidebar, viewport, tooltip, gap = 8 }) => {
+ const anchorRect = finiteRect(anchor);
+ const sidebarRect = finiteRect(sidebar);
+ const viewportRect = finiteRect(viewport);
+ if (!anchorRect || !sidebarRect || !viewportRect) return null;
+ const horizontalInset = 16;
+ const verticalInset = 8;
+ const maximumWidth = Math.max(1, Math.min(
+ 240,
+ viewportRect.width - horizontalInset * 2,
+ ));
+ const requestedWidth = Number.isFinite(tooltip?.width) && tooltip.width > 0
+ ? tooltip.width
+ : maximumWidth;
+ const width = Math.max(1, Math.min(requestedWidth, maximumWidth));
+ const maximumHeight = Math.max(1, Math.min(
+ viewportRect.height - verticalInset * 2,
+ ));
+ const requestedHeight = Number.isFinite(tooltip?.height) && tooltip.height > 0
+ ? tooltip.height
+ : 1;
+ const height = Math.min(requestedHeight, maximumHeight);
+ const viewportMinimumLeft = viewportRect.left + horizontalInset;
+ const viewportMaximumLeft = Math.max(
+ viewportMinimumLeft,
+ viewportRect.right - width - horizontalInset,
+ );
+ const sidebarMinimumLeft = Math.max(
+ sidebarRect.left + horizontalInset,
+ viewportMinimumLeft,
+ );
+ const sidebarMaximumLeft = Math.min(
+ sidebarRect.right - width - horizontalInset,
+ viewportMaximumLeft,
+ );
+ const attachedLeft = Math.max(sidebarMinimumLeft, anchorRect.right - width);
+ const left = sidebarMaximumLeft >= sidebarMinimumLeft
+ ? clamp(attachedLeft, sidebarMinimumLeft, sidebarMaximumLeft)
+ : clamp(attachedLeft, viewportMinimumLeft, viewportMaximumLeft);
+ const minimumTop = viewportRect.top + verticalInset;
+ const maximumTop = Math.max(
+ minimumTop,
+ viewportRect.bottom - height - verticalInset,
+ );
+ const above = anchorRect.top - height - gap;
+ const below = anchorRect.bottom + gap;
+ const preferredTop = above >= minimumTop ? above : below;
+ return {
+ left,
+ top: clamp(preferredTop, minimumTop, maximumTop),
+ width,
+ maximumHeight,
+ };
+ };
+
+ const canFitCapsule = ({ leftBoundary, rightBoundary, capsuleWidth, gap = 8 }) => [
+ leftBoundary,
+ rightBoundary,
+ capsuleWidth,
+ gap,
+ ].every(Number.isFinite) && rightBoundary - leftBoundary >= capsuleWidth + gap * 2;
+
+ const isSafeInlineCapsuleLayout = ({
+ accountSlot,
+ avatar,
+ host,
+ trailingSlot,
+ sidebar,
+ viewportBottom,
+ minimumHostWidth = MIN_INLINE_CAPSULE_WIDTH,
+ minimumAccountWidth = MIN_ACCOUNT_SLOT_WIDTH,
+ }) => {
+ const accountRect = finiteRect(accountSlot);
+ const avatarRect = finiteRect(avatar);
+ const hostRect = finiteRect(host);
+ const trailingRect = finiteRect(trailingSlot);
+ const sidebarRect = finiteRect(sidebar);
+ if (!accountRect || !avatarRect || !hostRect || !trailingRect || !sidebarRect) return false;
+ if (![viewportBottom, minimumHostWidth, minimumAccountWidth].every(Number.isFinite)) {
+ return false;
+ }
+ const maximumBottom = Math.min(viewportBottom, sidebarRect.bottom);
+ return accountRect.width >= minimumAccountWidth
+ && hostRect.width >= minimumHostWidth
+ && accountRect.left >= sidebarRect.left
+ && accountRect.right <= hostRect.left
+ && avatarRect.left >= accountRect.left
+ && avatarRect.right <= accountRect.right
+ && avatarRect.top >= accountRect.top
+ && avatarRect.bottom <= accountRect.bottom
+ && hostRect.right <= trailingRect.left
+ && trailingRect.right <= sidebarRect.right
+ && hostRect.left >= sidebarRect.left
+ && hostRect.right <= sidebarRect.right
+ && hostRect.top >= Math.max(0, sidebarRect.top)
+ && hostRect.bottom <= maximumBottom;
+ };
+
+ const createReadCoordinator = ({
+ now = () => Date.now(),
+ setTimer = setTimeout,
+ clearTimer = clearTimeout,
+ send,
+ onFailure = () => {},
+ }) => {
+ let disposed = false;
+ let sequence = 0;
+ let inFlight = null;
+ let requestTimeout = null;
+ let calibrationTimer = null;
+ let calibrationPending = false;
+ let calibrationReady = false;
+ let lastRequestedAt = Number.NEGATIVE_INFINITY;
+
+ const clearRequest = (requestId) => {
+ if (inFlight !== requestId) return false;
+ if (requestTimeout !== null) clearTimer(requestTimeout);
+ requestTimeout = null;
+ inFlight = null;
+ return true;
+ };
+
+ const request = () => {
+ if (disposed || inFlight !== null) return null;
+ const requestedAt = now();
+ const requestId = `${REQUEST_ID_PREFIX}:${requestedAt}:${++sequence}`;
+ inFlight = requestId;
+ lastRequestedAt = requestedAt;
+ requestTimeout = setTimer(() => {
+ if (!clearRequest(requestId)) return;
+ onFailure(requestId);
+ if (calibrationPending && calibrationReady) requestCalibration();
+ }, REQUEST_TIMEOUT_MS);
+ try {
+ Promise.resolve(send(requestId)).catch(() => {
+ if (!clearRequest(requestId)) return;
+ onFailure(requestId);
+ if (calibrationPending && calibrationReady) requestCalibration();
+ });
+ } catch {
+ if (clearRequest(requestId)) onFailure(requestId);
+ }
+ return requestId;
+ };
+
+ const requestCalibration = () => {
+ if (disposed || !calibrationPending || !calibrationReady || inFlight !== null) return null;
+ calibrationPending = false;
+ calibrationReady = false;
+ return request();
+ };
+
+ const finish = (requestId) => {
+ if (!clearRequest(requestId)) return false;
+ requestCalibration();
+ return true;
+ };
+
+ const scheduleCalibration = () => {
+ calibrationPending = true;
+ calibrationReady = false;
+ if (calibrationTimer !== null) clearTimer(calibrationTimer);
+ calibrationTimer = setTimer(() => {
+ calibrationTimer = null;
+ calibrationReady = true;
+ requestCalibration();
+ }, NOTIFICATION_CALIBRATION_MS);
+ };
+
+ const focus = () => now() - lastRequestedAt >= FOCUS_REFRESH_MIN_MS ? request() : null;
+ const visibleTick = (visible) => visible ? request() : null;
+ const dispose = () => {
+ disposed = true;
+ if (requestTimeout !== null) clearTimer(requestTimeout);
+ if (calibrationTimer !== null) clearTimer(calibrationTimer);
+ requestTimeout = null;
+ calibrationTimer = null;
+ inFlight = null;
+ calibrationPending = false;
+ };
+
+ return {
+ dispose,
+ finish,
+ focus,
+ request,
+ scheduleCalibration,
+ visibleTick,
+ inspect: () => ({ disposed, inFlight, lastRequestedAt, calibrationPending }),
+ };
+ };
+
+ return {
+ FOCUS_REFRESH_MIN_MS,
+ MIN_INLINE_CAPSULE_WIDTH,
+ NOTIFICATION_CALIBRATION_MS,
+ REQUEST_ID_PREFIX,
+ REQUEST_TIMEOUT_MS,
+ VISIBLE_REFRESH_MS,
+ canFitCapsule,
+ computeTooltipPlacement,
+ createReadCoordinator,
+ formatMessage,
+ hasPresentableWindows,
+ isSafeInlineCapsuleLayout,
+ mergeSnapshot,
+ normalizeResetCredits,
+ normalizeResetCreditsUpdate,
+ normalizeSnapshot,
+ presentResetCredits,
+ presentWindows,
+ selectLocale,
+ summaryFor,
+ };
+};
diff --git a/crates/opsail-refit-codex/assets/opsail-refit-codex-usage-runtime.js b/crates/opsail-refit-codex/assets/opsail-refit-codex-usage-runtime.js
new file mode 100644
index 0000000..f148c27
--- /dev/null
+++ b/crates/opsail-refit-codex/assets/opsail-refit-codex-usage-runtime.js
@@ -0,0 +1,1093 @@
+(() => {
+ __OPSAIL_REFIT_CODEX_MODEL_SOURCE__
+ __OPSAIL_REFIT_CODEX_DOM_ADAPTER_SOURCE__
+
+ const STATE_KEY = "__OPSAIL_REFIT_CODEX_STATE__";
+ const DISABLED_KEY = "__OPSAIL_REFIT_CODEX_DISABLED__";
+ const STYLE_ID = "opsail-refit-codex-usage-style";
+ const USAGE_ID = "opsail-refit-codex-usage";
+ const DETAILS_ID = "opsail-refit-codex-usage-details";
+ const NOTICE_ID = "opsail-refit-codex-launch-notice";
+ const ROOT_CLASS = "opsail-refit-codex-usage-enabled";
+ const HOST_ID = "local";
+ const CONFIG_READ_METHOD = "config/read";
+ const CONFIG_READ_STALE_MS = 15_000;
+ const CONFIG_READ_GATE_MS = 1_000;
+ const VERSION = __OPSAIL_REFIT_CODEX_VERSION_JSON__;
+ const REVISION = __OPSAIL_REFIT_CODEX_REVISION_JSON__;
+ const SESSION_MODE = __OPSAIL_REFIT_CODEX_SESSION_MODE_JSON__;
+ const MANAGER_TOKEN = __OPSAIL_REFIT_CODEX_MANAGER_TOKEN_JSON__;
+ const CSS_TEXT = __OPSAIL_REFIT_CODEX_CSS_JSON__;
+ const LOCALE_BUNDLE = __OPSAIL_REFIT_CODEX_LOCALES_JSON__;
+ const installToken = {};
+ const usageModel = createOpsailRefitCodexUsageModel(LOCALE_BUNDLE);
+ const codexDom = createOpsailRefitCodexDomAdapter();
+ let configuredLocale = null;
+ const resolveCopy = () => usageModel.selectLocale(
+ configuredLocale,
+ ...codexDom.languageCandidates(),
+ );
+ let copy = resolveCopy();
+ const syncLocale = () => {
+ const nextCopy = resolveCopy();
+ if (nextCopy === copy) return false;
+ copy = nextCopy;
+ return true;
+ };
+
+ try { window[STATE_KEY]?.cleanup?.(); } catch {}
+ document.getElementById(USAGE_ID)?.remove();
+ document.getElementById(DETAILS_ID)?.remove();
+ document.getElementById(STYLE_ID)?.remove();
+ document.getElementById(NOTICE_ID)?.remove();
+ document.documentElement?.classList.remove(ROOT_CLASS);
+ window[DISABLED_KEY] = false;
+
+ const state = {
+ disposed: false,
+ status: "loading",
+ snapshot: null,
+ startupCalibrationAttempted: false,
+ resetCredits: [],
+ resetCreditState: "not-observed",
+ presentResetCreditCount: 0,
+ host: null,
+ details: null,
+ parts: null,
+ sidebar: null,
+ row: null,
+ hasWindows: false,
+ detailsOpen: false,
+ closeTimer: null,
+ resetCountdownTimer: null,
+ refreshTimer: null,
+ notice: null,
+ noticeTimer: null,
+ localeRefreshOnAccountRecovery: false,
+ };
+ const metrics = {
+ ensureCalls: 0,
+ layoutCalls: 0,
+ localeRequests: 0,
+ usageRequests: 0,
+ usageUpdates: 0,
+ };
+ const listeners = [];
+ let mutationObserver = null;
+ let observedMutationAnchor;
+ let observedMutationTargets = [];
+ let accountRecoveryPath = [];
+ let resizeObserver = null;
+ let observedSidebar = null;
+ let observedRow = null;
+ let documentReadyListener = null;
+ let configRequestId = null;
+ let configRequestStartedAt = 0;
+ let lastConfigReadAt = 0;
+ let configRequestSequence = 0;
+ const scheduler = {
+ ensureTimeout: null,
+ frame: null,
+ timeout: null,
+ tooltipFrame: null,
+ tooltipFrameKind: null,
+ };
+
+ const addListener = (target, type, listener, options) => {
+ if (!target?.addEventListener) return;
+ target.addEventListener(type, listener, options);
+ listeners.push({ target, type, listener, options });
+ };
+
+ const removeListeners = () => {
+ for (const item of listeners.splice(0)) {
+ try { item.target.removeEventListener(item.type, item.listener, item.options); } catch {}
+ }
+ };
+
+ const setText = (node, value) => {
+ if (node && node.textContent !== value) node.textContent = value;
+ };
+
+ const createElement = (tagName, className = "") => {
+ const element = document.createElement(tagName);
+ if (className) element.className = className;
+ return element;
+ };
+
+ const normalizeConfiguredLocale = (value) => {
+ if (typeof value !== "string") return null;
+ const locale = value.trim();
+ if (locale.length === 0 || locale.length > 64) return null;
+ return /^[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*$/.test(locale) ? locale : null;
+ };
+
+ const requestConfiguredLocale = (force = false) => {
+ if (state.disposed || window[DISABLED_KEY]) return false;
+ const now = Date.now();
+ if (configRequestId && now - configRequestStartedAt < CONFIG_READ_STALE_MS) {
+ return false;
+ }
+ if (configRequestId) {
+ configRequestId = null;
+ configRequestStartedAt = 0;
+ }
+ if (!force && now - lastConfigReadAt < CONFIG_READ_GATE_MS) return false;
+ const bridge = window.electronBridge;
+ if (!bridge || typeof bridge.sendMessageFromView !== "function") return false;
+ const requestId = `opsail-refit-codex-config:${now}:${++configRequestSequence}`;
+ configRequestId = requestId;
+ configRequestStartedAt = now;
+ lastConfigReadAt = now;
+ try {
+ bridge.sendMessageFromView({
+ type: "mcp-request",
+ hostId: HOST_ID,
+ request: {
+ id: requestId,
+ method: CONFIG_READ_METHOD,
+ params: { includeLayers: false, cwd: null },
+ },
+ });
+ metrics.localeRequests += 1;
+ return true;
+ } catch {
+ if (configRequestId === requestId) {
+ configRequestId = null;
+ configRequestStartedAt = 0;
+ }
+ return false;
+ }
+ };
+
+ const refreshConfiguredLocaleAfterRecovery = () => {
+ if (!state.localeRefreshOnAccountRecovery || !state.row?.isConnected) return;
+ if (requestConfiguredLocale(true)) state.localeRefreshOnAccountRecovery = false;
+ };
+
+ const removeLaunchNotice = () => {
+ if (state.noticeTimer !== null) clearTimeout(state.noticeTimer);
+ state.noticeTimer = null;
+ state.notice?.remove();
+ document.getElementById(NOTICE_ID)?.remove();
+ state.notice = null;
+ };
+
+ const showLaunchNotice = () => {
+ if (state.disposed || window[DISABLED_KEY]) return false;
+ syncLocale();
+ removeLaunchNotice();
+ const notice = createElement("section", "opsail-refit-codex-launch-notice");
+ notice.id = NOTICE_ID;
+ notice.setAttribute("role", "status");
+ notice.setAttribute("aria-live", "polite");
+ notice.setAttribute("aria-atomic", "true");
+ const title = createElement("strong", "opsail-refit-codex-launch-notice-title");
+ const message = createElement("span", "opsail-refit-codex-launch-notice-message");
+ setText(title, copy.launchNoticeTitle);
+ setText(message, copy.launchNoticeMessage);
+ notice.append(title, message);
+ (document.body || document.documentElement).append(notice);
+ state.notice = notice;
+ state.noticeTimer = setTimeout(removeLaunchNotice, 2800);
+ return true;
+ };
+
+ const createRow = (index) => {
+ const row = createElement("div", "opsail-refit-codex-usage-row");
+ row.dataset.opsailRefitCodexWindowIndex = String(index);
+ const line = createElement("div", "opsail-refit-codex-usage-line");
+ const label = createElement("span");
+ const value = createElement("b");
+ line.append(label, value);
+ const meta = createElement("div", "opsail-refit-codex-usage-meta");
+ const track = createElement("div", "opsail-refit-codex-usage-track");
+ track.setAttribute("role", "progressbar");
+ const fill = createElement("i");
+ fill.setAttribute("aria-hidden", "true");
+ track.append(fill);
+ row.append(line, meta, track);
+ return { row, label, value, meta, track };
+ };
+
+ const scheduleTooltipPosition = () => {
+ if (!state.detailsOpen || scheduler.tooltipFrame !== null) return;
+ const position = () => {
+ scheduler.tooltipFrame = null;
+ scheduler.tooltipFrameKind = null;
+ if (!state.detailsOpen || !state.host || !state.details || !state.sidebar) return;
+ const anchor = codexDom.elementRect(state.host);
+ const sidebar = codexDom.elementRect(state.sidebar);
+ const tooltip = codexDom.elementRect(state.details);
+ const viewport = {
+ left: 0,
+ top: 0,
+ right: Number(window.innerWidth) || 0,
+ bottom: Number(window.innerHeight) || 0,
+ width: Number(window.innerWidth) || 0,
+ height: Number(window.innerHeight) || 0,
+ };
+ let placement = usageModel.computeTooltipPlacement({ anchor, sidebar, viewport, tooltip });
+ if (!placement) {
+ closeDetails();
+ return;
+ }
+ state.details.style.setProperty(
+ "--opsail-refit-usage-details-width",
+ `${Math.round(placement.width)}px`,
+ );
+ state.details.style.setProperty(
+ "--opsail-refit-usage-details-max-height",
+ `${Math.round(placement.maximumHeight)}px`,
+ );
+ placement = usageModel.computeTooltipPlacement({
+ anchor,
+ sidebar,
+ viewport,
+ tooltip: codexDom.elementRect(state.details),
+ });
+ if (!placement) {
+ closeDetails();
+ return;
+ }
+ state.details.style.setProperty("--opsail-refit-usage-details-left", `${Math.round(placement.left)}px`);
+ state.details.style.setProperty("--opsail-refit-usage-details-top", `${Math.round(placement.top)}px`);
+ state.details.style.setProperty("--opsail-refit-usage-details-width", `${Math.round(placement.width)}px`);
+ state.details.style.setProperty(
+ "--opsail-refit-usage-details-max-height",
+ `${Math.round(placement.maximumHeight)}px`,
+ );
+ };
+ if (typeof requestAnimationFrame === "function") {
+ scheduler.tooltipFrameKind = "animation";
+ scheduler.tooltipFrame = requestAnimationFrame(position);
+ } else {
+ scheduler.tooltipFrameKind = "timeout";
+ scheduler.tooltipFrame = setTimeout(position, 0);
+ }
+ };
+
+ const openDetails = () => {
+ if (!state.hasWindows || state.host?.hidden || !state.details) return;
+ if (state.closeTimer !== null) clearTimeout(state.closeTimer);
+ state.closeTimer = null;
+ state.detailsOpen = true;
+ state.details.dataset.opsailRefitCodexOpen = "true";
+ state.details.setAttribute("aria-hidden", "false");
+ render();
+ scheduleTooltipPosition();
+ };
+
+ const closeDetails = () => {
+ if (state.closeTimer !== null) clearTimeout(state.closeTimer);
+ state.closeTimer = null;
+ state.detailsOpen = false;
+ clearResetCreditCountdown();
+ if (state.details) {
+ state.details.dataset.opsailRefitCodexOpen = "false";
+ state.details.setAttribute("aria-hidden", "true");
+ }
+ };
+
+ const scheduleCloseDetails = () => {
+ if (state.closeTimer !== null) clearTimeout(state.closeTimer);
+ state.closeTimer = setTimeout(closeDetails, 80);
+ };
+
+ const createUi = () => {
+ const host = createElement("section", "opsail-refit-codex-usage-host");
+ host.id = USAGE_ID;
+ host.tabIndex = 0;
+ host.hidden = true;
+ host.setAttribute("aria-live", "polite");
+ host.setAttribute("aria-describedby", DETAILS_ID);
+ const summary = createElement("div", "opsail-refit-codex-usage-summary");
+ host.append(summary);
+
+ const details = createElement("aside");
+ details.id = DETAILS_ID;
+ details.dataset.opsailRefitCodexOpen = "false";
+ details.setAttribute("role", "tooltip");
+ details.setAttribute("aria-hidden", "true");
+ details.setAttribute("aria-label", copy.usageTitle);
+ const stale = createElement("div", "opsail-refit-codex-usage-stale");
+ stale.hidden = true;
+ const rows = [createRow(0), createRow(1)];
+ const resetCreditSection = createElement(
+ "section",
+ "opsail-refit-codex-reset-credits",
+ );
+ resetCreditSection.hidden = true;
+ const resetCreditTitle = createElement(
+ "div",
+ "opsail-refit-codex-reset-credits-title",
+ );
+ const resetCreditTable = createElement(
+ "table",
+ "opsail-refit-codex-reset-credits-table",
+ );
+ const resetCreditBody = createElement("tbody");
+ resetCreditTable.append(resetCreditBody);
+ resetCreditSection.append(resetCreditTitle, resetCreditTable);
+ const timeFormatNote = createElement("p", "opsail-refit-codex-time-format-note");
+ details.append(
+ stale,
+ ...rows.map((row) => row.row),
+ resetCreditSection,
+ timeFormatNote,
+ );
+ (document.body || document.documentElement).append(details);
+
+ addListener(host, "pointerenter", openDetails);
+ addListener(host, "pointerleave", scheduleCloseDetails);
+ addListener(host, "focusin", openDetails);
+ addListener(host, "focusout", (event) => {
+ if (event.relatedTarget && (host.contains(event.relatedTarget) || details.contains(event.relatedTarget))) return;
+ closeDetails();
+ });
+ addListener(details, "pointerenter", openDetails);
+ addListener(details, "pointerleave", scheduleCloseDetails);
+
+ state.host = host;
+ state.details = details;
+ state.parts = {
+ summary,
+ stale,
+ rows,
+ resetCreditSection,
+ resetCreditTitle,
+ resetCreditTable,
+ resetCreditBody,
+ timeFormatNote,
+ };
+ };
+
+ const ensureStyle = () => {
+ let style = document.getElementById(STYLE_ID);
+ if (!style) {
+ style = document.createElement("style");
+ style.id = STYLE_ID;
+ (document.head || document.documentElement).append(style);
+ }
+ if (style.textContent !== CSS_TEXT) style.textContent = CSS_TEXT;
+ style.dataset.opsailRefitCodexRevision = REVISION;
+ };
+
+ const hideHost = () => {
+ if (state.host) state.host.hidden = true;
+ closeDetails();
+ };
+
+ const rememberAccountRow = (row) => {
+ if (!row?.isConnected) return;
+ const path = [];
+ for (let current = row.parentElement; current; current = current.parentElement) {
+ path.push(current);
+ }
+ accountRecoveryPath = path;
+ };
+
+ const layout = () => {
+ metrics.layoutCalls += 1;
+ const host = state.host;
+ const sidebar = state.sidebar;
+ const previousRow = state.row;
+ if (previousRow && !previousRow.isConnected) {
+ state.localeRefreshOnAccountRecovery = true;
+ }
+ const wasInline = host?.dataset.opsailRefitCodexLayout === "inline"
+ && previousRow?.isConnected
+ && host.parentElement === previousRow;
+ state.row = null;
+ if (!host || !sidebar || !state.hasWindows) {
+ if (host && sidebar) {
+ const measured = codexDom.measureNativeLayout(sidebar, previousRow);
+ state.row = measured?.row?.isConnected ? measured.row : null;
+ rememberAccountRow(state.row);
+ refreshConfiguredLocaleAfterRecovery();
+ }
+ hideHost();
+ observeMutations();
+ observeGeometry();
+ return;
+ }
+
+ const measurementRoot = document.body || document.documentElement;
+ if (!wasInline && host.parentElement !== measurementRoot) measurementRoot.append(host);
+ host.dataset.opsailRefitCodexLayout = wasInline ? "inline" : "measuring";
+ host.hidden = false;
+ host.style.visibility = "hidden";
+ host.style.removeProperty("--opsail-refit-usage-left");
+ host.style.removeProperty("--opsail-refit-usage-top");
+ host.style.removeProperty("--opsail-refit-usage-inline-max-width");
+
+ const measured = codexDom.measureNativeLayout(sidebar, previousRow);
+ state.row = measured?.row?.isConnected ? measured.row : null;
+ rememberAccountRow(state.row);
+ refreshConfiguredLocaleAfterRecovery();
+ const hostRect = codexDom.elementRect(host);
+ const capsuleWidth = Math.max(
+ hostRect?.width || 0,
+ Number(state.parts?.summary?.scrollWidth) || 0,
+ );
+ let mounted = false;
+
+ if (measured?.row && measured.accountSlot && measured.trailingSlot && capsuleWidth > 0) {
+ const maximumInlineWidth = Math.max(
+ usageModel.MIN_INLINE_CAPSULE_WIDTH,
+ Math.min(112, measured.sidebarRect.width * 0.42),
+ );
+ host.style.setProperty(
+ "--opsail-refit-usage-inline-max-width",
+ `${Math.floor(maximumInlineWidth)}px`,
+ );
+ if (host.parentElement !== measured.row
+ || host.nextElementSibling !== measured.trailingSlot) {
+ measured.row.insertBefore(host, measured.trailingSlot);
+ }
+ host.dataset.opsailRefitCodexLayout = "inline";
+ mounted = usageModel.isSafeInlineCapsuleLayout({
+ accountSlot: codexDom.elementRect(measured.accountSlot),
+ avatar: codexDom.elementRect(measured.avatar?.element),
+ host: codexDom.elementRect(host),
+ trailingSlot: codexDom.elementRect(measured.trailingSlot),
+ sidebar: measured.sidebarRect,
+ viewportBottom: Number(window.innerHeight) || measured.sidebarRect.bottom,
+ });
+ }
+
+ if (!mounted && !measured?.row
+ && measured?.accountControl && measured.trailingAction && capsuleWidth > 0) {
+ const leftBoundary = measured.accountControl.rect.right;
+ const rightBoundary = measured.trailingAction.rect.left;
+ const hostHeight = hostRect?.height || 0;
+ const minimumTop = Math.max(0, measured.sidebarRect.top);
+ const maximumBottom = Math.min(
+ Number(window.innerHeight) || measured.sidebarRect.bottom,
+ measured.sidebarRect.bottom,
+ );
+ if (hostHeight > 0
+ && maximumBottom - minimumTop >= hostHeight
+ && usageModel.canFitCapsule({ leftBoundary, rightBoundary, capsuleWidth })) {
+ (document.body || document.documentElement).append(host);
+ host.dataset.opsailRefitCodexLayout = "fallback";
+ const left = Math.max(
+ measured.sidebarRect.left,
+ Math.min(
+ measured.sidebarRect.right - capsuleWidth,
+ leftBoundary + (rightBoundary - leftBoundary - capsuleWidth) / 2,
+ ),
+ );
+ const preferredTop = (
+ measured.accountControl.rect.centerY + measured.trailingAction.rect.centerY
+ ) / 2 - hostHeight / 2;
+ const top = Math.max(
+ minimumTop,
+ Math.min(maximumBottom - hostHeight, preferredTop),
+ );
+ host.style.setProperty("--opsail-refit-usage-left", `${Math.round(left)}px`);
+ host.style.setProperty("--opsail-refit-usage-top", `${Math.round(top)}px`);
+ const fallbackRect = codexDom.elementRect(host);
+ mounted = Boolean(
+ fallbackRect
+ && fallbackRect.left >= measured.sidebarRect.left
+ && fallbackRect.right <= measured.sidebarRect.right
+ && fallbackRect.left >= leftBoundary
+ && fallbackRect.right <= rightBoundary
+ && fallbackRect.top >= minimumTop
+ && fallbackRect.bottom <= maximumBottom,
+ );
+ }
+ }
+
+ host.style.visibility = "";
+ host.hidden = !mounted;
+ if (!mounted) closeDetails();
+ if (mounted) scheduleTooltipPosition();
+ observeMutations();
+ observeGeometry();
+ };
+
+ const flushLayout = () => {
+ if (scheduler.frame !== null && typeof cancelAnimationFrame === "function") {
+ cancelAnimationFrame(scheduler.frame);
+ }
+ if (scheduler.timeout !== null) clearTimeout(scheduler.timeout);
+ scheduler.frame = null;
+ scheduler.timeout = null;
+ layout();
+ };
+
+ const scheduleLayout = () => {
+ if (state.disposed || scheduler.frame !== null || scheduler.timeout !== null) return;
+ if (typeof requestAnimationFrame === "function") {
+ scheduler.frame = requestAnimationFrame(flushLayout);
+ scheduler.timeout = setTimeout(flushLayout, 96);
+ } else {
+ scheduler.timeout = setTimeout(flushLayout, 64);
+ }
+ };
+
+ const observeGeometry = () => {
+ if (typeof ResizeObserver !== "function") return;
+ const nextSidebar = state.sidebar?.isConnected ? state.sidebar : null;
+ const nextRow = state.row?.isConnected && state.row !== nextSidebar ? state.row : null;
+ if (!resizeObserver) resizeObserver = new ResizeObserver(scheduleLayout);
+ if (observedSidebar === nextSidebar && observedRow === nextRow) return;
+ resizeObserver.disconnect();
+ observedSidebar = nextSidebar;
+ observedRow = nextRow;
+ if (observedSidebar) resizeObserver.observe(observedSidebar);
+ if (observedRow) resizeObserver.observe(observedRow);
+ };
+
+ const clearResetCreditCountdown = () => {
+ if (state.resetCountdownTimer !== null) clearTimeout(state.resetCountdownTimer);
+ state.resetCountdownTimer = null;
+ };
+
+ const scheduleDetailCountdown = (windows, resetCredits) => {
+ clearResetCreditCountdown();
+ if (state.disposed
+ || !state.detailsOpen
+ || document.visibilityState === "hidden"
+ ) return;
+ const updates = [
+ ...(Array.isArray(windows) ? windows : [])
+ .map((windowValue) => windowValue.reset?.nextUpdateMs),
+ ...(Array.isArray(resetCredits) ? resetCredits : [])
+ .map((credit) => credit.nextUpdateMs),
+ ].filter(Number.isFinite);
+ if (updates.length === 0) return;
+ const delay = Math.max(1000, Math.min(...updates));
+ if (!Number.isFinite(delay)) return;
+ state.resetCountdownTimer = setTimeout(() => {
+ state.resetCountdownTimer = null;
+ if (document.visibilityState !== "hidden") render();
+ }, delay);
+ };
+
+ const refreshResetCreditObservation = (presentCount) => {
+ if (state.resetCreditState === "not-observed") {
+ state.presentResetCreditCount = 0;
+ return;
+ }
+ const count = Number.isInteger(presentCount) && presentCount >= 0
+ ? presentCount
+ : state.resetCredits.filter((credit) => (
+ Number.isFinite(credit?.expiresAt) && credit.expiresAt * 1000 > Date.now()
+ )).length;
+ state.presentResetCreditCount = count;
+ state.resetCreditState = count > 0 ? "available" : "empty";
+ };
+
+ const renderResetCredits = () => {
+ if (!state.parts) return [];
+ const resetCredits = usageModel.presentResetCredits(
+ state.resetCredits,
+ copy,
+ copy.locale,
+ );
+ refreshResetCreditObservation(resetCredits.length);
+ state.parts.resetCreditSection.hidden = resetCredits.length === 0;
+ state.parts.resetCreditSection.setAttribute("aria-label", copy.resetCreditsTitle);
+ state.parts.resetCreditTable.setAttribute("aria-label", copy.resetCreditsTitle);
+ setText(state.parts.resetCreditTitle, copy.resetCreditsTitle);
+ const resetCreditRows = resetCredits.map((credit) => ({
+ ariaCountdown: usageModel.formatMessage(copy.resetCreditCountdown, credit),
+ ariaExpiry: usageModel.formatMessage(copy.resetCreditExpires, {
+ ...credit,
+ dateTime: credit.full,
+ }),
+ credit,
+ }));
+ const resetCreditsKey = `${copy.locale}:${resetCreditRows
+ .map(({ ariaCountdown, ariaExpiry, credit }) => (
+ `${credit.expiresAt}:${credit.dateTime}:${credit.countdown}:${ariaExpiry}:${ariaCountdown}`
+ ))
+ .join("|")}`;
+ if (state.parts.resetCreditBody.dataset.opsailRefitCodexCredits !== resetCreditsKey) {
+ while (state.parts.resetCreditBody.children.length > 0) {
+ state.parts.resetCreditBody.children[0].remove();
+ }
+ for (const { ariaCountdown, ariaExpiry, credit } of resetCreditRows) {
+ const item = createElement("tr", "opsail-refit-codex-reset-credits-row");
+ const expiryCell = createElement(
+ "td",
+ "opsail-refit-codex-reset-credits-expiry",
+ );
+ const countdownCell = createElement(
+ "td",
+ "opsail-refit-codex-reset-credits-countdown",
+ );
+ setText(expiryCell, credit.dateTime);
+ setText(countdownCell, credit.countdown);
+ item.append(expiryCell, countdownCell);
+ item.setAttribute("aria-label", usageModel.formatMessage(copy.resetCreditAria, {
+ countdown: ariaCountdown,
+ expiry: ariaExpiry,
+ }));
+ state.parts.resetCreditBody.append(item);
+ }
+ state.parts.resetCreditBody.dataset.opsailRefitCodexCredits = resetCreditsKey;
+ }
+ return resetCredits;
+ };
+
+ const render = () => {
+ syncLocale();
+ if (!state.parts || !state.host || !state.details) return;
+ const windows = usageModel.presentWindows(state.snapshot, copy, copy.locale);
+ const resetCredits = renderResetCredits();
+ const stale = state.status === "stale" && windows.length > 0;
+ state.hasWindows = windows.length > 0;
+ state.details.setAttribute("aria-label", copy.usageTitle);
+ state.host.dataset.opsailRefitCodexStale = String(stale);
+ state.host.dataset.opsailRefitCodexState = state.status;
+ state.parts.stale.hidden = !stale;
+ setText(state.parts.stale, stale ? copy.stale : "");
+ setText(state.parts.timeFormatNote, copy.timeFormatNote);
+ if (windows.length === 0) {
+ clearResetCreditCountdown();
+ setText(state.parts.summary, "");
+ for (const row of state.parts.rows) row.row.hidden = true;
+ hideHost();
+ return;
+ }
+
+ const summary = usageModel.summaryFor(windows, copy);
+ setText(state.parts.summary, summary);
+ state.host.setAttribute("aria-label", usageModel.formatMessage(copy.ariaSummary, { summary }));
+ for (let index = 0; index < state.parts.rows.length; index += 1) {
+ const row = state.parts.rows[index];
+ const windowValue = windows[index];
+ if (!windowValue) {
+ row.row.hidden = true;
+ continue;
+ }
+ row.row.hidden = false;
+ setText(row.label, windowValue.label);
+ setText(row.value, usageModel.formatMessage(copy.remaining, windowValue));
+ const resetCountdownLine = windowValue.reset?.countdown
+ ? usageModel.formatMessage(copy.windowResetCountdown, windowValue.reset)
+ : null;
+ const resetLine = windowValue.reset
+ ? usageModel.formatMessage(copy.windowReset, windowValue.reset)
+ : null;
+ setText(row.meta, [
+ usageModel.formatMessage(copy.used, windowValue),
+ resetCountdownLine,
+ resetLine,
+ ].filter(Boolean).join("\n"));
+ if (windowValue.reset) {
+ row.meta.setAttribute("aria-label", usageModel.formatMessage(copy.ariaMeta, {
+ ...windowValue,
+ time: windowValue.reset.full,
+ }));
+ } else {
+ row.meta.removeAttribute?.("aria-label");
+ }
+ row.track.style.setProperty("--opsail-refit-usage-remaining", `${windowValue.remaining}%`);
+ row.track.setAttribute("aria-label", usageModel.formatMessage(copy.ariaProgress, windowValue));
+ row.track.setAttribute("aria-valuemin", "0");
+ row.track.setAttribute("aria-valuemax", "100");
+ row.track.setAttribute("aria-valuenow", String(windowValue.remaining));
+ }
+ scheduleDetailCountdown(windows, resetCredits);
+ scheduleLayout();
+ };
+
+ const hasPresentableSnapshot = () => usageModel.hasPresentableWindows(state.snapshot);
+
+ const scheduleStartupCalibration = () => {
+ if (hasPresentableSnapshot() || state.startupCalibrationAttempted) return false;
+ state.startupCalibrationAttempted = true;
+ coordinator.scheduleCalibration();
+ return true;
+ };
+
+ const markReadFailure = () => {
+ const hasSnapshot = hasPresentableSnapshot();
+ if (!hasSnapshot) scheduleStartupCalibration();
+ state.status = hasSnapshot ? "stale" : "unavailable";
+ render();
+ };
+
+ const bridgeSend = (requestId) => {
+ const bridge = window.electronBridge;
+ if (!bridge || typeof bridge.sendMessageFromView !== "function") {
+ throw new Error("opsail-refit-codex-bridge-unavailable");
+ }
+ metrics.usageRequests += 1;
+ return bridge.sendMessageFromView({
+ type: "mcp-request",
+ hostId: HOST_ID,
+ request: { id: requestId, method: "account/rateLimits/read" },
+ });
+ };
+
+ const coordinator = usageModel.createReadCoordinator({
+ send: bridgeSend,
+ onFailure: markReadFailure,
+ });
+
+ const mergeResetCredits = (value) => {
+ const resetCredits = usageModel.normalizeResetCreditsUpdate(value);
+ if (resetCredits === null) return false;
+ state.resetCredits = resetCredits;
+ state.resetCreditState = resetCredits.length > 0 ? "available" : "empty";
+ return true;
+ };
+
+ const mergeUsagePayload = (result, {
+ mergeWindows = false,
+ } = {}) => {
+ const snapshot = usageModel.normalizeSnapshot(result?.rateLimits);
+ const resetCreditsAccepted = mergeResetCredits(result?.rateLimitResetCredits);
+ if (snapshot) {
+ state.snapshot = mergeWindows
+ ? usageModel.mergeSnapshot(state.snapshot, snapshot)
+ : snapshot;
+ }
+ const hasSnapshot = hasPresentableSnapshot();
+ if (hasSnapshot) state.startupCalibrationAttempted = true;
+ else if (snapshot && !mergeWindows) scheduleStartupCalibration();
+ const accepted = Boolean(snapshot) || resetCreditsAccepted;
+ if (accepted) {
+ state.status = hasSnapshot ? "ready" : "unavailable";
+ metrics.usageUpdates += 1;
+ render();
+ }
+ return accepted;
+ };
+
+ const handleMessage = (event) => {
+ try {
+ const payload = event?.data;
+ if (!payload || payload.hostId !== HOST_ID) return;
+ if (payload.type === "mcp-response") {
+ const message = payload.message;
+ const messageId = String(message?.id || "");
+ if (messageId === configRequestId) {
+ configRequestId = null;
+ configRequestStartedAt = 0;
+ if (!message?.error) {
+ const nextLocale = normalizeConfiguredLocale(
+ message?.result?.config?.desktop?.localeOverride,
+ );
+ if (configuredLocale !== nextLocale) {
+ configuredLocale = nextLocale;
+ render();
+ }
+ }
+ refreshConfiguredLocaleAfterRecovery();
+ return;
+ }
+ if (!coordinator.finish(messageId)) return;
+ if (message?.error) {
+ markReadFailure();
+ return;
+ }
+ if (!mergeUsagePayload(message?.result)) {
+ markReadFailure();
+ }
+ return;
+ }
+ if (payload.type !== "mcp-notification" || payload.method !== "account/rateLimits/updated") return;
+ mergeUsagePayload(payload.params, { mergeWindows: true });
+ coordinator.scheduleCalibration();
+ } catch {
+ markReadFailure();
+ }
+ };
+
+ const waitForDocument = () => {
+ if ((document.documentElement && document.readyState === "complete")
+ || documentReadyListener !== null) return;
+ documentReadyListener = () => {
+ if (!document.documentElement) return;
+ window.removeEventListener("load", documentReadyListener);
+ documentReadyListener = null;
+ ensure();
+ requestConfiguredLocale(true);
+ if (!hasPresentableSnapshot()) coordinator.request();
+ };
+ window.addEventListener("load", documentReadyListener);
+ };
+
+ const ensure = () => {
+ if (state.disposed || window[DISABLED_KEY]) return;
+ if (!document.documentElement) {
+ waitForDocument();
+ return;
+ }
+ metrics.ensureCalls += 1;
+ ensureStyle();
+ document.documentElement?.classList.add(ROOT_CLASS);
+ if (!state.host || !state.details) createUi();
+ if (!mutationObserver && typeof MutationObserver === "function") {
+ mutationObserver = new MutationObserver(handleMutations);
+ observeMutations();
+ }
+ const sidebar = codexDom.findSidebar(document);
+ if (!sidebar) {
+ const sidebarChanged = state.sidebar !== null;
+ if (state.row || accountRecoveryPath.length > 0) {
+ state.localeRefreshOnAccountRecovery = true;
+ }
+ state.sidebar = null;
+ state.row = null;
+ if (sidebarChanged) {
+ observedMutationAnchor = undefined;
+ observedMutationTargets = [];
+ observeMutations();
+ }
+ observeGeometry();
+ hideHost();
+ return;
+ }
+ if (state.sidebar !== sidebar) {
+ state.sidebar = sidebar;
+ state.row = null;
+ observedMutationAnchor = undefined;
+ observedMutationTargets = [];
+ observeMutations();
+ observeGeometry();
+ }
+ render();
+ };
+
+ const scheduleEnsure = () => {
+ if (state.disposed || scheduler.ensureTimeout !== null) return;
+ scheduler.ensureTimeout = setTimeout(() => {
+ scheduler.ensureTimeout = null;
+ ensure();
+ }, 96);
+ };
+
+ const observeMutations = () => {
+ if (!mutationObserver || !document.documentElement) return;
+ const nextSidebar = state.sidebar?.isConnected ? state.sidebar : null;
+ const nextRow = state.row?.isConnected ? state.row : null;
+ const sidebarMissing = !nextSidebar;
+ const recoveryAnchor = (sidebarMissing ? document.body : null)
+ || accountRecoveryPath.find((element) => (
+ element?.isConnected
+ && (element === nextSidebar || nextSidebar?.contains?.(element))
+ ))
+ || nextSidebar
+ || accountRecoveryPath.find((element) => element?.isConnected)
+ || document.body
+ || document.documentElement;
+ const nextAnchor = nextRow || recoveryAnchor;
+ const nextTargets = [];
+ if (nextRow) nextTargets.push({ subtree: true, target: nextRow });
+ const bootstrapping = !nextRow && accountRecoveryPath.length === 0;
+ if ((sidebarMissing || bootstrapping) && recoveryAnchor) {
+ nextTargets.push({ subtree: true, target: recoveryAnchor });
+ }
+ for (
+ let current = sidebarMissing || bootstrapping
+ ? recoveryAnchor?.parentElement
+ : nextRow?.parentElement || recoveryAnchor;
+ current && current !== document.documentElement;
+ current = current.parentElement
+ ) {
+ if (!nextTargets.some((entry) => entry.target === current)) {
+ nextTargets.push({ subtree: false, target: current });
+ }
+ }
+ nextTargets.push({ subtree: false, target: document.documentElement });
+ const unchanged = observedMutationAnchor === nextAnchor
+ && observedMutationTargets.length === nextTargets.length
+ && observedMutationTargets.every((entry, index) => (
+ entry.target === nextTargets[index].target
+ && entry.subtree === nextTargets[index].subtree
+ ));
+ if (unchanged) return;
+ mutationObserver.disconnect();
+ observedMutationAnchor = nextAnchor;
+ observedMutationTargets = nextTargets;
+ for (const { subtree, target } of nextTargets) {
+ if (target === document.documentElement) {
+ mutationObserver.observe(target, {
+ attributes: true,
+ attributeFilter: ["lang"],
+ childList: true,
+ });
+ } else {
+ mutationObserver.observe(target, { childList: true, subtree });
+ }
+ }
+ };
+
+ const handleMutations = (records) => {
+ if ([...(records || [])].some((record) => record.type === "attributes"
+ && record.target === document.documentElement
+ && record.attributeName === "lang")) {
+ render();
+ return;
+ }
+ const mutationNodes = [...(records || [])].flatMap((record) => [
+ ...(record.addedNodes || []),
+ ...(record.removedNodes || []),
+ ]);
+ if (!state.sidebar?.isConnected) {
+ if (state.row || accountRecoveryPath.length > 0) {
+ state.localeRefreshOnAccountRecovery = true;
+ }
+ state.sidebar = null;
+ state.row = null;
+ observeMutations();
+ if (mutationNodes.some(codexDom.nodeMayContainSidebar)) {
+ scheduleEnsure();
+ }
+ return;
+ }
+ if (state.row && !state.row.isConnected) {
+ state.localeRefreshOnAccountRecovery = true;
+ state.row = null;
+ observeMutations();
+ scheduleEnsure();
+ return;
+ }
+ if (!state.row?.isConnected && accountRecoveryPath.length === 0) {
+ if (mutationNodes.some(codexDom.nodeMayAffectLayout)) scheduleEnsure();
+ return;
+ }
+ if (state.hasWindows && !state.host?.isConnected) {
+ scheduleEnsure();
+ return;
+ }
+ for (const record of records || []) {
+ const changedNodes = [...(record.addedNodes || []), ...(record.removedNodes || [])];
+ if (!state.row?.isConnected) {
+ const touchesRecoveryAnchor = record.target === observedMutationAnchor
+ || changedNodes.some((node) => (
+ node === observedMutationAnchor
+ || node?.contains?.(observedMutationAnchor)
+ || node === state.sidebar
+ || node?.contains?.(state.sidebar)
+ ));
+ if (touchesRecoveryAnchor) {
+ scheduleEnsure();
+ return;
+ }
+ continue;
+ }
+ const inAccountRow = record.target === state.row || state.row.contains?.(record.target);
+ if (!inAccountRow) continue;
+ for (const node of changedNodes) {
+ if (codexDom.nodeMayAffectLayout(node)) {
+ scheduleLayout();
+ return;
+ }
+ }
+ }
+ };
+
+ const cleanup = () => {
+ if (window[STATE_KEY]?.installToken !== installToken) return false;
+ window[DISABLED_KEY] = true;
+ state.disposed = true;
+ configRequestId = null;
+ configRequestStartedAt = 0;
+ if (documentReadyListener !== null) {
+ window.removeEventListener("load", documentReadyListener);
+ documentReadyListener = null;
+ }
+ coordinator.dispose();
+ mutationObserver?.disconnect();
+ observedMutationAnchor = undefined;
+ observedMutationTargets = [];
+ accountRecoveryPath = [];
+ resizeObserver?.disconnect();
+ observedSidebar = null;
+ observedRow = null;
+ removeListeners();
+ if (state.refreshTimer !== null) clearInterval(state.refreshTimer);
+ if (state.closeTimer !== null) clearTimeout(state.closeTimer);
+ clearResetCreditCountdown();
+ removeLaunchNotice();
+ if (scheduler.ensureTimeout !== null) clearTimeout(scheduler.ensureTimeout);
+ if (scheduler.timeout !== null) clearTimeout(scheduler.timeout);
+ if (scheduler.frame !== null && typeof cancelAnimationFrame === "function") {
+ cancelAnimationFrame(scheduler.frame);
+ }
+ if (scheduler.tooltipFrame !== null) {
+ if (scheduler.tooltipFrameKind === "animation"
+ && typeof cancelAnimationFrame === "function") {
+ cancelAnimationFrame(scheduler.tooltipFrame);
+ } else if (scheduler.tooltipFrameKind === "timeout") {
+ clearTimeout(scheduler.tooltipFrame);
+ }
+ }
+ scheduler.tooltipFrame = null;
+ scheduler.tooltipFrameKind = null;
+ state.host?.remove();
+ state.details?.remove();
+ document.getElementById(STYLE_ID)?.remove();
+ document.documentElement?.classList.remove(ROOT_CLASS);
+ delete window[STATE_KEY];
+ return true;
+ };
+
+ addListener(window, "message", handleMessage);
+ addListener(window, "focus", () => {
+ requestConfiguredLocale();
+ coordinator.focus();
+ render();
+ });
+ addListener(window, "resize", () => {
+ scheduleLayout();
+ scheduleTooltipPosition();
+ });
+ addListener(window, "scroll", scheduleTooltipPosition, { capture: true, passive: true });
+ state.refreshTimer = setInterval(
+ () => coordinator.visibleTick(document.visibilityState !== "hidden"),
+ usageModel.VISIBLE_REFRESH_MS,
+ );
+ window[STATE_KEY] = {
+ cleanup,
+ ensure,
+ installToken,
+ mode: "usage",
+ sessionMode: SESSION_MODE,
+ managerToken: MANAGER_TOKEN,
+ showLaunchNotice,
+ revision: REVISION,
+ version: VERSION,
+ diagnostics: () => {
+ refreshResetCreditObservation();
+ return {
+ installed: true,
+ mode: "usage",
+ sessionMode: SESSION_MODE,
+ managerToken: MANAGER_TOKEN,
+ revision: REVISION,
+ domAdapterVersion: codexDom.VERSION,
+ language: copy.locale,
+ hostCount: document.querySelectorAll(`#${USAGE_ID}`).length,
+ styleCount: document.querySelectorAll(`#${STYLE_ID}`).length,
+ detailsCount: document.querySelectorAll(`#${DETAILS_ID}`).length,
+ listenerCount: listeners.length,
+ mutationObserver: Boolean(mutationObserver),
+ resizeObserver: Boolean(resizeObserver),
+ refreshTimer: state.refreshTimer !== null,
+ resetCountdownTimer: state.resetCountdownTimer !== null,
+ bridgeAvailable: typeof window.electronBridge?.sendMessageFromView === "function",
+ dataState: state.status,
+ visible: Boolean(state.hasWindows && state.host?.isConnected && !state.host.hidden),
+ stale: state.status === "stale",
+ resetCreditCount: state.presentResetCreditCount,
+ resetCreditState: state.resetCreditState,
+ };
+ },
+ metrics,
+ };
+ waitForDocument();
+ ensure();
+ requestConfiguredLocale(true);
+ coordinator.request();
+ return window[STATE_KEY].diagnostics();
+})();
diff --git a/crates/opsail-refit-codex/assets/opsail-refit-codex-usage.css b/crates/opsail-refit-codex/assets/opsail-refit-codex-usage.css
new file mode 100644
index 0000000..8346f68
--- /dev/null
+++ b/crates/opsail-refit-codex/assets/opsail-refit-codex-usage.css
@@ -0,0 +1,311 @@
+#opsail-refit-codex-usage {
+ box-sizing: border-box;
+ z-index: var(--opsail-refit-usage-layer, 40);
+ display: block;
+ flex: 0 0 auto;
+ min-width: 0;
+ max-width: 100%;
+ color: var(--color-token-foreground, var(--color-text-foreground, currentColor));
+ cursor: default;
+ font: inherit;
+ font-size: 10px;
+ line-height: 16px;
+ outline: none;
+ pointer-events: auto;
+}
+
+#opsail-refit-codex-usage[data-opsail-refit-codex-layout="fallback"] {
+ position: fixed;
+ top: var(--opsail-refit-usage-top, 0);
+ left: var(--opsail-refit-usage-left, 0);
+}
+
+#opsail-refit-codex-usage[data-opsail-refit-codex-layout="measuring"] {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: max-content;
+ max-width: none;
+ pointer-events: none;
+}
+
+#opsail-refit-codex-usage[data-opsail-refit-codex-layout="inline"] {
+ flex: 0 1 auto;
+ min-width: 36px;
+ max-width: var(--opsail-refit-usage-inline-max-width, 112px);
+ overflow: hidden;
+}
+
+#opsail-refit-codex-usage[hidden],
+.opsail-refit-codex-usage-row[hidden],
+.opsail-refit-codex-reset-credits[hidden] {
+ display: none !important;
+}
+
+.opsail-refit-codex-usage-summary {
+ box-sizing: border-box;
+ max-width: 100%;
+ padding: 2px 8px;
+ overflow: hidden;
+ border: 1px solid var(--color-token-border-light, var(--color-border-light, currentColor));
+ border-radius: 999px;
+ background: var(--color-token-list-hover-background, var(--color-background-button-tertiary, transparent));
+ color: var(--color-token-text-secondary, var(--color-text-foreground-secondary, currentColor));
+ cursor: default;
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+ font-weight: 550;
+ letter-spacing: 0;
+ line-height: 16px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+#opsail-refit-codex-usage:focus-visible .opsail-refit-codex-usage-summary {
+ outline: 1px solid var(--color-token-focus-ring, var(--color-token-interactive-label-accent-default, currentColor));
+ outline-offset: 2px;
+}
+
+#opsail-refit-codex-usage[data-opsail-refit-codex-stale="true"] .opsail-refit-codex-usage-summary {
+ border-style: dashed;
+}
+
+#opsail-refit-codex-usage-details {
+ box-sizing: border-box;
+ position: fixed;
+ z-index: var(--opsail-refit-usage-tooltip-layer, 1000);
+ top: var(--opsail-refit-usage-details-top, 0);
+ left: var(--opsail-refit-usage-details-left, 0);
+ display: none;
+ width: var(--opsail-refit-usage-details-width, 240px);
+ min-width: 0;
+ max-width: calc(100vw - 32px);
+ max-height: var(--opsail-refit-usage-details-max-height, calc(100vh - 16px));
+ padding: 8px;
+ overflow-y: auto;
+ border: 1px solid var(--color-token-border-default, var(--color-border, currentColor));
+ border-radius: 8px;
+ background: var(--color-token-side-bar-background, var(--color-background-surface, transparent));
+ box-shadow: var(--elevation-sidebar, none);
+ color: var(--color-token-foreground, var(--color-text-foreground, currentColor));
+ cursor: default;
+ font: inherit;
+ font-size: 10px;
+ line-height: 14px;
+ pointer-events: auto;
+}
+
+#opsail-refit-codex-usage-details[data-opsail-refit-codex-open="true"] {
+ display: grid;
+ gap: 8px;
+}
+
+#opsail-refit-codex-launch-notice {
+ box-sizing: border-box;
+ position: fixed;
+ z-index: var(--opsail-refit-usage-notice-layer, 1100);
+ top: 30%;
+ left: 50%;
+ display: grid;
+ width: max-content;
+ max-width: calc(100vw - 32px);
+ gap: 2px;
+ padding: 10px 14px;
+ transform: translate(-50%, -50%);
+ border: 1px solid var(--color-token-activity-bar-badge-background, var(--color-token-progress-bar-background, CanvasText));
+ border-radius: 10px;
+ background: var(--color-token-activity-bar-badge-background, var(--color-token-progress-bar-background, CanvasText));
+ box-shadow: var(--elevation-sidebar, none);
+ color: var(--color-token-activity-bar-badge-foreground, var(--color-token-foreground, Canvas));
+ cursor: default;
+ font: inherit;
+ line-height: 1.4;
+ pointer-events: none;
+ text-align: center;
+ animation: opsail-refit-codex-launch-notice 2800ms ease both;
+}
+
+.opsail-refit-codex-launch-notice-title,
+.opsail-refit-codex-launch-notice-message {
+ display: block;
+ overflow-wrap: anywhere;
+ white-space: normal;
+}
+
+.opsail-refit-codex-launch-notice-title {
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.opsail-refit-codex-launch-notice-message {
+ color: inherit;
+ font-size: 11px;
+}
+
+@keyframes opsail-refit-codex-launch-notice {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.98);
+ }
+
+ 12%, 82% {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.98);
+ }
+}
+
+.opsail-refit-codex-usage-stale {
+ overflow-wrap: anywhere;
+ color: var(--color-token-description-foreground, var(--color-text-foreground-tertiary, currentColor));
+ font-size: 10px;
+ line-height: 14px;
+ white-space: normal;
+}
+
+.opsail-refit-codex-usage-row {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.opsail-refit-codex-usage-row:not([hidden]) + .opsail-refit-codex-usage-row:not([hidden]) {
+ padding-top: 8px;
+ border-top: 1px solid var(--color-token-border-light, var(--color-border-light, currentColor));
+}
+
+.opsail-refit-codex-usage-line {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ color: var(--color-token-text-secondary, var(--color-text-foreground-secondary, currentColor));
+ font-size: 10px;
+ line-height: 14px;
+ white-space: normal;
+}
+
+.opsail-refit-codex-usage-line b {
+ flex: 0 0 auto;
+ color: var(--color-token-foreground, var(--color-text-foreground, currentColor));
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+}
+
+.opsail-refit-codex-usage-meta {
+ overflow: visible;
+ overflow-wrap: anywhere;
+ color: var(--color-token-description-foreground, var(--color-text-foreground-tertiary, currentColor));
+ font-size: 10px;
+ line-height: 14px;
+ text-overflow: clip;
+ white-space: pre-line;
+}
+
+.opsail-refit-codex-usage-track {
+ position: relative;
+ height: 3px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: var(--color-token-list-hover-background, var(--color-background-button-tertiary, transparent));
+}
+
+.opsail-refit-codex-usage-track > i {
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: var(--opsail-refit-usage-remaining, 0%);
+ border-radius: inherit;
+ background: var(--color-token-interactive-label-accent-default, var(--color-token-primary, currentColor));
+ transition: width 180ms ease-out;
+}
+
+.opsail-refit-codex-reset-credits {
+ display: grid;
+ min-width: 0;
+ gap: 4px;
+ padding-top: 8px;
+ border-top: 1px solid var(--color-token-border-light, var(--color-border-light, currentColor));
+}
+
+.opsail-refit-codex-reset-credits-title {
+ color: var(--color-token-foreground, var(--color-text-foreground, currentColor));
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 14px;
+}
+
+.opsail-refit-codex-reset-credits-table {
+ width: 100%;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-collapse: collapse;
+ color: inherit;
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+ line-height: 14px;
+}
+
+.opsail-refit-codex-reset-credits-row + .opsail-refit-codex-reset-credits-row > td {
+ border-top: 1px solid var(--color-token-border-light, var(--color-border-light, currentColor));
+}
+
+.opsail-refit-codex-reset-credits-row > td {
+ padding: 3px 0;
+ vertical-align: middle;
+ white-space: nowrap;
+}
+
+.opsail-refit-codex-reset-credits-row:first-child > td {
+ padding-top: 0;
+}
+
+.opsail-refit-codex-reset-credits-row:last-child > td {
+ padding-bottom: 0;
+}
+
+.opsail-refit-codex-reset-credits-expiry {
+ color: var(--color-token-text-secondary, var(--color-text-foreground-secondary, currentColor));
+ text-align: start;
+}
+
+.opsail-refit-codex-reset-credits-row > .opsail-refit-codex-reset-credits-countdown {
+ padding-inline-start: 8px;
+ color: var(--color-token-description-foreground, var(--color-text-foreground-tertiary, currentColor));
+ text-align: end;
+}
+
+.opsail-refit-codex-time-format-note {
+ margin: 0;
+ overflow-wrap: anywhere;
+ color: var(--color-token-description-foreground, var(--color-text-foreground-tertiary, currentColor));
+ font-size: 9px;
+ line-height: 13px;
+ white-space: normal;
+}
+
+.opsail-refit-codex-visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ border: 0;
+ white-space: nowrap;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .opsail-refit-codex-usage-track > i,
+ #opsail-refit-codex-launch-notice {
+ transition: none;
+ animation: none;
+ }
+}
diff --git a/crates/opsail-refit-codex/src/atomic_file.rs b/crates/opsail-refit-codex/src/atomic_file.rs
new file mode 100644
index 0000000..cfdbe51
--- /dev/null
+++ b/crates/opsail-refit-codex/src/atomic_file.rs
@@ -0,0 +1,170 @@
+use std::fs::{self, File, OpenOptions};
+use std::io::{self, ErrorKind, Write as _};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum AtomicWriteError {
+ CreateTemporary,
+ WriteTemporary,
+ FlushTemporary,
+ UnsafeDestination,
+ ReplaceDestination,
+}
+
+/// Persist bytes through a private temporary file in the destination directory.
+///
+/// `std::fs::rename` replaces an existing destination on supported Unix and Windows
+/// filesystems. Keeping both paths in one directory makes the commit a same-volume
+/// rename and avoids a delete-before-replace window.
+pub(crate) fn write_private_atomically(
+ destination: &Path,
+ bytes: &[u8],
+) -> Result<(), AtomicWriteError> {
+ ensure_no_windows_reparse_points(destination)
+ .map_err(|_| AtomicWriteError::UnsafeDestination)?;
+ let parent = destination
+ .parent()
+ .ok_or(AtomicWriteError::CreateTemporary)?;
+ let (temporary, mut file) = create_private_temporary(parent)?;
+
+ let write_result = file
+ .write_all(bytes)
+ .map_err(|_| AtomicWriteError::WriteTemporary)
+ .and_then(|()| {
+ file.sync_all()
+ .map_err(|_| AtomicWriteError::FlushTemporary)
+ });
+ drop(file);
+
+ if let Err(error) = write_result {
+ let _ = fs::remove_file(&temporary);
+ return Err(error);
+ }
+
+ if ensure_no_windows_reparse_points(destination).is_err() {
+ let _ = fs::remove_file(&temporary);
+ return Err(AtomicWriteError::UnsafeDestination);
+ }
+ if fs::rename(&temporary, destination).is_err() {
+ let _ = fs::remove_file(&temporary);
+ return Err(AtomicWriteError::ReplaceDestination);
+ }
+ Ok(())
+}
+
+fn create_private_temporary(parent: &Path) -> Result<(PathBuf, File), AtomicWriteError> {
+ static SEQUENCE: AtomicU64 = AtomicU64::new(1);
+
+ for _ in 0..32 {
+ let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
+ let temporary = parent.join(format!(
+ ".opsail-atomic-{}-{sequence}.tmp",
+ std::process::id()
+ ));
+ let mut options = OpenOptions::new();
+ options.create_new(true).write(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt as _;
+ options.mode(0o600);
+ }
+ match options.open(&temporary) {
+ Ok(file) => return Ok((temporary, file)),
+ Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
+ Err(_) => return Err(AtomicWriteError::CreateTemporary),
+ }
+ }
+
+ Err(AtomicWriteError::CreateTemporary)
+}
+
+pub(crate) fn is_symlink_or_windows_reparse_point(metadata: &fs::Metadata) -> bool {
+ if metadata.file_type().is_symlink() {
+ return true;
+ }
+ #[cfg(windows)]
+ {
+ use std::os::windows::fs::MetadataExt as _;
+
+ has_windows_reparse_attribute(metadata.file_attributes())
+ }
+ #[cfg(not(windows))]
+ {
+ false
+ }
+}
+
+#[cfg(windows)]
+pub(crate) fn ensure_no_windows_reparse_points(path: &Path) -> io::Result<()> {
+ let absolute = std::path::absolute(path)?;
+ for component in absolute.ancestors() {
+ if component.as_os_str().is_empty() {
+ continue;
+ }
+ match fs::symlink_metadata(component) {
+ Ok(metadata) if is_symlink_or_windows_reparse_point(&metadata) => {
+ return Err(io::Error::new(
+ ErrorKind::InvalidInput,
+ "path contains a Windows reparse point",
+ ));
+ }
+ Ok(_) => {}
+ Err(error) if error.kind() == ErrorKind::NotFound => {}
+ Err(error) => return Err(error),
+ }
+ }
+ Ok(())
+}
+
+#[cfg(not(windows))]
+pub(crate) fn ensure_no_windows_reparse_points(_path: &Path) -> io::Result<()> {
+ Ok(())
+}
+
+#[cfg(any(windows, test))]
+fn has_windows_reparse_attribute(attributes: u32) -> bool {
+ const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
+
+ attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
+}
+
+#[cfg(test)]
+mod tests {
+ use tempfile::tempdir;
+
+ use super::*;
+
+ #[test]
+ fn replaces_an_existing_file_and_removes_the_temporary_file() {
+ let directory = tempdir().unwrap();
+ let destination = directory.path().join("state.json");
+ fs::write(&destination, b"old").unwrap();
+
+ write_private_atomically(&destination, b"new").unwrap();
+
+ assert_eq!(fs::read(&destination).unwrap(), b"new");
+ assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1);
+ }
+
+ #[test]
+ fn failed_replacement_preserves_the_existing_destination() {
+ let directory = tempdir().unwrap();
+ let destination = directory.path().join("existing-directory");
+ fs::create_dir(&destination).unwrap();
+
+ assert_eq!(
+ write_private_atomically(&destination, b"new").unwrap_err(),
+ AtomicWriteError::ReplaceDestination
+ );
+ assert!(destination.is_dir());
+ assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1);
+ }
+
+ #[test]
+ fn windows_reparse_attribute_mask_is_detected_without_ffi() {
+ assert!(!has_windows_reparse_attribute(0));
+ assert!(has_windows_reparse_attribute(0x400));
+ assert!(has_windows_reparse_attribute(0x400 | 0x20));
+ }
+}
diff --git a/crates/opsail-refit-codex/src/cdp.rs b/crates/opsail-refit-codex/src/cdp.rs
new file mode 100644
index 0000000..d45240e
--- /dev/null
+++ b/crates/opsail-refit-codex/src/cdp.rs
@@ -0,0 +1,897 @@
+use std::collections::HashMap;
+use std::sync::{Arc, Once, OnceLock};
+use std::time::Duration;
+
+use futures_util::stream::{SplitSink, SplitStream};
+use futures_util::{SinkExt, StreamExt};
+use reqwest::redirect::Policy;
+use serde::Deserialize;
+use serde_json::{Value, json};
+use tokio::net::TcpStream;
+use tokio::sync::{Mutex, mpsc, oneshot, watch};
+use tokio::task::JoinHandle;
+use tokio::time::{Instant, timeout, timeout_at};
+use tokio_tungstenite::tungstenite::Message;
+use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
+use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async_with_config};
+use url::Url;
+
+use crate::error::{CodexRefitError, CodexRefitErrorCode};
+
+const DISCOVERY_MAX_BYTES: usize = 1024 * 1024;
+const MAX_CDP_MESSAGE_BYTES: usize = 4 * 1024 * 1024;
+const TARGET_ID_MAX_BYTES: usize = 200;
+const HTTP_TIMEOUT: Duration = Duration::from_secs(2);
+const CDP_TIMEOUT: Duration = Duration::from_secs(10);
+
+type Socket = WebSocketStream>;
+
+#[derive(Debug, Clone)]
+pub(crate) struct RendererTarget {
+ pub id: String,
+ pub websocket_url: Url,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct DiscoveryTarget {
+ #[serde(rename = "type")]
+ kind: String,
+ id: String,
+ url: String,
+ web_socket_debugger_url: String,
+}
+
+pub(crate) struct CdpSession {
+ target_id: String,
+ writer_tx: mpsc::UnboundedSender,
+ pending: Arc>>,
+ termination: watch::Receiver,
+ reader: Option>,
+ writer: Option>,
+ next_id: u64,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum SessionTermination {
+ Open,
+ Closed,
+ Failed,
+}
+
+struct PendingCommand {
+ method: &'static str,
+ response: oneshot::Sender>,
+}
+
+enum WriterCommand {
+ Message {
+ message: Message,
+ complete: Option>,
+ },
+ PeerClosed,
+ Abort,
+ Close {
+ complete: oneshot::Sender<()>,
+ },
+}
+
+impl CdpSession {
+ pub async fn connect(target: &RendererTarget) -> Result {
+ let mut config = WebSocketConfig::default();
+ config.max_message_size = Some(MAX_CDP_MESSAGE_BYTES);
+ config.max_frame_size = Some(MAX_CDP_MESSAGE_BYTES);
+ let connected = timeout(
+ Duration::from_secs(5),
+ connect_async_with_config(target.websocket_url.as_str(), Some(config), false),
+ )
+ .await
+ .map_err(|_| session_error("timed out connecting to the verified renderer"))?
+ .map_err(|_| session_error("could not connect to the verified renderer"))?;
+ let (sink, stream) = connected.0.split();
+ let (writer_tx, writer_rx) = mpsc::unbounded_channel();
+ let (termination_tx, termination) = watch::channel(SessionTermination::Open);
+ let pending = Arc::new(Mutex::new(HashMap::new()));
+ let writer = tokio::spawn(run_socket_writer(
+ sink,
+ writer_rx,
+ termination_tx.clone(),
+ Arc::clone(&pending),
+ ));
+ let reader = tokio::spawn(run_socket_reader(
+ stream,
+ writer_tx.clone(),
+ termination_tx,
+ Arc::clone(&pending),
+ ));
+ Ok(Self {
+ target_id: target.id.clone(),
+ writer_tx,
+ pending,
+ termination,
+ reader: Some(reader),
+ writer: Some(writer),
+ next_id: 1,
+ })
+ }
+
+ pub fn target_id(&self) -> &str {
+ &self.target_id
+ }
+
+ pub async fn evaluate(&mut self, expression: &str) -> Result {
+ let result = self
+ .command(
+ "Runtime.evaluate",
+ Some(json!({
+ "expression": expression,
+ "awaitPromise": true,
+ "returnByValue": true,
+ "userGesture": false
+ })),
+ )
+ .await?;
+ if result.get("exceptionDetails").is_some() {
+ return Err(CodexRefitError::new(
+ CodexRefitErrorCode::InjectionFailed,
+ "the renderer rejected the refit expression",
+ ));
+ }
+ Ok(result
+ .get("result")
+ .and_then(|value| value.get("value"))
+ .cloned()
+ .unwrap_or(Value::Null))
+ }
+
+ pub async fn add_script(&mut self, source: &str) -> Result {
+ let result = self
+ .command(
+ "Page.addScriptToEvaluateOnNewDocument",
+ Some(json!({ "source": source })),
+ )
+ .await?;
+ result
+ .get("identifier")
+ .and_then(Value::as_str)
+ .filter(|identifier| !identifier.is_empty() && identifier.len() <= 512)
+ .map(str::to_owned)
+ .ok_or_else(|| {
+ CodexRefitError::new(
+ CodexRefitErrorCode::InjectionFailed,
+ "the renderer did not return an early-script identifier",
+ )
+ })
+ }
+
+ pub async fn remove_script(&mut self, identifier: &str) -> Result<(), CodexRefitError> {
+ self.command(
+ "Page.removeScriptToEvaluateOnNewDocument",
+ Some(json!({ "identifier": identifier })),
+ )
+ .await
+ .map(|_| ())
+ .map_err(|_| {
+ CodexRefitError::new(
+ CodexRefitErrorCode::CleanupFailed,
+ "could not remove a registered renderer script",
+ )
+ })
+ }
+
+ pub async fn close(&mut self) {
+ let (complete, completed) = oneshot::channel();
+ let _ = self.writer_tx.send(WriterCommand::Close { complete });
+ let _ = timeout(Duration::from_millis(250), completed).await;
+ if let Some(mut reader) = self.reader.take()
+ && timeout(Duration::from_millis(250), &mut reader)
+ .await
+ .is_err()
+ {
+ reader.abort();
+ }
+ if let Some(mut writer) = self.writer.take()
+ && timeout(Duration::from_millis(250), &mut writer)
+ .await
+ .is_err()
+ {
+ writer.abort();
+ }
+ }
+
+ pub fn termination_receiver(&self) -> watch::Receiver {
+ self.termination.clone()
+ }
+
+ async fn command(
+ &mut self,
+ method: &'static str,
+ params: Option,
+ ) -> Result {
+ let id = self.next_id;
+ self.next_id = self.next_id.saturating_add(1);
+ let mut command = json!({ "id": id, "method": method });
+ if let Some(params) = params {
+ command["params"] = params;
+ }
+ let text = serde_json::to_string(&command)
+ .map_err(|_| session_error("could not serialize a renderer command"))?;
+ let (response, receiver) = oneshot::channel();
+ self.pending
+ .lock()
+ .await
+ .insert(id, PendingCommand { method, response });
+ let (sent, sent_result) = oneshot::channel();
+ let deadline = Instant::now() + CDP_TIMEOUT;
+ if self
+ .writer_tx
+ .send(WriterCommand::Message {
+ message: Message::Text(text.into()),
+ complete: Some(sent),
+ })
+ .is_err()
+ {
+ self.pending.lock().await.remove(&id);
+ return Err(session_error("renderer session closed"));
+ }
+ match timeout_at(deadline, sent_result).await {
+ Ok(Ok(true)) => {}
+ Ok(Ok(false) | Err(_)) => {
+ self.pending.lock().await.remove(&id);
+ return Err(session_error("renderer command failed"));
+ }
+ Err(_) => {
+ self.pending.lock().await.remove(&id);
+ return Err(session_error("renderer command timed out"));
+ }
+ }
+ match timeout_at(deadline, receiver).await {
+ Ok(Ok(result)) => result,
+ Ok(Err(_)) => Err(session_error("renderer session closed")),
+ Err(_) => {
+ self.pending.lock().await.remove(&id);
+ Err(session_error("renderer response timed out"))
+ }
+ }
+ }
+}
+
+impl Drop for CdpSession {
+ fn drop(&mut self) {
+ if let Some(reader) = self.reader.take() {
+ reader.abort();
+ }
+ if let Some(writer) = self.writer.take() {
+ writer.abort();
+ }
+ }
+}
+
+pub(crate) async fn wait_for_termination(
+ mut receiver: watch::Receiver,
+) -> SessionTermination {
+ loop {
+ let state = *receiver.borrow_and_update();
+ if state != SessionTermination::Open {
+ return state;
+ }
+ if receiver.changed().await.is_err() {
+ return SessionTermination::Closed;
+ }
+ }
+}
+
+async fn run_socket_reader(
+ mut stream: SplitStream,
+ writer: mpsc::UnboundedSender,
+ termination: watch::Sender,
+ pending: Arc>>,
+) {
+ let final_state = loop {
+ match stream.next().await {
+ Some(Ok(Message::Text(text))) => {
+ if route_protocol_message(text.as_bytes(), &pending)
+ .await
+ .is_err()
+ {
+ break SessionTermination::Failed;
+ }
+ }
+ Some(Ok(Message::Binary(bytes))) => {
+ if route_protocol_message(bytes.as_ref(), &pending)
+ .await
+ .is_err()
+ {
+ break SessionTermination::Failed;
+ }
+ }
+ Some(Ok(Message::Ping(payload))) => {
+ if writer
+ .send(WriterCommand::Message {
+ message: Message::Pong(payload),
+ complete: None,
+ })
+ .is_err()
+ {
+ break SessionTermination::Failed;
+ }
+ }
+ Some(Ok(Message::Close(_))) | None => break SessionTermination::Closed,
+ Some(Err(_)) => break SessionTermination::Failed,
+ Some(Ok(Message::Pong(_) | Message::Frame(_))) => {}
+ }
+ };
+ let control = if final_state == SessionTermination::Closed {
+ WriterCommand::PeerClosed
+ } else {
+ WriterCommand::Abort
+ };
+ let _ = writer.send(control);
+ terminate_session(&termination, &pending, final_state).await;
+}
+
+async fn run_socket_writer(
+ mut sink: SplitSink,
+ mut commands: mpsc::UnboundedReceiver,
+ termination: watch::Sender,
+ pending: Arc>>,
+) {
+ while let Some(command) = commands.recv().await {
+ match command {
+ WriterCommand::Message { message, complete } => {
+ let sent = sink.send(message).await.is_ok();
+ if let Some(complete) = complete {
+ let _ = complete.send(sent);
+ }
+ if !sent {
+ terminate_session(&termination, &pending, SessionTermination::Failed).await;
+ return;
+ }
+ }
+ WriterCommand::PeerClosed => {
+ let _ = sink.flush().await;
+ terminate_session(&termination, &pending, SessionTermination::Closed).await;
+ return;
+ }
+ WriterCommand::Abort => {
+ terminate_session(&termination, &pending, SessionTermination::Failed).await;
+ return;
+ }
+ WriterCommand::Close { complete } => {
+ let state = if sink.send(Message::Close(None)).await.is_ok() {
+ SessionTermination::Closed
+ } else {
+ SessionTermination::Failed
+ };
+ let _ = complete.send(());
+ terminate_session(&termination, &pending, state).await;
+ return;
+ }
+ }
+ }
+ let _ = sink.send(Message::Close(None)).await;
+ terminate_session(&termination, &pending, SessionTermination::Closed).await;
+}
+
+async fn terminate_session(
+ termination: &watch::Sender,
+ pending: &Arc>>,
+ state: SessionTermination,
+) {
+ if *termination.borrow() == SessionTermination::Open {
+ let _ = termination.send(state);
+ }
+ let message = if state == SessionTermination::Closed {
+ "renderer session closed"
+ } else {
+ "renderer session failed"
+ };
+ for (_, command) in pending.lock().await.drain() {
+ let _ = command.response.send(Err(session_error(message)));
+ }
+}
+
+async fn route_protocol_message(
+ bytes: &[u8],
+ pending: &Arc>>,
+) -> Result<(), CodexRefitError> {
+ let value: Value = serde_json::from_slice(bytes)
+ .map_err(|_| session_error("renderer returned invalid protocol JSON"))?;
+ let Some(id) = value.get("id").and_then(Value::as_u64) else {
+ return Ok(());
+ };
+ let Some(command) = pending.lock().await.remove(&id) else {
+ return Ok(());
+ };
+ let result = if value.get("error").is_some() {
+ Err(session_error(format!(
+ "renderer rejected the `{}` command",
+ command.method
+ )))
+ } else {
+ Ok(value.get("result").cloned().unwrap_or(Value::Null))
+ };
+ let _ = command.response.send(result);
+ Ok(())
+}
+
+pub(crate) async fn discover_targets(port: u16) -> Result, CodexRefitError> {
+ install_tls_provider();
+ let client = http_client()?;
+ let endpoint = format!("http://127.0.0.1:{port}/json/list");
+ let response = timeout(HTTP_TIMEOUT, client.get(endpoint).send())
+ .await
+ .map_err(|_| session_error("the loopback debug endpoint timed out"))?
+ .map_err(|_| session_error("the loopback debug endpoint is unavailable"))?;
+ if !response.status().is_success() {
+ return Err(session_error(
+ "the loopback debug endpoint rejected discovery",
+ ));
+ }
+ let mut bytes = Vec::new();
+ let mut stream = response.bytes_stream();
+ while let Some(chunk) = timeout(HTTP_TIMEOUT, stream.next())
+ .await
+ .map_err(|_| session_error("renderer discovery timed out"))?
+ {
+ let chunk = chunk.map_err(|_| session_error("renderer discovery failed"))?;
+ if bytes.len().saturating_add(chunk.len()) > DISCOVERY_MAX_BYTES {
+ return Err(CodexRefitError::new(
+ CodexRefitErrorCode::TargetValidationFailed,
+ "renderer discovery exceeded its response limit",
+ ));
+ }
+ bytes.extend_from_slice(&chunk);
+ }
+ let values: Vec = serde_json::from_slice(&bytes).map_err(|_| {
+ CodexRefitError::new(
+ CodexRefitErrorCode::TargetValidationFailed,
+ "renderer discovery returned an invalid target list",
+ )
+ })?;
+ let collected = collect_valid_targets(values, port);
+ if collected.targets.is_empty() {
+ return Err(CodexRefitError::new(
+ if collected.rejected > 0 {
+ CodexRefitErrorCode::TargetValidationFailed
+ } else {
+ CodexRefitErrorCode::TargetNotFound
+ },
+ if collected.transitional > 0 {
+ "the app renderer is still starting"
+ } else {
+ "no app renderer matched the required local target shape"
+ },
+ ));
+ }
+ Ok(collected.targets)
+}
+
+#[derive(Default)]
+struct TargetCollection {
+ targets: Vec,
+ rejected: usize,
+ transitional: usize,
+}
+
+enum TargetDisposition {
+ Valid(RendererTarget),
+ Transitional,
+ Rejected,
+}
+
+fn collect_valid_targets(values: Vec, port: u16) -> TargetCollection {
+ let mut collection = TargetCollection::default();
+ for value in values {
+ if value.get("type").and_then(Value::as_str) != Some("page") {
+ continue;
+ }
+ let Ok(value) = serde_json::from_value::(value) else {
+ collection.rejected = collection.rejected.saturating_add(1);
+ continue;
+ };
+ match classify_target(value, port) {
+ TargetDisposition::Valid(target) => collection.targets.push(target),
+ TargetDisposition::Transitional => {
+ collection.transitional = collection.transitional.saturating_add(1);
+ }
+ TargetDisposition::Rejected => {
+ collection.rejected = collection.rejected.saturating_add(1);
+ }
+ }
+ }
+ collection
+}
+
+fn classify_target(value: DiscoveryTarget, port: u16) -> TargetDisposition {
+ if value.kind != "page" || !valid_target_id(&value.id) {
+ return TargetDisposition::Rejected;
+ }
+ let Some(mut websocket_url) = validated_websocket_url(&value, port) else {
+ return TargetDisposition::Rejected;
+ };
+ if value.url == "about:blank" {
+ return TargetDisposition::Transitional;
+ }
+ if !is_local_app_renderer_url(&value.url) {
+ return TargetDisposition::Rejected;
+ }
+ if websocket_url.set_host(Some("127.0.0.1")).is_err() {
+ return TargetDisposition::Rejected;
+ }
+ TargetDisposition::Valid(RendererTarget {
+ id: value.id,
+ websocket_url,
+ })
+}
+
+fn is_local_app_renderer_url(value: &str) -> bool {
+ if value.len() > 8_192 {
+ return false;
+ }
+ let Ok(renderer_url) = Url::parse(value) else {
+ return false;
+ };
+ if renderer_url.scheme() != "app"
+ || renderer_url.host_str().is_none_or(str::is_empty)
+ || renderer_url.port().is_some()
+ || renderer_url.path().len() <= 1
+ || renderer_url.path().len() > 4_096
+ || !renderer_url.username().is_empty()
+ || renderer_url.password().is_some()
+ {
+ return false;
+ }
+ true
+}
+
+fn validated_websocket_url(value: &DiscoveryTarget, port: u16) -> Option {
+ let websocket_url = Url::parse(&value.web_socket_debugger_url).ok()?;
+ if websocket_url.scheme() != "ws"
+ || !is_loopback_host(websocket_url.host_str()?)
+ || websocket_url.port() != Some(port)
+ || !websocket_url.username().is_empty()
+ || websocket_url.password().is_some()
+ || websocket_url.query().is_some()
+ || websocket_url.fragment().is_some()
+ || websocket_url.path() != format!("/devtools/page/{}", value.id)
+ {
+ return None;
+ }
+ Some(websocket_url)
+}
+
+fn valid_target_id(value: &str) -> bool {
+ !value.is_empty()
+ && value.len() <= TARGET_ID_MAX_BYTES
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
+}
+
+fn is_loopback_host(value: &str) -> bool {
+ value == "127.0.0.1"
+}
+
+fn http_client() -> Result<&'static reqwest::Client, CodexRefitError> {
+ static CLIENT: OnceLock> = OnceLock::new();
+ CLIENT
+ .get_or_init(|| {
+ reqwest::Client::builder()
+ .connect_timeout(HTTP_TIMEOUT)
+ .timeout(HTTP_TIMEOUT)
+ .redirect(Policy::none())
+ .no_proxy()
+ .build()
+ .map_err(|_| ())
+ })
+ .as_ref()
+ .map_err(|()| session_error("could not initialize the loopback HTTP client"))
+}
+
+fn install_tls_provider() {
+ static INSTALL: Once = Once::new();
+ INSTALL.call_once(|| {
+ let _ = rustls::crypto::ring::default_provider().install_default();
+ });
+}
+
+fn session_error(message: impl Into) -> CodexRefitError {
+ CodexRefitError::new(CodexRefitErrorCode::SessionUnavailable, message)
+}
+
+#[cfg(test)]
+mod tests {
+ use tokio::net::TcpListener;
+ use tokio::sync::oneshot;
+ use tokio_tungstenite::accept_async;
+
+ use super::*;
+
+ fn target(websocket_url: &str, renderer_url: &str) -> DiscoveryTarget {
+ DiscoveryTarget {
+ kind: "page".to_owned(),
+ id: "renderer-1".to_owned(),
+ url: renderer_url.to_owned(),
+ web_socket_debugger_url: websocket_url.to_owned(),
+ }
+ }
+
+ async fn test_listener() -> (TcpListener, RendererTarget) {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let port = listener.local_addr().unwrap().port();
+ let target = RendererTarget {
+ id: "renderer-test".to_owned(),
+ websocket_url: Url::parse(&format!(
+ "ws://127.0.0.1:{port}/devtools/page/renderer-test"
+ ))
+ .unwrap(),
+ };
+ (listener, target)
+ }
+
+ #[test]
+ fn accepts_only_loopback_app_page_candidates_with_matching_ids() {
+ assert!(matches!(
+ classify_target(
+ target(
+ "ws://127.0.0.1:55321/devtools/page/renderer-1",
+ "app://-/index.html"
+ ),
+ 55321
+ ),
+ TargetDisposition::Valid(_)
+ ));
+ for websocket_url in [
+ "ws://example.test:55321/devtools/page/renderer-1",
+ "ws://localhost:55321/devtools/page/renderer-1",
+ "ws://[::1]:55321/devtools/page/renderer-1",
+ "ws://127.0.0.1:55322/devtools/page/renderer-1",
+ "ws://127.0.0.1:55321/devtools/page/another",
+ "ws://127.0.0.1:55321/devtools/page/renderer-1?token=secret",
+ ] {
+ assert!(matches!(
+ classify_target(target(websocket_url, "app://-/index.html"), 55321),
+ TargetDisposition::Rejected
+ ));
+ }
+ for renderer_url in [
+ "https://example.test",
+ "file:///Applications/ChatGPT.app/index.html",
+ "app:///index.html",
+ "app://-/",
+ "app://user@-/index.html",
+ "app://-:55321/index.html",
+ ] {
+ assert!(matches!(
+ classify_target(
+ target(
+ "ws://127.0.0.1:55321/devtools/page/renderer-1",
+ renderer_url,
+ ),
+ 55321,
+ ),
+ TargetDisposition::Rejected
+ ));
+ }
+ for renderer_url in [
+ "app://-/index.html?initialRoute=%2Flocal%2Fthread-id",
+ "app://-/index.html?mcpAppSandboxDevtools=1#route",
+ "app://shell/main.html?route=%2Flocal%2Fthread-id",
+ ] {
+ assert!(matches!(
+ classify_target(
+ target(
+ "ws://127.0.0.1:55321/devtools/page/renderer-1",
+ renderer_url,
+ ),
+ 55321,
+ ),
+ TargetDisposition::Valid(_)
+ ));
+ }
+ }
+
+ #[test]
+ fn accepts_the_packaged_chatgpt_renderer_url() {
+ assert!(matches!(
+ classify_target(
+ target(
+ "ws://127.0.0.1:55321/devtools/page/renderer-1",
+ "app://-/index.html",
+ ),
+ 55321,
+ ),
+ TargetDisposition::Valid(_)
+ ));
+ }
+
+ #[test]
+ fn accepts_a_safe_future_local_app_renderer_for_the_identity_probe() {
+ assert!(matches!(
+ classify_target(
+ target(
+ "ws://127.0.0.1:55321/devtools/page/renderer-1",
+ "app://shell/main.html?route=%2Flocal%2Fthread-id",
+ ),
+ 55321,
+ ),
+ TargetDisposition::Valid(_)
+ ));
+ }
+
+ #[test]
+ fn startup_blank_page_is_transitional_but_wrong_app_pages_fail_closed() {
+ assert!(matches!(
+ classify_target(
+ target(
+ "ws://127.0.0.1:55321/devtools/page/renderer-1",
+ "about:blank",
+ ),
+ 55321,
+ ),
+ TargetDisposition::Transitional
+ ));
+ for renderer_url in ["about:blank?unexpected=true", "about:blank#unexpected"] {
+ assert!(matches!(
+ classify_target(
+ target(
+ "ws://127.0.0.1:55321/devtools/page/renderer-1",
+ renderer_url,
+ ),
+ 55321,
+ ),
+ TargetDisposition::Rejected
+ ));
+ }
+
+ let transitional = collect_valid_targets(
+ vec![serde_json::json!({
+ "type": "page",
+ "id": "renderer-1",
+ "url": "about:blank",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:55321/devtools/page/renderer-1",
+ })],
+ 55321,
+ );
+ assert!(transitional.targets.is_empty());
+ assert_eq!(transitional.transitional, 1);
+ assert_eq!(transitional.rejected, 0);
+ }
+
+ #[test]
+ fn target_ids_are_strictly_bounded() {
+ assert!(valid_target_id("renderer-1._"));
+ assert!(!valid_target_id(""));
+ assert!(!valid_target_id("renderer/1"));
+ assert!(!valid_target_id(&"a".repeat(TARGET_ID_MAX_BYTES + 1)));
+ }
+
+ #[test]
+ fn malformed_unrelated_discovery_entries_do_not_hide_a_valid_renderer() {
+ let values = vec![
+ json!({ "type": "worker", "id": "unrelated" }),
+ json!({
+ "type": "page",
+ "id": "renderer-1",
+ "url": "app://-/index.html",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:55321/devtools/page/renderer-1"
+ }),
+ ];
+ let collection = collect_valid_targets(values, 55321);
+ assert_eq!(collection.targets.len(), 1);
+ assert_eq!(collection.rejected, 0);
+ }
+
+ #[tokio::test]
+ async fn connect_enables_no_cdp_domain_and_idle_sessions_do_not_poll() {
+ let (listener, target) = test_listener().await;
+ let (observed_tx, observed_rx) = oneshot::channel();
+ tokio::spawn(async move {
+ let (stream, _) = listener.accept().await.unwrap();
+ let mut socket = accept_async(stream).await.unwrap();
+ let observed = timeout(Duration::from_millis(100), socket.next())
+ .await
+ .ok()
+ .flatten()
+ .and_then(Result::ok)
+ .and_then(|message| match message {
+ Message::Text(text) => serde_json::from_str::(text.as_ref()).ok(),
+ _ => None,
+ })
+ .and_then(|value| value["method"].as_str().map(str::to_owned));
+ let _ = observed_tx.send(observed);
+ });
+
+ let mut session = CdpSession::connect(&target).await.unwrap();
+ let observed = observed_rx.await.unwrap();
+ assert_eq!(observed, None);
+ for forbidden in [
+ "Runtime.enable",
+ "Page.enable",
+ "Network.enable",
+ "Debugger.enable",
+ "Profiler.enable",
+ "Tracing.start",
+ "Page.startScreencast",
+ ] {
+ assert_ne!(observed.as_deref(), Some(forbidden));
+ }
+ session.close().await;
+ }
+
+ #[tokio::test]
+ async fn reader_drains_events_routes_responses_and_reports_peer_close() {
+ let (listener, target) = test_listener().await;
+ let (acknowledged_tx, acknowledged_rx) = oneshot::channel();
+ tokio::spawn(async move {
+ let (stream, _) = listener.accept().await.unwrap();
+ let mut socket = accept_async(stream).await.unwrap();
+ socket
+ .send(Message::Text(
+ json!({ "method": "Runtime.executionContextCreated", "params": {} })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .unwrap();
+ let request = socket.next().await.unwrap().unwrap();
+ let Message::Text(request) = request else {
+ panic!("expected a text command");
+ };
+ let request: Value = serde_json::from_str(request.as_ref()).unwrap();
+ assert_eq!(request["method"], "Runtime.evaluate");
+ socket
+ .send(Message::Text(
+ json!({ "method": "Console.messageAdded", "params": {} })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .unwrap();
+ socket
+ .send(Message::Text(
+ json!({
+ "id": request["id"],
+ "result": { "result": { "value": { "ok": true } } }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .unwrap();
+ socket.send(Message::Close(None)).await.unwrap();
+ let acknowledged = matches!(
+ timeout(Duration::from_millis(250), socket.next()).await,
+ Ok(Some(Ok(Message::Close(_))))
+ );
+ let _ = acknowledged_tx.send(acknowledged);
+ });
+
+ let mut session = CdpSession::connect(&target).await.unwrap();
+ let termination = session.termination_receiver();
+ assert_eq!(session.evaluate("1").await.unwrap(), json!({ "ok": true }));
+ assert_eq!(
+ wait_for_termination(termination).await,
+ SessionTermination::Closed
+ );
+ assert!(acknowledged_rx.await.unwrap());
+ session.close().await;
+ }
+
+ #[tokio::test]
+ async fn explicit_close_sends_a_websocket_close_frame() {
+ let (listener, target) = test_listener().await;
+ let (closed_tx, closed_rx) = oneshot::channel();
+ tokio::spawn(async move {
+ let (stream, _) = listener.accept().await.unwrap();
+ let mut socket = accept_async(stream).await.unwrap();
+ let closed = matches!(socket.next().await, Some(Ok(Message::Close(_))));
+ let _ = closed_tx.send(closed);
+ });
+
+ let mut session = CdpSession::connect(&target).await.unwrap();
+ session.close().await;
+ assert!(closed_rx.await.unwrap());
+ }
+}
diff --git a/crates/opsail-refit-codex/src/error.rs b/crates/opsail-refit-codex/src/error.rs
new file mode 100644
index 0000000..98f3d79
--- /dev/null
+++ b/crates/opsail-refit-codex/src/error.rs
@@ -0,0 +1,62 @@
+use serde::Serialize;
+use thiserror::Error;
+
+/// Stable diagnostic categories returned by the Codex refit adapter.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum CodexRefitErrorCode {
+ Unsupported,
+ TargetNotFound,
+ TargetValidationFailed,
+ BridgeUnavailable,
+ SessionUnavailable,
+ RestartRequired,
+ PortUnavailable,
+ LaunchFailed,
+ InjectionFailed,
+ CleanupFailed,
+ UpdateFailed,
+ Stale,
+ StateIo,
+}
+
+impl CodexRefitErrorCode {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Unsupported => "unsupported",
+ Self::TargetNotFound => "target-not-found",
+ Self::TargetValidationFailed => "target-validation-failed",
+ Self::BridgeUnavailable => "bridge-unavailable",
+ Self::SessionUnavailable => "session-unavailable",
+ Self::RestartRequired => "restart-required",
+ Self::PortUnavailable => "port-unavailable",
+ Self::LaunchFailed => "launch-failed",
+ Self::InjectionFailed => "injection-failed",
+ Self::CleanupFailed => "cleanup-failed",
+ Self::UpdateFailed => "update-failed",
+ Self::Stale => "stale",
+ Self::StateIo => "state-io",
+ }
+ }
+}
+
+/// A bounded error that never retains renderer payloads or credentials.
+#[derive(Debug, Error)]
+#[error("{message}")]
+pub struct CodexRefitError {
+ code: CodexRefitErrorCode,
+ message: String,
+}
+
+impl CodexRefitError {
+ pub(crate) fn new(code: CodexRefitErrorCode, message: impl Into) -> Self {
+ Self {
+ code,
+ message: message.into(),
+ }
+ }
+
+ pub fn code(&self) -> CodexRefitErrorCode {
+ self.code
+ }
+}
diff --git a/crates/opsail-refit-codex/src/github_update.rs b/crates/opsail-refit-codex/src/github_update.rs
new file mode 100644
index 0000000..4a054f9
--- /dev/null
+++ b/crates/opsail-refit-codex/src/github_update.rs
@@ -0,0 +1,186 @@
+use std::collections::BTreeMap;
+use std::future::Future;
+use std::pin::Pin;
+use std::sync::{Once, OnceLock};
+use std::time::Duration;
+
+use futures_util::StreamExt as _;
+use reqwest::redirect::Policy;
+use url::Url;
+
+use crate::error::{CodexRefitError, CodexRefitErrorCode};
+use crate::renderer_assets::{
+ MAX_RENDERER_ASSET_BYTES, RendererAssetBundle, RendererAssetManifest,
+};
+
+const RAW_REPOSITORY_ROOT: &str = "https://raw.githubusercontent.com/lencx/opsail/refs/heads/main";
+const RENDERER_ASSET_PATH: &str = "crates/opsail-refit-codex/assets";
+const UPDATE_MANIFEST_NAME: &str = "opsail-refit-codex-update.json";
+const HTTP_TIMEOUT: Duration = Duration::from_secs(20);
+const MAX_MANIFEST_RESPONSE_BYTES: usize = 64 * 1024;
+
+pub(crate) type ManifestFuture<'a> =
+ Pin> + Send + 'a>>;
+pub(crate) type BundleFuture<'a> =
+ Pin> + Send + 'a>>;
+
+pub(crate) trait RendererAssetUpdateClient: Send + Sync {
+ fn fetch_latest_manifest(&self) -> ManifestFuture<'_>;
+ fn fetch_bundle(&self, manifest: RendererAssetManifest) -> BundleFuture<'_>;
+}
+
+#[derive(Debug, Default)]
+pub(crate) struct GithubRendererAssetClient;
+
+impl RendererAssetUpdateClient for GithubRendererAssetClient {
+ fn fetch_latest_manifest(&self) -> ManifestFuture<'_> {
+ Box::pin(fetch_latest_manifest())
+ }
+
+ fn fetch_bundle(&self, manifest: RendererAssetManifest) -> BundleFuture<'_> {
+ Box::pin(fetch_bundle(manifest))
+ }
+}
+
+async fn fetch_latest_manifest() -> Result {
+ install_tls_provider();
+ let client = http_client()?;
+ let manifest_url = raw_asset_url(UPDATE_MANIFEST_NAME)?;
+ let manifest_bytes = fetch_bounded(
+ client,
+ &manifest_url,
+ MAX_MANIFEST_RESPONSE_BYTES,
+ "renderer asset manifest",
+ )
+ .await?;
+ RendererAssetManifest::parse(&manifest_bytes)
+}
+
+async fn fetch_bundle(
+ manifest: RendererAssetManifest,
+) -> Result {
+ install_tls_provider();
+ let client = http_client()?;
+ let mut pending = futures_util::stream::FuturesUnordered::new();
+ for name in manifest.file_names().map(str::to_owned) {
+ let url = raw_asset_url(&name)?;
+ pending.push(async move {
+ let bytes = fetch_bounded(
+ client,
+ &url,
+ MAX_RENDERER_ASSET_BYTES,
+ "renderer JavaScript",
+ )
+ .await?;
+ Ok::<_, CodexRefitError>((name, bytes))
+ });
+ }
+ let mut files = BTreeMap::new();
+ while let Some(result) = pending.next().await {
+ let (name, bytes) = result?;
+ files.insert(name, bytes);
+ }
+ let manifest_bytes = serde_json::to_vec(&manifest)
+ .map_err(|_| update_error("could not serialize the validated renderer manifest"))?;
+ RendererAssetBundle::from_parts(&manifest_bytes, files)
+}
+
+async fn fetch_bounded(
+ client: &reqwest::Client,
+ url: &Url,
+ limit: usize,
+ kind: &str,
+) -> Result, CodexRefitError> {
+ let response = client
+ .get(url.clone())
+ .send()
+ .await
+ .map_err(|_| update_error(format!("could not download {kind} from GitHub")))?;
+ if response.url() != url {
+ return Err(update_error("GitHub update request changed its fixed URL"));
+ }
+ if !response.status().is_success() {
+ return Err(update_error(format!(
+ "GitHub {kind} request failed with status {}",
+ response.status().as_u16()
+ )));
+ }
+ if response
+ .content_length()
+ .is_some_and(|length| length > limit as u64)
+ {
+ return Err(update_error(format!(
+ "GitHub {kind} exceeds its size limit"
+ )));
+ }
+ let mut bytes = Vec::new();
+ let mut stream = response.bytes_stream();
+ while let Some(chunk) = stream.next().await {
+ let chunk = chunk.map_err(|_| update_error(format!("could not read GitHub {kind}")))?;
+ if bytes.len().saturating_add(chunk.len()) > limit {
+ return Err(update_error(format!(
+ "GitHub {kind} exceeds its size limit"
+ )));
+ }
+ bytes.extend_from_slice(&chunk);
+ }
+ Ok(bytes)
+}
+
+fn raw_asset_url(name: &str) -> Result {
+ let allowed_name = name == UPDATE_MANIFEST_NAME
+ || crate::renderer_assets::RENDERER_ASSET_FILES.contains(&name);
+ if !allowed_name {
+ return Err(update_error(
+ "GitHub renderer asset name is not allowlisted",
+ ));
+ }
+ Url::parse(&format!(
+ "{RAW_REPOSITORY_ROOT}/{RENDERER_ASSET_PATH}/{name}"
+ ))
+ .map_err(|_| update_error("could not construct the fixed GitHub renderer asset URL"))
+}
+
+fn http_client() -> Result<&'static reqwest::Client, CodexRefitError> {
+ static CLIENT: OnceLock> = OnceLock::new();
+ CLIENT
+ .get_or_init(|| {
+ reqwest::Client::builder()
+ .connect_timeout(HTTP_TIMEOUT)
+ .timeout(HTTP_TIMEOUT)
+ .redirect(Policy::none())
+ .user_agent(format!("opsail-refit-codex/{}", env!("CARGO_PKG_VERSION")))
+ .build()
+ .map_err(|_| ())
+ })
+ .as_ref()
+ .map_err(|()| update_error("could not initialize the GitHub update client"))
+}
+
+fn install_tls_provider() {
+ static INSTALL: Once = Once::new();
+ INSTALL.call_once(|| {
+ let _ = rustls::crypto::ring::default_provider().install_default();
+ });
+}
+
+fn update_error(message: impl Into) -> CodexRefitError {
+ CodexRefitError::new(CodexRefitErrorCode::UpdateFailed, message)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn raw_urls_are_pinned_to_the_official_repository_main_branch() {
+ let url = raw_asset_url(UPDATE_MANIFEST_NAME).unwrap();
+ assert_eq!(url.scheme(), "https");
+ assert_eq!(url.host_str(), Some("raw.githubusercontent.com"));
+ assert_eq!(
+ url.path(),
+ format!("/lencx/opsail/refs/heads/main/{RENDERER_ASSET_PATH}/{UPDATE_MANIFEST_NAME}")
+ );
+ assert!(raw_asset_url("unexpected.js").is_err());
+ }
+}
diff --git a/crates/opsail-refit-codex/src/launch.rs b/crates/opsail-refit-codex/src/launch.rs
new file mode 100644
index 0000000..757f553
--- /dev/null
+++ b/crates/opsail-refit-codex/src/launch.rs
@@ -0,0 +1,358 @@
+use std::future::Future;
+use std::time::Duration;
+
+use tokio::time::{Instant, sleep};
+
+use crate::ProgressReporter;
+use crate::error::{CodexRefitError, CodexRefitErrorCode};
+use crate::model::CodexRefitStage;
+use crate::platform::{self, LaunchedProcess, ValidatedAppIdentity};
+
+pub(crate) trait LaunchBackend: Send + Sync {
+ #[cfg(test)]
+ fn validate_app(&self) -> Result;
+ fn loopback_port_available(&self, port: u16) -> Result;
+ fn app_is_running(&self, app: &ValidatedAppIdentity) -> Result;
+ fn spawn(
+ &self,
+ port: u16,
+ app: &ValidatedAppIdentity,
+ ) -> Result;
+}
+
+pub(crate) struct SystemLaunchBackend;
+
+impl LaunchBackend for SystemLaunchBackend {
+ #[cfg(test)]
+ fn validate_app(&self) -> Result {
+ platform::validate_app()
+ }
+
+ fn loopback_port_available(&self, port: u16) -> Result {
+ platform::loopback_port_available(port)
+ }
+
+ fn app_is_running(&self, app: &ValidatedAppIdentity) -> Result {
+ platform::app_is_running(app)
+ }
+
+ fn spawn(
+ &self,
+ port: u16,
+ app: &ValidatedAppIdentity,
+ ) -> Result {
+ platform::launch_app(port, app)
+ }
+}
+
+#[cfg(test)]
+fn launch_if_stopped(
+ backend: &dyn LaunchBackend,
+ port: u16,
+) -> Result<(ValidatedAppIdentity, LaunchedProcess), CodexRefitError> {
+ let app = backend.validate_app()?;
+ let process = launch_validated(backend, port, &app, &ProgressReporter::default())?;
+ Ok((app, process))
+}
+
+pub(crate) fn launch_validated(
+ backend: &dyn LaunchBackend,
+ port: u16,
+ app: &ValidatedAppIdentity,
+ progress: &ProgressReporter,
+) -> Result {
+ progress.report(CodexRefitStage::CheckLaunchReadiness);
+ if !backend.loopback_port_available(port)? {
+ return Err(CodexRefitError::new(
+ CodexRefitErrorCode::PortUnavailable,
+ format!("loopback CDP port {port} is already occupied"),
+ ));
+ }
+ if backend.app_is_running(app)? {
+ return Err(CodexRefitError::new(
+ CodexRefitErrorCode::RestartRequired,
+ "ChatGPT is already running without the requested CDP listener; quit and relaunch it manually or choose attach-only after configuring CDP",
+ ));
+ }
+ progress.report(CodexRefitStage::LaunchApplication);
+ backend.spawn(port, app)
+}
+
+pub(crate) async fn wait_for_endpoint(
+ port: u16,
+ timeout: Duration,
+ mut connect: F,
+) -> Result
+where
+ F: FnMut() -> Fut,
+ Fut: Future>,
+{
+ let deadline = Instant::now() + timeout;
+ let mut delay = Duration::from_millis(100);
+ loop {
+ match connect().await {
+ Ok(value) => return Ok(value),
+ Err(error) if !retryable_launch_wait(error.code()) => return Err(error),
+ Err(_) if Instant::now() >= deadline => {
+ return Err(CodexRefitError::new(
+ CodexRefitErrorCode::LaunchFailed,
+ format!(
+ "ChatGPT did not expose a validated loopback CDP endpoint on port {port} before the launch timeout"
+ ),
+ ));
+ }
+ Err(_) => {
+ sleep(delay.min(deadline.saturating_duration_since(Instant::now()))).await;
+ delay = delay.saturating_mul(2).min(Duration::from_secs(1));
+ }
+ }
+ }
+}
+
+pub(crate) async fn wait_for_endpoint_or_process_exit(
+ port: u16,
+ timeout: Duration,
+ mut process_exit: tokio::sync::watch::Receiver,
+ connect: F,
+) -> Result
+where
+ F: FnMut() -> Fut,
+ Fut: Future>,
+{
+ // The real renderer connection future is intentionally large. Keep it off
+ // the caller's stack so debug Windows builds do not exhaust the platform's
+ // smaller default main-thread stack while `select!` also holds the process
+ // exit future.
+ let endpoint = Box::pin(wait_for_endpoint(port, timeout, connect));
+ tokio::select! {
+ result = endpoint => result,
+ () = wait_for_process_exit(&mut process_exit) => Err(CodexRefitError::new(
+ CodexRefitErrorCode::LaunchFailed,
+ "the launched ChatGPT process exited before its validated CDP endpoint was ready",
+ )),
+ }
+}
+
+async fn wait_for_process_exit(exit: &mut tokio::sync::watch::Receiver) {
+ if *exit.borrow() {
+ return;
+ }
+ while exit.changed().await.is_ok() {
+ if *exit.borrow() {
+ return;
+ }
+ }
+}
+
+fn retryable_launch_wait(code: CodexRefitErrorCode) -> bool {
+ matches!(
+ code,
+ CodexRefitErrorCode::SessionUnavailable
+ | CodexRefitErrorCode::TargetNotFound
+ | CodexRefitErrorCode::BridgeUnavailable
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::sync::{Arc, Mutex};
+
+ use super::*;
+
+ struct FakeBackend {
+ validation_error: bool,
+ port_available: bool,
+ running: bool,
+ spawn_error: bool,
+ validations: AtomicUsize,
+ port_checks: AtomicUsize,
+ inspections: AtomicUsize,
+ spawns: AtomicUsize,
+ }
+
+ impl FakeBackend {
+ fn new(port_available: bool, running: bool, spawn_error: bool) -> Self {
+ Self {
+ validation_error: false,
+ port_available,
+ running,
+ spawn_error,
+ validations: AtomicUsize::new(0),
+ port_checks: AtomicUsize::new(0),
+ inspections: AtomicUsize::new(0),
+ spawns: AtomicUsize::new(0),
+ }
+ }
+
+ fn invalid() -> Self {
+ Self {
+ validation_error: true,
+ ..Self::new(true, false, false)
+ }
+ }
+ }
+
+ impl LaunchBackend for FakeBackend {
+ fn validate_app(&self) -> Result {
+ self.validations.fetch_add(1, Ordering::Relaxed);
+ if self.validation_error {
+ Err(CodexRefitError::new(
+ CodexRefitErrorCode::TargetValidationFailed,
+ "planned application validation failure",
+ ))
+ } else {
+ Ok(ValidatedAppIdentity::for_test())
+ }
+ }
+
+ fn loopback_port_available(&self, _port: u16) -> Result {
+ self.port_checks.fetch_add(1, Ordering::Relaxed);
+ Ok(self.port_available)
+ }
+
+ fn app_is_running(&self, _app: &ValidatedAppIdentity) -> Result {
+ self.inspections.fetch_add(1, Ordering::Relaxed);
+ Ok(self.running)
+ }
+
+ fn spawn(
+ &self,
+ _port: u16,
+ _app: &ValidatedAppIdentity,
+ ) -> Result {
+ self.spawns.fetch_add(1, Ordering::Relaxed);
+ if self.spawn_error {
+ Err(CodexRefitError::new(
+ CodexRefitErrorCode::LaunchFailed,
+ "planned launch failure",
+ ))
+ } else {
+ Ok(LaunchedProcess::untracked(4242))
+ }
+ }
+ }
+
+ #[test]
+ fn invalid_application_fails_before_port_or_process_work() {
+ let backend = FakeBackend::invalid();
+ let error = launch_if_stopped(&backend, 55321).unwrap_err();
+ assert_eq!(error.code(), CodexRefitErrorCode::TargetValidationFailed);
+ assert_eq!(backend.validations.load(Ordering::Relaxed), 1);
+ assert_eq!(backend.port_checks.load(Ordering::Relaxed), 0);
+ assert_eq!(backend.inspections.load(Ordering::Relaxed), 0);
+ assert_eq!(backend.spawns.load(Ordering::Relaxed), 0);
+ }
+
+ #[test]
+ fn occupied_port_fails_before_process_inspection_or_spawn() {
+ let backend = FakeBackend::new(false, false, false);
+ let error = launch_if_stopped(&backend, 55321).unwrap_err();
+ assert_eq!(error.code(), CodexRefitErrorCode::PortUnavailable);
+ assert_eq!(backend.validations.load(Ordering::Relaxed), 1);
+ assert_eq!(backend.port_checks.load(Ordering::Relaxed), 1);
+ assert_eq!(backend.inspections.load(Ordering::Relaxed), 0);
+ assert_eq!(backend.spawns.load(Ordering::Relaxed), 0);
+ }
+
+ #[test]
+ fn running_app_requires_manual_restart_and_is_never_spawned() {
+ let backend = FakeBackend::new(true, true, false);
+ let error = launch_if_stopped(&backend, 55321).unwrap_err();
+ assert_eq!(error.code(), CodexRefitErrorCode::RestartRequired);
+ assert_eq!(backend.inspections.load(Ordering::Relaxed), 1);
+ assert_eq!(backend.spawns.load(Ordering::Relaxed), 0);
+ }
+
+ #[test]
+ fn stopped_app_is_spawned_exactly_once() {
+ let backend = FakeBackend::new(true, false, false);
+ let (_, process) = launch_if_stopped(&backend, 55321).unwrap();
+ assert_eq!(process.pid(), 4242);
+ assert_eq!(backend.spawns.load(Ordering::Relaxed), 1);
+ }
+
+ #[test]
+ fn launch_reports_preflight_before_spawn() {
+ let backend = FakeBackend::new(true, false, false);
+ let observed = Arc::new(Mutex::new(Vec::new()));
+ let progress_observed = Arc::clone(&observed);
+ let progress = ProgressReporter(Some(Arc::new(move |stage| {
+ progress_observed.lock().unwrap().push(stage);
+ })));
+ launch_validated(
+ &backend,
+ 55321,
+ &ValidatedAppIdentity::for_test(),
+ &progress,
+ )
+ .unwrap();
+ assert_eq!(
+ *observed.lock().unwrap(),
+ [
+ CodexRefitStage::CheckLaunchReadiness,
+ CodexRefitStage::LaunchApplication,
+ ]
+ );
+ }
+
+ #[test]
+ fn spawn_failure_has_a_distinct_diagnostic() {
+ let backend = FakeBackend::new(true, false, true);
+ let error = launch_if_stopped(&backend, 55321).unwrap_err();
+ assert_eq!(error.code(), CodexRefitErrorCode::LaunchFailed);
+ assert_eq!(backend.spawns.load(Ordering::Relaxed), 1);
+ }
+
+ #[tokio::test]
+ async fn endpoint_wait_retries_bounded_startup_states_and_then_succeeds() {
+ let attempts = AtomicUsize::new(0);
+ let value = wait_for_endpoint(55321, Duration::from_secs(1), || async {
+ let attempt = attempts.fetch_add(1, Ordering::Relaxed);
+ if attempt < 2 {
+ Err(CodexRefitError::new(
+ CodexRefitErrorCode::SessionUnavailable,
+ "not ready",
+ ))
+ } else {
+ Ok("ready")
+ }
+ })
+ .await
+ .unwrap();
+ assert_eq!(value, "ready");
+ assert_eq!(attempts.load(Ordering::Relaxed), 3);
+ }
+
+ #[tokio::test]
+ async fn endpoint_wait_times_out_without_respawning() {
+ let attempts = AtomicUsize::new(0);
+ let error = wait_for_endpoint(55321, Duration::from_millis(1), || async {
+ attempts.fetch_add(1, Ordering::Relaxed);
+ Err::<(), _>(CodexRefitError::new(
+ CodexRefitErrorCode::SessionUnavailable,
+ "not ready",
+ ))
+ })
+ .await
+ .unwrap_err();
+ assert_eq!(error.code(), CodexRefitErrorCode::LaunchFailed);
+ assert!(attempts.load(Ordering::Relaxed) >= 1);
+ }
+
+ #[tokio::test]
+ async fn endpoint_wait_stops_as_soon_as_the_launched_process_exits() {
+ let (exit_tx, exit) = tokio::sync::watch::channel(false);
+ tokio::spawn(async move {
+ tokio::task::yield_now().await;
+ let _ = exit_tx.send(true);
+ });
+ let error = wait_for_endpoint_or_process_exit(55321, Duration::from_secs(30), exit, || {
+ std::future::pending::