diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index adbf57ea19..1817f2d880 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -277,6 +277,8 @@ pub fn bundled_manifests() -> Vec { .expect("codex manifest"), serde_json::from_str(include_str!("../../../src/bundled/goose/manifest.json")) .expect("goose manifest"), + serde_json::from_str(include_str!("../../../src/bundled/acp/manifest.json")) + .expect("acp manifest"), serde_json::from_str(include_str!("../../../src/bundled/workflows/manifest.json")) .expect("workflows manifest"), serde_json::from_str(include_str!("../../../src/bundled/feedback/manifest.json")) diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 41c5adef90..6ce76ea6bb 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -720,7 +720,7 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() { .any(|plugin| plugin.manifest.id == "buzz.me")); for plugin in &catalog.plugins { let id = plugin.manifest.id.as_str(); - let default = !matches!(id, "buzz.bestie" | "buzz.todos"); + let default = !matches!(id, "buzz.acp" | "buzz.bestie" | "buzz.todos"); assert_eq!(plugin.enabled, default, "{id}"); if id == "buzz.channels" { assert!(manager.change("disable", id).is_err()); diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 441b1e6c7a..8516d50fa4 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -70,7 +70,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", ); assert.equal( plugin.enabledByDefault, - !["buzz.bestie", "buzz.todos"].includes(plugin.manifest.id), + !["buzz.acp", "buzz.bestie", "buzz.todos"].includes(plugin.manifest.id), plugin.manifest.id, ); } diff --git a/src/bundled/acp/config.test.ts b/src/bundled/acp/config.test.ts new file mode 100644 index 0000000000..5cacdf0723 --- /dev/null +++ b/src/bundled/acp/config.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { + adopt, + approve, + approved, + carry, + config, + DEFAULT_CONFIG, + digest, + launch, + problems, + UNCONFIRMED, + withPreset, + type Config, +} from "./config"; +import { PRESETS } from "./presets"; + +const base: Config = { + ...DEFAULT_CONFIG, + command: "cursor-agent", + args: ["acp"], + env: { CURSOR_API_KEY: "secret" }, +}; + +describe("problems", () => { + it("accepts a complete launch", () => { + expect(problems(base)).toEqual([]); + }); + + it.each([ + ["", "Enter the command"], + [" ", "Enter the command"], + ["-i", "can't start with `-`"], + ["FOO=bar", "can't contain `=`"], + ])("rejects the command %j", (command, message) => { + expect(problems({ ...base, command })[0]).toContain(message); + }); + + it.each([ + ["command", { command: "a\0b" }], + ["an arg", { args: ["ok", "a\0b"] }], + ["an env value", { env: { KEY: "a\0b" } }], + ["the workspace", { workspace: "/tmp\0" }], + ])("rejects NUL in %s", (_, change) => { + expect(problems({ ...base, ...change })).toContain( + "Settings can't contain NUL characters.", + ); + }); + + it.each(["relative/folder", "./here", "~user/x"])( + "rejects the workspace %j", + (workspace) => { + expect(problems({ ...base, workspace })).toContain( + "The workspace must be an absolute path or start with `~/`.", + ); + }, + ); + + it.each([["acp", ""], ["a\nb"]])("rejects the args %j", (...args) => { + expect(problems({ ...base, args })).toContain( + "Arguments can't be empty or span lines.", + ); + }); + + it.each(["1KEY", "A-B", "", "A B"])("rejects the env name %j", (name) => { + expect(problems({ ...base, env: { [name]: "x" } })[0]).toContain( + "isn't a valid environment variable name", + ); + }); + + it.each(["BUZZ_PRIVATE_KEY", "BUZZ_X", "NOSTR_KEY", "buzz_relay_url"])( + "rejects the reserved env name %s", + (name) => { + expect(problems({ ...base, env: { [name]: "x" } })[0]).toContain( + "is reserved for Buzz", + ); + }, + ); + + it("allows names that only contain BUZZ", () => { + expect(problems({ ...base, env: { MY_BUZZ_X: "x" } })).toEqual([]); + }); +}); + +describe("approval", () => { + it("holds for the confirmed launch, whatever the env order", async () => { + const confirmed = await approve({ ...base, env: { A: "1", B: "2" } }); + expect(await approved(confirmed)).toBe(true); + expect(await approved({ ...confirmed, env: { B: "2", A: "1" } })).toBe( + true, + ); + }); + + it("is not needed for instructions", async () => { + const confirmed = await approve(base); + expect(await approved({ ...confirmed, instructions: "be brief" })).toBe( + true, + ); + }); + + it.each<[string, Partial]>([ + ["the command", { command: "cursor" }], + ["an arg", { args: ["acp", "--x"] }], + ["the arg order", { args: ["b", "a"] }], + ["an arg split", { args: ["a b"] }], + ["an env value", { env: { CURSOR_API_KEY: "other" } }], + ["an env name", { env: { OTHER_KEY: "secret" } }], + ["an added env var", { env: { CURSOR_API_KEY: "secret", PATH: "/x" } }], + ["the workspace", { workspace: "/tmp" }], + ])("is lost when %s changes", async (_, change) => { + const confirmed = await approve({ ...base, args: ["a", "b"] }); + expect(await approved({ ...confirmed, ...change })).toBe(false); + }); + + it("is lost on a preset switch, even to the same command", async () => { + const confirmed = await approve(withPreset(base, "cursor")); + expect(await approved(confirmed)).toBe(true); + const switched = withPreset(confirmed, "cursor"); + expect(switched.approval).toBe(""); + expect(await approved(switched)).toBe(false); + }); + + it("is refused for an invalid launch", async () => { + await expect(approve({ ...base, command: "" })).rejects.toThrow(); + }); + + it("does not hold for an empty or forged approval", async () => { + expect(await approved(base)).toBe(false); + expect(await approved({ ...base, approval: "00".repeat(32) })).toBe(false); + }); +}); + +describe("launch", () => { + it("returns the confirmed launch", async () => { + const stored = await approve(base); + expect(await launch(stored)).toEqual({ + launch: { + command: "cursor-agent", + args: ["acp"], + env: { CURSOR_API_KEY: "secret" }, + workspace: "~/.buzz", + }, + }); + }); + + it("refuses an unconfirmed or changed launch", async () => { + expect(await launch(base)).toEqual({ error: UNCONFIRMED }); + const stored = await approve(base); + expect(await launch({ ...stored, args: ["other"] })).toEqual({ + error: UNCONFIRMED, + }); + }); + + it("refuses a reserved env name even with a matching digest", async () => { + const forged = { ...base, env: { BUZZ_PRIVATE_KEY: "x" } }; + const stored = { ...forged, approval: await digest(forged) }; + expect(await launch(stored)).toEqual({ + error: expect.stringContaining("reserved for Buzz"), + }); + }); + + it("reads a malformed stored config as unconfirmed defaults", async () => { + expect(config("junk")).toEqual(DEFAULT_CONFIG); + expect( + config({ args: ["a", 1], env: { A: "1", B: 2 }, command: 3 }), + ).toMatchObject({ args: ["a"], env: { A: "1" }, command: "" }); + expect(await launch(null)).toEqual({ + error: "Enter the command that starts the harness.", + }); + }); +}); + +describe("presets", () => { + it("are all unverified and valid", () => { + for (const p of PRESETS) { + expect(p.verified).toBe(false); + expect(problems(withPreset(DEFAULT_CONFIG, p.id))).toEqual([]); + } + }); +}); + +describe("carry and adopt", () => { + it("carries only the instructions", async () => { + const saved = await approve({ ...base, instructions: "Triage." }); + expect(carry(saved)).toEqual({ instructions: "Triage." }); + expect(carry({ ...saved, instructions: " " })).toEqual({}); + }); + + it("can't launch an adopted config until it is confirmed again", async () => { + const confirmed = await approve(base); + const adopted = adopt( + { instructions: "Triage.", workspace: "/elsewhere" }, + confirmed, + ); + expect(adopted).toMatchObject({ + instructions: "Triage.", + workspace: base.workspace, + approval: "", + }); + expect(await launch(adopted)).toEqual({ error: UNCONFIRMED }); + expect("launch" in (await launch(await approve(adopted)))).toBe(true); + }); +}); diff --git a/src/bundled/acp/config.ts b/src/bundled/acp/config.ts new file mode 100644 index 0000000000..ee463f8d58 --- /dev/null +++ b/src/bundled/acp/config.ts @@ -0,0 +1,181 @@ +// An ACP agent's settings, their validation, and the owner's approval of the +// exact command they launch. Everything here stays on this device: `carry` +// passes on only the instructions. +import type { Carry } from "../../features/agents/native"; +import { CUSTOM, preset } from "./presets"; + +export type Config = Readonly<{ + /** A preset id, or `custom`. */ + preset: string; + /** A program name or absolute path, run by `env` with no shell. */ + command: string; + args: readonly string[]; + /** Extra environment variables for the harness. */ + env: Readonly>; + /** The harness's working directory, absolute or `~/…`. */ + workspace: string; + instructions: string; + /** The digest of the launch the owner last confirmed; empty when none. */ + approval: string; +}>; + +export const DEFAULT_CONFIG: Config = Object.freeze({ + preset: CUSTOM, + command: "", + args: [], + env: {}, + workspace: "~/.buzz", + instructions: "", + approval: "", +}); + +const strings = (value: unknown): string[] => + Array.isArray(value) ? value.filter((v) => typeof v === "string") : []; + +/** Reads a stored config, dropping anything of the wrong shape. */ +export function config(value: unknown): Config { + const saved = (value && typeof value === "object" ? value : {}) as Partial< + Record + >; + const text = (key: keyof Config) => + typeof saved[key] === "string" + ? (saved[key] as string) + : (DEFAULT_CONFIG[key] as string); + const env = + saved.env && typeof saved.env === "object" && !Array.isArray(saved.env) + ? Object.fromEntries( + Object.entries(saved.env).filter(([, v]) => typeof v === "string"), + ) + : {}; + return { + preset: text("preset"), + command: text("command"), + args: strings(saved.args), + env, + workspace: text("workspace"), + instructions: text("instructions"), + approval: text("approval"), + }; +} + +/** Switches to a preset: its command and args replace the current ones, and + * the approval goes until the owner confirms again. */ +export function withPreset(current: Config, id: string): Config { + const chosen = preset(id); + return { + ...current, + preset: chosen ? chosen.id : CUSTOM, + ...(chosen && { command: chosen.command, args: [...chosen.args] }), + approval: "", + }; +} + +/** The forms the native host accepts for a process directory. */ +const ABSOLUTE = /^(\/|~$|~\/)/; +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +/** Names reserved for the agent's identity; the host strips inherited ones but + * lets a caller set them, so a harness must never get them from settings. */ +const RESERVED = /^(BUZZ_|NOSTR_)/i; + +/** What's wrong with a config's launch settings, in order; empty when it can + * launch. */ +export function problems(value: Config): string[] { + const found: string[] = []; + const { command, args, env, workspace } = value; + if (!command.trim()) found.push("Enter the command that starts the harness."); + else if (command.startsWith("-")) + found.push("The command can't start with `-`."); + else if (command.includes("=")) found.push("The command can't contain `=`."); + if (!workspace.trim()) found.push("Choose a workspace."); + else if (!ABSOLUTE.test(workspace)) + found.push("The workspace must be an absolute path or start with `~/`."); + if (args.some((arg) => arg === "" || arg.includes("\n"))) + found.push("Arguments can't be empty or span lines."); + const fields = [command, workspace, ...args, ...Object.entries(env).flat()]; + if (fields.some((field) => field.includes("\0"))) + found.push("Settings can't contain NUL characters."); + for (const name of Object.keys(env)) { + if (!ENV_NAME.test(name)) + found.push(`\`${name}\` isn't a valid environment variable name.`); + else if (RESERVED.test(name)) + found.push(`\`${name}\` is reserved for Buzz and can't be set here.`); + } + return found; +} + +/** The launch an approval covers, in one fixed order: command, args in order, + * env sorted by name with values, and workspace. JSON gives each string one + * unambiguous encoding, so no two launches encode the same. */ +function canonical({ command, args, env, workspace }: Config) { + const vars = Object.entries(env).sort(([a], [b]) => (a < b ? -1 : 1)); + return JSON.stringify([ + "buzz.acp/approval/1", + command, + args, + vars, + workspace, + ]); +} + +/** The SHA-256 digest, in hex, of the launch `value` would run. It holds no + * secret: anything that writes the stored config can write a matching one. + * If config sync or import ever carries the launch fields, this must become + * an HMAC with a device-local key first. */ +export async function digest(value: Config): Promise { + const bytes = new TextEncoder().encode(canonical(value)); + const hash = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(hash), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); +} + +/** Records the owner's confirmation of `value`'s launch as it stands. */ +export async function approve(value: Config): Promise { + const [problem] = problems(value); + if (problem) throw new Error(problem); + return { ...value, approval: await digest(value) }; +} + +/** Whether the owner has confirmed exactly this launch. */ +export async function approved(value: Config): Promise { + return value.approval !== "" && value.approval === (await digest(value)); +} + +export type Launch = Readonly<{ + command: string; + args: readonly string[]; + env: Readonly>; + workspace: string; +}>; + +export const UNCONFIRMED = "Confirm this agent's command in its settings."; + +/** The launch to run, from one read of the stored config, or why it can't. */ +export async function launch( + stored: unknown, +): Promise<{ launch: Launch } | { error: string }> { + const value = config(stored); + const [problem] = problems(value); + if (problem) return { error: problem }; + if (!(await approved(value))) return { error: UNCONFIRMED }; + const { command, args, env, workspace } = value; + return { launch: { command, args, env, workspace } }; +} + +/** The env values safe to show: only `PATH`, which decides what `env` runs. */ +export const shownValue = (name: string, value: string) => + name === "PATH" ? value : undefined; + +/** What another agent type may take: the instructions, never the launch. */ +export function carry(saved: unknown): Carry { + const { instructions } = config(saved); + return instructions.trim() ? { instructions } : {}; +} + +/** A brought-over agent's config: its instructions, and no approval, so it + * can't launch until the owner confirms its command here. */ +export const adopt = (from: Carry, saved: Config): Config => ({ + ...saved, + instructions: from.instructions ?? saved.instructions, + approval: "", +}); diff --git a/src/bundled/acp/index.ts b/src/bundled/acp/index.ts new file mode 100644 index 0000000000..a59ed61fe9 --- /dev/null +++ b/src/bundled/acp/index.ts @@ -0,0 +1,57 @@ +// ACP agents: an agent type for any harness that speaks the Agent Client +// Protocol, started from a preset or a command the owner types and confirms. +import { isTauri } from "@tauri-apps/api/core"; +import type { PluginModule } from "../../plugins/api"; +import { + adopt, + carry, + type Config, + config, + DEFAULT_CONFIG, + launch, +} from "./config"; +import { preset } from "./presets"; +import { createTabs } from "./tabs"; + +export const TYPE_ID = "acp"; + +export const inject = ["agents"]; +export const apply: PluginModule["apply"] = (ctx) => { + if (!isTauri()) return; + const { SettingsTab, HistoryTab } = createTabs({ + // Until the runtime lands, Check verifies only what launch would refuse. + check: async (value) => { + const found = await launch(value); + return "error" in found + ? found.error + : "The settings are valid. Starting the harness to check it isn't available yet."; + }, + }); + ctx.agents.register({ + id: TYPE_ID, + title: "ACP agent", + description: + "Any harness that speaks the Agent Client Protocol, from a preset or a command you confirm.", + defaults: () => ({ config: DEFAULT_CONFIG }), + adopt, + carry, + summary: (agent) => { + const value = config(agent.config); + return preset(value.preset)?.title ?? (value.command || "ACP agent"); + }, + tabs: [ + { + id: "settings", + title: "Settings", + role: "settings", + component: SettingsTab, + }, + { + id: "history", + title: "History", + role: "activity", + component: HistoryTab, + }, + ], + }); +}; diff --git a/src/bundled/acp/manifest.json b/src/bundled/acp/manifest.json new file mode 100644 index 0000000000..effd2bab19 --- /dev/null +++ b/src/bundled/acp/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "buzz.acp", + "name": "ACP agents", + "apiVersion": 1, + "description": "Run any harness that speaks the Agent Client Protocol in Buzz.", + "discovery": { "category": "agents" }, + "host": { + "processes": [{ "id": "harness", "program": "env" }], + "mcp": true + } +} diff --git a/src/bundled/acp/presets.ts b/src/bundled/acp/presets.ts new file mode 100644 index 0000000000..f7dcade123 --- /dev/null +++ b/src/bundled/acp/presets.ts @@ -0,0 +1,95 @@ +// Long-tail harnesses an owner can start from. Choosing one prefills the +// command and args; nothing here is launched until the owner confirms it. +// A preset counts as verified only after an authenticated `session/new` and a +// Buzz tool call over HTTP MCP have passed through the desktop app. + +export type Preset = Readonly<{ + id: string; + title: string; + command: string; + args: readonly string[]; + /** How to install or set up the harness, shown when its program is missing. */ + install: string; + /** How to sign in, shown when a turn fails on login. */ + login: string; + verified: boolean; +}>; + +export const CUSTOM = "custom"; + +export const PRESETS: readonly Preset[] = [ + { + id: "cursor", + title: "Cursor", + command: "cursor-agent", + args: ["acp"], + install: "Install the Cursor CLI: https://cursor.com/cli", + login: "Run `cursor-agent login`.", + verified: false, + }, + { + id: "opencode", + title: "OpenCode", + command: "opencode", + args: ["acp"], + install: "Install OpenCode: https://opencode.ai", + login: "Run `opencode auth login`.", + verified: false, + }, + { + id: "amp", + title: "Amp", + command: "amp-acp", + args: [], + install: "Install `amp` and the `amp-acp` adapter.", + login: "Run `amp-acp --setup` to set your API key.", + verified: false, + }, + { + id: "gemini", + title: "Gemini CLI", + command: "gemini", + args: ["--experimental-acp"], + install: "Install Gemini CLI: https://github.com/google-gemini/gemini-cli", + login: "Run `gemini` once to sign in with Google, or set `GEMINI_API_KEY`.", + verified: false, + }, + { + id: "devin", + title: "Devin", + command: "devin", + args: ["acp"], + install: "Install the Devin CLI.", + login: "Run `devin` once to sign in in your browser.", + verified: false, + }, + { + id: "grok", + title: "Grok Build", + command: "grok", + args: ["agent", "--always-approve", "stdio"], + install: "Install the Grok CLI.", + login: "Run `grok` once to sign in.", + verified: false, + }, + { + id: "kimi", + title: "Kimi Code", + command: "kimi", + args: ["acp"], + install: "Install Kimi Code: https://code.kimi.com", + login: "Run `kimi` once to sign in.", + verified: false, + }, + { + id: "omp", + title: "Oh My Pi", + command: "omp", + args: ["acp"], + install: "Install Oh My Pi; it needs Bun on your PATH.", + login: "Run `omp` once to sign in.", + verified: false, + }, +]; + +export const preset = (id: string) => PRESETS.find((p) => p.id === id); diff --git a/src/bundled/acp/tabs.test.tsx b/src/bundled/acp/tabs.test.tsx new file mode 100644 index 0000000000..f6c755cedd --- /dev/null +++ b/src/bundled/acp/tabs.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { Agent } from "../../features/agents/service"; +import { approved, type Config, DEFAULT_CONFIG } from "./config"; +import { createTabs } from "./tabs"; + +afterEach(cleanup); + +const start: Config = { + ...DEFAULT_CONFIG, + command: "cursor-agent", + args: ["acp"], + env: { CURSOR_API_KEY: "hunter2", PATH: "/opt/bin" }, +}; + +function mount(check = vi.fn(async () => "Ready."), initial = start) { + const { SettingsTab } = createTabs({ check }); + const saves: Config[] = []; + function Host() { + const [config, setConfig] = useState(initial); + return ( + } + save={async (next) => { + saves.push(next); + setConfig(next); + }} + /> + ); + } + render(); + return { saves, check }; +} + +it("confirms the exact launch without showing env values other than PATH", async () => { + const { saves, check } = mount(); + expect(screen.getByRole("button", { name: "Check" })).toBeDisabled(); + await userEvent.click( + screen.getByRole("button", { name: "Confirm command" }), + ); + const dialog = await screen.findByRole("dialog"); + expect(dialog).toHaveTextContent('["cursor-agent","acp"]'); + expect(dialog).toHaveTextContent("CURSOR_API_KEY"); + expect(dialog).toHaveTextContent("PATH=/opt/bin"); + expect(dialog).not.toHaveTextContent("hunter2"); + await userEvent.click(screen.getByRole("button", { name: "Confirm" })); + await waitFor(() => expect(saves).toHaveLength(1)); + expect(await approved(saves[0] as Config)).toBe(true); + const checkButton = screen.getByRole("button", { name: "Check" }); + await waitFor(() => expect(checkButton).toBeEnabled()); + await userEvent.click(checkButton); + expect(check).toHaveBeenCalledWith(saves[0]); + expect(await screen.findByRole("status")).toHaveTextContent("Ready."); +}); + +it("drops the approval when a launch setting is saved changed", async () => { + const { saves } = mount(); + await userEvent.click( + screen.getByRole("button", { name: "Confirm command" }), + ); + await userEvent.click(await screen.findByRole("button", { name: "Confirm" })); + await waitFor(() => expect(saves).toHaveLength(1)); + const args = screen.getByRole("textbox", { name: "Arguments" }); + await userEvent.type(args, "\n--force"); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(saves).toHaveLength(2)); + expect(saves[1]).toMatchObject({ args: ["acp", "--force"], approval: "" }); + expect(screen.getByRole("button", { name: "Check" })).toBeDisabled(); +}); + +it("won't save a reserved env name", async () => { + mount(); + await userEvent.click(screen.getByRole("button", { name: "Add variable" })); + const names = screen.getAllByRole("textbox", { name: "Name" }); + await userEvent.type(names.at(-1) as HTMLElement, "BUZZ_PRIVATE_KEY"); + expect(screen.getByRole("alert")).toHaveTextContent("reserved for Buzz"); + expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute( + "aria-disabled", + "true", + ); +}); + +it("needs a new confirmation after switching preset away and back", async () => { + const { saves } = mount(); + await userEvent.click( + screen.getByRole("button", { name: "Confirm command" }), + ); + await userEvent.click(await screen.findByRole("button", { name: "Confirm" })); + await waitFor(() => expect(saves).toHaveLength(1)); + const check = screen.getByRole("button", { name: "Check" }); + await waitFor(() => expect(check).toBeEnabled()); + for (const name of [/OpenCode/, /Cursor/]) { + await userEvent.click(screen.getByRole("combobox", { name: "Harness" })); + await userEvent.click(await screen.findByRole("option", { name })); + } + expect(check).toBeDisabled(); + expect( + screen.getByText( + "This command needs your confirmation before the agent can run.", + ), + ).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(saves).toHaveLength(2)); + expect(saves[1]).toMatchObject({ preset: "cursor", approval: "" }); +}); + +it.each([[["a\nb"]], [[""]]])( + "won't silently rewrite stored args %j the box can't show", + async (stored) => { + const { saves } = mount(undefined, { ...start, args: stored }); + const save = screen.getByRole("button", { name: "Save" }); + expect(screen.getByRole("alert")).toHaveTextContent("can't be empty"); + expect(save).toHaveAttribute("aria-disabled", "true"); + await userEvent.click(save); + expect(saves).toHaveLength(0); + const args = screen.getByRole("textbox", { name: "Arguments" }); + await userEvent.clear(args); + await userEvent.type(args, "acp"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + await userEvent.click(save); + await waitFor(() => expect(saves).toHaveLength(1)); + expect(saves[0]).toMatchObject({ args: ["acp"] }); + }, +); diff --git a/src/bundled/acp/tabs.tsx b/src/bundled/acp/tabs.tsx new file mode 100644 index 0000000000..963d417782 --- /dev/null +++ b/src/bundled/acp/tabs.tsx @@ -0,0 +1,309 @@ +// An ACP agent's settings: a preset or a typed command, its args and env, and +// the screen where the owner confirms the exact launch before it can run. +import { useEffect, useState } from "react"; +import { AgentInstructions } from "../../features/agents/AgentInstructions"; +import type { AgentViewProps } from "../../features/agents/service"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Input } from "../../shared/design-system/ui/Input"; +import { Select } from "../../shared/design-system/ui/Select"; +import { Textarea } from "../../shared/design-system/ui/Textarea"; +import { + approve, + approved, + type Config, + problems, + config as readConfig, + shownValue, + withPreset, +} from "./config"; +import { CUSTOM, PRESETS } from "./presets"; + +const message = (error: unknown) => + error instanceof Error ? error.message : String(error); + +const PRESET_GROUPS = [ + { + label: "", + options: [ + ...PRESETS.map(({ id, title, verified }) => ({ + value: id, + label: verified ? title : `${title} (unverified)`, + })), + { value: CUSTOM, label: "Custom command" }, + ], + }, +]; + +type Row = Readonly<{ name: string; value: string }>; +const rowsOf = (env: Config["env"]): Row[] => + Object.entries(env).map(([name, value]) => ({ name, value })); +const replaced = (rows: readonly Row[], at: number, row: Row) => + rows.map((old, index) => (index === at ? row : old)); +const envOf = (rows: readonly Row[]) => + Object.fromEntries( + rows.filter(({ name }) => name.trim()).map((r) => [r.name.trim(), r.value]), + ); + +/** Whether the stored config's approval covers its launch as saved. */ +function useApproved(value: Config) { + const [current, setCurrent] = useState(false); + const key = JSON.stringify(value); + // biome-ignore lint/correctness/useExhaustiveDependencies: `value` follows `key`. + useEffect(() => { + let live = true; + approved(value).then((ok) => live && setCurrent(ok)); + return () => { + live = false; + }; + }, [key]); + return current; +} + +/** The exact launch, as the owner confirms it: argv, workspace, env names, and + * the value of `PATH` only. */ +export function Confirmation({ value }: Readonly<{ value: Config }>) { + const names = Object.keys(value.env).sort(); + return ( +
+
Runs
+
+ {JSON.stringify([value.command, ...value.args])} +
+
In
+
+ {value.workspace} +
+
Environment
+
+ {names.length === 0 + ? "No extra variables." + : names.map((name) => { + const shown = shownValue(name, value.env[name] ?? ""); + return ( +
+ {shown === undefined ? name : `${name}=${shown}`} +
+ ); + })} +
+
+ ); +} + +export function createTabs({ + check, +}: Readonly<{ + /** Starts the saved, confirmed harness once and says what it found. */ + check(config: Config): Promise; +}>) { + function SettingsTab({ agent, save, settingsHost }: AgentViewProps) { + const saved = readConfig(agent.config); + const [draft, setDraft] = useState(saved); + const [rows, setRows] = useState(() => rowsOf(saved.env)); + // Untouched, the stored argv stays exact, so one the box can't show is refused. + const [args, setArgs] = useState(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [confirming, setConfirming] = useState(false); + const [checked, setChecked] = useState(""); + // A preset switch needs a new confirmation, even back to the same command. + const [reset, setReset] = useState(false); + const current = useApproved(saved) && !reset; + const edited: Config = { + ...draft, + approval: reset ? "" : saved.approval, + args: + args === undefined ? saved.args : args === "" ? [] : args.split("\n"), + env: envOf(rows), + }; + const configChanged = JSON.stringify(edited) !== JSON.stringify(saved); + const changed = configChanged || Boolean(settingsHost?.pendingSave); + const found = problems(edited); + const run = async (work: () => Promise) => { + setBusy(true); + setError(""); + try { + await work(); + } catch (reason) { + setError(message(reason)); + } finally { + setBusy(false); + } + }; + // Saving a launch the owner hasn't confirmed drops the approval. + const submit = () => + run(async () => { + await save( + (await approved(edited)) ? edited : { ...edited, approval: "" }, + ); + setChecked(""); + setReset(false); + }); + const confirm = () => + run(async () => { + await save(await approve(saved)); + setConfirming(false); + }); + return ( +
{ + event.preventDefault(); + void submit(); + }} + > + {settingsHost?.typeControl?.(configChanged)} + + setDraft({ ...draft, command, preset: CUSTOM }) + } + /> + + +