diff --git a/server/index.ts b/server/index.ts index 559fd1a7be..3b6749c18a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -12,7 +12,7 @@ import { readHerdrProfiles, type HerdrMachineProfile } from "./herdr-profiles.ts import { accessSync, constants, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import type { AgentKind, HealthAuth, HerdrPane, PanePlan, PendingMessage, PluginActionsResponse, ProjectFavicon } from "../shared/protocol.ts"; +import type { AgentKind, HealthAuth, HerdrPane, PanePlan, PendingMessage, ProjectFavicon } from "../shared/protocol.ts"; import { DEFAULT_PORT } from "../shared/protocol.ts"; import { authClient, deviceCookieValue, bearerMatches, handleAuthRequest, isAuthenticated, isSecureRequest, parseCookies, presentedToken, presentedTokenHeld, recordPresentedTokenFailure, requiresAuth, unauthorizedJson } from "./auth.ts"; import { cameThroughProxy, decideAccess, isLoopbackAddress, isLoopbackBind, isServeOwnerRequest, lanExposed } from "./access.ts"; @@ -20,7 +20,7 @@ import { DeviceStore, handleDeviceRequest, type DeviceMatch } from "./devices.ts import { handlePreferencesRequest, PreferenceStore } from "./preferences.ts"; import { handleSeenRequest, SeenStore } from "./seen.ts"; import { remoteAccess, TailnetIdentitySource } from "./tailscale.ts"; -import { badRequest, errorResponse, jsonResponse, readJsonObject, setRequestRoute } from "./http.ts"; +import { badRequest, errorResponse, jsonResponse, setRequestRoute } from "./http.ts"; import { requestOrigin, serveStatic } from "./static.ts"; import { compressResponse } from "./compress.ts"; import { claudePaneSession } from "./conversation.ts"; @@ -33,15 +33,9 @@ import { fileResponse, findFile, crossSiteFileRead, forgetPageMedia, pageMediaRe import { findProjectFavicon } from "./project-favicon.ts"; import { agentManifests, - HerdrError, herdrSocketPath, paneGet, ping, - pluginActionInvoke, - pluginActionList, - pluginList, - pluginLogList, - type PluginInvocationContext, sessionSnapshot, } from "./herdr/client.ts"; import { type AlertTiming, defaultStateDir, handlePushRequest } from "./push.ts"; @@ -66,13 +60,8 @@ import { HerdrSidebarConfig, herdrConfigPath } from "./herdr-sidebar.ts"; import { closeCodexDaemonSources } from "./codex-daemon.ts"; import { MachineRelay } from "./machine-relay.ts"; import { sameOrigin } from "./machine-security.ts"; -import { PLUGIN_LOG_LIMIT, pluginActionResult, pluginLogEntry, pluginPaneContext, waitForPluginAction } from "./plugin-actions.ts"; +import { handlePluginRequest } from "./plugin-routes.ts"; -/** How herdr's plugin manifests name the platform this server (and so its herdr) runs on. */ -const PLUGIN_PLATFORM = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"; -/** How long POST /api/plugin/action waits for the command herdr started to end before it answers `running`. */ -const PLUGIN_ACTION_WAIT_MS = 5_000; -const livePaneIds = async (timeoutMs?: number): Promise => (await sessionSnapshot(undefined, timeoutMs)).panes.map((pane) => pane.pane_id); /** The gap between a composer message's text and its Enter (see submitText). */ export const SUBMIT_DELAY_MS = 120; /** @@ -587,73 +576,8 @@ export function createServer( }); if (workspaceResponse) return workspaceResponse; - if (pathname === "/api/plugins/actions") { - setRequestRoute(pathname); - if (request.method !== "GET") return badRequest("method_not_allowed", "use GET"); - try { - const [plugins, actions] = await Promise.all([pluginList(), pluginActionList()]); - const body: PluginActionsResponse = { - plugins: plugins.map((plugin) => ({ - plugin_id: plugin.plugin_id, - name: plugin.name, - version: plugin.version, - description: plugin.description ?? null, - enabled: plugin.enabled, - // herdr lists another platform's actions too and refuses them: platform_unsupported - actions: actions - .filter((action) => action.plugin_id === plugin.plugin_id && (!action.platforms || action.platforms.includes(PLUGIN_PLATFORM))) - .map((action) => ({ action_id: action.action_id, title: action.title, description: action.description ?? null, contexts: action.contexts ?? [] })), - })), - }; - return jsonResponse(body); - } catch (error) { - return errorResponse(error); - } - } - - if (pathname === "/api/plugin/action" && request.method === "GET") { - setRequestRoute(pathname); - const pluginId = url.searchParams.get("plugin_id"); - const logId = url.searchParams.get("log_id"); - if (!pluginId) return badRequest("missing_plugin_id", "plugin_id is required"); - if (!logId) return badRequest("missing_log_id", "log_id is required"); - try { - const log = await pluginLogEntry(logId, (limit) => pluginLogList(pluginId, limit)); - return jsonResponse(await pluginActionResult(log, () => livePaneIds())); - } catch (error) { - return errorResponse(error); - } - } - - if (pathname === "/api/plugin/action") { - setRequestRoute(pathname); - if (request.method !== "POST") return badRequest("method_not_allowed", "use GET or POST"); - const payload = await readJsonObject<{ plugin_id?: unknown; action_id?: unknown; pane_id?: unknown }>(request); - if (payload instanceof Response) return payload; - if (typeof payload.plugin_id !== "string" || payload.plugin_id.length === 0) return badRequest("missing_plugin_id", "plugin_id is required"); - if (typeof payload.action_id !== "string" || payload.action_id.length === 0) return badRequest("missing_action_id", "action_id is required"); - if (payload.pane_id !== undefined && (typeof payload.pane_id !== "string" || payload.pane_id.length === 0)) return badRequest("invalid_pane_id", "pane_id must be a pane's ID"); - try { - let context: PluginInvocationContext | undefined; - if (payload.pane_id !== undefined) { - const snapshot = await sessionSnapshot(); - const pane = snapshot.panes.find((entry) => entry.pane_id === payload.pane_id); - // herdr would run the action with whatever ID it was handed - if (!pane) throw new HerdrError("pane_not_found", `pane ${payload.pane_id} not found`); - context = pluginPaneContext(snapshot, pane); - } - const pluginId = payload.plugin_id; - const invoked = await pluginActionInvoke(pluginId, payload.action_id, context); - return jsonResponse(await waitForPluginAction( - invoked.log, - options.pluginActionWaitMs ?? PLUGIN_ACTION_WAIT_MS, - (timeoutMs) => pluginLogList(pluginId, PLUGIN_LOG_LIMIT, undefined, timeoutMs), - livePaneIds, - )); - } catch (error) { - return errorResponse(error); - } - } + const pluginResponse = await handlePluginRequest(request, url, pathname, options); + if (pluginResponse) return pluginResponse; const paneResponse = await handlePaneRequest(request, url, pathname, options); if (paneResponse) return paneResponse; diff --git a/server/plugin-routes.test.ts b/server/plugin-routes.test.ts new file mode 100644 index 0000000000..7a0932e3e4 --- /dev/null +++ b/server/plugin-routes.test.ts @@ -0,0 +1,113 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, spyOn } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { SessionSnapshot } from "../shared/protocol.ts"; +import * as herdr from "./herdr/client.ts"; +import { HerdrError, type PluginActionInfo, type PluginCommandLog } from "./herdr/client.ts"; +import { createServer } from "./index.ts"; +import { pluginPaneContext } from "./plugin-actions.ts"; + +const PLATFORM = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"; +const OTHER = PLATFORM === "linux" ? "macos" : "linux"; + +describe("plugin routes", () => { + const actions: PluginActionInfo[] = [ + { plugin_id: "deploy", action_id: "ship", title: "Ship", description: "Ship it", contexts: ["pane"], command: ["./ship.sh"], platforms: [PLATFORM] }, + { plugin_id: "deploy", action_id: "elsewhere", title: "Elsewhere", command: ["./other.sh"], platforms: [OTHER] }, + { plugin_id: "deploy", action_id: "anywhere", title: "Anywhere", command: ["./any.sh"] }, + { plugin_id: "notes", action_id: "jot", title: "Jot", description: null, command: ["jot"], platforms: null }, + ]; + const finished: PluginCommandLog = { log_id: "log-1", plugin_id: "deploy", action_id: "ship", status: "failed", exit_code: 2, stderr: "boom\n" }; + let server: ReturnType; + let stateDir: string; + const list = spyOn(herdr, "pluginList").mockImplementation(async () => [ + { plugin_id: "deploy", name: "Deploy", version: "1.0.0", description: "Ships builds", enabled: true }, + { plugin_id: "notes", name: "Notes", version: "0.1.0", enabled: false }, + ] as Awaited>); + const actionList = spyOn(herdr, "pluginActionList").mockImplementation(async () => actions); + const logs = spyOn(herdr, "pluginLogList").mockImplementation(async () => [finished]); + const invoke = spyOn(herdr, "pluginActionInvoke").mockImplementation(async () => ({ log: { log_id: "log-2", plugin_id: "deploy", status: "succeeded", exit_code: 0, stdout: "done" } })); + const url = (path: string) => `http://127.0.0.1:${server.port}${path}`; + const post = (path: string, body: unknown, method = "POST") => fetch(url(path), { + method, headers: { "content-type": "application/json", origin: url("") }, body: typeof body === "string" ? body : JSON.stringify(body), + }); + const error = async (response: Response) => [response.status, ((await response.json()) as { error: { code: string } }).error.code]; + + beforeAll(() => { + stateDir = mkdtempSync(join(tmpdir(), "ovis-plugin-routes-")); + server = createServer({ port: 0, hostname: "127.0.0.1", stateDir, token: "", machines: false, sidecar: false }); + }); + afterEach(() => { + for (const spy of [list, actionList, logs, invoke]) spy.mockClear(); + }); + afterAll(() => { + server.stop(); + for (const spy of [list, actionList, logs, invoke]) spy.mockRestore(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("lists each plugin with the actions this platform runs, without their command lines", async () => { + const response = await fetch(url("/api/plugins/actions")); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + plugins: [ + { + plugin_id: "deploy", name: "Deploy", version: "1.0.0", description: "Ships builds", enabled: true, + actions: [ + { action_id: "ship", title: "Ship", description: "Ship it", contexts: ["pane"] }, + { action_id: "anywhere", title: "Anywhere", description: null, contexts: [] }, + ], + }, + { plugin_id: "notes", name: "Notes", version: "0.1.0", description: null, enabled: false, actions: [{ action_id: "jot", title: "Jot", description: null, contexts: [] }] }, + ], + }); + expect(await error(await post("/api/plugins/actions", {}))).toEqual([400, "method_not_allowed"]); + }); + + it("answers how a run ended from the plugin's log, and what it cannot look up", async () => { + expect(await error(await fetch(url("/api/plugin/action?log_id=log-1")))).toEqual([400, "missing_plugin_id"]); + expect(await error(await fetch(url("/api/plugin/action?plugin_id=deploy")))).toEqual([400, "missing_log_id"]); + const response = await fetch(url("/api/plugin/action?plugin_id=deploy&log_id=log-1")); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ log_id: "log-1", status: "failed", exit_code: 2, output: "boom", opened_pane_id: null }); + expect(logs).toHaveBeenCalledWith("deploy", 200); + expect(await error(await fetch(url("/api/plugin/action?plugin_id=deploy&log_id=gone")))).toEqual([404, "plugin_log_not_found"]); + logs.mockImplementationOnce(async () => { throw new HerdrError("connect_failed", "herdr is not answering"); }); + expect(await error(await fetch(url("/api/plugin/action?plugin_id=deploy&log_id=log-1")))).toEqual([502, "connect_failed"]); + }); + + it("validates a run before asking herdr, and refuses other methods", async () => { + expect(await error(await post("/api/plugin/action", "not json"))).toEqual([400, "invalid_json"]); + expect(await error(await post("/api/plugin/action", { action_id: "ship" }))).toEqual([400, "missing_plugin_id"]); + expect(await error(await post("/api/plugin/action", { plugin_id: "deploy" }))).toEqual([400, "missing_action_id"]); + expect(await error(await post("/api/plugin/action", { plugin_id: "deploy", action_id: "ship", pane_id: "" }))).toEqual([400, "invalid_pane_id"]); + expect(await error(await post("/api/plugin/action", { plugin_id: "deploy", action_id: "ship" }, "PUT"))).toEqual([400, "method_not_allowed"]); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("runs an action without a pane, or with the named pane's whole context, and refuses a pane that is not there", async () => { + const plain = await post("/api/plugin/action", { plugin_id: "deploy", action_id: "ship" }); + expect(plain.status).toBe(200); + expect(await plain.json()).toEqual({ log_id: "log-2", status: "succeeded", exit_code: 0, output: null, opened_pane_id: null }); + expect(invoke).toHaveBeenLastCalledWith("deploy", "ship", undefined); + + const snapshot = { + workspaces: [{ workspace_id: "w1", label: "app", worktree: null }], + tabs: [{ tab_id: "w1:t1", workspace_id: "w1", label: "main", number: 1 }], + panes: [{ pane_id: "w1:p1", workspace_id: "w1", tab_id: "w1:t1", cwd: "/repo", agent: "claude", agent_status: "idle" }], + } as unknown as SessionSnapshot; + const snap = spyOn(herdr, "sessionSnapshot").mockImplementation(async () => snapshot); + try { + const withPane = await post("/api/plugin/action", { plugin_id: "deploy", action_id: "ship", pane_id: "w1:p1" }); + expect(withPane.status).toBe(200); + expect(invoke).toHaveBeenLastCalledWith("deploy", "ship", pluginPaneContext(snapshot, snapshot.panes[0]!)); + invoke.mockClear(); + expect(await error(await post("/api/plugin/action", { plugin_id: "deploy", action_id: "ship", pane_id: "w9:p9" }))).toEqual([404, "pane_not_found"]); + expect(invoke).not.toHaveBeenCalled(); + } finally { + snap.mockRestore(); + } + }); +}); diff --git a/server/plugin-routes.ts b/server/plugin-routes.ts new file mode 100644 index 0000000000..288ead8f9a --- /dev/null +++ b/server/plugin-routes.ts @@ -0,0 +1,87 @@ +/** + * The plugin routes: the actions herdr's plugins offer on this PC (GET /api/plugins/actions), running + * one (POST /api/plugin/action) and how a run stands (GET /api/plugin/action). The dispatcher in + * index.ts runs the access gate first. + */ +import type { PluginActionsResponse } from "../shared/plugins.ts"; +import { HerdrError, pluginActionInvoke, pluginActionList, pluginList, pluginLogList, type PluginInvocationContext, sessionSnapshot } from "./herdr/client.ts"; +import { badRequest, errorResponse, jsonResponse, readJsonObject, setRequestRoute } from "./http.ts"; +import { PLUGIN_LOG_LIMIT, pluginActionResult, pluginLogEntry, pluginPaneContext, waitForPluginAction } from "./plugin-actions.ts"; + +/** How herdr's plugin manifests name the platform this server (and so its herdr) runs on. */ +const PLUGIN_PLATFORM = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"; +/** How long POST /api/plugin/action waits for the command herdr started to end before it answers `running`. */ +const PLUGIN_ACTION_WAIT_MS = 5_000; +const livePaneIds = async (timeoutMs?: number): Promise => (await sessionSnapshot(undefined, timeoutMs)).panes.map((pane) => pane.pane_id); + +/** The answer for a plugin route, or null when the path is not one. */ +export async function handlePluginRequest(request: Request, url: URL, pathname: string, options: { pluginActionWaitMs?: number }): Promise { + if (pathname === "/api/plugins/actions") { + setRequestRoute(pathname); + if (request.method !== "GET") return badRequest("method_not_allowed", "use GET"); + try { + const [plugins, actions] = await Promise.all([pluginList(), pluginActionList()]); + const body: PluginActionsResponse = { + plugins: plugins.map((plugin) => ({ + plugin_id: plugin.plugin_id, + name: plugin.name, + version: plugin.version, + description: plugin.description ?? null, + enabled: plugin.enabled, + // herdr lists another platform's actions too and refuses them: platform_unsupported + actions: actions + .filter((action) => action.plugin_id === plugin.plugin_id && (!action.platforms || action.platforms.includes(PLUGIN_PLATFORM))) + .map((action) => ({ action_id: action.action_id, title: action.title, description: action.description ?? null, contexts: action.contexts ?? [] })), + })), + }; + return jsonResponse(body); + } catch (error) { + return errorResponse(error); + } + } + + if (pathname === "/api/plugin/action" && request.method === "GET") { + setRequestRoute(pathname); + const pluginId = url.searchParams.get("plugin_id"); + const logId = url.searchParams.get("log_id"); + if (!pluginId) return badRequest("missing_plugin_id", "plugin_id is required"); + if (!logId) return badRequest("missing_log_id", "log_id is required"); + try { + const log = await pluginLogEntry(logId, (limit) => pluginLogList(pluginId, limit)); + return jsonResponse(await pluginActionResult(log, () => livePaneIds())); + } catch (error) { + return errorResponse(error); + } + } + + if (pathname === "/api/plugin/action") { + setRequestRoute(pathname); + if (request.method !== "POST") return badRequest("method_not_allowed", "use GET or POST"); + const payload = await readJsonObject<{ plugin_id?: unknown; action_id?: unknown; pane_id?: unknown }>(request); + if (payload instanceof Response) return payload; + if (typeof payload.plugin_id !== "string" || payload.plugin_id.length === 0) return badRequest("missing_plugin_id", "plugin_id is required"); + if (typeof payload.action_id !== "string" || payload.action_id.length === 0) return badRequest("missing_action_id", "action_id is required"); + if (payload.pane_id !== undefined && (typeof payload.pane_id !== "string" || payload.pane_id.length === 0)) return badRequest("invalid_pane_id", "pane_id must be a pane's ID"); + try { + let context: PluginInvocationContext | undefined; + if (payload.pane_id !== undefined) { + const snapshot = await sessionSnapshot(); + const pane = snapshot.panes.find((entry) => entry.pane_id === payload.pane_id); + // herdr would run the action with whatever ID it was handed + if (!pane) throw new HerdrError("pane_not_found", `pane ${payload.pane_id} not found`); + context = pluginPaneContext(snapshot, pane); + } + const pluginId = payload.plugin_id; + const invoked = await pluginActionInvoke(pluginId, payload.action_id, context); + return jsonResponse(await waitForPluginAction( + invoked.log, + options.pluginActionWaitMs ?? PLUGIN_ACTION_WAIT_MS, + (timeoutMs) => pluginLogList(pluginId, PLUGIN_LOG_LIMIT, undefined, timeoutMs), + livePaneIds, + )); + } catch (error) { + return errorResponse(error); + } + } + return null; +} diff --git a/shared/plugins.ts b/shared/plugins.ts new file mode 100644 index 0000000000..b2a2ae92cb --- /dev/null +++ b/shared/plugins.ts @@ -0,0 +1,52 @@ +/** Where a plugin manifest says an action applies. herdr may add more, so it stays open. */ +export type PluginActionContext = "global" | "workspace" | "tab" | "pane" | "selection" | (string & {}); + +/** One manifest action of a herdr plugin. Its command line stays on the server. */ +export interface PluginAction { + /** local to its plugin: herdr's global name is `.` */ + action_id: string; + title: string; + description: string | null; + /** empty when the manifest names none */ + contexts: PluginActionContext[]; +} + +/** GET /api/plugins/actions: one installed plugin and the actions this PC can run. */ +export interface PluginActions { + plugin_id: string; + name: string; + version: string; + description: string | null; + /** herdr refuses a disabled plugin's actions (`plugin_disabled`) */ + enabled: boolean; + actions: PluginAction[]; +} + +export interface PluginActionsResponse { + plugins: PluginActions[]; +} + +/** POST /api/plugin/action. Without `pane_id` herdr runs the action in its own focus. */ +export interface PluginActionRequest { + plugin_id: string; + action_id: string; + pane_id?: string; +} + +/** POST /api/plugin/action: what herdr's plugin command log said by the time the server answered. */ +export interface PluginActionResult { + /** herdr's log entry of this run: what GET /api/plugin/action is asked about while it is `running` */ + log_id: string; + /** `running`: the command had not ended when the wait ran out */ + status: "running" | "succeeded" | "failed"; + exit_code: number | null; + /** a failed command's own words (spawn error, else the end of stderr, else of stdout); null otherwise */ + output: string | null; + /** + * The pane this run's own output says it opened with focus (the answer of `herdr plugin pane + * open --focus`), while that pane still exists. Null for everything else: a command that + * printed no such answer, a popup (it has no pane ID), and a pane that merely appeared while + * the command ran, which nothing ties to it. + */ + opened_pane_id: string | null; +} diff --git a/shared/protocol.ts b/shared/protocol.ts index 9b6ef3c312..e4e334020a 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -697,58 +697,7 @@ export interface ConversationResponse { abandoned?: { count: number; branches: number; summary: string | null }; } -/** Where a plugin manifest says an action applies. herdr may add more, so it stays open. */ -export type PluginActionContext = "global" | "workspace" | "tab" | "pane" | "selection" | (string & {}); - -/** One manifest action of a herdr plugin. Its command line stays on the server. */ -export interface PluginAction { - /** local to its plugin: herdr's global name is `.` */ - action_id: string; - title: string; - description: string | null; - /** empty when the manifest names none */ - contexts: PluginActionContext[]; -} - -/** GET /api/plugins/actions: one installed plugin and the actions this PC can run. */ -export interface PluginActions { - plugin_id: string; - name: string; - version: string; - description: string | null; - /** herdr refuses a disabled plugin's actions (`plugin_disabled`) */ - enabled: boolean; - actions: PluginAction[]; -} - -export interface PluginActionsResponse { - plugins: PluginActions[]; -} - -/** POST /api/plugin/action. Without `pane_id` herdr runs the action in its own focus. */ -export interface PluginActionRequest { - plugin_id: string; - action_id: string; - pane_id?: string; -} - -/** POST /api/plugin/action: what herdr's plugin command log said by the time the server answered. */ -export interface PluginActionResult { - /** herdr's log entry of this run: what GET /api/plugin/action is asked about while it is `running` */ - log_id: string; - /** `running`: the command had not ended when the wait ran out */ - status: "running" | "succeeded" | "failed"; - exit_code: number | null; - /** a failed command's own words (spawn error, else the end of stderr, else of stdout); null otherwise */ - output: string | null; - /** - * The pane this run's own output says it opened with focus (the answer of `herdr plugin pane - * open --focus`), while that pane still exists. Null for everything else: a command that - * printed no such answer, a popup (it has no pane ID), and a pane that merely appeared while - * the command ran, which nothing ties to it. - */ - opened_pane_id: string | null; -} +export type { PluginActionContext, PluginAction, PluginActions, PluginActionsResponse, PluginActionRequest, PluginActionResult } from "./plugins.ts"; /** GET /api/agents: one agent kind herdr can start (`agent.start` kind), with a display label. */ /** GET /api/workspace/directories: the folders inside one directory, for the folder browser. */