From 974850426aa7bf5f07eb3cc3fd8a40a236c2ab27 Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Sun, 13 Sep 2026 01:07:02 +0530 Subject: [PATCH 1/3] feat(sdk): add typed TypeScript client --- .github/workflows/ci.yml | 15 +- .github/workflows/release.yml | 7 +- packages/headless-npm/LICENSE | 22 + packages/headless-npm/README.md | 75 +- packages/headless-npm/lib/installer.d.mts | 18 + packages/headless-npm/lib/installer.mjs | 234 +- packages/headless-npm/package.json | 31 +- packages/headless-npm/scripts/clean.mjs | 6 + .../headless-npm/scripts/generate-sdk.mjs | 271 ++ packages/headless-npm/scripts/lint.mjs | 24 + packages/headless-npm/src/client.ts | 209 + packages/headless-npm/src/errors.ts | 167 + packages/headless-npm/src/generated.ts | 3409 +++++++++++++++++ packages/headless-npm/src/index.ts | 25 + packages/headless-npm/src/lifecycle.ts | 445 +++ packages/headless-npm/src/protocol.ts | 332 ++ packages/headless-npm/src/transport.ts | 232 ++ .../test/fixtures/auth-required.json | 53 + packages/headless-npm/test/helpers.mjs | 74 + packages/headless-npm/test/installer.test.mjs | 134 +- packages/headless-npm/test/lifecycle.test.mjs | 398 ++ .../test/macos-swift-integration.mjs | 33 + packages/headless-npm/test/package.test.mjs | 67 + packages/headless-npm/test/protocol.test.mjs | 175 + packages/headless-npm/test/transport.test.mjs | 263 ++ packages/headless-npm/test/types.test.ts | 40 + packages/headless-npm/tsconfig.json | 22 + packages/headless-npm/tsconfig.test.json | 10 + pnpm-lock.yaml | 21 +- 29 files changed, 6760 insertions(+), 52 deletions(-) create mode 100644 packages/headless-npm/LICENSE create mode 100644 packages/headless-npm/lib/installer.d.mts create mode 100644 packages/headless-npm/scripts/clean.mjs create mode 100644 packages/headless-npm/scripts/generate-sdk.mjs create mode 100644 packages/headless-npm/scripts/lint.mjs create mode 100644 packages/headless-npm/src/client.ts create mode 100644 packages/headless-npm/src/errors.ts create mode 100644 packages/headless-npm/src/generated.ts create mode 100644 packages/headless-npm/src/index.ts create mode 100644 packages/headless-npm/src/lifecycle.ts create mode 100644 packages/headless-npm/src/protocol.ts create mode 100644 packages/headless-npm/src/transport.ts create mode 100644 packages/headless-npm/test/fixtures/auth-required.json create mode 100644 packages/headless-npm/test/helpers.mjs create mode 100644 packages/headless-npm/test/lifecycle.test.mjs create mode 100644 packages/headless-npm/test/macos-swift-integration.mjs create mode 100644 packages/headless-npm/test/package.test.mjs create mode 100644 packages/headless-npm/test/protocol.test.mjs create mode 100644 packages/headless-npm/test/transport.test.mjs create mode 100644 packages/headless-npm/test/types.test.ts create mode 100644 packages/headless-npm/tsconfig.json create mode 100644 packages/headless-npm/tsconfig.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5556444..69f6d66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile --filter @lockintime/headless - run: pnpm --filter @lockintime/headless test @@ -144,10 +144,23 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile --filter @lockintime/headless - name: Build app run: ./apps/headless/build.sh - name: Protocol and security tests run: ./apps/headless/test.sh + - name: Build TypeScript SDK + run: pnpm --filter @lockintime/headless build + - name: Swift CLI to TypeScript SDK integration + env: + HEADLESS_TEST_CLI: ${{ github.workspace }}/apps/headless/Headless.app/Contents/Resources/bin/headless + HEADLESS_TEST_HOST: ${{ github.workspace }}/apps/headless/Headless.app/Contents/MacOS/Headless + run: node packages/headless-npm/test/macos-swift-integration.mjs macos-e2e: name: macOS E2E (WKWebView) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efe110b..2853e6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,7 +103,7 @@ jobs: - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile --filter @lockintime/headless - run: pnpm --filter @lockintime/headless test @@ -416,10 +416,13 @@ jobs: id-token: write steps: - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 22 registry-url: https://registry.npmjs.org + cache: pnpm + - run: pnpm install --frozen-lockfile --filter @lockintime/headless - name: Publish verified launcher working-directory: packages/headless-npm env: diff --git a/packages/headless-npm/LICENSE b/packages/headless-npm/LICENSE new file mode 100644 index 0000000..68385f9 --- /dev/null +++ b/packages/headless-npm/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 LockInTime +Copyright (c) 2026 Antiwork, Inc. (original chromeless foundation) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/headless-npm/README.md b/packages/headless-npm/README.md index 50ab6e8..da003cb 100644 --- a/packages/headless-npm/README.md +++ b/packages/headless-npm/README.md @@ -1,6 +1,12 @@ # `@lockintime/headless` -Verified npm launcher for the [Headless agent browser](https://github.com/LockInTime/headless). +Typed Node.js SDK and verified npm launcher for the +[Headless agent browser](https://github.com/LockInTime/headless). Node.js 22 or +newer is required. The SDK has no runtime dependencies and talks directly to +Headless over its private per-user Unix socket. It does not start a network +service or use MCP as an internal transport. + +## CLI launcher ```sh npx @lockintime/headless help @@ -15,3 +21,70 @@ plus Linux x86_64 and arm64. Windows users should use the published GHCR image. Set `HEADLESS_NPM_CACHE` to an absolute directory to move the verified cache. The release download origin is fixed and cannot be overridden. + +Because this package distributes both the SDK and verified product launcher, +its version follows Headless product tags. The supported wire and schema +versions remain independent and are pinned in the generated SDK contract. + +## Connect to a shared host + +Replace repeated CLI calls with typed methods. Closing this client closes only +its active socket requests. It never stops a shared Headless host. + +```ts +import { connect } from "@lockintime/headless"; + +// CLI: headless status +await using headless = await connect(); + +// CLI: headless visit https://example.com +const page = await headless.visit({ url: "https://example.com" }); +if (page.untrustedContent) { + console.log(page.value.title); +} +``` + +Every page-derived result is returned as `Untrusted`. Callers must preserve +that trust marker when sending page content to an agent or another system. + +## Supervised host + +Use `launch()` when this process must own a new host. It invokes the installed +CLI with `headless start --background --supervised`, keeps the ownership pipe +open, verifies that the startup response and socket report the same host PID, +and reaps only that launcher during disposal. It fails rather than claiming an +already-running shared host. + +```ts +import { launch } from "@lockintime/headless"; + +await using host = await launch({ + allow: ["example.com"], + installationTimeoutMs: 300_000, + startupTimeoutMs: 10_000, +}); + +const session = await host.client.openSession("research", { isolated: true }); +await using scoped = session; +const snapshot = await scoped.inspect({ context: "actions" }); +``` + +Session helpers expose only session-scoped commands. Host lifecycle and session +creation remain on `HeadlessClient`. + +## Cancellation and authentication + +Methods accept `{ signal, timeoutMs }` as their final argument. Cancellation or +timeout before any request byte is written is retry-safe. After a write, +`OperationOutcomeUnknown` means the SDK cannot know whether the browser action +completed. Never retry it automatically; inspect browser state first. + +Saved-login methods accept only a challenge ID and account alias. There is no +password parameter in the authentication API: + +```ts +await scoped.authLogin({ + challenge: "11111111-1111-4111-8111-111111111111", + account: "work", +}); +``` diff --git a/packages/headless-npm/lib/installer.d.mts b/packages/headless-npm/lib/installer.d.mts new file mode 100644 index 0000000..b143943 --- /dev/null +++ b/packages/headless-npm/lib/installer.d.mts @@ -0,0 +1,18 @@ +export interface InstalledRelease { + readonly directory: string; + readonly release: { + readonly executable: string; + }; +} + +export interface InstallOptions { + readonly cacheRoot?: string; + readonly signal?: AbortSignal; +} + +export function defaultCacheRoot( + platform?: NodeJS.Platform, + environment?: NodeJS.ProcessEnv, +): string; + +export function ensureInstalled(options?: InstallOptions): Promise; diff --git a/packages/headless-npm/lib/installer.mjs b/packages/headless-npm/lib/installer.mjs index dc1b754..0ab9441 100644 --- a/packages/headless-npm/lib/installer.mjs +++ b/packages/headless-npm/lib/installer.mjs @@ -20,6 +20,9 @@ const RELEASE_ORIGIN = "https://github.com"; const RELEASE_REPOSITORY = "LockInTime/headless"; const MANIFEST_LIMIT = 256 * 1024; const ASSET_LIMIT = 512 * 1024 * 1024; +const PROCESS_STDOUT_LIMIT = 1024 * 1024; +const PROCESS_STDERR_LIMIT = 64 * 1024; +const PROCESS_TIMEOUT_MS = 120_000; const LOCK_WAIT_MS = 30_000; const LOCK_STALE_MS = 20 * 60_000; const REDIRECT_LIMIT = 5; @@ -33,15 +36,45 @@ const ALLOWED_DOWNLOAD_HOSTS = new Set([ const SEMVER = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; export class InstallError extends Error { - constructor(message, exitCode = 69) { - super(message); + constructor(message, exitCode = 69, options) { + super(message, options); this.name = "InstallError"; this.exitCode = exitCode; } } -function sleep(milliseconds) { - return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); +export class InstallCancelledError extends InstallError { + constructor(signal) { + super("Headless installation was cancelled", 75, { + ...(signal?.reason instanceof Error ? { cause: signal.reason } : {}), + }); + this.name = "InstallCancelledError"; + } +} + +function throwIfCancelled(signal) { + if (signal?.aborted) throw new InstallCancelledError(signal); +} + +function sleep(milliseconds, signal) { + throwIfCancelled(signal); + return new Promise((resolvePromise, rejectPromise) => { + let settled = false; + const finish = (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + if (error) rejectPromise(error); + else resolvePromise(); + }; + const onAbort = () => { + finish(new InstallCancelledError(signal)); + }; + const timer = setTimeout(() => finish(), milliseconds); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + }); } function packageRoot() { @@ -127,21 +160,37 @@ function validateDownloadURL(url, allowedHosts, allowHTTP, allowCustomPort) { } async function trustedFetch(url, options) { - const { allowedHosts, allowHTTP, allowCustomPort, fetchImpl, timeoutMilliseconds } = options; + const { allowedHosts, allowHTTP, allowCustomPort, fetchImpl, signal, timeoutMilliseconds } = options; let current = new URL(url); for (let redirects = 0; redirects <= REDIRECT_LIMIT; redirects += 1) { + throwIfCancelled(signal); validateDownloadURL(current, allowedHosts, allowHTTP, allowCustomPort); - const response = await fetchImpl(current, { - redirect: "manual", - signal: AbortSignal.timeout(timeoutMilliseconds), - }); + const timeoutSignal = AbortSignal.timeout(timeoutMilliseconds); + const requestSignal = signal === undefined + ? timeoutSignal + : AbortSignal.any([signal, timeoutSignal]); + let response; + try { + response = await fetchImpl(current, { redirect: "manual", signal: requestSignal }); + } catch (cause) { + if (signal?.aborted) throw new InstallCancelledError(signal); + if (timeoutSignal.aborted) { + throw new InstallError("release download timed out", 69, { cause }); + } + throw new InstallError("release download failed", 69, { cause }); + } if (response.status >= 300 && response.status < 400) { const location = response.headers.get("location"); - if (!location) throw new InstallError("release download returned a redirect without a location"); + if (!location) { + await response.body?.cancel(); + throw new InstallError("release download returned a redirect without a location"); + } + await response.body?.cancel(); current = new URL(location, current); continue; } if (!response.ok) { + await response.body?.cancel(); throw new InstallError(`release download failed with HTTP ${response.status}: ${current}`); } return response; @@ -149,18 +198,28 @@ async function trustedFetch(url, options) { throw new InstallError("release download exceeded the redirect limit"); } -async function boundedText(response, maximumBytes) { +async function boundedText(response, maximumBytes, signal) { + throwIfCancelled(signal); + if (response.body === null) throw new InstallError("release manifest response has no body"); const declared = Number(response.headers.get("content-length")); if (Number.isFinite(declared) && declared > maximumBytes) { + await response.body.cancel(); throw new InstallError("release manifest is too large"); } const chunks = []; let size = 0; - for await (const chunk of response.body) { - size += chunk.byteLength; - if (size > maximumBytes) throw new InstallError("release manifest is too large"); - chunks.push(chunk); + try { + for await (const chunk of response.body) { + throwIfCancelled(signal); + size += chunk.byteLength; + if (size > maximumBytes) throw new InstallError("release manifest is too large"); + chunks.push(chunk); + } + } catch (cause) { + if (signal?.aborted) throw new InstallCancelledError(signal); + throw cause; } + throwIfCancelled(signal); return Buffer.concat(chunks, size).toString("utf8"); } @@ -176,9 +235,14 @@ export function checksumFromManifest(manifest, asset) { return matches[0]; } -async function downloadAsset(response, destination, expectedChecksum) { - const declared = Number(response.headers.get("content-length")); - if (Number.isFinite(declared) && (declared <= 0 || declared > ASSET_LIMIT)) { +async function downloadAsset(response, destination, expectedChecksum, signal) { + throwIfCancelled(signal); + if (response.body === null) throw new InstallError("release asset response has no body", 65); + const contentLength = response.headers.get("content-length"); + const declared = contentLength === null ? undefined : Number(contentLength); + if (declared !== undefined + && (!Number.isSafeInteger(declared) || declared <= 0 || declared > ASSET_LIMIT)) { + await response.body.cancel(); throw new InstallError("release asset has an unsafe size", 65); } const handle = await open(destination, "wx", 0o600); @@ -186,11 +250,13 @@ async function downloadAsset(response, destination, expectedChecksum) { let size = 0; try { for await (const chunk of response.body) { + throwIfCancelled(signal); size += chunk.byteLength; if (size > ASSET_LIMIT) throw new InstallError("release asset is too large", 65); hash.update(chunk); let offset = 0; while (offset < chunk.byteLength) { + throwIfCancelled(signal); const { bytesWritten } = await handle.write( chunk, offset, @@ -200,24 +266,98 @@ async function downloadAsset(response, destination, expectedChecksum) { offset += bytesWritten; } } + } catch (cause) { + if (signal?.aborted) throw new InstallCancelledError(signal); + throw cause; } finally { await handle.close(); } + throwIfCancelled(signal); if (size === 0) throw new InstallError("release asset is empty", 65); const actual = hash.digest("hex"); if (actual !== expectedChecksum) throw new InstallError("release asset checksum mismatch", 65); } -function run(command, argumentsList, options = {}) { +export function run(command, argumentsList, options = {}) { + throwIfCancelled(options.signal); + const timeoutMilliseconds = options.timeoutMilliseconds ?? PROCESS_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMilliseconds) + || timeoutMilliseconds < 1 || timeoutMilliseconds > PROCESS_TIMEOUT_MS) { + throw new InstallError( + `subprocess timeout must be an integer between 1 and ${PROCESS_TIMEOUT_MS}`, + 64, + ); + } return new Promise((resolvePromise, rejectPromise) => { const child = spawn(command, argumentsList, { stdio: options.stdio ?? "pipe" }); let stdout = ""; let stderr = ""; - child.stdout?.on("data", (chunk) => { stdout += chunk; }); - child.stderr?.on("data", (chunk) => { stderr += chunk; }); - child.on("error", rejectPromise); + let stdoutBytes = 0; + let stderrBytes = 0; + let cancelled = false; + let outputError; + let killTimer; + let timeoutTimer; + const cleanup = () => { + options.signal?.removeEventListener("abort", onAbort); + if (killTimer) clearTimeout(killTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + }; + const terminate = () => { + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), 1_000); + killTimer.unref(); + }; + const onAbort = () => { + if (cancelled) return; + cancelled = true; + terminate(); + }; + const stopForOutput = (stream, maximumBytes) => { + if (outputError !== undefined) return; + outputError = new InstallError( + `${basename(command)} ${stream} exceeded ${maximumBytes} bytes`, + 65, + ); + terminate(); + }; + options.signal?.addEventListener("abort", onAbort, { once: true }); + if (options.signal?.aborted) onAbort(); + timeoutTimer = setTimeout(() => { + if (cancelled || outputError !== undefined) return; + outputError = new InstallError( + `${basename(command)} timed out after ${timeoutMilliseconds} ms`, + 75, + ); + terminate(); + }, timeoutMilliseconds); + timeoutTimer.unref(); + child.stdout?.on("data", (chunk) => { + stdoutBytes += chunk.byteLength; + if (stdoutBytes > PROCESS_STDOUT_LIMIT) { + stopForOutput("stdout", PROCESS_STDOUT_LIMIT); + } else { + stdout += chunk; + } + }); + child.stderr?.on("data", (chunk) => { + stderrBytes += chunk.byteLength; + if (stderrBytes > PROCESS_STDERR_LIMIT) { + stopForOutput("stderr", PROCESS_STDERR_LIMIT); + } else { + stderr += chunk; + } + }); + child.on("error", (cause) => { + cleanup(); + if (cancelled) rejectPromise(new InstallCancelledError(options.signal)); + else rejectPromise(cause); + }); child.on("close", (code, signal) => { - if (code === 0) resolvePromise({ stdout, stderr }); + cleanup(); + if (cancelled) rejectPromise(new InstallCancelledError(options.signal)); + else if (outputError) rejectPromise(outputError); + else if (code === 0) resolvePromise({ stdout, stderr }); else rejectPromise(new InstallError( `${basename(command)} failed${signal ? ` with ${signal}` : ` with status ${code}`}: ${stderr.trim()}`, 65, @@ -269,9 +409,10 @@ export function validateArchiveEntries(text, kind) { return entries; } -async function rejectLinks(root) { +async function rejectLinks(root, signal) { const pending = [root]; while (pending.length > 0) { + throwIfCancelled(signal); const directory = pending.pop(); for (const entry of await readdir(directory, { withFileTypes: true })) { const path = join(directory, entry.name); @@ -283,37 +424,40 @@ async function rejectLinks(root) { } } -async function extractArchive(archive, staging, release) { +async function extractArchive(archive, staging, release, signal) { if (release.kind === "tar.gz") { - const listing = await run("/usr/bin/tar", ["-tzf", archive]); + const listing = await run("/usr/bin/tar", ["-tzf", archive], { signal }); validateArchiveEntries(listing.stdout, release.kind); - await run("/usr/bin/tar", ["-xzf", archive, "-C", staging, "--no-same-owner", "--no-same-permissions"]); + await run("/usr/bin/tar", ["-xzf", archive, "-C", staging, "--no-same-owner", "--no-same-permissions"], { signal }); } else { - const listing = await run("/usr/bin/unzip", ["-Z1", archive]); + const listing = await run("/usr/bin/unzip", ["-Z1", archive], { signal }); validateArchiveEntries(listing.stdout, release.kind); - await run("/usr/bin/unzip", ["-q", archive, "-d", staging]); + await run("/usr/bin/unzip", ["-q", archive, "-d", staging], { signal }); } - await rejectLinks(staging); + await rejectLinks(staging, signal); } -async function isUsableInstall(directory, release, version) { +async function isUsableInstall(directory, release, version, signal) { try { + throwIfCancelled(signal); for (const relative of [ release.executable, release.hostExecutable, release.mcpExecutable, release.brokerExecutable, ]) { const metadata = await lstat(join(directory, relative)); if (!metadata.isFile() || metadata.isSymbolicLink()) return false; } - const result = await run(join(directory, release.executable), ["--version"]); + const result = await run(join(directory, release.executable), ["--version"], { signal }); return result.stdout.trim() === `headless ${version}`; - } catch { + } catch (error) { + if (error instanceof InstallCancelledError) throw error; return false; } } -async function acquireLock(lockPath) { +async function acquireLock(lockPath, signal) { const deadline = Date.now() + LOCK_WAIT_MS; while (Date.now() < deadline) { + throwIfCancelled(signal); try { await mkdir(lockPath, { mode: 0o700 }); return; @@ -328,13 +472,15 @@ async function acquireLock(lockPath) { } catch (statError) { if (statError.code !== "ENOENT") throw statError; } - await sleep(100); + await sleep(100, signal); } } throw new InstallError("timed out waiting for another Headless npm installation", 75); } export async function ensureInstalled(options = {}) { + const signal = options.signal; + throwIfCancelled(signal); const version = options.version ?? await packageVersion(); const platform = options.platform ?? process.platform; const architecture = options.architecture ?? process.arch; @@ -347,13 +493,14 @@ export async function ensureInstalled(options = {}) { await chmod(cacheRoot, 0o700).catch(() => {}); await chmod(installParent, 0o700); - if (await isUsableInstall(installDirectory, release, version)) { + if (await isUsableInstall(installDirectory, release, version, signal)) { return { directory: installDirectory, release }; } - await acquireLock(lockPath); + await acquireLock(lockPath, signal); try { - if (await isUsableInstall(installDirectory, release, version)) { + throwIfCancelled(signal); + if (await isUsableInstall(installDirectory, release, version, signal)) { return { directory: installDirectory, release }; } await rm(installDirectory, { recursive: true, force: true }); @@ -372,24 +519,27 @@ export async function ensureInstalled(options = {}) { allowHTTP, allowCustomPort: options.allowCustomPort === true, fetchImpl, + signal, }; const manifestResponse = await trustedFetch(`${baseURL}/SHA256SUMS`, { ...fetchOptions, timeoutMilliseconds: MANIFEST_TIMEOUT_MS, }); - const manifest = await boundedText(manifestResponse, MANIFEST_LIMIT); + const manifest = await boundedText(manifestResponse, MANIFEST_LIMIT, signal); const checksum = checksumFromManifest(manifest, release.asset); const assetResponse = await trustedFetch(`${baseURL}/${release.asset}`, { ...fetchOptions, timeoutMilliseconds: ASSET_TIMEOUT_MS, }); - await downloadAsset(assetResponse, archive, checksum); - await extractArchive(archive, staging, release); + await downloadAsset(assetResponse, archive, checksum, signal); + await extractArchive(archive, staging, release, signal); + throwIfCancelled(signal); await chmod(join(staging, release.executable), 0o755); await chmod(join(staging, release.hostExecutable), 0o755); await chmod(join(staging, release.mcpExecutable), 0o755); await chmod(join(staging, release.brokerExecutable), 0o755); - if (!(await isUsableInstall(staging, release, version))) { + if (!(await isUsableInstall(staging, release, version, signal))) { throw new InstallError("downloaded Headless package failed its version check", 65); } + throwIfCancelled(signal); await rename(staging, installDirectory); return { directory: installDirectory, release }; } finally { diff --git a/packages/headless-npm/package.json b/packages/headless-npm/package.json index e3c3c85..bf55405 100644 --- a/packages/headless-npm/package.json +++ b/packages/headless-npm/package.json @@ -1,7 +1,7 @@ { "name": "@lockintime/headless", "version": "1.1.0", - "description": "Verified npm launcher for the Headless agent browser", + "description": "Typed local SDK and verified launcher for the Headless agent browser", "license": "MIT", "repository": { "type": "git", @@ -11,18 +11,34 @@ "homepage": "https://github.com/LockInTime/headless", "bugs": "https://github.com/LockInTime/headless/issues", "type": "module", + "sideEffects": false, + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, "bin": { "headless": "bin/headless.mjs", "headless-mcp": "bin/headless-mcp.mjs" }, "files": [ "bin", + "dist", "lib", + "LICENSE", "README.md" ], "scripts": { - "test": "node --test test/*.test.mjs", - "prepack": "npm test" + "build": "npm run clean && npm run generate:check && tsc -p tsconfig.json", + "clean": "node scripts/clean.mjs", + "generate": "node scripts/generate-sdk.mjs", + "generate:check": "node scripts/generate-sdk.mjs --check", + "lint": "node scripts/lint.mjs && npm run generate:check", + "test": "npm run build && npm run typecheck && node --test test/*.test.mjs", + "typecheck": "tsc -p tsconfig.test.json", + "prepack": "npm run lint && npm test" }, "engines": { "node": ">=22" @@ -30,5 +46,14 @@ "publishConfig": { "access": "public", "provenance": true + }, + "keywords": [ + "browser-automation", + "headless-browser", + "sdk" + ], + "devDependencies": { + "@types/node": "^22.20.2", + "typescript": "^6.0.3" } } diff --git a/packages/headless-npm/scripts/clean.mjs b/packages/headless-npm/scripts/clean.mjs new file mode 100644 index 0000000..05797ac --- /dev/null +++ b/packages/headless-npm/scripts/clean.mjs @@ -0,0 +1,6 @@ +import { rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +await rm(resolve(packageRoot, "dist"), { recursive: true, force: true }); diff --git a/packages/headless-npm/scripts/generate-sdk.mjs b/packages/headless-npm/scripts/generate-sdk.mjs new file mode 100644 index 0000000..3687c4a --- /dev/null +++ b/packages/headless-npm/scripts/generate-sdk.mjs @@ -0,0 +1,271 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const schemaPath = resolve(packageRoot, "../../sdk/protocol-schema.json"); +const fixturesPath = resolve(packageRoot, "../../sdk/protocol-fixtures.json"); +const outputPath = resolve(packageRoot, "src/generated.ts"); +const schemaBytes = await readFile(schemaPath); +const fixtureBytes = await readFile(fixturesPath); +const schema = JSON.parse(schemaBytes.toString("utf8")); +const fixtures = JSON.parse(fixtureBytes.toString("utf8")); + +if (schema.format !== "headless-sdk-contract" || schema.schemaVersion !== 1) { + throw new Error("unsupported Headless SDK schema"); +} +if (typeof schema.protocolVersion !== "string" || !Array.isArray(schema.commands)) { + throw new Error("invalid Headless SDK schema"); +} +if (fixtures.schemaVersion !== schema.schemaVersion + || fixtures.protocolVersion !== schema.protocolVersion) { + throw new Error("protocol fixtures do not match the SDK schema"); +} +if (!Array.isArray(schema.localLifecycle?.launch?.argv) + || !Array.isArray(schema.localLifecycle.launch.options)) { + throw new Error("schema has no supervised launch contract"); +} +const presentationOption = schema.localLifecycle.launch.options + .find((option) => option.name === "presentation"); +if (!Array.isArray(presentationOption?.values) + || presentationOption.values.length === 0 + || !presentationOption.values.includes("background")) { + throw new Error("schema has no valid launch presentation contract"); +} + +function identifier(value) { + return value.split(/[^A-Za-z0-9]+/).filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)).join(""); +} + +function methodName(command) { + const parts = command.split("."); + return parts[0] + parts.slice(1).map(identifier).join(""); +} + +function literal(value) { + return JSON.stringify(value); +} + +function parameterType(parameter) { + if (Array.isArray(parameter.values) && !parameter.caseInsensitiveValues) { + return parameter.values.map(literal).join(" | "); + } + switch (parameter.type) { + case "boolean": return "boolean"; + case "integer": + case "number": return "number"; + case "string-array": return "readonly string[]"; + case "string": return "string"; + default: throw new Error(`unsupported parameter type: ${parameter.type}`); + } +} + +function resultType(type) { + switch (type) { + case "array": return "readonly JsonValue[]"; + case "boolean": return "boolean"; + case "json": return "JsonValue"; + case "number": return "number"; + case "object": return "Readonly>"; + case "string": return "string"; + case "string-or-null": return "string | null"; + default: throw new Error(`unsupported result type: ${type}`); + } +} + +const resultSchemas = new Map(); +function registerResultSchema(result, label) { + const name = result?.name; + if (!name || !Array.isArray(result.fields)) throw new Error(`invalid result schema: ${label}`); + const encoded = JSON.stringify(result); + const previous = resultSchemas.get(name); + if (previous && previous !== encoded) throw new Error(`conflicting result schema: ${name}`); + resultSchemas.set(name, encoded); +} + +for (const command of schema.commands) { + const name = command.result?.schema?.name; + if (!name || !["host", "session"].includes(command.scope) + || typeof command.timeout?.defaultMilliseconds !== "number" + || !Array.isArray(command.parameters) || !Array.isArray(command.result.schema.fields)) { + throw new Error(`invalid command schema: ${command.name}`); + } + registerResultSchema(command.result.schema, command.name); +} + +if (!schema.errorDetails || typeof schema.errorDetails !== "object") { + throw new Error("schema has no error detail contracts"); +} +for (const [code, details] of Object.entries(schema.errorDetails)) { + registerResultSchema(details.schema, code); +} + +const lines = [ + "// Generated by scripts/generate-sdk.mjs. Do not edit.", + "", + "export type JsonPrimitive = boolean | number | string | null;", + "export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };", + "export type Untrusted = Readonly<{ readonly untrustedContent: true; readonly value: T }> ;", + "", + `export const PROTOCOL_VERSION = ${literal(schema.protocolVersion)} as const;`, + `export const PROTOCOL_SCHEMA_VERSION = ${schema.schemaVersion} as const;`, + `export const MAXIMUM_MESSAGE_BYTES = ${schema.maximumMessageBytes} as const;`, + `export const PROTOCOL_SCHEMA_SHA256 = ${literal(createHash("sha256").update(schemaBytes).digest("hex"))} as const;`, + `export const PROTOCOL_FIXTURES_SHA256 = ${literal(createHash("sha256").update(fixtureBytes).digest("hex"))} as const;`, + `export const RESPONSE_ADDITIONAL_PROPERTIES = ${schema.response.additionalProperties === true} as const;`, + `export const MAXIMUM_COMMAND_TIMEOUT_MS = ${Math.max(...schema.commands.flatMap((command) => [ + command.timeout.defaultMilliseconds, + command.timeout.maximumMilliseconds ?? 0, + ...Object.values(command.timeout.parameterPresentOverrides), + ]))} as const;`, + `export const LOCAL_LIFECYCLE = ${JSON.stringify(schema.localLifecycle, null, 2)} as const;`, + `export const LAUNCH_PRESENTATIONS = ${JSON.stringify(presentationOption.values)} as const;`, + `export type LaunchPresentation = typeof LAUNCH_PRESENTATIONS[number];`, + "", + "export interface CommandOptions {", + " readonly signal?: AbortSignal;", + " readonly timeoutMs?: number;", + "}", + "", +]; + +for (const command of schema.commands) { + const typeName = `${identifier(command.name)}Parameters`; + lines.push(`export interface ${typeName} {`); + for (const parameter of command.parameters) { + lines.push(` readonly ${JSON.stringify(parameter.name)}${parameter.required ? "" : "?"}: ${parameterType(parameter)};`); + } + lines.push("}", ""); +} + +for (const encoded of resultSchemas.values()) { + const result = JSON.parse(encoded); + lines.push(`export interface ${result.name} {`); + for (const field of result.fields) { + lines.push(` readonly ${JSON.stringify(field.name)}${field.required ? "" : "?"}: ${resultType(field.type)};`); + } + if (result.additionalProperties) lines.push(" readonly [key: string]: JsonValue;"); + lines.push("}", ""); +} + +const errorCodes = schema.response?.failure?.error?.codes; +if (!Array.isArray(errorCodes) || errorCodes.length === 0) throw new Error("schema has no error codes"); +const lifecycleErrorCodes = schema.localLifecycle.launch.errors; +if (!Array.isArray(lifecycleErrorCodes) || lifecycleErrorCodes.length === 0) { + throw new Error("schema has no lifecycle error codes"); +} +lines.push( + `export type CommandErrorCode = ${errorCodes.map(literal).join(" | ")};`, + "", + `export type LifecycleErrorCode = ${lifecycleErrorCodes.map(literal).join(" | ")};`, + "", + `export const LIFECYCLE_ERROR_CODES = ${JSON.stringify(lifecycleErrorCodes)} as const;`, + "", + "export interface ErrorDetails {", +); +for (const [code, details] of Object.entries(schema.errorDetails)) { + const detailType = details.mayContainUntrustedContent + ? `Untrusted<${details.schema.name}>` + : details.schema.name; + lines.push(` readonly ${literal(code)}: ${detailType};`); +} +lines.push( + "}", + "", + `export const ERROR_DETAILS_METADATA = ${JSON.stringify(schema.errorDetails, null, 2)} as const;`, + "", + `export type CommandName = ${schema.commands.map((command) => literal(command.name)).join(" | ")};`, + "", + "export interface CommandParameters {", +); +for (const command of schema.commands) { + lines.push(` readonly ${literal(command.name)}: ${identifier(command.name)}Parameters;`); +} +lines.push("}", "", "export interface CommandResults {"); +for (const command of schema.commands) { + const resultName = command.result.schema.name; + const result = command.result.mayContainUntrustedContent ? `Untrusted<${resultName}>` : resultName; + lines.push(` readonly ${literal(command.name)}: ${result};`); +} +lines.push( + "}", + "", + "export type CommandResult = CommandResults[C];", + "", + `export const COMMAND_METADATA = ${JSON.stringify(Object.fromEntries(schema.commands.map((command) => [command.name, { + capabilityNegotiated: command.capabilityNegotiated, + parameters: command.parameters, + result: command.result, + scope: command.scope, + timeout: command.timeout, + }])), null, 2)} as const;`, + "", + "export function commandTimeoutMilliseconds(", + " command: C,", + " parameters: CommandParameters[C],", + "): number {", + " const policy = COMMAND_METADATA[command].timeout;", + " for (const [parameter, timeout] of Object.entries(policy.parameterPresentOverrides)) {", + " if ((parameters as Readonly>)[parameter] !== undefined) return timeout;", + " }", + " if (\"parameterName\" in policy) {", + " const value = (parameters as Readonly>)[policy.parameterName];", + " if (typeof value === \"number\") {", + " return Math.max(", + " policy.minimumMilliseconds,", + " Math.min(policy.maximumMilliseconds, value + policy.parameterGraceMilliseconds),", + " );", + " }", + " }", + " return policy.defaultMilliseconds;", + "}", + "", + "export abstract class GeneratedCommandClient {", + " protected abstract invoke(", + " command: C,", + " parameters: CommandParameters[C],", + " options?: CommandOptions,", + " ): Promise>;", + "", +); +function emitMethods(commands) { + for (const command of commands) { + const parameters = `${identifier(command.name)}Parameters`; + const result = `Promise>`; + const required = command.parameters.some((parameter) => parameter.required); + if (command.parameters.length === 0) { + lines.push(` ${methodName(command.name)}(options?: CommandOptions): ${result} {`); + lines.push(` return this.invoke(${literal(command.name)}, {}, options);`, " }", ""); + } else if (required) { + lines.push(` ${methodName(command.name)}(parameters: ${parameters}, options?: CommandOptions): ${result} {`); + lines.push(` return this.invoke(${literal(command.name)}, parameters, options);`, " }", ""); + } else { + lines.push(` ${methodName(command.name)}(parameters: ${parameters} = {}, options?: CommandOptions): ${result} {`); + lines.push(` return this.invoke(${literal(command.name)}, parameters, options);`, " }", ""); + } + } +} +emitMethods(schema.commands); +lines.push( + "}", + "", + "export abstract class GeneratedSessionCommandClient {", + " protected abstract invoke(", + " command: C,", + " parameters: CommandParameters[C],", + " options?: CommandOptions,", + " ): Promise>;", + "", +); +emitMethods(schema.commands.filter((command) => command.scope === "session")); +lines.push("}", ""); + +const output = `${lines.join("\n")}\n`; +if (process.argv.includes("--check")) { + const existing = await readFile(outputPath, "utf8").catch(() => ""); + if (existing !== output) throw new Error("generated SDK declarations are stale; run npm run generate"); +} else { + await writeFile(outputPath, output); +} diff --git a/packages/headless-npm/scripts/lint.mjs b/packages/headless-npm/scripts/lint.mjs new file mode 100644 index 0000000..98addc2 --- /dev/null +++ b/packages/headless-npm/scripts/lint.mjs @@ -0,0 +1,24 @@ +import { readdir, readFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const forbidden = /(?:\bas\s+any\b|:\s*any\b||\bts-(?:ignore|nocheck)\b)/; + +async function sourceFiles(directory) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await sourceFiles(path)); + else if ([".ts", ".mjs"].includes(extname(entry.name))) files.push(path); + } + return files; +} + +for (const file of await sourceFiles(join(root, "src"))) { + const contents = await readFile(file, "utf8"); + if (forbidden.test(contents)) { + throw new Error(`${file} contains a forbidden type escape`); + } + if (contents.includes("\r")) throw new Error(`${file} contains CRLF line endings`); +} diff --git a/packages/headless-npm/src/client.ts b/packages/headless-npm/src/client.ts new file mode 100644 index 0000000..03c8bb0 --- /dev/null +++ b/packages/headless-npm/src/client.ts @@ -0,0 +1,209 @@ +import { + COMMAND_METADATA, + commandTimeoutMilliseconds, + GeneratedCommandClient, + GeneratedSessionCommandClient, + MAXIMUM_COMMAND_TIMEOUT_MS, + PROTOCOL_VERSION, + type CommandName, + type CommandOptions, + type CommandParameters, + type CommandResult, + type HostStatus, + type JsonValue, +} from "./generated.js"; +import { + ClientClosedError, + CommandError, + MalformedResponseError, + OperationOutcomeUnknown, + UnsupportedCapabilityError, + ValidationError, +} from "./errors.js"; +import { createRequest, decodeResponse, encodeRequest, validateSession } from "./protocol.js"; +import { defaultSocketPath, UnixSocketTransport } from "./transport.js"; + +export interface ConnectOptions extends CommandOptions { + readonly socketPath?: string; +} + +export interface RequestOptions extends CommandOptions { + readonly session?: string; +} + +function timeoutFor( + command: C, + parameters: CommandParameters[C], + requested?: number, +): number { + if (requested !== undefined) { + if (!Number.isSafeInteger(requested) || requested < 1 || requested > MAXIMUM_COMMAND_TIMEOUT_MS) { + throw new ValidationError( + `timeoutMs must be an integer between 1 and ${MAXIMUM_COMMAND_TIMEOUT_MS}`, + ); + } + return requested; + } + return commandTimeoutMilliseconds(command, parameters); +} + +function supportedCommands(status: HostStatus): ReadonlySet { + const capabilities = status.capabilities; + const commands = capabilities.commands; + if (!Array.isArray(commands) || !commands.every((command) => typeof command === "string")) { + throw new MalformedResponseError("host capabilities do not declare supported commands"); + } + const known = new Set(Object.keys(COMMAND_METADATA)); + return new Set(commands.filter((command): command is CommandName => known.has(command))); +} + +export class HeadlessClient extends GeneratedCommandClient { + readonly socketPath: string; + #transport: UnixSocketTransport; + #hostStatus: HostStatus | undefined; + #supportedCommands: ReadonlySet | undefined; + #closed = false; + + constructor(options: { readonly socketPath?: string } = {}) { + super(); + this.socketPath = options.socketPath ?? defaultSocketPath(); + this.#transport = new UnixSocketTransport(this.socketPath); + } + + get capabilities(): Readonly> { + if (!this.#hostStatus) throw new ValidationError("connect() must complete before reading capabilities"); + return this.#hostStatus.capabilities; + } + + get hostStatus(): HostStatus { + if (!this.#hostStatus) throw new ValidationError("connect() must complete before reading host status"); + return this.#hostStatus; + } + + async connect(options: CommandOptions = {}): Promise { + if (this.#closed) throw new ClientClosedError(); + const status = await this.request("ping", {}, options); + this.#supportedCommands = supportedCommands(status); + this.#hostStatus = status; + return this; + } + + async request( + command: C, + parameters: CommandParameters[C], + options: RequestOptions = {}, + ): Promise> { + if (this.#closed) throw new ClientClosedError(); + if (options.session !== undefined) validateSession(options.session); + if (command !== "ping" && !this.#supportedCommands) { + throw new ValidationError("connect() must complete before browser commands are sent"); + } + if (command !== "ping" && this.#supportedCommands && !this.#supportedCommands.has(command)) { + throw new UnsupportedCapabilityError(command); + } + const request = createRequest(command, parameters, options.session); + const frame = await this.#transport.send({ + frame: encodeRequest(request), + requestId: request.id, + timeoutMs: timeoutFor(command, parameters, options.timeoutMs), + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); + try { + const result = decodeResponse(frame, request.id, command); + if (command === "ping") { + const status = result as HostStatus; + if (status.protocolVersion !== PROTOCOL_VERSION) { + throw new MalformedResponseError( + "ping result protocolVersion does not match the response envelope", + ); + } + supportedCommands(status); + } + return result; + } catch (cause) { + if (cause instanceof CommandError) throw cause; + if (cause instanceof MalformedResponseError) { + throw new OperationOutcomeUnknown(request.id, "read-failed", { cause }); + } + throw cause; + } + } + + session(name: string): HeadlessSession { + validateSession(name); + return new HeadlessSession(this, name); + } + + async openSession( + name: string, + options: { readonly isolated?: boolean; readonly signal?: AbortSignal; readonly timeoutMs?: number } = {}, + ): Promise { + const { isolated, ...commandOptions } = options; + await this.sessionCreate( + { name, ...(isolated === undefined ? {} : { isolated }) }, + commandOptions, + ); + return this.session(name); + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#transport.close(); + } + + async [Symbol.asyncDispose](): Promise { + this.close(); + } + + protected override invoke( + command: C, + parameters: CommandParameters[C], + options?: CommandOptions, + ): Promise> { + return this.request(command, parameters, options); + } +} + +export class HeadlessSession extends GeneratedSessionCommandClient { + readonly name: string; + readonly #client: HeadlessClient; + #closed = false; + + constructor(client: HeadlessClient, name: string) { + super(); + validateSession(name); + this.#client = client; + this.name = name; + } + + async close(options: CommandOptions = {}): Promise { + if (this.#closed) return; + await this.#client.request("session.close", {}, { ...options, session: this.name }); + this.#closed = true; + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } + + protected override invoke( + command: C, + parameters: CommandParameters[C], + options?: CommandOptions, + ): Promise> { + if (this.#closed) return Promise.reject(new ClientClosedError()); + return this.#client.request(command, parameters, { ...options, session: this.name }); + } +} + +export async function connect(options: ConnectOptions = {}): Promise { + const { socketPath, ...commandOptions } = options; + const client = new HeadlessClient(socketPath === undefined ? {} : { socketPath }); + try { + return await client.connect(commandOptions); + } catch (error) { + client.close(); + throw error; + } +} diff --git a/packages/headless-npm/src/errors.ts b/packages/headless-npm/src/errors.ts new file mode 100644 index 0000000..5a157e6 --- /dev/null +++ b/packages/headless-npm/src/errors.ts @@ -0,0 +1,167 @@ +import type { + AuthenticationRequired, + CommandErrorCode, + JsonValue, + LifecycleErrorCode, + Untrusted, +} from "./generated.js"; + +export class HeadlessError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = new.target.name; + } +} + +export class ValidationError extends HeadlessError {} + +export class ClientClosedError extends HeadlessError { + constructor() { + super("the Headless client is closed"); + } +} + +export class TransportError extends HeadlessError { + readonly retrySafe: boolean; + + constructor(message: string, retrySafe: boolean, options?: ErrorOptions) { + super(message, options); + this.retrySafe = retrySafe; + } +} + +export class ConnectionError extends TransportError { + constructor(message: string, options?: ErrorOptions) { + super(message, true, options); + } +} + +export class TimeoutBeforeSend extends TransportError { + constructor(message = "the request timed out before any bytes were sent") { + super(message, true); + } +} + +export class CancelledBeforeSend extends TransportError { + constructor(message = "the request was cancelled before any bytes were sent") { + super(message, true); + } +} + +export class OperationOutcomeUnknown extends TransportError { + readonly requestId: string; + readonly reason: "cancelled" | "closed" | "read-failed" | "timed-out"; + + constructor( + requestId: string, + reason: "cancelled" | "closed" | "read-failed" | "timed-out", + options?: ErrorOptions, + ) { + super( + `request ${requestId} was sent but its outcome is unknown (${reason}); inspect host state before continuing`, + false, + options, + ); + this.requestId = requestId; + this.reason = reason; + } +} + +export class MalformedResponseError extends TransportError { + constructor(message: string, options?: ErrorOptions) { + super(message, false, options); + } +} + +export class ResponseTooLargeError extends MalformedResponseError { + constructor(maximumBytes: number) { + super(`Headless response exceeded the ${maximumBytes}-byte frame limit`); + } +} + +export class ProtocolMismatchError extends MalformedResponseError { + readonly expectedVersion: string; + readonly actualVersion: string; + + constructor(expectedVersion: string, actualVersion: string) { + super(`Headless protocol mismatch: expected ${expectedVersion}, received ${actualVersion}`); + this.expectedVersion = expectedVersion; + this.actualVersion = actualVersion; + } +} + +export class ResponseIdMismatchError extends MalformedResponseError { + readonly expectedId: string; + readonly actualId: string; + + constructor(expectedId: string, actualId: string) { + super(`Headless response id mismatch: expected ${expectedId}, received ${actualId}`); + this.expectedId = expectedId; + this.actualId = actualId; + } +} + +export class CommandError extends HeadlessError { + readonly code: CommandErrorCode | string; + readonly suggestion: string | undefined; + readonly details: JsonValue | Untrusted | undefined; + + constructor( + code: CommandErrorCode | string, + message: string, + suggestion?: string, + details?: JsonValue | Untrusted, + ) { + super(message); + this.code = code; + this.suggestion = suggestion; + this.details = details; + } +} + +export class AuthenticationRequiredError extends CommandError { + declare readonly details: Untrusted; + + constructor( + message: string, + suggestion: string | undefined, + details: Untrusted, + ) { + super("AUTH_REQUIRED", message, suggestion, details); + } +} + +export class UnsupportedCapabilityError extends CommandError { + readonly command: string; + + constructor(command: string, message = `the connected Headless host does not support ${command}`) { + super("UNSUPPORTED_CAPABILITY", message); + this.command = command; + } +} + +export class HostLaunchError extends HeadlessError { + readonly code: LifecycleErrorCode; + readonly suggestion: string | undefined; + readonly details: JsonValue | undefined; + readonly exitCode: number | null | undefined; + readonly signal: NodeJS.Signals | null | undefined; + + constructor( + message: string, + options: ErrorOptions & { + readonly code?: LifecycleErrorCode; + readonly suggestion?: string; + readonly details?: JsonValue; + readonly exitCode?: number | null; + readonly signal?: NodeJS.Signals | null; + } = {}, + ) { + super(message, options); + this.code = options.code ?? "HOST_START_FAILED"; + this.suggestion = options.suggestion; + this.details = options.details; + this.exitCode = options.exitCode; + this.signal = options.signal; + } +} diff --git a/packages/headless-npm/src/generated.ts b/packages/headless-npm/src/generated.ts new file mode 100644 index 0000000..5513083 --- /dev/null +++ b/packages/headless-npm/src/generated.ts @@ -0,0 +1,3409 @@ +// Generated by scripts/generate-sdk.mjs. Do not edit. + +export type JsonPrimitive = boolean | number | string | null; +export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue }; +export type Untrusted = Readonly<{ readonly untrustedContent: true; readonly value: T }> ; + +export const PROTOCOL_VERSION = "0.5" as const; +export const PROTOCOL_SCHEMA_VERSION = 1 as const; +export const MAXIMUM_MESSAGE_BYTES = 1048576 as const; +export const PROTOCOL_SCHEMA_SHA256 = "c199f18185cfa05b5c16c9140e48e1f588c61188b5f2ea6fa58eea2ddc57dcbf" as const; +export const PROTOCOL_FIXTURES_SHA256 = "0b51ffaa2d3e3aaf0c32adcfeb02c180dcbe44face0d49e1c332b69f403ae062" as const; +export const RESPONSE_ADDITIONAL_PROPERTIES = true as const; +export const MAXIMUM_COMMAND_TIMEOUT_MS = 125000 as const; +export const LOCAL_LIFECYCLE = { + "connect": { + "errors": [ + "HOST_UNAVAILABLE" + ], + "ownership": "shared", + "transport": "local-unix-socket" + }, + "launch": { + "argv": [ + "start", + "--background", + "--supervised" + ], + "command": "start", + "errors": [ + "HOST_START_FAILED", + "NAVIGATION_ALLOWLIST_CONFLICT", + "UNSUPPORTED_BROWSER_RUNTIME", + "UNSUPPORTED_CAPABILITY" + ], + "options": [ + { + "name": "presentation", + "required": false, + "type": "string", + "values": [ + "background", + "foreground" + ] + }, + { + "itemMaximumBytes": 300, + "maximumItems": 32, + "name": "allow", + "required": false, + "type": "string-array" + }, + { + "const": true, + "name": "supervised", + "required": true, + "type": "boolean" + } + ], + "ownership": "owned-only-after-response-pid-matches-launched-child", + "result": { + "additionalProperties": true, + "fields": [ + { + "name": "ready", + "required": true, + "type": "boolean" + }, + { + "name": "pid", + "required": true, + "type": "number" + }, + { + "name": "engine", + "required": true, + "type": "string" + }, + { + "name": "platform", + "required": true, + "type": "string" + }, + { + "name": "productVersion", + "required": true, + "type": "string" + }, + { + "name": "protocolVersion", + "required": true, + "type": "string" + }, + { + "name": "capabilities", + "required": true, + "type": "object" + }, + { + "name": "recordingAvailable", + "required": true, + "type": "boolean" + }, + { + "name": "artifactDirectory", + "required": true, + "type": "string" + }, + { + "name": "navigationAllowlist", + "required": true, + "type": "array" + } + ], + "name": "HostStatus", + "type": "object" + } + } +} as const; +export const LAUNCH_PRESENTATIONS = ["background","foreground"] as const; +export type LaunchPresentation = typeof LAUNCH_PRESENTATIONS[number]; + +export interface CommandOptions { + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +} + +export interface PingParameters { +} + +export interface ShutdownParameters { +} + +export interface ProfileClearParameters { +} + +export interface SessionCreateParameters { + readonly "name": string; + readonly "isolated"?: boolean; +} + +export interface SessionListParameters { +} + +export interface SessionCloseParameters { +} + +export interface VisitParameters { + readonly "url": string; +} + +export interface InspectParameters { + readonly "interactive"?: boolean; + readonly "text"?: boolean; + readonly "context"?: "summary" | "outline" | "text" | "actions" | "full"; + readonly "task"?: string; + readonly "within"?: string; + readonly "limit"?: number; + readonly "budget"?: number; + readonly "depth"?: number; +} + +export interface ClickParameters { + readonly "target"?: string; + readonly "role"?: string; + readonly "name"?: string; +} + +export interface FillParameters { + readonly "target"?: string; + readonly "role"?: string; + readonly "name"?: string; + readonly "value": string; +} + +export interface UploadParameters { + readonly "target"?: string; + readonly "role"?: string; + readonly "name"?: string; + readonly "artifact": string; +} + +export interface PressParameters { + readonly "key": string; +} + +export interface ScrollParameters { + readonly "direction"?: "up" | "down" | "top" | "bottom"; + readonly "amount"?: number; +} + +export interface BackParameters { +} + +export interface ReloadParameters { +} + +export interface WaitParameters { + readonly "settled"?: boolean; + readonly "url"?: string; + readonly "text"?: string; + readonly "timeoutMs"?: number; +} + +export interface TourParameters { + readonly "fullPage"?: boolean; + readonly "pace"?: number; +} + +export interface CaptureInfoParameters { +} + +export interface ScreenshotParameters { + readonly "target"?: string; + readonly "role"?: string; + readonly "name"?: string; + readonly "fullPage"?: boolean; + readonly "output"?: string; + readonly "series"?: "viewport" | "section"; + readonly "outputPrefix"?: string; + readonly "format"?: string; + readonly "clipboard"?: boolean; +} + +export interface ArtifactListParameters { +} + +export interface RecordStartParameters { + readonly "output"?: string; + readonly "fps"?: number; + readonly "format"?: string; + readonly "quality"?: string; +} + +export interface RecordStatusParameters { +} + +export interface RecordStopParameters { + readonly "output"?: string; +} + +export interface QaReportParameters { +} + +export interface QaClearParameters { +} + +export interface ConsoleListParameters { + readonly "level"?: "all" | "log" | "info" | "debug" | "warn" | "error" | "assert"; + readonly "limit"?: number; +} + +export interface NetworkListParameters { + readonly "failed"?: boolean; + readonly "status"?: number; + readonly "limit"?: number; +} + +export interface NetworkGetParameters { + readonly "requestId": string; +} + +export interface StylesGetParameters { + readonly "target"?: string; + readonly "role"?: string; + readonly "name"?: string; + readonly "properties"?: readonly string[]; +} + +export interface CookiesListParameters { + readonly "includeValues"?: boolean; +} + +export interface StorageListParameters { + readonly "scope"?: "local" | "session" | "all"; + readonly "includeValues"?: boolean; +} + +export interface VisualCompareParameters { + readonly "before": string; + readonly "after": string; + readonly "output"?: string; +} + +export interface PerformanceGetParameters { +} + +export interface AnimationListParameters { +} + +export interface ReportCreateParameters { + readonly "output"?: string; +} + +export interface FlowStartParameters { +} + +export interface FlowStopParameters { + readonly "output"?: string; +} + +export interface FlowRunParameters { + readonly "input": string; +} + +export interface NetworkEmulateParameters { + readonly "offline"?: boolean; + readonly "latencyMs"?: number; + readonly "downloadKbps"?: number; + readonly "uploadKbps"?: number; +} + +export interface NetworkMockSetParameters { + readonly "url": string; + readonly "status"?: number; + readonly "body": string; + readonly "contentType"?: string; +} + +export interface NetworkMockClearParameters { +} + +export interface AuthLoginParameters { + readonly "challenge"?: string; + readonly "account"?: string; + readonly "interactive"?: boolean; +} + +export interface HostStatus { + readonly "ready": boolean; + readonly "pid": number; + readonly "engine": string; + readonly "platform": string; + readonly "productVersion": string; + readonly "protocolVersion": string; + readonly "capabilities": Readonly>; + readonly "recordingAvailable": boolean; + readonly "artifactDirectory": string; + readonly "navigationAllowlist": readonly JsonValue[]; + readonly [key: string]: JsonValue; +} + +export interface Shutdown { + readonly "stopping": boolean; + readonly [key: string]: JsonValue; +} + +export interface ProfileClear { + readonly "cleared": boolean; + readonly "session": string; + readonly [key: string]: JsonValue; +} + +export interface SessionCreate { + readonly "session": string; + readonly "isolated": boolean; + readonly [key: string]: JsonValue; +} + +export interface SessionList { + readonly "sessions": readonly JsonValue[]; + readonly "details": readonly JsonValue[]; + readonly [key: string]: JsonValue; +} + +export interface SessionClose { + readonly "closed": string; + readonly [key: string]: JsonValue; +} + +export interface PageState { + readonly "url": string; + readonly "title": string; + readonly "readyState": string; + readonly "text": string; + readonly "runningAnimations": number; + readonly "mutationQuietMs": number; + readonly "scrollY": number; + readonly "contentHeight": number; + readonly [key: string]: JsonValue; +} + +export interface Inspection { + readonly "url": string; + readonly "title": string; + readonly "contextMode": string; + readonly "viewport": Readonly>; + readonly "untrustedContent": boolean; + readonly "elements"?: readonly JsonValue[]; + readonly "regions"?: readonly JsonValue[]; + readonly "snippets"?: readonly JsonValue[]; + readonly "text"?: string; + readonly [key: string]: JsonValue; +} + +export interface Click { + readonly "clicked": string; + readonly "role": string; + readonly "name": string; + readonly [key: string]: JsonValue; +} + +export interface Fill { + readonly "filled": string; + readonly "valueLength": number; + readonly [key: string]: JsonValue; +} + +export interface Upload { + readonly "uploaded": string; + readonly "role": string; + readonly "name": string; + readonly "artifact": string; + readonly [key: string]: JsonValue; +} + +export interface Press { + readonly "pressed": string; + readonly [key: string]: JsonValue; +} + +export interface Scroll { + readonly "direction": string; + readonly "amount": number; + readonly [key: string]: JsonValue; +} + +export interface Tour { + readonly "start": number; + readonly "end": number; + readonly "durationMs": number; + readonly [key: string]: JsonValue; +} + +export interface CaptureInfo { + readonly "engine": string; + readonly "page": Readonly>; + readonly "trace": readonly JsonValue[]; + readonly "recording": Readonly>; + readonly [key: string]: JsonValue; +} + +export interface Screenshot { + readonly "name"?: string; + readonly "path"?: string; + readonly "kind"?: string; + readonly "bytes"?: number; + readonly "createdAt"?: number; + readonly "artifacts"?: readonly JsonValue[]; + readonly "truncated"?: boolean; + readonly [key: string]: JsonValue; +} + +export interface ArtifactList { + readonly "directory": string; + readonly "artifacts": readonly JsonValue[]; + readonly "total": number; + readonly "omitted": number; + readonly "truncated": boolean; + readonly [key: string]: JsonValue; +} + +export interface Recording { + readonly "active": boolean; + readonly "format"?: string; + readonly "quality"?: string; + readonly "name"?: string; + readonly [key: string]: JsonValue; +} + +export interface QAReport { + readonly "untrustedContent": boolean; + readonly "summary": Readonly>; + readonly "issues": readonly JsonValue[]; + readonly "events": readonly JsonValue[]; + readonly "omitted": Readonly>; + readonly "truncated": boolean; + readonly [key: string]: JsonValue; +} + +export interface QAClear { + readonly "cleared": number; + readonly [key: string]: JsonValue; +} + +export interface ConsoleList { + readonly "untrustedContent": boolean; + readonly "messages": readonly JsonValue[]; + readonly "returned": number; + readonly "available": number; + readonly [key: string]: JsonValue; +} + +export interface NetworkList { + readonly "untrustedContent": boolean; + readonly "requests": readonly JsonValue[]; + readonly "returned": number; + readonly "available": number; + readonly [key: string]: JsonValue; +} + +export interface NetworkDetail { + readonly "found": boolean; + readonly "requestId"?: string; + readonly "untrustedContent": boolean; + readonly "request"?: Readonly>; + readonly [key: string]: JsonValue; +} + +export interface Styles { + readonly "ref": string; + readonly "role": string; + readonly "name": string; + readonly "box": Readonly>; + readonly "styles": Readonly>; + readonly [key: string]: JsonValue; +} + +export interface CookieList { + readonly "cookies": readonly JsonValue[]; + readonly "returned": number; + readonly "available": number; + readonly "truncated": boolean; + readonly [key: string]: JsonValue; +} + +export interface StorageList { + readonly "origin": string; + readonly "stores": readonly JsonValue[]; + readonly [key: string]: JsonValue; +} + +export interface VisualComparison { + readonly "name": string; + readonly "changedPixels"?: number; + readonly "differenceRatio"?: number; + readonly [key: string]: JsonValue; +} + +export interface Performance { + readonly "url": string; + readonly "timing": JsonValue; + readonly "webVitals": Readonly>; + readonly "resources": Readonly>; + readonly [key: string]: JsonValue; +} + +export interface AnimationList { + readonly "count": number; + readonly "animations": readonly JsonValue[]; + readonly "truncated": boolean; + readonly [key: string]: JsonValue; +} + +export interface Artifact { + readonly "name": string; + readonly "path": string; + readonly "kind": string; + readonly "bytes": number; + readonly "createdAt": number; + readonly [key: string]: JsonValue; +} + +export interface FlowStart { + readonly "recording": boolean; + readonly "note": string; + readonly [key: string]: JsonValue; +} + +export interface FlowRun { + readonly "completed": number; + readonly "input": string; + readonly [key: string]: JsonValue; +} + +export interface NetworkEmulation { + readonly "offline": boolean; + readonly "latencyMs": number; + readonly "downloadKbps": number; + readonly "uploadKbps": number; + readonly "engine": string; + readonly [key: string]: JsonValue; +} + +export interface NetworkMock { + readonly "url": string; + readonly "status": number; + readonly "activeMocks": number; + readonly [key: string]: JsonValue; +} + +export interface NetworkMockClear { + readonly "cleared": number; + readonly [key: string]: JsonValue; +} + +export interface AuthenticationLogin { + readonly "origin": string; + readonly "account": string | null; + readonly "saved": boolean; + readonly "continuation": string; + readonly "passwordExposed": boolean; + readonly "originalActionReplayed": boolean; + readonly [key: string]: JsonValue; +} + +export interface AuthenticationRequired { + readonly "challenge": string; + readonly "origin": string; + readonly "detection": string; + readonly "accounts": readonly JsonValue[]; + readonly "expiresInSeconds": number; + readonly "userPresenceRequired": boolean; + readonly "credentialUseAvailable": boolean; + readonly "vaultAvailable": boolean; + readonly "vaultStatus": string; + readonly "untrustedContent": boolean; + readonly "originalActionReplayed": boolean; + readonly [key: string]: JsonValue; +} + +export type CommandErrorCode = "ARTIFACT_ERROR" | "AUTH_ACCOUNT_NOT_FOUND" | "AUTH_CHALLENGE_CONSUMED" | "AUTH_CHALLENGE_EXPIRED" | "AUTH_CHALLENGE_NOT_FOUND" | "AUTH_FORM_CHANGED" | "AUTH_ORIGIN_CHANGED" | "AUTH_REQUIRED" | "CREDENTIAL_ALIAS_EXISTS" | "ELEMENT_NOT_FOUND" | "FLOW_FAILED" | "HOST_STOPPING" | "HOST_UNAVAILABLE" | "INTERNAL_ERROR" | "INVALID_CAPTURE_FORMAT" | "INVALID_COMMAND" | "INVALID_FLOW" | "INVALID_INPUT" | "INVALID_REQUEST" | "INVALID_SESSION" | "MISSING_PARAMETER" | "OPERATION_FAILED" | "PEER_DENIED" | "RECORDER_UNAVAILABLE" | "RECORDING_ACTIVE" | "RECORDING_FAILED" | "RECORDING_NOT_ACTIVE" | "REGION_NOT_FOUND" | "RESPONSE_TOO_LARGE" | "SENSITIVE_DIAGNOSTICS_DISABLED" | "SESSION_EXISTS" | "SESSION_NOT_FOUND" | "TIMEOUT" | "UNSAFE_NAVIGATION" | "UNSAFE_RESOURCE_TYPE" | "UNSUPPORTED_CAPABILITY" | "USER_PRESENCE_DENIED" | "USER_PRESENCE_UNAVAILABLE" | "VAULT_LOCKED" | "VAULT_OPERATION_FAILED" | "VAULT_RESPONSE_INVALID" | "VAULT_UNAVAILABLE"; + +export type LifecycleErrorCode = "HOST_START_FAILED" | "NAVIGATION_ALLOWLIST_CONFLICT" | "UNSUPPORTED_BROWSER_RUNTIME" | "UNSUPPORTED_CAPABILITY"; + +export const LIFECYCLE_ERROR_CODES = ["HOST_START_FAILED","NAVIGATION_ALLOWLIST_CONFLICT","UNSUPPORTED_BROWSER_RUNTIME","UNSUPPORTED_CAPABILITY"] as const; + +export interface ErrorDetails { + readonly "AUTH_REQUIRED": Untrusted; +} + +export const ERROR_DETAILS_METADATA = { + "AUTH_REQUIRED": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "challenge", + "required": true, + "type": "string" + }, + { + "name": "origin", + "required": true, + "type": "string" + }, + { + "name": "detection", + "required": true, + "type": "string" + }, + { + "name": "accounts", + "required": true, + "type": "array" + }, + { + "name": "expiresInSeconds", + "required": true, + "type": "number" + }, + { + "name": "userPresenceRequired", + "required": true, + "type": "boolean" + }, + { + "name": "credentialUseAvailable", + "required": true, + "type": "boolean" + }, + { + "name": "vaultAvailable", + "required": true, + "type": "boolean" + }, + { + "name": "vaultStatus", + "required": true, + "type": "string" + }, + { + "name": "untrustedContent", + "required": true, + "type": "boolean" + }, + { + "name": "originalActionReplayed", + "required": true, + "type": "boolean" + } + ], + "name": "AuthenticationRequired", + "type": "object" + } + } +} as const; + +export type CommandName = "ping" | "shutdown" | "profile.clear" | "session.create" | "session.list" | "session.close" | "visit" | "inspect" | "click" | "fill" | "upload" | "press" | "scroll" | "back" | "reload" | "wait" | "tour" | "capture.info" | "screenshot" | "artifact.list" | "record.start" | "record.status" | "record.stop" | "qa.report" | "qa.clear" | "console.list" | "network.list" | "network.get" | "styles.get" | "cookies.list" | "storage.list" | "visual.compare" | "performance.get" | "animation.list" | "report.create" | "flow.start" | "flow.stop" | "flow.run" | "network.emulate" | "network.mock.set" | "network.mock.clear" | "auth.login"; + +export interface CommandParameters { + readonly "ping": PingParameters; + readonly "shutdown": ShutdownParameters; + readonly "profile.clear": ProfileClearParameters; + readonly "session.create": SessionCreateParameters; + readonly "session.list": SessionListParameters; + readonly "session.close": SessionCloseParameters; + readonly "visit": VisitParameters; + readonly "inspect": InspectParameters; + readonly "click": ClickParameters; + readonly "fill": FillParameters; + readonly "upload": UploadParameters; + readonly "press": PressParameters; + readonly "scroll": ScrollParameters; + readonly "back": BackParameters; + readonly "reload": ReloadParameters; + readonly "wait": WaitParameters; + readonly "tour": TourParameters; + readonly "capture.info": CaptureInfoParameters; + readonly "screenshot": ScreenshotParameters; + readonly "artifact.list": ArtifactListParameters; + readonly "record.start": RecordStartParameters; + readonly "record.status": RecordStatusParameters; + readonly "record.stop": RecordStopParameters; + readonly "qa.report": QaReportParameters; + readonly "qa.clear": QaClearParameters; + readonly "console.list": ConsoleListParameters; + readonly "network.list": NetworkListParameters; + readonly "network.get": NetworkGetParameters; + readonly "styles.get": StylesGetParameters; + readonly "cookies.list": CookiesListParameters; + readonly "storage.list": StorageListParameters; + readonly "visual.compare": VisualCompareParameters; + readonly "performance.get": PerformanceGetParameters; + readonly "animation.list": AnimationListParameters; + readonly "report.create": ReportCreateParameters; + readonly "flow.start": FlowStartParameters; + readonly "flow.stop": FlowStopParameters; + readonly "flow.run": FlowRunParameters; + readonly "network.emulate": NetworkEmulateParameters; + readonly "network.mock.set": NetworkMockSetParameters; + readonly "network.mock.clear": NetworkMockClearParameters; + readonly "auth.login": AuthLoginParameters; +} + +export interface CommandResults { + readonly "ping": HostStatus; + readonly "shutdown": Shutdown; + readonly "profile.clear": ProfileClear; + readonly "session.create": SessionCreate; + readonly "session.list": SessionList; + readonly "session.close": SessionClose; + readonly "visit": Untrusted; + readonly "inspect": Untrusted; + readonly "click": Untrusted; + readonly "fill": Untrusted; + readonly "upload": Untrusted; + readonly "press": Untrusted; + readonly "scroll": Untrusted; + readonly "back": Untrusted; + readonly "reload": Untrusted; + readonly "wait": Untrusted; + readonly "tour": Untrusted; + readonly "capture.info": Untrusted; + readonly "screenshot": Screenshot; + readonly "artifact.list": ArtifactList; + readonly "record.start": Recording; + readonly "record.status": Recording; + readonly "record.stop": Recording; + readonly "qa.report": Untrusted; + readonly "qa.clear": QAClear; + readonly "console.list": Untrusted; + readonly "network.list": Untrusted; + readonly "network.get": Untrusted; + readonly "styles.get": Untrusted; + readonly "cookies.list": Untrusted; + readonly "storage.list": Untrusted; + readonly "visual.compare": VisualComparison; + readonly "performance.get": Untrusted; + readonly "animation.list": Untrusted; + readonly "report.create": Untrusted; + readonly "flow.start": FlowStart; + readonly "flow.stop": Artifact; + readonly "flow.run": Untrusted; + readonly "network.emulate": NetworkEmulation; + readonly "network.mock.set": Untrusted; + readonly "network.mock.clear": NetworkMockClear; + readonly "auth.login": Untrusted; +} + +export type CommandResult = CommandResults[C]; + +export const COMMAND_METADATA = { + "ping": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "ready", + "required": true, + "type": "boolean" + }, + { + "name": "pid", + "required": true, + "type": "number" + }, + { + "name": "engine", + "required": true, + "type": "string" + }, + { + "name": "platform", + "required": true, + "type": "string" + }, + { + "name": "productVersion", + "required": true, + "type": "string" + }, + { + "name": "protocolVersion", + "required": true, + "type": "string" + }, + { + "name": "capabilities", + "required": true, + "type": "object" + }, + { + "name": "recordingAvailable", + "required": true, + "type": "boolean" + }, + { + "name": "artifactDirectory", + "required": true, + "type": "string" + }, + { + "name": "navigationAllowlist", + "required": true, + "type": "array" + } + ], + "name": "HostStatus", + "type": "object" + } + }, + "scope": "host", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "shutdown": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "stopping", + "required": true, + "type": "boolean" + } + ], + "name": "Shutdown", + "type": "object" + } + }, + "scope": "host", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "profile.clear": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "cleared", + "required": true, + "type": "boolean" + }, + { + "name": "session", + "required": true, + "type": "string" + } + ], + "name": "ProfileClear", + "type": "object" + } + }, + "scope": "host", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "session.create": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 64, + "name": "name", + "required": true, + "sensitive": false, + "type": "string" + }, + { + "name": "isolated", + "required": false, + "sensitive": false, + "type": "boolean" + } + ], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "session", + "required": true, + "type": "string" + }, + { + "name": "isolated", + "required": true, + "type": "boolean" + } + ], + "name": "SessionCreate", + "type": "object" + } + }, + "scope": "host", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "session.list": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "sessions", + "required": true, + "type": "array" + }, + { + "name": "details", + "required": true, + "type": "array" + } + ], + "name": "SessionList", + "type": "object" + } + }, + "scope": "host", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "session.close": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "closed", + "required": true, + "type": "string" + } + ], + "name": "SessionClose", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "visit": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 8192, + "name": "url", + "required": true, + "sensitive": false, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "url", + "required": true, + "type": "string" + }, + { + "name": "title", + "required": true, + "type": "string" + }, + { + "name": "readyState", + "required": true, + "type": "string" + }, + { + "name": "text", + "required": true, + "type": "string" + }, + { + "name": "runningAnimations", + "required": true, + "type": "number" + }, + { + "name": "mutationQuietMs", + "required": true, + "type": "number" + }, + { + "name": "scrollY", + "required": true, + "type": "number" + }, + { + "name": "contentHeight", + "required": true, + "type": "number" + } + ], + "name": "PageState", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "inspect": { + "capabilityNegotiated": false, + "parameters": [ + { + "name": "interactive", + "required": false, + "sensitive": false, + "type": "boolean" + }, + { + "name": "text", + "required": false, + "sensitive": false, + "type": "boolean" + }, + { + "maximumBytes": 16, + "name": "context", + "required": false, + "sensitive": false, + "type": "string", + "values": [ + "summary", + "outline", + "text", + "actions", + "full" + ] + }, + { + "maximumBytes": 512, + "name": "task", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 16, + "name": "within", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximum": 250, + "minimum": 1, + "name": "limit", + "required": false, + "sensitive": false, + "type": "integer" + }, + { + "maximum": 16000, + "minimum": 256, + "name": "budget", + "required": false, + "sensitive": false, + "type": "integer" + }, + { + "maximum": 8, + "minimum": 0, + "name": "depth", + "required": false, + "sensitive": false, + "type": "integer" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "url", + "required": true, + "type": "string" + }, + { + "name": "title", + "required": true, + "type": "string" + }, + { + "name": "contextMode", + "required": true, + "type": "string" + }, + { + "name": "viewport", + "required": true, + "type": "object" + }, + { + "name": "untrustedContent", + "required": true, + "type": "boolean" + }, + { + "name": "elements", + "required": false, + "type": "array" + }, + { + "name": "regions", + "required": false, + "type": "array" + }, + { + "name": "snippets", + "required": false, + "type": "array" + }, + { + "name": "text", + "required": false, + "type": "string" + } + ], + "name": "Inspection", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "click": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 16, + "name": "target", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 128, + "name": "role", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 1000, + "name": "name", + "required": false, + "sensitive": false, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "clicked", + "required": true, + "type": "string" + }, + { + "name": "role", + "required": true, + "type": "string" + }, + { + "name": "name", + "required": true, + "type": "string" + } + ], + "name": "Click", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "fill": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 16, + "name": "target", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 128, + "name": "role", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 1000, + "name": "name", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 900000, + "name": "value", + "required": true, + "sensitive": true, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "filled", + "required": true, + "type": "string" + }, + { + "name": "valueLength", + "required": true, + "type": "number" + } + ], + "name": "Fill", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "upload": { + "capabilityNegotiated": true, + "parameters": [ + { + "maximumBytes": 16, + "name": "target", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 128, + "name": "role", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 1000, + "name": "name", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 128, + "name": "artifact", + "required": true, + "sensitive": false, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "uploaded", + "required": true, + "type": "string" + }, + { + "name": "role", + "required": true, + "type": "string" + }, + { + "name": "name", + "required": true, + "type": "string" + }, + { + "name": "artifact", + "required": true, + "type": "string" + } + ], + "name": "Upload", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "press": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 32, + "name": "key", + "required": true, + "sensitive": false, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "pressed", + "required": true, + "type": "string" + } + ], + "name": "Press", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "scroll": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 8192, + "name": "direction", + "required": false, + "sensitive": false, + "type": "string", + "values": [ + "up", + "down", + "top", + "bottom" + ] + }, + { + "maximum": 100000, + "minimum": 0.1, + "name": "amount", + "required": false, + "sensitive": false, + "type": "number" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "direction", + "required": true, + "type": "string" + }, + { + "name": "amount", + "required": true, + "type": "number" + } + ], + "name": "Scroll", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "back": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "url", + "required": true, + "type": "string" + }, + { + "name": "title", + "required": true, + "type": "string" + }, + { + "name": "readyState", + "required": true, + "type": "string" + }, + { + "name": "text", + "required": true, + "type": "string" + }, + { + "name": "runningAnimations", + "required": true, + "type": "number" + }, + { + "name": "mutationQuietMs", + "required": true, + "type": "number" + }, + { + "name": "scrollY", + "required": true, + "type": "number" + }, + { + "name": "contentHeight", + "required": true, + "type": "number" + } + ], + "name": "PageState", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "reload": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "url", + "required": true, + "type": "string" + }, + { + "name": "title", + "required": true, + "type": "string" + }, + { + "name": "readyState", + "required": true, + "type": "string" + }, + { + "name": "text", + "required": true, + "type": "string" + }, + { + "name": "runningAnimations", + "required": true, + "type": "number" + }, + { + "name": "mutationQuietMs", + "required": true, + "type": "number" + }, + { + "name": "scrollY", + "required": true, + "type": "number" + }, + { + "name": "contentHeight", + "required": true, + "type": "number" + } + ], + "name": "PageState", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "wait": { + "capabilityNegotiated": false, + "parameters": [ + { + "name": "settled", + "required": false, + "sensitive": false, + "type": "boolean" + }, + { + "maximumBytes": 8192, + "name": "url", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 30000, + "name": "text", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximum": 120000, + "minimum": 100, + "name": "timeoutMs", + "required": false, + "sensitive": false, + "type": "number" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "url", + "required": true, + "type": "string" + }, + { + "name": "title", + "required": true, + "type": "string" + }, + { + "name": "readyState", + "required": true, + "type": "string" + }, + { + "name": "text", + "required": true, + "type": "string" + }, + { + "name": "runningAnimations", + "required": true, + "type": "number" + }, + { + "name": "mutationQuietMs", + "required": true, + "type": "number" + }, + { + "name": "scrollY", + "required": true, + "type": "number" + }, + { + "name": "contentHeight", + "required": true, + "type": "number" + } + ], + "name": "PageState", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "maximumMilliseconds": 125000, + "minimumMilliseconds": 10000, + "parameterGraceMilliseconds": 5000, + "parameterName": "timeoutMs", + "parameterPresentOverrides": {} + } + }, + "tour": { + "capabilityNegotiated": false, + "parameters": [ + { + "name": "fullPage", + "required": false, + "sensitive": false, + "type": "boolean" + }, + { + "maximum": 5000, + "minimum": 100, + "name": "pace", + "required": false, + "sensitive": false, + "type": "number" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "start", + "required": true, + "type": "number" + }, + { + "name": "end", + "required": true, + "type": "number" + }, + { + "name": "durationMs", + "required": true, + "type": "number" + } + ], + "name": "Tour", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 125000, + "parameterPresentOverrides": {} + } + }, + "capture.info": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "engine", + "required": true, + "type": "string" + }, + { + "name": "page", + "required": true, + "type": "object" + }, + { + "name": "trace", + "required": true, + "type": "array" + }, + { + "name": "recording", + "required": true, + "type": "object" + } + ], + "name": "CaptureInfo", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "screenshot": { + "capabilityNegotiated": true, + "parameters": [ + { + "maximumBytes": 16, + "name": "target", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 128, + "name": "role", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 1000, + "name": "name", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "name": "fullPage", + "required": false, + "sensitive": false, + "type": "boolean" + }, + { + "maximumBytes": 128, + "name": "output", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 32, + "name": "series", + "required": false, + "sensitive": false, + "type": "string", + "values": [ + "viewport", + "section" + ] + }, + { + "maximumBytes": 80, + "name": "outputPrefix", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "caseInsensitiveValues": true, + "maximumBytes": 16, + "name": "format", + "required": false, + "sensitive": false, + "type": "string", + "values": [ + "png", + "jpg", + "jpeg", + "pdf" + ] + }, + { + "name": "clipboard", + "required": false, + "sensitive": false, + "type": "boolean" + } + ], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "name", + "required": false, + "type": "string" + }, + { + "name": "path", + "required": false, + "type": "string" + }, + { + "name": "kind", + "required": false, + "type": "string" + }, + { + "name": "bytes", + "required": false, + "type": "number" + }, + { + "name": "createdAt", + "required": false, + "type": "number" + }, + { + "name": "artifacts", + "required": false, + "type": "array" + }, + { + "name": "truncated", + "required": false, + "type": "boolean" + } + ], + "name": "Screenshot", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 30000, + "parameterPresentOverrides": { + "series": 125000 + } + } + }, + "artifact.list": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "directory", + "required": true, + "type": "string" + }, + { + "name": "artifacts", + "required": true, + "type": "array" + }, + { + "name": "total", + "required": true, + "type": "number" + }, + { + "name": "omitted", + "required": true, + "type": "number" + }, + { + "name": "truncated", + "required": true, + "type": "boolean" + } + ], + "name": "ArtifactList", + "type": "object" + } + }, + "scope": "host", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "record.start": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 128, + "name": "output", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximum": 30, + "minimum": 1, + "name": "fps", + "required": false, + "sensitive": false, + "type": "number" + }, + { + "caseInsensitiveValues": true, + "maximumBytes": 16, + "name": "format", + "required": false, + "sensitive": false, + "type": "string", + "values": [ + "mp4", + "mov", + "webm", + "gif" + ] + }, + { + "caseInsensitiveValues": true, + "maximumBytes": 16, + "name": "quality", + "required": false, + "sensitive": false, + "type": "string", + "values": [ + "fast", + "balanced", + "high" + ] + } + ], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "active", + "required": true, + "type": "boolean" + }, + { + "name": "format", + "required": false, + "type": "string" + }, + { + "name": "quality", + "required": false, + "type": "string" + }, + { + "name": "name", + "required": false, + "type": "string" + } + ], + "name": "Recording", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "record.status": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "active", + "required": true, + "type": "boolean" + }, + { + "name": "format", + "required": false, + "type": "string" + }, + { + "name": "quality", + "required": false, + "type": "string" + }, + { + "name": "name", + "required": false, + "type": "string" + } + ], + "name": "Recording", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "record.stop": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 128, + "name": "output", + "required": false, + "sensitive": false, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "active", + "required": true, + "type": "boolean" + }, + { + "name": "format", + "required": false, + "type": "string" + }, + { + "name": "quality", + "required": false, + "type": "string" + }, + { + "name": "name", + "required": false, + "type": "string" + } + ], + "name": "Recording", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 30000, + "parameterPresentOverrides": {} + } + }, + "qa.report": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "untrustedContent", + "required": true, + "type": "boolean" + }, + { + "name": "summary", + "required": true, + "type": "object" + }, + { + "name": "issues", + "required": true, + "type": "array" + }, + { + "name": "events", + "required": true, + "type": "array" + }, + { + "name": "omitted", + "required": true, + "type": "object" + }, + { + "name": "truncated", + "required": true, + "type": "boolean" + } + ], + "name": "QAReport", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "qa.clear": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "cleared", + "required": true, + "type": "number" + } + ], + "name": "QAClear", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "console.list": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 16, + "name": "level", + "required": false, + "sensitive": false, + "type": "string", + "values": [ + "all", + "log", + "info", + "debug", + "warn", + "error", + "assert" + ] + }, + { + "maximum": 200, + "minimum": 1, + "name": "limit", + "required": false, + "sensitive": false, + "type": "number" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "untrustedContent", + "required": true, + "type": "boolean" + }, + { + "name": "messages", + "required": true, + "type": "array" + }, + { + "name": "returned", + "required": true, + "type": "number" + }, + { + "name": "available", + "required": true, + "type": "number" + } + ], + "name": "ConsoleList", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "network.list": { + "capabilityNegotiated": false, + "parameters": [ + { + "name": "failed", + "required": false, + "sensitive": false, + "type": "boolean" + }, + { + "maximum": 599, + "minimum": 100, + "name": "status", + "required": false, + "sensitive": false, + "type": "number" + }, + { + "maximum": 200, + "minimum": 1, + "name": "limit", + "required": false, + "sensitive": false, + "type": "number" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "untrustedContent", + "required": true, + "type": "boolean" + }, + { + "name": "requests", + "required": true, + "type": "array" + }, + { + "name": "returned", + "required": true, + "type": "number" + }, + { + "name": "available", + "required": true, + "type": "number" + } + ], + "name": "NetworkList", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "network.get": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 128, + "name": "requestId", + "required": true, + "sensitive": false, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "found", + "required": true, + "type": "boolean" + }, + { + "name": "requestId", + "required": false, + "type": "string" + }, + { + "name": "untrustedContent", + "required": true, + "type": "boolean" + }, + { + "name": "request", + "required": false, + "type": "object" + } + ], + "name": "NetworkDetail", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "styles.get": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 16, + "name": "target", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 128, + "name": "role", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 1000, + "name": "name", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "itemMaximumBytes": 128, + "maximumItems": 64, + "name": "properties", + "required": false, + "sensitive": false, + "type": "string-array" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "ref", + "required": true, + "type": "string" + }, + { + "name": "role", + "required": true, + "type": "string" + }, + { + "name": "name", + "required": true, + "type": "string" + }, + { + "name": "box", + "required": true, + "type": "object" + }, + { + "name": "styles", + "required": true, + "type": "object" + } + ], + "name": "Styles", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "cookies.list": { + "capabilityNegotiated": false, + "parameters": [ + { + "name": "includeValues", + "required": false, + "sensitive": false, + "type": "boolean" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "cookies", + "required": true, + "type": "array" + }, + { + "name": "returned", + "required": true, + "type": "number" + }, + { + "name": "available", + "required": true, + "type": "number" + }, + { + "name": "truncated", + "required": true, + "type": "boolean" + } + ], + "name": "CookieList", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "storage.list": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 16, + "name": "scope", + "required": false, + "sensitive": false, + "type": "string", + "values": [ + "local", + "session", + "all" + ] + }, + { + "name": "includeValues", + "required": false, + "sensitive": false, + "type": "boolean" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "origin", + "required": true, + "type": "string" + }, + { + "name": "stores", + "required": true, + "type": "array" + } + ], + "name": "StorageList", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "visual.compare": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 128, + "name": "before", + "required": true, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 128, + "name": "after", + "required": true, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 128, + "name": "output", + "required": false, + "sensitive": false, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "name", + "required": true, + "type": "string" + }, + { + "name": "changedPixels", + "required": false, + "type": "number" + }, + { + "name": "differenceRatio", + "required": false, + "type": "number" + } + ], + "name": "VisualComparison", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "performance.get": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "url", + "required": true, + "type": "string" + }, + { + "name": "timing", + "required": true, + "type": "json" + }, + { + "name": "webVitals", + "required": true, + "type": "object" + }, + { + "name": "resources", + "required": true, + "type": "object" + } + ], + "name": "Performance", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "animation.list": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "count", + "required": true, + "type": "number" + }, + { + "name": "animations", + "required": true, + "type": "array" + }, + { + "name": "truncated", + "required": true, + "type": "boolean" + } + ], + "name": "AnimationList", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "report.create": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 128, + "name": "output", + "required": false, + "sensitive": false, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "name", + "required": true, + "type": "string" + }, + { + "name": "path", + "required": true, + "type": "string" + }, + { + "name": "kind", + "required": true, + "type": "string" + }, + { + "name": "bytes", + "required": true, + "type": "number" + }, + { + "name": "createdAt", + "required": true, + "type": "number" + } + ], + "name": "Artifact", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "flow.start": { + "capabilityNegotiated": false, + "parameters": [], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "recording", + "required": true, + "type": "boolean" + }, + { + "name": "note", + "required": true, + "type": "string" + } + ], + "name": "FlowStart", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "flow.stop": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 128, + "name": "output", + "required": false, + "sensitive": false, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "name", + "required": true, + "type": "string" + }, + { + "name": "path", + "required": true, + "type": "string" + }, + { + "name": "kind", + "required": true, + "type": "string" + }, + { + "name": "bytes", + "required": true, + "type": "number" + }, + { + "name": "createdAt", + "required": true, + "type": "number" + } + ], + "name": "Artifact", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "flow.run": { + "capabilityNegotiated": false, + "parameters": [ + { + "maximumBytes": 128, + "name": "input", + "required": true, + "sensitive": false, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "completed", + "required": true, + "type": "number" + }, + { + "name": "input", + "required": true, + "type": "string" + } + ], + "name": "FlowRun", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 125000, + "parameterPresentOverrides": {} + } + }, + "network.emulate": { + "capabilityNegotiated": true, + "parameters": [ + { + "name": "offline", + "required": false, + "sensitive": false, + "type": "boolean" + }, + { + "maximum": 120000, + "minimum": 0, + "name": "latencyMs", + "required": false, + "sensitive": false, + "type": "number" + }, + { + "maximum": 1000000, + "minimum": -1, + "name": "downloadKbps", + "required": false, + "sensitive": false, + "type": "number" + }, + { + "maximum": 1000000, + "minimum": -1, + "name": "uploadKbps", + "required": false, + "sensitive": false, + "type": "number" + } + ], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "offline", + "required": true, + "type": "boolean" + }, + { + "name": "latencyMs", + "required": true, + "type": "number" + }, + { + "name": "downloadKbps", + "required": true, + "type": "number" + }, + { + "name": "uploadKbps", + "required": true, + "type": "number" + }, + { + "name": "engine", + "required": true, + "type": "string" + } + ], + "name": "NetworkEmulation", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "network.mock.set": { + "capabilityNegotiated": true, + "parameters": [ + { + "maximumBytes": 8192, + "name": "url", + "required": true, + "sensitive": false, + "type": "string" + }, + { + "maximum": 599, + "minimum": 100, + "name": "status", + "required": false, + "sensitive": false, + "type": "number" + }, + { + "maximumBytes": 65536, + "name": "body", + "required": true, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 256, + "name": "contentType", + "required": false, + "sensitive": false, + "type": "string" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "url", + "required": true, + "type": "string" + }, + { + "name": "status", + "required": true, + "type": "number" + }, + { + "name": "activeMocks", + "required": true, + "type": "number" + } + ], + "name": "NetworkMock", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "network.mock.clear": { + "capabilityNegotiated": true, + "parameters": [], + "result": { + "mayContainUntrustedContent": false, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "cleared", + "required": true, + "type": "number" + } + ], + "name": "NetworkMockClear", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + }, + "auth.login": { + "capabilityNegotiated": true, + "parameters": [ + { + "maximumBytes": 64, + "name": "challenge", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "maximumBytes": 64, + "name": "account", + "required": false, + "sensitive": false, + "type": "string" + }, + { + "name": "interactive", + "required": false, + "sensitive": false, + "type": "boolean" + } + ], + "result": { + "mayContainUntrustedContent": true, + "schema": { + "additionalProperties": true, + "fields": [ + { + "name": "origin", + "required": true, + "type": "string" + }, + { + "name": "account", + "required": true, + "type": "string-or-null" + }, + { + "name": "saved", + "required": true, + "type": "boolean" + }, + { + "name": "continuation", + "required": true, + "type": "string" + }, + { + "name": "passwordExposed", + "required": true, + "type": "boolean" + }, + { + "name": "originalActionReplayed", + "required": true, + "type": "boolean" + } + ], + "name": "AuthenticationLogin", + "type": "object" + } + }, + "scope": "session", + "timeout": { + "defaultMilliseconds": 15000, + "parameterPresentOverrides": {} + } + } +} as const; + +export function commandTimeoutMilliseconds( + command: C, + parameters: CommandParameters[C], +): number { + const policy = COMMAND_METADATA[command].timeout; + for (const [parameter, timeout] of Object.entries(policy.parameterPresentOverrides)) { + if ((parameters as Readonly>)[parameter] !== undefined) return timeout; + } + if ("parameterName" in policy) { + const value = (parameters as Readonly>)[policy.parameterName]; + if (typeof value === "number") { + return Math.max( + policy.minimumMilliseconds, + Math.min(policy.maximumMilliseconds, value + policy.parameterGraceMilliseconds), + ); + } + } + return policy.defaultMilliseconds; +} + +export abstract class GeneratedCommandClient { + protected abstract invoke( + command: C, + parameters: CommandParameters[C], + options?: CommandOptions, + ): Promise>; + + ping(options?: CommandOptions): Promise> { + return this.invoke("ping", {}, options); + } + + shutdown(options?: CommandOptions): Promise> { + return this.invoke("shutdown", {}, options); + } + + profileClear(options?: CommandOptions): Promise> { + return this.invoke("profile.clear", {}, options); + } + + sessionCreate(parameters: SessionCreateParameters, options?: CommandOptions): Promise> { + return this.invoke("session.create", parameters, options); + } + + sessionList(options?: CommandOptions): Promise> { + return this.invoke("session.list", {}, options); + } + + sessionClose(options?: CommandOptions): Promise> { + return this.invoke("session.close", {}, options); + } + + visit(parameters: VisitParameters, options?: CommandOptions): Promise> { + return this.invoke("visit", parameters, options); + } + + inspect(parameters: InspectParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("inspect", parameters, options); + } + + click(parameters: ClickParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("click", parameters, options); + } + + fill(parameters: FillParameters, options?: CommandOptions): Promise> { + return this.invoke("fill", parameters, options); + } + + upload(parameters: UploadParameters, options?: CommandOptions): Promise> { + return this.invoke("upload", parameters, options); + } + + press(parameters: PressParameters, options?: CommandOptions): Promise> { + return this.invoke("press", parameters, options); + } + + scroll(parameters: ScrollParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("scroll", parameters, options); + } + + back(options?: CommandOptions): Promise> { + return this.invoke("back", {}, options); + } + + reload(options?: CommandOptions): Promise> { + return this.invoke("reload", {}, options); + } + + wait(parameters: WaitParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("wait", parameters, options); + } + + tour(parameters: TourParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("tour", parameters, options); + } + + captureInfo(options?: CommandOptions): Promise> { + return this.invoke("capture.info", {}, options); + } + + screenshot(parameters: ScreenshotParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("screenshot", parameters, options); + } + + artifactList(options?: CommandOptions): Promise> { + return this.invoke("artifact.list", {}, options); + } + + recordStart(parameters: RecordStartParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("record.start", parameters, options); + } + + recordStatus(options?: CommandOptions): Promise> { + return this.invoke("record.status", {}, options); + } + + recordStop(parameters: RecordStopParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("record.stop", parameters, options); + } + + qaReport(options?: CommandOptions): Promise> { + return this.invoke("qa.report", {}, options); + } + + qaClear(options?: CommandOptions): Promise> { + return this.invoke("qa.clear", {}, options); + } + + consoleList(parameters: ConsoleListParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("console.list", parameters, options); + } + + networkList(parameters: NetworkListParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("network.list", parameters, options); + } + + networkGet(parameters: NetworkGetParameters, options?: CommandOptions): Promise> { + return this.invoke("network.get", parameters, options); + } + + stylesGet(parameters: StylesGetParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("styles.get", parameters, options); + } + + cookiesList(parameters: CookiesListParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("cookies.list", parameters, options); + } + + storageList(parameters: StorageListParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("storage.list", parameters, options); + } + + visualCompare(parameters: VisualCompareParameters, options?: CommandOptions): Promise> { + return this.invoke("visual.compare", parameters, options); + } + + performanceGet(options?: CommandOptions): Promise> { + return this.invoke("performance.get", {}, options); + } + + animationList(options?: CommandOptions): Promise> { + return this.invoke("animation.list", {}, options); + } + + reportCreate(parameters: ReportCreateParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("report.create", parameters, options); + } + + flowStart(options?: CommandOptions): Promise> { + return this.invoke("flow.start", {}, options); + } + + flowStop(parameters: FlowStopParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("flow.stop", parameters, options); + } + + flowRun(parameters: FlowRunParameters, options?: CommandOptions): Promise> { + return this.invoke("flow.run", parameters, options); + } + + networkEmulate(parameters: NetworkEmulateParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("network.emulate", parameters, options); + } + + networkMockSet(parameters: NetworkMockSetParameters, options?: CommandOptions): Promise> { + return this.invoke("network.mock.set", parameters, options); + } + + networkMockClear(options?: CommandOptions): Promise> { + return this.invoke("network.mock.clear", {}, options); + } + + authLogin(parameters: AuthLoginParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("auth.login", parameters, options); + } + +} + +export abstract class GeneratedSessionCommandClient { + protected abstract invoke( + command: C, + parameters: CommandParameters[C], + options?: CommandOptions, + ): Promise>; + + sessionClose(options?: CommandOptions): Promise> { + return this.invoke("session.close", {}, options); + } + + visit(parameters: VisitParameters, options?: CommandOptions): Promise> { + return this.invoke("visit", parameters, options); + } + + inspect(parameters: InspectParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("inspect", parameters, options); + } + + click(parameters: ClickParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("click", parameters, options); + } + + fill(parameters: FillParameters, options?: CommandOptions): Promise> { + return this.invoke("fill", parameters, options); + } + + upload(parameters: UploadParameters, options?: CommandOptions): Promise> { + return this.invoke("upload", parameters, options); + } + + press(parameters: PressParameters, options?: CommandOptions): Promise> { + return this.invoke("press", parameters, options); + } + + scroll(parameters: ScrollParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("scroll", parameters, options); + } + + back(options?: CommandOptions): Promise> { + return this.invoke("back", {}, options); + } + + reload(options?: CommandOptions): Promise> { + return this.invoke("reload", {}, options); + } + + wait(parameters: WaitParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("wait", parameters, options); + } + + tour(parameters: TourParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("tour", parameters, options); + } + + captureInfo(options?: CommandOptions): Promise> { + return this.invoke("capture.info", {}, options); + } + + screenshot(parameters: ScreenshotParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("screenshot", parameters, options); + } + + recordStart(parameters: RecordStartParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("record.start", parameters, options); + } + + recordStatus(options?: CommandOptions): Promise> { + return this.invoke("record.status", {}, options); + } + + recordStop(parameters: RecordStopParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("record.stop", parameters, options); + } + + qaReport(options?: CommandOptions): Promise> { + return this.invoke("qa.report", {}, options); + } + + qaClear(options?: CommandOptions): Promise> { + return this.invoke("qa.clear", {}, options); + } + + consoleList(parameters: ConsoleListParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("console.list", parameters, options); + } + + networkList(parameters: NetworkListParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("network.list", parameters, options); + } + + networkGet(parameters: NetworkGetParameters, options?: CommandOptions): Promise> { + return this.invoke("network.get", parameters, options); + } + + stylesGet(parameters: StylesGetParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("styles.get", parameters, options); + } + + cookiesList(parameters: CookiesListParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("cookies.list", parameters, options); + } + + storageList(parameters: StorageListParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("storage.list", parameters, options); + } + + visualCompare(parameters: VisualCompareParameters, options?: CommandOptions): Promise> { + return this.invoke("visual.compare", parameters, options); + } + + performanceGet(options?: CommandOptions): Promise> { + return this.invoke("performance.get", {}, options); + } + + animationList(options?: CommandOptions): Promise> { + return this.invoke("animation.list", {}, options); + } + + reportCreate(parameters: ReportCreateParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("report.create", parameters, options); + } + + flowStart(options?: CommandOptions): Promise> { + return this.invoke("flow.start", {}, options); + } + + flowStop(parameters: FlowStopParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("flow.stop", parameters, options); + } + + flowRun(parameters: FlowRunParameters, options?: CommandOptions): Promise> { + return this.invoke("flow.run", parameters, options); + } + + networkEmulate(parameters: NetworkEmulateParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("network.emulate", parameters, options); + } + + networkMockSet(parameters: NetworkMockSetParameters, options?: CommandOptions): Promise> { + return this.invoke("network.mock.set", parameters, options); + } + + networkMockClear(options?: CommandOptions): Promise> { + return this.invoke("network.mock.clear", {}, options); + } + + authLogin(parameters: AuthLoginParameters = {}, options?: CommandOptions): Promise> { + return this.invoke("auth.login", parameters, options); + } + +} + diff --git a/packages/headless-npm/src/index.ts b/packages/headless-npm/src/index.ts new file mode 100644 index 0000000..4ac3b61 --- /dev/null +++ b/packages/headless-npm/src/index.ts @@ -0,0 +1,25 @@ +export { connect, HeadlessClient, HeadlessSession } from "./client.js"; +export type { ConnectOptions, RequestOptions } from "./client.js"; +export { + AuthenticationRequiredError, + CancelledBeforeSend, + ClientClosedError, + CommandError, + ConnectionError, + HeadlessError, + HostLaunchError, + MalformedResponseError, + OperationOutcomeUnknown, + ProtocolMismatchError, + ResponseIdMismatchError, + ResponseTooLargeError, + TimeoutBeforeSend, + TransportError, + UnsupportedCapabilityError, + ValidationError, +} from "./errors.js"; +export { HeadlessHost, launch } from "./lifecycle.js"; +export type { HostExit, LaunchOptions } from "./lifecycle.js"; +export { createRequest, decodeResponse, encodeRequest, validateParameters } from "./protocol.js"; +export type { CommandRequest } from "./protocol.js"; +export * from "./generated.js"; diff --git a/packages/headless-npm/src/lifecycle.ts b/packages/headless-npm/src/lifecycle.ts new file mode 100644 index 0000000..1dee059 --- /dev/null +++ b/packages/headless-npm/src/lifecycle.ts @@ -0,0 +1,445 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { isAbsolute, join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { setImmediate as defer } from "node:timers/promises"; +import { defaultCacheRoot, ensureInstalled } from "../lib/installer.mjs"; +import { connect, type ConnectOptions, HeadlessClient } from "./client.js"; +import { + CommandError, + HeadlessError, + HostLaunchError, + ValidationError, +} from "./errors.js"; +import { + LIFECYCLE_ERROR_CODES, + LAUNCH_PRESENTATIONS, + LOCAL_LIFECYCLE, + MAXIMUM_MESSAGE_BYTES, + type HostStatus, + type JsonValue, + type LaunchPresentation, + type LifecycleErrorCode, +} from "./generated.js"; +import { decodeResponse } from "./protocol.js"; +import { defaultSocketPath, validateSocketLocation } from "./transport.js"; + +const STARTUP_OUTPUT_LIMIT = 64 * 1024; +const DEFAULT_INSTALLATION_TIMEOUT_MS = 300_000; +const DEFAULT_STARTUP_TIMEOUT_MS = 10_000; +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000; + +export interface LaunchOptions extends Omit { + readonly allow?: readonly string[]; + readonly environment?: Readonly; + readonly executable?: string; + readonly installationTimeoutMs?: number; + readonly presentation?: LaunchPresentation; + readonly shutdownTimeoutMs?: number; + readonly startupTimeoutMs?: number; +} + +export interface HostExit { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; +} + +interface ChildState { + readonly exited: Promise; + readonly startup: Promise; + readonly violation: Promise; + readonly startupViolation: () => Error | undefined; + readonly output: () => string; + readonly spawnError: () => Error | undefined; +} + +function boundedOutput(child: ChildProcess): ChildState { + let diagnostics = ""; + let error: Error | undefined; + let violation: Error | undefined; + let stdout = Buffer.alloc(0); + let startupComplete = false; + let resolveStartup: ((status: HostStatus) => void) | undefined; + let rejectStartup: ((cause: Error) => void) | undefined; + let resolveViolation: ((cause: Error) => void) | undefined; + const startup = new Promise((resolve, reject) => { + resolveStartup = resolve; + rejectStartup = reject; + }); + const violationPromise = new Promise((resolve) => { + resolveViolation = resolve; + }); + const failStartup = (cause: Error): void => { + if (violation === undefined) { + violation = cause; + resolveViolation?.(cause); + } + if (!startupComplete) { + startupComplete = true; + rejectStartup?.(cause); + } + }; + const appendDiagnostics = (chunk: Buffer): void => { + if (Buffer.byteLength(diagnostics, "utf8") >= STARTUP_OUTPUT_LIMIT) return; + diagnostics += chunk.toString("utf8"); + if (Buffer.byteLength(diagnostics, "utf8") > STARTUP_OUTPUT_LIMIT) { + diagnostics = Buffer.from(diagnostics, "utf8") + .subarray(0, STARTUP_OUTPUT_LIMIT).toString("utf8"); + } + }; + child.stderr?.on("data", appendDiagnostics); + child.stdout?.on("data", (chunk: Buffer) => { + appendDiagnostics(chunk); + if (startupComplete) { + if (chunk.byteLength > 0) failStartup(new HostLaunchError("supervised launcher emitted multiple startup frames")); + return; + } + stdout = Buffer.concat([stdout, chunk], stdout.byteLength + chunk.byteLength); + if (stdout.byteLength > MAXIMUM_MESSAGE_BYTES) { + failStartup(new HostLaunchError("supervised launcher startup response exceeded the frame limit")); + return; + } + const newline = stdout.indexOf(0x0a); + if (newline < 0) return; + if (stdout.byteLength !== newline + 1) { + failStartup(new HostLaunchError("supervised launcher emitted multiple startup frames")); + return; + } + let decoded: unknown; + try { + decoded = JSON.parse(stdout.subarray(0, newline).toString("utf8")); + if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded) + || typeof (decoded as Record).id !== "string") { + throw new Error("startup response has no request id"); + } + const id = (decoded as Record).id as string; + const status = decodeResponse(stdout.subarray(0, newline), id, "ping"); + startupComplete = true; + resolveStartup?.(status); + } catch (cause) { + if (cause instanceof CommandError && isLifecycleErrorCode(cause.code)) { + failStartup(new HostLaunchError(cause.message, { + code: cause.code, + ...(cause.suggestion === undefined ? {} : { suggestion: cause.suggestion }), + ...(cause.details === undefined ? {} : { details: cause.details as JsonValue }), + cause, + })); + } else { + failStartup(new HostLaunchError("supervised launcher returned an invalid startup response", { + cause, + })); + } + } + }); + const exited = new Promise((resolve) => { + child.once("error", (cause) => { + error = cause; + failStartup(new HostLaunchError("could not spawn the supervised Headless launcher", { cause })); + resolve({ code: null, signal: null }); + }); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + return { + exited, + startup, + violation: violationPromise, + startupViolation: () => violation, + output: () => diagnostics.trim(), + spawnError: () => error, + }; +} + +function isLifecycleErrorCode(code: string): code is LifecycleErrorCode { + return (LIFECYCLE_ERROR_CODES as readonly string[]).includes(code); +} + +function setReferenced(handle: unknown, referenced: boolean): void { + if (typeof handle !== "object" || handle === null) return; + const referenceable = handle as { ref?: () => void; unref?: () => void }; + if (referenced) referenceable.ref?.(); + else referenceable.unref?.(); +} + +function setChildReferenced(child: ChildProcess, referenced: boolean): void { + setReferenced(child, referenced); + setReferenced(child.stdin, referenced); + setReferenced(child.stdout, referenced); + setReferenced(child.stderr, referenced); +} + +async function waitForExit(child: ChildProcess, exited: Promise, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + await exited; + return true; + } + return Promise.race([ + exited.then(() => true), + delay(timeoutMs, false, { ref: false }), + ]); +} + +async function terminateAndAwait( + child: ChildProcess, + exited: Promise, + timeoutMs: number, +): Promise { + setChildReferenced(child, true); + child.stdin?.end(); + if (await waitForExit(child, exited, timeoutMs)) return; + child.kill("SIGTERM"); + if (await waitForExit(child, exited, timeoutMs)) return; + child.kill("SIGKILL"); + await exited; +} + +const ownedHosts = new Set(); +let hooksInstalled = false; + +const closeBeforeExit = (): void => { + for (const host of ownedHosts) host.close().catch(() => {}); +}; + +function installOwnershipHooks(): void { + if (hooksInstalled) return; + hooksInstalled = true; + process.on("beforeExit", closeBeforeExit); +} + +function removeOwnershipHooksIfUnused(): void { + if (!hooksInstalled || ownedHosts.size > 0) return; + hooksInstalled = false; + process.removeListener("beforeExit", closeBeforeExit); +} + +export class HeadlessHost { + readonly client: HeadlessClient; + readonly exited: Promise; + readonly #launcher: ChildProcess; + readonly #shutdownTimeoutMs: number; + #closePromise: Promise | undefined; + + constructor( + client: HeadlessClient, + launcher: ChildProcess, + exited: Promise, + violation: Promise, + shutdownTimeoutMs: number, + ) { + this.client = client; + this.#launcher = launcher; + this.exited = exited; + this.#shutdownTimeoutMs = shutdownTimeoutMs; + ownedHosts.add(this); + installOwnershipHooks(); + exited.finally(() => { + this.client.close(); + ownedHosts.delete(this); + removeOwnershipHooksIfUnused(); + }); + void violation.then(() => this.close()).catch(() => {}); + setChildReferenced(launcher, false); + } + + close(): Promise { + if (this.#closePromise) return this.#closePromise; + this.client.close(); + this.#closePromise = terminateAndAwait( + this.#launcher, + this.exited, + this.#shutdownTimeoutMs, + ).finally(() => { + ownedHosts.delete(this); + removeOwnershipHooksIfUnused(); + }); + return this.#closePromise; + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } +} + +function validateBoundedTimeout(name: string, value: number, maximum: number): void { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw new ValidationError(`${name} must be an integer between 1 and ${maximum}`); + } +} + +async function executableFor(options: LaunchOptions, signal: AbortSignal): Promise { + if (options.executable !== undefined) { + if (!options.executable) throw new ValidationError("executable must not be empty"); + if (!isAbsolute(options.executable)) throw new ValidationError("executable must be an absolute path"); + return options.executable; + } + let installed; + try { + const environment = { ...process.env, ...options.environment }; + installed = await ensureInstalled({ + cacheRoot: defaultCacheRoot(process.platform, environment), + signal, + }); + } catch (cause) { + if (options.signal?.aborted) { + throw new HostLaunchError("supervised launch was cancelled during installation", { cause }); + } + if (signal.aborted) { + throw new HostLaunchError( + "verified Headless installation did not complete before the deadline", + { cause }, + ); + } + throw new HostLaunchError("could not install the Headless launcher", { cause }); + } + return join(installed.directory, installed.release.executable); +} + +function startupError(state: ChildState, exit: HostExit): HostLaunchError { + const cause = state.spawnError(); + const details = state.output(); + const suffix = details ? `: ${details}` : ""; + return new HostLaunchError(`supervised Headless launcher exited before readiness${suffix}`, { + ...(cause === undefined ? {} : { cause }), + exitCode: exit.code, + signal: exit.signal, + }); +} + +export async function launch(options: LaunchOptions = {}): Promise { + if ((options as Readonly>).timeoutMs !== undefined) { + throw new ValidationError( + "launch does not accept timeoutMs; use startupTimeoutMs and per-command timeouts", + ); + } + const startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; + const installationTimeoutMs = options.installationTimeoutMs ?? DEFAULT_INSTALLATION_TIMEOUT_MS; + const shutdownTimeoutMs = options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS; + validateBoundedTimeout("installationTimeoutMs", installationTimeoutMs, 600_000); + validateBoundedTimeout("startupTimeoutMs", startupTimeoutMs, 120_000); + validateBoundedTimeout("shutdownTimeoutMs", shutdownTimeoutMs, 30_000); + const hostExecutable = options.environment?.HEADLESS_HOST_EXECUTABLE; + if (hostExecutable !== undefined && !isAbsolute(hostExecutable)) { + throw new ValidationError("HEADLESS_HOST_EXECUTABLE must be an absolute path"); + } + if (options.signal?.aborted) throw new HostLaunchError("supervised launch was cancelled before spawn"); + + const installationDeadlineSignal = AbortSignal.timeout(installationTimeoutMs); + const installationSignal = options.signal === undefined + ? installationDeadlineSignal + : AbortSignal.any([options.signal, installationDeadlineSignal]); + const executable = await executableFor(options, installationSignal); + if (options.signal?.aborted) throw new HostLaunchError("supervised launch was cancelled before spawn"); + if (installationDeadlineSignal.aborted) { + throw new HostLaunchError("verified Headless installation did not complete before the deadline"); + } + const deadline = Date.now() + startupTimeoutMs; + const deadlineSignal = AbortSignal.timeout(startupTimeoutMs); + const startupSignal = options.signal === undefined + ? deadlineSignal + : AbortSignal.any([options.signal, deadlineSignal]); + const socketPath = options.socketPath ?? defaultSocketPath(options.environment ?? process.env); + validateSocketLocation(socketPath); + const presentation = options.presentation ?? "background"; + if (!(LAUNCH_PRESENTATIONS as readonly string[]).includes(presentation)) { + throw new ValidationError(`presentation must be one of ${LAUNCH_PRESENTATIONS.join(", ")}`); + } + const presentationFlags = new Set(LAUNCH_PRESENTATIONS.map((value) => `--${value}`)); + const generatedPresentationFlags = LOCAL_LIFECYCLE.launch.argv + .filter((argument) => presentationFlags.has(argument)); + if (generatedPresentationFlags.length !== 1) { + throw new ValidationError("generated launch argv has an invalid presentation flag"); + } + const argumentsList: string[] = LOCAL_LIFECYCLE.launch.argv.map((argument) => ( + presentationFlags.has(argument) ? `--${presentation}` : argument + )); + const allowDefinition = LOCAL_LIFECYCLE.launch.options.find((option) => option.name === "allow"); + const allow = options.allow ?? []; + if (!allowDefinition || allow.length > allowDefinition.maximumItems) { + throw new ValidationError("allowlist has too many patterns"); + } + for (const pattern of allow) { + if (!pattern || Buffer.byteLength(pattern, "utf8") > allowDefinition.itemMaximumBytes) { + throw new ValidationError("allowlist patterns are empty or exceed the schema limit"); + } + argumentsList.push("--allow", pattern); + } + const child = spawn(executable, argumentsList, { + env: { + ...process.env, + ...options.environment, + HEADLESS_SOCKET: socketPath, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + const state = boundedOutput(child); + let client: HeadlessClient | undefined; + let abortListener: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + abortListener = () => reject(new HostLaunchError( + options.signal?.aborted + ? "supervised launch was cancelled during startup" + : "supervised Headless launcher did not become ready before the deadline", + )); + startupSignal.addEventListener("abort", abortListener, { once: true }); + }); + try { + const startup = await Promise.race([ + state.startup, + state.exited.then((exit) => { throw startupError(state, exit); }), + delay(Math.max(1, deadline - Date.now()), undefined, { ref: false }).then(() => { + throw new HostLaunchError("supervised Headless launcher did not become ready before the deadline"); + }), + aborted, + ]); + if (!Number.isSafeInteger(startup.pid) || startup.pid <= 0) { + throw new HostLaunchError("supervised launcher returned an invalid host pid"); + } + if (!startup.ready) { + throw new HostLaunchError("supervised launcher reported a host that is not ready"); + } + await defer(undefined, { ref: false }); + if (state.startupViolation()) throw state.startupViolation(); + if (child.exitCode !== null || child.signalCode !== null || state.spawnError() !== undefined) { + throw startupError(state, await state.exited); + } + const remaining = deadline - Date.now(); + if (remaining < 1) { + throw new HostLaunchError("supervised Headless launcher did not become ready before the deadline"); + } + client = await connect({ + socketPath, + timeoutMs: remaining, + signal: startupSignal, + }); + if (!Number.isSafeInteger(client.hostStatus.pid) || client.hostStatus.pid <= 0) { + throw new HostLaunchError("connected Headless host returned an invalid pid"); + } + if (client.hostStatus.pid !== startup.pid) { + throw new HostLaunchError( + `supervised launcher reported host pid ${startup.pid}, but the socket belongs to pid ${client.hostStatus.pid}`, + ); + } + await defer(undefined, { ref: false }); + if (state.startupViolation()) throw state.startupViolation(); + if (child.exitCode !== null || child.signalCode !== null || state.spawnError() !== undefined) { + throw startupError(state, await state.exited); + } + return new HeadlessHost(client, child, state.exited, state.violation, shutdownTimeoutMs); + } catch (error) { + const cancelledByUser = options.signal?.aborted === true; + const timedOut = deadlineSignal.aborted; + client?.close(); + await terminateAndAwait(child, state.exited, shutdownTimeoutMs); + if (cancelledByUser) { + throw new HostLaunchError("supervised launch was cancelled during startup", { cause: error }); + } + if (timedOut) { + throw new HostLaunchError( + "supervised Headless launcher did not become ready before the deadline", + { cause: error }, + ); + } + if (error instanceof HeadlessError) throw error; + throw new HostLaunchError("supervised Headless launch failed", { cause: error }); + } finally { + if (abortListener) startupSignal.removeEventListener("abort", abortListener); + } +} diff --git a/packages/headless-npm/src/protocol.ts b/packages/headless-npm/src/protocol.ts new file mode 100644 index 0000000..90e933f --- /dev/null +++ b/packages/headless-npm/src/protocol.ts @@ -0,0 +1,332 @@ +import { randomUUID } from "node:crypto"; +import { + COMMAND_METADATA, + ERROR_DETAILS_METADATA, + MAXIMUM_MESSAGE_BYTES, + PROTOCOL_VERSION, + RESPONSE_ADDITIONAL_PROPERTIES, + type AuthenticationRequired, + type CommandName, + type CommandParameters, + type CommandResult, + type JsonValue, + type Untrusted, +} from "./generated.js"; +import { + AuthenticationRequiredError, + CommandError, + MalformedResponseError, + ProtocolMismatchError, + ResponseIdMismatchError, + UnsupportedCapabilityError, + ValidationError, +} from "./errors.js"; + +export interface CommandRequest { + readonly id: string; + readonly version: typeof PROTOCOL_VERSION; + readonly command: C; + readonly session?: string; + readonly parameters: CommandParameters[C]; +} + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPlainParameterRecord(value: unknown): value is UnknownRecord { + if (!isRecord(value)) return false; + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) return false; + return Object.values(Object.getOwnPropertyDescriptors(value)) + .every((descriptor) => "value" in descriptor); +} + +function isJsonValue(value: unknown): value is JsonValue { + const pending: unknown[] = [value]; + let inspected = 0; + while (pending.length > 0) { + if (inspected >= MAXIMUM_MESSAGE_BYTES) return false; + inspected += 1; + const candidate = pending.pop(); + if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") { + continue; + } + if (typeof candidate === "number" && Number.isFinite(candidate)) continue; + if (Array.isArray(candidate)) { + for (const item of candidate) pending.push(item); + continue; + } + if (isRecord(candidate)) { + for (const item of Object.values(candidate)) pending.push(item); + continue; + } + return false; + } + return true; +} + +function byteLength(value: string): number { + return Buffer.byteLength(value, "utf8"); +} + +function validateParameter(command: CommandName, definition: UnknownRecord, value: unknown): void { + const name = String(definition.name); + const label = `${command}.${name}`; + const type = definition.type; + if (type === "string") { + if (typeof value !== "string") throw new ValidationError(`${label} must be a string`); + if (definition.required === true && value.length === 0) { + throw new ValidationError(`${label} must not be empty`); + } + if (typeof definition.minimumBytes === "number" && byteLength(value) < definition.minimumBytes) { + throw new ValidationError(`${label} must contain at least ${definition.minimumBytes} UTF-8 bytes`); + } + if (typeof definition.maximumBytes === "number" && byteLength(value) > definition.maximumBytes) { + throw new ValidationError(`${label} exceeds ${definition.maximumBytes} UTF-8 bytes`); + } + if (Array.isArray(definition.values)) { + const candidate = definition.caseInsensitiveValues === true ? value.toLowerCase() : value; + if (!definition.values.includes(candidate)) { + throw new ValidationError(`${label} must be one of ${definition.values.join(", ")}`); + } + } + return; + } + if (type === "boolean") { + if (typeof value !== "boolean") throw new ValidationError(`${label} must be a boolean`); + return; + } + if (type === "number" || type === "integer") { + if (typeof value !== "number" || !Number.isFinite(value) + || (type === "integer" && !Number.isInteger(value))) { + throw new ValidationError(`${label} must be a finite ${type}`); + } + if (typeof definition.minimum === "number" && value < definition.minimum) { + throw new ValidationError(`${label} must be at least ${definition.minimum}`); + } + if (typeof definition.maximum === "number" && value > definition.maximum) { + throw new ValidationError(`${label} must be at most ${definition.maximum}`); + } + return; + } + if (type === "string-array") { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.length > 0)) { + throw new ValidationError(`${label} must be an array of strings`); + } + if (typeof definition.maximumItems === "number" && value.length > definition.maximumItems) { + throw new ValidationError(`${label} exceeds ${definition.maximumItems} items`); + } + const itemMaximumBytes = definition.itemMaximumBytes; + if (typeof itemMaximumBytes === "number" + && value.some((item) => byteLength(item) > itemMaximumBytes)) { + throw new ValidationError(`${label} contains an item exceeding ${itemMaximumBytes} UTF-8 bytes`); + } + return; + } + throw new ValidationError(`unsupported generated parameter type for ${label}`); +} + +export function validateParameters( + command: C, + parameters: CommandParameters[C], +): void { + if (!Object.hasOwn(COMMAND_METADATA, command)) { + throw new ValidationError(`unknown Headless command: ${String(command)}`); + } + if (!isPlainParameterRecord(parameters)) { + throw new ValidationError(`${command} parameters must be a plain data object`); + } + const definitions = COMMAND_METADATA[command].parameters as readonly UnknownRecord[]; + const known = new Map(definitions.map((definition) => [String(definition.name), definition])); + for (const key of Object.keys(parameters)) { + if (!known.has(key)) throw new ValidationError(`${command} received unknown parameter ${key}`); + } + for (const definition of definitions) { + const name = String(definition.name); + const value = parameters[name]; + if (value === undefined) { + if (definition.required === true) throw new ValidationError(`${command} requires ${name}`); + } else { + validateParameter(command, definition, value); + } + } + if (command === "session.create") { + validateSession(String(parameters.name)); + } +} + +export function validateSession(session: string): void { + if (!session || byteLength(session) > 64 || !/^[A-Za-z0-9._-]+$/.test(session)) { + throw new ValidationError( + "session must contain 1 to 64 bytes using only letters, digits, dot, underscore, or hyphen", + ); + } +} + +export function createRequest( + command: C, + parameters: CommandParameters[C], + session?: string, + id = randomUUID(), +): CommandRequest { + validateParameters(command, parameters); + if (!id || byteLength(id) > 128) throw new ValidationError("request id is invalid"); + if (session !== undefined) { + validateSession(session); + if (COMMAND_METADATA[command].scope !== "session") { + throw new ValidationError(`${command} is host-scoped and cannot target a session`); + } + } + return { + id, + version: PROTOCOL_VERSION, + command, + ...(session === undefined ? {} : { session }), + parameters, + }; +} + +export function encodeRequest(request: CommandRequest): Buffer { + let serialized: string; + try { + serialized = JSON.stringify(request); + } catch (cause) { + throw new ValidationError("request could not be encoded as JSON", { cause }); + } + const encoded = Buffer.from(`${serialized}\n`, "utf8"); + if (encoded.byteLength > MAXIMUM_MESSAGE_BYTES) { + throw new ValidationError(`request exceeds the ${MAXIMUM_MESSAGE_BYTES}-byte frame limit`); + } + return encoded; +} + +function validateField(type: string, value: unknown): boolean { + switch (type) { + case "array": return Array.isArray(value) && value.every(isJsonValue); + case "boolean": return typeof value === "boolean"; + case "json": return isJsonValue(value); + case "number": return typeof value === "number" && Number.isFinite(value); + case "object": return isRecord(value) && isJsonValue(value); + case "string": return typeof value === "string"; + case "string-or-null": return typeof value === "string" || value === null; + default: return false; + } +} + +interface RuntimeObjectSchema { + readonly additionalProperties: boolean; + readonly fields: readonly Readonly<{ name: string; required: boolean; type: string }>[]; +} + +function validateObjectSchema( + label: string, + schema: RuntimeObjectSchema, + value: unknown, +): UnknownRecord { + if (!isRecord(value)) throw new MalformedResponseError(`${label} must be an object`); + const knownFields = new Set(schema.fields.map((field) => field.name)); + for (const field of schema.fields) { + const fieldValue = value[field.name]; + if (fieldValue === undefined) { + if (field.required) { + throw new MalformedResponseError(`${label} is missing ${field.name}`); + } + } else if (!validateField(field.type, fieldValue)) { + throw new MalformedResponseError(`${label} has invalid ${field.name}`); + } + } + if (!schema.additionalProperties) { + const unknown = Object.keys(value).find((field) => !knownFields.has(field)); + if (unknown !== undefined) throw new MalformedResponseError(`${label} has unknown field ${unknown}`); + } + if (!isJsonValue(value)) throw new MalformedResponseError(`${label} is not valid JSON`); + return value; +} + +function validateResult(command: C, value: unknown): UnknownRecord { + return validateObjectSchema( + `${command} result`, + COMMAND_METADATA[command].result.schema as RuntimeObjectSchema, + value, + ); +} + +function optionalString(record: UnknownRecord, key: string): string | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== "string") throw new MalformedResponseError(`response error ${key} must be a string`); + return value; +} + +export function decodeResponse( + frame: Buffer, + expectedId: string, + command: C, +): CommandResult { + let value: unknown; + try { + value = JSON.parse(frame.toString("utf8")); + } catch (cause) { + throw new MalformedResponseError("Headless returned malformed JSON", { cause }); + } + if (!isRecord(value)) throw new MalformedResponseError("Headless response must be an object"); + if (!RESPONSE_ADDITIONAL_PROPERTIES) { + const knownEnvelopeFields = new Set(["id", "version", "ok", "result", "error"]); + const unknown = Object.keys(value).find((field) => !knownEnvelopeFields.has(field)); + if (unknown !== undefined) { + throw new MalformedResponseError(`Headless response has unknown field ${unknown}`); + } + } + if (typeof value.version !== "string") { + throw new MalformedResponseError("Headless response is missing its protocol version"); + } + if (value.version !== PROTOCOL_VERSION) { + throw new ProtocolMismatchError(PROTOCOL_VERSION, value.version); + } + if (typeof value.id !== "string") throw new MalformedResponseError("Headless response is missing its id"); + if (value.id !== expectedId) throw new ResponseIdMismatchError(expectedId, value.id); + if (typeof value.ok !== "boolean") throw new MalformedResponseError("Headless response is missing ok"); + + if (!value.ok) { + if (value.result !== undefined && value.result !== null) { + throw new MalformedResponseError("failed Headless response contains a result"); + } + if (!isRecord(value.error) || typeof value.error.code !== "string" + || typeof value.error.message !== "string") { + throw new MalformedResponseError("failed Headless response has an invalid error"); + } + const suggestion = optionalString(value.error, "suggestion"); + const rawDetails = value.error.details; + if (rawDetails !== undefined && !isJsonValue(rawDetails)) { + throw new MalformedResponseError("response error details are not valid JSON"); + } + if (value.error.code === "UNSUPPORTED_CAPABILITY") { + throw new UnsupportedCapabilityError(command, value.error.message); + } + if (value.error.code === "AUTH_REQUIRED") { + const validated = validateObjectSchema( + "AUTH_REQUIRED details", + ERROR_DETAILS_METADATA.AUTH_REQUIRED.schema as RuntimeObjectSchema, + rawDetails, + ) as unknown as AuthenticationRequired; + const details = Object.freeze({ + untrustedContent: true as const, + value: validated, + }) satisfies Untrusted; + throw new AuthenticationRequiredError(value.error.message, suggestion, details); + } + throw new CommandError(value.error.code, value.error.message, suggestion, rawDetails); + } + + if (value.error !== undefined && value.error !== null) { + throw new MalformedResponseError("successful Headless response contains an error"); + } + const result = validateResult(command, value.result); + if (COMMAND_METADATA[command].result.mayContainUntrustedContent) { + return Object.freeze({ untrustedContent: true as const, value: result }) as CommandResult; + } + return result as CommandResult; +} diff --git a/packages/headless-npm/src/transport.ts b/packages/headless-npm/src/transport.ts new file mode 100644 index 0000000..efc3b84 --- /dev/null +++ b/packages/headless-npm/src/transport.ts @@ -0,0 +1,232 @@ +import { lstat } from "node:fs/promises"; +import { createConnection, type Socket } from "node:net"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { + CancelledBeforeSend, + ClientClosedError, + ConnectionError, + MalformedResponseError, + OperationOutcomeUnknown, + ResponseTooLargeError, + TimeoutBeforeSend, + ValidationError, +} from "./errors.js"; +import { MAXIMUM_COMMAND_TIMEOUT_MS, MAXIMUM_MESSAGE_BYTES } from "./generated.js"; + +export function defaultSocketPath(environment: NodeJS.ProcessEnv = process.env): string { + const override = environment.HEADLESS_SOCKET; + if (override !== undefined) { + validateSocketLocation(override, "HEADLESS_SOCKET"); + return override; + } + return join(runtimeDirectory(), "host.sock"); +} + +export function runtimeDirectory(): string { + if (typeof process.getuid !== "function") { + throw new ConnectionError("Headless local transport requires a Unix-like operating system"); + } + return join("/tmp", `headless-${process.getuid()}`); +} + +export function validateSocketLocation(socketPath: string, label = "socketPath"): void { + if (!isAbsolute(socketPath)) throw new ValidationError(`${label} must be absolute`); + const resolved = resolve(socketPath); + if (resolved !== socketPath || dirname(resolved) !== runtimeDirectory()) { + throw new ValidationError( + `${label} must be a direct child of the Headless runtime directory ${runtimeDirectory()}`, + ); + } +} + +async function validatePrivateSocketPath(socketPath: string): Promise { + validateSocketLocation(socketPath); + if (typeof process.getuid !== "function") { + throw new ConnectionError("Headless local transport requires a Unix-like operating system"); + } + const userId = process.getuid(); + let parent; + try { + parent = await lstat(dirname(socketPath)); + } catch (cause) { + throw new ConnectionError("Headless runtime directory is unavailable", { cause }); + } + if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== userId + || (parent.mode & 0o077) !== 0) { + throw new ConnectionError("Headless runtime directory is not private to the current user"); + } + let socket; + try { + socket = await lstat(socketPath); + } catch (cause) { + throw new ConnectionError("Headless host is not running", { cause }); + } + if (!socket.isSocket() || socket.isSymbolicLink() || socket.uid !== userId + || (socket.mode & 0o077) !== 0) { + throw new ConnectionError("Headless socket is not private to the current user"); + } +} + +export interface TransportRequest { + readonly frame: Buffer; + readonly requestId: string; + readonly signal?: AbortSignal; + readonly timeoutMs: number; +} + +interface TransportInternals { + readonly createSocket?: (socketPath: string) => Socket; + readonly validatePath?: (socketPath: string) => Promise; +} + +export class UnixSocketTransport { + readonly socketPath: string; + #closed = false; + readonly #active = new Set(); + readonly #createSocket: (socketPath: string) => Socket; + readonly #validatePath: (socketPath: string) => Promise; + + constructor(socketPath = defaultSocketPath(), internals: TransportInternals = {}) { + validateSocketLocation(socketPath); + this.socketPath = socketPath; + this.#createSocket = internals.createSocket ?? ((path) => createConnection({ path })); + this.#validatePath = internals.validatePath ?? validatePrivateSocketPath; + } + + async send(request: TransportRequest): Promise { + if (this.#closed) throw new ClientClosedError(); + if (!Number.isSafeInteger(request.timeoutMs) || request.timeoutMs < 1 + || request.timeoutMs > MAXIMUM_COMMAND_TIMEOUT_MS) { + throw new ValidationError( + `timeoutMs must be an integer between 1 and ${MAXIMUM_COMMAND_TIMEOUT_MS}`, + ); + } + const newline = request.frame.indexOf(0x0a); + if (request.frame.byteLength > MAXIMUM_MESSAGE_BYTES) { + throw new ValidationError(`request exceeds the ${MAXIMUM_MESSAGE_BYTES}-byte frame limit`); + } + if (newline < 0 || newline !== request.frame.byteLength - 1) { + throw new ValidationError("request must contain exactly one terminal newline frame"); + } + if (request.signal?.aborted) throw new CancelledBeforeSend(); + const startedAt = Date.now(); + await new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + request.signal?.removeEventListener("abort", onAbort); + if (error) reject(error); + else resolve(); + }; + const onAbort = (): void => finish(new CancelledBeforeSend()); + request.signal?.addEventListener("abort", onAbort, { once: true }); + const timer = setTimeout(() => finish(new TimeoutBeforeSend()), request.timeoutMs); + timer.unref(); + this.#validatePath(this.socketPath).then(() => finish(), (cause: unknown) => { + finish(cause instanceof Error ? cause : new ConnectionError("socket validation failed")); + }); + }); + if (this.#closed) throw new ClientClosedError(); + if (request.signal?.aborted) throw new CancelledBeforeSend(); + const remainingTimeoutMs = Math.max(1, request.timeoutMs - (Date.now() - startedAt)); + + return new Promise((resolve, reject) => { + let sent = false; + let settled = false; + let response = Buffer.alloc(0); + const socket = this.#createSocket(this.socketPath); + this.#active.add(socket); + + const finish = (error?: Error, frame?: Buffer): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + request.signal?.removeEventListener("abort", onAbort); + this.#active.delete(socket); + socket.destroy(); + if (error) reject(error); + else if (frame) resolve(frame); + else reject(new MalformedResponseError("Headless response was empty")); + }; + + const unknown = ( + reason: "cancelled" | "closed" | "read-failed" | "timed-out", + cause?: Error, + ): void => { + finish(new OperationOutcomeUnknown(request.requestId, reason, cause ? { cause } : undefined)); + }; + + const onAbort = (): void => { + if (sent) unknown("cancelled"); + else finish(new CancelledBeforeSend()); + }; + request.signal?.addEventListener("abort", onAbort, { once: true }); + + const timer = setTimeout(() => { + if (sent) unknown("timed-out"); + else finish(new TimeoutBeforeSend()); + }, remainingTimeoutMs); + timer.unref(); + + socket.once("connect", () => { + if (settled) return; + if (request.signal?.aborted) { + finish(new CancelledBeforeSend()); + return; + } + sent = true; + socket.write(request.frame, (error) => { + if (error && !settled) unknown("read-failed", error); + }); + }); + socket.on("data", (chunk: Buffer) => { + if (settled) return; + response = Buffer.concat([response, chunk], response.byteLength + chunk.byteLength); + const newline = response.indexOf(0x0a); + if (response.byteLength > MAXIMUM_MESSAGE_BYTES + || (response.byteLength === MAXIMUM_MESSAGE_BYTES && newline < 0)) { + unknown("read-failed", new ResponseTooLargeError(MAXIMUM_MESSAGE_BYTES)); + return; + } + if (newline < 0) return; + if (response.byteLength !== newline + 1) { + unknown( + "read-failed", + new MalformedResponseError("Headless returned more than one response frame"), + ); + return; + } + }); + socket.once("end", () => { + if (settled) return; + const newline = response.indexOf(0x0a); + if (newline < 0) { + const message = response.byteLength === 0 + ? "Headless response was empty" + : "Headless response did not end with a newline"; + unknown("read-failed", new MalformedResponseError(message)); + return; + } + finish(undefined, response.subarray(0, newline)); + }); + socket.once("error", (cause: Error) => { + if (settled) return; + if (sent) unknown("read-failed", cause); + else finish(new ConnectionError("could not connect to the Headless host", { cause })); + }); + socket.once("close", () => { + if (settled) return; + if (sent) unknown("closed"); + else finish(new ConnectionError("Headless connection closed before the request was sent")); + }); + }); + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + for (const socket of this.#active) socket.destroy(); + } +} diff --git a/packages/headless-npm/test/fixtures/auth-required.json b/packages/headless-npm/test/fixtures/auth-required.json new file mode 100644 index 0000000..9b39b7b --- /dev/null +++ b/packages/headless-npm/test/fixtures/auth-required.json @@ -0,0 +1,53 @@ +{ + "valid": { + "challenge": "11111111-1111-4111-8111-111111111111", + "origin": "https://example.com", + "detection": "password-field", + "accounts": [ + { + "alias": "work", + "username": "person@example.com" + } + ], + "expiresInSeconds": 300, + "userPresenceRequired": true, + "credentialUseAvailable": true, + "vaultAvailable": true, + "vaultStatus": "available", + "untrustedContent": true, + "originalActionReplayed": false + }, + "invalid": [ + { + "name": "missing challenge", + "details": { + "origin": "https://example.com", + "detection": "password-field", + "accounts": [], + "expiresInSeconds": 300, + "userPresenceRequired": true, + "credentialUseAvailable": true, + "vaultAvailable": true, + "vaultStatus": "available", + "untrustedContent": true, + "originalActionReplayed": false + } + }, + { + "name": "invalid accounts", + "details": { + "challenge": "11111111-1111-4111-8111-111111111111", + "origin": "https://example.com", + "detection": "password-field", + "accounts": "work", + "expiresInSeconds": 300, + "userPresenceRequired": true, + "credentialUseAvailable": true, + "vaultAvailable": true, + "vaultStatus": "available", + "untrustedContent": true, + "originalActionReplayed": false + } + } + ] +} diff --git a/packages/headless-npm/test/helpers.mjs b/packages/headless-npm/test/helpers.mjs new file mode 100644 index 0000000..5c54630 --- /dev/null +++ b/packages/headless-npm/test/helpers.mjs @@ -0,0 +1,74 @@ +import { randomUUID } from "node:crypto"; +import { chmod, lstat, mkdir, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { join } from "node:path"; +import { COMMAND_METADATA, MAXIMUM_MESSAGE_BYTES, PROTOCOL_VERSION } from "../dist/index.js"; +import { runtimeDirectory } from "../dist/transport.js"; + +export const allCommands = Object.keys(COMMAND_METADATA); + +export async function uniqueSocketPath(prefix = "sdk-test") { + const directory = runtimeDirectory(); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const metadata = await lstat(directory); + if (!metadata.isDirectory() || metadata.isSymbolicLink() || metadata.uid !== process.getuid() + || (metadata.mode & 0o077) !== 0) { + throw new Error(`test runtime directory is unsafe: ${directory}`); + } + return join(directory, `${prefix}-${randomUUID()}.sock`); +} + +export function hostStatus(id, pid = process.pid, commands = allCommands) { + return { + id, + version: PROTOCOL_VERSION, + ok: true, + result: { + ready: true, + pid, + engine: "chromium", + platform: "linux", + productVersion: "1.1.0-test", + protocolVersion: PROTOCOL_VERSION, + capabilities: { commands }, + recordingAvailable: false, + artifactDirectory: "/private/test-artifacts", + navigationAllowlist: [], + }, + }; +} + +export async function privateSocketServer(handler) { + const socketPath = await uniqueSocketPath(); + const server = createServer((socket) => { + let requestBytes = Buffer.alloc(0); + socket.on("error", () => {}); + socket.on("data", async (chunk) => { + requestBytes = Buffer.concat([requestBytes, chunk], requestBytes.byteLength + chunk.byteLength); + if (requestBytes.byteLength > MAXIMUM_MESSAGE_BYTES) { + socket.destroy(); + return; + } + const newline = requestBytes.indexOf(0x0a); + if (newline < 0) return; + socket.removeAllListeners("data"); + const request = JSON.parse(requestBytes.subarray(0, newline).toString("utf8")); + const response = await handler(request, socket); + if (response === undefined || socket.destroyed) return; + if (Buffer.isBuffer(response)) socket.end(response); + else socket.end(`${JSON.stringify(response)}\n`); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + await chmod(socketPath, 0o600); + return { + socketPath, + async close() { + await new Promise((resolve) => server.close(resolve)); + await rm(socketPath, { force: true }); + }, + }; +} diff --git a/packages/headless-npm/test/installer.test.mjs b/packages/headless-npm/test/installer.test.mjs index dc7d7d9..6a4a891 100644 --- a/packages/headless-npm/test/installer.test.mjs +++ b/packages/headless-npm/test/installer.test.mjs @@ -1,6 +1,14 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,8 +18,10 @@ import { checksumFromManifest, defaultCacheRoot, ensureInstalled, + InstallCancelledError, InstallError, platformRelease, + run, validateArchiveEntries, } from "../lib/installer.mjs"; @@ -58,7 +68,9 @@ before(async () => { if (request.url.endsWith("/SHA256SUMS")) { response.end(servedManifest); } else if (request.url.endsWith(`/${asset}`)) { - response.end(archiveBytes); + response.setHeader("transfer-encoding", "chunked"); + response.write(archiveBytes.subarray(0, Math.ceil(archiveBytes.length / 2))); + response.end(archiveBytes.subarray(Math.ceil(archiveBytes.length / 2))); } else { response.statusCode = 404; response.end(); @@ -153,3 +165,121 @@ test("downloads, verifies, installs, and reuses the cached release", async () => assert.equal(second.directory, first.directory); assert.equal(requestCount, afterFirst, "a valid cache entry must not redownload"); }); + +test("cancellation interrupts lock waits without deleting another installer's lock", async () => { + const cacheRoot = join(root, "cancel-lock-cache"); + const lockPath = join(cacheRoot, `v${version}`, "linux-amd64.lock"); + mkdirSync(lockPath, { recursive: true, mode: 0o700 }); + const controller = new AbortController(); + const pending = ensureInstalled({ + version, + platform: "linux", + architecture: "x64", + cacheRoot, + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 25); + await assert.rejects(pending, InstallCancelledError); + assert.equal(existsSync(lockPath), true); +}); + +test("cancellation aborts release fetches and rolls back owned staging", async () => { + const cacheRoot = join(root, "cancel-fetch-cache"); + const controller = new AbortController(); + let fetchStarted; + const started = new Promise((resolvePromise) => { fetchStarted = resolvePromise; }); + const fetchImpl = async (_url, options) => { + fetchStarted(); + return new Promise((_resolve, rejectPromise) => { + options.signal.addEventListener("abort", () => rejectPromise(options.signal.reason), { once: true }); + }); + }; + const pending = ensureInstalled({ + version, + platform: "linux", + architecture: "x64", + cacheRoot, + releaseBaseURL: `https://github.com/${version}`, + fetchImpl, + signal: controller.signal, + }); + await started; + controller.abort(); + await assert.rejects(pending, InstallCancelledError); + assert.equal(existsSync(join(cacheRoot, `v${version}`, "linux-amd64")), false); + assert.equal(existsSync(join(cacheRoot, `v${version}`, "linux-amd64.lock")), false); +}); + +test("cancellation interrupts asset streaming and removes partial downloads", async () => { + const cacheRoot = join(root, "cancel-stream-cache"); + const controller = new AbortController(); + let request = 0; + let assetStarted; + const started = new Promise((resolvePromise) => { assetStarted = resolvePromise; }); + const fetchImpl = async (_url, options) => { + request += 1; + if (request === 1) { + return new Response(`${"0".repeat(64)} ${asset}\n`); + } + return new Response(new ReadableStream({ + start(stream) { + stream.enqueue(new Uint8Array([1, 2, 3])); + assetStarted(); + options.signal.addEventListener("abort", () => stream.error(options.signal.reason), { once: true }); + }, + })); + }; + const pending = ensureInstalled({ + version, + platform: "linux", + architecture: "x64", + cacheRoot, + releaseBaseURL: `https://github.com/${version}`, + fetchImpl, + signal: controller.signal, + }); + await started; + controller.abort(); + await assert.rejects(pending, InstallCancelledError); + assert.equal(existsSync(join(cacheRoot, `v${version}`, "linux-amd64")), false); + assert.equal(existsSync(join(cacheRoot, `v${version}`, "linux-amd64.lock")), false); +}); + +test("cancellation terminates and reaps installer child processes", async () => { + const pidFile = join(root, "cancelled-child.pid"); + const controller = new AbortController(); + const pending = run(process.execPath, [ + "--eval", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`, + ], { signal: controller.signal }); + for (let attempt = 0; attempt < 100 && !existsSync(pidFile); attempt += 1) { + await new Promise((resolvePromise) => setTimeout(resolvePromise, 10)); + } + assert.equal(existsSync(pidFile), true); + const pid = Number(readFileSync(pidFile, "utf8")); + controller.abort(); + await assert.rejects(pending, InstallCancelledError); + assert.throws(() => process.kill(pid, 0), /ESRCH/); +}); + +test("installer child output is bounded and the child is reaped", async () => { + const pidFile = join(root, "oversized-output-child.pid"); + const pending = run(process.execPath, [ + "--eval", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); process.stdout.write('x'.repeat(2 * 1024 * 1024)); setInterval(() => {}, 1000);`, + ]); + await assert.rejects(pending, /stdout exceeded/); + const pid = Number(readFileSync(pidFile, "utf8")); + assert.throws(() => process.kill(pid, 0), /ESRCH/); +}); + +test("installer subprocess timeout terminates and reaps a silent child", async () => { + const pidFile = join(root, "timed-out-child.pid"); + const pending = run(process.execPath, [ + "--eval", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`, + ], { timeoutMilliseconds: 250 }); + await assert.rejects(pending, /timed out after 250 ms/); + const pid = Number(readFileSync(pidFile, "utf8")); + assert.throws(() => process.kill(pid, 0), /ESRCH/); +}); diff --git a/packages/headless-npm/test/lifecycle.test.mjs b/packages/headless-npm/test/lifecycle.test.mjs new file mode 100644 index 0000000..8d37644 --- /dev/null +++ b/packages/headless-npm/test/lifecycle.test.mjs @@ -0,0 +1,398 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; +import { test } from "node:test"; +import { + connect, + HostLaunchError, + launch, + LOCAL_LIFECYCLE, + PROTOCOL_VERSION, +} from "../dist/index.js"; +import { allCommands, hostStatus, privateSocketServer, uniqueSocketPath } from "./helpers.mjs"; +import { packageVersion, platformRelease } from "../lib/installer.mjs"; + +async function socketFor(t, prefix) { + const socketPath = await uniqueSocketPath(prefix); + t.after(() => rm(socketPath, { force: true })); + return socketPath; +} + +function withDeadline(promise, milliseconds, message) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), milliseconds); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +async function mockLauncher(t) { + const directory = await mkdtemp(join(tmpdir(), "headless-sdk-launcher.")); + const executable = join(directory, "headless-test.mjs"); + await writeFile(executable, `#!/usr/bin/env node +import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { dirname } from "node:path"; + +const expectedPresentation = process.env.HEADLESS_TEST_EXPECT_PRESENTATION ?? "background"; +const expected = ["start", "--" + expectedPresentation, "--supervised"]; +if (JSON.stringify(process.argv.slice(2, 5)) !== JSON.stringify(expected)) process.exit(64); +const mode = process.env.HEADLESS_TEST_MODE ?? "owned"; +const socketPath = process.env.HEADLESS_SOCKET; +const commands = JSON.parse(process.env.HEADLESS_TEST_COMMANDS); +const pidFile = process.env.HEADLESS_TEST_PID_FILE; +if (pidFile) writeFileSync(pidFile, String(process.pid)); +if (mode === "failure") process.exit(7); +if (mode === "failure-envelope") { + process.stdout.write(JSON.stringify({ + id: "startup-failure", + version: "${PROTOCOL_VERSION}", + ok: false, + error: { + code: "NAVIGATION_ALLOWLIST_CONFLICT", + message: "an incompatible host is already running", + suggestion: "stop the existing host", + details: { existing: ["one.example"], requested: ["two.example"] }, + }, + }) + "\\n"); + process.stdin.resume(); +} + +const status = (id, pid) => ({ + id, + version: "${PROTOCOL_VERSION}", + ok: true, + result: { + ready: true, + pid, + engine: "chromium", + platform: "linux", + productVersion: "1.1.0-test", + protocolVersion: "${PROTOCOL_VERSION}", + capabilities: { commands }, + recordingAvailable: false, + artifactDirectory: "/private/test-artifacts", + navigationAllowlist: [], + }, +}); + +let server; +if (mode !== "existing" && mode !== "no-frame" && mode !== "malformed" && mode !== "multiple") { + mkdirSync(dirname(socketPath), { recursive: true, mode: 0o700 }); + rmSync(socketPath, { force: true }); + server = createServer((socket) => { + let bytes = Buffer.alloc(0); + socket.on("data", (chunk) => { + bytes = Buffer.concat([bytes, chunk]); + const newline = bytes.indexOf(0x0a); + if (newline < 0) return; + const request = JSON.parse(bytes.subarray(0, newline)); + socket.end(JSON.stringify(status(request.id, Number(process.env.HEADLESS_TEST_SOCKET_PID ?? process.pid))) + "\\n"); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + chmodSync(socketPath, 0o600); +} + +if (mode === "malformed") process.stdout.write("not-json\\n"); +else if (mode === "multiple") { + const line = JSON.stringify(status("startup", process.pid)) + "\\n"; + process.stdout.write(line + line); +} else if (mode !== "no-frame" && mode !== "failure-envelope") { + process.stdout.write(JSON.stringify(status( + "startup", + Number(process.env.HEADLESS_TEST_STARTUP_PID ?? process.pid), + )) + "\\n"); + if (mode === "delayed-multiple") { + setTimeout(() => process.stdout.write(JSON.stringify(status("late", process.pid)) + "\\n"), 75); + } +} + +process.stdin.resume(); +const stop = () => { + if (server) server.close(() => process.exit(0)); + else process.exit(0); +}; +process.stdin.on("end", stop); +process.on("SIGTERM", stop); +if (process.env.HEADLESS_TEST_EXIT_AFTER_MS) { + setTimeout(stop, Number(process.env.HEADLESS_TEST_EXIT_AFTER_MS)); +} +`); + await chmod(executable, 0o755); + t.after(() => rm(directory, { recursive: true, force: true })); + return { directory, executable }; +} + +function launchEnvironment(extra = {}) { + return { + HEADLESS_TEST_COMMANDS: JSON.stringify(allCommands), + ...extra, + }; +} + +test("supervised launch uses generated argv and owns only the matching host", async (t) => { + const fixture = await mockLauncher(t); + const socketPath = await socketFor(t, "owned"); + const signalListeners = { + SIGINT: process.listenerCount("SIGINT"), + SIGTERM: process.listenerCount("SIGTERM"), + }; + const host = await launch({ + executable: fixture.executable, + socketPath, + environment: launchEnvironment(), + }); + assert.deepEqual(LOCAL_LIFECYCLE.launch.argv, ["start", "--background", "--supervised"]); + assert.equal(host.client.hostStatus.pid > 0, true); + assert.deepEqual( + { SIGINT: process.listenerCount("SIGINT"), SIGTERM: process.listenerCount("SIGTERM") }, + signalListeners, + ); + host.client.close(); + const exitedEarly = await Promise.race([host.exited.then(() => true), delay(50, false)]); + assert.equal(exitedEarly, false, "closing an SDK client must not stop its owned launcher implicitly"); + await host.close(); + assert.deepEqual(await host.exited, { code: 0, signal: null }); +}); + +test("connect attaches to a shared host and close never shuts it down", async (t) => { + const server = await privateSocketServer((request) => hostStatus(request.id, 7101)); + t.after(() => server.close()); + const first = await connect({ socketPath: server.socketPath }); + first.close(); + const second = await connect({ socketPath: server.socketPath }); + assert.equal(second.hostStatus.pid, 7101); + second.close(); +}); + +test("supervised launch derives foreground presentation argv from the schema", async (t) => { + const fixture = await mockLauncher(t); + const host = await launch({ + executable: fixture.executable, + socketPath: await socketFor(t, "foreground"), + presentation: "foreground", + environment: launchEnvironment({ HEADLESS_TEST_EXPECT_PRESENTATION: "foreground" }), + }); + await host.close(); + await assert.rejects( + launch({ + executable: fixture.executable, + socketPath: await socketFor(t, "bad-presentation"), + presentation: "sideways", + }), + /presentation must be one of/, + ); + await assert.rejects( + launch({ + executable: fixture.executable, + socketPath: await socketFor(t, "bad-timeout"), + timeoutMs: 100, + }), + /does not accept timeoutMs/, + ); +}); + +test("concurrent shared host cannot be claimed or stopped by supervised launch", async (t) => { + let requests = 0; + const shared = await privateSocketServer((request) => { + requests += 1; + return hostStatus(request.id, 7201); + }); + t.after(() => shared.close()); + const fixture = await mockLauncher(t); + const launcherPidFile = join(fixture.directory, "race-launcher.pid"); + await assert.rejects( + launch({ + executable: fixture.executable, + socketPath: shared.socketPath, + environment: launchEnvironment({ + HEADLESS_TEST_MODE: "existing", + HEADLESS_TEST_PID_FILE: launcherPidFile, + HEADLESS_TEST_STARTUP_PID: "7202", + }), + }), + (error) => error instanceof HostLaunchError && /pid 7202.*pid 7201/.test(error.message), + ); + const launcherPid = Number(await readFile(launcherPidFile, "utf8")); + assert.throws(() => process.kill(launcherPid, 0), /ESRCH/); + const client = await connect({ socketPath: shared.socketPath }); + assert.equal(client.hostStatus.pid, 7201); + client.close(); + assert.equal(requests, 2, "the failed ownership check and later attach should both reach the shared host"); +}); + +test("missing binary and startup failure are typed and fully awaited", async (t) => { + const fixture = await mockLauncher(t); + const otherPrivateDirectory = await mkdtemp(join(tmpdir(), "headless-other-private.")); + t.after(() => rm(otherPrivateDirectory, { recursive: true, force: true })); + await assert.rejects( + launch({ + executable: fixture.executable, + socketPath: join(otherPrivateDirectory, "launch.sock"), + }), + /direct child of the Headless runtime directory/, + ); + await assert.rejects( + launch({ executable: join(fixture.directory, "missing"), socketPath: await socketFor(t, "missing") }), + HostLaunchError, + ); + await assert.rejects( + launch({ + executable: fixture.executable, + socketPath: await socketFor(t, "failed"), + environment: launchEnvironment({ HEADLESS_TEST_MODE: "failure" }), + }), + (error) => error instanceof HostLaunchError && error.exitCode === 7, + ); + await assert.rejects( + launch({ + executable: fixture.executable, + socketPath: await socketFor(t, "failure-envelope"), + environment: launchEnvironment({ HEADLESS_TEST_MODE: "failure-envelope" }), + }), + (error) => error instanceof HostLaunchError + && error.code === "NAVIGATION_ALLOWLIST_CONFLICT" + && error.suggestion === "stop the existing host" + && error.details.requested[0] === "two.example", + ); + await assert.rejects( + launch({ executable: "relative/headless", socketPath: await socketFor(t, "relative") }), + /executable must be an absolute path/, + ); + await assert.rejects( + launch({ + executable: fixture.executable, + socketPath: await socketFor(t, "relative-host"), + environment: launchEnvironment({ HEADLESS_HOST_EXECUTABLE: "relative/host" }), + }), + /HEADLESS_HOST_EXECUTABLE must be an absolute path/, + ); +}); + +test("startup timeout terminates and reaps only its launcher", async (t) => { + const fixture = await mockLauncher(t); + const pidFile = join(fixture.directory, "launcher.pid"); + await assert.rejects( + launch({ + executable: fixture.executable, + socketPath: await socketFor(t, "timeout"), + startupTimeoutMs: 500, + shutdownTimeoutMs: 100, + environment: launchEnvironment({ + HEADLESS_TEST_MODE: "no-frame", + HEADLESS_TEST_PID_FILE: pidFile, + }), + }), + HostLaunchError, + ); + const pid = Number(await readFile(pidFile, "utf8")); + assert.throws(() => process.kill(pid, 0), /ESRCH/); +}); + +test("installation has a separate bounded deadline", async (t) => { + const cacheRoot = await mkdtemp(join(tmpdir(), "headless-sdk-install-timeout.")); + t.after(() => rm(cacheRoot, { recursive: true, force: true })); + const version = await packageVersion(); + const release = platformRelease(version); + const lockPath = join(cacheRoot, `v${version}`, `${release.key}.lock`); + await mkdir(lockPath, { recursive: true, mode: 0o700 }); + const startedAt = Date.now(); + await assert.rejects( + launch({ + environment: { HEADLESS_NPM_CACHE: cacheRoot }, + installationTimeoutMs: 50, + }), + (error) => error instanceof HostLaunchError && /deadline/.test(error.message), + ); + assert.ok(Date.now() - startedAt < 2_000, "installation ignored the launch deadline"); + assert.equal((await stat(lockPath)).isDirectory(), true); +}); + +test("malformed and multiple startup frames fail closed", async (t) => { + const fixture = await mockLauncher(t); + for (const mode of ["malformed", "multiple"]) { + await assert.rejects( + launch({ + executable: fixture.executable, + socketPath: await socketFor(t, mode), + environment: launchEnvironment({ HEADLESS_TEST_MODE: mode }), + }), + HostLaunchError, + ); + } +}); + +test("unexpected owned host termination closes its client", async (t) => { + const fixture = await mockLauncher(t); + const host = await launch({ + executable: fixture.executable, + socketPath: await socketFor(t, "terminates"), + environment: launchEnvironment({ HEADLESS_TEST_EXIT_AFTER_MS: "150" }), + }); + await withDeadline(host.exited, 2_000, "owned launcher did not exit"); + await assert.rejects(host.client.ping(), /client is closed/); +}); + +test("delayed extra startup output closes the client and reaps the owned launcher", async (t) => { + const fixture = await mockLauncher(t); + const pidFile = join(fixture.directory, "delayed.pid"); + const host = await launch({ + executable: fixture.executable, + socketPath: await socketFor(t, "delayed"), + environment: launchEnvironment({ + HEADLESS_TEST_MODE: "delayed-multiple", + HEADLESS_TEST_PID_FILE: pidFile, + }), + }); + await withDeadline(host.exited, 2_000, "delayed startup violation did not stop the launcher"); + await assert.rejects(host.client.ping(), /client is closed/); + const pid = Number(await readFile(pidFile, "utf8")); + assert.throws(() => process.kill(pid, 0), /ESRCH/); +}); + +test("an unreferenced owned launcher is closed and reaped during natural Node exit", async (t) => { + const fixture = await mockLauncher(t); + const socketPath = await socketFor(t, "natural-exit"); + const pidFile = join(fixture.directory, "natural-exit.pid"); + const entrypoint = pathToFileURL(join(process.cwd(), "dist/index.js")).href; + const script = ` + import { launch } from ${JSON.stringify(entrypoint)}; + await launch({ + executable: ${JSON.stringify(fixture.executable)}, + socketPath: ${JSON.stringify(socketPath)}, + environment: { + HEADLESS_TEST_COMMANDS: ${JSON.stringify(JSON.stringify(allCommands))}, + HEADLESS_TEST_PID_FILE: ${JSON.stringify(pidFile)} + } + }); + `; + const child = spawn(process.execPath, ["--input-type=module", "--eval", script], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const result = await Promise.race([ + new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal }))), + delay(5_000).then(() => ({ timeout: true })), + ]); + if (result.timeout) child.kill("SIGKILL"); + assert.deepEqual(result, { code: 0, signal: null }, stderr); + const pid = Number(await readFile(pidFile, "utf8")); + assert.throws(() => process.kill(pid, 0), /ESRCH/); +}); diff --git a/packages/headless-npm/test/macos-swift-integration.mjs b/packages/headless-npm/test/macos-swift-integration.mjs new file mode 100644 index 0000000..7a32e43 --- /dev/null +++ b/packages/headless-npm/test/macos-swift-integration.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { rm } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { launch, PROTOCOL_VERSION } from "../dist/index.js"; +import { runtimeDirectory } from "../dist/transport.js"; + +if (process.platform !== "darwin") throw new Error("macOS SDK integration requires macOS"); +const executable = process.env.HEADLESS_TEST_CLI; +const hostExecutable = process.env.HEADLESS_TEST_HOST; +if (!executable || !isAbsolute(executable)) throw new Error("HEADLESS_TEST_CLI must be absolute"); +if (!hostExecutable || !isAbsolute(hostExecutable)) { + throw new Error("HEADLESS_TEST_HOST must be absolute"); +} + +const socketPath = join(runtimeDirectory(), `sdk-swift-${randomUUID()}.sock`); +let host; +try { + host = await launch({ + executable, + socketPath, + environment: { HEADLESS_HOST_EXECUTABLE: hostExecutable }, + }); + assert.equal(host.client.hostStatus.ready, true); + assert.equal(host.client.hostStatus.protocolVersion, PROTOCOL_VERSION); + const status = await host.client.ping(); + assert.equal(status.pid, host.client.hostStatus.pid); + const sessions = await host.client.sessionList(); + assert.ok(Array.isArray(sessions.sessions)); +} finally { + await host?.close(); + await rm(socketPath, { force: true }); +} diff --git a/packages/headless-npm/test/package.test.mjs b/packages/headless-npm/test/package.test.mjs new file mode 100644 index 0000000..8097d12 --- /dev/null +++ b/packages/headless-npm/test/package.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repositoryRoot = resolve(packageRoot, "../.."); + +test("package metadata declares support, provenance, and zero runtime dependencies", async () => { + const manifest = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8")); + assert.equal(manifest.type, "module"); + assert.equal(manifest.engines.node, ">=22"); + assert.equal(manifest.license, "MIT"); + assert.equal(manifest.publishConfig.access, "public"); + assert.equal(manifest.publishConfig.provenance, true); + assert.equal(manifest.dependencies, undefined); + assert.deepEqual(manifest.exports["."], { + types: "./dist/index.d.ts", + import: "./dist/index.js", + }); +}); + +test("pack contents match the exact runtime allowlist", () => { + const packed = spawnSync( + "npm", + ["pack", "--dry-run", "--json", "--ignore-scripts"], + { cwd: packageRoot, encoding: "utf8" }, + ); + assert.equal(packed.status, 0, packed.stderr); + const report = JSON.parse(packed.stdout); + const files = report[0].files.map((file) => file.path).sort(); + const expected = [ + "LICENSE", + "README.md", + "bin/headless.mjs", + "bin/headless-mcp.mjs", + "dist/client.d.ts", + "dist/client.js", + "dist/errors.d.ts", + "dist/errors.js", + "dist/generated.d.ts", + "dist/generated.js", + "dist/index.d.ts", + "dist/index.js", + "dist/lifecycle.d.ts", + "dist/lifecycle.js", + "dist/protocol.d.ts", + "dist/protocol.js", + "dist/transport.d.ts", + "dist/transport.js", + "lib/installer.d.mts", + "lib/installer.mjs", + "lib/launcher.mjs", + "package.json", + ].sort(); + assert.deepEqual(files, expected); +}); + +test("published license matches the repository license", async () => { + const [packaged, repository] = await Promise.all([ + readFile(resolve(packageRoot, "LICENSE"), "utf8"), + readFile(resolve(repositoryRoot, "LICENSE"), "utf8"), + ]); + assert.equal(packaged, repository); +}); diff --git a/packages/headless-npm/test/protocol.test.mjs b/packages/headless-npm/test/protocol.test.mjs new file mode 100644 index 0000000..a766349 --- /dev/null +++ b/packages/headless-npm/test/protocol.test.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import { + COMMAND_METADATA, + commandTimeoutMilliseconds, + AuthenticationRequiredError, + CommandError, + createRequest, + decodeResponse, + encodeRequest, + MalformedResponseError, + PROTOCOL_FIXTURES_SHA256, + PROTOCOL_SCHEMA_SHA256, + PROTOCOL_VERSION, + ProtocolMismatchError, + ResponseIdMismatchError, + UnsupportedCapabilityError, + ValidationError, + validateParameters, +} from "../dist/index.js"; +import { createHash } from "node:crypto"; + +const fixturesURL = new URL("../../../sdk/protocol-fixtures.json", import.meta.url); +const schemaURL = new URL("../../../sdk/protocol-schema.json", import.meta.url); +const authFixturesURL = new URL("./fixtures/auth-required.json", import.meta.url); +const fixturesBytes = await readFile(fixturesURL); +const fixtures = JSON.parse(fixturesBytes); +const authFixtures = JSON.parse(await readFile(authFixturesURL)); + +test("generated contract matches canonical schema and fixtures", async () => { + const schemaBytes = await readFile(schemaURL); + const schema = JSON.parse(schemaBytes); + assert.equal(createHash("sha256").update(schemaBytes).digest("hex"), PROTOCOL_SCHEMA_SHA256); + assert.equal(createHash("sha256").update(fixturesBytes).digest("hex"), PROTOCOL_FIXTURES_SHA256); + assert.equal(fixtures.protocolVersion, PROTOCOL_VERSION); + assert.equal(Object.keys(COMMAND_METADATA).length, schema.commands.length); +}); + +test("canonical CLI fixtures encode and decode with identical wire shapes", () => { + for (const fixture of fixtures.cases) { + const request = createRequest( + fixture.request.command, + fixture.request.parameters, + fixture.request.session, + fixture.request.id, + ); + assert.deepEqual(request, fixture.request, fixture.name); + const result = decodeResponse( + Buffer.from(JSON.stringify(fixture.response)), + fixture.request.id, + fixture.request.command, + ); + const untrusted = COMMAND_METADATA[fixture.request.command].result.mayContainUntrustedContent; + if (untrusted) { + assert.deepEqual(result, { untrustedContent: true, value: fixture.response.result }); + } else { + assert.deepEqual(result, fixture.response.result); + } + } + for (const request of fixtures.directRequests) { + assert.doesNotThrow(() => createRequest(request.command, request.parameters, undefined, request.id)); + } + for (const request of fixtures.invalidRequests) { + assert.throws( + () => createRequest(request.command, request.parameters, undefined, request.id), + ValidationError, + ); + } +}); + +test("schema-driven validation mirrors portable Swift bounds", () => { + assert.throws(() => validateParameters("unknown.command", {}), /unknown Headless command/); + assert.throws(() => validateParameters("visit", { url: "" }), /must not be empty/); + assert.throws(() => validateParameters("styles.get", { target: "@1", properties: [""] }), /array of strings/); + assert.doesNotThrow(() => validateParameters("screenshot", { format: "PnG" })); + assert.doesNotThrow(() => createRequest("visit", { url: "https://example.com" }, "session.one_2-test")); + assert.throws( + () => createRequest("visit", { url: "https://example.com" }, "spaces are unsafe"), + /letters, digits/, + ); + assert.throws(() => createRequest("session.create", { name: "spaces are unsafe" }), /letters, digits/); + assert.throws(() => createRequest("ping", {}, "session"), /host-scoped/); + assert.throws( + () => validateParameters("auth.login", { challenge: "id", account: "work", password: "secret" }), + /unknown parameter password/, + ); +}); + +test("generated scopes and timeout policies drive the SDK", () => { + assert.equal(COMMAND_METADATA.ping.scope, "host"); + assert.equal(COMMAND_METADATA.visit.scope, "session"); + assert.equal(commandTimeoutMilliseconds("ping", {}), 15_000); + assert.equal(commandTimeoutMilliseconds("wait", { timeoutMs: 100 }), 10_000); + assert.equal(commandTimeoutMilliseconds("wait", { timeoutMs: 120_000 }), 125_000); + assert.equal(commandTimeoutMilliseconds("tour", {}), 125_000); + assert.equal(commandTimeoutMilliseconds("screenshot", {}), 30_000); + assert.equal(commandTimeoutMilliseconds("screenshot", { series: "viewport" }), 125_000); + assert.equal(commandTimeoutMilliseconds("record.stop", {}), 30_000); + assert.equal(commandTimeoutMilliseconds("flow.run", { input: "flow.json" }), 125_000); +}); + +test("request framing contains exactly one terminal newline", () => { + const frame = encodeRequest(createRequest("fill", { target: "@1", value: "line one\nline two" }, undefined, "frame")); + assert.equal(frame.at(-1), 0x0a); + assert.equal(frame.subarray(0, -1).includes(0x0a), false); + assert.equal(JSON.parse(frame.subarray(0, -1)).parameters.value, "line one\nline two"); +}); + +test("response validation accepts additive fields but rejects contract mismatches", () => { + const valid = { + id: "one", + version: PROTOCOL_VERSION, + ok: true, + result: { stopping: true, futureResultField: "accepted" }, + futureEnvelopeField: { accepted: true }, + }; + assert.deepEqual(decodeResponse(Buffer.from(JSON.stringify(valid)), "one", "shutdown"), valid.result); + assert.throws( + () => decodeResponse(Buffer.from("not-json"), "one", "shutdown"), + MalformedResponseError, + ); + assert.throws( + () => decodeResponse(Buffer.from(JSON.stringify({ ...valid, id: "two" })), "one", "shutdown"), + ResponseIdMismatchError, + ); + assert.throws( + () => decodeResponse(Buffer.from(JSON.stringify({ ...valid, version: "9.9" })), "one", "shutdown"), + ProtocolMismatchError, + ); + assert.throws( + () => decodeResponse(Buffer.from(JSON.stringify({ ...valid, result: {} })), "one", "shutdown"), + MalformedResponseError, + ); +}); + +test("command failures map to typed errors", () => { + const failure = (code) => Buffer.from(JSON.stringify({ + id: "failed", + version: PROTOCOL_VERSION, + ok: false, + error: { code, message: "failed safely" }, + })); + assert.throws(() => decodeResponse(failure("TIMEOUT"), "failed", "wait"), CommandError); + assert.throws( + () => decodeResponse(failure("UNSUPPORTED_CAPABILITY"), "failed", "upload"), + UnsupportedCapabilityError, + ); +}); + +test("AUTH_REQUIRED details are generated, validated, and marked untrusted", () => { + const failure = (details) => Buffer.from(JSON.stringify({ + id: "auth", + version: PROTOCOL_VERSION, + ok: false, + error: { code: "AUTH_REQUIRED", message: "login required", details }, + })); + assert.throws( + () => decodeResponse(failure(authFixtures.valid), "auth", "click"), + (error) => error instanceof AuthenticationRequiredError + && error.details.untrustedContent === true + && error.details.value.challenge === authFixtures.valid.challenge, + ); + for (const fixture of authFixtures.invalid) { + assert.throws( + () => decodeResponse(failure(fixture.details), "auth", "click"), + MalformedResponseError, + fixture.name, + ); + } + assert.throws( + () => decodeResponse(failure(undefined), "auth", "click"), + MalformedResponseError, + ); +}); diff --git a/packages/headless-npm/test/transport.test.mjs b/packages/headless-npm/test/transport.test.mjs new file mode 100644 index 0000000..10febd8 --- /dev/null +++ b/packages/headless-npm/test/transport.test.mjs @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { test } from "node:test"; +import { + CancelledBeforeSend, + CommandError, + connect, + HeadlessClient, + MalformedResponseError, + MAXIMUM_MESSAGE_BYTES, + OperationOutcomeUnknown, + PROTOCOL_VERSION, + ProtocolMismatchError, + ResponseIdMismatchError, + ResponseTooLargeError, + TimeoutBeforeSend, + UnsupportedCapabilityError, + ValidationError, +} from "../dist/index.js"; +import { UnixSocketTransport } from "../dist/transport.js"; +import { hostStatus, privateSocketServer, uniqueSocketPath } from "./helpers.mjs"; + +test("connect negotiates capabilities and session helpers preserve untrusted results", async (t) => { + const seen = []; + const server = await privateSocketServer((request) => { + seen.push(request); + if (request.command === "ping") return hostStatus(request.id); + if (request.command === "visit") { + return { + id: request.id, + version: PROTOCOL_VERSION, + ok: true, + result: { + url: request.parameters.url, + title: "Untrusted", + readyState: "complete", + text: "page text", + runningAnimations: 0, + mutationQuietMs: 500, + scrollY: 0, + contentHeight: 900, + }, + }; + } + throw new Error(`unexpected command ${request.command}`); + }); + t.after(() => server.close()); + const client = await connect({ socketPath: server.socketPath }); + t.after(() => client.close()); + const result = await client.session("work.one").visit({ url: "https://example.com" }); + assert.equal(result.untrustedContent, true); + assert.equal(result.value.title, "Untrusted"); + assert.equal(seen[1].session, "work.one"); + assert.ok(client.capabilities.commands.includes("visit")); +}); + +test("commands cannot bypass capability negotiation", async () => { + const client = new HeadlessClient({ socketPath: await uniqueSocketPath("not-contacted") }); + await assert.rejects(client.visit({ url: "https://example.com" }), /connect\(\) must complete/); + client.close(); +}); + +test("transport rejects paths outside the Swift runtime directory and malformed outbound frames", async (t) => { + const server = await privateSocketServer((request) => hostStatus(request.id)); + t.after(() => server.close()); + const transport = new UnixSocketTransport(server.socketPath); + await assert.rejects( + transport.send({ frame: Buffer.from("{}"), requestId: "no-newline", timeoutMs: 100 }), + ValidationError, + ); + await assert.rejects( + transport.send({ frame: Buffer.from("{}\n{}\n"), requestId: "two-frames", timeoutMs: 100 }), + ValidationError, + ); + await chmod(server.socketPath, 0o666); + await assert.rejects( + transport.send({ frame: Buffer.from("{}\n"), requestId: "public-socket", timeoutMs: 100 }), + /socket is not private/, + ); + await chmod(server.socketPath, 0o600); + transport.close(); + + const otherPrivateDirectory = await mkdtemp(join(tmpdir(), "headless-other-private.")); + t.after(() => rm(otherPrivateDirectory, { recursive: true, force: true })); + assert.throws( + () => new UnixSocketTransport(join(otherPrivateDirectory, "host.sock")), + /direct child of the Headless runtime directory/, + ); + await assert.rejects( + connect({ socketPath: join(otherPrivateDirectory, "connect.sock") }), + /direct child of the Headless runtime directory/, + ); +}); + +test("capability negotiation rejects unsupported commands before transport", async (t) => { + let requests = 0; + const server = await privateSocketServer((request) => { + requests += 1; + return hostStatus(request.id, process.pid, ["ping"]); + }); + t.after(() => server.close()); + const client = await connect({ socketPath: server.socketPath }); + t.after(() => client.close()); + await assert.rejects(client.visit({ url: "https://example.com" }), UnsupportedCapabilityError); + assert.equal(requests, 1); +}); + +test("timeout and cancellation before send are explicitly retry-safe", async () => { + const transport = new UnixSocketTransport(await uniqueSocketPath("unused"), { + validatePath: async () => delay(100), + }); + await assert.rejects( + transport.send({ frame: Buffer.from("{}\n"), requestId: "before-timeout", timeoutMs: 5 }), + (error) => error instanceof TimeoutBeforeSend && error.retrySafe, + ); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + transport.send({ + frame: Buffer.from("{}\n"), + requestId: "before-cancel", + timeoutMs: 100, + signal: controller.signal, + }), + (error) => error instanceof CancelledBeforeSend && error.retrySafe, + ); +}); + +test("timeout, cancellation, and read failure after write report unknown outcome", async (t) => { + const server = await privateSocketServer(async (request, socket) => { + if (request.id === "read-failure") socket.destroy(); + return undefined; + }); + t.after(() => server.close()); + const transport = new UnixSocketTransport(server.socketPath); + await assert.rejects( + transport.send({ frame: Buffer.from('{"id":"post-timeout"}\n'), requestId: "post-timeout", timeoutMs: 20 }), + (error) => error instanceof OperationOutcomeUnknown && error.reason === "timed-out" && !error.retrySafe, + ); + const controller = new AbortController(); + const pending = transport.send({ + frame: Buffer.from('{"id":"post-cancel"}\n'), + requestId: "post-cancel", + timeoutMs: 1_000, + signal: controller.signal, + }); + await delay(20); + controller.abort(); + await assert.rejects( + pending, + (error) => error instanceof OperationOutcomeUnknown && error.reason === "cancelled", + ); + await assert.rejects( + transport.send({ frame: Buffer.from('{"id":"read-failure"}\n'), requestId: "read-failure", timeoutMs: 1_000 }), + (error) => error instanceof OperationOutcomeUnknown && ["closed", "read-failed"].includes(error.reason), + ); + transport.close(); +}); + +test("post-write response failures preserve framing causes under unknown outcome", async (t) => { + const modes = new Map(); + const server = await privateSocketServer((request, socket) => { + const mode = modes.get(request.command); + if (request.command === "ping") return hostStatus(request.id); + if (mode === "malformed") return Buffer.from("not-json\n"); + if (mode === "empty") return Buffer.alloc(0); + if (mode === "partial") return Buffer.from("{\"id\":\"partial\""); + if (mode === "oversized") return Buffer.alloc(MAXIMUM_MESSAGE_BYTES + 1, 0x61); + if (mode === "multiple") return Buffer.from("{}\n{}\n"); + if (mode === "delayed-multiple") { + socket.write(`${JSON.stringify({ + id: request.id, + version: PROTOCOL_VERSION, + ok: true, + result: { stopping: true }, + })}\n`); + setTimeout(() => socket.end("{}\n"), 10); + return undefined; + } + if (mode === "mismatch") { + return { id: "another-request", version: PROTOCOL_VERSION, ok: true, result: { stopping: true } }; + } + if (mode === "wrong-version") { + return { id: request.id, version: "9.9", ok: true, result: { stopping: true } }; + } + if (mode === "malformed-result") { + return { id: request.id, version: PROTOCOL_VERSION, ok: true, result: {} }; + } + if (mode === "command-error") { + return { + id: request.id, + version: PROTOCOL_VERSION, + ok: false, + error: { code: "TIMEOUT", message: "host timed out" }, + }; + } + return undefined; + }); + t.after(() => server.close()); + const client = await connect({ socketPath: server.socketPath }); + t.after(() => client.close()); + modes.set("shutdown", "malformed"); + await assert.rejects( + client.shutdown(), + (error) => error instanceof OperationOutcomeUnknown + && error.cause instanceof MalformedResponseError, + ); + modes.set("shutdown", "oversized"); + await assert.rejects( + client.shutdown(), + (error) => error instanceof OperationOutcomeUnknown + && error.cause instanceof ResponseTooLargeError, + ); + modes.set("shutdown", "multiple"); + await assert.rejects( + client.shutdown(), + (error) => error instanceof OperationOutcomeUnknown + && error.cause instanceof MalformedResponseError + && /more than one/.test(error.cause.message), + ); + modes.set("shutdown", "delayed-multiple"); + await assert.rejects( + client.shutdown(), + (error) => error instanceof OperationOutcomeUnknown + && error.cause instanceof MalformedResponseError + && /more than one/.test(error.cause.message), + ); + modes.set("shutdown", "mismatch"); + await assert.rejects( + client.shutdown(), + (error) => error instanceof OperationOutcomeUnknown + && error.cause instanceof ResponseIdMismatchError, + ); + modes.set("shutdown", "wrong-version"); + await assert.rejects( + client.shutdown(), + (error) => error instanceof OperationOutcomeUnknown + && error.cause instanceof ProtocolMismatchError, + ); + modes.set("shutdown", "malformed-result"); + await assert.rejects( + client.shutdown(), + (error) => error instanceof OperationOutcomeUnknown + && error.cause instanceof MalformedResponseError + && /missing stopping/.test(error.cause.message), + ); + for (const mode of ["empty", "partial"]) { + modes.set("shutdown", mode); + await assert.rejects( + client.shutdown(), + (error) => error instanceof OperationOutcomeUnknown + && error.cause instanceof MalformedResponseError, + ); + } + modes.set("shutdown", "command-error"); + await assert.rejects( + client.shutdown(), + (error) => error instanceof CommandError && error.code === "TIMEOUT", + ); +}); diff --git a/packages/headless-npm/test/types.test.ts b/packages/headless-npm/test/types.test.ts new file mode 100644 index 0000000..0b9f9f3 --- /dev/null +++ b/packages/headless-npm/test/types.test.ts @@ -0,0 +1,40 @@ +import { + type AuthLoginParameters, + type AuthenticationRequired, + AuthenticationRequiredError, + type CommandResult, + HeadlessClient, + HeadlessSession, + type LaunchOptions, + type Untrusted, +} from "../src/index.js"; + +type HasPasswordParameter = "password" extends keyof AuthLoginParameters ? true : false; +const hasPasswordParameter: HasPasswordParameter = false; +void hasPasswordParameter; +type SessionHasHostPing = "ping" extends keyof HeadlessSession ? true : false; +const sessionHasHostPing: SessionHasHostPing = false; +void sessionHasHostPing; + +declare const client: HeadlessClient; +declare const session: HeadlessSession; +declare const authError: AuthenticationRequiredError; + +const visit: Promise> = client.visit({ url: "https://example.com" }); +const scopedVisit: Promise> = session.visit({ + url: "https://example.com", +}); +const login: Promise> = session.authLogin({ + challenge: "11111111-1111-4111-8111-111111111111", + account: "work", +}); +const authenticationRequired: Untrusted = authError.details; + +void visit; +void scopedVisit; +void login; +void authenticationRequired; + +// @ts-expect-error launch has explicit startup and shutdown timeouts, not a command timeout. +const misleadingLaunchTimeout = { timeoutMs: 1 } satisfies LaunchOptions; +void misleadingLaunchTimeout; diff --git a/packages/headless-npm/tsconfig.json b/packages/headless-npm/tsconfig.json new file mode 100644 index 0000000..6b7c98b --- /dev/null +++ b/packages/headless-npm/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ESNext"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "outDir": "dist", + "rootDir": "src", + "sourceMap": false, + "strict": true, + "target": "ES2023", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/headless-npm/tsconfig.test.json b/packages/headless-npm/tsconfig.test.json new file mode 100644 index 0000000..9199124 --- /dev/null +++ b/packages/headless-npm/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/types.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 771e10a..6056f23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,14 @@ importers: specifier: ^6.0.3 version: 6.0.3 - packages/headless-npm: {} + packages/headless-npm: + devDependencies: + '@types/node': + specifier: ^22.20.2 + version: 22.20.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 packages: @@ -624,6 +631,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/node@22.20.2': + resolution: {integrity: sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==} + '@types/node@26.5.0': resolution: {integrity: sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==} @@ -2090,6 +2100,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} @@ -2684,6 +2697,10 @@ snapshots: '@types/json5@0.0.29': {} + '@types/node@22.20.2': + dependencies: + undici-types: 6.21.0 + '@types/node@26.5.0': dependencies: undici-types: 8.9.0 @@ -4402,6 +4419,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@6.21.0: {} + undici-types@8.9.0: {} undici@8.10.0: {} From 55a87d9e5bb806fc7b52ffe13ad44f13ab70eb76 Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Sun, 13 Sep 2026 01:11:12 +0530 Subject: [PATCH 2/3] fix(sdk): normalize generated file ending --- packages/headless-npm/scripts/generate-sdk.mjs | 2 +- packages/headless-npm/src/generated.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/headless-npm/scripts/generate-sdk.mjs b/packages/headless-npm/scripts/generate-sdk.mjs index 3687c4a..848ebbb 100644 --- a/packages/headless-npm/scripts/generate-sdk.mjs +++ b/packages/headless-npm/scripts/generate-sdk.mjs @@ -262,7 +262,7 @@ lines.push( emitMethods(schema.commands.filter((command) => command.scope === "session")); lines.push("}", ""); -const output = `${lines.join("\n")}\n`; +const output = lines.join("\n"); if (process.argv.includes("--check")) { const existing = await readFile(outputPath, "utf8").catch(() => ""); if (existing !== output) throw new Error("generated SDK declarations are stale; run npm run generate"); diff --git a/packages/headless-npm/src/generated.ts b/packages/headless-npm/src/generated.ts index 5513083..3b25144 100644 --- a/packages/headless-npm/src/generated.ts +++ b/packages/headless-npm/src/generated.ts @@ -3406,4 +3406,3 @@ export abstract class GeneratedSessionCommandClient { } } - From 658b676a0a160beee7a30bf49ba539cc1042fcf5 Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Sun, 13 Sep 2026 03:11:24 +0530 Subject: [PATCH 3/3] fix(sdk): preserve platform launch defaults --- packages/headless-npm/README.md | 9 +++++---- packages/headless-npm/src/generated.ts | 3 +-- packages/headless-npm/src/lifecycle.ts | 16 ++++++++++------ packages/headless-npm/test/lifecycle.test.mjs | 14 ++++++++++---- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/headless-npm/README.md b/packages/headless-npm/README.md index da003cb..3451dd8 100644 --- a/packages/headless-npm/README.md +++ b/packages/headless-npm/README.md @@ -50,10 +50,11 @@ that trust marker when sending page content to an agent or another system. ## Supervised host Use `launch()` when this process must own a new host. It invokes the installed -CLI with `headless start --background --supervised`, keeps the ownership pipe -open, verifies that the startup response and socket report the same host PID, -and reaps only that launcher during disposal. It fails rather than claiming an -already-running shared host. +CLI with `headless start --supervised`, keeps the ownership pipe open, verifies +that the startup response and socket report the same host PID, and reaps only +that launcher during disposal. Omit `presentation` to preserve the platform +default, or explicitly select `background` or `foreground` on macOS. Launch +fails rather than claiming an already-running shared host. ```ts import { launch } from "@lockintime/headless"; diff --git a/packages/headless-npm/src/generated.ts b/packages/headless-npm/src/generated.ts index 3b25144..13a3531 100644 --- a/packages/headless-npm/src/generated.ts +++ b/packages/headless-npm/src/generated.ts @@ -7,7 +7,7 @@ export type Untrusted = Readonly<{ readonly untrustedContent: true; readonly export const PROTOCOL_VERSION = "0.5" as const; export const PROTOCOL_SCHEMA_VERSION = 1 as const; export const MAXIMUM_MESSAGE_BYTES = 1048576 as const; -export const PROTOCOL_SCHEMA_SHA256 = "c199f18185cfa05b5c16c9140e48e1f588c61188b5f2ea6fa58eea2ddc57dcbf" as const; +export const PROTOCOL_SCHEMA_SHA256 = "882634187c7ef02ec4ed51fff0e747114eadeff308c10bb9b3274b3630fad11d" as const; export const PROTOCOL_FIXTURES_SHA256 = "0b51ffaa2d3e3aaf0c32adcfeb02c180dcbe44face0d49e1c332b69f403ae062" as const; export const RESPONSE_ADDITIONAL_PROPERTIES = true as const; export const MAXIMUM_COMMAND_TIMEOUT_MS = 125000 as const; @@ -22,7 +22,6 @@ export const LOCAL_LIFECYCLE = { "launch": { "argv": [ "start", - "--background", "--supervised" ], "command": "start", diff --git a/packages/headless-npm/src/lifecycle.ts b/packages/headless-npm/src/lifecycle.ts index 1dee059..68a8d42 100644 --- a/packages/headless-npm/src/lifecycle.ts +++ b/packages/headless-npm/src/lifecycle.ts @@ -337,19 +337,23 @@ export async function launch(options: LaunchOptions = {}): Promise : AbortSignal.any([options.signal, deadlineSignal]); const socketPath = options.socketPath ?? defaultSocketPath(options.environment ?? process.env); validateSocketLocation(socketPath); - const presentation = options.presentation ?? "background"; - if (!(LAUNCH_PRESENTATIONS as readonly string[]).includes(presentation)) { + const presentation = options.presentation; + if (presentation !== undefined + && !(LAUNCH_PRESENTATIONS as readonly string[]).includes(presentation)) { throw new ValidationError(`presentation must be one of ${LAUNCH_PRESENTATIONS.join(", ")}`); } const presentationFlags = new Set(LAUNCH_PRESENTATIONS.map((value) => `--${value}`)); const generatedPresentationFlags = LOCAL_LIFECYCLE.launch.argv .filter((argument) => presentationFlags.has(argument)); - if (generatedPresentationFlags.length !== 1) { + if (generatedPresentationFlags.length !== 0) { throw new ValidationError("generated launch argv has an invalid presentation flag"); } - const argumentsList: string[] = LOCAL_LIFECYCLE.launch.argv.map((argument) => ( - presentationFlags.has(argument) ? `--${presentation}` : argument - )); + const argumentsList: string[] = [...LOCAL_LIFECYCLE.launch.argv]; + const supervisedIndex = argumentsList.indexOf("--supervised"); + if (supervisedIndex < 0 || argumentsList.lastIndexOf("--supervised") !== supervisedIndex) { + throw new ValidationError("generated launch argv has an invalid supervised flag"); + } + if (presentation !== undefined) argumentsList.splice(supervisedIndex, 0, `--${presentation}`); const allowDefinition = LOCAL_LIFECYCLE.launch.options.find((option) => option.name === "allow"); const allow = options.allow ?? []; if (!allowDefinition || allow.length > allowDefinition.maximumItems) { diff --git a/packages/headless-npm/test/lifecycle.test.mjs b/packages/headless-npm/test/lifecycle.test.mjs index 8d37644..9f789e9 100644 --- a/packages/headless-npm/test/lifecycle.test.mjs +++ b/packages/headless-npm/test/lifecycle.test.mjs @@ -46,9 +46,15 @@ import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { dirname } from "node:path"; -const expectedPresentation = process.env.HEADLESS_TEST_EXPECT_PRESENTATION ?? "background"; -const expected = ["start", "--" + expectedPresentation, "--supervised"]; -if (JSON.stringify(process.argv.slice(2, 5)) !== JSON.stringify(expected)) process.exit(64); +const expectedPresentation = process.env.HEADLESS_TEST_EXPECT_PRESENTATION; +const expected = [ + "start", + ...(expectedPresentation ? ["--" + expectedPresentation] : []), + "--supervised", +]; +if (JSON.stringify(process.argv.slice(2, 2 + expected.length)) !== JSON.stringify(expected)) { + process.exit(64); +} const mode = process.env.HEADLESS_TEST_MODE ?? "owned"; const socketPath = process.env.HEADLESS_SOCKET; const commands = JSON.parse(process.env.HEADLESS_TEST_COMMANDS); @@ -158,7 +164,7 @@ test("supervised launch uses generated argv and owns only the matching host", as socketPath, environment: launchEnvironment(), }); - assert.deepEqual(LOCAL_LIFECYCLE.launch.argv, ["start", "--background", "--supervised"]); + assert.deepEqual(LOCAL_LIFECYCLE.launch.argv, ["start", "--supervised"]); assert.equal(host.client.hostStatus.pid > 0, true); assert.deepEqual( { SIGINT: process.listenerCount("SIGINT"), SIGTERM: process.listenerCount("SIGTERM") },