diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index fbed519..44c06f0 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -315,9 +315,9 @@ jobs: const minimumFiles = 50; const maximumFiles = 69; const minimumPackedBytes = 140_000; - const maximumPackedBytes = 270_000; + const maximumPackedBytes = 280_000; const minimumUnpackedBytes = 650_000; - const maximumUnpackedBytes = 1_250_000; + const maximumUnpackedBytes = 1_280_000; const maximumMetadataBytes = 250_000; const expectedName = "@hraness/direct"; const expectedVersion = process.env.EXPECTED_VERSION; diff --git a/AGENTS.md b/AGENTS.md index af63c94..662b1e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ - Deliver changes to `main` through a current-head pull request. Keep the stable `Required` CI job green, resolve every review thread, and serialize merges. Human approval stays optional while one regular maintainer would otherwise self-review. Never force-push or bypass the gate. - Keep core code product-, platform-, and framework-neutral. Put React, browser globals, and Node-only tooling behind explicit subpaths. - Build Bun host `@hraness/direct/tooling/*` entries separately. Keep every development-only export out of the default, core, React, testing, and web graphs, and prove the separation through the packed-consumer boundary gate. Ship the Bombadil campaign subpath as TypeScript source because 0.7.2 resolves no package export conditions, and keep it free of filesystem and process APIs because its compiler loads that subpath into a browser specification. +- Owned browser automation must explicitly select provisioned Chrome for Testing or Playwright Chromium in its task-owned config. Never fall back to installed auto-updating Chrome or ambient browser discovery. Keep graceful close and existing host/browser ownership requirements intact. - Pin optional browser tools exactly. The Bombadil integration supports 0.7.2 only, treats its JSONL trace as foreign bounded input, and must attest the canonical Direct manifest and probe after every run rather than trust a zero exit status. - Constrain every Bombadil run to exclusive UUID leaves, owned process groups, bounded files and totals, a final descriptor-bound inventory, and a sanitized receipt. Public CI may upload only the exact receipt/summary leaf; raw traces and diagnostics require explicit bounded private vetting. Give each product-owned named snapshot an exact fail-closed parser or predicate. - Keep React Native and Expo imports in the reference example; `@hraness/direct/react` remains the platform-neutral React binding. diff --git a/CHANGELOG.md b/CHANGELOG.md index de64575..3e4a0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Each section below is the release page text for one Direct version: a summary, then the changes. The release workflow copies the section whose heading matches the tagged version and adds the install and verification steps itself. +## 0.7.24 - 2026-09-29 + +Direct's browser verification helper now requires an explicitly provisioned automation browser, preventing agent-browser from silently selecting installed, auto-updating Chrome. + +- Set `executablePath` in `scripts/direct/agent-browser.verify.json` to Chrome for Testing or provisioned Chromium. The helper resolves the path, rejects installed Chrome bundles, checks the browser version with bounded output and execution time, and reports the selected executable and version. +- Browser attachment, alternate providers, and executable overrides are rejected by the owned-browser helper. `run()` requires the command first; batches support plain unquoted command strings and `--bail`. Use individual calls for complex payloads. +- The verification guide and Agent Skill recipe select and check the automation browser explicitly. Existing session isolation and browser cleanup requirements remain in place. + ## 0.7.23 - 2026-09-28 This release updates Direct's package description, README, and Agent Skill guidance. The library's exports and browser tooling are unchanged. diff --git a/dist/tooling/bombadil.js b/dist/tooling/bombadil.js index 68c37e8..8d00674 100644 --- a/dist/tooling/bombadil.js +++ b/dist/tooling/bombadil.js @@ -6,15 +6,15 @@ import { mkdir as mkdir2, open, opendir, - readFile, - realpath, + readFile as readFile2, + realpath as realpath2, rename as rename2, rmdir, rm as rm2, - stat, + stat as stat2, unlink } from "fs/promises"; -import { extname, isAbsolute, join as join2, relative, resolve } from "path"; +import { extname, isAbsolute as isAbsolute2, join as join2, relative, resolve } from "path"; import process2 from "process"; import { createHash, randomUUID as randomUUID2 } from "crypto"; @@ -901,8 +901,8 @@ var SCENARIO_QUERY_KEY2 = "__direct_scenario"; // src/tooling/browser-verification.ts import { randomUUID } from "crypto"; -import { mkdir, rename, rm, writeFile } from "fs/promises"; -import { dirname, join } from "path"; +import { access, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "fs/promises"; +import { dirname, isAbsolute, join } from "path"; // src/tooling/verification-output.ts var VERIFICATION_OUTPUT_TAIL_LIMIT = 12000; @@ -1712,7 +1712,7 @@ function validateBombadilToolchainConfig(value, repositoryRoot) { const sourceRevision = Reflect.get(value, "sourceRevision"); const version = Reflect.get(value, "version"); const buildContract = Reflect.get(value, "buildContract"); - if (typeof executablePath !== "string" || !isAbsolute(executablePath) || resolve(executablePath) !== executablePath || !isWithin(repositoryRoot, executablePath)) { + if (typeof executablePath !== "string" || !isAbsolute2(executablePath) || resolve(executablePath) !== executablePath || !isWithin(repositoryRoot, executablePath)) { throw new Error("bombadilToolchain.executablePath must be an absolute normalized path inside repositoryRoot"); } if (typeof sha256 !== "string" || !SHA256_PATTERN.test(sha256)) { @@ -1857,7 +1857,7 @@ function normalizeFuzzRunOptions(input) { } function validateArtifactRunPlan(input) { const repositoryRoot = resolve(input.repositoryRoot); - if (!isAbsolute(input.repositoryRoot) || repositoryRoot !== input.repositoryRoot) { + if (!isAbsolute2(input.repositoryRoot) || repositoryRoot !== input.repositoryRoot) { throw new Error("artifactRun.repositoryRoot must be an absolute normalized path"); } if (!UUID_PATTERN.test(input.runId)) { @@ -2233,7 +2233,7 @@ async function ensureSafeDirectoryChain(repositoryRoot, parts) { throw error; } await requireSafeDirectory(current, `Artifact directory ${part}`); - const resolved = await realpath(current); + const resolved = await realpath2(current); if (!isWithin(repositoryRoot, resolved) || resolved !== current) { throw new BombadilArtifactPolicyError("Artifact directory escaped repositoryRoot"); } @@ -2272,7 +2272,7 @@ async function prepareArtifactUploadSession(planInput) { const plan = validateArtifactRunPlan(planInput); let repositoryRoot; try { - repositoryRoot = await realpath(plan.repositoryRoot); + repositoryRoot = await realpath2(plan.repositoryRoot); } catch (error) { if (!isRecord2(error) || error.code !== "ENOENT") { throw new BombadilArtifactPolicyError(`artifactRun.repositoryRoot could not be proven safe: ${renderUnknown(error)}`); @@ -3005,7 +3005,7 @@ async function ensureSafeChildDirectories(root, parts) { throw error; } await requireSafeDirectory(current, "Bombadil upload directory"); - const resolved = await realpath(current); + const resolved = await realpath2(current); if (!isWithin(root, resolved) || resolved !== current) { throw new BombadilArtifactPolicyError("Bombadil upload directory escaped staging root"); } @@ -4230,7 +4230,7 @@ function parseDirectBombadilFuzzArguments(arguments_, defaultBaseUrl) { } function isWithin(root, candidate) { const path = relative(root, candidate); - return path === "" || !path.startsWith("..") && !isAbsolute(path); + return path === "" || !path.startsWith("..") && !isAbsolute2(path); } function validateReadinessPath(value) { if (!value.startsWith("/") || value.startsWith("//")) { @@ -4415,7 +4415,7 @@ function validateExplorationPolicy(value) { } function validateDirectBombadilFuzzConfig(config, baseUrlOverride) { const repositoryRoot = resolve(config.repositoryRoot); - if (!isAbsolute(config.repositoryRoot) || repositoryRoot !== config.repositoryRoot) { + if (!isAbsolute2(config.repositoryRoot) || repositoryRoot !== config.repositoryRoot) { throw new Error("repositoryRoot must be an absolute normalized path"); } if (!isBoundedArtifactIdentifier(config.artifactName)) { @@ -4432,13 +4432,13 @@ function validateDirectBombadilFuzzConfig(config, baseUrlOverride) { } const specificationPath = resolve(config.specificationPath); const serverCwd = resolve(config.server.cwd); - if (!isAbsolute(config.specificationPath) || !isWithin(repositoryRoot, specificationPath)) { + if (!isAbsolute2(config.specificationPath) || !isWithin(repositoryRoot, specificationPath)) { throw new Error("specificationPath must be an absolute path inside repositoryRoot"); } if (!/\.[cm]?[jt]sx?$/u.test(specificationPath)) { throw new Error("specificationPath must name a JavaScript or TypeScript specification"); } - if (!isAbsolute(config.server.cwd) || !isWithin(repositoryRoot, serverCwd)) { + if (!isAbsolute2(config.server.cwd) || !isWithin(repositoryRoot, serverCwd)) { throw new Error("server.cwd must be an absolute path inside repositoryRoot"); } if (config.server.command.length === 0) { @@ -4931,7 +4931,7 @@ async function readServerOutputBounded(server, timeoutMs) { async function requireRegularFile(path, label) { let metadata; try { - metadata = await stat(path); + metadata = await stat2(path); } catch { throw new Error(`${label} does not exist at its configured path`); } @@ -4939,7 +4939,7 @@ async function requireRegularFile(path, label) { throw new Error(`${label} must be a regular file`); } async function resolveBombadilExecutablePath(candidate, repositoryRoot) { - if (!isAbsolute(candidate) || resolve(candidate) !== candidate || !isWithin(repositoryRoot, candidate)) { + if (!isAbsolute2(candidate) || resolve(candidate) !== candidate || !isWithin(repositoryRoot, candidate)) { throw new Error("The root Bombadil executable must be an absolute normalized path inside repositoryRoot"); } let metadata; @@ -5252,7 +5252,7 @@ function assertSameBombadilExecutableAttestation(before, after) { async function requireDirectory(path, label) { let metadata; try { - metadata = await stat(path); + metadata = await stat2(path); } catch { throw new Error(`${label} does not exist at its configured path`); } @@ -5261,7 +5261,7 @@ async function requireDirectory(path, label) { } async function resolveExistingRealPath(path, label) { try { - return await realpath(path); + return await realpath2(path); } catch { throw new Error(`${label} does not exist at its configured path`); } @@ -5322,7 +5322,7 @@ async function readExactBombadilVersion(repositoryRoot, toolchain, executablePat const packagePath = join2(repositoryRoot, "node_modules", "@antithesishq", "bombadil", "package.json"); let input; try { - input = JSON.parse(await readFile(packagePath, "utf8")); + input = JSON.parse(await readFile2(packagePath, "utf8")); } catch { throw new Error("The root Bombadil package metadata is missing or malformed"); } @@ -5425,7 +5425,7 @@ async function runDirectBombadilFuzzMatrix(campaignsInput, input = process2.argv throw new Error(`Bombadil campaign matrix must contain 1-${String(MAX_MATRIX_CAMPAIGNS)} campaigns`); } const requestedMatrixPlan = normalizedOptions.artifactRun ?? { - repositoryRoot: await realpath(resolve(firstRepositoryRoot ?? "")), + repositoryRoot: await realpath2(resolve(firstRepositoryRoot ?? "")), runId: dependencies.createRunId(), uploadMode: "public-summary" }; @@ -5659,7 +5659,7 @@ async function runDirectBombadilFuzzInternal(config, input = process2.argv.slice try { const generatedAt = dependencies.now(); const artifactPlan = preparedUpload?.plan ?? normalizedOptions.artifactRun ?? { - repositoryRoot: await realpath(resolve(config.repositoryRoot)), + repositoryRoot: await realpath2(resolve(config.repositoryRoot)), runId: dependencies.createRunId(), uploadMode: "public-summary" }; diff --git a/dist/tooling/browser-verification-entry.js b/dist/tooling/browser-verification-entry.js index 23702ea..e35b388 100644 --- a/dist/tooling/browser-verification-entry.js +++ b/dist/tooling/browser-verification-entry.js @@ -883,9 +883,12 @@ var DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2"; var DIRECT_BROWSER_BRIDGE_SCHEMA2 = DIRECT_BROWSER_BRIDGE_SCHEMA; // src/tooling/browser-verification.ts +import { execFile } from "child_process"; +import { constants } from "fs"; +import { promisify } from "util"; import { randomUUID } from "crypto"; -import { mkdir, rename, rm, writeFile } from "fs/promises"; -import { dirname, join } from "path"; +import { access, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "fs/promises"; +import { dirname, isAbsolute, join } from "path"; // src/tooling/verification-output.ts var VERIFICATION_OUTPUT_TAIL_LIMIT = 12000; @@ -1340,6 +1343,83 @@ function parseAgentBrowserBatchEnvelope(source) { return result.value; }); } +async function managedAgentBrowserExecutable(configPath) { + const guidance = "Set executablePath in scripts/direct/agent-browser.verify.json to a provisioned Chrome for Testing or Playwright Chromium executable; run agent-browser install or playwright install chromium first. Installed auto-updating Chrome and automatic discovery are not supported."; + let config; + try { + config = JSON.parse(await readFile(configPath, "utf8")); + } catch (error) { + throw new Error(`Cannot read browser configuration. ${guidance}`, { cause: error }); + } + if (isNonArrayObject(config)) { + for (const key of ["autoConnect", "cdp", "provider"]) { + const value = Reflect.get(config, key); + if (value !== undefined && value !== false && value !== null && value !== "") { + throw new Error(`Owned browser configuration cannot select ${key}; browser attachment requires a separate explicit workflow.`); + } + } + const engine = Reflect.get(config, "engine"); + if (engine !== undefined && engine !== "chrome") { + throw new Error("Owned browser configuration requires the chrome engine"); + } + } + const selected = isNonArrayObject(config) ? Reflect.get(config, "executablePath") : undefined; + if (typeof selected !== "string" || !isAbsolute(selected)) { + throw new Error(guidance); + } + let executable; + try { + executable = await realpath(selected); + if (!(await stat(executable)).isFile()) + throw new Error("not a file"); + await access(executable, constants.X_OK); + } catch (error) { + throw new Error(`Browser executable is unavailable: ${selected}. ${guidance}`, { cause: error }); + } + if (/(?:^|[/\\])Google Chrome(?: Beta| Dev| Canary)?\.app(?:[/\\]|$)/i.test(executable)) { + throw new Error(`Installed Google Chrome cannot be used for automation: ${executable}. ${guidance}`); + } + let version; + try { + const result = await promisify(execFile)(executable, ["--version"], { + timeout: 5000, + killSignal: "SIGKILL", + maxBuffer: 4096, + encoding: "utf8", + windowsHide: true + }); + version = result.stdout.trim(); + } catch (error) { + throw new Error(`Cannot identify browser executable: ${executable}. ${guidance}`, { cause: error }); + } + if (!/^(?:Google Chrome for Testing|Chromium) \d+\.\d+\.\d+\.\d+(?:[ \t]+[^\r\n]+)?$/.test(version)) { + throw new Error(`Unsupported browser ${JSON.stringify(version)} at ${executable}. ${guidance}`); + } + console.error(`Direct browser: ${version} (${executable})`); + return executable; +} +function validateOwnedBrowserCommand(arguments_, nested = false) { + const command = arguments_[0]; + if (command === "connect" || arguments_.some((argument) => /^(?:--(?:executable-path|config|auto-connect|cdp|provider|engine)(?:=|$)|-p(?:=|$))/.test(argument))) { + throw new Error("Owned browser selection must be selected through scripts/direct/agent-browser.verify.json; attachment requires a separate explicit workflow"); + } + if (command === undefined || command.startsWith("-")) { + throw new Error("run() requires a command first; the helper owns global browser options"); + } + if (command === "batch") { + if (nested) + throw new Error("Nested browser batches are not supported; use separate run() calls"); + const commands = arguments_.slice(1).filter((argument) => argument !== "--bail"); + if (commands.length === 0) + throw new Error("batch requires plain command strings"); + for (const entry of commands) { + if (/["'\\]/.test(entry) || entry.trim().startsWith("[")) { + throw new Error("batch accepts plain command strings only; use separate run() calls for quoted, escaped, JSON, or evaluation payloads"); + } + validateOwnedBrowserCommand(entry.trim().split(/\s+/), true); + } + } +} function createAgentBrowser(options) { const binary = join(options.repositoryRoot, "node_modules/.bin/agent-browser"); const createEnvironment = () => { @@ -1355,7 +1435,11 @@ function createAgentBrowser(options) { }; let environment = createEnvironment(); let used = false; + let executable; async function run(arguments_) { + validateOwnedBrowserCommand(arguments_); + executable ??= managedAgentBrowserExecutable(join(options.repositoryRoot, "scripts/direct/agent-browser.verify.json")); + environment.AGENT_BROWSER_EXECUTABLE_PATH = await executable; used = true; const defaultTimeoutMs = options.defaultTimeoutMs ?? 35000; const commandArguments = arguments_[0] === "wait" && !arguments_.includes("--timeout") ? [...arguments_, "--timeout", String(defaultTimeoutMs)] : arguments_; diff --git a/docs/verification.md b/docs/verification.md index bdc82be..9da7ead 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -70,6 +70,36 @@ deadlines, and rotates a namespace after an unresponsive process. The product still supplies allowed-domain launch flags, commands, semantic assertions, context inventory, and the final close decision. +### Select an automation browser + +Provision Chrome for Testing with your pinned local `agent-browser install`, +or provision Chromium with your pinned Playwright installation. Set the absolute +executable path in `scripts/direct/agent-browser.verify.json`: + +```json +{ + "executablePath": "/absolute/path/to/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" +} +``` + +Generate this machine-specific path during setup alongside your existing browser +configuration. Keep the provisioned browser version in the setup record. +`createAgentBrowser` resolves symlinks, checks executable access, and reads +`--version` with a five-second deadline and a 4 KiB output limit before starting +the driver. It accepts Chrome for Testing and Chromium, reports the selected +path and version, and rejects ordinary Google Chrome, missing selections, and +command-level executable or config overrides. `run()` takes the command first; +the helper owns global options. Batches accept plain unquoted command strings +and optional `--bail`. Use separate `run()` calls for quoted or escaped arguments, +JSON input, and evaluation payloads. Nested batches and connection commands are +rejected. This check identifies the browser +distribution; it does not authenticate downloaded artifacts. + +agent-browser's automatic discovery can select installed Chrome. Direct disables +that path by requiring the explicit selection and passing its resolved path to +the driver. Shell `AGENT_BROWSER_*` settings remain isolated. Keep the task's +final `close()` and external process cleanup requirements in place. + ### Inspect bounded server output `spawnVerificationServer` continuously drains stdout and stderr. Its `output` @@ -111,7 +141,9 @@ independent process and descriptor evidence when diagnosing a timeout. ### Isolate and run the session -This command path uses an empty task-owned config, a fresh socket directory, a +Set `DIRECT_BROWSER_EXECUTABLE` to the absolute path of your provisioned Chrome +for Testing or Playwright Chromium executable before running this recipe. +This command path uses an explicit task-owned browser config, a fresh socket directory, a sanitized environment, an exact allowlist, and a one-minute idle timeout. A no-URL `open` launches Chromium on its inert internal `about:blank` tab while installing the allowlist. Do not pass `about:blank` as an explicit URL; @@ -121,6 +153,8 @@ agent-browser 0.32.3 rejects that hostname-free navigation under the allowlist. set -eu DIRECT_AGENT_BROWSER_BIN="$(command -v agent-browser)" test -x "$DIRECT_AGENT_BROWSER_BIN" +: "${DIRECT_BROWSER_EXECUTABLE:?Set the absolute provisioned automation browser path}" +test -x "$DIRECT_BROWSER_EXECUTABLE" DIRECT_BROWSER_SESSION='direct-chromium' DIRECT_BROWSER_BACKEND='local-chromium' DIRECT_BROWSER_ALLOWED_DOMAINS='127.0.0.1' @@ -129,8 +163,24 @@ DIRECT_BROWSER_IDLE_TIMEOUT_MS=60000 DIRECT_BROWSER_CONFIG_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/direct-browser-config.XXXXXX")" DIRECT_BROWSER_SOCKET_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/direct-browser-socket.XXXXXX")" DIRECT_BROWSER_CONFIG="$DIRECT_BROWSER_CONFIG_DIRECTORY/agent-browser.json" -printf '%s\n' '{}' > "$DIRECT_BROWSER_CONFIG" -test "$(tr -d '[:space:]' < "$DIRECT_BROWSER_CONFIG")" = '{}' +node --input-type=module - "$DIRECT_BROWSER_EXECUTABLE" "$DIRECT_BROWSER_CONFIG" <<'JS' +import { execFileSync } from 'node:child_process'; +import { realpathSync, writeFileSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; +if (!isAbsolute(process.argv[2])) throw new Error('Browser path must be absolute'); +const executablePath = realpathSync(process.argv[2]); +if (/Google Chrome(?: Beta| Dev| Canary)?\.app\//i.test(executablePath)) { + throw new Error('Installed Chrome is not an automation browser'); +} +const version = execFileSync(executablePath, ['--version'], { + encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 4096, +}).trim(); +if (!/^(Google Chrome for Testing|Chromium) \d+\.\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Unsupported browser: ${version}`); +} +console.error(`Browser: ${version} (${executablePath})`); +writeFileSync(process.argv[3], JSON.stringify({ executablePath })); +JS direct_agent_browser() { env -i \ diff --git a/package.json b/package.json index 7b83ffe..1eb2c6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/direct", - "version": "0.7.23", + "version": "0.7.24", "description": "Direct gives browser agents repeatable app states that open by URL, with your real interface running on fixture data.", "license": "MIT", "type": "module", diff --git a/scripts/github-release.test.ts b/scripts/github-release.test.ts index 696e5e6..feb9073 100644 --- a/scripts/github-release.test.ts +++ b/scripts/github-release.test.ts @@ -435,11 +435,11 @@ test("terminal metadata admission retains only the parser's exact named extra-fi const archivePath = join(root, filename), metadata = join(root, "npm-pack.json"), digest = join(root, "npm-package.sha256"); try { await writeFile(archivePath, archiveBytes); - const execute = async (extraPath: string, additional = false) => { + const execute = async (extraPath: string, additional = false, unpackedBytes = 700_000) => { const paths = [...requiredPaths, ...Array.from({ length: 57 }, (_, index) => `src/fixture-${index}.ts`), extraPath, ...(additional ? ["src/unreviewed.ts"] : [])]; const packing = Buffer.from(JSON.stringify([{ name: "@hraness/direct", id: "@hraness/direct@0.7.21", version: "0.7.21", filename, - size: archiveBytes.length, entryCount: paths.length, unpackedSize: paths.length * 10_000, - files: paths.map(path => ({ path, size: 10_000, mode: 0o644 })), shasum: hash(archiveBytes, "sha1"), + size: archiveBytes.length, entryCount: paths.length, unpackedSize: unpackedBytes, + files: paths.map((path, index) => ({ path, size: index === 0 ? unpackedBytes - (paths.length - 1) * 10_000 : 10_000, mode: 0o644 })), shasum: hash(archiveBytes, "sha1"), integrity: `sha512-${createHash("sha512").update(archiveBytes).digest("base64")}` }])); await writeFile(metadata, packing); await writeFile(digest, `${hash(archiveBytes)} ${filename}\n${hash(packing)} npm-pack.json\n`); @@ -449,6 +449,11 @@ test("terminal metadata admission retains only the parser's exact named extra-fi }; const valid = await execute("src/tooling/verification-output.ts"); expect({ exit: valid.exitCode, stderr: valid.stderr.toString(), stdout: valid.stdout.toString() }).toEqual({ exit: 0, stderr: "", stdout: expect.stringContaining(hash(archiveBytes)) }); + const atLimit = await execute("src/tooling/verification-output.ts", false, 1_280_000); + expect({ exit: atLimit.exitCode, stderr: atLimit.stderr.toString() }).toEqual({ exit: 0, stderr: "" }); + const overLimit = await execute("src/tooling/verification-output.ts", false, 1_280_001); + expect(overLimit.exitCode).toBe(1); + expect(overLimit.stderr.toString()).toContain("unpackedSize"); for (const [path, additional] of [["src/other.ts", false], ["src/tooling/verification-output.ts", true]] as const) { const result = await execute(path, additional); expect({ path, additional, exit: result.exitCode, stderr: result.stderr.toString(), stdout: result.stdout.toString() }).toMatchObject({ exit: 1 }); @@ -481,7 +486,8 @@ test("the actual package passes canonical and terminal publication bounds", asyn const candidate = { ...m, version: record.version, tag: `v${record.version}`, archive: { name: record.filename, bytes: bytes.length, sha256: hash(bytes), sha512: hash(bytes, "sha512") } }; expect(parseManifest(candidate)).toEqual(candidate); - expect(() => parseManifest({ ...candidate, archive: { ...candidate.archive, bytes: 270_001 } })).toThrow(); + expect(parseManifest({ ...candidate, archive: { ...candidate.archive, bytes: 280_000 } }).archive.bytes).toBe(280_000); + expect(() => parseManifest({ ...candidate, archive: { ...candidate.archive, bytes: 280_001 } })).toThrow(); const metadata = join(root, "npm-pack.json"), digest = join(root, "npm-package.sha256"); await writeFile(metadata, packed.stdout); await writeFile(digest, `${hash(bytes)} ${record.filename}\n${hash(packed.stdout)} npm-pack.json\n`); diff --git a/scripts/github-release.ts b/scripts/github-release.ts index aad49a7..1cc1125 100644 --- a/scripts/github-release.ts +++ b/scripts/github-release.ts @@ -74,7 +74,7 @@ export function parseManifest(value: unknown): ReleaseManifest { if (item.schema !== "hraness-github-release-v1" || item.repository !== repository || item.repositoryId !== repositoryId || item.package !== packageName || item.tag !== `v${version}` || item.workflow !== workflow || typeof item.sourceSha !== "string" || !sha.test(item.sourceSha) || typeof item.workflowSha !== "string" || !sha.test(item.workflowSha) - || archive.name !== `hraness-direct-${version}.tgz` || positive(archive.bytes, "Archive size") > 270_000 + || archive.name !== `hraness-direct-${version}.tgz` || positive(archive.bytes, "Archive size") > 280_000 || typeof archive.sha256 !== "string" || !digest.test(archive.sha256) || typeof archive.sha512 !== "string" || !/^[a-f0-9]{128}$/u.test(archive.sha512)) throw new Error("Release manifest identity is invalid."); positive(item.runId, "Run ID"); diff --git a/scripts/npm-publish-workflow.test.ts b/scripts/npm-publish-workflow.test.ts index edb9bfc..dc985a3 100644 --- a/scripts/npm-publish-workflow.test.ts +++ b/scripts/npm-publish-workflow.test.ts @@ -377,7 +377,7 @@ import { isUtf8ByteLengthAtMost } from "./utf8-byte-boundary.js"; readonly version?: unknown; }; expect(manifest).toEqual(expect.objectContaining({ - version: "0.7.23", + version: "0.7.24", description: "Direct gives browser agents repeatable app states that open by URL, with your real interface running on fixture data.", keywords: [ "frontend-development", @@ -569,9 +569,9 @@ import { isUtf8ByteLengthAtMost } from "./utf8-byte-boundary.js"; "const minimumFiles = 50", "const maximumFiles = 69", "const minimumPackedBytes = 140_000", - "const maximumPackedBytes = 270_000", + "const maximumPackedBytes = 280_000", "const minimumUnpackedBytes = 650_000", - "const maximumUnpackedBytes = 1_250_000", + "const maximumUnpackedBytes = 1_280_000", "record.files.length !== record.entryCount", "unpackedSize !== record.unpackedSize", 'createHash("sha1")', @@ -655,6 +655,8 @@ import { isUtf8ByteLengthAtMost } from "./utf8-byte-boundary.js"; } }); + // Eleven stubbed workflow runs include repeated CLI startup; bound the whole + // matrix separately from Bun's five-second unit-test default. test("rechecks the immutable release tag at the terminal publishing boundary", async () => { const workflow = await readFile(publishWorkflowUrl, "utf8"); const script = workflowStepScript(workflow, "Revalidate release tag and publish exact package"); @@ -827,7 +829,7 @@ process.stdout.write(args.includes('--jq') ? value.object.sha + '\\n' : JSON.str } finally { await rm(directory, { force: true, recursive: true }); } - }); + }, 30_000); test("gates immutable releases on canonical package content and supports bounded recovery", async () => { const [workflow, artifact, identity] = await Promise.all([ diff --git a/scripts/package-artifact.ts b/scripts/package-artifact.ts index bb07c77..de06af9 100644 --- a/scripts/package-artifact.ts +++ b/scripts/package-artifact.ts @@ -11,11 +11,12 @@ const packageBudget = Object.freeze({ // The reviewed library/skill tree has 67 files; the standalone helper and // its license add two. The helper stays outside every library entry graph. fileCount: { min: 50, max: 69 }, - // The reviewed helper/license add 21,748 uncompressed bytes; the complete - // candidate is 265,852 packed bytes under Bun 1.3.14. - packedBytes: { min: 140_000, max: 270_000 }, + // 0.7.24 browser admission adds 9,754 unpacked bytes to 0.7.23 (1,249,782). + // Reviewed candidate: 1,259,536 unpacked; Bun 1.3.14 packs 269,291 bytes, + // npm 11.19.0 packs 269,869. Allow bounded toolchain compression variation. + packedBytes: { min: 140_000, max: 280_000 }, // The optional driver ships its source types plus its external-peer bundle. - unpackedBytes: { min: 650_000, max: 1_250_000 }, + unpackedBytes: { min: 650_000, max: 1_280_000 }, }); const requiredPaths = Object.freeze([ diff --git a/skills/direct/references/install.md b/skills/direct/references/install.md index fef2e54..88f832a 100644 --- a/skills/direct/references/install.md +++ b/skills/direct/references/install.md @@ -18,19 +18,19 @@ global `direct` CLI. ## Add the library -For a new installation, verify that the immutable v0.7.23 GitHub release and +For a new installation, verify that the immutable v0.7.24 GitHub release and its archive are published before using this version. Source candidates do not establish public availability. Check the release manifest, checksums, and provenance using the tagged publishing guide, then install the exact archive: ```sh -bun add --dev https://github.com/hraness/direct/releases/download/v0.7.23/hraness-direct-0.7.23.tgz +bun add --dev https://github.com/hraness/direct/releases/download/v0.7.24/hraness-direct-0.7.24.tgz # or, in an npm project -npm install --save-dev https://github.com/hraness/direct/releases/download/v0.7.23/hraness-direct-0.7.23.tgz +npm install --save-dev https://github.com/hraness/direct/releases/download/v0.7.24/hraness-direct-0.7.24.tgz ``` The package keeps the name `@hraness/direct`. An independently verified npm -mirror may instead use the immutable pin `@hraness/direct@0.7.23`. Use the +mirror may instead use the immutable pin `@hraness/direct@0.7.24`. Use the consumer's existing package manager and lockfile. To upgrade, replace the exact archive version and run the consumer's type, installation, and emitted production-boundary checks; do not use a moving Latest URL. @@ -39,8 +39,8 @@ If the task starts with skill installation rather than a loaded skill, install the single repository skill with either command: ```sh -npx skills add hraness/direct#v0.7.23 -bunx skills add hraness/direct#v0.7.23 +npx skills add hraness/direct#v0.7.24 +bunx skills add hraness/direct#v0.7.24 ``` Restart or reload the agent runner if it does not discover newly installed diff --git a/skills/direct/references/verification.md b/skills/direct/references/verification.md index 8411800..023edfe 100644 --- a/skills/direct/references/verification.md +++ b/skills/direct/references/verification.md @@ -99,7 +99,9 @@ cleanup claims require an external supervisor that owns both the agent-browser daemon and Chromium roots, or one containing job. The roots can occupy different process groups, so daemon exit alone is not cleanup proof. -Create an empty task-owned config and fresh socket directory. Remove inherited +Set `DIRECT_BROWSER_EXECUTABLE` to the absolute path of provisioned Chrome for +Testing or Playwright Chromium. Create a task-owned config with that selection +and a fresh socket directory. Remove inherited agent-browser and proxy settings, set a bounded idle timeout, and use the same wrapper and session for every batch command: @@ -107,6 +109,8 @@ wrapper and session for every batch command: set -eu DIRECT_AGENT_BROWSER_BIN="$(command -v agent-browser)" test -x "$DIRECT_AGENT_BROWSER_BIN" +: "${DIRECT_BROWSER_EXECUTABLE:?Set the absolute provisioned automation browser path}" +test -x "$DIRECT_BROWSER_EXECUTABLE" DIRECT_BROWSER_SESSION='direct-chromium' DIRECT_BROWSER_BACKEND='local-chromium' DIRECT_BROWSER_ALLOWED_DOMAINS='127.0.0.1' @@ -115,8 +119,24 @@ DIRECT_BROWSER_IDLE_TIMEOUT_MS=60000 DIRECT_BROWSER_CONFIG_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/direct-browser-config.XXXXXX")" DIRECT_BROWSER_SOCKET_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/direct-browser-socket.XXXXXX")" DIRECT_BROWSER_CONFIG="$DIRECT_BROWSER_CONFIG_DIRECTORY/agent-browser.json" -printf '%s\n' '{}' > "$DIRECT_BROWSER_CONFIG" -test "$(tr -d '[:space:]' < "$DIRECT_BROWSER_CONFIG")" = '{}' +node --input-type=module - "$DIRECT_BROWSER_EXECUTABLE" "$DIRECT_BROWSER_CONFIG" <<'JS' +import { execFileSync } from 'node:child_process'; +import { realpathSync, writeFileSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; +if (!isAbsolute(process.argv[2])) throw new Error('Browser path must be absolute'); +const executablePath = realpathSync(process.argv[2]); +if (/Google Chrome(?: Beta| Dev| Canary)?\.app\//i.test(executablePath)) { + throw new Error('Installed Chrome is not an automation browser'); +} +const version = execFileSync(executablePath, ['--version'], { + encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 4096, +}).trim(); +if (!/^(Google Chrome for Testing|Chromium) \d+\.\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Unsupported browser: ${version}`); +} +console.error(`Browser: ${version} (${executablePath})`); +writeFileSync(process.argv[3], JSON.stringify({ executablePath })); +JS direct_agent_browser() { env -i \ diff --git a/src/tooling/bombadil-runner.test.ts b/src/tooling/bombadil-runner.test.ts index 4cc6a35..c918e6b 100644 --- a/src/tooling/bombadil-runner.test.ts +++ b/src/tooling/bombadil-runner.test.ts @@ -4146,7 +4146,14 @@ describe("Direct Bombadil process lifecycle", () => { wallClockTimeoutMs: 5_000, })); expect(error.name).toBe("BombadilArtifactPolicyError"); - expect(error.message).toContain("outside the artifact allowlist"); + // The live scan can refuse the unproven completion before the final scan. + expect(error.message).toMatch(/outside the artifact allowlist|lacks live partial provenance/); + const stoppedError = await rejection(inspectBombadilArtifactTreeForTest({ + hashFiles: false, + policy: { maxDepth: 4, maxEntries: 8, maxFileBytes: 1_024, maxFiles: 4, maxPathBytes: 256, maxTotalBytes: 2_048 }, + root: directory, + })); + expect(stoppedError.message).toContain("outside the artifact allowlist"); }); test("tolerates only live-scan entry disappearance and fails final proof closed", async () => { diff --git a/src/tooling/browser-verification.test.ts b/src/tooling/browser-verification.test.ts index e11f9f8..4fa268a 100644 --- a/src/tooling/browser-verification.test.ts +++ b/src/tooling/browser-verification.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -231,7 +231,10 @@ if (typeof childPidPath !== 'string' || childPidPath.length > 4096 } const child = spawn(process.execPath, ['-e', "process.on('SIGTERM', () => {}); setTimeout(() => process.exit(0), 5000); setInterval(() => {}, 1000);"], { stdio: 'ignore' }); child.unref(); -fs.writeFileSync(childPidPath, String(child.pid)); +if (!Number.isSafeInteger(child.pid) || child.pid <= 0) throw new Error('Invalid descendant fixture PID'); +// Publish complete PID contents before the parent observes readiness. +fs.writeFileSync(childPidPath + '.pending', String(child.pid), { flag: 'wx' }); +fs.renameSync(childPidPath + '.pending', childPidPath); `; describe("verification output diagnostics", () => { @@ -583,6 +586,83 @@ describe("agent-browser envelopes", () => { .toBe("open https://example.com"); }); + async function configureBrowser(repositoryRoot: string, version = "Google Chrome for Testing 152.0.7977.83"): Promise { + const executable = join(repositoryRoot, "managed-browser"); + await writeFile(executable, `#!/bin/sh\nprintf '%s\\n' '${version}'\n`); + await chmod(executable, 0o755); + const directory = join(repositoryRoot, "scripts/direct"); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "agent-browser.verify.json"), JSON.stringify({ executablePath: executable })); + return executable; + } + + test("requires an explicit available automation browser before spawning agent-browser", async () => { + const repositoryRoot = await temporaryDirectory(); + const run = () => createAgentBrowser({ repositoryRoot, sessionPrefix: "test" }).run(["open", "about:blank"]); + await expect(run()).rejects.toThrow("Cannot read browser configuration"); + const configDirectory = join(repositoryRoot, "scripts/direct"); + await mkdir(configDirectory, { recursive: true }); + const configPath = join(configDirectory, "agent-browser.verify.json"); + for (const config of [{}, { executablePath: "relative/chrome" }, { executablePath: "/missing/chrome" }]) { + await writeFile(configPath, JSON.stringify(config)); + await expect(run()).rejects.toThrow("provisioned Chrome for Testing"); + } + await configureBrowser(repositoryRoot, "Google Chrome 152.0.7977.83"); + await expect(run()).rejects.toThrow('Unsupported browser "Google Chrome 152.0.7977.83"'); + }); + + test("rejects configuration that bypasses the owned browser", async () => { + const repositoryRoot = await temporaryDirectory(); + const executablePath = await configureBrowser(repositoryRoot); + for (const selection of [{ autoConnect: true }, { cdp: "9222" }, { provider: "browserbase" }, { engine: "lightpanda" }]) { + await writeFile(join(repositoryRoot, "scripts/direct/agent-browser.verify.json"), JSON.stringify({ executablePath, ...selection })); + const browser = createAgentBrowser({ repositoryRoot, sessionPrefix: "test" }); + await expect(browser.run(["open", "about:blank"])).rejects.toThrow("Owned browser configuration"); + } + }); + + test("rejects installed Chrome reached through a symlink without executing it", async () => { + const repositoryRoot = await temporaryDirectory(); + const configDirectory = join(repositoryRoot, "scripts/direct"); + await mkdir(configDirectory, { recursive: true }); + for (const channel of ["", " Beta", " Dev", " Canary"]) { + const directory = join(repositoryRoot, `Google Chrome${channel}.app/Contents/MacOS`); + await mkdir(directory, { recursive: true }); + const executable = join(directory, "Google Chrome"); + await writeFile(executable, "#!/bin/sh\nexit 99\n"); + await chmod(executable, 0o755); + const alias = join(repositoryRoot, `alias${channel}`); + await symlink(executable, alias); + await writeFile(join(configDirectory, "agent-browser.verify.json"), JSON.stringify({ executablePath: alias })); + const browser = createAgentBrowser({ repositoryRoot, sessionPrefix: "test" }); + await expect(browser.run(["open", "about:blank"])).rejects.toThrow("Installed Google Chrome cannot be used"); + await browser.close(); + } + }); + + test("passes the verified executable to the isolated driver and prevents command overrides", async () => { + const repositoryRoot = await temporaryDirectory(); + const binaryDirectory = join(repositoryRoot, "node_modules/.bin"); + await mkdir(binaryDirectory, { recursive: true }); + await writeFile(join(binaryDirectory, "agent-browser"), `console.log(JSON.stringify(process.argv[3] === "batch" ? [{ command: ["mouse"], success: true, error: null, result: "accepted" }] : { success: true, error: null, data: process.env.AGENT_BROWSER_EXECUTABLE_PATH }));`); + for (const version of ["Google Chrome for Testing 152.0.7977.83", "Chromium 152.0.7977.83"]) { + const executable = await configureBrowser(repositoryRoot, version); + const browser = createAgentBrowser({ repositoryRoot, sessionPrefix: "test" }); + expect(await browser.run(["open", "about:blank"])).toBe(await realpath(executable)); + await expect(browser.run(["--executable-path=/Applications/Google Chrome.app", "open"])).rejects.toThrow("must be selected through"); + await expect(browser.run(["--config", "/tmp/other.json", "open"])).rejects.toThrow("must be selected through"); + for (const command of [["--auto-connect", "open"], ["--cdp=9222", "open"], ["-p", "browserbase", "open"], ["--engine", "lightpanda", "open"], ["connect", "9222"]]) { + await expect(browser.run(command)).rejects.toThrow("attachment requires"); + } + await expect(browser.run(["--json", "connect", "9222"])).rejects.toThrow("command first"); + await expect(browser.run(["batch", "connect 9222"])).rejects.toThrow("attachment requires"); + await expect(browser.run(["batch", "batch connect 9222"])).rejects.toThrow("Nested browser batches"); + await expect(browser.run(["batch", 'eval "1 + 1"'])).rejects.toThrow("plain command strings"); + expect(await browser.run(["batch", "--bail", "mouse move 1 2", "wait 200"])).toEqual(["accepted"]); + await browser.close(); + } + }); + test("bounds close separately and rotates after an unresponsive namespace", async () => { expect(agentBrowserCloseProcessTimeoutMs).toBe(10_000); expect(agentBrowserProcessTimeoutMs(["eval", "1"], 60_000)).toBe(65_000); @@ -608,6 +688,7 @@ describe("agent-browser envelopes", () => { repositoryRoot, sessionPrefix: "test", }); + await configureBrowser(repositoryRoot); const firstNamespace = await browser.evaluate("1"); await browser.restart(); const secondNamespace = await browser.evaluate("1"); diff --git a/src/tooling/browser-verification.ts b/src/tooling/browser-verification.ts index 53664aa..e7adc7d 100644 --- a/src/tooling/browser-verification.ts +++ b/src/tooling/browser-verification.ts @@ -1,6 +1,9 @@ +import { execFile } from "node:child_process"; +import { constants } from "node:fs"; +import { promisify } from "node:util"; import { randomUUID } from "node:crypto"; -import { mkdir, rename, rm, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { access, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join } from "node:path"; import { captureVerificationOutput, @@ -576,6 +579,83 @@ export function parseAgentBrowserBatchEnvelope(source: string): readonly unknown }); } +async function managedAgentBrowserExecutable(configPath: string): Promise { + const guidance = "Set executablePath in scripts/direct/agent-browser.verify.json to a provisioned Chrome for Testing or Playwright Chromium executable; run agent-browser install or playwright install chromium first. Installed auto-updating Chrome and automatic discovery are not supported."; + let config: unknown; + try { + config = JSON.parse(await readFile(configPath, "utf8")) as unknown; + } catch (error) { + throw new Error(`Cannot read browser configuration. ${guidance}`, { cause: error }); + } + if (isNonArrayObject(config)) { + for (const key of ["autoConnect", "cdp", "provider"]) { + const value: unknown = Reflect.get(config, key); + if (value !== undefined && value !== false && value !== null && value !== "") { + throw new Error(`Owned browser configuration cannot select ${key}; browser attachment requires a separate explicit workflow.`); + } + } + const engine: unknown = Reflect.get(config, "engine"); + if (engine !== undefined && engine !== "chrome") { + throw new Error("Owned browser configuration requires the chrome engine"); + } + } + const selected = isNonArrayObject(config) ? Reflect.get(config, "executablePath") : undefined; + if (typeof selected !== "string" || !isAbsolute(selected)) { + throw new Error(guidance); + } + let executable: string; + try { + executable = await realpath(selected); + if (!(await stat(executable)).isFile()) throw new Error("not a file"); + await access(executable, constants.X_OK); + } catch (error) { + throw new Error(`Browser executable is unavailable: ${selected}. ${guidance}`, { cause: error }); + } + if (/(?:^|[/\\])Google Chrome(?: Beta| Dev| Canary)?\.app(?:[/\\]|$)/i.test(executable)) { + throw new Error(`Installed Google Chrome cannot be used for automation: ${executable}. ${guidance}`); + } + let version: string; + try { + const result = await promisify(execFile)(executable, ["--version"], { + timeout: 5_000, + killSignal: "SIGKILL", + maxBuffer: 4_096, + encoding: "utf8", + windowsHide: true, + }); + version = result.stdout.trim(); + } catch (error) { + throw new Error(`Cannot identify browser executable: ${executable}. ${guidance}`, { cause: error }); + } + // This identifies the automation distribution, not artifact provenance. + if (!/^(?:Google Chrome for Testing|Chromium) \d+\.\d+\.\d+\.\d+(?:[ \t]+[^\r\n]+)?$/.test(version)) { + throw new Error(`Unsupported browser ${JSON.stringify(version)} at ${executable}. ${guidance}`); + } + console.error(`Direct browser: ${version} (${executable})`); + return executable; +} + +function validateOwnedBrowserCommand(arguments_: readonly string[], nested = false): void { + const command = arguments_[0]; + if (command === "connect" || arguments_.some((argument) => /^(?:--(?:executable-path|config|auto-connect|cdp|provider|engine)(?:=|$)|-p(?:=|$))/.test(argument))) { + throw new Error("Owned browser selection must be selected through scripts/direct/agent-browser.verify.json; attachment requires a separate explicit workflow"); + } + if (command === undefined || command.startsWith("-")) { + throw new Error("run() requires a command first; the helper owns global browser options"); + } + if (command === "batch") { + if (nested) throw new Error("Nested browser batches are not supported; use separate run() calls"); + const commands = arguments_.slice(1).filter((argument) => argument !== "--bail"); + if (commands.length === 0) throw new Error("batch requires plain command strings"); + for (const entry of commands) { + if (/["'\\]/.test(entry) || entry.trim().startsWith("[")) { + throw new Error("batch accepts plain command strings only; use separate run() calls for quoted, escaped, JSON, or evaluation payloads"); + } + validateOwnedBrowserCommand(entry.trim().split(/\s+/), true); + } + } +} + export function createAgentBrowser(options: { readonly repositoryRoot: string; readonly sessionPrefix: string; @@ -605,8 +685,12 @@ export function createAgentBrowser(options: { }; let environment = createEnvironment(); let used = false; + let executable: Promise | undefined; async function run(arguments_: readonly string[]): Promise { + validateOwnedBrowserCommand(arguments_); + executable ??= managedAgentBrowserExecutable(join(options.repositoryRoot, "scripts/direct/agent-browser.verify.json")); + environment.AGENT_BROWSER_EXECUTABLE_PATH = await executable; used = true; const defaultTimeoutMs = options.defaultTimeoutMs ?? 35_000; const commandArguments = arguments_[0] === "wait" && !arguments_.includes("--timeout")