Merge pull request #312 from RAIT-09/experimental/wsl-launch-env-overhaul

WSL: harden agent & terminal launch (--exec + login shell + argv) and forward env via WSLENV
This commit is contained in:
RAIT-09 2026-06-20 01:54:20 +09:00 committed by GitHub
commit ca860a3a28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1757 additions and 26 deletions

View file

@ -143,6 +143,11 @@ If you sync your vault (e.g., via Nextcloud, Syncthing, or iCloud) across machin
- Enable **Settings → Agent Client → Windows Subsystem for Linux → Enable WSL mode**
- Optionally specify your distribution in **WSL distribution**
**Using a Node version manager (nvm/fnm) inside WSL:**
- These tools usually add `node` to `PATH` only in **interactive** shells (e.g. from `~/.bashrc`), so the agent — launched non-interactively — may not see your nvm-managed `node` and can fall back to an old system `node` (or none).
- Fix: set **Node.js path** to the absolute path of your nvm `node` directory, e.g. `/home/<user>/.nvm/versions/node/<version>/bin`. The plugin then injects it into `PATH` for the agent.
- API keys: in WSL mode you can enter them directly in the agent's API key field — the plugin forwards them into WSL automatically (no need to put them in `~/.profile`).
### Agent works in Terminal but not in Obsidian
The PATH environment may differ between Terminal and Obsidian.

View file

