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
15 changes: 7 additions & 8 deletions crates/ai-host/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,9 @@ impl Policy {
/// What a shell tells [`start`].
#[derive(Clone, Debug)]
pub struct Host {
/// The app's data dir (`cx.get_data_dir()`): a phone's kernel home lives
/// under it.
/// The app's data dir (`cx.get_data_dir()` on a phone, OctoSense's state
/// dir on a desktop): the kernel's octos home lives under it, never the
/// person's own `~/octos-home`.
pub data_dir: Option<String>,
pub kernel: KernelSource,
pub qr_import: QrImport,
Expand Down Expand Up @@ -262,8 +263,8 @@ fn configure_kernel(_host: &Host) -> (bool, Result<(), String>) {
}

/// The kernel's octos home, where the `llm` service writes: the kernel's core
/// dir (`$OCTOS_APP_CORE_DIR`, else on a phone `<data dir>/octos-home/.octos`,
/// else `$HOME/octos-home/.octos`).
/// dir (`$OCTOS_APP_CORE_DIR`, else `<data dir>/octos-home/.octos`, OctoSense's
/// own, else `$HOME/octos-home/.octos`).
pub fn core_dir(data_dir: Option<String>) -> Option<PathBuf> {
#[cfg(kernel)]
{
Expand All @@ -277,10 +278,8 @@ pub fn core_dir(data_dir: Option<String>) -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("OCTOS_APP_CORE_DIR").filter(|v| !v.is_empty()) {
return Some(PathBuf::from(dir));
}
if cfg!(any(target_os = "android", target_env = "ohos")) {
if let Some(dir) = data_dir.filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir).join("octos-home").join(".octos"));
}
if let Some(dir) = data_dir.filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir).join("octos-home").join(".octos"));
}
octosense_llm_config::profile::default_core_dir()
}
Expand Down
14 changes: 8 additions & 6 deletions crates/ai-host/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,18 @@ fn the_shipped_policy_grants_rinx_the_assistant() {
}

#[test]
fn a_phone_core_dir_is_the_kernels_octos_home() {
fn the_core_dir_is_octosenses_own_octos_home_under_its_data_dir() {
if std::env::var_os("OCTOS_APP_CORE_DIR").is_some() {
return;
}
let dir = core_dir(Some("/data/user/0/app/files".into()));
if cfg!(any(target_os = "android", target_env = "ohos")) {
assert_eq!(dir, Some(PathBuf::from("/data/user/0/app/files/octos-home/.octos")));
} else {
assert_eq!(dir, octosense_llm_config::profile::default_core_dir());
}
// With the kernel service, the kernel's (configured) core dir.
#[cfg(kernel)]
assert_eq!(dir, octosense_kernel::core_dir());
// Without it, every platform: `<data dir>/octos-home/.octos`, never
// `~/octos-home`.
#[cfg(not(kernel))]
assert_eq!(dir, Some(PathBuf::from("/data/user/0/app/files/octos-home/.octos")));
}

#[test]
Expand Down
18 changes: 14 additions & 4 deletions crates/app-peers/tests/fixtures/mock_agent_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
"QUESTION_ME" ("TELL_PEER_HOLD:<slug>": "QUESTION_HOLD", never answered);
- "TELL_PEER_AGAIN:<slug>": send the peer "SECOND_INPUT";
- "TELL_PEER_SUDO:<slug>": send the peer "RUN_SUDO", on which the peer runs
a shell command that needs the person's approval;
- "APPROVE_PEER:<slug>": try to approve the peer's pending tool approval
with peer_respond (the kernel must refuse it for a host-owned peer);
a shell command when it is offered octos's `shell` (the kernel profile's
policy denies it: ADR 0004 §12), else says "NO SHELL OFFERED";
- "APPROVE_PEER:<slug>": try to approve a peer's tool with peer_respond;
- a user text "QUESTION_ME" with ask_user_question offered: ask one question;
- a message naming a waiting peer with peer_respond offered: answer "42";
- otherwise echo.
Prints its port, then serves until killed. Logs each decision to stderr.
Prints its port, then serves until killed. Logs each decision to stderr and,
with MOCK_LLM_TOOLS_LOG set, appends {"user": <last user text>, "tools":
[<offered tool names>]} per request to that file.
"""
import itertools
import json
Expand Down Expand Up @@ -61,6 +63,8 @@ def decide(body):
return {"tool": "peer_send_input", "args": {"slug": sudo.group(1), "message": "RUN_SUDO"}}
if "RUN_SUDO" in last_user and "shell" in tools:
return {"tool": "shell", "args": {"command": "rm -rf ./approval-probe && echo APPROVED_RAN"}}
if "RUN_SUDO" in last_user:
return {"text": "NO SHELL OFFERED"}
approve = re.search(r"APPROVE_PEER:([a-z0-9-]+)", last_user)
if approve and "peer_respond" in tools:
return {"tool": "peer_respond", "args": {"slug": approve.group(1), "decision": "approve"}}
Expand All @@ -86,6 +90,12 @@ def log_message(self, *args):
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("content-length", "0"))) or b"{}")
decision = decide(body)
log = os.environ.get("MOCK_LLM_TOOLS_LOG")
if log:
user = next((text_of(m) for m in reversed(body.get("messages", [])) if m.get("role") == "user"), "")
tools = [t.get("function", {}).get("name") for t in body.get("tools", []) or []]
with open(log, "a") as f:
f.write(json.dumps({"user": user, "tools": tools}) + "\n")
last = (body.get("messages") or [{}])[-1]
print("decision:", json.dumps(decision), "after:", text_of(last)[:300].replace("\n", " "), file=sys.stderr, flush=True)
if "tool" in decision:
Expand Down
13 changes: 11 additions & 2 deletions crates/app-peers/tests/fixtures/mock_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
"""A scripted OpenAI-compatible model for kernel tests. No network, no keys.

