diff --git a/console/src/fixtures.ts b/console/src/fixtures.ts index 673f031..02c43bd 100644 --- a/console/src/fixtures.ts +++ b/console/src/fixtures.ts @@ -2,6 +2,7 @@ import type { AgentEndpointView, Deployment, FleetConfig, + RegistryConfig, RemoteConfig, RuntimeContext, } from "./types"; @@ -52,6 +53,30 @@ cwd = "/" status: "disconnected", }; +// Stand-in registry file so the browser build's "Edit config" opens a realistic +// `agents.toml` (one management entry + ordinary agent consoles). Mirrors +// FIXTURE_AGENTS; unlike the panel views this is the raw file, so tokens appear. +export const FIXTURE_REGISTRY_CONFIG: RegistryConfig = { + path: "~/.config/oab-studio/agents.toml", + text: `# OAB Studio — per-agent endpoint registry (agents.toml) +# One entry per /acp endpoint. Rules: names unique; at most one management = true +# (it backs the management console + the reverse-MCP grant). + +[[agent]] +name = "orca" +url = "wss://orca-acp.example/acp" +token = "…" +cwd = "/home/node" +management = true + +[[agent]] +name = "mira" +url = "wss://mira-acp.example/acp" +token = "…" +cwd = "/home/node" +`, +}; + // Stand-in data so the console renders without a live core. Mirrors the shape // studio-cp's `deploy_list` / `deploy_get` return. Swapped for the Tauri source // in the desktop shell (slice-2). diff --git a/console/src/main.ts b/console/src/main.ts index 2ef0b4c..6d81158 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -9,7 +9,12 @@ import { filterByMembers, deploymentKey, } from "./render"; -import type { Deployment, FleetConfig, RemoteConfig } from "./types"; +import type { + Deployment, + FleetConfig, + RegistryConfig, + RemoteConfig, +} from "./types"; import { createChatPanel, type ChatPanel } from "./chatPanel"; import { initAgentConsole, type AgentConsole } from "./agentConsole"; import { createPane, bindBackend, type Level } from "./log"; @@ -32,6 +37,9 @@ let activeCluster = DEFAULT_CLUSTER; let activeMembers: string[] = []; let fleetConfig: FleetConfig | null = null; let remoteConfig: RemoteConfig | null = null; +// The registry file (`agents.toml`) as the editor sees it — loaded lazily the +// first time "Edit config" opens it, and refreshed after a save. +let registryConfig: RegistryConfig | null = null; let agentConsole: AgentConsole | null = null; const roster = document.getElementById("roster"); @@ -242,7 +250,9 @@ function selectFleet(name: string): void { // One CodeMirror TOML editor, shared by both config files (which one is set by // `editorTarget`). Kept imperative (CM owns real DOM) and separate from the // re-rendered panels, so a background refresh never wipes an open editor. -type EditorTarget = "fleet" | "remote"; +// The remote panel's editor targets the registry (`agents.toml`) — the source of +// truth since slice 1 — not the deprecated `remote.toml`. +type EditorTarget = "fleet" | "registry"; let editorView: EditorView | null = null; let editorTarget: EditorTarget = "fleet"; @@ -252,15 +262,32 @@ function showEditorError(msg: string | null): void { editorError.hidden = !msg; } -function openEditor(target: EditorTarget): void { +async function openEditor(target: EditorTarget): Promise { if (!editorSection || !editorMount) return; editorTarget = target; - const isRemote = target === "remote"; - const doc = (isRemote ? remoteConfig?.text : fleetConfig?.text) ?? ""; - const path = (isRemote ? remoteConfig?.path : fleetConfig?.path) ?? ""; showEditorError(null); - if (editorTitleEl) - editorTitleEl.textContent = isRemote ? "edit remote.toml" : "edit fleets.toml"; + let doc = ""; + let path = ""; + let title = "edit fleets.toml"; + if (target === "registry") { + // Load `agents.toml` lazily (and re-read on each open, so an external edit + // or a prior save shows up). Missing file → the backend seeds it from the + // adopted legacy `remote.toml`, so the first save migrates it. + try { + registryConfig = await source.registryConfig(); + } catch (e) { + note("error", `config: agents.toml load failed — ${errText(e)}`); + return; + } + doc = registryConfig?.text ?? ""; + path = registryConfig?.path ?? ""; + title = "edit agents.toml"; + } else { + doc = fleetConfig?.text ?? ""; + path = fleetConfig?.path ?? ""; + title = "edit fleets.toml"; + } + if (editorTitleEl) editorTitleEl.textContent = title; if (editorPathEl) editorPathEl.textContent = path; editorView?.destroy(); editorView = new EditorView({ @@ -288,11 +315,14 @@ async function saveEditor(): Promise { showEditorError(null); try { // The backend validates the TOML and rejects (without writing) on error. - if (editorTarget === "remote") { - remoteConfig = await source.writeRemoteConfig(text); - if (remoteEl) renderRemote(remoteEl, remoteConfig); - note("info", "config: remote saved"); + if (editorTarget === "registry") { + registryConfig = await source.writeRegistryConfig(text); + note("info", "config: agents.toml saved"); closeEditor(); + // The registry drives both the management panel (its management entry) and + // the agent-console selector — refresh both to reflect the edit. + void refreshRemote(); + await agentConsole?.refresh(); } else { fleetConfig = await source.writeFleetConfig(text); if (configEl) renderFleetConfig(configEl, fleetConfig, activeFleet); @@ -317,7 +347,7 @@ if (configEl) { configEl.addEventListener("click", (ev) => { const target = ev.target as HTMLElement; if (target.closest('[data-action="edit-config"]')) { - openEditor("fleet"); + void openEditor("fleet"); return; } const btn = target.closest("[data-fleet]"); @@ -347,7 +377,8 @@ if (remoteEl) { remoteEl.addEventListener("click", (ev) => { const target = ev.target as HTMLElement; if (target.closest('[data-action="edit-remote-config"]')) { - openEditor("remote"); + // Edit the registry (`agents.toml`), not the deprecated `remote.toml`. + void openEditor("registry"); } else if (target.closest('[data-action="remote-connect"]')) { void remoteAction("connect"); } else if (target.closest('[data-action="remote-disconnect"]')) { diff --git a/console/src/source.ts b/console/src/source.ts index 6cd9037..7313fd5 100644 --- a/console/src/source.ts +++ b/console/src/source.ts @@ -2,6 +2,7 @@ import type { AgentEndpointView, Deployment, FleetConfig, + RegistryConfig, RemoteConfig, RuntimeContext, } from "./types"; @@ -9,6 +10,7 @@ import { FIXTURE_AGENTS, FIXTURE_DEPLOYMENTS, FIXTURE_FLEET_CONFIG, + FIXTURE_REGISTRY_CONFIG, FIXTURE_REMOTE_CONFIG, FIXTURE_RUNTIME_CONTEXT, } from "./fixtures"; @@ -32,10 +34,18 @@ export interface Source { namespace: string, cluster?: string, ): Promise; - // The remote reverse-MCP connection (Part B): its config/status, the raw - // `remote.toml` for the editor, and the explicit activate/deactivate actions. + // The remote reverse-MCP connection (Part B): the management endpoint's parsed + // url + live status for the panel. The editor now edits the registry + // (`agents.toml`, below); `writeRemoteConfig` remains for the legacy + // `remote.toml` path but is no longer wired to a button. remoteConfig(): Promise; writeRemoteConfig(text: string): Promise; + // The per-agent endpoint registry (`agents.toml`) as raw text for the editor. + // `registryConfig` is seeded from the adopted legacy `remote.toml` when the + // file is absent (first save migrates); `writeRegistryConfig` validates the + // structure (unique names, ≤1 management) and rejects without writing on error. + registryConfig(): Promise; + writeRegistryConfig(text: string): Promise; // Dial / tear down a connection. `agent` names a registry endpoint (an agent // console); omitted ⇒ the management endpoint (legacy single-console path). remoteConnect(agent?: string): Promise; @@ -78,6 +88,14 @@ export class MockSource implements Source { async writeRemoteConfig(text: string): Promise { return { ...structuredClone(FIXTURE_REMOTE_CONFIG), text }; } + async registryConfig(): Promise { + return structuredClone(FIXTURE_REGISTRY_CONFIG); + } + // Browser preview: no core, so "saving" echoes the text back — no persistence + // and no server-side structural validation. + async writeRegistryConfig(text: string): Promise { + return { ...structuredClone(FIXTURE_REGISTRY_CONFIG), text }; + } // Browser preview: no core to dial, so activate/deactivate are no-ops. async remoteConnect(): Promise {} async remoteDisconnect(): Promise {} @@ -140,6 +158,12 @@ export class TauriSource implements Source { async writeRemoteConfig(text: string): Promise { return this.invoke()("remote_config_write", { text }); } + async registryConfig(): Promise { + return this.invoke()("registry_config"); + } + async writeRegistryConfig(text: string): Promise { + return this.invoke()("registry_config_write", { text }); + } async remoteConnect(agent?: string): Promise { await this.invoke()("remote_connect", { agent }); } diff --git a/console/src/types.ts b/console/src/types.ts index 33bb2b2..666c84c 100644 --- a/console/src/types.ts +++ b/console/src/types.ts @@ -84,6 +84,15 @@ export interface RemoteConfig { status: string; } +// The per-agent endpoint registry file (`agents.toml`) as the editor sees it: +// its path + raw text. Mirrors src-tauri's `registry_config` command. Unlike the +// panel/selector views this carries the raw file, so it *does* include tokens — +// it is editor-only and never fed into `RemoteConfig` / `AgentEndpointView`. +export interface RegistryConfig { + path: string; + text: string; +} + // One entry of the per-agent endpoint registry — mirrors src-tauri's // `remote_agents` command (ADR agent-consoles, Parts B/C). It is the view an // agent-console selector renders: an identity (`name`), the dial target diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6ab2b31..cbcfafa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -409,6 +409,31 @@ async fn remote_config_write( remote_view(&remote).await } +/// The registry file (`agents.toml`) as `{ path, text }` for the editor. When the +/// file is absent it is seeded with the adopted legacy `remote.toml`, so opening +/// the editor migrates a single-endpoint setup into the multi-agent format on the +/// first save (see [`remote::read_registry_text`]). Tokens live in the file, so +/// this text is editor-only — it is never mixed into the panel/selector views, +/// which stay token-free. +#[tauri::command] +async fn registry_config() -> Result { + let text = remote::read_registry_text()?; + let path = remote::registry_path() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + Ok(json!({ "path": path, "text": text })) +} + +/// Persist the edited registry (`agents.toml`), **validating structure before it +/// writes** — parses, unique non-empty names, ≤1 `management` — so a bad edit +/// never lands. Returns the refreshed `{ path, text }`; the caller re-reads the +/// remote panel + agent selector to reflect the new registry. +#[tauri::command] +async fn registry_config_write(text: String) -> Result { + remote::write_registry_text(&text)?; + registry_config().await +} + /// Activate the remote connection (the explicit "Activate" button): dial `/acp` /// using the saved config and publish the `oab` tools to the attached agent. The /// core sidecar must be started first (it is what the tunnel relays to). @@ -550,6 +575,8 @@ pub fn run() { remote_config, remote_agents, remote_config_write, + registry_config, + registry_config_write, remote_connect, remote_disconnect, agent_prompt, diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index e5b081c..f0b475e 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -205,6 +205,43 @@ pub fn write_config_text(text: &str) -> Result<(), String> { std::fs::write(&p, text).map_err(|e| format!("write {}: {e}", p.display())) } +/// Raw text for the **registry** editor (`agents.toml`). Prefer the file; when it +/// is absent or empty, seed the editor with the *adopted* registry — the legacy +/// `remote.toml` rendered as `agents.toml` — so opening the editor migrates an +/// old single-endpoint setup into the new multi-agent format on first save. +/// Nothing configured anywhere → empty ("not configured"). +pub fn read_registry_text() -> Result { + let rp = registry_path()?; + match std::fs::read_to_string(&rp) { + Ok(s) if !s.trim().is_empty() => return Ok(s), + Ok(_) => {} // present but empty → seed from legacy below + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("read {}: {e}", rp.display())), + } + let reg = load_registry()?; + if reg.agents.is_empty() { + Ok(String::new()) + } else { + Ok(reg.to_toml()) + } +} + +/// Persist the edited registry, **validating structure first** so a bad edit +/// never lands (mirroring the fleets.toml / remote.toml editors): it must parse, +/// every `[[agent]]` needs a unique non-empty name, and at most one may carry +/// `management = true`. Per-endpoint url/token completeness is deliberately *not* +/// enforced here — a half-filled entry can be saved and is only checked at dial +/// time, exactly as [`AgentRegistry::validate`] documents. +pub fn write_registry_text(text: &str) -> Result<(), String> { + let reg = AgentRegistry::parse(text).map_err(|e| format!("invalid TOML: {e}"))?; + reg.validate().map_err(|e| format!("invalid agents.toml: {e}"))?; + let p = registry_path()?; + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + } + std::fs::write(&p, text).map_err(|e| format!("write {}: {e}", p.display())) +} + fn load_config() -> Result { RemoteConfig::parse(&read_config_text()?).map_err(|e| format!("invalid remote.toml: {e}")) } @@ -871,3 +908,53 @@ where .await .map_err(|e| format!("ws write: {e}")) } + +#[cfg(test)] +mod tests { + use super::*; + + // `write_registry_text` validates before it ever resolves a path or touches + // the filesystem, so the reject paths are safe to exercise in a unit test + // (a green run proves a bad edit never lands). + #[test] + fn write_registry_text_rejects_bad_toml() { + let err = write_registry_text("this is = not valid toml [[[").unwrap_err(); + assert!(err.contains("invalid TOML"), "got: {err}"); + } + + #[test] + fn write_registry_text_rejects_two_managements() { + let toml = r#" +[[agent]] +name = "a" +url = "wss://a/acp" +token = "t" +management = true + +[[agent]] +name = "b" +url = "wss://b/acp" +token = "t" +management = true +"#; + let err = write_registry_text(toml).unwrap_err(); + assert!(err.contains("management"), "got: {err}"); + } + + #[test] + fn write_registry_text_rejects_duplicate_names() { + let toml = r#" +[[agent]] +name = "dup" +url = "wss://a/acp" +token = "t" + +[[agent]] +name = "dup" +url = "wss://b/acp" +token = "t" +"#; + let err = write_registry_text(toml).unwrap_err(); + assert!(err.contains("unique") || err.contains("duplicate"), "got: {err}"); + } +}