refactor: simplify native session executable resolution (#108169)

This commit is contained in:
Peter Steinberger
2026-07-15 01:49:45 -07:00
committed by GitHub
parent bfb015dbc5
commit 6eda184f27
17 changed files with 207 additions and 215 deletions
@@ -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
+1
View File
@@ -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 |
@@ -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");
+15 -8
View File
@@ -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",
});
},
};
});
@@ -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",
});
}
@@ -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");
}
@@ -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");
}
+31 -41
View File
@@ -33,52 +33,42 @@ const nodeHostMocks = vi.hoisted(() => ({
vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/node-host")>();
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;
},
};
});
@@ -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) {
+15 -8
View File
@@ -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",
});
},
};
});
@@ -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 {
+13 -10
View File
@@ -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");
}
+16 -9
View File
@@ -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([
+14 -19
View File
@@ -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<typeof resolveExecutableFromUserShellPath>[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<typeof resolveExecutableFromUserShellPath>[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<typeof resolveExecutableFromUserShellPath>[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<typeof resolveExecutableFromUserShellPath>[1]["exec"],
});
expect(result).toEqual({ executable: "/bin/sh", pathEnv: "/bin" });
+4 -6
View File
@@ -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({
+23 -46
View File
@@ -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,
});
}
@@ -271,15 +271,15 @@ function renderCatalogSessionRow(
liveRowsByKey: ReadonlyMap<string, GatewaySessionRow>,
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) =>