pi-webdav-backup 1.0.2 → 1.3.1

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.
@@ -26,7 +26,8 @@ import { pipeline } from "node:stream/promises";
26
26
  import { randomBytes } from "node:crypto";
27
27
 
28
28
  import { WebDAVClient } from "./webdav.mjs";
29
- import { collectFiles } from "./collect.mjs";
29
+ import { collectFiles, deriveSensitivePaths } from "./collect.mjs";
30
+ import { buildExtraMap, EXTRA_MANIFEST_NAME } from "./extras.mjs";
30
31
  import { encryptFile, sha256Buffer, sha256File } from "./crypto.mjs";
31
32
 
32
33
  const TAR_BLOCK = 512;
@@ -182,113 +183,151 @@ export function writeState(stateFile, state) {
182
183
  * auth.json.bak-2026-... / auth.json.tmp 这类副本同样是明文密钥。
183
184
  */
184
185
  export function isRedactable(relPath) {
186
+ const p = String(relPath).replace(/\\/g, "/");
187
+ // 插件实现代码是第三方依赖,不参与脱敏(避免误改;其中个别的 auth.json 多为测试固件)
188
+ if (p.startsWith("npm/node_modules/") || p.includes("/node_modules/")) return false;
185
189
  const base = String(relPath).split("/").pop() ?? "";
186
190
  return base.startsWith("auth.json") || base.startsWith("webdav-backup.json");
187
191
  }
188
192
 
189
- /**
190
- * 脱敏敏感文件内容。
191
- *
192
- * @param {string} relPath
193
- * @param {Buffer} buf
194
- * @param {{aggressive?: boolean}} [opts]
195
- * aggressive=true(未加密备份时使用):按字段名 + 值形状双重判断,
196
- * 尽量不遗漏任何凭据;aggressive=false 时仍会脱敏已知字段。
197
- * @returns {Buffer|null} null 表示无需脱敏
198
- */
199
- export function redactSensitive(relPath, buf, opts = {}) {
200
- const aggressive = opts.aggressive !== false;
201
- const base = relPath.split("/").pop() ?? "";
202
-
203
- const SECRET_KEY_RE = /key|token|secret|password|passwd|credential|api[-_]?key|auth(?!or)|cookie|session[-_]?(token|key|secret|id)|bearer|private/i;
204
- // 密钥引用($ENV / !cmd / dpapi: / file:)是“密钥放在哪”的指针,不是秘密本身,应保留
205
- const SECRET_REF_SCHEMES = ["$", "!", "dpapi:", "file:"];
206
- const isRef = (v) =>
207
- typeof v === "string" && SECRET_REF_SCHEMES.some((p) => v.trim().startsWith(p));
208
- // 常见凭据前缀,以及“长且像随机串”的值
209
- const SECRET_VALUE_RE =
210
- /^(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|[A-Za-z0-9_+/=-]{32,})$/;
211
-
212
- const looksSecret = (v) => typeof v === "string" && SECRET_VALUE_RE.test(v.trim());
213
-
214
- const redactJson = (obj, { aggressiveValues }) => {
215
- const walk = (v, keyHint = "") => {
216
- if (Array.isArray(v)) return v.map((x) => walk(x, keyHint));
217
- if (v && typeof v === "object") {
218
- const out = {};
219
- for (const [k, val] of Object.entries(v)) {
220
- if (SECRET_KEY_RE.test(k)) {
221
- // 保留结构,抹掉值。
222
- // 只替换字符串:布尔/数字不可能是凭据,
223
- // 把 false 替换成字符串会破坏恢复后的配置语义(回归:includeSessions 曾被误伤)
224
- out[k] =
225
- typeof val === "object" && val !== null
226
- ? walk(val, k)
227
- : typeof val === "string"
228
- ? "__REDACTED__"
229
- : val;
230
- } else {
231
- out[k] = walk(val, k);
232
- }
193
+ const SECRET_KEY_RE = /key|token|secret|password|passwd|credential|api[-_]?key|auth(?!or)|cookie|session[-_]?(token|key|secret|id)|bearer|private/i;
194
+ // 密钥引用($ENV / !cmd / dpapi: / file:)是“密钥放在哪”的指针,不是秘密本身,应保留
195
+ const SECRET_REF_SCHEMES = ["$", "!", "dpapi:", "file:"];
196
+ // 常见凭据前缀,以及“长且像随机串”的值
197
+ const SECRET_VALUE_RE =
198
+ /^(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|[A-Za-z0-9_+/=-]{32,})$/;
199
+
200
+ const isSecretRef = (v) =>
201
+ typeof v === "string" && SECRET_REF_SCHEMES.some((p) => v.trim().startsWith(p));
202
+ const looksSecret = (v) => typeof v === "string" && SECRET_VALUE_RE.test(v.trim());
203
+
204
+ /** 按字段名 + (可选)值形状递归脱敏一个 JSON 值 */
205
+ function redactJsonValue(obj, { aggressiveValues }) {
206
+ const walk = (v, keyHint = "") => {
207
+ if (Array.isArray(v)) return v.map((x) => walk(x, keyHint));
208
+ if (v && typeof v === "object") {
209
+ const out = {};
210
+ for (const [k, val] of Object.entries(v)) {
211
+ if (SECRET_KEY_RE.test(k)) {
212
+ // 保留结构,抹掉值。
213
+ // 只替换字符串:布尔/数字不可能是凭据,
214
+ // 把 false 替换成字符串会破坏恢复后的配置语义(回归:includeSessions 曾被误伤)
215
+ out[k] =
216
+ typeof val === "object" && val !== null
217
+ ? walk(val, k)
218
+ : typeof val === "string"
219
+ ? "__REDACTED__"
220
+ : val;
221
+ } else {
222
+ out[k] = walk(val, k);
233
223
  }
234
- return out;
235
224
  }
236
- if (aggressiveValues && looksSecret(v)) return "__REDACTED__";
237
- return v;
238
- };
239
- return walk(obj);
225
+ return out;
226
+ }
227
+ if (aggressiveValues && looksSecret(v)) return "__REDACTED__";
228
+ return v;
240
229
  };
230
+ return walk(obj);
231
+ }
232
+
233
+ /**
234
+ * 对一段文本内容做脱敏(含凭据文件与 agentDir 外的 JSON/配置)。
235
+ * @returns {string} 脱敏后的文本
236
+ */
237
+ export function redactSensitiveContent(relPath, text, opts = {}) {
238
+ const aggressive = opts.aggressive !== false;
239
+ const base = String(relPath).split("/").pop() ?? "";
241
240
 
242
- // 按前缀匹配(见 isRedactable 注释):
243
- // auth.json → auth.json / auth.json.bak-* / auth.json.tmp
244
- // webdav-backup.json → 同上
245
- // 早期实现只做 basename === "auth.json" 精确匹配,导致 auth.json.bak-*
246
- // 这类副本绕过脱敏,把明文密钥随备份上传到云端。
247
241
  if (base.startsWith("auth.json")) {
248
242
  try {
249
- const obj = JSON.parse(buf.toString("utf8"));
243
+ const obj = JSON.parse(text);
250
244
  const walkCredential = (cred) => {
251
- if (!cred || typeof cred !== "object") return redactJson(cred, { aggressiveValues: aggressive });
245
+ if (!cred || typeof cred !== "object") return redactJsonValue(cred, { aggressiveValues: aggressive });
252
246
  const out = {};
253
247
  for (const [k, val] of Object.entries(cred)) {
254
248
  if (k === "key") {
255
249
  // 保留引用形式(如 $PI_PI_KEY_DEEPSEEK)——它是可恢复的非秘密指针,
256
250
  // 抹掉反而会丢失“密钥放在哪”的信息;真正的明文才需要处理
257
- out[k] = isRef(val) ? val : val === "" ? "" : "__REDACTED__";
251
+ out[k] = isSecretRef(val) ? val : val === "" ? "" : "__REDACTED__";
258
252
  continue;
259
253
  }
260
- out[k] = redactJson(val, { aggressiveValues: aggressive });
254
+ out[k] = redactJsonValue(val, { aggressiveValues: aggressive });
261
255
  }
262
256
  return out;
263
257
  };
264
-
265
258
  const result = {};
266
259
  for (const [provider, cred] of Object.entries(obj)) {
267
260
  result[provider] = walkCredential(cred);
268
261
  }
269
- return Buffer.from(JSON.stringify(result, null, 2), "utf8");
262
+ return JSON.stringify(result, null, 2);
270
263
  } catch {
271
- // 解析不了就不能冒险上传原文
272
- return Buffer.from("__REDACTED_UNPARSEABLE_AUTH_JSON__", "utf8");
264
+ return "__REDACTED_UNPARSEABLE_AUTH_JSON__";
273
265
  }
274
266
  }
275
267
 
276
268
  if (base.startsWith("webdav-backup.json")) {
277
269
  try {
278
- const obj = JSON.parse(buf.toString("utf8"));
279
- const cleaned = redactJson(obj, { aggressiveValues: false });
270
+ const obj = JSON.parse(text);
271
+ const cleaned = redactJsonValue(obj, { aggressiveValues: false });
280
272
  // 与 auth.json 同一约定:密钥引用保留(指针非秘密),
281
273
  // 明文由字段名规则抹掉,这里只需把被误抹的引用还原
282
- if (typeof obj.encryptKey === "string" && isRef(obj.encryptKey)) cleaned.encryptKey = obj.encryptKey;
274
+ if (typeof obj.encryptKey === "string" && isSecretRef(obj.encryptKey)) cleaned.encryptKey = obj.encryptKey;
283
275
  const pw = obj.remote && typeof obj.remote === "object" ? obj.remote.password : undefined;
284
- if (typeof pw === "string" && isRef(pw) && cleaned.remote) cleaned.remote.password = pw;
285
- return Buffer.from(JSON.stringify(cleaned, null, 2), "utf8");
276
+ if (typeof pw === "string" && isSecretRef(pw) && cleaned.remote) cleaned.remote.password = pw;
277
+ return JSON.stringify(cleaned, null, 2);
286
278
  } catch {
287
- return buf;
279
+ return text;
288
280
  }
289
281
  }
290
282
 
291
- return null;
283
+ // agentDir 之外的 JSON/配置(如 ~/.pi/settings.json、~/.config/rpiv-*/config.json):
284
+ // 用同一套字段名规则脱敏,不启用“值形状”激进模式(避免把模型名等误伤)。
285
+ // 仅对 extra/ 路径生效,避免改写 agentDir 内的普通配置格式。
286
+ const relPosix = String(relPath).replace(/\\/g, "/");
287
+ if (relPosix.startsWith("extra/") && (/\.json$/i.test(base) || /\.(ya?ml|toml|ini|conf|cfg|txt)$/i.test(base) || base === ".env" || base.startsWith(".env."))) {
288
+ if (/\.json$/i.test(base)) {
289
+ try {
290
+ return JSON.stringify(redactJsonValue(JSON.parse(text), { aggressiveValues: false }), null, 2);
291
+ } catch {
292
+ return text;
293
+ }
294
+ }
295
+ // 非 JSON 的键值文本:逐行做 `key=value` / `key: value` 脱敏
296
+ return text
297
+ .split(/\r?\n/)
298
+ .map((line) => {
299
+ const m = /^(\s*["']?[A-Za-z0-9_.-]*(?:key|token|secret|password|passwd|credential|auth)[A-Za-z0-9_.-]*["']?\s*[:=]\s*)(.*)$/i.exec(line);
300
+ return m && m[2].trim() && !isSecretRef(m[2].trim()) ? `${m[1]}__REDACTED__` : line;
301
+ })
302
+ .join("\n");
303
+ }
304
+
305
+ return text;
306
+ }
307
+
308
+ /**
309
+ * 脱敏敏感文件内容。
310
+ *
311
+ * @param {string} relPath
312
+ * @param {Buffer} buf
313
+ * @param {{aggressive?: boolean}} [opts]
314
+ * aggressive=true(未加密备份时使用):按字段名 + 值形状双重判断,
315
+ * 尽量不遗漏任何凭据;aggressive=false 时仍会脱敏已知字段。
316
+ * @returns {Buffer|null} null 表示无需脱敏
317
+ */
318
+ export function redactSensitive(relPath, buf, opts = {}) {
319
+ const base = String(relPath).split("/").pop() ?? "";
320
+ const relPosix = String(relPath).replace(/\\/g, "/");
321
+ const isCred = base.startsWith("auth.json") || base.startsWith("webdav-backup.json");
322
+ const isExtraData =
323
+ relPosix.startsWith("extra/") &&
324
+ (/\.json$/i.test(base) ||
325
+ /\.(ya?ml|toml|ini|conf|cfg|txt)$/i.test(base) ||
326
+ base === ".env" ||
327
+ base.startsWith(".env."));
328
+ if (!isCred && !isExtraData) return null;
329
+ const out = redactSensitiveContent(relPath, buf.toString("utf8"), opts);
330
+ return Buffer.from(out, "utf8");
292
331
  }
293
332
 
294
333
  /**
@@ -305,13 +344,14 @@ export function redactSensitive(relPath, buf, opts = {}) {
305
344
  * @returns {{clean: boolean, leaks: {rel: string, reason: string}[]}}
306
345
  */
307
346
  export function auditForPlaintext(files, opts = {}) {
308
- const { agentDir, config, readFile = readFileSync } = opts;
347
+ const { agentDir, config, readFile = readFileSync, extraSensitive = [] } = opts;
309
348
  const leaks = [];
310
349
 
311
350
  const SUSPICIOUS = [
312
351
  /"key"\s*:\s*"(?!\$|!|dpapi:|file:|__REDACTED)([A-Za-z0-9_\-]{16,})"/,
313
352
  /"password"\s*:\s*"(?!\$|!|dpapi:|file:|__REDACTED)([^"\s]{6,})"/,
314
353
  /"encryptKey"\s*:\s*"(?!\$|!|dpapi:|file:|__REDACTED)([^"\s]{6,})"/,
354
+ /"apiKey"\s*:\s*"(?!\$|!|dpapi:|file:|__REDACTED)([A-Za-z0-9_\-]{16,})"/,
315
355
  /sk-[A-Za-z0-9_-]{20,}/,
316
356
  /ghp_[A-Za-z0-9]{20,}/,
317
357
  /AIza[0-9A-Za-z_-]{30,}/,
@@ -320,6 +360,12 @@ export function auditForPlaintext(files, opts = {}) {
320
360
  for (const f of files) {
321
361
  const base = f.rel.split("/").pop() ?? "";
322
362
 
363
+ // 插件实现代码(node_modules)是第三方依赖,不是用户配置:
364
+ // 里面可能有大量字符串字面量(示例密钥、测试数据),逐文件审计既慢又容易误报。
365
+ // 备份它只是为了恢复后能直接用,不参与凭据审计。
366
+ const relPosix0 = String(f.rel).replace(/\\/g, "/");
367
+ if (relPosix0.startsWith("npm/node_modules/") || relPosix0.includes("/node_modules/")) continue;
368
+
323
369
  // 凭据存储文件根本不该出现在待上传列表里(排除规则应已拦住)
324
370
  if (looksCredentialFile(f.rel)) {
325
371
  const isAuditable =
@@ -340,17 +386,24 @@ export function auditForPlaintext(files, opts = {}) {
340
386
 
341
387
  let text;
342
388
  try {
343
- text = readFile(join(agentDir, f.rel), "utf8");
389
+ // extraScope 的文件在 agentDir 之外,必须用绝对路径读(否则 join 会读错位置)
390
+ const abs = String(f.rel).replace(/\\/g, "/").startsWith("extra/") ? f.abs : join(agentDir, f.rel);
391
+ text = readFile(abs, "utf8");
344
392
  } catch {
345
393
  continue;
346
394
  }
347
395
  if (typeof text !== "string") continue;
348
396
 
349
397
  // 对会被脱敏的文件,审计脱敏后的内容(即真正上传的字节)
350
- const redacted = redactSensitive(f.rel, Buffer.from(text, "utf8"), {
398
+ const redactOpts2 = {
351
399
  aggressive: config ? !config.encrypt : true,
352
- });
400
+ };
401
+ const redacted = redactSensitive(f.rel, Buffer.from(text, "utf8"), redactOpts2);
353
402
  if (redacted) text = redacted.toString("utf8");
403
+ // 已经脱敏过一次的 extra 数据,再做值形状命中(如 ~/.pi/settings.json 里的模型 apiKey)
404
+ if (String(f.rel).replace(/\\/g, "/").startsWith("extra/") && isExtraSensitive(f.rel, extraSensitive)) {
405
+ text = redactSensitiveContent(f.rel, text, redactOpts2);
406
+ }
354
407
 
355
408
  for (const re of SUSPICIOUS) {
356
409
  const m = re.exec(text);
@@ -398,7 +451,10 @@ export async function runBackup(config, opts) {
398
451
 
399
452
  const t0 = Date.now();
400
453
  log(`扫描文件… (范围: ${config.scope.join(", ")})`);
401
- const { files, skipped, totalSize } = collectFiles({ agentDir, scope: config.scope, exclude });
454
+ const extraScope = Array.isArray(config.extraScope) ? config.extraScope : [];
455
+ const { files, skipped, totalSize } = collectFiles({ agentDir, scope: config.scope, exclude, extraScope });
456
+ // agentDir 之外的路径也可能含凭据(~/.pi/settings.json 等),推导出需脱敏的归档内路径
457
+ const extraSensitive = deriveSensitivePaths(extraScope);
402
458
 
403
459
  if (files.length === 0) {
404
460
  return { ok: false, error: "没有需要备份的文件(检查 scope 与 exclude 配置)", files: 0 };
@@ -407,7 +463,7 @@ export async function runBackup(config, opts) {
407
463
 
408
464
  // 未加密上传时,先审计是否还有明文凭据漏网(脱敏白名单之外的第二道防线)
409
465
  if (!config.encrypt) {
410
- const audit = auditForPlaintext(files, { agentDir, config });
466
+ const audit = auditForPlaintext(files, { agentDir, config, extraSensitive });
411
467
  if (!audit.clean) {
412
468
  const detail = audit.leaks.map((l) => ` ${l.rel} — ${l.reason}`).join("\n");
413
469
  const msg =
@@ -432,7 +488,7 @@ export async function runBackup(config, opts) {
432
488
  }
433
489
 
434
490
  if (opts.dryRun) {
435
- const audit = auditForPlaintext(files, { agentDir, config });
491
+ const audit = auditForPlaintext(files, { agentDir, config, extraSensitive });
436
492
  return {
437
493
  ok: true,
438
494
  dryRun: true,
@@ -448,9 +504,9 @@ export async function runBackup(config, opts) {
448
504
  const baseDir = config.remoteDir;
449
505
 
450
506
  if (snapshot) {
451
- return await runSnapshot({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile });
507
+ return await runSnapshot({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile, extraScope, extraSensitive });
452
508
  }
453
- return await runArchive({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile });
509
+ return await runArchive({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile, extraScope, extraSensitive });
454
510
  }
455
511
 
456
512
  /** 脱敏时的上下文:未加密上传时必须更激进 */
@@ -458,8 +514,15 @@ function redactOpts(config) {
458
514
  return { aggressive: !config.encrypt };
459
515
  }
460
516
 
517
+ /** 归档内相对路径是否命中 extraScope 推导出的敏感路径(目录前缀或精确文件) */
518
+ function isExtraSensitive(rel, extraSensitive) {
519
+ if (!extraSensitive || extraSensitive.length === 0) return false;
520
+ const r = String(rel).replace(/\\/g, "/");
521
+ return extraSensitive.some((p) => (p.endsWith("/") ? r.startsWith(p) : r === p));
522
+ }
523
+
461
524
  /** 归档模式:一个 tar.gz(可选加密)整体上传 */
462
- async function runArchive({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile }) {
525
+ async function runArchive({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile, extraScope = [], extraSensitive = [] }) {
463
526
  const tmpName = `pi-backup-${randomBytes(6).toString("hex")}`;
464
527
  const tmpDir = join(tmpdir(), tmpName);
465
528
  mkdirSync(tmpDir, { recursive: true });
@@ -470,7 +533,9 @@ async function runArchive({ client, config, files, totalSize, skipped, baseDir,
470
533
  createdAt: new Date().toISOString(),
471
534
  agentDir,
472
535
  scope: config.scope,
536
+ extraScope,
473
537
  includeSessions: !!config.includeSessions,
538
+ includePlugins: !!config.includePlugins,
474
539
  encrypted: !!config.encrypt,
475
540
  totalSize,
476
541
  fileCount: files.length,
@@ -485,9 +550,8 @@ async function runArchive({ client, config, files, totalSize, skipped, baseDir,
485
550
  let redactedCount = 0;
486
551
 
487
552
  for (const f of files) {
488
- // 配置文件本身脱敏后写入;其余正常写入
489
- const isSelfConfig = f.rel === "webdav-backup.json" || f.rel.endsWith("/webdav-backup.json");
490
- if (isRedactable(f.rel)) {
553
+ // 配置文件本身与 extraScope 下的数据类文件脱敏后写入;其余正常写入
554
+ if (isRedactable(f.rel) || isExtraSensitive(f.rel, extraSensitive)) {
491
555
  try {
492
556
  const raw = readFileSync(f.abs);
493
557
  const redacted = redactSensitive(f.rel, raw, redactOpts(config));
@@ -504,6 +568,11 @@ async function runArchive({ client, config, files, totalSize, skipped, baseDir,
504
568
  }
505
569
  // 附带 manifest 便于恢复时对照
506
570
  await tar.addBuffer(Buffer.from(JSON.stringify(manifest, null, 2), "utf8"), ".pi-backup-manifest.json");
571
+ // 额外数据(extra/)的可移植映射:记录源锚点,恢复时可在不同用户名/盘符的机器上重算目标路径
572
+ const extraMap = buildExtraMap(files);
573
+ if (extraMap.entries.length > 0) {
574
+ await tar.addBuffer(Buffer.from(JSON.stringify(extraMap, null, 2), "utf8"), EXTRA_MANIFEST_NAME);
575
+ }
507
576
  await tar.finish();
508
577
 
509
578
  log(`压缩中…${redactedCount > 0 ? `(已脱敏 ${redactedCount} 个敏感文件)` : ""}`);
@@ -576,7 +645,7 @@ async function runArchive({ client, config, files, totalSize, skipped, baseDir,
576
645
  }
577
646
 
578
647
  /** 快照模式:逐文件上传 + manifest 增量 */
579
- async function runSnapshot({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile }) {
648
+ async function runSnapshot({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile, extraScope = [], extraSensitive = [] }) {
580
649
  const dir = `${baseDir}/${config.remoteName}`;
581
650
  await client.mkdirp(dir);
582
651
  log("读取远端清单…");
@@ -601,9 +670,10 @@ async function runSnapshot({ client, config, files, totalSize, skipped, baseDir,
601
670
  const prevEntry = prev[f.rel];
602
671
  const isSelf = f.rel === "webdav-backup.json" || f.rel.endsWith("/webdav-backup.json");
603
672
  const isAuth = f.rel === "auth.json" || f.rel.startsWith("auth.json") || f.rel.endsWith("/auth.json");
673
+ const isExtraData = isExtraSensitive(f.rel, extraSensitive);
604
674
 
605
- // 增量判断:大小与 mtime 都一致则跳过(auth.json / 配置总是重新上传,因为会脱敏)
606
- if (prevEntry && prevEntry.size === f.size && prevEntry.mtimeMs === f.mtimeMs && !isAuth && !isSelf) {
675
+ // 增量判断:大小与 mtime 都一致则跳过(auth.json / 配置 / extra 数据总是重新上传,因为会脱敏)
676
+ if (prevEntry && prevEntry.size === f.size && prevEntry.mtimeMs === f.mtimeMs && !isAuth && !isSelf && !isExtraData) {
607
677
  skippedSame.push(f.rel);
608
678
  manifestFiles.push(prevEntry);
609
679
  continue;
@@ -611,7 +681,7 @@ async function runSnapshot({ client, config, files, totalSize, skipped, baseDir,
611
681
 
612
682
  let payload = readFileSync(f.abs);
613
683
  let stored = f;
614
- if (isRedactable(f.rel)) {
684
+ if (isRedactable(f.rel) || isExtraData) {
615
685
  const redacted = redactSensitive(f.rel, payload, redactOpts(config));
616
686
  if (redacted) {
617
687
  payload = redacted;
@@ -635,7 +705,9 @@ async function runSnapshot({ client, config, files, totalSize, skipped, baseDir,
635
705
  updatedAt: new Date().toISOString(),
636
706
  agentDir,
637
707
  scope: config.scope,
708
+ extraScope,
638
709
  includeSessions: !!config.includeSessions,
710
+ includePlugins: !!config.includePlugins,
639
711
  totalSize,
640
712
  fileCount: files.length,
641
713
  files: manifestFiles,
@@ -644,6 +716,14 @@ async function runSnapshot({ client, config, files, totalSize, skipped, baseDir,
644
716
  contentType: "application/json",
645
717
  });
646
718
 
719
+ // 额外数据(extra/)的可移植映射
720
+ const extraMap = buildExtraMap(files);
721
+ if (extraMap.entries.length > 0) {
722
+ await client.put(`${dir}/${EXTRA_MANIFEST_NAME}`, Buffer.from(JSON.stringify(extraMap, null, 2), "utf8"), {
723
+ contentType: "application/json",
724
+ });
725
+ }
726
+
647
727
  const elapsedMs = Date.now() - t0;
648
728
  writeState(stateFile, {
649
729
  lastBackupAt: new Date().toISOString(),