Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 22 additions & 22 deletions dist/tooling/bombadil.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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)}`);
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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("//")) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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) {
Expand Down Expand Up @@ -4931,15 +4931,15 @@ 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`);
}
if (!metadata.isFile())
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;
Expand Down Expand Up @@ -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`);
}
Expand All @@ -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`);
}
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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"
};
Expand Down Expand Up @@ -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"
};
Expand Down
88 changes: 86 additions & 2 deletions dist/tooling/browser-verification-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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_;
Expand Down
Loading
Loading