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
6 changes: 6 additions & 0 deletions .github/workflows/apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ jobs:
cargo test --locked -p octosense-ai-host
cargo test --locked -p octosense-ai-host --features octos-core,llm
cargo clippy --locked -p octosense-ai-host --all-targets --features octos-core,llm --no-deps -- -D warnings
# The system toolbox for app agents (off by default): its grants and
# executor through main's broker, against the scripted kernel. The
# real-kernel test (tests/toolbox_real_kernel.rs) needs an octos
# binary and passes without.
cargo test --locked -p octosense-ai-host --features toolbox-peers
cargo clippy --locked -p octosense-ai-host --all-targets --features toolbox-peers --no-deps -- -D warnings
- name: AppCard runtime setup safeguards
working-directory: apps/appcard
run: PYTHONPATH=tools python3 -m unittest core.test_native_runtime
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/phone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,6 @@ jobs:
cargo test --locked --features mobile-apps -p octosense-shell -p octosense-home -p octosense-app-policy -p octosense-app-hub -p octosense-appcard -p octosense-ai-host
# The opt-in AppCard build: its module registers into the shell.
cargo test --locked --features mobile-apps,app-appcard -p octosense-shell appcard
cargo test --locked --features mobile-apps,toolbox-peers -p octosense-shell host_tools
cargo test --locked -p makepad-widgets splash_policy
cargo test --locked -p makepad-script-std gate::tests
2 changes: 2 additions & 0 deletions Cargo.lock

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

