onlyne_client/ops/
init.rs1use anyhow::{Result, anyhow};
2use onlyne_config::Spec;
3use onlyne_config::layout::{RoleWorkspace, ServerRoot, detect_legacy};
4use onlyne_net::KeyPair;
5use std::path::{Path, PathBuf};
6
7const PLACEMENT_COMMENTS: &str = "\
17# Where this machine displays the role's runtime: orca | zellij |
18# headless | external. An absent value probes orca, zellij in that order
19# and falls back to headless; a nonempty ONLYNE_BACKEND wins over this key.
20# placement = \"headless\"
21";
22
23const ACP_COMMENTS: &str = "\
27# ACP options, read only when the role's `[client.runtime] drive` is `acp`.
28# `mode`, `model`,
29# and `reasoning_effort` name the agent's own configuration values: the agent
30# validates them, and an empty one keeps the agent's default. `permission` is
31# this machine's answer to a permission request from the agent: `deny` (the
32# default, it refuses and records a fault) or `allow`.
33# [acp]
34# mode = \"\"
35# model = \"\"
36# reasoning_effort = \"\"
37# permission = \"deny\"
38";
39
40#[derive(Debug, Clone)]
41pub struct InitArgs {
42 pub workspace: PathBuf,
43 pub role: String,
44 pub server_root: PathBuf,
45 pub prose: String,
47}
48
49#[derive(Debug)]
56pub struct LegacyWorkspace;
57
58impl std::fmt::Display for LegacyWorkspace {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.write_str("legacy workspace")
61 }
62}
63
64impl std::error::Error for LegacyWorkspace {}
65
66const RECONNECT_COMMENTS: &str = "\
71# Seconds a dropped plugin connection may stay away before this client retires
72# the session it left behind. A session with a task bound goes with it: an
73# unsettled task ends `failed`, the delivery that session still held is refused
74# with reason `session_dead`, and the session's own exit is published. An agent
75# that reconnects inside the window keeps its session; a connection that returns
76# after a newer session took the task is held read-only, and what it sends rides
77# that session's closing handoff. 0 disables the sweep.
78# reconnect_grace_secs = 60
79";
80
81fn role_key(path: &Path) -> Result<KeyPair> {
88 if path.exists() {
89 return KeyPair::load(path).map_err(|error| anyhow!(error.to_string()));
90 }
91 let key = KeyPair::generate();
92 key.save(path).map_err(|error| anyhow!(error.to_string()))?;
93 Ok(key)
94}
95
96fn server_section(root: &Path) -> Result<(String, String, String)> {
97 let layout = ServerRoot::resolve(root);
98 let spec = Spec::load(layout.spec_path())?;
99 Ok((spec.server.listen, spec.server.cert_pin, spec.server.name))
100}
101
102pub async fn init(args: InitArgs) -> Result<String> {
103 if let Some(reason) = detect_legacy(&args.workspace) {
104 eprintln!(
105 "{}",
106 onlyne_proto::legacy_workspace_message(&[reason.to_string()])
107 );
108 return Err(anyhow::Error::new(LegacyWorkspace));
109 }
110 let workspace = RoleWorkspace::resolve(&args.workspace);
111 workspace.bootstrap()?;
112 let key = role_key(&workspace.key_path())?;
113 let (listen, cert_pin, _server_name) = server_section(&args.server_root)?;
114 let (host, port) = listen
115 .rsplit_once(':')
116 .map(|(h, p)| (h.to_string(), p.parse::<u16>().unwrap_or(0)))
117 .unwrap_or((listen, 0));
118 let config = format!(
119 "role = {role:?}\ncert_pin = {pin:?}\nkey_path = {key_path:?}\nplugins = []\n\n{PLACEMENT_COMMENTS}\n{RECONNECT_COMMENTS}\n[server]\nhost = {host:?}\nport = {port}\n\n{ACP_COMMENTS}",
120 role = args.role,
121 pin = cert_pin,
122 key_path = workspace.key_path().display().to_string(),
123 host = host,
124 port = port,
125 );
126 std::fs::write(workspace.config_path(), config)?;
127 Ok(fragment(&args.role, &key.public_str(), &args.prose))
128}
129
130const SEED_RUNTIME: &str = "\
136[client.runtime]\n\
137drive = \"plugin\"\n\
138command = [\"pi\", \"--session-id\", \"{session}\", \"--session-dir\", \".pi/sessions\", \"-ns\"]";
139
140pub fn fragment(role: &str, public_key: &str, prose: &str) -> String {
150 let role = toml_string(role);
151 let key = toml_string(public_key);
152 let prose = toml_string(prose);
153 format!(
154 "[[client]]\nrole = {role}\nkey = {key}\nadmin = false\nmax_sessions = 1\nallowed_senders = [\"*\", {role}]\nallowed_targets = [{role}]\nprose = {prose}\n{KNOB_COMMENTS}\n{runtime}\n",
155 runtime = SEED_RUNTIME,
156 )
157}
158
159const KNOB_COMMENTS: &str = "\
165# timeout = { ready_ms = 30000, idle_ms = 60000 }
166# Per-session budgets in milliseconds; the server projects them into the hello
167# reply as `timeout_ready_ms` and `timeout_idle_ms`.
168# intent = { attempts = 3, backoff_ms = [1000, 2000, 4000] }
169# Retry policy for one intent: total attempts, then the per-retry waits in
170# milliseconds; a longer list repeats its last entry.
171# aggregate = \"\"
172# The child-cluster name this role stands for. It is an annotation only: no
173# delivery decision reads it, and a plain role leaves it empty.
174# The [client.runtime] table below is the drive and the argv a session runs.
175# `drive` names how the client talks to the runtime: plugin (the default)
176# starts it and lets its plugin dial back, acp runs the agent as a child over
177# stdio (placement headless only), and exec runs the command and reads its exit
178# code. `command` is the argv, with {session} and {task} substituted; any other
179# brace is refused.
180";
181
182pub fn toml_string(text: &str) -> String {
184 let mut out = String::with_capacity(text.len() + 2);
185 out.push('"');
186 for character in text.chars() {
187 match character {
188 '"' => out.push_str("\\\""),
189 '\\' => out.push_str("\\\\"),
190 '\n' => out.push_str("\\n"),
191 '\r' => out.push_str("\\r"),
192 '\t' => out.push_str("\\t"),
193 other => out.push(other),
194 }
195 }
196 out.push('"');
197 out
198}
199
200pub fn legacy_error_code() -> i32 {
201 2
202}