mirror of
https://github.com/rait-09/obsidian-agent-client.git
synced 2026-07-22 06:43:37 +00:00
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:
commit
ca860a3a28
11 changed files with 1757 additions and 26 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
1136
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
375
test/platform.test.ts
Normal 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
14
test/stubs/obsidian.ts
Normal 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
4
tsconfig.eslint.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
|
||||
}
|
||||
19
vitest.config.mts
Normal file
19
vitest.config.mts
Normal 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),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue