fix: 插件最终版(stdin 机制)+ 踩坑经验归档

- dsh-scheduler: shell 请求无 args 字段,改 stdin 传脚本 + JS 字面量持久化(Unicode 安全)
- dsh-usage: 余额查询同样改 stdin 机制
- 记录 CollectedOutput.text / sandboxPolicy danger-full-access / exitCode / workdir 等关键经验
This commit is contained in:
江晨
2026-08-17 12:06:47 +08:00
parent c7da6a32ce
commit c33fe31f49
4 changed files with 89 additions and 55 deletions
+13 -4
View File
@@ -13,18 +13,27 @@ Host 侧定时任务调度器。给 DeepSeek Harness 补上缺失的**定时任
| 工具 | 作用 |
|---|---|
| `sched_add` | 添加任务(名称/调度/命令/cwd/enabled |
| `sched_add` | 添加任务(名称/调度/命令) |
| `sched_list` | 列出任务与运行状态 |
| `sched_remove` | 按 id 删除 |
| `sched_run_now` | 立即执行一次 |
| `sched_toggle` | 启用/停用 |
| `sched_diag` | 诊断 shell 执行环境与持久化 |
## 实现要点
## 实现要点(踩坑记录)
- 宿主插件环境**没有** `fs`/`process` 全局 → 文件读写走 `shell` 服务 + node 子进程
`btoa` 内置做 base64 传参,避免引号转义问题)
- **ShellExecRequest 没有 `args` 字段**node 脚本必须用 `stdin` 传入(`command:'node'` + `stdin:脚本`),
`args` 会被静默忽略,导致"裸跑 node"零输出
- `res.stdout``CollectedOutput` 对象(`{text, truncated, spillPath}`),用 `.text` 取值,
`String()` 会得到 `"[object Object]"`
- 宿主 `ctx.shell` 默认按 `workspace-write` 沙箱执行,本机无可用沙箱后端会被拒绝 →
必须显式传 `sandboxPolicy: sandboxPolicyService.resolve({mode:'danger-full-access'})`
- 结果字段是 `res.exitCode`(不是 `res.code`);工作目录字段是 `workdir`
- 数据持久化用 **JS 字符串字面量**直接嵌入脚本(`JSON.stringify(JSON.stringify(data))`),
Unicode 安全(`btoa` base64 曾引发中文乱码)
- 任务执行用完整命令行字符串 `cmd /c <command>`
- 每秒 tick`ctx.timer.interval`),`ticking` 标志防重入
- 任务执行用 `cmd /c <command>`,默认 cwd `D:\project`,默认超时 60s
- cron 的 `nextRun` 预计算:从当前时间起逐分钟扫描(最多 7 天)
## 文件
+37 -25
View File
@@ -1,41 +1,53 @@
// dsh-scheduler 插件 — Host 半(完整源码,与 cordis_define 提交的包一致
// dsh-scheduler 插件 — Host 半(最终版,pkg-10
// 定时任务调度器:
// - 调度表达式:cron 5字段(分 时 日 月 周)或 every:N{s|m|h}
// - 持久化:%DSH_HOME%\storages\dsh-scheduler.jsonnode 子进程读写,btoa 编码
// - 每秒 tick 检查到期任务,cmd /c 执行命令行
// - 工具:sched_add / sched_list / sched_remove / sched_run_now / sched_toggle
// 说明:宿主插件环境无 fs/process 全局,文件 IO 走 shell+node 子进程。
// - 持久化:%DSH_HOME%\storages\dsh-scheduler.jsonnode 子进程,JS 字符串字面量嵌入,Unicode 安全
// - 每秒 tick 检查到期任务,cmd /c 执行命令行
// - 工具:sched_add / sched_list / sched_remove / sched_run_now / sched_toggle / sched_diag
//
// 关键技术点(踩坑记录):
// 1. ShellExecRequest 没有 args 字段!脚本走 stdincommand:'node' + stdin:脚本),
// 数据用 env 或 JS 字符串字面量嵌入(btoa 曾引发中文乱码,改字面量后 UTF-8 正确)。
// 2. res.stdout 是 CollectedOutput 对象({text, truncated, spillPath}),用 .text 取值。
// 3. 宿主 shell 默认沙箱 workspace-write 本机不可用 → 显式 sandboxPolicy: danger-full-access。
// 4. 结果字段是 res.exitCode(不是 res.code);工作目录字段是 workdir。
// 5. 任务命令执行:command 用完整字符串 'cmd /c <命令行>'。
return {
inject: ['shell', 'timer'],
async apply(ctx) {
const sp = ctx.get('sandboxPolicy')
const fullAccess = sp ? sp.resolve({ mode: 'danger-full-access' }) : { mode: 'danger-full-access' }
const readScript = [
"const fs=require('node:fs');const os=require('node:os');const path=require('node:path');",
"const home=process.env.DSH_HOME||path.join(os.homedir(),'.dsh');",
"const f=path.join(home,'storages','dsh-scheduler.json');",
"try{console.log(fs.readFileSync(f,'utf8'))}catch(e){console.log('[]')}",
].join('\n')
const writeScript = [
"const fs=require('node:fs');const os=require('node:os');const path=require('node:path');",
"const home=process.env.DSH_HOME||path.join(os.homedir(),'.dsh');",
"const f=path.join(home,'storages','dsh-scheduler.json');",
"const d=Buffer.from(process.argv[1],'base64').toString('utf8');",
"fs.mkdirSync(path.dirname(f),{recursive:true});fs.writeFileSync(f,d);",
].join('\n')
let tasks = []
const out = (o) => (o && typeof o === 'object' && 'text' in o) ? o.text : String(o || '')
const load = async () => {
try {
const spec = ctx.shell.resolve({ command: 'node', args: ['-e', readScript] })
const spec = ctx.shell.resolve({ command: 'node', stdin: readScript, sandboxPolicy: fullAccess })
const res = await ctx.shell.run(spec)
const parsed = JSON.parse(String(res.stdout || '').trim())
const parsed = JSON.parse(out(res.stdout).trim())
tasks = Array.isArray(parsed) ? parsed : []
} catch (e) { tasks = [] }
}
const save = async () => {
try {
const b64 = btoa(JSON.stringify(tasks))
const spec = ctx.shell.resolve({ command: 'node', args: ['-e', writeScript, b64] })
await ctx.shell.run(spec)
const lit = JSON.stringify(JSON.stringify(tasks))
const script = [
"const fs=require('node:fs');const os=require('node:os');const path=require('node:path');",
"const home=process.env.DSH_HOME||path.join(os.homedir(),'.dsh');",
"const f=path.join(home,'storages','dsh-scheduler.json');",
'const d=' + lit + ';',
"fs.mkdirSync(path.dirname(f),{recursive:true});fs.writeFileSync(f,d);",
].join('\n')
const spec = ctx.shell.resolve({ command: 'node', stdin: script, sandboxPolicy: fullAccess })
const res = await ctx.shell.run(spec)
if (res.exitCode !== 0) console.error('scheduler save exit=' + res.exitCode + ' stderr=' + out(res.stderr).slice(0, 300))
} catch (e) { console.error('scheduler save failed: ' + String((e && e.message) || e)) }
}
@@ -84,15 +96,15 @@ return {
const runTask = async (t) => {
try {
const spec = ctx.shell.resolve({
command: 'cmd',
args: ['/c', t.command],
cwd: t.cwd || 'D:\\project',
command: 'cmd /c ' + t.command,
workdir: t.cwd || 'D:\\project',
timeoutMs: t.timeoutMs || 60000,
sandboxPolicy: fullAccess,
})
const res = await ctx.shell.run(spec)
t.lastStatus = res.code === 0 ? 'ok' : 'error'
t.lastOutput = String(res.stdout || '').slice(0, 400)
t.lastError = res.code === 0 ? null : String(res.stderr || '').slice(0, 400)
t.lastStatus = res.exitCode === 0 ? 'ok' : 'error'
t.lastOutput = out(res.stdout).slice(0, 400)
t.lastError = res.exitCode === 0 ? null : out(res.stderr).slice(0, 400)
} catch (e) {
t.lastStatus = 'error'
t.lastError = String((e && e.message) || e).slice(0, 400)
@@ -165,8 +177,8 @@ return {
everyMs: sch.everyMs,
cronExpr: sch.cronExpr,
command: args.command,
cwd: args.cwd || 'D:\\project',
enabled: args.enabled !== false,
cwd: 'D:\\project',
enabled: true,
lastRun: null, runs: 0, lastStatus: null, lastOutput: null, lastError: null,
nextRun: sch.type === 'every' ? Date.now() : nextCron(sch.cronExpr, Date.now()),
createdAt: Date.now(),