You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Plan 034 (specs/034-opendox-standalone-operation/), phase 3:
T104, the console token travels in the opened URL, not /capabilities. RULED on openxFactory#656 in 5963851934 (Brett, 2026-10-03, "Token via the opened URL (Recommended)"). Its plan entry landed with openxFactory#1220 (ec9308c8).
From adversarial review 2's M5 (pre-existing). A standalone openDox served its per-serve console token from /capabilities to any loopback caller, other OS users on the same machine included. Nothing checks which local user connects. The token lets a page edit documents and run chat turns that spend the operator's model credential.
The holder's ruling on openxFactory#1220's review (Copilot r4171166321): the private copy refuses an OPENDOX_STATE_DIR that is, or lies inside, a served root. This mirrors T100's served-repository boundary.
Fix round 1 (cb899546, Copilot r4173806506, r4173806552, r4173806590, r4173806621): every served root is in the boundary, removal is exact under a race, a plain kill removes the copy, and the shutdown cases look before cleanup. See "Fix round 1" below.
Fix round 2 (c979747a, Copilot r4173889265, r4173889294): the copy is judged by its own path, and no static link serves it. See "Fix round 2" below.
The reverse boundary (36ff6103): the holder's ruling on batch N's Copilot review, openxFactory#1222 r4174345203. The state directory and every served root may not overlap in EITHER direction. See "The reverse boundary" below.
Fix round 3 (219d7cf9, Copilot r4174674625, r4174674702): a directory's index page is judged, and a FIFO never blocks a read. See "Fix round 3" below.
12.4a, clause by clause (a13857ad): openxFactory#1222 (T007 batch N) landed as bdd0f586, and its amended 12.4a is the normative text for T104. Every clause is checked and its gaps are closed; the opener file's lifecycle is self-reviewed in the same commit. See "12.4a, clause by clause" below.
One walk of the state directory (fix round 4, 14285fbb, Copilot r4174785933): every directory and link the walk passes through is judged, and the write is anchored to that walk. See "One walk of the state directory" below.
Fix round 5 (9f328892, Copilot r4175213798, r4175213842, r4175213864, and r4177975845 at ddb26c34): the snapshot files are served roots, publication and removal take one lock, and a stop is held through publication. See "Fix round 5" below.
Fix round 6 (9dcc9bb5, Copilot r4177975898): an operating-system error during the walk is a refusal by name, for the writer and the reader. See "Fix round 6" below.
Fix round 7 (fb8a1cc4, Copilot r4178041022): Ctrl-C is held like SIGTERM while a copy is written or removed. See "Fix round 7" below.
Fix round 8 (66cffdb8, Copilot r4178133814, r4178133842): a running console's copy is reserved for its server's life, and every read that serves a file without the console check judges the file it opened. See "Fix round 8" below.
The holder's adversarial review (fix round 9, 45958bf3; findings B1-B10 on fb8a1cc4 and round 8): every standalone plane keeps the boundary, token or not; a platform without the POSIX primitives is refused by name; a browser that cannot open the copy is told how to move it (B3, ruled by Brett); a second spelling of a directory is judged by its identity; only the first stop is raised; --no-serve publishes nothing; and a publication sweeps the copies of consoles that died. See "The adversarial review" below.
Fix round 10 (af2a2efb, Copilot r4179091592, r4179091624): the tokenless plane's guard walks the state directory once, as the writer does, and refuses an unsupported platform first. See "Fix round 10" below.
Fix round 11 (ed6e4769, Copilot r4179239380, r4179239411, r4179239424, and a finding its review at af2a2efb lists as previously missed): a copy is known by what it holds, in any state directory and mid-removal; a check that cannot be made denies; an entry's in-memory payload comes before its guarded file. See "Fix round 11" below.
Fix round 12 (a2e36652, Copilot r4179793524): a copy is written from a marker, so a part-written or growing copy is known by its first bytes; every reader judges the bytes it sends. See "Fix round 12" below.
Fix round 13 (c5fcdfa4, Copilot r4180089809): the console page's URL is judged as a browser reads it: an exact loopback authority, and no backslash or control character. See "Fix round 13" below.
Claimed on openxFactory#656 in 5963979413. The holder posts READY, and the landers merge.
What changes: the delivery only
Standalone means openDox's own default profile. A plane is standalone when the profile build_server builds from IS opendox.default_profile, the one an entry point registers where no host has (console_access.delivery_for).
On a standalone plane, the token is minted exactly as before, and /capabilities no longer carries it.
A HOST's plane (openxFactory's opendox_host.register_openxfactory(), and this suite's own _SuiteProfile) keeps the /capabilities delivery, unchanged. See "The governed path" below.
generate-and-open and python -m opendox.serve write a private copy at <OPENDOX_STATE_DIR>/console/<port>.html:
it is an HTML page that forwards to http://127.0.0.1:<port>/index.html#console_token=<token>. The token is in the fragment, never the query string, so it never reaches a request line, a server log or a Referer;
it begins with a marker comment (COPY_MARKER), before any byte of the token, so any part of a copy that holds a token byte is known for a copy (fix round 12);
it embeds the same record as JSON, escaped so that no value can end its <script> element;
the browser is handed the copy's file:// path, never the tokenized URL. A URL given to webbrowser.open sits on a command line (xdg-open, the browser), and every user of the machine can read /proc/<pid>/cmdline. This is Jupyter's own redirect file, for the same reason;
the start prints the copy's path ( console file://…) and never the token, with or without --no-open. To re-open the page, open that file again. Beside the path, one line with no token tells a user whose browser cannot open that file how to move the state directory (B3, ruled; see "Known limits"). There is no new verb, so the governed --help golden is unchanged;
the copy is removed when the server stops, BEFORE the listening socket closes. Only the file this process wrote is removed, so a later serve on the same port keeps its own. Another serve's copy is put back by a hard link, or by a rename where links fail. Every writer and remover holds the console directory's lock, so the put-back can never overwrite a newer copy. SIGTERM and SIGHUP are read as Ctrl-C from before the copy is written to after it is removed. So a plain kill or a closed terminal removes it too. A stop that arrives while the copy is written or removed, Ctrl-C included, is held until that is done, and only the first stop is raised (fix round 9). A SIGHUP the process was started ignoring (nohup), an ignored SIGINT, and a host's own handler are left as they were;
a copy is reserved for its server's life (fix round 8): the writer holds an exclusive flock on the file it wrote, on its own descriptor, until the copy is removed. A second console on the same port number and state directory (one on 127.0.0.1, one on ::1) is refused by name and never replaces it. A copy whose console died holds no lock, and the next publication sweeps it, whatever its port (fix round 9);
--no-serve writes no copy, opens none and prints no console line, since it closes the server before anything could use one (fix round 9);
if no safe copy can be written, the start is refused by name (generate-and-open refused: … / serve refused: …, exit 1), and the listening socket is closed. A platform without the POSIX primitives the copy rests on (Windows) is refused by name as well (fix round 9). An operating-system refusal, such as a directory this user cannot make or a full disk, is refused by name too. A write that fails part way leaves no partial file, and a copy whose read-back fails is removed. A console nobody can be handed is not served as if it could be.
The copy is checked as #69's bundle checks its tree, and by T100's trust-file rules.src/opendox/console_access.py copies the rules (it does not import runtime/bundle.py's private helpers):
the state directory and console/ must be real directories, owned by this user and writable by no one else. console/ must also be exactly 0700: its permission bits are judged, so a setgid bit inherited from a parent is accepted;
the state directory is resolved ONCE, by one walk. Every directory the walk passes through must be this user's or root's, and sticky if others can write it, including a directory reached only through a link's target. Every symbolic link it follows must be this user's or root's. The served-root boundary, the tree rules, the write and the read all work on the walked path, never on the configured one again;
a missing directory is made relative to its parent's descriptor, born 0700 under a 077 umask, and opened with O_NOFOLLOW before anything is made beneath it;
the file is created with O_CREAT|O_EXCL|O_NOFOLLOW, fchmoded to 0600 on its descriptor, fsynced, then renamed into place;
if a name already at the target is anything other than this user's own regular file of mode 0600 with one link, it is refused by name and never followed or replaced, and the start is refused. That covers a link, a dangling link, a directory, a FIFO, another user's file, a hard link and a loosened copy;
the served-root boundary (the #1220 ruling, made two-way by the reverse ruling): the state directory and every root the plane serves may not overlap in either direction. A state directory that IS a served root, lies inside one, or HOLDS one is refused by name before anything is created (OPENDOX_STATE_DIR (…) lies inside the served repository (…), or … holds …, a root this plane serves). The served roots (fix round 1) are:
the checkout;
the static bundle's --web-dir;
each declared --source-root;
each registry entry's root;
on a loopback plane, the sessions container;
the snapshot files /snapshot.json reads directly: the configured snapshot, each registered entry's, and, on a loopback plane, the session snapshots' container (fix round 5).
Paths are compared after resolving, so a link from outside into a served root counts, and so does a served root named through a link into the state directory. Every directory on either path is also compared by its identity, (st_dev, st_ino), so a second spelling of one directory, as a case-insensitive filesystem allows, is the same directory (fix round 9);
every standalone plane keeps that boundary, token or not (fix round 9). A plane with no git identity mints no token and writes no copy, but it shares the state directory with the planes that do. It still refuses a state directory that overlaps its served roots, and still marks the copies' directory private;
fix round 2 judged the copy's own path, so a served root that holds console/ refused the write. The reverse rule covers that case and every other served root inside the state directory. That separate check is gone, and its case still passes;
the static handler never serves a copy: publish marks the private-copy directory on the server, and DashboardHandler.send_head answers 404 for any static target whose resolved path is that directory or inside it (fix round 2), by name or by the directory's identity (fix round 9). For a directory request, the index page the handler would serve (index.html, then index.htm) is judged too (fix round 3). The file it would serve is judged by its own identity before it is opened, and the file the handler opened is judged again, so a hard link or a link swapped in between is never sent (fix round 8). The body sent is bounded by the length judged, and its first bytes are judged as they are read (fix round 12). Links in --web-dir are still followed, since a governed host's composed web root is made of them;
/source, /snapshot.json and each registered entry's snapshot read through serve.read_unless_private, which judges the file it opened by its identity: a root re-pointed at the state directory after publication, or a hard link to the copy, is a 404 (fix round 8). Every such check also judges the opened file by what it holds, so a copy in another plane's state directory, or one removed mid-read, is refused, and a check that cannot be made denies (fix round 11). The bytes read are judged too, so a copy part-written or growing in another state directory is refused (fix round 12);
a read (read_private_copy, which the tests and T095's harness use) asks all of it again. The file is opened without blocking, so a FIFO is refused at once (fix round 3). It is checked on its descriptor: a regular file, this user's, exactly 0600, one link.
The page (web/views/notebook.js, its only web file):
it takes #console_token= from location.hash at import, before the shell's first fetch;
it keeps the token in sessionStorage for this tab, or in memory where storage is blocked;
it strips the fragment with history.replaceState(state, "", pathname + search), and a malformed token is stripped too;
probeCapabilities() fills the token into a payload that carries none, so every view keeps reading caps.console_token unchanged. A host's published token always wins. The query string is never read;
app.js, edit.js and the staging workbench's JS are untouched (T102 edits the workbench). notebook.js stays import-free, so its census row is re-measured (109 → 204) and class A's total is re-derived.
Every route that requires the token still requires it._not_the_human_console, the catalog, the thread read, the chat turn, the abstract, /actions/edit and the gate verbs are unchanged.
Fix round 1 (cb899546)
Copilot's review at 6db50b03, four threads:
r4173806506, served roots.build_server reports every root the plane serves files from: the checkout; the static bundle's --web-dir, whose handler would serve a copy under it to anyone, 0600 notwithstanding, since the server reads it as its owner; each declared source root; each registry entry's root, which includes the bootstrapped session worktrees; and on a loopback plane the sessions container, where every later session worktree is made. Cases: a state directory under the static bundle and under the sessions container is refused and nothing is written; the reported set is asserted.
r4173806552, a removal race.remove_private_copy takes the name with an atomic rename to a name only this process uses, then judges what it took. Its own file is removed; anything else is linked back under the name, never over a still newer copy. Both entry points also remove the copy BEFORE closing the listening socket. Case: a replacement written at the instant of removal survives whole.
r4173806590, SIGTERM. While a copy exists, python -m opendox.serve and generate-and-open (local and hosted) read SIGTERM as Ctrl-C (console_access.terminate_as_interrupt) and restore the previous handler afterwards. A plane that wrote no copy keeps SIGTERM's default, so the governed and hosted images are unchanged. Cases: the context manager; a plain kill of each of the three entry points exits 0 with the copy gone.
r4173806621, the shutdown assertions. They now signal and wait (_stop), assert while the state directory still exists, and leave cleanup to the case's finally.
Fix round 2 (c979747a)
Copilot's review at cb899546, two threads. Each case failed first at cb899546, then passed:
r4173889265, a served root holding console/. The boundary judged the state directory only, so --web-dir equal to <state>/console (state directory outside every served root) let GET /<port>.html serve the copy. The copy's own resolved path is now judged as well: a served root that holds it refuses the write by name. Case: test_a_served_root_equal_to_the_console_directory_is_refused (it failed first: DID NOT RAISE).
r4173889294, an outward link in the bundle. The static handler follows links inside --web-dir, so web/state-alias -> <state> served the copy. Links are not refused wholesale: openxFactory's scripts/ideation-dashboard-serve.py composes its web root from links out of the bundle (_composed_web_root), and confining to the resolved root would 404 the governed bundle. Instead publish marks the private-copy directory (httpd.private_roots), and DashboardHandler.send_head answers 404 for any static target whose resolved path is that directory or inside it, for GET and HEAD, files and listings, every port's copy included. A server with no copy marks nothing. Case: test_a_static_link_out_of_the_bundle_never_serves_a_private_copy (it failed first: GET answered 200).
The reverse boundary (36ff6103)
The holder's ruling, from batch N's Copilot review (r4174345203). _refuse_a_served_state_dir checked one direction only: a state directory in a served root. A served root INSIDE the state directory would let the plane serve what the state directory keeps, the copy among it. Such a root could be <state>/console itself, the bundled PostgreSQL's postgres/run, or any deeper path. Fix round 2's copy-path check caught only the root that holds console/.
The fix. The two may not overlap in either direction. A state directory equal to a served root, inside one, or holding one is refused by name before any write.
Case:test_a_served_root_inside_the_state_directory_is_refused, for console, postgres/run, anything/else/deep and . (the state directory itself). It failed first (DID NOT RAISE). At cb899546, 3 cases failed: console, postgres/run and anything/else/deep. At c979747a, 2 failed, because fix round 2 already covered console.
Pin:test_a_source_link_into_the_state_directory_never_serves_the_copy. A link inside a declared source root that points at the state directory gets 404 from /source, which never follows a link out of its root. It passed before the change. It is pinned here and held by mutant M13.
Fix round 3 (219d7cf9)
Copilot's review at 60bace00, two threads. Each case failed first at 60bace00:
r4174674625, a directory's index page. For /sub/, the stdlib handler serves the first of index_pages that is a file, and send_head judged only the directory. So web/sub/index.html, linked to a copy, was served at /sub/ while /sub/index.html answered 404. The index page the handler would pick is judged too. The case is test_a_directory_index_linked_to_a_private_copy_is_never_served, run for index.html and index.htm. It covers GET and HEAD of /sub/, /sub/<index> and /sub/?x=1, and checks that the bare /sub redirect carries nothing. A directory whose index page is the bundle's own still answers 200. Before the fix, GET /sub/ answered 200.
r4174674702, a FIFO.read_private_copy's read-only open of a FIFO with no writer blocked forever, before the descriptor check could refuse it. It opens with O_NONBLOCK now. The case is test_a_fifo_at_the_copy_is_refused_without_blocking, which runs the read and the write on threads with a bounded join, so a failure fails and never hangs. Before the fix, "the read blocked on a FIFO".
12.4a, clause by clause (a13857ad)
openxFactory#1222 (T007 batch N) landed as bdd0f586, and its amended 12.4a is the normative text for T104. Every clause was checked against d4b99436. Batch N's gaps (c), the non-blocking reader, and (d), the automatic index file, closed in fix round 3. This commit closes the rest. Each case failed first at d4b99436 unless it is marked as a pin.
(a) "Replaced only when it is this user's own regular file of mode 0600 with one link." The writer checked the type, the owner and the link count, but not the mode, so a loosened own copy was replaced. It now applies the reader's own rule, so a loosened copy is refused by name and left as it is. This also answers Copilot's r4174785965 at d4b99436.
"In a directory of mode 0700." A console/ loosened after it was made (0755, 0750, 0711) was accepted wherever no one else could write it. The writer and the reader now refuse it by name. A setgid bit inherited from a parent is accepted (a pin).
(b) The writer's and the entry points' regressions. One is another user's file at the name (a pin). The other runs through generate-and-open and through python -m opendox.serve: each of a symbolic link, a directory, a FIFO, a hard-linked copy and a loosened copy refuses the START by name. That means exit 1, nothing printed that serves, no browser, the planted thing untouched and the socket closed. The loosened copy failed first; the other kinds are pins.
(e) A served root named through a symbolic link that leads to the state directory or into it (console, postgres/run, the directory itself) is judged where it leads. Through the entry point, the case is a --web-dir link to <state>/console (pins, held by mutant M24).
The opener file's lifecycle, self-reviewed (write, replace, read, remove at stop, refuse before writing). Each finding below has a case that failed first at d4b99436, and a mutant:
an operating-system refusal on the way, such as a parent that will not let this user make the state directory or a full disk, escaped as a raw OSError with a traceback and no refusal by name. It is now a ConsoleAccessRefused naming the copy, through both entry points;
a write that fails part way removes its temporary file;
a copy whose read-back fails is removed with the refusal;
removal put another serve's copy back by a hard link only. Where links fail (EPERM: a filesystem without them, or a directory), that copy was deleted. It is now renamed back where the name is free;
SIGHUP, which a closed terminal sends, ended the process with the copy left behind. While a copy exists it is read as Ctrl-C, as SIGTERM is, unless the process was started ignoring it (nohup);
the signal handling covered only the serve loop. A SIGTERM while the browser opener ran, which can take seconds, took SIGTERM's default action on a hosted standalone plane and left the copy behind. It now covers the whole window from the write to the stop, in both entry points.
One walk of the state directory (fix round 4, 14285fbb)
Copilot's review at d4b99436, r4174785933. Copilot's probe reproduced here. With OPENDOX_STATE_DIR=alias/state, alias -> shared/hop and hop -> private, the tree rules judged the configured path's components and the directories above the resolved path. shared, reached only through a link's target, was neither, so at mode 0777 and not sticky it went unjudged. The write also walked the configured path again after the served-root check. So a hop re-pointed in between put the token's copy in a served root, at served/state/console/8080.html, where /source serves it.
_walked resolves the state directory once, component by component as the kernel walks it. Every directory it passes through is judged by the rule for directories above the state directory, and every link it follows by the rule for a link. The served-root boundary, the tree rules, the write and the read all work on the walked path. Both cases failed first at 182cac76:
test_a_directory_passed_through_by_an_intermediate_link_is_judged: Copilot's layout with shared at 0777. The write and the read are both refused by name. Before the fix: DID NOT RAISE;
test_a_link_swapped_after_the_checks_never_redirects_the_write: hop is swapped to a served root right after the served-root check. The copy lands where the checks saw the state directory, and the served root gains nothing. Before the fix, the copy landed in the served root.
Fix round 5 (9f328892)
Copilot's review at 182cac76, three threads. Each case failed first at ddb26c34:
r4175213798, the snapshot files./snapshot.json reads its file directly, not through the static handler. So a --snapshot named at an earlier copy, <state>/console/<port>.html, would have been replaced by the new copy and served to anyone. The served roots now hold the configured snapshot, each registered entry's snapshot, and, on a loopback plane, the session snapshots' container. The reverse boundary therefore refuses that start by name, through a link as well. Cases:
test_a_snapshot_inside_the_state_directory_refuses_the_start, which before the fix reported "the server served";
r4175213842, the rename-back race. Where a hard link fails, another serve's copy is renamed back where the name is free. A newer copy published between that check and the rename was overwritten. Every writer and remover of console/ now takes the directory's exclusive flock, so publication and removal are serialized. The case is test_a_copy_published_during_a_rename_back_is_never_overwritten: a third copy is published at exactly that moment, waits, and stands. Before the fix, "an older copy overwrote the newest".
r4175213864, a stop during publication. SIGTERM was read as Ctrl-C only after publish() returned. Both entry points now install the handler BEFORE publication, on any plane that writes a copy, and keep it through removal. A stop that arrives while the copy is written or removed is held (deferred_termination): publication raises it once the copy is in hand, and removal lets it go. Cases:
test_a_stop_during_publication_removes_the_copy, for both entry points;
Copilot's review at ddb26c34 raised the same stop for generate-and-open (r4177975845), and 9f328892 answers it.
Fix round 6 (9dcc9bb5)
Copilot's review at ddb26c34, r4177975898. The walk and the served-root check ran outside the writer's conversion of OSError. So an overlong state-path component (ENAMETOOLONG) or an unsearchable parent (EACCES) escaped as a raw OSError, and both entry points ended in a traceback instead of a named refusal. Both are inside the conversion now, and the reader converts the same way. The cases failed first at 9f328892:
test_a_state_path_the_walk_cannot_take_is_refused_by_name, for both kinds of path, through the writer and the reader;
test_a_state_path_the_walk_cannot_take_refuses_the_start, for both kinds through serve and through generate-and-open. Each exits 1 with the refusal named, prints nothing that serves, and closes the socket.
Fix round 7 (fb8a1cc4)
Copilot's review at 9f328892, r4178041022. SIGINT kept Python's immediate handler, so deferred_termination never held it. A Ctrl-C just after publication's rename raised before the caller held the copy, and the copy was left behind. A second Ctrl-C just after a removal's take left a .removing-* file holding the token. terminate_as_interrupt now takes SIGINT too, still raised as a KeyboardInterrupt, but only where it has Python's own handler; an ignored SIGINT, or a host's own handler, is left as it was. The cases failed first at 9dcc9bb5. Each one checks for the handler before it sends SIGINT, so a raw interrupt never aborts the test session:
test_ctrl_c_just_after_the_copys_rename_leaves_no_copy, for both entry points;
Copilot's review at fb8a1cc4, two threads. Each case failed first at fb8a1cc4:
r4178133814, two consoles on one port number. The copy's name is per port, so a console on 127.0.0.1 and one on ::1, sharing a state directory, replaced each other's copy, and the first console's printed path opened the second. The writer now takes an exclusive flock on the file it wrote, before the rename, on its own descriptor (_Reservation, held in PrivateCopy.reservation), and keeps it until the copy is removed. A later publication on that port asks for the lock without waiting. A held lock is a running console's, refused by name (… belongs to a console that is still running (pid N)), and its copy is left as it was. A free lock is a stale copy's, and it is replaced. Cases:
test_two_consoles_on_one_port_number_never_share_a_copy, with real IPv4 and IPv6 planes (failed first: DID NOT RAISE);
test_a_running_consoles_copy_is_never_replaced (failed first: DID NOT RAISE);
test_a_copy_whose_console_died_is_replaced, a pin: a subprocess writes a copy and exits, and the kernel releases its lock.
r4178133842, a root retargeted after publication./source resolves a declared root again on every request, so a root re-pointed at the state directory after publication served the copy. Every read that serves a file to any caller without the console check now judges the file it OPENED, by (st_dev, st_ino), against every name in the copies' directory (console_access.is_private_file). /source and /snapshot.json read through serve.read_unless_private. The static handler judges the file it would serve before the stdlib opens it, and judges what the stdlib opened; a file swapped in between is closed unsent and the connection closed. The identity also stops a hard link to the copy, which a path cannot tell apart. Cases, each answered 200 with the copy before the fix:
test_a_hard_link_to_the_copy_is_never_served, for the static bundle and the served checkout;
test_the_static_backstop_never_sends_a_copy_swapped_in_after_the_check, which blinds the first check and reads the raw response: the copy's bytes are never sent.
The adversarial review (fix round 9, 45958bf3)
The holder had an independent adversarial review of fb8a1cc4 and of round 8 run ahead of Copilot: 1 high, 3 medium and 6 low findings. It confirmed that round 8 answers both of Copilot's threads, and it found what follows. The reviewer's own cases are kept as written, under their ids (test_b1_…, test_b2_…, test_b3_…, test_b6a_… to test_b6c_…). The cases that pin B1, B2, B3, B4, B5, B8 and B9 failed first at the merged pre-fix tree.
B1 (high), a tokenless sibling plane. A standalone plane that minted no token (no git identity, so no session verbs) wrote no copy, so it asked no boundary and marked no private root. A root of its that held the shared state directory served a sibling plane's copy, token and all, to any local caller. The delivery is now the plane's, token or not (build_server), and publish on every standalone plane asks the boundary and marks the copies' directory private (console_access.guard_private_roots). Only the writing still needs a token. Cases: the reviewer's two real planes, where the tokenless plane now refuses its start by name; the same refusal in the process; and a tokenless plane whose --web-dir links into the state directory and whose checkout holds a hard link to the sibling's copy, which answers 404 for the copy, the listing and /source.
B2, a platform without the POSIX primitives. On Windows the standalone start ended in an AttributeError traceback. console_access.unsupported_platform() names what is missing, and the writer and the reader refuse by name before anything else, as bundle.unsupported_platform() does for T072, 13.1: the bundled PostgreSQL server, the local install's own child (plan 034) #69's bundle (holder's ruling). Cases: the reviewer's child with os.getuid, O_NOFOLLOW and O_DIRECTORY removed, which now exits 1 with serve refused: …; and the writer and the reader without O_NOFOLLOW, and without calls relative to a directory's descriptor.
B3, a browser that cannot open the copy. Ubuntu's default snap browser cannot read a file under a hidden directory such as ~/.local/state, and a Windows browser under WSL may not open a Linux path. There was no way past it, because the token is never printed. RULED by Brett (2026-10-04, "Hint line, accepted limit (Recommended)"): beside the copy's path, the start prints ONE line with no token, saying to set OPENDOX_STATE_DIR to a folder that is not hidden and start again (console_access.UNOPENABLE_HINT). The case runs both entry points, finds the line once, right after the copy's path, and finds the token in no line printed. The README's side is openDox#17's (T076). See "Known limits" below.
B4, a second spelling. The served-root overlap and the static handler's guard compared spellings. On a case-insensitive filesystem (macOS's default), <root>/STATE is <root>/state and /state-alias/CONSOLE/ lists console/, while resolving a path keeps the case it was given. Both checks now also compare the directories' (st_dev, st_ino), the copies' directory's own included (within_private_roots). Linux cannot spell one directory two ways without root, so the cases simulate a case-insensitive filesystem by telling os.stat and os.listdir that the second spelling is the first: the boundary for the state directory, a root inside it and a root holding it, and the static listing. The reviewer's macOS reading is derived from the code, not observed on a Mac, and the same holds here.
B5, a second stop. A double Ctrl-C, or a SIGTERM and then a closing terminal's SIGHUP, could land its second signal after the first had unwound the serve loop and before the cleanup's hold. That second interrupt escaped the finally and left the copy. The first stop is now latched, and every later one is only recorded. Cases: the reviewer's child, which delivers the second stop at exactly that point and now exits 0 with no copy and no traceback; and the latch in the process.
B6, three rules no case pinned. The walk's link-owner check, the reader's re-judging of the tree, and the fchmod under a umask that strips owner write each had a mutant the suite let live. The reviewer's three cases kill them (M46, M47, M48).
B7, browser history. Accepted by the holder as a limit within the ruled design. See "Known limits" below.
B8, --no-serve. It opened a copy for a server it then closed, and deleted the copy on return. It now publishes, opens and prints no copy. The page's URL is still printed and opened, as before T104, and carries no token. The cases that read a copy now serve once and stop at a Ctrl-C (stopped_once_serving), and each refusal case asserts that it never served.
B9, a copy left by a crash. A SIGKILLed serve's copy stayed until a later serve took its port. A publication now sweeps, under the console directory's lock, every copy whose reservation is free, and every temporary or taken name that a dead writer or remover left. Nothing else is touched: not a running console's copy, not a loosened copy (which 12.4a refuses and never replaces), not a link, and no file of another name. Cases: the sweep's rules in the process, and the reviewer's SIGKILL across two real serves.
B10, the body. This body was rebuilt from the evidence at the head: the suite, every mutant, the browser and the governed run.
Mutant run 16 at fb8a1cc4 also left one mutant alive: M32, where the configured snapshot is not a served root. Every case named the snapshot at a copy that already existed, so the registered entry's own snapshot (M32b's line) refused it either way. 9fe57dff adds a snapshot named at <state>/console/<port>.html before that copy exists, which only the configured snapshot's own served root refuses.
Two follow-ups after round 9. c4e6c01b adds B3's hint line, once Brett had ruled it. 0539f8c0 answers mutant run 18 at 45958bf3, whose one survivor was M36c: the removal never closed the descriptor that reserved the copy, and nothing asked about it. test_a_removal_releases_the_copys_reservation now asks that, after a removal and where the directory is gone already, no descriptor of this process is the copy's file. That case's first failure printed the copy's repr, and with it the token. So opened_url is kept out of PrivateCopy's repr, and test_a_copys_repr_never_carries_its_token pins it.
Fix round 10 (af2a2efb)
Copilot's review at 0539f8c0, two threads, both on round 9's tokenless guard. Each case failed first at 0539f8c0:
r4179091592, one walk for the tokenless plane. The boundary check and the marking each resolved the configured state path for themselves. A link on that path re-pointed between the two left the boundary judging the real state directory and the marking naming a decoy, and an outward static link then served a sibling plane's copy. guard_private_roots now walks the state directory once (_walked), judging every directory and link on the way as the writer does, and the boundary and the marking both use that walk's path. Cases:
test_a_tokenless_planes_state_link_retargeted_mid_guard_marks_the_real_directory, Copilot's layout. The sibling's copy and the listing stay 404;
test_a_tokenless_planes_unsafe_state_path_refuses_its_start: a world-writable, non-sticky directory on the way refuses the tokenless start by name.
r4179091624, the platform first. A tokenless plane skipped the platform check, started, and marked a private root that its handlers then judged with the missing O_NONBLOCK. publish and the guard now refuse an unsupported platform by name before anything else, token or not. Cases:
test_a_tokenless_plane_refuses_a_platform_without_the_primitives, in the process;
test_a_tokenless_start_without_the_posix_primitives_refuses_by_name, the no-identity variant of B2's child.
Fix round 11 (ed6e4769)
Copilot's review at af2a2efb, three threads and one finding in the review's body. Each case failed first at af2a2efb:
r4179239380, another state directory. Two standalone planes of one user can have different OPENDOX_STATE_DIR values. The guard knew only its own plane's copies, so if plane A's web root linked to plane B's state directory, A served B's copy. is_private_file now first judges the file it was given by what that file holds. A regular file whose head carries a console record is a copy, wherever it lies (_carries_a_console_record); the head is read with pread from the descriptor already open. Case: test_another_state_directorys_copy_is_never_served, through a static link and through a hard link under /source.
r4179239411, a removal mid-read. A copy removed after a read opened it, and before the scan could stat its name, matched nothing in the directory. The open file still holds its record, so it is refused. Case: test_a_copy_removed_during_the_scan_is_never_served.
r4179239424, a check that cannot be made. These used to let the file through, and each now denies it:
a private directory that exists but cannot be listed (EMFILE, EACCES);
a name in it whose status cannot be read, for any reason but its removal;
a regular file whose head cannot be read.
A private directory that does not exist still holds no copy. Cases:
test_a_private_directory_that_cannot_be_scanned_denies_the_read, with EMFILE simulated, and with EACCES;
Previously missed, an entry's payload. On a standalone plane, an entry with both an in-memory payload and a snapshot file served the file, and a 404 where the file was missing. That broke SnapshotEntry.read_bytes' payload-first contract. The payload comes first again, and only the file fallback is guarded. Case: test_an_entrys_payload_comes_before_its_guarded_file, with the file missing, present, and a private copy.
Fix round 12 (a2e36652)
Copilot's review at 1e114a19, r4179793524. Round 11 recognized a copy in another state directory only by its whole record. So another plane's temporary file, part-written with the token in its meta refresh and no record yet, passed the static handler, /source and /snapshot.json. A file that grew after it was judged passed too, because the stdlib copies a static file to its end as it is when read.
A marker first. The writer now begins every copy with COPY_MARKER, an HTML comment, ahead of any byte of the token. A copy is written from its start, so any part of it that holds a byte of the token already holds the whole marker. is_copy_bytes recognizes a copy by that marker, or by its whole record.
What is read is judged.read_unless_private reads first, then judges what it read, as well as the file by its descriptor.
What is sent is bounded and judged. The static handler's copyfile sends at most the length the file had when it was judged. It reads the body's first bytes before sending anything and judges them, so a copy's bytes are never sent.
Cases, each failing first at 1e114a19:
test_another_state_directorys_partial_copy_is_never_served, Copilot's layout, through all three readers, GET and HEAD;
test_a_copy_replaced_after_its_read_is_never_returned, which pins the read-first design.
The identity match against this plane's own console/ remains as defence in depth. 1e114a19 had pinned it with a part-written copy (mutant run 23 left M38 alive); with the marker such a copy is known by its bytes, so test_a_file_in_the_copies_directory_is_refused_by_its_place now holds a token-bearing file there that the marker cannot recognize.
Fix round 13 (c5fcdfa4)
Copilot's review at a2e36652, r4180089809. urlsplit reads http://evil.example\@127.0.0.1:8080/index.html as user information at 127.0.0.1. A browser takes the backslash for a slash and navigates to evil.example, whose page could then read the token's fragment. The entry points build their own URLs, but write_private_copy and opened_url are public. _refuse_page_url now requires the authority to be exactly a loopback host, spelled as serve.server_url spells it, with an optional port of at most 65535. A backslash or a control character anywhere in the URL is refused. Cases:
test_a_page_url_a_browser_reads_as_another_host_is_refused, eight URLs, Copilot's first. Seven failed first at a2e36652;
The holder found orphaned python -m opendox.serve children of these cases on the machine, hours old. Each was plane B of the B1 case, left by a mutant run: where a mutant made B serve instead of refusing, the case failed, and its finally stopped plane A only. Every server child the cases start now runs in its own session, and is reaped with its process group at teardown, pass or fail: SIGTERM, a bounded wait, then SIGKILL. On Linux it also gets SIGTERM from the kernel if the test process dies first (PR_SET_PDEATHSIG), since a killed run runs no teardown. Cases: test_a_server_left_running_is_reaped_with_its_group and test_a_server_outlives_no_killed_run. Re-run under mutant M44, the B1 case fails as it must, and no server from that run survives.
Known limits, accepted for release 1
After a serve restart, a tab opened against the old serve holds a stale token in its sessionStorage. The fixed "reload the page" messages (doxbench-chat.js, staging-workbench-model.js, T102's area) then name the wrong remedy on a standalone plane: a reload keeps the stale token. The remedy is the new tab the restart opened, or the new console file. The holder ACCEPTED this for release 1 and passes the copy change to T102's writer.
Some browsers cannot open the copy (adversarial review B3). Ubuntu's default snap browser, and Flatpak browsers, are kept out of hidden directories such as ~/.local/state, and a Windows browser under WSL may not open a Linux path. Brett ruled this an accepted limit for release 1, with a hint ("Hint line, accepted limit (Recommended)", 2026-10-04): the start prints one line, with no token, saying to set OPENDOX_STATE_DIR to a folder that is not hidden and start again. The README's side is openDox#17's (T076).
The browser's persistent history keeps the fragment (adversarial review B7). history.replaceState strips the token from the address bar and from the tab's session history, but the browser's own history store (Chromium's Default/History) has already recorded the opened URL, fragment included. The holder ruled this a limit within the ruled design: the history file is this same user's data, as the 0600 copy is, and it is not served or sent anywhere.
Tests
tests/test_console_token_delivery.py (new):
a standalone plane carries no token on /capabilities, under no key and in no byte;
a host's plane keeps it there and writes no copy;
a second OS user cannot obtain it. First, by asking: 102 GET and HEAD requests (51 paths: the whole bundle of 42 files, /, /capabilities, the snapshots, the project register, /source, the guarded reads without the token), and 8 POST routes. No body and no header carries it. Second, by the copy: the file is 0600 and the directories 0700, all this user's. A copy owned by another uid is refused by the reader (simulated through getuid);
the opened URL, the meta refresh and the link carry the token in the fragment only, with an empty query; a page URL that already has a query or a fragment, is not http, or is not loopback is refused;
the record cannot break out of its <script>: a hostile </script><img …> value stays inside, and the record round-trips;
generate-and-open hands the opener a file:// path, prints the copy's path and never the token, and removes the copy; --no-open opens nothing and still prints the path. These cases serve once and stop at a Ctrl-C, since --no-serve writes no copy;
an unsafe state directory refuses the run, naming the directory;
the copy is born 0600 in a 0700 tree even under umask 0;
these are refused: a planted link, a dangling link (nothing is created at its target), a hard link, a loosened copy (0644), a planted directory, a linked console/, a group- or world-writable state directory, and a non-sticky shared parent (a sticky one is accepted);
a later copy replaces this user's earlier one, and removal is exact;
every guarded route refuses without the token, or with a wrong one, and passes the console check with the copy's token;
the served-root boundary: the state directory equal to the served root, under it, under a declared source root, through a link into it, and through generate-and-open. Each is refused by name, and nothing is written;
the reverse boundary: a served root that is <state>/console, <state>/postgres/run, a deeper path under the state directory, or the state directory itself. Each is refused by name, and nothing is written. Separately, a link in a source root that points at the state directory gets 404 from /source;
the plane reports its served roots: the checkout and each declared source root;
fix round 3: a directory's index page linked to a copy is never served, for either index name; a FIFO at the copy is refused at once by the reader and by the writer;
12.4a: the writer refuses a loosened own copy and another user's file, and leaves each as it was. Both entry points refuse their start by name for each of five planted kinds. A served root named through a link into the state directory is refused. A console/ that is not 0700 is refused, and a setgid one is accepted;
the lifecycle: an unwritable state directory and a full disk are refusals by name, and no partial file is left. A failed read-back leaves no copy. Another serve's copy survives a removal where hard links fail. SIGHUP removes the copy at all three entry points, and a nohup SIGHUP stays ignored. A SIGTERM while the browser opens removes the copy, hosted and local;
one walk: a directory passed through by an intermediate link is judged, and a link swapped after the checks never redirects the write;
fix round 5: a snapshot inside the state directory refuses the start, through a link too. The snapshot files are reported as served roots. A copy published during a rename-back is never overwritten. A stop during publication, at either entry point, or during removal leaves no copy;
fix round 6: an overlong or unsearchable state path is a refusal by name, for the writer, the reader and both entry points;
fix round 7: Ctrl-C right after the copy's rename, or right after a removal's take, leaves nothing behind. Ctrl-C is taken only from Python's own handler;
fix round 8: a running console's copy is never replaced, by a second publication or by a real IPv6 plane on the same port number, and a dead console's copy is. A source root or a snapshot retargeted after publication, a hard link to the copy in the bundle or the checkout, and a link swapped after the static check never serve the copy;
fix round 9 (the adversarial review): a tokenless standalone plane refuses a state directory inside its checkout, as a real second plane and in the process, and never serves a sibling's copy through a link or a hard link. A platform without the POSIX primitives is refused by name, through serve and by the writer and the reader. A second spelling of the state directory, of a root inside it, of a root holding it, or of console/ is judged by its identity. Only the first stop is raised, so a second one never leaves a copy. The walk's link-owner rule, the reader's re-judging of the tree and the fchmod under umask 0o277 are pinned. --no-serve writes, opens and prints no copy. A dead console's copy, temporary file or taken name is swept, and nothing else is. A snapshot named at a copy not yet written refuses the start. Both entry points print the hint line once, right after the copy's path, and no line they print carries the token. A removal releases the copy's reservation, and a copy's repr never carries its token;
fix round 10: a tokenless plane whose state link is re-pointed mid-guard marks the real directory, and the sibling's copy stays 404. A tokenless plane refuses an unsafe state path, and a platform without the POSIX primitives, by name, in the process and as a user starts it;
fix round 11: another state directory's copy is never served, through a static link or a hard link. A copy removed mid-read is still refused. A private directory that cannot be listed, a name whose status cannot be read, and a head that cannot be read each deny. An entry's payload comes before its guarded file;
fix round 12: a copy begins with its marker, before any token byte. Another state directory's part-written copy is refused by the static handler, /source and /snapshot.json. A file that grows after its checks, or is emptied after its read, never hands out a token.;
fix round 13: a page URL whose authority a browser reads as another host (a backslash, user information, a bad port) is refused, and nothing is written; every loopback spelling a plane announces is accepted;
no test server outlives its run: a server left running is reaped with its process group, and a child outlives no killed run;
end to end, as a user runs it: python -m opendox.cli generate-and-open --local --no-open and python -m opendox.serve, each in a child process with neither sibling importable.
tests/test_console_token_view.py (new, node): fragment taken, kept and stripped; a host's token wins; the degraded probes; a reload keeps the token from storage; the query string is never read; a malformed token is stripped and not kept; blocked storage keeps the token in memory.
These standalone children read the token from the private copy (standalone_child.Child.console_token), because they used to read it from /capabilities:
test_capability_honesty.py, its 4 standalone cases and the unknown-tile-kind thread read;
T103's test_loopback_host_gate.py real local serve, which now asserts that no token is on /capabilities and that the copy exists exactly when a token is minted.
Mutants, 90 of 90 killed (each applied alone, both new test files run, in a throwaway worktree of c5fcdfa4):
mutant
failed
M1 token back in /capabilities
4
M2 query string instead of fragment (server)
6
M2b page reads the query string instead of the fragment
4
M3 the file at 0644 (one constant)
8
M3b the file written 0644, reader unchanged
89
M4 no link check at all
18
M4b no planted-target check on write
15
M4c the read follows a link
1
M5 the page never strips the fragment
3
M6 no served-root boundary
19
M6b the boundary checks equality only
8
M6c the plane reports no served root
9
M7 removal puts no replacement back
3
M8 a plain kill is not read as Ctrl-C
its own SIGTERM ended the run (rc -15), after 5 failures
M9 the static bundle is not a served root
3
M9b the sessions container is not a served root
2
M11 the static handler serves a private copy
4
M12 no reverse check (a served root inside the state dir)
9
M13 /source follows a link out of its root
1
M14 a directory request serves its index page unjudged
2
M15 the read blocks on a FIFO
1
M16 the writer replaces a loosened own copy (12.4a)
3
M17 the console directory's mode is not judged (12.4a)
3
M17b the setgid bit counts as a loosened mode
1
M18 an operating-system refusal escapes raw
8
M19 a failed read-back leaves the copy
1
M20 no rename-back where a hard link fails
2
M21 a hangup is not read as Ctrl-C
4
M21b nohup's ignored hangup is overridden
1
M22 cli: the handler covers only the serve loop
3
M23 a partial temporary file is left
1
M24 a served root is not judged where its link leads
2
M25 the walk judges no directory it passes through
2
M26 the write is not anchored to the walk
1
M30 serve: the handler is installed only after publication
5
M31 a stop is never held
1
M32 the snapshot file is not a served root
1
M32b a registered entry's snapshot is not a served root
1
M32c the session snapshots' container is not a served root
1
M33 a removal takes no lock
1
M33b a publication takes no lock
1
M34 the walk runs outside the writer's conversion of OS errors
6
M34b the reader converts no OS error
2
M35 Ctrl-C is not held
5
M35b a host's own Ctrl-C handler is overridden
1
M36 a running console's copy is replaced
2
M36b the copy is never reserved
3
M36c removal keeps the reservation
1
M36d removal keeps the reservation where nothing is left to remove
1
M37 /source reads unguarded
5
M37b a registered snapshot reads unguarded
3
M37c the static handler judges no identity before it opens
4
M37d the static backstop sends what the stdlib opened
1
M38 is_private_file matches no identity
1
M39 the boundary ignores identity (B4)
3
M40 the static guard ignores identity (B4)
1
M41 the first stop is not latched (B5)
2
M41b a held stop is raised after the first (B5)
1
M42 no sweep (B9)
2
M42b the sweep ignores reservations (B9)
1
M42c the sweep removes what is not this user's own copy (B9)
1
M43 --no-serve publishes (B8)
1
M44 a tokenless plane guards nothing (B1)
5
M44b the delivery depends on the token (B1)
7
M44c a tokenless plane marks no private root (B1)
2
M44d a tokenless plane asks no boundary (B1)
3
M45 the writer refuses no platform (B2)
1
M45b the reader refuses no platform (B2)
2
M46 (reviewer m2) the walk never judges a link's owner (B6a)
1
M47 (reviewer MA) the reader never re-judges the tree (B6b)
1
M48 (reviewer m1) no fchmod (B6c)
1
M49 (reviewer MC) the page keeps the token in localStorage
2
M50 generate-and-open prints no hint line (B3)
1
M50b serve prints no hint line (B3)
1
M51 a copy's repr carries its token
1
M52 the tokenless guard resolves the state path again to mark it
1
M52b the tokenless guard does not walk
1
M53 a tokenless plane skips the platform check
2
M54 a copy is not known by what it holds
3
M54b a head that cannot be read is allowed
1
M55 a directory that cannot be listed allows
2
M55b a name whose status cannot be read allows
1
M56 an entry's file comes before its payload
3
M57 a copy is written without its marker first
4
M57b a copy's marker is not recognized
4
M58 the reader judges none of the bytes it read
1
M58b the reader judges the file before it reads it
2
M59 the static body is copied as the stdlib copies it
1
M61 the page URL's authority is not judged exactly
4
M61b a backslash or a control character passes
2
M10 (the copy's own path not judged) is retired with the check it mutated: the reverse rule replaced that check, and M12 is the mutant that removes the reverse rule.
The browser, end to end. Chromium (Playwright 1.61.0, the T096 prep harness's driver) ran opendox generate-and-open --local --no-open from a fresh install. The first run was on a LOCAL, never-pushed integration of this branch with #77 and main. It was re-run at 60bace00, at 182cac76 and at c5fcdfa4, which carry both. 18 of 18 checks passed every time:
the private copy (0600) forwards to /index.html;
the address bar and the tab's session-history entry carry no fragment. The browser's persistent history still records the opened URL; see "Known limits" above;
sessionStorage holds the token, and probeCapabilities fills it;
the page's own /capabilities has none;
the catalog answers 200 with the token and console_required without it;
a reload keeps the token, and a new tab at the bare URL has none;
no request URL or Referer carries the token, and there was zero pageerror;
nothing the server printed carries the token;
the copy is gone after SIGTERM, and no bundled PostgreSQL is left.
The whole suite, locally (tests and tests_runtime, with --basetemp and TMPDIR outside the tree):
commit
passed
skipped
6db50b03
3415
177
cb899546
3422
177
c979747a
3424
177
60bace00
3774
177
d4b99436
3821
177
a13857ad
3851
177
182cac76
3858
177
14285fbb
3860
177
ddb26c34
3894
177
9f328892
3901
177
9dcc9bb5
3907
177
fb8a1cc4
3911
177
0539f8c0
4078
177
af2a2efb
4082
177
ed6e4769
4091
177
d466c1d2
4093
177
1e114a19
4094
177
a2e36652
4099
177
c5fcdfa4, the head
4111
177
At the head, 0 failed. The count grew with the merges: #80's final head carried main's landings since d0f1efcb (#63, #69, #73, #64, #72 and #77), and then #81, #85, #76 and #82 landed. CI's own reading at c5fcdfa4 is selected=4288 passed=4277 skipped=11, against validate.yml's floors 3977 / 3966 and its exact 11.
The governed path
openxFactory's existing token-reading suites were run twice, locally only. First against the pinned openDox-code 047bb4fa, then against 047bb4fa with T104's commits applied. The last such run applied every server-side change through c5fcdfa4, at local f18e3342. That is the head's whole server side, rounds 8 to 13 included. cli.py's hunks are left out there: the governed host serves through opendox.serve, and the pin's cli.py predates T084's refactor. The suites are tests/ideation-dashboard/test_doxbench_routes.py, test_gate_routes.py, test_shared_identity.py and test_staging_seed.py, and they read caps["console_token"] through _console_token. The result is identical: 239 passed, 1 failed at both. The one failure is the same pre-existing case (test_lens_add_as_cluster_lands_manifest_pending_and_record, 409). Nothing in openxFactory changes, so there is nothing for T094.
For T086 (openXdox-code), recorded with the holder. openXdox-code 6a3b93b9's route harnesses (tests/doxbench_routes_harness.py:290, tests/gate_routes_harness.py:139-149) build the server with no host profile registered, so by this PR's rule their plane is standalone, and they read caps["console_token"]. Composed with openxFactory's scripts/ for doc_health, 90 cases there fail with KeyError: 'console_token'. All 90 are in 6 files that its tests/declared_exclusion.yaml already excludes (doc_health), so its CI does not run them. When its openDox pin passes this PR, those harnesses read httpd.console_token, or register openXdox's profile as a host.
Batch N, and T095
T007's batch N landed in openxFactory#1222 (bdd0f586): #1144 12.4a's amendment is the normative text this PR realizes (see "12.4a, clause by clause" above). F10.1, F13.1, F16.1 and F12.x are unaffected. Plan 034's quickstart § 3 and § 4 step 1, and AT-R1 step 4, read the private copy.
T095's harness (openDox-code#75, not edited here) reads the private copy at <state_dir>/console/<port>.html instead of /capabilities. The holder has its proposed helper and the three line changes. The helper never opens a copy that failed its checks, so a FIFO cannot block it.
…a collapsed DSN pair (plan 034)
Realizes #1144 13.2 and 13.3, falsifier F13.1's `load_settings` block.
- 13.2: `_refuse_non_postgresql_dsn` refuses either DSN (`OPENDOX_DATABASE_URL`
or `OPENDOX_MIGRATION_DATABASE_URL`) whose URI scheme is not `postgresql://`
or `postgres://`, naming the setting and the dialect kept. The keyword/value
conninfo form (`host=h dbname=d …`) names no dialect at all and is
unaffected — that syntax is libpq's own grammar, and no other driver reads
it.
- 13.3: `OPENDOX_MIGRATION_DATABASE_URL` stops being optional in
`load_settings` (the `Setting` row's `required` flag, `_require` in place
of `_optional`, and `RuntimeSettings.migration_database_url`'s type). A new
`_refuse_the_same_dsn_in_both_settings` refuses the two DSNs being the exact
same STRING, naming `OPENDOX_MIGRATION_DATABASE_URL`, once they are already
known to agree on where they land
(`_refuse_two_dsns_that_select_different_schemas`, unchanged, now called
first): two DIFFERENT secrets for one role still pass, as the existing
"single-role install" case documents.
- Explicitly NOT in this task: 13.4-13.6 (`OPENDOX_INSTALL_MODE`, T070).
Nothing here reads or names that setting, and `load_settings`'s only new
required input is the migration DSN itself.
Every existing call site that built an environment without
`OPENDOX_MIGRATION_DATABASE_URL` needed one once it became required:
`tests_runtime/conftest.py` gains a `migration_dsn` fixture (a `postgres_dsn`
distinguished by a URI fragment, invisible to every DSN reader this module
has); `test_api_endpoints.py`, `test_migrations_apply.py`,
`test_runtime_cli.py` and `test_runtime_surface.py` thread it or a literal
peer through. `test_two_dsns_that_select_different_schemas_are_refused`'s
"a migration DSN that is simply absent" case is rewritten from accepted to
refused, which is the behavior 13.3 changes. Two new tests
(`test_a_non_postgresql_dsn_is_refused_naming_the_dialect_kept`,
`test_the_same_dsn_in_both_settings_is_refused_naming_the_migration_one`)
cover the two new refusals directly.
Measured locally against this change (own Postgres container, bridge IP —
this sandbox's host-mapped loopback ports are unreachable): `python -m
pytest -q` reports 2469 passed, 11 skipped, 1 failed — the one failure is
`tests/test_model_provider_broker.py::test_the_broker_child_inherits_no_
credential_shaped_environment`, already red against unmodified `main`
(2d11641) in the same environment (an `LC_CTYPE` ambient in this sandbox,
unrelated to runtime/config.py). Against `main`'s own reading (2479
selected / 2468 passed / 11 skipped, matching this repo's last recorded CI
triple), this change is +2/+2/+0 for the two new tests — `validate.yml`'s
`Pin the triple` floors (`MIN_SELECTED=2476`, `MIN_PASSED=2465`,
`EXPECT_SKIPPED=11`) permit the rise unchanged.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…(Copilot review of this PR)
`urlsplit` itself raises for a DSN it cannot parse — MEASURED,
ValueError("Invalid IPv6 URL") for an unbracketed IPv6 host, which
tests_runtime/conftest.py's own postgres_dsn docstring names as "the
ordinary way to mis-set this variable". `_refuse_non_postgresql_dsn` called
`urlsplit(dsn).scheme` unguarded, so that ValueError escaped load_settings
as a bare exception instead of the promised ConfigurationError — the CLI's
boundary catches only ConfigurationError, so a malformed OPENDOX_DATABASE_URL
or OPENDOX_MIGRATION_DATABASE_URL would have printed a traceback instead of
a redacted refusal.
Wrapped the same way _split_url already wraps it for the broker settings
(Copilot review of openDox-code#25, round 24), with DSN-appropriate wording
rather than reused verbatim ("set it to the broker endpoint" does not fit
a database DSN). New test
test_an_unparseable_dsn_is_refused_and_never_raises_a_bare_valueerror
proves both DSNs are covered and that the value is never repeated in the
message.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… only for migrate
Brett ruled on the held conflict (openxFactory#656, on the claim thread for
plan 034's T071, 2026-09-28), choosing "Required only for migrate
(Recommended)" over making the setting required everywhere:
- OPENDOX_MIGRATION_DATABASE_URL goes back to OPTIONAL in `load_settings`
(the `Setting` row's `required` flag, `RuntimeSettings.migration_
database_url`'s type back to `str | None`, `_optional` in place of
`_require`). `load_migration_settings` is unaffected either way — it
already independently required one, for `migrate`/`reset` alone.
- Both refusals from the previous commits stay, and are now no-ops on an
ABSENT migration DSN rather than being unreachable: `_refuse_non_
postgresql_dsn` and `_refuse_the_same_dsn_in_both_settings` each return
early when the migration value is falsy, exactly the way `_refuse_two_
dsns_that_select_different_schemas` already treated "nothing to compare"
as nothing to fault. When BOTH are given, every check still runs, in the
same order as before (dialect, then schema-mismatch, then collapse).
It is never defaulted from OPENDOX_DATABASE_URL.
- This matches #1144 13.3's own text and `deploy/compose/docker-compose.
yaml`'s separation (the `opendox` service never gets a migration DSN;
`docs/runtime.md` § 3 never lists it as required) — neither file needed
a change; both already said the now-ruled behavior. The plan's "stops
being optional" line is a holder-side correction, not part of this PR,
and #1144's own wording is unchanged.
Reverted the 27-call-site ripple the `required` flip had forced, now that
it is not needed: `tests_runtime/conftest.py`'s `migration_dsn` fixture is
gone; `test_api_endpoints.py`, `test_migrations_apply.py`, `test_runtime_
cli.py` and `test_runtime_surface.py` are back to threading only the
served DSN through every call site that does not itself test the
migration path. All four files after conftest.py are byte-for-byte
`main` again. `test_two_dsns_that_select_different_schemas_are_refused`'s
"absent migration" case is back to ACCEPTED (with a note on why it was
briefly the opposite), which is what the setting being optional again
means for that test.
Added three tests showing the ruled behavior, at the CLI dispatch level
rather than only `load_settings` directly, next to the existing `migrate`
counterpart:
- `test_serve_and_status_load_with_no_migration_dsn_configured`: `status`
reports no configuration refusal and `settings[…MIGRATION_DATABASE_URL]
` as `null` with only the served DSN set; `serve` starts (`ok: true`)
the same way.
- `test_the_collapse_is_refused_through_the_served_workload_too`: 13.3's
collapse refusal still fires through `status`, not only through
`load_settings` called directly, the moment both DSNs are given and are
the same value.
- `test_migrate_refuses_rather_than_borrowing_the_served_identity`
(pre-existing, untouched) already covers "migrate refuses without it".
Measured locally against this change (own Postgres container, bridge
IP): `python -m pytest -q` reports 2472 passed, 11 skipped, 1 failed —
the one failure is the same `tests/test_model_provider_broker.py::
test_the_broker_child_inherits_no_credential_shaped_environment` LC_CTYPE
sandbox artifact already characterized as pre-existing and unrelated in
the first commit on this branch. Against main's 2479 selected / 11
skipped in this same environment, this change is +5/+5/+0 (five tests:
the three already on this branch plus the two new ones above) —
`validate.yml`'s `Pin the triple` floors (`MIN_SELECTED=2476`,
`MIN_PASSED=2465`, `EXPECT_SKIPPED=11`) permit the rise unchanged, and
the exact skip count is unchanged.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ttings has
Copilot review of this PR (thread on _refuse_non_postgresql_dsn's own
definition): 13.2's dialect gate was wired into `load_settings` only.
`load_migration_settings` — the loader `runtime migrate`/`reset` actually
use — read OPENDOX_MIGRATION_DATABASE_URL, checked only that it was
non-empty, and handed it straight to `Database`, so a non-PostgreSQL
migration DSN (`sqlite:///x.db`, say) reached the driver instead of being
refused by name at configuration. That is the same un-named failure 13.2
exists to prevent for the served loader, just reachable through the one
path F13.1's falsifier does not call.
One call to the existing `_refuse_non_postgresql_dsn`, right after the
existing empty-DSN refusal and before `database_url`/`migration_database_
url` are both set to the same value. New test
`test_migrate_refuses_a_non_postgresql_migration_dsn_at_configuration`
is the dialect-refused twin of the existing `test_migrate_and_reset_need_
no_served_identity_and_no_broker`, which already shows an unreachable but
valid-dialect migration DSN getting PAST configuration — this one shows a
wrong-dialect one refused AT configuration, naming the setting and never
repeating the DSN.
Measured locally (own Postgres container, bridge IP): 2473 passed (+1),
11 skipped, 1 failed (the same pre-existing, unrelated LC_CTYPE sandbox
artifact) — the new test is the only change to the count.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n --local (plan 034)
OPENDOX_INSTALL_MODE (`local` | `hosted`, default `hosted`) is read in
runtime/config.py beside OPENDOX_OIDC_ISSUER and decides the install shape
(#1144 13.4). `generate-and-open --local` makes the same selection
(R1Q15 (b), as T007 batch H's 13.4 addendum reads); with neither the install
is hosted (13.5).
- A flag and a setting that disagree (`--local` beside
OPENDOX_INSTALL_MODE=hosted) are refused, naming both. This is plan 034's
fail-closed reading (Principle VII); no answer rules it and batch H does
not write it into #1144.
- LOCAL needs no broker: issuer, audience and key-set URL are empty.
- LOCAL binds loopback only, with no opt-in. A non-loopback `--host` or
OPENDOX_BIND_HOST is refused, naming the rule. The set is serve.py's own
LOOPBACK_HOSTS, and a test holds the two equal.
- HOSTED, set or by default, with no issuer refuses, naming
OPENDOX_OIDC_ISSUER. generate-and-open asks the issuer first, so a run with
nothing configured names it and `--local`. The hosted mode is otherwise
unchanged (13.6).
Holder readings on openxFactory#656 (Brett may overrule):
- `runtime serve` refuses under local, because the API's identity is the
broker's.
- `runtime status` under local reports broker_keys "not configured (local
mode)" and does not count it as a fault.
- A broker setting beside local is refused by name.
- An unrecognised mode value is refused, case-sensitively.
The document server's generate-and-open resolves the shape before it scans,
mints or binds anything. The hosted path loads the whole runtime
configuration (R1Q16 (i); 13.4a).
Also:
- deploy/compose/.env.example gains OPENDOX_INSTALL_MODE=hosted, which
test_every_runtime_setting_is_documented_in_env_example requires of every
SETTINGS entry.
- tests/test_doxbench_entrypoint.py's fixture now selects `--local` and
scrubs the runtime settings, since the unset default is hosted and refuses
with no issuer.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hild (plan 034)
A LOCAL install (T070's `generate-and-open --local`, or
OPENDOX_INSTALL_MODE=local) now brings its own database (#1144 13.1, as
T007 batch H's addendum reads; RULED R1Q16 (i)-(iv), 5850003126).
- (i) `generate-and-open --local` starts a PostgreSQL server as its own
direct child (subprocess.Popen, never pg_ctl) and reports it. The
document server a user reaches is the process that owns it.
- (ii) The server is started AND migrated: initdb once, an idempotent
bootstrap (the database, the served role, and the compose stack's grants
narrowed to this install's owner), then migrations.MigrationRunner as the
owner, with the served role and database declared.
- (iii) It ships as the `opendox[local]` extra: `opendox[runtime]` plus
`pgserver>=0.1.4`, whose bundled binaries link only libc and libz. The
`test` extra joins it, so F9.1's `.[test]` install still runs every case.
- (iv) It stops with the entry point. SIGTERM is read as the Ctrl-C the
serve loop already stops on, followed by a fast shutdown.
PR_SET_PDEATHSIG is the backstop when the entry point is SIGKILLed.
- Its data and socket directories live under OPENDOX_STATE_DIR, a new
setting that defaults per user and must be absolute. The server listens
on a 0700 Unix socket with listen_addresses empty: no TCP listener at all.
- Both DSNs are supplied: two users over the one socket, which pass T071's
three checks. An operator DSN beside `local` is refused by name, joining
T070's broker settings (a holder reading on openxFactory#656).
- `runtime status` reports database_bundle (data_dir, socket_dir, pid).
`runtime migrate` under local migrates the bundle.
THE MIGRATIONS GAP (assigned to T072 by the holder). pyproject maps
migrations/*.sql into the wheel's data directory (share/opendox/migrations),
without moving the root migrations/ that the image copies. An unset
OPENDOX_MIGRATIONS_DIR is `migrations` wherever the working directory has
one (today's default, unchanged), and otherwise the copy the installed
distribution records. A test builds the wheel, installs it outside the
checkout, runs from a directory with no migrations/, and migrates the
bundled server.
Also:
- deploy/compose/.env.example gains OPENDOX_STATE_DIR=, because every
SETTINGS entry is named there.
- tests/test_doxbench_entrypoint.py stands the bundle in, since those cases
test the model port.
- T070's own tests stop passing DSNs beside `local`.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…xtra's setuptools
The lock is extended under its own pins (`-c` this file), in a clean
cpython 3.12.3 venv on linux x86_64, as its header asks. Five pins are new:
- pgserver 0.1.4, with its own psutil, platformdirs and fasteners;
- setuptools, for the wheel-install test's offline build.
No earlier pin moved.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…it config (Copilot review)
`tests/test_install_mode_entrypoint.py`'s `corpus` fixture ran `git commit`
under the caller's global and system git configuration. A global
`commit.gpgsign=true` therefore failed the setup before any install-mode
probe ran. Measured with a hostile global config (`commit.gpgsign = true`,
`gpg.program = /bin/false`): 7 errors at b50e3b1, 14 passed here. The
fixture now sets GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1, as
tests/test_checkout_head.py does.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ot be (Copilot review)
load_migration_settings recorded OPENDOX_INSTALL_MODE=local but never asked
refuse_what_a_local_install_cannot_be. So `runtime migrate` and a
confirmed `runtime reset` accepted OPENDOX_OIDC_ISSUER, OPENDOX_OIDC_AUDIENCE,
OPENDOX_OIDC_JWKS_URL or a non-loopback OPENDOX_BIND_HOST beside `local`,
which load_settings and generate-and-open both refuse. They now refuse them
at configuration, before any database is reached.
Seven new cases:
- the three broker settings x {migrate, reset};
- the bind.
All seven fail at 32683e8 and pass here. Full suite: 2538 selected, 2527
passed, 11 skipped, 0 failed.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 525f61c makes `load_migration_settings` refuse what a local install
cannot be (Copilot review of openDox-code#67). T072 had already restructured
the same lines: under `local`, it asks that refusal and then takes the
bundle's migration DSN. The conflict resolves to T072's structure, with
T070's reason carried into its comment. The refusal is asked once, before
the bundle's DSN is read.
The two merged cases now set the local shape as T072 defines it, with the
mode and the state dir and no operator DSN. Beside `local` a DSN is itself
refused (T072), so a merged case that set one would have tested the DSN
refusal rather than the broker or bind refusal it names.
Full suite: 2552 selected, 2541 passed, 11 skipped, 0 failed. A mutant that
drops the refusal from the migration loader fails all 7 merged cases.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n too (Copilot review)
When the runtime extra is absent, `runtime status` returns early, and that
return said `broker_keys: "not probed"` for every install. A local install's
broker is not configured whether or not the extra is present. That answer
comes from the configuration, not from a probe, so the early return now gives
the local install the answer the full report gives: `"not configured (local
mode)"`, with `broker_discovery: null`. Both returns write it through one
helper, so the two cannot drift. A hosted install's early return still reads
"not probed", as before (13.6).
The branch is covered now, so its `pragma: no cover` goes. A new case runs
both shapes with `opendox.runtime.db` absent from `sys.modules`. Before
(`525f61c`'s runtime/cli.py): local 1 failed and hosted passed. After: both
pass. Four mutants of the fix are killed. Full suite: 2540 selected, 2529
passed, 11 skipped, 0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 02dadc5 makes `runtime status`, when the runtime extra is absent,
report a local install's broker as not configured on the early return too
(Copilot review of openDox-code#67). It merges cleanly: T072's
`database_bundle` report comes before that return, in another hunk.
The merged case sets the local shape as T072 defines it, with the mode and
the state dir and no operator DSN. It also asserts that the bundle is
reported on the early return (`database_bundle` present for local, `null`
for hosted).
Full suite: 2554 selected, 2543 passed, 11 skipped, 0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tings' broker invariants are scoped (Copilot review)
A local `status` returns `ok` on the database's verdict alone. Both earlier
local cases forced a database fault and asserted exit 1, so a regression
that also counted the absent broker as a fault would still have passed. A
DB-backed case now runs `status` for a local install against a migrated
schema on the suite's own server (`database` and `postgres_dsn`, with the
schema selected in the DSN). It asserts `ok` true, exit 0, the database
reachable with nothing pending and no drift, and the broker reported as not
configured and never probed. Measured: with the local return mutated to
`ok=False`, this case fails and the other 40 in the module pass.
`RuntimeSettings`' docstring said that a local install's issuer and audience
are empty and that a hosted one always carries a real issuer. That is true of
`load_settings` alone. `load_migration_settings` carries the migration
sentinels in either shape. The docstring now scopes each statement to its
loader.
Full suite: 2541 selected, 2530 passed, 11 skipped, 0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…errupts hardened (Copilot review)
Copilot's reviews at 95fe16f and 32db5d8 opened ten threads. Eight are fixed
here. The two about the server package (PostgreSQL 16.2, no wheel for 3.13)
wait on the holder.
- Migrations (r4139811473, r4139880241). An explicit OPENDOX_MIGRATIONS_DIR
is used as given. Unset, a LOCAL install uses only the copy its own
installation carries, and never the working directory's: its entry point
runs every migration as the bundle's owner, and the canonical gate pins
0001 alone. Where the installation carries none, it is refused, naming the
setting. The installation's copy is the source tree the module was
imported from (src/ beside a pyproject.toml naming opendox), then the
RECORD of the distribution that holds the running module, and never
another one found by name. A HOSTED install's unset default is unchanged
(13.6).
- The pid (r4139811555). A postmaster.pid is believed only for this data
directory's postmaster, as the kernel reports it: an executable named
postgres whose working directory is the data directory. Another user's
process is never believed. A lock that /proc proves stale is removed
before the launch, so a recycled pid no longer holds the bundle.
- initdb (r4139880213). It runs into an attempt directory beside the data
directory, which is renamed into place only on success. An attempt whose
process is gone is removed. A non-empty data directory that holds no
cluster is refused and left untouched.
- start() (r4139880279). Directories, initialize, launch, wait, bootstrap
and migrate are one guarded operation, and every failure is the one named
refusal (phase and class name), with anything started stopped.
- Interrupts (r4139880267). SIGTERM or Ctrl-C anywhere in the local
lifecycle is a clean stop: no traceback, the bundle stopped, the handler
restored first. Nothing was served, so the exit is 128 + the signal number.
A served run ended by SIGTERM still exits 0.
- Refusal wording (r4139880298). Broker settings and operator DSNs are two
classes, and each is refused with its own reason.
- The test helper (r4139811584). The launch helper is bounded by its
deadline, through a selector. Measured with a silent 8 s child and a 1 s
deadline: the old loop returned after 8.0 s, the new one after 1.0 s.
tests_runtime/test_local_lifecycle.py (new, hermetic) holds these cases,
plus a real-server stale-lock case and the helper's own case in
test_bundled_postgres.py. Against ac61596's source, 17 of the module's
first 18 cases fail. The one that passes is the hosted default, which is
unchanged on purpose. All 23 mutants of the fixes are killed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 859b37b adds a DB-backed case proving that a healthy local
`status` exits 0, and scopes RuntimeSettings' broker invariants to their
loader (Copilot review of openDox-code#67). It merges cleanly.
Here the case uses the local install's own database. Beside `local` an
operator's DSN is refused (T072), so the case starts the bundled server
on a fresh state directory and asks `status` about it. With the local
return mutated to `ok=False`, it fails and the other 42 cases in the
module pass.
Full suite, with this PR's fourth fix round (5e52872): 2580 selected,
2569 passed, 11 skipped, 0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…es by name (Copilot review)
Copilot's review at ac61596 opened two more threads, both real.
- r4139938402: the old fallback believed any pid it could not inspect. So a
process that exited between the signal check and the /proc read, or any
pid on a platform without /proc, counted as the server. Round 4 already
treated a vanished process as gone on the /proc path. This round makes
the rule total. `running_pid` believes a pid only when the kernel proves
it is this data directory's postmaster. Where nothing can be asked (no
/proc: macOS, the BSDs), it believes nothing, and this module does not
refuse a start over it. PostgreSQL's own interlocks, the lock file's
live-pid check and the shared-memory check, still refuse a second
postmaster, so this never yields two servers, and never a refusal over a
process that is not one. A lock that cannot be proven stale is left for
PostgreSQL to judge. The price on such a platform is a `status` with no
pid. That is recorded, not hidden: the standard library has no portable
way to ask, and a third-party module here would be an undeclared runtime
dependency (test_consumer_reach). Measured before: round 4's source with
no /proc, and a python decoy in the data dir, reported the decoy's pid.
ac61596's source reported a pid that had already exited.
- r4139938444: `Path.expanduser()` raises RuntimeError for an unknown
`~user`, and `Path.home()` does the same where there is no home. Both now
refuse by name, as ConfigurationError naming OPENDOX_STATE_DIR. A hosted
install still never reads the setting and is not refused over it (13.6).
Five new cases fail against 28bdccd's source and pass here. Five mutants of
the fixes are killed. Full suite: 2584 selected, 2573 passed, 11 skipped,
0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed (Copilot review)
The module docstring called every case hermetic. Since the fourth fix round,
one is not: `test_runtime_status_of_a_healthy_local_install_exits_zero` takes
the suite's `postgres_dsn` and `database` fixtures, because a healthy local
`status` exits 0 only against a database that answers. The docstring now
names that case and says it is skipped without Postgres and fails under CI,
like every DB-backed case. It says the rest stay hermetic. Docstring only:
the module runs 41 passed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 026f00e corrects the install-mode module's docstring. It now names
the one DB-backed case instead of calling every case hermetic (Copilot
review of openDox-code#67). Here that case starts the local install's own
bundled server, because beside `local` an operator's DSN is refused, so the
merged sentence says so. Docstring only: the module runs 43 passed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…(Copilot review)
The data-files note pointed readers at
`opendox.runtime.config.packaged_migrations_dir`, which fix round 4 replaced
with `installation_migrations_dir`, the source tree first and then the RECORD
of the distribution that holds the running module. The note now names that
function and says what it asks.
The packaging case now also checks that every
`opendox.runtime.config.<name>` pyproject.toml names exists, so a stale
pointer cannot come back. Against 4aed627's pyproject.toml it fails, naming
`packaged_migrations_dir`. Here it passes. Full suite: 2584 selected, 2573
passed, 11 skipped, 0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… (Copilot and SonarCloud review)
Copilot's review at a0fb7c8 opened three threads, and SonarCloud raised a
reliability finding. All four are fixed here.
- PG* defaults (r4146787926). libpq fills every parameter a DSN leaves
unset from the environment. PGHOSTADDR outranks the socket `host` and
sends the connection to TCP, PGSERVICE fills parameters from a service
file, and PGOPTIONS sets the session's parameters. No DSN can name every
parameter, and an explicitly empty `service` is itself an error. So
`bundle.isolated_from_libpq_environment` lifts every PG* variable out of
os.environ for the duration and puts it back afterwards. It wraps
`generate-and-open --local`'s whole lifecycle and the runtime CLI's verbs
under `local`. A hosted install's libpq is untouched (13.6). With
PGHOSTADDR=192.0.2.1, PGSERVICE=no-such-service and PGOPTIONS=-c
search_path=nowhere set, the real entry point still starts, migrates and
serves its own server, and `runtime status` still finds it.
- The socket's path (r4146787852). Before the socket directory is chmodded
(a chmod follows a symlink), the resolved path is checked. The state dir,
postgres/ and run/ must be real directories owned by this user and
writable by no one else. Every ancestor must be owned by this user or by
root, and must be sticky if every user can write it, or if a group other
than this user's own can write it. Anything else is refused by name.
- A relative HOME (r4146659876). It is refused for the default state
directory, which would otherwise depend on the working directory.
- SonarCloud S6466. server_binaries no longer indexes a list. It takes the
first search location or none, and both refusal shapes have a case.
Against a0fb7c8's source, 8 of the new cases fail and the positive control
passes. 11 mutants are killed. Full suite: 2595 selected, 2584 passed,
11 skipped, 0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…uch for (Copilot review)
Copilot's review at 0f77d5c opened two threads. Both were real.
- r4147004990: a user's primary group can have other members, so a 0775
ancestor is not private. Every ancestor that anyone else can write, a
group included, must now be sticky. The round-7 allowance for the user's
own group is gone, and its positive control is now a refusal case. The
sticky shape (/tmp) is still the control.
- r4147005063: resolving the configured path before checking it discarded
the path that was actually configured. A link on that path could be
repointed afterwards, while the bundle kept using the unresolved paths.
Now:
- the ancestors of BOTH the configured path and the resolved one are
checked;
- every symbolic link on the configured path must be owned by this user
or by root;
- `..` is refused in OPENDOX_STATE_DIR and XDG_STATE_HOME (and in a
derived HOME), so the configured components are the ones the kernel
walks.
A user's own link to a private directory is still accepted.
Against 0f77d5c's source, 5 of the new cases fail and the 4 controls pass.
5 mutants are killed. Full suite: 2601 selected, 2590 passed, 11 skipped,
0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… peer (RULED 5916000030 items 2, 3)
Brett's rulings on openxFactory#656 (comment 5916000030) cover two things.
Item 2, "pixeltable-pgserver (Recommended)". The `local` extra's carrier is
now pixeltable-pgserver>=0.6.0, the maintained fork of pgserver. Only its
binaries are used, found under pixeltable_pgserver/pginstall/bin. Measured
on the installed 0.6.0 wheel:
- initdb and postgres report PostgreSQL 16.14;
- postgres links libz, libpthread, librt, libdl, libm and libc only, and
initdb links the wheel's own vendored libpq through $ORIGIN;
- the highest GLIBC symbol any binary or server module needs is 2.25, and
the wheels are tagged manylinux_2_27/2_28;
- the licence is Apache-2.0 (dist-info LICENSE and classifier);
- the cp312 x86_64 wheel is 24,704,230 bytes;
- wheels exist for cp310 to cp314.
The lock was re-resolved in a clean environment under the existing pins
less pgserver. The only line that moved is pgserver==0.1.4 ->
pixeltable-pgserver==0.6.0.
Item 3, "Peer auth + accept (Recommended)".
- initdb now runs with --auth-local=peer --auth-host=reject.
- Before every launch the bundle writes pg_hba.conf and pg_ident.conf
atomically, mode 0600. pg_hba.conf holds one local rule, peer map=opendox,
and host reject for IPv4 and IPv6. pg_ident.conf maps the running OS user
(from the password database), and nobody else, to opendox and
opendox_runtime.
- listen_addresses stays empty.
- An OS user name the map cannot hold plainly is refused, as is a uid with
no password entry.
- A cluster that an older build left as trust is put back to peer on its
next start.
The server's own reading proves it. pg_hba_file_rules has exactly those
three rules and pg_ident_file_mappings exactly those two mappings, and
system_user is peer:<os user> for both roles. The same OS user asking for a
role outside the map is refused ("peer authentication failed").
Against 379fbb1's source and packaging, 13 of the new cases fail. Nine
mutants of the carrier and the authentication are killed. The auth mutants
are also killed by the real-server cases alone. Full suite: 2613 selected,
2602 passed, 11 skipped, 0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…his install (Copilot review)
Copilot's review at 84a6c04 made three points, all real.
- r4147680113: the tree check left out postgres/data. An existing data
directory, a broken link included, now joins the own-tree check: a real
directory, owned by this user, writable by no one else, not a link. A link
to a cluster elsewhere would otherwise have been given this install's
authentication files and launched outside the state tree. A fresh data
directory needs no check, because _initialize renames it into place.
- Fresh directories and the umask (overview, previously missed).
mkdir(parents=True) creates intermediate directories with the default mode
less the umask. Under umask 0002, a fresh ~/.local/state/opendox would
create group-writable parents, which the tree check then refused. Each
missing component is now created on its own and set to exactly 0700,
whatever the umask.
- Readiness (overview, previously missed). A successful connection proves
only that some server answered. Two entry points racing from an idle
state both launch, and the loser's postgres lives a moment while the
winner's socket answers. So readiness now also needs the data directory's
lock file to name this child. Otherwise the wait goes on until this child
exits and is refused. One check after the connection is enough, since the
lock admits one postmaster per data directory and the socket directory
belongs to exactly one data directory. A before-check was tried and
dropped: no mutant distinguishes it.
Against 84a6c04's bundle.py, all 6 new cases fail. 4 mutants are killed.
Full suite: 2619 selected, 2608 passed, 11 skipped, 0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main now carries T054 to T058, T055's follow-up (#70) and T056's
standalone test. This PR edits src/opendox/runtime/config.py and
tests_runtime/test_runtime_cli.py, and main touches neither, so the merge
is clean.
Full suite on the merged tree: 3061 selected, 3050 passed, 11 skipped,
0 failed. EXPECT_SKIPPED=11 holds exactly, and the floors are met.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T071 (#60) now carries main 047bb4f: phase 2, with T054 to T058, T055's
follow-up #70 and T056's standalone test. Git auto-merges cli.py and
test_doxbench_entrypoint.py without a conflict: main's
_refuse_empty_source_options sits after the install shape is resolved, and
--local still precedes --host.
Four callers on main relied on generate-and-open's old default, and since
this PR an unflagged run is HOSTED and refuses without its broker's issuer.
They get --local in the next commit, which T070 owes now that T056 has
landed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…say --local
Since this PR, generate-and-open with neither --local nor
OPENDOX_INSTALL_MODE=local is a HOSTED install, which refuses without its
broker's issuer (#1144 13.4, 13.5). Four cases that landed on main with
phase 2 run generate-and-open as the single-user install and relied on the
old default, so each now says --local:
- tests/test_standalone_generate_path.py (T056), case 3: the server starts,
answers and stops. The module docstring names the change and moves F10.1's
plain-install run to T077.
- tests/test_post_render_validator.py (T058),
test_generate_and_open_gives_the_same_verdicts, both fixtures.
- tests/test_projection_seams.py (T055),
test_generate_and_open_refuses_an_empty_source_option_before_its_run_dir.
No case means hosted, so none takes a hosted fixture. Before this commit,
all four fail on the merged tree with the hosted issuer refusal; after it
they pass. Three mutants of the local path are killed, each failing all
four cases: --local ignored, local refusing its own loopback default, and
local also asking for the hosted issuer. Full suite: 3117 selected, 3106
passed, 11 skipped, 0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
cli._report_non_conformance printed the validator's last 20 lines. So a
snapshot that broke one rule many times and a second rule once showed
copies of the first and never named the second. Now each rule id the
report names is printed once, on its own line,
`<count> × [<rule>] <where>: <detail>`, in the order found and with
where it is first broken. The next places that rule is broken follow
beneath it without the id, five in all, then "… and N more of this
rule". One rule can be broken in different ways, and a count beside
the first place alone would read as that place repeated. The
validator's own summary follows, and a report that names no rule id
prints its own last lines as before.
RULED openxFactory#656 5920216845, item 3 ("Show every rule, grouped
(Recommended)"). No #1144 line moves: F7.2 asserts the fixture's rule
id is printed, which stays true.
The new module tests/test_rejection_report.py sits clear of
tests/test_post_render_validator.py, which is T085's. Before the
change: 3 failed, 1 passed. After: 6 passed, with
test_post_render_validator.py still 50 passed.
tests/test_projection_seams.py's envelope-keys case now asserts the
grouped line, that the id appears once, and that the `documents` key is
still named. The always-1 and never-groups mutants each fail 5 cases,
and the drops-places mutant fails 3.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070 (#67) now carries T071's merge of main 047bb4f: phase 2, with T054
to T058, T055's follow-up #70 and T056's standalone test. It also carries
the four generate-and-open callers that now say --local. Git auto-merges
pyproject.toml (main's validator package data beside this PR's local extra
and data files), src/opendox/cli.py and tests/test_doxbench_entrypoint.py
without a conflict.
On their own, the merged callers run --local, and here that starts the
bundled server. Three of them would do so under the user's own state
directory. The stand-in driver no longer stands in for anything, so
three bundled cases fail on this merge alone. The next commit takes both
in hand.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…l child keeps its own state
Phase 2 is on this stack's base, so four things T072 owed at its merge
round are done.
- The stand-ins go. tests_runtime/local_entrypoint_driver.py is deleted.
Its stand-ins patched names that T055 has since replaced, so on the
merged tree they stood in for nothing, and all three background cases
failed: the real corpus-root check refused the stand-in corpus, a
directory with no repository. test_bundled_postgres.py now launches
`python -m opendox.cli generate-and-open --local`, with the validator on,
over T050's tests/fixtures/plain-documents copied into a fresh repository,
as F13.1's preamble does.
- Every cheap refusal comes before the database start. main's T055 added
_refuse_empty_source_options to the generate path, so the local path
asks it before it builds the bundled server, beside the corpus-root and
generated-at refusals. test_projection_seams.py's empty-option case now
carries a tripwire bundle, so a regression neither starts a server nor
passes.
- No child touches the user's state directory. A `generate-and-open
--local` child now starts the bundled server, and OPENDOX_STATE_DIR
defaults to the user's own ~/.local/state/opendox. tests/standalone_child.py
gives every child a fresh, short, private state directory under /tmp and
removes it when the child is stopped. Measured before: the three --local
children of T056 and T058 initialized a cluster in the (sandboxed) default
state home.
- T056's case 3 asserts that its bundled server's data directory is under
the child's own state directory while serving, and that the directory is
gone after the stop.
Four mutants are killed. They drop the cheap refusal, the private state
dir, its removal, and the fixture's repository. Full suite: 3195 selected,
3184 passed, 11 skipped, 0 failed. Nothing is left under
~/.local/state/opendox or /tmp/odx-child-*.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… and refuses an unsupported platform first (Copilot review)
Copilot's review at 0539f8c (5407901398), two threads, both on
round 9's tokenless guard (`guard_private_roots`). Each case failed first
at 0539f8c (review10-red.txt):
- r4179091592. The boundary check and the marking each resolved the
configured state path for themselves, so a link on it re-pointed
between the two left the boundary judging the real state directory
and the marking naming a decoy, and an outward static link served a
sibling plane's copy. The guard now walks the state directory once
(`_walked`), judging every directory and link on the way as the
writer does, and the boundary and the marking both use that walk's
path. An operating-system error on the way is a refusal by name.
Cases:
- test_a_tokenless_planes_state_link_retargeted_mid_guard_marks_the_real_directory,
Copilot's layout: the link is re-pointed at a decoy between the
two, and the sibling's copy and the listing stay 404;
- test_a_tokenless_planes_unsafe_state_path_refuses_its_start: a
world-writable, non-sticky directory on the way refuses the
tokenless start by name, and nothing is marked.
- r4179091624. A tokenless plane skipped the platform check, started,
and marked a private root that its handlers then judged with the
missing O_NONBLOCK. `publish` and the guard now refuse an unsupported
platform by name before anything else, token or not. Cases:
- test_a_tokenless_plane_refuses_a_platform_without_the_primitives,
in the process, without O_NONBLOCK;
- test_a_tokenless_start_without_the_posix_primitives_refuses_by_name,
the no-identity variant of the reviewer's B2 child.
Mutants M52 (the marking resolves the path again), M52b (the guard does
not walk) and M53 (the tokenless plane skips the platform check) join
the run; M26, M34, M44c and M44d now point at the walked guard's lines.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Disk snapshots override in-memory payloads and cause false 404s
src/opendox/serve.py:1466
This bypasses SnapshotEntry.read_bytes()'s payload-first contract (default_registry.py:208–215). On a standalone plane, an entry with both an in-memory payload and a snapshot path now serves the disk version; a missing path produces 404 despite the available payload. Preserve the in-memory payload precedence and apply the private-file guard only to the file fallback. Add cases with both fields set, including a missing file.
…not be made denies, and an entry's payload comes first (Copilot review)
Copilot's review at af2a2ef (5408101006): three threads, and one
finding "previously missed" in the review's body. Each case failed first
at af2a2ef (review11-red.txt):
- r4179239380, another state directory. Two standalone planes of one
user can have different OPENDOX_STATE_DIR values, and the guard knew
only its own plane's copies. If plane A's web root linked to plane B's
state directory, A served B's copy. `is_private_file` now first judges
the file it was handed by what that file holds: a regular file whose
head carries a console record (`_carries_a_console_record`, read with
pread from the open descriptor) is a copy, wherever it lies. Case:
test_another_state_directorys_copy_is_never_served, through a static
link and a hard link under /source.
- r4179239411, a removal mid-read. A copy removed after a read opened it,
and before the scan could stat its name, matched nothing. The open
file still holds its record, so it is refused. Case:
test_a_copy_removed_during_the_scan_is_never_served.
- r4179239424, a scan that fails. A private directory that exists but
cannot be listed (EMFILE, EACCES), or a name in it whose status cannot
be read for any reason but its removal, used to let the file through.
Both deny now, and so does a regular file whose head cannot be read. A
private directory that does not exist still holds no copy. Cases:
test_a_private_directory_that_cannot_be_scanned_denies_the_read (EMFILE
simulated, and EACCES), test_a_name_whose_status_cannot_be_read_denies_the_read,
test_a_file_whose_head_cannot_be_read_is_denied.
- Previously missed, serve.py `_entry_bytes`: on a standalone plane an
entry with both an in-memory payload and a snapshot path served the
file, and a 404 where the file was missing, against
SnapshotEntry.read_bytes' payload-first contract. The payload comes
first again, and only the file fallback is guarded. Case:
test_an_entrys_payload_comes_before_its_guarded_file, with the file
missing, present, and a private copy.
Mutants M54, M54b, M55, M55b and M56 join the run; M38 now points at the
identity match's new indentation.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The holder found orphaned `python -m opendox.serve` children of the
T104 cases on the machine, hours old, with PPID 1. Each was plane B of
test_b1_a_tokenless_sibling_plane_never_serves_another_planes_copy, left
by a mutant run: where a mutant made B serve instead of refusing, the
case failed on `b.wait(60)`, and its `finally` stopped plane A only. A
run killed outside pytest's control left its children too.
Every child `_adv_serve` starts now:
- runs in its own session, so its process group is its own
(`start_new_session`);
- is registered, and an autouse fixture reaps every registered child at
teardown, whether the case passed, failed or raised: SIGTERM to the
group, a bounded wait, then SIGKILL (`_reap`);
- on Linux, gets SIGTERM from the kernel if the test process dies first
(`PR_SET_PDEATHSIG`, `_child_setup`), since a killed run runs no
teardown.
Cases:
- test_a_server_left_running_is_reaped_with_its_group: a serving child
is in its own group and is reaped with its copy removed and its port
freed; a child that ignores SIGTERM is killed after the bounded wait;
- test_a_server_outlives_no_killed_run: an intermediate process starts a
child the way `_adv_serve` does and is SIGKILLed, and the child ends
with it.
Checked against mutant M44 in a throwaway worktree (orphan-check.txt):
the B1 case fails, as it must, and no server from that run survives it.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mutant run 23 (83 mutants at d466c1d) killed 82; M38 lived, where the
identity match against the copies' directory finds nothing. Since round
11 judges every file by what it holds first, every whole copy was
already refused by its record, and nothing asked for the identity match
on its own. It answers for a file in the copies' directory that holds no
whole record: a copy caught part way through its write has the token
and not yet the end of its element.
test_a_partly_written_copy_is_refused_by_its_place hard-links such a
file under a served root and checks that it is never read out, and that
its content alone would not have refused it.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…is judged as it is read (Copilot review)
Copilot's review at 1e114a1 (5408767954), r4179793524. Round 11 knew a
copy in another state directory by its whole record, so another plane's
temporary file, part written with the token in its meta refresh and no
record yet, passed the static handler, /source and /snapshot.json. A
file that grew after it was judged passed as well, since the stdlib
copies a static file to its end as it is when read.
- The writer now begins every copy with COPY_MARKER, an HTML comment,
before any byte of the token. A copy is written from its start, so any
part of one that holds a token byte holds the whole marker first.
`is_copy_bytes` knows a copy by the marker or by its whole record;
fewer bytes than the marker hold no token.
- `read_unless_private` reads first and judges what it read
(`is_copy_bytes`) as well as the file by its descriptor, so a file
that holds no token when judged cannot hand one out after.
- The static handler's `copyfile` sends no more than the file's length
when it was judged, and judges the body's first bytes, read before
anything is sent: a copy's are never sent, and the connection is
closed, as the backstop closes it. The judged length is reset for
every request.
Cases (review12-red.txt):
- test_a_copy_starts_with_its_marker_before_any_token_byte;
- test_another_state_directorys_partial_copy_is_never_served: Copilot's
two-state-directory layout, through the static handler, /source and
/snapshot.json, GET and HEAD;
- test_a_file_that_grows_after_its_static_check_never_sends_a_token: the
file grows right after the backstop's check;
- test_a_file_that_grows_after_its_read_check_never_returns_a_token;
- test_a_copy_replaced_after_its_read_is_never_returned, which pins the
read-first design (at 1e114a1 it fails only because that reader judged
before it read, so the staged replacement never happened).
Each failed first at 1e114a1.
The identity case for a file in the copies' directory is recast as
defence in depth (test_a_file_in_the_copies_directory_is_refused_by_its_place):
with the marker, a partly written copy is known by its bytes, so the case
now holds a token-bearing file the marker cannot recognize. It still
kills M38. serve.py imports os, for the judged length.
Mutants M57, M57b, M58, M58b and M59 join the run (88).
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s it (Copilot review)
Copilot's review at a2e3665 (5409152074), r4180089809. `urlsplit`
reads `http://evil.example\@127.0.0.1:8080/index.html` as user
information at 127.0.0.1, but a browser takes the backslash for a slash
and navigates to evil.example, whose page could read the token's
fragment. The built-in entry points build their own URLs, but
write_private_copy and opened_url are public, and their loopback rule
did not hold its contract.
`_refuse_page_url` now requires the authority to be exactly a loopback
host, spelled as `serve.server_url` spells it, and an optional port of
at most 65535 (`_LOOPBACK_AUTHORITY`): no user information and no second
port. A backslash or a control character anywhere in the URL, which a
browser rewrites or strips, is refused.
Cases (review13-red.txt; 7 of the 8 bad URLs failed first at a2e3665,
the backslash after the port was already refused):
- test_a_page_url_a_browser_reads_as_another_host_is_refused: Copilot's
backslash-userinfo URL first, through opened_url and
write_private_copy, with nothing written;
- test_every_loopback_page_url_a_plane_announces_is_accepted: 127.0.0.1,
[::1] and localhost, with and without a port.
Mutants M61 (the authority is not judged exactly) and M61b (a backslash
or a control character passes) join the run (90).
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
brettheap
changed the title
DRAFT (phase 3, after T063): T104, the console token travels in the opened URL, not /capabilities (plan 034)
T104, the console token travels in the opened URL, not /capabilities (plan 034)
Oct 5, 2026
READY at c5fcdfa — Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Holder: T104. The console token travels in the opened file:// URL's fragment, not in /capabilities. It is an arc landing.
Review. Copilot at the head (review 5409553728) has no findings. Its overview's "Needs a closer look" asks for a security review of the filesystem, concurrency and signal handling. The holder judges that review done:
an independent adversarial review, work/advreview-84 B1 to B10, every finding fixed or ruled;
B3's hint line and B7's history limit, both ruled accepted limits;
thirteen Copilot rounds, the last one, r4180089809's backslash-authority leak, fixed by c5fcdfa4 with 8 refusal and 4 acceptance cases.
25 threads, none open.
Evidence at the head.
Full suite: 4111 passed, 177 skipped, 0 failed.
Mutants: 90 of 90 killed.
Governed run: 239 passed, plus the 1 known failure that the clone's name causes.
Browser check: 18 of 18.
CI validate: green, selected 4288, passed 4277, skipped exactly 11.
SonarCloud: passed.
Test hygiene. B1's teardown (d466c1d2) puts each test server in its own process group and reaps it.
Brings in #84 (T104), the console token through the opened URL, which the
harness's step 6 reads. With it, this branch's own `acceptance` job runs
the harness against the delivery it was written for.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#84 landed first, as planned; this brings main at 32943cb into #86 before
its final gates. The merge is clean: tests/test_capability_honesty.py and
the web boundary census auto-merged, and census A needs no re-derivation.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Plan 034 (
specs/034-opendox-standalone-operation/), phase 3:/capabilities. RULED on openxFactory#656 in5963851934(Brett, 2026-10-03, "Token via the opened URL (Recommended)"). Its plan entry landed with openxFactory#1220 (ec9308c8)./capabilitiesto any loopback caller, other OS users on the same machine included. Nothing checks which local user connects. The token lets a page edit documents and run chat turns that spend the operator's model credential.r4171166321): the private copy refuses anOPENDOX_STATE_DIRthat is, or lies inside, a served root. This mirrors T100's served-repository boundary.390e2c28) and T102 (T102, the staging workbench offers the editors and the chat rail by scope (plan 034) #81, landed as0116293a; its follow-on T102 follow-on: the chat rail reads a thread only with a branch session (plan 034) #85 landed asc4b55cc4).serve.py's single-writer order is T084 (T084, 4.3: the last deferred reaches through declared seams; consumer_reach retired (plan 034) #77) → T103 (T103, every loopback route checks the Host (DNS rebinding) (plan 034) #80) → T104. Every After line is met.main. The holder retargeted T104, the console token travels in the opened URL, not /capabilities (plan 034) #84 when T103, every loopback route checks the Host (DNS rebinding) (plan 034) #80 landed, and the diff is still T104 alone. It was built on T084, 4.3: the last deferred reaches through declared seams; consumer_reach retired (plan 034) #77's3387293eand merged forward as its base moved, never rebased:d0f1efcbat6db50b03;b9025b6catc1e7d8d8;390e2c28, T103, every loopback route checks the Host (DNS rebinding) (plan 034) #80's squash, at60bace00. That merge's tree came fromgit merge-tree --write-tree --merge-base b9025b6c, with both parents recorded. The squash hasb9025b6c's tree, so the merge changed no file;0116293a(T102, the staging workbench offers the editors and the chat rail by scope (plan 034) #81) atd4b99436, mainc4b55cc4(T102 follow-on: the chat rail reads a thread only with a branch session (plan 034) #85) at182cac76, mainca9e1bd5(T082, 16.5: every other surface works with no model (plan 034) #76, T082) atddb26c34, and main38d3350e(T100, 16.3a: a served repository's bindings are trusted per machine (plan 034) #82, T100) at021944a6. All are plain merges, because this branch held none of those PRs' own commits. Each time, the census's class-A total was re-derived from the rows of the merged tree, now 18621: T100, 16.3a: a served repository's bindings are trusted per machine (plan 034) #82 moveddoxbench-chat.js1890 → 1930, and T104 movesnotebook.js109 → 204.validate.ymlmerged cleanly each time; T104 adds no skip, soEXPECT_SKIPPEDstays 11. Theca9e1bd5merge applies F2 from the holder's T096 dry run: T082, 16.5: every other surface works with no model (plan 034) #76's new postures fixture read a standalone child's token from/capabilities, and now reads the private copy (child.console_token(base[1])).cb899546, Copilotr4173806506,r4173806552,r4173806590,r4173806621): every served root is in the boundary, removal is exact under a race, a plainkillremoves the copy, and the shutdown cases look before cleanup. See "Fix round 1" below.c979747a, Copilotr4173889265,r4173889294): the copy is judged by its own path, and no static link serves it. See "Fix round 2" below.36ff6103): the holder's ruling on batch N's Copilot review, openxFactory#1222r4174345203. The state directory and every served root may not overlap in EITHER direction. See "The reverse boundary" below.219d7cf9, Copilotr4174674625,r4174674702): a directory's index page is judged, and a FIFO never blocks a read. See "Fix round 3" below.a13857ad): openxFactory#1222 (T007 batch N) landed asbdd0f586, and its amended 12.4a is the normative text for T104. Every clause is checked and its gaps are closed; the opener file's lifecycle is self-reviewed in the same commit. See "12.4a, clause by clause" below.14285fbb, Copilotr4174785933): every directory and link the walk passes through is judged, and the write is anchored to that walk. See "One walk of the state directory" below.9f328892, Copilotr4175213798,r4175213842,r4175213864, andr4177975845atddb26c34): the snapshot files are served roots, publication and removal take one lock, and a stop is held through publication. See "Fix round 5" below.9dcc9bb5, Copilotr4177975898): an operating-system error during the walk is a refusal by name, for the writer and the reader. See "Fix round 6" below.fb8a1cc4, Copilotr4178041022): Ctrl-C is held like SIGTERM while a copy is written or removed. See "Fix round 7" below.66cffdb8, Copilotr4178133814,r4178133842): a running console's copy is reserved for its server's life, and every read that serves a file without the console check judges the file it opened. See "Fix round 8" below.45958bf3; findings B1-B10 onfb8a1cc4and round 8): every standalone plane keeps the boundary, token or not; a platform without the POSIX primitives is refused by name; a browser that cannot open the copy is told how to move it (B3, ruled by Brett); a second spelling of a directory is judged by its identity; only the first stop is raised;--no-servepublishes nothing; and a publication sweeps the copies of consoles that died. See "The adversarial review" below.af2a2efb, Copilotr4179091592,r4179091624): the tokenless plane's guard walks the state directory once, as the writer does, and refuses an unsupported platform first. See "Fix round 10" below.ed6e4769, Copilotr4179239380,r4179239411,r4179239424, and a finding its review ataf2a2efblists as previously missed): a copy is known by what it holds, in any state directory and mid-removal; a check that cannot be made denies; an entry's in-memory payload comes before its guarded file. See "Fix round 11" below.a2e36652, Copilotr4179793524): a copy is written from a marker, so a part-written or growing copy is known by its first bytes; every reader judges the bytes it sends. See "Fix round 12" below.c5fcdfa4, Copilotr4180089809): the console page's URL is judged as a browser reads it: an exact loopback authority, and no backslash or control character. See "Fix round 13" below.Claimed on openxFactory#656 in
5963979413. The holder posts READY, and the landers merge.What changes: the delivery only
Standalone means openDox's own default profile. A plane is standalone when the profile
build_serverbuilds from ISopendox.default_profile, the one an entry point registers where no host has (console_access.delivery_for)./capabilitiesno longer carries it.opendox_host.register_openxfactory(), and this suite's own_SuiteProfile) keeps the/capabilitiesdelivery, unchanged. See "The governed path" below.generate-and-openandpython -m opendox.servewrite a private copy at<OPENDOX_STATE_DIR>/console/<port>.html:http://127.0.0.1:<port>/index.html#console_token=<token>. The token is in the fragment, never the query string, so it never reaches a request line, a server log or aReferer;COPY_MARKER), before any byte of the token, so any part of a copy that holds a token byte is known for a copy (fix round 12);<script>element;file://path, never the tokenized URL. A URL given towebbrowser.opensits on a command line (xdg-open, the browser), and every user of the machine can read/proc/<pid>/cmdline. This is Jupyter's own redirect file, for the same reason;console file://…) and never the token, with or without--no-open. To re-open the page, open that file again. Beside the path, one line with no token tells a user whose browser cannot open that file how to move the state directory (B3, ruled; see "Known limits"). There is no new verb, so the governed--helpgolden is unchanged;killor a closed terminal removes it too. A stop that arrives while the copy is written or removed, Ctrl-C included, is held until that is done, and only the first stop is raised (fix round 9). A SIGHUP the process was started ignoring (nohup), an ignored SIGINT, and a host's own handler are left as they were;flockon the file it wrote, on its own descriptor, until the copy is removed. A second console on the same port number and state directory (one on 127.0.0.1, one on ::1) is refused by name and never replaces it. A copy whose console died holds no lock, and the next publication sweeps it, whatever its port (fix round 9);--no-servewrites no copy, opens none and prints no console line, since it closes the server before anything could use one (fix round 9);generate-and-open refused: …/serve refused: …, exit 1), and the listening socket is closed. A platform without the POSIX primitives the copy rests on (Windows) is refused by name as well (fix round 9). An operating-system refusal, such as a directory this user cannot make or a full disk, is refused by name too. A write that fails part way leaves no partial file, and a copy whose read-back fails is removed. A console nobody can be handed is not served as if it could be.The copy is checked as #69's bundle checks its tree, and by T100's trust-file rules.
src/opendox/console_access.pycopies the rules (it does not importruntime/bundle.py's private helpers):the state directory and
console/must be real directories, owned by this user and writable by no one else.console/must also be exactly 0700: its permission bits are judged, so a setgid bit inherited from a parent is accepted;the state directory is resolved ONCE, by one walk. Every directory the walk passes through must be this user's or root's, and sticky if others can write it, including a directory reached only through a link's target. Every symbolic link it follows must be this user's or root's. The served-root boundary, the tree rules, the write and the read all work on the walked path, never on the configured one again;
a missing directory is made relative to its parent's descriptor, born 0700 under a 077 umask, and opened with
O_NOFOLLOWbefore anything is made beneath it;the file is created with
O_CREAT|O_EXCL|O_NOFOLLOW,fchmoded to 0600 on its descriptor, fsynced, then renamed into place;if a name already at the target is anything other than this user's own regular file of mode 0600 with one link, it is refused by name and never followed or replaced, and the start is refused. That covers a link, a dangling link, a directory, a FIFO, another user's file, a hard link and a loosened copy;
the served-root boundary (the #1220 ruling, made two-way by the reverse ruling): the state directory and every root the plane serves may not overlap in either direction. A state directory that IS a served root, lies inside one, or HOLDS one is refused by name before anything is created (
OPENDOX_STATE_DIR (…) lies inside the served repository (…), or… holds …, a root this plane serves). The served roots (fix round 1) are:--web-dir;--source-root;/snapshot.jsonreads directly: the configured snapshot, each registered entry's, and, on a loopback plane, the session snapshots' container (fix round 5).Paths are compared after resolving, so a link from outside into a served root counts, and so does a served root named through a link into the state directory. Every directory on either path is also compared by its identity,
(st_dev, st_ino), so a second spelling of one directory, as a case-insensitive filesystem allows, is the same directory (fix round 9);every standalone plane keeps that boundary, token or not (fix round 9). A plane with no git identity mints no token and writes no copy, but it shares the state directory with the planes that do. It still refuses a state directory that overlaps its served roots, and still marks the copies' directory private;
fix round 2 judged the copy's own path, so a served root that holds
console/refused the write. The reverse rule covers that case and every other served root inside the state directory. That separate check is gone, and its case still passes;the static handler never serves a copy:
publishmarks the private-copy directory on the server, andDashboardHandler.send_headanswers 404 for any static target whose resolved path is that directory or inside it (fix round 2), by name or by the directory's identity (fix round 9). For a directory request, the index page the handler would serve (index.html, thenindex.htm) is judged too (fix round 3). The file it would serve is judged by its own identity before it is opened, and the file the handler opened is judged again, so a hard link or a link swapped in between is never sent (fix round 8). The body sent is bounded by the length judged, and its first bytes are judged as they are read (fix round 12). Links in--web-dirare still followed, since a governed host's composed web root is made of them;/source,/snapshot.jsonand each registered entry's snapshot read throughserve.read_unless_private, which judges the file it opened by its identity: a root re-pointed at the state directory after publication, or a hard link to the copy, is a 404 (fix round 8). Every such check also judges the opened file by what it holds, so a copy in another plane's state directory, or one removed mid-read, is refused, and a check that cannot be made denies (fix round 11). The bytes read are judged too, so a copy part-written or growing in another state directory is refused (fix round 12);a read (
read_private_copy, which the tests and T095's harness use) asks all of it again. The file is opened without blocking, so a FIFO is refused at once (fix round 3). It is checked on its descriptor: a regular file, this user's, exactly 0600, one link.The page (
web/views/notebook.js, its only web file):#console_token=fromlocation.hashat import, before the shell's first fetch;sessionStoragefor this tab, or in memory where storage is blocked;history.replaceState(state, "", pathname + search), and a malformed token is stripped too;probeCapabilities()fills the token into a payload that carries none, so every view keeps readingcaps.console_tokenunchanged. A host's published token always wins. The query string is never read;app.js,edit.jsand the staging workbench's JS are untouched (T102 edits the workbench).notebook.jsstays import-free, so its census row is re-measured (109 → 204) and class A's total is re-derived.Every route that requires the token still requires it.
_not_the_human_console, the catalog, the thread read, the chat turn, the abstract,/actions/editand the gate verbs are unchanged.Fix round 1 (
cb899546)Copilot's review at
6db50b03, four threads:r4173806506, served roots.build_serverreports every root the plane serves files from: the checkout; the static bundle's--web-dir, whose handler would serve a copy under it to anyone, 0600 notwithstanding, since the server reads it as its owner; each declared source root; each registry entry's root, which includes the bootstrapped session worktrees; and on a loopback plane the sessions container, where every later session worktree is made. Cases: a state directory under the static bundle and under the sessions container is refused and nothing is written; the reported set is asserted.r4173806552, a removal race.remove_private_copytakes the name with an atomic rename to a name only this process uses, then judges what it took. Its own file is removed; anything else is linked back under the name, never over a still newer copy. Both entry points also remove the copy BEFORE closing the listening socket. Case: a replacement written at the instant of removal survives whole.r4173806590, SIGTERM. While a copy exists,python -m opendox.serveandgenerate-and-open(local and hosted) read SIGTERM as Ctrl-C (console_access.terminate_as_interrupt) and restore the previous handler afterwards. A plane that wrote no copy keeps SIGTERM's default, so the governed and hosted images are unchanged. Cases: the context manager; a plain kill of each of the three entry points exits 0 with the copy gone.r4173806621, the shutdown assertions. They now signal and wait (_stop), assert while the state directory still exists, and leave cleanup to the case'sfinally.Fix round 2 (
c979747a)Copilot's review at
cb899546, two threads. Each case failed first atcb899546, then passed:r4173889265, a served root holdingconsole/. The boundary judged the state directory only, so--web-direqual to<state>/console(state directory outside every served root) letGET /<port>.htmlserve the copy. The copy's own resolved path is now judged as well: a served root that holds it refuses the write by name. Case:test_a_served_root_equal_to_the_console_directory_is_refused(it failed first: DID NOT RAISE).r4173889294, an outward link in the bundle. The static handler follows links inside--web-dir, soweb/state-alias -> <state>served the copy. Links are not refused wholesale: openxFactory'sscripts/ideation-dashboard-serve.pycomposes its web root from links out of the bundle (_composed_web_root), and confining to the resolved root would 404 the governed bundle. Insteadpublishmarks the private-copy directory (httpd.private_roots), andDashboardHandler.send_headanswers 404 for any static target whose resolved path is that directory or inside it, for GET and HEAD, files and listings, every port's copy included. A server with no copy marks nothing. Case:test_a_static_link_out_of_the_bundle_never_serves_a_private_copy(it failed first: GET answered 200).The reverse boundary (
36ff6103)The holder's ruling, from batch N's Copilot review (
r4174345203)._refuse_a_served_state_dirchecked one direction only: a state directory in a served root. A served root INSIDE the state directory would let the plane serve what the state directory keeps, the copy among it. Such a root could be<state>/consoleitself, the bundled PostgreSQL'spostgres/run, or any deeper path. Fix round 2's copy-path check caught only the root that holdsconsole/.test_a_served_root_inside_the_state_directory_is_refused, forconsole,postgres/run,anything/else/deepand.(the state directory itself). It failed first (DID NOT RAISE). Atcb899546, 3 cases failed:console,postgres/runandanything/else/deep. Atc979747a, 2 failed, because fix round 2 already coveredconsole.test_a_source_link_into_the_state_directory_never_serves_the_copy. A link inside a declared source root that points at the state directory gets 404 from/source, which never follows a link out of its root. It passed before the change. It is pinned here and held by mutant M13.Fix round 3 (
219d7cf9)Copilot's review at
60bace00, two threads. Each case failed first at60bace00:r4174674625, a directory's index page. For/sub/, the stdlib handler serves the first ofindex_pagesthat is a file, andsend_headjudged only the directory. Soweb/sub/index.html, linked to a copy, was served at/sub/while/sub/index.htmlanswered 404. The index page the handler would pick is judged too. The case istest_a_directory_index_linked_to_a_private_copy_is_never_served, run forindex.htmlandindex.htm. It covers GET and HEAD of/sub/,/sub/<index>and/sub/?x=1, and checks that the bare/subredirect carries nothing. A directory whose index page is the bundle's own still answers 200. Before the fix, GET/sub/answered 200.r4174674702, a FIFO.read_private_copy's read-onlyopenof a FIFO with no writer blocked forever, before the descriptor check could refuse it. It opens withO_NONBLOCKnow. The case istest_a_fifo_at_the_copy_is_refused_without_blocking, which runs the read and the write on threads with a bounded join, so a failure fails and never hangs. Before the fix, "the read blocked on a FIFO".12.4a, clause by clause (
a13857ad)openxFactory#1222 (T007 batch N) landed as
bdd0f586, and its amended 12.4a is the normative text for T104. Every clause was checked againstd4b99436. Batch N's gaps (c), the non-blocking reader, and (d), the automatic index file, closed in fix round 3. This commit closes the rest. Each case failed first atd4b99436unless it is marked as a pin.r4174785965atd4b99436.console/loosened after it was made (0755, 0750, 0711) was accepted wherever no one else could write it. The writer and the reader now refuse it by name. A setgid bit inherited from a parent is accepted (a pin).generate-and-openand throughpython -m opendox.serve: each of a symbolic link, a directory, a FIFO, a hard-linked copy and a loosened copy refuses the START by name. That means exit 1, nothing printed that serves, no browser, the planted thing untouched and the socket closed. The loosened copy failed first; the other kinds are pins.console,postgres/run, the directory itself) is judged where it leads. Through the entry point, the case is a--web-dirlink to<state>/console(pins, held by mutant M24).The opener file's lifecycle, self-reviewed (write, replace, read, remove at stop, refuse before writing). Each finding below has a case that failed first at
d4b99436, and a mutant:OSErrorwith a traceback and no refusal by name. It is now aConsoleAccessRefusednaming the copy, through both entry points;nohup);One walk of the state directory (fix round 4,
14285fbb)Copilot's review at
d4b99436,r4174785933. Copilot's probe reproduced here. WithOPENDOX_STATE_DIR=alias/state,alias -> shared/hopandhop -> private, the tree rules judged the configured path's components and the directories above the resolved path.shared, reached only through a link's target, was neither, so at mode 0777 and not sticky it went unjudged. The write also walked the configured path again after the served-root check. So ahopre-pointed in between put the token's copy in a served root, atserved/state/console/8080.html, where/sourceserves it._walkedresolves the state directory once, component by component as the kernel walks it. Every directory it passes through is judged by the rule for directories above the state directory, and every link it follows by the rule for a link. The served-root boundary, the tree rules, the write and the read all work on the walked path. Both cases failed first at182cac76:test_a_directory_passed_through_by_an_intermediate_link_is_judged: Copilot's layout withsharedat 0777. The write and the read are both refused by name. Before the fix: DID NOT RAISE;test_a_link_swapped_after_the_checks_never_redirects_the_write:hopis swapped to a served root right after the served-root check. The copy lands where the checks saw the state directory, and the served root gains nothing. Before the fix, the copy landed in the served root.Fix round 5 (
9f328892)Copilot's review at
182cac76, three threads. Each case failed first atddb26c34:r4175213798, the snapshot files./snapshot.jsonreads its file directly, not through the static handler. So a--snapshotnamed at an earlier copy,<state>/console/<port>.html, would have been replaced by the new copy and served to anyone. The served roots now hold the configured snapshot, each registered entry's snapshot, and, on a loopback plane, the session snapshots' container. The reverse boundary therefore refuses that start by name, through a link as well. Cases:test_a_snapshot_inside_the_state_directory_refuses_the_start, which before the fix reported "the server served";test_the_plane_reports_its_snapshot_files_as_served_roots.r4175213842, the rename-back race. Where a hard link fails, another serve's copy is renamed back where the name is free. A newer copy published between that check and the rename was overwritten. Every writer and remover ofconsole/now takes the directory's exclusiveflock, so publication and removal are serialized. The case istest_a_copy_published_during_a_rename_back_is_never_overwritten: a third copy is published at exactly that moment, waits, and stands. Before the fix, "an older copy overwrote the newest".r4175213864, a stop during publication. SIGTERM was read as Ctrl-C only afterpublish()returned. Both entry points now install the handler BEFORE publication, on any plane that writes a copy, and keep it through removal. A stop that arrives while the copy is written or removed is held (deferred_termination): publication raises it once the copy is in hand, and removal lets it go. Cases:test_a_stop_during_publication_removes_the_copy, for both entry points;test_a_stop_during_removal_lets_the_removal_finish;test_deferred_termination_holds_a_stop_until_the_block_ends.Copilot's review at
ddb26c34raised the same stop forgenerate-and-open(r4177975845), and9f328892answers it.Fix round 6 (
9dcc9bb5)Copilot's review at
ddb26c34,r4177975898. The walk and the served-root check ran outside the writer's conversion ofOSError. So an overlong state-path component (ENAMETOOLONG) or an unsearchable parent (EACCES) escaped as a rawOSError, and both entry points ended in a traceback instead of a named refusal. Both are inside the conversion now, and the reader converts the same way. The cases failed first at9f328892:test_a_state_path_the_walk_cannot_take_is_refused_by_name, for both kinds of path, through the writer and the reader;test_a_state_path_the_walk_cannot_take_refuses_the_start, for both kinds throughserveand throughgenerate-and-open. Each exits 1 with the refusal named, prints nothing that serves, and closes the socket.Fix round 7 (
fb8a1cc4)Copilot's review at
9f328892,r4178041022. SIGINT kept Python's immediate handler, sodeferred_terminationnever held it. A Ctrl-C just after publication's rename raised before the caller held the copy, and the copy was left behind. A second Ctrl-C just after a removal's take left a.removing-*file holding the token.terminate_as_interruptnow takes SIGINT too, still raised as aKeyboardInterrupt, but only where it has Python's own handler; an ignored SIGINT, or a host's own handler, is left as it was. The cases failed first at9dcc9bb5. Each one checks for the handler before it sends SIGINT, so a raw interrupt never aborts the test session:test_ctrl_c_just_after_the_copys_rename_leaves_no_copy, for both entry points;test_a_second_ctrl_c_after_the_removal_rename_leaves_nothing;test_terminate_as_interrupt_takes_ctrl_c_only_from_its_default.Fix round 8 (
66cffdb8)Copilot's review at
fb8a1cc4, two threads. Each case failed first atfb8a1cc4:r4178133814, two consoles on one port number. The copy's name is per port, so a console on 127.0.0.1 and one on ::1, sharing a state directory, replaced each other's copy, and the first console's printed path opened the second. The writer now takes an exclusiveflockon the file it wrote, before the rename, on its own descriptor (_Reservation, held inPrivateCopy.reservation), and keeps it until the copy is removed. A later publication on that port asks for the lock without waiting. A held lock is a running console's, refused by name (… belongs to a console that is still running (pid N)), and its copy is left as it was. A free lock is a stale copy's, and it is replaced. Cases:test_two_consoles_on_one_port_number_never_share_a_copy, with real IPv4 and IPv6 planes (failed first: DID NOT RAISE);test_a_running_consoles_copy_is_never_replaced(failed first: DID NOT RAISE);test_a_copy_whose_console_died_is_replaced, a pin: a subprocess writes a copy and exits, and the kernel releases its lock.r4178133842, a root retargeted after publication./sourceresolves a declared root again on every request, so a root re-pointed at the state directory after publication served the copy. Every read that serves a file to any caller without the console check now judges the file it OPENED, by(st_dev, st_ino), against every name in the copies' directory (console_access.is_private_file)./sourceand/snapshot.jsonread throughserve.read_unless_private. The static handler judges the file it would serve before the stdlib opens it, and judges what the stdlib opened; a file swapped in between is closed unsent and the connection closed. The identity also stops a hard link to the copy, which a path cannot tell apart. Cases, each answered 200 with the copy before the fix:test_a_source_root_retargeted_after_publication_never_serves_the_copy, Copilot's layout;test_a_snapshot_retargeted_after_publication_never_serves_the_copy;test_a_hard_link_to_the_copy_is_never_served, for the static bundle and the served checkout;test_the_static_backstop_never_sends_a_copy_swapped_in_after_the_check, which blinds the first check and reads the raw response: the copy's bytes are never sent.The adversarial review (fix round 9,
45958bf3)The holder had an independent adversarial review of
fb8a1cc4and of round 8 run ahead of Copilot: 1 high, 3 medium and 6 low findings. It confirmed that round 8 answers both of Copilot's threads, and it found what follows. The reviewer's own cases are kept as written, under their ids (test_b1_…,test_b2_…,test_b3_…,test_b6a_…totest_b6c_…). The cases that pin B1, B2, B3, B4, B5, B8 and B9 failed first at the merged pre-fix tree.build_server), andpublishon every standalone plane asks the boundary and marks the copies' directory private (console_access.guard_private_roots). Only the writing still needs a token. Cases: the reviewer's two real planes, where the tokenless plane now refuses its start by name; the same refusal in the process; and a tokenless plane whose--web-dirlinks into the state directory and whose checkout holds a hard link to the sibling's copy, which answers 404 for the copy, the listing and/source.AttributeErrortraceback.console_access.unsupported_platform()names what is missing, and the writer and the reader refuse by name before anything else, asbundle.unsupported_platform()does for T072, 13.1: the bundled PostgreSQL server, the local install's own child (plan 034) #69's bundle (holder's ruling). Cases: the reviewer's child withos.getuid,O_NOFOLLOWandO_DIRECTORYremoved, which now exits 1 withserve refused: …; and the writer and the reader withoutO_NOFOLLOW, and without calls relative to a directory's descriptor.~/.local/state, and a Windows browser under WSL may not open a Linux path. There was no way past it, because the token is never printed. RULED by Brett (2026-10-04, "Hint line, accepted limit (Recommended)"): beside the copy's path, the start prints ONE line with no token, saying to setOPENDOX_STATE_DIRto a folder that is not hidden and start again (console_access.UNOPENABLE_HINT). The case runs both entry points, finds the line once, right after the copy's path, and finds the token in no line printed. The README's side is openDox#17's (T076). See "Known limits" below.<root>/STATEis<root>/stateand/state-alias/CONSOLE/listsconsole/, while resolving a path keeps the case it was given. Both checks now also compare the directories'(st_dev, st_ino), the copies' directory's own included (within_private_roots). Linux cannot spell one directory two ways without root, so the cases simulate a case-insensitive filesystem by tellingos.statandos.listdirthat the second spelling is the first: the boundary for the state directory, a root inside it and a root holding it, and the static listing. The reviewer's macOS reading is derived from the code, not observed on a Mac, and the same holds here.finallyand left the copy. The first stop is now latched, and every later one is only recorded. Cases: the reviewer's child, which delivers the second stop at exactly that point and now exits 0 with no copy and no traceback; and the latch in the process.fchmodunder a umask that strips owner write each had a mutant the suite let live. The reviewer's three cases kill them (M46, M47, M48).--no-serve. It opened a copy for a server it then closed, and deleted the copy on return. It now publishes, opens and prints no copy. The page's URL is still printed and opened, as before T104, and carries no token. The cases that read a copy now serve once and stop at a Ctrl-C (stopped_once_serving), and each refusal case asserts that it never served.Mutant run 16 at
fb8a1cc4also left one mutant alive: M32, where the configured snapshot is not a served root. Every case named the snapshot at a copy that already existed, so the registered entry's own snapshot (M32b's line) refused it either way.9fe57dffadds a snapshot named at<state>/console/<port>.htmlbefore that copy exists, which only the configured snapshot's own served root refuses.Two follow-ups after round 9.
c4e6c01badds B3's hint line, once Brett had ruled it.0539f8c0answers mutant run 18 at45958bf3, whose one survivor was M36c: the removal never closed the descriptor that reserved the copy, and nothing asked about it.test_a_removal_releases_the_copys_reservationnow asks that, after a removal and where the directory is gone already, no descriptor of this process is the copy's file. That case's first failure printed the copy'srepr, and with it the token. Soopened_urlis kept out ofPrivateCopy'srepr, andtest_a_copys_repr_never_carries_its_tokenpins it.Fix round 10 (
af2a2efb)Copilot's review at
0539f8c0, two threads, both on round 9's tokenless guard. Each case failed first at0539f8c0:r4179091592, one walk for the tokenless plane. The boundary check and the marking each resolved the configured state path for themselves. A link on that path re-pointed between the two left the boundary judging the real state directory and the marking naming a decoy, and an outward static link then served a sibling plane's copy.guard_private_rootsnow walks the state directory once (_walked), judging every directory and link on the way as the writer does, and the boundary and the marking both use that walk's path. Cases:test_a_tokenless_planes_state_link_retargeted_mid_guard_marks_the_real_directory, Copilot's layout. The sibling's copy and the listing stay 404;test_a_tokenless_planes_unsafe_state_path_refuses_its_start: a world-writable, non-sticky directory on the way refuses the tokenless start by name.r4179091624, the platform first. A tokenless plane skipped the platform check, started, and marked a private root that its handlers then judged with the missingO_NONBLOCK.publishand the guard now refuse an unsupported platform by name before anything else, token or not. Cases:test_a_tokenless_plane_refuses_a_platform_without_the_primitives, in the process;test_a_tokenless_start_without_the_posix_primitives_refuses_by_name, the no-identity variant of B2's child.Fix round 11 (
ed6e4769)Copilot's review at
af2a2efb, three threads and one finding in the review's body. Each case failed first ataf2a2efb:r4179239380, another state directory. Two standalone planes of one user can have differentOPENDOX_STATE_DIRvalues. The guard knew only its own plane's copies, so if plane A's web root linked to plane B's state directory, A served B's copy.is_private_filenow first judges the file it was given by what that file holds. A regular file whose head carries a console record is a copy, wherever it lies (_carries_a_console_record); the head is read withpreadfrom the descriptor already open. Case:test_another_state_directorys_copy_is_never_served, through a static link and through a hard link under/source.r4179239411, a removal mid-read. A copy removed after a read opened it, and before the scan could stat its name, matched nothing in the directory. The open file still holds its record, so it is refused. Case:test_a_copy_removed_during_the_scan_is_never_served.r4179239424, a check that cannot be made. These used to let the file through, and each now denies it:EMFILE,EACCES);A private directory that does not exist still holds no copy. Cases:
test_a_private_directory_that_cannot_be_scanned_denies_the_read, withEMFILEsimulated, and withEACCES;test_a_name_whose_status_cannot_be_read_denies_the_read;test_a_file_whose_head_cannot_be_read_is_denied.Previously missed, an entry's payload. On a standalone plane, an entry with both an in-memory payload and a snapshot file served the file, and a 404 where the file was missing. That broke
SnapshotEntry.read_bytes' payload-first contract. The payload comes first again, and only the file fallback is guarded. Case:test_an_entrys_payload_comes_before_its_guarded_file, with the file missing, present, and a private copy.Fix round 12 (
a2e36652)Copilot's review at
1e114a19,r4179793524. Round 11 recognized a copy in another state directory only by its whole record. So another plane's temporary file, part-written with the token in its meta refresh and no record yet, passed the static handler,/sourceand/snapshot.json. A file that grew after it was judged passed too, because the stdlib copies a static file to its end as it is when read.COPY_MARKER, an HTML comment, ahead of any byte of the token. A copy is written from its start, so any part of it that holds a byte of the token already holds the whole marker.is_copy_bytesrecognizes a copy by that marker, or by its whole record.read_unless_privatereads first, then judges what it read, as well as the file by its descriptor.copyfilesends at most the length the file had when it was judged. It reads the body's first bytes before sending anything and judges them, so a copy's bytes are never sent.Cases, each failing first at
1e114a19:test_another_state_directorys_partial_copy_is_never_served, Copilot's layout, through all three readers, GET and HEAD;test_a_file_that_grows_after_its_static_check_never_sends_a_token;test_a_file_that_grows_after_its_read_check_never_returns_a_token;test_a_copy_starts_with_its_marker_before_any_token_byte;test_a_copy_replaced_after_its_read_is_never_returned, which pins the read-first design.The identity match against this plane's own
console/remains as defence in depth.1e114a19had pinned it with a part-written copy (mutant run 23 left M38 alive); with the marker such a copy is known by its bytes, sotest_a_file_in_the_copies_directory_is_refused_by_its_placenow holds a token-bearing file there that the marker cannot recognize.Fix round 13 (
c5fcdfa4)Copilot's review at
a2e36652,r4180089809.urlsplitreadshttp://evil.example\@127.0.0.1:8080/index.htmlas user information at127.0.0.1. A browser takes the backslash for a slash and navigates toevil.example, whose page could then read the token's fragment. The entry points build their own URLs, butwrite_private_copyandopened_urlare public._refuse_page_urlnow requires the authority to be exactly a loopback host, spelled asserve.server_urlspells it, with an optional port of at most 65535. A backslash or a control character anywhere in the URL is refused. Cases:test_a_page_url_a_browser_reads_as_another_host_is_refused, eight URLs, Copilot's first. Seven failed first ata2e36652;test_every_loopback_page_url_a_plane_announces_is_accepted.No test server outlives its run (
d466c1d2)The holder found orphaned
python -m opendox.servechildren of these cases on the machine, hours old. Each was plane B of the B1 case, left by a mutant run: where a mutant made B serve instead of refusing, the case failed, and itsfinallystopped plane A only. Every server child the cases start now runs in its own session, and is reaped with its process group at teardown, pass or fail: SIGTERM, a bounded wait, then SIGKILL. On Linux it also gets SIGTERM from the kernel if the test process dies first (PR_SET_PDEATHSIG), since a killed run runs no teardown. Cases:test_a_server_left_running_is_reaped_with_its_groupandtest_a_server_outlives_no_killed_run. Re-run under mutant M44, the B1 case fails as it must, and no server from that run survives.Known limits, accepted for release 1
After a serve restart, a tab opened against the old serve holds a stale token in its
sessionStorage. The fixed "reload the page" messages (doxbench-chat.js,staging-workbench-model.js, T102's area) then name the wrong remedy on a standalone plane: a reload keeps the stale token. The remedy is the new tab the restart opened, or the new console file. The holder ACCEPTED this for release 1 and passes the copy change to T102's writer.Some browsers cannot open the copy (adversarial review B3). Ubuntu's default snap browser, and Flatpak browsers, are kept out of hidden directories such as
~/.local/state, and a Windows browser under WSL may not open a Linux path. Brett ruled this an accepted limit for release 1, with a hint ("Hint line, accepted limit (Recommended)", 2026-10-04): the start prints one line, with no token, saying to setOPENDOX_STATE_DIRto a folder that is not hidden and start again. The README's side is openDox#17's (T076).The browser's persistent history keeps the fragment (adversarial review B7).
history.replaceStatestrips the token from the address bar and from the tab's session history, but the browser's own history store (Chromium'sDefault/History) has already recorded the opened URL, fragment included. The holder ruled this a limit within the ruled design: the history file is this same user's data, as the 0600 copy is, and it is not served or sent anywhere.Tests
tests/test_console_token_delivery.py(new):/capabilities, under no key and in no byte;/,/capabilities, the snapshots, the project register,/source, the guarded reads without the token), and 8 POST routes. No body and no header carries it. Second, by the copy: the file is 0600 and the directories 0700, all this user's. A copy owned by another uid is refused by the reader (simulated throughgetuid);<script>: a hostile</script><img …>value stays inside, and the record round-trips;generate-and-openhands the opener afile://path, prints the copy's path and never the token, and removes the copy;--no-openopens nothing and still prints the path. These cases serve once and stop at a Ctrl-C, since--no-servewrites no copy;console/, a group- or world-writable state directory, and a non-sticky shared parent (a sticky one is accepted);generate-and-open. Each is refused by name, and nothing is written;<state>/console,<state>/postgres/run, a deeper path under the state directory, or the state directory itself. Each is refused by name, and nothing is written. Separately, a link in a source root that points at the state directory gets 404 from/source;console/that is not 0700 is refused, and a setgid one is accepted;nohupSIGHUP stays ignored. A SIGTERM while the browser opens removes the copy, hosted and local;serveand by the writer and the reader. A second spelling of the state directory, of a root inside it, of a root holding it, or ofconsole/is judged by its identity. Only the first stop is raised, so a second one never leaves a copy. The walk's link-owner rule, the reader's re-judging of the tree and thefchmodunder umask 0o277 are pinned.--no-servewrites, opens and prints no copy. A dead console's copy, temporary file or taken name is swept, and nothing else is. A snapshot named at a copy not yet written refuses the start. Both entry points print the hint line once, right after the copy's path, and no line they print carries the token. A removal releases the copy's reservation, and a copy'sreprnever carries its token;/sourceand/snapshot.json. A file that grows after its checks, or is emptied after its read, never hands out a token.;python -m opendox.cli generate-and-open --local --no-openandpython -m opendox.serve, each in a child process with neither sibling importable.tests/test_console_token_view.py(new, node): fragment taken, kept and stripped; a host's token wins; the degraded probes; a reload keeps the token from storage; the query string is never read; a malformed token is stripped and not kept; blocked storage keeps the token in memory.These standalone children read the token from the private copy (
standalone_child.Child.console_token), because they used to read it from/capabilities:test_capability_honesty.py, its 4 standalone cases and the unknown-tile-kind thread read;test_neutral_turn_scope.py;test_doxbench_defaults.py;test_chat_model_configuration.py's standalone fixture;test_loopback_host_gate.pyreal local serve, which now asserts that no token is on/capabilitiesand that the copy exists exactly when a token is minted.Mutants, 90 of 90 killed (each applied alone, both new test files run, in a throwaway worktree of
c5fcdfa4):M10 (the copy's own path not judged) is retired with the check it mutated: the reverse rule replaced that check, and M12 is the mutant that removes the reverse rule.
The browser, end to end. Chromium (Playwright 1.61.0, the T096 prep harness's driver) ran
opendox generate-and-open --local --no-openfrom a fresh install. The first run was on a LOCAL, never-pushed integration of this branch with #77 andmain. It was re-run at60bace00, at182cac76and atc5fcdfa4, which carry both. 18 of 18 checks passed every time:/index.html;sessionStorageholds the token, andprobeCapabilitiesfills it;/capabilitieshas none;console_requiredwithout it;Referercarries the token, and there was zeropageerror;The whole suite, locally (
testsandtests_runtime, with--basetempandTMPDIRoutside the tree):6db50b03cb899546c979747a60bace00d4b99436a13857ad182cac7614285fbbddb26c349f3288929dcc9bb5fb8a1cc40539f8c0af2a2efbed6e4769d466c1d21e114a19a2e36652c5fcdfa4, the headAt the head, 0 failed. The count grew with the merges: #80's final head carried main's landings since
d0f1efcb(#63, #69, #73, #64, #72 and #77), and then #81, #85, #76 and #82 landed. CI's own reading atc5fcdfa4isselected=4288 passed=4277 skipped=11, againstvalidate.yml's floors 3977 / 3966 and its exact 11.The governed path
openxFactory's existing token-reading suites were run twice, locally only. First against the pinned openDox-code
047bb4fa, then against047bb4fawith T104's commits applied. The last such run applied every server-side change throughc5fcdfa4, at localf18e3342. That is the head's whole server side, rounds 8 to 13 included.cli.py's hunks are left out there: the governed host serves throughopendox.serve, and the pin'scli.pypredates T084's refactor. The suites aretests/ideation-dashboard/test_doxbench_routes.py,test_gate_routes.py,test_shared_identity.pyandtest_staging_seed.py, and they readcaps["console_token"]through_console_token. The result is identical: 239 passed, 1 failed at both. The one failure is the same pre-existing case (test_lens_add_as_cluster_lands_manifest_pending_and_record, 409). Nothing in openxFactory changes, so there is nothing for T094.For T086 (openXdox-code), recorded with the holder. openXdox-code
6a3b93b9's route harnesses (tests/doxbench_routes_harness.py:290,tests/gate_routes_harness.py:139-149) build the server with no host profile registered, so by this PR's rule their plane is standalone, and they readcaps["console_token"]. Composed with openxFactory'sscripts/fordoc_health, 90 cases there fail withKeyError: 'console_token'. All 90 are in 6 files that itstests/declared_exclusion.yamlalready excludes (doc_health), so its CI does not run them. When its openDox pin passes this PR, those harnesses readhttpd.console_token, or register openXdox's profile as a host.Batch N, and T095
T007's batch N landed in openxFactory#1222 (
bdd0f586): #1144 12.4a's amendment is the normative text this PR realizes (see "12.4a, clause by clause" above). F10.1, F13.1, F16.1 and F12.x are unaffected. Plan 034's quickstart § 3 and § 4 step 1, and AT-R1 step 4, read the private copy.T095's harness (openDox-code#75, not edited here) reads the private copy at
<state_dir>/console/<port>.htmlinstead of/capabilities. The holder has its proposed helper and the three line changes. The helper never opens a copy that failed its checks, so a FIFO cannot block it.🤖 Generated with Claude Code