1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
//! Construction of a [`DaemonState`] outside the CLI binary.
//!
//! Step 3 of the embedded-daemon refactor: the embedder (a GUI process) must
//! be able to open daemon state — database, accounts, tool registry — without
//! the CLI's `anyhow` chain or its global-path assumptions. Everything here is
//! the exact code the CLI used to inline (see `cli.rs`), lifted behind
//! [`OpenOptions`] so the paths and the tool policy become explicit
//! parameters. The CLI delegates to this constructor with the standard paths
//! and [`ToolPolicy::Full`], so its behavior is unchanged.
use crate::accounts::AccountManager;
use crate::catalog::CatalogPaths;
use crate::daemon::DaemonState;
use crate::db;
use crate::mcp::McpManager;
use crate::tools::ios_bridge::IosToolBridge;
use crate::tools::{ToolPolicy, ToolRegistry};
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc;
use tracing::{info, warn};
/// Parameters for [`DaemonState::open`]. Explicit paths (not global helpers)
/// so an embedder can point the daemon at its own sandbox (app-container
/// directories, test tempdirs) without environment-variable overrides.
pub struct OpenOptions {
/// Database file path (`state.redb`). Created and migrated if needed.
pub db_path: PathBuf,
/// `accounts.toml` path. Missing file = empty account set (same tolerant
/// policy as the CLI).
pub accounts_path: PathBuf,
/// Catalog cache bin + user overlay locations (see `crate::catalog`).
pub catalog_paths: CatalogPaths,
/// Which tool groups to register (see [`ToolPolicy`]).
pub tool_policy: ToolPolicy,
/// Tool-loop iteration limit (0 = unlimited). The CLI resolves this from
/// env/config; an embedder decides it directly.
pub max_turns: u32,
/// Optional bridge to the host's platform-native tools (clipboard,
/// open_url, notify on iOS). When `Some`, the `ios` tool group is
/// registered and PROTECTED (always active, unloadable by no one); the
/// bridge's PRESENCE is the gate — a desktop embedder passes `None` and
/// the group simply never exists. No `cfg` here on purpose: that keeps
/// the registration testable on every platform.
pub platform_tool_bridge: Option<Arc<dyn IosToolBridge>>,
}
impl DaemonState {
/// Open daemon state from explicit paths: database (with the same
/// Windows-safe open → version → drop → backup → reopen → migrate
/// sequence the CLI ran), tombstone purge, session index load, accounts,
/// and the tool registry under `tool_policy`.
///
/// Returns `io::Result` (not anyhow) so an embedder without the CLI's
/// error-reporting stack can consume it directly; every failure carries
/// its stage in the message.
pub fn open(opts: OpenOptions) -> io::Result<Self> {
// Windows-safe startup sequence (verbatim from the CLI): redb holds a
// whole-file exclusive lock on the database for as long as a
// `redb::Database` handle is open, and on Windows that lock blocks
// even same-process reads of the file (os error 33). The pre-migration
// backup therefore must be taken BEFORE the file is opened/locked.
// Sequence: open → read the schema version → drop the handle
// (releasing the lock) → if a real migration is pending
// (migration_backup_version returns Some exactly when run_migrations_at
// would back up), copy the backup from the unlocked file → reopen →
// migrate. The in-runner backup step is a no-op then (skip-if-exists).
let db = Arc::new({
let opened = db::open_db_at(&opts.db_path)?;
match db::migration_backup_version(&opened)? {
Some(version) => {
// Release redb's whole-file exclusive lock before the
// copy — reading the open file is exactly what fails on
// Windows.
drop(opened);
db::backup_database(&opts.db_path, version)?;
db::open_db_at(&opts.db_path)?
}
// No pending real migration (fresh DB, up-to-date,
// newer-version refusal, or unversioned): keep the original
// handle.
None => opened,
}
});
// Bring the database up to the current schema version before any table
// access (same invariant as the CLI; see db::run_migrations).
db::run_migrations_at(&db, &opts.db_path)?;
// Purge any sessions that were deleted while their still-shutting-down
// thread was alive and re-created the record before the daemon
// crashed. Runs before the session index is loaded so the record never
// surfaces. Warn-and-continue, exactly as the CLI did.
match db::purge_tombstoned_sessions(&db) {
Ok(n) if n > 0 => warn!(
purged = n,
"purged records left behind by interrupted session deletions"
),
Ok(_) => {}
Err(e) => warn!(error = %e, "failed to purge tombstoned sessions; continuing"),
}
let mut session_metadata = HashMap::new();
match db::read_all_sessions(&db) {
Ok(sessions) => {
for (id, record) in sessions {
session_metadata.insert(id, record.into());
}
}
Err(e) => {
warn!("failed to load sessions from database: {e}");
}
}
info!(
count = session_metadata.len(),
"loaded sessions from database"
);
// Load accounts (may be empty — unlock will reload them). Missing
// config path/file = empty manager, same tolerant policy as the CLI.
let accounts = AccountManager::load(&opts.accounts_path).unwrap_or_else(|e| {
warn!(error = %e, "failed to load accounts; starting empty");
AccountManager::empty()
});
// Build the tool registry under the requested policy, and initialize
// MCP servers only under `Full` — a `Mobile` daemon must not spawn
// subprocesses at all, so the manager stays empty (registration-time
// filtering; see ToolPolicy).
let mut tool_registry = ToolRegistry::new_for_policy(opts.tool_policy);
// Register the platform tools (if a bridge was supplied) BEFORE
// `build_for_policy`: `register_platform_tools` needs `&mut self`,
// and `build_for_policy` consumes the registry into the shared `Arc`
// (its `Arc::new_cyclic` closure keeps the `protected_groups` field
// alive — it moves with the value).
if let Some(bridge) = opts.platform_tool_bridge {
tool_registry.register_platform_tools(bridge);
}
let mcp_manager = if opts.tool_policy == ToolPolicy::Full {
McpManager::from_config(&mut tool_registry)
} else {
info!("tool policy Mobile: MCP servers not spawned");
McpManager::empty()
};
let tool_registry = tool_registry.build_for_policy(opts.tool_policy);
Ok(DaemonState {
// Placeholder command channel: `start_daemon_core` overwrites
// `daemon_tx` with the real command-loop channel before any
// consumer exists, so the dropped receiver here never matters.
// std `mpsc` deliberately: this must match the pre-existing
// `DaemonState::daemon_tx` field type (the convention converts
// existing std channels opportunistically only).
daemon_tx: mpsc::channel().0,
// Derive the next session ID from the highest existing record so a
// fresh daemon never collides with a persisted session.
next_session_id: session_metadata
.keys()
.max()
.copied()
.map(|m| m + 1)
.unwrap_or(1),
max_turns: opts.max_turns,
active_sessions: HashMap::new(),
session_metadata,
deleted_sessions: HashSet::new(),
children: HashMap::new(),
accounts,
// Daemon-owned registry for NON-session provider clients (model
// prefetch, catalog maintenance) — never individually cancelled.
// Sessions create their OWN registries at spawn time.
daemon_registry: choreo_ai_protocols::SocketRegistry::default(),
session_registries: HashMap::new(),
credentials: HashMap::new(),
x_credentials: None,
// The daemon starts locked: credentials are only decrypted into
// memory once a client presents the valid unlock key.
locked: true,
db,
tool_registry,
summary_subscribers: HashMap::new(),
client_writers: HashMap::new(),
activity_subscribers: HashMap::new(),
client_subscribed_sessions: HashMap::new(),
// One daemon-wide lag counter shared by every connection's sink
// and every session thread (see `broadcast::SubscriberSink`).
global_lag: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
lag_limits: crate::broadcast::LagLimits::default(),
model_cache: HashMap::new(),
model_prefetch_in_flight: HashSet::new(),
mcp_manager,
// Populated by `start_daemon_core`, which spawns the maintenance
// thread (it needs the real command-loop channel).
maintenance_tx: None,
// Installed by `start_daemon_core` from its `CoreOptions.acl`
// (the command loop needs the same Arc the accept paths read).
acl: None,
catalog_paths: opts.catalog_paths,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The four platform tools (shared by the registration test's two
/// assertion loops).
const IOS_TOOLS: [&str; 4] = ["clipboard_write", "clipboard_read", "open_url", "notify"];
/// A full-policy state opens on explicit temp paths: DB migrated, session
/// index empty but valid, and the shell tools present (the policy default
/// must not restrict the CLI-like case).
#[test]
fn open_full_policy_on_temp_paths() {
let dir = tempfile::tempdir().unwrap();
let state = DaemonState::open(OpenOptions {
db_path: dir.path().join("state.redb"),
accounts_path: dir.path().join("accounts.toml"),
catalog_paths: CatalogPaths {
bin: dir.path().join("catalog.bin"),
overlay: dir.path().join("models-overlay.toml"),
},
tool_policy: ToolPolicy::Full,
max_turns: 0,
platform_tool_bridge: None,
})
.unwrap();
assert_eq!(state.next_session_id, 1);
assert!(state.locked, "a fresh daemon starts locked");
let active: HashSet<String> = ["shell".into()].into_iter().collect();
let defs = state.tool_registry.available_definitions(&active);
assert!(
defs.iter().any(|d| d.function.name == "sh"),
"Full policy must register the shell tools"
);
}
/// The Mobile policy filters at registration time: no `sh`, no `exec`, no
/// `run_riscv` — and no MCP manager contents. Unregistered tools cannot
/// be activated later by any client message.
#[test]
fn open_mobile_policy_omits_shell_and_vm() {
let dir = tempfile::tempdir().unwrap();
let state = DaemonState::open(OpenOptions {
db_path: dir.path().join("state.redb"),
accounts_path: dir.path().join("accounts.toml"),
catalog_paths: CatalogPaths::default(),
tool_policy: ToolPolicy::Mobile,
max_turns: 0,
platform_tool_bridge: None,
})
.unwrap();
let all: HashSet<String> = state
.tool_registry
.group_names()
.into_iter()
.chain(["core".to_string()])
.collect();
let defs = state.tool_registry.available_definitions(&all);
let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect();
for absent in ["sh", "exec", "run_riscv"] {
assert!(
!names.contains(&absent),
"Mobile policy must not register {absent}: {names:?}"
);
}
}
/// Supplying a `platform_tool_bridge` registers the four iOS tools as a
/// PROTECTED group even on a non-iOS build: the registration is
/// bridge-gated, not platform-gated. They are available WITHOUT "ios"
/// being in the caller-supplied active set (the protected-groups union
/// rule), excluded from `group_names()` (no load/unload schema slot),
/// and an unload_tools("ios") style request is impossible through the
/// schema — the group is simply always active.
#[test]
fn open_with_platform_bridge_registers_protected_ios_group() {
let dir = tempfile::tempdir().unwrap();
let state = DaemonState::open(OpenOptions {
db_path: dir.path().join("state.redb"),
accounts_path: dir.path().join("accounts.toml"),
catalog_paths: CatalogPaths::default(),
tool_policy: ToolPolicy::Mobile,
max_turns: 0,
platform_tool_bridge: Some(std::sync::Arc::new(
crate::tools::ios_bridge::MockBridge::default(),
)),
})
.unwrap();
// Available WITHOUT "ios" in the caller-supplied active set.
let active: HashSet<String> = HashSet::new();
let defs = state.tool_registry.available_definitions(&active);
let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect();
for tool in IOS_TOOLS {
assert!(names.contains(&tool), "missing {tool}: {names:?}");
}
// "ios" is protected: excluded from the load/unload schema enum and
// present in the protected set.
assert!(
!state.tool_registry.group_names().iter().any(|g| g == "ios"),
"protected groups must not appear in group_names()"
);
assert!(
state.tool_registry.protected_groups().contains("ios"),
"ios must be marked protected"
);
// Direct-only callers (exfiltration-chain mitigation) — checked on
// the four ios tools specifically (the union also surfaces core
// tools, which keep their own caller policy).
let resp_defs = state
.tool_registry
.available_definitions_for_responses(&active);
for def in resp_defs
.iter()
.filter(|d| IOS_TOOLS.contains(&d.function.name.as_str()))
{
assert_eq!(
def.function.allowed_callers.as_deref(),
Some(&[choreo_ai_protocols::openai::AllowedCaller::Direct][..]),
"{} must be Direct-only",
def.function.name
);
}
}
}