Skip to content
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All notable changes to OriginWeave are documented in this file. The format follo
- Runtime browser-adapter version binding at the atomic protocol-use boundary: the caller-supplied bounded adapter-version token must exactly match the reviewed descriptor version before runtime revision or capability checks can succeed, preventing adapter-build drift from silently reusing otherwise matching protocol/browser metadata without authenticating or attesting the adapter process.
- Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority.
- Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority.
- Explicit `BrowserAuthorityRegistry::bind_context_origin` registration that binds one canonical origin to the exact current browser session, browsing context, and document epoch before origin-sensitive protocol use, rejecting cross-session ownership and same-epoch origin changes without granting navigation or action authority.
- Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority.
- Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors.
- Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes.
Expand Down Expand Up @@ -80,4 +81,4 @@ All notable changes to OriginWeave are documented in this file. The format follo
- The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it.
- The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels.

[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD
[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD
29 changes: 29 additions & 0 deletions crates/originweave-core/src/browser_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,35 @@ impl BrowserAuthorityRegistry {
self.current_epoch(browsing_context)
}

/// Bind the canonical origin observed for the exact current browser document.
///
/// This boundary lets a trusted browser adapter establish current document-origin state before
/// semantic-node discovery begins. The supplied session must own the context. Rebinding the
/// same canonical origin in the same document epoch is idempotent, while a different origin
/// fails closed until [`Self::advance_document`] rotates the document epoch and clears the old
/// binding. The returned epoch is descriptive immediate-use state, not reusable capability.
///
/// This method does not authenticate the adapter, derive an origin from Chromium, authorize a
/// destination or action, or prove that any browser I/O occurred.
pub fn bind_context_origin(
&mut self,
browser_session: BrowserSessionId,
browsing_context: BrowsingContextId,
origin: &Origin,
) -> Result<DocumentEpoch, BrowserRegistryError> {
Comment thread
seonghobae marked this conversation as resolved.
let epoch = self.current_context_epoch(browser_session, browsing_context)?;
match self.context_origin.get(&browsing_context) {
Some(expected_origin) if expected_origin != origin => {
return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance);
}
Some(_expected_origin) => {}
None => {
self.context_origin.insert(browsing_context, origin.clone());
}
}
Ok(epoch)
}
Comment thread
seonghobae marked this conversation as resolved.

/// Advance a browsing context to the next document epoch and invalidate old node bindings.
///
/// Call this whenever navigation or document replacement invalidates actionable node identity.
Expand Down
91 changes: 91 additions & 0 deletions crates/originweave-core/tests/browser_context_origin_binding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
use std::error::Error;
use std::io;

use originweave_core::{
BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId,
DocumentEpoch, Origin,
};

fn first_origin() -> Result<Origin, Box<dyn Error>> {
Origin::parse("http://127.0.0.1:43127")
.map_err(|_error| io::Error::other("controlled first origin must be valid").into())
}

fn second_origin() -> Result<Origin, Box<dyn Error>> {
Origin::parse("http://localhost:43127")
.map_err(|_error| io::Error::other("controlled second origin must be valid").into())
}

#[test]
fn context_origin_can_be_bound_before_node_discovery() -> Result<(), Box<dyn Error>> {
let mut registry = BrowserAuthorityRegistry::new();
let session = registry.register_session("webdriver-session")?;
let context = registry.register_context(session, "top-level-context")?;
let origin = first_origin()?;

let epoch = registry.bind_context_origin(session, context, &origin)?;
assert_eq!(epoch, DocumentEpoch::new(1)?);
assert_eq!(
registry.bind_context_origin(session, context, &origin)?,
epoch
);

let node = registry.bind_node(session, context, &origin, "backend-node-17")?;
assert_eq!(node.document_epoch(), epoch);
assert_eq!(node.origin(), &origin);
Ok(())
}

#[test]
fn context_origin_change_requires_document_rotation() -> Result<(), Box<dyn Error>> {
let mut registry = BrowserAuthorityRegistry::new();
let session = registry.register_session("webdriver-session")?;
let context = registry.register_context(session, "top-level-context")?;
let first = first_origin()?;
let second = second_origin()?;

registry.bind_context_origin(session, context, &first)?;
assert_eq!(
registry.bind_context_origin(session, context, &second),
Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance)
);

let next_epoch = registry.advance_document(context)?;
assert_eq!(next_epoch, DocumentEpoch::new(2)?);
assert_eq!(
registry.bind_context_origin(session, context, &second)?,
next_epoch
);
Ok(())
}

#[test]
fn context_origin_binding_rejects_cross_session_and_unknown_authority() -> Result<(), Box<dyn Error>>
{
let mut registry = BrowserAuthorityRegistry::new();
let owner = registry.register_session("owner-session")?;
let attacker = registry.register_session("attacker-session")?;
let context = registry.register_context(owner, "top-level-context")?;
let origin = first_origin()?;

assert_eq!(
registry.bind_context_origin(attacker, context, &origin),
Err(BrowserRegistryError::ContextSessionMismatch {
expected: owner,
actual: attacker,
})
);

let unknown_session = BrowserSessionId::new(999)?;
assert_eq!(
registry.bind_context_origin(unknown_session, context, &origin),
Err(BrowserRegistryError::UnknownBrowserSession)
);

let unknown_context = BrowsingContextId::new(999)?;
assert_eq!(
registry.bind_context_origin(owner, unknown_context, &origin),
Err(BrowserRegistryError::UnknownBrowsingContext)
);
Ok(())
}
8 changes: 7 additions & 1 deletion tests/test_repository_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ def test_product_name_is_consistent_in_binding_documents(self) -> None:
self.assertNotIn("TraceWeave", text, relative)
self.assertNotIn("ProofRail", text, relative)

def test_context_origin_binding_is_recorded_in_the_changelog(self) -> None:
"""The public origin-binding boundary must remain visible in release history."""

changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
self.assertIn("BrowserAuthorityRegistry::bind_context_origin", changelog)

def test_database_contract_requires_two_word_snake_case(self) -> None:
"""Persistent naming policy must include the mandated canonical form."""

Expand All @@ -185,4 +191,4 @@ def test_database_contract_requires_two_word_snake_case(self) -> None:


if __name__ == "__main__":
unittest.main()
unittest.main()
Loading