Plugin version
v0.2.16
Harness version
0.1.5-rc.2
OS and Node versions
Windows 11 (x64) DSH 0.1.5-rc.2 (DSH Desktop 2.0.13) Node 24.18.1 (Electron 43.3.0 embedded runtime, ELECTRON_RUN_AS_NODE=1) Plugin: dsh-session-sync@0.2.16 age v1.3.2 (FiloSottile/age, windows-amd64) Profile: desktop · backend: encrypted Remote type: local bare git repository on a USB drive (filesystem path, not https/ssh)
Steps to reproduce
-
Install dsh-session-sync into the profile and add it to dsh.profile.bundles.
The user patch layer must override the row by id — wrapping it in - insert:
aborts startup with: duplicate loader entry id "session-sync".
-
Configure:
backend: encrypted
remote: 'X:/dsh-sessions.git' (local bare repo on a USB drive)
ageBin: 'C:/Users//Programs/age/age.exe' (absolute path)
ageRecipient: age1... (valid public key)
ageIdentity: 'C:/Users//age-dsh-key.txt' (valid passphrase-less key)
-
Run: /sync status
-
Observed:
warning: age binary not found (probed age --version);
mirror content will be pushed unencrypted
Pre-checks proving the age binary itself is fine:
- The same absolute path run through DSH's own subprocess runtime prints
"v1.3.2" and exits 0.
- subprocess.resolveExecutable('C:/Users//Programs/age/age.exe')
resolves successfully (not an absolute-vs-bare-name problem).
- Control: gitBin uses the same style of absolute path, and every git
operation works.
Expected behavior
With backend: encrypted and a present, executable age binary plus valid
ageRecipient/ageIdentity, /sync status should report encrypted mode and emit no
"will be pushed unencrypted" warning.
Even when the probe genuinely fails, the warning should name the real cause. The
current message blames the age binary, while the actual failure is a host-API
contract violation (subprocess.spawn called with an undefined cwd).
Actual behavior
Summary
detectAge() always returned null under backend: encrypted, so the plugin
silently degraded to plaintext transport while reporting
age binary not found. The binary was present and executable the whole time.
Root cause
makeRunAge() calls subprocess.spawn() without a defined cwd:
// dsh-session-sync/index.mjs:309 (v0.2.16)
const handle = subprocess.spawn({
argv: [executable, ...args.slice(1)],
cwd: runOpts.cwd, // ← undefined: detectAge() passes no cwd
…
})
The host's targetEnvironment() guards every argv entry but validates
spec.cwd unconditionally:
// dsh-subprocess-local/lib/runner-launch-COYGu0Dl.js:1618-1622
function targetEnvironment(spec) {
spec.argv.forEach((value, index) => {
validateNoNullByte(index === 0 ? "file" : `args[${index - 1}]`, value, true);
});
validateNoNullByte("options.cwd", spec.cwd); // ← spec.cwd === undefined
…
}
// :1610-1611
function validateNoNullByte(property, value, argument = false) {
if (value.includes("\0")) … // ← undefined.includes → TypeError
}
detectAge()'s empty catch swallows the exception and returns null:
// dsh-session-sync/lib/age.mjs:23-31
export async function detectAge(run, ageBin = 'age', opts = {}) {
if (typeof ageBin !== 'string' || ageBin.length === 0) return null
try {
const result = await run([ageBin, '--version'], { timeoutMs: opts.timeoutMs, cwd: opts.cwd })
return result.code === 0 ? ageBin : null
} catch {
return null // ← the real error is discarded here
}
}
Stack trace captured by temporarily logging inside that catch:
TypeError: Cannot read properties of undefined (reading 'includes')
at validateNoNullByte (…/dsh-subprocess-local/lib/runner-launch-COYGu0Dl.js:1611:12)
at targetEnvironment (…/dsh-subprocess-local/lib/runner-launch-COYGu0Dl.js:1622:2)
at Proxy.spawn (…/dsh-subprocess-local/lib/index.js:992:15)
at …/dsh-session-sync/index.mjs:309:33
at async detectAge (…/dsh-session-sync/lib/age.mjs:26:20)
at async …/dsh-session-sync/lib/encrypted.mjs:268:24
at async EncryptedBackend.status (…/dsh-session-sync/lib/encrypted.mjs:384:23)
at async handleSyncCommand (…/dsh-session-sync/index.mjs:760:24)
makeRunGit() is unaffected only because every git call already supplies a
cwd; the age runner never did.
Note ageBin arrived intact — the logged probe argument was the configured
absolute path to age.exe. Configuration was not the problem.
Impact
backend: encrypted silently degrades to plaintext, and the only visible
clue is a warning that misattributes the failure to the wrong component,
making it effectively undiagnosable without reading host internals.
- Users may believe their session data is encrypted when it is not.
Suggested fix
Guarantee a defined cwd in the age runner:
const handle = subprocess.spawn({
argv: [executable, ...args.slice(1)],
cwd: runOpts.cwd ?? opts.repoDir ?? process.cwd(), // ← added fallback
…
})
and pass the worktree through:
run: makeRunAge(ctx.subprocess, {
repoDir, // ← added
graceMs: resolved.graceMs,
commandTimeoutMs: resolved.commandTimeoutMs,
maxOutputBytes: resolved.maxOutputBytes,
}),
Two optional hardening suggestions:
- Surface the swallowed error — record it in status warnings /
logger.warn
instead of an empty catch. A host-API contract break like this one would
then be self-diagnosable, and the warning text could quote the real reason.
- Consider null-safe validation of
cwd at the call sites too, or report it
upstream to @deepseek-ai/dsh-subprocess-local, since argv is guarded but
cwd is not — the asymmetry is easy to trip over.
Verification after the fix
$ cat $TMPDIR/dsh-age-detect.log
2026-09-22T12:26:00.645Z OK ageBin="<absolute path to age.exe>" code=0 stdout="v1.3.2\n" stderr=""
/sync push then succeeds and the remote contains only ciphertext —
17 encrypted/**/*.zstd.age blobs, zero plaintext sessions/**/*.zstd:
$ git --git-dir=X:/dsh-sessions.git ls-tree -r --name-only HEAD
.gitignore
device.txt
encrypted/<workspace-key>/session-<uuid>/session.v3.jsonl.zstd.age
… (17 total)
$ git --git-dir=X:/dsh-sessions.git show HEAD:encrypted/<workspace-key>/session-<uuid>/session.v3.jsonl.zstd.age
age-encryption.org/v1
-> X25519 <ephemeral-recipient>
…
Happy to open a PR with the fix if you prefer that over patching on your side.
Note: the reporter's local install carries a temporary one-line patch, so the
original 0.2.16 code is what still reproduces this.
Plugin version
v0.2.16
Harness version
0.1.5-rc.2
OS and Node versions
Windows 11 (x64) DSH 0.1.5-rc.2 (DSH Desktop 2.0.13) Node 24.18.1 (Electron 43.3.0 embedded runtime, ELECTRON_RUN_AS_NODE=1) Plugin: dsh-session-sync@0.2.16 age v1.3.2 (FiloSottile/age, windows-amd64) Profile: desktop · backend: encrypted Remote type: local bare git repository on a USB drive (filesystem path, not https/ssh)
Steps to reproduce
Install dsh-session-sync into the profile and add it to dsh.profile.bundles.
The user patch layer must override the row by id — wrapping it in
- insert:aborts startup with: duplicate loader entry id "session-sync".
Configure:
backend: encrypted
remote: 'X:/dsh-sessions.git' (local bare repo on a USB drive)
ageBin: 'C:/Users//Programs/age/age.exe' (absolute path)
ageRecipient: age1... (valid public key)
ageIdentity: 'C:/Users//age-dsh-key.txt' (valid passphrase-less key)
Run: /sync status
Observed:
warning: age binary not found (probed
age --version);mirror content will be pushed unencrypted
Pre-checks proving the age binary itself is fine:
"v1.3.2" and exits 0.
resolves successfully (not an absolute-vs-bare-name problem).
operation works.
Expected behavior
With backend: encrypted and a present, executable age binary plus valid
ageRecipient/ageIdentity, /sync status should report encrypted mode and emit no
"will be pushed unencrypted" warning.
Even when the probe genuinely fails, the warning should name the real cause. The
current message blames the age binary, while the actual failure is a host-API
contract violation (subprocess.spawn called with an undefined cwd).
Actual behavior
Summary
detectAge()always returnednullunderbackend: encrypted, so the pluginsilently degraded to plaintext transport while reporting
age binary not found. The binary was present and executable the whole time.Root cause
makeRunAge()callssubprocess.spawn()without a definedcwd:The host's
targetEnvironment()guards everyargventry but validatesspec.cwdunconditionally:detectAge()'s empty catch swallows the exception and returnsnull:Stack trace captured by temporarily logging inside that catch:
makeRunGit()is unaffected only because every git call already supplies acwd; the age runner never did.Note
ageBinarrived intact — the logged probe argument was the configuredabsolute path to
age.exe. Configuration was not the problem.Impact
backend: encryptedsilently degrades to plaintext, and the only visibleclue is a warning that misattributes the failure to the wrong component,
making it effectively undiagnosable without reading host internals.
Suggested fix
Guarantee a defined
cwdin the age runner:and pass the worktree through:
Two optional hardening suggestions:
logger.warninstead of an empty
catch. A host-API contract break like this one wouldthen be self-diagnosable, and the warning text could quote the real reason.
cwdat the call sites too, or report itupstream to
@deepseek-ai/dsh-subprocess-local, sinceargvis guarded butcwdis not — the asymmetry is easy to trip over.Verification after the fix
/sync pushthen succeeds and the remote contains only ciphertext —17
encrypted/**/*.zstd.ageblobs, zero plaintextsessions/**/*.zstd:Happy to open a PR with the fix if you prefer that over patching on your side.