Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions console/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
AgentEndpointView,
Deployment,
FleetConfig,
RegistryConfig,
RemoteConfig,
RuntimeContext,
} from "./types";
Expand Down Expand Up @@ -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).
Expand Down
59 changes: 45 additions & 14 deletions console/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -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";

Expand All @@ -252,15 +262,32 @@ function showEditorError(msg: string | null): void {
editorError.hidden = !msg;
}

function openEditor(target: EditorTarget): void {
async function openEditor(target: EditorTarget): Promise<void> {
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({
Expand Down Expand Up @@ -288,11 +315,14 @@ async function saveEditor(): Promise<void> {
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);
Expand All @@ -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<HTMLElement>("[data-fleet]");
Expand Down Expand Up @@ -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"]')) {
Expand Down
28 changes: 26 additions & 2 deletions console/src/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import type {
AgentEndpointView,
Deployment,
FleetConfig,
RegistryConfig,
RemoteConfig,
RuntimeContext,
} from "./types";
import {
FIXTURE_AGENTS,
FIXTURE_DEPLOYMENTS,
FIXTURE_FLEET_CONFIG,
FIXTURE_REGISTRY_CONFIG,
FIXTURE_REMOTE_CONFIG,
FIXTURE_RUNTIME_CONTEXT,
} from "./fixtures";
Expand All @@ -32,10 +34,18 @@ export interface Source {
namespace: string,
cluster?: string,
): Promise<void>;
// 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<RemoteConfig>;
writeRemoteConfig(text: string): Promise<RemoteConfig>;
// 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<RegistryConfig>;
writeRegistryConfig(text: string): Promise<RegistryConfig>;
// 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<void>;
Expand Down Expand Up @@ -78,6 +88,14 @@ export class MockSource implements Source {
async writeRemoteConfig(text: string): Promise<RemoteConfig> {
return { ...structuredClone(FIXTURE_REMOTE_CONFIG), text };
}
async registryConfig(): Promise<RegistryConfig> {
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<RegistryConfig> {
return { ...structuredClone(FIXTURE_REGISTRY_CONFIG), text };
}
// Browser preview: no core to dial, so activate/deactivate are no-ops.
async remoteConnect(): Promise<void> {}
async remoteDisconnect(): Promise<void> {}
Expand Down Expand Up @@ -140,6 +158,12 @@ export class TauriSource implements Source {
async writeRemoteConfig(text: string): Promise<RemoteConfig> {
return this.invoke()<RemoteConfig>("remote_config_write", { text });
}
async registryConfig(): Promise<RegistryConfig> {
return this.invoke()<RegistryConfig>("registry_config");
}
async writeRegistryConfig(text: string): Promise<RegistryConfig> {
return this.invoke()<RegistryConfig>("registry_config_write", { text });
}
async remoteConnect(agent?: string): Promise<void> {
await this.invoke()<unknown>("remote_connect", { agent });
}
Expand Down
9 changes: 9 additions & 0 deletions console/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value, String> {
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<Value, String> {
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).
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions src-tauri/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> {
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, String> {
RemoteConfig::parse(&read_config_text()?).map_err(|e| format!("invalid remote.toml: {e}"))
}
Expand Down Expand Up @@ -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}");
}
}
Loading