dsh-recall-plugin 2.3.21 → 2.3.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/store.js +158 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
|
4
4
|
|
|
5
|
+
## [2.3.22] - 2026-09-16
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **win32 上宿主把 `ctx.shell` 配成 bash 时功能面整个死亡(issue #15)**:官方 shell 是提供方注册制——一个 composition 恰好一个 `ctx.shell` 实现,win32 的 profile 可以只启用 `bash-sandbox`(禁用 `pwsh-sandbox`);而插件按 `process.platform` 单选 pwsh 模板,两者之间没有契约保证,于是模板被 bash 执行、第一行编码前导即语法错误(`bash: -c: line 1: syntax error near unexpected token '('`),`ensureGit` 起每一步都失败:快照从未成功、撤回按钮不可用。官方 `ShellExecutor` 公开面(`resolve`/`run`/`start` + `sandboxMode`)没有任何方言标识,**不能查询只能探测**,故修复=行为探针判方言 + 判成 bash 时改走直连通道:
|
|
10
|
+
- **S1 方言探针**:`runShellMeta` 首调内联执行 `Write-Output <罕见 ASCII 哨兵>`(不带 UTF8_PRELUDE、30s 短超时、直调 `ctx.shell` 不触发失败清扫);exit 0 且回显哨兵判 pwsh,非零退出(bash 下 command-not-found 即 127)/无输出/reject 一律判 bash。结果按进程缓存并以 in-flight promise 去重(突发 `session/event` 只探一次)。POSIX 不探测——bash 模板与 bash 执行器天然一致。
|
|
11
|
+
- **S2 直连通道**:判 bash 时 `runShellMeta` 分流到 Node `spawn` 直连 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -NonInteractive -Command <单 argv>`(PS 5.1 全平台自带,不赌 PS7;模板本就按 5.1 兼容写),保留官方四项语义:stdin 字节透传(PS 5.1 代码页坑由字节流绕开)、stdout 超截断保留尾部并置 `truncated`(索引读取靠它区分截断与损坏)、超时 `child.kill()`、非零退出仍走 `cleanupAfterGitFailure`;env 复刻官方清洗(剥凭证形状名与全部 `DSH_*`,叠 `NO_COLOR`/`PAGER`/`GIT_PAGER`),cwd 取 `sandboxPolicy.workspaceRoot || process.cwd()`,`windowsHide` 防桌面端闪窗。带 `RECALL_CLEANUP` 哨兵的失败清扫脚本不触发探针(只用缓存)——它只在真实命令失败后被调用,方言早已判定,善后路径不再叠一条探测进程。
|
|
12
|
+
- **默认路径零变化**:`ctx.shell` 即 pwsh 的常规部署(探针判 pwsh)与 POSIX 都不触达直连通道;探针误判的代价也低——两条通道对 pwsh 模板都兼容,判成 bash 只是绕开官方托管环境(无 `dshEnv`/PATH 注入、无进程树级终止)。
|
|
13
|
+
- 新增 `tests/unit/store-shell-dialect.test.js`(31 例:判定/收集/env 清洗/可执行路径四个纯函数 + 假 child 覆盖 stdin 字节透传、截断、超时 kill、spawn error + 分流接线与 in-flight 去重);`tests/probe/api-surface.test.js` 新增「win32 shell 方言」3 例(ShellExecutor 无方言字段、直连复刻的 PS 5.1 候选路径与 argv 旗标、env 清洗口径);compat-audit 新增 I36。
|
|
14
|
+
|
|
5
15
|
## [2.3.21] - 2026-09-15
|
|
6
16
|
|
|
7
17
|
### 变更
|
package/README.en.md
CHANGED
|
@@ -137,6 +137,7 @@ The plugin manages disk usage automatically — no manual housekeeping needed:
|
|
|
137
137
|
When each user message is sent (before the agent touches any files), the workspace is snapshotted into an independent shadow git repository; on recall, a "pre-rollback" safety snapshot is taken first, then files are restored via `git archive` and the conversation is rewound through DSH's official `sessions.fork` mechanism. Binary- and line-ending-safe, and your project's own git state is never touched.
|
|
138
138
|
|
|
139
139
|
- Snapshot storage: `dsh-recall-snapshots/<SHA256(project absolute path)>/` under home, containing the shadow git repository (`git/`, tags named `snap-<messageID>`), the index file `index.json` (message ID → snapshot time / session), and the recall chain `lineage.json`. Scripts run via PowerShell on Windows and bash on Linux/macOS (selected automatically by platform).
|
|
140
|
+
- **Works on Windows even when the host configures its shell as bash** (2.3.22+): DSH's shell is registered by the profile, and on win32 you can enable only `bash-sandbox` — the PowerShell templates would then be executed by bash and fail across the board. Before running its first command the plugin probes whether the executor can run pwsh (a sentinel command); if it turns out to be bash, the plugin switches to spawning the system PowerShell 5.1 directly, so snapshots and recall keep working. Deployments where `ctx.shell` is pwsh behave exactly as before (one probe, then everything as usual).
|
|
140
141
|
- To browse historical snapshots directly:
|
|
141
142
|
|
|
142
143
|
```powershell
|
|
@@ -162,7 +163,7 @@ Note: all source lives in `src/` (host in `src/host/`, browser side in `src/clie
|
|
|
162
163
|
|
|
163
164
|
### Tests
|
|
164
165
|
|
|
165
|
-
- `npm test`: pure-logic unit tests (vitest,
|
|
166
|
+
- `npm test`: pure-logic unit tests (vitest, 28 files / 361 cases, no DSH dependency, runs identically in CI and locally) — config parsing, snapshot parsers, rescue orchestration, error classification, script-template same-name-export contract, client pure functions, published-package layout, snapshot index persistence, storage caps and retention, etc.;
|
|
166
167
|
- `npm run test:probe`: official-API field probes (requires a local dsh installation; **must run after any dsh upgrade**) — pins fields like `renderMessageImages`/`node`/`cwd`, `atSeq`/`increaseTitle` of `sessions.fork`, `listSessions` record shape, `AgentRegistry`, and goes red on violation;
|
|
167
168
|
- `npm run verify:host`: assembly gate (requires a local dsh installation) — boots the plugin with a real cordis context, asserting inject declarations, endpoint registration, Config schema, and teardown cleanliness, catching assembly regressions before release;
|
|
168
169
|
- `npm run build`: full host+client build (mandatory after any `src/` change); `npm run check:dsh`: dsh version inspection (pre-release).
|
package/README.md
CHANGED
|
@@ -140,6 +140,7 @@ dsh plugin --profile web remove dsh-recall-plugin
|
|
|
140
140
|
每条用户消息发送时(agent 动文件之前),工作区被快照进一个独立的影子 git 仓库;撤回时先打「回退前」安全快照、再用 `git archive` 恢复文件、通过 DSH 官方 `sessions.fork` 机制把会话切到该消息之前。二进制与换行符安全,全程不触碰项目自身的 git 状态。
|
|
141
141
|
|
|
142
142
|
- 快照存储:home 下 `dsh-recall-snapshots/<SHA256(项目绝对路径)>/`,内含影子 git 仓库(`git/`,tag 名为 `snap-<消息ID>`)、索引文件 `index.json`(消息 ID → 快照时间 / 会话)与撤回链 `lineage.json`。Windows 上脚本走 PowerShell,Linux/macOS 走 bash(按平台自动分叉)。
|
|
143
|
+
- **Windows 上宿主把 shell 配成 bash 也照常工作**(2.3.22+):官方 shell 由 profile 注册,win32 上可以只启用 `bash-sandbox`——此时 pwsh 脚本会被 bash 执行而全盘失败。插件首次执行命令前会先探测执行器能否跑 pwsh(哨兵命令),确认是 bash 后改用系统 PowerShell 5.1 直连执行,快照与撤回不受影响;`ctx.shell` 即 pwsh 的常规部署行为不变(探测一次、判为 pwsh 后一切照旧)。
|
|
143
144
|
- 想直接翻历史快照:
|
|
144
145
|
|
|
145
146
|
```powershell
|
|
@@ -165,7 +166,7 @@ pnpm install
|
|
|
165
166
|
|
|
166
167
|
### 测试
|
|
167
168
|
|
|
168
|
-
- `npm test`:纯逻辑单测(vitest,
|
|
169
|
+
- `npm test`:纯逻辑单测(vitest,28 个文件 361 例,无 DSH 依赖,CI 与本地同跑)——配置解析、快照解析器、救援编排、错误分类、脚本模板同名导出契约、客户端纯函数、发布包内容布局、快照索引持久化、存储上限与保留天数等;
|
|
169
170
|
- `npm run test:probe`:官方 API 字段探针(依赖本机 dsh 安装;dsh 升级后本地必跑)——钉住 `renderMessageImages`/`node`/`cwd`、`sessions.fork` 的 `atSeq`/`increaseTitle`、`listSessions` 记录结构、`AgentRegistry` 等字段,违反即红;
|
|
170
171
|
- `npm run verify:host`:装配门禁(依赖本机 dsh 安装)——用真实 cordis 起插件,断言 inject 声明、端点注册、Config schema、卸载清理,装配回归发版前即可拦截;
|
|
171
172
|
- `npm run build`:host+client 全量打包(改任何 `src/` 后必跑);`npm run check:dsh`:dsh 版本巡检(发布前)。
|
package/lib/store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
+
import { spawn as childSpawn } from "node:child_process";
|
|
3
4
|
import * as pwshScripts from "./scripts.pwsh.js";
|
|
4
5
|
import * as posixScripts from "./scripts.posix.js";
|
|
5
6
|
import { classifyEnvError } from "./diagnostics.js";
|
|
@@ -37,9 +38,100 @@ function isTmpConsumedError(error, basename) {
|
|
|
37
38
|
if (!basename || s.indexOf(basename) < 0) return false;
|
|
38
39
|
return /No such file/i.test(s) || /does not exist/i.test(s) || /cannot find path/i.test(s);
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
+
const SHELL_PROBE_SENTINEL = "RCL_DIALECT_PROBE_9f4a2e";
|
|
42
|
+
const SHELL_PROBE_COMMAND = "Write-Output " + SHELL_PROBE_SENTINEL;
|
|
43
|
+
function judgeShellDialect(res) {
|
|
44
|
+
if (!res || res.exitCode !== 0) return "bash";
|
|
45
|
+
const text = res.stdout && res.stdout.text || "";
|
|
46
|
+
return text.indexOf(SHELL_PROBE_SENTINEL) >= 0 ? "pwsh" : "bash";
|
|
47
|
+
}
|
|
48
|
+
const SENSITIVE_ENV = /KEY|PASSWORD|SECRET|TOKEN/i;
|
|
49
|
+
const STDERR_KEEP_BYTES = 65536;
|
|
50
|
+
function scrubChildEnv(env) {
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const key of Object.keys(env)) {
|
|
53
|
+
const value = env[key];
|
|
54
|
+
if (value === void 0) continue;
|
|
55
|
+
if (SENSITIVE_ENV.test(key) || key.toUpperCase().indexOf("DSH_") === 0) continue;
|
|
56
|
+
out[key] = value;
|
|
57
|
+
}
|
|
58
|
+
out.NO_COLOR = "1";
|
|
59
|
+
out.PAGER = "cat";
|
|
60
|
+
out.GIT_PAGER = "cat";
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function collectStdout(chunks, maxBytes) {
|
|
64
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
65
|
+
if (total <= maxBytes) return { text: Buffer.concat(chunks).toString("utf8"), truncated: false };
|
|
66
|
+
const all = Buffer.concat(chunks);
|
|
67
|
+
return { text: all.subarray(all.length - maxBytes).toString("utf8"), truncated: true };
|
|
68
|
+
}
|
|
69
|
+
function directPwshPath(env) {
|
|
70
|
+
return (env.SystemRoot || env.windir || "C:\\Windows") + "\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
|
71
|
+
}
|
|
72
|
+
async function runViaSpawn(spawn, req) {
|
|
73
|
+
const child = spawn(directPwshPath(process.env), ["-NoProfile", "-NonInteractive", "-Command", req.command], {
|
|
74
|
+
cwd: req.cwd,
|
|
75
|
+
env: scrubChildEnv(process.env),
|
|
76
|
+
windowsHide: true
|
|
77
|
+
});
|
|
78
|
+
return await new Promise((resolve, reject) => {
|
|
79
|
+
const sink = (maxBytes) => {
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let total = 0;
|
|
82
|
+
return {
|
|
83
|
+
push(chunk) {
|
|
84
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
85
|
+
chunks.push(buf);
|
|
86
|
+
total += buf.length;
|
|
87
|
+
while (chunks.length > 1 && total - chunks[0].length > maxBytes) {
|
|
88
|
+
total -= chunks[0].length;
|
|
89
|
+
chunks.shift();
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
take() {
|
|
93
|
+
return collectStdout(chunks, maxBytes);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
const outSink = sink(req.stdoutMaxBytes);
|
|
98
|
+
const errSink = sink(STDERR_KEEP_BYTES);
|
|
99
|
+
let timedOut = false;
|
|
100
|
+
let settled = false;
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
timedOut = true;
|
|
103
|
+
try {
|
|
104
|
+
child.kill();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
}
|
|
107
|
+
}, Math.max(1, req.timeoutMs));
|
|
108
|
+
const finish = (code) => {
|
|
109
|
+
if (settled) return;
|
|
110
|
+
settled = true;
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
const stdout = outSink.take();
|
|
113
|
+
resolve({ text: stdout.text, truncated: stdout.truncated, exitCode: code, stderr: errSink.take().text, timedOut });
|
|
114
|
+
};
|
|
115
|
+
if (child.stdout) child.stdout.on("data", (chunk) => outSink.push(chunk));
|
|
116
|
+
if (child.stderr) child.stderr.on("data", (chunk) => errSink.push(chunk));
|
|
117
|
+
child.on("error", (error) => {
|
|
118
|
+
if (settled) return;
|
|
119
|
+
settled = true;
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
reject(error);
|
|
122
|
+
});
|
|
123
|
+
child.on("close", (code) => finish(code === void 0 ? null : code));
|
|
124
|
+
if (child.stdin) {
|
|
125
|
+
child.stdin.on("error", () => {
|
|
126
|
+
});
|
|
127
|
+
child.stdin.end(Buffer.from(req.stdin === void 0 ? "" : req.stdin, "utf8"));
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function createRuntime(ctx, config, deps) {
|
|
41
132
|
const shell = ctx.shell;
|
|
42
133
|
const sessions = ctx.sessions;
|
|
134
|
+
const spawn = deps && deps.spawn || childSpawn;
|
|
43
135
|
const isWin = process.platform === "win32";
|
|
44
136
|
const SEP = isWin ? "\\" : "/";
|
|
45
137
|
const scripts = isWin ? pwshScripts : posixScripts;
|
|
@@ -66,6 +158,10 @@ function createRuntime(ctx, config) {
|
|
|
66
158
|
gcCount: /* @__PURE__ */ new Map(),
|
|
67
159
|
gitExe: null,
|
|
68
160
|
posixHomeBase: null,
|
|
161
|
+
// win32 方言探针缓存(issue #15):判定结果 + in-flight promise(并发首调
|
|
162
|
+
// 只跑一次探针);POSIX 不探测,两字段恒为 null
|
|
163
|
+
shellDialect: null,
|
|
164
|
+
shellDialectProbe: null,
|
|
69
165
|
homeContainer: null,
|
|
70
166
|
errors: [],
|
|
71
167
|
// 逐消息的快照反馈(issue #7 失败可见性):失败 {failed,error} 或
|
|
@@ -95,28 +191,71 @@ function createRuntime(ctx, config) {
|
|
|
95
191
|
const missing = pwshKeys.filter((k) => posixKeys.indexOf(k) < 0);
|
|
96
192
|
if (missing.length) recordError("recall script parity: posix \u7F3A\u5C11\u5BFC\u51FA " + missing.join(", "));
|
|
97
193
|
})();
|
|
194
|
+
function resolveShellDialect() {
|
|
195
|
+
if (state.shellDialect) return Promise.resolve(state.shellDialect);
|
|
196
|
+
if (!state.shellDialectProbe) {
|
|
197
|
+
state.shellDialectProbe = (async () => {
|
|
198
|
+
let res = null;
|
|
199
|
+
try {
|
|
200
|
+
const sp = ctx.get("sandboxPolicy");
|
|
201
|
+
res = await shell.run(shell.resolve({
|
|
202
|
+
command: SHELL_PROBE_COMMAND,
|
|
203
|
+
timeoutMs: 3e4,
|
|
204
|
+
stdoutMaxBytes: 4096,
|
|
205
|
+
sandboxPolicy: { mode: "danger-full-access", workspaceRoot: sp && sp.workspaceRoot || process.cwd() }
|
|
206
|
+
}));
|
|
207
|
+
} catch (error) {
|
|
208
|
+
res = null;
|
|
209
|
+
}
|
|
210
|
+
const dialect = judgeShellDialect(res);
|
|
211
|
+
state.shellDialect = dialect;
|
|
212
|
+
console.error("recall shell dialect probe: " + dialect + (dialect === "bash" ? "\uFF08ctx.shell \u975E pwsh\uFF0C\u6539\u7528\u76F4\u8FDE powershell.exe \u901A\u9053\uFF09" : ""));
|
|
213
|
+
return dialect;
|
|
214
|
+
})();
|
|
215
|
+
}
|
|
216
|
+
return state.shellDialectProbe;
|
|
217
|
+
}
|
|
218
|
+
async function shellDialectFor(command) {
|
|
219
|
+
if (command.indexOf("RECALL_CLEANUP") >= 0) return state.shellDialect || "pwsh";
|
|
220
|
+
return await resolveShellDialect();
|
|
221
|
+
}
|
|
222
|
+
async function throwShellFailure(command, stderr, exitCode) {
|
|
223
|
+
await cleanupAfterGitFailure(command);
|
|
224
|
+
const detail = String(stderr || "").trim() || "exit " + String(exitCode);
|
|
225
|
+
throw new Error(detail.slice(0, 1500));
|
|
226
|
+
}
|
|
227
|
+
async function runShellDirect(command, req) {
|
|
228
|
+
const res = await runViaSpawn(spawn, req);
|
|
229
|
+
if (res.timedOut) {
|
|
230
|
+
await throwShellFailure(command, res.stderr + "\n\u76F4\u8FDE powershell.exe \u8D85\u65F6\uFF08" + String(req.timeoutMs) + "ms\uFF09", null);
|
|
231
|
+
}
|
|
232
|
+
if (res.exitCode !== 0) await throwShellFailure(command, res.stderr, res.exitCode);
|
|
233
|
+
return { text: res.text, truncated: res.truncated };
|
|
234
|
+
}
|
|
98
235
|
async function runShellMeta(command, opts) {
|
|
99
236
|
const sp = ctx.get("sandboxPolicy");
|
|
237
|
+
const workspaceRoot = sp && sp.workspaceRoot || process.cwd();
|
|
238
|
+
const timeoutMs = opts && opts.timeoutMs || 3e5;
|
|
239
|
+
const stdoutMaxBytes = opts && opts.stdoutMaxBytes || 4194304;
|
|
240
|
+
const fullCommand = scripts.UTF8_PRELUDE + "\n" + command;
|
|
241
|
+
if (isWin && await shellDialectFor(command) === "bash") {
|
|
242
|
+
return await runShellDirect(command, { command: fullCommand, timeoutMs, stdoutMaxBytes, cwd: workspaceRoot, stdin: opts && opts.stdin });
|
|
243
|
+
}
|
|
100
244
|
const spec = shell.resolve({
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
timeoutMs: opts && opts.timeoutMs || 3e5,
|
|
105
|
-
stdoutMaxBytes: opts && opts.stdoutMaxBytes || 4194304,
|
|
245
|
+
command: fullCommand,
|
|
246
|
+
timeoutMs,
|
|
247
|
+
stdoutMaxBytes,
|
|
106
248
|
// stdin 是官方 ShellExecRequest 契约字段(bash-local/pwsh 均实现),
|
|
107
249
|
// POSIX 侧用它传 index.json 全文,绕开 argv 长度上限
|
|
108
250
|
...opts && opts.stdin !== void 0 ? { stdin: opts.stdin } : {},
|
|
109
|
-
sandboxPolicy: { mode: "danger-full-access", workspaceRoot
|
|
251
|
+
sandboxPolicy: { mode: "danger-full-access", workspaceRoot }
|
|
110
252
|
});
|
|
111
253
|
const res = await shell.run(spec);
|
|
112
|
-
const out = res && res.stdout && res.stdout.text || "";
|
|
113
254
|
if (res && res.exitCode !== 0) {
|
|
114
|
-
await
|
|
115
|
-
const err = (res && res.stderr && res.stderr.text || "").trim() || "exit " + String(res.exitCode);
|
|
116
|
-
throw new Error(err.slice(0, 1500));
|
|
255
|
+
await throwShellFailure(command, res && res.stderr && res.stderr.text || "", res.exitCode);
|
|
117
256
|
}
|
|
118
257
|
return {
|
|
119
|
-
text:
|
|
258
|
+
text: res && res.stdout && res.stdout.text || "",
|
|
120
259
|
truncated: Boolean(res && res.stdout && res.stdout.truncated)
|
|
121
260
|
};
|
|
122
261
|
}
|
|
@@ -356,9 +495,16 @@ function createRuntime(ctx, config) {
|
|
|
356
495
|
return { state, isWin, scripts, recordError, runShell, runShellMeta, writeTextViaShell, resolveRoot, resolveGit, homeDirFor, resolveHomeContainer, resolveStore, storeFromDir, tryUpgradeToHome, ensureGit, cleanupLegacy, cleanupAfterGitFailure };
|
|
357
496
|
}
|
|
358
497
|
export {
|
|
498
|
+
SHELL_PROBE_COMMAND,
|
|
499
|
+
SHELL_PROBE_SENTINEL,
|
|
500
|
+
collectStdout,
|
|
359
501
|
createRuntime,
|
|
502
|
+
directPwshPath,
|
|
360
503
|
isTmpConsumedError,
|
|
504
|
+
judgeShellDialect,
|
|
361
505
|
parseCleanupResult,
|
|
362
506
|
resolvePosixHomeBase,
|
|
507
|
+
runViaSpawn,
|
|
508
|
+
scrubChildEnv,
|
|
363
509
|
selectPosixHomeBase
|
|
364
510
|
};
|
package/package.json
CHANGED