comber
v0.1.10
Published
Comber — every-click AI QA agent. Walks every inch of an app and surfaces what washed up.
Downloads
1,676
Maintainers
Readme
Comber
Every-click AI QA agent. Point it at a URL and it walks the app like a real user, clicks what's safe, watches for breakage, and writes a self-contained HTML report — leaving cheap deterministic replays behind so the expensive every-click run happens once.
Comber by RADLAB — product site codecomber.io. CLI/package: comber.
What it does today. Runnable DOM-first web crawler with breakage detection, optional Claude action-picker + judge, and an HTML report. Four drivers now sit behind the universal contract — web (Playwright), api (OpenAPI), native (iOS/Android over a pluggable Maestro runner), and desktop (Windows UI Automation or macOS Accessibility). The rest (Stagehand selector-cache replay polish, GitHub Action on Vercel preview deploys) is staged — see Roadmap below.
Why DOM-first
The accessibility/DOM snapshot is ~200–400 tokens per step with stable element refs; a screenshot is ~1,000–1,800 image tokens per step. So Comber drives the browser off the DOM (Playwright today, Stagehand selector-cache next) and reserves Claude vision/computer-use for the few surfaces the DOM can't describe (canvas, file-preview, drag-drop, visual diff). That keeps a full crawl in the cents, deterministic, and CI-safe.
Install
Comber ships as the comber package (CLI + reusable GitHub Action). The published
tarball carries a prebuilt dist/ plus action.yml, so no build step is needed on a normal
npm/bun install.
# Pinned one-off quickstart (install the matching Chromium once, then crawl):
npx -y [email protected] install-browsers
npx -y [email protected] check https://example.com
# Optional project/global installs:
bun add --exact [email protected]
npm i -g [email protected]
# Then run the installed CLI:
comber check https://example.com
comber check https://example.com --max-states=30 --llmPublic docs, the reusable Action, and the connector contract + catalog live at
gitlab.com/getcomber/comber-public. Comber
itself is proprietary and its source is not public, so npm is the install path — the tarball
carries a prebuilt dist/, and pinning an exact version with --exact is the way to hold a
build steady.
Run from source
cd ~/repos/comber
bun install
bunx playwright install chromium
# Heuristic crawl (no API key needed):
bun run dev https://example.com
# LLM-judged (Opus judges findings; heuristic action ordering):
cp .env.example .env && echo "ANTHROPIC_API_KEY=sk-ant-..." >> .env
bun run dev https://example.com --max-states=30 --llm
# Authenticated crawl against a SYNTHETIC account:
# COMBER_STORAGE_STATE=./auth.json bun run dev https://example.comGenerating a synthetic session
An authenticated crawl needs a Playwright storageState (cookies + localStorage)
for a synthetic / throwaway account — never a real user's. Point the CLI at one via
COMBER_STORAGE_STATE:
COMBER_STORAGE_STATE=auth.json comber https://example.comAny Playwright storageState JSON works (e.g. one produced by your own Playwright
context.storageState({ path })). This repo also ships two capture helpers — run them
from a checkout (git clone + bun install), as they live under scripts/ and aren't
part of the published npm package:
# Interactive: opens a browser, you log in, press ENTER to capture.
bun run save-session https://example.com/login auth.json
# Headless (CI-friendly): credentials from the environment, never the CLI.
[email protected] SYNTH_PW=… bun run login https://example.com/login auth.json
# …or read the password from a file instead of $SYNTH_PW:
[email protected] bun run login https://example.com/login auth.json ./pw.txtThe saved file holds live session tokens — .gitignore already covers
auth*.json / *-auth.json / auth/. Keep it there.
Flags: --max-states=N --max-actions=N --max-ms=N --max-tokens=N --headed
--llm --cross-origin --external-links --allow-writes --baseline=PATH
--pr-comment=PATH --config=PATH --profile=NAME --strict (exit 1 on any
fail-severity finding; default is soft).
API target (comber api …): --openapi=PATH|URL (JSON or YAML) --api-base=URL
--graphql=URL --har=PATH --seed=PATH --allow-writes. Credentials are env-only:
COMBER_API_TOKEN (single Bearer) and COMBER_API_AUTH_PROFILES (a JSON {role:token}
map for the multi-role IDOR/broken-auth harness). See API driver below.
Profiles, baselines, and PR comments
Profiles keep per-app crawl settings out of CI YAML:
cp comber.config.example.json comber.config.json
bun run dev --profile local --config comber.config.jsoncomber.config.json can be either one direct config object or a named profiles object
with defaultProfile. Supported aliases include url/startUrl, maxMs/maxWallClockMs,
maxTokens/maxTokenBudget, maxPerState/maxActionsPerState, maxRegotos/
maxRegotosPerState, nearDup/nearDupThreshold, storageState/storageStatePath,
baseline/baselinePath, and prComment/prCommentPath.
Baseline diffing compares current findings by stable signature against a prior
result.json:
bun run dev https://preview.example.com --baseline baselines/preview-result.jsonWhen no baseline exists, Comber treats all current findings as new and keeps running. To produce a Markdown body for a non-blocking pull-request comment:
bun run dev https://preview.example.com --baseline baselines/preview-result.json --pr-comment runs/pr-comment.mdInformational findings
Comber reports fail, warn, and info severities. Strict gates and baseline headline
counts use only fail and warn; info findings are visible context that should not block
a run.
The web crawler normalizes crawler-caused noise before dedupe:
- Comber-blocked egress fan-out (
egress-blockedplus matching request/console/fetch errors) becomes onecrawler-suppressedinfo finding. - Known telemetry infrastructure such as Cloudflare Insights, Statsig, Sentry ingest, and
Microsoft browser events becomes
third-party-telemetryinfo. - Turnstile, hCaptcha, and Cloudflare challenge endpoints or widget errors become
bot-challengeinfo findings. .icoimages are not markedbroken-imagesolely because browser decode reportsnaturalWidth === 0.
The example workflow .github/workflows/comber.yml runs Comber from source. It uploads runs/
as an artifact and can optionally post runs/pr-comment.md to a PR when pr_number is supplied.
For dropping Comber into another repo, use the reusable Action below.
.github/workflows/holds consumer examples only — copy them into your repo. Comber's own CI runs on Forgejo, mirrored to GitLab; nothing under.github/gates this repository.
CI integration (reusable Action)
action.yml at the repo root is a composite GitHub Action so any repo can run Comber on
a preview deploy. It sets up bun → installs comber → runs it → uploads the runs/ report
artifact → posts a non-blocking PR comment → and gates the job only when strict and
fail-severity findings are present.
Action status: the action is complete and
action.ymlships in the npm tarball today, but the hosteduses: getcomber/comber@v1listing is not currently available — treat the yaml below as the Action's shape, not a working reference.Nothing is blocked by that. The CLI is the supported path and runs in every pipeline, GitHub included: install the matching browser with
npx -y [email protected] install-browsers, then runnpx -y [email protected] check <preview-url> --strict. Consumers who want the composite-action ergonomics can vendoraction.ymlfrom the tarball and reference it locally withuses: ./.
# .github/workflows/comber-pr.yml in the consumer repo
name: comber
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
comber:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: getcomber/comber@v1 # NOT yet resolvable — see Action status above
with:
url: https://your-preview-deploy.example.com
max-states: "20"
strict: "false" # non-blocking PR comment first; flip to "true" to fail on defects
pr-comment: "true"
env: {}
# secrets:
# anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} # only when llm: "true"For an API target, swap url for openapi + api-base:
- uses: getcomber/comber@v1 # NOT yet resolvable — see Action status above
with:
openapi: ./openapi.json # file path or http(s) URL
api-base: https://api-preview.example.com
strict: "true"Inputs
| Input | Default | Purpose |
|---|---|---|
| url | "" | Web target URL to crawl (use this or openapi). |
| openapi | "" | OpenAPI 3.x source (path/URL); selects the API driver. |
| api-base | "" | API base URL (required with openapi). |
| seed | "" | API seed-data JSON path (per-operation overrides). |
| max-states | "" | Max states to exercise (blank = Comber default). |
| llm | "false" | Use the Claude action-picker + judge (needs anthropic-api-key). |
| external-links | "false" | Check external links (egress to private/metadata hosts stays blocked). |
| allow-writes | "false" | Allow mutating HTTP methods — synthetic environments only. |
| strict | "false" | Exit non-zero (fail the job) when fail-severity findings exist. |
| pr-comment | "true" | Post a non-blocking comment on the PR. |
| baseline | "" | Prior result.json for baseline diffing. |
| config / profile | "" | comber.config.json path / profile name. |
| install-spec | "comber" | Spec passed to bun add (see the publish gate). |
| comber-version | "" | Version pin when install-spec is the default (→ comber@<version>). |
| bun-version | "latest" | Bun version for oven-sh/setup-bun. |
| anthropic-api-key | "" | ANTHROPIC_API_KEY pass-through (LLM mode). |
| storage-state | "" | Absolute path to a synthetic Playwright storageState (COMBER_STORAGE_STATE). |
| api-token | "" | Bearer for the API target (COMBER_API_TOKEN). |
| sink-file / sink-dir / sink-url / sink-token | "" | COMBER_SINK_* persistence pass-through. |
| github-token | ${{ github.token }} | Token for gh pr comment (needs pull-requests: write). |
| pr-number | "" | PR to comment on (defaults to the triggering pull_request). |
The job's only output, exit-code, is Comber's process code (0 clean/soft · 1 strict + fails · 2 crash).
Script-injection safety
Every consumer-supplied value (url, openapi, api-base, …) is forwarded to the run step
through env: and read inside a quoted bash array as "$VAR" — it is never
interpolated into a run: script via ${{ … }}. That is the GitHub Actions untrusted-input
rule: a url like "; rm -rf / # cannot break out of an env var into the shell. The action
follows the same pattern as .github/workflows/comber.yml, and pins every third-party action
to a commit SHA.
Synthetic session + secrets
An authenticated crawl needs a Playwright storageState for a synthetic / throwaway
account (never a real user). Generate it once (see Generating a synthetic session above),
store it as a CI secret, write it to a file in a prior step, and pass its absolute path:
- name: Materialize synthetic session
run: printf '%s' "$STATE" > "$GITHUB_WORKSPACE/auth.json"
env: { STATE: ${{ secrets.COMBER_STORAGE_STATE }} }
- uses: getcomber/comber@v1 # NOT yet resolvable — see Action status above
with:
url: https://your-preview-deploy.example.com
storage-state: ${{ github.workspace }}/auth.json
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
llm: "true"Secrets are pass-through only — they reach Comber as env (ANTHROPIC_API_KEY,
COMBER_STORAGE_STATE, COMBER_API_TOKEN, COMBER_SINK_*) and never appear in a run:
script, the report, the PR comment, or logs. Every text artifact (report, result.json,
plan.json, desktop automation trees, PR comments, sink records, and logs) is redacted before
it leaves the process. Per-state screenshots (state-*.png) are raw pixels of the target
and can carry on-screen secrets or PII that text redaction cannot reach, so screenshot capture
is off by default. Enable it only with --screenshots or screenshots: true in a profile, and
keep the resulting run directory access-restricted with the shortest practical retention.
See docs/DATA_HANDLING.md.
Releases
comber is live on npm — bun add comber / npm i -g comber resolves the published
package (prebuilt dist/ + action.yml in the tarball). The Action defaults to
install-spec: comber; pin a version with comber-version: <ver> (→ bun add comber@<ver>),
or point install-spec at a git ref to run an unreleased branch — the prepare script
builds dist/ on install either way.
New releases are prepared from the authoritative Forgejo repository and may be initiated
only by the human listed in CODEOWNERS. Releasing is split across two planes,
deliberately:
- Forgejo gates. A manual, fail-closed workflow verifies the exact tag, commit, changelog, package artifact, clean install and secret scans, packs the exact bytes and scans them, writes a machine-readable release record, and refuses a version the registry already serves. It holds no publish credential and does not publish.
- The GitLab mirror publishes. On a semver tag only, via npm Trusted Publishing — a short-lived OIDC token rather than a stored secret.
So there is no long-lived npm credential anywhere in the release path.
Comber does not carry an npm provenance attestation, and cannot: npm generates one only
when the publishing project is public and matches package.json's repository, and Comber is
proprietary — its source is private. Trusted Publishing and provenance are easy to conflate, so
to be exact: the first is how the publish authenticates, the second is a signed statement binding
an artifact to a public source commit. Comber has the first and not the second.
What you can verify:
npm view comber@<ver> version dist.integrity # matches the tarball you installed
npm audit signatures # npm's registry signature over that tarballThe full procedure is docs/releases/PROCESS.md; per-version records
live beside it. AI agents do not tag, dispatch, publish, deploy, approve, or merge releases.
API driver — OpenAPI / GraphQL / HAR + multi-role
The API target discovers an API's operation set, exercises what's safe, and classifies breakage
into its own signal set — the same discover → exercise → classify → dedupe → report contract as
web/native. It read-only by default (only GET/HEAD/OPTIONS for REST, queries for GraphQL, the
recorded reads for HAR); writes/mutations are surfaced as a counted coverage skip and never fired
unless --allow-writes. Four spec sources feed the same pipeline:
comber api --openapi ./openapi.json --api-base https://api-preview.example.com # OpenAPI (JSON)
comber api --openapi ./openapi.yaml --api-base https://api-preview.example.com # OpenAPI (YAML; sniffed)
comber api --graphql https://api-preview.example.com/graphql # GraphQL introspection
comber api --har ./recorded-traffic.har # recorded HAR capture| Source | Discover | Exercise | Breakage signal(s) |
|---|---|---|---|
| OpenAPI (JSON/YAML) | parse paths → operations ($refs resolved) | build a concrete request per operation | http-5xx, undocumented-status, content-type-mismatch, response-schema-violation (full ajv JSON-Schema validation, carrying the offending paths), response-shape-mismatch (light fallback), broken-auth, broken-object-authorization |
| GraphQL | send the standard introspection query → query/mutation fields | POST a minimal valid operation document | graphql-error (non-empty errors[]), graphql-http-status (non-200), request-failed |
| HAR | recorded request entries, deduped by route template | re-send and diff against the recorded response | contract-drift (status or body-shape divergence), request-failed |
Response-schema validation (ajv). OpenAPI 2xx bodies are validated against the operation's
declared response schema with ajv + ajv-formats ($refs resolved, cycles
broken, OpenAPI nullable honored). A violation emits response-schema-violation carrying only
the ajv error paths (/items/0/id must be integer) — never the body's values, so it stays
redaction-safe. When no compilable schema is declared, the light required-property check
(response-shape-mismatch) is the fallback.
Multi-role auth → broken-auth + IDOR. Supply named credential sets (role → token) via config
or $COMBER_API_AUTH_PROFILES (a JSON { "role": "token" } map — never a CLI flag, env wins
over config). With ≥ 2 roles plus seed data tying a resource to its owning role, operations marked
ownerScoped get a cross-role object-access check: Comber confirms role B can read B's resource,
then re-issues the same request with role A's token — a 2xx for A is broken-object-authorization
(a classic IDOR). The no-credential broken-auth check (auth-required op returns 2xx without a
credential) is unchanged. Opt-in + sandbox-only.
// comber.config.json (api profile)
{
"target": "api",
"openapi": "./openapi.json",
"apiBase": "https://api-sandbox.example.com",
"apiAuthProfiles": { "alice": "", "bob": "" }, // tokens via $COMBER_API_AUTH_PROFILES
"ownerScoped": ["GET /users/{id}"],
"roleSeed": "./role-seed.json" // { "alice": { "GET /users/{id}": { "path": { "id": 1 } } }, "bob": { … } }
}Deferred (honest): the IDOR harness checks operator-declared ownerScoped operations against
operator-supplied per-role seed data. Full automatic resource-ownership discovery — creating a
resource under role B at runtime and tracking its id across operations to test every read path — is
deferred; today the operator declares which operations are owner-scoped and which resource each role
owns. GraphQL argument synthesis covers scalars/enums/required-input-objects with minimal literals;
exotic custom scalars fall back to a string sample.
Native (mobile) driver — Maestro
Comber's third target is a native iOS/Android app, driven over Maestro.
It proves the Driver abstraction holds for a non-page, non-API surface: the core's
frontier / dedupe / caps / report / baseline / redact / triage / coverage run unchanged against
the screens, controls, and breakage signals the native driver emits.
# Android app already installed on a booted emulator (default platform = android):
comber native --app com.example.app
# iOS bundle on a booted simulator, a specific device, and declared deeplinks:
comber native --app com.example.app --platform ios --device "iPhone 15" \
--deeplink "myapp://settings" --deeplink "myapp://profile"Flags: --app <appId|apk|path> (required) · --platform android|ios (default android) ·
--device <id> · --deeplink <uri> (repeatable). The same --profile / --config / --strict
machinery applies; a profile can set target: "native", app, platform, device, deeplinks.
Evidence flags for native/OEM runs: --provider <name>, --device-class phone|tablet|foldable|emulator|simulator|unknown,
--device-model <model>, --os-version <version>, --android-version <version>,
--app-version <version>, --samsung, --one-ui <version>, and
--build-fingerprint <fingerprint>. Evidence artifact flags are repeatable:
--evidence-screenshot <path>, --evidence-video <path>, --evidence-log <path>,
and --evidence-crash-log <path>. Provider audit fields include --provider-run-id <id>
and --verification-command <command>. These populate result.json.extra.deviceEvidence.
Comber validates native evidence at runtime. Blank artifact paths, unknown artifact kinds,
provider run ids without providers, and incomplete verified proxy evidence are surfaced in
result.json.extra.deviceEvidence.validationErrors and block exhaustive claims through the typed
native-evidence-invalid blind spot. Every native run also includes
result.json.extra.nativeReadiness, a provider-neutral preflight summary for device id, provider,
verification command, evidence artifacts, and Samsung/OEM claim readiness.
Native network egress remains a blocker unless the run declares complete verified proxy evidence:
--egress-proxy-verified, --egress-proxy-provider <name>, --egress-proxy-command <command>,
and --egress-proxy-artifact <path>. All three support fields are required; an incomplete proxy
claim is still reported as native-egress-unenforced.
Mac/iOS runs can produce that evidence through the bundled provider-neutral proof helper. It does
not install or configure a proxy service; it runs an operator-supplied local proof command with
execFile argv semantics, requires expected output markers, writes a JSON proof artifact, and
prints the --egress-proxy-proof <path> flag for the native run:
bunx tsx scripts/native-ios-proxy-verify.ts \
--device CAE50036-EF58-4880-B368-67CBD2EF0728 \
--provider local-mac-proxy \
--proxy-command "mitmproxy/Proxyman profile active for simulator CAE50036-EF58-4880-B368-67CBD2EF0728" \
--proof-command-json '["xcrun","simctl","spawn","CAE50036-EF58-4880-B368-67CBD2EF0728","log","show","--last","2m"]' \
--expect-output comber-proxy-marker \
--artifact runs/native/ios-proxy-proof.json
comber native --app com.example.ios --platform ios \
--device CAE50036-EF58-4880-B368-67CBD2EF0728 \
--egress-proxy-proof runs/native/ios-proxy-proof.jsonThe proof command must be the real local check that demonstrates the app/device traffic is flowing
through the device-level proxy. A host-only curl --proxy ... can show that a proxy listener works,
but by itself it is not proof that an iOS simulator or device is routed through that proxy. If the
proof command fails, the expected marker is absent, the proof file is missing, or the proof file is
malformed, Comber keeps egressProxy.verified: false and continues to report
native-egress-unenforced.
Desktop drivers — Windows UIA and macOS Accessibility
Comber's desktop target ships adapter-owned native desktop runners behind the same Driver
contract as web, API, and mobile. The Windows path uses UI Automation and replays discovered
branch paths through uiapath: addresses. The macOS path uses Accessibility and replays discovered
branch paths through axpath: addresses. Neither path changes core traversal semantics.
# Windows: launch an app by executable path:
comber desktop --platform windows --app-path "C:\Apps\Example\Example.exe"
# Windows: attach to an already-running process:
comber desktop --platform windows --process-name Example.exe
# Prefer PID/window-title disambiguation before live action runs:
comber desktop --platform windows --process-id 4242 --window-title "Example"
# Optional local smoke, outside default CI, that performs real safe fixture actions:
bun run smoke:windows-uia -- --fixture --max-states 4 --max-actions 12
# Generic dry-run smoke against an app you provide:
bun run smoke:windows-uia -- --app-path "C:\Windows\System32\notepad.exe" --max-states 1 --dry-run
# macOS: launch by .app path or bundle id:
comber desktop --platform macos --app-path /Applications/Example.app --process-name Example \
--automation-backend accessibility \
--provider local-mac \
--verification-command "osascript -l JavaScript runtime/macos-accessibility-runner.js snapshot" \
--accessibility-permission-verified
comber desktop --platform macos --bundle-id com.example.Example --process-name Example \
--automation-backend accessibility
# macOS controlled dry-run smoke. Without --app-path it creates a temporary fixture app.
bun run smoke:macos-accessibility
# macOS desktop proxy proof from already-configured system proxy settings.
# This inspects networksetup state and does not modify macOS proxy configuration.
bun run verify:desktop-macos-proxy -- \
--app-identity Example \
--artifact runs/desktop/macos-proxy-proof.json
comber desktop --platform macos --app-path /Applications/Example.app --process-name Example \
--egress-proxy-proof runs/desktop/macos-proxy-proof.jsonWindows controls are identified by AutomationId first, then Name plus tree path, then RuntimeId/path.
Buttons, menu items, hyperlinks, tabs, list/data rows, and tree items are treated as branch-capable
controls; edits, toggles, radio buttons, combo boxes, and sliders are exercised in place with
synthetic input only. Custom, owner-drawn, canvas, DirectX, or otherwise opaque UIA subtrees are
reported as desktop-opaque-subtree blind spots.
macOS controls are identified by Accessibility identifier first, then title plus tree path, then
role/path. AX buttons, links, tabs, rows, menu items, and tree items are branch-capable; text fields,
text areas, toggles, radio buttons, pop-up buttons, and sliders are exercised in place with synthetic
input only. Custom, canvas, Metal, WebView, and otherwise opaque Accessibility regions are reported
as desktop-opaque-subtree blind spots. If the terminal or Codex host process lacks macOS
Accessibility permission, the runner fails closed and Comber reports desktop-accessibility-unavailable.
Desktop evidence lands in result.json.extra.desktopEvidence; readiness lands in
result.json.extra.desktopReadiness. Windows readiness requires app identity, the uia backend,
provider/source metadata for complete proof, launch command, verification command, and UIA
automation-tree evidence. macOS readiness requires app identity, the accessibility backend,
provider/source metadata, launch command, verification command, verified Accessibility permission,
and an accessibility-tree artifact. Desktop network egress is still reported as
desktop-egress-unenforced unless verified OS-level proxy evidence includes provider, command,
and artifact path. bun run verify:desktop-macos-proxy can create that proof only for an already
configured macOS system proxy; if no active proxy is found, it writes an unverified artifact and the
desktop egress blocker remains. The macOS XCTest backend is reserved in the evidence contract but is
not a live default runner yet.
Process-name-only attachment is allowed for inspection, but it can be ambiguous when multiple
windows share the same executable. For non-dry-run desktop checks, prefer --process-id and
--window-title; the Windows helper refuses missing or ambiguous declared targets instead of
falling back to an arbitrary desktop window.
Exhaustive action-tree traversal
Comber treats every discovered surface as an action tree:
exercise: safe in-place controls are clicked, tapped, typed into, or nudged.follow: known destinations such as safe web links are enqueued without clicking through.branch: route-less controls such as tabs, accordions, dialogs, menus, SPA buttons, Android/iOS tabs, native links, list cells, Windows UIA branch controls, and macOS AX branch controls are activated; any child state they reveal is added to the frontier.
A clean report is only an exhaustive claim when the frontier drains without caps and without unreported blind spots. Capped runs, native/desktop egress limitations, desktop opaque UIA subtrees, closed shadow roots, cross-origin iframes, unavailable devices, and skipped destructive controls are reported as gaps.
Samsung support is Android-plus-OEM validation. Future Samsung profiles must record the exact Galaxy model, Android version, One UI version, provider or device source, command, screenshots/video, logcat crash buffer, and unavailable device classes. A generic Android emulator is not Samsung coverage.
Every run also writes an exhaustivenessLedger into result.json. It records each discovered
unit as exercised, followed, branched, skipped, blocked, or blind-spot, with the reason
and child-state count when applicable. This is the machine-readable audit trail behind the
headline coverage percentage.
For machines with a working Playwright browser install, bun run smoke:web-branch runs a local
route-less web branch fixture and verifies from the exhaustiveness ledger that Comber creates a
replayable webaction: address and exercises the child control it reveals.
How a phone maps onto the universal contract
| Contract | Native meaning |
|---|---|
| Surface | one screen (the current accessibility/view hierarchy) |
| SurfaceNode | one control - kind = control type (button/text-input/toggle/tab/link/cell), id = the stable test id (Android resource-id / iOS accessibilityIdentifier / testTag, else text+path), label = visible text, handle = the raw native node, disabled, intent (exercise/follow/branch) |
| Address | screen identity — id is the goto handle (launch · deeplink:<uri> · tappath:<base>::<tap>…), scope = screen name/route, dedupeKey = destination-screen fingerprint for a tap-path. Frontier de-dups by the goto handle; structural "seen this screen?" is the core's stateFingerprint — two distinct layers |
| entryAddress() | the launch screen (relaunch, no deeplink) |
| goto(addr) | relaunch + optional deeplink, or relaunch-base + replay-taps (tap-path) |
| snapshot() | viewHierarchy() → Surface |
| exercise(node) | tap (default) or input (text fields); a tap that reaches a new screen returns it as ExerciseResult.discovered (frontier expansion from acting) |
| probes | session (after-goto) · dom (after-snapshot) · crash (after-exercise) · logs (finalize) — each registered with its lifecycle slot |
Native breakage signals: crash (fail — app left the foreground / a FATAL EXCEPTION after an
action or launch), anr (warn — an "Application Not Responding" log line), frozen (warn — a tap or
viewHierarchy() that times out), error-text (warn — an on-screen error/canary string),
assertion-fail (warn — a declared Maestro flow assertion). All dedupe on a signature keyed by
screen scope + kind.
The pluggable MaestroRunner transport
The driver depends only on a MaestroRunner interface — the exact same pattern as the API
driver's pluggable ApiTransport — so the whole driver is tested hermetically by injecting a stub
(no device, no maestro binary). The seam:
interface MaestroRunner {
launch(appId, deeplink?): Promise<void>; // the goto reset point
viewHierarchy(): Promise<NativeView>; // the current screen's view tree
tap(selector): Promise<void>;
input(selector, text): Promise<void>;
back(): Promise<void>; // softReset — dismiss a dialog / pop a screen
foregroundApp(): Promise<string>; // crash detection (≠ appId ⇒ died)
logsSince(sinceMs): Promise<string[]>; // crash / ANR log scan
runFlow?(yaml): Promise<void>; // optional — declared-flow assertions
screenshot?(): Promise<Buffer>; // optional — report evidence
}The bundled DefaultMaestroRunner shells out to maestro (ephemeral one-command flows for
launch/tap/input/back, maestro hierarchy for the view tree) and to adb logcat / dumpsys for
crash/ANR detection on Android.
What a LIVE run needs (and what was verified here)
The driver and its mapping/signal logic are covered by a hermetic test suite that stubs the
MaestroRunner — no live device crawl was run (none was available). A real run requires, none
of which ship with Comber:
- Maestro CLI on
PATH(maestro), plusadbfor Android crash/ANR logs. - A booted emulator / simulator (or a Maestro Cloud session) with the app installed.
- The exact
DefaultMaestroRunnercommand strings reconciled against your installed maestro version — they are a best-effort starting point, marked insrc/native/maestro.ts, not a CI-verified contract. - For iOS egress proof, a configured device-level proxy plus a proof command that produces a
durable artifact through
scripts/native-ios-proxy-verify.ts.
Known gaps (honest, not silent)
- Network-egress safety is NOT enforced on native v1. The web driver's SSRF / write-egress gate
works by intercepting browser requests; Maestro cannot proxy traffic, so there is no
equivalent wire-level guard here. The only write protection is the name-denylist (destructive
controls are observed, never tapped — the core's
deniedNameover the control label). The egress-hardening path is a device-level proxy (e.g. mitmproxy) the runner routes through. Comber clears the native egress blocker only when complete verified proxy evidence is provided with provider, command, and artifact path, or when a verified--egress-proxy-proofartifact provides those fields. The CLI banner andnativeAudit.egressEnforced: falsestill surface the Maestro transport limitation on every run. - Network-egress safety is NOT enforced on desktop v1. Windows UI Automation drives the UI tree;
it does not intercept process network traffic. Complete verified OS-level proxy evidence is still
required to clear
desktop-egress-unenforced.
Abstraction limits the native driver surfaced — RESOLVED in contract v2
Building the third driver stress-tested the fixed Driver contract. Three seams that web + API
never exercised showed their edges. Rather than bend the core per-target, the contract was evolved
(v2) so all three drivers conform and web + API stay byte-identical (same findings, coverage,
dedupe, ordering; the full suite stays green):
Address.idconflated "dedupe key" with "goto handle" — resolved. For web a URL is both; for API an operation is both. A native screen's structural identity (for dedupe) and its re-reachability (forgoto) are different things. The contract now states the two distinct dedup layers explicitly: FRONTIER de-dup is by the goto handle (Address.dedupeKey ?? id, thequeuedset), while "have I processed this screen?" is the core'sstateFingerprintover theSurface(thevisitedset).idis documented as the goto handle; the optionaldedupeKeylets a driver whose handle ≠ structural identity (native tap-paths) collapse distinct handles that reach the same screen. Web/API leavededupeKeyunset → unchanged.DriverCapabilities.probeswas a closed phase set the orchestrator hard-coded (session/dom/axe/links/forms) — resolved. Probes are now driver-registered with a lifecycle slot ({ name, slot }); the orchestrator iterates each driver's probes at the matching slot. The slot set is fixed and reproduces web's exact order —after-goto(session),after-snapshot(axe, dom, links),after-exercise(forms),finalize— but which probes run is open, so native now registers its owncrash(after-exercise) +logs(finalize) phases the closed vocabulary forbade.- The frontier grew only by FOLLOWING links (
outboundAddresses), stranding screens reachable only by tapping — resolved.exercise()may now returnExerciseResult.discovered: Addresses a tap/click navigated to. The orchestrator enqueues them (inScope + caps/dedup), so a native tap-only screen is crawled — returned as a replayable tap-pathAddresswhosegotorelaunches the base anchor and re-taps the recorded sequence. Web/API navigate via links (followed, never clicked) and return none. Declaring--deeplinkis now an optional seed for depth, no longer the only way past the launch screen.
Sinks / Persistence
By default a run writes only the local HTML report + result.json under ./runs/. Opt-in
sinks persist or forward each run's results to any backend — the substrate a hosted tier
consumes. The baseline ships two generic, dependency-light adapters (no new dependencies; both
use only the Node stdlib). Each is configured purely by environment variables, fans out via a
MultiSink, never breaks a run (a sink failure is caught + logged, the run still passes),
and is a no-op when unconfigured (nothing set = unchanged local-only behavior).
Every record a sink emits is redacted (redactResult) before it leaves the process, so a
crawled target's secrets — finding URLs/messages, signed-URL params, OAuth fragments — never
reach a file or cross the wire.
| Env var | Sink | Effect |
|---|---|---|
| COMBER_SINK_FILE | FileSink | Append one redacted run-summary record (a JSON line) per run to this JSONL file. |
| COMBER_SINK_DIR | FileSink | Copy the run's screenshots into this directory, under a per-run runId subfolder. |
| COMBER_SINK_URL | HttpSink | POST the redacted run record (JSON) to this webhook URL. Covers backend ingest + Sentry-style error-tracking collectors. |
| COMBER_SINK_TOKEN | HttpSink | Optional Bearer token for the webhook — sent as an Authorization header only, never logged and never in the body. |
# Local persistence: a growing JSONL log + a screenshot archive.
COMBER_SINK_FILE=./comber-runs.jsonl COMBER_SINK_DIR=./comber-shots bun run dev https://example.com
# Forward each run to a webhook / collector with auth.
COMBER_SINK_URL=https://hooks.example.com/comber COMBER_SINK_TOKEN=… bun run dev https://example.comConfigured sink paths are path-traversal-guarded, and every copied screenshot filename is constrained to stay within the target directory.
Vendor neutrality. The baseline carries only these generic adapters. A vendor-specific backend (a database, object store, or hosted error tracker) belongs in an overlay as its own
Sinkimplementation against the same interface — it is never added tosrc/here.
Connectors & marketplace
Where a sink persists the raw record, a connector delivers a finished run to where developers
already work — Slack, Discord, a GitHub PR check, a Linear issue, a JUnit report, or any webhook.
Connectors are opt-in, declared in the connectors: [...] array of comber.config.json, and each
consumes the same redacted RunRecord a sink does — a third-party connector physically cannot
receive a crawled-app secret. Six connectors ship built-in; the marketplace is any npm package that
satisfies the contract, loaded by module specifier.
{
"url": "https://preview.example.com",
"connectors": [
{ "use": "slack", "on": "fail", "mentionOnFail": "<!here>" },
{ "use": "junit", "outFile": "runs/comber-junit.xml" },
{ "use": "@acme/comber-teams", "on": "warn" }
]
}use names a built-in (slack, discord, github, junit, linear, webhook) or an installed
package; on gates delivery by severity (always | warn | fail); all other keys are non-secret
settings. Secrets come from the environment, never from config (e.g. COMBER_SLACK_WEBHOOK_URL).
List the built-ins with comber connectors. Full guide — the built-in table, the RunRecord shape,
authoring an external connector, and publishing to the catalog — in
docs/CONNECTORS.md.
Architecture
cli.ts → crawl.ts ─ drives ─▶ Driver (driver.ts: WebDriver over Playwright)
│ │
│ orderForExhaustion └─ breakage.ts (CDP listeners, console canaries, axe, signatures)
│ (heuristic, DOM-first)
▼
triage.ts (Opus "genuine defect?" judge) ──▶ report.ts (self-contained HTML)- Driver adapter (
core/contract.ts) is the seam that makes a surface pluggable.WebDriver(Playwright),ApiDriver(OpenAPI), andNativeDriver(native iOS/Android over a pluggableMaestroRunner) all implement the same shape — so crawl/breakage/report never change. - Breakage (
breakage.ts): deterministic auto-fail (uncaught exceptions, 5xx, failed assets, Next.js regression canaries) + judged tier (axe-core criticals, console noise). Every signal carries a normalizedsignatureso a broken shared footer across 50 pages collapses to one finding. - Signal normalization (
signal-normalize.ts): removes crawler-caused egress fan-out from warn/fail gates while keeping a transparentinfofinding in the report. - Frontier crawl (
crawl.ts): BFS over same-origin states with fingerprint dedupe and hard caps (states / actions / wall-clock / token budget) — the runaway-cost backstops.
Safety rails (non-negotiable)
- Destructive-action denylist (
config.ts): elements named delete/remove/pay/checkout/ cancel/etc. are observed, never clicked. - Write blocking is on by default: mutating HTTP verbs are aborted unless
--allow-writesis passed for a reviewed synthetic environment. - Private-network egress is always blocked: browser requests and optional external link checks cannot hit localhost, private IP ranges, link-local metadata, or hosts resolving there.
- External link checking is opt-in via
--external-links. - Synthetic accounts only via
COMBER_STORAGE_STATE— never point an authed crawl at real user data. Use a dedicated throwaway/test account (e.g.[email protected]). - Hard caps abort runaway loops; same-origin confinement by default.
- (Planned) per-app convert-flow guard: upload a 1 KB dummy and STOP before the GPU worker.
Roadmap
Comber's through-line is one contract — discover a target's surface → exercise it → classify
breakage → dedupe → report / baseline / gate — so new targets land as new drivers behind it,
not core rewrites. See PRODUCT.md for the model.
- Now — web MVP: DOM-first crawler, breakage detection, optional Claude action-picker + judge, self-contained HTML report, baseline diffing, PR comment. ✅
- Next — deterministic selector-cache replay (run the expensive every-click pass once, cheap
replays after) ✅; generic persistence sinks (JSONL + screenshot dir, webhook/collector POST)
✅; CI integration on preview deploys — reusable composite GitHub Action (
action.yml), non-blocking PR comment first ✅ (gated on npm publish — see CI integration above). - Native — a
NativeDriverdriving native iOS/Android over a pluggableMaestroRunnerbehind the same contract ✅ (hermetic; a live run needsmaestro+ a booted device — see Native driver above). - Desktop native — Windows UI Automation and macOS Accessibility now ship as desktop adapters behind the same contract ✅. macOS XCTest remains a reserved optional backend, not a default live runner. Desktop evidence/readiness is in place for platform, app identity, automation backend, provider/source, launch command, verification command, screenshots/video/logs/crash logs, accessibility-tree or automation-tree artifacts, and verified OS-level proxy evidence when desktop network egress is claimed controlled.
- API / bot — request/response contract checks as their own breakage-signal set, same pipeline ✅.
- Hosted — multi-tenant dashboard, bring-your-own-key, and billing — the path toward marketplace integrations with CI/deploy platforms.
