From 1c064bb33b9890b5aeecd5acbdd938d594bce8b2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:36:34 +0000 Subject: [PATCH 01/36] T095: AT-R1's HTTP half, an acceptance harness in its own CI job (plan 034) acceptance/at_r1_http.py is release 1's acceptance test, the HTTP half (FR-011; spec.md section AT-R1, steps 1-4 and the route answers behind steps 5-8). It is not a pytest module and sits outside tests/ and tests_runtime/, so testpaths and F9.1 are unchanged. It: - installs openDox alone into a fresh venv as opendox[local] (R1Q16 (iii)), from a copy of this checkout's tracked files, through the dependency lock; - asserts the clean machine in a fresh, short OPENDOX_STATE_DIR: the four siblings not importable, no omp, no identity broker, no database on the default port or socket and no DSN, and no model binding; - copies both plain repositories into fresh git inits with a git identity; - runs the documented opendox generate-and-open --local ... (T007 batch H's 10.3 addendum, as the openDox root README documents it) on loopback, checking its argv against that line; - fetches /, /snapshot.json (non-empty, neutral per F5.3, a grouping tile) and /capabilities (install.mode == local); - fetches the model catalog with the console token: no available entry; - fetches every route the served bundle names, derived from the running server's module graph, and none may answer 5xx or drop the connection; - stops the entry point with SIGTERM and asserts no bundled PostgreSQL process is left (R1Q16 (iv)). It exits 1 on the first failed assertion, naming it, and 2 on a harness error. .github/workflows/validate.yml gains the acceptance job, which has no database service, because the harness asserts a clean machine. It runs no pytest and installs nothing itself. Making it a required check is a ruleset change for the repository's owner. tests_runtime/test_deploy_shape.py admits that one other job, holds it to no service, no pytest and no pip step, and extends the one-lock rule to the harness's install. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 37 + acceptance/at_r1_http.py | 1156 ++++++++++++++++++++++++++++ tests_runtime/test_deploy_shape.py | 62 +- 3 files changed, 1249 insertions(+), 6 deletions(-) create mode 100644 acceptance/at_r1_http.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5227f2fe..48c99b16 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -299,3 +299,40 @@ jobs: [ "$FAILURES" = "0" ] || { echo "::error::failures $FAILURES, clause (d) requires zero"; rc=1; } [ "$ERRORS" = "0" ] || { echo "::error::errors $ERRORS, clause (d) requires zero"; rc=1; } exit $rc + + # --------------------------------------------------------------------------- + # AT-R1, THE HTTP HALF — plan 034 T095 (FR-011's HTTP half; openxFactory + # `specs/034-opendox-standalone-operation/spec.md` § "AT-R1"). Release 1's + # acceptance: on a clean machine with only openDox installed, a plain git + # repository opens, and the wheel, the radar lens and the chat pane are + # served, with chat's "no model configured" state. + # + # ITS OWN JOB, AND WITH NO DATABASE SERVICE, because the harness ASSERTS a + # clean machine: nothing answers on PostgreSQL's port or socket, and no DSN + # is set. The `validate` job's PostgreSQL service (T036) would break that + # precondition, so the two cannot share a job. + # + # NOT A PYTEST RUN. `acceptance/at_r1_http.py` sits outside `tests/` and + # `tests_runtime/`, so `testpaths` never collects it and #1144's F9.1 is + # unchanged. It installs `opendox[local]` into a fresh venv of its own, + # from this checkout and through the dependency lock, runs the documented + # `opendox generate-and-open --local …` over two plain repositories, checks + # what the server answers, stops it, and asserts that no bundled PostgreSQL + # process is left. It exits non-zero on the first failed assertion, naming + # it. So this job installs nothing itself, and the one `pip install` step + # in this file stays the `validate` job's. + # + # NOT A REQUIRED CHECK. Making it one is a ruleset change for the + # repository's owner (`docs/branch-protection.md`); this file cannot. + acceptance: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: AT-R1, the HTTP half + run: python3 acceptance/at_r1_http.py diff --git a/acceptance/at_r1_http.py b/acceptance/at_r1_http.py new file mode 100644 index 00000000..f6b0c0b2 --- /dev/null +++ b/acceptance/at_r1_http.py @@ -0,0 +1,1156 @@ +#!/usr/bin/env python3 +"""AT-R1, the HTTP half: release 1's acceptance, run in CI (plan 034 T095). + +The brief AT-R1 makes checkable (openxFactory +`specs/034-opendox-standalone-operation/spec.md` § "AT-R1"): *"on a clean +machine with only openDox installed, a plain git repo opens, and the wheel, +radar lens and chat panes work, with chat's 'no model configured' state."* +This harness is that test's HTTP half: steps 1-4, and the route answers behind +steps 5-8. The browser half is a Playwright run on the host (T096), whose +verdict `tests/smoke_signals.py` computes. It realizes FR-011's HTTP half. + +WHAT IT IS NOT. It is not a pytest module, and it sits outside `tests/` and +`tests_runtime/`, so `pyproject.toml`'s `testpaths` never collects it and +#1144's F9.1 is unchanged. It installs the product and drives it from +outside, as a user would, so it is no member of this leg's suite, and FR-006's +"no exclusion" still holds. It runs in its own `acceptance` job in +`.github/workflows/validate.yml`, which has NO database service, because the +harness asserts a clean machine and the `validate` job's PostgreSQL service +would break that precondition. Making `acceptance` a required check is a +ruleset change for the repository's owner. + +WHAT IT DOES, in order. Each step is an assertion with an id; the run stops at +the FIRST that fails, prints `AT-R1 HTTP half: FAIL []: ` and exits 1 +(`--keep-going` reports every failed assertion instead, and still exits 1). +A harness that breaks before a verdict exits 2, never 0. + + 1. INSTALLS openDox ALONE into a fresh venv, as `opendox[local]` (R1Q16 + (iii)), from a copy of this checkout's tracked files. The documented + install is `pip install "opendox[local]"`; no release of `opendox` is + published to a package index, so this installs the same distribution + with the same extra from the checkout, `pip install "[local]"`, + which is the substitution the openDox root's README states for the first + line. With `constraints-cpython312-linux.txt` beside it and a CPython + 3.12 on Linux, the install reads that lock, as every install of this + package does (`tests_runtime/test_deploy_shape.py`); the lock pins + versions and adds no package. + 2. ASSERTS THE CLEAN MACHINE, in a fresh `OPENDOX_STATE_DIR` created empty + for this run (and short, so the bundled server's socket path fits): none + of the four siblings (`openxdox`, `ideation_dashboard`, + `doc_health`, `corpus_adapter_openxfactory`) is importable; `omp` (the + harness the product names, `doxbench_bridge.HARNESS_COMMAND`) is not on + the PATH; no identity broker (`openprofiler-broker`, the one that exists) + is on the PATH and no issuer is set; no database answers on PostgreSQL's + default port or socket and no DSN is set; and neither repository holds a + model binding (`doxbench_binding.bindings_path`). + 3. COPIES BOTH PLAIN REPOSITORIES into fresh `git init`s (AT-R1 step 3): + (a) 5.0's `tests/fixtures/plain-documents`, and (b) three ordinary + Markdown notes with no front matter at all, quickstart.md § 2's. Each gets + a git identity (`git config user.name` and `user.email`), which the served + actor is read from. + 4. RUNS THE DOCUMENTED COMMAND on loopback, once per repository. The command + is the one T007 batch H's 10.3 addendum names and the openDox root's + README documents, `opendox generate-and-open --local …`. The harness fills + the `…` with the verb's own arguments, as quickstart.md § 3 does, and + checks its argv against that line before it runs it. + 5. FETCHES `/` (it must be HTML), `/snapshot.json` (non-empty, and neutral + per F5.3: none of openxFactory's declared governance words in any string + value; and it fills the grouping station, so the chat pane can open, R1Q13 + (a) with (c)) and `/capabilities` (`install.mode == "local"`). + 6. FETCHES THE MODEL CATALOG, presenting `/capabilities`' `console_token` in + `X-XF-Console-Token`, and it must answer with no available entry (16.4). + 7. FETCHES EVERY ROUTE THE PANES CAN REQUEST, and none may answer 5xx or + drop the connection. The list is DERIVED from the served bundle, not kept + here: see `derive_bundle` below. + 8. STOPS THE SERVER (SIGTERM to the entry point alone, as `kill` would), and + asserts that no bundled PostgreSQL process is left running (R1Q16 (iv)). + +HOW THE ROUTE LIST IS DERIVED (`derive_bundle`). From the RUNNING server, not +from the source tree: `/` is fetched, its `' + for m in modules) + return f"{scripts}" + + +JS = "text/javascript" + + +def _failures(verdict) -> list[str]: + return [failure.ident for failure in verdict.failures] + + +def test_the_lexer_skips_comments_and_regular_expressions() -> None: + source = ( + '// const A = "/in-a-line-comment";\n' + '/* const B = "/in-a-block-comment"; */\n' + "const quote = /[\"']/g;\n" + 'const C = "/a-real-route";\n' + "const D = `/source/${key}/${path}`;\n" + "const E = 'single';\n" + ) + values = [value for _quote, value, _before in + harness.JsStrings(source).scan()] + assert values == ["/a-real-route", "/source/", "single"], values + + +def test_an_import_specifier_is_told_from_a_route_literal() -> None: + source = ( + 'import { a } from "./a.js";\n' + 'export * from "./b.js";\n' + 'import "./c.js";\n' + 'const later = () => import("./d.js");\n' + 'const SNAPSHOT = "./snapshot.json";\n' + 'const READ = "/workbench/thread";\n' + 'const SHEET = "/styles.css";\n' + 'const PROSE = "/not a route";\n' + ) + pending: collections.deque = collections.deque() + routes: set[str] = set() + harness._scan_module("/views/x.js", source.encode("utf-8"), pending, routes) + assert list(pending) == [ + ("/views/a.js", True, "/views/x.js"), + ("/views/b.js", True, "/views/x.js"), + ("/views/c.js", True, "/views/x.js"), + ("/views/d.js", False, "/views/x.js"), + ] + # A route is resolved against the PAGE (`/`), not the module that names it, + # as `fetch` resolves it; a stylesheet and a sentence are not routes. + assert routes == {"/snapshot.json", "/workbench/thread"} + + +def test_the_module_graph_terminates_on_a_cycle_and_counts_each_module_once() -> None: + files = { + "/app.js": (JS, 'import "./a.js";\nconst R = "/capabilities";\n'), + "/a.js": (JS, 'import "./b.js";\nconst S = "/workbench/model-catalog";\n'), + "/b.js": (JS, 'import "./a.js";\nimport "./app.js";\n'), + } + verdict = harness.Verdict(keep_going=True) + with served(files) as port: + routes, modules = harness.derive_bundle( + port, _page("./app.js"), {}, verdict, "t") + assert _failures(verdict) == [] + assert modules == 3 + assert routes == ["/capabilities", "/workbench/model-catalog"] + + +def test_a_dynamic_refusal_does_not_hide_a_static_import_of_the_same_path() -> None: + files = { + "/app.js": (JS, 'import("./missing.js").catch(() => null);\n' + 'import { x } from "./child.js";\n'), + "/child.js": (JS, 'import "./missing.js";\nexport const x = 1;\n'), + } + verdict = harness.Verdict(keep_going=True) + with served(files) as port: + _routes, modules = harness.derive_bundle( + port, _page("./app.js"), {}, verdict, "t") + assert _failures(verdict) == ["t.bundle.module /missing.js"] + assert modules == 2 + + +def test_a_dynamic_refusal_alone_is_not_a_failure() -> None: + """10.2a's case: `intent-feed.js` is not owed, its importer degrades.""" + files = {"/app.js": (JS, 'import("./intent-feed.js").catch(() => null);\n')} + verdict = harness.Verdict(keep_going=True) + with served(files) as port: + harness.derive_bundle(port, _page("./app.js"), {}, verdict, "t") + assert _failures(verdict) == [] + + +class _Server: + """The two attributes `check_catalog` reads off a launched server.""" + + label = "t" + + def __init__(self, port: int) -> None: + self.port = port + + +@pytest.mark.parametrize("body, expected", [ + ("[]", ["t.catalog is a catalog"]), + ("null", ["t.catalog is a catalog"]), + ('{"models": 1}', ["t.catalog is a catalog"]), + ("not json", ["t.catalog is a catalog"]), + ('{"models": [1, {"model_id": "m", "available": true}]}', + ["t.catalog offers no available entry"]), + ('{"models": [{"model_id": "m", "available": false}]}', []), + ('{"models": []}', []), +]) +def test_a_malformed_catalog_is_a_named_failure_never_an_exception( + body: str, expected: list[str]) -> None: + files = {harness.CATALOG_ROUTE: ("application/json", body)} + verdict = harness.Verdict(keep_going=True) + with served(files) as port: + harness.check_catalog(_Server(port), "token", verdict) + assert _failures(verdict) == expected + + +def test_the_documented_start_is_the_readme_line_less_its_ellipsis() -> None: + """The one command the openDox root README documents (T007 batch H's + 10.3 addendum): the harness runs these tokens, then fills the `…`.""" + assert harness.DOCUMENTED_START == "opendox generate-and-open --local …" + assert harness.documented_prefix() == ["opendox", "generate-and-open", + "--local"] + assert json.dumps(harness.DOCUMENTED_INSTALL) == '"pip install \\"opendox[local]\\""' From 4dcb4221f8f2bcfb9ee6a60372e34cc04f36d9f2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:00:58 +0000 Subject: [PATCH 07/36] T095 review round 5: the catalog must be the envelope the chat rail adopts (Copilot) Copilot's review of openDox-code#75 at f0e0ffe1 ("previously missed"): a 200 catalog of {"models": []} passed every catalog check, though the chat rail's adoptCatalog (views/doxbench-chat-model.js) adopts only schema_version === 1 and kind === "workbench-model-catalog", and shows any other catalog as unreadable, never as its no-model state. - A named check, [catalog envelope is the one the chat rail adopts], requires schema_version to be the integer 1 (as JavaScript's === 1, so no true and no "1") and kind == "workbench-model-catalog". - So that constant cannot drift from the bundle, the module-graph walk also collects every string literal, and [bundle names the catalog kind] requires the served bundle to name it, as [bundle names the catalog route] already does for the route. - tests/test_at_r1_http_harness.py: the passing catalogs carry a valid envelope, and seven envelopes adoptCatalog refuses are added: missing kind, missing version, version 2, true, "1", and a different kind. Relaxing the int check fails the `true` case. At the local integration with T084 (#77 at b1db1965), the product's real catalog passes: 206 assertions held, PASS. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- acceptance/at_r1_http.py | 43 +++++++++++++++++++++++++++----- tests/test_at_r1_http_harness.py | 43 ++++++++++++++++++++++++-------- 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/acceptance/at_r1_http.py b/acceptance/at_r1_http.py index 9ea59581..f5629654 100644 --- a/acceptance/at_r1_http.py +++ b/acceptance/at_r1_http.py @@ -58,7 +58,9 @@ value; and it fills the grouping station, so the chat pane can open, R1Q13 (a) with (c)) and `/capabilities` (`install.mode == "local"`). 6. FETCHES THE MODEL CATALOG, presenting `/capabilities`' `console_token` in - `X-XF-Console-Token`, and it must answer with no available entry (16.4). + `X-XF-Console-Token`. It must answer the envelope the chat rail adopts + (`schema_version` 1, `kind` `workbench-model-catalog`, `models[]`), with + no available entry (16.4). 7. FETCHES EVERY ROUTE THE PANES CAN REQUEST, and none may answer 5xx or drop the connection. The list is DERIVED from the served bundle, not kept here: see `derive_bundle` below. @@ -174,6 +176,13 @@ #: must also name, so this constant cannot drift from the bundle unseen. CATALOG_ROUTE = "/workbench/model-catalog" +#: The only catalog envelope the chat rail ADOPTS (`views/doxbench-chat-model.js`, +#: `adoptCatalog`: `schema_version === 1`, `kind === CATALOG_WIRE_KIND`, and an +#: array of `models`); it reads any other as unreadable. The served bundle must +#: name the kind too, so this constant cannot drift from the bundle unseen. +CATALOG_KIND = "workbench-model-catalog" +CATALOG_SCHEMA_VERSION = 1 + #: The thread read the chat rail makes on open, and the kind of a grouping #: tile (`views/wheel-model.js`: "clusters -> \"cluster\""). THREAD_ROUTE = "/workbench/thread" @@ -643,9 +652,11 @@ def _judge_module(answer: Answer, path: str, static: bool, importer: str, def _scan_module(path: str, body: bytes, pending: collections.deque, - routes: set[str]) -> None: + routes: set[str], literals: set[str] | None = None) -> None: for _quote, value, before in JsStrings( body.decode("utf-8", "replace")).scan(): + if literals is not None: + literals.add(value) if _DYNAMIC_IMPORT_CONTEXT.search(before): pending.append((_resolve(path, value), False, path)) elif _STATIC_IMPORT_CONTEXT.search(before): @@ -655,10 +666,12 @@ def _scan_module(path: str, body: bytes, pending: collections.deque, def derive_bundle(port: int, index_html: str, capabilities: dict, - verdict: Verdict, label: str) -> tuple[list[str], int]: + verdict: Verdict, label: str, + literals: set[str] | None = None) -> tuple[list[str], int]: """Walk the module graph the served `/` loads, fetching each module from the server, and return `(routes, modules)`: every same-origin path - literal the graph names, and how many modules it holds.""" + literal the graph names, and how many modules it holds. Every string + literal of the graph is added to `literals` where one is given.""" roots, sheets = _graph_roots(index_html, capabilities) for sheet in sheets: answer = get(port, sheet) @@ -683,7 +696,7 @@ def derive_bundle(port: int, index_html: str, capabilities: dict, answer = answers[path] _judge_module(answer, path, static, importer, verdict, label) if first and answer.status == 200: - _scan_module(path, answer.body, pending, routes) + _scan_module(path, answer.body, pending, routes, literals) modules = sum(1 for answer in answers.values() if answer.status == 200) return sorted(routes), modules @@ -1106,6 +1119,19 @@ def check_catalog(server: Server, token: str | None, verdict: Verdict) -> None: verdict.check(f"{label}.catalog is a catalog", isinstance(models, list), f"{CATALOG_ROUTE} answered no models[]: " f"{catalog.body[:300]!r}") + # THE ENVELOPE THE RAIL ADOPTS, or it shows "the catalog could not be + # read" and never its no-model state (Copilot review of + # openDox-code#75, at f0e0ffe1). JavaScript's `=== 1` admits no `true` + # and no `"1"`, so neither does this. + envelope = as_object(payload) + version = envelope.get("schema_version") + verdict.check(f"{label}.catalog envelope is the one the chat rail adopts", + type(version) is int and version == CATALOG_SCHEMA_VERSION + and envelope.get("kind") == CATALOG_KIND, + f"the catalog's envelope is schema_version={version!r}, " + f"kind={envelope.get('kind')!r}; the chat rail adopts only " + f"schema_version={CATALOG_SCHEMA_VERSION}, " + f"kind={CATALOG_KIND!r}") available = [as_object(m).get("model_id") for m in as_list(models) if as_object(m).get("available")] verdict.check(f"{label}.catalog offers no available entry", not available, @@ -1155,15 +1181,20 @@ def check_routes(server: Server, index: Answer, snapshot: dict, caps: dict, token: str | None, verdict: Verdict) -> None: """Step 7: every route the served bundle names answers, below 5xx.""" label = server.label + literals: set[str] = set() routes, modules = derive_bundle(server.port, index.body.decode("utf-8", "replace"), - caps, verdict, label) + caps, verdict, label, literals) note(f"derived from the served bundle: {modules} modules, " f"{len(routes)} routes: {', '.join(routes)}") verdict.check(f"{label}.bundle names the catalog route", CATALOG_ROUTE in routes, f"the served bundle no longer names {CATALOG_ROUTE}, so " "this harness's catalog step asks the wrong route") + verdict.check(f"{label}.bundle names the catalog kind", + CATALOG_KIND in literals, + f"the served bundle no longer names {CATALOG_KIND!r}, so " + "this harness's catalog envelope check is stale") for target in requests_for(routes, snapshot, grouping_field_of(caps)): answer = get(server.port, target, token=token) note(f"GET {target} -> {answer.describe()}") diff --git a/tests/test_at_r1_http_harness.py b/tests/test_at_r1_http_harness.py index 2eb02864..1933a7c3 100644 --- a/tests/test_at_r1_http_harness.py +++ b/tests/test_at_r1_http_harness.py @@ -21,7 +21,8 @@ * a module refused as a DYNAMIC import is still judged where another module imports it STATICALLY (Copilot review of #75 at 1c064bb3, r4170450448); * a malformed 200 catalog is a named failure, never an exception - (r4170450491). + (r4170450491), and so is a catalog whose envelope the chat rail would + not adopt (Copilot review of #75 at f0e0ffe1). """ from __future__ import annotations @@ -143,15 +144,19 @@ def test_the_module_graph_terminates_on_a_cycle_and_counts_each_module_once() -> files = { "/app.js": (JS, 'import "./a.js";\nconst R = "/capabilities";\n'), "/a.js": (JS, 'import "./b.js";\nconst S = "/workbench/model-catalog";\n'), - "/b.js": (JS, 'import "./a.js";\nimport "./app.js";\n'), + "/b.js": (JS, 'import "./a.js";\nimport "./app.js";\n' + 'const KIND = "workbench-model-catalog";\n'), } verdict = harness.Verdict(keep_going=True) + literals: set[str] = set() with served(files) as port: routes, modules = harness.derive_bundle( - port, _page("./app.js"), {}, verdict, "t") + port, _page("./app.js"), {}, verdict, "t", literals) assert _failures(verdict) == [] assert modules == 3 assert routes == ["/capabilities", "/workbench/model-catalog"] + # every string literal of the graph is collected, the kind among them + assert harness.CATALOG_KIND in literals def test_a_dynamic_refusal_does_not_hide_a_static_import_of_the_same_path() -> None: @@ -186,15 +191,33 @@ def __init__(self, port: int) -> None: self.port = port +_ENVELOPE = '"schema_version": 1, "kind": "workbench-model-catalog"' +_NOT_A_CATALOG = ["t.catalog is a catalog", + "t.catalog envelope is the one the chat rail adopts"] +_WRONG_ENVELOPE = ["t.catalog envelope is the one the chat rail adopts"] + + @pytest.mark.parametrize("body, expected", [ - ("[]", ["t.catalog is a catalog"]), - ("null", ["t.catalog is a catalog"]), - ('{"models": 1}', ["t.catalog is a catalog"]), - ("not json", ["t.catalog is a catalog"]), - ('{"models": [1, {"model_id": "m", "available": true}]}', + ("[]", _NOT_A_CATALOG), + ("null", _NOT_A_CATALOG), + ('{"models": 1}', _NOT_A_CATALOG), + ("not json", _NOT_A_CATALOG), + ('{%s, "models": [1, {"model_id": "m", "available": true}]}' % _ENVELOPE, ["t.catalog offers no available entry"]), - ('{"models": [{"model_id": "m", "available": false}]}', []), - ('{"models": []}', []), + ('{%s, "models": [{"model_id": "m", "available": false}]}' % _ENVELOPE, []), + ('{%s, "models": []}' % _ENVELOPE, []), + # the envelopes `adoptCatalog` refuses (Copilot review of #75 at f0e0ffe1) + ('{"models": []}', _WRONG_ENVELOPE), + ('{"schema_version": 1, "models": []}', _WRONG_ENVELOPE), + ('{"kind": "workbench-model-catalog", "models": []}', _WRONG_ENVELOPE), + ('{"schema_version": 2, "kind": "workbench-model-catalog", "models": []}', + _WRONG_ENVELOPE), + ('{"schema_version": true, "kind": "workbench-model-catalog", "models": []}', + _WRONG_ENVELOPE), + ('{"schema_version": "1", "kind": "workbench-model-catalog", "models": []}', + _WRONG_ENVELOPE), + ('{"schema_version": 1, "kind": "workbench-model-catalog-v2", "models": []}', + _WRONG_ENVELOPE), ]) def test_a_malformed_catalog_is_a_named_failure_never_an_exception( body: str, expected: list[str]) -> None: From 64dbb6d8847b0d93646924c498f519b81fd9839b Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:23:04 +0000 Subject: [PATCH 08/36] T095: read the console token as T104 delivers it, from the opener file (plan 034) T104 (RULED openxFactory#656 5963851934, adversarial review 2's M5) takes the console token off /capabilities on a standalone plane. The start prints the PATH of a 0600 opener, /console/ .html, whose meta-refresh carries #console_token= in the URL's fragment, and removes it when the server stops. The harness read the token from /capabilities, so against T104 it stopped at [a.capabilities console_token]. The harness now reads the token the way the user's browser is handed it, with the standard library and nothing imported from the product: - [capabilities carries no console token]; - [console opener printed], [console opener is OPENDOX_STATE_DIR/ console/.html], [console opener is outside the served repository], and [console opener is private]: this user's regular file, mode exactly 0600, one link, in a directory no one else can enter; - [console opener forwards with the token in its fragment]: one meta-refresh, to this plane on loopback, with the token in the fragment and never in the query or the path; - [catalog refuses a caller without the console token], so the token the opener carries is what opens the catalog; - after the stop, [stop removes the console opener] and [console token never printed], over both streams of the whole serve. No failure message quotes the token. tests/test_at_r1_http_harness.py gains 29 cases for these decisions. Its stub server guards the catalog as the product does. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- acceptance/at_r1_http.py | 279 ++++++++++++++++++++++++++++--- tests/test_at_r1_http_harness.py | 227 ++++++++++++++++++++++++- 2 files changed, 480 insertions(+), 26 deletions(-) diff --git a/acceptance/at_r1_http.py b/acceptance/at_r1_http.py index f5629654..8f012960 100644 --- a/acceptance/at_r1_http.py +++ b/acceptance/at_r1_http.py @@ -56,16 +56,29 @@ 5. FETCHES `/` (it must be HTML), `/snapshot.json` (non-empty, and neutral per F5.3: none of openxFactory's declared governance words in any string value; and it fills the grouping station, so the chat pane can open, R1Q13 - (a) with (c)) and `/capabilities` (`install.mode == "local"`). - 6. FETCHES THE MODEL CATALOG, presenting `/capabilities`' `console_token` in + (a) with (c)) and `/capabilities` (`install.mode == "local"`, and NO + `console_token`: a standalone plane hands its token to no loopback + caller, T104). + 6. READS THE CONSOLE TOKEN THE WAY THE USER'S BROWSER IS HANDED IT (plan 034 + T104; RULED openxFactory#656 `5963851934`). The start prints the PATH of + a private opener file, `/console/.html`, and + never the token. The file must be this user's regular file, mode 0600, + with one link, in a directory no one else can enter, and outside the + served repository. Its meta-refresh forwards to this plane on loopback + with `#console_token=` in the URL's FRAGMENT, and never in the + query. The harness reads that file itself, with the standard library, + as a browser would, and imports nothing from the product. + 7. FETCHES THE MODEL CATALOG, presenting that token in `X-XF-Console-Token`. It must answer the envelope the chat rail adopts (`schema_version` 1, `kind` `workbench-model-catalog`, `models[]`), with - no available entry (16.4). - 7. FETCHES EVERY ROUTE THE PANES CAN REQUEST, and none may answer 5xx or + no available entry (16.4). Asked WITHOUT the token, it must refuse, so + the token the opener carries is the one that opens it. + 8. FETCHES EVERY ROUTE THE PANES CAN REQUEST, and none may answer 5xx or drop the connection. The list is DERIVED from the served bundle, not kept here: see `derive_bundle` below. - 8. STOPS THE SERVER (SIGTERM to the entry point alone, as `kill` would), and - asserts that no bundled PostgreSQL process is left running (R1Q16 (iv)). + 9. STOPS THE SERVER (SIGTERM to the entry point alone, as `kill` would), and + asserts that no bundled PostgreSQL process is left running (R1Q16 (iv)) + and that the opener file went with the server. HOW THE ROUTE LIST IS DERIVED (`derive_bundle`). From the RUNNING server, not from the source tree: `/` is fetched, its `') + verdict = harness.Verdict(keep_going=True) + with served(files) as port: + harness.derive_bundle(port, page, {}, verdict, "t") + assert _failures(verdict) == expected + + def test_a_dynamic_refusal_alone_is_not_a_failure() -> None: """10.2a's case: `intent-feed.js` is not owed, its importer degrades.""" files = {"/app.js": (JS, 'import("./intent-feed.js").catch(() => null);\n')} From d53a737806b69d02fde86edfbe68cb1292223f4b Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:55:18 +0000 Subject: [PATCH 11/36] T095: the opener's JSON record must describe its own forward (plan 034) T104's writer specified the opener's format (holder, 2026-10-03): JSON in `\n") + + +def _opener_page(*targets: str, records: list | None = None) -> str: + """An opener as T104 writes one: a meta-refresh to each target, and the + record of the first (or `records`, as given).""" metas = "".join(f'\n' for t in targets) + if records is None: + records = [_record(targets[0].replace("&", "&"))] if targets else [] + scripts = "".join(_record_script(r) for r in records) return ("\n\n" - f"{metas}Opening openDox\n") + f"{metas}Opening openDox\n{scripts}" + "\n") def _opener(tmp_path: Path, page: str | None = None, *, mode: int = 0o600, @@ -395,6 +418,45 @@ def test_the_opener_is_read_as_a_browser_reads_its_refresh( assert token == TOKEN +RECORD = "t.console opener record agrees with its forward" + + +@pytest.mark.parametrize("records", [ + [], + [_record(), _record()], + ["not json"], + [["a", "list"]], + [_record(kind="opendox-console")], + [_record(schema_version=2)], + [_record(schema_version="1")], + [_record(port=PORT + 1)], + [_record(port=str(PORT))], + [_record(console_token=TOKEN[::-1])], + [_record(opened_url=FORWARD.replace("index.html", "other.html"))], + [_record(page_url=f"http://127.0.0.1:{PORT}/other.html")], +], ids=["none", "two", "not-json", "not-an-object", "kind", "version", + "version-string", "port", "port-string", "token", "opened-url", + "page-url"]) +def test_a_record_that_disagrees_with_the_forward_is_a_named_failure( + tmp_path: Path, records: list) -> None: + page = _opener_page(FORWARD, records=records).replace( + '"not json"', "not json") + state, opener = _opener(tmp_path, page) + failures, _path, token = _read(state, _printed(opener)) + assert failures == [RECORD] + assert token == TOKEN + + +def test_a_record_escaped_as_t104_writes_it_agrees(tmp_path: Path) -> None: + """A token-free `page_url` with every character T104 escapes in it.""" + target = f"http://127.0.0.1:{PORT}/index.html#console_token={TOKEN}" + page = _opener_page(target, records=[_record(target, note="<&>")]) + state, opener = _opener(tmp_path, page) + failures, _path, token = _read(state, _printed(opener)) + assert failures == [] + assert token == TOKEN + + def test_a_published_token_is_a_named_failure() -> None: verdict = harness.Verdict(keep_going=True) harness.check_no_published_token("t", {"console_token": TOKEN}, verdict) From 5636eb8d5c23c21998b2ed1741096f7b4325b8b7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:13:45 +0000 Subject: [PATCH 12/36] T095: the opener's forward is judged as a browser reads it, and never quoted (plan 034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review of openDox-code#75 at d53a7378: - r4173842763: `http://example.invalid\@127.0.0.1:/…` passed, though a browser reads the backslash as `/` and opens example.invalid, where a script can read the fragment. A token in the URL's user information also passed, because `hostname` leaves the user information out. A forward holding a backslash, whitespace or a control character is now refused before it is parsed (_UNPARSED_ALIKE), and so is one with user information. - r4173842794: a refused destination's message quoted its authority and path, which can hold the token. The message is now fixed, with no URL component. A record's reason replaces the token wherever it appears. - r4173842805 and r4173842811: an unparseable `console file://[…` line, or a malformed refresh URL, raised ValueError, which is a harness ERROR (exit 2) that stops --keep-going. Both are now the named failures `console opener printed` and `console opener forwards with the token in its fragment`. The tests add 14 cases: seven forwards (backslash, the token as the user, the token as the password, a tab, a space, a malformed host, a port out of range); five refused forwards whose messages must not hold the token; an unparseable printed location; and a record reason that must not hold the token. Six mutants are each killed: no divergence check, no user-information check, the split unguarded, the URL quoted again, the location split unguarded, and the record reason unredacted. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- acceptance/at_r1_http.py | 51 +++++++++++++++++++++++------ tests/test_at_r1_http_harness.py | 55 +++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/acceptance/at_r1_http.py b/acceptance/at_r1_http.py index f91b5734..59bc3e55 100644 --- a/acceptance/at_r1_http.py +++ b/acceptance/at_r1_http.py @@ -1272,8 +1272,13 @@ def record_disagrees_because(records: list[str], port: int, if not (isinstance(page_url, str) and isinstance(target, str) and target.split("#", 1)[0] == page_url): problems.append("its `page_url` is not the forward's page") - return "the opener's record disagrees with its forward: " + "; ".join( - problems) if problems else None + if not problems: + return None + # A record value may hold the token (a `kind` that is the token, say), + # and a reason reaches the CI log, so it never quotes it. + reason = "the opener's record disagrees with its forward: " + "; ".join( + problems) + return reason.replace(token, "") def refresh_target(content: str) -> str | None: @@ -1296,7 +1301,13 @@ def opener_location(printed: str) -> Path | None: where = match.group("where") if not where.startswith("file:"): return Path(where) - parts = urllib.parse.urlsplit(where) + # An unparseable file URL is the product's output, so it is the named + # `console opener printed` failure, never a harness error (Copilot review + # of openDox-code#75 at d53a7378, r4173842805). + try: + parts = urllib.parse.urlsplit(where) + except ValueError: + return None if parts.netloc not in ("", "localhost") or parts.query or parts.fragment: return None return Path(urllib.parse.unquote(parts.path)) @@ -1352,22 +1363,44 @@ def _within(path: Path, root: Path) -> bool: return real.is_relative_to(Path(os.path.realpath(root))) +#: What a browser's URL parser reads differently from `urllib.parse`, so a +#: forward carrying it is refused before it is parsed at all: a backslash, +#: which a browser reads as `/` in an http URL (`http://evil\@127.0.0.1/` +#: goes to `evil`), and whitespace and control characters, which a browser +#: strips or rejects. +_UNPARSED_ALIKE = re.compile(r"[\\\x00-\x20\x7f]") + + def token_in_fragment(targets: list[str | None], port: int) -> tuple[str | None, str]: """The token the opener's one forward carries in its FRAGMENT, or `None` - and why not. No message here quotes a token.""" + and why not. No message here quotes a token, or any part of the URL, + which could hold one (Copilot review of openDox-code#75 at d53a7378, + r4173842794).""" if len(targets) != 1 or targets[0] is None: return None, (f"the opener has {len(targets)} meta-refresh forwards, " "not one that names a URL") - parts = urllib.parse.urlsplit(targets[0]) + elsewhere = (f"the opener does not forward to this plane on loopback " + f"port {port} (its forward is not quoted, because it may " + "hold the token)") + target = targets[0] + # A browser and `urllib.parse` must read the SAME destination, or the + # check below judges a URL the browser never opens (r4173842763). + if _UNPARSED_ALIKE.search(target): + return None, (f"{elsewhere}: it holds a backslash, whitespace or a " + "control character, which a browser reads differently") try: + parts = urllib.parse.urlsplit(target) target_port = parts.port - except ValueError: - target_port = None + except ValueError: # a malformed authority or port (r4173842811) + return None, f"{elsewhere}: it is not a URL this harness can parse" + if parts.username is not None or parts.password is not None \ + or "@" in parts.netloc: + return None, (f"{elsewhere}: it carries user information before its " + "host") if (parts.scheme != "http" or parts.hostname not in LOOPBACK_HOSTS or target_port != port): - return None, (f"the opener forwards to {parts.scheme}://{parts.netloc}" - f"{parts.path}, not to this plane on loopback port {port}") + return None, elsewhere if CONSOLE_FRAGMENT_KEY in urllib.parse.parse_qs(parts.query, keep_blank_values=True): return None, ("the opener's forward carries the token in its QUERY, " diff --git a/tests/test_at_r1_http_harness.py b/tests/test_at_r1_http_harness.py index 927cd745..3ccdf4a5 100644 --- a/tests/test_at_r1_http_harness.py +++ b/tests/test_at_r1_http_harness.py @@ -537,9 +537,22 @@ def test_a_hard_linked_opener_is_a_named_failure(tmp_path: Path) -> None: _opener_page(FORWARD + f"&console_token={TOKEN}"), _opener_page(FORWARD, FORWARD), _opener_page(), + # what a browser reads differently from urllib.parse (Copilot review of + # #75 at d53a7378, r4173842763 and r4173842811) + _opener_page(f"http://example.invalid\\@127.0.0.1:{PORT}/index.html" + f"#console_token={TOKEN}"), + _opener_page(f"http://{TOKEN}@127.0.0.1:{PORT}/index.html" + f"#console_token={TOKEN}"), + _opener_page(f"http://user:{TOKEN}@127.0.0.1:{PORT}/index.html" + f"#console_token={TOKEN}"), + _opener_page(f"http://127.0.0.1:{PORT}/index.html\t#console_token={TOKEN}"), + _opener_page(f"http://127.0.0.1:{PORT}/ index.html#console_token={TOKEN}"), + _opener_page(f"http://[broken:{PORT}/index.html#console_token={TOKEN}"), + _opener_page(f"http://127.0.0.1:99999/index.html#console_token={TOKEN}"), ], ids=["query", "query-and-fragment", "path", "other-port", "off-loopback", "https", "no-token", "empty-token", "two-tokens", "two-forwards", - "no-forward"]) + "no-forward", "backslash", "userinfo-token", "userinfo-password", + "tab", "space", "malformed-host", "port-out-of-range"]) def test_a_forward_that_leaks_or_misses_the_token_is_a_named_failure( tmp_path: Path, page: str) -> None: state, opener = _opener(tmp_path, page) @@ -548,6 +561,46 @@ def test_a_forward_that_leaks_or_misses_the_token_is_a_named_failure( assert token is None +@pytest.mark.parametrize("target", [ + f"http://example.invalid:{PORT}/{TOKEN}/index.html#console_token={TOKEN}", + f"https://127.0.0.1:{PORT}/{TOKEN}#console_token={TOKEN}", + f"http://{TOKEN}.example:{PORT}/#console_token={TOKEN}", + f"http://[{TOKEN}:{PORT}/#console_token={TOKEN}", + f"http://x\\{TOKEN}@127.0.0.1:{PORT}/#console_token={TOKEN}", +], ids=["path-elsewhere", "path-https", "host", "malformed", "backslash"]) +def test_a_refused_forward_never_quotes_its_url(tmp_path: Path, + target: str) -> None: + """A refused destination is not quoted, because it may hold the token + (Copilot review of #75 at d53a7378, r4173842794).""" + state, opener = _opener(tmp_path, _opener_page(target)) + verdict = harness.Verdict(keep_going=True) + harness.check_console_opener("t", PORT, _printed(opener), state, + tmp_path / "repo", verdict) + assert [failure.ident for failure in verdict.failures] == [FRAGMENT] + assert not any(TOKEN in str(failure) for failure in verdict.failures) + + +def test_an_unparseable_printed_location_is_a_named_failure( + tmp_path: Path) -> None: + """The product's output, so a named failure and never a harness error + (Copilot review of #75 at d53a7378, r4173842805).""" + state, _opener_path = _opener(tmp_path) + failures, path, token = _read( + state, " console file://[broken/opener.html (this user's copy)\n") + assert failures == ["t.console opener printed"] + assert (path, token) == (None, None) + + +def test_a_record_reason_never_quotes_the_token(tmp_path: Path) -> None: + page = _opener_page(FORWARD, records=[_record(kind=TOKEN, port=TOKEN)]) + state, opener = _opener(tmp_path, page) + verdict = harness.Verdict(keep_going=True) + harness.check_console_opener("t", PORT, _printed(opener), state, + tmp_path / "repo", verdict) + assert [failure.ident for failure in verdict.failures] == [RECORD] + assert not any(TOKEN in str(failure) for failure in verdict.failures) + + def test_a_token_in_the_query_is_named_and_never_quoted(tmp_path: Path) -> None: state, opener = _opener(tmp_path, _opener_page( f"http://127.0.0.1:{PORT}/index.html?console_token={TOKEN}" From 486e426edb6e9fd851902aa580a58d2b65958625 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:20:44 +0000 Subject: [PATCH 13/36] T095: the opener's refresh and token are read as the browser and the page read them (plan 034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review of openDox-code#75 at 5636eb8d: - r4173894317: the refresh delay was ignored, so `content="invalid;url=…"` or `"-1;url=…"` yielded a token, though a browser aborts that refresh and leaves the user on the opener. `refresh_target` now follows the HTML standard's shared declarative refresh steps for the forms a refresh takes. The delay must start with an ASCII digit or `.`. A `,` or `;` separator and the `url =` keyword, in any case and spacing, are optional. A quoted URL ends at its closing quote. A refresh with no URL part opens no console. - r4173894352: any non-empty fragment value was taken as the token, but T104's `takeDeliveredConsoleToken` (web/views/notebook.js) discards a value that is not `[A-Za-z0-9_-]{16,512}`. The harness could then authenticate where the user's page cannot. The forward's token must now match that shape exactly (CONSOLE_TOKEN_SHAPE), and the message does not quote the value. The tests add 14 cases. Four refresh forms a browser follows: comma and spaces, a fractional delay, no `url` keyword, and a quote that truncates. Four delays that abort the refresh: a word, a negative, an empty delay, and a bare `url=`. Four tokens the page discards: too short, too long, dots, and an escaped `+`. Two tokens at the page's bounds, 16 and 512 characters. Seven mutants are each killed. At the local integration with T104 (#84 cb899546), the real opener passes (r37: PASS, 302 assertions held). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- acceptance/at_r1_http.py | 51 +++++++++++++++++++++++++++----- tests/test_at_r1_http_harness.py | 45 +++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/acceptance/at_r1_http.py b/acceptance/at_r1_http.py index 59bc3e55..1160cfc5 100644 --- a/acceptance/at_r1_http.py +++ b/acceptance/at_r1_http.py @@ -205,6 +205,9 @@ CONSOLE_TOKEN_FIELD = "console_token" CONSOLE_DIRNAME = "console" CONSOLE_FRAGMENT_KEY = "console_token" +#: The only token the page accepts from the fragment (T104's +#: `web/views/notebook.js`, `CONSOLE_TOKEN_SHAPE`, `^[A-Za-z0-9_-]{16,512}$`). +CONSOLE_TOKEN_SHAPE = re.compile(r"[A-Za-z0-9_-]{16,512}") CONSOLE_LINE = re.compile(r"^[ \t]*console (?P(?:file:|/)\S*)", re.M) #: The opener's machine-readable record, "for a harness or a script" #: (`opendox.console_access`, T104): JSON in @@ -1281,15 +1284,37 @@ def record_disagrees_because(records: list[str], port: int, return reason.replace(token, "") +_ASCII_WHITESPACE = " \t\n\f\r" + + def refresh_target(content: str) -> str | None: - """The URL a refresh's `content` names (`0;url=`, quoted or not).""" - _delay, separator, rest = content.partition(";") - rest = rest.strip() - if not separator or rest[:4].lower() != "url=": + """The URL a browser's refresh follows from `content`, or `None` where a + browser follows none: the HTML standard's "shared declarative refresh + steps", for the forms a refresh takes (`0;url=`, `0; URL='…'`, + `0,url=…`, `.5;url=…`, `0;`). + + A browser ABORTS the refresh when the first non-whitespace code point is + neither an ASCII digit nor `.`, so `invalid;url=…` and `-1;url=…` open + nothing, and a user is left on the opener (Copilot review of + openDox-code#75 at 5636eb8d, r4173894317). A `content` with no URL part + refreshes the opener itself, which opens no console either.""" + rest = content.lstrip(_ASCII_WHITESPACE) + digits = len(rest) - len(rest.lstrip("0123456789")) + if digits == 0 and not rest.startswith("."): return None - target = rest[4:].strip() - if len(target) >= 2 and target[0] == target[-1] and target[0] in "'\"": - target = target[1:-1] + rest = rest[digits:].lstrip("0123456789.") + rest = rest.lstrip(_ASCII_WHITESPACE) + if rest[:1] in (";", ","): + rest = rest[1:] + rest = rest.lstrip(_ASCII_WHITESPACE) + if rest[:3].lower() == "url": + after = rest[3:].lstrip(_ASCII_WHITESPACE) + if after.startswith("="): + rest = after[1:].lstrip(_ASCII_WHITESPACE) + if rest[:1] in ("'", '"'): + quote, rest = rest[0], rest[1:] + rest = rest.split(quote, 1)[0] + target = rest.strip(_ASCII_WHITESPACE) return target or None @@ -1379,7 +1404,8 @@ def token_in_fragment(targets: list[str | None], r4173842794).""" if len(targets) != 1 or targets[0] is None: return None, (f"the opener has {len(targets)} meta-refresh forwards, " - "not one that names a URL") + "not one a browser follows to a URL (a delay that " + "does not start with a digit or `.` aborts it)") elsewhere = (f"the opener does not forward to this plane on loopback " f"port {port} (its forward is not quoted, because it may " "hold the token)") @@ -1411,6 +1437,15 @@ def token_in_fragment(targets: list[str | None], if len(values) != 1 or not values[0]: return None, (f"the opener's forward carries no single " f"`#{CONSOLE_FRAGMENT_KEY}=` in its fragment") + # THE TOKEN THE PAGE ACCEPTS, or the harness could authenticate where the + # user's page cannot: T104's `takeDeliveredConsoleToken` + # (`web/views/notebook.js`) discards any other value (Copilot review of + # openDox-code#75 at 5636eb8d, r4173894352). + if not CONSOLE_TOKEN_SHAPE.fullmatch(values[0]): + return None, ("the opener's forward carries a token the page " + "discards: `takeDeliveredConsoleToken` accepts only " + f"{CONSOLE_TOKEN_SHAPE.pattern} (the value is not " + "quoted)") if values[0] in parts.path or values[0] in parts.query: return None, ("the opener's forward carries the token outside its " "fragment too") diff --git a/tests/test_at_r1_http_harness.py b/tests/test_at_r1_http_harness.py index 3ccdf4a5..b668f23a 100644 --- a/tests/test_at_r1_http_harness.py +++ b/tests/test_at_r1_http_harness.py @@ -350,10 +350,11 @@ def _record_script(record) -> str: f"{text}\n") -def _opener_page(*targets: str, records: list | None = None) -> str: +def _opener_page(*targets: str, records: list | None = None, + delay: str = "0;url=") -> str: """An opener as T104 writes one: a meta-refresh to each target, and the record of the first (or `records`, as given).""" - metas = "".join(f'\n' + metas = "".join(f'\n' for t in targets) if records is None: records = [_record(targets[0].replace("&", "&"))] if targets else [] @@ -396,6 +397,18 @@ def _read(state: Path, printed: str, served_root: Path | None = None): FRAGMENT = "t.console opener forwards with the token in its fragment" +@pytest.mark.parametrize("token", ["a" * 16, "Z_-9" * 128], + ids=["shortest", "longest"]) +def test_a_token_at_the_page_s_bounds_is_delivered(tmp_path: Path, + token: str) -> None: + target = f"http://127.0.0.1:{PORT}/index.html#console_token={token}" + state, opener = _opener(tmp_path, _opener_page( + target, records=[_record(target, console_token=token)])) + failures, _path, delivered = _read(state, _printed(opener)) + assert failures == [] + assert delivered == token + + def test_the_opener_t104_writes_delivers_its_token(tmp_path: Path) -> None: state, opener = _opener(tmp_path) failures, path, token = _read(state, _printed(opener)) @@ -409,7 +422,15 @@ def test_the_opener_t104_writes_delivers_its_token(tmp_path: Path) -> None: _opener_page(FORWARD).replace("0;url=", "0; URL="), _opener_page(FORWARD).replace(f"url={FORWARD}", f"url='{FORWARD}'"), _opener_page(FORWARD + "&then=1"), -], ids=["spaced-upper-URL", "quoted", "escaped-ampersand"]) + # the refresh forms a browser follows (the HTML standard's shared + # declarative refresh steps) + _opener_page(FORWARD, delay=" 0 , url = "), + _opener_page(FORWARD, delay=".5;url="), + _opener_page(FORWARD, delay="0; "), + _opener_page(FORWARD).replace(f"url={FORWARD}", + f"url=\'{FORWARD}\' trailing"), +], ids=["spaced-upper-URL", "quoted", "escaped-ampersand", "comma-spaced", + "fractional", "no-url-keyword", "quote-truncates"]) def test_the_opener_is_read_as_a_browser_reads_its_refresh( tmp_path: Path, page: str) -> None: state, opener = _opener(tmp_path, page) @@ -549,10 +570,26 @@ def test_a_hard_linked_opener_is_a_named_failure(tmp_path: Path) -> None: _opener_page(f"http://127.0.0.1:{PORT}/ index.html#console_token={TOKEN}"), _opener_page(f"http://[broken:{PORT}/index.html#console_token={TOKEN}"), _opener_page(f"http://127.0.0.1:99999/index.html#console_token={TOKEN}"), + # a delay that aborts the browser's refresh (Copilot review of #75 at + # 5636eb8d, r4173894317) + _opener_page(FORWARD, delay="invalid;url="), + _opener_page(FORWARD, delay="-1;url="), + _opener_page(FORWARD, delay="; url="), + _opener_page(FORWARD, delay="url="), + # a token the page's `takeDeliveredConsoleToken` discards (r4173894352) + _opener_page(f"http://127.0.0.1:{PORT}/index.html#console_token=short"), + _opener_page(f"http://127.0.0.1:{PORT}/index.html#console_token=" + + "a" * 513), + _opener_page(f"http://127.0.0.1:{PORT}/index.html#console_token=" + "token.with.dots.0123456789"), + _opener_page(f"http://127.0.0.1:{PORT}/index.html#console_token=" + "token%2Bplus%2B0123456789"), ], ids=["query", "query-and-fragment", "path", "other-port", "off-loopback", "https", "no-token", "empty-token", "two-tokens", "two-forwards", "no-forward", "backslash", "userinfo-token", "userinfo-password", - "tab", "space", "malformed-host", "port-out-of-range"]) + "tab", "space", "malformed-host", "port-out-of-range", + "delay-word", "delay-negative", "delay-empty", "delay-absent", + "token-short", "token-oversized", "token-dots", "token-plus"]) def test_a_forward_that_leaks_or_misses_the_token_is_a_named_failure( tmp_path: Path, page: str) -> None: state, opener = _opener(tmp_path, page) From 142d1352553b63bc048739273c0bd428d31f0a5b Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:31:43 +0000 Subject: [PATCH 14/36] T095: the opener's forward must open the console page (plan 034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review of openDox-code#75 at 486e426e, previously missed: the destination check validated the authority but not the path. With a record that agreed, `/missing.html#console_token=…` or `/snapshot.json#console_token=…` passed every opener assertion, while the user's browser would open a missing page or JSON instead of the console. The forward's path must now be one of CONSOLE_PAGES, `/` or the `/index.html` that T104's entry points open. An empty path is `/`, as a browser reads it. The message does not quote the path. The tests add 5 cases: `/missing.html`, `/snapshot.json` and `//index.html` are refused; `/` and an empty path are accepted. Three mutants are each killed: no path check, an empty path refused, and `/` not a console page. At the local integration with T104 (#84 cb899546), the real opener passes (r38: PASS, 302 assertions held). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- acceptance/at_r1_http.py | 13 +++++++++++++ tests/test_at_r1_http_harness.py | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/acceptance/at_r1_http.py b/acceptance/at_r1_http.py index 1160cfc5..287d5ed5 100644 --- a/acceptance/at_r1_http.py +++ b/acceptance/at_r1_http.py @@ -218,6 +218,9 @@ CONSOLE_RECORD_SCHEMA_VERSION = 1 OPENER_MODE = 0o600 LOOPBACK_HOSTS = ("127.0.0.1", "::1", "localhost") +#: The paths that serve the console page, `/` and the `/index.html` T104's +#: entry points open (`serve_mod.server_url(httpd, "/index.html")`). +CONSOLE_PAGES = ("/", "/index.html") #: The model-catalog route (`app.js` `CATALOG_ROUTE`), which the derived list #: must also name, so this constant cannot drift from the bundle unseen. @@ -1427,6 +1430,16 @@ def token_in_fragment(targets: list[str | None], if (parts.scheme != "http" or parts.hostname not in LOOPBACK_HOSTS or target_port != port): return None, elsewhere + # THE CONSOLE PAGE, and no other page of this plane: a forward to + # `/missing.html` or `/snapshot.json` opens no console, though every + # later check asks `/` and the catalog itself (Copilot review of + # openDox-code#75 at 486e426e, previously missed). + # (An empty path is `/` to a browser, as the URL standard reads it.) + if (parts.path or "/") not in CONSOLE_PAGES: + return None, (f"the opener's forward opens a page other than the " + f"console ({' or '.join(CONSOLE_PAGES)}) of this plane " + "(the path is not quoted, because it may hold the " + "token)") if CONSOLE_FRAGMENT_KEY in urllib.parse.parse_qs(parts.query, keep_blank_values=True): return None, ("the opener's forward carries the token in its QUERY, " diff --git a/tests/test_at_r1_http_harness.py b/tests/test_at_r1_http_harness.py index b668f23a..fdc2a342 100644 --- a/tests/test_at_r1_http_harness.py +++ b/tests/test_at_r1_http_harness.py @@ -429,8 +429,12 @@ def test_the_opener_t104_writes_delivers_its_token(tmp_path: Path) -> None: _opener_page(FORWARD, delay="0; "), _opener_page(FORWARD).replace(f"url={FORWARD}", f"url=\'{FORWARD}\' trailing"), + # the console page at `/` as well as `/index.html` + _opener_page(f"http://127.0.0.1:{PORT}/#console_token={TOKEN}"), + _opener_page(f"http://127.0.0.1:{PORT}#console_token={TOKEN}"), ], ids=["spaced-upper-URL", "quoted", "escaped-ampersand", "comma-spaced", - "fractional", "no-url-keyword", "quote-truncates"]) + "fractional", "no-url-keyword", "quote-truncates", "root-page", + "empty-path"]) def test_the_opener_is_read_as_a_browser_reads_its_refresh( tmp_path: Path, page: str) -> None: state, opener = _opener(tmp_path, page) @@ -584,12 +588,18 @@ def test_a_hard_linked_opener_is_a_named_failure(tmp_path: Path) -> None: "token.with.dots.0123456789"), _opener_page(f"http://127.0.0.1:{PORT}/index.html#console_token=" "token%2Bplus%2B0123456789"), + # a page of this plane that is not the console, its record agreeing + # (Copilot review of #75 at 486e426e, previously missed) + _opener_page(f"http://127.0.0.1:{PORT}/missing.html#console_token={TOKEN}"), + _opener_page(f"http://127.0.0.1:{PORT}/snapshot.json#console_token={TOKEN}"), + _opener_page(f"http://127.0.0.1:{PORT}//index.html#console_token={TOKEN}"), ], ids=["query", "query-and-fragment", "path", "other-port", "off-loopback", "https", "no-token", "empty-token", "two-tokens", "two-forwards", "no-forward", "backslash", "userinfo-token", "userinfo-password", "tab", "space", "malformed-host", "port-out-of-range", "delay-word", "delay-negative", "delay-empty", "delay-absent", - "token-short", "token-oversized", "token-dots", "token-plus"]) + "token-short", "token-oversized", "token-dots", "token-plus", + "page-missing", "page-snapshot", "page-double-slash"]) def test_a_forward_that_leaks_or_misses_the_token_is_a_named_failure( tmp_path: Path, page: str) -> None: state, opener = _opener(tmp_path, page) From 27479495fac7c00676791c968616c7bf3807c429 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:46:26 +0000 Subject: [PATCH 15/36] T095: external dependencies, unserved hosts, encoded token copies and a failed stop are named failures (plan 034) Copilot review of openDox-code#75 at 142d1352: - r4174355680: the duplicate-token check searched the raw query, so `?extra=#console_token=` passed, though the request line still carries a recoverable copy. The path and query are now also searched in every percent-decoding (`_decodings`, with `+` read as a space and not, until nothing new appears). - previously missed: a forward to `[::1]` passed, though the launched plane listens on 127.0.0.1 alone. The forward's host must now be one the plane answers on. `served_loopback_hosts` probes 127.0.0.1 and ::1 at the server's port, and adds `localhost` where one answers. - previously missed: `_resolve` discarded the origin, so a static import of `https://unreachable.invalid/app.js` was fetched as `/app.js` from loopback and passed. It now keeps any URL that leaves the plane whole. A module or stylesheet from outside the plane is a named failure, `[bundle.external ]`, and is never fetched from loopback. - previously missed: any exit status passed the stop. It must now be 0, as tests_runtime/test_bundled_postgres.py requires of the same stop: `[stop exits 0]`. The tests add 19 cases: an encoded query, a double-encoded query and an encoded path; an unserved `[::1]`; a served `[::1]`; the host probe; three imports from outside the plane (static https, dynamic protocol-relative, a data: URL); page links from outside the plane; and four stop statuses (0, 1, -15, timeout). Eight mutants are each killed. At the local integration with T104 (#84 cb899546), the real plane passes (r39: PASS, 302 assertions held, `[a.stop exits 0]` and `[b.stop exits 0]` among them). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- acceptance/at_r1_http.py | 102 +++++++++++++++++++++---- tests/test_at_r1_http_harness.py | 127 +++++++++++++++++++++++++++++-- 2 files changed, 207 insertions(+), 22 deletions(-) diff --git a/acceptance/at_r1_http.py b/acceptance/at_r1_http.py index 287d5ed5..9aa194d2 100644 --- a/acceptance/at_r1_http.py +++ b/acceptance/at_r1_http.py @@ -671,12 +671,25 @@ def handle_starttag(self, tag, attrs): self.sheets.append(a["href"]) +_SAME_ORIGIN = "http://loopback" + + def _resolve(base: str, ref: str) -> str: - parts = urllib.parse.urlsplit( - urllib.parse.urljoin("http://loopback" + base, ref)) + """`ref` resolved against `base` as a browser resolves it on this plane: + a same-origin path (with its query), or, for anything that leaves the + plane, the whole absolute URL, which never starts with `/` (Copilot + review of openDox-code#75 at 142d1352, previously missed).""" + joined = urllib.parse.urljoin(_SAME_ORIGIN + base, ref) + parts = urllib.parse.urlsplit(joined) + if f"{parts.scheme}://{parts.netloc}" != _SAME_ORIGIN: + return joined return parts.path + (f"?{parts.query}" if parts.query else "") +def _external(where: str) -> bool: + return not where.startswith("/") + + def _graph_roots(index_html: str, capabilities: dict) -> tuple[list, list]: links = _IndexLinks() links.feed(index_html) @@ -763,6 +776,13 @@ def derive_bundle(port: int, index_html: str, capabilities: dict, literal of the graph is added to `literals` where one is given.""" roots, sheets = _graph_roots(index_html, capabilities) for sheet in sheets: + if _external(sheet): + verdict.check( + f"{label}.bundle.external {sheet}", False, + f"`/` links the stylesheet {sheet}, from outside this plane, " + "which a clean machine with only openDox installed cannot be " + "assumed to reach") + continue answer = get(port, sheet) verdict.check(f"{label}.bundle.sheet {sheet}", answer.status == 200, f"the stylesheet `/` links answers {answer.describe()}") @@ -787,6 +807,16 @@ def derive_bundle(port: int, index_html: str, capabilities: dict, if (path, static) in judged: continue judged.add((path, static)) + if _external(path): + # Never fetched from loopback by its path, where a local file of + # the same name would answer for it. + verdict.check( + f"{label}.bundle.external {path}", False, + f"{importer} imports {path} " + f"{'statically' if static else 'dynamically'}, from outside " + "this plane, which a clean machine with only openDox " + "installed cannot be assumed to reach") + continue first = path not in answers if first: answers[path] = get(port, path) @@ -1399,8 +1429,16 @@ def _within(path: Path, root: Path) -> bool: _UNPARSED_ALIKE = re.compile(r"[\\\x00-\x20\x7f]") -def token_in_fragment(targets: list[str | None], - port: int) -> tuple[str | None, str]: +def served_loopback_hosts(port: int) -> tuple[str, ...]: + """The loopback hosts the launched plane answers on: `127.0.0.1` and + `::1` each where something listens on `port`, and `localhost`, which a + browser resolves to either, where one does.""" + hosts = [host for host in ("127.0.0.1", "::1") if listening(host, port)] + return (*hosts, "localhost") if hosts else () + + +def token_in_fragment(targets: list[str | None], port: int, + hosts: tuple[str, ...]) -> tuple[str | None, str]: """The token the opener's one forward carries in its FRAGMENT, or `None` and why not. No message here quotes a token, or any part of the URL, which could hold one (Copilot review of openDox-code#75 at d53a7378, @@ -1409,9 +1447,10 @@ def token_in_fragment(targets: list[str | None], return None, (f"the opener has {len(targets)} meta-refresh forwards, " "not one a browser follows to a URL (a delay that " "does not start with a digit or `.` aborts it)") + answering = " and ".join(hosts) or "no host" elsewhere = (f"the opener does not forward to this plane on loopback " - f"port {port} (its forward is not quoted, because it may " - "hold the token)") + f"port {port}, which answers on {answering} (its forward is " + "not quoted, because it may hold the token)") target = targets[0] # A browser and `urllib.parse` must read the SAME destination, or the # check below judges a URL the browser never opens (r4173842763). @@ -1427,8 +1466,11 @@ def token_in_fragment(targets: list[str | None], or "@" in parts.netloc: return None, (f"{elsewhere}: it carries user information before its " "host") + # A loopback host the LAUNCHED plane answers on: a forward to `[::1]` + # reaches nothing where the plane listens on 127.0.0.1 alone (Copilot + # review of openDox-code#75 at 142d1352, previously missed). if (parts.scheme != "http" or parts.hostname not in LOOPBACK_HOSTS - or target_port != port): + or parts.hostname not in hosts or target_port != port): return None, elsewhere # THE CONSOLE PAGE, and no other page of this plane: a forward to # `/missing.html` or `/snapshot.json` opens no console, though every @@ -1459,15 +1501,37 @@ def token_in_fragment(targets: list[str | None], "discards: `takeDeliveredConsoleToken` accepts only " f"{CONSOLE_TOKEN_SHAPE.pattern} (the value is not " "quoted)") - if values[0] in parts.path or values[0] in parts.query: + # Decoded too, as often as it decodes: a percent-encoded copy in the path + # or the query still reaches the request line and the server's log, and + # is recovered from them (Copilot review of openDox-code#75 at 142d1352, + # r4174355680). + if any(values[0] in form for part in (parts.path, parts.query) + for form in _decodings(part)): return None, ("the opener's forward carries the token outside its " - "fragment too") + "fragment too, perhaps percent-encoded (the copy is " + "not quoted)") return values[0], "" +def _decodings(text: str, rounds: int = 5) -> set[str]: + """`text` and every percent-decoding of it (with `+` read as a space, and + not), repeated until nothing new appears, at most `rounds` deep.""" + forms, frontier = {text}, [text] + for _ in range(rounds): + frontier = [decoded for form in frontier + for decoded in (urllib.parse.unquote(form), + urllib.parse.unquote_plus(form)) + if decoded not in forms] + if not frontier: + break + forms.update(frontier) + return forms + + def check_console_opener(label: str, port: int, printed: str, state_dir: Path, - served_root: Path, - verdict: Verdict) -> tuple[Path | None, str | None]: + served_root: Path, verdict: Verdict, *, + hosts: tuple[str, ...], + ) -> tuple[Path | None, str | None]: """Step 6: the opener the start printed, and the token its forward carries, read from the file as the user's browser reads it.""" path = opener_location(printed) @@ -1497,7 +1561,7 @@ def check_console_opener(label: str, port: int, printed: str, state_dir: Path, contents.feed(page) contents.close() targets = [refresh_target(content) for content in contents.contents] - token, why = token_in_fragment(targets, port) + token, why = token_in_fragment(targets, port, hosts) verdict.check(f"{label}.console opener forwards with the token in its " "fragment", token is not None, why) if token is not None: @@ -1636,9 +1700,14 @@ def stop_and_look(server: Server, ctx: Context, verdict: Verdict, rc = server.proc.wait(timeout=STOP_TIMEOUT_SECONDS) except subprocess.TimeoutExpired: rc = None - verdict.check(f"{label}.stop exits", rc is not None, + # EXIT STATUS 0, as `tests_runtime/test_bundled_postgres.py` requires of + # the same stop: a server that fails its own shutdown has not stopped + # cleanly, whatever it removed first (Copilot review of openDox-code#75 + # at 142d1352, previously missed). + verdict.check(f"{label}.stop exits 0", rc == 0, f"the server did not exit within {STOP_TIMEOUT_SECONDS:.0f}s " - "of SIGTERM") + "of SIGTERM" if rc is None else + f"the server exited rc={rc} after SIGTERM, not 0") note(f"the entry point exited rc={rc}") left = bundled_server_processes(ctx.server_package, ctx.state_dir) verdict.check(f"{label}.stop leaves no bundled PostgreSQL process", @@ -1670,8 +1739,9 @@ def serve_one(label: str, repo: Path, ctx: Context, verdict: Verdict) -> None: server, index = launch(label, repo, ctx, verdict) snapshot, caps = check_pages(server, index, verdict) check_grouping(label, snapshot, caps, verdict) - opener, token = check_console_opener(label, server.port, server.printed(), - ctx.state_dir, repo, verdict) + opener, token = check_console_opener( + label, server.port, server.printed(), ctx.state_dir, repo, verdict, + hosts=served_loopback_hosts(server.port)) check_catalog(server, token, verdict) check_routes(server, index, snapshot, caps, token, verdict) stop_and_look(server, ctx, verdict, opener, token) diff --git a/tests/test_at_r1_http_harness.py b/tests/test_at_r1_http_harness.py index fdc2a342..d3b80dff 100644 --- a/tests/test_at_r1_http_harness.py +++ b/tests/test_at_r1_http_harness.py @@ -244,6 +244,41 @@ def test_a_stylesheet_served_as_anything_but_css_is_a_named_failure( assert _failures(verdict) == expected +@pytest.mark.parametrize("importer, expected", [ + ('import "https://unreachable.invalid/app.js";\n', + "t.bundle.external https://unreachable.invalid/app.js"), + ('import("//unreachable.invalid/app.js").catch(() => null);\n', + "t.bundle.external http://unreachable.invalid/app.js"), + ('export { x } from "data:text/javascript,export const x = 1";\n', + "t.bundle.external data:text/javascript,export const x = 1"), +], ids=["static-https", "dynamic-protocol-relative", "data-url"]) +def test_an_import_from_outside_the_plane_is_a_named_failure( + importer: str, expected: str) -> None: + """Never fetched from loopback by its path, where the local `/app.js` + would answer for it (Copilot review of #75 at 142d1352, previously + missed).""" + files = {"/app.js": (JS, importer)} + verdict = harness.Verdict(keep_going=True) + with served(files) as port: + harness.derive_bundle(port, _page("./app.js"), {}, verdict, "t") + assert _failures(verdict) == [expected] + + +def test_a_page_link_from_outside_the_plane_is_a_named_failure() -> None: + files = {"/app.js": (JS, "export const x = 1;\n"), + "/styles.css": ("text/css", "body { margin: 0; }\n")} + page = ('' + '' + '' + '') + verdict = harness.Verdict(keep_going=True) + with served(files) as port: + _routes, modules = harness.derive_bundle(port, page, {}, verdict, "t") + assert _failures(verdict) == ["t.bundle.external https://cdn.invalid/styles.css", + "t.bundle.external https://cdn.invalid/app.js"] + assert modules == 0 + + def test_a_dynamic_refusal_alone_is_not_a_failure() -> None: """10.2a's case: `intent-feed.js` is not owed, its importer degrades.""" files = {"/app.js": (JS, 'import("./intent-feed.js").catch(() => null);\n')} @@ -329,6 +364,8 @@ def test_a_catalog_that_answers_without_the_token_is_a_named_failure() -> None: PORT = 43123 TOKEN = "Zq3_token-of-a-standalone-console-0123456789" +#: The token with every character percent-encoded. +ENCODED = "".join(f"%{ord(c):02X}" for c in TOKEN) FORWARD = f"http://127.0.0.1:{PORT}/index.html#console_token={TOKEN}" @@ -385,11 +422,17 @@ def _printed(opener: Path) -> str: f"http://127.0.0.1:{PORT}/index.html\n") -def _read(state: Path, printed: str, served_root: Path | None = None): +#: What a plane launched as the harness launches it answers on: 127.0.0.1 +#: alone, and `localhost`, which a browser resolves to it. +SERVED = ("127.0.0.1", "localhost") + + +def _read(state: Path, printed: str, served_root: Path | None = None, + hosts: tuple[str, ...] = SERVED): verdict = harness.Verdict(keep_going=True) root = served_root if served_root is not None else state.parent / "repo" path, token = harness.check_console_opener( - "t", PORT, printed, state, root, verdict) + "t", PORT, printed, state, root, verdict, hosts=hosts) return _failures(verdict), path, token @@ -409,6 +452,27 @@ def test_a_token_at_the_page_s_bounds_is_delivered(tmp_path: Path, assert delivered == token +def test_a_forward_to_a_host_the_plane_answers_on_is_delivered( + tmp_path: Path) -> None: + """`[::1]` is refused where the plane answers on 127.0.0.1 alone, and + accepted where it answers there too.""" + target = f"http://[::1]:{PORT}/index.html#console_token={TOKEN}" + state, opener = _opener(tmp_path, _opener_page(target)) + assert _read(state, _printed(opener))[0] == [FRAGMENT] + failures, _path, token = _read(state, _printed(opener), + hosts=("127.0.0.1", "::1", "localhost")) + assert failures == [] + assert token == TOKEN + + +def test_the_hosts_a_plane_answers_on_are_probed() -> None: + """`served_loopback_hosts` asks the socket, as the browser will.""" + with served({}) as port: + assert harness.served_loopback_hosts(port) == ("127.0.0.1", + "localhost") + assert harness.served_loopback_hosts(port) == () + + def test_the_opener_t104_writes_delivers_its_token(tmp_path: Path) -> None: state, opener = _opener(tmp_path) failures, path, token = _read(state, _printed(opener)) @@ -593,13 +657,26 @@ def test_a_hard_linked_opener_is_a_named_failure(tmp_path: Path) -> None: _opener_page(f"http://127.0.0.1:{PORT}/missing.html#console_token={TOKEN}"), _opener_page(f"http://127.0.0.1:{PORT}/snapshot.json#console_token={TOKEN}"), _opener_page(f"http://127.0.0.1:{PORT}//index.html#console_token={TOKEN}"), + # a percent-encoded copy of the token in the query or the path, which + # the request line still carries (Copilot review of #75 at 142d1352, + # r4174355680) + _opener_page(f"http://127.0.0.1:{PORT}/index.html?extra={ENCODED}" + f"#console_token={TOKEN}"), + _opener_page(f"http://127.0.0.1:{PORT}/index.html?extra=" + f"{ENCODED.replace('%', '%25')}#console_token={TOKEN}"), + _opener_page(f"http://127.0.0.1:{PORT}/{ENCODED}/../index.html" + f"#console_token={TOKEN}"), + # a loopback host the launched plane does not answer on (previously + # missed at 142d1352) + _opener_page(f"http://[::1]:{PORT}/index.html#console_token={TOKEN}"), ], ids=["query", "query-and-fragment", "path", "other-port", "off-loopback", "https", "no-token", "empty-token", "two-tokens", "two-forwards", "no-forward", "backslash", "userinfo-token", "userinfo-password", "tab", "space", "malformed-host", "port-out-of-range", "delay-word", "delay-negative", "delay-empty", "delay-absent", "token-short", "token-oversized", "token-dots", "token-plus", - "page-missing", "page-snapshot", "page-double-slash"]) + "page-missing", "page-snapshot", "page-double-slash", + "query-encoded", "query-double-encoded", "path-encoded", "ipv6-unserved"]) def test_a_forward_that_leaks_or_misses_the_token_is_a_named_failure( tmp_path: Path, page: str) -> None: state, opener = _opener(tmp_path, page) @@ -622,7 +699,7 @@ def test_a_refused_forward_never_quotes_its_url(tmp_path: Path, state, opener = _opener(tmp_path, _opener_page(target)) verdict = harness.Verdict(keep_going=True) harness.check_console_opener("t", PORT, _printed(opener), state, - tmp_path / "repo", verdict) + tmp_path / "repo", verdict, hosts=SERVED) assert [failure.ident for failure in verdict.failures] == [FRAGMENT] assert not any(TOKEN in str(failure) for failure in verdict.failures) @@ -643,7 +720,7 @@ def test_a_record_reason_never_quotes_the_token(tmp_path: Path) -> None: state, opener = _opener(tmp_path, page) verdict = harness.Verdict(keep_going=True) harness.check_console_opener("t", PORT, _printed(opener), state, - tmp_path / "repo", verdict) + tmp_path / "repo", verdict, hosts=SERVED) assert [failure.ident for failure in verdict.failures] == [RECORD] assert not any(TOKEN in str(failure) for failure in verdict.failures) @@ -654,7 +731,7 @@ def test_a_token_in_the_query_is_named_and_never_quoted(tmp_path: Path) -> None: f"#console_token={TOKEN}")) verdict = harness.Verdict(keep_going=True) harness.check_console_opener("t", PORT, _printed(opener), state, - tmp_path / "repo", verdict) + tmp_path / "repo", verdict, hosts=SERVED) assert [failure.ident for failure in verdict.failures] == [FRAGMENT] assert "QUERY" in verdict.failures[0].why assert not any(TOKEN in str(failure) for failure in verdict.failures) @@ -681,3 +758,41 @@ def test_the_documented_start_is_the_readme_line_less_its_ellipsis() -> None: assert harness.documented_prefix() == ["opendox", "generate-and-open", "--local"] assert json.dumps(harness.DOCUMENTED_INSTALL) == '"pip install \\"opendox[local]\\""' + + +# --------------------------------------------------------------------------- +# The stop's exit status (Copilot review of #75 at 142d1352, previously +# missed): 0, as `tests_runtime/test_bundled_postgres.py` requires. +# --------------------------------------------------------------------------- + +class _StoppedProc: + def __init__(self, rc) -> None: + self.rc = rc + + def send_signal(self, _signal) -> None: + pass + + def wait(self, timeout=None): + if self.rc is None: + raise harness.subprocess.TimeoutExpired("opendox", timeout) + return self.rc + + +@pytest.mark.parametrize("rc, expected", [ + (0, []), + (1, ["t.stop exits 0"]), + (-15, ["t.stop exits 0"]), + (None, ["t.stop exits 0"]), +], ids=["zero", "one", "sigterm", "timeout"]) +def test_the_stop_must_exit_zero(tmp_path: Path, rc, expected) -> None: + import types + out, err = tmp_path / "out", tmp_path / "err" + out.write_text("", encoding="utf-8") + err.write_text("", encoding="utf-8") + server = harness.Server("t", _StoppedProc(rc), PORT, out, err) + ctx = types.SimpleNamespace(server_package=None, + state_dir=tmp_path / "state-of-no-server") + ctx.state_dir.mkdir() + verdict = harness.Verdict(keep_going=True) + harness.stop_and_look(server, ctx, verdict) + assert _failures(verdict) == expected From 64dc06f5805afe7ffef9337d5edd5351a4b64073 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:52:46 +0000 Subject: [PATCH 16/36] T095: a bare module specifier and a non-regular opener are named failures (plan 034) Copilot review of openDox-code#75 at 27479495: - r4174411680: a bare specifier such as `import "child.js"` resolved to /child.js and passed when that file was served. A browser with no import map refuses it: the HTML standard's "resolve a module specifier" accepts only a URL, or a specifier that starts with `/`, `./` or `../`. A bare specifier, static or dynamic, is now `[bundle.bare ]`, and is never fetched by its path (`_specifier`, BARE_PREFIX). - previously missed: under --keep-going, a FIFO at the opener's path failed the privacy check but was still opened with a blocking os.open. With no writer, that hung the harness before its verdict and its cleanup. `_read_without_following` now opens with O_NONBLOCK and reads only what fstat calls a regular file. The tests add 10 cases: - four bare specifiers (static, dynamic, export-from and a package name), each with the file served; - three relative specifiers that still resolve (./, ../ and /); - a FIFO opener, which must fail as not private and not delivering, within a bounded wait; - a FIFO that a gone writer has filled with a page, which must be refused and not read. Five mutants are each killed: a bare specifier resolved, a bare one not judged, any slash read as relative, a blocking open, and no regular-file check. At the local integration with T104 (#84 c979747a, on a tree identical to main 390e2c28), the real plane passes (r43 fail-fast and r44 --keep-going: PASS, 302 assertions held). Without T104, main's own tree fails in 6 checks, all where T104 is missing (r45). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- acceptance/at_r1_http.py | 39 +++++++++++++-- tests/test_at_r1_http_harness.py | 81 ++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/acceptance/at_r1_http.py b/acceptance/at_r1_http.py index 9aa194d2..5e207697 100644 --- a/acceptance/at_r1_http.py +++ b/acceptance/at_r1_http.py @@ -138,6 +138,7 @@ import argparse import collections import dataclasses +import errno import html.parser import http.client import json @@ -753,6 +754,23 @@ def _judge_module(answer: Answer, path: str, static: bool, importer: str, "refuses for a module script") +#: A bare module specifier (`import "child.js"`), marked so: with no import +#: map, a browser resolves only a specifier that starts with `/`, `./` or +#: `../`, or one that is an absolute URL, and throws on any other (the HTML +#: standard's "resolve a module specifier"). The served page has no import +#: map (Copilot review of openDox-code#75 at 27479495, r4174411680). +BARE_PREFIX = "bare:" +_URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:") + + +def _specifier(importer: str, value: str) -> str: + """A module specifier as a browser with no import map resolves it: the + resolved path or URL, or `bare:` where it throws.""" + if value.startswith(("/", "./", "../")) or _URL_SCHEME.match(value): + return _resolve(importer, value) + return BARE_PREFIX + value + + def _scan_module(path: str, body: bytes, pending: collections.deque, routes: set[str], literals: set[str] | None = None) -> None: for _quote, value, before in JsStrings( @@ -760,9 +778,9 @@ def _scan_module(path: str, body: bytes, pending: collections.deque, if literals is not None: literals.add(value) if _DYNAMIC_IMPORT_CONTEXT.search(before): - pending.append((_resolve(path, value), False, path)) + pending.append((_specifier(path, value), False, path)) elif _STATIC_IMPORT_CONTEXT.search(before): - pending.append((_resolve(path, value), True, path)) + pending.append((_specifier(path, value), True, path)) elif _PATH_LITERAL.match(value) and not _MODULE_OR_SHEET.search(value): routes.add(_resolve("/", value)) @@ -807,6 +825,14 @@ def derive_bundle(port: int, index_html: str, capabilities: dict, if (path, static) in judged: continue judged.add((path, static)) + if path.startswith(BARE_PREFIX): + verdict.check( + f"{label}.bundle.bare {path[len(BARE_PREFIX):]}", False, + f"{importer} imports {path[len(BARE_PREFIX):]!r} " + f"{'statically' if static else 'dynamically'} as a bare " + "specifier, which a browser with no import map refuses; a " + "relative one starts with `./` or `../`") + continue if _external(path): # Never fetched from loopback by its path, where a local file of # the same name would answer for it. @@ -1402,8 +1428,15 @@ def opener_unsafe_because(path: Path) -> str | None: def _read_without_following(path: Path, limit: int = 64 * 1024) -> str: - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + """The file at `path`, opened without following a link and WITHOUT + BLOCKING, and read only if what opened is a regular file: a FIFO with no + writer would otherwise hang the harness before its verdict and its + cleanup (Copilot review of openDox-code#75 at 27479495, previously + missed).""" + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise OSError(errno.EINVAL, "not a regular file") chunks, size = [], 0 while size < limit: chunk = os.read(descriptor, limit - size) diff --git a/tests/test_at_r1_http_harness.py b/tests/test_at_r1_http_harness.py index d3b80dff..2898e968 100644 --- a/tests/test_at_r1_http_harness.py +++ b/tests/test_at_r1_http_harness.py @@ -279,6 +279,42 @@ def test_a_page_link_from_outside_the_plane_is_a_named_failure() -> None: assert modules == 0 +@pytest.mark.parametrize("importer, expected", [ + ('import "child.js";\n', "t.bundle.bare child.js"), + ('import("child.js").catch(() => null);\n', "t.bundle.bare child.js"), + ('export { x } from "lib/child.js";\n', "t.bundle.bare lib/child.js"), + ('import { x } from "child";\n', "t.bundle.bare child"), +], ids=["static", "dynamic", "export-from", "package-name"]) +def test_a_bare_specifier_is_a_named_failure(importer: str, + expected: str) -> None: + """With no import map, a browser refuses a specifier that is neither a + URL nor `/`, `./` or `../`-led, though `/child.js` is served (Copilot + review of #75 at 27479495, r4174411680).""" + files = {"/app.js": (JS, importer), + "/child.js": (JS, "export const x = 1;\n"), + "/lib/child.js": (JS, "export const x = 1;\n")} + verdict = harness.Verdict(keep_going=True) + with served(files) as port: + _routes, modules = harness.derive_bundle( + port, _page("./app.js"), {}, verdict, "t") + assert _failures(verdict) == [expected] + assert modules == 1 # never fetched by its path + + +@pytest.mark.parametrize("specifier", ["./child.js", "../views/child.js", + "/views/child.js"], + ids=["dot-slash", "dot-dot-slash", "absolute-path"]) +def test_every_relative_specifier_resolves(specifier: str) -> None: + files = {"/views/app.js": (JS, f'import "{specifier}";\n'), + "/views/child.js": (JS, "export const x = 1;\n")} + verdict = harness.Verdict(keep_going=True) + with served(files) as port: + _routes, modules = harness.derive_bundle( + port, _page("/views/app.js"), {}, verdict, "t") + assert _failures(verdict) == [] + assert modules == 2 + + def test_a_dynamic_refusal_alone_is_not_a_failure() -> None: """10.2a's case: `intent-feed.js` is not owed, its importer degrades.""" files = {"/app.js": (JS, 'import("./intent-feed.js").catch(() => null);\n')} @@ -602,6 +638,51 @@ def test_a_linked_opener_is_a_named_failure(tmp_path: Path) -> None: assert token is None +def test_a_fifo_opener_is_a_named_failure_and_never_hangs( + tmp_path: Path) -> None: + """A FIFO with no writer would block a plain `open` for ever, before the + verdict and the cleanup (Copilot review of #75 at 27479495, previously + missed). It is refused as not private, and is not read.""" + state, opener = _opener(tmp_path) + opener.unlink() + os.mkfifo(opener, 0o600) + outcome: dict = {} + + def read() -> None: + outcome["result"] = _read(state, _printed(opener)) + + reader = threading.Thread(target=read, daemon=True) + reader.start() + reader.join(timeout=10) + if reader.is_alive(): + # Release the blocked reader so the run can end, then fail. + with open(os.open(opener, os.O_WRONLY | os.O_NONBLOCK), "wb"): + pass + reader.join(timeout=5) + pytest.fail("reading a FIFO opener blocked") + failures, _path, token = outcome["result"] + assert failures == [PRIVATE, FRAGMENT] + assert token is None + + +def test_a_fifo_with_a_writer_is_never_read(tmp_path: Path) -> None: + """Only a regular file is read: a FIFO that a writer has filled is + refused by its descriptor, not read as the opener's page.""" + fifo = tmp_path / "opener.html" + os.mkfifo(fifo, 0o600) + holder = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK) # lets a writer open + try: + # A whole page waits in the pipe, and its writer has gone, so a + # reader would get the page and then an end of file. + writer = os.open(fifo, os.O_WRONLY | os.O_NONBLOCK) + os.write(writer, _opener_page(FORWARD).encode("utf-8")) + os.close(writer) + with pytest.raises(OSError): + harness._read_without_following(fifo) + finally: + os.close(holder) + + def test_a_hard_linked_opener_is_a_named_failure(tmp_path: Path) -> None: state, opener = _opener(tmp_path) os.link(opener, tmp_path / "second-name.html") From 4809b3d27c055dfd995443bc129fddc9847174ba Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:07:31 +0000 Subject: [PATCH 17/36] T095: diagnostics never echo the entry point's output, and every document names its path (plan 034) Copilot review of openDox-code#75 at 33841d4a: - r4174621486: `Server.said()` put the tail of the entry point's stdout and stderr into start and route failures. If the entry point printed its console token and then failed, the harness published that token in its own log before the after-stop check could catch it. A diagnostic now names where the output is (both files), says why it is not echoed, and points at --keep. `printed()` still reads all of it for the after-stop check. - r4174621535: `documents: [{}]` passed `snapshot non-empty`, while `requests_for` dropped every entry without a path and so skipped every document's source reads. New check, [snapshot documents each name a path]: every entry must be an object with a non-empty string `path`. The tests add 7 cases: six document lists (named; an empty object; an empty path; a numeric path; a string entry; one of two pathless) and a diagnostic that must name both files and quote no token, while printed() still holds the token. Four mutants are each killed: the output echoed, the path check dropped, an empty path admitted, and a path of any type admitted. At the local integration with T104 (#84 c979747a, on a tree identical to main 390e2c28), the real plane passes (r46 fail-fast and r47 --keep-going: PASS, 304 assertions held, the two new ones among them). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- acceptance/at_r1_http.py | 23 ++++++++++++-- tests/test_at_r1_http_harness.py | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/acceptance/at_r1_http.py b/acceptance/at_r1_http.py index 5e207697..d51ae9f8 100644 --- a/acceptance/at_r1_http.py +++ b/acceptance/at_r1_http.py @@ -1102,8 +1102,15 @@ def __init__(self, label: str, proc: subprocess.Popen, port: int, self.err = err def said(self) -> str: - return (f"\n--- stdout ---\n{tail(self.out.read_text('utf-8', 'replace'))}" - f"\n--- stderr ---\n{tail(self.err.read_text('utf-8', 'replace'))}") + """Where the entry point's output is, for a diagnostic, and never + the output itself: it may hold the console token, and a diagnostic + reaches the CI log before the after-stop check could catch a printed + token (Copilot review of openDox-code#75 at 33841d4a, + r4174621486). `printed()` still reads the whole output for that + check.""" + return (f" (the entry point's output is in {self.out} and " + f"{self.err}, not echoed here because it may hold the console " + "token; run with --keep to keep it)") def printed(self) -> str: """Everything the entry point has written so far, both streams.""" @@ -1214,6 +1221,18 @@ def check_pages(server: Server, index: Answer, isinstance(documents, list) and bool(documents), f"the snapshot's documents are not a non-empty list: " f"{documents!r:.200}") + # EVERY DOCUMENT NAMES ITS PATH, or `requests_for` would skip its source + # reads and the run would pass on less than it claims (Copilot review of + # openDox-code#75 at 33841d4a, r4174621535). + pathless = [index for index, document in enumerate(as_list(documents)) + if not (isinstance(document, dict) + and isinstance(document.get("path"), str) + and document["path"])] + verdict.check(f"{label}.snapshot documents each name a path", + not pathless, + f"the snapshot's documents at {pathless[:10]} are not " + "objects with a non-empty string `path`, so no source read " + "can be asked for them") leaks = sorted({m.group(1) for v in string_values(snapshot) for m in F53_PATTERN.finditer(v.lower())}) verdict.check(f"{label}.snapshot neutral (F5.3)", not leaks, diff --git a/tests/test_at_r1_http_harness.py b/tests/test_at_r1_http_harness.py index 2898e968..364d1780 100644 --- a/tests/test_at_r1_http_harness.py +++ b/tests/test_at_r1_http_harness.py @@ -877,3 +877,56 @@ def test_the_stop_must_exit_zero(tmp_path: Path, rc, expected) -> None: verdict = harness.Verdict(keep_going=True) harness.stop_and_look(server, ctx, verdict) assert _failures(verdict) == expected + + +# --------------------------------------------------------------------------- +# Every document names its path (Copilot review of #75 at 33841d4a, +# r4174621535), and a diagnostic never echoes the entry point's output +# (r4174621486). +# --------------------------------------------------------------------------- + +_HTML_INDEX = harness.Answer(200, {"content-type": "text/html; charset=utf-8"}, + b"", None) +_CAPS = json.dumps({"install": {"mode": "local"}}) + + +def _snapshot_server(documents) -> dict: + return {"/snapshot.json": ("application/json", + json.dumps({"kind": "opendox-snapshot", + "documents": documents})), + "/capabilities": ("application/json", _CAPS)} + + +@pytest.mark.parametrize("documents, expected", [ + ([{"path": "a.md"}, {"path": "b/c.md"}], []), + ([{}], ["t.snapshot documents each name a path"]), + ([{"path": ""}], ["t.snapshot documents each name a path"]), + ([{"path": 1}], ["t.snapshot documents each name a path"]), + (["a.md"], ["t.snapshot documents each name a path"]), + ([{"path": "a.md"}, {"title": "no path"}], + ["t.snapshot documents each name a path"]), +], ids=["named", "empty-object", "empty-path", "number-path", "string-entry", + "one-of-two"]) +def test_a_document_without_a_path_is_a_named_failure( + tmp_path: Path, documents, expected) -> None: + out, err = tmp_path / "out", tmp_path / "err" + out.write_text("", encoding="utf-8") + err.write_text("", encoding="utf-8") + verdict = harness.Verdict(keep_going=True) + with served(_snapshot_server(documents)) as port: + server = harness.Server("t", None, port, out, err) + harness.check_pages(server, _HTML_INDEX, verdict) + assert _failures(verdict) == expected + + +def test_a_diagnostic_names_where_the_output_is_and_never_echoes_it( + tmp_path: Path) -> None: + out, err = tmp_path / "t-server.out", tmp_path / "t-server.err" + out.write_text(f" console file:///x\n token {TOKEN}\n", encoding="utf-8") + err.write_text(f"Traceback: {TOKEN}\n", encoding="utf-8") + server = harness.Server("t", None, PORT, out, err) + said = server.said() + assert TOKEN not in said + assert str(out) in said and str(err) in said + # the after-stop check still reads all of it + assert TOKEN in server.printed() From 32fbb6dc69864c5e1b7410e750cc502cb1919d64 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:45:40 +0000 Subject: [PATCH 18/36] T095: only a live refresh forwards, the opener's whole tree is judged, and no line quotes a token (plan 034) Copilot review of openDox-code#75 at 4809b3d2: - r4174671390: a refresh inside `