Line data Source code
1 : //! The configuration of the agent server, ~/.dashql/agent/config.json
2 :
3 : use serde_json::{json, Map, Value};
4 : use std::path::{Path, PathBuf};
5 : use std::time::Duration;
6 :
7 : /// The default port of the agent server
8 : pub const DEFAULT_PORT: u16 = 47100;
9 :
10 : /// The origins of DashQL clients: the web app and desktop windows
11 : const RELEASE_ORIGINS: [&str; 3] = ["https://dashql.app", "https://stable.dashql.app", "app://bundle"];
12 :
13 : /// The origins of the dev server of the web app. Only development builds accept them: in a release, any page on
14 : /// that port, e.g. the dev server of another project, would be a DashQL client. A release that should accept them
15 : /// lists them in `origins` of config.json.
16 : const DEV_ORIGINS: [&str; 2] = ["http://localhost:9002", "http://127.0.0.1:9002"];
17 :
18 : /// The origins of DashQL clients that this build accepts, release builds (`--config=release`) set `dashql_release`
19 28 : pub fn client_origins() -> Vec<&'static str> {
20 28 : let dev: &[&str] = if cfg!(dashql_release) { &[] } else { &DEV_ORIGINS };
21 28 : RELEASE_ORIGINS.iter().chain(dev).copied().collect()
22 28 : }
23 :
24 : /// How long a call waits for DashQL to hold its session, unless `callWaitMs` says otherwise
25 : const DEFAULT_CALL_WAIT: Duration = Duration::from_secs(30);
26 :
27 : /// How the server reads the event stream of a harness
28 : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
29 : pub enum HarnessAdapter {
30 : Codex,
31 : Claude,
32 : Opencode,
33 : Text,
34 : }
35 :
36 : impl HarnessAdapter {
37 9 : pub fn name(self) -> &'static str {
38 9 : match self {
39 3 : HarnessAdapter::Codex => "codex",
40 3 : HarnessAdapter::Claude => "claude",
41 3 : HarnessAdapter::Opencode => "opencode",
42 0 : HarnessAdapter::Text => "text",
43 : }
44 9 : }
45 :
46 : /// Unknown adapters read plain text
47 139 : fn parse(name: &str) -> Self {
48 139 : match name {
49 139 : "codex" => HarnessAdapter::Codex,
50 95 : "claude" => HarnessAdapter::Claude,
51 49 : "opencode" => HarnessAdapter::Opencode,
52 3 : _ => HarnessAdapter::Text,
53 : }
54 139 : }
55 : }
56 :
57 : /// A setting of harness runs that requests may pick, the model or the effort
58 : #[derive(Clone, Debug, Default, PartialEq, Eq)]
59 : pub struct HarnessChoice {
60 : /// The values that requests may pick, no request can pass another value
61 : pub values: Vec<String>,
62 : /// The arguments that pass a picked value
63 : pub args: Vec<String>,
64 : }
65 :
66 : /// How the agent server runs a harness headless.
67 : ///
68 : /// Arguments are passed to the process as they are, never through a shell.
69 : /// `{prompt}` is replaced by the prompt, `{resume}` by `resumeArgs` once the DashQL session has a harness
70 : /// session, and is dropped otherwise. `{harnessSession}` in `resumeArgs` is the stored harness session id.
71 : /// `{model}` and `{effort}` are replaced by `modelArgs` and `effortArgs` once a request picks one of `models` or
72 : /// `efforts`, and are dropped otherwise. In `modelArgs` and `effortArgs`, they are the picked value.
73 : #[derive(Clone, Debug, PartialEq, Eq)]
74 : pub struct HarnessConfig {
75 : /// The program and its arguments
76 : pub command: Vec<String>,
77 : /// The arguments that resume a harness session
78 : pub resume_args: Vec<String>,
79 : /// The models that requests may pick
80 : pub model: HarnessChoice,
81 : /// The efforts that requests may pick
82 : pub effort: HarnessChoice,
83 : /// The command that continues the harness session in a terminal, shown by the app
84 : pub terminal_command: Option<Vec<String>>,
85 : /// How the output is read
86 : pub adapter: HarnessAdapter,
87 : }
88 :
89 : /// The configuration of the agent server
90 : #[derive(Clone, Debug)]
91 : pub struct AgentConfig {
92 : /// The port on 127.0.0.1
93 : pub port: u16,
94 : /// The token of the command line tool
95 : pub token: String,
96 : /// The harness that runs requests without a harness name
97 : pub default_harness: String,
98 : /// The harnesses by name, the built-in ones first
99 : pub harnesses: Vec<(String, HarnessConfig)>,
100 : /// Additional origins that the server accepts
101 : pub origins: Vec<String>,
102 : /// How long a call waits for DashQL to hold its session
103 : pub call_wait: Duration,
104 : }
105 :
106 : impl AgentConfig {
107 84 : pub fn harness(&self, name: &str) -> Option<&HarnessConfig> {
108 170 : self.harnesses.iter().find(|(n, _)| n == name).map(|(_, h)| h)
109 84 : }
110 : }
111 :
112 : /// The built-in harnesses. config.json can override them by name and add its own.
113 : /// `--` ends the options before the prompt: Claude Code's tool lists take every following argument, and a prompt
114 : /// starting with `-` would be read as an option.
115 : /// Run against Claude Code 2.1, opencode 1.18 and Codex 0.162.
116 : /// Claude Code denies heredocs with JSON, so its runs may write the inputs of `--file` into the input folder.
117 : /// The models of Claude Code are aliases of its latest models. Codex and opencode name their models by provider,
118 : /// config.json lists the ones to pick.
119 46 : fn default_harnesses() -> Value {
120 46 : json!({
121 46 : "claude": {
122 46 : "command": [
123 : "claude", "-p", "--output-format", "stream-json", "--verbose",
124 : "--allowedTools", "Bash(dashql agent:*)", "Edit(./input/*)",
125 : "--disallowedTools", "Bash(dashql agent serve:*)",
126 : "{model}", "{effort}", "{resume}", "--", "{prompt}",
127 : ],
128 46 : "resumeArgs": ["--resume", "{harnessSession}"],
129 46 : "models": ["fable", "opus", "sonnet", "haiku"],
130 46 : "modelArgs": ["--model", "{model}"],
131 46 : "efforts": ["low", "medium", "high", "xhigh", "max"],
132 46 : "effortArgs": ["--effort", "{effort}"],
133 46 : "terminalCommand": ["claude", "--resume", "{harnessSession}"],
134 46 : "adapter": "claude",
135 : },
136 : // Codex sandboxes the shell of a run without network, so `dashql agent` could not reach the agent server on
137 : // 127.0.0.1. Codex cannot allow only loopback, so runs get the workspace-write sandbox with network access.
138 46 : "codex": {
139 46 : "command": [
140 : "codex", "exec", "--json", "--skip-git-repo-check",
141 : "--sandbox", "workspace-write", "-c", "sandbox_workspace_write.network_access=true",
142 : "{model}", "{effort}", "{resume}", "--", "{prompt}",
143 : ],
144 46 : "resumeArgs": ["resume", "{harnessSession}"],
145 46 : "modelArgs": ["-m", "{model}"],
146 46 : "efforts": ["low", "medium", "high"],
147 46 : "effortArgs": ["-c", "model_reasoning_effort={effort}"],
148 46 : "terminalCommand": ["codex", "resume", "{harnessSession}"],
149 46 : "adapter": "codex",
150 : },
151 : // The permissions of opencode runs come from the opencode.json of the working folder
152 46 : "opencode": {
153 46 : "command": ["opencode", "run", "--format", "json", "{model}", "{resume}", "--", "{prompt}"],
154 46 : "resumeArgs": ["--session", "{harnessSession}"],
155 46 : "modelArgs": ["--model", "{model}"],
156 46 : "terminalCommand": ["opencode", "--session", "{harnessSession}"],
157 46 : "adapter": "opencode",
158 : },
159 : })
160 46 : }
161 :
162 : /// The folder of the configuration and the state of the agent server
163 2 : pub fn agent_home(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
164 2 : if let Some(home) = env("DASHQL_AGENT_HOME").filter(|h| !h.is_empty()) {
165 1 : return home.into();
166 1 : }
167 1 : let user_home = env("HOME").or_else(|| env("USERPROFILE")).unwrap_or_default();
168 1 : PathBuf::from(user_home).join(".dashql").join("agent")
169 2 : }
170 :
171 : /// Random bytes from the operating system
172 129 : pub fn random_bytes<const N: usize>() -> [u8; N] {
173 129 : let mut bytes = [0; N];
174 129 : getrandom::fill(&mut bytes).expect("the operating system has no random numbers");
175 129 : bytes
176 129 : }
177 :
178 83 : fn new_token() -> String {
179 1992 : random_bytes::<24>().iter().map(|b| format!("{b:02x}")).collect()
180 83 : }
181 :
182 : /// Read the configuration, a missing configuration is created with a new token.
183 : /// The folder and the file are readable by the user only.
184 46 : pub fn load_config(home: &Path, env: &dyn Fn(&str) -> Option<String>) -> Result<AgentConfig, String> {
185 46 : create_private_dir(home)?;
186 46 : let mut stored = read_stored_config(home)?;
187 46 : if stored.get("token").and_then(Value::as_str).is_none_or(str::is_empty) {
188 11 : stored.insert("token".into(), new_token().into());
189 11 : stored.entry("port").or_insert(DEFAULT_PORT.into());
190 11 : stored.entry("defaultHarness").or_insert("claude".into());
191 11 : save_config(home, &stored)?;
192 35 : }
193 46 : let mut config = parse_config(&stored)?;
194 43 : if let Some(port) = env("DASHQL_AGENT_PORT") {
195 2 : config.port = port
196 2 : .parse()
197 2 : .ok()
198 2 : .filter(|p| *p != 0)
199 2 : .ok_or_else(|| format!("DASHQL_AGENT_PORT is no valid port: {port}"))?;
200 41 : }
201 42 : Ok(config)
202 46 : }
203 :
204 46 : fn parse_config(stored: &Map<String, Value>) -> Result<AgentConfig, String> {
205 46 : let port = match stored.get("port") {
206 31 : None => DEFAULT_PORT,
207 15 : Some(port) => port
208 15 : .as_u64()
209 15 : .and_then(|p| u16::try_from(p).ok())
210 15 : .filter(|p| *p != 0)
211 15 : .ok_or_else(|| format!("port is no valid port: {port}"))?,
212 : };
213 : // New built-in harnesses appear in existing configurations, config.json overrides them by name
214 46 : let mut harnesses = match default_harnesses() {
215 46 : Value::Object(map) => map,
216 0 : _ => unreachable!(),
217 : };
218 46 : if let Some(stored) = stored.get("harnesses") {
219 6 : let stored = stored.as_object().ok_or("harnesses must be an object")?;
220 6 : harnesses.extend(stored.clone());
221 40 : }
222 778 : let strings = |value: &Value, what: &str| -> Result<Vec<String>, String> {
223 778 : let list = value.as_array().ok_or(format!("{what} must be a list of strings"))?;
224 3307 : list.iter().map(|s| s.as_str().map(str::to_string).ok_or(format!("{what} must be a list of strings"))).collect()
225 778 : };
226 46 : let harnesses = harnesses
227 46 : .iter()
228 142 : .map(|(name, harness)| {
229 781 : let what = |key: &str| format!("harnesses.{name}.{key}");
230 142 : let command = strings(&harness["command"], &what("command"))?;
231 141 : if command.is_empty() {
232 0 : return Err(format!("{} is empty", what("command")));
233 141 : }
234 141 : let optional =
235 842 : |key: &str| (!harness[key].is_null()).then(|| strings(&harness[key], &what(key))).transpose();
236 : // A value to pick needs its place in the command
237 281 : let choice = |name: &str, values_key: &str, args_key: &str| -> Result<HarnessChoice, String> {
238 281 : let choice = HarnessChoice {
239 281 : values: optional(values_key)?.unwrap_or_default(),
240 281 : args: optional(args_key)?.unwrap_or_default(),
241 : };
242 281 : let placeholder = format!("{{{name}}}");
243 281 : if !choice.values.is_empty() {
244 138 : if !command.contains(&placeholder) {
245 1 : return Err(format!("{} needs {placeholder} in {}", what(values_key), what("command")));
246 137 : }
247 273 : if !choice.args.iter().any(|arg| arg.contains(&placeholder)) {
248 1 : return Err(format!("{} needs {placeholder} in {}", what(values_key), what(args_key)));
249 136 : }
250 143 : }
251 279 : Ok(choice)
252 281 : };
253 139 : let config = HarnessConfig {
254 141 : resume_args: optional("resumeArgs")?.unwrap_or_default(),
255 141 : model: choice("model", "models", "modelArgs")?,
256 140 : effort: choice("effort", "efforts", "effortArgs")?,
257 139 : command,
258 139 : terminal_command: optional("terminalCommand")?,
259 139 : adapter: HarnessAdapter::parse(harness["adapter"].as_str().unwrap_or("text")),
260 : };
261 139 : Ok((name.clone(), config))
262 142 : })
263 46 : .collect::<Result<Vec<_>, String>>()?;
264 43 : let origins = match stored.get("origins") {
265 1 : Some(origins) => strings(origins, "origins")?,
266 42 : None => Vec::new(),
267 : };
268 43 : Ok(AgentConfig {
269 43 : port,
270 43 : token: stored.get("token").and_then(Value::as_str).unwrap_or_default().to_string(),
271 43 : default_harness: stored.get("defaultHarness").and_then(Value::as_str).unwrap_or("claude").to_string(),
272 43 : harnesses,
273 43 : origins,
274 43 : call_wait: stored.get("callWaitMs").and_then(Value::as_u64).map_or(DEFAULT_CALL_WAIT, Duration::from_millis),
275 43 : })
276 46 : }
277 :
278 46 : fn read_stored_config(home: &Path) -> Result<Map<String, Value>, String> {
279 46 : let path = home.join("config.json");
280 46 : let text = match std::fs::read_to_string(&path) {
281 35 : Ok(text) => text,
282 11 : Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Map::new()),
283 0 : Err(e) => return Err(format!("{}: {e}", path.display())),
284 : };
285 35 : match serde_json::from_str(&text) {
286 35 : Ok(Value::Object(map)) => Ok(map),
287 0 : Ok(_) => Err(format!("{}: expected an object", path.display())),
288 0 : Err(e) => Err(format!("{}: {e}", path.display())),
289 : }
290 46 : }
291 :
292 11 : fn save_config(home: &Path, config: &Map<String, Value>) -> Result<(), String> {
293 11 : write_private_file(&home.join("config.json"), &(to_json_text(&Value::Object(config.clone())) + "\n"))
294 11 : }
295 :
296 : /// JSON with four spaces of indentation, as the stored files are written
297 25 : pub fn to_json_text(value: &Value) -> String {
298 25 : let text = serde_json::to_string_pretty(value).expect("JSON values serialize");
299 : // Strings never hold a raw newline, the leading spaces of a line are its indentation
300 101 : let lines = text.lines().map(|line| {
301 101 : let content = line.trim_start_matches(' ');
302 101 : format!("{}{content}", " ".repeat(2 * (line.len() - content.len())))
303 101 : });
304 25 : lines.collect::<Vec<_>>().join("\n")
305 25 : }
306 :
307 : /// Create a folder that only the user can read
308 92 : pub fn create_private_dir(dir: &Path) -> Result<(), String> {
309 92 : let mut builder = std::fs::DirBuilder::new();
310 92 : builder.recursive(true);
311 : #[cfg(unix)]
312 92 : std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
313 92 : builder.create(dir).map_err(|e| format!("{}: {e}", dir.display()))
314 92 : }
315 :
316 : /// Write a file that only the user can read
317 25 : pub fn write_private_file(path: &Path, text: &str) -> Result<(), String> {
318 25 : let error = |e: std::io::Error| format!("{}: {e}", path.display());
319 25 : let mut options = std::fs::OpenOptions::new();
320 25 : options.write(true).create(true).truncate(true);
321 : #[cfg(unix)]
322 25 : std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
323 25 : let mut file = options.open(path).map_err(error)?;
324 : // The mode applies to new files only
325 : #[cfg(unix)]
326 25 : file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600)).map_err(error)?;
327 25 : std::io::Write::write_all(&mut file, text.as_bytes()).map_err(error)
328 25 : }
329 :
330 : #[cfg(test)]
331 : mod tests {
332 : use super::*;
333 :
334 : #[test]
335 1 : fn accepts_the_dev_server_only_in_development_builds() {
336 1 : let origins = client_origins();
337 1 : assert!(origins.contains(&"https://dashql.app") && origins.contains(&"app://bundle"));
338 1 : assert!(origins.contains(&"https://stable.dashql.app"));
339 1 : assert_eq!(origins.contains(&"http://localhost:9002"), !cfg!(dashql_release));
340 1 : assert_eq!(origins.contains(&"http://127.0.0.1:9002"), !cfg!(dashql_release));
341 1 : }
342 :
343 72 : pub fn temp_dir(name: &str) -> PathBuf {
344 72 : let base = std::env::var_os("TEST_TMPDIR").map_or_else(std::env::temp_dir, PathBuf::from);
345 72 : let dir = base.join(format!("{name}-{}", new_token()));
346 72 : std::fs::create_dir_all(&dir).unwrap();
347 72 : dir
348 72 : }
349 :
350 5 : fn no_env(_: &str) -> Option<String> {
351 5 : None
352 5 : }
353 :
354 : #[test]
355 1 : fn creates_a_configuration_with_claude_as_default_harness() {
356 1 : let home = temp_dir("dashql-config");
357 1 : let config = load_config(&home, &no_env).unwrap();
358 1 : assert_eq!(config.default_harness, "claude");
359 1 : assert_eq!(config.port, DEFAULT_PORT);
360 1 : assert_eq!(config.token.len(), 48);
361 48 : assert!(config.token.chars().all(|c| c.is_ascii_hexdigit()));
362 3 : let names: Vec<_> = config.harnesses.iter().map(|(n, _)| n.as_str()).collect();
363 1 : assert_eq!(names, ["claude", "codex", "opencode"]);
364 1 : assert_eq!(load_config(&home, &no_env).unwrap().token, config.token);
365 : // The built-in harnesses are not written, so they change with the binary
366 1 : let stored: Value = serde_json::from_str(&std::fs::read_to_string(home.join("config.json")).unwrap()).unwrap();
367 1 : assert!(stored.get("harnesses").is_none());
368 1 : }
369 :
370 : #[test]
371 1 : fn adds_new_built_in_harnesses_to_stored_configurations() {
372 1 : let home = temp_dir("dashql-config");
373 1 : let stored = json!({
374 1 : "port": 1234, "token": "t", "defaultHarness": "mine",
375 1 : "harnesses": { "codex": { "command": ["codex2", "{prompt}"], "adapter": "codex" }, "mine": { "command": ["mine", "{prompt}"], "adapter": "text" } },
376 : });
377 1 : std::fs::write(home.join("config.json"), stored.to_string()).unwrap();
378 1 : let config = load_config(&home, &no_env).unwrap();
379 1 : assert_eq!(config.default_harness, "mine");
380 1 : assert_eq!(config.port, 1234);
381 4 : let names: Vec<_> = config.harnesses.iter().map(|(n, _)| n.as_str()).collect();
382 1 : assert_eq!(names, ["claude", "codex", "opencode", "mine"]);
383 1 : assert_eq!(config.harness("codex").unwrap().command, ["codex2", "{prompt}"]);
384 1 : assert_eq!(config.harness("mine").unwrap().adapter, HarnessAdapter::Text);
385 1 : }
386 :
387 : #[test]
388 1 : fn does_not_persist_the_port_of_the_environment() {
389 1 : let home = temp_dir("dashql-config");
390 1 : load_config(&home, &no_env).unwrap();
391 1 : let env = |name: &str| (name == "DASHQL_AGENT_PORT").then(|| "4000".to_string());
392 1 : assert_eq!(load_config(&home, &env).unwrap().port, 4000);
393 1 : let stored: Value = serde_json::from_str(&std::fs::read_to_string(home.join("config.json")).unwrap()).unwrap();
394 1 : assert_eq!(stored["port"], DEFAULT_PORT);
395 1 : let bad = |name: &str| (name == "DASHQL_AGENT_PORT").then(|| "70000".to_string());
396 1 : assert!(load_config(&home, &bad).is_err());
397 1 : }
398 :
399 : #[test]
400 1 : fn rejects_malformed_harnesses() {
401 1 : let home = temp_dir("dashql-config");
402 1 : std::fs::write(home.join("config.json"), r#"{"token": "t", "harnesses": {"x": {"command": "x"}}}"#).unwrap();
403 1 : assert_eq!(load_config(&home, &no_env).unwrap_err(), "harnesses.x.command must be a list of strings");
404 1 : }
405 :
406 : #[test]
407 1 : fn reads_the_models_and_efforts_of_harnesses() {
408 1 : let home = temp_dir("dashql-config");
409 1 : let stored = json!({
410 1 : "token": "t",
411 1 : "harnesses": { "codex": { "command": ["codex", "{model}", "{prompt}"], "models": ["gpt-a"], "modelArgs": ["-m", "{model}"] } },
412 : });
413 1 : std::fs::write(home.join("config.json"), stored.to_string()).unwrap();
414 1 : let config = load_config(&home, &no_env).unwrap();
415 1 : let claude = config.harness("claude").unwrap();
416 1 : assert_eq!(claude.model.values, ["fable", "opus", "sonnet", "haiku"]);
417 1 : assert_eq!(claude.effort.args, ["--effort", "{effort}"]);
418 : // An override replaces the built-in harness with its choices
419 1 : let codex = config.harness("codex").unwrap();
420 1 : assert_eq!(codex.model.values, ["gpt-a"]);
421 1 : assert_eq!(codex.model.args, ["-m", "{model}"]);
422 1 : assert_eq!(codex.effort, HarnessChoice::default());
423 1 : assert!(config.harness("opencode").unwrap().model.values.is_empty());
424 1 : }
425 :
426 : #[test]
427 1 : fn rejects_choices_without_their_place_in_the_command() {
428 1 : let home = temp_dir("dashql-config");
429 1 : let stored = json!({ "token": "t", "harnesses": { "x": { "command": ["x", "{prompt}"], "models": ["m"], "modelArgs": ["-m", "{model}"] } } });
430 1 : std::fs::write(home.join("config.json"), stored.to_string()).unwrap();
431 1 : assert_eq!(load_config(&home, &no_env).unwrap_err(), "harnesses.x.models needs {model} in harnesses.x.command");
432 1 : let stored = json!({ "token": "t", "harnesses": { "x": { "command": ["x", "{effort}", "{prompt}"], "efforts": ["high"], "effortArgs": ["--effort"] } } });
433 1 : std::fs::write(home.join("config.json"), stored.to_string()).unwrap();
434 1 : let error = load_config(&home, &no_env).unwrap_err();
435 1 : assert_eq!(error, "harnesses.x.efforts needs {effort} in harnesses.x.effortArgs");
436 1 : }
437 :
438 : #[test]
439 1 : fn resolves_the_home_folder() {
440 2 : let env = |name: &str| (name == "HOME").then(|| "/home/u".to_string());
441 1 : assert_eq!(agent_home(&env), Path::new("/home/u/.dashql/agent"));
442 1 : let env = |name: &str| (name == "DASHQL_AGENT_HOME").then(|| "/tmp/a".to_string());
443 1 : assert_eq!(agent_home(&env), Path::new("/tmp/a"));
444 1 : }
445 : }
446 :
447 : #[cfg(test)]
448 : pub use tests::temp_dir;
|