Login
Migration plan
Login

Superseded for apps, 2026-10-29: the maintainer found this plan far too complicated. The simpler rule is on Apps in a sandbox. This page is kept as history.

Migration plan: Fossil peers

Revised 2026-10-28. This is a plan only; nothing has been changed on the server. It revises the plan of 2026-10-27, whose inventory and run-work steps are in this page's history. Four read-only surveys back it: the web page's operations, the deployment, the decisions this touches, and Fossil 2.27's source. The facts and their sources are in the appendix.

Status, 7:20 PM EDT Oct 28. The maintainer chose the simple design: the receipt service may read the whole repository directory, with no masking, and the daemon is actually confined. Stage A is built and was tried on a scratch deployment (tech-note bae6999eb9):

Receipts and the console work. Writes and the network fail from the receipt service. Neither agents nor the daemon can open the repositories, and agent sandboxes start. The Transition runbook switches it live, with a rollback at each step. Unlike sections 2 and 9, the daemon is not a root process with hidden paths. It runs as an account of its own, in a mount namespace without the repositories (D5's end state). Inside it, that account's homes are seen at /root.

Second revision, after Slowpen's review of the 18:55 UTC version.

Third revision, after Slowpen's second review. The direction: no Fossil source patch, and no Admin credential for agents or the daemon. Instead, a small, trusted, read-only receipt service written in Crystal.

The boundary

The maintainer's direction, from a review with Slowpen: applications and agents act as Fossil peers, and only Fossil itself directly accesses the authoritative served repositories. In practice:

Who does what

Part Runs as, where May touch Never touches
Fossil server the account owning /var/lib/fossil the served repositories —
After-receive hook Fossil's backoffice fossil git export into each mirror anything else; no OCX code
Launcher scripts/start-ocx.cr, built, started by Fossil as the /ext program reads its policy, pins a release, starts the sandbox repositories, clones
CGI the release's ocx2 inside the sandbox its release (read-only), a scratch /tmp, the daemon's socket repository files, clones, the network, the viewer's cookie
Daemon ocx2 daemon, a systemd service of its own its root: clones, run state, credentials, its sockets; Fossil over HTTPS any path to a served repository
Receipt service scripts/receipts.cr, built, run by a systemd unit inside its own bubblewrap boundary on the server host the configured served repositories, read-only; its one socket any write, the network, any other repository's contents
Watchers and agents started by the daemon, sandboxed per run their run's workspace and clone served repositories (inherited from the daemon's service)
A person's browser the person, signed in to Fossil Fossil's own forms, as that person —
Admin at the terminal root or the repositories' owner the exceptions E1 to E5 —

1. Setup: the clones and the daemon come first

Setup is one admin command, ocx2 migrate (already built; it gains the web part). It must finish, and a check must pass, before the web page is pointed at the launcher. The CGI never makes a clone or starts a daemon.

Ownership.

Configuration. Each item is a file the daemon is given explicitly (precept C6, Explicit Arguments):

Order at setup.

  1. The admin runs migrate (exception E2/E3). It makes the web user on each web repository, writes the three files and the unit, and installs the export-only hook.
  2. The admin starts the service. The daemon clones each web repository over HTTPS as the web user, and completes a first pull.
  3. The daemon opens its sockets. It writes node/web.ready, listing each repository and its completed pull.
  4. The admin checks web.ready and the acceptance checks A1 to A3 (section 7). Only then do they point the extroot entries at the launcher (section 2). That is the moment the web page is enabled.

Lifecycle.

2. The launcher and its policy

The launcher is a small, self-contained Crystal program, scripts/start-ocx.cr. The maintainer, 2026-10-28: "a small, self-contained Crystal script in scripts directory that follows the rule of not updating without strict maintainer review/approval should be the approach."

Fossil runs it as the /ext program ocx2-cgi, and as git for the git program, each name choosing its policy. Fossil clears the environment, passes no arguments and does not use a shell (appendix F1), so the launcher uses absolute paths only.