@ -13,7 +13,7 @@ export default defineConfig([
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser: tsparser,
parserOptions: { project: "./tsconfig.json" },
parserOptions: { project: "./tsconfig.eslint.json" },
},
rules: {
// Preserve existing rules

1136
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,8 @@
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "vitest run",
"test:watch": "vitest",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"*.css\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"*.css\"",
@ -33,7 +35,8 @@
"tslib": "2.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.0.0",
"vitepress": "^1.6.4"
"vitepress": "^1.6.4",
"vitest": "^4.1.8"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",

View file

@ -21,6 +21,7 @@ import {
convertWindowsPathToWsl,
getEnhancedWindowsEnv,
prepareShellCommand,
buildWslEnv,
} from "../utils/platform";
import { resolveNodeDirectory } from "../utils/paths";
import {
@ -203,6 +204,20 @@ export class AcpClient {
baseEnv[config.apiKey.envVarName] = secretValue;
}
// In WSL mode, forward the configured env var NAMES into WSL via WSLENV
// (Windows env vars are otherwise invisible to the Linux agent process,
// so the plugin's API key field would have no effect in WSL). Built-in
// agents resolve the API key into baseEnv above — not into config.env —
// so its var name must be added explicitly, or the key would never cross
// into WSL. Must run AFTER the secret is injected into baseEnv. (#312)
if (Platform.isWin && this.plugin.settings.windowsWslMode) {
const wslEnvNames = Object.keys(config.env || {});
if (config.apiKey?.envVarName) {
wslEnvNames.push(config.apiKey.envVarName);
}
baseEnv = buildWslEnv(baseEnv, wslEnvNames);
}
this.logger.log(
"[AcpClient] Starting agent process in directory:",
config.workingDirectory,

View file

@ -3,7 +3,11 @@ import type AgentClientPlugin from "../plugin";
import { getLogger, Logger } from "../utils/logger";
import { Platform } from "obsidian";
import { resolveNodeDirectory } from "../utils/paths";
import { getEnhancedWindowsEnv, prepareShellCommand } from "../utils/platform";
import {
getEnhancedWindowsEnv,
prepareShellCommand,
buildWslEnv,
} from "../utils/platform";
/**
* Parameters for creating a terminal process.
@ -70,6 +74,15 @@ export class TerminalManager {
}
}
// In WSL mode, forward the tool-provided env vars into WSL via WSLENV
// (Windows env is otherwise not visible to the Linux process).
if (Platform.isWin && this.plugin.settings.windowsWslMode && params.env) {
env = buildWslEnv(
env,
params.env.map((e) => e.name),
);
}
// Handle command parsing
let command = params.command;
let args = params.args || [];
@ -97,9 +110,13 @@ export class TerminalManager {
cwd: params.cwd,
});
// Spawn the process
// Spawn the process.
// In WSL mode the working directory is applied inside the launcher
// (cd '<wslCwd>'); the wsl.exe process must NOT receive a Linux path as
// its Windows cwd (CreateProcess would fail), so omit cwd there.
const useWsl = Platform.isWin && this.plugin.settings.windowsWslMode;
const spawnOptions: SpawnOptions = {
cwd: params.cwd || undefined,
cwd: useWsl ? undefined : params.cwd || undefined,
env,
stdio: ["pipe", "pipe", "pipe"],
shell: needsShell,

View file

@ -174,6 +174,60 @@ export function clearWindowsPathCache(): void {
cachedFullPath = null;
}
/**
* Forward selected environment variables into WSL via WSLENV.
*
* WSL only imports Windows environment variables that are listed in WSLENV
* (Build 17063+). Values cross through the Windows process environment, not the
* command line, so secrets are not exposed in `ps`/argv. This makes env-based
* config (API keys from the plugin's key field, custom agent env, tool env)
* actually reach the agent in WSL mode, instead of relying on the user's
* ~/.profile.
*
* Defensive by design (this runs on every WSL launch): merges with any existing
* WSLENV without clobbering, skips empty values (so an empty key field never
* overrides a profile-set value with ""), skips invalid key names, and never
* throws.
*
* @param baseEnv - Base environment (not mutated)
* @param keysToForward - Names of env vars to make visible inside WSL
* @returns A new env object with WSLENV augmented, or baseEnv unchanged if nothing to add
*/
export function buildWslEnv(
baseEnv: NodeJS.ProcessEnv,
keysToForward: string[],
): NodeJS.ProcessEnv {
// A valid WSLENV entry name is a normal env var name. Reject names containing
// ':' or '/' (the WSLENV separators / flag marker) to avoid corrupting the list.
const validName = /^[A-Za-z_][A-Za-z0-9_]*$/;
const toAdd = keysToForward.filter(
(k) =>
validName.test(k) &&
typeof baseEnv[k] === "string" &&
baseEnv[k] !== "",
);
if (toAdd.length === 0) {
return baseEnv;
}
const existing =
typeof baseEnv.WSLENV === "string"
? baseEnv.WSLENV.split(":").filter((e) => e.length > 0)
: [];
const existingNames = new Set(existing.map((e) => e.split("/")[0]));
const merged = [...existing];
for (const k of toAdd) {
if (!existingNames.has(k)) {
// "/u": share only when launching WSL from Win32 (our direction).
merged.push(`${k}/u`);
existingNames.add(k);
}
}
return { ...baseEnv, WSLENV: merged.join(":") };
}
/**
* Convert Windows path to WSL path format.
* Example: C:\Users\name\vault /mnt/c/Users/name/vault
@ -236,6 +290,10 @@ export function isSameDirectory(pathA: string, pathB: string): boolean {
* $SHELL, and falls back to /bin/sh for non-POSIX shells (fish, elvish,
* nushell, xonsh).
*
* NOTE: agent and terminal launches now use buildWslArgvScript /
* buildWslTerminalScript (--exec + positional argv) instead. This helper is
* currently used only by paths.ts (resolveCommandPathInWsl, the `which` lookup).
*
* IMPORTANT: wsl.exe pre-expands $VAR references using WSL environment
* variables before passing them to the Linux shell. Intermediate variables
* (e.g., s=$SHELL; exec $s) will NOT work because wsl.exe expands $s to
@ -255,9 +313,87 @@ export function buildWslShellWrapper(innerCommand: string): string {
);
}
/**
* Build the constant launcher script for argv-based WSL agent launch.
*
* Used as: `wsl.exe [-d dist] --exec /bin/sh -c '<this>' sh <pathDir> <cwd> <command> <args...>`
* where pathDir/cwd/command/args are passed as separate argv (positional params),
* NEVER baked into this string. As a result there is no nested-quoting fragility
* and no escaping of user data is required the returned string is a pure constant.
*
* This combines the strengths of both prior approaches: it skips wsl's default
* shell layer (via `--exec`, fixing environments where the nested `sh -c "<baked
* string>"` construction fails), runs under the user's login shell (`$SHELL -l`,
* so ~/.profile and login files are sourced unlike a bare `--exec <command>`
* which would drop the environment), and forwards command/args as clean argv.
* Falls back to /bin/sh for non-POSIX shells (fish, elvish, nushell, xonsh).
*
* IMPORTANT (same caveat as buildWslShellWrapper): reference `${SHELL:-/bin/sh}`
* directly; do not use intermediate variables.
*
* Positional params seen by the inner login shell:
* $1 = extra PATH dir ("" = none), $2 = working dir, $3 = command, $4.. = args
*/
export function buildWslArgvScript(): string {
// Constant inner script: optional PATH prepend, cd into the working dir
// (fail fast like the terminal wrapper / pre-#304 launch, so the agent never
// runs in an unintended directory), then exec the command with its args.
const core =
'[ -n "$1" ] && export PATH="$1:$PATH"; shift; ' +
'cd "$1" || exit 1; shift; exec "$@"';
const coreEsc = core.replace(/'/g, "'\\''");
// Source ~/.profile first (like buildWslShellWrapper): bash -l skips ~/.profile
// when ~/.bash_profile exists, yet linuxbrew/nvm/mise put their PATH there and
// bare command names resolve via that PATH.
return (
`. ~/.profile 2>/dev/null; ` +
`case \${SHELL:-/bin/sh} in ` +
`*/fish|*/elvish|*/nushell|*/xonsh) exec /bin/sh -l -c '${coreEsc}' sh "$@";; ` +
`*) exec \${SHELL:-/bin/sh} -l -c '${coreEsc}' sh "$@";; ` +
`esac`
);
}
/**
* Build the constant launcher script for terminal commands in WSL.
*
* Used as: `wsl.exe [-d dist] --exec /bin/sh -c '<this>' sh '<commandLine>'`
* where the full shell command line is passed as a single positional ($1),
* NEVER baked into this string so there is no nested-quoting fragility. The
* command line is then run under the user's login shell with `-c`, so it is
* parsed by the user's actual shell (e.g. bash): pipes, redirects, subshells and
* bash-specific syntax are preserved, with ~/.profile sourced. Falls back to
* /bin/sh for non-POSIX shells (fish, elvish, nushell, xonsh).
*
* This mirrors buildWslShellWrapper's effective behavior (`$SHELL -l -c
* <commandLine>`) but delivers the command line via an argv positional + `--exec`
* instead of a doubly-escaped nested string, which avoids quoting failures seen
* in some WSL environments (e.g. RHEL8).
*
* IMPORTANT (same caveat as buildWslArgvScript): reference `${SHELL:-/bin/sh}`
* directly; do not use intermediate variables.
*/
export function buildWslTerminalScript(): string {
// Source ~/.profile first (like buildWslShellWrapper): bash -l skips ~/.profile
// when ~/.bash_profile exists, yet linuxbrew/nvm/mise put their PATH there and
// bare command names resolve via that PATH.
return (
`. ~/.profile 2>/dev/null; ` +
`case \${SHELL:-/bin/sh} in ` +
`*/fish|*/elvish|*/nushell|*/xonsh) exec /bin/sh -l -c "$1";; ` +
`*) exec \${SHELL:-/bin/sh} -l -c "$1";; ` +
`esac`
);
}
/**
* Wrap a command to run inside WSL using wsl.exe.
* Generates wsl.exe command with proper arguments for executing commands in WSL environment.
*
* @param useArgvExec - When true (agent launch), use the hybrid argv launcher
* (`--exec` + login shell + argv) so command/args need no escaping and the
* environment is preserved. When false (terminal launch), use the shell-string
* wrapper so the login shell parses pipes/redirects in the command.
*/
export function wrapCommandForWsl(
command: string,
@ -265,6 +401,7 @@ export function wrapCommandForWsl(
cwd: string,
distribution?: string,
additionalPath?: string,
useArgvExec = false,
): { command: string; args: string[] } {
// Validate working directory path
// Check for UNC paths (\\server\share) which are not supported by WSL
@ -294,8 +431,39 @@ export function wrapCommandForWsl(
wslArgs.push("-d", distribution);
}
// Build command to execute inside WSL
// Use login shell (-l) to inherit PATH from user's shell profile
if (useArgvExec) {
// Agent launch: hybrid argv launcher.
// command/args/cwd/pathDir are passed as separate argv (positional params),
// so no shell quoting of user data is needed (fixes paths/args with spaces
// or special chars), `--exec` skips wsl's default shell layer (fixes envs
// where the nested `sh -c "<baked string>"` construction breaks), and the
// login shell inside still sources ~/.profile (preserves the environment).
const pathDir = additionalPath
? convertWindowsPathToWsl(additionalPath)
: "";
wslArgs.push(
"--exec",
"/bin/sh",
"-c",
buildWslArgvScript(),
"sh",
pathDir,
wslCwd,
command,
...args,
);
return {
command: "C:\\Windows\\System32\\wsl.exe",
args: wslArgs,
};
}
// Terminal launch: the command may be a shell line (pipes, redirects, &&,
// subshells, bash-isms), so it must be parsed by the user's shell. Build the
// same shell command line as before, but deliver it via --exec + a single
// positional (no nested escaped string) so it survives WSL quoting, and run
// it under the user's login shell ($SHELL -l -c "$1"). Behaviorally identical
// to the previous `$SHELL -l -c <innerCommand>`, just delivered robustly.
const escapedArgs = args.map(escapeShellArgBash).join(" ");
const argsString = escapedArgs.length > 0 ? ` ${escapedArgs}` : "";
@ -308,7 +476,14 @@ export function wrapCommandForWsl(
}
const innerCommand = `${pathPrefix}cd ${escapeShellArgBash(wslCwd)} && ${command}${argsString}`;
wslArgs.push("sh", "-c", buildWslShellWrapper(innerCommand));
wslArgs.push(
"--exec",
"/bin/sh",
"-c",
buildWslTerminalScript(),
"sh",
innerCommand,
);
return {
command: "C:\\Windows\\System32\\wsl.exe",
@ -374,12 +549,16 @@ export function prepareShellCommand(
// WSL mode (Windows only)
if (Platform.isWin && options.wslMode) {
// Agent launch (alwaysEscape: true) uses the hybrid argv launcher;
// terminal launch (alwaysEscape: false) uses the shell-string wrapper
// so pipes/redirects in the command are parsed by the shell.
const wrapped = wrapCommandForWsl(
command,
args,
cwd,
options.wslDistribution,
options.nodeDir,
alwaysEscape,
);
return {
command: wrapped.command,

375
test/platform.test.ts Normal file
View file

@ -0,0 +1,375 @@
import { describe, it, expect, beforeEach } from "vitest";
import { Platform } from "obsidian";
import {
convertWindowsPathToWsl,
convertWslPathToWindows,
escapeShellArgBash,
escapeShellArgWindows,
buildWslArgvScript,
buildWslTerminalScript,
wrapCommandForWsl,
prepareShellCommand,
buildWslEnv,
isSameDirectory,
} from "../src/utils/platform";
const WSL_EXE = "C:\\Windows\\System32\\wsl.exe";
function resetPlatform(): void {
Platform.isWin = false;
Platform.isMacOS = false;
Platform.isLinux = false;
Platform.isDesktopApp = true;
}
beforeEach(resetPlatform);
describe("convertWindowsPathToWsl", () => {
it("converts a Windows drive path", () => {
expect(convertWindowsPathToWsl("C:\\Users\\me")).toBe("/mnt/c/Users/me");
});
it("lowercases the drive letter and normalizes slashes", () => {
expect(convertWindowsPathToWsl("D:/Foo/Bar")).toBe("/mnt/d/Foo/Bar");
});
it("is idempotent on already-WSL paths", () => {
expect(convertWindowsPathToWsl("/mnt/c/x")).toBe("/mnt/c/x");
});
it("passes through non-drive paths unchanged", () => {
expect(convertWindowsPathToWsl("relative/dir")).toBe("relative/dir");
});
});
describe("convertWslPathToWindows", () => {
it("converts a /mnt path", () => {
expect(convertWslPathToWindows("/mnt/c/Users/me")).toBe("C:\\Users\\me");
});
it("passes through non-/mnt paths unchanged", () => {
expect(convertWslPathToWindows("/home/me")).toBe("/home/me");
});
it("round-trips with convertWindowsPathToWsl", () => {
const win = "C:\\Users\\me\\vault";
expect(convertWslPathToWindows(convertWindowsPathToWsl(win))).toBe(win);
});
});
describe("escapeShellArgBash", () => {
it("wraps plain args in single quotes", () => {
expect(escapeShellArgBash("foo")).toBe("'foo'");
});
it("preserves spaces inside quotes", () => {
expect(escapeShellArgBash("foo bar")).toBe("'foo bar'");
});
it("escapes embedded single quotes", () => {
expect(escapeShellArgBash("it's")).toBe("'it'\\''s'");
});
});
describe("escapeShellArgWindows", () => {
it("leaves simple args unquoted", () => {
expect(escapeShellArgWindows("foo")).toBe("foo");
});
it("quotes args with spaces", () => {
expect(escapeShellArgWindows("foo bar")).toBe('"foo bar"');
});
it("doubles percent signs", () => {
expect(escapeShellArgWindows("%PATH%")).toBe("%%PATH%%");
});
});
describe("buildWslArgvScript", () => {
const script = buildWslArgvScript();
it("runs a login shell so ~/.profile is sourced (env preserved)", () => {
expect(script).toContain(" -l ");
});
it("execs the forwarded argv", () => {
expect(script).toContain('exec "$@"');
});
it("references $SHELL directly with a /bin/sh fallback", () => {
expect(script).toContain("${SHELL:-/bin/sh}");
expect(script).toContain("*/fish");
});
it("sources ~/.profile (bash -l skips it when ~/.bash_profile exists)", () => {
expect(script).toContain(". ~/.profile");
});
it("fails fast if cd to the working dir fails (no run in wrong dir)", () => {
expect(script).toContain('cd "$1" || exit');
});
it("does not bake in any command/args (pure constant)", () => {
// Sanity: the script must not contain a placeholder that implies
// string interpolation of user data.
expect(script).not.toContain("undefined");
});
});
describe("wrapCommandForWsl — agent (useArgvExec)", () => {
it("uses --exec + /bin/sh -c <script>, forwarding command/args as ordered argv", () => {
const { command, args } = wrapCommandForWsl(
"/home/u/agent",
["acp"],
"C:\\vault",
undefined,
undefined,
true,
);
expect(command).toBe(WSL_EXE);
// header: --exec /bin/sh -c <launcher script>
expect(args.slice(0, 3)).toEqual(["--exec", "/bin/sh", "-c"]);
// launcher keeps a login shell so the environment is preserved
expect(args[3]).toContain(" -l ");
// and sources ~/.profile (regression guard: bash -l skips it when
// ~/.bash_profile exists; linuxbrew/nvm/mise/bare-commands need it)
expect(args[3]).toContain(". ~/.profile");
// positionals: sh <pathDir> <cwd> <command> <args...> — ORDER matters
// ("" pathDir must be present so $1..$N line up with the script)
expect(args.slice(4)).toEqual([
"sh",
"",
"/mnt/c/vault",
"/home/u/agent",
"acp",
]);
});
it("keeps a command path with spaces as a single, correctly-positioned argv element", () => {
const { args } = wrapCommandForWsl(
"/home/u/my agent",
[],
"C:\\vault",
undefined,
undefined,
true,
);
expect(args.slice(4)).toEqual([
"sh",
"",
"/mnt/c/vault",
"/home/u/my agent",
]);
});
it("forwards the additionalPath dir as the first positional (before cwd)", () => {
const { args } = wrapCommandForWsl(
"/home/u/agent",
[],
"C:\\vault",
undefined,
"C:\\node\\bin",
true,
);
expect(args.slice(4)).toEqual([
"sh",
"/mnt/c/node/bin",
"/mnt/c/vault",
"/home/u/agent",
]);
});
it("passes -d <distribution> before the launcher header", () => {
const { args } = wrapCommandForWsl(
"/home/u/agent",
[],
"C:\\vault",
"Ubuntu",
undefined,
true,
);
expect(args.slice(0, 5)).toEqual([
"-d",
"Ubuntu",
"--exec",
"/bin/sh",
"-c",
]);
// positionals (index 5 is the launcher script)
expect(args.slice(6)).toEqual([
"sh",
"",
"/mnt/c/vault",
"/home/u/agent",
]);
});
});
describe("wrapCommandForWsl — terminal (shell string via hybrid)", () => {
it("uses --exec + login shell, command line as a single positional (pipes preserved)", () => {
const { command, args } = wrapCommandForWsl(
"ls -la | grep foo",
[],
"C:\\vault",
undefined,
undefined,
false,
);
expect(command).toBe(WSL_EXE);
// header: --exec /bin/sh -c <launcher script>
expect(args.slice(0, 3)).toEqual(["--exec", "/bin/sh", "-c"]);
// launcher runs the command line under the user's login shell
const script = args[3];
expect(script).toContain(" -l ");
expect(script).toContain("${SHELL:-/bin/sh}");
expect(script).toContain('-c "$1"');
expect(script).toContain(". ~/.profile"); // regression guard
// positionals: sh <innerCommand> — the command line is ONE intact element
expect(args).toHaveLength(6);
expect(args[4]).toBe("sh");
const innerCommand = args[5];
expect(innerCommand).toContain("ls -la | grep foo"); // raw pipe preserved
expect(innerCommand).toContain("cd '/mnt/c/vault'");
});
it("includes the PATH export and cd in the command line when additionalPath is set", () => {
const { args } = wrapCommandForWsl(
"node x.js",
[],
"C:\\v",
undefined,
"C:\\node\\bin",
false,
);
const innerCommand = args[args.length - 1];
expect(innerCommand).toContain('export PATH="/mnt/c/node/bin:$PATH"');
expect(innerCommand).toContain("node x.js");
});
});
describe("buildWslTerminalScript", () => {
const s = buildWslTerminalScript();
it("runs the command line under the user's login shell via -c", () => {
expect(s).toContain(" -l ");
expect(s).toContain('-c "$1"');
expect(s).toContain("${SHELL:-/bin/sh}");
});
it("falls back to /bin/sh for non-POSIX shells", () => {
expect(s).toContain("*/fish");
});
it("sources ~/.profile (bash -l skips it when ~/.bash_profile exists)", () => {
expect(s).toContain(". ~/.profile");
});
it("is a pure constant (no baked-in user data)", () => {
expect(s).not.toContain("undefined");
});
});
describe("wrapCommandForWsl — validation", () => {
it("rejects UNC working directories", () => {
expect(() =>
wrapCommandForWsl("/x", [], "\\\\server\\share"),
).toThrow(/UNC/);
});
it("rejects invalid distribution names", () => {
expect(() =>
wrapCommandForWsl("/x", [], "C:\\v", "bad;name"),
).toThrow(/distribution/i);
});
});
describe("prepareShellCommand", () => {
it("WSL agent: wsl.exe via --exec, no shell", () => {
Platform.isWin = true;
const r = prepareShellCommand("/home/u/agent", ["acp"], "C:\\vault", {
wslMode: true,
alwaysEscape: true,
});
expect(r.command).toBe(WSL_EXE);
expect(r.needsShell).toBe(false);
expect(r.args).toContain("--exec");
});
it("WSL terminal: wsl.exe via --exec + login shell, command line as positional", () => {
Platform.isWin = true;
const r = prepareShellCommand("ls | grep x", [], "C:\\vault", {
wslMode: true,
alwaysEscape: false,
});
expect(r.command).toBe(WSL_EXE);
expect(r.needsShell).toBe(false);
expect(r.args.slice(0, 3)).toEqual(["--exec", "/bin/sh", "-c"]);
// the command line (with the pipe) is the last positional, intact
expect(r.args[r.args.length - 1]).toContain("ls | grep x");
});
it("macOS: wraps in a login shell (-l -c)", () => {
Platform.isMacOS = true;
const r = prepareShellCommand("agent", [], "/home/u", {
wslMode: false,
});
expect(r.args[0]).toBe("-l");
expect(r.args[1]).toBe("-c");
expect(r.needsShell).toBe(false);
});
it("Windows non-WSL: needs cmd.exe shell", () => {
Platform.isWin = true;
const r = prepareShellCommand("agent", ["x"], "C:\\vault", {
wslMode: false,
});
expect(r.needsShell).toBe(true);
});
});
describe("buildWslEnv", () => {
it("adds a forwarded key with the /u flag", () => {
const out = buildWslEnv({ FOO: "bar" }, ["FOO"]);
expect(out.WSLENV).toBe("FOO/u");
});
it("skips empty values (never clobbers a profile-set var)", () => {
const base = { FOO: "" };
const out = buildWslEnv(base, ["FOO"]);
expect(out.WSLENV).toBeUndefined();
expect(out).toBe(base); // unchanged reference when nothing to add
});
it("skips undefined values", () => {
const out = buildWslEnv({}, ["MISSING"]);
expect(out.WSLENV).toBeUndefined();
});
it("skips invalid key names", () => {
const out = buildWslEnv({ "A:B": "x" }, ["A:B"]);
expect(out.WSLENV).toBeUndefined();
});
it("merges with an existing WSLENV without clobbering", () => {
const out = buildWslEnv({ FOO: "bar", WSLENV: "EXISTING/p" }, ["FOO"]);
expect(out.WSLENV).toBe("EXISTING/p:FOO/u");
});
it("does not duplicate an already-listed key", () => {
const out = buildWslEnv({ FOO: "bar", WSLENV: "FOO/p" }, ["FOO"]);
expect(out.WSLENV).toBe("FOO/p");
});
it("does not mutate the input env", () => {
const base: NodeJS.ProcessEnv = { FOO: "bar" };
buildWslEnv(base, ["FOO"]);
expect(base.WSLENV).toBeUndefined();
});
it("does not throw when WSLENV is present but not a string (defensive)", () => {
const base = {
FOO: "bar",
WSLENV: 123 as unknown as string,
};
expect(() => buildWslEnv(base, ["FOO"])).not.toThrow();
expect(buildWslEnv(base, ["FOO"]).WSLENV).toBe("FOO/u");
});
});
describe("isSameDirectory", () => {
it("treats equivalent WSL and Windows paths as the same", () => {
expect(isSameDirectory("/mnt/c/x", "C:\\x")).toBe(true);
});
it("ignores trailing slashes", () => {
expect(isSameDirectory("/mnt/c/x/", "/mnt/c/x")).toBe(true);
});
it("is case-insensitive on Windows", () => {
Platform.isWin = true;
expect(isSameDirectory("C:\\Foo", "C:\\foo")).toBe(true);
});
it("is case-sensitive off Windows", () => {
Platform.isWin = false;
expect(isSameDirectory("/mnt/c/Foo", "/mnt/c/foo")).toBe(false);
});
});

14
test/stubs/obsidian.ts Normal file
View file

@ -0,0 +1,14 @@
/**
* Lightweight `obsidian` stub for unit tests.
*
* The real `obsidian` module only exists inside the Obsidian runtime. The pure
* utilities under test (`src/utils/platform.ts`, `src/utils/paths.ts`) only need
* `Platform`, whose flags they read at call time. Tests mutate these flags to
* exercise the platform-specific branches.
*/
export const Platform = {
isWin: false,
isMacOS: false,
isLinux: false,
isDesktopApp: true,
};

4
tsconfig.eslint.json Normal file
View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
}

19
vitest.config.mts Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
// `obsidian` has no real module outside Obsidian, so alias it to a lightweight
// stub for unit tests. Only the pieces the tested pure functions need (Platform)
// are provided.
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
},
resolve: {
alias: {
obsidian: fileURLToPath(
new URL("./test/stubs/obsidian.ts", import.meta.url),
),
},
},
});