diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 5aa46c13f228..2328c4ad4f71 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -382,7 +382,10 @@ catalogs on the Gateway and paired nodes. A node advertises `opencode.sessions.list.v1` / `opencode.sessions.read.v1` when the `opencode` CLI is installed, and `acpx.pi.sessions.list.v1` / `acpx.pi.sessions.read.v1` when Pi's session directory exists. Approve the node pairing upgrade when new -commands first appear. +commands first appear. When the matching CLI is also available, the node adds +`opencode.terminal.resume.v1` or `acpx.pi.terminal.resume.v1`; the existing row +menu and viewer header can then reopen the selected session in its owning +terminal with `opencode --session ` or `pi --session `. OpenCode reads through its official CLI JSON/export surface. Pi reads its documented JSONL session store, including project and global `settings.json` @@ -390,6 +393,10 @@ session directories plus `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR` overrides. Both catalogs are enabled by default; turn them off in the Web UI under **Config > Plugins**. +Terminal resume uses the stored session working directory and the same +allowlisted duplex PTY relay as Codex and Claude. It does not expose arbitrary +node command execution. + ## Invoking commands Low-level (raw RPC): diff --git a/extensions/acpx/src/pi-session-catalog-plugin.ts b/extensions/acpx/src/pi-session-catalog-plugin.ts index b54e673b24ce..5ac4b5cd55c3 100644 --- a/extensions/acpx/src/pi-session-catalog-plugin.ts +++ b/extensions/acpx/src/pi-session-catalog-plugin.ts @@ -1,4 +1,9 @@ import process from "node:process"; +import { + decodeNodePtyResumeParams, + resolveExecutableFromPathEnv, + runNodePtyCommand, +} from "openclaw/plugin-sdk/node-host"; import type { OpenClawPluginApi, OpenClawPluginNodeHostCommand, @@ -9,6 +14,7 @@ import type { SessionCatalogHost, SessionCatalogProvider, SessionCatalogSession, + SessionCatalogTerminalPlan, SessionCatalogTranscriptItem, SessionsCatalogReadResult, } from "openclaw/plugin-sdk/session-catalog"; @@ -23,6 +29,7 @@ import { piSessionStoreAvailable } from "./pi-session-paths.js"; const PI_SESSIONS_LIST_COMMAND = "acpx.pi.sessions.list.v1"; const PI_SESSION_READ_COMMAND = "acpx.pi.sessions.read.v1"; +const PI_TERMINAL_RESUME_COMMAND = "acpx.pi.terminal.resume.v1"; const CAPABILITY = "pi-sessions"; const LOCAL_HOST_ID = "gateway"; @@ -41,6 +48,13 @@ const TRANSCRIPT_ITEM_TYPES = new Set([ "other", ]); +function validatePiThreadId(value: unknown): string { + if (typeof value !== "string" || !SESSION_ID_PATTERN.test(value)) { + throw new Error("INVALID_REQUEST: threadId is invalid"); + } + return value; +} + function isOptionalString(value: unknown): boolean { return value === undefined || typeof value === "string"; } @@ -116,14 +130,14 @@ function isPiSessionCatalogEnabled(pluginConfig: unknown): boolean { } function createPiSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] { - const available = ({ config, env }: { config: unknown; env: NodeJS.ProcessEnv }) => + const storeAvailable = ({ config, env }: { config: unknown; env: NodeJS.ProcessEnv }) => fullConfigCatalogEnabled(config) && piSessionStoreAvailable(env); return [ { command: PI_SESSIONS_LIST_COMMAND, cap: CAPABILITY, dangerous: false, - isAvailable: available, + isAvailable: storeAvailable, handle: async (paramsJSON) => JSON.stringify(await listLocalPiSessionPage(parseNodeParams(paramsJSON))), }, @@ -131,19 +145,52 @@ function createPiSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] { command: PI_SESSION_READ_COMMAND, cap: CAPABILITY, dangerous: false, - isAvailable: available, + isAvailable: storeAvailable, handle: async (paramsJSON) => JSON.stringify(await readLocalPiTranscriptPage(parseNodeParams(paramsJSON))), }, + { + command: PI_TERMINAL_RESUME_COMMAND, + cap: CAPABILITY, + dangerous: false, + duplex: true, + isAvailable: ({ config, env }) => + storeAvailable({ config, env }) && + Boolean(resolveExecutableFromPathEnv("pi", env.PATH ?? "")), + handle: async (paramsJSON, io) => { + if (!io) { + throw new Error("Pi terminal command requires duplex transport"); + } + const params = decodeNodePtyResumeParams(paramsJSON, validatePiThreadId); + const record = await requireLocalPiSession(params.threadId); + const file = resolveExecutableFromPathEnv("pi", process.env.PATH ?? ""); + if (!file) { + throw new Error("Pi CLI is unavailable"); + } + return JSON.stringify( + await runNodePtyCommand( + { + file, + args: ["--session", params.threadId], + cwd: record.cwd, + cols: params.cols, + rows: params.rows, + }, + io, + ), + ); + }, + }, ]; } function createPiSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] { return [ { - commands: [PI_SESSIONS_LIST_COMMAND, PI_SESSION_READ_COMMAND], + commands: [PI_SESSIONS_LIST_COMMAND, PI_SESSION_READ_COMMAND, PI_TERMINAL_RESUME_COMMAND], defaultPlatforms: ["macos", "linux", "windows"], - handle: (context) => context.invokeNode(), + handle: (context) => + context.command === PI_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(), }, ]; } @@ -160,6 +207,13 @@ function unwrapNodePayload(value: unknown): unknown { type CatalogNode = Awaited>["nodes"][number]; +function setTerminalCapability(page: PiSessionPage, canOpenTerminal: boolean): PiSessionPage { + for (const session of page.sessions) { + session.canOpenTerminal = canOpenTerminal; + } + return page; +} + async function listPiNodeHost( runtime: PluginRuntime, query: Parameters[0], @@ -194,7 +248,13 @@ async function listPiNodeHost( timeoutMs: NODE_TIMEOUT_MS, scopes: ["operator.write"], }); - return { ...common, ...parseNodeSessionPage(unwrapNodePayload(raw)) }; + const page = parseNodeSessionPage(unwrapNodePayload(raw)); + const commands = node.invocableCommands ?? node.commands; + const canOpenTerminal = commands?.includes(PI_TERMINAL_RESUME_COMMAND) === true; + return { + ...common, + ...setTerminalCapability(page, canOpenTerminal), + }; } catch { return { ...common, @@ -263,7 +323,12 @@ async function listPiHosts( limit: query.limitPerHost, ...(searchTerm ? { searchTerm } : {}), cursor: query.cursors?.[LOCAL_HOST_ID], - })), + }).then((page) => + setTerminalCapability( + page, + resolveExecutableFromPathEnv("pi", process.env.PATH ?? "") !== undefined, + ), + )), }); } catch { hosts.push({ @@ -294,6 +359,85 @@ async function listPiHosts( return [...hosts, ...nodeHosts]; } +async function requireLocalPiSession(threadId: string): Promise { + const page = await listLocalPiSessionPage({ searchTerm: threadId, limit: MAX_PAGE_LIMIT }); + const record = page.sessions.find((session) => session.threadId === threadId); + if (!record) { + throw new Error("Pi session is unavailable"); + } + return record; +} + +async function resolveNodePiSession(params: { + runtime: PluginRuntime; + nodeId: string; + threadId: string; +}): Promise { + const raw = await params.runtime.nodes.invoke({ + nodeId: params.nodeId, + command: PI_SESSIONS_LIST_COMMAND, + params: { searchTerm: params.threadId, limit: MAX_PAGE_LIMIT }, + timeoutMs: NODE_TIMEOUT_MS, + scopes: ["operator.write"], + }); + const page = parseNodeSessionPage(unwrapNodePayload(raw)); + const record = page.sessions.find((session) => session.threadId === params.threadId); + if (!record) { + throw new Error("Pi session is unavailable"); + } + return record; +} + +async function openPiTerminal(params: { + runtime: PluginRuntime; + hostId: string; + threadId: string; +}): Promise { + const title = `pi --session ${params.threadId.slice(0, 12)}…`; + if (params.hostId === LOCAL_HOST_ID) { + const record = await requireLocalPiSession(params.threadId); + const executable = resolveExecutableFromPathEnv("pi", process.env.PATH ?? ""); + if (!executable) { + throw new Error("Pi CLI is unavailable"); + } + return { + kind: "local", + argv: [executable, "--session", params.threadId], + ...(record.cwd ? { cwd: record.cwd } : {}), + title, + }; + } + if (!params.hostId.startsWith("node:")) { + throw new Error("hostId is invalid"); + } + const nodeId = params.hostId.slice("node:".length); + const node = (await params.runtime.nodes.list()).nodes.find((candidate) => { + const commands = candidate.invocableCommands ?? candidate.commands; + return ( + candidate.nodeId === nodeId && + candidate.connected === true && + commands?.includes(PI_SESSIONS_LIST_COMMAND) === true && + commands.includes(PI_TERMINAL_RESUME_COMMAND) + ); + }); + if (!node) { + throw new Error("paired-node Pi terminal is unavailable"); + } + const record = await resolveNodePiSession({ + runtime: params.runtime, + nodeId, + threadId: params.threadId, + }); + return { + kind: "node", + nodeId, + command: PI_TERMINAL_RESUME_COMMAND, + paramsJSON: JSON.stringify({ threadId: params.threadId }), + ...(record.cwd ? { cwd: record.cwd } : {}), + title, + }; +} + async function readPiTranscript( runtime: PluginRuntime, request: Parameters[0], @@ -345,6 +489,7 @@ export function registerPiSessionCatalog(api: OpenClawPluginApi): void { label: "Pi", list: async (query) => await listPiHosts(api.runtime, query), read: async (request) => await readPiTranscript(api.runtime, request), + openTerminal: async (request) => await openPiTerminal({ runtime: api.runtime, ...request }), }); for (const command of createPiSessionNodeHostCommands()) { api.registerNodeHostCommand(command); diff --git a/extensions/acpx/src/pi-session-catalog.test.ts b/extensions/acpx/src/pi-session-catalog.test.ts index b2eafc0dace4..f395b827ad5f 100644 --- a/extensions/acpx/src/pi-session-catalog.test.ts +++ b/extensions/acpx/src/pi-session-catalog.test.ts @@ -3,18 +3,30 @@ import os from "node:os"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { afterEach, describe, expect, it, vi } from "vitest"; + +const nodeHostMocks = vi.hoisted(() => ({ + runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })), +})); + +vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runNodePtyCommand: nodeHostMocks.runNodePtyCommand }; +}); + import { registerPiSessionCatalog } from "./pi-session-catalog-plugin.js"; import { listLocalPiSessionPage, readLocalPiTranscriptPage } from "./pi-session-catalog.js"; import { piSessionStore } from "./pi-session-paths.js"; const PI_SESSIONS_LIST_COMMAND = "acpx.pi.sessions.list.v1"; const PI_SESSION_READ_COMMAND = "acpx.pi.sessions.read.v1"; +const PI_TERMINAL_RESUME_COMMAND = "acpx.pi.terminal.resume.v1"; const temporaryDirectories: string[] = []; const originalSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR; const originalAgentDir = process.env.PI_CODING_AGENT_DIR; const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; +const originalPath = process.env.PATH; async function createPiStore(assistantText = "hi"): Promise { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-catalog-")); @@ -80,6 +92,16 @@ async function createPiStore(assistantText = "hi"): Promise { return directory; } +async function installFakePi(): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-cli-")); + temporaryDirectories.push(directory); + const executable = path.join(directory, "pi"); + await fs.writeFile(executable, "#!/bin/sh\nexit 0\n"); + await fs.chmod(executable, 0o755); + process.env.PATH = `${directory}${path.delimiter}${originalPath ?? ""}`; + return directory; +} + function registerPiNodeHostCommands(): Parameters< OpenClawPluginApi["registerNodeHostCommand"] >[0][] { @@ -96,6 +118,8 @@ function registerPiNodeHostCommands(): Parameters< } afterEach(async () => { + nodeHostMocks.runNodePtyCommand.mockClear(); + process.env.PATH = originalPath; if (originalSessionDir === undefined) { delete process.env.PI_CODING_AGENT_SESSION_DIR; } else { @@ -552,16 +576,18 @@ describe("Pi session catalog", () => { it("auto-detects the store and honors the node-local Web UI switch", async () => { const directory = await createPiStore(); + const binDirectory = await installFakePi(); const commands = registerPiNodeHostCommands(); expect(commands.map((command) => command.command)).toEqual([ PI_SESSIONS_LIST_COMMAND, PI_SESSION_READ_COMMAND, + PI_TERMINAL_RESUME_COMMAND, ]); expect( commands.every((command) => command.isAvailable?.({ config: {}, - env: { PI_CODING_AGENT_SESSION_DIR: directory }, + env: { PI_CODING_AGENT_SESSION_DIR: directory, PATH: binDirectory }, } as never), ), ).toBe(true); @@ -571,7 +597,7 @@ describe("Pi session catalog", () => { config: { plugins: { entries: { acpx: { config: { piSessionCatalog: { enabled: false } } } } }, }, - env: { PI_CODING_AGENT_SESSION_DIR: directory }, + env: { PI_CODING_AGENT_SESSION_DIR: directory, PATH: binDirectory }, } as never), ), ).toBe(false); @@ -593,6 +619,134 @@ describe("Pi session catalog", () => { ).toBe(false); }); + it.runIf(process.platform !== "win32")( + "opens validated local Pi sessions with the upstream terminal resume contract", + async () => { + await createPiStore(); + await installFakePi(); + let provider: Parameters[0] | undefined; + const commands: Parameters[0][] = []; + registerPiSessionCatalog({ + pluginConfig: {}, + runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } }, + registerSessionCatalog: (value: NonNullable) => { + provider = value; + }, + registerNodeHostCommand: ( + command: Parameters[0], + ) => commands.push(command), + registerNodeInvokePolicy: vi.fn(), + } as unknown as OpenClawPluginApi); + + await expect(provider!.list({ hostIds: ["gateway"] })).resolves.toEqual([ + expect.objectContaining({ + sessions: [expect.objectContaining({ threadId: "pi-session", canOpenTerminal: true })], + }), + ]); + await expect( + provider!.openTerminal!({ hostId: "gateway", threadId: "pi-session" }), + ).resolves.toEqual({ + kind: "local", + argv: [expect.stringMatching(/pi$/u), "--session", "pi-session"], + cwd: "/workspace", + title: "pi --session pi-session…", + }); + await expect( + provider!.openTerminal!({ hostId: "gateway", threadId: "missing" }), + ).rejects.toThrow("Pi session is unavailable"); + + const terminal = commands.find((command) => command.command === PI_TERMINAL_RESUME_COMMAND)!; + const io = { + signal: new AbortController().signal, + onInput: vi.fn(), + emitChunk: vi.fn(), + }; + await expect( + terminal.handle?.( + JSON.stringify({ threadId: "pi-session", cols: 100, rows: 30 }), + io as never, + ), + ).resolves.toBe(JSON.stringify({ exitCode: 0 })); + expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith( + { + file: expect.stringMatching(/pi$/u), + args: ["--session", "pi-session"], + cwd: "/workspace", + cols: 100, + rows: 30, + }, + io, + ); + await expect( + terminal.handle?.(JSON.stringify({ threadId: "--help", cols: 100, rows: 30 }), io as never), + ).rejects.toThrow("threadId is invalid"); + }, + ); + + it("opens paired-node Pi sessions only through the advertised terminal command", async () => { + let provider: Parameters[0] | undefined; + const page = { + payloadJSON: JSON.stringify({ + sessions: [ + { + threadId: "pi-remote", + cwd: "/remote/workspace", + status: "stored", + archived: false, + canContinue: false, + canArchive: false, + }, + ], + }), + }; + const invoke = vi.fn().mockResolvedValue(page); + registerPiSessionCatalog({ + pluginConfig: {}, + runtime: { + nodes: { + list: vi.fn().mockResolvedValue({ + nodes: [ + { + nodeId: "node-1", + connected: true, + commands: [PI_SESSIONS_LIST_COMMAND, PI_TERMINAL_RESUME_COMMAND], + }, + ], + }), + invoke, + }, + }, + registerSessionCatalog: (value: NonNullable) => { + provider = value; + }, + registerNodeHostCommand: vi.fn(), + registerNodeInvokePolicy: vi.fn(), + } as unknown as OpenClawPluginApi); + + await expect(provider!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([ + expect.objectContaining({ + sessions: [expect.objectContaining({ threadId: "pi-remote", canOpenTerminal: true })], + }), + ]); + await expect( + provider!.openTerminal!({ hostId: "node:node-1", threadId: "pi-remote" }), + ).resolves.toEqual({ + kind: "node", + nodeId: "node-1", + command: PI_TERMINAL_RESUME_COMMAND, + paramsJSON: JSON.stringify({ threadId: "pi-remote" }), + cwd: "/remote/workspace", + title: "pi --session pi-remote…", + }); + expect(invoke).toHaveBeenLastCalledWith({ + nodeId: "node-1", + command: PI_SESSIONS_LIST_COMMAND, + params: { searchTerm: "pi-remote", limit: 100 }, + timeoutMs: 20_000, + scopes: ["operator.write"], + }); + }); + it("does not register the catalog when explicitly disabled", () => { const registerSessionCatalog = vi.fn(); const api = { diff --git a/extensions/opencode/session-catalog-plugin.ts b/extensions/opencode/session-catalog-plugin.ts index 92904aeaa5ba..8bfb3063ad14 100644 --- a/extensions/opencode/session-catalog-plugin.ts +++ b/extensions/opencode/session-catalog-plugin.ts @@ -15,6 +15,20 @@ import type { SessionsCatalogReadResult, } from "openclaw/plugin-sdk/session-catalog"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + OPENCODE_LOCAL_SESSION_HOST_ID as LOCAL_HOST_ID, + OPENCODE_NODE_INVOKE_TIMEOUT_MS as NODE_TIMEOUT_MS, + OPENCODE_SESSIONS_CAPABILITY as CAPABILITY, + OPENCODE_SESSIONS_LIST_COMMAND, + OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT as MAX_PAGE_LIMIT, + OPENCODE_SESSION_ID_PATTERN as SESSION_ID_PATTERN, + OPENCODE_SESSION_READ_COMMAND, + OPENCODE_TERMINAL_RESUME_COMMAND, +} from "./session-catalog-shared.js"; +import { + createOpenCodeTerminalNodeHostCommand, + openOpenCodeCatalogTerminal, +} from "./session-catalog-terminal.js"; import { listLocalOpenCodeSessionPage, optionalOpenCodeString, @@ -22,17 +36,15 @@ import { type OpenCodeSessionPage, } from "./session-catalog.js"; -export const OPENCODE_SESSIONS_LIST_COMMAND = "opencode.sessions.list.v1"; -export const OPENCODE_SESSION_READ_COMMAND = "opencode.sessions.read.v1"; +export { + OPENCODE_SESSIONS_LIST_COMMAND, + OPENCODE_SESSION_READ_COMMAND, + OPENCODE_TERMINAL_RESUME_COMMAND, +} from "./session-catalog-shared.js"; -const CAPABILITY = "opencode-sessions"; -const LOCAL_HOST_ID = "gateway"; -const MAX_PAGE_LIMIT = 100; const MAX_HOSTS = 100; const MAX_CURSOR_LENGTH = 128; const MAX_SEARCH_LENGTH = 500; -const NODE_TIMEOUT_MS = 35_000; -const SESSION_ID_PATTERN = /^(?!-)[A-Za-z0-9._:-]{1,256}$/u; const TRANSCRIPT_ITEM_TYPES = new Set([ "userMessage", "agentMessage", @@ -163,15 +175,21 @@ export function createOpenCodeSessionNodeHostCommands(): OpenClawPluginNodeHostC handle: async (paramsJSON) => JSON.stringify(await readLocalOpenCodeTranscriptPage(parseNodeParams(paramsJSON))), }, + createOpenCodeTerminalNodeHostCommand(available), ]; } export function createOpenCodeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] { return [ { - commands: [OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_READ_COMMAND], + commands: [ + OPENCODE_SESSIONS_LIST_COMMAND, + OPENCODE_SESSION_READ_COMMAND, + OPENCODE_TERMINAL_RESUME_COMMAND, + ], defaultPlatforms: ["macos", "linux", "windows"], - handle: (context) => context.invokeNode(), + handle: (context) => + context.command === OPENCODE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(), }, ]; } @@ -188,6 +206,16 @@ function unwrapNodePayload(value: unknown): unknown { type CatalogNode = Awaited>["nodes"][number]; +function setTerminalCapability( + page: OpenCodeSessionPage, + canOpenTerminal: boolean, +): OpenCodeSessionPage { + for (const session of page.sessions) { + session.canOpenTerminal = canOpenTerminal; + } + return page; +} + async function listOpenCodeNodeHost( runtime: PluginRuntime, query: Parameters[0], @@ -222,7 +250,13 @@ async function listOpenCodeNodeHost( timeoutMs: NODE_TIMEOUT_MS, scopes: ["operator.write"], }); - return { ...common, ...parseNodeSessionPage(unwrapNodePayload(raw)) }; + const page = parseNodeSessionPage(unwrapNodePayload(raw)); + const commands = node.invocableCommands ?? node.commands; + const canOpenTerminal = commands?.includes(OPENCODE_TERMINAL_RESUME_COMMAND) === true; + return { + ...common, + ...setTerminalCapability(page, canOpenTerminal), + }; } catch { return { ...common, @@ -294,7 +328,7 @@ async function listOpenCodeHosts( limit: query.limitPerHost, ...(searchTerm ? { searchTerm } : {}), cursor: query.cursors?.[LOCAL_HOST_ID], - })), + }).then((page) => setTerminalCapability(page, true))), }); } catch { hosts.push({ @@ -378,6 +412,13 @@ export function registerOpenCodeSessionCatalog(api: OpenClawPluginApi): void { label: "OpenCode", list: async (query) => await listOpenCodeHosts(api.runtime, query), read: async (request) => await readOpenCodeTranscript(api.runtime, request), + openTerminal: async (request) => + await openOpenCodeCatalogTerminal({ + runtime: api.runtime, + ...request, + parseNodeSessionPage, + unwrapNodePayload, + }), }); for (const command of createOpenCodeSessionNodeHostCommands()) { api.registerNodeHostCommand(command); diff --git a/extensions/opencode/session-catalog-shared.ts b/extensions/opencode/session-catalog-shared.ts new file mode 100644 index 000000000000..31591145a332 --- /dev/null +++ b/extensions/opencode/session-catalog-shared.ts @@ -0,0 +1,9 @@ +export const OPENCODE_SESSIONS_LIST_COMMAND = "opencode.sessions.list.v1"; +export const OPENCODE_SESSION_READ_COMMAND = "opencode.sessions.read.v1"; +export const OPENCODE_TERMINAL_RESUME_COMMAND = "opencode.terminal.resume.v1"; + +export const OPENCODE_SESSIONS_CAPABILITY = "opencode-sessions"; +export const OPENCODE_LOCAL_SESSION_HOST_ID = "gateway"; +export const OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT = 100; +export const OPENCODE_NODE_INVOKE_TIMEOUT_MS = 35_000; +export const OPENCODE_SESSION_ID_PATTERN = /^(?!-)[A-Za-z0-9._:-]{1,256}$/u; diff --git a/extensions/opencode/session-catalog-terminal.ts b/extensions/opencode/session-catalog-terminal.ts new file mode 100644 index 000000000000..f8e401eac822 --- /dev/null +++ b/extensions/opencode/session-catalog-terminal.ts @@ -0,0 +1,157 @@ +// OpenCode catalog terminal ownership: validated resume commands and terminal plans. +import { + decodeNodePtyResumeParams, + resolveExecutableFromPathEnv, + runNodePtyCommand, +} from "openclaw/plugin-sdk/node-host"; +import type { OpenClawPluginNodeHostCommand } from "openclaw/plugin-sdk/plugin-entry"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; +import type { + SessionCatalogSession, + SessionCatalogTerminalPlan, +} from "openclaw/plugin-sdk/session-catalog"; +import { + OPENCODE_LOCAL_SESSION_HOST_ID, + OPENCODE_NODE_INVOKE_TIMEOUT_MS, + OPENCODE_SESSIONS_CAPABILITY, + OPENCODE_SESSIONS_LIST_COMMAND, + OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT, + OPENCODE_SESSION_ID_PATTERN, + OPENCODE_TERMINAL_RESUME_COMMAND, +} from "./session-catalog-shared.js"; +import { listLocalOpenCodeSessionPage, type OpenCodeSessionPage } from "./session-catalog.js"; + +type OpenCodeTerminalDependencies = { + parseNodeSessionPage: (value: unknown) => OpenCodeSessionPage; + unwrapNodePayload: (value: unknown) => unknown; +}; + +function validateOpenCodeThreadId(value: unknown): string { + if (typeof value !== "string" || !OPENCODE_SESSION_ID_PATTERN.test(value)) { + throw new Error("INVALID_REQUEST: threadId is invalid"); + } + return value; +} + +async function requireLocalOpenCodeSession(threadId: string): Promise { + const page = await listLocalOpenCodeSessionPage({ + searchTerm: threadId, + limit: OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT, + }); + const record = page.sessions.find((session) => session.threadId === threadId); + if (!record) { + throw new Error("OpenCode session is unavailable"); + } + return record; +} + +export function createOpenCodeTerminalNodeHostCommand( + isAvailable: NonNullable, +): OpenClawPluginNodeHostCommand { + return { + command: OPENCODE_TERMINAL_RESUME_COMMAND, + cap: OPENCODE_SESSIONS_CAPABILITY, + dangerous: false, + duplex: true, + isAvailable, + handle: async (paramsJSON, io) => { + if (!io) { + throw new Error("OpenCode terminal command requires duplex transport"); + } + const params = decodeNodePtyResumeParams(paramsJSON, validateOpenCodeThreadId); + const record = await requireLocalOpenCodeSession(params.threadId); + const file = resolveExecutableFromPathEnv("opencode", process.env.PATH ?? ""); + if (!file) { + throw new Error("OpenCode CLI is unavailable"); + } + return JSON.stringify( + await runNodePtyCommand( + { + file, + args: ["--session", params.threadId], + cwd: record.cwd, + cols: params.cols, + rows: params.rows, + }, + io, + ), + ); + }, + }; +} + +async function resolveNodeOpenCodeSession( + params: { + runtime: PluginRuntime; + nodeId: string; + threadId: string; + } & OpenCodeTerminalDependencies, +): Promise { + const raw = await params.runtime.nodes.invoke({ + nodeId: params.nodeId, + command: OPENCODE_SESSIONS_LIST_COMMAND, + params: { searchTerm: params.threadId, limit: OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT }, + timeoutMs: OPENCODE_NODE_INVOKE_TIMEOUT_MS, + scopes: ["operator.write"], + }); + const page = params.parseNodeSessionPage(params.unwrapNodePayload(raw)); + const record = page.sessions.find((session) => session.threadId === params.threadId); + if (!record) { + throw new Error("OpenCode session is unavailable"); + } + return record; +} + +export async function openOpenCodeCatalogTerminal( + params: { + runtime: PluginRuntime; + hostId: string; + threadId: string; + } & OpenCodeTerminalDependencies, +): Promise { + const title = `opencode --session ${params.threadId.slice(0, 12)}…`; + if (params.hostId === OPENCODE_LOCAL_SESSION_HOST_ID) { + const record = await requireLocalOpenCodeSession(params.threadId); + const executable = resolveExecutableFromPathEnv("opencode", process.env.PATH ?? ""); + if (!executable) { + throw new Error("OpenCode CLI is unavailable"); + } + return { + kind: "local", + argv: [executable, "--session", params.threadId], + ...(record.cwd ? { cwd: record.cwd } : {}), + title, + }; + } + if (!params.hostId.startsWith("node:")) { + throw new Error("hostId is invalid"); + } + const nodeId = params.hostId.slice("node:".length); + const node = (await params.runtime.nodes.list()).nodes.find((candidate) => { + const commands = candidate.invocableCommands ?? candidate.commands; + return ( + candidate.nodeId === nodeId && + candidate.connected === true && + commands?.includes(OPENCODE_SESSIONS_LIST_COMMAND) === true && + commands.includes(OPENCODE_TERMINAL_RESUME_COMMAND) + ); + }); + if (!node) { + throw new Error("paired-node OpenCode terminal is unavailable"); + } + const record = await resolveNodeOpenCodeSession({ + runtime: params.runtime, + nodeId, + threadId: params.threadId, + parseNodeSessionPage: params.parseNodeSessionPage, + unwrapNodePayload: params.unwrapNodePayload, + }); + return { + kind: "node", + nodeId, + command: OPENCODE_TERMINAL_RESUME_COMMAND, + paramsJSON: JSON.stringify({ threadId: params.threadId }), + ...(record.cwd ? { cwd: record.cwd } : {}), + title, + }; +} diff --git a/extensions/opencode/session-catalog.test.ts b/extensions/opencode/session-catalog.test.ts index 029a2bd65d12..0509958847d7 100644 --- a/extensions/opencode/session-catalog.test.ts +++ b/extensions/opencode/session-catalog.test.ts @@ -3,11 +3,23 @@ import os from "node:os"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { afterEach, describe, expect, it, vi } from "vitest"; + +const nodeHostMocks = vi.hoisted(() => ({ + runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })), +})); + +vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runNodePtyCommand: nodeHostMocks.runNodePtyCommand }; +}); + import { + createOpenCodeSessionNodeInvokePolicies, createOpenCodeSessionNodeHostCommands, isOpenCodeSessionCatalogEnabled, OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_READ_COMMAND, + OPENCODE_TERMINAL_RESUME_COMMAND, registerOpenCodeSessionCatalog, } from "./session-catalog-plugin.js"; import { @@ -85,6 +97,7 @@ if (args[0] === "--pure" && args[1] === "db" && args.includes("--format") && arg } afterEach(async () => { + nodeHostMocks.runNodePtyCommand.mockClear(); process.env.PATH = originalPath; if (originalUnrelatedEnv === undefined) { delete process.env.CATALOG_UNRELATED_ENV; @@ -193,6 +206,7 @@ describe("OpenCode session catalog", () => { expect(commands.map((command) => command.command)).toEqual([ OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_READ_COMMAND, + OPENCODE_TERMINAL_RESUME_COMMAND, ]); expect( commands.every((command) => @@ -222,6 +236,150 @@ describe("OpenCode session catalog", () => { }, ); + it.runIf(process.platform !== "win32")( + "opens validated local sessions with the upstream terminal resume contract", + async () => { + await installFakeOpenCode(); + let provider: Parameters[0] | undefined; + registerOpenCodeSessionCatalog({ + pluginConfig: {}, + runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } }, + registerSessionCatalog: (value: NonNullable) => { + provider = value; + }, + registerNodeHostCommand: vi.fn(), + registerNodeInvokePolicy: vi.fn(), + } as unknown as OpenClawPluginApi); + + await expect(provider!.list({ hostIds: ["gateway"] })).resolves.toEqual([ + expect.objectContaining({ + sessions: [expect.objectContaining({ threadId: "ses_test", canOpenTerminal: true })], + }), + ]); + await expect( + provider!.openTerminal!({ hostId: "gateway", threadId: "ses_test" }), + ).resolves.toEqual({ + kind: "local", + argv: [expect.stringMatching(/opencode$/u), "--session", "ses_test"], + cwd: "/workspace", + title: "opencode --session ses_test…", + }); + await expect( + provider!.openTerminal!({ hostId: "gateway", threadId: "missing" }), + ).rejects.toThrow("OpenCode session is unavailable"); + }, + ); + + it.runIf(process.platform !== "win32")( + "runs only catalog-validated OpenCode sessions through the node PTY", + async () => { + await installFakeOpenCode(); + const terminal = createOpenCodeSessionNodeHostCommands().find( + (command) => command.command === OPENCODE_TERMINAL_RESUME_COMMAND, + ); + const io = { + signal: new AbortController().signal, + onInput: vi.fn(), + emitChunk: vi.fn(), + }; + await expect( + terminal!.handle?.( + JSON.stringify({ threadId: "ses_test", cols: 100, rows: 30 }), + io as never, + ), + ).resolves.toBe(JSON.stringify({ exitCode: 0 })); + expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith( + { + file: expect.stringMatching(/opencode$/u), + args: ["--session", "ses_test"], + cwd: "/workspace", + cols: 100, + rows: 30, + }, + io, + ); + await expect( + terminal!.handle?.( + JSON.stringify({ threadId: "--help", cols: 100, rows: 30 }), + io as never, + ), + ).rejects.toThrow("threadId is invalid"); + + const invokeNode = vi.fn(() => ({ ok: false as const, error: "unexpected" })); + const policy = createOpenCodeSessionNodeInvokePolicies()[0]!; + expect( + policy.handle({ command: OPENCODE_TERMINAL_RESUME_COMMAND, invokeNode } as never), + ).toEqual({ ok: true }); + expect( + policy.handle({ command: OPENCODE_SESSIONS_LIST_COMMAND, invokeNode } as never), + ).toEqual({ ok: false, error: "unexpected" }); + }, + ); + + it("marks paired-node sessions terminal-capable only when the resume command is advertised", async () => { + let provider: Parameters[0] | undefined; + const page = { + payloadJSON: JSON.stringify({ + sessions: [ + { + threadId: "ses_remote", + cwd: "/remote/workspace", + status: "stored", + archived: false, + canContinue: false, + canArchive: false, + }, + ], + }), + }; + const invoke = vi.fn().mockResolvedValue(page); + registerOpenCodeSessionCatalog({ + pluginConfig: {}, + runtime: { + nodes: { + list: vi.fn().mockResolvedValue({ + nodes: [ + { + nodeId: "node-1", + connected: true, + commands: [OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_TERMINAL_RESUME_COMMAND], + }, + ], + }), + invoke, + }, + }, + registerSessionCatalog: (value: NonNullable) => { + provider = value; + }, + registerNodeHostCommand: vi.fn(), + registerNodeInvokePolicy: vi.fn(), + } as unknown as OpenClawPluginApi); + + await expect(provider!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([ + expect.objectContaining({ + sessions: [expect.objectContaining({ threadId: "ses_remote", canOpenTerminal: true })], + }), + ]); + await expect( + provider!.openTerminal!({ hostId: "node:node-1", threadId: "ses_remote" }), + ).resolves.toEqual({ + kind: "node", + nodeId: "node-1", + command: OPENCODE_TERMINAL_RESUME_COMMAND, + paramsJSON: JSON.stringify({ threadId: "ses_remote" }), + cwd: "/remote/workspace", + title: "opencode --session ses_remote…", + }); + expect(invoke).toHaveBeenLastCalledWith({ + nodeId: "node-1", + command: OPENCODE_SESSIONS_LIST_COMMAND, + params: { searchTerm: "ses_remote", limit: 100 }, + timeoutMs: 35_000, + scopes: ["operator.write"], + }); + }); + it("does not register the catalog when explicitly disabled", () => { const registerSessionCatalog = vi.fn(); const api = {