mirror of
https://github.com/rait-09/obsidian-agent-client.git
synced 2026-07-22 06:43:37 +00:00
fix(wsl): launch agents via --exec login-shell + argv, forward env via WSLENV
WSL agent launch previously wrapped the command in a nested `sh -c "<baked string>"` construction. In some environments (e.g. RHEL8) this fails before ACP connects (the process exits 1), and command paths/args with spaces broke because the command was interpolated unescaped. Launch agents instead with: wsl.exe [--exec] /bin/sh -c '<constant launcher>' sh <pathDir> <cwd> <command> <args...> This skips wsl's default-shell layer (--exec), runs under the user's login shell so ~/.profile is sourced (environment preserved, unlike a bare --exec), and passes command/args as argv (no quoting of user data). Also forward configured env vars (API keys, custom agent env, tool env) into WSL via WSLENV (buildWslEnv), so the plugin's API key field works in WSL mode without requiring keys in ~/.profile. Defensive: skips empty values and invalid names, merges existing WSLENV, never throws. Add a vitest unit-test suite for the platform WSL helpers (39 cases, incl. exact argv-ordering checks), with an obsidian stub alias and a dedicated tsconfig for typed linting of tests. Builds on #304 (direct WSL exec for absolute-path commands); this retains the environment that a bare --exec would drop and also fixes paths/args with spaces. Terminal launch keeps the existing wrapper for now (env forwarding is applied); a terminal-specific hybrid follows separately.
This commit is contained in:
parent
ee01a76e96
commit
850e2e1557
11 changed files with 1641 additions and 48 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
|
||||
|
|
|
|||
1142
package-lock.json
generated
1142
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 {
|
||||
|
|
@ -166,6 +167,14 @@ export class AcpClient {
|
|||
baseEnv = getEnhancedWindowsEnv(baseEnv);
|
||||
}
|
||||
|
||||
// In WSL mode, forward the configured env vars (API keys, custom agent
|
||||
// env) into WSL via WSLENV. Windows env vars are otherwise not visible to
|
||||
// the Linux agent process, so without this the plugin's API key field has
|
||||
// no effect in WSL mode (the user would have to put keys in ~/.profile).
|
||||
if (Platform.isWin && this.plugin.settings.windowsWslMode) {
|
||||
baseEnv = buildWslEnv(baseEnv, Object.keys(config.env || {}));
|
||||
}
|
||||
|
||||
// Add Node.js directory to PATH only when nodePath is an explicit absolute path.
|
||||
// When nodePath is empty or a bare command name, the login shell handles it.
|
||||
const nodeDir = resolveNodeDirectory(this.plugin.settings.nodePath);
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
|
|
|||
|
|
@ -174,6 +174,59 @@ 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 = baseEnv.WSLENV
|
||||
? 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
|
||||
|
|
@ -255,9 +308,50 @@ 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,
|
||||
// then exec the command with its args as argv.
|
||||
const core =
|
||||
'[ -n "$1" ] && export PATH="$1:$PATH"; shift; ' +
|
||||
'cd "$1" 2>/dev/null; shift; exec "$@"';
|
||||
const coreEsc = core.replace(/'/g, "'\\''");
|
||||
return (
|
||||
`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`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +359,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 +389,36 @@ 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, &&),
|
||||
// so keep the shell-string wrapper and let the login shell parse it.
|
||||
// Use login shell (-l) to inherit PATH from user's shell profile.
|
||||
const escapedArgs = args.map(escapeShellArgBash).join(" ");
|
||||
const argsString = escapedArgs.length > 0 ? ` ${escapedArgs}` : "";
|
||||
|
||||
|
|
@ -307,29 +430,13 @@ export function wrapCommandForWsl(
|
|||
pathPrefix = `export PATH="${escapePathForShell(wslPath)}:$PATH"; `;
|
||||
}
|
||||
|
||||
const commandIsAbsoluteWslPath = command.startsWith("/");
|
||||
const commandHasShellSyntax = /[\s"'`$&|;<>(){}[\]*?!\\]/.test(command);
|
||||
const canUseDirectExec =
|
||||
commandIsAbsoluteWslPath &&
|
||||
!commandHasShellSyntax &&
|
||||
!additionalPath;
|
||||
const innerCommand = `${pathPrefix}cd ${escapeShellArgBash(wslCwd)} && ${command}${argsString}`;
|
||||
wslArgs.push("sh", "-c", buildWslShellWrapper(innerCommand));
|
||||
|
||||
if (canUseDirectExec) {
|
||||
wslArgs.push("--cd", wslCwd, "--exec", command, ...args);
|
||||
|
||||
return {
|
||||
command: "C:\\Windows\\System32\\wsl.exe",
|
||||
args: wslArgs,
|
||||
};
|
||||
}
|
||||
|
||||
const innerCommand = `${pathPrefix}cd ${escapeShellArgBash(wslCwd)} && ${command}${argsString}`;
|
||||
wslArgs.push("sh", "-c", buildWslShellWrapper(innerCommand));
|
||||
|
||||
return {
|
||||
command: "C:\\Windows\\System32\\wsl.exe",
|
||||
args: wslArgs,
|
||||
};
|
||||
return {
|
||||
command: "C:\\Windows\\System32\\wsl.exe",
|
||||
args: wslArgs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -390,12 +497,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,
|
||||
|
|
|
|||
315
test/platform.test.ts
Normal file
315
test/platform.test.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { Platform } from "obsidian";
|
||||
import {
|
||||
convertWindowsPathToWsl,
|
||||
convertWslPathToWindows,
|
||||
escapeShellArgBash,
|
||||
escapeShellArgWindows,
|
||||
buildWslArgvScript,
|
||||
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("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 ");
|
||||
// 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)", () => {
|
||||
it("uses sh -c wrapper and lets the shell parse the command (pipes)", () => {
|
||||
const { command, args } = wrapCommandForWsl(
|
||||
"ls -la | grep foo",
|
||||
[],
|
||||
"C:\\vault",
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
expect(command).toBe(WSL_EXE);
|
||||
expect(args).toContain("sh");
|
||||
expect(args).toContain("-c");
|
||||
const wrapper = args[args.indexOf("-c") + 1];
|
||||
// login shell + profile sourcing preserved
|
||||
expect(wrapper).toContain(". ~/.profile");
|
||||
expect(wrapper).toContain(" -l ");
|
||||
// command is left raw so the shell parses the pipe
|
||||
expect(wrapper).toContain("ls -la | grep foo");
|
||||
});
|
||||
});
|
||||
|
||||
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 sh -c wrapper", () => {
|
||||
Platform.isWin = true;
|
||||
const r = prepareShellCommand("ls | grep x", [], "C:\\vault", {
|
||||
wslMode: true,
|
||||
alwaysEscape: false,
|
||||
});
|
||||
expect(r.command).toBe(WSL_EXE);
|
||||
expect(r.args).toContain("sh");
|
||||
expect(r.args).not.toContain("--exec");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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