Login
ocx-rootfs
Login

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.

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:

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.

  1. A client builds. A stage run, sent to a builder, runs ci.cr -- build on its check-in in its sandbox, which holds no password, and names the built files in unversioned_files as releases/$$run$$/FILE. The builder's watcher posts ended, recording each file's SHA-256 in outputs, then sends the files as the builder's login, which holds y.
  2. A person imports it. A person with setup runs import RUN, RUN the stage run named where its ended was 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 counted started names the check-in; its first counted ended for that run, in its thread, is exited with 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.
  3. 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 as next on /ext/ocx2-cgi-next, and removes the shared files. Only import writes the server's staged/; the server starts no run. The person tries the release there.
  4. The server accepts it, as it does today: accept copies next into the server's root and moves current, keeping the one before as previous; rolling back moves it back. The release tools take one lock, so two never change the root at once.
  5. The server serves it. The server's page /ext/ocx2-cgi/release answers what is current and previous: 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:

  1. Stop the client's daemon and wait until idle (idle ROOT, below).
  2. Check that the client's root already has version 2's layout, as the switch left it (to-v2, below): settings/ and state/, and nothing in ROOT/node/. A request to the client's page under the old confine would leave ROOT/node/daemon.lock, which the new daemon refuses to start beside; remove it while idle.
  3. 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.

  1. On the client, before the switch, crystal run migrate-sessions.cr -- list > sessions.json lists 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.
  2. On the server, crystal run migrate-sessions.cr -- ask sessions.json /var/lib/fossil LOGIN MINUTES > handoffs.json replies to each session's run, as LOGIN, 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.
  3. After the switch, crystal run migrate-sessions.cr -- links handoffs.json PATTERN prints a link to version 2's create page for each session that handed off and was stopped: a session from PATTERN, replying to its handoff, addressed to the acting clone's login and clone (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.

The switch, with both, on each machine in turn:

  1. Hand off the sessions (above, steps 1 and 2).
  2. Client: stop the daemon, then idle ROOT 60 until the last task ends.
  3. Server, then client: accept the version 2 release; on the client, to-v2 ROOT --apply, then start the daemon.
  4. Publish the patterns in version 2's words: run-claude.md and run-codex.md as the wiki pages run/claude and run/codex, written as a user holding admin or setup; session.md, codex-task.md and stage.md as attachments of a tech-note, each then named by its content-addressed raw URL. session.md names codex-task.md by that URL, so publish them as they are.
  5. Start the sessions again from their links (above, step 3), giving session.md's URL as PATTERN.

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.

  1. Once both run well: sweep ROOT --apply on each, and later sweep 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