For each request, the launcher:

  1. Pins the release. It reads the current link once, resolves it to the release's own directory, and runs that copy for the whole request. A later accept does not change a request already started.
  2. Closes inherited descriptors other than 0, 1 and 2. Fossil's database handles are already closed on exec (F1), but stderr and debug files are inherited.
  3. Opens the request's connection to the daemon itself and writes one first line: the viewer's identity as Fossil set it, {"user": FOSSIL_USER, "capabilities": FOSSIL_CAPABILITIES, "repository": NAME, "release": PINNED}. Only that connected descriptor, as fd 3, goes into the sandbox; the socket's path does not. The daemon takes identity from that line alone. So a compromised CGI can choose a tool and its input, within the real viewer's capabilities, but cannot claim another user or more capabilities.
  4. Starts bubblewrap with:
    • new namespaces for everything, the network included;
    • the pinned release read-only at /release, and the system's read-only /usr, /lib, /lib64 and /etc/ssl;
    • an empty /tmp of limited size, as the only writable place;
    • no path from /var/lib/fossil, the daemon's root, the release root or the home directories, and no socket path;
    • --die-with-parent and --new-session;
    • a cleared environment, then only the request fields precept C6 lists, less three. FOSSIL_REPOSITORY is cut to the repository's name. FOSSIL_NONCE is not passed, since the page runs no script. HTTP_COOKIE is never passed: Fossil hands every /ext program the viewer's login cookie (F1), and anyone holding it can act as that viewer (F5).

The program runs as the account Fossil runs it as. The launcher's binary, and so its policies, are owned by root and read-only to that account.

There are three policies:

3. The CGI asks the daemon: the interface

The daemon has no such interface today (it only waits on chat and pulls). It is new, and this is its whole contract.

Transport. A Unix stream socket per policy, in a directory the daemon owns, such as /run/ocx2/. The socket is mode 0660, its group is the account Fossil runs as, and nothing else is in the group.

Framing. Two lines of JSON, each at most 1 MiB, both read within 5 seconds. The answer is one line of JSON, after which the daemon closes the connection.

The first line, written by the launcher. The CGI never writes it:

{"user": "stone", "capabilities": "…", "repository": "ollama", "release": "f150579295dc"}
user and capabilities are Fossil's FOSSIL_USER and FOSSIL_CAPABILITIES. user is absent for nobody.

The second line, written by the CGI:

{"ocx_cgi": 1, "method": "POST", "same_origin": true,
 "tool": "questions", "input": {}, "dry_run": false}

The answer is the tool's answer as every entry point gives it today ({tool, dry_run, log, result, stopped, took_ms}), plus {"clone": {"completed": N, "at": TIME}}, which says how fresh the clone was. When the daemon cannot serve, it answers {"ocx_cgi": 1, "unavailable": WHY, "retry_after": SECONDS} instead.

What the daemon checks. These are the same refusals as today, from the same tool definitions:

The socket's list is the outer limit, and it holds nothing that changes Fossil (section 4).

The trust that remains. Any program running as Fossil's account can open the socket's path and write a first line of its own. That is why every /ext program must run under a policy, or be retired (D7).

Time and failure.

Concurrency. The daemon serves each connection in its own fiber.

Releases.

4. The daemon's clones, and who writes

Reads.

Who sent what (receipts). Protocol v1 counts a run, a stop, a report or a decision by the user the server received it from. Only the served repository knows that: sync does not carry receipts, so the daemon's clone cannot answer it. Fossil 2.27 shows it only to Admin (F2). The receipt service in section 4a supplies it, and nothing else.

Writes by a person. These are create, stop, post and a console press (a decision), and decision D2 settles how they work. The recommended way: the person writes through Fossil's own form, and OCX only fills it in.

  1. The daemon composes the post. For example, create reads the pattern from its clone and checks the caller's capability. It answers with the composed text and the Fossil form it belongs to: /forume1 or /forume2 for a post, /tktedit for a ticket change.
  2. OCX's page shows a plain HTML form whose button posts those fields to Fossil's form with preview set and no CSRF token: title, content and mimetype, plus fpid and reply for a reply, or the ticket's fields. Fossil fills its own form from what it is given (F6) and shows its own preview, with its own Submit button and its own token. The handoff alone stores nothing (tried on scratch).
  3. The person reads the text in Fossil's own preview and presses Fossil's Submit. Fossil receives the post from them, and the receipt names them, so their authority is exactly their own.
  4. The next time OCX's page is shown, the daemon's clone already holds the post (the chat wake brings it within about a second), and the page shows where it stands.

In this flow OCX writes nothing and runs no script. The daemon needs no write capability, and requester and sender are the same person. Each write takes two presses instead of one: OCX's, then Fossil's Submit. Agents and the command line already write from their own clones.

What the browser still trusts. Fossil's page policy allows scripts from the same address (script-src 'self', F7). So any program served under Fossil's address, OCX's CGI included, could serve a script file and have the viewer's browser run it as them. Withholding the nonce does not change that. The sandbox limits what a compromised CGI is, not what a browser will run from it.

