diff --git a/extensions/openshell/src/cli.test.ts b/extensions/openshell/src/cli.test.ts index d039a571ebc..88dc7764860 100644 --- a/extensions/openshell/src/cli.test.ts +++ b/extensions/openshell/src/cli.test.ts @@ -1,8 +1,18 @@ -import { describe, expect, it } from "vitest"; -import { buildExecRemoteCommand, buildOpenShellBaseArgv, shellEscape } from "./cli.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildExecRemoteCommand, + buildOpenShellBaseArgv, + resolveOpenShellCommand, + setBundledOpenShellCommandResolverForTest, + shellEscape, +} from "./cli.js"; import { resolveOpenShellPluginConfig } from "./config.js"; describe("openshell cli helpers", () => { + afterEach(() => { + setBundledOpenShellCommandResolverForTest(); + }); + it("builds base argv with gateway overrides", () => { const config = resolveOpenShellPluginConfig({ command: "/usr/local/bin/openshell", @@ -18,6 +28,20 @@ describe("openshell cli helpers", () => { ]); }); + it("prefers the bundled openshell command when available", () => { + setBundledOpenShellCommandResolverForTest(() => "/tmp/node_modules/.bin/openshell"); + const config = resolveOpenShellPluginConfig(undefined); + + expect(resolveOpenShellCommand("openshell")).toBe("/tmp/node_modules/.bin/openshell"); + expect(buildOpenShellBaseArgv(config)).toEqual(["/tmp/node_modules/.bin/openshell"]); + }); + + it("falls back to the PATH command when no bundled openshell is present", () => { + setBundledOpenShellCommandResolverForTest(() => null); + + expect(resolveOpenShellCommand("openshell")).toBe("openshell"); + }); + it("shell escapes single quotes", () => { expect(shellEscape(`a'b`)).toBe(`'a'"'"'b'`); }); diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts index a35b6aba69f..19b61667865 100644 --- a/extensions/openshell/src/cli.ts +++ b/extensions/openshell/src/cli.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; import { buildExecRemoteCommand, createSshSandboxSessionFromConfigText, @@ -9,14 +12,54 @@ import type { ResolvedOpenShellPluginConfig } from "./config.js"; export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/sandbox"; +const require = createRequire(import.meta.url); + +let cachedBundledOpenShellCommand: string | null | undefined; +let bundledCommandResolverForTest: (() => string | null) | undefined; + export type OpenShellExecContext = { config: ResolvedOpenShellPluginConfig; sandboxName: string; timeoutMs?: number; }; +export function setBundledOpenShellCommandResolverForTest(resolver?: () => string | null): void { + bundledCommandResolverForTest = resolver; + cachedBundledOpenShellCommand = undefined; +} + +function resolveBundledOpenShellCommand(): string | null { + if (bundledCommandResolverForTest) { + return bundledCommandResolverForTest(); + } + if (cachedBundledOpenShellCommand !== undefined) { + return cachedBundledOpenShellCommand; + } + try { + const packageJsonPath = require.resolve("openshell/package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + bin?: string | Record; + }; + const relativeBin = + typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.openshell; + cachedBundledOpenShellCommand = relativeBin + ? path.resolve(path.dirname(packageJsonPath), relativeBin) + : null; + } catch { + cachedBundledOpenShellCommand = null; + } + return cachedBundledOpenShellCommand; +} + +export function resolveOpenShellCommand(command: string): string { + if (command !== "openshell") { + return command; + } + return resolveBundledOpenShellCommand() ?? command; +} + export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): string[] { - const argv = [config.command]; + const argv = [resolveOpenShellCommand(config.command)]; if (config.gateway) { argv.push("--gateway", config.gateway); } diff --git a/package.json b/package.json index 5caecd8431b..cc6deba4444 100644 --- a/package.json +++ b/package.json @@ -811,6 +811,9 @@ "optional": true } }, + "optionalDependencies": { + "openshell": "0.1.0" + }, "engines": { "node": ">=22.16.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7127967c3d..afacca2cd63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,10 @@ importers: vitest: specifier: ^4.1.0 version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + optionalDependencies: + openshell: + specifier: 0.1.0 + version: 0.1.0 extensions/acpx: dependencies: @@ -3268,6 +3272,9 @@ packages: '@swc/helpers@0.5.19': resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + '@telegraf/types@7.1.0': + resolution: {integrity: sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==} + '@thi.ng/bitstream@2.4.44': resolution: {integrity: sha512-SN5GtdycUC8J9kRn4+GAcAJ93F9vKtaiI/SjpOyLl911bWNlF8gANFFCdgBkzbF2DHcpKflHxrVVfrYB6aCdsw==} engines: {node: '>=18'} @@ -3859,12 +3866,21 @@ packages: bs58@6.0.0: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + buffer-alloc-unsafe@1.1.0: + resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} + + buffer-alloc@1.2.0: + resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-fill@1.0.0: + resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -4160,6 +4176,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dotenv@17.3.1: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} @@ -5187,6 +5207,10 @@ packages: mpg123-decoder@1.0.3: resolution: {integrity: sha512-+fjxnWigodWJm3+4pndi+KUg9TBojgn31DPk85zEsim7C6s0X5Ztc/hQYdytXkwuGXH+aB0/aEkG40Emukv6oQ==} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -5374,6 +5398,11 @@ packages: zod: optional: true + openshell@0.1.0: + resolution: {integrity: sha512-B7jLewH+d73hraWcrSFgNOjvd+frW5JPejkTpqgj2EJBjX/Yk1Y4blgP5pDl4FwrBxfmwsTKR08Uwgrdo+xpSg==} + engines: {node: '>=18'} + hasBin: true + opus-decoder@0.7.11: resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==} @@ -5431,6 +5460,10 @@ packages: resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} engines: {node: '>=8'} + p-timeout@4.1.0: + resolution: {integrity: sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==} + engines: {node: '>=10'} + p-timeout@7.0.1: resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} engines: {node: '>=20'} @@ -5849,6 +5882,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-compare@1.1.4: + resolution: {integrity: sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==} + safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -5856,6 +5892,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sandwich-stream@2.0.2: + resolution: {integrity: sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==} + engines: {node: '>= 0.10'} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -6144,6 +6184,11 @@ packages: teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + telegraf@4.16.3: + resolution: {integrity: sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==} + engines: {node: ^12.20.0 || >=14.13.1} + hasBin: true + text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -9743,6 +9788,9 @@ snapshots: dependencies: tslib: 2.8.1 + '@telegraf/types@7.1.0': + optional: true + '@thi.ng/bitstream@2.4.44': dependencies: '@thi.ng/errors': 2.6.6 @@ -10406,10 +10454,22 @@ snapshots: dependencies: base-x: 5.0.1 + buffer-alloc-unsafe@1.1.0: + optional: true + + buffer-alloc@1.2.0: + dependencies: + buffer-alloc-unsafe: 1.1.0 + buffer-fill: 1.0.0 + optional: true + buffer-crc32@0.2.13: {} buffer-equal-constant-time@1.0.1: {} + buffer-fill@1.0.0: + optional: true + buffer-from@1.1.2: {} bun-types@1.3.9: @@ -10689,6 +10749,9 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dotenv@16.6.1: + optional: true + dotenv@17.3.1: {} dts-resolver@2.1.3: {} @@ -11872,6 +11935,9 @@ snapshots: '@wasm-audio-decoders/common': 9.0.7 optional: true + mri@1.2.0: + optional: true + mrmime@2.0.1: {} ms@2.1.3: {} @@ -12099,6 +12165,15 @@ snapshots: ws: 8.20.0 zod: 4.3.6 + openshell@0.1.0: + dependencies: + dotenv: 16.6.1 + telegraf: 4.16.3 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + opus-decoder@0.7.11: dependencies: '@wasm-audio-decoders/common': 9.0.7 @@ -12200,6 +12275,9 @@ snapshots: dependencies: p-finally: 1.0.0 + p-timeout@4.1.0: + optional: true + p-timeout@7.0.1: {} pac-proxy-agent@7.2.0: @@ -12693,10 +12771,18 @@ snapshots: safe-buffer@5.2.1: {} + safe-compare@1.1.4: + dependencies: + buffer-alloc: 1.2.0 + optional: true + safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} + sandwich-stream@2.0.2: + optional: true + sax@1.6.0: optional: true @@ -13053,6 +13139,21 @@ snapshots: - bare-abort-controller - react-native-b4a + telegraf@4.16.3: + dependencies: + '@telegraf/types': 7.1.0 + abort-controller: 3.0.0 + debug: 4.4.3 + mri: 1.2.0 + node-fetch: 2.7.0 + p-timeout: 4.1.0 + safe-compare: 1.1.4 + sandwich-stream: 2.0.2 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + text-decoder@1.2.7: dependencies: b4a: 1.8.0