dsh-message-rewind 0.0.0-stage → 1.1.2
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/LICENSE +21 -0
- package/README.en.md +136 -0
- package/README.md +137 -2
- package/client.js +518 -0
- package/client.test.js +294 -0
- package/cordis.patch.yml +3 -0
- package/files.js +160 -0
- package/files.test.js +160 -0
- package/icon.svg +6 -0
- package/index.js +354 -0
- package/index.test.js +214 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/manifest.test.js +25 -0
- package/package.json +48 -4
- package/rewind.js +126 -0
- package/rewind.test.js +76 -0
package/index.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { promises as fsp } from 'node:fs';
|
|
2
|
+
import { basename, isAbsolute, join, relative, sep } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { prepareCut, userError } from './rewind.js';
|
|
6
|
+
import { planRewind, applyRewind } from './files.js';
|
|
7
|
+
|
|
8
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
9
|
+
const CHANNEL = '/message-rewind';
|
|
10
|
+
const DIFF_TIMEOUT = 30_000;
|
|
11
|
+
/** Matches the `$DSH_HOME` > `~/.dsh` resolution the harness itself uses. */
|
|
12
|
+
const SESSIONS_ROOT = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'sessions');
|
|
13
|
+
/** The harness mints `session-<uuid>`; refuse anything that could address a path. */
|
|
14
|
+
const SESSION_ID = /^session-[0-9a-zA-Z]+(?:-[0-9a-zA-Z]+)*$/;
|
|
15
|
+
|
|
16
|
+
export const inject = ['sessionController', 'workspaceChanges', 'workspaceController', 'connection', 'webServer'];
|
|
17
|
+
|
|
18
|
+
const text = (error) => (error instanceof Error ? error.message : String(error));
|
|
19
|
+
|
|
20
|
+
function diffTimeout() {
|
|
21
|
+
return AbortSignal.timeout(DIFF_TIMEOUT);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a value is a session id this plugin may address on disk. */
|
|
25
|
+
export function validSessionId(sessionId) {
|
|
26
|
+
return typeof sessionId === 'string' && SESSION_ID.test(sessionId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Locate one session's on-disk directory. A workspace session lives under its
|
|
31
|
+
* cwd slug; a session without a cwd sits directly in the sessions root.
|
|
32
|
+
*
|
|
33
|
+
* The id is validated and the resolved path is confined to the root, so a
|
|
34
|
+
* crafted id can never make `purge` delete a directory outside the sessions
|
|
35
|
+
* tree.
|
|
36
|
+
* @param root - sessions root directory.
|
|
37
|
+
* @param sessionId - durable session identity.
|
|
38
|
+
* @returns the real directory, or undefined when it is absent or outside the root.
|
|
39
|
+
*/
|
|
40
|
+
export async function sessionDirectory(root, sessionId) {
|
|
41
|
+
if (!validSessionId(sessionId)) return undefined;
|
|
42
|
+
let realRoot;
|
|
43
|
+
try {
|
|
44
|
+
realRoot = await fsp.realpath(root);
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = await fsp.readdir(realRoot, { withFileTypes: true });
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const candidates = [join(realRoot, sessionId)];
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (entry.isDirectory()) candidates.push(join(realRoot, entry.name, sessionId));
|
|
57
|
+
}
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
let real;
|
|
60
|
+
try {
|
|
61
|
+
real = await fsp.realpath(candidate);
|
|
62
|
+
} catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (basename(real) !== sessionId) continue;
|
|
66
|
+
const relativePath = relative(realRoot, real);
|
|
67
|
+
if (relativePath === '' || relativePath.startsWith('..') || isAbsolute(relativePath)) continue;
|
|
68
|
+
const parts = relativePath.split(sep);
|
|
69
|
+
if (parts.length > 2) continue;
|
|
70
|
+
return real;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const delay = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether a directory sits inside a Git work tree. The change recorder reaches
|
|
79
|
+
* shell-command effects through Git; outside a repository it records only the
|
|
80
|
+
* files DSH's own write/edit tools touched.
|
|
81
|
+
* @param cwd - absolute session working directory.
|
|
82
|
+
* @returns true when some ancestor holds a `.git` entry.
|
|
83
|
+
*/
|
|
84
|
+
async function insideGitWorkTree(cwd) {
|
|
85
|
+
let current = join(cwd);
|
|
86
|
+
for (;;) {
|
|
87
|
+
try {
|
|
88
|
+
await fsp.stat(join(current, '.git'));
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
/* keep walking up */
|
|
92
|
+
}
|
|
93
|
+
const parent = join(current, '..');
|
|
94
|
+
if (parent === current) return false;
|
|
95
|
+
current = parent;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function apply(ctx) {
|
|
100
|
+
const chains = new Map();
|
|
101
|
+
const ordered = (sessionId, operation) => {
|
|
102
|
+
const result = (chains.get(sessionId) ?? Promise.resolve()).then(operation);
|
|
103
|
+
chains.set(sessionId, result.catch(() => {}));
|
|
104
|
+
return result;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
async function readDiff(sessionId, seq, index) {
|
|
108
|
+
const diff = await ctx.workspaceChanges.diff(sessionId, seq, index, diffTimeout());
|
|
109
|
+
if (!diff) throw userError('snapshot-missing', '文件改动快照已不可用,未回滚任何文件。');
|
|
110
|
+
if (diff.kind !== 'text') throw userError('binary-file', `只能回滚文本文件:${diff.display}`);
|
|
111
|
+
if (diff.coarse) throw userError('coarse-diff', `改动过大,无法安全回滚:${diff.display}`);
|
|
112
|
+
return diff;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function load(sessionId, seq) {
|
|
116
|
+
const inspection = await ctx.sessionController.inspect(sessionId);
|
|
117
|
+
const cut = prepareCut(inspection.events, seq);
|
|
118
|
+
const records = cut.records.map((recordSeq) => ({
|
|
119
|
+
seq: recordSeq,
|
|
120
|
+
summary: ctx.workspaceChanges.summary(sessionId, recordSeq),
|
|
121
|
+
}));
|
|
122
|
+
return { inspection, cut, records };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Replay the inbox splices to count prompts still waiting for a turn. */
|
|
126
|
+
function pendingQueue(events) {
|
|
127
|
+
let queued = 0;
|
|
128
|
+
for (const event of events) {
|
|
129
|
+
if (event.type !== 'agent/inbox/spliced') continue;
|
|
130
|
+
const data = event.data;
|
|
131
|
+
if (data?.target !== 'next-turn') continue;
|
|
132
|
+
const removed = Number(data.removedCount ?? 0);
|
|
133
|
+
const inserted = Array.isArray(data.inserted) ? data.inserted.length : 0;
|
|
134
|
+
queued = Math.max(0, queued - removed) + inserted;
|
|
135
|
+
}
|
|
136
|
+
return queued;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function describe(records) {
|
|
140
|
+
const files = new Map();
|
|
141
|
+
let incomplete = false;
|
|
142
|
+
for (const { summary } of records) {
|
|
143
|
+
if (!summary) {
|
|
144
|
+
incomplete = true;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
for (const file of summary.files) files.set(`${summary.cwd}\0${file.path}`, file.display);
|
|
148
|
+
}
|
|
149
|
+
return { files: [...files.values()], incomplete };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Read every recorded diff and compute the pre-cut content without touching disk. */
|
|
153
|
+
const plan = (sessionId, records) => planRewind((seq, index) => readDiff(sessionId, seq, index), records);
|
|
154
|
+
|
|
155
|
+
/** The workspace owning one session, when the registry is available. */
|
|
156
|
+
function workspaceFor(sessionId) {
|
|
157
|
+
const registry = ctx.get('workspaceRegistry');
|
|
158
|
+
if (registry === undefined) return undefined;
|
|
159
|
+
return registry.list().find((workspace) => workspace.sessionIds.includes(sessionId));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function forkOrCreate(sessionId, boundary, meta) {
|
|
163
|
+
if (boundary !== undefined) {
|
|
164
|
+
const forked = await ctx.sessionController.fork({ sessionId, atSeq: boundary });
|
|
165
|
+
return forked.sessionId;
|
|
166
|
+
}
|
|
167
|
+
// The first-message cut has no boundary to fork at, so it creates a fresh
|
|
168
|
+
// session. A bare `cwd` is not attached to any workspace, while the fork
|
|
169
|
+
// path is: reuse the source workspace so the conversation stays in its group.
|
|
170
|
+
const request = {};
|
|
171
|
+
const workspace = workspaceFor(sessionId);
|
|
172
|
+
if (workspace !== undefined) request.workspaceId = workspace.id;
|
|
173
|
+
else if (meta.cwd !== undefined) request.cwd = meta.cwd;
|
|
174
|
+
if (meta.agentPreset !== undefined) request.agentPreset = meta.agentPreset;
|
|
175
|
+
const created = await ctx.sessionController.create(request);
|
|
176
|
+
return created.sessionId;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function inspect(sessionId, seq) {
|
|
180
|
+
const { inspection, cut, records } = await load(sessionId, seq);
|
|
181
|
+
const { files, incomplete } = describe(records);
|
|
182
|
+
const cwd = inspection.meta.cwd;
|
|
183
|
+
const git = cwd === undefined ? true : await insideGitWorkTree(cwd);
|
|
184
|
+
return { files, incomplete, unfinished: cut.hasOpenTurn, queued: pendingQueue(inspection.events), git };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function execute(request) {
|
|
188
|
+
const sessionId = String(request?.sessionId ?? '');
|
|
189
|
+
const action = request?.action;
|
|
190
|
+
if (action !== 'delete' && action !== 'edit') throw userError('bad-action', '操作类型无效。');
|
|
191
|
+
const withFiles = request?.rewindFiles === true;
|
|
192
|
+
const { inspection, cut, records } = await load(sessionId, request?.seq);
|
|
193
|
+
if (cut.hasOpenTurn) throw userError('open-turn', '请先停止当前运行,再编辑或删除消息。');
|
|
194
|
+
if (action === 'edit' && cut.content.some((block) => block.type !== 'text')) throw userError('attachments', '含附件的消息暂时只能删除,不能编辑。');
|
|
195
|
+
let content = cut.content;
|
|
196
|
+
if (action === 'edit') {
|
|
197
|
+
const replacement = typeof request?.text === 'string' ? request.text.trim() : '';
|
|
198
|
+
if (replacement === '') throw userError('empty-message', '消息不能为空。');
|
|
199
|
+
content = [{ type: 'text', text: replacement }];
|
|
200
|
+
}
|
|
201
|
+
let restored = 0;
|
|
202
|
+
let undo = null;
|
|
203
|
+
if (withFiles) {
|
|
204
|
+
if (describe(records).incomplete) throw userError('incomplete-snapshot', '部分文件改动快照已不可用,只能只回滚消息。');
|
|
205
|
+
if (records.some((record) => record.summary)) {
|
|
206
|
+
if (!inspection.meta.cwd) throw userError('no-workspace', '这个会话没有工作区,只能只回滚消息。');
|
|
207
|
+
const targets = await plan(sessionId, records);
|
|
208
|
+
const applied = await applyRewind(targets);
|
|
209
|
+
undo = applied.undo;
|
|
210
|
+
restored = applied.written;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
let childId;
|
|
214
|
+
try {
|
|
215
|
+
childId = await forkOrCreate(sessionId, cut.boundary, inspection.meta);
|
|
216
|
+
if (action === 'edit') {
|
|
217
|
+
await ctx.sessionController.prompt({
|
|
218
|
+
requestId: randomUUID(),
|
|
219
|
+
sessionId: childId,
|
|
220
|
+
mode: 'queue',
|
|
221
|
+
content,
|
|
222
|
+
}, new AbortController().signal);
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (undo) await undo().catch((undoError) => { error.message = `${text(error)};${text(undoError)}`; });
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
return { sessionId: childId, restored, sourceSessionId: sessionId };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Remove a rolled-back source session for good. The harness keeps session logs
|
|
233
|
+
* append-only and offers no delete API, so this retires the artifact instead:
|
|
234
|
+
* archive it out of the sidebar, delete its directory, and report whether the
|
|
235
|
+
* bytes are gone.
|
|
236
|
+
*
|
|
237
|
+
* The archive only cancels the running turn — the Agent stays in the session
|
|
238
|
+
* store for this Host's lifetime — so waiting for `sessions.get()` to clear
|
|
239
|
+
* would never finish. Deletion is attempted directly and its outcome is
|
|
240
|
+
* reported, with liveness used only to explain a failure.
|
|
241
|
+
*/
|
|
242
|
+
async function purge(sessionId) {
|
|
243
|
+
try {
|
|
244
|
+
await ctx.workspaceController.archiveSession({ sessionId, stopActivity: true });
|
|
245
|
+
} catch (error) {
|
|
246
|
+
// Without the archive the session would stay visible after its log is gone.
|
|
247
|
+
return { removed: false, archivedOnly: false, message: `会话归档失败,未删除任何内容:${text(error)}` };
|
|
248
|
+
}
|
|
249
|
+
const directory = await sessionDirectory(SESSIONS_ROOT, sessionId);
|
|
250
|
+
if (directory === undefined) return { removed: false, archivedOnly: false, message: '未找到会话日志目录,未删除任何内容。' };
|
|
251
|
+
const live = ctx.get('sessions')?.get(sessionId) !== undefined;
|
|
252
|
+
let failure;
|
|
253
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
254
|
+
try {
|
|
255
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
256
|
+
return { removed: true, archivedOnly: false };
|
|
257
|
+
} catch (error) {
|
|
258
|
+
failure = error;
|
|
259
|
+
await delay(250);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
removed: false,
|
|
264
|
+
archivedOnly: true,
|
|
265
|
+
message: live
|
|
266
|
+
? `会话仍在本进程中运行,日志目录无法删除,已移出侧边栏:${text(failure)}`
|
|
267
|
+
: `日志目录无法删除,已移出侧边栏:${text(failure)}`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function dispatch(endpoint, payload) {
|
|
272
|
+
if (endpoint === 'inspect') {
|
|
273
|
+
const sessionId = String(payload?.sessionId ?? '');
|
|
274
|
+
return ordered(sessionId, () => inspect(sessionId, payload?.seq));
|
|
275
|
+
}
|
|
276
|
+
if (endpoint === 'execute') {
|
|
277
|
+
const sessionId = String(payload?.sessionId ?? '');
|
|
278
|
+
return ordered(sessionId, () => execute(payload));
|
|
279
|
+
}
|
|
280
|
+
if (endpoint === 'purge') {
|
|
281
|
+
const sessionId = String(payload?.sessionId ?? '');
|
|
282
|
+
if (!validSessionId(sessionId)) throw userError('invalid-session', '会话标识无效。');
|
|
283
|
+
return ordered(sessionId, () => purge(sessionId));
|
|
284
|
+
}
|
|
285
|
+
throw userError('unknown-endpoint', `未知接口:${endpoint}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async function readBody(request) {
|
|
290
|
+
const chunks = [];
|
|
291
|
+
let total = 0;
|
|
292
|
+
for await (const chunk of request) {
|
|
293
|
+
total += chunk.length;
|
|
294
|
+
if (total > MAX_BODY_BYTES) throw userError('request-too-large', '请求体过大。');
|
|
295
|
+
chunks.push(chunk);
|
|
296
|
+
}
|
|
297
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function send(response, status, body) {
|
|
301
|
+
const json = JSON.stringify(body);
|
|
302
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
303
|
+
response.end(json);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function registerRoute() {
|
|
307
|
+
// A reload replaces this plugin's fiber; drop the route the previous fiber
|
|
308
|
+
// could not dispose so reactivation stays idempotent.
|
|
309
|
+
const prefixes = ctx.webServer.prefixes;
|
|
310
|
+
if (prefixes instanceof Map && prefixes.get(CHANNEL) !== undefined) prefixes.delete(CHANNEL);
|
|
311
|
+
return ctx.webServer.register({
|
|
312
|
+
kind: 'prefix',
|
|
313
|
+
path: CHANNEL,
|
|
314
|
+
handler: routeHandler,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function routeHandler(request, response) {
|
|
319
|
+
const admission = ctx.connection.admit(request);
|
|
320
|
+
if ('rejection' in admission) {
|
|
321
|
+
response.writeHead(admission.rejection);
|
|
322
|
+
response.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
326
|
+
const endpoint = pathname.startsWith(`${CHANNEL}/`) ? pathname.slice(CHANNEL.length + 1) : '';
|
|
327
|
+
if (request.method !== 'POST' || endpoint === '' || endpoint.includes('/')) {
|
|
328
|
+
send(response, 404, { error: 'not found' });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
let envelope;
|
|
332
|
+
try {
|
|
333
|
+
envelope = JSON.parse(await readBody(request));
|
|
334
|
+
} catch (error) {
|
|
335
|
+
send(response, 400, { error: text(error) });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const rpcId = typeof envelope?.rpcId === 'string' ? envelope.rpcId : 'invalid-request';
|
|
339
|
+
const reply = (result) => send(response, 200, { type: 'server-response', rpcId, result });
|
|
340
|
+
if (envelope?.type !== 'client-request' || envelope.method !== endpoint) {
|
|
341
|
+
reply({ ok: false, error: { code: 'message-rewind/bad-request', message: '请求格式无效。', details: { rewindCode: 'bad-request' } } });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
reply({ ok: true, value: await dispatch(endpoint, envelope.payload) });
|
|
346
|
+
} catch (error) {
|
|
347
|
+
const rewindCode = typeof error?.rewindCode === 'string' ? error.rewindCode : 'unknown';
|
|
348
|
+
reply({ ok: false, error: { code: 'message-rewind/failed', message: text(error), details: { rewindCode } } });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
ctx.effect(registerRoute, 'message-rewind: rpc route');
|
|
353
|
+
}
|
|
354
|
+
|
package/index.test.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-check for the Host half's disk and RPC surface.
|
|
3
|
+
*
|
|
4
|
+
* `sessionDirectory` is the only place that maps a session id onto disk, so it
|
|
5
|
+
* must never return a path outside the sessions root — a bad id previously
|
|
6
|
+
* resolved to `$DSH_HOME` or a parent directory before `purge` recursively
|
|
7
|
+
* removed it. The route check drives the registered handler with a stub context
|
|
8
|
+
* so admission, envelope validation, error codes, and the purge guard stay
|
|
9
|
+
* covered without a running Harness.
|
|
10
|
+
*/
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { promises as fsp } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { apply, sessionDirectory, validSessionId } from './index.js';
|
|
16
|
+
|
|
17
|
+
let passed = 0;
|
|
18
|
+
const check = async (label, fn) => {
|
|
19
|
+
try {
|
|
20
|
+
await fn();
|
|
21
|
+
passed += 1;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
throw new Error(`${label}: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const root = await fsp.mkdtemp(join(tmpdir(), 'dmr-sessions-'));
|
|
28
|
+
const realRoot = await fsp.realpath(root);
|
|
29
|
+
const nested = 'session-11111111-2222-3333-4444-555555555555';
|
|
30
|
+
const flat = 'session-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
|
31
|
+
|
|
32
|
+
await check('nested project directory is found', async () => {
|
|
33
|
+
await fsp.mkdir(join(realRoot, '--workspace--', nested), { recursive: true });
|
|
34
|
+
assert.equal(await sessionDirectory(realRoot, nested), join(realRoot, '--workspace--', nested));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await check('root-level session directory is found', async () => {
|
|
38
|
+
await fsp.mkdir(join(realRoot, flat), { recursive: true });
|
|
39
|
+
assert.equal(await sessionDirectory(realRoot, flat), join(realRoot, flat));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await check('path traversal ids are rejected', async () => {
|
|
43
|
+
for (const id of ['..', '../..', '../../..', 'session-..', 'session-../..', 'a/b', 'C:\\Windows', '', 'session-']) {
|
|
44
|
+
assert.equal(await sessionDirectory(realRoot, id), undefined, `accepted ${JSON.stringify(id)}`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await check('id validation matches the harness format', async () => {
|
|
49
|
+
assert.equal(validSessionId(nested), true);
|
|
50
|
+
assert.equal(validSessionId('session-123'), true);
|
|
51
|
+
assert.equal(validSessionId('..'), false);
|
|
52
|
+
assert.equal(validSessionId('session-..'), false);
|
|
53
|
+
assert.equal(validSessionId('session-1/../..'), false);
|
|
54
|
+
assert.equal(validSessionId(42), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await check('a junction cannot escape the sessions root', async () => {
|
|
58
|
+
const outsideParent = await fsp.mkdtemp(join(tmpdir(), 'dmr-outside-'));
|
|
59
|
+
const linked = 'session-99999999-8888-7777-6666-555555555555';
|
|
60
|
+
const outside = join(outsideParent, linked);
|
|
61
|
+
const linkedPath = join(realRoot, '--workspace--', linked);
|
|
62
|
+
await fsp.mkdir(outside, { recursive: true });
|
|
63
|
+
try {
|
|
64
|
+
await fsp.symlink(outside, linkedPath, process.platform === 'win32' ? 'junction' : 'dir');
|
|
65
|
+
} catch (error) {
|
|
66
|
+
await fsp.rm(outsideParent, { recursive: true, force: true });
|
|
67
|
+
if (error?.code === 'EPERM' || error?.code === 'EACCES') return;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
assert.equal(await sessionDirectory(realRoot, linked), undefined);
|
|
72
|
+
} finally {
|
|
73
|
+
// A junction is removed like a file, but only when `recursive` is set on
|
|
74
|
+
// Windows; a bare rm leaves the reparse point behind and the root cleanup
|
|
75
|
+
// below then fails with ENOTEMPTY.
|
|
76
|
+
await fsp.rm(linkedPath, { recursive: true, force: true }).catch(() => {});
|
|
77
|
+
await fsp.rm(outsideParent, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await fsp.rm(realRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
82
|
+
|
|
83
|
+
// --- route surface ---------------------------------------------------------
|
|
84
|
+
const routeCalls = [];
|
|
85
|
+
const registrations = [];
|
|
86
|
+
const makeContext = (overrides = {}) => ({
|
|
87
|
+
effect: (factory) => factory(),
|
|
88
|
+
get: (name) => (name === 'workspaceRegistry'
|
|
89
|
+
? { list: () => [{ id: 'ws-1', sessionIds: ['session-source'] }] }
|
|
90
|
+
: name === 'sessions'
|
|
91
|
+
? { get: () => undefined }
|
|
92
|
+
: undefined),
|
|
93
|
+
webServer: {
|
|
94
|
+
register: (route) => { registrations.push(route); return () => {}; },
|
|
95
|
+
},
|
|
96
|
+
connection: { admit: () => ({ peer: {} }) },
|
|
97
|
+
workspaceController: {
|
|
98
|
+
archiveSession: async ({ sessionId }) => { routeCalls.push(['archive', sessionId]); },
|
|
99
|
+
},
|
|
100
|
+
workspaceChanges: { summary: () => undefined, diff: async () => undefined },
|
|
101
|
+
sessionController: {
|
|
102
|
+
inspect: async () => ({
|
|
103
|
+
meta: { cwd: 'D:/tmp', agentPreset: 'preset-a' },
|
|
104
|
+
events: [
|
|
105
|
+
{ seq: 0, type: 'turn/start', data: { turn: 1 } },
|
|
106
|
+
{ seq: 1, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'hi' }] } },
|
|
107
|
+
{ seq: 2, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
108
|
+
],
|
|
109
|
+
}),
|
|
110
|
+
fork: async () => ({ sessionId: 'session-child' }),
|
|
111
|
+
create: async (request) => { routeCalls.push(['create', request]); return { sessionId: 'session-child' }; },
|
|
112
|
+
prompt: async () => ({ accepted: true }),
|
|
113
|
+
},
|
|
114
|
+
...overrides,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const rpc = async (handler, endpoint, payload) => {
|
|
118
|
+
const body = JSON.stringify({ type: 'client-request', rpcId: 'rpc-1', method: endpoint, payload });
|
|
119
|
+
let status;
|
|
120
|
+
let bodyText;
|
|
121
|
+
const request = {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
url: `/message-rewind/${endpoint}`,
|
|
124
|
+
async *[Symbol.asyncIterator]() { yield Buffer.from(body); },
|
|
125
|
+
};
|
|
126
|
+
await handler(request, { writeHead: (code) => { status = code; }, end: (value) => { bodyText = value; } });
|
|
127
|
+
return { status, envelope: bodyText === undefined ? undefined : JSON.parse(bodyText) };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
await check('the route is registered as one prefix', async () => {
|
|
131
|
+
apply(makeContext());
|
|
132
|
+
assert.equal(registrations.length, 1);
|
|
133
|
+
assert.equal(registrations[0].kind, 'prefix');
|
|
134
|
+
assert.equal(registrations[0].path, '/message-rewind');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await check('an unauthenticated request is rejected before the body is read', async () => {
|
|
138
|
+
apply(makeContext({ connection: { admit: () => ({ rejection: 401 }) } }));
|
|
139
|
+
const handler = registrations.at(-1).handler;
|
|
140
|
+
let status;
|
|
141
|
+
await handler({ method: 'POST', url: '/message-rewind/inspect', headers: {} }, { writeHead: (code) => { status = code; }, end: () => {} });
|
|
142
|
+
assert.equal(status, 401);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await check('a traversal id never reaches the filesystem', async () => {
|
|
146
|
+
apply(makeContext());
|
|
147
|
+
const handler = registrations.at(-1).handler;
|
|
148
|
+
for (const bad of ['..', '../../..', 'session-..', 'session-1/../..']) {
|
|
149
|
+
const { envelope } = await rpc(handler, 'purge', { sessionId: bad });
|
|
150
|
+
assert.equal(envelope.result.ok, false, `purge accepted ${JSON.stringify(bad)}`);
|
|
151
|
+
assert.equal(envelope.result.error.details.rewindCode, 'invalid-session');
|
|
152
|
+
}
|
|
153
|
+
assert.equal(routeCalls.some((call) => call[0] === 'archive'), false, 'purge archived before validating');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await check('a bad envelope is refused with a code', async () => {
|
|
157
|
+
const handler = registrations.at(-1).handler;
|
|
158
|
+
const body = JSON.stringify({ type: 'wrong', rpcId: 'rpc-2', method: 'inspect', payload: {} });
|
|
159
|
+
let bodyText;
|
|
160
|
+
await handler({
|
|
161
|
+
method: 'POST',
|
|
162
|
+
url: '/message-rewind/inspect',
|
|
163
|
+
async *[Symbol.asyncIterator]() { yield Buffer.from(body); },
|
|
164
|
+
}, { writeHead: () => {}, end: (value) => { bodyText = value; } });
|
|
165
|
+
assert.equal(JSON.parse(bodyText).result.error.details.rewindCode, 'bad-request');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await check('execute binds the replacement to the source workspace', async () => {
|
|
169
|
+
const handler = registrations.at(-1).handler;
|
|
170
|
+
const { envelope } = await rpc(handler, 'execute', { sessionId: 'session-source', seq: 1, action: 'edit', text: 'edited', rewindFiles: false });
|
|
171
|
+
assert.equal(envelope.result.ok, true);
|
|
172
|
+
assert.deepEqual(envelope.result.value, { sessionId: 'session-child', restored: 0, sourceSessionId: 'session-source' });
|
|
173
|
+
assert.deepEqual(routeCalls.filter((call) => call[0] === 'create').at(-1)[1], { workspaceId: 'ws-1', agentPreset: 'preset-a' });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await check('an open turn is refused with a localizable code', async () => {
|
|
177
|
+
const context = makeContext({
|
|
178
|
+
sessionController: {
|
|
179
|
+
inspect: async () => ({
|
|
180
|
+
meta: { cwd: 'D:/tmp' },
|
|
181
|
+
events: [
|
|
182
|
+
{ seq: 0, type: 'turn/start', data: { turn: 1 } },
|
|
183
|
+
{ seq: 1, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'hi' }] } },
|
|
184
|
+
],
|
|
185
|
+
}),
|
|
186
|
+
fork: async () => ({ sessionId: 'session-child' }),
|
|
187
|
+
create: async () => ({ sessionId: 'session-child' }),
|
|
188
|
+
prompt: async () => ({ accepted: true }),
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
apply(context);
|
|
192
|
+
const { envelope } = await rpc(registrations.at(-1).handler, 'execute', { sessionId: 'session-source', seq: 1, action: 'delete', rewindFiles: false });
|
|
193
|
+
assert.equal(envelope.result.error.details.rewindCode, 'open-turn');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await check('purge reports a failed archive instead of deleting', async () => {
|
|
197
|
+
const context = makeContext({
|
|
198
|
+
workspaceController: { archiveSession: async () => { throw new Error('workspace/session-active'); } },
|
|
199
|
+
});
|
|
200
|
+
apply(context);
|
|
201
|
+
const { envelope } = await rpc(registrations.at(-1).handler, 'purge', { sessionId: 'session-11111111-2222-3333-4444-555555555555' });
|
|
202
|
+
assert.equal(envelope.result.ok, true);
|
|
203
|
+
assert.equal(envelope.result.value.removed, false);
|
|
204
|
+
assert.match(envelope.result.value.message, /归档失败/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await check('purge reports a missing log directory honestly', async () => {
|
|
208
|
+
apply(makeContext());
|
|
209
|
+
const { envelope } = await rpc(registrations.at(-1).handler, 'purge', { sessionId: 'session-11111111-2222-3333-4444-555555555555' });
|
|
210
|
+
assert.equal(envelope.result.value.removed, false);
|
|
211
|
+
assert.equal(envelope.result.value.archivedOnly, false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
console.log(`index self-check passed (${passed} scenarios)`);
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/manifest.test.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest self-check: DSH reads these fields before it imports the plugin, so
|
|
3
|
+
* a mismatch fails the whole bundle instead of one feature. `engines.dsh` is a
|
|
4
|
+
* hard gate in app boot (`evaluatePluginCompatibility`); the client bundle id
|
|
5
|
+
* must equal the package name or that entry stays pending and web boot fails.
|
|
6
|
+
*/
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
|
|
10
|
+
const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
|
|
11
|
+
|
|
12
|
+
assert.equal(pkg.name, 'dsh-message-rewind');
|
|
13
|
+
assert.match(pkg.version, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
|
|
14
|
+
// Bounded to the tested DSH release line: rc.3 through 0.2.x, never 0.3.
|
|
15
|
+
assert.match(pkg.engines?.dsh ?? '', /^>=0\.2\.1-rc\.\d+ <0\.3\.0-0$/);
|
|
16
|
+
assert.equal(pkg.engines?.node, '>=20');
|
|
17
|
+
assert.equal(pkg.dsh?.manifestVersion, 1);
|
|
18
|
+
assert.equal(pkg.dsh?.bundle?.patch, './cordis.patch.yml');
|
|
19
|
+
assert.equal(pkg.dsh?.client?.platform, 'web');
|
|
20
|
+
assert.equal(pkg.exports?.['./client'], './client.js');
|
|
21
|
+
|
|
22
|
+
const client = await readFile(new URL('./client.js', import.meta.url), 'utf8');
|
|
23
|
+
assert.match(client, new RegExp(`id: '${pkg.name}'`), 'the client bundle id must be the package name');
|
|
24
|
+
|
|
25
|
+
console.log('manifest self-check passed');
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-message-rewind",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"description": "DeepSeek Harness plugin: edit sent messages and roll back following turns, optionally restoring recorded file changes.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "xCube007",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/xCube007/dsh-message-rewind.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/xCube007/dsh-message-rewind#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/xCube007/dsh-message-rewind/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["deepseek", "deepseek-harness", "dsh", "dsh-plugin", "message", "rewind", "rollback", "edit"],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./index.js",
|
|
19
|
+
"./client": "./client.js",
|
|
20
|
+
"./locale/*.json": "./locale/*.json",
|
|
21
|
+
"./icon": "./icon.svg"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"rewind.js",
|
|
26
|
+
"files.js",
|
|
27
|
+
"client.js",
|
|
28
|
+
"cordis.patch.yml",
|
|
29
|
+
"locale/*.json",
|
|
30
|
+
"icon.svg",
|
|
31
|
+
"README.en.md",
|
|
32
|
+
"*.test.js"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20",
|
|
36
|
+
"dsh": ">=0.2.1-rc.3 <0.3.0-0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node manifest.test.js && node rewind.test.js && node files.test.js && node index.test.js && node client.test.js",
|
|
40
|
+
"prepublishOnly": "npm test"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"dsh": {
|
|
46
|
+
"manifestVersion": 1,
|
|
47
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
48
|
+
"client": { "platform": "web" }
|
|
49
|
+
}
|
|
50
|
+
}
|