Duplicates. Fossil's Submit can be pressed twice, and each press stores a post, so the rule is exact and does not depend on timing:

Writes by an app (swk-ai's exception). swk-ai's page sends runs for a person who may not send runs themselves. Under the boundary this becomes:

Never the daemon's. The daemon holds no admin or setup credential and makes no user, password, setting or hook change on any served repository.

4a. The receipt service

This service supplies one thing: the server-local receipt information that sync does not carry. Everything else, ordinary console reads included, comes from the daemon's clone.

What it is. A small Crystal program, scripts/receipts.cr.

Where it runs. A systemd unit on the server host starts it inside its own bubblewrap boundary, as the account Fossil runs as. That account already reads and writes these files, so no file permission changes.

The boundary:

Who may ask, and how. Only OCX's launcher connects. A daemon, a node or a remote agent machine asks as today: an HTTPS POST to SERVER/ext/ocx2-cgi/receipt.

The API. After the launcher's identity line, one line of JSON:

{"ocx_receipt": 1, "artifacts": ["<hash>", "..."], "capabilities_of": "<user>"}

The queries. These are fixed, parameterized statements in the program's source, prepared once. They read four of Fossil's tables (blob, private, rcvfrom, user) and two settings (modreq-wiki, modreq-tkt in config):

The answer. The same fields receipt answers today:

The answer never holds a password or its hash, a cookie or session, the receipt's address (ipaddr), the receipt's nonce, any other column, or any other user's data. The answer's type has no field for them.

What a receipt means. These are stated here, since nodes rely on them:

Reading safely while Fossil writes.

Failure.

The trust exception, stated plainly.

So the guarantee is only this:

Why it is smaller and tighter than what it replaces. Today the general CGI reads and writes these files with fossil, as Fossil's account, on any request. Under the boundary, the daemon and agents run large, changing code. This service instead:

5. Every web operation today, and where it goes

Paths and capabilities are in the appendix. "Daemon read" means the CGI asks the daemon, which reads its clone. "Fossil's form" means the handoff in section 4: OCX fills Fossil's own form, and the person submits it.

Today on the web page Needs Goes to
list, help, echo, commit_and_sync_guide — daemon read. The card dry runs of admin tools are no longer run for viewers.
console, questions 2 or r daemon read, with receipts per D1
console press, post i daemon composes, then Fossil's form
create a or R daemon composes, then Fossil's form. Making a run user moves to E3.
stop a (or R for its own runs) daemon composes, then Fossil's form
status — daemon read: runs, releases, the daemon's last round. The hook and backoffice settings and the nodes= path move to the admin's command line.
receipt — the receipt service (section 4a), asked at the same path: the launcher routes /receipt to it
unaudited, thread o daemon read
audit (verdict tag) i withdrawn from the web. Fossil's forms cannot write a tag with a value, so verdicts come from agents' clones.
tag_messages i withdrawn from the web. It is a hint, and daemons read every message.
sync — already refused on the web; withdrawn
secret a E5, the admin on the node's host
approve a E3, the admin's command line
collect — daemon read of the approval kept in its root. It no longer reads a served repository, and it is POST only.
send_runs a E3
hook, unhook s E2
export s E4. The hook already exports after each arrival.
import s E4
accept, rollback s D3. Recommended: the setup user posts an accept message through Fossil's form, and the daemon acts on it by receipt. Otherwise E1, the command line.
git program o or h the git policy: mirrors read-only. It reads no repository setting.
ocx2-cgi-next (preview) — D4: withdrawn

Admin exceptions. Each is run by an admin at the server host's terminal, writes the served repository through Fossil's own commands, and is given to no program:

6. The hook, and keeping the parts apart

7. Acceptance checks

Each check is either a spec in the gate or a host check that the runbook runs. The host checks are scripted, and their output is kept in a tech-note.

8. How this differs from what is staged (f150579295dc)

Today, in the staged release Under the boundary
The CGI runs unsandboxed as Fossil's account, with the viewer's cookie in its environment It runs in the launcher's sandbox, with no cookie
The CGI reads the served repository with fossil sql and writes it with fossil bundle import --user VIEWER It reads through the daemon's clone, and a person submits Fossil's own form, which OCX only fills in
The CGI states the viewer's identity itself The launcher states it on the connection, and the CGI cannot change it
The CGI's receipt page reads the served repository with fossil sql, as Fossil's account, unconfined; nodes rely on it The receipt service answers it, read-only and confined, through the same HTTPS path, so nodes are unchanged (D1)
approve, send_runs, secret, hook, export, import, accept and rollback run from the page Admin exceptions at the terminal, or D3
The daemon does run-work only, has no local interface, and runs as root with the served paths reachable It also keeps web clones and answers sockets, and its unit makes served paths inaccessible
migrate makes node users, run users and the hook It also makes the web user, web.json, mirrors.json and the unit's containment
swk-ai's page reads the served file with SQLite, writes runs with ocx2 create --repository SERVED --user swk-ai (a user holding Admin), uploads with fossil attachment add -R SERVED, and starts its greeting daemon from a request It reads through its socket, sends runs as its own user holding R, and uploads through Fossil's attach page in the browser. Its scheduler becomes a service set up once.
The list page runs admin dry runs for anonymous viewers; status nodes=PATH reads any file the server's account can read; collect answers a GET Gone, since the CGI has no files to read and no admin tools
v1 pages (/ext/ocx, agent, chat, docs, the old git) run as Fossil's account Retired, or put under a policy (D7), since an unsandboxed program running as Fossil's account can reach the daemon's socket

9. Decisions for the maintainer

Each has a recommendation. Nothing waits on the others except where it says so.

10. Smallest set of changes, in order

Each is one check-in, through the gate and staged, with docs and specs in the same check-in.

  1. The daemon's socket server: the contract in section 3, web.json, web clones on the existing wait and pull loop, and the peer check. Specs: the contract, A5's socket refusals, A6 and A7.
  2. The thin CGI: the CGI path builds no Fossil::Place and no process, and a guard holds that. It sends the request and lays out the answer, and every tool runs in the daemon from the same definition. Specs: the request and answer shapes, the 503 path, and the release field.
  3. The launcher, scripts/start-ocx.cr, with its policies as constants. It is a small, self-contained Crystal program, standard library only, and goes in under the maintainer's strict review and approval on the record (decision 22). Host check script: A1 and A2.
  4. Fossil's form: compose answers for create, stop and post carrying request, the page's plain form posting to Fossil's, and the nodes' duplicate rule. Specs: A4 and A8, with a client submitting Fossil's forms on a scratch server, and a facts spec pinning the form fields.
  5. The receipt service, scripts/receipts.cr, under the maintainer's strict review (decision 22). It includes its unit, its configuration, the launcher's /receipt route, and the CGI's receipt reduced to a relay.
    • Specs: A13 to A16 against a scratch Fossil server, run as the facts specs are.
    • Host check: A11 and A12.
    • Deployment: the unit, /etc/ocx/receipts.json and the socket directory, made by the admin at setup. No file permission changes, since the service runs as Fossil's account.
  6. Admin tools off the web page (E1 to E5), and collect through the daemon from its root. Specs: each tool refused on the web, saying where it went.
  7. The git policy and mirrors.json. Spec: the git program with no repository access.
  8. migrate gains web users, the files and the unit's containment. Host check: A3.
  9. Documents:
    • precept I4's text, which today lets programs Fossil spawns open a served repository;
    • decisions 20, 24 and 31, which this reverses, recorded in HISTORY with the maintainer's words;
    • the glossary words "launcher", "policy" and "web user";
    • interfaces.md rows for the socket contract and the policies;
    • the runbook.
  10. swk-ai's own changes, made in its repository by its session: reads and runs through its socket, uploads through the browser, the scheduler as a service, and its user's Admin dropped for R.

Then the runbook is rewritten around the order in section 6, and A1 to A16 are its checks.

Why each special case is kept. Each of these is a case the boundary itself creates:

Appendix: facts

Fossil 2.27 source, cited by file and line:

The deployment, the CGI's operations and the decisions this touches are from today's read-only surveys of the checkout and the earlier server surveys (the page Migration surveys).

A fresh reader check of the first revision: Gemma (gemma4:31b) read it cold and answered 8 of 8 questions on it as intended.

A fresh reader check of this second revision: Gemma (gemma4:31b) read it cold and answered 8 of 8 new questions as intended (forged identity, receipts, the form handoff, browser trust, duplicates, the dropped reader, E6, who chooses the receipt page's route).

A fresh reader check of this third revision: Gemma (gemma4:31b) read it cold and answered 8 of 8 new questions as intended (what the receipt service supplies, who may ask and how, the narrow request, capabilities now not then, a hot journal, the trust exception, D10, the CGI's descriptors).