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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

This is a pre-1.0 breaking Runtime cutover.

- Runtime Workers now accept and strictly validate Core-owned standard and
Browser authority envelopes, expose Browser interaction policy and canonical
mutation-origin evidence to handlers, and reject tampered authority fields.
- Added Browser interaction policy, policy generation, canonical mutation
origins, origin digest, and Browser contract evidence to `RunResponse` and
the public Core client contract fixture.
Expand Down
5 changes: 5 additions & 0 deletions src/openlinker/runtime/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ class RuntimeAuthority:
runtime_session_id: str
runtime_session_epoch: int
runtime_attachment_id: str
execution_profile: str = ""
browser_interaction_policy: str = ""
browser_interaction_policy_generation: int = 0
browser_mutation_origins: tuple[str, ...] = ()
browser_mutation_origins_sha256: str = ""


@dataclass(frozen=True)
Expand Down
126 changes: 125 additions & 1 deletion src/openlinker/runtime/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import asyncio
import hashlib
import ipaddress
import inspect
import json
import logging
import random
import time
Expand All @@ -13,6 +15,7 @@
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Protocol
from urllib.parse import urlsplit

from websockets.exceptions import ConnectionClosed

Expand Down Expand Up @@ -2642,6 +2645,15 @@ def _canonical_uuid(value: str, label: str) -> None:


_RUNTIME_AUTHORITY_METADATA_KEY = "_openlinker_runtime_authority"
_RUNTIME_AUTHORITY_KEYS = {
"principal_scope_id",
"source",
"execution_profile",
"browser_interaction_policy",
"browser_interaction_policy_generation",
"browser_mutation_origins",
"browser_mutation_origins_sha256",
}


