Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
$DSH_HOME/sessions ──byte mirror──▶ <repoDir>/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 `<repoDir>/.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 `<repoDir>` 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.

Expand All @@ -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 `<basename>.remote-fork-<UTC 14-digit yyyyMMddHHmmss>-<device8>` (`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>/<sessionId>/<file>` — `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 `<mirrorDir>/<sessionId>/<file>` form) are reported with a warning and never silently dropped; the fork files themselves are always preserved.

## Confirmation gate
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ 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://<token>@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.
- **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 `<repoDir>/.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 `<repoDir>` 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
Expand Down
2 changes: 1 addition & 1 deletion README-es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

Expand Down
2 changes: 1 addition & 1 deletion README-hi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 को पलट नहीं देती। विचलित सत्र सत्र-स्तर पर भी फ़ोर्क हो सकते हैं।
- **स्वचालित मोड** — स्टार्ट पर pull, हर बंद टर्न के बाद push, और आवधिक pull, सभी कॉन्फ़िगर करने योग्य और प्रतिवर्ती।
- **पुष्टि-गेटेड लेखन** — `pull`/`push` पहले पूछते हैं (`userQuestions` या `approval` के माध्यम से); केवल-पठन सतहें कभी नहीं पूछतीं; बिना उत्तरदाता के ऑपरेशन बंद-असफल होता है।

Expand Down
2 changes: 1 addition & 1 deletion README-pt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

Expand Down
2 changes: 1 addition & 1 deletion README-zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`);只读界面从不询问;没有回答者时操作失败关闭。

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

Expand Down
23 changes: 16 additions & 7 deletions lib/encrypted.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 中)。
}
Expand Down
Loading
Loading