@xiaoyuyu6420/dsh-backup 0.14.0 → 0.14.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/README.en.md +4 -1
- package/README.md +5 -2
- package/lib/client.js +8 -8
- package/lib/encrypt.js +111 -12
- package/lib/index.js +201 -79
- package/package.json +1 -1
- package/rescue/rescue.mjs +4 -0
package/lib/encrypt.js
CHANGED
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
randomBytes,
|
|
24
24
|
scrypt as scryptCb,
|
|
25
25
|
} from 'node:crypto';
|
|
26
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
27
|
+
import { rm } from 'node:fs/promises';
|
|
28
|
+
import { Transform } from 'node:stream';
|
|
29
|
+
import { pipeline } from 'node:stream/promises';
|
|
26
30
|
import { promisify } from 'node:util';
|
|
27
31
|
|
|
28
32
|
const scrypt = promisify(scryptCb);
|
|
@@ -91,15 +95,10 @@ export async function encryptBuffer(plaintext, password) {
|
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* @param {Buffer|Uint8Array} ciphertext 密文(归档文件内容)
|
|
98
|
-
* @param {object} sidecar `.encrypted.json` 解析出的对象
|
|
99
|
-
* @param {string} password 口令(内存态)
|
|
100
|
-
* @returns {Promise<Buffer>} 明文(tar.gz 字节)
|
|
98
|
+
* 校验边车并派生出解开这段密文所需的 { key, nonce, authTag }。
|
|
99
|
+
* 版本/算法/字段缺失一律拒绝(宁可不恢复,也不拿不认识的参数猜着解)。
|
|
101
100
|
*/
|
|
102
|
-
|
|
101
|
+
async function unlock(sidecar, password) {
|
|
103
102
|
if (typeof password !== 'string' || !password) {
|
|
104
103
|
throw new Error('这份备份已加密,恢复需要口令(口令只经内存传入,绝不写盘)');
|
|
105
104
|
}
|
|
@@ -120,16 +119,116 @@ export async function decryptBuffer(ciphertext, sidecar, password) {
|
|
|
120
119
|
throw new Error('加密边车字段缺失(salt/nonce/authTag),无法解密');
|
|
121
120
|
}
|
|
122
121
|
const key = await deriveKey(password, { ...(sidecar.kdfParams || KDF_PARAMS), salt });
|
|
122
|
+
return { key, nonce, authTag };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** GCM 认证失败的统一人话报错(不泄露具体原因)。 */
|
|
126
|
+
function authFailed(err) {
|
|
127
|
+
if (/unable to authenticate|authentication/i.test(String(err && err.message ? err.message : err))) {
|
|
128
|
+
return new Error('口令错误或归档已损坏(AES-GCM 认证失败)——请核对口令后重试');
|
|
129
|
+
}
|
|
130
|
+
return err;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 解密一段密文(内存版,供小体积数据/测试用)。口令错误 / 密文被篡改时 AES-GCM
|
|
135
|
+
* 认证失败,抛出口令错误人话报错(调用方可直接展示)。
|
|
136
|
+
*
|
|
137
|
+
* @param {Buffer|Uint8Array} ciphertext 密文(归档文件内容)
|
|
138
|
+
* @param {object} sidecar `.encrypted.json` 解析出的对象
|
|
139
|
+
* @param {string} password 口令(内存态)
|
|
140
|
+
* @returns {Promise<Buffer>} 明文(tar.gz 字节)
|
|
141
|
+
*/
|
|
142
|
+
export async function decryptBuffer(ciphertext, sidecar, password) {
|
|
143
|
+
const { key, nonce, authTag } = await unlock(sidecar, password);
|
|
123
144
|
const decipher = createDecipheriv(CIPHER, key, nonce);
|
|
124
145
|
decipher.setAuthTag(authTag);
|
|
125
146
|
const body = Buffer.isBuffer(ciphertext) ? ciphertext : Buffer.from(ciphertext);
|
|
126
147
|
try {
|
|
127
148
|
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
128
149
|
} catch (err) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
150
|
+
throw authFailed(err);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 加密文件:**分块流式**,不整包入内存(归档可以远超 Buffer 上限)。
|
|
156
|
+
* 调用方负责把输出写到临时路径后自己 `rename` 覆盖(本函数只保证「要么完整
|
|
157
|
+
* 密文、要么失败且不留半截文件」)。
|
|
158
|
+
*
|
|
159
|
+
* @param {string} inPath 明文文件
|
|
160
|
+
* @param {string} outPath 密文输出文件
|
|
161
|
+
* @param {string} password 口令
|
|
162
|
+
* @returns {Promise<{ sidecar: object, ciphertextSize: number }>}
|
|
163
|
+
*/
|
|
164
|
+
export async function encryptFile(inPath, outPath, password) {
|
|
165
|
+
if (typeof password !== 'string' || !password) {
|
|
166
|
+
throw new Error('加密需要非空口令(口令只经内存传入,绝不写盘)');
|
|
167
|
+
}
|
|
168
|
+
const salt = randomBytes(SALT_BYTES);
|
|
169
|
+
const nonce = randomBytes(NONCE_BYTES);
|
|
170
|
+
const key = await deriveKey(password, { ...KDF_PARAMS, salt });
|
|
171
|
+
const cipher = createCipheriv(CIPHER, key, nonce);
|
|
172
|
+
let ciphertextSize = 0;
|
|
173
|
+
const counter = new Transform({
|
|
174
|
+
transform(chunk, _enc, done) {
|
|
175
|
+
ciphertextSize += chunk.length;
|
|
176
|
+
done(null, chunk);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
try {
|
|
180
|
+
await pipeline(
|
|
181
|
+
createReadStream(inPath),
|
|
182
|
+
cipher,
|
|
183
|
+
counter,
|
|
184
|
+
createWriteStream(outPath, { mode: 0o600 }),
|
|
185
|
+
);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
await rm(outPath, { force: true }).catch(() => {});
|
|
133
188
|
throw err;
|
|
134
189
|
}
|
|
190
|
+
const authTag = cipher.getAuthTag();
|
|
191
|
+
return {
|
|
192
|
+
ciphertextSize,
|
|
193
|
+
sidecar: {
|
|
194
|
+
version: ENCRYPT_VERSION,
|
|
195
|
+
cipher: CIPHER,
|
|
196
|
+
kdf: KDF_PARAMS.algorithm,
|
|
197
|
+
kdfParams: {
|
|
198
|
+
N: KDF_PARAMS.N,
|
|
199
|
+
r: KDF_PARAMS.r,
|
|
200
|
+
p: KDF_PARAMS.p,
|
|
201
|
+
keyLen: KDF_PARAMS.keyLen,
|
|
202
|
+
},
|
|
203
|
+
salt: salt.toString('base64'),
|
|
204
|
+
nonce: nonce.toString('base64'),
|
|
205
|
+
authTag: authTag.toString('base64'),
|
|
206
|
+
ciphertextSize,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 解密文件:**分块流式**。认证通过才产出完整明文;口令错/密文被改时删除半截
|
|
213
|
+
* 输出并抛人话报错(绝不留下「解了一半」的明文)。
|
|
214
|
+
*
|
|
215
|
+
* @param {string} inPath 密文文件
|
|
216
|
+
* @param {string} outPath 明文输出文件(可能不完整;失败时已被删除)
|
|
217
|
+
* @param {object} sidecar `.encrypted.json` 内容
|
|
218
|
+
* @param {string} password 口令
|
|
219
|
+
*/
|
|
220
|
+
export async function decryptFile(inPath, outPath, sidecar, password) {
|
|
221
|
+
const { key, nonce, authTag } = await unlock(sidecar, password);
|
|
222
|
+
const decipher = createDecipheriv(CIPHER, key, nonce);
|
|
223
|
+
decipher.setAuthTag(authTag);
|
|
224
|
+
try {
|
|
225
|
+
await pipeline(
|
|
226
|
+
createReadStream(inPath),
|
|
227
|
+
decipher,
|
|
228
|
+
createWriteStream(outPath, { mode: 0o600 }),
|
|
229
|
+
);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
await rm(outPath, { force: true }).catch(() => {});
|
|
232
|
+
throw authFailed(err);
|
|
233
|
+
}
|
|
135
234
|
}
|
package/lib/index.js
CHANGED
|
@@ -63,7 +63,7 @@ import { basename, dirname, join, resolve } from 'node:path';
|
|
|
63
63
|
import { StringDecoder } from 'node:string_decoder';
|
|
64
64
|
import { fileURLToPath } from 'node:url';
|
|
65
65
|
import * as zlib from 'node:zlib';
|
|
66
|
-
import {
|
|
66
|
+
import { decryptFile, encryptFile } from './encrypt.js';
|
|
67
67
|
import { buildZip, listZip, extractZip, readZipEntry } from './zip.js';
|
|
68
68
|
import { gfsProtectedSet } from './gfs.js';
|
|
69
69
|
import { acquireLock, releaseLock, heartbeatLock, reclaimLock as reclaimOplock, isLockStale, LOCK_HEARTBEAT_MS } from './oplock.js';
|
|
@@ -1777,9 +1777,17 @@ export function apply(ctx, pluginConfig) {
|
|
|
1777
1777
|
// 替换——旧版插件遇到它只会 tar 解不开,边车只增不改、不误删不误读。
|
|
1778
1778
|
let encrypted = false;
|
|
1779
1779
|
if (typeof opts?.password === 'string' && opts.password) {
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1780
|
+
// 流式加密 + 临时文件后原子替换:绝不在原路径「整包读入 → 原地覆盖」。
|
|
1781
|
+
// 中途崩溃只会留下完整明文或完整密文,不会留半截密文(#103 同类教训)。
|
|
1782
|
+
const tmpEnc = `${out}.enc-${stampNow()}.tmp`;
|
|
1783
|
+
let enc;
|
|
1784
|
+
try {
|
|
1785
|
+
enc = await encryptFile(out, tmpEnc, opts.password);
|
|
1786
|
+
await rename(tmpEnc, out);
|
|
1787
|
+
} catch (err) {
|
|
1788
|
+
await rm(tmpEnc, { force: true }).catch(() => {});
|
|
1789
|
+
throw err;
|
|
1790
|
+
}
|
|
1783
1791
|
await writeOwned(`${out}.encrypted.json`, `${JSON.stringify(enc.sidecar)}\n`);
|
|
1784
1792
|
encrypted = true;
|
|
1785
1793
|
}
|
|
@@ -2190,7 +2198,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2190
2198
|
function redactSyncChannel(channel, ch) {
|
|
2191
2199
|
if (!ch) return { channel, type: channel, configured: false };
|
|
2192
2200
|
const out = { channel, type: ch.type, configured: true, lastPush: ch.lastPush ?? null, lastError: ch.lastError ?? null };
|
|
2193
|
-
if (ch.type === 'webdav') out.url = ch.url; out.username = ch.username; out.passwordSet = Boolean(ch.password);
|
|
2201
|
+
if (ch.type === 'webdav') { out.url = ch.url; out.username = ch.username; out.passwordSet = Boolean(ch.password); }
|
|
2194
2202
|
if (ch.type === 's3') { out.endpoint = ch.endpoint; out.region = ch.region; out.bucket = ch.bucket; out.pathPrefix = ch.pathPrefix || ''; out.accessKey = ch.accessKey; out.secretKeySet = Boolean(ch.secretKey); out.pathStyle = ch.pathStyle !== false; }
|
|
2195
2203
|
if (ch.type === 'gist') { out.gistId = ch.gistId || ''; out.tokenSet = Boolean(ch.token); }
|
|
2196
2204
|
return out;
|
|
@@ -2404,13 +2412,12 @@ export function apply(ctx, pluginConfig) {
|
|
|
2404
2412
|
// meta.encrypted 标记了加密但边车缺失:这是不一致状态,拒绝而非猜着解
|
|
2405
2413
|
throw new Error(`加密边车(.encrypted.json)缺失或损坏,无法解密——请核对备份是否完整(技术细节: ${snippet(String(err && err.message ? err.message : err), 80)})`);
|
|
2406
2414
|
}
|
|
2407
|
-
const
|
|
2408
|
-
|
|
2415
|
+
const tmpName = `.decrypt-${stampNow()}.tar.gz`;
|
|
2416
|
+
// 直接流式解密到目标临时文件:大归档不整包入内存,认证失败时不留半截明文。
|
|
2417
|
+
await decryptFile(`${root}/${archiveName}`, `${root}/${tmpName}`, sidecar, password);
|
|
2409
2418
|
// 隐藏名 `.decrypt-<时间戳>.tar.gz`:listBackups 要求 `dsh-` 前缀,隐藏点开头
|
|
2410
2419
|
// 的临时文件绝不进用户列表/轮换;位于备份目录内、相对名传 tar,规避 Windows
|
|
2411
2420
|
// GNU tar 盘符冒号问题。finally 里必清理(明文 tar.gz 不能遗留)。
|
|
2412
|
-
const tmpName = `.decrypt-${stampNow()}.tar.gz`;
|
|
2413
|
-
await writeBinary(`${root}/${tmpName}`, plain);
|
|
2414
2421
|
return { name: tmpName, cleanup: () => rm(`${root}/${tmpName}`, { force: true }) };
|
|
2415
2422
|
}
|
|
2416
2423
|
|
|
@@ -3101,9 +3108,17 @@ export function apply(ctx, pluginConfig) {
|
|
|
3101
3108
|
for (const p of packedPlugins) entryList.push({ name: `local-plugins/${p.tarballName}`, sourcePath: p.tarballPath });
|
|
3102
3109
|
await buildZip(tmpPath, entryList, { signal });
|
|
3103
3110
|
if (encrypted) {
|
|
3104
|
-
const
|
|
3105
|
-
|
|
3106
|
-
|
|
3111
|
+
const tmpEnc = `${finalPath}.enc-${stampNow()}.tmp`;
|
|
3112
|
+
let enc;
|
|
3113
|
+
try {
|
|
3114
|
+
enc = await encryptFile(tmpPath, tmpEnc, password);
|
|
3115
|
+
await rename(tmpEnc, finalPath);
|
|
3116
|
+
} catch (err) {
|
|
3117
|
+
await rm(tmpEnc, { force: true }).catch(() => {});
|
|
3118
|
+
throw err;
|
|
3119
|
+
} finally {
|
|
3120
|
+
await rm(tmpPath, { force: true }).catch(() => {});
|
|
3121
|
+
}
|
|
3107
3122
|
await writeOwned(`${finalPath}.encrypted.json`, `${JSON.stringify(enc.sidecar)}\n`);
|
|
3108
3123
|
} else {
|
|
3109
3124
|
await rename(tmpPath, finalPath);
|
|
@@ -3701,17 +3716,23 @@ export function apply(ctx, pluginConfig) {
|
|
|
3701
3716
|
};
|
|
3702
3717
|
}
|
|
3703
3718
|
|
|
3704
|
-
// ---------------- 外部聊天记录导入
|
|
3705
|
-
//
|
|
3706
|
-
//
|
|
3707
|
-
//
|
|
3719
|
+
// ---------------- 外部聊天记录导入(多来源) ----------------
|
|
3720
|
+
// 把其它 AI 工具的会话日志翻译成 DSH v4 会话日志(新建会话、绝不改原数据)。
|
|
3721
|
+
// 支持:claude-code(~/.claude/projects/*/*.jsonl)、codex(~/.codex/sessions/**/rollout-*.jsonl)。
|
|
3722
|
+
// 只映射「用户/助手文本 + 推理摘要 + 工具调用/结果」;其余事件跳过并计数。
|
|
3723
|
+
// 行格式对齐宿主:{type,seq,time,data} + 消息事件带 surfaceOp:'append'。
|
|
3708
3724
|
const CLAUDE_PROJECTS_DIR = '.claude/projects';
|
|
3725
|
+
const CODEX_SESSIONS_DIR = '.codex/sessions';
|
|
3709
3726
|
const CHAT_IMPORT_MAX_MESSAGES = 5000;
|
|
3727
|
+
const CHAT_SOURCE_LABELS = { 'claude-code': 'Claude Code', codex: 'Codex' };
|
|
3710
3728
|
|
|
3711
|
-
function
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3729
|
+
function chatSourceOf(id) {
|
|
3730
|
+
return String(id || '').startsWith('codex-') ? 'codex' : 'claude-code';
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
function chatSessionId(prefix, name) {
|
|
3734
|
+
const tail = String(name).replace(/[^A-Za-z0-9]/g, '').slice(-12);
|
|
3735
|
+
return `${prefix}-${tail || 'session'}`;
|
|
3715
3736
|
}
|
|
3716
3737
|
|
|
3717
3738
|
async function listClaudeSessions() {
|
|
@@ -3729,12 +3750,39 @@ export function apply(ctx, pluginConfig) {
|
|
|
3729
3750
|
const abs = `${root}/${proj.name}/${f}`;
|
|
3730
3751
|
let st;
|
|
3731
3752
|
try { st = await fsStat(abs); } catch { continue; }
|
|
3732
|
-
out.push({ abs, rel: `${CLAUDE_PROJECTS_DIR}/${proj.name}/${f}`,
|
|
3753
|
+
out.push({ abs, rel: `${CLAUDE_PROJECTS_DIR}/${proj.name}/${f}`, project: proj.name, file: f, id: chatSessionId('claude', `${proj.name}${f}`), bytes: st.size, mtime: st.mtimeMs });
|
|
3733
3754
|
}
|
|
3734
3755
|
}
|
|
3735
3756
|
return out;
|
|
3736
3757
|
}
|
|
3737
3758
|
|
|
3759
|
+
async function listCodexSessions() {
|
|
3760
|
+
const { home } = paths();
|
|
3761
|
+
const root = `${home}/${CODEX_SESSIONS_DIR}`;
|
|
3762
|
+
const out = [];
|
|
3763
|
+
const walk = async (dir, depth) => {
|
|
3764
|
+
if (depth > 6 || out.length >= 200) return;
|
|
3765
|
+
let entries;
|
|
3766
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
3767
|
+
for (const e of entries) {
|
|
3768
|
+
const abs = `${dir}/${e.name}`;
|
|
3769
|
+
if (e.isDirectory()) { await walk(abs, depth + 1); continue; }
|
|
3770
|
+
if (!e.name.endsWith('.jsonl')) continue;
|
|
3771
|
+
let st;
|
|
3772
|
+
try { st = await fsStat(abs); } catch { continue; }
|
|
3773
|
+
const stem = e.name.replace(/^rollout-/, '').replace(/\.jsonl$/, '');
|
|
3774
|
+
out.push({ abs, rel: abs.slice(home.length + 1), project: stem.slice(0, 10), file: e.name, id: chatSessionId('codex', stem), bytes: st.size, mtime: st.mtimeMs });
|
|
3775
|
+
}
|
|
3776
|
+
};
|
|
3777
|
+
await walk(root, 0);
|
|
3778
|
+
return out;
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
function textBlocks(blocks) {
|
|
3782
|
+
if (!Array.isArray(blocks)) return [];
|
|
3783
|
+
return blocks.filter((b) => b && typeof b.text === 'string' && b.text).map((b) => b.text);
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3738
3786
|
/** 解析 Claude Code JSONL → 归一化消息序列。 */
|
|
3739
3787
|
async function parseClaudeSession(abs) {
|
|
3740
3788
|
const text = await readFile(abs, 'utf8');
|
|
@@ -3751,7 +3799,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
3751
3799
|
const blocks = Array.isArray(msg.content)
|
|
3752
3800
|
? msg.content
|
|
3753
3801
|
: (typeof msg.content === 'string' ? [{ type: 'text', text: msg.content }] : []);
|
|
3754
|
-
const time = rec.timestamp ? (Date.parse(rec.timestamp) ||
|
|
3802
|
+
const time = rec.timestamp ? (Date.parse(rec.timestamp) || 0) : 0;
|
|
3755
3803
|
if (type === 'user') {
|
|
3756
3804
|
const texts = [];
|
|
3757
3805
|
const results = [];
|
|
@@ -3774,7 +3822,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
3774
3822
|
for (const b of blocks) {
|
|
3775
3823
|
if (b && b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
|
|
3776
3824
|
else if (b && b.type === 'thinking' && typeof b.thinking === 'string') reasoning.push(b.thinking);
|
|
3777
|
-
else if (b && b.type === 'tool_use') toolCalls.push({ id: String(b.id || `call-${toolCalls.length}`), name: String(b.name || 'tool'),
|
|
3825
|
+
else if (b && b.type === 'tool_use') toolCalls.push({ id: String(b.id || `call-${toolCalls.length}`), name: String(b.name || 'tool'), arguments: JSON.stringify(b.input ?? {}) });
|
|
3778
3826
|
else skipped += 1;
|
|
3779
3827
|
}
|
|
3780
3828
|
if (texts.length || reasoning.length || toolCalls.length) {
|
|
@@ -3782,11 +3830,72 @@ export function apply(ctx, pluginConfig) {
|
|
|
3782
3830
|
}
|
|
3783
3831
|
}
|
|
3784
3832
|
}
|
|
3785
|
-
return { messages, skipped };
|
|
3833
|
+
return { messages, skipped, cwd: null, model: null };
|
|
3786
3834
|
}
|
|
3787
3835
|
|
|
3788
|
-
/**
|
|
3789
|
-
function
|
|
3836
|
+
/** 解析 Codex rollout JSONL → 归一化消息序列(函数调用与结果按 call_id 配对)。 */
|
|
3837
|
+
async function parseCodexSession(abs) {
|
|
3838
|
+
const text = await readFile(abs, 'utf8');
|
|
3839
|
+
const messages = [];
|
|
3840
|
+
const calls = new Map();
|
|
3841
|
+
let skipped = 0;
|
|
3842
|
+
let cwd = null;
|
|
3843
|
+
let model = null;
|
|
3844
|
+
for (const line of text.split('\n')) {
|
|
3845
|
+
if (!line.trim()) continue;
|
|
3846
|
+
if (messages.length >= CHAT_IMPORT_MAX_MESSAGES) break;
|
|
3847
|
+
let rec;
|
|
3848
|
+
try { rec = JSON.parse(line); } catch { skipped += 1; continue; }
|
|
3849
|
+
const p = rec.payload || {};
|
|
3850
|
+
if (rec.type === 'session_meta') {
|
|
3851
|
+
if (typeof p.cwd === 'string') cwd = p.cwd;
|
|
3852
|
+
continue;
|
|
3853
|
+
}
|
|
3854
|
+
if (rec.type === 'turn_context') {
|
|
3855
|
+
if (typeof p.model === 'string') model = p.model;
|
|
3856
|
+
continue;
|
|
3857
|
+
}
|
|
3858
|
+
if (rec.type !== 'response_item') { skipped += 1; continue; }
|
|
3859
|
+
const time = rec.timestamp ? (Date.parse(rec.timestamp) || 0) : 0;
|
|
3860
|
+
const kind = p.type;
|
|
3861
|
+
if (kind === 'message') {
|
|
3862
|
+
if (p.role === 'user') messages.push({ role: 'user', texts: textBlocks(p.content), reasoning: [], toolCalls: [], time });
|
|
3863
|
+
else if (p.role === 'assistant') messages.push({ role: 'assistant', texts: textBlocks(p.content), reasoning: [], toolCalls: [], time });
|
|
3864
|
+
else skipped += 1; // developer / system 指令不导入
|
|
3865
|
+
} else if (kind === 'reasoning') {
|
|
3866
|
+
const summary = textBlocks(p.summary).join('\n').trim();
|
|
3867
|
+
if (summary) messages.push({ role: 'assistant', texts: [], reasoning: [summary], toolCalls: [], time });
|
|
3868
|
+
else skipped += 1; // 只有加密推理,无法还原
|
|
3869
|
+
} else if (kind === 'function_call' || kind === 'custom_tool_call') {
|
|
3870
|
+
const callId = String(p.call_id || p.id || '');
|
|
3871
|
+
const args = typeof p.arguments === 'string'
|
|
3872
|
+
? p.arguments
|
|
3873
|
+
: (typeof p.input === 'string' ? JSON.stringify({ input: p.input }) : '{}');
|
|
3874
|
+
calls.set(callId, { name: String(p.name || 'tool'), arguments: args });
|
|
3875
|
+
} else if (kind === 'function_call_output' || kind === 'custom_tool_call_output') {
|
|
3876
|
+
const callId = String(p.call_id || '');
|
|
3877
|
+
const call = calls.get(callId);
|
|
3878
|
+
const body = typeof p.output === 'string' ? p.output : textBlocks(p.output).join('\n');
|
|
3879
|
+
messages.push({
|
|
3880
|
+
role: 'tool',
|
|
3881
|
+
texts: [body],
|
|
3882
|
+
reasoning: [],
|
|
3883
|
+
toolCalls: [],
|
|
3884
|
+
callId,
|
|
3885
|
+
name: call?.name,
|
|
3886
|
+
arguments: call?.arguments,
|
|
3887
|
+
time,
|
|
3888
|
+
});
|
|
3889
|
+
} else {
|
|
3890
|
+
skipped += 1; // compaction / agent_message / world_state 等
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
const kept = messages.filter((m) => m.role === 'tool' ? m.texts.length > 0 : (m.texts.length > 0 || m.reasoning.length > 0));
|
|
3894
|
+
return { messages: kept, skipped, cwd, model };
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
/** 归一化消息 → DSH v4 事件行(seq 连续;消息事件带 surfaceOp)。 */
|
|
3898
|
+
function chatMessagesToEvents(messages, baseTime) {
|
|
3790
3899
|
const rows = [];
|
|
3791
3900
|
let seq = 0;
|
|
3792
3901
|
let turn = 0;
|
|
@@ -3803,6 +3912,8 @@ export function apply(ctx, pluginConfig) {
|
|
|
3803
3912
|
if (m.role === 'user') {
|
|
3804
3913
|
push('user/message', { id: `u${turn}`, role: 'user', content: m.texts.map((t) => ({ type: 'text', text: t })), source: { kind: 'user' } }, true);
|
|
3805
3914
|
} else if (m.role === 'tool') {
|
|
3915
|
+
// Codex 的调用与结果分两条记录:同一回合内先补 tool/call,再落 tool/result。
|
|
3916
|
+
if (m.name) push('tool/call', { turn, step: 1, callId: m.callId, name: m.name, arguments: m.arguments || '{}' });
|
|
3806
3917
|
push('tool/result', {
|
|
3807
3918
|
turn,
|
|
3808
3919
|
step: 1,
|
|
@@ -3819,11 +3930,17 @@ export function apply(ctx, pluginConfig) {
|
|
|
3819
3930
|
const content = [];
|
|
3820
3931
|
for (const r of m.reasoning) content.push({ type: 'reasoning', text: r });
|
|
3821
3932
|
for (const t of m.texts) content.push({ type: 'text', text: t });
|
|
3822
|
-
for (const c of m.toolCalls) content.push({ type: 'tool-call', id: c.id, name: c.name, arguments:
|
|
3933
|
+
for (const c of m.toolCalls) content.push({ type: 'tool-call', id: c.id, name: c.name, arguments: c.arguments });
|
|
3934
|
+
if (!content.length) {
|
|
3935
|
+
rows.pop();
|
|
3936
|
+
rows.pop();
|
|
3937
|
+
turn -= 1;
|
|
3938
|
+
continue;
|
|
3939
|
+
}
|
|
3823
3940
|
push('assistant/message', {
|
|
3824
3941
|
turn,
|
|
3825
3942
|
step: 1,
|
|
3826
|
-
message: { id: `a${turn}`, role: 'assistant', content, source: { kind: 'model', provider: '
|
|
3943
|
+
message: { id: `a${turn}`, role: 'assistant', content, source: { kind: 'model', provider: 'imported', model: 'imported' } },
|
|
3827
3944
|
stream: [],
|
|
3828
3945
|
}, true);
|
|
3829
3946
|
}
|
|
@@ -3833,61 +3950,74 @@ export function apply(ctx, pluginConfig) {
|
|
|
3833
3950
|
return rows;
|
|
3834
3951
|
}
|
|
3835
3952
|
|
|
3836
|
-
/**
|
|
3953
|
+
/** 外部聊天来源汇总(检测 + 会话清单)。 */
|
|
3837
3954
|
async function chatSources() {
|
|
3838
|
-
const
|
|
3955
|
+
const [claude, codex] = await Promise.all([listClaudeSessions(), listCodexSessions()]);
|
|
3839
3956
|
const sessions = [];
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3957
|
+
const sources = [];
|
|
3958
|
+
const collect = async (sourceId, list) => {
|
|
3959
|
+
sources.push({ id: sourceId, label: CHAT_SOURCE_LABELS[sourceId], detected: list.length > 0, count: list.length });
|
|
3960
|
+
for (const s of list.slice(0, 50)) {
|
|
3961
|
+
try {
|
|
3962
|
+
const parsed = sourceId === 'codex' ? await parseCodexSession(s.abs) : await parseClaudeSession(s.abs);
|
|
3963
|
+
const firstUser = parsed.messages.find((m) => m.role === 'user');
|
|
3964
|
+
sessions.push({
|
|
3965
|
+
source: sourceId,
|
|
3966
|
+
id: s.id,
|
|
3967
|
+
file: s.file,
|
|
3968
|
+
project: s.project,
|
|
3969
|
+
messages: parsed.messages.length,
|
|
3970
|
+
title: firstUser ? String(firstUser.texts[0] || '').slice(0, 60) : '',
|
|
3971
|
+
});
|
|
3972
|
+
} catch { /* 单文件解析失败不影响其它会话 */ }
|
|
3973
|
+
}
|
|
3974
|
+
};
|
|
3975
|
+
await collect('claude-code', claude);
|
|
3976
|
+
await collect('codex', codex);
|
|
3977
|
+
const detected = sources.filter((s) => s.detected);
|
|
3853
3978
|
return {
|
|
3854
3979
|
ok: true,
|
|
3855
|
-
|
|
3856
|
-
detected:
|
|
3980
|
+
sources,
|
|
3981
|
+
detected: detected.length > 0,
|
|
3857
3982
|
sessions,
|
|
3858
|
-
summary:
|
|
3983
|
+
summary: detected.length
|
|
3984
|
+
? `💬 检测到 ${detected.map((s) => `${s.label} ${s.count}`).join(' · ')} 个会话`
|
|
3985
|
+
: '💬 没有检测到可导入的聊天记录(Claude Code / Codex)',
|
|
3859
3986
|
};
|
|
3860
3987
|
}
|
|
3861
3988
|
|
|
3862
|
-
async function
|
|
3863
|
-
const
|
|
3989
|
+
async function buildChatImport(source, sessionId) {
|
|
3990
|
+
const src = source === 'codex' ? 'codex' : 'claude-code';
|
|
3991
|
+
const list = src === 'codex' ? await listCodexSessions() : await listClaudeSessions();
|
|
3864
3992
|
const target = list.find((s) => s.id === sessionId || s.file === sessionId || s.abs === sessionId);
|
|
3865
|
-
if (!target) throw new Error(`找不到
|
|
3866
|
-
const parsed = await parseClaudeSession(target.abs);
|
|
3993
|
+
if (!target) throw new Error(`找不到 ${CHAT_SOURCE_LABELS[src]} 会话:${sessionId}`);
|
|
3994
|
+
const parsed = src === 'codex' ? await parseCodexSession(target.abs) : await parseClaudeSession(target.abs);
|
|
3867
3995
|
if (!parsed.messages.length) throw new Error('该会话没有可导入的文本消息');
|
|
3868
3996
|
// 宿主要求 time 是安全整数:毫秒时间戳必须取整(mtimeMs 可能是小数)。
|
|
3869
|
-
const rows =
|
|
3997
|
+
const rows = chatMessagesToEvents(parsed.messages, Math.floor(target.mtime || Date.now()));
|
|
3870
3998
|
const id = `${target.id}-${stampNow()}`;
|
|
3871
|
-
const header = { type: 'session', version: 4, id, createdAt: Math.floor(target.mtime || Date.now()), isSeeded: false, delegationDepth: 0 };
|
|
3999
|
+
const header = { type: 'session', version: 4, id, createdAt: Math.floor(target.mtime || Date.now()), isSeeded: false, delegationDepth: 0, ...(parsed.cwd ? { cwd: parsed.cwd } : {}) };
|
|
3872
4000
|
const lines = [JSON.stringify(header), ...rows.map((r) => JSON.stringify(r))];
|
|
3873
4001
|
const defect = validateSessionLines(lines);
|
|
3874
|
-
if (defect !== null) throw new Error(
|
|
3875
|
-
return { target, parsed, rows, header, id, lines };
|
|
4002
|
+
if (defect !== null) throw new Error(`导入会话未通过宿主容器校验,已中止:${defect}`);
|
|
4003
|
+
return { source: src, target, parsed, rows, header, id, lines };
|
|
3876
4004
|
}
|
|
3877
4005
|
|
|
3878
|
-
/**
|
|
4006
|
+
/** 预览:只读,不写盘。 */
|
|
3879
4007
|
async function chatPreview(opts, signal) {
|
|
3880
4008
|
const sessionId = typeof opts?.session === 'string' ? opts.session : '';
|
|
3881
4009
|
if (!sessionId) throw new Error('缺少会话 id');
|
|
3882
4010
|
if (signal?.aborted) throw new Error('操作已取消');
|
|
3883
|
-
const
|
|
4011
|
+
const source = typeof opts?.source === 'string' && opts.source ? opts.source : chatSourceOf(sessionId);
|
|
4012
|
+
const bundle = await buildChatImport(source, sessionId);
|
|
3884
4013
|
const userCount = bundle.parsed.messages.filter((m) => m.role === 'user').length;
|
|
3885
4014
|
const asstCount = bundle.parsed.messages.filter((m) => m.role === 'assistant').length;
|
|
3886
4015
|
const toolCount = bundle.parsed.messages.filter((m) => m.role === 'tool').length;
|
|
4016
|
+
const label = CHAT_SOURCE_LABELS[bundle.source];
|
|
3887
4017
|
return {
|
|
3888
4018
|
ok: true,
|
|
3889
4019
|
dryRun: true,
|
|
3890
|
-
source:
|
|
4020
|
+
source: bundle.source,
|
|
3891
4021
|
session: bundle.target.id,
|
|
3892
4022
|
file: bundle.target.file,
|
|
3893
4023
|
messages: bundle.parsed.messages.length,
|
|
@@ -3897,45 +4027,46 @@ export function apply(ctx, pluginConfig) {
|
|
|
3897
4027
|
skipped: bundle.parsed.skipped,
|
|
3898
4028
|
events: bundle.rows.length,
|
|
3899
4029
|
newId: bundle.id,
|
|
3900
|
-
warnings: ['
|
|
3901
|
-
summary: `💬
|
|
4030
|
+
warnings: ['仅导入文本、推理摘要与工具调用/结果;图片/文件块与私有事件已跳过', '导入为新的 DSH 会话,不改动原会话'],
|
|
4031
|
+
summary: `💬 ${label} 导入预览\n${bundle.parsed.messages.length} 条消息(用户 ${userCount} / 助手 ${asstCount} / 工具结果 ${toolCount})→ ${bundle.rows.length} 个事件\n跳过 ${bundle.parsed.skipped} 个非文本事件;导入为新会话,原会话不动`,
|
|
3902
4032
|
};
|
|
3903
4033
|
}
|
|
3904
4034
|
|
|
3905
|
-
/**
|
|
4035
|
+
/** 执行导入:默认 dry-run;confirm:true 才写(新会话目录,绝不覆盖)。 */
|
|
3906
4036
|
async function chatApply(opts, signal) {
|
|
3907
4037
|
const sessionId = typeof opts?.session === 'string' ? opts.session : '';
|
|
3908
4038
|
if (!sessionId) throw new Error('缺少会话 id');
|
|
3909
4039
|
if (opts?.confirm !== true) return chatPreview(opts, signal);
|
|
3910
|
-
const
|
|
4040
|
+
const source = typeof opts?.source === 'string' && opts.source ? opts.source : chatSourceOf(sessionId);
|
|
4041
|
+
const bundle = await buildChatImport(source, sessionId);
|
|
3911
4042
|
const { dshHome } = paths();
|
|
3912
4043
|
const dir = `${dshHome}/sessions/_no-cwd/${encodeSessionSegment(bundle.id)}`;
|
|
3913
|
-
if (pathExists(dir)) throw new Error('
|
|
4044
|
+
if (pathExists(dir)) throw new Error('目标会话目录已存在,拒绝覆盖');
|
|
3914
4045
|
await mkdir(dir, { recursive: true });
|
|
3915
4046
|
const headerFrame = zlib.zstdCompressSync(Buffer.from(`${JSON.stringify(bundle.header)}\n`));
|
|
3916
4047
|
const bodyFrame = zlib.zstdCompressSync(Buffer.from(`${bundle.rows.map((r) => JSON.stringify(r)).join('\n')}\n`));
|
|
3917
4048
|
const file = `${dir}/session.v4.jsonl.zstd`;
|
|
3918
4049
|
try {
|
|
3919
4050
|
await writeFile(file, Buffer.concat([headerFrame, bodyFrame]));
|
|
3920
|
-
//
|
|
4051
|
+
// 回读校验:同一套容器契约(首帧恰好一行 header + seq 连续)
|
|
3921
4052
|
const back = decodeZstdLog(await readFile(file));
|
|
3922
4053
|
const defect = validateSessionLines(back.lines);
|
|
3923
4054
|
if (defect !== null) throw new Error(defect);
|
|
3924
4055
|
} catch (err) {
|
|
3925
4056
|
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
3926
|
-
throw new Error(
|
|
4057
|
+
throw new Error(`写出的会话未通过校验:${String(err && err.message ? err.message : err)}`);
|
|
3927
4058
|
}
|
|
3928
4059
|
return {
|
|
3929
4060
|
ok: true,
|
|
3930
4061
|
dryRun: false,
|
|
4062
|
+
source: bundle.source,
|
|
3931
4063
|
newId: bundle.id,
|
|
3932
4064
|
path: file,
|
|
3933
4065
|
messages: bundle.parsed.messages.length,
|
|
3934
4066
|
events: bundle.rows.length,
|
|
3935
|
-
summary: `💬 已导入
|
|
4067
|
+
summary: `💬 已导入 ${CHAT_SOURCE_LABELS[bundle.source]} 会话:${bundle.parsed.messages.length} 条消息(${bundle.rows.length} 个事件)\n新会话 id: ${bundle.id}\n重启 dsh 后可在会话列表看到(仅文本、推理摘要与工具调用/结果)`,
|
|
3936
4068
|
};
|
|
3937
4069
|
}
|
|
3938
|
-
|
|
3939
4070
|
/**
|
|
3940
4071
|
* 打开导出 ZIP(必要时用口令解密到备份目录临时文件)。返回 { zipPath, encrypted,
|
|
3941
4072
|
* cleanup };调用方在 finally 里必调 cleanup(解密出的明文 ZIP 不能遗留)。
|
|
@@ -3949,11 +4080,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
3949
4080
|
if (encrypted) {
|
|
3950
4081
|
if (!password) throw new Error('这份导出已加密,需要口令才能读取(口令只经面板 RPC 内存态传入,绝不落盘)');
|
|
3951
4082
|
const sidecar = JSON.parse(await readFile(sidecarPath, 'utf8'));
|
|
3952
|
-
const ciphertext = await readFile(src);
|
|
3953
|
-
const plain = await decryptBuffer(ciphertext, sidecar, password);
|
|
3954
4083
|
const { root } = paths();
|
|
3955
4084
|
const tmp = `${root}/.export-dec-${stampNow()}.zip`;
|
|
3956
|
-
await
|
|
4085
|
+
await decryptFile(src, tmp, sidecar, password);
|
|
3957
4086
|
return { zipPath: tmp, encrypted: true, cleanup: () => rm(tmp, { force: true }) };
|
|
3958
4087
|
}
|
|
3959
4088
|
return { zipPath: src, encrypted: false, cleanup: null };
|
|
@@ -4049,7 +4178,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
4049
4178
|
const remap = await remapScan(zipPath, manifest, home);
|
|
4050
4179
|
const compatibility = compatScore(manifest, home);
|
|
4051
4180
|
const totalFiles = sections.reduce((s, x) => s + x.files, 0);
|
|
4052
|
-
const summary = `📦 导入预览 —— 只读,还没动磁盘\n来源: ${manifest.source?.host ?? '未知'}(${manifest.source?.platform ?? '未知平台'})\n将导入 ${totalFiles} 个文件、${sections.length} 个节段${conflicts.length ? `\n⚠️ 其中 ${conflicts.length}
|
|
4181
|
+
const summary = `📦 导入预览 —— 只读,还没动磁盘\n来源: ${manifest.source?.host ?? '未知'}(${manifest.source?.platform ?? '未知平台'})\n将导入 ${totalFiles} 个文件、${sections.length} 个节段${conflicts.length ? `\n⚠️ 其中 ${conflicts.length} 个目标文件已存在(默认保留本机,不改动已有文件)` : ''}${remap.length ? '\n⚠️ 需要跨机绝对路径 remap(见 remap 列表)' : ''}\n兼容性: ${compatibility.score}/100${compatibility.reasons.length ? `(${compatibility.reasons.join(';')})` : ''}`;
|
|
4053
4182
|
return { ok: true, encrypted, manifest, sections, totalFiles, totalBytes: manifest.totalBytes ?? 0, conflicts, remap, compatibility, summary };
|
|
4054
4183
|
} finally {
|
|
4055
4184
|
if (cleanup) await cleanup().catch(() => {});
|
|
@@ -4111,14 +4240,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
4111
4240
|
await prunePrefixedSnapshots(IMPORT_SNAPSHOT_PREFIX, 1, snap.name);
|
|
4112
4241
|
} catch { /* 快照失败不阻断导入 */ }
|
|
4113
4242
|
|
|
4114
|
-
|
|
4115
|
-
// 之后、真正写入之前」抛一次错,模拟导入中途失败——零依赖 smoke 据此断言失败
|
|
4116
|
-
// 路径与快照安全网(~/.dsh 未被改动)。生产默认绝不触发:环境变量未显式命中
|
|
4117
|
-
// '1' 时此分支永不进入。
|
|
4118
|
-
if (process.env.DSH_BACKUP_IMPORT_FAIL === '1') {
|
|
4119
|
-
throw new Error('test-only import fault injection (DSH_BACKUP_IMPORT_FAIL=1)');
|
|
4120
|
-
}
|
|
4121
|
-
|
|
4243
|
+
|
|
4122
4244
|
// 解压到暂存目录(extractZip 内 Zip Slip/符号链接/zip bomb/CRC 全套防线),再拷入 ~/.dsh
|
|
4123
4245
|
staging = `${root}/.import-stage-${stampNow()}`;
|
|
4124
4246
|
await extractZip(zipPath, staging, { names: new Set(willWrite.map((e) => e.name)), signal });
|
|
@@ -5547,7 +5669,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
5547
5669
|
const sub = parts[1] || 'list';
|
|
5548
5670
|
if (sub === 'list') {
|
|
5549
5671
|
const r = await chatSources();
|
|
5550
|
-
const lines = r.sessions.map((s) => ` ${s.id} ${s.messages} 条 ${s.title ? `「${s.title}」` : ''}`);
|
|
5672
|
+
const lines = r.sessions.map((s) => ` ${CHAT_SOURCE_LABELS[s.source] ?? s.source} ${s.id} ${s.messages} 条 ${s.title ? `「${s.title}」` : ''}`);
|
|
5551
5673
|
return { kind: 'success', text: `${r.summary}${lines.length ? `\n${lines.join('\n')}\n\n预览:/backup chat preview <id>;导入:/backup chat import <id> [--yes]` : ''}` };
|
|
5552
5674
|
}
|
|
5553
5675
|
if (sub === 'preview' || sub === 'import') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiaoyuyu6420/dsh-backup",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"description": "一条命令备份、恢复与迁移 DeepSeek Harness(dsh)的全部数据(~/.dsh:会话、设置、插件、MCP、技能):定时自动备份、升级前快照、换机迁移、会话日志体检修复、宿主起不来也能用的进程外救援;凭据默认脱敏只存本机。 Backup, restore & migrate all DeepSeek Harness (~/.dsh) data — sessions, settings, plugins, MCP, skills: scheduled backups, pre-upgrade snapshots, cross-machine migration, session-log doctor, out-of-process rescue console; credentials redacted to a local-only vault.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.24.0",
|
package/rescue/rescue.mjs
CHANGED
|
@@ -830,6 +830,10 @@ async function sessionsRepair(root, dshHome, argv) {
|
|
|
830
830
|
}
|
|
831
831
|
}
|
|
832
832
|
if (!maps.length && keep === undefined) throw new Error('sessions repair 至少需要 --map old=new 或 --keep <id>');
|
|
833
|
+
// --map 会**改写会话 header 里的 cwd**(动用户自己的日志),必须显式确认。
|
|
834
|
+
if (fix && maps.length && !argv.includes('--yes')) {
|
|
835
|
+
throw new Error('sessions repair --fix --map 会原地改写会话 header 的 cwd(动用户日志)——确认请加 --yes;不加 --fix 时仅预览');
|
|
836
|
+
}
|
|
833
837
|
|
|
834
838
|
const files = [];
|
|
835
839
|
await collectSessionLogs(`${dshHome}/sessions`, '', 0, files);
|