fix(extensions): preserve existing global packages

This commit is contained in:
Julian lechner committed 2026-09-24 17:10:02 +02:00
1 parent 46d07e578d
commit eeb437e234
3 files changed
+25 -5

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
- [x] Keep an installed semantic retrieval runtime when an `auto` benchmark says no. `benchmark()` deletes the whole runtime and ownership state even when `state['clients']` still contains active clients. (`scripts/lib/semantic-retrieval.py:117`, `scripts/lib/semantic-retrieval.py:142`) - [x] Keep an installed semantic retrieval runtime when an `auto` benchmark says no. `benchmark()` deletes the whole runtime and ownership state even when `state['clients']` still contains active clients. (`scripts/lib/semantic-retrieval.py:117`, `scripts/lib/semantic-retrieval.py:142`)
- [x] Check semantic retrieval ownership and links before copying or deleting its directory. An existing owned runtime's source files are overwritten without comparing recorded hashes; a replaced target symlink is followed on enable, and disabling the last client removes foreign files inside the runtime. (`scripts/lib/semantic-retrieval.py:82`, `scripts/lib/semantic-retrieval.py:100`) - [x] Check semantic retrieval ownership and links before copying or deleting its directory. An existing owned runtime's source files are overwritten without comparing recorded hashes; a replaced target symlink is followed on enable, and disabling the last client removes foreign files inside the runtime. (`scripts/lib/semantic-retrieval.py:82`, `scripts/lib/semantic-retrieval.py:100`)
- [x] Preserve pre-existing MCPorter server entries. `ensure_mcporter()` adds a server and claims ownership without checking existing configuration; if the add succeeds for an existing name, a later removal deletes that user's entry. (`scripts/lib/managed-extensions.py:395`, `scripts/lib/managed-extensions.py:263`) - [x] Preserve pre-existing MCPorter server entries. `ensure_mcporter()` adds a server and claims ownership without checking existing configuration; if the add succeeds for an existing name, a later removal deletes that user's entry. (`scripts/lib/managed-extensions.py:395`, `scripts/lib/managed-extensions.py:263`)
- [ ] Preserve pre-existing global CLI packages. `ensure_cli()` and `ensure_qmd()` install and claim npm packages without checking whether they were already installed; later removal can uninstall a user's package. (`scripts/lib/managed-extensions.py:342`, `scripts/lib/managed-extensions.py:378`) - [x] Preserve pre-existing global CLI packages. `ensure_cli()` and `ensure_qmd()` install and claim npm packages without checking whether they were already installed; later removal can uninstall a user's package. (`scripts/lib/managed-extensions.py:342`, `scripts/lib/managed-extensions.py:378`)
- [x] Fetch skill archives by an immutable commit and record that same commit. The archive uses GitHub `HEAD`, while `resolvedRevision` comes from a separate later request, so the recorded revision may not identify the installed payload. (`scripts/lib/managed-extensions.py:94`, `scripts/lib/managed-extensions.py:140`, `scripts/lib/managed-extensions.py:409`) - [x] Fetch skill archives by an immutable commit and record that same commit. The archive uses GitHub `HEAD`, while `resolvedRevision` comes from a separate later request, so the recorded revision may not identify the installed payload. (`scripts/lib/managed-extensions.py:94`, `scripts/lib/managed-extensions.py:140`, `scripts/lib/managed-extensions.py:409`)
- [x] Protect the semantic retrieval daemon's IPC protocol. It uses a hardcoded authentication key and `multiprocessing.connection.recv()`, which deserializes messages; a process that can reach its socket or pipe can submit arbitrary roots and potentially crafted objects. Use a private per-install credential and a non-pickle wire format, or restrict the endpoint to the current user. (`shared/retrieval/server.py:47`, `shared/retrieval/server.py:306`, `shared/retrieval/server.py:314`) - [x] Protect the semantic retrieval daemon's IPC protocol. It uses a hardcoded authentication key and `multiprocessing.connection.recv()`, which deserializes messages; a process that can reach its socket or pipe can submit arbitrary roots and potentially crafted objects. Use a private per-install credential and a non-pickle wire format, or restrict the endpoint to the current user. (`shared/retrieval/server.py:47`, `shared/retrieval/server.py:306`, `shared/retrieval/server.py:314`)
- [x] Write semantic retrieval ownership state atomically. `save_state()` replaces the JSON with a direct write, unlike the extension and selection ledgers; interruption can leave it unreadable and block later reconciliation. (`scripts/lib/semantic-retrieval.py:63`) - [x] Write semantic retrieval ownership state atomically. `save_state()` replaces the JSON with a direct write, unlike the extension and selection ledgers; interruption can leave it unreadable and block later reconciliation. (`scripts/lib/semantic-retrieval.py:63`)
+22 -4
View File
@@ -163,6 +163,7 @@ class Manager:
self.state = json.loads(self.path.read_text(encoding='utf-8')) self.state = json.loads(self.path.read_text(encoding='utf-8'))
self.validate() self.validate()
self.cache = {} self.cache = {}
self.new_packages = set()
def validate(self): def validate(self):
if not isinstance(self.state, dict) or self.state.get('version') != 1 or not isinstance(self.state.get('resources'), list): if not isinstance(self.state, dict) or self.state.get('version') != 1 or not isinstance(self.state.get('resources'), list):
@@ -212,6 +213,14 @@ class Manager:
self.cache[client] = installed_plugins(client, self.home) self.cache[client] = installed_plugins(client, self.home)
return self.cache[client] return self.cache[client]
def global_packages(self):
if 'global_packages' not in self.cache:
result = json.loads(run_command(['npm', 'list', '--global', '--depth=0', '--json'], True, self.home))
if not isinstance(result, dict) or not isinstance(result.get('dependencies'), dict):
raise ValueError('Invalid global npm package inventory.')
self.cache['global_packages'] = set(result['dependencies'])
return self.cache['global_packages']
def command(self, arguments): def command(self, arguments):
if self.dry_run: if self.dry_run:
if not self.summary: if not self.summary:
@@ -347,10 +356,16 @@ class Manager:
if not re.fullmatch(r'[a-z0-9@/_.-]+', package): if not re.fullmatch(r'[a-z0-9@/_.-]+', package):
raise ValueError('Invalid CLI package.') raise ValueError('Invalid CLI package.')
record = next((r for r in self.state['resources'] if r['client'] == client and r['name'] == entry['name']), None) record = next((r for r in self.state['resources'] if r['client'] == client and r['name'] == entry['name']), None)
installed = shutil.which(package) is not None installed = package in self.global_packages()
if record is None and installed:
if not self.summary:
print(f'PASS Preserving pre-existing global package: {package}')
return
if record is not None and installed and not self.update: if record is not None and installed and not self.update:
return return
self.command(['npm', 'update' if record is not None and installed else 'install', '--global', package]) self.command(['npm', 'update' if record is not None and installed else 'install', '--global', package])
self.global_packages().add(package)
self.new_packages.add(package)
if not self.dry_run and record is None: if not self.dry_run and record is None:
self.state['resources'].append(dict(client=client, name=entry['name'], kind='cli', package=package)) self.state['resources'].append(dict(client=client, name=entry['name'], kind='cli', package=package))
self.save() self.save()
@@ -383,15 +398,18 @@ class Manager:
package = entry['codex_source'] package = entry['codex_source']
if package != '@tobilu/qmd': if package != '@tobilu/qmd':
raise ValueError('Invalid QMD package.') raise ValueError('Invalid QMD package.')
if not getattr(self, 'qmd_ready', False): record = next((r for r in self.state['resources'] if r['client'] == 'codex' and r['name'] == entry['name']), None)
pre_existing = record is None and package in self.global_packages() and package not in self.new_packages
if not getattr(self, 'qmd_ready', False) and not pre_existing and package not in self.global_packages():
self.command(['npm', 'install', '--global', package]) self.command(['npm', 'install', '--global', package])
self.global_packages().add(package)
self.new_packages.add(package)
self.qmd_ready = True self.qmd_ready = True
if client == 'claude': if client == 'claude':
entry = dict(entry, codex_method='plugin') entry = dict(entry, codex_method='plugin')
self.ensure(client, entry) self.ensure(client, entry)
return return
record = next((r for r in self.state['resources'] if r['client'] == client and r['name'] == entry['name']), None) if record is not None or pre_existing:
if record is not None:
return return
self.state['resources'].append(dict(client='codex', name=entry['name'], kind='qmd', package=package)) self.state['resources'].append(dict(client='codex', name=entry['name'], kind='qmd', package=package))
self.save() self.save()
+2
View File
@@ -53,6 +53,8 @@ class ManagedExtensionTests(unittest.TestCase):
self.addCleanup(self.output.__exit__, None, None, None) self.addCleanup(self.output.__exit__, None, None, None)
def command(self, args, summary, home=None): def command(self, args, summary, home=None):
if args[:2] == ['npm', 'list']:
return '{"dependencies":{}}'
self.commands.append(args) self.commands.append(args)
if len(args) > 3 and args[2] in ('add', 'install') and args[1] == 'plugin': if len(args) > 3 and args[2] in ('add', 'install') and args[1] == 'plugin':
self.installed[args[0]][args[3]] = {'scope': 'user'} self.installed[args[0]][args[3]] = {'scope': 'user'}