From 8311c578885f1a5ab59852f9e4e5e6caf784ccf0 Mon Sep 17 00:00:00 2001 From: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Date: Thu, 15 Oct 2026 16:54:46 -0400 Subject: [PATCH 1/8] feat(acp): add generic ACP plugin config, presets and launch approval Owners will add long-tail ACP harnesses by preset or typed command; the launch must be owner-confirmed, so approval is a digest over the exact argv, env and workspace that launch re-checks. Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- src/bundled/acp/config.test.ts | 163 +++++++++++++++++++++++++++++++++ src/bundled/acp/config.ts | 157 +++++++++++++++++++++++++++++++ src/bundled/acp/manifest.json | 11 +++ src/bundled/acp/presets.ts | 95 +++++++++++++++++++ 4 files changed, 426 insertions(+) create mode 100644 src/bundled/acp/config.test.ts create mode 100644 src/bundled/acp/config.ts create mode 100644 src/bundled/acp/manifest.json create mode 100644 src/bundled/acp/presets.ts diff --git a/src/bundled/acp/config.test.ts b/src/bundled/acp/config.test.ts new file mode 100644 index 0000000000..9326bd57a3 --- /dev/null +++ b/src/bundled/acp/config.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + approve, + approved, + 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(["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([]); + } + }); +}); diff --git a/src/bundled/acp/config.ts b/src/bundled/acp/config.ts new file mode 100644 index 0000000000..2ab7f40c89 --- /dev/null +++ b/src/bundled/acp/config.ts @@ -0,0 +1,157 @@ +// 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 { 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: "", + }; +} + +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."); + 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. */ +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; 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); From 4cf2ceda0b5e2524c356687d505a0b5f4a41dc63 Mon Sep 17 00:00:00 2001 From: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Date: Thu, 15 Oct 2026 17:02:21 -0400 Subject: [PATCH 2/8] feat(acp): add ACP agent settings, launch confirmation and registration Owners confirm the exact argv, workspace and env names before an ACP agent can launch; Check runs only while that confirmation is current. The type registers disabled by default until its runtime lands, and carries only instructions across type changes. Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- src/app/pages.integration.test.mjs | 2 +- src/bundled/acp/config.test.ts | 25 +++ src/bundled/acp/config.ts | 15 ++ src/bundled/acp/index.ts | 57 ++++++ src/bundled/acp/tabs.test.tsx | 85 ++++++++ src/bundled/acp/tabs.tsx | 303 +++++++++++++++++++++++++++++ src/bundled/index.ts | 8 + 7 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 src/bundled/acp/index.ts create mode 100644 src/bundled/acp/tabs.test.tsx create mode 100644 src/bundled/acp/tabs.tsx 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 index 9326bd57a3..16c66720ba 100644 --- a/src/bundled/acp/config.test.ts +++ b/src/bundled/acp/config.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + adopt, approve, approved, + carry, config, DEFAULT_CONFIG, digest, @@ -161,3 +163,26 @@ describe("presets", () => { } }); }); + +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 index 2ab7f40c89..b087db9080 100644 --- a/src/bundled/acp/config.ts +++ b/src/bundled/acp/config.ts @@ -1,6 +1,7 @@ // 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<{ @@ -155,3 +156,17 @@ export async function launch( /** 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/tabs.test.tsx b/src/bundled/acp/tabs.test.tsx new file mode 100644 index 0000000000..780f1a77c6 --- /dev/null +++ b/src/bundled/acp/tabs.test.tsx @@ -0,0 +1,85 @@ +// @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.")) { + const { SettingsTab } = createTabs({ check }); + const saves: Config[] = []; + function Host() { + const [config, setConfig] = useState(start); + 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", + ); +}); diff --git a/src/bundled/acp/tabs.tsx b/src/bundled/acp/tabs.tsx new file mode 100644 index 0000000000..4cd55bb3a3 --- /dev/null +++ b/src/bundled/acp/tabs.tsx @@ -0,0 +1,303 @@ +// 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)); + const [args, setArgs] = useState(saved.args.join("\n")); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [confirming, setConfirming] = useState(false); + const [checked, setChecked] = useState(""); + const current = useApproved(saved); + const edited: Config = { + ...draft, + approval: saved.approval, + args: args.split("\n").filter((arg) => arg !== ""), + 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(""); + }); + const confirm = () => + run(async () => { + await save(await approve(saved)); + setConfirming(false); + }); + return ( +
{ + event.preventDefault(); + void submit(); + }} + > + {settingsHost?.typeControl?.(configChanged)} + + setDraft({ ...draft, command, preset: CUSTOM }) + } + /> + + +