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/agent/slash-commands.ts b/src/agent/slash-commands.ts index f84531e..272927e 100644 --- a/src/agent/slash-commands.ts +++ b/src/agent/slash-commands.ts @@ -1207,6 +1207,17 @@ export async function handleSlashCommand( break; } + // Load source before enabling — syncPluginsToSandbox needs + // plugin.source for verifySourceHash(). Without this, the + // hash check fails and the plugin is silently disabled. + if (!pluginManager.loadSource(pluginName)) { + console.log( + ` ${C.err("❌ Failed to load source for")} "${pluginName}". Plugin will not be enabled.`, + ); + console.log(); + break; + } + pluginManager.enable(pluginName); console.log( ` ✅ Plugin "${pluginName}" enabled (approved fast-path).`, diff --git a/src/plugin-system/manager.ts b/src/plugin-system/manager.ts index 0a67870..da09058 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; } @@ -688,10 +718,27 @@ export function createPluginManager(pluginsDir: string) { const plugin = plugins.get(name); if (!plugin) return false; - // Load source if not already loaded + // For schema extraction, prefer .ts source — it's the canonical + // schema definition and the Rust parser handles it best. Under + // node_modules, only .js exists so we fall back gracefully. + // try/catch guards against TOCTOU (file deleted between exists check and read). + const tsPath = join(plugin.dir, "index.ts"); + let extractionSource: string | null = null; + if (existsSync(tsPath)) { + try { + extractionSource = readFileSync(tsPath, "utf8"); + } catch { + // Fall through to plugin.source / loadSource + } + } + if (!extractionSource) { + extractionSource = plugin.source ?? loadSource(name); + } + if (!extractionSource) return false; + + // Also ensure plugin.source is loaded for hash verification if (!plugin.source) { - const source = loadSource(name); - if (!source) return false; + if (!loadSource(name)) return false; } // If analysis guest is not enabled, fall back to manifest. @@ -705,7 +752,7 @@ export function createPluginManager(pluginsDir: string) { } try { - const metadata = await extractPluginMetadata(plugin.source!); + const metadata = await extractPluginMetadata(extractionSource); // Use extracted schema or fall back to manifest if (metadata.schema) { @@ -804,7 +851,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 +898,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 +916,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 +1214,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", () => {