fix(qmd): reject unreliable npm listings and restore non-mapping models verbatim

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian lechnerandClaude Opus 5.5 committed 2026-10-08 11:55:47 +02:00
1 parent 7f89ae70bd
commit 953401a431
4 files changed
+27 -6

No files matched your search

+5 -3
View File
@@ -74,12 +74,14 @@ class Setup:
def package_installed(self): def package_installed(self):
# npm list exits non-zero on tree problems (ELSPROBLEMS) but still prints valid JSON. # npm list exits non-zero on tree problems (ELSPROBLEMS) but still prints valid JSON.
output = self.run(['npm', 'list', '--global', '--depth=0', '--json'], check=False) or '{}' output = self.run(['npm', 'list', '--global', '--depth=0', '--json'], check=False)
try: try:
listing = json.loads(output) listing = json.loads(output)
except json.JSONDecodeError: except json.JSONDecodeError:
raise ValueError('npm list did not return JSON.') raise ValueError('npm list did not return JSON.')
return PACKAGE in (listing.get('dependencies') or {}) if not isinstance(listing, dict) or not isinstance(listing.get('dependencies'), dict):
raise ValueError('npm list did not report the global packages.')
return PACKAGE in listing['dependencies']
def require_node(self): def require_node(self):
if not node_version_ok(self.run(['node', '--version'])): if not node_version_ok(self.run(['node', '--version'])):
@@ -135,7 +137,7 @@ class Setup:
previous = json.loads(output.strip().splitlines()[-1])['previous'] previous = json.loads(output.strip().splitlines()[-1])['previous']
except (IndexError, KeyError, TypeError, json.JSONDecodeError): except (IndexError, KeyError, TypeError, json.JSONDecodeError):
raise ValueError('qmd-config set-models returned no previous models.') raise ValueError('qmd-config set-models returned no previous models.')
state['previous_models'] = previous if isinstance(previous, dict) else None state['previous_models'] = previous
state['models_block'] = True state['models_block'] = True
atomic_json(self.state_path, state) atomic_json(self.state_path, state)
before = {p.name for p in self.models.iterdir()} if self.models.is_dir() else set() before = {p.name for p in self.models.iterdir()} if self.models.is_dir() else set()
+2
View File
@@ -78,6 +78,8 @@ test('runQmdCli runs the CLI entry of QMD_PACKAGE_DIR without a shell', () => {
test('previousModels captures a user models block, null when absent', () => { test('previousModels captures a user models block, null when absent', () => {
assert.deepEqual(previousModels('models:\n embed: mine\ncollections: {}\n', YAML), { embed: 'mine' }); assert.deepEqual(previousModels('models:\n embed: mine\ncollections: {}\n', YAML), { embed: 'mine' });
assert.equal(previousModels('models: custom\n', YAML), 'custom');
assert.deepEqual(previousModels('models: [a, b]\n', YAML), ['a', 'b']);
assert.equal(previousModels('collections: {}\n', YAML), null); assert.equal(previousModels('collections: {}\n', YAML), null);
assert.equal(previousModels('', YAML), null); assert.equal(previousModels('', YAML), null);
}); });
+19 -2
View File
@@ -13,9 +13,9 @@ spec.loader.exec_module(qmd)
class FakeRunner: class FakeRunner:
def __init__(self, installed=False, gpu='cuda', gpu_result=None, cpu_result=None, previous=None, fail_pull=False): def __init__(self, installed=False, gpu='cuda', gpu_result=None, cpu_result=None, previous=None, fail_pull=False, list_output=None):
self.calls, self.installed, self.gpu = [], installed, gpu self.calls, self.installed, self.gpu = [], installed, gpu
self.previous, self.fail_pull = previous, fail_pull self.previous, self.fail_pull, self.list_output = previous, fail_pull, list_output
self.results = {'gpu': gpu_result, 'cpu': cpu_result} self.results = {'gpu': gpu_result, 'cpu': cpu_result}
def __call__(self, arguments, env=None, timeout=None, check=True): def __call__(self, arguments, env=None, timeout=None, check=True):
@@ -27,6 +27,8 @@ class FakeRunner:
return json.dumps({'previous': self.previous}) + chr(10) return json.dumps({'previous': self.previous}) + chr(10)
if self.fail_pull and arguments[:2] == ['qmd', 'pull']: if self.fail_pull and arguments[:2] == ['qmd', 'pull']:
raise ValueError('pull failed') raise ValueError('pull failed')
if 'npm list' in joined and self.list_output is not None:
return self.list_output
if 'npm list' in joined: if 'npm list' in joined:
return json.dumps({'dependencies': {'@tobilu/qmd': {}} if self.installed else {}}) return json.dumps({'dependencies': {'@tobilu/qmd': {}} if self.installed else {}})
if 'npm install' in joined: if 'npm install' in joined:
@@ -198,6 +200,21 @@ class SetupTest(unittest.TestCase):
self.assertTrue(self.setup(FakeRunner(installed=True)).package_installed()) self.assertTrue(self.setup(FakeRunner(installed=True)).package_installed())
self.assertFalse(self.setup(FakeRunner()).package_installed()) self.assertFalse(self.setup(FakeRunner()).package_installed())
def test_package_installed_rejects_unreliable_listings(self):
for output in ('', ' ', 'not json', '{"error": {"code": "ELSPROBLEMS"}}', '{}', '[]'):
with self.assertRaises(ValueError, msg=repr(output)):
self.setup(FakeRunner(list_output=output)).package_installed()
self.assertTrue(self.setup(FakeRunner(list_output='{"dependencies": {"@tobilu/qmd": {}}, "error": {}}')).package_installed())
self.assertFalse(self.setup(FakeRunner(list_output='{"dependencies": {}}')).package_installed())
def test_non_mapping_previous_models_are_restored_verbatim(self):
runner = FakeRunner(previous='custom.gguf')
setup = self.setup(runner)
setup.enable(['claude'])
self.assertEqual(self.state()['previous_models'], 'custom.gguf')
setup.disable(['claude'])
self.assertTrue(any(c[-2:] == ['restore-models', '"custom.gguf"'] for c in runner.calls))
def test_dry_run_changes_nothing(self): def test_dry_run_changes_nothing(self):
runner = FakeRunner() runner = FakeRunner()
self.setup(runner, dry_run=True).enable(['claude']) self.setup(runner, dry_run=True).enable(['claude'])
+1 -1
View File
@@ -46,7 +46,7 @@ async function main(action, argument) {
next = setModels(current, YAML); next = setModels(current, YAML);
} else if (action === 'restore-models') { } else if (action === 'restore-models') {
const mapping = JSON.parse(argument ?? ''); const mapping = JSON.parse(argument ?? '');
if (mapping === null || typeof mapping !== 'object' || Array.isArray(mapping)) throw new Error('restore-models needs a JSON object'); if (mapping === undefined) throw new Error('restore-models needs a JSON value');
next = restoreModels(current, YAML, mapping); next = restoreModels(current, YAML, mapping);
} else next = unsetModels(current, YAML); } else next = unsetModels(current, YAML);
mkdirSync(dirname(path), { recursive: true }); mkdirSync(dirname(path), { recursive: true });