Validate Mosh client is executable in Settings UI

Address Codex P2 on PR #849 commit b6c384af. UI's debounced validator
called validatePath which only reported exists / isFile / isDirectory,
so a regular file without the POSIX execute bit (e.g. a stray
/etc/hosts-style path) was marked as valid in Settings — but
startMoshSession's isExecutableFile check then rejected the same path
at connect time, deferring the error until the user actually tried to
use Mosh.

- validatePath now returns `isExecutable: boolean`, mirroring
  isExecutableFile semantics (POSIX: stat.mode & 0o111; Win32: any
  regular file is treated as executable since NTFS lacks POSIX bits).
  Existing callers (localShell, localStartDir) ignore the new field.
- global.d.ts ValidatePath return type extended.
- SettingsTerminalTab Mosh validator surfaces a `notExecutable`
  message when the file exists but lacks exec permissions, keeping
  the UI in lockstep with main-process gating.
- en / zh-CN strings for the new state.

Verified: /bin/sh -> isExecutable:true, /etc/hosts -> false, /etc ->
false (directory). UI now warns immediately on the regression case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
bincxz
2026-04-28 10:40:40 +08:00
parent b6c384afd7
commit 2eba549ea6
5 changed files with 31 additions and 10 deletions
+1
View File
@@ -383,6 +383,7 @@ const en: Messages = {
'settings.terminal.mosh.client.placeholder': 'Auto-detect',
'settings.terminal.mosh.client.notFound': 'File not found at that path.',
'settings.terminal.mosh.client.isDirectory': 'Path points to a directory, not an executable.',
'settings.terminal.mosh.client.notExecutable': 'File exists but is not executable. Run `chmod +x` on it or pick another binary.',
'settings.terminal.mosh.detect': 'Detect',
'settings.terminal.mosh.browse': 'Browse…',
'settings.terminal.mosh.detected': 'Detected at',
+1
View File
@@ -1467,6 +1467,7 @@ const zhCN: Messages = {
'settings.terminal.mosh.client.placeholder': '自动探测',
'settings.terminal.mosh.client.notFound': '该路径下未找到文件。',
'settings.terminal.mosh.client.isDirectory': '该路径指向目录而非可执行文件。',
'settings.terminal.mosh.client.notExecutable': '文件存在但不可执行。请对其执行 `chmod +x`,或选择其它二进制文件。',
'settings.terminal.mosh.detect': '探测',
'settings.terminal.mosh.browse': '浏览…',
'settings.terminal.mosh.detected': '已找到',
@@ -478,7 +478,11 @@ export default function SettingsTerminalTab(props: {
}
const timeoutId = setTimeout(() => {
bridge.validatePath(moshPath, "file").then((result) => {
if (result.exists && result.isFile) {
if (result.exists && result.isFile && !result.isExecutable) {
// Stays consistent with startMoshSession's isExecutableFile check —
// a regular file without the execute bit can't actually launch.
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notExecutable") });
} else if (result.exists && result.isFile) {
setMoshValidation({ valid: true });
} else if (result.exists && result.isDirectory) {
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.isDirectory") });
+23 -8
View File
@@ -1287,29 +1287,42 @@ function getDefaultShell() {
* Validate a path - check if it exists and whether it's a file or directory
* @param {object} event - IPC event
* @param {object} payload - Contains { path: string, type?: 'file' | 'directory' | 'any' }
* @returns {{ exists: boolean, isFile: boolean, isDirectory: boolean }}
* @returns {{ exists: boolean, isFile: boolean, isDirectory: boolean, isExecutable: boolean }}
*
* `isExecutable` mirrors isExecutableFile(): POSIX requires the file mode
* to have an execute bit; Win32 treats any regular file as executable
* (NTFS lacks POSIX bits — extension/PATHEXT decides at spawn time).
* Existing callers ignore the new field; consumers that need exec
* semantics (e.g. Mosh client path) read it explicitly.
*/
function statIsExecutable(stat) {
if (!stat || !stat.isFile()) return false;
if (process.platform === "win32") return true;
return (stat.mode & 0o111) !== 0;
}
function validatePath(event, payload) {
const targetPath = payload?.path;
const type = payload?.type || 'any';
if (!targetPath) {
return { exists: false, isFile: false, isDirectory: false };
return { exists: false, isFile: false, isDirectory: false, isExecutable: false };
}
try {
// Resolve path (handle ~, etc.)
let resolvedPath = expandHomePath(targetPath);
resolvedPath = path.resolve(resolvedPath);
if (fs.existsSync(resolvedPath)) {
const stat = fs.statSync(resolvedPath);
return {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
isExecutable: statIsExecutable(stat),
};
}
// If type is 'file' and path doesn't exist, try to resolve via PATH (for executables like cmd.exe, powershell.exe)
if (type === 'file') {
const resolvedExecutable = findExecutable(targetPath);
@@ -1320,6 +1333,7 @@ function validatePath(event, payload) {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
isExecutable: statIsExecutable(stat),
};
}
// Also try with .exe extension on Windows if not already present
@@ -1331,15 +1345,16 @@ function validatePath(event, payload) {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
isExecutable: statIsExecutable(stat),
};
}
}
}
return { exists: false, isFile: false, isDirectory: false };
return { exists: false, isFile: false, isDirectory: false, isExecutable: false };
} catch (err) {
console.warn(`[Terminal] Error validating path "${targetPath}":`, err.message);
return { exists: false, isFile: false, isDirectory: false };
return { exists: false, isFile: false, isDirectory: false, isExecutable: false };
}
}
Vendored
+1 -1
View File
@@ -216,7 +216,7 @@ declare global {
}>>;
getDefaultShell?(): Promise<string>;
discoverShells?(): Promise<DiscoveredShell[]>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean; isExecutable: boolean }>;
generateKeyPair?(options: {
type: 'RSA' | 'ECDSA' | 'ED25519';
bits?: number;