fix(ui): keep agent terminals backgrounded (#125101)

This commit is contained in:
Peter Steinberger
2026-08-16 23:32:06 -07:00
committed by GitHub
parent 1204225303
commit ac5c8be001
6 changed files with 38 additions and 71 deletions
+1 -1
View File
@@ -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.
+18 -23
View File
@@ -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<ReturnType<typeof spawnTerminalPty>>;
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<string, unknown> };
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",
);
+2 -39
View File
@@ -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<Pick<TaskRecord, "taskId" | "status" | "childSessionKey"> | undefined>;
callGateway?: InProcessGatewayCaller;
getGatewayContext?: () => TerminalToolGatewayContext | undefined;
};
@@ -117,17 +110,6 @@ function readDimension(
throw new ToolInputError(`${key} required`);
}
function readShow(params: Record<string, unknown>): 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<string, unknown>,
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);
}
+1 -1
View File
@@ -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",
@@ -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<HTMLElement>(".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"]),
);
@@ -59,11 +59,14 @@ export function renderTerminalSessionPicker(props: TerminalSessionPickerProps) {
? html`<div class="tp-session-empty">${t("terminal.noSessions")}</div>`
: 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`<button
class="tp-session"
type="button"