Repository navigation
fix(sanitize,engine): redact a token used as the remote URL's username; resolve delete/modify conflicts keep-both - #14
Conversation
`sanitizeRemote` only masked userinfo when a password was present, so the `https://<token>@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.
…he pull
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.
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
`<repoDir>/.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 `<repoDir>`
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.
|
Adding a third fix to this PR (
|
| Gate | Result |
|---|---|
pnpm test |
100/100 (was 96; +4 new, 2 updated) |
| New tests vs. previous code | 4 fail |
| Existing "source deletions sync" test | passes on both — deletion semantics not weakened |
typecheck / typecheck:ci / lint / verify:* / check:readmes |
pass |
New e2e case: a pulled session survives every later sync, while real deletions still propagate (covers pull → status → push → remote round trip, then a genuine owner-side deletion propagating to the non-owner and staying gone).
Two independent bug fixes, one commit each. Happy to split this into two PRs if you would rather review them separately.
1.
fix(sanitize): a token used as the remote URL's username was rendered verbatimsanitizeRemotemasked userinfo only when a password was present:The most common non-interactive PAT form has no password at all —
https://<token>@github.com/you/repo.git— so the token was returned untouched. That value is exactly whatcollectStatusputs instatus.remote(lib/status.mjs:26), andrenderStatusinterpolates it with no further redaction (lib/render.mjs:21, reached from both/sync statusand thesync_statustool).redactTextis applied only to error messages on that path, never toremote, so there was no second line of defence.Reproduced against the shipped 0.2.23 tarball:
This is worse here than in a typical plugin, because the leaked string lands in the session transcript — and this plugin mirrors the session store, so the token could then be committed into the synced repository the user is trying to keep private.
The fix keeps your "usernames are not secrets" intent (
alice:***@and scp syntax are unchanged,SECURITY.md's promise becomes true rather than newly narrowed):>=16hex digits, or a>=20-character separator-free multi-class string.git,oauth2,x-access-token,gitlab-ci-tokenand ordinary names stay visible.TOKEN_PATTERNlearns the families it had no coverage for:github_pat_(the current default PAT form), the fullgh[pousr]_set,glpat-,gldt-,npm_,pypi-,AIza,ya29.,SG.,dop_v1_,shpat_, and three-segment JWTs.redactText(trimmed)instead of echoing the input — which the module header already promised ("输入不合法时返回保守的脱敏文本").?access_token=redaction; both redactions now always run.2.
fix(engine): a delete/modify conflict aborted the entire pullWhen 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 — and
#resolveConflictscalledcheckoutStageunconditionally:checkoutStagegoes through#must, so the failure propagated to thecatch,abortMerge()rolled the pull back, and sync stalled until the user resolved it by hand. Both directions were affected.#resolveConflictsnow 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 throughsummary.diverged. Fork-ability is keyed on the remote side actually having bytes, so a deletion can no longer produce a bogus empty fork.Same case in the
encryptedbackend, found while fixing the abovemergeTreesoperated onundefinedas if it were content:THEIRS_ONLYwith no remote bytes didmerged.set(rel, undefined)forkTheirswith no remote bytes inserted anundefined-valued fork keywriteTreewrites every entry (lib/encrypted.mjs:73-77), so both becamefs.writeFile(target, undefined)— aTypeError. An adopted deletion now leaves the merged tree (merged.delete(rel)) and an un-forkable deletion is counted asdivergedwith no fork entry.Verification
New regression coverage: 5 sanitize cases, 1
mergeTreescase, 1 end-to-end two-device git case covering both delete/modify directions.Reverting only
lib/and re-running the new tests — 6 of the 7 fail, so they genuinely pin these bugs (the 7th is the anti-over-redaction guard and correctly passes on both):With the fixes in place, the full gate is green:
pnpm testpnpm run typecheck/typecheck:cipnpm run lintverify:self-contained/verify:artifacts/check:readmesARCHITECTURE.mdrecords 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.One note on scope: I also confirmed the published npm tarball for 0.2.23 is byte-identical to
main(all 18 shipped files), so these findings apply to the released artifact and not just to the checkout.