POST /v1/chat/completions answers "ECHO: <last user text>". A user text that
contains SLOW waits 20 s first (for revocation tests). Prints its port on the
first line of stdout, then serves until killed. Standard library only.
contains SLOW waits 20 s first (for revocation tests). With
MOCK_LLM_TOOLS_LOG set, each request appends one JSON line {"user": <last user
text>, "tools": [<offered tool names>]} to that file (what a turn was offered).
Prints its port on the first line of stdout, then serves until killed.
Standard library only.
"""
import json
import os
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
Expand Down Expand Up @@ -35,6 +39,11 @@ def do_POST(self):
length = int(self.headers.get("content-length", "0"))
body = json.loads(self.rfile.read(length) or b"{}")
text = last_user_text(body)
log = os.environ.get("MOCK_LLM_TOOLS_LOG")
if log:
tools = [t.get("function", {}).get("name") for t in body.get("tools", []) or []]
with open(log, "a") as f:
f.write(json.dumps({"user": text, "tools": tools}) + "\n")
if "SLOW" in text:
time.sleep(20)
reply = "ECHO: " + text.strip().splitlines()[-1] if text.strip() else "ECHO:"
Expand Down
105 changes: 54 additions & 51 deletions crates/app-peers/tests/real_kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,13 +304,20 @@ fn an_account_change_drops_a_late_reply_and_resume_keeps_the_peer_across_restart

/// Criterion 3 of ADR 0007 with a scripted model: the system agent sends the
/// app peer input, the peer asks a question, the kernel wakes the system
/// agent, which answers, and the peer continues with the answer.
/// agent, which answers, and the peer continues with the answer. Every model
/// request on the way (the host's turn, the kernel's wake continuation, the
/// peer's turns) is offered none of octos's shell (ADR 0004 §12: command
/// execution is a granted host tool, never octos's `shell`).
#[test]
fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() {
let Some(program) = kernel() else { return };
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mock_agent_llm.py");
let dir = temp("qa");
std::fs::create_dir_all(&dir).unwrap();
let offered_log = dir.join("offered.jsonl");
let mut child = std::process::Command::new("python3")
.arg(script)
.env("MOCK_LLM_TOOLS_LOG", &offered_log)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.spawn()
Expand All @@ -320,7 +327,6 @@ fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() {
.read_line(&mut line)
.unwrap();
let model = Model(child, line.trim().parse().unwrap());
let dir = temp("qa");
let core_dir = dir.join("octos-home/.octos");
write_profile(&core_dir, model.1);
let core = Core::new(Options::default().core_dir(&core_dir).program(&program));
Expand Down Expand Up @@ -358,6 +364,27 @@ fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() {
text.contains("PEER GOT 42"),
"the peer continued with the system agent's answer: {text}"
);
let requests: Vec<Value> = std::fs::read_to_string(&offered_log)
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
for request in &requests {
for tool in request["tools"].as_array().unwrap() {
let tool = tool.as_str().unwrap();
assert!(
!["shell", "bash", "exec_command", "write_stdin"].contains(&tool),
"{tool} offered: {request}"
);
}
}
assert!(
requests.iter().any(|r| {
!r["user"].as_str().unwrap().contains("TELL_PEER")
&& r["tools"].as_array().unwrap().iter().any(|t| t == "peer_respond")
}),
"the system agent's wake continuation was seen and checked"
);

rinx.release();
drop(rinx);
Expand Down Expand Up @@ -553,13 +580,19 @@ fn a_second_input_to_an_answered_peer_runs() {
let _ = std::fs::remove_dir_all(&dir);
}

/// ADR 0007: a host-owned app peer's tool approval is the person's, answered
/// in the app. The system agent hands the peer work that needs an approval;
/// the kernel must not wake the system agent for it, and when the system
/// agent tries to approve it anyway with `peer_respond` the kernel refuses.
/// The approval stays pending until the person answers it through the host.
/// ADR 0004 §12 (was ADR 0007's approval test): octos's own `shell` is
/// offered to no `_main` session (the kernel profile's policy, written by
/// `octosense-kernel` at every start, denies it and nothing else), so the system agent cannot
/// get a command run through an app peer by asking or by "approving". The
/// system agent handing the peer a command gets no tool approval parked and
/// nothing run; its `peer_respond` "approval" finds nothing to approve.
/// Command execution an app is granted arrives as a host tool with a live
/// approval (for example `terminal.run`), registered by the shell (plan
/// steps 6 and 7); approvals of app tools return here then. That octos
/// refuses the system agent an app peer's approval is octos's own test
/// (`ui_protocol_tests.rs`, ADR 0007).
#[test]
fn the_system_agent_cannot_approve_an_app_peers_tool() {
fn the_system_agent_cannot_get_a_command_run_through_an_app_peer() {
let Some(program) = kernel() else { return };
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mock_agent_llm.py");
let mut child = std::process::Command::new("python3")
Expand Down Expand Up @@ -612,74 +645,44 @@ fn the_system_agent_cannot_approve_an_app_peers_tool() {
};

start_system_turn(format!("TELL_PEER_SUDO:{slug}"));
let mut approvals = Vec::new();
let mut peer_text = String::new();
for _ in 0..120 {
approvals = pending_approvals();
if !approvals.is_empty() {
peer_text = hydrate(&peer_session, &["messages"]).to_string();
if peer_text.contains("NO SHELL OFFERED") {
break;
}
std::thread::sleep(Duration::from_millis(500));
}
assert_eq!(approvals.len(), 1, "the peer parked on one tool approval");
// Room for a wake to fire, were the kernel to send one.
std::thread::sleep(Duration::from_secs(3));
// A woken system agent acts on the wake (the scripted one reaches for
// peer_respond), which leaves a tool result beyond the peer_send_input one.
let system_text = hydrate(system, &["messages"]).to_string();
assert_eq!(
system_text.matches("\"role\":\"tool\"").count(),
1,
"the system agent was not woken for the app peer's approval: {system_text}"
assert!(
peer_text.contains("NO SHELL OFFERED"),
"the peer ran the system agent's input without a shell tool: {peer_text}"
);
assert!(pending_approvals().is_empty(), "no tool approval parked");

start_system_turn(format!("APPROVE_PEER:{slug}"));
let mut system_text = String::new();
for _ in 0..60 {
system_text = hydrate(system, &["messages"]).to_string();
if system_text.contains("host-owned app peer") {
if system_text.contains("not awaiting input") {
break;
}
std::thread::sleep(Duration::from_millis(500));
}
assert!(
system_text.contains("host-owned app peer"),
"peer_respond refused the system agent's approval: {system_text}"
);
assert_eq!(
pending_approvals().len(),
1,
"the approval is still the person's to answer"
system_text.contains("not awaiting input"),
"peer_respond had nothing to approve: {system_text}"
);
std::thread::sleep(Duration::from_secs(2));
assert!(pending_approvals().is_empty());
assert!(
!hydrate(&peer_session, &["messages"])
.to_string()
.contains("APPROVED_RAN"),
"the command did not run on the system agent's say-so"
"no command ran"
);

// The person approves in the app; the peer's command then runs.
let approval_id = approvals[0]["approval_id"].as_str().unwrap().to_owned();
rinx.host_request(
"approval/respond",
json!({"session_id": peer_session, "approval_id": approval_id, "decision": "approve"}),
)
.expect("the person's approval");
let mut ran = false;
for _ in 0..60 {
if pending_approvals().is_empty()
&& hydrate(&peer_session, &["messages"])
.to_string()
.contains("APPROVED_RAN")
{
ran = true;
break;
}
std::thread::sleep(Duration::from_millis(500));
}

rinx.release();
drop(rinx);
core.shutdown_within(Duration::from_secs(5));
assert!(ran, "the peer ran the command once the person approved it");
let _ = std::fs::remove_dir_all(&dir);
}
36 changes: 35 additions & 1 deletion crates/kernel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ the shared runtime piece the shells, AppCard (`apps/appcard/app`) and the

| | |
|---|---|
| **Core dir** | octos's data dir: `<core_dir>/profiles/_main.json` is the profile the AI providers app writes. Resolved as: the shell's `Options::core_dir`, else `$OCTOS_APP_CORE_DIR`, else on Android/OpenHarmony `<app data dir>/octos-home/.octos` (the app-private octos home AppCard has always used), else `$HOME/octos-home/.octos` (`octosense_llm_config::profile::default_core_dir()`). |
| **Core dir** | octos's data dir: `<core_dir>/profiles/_main.json` is the profile the AI providers app writes. Resolved as: the shell's `Options::core_dir`, else `$OCTOS_APP_CORE_DIR`, else `<app data dir>/octos-home/.octos`: OctoSense's own octos home (the platform's app data dir on a phone, the app-private home AppCard has always used; OctoSense's state dir `~/.octosense` on a desktop), else `$HOME/octos-home/.octos` (`octosense_llm_config::profile::default_core_dir()`, for a consumer that names nothing). A desktop OctoSense used to share `$HOME/octos-home/.octos` with the person's standalone octos; on first use it now copies only the provider and model settings (`llm`, `env_vars`) from there into its own profile, and never writes, moves or deletes anything there. |
| **One kernel, lazily** | The first `connect()` starts it; later ones share it. octos holds a single-writer lock on its data dir, so a second kernel on the same dir could not run anyway. |
| **Shared by frames** | A `Connection` carries UI Protocol (JSON-RPC) frames exactly as `octos serve --stdio` speaks them. Each consumer uses its own request ids and receives the replies to its requests and the notifications of the sessions it named (a notification for a session nobody named goes to every consumer). |
| **Restart** | `restart()` stops a running kernel (a no-op when none runs). Connections then end with `CloseReason::Restarted`; a consumer connects again, which starts a fresh kernel that reads the new profile. The next kernel starts only after the old one has exited and released its data dir. |
Expand Down Expand Up @@ -155,6 +155,40 @@ scoped session agree. OpenHarmony (embedded core) and iOS (no kernel) have no
Talk to Octos. See [ADR 0003](../../docs/adr/0003-shared-octos-client-access.md)
for the threat model.

## The system agent's tools

[ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12: the
system agent's tool set is its grants. Its default octos tools are
`system_tools::SYSTEM_AGENT_TOOLS`: supervision (`peer_send_input`,
`peer_gather`, `peer_list`, `peer_respond`, `peer_close`), its workspace's
file tools (octos fences them to the session's working directory), memory,
`ask_user_question`, media viewing, octos's `web_search` / `web_fetch` (until
toolbox grants replace them, #108) and `tool_search`. Granted toolbox and
cross-app tools, and command execution when the person turns it on in
Settings (off by default), join it as host tools through `SystemAgentTools`;
command execution is a host tool with a live approval (`terminal.run`).

**What the kernel enforces today.** octos has no tool list the host can set
for one session, so every start writes the `_main` profile's `tool_policy`
(`system_tools::tool_policy`): everything a grant can give, minus octos's own
shell (`group:runtime`: `shell`, `bash`, `exec_command`, `write_stdin`), the
one tool OctoSense never offers, since §12 grants command execution only as
a host tool. octos applies it to every turn of the profile, wake
continuations included. So:

- **§12's "exactly its grants" is not yet enforced for the system agent**:
it gets no octos shell, but otherwise is bounded by the grantable ceiling,
not its list, until octos#2567 adds session-targeted registration and tool
lists (reviewer item M1; our item 5). The exact-list real-kernel test is
ignored until then;
- app peers are narrowed to their grants by their turns' `generic_tools`
(plan step 6);
- Talk to Octos external turns keep octos's own allowlist.

The policy is written only into OctoSense's own core dir, and only over a
policy OctoSense wrote (`"owner": "octosense"`): a foreign policy, or the
person's own `$HOME/octos-home/.octos`, is refused with a warning.

## Testing

From the repository root:
Expand Down
Loading