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):
- the receipt service and the launcher, awaiting the maintainer's review;
- the CGI's relay and
migrate's confined unit, staged as release 77291d7cb0d9.
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.
- D1, receipts: a narrow receipt page inside Fossil was built and tried on scratch (tech-note c8e705f2d2). It is now the recommendation, and the read-only receipt exception is gone.
- D2, a person's write: it hands Fossil's own form the composed text. OCX's page runs no script, and the remaining trust in the browser is stated.
- The launcher passes the viewer's identity itself, so a compromised CGI can no longer claim another user.
- Retries follow one exact rule.
- Tonight's partial step is marked as a temporary exception.
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.
- D1 is now that service (section 4a). It is compared with the Fossil page the second revision proposed. The patch stays recorded on its tech-note, and is no longer proposed.
- D2, the browser write, is unchanged, and still waits on the maintainer.
- D10 makes the live console's path explicit.
- A1 now matches the actual interface: the CGI holds descriptor 3.
- Checks A11 to A16 cover the receipt service.
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:
- Fossil (
fossil server) is the only program that opens a served repository. Its after-receive hook runs only Fossil's ownfossil git export. - OCX's web page (the CGI) runs inside a fixed sandbox. It asks a daemon for everything, and touches no repository, clone or sync itself.
- The daemon owns local clones, made at setup. It talks to Fossil only through Fossil's authenticated web interface (HTTPS), as users that hold neither admin nor setup. It has no path to a served repository's file.
- Agents run as today, in sandboxes started by the daemon's watchers, writing from their own clones.
- People write as themselves, through Fossil's own pages or their own clones.
- Admin exceptions are the few operations that must change a served repository directly. They are named below, run only by an admin at the server host's terminal, and are given to no program.
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.
- The daemon's root, for example
/var/lib/ocx/agents-daemon, belongs to the account the service runs as. It holds the releases,node/(clones, credentials, run state, logs) and the socket directory. - The service unit makes every host path to a served repository inaccessible to the daemon and everything it starts. That covers
/var/lib/fossiland the same files as seen from outside the server's container root. The unit uses systemd'sInaccessiblePaths=together with a capability set that cannot undo it. Decision D5 asks which account the service runs as.
Configuration. Each item is a file the daemon is given explicitly (precept C6, Explicit Arguments):
nodes.json, as built: one entry per node (clone, server URL, node user's credentials).web.json, new: one entry per repository the web page serves. Each entry names the clone, the server URL and the credentials of the web user, a Fossil user holding read capabilities only:o 2 r j, plusCfor the chat wake. One more key lists the sockets the daemon listens on, each with the one account allowed to connect and the requests that socket accepts (section 3).mirrors.json, new: each repository's git export directory, for the git program. Setup reads each repository'slast-git-export-reposetting once to make it.
Order at setup.
- 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. - The admin starts the service. The daemon clones each web repository over HTTPS as the web user, and completes a first pull.
- The daemon opens its sockets. It writes
node/web.ready, listing each repository and its completed pull. - The admin checks
web.readyand 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.
- The service manager restarts the daemon if it stops. It removes and remakes its sockets on each start.
- On an accept, it exits as now and starts again on the new release. A CGI request meanwhile gets "unavailable, retry" (section 3).
- Clones persist across restarts.
- Removal: the admin points the extroot entries away, stops the service, and deletes the root.
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."
- Self-contained: it uses Crystal's standard library alone, no shards, and nothing from OCX's source. It is not part of a release: one launcher runs whichever release it pins.
- Built and installed: it is built with
shards buildfrom the maintainer's checkout, asstart-fossil.cris. The binary is installed into the extroot owned by root and read-only to Fossil's account, so it needs no interpreter in the root Fossil runs in. - Changed only with the maintainer's review: like everything in
scripts/, it is not changed on any branch without the maintainer's strict review and approval on the record (decision 22). - Its policies are constants in its source, so a policy changes only through that same review.
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:
- Pins the release. It reads the
currentlink 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. - 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.
- 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. - 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,/lib64and/etc/ssl; - an empty
/tmpof 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-parentand--new-session;- a cleared environment, then only the request fields precept C6 lists, less three.
FOSSIL_REPOSITORYis cut to the repository's name.FOSSIL_NONCEis 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:
- ocx, for OCX's page: a connection to
cgi.sock, except for the path/receipt. For that path, the connection goes to the receipt service's socket instead (section 4a), and the daemon is not involved. - git, for the git program: the mirror directories read-only,
mirrors.json, and no connection. - swk-ai, for swk-ai's page: a connection to its own socket,
swk-ai.sock.
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.
- The daemon checks the connecting process's uid (SO_PEERCRED) against the one account listed for that socket.
- One connection carries one request.
- Unix sockets named by a path work across network namespaces, so the CGI's network can stay cut.
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}
same_originis the CGI's existing Referer check: a change counts only from a POST that came from OCX's own page.- Any
user,capabilitiesorrepositoryin this line is refused.
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 repository is in
web.json; - the tool is one this socket accepts;
- the caller holds the capability the tool needs (from the first line);
- a change came from a same-origin POST;
- input bounds.
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.
- The CGI waits up to 25 seconds, and the daemon gives up on a request after 20.
- If it cannot connect, gets no answer in time, or gets
unavailable, the CGI answers HTTP 503 with a plain retry message. It has no direct path to fall back on.
Concurrency. The daemon serves each connection in its own fiber.
- Reads open the clone query-only, as clone reads do today.
- A pull holds the clone's round lock, and a read meanwhile waits on SQLite's lock as Fossil's own readers do.
- Requests do not share state beyond the clone.
Releases.
release, in the first line, is the launcher's pinned release.- If it is not the daemon's own, the daemon still serves when the interface version (
ocx_cgi) matches; otherwise it answersunavailablewith a short retry, while an accept is in progress. - Within a release, an answer is never a mix of two releases' code: the CGI only lays out what the daemon's tool answered.
4. The daemon's clones, and who writes
Reads.
- For
console,questions,status,thread,unaudited,listandhelp, the daemon runs the tool on its clone. - It filters what it shows by the caller's capabilities, as the tools do now (decision 16).
- The clone is current to within about a second, through the same chat wake and complete pull the nodes use. The answer says when it was pulled.
- The web user holds no
x, so posts waiting for a moderator are not in the clone, and the web page does not show them. Fossil's own pages still do.
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.
- The daemon composes the post. For example,
createreads the pattern from its clone and checks the caller's capability. It answers with the composed text and the Fossil form it belongs to:/forume1or/forume2for a post,/tkteditfor a ticket change. - OCX's page shows a plain HTML form whose button posts those fields to Fossil's form with
previewset and no CSRF token:title,contentandmimetype, plusfpidandreplyfor 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). - 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.
- 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.
- This is true of every /ext program Fossil serves, not only OCX's.
- One tightening is the repository's
default-cspsetting without'self'for scripts, if Fossil's own pages still work under it. That is not yet tried, and acceptance check A10 tries 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:
- Each message the daemon composes carries
request, 128 random bits in hex. - Among messages with the same
requestfrom the same sender, by receipt, only the first in the server's receipt order counts. - A node does not act on a later one, and the page shows only the first.
- The rule needs no pull to have happened or not happened: it holds whenever the duplicate arrives.
requestis a new optional key, which protocol v1, section 15, allows (decision D8).
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:
- swk-ai's own Fossil user sends them. It is a user holding
R, bounded byr-senders.json, and it no longer holds Admin, as it does today. - The daemon holds that user's credentials and posts the run through Fossil's
/forume1or/forume2over HTTPS, as watchers post reports. - The run records the person who asked: the post's text names them, and a new optional key
requested_bymay be added (decision D8). - So the receipt names the sender, swk-ai's user, and the requester is kept apart from it.
- Only the
swk-aisocket accepts this request, and only for swk-ai's pattern.
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.
- It uses the standard library, plus a binding to the system's libsqlite3 written in its own source: no shards, and nothing from OCX's source. It is not part of a release, so no agent's staged release can change it.
- It goes in, and changes, only under the maintainer's strict review and approval on the record (decision 22), as
scripts/start-ocx.crdoes. - It is built from the maintainer's checkout and installed owned by root, read-only to every other account.
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:
- The served repositories: the directory holding the configured served repositories, read-only (
--ro-bind). The directory, not each file, because SQLite must see a-journal,-walor-shmfile appear beside the database. Every other*.fossilpresent when the service starts is covered by an empty read-only file, so only configured repositories have content. - No network: its own network namespace.
- One socket, in a directory of its own. Only the account Fossil runs as may connect, which the service checks (SO_PEERCRED).
- Its configuration,
/etc/ocx/receipts.json: repository names, each mapped to its file. Root owns it and the service reads it once at start. A changed configuration means a restart, done by the admin. - No shell and no
fossilprogram. Only the system's read-only libraries and its own binary. - No writable place but its socket directory.
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.
- Fossil authenticates the caller, and the launcher writes the caller's identity line (section 2), then passes the connected descriptor to the CGI. The CGI only relays the question and lays out the answer.
- Nodes and the daemon need no change to how they ask.
- The service authorizes by that identity line, as
receiptdoes today:- a caller without Read (
o) is told every artifact is not received, with no capabilities; - a private artifact counts as received only for a caller holding Private (
x).
- a caller without Read (
The API. After the launcher's identity line, one line of JSON:
{"ocx_receipt": 1, "artifacts": ["<hash>", "..."], "capabilities_of": "<user>"}
repositorycomes from the identity line, and must be a configured name. No request names a path, and none can send SQL: any other key is refused.- Each hash is 40 or 64 lower-case hex digits; anything else is refused.
- A request holds at most 64 hashes and is at most 8 KiB. It must arrive within 2 seconds.
- At most 8 requests are served at once; more wait up to 2 seconds, then are answered
unavailable.
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):
- for each hash: the artifact's row (
rid), its receipt (rcvid), whether it is private, and the receipt's user: their login and theircap; - the
capof the users nobody, anonymous, reader and developer, so it can expand inheritance; - the two moderation settings.
The answer. The same fields receipt answers today:
- for each hash in the order asked,
{artifact, received, sender, capabilities, receipt, rid}; - for
capabilities_of,{user, capabilities, wiki_moderated, ticket_moderated}.
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:
senderis the user Fossil recorded when it received the artifact. That is the user who signed the sync, or who was signed in to the page that wrote it (F3, F4), not the artifact's U card.- Order is
receipt(rcvid) and thenrid, the server's order of arrival. capabilitiesare the sender's capabilities now, expanded as Fossil expands them (F8), not as they were when the artifact arrived. Fossil keeps no history of a user's capabilities, and the service does not pretend to. A run's authority is therefore judged by its sender's capabilities when a node reads it, which is what protocol v1 already does. TakingRfrom a user stops their runs that no node has started yet.
Reading safely while Fossil writes.
- Opening the file. The service opens each repository with SQLite's read-only flag and URI (
mode=ro), and setsPRAGMA query_only. It never usesimmutableornolock. - One snapshot per request. Each request reads in one short transaction, so one answer is one consistent snapshot. Locking is SQLite's own:
- Rollback journal (Fossil's default): a read takes SQLite's shared lock, which works on a read-only file. A Fossil commit waits at most for that one short read. If a crash leaves a hot journal, the read-only reader cannot replay it, so SQLite refuses (SQLITE_READONLY_ROLLBACK, F9). The service answers
unavailableuntil Fossil's next writer replays it, and never reads a half-written file. - WAL (a repository rebuilt with
--wal): the-waland-shmfiles are read through the same read-only directory. With-shmread-only, SQLite reads the log into its own memory when it cannot trust the shared copy (F9). That is slower, but consistent. If those files are absent, the answer isunavailable.
- Rollback journal (Fossil's default): a read takes SQLite's shared lock, which works on a read-only file. A Fossil commit waits at most for that one short read. If a crash leaves a hot journal, the read-only reader cannot replay it, so SQLite refuses (SQLITE_READONLY_ROLLBACK, F9). The service answers
- Checking the mode. The admin checks each configured repository's mode at setup, by the file's header: bytes 18 and 19 are 2 in WAL and 1 otherwise. A13 tries both modes.
- Timeouts. A busy repository waits up to 2 seconds (
busy_timeout). After that the answer is{"unavailable": ..., "retry_after": 1}, and the CGI answers 503.
Failure.
- Unknown repository, bad hash, too many hashes, an unknown key or an oversized request: refused, saying which.
- A busy, locked or unreadable database:
unavailable, within 5 seconds of the request. - The service down: the CGI answers 503 at once.
- No answer is ever partial: every hash is answered, or none.
The trust exception, stated plainly.
- Fossil remains the only program that writes a served repository. This service may read them.
- Its process can read whole files, the user table's stored password hashes among them, and such a hash is enough to sync as that user (F3).
- Bubblewrap prevents writes and the network. It does not limit which columns SQLite reads.
So the guarantee is only this:
- the code that reads is small and fixed, and reviewed by the maintainer;
- what it can do with what it reads is bounded: no network, no writes, and answers whose type has no field for a credential.
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:
- runs a few hundred lines in one file, with about five fixed queries;
- accepts no SQL, no path and no free text, only hex hashes and a user name;
- has no network, shell,
fossilor writable file; - is changed only under decision 22, never by a staged release;
- is reached only through the launcher's identity line.
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:
- E1 release activation (
accept,rollback), if D3 keeps it on the command line. - E2 hook configuration: the export-only hook and
chat-timeline-user(migrate,hook,unhook). - E3 user provisioning:
- node users, web users and run users, and their passwords (
migrate); approve,send_runs, and a run user thatcreatewould have made.
- node users, web users and run users, and their passwords (
- E4 repository lifecycle:
importmakes a served repository, andexportruns Fossil's export by hand. - E5 secrets:
secret, on a node's own clone, on the node's host. It is not on the served repository; that is where the run reads it. - E6, temporary: until the boundary's check-ins are accepted, the web page now served keeps its direct access to the served repositories (D9). It is listed so no one takes the partial step for the finished boundary.
6. The hook, and keeping the parts apart
- On every served repository, the after-receive hook is
%F git export --if-mirrored -R %Rand nothing else.- The v1
wakehook and stone's ownfossilhook are removed. Decision D7 asks when.
- The v1
- The CGI only lays out answers.
- The daemon only syncs, decides runs, answers requests and starts watchers.
- Agents only run in their sandboxes.
- Each has its own section in the architecture document and its own step in the runbook. In the runbook's order: the hook (setup); the daemon and its clones; then the web page; then sessions.
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.
- A1. The CGI cannot reach repository files. Under each policy, a probe program run by the real launcher finds:
- every served path missing:
/var/lib/fossil, the container root's copy, the daemon's root and the release root; - only descriptors 0, 1 and 2 open, and under the ocx policy also 3: one connected Unix socket, to the daemon, or to the receipt service for
/receipt. Nothing else is open, and no socket path is in its file system; - no
HTTP_COOKIE, noFOSSIL_NONCEand no repository path in its environment; - writes failing everywhere but
/tmp.
- every served path missing:
- A2. The CGI cannot bypass its daemon. The same probe finds:
- no network: connecting to the server's own address fails;
- no
fossilprogram; - no socket path at all: only descriptor 3, already connected by the launcher, under the ocx policy; none under git.
- A3. The daemon cannot reach served databases. A probe run by the daemon's unit (
systemd-runwith the unit's properties), and one run by a watcher's sandbox, each find every served path inaccessible, for root too. - A4. Authorized requests give confirmed, synchronized changes. For each, Fossil's receipt names the right user and the page then confirms the hash:
- a user holding
Rcreates a run from OCX's page, through Fossil's form; - a user holding
ianswers a question; - swk-ai's page sends a run as swk-ai's user, with the asker named.
- a user holding
- A5. Unauthorized requests fail. Each of these is refused, and nothing reaches Fossil:
- a viewer without the capability, for every changing tool;
- a change by GET, or from another page's Referer;
- a connection from any account but the listed one;
- a request the socket does not accept, such as a run on the
ocxsocket or a setting on any socket; - a forged browser post by a user without
R, which the node refuses as protocol v1 says; - a second line naming another
user,capabilitiesorrepository; - from a CGI replaced by a probe that claims more than the viewer holds: reads included. For example, nobody asking for
consolewhere nobody lacks2andr, or a reader asking for a question's references they may not read. Each is refused or left out exactly as for that viewer, because the daemon used the launcher's line.
- A6. Concurrent requests. Forty page requests at once, mixed reads and confirms, arrive while a pull runs. All are answered, none waits past the bound, and two answers given at the same completed pull agree.
- A7. The daemon unavailable. With the service stopped, every page answers 503 within 25 seconds, and nothing is written anywhere. Once it restarts, pages work again with no step by hand.
- A8. Retries.
- A repeated read gives the same answer.
- Fossil's Submit pressed twice stores two posts with one
request. The node acts on the first in receipt order and never on the second, whether the second arrives in the same pull or a later one. - Composing again gives a new
request, so only what the person submits counts.
- A9. Release changes. An accept made while requests run leaves every request either answered wholly by one release or answered 503 and retried. The CGI and the daemon serve the new release within one restart.
- A10. Scripts. With the repository's
default-cspset so scripts need the nonce and'self'is gone, Fossil's own pages still work. A script file served from the CGI's own path does not run. If Fossil's pages break, the setting stays as it is and section 4's statement of trust stands. - A11. The receipt service cannot write. Inside its boundary:
- opening any served repository for writing fails;
- making a file beside one fails;
PRAGMA query_onlyrefuses a write;- the repository's bytes, and its
-journal,-waland-shmfiles, are unchanged after a thousand answers.
- A12. It has no network, and the database files cannot be reached from the CGI, the daemon or an agent's sandbox. A1 and A3, run again with the service up, show the service gives none of them a path or a handle.
- A13. Correct receipts while Fossil writes. A scratch Fossil server takes pushes and page posts from several users in a loop, under the rollback journal and again under WAL, while the service answers. Each answer must match what Fossil itself says when asked as Admin (
/rcvfrom): the same user, and the same receipt and row order. Eachcapabilitiesmust equal Fossil's ownfullcap()for that user. No commit may wait longer than one read. A hot journal left by a killed writer must giveunavailable, never a wrong answer. - A14. Only the narrow API. Each of these is refused, and none is opened or run:
- a key named
sql,path,queryor anything unknown; - a repository not configured,
.., an absolute path, or a hash of other length or letters; - 65 hashes, a 9 KiB request, or a request held open past 2 seconds.
- a key named
- A15. Answers free of credentials. For a scratch repository whose users' password hashes, cookies and addresses are known:
- no answer to any request, refused ones and errors included, holds any of them;
- every answer's keys are exactly the API's.
- A16. Bounded failure. Each answers within 5 seconds, and nothing waits on it:
- a repository held locked past 2 seconds:
unavailable; - the service stopped: 503 at once;
- nine requests at once: eight are served and the ninth gets
unavailable.
- a repository held locked past 2 seconds:
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.
D1. Who sent an artifact. Fossil 2.27 gives the receipt's user only to Admin, and no page, report or TH1 query can read it otherwise (F2). Slowpen asked for no Fossil patch and no Admin credential.
- Recommended: the receipt service (section 4a).
- Compared with the Fossil page
/rcvloginof the second revision (tech-note c8e705f2d2):
Receipt service Fossil page /rcvloginWho reads the database this service, read-only, confined Fossil itself Credential-bearing data within reach yes: it can read whole files, though no answer carries a credential none beyond Fossil Fossil itself unchanged, distribution binary patched, built from source for this host until upstream takes it Coupling to Fossil's schema: four tables and two settings, pinned by A13 against Fossil's own answers to the page's JSON Capability expansion its own copy of Fossil's rule (F8), pinned by A13 against fullcap()Fossil's own New moving parts one service, one unit, one socket one setting, and the build Nodes and daemon unchanged: the same path changed to the new page Who changes it the maintainer, under decision 22 Fossil's developers, or the maintainer's build - The patch is no longer proposed. Its tech-note stays as the record of the trial.
- An Admin credential stays rejected, for agents and the daemon alike.
- Until the service is in, nodes keep reading OCX's
receiptpage as now (E6).
D2. How a person's write reaches Fossil. Unchanged by this revision, and still the maintainer's decision. Recommended: OCX fills Fossil's own form, and the person submits it (section 4).
- The person is both requester and sender, and their receipt is Fossil's own. OCX runs no script and holds no write capability for people.
- The costs:
- two presses per write;
- a dependence on Fossil's form fields (
title,content,mimetype,fpid,reply,preview, and the ticket's), which a facts spec pins as it pins/forume2for reports today; - the trust a browser gives any program served under Fossil's address, stated in section 4.
- This does not move people's writes into the daemon. The daemon writes only its own: nodes' reports, and an app's runs under that app's own user. People write as themselves, as the boundary says.
- The alternative is a relay: the daemon writes as its own user, naming the requester, and nodes trust the daemon's word. That is a protocol change, and the daemon could then act as any web user. Not recommended.
D3. Accept and rollback. Recommended: the setup user posts an
acceptOCX message through Fossil's form, two presses. The daemon acts on it only after a complete pull, and only if the receipt service says a user holding setup sent it (needs D1).- The other way: the admin's command line only (E1).
D4. The preview page
ocx2-cgi-next. Recommended: withdraw it. With a thin CGI, a preview shows only layout, and the daemon runs one release.D5. The daemon's account.
- (a) Root, with served paths made inaccessible by the unit and the capability to undo that dropped. This keeps the Claude and Codex homes where they are. Recommended for the first cut.
- (b) An account of its own, which needs those homes moved. It is the cleaner end state.
- A3 must pass either way.
- Not yet tried: whether bubblewrap still starts a run's sandbox for a root daemon without that capability. It would rely on user namespaces then. A3 also starts one run.
- (a) is a transitional arrangement. It exists only to keep those homes in place, and it ends when (b) is done.
D6. Which repositories the web page serves. Recommended: the nine with a node, plus ollama. The others get the page later, one
web.jsonentry each.D7. The v1 pieces. Recommended: retire, in this migration, the v1 pages (
/ext/ocx, agent, chat, docs, the oldgit), thewakehook, the oldocxd, and stone's ownfossilhook once its purpose is known. Each runs as Fossil's account or from the hook.- The cost: opus-stone, the pre-v1 session, must move first.
D8. Two new optional keys, which protocol v1, section 15, allows:
requested_byon an app's run. It is informational: authority stays with the receipt's sender.requeston every message OCX composes, for the duplicate rule (section 4).
D9. Tonight, as a temporary exception (E6), not as the boundary. The boundary needs about ten check-ins (section 10), roughly two to three days with reviews and gates, and D1's Fossil page besides. Recommended:
- Run phase 1 of the staged migration tonight: the export-only hook, the daemon with its nodes, and the sessions moved.
- Add one step: a drop-in for the daemon's unit that makes the served paths inaccessible, checked by A3.
- This adds no new direct access, and it builds two parts of the boundary: no OCX code on arrival, and agents as Fossil peers.
- The web page stays as it is until the boundary's check-ins are staged and accepted.
- Holding everything instead keeps run-work on the old hook for those days.
D10. The live console. Fossil buffers an /ext program's whole answer (F1), so no stream can pass through the CGI. Today's console shows only what was true when the page was drawn. Under the boundary, and with D2 as recommended, OCX's page runs no script. Choices:
- (a) No script. The console page reloads itself with HTML's refresh, every 10 seconds. That is simple, but it is not live.
- (b) A long poll through the CGI. The page asks for
consolewithafter=N, and the daemon answers when its clone completes a pull pastN, or after 20 seconds. A buffered answer is fine for that. The page needs a script, allowed by Fossil's nonce. That isFOSSIL_NONCEgiven back to the CGI, which only re-requests and redraws; writes still go through Fossil's form (D2). Each open console holds one Fossil request, one sandbox and one daemon connection at a time. - (c) A stream outside the CGI: the earlier direction of a real event stream, or chat-carried live traffic in a chat-only repository. It needs a path other than /ext, such as Fossil's own
/chat-pollread by the page with the viewer'sC, or a separate endpoint. That is a design of its own, not part of this migration. - Recommended: (a) now, and (b) as the next step, each explicit. Neither changes D2.
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.
- 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. - The thin CGI: the CGI path builds no
Fossil::Placeand 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. - 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. - Fossil's form: compose answers for
create,stopandpostcarryingrequest, 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. - The receipt service,
scripts/receipts.cr, under the maintainer's strict review (decision 22). It includes its unit, its configuration, the launcher's/receiptroute, and the CGI'sreceiptreduced 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.jsonand the socket directory, made by the admin at setup. No file permission changes, since the service runs as Fossil's account.
- Admin tools off the web page (E1 to E5), and
collectthrough the daemon from its root. Specs: each tool refused on the web, saying where it went. - The git policy and
mirrors.json. Spec: the git program with no repository access. migrategains web users, the files and the unit's containment. Host check: A3.- 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.
- 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:
- The launcher's identity line: without it, the CGI's word decides who the viewer is.
- Fossil's form handoff: the only way a person's own identity sends their write without trusting a relay.
- The receipt service: the only way to learn a sender without Admin or a Fossil patch, since sync does not carry receipts.
- The git policy: exports are read by git, not Fossil.
- The swk-ai socket: the one app the maintainer allowed to send runs for a person.
- D5(a): transitional, as it says.
Appendix: facts
Fossil 2.27 source, cited by file and line:
- F1. /ext programs.
- Fossil checks no capability before running one (extcgi.c:181).
- It clears the environment and sets only its CGI list. That list includes HTTP_COOKIE, so the viewer's login cookie reaches the program (extcgi.c:39-75, 282-287).
- It execs the file directly, with no shell, no arguments and no PATH (popen.c:199-200), and any executable file works (file.c:316-333).
- SQLite opens the database close-on-exec, so the program does not inherit the repository handle (sqlite3.c:39737-39772). It does inherit stderr.
- Output is buffered whole (extcgi.c:318-345).
- F2. Receipts.
/rcvfrom,/rcvfromlist, the "Received From" line on/ciand/artifact, and/clusterlistneed Admin (shun.c:374, 483; info.c:1098-1115, 2944-2960; name.c:2405).- Ticket reports and TH1's
queryshare one authorizer, which allows neitherrcvfromnoruser(report.c:200-214; th_main.c's queryCmd calls report_restrict_sql). /json/queryneedsaors.
- F3. Sync.
- The server records the pushing user in the receipt and checks no U card against it (content.c:472-481; xfer.c:149-171).
- The stored password hash suffices to sign a sync (xfer.c:840-884).
- F4. Fossil's forms.
/forume1and/forume2need3, and their CSRF check needs a POST, a Referer under the base URL and a token derived from the session cookie (forum.c:1571-1575; cgi.c:711-778)./tkteditneedscorw(tkt.c:1087).- A page write is received with the signed-in user in its receipt (login.c:1548).
- F5. A session cookie alone lets a program act as its user: Fossil checks only the cookie and its expiry (login.c:1266-1281).
- F6. Fossil's forms fill themselves from the request.
/forumnewand/forume2readtitle,contentandmimetypefrom it (forum.c:1566-1568, 1751-1753), and withpreviewshow the Submit button (1590, 1782).- A submit still needs Fossil's own token (1575).
- The ticket pages copy every request field into the template's variables (tkt.c:236-242, 1119).
- F7. Fossil's default page policy is
script-src 'self' 'nonce-…'(style.c:590-603), seen on the scratch server. - F8. Effective capabilities (capabilities.c:100-137). They are a user's own letters, plus anonymous's and nobody's. Admin (
a) or setup (s) addsbcdefghijklmnopqrtwz234567AD.vadds developer's, anduadds reader's (and developer's again when that bringsv). - F9. SQLite read-only (the bundled sqlite3.c, 3.51.0):
- A read-only reader that finds a hot journal fails with SQLITE_READONLY_ROLLBACK and does not read on (63742-63745).
- With
-shmopened read-only, SQLite marks it so (43797-43807). If it cannot confirm a writer holds the shared memory, it reads the log into its own memory instead of trusting it (68944-68952). - Fossil uses WAL for a repository only after
fossil rebuild --wal(rebuild.c:808).
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).