mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
refactor: simplify native session executable resolution (#108169)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user