From db54b927083f6e2226c5d67205b0a2286f762697 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 23 Oct 2026 20:42:17 +0800 Subject: [PATCH] fix(dsh): call ShellExecutor.execute() on dsh 0.2.x, keep run() for 0.1.x dsh 0.2.x replaced ShellExecutor.run() with execute(), which returns a ShellExecution whose foreground outcome is handle.result(); 0.1.x has only run(). plugins/dsh declares @deepseek-ai/dsh-tools for BOTH generations (>=0.1.1-rc.2 <0.2.0 || >=0.2.0-rc.1 <0.3.0), so under 0.2.x every skillopt_* tool call failed with "shell.run is not a function". Feature-detect the seam instead of switching it: execute() wins when present, run() stays the 0.1.x path. The test covers all three shapes (0.2.x handle, 0.1.x direct, transitional both-present) and dies on the pre-fix body with the same production error. --- plugins/dsh/scripts/test-shell-seam.mjs | 76 +++++++++++++++++++++++++ plugins/dsh/src/index.js | 16 +++++- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 plugins/dsh/scripts/test-shell-seam.mjs diff --git a/plugins/dsh/scripts/test-shell-seam.mjs b/plugins/dsh/scripts/test-shell-seam.mjs new file mode 100644 index 000000000..daa2ae590 --- /dev/null +++ b/plugins/dsh/scripts/test-shell-seam.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// Host-shell seam, both dsh generations. +// +// dsh 0.1.x exposes ShellExecutor.run(); 0.2.x exposes execute(), whose +// ShellExecution carries the outcome on result(). plugins/dsh declares +// @deepseek-ai/dsh-tools for both ranges, so the call must be feature-detected: +// a hard switch either way throws " is not a function" on the other +// generation, and dsh 0.2.x users hit it on every skillopt_* tool call. +// +// Usage: node scripts/test-shell-seam.mjs +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const { runShell } = await import(pathToFileURL(join(root, 'src/index.js')).href) + +let failures = 0 +function check(name, cond, detail = '') { + if (cond) console.log(` ✅ ${name}`) + else { + failures++ + console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`) + } +} + +const spec = { command: "'python' 'scripts/sleep.py' 'status'", timeoutMs: 1000 } + +console.log('1. dsh 0.2.x: execute() returns a handle, outcome from result()') +{ + const seen = [] + const shell = { + execute: async (s) => { + seen.push(['execute', s]) + return { result: async () => ({ exitCode: 0, stdout: 'ok' }) } + }, + } + const r = await runShell(shell, spec) + check('execute() used', seen.length === 1 && seen[0][0] === 'execute', JSON.stringify(seen)) + check('spec passed through unchanged', seen[0]?.[1] === spec) + check('handle.result() outcome returned', r?.exitCode === 0 && r?.stdout === 'ok', JSON.stringify(r)) +} + +console.log('2. dsh 0.1.x: run() returns the outcome directly') +{ + const seen = [] + const shell = { + run: async (s) => { + seen.push(['run', s]) + return { exitCode: 7, stderr: 'boom' } + }, + } + const r = await runShell(shell, spec) + check('run() used', seen.length === 1 && seen[0][0] === 'run', JSON.stringify(seen)) + check('outcome returned as-is', r?.exitCode === 7 && r?.stderr === 'boom', JSON.stringify(r)) +} + +console.log('3. transitional executor exposing both: execute() wins') +{ + const calls = [] + const shell = { + execute: async () => { + calls.push('execute') + return { result: async () => ({ exitCode: 1 }) } + }, + run: async () => { + calls.push('run') + return { exitCode: 2 } + }, + } + const r = await runShell(shell, spec) + check('only execute() called', calls.join() === 'execute', calls.join()) + check('execute() outcome wins', r?.exitCode === 1, JSON.stringify(r)) +} + +console.log(failures === 0 ? '\nALL SHELL SEAM CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`) +process.exit(failures === 0 ? 0 : 1) diff --git a/plugins/dsh/src/index.js b/plugins/dsh/src/index.js index bcc7753cc..f3851cbbf 100644 --- a/plugins/dsh/src/index.js +++ b/plugins/dsh/src/index.js @@ -155,6 +155,20 @@ function quoteArgv(argv) { return IS_WINDOWS ? `& ${quoted}` : quoted } +/** + * Run one resolved spec on the host shell executor. + * + * dsh 0.2.x: `shell.execute(spec)` returns a ShellExecution; the foreground + * outcome is `await handle.result()`. dsh 0.1.x has only `shell.run(spec)`. + * plugins/dsh declares @deepseek-ai/dsh-tools for BOTH generations + * (`>=0.1.1-rc.2 <0.2.0 || >=0.2.0-rc.1 <0.3.0`), so the seam is detected + * instead of hard-switched — a hard switch breaks whichever side is older. + */ +export async function runShell(shell, spec) { + if (typeof shell.execute === 'function') return (await shell.execute(spec)).result() + return shell.run(spec) +} + // Pick exactly the parameters a tool declares. dsh's parameter schema does // not reject undeclared properties by default (no additionalProperties:false), // so without this filter the model could inject fields (backend, model, json, @@ -370,7 +384,7 @@ export function apply(ctx, config = {}) { } const spec = typeof shell.resolve === 'function' ? shell.resolve(request) : request try { - const result = await shell.run(spec) + const result = await runShell(shell, spec) // Distinguish the executor's timeout (timedOut: true, exitCode null) // from an abort/kill (exitCode null, no timedOut) so the marker is // honest instead of lumping both under "signal".