From 7000e724c604314afa6ddcd976a774a0cfa461ab Mon Sep 17 00:00:00 2001 From: BlakrPander Date: Thu, 8 Oct 2026 16:21:52 +0800 Subject: [PATCH 1/4] fix(sanitize): redact a token used as the remote URL's username MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sanitizeRemote` only masked userinfo when a password was present, so the `https://@host/…` form — the most common non-interactive PAT usage — was rendered verbatim by `/sync status` and handed to the model by the `sync_status` tool. Because this plugin mirrors session logs, a token seen there could then be committed into the synced repository. A password-less username now counts as a credential when it carries a token shape: a known prefix, >=16 hex digits, or a >=20-character separator-free multi-class string. Conventional logins (git, oauth2, x-access-token, gitlab-ci-token) and ordinary names stay visible, so the documented "username kept" behaviour for `user:pass@` and for scp syntax is unchanged. Also here: - `TOKEN_PATTERN` learns the families it had no coverage for: `github_pat_` (the current default PAT form), the full `gh[pousr]_` set, `glpat-`, `gldt-`, `npm_`, `pypi-`, `AIza`, `ya29.`, `SG.`, `dop_v1_`, `shpat_`, and three-segment JWTs. - The unparseable-remote branch returns `redactText(trimmed)` instead of echoing the input, which is what this module's header already promised. - The early return after masking `user:pass@` skipped the `?access_token=` redaction below it; both redactions now always run. Verified: 6 of the 7 new sanitize tests fail against the previous code. --- lib/sanitize.mjs | 43 ++++++++++++++++++++++++++++++++++++++---- test/sanitize.test.mjs | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/lib/sanitize.mjs b/lib/sanitize.mjs index 4bdacfb..49fd33f 100644 --- a/lib/sanitize.mjs +++ b/lib/sanitize.mjs @@ -7,8 +7,13 @@ /** URL 中视为凭据的查询键(值一律整体打码)。 */ const CREDENTIAL_QUERY_KEYS = /^(?:access_?token|token|key|secret|password|passwd|auth|credential|code|signature|x-amz-|sig)$/iu -/** 常见令牌形态(前缀 + 足够长的 secret)。 */ -const TOKEN_PATTERN = /\b(?:sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{16,}|gho_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,}|Bearer\s+[A-Za-z0-9._~+/=-]{8,}|authorization:\s*[A-Za-z0-9._~+/=-]{8,})/giu +/** + * 常见令牌形态(前缀 + 足够长的 secret)。 + * `gh[pousr]_` 覆盖 GitHub 的 ghp/gho/ghu/ghs/ghr 全家族;`github_pat_` 单列 + * (fine-grained PAT 是现在的默认形态,曾整体漏网);其余为各家用得最广的 + * 前缀形态。 + */ +const TOKEN_PATTERN = /\b(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|gldt-[A-Za-z0-9_-]{16,}|npm_[A-Za-z0-9]{30,}|pypi-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{30,}|ya29\.[A-Za-z0-9_-]{20,}|SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}|dop_v1_[A-Fa-f0-9]{40,}|shpat_[A-Fa-f0-9]{32,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}|Bearer\s+[A-Za-z0-9._~+/=-]{8,}|authorization:\s*[A-Za-z0-9._~+/=-]{8,})/giu /** key=value 形态的凭据(值整体打码)。 */ const CREDENTIAL_ASSIGNMENT = /\b((?:api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|password|passwd|client[_-]?secret|private[_-]?key)\s*=\s*)([^\s&;,]+)/giu @@ -16,9 +21,36 @@ const CREDENTIAL_ASSIGNMENT = /\b((?:api[_-]?key|access[_-]?token|auth[_-]?token /** URL userinfo(user:password@)——git stderr 可能回显远端地址。 */ const URL_USERINFO = /\/\/([^/\s:@]+):([^/\s@]+)@/gu +/** + * 无密码时,URL 里的用户名本身是否就是凭据。 + * `https://@host/…` 是 PAT 最常见的免交互写法:此时用户名字段装的是 + * 密钥而不是身份,`user:pass@` 的「用户名不是秘密」前提在这里不成立。 + * 判定依据(任一命中即视为凭据): + * 1. 已知令牌形态——`redactText` 认得出(github_pat_/ghp_/glpat-… 等前缀); + * 2. 纯十六进制且够长(≥16 位,覆盖无前缀的随机 token); + * 3. 够长(≥20)且不含分隔符的多字符类串(大小写/数字至少两类)。 + * 约定俗成的登录名(git/oauth2/x-access-token/gitlab-ci-token)与普通短人名 + * 不满足任何一条,保持可见。 + * @param {string} username - URL 的用户名部分。 + * @returns {boolean} 是否按凭据处理。 + */ +function usernameIsCredential(username) { + if (username.length === 0) return false + if (redactText(username) !== username) return true + if (/^[A-Fa-f0-9]{16,}$/u.test(username)) return true + if (username.length >= 20 && !/[-_.]/u.test(username)) { + const classes = [/[a-z]/u, /[A-Z]/u, /[0-9]/u].filter(pattern => pattern.test(username)).length + if (classes >= 2) return true + } + return false +} + /** * 脱敏 remote URL:用户信息中的密码打码,凭据查询键的值打码。 * scp 语法(user@host:path)不含密码,原样保留(用户名不是秘密)。 + * 例外:带用户名、不带密码的 http(s) URL 走 usernameIsCredential——那种形状 + * 的用户名常常就是令牌本身,原样回显会把它送进 `/sync status`、`sync_status` + * 的工具结果和会话日志(而本插件随后还会把会话日志同步出去)。 * @param {string} remote - git remote 地址。 * @returns {string} 脱敏后的地址。 */ @@ -29,14 +61,17 @@ export function sanitizeRemote(remote) { const url = new URL(trimmed) if (url.username !== '' && url.password !== '') { url.password = '***' - return url.toString() + } else if (url.username !== '') { + if (usernameIsCredential(url.username)) url.username = '***' } for (const key of [...url.searchParams.keys()]) { if (CREDENTIAL_QUERY_KEYS.test(key)) url.searchParams.set(key, '***') } return url.toString() } catch { - return remote + // 解析失败(scp 语法、畸形 URL)走通用脱敏。模块契约是「输入不合法时返回 + // 保守的脱敏文本」:原样回显会让畸形 URL 里的令牌直接落到日志与模型。 + return redactText(trimmed) } } diff --git a/test/sanitize.test.mjs b/test/sanitize.test.mjs index 38a40a9..1ef3d96 100644 --- a/test/sanitize.test.mjs +++ b/test/sanitize.test.mjs @@ -74,3 +74,40 @@ test('sanitize output embeds no remote credentials when wrapped twice', () => { const twice = redactText(`remote ${once}`) assert.ok(!twice.includes('p@host')) }) + +test('password-less token username is redacted (PAT-as-username)', () => { + const classic = sanitizeRemote('https://ghp_AAAABBBBCCCCDDDDEEEEFFFF1234@github.com/you/repo.git') + assert.ok(!classic.includes('ghp_AAAABBBBCCCCDDDDEEEEFFFF1234'), 'classic PAT') + assert.ok(classic.includes('***@github.com')) + const fine = sanitizeRemote('https://github_pat_11ABCDEFG0123456789_abcdefghijklmnopqrstuvwxyz0123456789ABCDEF@github.com/you/repo.git') + assert.ok(!fine.includes('github_pat_11ABCDEFG'), 'fine-grained PAT') + const gitlab = sanitizeRemote('https://glpat-ABCDEFGHIJKLMNOPQRST@gitlab.com/you/repo.git') + assert.ok(!gitlab.includes('glpat-ABCDEFGHIJKLMNOPQRST'), 'gitlab PAT') + const hex = sanitizeRemote('https://a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0@github.com/you/repo.git') + assert.ok(!hex.includes('a1b2c3d4e5f6'), 'prefix-less random token') +}) + +test('benign password-less usernames stay visible', () => { + assert.equal(sanitizeRemote('https://alice@github.com/you/repo.git'), 'https://alice@github.com/you/repo.git') + assert.equal(sanitizeRemote('https://x-access-token@github.com/you/repo.git'), 'https://x-access-token@github.com/you/repo.git') + assert.equal(sanitizeRemote('https://gitlab-ci-token@gitlab.com/you/repo.git'), 'https://gitlab-ci-token@gitlab.com/you/repo.git') +}) + +test('userinfo and query credentials are both redacted on the same URL', () => { + const out = sanitizeRemote('https://u:p@host/x.git?access_token=secret123') + assert.ok(!out.includes('p@host'), 'password redacted') + assert.ok(!out.includes('secret123'), 'query token redacted in the same pass') +}) + +test('modern token families are redacted in free text', () => { + const text = 'a github_pat_11ABCDEFG0123456789_abcdefghijklmnopqrstuvwxyz0123456789ABCDEF b glpat-ABCDEFGHIJKLMNOPQRST c npm_0123456789012345678901234567890 d ghs_0123456789abcdef0123 e' + const out = redactText(text) + for (const secret of ['github_pat_11ABCDEFG', 'glpat-ABCDEFGHIJKLMNOPQRST', 'npm_0123456789012345678901234567890', 'ghs_0123456789abcdef0123']) { + assert.ok(!out.includes(secret), `${secret} must be redacted`) + } +}) + +test('unparseable remote with an embedded token is redacted, not echoed', () => { + const out = sanitizeRemote('https://ghp_AAAABBBBCCCCDDDDEEEEFFFF1234@host:path/repo.git') + assert.ok(!out.includes('ghp_AAAABBBBCCCCDDDDEEEEFFFF1234')) +}) From 1ed92a7f1f98cee995f59d43c4136cb7d65483fa Mon Sep 17 00:00:00 2001 From: BlakrPander Date: Thu, 8 Oct 2026 16:21:55 +0800 Subject: [PATCH 2/4] fix(engine): resolve delete/modify conflicts keep-both, never abort the pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When one side deletes a session file and the other modifies it, git registers only the surviving stages — a remote-side deletion leaves no stage 3, a local-side deletion leaves no stage 2 — so the unconditional `checkoutStage('theirs'|'ours')` failed with `does not have their/our version`, `abortMerge` rolled the whole pull back, and sync stalled until the user resolved it by hand. Both directions were affected. `#resolveConflicts` now reads each side's presence from the stage blobs first and treats a missing stage as "that side deleted the file": the deletion is kept, the surviving side is preserved (remote bytes still land in a fork file when there are any), and the divergence is still reported loudly through `summary.diverged`. Fork-ability is keyed on the remote side actually having bytes, so a deletion can no longer produce a bogus empty fork. The same case in the `encrypted` backend's in-memory `mergeTrees` inserted `undefined` into the merged tree: `THEIRS_ONLY` with no remote bytes did `merged.set(rel, undefined)`, and `forkTheirs` with no remote bytes inserted an `undefined`-valued fork key. `writeTree` writes every entry, so both became a `fs.writeFile(target, undefined)` TypeError. An adopted deletion now leaves the merged tree and an un-forkable deletion is counted as `diverged` with no fork entry. ARCHITECTURE.md records the deletion semantics in the merge table, and the five READMEs note that a deletion is a side too (the conflict bullet claimed the remote version is always preserved as fork files, which a deletion — having no bytes — cannot be). Verified: both new end-to-end cases and the new `mergeTrees` case fail against the previous code. --- ARCHITECTURE.md | 2 ++ README-es.md | 2 +- README-hi.md | 2 +- README-pt.md | 2 +- README-zh.md | 2 +- README.md | 2 +- lib/encrypted.mjs | 23 ++++++++++++------ lib/engine.mjs | 28 +++++++++++++++++----- test/encrypted.test.mjs | 26 ++++++++++++++++++++ test/engine.test.mjs | 53 +++++++++++++++++++++++++++++++++++++++++ 10 files changed, 124 insertions(+), 18 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ab573c8..ca5c51b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -52,6 +52,8 @@ The three-way merge compares `base` (merge base), `ours` (local), and `theirs` ( | `append-both` | both appended past base | keep ours + fork file for theirs | | `diverged` | neither is a superset | keep ours + fork file for theirs | +Deletions are keep-both too, but a deleted side has no bytes to fork, so the surviving side is preserved on its own terms: a **remote deletion against a local edit** keeps the local file and produces no fork (there is nothing to fork), while a **local deletion against a remote edit** keeps the deletion and stores the remote bytes as a fork file. Both are reported as `diverged`. This is also why the two backends must test for presence before acting: at the git layer a delete/modify conflict registers only the surviving stage (no stage 3 when the remote deleted, no stage 2 when the local side did), and `checkout --ours/--theirs` on the missing side fails with `does not have our/their version` — so `#resolveConflicts` reads the stage blobs first and `mergeTrees` checks the map value, neither of which may treat "no bytes" as "empty bytes". + Fork files are named `.remote-fork--` (`forkFileName` rejects any non-14-digit stamp, so both backends must strip the ISO separators `-`, `:`, and `T`); mirror and cleanup **never delete or overwrite** files matching that pattern, so both lineages always survive. When a conflicting session is live and has no open turn, the plugin also forks the session at the harness level and injects a persistent `user/message` notice into the child. The session-level mapping follows the host's real session-store layout `$DSH_HOME/sessions///` — `projectKey` is derived from the session `cwd` (`_no-cwd` when absent) and `sessionId` is the host's `encodeSegment` form, which `lib/paths.mjs` decodes back (`~XXXX` escapes). Fork paths that do not fit that shape (or the legacy flat `//` form) are reported with a warning and never silently dropped; the fork files themselves are always preserved. ## Confirmation gate diff --git a/README-es.md b/README-es.md index e1831ef..ccf612d 100644 --- a/README-es.md +++ b/README-es.md @@ -49,7 +49,7 @@ Este plugin forma parte de la [familia de plugins DSH](https://github.com/PerryL - **Comando `/sync`** — `status` (rama, remoto saneado, delante/detrás, archivos sucios, forks), `diff`, `log`, `pull`, `push`, `help`. - **Herramientas `sync_status` / `sync_pull` / `sync_push`** — la misma superficie para el modelo, dentro de un turno. -- **Resolución de conflictos append-only** — los registros de sesión son append-only; ante cualquier divergencia el plugin conserva **ambos** lados (la versión local se conserva, la remota se preserva como archivos fork) y nunca sobrescribe en silencio. Las sesiones divergentes también pueden bifurcarse a nivel de sesión. +- **Resolución de conflictos append-only** — los registros de sesión son append-only; ante cualquier divergencia el plugin conserva **ambos** lados (la versión local se conserva, la remota se preserva como archivos fork) y nunca sobrescribe en silencio. Una eliminación también cuenta como lado: un conflicto borrado/edición conserva el borrado y preserva los bytes del lado superviviente como archivo fork. Las sesiones divergentes también pueden bifurcarse a nivel de sesión. - **Modos automáticos** — pull al iniciar, push tras cada turno cerrado y pull periódico, todos configurables y reversibles. - **Escrituras con confirmación** — `pull`/`push` preguntan primero (mediante `userQuestions` o `approval`); las superficies de solo lectura nunca preguntan; sin respondedor la operación falla cerrada. diff --git a/README-hi.md b/README-hi.md index 40f1449..6e0f3c0 100644 --- a/README-hi.md +++ b/README-hi.md @@ -49,7 +49,7 @@ - **`/sync` कमांड** — `status` (ब्रांच, सैनिटाइज़ किया गया रिमोट, आगे/पीछे, गंदी फ़ाइलें, फ़ोर्क), `diff`, `log`, `pull`, `push`, `help`। - **`sync_status` / `sync_pull` / `sync_push` टूल** — मॉडल के लिए वही सतह, एक टर्न के अंदर। -- **Append-only टकराव समाधान** — सत्र लॉग append-only होते हैं; किसी भी विचलन पर प्लगइन **दोनों** पक्ष रखता है (स्थानीय संस्करण रखा जाता है, रिमोट संस्करण फ़ोर्क फ़ाइलों के रूप में संरक्षित रहता है) और कभी चुपचाप ओवरराइट नहीं करता। विचलित सत्र सत्र-स्तर पर भी फ़ोर्क हो सकते हैं। +- **Append-only टकराव समाधान** — सत्र लॉग append-only होते हैं; किसी भी विचलन पर प्लगइन **दोनों** पक्ष रखता है (स्थानीय संस्करण रखा जाता है, रिमोट संस्करण फ़ोर्क फ़ाइलों के रूप में संरक्षित रहता है) और कभी चुपचाप ओवरराइट नहीं करता। हटाना भी एक पक्ष है: delete/edit टकराव में हटाना बना रहता है और बचे हुए पक्ष के बाइट्स फ़ोर्क फ़ाइल के रूप में सुरक्षित रहते हैं। विचलित सत्र सत्र-स्तर पर भी फ़ोर्क हो सकते हैं। - **स्वचालित मोड** — स्टार्ट पर pull, हर बंद टर्न के बाद push, और आवधिक pull, सभी कॉन्फ़िगर करने योग्य और प्रतिवर्ती। - **पुष्टि-गेटेड लेखन** — `pull`/`push` पहले पूछते हैं (`userQuestions` या `approval` के माध्यम से); केवल-पठन सतहें कभी नहीं पूछतीं; बिना उत्तरदाता के ऑपरेशन बंद-असफल होता है। diff --git a/README-pt.md b/README-pt.md index 463e194..64e8656 100644 --- a/README-pt.md +++ b/README-pt.md @@ -49,7 +49,7 @@ O `dsh-session-sync` espelha o seu armazenamento de sessões do DSH em uma árvo - **Comando `/sync`** — `status` (branch, remoto higienizado, à frente/atrás, arquivos sujos, forks), `diff`, `log`, `pull`, `push`, `help`. - **Ferramentas `sync_status` / `sync_pull` / `sync_push`** — a mesma superfície para o modelo, dentro de um turno. -- **Resolução de conflitos append-only** — os registros de sessão são append-only; em qualquer divergência o plugin mantém **os dois** lados (a versão local é mantida, a remota é preservada como arquivos fork) e nunca sobrescreve em silêncio. Sessões divergentes também podem bifurcar no nível da sessão. +- **Resolução de conflitos append-only** — os registros de sessão são append-only; em qualquer divergência o plugin mantém **os dois** lados (a versão local é mantida, a remota é preservada como arquivos fork) e nunca sobrescreve em silêncio. Uma exclusão também conta como lado: um conflito exclusão/edição mantém a exclusão e preserva os bytes do lado sobrevivente como arquivo fork. Sessões divergentes também podem bifurcar no nível da sessão. - **Modos automáticos** — pull ao iniciar, push após cada turno fechado e pull periódico, todos configuráveis e reversíveis. - **Escritas com confirmação** — `pull`/`push` perguntam primeiro (via `userQuestions` ou `approval`); superfícies somente leitura nunca perguntam; sem respondedor a operação falha fechada. diff --git a/README-zh.md b/README-zh.md index 9da386b..6a7611a 100644 --- a/README-zh.md +++ b/README-zh.md @@ -51,7 +51,7 @@ - **`/sync` 命令** —— `status`(分支、脱敏后的远端、领先/落后、未提交文件、fork 文件)、`diff`、`log`、`pull`、`push`、`help`。 - **`sync_status` / `sync_pull` / `sync_push` 工具** —— 在轮次内为模型提供同样的能力。 -- **append-only 冲突裁决** —— 会话日志是 append-only;任何分歧都会**两边都保留**(本地版本保留、远端版本留存为 fork 文件),绝不静默覆盖。分歧的会话还可在会话层面 fork。 +- **append-only 冲突裁决** —— 会话日志是 append-only;任何分歧都会**两边都保留**(本地版本保留、远端版本留存为 fork 文件),绝不静默覆盖。删除也算一方:删除/修改冲突保留删除,并把存活一侧的字节留存为 fork 文件。分歧的会话还可在会话层面 fork。 - **自动模式** —— 启动时拉取、每个关闭轮次后推送、周期拉取,全部可配置且可逆。 - **写操作走确认门** —— `pull`/`push` 会先询问(经 `userQuestions` 或 `approval`);只读界面从不询问;没有回答者时操作失败关闭。 diff --git a/README.md b/README.md index 8cafe4a..d55ab6f 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ - **`/sync` command** — `status` (branch, sanitized remote, ahead/behind, dirty files, forks), `diff`, `log`, `pull`, `push`, `help`. - **`sync_status` / `sync_pull` / `sync_push` tools** — the same surface for the model, inside a turn. -- **Append-only conflict resolution** — session logs are append-only; on any divergence the plugin keeps **both** sides (local version kept, remote version preserved as fork files) and never silently overwrites. Diverged sessions can also fork at the session level. +- **Append-only conflict resolution** — session logs are append-only; on any divergence the plugin keeps **both** sides (local version kept, remote version preserved as fork files) and never silently overwrites. A deletion counts as a side too: a delete/edit conflict keeps the deletion and preserves the surviving side's bytes as a fork file. Diverged sessions can also fork at the session level. - **Auto modes** — pull on start, push after every closed turn, and periodic pull, all configurable and all reversible. - **Confirmation-gated writes** — `pull`/`push` ask first (through `userQuestions` or `approval`); read-only surfaces never ask; with no answerer the operation fails closed. diff --git a/lib/encrypted.mjs b/lib/encrypted.mjs index bbc03ee..c0cd84a 100644 --- a/lib/encrypted.mjs +++ b/lib/encrypted.mjs @@ -155,16 +155,25 @@ export function mergeTrees(base, ours, theirs, stamp, deviceShort) { let diverged = 0 const keys = new Set([...base.keys(), ...ours.keys(), ...theirs.keys()]) for (const rel of keys) { - const verdict = classifyPath(base.get(rel), ours.get(rel), theirs.get(rel)) + const remote = theirs.get(rel) + const verdict = classifyPath(base.get(rel), ours.get(rel), remote) + // 远端删除(theirs 无字节)是这棵树里唯一「没有远端内容可留存」的分歧: + // 采纳删除=从合并树移除,其余=保留 ours 并计分歧。绝不把 undefined 塞进 + // merged —— writeTree 对每个条目 fs.writeFile,undefined 会直接抛错。 if (verdict.kind === MERGE_KINDS.THEIRS_ONLY) { - merged.set(rel, theirs.get(rel)) + if (remote === undefined) merged.delete(rel) + else merged.set(rel, remote) adopted += 1 } else if (verdict.forkTheirs) { - const forkRel = path.posix.join(path.posix.dirname(rel), forkFileName(path.posix.basename(rel), stamp, deviceShort)) - merged.set(forkRel, theirs.get(rel)) - forks.push(forkRel) - if (verdict.kind === MERGE_KINDS.APPEND_BOTH) appended += 1 - else diverged += 1 + if (remote === undefined) { + diverged += 1 + } else { + const forkRel = path.posix.join(path.posix.dirname(rel), forkFileName(path.posix.basename(rel), stamp, deviceShort)) + merged.set(forkRel, remote) + forks.push(forkRel) + if (verdict.kind === MERGE_KINDS.APPEND_BOTH) appended += 1 + else diverged += 1 + } } // identical / ours-only:保留 ours(已在 merged 中)。 } diff --git a/lib/engine.mjs b/lib/engine.mjs index 010a8de..53f6549 100644 --- a/lib/engine.mjs +++ b/lib/engine.mjs @@ -236,6 +236,10 @@ export class SyncEngine { /** * 冲突解决:逐路径分类 → ours/theirs 检出 → 远端侧留存为 fork 文件 → * 全量暂存(提交由调用方落合并提交)。失败时 abortMerge 恢复。 + * + * 删除/修改冲突(一侧删除、另一侧修改)同样走 keep-both:git 只为存在的一侧 + * 登记 stage,缺失的一侧按「该侧删除了该文件」解释——删除意图保留,另一侧 + * 按既有规则存续(远端内容仍以 fork 文件留存)。绝不因缺 stage 而中止整次 pull。 * @returns {Promise<{adopted: number, appended: number, diverged: number, forks: string[]}>} */ async #resolveConflicts() { @@ -251,6 +255,8 @@ export class SyncEngine { entries.push({ path: conflict.path, base, ours, theirs }) } const { resolutions, summary } = planMerge(entries) + /** 路径 → ours/theirs 字节;某侧缺失即该侧删除了该文件(git 不登记其 stage)。 */ + const sides = new Map(entries.map(entry => [entry.path, entry])) const forks = [] const now = new Date() // 严格 14 位 UTC 戳(yyyyMMddHHmmss):与 lib/encrypted.mjs 同口径剔除 ISO @@ -258,23 +264,33 @@ export class SyncEngine { // forkFileName 的 /^\d{14}$/ 失配并静默回退到纪元戳。 const stamp = now.toISOString().replace(/[-:T]/gu, '').slice(0, 14) for (const resolution of resolutions) { + const side = sides.get(resolution.path) + const hasOurs = side?.ours !== undefined + const hasTheirs = side?.theirs !== undefined + const absolute = path.join(this.#deps.repoDir, ...resolution.path.split('/')) + // `checkout --ours/--theirs` 对缺失的 stage 会失败(`does not have + // our/their version`),因此每一侧都先看它在冲突里是否存在。 if (resolution.adoptTheirs) { - await this.#deps.git.checkoutStage('theirs', resolution.path) + if (hasTheirs) await this.#deps.git.checkoutStage('theirs', resolution.path) + else await fs.rm(absolute, { force: true }) continue } - if (resolution.forkTheirs) { - // 先取 theirs 工作树字节 → 写 fork 文件 → 再检出 ours 回原路径。 + if (resolution.forkTheirs && hasTheirs) { + // 先取 theirs 工作树字节 → 写 fork 文件 → 再恢复 ours 侧。 await this.#deps.git.checkoutStage('theirs', resolution.path) - const absolute = path.join(this.#deps.repoDir, ...resolution.path.split('/')) const content = await fs.readFile(absolute) const forkName = forkFileName(path.posix.basename(resolution.path), stamp, this.deviceShort) await fs.writeFile(path.join(path.dirname(absolute), forkName), content) - await this.#deps.git.checkoutStage('ours', resolution.path) + // ours 缺失=本地已删除:保留删除,远端内容只以 fork 文件存续。 + if (hasOurs) await this.#deps.git.checkoutStage('ours', resolution.path) + else await fs.rm(absolute, { force: true }) const forkRel = path.posix.join(path.posix.dirname(resolution.path), forkName) forks.push(forkRel) continue } - await this.#deps.git.checkoutStage('ours', resolution.path) + // 远端已删除(theirs 缺失):保留本地版本,分歧照常计入 summary.diverged。 + if (hasOurs) await this.#deps.git.checkoutStage('ours', resolution.path) + else await fs.rm(absolute, { force: true }) } await this.#deps.git.addAll() return { adopted: summary.adopted, appended: summary.appended, diverged: summary.diverged, forks } diff --git a/test/encrypted.test.mjs b/test/encrypted.test.mjs index 9c02065..de97909 100644 --- a/test/encrypted.test.mjs +++ b/test/encrypted.test.mjs @@ -136,6 +136,32 @@ test('mergeTrees keeps ours + forks theirs on append-both and adopts theirs-only assert.equal(merged.get(forkRel).toString(), 'line1\ntheirs\n', 'remote version preserved as fork file') }) +test('mergeTrees handles remote deletions without ever storing undefined', () => { + const base = new Map([ + ['a/gone.jsonl', Buffer.from('line1\n')], + ['b/edited.jsonl', Buffer.from('line1\n')], + ['c/same.jsonl', Buffer.from('line1\n')], + ]) + const ours = new Map([ + ['a/gone.jsonl', Buffer.from('line1\n')], + ['b/edited.jsonl', Buffer.from('line1\nours\n')], + ['c/same.jsonl', Buffer.from('line1\n')], + ]) + // 远端删除了 a(本地未改)与 b(本地已改),只留 c。 + const theirs = new Map([['c/same.jsonl', Buffer.from('line1\n')]]) + const { merged, forks, adopted, appended, diverged } = mergeTrees(base, ours, theirs, '20260815120000', 'aaaaaaaa') + assert.equal(adopted, 1, 'remote-only deletion is adopted') + assert.equal(diverged, 1, 'remote deletion against a local edit is a loud divergence') + assert.equal(appended, 0) + assert.equal(forks.length, 0, 'a deletion carries no bytes to fork') + assert.equal(merged.has('a/gone.jsonl'), false, 'adopted deletion leaves the merged tree') + assert.equal(merged.get('b/edited.jsonl').toString(), 'line1\nours\n', 'local edit kept') + assert.equal(merged.get('c/same.jsonl').toString(), 'line1\n') + for (const [rel, content] of merged) { + assert.ok(Buffer.isBuffer(content), `${rel} must hold real bytes — writeTree fs.writeFile would throw on undefined`) + } +}) + test('encryptTree/decryptTree roundtrip preserves every file byte', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-session-sync-enc-')) t.after(() => removeTree(root)) diff --git a/test/engine.test.mjs b/test/engine.test.mjs index f49e342..a0a6d7b 100644 --- a/test/engine.test.mjs +++ b/test/engine.test.mjs @@ -261,3 +261,56 @@ test('engine status reports dirty mirror and fork files', { skip: !GIT_OK && 'gi const dirty = await device.engine.status() assert.ok(dirty.dirty.includes('sessions/s2/new.jsonl')) }) + +test('engine delete/modify conflicts stay keep-both instead of aborting the pull', { skip: !GIT_OK && 'git binary not available' }, async (t) => { + const { root, clean } = await makeTemp() + t.after(clean) + const remotePath = path.join(root, 'remote.git') + spawnSync('git', ['init', '--bare', remotePath], { stdio: 'ignore' }) + + const eventsA = { meta: [], errors: [], forks: [] } + const eventsB = { meta: [], errors: [], forks: [] } + const a = await setupDevice(root, 'A', 'aaaaaaaa', remotePath, eventsA) + const b = await setupDevice(root, 'B', 'bbbbbbbb', remotePath, eventsB) + + // 基线:两端各持有同一份会话树(pull 只写同步仓库的镜像,不回写会话根, + // 所以真实设备的会话文件必须由测试自己摆好)。 + await writeSession(a.sessionRoot, 's1/log.jsonl', 'line1\n') + await writeSession(a.sessionRoot, 's2/keep.jsonl', 'keep1\n') + assert.equal((await a.engine.push()).ok, true) + assert.equal((await b.engine.pull()).ok, true) + await writeSession(b.sessionRoot, 's1/log.jsonl', 'line1\n') + await writeSession(b.sessionRoot, 's2/keep.jsonl', 'keep1\n') + + // 情形一:远端删除、本地修改——git 的 modify/delete 冲突不登记 stage 3, + // 旧实现对缺失的 stage 调 `checkout --theirs` 会失败并中止整次 pull。 + await fs.rm(path.join(a.sessionRoot, 's1', 'log.jsonl')) + const pushDelete = await a.engine.push() + assert.equal(pushDelete.ok, true, `A delete push failed: ${pushDelete.error}`) + await writeSession(b.sessionRoot, 's1/log.jsonl', 'line1\nB-more\n') + const pullRemoteDelete = await b.engine.pull() + assert.equal(pullRemoteDelete.ok, true, `remote-delete pull must not abort: ${pullRemoteDelete.error}`) + assert.equal(pullRemoteDelete.diverged, 1) + assert.equal(pullRemoteDelete.forks.length, 0, 'a deletion carries no bytes to fork') + assert.equal(await readWorktree(b.repoDir, 'sessions/s1/log.jsonl'), 'line1\nB-more\n', 'local edit kept') + + // 情形二:本地删除、远端修改——同一个冲突的镜像方向,不登记 stage 2。 + await writeSession(a.sessionRoot, 's2/keep.jsonl', 'keep1\nA-more\n') + const pushEdit = await a.engine.push() + assert.equal(pushEdit.ok, true, `A edit push failed: ${pushEdit.error}`) + await fs.rm(path.join(b.sessionRoot, 's2', 'keep.jsonl')) + const pullLocalDelete = await b.engine.pull() + assert.equal(pullLocalDelete.ok, true, `local-delete pull must not abort: ${pullLocalDelete.error}`) + assert.equal(pullLocalDelete.diverged, 1) + assert.equal(pullLocalDelete.forks.length, 1) + assert.match(pullLocalDelete.forks[0], /^sessions\/s2\/keep\.jsonl\.remote-fork-\d{14}-bbbbbbbb$/u) + assert.equal(await readWorktree(b.repoDir, pullLocalDelete.forks[0]), 'keep1\nA-more\n', 'remote version preserved as fork file') + await assert.rejects(readWorktree(b.repoDir, 'sessions/s2/keep.jsonl'), 'local deletion is kept') + assert.ok(eventsB.forks.includes(pullLocalDelete.forks[0])) + + // 两侧冲突都已落地:仓库可继续提交与推送(残留的 unmerged stage 会在这里失败)。 + const pushResolved = await b.engine.push() + assert.equal(pushResolved.ok, true, `push after delete/modify resolution failed: ${pushResolved.error}`) + const status = await b.engine.status() + assert.ok(!status.mergeInProgress, 'no merge left in progress after resolution') +}) From 34c3c83f25ad9c706dc8e26370afd4f9a547286c Mon Sep 17 00:00:00 2001 From: BlakrPander Date: Thu, 8 Oct 2026 16:21:57 +0800 Subject: [PATCH 3/4] docs(changelog): record the token-username and delete/modify fixes --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 031ab2d..f00566d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **A token used as the remote URL's username is now redacted.** `https://@host/…` is the most common non-interactive PAT form, and `sanitizeRemote` only masked userinfo when a password was present (`username !== '' && password !== ''`), so the token was rendered verbatim by `/sync status` and handed to the model by the `sync_status` tool — and because this plugin mirrors session logs, a token seen there could then be committed into the synced repository. A password-less username now counts as a credential when it carries a token shape (a known prefix, ≥16 hex digits, or a ≥20-character separator-free multi-class string) and is masked as `***`; conventional logins (`git`, `oauth2`, `x-access-token`, `gitlab-ci-token`) and ordinary names stay visible, so the existing "username kept" behaviour for `user:pass@` and scp syntax is unchanged. `TOKEN_PATTERN` also learns the token families that had no coverage (`github_pat_`, the full `gh[pousr]_` set, `glpat-`, `gldt-`, `npm_`, `pypi-`, `AIza`, `ya29.`, `SG.`, `dop_v1_`, `shpat_`, and three-segment JWTs), and the branch handling unparseable remotes (scp syntax, malformed URLs) now returns `redactText(trimmed)` instead of echoing the input — which is what this module's own header already promised ("输入不合法时返回保守的脱敏文本"). +- **Userinfo and query credentials are redacted in the same pass.** `sanitizeRemote` returned early after masking `user:pass@`, so the `?access_token=…` redaction below it never ran for a URL that had both; the early return is gone and both redactions always apply. + +### Fixed + +- **`pull` no longer aborts on a delete/modify conflict.** When one side deletes a session file and the other modifies it, git registers only the surviving stages — a remote-side deletion leaves no stage 3, a local-side deletion leaves no stage 2 — so the unconditional `checkoutStage('theirs'|'ours')` failed with `does not have their/our version`, `abortMerge` rolled the whole pull back, and sync stalled until the user resolved it by hand. `#resolveConflicts` now reads each side's presence from the stage blobs first and treats a missing stage as "that side deleted the file": the deletion is kept, the surviving side is preserved (remote bytes still land in a fork file when there are any), and the divergence is still reported loudly through `summary.diverged`. Fork-ability is now keyed on the remote side actually having bytes, so a deletion never produces a bogus empty fork. +- **The `encrypted` backend's `mergeTrees` no longer inserts `undefined` for a remote deletion.** `THEIRS_ONLY` with no remote bytes did `merged.set(rel, undefined)`, and `forkTheirs` with no remote bytes inserted a `undefined`-valued fork key; `writeTree` then called `fs.writeFile(target, undefined)` on both, which throws `TypeError`. An adopted deletion now leaves the merged tree (`merged.delete(rel)`) and an un-forkable deletion is counted as `diverged` without a fork entry. ## [0.2.22] - 2026-10-04 undefined From f9279819bcac3d9b51333dc10c7d6f1bddcb34dc Mon Sep 17 00:00:00 2001 From: BlakrPander Date: Thu, 8 Oct 2026 16:44:10 +0800 Subject: [PATCH 4/4] fix(mirror): never mistake a pulled session for a local deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mirror deleted every worktree file that was absent from `sessionRoot`, but a worktree file has two possible origins: mirrored from the local store, or merged in from the remote by a pull. `pull` never writes the live store (`sessionRoot` is read-only to this plugin, by design), so a session that arrived from another device was read as "deleted locally" and removed — along with a deletion commit pushed back to the remote. The result was that every pull was undone by the next mirror run, and a pull was therefore useless on its own: two devices deleted each other's sessions in turn. Reproduced with two devices over a real bare remote: A push -> ok B pull -> ok in B mirror? true <- pulled in B live? false <- never enters the live store, by design B pull again -> ok in B mirror? false <- and it is gone again `/sync status` alone was enough to trigger it, because status mirrors too. The mirror now records the source's file list after every run in `/.git/dsh-session-sync-mirror.json` — local state, never committed, never synced — and deletes a file only when it is absent from the source **and** present in that previous snapshot, i.e. one this device actually mirrored. Anything else is reported as `remotePreserved` and kept. When the snapshot cannot be read (first run, cleaned, corrupt, or `` not yet a git worktree) nothing is deleted at all, so the failure direction is a stale file rather than a lost one. Local deletion propagation is unchanged, and still reaches a device that only ever held the remote copy (git resolves that as a clean merge, so the non-owner adopts the deletion and does not resurrect it). Verified: 4 of the new tests fail against the previous code, while the existing "source deletions sync" test passes on both — the deletion semantics are not weakened, only the misattribution is fixed. --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 2 ++ README-es.md | 2 +- README-hi.md | 2 +- README-pt.md | 2 +- README-zh.md | 2 +- README.md | 2 +- lib/mirror.mjs | 86 ++++++++++++++++++++++++++++++++++++++++---- test/engine.test.mjs | 48 +++++++++++++++++++++++++ test/mirror.test.mjs | 71 ++++++++++++++++++++++++++++++++++-- 10 files changed, 203 insertions(+), 16 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca5c51b..489fd46 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -36,7 +36,7 @@ $DSH_HOME/sessions ──byte mirror──▶ /sessions ──git──▶ custom remote ``` -1. **Mirror** copies each session file as opaque bytes into the worktree (symlinks refused, deletions only for files that vanished at the source and are neither fork files nor host-private artifacts). Host-private artifacts (`session.lock`, the harness session lease, and `session.migration.*.tmp` staging files) are runtime state, not syncable content: the mirror neither copies them in nor deletes them from the worktree. +1. **Mirror** copies each session file as opaque bytes into the worktree (symlinks refused; deletions only for files this device actually mirrored and that have since vanished at the source, never fork files or host-private artifacts). The "this device actually mirrored" qualifier is load-bearing: a worktree file has two possible origins — mirrored from the local `sessionRoot`, or merged in from the remote by a pull — and only the first can be interpreted as a local deletion. Judging by absence at the source alone would treat every pulled session as locally deleted, so each pull would be undone by the next mirror run (the read-only `status` included, since it mirrors too) and the deletion would be pushed back, leaving two devices deleting each other's sessions in turn. The mirror therefore records the source's file list after every run in `/.git/dsh-session-sync-mirror.json` — local state, never committed, never synced — and a mirror file that is missing from both the source and that snapshot is reported as `remotePreserved` and kept. When the snapshot is unreadable (first run, cleaned, corrupt, or `` is not yet a git worktree) **nothing is deleted at all**: the failure mode is a stale file, never a lost one. Host-private artifacts (`session.lock`, the harness session lease, and `session.migration.*.tmp` staging files) are runtime state, not syncable content: the mirror neither copies them in nor deletes them from the worktree. 2. **Commit** records the mirror as a git commit (plugin identity, never the user's git identity). 3. **Push/pull** move commits to/from the configured remote over `ctx.subprocess`-driven git. diff --git a/CHANGELOG.md b/CHANGELOG.md index f00566d..a1446ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`pull` no longer aborts on a delete/modify conflict.** When one side deletes a session file and the other modifies it, git registers only the surviving stages — a remote-side deletion leaves no stage 3, a local-side deletion leaves no stage 2 — so the unconditional `checkoutStage('theirs'|'ours')` failed with `does not have their/our version`, `abortMerge` rolled the whole pull back, and sync stalled until the user resolved it by hand. `#resolveConflicts` now reads each side's presence from the stage blobs first and treats a missing stage as "that side deleted the file": the deletion is kept, the surviving side is preserved (remote bytes still land in a fork file when there are any), and the divergence is still reported loudly through `summary.diverged`. Fork-ability is now keyed on the remote side actually having bytes, so a deletion never produces a bogus empty fork. - **The `encrypted` backend's `mergeTrees` no longer inserts `undefined` for a remote deletion.** `THEIRS_ONLY` with no remote bytes did `merged.set(rel, undefined)`, and `forkTheirs` with no remote bytes inserted a `undefined`-valued fork key; `writeTree` then called `fs.writeFile(target, undefined)` on both, which throws `TypeError`. An adopted deletion now leaves the merged tree (`merged.delete(rel)`) and an un-forkable deletion is counted as `diverged` without a fork entry. +- **A pulled session is no longer deleted by the next sync.** The mirror deleted any worktree file that was absent from `sessionRoot`, but a worktree file has two origins — mirrored from the local store, or merged in from the remote by a pull — and `pull` never writes the live store (by design, `sessionRoot` is read-only to this plugin). So a session that arrived from another device was read as "deleted locally" and removed, together with a deletion commit pushed back to the remote: **a pull was undone by the very next mirror run, and `/sync status` alone was enough to do it** (it mirrors too). Two devices then deleted each other's sessions in turn. The mirror now records the source's file list after every run in `/.git/dsh-session-sync-mirror.json` — local state, never committed, never synced — and only deletes a file that is missing from the source **and** present in that previous snapshot, i.e. one this device really did mirror. Anything else is reported as `remotePreserved` and kept. With no readable snapshot (first run, cleaned, corrupt, or `` not yet a git worktree) nothing is deleted at all, so the failure direction is a stale file rather than a lost one. Genuine deletions still propagate exactly as before, including to a device that only ever held the remote copy. + ## [0.2.22] - 2026-10-04 undefined diff --git a/README-es.md b/README-es.md index ccf612d..b03f44a 100644 --- a/README-es.md +++ b/README-es.md @@ -49,7 +49,7 @@ Este plugin forma parte de la [familia de plugins DSH](https://github.com/PerryL - **Comando `/sync`** — `status` (rama, remoto saneado, delante/detrás, archivos sucios, forks), `diff`, `log`, `pull`, `push`, `help`. - **Herramientas `sync_status` / `sync_pull` / `sync_push`** — la misma superficie para el modelo, dentro de un turno. -- **Resolución de conflictos append-only** — los registros de sesión son append-only; ante cualquier divergencia el plugin conserva **ambos** lados (la versión local se conserva, la remota se preserva como archivos fork) y nunca sobrescribe en silencio. Una eliminación también cuenta como lado: un conflicto borrado/edición conserva el borrado y preserva los bytes del lado superviviente como archivo fork. Las sesiones divergentes también pueden bifurcarse a nivel de sesión. +- **Resolución de conflictos append-only** — los registros de sesión son append-only; ante cualquier divergencia el plugin conserva **ambos** lados (la versión local se conserva, la remota se preserva como archivos fork) y nunca sobrescribe en silencio. Una eliminación también cuenta como lado: un conflicto borrado/edición conserva el borrado y preserva los bytes del lado superviviente como archivo fork. Una sesión que solo llegó desde otro dispositivo nunca se confunde con un borrado local, así que la siguiente sincronización nunca deshace un pull. Las sesiones divergentes también pueden bifurcarse a nivel de sesión. - **Modos automáticos** — pull al iniciar, push tras cada turno cerrado y pull periódico, todos configurables y reversibles. - **Escrituras con confirmación** — `pull`/`push` preguntan primero (mediante `userQuestions` o `approval`); las superficies de solo lectura nunca preguntan; sin respondedor la operación falla cerrada. diff --git a/README-hi.md b/README-hi.md index 6e0f3c0..1b8b024 100644 --- a/README-hi.md +++ b/README-hi.md @@ -49,7 +49,7 @@ - **`/sync` कमांड** — `status` (ब्रांच, सैनिटाइज़ किया गया रिमोट, आगे/पीछे, गंदी फ़ाइलें, फ़ोर्क), `diff`, `log`, `pull`, `push`, `help`। - **`sync_status` / `sync_pull` / `sync_push` टूल** — मॉडल के लिए वही सतह, एक टर्न के अंदर। -- **Append-only टकराव समाधान** — सत्र लॉग append-only होते हैं; किसी भी विचलन पर प्लगइन **दोनों** पक्ष रखता है (स्थानीय संस्करण रखा जाता है, रिमोट संस्करण फ़ोर्क फ़ाइलों के रूप में संरक्षित रहता है) और कभी चुपचाप ओवरराइट नहीं करता। हटाना भी एक पक्ष है: delete/edit टकराव में हटाना बना रहता है और बचे हुए पक्ष के बाइट्स फ़ोर्क फ़ाइल के रूप में सुरक्षित रहते हैं। विचलित सत्र सत्र-स्तर पर भी फ़ोर्क हो सकते हैं। +- **Append-only टकराव समाधान** — सत्र लॉग append-only होते हैं; किसी भी विचलन पर प्लगइन **दोनों** पक्ष रखता है (स्थानीय संस्करण रखा जाता है, रिमोट संस्करण फ़ोर्क फ़ाइलों के रूप में संरक्षित रहता है) और कभी चुपचाप ओवरराइट नहीं करता। हटाना भी एक पक्ष है: delete/edit टकराव में हटाना बना रहता है और बचे हुए पक्ष के बाइट्स फ़ोर्क फ़ाइल के रूप में सुरक्षित रहते हैं। जो सत्र केवल किसी अन्य डिवाइस से आया है, उसे कभी स्थानीय डिलीशन नहीं माना जाता, इसलिए अगली सिंक कभी pull को पलट नहीं देती। विचलित सत्र सत्र-स्तर पर भी फ़ोर्क हो सकते हैं। - **स्वचालित मोड** — स्टार्ट पर pull, हर बंद टर्न के बाद push, और आवधिक pull, सभी कॉन्फ़िगर करने योग्य और प्रतिवर्ती। - **पुष्टि-गेटेड लेखन** — `pull`/`push` पहले पूछते हैं (`userQuestions` या `approval` के माध्यम से); केवल-पठन सतहें कभी नहीं पूछतीं; बिना उत्तरदाता के ऑपरेशन बंद-असफल होता है। diff --git a/README-pt.md b/README-pt.md index 64e8656..8a94a6c 100644 --- a/README-pt.md +++ b/README-pt.md @@ -49,7 +49,7 @@ O `dsh-session-sync` espelha o seu armazenamento de sessões do DSH em uma árvo - **Comando `/sync`** — `status` (branch, remoto higienizado, à frente/atrás, arquivos sujos, forks), `diff`, `log`, `pull`, `push`, `help`. - **Ferramentas `sync_status` / `sync_pull` / `sync_push`** — a mesma superfície para o modelo, dentro de um turno. -- **Resolução de conflitos append-only** — os registros de sessão são append-only; em qualquer divergência o plugin mantém **os dois** lados (a versão local é mantida, a remota é preservada como arquivos fork) e nunca sobrescreve em silêncio. Uma exclusão também conta como lado: um conflito exclusão/edição mantém a exclusão e preserva os bytes do lado sobrevivente como arquivo fork. Sessões divergentes também podem bifurcar no nível da sessão. +- **Resolução de conflitos append-only** — os registros de sessão são append-only; em qualquer divergência o plugin mantém **os dois** lados (a versão local é mantida, a remota é preservada como arquivos fork) e nunca sobrescreve em silêncio. Uma exclusão também conta como lado: um conflito exclusão/edição mantém a exclusão e preserva os bytes do lado sobrevivente como arquivo fork. Uma sessão que só chegou de outro dispositivo nunca é confundida com uma exclusão local, então a sincronização seguinte nunca desfaz um pull. Sessões divergentes também podem bifurcar no nível da sessão. - **Modos automáticos** — pull ao iniciar, push após cada turno fechado e pull periódico, todos configuráveis e reversíveis. - **Escritas com confirmação** — `pull`/`push` perguntam primeiro (via `userQuestions` ou `approval`); superfícies somente leitura nunca perguntam; sem respondedor a operação falha fechada. diff --git a/README-zh.md b/README-zh.md index 6a7611a..3a59e8f 100644 --- a/README-zh.md +++ b/README-zh.md @@ -51,7 +51,7 @@ - **`/sync` 命令** —— `status`(分支、脱敏后的远端、领先/落后、未提交文件、fork 文件)、`diff`、`log`、`pull`、`push`、`help`。 - **`sync_status` / `sync_pull` / `sync_push` 工具** —— 在轮次内为模型提供同样的能力。 -- **append-only 冲突裁决** —— 会话日志是 append-only;任何分歧都会**两边都保留**(本地版本保留、远端版本留存为 fork 文件),绝不静默覆盖。删除也算一方:删除/修改冲突保留删除,并把存活一侧的字节留存为 fork 文件。分歧的会话还可在会话层面 fork。 +- **append-only 冲突裁决** —— 会话日志是 append-only;任何分歧都会**两边都保留**(本地版本保留、远端版本留存为 fork 文件),绝不静默覆盖。删除也算一方:删除/修改冲突保留删除,并把存活一侧的字节留存为 fork 文件。只在别的设备上存在过的会话绝不会被误判为本地删除,所以拉取成果不会被下一次同步抹掉。分歧的会话还可在会话层面 fork。 - **自动模式** —— 启动时拉取、每个关闭轮次后推送、周期拉取,全部可配置且可逆。 - **写操作走确认门** —— `pull`/`push` 会先询问(经 `userQuestions` 或 `approval`);只读界面从不询问;没有回答者时操作失败关闭。 diff --git a/README.md b/README.md index d55ab6f..17779de 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ - **`/sync` command** — `status` (branch, sanitized remote, ahead/behind, dirty files, forks), `diff`, `log`, `pull`, `push`, `help`. - **`sync_status` / `sync_pull` / `sync_push` tools** — the same surface for the model, inside a turn. -- **Append-only conflict resolution** — session logs are append-only; on any divergence the plugin keeps **both** sides (local version kept, remote version preserved as fork files) and never silently overwrites. A deletion counts as a side too: a delete/edit conflict keeps the deletion and preserves the surviving side's bytes as a fork file. Diverged sessions can also fork at the session level. +- **Append-only conflict resolution** — session logs are append-only; on any divergence the plugin keeps **both** sides (local version kept, remote version preserved as fork files) and never silently overwrites. A deletion counts as a side too: a delete/edit conflict keeps the deletion and preserves the surviving side's bytes as a fork file. A session that only ever arrived from another device is never mistaken for a local deletion, so a pull is never undone by the next sync. Diverged sessions can also fork at the session level. - **Auto modes** — pull on start, push after every closed turn, and periodic pull, all configurable and all reversible. - **Confirmation-gated writes** — `pull`/`push` ask first (through `userQuestions` or `approval`); read-only surfaces never ask; with no answerer the operation fails closed. diff --git a/lib/mirror.mjs b/lib/mirror.mjs index b0f6ab4..c8357ff 100644 --- a/lib/mirror.mjs +++ b/lib/mirror.mjs @@ -3,10 +3,18 @@ // 会话文件一律按不透明字节复制(JSONL/zstd 等物理编码属于宿主,插件不解析)。 // 安全边界: // - 绝不跟随符号链接(源或目标为链接一律跳过并报告,杜绝写出根外); -// - 删除仅限工作树内、源已不存在、且非 fork 文件、非宿主私有产物的常规文件; +// - 删除仅限工作树内、**上一次镜像时确实来自源**、这次源已不存在、且非 fork +// 文件、非宿主私有产物的常规文件; // - fork 文件(FORK_NAME_RE)永不复制、永不删除——冲突双方字节的持久载体; // - 宿主私有会话产物(HOST_ARTIFACT_NAME_RE:session.lock / 迁移暂存)同样 // 永不复制、永不删除——运行时状态不属于可同步内容。 +// +// 「上一次镜像时确实来自源」这条判据是必需的:工作树里的文件有两种来源—— +// 由本机 sessionRoot 镜像来的,和由 pull 从远端合进来的。只按「源里没有」删除 +// 会把后者也清掉(拉来的会话从没进过活会话库),于是每次 pull 的成果都会被 +// 下一次镜像抹掉,并把删除推回远端,形成 A/B 两台设备互相删除的来回震荡。 +// 判据存在 /.git 下(本地、不进提交、不随仓库同步);读不到就一律 +// 不删(失败方向是留下文件,不是丢文件)。 import { promises as fs } from 'node:fs' import path from 'node:path' @@ -50,18 +58,62 @@ async function writeIfDifferent(target, content) { return true } +/** 源快照状态文件(相对 /.git):本地、不进提交、不随仓库同步。 */ +export const SOURCE_SNAPSHOT_FILE = path.join('.git', 'dsh-session-sync-mirror.json') + +/** + * 读上一次镜像时 sessionRoot 的文件清单。 + * 返回 undefined 表示「不知道」——此时调用方一律不得删除任何镜像文件, + * 失败方向必须是「留下文件」而不是「丢文件」。 + * @param {string} repoDir - 同步仓库根。 + * @returns {Promise|undefined>} 相对路径集合,或 undefined(未知)。 + */ +async function readSourceSnapshot(repoDir) { + try { + const raw = await fs.readFile(path.join(repoDir, SOURCE_SNAPSHOT_FILE), 'utf8') + const parsed = JSON.parse(raw) + if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.sourcePaths)) return undefined + return new Set(parsed.sourcePaths.filter(rel => typeof rel === 'string')) + } catch { + // 首次运行、被清理、损坏、或 还不是 git 仓库 → 视为未知。 + return undefined + } +} + +/** + * 写回本次镜像的源清单(内容不变则跳过写)。 + * @param {string} repoDir - 同步仓库根。 + * @param {Set} sourcePaths - 本次实际镜像的源相对路径。 + * @returns {Promise} 是否写入。 + */ +async function writeSourceSnapshot(repoDir, sourcePaths) { + const target = path.join(repoDir, SOURCE_SNAPSHOT_FILE) + const payload = Buffer.from(`${JSON.stringify({ version: 1, sourcePaths: [...sourcePaths].sort() })}\n`) + try { + const stat = await fs.stat(path.dirname(target)) + // 不是 git 工作树(.git 缺失或为 file 形式的 gitdir 指针)时不留状态, + // 该次运行也就不会删除任何东西。 + if (!stat.isDirectory()) return false + } catch { + return false + } + return writeIfDifferent(target, payload) +} + /** * 把 sessionRoot 镜像进 //…。 * 宿主私有会话产物(租约/迁移暂存,HOST_ARTIFACT_NAME_RE)既不复制、也不删除: * 它们是运行时状态,同步它们只会把某设备的瞬时状态变成另一侧的删除/噪音。 * @param {object} deps - {sessionRoot, repoDir, mirrorDir, forkNameRe?}。 - * @returns {Promise<{mirrored: number, unchanged: number, skippedLinks: string[], deleted: string[], forkedPreserved: string[], hostArtifactsSkipped: number, hostArtifactsPreserved: string[]}>} + * @returns {Promise<{mirrored: number, unchanged: number, skippedLinks: string[], deleted: string[], remotePreserved: string[], snapshotUnknown: boolean, forkedPreserved: string[], hostArtifactsSkipped: number, hostArtifactsPreserved: string[]}>} */ export async function mirrorSessionRoot(deps) { const forkRe = deps.forkNameRe ?? FORK_NAME_RE const source = await listFiles(deps.sessionRoot) const mirrorRoot = path.join(deps.repoDir, deps.mirrorDir) const targets = new Set() + // 上一次镜像时源里有什么。undefined = 未知(首次运行/状态被清)→ 本次不删任何东西。 + const previous = await readSourceSnapshot(deps.repoDir) let mirrored = 0 let unchanged = 0 @@ -78,8 +130,12 @@ export async function mirrorSessionRoot(deps) { else unchanged += 1 } - // 删除:目标树中源已不存在、且非 fork 命名、非宿主私有产物的文件。 + // 删除:仅限「上一次镜像时确实来自源」(previous 命中)而这次源里已经没有的 + // 文件。工作树里源里没有的文件有两种:本机删掉的会话,和 pull 从远端合进来的 + // 会话——后者从没进过活会话库,按「源里没有」删掉会让每次 pull 的成果被下一次 + // 镜像抹掉并把删除推回远端。previous 未知时一律不删(失败方向是留下,不是丢)。 const deleted = [] + const remotePreserved = [] const forkedPreserved = [] const hostArtifactsPreserved = [] const targetFiles = [] @@ -111,13 +167,29 @@ export async function mirrorSessionRoot(deps) { hostArtifactsPreserved.push(rel) continue } - if (!targets.has(rel)) { - await fs.unlink(absolute) - deleted.push(rel) + if (targets.has(rel)) continue + if (previous === undefined || !previous.has(rel)) { + // 上一次镜像时源里没有它(远端拉来的),或快照未知 → 保留,绝不删。 + remotePreserved.push(rel) + continue } + await fs.unlink(absolute) + deleted.push(rel) } - return { mirrored, unchanged, skippedLinks: source.skipped, deleted, forkedPreserved, hostArtifactsSkipped, hostArtifactsPreserved } + await writeSourceSnapshot(deps.repoDir, targets) + + return { + mirrored, + unchanged, + skippedLinks: source.skipped, + deleted, + remotePreserved, + snapshotUnknown: previous === undefined, + forkedPreserved, + hostArtifactsSkipped, + hostArtifactsPreserved, + } } /** diff --git a/test/engine.test.mjs b/test/engine.test.mjs index a0a6d7b..8d90253 100644 --- a/test/engine.test.mjs +++ b/test/engine.test.mjs @@ -314,3 +314,51 @@ test('engine delete/modify conflicts stay keep-both instead of aborting the pull const status = await b.engine.status() assert.ok(!status.mergeInProgress, 'no merge left in progress after resolution') }) + +test('a pulled session survives every later sync, while real deletions still propagate', { skip: !GIT_OK && 'git binary not available' }, async (t) => { + const { root, clean } = await makeTemp() + t.after(clean) + const remotePath = path.join(root, 'remote.git') + spawnSync('git', ['init', '--bare', remotePath], { stdio: 'ignore' }) + + const eventsA = { meta: [], errors: [], forks: [] } + const eventsB = { meta: [], errors: [], forks: [] } + const a = await setupDevice(root, 'A', 'aaaaaaaa', remotePath, eventsA) + const b = await setupDevice(root, 'B', 'bbbbbbbb', remotePath, eventsB) + const rel = 'sessions/p/remote/session.jsonl' + + // A 的会话推到远端,B 拉下来。它只进 B 的同步镜像——pull 从不回写活会话库, + // 所以 B 的 sessionRoot 里始终没有这个文件。 + await writeSession(a.sessionRoot, 'p/remote/session.jsonl', 'from-A\n') + assert.equal((await a.engine.push()).ok, true) + assert.equal((await b.engine.pull()).ok, true) + assert.equal(await readWorktree(b.repoDir, rel), 'from-A\n') + + // 关键回归:此后每一次同步都不得把它当成「本机删除了」而抹掉。旧实现在这里 + // 连只读的 status 都会删(镜像先跑,按「源里没有」判定),并把删除推回远端, + // 造成两台设备互相删除的来回震荡。 + await b.engine.status() + assert.equal(await readWorktree(b.repoDir, rel), 'from-A\n', 'status must not delete a pulled session') + const pushB = await b.engine.push() + assert.equal(pushB.ok, true, `B push failed: ${pushB.error}`) + assert.equal(pushB.deleted, 0, 'B owns nothing here, so it deletes nothing') + assert.equal(await readWorktree(b.repoDir, rel), 'from-A\n', 'push must not delete a pulled session') + + // 也不得把删除推回远端:A 再拉一次仍然有它。 + assert.equal((await a.engine.pull()).ok, true) + assert.equal(await readWorktree(a.repoDir, rel), 'from-A\n', 'no ping-pong deletion back to A') + + // 真正的本地删除仍然传播(删除语义未被削弱):A 删掉自己的会话。 + await fs.rm(path.join(a.sessionRoot, 'p', 'remote', 'session.jsonl')) + const pushDelete = await a.engine.push() + assert.equal(pushDelete.ok, true, `A delete push failed: ${pushDelete.error}`) + assert.equal(pushDelete.deleted, 1, 'a genuine local deletion still propagates') + assert.equal((await b.engine.pull()).ok, true) + await assert.rejects(readWorktree(b.repoDir, rel), 'the deletion reaches the non-owner device') + + // 而且 B 不会把它复活推回去。 + const pushB2 = await b.engine.push() + assert.equal(pushB2.ok, true) + assert.equal((await a.engine.pull()).ok, true) + await assert.rejects(readWorktree(a.repoDir, rel), 'a deleted session is not resurrected by B') +}) diff --git a/test/mirror.test.mjs b/test/mirror.test.mjs index 99b28ee..6e64c29 100644 --- a/test/mirror.test.mjs +++ b/test/mirror.test.mjs @@ -43,6 +43,9 @@ test('source deletions sync; fork files are never copied or deleted', async (t) const src = path.join(root, 'sessions') const repo = path.join(root, 'repo') await fs.mkdir(path.join(src, 's1'), { recursive: true }) + // 删源快照落在 /.git 下(本地状态,不进提交)。生产路径上 + // ensureRepo/bootstrap 一定先建好仓库,所以这里照做。 + await fs.mkdir(path.join(repo, '.git'), { recursive: true }) await fs.writeFile(path.join(src, 's1', 'log.jsonl'), 'a\n') await mirrorSessionRoot({ sessionRoot: src, repoDir: repo, mirrorDir: 'sessions' }) @@ -149,13 +152,18 @@ test('a target-side host-private artifact is preserved, never deleted', async (t const src = path.join(root, 'sessions') const repo = path.join(root, 'repo') await fs.mkdir(path.join(src, 's1'), { recursive: true }) + await fs.mkdir(path.join(repo, '.git'), { recursive: true }) await fs.writeFile(path.join(src, 's1', 'session.jsonl'), 'payload\n') + // 源侧一开始也有 obsolete.jsonl:先镜像一次,把它记进删源快照(=「这个文件是本机的」)。 + await fs.writeFile(path.join(src, 's1', 'obsolete.jsonl'), 'gone\n') + await mirrorSessionRoot({ sessionRoot: src, repoDir: repo, mirrorDir: 'sessions' }) + // 目标侧已有另一设备提交/持有的租约与迁移暂存:源侧没有它们。 - await fs.mkdir(path.join(repo, 'sessions', 's1'), { recursive: true }) await fs.writeFile(path.join(repo, 'sessions', 's1', 'session.lock'), '{"pid":9}') await fs.writeFile(path.join(repo, 'sessions', 's1', 'session.migration.zz99.jsonl.tmp'), 'stale\n') - // 一个源侧已不存在、且不受白名单保护的常规文件:仍必须被删(删除语义未被削弱)。 - await fs.writeFile(path.join(repo, 'sessions', 's1', 'obsolete.jsonl'), 'gone\n') + // 源侧删掉 obsolete.jsonl:它进过上一次快照,且不受白名单保护 → 仍必须被删 + // (删除语义未被本次改动削弱)。 + await fs.unlink(path.join(src, 's1', 'obsolete.jsonl')) const result = await mirrorSessionRoot({ sessionRoot: src, repoDir: repo, mirrorDir: 'sessions' }) @@ -164,3 +172,60 @@ test('a target-side host-private artifact is preserved, never deleted', async (t assert.deepEqual(result.hostArtifactsPreserved, ['s1/session.lock', 's1/session.migration.zz99.jsonl.tmp'].sort()) assert.deepEqual(result.deleted, ['s1/obsolete.jsonl']) }) + +test('files that only ever arrived from the remote are never deleted', async (t) => { + const { root, clean } = await makeTemp() + t.after(clean) + const src = path.join(root, 'sessions') + const repo = path.join(root, 'repo') + await fs.mkdir(path.join(src, 's1'), { recursive: true }) + await fs.mkdir(path.join(repo, '.git'), { recursive: true }) + await fs.writeFile(path.join(src, 's1', 'mine.jsonl'), 'mine\n') + await mirrorSessionRoot({ sessionRoot: src, repoDir: repo, mirrorDir: 'sessions' }) + + // 一次 pull 把远端会话合进工作树:源侧从来没有过它。 + await fs.mkdir(path.join(repo, 'sessions', 's2'), { recursive: true }) + await fs.writeFile(path.join(repo, 'sessions', 's2', 'theirs.jsonl'), 'theirs\n') + + // 反复镜像(每次 pull/push/status 都会跑一遍)都必须保留它。 + for (let run = 0; run < 3; run++) { + const result = await mirrorSessionRoot({ sessionRoot: src, repoDir: repo, mirrorDir: 'sessions' }) + assert.deepEqual(result.deleted, [], `run ${run}: a remote-origin file must never be deleted`) + assert.deepEqual(result.remotePreserved, ['s2/theirs.jsonl'], `run ${run}: it is reported as remote-preserved`) + assert.equal(await fs.readFile(path.join(repo, 'sessions', 's2', 'theirs.jsonl'), 'utf8'), 'theirs\n') + } + // 本机文件照旧被镜像。 + assert.equal(await fs.readFile(path.join(repo, 'sessions', 's1', 'mine.jsonl'), 'utf8'), 'mine\n') +}) + +test('with no recorded source snapshot nothing is deleted (fail safe)', async (t) => { + const { root, clean } = await makeTemp() + t.after(clean) + const src = path.join(root, 'sessions') + const repo = path.join(root, 'repo') + await fs.mkdir(path.join(src, 's1'), { recursive: true }) + await fs.mkdir(path.join(repo, '.git'), { recursive: true }) + // 工作树里先有内容(等价于一次 clone/pull 之后),但没有任何快照记录。 + await fs.mkdir(path.join(repo, 'sessions', 's1'), { recursive: true }) + await fs.writeFile(path.join(repo, 'sessions', 's1', 'unknown.jsonl'), 'unknown\n') + + const result = await mirrorSessionRoot({ sessionRoot: src, repoDir: repo, mirrorDir: 'sessions' }) + + assert.deepEqual(result.deleted, [], 'unknown provenance must never delete') + assert.deepEqual(result.remotePreserved, ['s1/unknown.jsonl']) + assert.equal(await fs.readFile(path.join(repo, 'sessions', 's1', 'unknown.jsonl'), 'utf8'), 'unknown\n') +}) + +test('without a git directory the mirror still works, it just never deletes', async (t) => { + const { root, clean } = await makeTemp() + t.after(clean) + const src = path.join(root, 'sessions') + const repo = path.join(root, 'repo') // 故意不建 .git + await fs.mkdir(path.join(src, 's1'), { recursive: true }) + await fs.writeFile(path.join(src, 's1', 'log.jsonl'), 'a\n') + const first = await mirrorSessionRoot({ sessionRoot: src, repoDir: repo, mirrorDir: 'sessions' }) + assert.equal(first.mirrored, 1) + await fs.unlink(path.join(src, 's1', 'log.jsonl')) + const second = await mirrorSessionRoot({ sessionRoot: src, repoDir: repo, mirrorDir: 'sessions' }) + assert.deepEqual(second.deleted, [], 'no snapshot store means no deletions — never a wrong delete') +})