Skip to content
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ templates and test fixtures.
| Path | What it is |
| --- | --- |
| `catalog.json` | The signed catalog. Stores verify it against the anchor below before showing anything. |
| `index/<app>-<version>.json` | One admitted entry per app version: its manifest, publisher, source and status. Created by `hub publish`; absent while no app is published. |
| `index/<app>-<version>.json` | One admitted entry per app version: its manifest, publisher, source and status. A maintainer exports the admitted entry from the catalog after `hub publish`; absent while no app is published. |
| `artifacts/<app>-<version>.bundle/` | The hub's copy of the bundle, exactly the bytes that were reviewed. Created by `hub publish`. |
| `artifacts/<app>-<version>.bundle.pack.json` | The same bundle as one file, which stores download. |
| `docs/FIRST-APP.md` | First-app walkthrough for a card app or a script app: author, package, run, capture, validate and submit. |
Expand Down Expand Up @@ -61,11 +61,40 @@ app id. Only requested names are eligible; owner sharing, caller grants and a
real executor remain separate shell checks. An offer neither starts an agent
nor gives script UI code raw host-service access. Store defaults are unchanged.

App contract 1.5 admits four additional, independent host-service capabilities:
`auth` for GitHub/Google connection management, `github` for repository operations,
`gcalendar` for Google Calendar, and `gmail` for Gmail. OAuth tokens belong to the
host; apps receive handles bound to their own identity and authorized scopes.
The declarations do not register services: standalone `card-host` has none of
these providers. The OctoSense host implementation and provider registrations
must be installed before a sample can sign in. No OctoSense account is required.

An ordinary app can bind its own agent tool to a reviewed shared service with
`"implemented_by":"host-service", "host_method":"github.read"` in `tools.json`.
The tool keeps its app namespace. Admission checks the target against
`SHARED_HOST_METHODS`, the declared service grant, private-data disclosure and
minimum risk; the shell must check the resolved grant again when executing it.
Omitting `host_method` preserves the original dispatch behavior. Authentication,
host sheets, direct saves and sends cannot be aliased. An admitted alias neither
installs its service nor grants approval for an external action.

Catalog tool summaries omit `host_method` so older stores can read the catalog.
They retain permission and risk metadata, but are never used for dispatch.
The signed bundle keeps the complete tools file; installation and launch still
verify its exact digest and publisher signature before loading those bindings.

The `calendar` capability (app contract 1.4) separately admits Calendar UI
requests. OctoSense registers the Calendar service and checks its owning app
identity. A declared capability alone does not provide a service, a calendar
account, or agent tools.

Host-owned account and review sheets are modal: text, keyboard, IME,
clipboard and pointer-release events reach only the visible sheet. Timer and
service callbacks continue to reach the app. The runner captures its own card
and sheet references before evaluating app widgets, so a bundle cannot replace
the host surface by reusing a widget ID. `services::is_sheet_input_event` supplies
the same input boundary to integrated foreground Glance hosts.

The `photos` and `youtube` capabilities (app contract 1.5) admit requests to
the app-owned media services supplied by OctoSense. They do not grant Android
Gallery access, a YouTube account, or another agent's tools. Cross-app tools
Expand Down
24 changes: 23 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
| 路径 | 说明 |
| --- | --- |
| `catalog.json` | 签名目录。商店在展示任何内容之前,先用下方的信任锚验证它。 |
| `index/<app>-<version>.json` | 每个应用版本一条已准入条目:清单、发布者、源码位置与状态。由 `hub publish` 生成;尚无已发布应用时不存在。 |
| `index/<app>-<version>.json` | 每个应用版本一条已准入条目:清单、发布者、源码位置与状态。维护者在 `hub publish` 后从目录导出已准入条目;尚无已发布应用时不存在。 |
| `artifacts/<app>-<version>.bundle/` | App Hub 保存的应用包副本,与审核时的字节完全一致。由 `hub publish` 生成。 |
| `artifacts/<app>-<version>.bundle.pack.json` | 打成单个文件的同一应用包,商店下载的就是它。 |
| `docs/FIRST-APP.md` | 第一个应用(卡片应用或脚本应用)的完整演练:编写、打包、运行、截图、验证与提交。 |
Expand Down Expand Up @@ -50,6 +50,11 @@ Splash 脚本和 L0 卡片,并把 crate 索引放在最后。仓库协作规
工具所有者的共享声明、调用者授权与实际执行器仍由 Shell 分别检查。提供工具范围
不会启动代理,也不授予脚本 UI 原始宿主服务权限;商店应用的默认规则不变。

