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