13 changes: 10 additions & 3 deletions apps/ai-providers/host-service/src/complete/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
//! to call it too, so direct calls and template calls share one budget per
//! app: its `ModelRequest {system, user, output_schema}` maps onto a
//! [`Request`] with `system` set and `input` the user document ([`host`]
//! hands out the registered host).
//! hands out the registered host). A host caller's input may be up to
//! [`HOST_INPUT_MAX`] (a digest carries its articles' evidence); an app's
//! stays at [`INPUT_MAX`].
use crate::model::{effective_model, ProfileStore};
use crate::probe;
use crate::vault::Vault;
Expand All @@ -77,6 +79,10 @@ pub const FAMILY: &str = "model";
pub const TASK_MAX: usize = 4 * 1024;
/// The input, as compact JSON, in bytes.
pub const INPUT_MAX: usize = 32 * 1024;
/// The input of a host caller's call (`system` set: the toolbox's digests,
/// whose user document carries the articles' evidence), which
/// `host.request` can never make.
pub const HOST_INPUT_MAX: usize = 256 * 1024;
/// The model's reply text (after a code fence is removed), in bytes: the
/// host's hard cap on what an app gets back.
pub const OUTPUT_MAX: usize = 16 * 1024;
Expand Down Expand Up @@ -219,8 +225,9 @@ impl Request {
return Err(bad(format!("task is over {TASK_MAX} bytes")));
}
let input = self.input.to_string().len();
if input > INPUT_MAX {
return Err(bad(format!("input is {input} bytes; the most is {INPUT_MAX}")));
let max = if self.system.is_some() { HOST_INPUT_MAX } else { INPUT_MAX };
if input > max {
return Err(bad(format!("input is {input} bytes; the most is {max}")));
}
if self.schema.is_null() {
return Err(bad("schema is required".into()));
Expand Down
22 changes: 22 additions & 0 deletions apps/ai-providers/host-service/tests/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,25 @@ fn a_host_caller_shares_the_same_ledger() {
assert_eq!(body["messages"][1]["content"], "{\"query\":\"x\"}");
assert_eq!(Code::Budget.as_str(), "budget");
}

#[test]
fn a_host_caller_may_send_a_larger_input_than_an_app() {
// A toolbox digest carries the articles' evidence (up to 6 KB each):
// over an app's 32 KiB, within the host's 256 KiB.
let rig = Rig::new("host-input", vec![deepseek("deepseek-v4-flash")]);
rig.fake.says(GOOD);
let big = "x".repeat(complete::INPUT_MAX + 1024);
let request = |system: Option<&str>| Request {
class: complete::Class::Fast,
task: if system.is_some() { String::new() } else { "t".into() },
input: json!(big),
schema: args()["schema"].clone(),
allow_urls: true,
system: system.map(str::to_owned),
};
let refused = rig.host.complete(APP, request(None)).unwrap_err();
assert_eq!(refused.code, Code::BadRequest);
rig.host.complete(APP, request(Some("You write a digest."))).unwrap();
let huge = Request { input: json!("x".repeat(complete::HOST_INPUT_MAX + 1)), ..request(Some("s")) };
assert_eq!(rig.host.complete(APP, huge).unwrap_err().code, Code::BadRequest);
}
16 changes: 16 additions & 0 deletions crates/ai-host/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
# dependencies below): `cfg(kernel)` in the source, set by build.rs.
# - `llm`: register the `llm` host service (a shell's `app-hub` feature turns
# it on; phones always).
# - `toolbox-peers`: the system toolbox offered to app agents as host-routed
# tools (octos#2567): the grants, and the executor the shell's host-tool
# relay routes the `toolbox` owner's calls to. Off by default; the shell's
# feature of the same name turns it on.
#
# The kernel is crates/kernel, the app-peers contract and broker
# crates/app-peers, the `llm` service and its config apps/ai-providers/*;
Expand All @@ -38,6 +42,16 @@ license.workspace = true
default = []
octos-core = ["dep:octosense-kernel", "octosense-app-peers/octos-core", "octosense-llm-service?/octos-core"]
llm = ["dep:octosense-llm-service"]
# The system toolbox for app agents (ADR 0002 section 6, ADR 0004 section
# 12): `toolbox_peers`, over the octos research engine and the `model`
# service's host.
toolbox-peers = [
"octos-core",
"llm",
"dep:octosense-toolbox",
"octosense-toolbox/octos-engine",
"dep:tokio",
]

[dependencies]
makepad-widgets = { workspace = true }
Expand All @@ -51,6 +65,8 @@ octosense-appstore = { workspace = true }
serde_json = { workspace = true }
octosense-kernel = { workspace = true, optional = true }
octosense-llm-service = { workspace = true, optional = true }
octosense-toolbox = { workspace = true, optional = true }
tokio = { workspace = true, optional = true, features = ["rt", "sync", "macros"] }

# Native mobile builds always carry the kernel (Android bundles liboctos.so,
# OpenHarmony links the core in-process, iOS runs none) and, when the `llm`
Expand Down
58 changes: 55 additions & 3 deletions crates/ai-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,63 @@ QrImport` (`platform()` or `paste_only()`); `policy: Policy`
(`Policy::shipped()` grants Rinx the `octos.*` services).

Features: `octos-core` (the kernel, app-peers broker, llm restart; native
mobile targets always have it — `cfg(kernel)`, set by build.rs) and `llm`
(register the `llm` service; a shell's `app-hub` turns it on).
mobile targets always have it — `cfg(kernel)`, set by build.rs), `llm`
(register the `llm` service; a shell's `app-hub` turns it on) and
`toolbox-peers` (below; off by default, turned on by the shell's feature of
the same name).

## The system toolbox for app agents (`toolbox-peers`)

ADR 0002 section 6 and ADR 0004 section 12. The toolbox is one more owner of
host-routed tools in the shell's host-tool relay (octos#2567's shell side,
`crates/shell/src/host_tools/`): main's broker registers them after every
`peer/prepare` and reconnect (`generic_tools` omitted), and the relay
authorizes each call and routes it to the toolbox's executor. This crate adds
only the toolbox's part (`src/toolbox_peers.rs`, over `crates/toolbox`'s
`peer` module):

| Declared and granted | Offered (risk), each `app: "toolbox"` |
| --- | --- |
| neither | nothing |
| `research` | `workflow.run` (read), `workflow.fork` (act), `toolbox.search` (read), `toolbox.web_read` (read) |
| `crawl`, with `max_depth` and `max_pages` above 0 in the scope | `toolbox.deep_crawl` (read) |

- `catalog()`: every toolbox tool, `shareable`, owned by `toolbox`; the relay
declares it once and grants each app its `ToolboxGrant::tools()`.
- `ToolboxGrant`: what the app declares AND the person granted
(`ToolboxGrant::new(app, declared, granted, scope)`). A native module's
declared capabilities are reviewed with the shell (`for_module`). A script
app's manifest (`for_manifest`: `research`/`crawl` in `capabilities`, the
scope in octos's `Scope` shape under the top-level `research` object, App
Hub #26's shape) is, **temporarily**, granted only to system apps (`os.*`)
until the shells' App Hub pin includes #26 and the host reads its verified
grant.
- `ToolboxExecutor`: the relay's executor for the `toolbox` owner. It checks
the calling app's grant again (a forged `toolbox.deep_crawl` is
`not_granted`), runs the call with the app's `AppContext` (id, grants,
octos `Scope`) on a worker thread per app, answers once, and never answers
a cancelled call. Template model calls go through the `model` service's
`ModelHost::complete`: the person's providers and the app's daily budget,
in the same ledger as `model.complete`.
- Consent (the #120 first-use sheet) is the relay's: no toolbox tool is
offered to an app, or run for it, before the person allowed its agent.

Nothing else is held back: octos's own generic tools (`deep_research` among
them) are the kernel's, and which of them a peer gets is its `generic_tools`
list, which the broker does not set.

Results are written to the host-owned `<apps root>/.host/toolbox/<app id>`
(`toolbox_folder`, always compiled; run results under
`toolbox/runs/<template>/<run>.json`, research items under `research/`),
outside the app's jail, where the glance screen's `sys.digest` (OctoSense
#87) reads them.

Tests: `cargo test -p octosense-ai-host --features octos-core,llm` (and
without features for a kernel-less desktop). The module-host tests that
without features for a kernel-less desktop); `--features toolbox-peers` adds
the toolbox's grants and executor through the broker against a scripted
kernel with the toolbox's fixture backends, and, when
`OCTOS_APP_PEERS_TEST_KERNEL` names an `octos` binary at the pinned revision,
`tests/toolbox_real_kernel.rs` against the real kernel. The module-host tests that
create real instances (Rinx included) live with each shell's
`module_host.rs`.

Expand Down
33 changes: 33 additions & 0 deletions crates/ai-host/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,17 @@
//! runner hosts that declare `octos.*` get their own peer (`card.<app id>`)
//! under the same contract, while [`Policy::contained_apps`] is on. Tool
//! approvals are declined until the Card runner has an approval sheet.
//! - **The system toolbox for app agents** (feature `toolbox-peers`, off by
//! default): the toolbox's grants and the executor the shell's host-tool
//! relay runs its calls on ([`toolbox_peers`]). Its results live in each
//! app's host-owned toolbox folder ([`toolbox_folder`]), which exists with
//! or without the feature.

mod bridge;
pub mod contained;
mod qr;
#[cfg(feature = "toolbox-peers")]
pub mod toolbox_peers;

pub use bridge::{Bridge, Done};
pub use qr::{ImageSource, PickError, QrImport, DROP_APP};
Expand Down Expand Up @@ -394,6 +401,32 @@ pub fn kernel_running() -> bool {
false
}

// ---- the system toolbox's folders ------------------------------------------

/// The host's folder for every app's toolbox, under the apps root:
/// `<apps root>/.host/toolbox`. Outside every app's jail
/// (`<apps root>/<app id>`), so an app cannot write a digest the glance
/// screen would show as the host's.
pub const TOOLBOX_HOST_DIR: [&str; 2] = [".host", "toolbox"];

/// `<apps root>/.host/toolbox` ([`TOOLBOX_HOST_DIR`]). The glance screen's
/// `sys.digest` resolver (OctoSense #87) reads run results under it.
pub fn toolbox_root(apps_root: &std::path::Path) -> PathBuf {
TOOLBOX_HOST_DIR.iter().fold(apps_root.to_path_buf(), |dir, part| dir.join(part))
}

/// One app's toolbox folder, `<apps root>/.host/toolbox/<app id>`: run
/// results in `toolbox/runs/<template>/<run>.json`, forks in
/// `toolbox/templates/`, research items in `research/`. `None` for an id
/// that is not one safe path segment.
pub fn toolbox_folder(apps_root: &std::path::Path, app_id: &str) -> Option<PathBuf> {
let valid = !app_id.is_empty()
&& app_id.len() <= 64
&& !app_id.starts_with('.')
&& app_id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
valid.then(|| toolbox_root(apps_root).join(app_id))
}

// ---- apps' assistant access -------------------------------------------------

/// The host policy in force: [`Policy::shipped`] until [`start`] installs
Expand Down
Loading
Loading