Contributor’s Guide

Interested in contributing to Vaibify? This page outlines the steps to make a meaningful contribution. Before you begin, contact Rory Barnes to confirm that your proposed changes are not duplicating work and will be of general interest.

Style Guide

Vaibify follows the style conventions described in the project’s global development standards: camelCase with Hungarian prefixes for variables, f-prefixed names for functions (with a return-type letter), files in camelCase without Hungarian prefixes, functions usually 20-30 lines (split for reuse or a genuine conceptual boundary, never to satisfy a line count), no abbreviations for words shorter than 8 characters, and clear naming in preference to inline comments. If you are developing with an AI coding agent, read AGENTS.md at the repo root for the rules, traps, and discovery commands the agent should follow.

Running Tests

Run the full test suite:

pytest tests/

Run only tests that require Docker:

pytest -m docker

Run with coverage:

pytest --cov=vaibify tests/

Run the architectural invariant tests directly to verify that route registration, leaf-module discipline, and the science-agnostic source rule are intact:

pytest tests/testArchitecturalInvariants.py -v

Run the static-analysis check, which is also the lint lane in CI. It runs pyflakes (every message except “imported but unused”, because the package re-exports names through facade modules on purpose) and pylint --errors-only against vaibify/, and compares the findings to two exact seeds in the test file:

pytest tests/testStaticAnalysisClean.py -v

A new finding fails the test. A seeded finding that has since been fixed also fails it, so the seed can only shrink: lower the entry in the same commit as the fix. Add a seed entry only with a reason, never to silence a real defect. Checks that pylint gets wrong for this codebase are disabled in pyproject.toml under [tool.pylint], each with its reason. Run pylint --errors-only vaibify/ to see the raw report.

The suite has three kinds of test — unit/behavior tests, architectural invariants, and falsification tests (kill-confirmed tests, proven to fail when the guard they defend is broken) — plus a standing re-kill harness (python tools/reconfirmFalsification.py) and a warn-only cosmic-ray mutation gate that is run on demand, not per pull request. See Testing for what each is, why falsification testing matters, and how to run them all.

Portability and CI

All code must work on both macOS and Linux, and with Python versions 3.9 through 3.14. GitHub Actions runs unit tests on every pull request across all permutations of Ubuntu 22.04/24.04, macOS 15/26, and Python 3.9 through 3.14. Tests that require a running Docker daemon are excluded from CI and run locally. Documentation is rebuilt and deployed automatically on every merge to main.

A separate CI job (agent-docs-path-check) verifies that every path reference in AGENTS.md and SKILL.md files resolves to an existing file. This catches stale references after refactors rename or delete files.

Verifying a change reaches the screen

The Python suite does not execute the frontend at all, so a green suite says nothing about whether the dashboard still loads. Three lanes cover progressively more reality, and each proves something the others do not.

Lane

What is real

When

What it proves

browser-chromium (browser-chromium.yml)

Chromium + uvicorn + real HTTP/WebSockets; Docker is a fail-closed fake

every PR

JS loads and evaluates; API and refusal behavior reach the screen honestly

browser-firefox (browser-firefox.yml)

the same journeys in Firefox

every PR

the engine the maintainer actually uses executes the frontend; clipboard-permission tests are deselected (Chromium only)

browser-webkit (browser-webkit.yml)

the same journeys in WebKit

every PR

Safari’s engine executes the frontend; clipboard-permission tests are deselected (Chromium only)

container acceptance (containerAcceptance.yml)

a real container, image keyed by build-input hash

nightly / manual

a real container answers the commands the browser lane’s fake models

fresh image (freshImageBuild.yml)

full build from scratch, one leg per pinned architecture (amd64 and arm64)

