diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index d636c42b813a..48e8fcfa5458 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -48463ce5e58d30211c147f877df21f07680b7dfd33a25a590f9d1bc96dda6b28 plugin-sdk-api-baseline.json -256ed5e9ea51a4149e7f751a724f9a24d601591005b6839fcc4b1d6b55c6001c plugin-sdk-api-baseline.jsonl +aaab273f4a65e33165556b0a9b35a4de1bebbef4786214de59de9efb32ffb1a3 plugin-sdk-api-baseline.json +05dc60f0b6a4189a11f6409da696d55c9ee246ae7e89b775f23f6dc4ac874e17 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index ce66a6cbb9c2..0c83b9d96ea6 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -255,6 +255,7 @@ usage endpoint failed or returned no usable usage data. | `plugin-sdk/hook-runtime` | Deprecated broad barrel for webhook/internal hook pipeline helpers; prefer focused hook/plugin runtime subpaths | | `plugin-sdk/lazy-runtime` | Lazy runtime import/binding helpers such as `createLazyRuntimeModule`, `createLazyRuntimeMethod`, and `createLazyRuntimeSurface` | | `plugin-sdk/process-runtime` | Process exec helpers | + | `plugin-sdk/node-host` | Node-host executable resolution and PTY resume helpers | | `plugin-sdk/cli-runtime` | Deprecated broad barrel for CLI formatting, wait, version, argument-invocation, and lazy command-group helpers; prefer focused CLI/runtime subpaths | | `plugin-sdk/qa-live-transport-scenarios` | Shared live transport QA scenario ids, baseline coverage helpers, and scenario-selection helper | | `plugin-sdk/qa-runner-runtime` | Supported facade exposing plugin QA scenarios through the CLI command surface | diff --git a/extensions/acpx/src/pi-session-catalog-plugin.ts b/extensions/acpx/src/pi-session-catalog-plugin.ts index e130e80f9af1..c8b79d63149f 100644 --- a/extensions/acpx/src/pi-session-catalog-plugin.ts +++ b/extensions/acpx/src/pi-session-catalog-plugin.ts @@ -1,7 +1,7 @@ import process from "node:process"; import { decodeNodePtyResumeParams, - resolveExecutableFromPathEnv, + resolveNodeHostExecutable, runNodePtyCommand, } from "openclaw/plugin-sdk/node-host"; import type { @@ -157,21 +157,31 @@ function createPiSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] { duplex: true, isAvailable: ({ config, env }) => storeAvailable({ config, env }) && - Boolean(resolveExecutableFromPathEnv("pi", env.PATH ?? "")), + Boolean( + resolveNodeHostExecutable("pi", { + env, + pathEnv: env.PATH ?? env.Path ?? "", + strategy: "direct", + }), + ), 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) { + const resolution = resolveNodeHostExecutable("pi", { + env: process.env, + pathEnv: process.env.PATH ?? process.env.Path ?? "", + strategy: "direct", + }); + if (!resolution) { throw new Error("Pi CLI is unavailable"); } return JSON.stringify( await runNodePtyCommand( { - file, + file: resolution.executable, args: ["--session", params.threadId], cwd: record.cwd, cols: params.cols, @@ -329,8 +339,10 @@ async function listPiHosts( }).then((page) => setTerminalCapability( page, - resolveExecutableFromPathEnv("pi", process.env.PATH ?? "", process.env, { - fallbackToLoginShell: true, + resolveNodeHostExecutable("pi", { + env: process.env, + pathEnv: process.env.PATH ?? "", + strategy: "fallback", }) !== undefined, ), )), @@ -401,9 +413,10 @@ async function openPiTerminal(params: { const title = `pi --session ${params.threadId.slice(0, 12)}…`; if (params.hostId === LOCAL_HOST_ID) { const record = await requireLocalPiSession(params.threadId); - const resolution = resolveExecutableFromPathEnv("pi", process.env.PATH ?? "", process.env, { - fallbackToLoginShell: true, - withPathEnv: true, + const resolution = resolveNodeHostExecutable("pi", { + env: process.env, + pathEnv: process.env.PATH ?? "", + strategy: "fallback", }); if (!resolution) { throw new Error("Pi CLI is unavailable"); diff --git a/extensions/acpx/src/pi-session-catalog.test.ts b/extensions/acpx/src/pi-session-catalog.test.ts index f008536fa753..5b816f4a0b53 100644 --- a/extensions/acpx/src/pi-session-catalog.test.ts +++ b/extensions/acpx/src/pi-session-catalog.test.ts @@ -13,15 +13,22 @@ vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => { return { ...actual, runNodePtyCommand: nodeHostMocks.runNodePtyCommand, - resolveExecutableFromPathEnv: ( + resolveNodeHostExecutable: ( command: string, - pathEnv: string, - env?: NodeJS.ProcessEnv, - options?: { withPathEnv?: boolean }, - ) => - options?.withPathEnv - ? actual.resolveExecutableFromPathEnv(command, pathEnv, env, { withPathEnv: true }) - : actual.resolveExecutableFromPathEnv(command, pathEnv, env), + options: { + env?: NodeJS.ProcessEnv; + pathEnv?: string; + includeExtensionless?: boolean; + }, + ) => { + const env = options.env ?? process.env; + return actual.resolveNodeHostExecutable(command, { + env, + pathEnv: options.pathEnv ?? env.PATH ?? env.Path ?? "", + includeExtensionless: options.includeExtensionless, + strategy: "direct", + }); + }, }; }); diff --git a/extensions/anthropic/session-catalog-executable.ts b/extensions/anthropic/session-catalog-executable.ts new file mode 100644 index 000000000000..124e1b93bc6d --- /dev/null +++ b/extensions/anthropic/session-catalog-executable.ts @@ -0,0 +1,11 @@ +import { resolveNodeHostExecutable } from "openclaw/plugin-sdk/node-host"; + +// Claude's npm shim can remain executable after its postinstall failed. Prefer +// the operator's login-shell command, then carry that PATH into the PTY. +export function resolveClaudeTerminalExecutable(env: NodeJS.ProcessEnv = process.env) { + return resolveNodeHostExecutable("claude", { + env, + pathEnv: env.PATH ?? env.Path ?? "", + strategy: "prefer", + }); +} diff --git a/extensions/anthropic/session-catalog-node-commands.ts b/extensions/anthropic/session-catalog-node-commands.ts index 0e9997efc539..171843769396 100644 --- a/extensions/anthropic/session-catalog-node-commands.ts +++ b/extensions/anthropic/session-catalog-node-commands.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { decodeNodePtyResumeParams, - resolveExecutableFromPathEnv, runNodePtyCommand, validateClaudeSessionId, } from "openclaw/plugin-sdk/node-host"; @@ -11,6 +10,7 @@ import type { OpenClawPluginNodeHostCommand, OpenClawPluginNodeInvokePolicy, } from "openclaw/plugin-sdk/plugin-entry"; +import { resolveClaudeTerminalExecutable } from "./session-catalog-executable.js"; import { CLAUDE_CLI_NODE_RUN_COMMAND, CLAUDE_SESSION_READ_COMMAND, @@ -97,29 +97,14 @@ export function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCom dangerous: false, duplex: true, isAvailable: ({ env }) => - claudeProjectsAvailable(env) && - Boolean( - resolveExecutableFromPathEnv("claude", env.PATH ?? "", env, { - fallbackToLoginShell: true, - preferLoginShell: true, - }), - ), + claudeProjectsAvailable(env) && Boolean(resolveClaudeTerminalExecutable(env)), handle: async (paramsJSON, io) => { if (!io) { throw new Error("Claude terminal command requires duplex transport"); } const params = decodeNodePtyResumeParams(paramsJSON, validateClaudeSessionId); const record = await requireLocalResumableClaudeSession(params.threadId); - const resolution = resolveExecutableFromPathEnv( - "claude", - process.env.PATH ?? "", - process.env, - { - fallbackToLoginShell: true, - preferLoginShell: true, - withPathEnv: true, - }, - ); + const resolution = resolveClaudeTerminalExecutable(); if (!resolution) { throw new Error("Claude CLI is unavailable"); } diff --git a/extensions/anthropic/session-catalog-terminal.ts b/extensions/anthropic/session-catalog-terminal.ts index 396aa1c8d0d0..85f367ebb03b 100644 --- a/extensions/anthropic/session-catalog-terminal.ts +++ b/extensions/anthropic/session-catalog-terminal.ts @@ -1,9 +1,9 @@ // Claude catalog terminal ownership: validated local and paired-node resume plans. import fs from "node:fs/promises"; -import { resolveExecutableFromPathEnv } from "openclaw/plugin-sdk/node-host"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import type { SessionCatalogTerminalPlan } from "openclaw/plugin-sdk/session-catalog"; import { CLAUDE_LOCAL_SESSION_HOST_ID } from "./session-catalog-adoption.js"; +import { resolveClaudeTerminalExecutable } from "./session-catalog-executable.js"; import { CLAUDE_SESSIONS_LIST_COMMAND, CLAUDE_TERMINAL_RESUME_COMMAND, @@ -26,12 +26,7 @@ type ClaudeTerminalDependencies = { export function isClaudeCliAvailable(pathEnv = process.env.PATH ?? ""): boolean { const env = { ...process.env, PATH: pathEnv }; - return ( - resolveExecutableFromPathEnv("claude", pathEnv, env, { - fallbackToLoginShell: true, - preferLoginShell: true, - }) !== undefined - ); + return resolveClaudeTerminalExecutable(env) !== undefined; } export function claudeNodeTerminalCapability(node: { @@ -96,11 +91,7 @@ export async function openClaudeCatalogTerminal( if (!source?.isFile()) { throw new ClaudeCatalogParamsError("Claude session transcript is unavailable"); } - const resolution = resolveExecutableFromPathEnv("claude", process.env.PATH ?? "", process.env, { - fallbackToLoginShell: true, - preferLoginShell: true, - withPathEnv: true, - }); + const resolution = resolveClaudeTerminalExecutable(); if (!resolution) { throw new ClaudeCatalogParamsError("Claude CLI is unavailable"); } diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index b1bf3799184e..d5116cb0d0cb 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -33,52 +33,42 @@ const nodeHostMocks = vi.hoisted(() => ({ vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => { const actual = await importOriginal(); - const resolveWithTestShellPath = ( - command: string, - pathEnv: string, - env: NodeJS.ProcessEnv | undefined, - options: - | { - fallbackToLoginShell?: boolean; - preferLoginShell?: boolean; - withPathEnv?: boolean; - } - | undefined, - ) => { - const fallbackPath = options?.fallbackToLoginShell - ? nodeHostMocks.userShellPaths.get(command) - : undefined; - const directResolution = actual.resolveExecutableFromPathEnv(command, pathEnv, env, { - withPathEnv: true, - }); - if (directResolution && !options?.preferLoginShell) { - return directResolution; - } - if (!fallbackPath) { - return directResolution; - } - const shellResolution = actual.resolveExecutableFromPathEnv(command, fallbackPath, env, { - withPathEnv: true, - }); - return shellResolution ? { ...shellResolution, pathEnv: fallbackPath } : directResolution; - }; return { ...actual, runNodePtyCommand: nodeHostMocks.runNodePtyCommand, - resolveExecutableFromPathEnv: ( + resolveNodeHostExecutable: ( command: string, - pathEnv: string, - env: NodeJS.ProcessEnv | undefined, - options: - | { - fallbackToLoginShell?: boolean; - preferLoginShell?: boolean; - withPathEnv?: boolean; - } - | undefined, + options: { + env?: NodeJS.ProcessEnv; + pathEnv?: string; + includeExtensionless?: boolean; + strategy: "direct" | "fallback" | "prefer"; + }, ) => { - const resolution = resolveWithTestShellPath(command, pathEnv, env, options); - return options?.withPathEnv ? resolution : resolution?.executable; + const env = options.env ?? process.env; + const pathEnv = options.pathEnv ?? env.PATH ?? env.Path ?? ""; + const direct = actual.resolveNodeHostExecutable(command, { + env, + pathEnv, + includeExtensionless: options.includeExtensionless, + strategy: "direct", + }); + if (direct && options.strategy !== "prefer") { + return direct; + } + const shellPath = nodeHostMocks.userShellPaths.get(command); + if (!shellPath) { + return direct; + } + const shellExecutable = actual.resolveNodeHostExecutable(command, { + env, + pathEnv: shellPath, + includeExtensionless: options.includeExtensionless, + strategy: "direct", + }); + return shellExecutable + ? { executable: shellExecutable.executable, pathEnv: shellPath } + : direct; }, }; }); diff --git a/extensions/codex/src/session-catalog-terminal.ts b/extensions/codex/src/session-catalog-terminal.ts index c510ab3a5a9e..388a977b46b3 100644 --- a/extensions/codex/src/session-catalog-terminal.ts +++ b/extensions/codex/src/session-catalog-terminal.ts @@ -1,7 +1,7 @@ // Codex catalog terminal ownership: validated resume commands and terminal plans. import { decodeNodePtyResumeParams, - resolveExecutableFromPathEnv, + resolveNodeHostExecutable, runNodePtyCommand, } from "openclaw/plugin-sdk/node-host"; import type { @@ -32,15 +32,14 @@ export const CODEX_TERMINAL_RESUME_COMMAND = "codex.terminal.resume.v1"; export function resolveLocalCodexTerminalExecutable( env: NodeJS.ProcessEnv = process.env, ): string | undefined { - return resolveExecutableFromPathEnv("codex", env.PATH ?? env.Path ?? "", env, { - fallbackToLoginShell: true, - }); + return resolveLocalCodexTerminalResolution(env)?.executable; } -function resolveLocalCodexTerminalExecutableWithPathEnv(env: NodeJS.ProcessEnv = process.env) { - return resolveExecutableFromPathEnv("codex", env.PATH ?? env.Path ?? "", env, { - fallbackToLoginShell: true, - withPathEnv: true, +function resolveLocalCodexTerminalResolution(env: NodeJS.ProcessEnv = process.env) { + return resolveNodeHostExecutable("codex", { + env, + pathEnv: env.PATH ?? env.Path ?? "", + strategy: "fallback", }); } @@ -98,7 +97,14 @@ export function createCodexTerminalNodeHostCommand( cap: CODEX_APP_SERVER_THREADS_CAPABILITY, dangerous: false, duplex: true, - isAvailable: ({ env }) => Boolean(resolveExecutableFromPathEnv("codex", env.PATH ?? "")), + isAvailable: ({ env }) => + Boolean( + resolveNodeHostExecutable("codex", { + env, + pathEnv: env.PATH ?? env.Path ?? "", + strategy: "direct", + }), + ), handle: async (paramsJSON, io) => { if (!io) { throw new Error("Codex terminal command requires duplex transport"); @@ -113,14 +119,18 @@ export function createCodexTerminalNodeHostCommand( return value; }); const record = await requireCatalogEligibleThread(control, resume.threadId); - const file = resolveExecutableFromPathEnv("codex", process.env.PATH ?? ""); - if (!file) { + const resolution = resolveNodeHostExecutable("codex", { + env: process.env, + pathEnv: process.env.PATH ?? process.env.Path ?? "", + strategy: "direct", + }); + if (!resolution) { throw new Error("Codex CLI is unavailable"); } return JSON.stringify( await runNodePtyCommand( { - file, + file: resolution.executable, args: ["resume", resume.threadId], cwd: record.cwd, cols: resume.cols, @@ -182,7 +192,7 @@ export async function openCodexCatalogTerminal(params: { const title = `codex resume ${params.threadId.slice(0, 8)}…`; if (params.hostId === CODEX_LOCAL_SESSION_HOST_ID) { const record = await requireCatalogEligibleThread(params.control, params.threadId); - const resolution = resolveLocalCodexTerminalExecutableWithPathEnv(); + const resolution = resolveLocalCodexTerminalResolution(); // A managed app-server may exist without a local CLI. Fail closed so // terminal resume never targets a different machine or missing binary. if (!resolution) { diff --git a/extensions/codex/src/session-catalog.test.ts b/extensions/codex/src/session-catalog.test.ts index 8c194e5ffcc2..27e37e36e5b1 100644 --- a/extensions/codex/src/session-catalog.test.ts +++ b/extensions/codex/src/session-catalog.test.ts @@ -82,15 +82,22 @@ vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => { return { ...actual, runNodePtyCommand: nodeHostMocks.runNodePtyCommand, - resolveExecutableFromPathEnv: ( + resolveNodeHostExecutable: ( command: string, - pathEnv: string, - env?: NodeJS.ProcessEnv, - options?: { withPathEnv?: boolean }, - ) => - options?.withPathEnv - ? actual.resolveExecutableFromPathEnv(command, pathEnv, env, { withPathEnv: true }) - : actual.resolveExecutableFromPathEnv(command, pathEnv, env), + options: { + env?: NodeJS.ProcessEnv; + pathEnv?: string; + includeExtensionless?: boolean; + }, + ) => { + const env = options.env ?? process.env; + return actual.resolveNodeHostExecutable(command, { + env, + pathEnv: options.pathEnv ?? env.PATH ?? env.Path ?? "", + includeExtensionless: options.includeExtensionless, + strategy: "direct", + }); + }, }; }); diff --git a/extensions/opencode/session-catalog-plugin.ts b/extensions/opencode/session-catalog-plugin.ts index 54a3efb55db1..f37b54ee48cb 100644 --- a/extensions/opencode/session-catalog-plugin.ts +++ b/extensions/opencode/session-catalog-plugin.ts @@ -1,7 +1,7 @@ import { accessSync, constants, statSync } from "node:fs"; import path from "node:path"; import process from "node:process"; -import { resolveExecutableFromPathEnv } from "openclaw/plugin-sdk/node-host"; +import { resolveNodeHostExecutable } from "openclaw/plugin-sdk/node-host"; import type { OpenClawPluginApi, OpenClawPluginNodeHostCommand, @@ -323,8 +323,10 @@ async function listOpenCodeHosts( const hosts: SessionCatalogHost[] = []; if ( (!requested || requested.has(LOCAL_HOST_ID)) && - resolveExecutableFromPathEnv("opencode", process.env.PATH ?? "", process.env, { - fallbackToLoginShell: true, + resolveNodeHostExecutable("opencode", { + env: process.env, + pathEnv: process.env.PATH ?? "", + strategy: "fallback", }) ) { try { diff --git a/extensions/opencode/session-catalog-terminal.ts b/extensions/opencode/session-catalog-terminal.ts index 03f7e2ecf24b..02ddfffe7b35 100644 --- a/extensions/opencode/session-catalog-terminal.ts +++ b/extensions/opencode/session-catalog-terminal.ts @@ -1,7 +1,7 @@ // OpenCode catalog terminal ownership: validated resume commands and terminal plans. import { decodeNodePtyResumeParams, - resolveExecutableFromPathEnv, + resolveNodeHostExecutable, runNodePtyCommand, } from "openclaw/plugin-sdk/node-host"; import type { OpenClawPluginNodeHostCommand } from "openclaw/plugin-sdk/plugin-entry"; @@ -60,14 +60,18 @@ export function createOpenCodeTerminalNodeHostCommand( } const params = decodeNodePtyResumeParams(paramsJSON, validateOpenCodeThreadId); const record = await requireLocalOpenCodeSession(params.threadId); - const file = resolveExecutableFromPathEnv("opencode", process.env.PATH ?? ""); - if (!file) { + const resolution = resolveNodeHostExecutable("opencode", { + env: process.env, + pathEnv: process.env.PATH ?? process.env.Path ?? "", + strategy: "direct", + }); + if (!resolution) { throw new Error("OpenCode CLI is unavailable"); } return JSON.stringify( await runNodePtyCommand( { - file, + file: resolution.executable, args: ["--session", params.threadId], cwd: record.cwd, cols: params.cols, @@ -112,12 +116,11 @@ export async function openOpenCodeCatalogTerminal( const title = `opencode --session ${params.threadId.slice(0, 12)}…`; if (params.hostId === OPENCODE_LOCAL_SESSION_HOST_ID) { const record = await requireLocalOpenCodeSession(params.threadId); - const resolution = resolveExecutableFromPathEnv( - "opencode", - process.env.PATH ?? "", - process.env, - { fallbackToLoginShell: true, withPathEnv: true }, - ); + const resolution = resolveNodeHostExecutable("opencode", { + env: process.env, + pathEnv: process.env.PATH ?? "", + strategy: "fallback", + }); if (!resolution) { throw new Error("OpenCode CLI is unavailable"); } diff --git a/extensions/opencode/session-catalog.test.ts b/extensions/opencode/session-catalog.test.ts index 803dbf774432..113529ecd4e3 100644 --- a/extensions/opencode/session-catalog.test.ts +++ b/extensions/opencode/session-catalog.test.ts @@ -13,15 +13,22 @@ vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => { return { ...actual, runNodePtyCommand: nodeHostMocks.runNodePtyCommand, - resolveExecutableFromPathEnv: ( + resolveNodeHostExecutable: ( command: string, - pathEnv: string, - env?: NodeJS.ProcessEnv, - options?: { withPathEnv?: boolean }, - ) => - options?.withPathEnv - ? actual.resolveExecutableFromPathEnv(command, pathEnv, env, { withPathEnv: true }) - : actual.resolveExecutableFromPathEnv(command, pathEnv, env), + options: { + env?: NodeJS.ProcessEnv; + pathEnv?: string; + includeExtensionless?: boolean; + }, + ) => { + const env = options.env ?? process.env; + return actual.resolveNodeHostExecutable(command, { + env, + pathEnv: options.pathEnv ?? env.PATH ?? env.Path ?? "", + includeExtensionless: options.includeExtensionless, + strategy: "direct", + }); + }, }; }); @@ -614,7 +621,7 @@ describe("OpenCode session catalog", () => { } as unknown as OpenClawPluginApi; registerOpenCodeSessionCatalog(api); - const listing = provider!.list({}); + const listing = provider!.list({ hostIds: ["node:node-a", "node:node-b"] }); await vi.waitFor(() => expect(invoke).toHaveBeenCalledTimes(2)); releaseSlow?.(page("session-a")); await expect(listing).resolves.toEqual([ diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index 1986008cf100..996fbca83e3a 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -12,7 +12,7 @@ let clearShellEnvAppliedKeys: ShellEnvModule["clearShellEnvAppliedKeys"]; let getShellEnvAppliedKeys: ShellEnvModule["getShellEnvAppliedKeys"]; let getShellPathFromLoginShell: ShellEnvModule["getShellPathFromLoginShell"]; let loadShellEnvFallback: ShellEnvModule["loadShellEnvFallback"]; -let resolveExecutableFromUserShellPathWithPathEnv: ShellEnvModule["resolveExecutableFromUserShellPathWithPathEnv"]; +let resolveExecutableFromUserShellPath: ShellEnvModule["resolveExecutableFromUserShellPath"]; let resolveShellEnvFallbackTimeoutMs: ShellEnvModule["resolveShellEnvFallbackTimeoutMs"]; let shouldDeferShellEnvFallback: ShellEnvModule["shouldDeferShellEnvFallback"]; let shouldEnableShellEnvFallback: ShellEnvModule["shouldEnableShellEnvFallback"]; @@ -26,7 +26,7 @@ beforeEach(async () => { getShellEnvAppliedKeys, getShellPathFromLoginShell, loadShellEnvFallback, - resolveExecutableFromUserShellPathWithPathEnv, + resolveExecutableFromUserShellPath, resolveShellEnvFallbackTimeoutMs, shouldDeferShellEnvFallback, shouldEnableShellEnvFallback, @@ -572,11 +572,10 @@ describe("shell env fallback", () => { it("resolves from the daemon PATH without probing the login shell", () => { const exec = vi.fn(() => Buffer.from("PATH=/bin\0")); - const result = resolveExecutableFromUserShellPathWithPathEnv("sh", { + const result = resolveExecutableFromUserShellPath("sh", { env: { PATH: "/bin" }, - exec: exec as unknown as Parameters< - typeof resolveExecutableFromUserShellPathWithPathEnv - >[1]["exec"], + strategy: "fallback", + exec: exec as unknown as Parameters[1]["exec"], }); expect(result).toEqual({ executable: "/bin/sh" }); @@ -586,11 +585,10 @@ describe("shell env fallback", () => { it("resolves from the login-shell PATH when the daemon PATH misses the executable", () => { const exec = vi.fn(() => Buffer.from("PATH=/bin\0")); - const result = resolveExecutableFromUserShellPathWithPathEnv("sh", { + const result = resolveExecutableFromUserShellPath("sh", { env: { PATH: "/missing", SHELL: "/bin/sh" }, - exec: exec as unknown as Parameters< - typeof resolveExecutableFromUserShellPathWithPathEnv - >[1]["exec"], + strategy: "fallback", + exec: exec as unknown as Parameters[1]["exec"], }); expect(result).toEqual({ executable: "/bin/sh", pathEnv: "/bin" }); @@ -612,12 +610,10 @@ describe("shell env fallback", () => { fs.writeFileSync(shellTool, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); const exec = vi.fn(() => Buffer.from(`PATH=${shellBin}\0`)); - const result = resolveExecutableFromUserShellPathWithPathEnv("tool", { + const result = resolveExecutableFromUserShellPath("tool", { env: { PATH: daemonBin, SHELL: "/bin/sh" }, - exec: exec as unknown as Parameters< - typeof resolveExecutableFromUserShellPathWithPathEnv - >[1]["exec"], - preferLoginShell: true, + strategy: "prefer", + exec: exec as unknown as Parameters[1]["exec"], }); expect(result).toEqual({ executable: shellTool, pathEnv: shellBin }); @@ -627,11 +623,10 @@ describe("shell env fallback", () => { it("returns the login-shell PATH needed by env-based executable launchers", () => { const exec = vi.fn(() => Buffer.from("PATH=/bin\0")); - const result = resolveExecutableFromUserShellPathWithPathEnv("sh", { + const result = resolveExecutableFromUserShellPath("sh", { env: { PATH: "/missing", SHELL: "/bin/sh" }, - exec: exec as unknown as Parameters< - typeof resolveExecutableFromUserShellPathWithPathEnv - >[1]["exec"], + strategy: "fallback", + exec: exec as unknown as Parameters[1]["exec"], }); expect(result).toEqual({ executable: "/bin/sh", pathEnv: "/bin" }); diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index 85f9d96034fe..b51b7413d505 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -321,17 +321,17 @@ export function getShellPathFromLoginShell(opts: { type UserShellExecutableResolution = { executable: string; - /** Present only when resolution required the login-shell PATH fallback. */ + /** Present only when the login-shell PATH selected the executable. */ pathEnv?: string; }; -export function resolveExecutableFromUserShellPathWithPathEnv( +export function resolveExecutableFromUserShellPath( executable: string, opts: { env: NodeJS.ProcessEnv; pathEnv?: string; includeExtensionless?: boolean; - preferLoginShell?: boolean; + strategy: "fallback" | "prefer"; timeoutMs?: number; exec?: typeof execFileSync; platform?: NodeJS.Platform; @@ -343,9 +343,7 @@ export function resolveExecutableFromUserShellPathWithPathEnv( opts.env, { includeExtensionless: opts.includeExtensionless }, ); - // Interactive catalog terminals should mirror the operator's shell command. - // A service PATH can contain package shims that are absent or unusable there. - if (direct && !opts.preferLoginShell) { + if (direct && opts.strategy === "fallback") { return { executable: direct }; } const shellPath = getShellPathFromLoginShell({ diff --git a/src/plugin-sdk/node-host.ts b/src/plugin-sdk/node-host.ts index 86a71d646c43..b3cd1a94a395 100644 --- a/src/plugin-sdk/node-host.ts +++ b/src/plugin-sdk/node-host.ts @@ -1,5 +1,5 @@ -import { resolveExecutableFromPathEnv as resolveExecutableFromPathEnvDirect } from "../infra/executable-path.js"; -import { resolveExecutableFromUserShellPathWithPathEnv } from "../infra/shell-env.js"; +import { resolveExecutableFromPathEnv } from "../infra/executable-path.js"; +import { resolveExecutableFromUserShellPath as resolveExecutableFromUserShellPathInternal } from "../infra/shell-env.js"; export { decodeNodePtyResumeParams, @@ -10,53 +10,30 @@ export { export { validateClaudeSessionId } from "../node-host/invoke-agent-cli-claude-params.js"; export type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js"; -export function resolveExecutableFromPathEnv( +/** Resolve a node-host executable using the selected PATH source policy. */ +export function resolveNodeHostExecutable( executable: string, - pathEnv: string, - env: NodeJS.ProcessEnv | undefined, options: { + env?: NodeJS.ProcessEnv; + pathEnv?: string; includeExtensionless?: boolean; - fallbackToLoginShell?: boolean; - preferLoginShell?: boolean; - withPathEnv: true; + strategy: "direct" | "fallback" | "prefer"; }, -): { executable: string; pathEnv?: string } | undefined; -export function resolveExecutableFromPathEnv( - executable: string, - pathEnv: string, - env?: NodeJS.ProcessEnv, - options?: { - includeExtensionless?: boolean; - fallbackToLoginShell?: boolean; - preferLoginShell?: boolean; - withPathEnv?: false; - }, -): string | undefined; -export function resolveExecutableFromPathEnv( - executable: string, - pathEnv: string, - env?: NodeJS.ProcessEnv, - options?: { - includeExtensionless?: boolean; - fallbackToLoginShell?: boolean; - preferLoginShell?: boolean; - withPathEnv?: boolean; - }, -): string | { executable: string; pathEnv?: string } | undefined { - let resolution: { executable: string; pathEnv?: string } | undefined; - if (!options?.fallbackToLoginShell) { - const resolved = resolveExecutableFromPathEnvDirect(executable, pathEnv, env, options); - resolution = resolved ? { executable: resolved } : undefined; - } else { - // Local catalog terminals launch with this same login-shell PATH. Carry it - // forward because npm-style launchers may use `#!/usr/bin/env node`. - const shellEnv = env ?? process.env; - resolution = resolveExecutableFromUserShellPathWithPathEnv(executable, { - env: shellEnv, - pathEnv, - includeExtensionless: options.includeExtensionless, - preferLoginShell: options.preferLoginShell, - }); +): { executable: string; pathEnv?: string } | undefined { + const env = options.env ?? process.env; + if (options.strategy === "direct") { + const resolved = resolveExecutableFromPathEnv( + executable, + options.pathEnv ?? env.PATH ?? env.Path ?? "", + env, + { includeExtensionless: options.includeExtensionless }, + ); + return resolved ? { executable: resolved } : undefined; } - return options?.withPathEnv ? resolution : resolution?.executable; + return resolveExecutableFromUserShellPathInternal(executable, { + env, + pathEnv: options.pathEnv, + includeExtensionless: options.includeExtensionless, + strategy: options.strategy, + }); } diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts index 82551273a353..f88712d599dc 100644 --- a/ui/src/components/app-sidebar-session-catalogs.ts +++ b/ui/src/components/app-sidebar-session-catalogs.ts @@ -271,15 +271,15 @@ function renderCatalogSessionRow( liveRowsByKey: ReadonlyMap, params: SessionCatalogGroupsParams, ) { + const rawTimestamp = session.recencyAt ?? session.updatedAt ?? session.createdAt; + const timestamp = + typeof rawTimestamp === "number" && rawTimestamp < 1_000_000_000_000 + ? rawTimestamp * 1000 + : rawTimestamp; const adoptedRow = session.openClawSessionKey ? liveRowsByKey.get(session.openClawSessionKey) : undefined; if (adoptedRow) { - const rawTimestamp = session.recencyAt ?? session.updatedAt ?? session.createdAt; - const timestamp = - typeof rawTimestamp === "number" && rawTimestamp < 1_000_000_000_000 - ? rawTimestamp * 1000 - : rawTimestamp; const label = session.name || session.threadId; return params.renderLiveRow(adoptedRow, { label, @@ -296,11 +296,6 @@ function renderCatalogSessionRow( const search = searchForSession(key); const href = `${pathForRoute("chat", params.basePath)}${search}`; const active = params.routeSessionKey !== "" && key === params.routeSessionKey; - const rawTimestamp = session.recencyAt ?? session.updatedAt ?? session.createdAt; - const timestamp = - typeof rawTimestamp === "number" && rawTimestamp < 1_000_000_000_000 - ? rawTimestamp * 1000 - : rawTimestamp; const canOpenTerminal = session.canOpenTerminal === true && params.terminalAvailable; const openTerminal = () => params.onOpenTerminal(catalogKey); const openMenu = (x: number, y: number, trigger?: HTMLElement) =>