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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions crates/app-contract/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ versions' changes, and 1.10.0 includes 1.9.0's changes.
[README.md](README.md#versions-on-cratesio) shows how to update a lock file
that holds 1.2.0.

## 1.12.0 — unreleased

- Add the optional `window` manifest field (`WindowHint { width, height }`,
with `WindowHint::new`): the size, in points, at which the app prefers its
desktop window to open. It is a hint, not a grant: hosts clamp it to the
screen, phones and other full-screen hosts ignore it, and the person can
still resize the window. `AppPolicy` is unchanged by it.
- `SCHEMA_MINOR` is now 1, the first bump. A manifest that sets `window`
must declare `"schema_minor": 1` or later; `parse` and `resolve` refuse it
otherwise. A host on an earlier contract then reads such a manifest
leniently: it ignores `window`, lists it in `ignored_fields()`, and
computes the same signing bytes, instead of refusing an unknown field.
- `parse` refuses a width outside 320 to 4096, a height outside 240 to
4096, and a `window` that is not an object of those two integers. The
field is skipped when absent, so existing manifests and their signing
bytes do not change.
- A manifest with `"schema_minor": 1` is now read strictly, like any
manifest at this build's minor: an unknown field in it is refused rather
than ignored. Manifests for later minors are still read leniently.

## 1.11.0 — 2026-10-10

Published through the [release workflow](https://github.com/OctoSense-org/OctoSense-App-Hub/actions/runs/38026012863)
Expand Down
2 changes: 1 addition & 1 deletion crates/app-contract/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# and no path dependencies. Within 1.x it only grows (see README.md).
[package]
name = "octosense-app-contract"
version = "1.11.0"
version = "1.12.0"
edition = "2021"
license = "Apache-2.0"
description = "The OctoSense app contract: app manifests, the policy an app gets, package integrity and running a package"
Expand Down
10 changes: 7 additions & 3 deletions crates/app-contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ Within `1.x` the contract only grows (ADR 0005 section 2):
2. Every `requires` entry must be in `KNOWN_FEATURES`, at every
`schema_minor`. Otherwise: `app <id> needs a newer host: <feature>`.
`1.0.0` knows no features.
3. If `schema_minor` (default 0) is at most `SCHEMA_MINOR` (0 in every
release so far), `parse` reads the manifest strictly and refuses it if
any level has an unknown field.
3. If `schema_minor` (default 0) is at most `SCHEMA_MINOR` (0 before
1.12.0; 1 from 1.12.0, which adds the optional `window`), `parse` reads
the manifest strictly and refuses it if any level has an unknown field.
4. If `schema_minor` is above `SCHEMA_MINOR`, the manifest was written for
a newer `1.x`. Rule 2 has passed, so every unknown field, at any level,
is optional: `parse` ignores it, and `AppManifest::ignored_fields()`
Expand All @@ -167,6 +167,10 @@ Within `1.x` the contract only grows (ADR 0005 section 2):

An older host therefore never runs an app under weaker rules than its
author wrote, and a newer optional field never breaks an older host.
That holds only when the manifest declares the minor that added the
field. `window`, the first field added at a new minor, needs
`"schema_minor": 1`, and `parse` refuses it in a manifest that declares
less.
Ignored fields stay in the manifest's signing bytes, so a newer signed
manifest still verifies. A field added in `1.x` must be omitted from
serialization when it holds its default, and must serialize exactly as
Expand Down
12 changes: 7 additions & 5 deletions crates/app-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@
//! `schema_minor`; otherwise the app is refused with
//! `app <id> needs a newer host: <feature>`. `1.0.0` knows none.
//! 3. A manifest whose `schema_minor` (default 0) is at most
//! [`SCHEMA_MINOR`] (`0` in `1.0.0`) is read strictly: an unknown
//! field at any level refuses it, exactly as before `requires`
//! existed.
//! [`SCHEMA_MINOR`] (`0` in `1.0.0`, `1` since `1.12.0`, which added
//! `window`) is read strictly: an unknown field at any level refuses
//! it, exactly as before `requires` existed.
//! 4. A manifest whose `schema_minor` is above [`SCHEMA_MINOR`] was
//! written for a newer `1.x`. Its unknown fields, at any level
//! (top level, `network`, `storage`, `agent`, `agent.model`, …), are
Expand All @@ -80,7 +80,9 @@
//! the old toolbox shape is still refused.
//!
//! So an older host never runs an app under weaker rules than its author
//! wrote, and a newer optional field never breaks an older host. The
//! wrote, and a newer optional field never breaks an older host, as long
//! as the manifest declares the minor that added the field: `window`, the
//! first field added at a new minor, is refused below `schema_minor` 1. The
//! ignored fields stay part of the manifest's signing bytes, so a newer
//! manifest's signature still verifies; a field added in `1.x` must
//! therefore be skipped when serialising its default and must serialise
Expand Down Expand Up @@ -109,7 +111,7 @@ pub use entry::{script_source, ASSETS_PLACEHOLDER, SCRIPT_ENTRY};
pub use manifest::{
check_component_id, check_reserved_id, is_exact_version, parse, short_id, AgentSpec, AgentWorkspace, AppManifest, ComponentDependency,
Compute, GithubPublisher, Integrity, ModelNeed, ModelSpec, ModelTier, Network, ProfileMode, Signature, Storage, TaskModel, Triggers,
KNOWN_CAPABILITIES, KNOWN_FEATURES, KNOWN_MODEL_NEEDS, MAX_COMPONENTS, RESERVED_HOST_FILE_IDS, RESERVED_NAMES, SCHEMA, SCHEMA_MINOR,
WindowHint, KNOWN_CAPABILITIES, KNOWN_FEATURES, KNOWN_MODEL_NEEDS, MAX_COMPONENTS, RESERVED_HOST_FILE_IDS, RESERVED_NAMES, SCHEMA, SCHEMA_MINOR,
};
pub use policy::{resolve, AppPolicy, HostLimits, StorageGrant};
pub use research::ResearchScope;
Expand Down
126 changes: 123 additions & 3 deletions crates/app-contract/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,19 @@ use std::collections::BTreeMap;
pub const SCHEMA: u32 = 1;

/// The newest `schema_minor` this build knows: which additions to schema 1
/// it understands. `0` is the grammar of `1.0.0`. A manifest may carry a
/// it understands. `0` is the grammar of `1.0.0`; `1` adds
/// [`AppManifest::window`] (contract `1.12.0`). A manifest may carry a
/// higher `schema_minor` (it records what the manifest uses, and asks for
/// nothing by itself); what a host must understand is listed in
/// [`AppManifest::requires`].
pub const SCHEMA_MINOR: u32 = 0;
pub const SCHEMA_MINOR: u32 = 1;

/// The first `schema_minor` that knows [`AppManifest::window`].
const WINDOW_SCHEMA_MINOR: u32 = 1;

/// The widths and heights, in points, a [`WindowHint`] may ask for.
const WINDOW_WIDTHS: std::ops::RangeInclusive<u32> = 320..=4096;
const WINDOW_HEIGHTS: std::ops::RangeInclusive<u32> = 240..=4096;

/// The features a manifest may list in [`AppManifest::requires`] and this
/// build honours. Empty in `1.0.0`: every feature added in `1.x` that
Expand Down Expand Up @@ -332,6 +340,16 @@ pub struct AppManifest {
/// exactly as it did.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub components: Vec<ComponentDependency>,
/// The size the app prefers its desktop window to open at, in points
/// ([`WindowHint`]). A hint, not a grant: hosts clamp it to the screen,
/// phones and other full-screen hosts ignore it, and the person can
/// still resize the window. An optional field in the growth rule's
/// sense, so it needs `schema_minor` 1 or later: a host that predates
/// it then reads the manifest leniently and ignores the hint instead of
/// refusing an unknown field. Skipped when absent, so a manifest written
/// before it existed signs exactly as it did.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window: Option<WindowHint>,
/// Features added in `1.x` that this manifest needs a host to honour,
/// because they restrict or change what the app gets. A host that does
/// not know one ([`KNOWN_FEATURES`]) refuses the app rather than run it
Expand Down Expand Up @@ -562,6 +580,70 @@ pub fn is_exact_version(version: &str) -> bool {
parts.len() == 3 && parts.iter().all(|p| numeric(p)) && pre_ok && build_ok
}

/// The size an app prefers its desktop window to open at, in points
/// ([`AppManifest::window`], `schema_minor` 1): `{"width": 1536, "height":
/// 1024}`.
///
/// A hint, not a grant. It asks for no capability, storage or budget, and
/// a host owes the app nothing for it: hosts clamp it to the screen, phones
/// and other full-screen hosts ignore it, and the person can still resize
/// the window. [`parse`] refuses a width outside 320 to 4096 or a height
/// outside 240 to 4096.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct WindowHint {
/// Points, 320 to 4096.
pub width: u32,
/// Points, 240 to 4096.
pub height: u32,
}

impl WindowHint {
/// A hint of `width` by `height` points. [`parse`] checks the range; this
/// does not.
pub fn new(width: u32, height: u32) -> Self {
WindowHint { width, height }
}
}

/// What a derived, `deny_unknown_fields` `Deserialize` does, except that only
/// an object is a window: serde would also read `[1536, 1024]` as one, which
/// then serialises, and signs, as an object rather than as written.
impl<'de> Deserialize<'de> for WindowHint {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::{Error, MapAccess, Visitor};
const FIELDS: &[&str] = &["width", "height"];
struct Object;
impl<'de> Visitor<'de> for Object {
type Value = WindowHint;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(r#"an object {"width": <points>, "height": <points>}"#)
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<WindowHint, A::Error> {
let (mut width, mut height) = (None, None);
while let Some(key) = map.next_key::<String>()? {
let (slot, name) = match key.as_str() {
"width" => (&mut width, "width"),
"height" => (&mut height, "height"),
other => return Err(A::Error::unknown_field(other, FIELDS)),
};
if slot.is_some() {
return Err(A::Error::duplicate_field(name));
}
*slot = Some(map.next_value::<u32>()?);
}
Ok(WindowHint {
width: width.ok_or_else(|| A::Error::missing_field("width"))?,
height: height.ok_or_else(|| A::Error::missing_field("height"))?,
})
}
}
// Named as a struct, so a manifest for a newer 1.x has a later
// field inside `window` ignored and listed like any other.
deserializer.deserialize_struct("WindowHint", FIELDS, Object)
}
}

#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
Expand Down Expand Up @@ -838,6 +920,11 @@ impl AppManifest {
/// level: by the growth rule they are optional, because a field that
/// restricts or changes what the app gets must be named in `requires`.
/// The ignored fields are reported by [`AppManifest::ignored_fields`].
/// - Known fields are checked as always, at every `schema_minor`
/// ([`AppManifest::check_requires`]). A manifest that sets `window`,
/// which `schema_minor` 1 added, is refused at `schema_minor` 0: a
/// host that predates `window` must read it leniently and ignore the
/// hint, never refuse it as an unknown field.
pub fn parse(json: &str) -> Result<Self, String> {
let manifest = match serde_json::from_str::<AppManifest>(json) {
Ok(manifest) => manifest,
Expand Down Expand Up @@ -904,7 +991,9 @@ impl AppManifest {

/// Refuse a manifest that requires a feature this build does not know:
/// running it would give the app less containment, or something other,
/// than its author declared.
/// than its author declared. Also refuse a known field its rules refuse:
/// `integrity.github`, `host_api`, `backend` and `components` without
/// their feature, or `window` below `schema_minor` 1 or out of range.
pub fn check_requires(&self) -> Result<(), String> {
let github_required = self.requires.iter().any(|f| f == "publisher-github-v1");
if github_required != self.integrity.github.is_some() {
Expand All @@ -930,6 +1019,7 @@ impl AppManifest {
backend.validate()?;
}
self.check_components()?;
self.check_window()?;
let unknown: Vec<&str> =
self.requires.iter().map(String::as_str).filter(|feature| !KNOWN_FEATURES.contains(feature)).collect();
if unknown.is_empty() {
Expand Down Expand Up @@ -961,6 +1051,36 @@ impl AppManifest {
Ok(())
}

/// The `window` rules: the field arrived with `schema_minor` 1, so a
/// manifest that sets it must say so (an older host then ignores the
/// hint instead of refusing an unknown field), and each side must be in
/// range. The hint grants nothing, so nothing else is checked.
fn check_window(&self) -> Result<(), String> {
let Some(window) = &self.window else { return Ok(()) };
if self.schema_minor < WINDOW_SCHEMA_MINOR {
return Err(format!(
"window requires \"schema_minor\": {WINDOW_SCHEMA_MINOR} or later, so that hosts which predate window ignore it instead of refusing the app"
));
}
if !WINDOW_WIDTHS.contains(&window.width) {
return Err(format!(
"window width must be {} to {} points, not {}",
WINDOW_WIDTHS.start(),
WINDOW_WIDTHS.end(),
window.width
));
}
if !WINDOW_HEIGHTS.contains(&window.height) {
return Err(format!(
"window height must be {} to {} points, not {}",
WINDOW_HEIGHTS.start(),
WINDOW_HEIGHTS.end(),
window.height
));
}
Ok(())
}

/// Device-side compatibility. Admission validates declarations separately;
/// installers and runners must supply their actual implemented API versions.
pub fn check_host_apis(&self, available: &BTreeMap<String, u32>) -> Result<(), String> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"digest": "729bc3dfe1ef9ae2e49cb86f82db670adc09282c4aaadc65d58ed804d6255c26",
"limits": "unsigned",
"refused": "window requires \"schema_minor\": 1 or later, so that hosts which predate window ignore it instead of refusing the app",
"source": "Contract 1.12.0: window in a manifest that does not declare schema_minor 1, which a host that predates window would refuse as an unknown field"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"schema": 1,
"id": "forecast",
"version": "1.2.0",
"name": "Forecast",
"integrity": { "bundle_blake3": "729bc3dfe1ef9ae2e49cb86f82db670adc09282c4aaadc65d58ed804d6255c26" },
"capabilities": ["storage"],
"window": { "width": 1536, "height": 1024 }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
a card bundle
28 changes: 28 additions & 0 deletions crates/app-contract/tests/fixtures/test-window-hint/expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"digest": "729bc3dfe1ef9ae2e49cb86f82db670adc09282c4aaadc65d58ed804d6255c26",
"entry": "card",
"ignored_fields": [],
"limits": "unsigned",
"policy": {
"app_id": "forecast",
"capabilities": [
"storage"
],
"display_name": "Forecast",
"hosts": [],
"instruction_budget": 20000000,
"may_prompt": false,
"memory_bytes": 67108864,
"research": null,
"storage": {
"accounts": false,
"agent_workspace": "account",
"cache_max_bytes": null,
"max_bytes": 16777216
},
"storage_bytes": 16777216,
"version": "1.2.0"
},
"signing_blake3": "33cd9cadafd87de362f31926030bc01c3f4686538d1835d3e1c22524ccb1595a",
"source": "Contract 1.12.0 (schema_minor 1): the optional window hint, read strictly at this build's minor; it grants nothing"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"schema": 1,
"schema_minor": 1,
"id": "forecast",
"version": "1.2.0",
"name": "Forecast",
"integrity": { "bundle_blake3": "729bc3dfe1ef9ae2e49cb86f82db670adc09282c4aaadc65d58ed804d6255c26" },
"capabilities": ["storage"],
"window": { "width": 1536, "height": 1024 }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
a card bundle
Loading
Loading