diff --git a/src/agent/index.ts b/src/agent/index.ts index 4752806..8d268f3 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -46,6 +46,7 @@ import { contentHash, loadOperatorConfig, exceedsRiskThreshold, + resolvePluginSource, } from "../plugin-system/manager.js"; import { deepAudit, formatAuditResult } from "../plugin-system/auditor.js"; import { extractSuggestedCommands } from "./command-suggestions.js"; @@ -701,11 +702,12 @@ if (discoveredCount > 0) { async function syncPluginsToSandbox(): Promise { const enabled = pluginManager.getEnabledPlugins(); - // Dynamic-import each enabled plugin's index.ts to get the register fn + // Dynamic-import each enabled plugin to get the register fn const registrations = []; const loadErrors: string[] = []; for (const plugin of enabled) { - const indexPath = join(plugin.dir, "index.ts"); + // Resolve .ts (dev) or .js (npm/dist) — centralised in plugin-system + const indexPath = resolvePluginSource(plugin.dir); // SECURITY CHECK 1: Verify source hasn't changed since audit/approval if (!pluginManager.verifySourceHash(plugin.manifest.name)) { diff --git a/src/plugin-system/manager.ts b/src/plugin-system/manager.ts index 0a67870..db8ac6c 100644 --- a/src/plugin-system/manager.ts +++ b/src/plugin-system/manager.ts @@ -308,22 +308,51 @@ export function contentHash(source: string): string { return createHash("sha256").update(source, "utf8").digest("hex"); } +/** + * Resolve the source file for a plugin directory. + * + * In dev mode (source repo), prefers .ts so edits are reflected immediately + * without a rebuild. Under node_modules (npm install / bundled binary), + * Node.js refuses to strip types from .ts files, so we always use .js. + */ +export function resolvePluginSource(pluginDir: string): string { + const tsPath = join(pluginDir, "index.ts"); + const jsPath = join(pluginDir, "index.js"); + + // Under node_modules, Node.js can't type-strip .ts — always use .js. + // Use path-segment check to avoid false positives on dirs named "node_modules_foo". + const underNodeModules = + /[\\/]node_modules[\\/]/.test(pluginDir) || + pluginDir.startsWith("node_modules/"); + if (underNodeModules) { + return jsPath; + } + + // Dev mode: prefer .ts for live editing, fall back to .js + return existsSync(tsPath) ? tsPath : jsPath; +} + /** * Compute combined hash of plugin source and manifest. * Used for approval fingerprint and tamper detection. * Any change to either file invalidates the approval. + * + * Note: approvals are scoped to the install context. A plugin approved + * in dev (from .ts) must be re-approved when loaded from node_modules + * (.js), since the source content differs. This is intentional — + * the compiled output should be verified independently. */ export function computePluginHash(pluginDir: string): string | null { - const tsPath = join(pluginDir, "index.ts"); + const sourcePath = resolvePluginSource(pluginDir); const jsonPath = join(pluginDir, "plugin.json"); try { - const tsContent = readFileSync(tsPath, "utf8"); + const sourceContent = readFileSync(sourcePath, "utf8"); const jsonContent = readFileSync(jsonPath, "utf8"); // Hash both files together — any change invalidates return createHash("sha256") - .update(tsContent, "utf8") + .update(sourceContent, "utf8") .update(jsonContent, "utf8") .digest("hex"); } catch { @@ -648,16 +677,17 @@ export function createPluginManager(pluginsDir: string) { // ── Source Loading ──────────────────────────────────────────── /** - * Load the source code of a plugin's index.js for auditing. + * Load the source code of a plugin for auditing. + * Resolves .ts (dev) or .js (npm/dist) via resolvePluginSource(). * Returns the source string, or null if the file doesn't exist. */ function loadSource(name: string): string | null { const plugin = plugins.get(name); if (!plugin) return null; - const indexPath = join(plugin.dir, "index.ts"); + const indexPath = resolvePluginSource(plugin.dir); if (!existsSync(indexPath)) { - console.error(`[plugins] Warning: ${name}/index.ts not found`); + console.error(`[plugins] Warning: ${indexPath} not found`); return null; } @@ -667,7 +697,7 @@ export function createPluginManager(pluginsDir: string) { return source; } catch (err) { console.error( - `[plugins] Warning: failed to read ${name}/index.ts: ${(err as Error).message}`, + `[plugins] Warning: failed to read ${name} source: ${(err as Error).message}`, ); return null; } @@ -804,7 +834,7 @@ export function createPluginManager(pluginsDir: string) { * Approve a plugin. Requires an existing audit result. * Persists the approval to disk immediately. * - * Uses combined hash (index.ts + plugin.json) for tamper detection. + * Uses combined hash (plugin source + plugin.json) for tamper detection. * * @returns true if approved, false if plugin not found or not audited */ @@ -851,7 +881,7 @@ export function createPluginManager(pluginsDir: string) { /** * Check if a plugin has a valid, current approval. - * Compares the stored content hash against combined hash (index.ts + plugin.json). + * Compares the stored content hash against combined hash (plugin source + plugin.json). */ function isApproved(name: string): boolean { const plugin = plugins.get(name); @@ -869,7 +899,7 @@ export function createPluginManager(pluginsDir: string) { /** * Refresh the `approved` flag on all plugins based on the - * persisted approval store and current combined hash (index.ts + plugin.json). + * persisted approval store and current combined hash (plugin source + plugin.json). * Called after discover() to sync runtime flags with disk state. */ function refreshAllApprovals(): void { @@ -1167,7 +1197,7 @@ export function createPluginManager(pluginsDir: string) { const plugin = plugins.get(name); if (!plugin || !plugin.source) return false; - const indexPath = join(plugin.dir, "index.ts"); + const indexPath = resolvePluginSource(plugin.dir); try { const currentSource = readFileSync(indexPath, "utf8"); return currentSource === plugin.source; diff --git a/tests/plugin-manager.test.ts b/tests/plugin-manager.test.ts index 2d50065..d50e27a 100644 --- a/tests/plugin-manager.test.ts +++ b/tests/plugin-manager.test.ts @@ -24,6 +24,7 @@ import { coerceConfigValue, loadOperatorConfig, exceedsRiskThreshold, + resolvePluginSource, } from "../src/plugin-system/manager.js"; // ── Fixtures path ──────────────────────────────────────────────────── @@ -446,6 +447,61 @@ describe("contentHash", () => { }); }); +// ── resolvePluginSource ────────────────────────────────────────────── + +describe("resolvePluginSource", () => { + // Create temp fixtures with controlled file combinations + const tmpBase = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "tmp-resolve-test-" + process.pid, + ); + const devDir = join(tmpBase, "dev-plugin"); + const devJsOnly = join(tmpBase, "dev-js-only"); + const nmDir = join(tmpBase, "node_modules", "test-plugin"); + + beforeEach(async () => { + const { mkdirSync, writeFileSync: fsWrite } = await import("node:fs"); + mkdirSync(devDir, { recursive: true }); + fsWrite(join(devDir, "index.ts"), "export const x = 1;"); + fsWrite(join(devDir, "index.js"), "exports.x = 1;"); + + mkdirSync(devJsOnly, { recursive: true }); + fsWrite(join(devJsOnly, "index.js"), "exports.x = 1;"); + + mkdirSync(nmDir, { recursive: true }); + fsWrite(join(nmDir, "index.ts"), "export const x = 1;"); + fsWrite(join(nmDir, "index.js"), "exports.x = 1;"); + }); + + afterEach(async () => { + const { rmSync } = await import("node:fs"); + rmSync(tmpBase, { recursive: true, force: true }); + }); + + it("should prefer .ts over .js in dev (non-node_modules) dirs", () => { + const result = resolvePluginSource(devDir); + expect(result).toMatch(/index\.ts$/); + }); + + it("should fall back to .js when .ts does not exist in dev", () => { + const result = resolvePluginSource(devJsOnly); + expect(result).toMatch(/index\.js$/); + }); + + it("should always return .js under node_modules paths", () => { + const result = resolvePluginSource(nmDir); + expect(result).toMatch(/index\.js$/); + }); + + it("should return .js path even if .js missing under node_modules", () => { + unlinkSync(join(nmDir, "index.js")); + const result = resolvePluginSource(nmDir); + // Must return .js (not .ts) — Node can't strip types under node_modules + expect(result).toMatch(/index\.js$/); + }); +}); + // ── Plugin Manager ─────────────────────────────────────────────────── describe("createPluginManager", () => {