Explorar o código

fix(auto-update): prevent writes in project .opencode directories

The auto-updater was treating any node_modules/oh-my-opencode-slim/package.json
wrapper as writable, including project-local .opencode installs. This caused
bun install to run inside the project's .opencode directory, leaving behind
package-lock.json files.

Added isManagedInstallDir() guard to restrict auto-update writes to managed
OpenCode cache roots only (legacy cache dir and cache packages/ subtree).

Fixes: stray .opencode/package-lock.json in working directories
Alvin Real hai 3 meses
pai
achega
1ee08dd2d2

+ 57 - 27
src/hooks/auto-update-checker/cache.test.ts

@@ -1,5 +1,7 @@
 import { describe, expect, mock, spyOn, test } from 'bun:test';
 import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
 
 // Mock logger to avoid noise
 mock.module('../../utils/logger', () => ({
@@ -17,27 +19,44 @@ mock.module('../../cli/config-manager', () => ({
 // Cache buster for dynamic imports
 let importCounter = 0;
 
+const cacheDir =
+  process.platform === 'win32'
+    ? path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'opencode')
+    : path.join(os.homedir(), '.cache', 'opencode');
+const packagesInstallDir = path.join(
+  cacheDir,
+  'packages',
+  'oh-my-opencode-slim@latest',
+);
+const packagesRuntimePath = path.join(
+  packagesInstallDir,
+  'node_modules',
+  'oh-my-opencode-slim',
+  'package.json',
+);
+const packagesWrapperPath = path.join(packagesInstallDir, 'package.json');
+const legacyPackageJsonPath = path.join(cacheDir, 'package.json');
+const legacyInstalledPath = path.join(
+  cacheDir,
+  'node_modules',
+  'oh-my-opencode-slim',
+);
+
 describe('auto-update-checker/cache', () => {
   describe('resolveInstallContext', () => {
     test('detects OpenCode packages install root from runtime package path', async () => {
       const existsSpy = spyOn(fs, 'existsSync').mockImplementation(
-        (p: string) =>
-          p ===
-          '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/package.json',
+        (p: string) => p === packagesWrapperPath,
       );
       const { resolveInstallContext } = await import(
         `./cache?test=${importCounter++}`
       );
 
-      const context = resolveInstallContext(
-        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim/package.json',
-      );
+      const context = resolveInstallContext(packagesRuntimePath);
 
       expect(context).toEqual({
-        installDir:
-          '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest',
-        packageJsonPath:
-          '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/package.json',
+        installDir: packagesInstallDir,
+        packageJsonPath: packagesWrapperPath,
       });
 
       existsSpy.mockRestore();
@@ -49,8 +68,23 @@ describe('auto-update-checker/cache', () => {
         `./cache?test=${importCounter++}`
       );
 
+      const context = resolveInstallContext(packagesRuntimePath);
+
+      expect(context).toBeNull();
+
+      existsSpy.mockRestore();
+    });
+
+    test('rejects project-local .opencode wrapper installs', async () => {
+      const existsSpy = spyOn(fs, 'existsSync').mockImplementation(
+        (p: string) => p === '/repo/.opencode/package.json',
+      );
+      const { resolveInstallContext } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
       const context = resolveInstallContext(
-        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim/package.json',
+        '/repo/.opencode/node_modules/oh-my-opencode-slim/package.json',
       );
 
       expect(context).toBeNull();
@@ -75,17 +109,16 @@ describe('auto-update-checker/cache', () => {
     test('updates packages wrapper dependency and removes installed package', async () => {
       const existsSpy = spyOn(fs, 'existsSync').mockImplementation(
         (p: string) =>
-          p ===
-            '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/package.json' ||
-          p ===
-            '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim',
+          p === packagesWrapperPath ||
+          p === path.join(
+            packagesInstallDir,
+            'node_modules',
+            'oh-my-opencode-slim',
+          ),
       );
       const readSpy = spyOn(fs, 'readFileSync').mockImplementation(
         (p: string) => {
-          if (
-            p ===
-            '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/package.json'
-          ) {
+          if (p === packagesWrapperPath) {
             return JSON.stringify({
               dependencies: {
                 'oh-my-opencode-slim': '0.9.1',
@@ -109,14 +142,12 @@ describe('auto-update-checker/cache', () => {
       const result = preparePackageUpdate(
         '0.9.11',
         'oh-my-opencode-slim',
-        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim/package.json',
+        packagesRuntimePath,
       );
 
-      expect(result).toBe(
-        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest',
-      );
+      expect(result).toBe(packagesInstallDir);
       expect(rmSyncSpy).toHaveBeenCalledWith(
-        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim',
+        path.join(packagesInstallDir, 'node_modules', 'oh-my-opencode-slim'),
         { recursive: true, force: true },
       );
       expect(writtenData.length).toBeGreaterThan(0);
@@ -135,8 +166,7 @@ describe('auto-update-checker/cache', () => {
     test('keeps working when dependency is already on target version', async () => {
       const existsSpy = spyOn(fs, 'existsSync').mockImplementation(
         (p: string) =>
-          p.endsWith('/.cache/opencode/package.json') ||
-          p.endsWith('/.cache/opencode/node_modules/oh-my-opencode-slim'),
+          p === legacyPackageJsonPath || p === legacyInstalledPath,
       );
       const readSpy = spyOn(fs, 'readFileSync').mockReturnValue(
         JSON.stringify({
@@ -153,7 +183,7 @@ describe('auto-update-checker/cache', () => {
 
       const result = preparePackageUpdate('1.0.1', 'oh-my-opencode-slim', null);
 
-      expect(result?.endsWith('/.cache/opencode')).toBe(true);
+      expect(result).toBe(cacheDir);
       expect(writeSpy).not.toHaveBeenCalled();
       expect(rmSyncSpy).toHaveBeenCalled();
 

+ 30 - 0
src/hooks/auto-update-checker/cache.ts

@@ -19,6 +19,29 @@ interface AutoUpdateInstallContext {
   packageJsonPath: string;
 }
 
+function normalizePath(filePath: string): string {
+  return path.resolve(filePath);
+}
+
+function isWithinPath(childPath: string, parentPath: string): boolean {
+  const relativePath = path.relative(parentPath, childPath);
+  return (
+    relativePath === '' ||
+    (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))
+  );
+}
+
+function isManagedInstallDir(installDir: string): boolean {
+  const normalizedInstallDir = normalizePath(installDir);
+  const normalizedCacheDir = normalizePath(CACHE_DIR);
+  const normalizedPackagesDir = path.join(normalizedCacheDir, 'packages');
+
+  return (
+    normalizedInstallDir === normalizedCacheDir ||
+    isWithinPath(normalizedInstallDir, normalizedPackagesDir)
+  );
+}
+
 /**
  * Removes a package from the bun.lock file if it's in JSON format.
  * Note: Newer Bun versions (1.1+) use a custom text format for bun.lock.
@@ -123,6 +146,13 @@ export function resolveInstallContext(
       path.basename(nodeModulesDir) === 'node_modules'
     ) {
       const installDir = path.dirname(nodeModulesDir);
+      if (!isManagedInstallDir(installDir)) {
+        log(
+          `[auto-update-checker] Skipping auto-update for unmanaged install root: ${installDir}`,
+        );
+        return null;
+      }
+
       const packageJsonPath = path.join(installDir, 'package.json');
       if (fs.existsSync(packageJsonPath)) {
         return { installDir, packageJsonPath };