mixdog 0.7.15 → 0.7.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@
13
13
  "source": {
14
14
  "source": "url",
15
15
  "url": "https://github.com/trib-plugin/mixdog.git",
16
- "ref": "v0.7.15"
16
+ "ref": "v0.7.17"
17
17
  },
18
18
  "strict": false,
19
19
  "mcpServers": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.7.15",
3
+ "version": "0.7.17",
4
4
  "description": "Claude Code all-in-one agent plugin — autonomous agents, continuous memory, cost-aware sub-agents, and syntax-aware code editing.",
5
5
  "hooks": "./hooks/hooks.json",
6
6
  "mcpServers": {
@@ -1257,11 +1257,19 @@ async function runRulesPart() {
1257
1257
  // only when it is not already alive and never opens a browser window, so
1258
1258
  // this is a cheap no-op once the server is running.
1259
1259
  try {
1260
- spawn('bun', [path.join(PLUGIN_ROOT, 'setup', 'launch.mjs'), '--prewarm'], {
1260
+ const _prewarm = spawn('bun', [path.join(PLUGIN_ROOT, 'setup', 'launch.mjs'), '--prewarm'], {
1261
1261
  detached: true,
1262
1262
  stdio: 'ignore',
1263
1263
  windowsHide: true,
1264
- }).unref();
1264
+ });
1265
+ // ENOENT (bun not on PATH) and other spawn failures surface ASYNC as an
1266
+ // 'error' event, NOT via the try/catch above. Without a listener Node
1267
+ // throws it as an uncaught exception and crashes the hook. Fail soft:
1268
+ // prewarm is a best-effort optimization, never required for a session.
1269
+ _prewarm.on('error', (e) => {
1270
+ teeStderr(`[session-start] prewarm spawn failed (non-fatal): ${(e && e.message) || e}\n`);
1271
+ });
1272
+ _prewarm.unref();
1265
1273
  } catch {}
1266
1274
 
1267
1275
  try {
@@ -48,4 +48,18 @@ const child = spawnSync(shimPath, process.argv.slice(2), {
48
48
  windowsHide: true,
49
49
  });
50
50
 
51
- process.exit(child.status ?? 0);
51
+ // spawnSync reports a failure to even START the child (EACCES on a non-exec
52
+ // bit, ENOEXEC on a corrupt/foreign binary, etc.) via child.error, and leaves
53
+ // child.status === null. The old `child.status ?? 0` collapsed that null to a
54
+ // SUCCESS exit code, masking a broken shim as a clean run. Surface it instead.
55
+ if (child.error) {
56
+ process.stderr.write(`[mixdog-shim] failed to launch ${shimPath}: ${child.error.message}\n`);
57
+ process.exit(126); // 126 = command found but not executable / could not run
58
+ }
59
+ if (child.status === null) {
60
+ // No error object but no exit code either: the child was killed by a signal
61
+ // (child.signal set) or otherwise terminated abnormally. Report non-zero.
62
+ process.stderr.write(`[mixdog-shim] ${shimPath} terminated abnormally${child.signal ? ` (signal ${child.signal})` : ''}\n`);
63
+ process.exit(1);
64
+ }
65
+ process.exit(child.status);
@@ -6,7 +6,19 @@ const fs = require('fs');
6
6
  const { resolvePluginData } = require('./plugin-paths.cjs');
7
7
 
8
8
  const SERVICE = 'mixdog';
9
- const POWERSHELL_TIMEOUT_MS = Number(process.env.MIXDOG_KEYCHAIN_TIMEOUT_MS || 15000);
9
+ // Shared bound for every synchronous keychain backend (DPAPI/PowerShell on
10
+ // Windows, security(1) on macOS). A single env override keeps them consistent.
11
+ const KEYCHAIN_TIMEOUT_MS = Number(process.env.MIXDOG_KEYCHAIN_TIMEOUT_MS || 15000);
12
+ const POWERSHELL_TIMEOUT_MS = KEYCHAIN_TIMEOUT_MS;
13
+
14
+ // CommonJS module: cannot import the ESM src/shared/wsl.mjs, so inline an
15
+ // equivalent WSL check (process.platform reports 'linux' inside WSL).
16
+ function isWSL() {
17
+ if (process.platform !== 'linux') return false;
18
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true;
19
+ try { return /microsoft|wsl/i.test(fs.readFileSync('/proc/version', 'utf8')); }
20
+ catch { return false; }
21
+ }
10
22
 
11
23
  // ---------------------------------------------------------------------------
12
24
  // Helpers
@@ -81,19 +93,30 @@ function dpApiFile(account) {
81
93
  // darwin — security(1)
82
94
  // ---------------------------------------------------------------------------
83
95
 
96
+ // Bound security(1) calls so a stuck Keychain prompt (locked keychain, GUI
97
+ // unlock dialog with no display) cannot block hook/server callers forever —
98
+ // matching the Windows DPAPI and Linux keytar timeouts.
99
+ function darwinRun(args) {
100
+ const r = run('security', args, { timeout: KEYCHAIN_TIMEOUT_MS });
101
+ if (r.error && r.error.code === 'ETIMEDOUT') {
102
+ throw new Error(`[keychain] security command timed out after ${KEYCHAIN_TIMEOUT_MS}ms`);
103
+ }
104
+ return r;
105
+ }
106
+
84
107
  function darwinGet(account) {
85
- const r = run('security', ['find-generic-password', '-a', account, '-s', SERVICE, '-w']);
108
+ const r = darwinRun(['find-generic-password', '-a', account, '-s', SERVICE, '-w']);
86
109
  if (r.status !== 0) return null;
87
110
  return r.stdout.trimEnd();
88
111
  }
89
112
 
90
113
  function darwinSet(account, value) {
91
- const r = run('security', ['add-generic-password', '-a', account, '-s', SERVICE, '-w', value, '-U']);
114
+ const r = darwinRun(['add-generic-password', '-a', account, '-s', SERVICE, '-w', value, '-U']);
92
115
  if (r.status !== 0) throw new Error(`[keychain] security set failed: ${r.stderr || r.stdout}`);
93
116
  }
94
117
 
95
118
  function darwinDelete(account) {
96
- const r = run('security', ['delete-generic-password', '-a', account, '-s', SERVICE]);
119
+ const r = darwinRun(['delete-generic-password', '-a', account, '-s', SERVICE]);
97
120
  if (r.status !== 0) throw new Error(`[keychain] security delete failed: ${r.stderr || r.stdout}`);
98
121
  }
99
122
 
@@ -112,7 +135,11 @@ function loadKeytar() {
112
135
  throw new Error(
113
136
  '[keychain] keytar is not installed — run: npm install keytar\n' +
114
137
  ' Requires libsecret-dev (Debian/Ubuntu) or libsecret (other distros).\n' +
115
- ' Cannot access credentials on this platform without it.'
138
+ ' Cannot access credentials on this platform without it.' +
139
+ (isWSL()
140
+ ? '\n Detected WSL: there is usually no Secret Service here; ' +
141
+ 'set the relevant MIXDOG_*/PROVIDER_API_KEY env var instead.'
142
+ : '')
116
143
  );
117
144
  }
118
145
  return _keytarMod;
@@ -29,6 +29,14 @@ const fs = require('fs');
29
29
  const DEFAULT_PLUGIN = 'mixdog';
30
30
  const DEFAULT_MARKETPLACE = 'trib-plugin';
31
31
 
32
+ // Claude config base — honours CLAUDE_CONFIG_DIR when set, otherwise the
33
+ // real Claude Code default of ~/.claude (matches settings-loader.cjs,
34
+ // statusline, doctor, install). Only ADDS the env override; never relocates
35
+ // the default base.
36
+ function claudeConfigBase() {
37
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
38
+ }
39
+
32
40
  function readPluginManifestName(root) {
33
41
  try {
34
42
  const manifest = JSON.parse(fs.readFileSync(path.join(root, '.claude-plugin', 'plugin.json'), 'utf8'));
@@ -46,14 +54,14 @@ function resolvePluginData() {
46
54
  if (/^\d+\.\d+\.\d+/.test(dirName)) {
47
55
  const pluginName = path.basename(path.join(root, '..'));
48
56
  const marketplace = path.basename(path.join(root, '..', '..'));
49
- return path.join(os.homedir(), '.claude', 'plugins', 'data', `${pluginName}-${marketplace}`);
57
+ return path.join(claudeConfigBase(), 'plugins', 'data', `${pluginName}-${marketplace}`);
50
58
  }
51
59
  // Marketplace layout: .../marketplaces/{marketplace}/
52
60
  // The root dir itself is the marketplace. Plugin name lives in the
53
61
  // manifest; fall back to DEFAULT_PLUGIN when it's unreadable.
54
62
  const marketplace = dirName;
55
63
  const pluginName = readPluginManifestName(root);
56
- return path.join(os.homedir(), '.claude', 'plugins', 'data', `${pluginName}-${marketplace}`);
64
+ return path.join(claudeConfigBase(), 'plugins', 'data', `${pluginName}-${marketplace}`);
57
65
  }
58
66
  throw new Error('[plugin-paths] CLAUDE_PLUGIN_DATA and CLAUDE_PLUGIN_ROOT are both unset — cannot resolve plugin data dir outside of Claude Code.');
59
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.7.15",
3
+ "version": "0.7.17",
4
4
  "description": "Claude Code all-in-one bridge plugin: role-based bridge workers, continuous memory, and syntax-aware code editing.",
5
5
  "author": "mixdog contributors <dev@tribgames.com>",
6
6
  "license": "MIT",
@@ -157,6 +157,47 @@ function releaseLaunchLock() {
157
157
  try { unlinkSync(LAUNCH_LOCK_PATH); } catch {}
158
158
  }
159
159
 
160
+ // POSIX listener-pid resolution with graceful degradation across minimal
161
+ // images. Tries lsof → ss → fuser; returns the first listener pid found, or
162
+ // null if none of the tools exist / none report a listener. A missing tool
163
+ // throws ENOENT from execSync, which we swallow and move to the next probe so
164
+ // a bare container without lsof does not wedge port reclaim.
165
+ function resolveListenerPidPosix(trace) {
166
+ // lsof: -ti prints bare pids, -sTCP:LISTEN restricts to the listening socket.
167
+ try {
168
+ const out = execSync(`lsof -ti :${PORT} -sTCP:LISTEN`, { encoding: 'utf8', timeout: 3000 }).trim();
169
+ const n = parseInt(out.split('\n')[0], 10);
170
+ if (Number.isFinite(n) && n > 0) return n;
171
+ } catch (e) {
172
+ if (trace) trace(`reclaimPort: lsof unavailable/failed (${e?.code || e?.message || e}) → trying ss`);
173
+ }
174
+ // ss (iproute2): -H no header, -l listening, -t tcp, -n numeric, -p process.
175
+ // The pid lives in the trailing `users:(("name",pid=1234,fd=7))` field.
176
+ try {
177
+ const out = execSync(`ss -Hltnp 'sport = :${PORT}'`, { encoding: 'utf8', timeout: 3000 }).trim();
178
+ const m = out.match(/pid=(\d+)/);
179
+ if (m) {
180
+ const n = parseInt(m[1], 10);
181
+ if (Number.isFinite(n) && n > 0) return n;
182
+ }
183
+ } catch (e) {
184
+ if (trace) trace(`reclaimPort: ss unavailable/failed (${e?.code || e?.message || e}) → trying fuser`);
185
+ }
186
+ // fuser: prints the pids holding the tcp port to stdout (diagnostics on
187
+ // stderr). Take the first numeric token.
188
+ try {
189
+ const out = execSync(`fuser ${PORT}/tcp 2>/dev/null`, { encoding: 'utf8', timeout: 3000 }).trim();
190
+ const m = out.match(/\d+/);
191
+ if (m) {
192
+ const n = parseInt(m[0], 10);
193
+ if (Number.isFinite(n) && n > 0) return n;
194
+ }
195
+ } catch (e) {
196
+ if (trace) trace(`reclaimPort: fuser unavailable/failed (${e?.code || e?.message || e}) → no listener pid resolved`);
197
+ }
198
+ return null;
199
+ }
200
+
160
201
  // Reclaim PORT from a version-mismatched setup-server: resolve the LISTENER
161
202
  // pid, kill it (tree on win32, process-group on unix), wait for the port to
162
203
  // free, then spawn the current-version server window-free. Throws LaunchError
@@ -179,9 +220,12 @@ async function reclaimPort(pluginRoot, pluginData, remoteRoot, trace) {
179
220
  const n = parseInt(out, 10);
180
221
  if (Number.isFinite(n) && n > 0) stalePid = n;
181
222
  } else {
182
- const out = execSync(`lsof -ti :${PORT} -sTCP:LISTEN`, { encoding: 'utf8', timeout: 3000 }).trim();
183
- const n = parseInt(out.split('\n')[0], 10);
184
- if (Number.isFinite(n) && n > 0) stalePid = n;
223
+ // Resolve the LISTENER pid. lsof is the primary tool, but minimal
224
+ // Linux/WSL/container images often ship without it — falling back to
225
+ // ss (iproute2) then fuser keeps port reclaim working there instead
226
+ // of wedging on a missing-binary ENOENT. Each probe is independent and
227
+ // best-effort; the first that yields a pid wins.
228
+ stalePid = resolveListenerPidPosix(trace);
185
229
  }
186
230
  } catch (e) { pidResolveError = e; }
187
231
  if (stalePid !== null) {
@@ -1,16 +1,34 @@
1
1
  // locate-claude.mjs — shared PATH scan for the Claude Code CLI executable.
2
2
 
3
3
  import { execFileSync } from 'node:child_process';
4
- import { existsSync } from 'node:fs';
4
+ import { existsSync, statSync, accessSync, constants as fsConstants } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
 
7
+ // POSIX: a PATH match is only a real candidate if it is a regular file the
8
+ // current user may EXECUTE. existsSync alone would return a non-exec data file
9
+ // (e.g. a `claude` config/dir or a 0644 stray) and the caller would then fail
10
+ // to spawn it. On win32 executability is governed by PATHEXT, not a mode bit,
11
+ // so existence is the right test there.
12
+ function isUsable(p) {
13
+ const win32 = process.platform === 'win32';
14
+ try {
15
+ if (!existsSync(p)) return false;
16
+ if (win32) return true;
17
+ if (!statSync(p).isFile()) return false;
18
+ accessSync(p, fsConstants.X_OK);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
7
25
  export function resolveClaudeExecutable() {
8
26
  const win32 = process.platform === 'win32';
9
27
  try {
10
28
  const cmd = win32 ? 'where' : 'which';
11
29
  const out = execFileSync(cmd, ['claude'], { encoding: 'utf8', windowsHide: true }).trim();
12
30
  const first = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
13
- if (first && existsSync(first)) return first;
31
+ if (first && isUsable(first)) return first;
14
32
  } catch { /* PATH scan */ }
15
33
 
16
34
  const pathSep = win32 ? ';' : ':';
@@ -30,9 +48,9 @@ export function resolveClaudeExecutable() {
30
48
  if (existsSync(bare)) return bare;
31
49
  } else {
32
50
  const candidate = join(dir, base);
33
- if (existsSync(candidate)) return candidate;
51
+ if (isUsable(candidate)) return candidate;
34
52
  }
35
53
  }
36
54
  }
37
55
  return null;
38
- }
56
+ }
@@ -19,6 +19,7 @@ import { readSection, writeSection, updateSection, saveSecret, deleteSecret, has
19
19
  import { applyDefaults as applyChannelsDefaults } from '../src/channels/lib/config.mjs';
20
20
  import { validateCronExpression } from '../src/channels/lib/scheduler.mjs';
21
21
  import { mergeAgentConfig, mergeMemoryConfig, mergeSearchConfig, mergeConfig, mergeEndpointConfig, mergeWebhookEndpointConfig } from './config-merge.mjs';
22
+ import { isWSL } from '../src/shared/wsl.mjs';
22
23
 
23
24
  // C2 — Origin/Referer guard for mutating routes.
24
25
  // Returns true when the request is safe to handle (same-origin loopback UI,
@@ -1465,6 +1466,27 @@ async function openAppWindowSequence() {
1465
1466
  return { ...macResult, method: 'macOS open', attempts: [macAttempt] };
1466
1467
  }
1467
1468
 
1469
+ // WSL: process.platform is 'linux' but xdg-open targets a (usually absent)
1470
+ // Linux GUI. Reach the Windows-HOST browser instead, mirroring open-url.mjs:
1471
+ // prefer wslview (wslu), then PowerShell Start-Process (cmd.exe is avoided —
1472
+ // see the note below). Each is a sync spawn through trySyncOpen so a missing
1473
+ // opener advances to the next.
1474
+ if (isWSL()) {
1475
+ if (trySyncOpen('wslview', 'wslview', [appUrl], attempts)) {
1476
+ return { ok: true, method: 'wslview', attempts };
1477
+ }
1478
+ // cmd.exe `start` is intentionally NOT used: cmd re-parses URL
1479
+ // metacharacters, so a query-string `&` is treated as a command separator
1480
+ // and the URL truncates. Start-Process takes the URL as a single argument
1481
+ // and does not reparse `&`; quotePowerShellString single-quotes the URL
1482
+ // (doubling embedded quotes) so it is injection-safe.
1483
+ const wslPsCommand = `Start-Process -FilePath ${quotePowerShellString(appUrl)}`;
1484
+ if (trySyncOpen('PowerShell Start-Process', 'powershell.exe', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', wslPsCommand], attempts)) {
1485
+ return { ok: true, method: 'PowerShell Start-Process', attempts };
1486
+ }
1487
+ return { ok: false, error: 'Failed to open Windows browser from WSL', attempts };
1488
+ }
1489
+
1468
1490
  const xdgResult = await new Promise(resolve => {
1469
1491
  const child = spawn('xdg-open', [appUrl], { stdio: 'ignore' });
1470
1492
  let timer = setTimeout(() => {
package/setup/setup.html CHANGED
@@ -1310,7 +1310,7 @@
1310
1310
  <span style="font-size:11px;color:var(--text-3)">Description</span>
1311
1311
  <span class="file-open" onclick="wfOpenDescription()">Open</span>
1312
1312
  </div>
1313
- <textarea id="wf-description" class="r-input" placeholder="Free-form workflow description (markdown)." style="width:100%;height:150px;resize:vertical;font-family:inherit;padding:10px;line-height:1.5"></textarea>
1313
+ <textarea id="wf-description" class="r-input" placeholder="Free-form workflow description (markdown)." style="width:100%;height:330px;resize:vertical;font-family:inherit;padding:10px;line-height:1.5"></textarea>
1314
1314
  </div>
1315
1315
  <div class="r r-opt">
1316
1316
  <div class="opt-body" style="width:100%">
@@ -1,6 +1,7 @@
1
1
  import { homedir } from 'os';
2
2
  import { isAbsolute, relative, resolve } from 'path';
3
3
  import { realpathSync } from 'node:fs';
4
+ import { isWSL } from '../../../../shared/wsl.mjs';
4
5
 
5
6
  // Restore the on-disk casing of a path (win32 only). rg relativizes candidate
6
7
  // paths against its process cwd with a CASE-SENSITIVE prefix strip before
@@ -28,6 +29,21 @@ export function posixPathToWindowsPath(posixPath) {
28
29
  return posixPath;
29
30
  }
30
31
 
32
+ // Reverse of posixPathToWindowsPath, for WSL: a Windows-HOST drive path that
33
+ // arrives from the host (e.g. `C:\Users\x` or `C:/Users/x`) is unreachable as
34
+ // a literal under WSL — the drive surfaces at `/mnt/<letter>/...`. Map
35
+ // `<drive>:` → `/mnt/<lowercase-drive>` and flip backslashes to slashes. Only
36
+ // drive-letter paths are touched; anything else is returned unchanged.
37
+ export function windowsPathToPosixPath(winPath) {
38
+ if (typeof winPath !== 'string') return winPath;
39
+ const m = winPath.match(/^([a-zA-Z]):[\\/](.*)$/);
40
+ if (m) return `/mnt/${m[1].toLowerCase()}/${m[2].replace(/\\/g, '/')}`;
41
+ // Bare `C:` with no separator → drive root.
42
+ const root = winPath.match(/^([a-zA-Z]):$/);
43
+ if (root) return `/mnt/${root[1].toLowerCase()}/`;
44
+ return winPath;
45
+ }
46
+
31
47
  export function normalizeInputPath(p) {
32
48
  if (typeof p !== 'string') return p;
33
49
  // Trim leading/trailing whitespace — LLMs intermittently emit paths with
@@ -49,6 +65,13 @@ export function normalizeInputPath(p) {
49
65
  if (looksPosixDrive || looksCygdrive || looksWsl || looksUnc) {
50
66
  out = posixPathToWindowsPath(out);
51
67
  }
68
+ } else if (isWSL()) {
69
+ // Reverse direction: a Windows-style drive path from the host maps to
70
+ // the /mnt/<letter> mount. Native-Linux behavior is untouched (gated
71
+ // on isWSL()); a path already in /mnt/... POSIX form is left as-is.
72
+ if (/^[a-zA-Z]:([\\/]|$)/.test(out)) {
73
+ out = windowsPathToPosixPath(out);
74
+ }
52
75
  }
53
76
  try { out = out.normalize('NFC'); } catch {}
54
77
  return out;
@@ -712,7 +712,11 @@ export function adoptForegroundShellJob({ command, cwd, pid, timeoutMs, mergeStd
712
712
  }
713
713
 
714
714
  function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, shellArg, shellType, clientHostPid }) {
715
- if (process.platform === 'win32' && isPowerShellShell(shell, shellType)) {
715
+ // Route ANY PowerShell shell to the PS wrapper, regardless of platform.
716
+ // Gating on win32 sent shell:'powershell' on macOS/Linux down the POSIX
717
+ // path below, which spawns `pwsh <wrapper>.sh` — pwsh then tries to run a
718
+ // bash script. isPowerShellShell already matches pwsh/powershell by stem.
719
+ if (isPowerShellShell(shell, shellType)) {
716
720
  return startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, clientHostPid });
717
721
  }
718
722
 
@@ -738,9 +742,11 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
738
742
  // only the setTimeout below enforced; a mixdog restart between spawn
739
743
  // and deadline would orphan the runaway. --preserve-status keeps the
740
744
  // user command's exit code on success; on timeout the wrapper exits 124.
741
- // `timeout` ships with GNU coreutils on Linux and brew coreutils on
742
- // macOS; absent platforms fall through to the inner
743
- // command (the parent setTimeout still calls refreshShellJob to clean up).
745
+ // `timeout` ships with GNU coreutils on Linux. On macOS, Homebrew
746
+ // coreutils installs it as `gtimeout` (the un-prefixed name is NOT created
747
+ // by default), so the wrapper picks `timeout` if present else `gtimeout`.
748
+ // When neither exists it falls through to the inner command (the parent
749
+ // setTimeout still calls refreshShellJob to clean up).
744
750
  const userCmdQuoted = shellQuoteSingle(command);
745
751
  // P2 fix: invoke the resolved shell (not bash -c) so zsh / dash /
746
752
  // alternate shells run snapshot-aware commands correctly. Drop
@@ -762,7 +768,7 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
762
768
  // status for processes that actually exited 0. `rm -- "$0"` removes
763
769
  // the staged wrapper .cmd.sh after donePath is published so a host
764
770
  // crash before this point still leaves the file for the sweep to GC.
765
- const wrapped = `{ if command -v timeout >/dev/null 2>&1; then touch ${shellQuoteSingle(enforcedPath)}; timeout ${timeoutSeconds} ${innerShellQ} ${innerArgQ} ${userCmdQuoted}; else ${innerShellQ} ${innerArgQ} ${userCmdQuoted}; fi; rc=$?; printf '%s' "$rc" > ${shellQuoteSingle(exitPath)}; touch ${shellQuoteSingle(donePath)}; rm -- "$0" 2>/dev/null; exit $rc; }`;
771
+ const wrapped = `{ if command -v timeout >/dev/null 2>&1; then _to=timeout; elif command -v gtimeout >/dev/null 2>&1; then _to=gtimeout; else _to=; fi; if [ -n "$_to" ]; then touch ${shellQuoteSingle(enforcedPath)}; "$_to" ${timeoutSeconds} ${innerShellQ} ${innerArgQ} ${userCmdQuoted}; else ${innerShellQ} ${innerArgQ} ${userCmdQuoted}; fi; rc=$?; printf '%s' "$rc" > ${shellQuoteSingle(exitPath)}; touch ${shellQuoteSingle(donePath)}; rm -- "$0" 2>/dev/null; exit $rc; }`;
766
772
  // Stage the wrapped command to a .sh and let the script open its own
767
773
  // output files via `exec > … 2> …`. The parent does NOT pass file
768
774
  // descriptors via stdio inheritance (`stdio: 'ignore'` for all three).
@@ -841,9 +847,24 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
841
847
  '$code = 1',
842
848
  'try {',
843
849
  " $argList = @('-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', $encoded)",
844
- ' $p = Start-Process -FilePath $exe -ArgumentList $argList -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -WindowStyle Hidden -PassThru',
850
+ // -WindowStyle is a Windows-only Start-Process parameter; pwsh on
851
+ // macOS/Linux throws "not supported on this platform". Add it only on win32.
852
+ ' $spArgs = @{ FilePath = $exe; ArgumentList = $argList; RedirectStandardOutput = $stdoutPath; RedirectStandardError = $stderrPath; PassThru = $true }',
853
+ ' if ($IsWindows -or $null -eq $IsWindows) { $spArgs[\'WindowStyle\'] = \'Hidden\' }',
854
+ ' $p = Start-Process @spArgs',
845
855
  ' if ($timeoutMs -gt 0 -and -not $p.WaitForExit($timeoutMs)) {',
846
- ' try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {}',
856
+ // Kill the whole process TREE, not just the direct child: Start-Process
857
+ // launches an intermediate pwsh that spawns the user command, so
858
+ // Stop-Process on $p.Id alone orphans the grandchildren. On Windows use
859
+ // `taskkill /T /F` (tree-terminate); on macOS/Linux pwsh, fall back to
860
+ // Stop-Process (no taskkill there).
861
+ ' try {',
862
+ ' if ($IsWindows -or $null -eq $IsWindows) {',
863
+ ' & taskkill.exe /pid $p.Id /t /f 2>$null | Out-Null',
864
+ ' } else {',
865
+ ' try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {}',
866
+ ' }',
867
+ ' } catch {}',
847
868
  ' $code = 124',
848
869
  ' } else {',
849
870
  ' try { $p.WaitForExit() } catch {}',
@@ -875,9 +896,14 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
875
896
  }
876
897
 
877
898
  const shellStem = basename(String(shell || '')).toLowerCase().replace(/\.exe$/, '');
878
- const wrapperArgs = shellStem === 'powershell'
879
- ? ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', wrappedTempPath]
880
- : ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-File', wrappedTempPath];
899
+ // `-WindowStyle Hidden` is a Windows-only CLI switch; pwsh on macOS/Linux
900
+ // rejects it. `-ExecutionPolicy` likewise only applies to Windows
901
+ // PowerShell. Build args per-platform so cross-OS pwsh background jobs run.
902
+ const isWin = process.platform === 'win32';
903
+ const wrapperArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
904
+ if (isWin) wrapperArgs.push('-WindowStyle', 'Hidden');
905
+ if (isWin && shellStem === 'powershell') wrapperArgs.push('-ExecutionPolicy', 'Bypass');
906
+ wrapperArgs.push('-File', wrappedTempPath);
881
907
  // Spawn the staged wrapper directly. detached MUST be false on Windows:
882
908
  // a native pwsh launched with detached:true + stdio:'ignore' exits
883
909
  // immediately without running -File (verified — the detached child dies
@@ -19,10 +19,15 @@ function shellTypeFor(shell) {
19
19
 
20
20
  function shellSpec(shell, shellType = shellTypeFor(shell)) {
21
21
  if (shellType === 'powershell') {
22
+ // `-WindowStyle Hidden` is a Windows-only switch. PowerShell Core
23
+ // (pwsh) on macOS/Linux rejects it, so only append it on win32.
24
+ const psArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
25
+ if (process.platform === 'win32') psArgs.push('-WindowStyle', 'Hidden');
26
+ psArgs.push('-Command');
22
27
  return {
23
28
  shell,
24
29
  shellArg: '-Command',
25
- shellArgs: ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command'],
30
+ shellArgs: psArgs,
26
31
  shellType,
27
32
  };
28
33
  }
@@ -60,8 +65,10 @@ function resolveWindowsPowerShell() {
60
65
 
61
66
  export function resolveShell() {
62
67
  if (_resolvedShell) return _resolvedShell;
63
- const isWindows = process.platform === 'win32'
64
- || !!(process.env.WINDIR || process.env.SystemRoot);
68
+ // Gate on the actual platform, NOT WINDIR/SystemRoot env presence: under
69
+ // WSL those vars can be inherited via interop while process.platform is
70
+ // 'linux', and WSL must resolve /bin/sh — not Windows PowerShell.
71
+ const isWindows = process.platform === 'win32';
65
72
  if (!isWindows) {
66
73
  _resolvedShell = shellSpec('/bin/sh', 'posix');
67
74
  return _resolvedShell;
@@ -76,7 +83,9 @@ export function resolveShell() {
76
83
  }
77
84
 
78
85
  function _isWindows() {
79
- return process.platform === 'win32' || !!(process.env.WINDIR || process.env.SystemRoot);
86
+ // Real-platform check only (see resolveShell): env presence would make
87
+ // WSL (process.platform 'linux') mis-resolve to Windows Git Bash.
88
+ return process.platform === 'win32';
80
89
  }
81
90
 
82
91
  // Resolve Git Bash on Windows. Strategy (invariant-based, no silent fallback):
@@ -118,7 +118,17 @@ export function ensureGraphBinary(dataDir) {
118
118
  const pkey = platformKey();
119
119
  const asset = manifest.assets?.[pkey];
120
120
  if (!asset || !asset.url || !asset.sha256) {
121
- throw new Error(`[graph-fetcher] no prebuilt mixdog-graph asset for platform ${pkey}`);
121
+ // Unsupported platform/arch (e.g. win32-arm64): the manifest has no
122
+ // downloadable asset for this {os}-{arch}. The code graph has NO JS
123
+ // parsing fallback, so this is terminal — surface a single clear,
124
+ // actionable message instead of a cryptic crash downstream.
125
+ const supported = Object.keys(manifest.assets || {}).join(', ') || '(none)';
126
+ throw new Error(
127
+ `[graph-fetcher] no prebuilt mixdog-graph binary for platform ${pkey} `
128
+ + `(unsupported platform/arch — there is no JS parsing fallback). `
129
+ + `Supported platforms: ${supported}. `
130
+ + `Build it locally: cargo build --release in native/mixdog-graph.`,
131
+ );
122
132
  }
123
133
  const version = String(manifest.version || '0');
124
134
  const dir = graphBinDir(dataDir);
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.7.15",
2
+ "version": "0.7.17",
3
3
  "_comment": "Rewritten by .github/workflows/graph-release.yml on each tagged release. assets maps platformKey (process.platform-process.arch, e.g. win32-x64, linux-x64, darwin-arm64) to { url, sha256 } of the mixdog-graph binary on the GitHub release. A local cargo build under native/mixdog-graph/target/release always takes precedence at runtime. (v0.5.236 entries were filled manually after CI's commit step hit detached HEAD; the workflow now checks out ref: main so future releases self-update.)",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-graph-darwin-arm64",
6
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.17/mixdog-graph-darwin-arm64",
7
7
  "sha256": "53f45f8373000fb55ccb49f9b03af0275ca2a663f924b68ee8ded4535e14445a"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-graph-darwin-x64",
10
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.17/mixdog-graph-darwin-x64",
11
11
  "sha256": "347dc5e1b39dfc33ae07e01319b085c1f1ee775a70be1ac09f7b0d2954b36591"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-graph-linux-arm64",
14
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.17/mixdog-graph-linux-arm64",
15
15
  "sha256": "7648be2c4bc89bef837b4843639a9ab38c382ba103518ad52d4439587521542f"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-graph-linux-x64",
18
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.17/mixdog-graph-linux-x64",
19
19
  "sha256": "6e5a1b548c69c3a51170eb11c769985be7ca7029ede2597b19304c8dc106eded"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-graph-win32-x64.exe",
23
- "sha256": "e2933fcf6ad0d7ab517c0beda4f8c7258638602bca73170dbda2cd8311c8637f"
22
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.17/mixdog-graph-win32-x64.exe",
23
+ "sha256": "cd263b6bfd3d7371857f498fb53d1d67b4b4cc06dd4d9855408337390a56cee2"
24
24
  }
25
25
  }
26
26
  }
@@ -107,7 +107,17 @@ export function ensurePatchBinary(dataDir) {
107
107
  const pkey = platformKey();
108
108
  const asset = manifest.assets?.[pkey];
109
109
  if (!asset || !asset.url || !asset.sha256) {
110
- throw new Error(`[patch-fetcher] no prebuilt mixdog-patch asset for platform ${pkey}`);
110
+ // Unsupported platform/arch (e.g. win32-arm64): the manifest has no
111
+ // downloadable asset for this {os}-{arch}. apply_patch is native-only
112
+ // (no JS apply fallback), so this is terminal — surface a single clear,
113
+ // actionable message instead of a cryptic crash downstream.
114
+ const supported = Object.keys(manifest.assets || {}).join(', ') || '(none)';
115
+ throw new Error(
116
+ `[patch-fetcher] no prebuilt mixdog-patch binary for platform ${pkey} `
117
+ + `(unsupported platform/arch — apply_patch is native-only, no JS apply fallback). `
118
+ + `Supported platforms: ${supported}. `
119
+ + `Build it locally: cargo build --release in native/mixdog-patch.`,
120
+ );
111
121
  }
112
122
  const version = String(manifest.version || '0');
113
123
  const dir = patchBinDir(dataDir);
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.7.15",
2
+ "version": "0.7.17",
3
3
  "_comment": "Rewritten by .github/workflows/patch-release.yml on each tagged release. assets maps platformKey (process.platform-process.arch, e.g. win32-x64, linux-x64, darwin-arm64) to { url, sha256 } of the mixdog-patch binary on the GitHub release. A local cargo build under native/mixdog-patch/target/release always takes precedence; otherwise the binary is fetched per this manifest into the data dir (apply is native-only — no JS apply engine).",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-patch-darwin-arm64",
6
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.17/mixdog-patch-darwin-arm64",
7
7
  "sha256": "836a0b60a443b0a6a8c1bbe24d15a79ed70ee92c2f0fbc05374c4e9ed2536415"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-patch-darwin-x64",
10
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.17/mixdog-patch-darwin-x64",
11
11
  "sha256": "cab10c4e1e8b72d3958241dffdff764712ed74f4861d105bafa8258961215c98"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-patch-linux-arm64",
14
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.17/mixdog-patch-linux-arm64",
15
15
  "sha256": "a90c32ce3417a7d853f2723f82f3613cf2cd030fe885cf710cfc9f8e4b193264"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-patch-linux-x64",
18
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.17/mixdog-patch-linux-x64",
19
19
  "sha256": "0fea40ab98acd35bfb47515756024d1882a2abbaddce8a0b51642d20ac405577"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.15/mixdog-patch-win32-x64.exe",
23
- "sha256": "bc57bc029667bd62ce3798b4bfe23a42e4278148a6bbb778236769b3713397d8"
22
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.17/mixdog-patch-win32-x64.exe",
23
+ "sha256": "af92ae25c2434f9b2999df0186e11f6a8802d70bcbc4b91290cfd92f3e143466"
24
24
  }
25
25
  }
26
26
  }
@@ -130,30 +130,65 @@ function runScript(name, scriptName, onResult) {
130
130
  return;
131
131
  }
132
132
  const ext = extname(scriptName).toLowerCase();
133
- const cmd = ext === ".py" ? "python3" : "node";
134
- const proc = spawn(cmd, [scriptPath], {
135
- timeout: 3e4,
136
- env: { ...process.env },
137
- windowsHide: true
138
- });
139
- let stdout = "";
140
- let stderr = "";
141
- if (proc.stdout) proc.stdout.on("data", (d) => {
142
- stdout += d;
143
- });
144
- if (proc.stderr) proc.stderr.on("data", (d) => {
145
- stderr += d;
146
- });
147
- proc.on("close", (code) => {
148
- if (code !== 0) {
149
- logEvent(`${name}: script exited ${code}: ${stderr.substring(0, 500)}`);
150
- }
151
- onResult(stdout.substring(0, 2e3), code);
152
- });
153
- proc.on("error", (err) => {
154
- logEvent(`${name}: script spawn error: ${err.message}`);
155
- onResult("", null);
156
- });
133
+ // Pick interpreter candidates. `python3` does not exist on a default
134
+ // Windows install — the Python launcher `py` (and often `python`) does —
135
+ // so on win32 try py → python → python3, falling through on ENOENT.
136
+ // POSIX keeps python3 → python.
137
+ let candidates;
138
+ if (ext === ".py") {
139
+ candidates = process.platform === "win32"
140
+ ? ["py", "python", "python3"]
141
+ : ["python3", "python"];
142
+ } else {
143
+ candidates = ["node"];
144
+ }
145
+ // onResult MUST fire exactly once across the whole candidate chain. A failed
146
+ // ENOENT spawn emits BOTH 'error' (→ we advance) AND 'close', so guard the
147
+ // final callback at the chain level and mark each attempt that advanced so
148
+ // its own 'close' is ignored.
149
+ let resultSent = false;
150
+ const finish = (out, code) => {
151
+ if (resultSent) return;
152
+ resultSent = true;
153
+ onResult(out, code);
154
+ };
155
+ const trySpawn = (idx) => {
156
+ const cmd = candidates[idx];
157
+ let advanced = false;
158
+ const proc = spawn(cmd, [scriptPath], {
159
+ timeout: 3e4,
160
+ env: { ...process.env },
161
+ windowsHide: true
162
+ });
163
+ let stdout = "";
164
+ let stderr = "";
165
+ if (proc.stdout) proc.stdout.on("data", (d) => {
166
+ stdout += d;
167
+ });
168
+ if (proc.stderr) proc.stderr.on("data", (d) => {
169
+ stderr += d;
170
+ });
171
+ proc.on("close", (code) => {
172
+ // This attempt ENOENT'd and handed off to the next candidate — its
173
+ // 'close' is spurious and must not report a result.
174
+ if (advanced) return;
175
+ if (code !== 0) {
176
+ logEvent(`${name}: script exited ${code}: ${stderr.substring(0, 500)}`);
177
+ }
178
+ finish(stdout.substring(0, 2e3), code);
179
+ });
180
+ proc.on("error", (err) => {
181
+ // Interpreter not found → try the next candidate before giving up.
182
+ if (err.code === "ENOENT" && idx + 1 < candidates.length) {
183
+ advanced = true;
184
+ trySpawn(idx + 1);
185
+ return;
186
+ }
187
+ logEvent(`${name}: script spawn error: ${err.message}`);
188
+ finish("", null);
189
+ });
190
+ };
191
+ trySpawn(0);
157
192
  }
158
193
  export {
159
194
  applyParser,
@@ -35,7 +35,14 @@ const {
35
35
  // net.createServer().listen() accepts both transparently.
36
36
  const PIPE_PATH = moduleRequire('../../../lib/hook-pipe-path.cjs')()
37
37
 
38
- const RUNTIME_ROOT = join(tmpdir(), 'mixdog')
38
+ // Honor MIXDOG_RUNTIME_ROOT consistently with runtime-paths.mjs (the consumer
39
+ // of these tool-exec signals): when the override is set, the signal PRODUCER
40
+ // here must write into the same root the channels worker watches, or signals
41
+ // are silently dropped. Default stays tmpdir()/mixdog so non-override installs
42
+ // are unchanged.
43
+ const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
44
+ ? pathResolve(process.env.MIXDOG_RUNTIME_ROOT)
45
+ : join(tmpdir(), 'mixdog')
39
46
  const SIGNAL_CONSUMER_MARKER = join(RUNTIME_ROOT, '.tool-exec-consumer')
40
47
  const SUBAGENT_SIGNAL_CONSUMER_MARKER = join(RUNTIME_ROOT, '.tool-exec-subagent-consumer')
41
48
  const SIGNAL_RE_GENERIC = /^tool-exec-\d+-[0-9a-f]+\.signal$/
@@ -341,13 +341,29 @@ export async function ensureRuntime(dataDir) {
341
341
  const pkey = platformKey()
342
342
  const asset = manifest.assets?.[pkey]
343
343
  if (!asset) {
344
+ // Platform/arch absent from the manifest entirely (e.g. win32-arm64).
345
+ // The memory PG runtime cannot start here; fail with a single clear,
346
+ // actionable message. The memory worker's init().catch reports this as
347
+ // degraded and the rest of mixdog (agent, tools) keeps working without
348
+ // memory.
349
+ const supported = Object.keys(manifest.assets || {})
350
+ .filter((k) => isUsableAsset(manifest.assets[k]))
351
+ .join(', ') || '(none)'
344
352
  throw new Error(
345
- `[runtime-fetcher] no asset for platform ${pkey} in manifest. ` +
346
- `Available: ${Object.keys(manifest.assets || {}).join(', ')}`
353
+ `[runtime-fetcher] memory runtime not available on ${pkey}: ` +
354
+ `no runtime asset for this platform/arch in the manifest. ` +
355
+ `Supported: ${supported}. ` +
356
+ `Memory is disabled on this platform; the rest of mixdog continues to work.`
347
357
  )
348
358
  }
349
359
  if (!isUsableAsset(asset)) {
350
- throw new Error(`unsupported platform/arch: no validated runtime asset for ${pkey}`)
360
+ // Platform/arch present but explicitly marked unsupported or carrying a
361
+ // placeholder/TBD payload (e.g. linux-arm64). Same graceful-degrade path.
362
+ throw new Error(
363
+ `[runtime-fetcher] memory runtime not available on ${pkey}: ` +
364
+ `this platform/arch is marked unsupported (no validated runtime asset). ` +
365
+ `Memory is disabled on this platform; the rest of mixdog continues to work.`
366
+ )
351
367
  }
352
368
 
353
369
  const { url, sha256, size } = asset
@@ -6,6 +6,7 @@ import { Agent, fetch as undiciFetch } from 'undici'
6
6
  import { JSDOM } from 'jsdom'
7
7
  import puppeteer from 'puppeteer-core'
8
8
  import { Readability } from '@mozilla/readability'
9
+ import { isWSL } from '../../shared/wsl.mjs'
9
10
 
10
11
 
11
12
  const PKG_VERSION = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version } catch { return '0.0.1' } })()
@@ -38,13 +39,24 @@ const COMMON_BROWSER_PATHS = (() => {
38
39
  ].filter(Boolean)
39
40
  }
40
41
  if (platform === 'linux') {
41
- return [
42
+ // Native-Linux Chromium/Chrome binaries first. The /mnt/c Windows .exe
43
+ // entries are reachable from WSL's filesystem but puppeteer-core CANNOT
44
+ // drive a Windows GUI browser as a Linux child process (CDP over a pipe to
45
+ // a Win32 binary launched from the Linux ABI does not work), so advertising
46
+ // puppeteer-available off a Windows .exe yields launch failures at runtime.
47
+ // Only offer the Windows .exe fallbacks on plain Linux (Wine/dual-mount
48
+ // edge cases), never under WSL.
49
+ const linuxNative = [
42
50
  '/usr/bin/google-chrome',
43
51
  '/usr/bin/google-chrome-stable',
44
52
  '/usr/bin/chromium',
45
53
  '/usr/bin/chromium-browser',
46
54
  '/snap/bin/chromium',
47
55
  '/usr/bin/microsoft-edge',
56
+ ]
57
+ if (isWSL()) return linuxNative
58
+ return [
59
+ ...linuxNative,
48
60
  '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
49
61
  '/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe',
50
62
  '/mnt/c/Program Files/Microsoft/Edge/Application/msedge.exe',
@@ -334,7 +334,26 @@ export function getAgentApiKey(provider) {
334
334
  * Never writes to mixdog-config.json.
335
335
  */
336
336
  export function saveSecret(account, value) {
337
- _setSecret(account, value)
337
+ try {
338
+ _setSecret(account, value)
339
+ } catch (err) {
340
+ // On WSL/headless Linux (and any host missing a usable keychain backend,
341
+ // e.g. keytar/libsecret not installed or no running Secret Service) the OS
342
+ // write throws a cryptic backend error. Surface an actionable message that
343
+ // points at the env-var read path the getters already honor, instead of
344
+ // silently writing the plaintext secret to mixdog-config.json.
345
+ const envKey = _envKey(account)
346
+ const agentMatch = String(account || '').match(/^agent\.([^.]+)\.apiKey$/)
347
+ const stdEnv = agentMatch ? AGENT_PROVIDER_ENV[agentMatch[1]] : null
348
+ const envHint = stdEnv ? `${stdEnv} (or ${envKey})` : envKey
349
+ const e = new Error(
350
+ `[config] could not save secret to the OS keychain: ${err && err.message ? err.message : err}\n` +
351
+ ` No usable keychain backend on this host (common on WSL / headless Linux without libsecret).\n` +
352
+ ` Set the ${envHint} environment variable instead — the runtime reads it directly.`
353
+ )
354
+ e.cause = err
355
+ throw e
356
+ }
338
357
  }
339
358
 
340
359
  export function deleteSecret(account) {
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'child_process';
2
+ import { isWSL } from './wsl.mjs';
2
3
 
3
4
  /**
4
5
  * Open a URL in the user's default browser. Best-effort and non-blocking —
@@ -11,27 +12,51 @@ import { spawn } from 'child_process';
11
12
  * treats the first quoted string as the WINDOW TITLE, so the URL is dropped
12
13
  * and nothing opens.
13
14
  * - macOS: `open <url>`.
15
+ * - WSL: xdg-open usually fails (no Linux GUI), so reach the Windows-HOST
16
+ * browser — prefer `wslview` (wslu), else `powershell.exe Start-Process`.
17
+ * cmd.exe `start` is deliberately NOT used: cmd re-parses URL metacharacters
18
+ * so a query-string `&` (e.g. `?a=1&b=2`) is treated as a command separator
19
+ * and the URL truncates. PowerShell's Start-Process takes the URL as one
20
+ * argument and does not reparse `&`; the URL is passed inside a single-quoted
21
+ * PowerShell literal (single quotes doubled) so `&`, spaces and quotes
22
+ * survive and no injection is possible. Falls back through the chain when an
23
+ * opener is missing.
14
24
  * - Linux/other: `xdg-open <url>`.
15
25
  */
16
26
  export function openInBrowser(url) {
17
27
  const u = String(url);
18
- let cmd;
19
- let args;
28
+ // Ordered [cmd, args] candidates. Every platform but WSL has exactly one;
29
+ // WSL has a fallback chain since the first opener may be absent.
30
+ let candidates;
20
31
  if (process.platform === 'win32') {
21
- cmd = 'rundll32';
22
- args = ['url.dll,FileProtocolHandler', u];
32
+ candidates = [['rundll32', ['url.dll,FileProtocolHandler', u]]];
23
33
  } else if (process.platform === 'darwin') {
24
- cmd = 'open';
25
- args = [u];
34
+ candidates = [['open', [u]]];
35
+ } else if (isWSL()) {
36
+ // Single-quoted PowerShell literal: double any embedded single quote.
37
+ // Everything else (incl. `&`) is literal inside a single-quoted string.
38
+ const psUrl = `'${u.replace(/'/g, "''")}'`;
39
+ candidates = [
40
+ ['wslview', [u]],
41
+ ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `Start-Process -FilePath ${psUrl}`]],
42
+ ];
26
43
  } else {
27
- cmd = 'xdg-open';
28
- args = [u];
44
+ candidates = [['xdg-open', [u]]];
29
45
  }
46
+ tryOpenCandidates(candidates, 0);
47
+ }
48
+
49
+ // Spawn candidates[index]; on spawn error (opener missing) advance to the next.
50
+ // Always non-blocking — the caller prints the URL, so exhausting the chain is
51
+ // never fatal.
52
+ function tryOpenCandidates(candidates, index) {
53
+ if (index >= candidates.length) return;
54
+ const [cmd, args] = candidates[index];
30
55
  try {
31
56
  const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true });
32
- child.on('error', () => { /* opener missing user uses the printed URL */ });
57
+ child.on('error', () => { tryOpenCandidates(candidates, index + 1); });
33
58
  child.unref();
34
59
  } catch {
35
- /* spawn threw synchronously — user opens the printed URL manually */
60
+ tryOpenCandidates(candidates, index + 1);
36
61
  }
37
62
  }
@@ -54,8 +54,20 @@ export function _safeProcessCwd() {
54
54
  function _normalizePlatformCwd(p) {
55
55
  if (!p || typeof p !== 'string') return p
56
56
  if (process.platform !== 'win32') return resolve(p)
57
- const m = p.match(/^[\/\\]([a-zA-Z])[\/\\](.*)$/)
58
- const native = m ? `${m[1].toUpperCase()}:\\${m[2].replace(/\//g, '\\')}` : p
57
+ // Map all three POSIX drive-prefix shapes to native Windows paths before
58
+ // resolution, aligned with path-utils.mjs posixPathToWindowsPath:
59
+ // /cygdrive/c/... (Cygwin), /mnt/c/... (WSL), /c/... (MSYS/Git-Bash) → C:\...
60
+ // Cygwin/WSL prefixes must be tested before the bare single-letter form
61
+ // because their slice offsets differ.
62
+ const cyg = p.match(/^\/cygdrive\/([a-zA-Z])\//)
63
+ const wsl = p.match(/^\/mnt\/([a-zA-Z])\//)
64
+ let native = p
65
+ if (cyg) native = `${cyg[1].toUpperCase()}:\\${p.slice(11).replace(/\//g, '\\')}`
66
+ else if (wsl) native = `${wsl[1].toUpperCase()}:\\${p.slice(7).replace(/\//g, '\\')}`
67
+ else {
68
+ const m = p.match(/^[\/\\]([a-zA-Z])[\/\\](.*)$/)
69
+ if (m) native = `${m[1].toUpperCase()}:\\${m[2].replace(/\//g, '\\')}`
70
+ }
59
71
  return resolve(native)
60
72
  }
61
73
 
@@ -0,0 +1,35 @@
1
+ // WSL (Windows Subsystem for Linux) detection.
2
+ //
3
+ // process.platform reports 'linux' inside WSL, so any caller that needs
4
+ // Windows-HOST behavior — opening the Windows default browser, reaching the
5
+ // Windows credential store, translating Windows drive paths — must branch on
6
+ // this explicitly rather than on process.platform alone. Plain-Linux behavior
7
+ // stays the default; this only flags the WSL special case.
8
+ //
9
+ // Detection order (cheapest / most reliable first):
10
+ // 1. WSL_DISTRO_NAME / WSL_INTEROP env vars (set by the WSL init for every
11
+ // interactive and most non-interactive sessions).
12
+ // 2. /proc/version containing "microsoft"/"wsl" (the kernel is a Microsoft
13
+ // build under WSL1/WSL2). Read once and memoized.
14
+ import { readFileSync } from 'node:fs';
15
+
16
+ let _isWSL;
17
+
18
+ export function isWSL() {
19
+ if (_isWSL !== undefined) return _isWSL;
20
+ if (process.platform !== 'linux') {
21
+ _isWSL = false;
22
+ return _isWSL;
23
+ }
24
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
25
+ _isWSL = true;
26
+ return _isWSL;
27
+ }
28
+ try {
29
+ const v = readFileSync('/proc/version', 'utf8').toLowerCase();
30
+ _isWSL = v.includes('microsoft') || v.includes('wsl');
31
+ } catch {
32
+ _isWSL = false;
33
+ }
34
+ return _isWSL;
35
+ }