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 ( |
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 ( |
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 ( |
the same journeys in WebKit |
every PR |
Safari’s engine executes the frontend; clipboard-permission tests are deselected (Chromium only) |
container acceptance ( |
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 ( |
full build from scratch, one leg per pinned architecture (amd64 and arm64) |
weekly / on |
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/:
Read the console. Zero errors is the bar. A single
ReferenceErrormeans a module failed to evaluate and every feature below it in load order is dead.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 viawindow.: modules declared withconstcreate a global lexical binding, sowindow.VaibifyAppisundefinedwhileVaibifyAppworks. Probing the wrong one produces a false alarm.Confirm any new cross-module call resolves, e.g.
typeof VaibifyApp.fsGetLeaseId→"function".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:
Create
vaibify/templates/<name>/in a vaibify source checkout.Add a
container.conf, as every shipped template does: one repository per line in the formname|url|branch|install_method, with#starting a comment. A build clones only the repositories named in the environment’svaibify.yml, so treat this file as a record of the format rather than as build input;vaibify init --templatewarns when it lists repositories.Optionally add a
project.jsonat 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;testTemplateCommandsUseStepTokensenforces this for shipped templates.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
Fork the repository and create a feature branch.
Make your changes following the style guide.
Add or update tests as needed.
Run
pytestlocally and confirm all tests pass.Open a pull request against the
mainbranch 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.