weekly / on vaibify/containerImage/** PRs

the image still builds on both architectures; the container user is unprivileged

Run the browser lane locally when you want the fast signal:

pip install -e '.[browser]' && python -m playwright install chromium
VAIBIFY_REQUIRE_BROWSER=1 python -m pytest tests/browser -m browser -n 6 --dist loadfile

-n 6 --dist loadfile splits the lane across six worker processes and keeps every test of one file on one worker, which the lane requires: each file starts its own hub (module-scoped fixtures) and its tests build on that hub’s state. On a 10-core Mac the full lane took about 10 minutes this way, against about 45 minutes serially. Pick a worker count below your core count; each worker runs a hub and a browser. VAIBIFY_REQUIRE_BROWSER=1 turns a missing Playwright into a failure instead of a quiet skip.

The serial command is still the reference, and the one to fall back on when diagnosing:

VAIBIFY_REQUIRE_BROWSER=1 python -m pytest tests/browser -m browser

A failure that appears only in parallel is a bug in the lane, not noise. Rerun the failing file alone (python -m pytest tests/browser/<file>.py -m browser); if it passes there, look for what two workers share — a fixed path under .browserLaneTmp/ or test-results/, a port learned and released before it is bound, a file under ~/.vaibify the hub writes outside the lane’s redirects, or a wait that assumed an idle machine. Fix it at that cause. Do not skip or deselect the test, loosen its assertion, or drop to serial to make it pass; if a test genuinely cannot share the machine, give it an explicit, documented mechanism (an xdist_group or a file lock) and say why. tests/testBrowserLaneParallelSafety.py pins the two collisions found so far: held ports and per-module artifact directories.

What the browser lane does not cover. It drives a fail-closed fake Docker adapter, so it says nothing about container launch, file ownership on write, the real transport, terminal content, figure rendering, or the sync panel. Those belong to the container-acceptance lane, which runs nightly — so drift between the fake and a real container is caught up to a day late. The browser lane failing blocks merge; the container-acceptance lane failing blocks the next release, not retroactively. Nor does the lane test secret-file cleanup: its hub sweeps a redirected ephemeral root, never ~/.vaibify/tmp, and the fake reports every path as mounted, so the sweep keeps every file. Cleanup is tested in tests/testEphemeralStore.py, tests/testSecretFilesFollowTheirContainer.py and tests/testSecretFilesStayOutOfTheResearchersHome.py.

Legibility is measured, in two places. tests/testPaletteContrast.py reads the :root palette of each stylesheet and holds every text-grade token to the WCAG 2.1 text minimum (4.5:1) on every opaque surface token, and requires the base link rule that keeps an unstyled link off the browser’s default dark blue; a pair below the minimum is recorded with its reason and may only leave the record. tests/browser/testVisibleTextIsLegible.py sweeps the rendered hub, wizard and workflow (with the Zenodo token banner rendered), compositing each text color over the layers behind it; text over a gradient or an image is reported as unmeasured, never counted legible, and text dim on purpose is allowed by selector with a reason. The sweep sees only the screens the lane renders: a surface that appears only against a real container is still checked by eye.

The manual check

Still the right tool when you are working on something the lane does not assert — layout, wording, a specific interaction. About a minute, no Docker needed:

python -m vaibify --port 8137     # scratch port, not your usual hub

Then at http://127.0.0.1:8137/:

  1. Read the console. Zero errors is the bar. A single ReferenceError means a module failed to evaluate and every feature below it in load order is dead.

  2. Enumerate the globals. Object.keys(window).filter(k => /^Vaibify/.test(k)).length. Then check any global your change touched resolves as a bare identifier, not via window.: modules declared with const create a global lexical binding, so window.VaibifyApp is undefined while VaibifyApp works. Probing the wrong one produces a false alarm.

  3. Confirm any new cross-module call resolves, e.g. typeof VaibifyApp.fsGetLeaseId → "function".

  4. Look at the page. It should render, and any unavailable dependency (Docker down, no containers) must be reported honestly on screen rather than hidden.

Kill the scratch hub when done.

Container-dependent paths need a container. Anything touching the lease, the WebSockets, or the file-status poll is not verified by the above. Start Docker, open a project, and exercise the specific path.

Docker-dependent tests (tests/testContainerBuildIntegration.py) are excluded from routine runs and are the only ones requiring a live container. They are parametrized via VAIBIFY_INTEGRATION_CONFIG and skip when it is unset.

What survives what

Three different deaths, three different answers. Getting these backwards is how a long run gets abandoned, or a dashboard gets believed when it should not be.

A logout does not touch the work. Session expiry commits an orphan, never a release: the credential stops authorizing, and the record keeps its flock, its keep-alive, its agent token, and any live task. A dashboard-launched runAll survives the logout in full — the loop runs hub-side as a durable task nobody cancels on disconnect, and the status callback tolerates a dead socket by design. The returning browser reconciles from pipelineState.

A hub restart does not stop a run — it stops vaibify watching one. Measured against a live daemon, an in-container exec survives SIGKILL of the process holding its stream, and its exit code stays readable through exec_inspect on the journaled exec id. What is lost is the output stream: re-attaching to a started exec yields nothing. So anything that must survive a restart has to be recoverable from the journal and the filesystem, never from the hub’s memory. The full measurement is in architecture.md — “What survives what”.

The terminal-backgrounded gap. A job the researcher backgrounds in a terminal is not a vaibify pipeline run, so vaibify’s own bRunning flag never rises for it and none of the run-shaped vetoes fire. Sleep prevention no longer depends on those vetoes — sleepPrevention re-derives the keep-alive from whether the daemon reports a running exec — but note the honest limit: a setsid descendant whose parent exec has exited is invisible to that signal, exactly as it is invisible to terminalContainment’s process-group prover. Vaibify cannot prove what is running inside a container. Treat “no exec visible” as no evidence of work, never as proof of quiet; a project in which a terminal has run reports quiescence UNPROVEN for the same reason.

Verifying any of this by hand. The session windows are settable, so a test drive does not need twelve hours:

VAIBIFY_ABSOLUTE_SESSION_CAP_SECONDS=120 python -m vaibify --port 8137

The environment tier outranks the stored preference, so this cannot be masked by whatever is in ~/.vaibify/preferences.json. Leave a tab open past the cap and confirm two things: the run you started keeps going, and the refusal the tab meets names the ending rather than claiming the server restarted.

The terminal and the clipboard

Claude Code draws in its classic renderer inside vaibify. Its fullscreen renderer enters the terminal’s alternate screen (ESC[?1049h), where xterm keeps no scrollback, and captures the mouse. Every shell vaibify opens sets CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1, from the one table in vaibify/config/agentTerminalEnvironment.py. Measured on 2026-10-26 with Claude Code 2.1.311 in a container with no network: the default was the classic renderer; CLAUDE_CODE_NO_FLICKER=1 selected the fullscreen one (the CLI’s /tui fullscreen command is the other way in); and adding CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 to CLAUDE_CODE_NO_FLICKER=1 kept it classic. Whether the installed CLI still honors the variable is upstream’s decision, so tests/testClaudeCodeRendererLive.py asks it nightly, forcing fullscreen as its control. It reports inconclusive, never a pass, when the control does not reach the fullscreen renderer.

A browser’s clipboard permission runs from the press, and expires. Measured once on 2026-10-26, in real Firefox on a bare localhost page (the Firefox version was not recorded), with the page writing the clipboard 200 ms after the release, as the terminal’s copy-on-select does: holds of 0.5 s and 3 s were accepted, and holds of 6 s and 9 s were refused with NotAllowedError. That is consistent with the HTML standard’s transient activation, which a press grants for a duration each browser chooses and the standard expects to be at most a few seconds (Tracking user activation). A long drag can therefore outlast the permission before the copy is written. This is why the refusal toast says that long selections can outlast it and that right-clicking the highlight, which is a fresh press, tries again. It is one measurement of one browser version, not a browser contract, and the toast never claims it as the cause of any particular refusal.

No lane can reproduce it. Playwright’s own Firefox build (Firefox 155.0, Playwright 1.63) accepted the same write after holds of up to 9 s, with navigator.userActivation.isActive still true throughout and the pointer held still. So the browser tests stand in for the refusal with a writeText that rejects (tests/browser/testARefusedCopyOnSelectSaysHowToRetry.py, tests/browser/testRightClickingTheHighlightCopiesIt.py): they pin the page’s answer to a refusal, never the browser’s reason for one. The real browser is checked by hand: hold a drag for more than 6 s in Firefox, read the toast, then right-click the highlight and paste.

Creating custom templates

The templates offered on the Create New wizard’s Template page are the subdirectories of vaibify/templates/ in the installed package, each offered under its directory name. To add one:

  1. Create vaibify/templates/<name>/ in a vaibify source checkout.

  2. Add a container.conf, as every shipped template does: one repository per line in the form name|url|branch|install_method, with # starting a comment. A build clones only the repositories named in the environment’s vaibify.yml, so treat this file as a record of the format rather than as build input; vaibify init --template warns when it lists repositories.

  3. Optionally add a project.json at the template’s top level, with the step directories its commands use. Scaffolding moves the file into .vaibify/projects/. Write every cross-step file reference as a {step:<sStepId>.<stem>} token; testTemplateCommandsUseStepTokens enforces this for shipped templates.

  4. Reinstall the package (pip install .). An editable install (pip install -e .) picks up the new directory without reinstalling.

Do not put a vaibify.yml in a template; vaibify always writes that file itself. __pycache__ directories are skipped when a template is copied.

Pull Request Workflow

  1. Fork the repository and create a feature branch.

  2. Make your changes following the style guide.

  3. Add or update tests as needed.

  4. Run pytest locally and confirm all tests pass.

  5. Open a pull request against the main branch with a clear description of the change.

Project Layout

vaibify/
  cli/                Command-line interface (Click)
  completions/        Bash, zsh and fish tab-completion scripts
  config/             Configuration dataclasses and parsers
  containerImage/     Docker build context (Dockerfiles, entrypoint,
                      overlays, in-container skills and CLI)
  docker/             Container lifecycle management
  docs/               Docs that ship into the image; the five curated
                      ones are symlinks onto the Sphinx sources
  gui/                FastAPI web application and pipeline runner
    routes/           Route modules (one per endpoint group)
    static/           JavaScript IIFE modules + CSS + HTML
    AGENTS.md         Backend subtree rules for coding agents
    static/AGENTS.md  Frontend subtree rules for coding agents
  install/            Setup wizard and shell installer
  reproducibility/    Zenodo, Overleaf, and LaTeX integration
  templates/          Project templates (sandbox, workflow, toolkit)
tests/                Pytest test suite, including
                      testArchitecturalInvariants.py
tools/                On-demand helper scripts (listModules.py,
                      checkAgentDocsPaths.py)
docs/                 Sphinx documentation (this site) including
                      architecture.md and vibeCoding.md
.claude/skills/       Conditional recipes for recurring extension tasks
AGENTS.md             Repo-wide rules that apply to every edit, and
                      the routing table from subsystem to skill
                      (symlinked from CLAUDE.md)

For the full architectural narrative including module responsibilities, dependency graph, state machine, and known technical debt, see architecture.md. For the methodology behind the agent documentation system, see For Agents.

Run python tools/listModules.py <subtree> to print the current module layout with __all__ exports and docstring summaries, rather than relying on a static module map that can drift.