diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 328a976bca98..cebc1cca0f40 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -414,7 +414,7 @@ Use **Ctrl + backtick** to toggle the **Terminal** tab in the selected Chat pane The unified panel also hosts **Browser**, **Files**, **Tasks**, **Review**, **Side chat**, and capability-dependent **Desktop** and **Discussion** tabs. Its open or minimized state, active tab, tab order, width, dock, and expanded state are stored per session in the current browser profile, so switching sessions restores each session's own working layout. Drag tabs to reorder them, close a tab without closing the other tools, or use the panel close button to minimize the whole panel. -Owner-authorized, unsandboxed agents can use the `terminal` tool for long or interactive work that the operator should watch. Each tool call can open, read, write, resize, close, or list the agent's own gateway PTYs. New sessions open a co-attached Control UI tab by default, so the agent and operator share output and either can type or resize. Agent access is exact-session scoped: an agent cannot read or control operator-created terminals or terminals opened by another agent session. +Owner-authorized, unsandboxed agents can use the `terminal` tool for long or interactive work. Each tool call can open, read, write, resize, close, or list the agent's own gateway PTYs. These PTYs stay in the background by default; opening one does not open the Terminal panel or attach a browser tab. To watch one, open **Terminal**, select the server icon for **Terminal sessions**, and choose its agent-marked session. The agent and attached operator then share output, and either can type or resize. Agent access is exact-session scoped: an agent cannot read or control operator-created terminals or terminals opened by another agent session. Drag one or more files onto the active terminal, or use the paperclip button to choose files. OpenClaw stages each file on the machine that owns the PTY and pastes shell-quoted absolute paths at the cursor; it never presses Enter or executes the input. A compact batch indicator shows the current file and completed count. Cancel stops the remaining batch without pasting paths; a failed transfer stays visible so you can retry from that file without re-uploading completed files. Images, PDFs, archives, and other file types are accepted up to 16 MiB per file. Staged files use a private system-temporary directory on POSIX hosts (directory mode `0700`, file mode `0600`) or a directory under the user-profile ACL boundary on Windows, plus a 24-hour cleanup timer, so move or copy anything you need to keep. diff --git a/src/agents/tools/terminal-tool.test.ts b/src/agents/tools/terminal-tool.test.ts index 2c7891057f8b..b4c887b4d987 100644 --- a/src/agents/tools/terminal-tool.test.ts +++ b/src/agents/tools/terminal-tool.test.ts @@ -13,9 +13,15 @@ import { import type { spawnTerminalPty } from "../../process/terminal-pty.js"; import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../../security/dangerous-tools.js"; import { compactToolOutputHint } from "../tool-schema-hints.js"; -import type { InProcessGatewayCaller } from "./in-process-gateway.js"; import { createTerminalTool } from "./terminal-tool.js"; +const callInProcessGatewayTool = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); + +vi.mock("./in-process-gateway.js", () => ({ + callInProcessGatewayTool, + getInProcessGatewayToolContext: vi.fn(), +})); + type TerminalPtyHandle = Awaited>; function makeBackend() { @@ -78,6 +84,7 @@ function makeContext(manager: TerminalSessionManager) { describe("terminal tool", () => { beforeEach(() => { resetAgentRunRegistryForTest(); + callInProcessGatewayTool.mockClear(); }); it("uses a flat action enum and the owner-only core gate", () => { @@ -90,17 +97,17 @@ describe("terminal tool", () => { }, }, }); + const schema = tool.parameters as { properties?: Record }; + expect(schema.properties).not.toHaveProperty("show"); expect(GATEWAY_OWNER_ONLY_CORE_TOOLS).toContain("terminal"); }); - it("opens, shows, reads, writes, resizes, lists, and closes its terminal", async () => { + it("opens in the background, reads, writes, resizes, lists, and closes its terminal", async () => { const backend = makeBackend(); const manager = new TerminalSessionManager({ emit: vi.fn(), spawn: async () => backend }); - const callGateway = vi.fn(async () => ({ ok: true })) as InProcessGatewayCaller; const tool = createTerminalTool({ agentId: "main", agentSessionKey: "agent:main:main", - callGateway, getGatewayContext: () => makeContext(manager), }); expect(tool.outputSchema).toBeDefined(); @@ -112,16 +119,7 @@ describe("terminal tool", () => { expect(Value.Check(tool.outputSchema!, opened.details)).toBe(true); const sessionId = (opened.details as { sessionId: string }).sessionId; expect(backend.writes).toEqual(["echo ready\r"]); - expect(callGateway).toHaveBeenCalledWith("ui.command", { - agentId: "main", - command: { - kind: "panel", - panel: "terminal", - open: true, - terminalSessionId: sessionId, - }, - sessionKey: "agent:main:main", - }); + expect(callInProcessGatewayTool).not.toHaveBeenCalled(); backend.emitData("\u001b[31mready\u001b[0m\r\n"); const read = await tool.execute("read", { action: "read", sessionId }); @@ -186,9 +184,9 @@ describe("terminal tool", () => { getGatewayContext: () => makeContext(manager), }); - await createTaskTool("run-1").execute("open", { action: "open", show: false }); - await createTaskTool("run-2").execute("open", { action: "open", show: false }); - await createTaskTool("conversation-run").execute("open", { action: "open", show: false }); + await createTaskTool("run-1").execute("open", { action: "open" }); + await createTaskTool("run-2").execute("open", { action: "open" }); + await createTaskTool("conversation-run").execute("open", { action: "open" }); expect(lookupTaskByRunIdForChildSession.mock.calls).toEqual([ ["run-1", agentSessionKey], @@ -230,7 +228,7 @@ describe("terminal tool", () => { getGatewayContext: () => makeContext(manager), }); - await tool.execute("open", { action: "open", show: false }); + await tool.execute("open", { action: "open" }); expect(lookupTaskByRunIdForChildSession).toHaveBeenCalledWith("shared-run", agentSessionKey); expect(manager.closeAgentSessions("task-2")).toBe(1); @@ -266,7 +264,7 @@ describe("terminal tool", () => { }); try { - await tool.execute("open", { action: "open", show: false }); + await tool.execute("open", { action: "open" }); expect(lookupTaskByRunIdForChildSession).toHaveBeenCalledWith( "detached-task-run", @@ -297,7 +295,7 @@ describe("terminal tool", () => { getGatewayContext: () => makeContext(manager), }); - await expect(tool.execute("open", { action: "open", show: false })).rejects.toThrow( + await expect(tool.execute("open", { action: "open" })).rejects.toThrow( "terminal task already ended", ); expect(spawn).not.toHaveBeenCalled(); @@ -371,9 +369,6 @@ describe("terminal tool", () => { getGatewayContext: () => makeContext(manager), }); - await expect(tool.execute("open", { action: "open", show: "yes" })).rejects.toThrow( - "show must be boolean", - ); await expect(tool.execute("open", { action: "open", command: 42 })).rejects.toThrow( "command must be string", ); diff --git a/src/agents/tools/terminal-tool.ts b/src/agents/tools/terminal-tool.ts index 949fcbfff9f0..79d1b9317a33 100644 --- a/src/agents/tools/terminal-tool.ts +++ b/src/agents/tools/terminal-tool.ts @@ -1,5 +1,4 @@ import { Type } from "typebox"; -import type { UiCommandParams } from "../../../packages/gateway-protocol/src/index.js"; import type { GatewayRequestContext } from "../../gateway/server-methods/types.js"; import { renderTerminalBufferText } from "../../gateway/terminal/buffer-text.js"; import { buildTerminalEnv, resolveTerminalSpawnPlan } from "../../gateway/terminal/launch.js"; @@ -19,11 +18,7 @@ import { readToolStringParam, ToolInputError, } from "./common.js"; -import { - callInProcessGatewayTool, - getInProcessGatewayToolContext, - type InProcessGatewayCaller, -} from "./in-process-gateway.js"; +import { getInProcessGatewayToolContext } from "./in-process-gateway.js"; const ACTIONS = ["open", "read", "input", "resize", "close", "list"] as const; const DEFAULT_COLS = 100; @@ -39,7 +34,6 @@ const TerminalToolSchema = Type.Object( data: Type.Optional(Type.String({ description: "Raw terminal input" })), cols: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_DIMENSION })), rows: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_DIMENSION })), - show: Type.Optional(Type.Boolean({ description: "Show in web UI. Default: true" })), }, { additionalProperties: false }, ); @@ -86,7 +80,6 @@ type TerminalToolOptions = { runId: string, childSessionKey: string, ) => Promise | undefined>; - callGateway?: InProcessGatewayCaller; getGatewayContext?: () => TerminalToolGatewayContext | undefined; }; @@ -117,17 +110,6 @@ function readDimension( throw new ToolInputError(`${key} required`); } -function readShow(params: Record): boolean { - const value = params.show; - if (value === undefined) { - return true; - } - if (typeof value !== "boolean") { - throw new ToolInputError("show must be boolean"); - } - return value; -} - function readOptionalStringParam( params: Record, key: "command" | "cwd", @@ -165,14 +147,13 @@ function launchBlockMessage( } export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool { - const gatewayCall = opts.callGateway ?? callInProcessGatewayTool; const getContext = opts.getGatewayContext ?? getInProcessGatewayToolContext; const findOwnerTask = opts.lookupTaskByRunIdForChildSession ?? lookupTaskByRunIdForChildSession; return { label: "Terminal", name: "terminal", description: - "Own terminal on gateway host. open/read/input/resize/close/list. User sees it in web UI, can type too. read = buffer snapshot.", + "Own terminal on gateway host. open/read/input/resize/close/list. Operator can attach in web UI and type too. read = buffer snapshot.", parameters: TerminalToolSchema, outputSchema: TerminalToolOutputSchema, execute: async (_toolCallId, rawArgs, signal) => { @@ -198,7 +179,6 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool const cwd = readOptionalStringParam(params, "cwd"); const cols = readDimension(params, "cols", DEFAULT_COLS); const rows = readDimension(params, "rows", DEFAULT_ROWS); - const show = readShow(params); if (!context.isTerminalEnabled()) { throw new ToolInputError("terminal disabled"); } @@ -279,23 +259,6 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool manager.closeAgent(agentSessionKey, outcome.sessionId, agentId); throw new ToolInputError("terminal command failed"); } - if (show) { - const uiCommand: UiCommandParams = { - command: { - kind: "panel", - panel: "terminal", - open: true, - terminalSessionId: outcome.sessionId, - }, - sessionKey: agentSessionKey, - agentId, - }; - try { - await gatewayCall("ui.command", uiCommand); - } catch { - // Terminal remains useful when no capable Control UI is connected. - } - } return jsonResult(outcome); } diff --git a/src/gateway/tool-resolution.terminal.test.ts b/src/gateway/tool-resolution.terminal.test.ts index c919842bb207..566a52c3d5ed 100644 --- a/src/gateway/tool-resolution.terminal.test.ts +++ b/src/gateway/tool-resolution.terminal.test.ts @@ -62,7 +62,7 @@ describe("resolveGatewayScopedTools terminal ownership", () => { throw new Error("expected loopback terminal tool"); } - await terminal.execute("terminal-open", { action: "open", show: false }); + await terminal.execute("terminal-open", { action: "open" }); expect(taskStatusMocks.findTaskByRunIdForChildSessionForStatus).toHaveBeenCalledWith( "shared-run", diff --git a/ui/src/components/terminal/terminal-panel-reconnect.test.ts b/ui/src/components/terminal/terminal-panel-reconnect.test.ts index 063d0943395e..3891f5db881e 100644 --- a/ui/src/components/terminal/terminal-panel-reconnect.test.ts +++ b/ui/src/components/terminal/terminal-panel-reconnect.test.ts @@ -37,7 +37,7 @@ describe("OpenClawTerminalPanel reconnect", () => { await i18n.setLocale("en"); }); - it("attaches a detached session from a fresh browser profile", async () => { + it("attaches an agent-owned session from the picker in a fresh browser profile", async () => { const controllers = [createTerminalController(), createTerminalController()] as const; createGhosttyTerminalMock .mockResolvedValueOnce(controllers[0]) @@ -61,6 +61,7 @@ describe("OpenClawTerminalPanel reconnect", () => { cwd: "/work/detached", confined: false, attached: false, + owner: "agent:agent:main:background-task", createdAtMs: 2, }, { @@ -109,7 +110,11 @@ describe("OpenClawTerminalPanel reconnect", () => { }); const menuText = panel.renderRoot.querySelector(".tp-session-menu")?.textContent; expect(menuText).toContain("/work/detached"); - expect(menuText).toContain("detached"); + expect( + [...panel.renderRoot.querySelectorAll(".tp-session")] + .find((row) => row.textContent?.includes("detached-agent")) + ?.querySelector(".tp-session__state")?.textContent, + ).toContain("agent"); expect(menuText).toContain("attached"); expect(menuText).toContain("current"); const detachedRow = [ @@ -126,6 +131,7 @@ describe("OpenClawTerminalPanel reconnect", () => { expect(new TextDecoder().decode(controllers[1].write.mock.calls[0]?.[0])).toBe( "detached history", ); + expect(panel.renderRoot.querySelector(".tabstrip-tab__badge")?.textContent).toBe("agent"); expect(sessionStorage.getItem("openclaw.terminal.sessions.v1")).toBe( JSON.stringify(["current-1", "detached-1"]), ); diff --git a/ui/src/components/terminal/terminal-session-picker.ts b/ui/src/components/terminal/terminal-session-picker.ts index 90090e69d4c6..4cdcb8a8e7f3 100644 --- a/ui/src/components/terminal/terminal-session-picker.ts +++ b/ui/src/components/terminal/terminal-session-picker.ts @@ -59,11 +59,14 @@ export function renderTerminalSessionPicker(props: TerminalSessionPickerProps) { ? html`
${t("terminal.noSessions")}
` : props.sessions.map((session) => { const current = props.currentSessionIds.has(session.sessionId); - const state = current - ? t("terminal.currentSession") - : session.attached - ? t("terminal.sessionAttached") - : t("terminal.detached"); + const agentOwned = session.owner?.startsWith("agent:") === true; + const state = `${agentOwned ? `${t("terminal.agentOwnedBadge")} ยท ` : ""}${ + current + ? t("terminal.currentSession") + : session.attached + ? t("terminal.sessionAttached") + : t("terminal.detached") + }`; return html`