Two root folders built from a BQ2 seed, so that no agent OCX runs shares a
sandbox with the repositories: the public server, which runs Fossil and the
CGI programs and starts no agent, and the client, which runs the ocx2 daemon
and reaches the server only over HTTPS. Both start with serve, and carry the
same hosts.json; the hostname each is started with picks its
entry. The server keeps the machine's name. The client is x86-1.
Repositories, certificates, Git mirrors, passwords and sign-ins are data, not build output: no build puts them in a folder.
Build
Each input is checked against its pin, and everything compiles inside the seed with no network. A step that finished is not repeated.
crystal run build-rootfs.cr -- server OUTPUT BQ2 DOWNLOADS
crystal run build-rootfs.cr -- client OUTPUT BQ2 DOWNLOADS
OUTPUT is the new folder, BQ2 a bq2 built from the seed's own version,
and DOWNLOADS where inputs are kept. It must already hold clones, made over
HTTPS, named ollama.fossil, gp-crystal.fossil and crystal-robots.fossil.
Compare two builds, by kind, mode, link target and file hash:
crystal run compare-rootfs.cr -- FOLDER FOLDER
Start
The server:
bq2 sysroot-namespace --rootfs=SERVER --bind=REPOSITORIES:/var/lib/fossil --bind=MIRRORS:/var/lib/ollama-codex/git-mirror -- /usr/local/libexec/ocx/serve
The client holds its name in its /etc/hostname. A folder can hold the name but
not set it: the hostname belongs to the kernel, and the machine's cannot be
changed from inside. So the start command makes a hostname space of its own and
applies the name there:
bq2 sysroot-namespace --rootfs=CLIENT -- /usr/bin/unshare --uts /bin/sh -c 'hostname -F /etc/hostname && exec /usr/local/libexec/ocx/serve'
The client's daemon ends when the server cannot be reached, and nothing
restarts it (failures, item 20). Running
sessions go on. Start it again with confine daemon in place of serve
above, not by restarting a service whose cgroup holds the sessions.
Test
test-rootfs.cr runs both folders together on copies of
repositories, in a sealed network and process list, with the real settings
and certificate. It checks each page against pages.txt, a push,
an approved login, a run sent from the server and reported back from the
client, and a session across a server restart.
It fails one check on copies of today's repositories: a push is not exported
when Fossil's background process follows a request under /ext. That process
holds the CGI environment, so %F git export runs Fossil as a CGI, which
answers "no such file: git". With the hook %F git export --nocgi --if-mirrored -R %R,
that check passes.
Releasing from clients to servers (planned, not built)
Planned on ticket 8986816c0b, for the maintainer's approval, and revised after Codex's review there. Today a release moves from where it is built to where it runs only by hand. The aim: a client builds each next ordinary release of OCX, the public server takes it, tests it, accepts it and can roll it back, and the client then runs what the server accepted.
The first round is OCX alone, on x86_64: the server and x86-1. Other
projects, and aarch64, come after that round trip works (below).
What a project says it releases
A project opts in through its ci.cr. One that says nothing gets nothing.
crystal run ci.crruns the tests, as now, and publishes nothing.crystal run ci.cr -- programsprints, as JSON, the programs it can build: each one's name, source file, whether it is a CGI program or a tool, and the architectures it supports.crystal run ci.cr -- build OUT ARCHruns the same tests and, only if every one passes, builds those programs forARCHinto an emptyOUT, withrelease.txt, each file's SHA-256, andgate.txt, the tests' result. It writes nothing intoOUTwhen a test fails.
ci.cr never publishes. The project does not say where it runs: when a
project is added on a server, project setup asks whether that server serves
its CGI.
Who counts as a builder
A release counts only from a build that ran the known stage pattern, on a clone the server trusts to build:
- The release root's
settings/builders.json, which only its owner writes, lists each builder: the clone's login that acts on stage runs, such asx86-1, its clone's server-code, and its architecture. - It also names the stage pattern by its SHA3-256. A stage run counts only if its content is that pattern's, with only the check-in filled in. A run sent to a builder that copies in some other program and exits 0 is then not a stage run, and stages nothing.
This trusts the reviewed source and the builder's host; no hash can show that hostile source ran honest tests.
The steps
A person runs each step after the build, and can run one again or fix a link by hand if it is cut short; the steps add nothing to make that automatic.
- A client builds. A stage run, sent to a builder, runs
ci.cr -- buildon its check-in in its sandbox, which holds no password, and names the built files inunversioned_filesasreleases/$$run$$/FILE. The builder's watcher postsended, recording each file's SHA-256 inoutputs, then sends the files as the builder's login, which holdsy. - A person imports it. A person with setup runs
import RUN, RUN the stage run named where itsendedwas posted, on the page (a POST) or at the command line. The files are in the server's own repository, so it reads them there, with no sync. It counts the release only when, by its receipts and never by tags or user cards: the run is the stage pattern, compared field by field, addressed to a listed builder and its clone, sent by another user allowed to send it; the builder's first countedstartednames the check-in; its first countedendedfor that run, in its thread, isexitedwith exit 0; and every output is present and matches its hash. Otherwise it refuses and changes nothing. An unwanted release's shared files are removed by hand. - The server tests it. Import copies the files into its own
staged/NAME, keeping the run and hashes it counted beside them, runs each program with no arguments, as staging does today, requiring its usage line, offers it asnexton/ext/ocx2-cgi-next, and removes the shared files. Only import writes the server'sstaged/; the server starts no run. The person tries the release there. - The server accepts it, as it does today: accept copies
nextinto the server's root and movescurrent, keeping the one before asprevious; rolling back moves it back. The release tools take one lock, so two never change the root at once. - The server serves it. The server's page
/ext/ocx2-cgi/releaseanswers what iscurrentandprevious: each one's check-in and each file with its SHA-256, and serves those files by name, over HTTPS, to a signed-in builder or setup login.
The client follows
A person on the client runs ocx2 follow SERVER --root ROOT, inside the
client's root, as the root's owner. It asks the server's /release what is
current. If that is not the client's own current, it downloads each file
it lacks from the server by name, checks every hash, puts the release in its
root, and moves current, keeping the one before as previous. A rollback
is the same: the server names its previous, and the client takes that
release, from its own root if it has it. The daemon ends when current
moves, and the person starts it again with confine daemon, as under
"Start" above. Nothing follows on a timer.
When the server cannot be reached, the client keeps what it runs; to move
it back meanwhile, a person links its current to its previous by hand.
The client downloads only its own architecture's files, once per release,
over a plain HTTPS request; it needs no fossil uv sync, which today sends
every shared file a clone lacks, even for unversioned_get of one file.
Before the first ordinary release
Neither root's installed release can import, serve /release or follow,
and the client's installed confine locks ROOT/node/daemon.lock where
this tree locks ROOT/state/daemon.lock. So the first release that can is
installed once by hand on both roots:
- Stop the client's daemon and wait until idle (
idle ROOT, below). - Check that the client's root already has version 2's layout, as the
switch left it (
to-v2, below):settings/andstate/, and nothing inROOT/node/. A request to the client's page under the oldconfinewould leaveROOT/node/daemon.lock, which the new daemon refuses to start beside; remove it while idle. - Install the host programs from the maintainer's checkout with approval (decision 22), accept the release on both roots, and start one daemon.
The way back is the same in reverse. test-rootfs.cr rehearses this from
the installed layout, not an empty root, before it is done for real.
Other projects, after the first round
Then other projects move to the same steps, and build-rootfs.cr stops
building theirs (gp-crystal, crystal-robots, swk-ai), each project's
first install tested as OCX's was. serve and confine do not change,
except that confine learns once to run a project's CGI program named by an
entry in hosts.json, from that project's current, in a sandbox with no
access to the repository file. swk-ai's page reads its repository file and
writes runs and attachments with it, so it moves unsandboxed, as it runs
today, its entry stating that exception, and is sandboxed once its page
works through Fossil's web pages and a clone of its own; that change is
swk-ai's, on a ticket of its own. aarch64 comes with them, for interactor
and social-badge on a BeagleBadge. A server accepts once its own
architecture's build passes; a build of another architecture for the same
check-in is accepted on its own, and /release lists each architecture's
files with the run that built them. Clients compare by file hashes, never by
check-in alone.
What shows it works
test-rootfs.cr stages a check-in on the client, imports and tests it on
the server's ocx2-cgi-next, accepts it, runs follow on the client,
starts its daemon and shows exactly one daemon in the client's root running
the new release, then rolls both back. It also shows that import refuses a
run that is not the stage pattern, a report from a login not listed, and a
changed file; and that a client that missed a release follows straight to
the current one.
Moving sessions to protocol version 2
migrate-sessions.cr carries each open session across
the switch, by its own handoff on the forum. It reads, writes and prints no
password.
- On the client, before the switch,
crystal run migrate-sessions.cr -- list > sessions.jsonlists each run a watcher holds, from the watchers' command lines and their clones, with each acting clone's login (its default user) and server-code. - On the server,
crystal run migrate-sessions.cr -- ask sessions.json /var/lib/fossil LOGIN MINUTES > handoffs.jsonreplies to each session's run, asLOGIN, asking it to commit, push and answer with a handoff by the deadline. It stops each session that answered. One that did not answer, and a run that is not a session, is left running. It names the request, the handoff and the stop by their full hashes. - After the switch,
crystal run migrate-sessions.cr -- links handoffs.json PATTERNprints a link to version 2's create page for each session that handed off and was stopped: a session fromPATTERN, replying to its handoff, addressed to the acting clone'sloginandclone(its server-code), as the same process user, whose password the link resets for the new clone to collect. A run that ended lost after its stop was posted still counts as stopped. Each session it gives no link names what it lacks.
Bringing a root to rest, and clearing it
tidy-root.cr runs on a host, inside its namespaces, as the
root's own account. Without --apply it changes nothing and says what it
would do.
idle ROOT [MINUTES]names what still runs: the daemon, each watcher and its run, each run whose state is notdone, and eachfossilprocess other than a server. It exits 0 only when there is none, waiting up toMINUTESfor that. Stopping the daemon (kill PID) starts no new run and leaves the watchers to end theirs.to-v2 ROOTandto-v1 ROOTmove the folders betweenROOT/node/andROOT/settings/withROOT/state/, only when the root is idle and no target is taken. The passwords files andunconfinedare the owner's, insettings/; the clones, their completions and the logs are instate/; each version's runs go aside, toROOT/v1-runs/orROOT/v2-runs/, and come back on the way back.sweep ROOT [--archives]removes what no release serves: accepted releases neither current nor previous and run by no process, staged releases other thannext, copies a failed accept left, and run workspaces whose checkout and clone hold nothing the server lacks, as a watcher judges it; with--archives, the other version's runs too. It names anything else it finds, and leaves it.
The switch, with both, on each machine in turn:
- Hand off the sessions (above, steps 1 and 2).
- Client: stop the daemon, then
idle ROOT 60until the last task ends. - Server, then client: accept the version 2 release; on the client,
to-v2 ROOT --apply, then start the daemon. - Publish the patterns in version 2's words:
run-claude.mdandrun-codex.mdas the wiki pagesrun/claudeandrun/codex, written as a user holding admin or setup;session.md,codex-task.mdandstage.mdas attachments of a tech-note, each then named by its content-addressed raw URL.session.mdnamescodex-task.mdby that URL, so publish them as they are. - Start the sessions again from their links (above, step 3), giving
session.md's URL asPATTERN.
pattern-v2.cr rewrites a version 1 pattern's keys and
values in version 2's words, keeping its prose, and lists each line still
naming version 1's words for a person to rewrite:
crystal run pattern-v2.cr -- PATTERN.md > PATTERN-v2.md. The patterns in
../patterns/ were made with it, then their prose rewritten; each passes
the branch's create --dry-run, which refuses a version 1 key.
- Once both run well:
sweep ROOT --applyon each, and latersweep ROOT --apply --archives.
The way back is the same in reverse: stop the daemon, idle, rollback,
to-v1 ROOT --apply, start the daemon.
What it leaves out
- Old trunk's programs: chat, docs, agent, preview and
ollama-codexare not built.ocxis not either: a logged-in visit to it starts the old agent daemon. Each answers 404. - Certificate renewal: the seed has no certbot.
- aarch64: these pins are x86_64's. An aarch64 seed exists but comes from another check-in.
- Fossil's checkout wait: the seed's Fossil opens no
.fslckoutforfossil sql --no-repository, so OCX's wait in a checkout waits on nothing (docs/fossil.md, ticket fa798aee06). OCX pins the seed's Crystal, 1.19.1.