From 354a0e7e8597957b33f5d4f4afcf9ac7ebfe8693 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:20:49 +0800 Subject: [PATCH 01/11] feat(codewhale): add shared install and OAuth setup commands --- codewhale.mjs | 211 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100755 codewhale.mjs diff --git a/codewhale.mjs b/codewhale.mjs new file mode 100755 index 0000000..165f079 --- /dev/null +++ b/codewhale.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +// Shared by the Bash and PowerShell entrypoints; no credentials or host config are written here. +import { spawnSync } from 'node:child_process'; +import { existsSync, realpathSync, statSync } from 'node:fs'; +import { basename, delimiter, dirname, isAbsolute, join } from 'node:path'; +import { createRequire } from 'node:module'; +import { createInterface } from 'node:readline/promises'; +import { pathToFileURL } from 'node:url'; + +export const PROVIDER_REV = '8c78be0f936fb8f508badabc0195cdb21442a75d'; +export const PROVIDER_URL = `https://github.com/TokenNotIncluded/codewhale-lmm-provider/archive/${PROVIDER_REV}.tar.gz`; +const require = createRequire(import.meta.url); +const actions = ['setup', 'install', 'login', 'run', 'models', 'status', 'balance', 'usage', 'logout', 'doctor', 'menu', 'help']; +const help = `Codewhale + LMM (Node.js 22+, npm) + + bash codewhale.sh [action] [options] + .\\codewhale.ps1 [action] [options] + node codewhale.mjs [action] [options] + + setup Install, authorize in your browser, optionally choose a model and start (default) + install Install/update only; never log in (also suitable for CI) + login Authorize LMM; use --no-browser to open the URL yourself + run Choose a catalog model, or pass --model + models | status | balance | usage | logout + doctor Verify the selected native executable and adapter, without logging in + menu Installation, login, model selection, account queries and revocation + + run --model -- exec "your task" (arguments are forwarded without a shell) + +Uses official npm codewhale@latest and a pinned LMM adapter. Does not install Node, +change npm's registry, overwrite Codewhale config, or enable unsafe/auto-approve modes. +The server must deploy the lmm-codewhale registration (api.lmm.best PR #440). +Termux/Android remains preview. Windows adapter ACL hardening is not implemented; +use a private OS account, not a shared Windows account. +`; + +export function parseArguments(argv) { + const result = { action: 'setup', noBrowser: false, model: undefined, forwarded: [] }; + const args = [...argv]; + if (args[0] && !args[0].startsWith('-')) result.action = args.shift(); + while (args.length) { + const arg = args.shift(); + if (arg === '--') { result.forwarded = args.splice(0); break; } + if (arg === '--help' || arg === '-h') result.action = 'help'; + else if (arg === '--no-browser') result.noBrowser = true; + else if (arg === '--model' && !result.model && args[0] && !args[0].startsWith('-')) result.model = args.shift(); + else throw new Error('Invalid arguments. Use --help.'); + } + if (!actions.includes(result.action)) throw new Error('Unknown action. Use --help.'); + if (result.noBrowser && !['login', 'setup'].includes(result.action)) throw new Error('--no-browser is only valid with login/setup.'); + if ((result.model || result.forwarded.length) && result.action !== 'run') throw new Error('--model and forwarded arguments are only valid with run.'); + if (result.forwarded.some(arg => /^--(?:provider|model|base-url|api-key|config|config-path)(?:=|$)/.test(arg))) { + throw new Error('Do not override the LMM provider, model, configuration or credentials after --.'); + } + return result; +} + +export function npmCLI(env = process.env, platform = process.platform) { + // Run npm's JS entrypoint with Node: never interpolate arguments into cmd.exe on Windows. + for (const dir of (env.PATH || env.Path || '').split(delimiter).filter(Boolean)) { + const entry = join(dir, platform === 'win32' ? 'npm.cmd' : 'npm'); + if (!existsSync(entry)) continue; + const resolved = realpathSync(entry); + const candidates = [ + ...(basename(resolved) === 'npm-cli.js' ? [resolved] : []), + join(dirname(resolved), 'node_modules/npm/bin/npm-cli.js'), + join(dirname(resolved), '../lib/node_modules/npm/bin/npm-cli.js'), + ]; + const found = candidates.find(path => existsSync(path) && statSync(path).isFile()); + if (found) return realpathSync(found); + } + throw new Error('npm was not found with this Node installation. Install Node.js 22+ with npm; then reopen the terminal.'); +} + +export function command(binary, args, { capture = false, env = process.env } = {}) { + const child = spawnSync(binary, args, { env, shell: false, windowsHide: true, + stdio: capture ? ['inherit', 'pipe', 'inherit'] : 'inherit', encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); + if (child.error || child.signal || child.status !== 0) { + const error = new Error(`${basename(binary)} failed${child.signal ? ` (${child.signal})` : ` (exit ${child.status ?? 'unknown'})`}. No later step was run.`); + error.exitCode = child.signal === 'SIGINT' ? 130 : child.status || 1; + throw error; + } + return child.stdout || ''; +} + +export function parseModels(text) { + const models = JSON.parse(text); + if (!Array.isArray(models) || models.length > 10000 || models.some(m => + !m || typeof m.id !== 'string' || !/^lmm:[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/.test(m.id))) { + throw new Error('Invalid model catalog; no model was selected.'); + } + if (new Set(models.map(m => m.id)).size !== models.length) throw new Error('Duplicate catalog model IDs.'); + return models; +} +export function selectModel(models, choice) { + if (!/^[1-9][0-9]{0,4}$/.test(choice) || Number(choice) > models.length) throw new Error('Invalid model number.'); + return models[Number(choice) - 1].id; +} +const display = value => String(value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, 180); + +export async function nativeHost(root, env = process.env) { + let binary = env.LMM_CODEWHALE_BIN; + if (!binary) { + const installer = join(root, 'codewhale/scripts/install.js'); + if (!existsSync(installer)) throw new Error('Codewhale is not installed by npm. Run the install action first.'); + const { getBinaryPath } = require(installer); + if (typeof getBinaryPath !== 'function') throw new Error('The official npm launcher contract changed. Stop here and update lmm-scripts.'); + // Use the official resolver and checksum verification, including Android assets. + binary = await getBinaryPath('codewhale'); + } + if (typeof binary !== 'string' || !isAbsolute(binary) || /\.(?:cmd|bat|ps1|js|mjs)$/i.test(binary) || + !existsSync(binary) || !statSync(binary).isFile()) { + throw new Error('Expected an absolute native Codewhale executable, not an npm .cmd shim.'); + } + return binary; +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseArguments(argv); + if (options.action === 'help') { console.log(help); return; } + if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('Node.js 22+ is required.'); + const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY); + if (['setup', 'menu'].includes(options.action) && !interactive) { + throw new Error('An interactive terminal is required. Use install for unattended installation; login/run are separate actions.'); + } + const npm = npmCLI(); + const root = command(process.execPath, [npm, 'root', '--global'], { capture: true }).trim(); + if (!isAbsolute(root) || /[\r\n]/.test(root)) throw new Error('npm returned an invalid global package directory.'); + const adapter = join(root, '@tokennotincluded/codewhale-lmm-provider/src/cli.mjs'); + const call = (args, settings = {}) => { + if (!existsSync(adapter)) throw new Error('LMM adapter is missing. Run the install action first.'); + return command(process.execPath, [adapter, ...args], settings); + }; + const ask = async prompt => { + if (!interactive) throw new Error('Choose a model with run --model outside an interactive terminal.'); + const input = createInterface({ input: process.stdin, output: process.stdout }); + try { return (await input.question(prompt)).trim(); } finally { input.close(); } + }; + const doctor = async () => { + const binary = await nativeHost(root); + console.log(`Native executable: ${binary}`); + command(binary, ['--version']); + // The official npm wrapper may return zero for --version even without a binary. + // Invoke the resolved native binary, and check --help too. + command(binary, ['--help'], { capture: true }); + call(['--help'], { capture: true }); + console.log('Native executable and LMM adapter are available. This is not a live OAuth test.'); + }; + const install = async () => { + console.log('Installing official Codewhale and the pinned LMM adapter; existing login/configuration files are left alone.'); + command(process.execPath, [npm, 'install', '--global', 'codewhale@latest']); + command(process.execPath, [npm, 'install', '--global', '--ignore-scripts', PROVIDER_URL]); + await doctor(); + console.log('Next: login, then run. The server must include the lmm-codewhale registration (PR #440).'); + if (process.platform === 'android') console.log('Android/Termux support is preview; no Linux binary fallback is used.'); + if (process.platform === 'win32') console.log('Use a private Windows account: the alpha adapter does not implement Windows ACL hardening.'); + }; + const run = async () => { + const models = parseModels(call(['models'], { capture: true })); + if (!models.length) throw new Error('No authorized Chat Completions models. Check the LMM account/groups; no fallback was selected.'); + let id = options.model; + if (id && !models.some(m => m.id === id)) throw new Error('The model ID is not in the authorized catalog.'); + if (!id && models.length === 1) id = models[0].id; + if (!id) { + models.forEach((m, i) => console.log(`${i + 1} ${display(m.name || m.id)} [${display(m.group)}]\n ${m.id}`)); + const choice = await ask('Model number (0 cancels): '); + if (choice === '0') return; + id = selectModel(models, choice); + } + const binary = await nativeHost(root); + call(['run', '--model', id, ...(options.forwarded.length ? ['--', ...options.forwarded] : [])], + { env: { ...process.env, LMM_CODEWHALE_BIN: binary } }); + }; + const login = () => call(['login', ...(options.noBrowser ? ['--no-browser'] : [])]); + const setup = async () => { + await install(); + const status = JSON.parse(call(['status'], { capture: true })); + if (!status.signed_in) login(); + else console.log('Keeping the existing LMM authorization; it was not overwritten.'); + if ((await ask('Choose a model and start Codewhale now? [y/N] ')).toLowerCase() === 'y') await run(); + else console.log('Ready. Use the run action to choose a model later.'); + }; + const execute = async action => { + if (action === 'install') await install(); + else if (action === 'setup') await setup(); + else if (action === 'doctor') await doctor(); + else if (action === 'run') await run(); + else if (action === 'login') login(); + else call([action]); + }; + if (options.action !== 'menu') { await execute(options.action); return; } + const entries = ['setup', 'install', 'login', 'run', 'models', 'status', 'balance', 'usage', 'logout', 'doctor']; + const labels = ['Install + OAuth setup', 'Install / update only', 'Log in', 'Choose model and start', 'Models', 'Login status', 'Balance', 'Usage', 'Revoke authorization and log out', 'Check installation']; + while (true) { + console.log('\nCodewhale + LMM'); + entries.forEach((_, i) => console.log(`${i + 1} ${labels[i]}`)); + const choice = await ask('0 Back\n> '); + if (choice === '0') return; + if (!/^(?:[1-9]|10)$/.test(choice)) continue; + const action = entries[Number(choice) - 1]; + if (action === 'logout' && (await ask('Revoke LMM authorization? [y/N] ')).toLowerCase() !== 'y') continue; + try { await execute(action); } + catch (error) { if (error.exitCode === 130) throw error; console.error(error.message); } + } +} + +try { + if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { + await main().catch(error => { console.error(error.message); process.exitCode = error.exitCode || 1; }); + } +} catch (error) { console.error(error.message); process.exitCode = 1; } From 64e9c6ea048de43312c9579e8bdd05c9bf84d9c7 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:23:08 +0800 Subject: [PATCH 02/11] feat(codewhale): add verified Bash and PowerShell bootstrap scripts --- codewhale.ps1 | 37 +++++++++++++++++++++++++++++++++++++ codewhale.sh | 15 +++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 codewhale.ps1 create mode 100755 codewhale.sh diff --git a/codewhale.ps1 b/codewhale.ps1 new file mode 100644 index 0000000..7966607 --- /dev/null +++ b/codewhale.ps1 @@ -0,0 +1,37 @@ +$ErrorActionPreference = 'Stop' +$node = (Get-Command node -CommandType Application -ErrorAction Stop).Source +$nodeVersion = & $node -p 'process.versions.node' +if ($LASTEXITCODE) { exit $LASTEXITCODE } +if ([int]($nodeVersion.Split('.')[0]) -lt 22) { throw 'Node.js 22+ with npm is required.' } +# Windows PowerShell 5.1 drops embedded quotes in native @args calls. +# Build an argv-equivalent command line, without cmd.exe or Invoke-Expression. +function ConvertTo-NativeArgument([string]$Value) { + $escaped = [regex]::Replace($Value, '(\\*)"', '$1$1\"') + $escaped = [regex]::Replace($escaped, '(\\+)$', '$1$1') + return '"' + $escaped + '"' +} +$work = $null +try { + $helper = $null + if ($PSScriptRoot -and (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'codewhale.mjs') -PathType Leaf)) { + $helper = Join-Path $PSScriptRoot 'codewhale.mjs' + } else { + $work = Join-Path ([IO.Path]::GetTempPath()) ('lmm-codewhale-' + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $work | Out-Null + $helper = Join-Path $work 'codewhale.mjs' + Invoke-WebRequest -UseBasicParsing 'https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/354a0e7e8597957b33f5d4f4afcf9ac7ebfe8693/codewhale.mjs' -OutFile $helper + if ((Get-FileHash -LiteralPath $helper -Algorithm SHA256).Hash -ne 'd985a055e9f5a95ad8b358d3569f4a9d33a1eaca52dffa39c4b8aa2268a3bbf1') { + throw 'Codewhale setup script checksum mismatch; nothing was executed.' + } + } + $start = New-Object System.Diagnostics.ProcessStartInfo + $start.FileName = $node + $start.UseShellExecute = $false + $start.WorkingDirectory = $PWD.Path + $start.Arguments = ((@($helper) + @($args) | ForEach-Object { ConvertTo-NativeArgument $_ }) -join ' ') + $child = [Diagnostics.Process]::Start($start) + try { $child.WaitForExit(); $code = $child.ExitCode } finally { $child.Dispose() } + exit $code +} finally { + if ($work) { Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/codewhale.sh b/codewhale.sh new file mode 100755 index 0000000..bac5ef3 --- /dev/null +++ b/codewhale.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +command -v node >/dev/null 2>&1 || { echo 'Install Node.js 22+ with npm first. Termux: pkg install nodejs npm' >&2; exit 1; } +node -e 'if(Number(process.versions.node.split(".")[0])<22){console.error("Node.js 22+ is required.");process.exit(1)}' +dir=$(dirname -- "${BASH_SOURCE[0]:-}") +if [[ -n ${BASH_SOURCE[0]:-} && -f $dir/codewhale.mjs ]]; then + exec node "$dir/codewhale.mjs" "$@" +fi +temp_root=${TMPDIR:-/tmp} +if [[ -n ${TERMUX_VERSION:-} || ${PREFIX:-} == */com.termux/files/usr ]]; then temp_root=${TMPDIR:-${PREFIX:-/data/data/com.termux/files/usr}/tmp}; fi +work=$(mktemp -d "$temp_root/lmm-codewhale.XXXXXX") +trap 'rm -rf -- "$work"' EXIT +curl --proto '=https' --proto-redir '=https' -fsSL 'https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/354a0e7e8597957b33f5d4f4afcf9ac7ebfe8693/codewhale.mjs' -o "$work/codewhale.mjs" +node -e 'const fs=require("node:fs"),crypto=require("node:crypto");if(crypto.createHash("sha256").update(fs.readFileSync(process.argv[1])).digest("hex")!==process.argv[2]){console.error("Codewhale setup script checksum mismatch; nothing was executed.");process.exit(1)}' "$work/codewhale.mjs" 'd985a055e9f5a95ad8b358d3569f4a9d33a1eaca52dffa39c4b8aa2268a3bbf1' +node "$work/codewhale.mjs" "$@" From 798dcd4e2b47531e6ce61f8b96e7cd3413c958a1 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:32:40 +0800 Subject: [PATCH 03/11] feat(menu): add Codewhale setup entry, documentation and cross-platform tests --- .gitattributes | 1 + .github/workflows/codewhale.yml | 46 ++++++ CODEWHALE.md | 70 +++++++++ README.md | 17 +- menu.ps1 | 13 +- menu.sh | 14 +- test-codewhale-menu.py | 122 +++++++++++++++ test-codewhale.mjs | 266 ++++++++++++++++++++++++++++++++ test-codewhale.ps1 | 58 +++++++ test.py | 7 +- 10 files changed, 594 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/codewhale.yml create mode 100644 CODEWHALE.md create mode 100644 test-codewhale-menu.py create mode 100644 test-codewhale.mjs create mode 100644 test-codewhale.ps1 diff --git a/.gitattributes b/.gitattributes index dc3135d..3f25996 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,4 @@ *.sh text eol=lf *.ps1 text eol=lf *.py text eol=lf +*.mjs text eol=lf diff --git a/.github/workflows/codewhale.yml b/.github/workflows/codewhale.yml new file mode 100644 index 0000000..f2e297d --- /dev/null +++ b/.github/workflows/codewhale.yml @@ -0,0 +1,46 @@ +name: Codewhale installer +on: [push, pull_request, workflow_dispatch] +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + verify: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: '22' + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.x' + - name: Offline contracts + run: node --test test-codewhale.mjs + - name: Actual menu terminal routing + if: runner.os != 'Windows' + run: python3 test-codewhale-menu.py + - name: PowerShell entrypoint contracts + shell: pwsh + run: ./test-codewhale.ps1 + - name: Windows PowerShell 5.1 contracts + if: runner.os == 'Windows' + shell: powershell + run: .\test-codewhale.ps1 + - name: Real install, without OAuth or inference + shell: pwsh + run: | + $env:npm_config_prefix = Join-Path $env:RUNNER_TEMP 'codewhale-npm' + $env:LMM_CODEWHALE_HOME = Join-Path $env:RUNNER_TEMP 'codewhale-auth-unused' + Remove-Item Env:LMM_CODEWHALE_BIN -ErrorAction SilentlyContinue + node codewhale.mjs install + if ($LASTEXITCODE) { throw 'Codewhale installation/verification failed' } + node codewhale.mjs doctor + if ($LASTEXITCODE) { throw 'Native/adapter verification failed' } + if (Test-Path $env:LMM_CODEWHALE_HOME) { throw 'Install unexpectedly created OAuth state' } diff --git a/CODEWHALE.md b/CODEWHALE.md new file mode 100644 index 0000000..b809124 --- /dev/null +++ b/CODEWHALE.md @@ -0,0 +1,70 @@ +# Codewhale 一键安装和 LMM 配置 + +需要 Node.js 22+ 和 npm。Bash 入口单独下载时还需要 curl。脚本不代装 Node,不改 npm registry,不使用 sudo 或 `--force`。安装使用官方的 `npm install --global codewhale@latest`;二进制选择和校验仍由官方 npm 安装器负责。LMM 适配器安装固定 Git 提交 `8c78be0f936fb8f508badabc0195cdb21442a75d`,禁用其 npm 生命周期脚本;不依赖尚未发布的 npm 包。 + +## 安装并配置 + +Linux、macOS,或已准备好原生 Node/npm 的 Termux: + +```sh +curl -fsSLo codewhale.sh https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/main/codewhale.sh +bash codewhale.sh +``` + +Windows PowerShell: + +```powershell +Invoke-WebRequest -UseBasicParsing https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/main/codewhale.ps1 -OutFile codewhale.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File .\codewhale.ps1 +``` + +默认执行 `setup`:安装、检查原生程序、通过浏览器 OAuth 登录,再询问是否选择模型启动。已有授权会保留,不会重新登录覆盖。OAuth 授权仍必须由本人在网页确认;脚本不绕过同意页。拒绝或失败不会被报告为成功。 + +**服务端必须先合并并部署 api.lmm.best PR #440 中的 `lmm-codewhale` 注册。** 安装成功不是生产登录成功,更不代表已完成计费验收。 + +## 菜单和独立命令 + +总菜单新增 `codewhale`:普通环境第 8 项,Termux 第 6 项,原有工具编号不变。进入后可选择安装并配置、仅安装/更新、登录、选模型启动、模型列表、登录状态、余额、用量、撤销授权或检查安装。菜单中的退出登录有二次确认。 + +```sh +bash codewhale.sh menu +bash codewhale.sh install # 仅安装,适合自动化;不登录 +bash codewhale.sh login +bash codewhale.sh login --no-browser +bash codewhale.sh models +bash codewhale.sh run # 多模型时按编号选择;0 取消 +bash codewhale.sh run --model '<完整目录 ID>' -- exec '检查项目测试' +bash codewhale.sh status +bash codewhale.sh balance +bash codewhale.sh usage +bash codewhale.sh logout # 撤销失败时由适配器保留凭据 +bash codewhale.sh doctor # 原生程序 --version/--help + 适配器 --help +``` + +PowerShell 将 `bash codewhale.sh` 换为 `.\codewhale.ps1`。克隆仓库后也可直接运行 `node codewhale.mjs menu`,两个入口共用这一份实现。 + +非交互环境不能运行 `setup`/`menu`,请显式使用 `install`。非交互启动有多个模型时必须传 `--model`,不猜测模型和分组。`--no-browser` 只是手动打开授权 URL,回环回调仍需浏览器能够访问当前机器,不是 device-code 或 SSH 登录方案。 + +## 配置和平台边界 + +脚本只调用适配器,不另外复制 access/refresh token,不覆盖 Codewhale 的 TOML、shell 配置或系统 PATH。已有的 `LMM_ISSUER`、`LMM_CODEWHALE_HOME` 仍由适配器使用。正常启动显式使用官方 npm 解析到的原生二进制,而不是 Windows 的 `codewhale.cmd`;也可通过 `LMM_CODEWHALE_BIN` 指定已有原生程序的绝对路径。 + +`codewhale-lmm run` 在每次运行时建立临时 provider 配置,结束后清理。它不会合并用户原 TOML 中的自定义运行参数。这是伴随 CLI 接入,不是原生 `/login LMM` 插件。目前只支持目录声明的 Chat Completions 模型。 + +单独下载的 Bash/PowerShell 入口使用固定提交和 SHA-256 校验共用脚本;下载失败、内容不匹配或子进程失败都会停止后续步骤。克隆源码时使用同目录文件,便于维护;这不是对可修改本地目录的完整性保护。 + +Termux 先准备 `pkg install nodejs npm`,Codewhale Android 资产和设备支持仍属预览,不把 Linux ARM64 程序冒充 Android 程序。Windows 适配器尚无独立 ACL 加固,不适合共享 Windows 账号。现有系统/包管理器安装发生文件冲突时应先核对,不用强制覆盖绕过。 + +## 验证 + +```sh +node --test test-codewhale.mjs +python3 test-codewhale-menu.py +pwsh -NoProfile -File test-codewhale.ps1 +``` + +Node 测试使用模拟 npm、适配器和原生程序;菜单测试实际分配伪终端,覆盖本地/远程入口、原有编号、Termux 隐藏项和失败下载。PowerShell 测试覆盖语法、帮助、引号/反斜杠参数、退出码和校验拒绝。 + +新增 CI 在 Ubuntu、macOS、Windows 运行离线测试,并使用隔离的 npm prefix 做真实下载安装和无账号启动检查;另覆盖 Windows PowerShell 5.1。CI 结果以对应提交为准。以上都不代替生产账号 OAuth、真实推理和 Termux 真机验收。 + +接口依据:[官方安装文档](https://github.com/Hmbown/Codewhale/blob/1554ac11846bc3e1423b0ee66591eea2870f10b1/docs/INSTALL.md)、[官方 npm 原生程序解析](https://github.com/Hmbown/Codewhale/blob/1554ac11846bc3e1423b0ee66591eea2870f10b1/npm/codewhale/scripts/run.js)、[LMM 适配器](https://github.com/TokenNotIncluded/codewhale-lmm-provider)。 diff --git a/README.md b/README.md index 1fe37bd..381c2cf 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,19 @@ irm https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/main/menu.ps1 | `cc-switch` | Linux 发行包/AUR,macOS Homebrew,Windows 官方 MSI | 桌面应用 | | `clash-verge-rev` | Linux deb/rpm/AUR,macOS Homebrew,Windows WinGet | 桌面应用 | | `lmm` | 0.1.0 预览版发行包;另支持 `--from-source` / `-FromSource` | 安装结束打印的完整路径 | +| `codewhale` | 官方 npm 安装 + 固定版本 LMM OAuth 适配器 | `bash codewhale.sh run` / `.\codewhale.ps1 run` | Pi 的版本、Node、安装位置、权限和 Windows Git Bash 交给官方安装器。官方正常安装后,才从它选出的安装路径接着装 LMM 插件;取消或卸载不继续。现有 LMM alpha 只验证过 Pi 0.85.1,其他版本会明确跳过插件,不偷偷降级宿主。DSH 的宿主和插件继续固定已适配版本;其当前官方 README 使用 npx,没有现行的独立安装脚本,不能拿归档记录中的旧脚本替代。 -不写供应商配置、不登录账号、不启用系统代理或 TUN。 +原有安装器不写供应商配置、不登录账号、不启用系统代理或 TUN。Codewhale 默认 `setup` 会引导用户在浏览器确认 OAuth;`install` 只安装、不登录。两者都不覆盖原配置,见 [Codewhale 安装与配置](CODEWHALE.md)。 + +## Codewhale + +菜单新增第 8 项 `codewhale`(Termux 为第 6 项),进入安装、登录、选模型启动、余额/用量和登出子菜单,原有工具编号不变。可直接运行 `bash codewhale.sh` 或 `.\codewhale.ps1` 完成安装和授权;非交互环境必须显式使用 `install`。需要服务端先部署 `lmm-codewhale` 注册,安装成功不代表生产登录已验证。 ## 环境 -Unix 入口需要 Bash、curl。Pi 的依赖提示由官方处理;DSH 另需 Node 22.19+(22.x)或 24+、npm。不修改 npm registry。 +Unix 入口需要 Bash、curl。Pi 的依赖提示由官方处理;DSH 另需 Node 22.19+(22.x)或 24+、npm;Codewhale LMM 适配器需要 Node 22+ 和 npm。不修改 npm registry。 Codex/Claude 的 Linux、macOS、Windows 安装都用官方脚本,不要求 Node,也不限定 apt 发行版。先满足上游的系统和运行库要求。Alpine 的 Claude 需要 `bash curl libgcc libstdc++ ripgrep`,运行时使用 `USE_BUILTIN_RIPGREP=0 claude`。NixOS 请使用 Nix 包环境,不保证通用二进制可运行。 @@ -39,7 +44,7 @@ LMM 预览包仅提供 Linux x64(glibc 2.39+)、macOS arm64、Windows x64, ## Termux -Pi 先运行 `pkg install nodejs npm git` 准备 Android 原生依赖,再调用同一个官方安装器;没有 `TMPDIR` 时使用 `$PREFIX/tmp`。DSH 使用同一 npm 安装方式,Android 原生依赖尚未真机验证。 +Pi 先运行 `pkg install nodejs npm git` 准备 Android 原生依赖,再调用同一个官方安装器;没有 `TMPDIR` 时使用 `$PREFIX/tmp`。DSH 使用同一 npm 安装方式,Android 原生依赖尚未真机验证。Codewhale 使用官方 npm 的 Android 资产选择,不回退为 Linux ARM64 二进制,仍属预览且未完成真机验收。 Codex/Claude 使用**已有的 PRoot Linux 环境**,不是 Android 原生二进制。先准备 `proot-distro` 和 Ubuntu guest,并在 guest 中安装 Bash、curl、CA 证书,再运行相应脚本。其他 guest 用 `LMM_DISTRO=名称 bash codex.sh`。安装后进入同一 guest 运行 `codex` / `claude`;不再生成转发启动器,不创建或重置 guest,不关闭沙箱。两个桌面工具不支持 Termux,菜单会隐藏它们。 @@ -47,10 +52,10 @@ Codex/Claude 使用**已有的 PRoot Linux 环境**,不是 Android 原生二 旧的 `--network`、`--root`、`--check`、`--update` 等自定义参数已移除;不要继续传入。Codex/Claude 只接受各自官方参数,DSH 可传 profile。旧版 `lmm-tools` 目录不会被删除;从 PATH 中移除旧的 `lmm-tools/bin`,避免旧启动器优先于新安装。配置和账号数据不迁移、不清空。 -所有安装代码平铺在根目录;只有两个 Unix 桌面入口共用 `desktop.sh`。本地运行用同目录文件,单独下载运行时用固定 Git 提交获取共用脚本;菜单同样固定到完整的安装器提交。不维护生成器或哈希清单。 +所有安装代码平铺在根目录;两个 Unix 桌面入口共用 `desktop.sh`,Codewhale 两个平台入口共用 `codewhale.mjs`。本地运行用同目录文件,单独下载运行时用固定 Git 提交获取共用脚本;Codewhale 额外校验 SHA-256。菜单同样固定到完整的安装器提交。不维护生成器或独立哈希清单。 ## 依据与测试 -2026-09-21 核查:[Pi 官网安装入口](https://pi.dev/)([Shell](https://pi.dev/install.sh) / [PowerShell](https://pi.dev/install.ps1))· [Codex](https://learn.chatgpt.com/docs/codex/cli) · [Claude Code](https://code.claude.com/docs/en/setup) · [Pi Termux](https://pi.dev/docs/latest/termux) · [DSH 当前 README](https://github.com/deepseek-ai/deepseek-harness/blob/master/README.md) · [DSH 插件](https://deepseek-harness.github.io/deepseek-harness/en/develop/basic/publish) · [CC Switch](https://github.com/farion1231/cc-switch#download--installation) · [Clash Verge Rev](https://www.clashverge.dev/install.html)。LMM 插件适配版本见 [Pi 插件](https://github.com/TokenNotIncluded/pi-lmm-provider) 和 [DSH 插件](https://github.com/TokenNotIncluded/dsh-lmm-provider)。 +2026-09-21 核查:[Pi 官网安装入口](https://pi.dev/)([Shell](https://pi.dev/install.sh) / [PowerShell](https://pi.dev/install.ps1))· [Codex](https://learn.chatgpt.com/docs/codex/cli) · [Claude Code](https://code.claude.com/docs/en/setup) · [Pi Termux](https://pi.dev/docs/latest/termux) · [DSH 当前 README](https://github.com/deepseek-ai/deepseek-harness/blob/master/README.md) · [DSH 插件](https://deepseek-harness.github.io/deepseek-harness/en/develop/basic/publish) · [CC Switch](https://github.com/farion1231/cc-switch#download--installation) · [Clash Verge Rev](https://www.clashverge.dev/install.html)。LMM 插件适配版本见 [Pi 插件](https://github.com/TokenNotIncluded/pi-lmm-provider) 和 [DSH 插件](https://github.com/TokenNotIncluded/dsh-lmm-provider)。Codewhale 依据和平台限制见 [专用说明](CODEWHALE.md)。 -本地检查:`python3 test.py`、`pwsh -NoProfile -File test.ps1`、`shellcheck *.sh`。CI 另做三平台 CLI 实装和 Debian/Alpine 实装;不把模拟测试当作桌面 GUI、Termux 真机、账号登录或代理功能实测。 +本地检查:`python3 test.py`、`pwsh -NoProfile -File test.ps1`、`shellcheck *.sh`。Codewhale 另有 `node --test test-codewhale.mjs`、`python3 test-codewhale-menu.py`、`pwsh -NoProfile -File test-codewhale.ps1`。CI 另做三平台 CLI 实装和 Debian/Alpine 实装;不把模拟测试当作桌面 GUI、Termux 真机、账号登录或代理功能实测。 diff --git a/menu.ps1 b/menu.ps1 index 56c5324..e2442a5 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -1,6 +1,6 @@ $ErrorActionPreference = 'Stop' if ($args.Count) { throw 'Usage: .\menu.ps1' } -$tools = @('pi','dsh','lmm','codex','claude-code','cc-switch','clash-verge-rev') +$tools = @('pi','dsh','lmm','codex','claude-code','cc-switch','clash-verge-rev','codewhale') $work = Join-Path ([IO.Path]::GetTempPath()) ("lmm-menu-" + [guid]::NewGuid()) try { New-Item -ItemType Directory $work | Out-Null @@ -9,16 +9,19 @@ try { for ($i=0; $i -lt $tools.Count; $i++) { Write-Host "$($i+1) $($tools[$i])" } $choice = Read-Host '0 Exit' if ($choice -eq '0') { break } - if ($choice -notmatch '^[1-7]$') { continue } - $name = $tools[[int]$choice-1] + '.ps1' + $index = 0 + if (-not [int]::TryParse($choice, [ref]$index) -or $index -lt 1 -or $index -gt $tools.Count) { continue } + $name = $tools[$index-1] + '.ps1' + $installerArgs = @() + if ($tools[$index-1] -eq 'codewhale') { $installerArgs = @('menu') } try { $path = Join-Path $work $name if ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot $name))) { $path = Join-Path $PSScriptRoot $name } else { - Invoke-WebRequest -UseBasicParsing "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/b7b066e5ca0e8a16b13c37a308419bc475cd459b/$name" -OutFile $path + Invoke-WebRequest -UseBasicParsing "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/64e9c6ea048de43312c9579e8bdd05c9bf84d9c7/$name" -OutFile $path } - & (Get-Process -Id $PID).Path -NoProfile -ExecutionPolicy Bypass -File $path + & (Get-Process -Id $PID).Path -NoProfile -ExecutionPolicy Bypass -File $path @installerArgs if ($LASTEXITCODE) { Write-Warning "Installer exited with $LASTEXITCODE" } } catch { Write-Warning $_ } } diff --git a/menu.sh b/menu.sh index 1ddee00..d4af5dc 100755 --- a/menu.sh +++ b/menu.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail [[ $# = 0 ]] || { echo 'Usage: bash menu.sh' >&2; exit 2; } -tools=(pi dsh lmm codex claude-code cc-switch clash-verge-rev) -if [[ -n ${TERMUX_VERSION:-} || ${PREFIX:-} == */com.termux/files/usr ]]; then tools=(pi dsh lmm codex claude-code); fi +tools=(pi dsh lmm codex claude-code cc-switch clash-verge-rev codewhale) +if [[ -n ${TERMUX_VERSION:-} || ${PREFIX:-} == */com.termux/files/usr ]]; then tools=(pi dsh lmm codex claude-code codewhale); fi dir=$(dirname -- "${BASH_SOURCE[0]:-}") while true; do printf '\nInstall / update\n' @@ -10,13 +10,15 @@ while true; do printf '0 Exit\n> ' read -r choice &2 + bash "$dir/$tool.sh" "${installer_args[@]}" &2 else - script=$(curl -fsSL "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/b7b066e5ca0e8a16b13c37a308419bc475cd459b/$tool.sh") || continue - bash -c "$script" &2 + script=$(curl -fsSL "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/64e9c6ea048de43312c9579e8bdd05c9bf84d9c7/$tool.sh") || continue + bash -c "$script" -- "${installer_args[@]}" &2 fi done diff --git a/test-codewhale-menu.py b/test-codewhale-menu.py new file mode 100644 index 0000000..e5bb460 --- /dev/null +++ b/test-codewhale-menu.py @@ -0,0 +1,122 @@ +"""Real pseudo-terminal checks for menu routing; no packages or accounts are used.""" +import errno +import os +from pathlib import Path +import pty +import select +import shutil +import signal +import tempfile +import time +import unittest + +ROOT = Path(__file__).resolve().parent + + +class MenuTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='lmm-menu-') + self.home = Path(self.tmp.name) + shutil.copyfile(ROOT / 'menu.sh', self.home / 'menu.sh') + self.env = dict(os.environ) + self.env.pop('TERMUX_VERSION', None) + self.env.pop('PREFIX', None) + + def tearDown(self): + self.tmp.cleanup() + + def run_menu(self, choices): + bash = shutil.which('bash') + pid, fd = pty.fork() + if pid == 0: + os.chdir(self.home) + os.execve(bash, [bash, str(self.home / 'menu.sh')], self.env) + output = b'' + pending = list(choices) + deadline = time.monotonic() + 8 + status = None + try: + while time.monotonic() < deadline: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + chunk = os.read(fd, 65536) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not chunk: + break + output += chunk + if output.endswith(b'> ') and pending: + os.write(fd, (pending.pop(0) + '\n').encode()) + done, value = os.waitpid(pid, os.WNOHANG) + if done: + status = value + break + if status is None: + done, value = os.waitpid(pid, os.WNOHANG) + if done: + status = value + if status is None: + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + self.fail('Menu did not terminate: ' + output.decode(errors='replace')) + self.assertTrue(os.WIFEXITED(status), output) + self.assertEqual(os.WEXITSTATUS(status), 0, output) + self.assertFalse(pending, output) + return output.decode(errors='replace') + finally: + os.close(fd) + + def stub(self, name): + (self.home / (name + '.sh')).write_text("printf 'SELECTED:" + name + " %s\\n' \"$*\"\n") + + def test_new_eighth_option_routes_to_codewhale_submenu(self): + self.stub('codewhale') + output = self.run_menu(['8', '0']) + self.assertIn('8 codewhale', output) + self.assertIn('SELECTED:codewhale menu', output) + + def test_old_numbering_and_argument_contract_are_preserved(self): + self.stub('pi') + output = self.run_menu(['1', '0']) + self.assertIn('SELECTED:pi ', output) + self.assertNotIn('SELECTED:pi menu', output) + + def test_out_of_range_and_overflow_input_return_to_menu(self): + output = self.run_menu(['9', '999999999999999999999999999', '-1', '0']) + self.assertNotIn('SELECTED:', output) + + def test_termux_hides_desktop_apps_and_routes_sixth_option(self): + self.stub('codewhale') + self.env['TERMUX_VERSION'] = 'test' + output = self.run_menu(['6', '0']) + self.assertNotIn('cc-switch', output) + self.assertNotIn('clash-verge-rev', output) + self.assertIn('6 codewhale', output) + self.assertIn('SELECTED:codewhale menu', output) + + def test_remote_menu_preserves_submenu_argument(self): + bin_dir = self.home / 'bin' + bin_dir.mkdir() + stub = bin_dir / 'curl' + stub.write_text("#!/bin/sh\ncat <<'SCRIPT'\nprintf 'REMOTE:%s\\n' \"$*\"\nSCRIPT\n") + stub.chmod(0o755) + self.env['PATH'] = str(bin_dir) + os.pathsep + self.env['PATH'] + output = self.run_menu(['8', '0']) + self.assertIn('REMOTE:menu', output) + + def test_failed_remote_download_is_not_executed(self): + bin_dir = self.home / 'bin' + bin_dir.mkdir() + stub = bin_dir / 'curl' + stub.write_text('#!/bin/sh\necho "touch should-not-exist"\nexit 18\n') + stub.chmod(0o755) + self.env['PATH'] = str(bin_dir) + os.pathsep + self.env['PATH'] + self.run_menu(['8', '0']) + self.assertFalse((self.home / 'should-not-exist').exists()) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/test-codewhale.mjs b/test-codewhale.mjs new file mode 100644 index 0000000..28fbf0e --- /dev/null +++ b/test-codewhale.mjs @@ -0,0 +1,266 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, delimiter } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { parseArguments, npmCLI, parseModels, selectModel, nativeHost, command, PROVIDER_URL, PROVIDER_REV } from './codewhale.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const id = 'lmm:ZGVmYXVsdA:bW9kZWw'; +const other = 'lmm:YW5vdGhlcg:bW9kZWw'; +function fixture(t) { + const home = mkdtempSync(join(tmpdir(), 'lmm scripts space-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + const bin = join(home, 'bin'); + const root = join(home, 'global modules'); + const log = join(home, 'calls.jsonl'); + const npmFile = join(bin, 'node_modules/npm/bin/npm-cli.js'); + const adapter = join(root, '@tokennotincluded/codewhale-lmm-provider/src/cli.mjs'); + mkdirSync(dirname(npmFile), { recursive: true }); + mkdirSync(dirname(adapter), { recursive: true }); + writeFileSync(log, ''); + writeFileSync(npmFile, ` +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.TEST_LOG, JSON.stringify(['npm', args])+'\\n'); +if(args[0] === 'root') console.log(process.env.TEST_ROOT); +if(args[0] === 'install' && process.env.TEST_NPM_FAIL) process.exit(7); +`); + if (process.platform === 'win32') writeFileSync(join(bin, 'npm.cmd'), '@exit /b 99'); + else symlinkSync(npmFile, join(bin, 'npm')); + writeFileSync(adapter, ` +import fs from 'node:fs'; +const args = process.argv.slice(2); +fs.appendFileSync(process.env.TEST_LOG, JSON.stringify(['adapter', args, process.env.LMM_CODEWHALE_BIN || null])+'\\n'); +if(process.env.TEST_ADAPTER_FAIL === args[0]) process.exit(9); +if(args[0] === 'models') console.log(process.env.TEST_MODELS); +if(args[0] === 'status') console.log('{"signed_in":true}'); +`); + // Test native invocation portably: the real Node executable supports --version/--help. + const env = { ...process.env, PATH: bin + delimiter + process.env.PATH, + TEST_ROOT: root, TEST_LOG: log, TEST_MODELS: JSON.stringify([{ id, name: 'model', group: 'default' }]), + LMM_CODEWHALE_BIN: process.execPath }; + delete env.TEST_NPM_FAIL; delete env.TEST_ADAPTER_FAIL; + return { home, bin, root, npmFile, adapter, env, + calls: () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)), + run: (...args) => spawnSync(process.execPath, [join(here, 'codewhale.mjs'), ...args], { env, encoding: 'utf8', timeout: 10000 }), + }; +} + +test('help and default setup parse without loading installed packages', () => { + assert.equal(parseArguments([]).action, 'setup'); + assert.equal(parseArguments(['--help']).action, 'help'); + assert.deepEqual(parseArguments(['login', '--no-browser']), { action: 'login', noBrowser: true, model: undefined, forwarded: [] }); +}); +test('run preserves literal shell metacharacters after the separator', () => { + const task = 'literal $(touch SHOULD_NOT_EXIST); & | "hello"'; + assert.deepEqual(parseArguments(['run', '--model', id, '--', 'exec', task]).forwarded, ['exec', task]); +}); +test('unknown commands/options, missing values and cross-action flags fail closed', () => { + for (const args of [['delete'], ['install', '--local-only'], ['run', '--model'], ['run', '--model', '--help'], + ['run', '--model', id, '--model', other], ['status', '--no-browser'], ['install', '--model', id], ['login', '--', 'exec']]) { + assert.throws(() => parseArguments(args)); + } +}); +test('provider, credential and config overrides cannot be forwarded', () => { + for (const flag of ['--api-key=secret', '--provider', '--config-path', '--model=other', '--base-url=x']) { + assert.throws(() => parseArguments(['run', '--', flag])); + } +}); +test('catalog accepts separate groups with exact synthetic IDs', () => { + assert.deepEqual(parseModels(JSON.stringify([{ id }, { id: other }])), [{ id }, { id: other }]); + assert.equal(selectModel([{ id }, { id: other }], '2'), other); +}); +test('catalog rejects malformed, duplicate and control-character IDs', () => { + for (const json of ['{}', 'null', 'bad', JSON.stringify([{ id }, { id }]), JSON.stringify([{ id: 'raw-upstream' }]), + JSON.stringify([{ id: 'lmm:a:b\n' }])]) assert.throws(() => parseModels(json)); +}); +test('out-of-range and nonnumeric choices never silently select a model', () => { + for (const choice of ['0', '-1', '2', '01', '1x', '', '1;exit', '99999999999999999999']) { + assert.throws(() => selectModel([{ id }], choice)); + } +}); +test('npm CLI is resolved as JS rather than executing a Windows command shim', t => { + const f = fixture(t); + assert.equal(npmCLI(f.env), f.npmFile); + writeFileSync(join(f.bin, 'npm.cmd'), '@exit /b 99'); + assert.equal(npmCLI({ PATH: f.bin }, 'win32'), f.npmFile); +}); +test('missing npm has an actionable error', () => { + assert.throws(() => npmCLI({ PATH: '' }), /npm was not found/); +}); +test('headless setup/menu stops before npm, installation or authorization', t => { + const f = fixture(t); + for (const action of ['setup', 'menu']) { + const result = f.run(action); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /interactive terminal/); + } + assert.deepEqual(f.calls(), []); +}); +test('help needs no npm and never accesses account data', t => { + const f = fixture(t); f.env.PATH = ''; + const result = f.run('--help'); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /install.*Install\/update/); + assert.deepEqual(f.calls(), []); +}); +test('install uses official npm and pinned adapter; never logs in', t => { + const f = fixture(t); + const result = f.run('install'); + assert.equal(result.status, 0, result.stderr); + assert.match(PROVIDER_REV, /^[0-9a-f]{40}$/); + assert.deepEqual(f.calls().map(call => call.slice(0, 2)), [ + ['npm', ['root', '--global']], + ['npm', ['install', '--global', 'codewhale@latest']], + ['npm', ['install', '--global', '--ignore-scripts', PROVIDER_URL]], + ['adapter', ['--help']], + ]); +}); +test('failed host installation stops before adapter install and account access', t => { + const f = fixture(t); f.env.TEST_NPM_FAIL = '1'; + const result = f.run('install'); + assert.equal(result.status, 7, result.stderr); + assert.equal(f.calls().length, 2); + assert.match(result.stderr, /No later step/); +}); +test('login forwards no-browser, without installing or running inference', t => { + const f = fixture(t); + const result = f.run('login', '--no-browser'); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(f.calls().map(call => call.slice(0, 2)), [['npm', ['root', '--global']], ['adapter', ['login', '--no-browser']]]); +}); +test('failed login retains its failure code and never prints ready', t => { + const f = fixture(t); f.env.TEST_ADAPTER_FAIL = 'login'; + const result = f.run('login'); + assert.equal(result.status, 9); + assert.doesNotMatch(result.stdout, /Ready/); +}); +test('headless run with an exact ID forwards arguments literally and selects the native file', t => { + const f = fixture(t); + const task = 'literal $(echo no); "x" & |'; + const result = f.run('run', '--model', id, '--', 'exec', task); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(f.calls().at(-1), ['adapter', ['run', '--model', id, '--', 'exec', task], process.execPath]); +}); +test('a single catalog model may be selected without inventing a default group', t => { + const f = fixture(t); + assert.equal(f.run('run').status, 0); + assert.equal(f.calls().at(-1)[1][2], id); +}); +test('multiple models without a TTY fail before starting Codewhale', t => { + const f = fixture(t); f.env.TEST_MODELS = JSON.stringify([{ id }, { id: other }]); + assert.notEqual(f.run('run').status, 0); + assert.ok(!f.calls().some(call => call[0] === 'adapter' && call[1][0] === 'run')); +}); +test('empty or unauthorized catalog selection never launches inference', t => { + const f = fixture(t); + for (const models of ['[]', JSON.stringify([{ id: other }])]) { + f.env.TEST_MODELS = models; + assert.notEqual(f.run('run', '--model', id).status, 0); + } + assert.ok(!f.calls().some(call => call[0] === 'adapter' && call[1][0] === 'run')); +}); +test('queries and logout only invoke their corresponding adapter action', t => { + const f = fixture(t); + for (const action of ['models', 'status', 'balance', 'usage', 'logout']) { + assert.equal(f.run(action).status, 0); + assert.equal(f.calls().at(-1)[1][0], action); + } + assert.ok(!f.calls().some(call => call[0] === 'npm' && call[1][0] === 'install')); +}); +test('doctor checks native executable and adapter without credentials', t => { + const f = fixture(t); + const result = f.run('doctor'); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /not a live OAuth test/); + assert.deepEqual(f.calls().at(-1)[1], ['--help']); +}); +test('native resolver delegates to official getBinaryPath, not guessed download paths', async t => { + const f = fixture(t); + mkdirSync(join(f.root, 'codewhale/scripts'), { recursive: true }); + writeFileSync(join(f.root, 'codewhale/scripts/install.js'), `exports.getBinaryPath = async name => { if(name !== 'codewhale') throw Error('wrong binary'); return ${JSON.stringify(process.execPath)}; };`); + assert.equal(await nativeHost(f.root, {}), process.execPath); +}); +test('native resolver fails closed when upstream resolver contract is absent', async t => { + const f = fixture(t); + mkdirSync(join(f.root, 'codewhale/scripts'), { recursive: true }); + writeFileSync(join(f.root, 'codewhale/scripts/install.js'), 'exports.changed = true;'); + await assert.rejects(nativeHost(f.root, {}), /contract changed/); +}); +test('Windows npm shims, relative paths and missing binaries are not accepted', async t => { + const f = fixture(t); + const cmd = join(f.home, 'codewhale.cmd'); writeFileSync(cmd, 'exit 0'); + for (const value of [cmd, 'codewhale', join(f.home, 'missing')]) { + await assert.rejects(nativeHost(f.root, { LMM_CODEWHALE_BIN: value }), /native Codewhale executable/); + } +}); +test('child failure code is preserved without leaking arguments', () => { + assert.throws(() => command(process.execPath, ['-e', 'process.exit(17)', 'SENSITIVE']), error => error.exitCode === 17 && !error.message.includes('SENSITIVE')); +}); +test('installation and launch leave caller-owned configuration untouched', t => { + const f = fixture(t); + const config = join(f.home, 'config.toml'); const contents = 'provider = "existing"\n'; + writeFileSync(config, contents); f.env.CODEWHALE_CONFIG_PATH = config; + assert.equal(f.run('install').status, 0); + assert.equal(f.run('run', '--model', id).status, 0); + assert.equal(readFileSync(config, 'utf8'), contents); +}); + +test('Bash and PowerShell bootstrap pin the same verified helper', async () => { + const { createHash } = await import('node:crypto'); + const digest = createHash('sha256').update(readFileSync(join(here, 'codewhale.mjs'))).digest('hex'); + for (const ext of ['sh', 'ps1']) { + const source = readFileSync(join(here, `codewhale.${ext}`), 'utf8'); + assert.ok(source.includes(digest)); + assert.match(source, /lmm-scripts\/[a-f0-9]{40}\/codewhale\.mjs/); + } +}); + +if (process.platform !== 'win32') { + function shellFixture(t) { + const f = fixture(t); + const launch = join(f.home, 'launcher'); mkdirSync(launch); + const wrapper = join(launch, 'codewhale.sh'); + writeFileSync(wrapper, readFileSync(join(here, 'codewhale.sh'))); + f.env.TEST_HELPER = join(here, 'codewhale.mjs'); + f.env.TMPDIR = f.home; + const curl = `#!${process.execPath}\nimport fs from 'node:fs'; +const args=process.argv.slice(2), dest=args[args.indexOf('-o')+1]; +fs.appendFileSync(process.env.TEST_LOG,JSON.stringify(['curl',args])+'\\n'); +fs.writeFileSync(dest,process.env.TEST_BAD_DOWNLOAD ? 'throw Error("MALICIOUS_EXECUTED")' : fs.readFileSync(process.env.TEST_HELPER)); +process.exit(Number(process.env.TEST_CURL_EXIT||0));\n`; + writeFileSync(join(f.bin, 'curl'), curl, { mode: 0o755 }); + return { ...f, launch, runShell: (...args) => spawnSync('bash', [wrapper, ...args], { env: f.env, encoding: 'utf8', timeout: 10000 }) }; + } + test('standalone Bash download verifies and executes the pinned helper', t => { + const f = shellFixture(t); + const result = f.runShell('--help'); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Codewhale \+ LMM/); + assert.ok(f.calls()[0][1].some(arg => /354a0e7e8597957b33f5d4f4afcf9ac7ebfe8693\/codewhale.mjs/.test(arg))); + }); + test('truncated downloads fail before execution even when bytes were written', t => { + const f = shellFixture(t); f.env.TEST_BAD_DOWNLOAD = '1'; f.env.TEST_CURL_EXIT = '18'; + const result = f.runShell('--help'); + assert.equal(result.status, 18); + assert.doesNotMatch(result.stderr, /MALICIOUS_EXECUTED/); + }); + test('checksum mismatch fails before downloaded code executes', t => { + const f = shellFixture(t); f.env.TEST_BAD_DOWNLOAD = '1'; + const result = f.runShell('--help'); + assert.equal(result.status, 1); + assert.match(result.stderr, /checksum mismatch/); + assert.doesNotMatch(result.stderr, /MALICIOUS_EXECUTED/); + }); + test('local Bash helper preserves exact task arguments and child failure', t => { + const f = shellFixture(t); + writeFileSync(join(f.launch, 'codewhale.mjs'), `import fs from 'node:fs';fs.appendFileSync(process.env.TEST_LOG,JSON.stringify(['local',process.argv.slice(2)])+'\\n');process.exit(17);`); + const task = 'spaces "quoted" $(touch unwanted); & |'; + const result = f.runShell('run', '--model', id, '--', 'exec', task); + assert.equal(result.status, 17); + assert.deepEqual(f.calls(), [['local', ['run', '--model', id, '--', 'exec', task]]]); + }); +} diff --git a/test-codewhale.ps1 b/test-codewhale.ps1 new file mode 100644 index 0000000..8e24634 --- /dev/null +++ b/test-codewhale.ps1 @@ -0,0 +1,58 @@ +$ErrorActionPreference = 'Stop' +$engine = (Get-Process -Id $PID).Path +$work = Join-Path ([IO.Path]::GetTempPath()) ('lmm-powershell-' + [guid]::NewGuid()) +function Assert-True($Value, $Message) { if (-not $Value) { throw $Message } } +function Literal([string]$Value) { return "'" + $Value.Replace("'", "''") + "'" } +try { + New-Item -ItemType Directory $work | Out-Null + foreach ($name in @('codewhale.ps1', 'menu.ps1')) { + $tokens = $null; $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot $name), [ref]$tokens, [ref]$parseErrors) + Assert-True ($parseErrors.Count -eq 0) "$name parse failed" + } + $output = & $engine -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'codewhale.ps1') --help + Assert-True ($LASTEXITCODE -eq 0) 'Local help failed' + Assert-True (($output -join "`n") -match 'Codewhale \+ LMM') 'Help was not printed' + + $wrapper = Join-Path $work 'codewhale.ps1' + $helper = Join-Path $work 'codewhale.mjs' + $receipt = Join-Path $work 'args.json' + Copy-Item (Join-Path $PSScriptRoot 'codewhale.ps1') $wrapper + $env:LMM_SCRIPT_TEST_RECEIPT = $receipt + [IO.File]::WriteAllText($helper, 'import fs from "node:fs"; fs.writeFileSync(process.env.LMM_SCRIPT_TEST_RECEIPT, JSON.stringify(process.argv.slice(2))); process.exit(17);') + $task = 'literal "quoted" $(not-a-command); & | C:\path with spaces\' + $expected = @('run', '--model', 'lmm:ZGVmYXVsdA:bW9kZWw', '--', 'exec', $task) + $driver = Join-Path $work 'driver.ps1' + $arguments = ($expected | ForEach-Object { Literal $_ }) -join ',' + [IO.File]::WriteAllText($driver, ('& ' + (Literal $wrapper) + ' @(' + $arguments + ')')) + & $engine -NoProfile -ExecutionPolicy Bypass -File $driver + Assert-True ($LASTEXITCODE -eq 17) 'Child exit status was lost' + $received = @(Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json) + Assert-True ($received.Count -eq $expected.Count) 'Argument count changed' + for ($i=0; $i -lt $expected.Count; $i++) { Assert-True ($received[$i] -ceq $expected[$i]) "Argument $i changed" } + + # A detached download must be rejected before its contents execute. + Remove-Item -LiteralPath $helper + $marker = Join-Path $work 'executed' + $env:LMM_SCRIPT_TEST_MARKER = $marker + $driverCode = @' +function Invoke-WebRequest { + param([switch]$UseBasicParsing, [Parameter(Position=0)][string]$Uri, [string]$OutFile) + [IO.File]::WriteAllText($OutFile, 'import fs from "node:fs";fs.writeFileSync(process.env.LMM_SCRIPT_TEST_MARKER,"BAD");') +} +'@ + [IO.File]::WriteAllText($driver, $driverCode + "`n& " + (Literal $wrapper) + " --help") + $preference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $engine -NoProfile -ExecutionPolicy Bypass -File $driver 2>$null + $failureCode = $LASTEXITCODE + } finally { $ErrorActionPreference = $preference } + Assert-True ($failureCode -ne 0) 'Mismatched helper was accepted' + Assert-True (-not (Test-Path -LiteralPath $marker)) 'Unverified downloaded code executed' + Write-Host 'PowerShell checks passed: parser, help, literal argv/exit status, checksum refusal.' +} finally { + Remove-Item Env:LMM_SCRIPT_TEST_RECEIPT -ErrorAction SilentlyContinue + Remove-Item Env:LMM_SCRIPT_TEST_MARKER -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/test.py b/test.py index 898bc50..8e6ff03 100644 --- a/test.py +++ b/test.py @@ -227,13 +227,14 @@ def test_termux_menu_omits_desktop_entries(self): code=(ROOT/'menu.sh').read_text().split('while true; do')[0]+'printf "%s\\n" "${tools[@]}"\n' result=subprocess.run([shutil.which('bash'),'-c',code],env=self.env|{'TERMUX_VERSION':'test'},text=True,capture_output=True,timeout=10) self.assertEqual(result.returncode,0,result.stderr) - self.assertEqual(result.stdout.splitlines(),['pi','dsh','lmm','codex','claude-code']) + self.assertEqual(result.stdout.splitlines(),['pi','dsh','lmm','codex','claude-code','codewhale']) def test_flat_layout_and_size_budget(self): for path in ('templates','tools','docs','versions.json','generate.py'): self.assertFalse((ROOT/path).exists()) - scripts=[*ROOT.glob('*.sh'),*(f for f in ROOT.glob('*.ps1') if f.name!='test.ps1')] - self.assertLess(sum(len(f.read_bytes()) for f in scripts),14000) + scripts=[f for pattern in ('*.sh','*.ps1','*.mjs') for f in ROOT.glob(pattern) if not f.name.startswith('test')] + # Include the shared Codewhale implementation in the explicit size budget. + self.assertLess(sum(len(f.read_bytes()) for f in scripts),32000) for file in scripts: text=file.read_text() self.assertNotRegex(text,r'npmmirror|rank_urls|LMM_NETWORK|LMM_RETRIES|LMM_COMMAND_TIMEOUT|configure_npm') From 856bf5c3481a5f184397afe90db978a83be3b5a7 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:36:38 +0800 Subject: [PATCH 04/11] fix: retain Bash 3 compatibility and canonicalize macOS test paths --- menu.sh | 8 ++++---- test-codewhale.mjs | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/menu.sh b/menu.sh index d4af5dc..1679b95 100755 --- a/menu.sh +++ b/menu.sh @@ -13,12 +13,12 @@ while true; do [[ $choice =~ ^[1-9][0-9]?$ ]] || continue ((choice <= ${#tools[@]})) || continue tool=${tools[choice-1]} - installer_args=() - if [[ $tool = codewhale ]]; then installer_args=(menu); fi + # Positional parameters also work with empty arguments under Bash 3 + nounset. + if [[ $tool = codewhale ]]; then set -- menu; else set --; fi if [[ -n ${BASH_SOURCE[0]:-} && -f $dir/$tool.sh ]]; then - bash "$dir/$tool.sh" "${installer_args[@]}" &2 + bash "$dir/$tool.sh" "$@" &2 else script=$(curl -fsSL "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/64e9c6ea048de43312c9579e8bdd05c9bf84d9c7/$tool.sh") || continue - bash -c "$script" -- "${installer_args[@]}" &2 + bash -c "$script" -- "$@" &2 fi done diff --git a/test-codewhale.mjs b/test-codewhale.mjs index 28fbf0e..c4b3ebf 100644 --- a/test-codewhale.mjs +++ b/test-codewhale.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync, realpathSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, delimiter } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -84,9 +84,9 @@ test('out-of-range and nonnumeric choices never silently select a model', () => }); test('npm CLI is resolved as JS rather than executing a Windows command shim', t => { const f = fixture(t); - assert.equal(npmCLI(f.env), f.npmFile); + assert.equal(npmCLI(f.env), realpathSync(f.npmFile)); writeFileSync(join(f.bin, 'npm.cmd'), '@exit /b 99'); - assert.equal(npmCLI({ PATH: f.bin }, 'win32'), f.npmFile); + assert.equal(npmCLI({ PATH: f.bin }, 'win32'), realpathSync(f.npmFile)); }); test('missing npm has an actionable error', () => { assert.throws(() => npmCLI({ PATH: '' }), /npm was not found/); From bd0a81de99d28c0ed5701cf290dade1e124dcecd Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:39:23 +0800 Subject: [PATCH 05/11] fix: select one Node installation and reap PTY children reliably Fix issues observed in Windows/Ubuntu PowerShell and macOS CI without weakening assertions. --- codewhale.ps1 | 2 +- test-codewhale-menu.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/codewhale.ps1 b/codewhale.ps1 index 7966607..470d0f3 100644 --- a/codewhale.ps1 +++ b/codewhale.ps1 @@ -1,5 +1,5 @@ $ErrorActionPreference = 'Stop' -$node = (Get-Command node -CommandType Application -ErrorAction Stop).Source +$node = (Get-Command node -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source $nodeVersion = & $node -p 'process.versions.node' if ($LASTEXITCODE) { exit $LASTEXITCODE } if ([int]($nodeVersion.Split('.')[0]) -lt 22) { throw 'Node.js 22+ with npm is required.' } diff --git a/test-codewhale-menu.py b/test-codewhale-menu.py index e5bb460..ab540f1 100644 --- a/test-codewhale-menu.py +++ b/test-codewhale-menu.py @@ -54,10 +54,14 @@ def run_menu(self, choices): if done: status = value break - if status is None: + # macOS may close the PTY before waitpid reports the exited child. + # Reap within the original deadline rather than treating EOF as a hang. + while status is None and time.monotonic() < deadline: done, value = os.waitpid(pid, os.WNOHANG) if done: status = value + break + time.sleep(0.01) if status is None: os.kill(pid, signal.SIGKILL) os.waitpid(pid, 0) From 29578a94486a3484628b728909b3347b5bb18687 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:39:54 +0800 Subject: [PATCH 06/11] chore(menu): pin corrected PowerShell installer --- menu.ps1 | 2 +- menu.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/menu.ps1 b/menu.ps1 index e2442a5..7ad06af 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -19,7 +19,7 @@ try { if ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot $name))) { $path = Join-Path $PSScriptRoot $name } else { - Invoke-WebRequest -UseBasicParsing "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/64e9c6ea048de43312c9579e8bdd05c9bf84d9c7/$name" -OutFile $path + Invoke-WebRequest -UseBasicParsing "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/bd0a81de99d28c0ed5701cf290dade1e124dcecd/$name" -OutFile $path } & (Get-Process -Id $PID).Path -NoProfile -ExecutionPolicy Bypass -File $path @installerArgs if ($LASTEXITCODE) { Write-Warning "Installer exited with $LASTEXITCODE" } diff --git a/menu.sh b/menu.sh index 1679b95..a0fcfde 100755 --- a/menu.sh +++ b/menu.sh @@ -18,7 +18,7 @@ while true; do if [[ -n ${BASH_SOURCE[0]:-} && -f $dir/$tool.sh ]]; then bash "$dir/$tool.sh" "$@" &2 else - script=$(curl -fsSL "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/64e9c6ea048de43312c9579e8bdd05c9bf84d9c7/$tool.sh") || continue + script=$(curl -fsSL "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/bd0a81de99d28c0ed5701cf290dade1e124dcecd/$tool.sh") || continue bash -c "$script" -- "$@" &2 fi done From c341c3740fc2d23a9694aacfbdfa7b26c34dec4d Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:41:03 +0800 Subject: [PATCH 07/11] ci: expose the isolated Pi install directory to Windows smoke tests --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d720a7c..f16071d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,8 @@ jobs: shell: powershell run: | $env:PI_CODING_AGENT_DIR="$env:RUNNER_TEMP\pi-profile" + # Expose only this job's chosen install path; no persistent user PATH changes. + $env:PATH="$env:PI_CODING_AGENT_DIR\bin;$env:PATH" $env:DSH_HOME="$env:RUNNER_TEMP\dsh-profile" $env:CODEX_HOME="$env:RUNNER_TEMP\codex-profile" $env:CODEX_INSTALL_DIR="$env:RUNNER_TEMP\codex-bin" @@ -67,7 +69,7 @@ jobs: & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ".\$tool.ps1" if ($LASTEXITCODE) { throw "$tool install failed: $LASTEXITCODE" } } - foreach ($command in @('pi.cmd','dsh.cmd',"$env:CODEX_INSTALL_DIR\codex.exe","$HOME\.local\bin\claude.exe","$HOME\.local\bin\lmm.exe")) { + foreach ($command in @("$env:PI_CODING_AGENT_DIR\bin\pi.cmd",'dsh.cmd',"$env:CODEX_INSTALL_DIR\codex.exe","$HOME\.local\bin\claude.exe","$HOME\.local\bin\lmm.exe")) { & $command --version if ($LASTEXITCODE) { throw "$command failed: $LASTEXITCODE" } } From d7dd5e6bd512e5d9e8057dc936cd1c87c9158c1f Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:41:50 +0800 Subject: [PATCH 08/11] test: preserve splatted argv and native exit status in PowerShell harness --- test-codewhale.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test-codewhale.ps1 b/test-codewhale.ps1 index 8e24634..b79959a 100644 --- a/test-codewhale.ps1 +++ b/test-codewhale.ps1 @@ -24,7 +24,8 @@ try { $expected = @('run', '--model', 'lmm:ZGVmYXVsdA:bW9kZWw', '--', 'exec', $task) $driver = Join-Path $work 'driver.ps1' $arguments = ($expected | ForEach-Object { Literal $_ }) -join ',' - [IO.File]::WriteAllText($driver, ('& ' + (Literal $wrapper) + ' @(' + $arguments + ')')) + # The extra driver must splat the argument array and propagate the nested script's exit. + [IO.File]::WriteAllText($driver, ('$forwarded = @(' + $arguments + '); & ' + (Literal $wrapper) + ' @forwarded; exit $LASTEXITCODE')) & $engine -NoProfile -ExecutionPolicy Bypass -File $driver Assert-True ($LASTEXITCODE -eq 17) 'Child exit status was lost' $received = @(Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json) From e366553db8f3e7c6ed64cc08ae5508f6e58fcf54 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:45:14 +0800 Subject: [PATCH 09/11] test: report success after asserting expected native failures All assertions were passing, but the Actions PowerShell wrapper propagated the last deliberately failing checksum-test process status. Explicitly exit zero only after every assertion and cleanup succeeds. --- test-codewhale.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test-codewhale.ps1 b/test-codewhale.ps1 index b79959a..cb01ee5 100644 --- a/test-codewhale.ps1 +++ b/test-codewhale.ps1 @@ -57,3 +57,5 @@ function Invoke-WebRequest { Remove-Item Env:LMM_SCRIPT_TEST_MARKER -ErrorAction SilentlyContinue Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue } +# The nonzero child status above is expected and asserted, not a suite failure. +exit 0 From b76a40bd971846bd883731e022395358c6202a9d Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:47:04 +0800 Subject: [PATCH 10/11] test: compare decoded argv consistently in PowerShell 5.1 and 7 Assign the decoded JSON array directly; wrapping the pipeline in @() nests the array under Windows PowerShell 5.1. --- test-codewhale.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test-codewhale.ps1 b/test-codewhale.ps1 index cb01ee5..f6a07b8 100644 --- a/test-codewhale.ps1 +++ b/test-codewhale.ps1 @@ -28,8 +28,9 @@ try { [IO.File]::WriteAllText($driver, ('$forwarded = @(' + $arguments + '); & ' + (Literal $wrapper) + ' @forwarded; exit $LASTEXITCODE')) & $engine -NoProfile -ExecutionPolicy Bypass -File $driver Assert-True ($LASTEXITCODE -eq 17) 'Child exit status was lost' - $received = @(Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json) - Assert-True ($received.Count -eq $expected.Count) 'Argument count changed' + # Do not nest the JSON array with @(): PS 5.1 emits it as one pipeline object. + $received = Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json + Assert-True ($received.Count -eq $expected.Count) "Argument count changed: expected $($expected.Count), received $($received.Count)" for ($i=0; $i -lt $expected.Count; $i++) { Assert-True ($received[$i] -ceq $expected[$i]) "Argument $i changed" } # A detached download must be rejected before its contents execute. From 32a636358c80274bd4431c939fb6c060e61d1145 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:49:26 +0800 Subject: [PATCH 11/11] test: cover empty and backslash-quoted arguments in Codewhale PowerShell entrypoint --- test-codewhale.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test-codewhale.ps1 b/test-codewhale.ps1 index f6a07b8..39cbfba 100644 --- a/test-codewhale.ps1 +++ b/test-codewhale.ps1 @@ -21,7 +21,8 @@ try { $env:LMM_SCRIPT_TEST_RECEIPT = $receipt [IO.File]::WriteAllText($helper, 'import fs from "node:fs"; fs.writeFileSync(process.env.LMM_SCRIPT_TEST_RECEIPT, JSON.stringify(process.argv.slice(2))); process.exit(17);') $task = 'literal "quoted" $(not-a-command); & | C:\path with spaces\' - $expected = @('run', '--model', 'lmm:ZGVmYXVsdA:bW9kZWw', '--', 'exec', $task) + # Empty arguments and backslashes next to quotes must survive the native boundary too. + $expected = @('run', '--model', 'lmm:ZGVmYXVsdA:bW9kZWw', '--', 'exec', $task, '', 'a\"b', 'C:\trailing\\') $driver = Join-Path $work 'driver.ps1' $arguments = ($expected | ForEach-Object { Literal $_ }) -join ',' # The extra driver must splat the argument array and propagate the nested script's exit.