From 586051255d47a41dd00cb6daa060249ba43e5b5e Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Sat, 17 Oct 2026 12:18:57 +0800 Subject: [PATCH] fix(dsh-plugin): find the Python a Windows PATH registers The DSH LoopX plugin only knew the POSIX interpreter spelling. pluginPythonCandidates hard-coded `python3` and scanned PATH for `python3.` names, and resolveLoopXCommand's module fallback defaulted to `python3`. A python.org Windows install registers `python.exe` and no `python3` name, and the host's `python3` name is often the 0-byte WindowsApps App Execution Alias, which starts and exits 9009 without running Python. The candidate list therefore collapsed to ["python3"], install_cli threw kind=missing with "Python 3.11 or newer with pip could not be found", and neither the automatic first-DSH-start bootstrap nor /loopx-init could install or upgrade LoopX on Windows even though Python 3.11 with pip was on PATH. Try `python` as the last discovery candidate and as the second module fallback, the pair the Claude Code adapter installer already uses after #6229. Refs #6704 Signed-off-by: JasonBuildAI --- packages/dsh-loopx-plugin/README.md | 18 +++--- packages/dsh-loopx-plugin/src/cli.ts | 16 +++-- .../dsh-loopx-plugin/src/managed-runtime.ts | 11 +++- packages/dsh-loopx-plugin/tests/cli.spec.ts | 17 +++++ .../tests/init-command.spec.ts | 63 +++++++++++++++++++ .../tests/python-discovery.spec.ts | 24 ++++++- 6 files changed, 132 insertions(+), 17 deletions(-) diff --git a/packages/dsh-loopx-plugin/README.md b/packages/dsh-loopx-plugin/README.md index 3e6e76370d..085b96c499 100644 --- a/packages/dsh-loopx-plugin/README.md +++ b/packages/dsh-loopx-plugin/README.md @@ -40,13 +40,17 @@ Requirements are Node.js 22.19+, `pnpm`, Python 3.11+ with `pip`, and network access for the first DSH start when no compatible LoopX CLI is already installed. LoopX itself is deliberately not a prerequisite. The initializer honors an explicit `PYTHON_BIN`; otherwise it tries `python3`, then discovers -`python3.` executables on the supplied `PATH` in descending numeric order. -Installation still checks the Python version and pip; reopening the managed -runtime uses the same discovery and validates the LoopX CLI. No hard-coded -minor-version list is maintained. When selecting Python for installation, an -invalid explicit interpreter fails instead of falling back. Managed-runtime -readback keeps the existing CLI fallback behavior. If the plugin must install -or upgrade LoopX, it writes an isolated +`python3.` executables on the supplied `PATH` in descending numeric +order, then tries the bare name `python`. The last name is what a python.org +Windows install registers, so it keeps that host reachable when `python3` is +absent or is the `WindowsApps` App Execution Alias that never starts Python. +Unlike the versioned names it is not a `PATH` scan result; it is resolved when +the candidate runs. Installation still checks the Python version and pip; +reopening the managed runtime uses the same discovery and validates the LoopX +CLI. No hard-coded minor-version list is maintained. When selecting Python for +installation, an invalid explicit interpreter fails instead of falling back. +Managed-runtime readback keeps the existing CLI fallback behavior. If the +plugin must install or upgrade LoopX, it writes an isolated copy under `$DSH_AGENTS_HOME/runtime/dsh-loopx-plugin` (default `~/.agents/runtime/dsh-loopx-plugin`) and never mutates the system Python environment. This works with externally managed Python distributions that diff --git a/packages/dsh-loopx-plugin/src/cli.ts b/packages/dsh-loopx-plugin/src/cli.ts index aa9258b735..c4079936fc 100644 --- a/packages/dsh-loopx-plugin/src/cli.ts +++ b/packages/dsh-loopx-plugin/src/cli.ts @@ -334,7 +334,13 @@ export async function resolveLoopXCommand( ): Promise { const runner = options.runner ?? runFile const configured = options.env?.LOOPX_BIN ?? process.env.LOOPX_BIN - const python = options.env?.PYTHON_BIN ?? process.env.PYTHON_BIN ?? 'python3' + const configuredPython = options.env?.PYTHON_BIN ?? process.env.PYTHON_BIN + // A python.org Windows install registers `python`, not `python3`, and the + // Windows `python3` name can be the WindowsApps alias that never starts + // Python, so the module fallback tries both spellings in that order. + const pythonNames = configuredPython === undefined + ? ['python3', 'python'] + : [configuredPython] const managedLauncher = options.managedLauncher const managedCandidates: Array> = configured || managedLauncher === undefined @@ -352,11 +358,11 @@ export async function resolveLoopXCommand( : [ ...managedCandidates, { file: 'loopx', prefix: [], skillCommand: 'loopx' }, - { - file: python, + ...pythonNames.map(pythonName => ({ + file: pythonName, prefix: ['-m', 'loopx.cli'], - skillCommand: `${shellWord(python)} -m loopx.cli`, - }, + skillCommand: `${shellWord(pythonName)} -m loopx.cli`, + })), ] for (const candidate of candidates) { diff --git a/packages/dsh-loopx-plugin/src/managed-runtime.ts b/packages/dsh-loopx-plugin/src/managed-runtime.ts index 7ca84a8da2..df92e0610b 100644 --- a/packages/dsh-loopx-plugin/src/managed-runtime.ts +++ b/packages/dsh-loopx-plugin/src/managed-runtime.ts @@ -45,9 +45,16 @@ export function pluginPythonCandidates( } } // Preserve python3-first behavior; callers still probe the actual version/pip. - return ['python3', ...[...discovered] + // `python` is a bare last-resort name, not a PATH scan result: a python.org + // Windows install registers `python.exe`, and the host's `python3` name can be + // the WindowsApps alias that never starts Python. The same fallback pair is + // used by the Claude Code adapter installer + // (loopx/claude_goal_mode/scripts/install.py), and the callers above still + // reject an interpreter that fails the version/pip probe. + const discoveredNames = [...discovered] .sort(([nameA, minorA], [nameB, minorB]) => minorB - minorA || nameA.localeCompare(nameB)) - .map(([name]) => name)] + .map(([name]) => name) + return ['python3', ...discoveredNames, 'python'] } export function pluginAgentsHome(options: LoopXRuntimeOptions): string { diff --git a/packages/dsh-loopx-plugin/tests/cli.spec.ts b/packages/dsh-loopx-plugin/tests/cli.spec.ts index bab7a8126c..501aa07821 100644 --- a/packages/dsh-loopx-plugin/tests/cli.spec.ts +++ b/packages/dsh-loopx-plugin/tests/cli.spec.ts @@ -46,6 +46,23 @@ describe('runJsonCommand', () => { expect(command.prefix).toEqual(['-m', 'loopx.cli']) }) + it('tries the python name a python.org Windows PATH registers for the module fallback', async () => { + const attempted: string[] = [] + const resolved = await resolveLoopXCommand({ + env: { LOOPX_BIN: '' }, + runner: async (file, args) => { + attempted.push(`${file} ${args.join(' ')}`) + if (file === 'python') return { exitCode: 0, stdout: 'loopx 1.2.4\n', stderr: '' } + throw new LoopXCliError('missing', 'missing', false) + }, + }) + + expect(attempted).toContain('python3 -m loopx.cli --version') + expect(resolved.file).toBe('python') + expect(resolved.prefix).toEqual(['-m', 'loopx.cli']) + expect(resolved.skillCommand).toBe('python -m loopx.cli') + }) + it('keeps a configured LoopX executable as one quoted shell word', async () => { const configured = '/opt/Loop X/loopx' const resolved = await resolveLoopXCommand({ diff --git a/packages/dsh-loopx-plugin/tests/init-command.spec.ts b/packages/dsh-loopx-plugin/tests/init-command.spec.ts index 6a9fdda0ae..6937e150f5 100644 --- a/packages/dsh-loopx-plugin/tests/init-command.spec.ts +++ b/packages/dsh-loopx-plugin/tests/init-command.spec.ts @@ -411,6 +411,69 @@ describe('/loopx-init implementation', () => { ))).toBe(true) }) + it('finds the interpreter a python.org Windows PATH registers', async () => { + // A python.org Windows install registers python.exe and no python3 name, + // and the host's python3 name can be the WindowsApps alias, which starts + // and exits 9009 without running Python. + const pythonPath = await mkdtemp(join(tmpdir(), 'loopx-windows-pythons-')) + onTestFinished(() => rm(pythonPath, { recursive: true, force: true })) + await writeFile(join(pythonPath, 'python.exe'), '# fixture executable\n') + const calls: Array<{ readonly file: string; readonly args: readonly string[] }> = [] + let installed = false + const runner: FileRunner = async (file, args) => { + calls.push({ file, args: [...args] }) + if (file === 'python3') { + return { exitCode: 9009, stdout: '', stderr: '' } + } + if (args[0] === '-c') { + return { exitCode: 0, stdout: '', stderr: '' } + } + if (args.join(' ') === '-m pip --version') { + return { exitCode: 0, stdout: 'pip fixture', stderr: '' } + } + if (args.slice(0, 3).join(' ') === '-m pip install') { + installed = true + return { exitCode: 0, stdout: 'installed', stderr: '' } + } + if (args.at(-1) === '--version') { + if (!installed) throw new LoopXCliError('missing', 'missing', false) + return { exitCode: 0, stdout: 'loopx 1.2.4\n', stderr: '' } + } + if (args.includes('workflow-skills')) { + const operation = args.includes('--install') ? 'install' : 'inspect' + return { + exitCode: 0, + stdout: workflowPayload(operation, false), + stderr: '', + } + } + throw new Error(`unexpected argv: ${file} ${args.join(' ')}`) + } + + const result = await initializeLoopX({ + runner, + env: { PATH: pythonPath }, + skillsDir: '/fixture/skills', + runtimeDir: '/fixture/runtime', + }) + + expect(result.cliInstalled).toBe(true) + expect(calls.some(call => ( + call.file === 'python3' + && call.args[0] === '-c' + ))).toBe(true) + const pipCall = calls.find(call => call.args.slice(0, 3).join(' ') === '-m pip install') + expect(pipCall?.file).toBe('python') + const workflowCalls = calls.filter(call => call.args.includes('workflow-skills')) + expect(workflowCalls.every(call => call.file === 'python')).toBe(true) + const cliBins = workflowCalls.map(call => call.args[call.args.indexOf('--cli-bin') + 1]) + expect(cliBins.every(value => ( + typeof value === 'string' + && value.startsWith('python ') + && value.includes('loopx_cli.py') + ))).toBe(true) + }) + it('skips an implicit version-compatible Python that cannot run pip', async () => { const pythonPath = await mkdtemp(join(tmpdir(), 'loopx-installed-pythons-')) onTestFinished(() => rm(pythonPath, { recursive: true, force: true })) diff --git a/packages/dsh-loopx-plugin/tests/python-discovery.spec.ts b/packages/dsh-loopx-plugin/tests/python-discovery.spec.ts index 2c8eb10e06..ca6a7d9cdf 100644 --- a/packages/dsh-loopx-plugin/tests/python-discovery.spec.ts +++ b/packages/dsh-loopx-plugin/tests/python-discovery.spec.ts @@ -13,20 +13,38 @@ describe('installed Python discovery', () => { } await mkdir(join(directory, 'python3.998')) expect(pluginPythonCandidates({ env: { PATH: directory } })).toEqual([ - 'python3', 'python3.97', 'python3.12', 'python3.9', + 'python3', 'python3.97', 'python3.12', 'python3.9', 'python', ]) expect(pluginPythonCandidates({ env: { PATH: `${directory}${delimiter}${directory}` } })) - .toEqual(['python3', 'python3.97', 'python3.12', 'python3.9']) + .toEqual(['python3', 'python3.97', 'python3.12', 'python3.9', 'python']) } finally { await rm(directory, { recursive: true, force: true }) } }) + it('discovers a versioned Windows interpreter and ignores a bare-name alias stub', async () => { + const alias = await mkdtemp(join(tmpdir(), 'loopx-python-alias-')) + const host = await mkdtemp(join(tmpdir(), 'loopx-python-host-')) + try { + // The WindowsApps alias directory carries a stub python3.exe that starts + // and exits 9009 without running Python and names no minor version; a + // Windows host can also register python3.12.exe next to python.exe. + await writeFile(join(alias, 'python3.exe'), '') + await writeFile(join(host, 'python3.12.exe'), '# fixture executable\n') + await writeFile(join(host, 'python.exe'), '# fixture executable\n') + expect(pluginPythonCandidates({ env: { PATH: `${alias}${delimiter}${host}` } })) + .toEqual(['python3', 'python3.12.exe', 'python']) + } finally { + await rm(alias, { recursive: true, force: true }) + await rm(host, { recursive: true, force: true }) + } + }) + it('keeps an explicit interpreter authoritative and tolerates missing PATH directories', () => { expect(pluginPythonCandidates({ pythonBin: '/configured/python', env: { PATH: '/missing' } })) .toEqual(['/configured/python']) expect(pluginPythonCandidates({ env: { PATH: '/missing', PYTHON_BIN: '/custom/python' } })) .toEqual(['/custom/python']) - expect(pluginPythonCandidates({ env: { PATH: '/missing' } })).toEqual(['python3']) + expect(pluginPythonCandidates({ env: { PATH: '/missing' } })).toEqual(['python3', 'python']) }) })