From 2a2ec1fb4f587e8bf9dc0c1750fe28c4738ccf33 Mon Sep 17 00:00:00 2001 From: kousw Date: Mon, 16 Feb 2026 05:57:02 +0900 Subject: [PATCH 1/6] Fix runtime test command and add clipboard parser coverage --- crates/tui/AGENTS.md | 1 + crates/tui/src/app/runtime/client.rs | 102 +++++++++++- crates/tui/src/app/runtime/parser.rs | 73 +++++++++ crates/tui/src/main.rs | 200 ++++++++++++++++++++++-- docs/specs/ui-protocol.md | 40 ++++- packages/protocol/AGENTS.md | 1 + packages/protocol/src/capabilities.ts | 1 + packages/protocol/src/methods.ts | 3 +- packages/protocol/src/ui-requests.ts | 29 ++++ packages/runtime/AGENTS.md | 1 + packages/runtime/package.json | 3 +- packages/runtime/src/rpc/ui-requests.ts | 13 ++ 12 files changed, 439 insertions(+), 28 deletions(-) diff --git a/crates/tui/AGENTS.md b/crates/tui/AGENTS.md index c4228737..a8df58f2 100644 --- a/crates/tui/AGENTS.md +++ b/crates/tui/AGENTS.md @@ -48,3 +48,4 @@ The TUI launches runtime, sends UI protocol requests, and renders runtime events - Local test: `cargo test --manifest-path crates/tui/Cargo.toml` - Basic CLI options are handled in `src/main.rs` (`-h/--help`, `-V/-v/--version`, `--debug`, `--debug-perf`) and exit/enable flags before runtime loop. - Startup log includes a version line; `CODELIA_CLI_VERSION` (from launcher) is preferred when available. +- Runtime transport can be switched to SSH with `--runtime-transport ssh` (or `CODELIA_RUNTIME_TRANSPORT=ssh`) plus `CODELIA_RUNTIME_SSH_HOST`; remote runtime requests local clipboard via `ui.clipboard.read` handled in TUI. diff --git a/crates/tui/src/app/runtime/client.rs b/crates/tui/src/app/runtime/client.rs index 13ad99d9..61cb16e3 100644 --- a/crates/tui/src/app/runtime/client.rs +++ b/crates/tui/src/app/runtime/client.rs @@ -65,14 +65,97 @@ fn spawn_reader( }); } -pub fn spawn_runtime() -> RuntimeSpawnResult { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RuntimeTransportMode { + Local, + Ssh, +} + +fn resolve_transport_mode() -> RuntimeTransportMode { + match env::var("CODELIA_RUNTIME_TRANSPORT") { + Ok(value) if value.trim().eq_ignore_ascii_case("ssh") => RuntimeTransportMode::Ssh, + _ => RuntimeTransportMode::Local, + } +} + +fn build_local_runtime_command() -> Command { let runtime_cmd = env::var("CODELIA_RUNTIME_CMD").unwrap_or_else(|_| "bun".to_string()); let runtime_args = env::var("CODELIA_RUNTIME_ARGS") .map(|value| split_args(&value)) .unwrap_or_else(|_| vec!["packages/runtime/src/index.ts".to_string()]); - let mut child = Command::new(runtime_cmd) - .args(runtime_args) + let mut command = Command::new(runtime_cmd); + command.args(runtime_args); + command +} + +fn shell_join(parts: &[String]) -> String { + parts + .iter() + .map(|part| shell_words::quote(part).to_string()) + .collect::>() + .join(" ") +} + +fn build_ssh_runtime_command() -> Result> { + let host = env::var("CODELIA_RUNTIME_SSH_HOST") + .map(|value| value.trim().to_string()) + .ok() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "CODELIA_RUNTIME_SSH_HOST is required when runtime transport is ssh".to_string() + })?; + + let remote_cmd_raw = env::var("CODELIA_RUNTIME_REMOTE_CMD") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "bun packages/runtime/src/index.ts".to_string()); + let remote_cmd_parts = split_args(&remote_cmd_raw); + if remote_cmd_parts.is_empty() { + return Err("CODELIA_RUNTIME_REMOTE_CMD resolved to an empty command".into()); + } + + let remote_exec = shell_join(&remote_cmd_parts); + let remote_script = env::var("CODELIA_RUNTIME_REMOTE_CWD") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|cwd| format!("cd {} && {remote_exec}", shell_words::quote(&cwd))) + .unwrap_or(remote_exec); + + let mut command = Command::new("ssh"); + command.arg("-T"); + + let ssh_opts = env::var("CODELIA_RUNTIME_SSH_OPTS") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| split_args(&value)) + .unwrap_or_else(|| { + vec![ + "-o".to_string(), + "BatchMode=yes".to_string(), + "-o".to_string(), + "StrictHostKeyChecking=yes".to_string(), + "-o".to_string(), + "ServerAliveInterval=15".to_string(), + "-o".to_string(), + "ServerAliveCountMax=3".to_string(), + ] + }); + command.args(ssh_opts); + command.arg(host); + command.arg("sh"); + command.arg("-lc"); + command.arg(remote_script); + Ok(command) +} + +pub fn spawn_runtime() -> RuntimeSpawnResult { + let mut command = match resolve_transport_mode() { + RuntimeTransportMode::Local => build_local_runtime_command(), + RuntimeTransportMode::Ssh => build_ssh_runtime_command()?, + }; + + let mut child = command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -103,6 +186,7 @@ pub fn send_initialize( "supports_confirm": true, "supports_prompt": true, "supports_pick": true, + "supports_clipboard_read": true, "supports_permission_preflight_events": true } } @@ -391,7 +475,7 @@ pub fn send_session_history( #[cfg(test)] mod tests { - use super::{json_line, split_args}; + use super::{json_line, shell_join, split_args}; use serde_json::json; #[test] @@ -465,4 +549,14 @@ mod tests { assert!(line.contains("\"method\":\"tool.call\"")); assert!(line.contains("\"name\":\"lane_list\"")); } + + #[test] + fn shell_join_quotes_unsafe_parts() { + let joined = shell_join(&[ + "bun".to_string(), + "packages/runtime/src/index.ts".to_string(), + "--flag=value with space".to_string(), + ]); + assert!(joined.contains("'--flag=value with space'")); + } } diff --git a/crates/tui/src/app/runtime/parser.rs b/crates/tui/src/app/runtime/parser.rs index 6a8c719b..63134fcf 100644 --- a/crates/tui/src/app/runtime/parser.rs +++ b/crates/tui/src/app/runtime/parser.rs @@ -15,6 +15,7 @@ pub struct ParsedOutput { pub confirm_request: Option, pub prompt_request: Option, pub pick_request: Option, + pub clipboard_read_request: Option, pub tool_call_start_id: Option, pub tool_call_result: Option, pub permission_preview_update: Option, @@ -48,6 +49,7 @@ impl ParsedOutput { confirm_request: None, prompt_request: None, pick_request: None, + clipboard_read_request: None, tool_call_start_id: None, tool_call_result: None, permission_preview_update: None, @@ -94,6 +96,15 @@ pub struct UiPickRequest { pub multi: bool, } +pub struct UiClipboardReadRequest { + pub id: String, + pub run_id: Option, + pub purpose: String, + pub formats: Vec, + pub max_bytes: Option, + pub prompt: Option, +} + fn split_lines(value: &str) -> Vec { value .split('\n') @@ -1836,6 +1847,53 @@ pub fn parse_runtime_output(raw: &str) -> ParsedOutput { }; } + if method == "ui.clipboard.read" { + let id = value + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let params = value.get("params").and_then(|v| v.as_object()); + let run_id = params + .and_then(|p| p.get("run_id")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + let purpose = params + .and_then(|p| p.get("purpose")) + .and_then(|v| v.as_str()) + .unwrap_or("image_attachment") + .to_string(); + let formats = params + .and_then(|p| p.get("formats")) + .and_then(|v| v.as_array()) + .map(|formats| { + formats + .iter() + .filter_map(|item| item.as_str().map(|v| v.to_string())) + .collect::>() + }) + .unwrap_or_default(); + let max_bytes = params + .and_then(|p| p.get("max_bytes")) + .and_then(|v| v.as_u64()) + .and_then(|v| usize::try_from(v).ok()); + let prompt = params + .and_then(|p| p.get("prompt")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + return ParsedOutput { + clipboard_read_request: Some(UiClipboardReadRequest { + id, + run_id, + purpose, + formats, + max_bytes, + prompt, + }), + ..ParsedOutput::empty() + }; + } + if method == "agent.event" { let event = &value["params"]["event"]; let event_type = event @@ -2236,6 +2294,21 @@ mod tests { assert!(!texts.iter().any(|line| line.contains("\"ok\":true"))); } + #[test] + fn parse_runtime_output_parses_ui_clipboard_read_request() { + let raw = r#"{"jsonrpc":"2.0","id":"req-1","method":"ui.clipboard.read","params":{"run_id":"run-1","purpose":"image_attachment","formats":["image/png"],"max_bytes":4096,"prompt":"Attach clipboard image"}}"#; + let parsed = parse_runtime_output(raw); + let request = parsed + .clipboard_read_request + .expect("clipboard read request"); + assert_eq!(request.id, "req-1"); + assert_eq!(request.run_id.as_deref(), Some("run-1")); + assert_eq!(request.purpose, "image_attachment"); + assert_eq!(request.formats, vec!["image/png"]); + assert_eq!(request.max_bytes, Some(4096)); + assert_eq!(request.prompt.as_deref(), Some("Attach clipboard image")); + } + #[test] fn parse_runtime_output_formats_edit_tool_result_with_diff_body() { let raw = r#"{"method":"agent.event","params":{"event":{"type":"tool_result","tool":"edit","result":{"summary":"updated file","diff":"--- a/demo.txt\n+++ b/demo.txt\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context line"}}}}"#; diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index a824e5ce..87c314ff 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -18,7 +18,8 @@ use crate::app::{ use crate::app::runtime::{ parse_runtime_output, send_initialize, send_model_list, send_pick_response, send_prompt_response, send_run_cancel, send_session_list, send_tool_call, spawn_runtime, - ParsedOutput, RpcResponse, ToolCallResultUpdate, UiPickRequest, UiPromptRequest, + ParsedOutput, RpcResponse, ToolCallResultUpdate, UiClipboardReadRequest, UiPickRequest, + UiPromptRequest, }; use crate::app::view::{desired_height, draw_ui}; use crossterm::cursor::Show; @@ -37,7 +38,7 @@ use ratatui::layout::{Position, Rect, Size}; use serde_json::{json, Value}; use std::env; use std::fmt; -use std::io::BufWriter; +use std::io::{BufWriter, Write}; use std::process::ChildStdin; use std::sync::mpsc::{Receiver, TryRecvError}; use std::time::{Duration, Instant}; @@ -110,9 +111,7 @@ fn parse_basic_cli_mode() -> BasicCliMode { parse_basic_cli_mode_from_args(env::args().skip(1)) } -fn parse_basic_cli_mode_from_args( - args: impl IntoIterator>, -) -> BasicCliMode { +fn parse_basic_cli_mode_from_args(args: impl IntoIterator>) -> BasicCliMode { for arg in args { let value = arg.as_ref(); if value == "-h" || value == "--help" { @@ -131,13 +130,8 @@ fn resolve_version_label() -> String { resolve_version_label_from_versions(cli_version.as_deref(), tui_version) } -fn resolve_version_label_from_versions( - cli_version: Option<&str>, - _tui_version: &str, -) -> String { - let normalized = cli_version - .map(str::trim) - .filter(|value| !value.is_empty()); +fn resolve_version_label_from_versions(cli_version: Option<&str>, _tui_version: &str) -> String { + let normalized = cli_version.map(str::trim).filter(|value| !value.is_empty()); match normalized { Some(cli) => format!("codelia {cli}"), None => "codelia".to_string(), @@ -155,6 +149,11 @@ fn print_basic_help() { println!(" --initial-message Queue initial prompt"); println!(" --initial-user-message Alias of --initial-message"); println!(" --debug-perf[=true|false] Enable perf panel"); + println!(" --runtime-transport Runtime process transport"); + println!(" --runtime-ssh-host SSH host/alias for remote runtime"); + println!(" --runtime-ssh-opts Extra SSH options string"); + println!(" --runtime-remote-cmd Remote runtime command"); + println!(" --runtime-remote-cwd Remote runtime working directory"); } fn parse_resume_mode() -> ResumeMode { @@ -220,6 +219,81 @@ fn parse_initial_message_from_args( message.filter(|value| !value.trim().is_empty()) } +fn parse_runtime_cli_overrides() { + let mut args = env::args().skip(1).peekable(); + while let Some(arg) = args.next() { + if let Some(value) = arg.strip_prefix("--runtime-transport=") { + unsafe { + env::set_var("CODELIA_RUNTIME_TRANSPORT", value); + } + continue; + } + if arg == "--runtime-transport" { + if let Some(value) = args.next() { + unsafe { + env::set_var("CODELIA_RUNTIME_TRANSPORT", value); + } + } + continue; + } + if let Some(value) = arg.strip_prefix("--runtime-ssh-host=") { + unsafe { + env::set_var("CODELIA_RUNTIME_SSH_HOST", value); + } + continue; + } + if arg == "--runtime-ssh-host" { + if let Some(value) = args.next() { + unsafe { + env::set_var("CODELIA_RUNTIME_SSH_HOST", value); + } + } + continue; + } + if let Some(value) = arg.strip_prefix("--runtime-ssh-opts=") { + unsafe { + env::set_var("CODELIA_RUNTIME_SSH_OPTS", value); + } + continue; + } + if arg == "--runtime-ssh-opts" { + if let Some(value) = args.next() { + unsafe { + env::set_var("CODELIA_RUNTIME_SSH_OPTS", value); + } + } + continue; + } + if let Some(value) = arg.strip_prefix("--runtime-remote-cmd=") { + unsafe { + env::set_var("CODELIA_RUNTIME_REMOTE_CMD", value); + } + continue; + } + if arg == "--runtime-remote-cmd" { + if let Some(value) = args.next() { + unsafe { + env::set_var("CODELIA_RUNTIME_REMOTE_CMD", value); + } + } + continue; + } + if let Some(value) = arg.strip_prefix("--runtime-remote-cwd=") { + unsafe { + env::set_var("CODELIA_RUNTIME_REMOTE_CWD", value); + } + continue; + } + if arg == "--runtime-remote-cwd" { + if let Some(value) = args.next() { + unsafe { + env::set_var("CODELIA_RUNTIME_REMOTE_CWD", value); + } + } + } + } +} + fn parse_bool_like(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Some(true), @@ -228,10 +302,7 @@ fn parse_bool_like(value: &str) -> Option { } } -fn cli_flag_enabled_from_args( - flag: &str, - args: impl IntoIterator>, -) -> bool { +fn cli_flag_enabled_from_args(flag: &str, args: impl IntoIterator>) -> bool { args.into_iter().any(|arg| { let value = arg.as_ref(); if value == flag { @@ -413,6 +484,7 @@ fn apply_parsed_output( confirm_request, prompt_request, pick_request, + clipboard_read_request, tool_call_start_id, tool_call_result, permission_preview_update, @@ -559,6 +631,10 @@ fn apply_parsed_output( handle_pick_request(app, request); needs_redraw = true; } + if let Some(request) = clipboard_read_request { + handle_clipboard_read_request(app, request, child_stdin); + needs_redraw = true; + } needs_redraw } @@ -576,6 +652,92 @@ fn handle_prompt_request(app: &mut AppState, request: UiPromptRequest) { }); } +fn handle_clipboard_read_request( + app: &mut AppState, + request: UiClipboardReadRequest, + child_stdin: &mut RuntimeStdin, +) { + let UiClipboardReadRequest { + id, + run_id, + purpose, + formats, + max_bytes, + prompt, + } = request; + let supports_image = formats.iter().any(|format| format == "image/png"); + if !supports_image { + let msg = json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "ok": false, + "cancelled": false, + "error": "unsupported clipboard format" + } + }); + if writeln!(child_stdin, "{}", msg) + .and_then(|_| child_stdin.flush()) + .is_err() + { + app.push_line( + LogKind::Error, + "clipboard response error: failed to send unsupported format result", + ); + } + return; + } + + let max_bytes = max_bytes.unwrap_or(MAX_CLIPBOARD_IMAGE_BYTES); + if let Some(prompt) = prompt.filter(|value| !value.trim().is_empty()) { + app.push_line(LogKind::Status, format!("Clipboard request: {prompt}")); + } + let _ = (run_id, purpose); + let result = match read_clipboard_image_attachment(max_bytes) { + Ok(image) => json!({ + "ok": true, + "items": [{ + "type": "image", + "media_type": "image/png", + "data_url": image.data_url, + "width": image.width, + "height": image.height, + "bytes": image.encoded_bytes + }] + }), + Err(ClipboardImageError::NotAvailable) => json!({ + "ok": false, + "cancelled": true, + "error": "clipboard image not available" + }), + Err(ClipboardImageError::TooLarge { bytes, max_bytes }) => json!({ + "ok": false, + "cancelled": false, + "error": format!("clipboard image too large: {bytes} bytes (max {max_bytes})") + }), + Err(error) => json!({ + "ok": false, + "cancelled": false, + "error": format!("clipboard read failed: {error:?}") + }), + }; + + let msg = json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + }); + if writeln!(child_stdin, "{}", msg) + .and_then(|_| child_stdin.flush()) + .is_err() + { + app.push_line( + LogKind::Error, + "clipboard response error: failed to send result", + ); + } +} + fn parse_onboarding_model_provider(title: &str) -> Option { let prefix = "Select model ("; if !title.starts_with(prefix) || !title.ends_with(')') { @@ -2537,6 +2699,7 @@ fn main() -> Result<(), Box> { } BasicCliMode::Run => {} } + parse_runtime_cli_overrides(); let resume_mode = parse_resume_mode(); let mut pending_initial_message = parse_initial_message(); let (mut child, mut child_stdin, rx) = spawn_runtime()?; @@ -2581,7 +2744,10 @@ fn main() -> Result<(), Box> { } app.push_line(LogKind::Space, ""); app.push_line(LogKind::System, "Welcome to Codelia!"); - app.push_line(LogKind::System, format!("Version: {}", resolve_version_label())); + app.push_line( + LogKind::System, + format!("Version: {}", resolve_version_label()), + ); app.push_line(LogKind::Space, ""); if pending_initial_message.is_some() { app.push_line( diff --git a/docs/specs/ui-protocol.md b/docs/specs/ui-protocol.md index 1aadbfa3..deeadceb 100644 --- a/docs/specs/ui-protocol.md +++ b/docs/specs/ui-protocol.md @@ -119,8 +119,7 @@ protocol_version: string; // version that server can speak (same if compatible) UI side: - `supports_confirm`, `supports_prompt`, `supports_pick` - `supports_markdown`, `supports_images` -- Planned extension for remote-runtime mode: `supports_clipboard_read` - (see `docs/specs/tui-remote-runtime-ssh.md`) +- `supports_clipboard_read` (Runtime may request local clipboard data via `ui.clipboard.read`) Runtime side: - `supports_run_cancel` @@ -592,6 +591,39 @@ export type UiPickRequestParams = { export type UiPickResult = { ids: string[] }; // cancel => [] ``` +### 7.4 `ui.clipboard.read` (optional) + +```ts +export type UiClipboardReadRequestParams = { + run_id?: string; + purpose: "image_attachment" | "text_paste"; + formats: Array<"image/png" | "text/plain">; + max_bytes?: number; + prompt?: string; +}; + +export type UiClipboardReadResult = { + ok: boolean; + cancelled?: boolean; + items?: Array< + | { + type: "image"; + media_type: "image/png"; + data_url: string; + width?: number; + height?: number; + bytes: number; + } + | { + type: "text"; + text: string; + bytes: number; + } + >; + error?: string; +}; +``` + --- ## 8. Additional categories that are likely to be needed (in the future) @@ -602,9 +634,7 @@ When developing TUI/desktop as a “coding agent UI” you will likely need: - **Workspace API**: `workspace.list/read/search` for UI to display file tree / preview - **History API**: Get and export conversation history/tool history - **Task/ToDo**: `todos.update` etc. that works with planning tools -- **Clipboard**: Handle copy/paste explicitly (useful in TUI) - - Planned concrete request for remote-runtime mode: - `ui.clipboard.read` (see `docs/specs/tui-remote-runtime-ssh.md`) +- **Clipboard**: Handle copy/paste explicitly (useful in TUI), including `ui.clipboard.read` for remote-runtime mode. - **Shell execution**: user-initiated direct execution path - Planned concrete request: `shell.exec` (see `docs/specs/tui-bang-shell-mode.md`) diff --git a/packages/protocol/AGENTS.md b/packages/protocol/AGENTS.md index 72cabab1..96b671e9 100644 --- a/packages/protocol/AGENTS.md +++ b/packages/protocol/AGENTS.md @@ -11,6 +11,7 @@ run.start `input` supports both text (`{ type:"text", text }`) and multimodal pa Contains `mcp.list` and `supports_mcp_list` capability for MCP status display. Includes `skills.list` and `supports_skills_list` capabilities for skills catalog retrieval. Contains `context.inspect` and `supports_context_inspect` capabilities for taking context snapshots. +Includes `supports_clipboard_read` UI capability and Runtime→UI `ui.clipboard.read` request/result types for remote-runtime clipboard broker flow. `context.inspect` can receive `include_skills` and return skills catalog status. Permission preflight uses structured `AgentEvent` variants (`permission.preview` / `permission.ready`). `supports_permission_preflight_events` remains on both UI/server capabilities for explicit feature declaration. Provide `mcp-protocol.ts` (protocol version constant/compatibility check helper) for MCP transport handshake and share it with runtime/cli. diff --git a/packages/protocol/src/capabilities.ts b/packages/protocol/src/capabilities.ts index d45024e4..6c9730e0 100644 --- a/packages/protocol/src/capabilities.ts +++ b/packages/protocol/src/capabilities.ts @@ -2,6 +2,7 @@ export type UiCapabilities = { supports_confirm?: boolean; supports_prompt?: boolean; supports_pick?: boolean; + supports_clipboard_read?: boolean; supports_markdown?: boolean; supports_images?: boolean; supports_permission_preflight_events?: boolean; diff --git a/packages/protocol/src/methods.ts b/packages/protocol/src/methods.ts index 82651212..b49f1b76 100644 --- a/packages/protocol/src/methods.ts +++ b/packages/protocol/src/methods.ts @@ -17,4 +17,5 @@ export type ProtocolMethod = | "ui.context.update" | "ui.confirm.request" | "ui.prompt.request" - | "ui.pick.request"; + | "ui.pick.request" + | "ui.clipboard.read"; diff --git a/packages/protocol/src/ui-requests.ts b/packages/protocol/src/ui-requests.ts index 3c1c3c6b..29085572 100644 --- a/packages/protocol/src/ui-requests.ts +++ b/packages/protocol/src/ui-requests.ts @@ -38,3 +38,32 @@ export type UiPickRequestParams = { export type UiPickResult = { ids: string[]; }; + +export type UiClipboardReadRequestParams = { + run_id?: string; + purpose: "image_attachment" | "text_paste"; + formats: Array<"image/png" | "text/plain">; + max_bytes?: number; + prompt?: string; +}; + +export type UiClipboardReadResult = { + ok: boolean; + cancelled?: boolean; + items?: Array< + | { + type: "image"; + media_type: "image/png"; + data_url: string; + width?: number; + height?: number; + bytes: number; + } + | { + type: "text"; + text: string; + bytes: number; + } + >; + error?: string; +}; diff --git a/packages/runtime/AGENTS.md b/packages/runtime/AGENTS.md index b40e21c9..96cb9a13 100644 --- a/packages/runtime/AGENTS.md +++ b/packages/runtime/AGENTS.md @@ -25,6 +25,7 @@ If provider of `model.list` is not specified, the provider of config is given pr On startup after `initialize`, if no stored/env auth exists, runtime starts first-run onboarding via UI pick/prompt (provider -> auth -> model) before the first run. Return skills catalog (name/description/path/scope + errors) with RPC `skills.list`. Return a snapshot of runtime/UI/AGENTS resolver (including loaded AGENTS.md path) with RPC `context.inspect`. +Runtime UI request helpers include `ui.clipboard.read` for local clipboard broker integration when the UI advertises `supports_clipboard_read`. `context.inspect` can return skills catalog/loaded_versions with `include_skills=true`. Load `mcp.servers` (global/project merge) and start MCP server connection when runtime starts. The MCP adapter tool is generated at runtime, and `@codelia/core` does not have MCP transport/lifecycle. diff --git a/packages/runtime/package.json b/packages/runtime/package.json index d4df98cc..6341651d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -20,7 +20,8 @@ }, "scripts": { "build": "tsup src/index.ts --format esm,cjs --dts --clean", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "bun test tests" }, "dependencies": { "@codelia/config": "0.1.13", diff --git a/packages/runtime/src/rpc/ui-requests.ts b/packages/runtime/src/rpc/ui-requests.ts index 63700721..3a5a5848 100644 --- a/packages/runtime/src/rpc/ui-requests.ts +++ b/packages/runtime/src/rpc/ui-requests.ts @@ -1,5 +1,7 @@ import type { RpcRequest, + UiClipboardReadRequestParams, + UiClipboardReadResult, UiConfirmRequestParams, UiConfirmResult, UiPickRequestParams, @@ -23,6 +25,10 @@ type UiRequestMap = { params: UiPickRequestParams; result: UiPickResult; }; + "ui.clipboard.read": { + params: UiClipboardReadRequestParams; + result: UiClipboardReadResult; + }; }; const requestUi = async ( @@ -65,3 +71,10 @@ export const requestUiPick = async ( ): Promise => { return requestUi(state, "ui.pick.request", params); }; + +export const requestUiClipboardRead = async ( + state: RuntimeState, + params: UiClipboardReadRequestParams, +): Promise => { + return requestUi(state, "ui.clipboard.read", params); +}; From 7e678d1d27fff37d350864f58b12b0f2426cc096 Mon Sep 17 00:00:00 2001 From: kousw Date: Mon, 23 Feb 2026 01:06:16 +0900 Subject: [PATCH 2/6] feat(tui): add ssh remote runtime transport support --- crates/tui/AGENTS.md | 2 +- crates/tui/src/app/runtime/client.rs | 164 +++++++++++++++++- crates/tui/src/main.rs | 191 +++++++++++++++------ packages/runtime/src/rpc/ui-requests.ts | 3 + packages/runtime/tests/ui-requests.test.ts | 93 ++++++++++ 5 files changed, 399 insertions(+), 54 deletions(-) create mode 100644 packages/runtime/tests/ui-requests.test.ts diff --git a/crates/tui/AGENTS.md b/crates/tui/AGENTS.md index a8df58f2..cbefac87 100644 --- a/crates/tui/AGENTS.md +++ b/crates/tui/AGENTS.md @@ -48,4 +48,4 @@ The TUI launches runtime, sends UI protocol requests, and renders runtime events - Local test: `cargo test --manifest-path crates/tui/Cargo.toml` - Basic CLI options are handled in `src/main.rs` (`-h/--help`, `-V/-v/--version`, `--debug`, `--debug-perf`) and exit/enable flags before runtime loop. - Startup log includes a version line; `CODELIA_CLI_VERSION` (from launcher) is preferred when available. -- Runtime transport can be switched to SSH with `--runtime-transport ssh` (or `CODELIA_RUNTIME_TRANSPORT=ssh`) plus `CODELIA_RUNTIME_SSH_HOST`; remote runtime requests local clipboard via `ui.clipboard.read` handled in TUI. +- Runtime transport can be switched to SSH with `--ssh ` (plus optional `--ssh-port` / `--ssh-identity` / `--ssh-option` and `--remote-command` / `--remote-cwd`); remote runtime requests local clipboard via `ui.clipboard.read` handled in TUI. diff --git a/crates/tui/src/app/runtime/client.rs b/crates/tui/src/app/runtime/client.rs index 61cb16e3..dfc11baa 100644 --- a/crates/tui/src/app/runtime/client.rs +++ b/crates/tui/src/app/runtime/client.rs @@ -97,6 +97,105 @@ fn shell_join(parts: &[String]) -> String { .join(" ") } +#[derive(Clone, Debug, PartialEq, Eq)] +struct RemoteBootstrapOptions { + target_cli_version: Option, + ready_timeout_sec: u64, +} + +fn sanitize_cli_version_for_npm(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let valid = trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '+' | '_')); + if valid { + Some(trimmed.to_string()) + } else { + None + } +} + +fn resolve_remote_bootstrap_options() -> RemoteBootstrapOptions { + let target_cli_version = env::var("CODELIA_CLI_VERSION") + .ok() + .and_then(|value| sanitize_cli_version_for_npm(&value)); + let ready_timeout_sec = env::var("CODELIA_RUNTIME_REMOTE_READY_TIMEOUT_SEC") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(120); + RemoteBootstrapOptions { + target_cli_version, + ready_timeout_sec, + } +} + +fn build_remote_bootstrap_script( + remote_exec: &str, + remote_cwd: Option<&str>, + options: &RemoteBootstrapOptions, +) -> String { + let mut lines = vec![ + "set -eu".to_string(), + "log_bootstrap() { printf '%s\\n' \"[bootstrap] $1\" >&2; }".to_string(), + ]; + + if let Some(cwd) = remote_cwd { + lines.push(format!( + "log_bootstrap \"changing directory: {}\"", + cwd.replace('"', "\\\"") + )); + lines.push(format!("cd {}", shell_words::quote(cwd))); + } + + lines.push("if command -v codelia >/dev/null 2>&1; then".to_string()); + lines.push(" log_bootstrap \"found codelia on remote host\"".to_string()); + lines.push("else".to_string()); + lines.push(" if ! command -v npm >/dev/null 2>&1; then".to_string()); + lines.push(" log_bootstrap \"npm is required to install @codelia/cli\"".to_string()); + lines.push(" exit 1".to_string()); + lines.push(" fi".to_string()); + let install_target = options + .target_cli_version + .as_ref() + .map(|version| format!("@codelia/cli@{version}")) + .unwrap_or_else(|| "@codelia/cli".to_string()); + lines.push(format!( + " log_bootstrap \"installing {}\"", + install_target.replace('"', "\\\"") + )); + lines.push(format!( + " npm install -g {}", + shell_words::quote(&install_target) + )); + lines.push("fi".to_string()); + lines.push(format!( + "deadline=$(( $(date +%s) + {} ))", + options.ready_timeout_sec + )); + lines.push("while true; do".to_string()); + lines.push( + " if command -v codelia >/dev/null 2>&1 && codelia --version >/dev/null 2>&1; then" + .to_string(), + ); + lines.push(" log_bootstrap \"codelia ready\"".to_string()); + lines.push(" break".to_string()); + lines.push(" fi".to_string()); + lines.push(" if [ \"$(date +%s)\" -ge \"$deadline\" ]; then".to_string()); + lines.push(" log_bootstrap \"timed out waiting for codelia command\"".to_string()); + lines.push(" exit 1".to_string()); + lines.push(" fi".to_string()); + lines.push(" sleep 1".to_string()); + lines.push("done".to_string()); + lines.push("log_bootstrap \"starting runtime command\"".to_string()); + lines.push(format!("exec {remote_exec}")); + + lines.join("; ") +} + fn build_ssh_runtime_command() -> Result> { let host = env::var("CODELIA_RUNTIME_SSH_HOST") .map(|value| value.trim().to_string()) @@ -116,11 +215,12 @@ fn build_ssh_runtime_command() -> Result> { } let remote_exec = shell_join(&remote_cmd_parts); - let remote_script = env::var("CODELIA_RUNTIME_REMOTE_CWD") + let remote_cwd = env::var("CODELIA_RUNTIME_REMOTE_CWD") .ok() - .filter(|value| !value.trim().is_empty()) - .map(|cwd| format!("cd {} && {remote_exec}", shell_words::quote(&cwd))) - .unwrap_or(remote_exec); + .filter(|value| !value.trim().is_empty()); + let bootstrap_options = resolve_remote_bootstrap_options(); + let remote_script = + build_remote_bootstrap_script(&remote_exec, remote_cwd.as_deref(), &bootstrap_options); let mut command = Command::new("ssh"); command.arg("-T"); @@ -475,7 +575,10 @@ pub fn send_session_history( #[cfg(test)] mod tests { - use super::{json_line, shell_join, split_args}; + use super::{ + build_remote_bootstrap_script, json_line, sanitize_cli_version_for_npm, shell_join, + split_args, RemoteBootstrapOptions, + }; use serde_json::json; #[test] @@ -559,4 +662,55 @@ mod tests { ]); assert!(joined.contains("'--flag=value with space'")); } + + #[test] + fn sanitize_cli_version_for_npm_accepts_semver_like() { + assert_eq!( + sanitize_cli_version_for_npm(" 0.1.13 "), + Some("0.1.13".to_string()) + ); + assert_eq!( + sanitize_cli_version_for_npm("1.2.3-beta.1+build"), + Some("1.2.3-beta.1+build".to_string()) + ); + } + + #[test] + fn sanitize_cli_version_for_npm_rejects_invalid_chars() { + assert_eq!(sanitize_cli_version_for_npm(""), None); + assert_eq!(sanitize_cli_version_for_npm("latest && rm -rf /"), None); + } + + #[test] + fn build_remote_bootstrap_script_uses_versioned_install() { + let script = build_remote_bootstrap_script( + "bun packages/runtime/src/index.ts", + Some("/srv/codelia"), + &RemoteBootstrapOptions { + target_cli_version: Some("0.1.13".to_string()), + ready_timeout_sec: 45, + }, + ); + assert!(script.contains("npm install -g ")); + assert!(script.contains("@codelia/cli@0.1.13")); + assert!(script.contains("deadline=$(( $(date +%s) + 45 ))")); + assert!(script.contains("cd /srv/codelia")); + assert!(script.contains("[bootstrap]")); + assert!(script.contains("exec bun packages/runtime/src/index.ts")); + } + + #[test] + fn build_remote_bootstrap_script_falls_back_to_unversioned_install() { + let script = build_remote_bootstrap_script( + "bun packages/runtime/src/index.ts", + None, + &RemoteBootstrapOptions { + target_cli_version: None, + ready_timeout_sec: 120, + }, + ); + assert!(script.contains("npm install -g ")); + assert!(script.contains("@codelia/cli")); + assert!(!script.contains("@codelia/cli@0.")); + } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 87c314ff..b0de59ed 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -149,11 +149,12 @@ fn print_basic_help() { println!(" --initial-message Queue initial prompt"); println!(" --initial-user-message Alias of --initial-message"); println!(" --debug-perf[=true|false] Enable perf panel"); - println!(" --runtime-transport Runtime process transport"); - println!(" --runtime-ssh-host SSH host/alias for remote runtime"); - println!(" --runtime-ssh-opts Extra SSH options string"); - println!(" --runtime-remote-cmd Remote runtime command"); - println!(" --runtime-remote-cwd Remote runtime working directory"); + println!(" --ssh Use SSH remote runtime host"); + println!(" --ssh-port SSH port"); + println!(" --ssh-identity SSH private key path (-i)"); + println!(" --ssh-option Additional SSH option (-o), repeatable"); + println!(" --remote-command Remote runtime command"); + println!(" --remote-cwd Remote runtime working directory"); } fn parse_resume_mode() -> ResumeMode { @@ -219,79 +220,138 @@ fn parse_initial_message_from_args( message.filter(|value| !value.trim().is_empty()) } -fn parse_runtime_cli_overrides() { - let mut args = env::args().skip(1).peekable(); +fn take_next_cli_value(args: &mut std::iter::Peekable) -> Option +where + I: Iterator, +{ + if let Some(next) = args.peek() { + if next.starts_with('-') { + return None; + } + } + args.next() +} + +fn parse_runtime_cli_overrides_from_args( + args: impl IntoIterator>, +) -> Vec<(&'static str, String)> { + let mut args = args + .into_iter() + .map(|arg| arg.as_ref().to_string()) + .peekable(); + let mut overrides = Vec::new(); + let mut ssh_parts: Vec = Vec::new(); + let mut ssh_mode = false; + while let Some(arg) = args.next() { - if let Some(value) = arg.strip_prefix("--runtime-transport=") { - unsafe { - env::set_var("CODELIA_RUNTIME_TRANSPORT", value); + if let Some(value) = arg.strip_prefix("--ssh=") { + if !value.trim().is_empty() { + overrides.push(("CODELIA_RUNTIME_SSH_HOST", value.to_string())); + ssh_mode = true; } continue; } - if arg == "--runtime-transport" { - if let Some(value) = args.next() { - unsafe { - env::set_var("CODELIA_RUNTIME_TRANSPORT", value); - } + if arg == "--ssh" { + if let Some(value) = take_next_cli_value(&mut args) { + overrides.push(("CODELIA_RUNTIME_SSH_HOST", value)); + ssh_mode = true; } continue; } - if let Some(value) = arg.strip_prefix("--runtime-ssh-host=") { - unsafe { - env::set_var("CODELIA_RUNTIME_SSH_HOST", value); + if let Some(value) = arg.strip_prefix("--ssh-port=") { + if !value.trim().is_empty() { + ssh_parts.push("-p".to_string()); + ssh_parts.push(value.to_string()); + ssh_mode = true; } continue; } - if arg == "--runtime-ssh-host" { - if let Some(value) = args.next() { - unsafe { - env::set_var("CODELIA_RUNTIME_SSH_HOST", value); - } + if arg == "--ssh-port" { + if let Some(value) = take_next_cli_value(&mut args) { + ssh_parts.push("-p".to_string()); + ssh_parts.push(value); + ssh_mode = true; } continue; } - if let Some(value) = arg.strip_prefix("--runtime-ssh-opts=") { - unsafe { - env::set_var("CODELIA_RUNTIME_SSH_OPTS", value); + if let Some(value) = arg.strip_prefix("--ssh-identity=") { + if !value.trim().is_empty() { + ssh_parts.push("-i".to_string()); + ssh_parts.push(value.to_string()); + ssh_mode = true; } continue; } - if arg == "--runtime-ssh-opts" { - if let Some(value) = args.next() { - unsafe { - env::set_var("CODELIA_RUNTIME_SSH_OPTS", value); - } + if arg == "--ssh-identity" { + if let Some(value) = take_next_cli_value(&mut args) { + ssh_parts.push("-i".to_string()); + ssh_parts.push(value); + ssh_mode = true; } continue; } - if let Some(value) = arg.strip_prefix("--runtime-remote-cmd=") { - unsafe { - env::set_var("CODELIA_RUNTIME_REMOTE_CMD", value); + if let Some(value) = arg.strip_prefix("--ssh-option=") { + if !value.trim().is_empty() { + ssh_parts.push("-o".to_string()); + ssh_parts.push(value.to_string()); + ssh_mode = true; } continue; } - if arg == "--runtime-remote-cmd" { - if let Some(value) = args.next() { - unsafe { - env::set_var("CODELIA_RUNTIME_REMOTE_CMD", value); - } + if arg == "--ssh-option" { + if let Some(value) = take_next_cli_value(&mut args) { + ssh_parts.push("-o".to_string()); + ssh_parts.push(value); + ssh_mode = true; } continue; } - if let Some(value) = arg.strip_prefix("--runtime-remote-cwd=") { - unsafe { - env::set_var("CODELIA_RUNTIME_REMOTE_CWD", value); + if let Some(value) = arg.strip_prefix("--remote-command=") { + overrides.push(("CODELIA_RUNTIME_REMOTE_CMD", value.to_string())); + ssh_mode = true; + continue; + } + if arg == "--remote-command" { + if let Some(value) = take_next_cli_value(&mut args) { + overrides.push(("CODELIA_RUNTIME_REMOTE_CMD", value)); + ssh_mode = true; } continue; } - if arg == "--runtime-remote-cwd" { - if let Some(value) = args.next() { - unsafe { - env::set_var("CODELIA_RUNTIME_REMOTE_CWD", value); - } + if let Some(value) = arg.strip_prefix("--remote-cwd=") { + overrides.push(("CODELIA_RUNTIME_REMOTE_CWD", value.to_string())); + ssh_mode = true; + continue; + } + if arg == "--remote-cwd" { + if let Some(value) = take_next_cli_value(&mut args) { + overrides.push(("CODELIA_RUNTIME_REMOTE_CWD", value)); + ssh_mode = true; } } } + + if ssh_mode { + overrides.push(("CODELIA_RUNTIME_TRANSPORT", "ssh".to_string())); + } + if !ssh_parts.is_empty() { + let joined = ssh_parts + .iter() + .map(|part| shell_words::quote(part).to_string()) + .collect::>() + .join(" "); + overrides.push(("CODELIA_RUNTIME_SSH_OPTS", joined)); + } + + overrides +} + +fn parse_runtime_cli_overrides() { + for (key, value) in parse_runtime_cli_overrides_from_args(env::args().skip(1)) { + unsafe { + env::set_var(key, value); + } + } } fn parse_bool_like(value: &str) -> Option { @@ -3006,7 +3066,8 @@ mod tests { use super::{ apply_lane_list_result, cli_flag_enabled_from_args, parse_basic_cli_mode_from_args, parse_initial_message_from_args, parse_resume_mode_from_args, - resolve_version_label_from_versions, BasicCliMode, ResumeMode, + parse_runtime_cli_overrides_from_args, resolve_version_label_from_versions, BasicCliMode, + ResumeMode, }; use crate::app::AppState; use serde_json::json; @@ -3086,6 +3147,40 @@ mod tests { ); } + #[test] + fn parse_runtime_cli_overrides_maps_ssh_flags_and_ignores_missing_values() { + let overrides = parse_runtime_cli_overrides_from_args([ + "--ssh", + "host-alias", + "--ssh-port", + "2222", + "--ssh-identity", + "/home/me/.ssh/id_ed25519", + "--ssh-option=StrictHostKeyChecking=yes", + "--ssh-option", + "ServerAliveInterval=15", + "--remote-command=remote run", + "--remote-cwd", + "/srv/app", + "--ssh-option", + "--debug", + ]); + assert_eq!( + overrides, + vec![ + ("CODELIA_RUNTIME_SSH_HOST", "host-alias".to_string()), + ("CODELIA_RUNTIME_REMOTE_CMD", "remote run".to_string()), + ("CODELIA_RUNTIME_REMOTE_CWD", "/srv/app".to_string()), + ("CODELIA_RUNTIME_TRANSPORT", "ssh".to_string()), + ( + "CODELIA_RUNTIME_SSH_OPTS", + "-p 2222 -i /home/me/.ssh/id_ed25519 -o 'StrictHostKeyChecking=yes' -o 'ServerAliveInterval=15'" + .to_string(), + ), + ] + ); + } + #[test] fn version_label_uses_cli_version_without_tui_suffix() { assert_eq!( diff --git a/packages/runtime/src/rpc/ui-requests.ts b/packages/runtime/src/rpc/ui-requests.ts index 3a5a5848..3a9d1214 100644 --- a/packages/runtime/src/rpc/ui-requests.ts +++ b/packages/runtime/src/rpc/ui-requests.ts @@ -76,5 +76,8 @@ export const requestUiClipboardRead = async ( state: RuntimeState, params: UiClipboardReadRequestParams, ): Promise => { + if (!state.uiCapabilities?.supports_clipboard_read) { + return null; + } return requestUi(state, "ui.clipboard.read", params); }; diff --git a/packages/runtime/tests/ui-requests.test.ts b/packages/runtime/tests/ui-requests.test.ts new file mode 100644 index 00000000..ff367495 --- /dev/null +++ b/packages/runtime/tests/ui-requests.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import type { RpcRequest } from "@codelia/protocol"; +import { requestUiClipboardRead } from "../src/rpc/ui-requests"; +import { RuntimeState } from "../src/runtime-state"; + +const captureStdoutRequests = () => { + const originalWrite = process.stdout.write.bind(process.stdout); + const requests: RpcRequest[] = []; + let buffer = ""; + + process.stdout.write = ((chunk: string | Uint8Array) => { + const text = + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + buffer += text; + let idx = buffer.indexOf("\n"); + while (idx >= 0) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (line) { + try { + const parsed = JSON.parse(line) as unknown; + if ( + typeof parsed === "object" && + parsed !== null && + "method" in parsed && + "id" in parsed + ) { + requests.push(parsed as RpcRequest); + } + } catch { + // ignore non-JSON lines + } + } + idx = buffer.indexOf("\n"); + } + return true; + }) as typeof process.stdout.write; + + return { + requests, + restore() { + process.stdout.write = originalWrite; + }, + }; +}; + +describe("ui clipboard request", () => { + test("returns null without sending when clipboard capability is missing", async () => { + const state = new RuntimeState(); + const capture = captureStdoutRequests(); + try { + const result = await requestUiClipboardRead(state, { + purpose: "image_attachment", + formats: ["image/png"], + }); + expect(result).toBeNull(); + expect(capture.requests).toHaveLength(0); + } finally { + capture.restore(); + } + }); + + test("sends ui.clipboard.read and resolves response when capability is enabled", async () => { + const state = new RuntimeState(); + state.setUiCapabilities({ supports_clipboard_read: true }); + const capture = captureStdoutRequests(); + try { + const responsePromise = requestUiClipboardRead(state, { + purpose: "image_attachment", + formats: ["image/png"], + max_bytes: 1024, + prompt: "Attach clipboard image", + }); + + expect(capture.requests).toHaveLength(1); + const request = capture.requests[0]; + expect(request.method).toBe("ui.clipboard.read"); + + state.resolveUiResponse({ + jsonrpc: "2.0", + id: request.id, + result: { + ok: true, + items: [], + }, + }); + + await expect(responsePromise).resolves.toEqual({ ok: true, items: [] }); + } finally { + capture.restore(); + } + }); +}); From a81c7d33c035d8265ef367c9440c984133ef07e3 Mon Sep 17 00:00:00 2001 From: kousw Date: Mon, 23 Feb 2026 02:16:16 +0900 Subject: [PATCH 3/6] fix(tui): harden ssh bootstrap script and option handling --- crates/tui/src/app/runtime/client.rs | 51 +++++++++++++++++++++++++--- crates/tui/src/main.rs | 19 ++++++++--- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/app/runtime/client.rs b/crates/tui/src/app/runtime/client.rs index 2af110ed..f95e1cd3 100644 --- a/crates/tui/src/app/runtime/client.rs +++ b/crates/tui/src/app/runtime/client.rs @@ -137,6 +137,7 @@ fn build_remote_bootstrap_script( remote_exec: &str, remote_cwd: Option<&str>, options: &RemoteBootstrapOptions, + enable_diagnostics: bool, ) -> String { let mut lines = vec![ "set -eu".to_string(), @@ -191,12 +192,15 @@ fn build_remote_bootstrap_script( lines.push(" sleep 1".to_string()); lines.push("done".to_string()); lines.push("log_bootstrap \"starting runtime command\"".to_string()); + if enable_diagnostics { + lines.push("export CODELIA_DIAGNOSTICS=1".to_string()); + } lines.push(format!("exec {remote_exec}")); - lines.join("; ") + lines.join("\n") } -fn build_ssh_runtime_command() -> Result> { +fn build_ssh_runtime_command(enable_diagnostics: bool) -> Result> { let host = env::var("CODELIA_RUNTIME_SSH_HOST") .map(|value| value.trim().to_string()) .ok() @@ -219,8 +223,12 @@ fn build_ssh_runtime_command() -> Result> { .ok() .filter(|value| !value.trim().is_empty()); let bootstrap_options = resolve_remote_bootstrap_options(); - let remote_script = - build_remote_bootstrap_script(&remote_exec, remote_cwd.as_deref(), &bootstrap_options); + let remote_script = build_remote_bootstrap_script( + &remote_exec, + remote_cwd.as_deref(), + &bootstrap_options, + enable_diagnostics, + ); let mut command = Command::new("ssh"); command.arg("-T"); @@ -252,7 +260,7 @@ fn build_ssh_runtime_command() -> Result> { pub fn spawn_runtime(enable_diagnostics: bool) -> RuntimeSpawnResult { let mut command = match resolve_transport_mode() { RuntimeTransportMode::Local => build_local_runtime_command(), - RuntimeTransportMode::Ssh => build_ssh_runtime_command()?, + RuntimeTransportMode::Ssh => build_ssh_runtime_command(enable_diagnostics)?, }; command @@ -731,6 +739,7 @@ mod tests { target_cli_version: Some("0.1.13".to_string()), ready_timeout_sec: 45, }, + false, ); assert!(script.contains("npm install -g ")); assert!(script.contains("@codelia/cli@0.1.13")); @@ -749,9 +758,41 @@ mod tests { target_cli_version: None, ready_timeout_sec: 120, }, + false, ); assert!(script.contains("npm install -g ")); assert!(script.contains("@codelia/cli")); assert!(!script.contains("@codelia/cli@0.")); } + + #[test] + fn build_remote_bootstrap_script_uses_newlines_for_control_flow() { + let script = build_remote_bootstrap_script( + "bun packages/runtime/src/index.ts", + None, + &RemoteBootstrapOptions { + target_cli_version: None, + ready_timeout_sec: 120, + }, + false, + ); + assert!(script.contains("\nif command -v codelia")); + assert!(script.contains("\nwhile true; do\n")); + assert!(!script.contains("then;")); + assert!(!script.contains("do;")); + } + + #[test] + fn build_remote_bootstrap_script_exports_diagnostics_when_enabled() { + let script = build_remote_bootstrap_script( + "bun packages/runtime/src/index.ts", + None, + &RemoteBootstrapOptions { + target_cli_version: None, + ready_timeout_sec: 120, + }, + true, + ); + assert!(script.contains("export CODELIA_DIAGNOSTICS=1")); + } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 73e9b966..d66a7491 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -339,9 +339,20 @@ fn parse_runtime_cli_overrides_from_args( overrides.push(("CODELIA_RUNTIME_TRANSPORT", "ssh".to_string())); } if !ssh_parts.is_empty() { - let joined = ssh_parts - .iter() - .map(|part| shell_words::quote(part).to_string()) + let defaults = vec![ + "-o".to_string(), + "BatchMode=yes".to_string(), + "-o".to_string(), + "StrictHostKeyChecking=yes".to_string(), + "-o".to_string(), + "ServerAliveInterval=15".to_string(), + "-o".to_string(), + "ServerAliveCountMax=3".to_string(), + ]; + let joined = defaults + .into_iter() + .chain(ssh_parts) + .map(|part| shell_words::quote(&part).to_string()) .collect::>() .join(" "); overrides.push(("CODELIA_RUNTIME_SSH_OPTS", joined)); @@ -3491,7 +3502,7 @@ mod tests { ("CODELIA_RUNTIME_TRANSPORT", "ssh".to_string()), ( "CODELIA_RUNTIME_SSH_OPTS", - "-p 2222 -i /home/me/.ssh/id_ed25519 -o 'StrictHostKeyChecking=yes' -o 'ServerAliveInterval=15'" + "-o 'BatchMode=yes' -o 'StrictHostKeyChecking=yes' -o 'ServerAliveInterval=15' -o 'ServerAliveCountMax=3' -p 2222 -i /home/me/.ssh/id_ed25519 -o 'StrictHostKeyChecking=yes' -o 'ServerAliveInterval=15'" .to_string(), ), ] From 5b475535a6cd2b833245354a630883f6fa72e341 Mon Sep 17 00:00:00 2001 From: kousw Date: Mon, 23 Feb 2026 02:47:57 +0900 Subject: [PATCH 4/6] fix(tui): make ssh bootstrap conditional and enforce ssh mode on malformed flag --- crates/tui/src/app/runtime/client.rs | 113 +++++++++++++++++---------- crates/tui/src/main.rs | 13 ++- 2 files changed, 82 insertions(+), 44 deletions(-) diff --git a/crates/tui/src/app/runtime/client.rs b/crates/tui/src/app/runtime/client.rs index f95e1cd3..7d775d7e 100644 --- a/crates/tui/src/app/runtime/client.rs +++ b/crates/tui/src/app/runtime/client.rs @@ -138,6 +138,7 @@ fn build_remote_bootstrap_script( remote_cwd: Option<&str>, options: &RemoteBootstrapOptions, enable_diagnostics: bool, + bootstrap_cli: bool, ) -> String { let mut lines = vec![ "set -eu".to_string(), @@ -152,46 +153,50 @@ fn build_remote_bootstrap_script( lines.push(format!("cd {}", shell_words::quote(cwd))); } - lines.push("if command -v codelia >/dev/null 2>&1; then".to_string()); - lines.push(" log_bootstrap \"found codelia on remote host\"".to_string()); - lines.push("else".to_string()); - lines.push(" if ! command -v npm >/dev/null 2>&1; then".to_string()); - lines.push(" log_bootstrap \"npm is required to install @codelia/cli\"".to_string()); - lines.push(" exit 1".to_string()); - lines.push(" fi".to_string()); - let install_target = options - .target_cli_version - .as_ref() - .map(|version| format!("@codelia/cli@{version}")) - .unwrap_or_else(|| "@codelia/cli".to_string()); - lines.push(format!( - " log_bootstrap \"installing {}\"", - install_target.replace('"', "\\\"") - )); - lines.push(format!( - " npm install -g {}", - shell_words::quote(&install_target) - )); - lines.push("fi".to_string()); - lines.push(format!( - "deadline=$(( $(date +%s) + {} ))", - options.ready_timeout_sec - )); - lines.push("while true; do".to_string()); - lines.push( - " if command -v codelia >/dev/null 2>&1 && codelia --version >/dev/null 2>&1; then" - .to_string(), - ); - lines.push(" log_bootstrap \"codelia ready\"".to_string()); - lines.push(" break".to_string()); - lines.push(" fi".to_string()); - lines.push(" if [ \"$(date +%s)\" -ge \"$deadline\" ]; then".to_string()); - lines.push(" log_bootstrap \"timed out waiting for codelia command\"".to_string()); - lines.push(" exit 1".to_string()); - lines.push(" fi".to_string()); - lines.push(" sleep 1".to_string()); - lines.push("done".to_string()); - lines.push("log_bootstrap \"starting runtime command\"".to_string()); + if bootstrap_cli { + lines.push("if command -v codelia >/dev/null 2>&1; then".to_string()); + lines.push(" log_bootstrap \"found codelia on remote host\"".to_string()); + lines.push("else".to_string()); + lines.push(" if ! command -v npm >/dev/null 2>&1; then".to_string()); + lines.push(" log_bootstrap \"npm is required to install @codelia/cli\"".to_string()); + lines.push(" exit 1".to_string()); + lines.push(" fi".to_string()); + let install_target = options + .target_cli_version + .as_ref() + .map(|version| format!("@codelia/cli@{version}")) + .unwrap_or_else(|| "@codelia/cli".to_string()); + lines.push(format!( + " log_bootstrap \"installing {}\"", + install_target.replace('"', "\\\"") + )); + lines.push(format!( + " npm install -g {}", + shell_words::quote(&install_target) + )); + lines.push("fi".to_string()); + lines.push(format!( + "deadline=$(( $(date +%s) + {} ))", + options.ready_timeout_sec + )); + lines.push("while true; do".to_string()); + lines.push( + " if command -v codelia >/dev/null 2>&1 && codelia --version >/dev/null 2>&1; then" + .to_string(), + ); + lines.push(" log_bootstrap \"codelia ready\"".to_string()); + lines.push(" break".to_string()); + lines.push(" fi".to_string()); + lines.push(" if [ \"$(date +%s)\" -ge \"$deadline\" ]; then".to_string()); + lines.push(" log_bootstrap \"timed out waiting for codelia command\"".to_string()); + lines.push(" exit 1".to_string()); + lines.push(" fi".to_string()); + lines.push(" sleep 1".to_string()); + lines.push("done".to_string()); + lines.push("log_bootstrap \"starting runtime command\"".to_string()); + } else { + lines.push("log_bootstrap \"starting runtime command (bootstrap skipped)\"".to_string()); + } if enable_diagnostics { lines.push("export CODELIA_DIAGNOSTICS=1".to_string()); } @@ -200,7 +205,7 @@ fn build_remote_bootstrap_script( lines.join("\n") } -fn build_ssh_runtime_command(enable_diagnostics: bool) -> Result> { +fn build_ssh_runtime_command(enable_diagnostics: bool, bootstrap_cli: bool) -> Result> { let host = env::var("CODELIA_RUNTIME_SSH_HOST") .map(|value| value.trim().to_string()) .ok() @@ -222,12 +227,14 @@ fn build_ssh_runtime_command(enable_diagnostics: bool) -> Result Result RuntimeSpawnResult { let mut command = match resolve_transport_mode() { RuntimeTransportMode::Local => build_local_runtime_command(), - RuntimeTransportMode::Ssh => build_ssh_runtime_command(enable_diagnostics)?, + RuntimeTransportMode::Ssh => build_ssh_runtime_command(enable_diagnostics, true)?, }; command @@ -740,6 +747,7 @@ mod tests { ready_timeout_sec: 45, }, false, + true, ); assert!(script.contains("npm install -g ")); assert!(script.contains("@codelia/cli@0.1.13")); @@ -759,6 +767,7 @@ mod tests { ready_timeout_sec: 120, }, false, + true, ); assert!(script.contains("npm install -g ")); assert!(script.contains("@codelia/cli")); @@ -775,6 +784,7 @@ mod tests { ready_timeout_sec: 120, }, false, + true, ); assert!(script.contains("\nif command -v codelia")); assert!(script.contains("\nwhile true; do\n")); @@ -792,7 +802,26 @@ mod tests { ready_timeout_sec: 120, }, true, + true, ); assert!(script.contains("export CODELIA_DIAGNOSTICS=1")); } + + #[test] + fn build_remote_bootstrap_script_skips_cli_bootstrap_when_disabled() { + let script = build_remote_bootstrap_script( + "./custom-runtime-entry", + None, + &RemoteBootstrapOptions { + target_cli_version: None, + ready_timeout_sec: 120, + }, + false, + false, + ); + assert!(script.contains("bootstrap skipped")); + assert!(!script.contains("npm install -g")); + assert!(!script.contains("while true; do")); + assert!(script.contains("exec ./custom-runtime-entry")); + } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index d66a7491..914e7d5b 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -249,16 +249,16 @@ fn parse_runtime_cli_overrides_from_args( while let Some(arg) = args.next() { if let Some(value) = arg.strip_prefix("--ssh=") { + ssh_mode = true; if !value.trim().is_empty() { overrides.push(("CODELIA_RUNTIME_SSH_HOST", value.to_string())); - ssh_mode = true; } continue; } if arg == "--ssh" { + ssh_mode = true; if let Some(value) = take_next_cli_value(&mut args) { overrides.push(("CODELIA_RUNTIME_SSH_HOST", value)); - ssh_mode = true; } continue; } @@ -3509,6 +3509,15 @@ mod tests { ); } + #[test] + fn parse_runtime_cli_overrides_sets_ssh_mode_even_without_host_value() { + let overrides = parse_runtime_cli_overrides_from_args(["--ssh", "--debug"]); + assert_eq!( + overrides, + vec![("CODELIA_RUNTIME_TRANSPORT", "ssh".to_string())] + ); + } + #[test] fn version_label_uses_cli_version_without_tui_suffix() { assert_eq!( From 2867cec7ee39284166d54990d44dd4777bbf5ab2 Mon Sep 17 00:00:00 2001 From: kousw Date: Mon, 23 Feb 2026 03:01:01 +0900 Subject: [PATCH 5/6] docs(specs): add new backlog items for Terminal-Bench support and remote runtime config layering policy --- docs/specs/backlog.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/specs/backlog.md b/docs/specs/backlog.md index c2348618..3a6b0c60 100644 --- a/docs/specs/backlog.md +++ b/docs/specs/backlog.md @@ -66,3 +66,8 @@ Implementation ideas and "nice-to-have" tasks that are not scheduled yet. - **B-029** Terminal-Bench support (Harbor integration + headless benchmark mode). Purpose: run reproducible terminal-agent evaluations against Terminal-Bench datasets and compare Codelia behavior over time. Notes: requires non-interactive permission policy design (`full-access` approval mode for benchmark runs, with `minimal`/`trusted` retained for normal usage), a headless CLI/runtime entrypoint, and ATIF trajectory export/validation. + +- **B-035** Remote runtime config layering policy (GUI/SSH): evaluate optional layered precedence similar to editor-remote setups. + Purpose: improve cross-device ergonomics when a local GUI controls a remote runtime, while keeping behavior predictable. + Candidate order: `remote project > remote global > (optional) local global`. + Notes: keep default simple (`remote project > remote global`), make local-global layer opt-in, and add explicit “config source” visibility in UI to avoid confusion/security surprises. From ad2ee13a435eda387a741b593f0598fda9547a3e Mon Sep 17 00:00:00 2001 From: kousw Date: Mon, 23 Feb 2026 03:36:21 +0900 Subject: [PATCH 6/6] fix(tui): forward approval-mode to ssh remote runtime --- crates/tui/src/app/runtime/client.rs | 52 +++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/app/runtime/client.rs b/crates/tui/src/app/runtime/client.rs index e5b97589..c137a63e 100644 --- a/crates/tui/src/app/runtime/client.rs +++ b/crates/tui/src/app/runtime/client.rs @@ -78,16 +78,20 @@ fn resolve_transport_mode() -> RuntimeTransportMode { } } +fn append_approval_mode_arg(parts: &mut Vec, approval_mode: Option<&str>) { + if let Some(mode) = approval_mode { + parts.push("--approval-mode".to_string()); + parts.push(mode.to_string()); + } +} + fn build_local_runtime_command(approval_mode: Option<&str>) -> Command { let runtime_cmd = env::var("CODELIA_RUNTIME_CMD").unwrap_or_else(|_| "bun".to_string()); let mut runtime_args = env::var("CODELIA_RUNTIME_ARGS") .map(|value| split_args(&value)) .unwrap_or_else(|_| vec!["packages/runtime/src/index.ts".to_string()]); - if let Some(mode) = approval_mode { - runtime_args.push("--approval-mode".to_string()); - runtime_args.push(mode.to_string()); - } + append_approval_mode_arg(&mut runtime_args, approval_mode); let mut command = Command::new(runtime_cmd); command.args(runtime_args); @@ -210,7 +214,11 @@ fn build_remote_bootstrap_script( lines.join("\n") } -fn build_ssh_runtime_command(enable_diagnostics: bool, bootstrap_cli: bool) -> Result> { +fn build_ssh_runtime_command( + enable_diagnostics: bool, + bootstrap_cli: bool, + approval_mode: Option<&str>, +) -> Result> { let host = env::var("CODELIA_RUNTIME_SSH_HOST") .map(|value| value.trim().to_string()) .ok() @@ -223,11 +231,13 @@ fn build_ssh_runtime_command(enable_diagnostics: bool, bootstrap_cli: bool) -> R .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "bun packages/runtime/src/index.ts".to_string()); - let remote_cmd_parts = split_args(&remote_cmd_raw); + let mut remote_cmd_parts = split_args(&remote_cmd_raw); if remote_cmd_parts.is_empty() { return Err("CODELIA_RUNTIME_REMOTE_CMD resolved to an empty command".into()); } + append_approval_mode_arg(&mut remote_cmd_parts, approval_mode); + let remote_exec = shell_join(&remote_cmd_parts); let remote_cwd = env::var("CODELIA_RUNTIME_REMOTE_CWD") .ok() @@ -275,7 +285,9 @@ pub fn spawn_runtime( ) -> RuntimeSpawnResult { let mut command = match resolve_transport_mode() { RuntimeTransportMode::Local => build_local_runtime_command(approval_mode), - RuntimeTransportMode::Ssh => build_ssh_runtime_command(enable_diagnostics, true)?, + RuntimeTransportMode::Ssh => { + build_ssh_runtime_command(enable_diagnostics, true, approval_mode)? + } }; command @@ -640,8 +652,8 @@ pub fn send_session_history( #[cfg(test)] mod tests { use super::{ - build_remote_bootstrap_script, json_line, sanitize_cli_version_for_npm, shell_join, - split_args, RemoteBootstrapOptions, + append_approval_mode_arg, build_remote_bootstrap_script, json_line, + sanitize_cli_version_for_npm, shell_join, split_args, RemoteBootstrapOptions, }; use serde_json::json; @@ -654,6 +666,28 @@ mod tests { ); } + #[test] + fn append_approval_mode_arg_adds_flag_pair() { + let mut parts = vec!["bun".to_string(), "packages/runtime/src/index.ts".to_string()]; + append_approval_mode_arg(&mut parts, Some("trusted")); + assert_eq!( + parts, + vec![ + "bun".to_string(), + "packages/runtime/src/index.ts".to_string(), + "--approval-mode".to_string(), + "trusted".to_string(), + ] + ); + } + + #[test] + fn append_approval_mode_arg_noop_when_missing() { + let mut parts = vec!["bun".to_string()]; + append_approval_mode_arg(&mut parts, None); + assert_eq!(parts, vec!["bun".to_string()]); + } + #[test] fn split_args_falls_back_when_quotes_are_unbalanced() { let args = split_args("node \"unterminated");