mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
Marketplace fix (#712, #705): Claude Desktop rejects plugins containing a top-level bin/ directory, and .claude-plugin/marketplace.json packages the repo root, so the installer directory is now cli/. Every reference updated (package.json bin entry + files, shims, docs, tests, caveman-init require path). Supersedes PR #726. Security (PR #717 verified): quoteWinArg only quoted on whitespace/quotes, leaving cmd.exe metacharacters (& | ^ < > % parens) unescaped on the shell:true Windows spawn path. Attacker-influenced arguments (--with-init cwd, --with-mcp-shrink value) could chain commands. Trigger regex now covers the metacharacter set; quoting logic split into a platform- independent, unit-tested helper. Also: - uninstall removes .caveman-active.prev, .caveman-mode-log.jsonl, .caveman-statusline-suffix, .caveman-nudge-shown; keeps .caveman-history.jsonl with a printed note; dry-run now says 'would remove' instead of lying (#635, supersedes PRs #693 #636) - Array.isArray guard in rewriteLegacyManagedHookCommands — malformed hook event no longer crashes the installer mid-run (supersedes PR #646) - gemini extensions install --consent: the security prompt hung every piped/non-interactive install forever (#676, part of PR #664) - OpenClaw skill stamps the real PINNED_REF version instead of hardcoded 1.0.0; new --no-always flag for load-on-demand installs (supersedes PR #720) - shims scope NPM_CONFIG_ALLOW_GIT=all to the npx call — npm >=12 defaults allow-git to none and EALLOWGITs github: installs (#698) - .codex/config.toml ships hooks + codex_hooks keys so auto-activation works on both sides of the codex-cli rename (#617) - caveman-help card shows the Windows config path (%APPDATA%) (#723) - caveman-parse.js added to HOOK_FILES, opencode payload (.cjs), and the regenerated checksums.sha256; manifest now matches shipped hook contents — release must bump PINNED_REF to a tag containing these files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ySX6TBWZuvFze4ajf7Hpf
202 lines
8.6 KiB
JavaScript
202 lines
8.6 KiB
JavaScript
// Unit tests for the argv parser embedded in cli/install.js.
|
|
// We don't import parseArgs (it's not exported) — instead we shell out to the
|
|
// installer with --help / --list / unknown flags and assert the framing.
|
|
// For deeper coverage of flag-resolution semantics, exec --dry-run --list and
|
|
// check the rendered defaults.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createRequire } from 'node:module';
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const INSTALLER = path.resolve(HERE, '..', '..', 'cli', 'install.js');
|
|
const requireCjs = createRequire(import.meta.url);
|
|
const { winQuoteIfNeeded } = requireCjs(INSTALLER);
|
|
|
|
function run(...args) {
|
|
return spawnSync('node', [INSTALLER, ...args], { encoding: 'utf8' });
|
|
}
|
|
|
|
test('--help prints usage and exits 0', () => {
|
|
const r = run('--help');
|
|
assert.equal(r.status, 0);
|
|
assert.match(r.stdout, /USAGE/);
|
|
assert.match(r.stdout, /--with-hooks/);
|
|
});
|
|
|
|
test('--list prints provider matrix', () => {
|
|
const r = run('--list');
|
|
assert.equal(r.status, 0);
|
|
assert.match(r.stdout, /caveman provider matrix/);
|
|
assert.match(r.stdout, /claude\b/);
|
|
assert.match(r.stdout, /gemini\b/);
|
|
assert.match(r.stdout, /antigravity\b.*\(soft\)/);
|
|
});
|
|
|
|
test('unknown flag exits 2 with error', () => {
|
|
const r = run('--bogus');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /unknown flag/);
|
|
});
|
|
|
|
test('--all + --minimal mutually exclusive', () => {
|
|
const r = run('--all', '--minimal');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /mutually exclusive/);
|
|
});
|
|
|
|
test('--only without arg fails', () => {
|
|
const r = run('--only');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /--only requires an argument/);
|
|
});
|
|
|
|
test('--config-dir without arg fails', () => {
|
|
const r = run('--config-dir');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /--config-dir requires a path/);
|
|
});
|
|
|
|
test('--config-dir followed by another flag fails', () => {
|
|
const r = run('--config-dir', '--all');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /--config-dir requires a path/);
|
|
});
|
|
|
|
test('aider alias rewrites to aider-desk in dry-run output', () => {
|
|
const r = run('--dry-run', '--only', 'aider', '--non-interactive', '--config-dir', '/tmp/__cm_alias_test');
|
|
// No detection means no install lines, but the script should not crash.
|
|
assert.equal(r.status, 0);
|
|
});
|
|
|
|
test('--only with unknown agent id exits 2', () => {
|
|
const r = run('--only', 'definitely-not-an-agent', '--non-interactive');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /unknown agent: definitely-not-an-agent/);
|
|
assert.match(r.stderr, /caveman --list/);
|
|
});
|
|
|
|
test('--only known id passes argv validation', () => {
|
|
// Dry-run + --only claude exits 0 even if the claude binary isn't on PATH.
|
|
const r = run('--dry-run', '--only', 'claude', '--non-interactive', '--config-dir', '/tmp/__cm_only_test');
|
|
assert.equal(r.status, 0);
|
|
});
|
|
|
|
test('--config-dir expands ~ to home directory', async () => {
|
|
// Pass `~/cm-test-…` and assert the dry-run plan resolves it relative to $HOME.
|
|
// Use a unique suffix so the assertion is unambiguous.
|
|
const suffix = `cm-test-${process.pid}`;
|
|
// --with-hooks: since #392/#393 the hooks plan (which echoes the resolved
|
|
// config-dir) is only emitted when the plugin install fails OR hooks are
|
|
// forced. Force it so the path-expansion assertion below has something to
|
|
// match even when the caveman plugin is already installed.
|
|
const r = run('--dry-run', '--only', 'claude', '--with-hooks', '--non-interactive', '--config-dir', `~/${suffix}`);
|
|
assert.equal(r.status, 0);
|
|
// If the literal `~` had survived, we'd see `~/cm-test-…/hooks` in the plan.
|
|
// The fix expands it in parseArgs, so we expect the absolute home path.
|
|
assert.doesNotMatch(r.stdout, /~\/cm-test-/);
|
|
// The plan only includes the hooks dir if claude is detected. Skip the
|
|
// positive assertion when claude isn't on PATH on the runner.
|
|
if (/Claude Code detected/.test(r.stdout)) {
|
|
const { homedir } = await import('node:os');
|
|
assert.match(r.stdout, new RegExp(homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/' + suffix));
|
|
}
|
|
});
|
|
|
|
test('bare -- (POSIX end-of-options) is accepted and ignored', () => {
|
|
// Regression: npx forwarded `--` from `curl|bash -- --only openclaw` to the
|
|
// package, and parseArgs rejected it as an unknown flag. Now we accept it.
|
|
const r = run('--', '--only', 'claude', '--non-interactive', '--dry-run', '--config-dir', '/tmp/__cm_dashdash');
|
|
assert.equal(r.status, 0);
|
|
});
|
|
|
|
test('bare --with-mcp-shrink (no upstream) exits 2 with hint', () => {
|
|
// Regression for issue where --with-mcp-shrink registered a stub MCP entry
|
|
// that crashed on every Claude Code startup. caveman-shrink is a proxy and
|
|
// requires an upstream command — we now refuse the bare flag (#474).
|
|
const r = run('--with-mcp-shrink', '--non-interactive', '--dry-run');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /requires an upstream command/);
|
|
assert.match(r.stderr, /server-filesystem/);
|
|
});
|
|
|
|
test('--with-mcp-shrink followed by another flag (no value) exits 2', () => {
|
|
// The next-token form must distinguish "no value" from "value happens to
|
|
// start with --". A user typing `--with-mcp-shrink --dry-run` clearly
|
|
// forgot the upstream; refuse.
|
|
const r = run('--with-mcp-shrink', '--dry-run', '--non-interactive');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /requires an upstream command/);
|
|
});
|
|
|
|
test('--with-mcp-shrink="<cmd>" registers wrapping that upstream', () => {
|
|
const r = run(
|
|
'--with-mcp-shrink=npx @modelcontextprotocol/server-filesystem /tmp',
|
|
'--only', 'claude', '--dry-run', '--non-interactive',
|
|
'--config-dir', '/tmp/__cm_shrink_test'
|
|
);
|
|
assert.equal(r.status, 0);
|
|
// Dry-run only emits the planned `claude mcp add` line when claude is on
|
|
// PATH (installMcpShrink probes `claude mcp --help` first). Assert the
|
|
// wrapping content only when that line is actually present.
|
|
if (/would run: claude mcp add caveman-shrink/.test(r.stdout)) {
|
|
assert.match(r.stdout, /claude mcp add caveman-shrink .* npx -y caveman-shrink npx @modelcontextprotocol\/server-filesystem \/tmp/);
|
|
}
|
|
});
|
|
|
|
test('--with-mcp-shrink "<cmd>" (space-separated) also accepted', () => {
|
|
const r = run(
|
|
'--with-mcp-shrink', 'npx @modelcontextprotocol/server-filesystem /tmp',
|
|
'--only', 'claude', '--dry-run', '--non-interactive',
|
|
'--config-dir', '/tmp/__cm_shrink_space'
|
|
);
|
|
assert.equal(r.status, 0);
|
|
if (/would run: claude mcp add caveman-shrink/.test(r.stdout)) {
|
|
assert.match(r.stdout, /caveman-shrink npx @modelcontextprotocol\/server-filesystem \/tmp/);
|
|
}
|
|
});
|
|
|
|
test('--all does NOT auto-enable mcp-shrink (no sensible default upstream)', () => {
|
|
const r = run('--all', '--only', 'claude', '--dry-run', '--non-interactive', '--config-dir', '/tmp/__cm_all_no_shrink');
|
|
assert.equal(r.status, 0);
|
|
// Whether or not claude is on PATH, the wiring banner should not appear
|
|
// because withMcpShrink stays false under --all alone.
|
|
assert.doesNotMatch(r.stdout, /wiring caveman-shrink MCP proxy/);
|
|
});
|
|
|
|
test('winQuoteIfNeeded leaves a plain argument untouched', () => {
|
|
assert.equal(winQuoteIfNeeded('claude'), 'claude');
|
|
assert.equal(winQuoteIfNeeded('/tmp/plain-path'), '/tmp/plain-path');
|
|
});
|
|
|
|
test('winQuoteIfNeeded quotes whitespace and embedded quotes (pre-existing behavior)', () => {
|
|
assert.equal(winQuoteIfNeeded('has space'), '"has space"');
|
|
assert.equal(winQuoteIfNeeded(''), '""');
|
|
});
|
|
|
|
test('winQuoteIfNeeded quotes cmd.exe metacharacters (Windows command-injection fix)', () => {
|
|
// Pre-fix, the trigger regex only matched /[\s"]/ — none of these contain
|
|
// whitespace or a quote, so they reached cmd.exe (via spawnXplat's
|
|
// `shell: true`) completely unquoted. An attacker-influenced arg like a
|
|
// --with-mcp-shrink value or --with-init cwd containing one of these could
|
|
// chain a second command (e.g. `foo & calc.exe`).
|
|
for (const ch of ['&', '|', '^', '<', '>', '%', '(', ')']) {
|
|
const arg = `foo${ch}bar`;
|
|
assert.equal(winQuoteIfNeeded(arg), `"${arg}"`, `metacharacter ${JSON.stringify(ch)} must trigger quoting`);
|
|
}
|
|
});
|
|
|
|
test('--help discloses --config-dir scope', () => {
|
|
const r = run('--help');
|
|
assert.equal(r.status, 0);
|
|
// Disclosure: --config-dir does NOT scope third-party CLI invocations.
|
|
// Help text wraps mid-phrase, so collapse whitespace before matching.
|
|
const collapsed = r.stdout.replace(/\s+/g, ' ');
|
|
assert.match(collapsed, /Does NOT scope/);
|
|
assert.match(collapsed, /XDG_CONFIG_HOME/);
|
|
assert.match(collapsed, /OPENCLAW_WORKSPACE/);
|
|
});
|