Skip to content

[Bug]: backend: encrypted silently degrades to plaintext: age probe crashes on undefined cwd in subprocess.spawn #12

Description

@JFFLI

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

  1. 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".

  2. 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)

  3. Run: /sync status

  4. 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:

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions