Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions scripts/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
13 changes: 5 additions & 8 deletions skills/leaf/assets/runtime/document-identity.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand Down
27 changes: 8 additions & 19 deletions skills/leaf/assets/runtime/storage.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 4 additions & 3 deletions skills/leaf/assets/runtime/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 5 additions & 4 deletions skills/leaf/scripts/leaf/codex_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
import time
from pathlib import Path

# The stream-activity writers are called as `codex.<name>`, 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,
Expand All @@ -50,7 +53,6 @@
append_batch,
archive_record,
check_app_server_endpoint,
clear_stream_activity,
delivery_lock_path,
delivery_record_state,
delivery_records,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 1 addition & 3 deletions skills/leaf/scripts/leaf/interaction_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,14 @@
from pathlib import Path

from .event_log import jsonl_line, require_cross_process_locking
from .schema import INTERACTIONS_FILE

try:
import fcntl
except ImportError: # pragma: no cover - unsupported non-POSIX platform
fcntl = None


INTERACTIONS_FILE = "interactions.jsonl"


def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="milliseconds")

Expand Down
6 changes: 5 additions & 1 deletion skills/leaf/scripts/leaf/layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion skills/leaf/scripts/leaf/mcp_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions skills/leaf/scripts/leaf/presence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion skills/leaf/scripts/leaf/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand Down
9 changes: 7 additions & 2 deletions skills/leaf/scripts/leaf/served_state/reading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion skills/leaf/scripts/leaf/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
3 changes: 2 additions & 1 deletion skills/leaf/scripts/leaf/structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions skills/leaf/scripts/leaf/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<meta>`. 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
Expand Down
34 changes: 32 additions & 2 deletions tests/interact_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand Down
Loading
Loading