From f2d96d0f642663ca84f73ab67eb97ea2f3396d82 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:46:16 -0700 Subject: [PATCH] app-contract 1.12.0: the optional window hint (schema_minor 1) An app could not ask for a larger desktop window: the manifest had no size field and refuses unknown keys. PDF Tools v2 was designed at 1536 x 1024, and in the shell's default window its Combine button and Home captions fall below the fold. The manifest gains an optional `window` field, `WindowHint { width, height }` in points: the size the app prefers its desktop window to open at. It is a hint, not a grant. Hosts clamp it to the screen, phones and other full-screen hosts ignore it, the person can still resize, and the resolved AppPolicy does not change. It is an optional field in the growth rule's sense, so SCHEMA_MINOR moves to 1, its first bump. A manifest that sets `window` must declare "schema_minor": 1 or later, and parse and resolve refuse it otherwise. A host on an earlier contract then reads the manifest leniently, ignores `window`, lists it among the ignored fields and computes the same signing bytes, instead of refusing an unknown field (checked against the published 1.8.0, whose lenient path is unchanged through 1.11.0). Width must be 320 to 4096 and height 240 to 4096. Only an object is accepted: a derived Deserialize would also take [w, h], which would then sign as an object rather than as written. The field is skipped when absent, so existing manifests and their signing bytes are unchanged; the fixture corpus still reproduces, and gains test-window-hint and refuse-window-without-minor. With the bump, a manifest at schema_minor 1 is read strictly like any at this build's minor. The lenient-path tests now use SCHEMA_MINOR + 1, so they keep testing the lenient path. card-host: without --size, the window opens at the manifest's `window` size, sized like --size (no caption bar, not clamped), so App Flow can capture an app at its design size. Docs: the manifest reference in PUBLISHING (en and zh-CN) gains `window` and its rules, and `schema_minor` is no longer always left out; the card-host --size default and its log line in DEVELOPMENT (en and zh-CN). No crates.io publication is claimed: 1.12.0 is unreleased. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- crates/app-contract/CHANGELOG.md | 20 +++ crates/app-contract/Cargo.toml | 2 +- crates/app-contract/README.md | 10 +- crates/app-contract/src/lib.rs | 12 +- crates/app-contract/src/manifest.rs | 126 +++++++++++++++- .../refuse-window-without-minor/expected.json | 6 + .../package/manifest.json | 9 ++ .../package/page.card | 1 + .../fixtures/test-window-hint/expected.json | 28 ++++ .../test-window-hint/package/manifest.json | 10 ++ .../test-window-hint/package/page.card | 1 + crates/app-contract/tests/growth.rs | 142 +++++++++++++++++- crates/card-host/src/args.rs | 5 +- crates/card-host/src/host.rs | 40 ++++- docs/DEVELOPMENT.md | 3 +- docs/DEVELOPMENT.zh-CN.md | 3 +- docs/PUBLISHING.md | 45 +++++- docs/PUBLISHING.zh-CN.md | 36 ++++- 19 files changed, 473 insertions(+), 28 deletions(-) create mode 100644 crates/app-contract/tests/fixtures/refuse-window-without-minor/expected.json create mode 100644 crates/app-contract/tests/fixtures/refuse-window-without-minor/package/manifest.json create mode 100644 crates/app-contract/tests/fixtures/refuse-window-without-minor/package/page.card create mode 100644 crates/app-contract/tests/fixtures/test-window-hint/expected.json create mode 100644 crates/app-contract/tests/fixtures/test-window-hint/package/manifest.json create mode 100644 crates/app-contract/tests/fixtures/test-window-hint/package/page.card diff --git a/Cargo.lock b/Cargo.lock index 4555d61..2d9deab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1631,7 +1631,7 @@ dependencies = [ [[package]] name = "octosense-app-contract" -version = "1.11.0" +version = "1.12.0" dependencies = [ "blake3", "serde", diff --git a/crates/app-contract/CHANGELOG.md b/crates/app-contract/CHANGELOG.md index dafe559..2a0583f 100644 --- a/crates/app-contract/CHANGELOG.md +++ b/crates/app-contract/CHANGELOG.md @@ -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) diff --git a/crates/app-contract/Cargo.toml b/crates/app-contract/Cargo.toml index 4cbbbad..0deb45b 100644 --- a/crates/app-contract/Cargo.toml +++ b/crates/app-contract/Cargo.toml @@ -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" diff --git a/crates/app-contract/README.md b/crates/app-contract/README.md index c72215e..0fb2258 100644 --- a/crates/app-contract/README.md +++ b/crates/app-contract/README.md @@ -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 needs a newer host: `. `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()` @@ -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 diff --git a/crates/app-contract/src/lib.rs b/crates/app-contract/src/lib.rs index 3d0e487..9b849fc 100644 --- a/crates/app-contract/src/lib.rs +++ b/crates/app-contract/src/lib.rs @@ -68,9 +68,9 @@ //! `schema_minor`; otherwise the app is refused with //! `app needs a newer host: `. `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 @@ -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 @@ -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; diff --git a/crates/app-contract/src/manifest.rs b/crates/app-contract/src/manifest.rs index 5a58f37..9118296 100644 --- a/crates/app-contract/src/manifest.rs +++ b/crates/app-contract/src/manifest.rs @@ -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 = 320..=4096; +const WINDOW_HEIGHTS: std::ops::RangeInclusive = 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 @@ -332,6 +340,16 @@ pub struct AppManifest { /// exactly as it did. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub components: Vec, + /// 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, /// 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 @@ -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>(deserializer: D) -> Result { + 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": , "height": }"#) + } + fn visit_map>(self, mut map: A) -> Result { + let (mut width, mut height) = (None, None); + while let Some(key) = map.next_key::()? { + 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::()?); + } + 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] @@ -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 { let manifest = match serde_json::from_str::(json) { Ok(manifest) => manifest, @@ -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() { @@ -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() { @@ -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) -> Result<(), String> { diff --git a/crates/app-contract/tests/fixtures/refuse-window-without-minor/expected.json b/crates/app-contract/tests/fixtures/refuse-window-without-minor/expected.json new file mode 100644 index 0000000..2d34f6d --- /dev/null +++ b/crates/app-contract/tests/fixtures/refuse-window-without-minor/expected.json @@ -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" +} diff --git a/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/manifest.json b/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/manifest.json new file mode 100644 index 0000000..0eef033 --- /dev/null +++ b/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/manifest.json @@ -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 } +} diff --git a/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/page.card b/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/page.card new file mode 100644 index 0000000..03513c7 --- /dev/null +++ b/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/page.card @@ -0,0 +1 @@ +a card bundle \ No newline at end of file diff --git a/crates/app-contract/tests/fixtures/test-window-hint/expected.json b/crates/app-contract/tests/fixtures/test-window-hint/expected.json new file mode 100644 index 0000000..a6c3ecb --- /dev/null +++ b/crates/app-contract/tests/fixtures/test-window-hint/expected.json @@ -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" +} diff --git a/crates/app-contract/tests/fixtures/test-window-hint/package/manifest.json b/crates/app-contract/tests/fixtures/test-window-hint/package/manifest.json new file mode 100644 index 0000000..33d6aae --- /dev/null +++ b/crates/app-contract/tests/fixtures/test-window-hint/package/manifest.json @@ -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 } +} diff --git a/crates/app-contract/tests/fixtures/test-window-hint/package/page.card b/crates/app-contract/tests/fixtures/test-window-hint/package/page.card new file mode 100644 index 0000000..03513c7 --- /dev/null +++ b/crates/app-contract/tests/fixtures/test-window-hint/package/page.card @@ -0,0 +1 @@ +a card bundle \ No newline at end of file diff --git a/crates/app-contract/tests/growth.rs b/crates/app-contract/tests/growth.rs index a715c9b..61dad6c 100644 --- a/crates/app-contract/tests/growth.rs +++ b/crates/app-contract/tests/growth.rs @@ -12,10 +12,19 @@ fn manifest_with(body: &str) -> String { ) } +/// A `schema_minor` newer than this build's: what a manifest written for a +/// later 1.x says. Relative, so the lenient-path tests keep testing the +/// lenient path when this build's minor moves on. +fn newer_minor() -> u32 { + SCHEMA_MINOR + 1 +} + #[test] fn palpo_extension_preserves_schema_and_declares_its_host_feature() { assert_eq!(SCHEMA, 1); - assert_eq!(SCHEMA_MINOR, 0); + // 1 since 1.12.0, which added the optional `window` hint; Palpo's + // operations (1.2.0) left it at 0. + assert_eq!(SCHEMA_MINOR, 1); assert_eq!( KNOWN_FEATURES, &[ @@ -318,6 +327,11 @@ fn unknown_fields_are_still_refused() { assert!(err.contains("unknown field"), "{err}"); let err = parse(&manifest_with(r#""schema_minor":0,"network":{"hosts":[],"proxy":"x"}"#)).unwrap_err(); assert!(err.contains("unknown field `proxy`"), "{err}"); + // Minor 1 is this build's own minor now: no longer read leniently. + let err = parse(&manifest_with(r#""schema_minor":1,"sandbox":"off""#)).unwrap_err(); + assert!(err.contains("unknown field `sandbox`"), "{err}"); + let err = parse(&manifest_with(r#""schema_minor":1,"window":{"width":800,"height":600,"x":0}"#)).unwrap_err(); + assert!(err.contains("unknown field `x`"), "{err}"); } #[test] @@ -333,7 +347,8 @@ fn the_new_fields_do_not_change_an_older_manifests_signing_bytes() { #[test] fn a_newer_minor_ignores_an_unknown_optional_field_and_reports_it() { - let manifest = parse(&manifest_with(r#""schema_minor":1,"capabilities":["storage"],"theme_color":"slate""#)).unwrap(); + let minor = newer_minor(); + let manifest = parse(&manifest_with(&format!(r#""schema_minor":{minor},"capabilities":["storage"],"theme_color":"slate""#))).unwrap(); assert_eq!(manifest.ignored_fields(), ["theme_color"]); assert_eq!(manifest.capabilities, ["storage"]); let limits = HostLimits::default().with_require_signature(false); @@ -391,22 +406,137 @@ fn a_newer_minor_ignores_unknown_fields_at_every_level() { fn a_newer_minor_still_refuses_what_it_knows_to_be_wrong() { // Ignoring is for unknown fields only: a known field with a bad value, // a foreign schema and the old research shape are refused as before. - let err = parse(&manifest_with(r#""schema_minor":1,"storage":{"agent_workspace":"everything"}"#)).unwrap_err(); + let minor = newer_minor(); + let err = parse(&manifest_with(&format!(r#""schema_minor":{minor},"storage":{{"agent_workspace":"everything"}}"#))).unwrap_err(); assert!(err.contains("manifest is not valid"), "{err}"); - let err = parse(&manifest_with(r#""schema_minor":1,"x":1"#).replace(r#""schema":1"#, r#""schema":2"#)).unwrap_err(); + let err = parse(&manifest_with(&format!(r#""schema_minor":{minor},"x":1"#)).replace(r#""schema":1"#, r#""schema":2"#)).unwrap_err(); assert!(err.contains("manifest is not valid"), "{err}"); - let err = parse(&manifest_with(r#""schema_minor":1,"capabilities":["research"],"research":{"languages":["en"]}"#)).unwrap_err(); + let err = parse(&manifest_with(&format!( + r#""schema_minor":{minor},"capabilities":["research"],"research":{{"languages":["en"]}}"# + ))) + .unwrap_err(); assert!(err.contains("old toolbox shape"), "{err}"); + // So is a window out of range. + let err = parse(&manifest_with(&format!(r#""schema_minor":{minor},"window":{{"width":100,"height":600}}"#))).unwrap_err(); + assert_eq!(err, "window width must be 320 to 4096 points, not 100"); } #[test] fn a_newer_manifest_signs_with_the_fields_it_was_signed_with() { - let manifest = parse(&manifest_with(r#""schema_minor":1,"theme_color":"blue","network":{"hosts":[],"retry":2}"#)).unwrap(); + let minor = newer_minor(); + let manifest = + parse(&manifest_with(&format!(r#""schema_minor":{minor},"theme_color":"blue","network":{{"hosts":[],"retry":2}}"#))).unwrap(); let bytes = String::from_utf8(manifest.signing_bytes().unwrap()).unwrap(); assert!(bytes.contains(r#""theme_color":"blue""#), "{bytes}"); assert!(bytes.contains(r#""network":{"hosts":[],"retry":2}"#), "{bytes}"); } +// ---- the window hint (schema_minor 1, contract 1.12.0) ------------------- + +/// The signing bytes of `manifest_with(r#""capabilities":["storage"]"#)`, as +/// every contract before 1.12.0 computed them. +const PLAIN_SIGNING_BYTES: &str = r#"{"agent":null,"capabilities":["storage"],"compute":{"instruction_budget":null,"memory_bytes":null},"id":"forecast","integrity":{"bundle_blake3":"00","signature":null},"name":"Forecast","network":{"hosts":[]},"schema":1,"storage":{"max_bytes":null},"version":"1.0.0"}"#; + +#[test] +fn a_window_hint_parses_at_schema_minor_1_and_grants_nothing() { + let manifest = + parse(&manifest_with(r#""schema_minor":1,"capabilities":["storage"],"window":{"width":1536,"height":1024}"#)).unwrap(); + assert_eq!(manifest.window, Some(WindowHint::new(1536, 1024))); + assert!(manifest.ignored_fields().is_empty()); + // It serialises exactly as written, and its signing bytes carry it. + let json = serde_json::to_value(&manifest).unwrap(); + assert_eq!(json["window"], serde_json::json!({"width": 1536, "height": 1024})); + let bytes = String::from_utf8(manifest.signing_bytes().unwrap()).unwrap(); + assert!(bytes.contains(r#""schema_minor":1"#) && bytes.contains(r#""window":{"height":1024,"width":1536}"#), "{bytes}"); + // A hint, not a grant: the policy is the one the same manifest without + // it resolves to. + let limits = HostLimits::default().with_require_signature(false); + let plain = parse(&manifest_with(r#""capabilities":["storage"]"#)).unwrap(); + assert_eq!(resolve(&manifest, &limits).unwrap(), resolve(&plain, &limits).unwrap()); + let system = HostLimits::system(); + assert_eq!(resolve(&manifest, &system).unwrap(), resolve(&plain, &system).unwrap()); +} + +#[test] +fn a_manifest_without_a_window_parses_and_signs_as_before() { + let manifest = parse(&manifest_with(r#""capabilities":["storage"]"#)).unwrap(); + assert_eq!(manifest.window, None); + let json = serde_json::to_string(&manifest).unwrap(); + assert!(!json.contains("window"), "{json}"); + assert_eq!(String::from_utf8(manifest.signing_bytes().unwrap()).unwrap(), PLAIN_SIGNING_BYTES); + // Declaring this build's minor without using window changes only the + // schema_minor key. + let minor = parse(&manifest_with(r#""schema_minor":1,"capabilities":["storage"]"#)).unwrap(); + assert_eq!(minor.window, None); + let bytes = String::from_utf8(minor.signing_bytes().unwrap()).unwrap(); + assert_eq!(bytes.replace(r#","schema_minor":1"#, ""), PLAIN_SIGNING_BYTES); +} + +#[test] +fn a_window_hint_needs_schema_minor_1() { + let expected = r#"window requires "schema_minor": 1 or later, so that hosts which predate window ignore it instead of refusing the app"#; + for minor in ["", r#""schema_minor":0,"#] { + let err = parse(&manifest_with(&format!(r#"{minor}"window":{{"width":1536,"height":1024}}"#))).unwrap_err(); + assert_eq!(err, expected, "{minor:?}"); + } + // resolve checks it too, for a manifest that did not come through parse. + let unparsed: AppManifest = serde_json::from_str(&manifest_with(r#""window":{"width":1536,"height":1024}"#)).unwrap(); + assert_eq!(resolve(&unparsed, &HostLimits::system()).unwrap_err(), expected); +} + +#[test] +fn a_window_hint_is_held_to_its_range() { + let at = |width: i64, height: i64| { + parse(&manifest_with(&format!(r#""schema_minor":1,"window":{{"width":{width},"height":{height}}}"#))) + }; + for (width, height) in [(320, 240), (4096, 4096), (1536, 1024), (320, 4096), (4096, 240)] { + assert_eq!(at(width, height).unwrap().window, Some(WindowHint::new(width as u32, height as u32))); + } + for (width, height, expected) in [ + (319, 600, "window width must be 320 to 4096 points, not 319"), + (4097, 600, "window width must be 320 to 4096 points, not 4097"), + (0, 600, "window width must be 320 to 4096 points, not 0"), + (800, 239, "window height must be 240 to 4096 points, not 239"), + (800, 4097, "window height must be 240 to 4096 points, not 4097"), + ] { + assert_eq!(at(width, height).unwrap_err(), expected, "{width}x{height}"); + } + // Not a size at all: refused by the parser like any malformed field. + for (window, why) in [ + (r#"{"width":800}"#, "missing field `height`"), + (r#"{"height":600}"#, "missing field `width`"), + (r#"{"width":800,"width":900,"height":600}"#, "duplicate field `width`"), + (r#"{"width":-1,"height":600}"#, "invalid value"), + (r#"{"width":800.5,"height":600}"#, "invalid type: floating point"), + (r#"{"width":1536.0,"height":1024}"#, "invalid type: floating point"), + (r#"{"width":4294967296,"height":600}"#, "invalid value"), + (r#"{"width":"800","height":600}"#, "invalid type: string"), + // Only an object: an array would sign as an object, not as written. + (r#"[800,600]"#, "invalid type: sequence"), + (r#""800x600""#, "invalid type: string"), + ] { + let err = parse(&manifest_with(&format!(r#""schema_minor":1,"window":{window}"#))).unwrap_err(); + assert!(err.starts_with("manifest is not valid") && err.contains(why), "{window}: {err}"); + } +} + +#[test] +fn a_newer_minor_reads_the_window_and_still_ignores_what_it_does_not_know() { + // A manifest written for a later 1.x: window is known and checked, the + // rest is ignored, listed, and kept in the signing bytes. + let minor = newer_minor(); + let manifest = parse(&manifest_with(&format!( + r#""schema_minor":{minor},"capabilities":["storage"],"theme_color":"slate", + "window":{{"width":1536,"height":1024,"maximized":true}},"storage":{{"backup":"never"}}"# + ))) + .unwrap(); + assert_eq!(manifest.window, Some(WindowHint::new(1536, 1024))); + assert_eq!(manifest.ignored_fields(), ["storage.backup", "theme_color", "window.maximized"]); + let bytes = String::from_utf8(manifest.signing_bytes().unwrap()).unwrap(); + assert!(bytes.contains(r#""window":{"height":1024,"maximized":true,"width":1536}"#), "{bytes}"); + assert!(bytes.contains(r#""theme_color":"slate""#), "{bytes}"); +} + #[test] fn hostlimits_builders_set_each_ceiling() { let limits = HostLimits::system() diff --git a/crates/card-host/src/args.rs b/crates/card-host/src/args.rs index 3e5bfb8..93d2224 100644 --- a/crates/card-host/src/args.rs +++ b/crates/card-host/src/args.rs @@ -32,8 +32,9 @@ Options: --static = Serve 's files at /... from memory. Repeatable. --size x The window's inner size in layout points, e.g. - 390x844. Default: 412x892. card-studio renders a - card at several sizes with it. + 390x844. Default: the manifest's window size, + else 412x892. card-studio renders a card at + several sizes with it. -h, --help Print this and exit. Every L0 card logs one `card-host: realize {json}` line: the lint result diff --git a/crates/card-host/src/host.rs b/crates/card-host/src/host.rs index e4dd568..f99bbee 100644 --- a/crates/card-host/src/host.rs +++ b/crates/card-host/src/host.rs @@ -77,6 +77,17 @@ fn load_statics(mounts: &[(String, PathBuf)]) -> octosense_app_policy::StaticAss Box::leak(out.into_boxed_slice()) } +/// The size the bundle's manifest asks its window to open at (`window`, in +/// points), which a run without `--size` uses. card-host is a render target, +/// so it does not clamp the hint to the screen, just as it does not clamp +/// `--size`. A manifest that does not parse sizes nothing: it is refused, +/// with its reason, in the default window. +fn manifest_window(bundle: &Path) -> Option<(f64, f64)> { + let text = std::fs::read_to_string(bundle.join(octosense_app_policy::MANIFEST_FILE)).ok()?; + let window = octosense_app_policy::AppManifest::parse(&text).ok()?.window?; + Some((f64::from(window.width), f64::from(window.height))) +} + /// Admit the bundle and resolve what it gets. Refusals are fatal: a card that /// cannot be admitted must not be drawn, not even partially. fn policy_for(args: &Args) -> Result { @@ -323,15 +334,20 @@ impl App { error!("card-host: started without its arguments"); return; }; - if let Some((w, h)) = args.size { - // A sized window is a render target (card-studio): the card gets - // the whole inner size, so the caption bar goes. + let manifest_size = if args.size.is_none() { manifest_window(&args.bundle) } else { None }; + if let Some((w, h)) = args.size.or(manifest_size) { + // A sized window is a render target (card-studio, App Flow's + // captures at a design's size): the card gets the whole inner + // size, so the caption bar goes. let mut window = self.ui.widget(cx, ids!(main_window)); script_apply_eval!(cx, window, { show_caption_bar: false }); let size = dvec2(w, h); self.ui.window(cx, ids!(main_window)).configure_window(cx, size, dvec2(40.0, 40.0), false, "Card host".into()); self.ui.window(cx, ids!(main_window)).resize(cx, size); log!("card-host: window sized {w}x{h}"); + if manifest_size.is_some() { + log!("card-host: the size is the manifest's window; --size overrides it"); + } } let policy = match policy_for(args) { Ok(policy) => policy, @@ -495,6 +511,24 @@ mod tests { } } + #[test] + fn without_size_the_window_opens_at_the_manifests_hint() { + let dir = std::env::temp_dir().join(format!("card-host-window-hint-{}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("manifest.json"); + let manifest = |extra: &str| { + format!(r#"{{"schema":1,"id":"example.window","version":"1.0.0","name":"Fixture","integrity":{{"bundle_blake3":""}}{extra}}}"#) + }; + std::fs::write(&path, manifest(r#","schema_minor":1,"window":{"width":1536,"height":1024}"#)).unwrap(); + assert_eq!(manifest_window(&dir), Some((1536.0, 1024.0))); + // No hint, or a manifest the contract refuses: the default window. + std::fs::write(&path, manifest("")).unwrap(); + assert_eq!(manifest_window(&dir), None); + std::fs::write(&path, manifest(r#","window":{"width":1536,"height":1024}"#)).unwrap(); + assert_eq!(manifest_window(&dir), None); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn stamp_preserves_unsigned_null_signature_support() { let dir = std::env::temp_dir().join(format!("card-host-unsigned-null-{}", std::process::id())); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f178356..22580d2 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -105,7 +105,7 @@ the problem and the usage, and exits 2. Neither opens a window. | `--stamp` | Rewrite the manifest's `integrity.bundle_blake3` to match the directory before admitting. Without it, `card-host` refuses a bundle whose bytes changed since the last `hub stamp`. | | `--system` | Admit the bundle as a system app: by digest only, under the system ceilings. An empty digest is filled in memory. Use it to develop a system app. | | `--static =` | Serve ``'s files at `/...` from memory, as a shell serves a system app's compiled-in artwork. Photos uses `--static photos=`. Repeatable. | -| `--size x` | The window's inner size in layout points, with no caption bar, so the card gets exactly that viewport. Default: `412x892`. `card-studio` starts one `card-host` per size with it. | +| `--size x` | The window's inner size in layout points, with no caption bar, so the card gets exactly that viewport. Default: the manifest's [`window`](PUBLISHING.md#the-window-size) size when it has one, sized the same way and not clamped to the screen; otherwise `412x892`. `card-studio` starts one `card-host` per size with it. | | `--remote []` | Start the localhost HTTP control surface ([below](#drive-it-over-http-makepad_remote)) on ``. Without a port, `card-host` picks a free one and logs it. `MAKEPAD_REMOTE=` is equivalent to `--remote `. | The log says what happened: @@ -113,6 +113,7 @@ The log says what happened: | Log line | Meaning | | --- | --- | | `card-host: admitted — capabilities {…}, hosts {…}, storage bytes, agent ` | The bundle was admitted; the log shows usage declarations and resolved resource limits, not user consent. | +| `card-host: window sized x` | The window was sized by `--size`, or by the manifest's `window`, which the next line then says. | | `card-host: refused: ` | The bundle was refused. The window shows "card-host refused this bundle" and the reason. None of the bundle's code runs. | | `card-host: realize {json}` | An L0 card's lint and realize report, logged before the card is drawn. A script app logs none. | | `card-host: the card did not lower: ` | The card was admitted but could not be lowered to widgets. | diff --git a/docs/DEVELOPMENT.zh-CN.md b/docs/DEVELOPMENT.zh-CN.md index de495b6..d39b269 100644 --- a/docs/DEVELOPMENT.zh-CN.md +++ b/docs/DEVELOPMENT.zh-CN.md @@ -75,7 +75,7 @@ card-host --help | `--stamp` | 在准入之前重写清单的 `integrity.bundle_blake3`,使其与目录一致。不加这个参数时,如果应用包的字节在上次 `hub stamp` 之后有变化,`card-host` 会拒绝它。 | | `--system` | 按系统应用的方式准入:只校验摘要,适用系统上限。空摘要会在内存中补齐。用于开发系统应用。 | | `--static =` | 把 `` 中的文件读入内存,在 `/...` 路径下提供,与 Shell 提供系统应用内置素材的方式相同。相册使用 `--static photos=`。可重复使用。 | -| `--size x` | 窗口的内部尺寸(布局点),不带标题栏,使卡片正好获得这个视口。默认:`412x892`。`card-studio` 用它为每个尺寸各启动一个 `card-host`。 | +| `--size x` | 窗口的内部尺寸(布局点),不带标题栏,使卡片正好获得这个视口。默认:清单设有 [`window`](PUBLISHING.zh-CN.md#窗口尺寸) 时取它的尺寸,处理方式相同,也不受屏幕大小限制;否则为 `412x892`。`card-studio` 用它为每个尺寸各启动一个 `card-host`。 | | `--remote []` | 在 `` 上启动 localhost HTTP 控制接口(见 [下文](#通过-http-驱动makepad_remote))。不指定端口时,`card-host` 会自选一个空闲端口并记录在日志中。`MAKEPAD_REMOTE=` 与 `--remote ` 等效。 | 从日志可以看出运行结果: @@ -83,6 +83,7 @@ card-host --help | 日志行 | 含义 | | --- | --- | | `card-host: admitted — capabilities {…}, hosts {…}, storage bytes, agent ` | 应用包已准入;日志列出用途声明和解析后的资源上限,不代表用户同意。 | +| `card-host: window sized x` | 窗口尺寸来自 `--size`,或来自清单的 `window`;后一种情况下,下一行会说明。 | | `card-host: refused: ` | `card-host` 拒绝了这个应用包。窗口显示“card-host refused this bundle”和原因,应用包中的任何代码都不会运行。 | | `card-host: realize {json}` | L0 卡片的 lint 和 realize 报告,在绘制卡片之前记录。脚本应用没有这一行。 | | `card-host: the card did not lower: ` | 卡片已准入,但无法转换为控件。 | diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index b6c1de1..54bb577 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -673,7 +673,8 @@ Validate its exact build, platform and released bytes separately. | `components` | The shared components the app's functions use, each pinned to one exact version and digest. | Optional; needs `wasm-shared-components-v1`; at most 8 ([Shared components](#shared-components)). | | `host_api` | The host API methods the app needs (`required`) or can use (`optional`), each with its ABI major version. | Optional; needs `host-api-v1` in `requires`. The store checks the `required` methods at install and at every launch ([Declare what the app needs](HOST-API.md#declare-what-the-app-needs)). | | `backend` | The app's own backend: its public sign-in endpoints and the operations the app may call. | Optional; needs `backend-api-v1` in `requires` and `storage.accounts: true`. Holds no credentials ([Sign in to your own backend](#sign-in-to-your-own-backend)). | -| `schema_minor` | Which additions to schema 1 the manifest uses. | Leave it out. | +| `window` | The size the app prefers its desktop window to open at, in points: `{"width": 1536, "height": 1024}`. | Optional; needs `"schema_minor": 1`; width 320 to 4096, height 240 to 4096. A hint that grants nothing ([The window size](#the-window-size)). | +| `schema_minor` | Which additions to schema 1 the manifest uses. | Leave it out, unless the manifest sets `window`: then `1`. | Any other field is refused. After `hub publisher-prepare`, the manifest also holds `null` for optional fields you left out, such as `"agent": null`. They @@ -881,6 +882,48 @@ The store shows the scope in plain words. For `research` with privacy summary ([The listing](#the-listing)) says "Searches news in English and Chinese, from the last 7 days". +### The window size + +`window` asks a desktop host to open the app's window at a size, in points: + +```json +{ + "schema_minor": 1, + "window": { "width": 1536, "height": 1024 } +} +``` + +| Field | Rule | +| --- | --- | +| `width` | 320 to 4096. | +| `height` | 240 to 4096. | + +The parser refuses a size out of range, and refuses `window` in a manifest +without `"schema_minor": 1`: + +```text +window requires "schema_minor": 1 or later, so that hosts which predate window ignore it instead of refusing the app +``` + +`window` is a hint, not a grant. It changes no capability, quota or review, +and the store does not show it. + +- A desktop host that reads it opens the app's window at that size, clamped + to its screen. The person can still resize the window. +- Phones and other full-screen hosts ignore it. +- `card-host` opens at that size when `--size` is not given. It is a render + target, so it does not clamp the size to the screen + ([Run a bundle locally](DEVELOPMENT.md#run-a-bundle-locally-card-host)). +- A host built on a contract before 1.12.0 knows only `schema_minor` 0. It + reads a manifest with `"schema_minor": 1` leniently: it ignores `window`, + lists it in `AppManifest::ignored_fields()` and opens the app at its own + default size. Without `"schema_minor": 1`, that host would refuse `window` + as an unknown field. + +Without `window`, a host opens the app at its own default size. + +Source: `WindowHint` in `crates/app-contract/src/manifest.rs`. + ## The app's agent and tools An app can ship an agent of its own: its tools in `tools.json`, its diff --git a/docs/PUBLISHING.zh-CN.md b/docs/PUBLISHING.zh-CN.md index 639b7e9..689152f 100644 --- a/docs/PUBLISHING.zh-CN.md +++ b/docs/PUBLISHING.zh-CN.md @@ -497,7 +497,8 @@ hub: the bundle was refused | `components` | 应用的函数所用的共享组件,每个都固定到一个确切版本和摘要。 | 可选;需要 `wasm-shared-components-v1`;最多 8 个([共享组件](#共享组件))。 | | `host_api` | 应用需要(`required`)或可以使用(`optional`)的宿主 API 方法,每个方法都写明 ABI 主版本。 | 可选;须在 `requires` 中声明 `host-api-v1`。商店在安装时和每次启动时检查 `required` 中的方法([声明应用需要什么](HOST-API.zh-CN.md#声明应用需要什么))。 | | `backend` | 应用自己的后端:公开的登录端点,以及应用可以调用的操作。 | 可选;须在 `requires` 中声明 `backend-api-v1`,并设置 `storage.accounts: true`。不含任何凭据([登录自己的后端](#登录自己的后端))。 | -| `schema_minor` | 清单用到了 schema 1 的哪些新增内容。 | 省略此字段。 | +| `window` | 应用希望桌面窗口打开时的尺寸,单位为点:`{"width": 1536, "height": 1024}`。 | 可选;需要 `"schema_minor": 1`;宽度 320 到 4096,高度 240 到 4096。只是提示,不授予任何权限([窗口尺寸](#窗口尺寸))。 | +| `schema_minor` | 清单用到了 schema 1 的哪些新增内容。 | 省略此字段;但清单设置了 `window` 时,须写 `1`。 | 其他字段一律拒绝。运行 `hub publisher-prepare` 之后,你省略的可选字段也会出现在清单中,值为 `null`,例如 `"agent": null`。这些字段不改变任何行为。 @@ -661,6 +662,39 @@ ID 请以你能控制的反向域名前缀开头,例如把自己的域名倒 商店会用通俗的话展示这个范围。例如,`research` 的范围为 `{"langs":["en","zh"],"categories":["news"],"max_age_days":7}` 时,商店的隐私概要([商店信息](#商店信息))显示“Searches news in English and Chinese, from the last 7 days”。 +### 窗口尺寸 + +`window` 请求桌面宿主以指定尺寸打开应用窗口,单位为点: + +```json +{ + "schema_minor": 1, + "window": { "width": 1536, "height": 1024 } +} +``` + +| 字段 | 规则 | +| --- | --- | +| `width` | 320 到 4096。 | +| `height` | 240 到 4096。 | + +尺寸超出范围时,解析器会拒绝清单;清单没有写 `"schema_minor": 1` 却设置了 `window` 时,也会被拒绝: + +```text +window requires "schema_minor": 1 or later, so that hosts which predate window ignore it instead of refusing the app +``` + +`window` 只是提示,不是授权。它不改变任何能力、配额或审核,商店也不显示它。 + +- 读取它的桌面宿主按这个尺寸打开应用窗口,并限制在屏幕范围以内。用户仍然可以调整窗口大小。 +- 手机和其他全屏宿主忽略它。 +- 不加 `--size` 时,`card-host` 按这个尺寸打开窗口。它是渲染目标,所以不会把尺寸限制在屏幕范围以内([在本地运行应用包](DEVELOPMENT.zh-CN.md#在本地运行应用包card-host))。 +- 基于 1.12.0 之前的契约构建的宿主只认识 `schema_minor` 0。它以宽松方式读取写有 `"schema_minor": 1` 的清单:忽略 `window`,把它列入 `AppManifest::ignored_fields()`,并按自己的默认尺寸打开应用。清单如果没有写 `"schema_minor": 1`,这样的宿主会把 `window` 当作未知字段拒绝。 + +没有 `window` 时,宿主按自己的默认尺寸打开应用。 + +源码:`crates/app-contract/src/manifest.rs` 中的 `WindowHint`。 + ## 应用的 Agent 与工具 应用可以自带 Agent,由清单中的 `agent` 字段声明:工具写在 `tools.json` 中,指令写在 `AGENT.md` 中,只含数据的技能放在 `skills/` 下。每个文件都在应用包摘要的覆盖范围内,因此运行的 Agent 就是审核过的那一个。商店把这个 Agent 称为应用的助手(assistant)。