主机账户和审核面板采用模态输入:文字、键盘、输入法、剪贴板和指针抬起事件
只交给可见的主机面板,定时器及服务回调仍能到达应用。运行器在执行应用控件前
保存自身卡片和面板引用,应用不能通过重复控件 ID 替换主机界面。
`services::is_sheet_input_event` 为集成前台 Glance 主机提供相同输入边界。

## 信任锚

商店信任这个锚,并沿着它的证书找到为目录签名的工作密钥。轮换工作密钥不需要发布新版商店。
Expand Down Expand Up @@ -82,6 +87,23 @@ appstore

曾用于跑通发布流程的相机卡片已于 2026 年 9 月 20 日移除:相机是随 Shell 出厂的系统应用(与新闻、相册一样),不是商店应用。它的仓库仍保留在 [ymote/camera-card](https://github.com/ymote/camera-card),作为可发布应用包的完整示例。

应用契约 1.5 新增四项独立的宿主服务能力:`auth` 管理 GitHub/Google
连接,`github` 访问仓库,`gcalendar` 访问 Google Calendar,`gmail` 访问 Gmail。
OAuth 令牌由宿主保存;应用只取得绑定自身身份与授权范围的连接句柄。
能力声明不会注册服务;独立的 `card-host` 不提供这些服务。样例登录需要
安装 OctoSense 的宿主实现并配置提供商注册信息,无需 OctoSense 账号。

普通应用可在 `tools.json` 中用 `"implemented_by":"host-service"` 和
`"host_method":"github.read"` 将自身命名空间的工具绑定到已审核的共享服务。
准入检查 `SHARED_HOST_METHODS` 白名单、声明的服务权限、私密数据披露和最低风险;
执行时宿主仍需检查实际授予的权限。省略该字段时保持原有分发行为。
登录、宿主 sheet、直接保存和发送不能通过别名调用。别名通过准入不等于服务
已注册,也不等于用户批准外部操作。

目录中的工具摘要省略 `host_method`,以便旧版商店仍可读取目录;权限和风险信息
保持不变,该摘要不用于执行分发。签名应用包保留完整工具文件,安装及启动仍须
验证准确的包摘要和发布者签名,之后才加载工具绑定。

Calendar 的脚本 UI 另需应用契约 1.4 的 `calendar` 能力。OctoSense 注册日历服务,
每次调用均校验所属应用身份。声明能力本身不会创建服务、连接日历账号或授予 Agent 工具。

Expand Down
4 changes: 4 additions & 0 deletions crates/app-contract/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ within `1.x` it only grows.

## 1.5.0

- Admit the `auth`, `github`, `gcalendar` and `gmail` host-service capabilities.
Each remains an independent grant. OAuth returns app-bound connection handles;
provider tokens stay in the host. These declarations require corresponding
OctoSense implementations and do not make a provider available by themselves.
- Add the `photos` and `youtube` capabilities for the contained Photos and
YouTube UIs to call their owning host services. Neither grants Android
Gallery access, a YouTube account or another app's agent tools; cross-app
Expand Down
8 changes: 8 additions & 0 deletions crates/app-contract/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ pub const KNOWN_CAPABILITIES: &[&str] = &[
// person signs in to on the host's own sheet. The app never holds the
// password or the connection.
"mail",
// Host-owned OAuth connection lifecycle. Returns app-bound handles, never tokens.
"auth",
// GitHub repository reads and host-reviewed Markdown commits.
"github",
// Google Calendar connector; distinct from the local system calendar.
"gcalendar",
// Google Gmail API connector; distinct from the IMAP/SMTP mail service.
"gmail",
// Calendar's local event store and UI. The service additionally checks
// owning app identity; this is not Google/Android calendar access.
"calendar",
Expand Down
18 changes: 18 additions & 0 deletions crates/app-contract/tests/growth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ fn an_unknown_required_feature_needs_a_newer_host() {
assert!(err.ends_with("needs a newer host: a, b"), "{err}");
}

#[test]
fn oauth_provider_services_are_independent_grants() {
let limits = HostLimits::default().with_require_signature(false);
let capabilities = ["auth", "github", "gcalendar", "gmail"];
for granted in capabilities {
let manifest = parse(&manifest_with(&format!(r#""capabilities":["{granted}"]"#))).unwrap();
let policy = resolve(&manifest, &limits).unwrap();
for candidate in capabilities {
assert_eq!(policy.allows(candidate), candidate == granted);
}
for unrelated in ["mail", "calendar", "net", "glance", "auth.*"] {
assert!(!policy.allows(unrelated));
}
let wildcard = parse(&manifest_with(&format!(r#""capabilities":["{granted}.*"]"#))).unwrap();
assert!(resolve(&wildcard, &limits).is_err());
}
}

#[test]
fn resolve_checks_requires_even_for_a_manifest_not_read_by_parse() {
let manifest: AppManifest = serde_json::from_str(&manifest_with(r#""requires":["x"]"#)).unwrap();
Expand Down
10 changes: 9 additions & 1 deletion crates/app-hub/src/gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,11 +486,19 @@ pub fn entry_for(
None => {}
}
let listing = std::fs::read_to_string(bundle.join(LISTING_FILE)).ok().and_then(|t| Listing::parse(&t).ok());
let mut tools = octosense_app_policy::agent::read_tools(bundle)?.map(|t| t.tools).unwrap_or_default();
// Catalog tools describe permissions; dispatch reads the digest-pinned
// bundle instead. Older stores strictly parse ToolSpec, so including this
// new routing field here would make them reject the entire catalog.
// Keep every permission field, and leave the signed bundle untouched.
for tool in &mut tools {
tool.host_method = None;
}
Ok(Entry {
artifact: format!("artifacts/{}-{}.bundle", report.app_id, report.version),
manifest,
listing,
tools: octosense_app_policy::agent::read_tools(bundle)?.map(|t| t.tools).unwrap_or_default(),
tools,
publisher: publisher.to_string(),
publisher_key: publisher_key.to_string(),
source: crate::index::Source { repository: repository.to_string(), commit: commit.to_string() },
Expand Down
8 changes: 5 additions & 3 deletions crates/app-hub/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ pub struct Entry {
pub listing: Option<Listing>,
/// The app's tool manifest (`tools.json`) as reviewed, so a store can
/// say which tools wait for approval or may be shared before anything is
/// installed. Omitted when the app ships no tools, so an entry without
/// them serialises, and signs, as it did before the field existed. The
/// bundle's copy is authoritative on the device: the digest pins it.
/// installed. A display projection: `entry_for` omits `host_method` so
/// older stores can still read the catalog. It is never a dispatch table.
/// Omitted when the app ships no tools, so an entry without them serialises,
/// and signs, as it did before the field existed. The bundle's exact copy,
/// including its dispatch bindings, is authoritative: the digest pins it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ToolSpec>,
/// Where the hub's own copy of the bundle lives, relative to the catalog.
Expand Down
176 changes: 176 additions & 0 deletions crates/app-hub/tests/catalog_tool_compat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//! Catalog display metadata stays readable by stores before host_method.
//! Dispatch still comes from the exact publisher-signed bundle.
mod common;
use common::Fixture;
use octosense_app_hub::*;
use octosense_app_policy::{AgentBundle, HostLimits};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;

// Strict wire shape from app-policy at 966e711 (before connected services).
// Defaults/omissions match that reader so roundtrip equality checks the
// canonical bytes it verifies, not merely permissive JSON parsing.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyTool {
name: String,
description: String,
input_schema: Value,
output_schema: Value,
risk: String,
#[serde(default)]
background: bool,
#[serde(default)]
shareable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
private_data: Option<bool>,
implemented_by: String,
#[serde(default = "host", skip_serializing_if = "is_host")]
confirm: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
outward: bool,
#[serde(default = "yes", skip_serializing_if = "is_true")]
auto_approvable: bool,
}
fn host() -> String {
"host".into()
}
fn is_host(value: &String) -> bool {
value == "host"
}
fn yes() -> bool {
true
}
fn is_true(value: &bool) -> bool {
*value
}

fn connected() -> Fixture {
let mut f = Fixture::new();
f.manifest.id = "org.example.calendarpreview".into();
f.manifest.capabilities = vec!["gcalendar".into()];
fs::write(
f.bundle.join("tools.json"),
json!({"schema":1,"tools":[{
"name":"calendarpreview.cached", "description":"Read its cached agenda.",
"input_schema":{"type":"object"}, "output_schema":{"type":"object"},
"risk":"read", "background":false, "shareable":false, "private_data":true,
"implemented_by":"host-service", "host_method":"gcalendar.cached"
}]})
.to_string(),
)
.unwrap();
f.sign();
f
}

#[test]
fn legacy_catalog_tools_roundtrip_without_changing_signed_bundle_dispatch() {
let f = connected();
let original_bytes = fs::read(f.bundle.join("tools.json")).unwrap();
let original = AgentBundle::load(&f.bundle, &f.manifest).unwrap().unwrap();
// Demonstrate the actual pre-fix failure: the strict legacy reader
// refuses this new field, even though the entry is only for display.
let err =
serde_json::from_value::<LegacyTool>(serde_json::to_value(&original.tools[0]).unwrap())
.unwrap_err();
assert!(
err.to_string().contains("unknown field `host_method`"),
"{err}"
);

let entry = f.entry();
let mut expected = original.tools.clone();
for tool in &mut expected {
tool.host_method = None;
}
assert_eq!(
entry.tools, expected,
"all permission and schema fields must survive"
);
assert_eq!(
fs::read(f.bundle.join("tools.json")).unwrap(),
original_bytes
);

let anchor = HubKey::generate();
let working = HubKey::generate();
let mut catalog = Catalog::new(1, "2026-10-06", vec![entry]);
working
.sign_catalog(
&mut catalog,
&anchor.certify(&working.public_hex()).unwrap(),
)
.unwrap();
let wire = serde_json::to_value(&catalog).unwrap();
let legacy_tools: Vec<LegacyTool> =
serde_json::from_value(wire["entries"][0]["tools"].clone()).unwrap();
let mut legacy_wire = wire.clone();
legacy_wire["entries"][0]["tools"] = serde_json::to_value(legacy_tools).unwrap();
assert_eq!(
legacy_wire, wire,
"legacy defaults must preserve signed JSON"
);
let legacy_roundtrip: Catalog = serde_json::from_value(legacy_wire).unwrap();
assert_eq!(
legacy_roundtrip.signing_bytes().unwrap(),
catalog.signing_bytes().unwrap()
);
verify_catalog(&legacy_roundtrip, &anchor.public_hex()).unwrap();

let mut store = Store::new(
&anchor.public_hex(),
&f.root.join("installed"),
HostLimits::default(),
);
store
.accept_catalog(&serde_json::to_string(&catalog).unwrap())
.unwrap();
store
.install_staged(&f.manifest.id, &f.bundle, &f.keys(), "2026-10-06")
.unwrap();
store.may_run(&f.manifest.id).unwrap();
let installed = store.install_dir(&f.manifest.id);
let loaded = AgentBundle::load(&installed, &f.manifest).unwrap().unwrap();
assert_eq!(
loaded.tools, original.tools,
"dispatch must load the original signed bindings"
);
assert_eq!(
loaded.tools[0].host_method.as_deref(),
Some("gcalendar.cached")
);

// Removing or redirecting that binding after install still invalidates
// the signed package. Display projection must never relax integrity.
fs::write(installed.join("tools.json"), original_bytes).unwrap();
let mut tampered: Value =
serde_json::from_slice(&fs::read(installed.join("tools.json")).unwrap()).unwrap();
tampered["tools"][0]["host_method"] = json!("gcalendar.get");
fs::write(installed.join("tools.json"), tampered.to_string()).unwrap();
assert!(store.may_run(&f.manifest.id).is_err());
assert!(AgentBundle::load(&installed, &f.manifest).is_err());
}

#[test]
fn display_projection_never_masks_a_forbidden_binding_from_admission() {
let mut f = connected();
let path = f.bundle.join("tools.json");
let mut tools: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
tools["tools"][0]["host_method"] = json!("gcalendar.review_save");
fs::write(path, tools.to_string()).unwrap();
f.sign();
let report = f.report(None);
assert!(!report.passed());
assert!(entry_for(
&f.bundle,
&report,
"publisher-one",
&f.publisher.public_hex(),
"",
"",
"2026-10-06"
)
.is_err());
}
Loading
Loading