def _runtime_authority_from_metadata(
Expand All @@ -2658,7 +2670,7 @@ def _runtime_authority_from_metadata(
if (
ready is None
or not isinstance(raw, dict)
or set(raw) != {"principal_scope_id", "source"}
or not set(raw).issubset(_RUNTIME_AUTHORITY_KEYS)
or raw.get("source") != "core"
or not isinstance(raw.get("principal_scope_id"), str)
or session_epoch < 1
Expand All @@ -2671,6 +2683,7 @@ def _runtime_authority_from_metadata(
_canonical_runtime_principal_scope(principal_scope_id)
_canonical_uuid(runtime_session_id, "runtime_session_id")
_canonical_uuid(ready.attachment_id, "runtime_attachment_id")
authority = _validated_runtime_authority_fields(raw)
except ValueError as exc:
raise _RuntimeAssignmentAuthorityError(
"assignment Runtime authority is invalid"
Expand All @@ -2680,9 +2693,120 @@ def _runtime_authority_from_metadata(
runtime_session_id=runtime_session_id,
runtime_session_epoch=session_epoch,
runtime_attachment_id=ready.attachment_id,
**authority,
)


def _validated_runtime_authority_fields(raw: dict[str, Any]) -> dict[str, Any]:
execution_profile = raw.get("execution_profile", "")
policy = raw.get("browser_interaction_policy", "")
generation = raw.get("browser_interaction_policy_generation", 0)
origins = raw.get("browser_mutation_origins")
digest = raw.get("browser_mutation_origins_sha256", "")
if execution_profile in {"", "standard"}:
if any(
key in raw
for key in (
"browser_interaction_policy",
"browser_interaction_policy_generation",
"browser_mutation_origins",
"browser_mutation_origins_sha256",
)
):
raise ValueError("standard Runtime authority contains Browser fields")
return {
"execution_profile": execution_profile,
"browser_interaction_policy": "",
"browser_interaction_policy_generation": 0,
"browser_mutation_origins": (),
"browser_mutation_origins_sha256": "",
}
if (
execution_profile != "browser"
or policy not in {"restricted", "full"}
or isinstance(generation, bool)
or not isinstance(generation, int)
or generation < 1
or not isinstance(origins, list)
or len(origins) > 32
or any(not isinstance(origin, str) for origin in origins)
or not isinstance(digest, str)
or len(digest) != 64
or any(character not in "0123456789abcdef" for character in digest)
):
raise ValueError("Browser Runtime authority is invalid")
canonical = sorted(_canonical_runtime_browser_mutation_origin(origin) for origin in origins)
if (
canonical != origins
or len(set(canonical)) != len(canonical)
or (policy == "restricted" and canonical)
or (policy == "full" and not canonical)
or hashlib.sha256(
json.dumps(canonical, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
).hexdigest()
!= digest
):
raise ValueError("Browser Runtime authority is not canonical")
return {
"execution_profile": execution_profile,
"browser_interaction_policy": policy,
"browser_interaction_policy_generation": generation,
"browser_mutation_origins": tuple(canonical),
"browser_mutation_origins_sha256": digest,
}


def _canonical_runtime_browser_mutation_origin(raw: str) -> str:
if not raw or raw.strip() != raw or "%" in raw:
raise ValueError("Browser mutation origin is invalid")
try:
parsed = urlsplit(raw)
host = parsed.hostname
port = parsed.port
except ValueError as exc:
raise ValueError("Browser mutation origin is invalid") from exc
if (
parsed.scheme != "https"
or parsed.username is not None
or parsed.password is not None
or not host
or parsed.path
or parsed.query
or parsed.fragment
or host.endswith(".")
):
raise ValueError("Browser mutation origin is invalid")
try:
address = ipaddress.ip_address(host)
except ValueError:
try:
canonical_host = host.lower().encode("idna").decode("ascii")
except UnicodeError as exc:
raise ValueError("Browser mutation origin host is invalid") from exc
if len(canonical_host) > 253:
raise ValueError("Browser mutation origin host is invalid")
labels = canonical_host.split(".")
if any(
not label
or len(label) > 63
or label.startswith("-")
or label.endswith("-")
or any(character not in "abcdefghijklmnopqrstuvwxyz0123456789-" for character in label)
for label in labels
):
raise ValueError("Browser mutation origin host is invalid")
else:
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
raise ValueError("IPv4-mapped IPv6 Browser origins are invalid")
canonical_host = f"[{address.compressed}]" if address.version == 6 else address.compressed
if port == 443:
port = None
canonical = f"https://{canonical_host}{f':{port}' if port else ''}"
if raw != canonical:
raise ValueError("Browser mutation origin must already be canonical")
return canonical


def _canonical_runtime_principal_scope(value: str) -> None:
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-"
if (
Expand Down
51 changes: 48 additions & 3 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import asyncio
import hashlib
import json
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
from pathlib import Path
Expand Down Expand Up @@ -451,6 +453,7 @@ async def test_assignment_is_durable_and_confirmed_before_handler_runs():
"_openlinker_runtime_authority": {
"principal_scope_id": "ps1_" + ("A" * 43),
"source": "core",
"execution_profile": "standard",
}
}
)
Expand All @@ -464,6 +467,7 @@ async def handler(context: runtime.RuntimeContext) -> dict[str, Any]:
runtime_session_id=store.identity.runtime_session_id,
runtime_session_epoch=store.identity.session_epoch,
runtime_attachment_id=ATTACHMENT_ID,
execution_profile="standard",
)
handler_started.set()
await context.emit("run.progress", {"step": 1})
Expand All @@ -484,17 +488,58 @@ async def handler(context: runtime.RuntimeContext) -> dict[str, Any]:
assert transport.session_closed


def test_browser_runtime_authority_is_validated_and_exposed():
origins = ["https://github.com", "https://openlinker.ai"]
digest = hashlib.sha256(
json.dumps(origins, separators=(",", ":")).encode("utf-8")
).hexdigest()
metadata, authority = runtime_worker_module._runtime_authority_from_metadata(
{
"keep": "ordinary",
"_openlinker_runtime_authority": {
"principal_scope_id": "ps1_" + ("A" * 43),
"source": "core",
"execution_profile": "browser",
"browser_interaction_policy": "full",
"browser_interaction_policy_generation": 7,
"browser_mutation_origins": origins,
"browser_mutation_origins_sha256": digest,
},
},
3,
"99999999-9999-4999-8999-999999999999",
ready(),
)
assert metadata == {"keep": "ordinary"}
assert authority == runtime.RuntimeAuthority(
principal_scope_id="ps1_" + ("A" * 43),
runtime_session_id="99999999-9999-4999-8999-999999999999",
runtime_session_epoch=3,
runtime_attachment_id=ATTACHMENT_ID,
execution_profile="browser",
browser_interaction_policy="full",
browser_interaction_policy_generation=7,
browser_mutation_origins=tuple(origins),
browser_mutation_origins_sha256=digest,
)


@pytest.mark.asyncio
async def test_malformed_runtime_authority_is_rejected_before_handler():
store = runtime.MemoryRuntimeStore()
transport = FakeTransport()
transport.assignment = assignment(store)
transport.assignment.metadata.update(
{
"_openlinker_runtime_authority": {
"principal_scope_id": "not/an/opaque-id",
"_openlinker_runtime_authority": {
"principal_scope_id": "ps1_" + ("A" * 43),
"source": "core",
}
"execution_profile": "browser",
"browser_interaction_policy": "full",
"browser_interaction_policy_generation": 1,
"browser_mutation_origins": ["https://github.com"],
"browser_mutation_origins_sha256": "0" * 64,
},
}
)
handler_calls = 0
Expand Down