From 1dcaa1d1673cf102247708d1b0899713814df6df Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 27 Sep 2026 10:55:34 -0700 Subject: [PATCH] Give five restated facts one owner each Five small concepts had a second copy beside their owner. Each copy now reads the owner, and the copy is deleted. - The Worker's `frame-ancestors` header. `structure.FRAME_ANCESTORS_CSP` owns the policy. The Worker spelled it again in `staticAssetResponse`; the site manifest now carries it (`frame_ancestors`), the Worker validates it at its edge with the rest of the manifest, and adds what it says. - A document's version number. `lf-version`, which `delivery_identity` writes on every delivery of a stamped revision, owns it. `document-identity.js` also parsed it out of `/versions/vN.html` when the marker was absent, which no delivery reached; the fallback and `VERSION_PATH` are deleted. The version comparison fetched `versionUrl(base)`, a second derivation of the address the state's version entry already carries at the page root, which is what the version menu's travel reads; it now fetches that `url`, so it also resolves from a `revisions/` address. The MCP app snapshot now stamps the active version like every other delivery. - The stream-activity writers. `leaf.codex` owns `set_stream_activity` and `clear_stream_activity`, but `codex_adapter` and the website host bound them by name, so two test files each kept a helper that patched both bindings. Both callers now call through `codex`, and one helper in `interact_support` patches that one binding. - The page-directory inventory. `test_interact_layer` held its own copy of `PAGE_STATE_FILES` and `PAGE_OWNED_DIRS` under a test named for the storage contract. It now reads `page-storage.md`'s "Files" and holds the product to it, which found `interactions.jsonl` documented as Leaf's but missing from the inventory: an initialized page now owns it, so a package path aliased to it is refused. `INTERACTIONS_FILE` moves to `schema` so the inventory can name it. - The layer's sheet order. The face test ordered every bundled package alphabetically while claiming `composed_sheets`' order; it now walks a composed example's layer (`SHIPPED_PACKAGES`) by `layer.ROOT_SHEETS`, the per-root order `composed_sheets` itself reads. `interact_support.wait_for` takes a callable `failure`, for a poll whose message needs state read at the timeout, and four hand-rolled polls in `test_interact_session` and `test_render_export` use it. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/site.py | 6 +- .../leaf/assets/runtime/document-identity.js | 13 +-- skills/leaf/assets/runtime/storage.js | 27 ++--- skills/leaf/assets/runtime/version.js | 7 +- skills/leaf/scripts/leaf/codex_adapter.py | 9 +- skills/leaf/scripts/leaf/interaction_log.py | 4 +- skills/leaf/scripts/leaf/layer.py | 6 +- skills/leaf/scripts/leaf/mcp_app.py | 2 +- skills/leaf/scripts/leaf/presence.py | 9 +- skills/leaf/scripts/leaf/schema.py | 6 +- .../leaf/scripts/leaf/served_state/reading.py | 9 +- skills/leaf/scripts/leaf/service.py | 2 +- skills/leaf/scripts/leaf/structure.py | 3 +- skills/leaf/scripts/leaf/validation.md | 6 +- tests/interact_support.py | 34 +++++- tests/test_interact_layer.py | 100 ++++++++---------- tests/test_interact_session.py | 91 ++++++---------- tests/test_render_export.py | 10 +- tests/test_website_server.py | 20 +--- worker/server.py | 9 +- worker/src/index.ts | 16 +-- worker/src/routing.ts | 3 + worker/test/index.test.ts | 3 +- worker/test/routing.test.ts | 4 +- 24 files changed, 194 insertions(+), 205 deletions(-) diff --git a/scripts/site.py b/scripts/site.py index 5e8f1f6631..df3a9448c9 100755 --- a/scripts/site.py +++ b/scripts/site.py @@ -55,7 +55,7 @@ SESSION_ROUTE_DIRS, VENDORED_FILES, ) -from leaf.structure import SourceDocument +from leaf.structure import FRAME_ANCESTORS_CSP, SourceDocument from page_fixtures import prepare_page, read_fixture ROOT = Path(__file__).resolve().parent.parent @@ -451,9 +451,11 @@ def publish_live_shells( assets = asset_site(out) shutil.rmtree(assets, ignore_errors=True) assets.mkdir(parents=True) - # The Worker routes a page's namespace from here rather than a copy of its own. + # The Worker routes a page's namespace, and frames the HTML it serves from its own + # assets, from here rather than from copies of its own. manifest = { "release": release, + "frame_ancestors": FRAME_ANCESTORS_CSP, "routes": { "layer": list(BROWSER_DIRS), "session": list(SESSION_ROUTE_DIRS), diff --git a/skills/leaf/assets/runtime/document-identity.js b/skills/leaf/assets/runtime/document-identity.js index e575e9567f..4f926fb570 100644 --- a/skills/leaf/assets/runtime/document-identity.js +++ b/skills/leaf/assets/runtime/document-identity.js @@ -1,15 +1,16 @@ /* The immutable identity delivered with this document. * - * The server's runtime markers say which revision and public stamp the document is, + * The server's runtime markers say which revision and public stamp the document is + * (every delivery of a stamped revision writes `lf-version`, whatever its address, + * so the address is never read for it), * whether its executable graph can take a revision in place, and which authored widget * bodies may be retained. The composition root initializes the single semantic publisher * from this reading before it constructs browser owners. Each specimen receives its * own served identity through the same document delivery as an ordinary page. */ import { applicationState } from "./semantic-state.js"; -import { LIVE_ROOT, PAGE_PATH, VERSION_PATH } from "./storage.js"; +import { LIVE_ROOT } from "./storage.js"; -const versionMatch = PAGE_PATH.match(VERSION_PATH); const servedRevision = document.querySelector( 'meta[name="lf-revision"][data-lf-runtime]', )?.content; @@ -31,11 +32,7 @@ export const servedWidgets = documentWidgetDigests(document); export function initializeServedDocument() { applicationState.identify( servedRevision ? parseInt(servedRevision, 10) : null, - servedStampMarker - ? parseInt(servedStampMarker.content, 10) - : versionMatch - ? parseInt(versionMatch[1], 10) - : null, + servedStampMarker ? parseInt(servedStampMarker.content, 10) : null, LIVE_ROOT, ); servedStampMarker?.remove(); diff --git a/skills/leaf/assets/runtime/storage.js b/skills/leaf/assets/runtime/storage.js index 7bed036238..2d0282ad02 100644 --- a/skills/leaf/assets/runtime/storage.js +++ b/skills/leaf/assets/runtime/storage.js @@ -1,22 +1,11 @@ -// ---------- where a page's public version addresses are ---------- -// A page's mapped revisions are served at sibling addresses under its own root: -// versions/v1.html, v2.html… Two things read that path — which version this document -// is, and where another version of it is — so the shape is spelled once here, and a -// document served under a directory of its own cannot have one of them agreeing with its -// URL while the other contradicts it. -export const VERSION_PATH = /\/versions\/v([1-9]\d*)\.html$/; -export const PAGE_PATH = location.pathname; -// Where another version is: beside this one. It was "/versions/vN.html" at the three -// seats that travel, which is a claim about where the page directory sits — true of a -// server serving one page at a root of its own, and of nothing else. The published site -// serves every example from one vendored layer with each page under its own directory, -// and there each absolute jump left the page for a root that serves nothing. Resolved -// against the document, the travel agrees with the path the version number itself was -// read off, which is the one form that cannot disagree with what this document is. -export const versionUrl = (version) => - `${PAGE_PATH.match(VERSION_PATH) ? "" : "versions/"}v${version}.html`; -// The live root follows the active revision in place; a virtual version address under -// the path above stays pinned to its mapped revision. +// ---------- which address this document is ---------- +// Which version a document is comes from its served `lf-version` marker +// (`document-identity.js`), and where another version is from that version's `url` in +// the state reading, which the server addresses at the page root. Neither is read off +// this path. +const PAGE_PATH = location.pathname; +// The live root follows the active revision in place; a virtual version address +// (`versions/vN.html`) stays pinned to its mapped revision. export const LIVE_ROOT = PAGE_PATH.endsWith("/"); // Which page this document belongs to. The server declares the page's root in the // canonical link it adds to every document it serves, the live root and each version diff --git a/skills/leaf/assets/runtime/version.js b/skills/leaf/assets/runtime/version.js index ab7a76330e..a852e27e8b 100644 --- a/skills/leaf/assets/runtime/version.js +++ b/skills/leaf/assets/runtime/version.js @@ -91,7 +91,7 @@ import { shownRegionBounds, watchReadingRegionTransitions, } from "./reading-regions.js"; -import { LIVE_ROOT, PAGE_SCOPE, tabStore, versionUrl } from "./storage.js"; +import { LIVE_ROOT, PAGE_SCOPE, tabStore } from "./storage.js"; import { alignInlineText } from "./text-alignment.js"; import { el, keeps, layoutChanged, quoted, reveal } from "./widget-elements.js"; import { showNews } from "./banner-shelf.js"; @@ -1013,14 +1013,15 @@ export function createVersionController({ const mine = ++diffRequest; diffPendingBase = base; presentChooser(); - const baseRevision = stamped(base)?.revision; + const baseVersion = stamped(base); + const baseRevision = baseVersion?.revision; if (baseRevision == null) { diffPendingBase = null; presentChooser(); notice(`Couldn't load v${base}`); return; } - const documentRequest = authoredDocument(versionUrl(base)); + const documentRequest = authoredDocument(baseVersion.url); let doc; let reading; try { diff --git a/skills/leaf/scripts/leaf/codex_adapter.py b/skills/leaf/scripts/leaf/codex_adapter.py index ed7f97f538..78e5260691 100644 --- a/skills/leaf/scripts/leaf/codex_adapter.py +++ b/skills/leaf/scripts/leaf/codex_adapter.py @@ -37,6 +37,9 @@ import time from pathlib import Path +# The stream-activity writers are called as `codex.`, so `leaf.codex` holds their +# one binding: whatever takes a turn's readings there takes every carrier's too. +from . import codex from .codex import ( START_TIMEOUT, AppServerDeliveryUncertain, @@ -50,7 +53,6 @@ append_batch, archive_record, check_app_server_endpoint, - clear_stream_activity, delivery_lock_path, delivery_record_state, delivery_records, @@ -60,7 +62,6 @@ record_path, retire_gone_task_records, retry_delay, - set_stream_activity, start_app_server_delivery, stop_app_server, stream_reply_target, @@ -305,7 +306,7 @@ def _resume(self, thread: dict) -> None: # This snapshot does not establish a current provider turn, so an # old thinking, tool, waiting, or replying observation cannot prove # one is still live. - clear_stream_activity(self.thread_id) + codex.clear_stream_activity(self.thread_id) def _reconcile(self, turn: dict) -> None: """Bring one snapshot turn's fold up to what the snapshot says of it. @@ -548,7 +549,7 @@ def begin(self) -> None: open_stream_turn(self.session_id, self.turn_id) accept_offered_delivery(self.session_id, self.delivery_id, self.turn_id) self.open_reply() - set_stream_activity(self.session_id, self.turn_id, {"kind": "working"}) + codex.set_stream_activity(self.session_id, self.turn_id, {"kind": "working"}) def ended(self, error: BaseException) -> dict | None: """Account for the turn the stream stopped carrying, unless it is not over. diff --git a/skills/leaf/scripts/leaf/interaction_log.py b/skills/leaf/scripts/leaf/interaction_log.py index e7e7dd9c75..b7c33750cb 100644 --- a/skills/leaf/scripts/leaf/interaction_log.py +++ b/skills/leaf/scripts/leaf/interaction_log.py @@ -17,6 +17,7 @@ from pathlib import Path from .event_log import jsonl_line, require_cross_process_locking +from .schema import INTERACTIONS_FILE try: import fcntl @@ -24,9 +25,6 @@ fcntl = None -INTERACTIONS_FILE = "interactions.jsonl" - - def now_iso() -> str: return datetime.now().astimezone().isoformat(timespec="milliseconds") diff --git a/skills/leaf/scripts/leaf/layer.py b/skills/leaf/scripts/leaf/layer.py index d67169b3a7..5be6694b2a 100644 --- a/skills/leaf/scripts/leaf/layer.py +++ b/skills/leaf/scripts/leaf/layer.py @@ -215,6 +215,10 @@ def widget_confinement(root: Path) -> tuple[str, str] | None: return f":where({listed}, :is({listed}) *)", f":where({host}, {host} *)" +# A root's own stylesheets, in the order the document's theme.css reads them. +ROOT_SHEETS = ("shadow.css", "theme.css") + + def composed_sheets(inputs: list[Path]) -> dict[str, bytes]: """The layer's two stylesheets, each in layer precedence order. @@ -240,7 +244,7 @@ def composed_sheets(inputs: list[Path]) -> dict[str, bytes]: shadow = [] for position, root in enumerate(inputs): where = widget_confinement(root) if position else None - for name in ("shadow.css", "theme.css"): + for name in ROOT_SHEETS: source = root / name if not source.is_file(): continue diff --git a/skills/leaf/scripts/leaf/mcp_app.py b/skills/leaf/scripts/leaf/mcp_app.py index 356b960f6b..c2f6e5632e 100644 --- a/skills/leaf/scripts/leaf/mcp_app.py +++ b/skills/leaf/scripts/leaf/mcp_app.py @@ -56,7 +56,7 @@ def read_resource(path: str) -> Resource: document = compose_document( source, revision, - None, + active["version"], executable=artifact.executable, widgets=artifact.widgets, resources=artifact.resources, diff --git a/skills/leaf/scripts/leaf/presence.py b/skills/leaf/scripts/leaf/presence.py index eba7f92636..d290fb646c 100644 --- a/skills/leaf/scripts/leaf/presence.py +++ b/skills/leaf/scripts/leaf/presence.py @@ -16,10 +16,15 @@ read_json, ) from .host import claim_harness -from .interaction_log import INTERACTIONS_FILE from .leases import wait_is_live, waiter_lease_path from .machine import state_home -from .schema import STATUS_FILE, UNNAMED_AGENT, VIEWED_FILE, WAITER_LOCK +from .schema import ( + INTERACTIONS_FILE, + STATUS_FILE, + UNNAMED_AGENT, + VIEWED_FILE, + WAITER_LOCK, +) from .server import running_server from .service import ( claim_is_active, diff --git a/skills/leaf/scripts/leaf/schema.py b/skills/leaf/scripts/leaf/schema.py index 778fe59539..b55cdeb2f3 100644 --- a/skills/leaf/scripts/leaf/schema.py +++ b/skills/leaf/scripts/leaf/schema.py @@ -498,6 +498,8 @@ DATA_FILE = "data.json" DATA_DIR = "data" EVENTS_FILE = "events.jsonl" +# The diagnostic request and interaction trace (`interaction_log.py`). +INTERACTIONS_FILE = "interactions.jsonl" PREVIEW_FILE = "preview.json" VIEWED_FILE = "viewed.json" # One name, because there is one key (`host_key`). Cookies are scoped by host and @@ -522,7 +524,9 @@ SERVER_LOCK, PREVIEW_FILE, ) -PAGE_STATE_FILES = (EVENTS_FILE, DATA_FILE, *SESSION_FILES) +# The files Leaf writes in a page directory as it runs. With the author's index.html, +# the vendored files, and PAGE_OWNED_DIRS, the whole of page-storage.md's "Files". +PAGE_STATE_FILES = (EVENTS_FILE, INTERACTIONS_FILE, DATA_FILE, *SESSION_FILES) PAGE_OWNED_FILES = ("index.html", *VENDORED_FILES, *PAGE_STATE_FILES) PAGE_OWNED_DIRS = ("revisions", *PACKAGE_DIRS, MEDIA_DIR, DATA_DIR, "page") # A revision's and a version's file name, without `.html`. diff --git a/skills/leaf/scripts/leaf/served_state/reading.py b/skills/leaf/scripts/leaf/served_state/reading.py index d4ab843c57..07f30cff20 100644 --- a/skills/leaf/scripts/leaf/served_state/reading.py +++ b/skills/leaf/scripts/leaf/served_state/reading.py @@ -4,8 +4,13 @@ from pathlib import Path from ..files import STAGED, entry_stamps, file_stamp -from ..interaction_log import INTERACTIONS_FILE -from ..schema import DATA_DIR, EVENTS_FILE, SESSION_FILES, VIEWED_FILE +from ..schema import ( + DATA_DIR, + EVENTS_FILE, + INTERACTIONS_FILE, + SESSION_FILES, + VIEWED_FILE, +) from ..service import claim_path # Diagnostic writes cannot move application state. The server writes `viewed.json` diff --git a/skills/leaf/scripts/leaf/service.py b/skills/leaf/scripts/leaf/service.py index c925e23e0e..cdaa583346 100644 --- a/skills/leaf/scripts/leaf/service.py +++ b/skills/leaf/scripts/leaf/service.py @@ -26,13 +26,13 @@ message_identity, session_harness, ) -from leaf.interaction_log import INTERACTIONS_FILE from leaf.locations import page_key from leaf.machine import pid_alive, state_home from leaf.registry.layer import bookkeeping_kinds from leaf.schema import ( ACTIVITY_GRACE_SECS, EVENTS_FILE, + INTERACTIONS_FILE, STATUS_FILE, UNNAMED_AGENT, ) diff --git a/skills/leaf/scripts/leaf/structure.py b/skills/leaf/scripts/leaf/structure.py index e4b186abdd..e82941aad3 100644 --- a/skills/leaf/scripts/leaf/structure.py +++ b/skills/leaf/scripts/leaf/structure.py @@ -121,7 +121,8 @@ def external_reference(reference: str) -> bool: f"style-src 'self' 'unsafe-inline' {EXTERNAL_SOURCES}" ) # A meta policy cannot govern the document's ancestors. The ordinary server adds this -# separate header policy; the capability-scoped MCP transport is deliberately frameable. +# separate header policy, and the site manifest carries it to the Worker; the +# capability-scoped MCP transport is deliberately frameable. FRAME_ANCESTORS_CSP = "frame-ancestors 'none'" # Non-painting document structure that may stand outside the authored main. Head # metadata is allowed only while the parser is actually inside head. diff --git a/skills/leaf/scripts/leaf/validation.md b/skills/leaf/scripts/leaf/validation.md index 0bc0eb48fd..b1dc8ce40e 100644 --- a/skills/leaf/scripts/leaf/validation.md +++ b/skills/leaf/scripts/leaf/validation.md @@ -60,8 +60,10 @@ thread the log lacks is a mistake in the declaration, and refuses it. ## Delivery policy The document policy cannot restrict ancestors when delivered through ``. Every -ordinary served HTML response therefore adds `frame-ancestors 'none'`. Historical -version routes receive the current document policy and the same header. A standalone +ordinary served HTML response therefore adds `frame-ancestors 'none'` +(`structure.FRAME_ANCESTORS_CSP`). Historical version routes receive the current +document policy and the same header. The published site's Worker adds the same header +to the HTML it serves from its own assets, reading it from the site manifest. A standalone file has no response header and cannot make this framing guarantee. The process-scoped MCP page server omits the header because its exact, ephemeral origin is intentionally framed by the host that approved it; the unguessable page path remains that transport's diff --git a/tests/interact_support.py b/tests/interact_support.py index 626c77b8ae..f228efb308 100644 --- a/tests/interact_support.py +++ b/tests/interact_support.py @@ -22,6 +22,7 @@ import time import urllib.parse import urllib.request +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, wait from contextlib import contextmanager from functools import cache @@ -33,6 +34,7 @@ from click.testing import CliRunner from conftest import LEAF_COMMAND from leaf import cli as cli_model +from leaf import codex as codex_model from leaf import data as data_model from leaf import event_contracts as event_contracts_model from leaf import event_log as events_model @@ -244,9 +246,18 @@ def spawn_probe(spawn, page_dir, body, **environment): counterpart, more generous again for the work a page does.""" -def wait_for(read, accepts, *, failure: str, timeout: float = STATED_TIMEOUT): +def wait_for( + read, + accepts, + *, + failure: str | Callable[[], str], + timeout: float = STATED_TIMEOUT, +): """Return the first accepted reading, or fail with the last one observed. + `failure` is the message, or a function that composes it at the timeout, for a + poll whose diagnosis needs state read only once it has failed (a log, a record). + For pure-Python state polls. Keep a local loop where process exit, cancellation, or a deadline shared across several transitions is the contract.""" deadline = time.monotonic() + timeout @@ -255,10 +266,29 @@ def wait_for(read, accepts, *, failure: str, timeout: float = STATED_TIMEOUT): if accepts(reading): return reading if time.monotonic() >= deadline: - pytest.fail(f"{failure}; last reading was {reading!r}") + said = failure() if callable(failure) else failure + pytest.fail(f"{said}; last reading was {reading!r}") time.sleep(0.05) +def take_stream_activity(monkeypatch, updates: list, clears: list) -> None: + """Collect every activity reading a turn writes, instead of a page taking it. + + Every carrier and host calls the writers through `leaf.codex`, so that one binding + takes them all. Pass empty lists for a test that wants them to touch nothing. + """ + monkeypatch.setattr( + codex_model, + "set_stream_activity", + lambda session, turn, detail: updates.append((session, turn, detail)), + ) + monkeypatch.setattr( + codex_model, + "clear_stream_activity", + lambda session, turn=None: clears.append((session, turn)), + ) + + @contextmanager def running_http_server(httpd): """Serve an HTTP fixture and close every thread and socket it creates.""" diff --git a/tests/test_interact_layer.py b/tests/test_interact_layer.py index 6af830111c..8d91e80cef 100644 --- a/tests/test_interact_layer.py +++ b/tests/test_interact_layer.py @@ -24,6 +24,7 @@ PAGE_PACKAGES, PLUGIN_ROOT, ROOT, + SHIPPED_PACKAGES, SKILL_ROOT, add_test_widget, case_alias, @@ -55,27 +56,26 @@ from leaf.render_gate.preview import preview_server from page_fixtures import package_selection_args -EXPECTED_PAGE_STATE_FILES = ( - "events.jsonl", - "data.json", - "status.json", - "waiter.lock", - "cursor.json", - "viewed.json", - "service.json", - "server.lock", - "preview.json", -) -EXPECTED_PAGE_DIRECTORIES = ( - "revisions", - "runtime", - "widgets", - "vendor", - "guidance", - "media", - "data", - "page", -) + +def storage_contract() -> tuple[list[str], list[str]]: + """The files and directories page-storage.md's "Files" section puts in a page. + + Each entry is a bullet opening on its name in a code span; a name beneath a + directory (`revisions/rN-H.html`) is that directory's entry. A virtual address + (`/versions/…`) and state kept outside the page (`/…`) are not in it. + """ + text = (SKILL_ROOT / "scripts" / "leaf" / "page-storage.md").read_text() + section = text.split("\n## Files\n", 1)[1].split("\n## ", 1)[0] + files, directories = set(), set() + for name in re.findall(r"^- `([^`]+)`", section, re.MULTILINE): + if name.startswith(("/", "<")): + continue + head, beneath, _ = name.partition("/") + (directories if beneath else files).add(head) + return sorted(files), sorted(directories) + + +STORAGE_FILES, STORAGE_DIRECTORIES = storage_contract() def test_cli_help_groups_commands_with_complete_summaries(regtest): @@ -1409,19 +1409,12 @@ def test_no_face_is_stated_for_one_selector_in_both_layer_sheets(): ) -# The layer's page-side order, as layer.py's `composed_sheets` builds it: each root's -# shadow.css and then its theme.css, the assets root before every package. +# The page-side order `composed_sheets` reads a composed example's layer in: each +# root's own sheets, root by root in layer order. _LAYER_SHEET_ORDER = [ sheet - for source in [ - schema_model.ASSETS, - *sorted( - package - for package in schema_model.BUNDLED_PACKAGES.iterdir() - if package.is_dir() - ), - ] - for sheet in (source / "shadow.css", source / "theme.css") + for root in SHIPPED_PACKAGES + for sheet in (root / name for name in layer_model.ROOT_SHEETS) if sheet.is_file() ] @@ -2320,36 +2313,27 @@ def test_path_overlap_respects_case_sensitive_future_names(tmp_path, monkeypatch assert interact_locations.paths_same(upper, lower) -@pytest.mark.parametrize("name", EXPECTED_PAGE_STATE_FILES) -def test_initialized_page_owns_runtime_state_paths(tmp_path, monkeypatch, name): - monkeypatch.chdir(tmp_path) - page = tmp_path / "page" - initialized = CliRunner().invoke(cli_model.cli, ["page", "init", str(page)]) - assert initialized.exit_code == 0, initialized.output - - assert packages_model.initialized_page_owning(page / name) == page - assert packages_model.initialized_page_owning(page / ".leaf" / name) is None - - -def test_page_state_inventory_matches_the_storage_contract(): - assert schema_model.PAGE_STATE_FILES == EXPECTED_PAGE_STATE_FILES - - -@pytest.mark.parametrize("directory", EXPECTED_PAGE_DIRECTORIES) -def test_initialized_page_owns_declared_directory_trees( - tmp_path, monkeypatch, directory +def test_an_initialized_page_owns_what_the_storage_contract_names( + tmp_path, monkeypatch ): + """page-storage.md's "Files" is the page directory's whole inventory: the page + owns each file it names and everything beneath each directory, which `page init` + creates, and nothing else is Leaf's there. The same name one level down belongs + to no page.""" + assert sorted(schema_model.PAGE_OWNED_FILES) == STORAGE_FILES + assert sorted(schema_model.PAGE_OWNED_DIRS) == STORAGE_DIRECTORIES + monkeypatch.chdir(tmp_path) page = tmp_path / "page" initialized = CliRunner().invoke(cli_model.cli, ["page", "init", str(page)]) assert initialized.exit_code == 0, initialized.output - - assert (page / directory).is_dir() - assert packages_model.initialized_page_owning(page / directory / "future") == page - - -def test_page_directory_inventory_matches_the_storage_contract(): - assert schema_model.PAGE_OWNED_DIRS == EXPECTED_PAGE_DIRECTORIES + owner = packages_model.initialized_page_owning + for name in STORAGE_FILES: + assert owner(page / name) == page, name + assert owner(page / ".leaf" / name) is None, name + for directory in STORAGE_DIRECTORIES: + assert (page / directory).is_dir(), directory + assert owner(page / directory / "future") == page, directory def test_replace_files_rejects_case_aliased_future_targets(tmp_path, monkeypatch): @@ -2613,7 +2597,7 @@ def test_init_does_not_partially_revendor_on_a_destination_conflict( assert (page / "registry.json").read_bytes() == registry_before -@pytest.mark.parametrize("sub", EXPECTED_PAGE_DIRECTORIES) +@pytest.mark.parametrize("sub", STORAGE_DIRECTORIES) def test_init_refuses_a_symlinked_page_directory(tmp_path, monkeypatch, sub): monkeypatch.chdir(tmp_path) runner = CliRunner() diff --git a/tests/test_interact_session.py b/tests/test_interact_session.py index 0162d3999b..4f6c6d8bca 100644 --- a/tests/test_interact_session.py +++ b/tests/test_interact_session.py @@ -50,6 +50,7 @@ stamp, start_server_command, state_json, + take_stream_activity, vendored_by_another_leaf, wait_for, yaml_document, @@ -91,27 +92,6 @@ from websockets.sync.server import unix_serve as serve_unix_websocket -def take_stream_activity(monkeypatch, updates: list, clears: list) -> None: - """Collect every activity reading a turn writes, instead of a page taking it. - - Two bindings of the same two functions write them: a carrier calls them for the - connection it is opening or giving up on, and `leaf.codex` calls them for - everything the projection reads off the stream. A test that wants the readings, - or wants them to touch nothing, has to say so at both. - """ - for module in (codex_model, codex_adapter_model): - monkeypatch.setattr( - module, - "set_stream_activity", - lambda session, turn, detail: updates.append((session, turn, detail)), - ) - monkeypatch.setattr( - module, - "clear_stream_activity", - lambda session, turn=None: clears.append((session, turn)), - ) - - def last_deliverable_seq(page_dir: Path) -> int: """The last event a wait can print, excluding page-owned pickup records.""" return service_model.unacknowledged(events_model.read_events(page_dir), 0)[-1][ @@ -1492,6 +1472,10 @@ def codex_records(session_id: str) -> list[tuple[Path, dict]]: ] +def adapter_log(session_id: str) -> str: + return codex_adapter_model.adapter_log_path(session_id).read_text(encoding="utf-8") + + def current_codex_record(session_id: str) -> tuple[Path, dict]: current = [ (path, epoch) @@ -8951,20 +8935,15 @@ def test_codex_delivery_outlives_the_starting_command_and_acknowledges( page, {"kind": "comment", "author": "user", "text": "hello adapter"} ) comments = [events_model.read_events(page)[-1]] - deadline = time.monotonic() + 10 - while time.monotonic() < deadline: - if files_model.read_json(page / "cursor.json") == {"seq": 1}: - break - time.sleep(0.05) - else: - deliveries = codex_records("codex-thread") - log_text = codex_adapter_model.adapter_log_path("codex-thread").read_text( - encoding="utf-8" - ) - pytest.fail( + wait_for( + lambda: files_model.read_json(page / "cursor.json"), + lambda cursor: cursor == {"seq": 1}, + failure=lambda: ( "the adapter did not acknowledge its batch: " - f"deliveries={deliveries!r}; log={log_text!r}" - ) + f"deliveries={codex_records('codex-thread')!r}; " + f"log={adapter_log('codex-thread')!r}" + ), + ) hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "codex-thread"}) assert capsys.readouterr().out == "" @@ -9209,19 +9188,14 @@ def test_an_offline_sibling_does_not_stop_browser_comments_reaching_codex( assert status == 200, body comment = json.loads(body)["state"]["events"][-1] - deadline = time.monotonic() + 10 - while time.monotonic() < deadline: - if files_model.read_json(live / "cursor.json") == {"seq": comment["seq"]}: - break - time.sleep(0.05) - else: - adapter_log = codex_adapter_model.adapter_log_path( - "codex-thread" - ).read_text(encoding="utf-8") - pytest.fail( + wait_for( + lambda: files_model.read_json(live / "cursor.json"), + lambda cursor: cursor == {"seq": comment["seq"]}, + failure=lambda: ( "the browser comment did not reach the Codex queue: " - f"adapter_log={adapter_log!r}" - ) + f"adapter_log={adapter_log('codex-thread')!r}" + ), + ) calls = [json.loads(line) for line in log.read_text().splitlines()] [queued] = [call for call in calls if "--thread" in call] @@ -9318,19 +9292,18 @@ def test_codex_adapter_exits_when_delivery_retries_outlive_its_claim( events_model.append_event( page, {"kind": "comment", "author": "user", "text": "hello adapter"} ) - deadline = time.monotonic() + 10 - while time.monotonic() < deadline: - queues = codex_records("codex-thread") - calls = ( - [json.loads(line) for line in log.read_text().splitlines()] - if log.exists() - else [] - ) - if queues and queues[0][1]["state"] == "offering" and len(calls) > 1: - break - time.sleep(0.05) - else: - pytest.fail("the adapter did not reach its delivery retry") + wait_for( + lambda: ( + codex_records("codex-thread"), + log.read_text().splitlines() if log.exists() else [], + ), + lambda reading: ( + reading[0] + and reading[0][0][1]["state"] == "offering" + and len(reading[1]) > 1 + ), + failure="the adapter did not reach its delivery retry", + ) claim = service_model.page_claim(page) files_model.write_json( diff --git a/tests/test_render_export.py b/tests/test_render_export.py index f2109e944d..4027274b72 100644 --- a/tests/test_render_export.py +++ b/tests/test_render_export.py @@ -977,10 +977,12 @@ def test_terminating_a_preview_mid_update_leaves_no_service(served_preview): theme = runtime / "skills" / "leaf" / "assets" / "theme.css" with theme.open("a", encoding="utf-8") as stream: stream.write("\nh1 { color: navy; }\n") - deadline = time.monotonic() + 30 - while json.loads((directory / "service.json").read_text())["enabled"]: - assert time.monotonic() < deadline, "watcher did not begin the update" - time.sleep(0.05) + wait_for( + lambda: json.loads((directory / "service.json").read_text())["enabled"], + lambda enabled: not enabled, + failure="watcher did not begin the update", + timeout=30, + ) os.killpg(process.pid, signal.SIGTERM) process.wait(timeout=30) assert server_model.running_server(directory) is None diff --git a/tests/test_website_server.py b/tests/test_website_server.py index bf848546f0..b2d9d5ec56 100644 --- a/tests/test_website_server.py +++ b/tests/test_website_server.py @@ -30,6 +30,7 @@ append_command, publish, running_http_server, + take_stream_activity, yaml_document, ) from leaf import codex as leaf_codex @@ -129,23 +130,6 @@ def write_manifest(site: Path, pages: dict[str, tuple[str, str]]) -> None: target.write_text(json.dumps(manifest), encoding="utf-8") -def take_stream_activity(monkeypatch, updates: list, clears: list) -> None: - """Collect every activity reading a turn writes, instead of a page taking it. - - Two bindings of the same two functions write them: the host calls them for a - turn it is starting or giving up on, and `leaf.codex` calls them for everything - the projection reads off the stream. A test that wants the readings, or wants - them to touch nothing, has to say so at both. - """ - for module in (website_server, leaf_codex): - monkeypatch.setattr( - module, "set_stream_activity", lambda *args: updates.append(args) - ) - monkeypatch.setattr( - module, "clear_stream_activity", lambda *args: clears.append(args) - ) - - def hosted_follower( host, page_dir, @@ -2336,7 +2320,7 @@ def test_the_website_host_keeps_its_claim_listening_through_the_agent_turn( assert state["activity"]["observed_kind"] == "working" assert state["activity"]["obligations"][0]["input"] == comment["id"] - website_server.set_stream_activity( + leaf_codex.set_stream_activity( "hosted-thread", "app-server-turn", {"kind": "tool", "detail": "Editing index.html"}, diff --git a/worker/server.py b/worker/server.py index d1142012f4..a96df08e8a 100644 --- a/worker/server.py +++ b/worker/server.py @@ -24,17 +24,18 @@ from html import escape from pathlib import Path +# The stream-activity writers are called as `codex.`, so `leaf.codex` holds their +# one binding: whatever takes a turn's readings there takes the host's too. +from leaf import codex from leaf.codex import ( CarriedTurn, abandon_codex_delivery, app_server_connect, app_server_handshake, app_server_request, - clear_stream_activity, delivery_owed_moves, open_app_server_delivery, prepare_codex_delivery, - set_stream_activity, start_app_server_delivery, stop_app_server, stream_reply_target, @@ -463,7 +464,7 @@ def begin(self) -> None: ) self.record("turn_delivery_bound", deliveryId=self.delivery_id) self.open_reply() - set_stream_activity(self.session_id, self.turn_id, {"kind": "working"}) + codex.set_stream_activity(self.session_id, self.turn_id, {"kind": "working"}) def observe(self, message: dict, update: dict | None) -> None: """Record what one notification said, before its readings reach the page.""" @@ -557,7 +558,7 @@ def close(self, terminal: dict, reply_error: BaseException | None) -> None: self.page_dir, self.session_id, self.leaf_turn, terminal ) finally: - clear_stream_activity(self.session_id, self.turn_id) + codex.clear_stream_activity(self.session_id, self.turn_id) self._receipt_unanswered() def _receipt_unanswered(self) -> None: diff --git a/worker/src/index.ts b/worker/src/index.ts index dc3182b96a..0ec6e4b9c8 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -863,12 +863,12 @@ function recordAcceptedEvent( }); } -function staticAssetResponse(response: Response): Response { +function staticAssetResponse(response: Response, manifest: SiteManifest): Response { const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim(); if (contentType?.toLowerCase() !== "text/html") return response; const headers = new Headers(response.headers); - headers.append("Content-Security-Policy", "frame-ancestors 'none'"); + headers.append("Content-Security-Policy", manifest.frame_ancestors); return new Response(response.body, { status: response.status, statusText: response.statusText, @@ -895,12 +895,12 @@ async function siteManifest(request: Request, env: Env): Promise { function stampedStaticResponse( response: Response, route: PageRoute, - release: string, + manifest: SiteManifest, ): Response { - const staticResponse = staticAssetResponse(response); + const staticResponse = staticAssetResponse(response, manifest); const headers = new Headers(staticResponse.headers); headers.set("Leaf-Layer", route.layer); - headers.set("Leaf-Release", release); + headers.set("Leaf-Release", manifest.release); return new Response(staticResponse.body, { status: staticResponse.status, statusText: staticResponse.statusText, @@ -993,7 +993,7 @@ export default { const response = stampedStaticResponse( await env.ASSETS.fetch(new Request(assetUrl, request)), releasedAsset.route, - manifest.release, + manifest, ); const headers = new Headers(response.headers); headers.set("Cache-Control", "public, max-age=31536000, immutable"); @@ -1005,7 +1005,7 @@ export default { } const route = pageRoute(pathname, manifest); if (route === null) { - return staticAssetResponse(await env.ASSETS.fetch(request)); + return staticAssetResponse(await env.ASSETS.fetch(request), manifest); } if (needsPageSlash(pathname, route)) { const canonical = new URL(request.url); @@ -1057,7 +1057,7 @@ export default { const response = stampedStaticResponse( await env.ASSETS.fetch(request), route, - manifest.release, + manifest, ); if ( response.status !== 404 || diff --git a/worker/src/routing.ts b/worker/src/routing.ts index 49a13b6d46..953331548c 100644 --- a/worker/src/routing.ts +++ b/worker/src/routing.ts @@ -65,6 +65,9 @@ const pageRoutesSchema = z.object({ const siteManifestSchema = z .object({ release: z.string().check(z.regex(RELEASE)), + // The header policy every HTML response adds, since a document's own + // policy cannot govern its ancestors (`structure.FRAME_ANCESTORS_CSP`). + frame_ancestors: z.string().check(z.startsWith("frame-ancestors ")), routes: pageRoutesSchema, pages: z.record(z.string().check(z.regex(PAGE_ROOT)), sitePageSchema), }) diff --git a/worker/test/index.test.ts b/worker/test/index.test.ts index 22168b423c..25ee03bfe8 100644 --- a/worker/test/index.test.ts +++ b/worker/test/index.test.ts @@ -30,6 +30,7 @@ const RELEASE = "a".repeat(64); const LAYER = "edge-layer"; const MANIFEST = { release: RELEASE, + frame_ancestors: "frame-ancestors 'none'", routes: { layer: ["runtime", "widgets", "vendor"], session: ["media", "revisions", "versions"], @@ -235,7 +236,7 @@ describe("product-site delivery", () => { expect(assetFetch).toHaveBeenCalledOnce(); expect(getContainer).not.toHaveBeenCalled(); expect(response.headers.get("Content-Security-Policy")).toBe( - "frame-ancestors 'none'", + MANIFEST.frame_ancestors, ); expect(response.headers.get("Set-Cookie")).toMatch( /^__Host-leaf-page=[0-9a-f]{32}; Path=\/; Secure; HttpOnly; SameSite=Lax$/, diff --git a/worker/test/routing.test.ts b/worker/test/routing.test.ts index 30619363a3..e635b58948 100644 --- a/worker/test/routing.test.ts +++ b/worker/test/routing.test.ts @@ -40,7 +40,8 @@ const routes = { session: ["media", "revisions", "versions"], files: ["leaf.js", "theme.css", "shadow.css", "registry.json", "icon.svg"], }; -const manifest = { release: "a".repeat(64), routes, pages }; +const frame_ancestors = "frame-ancestors 'none'"; +const manifest = { release: "a".repeat(64), frame_ancestors, routes, pages }; const route = (pathname: string) => pageRoute(pathname, manifest); describe("website page routing", () => { @@ -48,6 +49,7 @@ describe("website page routing", () => { const release = "a".repeat(64); const manifest = { release, + frame_ancestors, routes, pages: { "/": page("product") }, };