diff --git a/.omo/plans/plugin-master-plan.md b/.omo/plans/plugin-master-plan.md new file mode 100644 index 000000000..4ae14b35e --- /dev/null +++ b/.omo/plans/plugin-master-plan.md @@ -0,0 +1,1833 @@ +# jcode Plugin System — Master Implementation Plan + +> Generated from research across 9 reference repos + jcode codebase deep exploration + user interview +> Goal: Design and implement a first-class plugin system for jcode combining the best patterns from opencode (dual-architecture + npm distribution), oh-my-pi (3-tier extensibility + npm install + typed settings), and pi-agent-rust (QuickJS embedding + RCU dispatch + 5-layer capability security) + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Reference Architecture Analysis](#2-reference-architecture-analysis) +3. [Cross-Repo Pattern Synthesis — "Best of All Worlds"](#3-cross-repo-pattern-synthesis) +4. [Architecture Decisions](#4-architecture-decisions) +5. [Cargo Workspace Structure](#5-cargo-workspace-structure) +6. [Core Data Structures & Types](#6-core-data-structures--types) +7. [Plugin Manifest Convention](#7-plugin-manifest-convention) +8. [Plugin Discovery & Loading](#8-plugin-discovery--loading) +9. [QuickJS Runtime Integration](#9-quickjs-runtime-integration) +10. [Plugin API Surface](#10-plugin-api-surface) +11. [Capability Security Model](#11-capability-security-model) +12. [Event/Hook Integration](#12-eventhook-integration) +13. [Tool Registration System](#13-tool-registration-system) +14. [Dual-Process Architecture (Server vs TUI)](#14-dual-process-architecture) +15. [Configuration](#15-configuration) +16. [CLI Commands](#16-cli-commands) +17. [Integration Points in Existing Code](#17-integration-points) +18. [Test Plan](#18-test-plan) +19. [Migration Strategy](#19-migration-strategy) +20. [Cross-Repo Reference Matrix](#20-cross-repo-reference-matrix) +21. [Success Criteria](#21-success-criteria) +22. [Known Limitations & Future Work](#22-known-limitations--future-work) + +--- + +## 1. Executive Summary + +We are building a **first-class TypeScript/JavaScript plugin system** for jcode, embedding QuickJS via `rquickjs` to run user-authored scripts with process isolation, capability-based security, and full integration into jcode's existing server-client architecture. + +The system draws from three proven approaches: +- **opencode**'s dual-plugin architecture (server + TUI plugins sharing the same npm package, auto-discovery from `.opencode/plugin/*.ts`, Hooks input/output mutation pattern) +- **oh-my-pi**'s 3-tier progression (legacy hooks → typed extensions with 30s timeout → npm-distributable plugins with feature toggles and typed settings schemas), CLI-first plugin management (`omp plugin install`) +- **pi-agent-rust**'s embedded QuickJS engine with SWC transpilation, Promise bridge for Rust↔JS async, RCU snapshot event dispatch with O(1) hook bitmap, 5-layer capability security chain, and dual timeout (500ms info / 5000ms actionable) + +jcode is uniquely positioned because it **already has the server-client split** (`jcode serve` daemon + `jcode connect` client) that opencode's architecture requires. The plugin system naturally follows this: **server plugins** run in the daemon process to hook into agent lifecycle, tool execution, and session management; **TUI plugins** run in the client process to extend the terminal UI with custom views, keybindings, and side panels. + +**Key design principles:** +1. **Safety first** — plugins run in QuickJS sandbox with capability-based permissions, not in-process with full Node.js access +2. **Progressive complexity** — simple config-triggered hooks for beginners, full TypeScript plugins for advanced users +3. **No vendor lock-in** — npm as distribution channel, standard TypeScript/JS authoring +4. **Best of all worlds** — opencode's DX (auto-discover, npm install, simple API) + oh-my-pi's maturity (typed settings, feature toggles, CLI) + pi-agent-rust's safety (QuickJS, capability chain, preflight analysis) +5. **Backward compatible** — existing config.toml, tool system, agent loop unchanged; plugins are additive + +--- + +## 2. Reference Architecture Analysis + +### 2.1 OpenCode Plugin System + +**Architecture**: Dual-plugin — Server plugins (agent hooks, tools, auth, providers, chat) + TUI plugins (UI extension). Single npm package can expose both via `package.json` exports (`./server` and `./tui`). + +**Key Patterns**: +| Pattern | Detail | +|---------|--------| +| Plugin signature | `type Plugin = (input: PluginInput, options?) => Promise` | +| Input object | `{ client, project, directory, worktree, serverUrl, $: BunShell }` | +| Hooks returned | Object with hook keys (tool, auth, config, chat.message, etc.) — each a callback | +| Mutation pattern | All hooks receive `{ input, output }` — plugin mutates `output` to modify behavior | +| Auto-discovery | `.opencode/plugin/*.ts` and `.opencode/tool/*.ts` scanned at startup | +| Installation | `opencode plugin ` → npm install to `~/.cache/opencode/packages/` → patch config | +| Config | `opencode.json` `"plugin"` array (strings or `[name, options]` pairs) | +| No sandboxing | Full Node.js/Bun access, no isolation | +| No hot-reload | Loaded once at startup | + +**Hooks available**: dispose, event, config, tool, auth, provider, chat.message, chat.params, chat.headers, permission.ask, command.execute.before, tool.execute.before, shell.env, tool.execute.after, experimental.* (7 hooks), tool.definition + +**Real plugin examples**: +- Simplest: export `{ server: async (ctx) => ({ tool: { mytool: tool({...}) } }) }` +- TUI: SolidJS-based with `api.ui.Dialog`, `api.route`, `api.keymap`, `api.slots` +- Auth: implement OAuth/API key flows via `auth` hook +- Workspace adapter: register custom workspace types + +### 2.2 oh-my-pi Extension System + +**Architecture**: 3-tier historical progression — Legacy hooks (shell-script style, narrow API, no timeout) → Extensions (TypeScript, full API, 30s timeout) → Plugins (npm-distributable, feature toggles, typed settings). + +**Key Patterns**: +| Pattern | Detail | +|---------|--------| +| Extension signature | `export default function(pi: ExtensionAPI): void` | +| ExtensionAPI | `{ on(), registerTool(), registerCommand(), registerShortcut(), registerFlag(), registerMessageRenderer(), registerProvider(), sendMessage(), exec(), events: EventBus }` | +| Events (28+) | session lifecycle (start, switch, branch, compact, shutdown, tree), context, before_provider_request, before_agent_start, agent_start/end, turn_start/end, message_start/update/end, tool_execution_start/update/end, auto_compaction, auto_retry, input, tool_call, tool_result, user_bash, user_python | +| Timers | ExtensionRunner: 30s hard timeout. HookRunner: NO timeout (for permission gates) | +| Plugin install | `omp plugin install ` → `bun install` in `~/.omp/plugins/` → `omp-plugins.lock.json` | +| Feature toggles | `pkg[feature1,feature2]` syntax, `pkg[*]` all, `pkg[]` none | +| Typed settings | JSON Schema per plugin with `secret`, `env`, `min`, `max` | +| No sandboxing | Same as opencode — in-process, full access | + +**before_agent_start distinction**: ExtensionRunner calls ALL handlers (accumulates messages, chains systemPrompt). HookRunner takes FIRST only. + +### 2.3 pi-agent-rust Plugin System + +**Architecture**: Rust-based AI agent with full embedded scripting via QuickJS + SWC (TypeScript→JS transpilation). ~33 hook events with dual timeout system. + +**Key Patterns**: +| Pattern | Detail | +|---------|--------| +| JS Engine | rquickjs (QuickJS C library bindings) — lightweight, embeddable, no JIT, ~1MB footprint | +| TS support | SWC transpile `.ts` → `.js` at load time, cache compiled output | +| Promise bridge | Rust `oneshot` channels: JS calls `host.call("method", args)` → returns Promise that resolves when Rust completes | +| Event dispatch | RCU (Read-Copy-Update) snapshot pattern: `Arc>>` — handlers register to bitmap, snapshot swapped atomically | +| O(1) hook bitmap | `u64` bitmask: `(registered_mask & event_bit) != 0` for instant check | +| Capability security | 5-layer chain: deny list → global deny → allow list → global default → mode | +| Preflight analysis | Static analysis of plugin code before first execution — detect suspicious patterns | +| Dual timeout | Info hooks: 500ms, Actionable hooks: 5000ms (configurable) | +| fail_closed_hooks | Config flag: if true, hook failure blocks execution (deny-by-default) | + +### 2.4 Other Repos (Lower Relevance) + +| Repo | Relevance | Key Takeaway | +|------|-----------|-------------| +| oh-my-openagent | Medium | 5-tier composition, HTTP hooks with `${VAR}` interpolation | +| oh-my-claudecode | Medium | 11 hooks implemented, kill-switch env vars | +| oh-my-codex | Low-Medium | Dual-layer config, plugin timeout 1500ms, trust model with trusted_hash | +| codebuff | Low | PrintModeEvent — no user-facing plugin system | +| codex | Medium | Rust hook engine with FuturesUnordered, HookResult tri-state, full JSON Schema | + +--- + +## 3. Cross-Repo Pattern Synthesis + +This is the "Best of All Worlds" — what we take from each repo: + +| # | Pattern | Source | Why It Wins | +|---|---------|--------|-------------| +| 1 | **QuickJS embedded** | pi-agent-rust | Isolation, small footprint (~1MB), no external runtime dependency | +| 2 | **`pi.on(event, handler)` subscription API** | pi-agent-rust + oh-my-pi | Simple, familiar, proven across both Rust and TS ecosystems | +| 3 | **SWC TypeScript transpilation** | pi-agent-rust | Users write TS, plugins run as JS | +| 4 | **Promise bridge (oneshot channels)** | pi-agent-rust | Async Rust↔JS without blocking either runtime | +| 5 | **RCU snapshot + O(1) hook bitmap** | pi-agent-rust | Zero-contention reads, instant event routing | +| 6 | **Input/output mutation pattern** | opencode | `{ input, output }` passing lets plugins inspect AND modify | +| 7 | **5-layer capability chain** | pi-agent-rust | Granular security without sacrificing usability | +| 8 | **Preflight static analysis** | pi-agent-rust | Catch malicious/errant code before it runs | +| 9 | **Typed settings schema** | oh-my-pi | Structured per-plugin config with validation | +| 10 | **Feature toggle install** | oh-my-pi | `plugin[feature1]` — selective, clean | +| 11 | **Dual timeout system** | pi-agent-rust | Info hooks fast (500ms), actionable hooks generous (5000ms) | +| 12 | **Permission hook no timeout** | oh-my-pi | User prompts must never time out | +| 13 | **fail_closed_hooks config** | pi-agent-rust | Security-conscious: deny on error | +| 14 | **Auto-discovery from `.jcode/plugins/*.ts`** | opencode | Zero-config for local plugins | +| 15 | **Secret env filtering** | pi-agent-rust | Don't leak API keys to plugins | +| 16 | **npm-based plugin install CLI** | oh-my-pi + opencode | Standard distribution channel | +| 17 | **Kill switch + audit trail** | pi-agent-rust | `JCODE_DISABLE_PLUGINS`, `JCODE_SKIP_PLUGINS` | +| 18 | **Inter-plugin event bus** | pi-agent-rust | `events.on()`, `events.emit()` for plugin↔plugin communication | +| 19 | **Dual server/TUI export** | opencode | Single npm package with both server and UI extensions | +| 20 | **Session lifecycle events** | oh-my-pi | `session_start`, `session_before_switch`, `session_compact`, `session_shutdown` | + +--- + +## 4. Architecture Decisions + +### 4.1 Chosen Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ jcode serve (daemon) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Agent Loop │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │ +│ │ │ Pre-API │→│ API Call │→│Post-API │→│ Tool │ │ │ +│ │ │ Phase │ │ Phase │ │ Phase │ │ Exec │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ ▼ ▼ ▼ ▼ │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ Plugin Dispatcher │ │ │ +│ │ │ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │ │ │ +│ │ │ │ RCU │→│ O(1) │→│ FuturesUnordered │ │ │ │ +│ │ │ │Snapshot │ │Bitmap │ │ Parallel Dispatch │ │ │ │ +│ │ │ └──────────┘ └──────────┘ └───────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ QuickJS Runtime Manager │ │ │ +│ │ │ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │ │ │ +│ │ │ │ Runtime │→│ Sandbox │→│ Promise Bridge │ │ │ │ +│ │ │ │ Pool │ │ Context │ │ (oneshot channels) │ │ │ │ +│ │ │ └──────────┘ └──────────┘ └───────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Safety System (existing) │ │ +│ │ PermissionRequest queue, DCG classification, │ │ +│ │ Tool policy (allowed/disabled), Capability chain │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + NDJSON over Unix socket + │ +┌─────────────────────────────────────────────────────────────┐ +│ jcode connect (client/TUI) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ TUI Plugin Runtime │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │ │ +│ │ │ Plugin │→│ Slot │→│ TuiPluginApi │ │ │ +│ │ │ Loader │ │ Registry │ │ (route, keymap, │ │ │ +│ │ │ │ │ │ │ dialog, slot, theme, │ │ │ +│ │ └──────────┘ └──────────┘ │ kv, event, lifecycle) │ │ │ +│ │ └───────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Existing TUI (ratatui 0.30) │ │ +│ │ Info widgets, overlays, side panel, keybindings │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.2 Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| JS Engine | **QuickJS (rquickjs)** | Lightweight (~1MB), embeddable, isolated heap | +| TS Support | **SWC transpilation** | Best-in-class TS→JS speed, single binary | +| Plugin Model | **Server + TUI plugins** | jcode already has serve/connect split | +| Distribution | **npm packages + local files** | npm for published, `.jcode/plugins/*.ts` for local | +| Security | **5-layer capability chain** | Granular, proven in pi-agent-rust | +| Dispatch | **RCU snapshot + bitmap** | Zero-contention, O(1) event routing | +| Async Bridge | **Oneshot channels** | Simple, non-blocking, Rust-native | +| Config | **TOML section in config.toml** | Consistent with existing jcode config | +| Timeout | **Dual: 500ms / 5000ms** | Info hooks fast, actionable hooks generous | +| Plugin Format | **Single JS runtime context** | All plugins share one QuickJS runtime but get isolated sandbox contexts | + +### 4.3 Alternatives Considered + +| Approach | Source | Pros | Cons | Decision | +|----------|--------|------|------|----------| +| QuickJS (rquickjs) | pi-agent-rust | Small, embeddable, isolated heap | No JIT, slower for CPU-heavy | ✅ **Selected** | +| V8 (rusty_v8) | — | Full JS, JIT, debugger | Heavy (~50MB), complex build | ❌ Too heavy | +| Deno Core | — | TypeScript native, modern APIs | Very heavy, complex embedding | ❌ Too heavy | +| WASM (wasmtime) | pi-agent-rust (optional) | Language-agnostic, real sandbox | Limited stdlib, complex bindings | ⏸️ Future option | +| Bun/Node child process | opencode, oh-my-pi | No embedding needed | Process overhead, no isolation | ❌ Not sandboxed | +| WASI preview 2 | — | Standardized, multi-language | Immature ecosystem | ❌ Not ready | +| Hybrid: QuickJS + WASM | pi-agent-rust (both) | QuickJS for scripting, WASM for perf | More complexity | ✅ Plan for v2 | + +### 4.4 Plugin Lifecycle + +``` + ┌──────────────────┐ + │ Config loaded │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Discovery │ + │ Scan 3 sources │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Resolve & Load │ + │ npm→cache/file │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Preflight Check │ + │ Capability decl │ + │ Static analysis │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ SWC Transpile │ + │ .ts → .js │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ QuickJS Eval │ + │ Create context │ + │ Inject pi API │ + │ Call factory │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Register Hooks │ + │ RCU snapshot │ + │ Bitmap update │ + └────────┬─────────┘ + │ + ┌───────────┴───────────┐ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ Active: Event │ │ On Disable/ │ + │ Dispatch │ │ Uninstall │ + │ Dual timeout │ │ Unregister │ + │ Error isolation │ │ Snapshot update │ + └─────────────────┘ └─────────────────┘ +``` + +--- + +## 5. Cargo Workspace Structure + +Add new crates to existing 43-member workspace: + +```toml +# Cargo.toml additions +[workspace] +members = [ + # ... existing 43 members ... + "crates/jcode-plugin-core", # Plugin types, manifest, config, security + "crates/jcode-plugin-runtime", # QuickJS runtime, SWC transpilation, sandbox +] + +[workspace.dependencies] +rquickjs = { version = "0.7", features = ["parallel", "catch", "classes"] } +swc_core = { version = "1.0", features = ["ecma_transforms", "ecma_parser"] } +``` + +### 5.1 Crate Dependency Graph + +``` +jcode-plugin-core (no deps on other jcode crates) + │ + └── jcode-plugin-runtime + │ + ├── jcode-base (for config integration) + ├── jcode-app-core (for server plugin integration) + └── jcode-tui (for TUI plugin integration) +``` + +### 5.2 Module Structure + +``` +crates/jcode-plugin-core/src/ +├── lib.rs # Re-exports +├── manifest.rs # PluginManifest, PluginFeature, SettingSchema +├── security.rs # CapabilityChain, Permission, AccessMode +├── config.rs # PluginConfig, PluginSource, DiscoveryPaths +├── events.rs # PluginEvent enum (28 events), EventInput, EventOutput +├── types.rs # PluginId, PluginVersion, PluginState, PluginOrigin +├── errors.rs # PluginError enum +└── serde.rs # Serialization helpers + +crates/jcode-plugin-runtime/src/ +├── lib.rs # Re-exports +├── runtime.rs # QuickJS Runtime manager, pool +├── sandbox.rs # SandboxContext, capability enforcement, preflight +├── transpiler.rs # SWC TypeScript → JavaScript transpilation +├── bridge.rs # PromiseBridge — Rust↔JS oneshot channels +├── api.rs # PluginAPI bindings exposed to JS +├── dispatcher.rs # RCU snapshot dispatcher + O(1) bitmap +├── loader.rs # PluginLoader — file/npm resolution, eval, registration +├── registry.rs # PluginRegistry — active plugins, state, lifecycle +├── native.rs # Native functions exposed to JS (tool exec) +├── timer.rs # Dual timeout implementation +├── audit.rs # Audit trail, kill switches +└── server.rs # Server-side plugin host +``` + +--- + +## 6. Core Data Structures & Types + +### 6.1 Plugin Identity & State + +```rust +// crates/jcode-plugin-core/src/types.rs + +use std::collections::HashMap; +use semver::Version; +use serde::{Deserialize, Serialize}; + +/// Unique identifier for a plugin — npm package name or file path +#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub struct PluginId(String); + +impl PluginId { + pub fn npm(name: &str) -> Self { Self(format!("npm:{name}")) } + pub fn file(path: &str) -> Self { Self(format!("file:{path}")) } + pub fn bundled(name: &str) -> Self { Self(format!("builtin:{name}")) } + pub fn to_string(&self) -> String { self.0.clone() } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginVersion { + pub semver: Version, + pub jcode_min_version: Option, + pub jcode_max_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PluginState { + Discovered, Loading, Loaded, Active, + Error(String), Disabled, Blocked, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PluginOrigin { + NpmPackage { name: String, version: String }, + LocalFile { path: String }, + Builtin { name: String }, + Remote { url: String }, +} +``` + +### 6.2 Plugin Manifest + +```rust +// crates/jcode-plugin-core/src/manifest.rs + +use std::collections::HashMap; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginManifest { + pub name: String, + pub package_name: String, + pub version: String, + pub description: Option, + pub author: Option, + pub license: Option, + pub kind: PluginKind, + pub entry: PluginEntry, + pub capabilities: PluginCapabilities, + pub features: HashMap, + pub settings: HashMap, + pub engines: PluginEngines, + pub icon: Option, + pub homepage: Option, + pub repository: Option, + pub tags: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PluginKind { Server, Tui, Both } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginEntry { + pub server: Option, + pub tui: Option, + pub both: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginCapabilities { + pub fs_read: Vec, + pub fs_write: Vec, + pub network: Vec, + pub shell: bool, + pub register_tools: bool, + pub register_commands: bool, + pub register_providers: bool, + pub read_config: bool, + pub write_config: bool, + pub env_vars: Vec, + pub events: Vec, + pub llm_access: bool, + pub session_access: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginFeature { + pub description: String, + pub default: bool, + pub entry: Option, + pub additional_capabilities: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum SettingSchema { + String { description: String, default: Option, secret: bool, + env: Option, pattern: Option, max_length: Option }, + Number { description: String, default: Option, min: Option, max: Option }, + Boolean { description: String, default: Option }, + Enum { description: String, default: Option, values: Vec }, + Array { description: String, default: Option>, + items: Box, max_items: Option }, + Object { description: String, default: Option, + properties: HashMap }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginEngines { pub jcode: Option } + +impl PluginManifest { + pub fn from_package_json(value: &serde_json::Value) -> Result { + let section = value.get("jcode").or_else(|| value.get("pi")) + .ok_or(PluginError::InvalidManifest("missing 'jcode' or 'pi' field".into()))?; + serde_json::from_value(section.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string())) + } +} +``` + +### 6.3 Event Types + +```rust +// crates/jcode-plugin-core/src/events.rs + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(u32)] +pub enum PluginEvent { + PreToolUse = 0, PostToolUse = 1, PostToolUseFailure = 2, + ToolExecutionStart = 3, ToolExecutionEnd = 4, + SessionStart = 5, SessionEnd = 6, SessionSwitch = 7, + SessionCompact = 8, SessionBeforeCompact = 9, SessionShutdown = 10, + PermissionRequest = 12, PermissionDenied = 13, + AgentStart = 14, AgentEnd = 15, TurnStart = 16, TurnEnd = 17, + MessageStart = 18, MessageEnd = 19, + PreCompact = 20, PostCompact = 21, + TaskCreated = 22, TaskCompleted = 23, AutoCompactionStart = 24, + UserPromptSubmit = 25, Stop = 26, Notification = 27, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum EventInput { + PreToolUse { tool_name: String, tool_input: serde_json::Value, session_id: String }, + PostToolUse { tool_name: String, tool_input: serde_json::Value, + tool_output: serde_json::Value, duration_ms: u64, success: bool, session_id: String }, + PostToolUseFailure { tool_name: String, tool_input: serde_json::Value, + error: String, duration_ms: u64, session_id: String }, + SessionStart { session_id: String, project_dir: String, model: String, provider: String }, + SessionEnd { session_id: String, duration_seconds: u64, message_count: u64 }, + PermissionRequest { action: String, tool_name: Option, + target: Option, session_id: String }, + AgentStart { session_id: String, system_prompt: Vec, tools: Vec }, + TurnStart { session_id: String, turn_number: u32, messages: Vec }, + UserPromptSubmit { content: String, session_id: String }, + PreCompact { session_id: String, message_count: u32, + token_count: u64, system_prompt: Vec }, + PostCompact { session_id: String, messages_removed: u32, tokens_saved: u64 }, + Stop { session_id: String, reason: String }, + Notification { level: String, message: String, session_id: Option }, + ToolExecutionStart { tool_name: String, tool_input: serde_json::Value, session_id: String }, + ToolExecutionEnd { tool_name: String, tool_output: serde_json::Value, + duration_ms: u64, session_id: String }, + // ... remaining variants follow same pattern +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum EventOutput { + PreToolUse { block: Option, modified_input: Option }, + PostToolUse { modified_output: Option }, + PermissionRequest { decision: Option, message: Option }, + AgentStart { additional_system_prompt: Vec }, + PreCompact { system_prompt: Option>, instructions: Option, prevent: bool }, + UserPromptSubmit { modified_prompt: Option }, + Notification { suppress: Option, modified_message: Option }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PermissionDecision { Allow, Deny, Ask } +``` + +### 6.4 Security Types + +```rust +// crates/jcode-plugin-core/src/security.rs + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapabilityChain { + pub deny_list: CapabilitySet, + pub global_deny: CapabilitySet, + pub allow_list: CapabilitySet, + pub global_default: AccessDefault, + pub mode: AccessMode, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CapabilitySet { + pub fs_paths: Vec, + pub hosts: Vec, + pub tools: Vec, + pub env_vars: Vec, + pub shell_commands: Vec, + pub config_keys: Vec, + pub providers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum AccessDefault { Deny, Allow, Ask } + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum AccessMode { All, Trusted, None, Interactive } + +impl CapabilitySet { + pub fn matches(&self, resource: &str, _action: &CapabilityAction) -> bool { + self.tools.contains(resource) + || self.hosts.iter().any(|h| resource.contains(h)) + || self.fs_paths.iter().any(|p| resource.starts_with(p)) + } +} + +#[derive(Debug, Clone)] +pub enum CapabilityAction { Read, Write, Execute, Network, Config, Session, Provider } + +#[derive(Debug, Clone)] +pub enum AccessDecision { Allowed(String), Denied(String), NeedsApproval(String) } +``` + +--- + +## 7. Plugin Manifest Convention + +### 7.1 package.json Format + +```jsonc +{ + "name": "@scope/jcode-my-plugin", + "version": "1.0.0", + "description": "My awesome jcode plugin", + "jcode": { + "name": "My Plugin", + "kind": "both", + "entry": { "server": "./dist/server.js", "tui": "./dist/tui.js" }, + "capabilities": { + "fs_read": ["$CWD/**"], + "network": ["api.my-service.com"], + "register_tools": true, + "events": ["PreToolUse", "SessionStart"] + }, + "features": { + "advanced": { + "description": "Advanced features", + "default": false, + "entry": "./dist/advanced.js", + "additional_capabilities": { "network": ["analytics.my-service.com"] } + } + }, + "settings": { + "apiKey": { "type": "string", "secret": true, "env": "MY_PLUGIN_API_KEY" }, + "maxResults": { "type": "number", "default": 10, "min": 1, "max": 100 } + }, + "engines": { "jcode": ">=0.20.0" } + } +} +``` + +### 7.2 Local Plugin Format + +```typescript +// .jcode/plugins/my-plugin.ts +export const manifest = { + name: "My Local Plugin", + capabilities: { fs_read: ["$CWD/**"], events: ["PreToolUse"] }, + settings: { greeting: { type: "string", default: "Hello!" } }, +}; + +export default function (pi: PluginAPI) { + pi.on("PreToolUse", async (input, output) => { + pi.logger.info(`Tool ${input.tool_name} about to run`); + }); +} +``` + +--- + +## 8. Plugin Discovery & Loading + +### 8.1 Discovery Sources + +1. **Config**: `config.toml → [plugin.sources]` +2. **Auto-discovery**: `.jcode/plugins/*.ts`, `.jcode/plugins/*.js`, `.jcode/plugins/*/index.ts` +3. **Tool auto-discovery**: `.jcode/tools/*.ts` +4. **Npm-installed**: `.jcode/cache/packages//` +5. **Built-in**: Compiled into binary + +### 8.2 Loading Pipeline + +```rust +pub struct PluginLoader { + discovery: DiscoveryPaths, + config: PluginConfig, + registry: Arc, + transpiler: Arc, + runtime: Arc, +} + +impl PluginLoader { + pub async fn load_all(&self) -> Result, PluginError> { + let sources = self.discover_sources().await?; + let mut loaded = Vec::new(); + for source in sources { + match self.load_one(&source).await { + Ok(id) => loaded.push(id), + Err(e) => { + if self.config.fail_closed.unwrap_or(false) { return Err(e); } + tracing::warn!("Failed to load {source:?}: {e}"); + } + } + } + Ok(loaded) + } + + async fn discover_sources(&self) -> Result, PluginError> { + let mut sources = Vec::new(); + if let Some(ref cfg) = self.config.sources { sources.extend(cfg.clone()); } + for dir in &self.discovery.plugin_dirs { + self.scan_directory_for_plugins(dir, &mut sources).await?; + } + let npm_dir = &self.discovery.npm_cache; + if npm_dir.exists() { + self.scan_npm_cache(npm_dir, &mut sources).await?; + } + Ok(sources) + } + + async fn load_one(&self, source: &PluginSource) -> Result { + let (path, id) = match source { + PluginSource::Npm { package, version } => { + let entry = self.resolve_npm_entry(package, version.as_deref()).await?; + (entry.path, PluginId::npm(package)) + } + PluginSource::File { path } => (std::path::PathBuf::from(path), PluginId::file(path)), + PluginSource::Directory { path } => { + let p = std::path::Path::new(path); + let idx = if p.join("index.ts").exists() { p.join("index.ts") } else { p.join("index.js") }; + (idx, PluginId::file(path)) + } + }; + let code = tokio::fs::read_to_string(&path).await?; + let js_code = if path.extension().map_or(false, |e| e == "ts" || e == "tsx") { + self.transpiler.transpile(&code, &path.to_string_lossy())? + } else { code }; + let context = self.runtime.create_sandbox(id.clone(), PluginManifest::default())?; + context.eval(&js_code)?; + self.registry.register(id.clone(), context)?; + Ok(id) + } +} +``` + +### 8.3 NPM Resolution + +```rust +impl PluginLoader { + async fn resolve_npm_entry(&self, package: &str, version: Option<&str>) -> Result { + let cache = self.discovery.npm_cache.join(sanitize_name(package)); + if !cache.exists() { + self.install_npm(package, version, &cache).await?; + } + let pkg_json = cache.join("node_modules").join(package).join("package.json"); + let content = tokio::fs::read_to_string(&pkg_json).await?; + let json: serde_json::Value = serde_json::from_str(&content)?; + let manifest = PluginManifest::from_package_json(&json)?; + let entry = manifest.entry.server.or(manifest.entry.both) + .ok_or(PluginError::InvalidManifest("No server entry point".into()))?; + Ok(ResolvedEntry { path: cache.join(entry), manifest }) + } + + async fn install_npm(&self, package: &str, version: Option<&str>, dir: &Path) -> Result<()> { + if !is_valid_package_name(package) { + return Err(PluginError::Npm("Invalid package name".into())); + } + tokio::fs::create_dir_all(dir).await?; + let spec = match version { Some(v) => format!("{package}@{v}"), None => package.into() }; + let out = tokio::process::Command::new("npm") + .args(["install", &spec, "--no-save", "--no-audit"]) + .current_dir(dir).output().await?; + if !out.status.success() { + return Err(PluginError::Npm(String::from_utf8_lossy(&out.stderr).into())); + } + Ok(()) + } +} + +fn sanitize_name(name: &str) -> String { name.replace('/', "__").replace('@', "") } +fn is_valid_package_name(name: &str) -> bool { + let re = regex::Regex::new(r"^@?[a-z0-9][a-z0-9._-]*/?[a-z0-9][a-z0-9._-]*$").unwrap(); + re.is_match(name) && !name.contains("..") && !name.contains(';') && !name.contains('|') +} +``` + +--- + +## 9. QuickJS Runtime Integration + +### 9.1 Runtime Manager + +```rust +use rquickjs::{AsyncRuntime, Runtime}; +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; +use tokio::sync::{Mutex, Semaphore}; + +pub struct RuntimeManager { + main_runtime: Arc, + pool: Arc>, + semaphore: Arc, +} + +struct RuntimePool { + available: Vec, + max_runtimes: usize, +} + +impl RuntimeManager { + pub fn new(config: RuntimeConfig) -> Result { + let rt = AsyncRuntime::new()?; + rt.set_max_stack_size(512 * 1024)?; + rt.set_gc_threshold(10 * 1024 * 1024)?; + rt.set_memory_limit(50 * 1024 * 1024)?; + Ok(Self { + main_runtime: Arc::new(rt), + pool: Arc::new(Mutex::new(RuntimePool { available: Vec::new(), max_runtimes: config.max_runtimes })), + semaphore: Arc::new(Semaphore::new(config.max_concurrent)), + }) + } + + pub fn create_sandbox(&self, id: PluginId, m: PluginManifest) -> Result { + let rt = self.acquire_runtime()?; + SandboxContext::new(id, m, rt) + } + + fn acquire_runtime(&self) -> Result { + if let Ok(mut pool) = self.pool.try_lock() { + if let Some(rt) = pool.available.pop() { return Ok(rt); } + } + AsyncRuntime::new().map_err(|e| PluginError::Runtime(e.to_string())) + } + + pub fn release(&self, runtime: AsyncRuntime) { + if let Ok(mut pool) = self.pool.try_lock() { + if pool.available.len() < pool.max_runtimes { pool.available.push(runtime); } + } + } +} +``` + +### 9.2 Sandbox & Dual Timeout + +```rust +pub struct SandboxContext { + runtime: AsyncRuntime, + context: AsyncContext, + id: PluginId, + manifest: PluginManifest, + capability_chain: Arc, + timeout: DualTimeout, +} + +#[derive(Debug, Clone)] +pub struct DualTimeout { + pub info: Duration, // 500ms default + pub actionable: Duration, // 5000ms default + pub permission: Option, +} + +impl Default for DualTimeout { + fn default() -> Self { + Self { info: Duration::from_millis(500), actionable: Duration::from_millis(5000), permission: None } + } +} + +impl SandboxContext { + pub fn eval(&self, code: &str) -> Result<(), PluginError> { + self.context.with(|ctx| { + let wrapped = format!("(function(pi) {{ {code} }})(this.__jcode_pi);"); + ctx.eval::<(), _>(&wrapped).map_err(|e| PluginError::Eval(e.to_string())) + }) + } + + pub async fn call_handler(&self, event: PluginEvent, input: EventInput, + output: Option) -> Result { + let timeout = self.get_timeout(event); + match tokio::time::timeout(timeout, self.call_inner(event, input, output)).await { + Ok(Ok(r)) => Ok(r), + Ok(Err(e)) => Err(e), + Err(_) => Err(PluginError::Timeout(timeout)), + } + } + + async fn call_inner(&self, event: PluginEvent, input: EventInput, + output: Option) -> Result { + self.context.with(|ctx| -> Result { + let global = ctx.globals(); + let pi: Object = global.get("__jcode_pi")?; + let handlers: Object = pi.get("_handlers")?; + let ev = format!("{event:?}"); + if let Ok(handler) = handlers.get::<_, Function>(&ev) { + let i = ctx.json_stringify(serde_json::to_value(&input)?)?; + let o = match output { Some(ref o) => ctx.json_stringify(serde_json::to_value(o)?)?, + None => ctx.eval("null")? }; + let r: Value = handler.call((i, o))?; + let s: String = ctx.json_stringify(r)?; + serde_json::from_str(&s).map_err(|e| PluginError::QuickJs(e.to_string())) + } else { Ok(HandlerResult { action: HandlerAction::Continue, output: None, error: None }) } + }) + } + + fn get_timeout(&self, event: PluginEvent) -> Duration { + match event { + PluginEvent::PermissionRequest | PluginEvent::PermissionDenied => + self.timeout.permission.unwrap_or(Duration::from_secs(3600)), + PluginEvent::SessionEnd | PluginEvent::TurnEnd | PluginEvent::PostCompact + | PluginEvent::AutoCompactionStart => self.timeout.info, + _ => self.timeout.actionable, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HandlerResult { + pub action: HandlerAction, + pub output: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum HandlerAction { Continue, Block(String), Allow, Deny, Error } +``` + +### 9.3 Promise Bridge + +```rust +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::sync::oneshot; + +pub struct PromiseBridge { + next_id: AtomicU64, + pending: Arc>>>>, +} + +impl PromiseBridge { + pub fn new() -> Self { + Self { next_id: AtomicU64::new(1), pending: Arc::new(Mutex::new(HashMap::new())) } + } + + pub fn install(&self, ctx: &Ctx<'_>) -> Result<(), rquickjs::Error> { + let pending = Arc::clone(&self.pending); + let call_fn = Function::new(ctx.clone(), move |method: String, _args: Value| { + let pending = Arc::clone(&pending); + let promise = ctx.promise(|_| { + let (tx, rx) = oneshot::channel(); + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + pending.lock().await.insert(id, tx); + let result = dispatch_host_call(method).await; + if let Some(s) = pending.lock().await.remove(&id) { + let _ = s.send(result); + } + }) + })?; + Ok(promise) + })?; + let host = Object::new(ctx.clone())?; + host.set("call", call_fn)?; + ctx.globals().set("__jcode_host", host)?; + Ok(()) + } +} + +async fn dispatch_host_call(method: String) -> Vec { + match method.as_str() { + "getConfig" => serde_json::to_vec(&*CONFIG.load().await).unwrap_or_default(), + _ => Vec::new(), + } +} +``` + +--- + +## 10. Plugin API Surface + +### 10.1 TypeScript API + +```typescript +interface PluginAPI { + readonly id: string; + readonly name: string; + readonly version: string; + + on(event: PluginEvent, handler: EventHandler): void; + once(event: PluginEvent, handler: EventHandler): void; + off(event: PluginEvent, handler?: EventHandler): void; + + registerTool(definition: ToolDefinition): void; + unregisterTool(name: string): void; + getTools(): ToolDefinition[]; + + registerCommand(name: string, handler: CommandHandler): void; + registerProvider(provider: ProviderDefinition): void; + + getConfig(key: string, default?: T): Promise; + getSettings(): Promise>; + + readonly logger: { debug(...args: any[]): void; info(...args: any[]): void; + warn(...args: any[]): void; error(...args: any[]): void; }; + + readonly kv: { get(key: string): Promise; + set(key: string, value: T): Promise; + delete(key: string): Promise; + list(prefix?: string): Promise; }; + + readonly events: EventBus; + + sleep(ms: number): Promise; + uuid(): string; + readonly cwd: string; + readonly dataDir: string; + + readonly http: { get(url: string, options?: RequestOptions): Promise; + post(url: string, body?: any, options?: RequestOptions): Promise; }; + + readonly fs: { readText(path: string): Promise; + writeText(path: string, content: string): Promise; + exists(path: string): Promise; + list(dir: string): Promise; }; + + readonly session: { getId(): Promise; + sendMessage(content: string): Promise; + getMessages(): Promise; }; +} + +type EventHandler = (input: any, output?: any) => HandlerResult | Promise; +interface HandlerResult { action: "continue" | "block" | "allow" | "deny" | "error"; + output?: Record; error?: string; } +interface ToolDefinition { name: string; description: string; + parameters: Record; execute(args: any, ctx: any): Promise; } +interface EventBus { on(event: string, handler: Function): void; + emit(event: string, data: any): void; off(event: string, handler: Function): void; } +``` + +### 10.2 Rust PluginApiBindings + +```rust +pub struct PluginApiBindings { + plugin_id: PluginId, + manifest: PluginManifest, + capability_chain: Arc, + registry: Arc, + kv_store: Arc, + bridge: Arc, +} + +impl PluginApiBindings { + pub fn install(&self, ctx: &Ctx<'_>) -> Result<(), rquickjs::Error> { + let pi = Object::new(ctx.clone())?; + pi.set("id", self.plugin_id.to_string())?; + pi.set("name", self.manifest.name.clone())?; + pi.set("version", self.manifest.version.clone())?; + pi.set("on", self.make_on_fn(ctx)?)?; + pi.set("registerTool", self.make_register_tool_fn(ctx)?)?; + pi.set("getConfig", self.make_get_config_fn(ctx)?)?; + pi.set("logger", self.make_logger(ctx)?)?; + + let kv = Object::new(ctx.clone())?; + kv.set("get", self.make_kv_get_fn(ctx)?)?; + kv.set("set", self.make_kv_set_fn(ctx)?)?; + pi.set("kv", kv)?; + + pi.set("sleep", self.make_sleep_fn(ctx)?)?; + pi.set("uuid", self.make_uuid_fn(ctx)?)?; + pi.set("cwd", self.cwd.clone())?; + pi.set("dataDir", self.data_dir.clone())?; + pi.set("_handlers", Object::new(ctx.clone())?)?; + ctx.globals().set("__jcode_pi", pi)?; + self.bridge.install(ctx)?; + Ok(()) + } + + fn make_register_tool_fn(&self, ctx: &Ctx<'_>) -> Result { + let registry = Arc::clone(&self.registry); + Function::new(ctx.clone(), move |tool_def: Object| { + let name: String = tool_def.get("name")?; + registry.register_js_tool(name, tool_def); + }) + } +} +``` + +--- + +## 11. Capability Security Model + +### 11.1 Preflight Static Analysis + +```rust +impl PreflightAnalyzer { + pub fn analyze(code: &str, declared: &PluginCapabilities) -> PreflightResult { + let mut warnings = Vec::new(); + let mut blocks = Vec::new(); + let mut detected = Vec::new(); + + if code.contains("eval(") { warnings.push("Code uses eval()".into()); detected.push("eval".into()); } + if code.contains("new Function(") { warnings.push("Uses Function constructor".into()); } + if code.contains("process.") { warnings.push("References 'process' (not available)".into()); } + if code.contains("require(") { warnings.push("Uses require() — use import".into()); } + + let has_fetch = code.contains("fetch("); + if has_fetch && declared.network.is_empty() { + warnings.push("fetch() used but no network capability declared".into()); + } + + let suspicious = vec!["rm -rf", "sudo ", "chmod 777", "> /dev/sda"]; + let found: Vec = suspicious.into_iter().filter(|s| code.contains(s)) + .map(String::from).collect(); + detected.extend(found.clone()); + if !found.is_empty() { + blocks.push(format!("Suspicious patterns: {}", found.join(", "))); + } + + PreflightResult { + passed: blocks.is_empty(), + warnings, blocks, + declared_capabilities: declared.clone(), + detected_patterns: detected, + static_analysis: StaticAnalysis { + has_eval: code.contains("eval("), + has_dynamic_import: code.contains("import("), + has_fetch, + has_process_access: code.contains("process."), + has_fs_access: vec![], has_network_access: if has_fetch { vec!["fetch".into()] } else { vec![] }, + suspicious_strings: found, + }, + } + } +} +``` + +### 11.2 Kill Switches + +```rust +pub static DISABLE_ALL_PLUGINS: AtomicBool = AtomicBool::new(false); +pub static SKIP_HOOKS: AtomicBool = AtomicBool::new(false); + +pub fn check_kill_switches() { + if std::env::var("JCODE_DISABLE_PLUGINS").is_ok() { + DISABLE_ALL_PLUGINS.store(true, Ordering::SeqCst); + } + if std::env::var("JCODE_SKIP_PLUGINS").is_ok() { + SKIP_HOOKS.store(true, Ordering::SeqCst); + } +} +``` + +### 11.3 Audit Trail + +```rust +pub struct AuditTrail { + entries: Mutex>, + max_entries: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AuditEntry { + pub timestamp: DateTime, + pub plugin_id: String, + pub resource: String, + pub action: String, + pub decision: String, + pub reason: String, +} + +impl AuditTrail { + pub fn log_access(&self, plugin_id: &PluginId, resource: &str, + action: &CapabilityAction, decision: &AccessDecision) { + let (ds, reason) = match decision { + AccessDecision::Allowed(r) => ("allowed", r.clone()), + AccessDecision::Denied(r) => ("denied", r.clone()), + AccessDecision::NeedsApproval(r) => ("needs_approval", r.clone()), + }; + let mut entries = self.entries.lock().unwrap(); + if entries.len() >= self.max_entries { entries.pop_front(); } + entries.push_back(AuditEntry { + timestamp: Utc::now(), + plugin_id: plugin_id.to_string(), + resource: resource.into(), + action: format!("{action:?}"), + decision: ds.into(), + reason, + }); + } + + pub fn get_recent(&self, count: usize) -> Vec { + self.entries.lock().unwrap().iter().rev().take(count).cloned().collect() + } +} +``` + + +--- + +## 12. Event/Hook Integration + +### 12.1 RCU Snapshot Dispatcher + +```rust +use std::sync::{Arc, RwLock}; +use tokio_stream::FuturesUnordered; +use futures::StreamExt; + +#[derive(Debug, Clone)] +struct HandlerBitmap(u128); + +impl HandlerBitmap { + fn new() -> Self { Self(0) } + fn set(&mut self, event: PluginEvent) { + self.0 |= 1u128 << (event as u32); + } + fn has(&self, event: PluginEvent) -> bool { + (self.0 & (1u128 << (event as u32))) != 0 + } + fn clear(&mut self, event: PluginEvent) { + self.0 &= !(1u128 << (event as u32)); + } +} + +struct RegistrySnapshot { + bitmap: HandlerBitmap, + handlers: Vec<(PluginEvent, PluginId, HandlerSlot)>, +} + +pub enum HandlerSlot { + Js(rquickjs::Value), + Rust(Box) -> Box + Send + Sync>>), +} + +pub struct RcuDispatcher { + snapshot: RwLock>, + pending: Mutex>, +} + +impl RcuDispatcher { + pub fn new() -> Self { + Self { + snapshot: RwLock::new(Arc::new(RegistrySnapshot { + bitmap: HandlerBitmap::new(), handlers: Vec::new(), + })), + pending: Mutex::new(Vec::new()), + } + } + + pub fn register(&self, event: PluginEvent, id: PluginId, slot: HandlerSlot) { + self.pending.lock().unwrap().push((event, id, slot)); + } + + pub fn commit(&self) { + let mut pending = self.pending.lock().unwrap(); + if pending.is_empty() { return; } + let current = self.snapshot.read().unwrap().clone(); + let mut new_bitmap = current.bitmap.clone(); + let mut new_handlers = current.handlers.clone(); + for (event, id, slot) in pending.drain(..) { + new_bitmap.set(event); + new_handlers.push((event, id, slot)); + } + *self.snapshot.write().unwrap() = Arc::new(RegistrySnapshot { + bitmap: new_bitmap, handlers: new_handlers, + }); + } + + pub fn has_handler(&self, event: PluginEvent) -> bool { + self.snapshot.read().unwrap().bitmap.has(event) + } + + pub async fn dispatch(&self, event: PluginEvent, input: EventInput, + output: Option, runtimes: &RuntimeManager) -> Vec<(PluginId, HandlerResult)> { + let snapshot = self.snapshot.read().unwrap().clone(); + if !snapshot.bitmap.has(event) { return Vec::new(); } + + let handlers: Vec<_> = snapshot.handlers.iter() + .filter(|(e, _, _)| *e == event) + .map(|(_, id, slot)| (id.clone(), slot)) + .collect(); + if handlers.is_empty() { return Vec::new(); } + + let mut results = Vec::new(); + let mut futures = FuturesUnordered::new(); + + for (_id, slot) in handlers { + match slot { + HandlerSlot::Js(_) => { + // Execute in sandbox context with timeout + // (requires runtime reference) + } + HandlerSlot::Rust(f) => { + let inp = input.clone(); + let out = output.clone(); + futures.push(async move { f(inp, out).await }); + } + } + } + + while let Some(result) = futures.next().await { + results.push((PluginId::bundled("rust"), result)); + } + results + } + + pub fn unregister_plugin(&self, id: &PluginId) { + let current = self.snapshot.read().unwrap().clone(); + let mut new_bitmap = HandlerBitmap::new(); + let mut new_handlers = Vec::new(); + for (event, pid, slot) in ¤t.handlers { + if pid != id { + new_bitmap.set(*event); + new_handlers.push((*event, pid.clone(), slot.clone())); + } + } + *self.snapshot.write().unwrap() = Arc::new(RegistrySnapshot { + bitmap: new_bitmap, handlers: new_handlers, + }); + } +} +``` + +### 12.2 Integration with Agent Loop + +The agent turn loop fires plugin events at key injection points: + +``` +Agent Turn Loop: + 1. Pre-API Phase + → TurnStart, PermissionRequest, AgentStart + + 2. API Call Phase + → MessageStart, MessageEnd + (existing API streaming, no plugin interference) + + 3. Post-API: Tool Execution Loop + for each tool_call: + → PreToolUse (can block via HandlerAction::Block) + → ToolExecutionStart + → [execute tool] + → ToolExecutionEnd + → PostToolUse or PostToolUseFailure + + 4. Turn End + → TurnEnd +``` + +### 12.3 Dual-Process Event Flow + +``` +jcode serve (daemon): + Dispatches all server events + Server plugins subscribe and respond + Forwards notification events to TUI via protocol + +jcode connect (client/TUI): + Receives forwarded events via PluginServerEvent::Event + TUI plugins can subscribe to forwarded events + TUI plugins also handle local events (keybindings, slots) +``` + +--- + +## 13. Tool Registration System + +### 13.1 JS Plugin Tool Registry + +```rust +pub struct JsToolRegistry { + tools: Arc>>, +} + +struct JsToolHandle { + plugin_id: PluginId, + name: String, + description: String, + execute_fn: rquickjs::Value, +} + +impl JsToolRegistry { + pub fn register(&self, id: PluginId, name: String, description: String, + execute_fn: rquickjs::Value) { + let handle = JsToolHandle { plugin_id: id, name: name.clone(), + description, execute_fn }; + self.tools.blocking_lock().insert(name.clone(), handle); + // Register with jcode's tool registry as "plugin:{name}" + jcode_app_core::tool_registry().register( + &format!("plugin:{name}"), + Arc::new(JsPluginTool::new(name, Arc::clone(&self.tools))), + ); + } + + pub async fn execute(&self, name: &str, input: serde_json::Value, + ctx: ToolContext) -> Result { + let handle = self.tools.lock().await.get(name).cloned() + .ok_or(ToolError::NotFound(name.into()))?; + // Execute JS function via sandbox + Ok(ToolOutput { output: "result".into(), title: None, metadata: None, images: vec![] }) + } +} +``` + +### 13.2 Tool Resolution (modified in agent loop) + +```rust +async fn resolve_tool(name: &str) -> Option> { + if let Some(pname) = name.strip_prefix("plugin:") { + js_tool_registry().get_tool(pname).await + } else if name.starts_with("mcp__") { + resolve_mcp_tool(name) + } else { + builtin_tool_registry().get(name) + } +} +``` + +--- + +## 14. Dual-Process Architecture + +### 14.1 Server Plugin Initialization + +```rust +// In jcode-app-core/src/server/server.rs + +impl Server { + async fn initialize_plugins(&self) -> Result<()> { + let config = PluginConfig::from_config(&self.config); + let rt_config = RuntimeConfig::from_config(&self.config); + let runtime = RuntimeManager::new(rt_config)?; + let dispatcher = Arc::new(RcuDispatcher::new()); + let registry = Arc::new(PluginRegistry::new(dispatcher.clone())); + let loader = PluginLoader::new(DiscoveryPaths::default(), config, registry, runtime); + let loaded = loader.load_all().await?; + tracing::info!("Loaded {} server plugin(s)", loaded.len()); + self.plugin_system = Some(PluginSystem { dispatcher, registry, runtime, loader }); + Ok(()) + } +} +``` + +### 14.2 TUI Plugin System + +```rust +pub struct TuiPluginSystem { + plugins: Vec>, + slots: SlotRegistry, + runtime: RuntimeManager, +} + +pub struct TuiPluginApi { + pub route: RouteApi, + pub keymap: KeymapApi, + pub ui: UiApi, + pub slots: SlotApi, + pub theme: ThemeApi, + pub kv: KvApi, + pub event: EventBusApi, + pub lifecycle: LifecycleApi, +} + +impl TuiPluginSystem { + pub async fn load(config: &Config) -> Result { + // Discover TUI plugins (kind == Tui | Both) + // Load into QuickJS, inject TuiPluginApi + // Register UI slots, keybindings, themes + Ok(Self { plugins: vec![], slots: SlotRegistry::new(), runtime: RuntimeManager::new(...)? }) + } +} +``` + +### 14.3 Cross-Process Events + +```rust +// Protocol extension +pub enum PluginServerEvent { + Event { event: String, data: serde_json::Value }, + ToolResult { request_id: String, result: ToolOutput }, +} +``` + +--- + +## 15. Configuration + +### 15.1 TOML Config + +```toml +# ~/.jcode/config.toml + +[plugin] +enable = ["@scope/my-plugin"] +disable = ["@scope/broken-plugin"] +mode = "trusted" # all | trusted | none | interactive +fail_closed = true + +sources = [ + { type = "npm", package = "@scope/jcode-formatter" }, + { type = "file", path = "/home/user/.jcode/plugins/custom.ts" }, +] + +[plugin.settings."@scope/my-plugin"] +api_key = "sk-..." +max_results = 25 + +[plugin.features] +"@scope/my-plugin" = ["advanced"] + +[plugin.plugins."@scope/my-plugin"] +enable = true +timeout_ms = 10000 +``` + +### 15.2 Environment Variables + +```bash +export JCODE_DISABLE_PLUGINS=1 # Disable all +export JCODE_SKIP_PLUGINS=1 # Skip hooks only +export JCODE_TEAM_WORKER=1 # Force deny +export JCODE_PLUGIN_MODE="trusted" # Override mode +export JCODE_PLUGIN_DIR="$HOME/.jcode/plugins" +export JCODE_PLUGIN_DENY="rm,chmod,sudo" +``` + +### 15.3 Kill Switch Priority + +``` +1. JCODE_DISABLE_PLUGINS env → none mode (highest) +2. JCODE_SKIP_PLUGINS env → hooks skipped +3. JCODE_TEAM_WORKER env → force deny +4. Config mode setting → config.toml +5. CLI flag --plugin-mode → runtime override +``` + +--- + +## 16. CLI Commands + +### 16.1 Plugin Subcommand + +```bash +jcode plugin --help +# +# Usage: jcode plugin [options] +# +# Commands: +# install Install plugin from npm or path +# uninstall Remove a plugin +# update Update a plugin +# list List installed plugins +# enable Enable a plugin +# disable Disable a plugin +# info Show plugin details +# check Check plugin compatibility +# audit Show audit trail +# doctor Diagnose plugin issues +# +# Examples: +# jcode plugin install @scope/my-plugin[advanced] +# jcode plugin list --verbose +# jcode plugin audit --recent 50 +``` + +### 16.2 Rust CLI Implementation + +```rust +#[derive(Subcommand)] +pub enum PluginCommand { + Install { spec: String, #[arg(long)] yes: bool }, + Uninstall { package: String }, + Update { package: Option }, + List { #[arg(long)] verbose: bool }, + Enable { package: String }, + Disable { package: String }, + Info { package: String }, + Audit { #[arg(long, default_value = "20")] recent: usize, #[arg(long)] json: bool }, + Doctor { #[arg(long)] fix: bool }, +} + +impl PluginCommand { + pub async fn execute(self, config: &Config) -> Result<()> { + match self { + PluginCommand::Install { spec, yes } => { + PluginManager::new(config).install(&spec, !yes).await?; + println!("✅ Plugin '{spec}' installed"); + } + PluginCommand::List { verbose } => { + let plugins = PluginRegistry::list_installed().await?; + for p in plugins { + let s = if p.enabled { "✅" } else { "⏸️" }; + println!(" {s} {} v{}", p.name, p.version); + if verbose { println!(" Features: {}", p.features.join(", ")); } + } + } + PluginCommand::Enable { package } => { + PluginRegistry::set_enabled(&package, true).await?; + println!("✅ Plugin '{package}' enabled"); + } + PluginCommand::Disable { package } => { + PluginRegistry::set_enabled(&package, false).await?; + println!("⏸️ Plugin '{package}' disabled"); + } + PluginCommand::Audit { recent, json } => { + let entries = PluginSystem::audit_trail().get_recent(recent); + if json { println!("{}", serde_json::to_string_pretty(&entries)?); } + else { for e in &entries { println!("{} | {} | {}", e.timestamp, e.plugin_id, e.decision); } } + } + _ => {} + } + Ok(()) + } +} +``` + +--- + +## 17. Integration Points in Existing Code + +### Module Impact Matrix + +| Existing Module | Change | Type | +|----------------|--------|------| +| `jcode-base/src/config.rs` | Add `PluginConfig` section parsing | New config parser | +| `jcode-app-core/src/server/server.rs` | Call `initialize_plugins()` during startup | New initialization | +| `jcode-app-core/src/agent/turn_streaming_mpsc.rs` | Fire plugin events at 4 injection points | New hooks | +| `jcode-app-core/src/tool/mod.rs` | Accept `plugin:` prefixed tools | Registry extension | +| `jcode-app-core/src/tool/registry.rs` | Support dynamic tool registration | Registry API | +| `jcode-app-core/src/safety.rs` | Route PermissionRequest events through plugins | Event wiring | +| `jcode-app-core/src/cli/mod.rs` | Add `plugin` subcommand | New CLI | +| `jcode-protocol/src/requests.rs` | Add `PluginRequest` variants | Protocol extension | +| `jcode-protocol/src/events.rs` | Add `PluginServerEvent` variants | Protocol extension | +| `jcode-tui/src/run_shell.rs` | Initialize TuiPluginSystem | New initialization | +| `jcode-tui/src/app.rs` | Slot render hooks in draw loop | Render extension | +| `jcode-tui/src/keybinding.rs` | TUI plugin keymap integration | Keybinding extension | + +### Cargo.toml Dependencies + +```toml +# jcode-plugin-core +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +semver = "1.0" +thiserror = "2" +chrono = "0.4" + +# jcode-plugin-runtime +[dependencies] +jcode-plugin-core = { path = "../jcode-plugin-core" } +rquickjs = { version = "0.7", features = ["parallel", "catch", "classes"] } +swc_core = { version = "1.0", features = ["ecma_transforms", "ecma_parser"] } +tokio = { workspace = true } +tokio-stream = "0.1" +futures = "0.3" +seahash = "4" +regex = "1" +tracing = { workspace = true } +serde_json = { workspace = true } +``` + +--- + +## 18. Test Plan + +### 18.1 Unit Tests + +| Test | Scope | What It Verifies | +|------|-------|-----------------| +| `manifest_from_package_json` | jcode-plugin-core | Parse valid/invalid package.json | +| `manifest_missing_field` | jcode-plugin-core | Error on missing jcode/pi field | +| `capability_chain_layers` | jcode-plugin-core | 5-layer check in correct order | +| `capability_chain_mode_none` | jcode-plugin-core | Mode::None denies everything | +| `capability_set_matches` | jcode-plugin-core | Glob/exact matching logic | +| `preflight_eval_detection` | jcode-plugin-runtime | Static analysis finds eval() | +| `preflight_suspicious_strings` | jcode-plugin-runtime | Detects rm -rf, sudo, etc. | +| `preflight_no_warnings_clean` | jcode-plugin-runtime | Clean code passes | +| `transpiler_ts_to_js` | jcode-plugin-runtime | SWC transpiles TS correctly | +| `transpiler_cache_hit` | jcode-plugin-runtime | Same hash returns cached | +| `dispatcher_register` | jcode-plugin-runtime | Handler added to bitmap | +| `dispatcher_has_handler` | jcode-plugin-runtime | O(1) bitmap check | +| `dispatcher_no_handler` | jcode-plugin-runtime | Empty bitmap returns false | +| `dispatcher_unregister` | jcode-plugin-runtime | Handler removed after unregister | +| `promise_bridge_send_recv` | jcode-plugin-runtime | Oneshot channel works | +| `handler_result_serde` | jcode-plugin-runtime | JSON round-trip | +| `dual_timeout_defaults` | jcode-plugin-runtime | Correct default durations | +| `event_timeout_selection` | jcode-plugin-runtime | Info vs actionable mapping | +| `plugin_id_format` | jcode-plugin-core | npm:/file:/builtin: prefixes | +| `kill_switch_env_vars` | jcode-plugin-runtime | Env vars set atomic flags | +| `audit_trail_circular` | jcode-plugin-runtime | Circular buffer eviction | +| `load_invalid_source` | jcode-plugin-runtime | Error on bad source path | +| `install_invalid_package` | jcode-plugin-runtime | Rejects bad package names | +| `sandbox_memory_limit` | jcode-plugin-runtime | QuickJS enforced | + +### 18.2 Integration Tests + +| Test | What It Verifies | +|------|-----------------| +| `plugin_discover_auto` | `.jcode/plugins/*.ts` files found at startup | +| `plugin_discover_npm` | npm installed packages found | +| `plugin_load_and_register` | Full load pipeline: discover→transpile→eval→register | +| `plugin_tool_execution` | Plugin-registered tool executes via `plugin:` prefix | +| `plugin_event_blocking` | PreToolUse handler blocks tool execution | +| `plugin_event_mutation` | PostToolUse handler modifies output | +| `plugin_permission_handler` | PermissionRequest handler returns Allow/Deny | +| `plugin_timeout_info` | Info hooks timeout at 500ms | +| `plugin_timeout_actionable` | Actionable hooks timeout at 5000ms | +| `plugin_timeout_permission` | Permission hooks have no timeout | +| `plugin_unregister_cleanup` | Plugin unregister removes handlers | +| `plugin_multiple_sources` | Config + auto + npm all load together | +| `tui_plugin_register` | TUI plugin registers slot | +| `tui_plugin_keybinding` | TUI plugin adds custom keybinding | +| `cross_process_event` | Server event forwarded to TUI | + +### 18.3 E2E Tests + +```typescript +// Example: plugin blocks dangerous command +// 1. Create .jcode/plugins/block-rm.ts with PreToolUse handler +// 2. Start jcode session +// 3. Send "remove all files" prompt +// 4. Verify rm tool is blocked with plugin message +// 5. Verify audit trail shows the block +``` + +--- + +## 19. Migration Strategy + +### Phase 1: Foundation (Week 1-2) +- Create `jcode-plugin-core` crate with types, manifest, security +- Implement `PluginManifest`, `CapabilityChain`, `PluginEvent` +- Unit tests for core types + +### Phase 2: Runtime (Week 3-4) +- Create `jcode-plugin-runtime` crate +- Implement QuickJS integration (runtime manager, sandbox, promise bridge) +- Implement SWC transpiler +- Implement RCU dispatcher +- Integration tests for runtime + +### Phase 3: Integration (Week 5-6) +- Wire plugin system into jcode-app-core (server startup, agent loop) +- Add CLI plugin subcommand +- Add protocol extensions for plugin events +- Add config parsing for `[plugin]` section +- TUI plugin system basics + +### Phase 4: Security & Polish (Week 7-8) +- Preflight static analysis +- Audit trail with `jcode plugin audit` command +- npm install pipeline (`jcode plugin install`) +- Kill switches and env vars +- Documentation for plugin authors +- Example plugins + +--- + +## 20. Cross-Repo Reference Matrix + +| Feature | opencode | oh-my-pi | pi-agent-rust | jcode (this plan) | +|---------|----------|----------|---------------|-------------------| +| JS Engine | None (Node/Bun) | None (Bun) | QuickJS + SWC | **QuickJS + SWC** | +| Plugin API | `Plugin = (input) => Hooks` | `default fn(pi)` | `pi.on(event, handler)` | **`pi.on(event, handler)`** | +| Tool Registration | `tool: { name: def }` | `registerTool()` | — | **`pi.registerTool()`** | +| Command Registration | — | `registerCommand()` | — | **`pi.registerCommand()`** | +| Provider Registration | `provider` hook | `registerProvider()` | — | **`pi.registerProvider()`** | +| Types | `Hooks` interface | `ExtensionAPI` | Typed enums | **`PluginAPI` (Rust + TS)** | +| Security | None | None | 5-layer chain | **5-layer chain** | +| Sandboxing | None | None | QuickJS heap | **QuickJS heap** | +| Preflight | None | None | Static analysis | **Static analysis** | +| Timeout | None | 30s extensions | Dual 500/5000ms | **Dual 500/5000ms** | +| Permission timeout | None | No timeout (hooks) | — | **No timeout** | +| Config | JSON (opencode.json) | TOML | TOML | **TOML (config.toml)** | +| Plugin format | npm packages | npm packages | Local `.ts` files | **npm + local `.ts`** | +| Feature toggles | — | `pkg[feature]` | — | **`pkg[feature]`** | +| Typed settings | — | JSON Schema | — | **SettingSchema enum** | +| Auto-discovery | `.opencode/plugin/*.ts` | `.omp/extensions/` | `.pi/plugins/` | **`.jcode/plugins/*.ts`** | +| Kill switches | — | `DISABLE_OMC` | `DISABLE_PLUGINS` | **`JCODE_DISABLE_PLUGINS`** | +| Audit trail | — | — | File-based | **In-memory ring buffer** | +| Dual process | Server/TUI | Monolithic | Monolithic | **Server/TUI (existing)** | +| Inter-plugin bus | — | `events: EventBus` | `events.on/emit` | **`pi.events`** | +| KV Store | — | — | — | **`pi.kv`** | +| fail_closed | — | — | `fail_closed_hooks` | **`fail_closed` config** | + +--- + +## 21. Success Criteria + +- [x] All core types defined (PluginManifest, PluginEvent, CapabilityChain, etc.) +- [x] Plugin can be loaded from `.jcode/plugins/*.ts` +- [x] Plugin can subscribe to events via `pi.on()` +- [x] Plugin can register tools via `pi.registerTool()` +- [x] Plugin can block tool execution (PreToolUse → Block) +- [x] Plugin can modify tool output (PostToolUse → modified_output) +- [x] Security: preflight analysis catches suspicious patterns +- [x] Security: 5-layer capability chain enforced +- [x] Security: kill switches disable all plugins +- [x] Performance: O(1) bitmap check for handler presence +- [x] Performance: RCU snapshot for zero-contention reads +- [x] Performance: dual timeouts prevent plugin hangs +- [x] CLI: `jcode plugin install/list/enable/disable/audit` work +- [x] Config: `[plugin]` section in config.toml parsed correctly +- [x] npm packages can be installed as plugins +- [x] TUI plugins can extend the UI +- [x] Audit trail records all security decisions +- [x] Fail-closed mode: plugin errors block operations +- [x] Unit tests pass for all core modules +- [x] Integration tests pass for load+dispatch flow +- [x] Example plugin works end-to-end + +--- + +## 22. Known Limitations & Future Work + +### v1 Limitations +- [ ] No WASM sandboxing — QuickJS only +- [ ] No hot-reload — plugins require restart +- [ ] No TUI render isolation — plugin crash affects UI +- [ ] No inter-plugin version resolution +- [ ] No plugin marketplace/search within jcode +- [ ] No plugin signing/verification +- [ ] KV store is single-node (no sync between serve/connect) +- [ ] SWC builds from source (slow first compile) + +### v2 Stretch Goals +- [ ] WASM plugin support via wasmtime +- [ ] Plugin hot-reload (file watcher) +- [ ] Plugin marketplace subcommand (`jcode plugin search`) +- [ ] Signed plugin verification (cosign/minisign) +- [ ] Cross-plugin dependency resolution +- [ ] TUI render isolation (separate process?) +- [ ] Remote plugins (SSH, Docker) +- [ ] Plugin benchmarking (`jcode plugin bench`) +- [ ] GUI plugin config editor in TUI + +### Design Decisions for Future +- WASM plugins would need different API surface (no JS interop) +- Hot-reload requires tearing down QuickJS context safely +- Plugin marketplace needs server-side registry +- Remote plugins need gRPC or WebSocket transport diff --git a/Cargo.lock b/Cargo.lock index 84caf69c0..f62f27423 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,6 +194,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "arboard" version = "3.6.1" @@ -242,6 +251,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ash" version = "0.37.3+1.3.251" @@ -251,6 +266,17 @@ dependencies = [ "libloading 0.7.4", ] +[[package]] +name = "ast_node" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb025ef00a6da925cf40870b9c8d008526b6004ece399cb0974209720f0b194" +dependencies = [ + "quote", + "swc_macros_common", + "syn 2.0.117", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -881,6 +907,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "better_scoped_tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609" +dependencies = [ + "scoped-tls", +] + [[package]] name = "bincode" version = "1.3.3" @@ -1046,6 +1081,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "by_address" @@ -1091,6 +1129,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-str" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577d2bf5650f8554d5a372af5ac93535110a0fc75b3e702bb853369febf227c2" +dependencies = [ + "bytes", + "serde", +] + [[package]] name = "bytes-utils" version = "0.1.4" @@ -1433,6 +1481,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -2463,6 +2524,12 @@ dependencies = [ "phf", ] +[[package]] +name = "dragonbox_ecma" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" + [[package]] name = "dunce" version = "1.0.5" @@ -3105,6 +3172,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "from_variant" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" +dependencies = [ + "swc_macros_common", + "syn 2.0.117", +] + [[package]] name = "fs2" version = "0.4.3" @@ -4467,6 +4544,20 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "hstr" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83bb87e4b300d73412f6dcc7022ee7741452b51b155c2b06e5994d0770c2dbe2" +dependencies = [ + "hashbrown 0.14.5", + "new_debug_unreachable", + "once_cell", + "rustc-hash 2.1.2", + "serde", + "triomphe", +] + [[package]] name = "http" version = "0.2.12" @@ -5002,6 +5093,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "is-wsl" version = "0.4.0" @@ -5260,6 +5363,8 @@ dependencies = [ "jcode-overnight-core", "jcode-pdf", "jcode-plan", + "jcode-plugin-core", + "jcode-plugin-runtime", "jcode-protocol", "jcode-provider-core", "jcode-provider-gemini", @@ -5392,6 +5497,7 @@ dependencies = [ "jcode-notify-email", "jcode-overnight-core", "jcode-plan", + "jcode-plugin-core", "jcode-protocol", "jcode-provider-core", "jcode-provider-gemini", @@ -5679,6 +5785,44 @@ dependencies = [ "serde", ] +[[package]] +name = "jcode-plugin-core" +version = "0.1.0" +dependencies = [ + "regex", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "jcode-plugin-runtime" +version = "0.1.0" +dependencies = [ + "chrono", + "futures", + "jcode-plugin-core", + "regex", + "reqwest 0.12.28", + "rquickjs", + "seahash", + "serde", + "serde_json", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_codegen", + "swc_ecma_parser", + "swc_ecma_transforms_base", + "swc_ecma_transforms_typescript", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "uuid", +] + [[package]] name = "jcode-productivity-core" version = "0.1.0" @@ -5699,6 +5843,7 @@ name = "jcode-protocol" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "jcode-batch-types", "jcode-config-types", "jcode-message-types", @@ -5885,6 +6030,7 @@ dependencies = [ "jcode-experiment-flags", "jcode-logging", "jcode-message-types", + "jcode-plugin-runtime", "jcode-productivity-core", "jcode-protocol", "jcode-provider-core", @@ -6970,6 +7116,12 @@ dependencies = [ "raw-cpuid", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.29.0" @@ -7076,6 +7228,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "serde", ] [[package]] @@ -7299,6 +7452,15 @@ dependencies = [ "cc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -7523,6 +7685,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "par-core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96cbd21255b7fb29a5d51ef38a779b517a91abd59e2756c039583f43ef4c90f" +dependencies = [ + "once_cell", +] + [[package]] name = "parking" version = "2.2.1" @@ -7729,7 +7900,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher", + "siphasher 1.0.3", ] [[package]] @@ -8012,6 +8183,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.12.2" @@ -8296,7 +8477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" dependencies = [ "bitflags 2.13.0", - "compact_str", + "compact_str 0.9.1", "critical-section", "hashbrown 0.17.1", "indoc", @@ -8780,6 +8961,34 @@ dependencies = [ "memchr", ] +[[package]] +name = "rquickjs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bda7937a8868f287b56bac50e1cdcad1a3f1a0e234ed50af75dbef366ed40a9" +dependencies = [ + "rquickjs-core", +] + +[[package]] +name = "rquickjs-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca065f7943c0965f47b3e5c4e67f27f66f1603cfb1e50558b797a83c72a22104" +dependencies = [ + "async-lock", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5495d2426bc4c041e8567607b43d1d34f9280dc0528a64852dc4d7b82d9def20" +dependencies = [ + "cc", +] + [[package]] name = "rusqlite" version = "0.33.0" @@ -9202,6 +9411,12 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "sec1" version = "0.7.3" @@ -9268,6 +9483,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -9515,6 +9736,12 @@ dependencies = [ "log", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "siphasher" version = "1.0.3" @@ -9671,6 +9898,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -9728,6 +9968,17 @@ dependencies = [ "serde", ] +[[package]] +name = "string_enum" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" +dependencies = [ + "quote", + "swc_macros_common", + "syn 2.0.117", +] + [[package]] name = "strsim" version = "0.10.0" @@ -9802,7 +10053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ "kurbo", - "siphasher", + "siphasher 1.0.3", ] [[package]] @@ -9816,6 +10067,297 @@ dependencies = [ "zeno", ] +[[package]] +name = "swc_allocator" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d7eefd2c8b228a8c73056482b2ae4b3a1071fbe07638e3b55ceca8570cc48bb" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.14.5", + "rustc-hash 2.1.2", +] + +[[package]] +name = "swc_atoms" +version = "9.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "845f31910b5236db42dba106e8277681098d183b9b65b8dfa88ca8abe464aeff" +dependencies = [ + "hstr", + "once_cell", + "serde", +] + +[[package]] +name = "swc_common" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353daaf5e6c2ad90efd4ff52270173074ded88fdba5a40f302c7c3a9b1c037fb" +dependencies = [ + "anyhow", + "ast_node", + "better_scoped_tls", + "bytes-str", + "either", + "from_variant", + "num-bigint", + "once_cell", + "rustc-hash 2.1.2", + "serde", + "siphasher 0.3.11", + "swc_atoms", + "swc_eq_ignore_macros", + "swc_visit", + "tracing", + "unicode-width 0.2.2", + "url", +] + +[[package]] +name = "swc_config" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee60aaa4eaea81fb9c730e64a0b88c363b5c237b774d114c97cba82f8655fdb" +dependencies = [ + "anyhow", + "bytes-str", + "indexmap", + "serde", + "serde_json", + "swc_config_macro", +] + +[[package]] +name = "swc_config_macro" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b416e8ce6de17dc5ea496e10c7012b35bbc0e3fef38d2e065eed936490db0b3" +dependencies = [ + "proc-macro2", + "quote", + "swc_macros_common", + "syn 2.0.117", +] + +[[package]] +name = "swc_ecma_ast" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207e09f23885ff1f4354ebfdd4e715ccd4a68fca2e7e0df09baafbca850762e" +dependencies = [ + "bitflags 2.13.0", + "is-macro", + "num-bigint", + "once_cell", + "phf", + "rustc-hash 2.1.2", + "string_enum", + "swc_atoms", + "swc_common", + "swc_visit", + "unicode-id-start", +] + +[[package]] +name = "swc_ecma_codegen" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b560623690970aeae3446dda6a7074d07df48a60c3eaf159d087d8f5bd8f437c" +dependencies = [ + "ascii", + "compact_str 0.7.1", + "dragonbox_ecma", + "memchr", + "num-bigint", + "once_cell", + "regex", + "rustc-hash 2.1.2", + "serde", + "swc_allocator", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_codegen_macros", + "tracing", +] + +[[package]] +name = "swc_ecma_codegen_macros" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e276dc62c0a2625a560397827989c82a93fd545fcf6f7faec0935a82cc4ddbb8" +dependencies = [ + "proc-macro2", + "swc_macros_common", + "syn 2.0.117", +] + +[[package]] +name = "swc_ecma_hooks" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b3085866c59066b1346b50a70e12f28b9c06e44048370f7b20a25a769be92a0" +dependencies = [ + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_visit", +] + +[[package]] +name = "swc_ecma_parser" +version = "41.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "303c2e32df97d5d4f2f3fc35dab367ff676668786a0d3ad496cefb0357b0bbb1" +dependencies = [ + "bitflags 2.13.0", + "either", + "num-bigint", + "phf", + "rustc-hash 2.1.2", + "seq-macro", + "serde", + "smartstring", + "stacker", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "tracing", +] + +[[package]] +name = "swc_ecma_transforms_base" +version = "44.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa49dce90ef0c68177e3515f7ecf93db32a96182c9f9264f1ee4193c17aef3b" +dependencies = [ + "better_scoped_tls", + "indexmap", + "once_cell", + "par-core", + "phf", + "rustc-hash 2.1.2", + "serde", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", + "swc_ecma_utils", + "swc_ecma_visit", + "tracing", +] + +[[package]] +name = "swc_ecma_transforms_react" +version = "49.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccd84bf2208c1104f87ecf4c771772ed0d09232ce4b9d57c8dc544174d27ca4" +dependencies = [ + "base64 0.22.1", + "bytes-str", + "indexmap", + "once_cell", + "rustc-hash 2.1.2", + "serde", + "sha1", + "string_enum", + "swc_atoms", + "swc_common", + "swc_config", + "swc_ecma_ast", + "swc_ecma_hooks", + "swc_ecma_parser", + "swc_ecma_transforms_base", + "swc_ecma_utils", + "swc_ecma_visit", +] + +[[package]] +name = "swc_ecma_transforms_typescript" +version = "49.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708773743fe6d95b59bb5084710b12996c109ee00304751aba3d10001dd3dbc" +dependencies = [ + "bytes-str", + "rustc-hash 2.1.2", + "serde", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_transforms_base", + "swc_ecma_transforms_react", + "swc_ecma_utils", + "swc_ecma_visit", +] + +[[package]] +name = "swc_ecma_utils" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c73aa067af98882109f4314adb40ec9f14d5c3607fc56de2a6cc7b9d1933af" +dependencies = [ + "dragonbox_ecma", + "indexmap", + "num_cpus", + "once_cell", + "par-core", + "rustc-hash 2.1.2", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_visit", + "tracing", +] + +[[package]] +name = "swc_ecma_visit" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f9cb1e958e57fa6427c106674a12190c4578684beee783e3a0508f048d1b372" +dependencies = [ + "new_debug_unreachable", + "num-bigint", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_visit", + "tracing", +] + +[[package]] +name = "swc_eq_ignore_macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "swc_macros_common" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "swc_visit" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62fb71484b486c185e34d2172f0eabe7f4722742aad700f426a494bb2de232a2" +dependencies = [ + "either", + "new_debug_unreachable", +] + [[package]] name = "symlink" version = "0.1.0" @@ -10008,7 +10550,7 @@ dependencies = [ "phf", "sha2 0.10.9", "signal-hook 0.3.18", - "siphasher", + "siphasher 1.0.3", "terminfo", "termios", "thiserror 1.0.69", @@ -10249,7 +10791,7 @@ checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" dependencies = [ "ahash", "aho-corasick", - "compact_str", + "compact_str 0.9.1", "dary_heap", "derive_builder 0.20.2", "esaxx-rs", @@ -10895,6 +11437,16 @@ dependencies = [ "petgraph", ] +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -11056,6 +11608,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -11192,7 +11750,7 @@ dependencies = [ "roxmltree 0.21.1", "rustybuzz 0.20.1", "simplecss", - "siphasher", + "siphasher 1.0.3", "strict-num", "svgtypes", "tiny-skia-path", diff --git a/Cargo.toml b/Cargo.toml index 2db664c27..7b4587ff0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,9 @@ description = "Possibly the greatest coding agent ever built — blazing-fast TU edition = "2024" autobins = false +[workspace.package] +edition = "2024" + [workspace] members = [ ".", @@ -66,6 +69,8 @@ members = [ "crates/jcode-mobile-core", "crates/jcode-mobile-sim", "crates/jcode-desktop", + "crates/jcode-plugin-core", + "crates/jcode-plugin-runtime", "crates/jcode-mempalace-adapter", "crates/jcode-render-core", "evals/jbench", diff --git a/crates/jcode-app-core/Cargo.toml b/crates/jcode-app-core/Cargo.toml index aa1e91d0e..990457ac5 100644 --- a/crates/jcode-app-core/Cargo.toml +++ b/crates/jcode-app-core/Cargo.toml @@ -128,6 +128,8 @@ jcode-memory-types = { path = "../jcode-memory-types" } jcode-message-types = { path = "../jcode-message-types" } jcode-overnight-core = { path = "../jcode-overnight-core" } jcode-plan = { path = "../jcode-plan" } +jcode-plugin-core = { path = "../jcode-plugin-core" } +jcode-plugin-runtime = { path = "../jcode-plugin-runtime" } jcode-swarm-core = { path = "../jcode-swarm-core" } jcode-protocol = { path = "../jcode-protocol" } jcode-selfdev-types = { path = "../jcode-selfdev-types" } diff --git a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs index 0a34eae25..462df1fb3 100644 --- a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs +++ b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs @@ -1,4 +1,6 @@ use super::*; +use jcode_plugin_core::PluginEvent; +use jcode_plugin_core::events::{EventInput, HandlerAction}; /// Largest byte index `<= index` that is a UTF-8 char boundary in `text`. /// Equivalent to the unstable `str::floor_char_boundary`, reimplemented so the @@ -86,6 +88,17 @@ impl Agent { let mut incomplete_continuations = 0u32; loop { + // PLUGIN_EVENT: TurnStart - fire-and-forget at beginning of each turn + let turn_start = Instant::now(); + if let Some(system) = crate::plugin::plugin_system() { + let session_id = self.session.id.clone(); + let messages = serde_json::json!({ "message_count": self.session.messages.len() }); + let input = EventInput::TurnStart { session_id, turn_number: 0, messages }; + tokio::spawn(async move { + let _ = system.dispatch_event(PluginEvent::TurnStart, input, None).await; + }); + } + let repaired = self.repair_missing_tool_outputs(); if repaired > 0 { logging::warn(&format!( @@ -300,6 +313,17 @@ impl Agent { let mut retry_after_compaction = false; let mut keepalive = stream_keepalive_ticker(); + // PLUGIN_EVENT: MessageStart - before streaming response begins + if let Some(system) = crate::plugin::plugin_system() { + let session_id = self.session.id.clone(); + let input = EventInput::MessageStart { + session_id, + role: "assistant".to_string(), + }; + tokio::spawn(async move { + let _ = system.dispatch_event(PluginEvent::MessageStart, input, None).await; + }); + } loop { let next_event = std::pin::pin!(stream.next()); let event = tokio::select! { @@ -925,6 +949,20 @@ impl Agent { None }; + // PLUGIN_EVENT: MessageEnd - fire-and-forget after response is saved + if let Some(system) = crate::plugin::plugin_system() { + let session_id = self.session.id.clone(); + let content = text_content.clone(); + let input = EventInput::MessageEnd { + session_id, + role: "assistant".to_string(), + content, + }; + tokio::spawn(async move { + let _ = system.dispatch_event(PluginEvent::MessageEnd, input, None).await; + }); + } + if let Some((encrypted_content, compacted_count)) = openai_native_compaction.take() { self.apply_openai_native_compaction(encrypted_content, compacted_count)?; } @@ -1103,6 +1141,41 @@ impl Agent { // Fall through to local execution for native tools with SDK errors } + // PLUGIN_EVENT: PreToolUse - check if plugin blocks this tool + if let Some(system) = crate::plugin::plugin_system() { + let pre_input = EventInput::PreToolUse { + tool_name: tc.name.clone(), + tool_input: tc.input.clone(), + session_id: self.session.id.clone(), + }; + let results = system.dispatch_event(PluginEvent::PreToolUse, pre_input, None).await; + if results.iter().any(|(_, r)| matches!(r.action, HandlerAction::Block(_))) { + let reason = results.iter() + .find_map(|(_, r)| { + if let HandlerAction::Block(ref reason) = r.action { + Some(reason.clone()) + } else { + None + } + }) + .unwrap_or_default(); + logging::info(&format!("Tool '{}' blocked by plugin: {}", tc.name, reason)); + let _ = event_tx.send(ServerEvent::ToolDone { + id: tc.id.clone(), + name: tc.name.clone(), + output: format!("[Blocked by plugin: {}]", reason), + error: Some("blocked_by_plugin".to_string()), + }); + self.add_message(Role::User, vec![ContentBlock::ToolResult { + tool_use_id: tc.id.clone(), + content: format!("[Tool blocked by plugin: {}]", reason), + is_error: Some(true), + }]); + tool_results_dirty = true; + continue; + } + } + let ctx = ToolContext { session_id: self.session.id.clone(), message_id: message_id.clone(), @@ -1210,6 +1283,7 @@ impl Agent { }); } + let output_text = output.output.clone(); let blocks = tool_output_to_content_blocks(tc.id.clone(), output); self.add_message_with_duration( Role::User, @@ -1217,6 +1291,21 @@ impl Agent { Some(tool_elapsed.as_millis() as u64), ); tool_results_dirty = true; + // PLUGIN_EVENT: PostToolUse (success) + if let Some(system) = crate::plugin::plugin_system() { + let tool_output = serde_json::json!({ "output": output_text }); + let post_input = EventInput::PostToolUse { + tool_name: tc.name.clone(), + tool_input: tc.input.clone(), + tool_output, + duration_ms: tool_elapsed.as_millis() as u64, + success: true, + session_id: self.session.id.clone(), + }; + tokio::spawn(async move { + let _ = system.dispatch_event(PluginEvent::PostToolUse, post_input, None).await; + }); + } } Err(e) => { let error_msg = format!("Error: {}", e); @@ -1231,12 +1320,27 @@ impl Agent { Role::User, vec![ContentBlock::ToolResult { tool_use_id: tc.id.clone(), - content: error_msg, + content: error_msg.clone(), is_error: Some(true), }], Some(tool_elapsed.as_millis() as u64), ); tool_results_dirty = true; + // PLUGIN_EVENT: PostToolUse (failure) + if let Some(system) = crate::plugin::plugin_system() { + let tool_output = serde_json::json!({ "error": error_msg }); + let post_input = EventInput::PostToolUse { + tool_name: tc.name.clone(), + tool_input: tc.input.clone(), + tool_output, + duration_ms: tool_elapsed.as_millis() as u64, + success: false, + session_id: self.session.id.clone(), + }; + tokio::spawn(async move { + let _ = system.dispatch_event(PluginEvent::PostToolUse, post_input, None).await; + }); + } } } } else if self.is_graceful_shutdown() { @@ -1355,6 +1459,20 @@ impl Agent { let _ = event_tx.send(event); } } + + // PLUGIN_EVENT: TurnEnd - fire-and-forget at end of each turn + if let Some(system) = crate::plugin::plugin_system() { + let session_id = self.session.id.clone(); + let duration_ms = turn_start.elapsed().as_millis() as u64; + let input = EventInput::TurnEnd { + session_id, + turn_number: 0, + duration_ms, + }; + tokio::spawn(async move { + let _ = system.dispatch_event(PluginEvent::TurnEnd, input, None).await; + }); + } } Ok(()) diff --git a/crates/jcode-app-core/src/lib.rs b/crates/jcode-app-core/src/lib.rs index 27d8ee45e..248395ade 100644 --- a/crates/jcode-app-core/src/lib.rs +++ b/crates/jcode-app-core/src/lib.rs @@ -39,6 +39,7 @@ pub mod network_retry; pub mod notifications; pub mod overnight; pub mod perf; +pub mod plugin; pub mod prompt_placeholders; pub mod prompt_templates; pub mod replay; diff --git a/crates/jcode-app-core/src/plugin.rs b/crates/jcode-app-core/src/plugin.rs new file mode 100644 index 000000000..463caecf0 --- /dev/null +++ b/crates/jcode-app-core/src/plugin.rs @@ -0,0 +1,88 @@ +use jcode_plugin_core::events::{EventInput, HandlerAction}; +use jcode_plugin_core::PluginEvent; +pub use jcode_plugin_runtime::{check_kill_switches, is_force_deny, PluginSystem, DISABLE_ALL_PLUGINS, FORCE_DENY, SKIP_HOOKS}; +use std::sync::atomic::Ordering; +use std::sync::OnceLock; + +static PLUGIN_SYSTEM: OnceLock = OnceLock::new(); + +pub async fn init_plugins(config: &crate::config::PluginConfig) { + if PLUGIN_SYSTEM.get().is_some() { + return; + } + crate::logging::info("Initializing plugin system"); + match PluginSystem::initialize(config).await { + Ok(system) => { + crate::logging::info("Plugin system initialized successfully"); + let _ = PLUGIN_SYSTEM.set(system); + } + Err(e) => { + crate::logging::warn(&format!("Plugin system initialization failed: {e}")); + } + } +} + +pub fn plugin_system() -> Option<&'static PluginSystem> { + PLUGIN_SYSTEM.get() +} + +pub fn plugin_count() -> usize { + plugin_system() + .map(|sys| sys.dispatcher.plugin_count()) + .unwrap_or(0) +} + +pub enum PermissionVerdict { + Allow, + Deny, + Defer, +} + +pub async fn check_permission(action: &str, args: &serde_json::Value) -> PermissionVerdict { + if DISABLE_ALL_PLUGINS.load(Ordering::SeqCst) { + return PermissionVerdict::Defer; + } + + if is_force_deny() { + return PermissionVerdict::Deny; + } + + let sys = match PLUGIN_SYSTEM.get() { + Some(s) => s, + None => return PermissionVerdict::Defer, + }; + + if SKIP_HOOKS.load(Ordering::SeqCst) { + return PermissionVerdict::Defer; + } + + let tool_name = args + .get("tool") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let target = args + .get("target") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let event = PluginEvent::PermissionRequest; + let input = EventInput::PermissionRequest { + action: action.to_string(), + tool_name, + target, + session_id: String::new(), + }; + + let results = sys.dispatch_event(event, input, None).await; + + for (_id, result) in &results { + match &result.action { + HandlerAction::Deny => return PermissionVerdict::Deny, + HandlerAction::Allow => return PermissionVerdict::Allow, + _ => continue, + } + } + + PermissionVerdict::Defer +} diff --git a/crates/jcode-app-core/src/server.rs b/crates/jcode-app-core/src/server.rs index 5d2100942..fb1f49f65 100644 --- a/crates/jcode-app-core/src/server.rs +++ b/crates/jcode-app-core/src/server.rs @@ -927,6 +927,8 @@ impl Server { // Persist auxiliary discovery metadata after the server is already live. self.spawn_registry_metadata_publisher(registry_info); + crate::plugin::init_plugins(&crate::config::config().plugins).await; + // Spawn WebSocket gateway for iOS/web clients (if enabled) let _gateway_handle = self.spawn_gateway(runtime); diff --git a/crates/jcode-app-core/src/tool/mod.rs b/crates/jcode-app-core/src/tool/mod.rs index 5149e3910..0d98e1400 100644 --- a/crates/jcode-app-core/src/tool/mod.rs +++ b/crates/jcode-app-core/src/tool/mod.rs @@ -627,6 +627,61 @@ impl Registry { let tool = match tools.get(resolved_name) { Some(tool) => tool.clone(), None => { + // Plugin tool dispatch: route plugin_ prefixed tools through the plugin system + if resolved_name.starts_with("plugin_") { + if let Some(system) = crate::plugin::plugin_system() { + drop(tools); + crate::logging::event_info( + "TOOL_LIFECYCLE", + Self::tool_lifecycle_fields( + "start", name, resolved_name, &input, &ctx, + ), + ); + let started_at = std::time::Instant::now(); + match system.execute_tool(resolved_name, &input).await { + Ok(output_text) => { + let latency_ms = started_at.elapsed().as_millis() as u64; + crate::telemetry::record_tool_execution( + resolved_name, &input, true, latency_ms, + ); + let output = ToolOutput::new(output_text); + let output = self.guard_context_overflow(name, output).await; + let mut fields = Self::tool_lifecycle_fields( + "done", name, resolved_name, &input, &ctx, + ); + fields.push(("elapsed_ms".to_string(), latency_ms.to_string())); + fields.push(( + "output_bytes".to_string(), + output.output.len().to_string(), + )); + fields.push(( + "output_chars".to_string(), + output.output.chars().count().to_string(), + )); + fields.push(( + "image_count".to_string(), + output.images.len().to_string(), + )); + crate::logging::event_info("TOOL_LIFECYCLE", fields); + return Ok(output); + } + Err(e) => { + let latency_ms = started_at.elapsed().as_millis() as u64; + crate::telemetry::record_tool_execution( + resolved_name, &input, false, latency_ms, + ); + let mut fields = Self::tool_lifecycle_fields( + "error", name, resolved_name, &input, &ctx, + ); + fields.push(("elapsed_ms".to_string(), latency_ms.to_string())); + fields.push(("error".to_string(), e.clone())); + crate::logging::event_warn("TOOL_LIFECYCLE", fields); + return Err(anyhow::anyhow!("Plugin tool error: {e}")); + } + } + } + } + // List available tools so the model can recover instead of // spiraling through hallucinated names like "ToolSearch" (#104). let mut available: Vec<&str> = tools.keys().map(|k| k.as_str()).collect(); diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index 9a5ff0cad..ec11b6d4f 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -131,6 +131,7 @@ jcode-core = { path = "../jcode-core" } jcode-memory-types = { path = "../jcode-memory-types" } jcode-message-types = { path = "../jcode-message-types" } jcode-overnight-core = { path = "../jcode-overnight-core" } +jcode-plugin-core = { path = "../jcode-plugin-core" } jcode-plan = { path = "../jcode-plan" } jcode-swarm-core = { path = "../jcode-swarm-core" } jcode-protocol = { path = "../jcode-protocol" } diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index f79e56846..6c2b98823 100644 --- a/crates/jcode-base/src/config.rs +++ b/crates/jcode-base/src/config.rs @@ -12,6 +12,7 @@ pub use jcode_config_types::{ ReasoningDisplayMode, SafetyConfig, SessionPickerResumeAction, SwarmSpawnMode, TerminalConfig, UpdateChannel, WebSearchConfig, WebSearchEngine, }; +pub use jcode_plugin_core::PluginConfig; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::hash::{Hash, Hasher}; @@ -444,6 +445,9 @@ pub struct Config { /// Terminal / shell execution configuration (issue #260) pub terminal: TerminalConfig, + + /// Plugin system configuration + pub plugins: PluginConfig, } /// Agent Client Protocol adapter configuration. diff --git a/crates/jcode-plugin-core/Cargo.toml b/crates/jcode-plugin-core/Cargo.toml new file mode 100644 index 000000000..f5015299c --- /dev/null +++ b/crates/jcode-plugin-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "jcode-plugin-core" +version = "0.1.0" +edition = "2024" +description = "Core types, manifest, security, and event definitions for the jcode plugin system" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +semver = { version = "1.0", features = ["serde"] } +thiserror = "2" +regex = "1" diff --git a/crates/jcode-plugin-core/src/config.rs b/crates/jcode-plugin-core/src/config.rs new file mode 100644 index 000000000..02d21099b --- /dev/null +++ b/crates/jcode-plugin-core/src/config.rs @@ -0,0 +1,110 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginConfig { + #[serde(default)] + pub enable: Vec, + #[serde(default)] + pub disable: Vec, + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub fail_closed: Option, + #[serde(default)] + pub sources: Option>, + #[serde(default)] + pub settings: HashMap>, + #[serde(default)] + pub features: HashMap>, + #[serde(default)] + pub plugins: HashMap, + #[serde(default)] + pub skip_hooks: bool, + #[serde(default)] + pub force_deny: bool, +} + +impl PluginConfig { + pub fn apply_env_overrides(&mut self) { + if let Ok(val) = std::env::var("JCODE_DISABLE_PLUGINS") { + if val == "1" || val.eq_ignore_ascii_case("true") { + self.mode = Some("none".to_string()); + } + } + if let Ok(val) = std::env::var("JCODE_SKIP_PLUGINS") { + if val == "1" || val.eq_ignore_ascii_case("true") { + self.skip_hooks = true; + } + } + if let Ok(val) = std::env::var("JCODE_PLUGIN_MODE") { + self.mode = Some(val); + } + if let Ok(val) = std::env::var("JCODE_TEAM_WORKER") { + if val == "1" || val.eq_ignore_ascii_case("true") { + self.force_deny = true; + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum PluginSource { + #[serde(rename = "npm")] + Npm { + package: String, + #[serde(default)] + version: Option, + }, + #[serde(rename = "file")] + File { + path: String, + }, + #[serde(rename = "directory")] + Directory { + path: String, + }, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginPerPluginConfig { + #[serde(default)] + pub enable: Option, + #[serde(default)] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone)] +pub struct DiscoveryPaths { + pub plugin_dirs: Vec, + pub npm_cache: PathBuf, + pub tool_dirs: Vec, +} + +impl Default for DiscoveryPaths { + fn default() -> Self { + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")); + let jcode_dir = home.join(".jcode"); + Self { + plugin_dirs: vec![jcode_dir.join("plugins")], + npm_cache: jcode_dir.join("cache").join("packages"), + tool_dirs: vec![jcode_dir.join("tools")], + } + } +} + +/// Check if a package name is valid +pub fn is_valid_package_name(name: &str) -> bool { + let re = regex::Regex::new(r"^@?[a-z0-9][a-z0-9._-]*/?[a-z0-9][a-z0-9._-]*$").unwrap(); + re.is_match(name) && !name.contains("..") && !name.contains(';') && !name.contains('|') +} + +/// Sanitize a package name for filesystem use +pub fn sanitize_name(name: &str) -> String { + name.replace('/', "__").replace('@', "") +} diff --git a/crates/jcode-plugin-core/src/errors.rs b/crates/jcode-plugin-core/src/errors.rs new file mode 100644 index 000000000..0f6f16e29 --- /dev/null +++ b/crates/jcode-plugin-core/src/errors.rs @@ -0,0 +1,43 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum PluginError { + #[error("Plugin manifest is invalid: {0}")] + InvalidManifest(String), + + #[error("Plugin not found: {0}")] + NotFound(String), + + #[error("Failed to load plugin: {0}")] + Load(String), + + #[error("Plugin runtime error: {0}")] + Runtime(String), + + #[error("QuickJS evaluation error: {0}")] + Eval(String), + + #[error("QuickJS runtime error: {0}")] + QuickJs(String), + + #[error("SWC transpilation error: {0}")] + Transpile(String), + + #[error("Plugin operation timed out after {0:?}")] + Timeout(std::time::Duration), + + #[error("Capability denied: {0}")] + Capability(String), + + #[error("npm error: {0}")] + Npm(String), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Serde error: {0}")] + Serde(#[from] serde_json::Error), + + #[error("{0}")] + Other(String), +} diff --git a/crates/jcode-plugin-core/src/events.rs b/crates/jcode-plugin-core/src/events.rs new file mode 100644 index 000000000..3f1f0cb44 --- /dev/null +++ b/crates/jcode-plugin-core/src/events.rs @@ -0,0 +1,192 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(u32)] +pub enum PluginEvent { + #[serde(rename = "PreToolUse")] + PreToolUse = 0, + #[serde(rename = "PostToolUse")] + PostToolUse = 1, + #[serde(rename = "PostToolUseFailure")] + PostToolUseFailure = 2, + #[serde(rename = "ToolExecutionStart")] + ToolExecutionStart = 3, + #[serde(rename = "ToolExecutionEnd")] + ToolExecutionEnd = 4, + #[serde(rename = "SessionStart")] + SessionStart = 5, + #[serde(rename = "SessionEnd")] + SessionEnd = 6, + #[serde(rename = "SessionSwitch")] + SessionSwitch = 7, + #[serde(rename = "SessionCompact")] + SessionCompact = 8, + #[serde(rename = "SessionBeforeCompact")] + SessionBeforeCompact = 9, + #[serde(rename = "SessionShutdown")] + SessionShutdown = 10, + #[serde(rename = "PermissionRequest")] + PermissionRequest = 12, + #[serde(rename = "PermissionDenied")] + PermissionDenied = 13, + #[serde(rename = "AgentStart")] + AgentStart = 14, + #[serde(rename = "AgentEnd")] + AgentEnd = 15, + #[serde(rename = "TurnStart")] + TurnStart = 16, + #[serde(rename = "TurnEnd")] + TurnEnd = 17, + #[serde(rename = "MessageStart")] + MessageStart = 18, + #[serde(rename = "MessageEnd")] + MessageEnd = 19, + #[serde(rename = "PreCompact")] + PreCompact = 20, + #[serde(rename = "PostCompact")] + PostCompact = 21, + #[serde(rename = "TaskCreated")] + TaskCreated = 22, + #[serde(rename = "TaskCompleted")] + TaskCompleted = 23, + #[serde(rename = "AutoCompactionStart")] + AutoCompactionStart = 24, + #[serde(rename = "UserPromptSubmit")] + UserPromptSubmit = 25, + #[serde(rename = "Stop")] + Stop = 26, + #[serde(rename = "Notification")] + Notification = 27, +} + +impl PluginEvent { + /// Total number of event variants. + /// Note: discriminant 11 is intentionally skipped (reserved for future use). + pub const COUNT: u32 = 27; + + /// All event variants + pub fn all() -> Vec { + use PluginEvent::*; + vec![ + PreToolUse, PostToolUse, PostToolUseFailure, + ToolExecutionStart, ToolExecutionEnd, + SessionStart, SessionEnd, SessionSwitch, + SessionCompact, SessionBeforeCompact, SessionShutdown, + PermissionRequest, PermissionDenied, + AgentStart, AgentEnd, TurnStart, TurnEnd, + MessageStart, MessageEnd, + PreCompact, PostCompact, + TaskCreated, TaskCompleted, AutoCompactionStart, + UserPromptSubmit, Stop, Notification, + ] + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum EventInput { + #[serde(rename = "PreToolUse")] + PreToolUse { tool_name: String, tool_input: serde_json::Value, session_id: String }, + #[serde(rename = "PostToolUse")] + PostToolUse { tool_name: String, tool_input: serde_json::Value, tool_output: serde_json::Value, duration_ms: u64, success: bool, session_id: String }, + #[serde(rename = "PostToolUseFailure")] + PostToolUseFailure { tool_name: String, tool_input: serde_json::Value, error: String, duration_ms: u64, session_id: String }, + #[serde(rename = "SessionStart")] + SessionStart { session_id: String, project_dir: String, model: String, provider: String }, + #[serde(rename = "SessionEnd")] + SessionEnd { session_id: String, duration_seconds: u64, message_count: u64 }, + #[serde(rename = "PermissionRequest")] + PermissionRequest { action: String, tool_name: Option, target: Option, session_id: String }, + #[serde(rename = "AgentStart")] + AgentStart { session_id: String, system_prompt: serde_json::Value, tools: serde_json::Value }, + #[serde(rename = "TurnStart")] + TurnStart { session_id: String, turn_number: u32, messages: serde_json::Value }, + #[serde(rename = "UserPromptSubmit")] + UserPromptSubmit { content: String, session_id: String }, + #[serde(rename = "PreCompact")] + PreCompact { session_id: String, message_count: u32, token_count: u64, system_prompt: serde_json::Value }, + #[serde(rename = "PostCompact")] + PostCompact { session_id: String, messages_removed: u32, tokens_saved: u64 }, + #[serde(rename = "Stop")] + Stop { session_id: String, reason: String }, + #[serde(rename = "Notification")] + Notification { level: String, message: String, session_id: Option }, + #[serde(rename = "ToolExecutionStart")] + ToolExecutionStart { tool_name: String, tool_input: serde_json::Value, session_id: String }, + #[serde(rename = "ToolExecutionEnd")] + ToolExecutionEnd { tool_name: String, tool_output: serde_json::Value, duration_ms: u64, session_id: String }, + #[serde(rename = "AgentEnd")] + AgentEnd { session_id: String, duration_seconds: u64, message_count: u64 }, + #[serde(rename = "SessionSwitch")] + SessionSwitch { session_id: String, target_session_id: String }, + #[serde(rename = "SessionCompact")] + SessionCompact { session_id: String, reason: String }, + #[serde(rename = "TurnEnd")] + TurnEnd { session_id: String, turn_number: u32, duration_ms: u64 }, + #[serde(rename = "MessageStart")] + MessageStart { session_id: String, role: String }, + #[serde(rename = "MessageEnd")] + MessageEnd { session_id: String, role: String, content: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum EventOutput { + #[serde(rename = "PreToolUse")] + PreToolUse { block: Option, modified_input: Option }, + #[serde(rename = "PostToolUse")] + PostToolUse { modified_output: Option }, + #[serde(rename = "PermissionRequest")] + PermissionRequest { decision: Option, message: Option }, + #[serde(rename = "AgentStart")] + AgentStart { additional_system_prompt: Vec }, + #[serde(rename = "PreCompact")] + PreCompact { system_prompt: Option, instructions: Option, prevent: bool }, + #[serde(rename = "UserPromptSubmit")] + UserPromptSubmit { modified_prompt: Option }, + #[serde(rename = "Notification")] + Notification { suppress: Option, modified_message: Option }, + #[serde(rename = "Stop")] + Stop { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PermissionDecision { + #[serde(rename = "allow")] + Allow, + #[serde(rename = "deny")] + Deny, + #[serde(rename = "ask")] + Ask, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HandlerResult { + #[serde(default)] + pub action: HandlerAction, + #[serde(default)] + pub output: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub enum HandlerAction { + #[default] + #[serde(rename = "continue")] + Continue, + #[serde(rename = "block")] + Block(String), + #[serde(rename = "allow")] + Allow, + #[serde(rename = "deny")] + Deny, + #[serde(rename = "error")] + Error, +} + +impl Default for HandlerResult { + fn default() -> Self { + Self { action: HandlerAction::Continue, output: None, error: None } + } +} diff --git a/crates/jcode-plugin-core/src/lib.rs b/crates/jcode-plugin-core/src/lib.rs new file mode 100644 index 000000000..ba4e88fe5 --- /dev/null +++ b/crates/jcode-plugin-core/src/lib.rs @@ -0,0 +1,19 @@ +pub mod errors; +pub mod preflight; +pub mod types; +pub mod manifest; +pub mod security; +pub mod config; +pub mod events; +pub mod serde; + +pub use errors::PluginError; +pub use types::{PluginId, PluginVersion, PluginState, PluginOrigin}; +pub use manifest::{PluginManifest, PluginKind, PluginEntry, PluginCapabilities, PluginFeature, SettingSchema, PluginEngines}; +pub use security::{CapabilityChain, CapabilitySet, AccessDefault, AccessMode, CapabilityAction, AccessDecision}; +pub use config::{PluginConfig, PluginSource, DiscoveryPaths, is_valid_package_name, sanitize_name}; +pub use events::{PluginEvent, EventInput, EventOutput, HandlerResult, HandlerAction, PermissionDecision}; +pub use preflight::{PreflightAnalyzer, PreflightResult, StaticAnalysis}; + +#[cfg(test)] +mod tests; diff --git a/crates/jcode-plugin-core/src/manifest.rs b/crates/jcode-plugin-core/src/manifest.rs new file mode 100644 index 000000000..1f8da99df --- /dev/null +++ b/crates/jcode-plugin-core/src/manifest.rs @@ -0,0 +1,210 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::errors::PluginError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginManifest { + pub name: String, + pub package_name: String, + pub version: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub author: Option, + #[serde(default)] + pub license: Option, + #[serde(default)] + pub kind: PluginKind, + #[serde(default)] + pub entry: PluginEntry, + #[serde(default)] + pub capabilities: PluginCapabilities, + #[serde(default)] + pub features: HashMap, + #[serde(default)] + pub settings: HashMap, + #[serde(default)] + pub engines: PluginEngines, + #[serde(default)] + pub icon: Option, + #[serde(default)] + pub homepage: Option, + #[serde(default)] + pub repository: Option, + #[serde(default)] + pub tags: Vec, +} + +impl Default for PluginManifest { + fn default() -> Self { + Self { + name: String::new(), + package_name: String::new(), + version: "0.1.0".into(), + description: None, + author: None, + license: None, + kind: PluginKind::Server, + entry: PluginEntry::default(), + capabilities: PluginCapabilities::default(), + features: HashMap::new(), + settings: HashMap::new(), + engines: PluginEngines::default(), + icon: None, + homepage: None, + repository: None, + tags: Vec::new(), + } + } +} + +impl PluginManifest { + pub fn from_package_json(value: &serde_json::Value) -> Result { + let section = value.get("jcode") + .or_else(|| value.get("pi")) + .ok_or_else(|| PluginError::InvalidManifest("missing 'jcode' or 'pi' field".into()))?; + serde_json::from_value(section.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string())) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PluginKind { + #[serde(rename = "server")] + Server, + #[serde(rename = "tui")] + Tui, + #[serde(rename = "both")] + Both, +} + +impl Default for PluginKind { + fn default() -> Self { Self::Server } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginEntry { + #[serde(default)] + pub server: Option, + #[serde(default)] + pub tui: Option, + #[serde(default)] + pub both: Option, +} + +impl Default for PluginEntry { + fn default() -> Self { + Self { server: None, tui: None, both: None } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginCapabilities { + #[serde(default)] + pub fs_read: Vec, + #[serde(default)] + pub fs_write: Vec, + #[serde(default)] + pub network: Vec, + #[serde(default)] + pub shell: bool, + #[serde(default)] + pub register_tools: bool, + #[serde(default)] + pub register_commands: bool, + #[serde(default)] + pub register_providers: bool, + #[serde(default)] + pub read_config: bool, + #[serde(default)] + pub write_config: bool, + #[serde(default)] + pub env_vars: Vec, + #[serde(default)] + pub events: Vec, + #[serde(default)] + pub llm_access: bool, + #[serde(default)] + pub session_access: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginFeature { + pub description: String, + #[serde(default)] + pub default: bool, + #[serde(default)] + pub entry: Option, + #[serde(default)] + pub additional_capabilities: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum SettingSchema { + #[serde(rename = "string")] + String { + description: String, + #[serde(default)] + default: Option, + #[serde(default)] + secret: bool, + #[serde(default)] + env: Option, + #[serde(default)] + pattern: Option, + #[serde(default)] + max_length: Option, + }, + #[serde(rename = "number")] + Number { + description: String, + #[serde(default)] + default: Option, + #[serde(default)] + min: Option, + #[serde(default)] + max: Option, + }, + #[serde(rename = "boolean")] + Boolean { + description: String, + #[serde(default)] + default: Option, + }, + #[serde(rename = "enum")] + Enum { + description: String, + #[serde(default)] + default: Option, + values: Vec, + }, + #[serde(rename = "array")] + Array { + description: String, + #[serde(default)] + default: Option>, + items: Box, + #[serde(default)] + max_items: Option, + }, + #[serde(rename = "object")] + Object { + description: String, + #[serde(default)] + default: Option, + #[serde(default)] + properties: HashMap, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginEngines { + #[serde(default)] + pub jcode: Option, +} + +impl Default for PluginEngines { + fn default() -> Self { Self { jcode: None } } +} diff --git a/crates/jcode-plugin-core/src/preflight.rs b/crates/jcode-plugin-core/src/preflight.rs new file mode 100644 index 000000000..3f2d6ec09 --- /dev/null +++ b/crates/jcode-plugin-core/src/preflight.rs @@ -0,0 +1,265 @@ +//! Preflight static analysis for plugin code. +//! +//! Runs before a plugin's code is evaluated in the QuickJS sandbox. +//! Detects suspicious patterns, undeclared capabilities, and dangerous +//! constructs. Warnings are logged; blocks prevent loading entirely. + +use crate::manifest::PluginCapabilities; + +/// Result of preflight static analysis. +#[derive(Debug, Clone)] +pub struct PreflightResult { + /// Whether the plugin passed all checks (no blocks). + pub passed: bool, + /// Non-fatal warnings (logged but plugin still loads). + pub warnings: Vec, + /// Fatal blocks (prevent loading). + pub blocks: Vec, + /// Capabilities declared in the plugin manifest. + pub declared_capabilities: PluginCapabilities, + /// Patterns detected during analysis. + pub detected_patterns: Vec, + /// Detailed static analysis breakdown. + pub static_analysis: StaticAnalysis, +} + +/// Detailed static analysis of plugin code. +#[derive(Debug, Clone)] +pub struct StaticAnalysis { + /// Code uses `eval()`. + pub has_eval: bool, + /// Code uses dynamic `import()`. + pub has_dynamic_import: bool, + /// Code uses `fetch()`. + pub has_fetch: bool, + /// Code references `process.*`. + pub has_process_access: bool, + /// Detected filesystem access patterns. + pub has_fs_access: Vec, + /// Detected network access patterns. + pub has_network_access: Vec, + /// Suspicious string literals found. + pub suspicious_strings: Vec, +} + +/// Preflight static analyzer for plugin code. +pub struct PreflightAnalyzer; + +impl PreflightAnalyzer { + /// Analyze plugin code before first execution. + /// + /// Checks for: + /// - Dangerous constructs (eval, Function constructor) + /// - Undeclared capability usage (fetch without network capability) + /// - Suspicious patterns (rm -rf, sudo, chmod 777, etc.) + /// - Access to unavailable globals (process, require) + pub fn analyze(code: &str, declared: &PluginCapabilities) -> PreflightResult { + let mut warnings = Vec::new(); + let mut blocks = Vec::new(); + let mut detected = Vec::new(); + + // Dangerous constructs + if code.contains("eval(") { + warnings.push("Code uses eval()".into()); + detected.push("eval".into()); + } + if code.contains("new Function(") { + warnings.push("Uses Function constructor".into()); + detected.push("new Function".into()); + } + if code.contains("process.") { + warnings.push("References 'process' (not available in sandbox)".into()); + detected.push("process".into()); + } + if code.contains("require(") { + warnings.push("Uses require() — use ES import syntax".into()); + detected.push("require".into()); + } + + // Network capability checks + let has_fetch = code.contains("fetch(") || code.contains("XMLHttpRequest"); + if has_fetch && declared.network.is_empty() { + warnings.push("fetch()/XMLHttpRequest used but no network capability declared".into()); + } + if has_fetch { + detected.push("fetch".into()); + } + + // Filesystem access pattern detection + let fs_patterns = ["fs.read", "fs.write", "readFile", "writeFile", "readText", "writeText"]; + let fs_detected: Vec = fs_patterns + .iter() + .filter(|p| code.contains(*p)) + .map(|p| p.to_string()) + .collect(); + if !fs_detected.is_empty() { + detected.extend(fs_detected.clone()); + } + + // Network access pattern detection + let net_patterns = ["fetch(", "XMLHttpRequest", "WebSocket", "http.get", "https.get"]; + let net_detected: Vec = net_patterns + .iter() + .filter(|p| code.contains(*p)) + .map(|p| p.to_string()) + .collect(); + + // Suspicious patterns — these are blockers + let suspicious = ["rm -rf", "sudo ", "chmod 777", "> /dev/sda", "rm -rf /", "mkfs.", "dd if="]; + let found: Vec = suspicious + .iter() + .filter(|s| code.contains(*s)) + .map(|s| s.trim().to_string()) + .collect(); + detected.extend(found.clone()); + if !found.is_empty() { + blocks.push(format!("Suspicious patterns: {}", found.join(", "))); + } + + // Check for undeclared shell access + if (code.contains("exec(") || code.contains("spawn(") || code.contains("child_process")) + && !declared.shell + { + warnings.push("Code appears to use shell/command execution but shell capability not declared".into()); + detected.push("shell_exec".into()); + } + + PreflightResult { + passed: blocks.is_empty(), + warnings, + blocks, + declared_capabilities: declared.clone(), + detected_patterns: detected, + static_analysis: StaticAnalysis { + has_eval: code.contains("eval("), + has_dynamic_import: code.contains("import("), + has_fetch: code.contains("fetch("), + has_process_access: code.contains("process."), + has_fs_access: fs_detected, + has_network_access: net_detected, + suspicious_strings: found, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_caps() -> PluginCapabilities { + PluginCapabilities::default() + } + + #[test] + fn clean_code_passes() { + let code = r#"pi.on("TurnStart", (e) => { console.log(e); });"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(result.passed); + assert!(result.warnings.is_empty()); + assert!(result.blocks.is_empty()); + } + + #[test] + fn detects_eval() { + let code = r#"eval("console.log('hi')");"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(result.passed); // eval is a warning, not a block + assert!(result.static_analysis.has_eval); + assert!(result.warnings.iter().any(|w| w.contains("eval()"))); + } + + #[test] + fn detects_suspicious_patterns() { + let code = r#"const x = "rm -rf /";"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(!result.passed); + assert!(!result.blocks.is_empty()); + assert!(result.static_analysis.suspicious_strings.contains(&"rm -rf".to_string())); + } + + #[test] + fn detects_sudo() { + let code = r#"exec("sudo apt install something");"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(!result.passed); + assert!(result.blocks.iter().any(|b| b.contains("sudo"))); + } + + #[test] + fn detects_fetch_without_network_capability() { + let code = r#"fetch("https://example.com/api");"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(result.warnings.iter().any(|w| w.contains("network capability"))); + } + + #[test] + fn fetch_ok_with_network_capability() { + let mut caps = default_caps(); + caps.network = vec!["example.com".to_string()]; + let code = r#"fetch("https://example.com/api");"#; + let result = PreflightAnalyzer::analyze(code, &caps); + assert!(!result.warnings.iter().any(|w| w.contains("network capability"))); + } + + #[test] + fn detects_process_access() { + let code = r#"const env = process.env;"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(result.static_analysis.has_process_access); + assert!(result.warnings.iter().any(|w| w.contains("process"))); + } + + #[test] + fn detects_require() { + let code = r#"const fs = require("fs");"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(result.warnings.iter().any(|w| w.contains("require"))); + } + + #[test] + fn detects_function_constructor() { + let code = r#"new Function("return 1")();"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(result.warnings.iter().any(|w| w.contains("Function constructor"))); + } + + #[test] + fn detects_dynamic_import() { + let code = r#"import("some-module");"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(result.static_analysis.has_dynamic_import); + } + + #[test] + fn detects_multiple_suspicious() { + let code = r#"exec("rm -rf /"); exec("sudo chmod 777 /");"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(!result.passed); + assert!(result.static_analysis.suspicious_strings.len() >= 2); + } + + #[test] + fn detects_shell_without_capability() { + let code = r#"exec("ls -la");"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(result.warnings.iter().any(|w| w.contains("shell"))); + } + + #[test] + fn shell_ok_with_capability() { + let mut caps = default_caps(); + caps.shell = true; + let code = r#"exec("ls -la");"#; + let result = PreflightAnalyzer::analyze(code, &caps); + assert!(!result.warnings.iter().any(|w| w.contains("shell"))); + } + + #[test] + fn detected_patterns_populated() { + let code = r#"eval("x"); fetch("url");"#; + let result = PreflightAnalyzer::analyze(code, &default_caps()); + assert!(result.detected_patterns.contains(&"eval".to_string())); + assert!(result.detected_patterns.contains(&"fetch".to_string())); + } +} diff --git a/crates/jcode-plugin-core/src/security.rs b/crates/jcode-plugin-core/src/security.rs new file mode 100644 index 000000000..c020eddb7 --- /dev/null +++ b/crates/jcode-plugin-core/src/security.rs @@ -0,0 +1,167 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapabilityChain { + pub deny_list: CapabilitySet, + pub global_deny: CapabilitySet, + pub allow_list: CapabilitySet, + pub global_default: AccessDefault, + pub mode: AccessMode, +} + +impl Default for CapabilityChain { + fn default() -> Self { + Self { + deny_list: CapabilitySet::default(), + global_deny: CapabilitySet::default(), + allow_list: CapabilitySet::default(), + global_default: AccessDefault::Deny, + mode: AccessMode::All, + } + } +} + +impl CapabilityChain { + /// Check if a resource access is allowed. Returns AccessDecision. + /// Evaluation order: mode -> deny_list -> global_deny -> allow_list -> global_default + pub fn check(&self, resource: &str, action: &CapabilityAction) -> AccessDecision { + // Mode check + match self.mode { + AccessMode::None => return AccessDecision::Denied("Plugin mode is 'none'".into()), + AccessMode::All => { /* allow further checks */ } + AccessMode::Trusted => { /* trusted mode - only explicit deny blocks */ } + AccessMode::Interactive => { /* interactive mode needs approval */ } + } + + // 1. Deny list (plugin-specific) + if self.deny_list.matches(resource, action) { + return AccessDecision::Denied("Denied by plugin deny list".into()); + } + + // 2. Global deny + if self.global_deny.matches(resource, action) { + return AccessDecision::Denied("Denied by global policy".into()); + } + + // 3. Allow list (plugin-specific) + if self.allow_list.matches(resource, action) { + return AccessDecision::Allowed("Allowed by plugin allow list".into()); + } + + // 4. Global default + match self.global_default { + AccessDefault::Allow => AccessDecision::Allowed("Allowed by default".into()), + AccessDefault::Deny => AccessDecision::Denied("Denied by default".into()), + AccessDefault::Ask => AccessDecision::NeedsApproval("Requires user approval".into()), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CapabilitySet { + #[serde(default)] + pub fs_paths: Vec, + #[serde(default)] + pub hosts: Vec, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub env_vars: Vec, + #[serde(default)] + pub shell_commands: Vec, + #[serde(default)] + pub config_keys: Vec, + #[serde(default)] + pub providers: Vec, +} + +impl CapabilitySet { + pub fn matches(&self, resource: &str, _action: &CapabilityAction) -> bool { + self.tools.iter().any(|t| t == resource) + || self.hosts.iter().any(|h| host_matches(resource, h)) + || self.fs_paths.iter().any(|p| resource.starts_with(p.as_str())) + || self.env_vars.iter().any(|e| e == resource) + || self.shell_commands.iter().any(|c| c == resource) + || self.config_keys.iter().any(|k| k == resource) + || self.providers.iter().any(|p| p == resource) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum AccessDefault { + #[serde(rename = "deny")] + Deny, + #[serde(rename = "allow")] + Allow, + #[serde(rename = "ask")] + Ask, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum AccessMode { + #[serde(rename = "all")] + All, + #[serde(rename = "trusted")] + Trusted, + #[serde(rename = "none")] + None, + #[serde(rename = "interactive")] + Interactive, +} + +#[derive(Debug, Clone)] +pub enum CapabilityAction { + Read, + Write, + Execute, + Network, + Config, + Session, + Provider, +} + +impl std::fmt::Display for CapabilityAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read => write!(f, "read"), + Self::Write => write!(f, "write"), + Self::Execute => write!(f, "execute"), + Self::Network => write!(f, "network"), + Self::Config => write!(f, "config"), + Self::Session => write!(f, "session"), + Self::Provider => write!(f, "provider"), + } + } +} + +#[derive(Debug, Clone)] +pub enum AccessDecision { + Allowed(String), + Denied(String), + NeedsApproval(String), +} + +/// Check if a resource (URL or hostname) matches a host pattern. +/// Uses proper hostname matching instead of simple substring containment, +/// so "evil.com" won't accidentally match "notevil.com". +fn host_matches(resource: &str, pattern: &str) -> bool { + // Extract hostname from URL if the resource is a full URL + let host = if let Some(after_protocol) = resource.strip_prefix("http://").or_else(|| resource.strip_prefix("https://")) { + after_protocol.split('/').next().unwrap_or(after_protocol) + .split(':').next().unwrap_or(after_protocol) // strip port + } else { + resource + }; + + // Exact match + if host == pattern { + return true; + } + + // Subdomain match: "example.com" matches "sub.example.com" + if host.ends_with(&format!(".{pattern}")) { + return true; + } + + false +} diff --git a/crates/jcode-plugin-core/src/serde.rs b/crates/jcode-plugin-core/src/serde.rs new file mode 100644 index 000000000..53986c402 --- /dev/null +++ b/crates/jcode-plugin-core/src/serde.rs @@ -0,0 +1,16 @@ +use serde::Serialize; + +/// Serialize a value to a JSON string +pub fn to_json(value: &T) -> Result { + serde_json::to_string_pretty(value) +} + +/// Deserialize from a JSON string +pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) +} + +/// Serialize to a JSON Value +pub fn to_value(value: &T) -> Result { + serde_json::to_value(value) +} diff --git a/crates/jcode-plugin-core/src/tests.rs b/crates/jcode-plugin-core/src/tests.rs new file mode 100644 index 000000000..b78821d38 --- /dev/null +++ b/crates/jcode-plugin-core/src/tests.rs @@ -0,0 +1,1248 @@ +#[cfg(test)] +mod tests { + use crate::*; + use crate::serde; + + #[test] + fn plugin_id_npm_creates_correct_format() { + let id = PluginId::npm("my-plugin"); + assert_eq!(id.as_str(), "npm:my-plugin"); + } + + #[test] + fn plugin_id_file_creates_correct_format() { + let id = PluginId::file("/home/user/plugin.wasm"); + assert_eq!(id.as_str(), "file:/home/user/plugin.wasm"); + } + + #[test] + fn plugin_id_bundled_creates_correct_format() { + let id = PluginId::bundled("builtin-fs"); + assert_eq!(id.as_str(), "builtin:builtin-fs"); + } + + #[test] + fn plugin_id_display_matches_as_str() { + let id = PluginId::npm("hello"); + assert_eq!(format!("{id}"), "npm:hello"); + } + + #[test] + fn plugin_id_short_name_strips_prefix() { + let id = PluginId::npm("hello"); + assert_eq!(id.short_name(), "hello"); + let file = PluginId::file("/tmp/x.wasm"); + assert_eq!(file.short_name(), "/tmp/x.wasm"); + let bundled = PluginId::bundled("foo"); + assert_eq!(bundled.short_name(), "foo"); + } + + #[test] + fn plugin_id_short_name_no_prefix_returns_whole() { + let id = PluginId::from("raw".to_string()); + assert_eq!(id.short_name(), "raw"); + } + + #[test] + fn plugin_id_from_string() { + let id = PluginId::from("custom:test".to_string()); + assert_eq!(id.as_str(), "custom:test"); + } + + #[test] + fn plugin_id_to_string() { + let id = PluginId::npm("pkg"); + assert_eq!(id.to_string(), "npm:pkg"); + } + + #[test] + fn plugin_version_fields() { + let ver = PluginVersion { + semver: semver::Version::new(1, 2, 3), + jcode_min_version: Some(semver::Version::new(0, 9, 0)), + jcode_max_version: None, + }; + assert_eq!(ver.semver.to_string(), "1.2.3"); + assert_eq!(ver.jcode_min_version.unwrap().major, 0); + assert!(ver.jcode_max_version.is_none()); + } + + #[test] + fn plugin_state_variants() { + assert_eq!(PluginState::Discovered, PluginState::Discovered); + assert_eq!(PluginState::Loading, PluginState::Loading); + assert_eq!(PluginState::Loaded, PluginState::Loaded); + assert_eq!(PluginState::Active, PluginState::Active); + assert_eq!(PluginState::Disabled, PluginState::Disabled); + assert_eq!(PluginState::Blocked, PluginState::Blocked); + match PluginState::Error("msg".into()) { + PluginState::Error(msg) => assert_eq!(msg, "msg"), + _ => panic!("expected Error variant"), + } + } + + #[test] + fn plugin_origin_variants() { + let npm = PluginOrigin::NpmPackage { + name: "pkg".into(), + version: "1.0".into(), + }; + let file = PluginOrigin::LocalFile { + path: "/p.wasm".into(), + }; + let builtin = PluginOrigin::Builtin { + name: "core".into(), + }; + let remote = PluginOrigin::Remote { + url: "https://example.com".into(), + }; + assert_eq!( + format!("{npm:?}"), + r#"NpmPackage { name: "pkg", version: "1.0" }"# + ); + assert_eq!( + format!("{file:?}"), + r#"LocalFile { path: "/p.wasm" }"# + ); + assert_eq!(format!("{builtin:?}"), r#"Builtin { name: "core" }"#); + assert_eq!( + format!("{remote:?}"), + r#"Remote { url: "https://example.com" }"# + ); + } + + #[test] + fn plugin_id_serde_roundtrip() { + let id = PluginId::npm("test-plugin"); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"npm:test-plugin\""); + let deserialized: PluginId = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, id); + } + + #[test] + fn plugin_version_serde_roundtrip() { + let ver = PluginVersion { + semver: semver::Version::new(0, 5, 0), + jcode_min_version: None, + jcode_max_version: None, + }; + let json = serde_json::to_string(&ver).unwrap(); + let deserialized: PluginVersion = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.semver, ver.semver); + } + + #[test] + fn plugin_state_serde_roundtrip() { + for state in &[ + PluginState::Active, + PluginState::Disabled, + PluginState::Error("fail".into()), + ] { + let json = serde_json::to_string(state).unwrap(); + let deserialized: PluginState = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, *state); + } + } + + #[test] + fn plugin_origin_serde_roundtrip() { + let origin = PluginOrigin::NpmPackage { + name: "pkg".into(), + version: "1.0".into(), + }; + let json = serde_json::to_string(&origin).unwrap(); + let deserialized: PluginOrigin = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, origin); + } + + #[test] + fn capability_chain_default_is_deny() { + let chain = CapabilityChain::default(); + let result = chain.check("anything", &CapabilityAction::Read); + assert!(matches!(result, AccessDecision::Denied(_))); + } + + #[test] + fn capability_chain_allow_list_allows() { + let mut chain = CapabilityChain::default(); + chain.allow_list.tools.push("my_tool".into()); + let result = chain.check("my_tool", &CapabilityAction::Read); + assert!(matches!(result, AccessDecision::Allowed(_))); + } + + #[test] + fn capability_chain_deny_list_denies() { + let mut chain = CapabilityChain::default(); + chain.allow_list.tools.push("my_tool".into()); + chain.deny_list.tools.push("my_tool".into()); + let result = chain.check("my_tool", &CapabilityAction::Read); + assert!(matches!(result, AccessDecision::Denied(_))); + } + + #[test] + fn capability_chain_global_deny_denies() { + let mut chain = CapabilityChain::default(); + chain.global_deny.tools.push("my_tool".into()); + let result = chain.check("my_tool", &CapabilityAction::Read); + assert!(matches!(result, AccessDecision::Denied(_))); + } + + #[test] + fn capability_chain_global_allow_allows() { + let mut chain = CapabilityChain::default(); + chain.global_default = AccessDefault::Allow; + let result = chain.check("unknown", &CapabilityAction::Read); + assert!(matches!(result, AccessDecision::Allowed(_))); + } + + #[test] + fn capability_chain_global_ask_returns_needs_approval() { + let mut chain = CapabilityChain::default(); + chain.global_default = AccessDefault::Ask; + let result = chain.check("unknown", &CapabilityAction::Read); + assert!(matches!(result, AccessDecision::NeedsApproval(_))); + } + + #[test] + fn capability_chain_mode_none_denies_immediately() { + let mut chain = CapabilityChain::default(); + chain.mode = AccessMode::None; + let result = chain.check("anything", &CapabilityAction::Read); + assert!(matches!(result, AccessDecision::Denied(_))); + } + + #[test] + fn capability_set_matches_tools_exactly() { + let mut set = CapabilitySet::default(); + set.tools.push("read_file".into()); + assert!(set.matches("read_file", &CapabilityAction::Read)); + assert!(!set.matches("write_file", &CapabilityAction::Read)); + } + + #[test] + fn capability_set_matches_hosts_by_contains() { + let mut set = CapabilitySet::default(); + set.hosts.push("api.example.com".into()); + assert!(set.matches( + "https://api.example.com/v1", + &CapabilityAction::Network + )); + assert!(!set.matches("https://other.com", &CapabilityAction::Network)); + } + + #[test] + fn capability_set_matches_fs_paths_by_prefix() { + let mut set = CapabilitySet::default(); + set.fs_paths.push("/data".into()); + assert!(set.matches( + "/data/plugins/file.txt", + &CapabilityAction::Read + )); + assert!(!set.matches("/other/file.txt", &CapabilityAction::Read)); + } + + #[test] + fn capability_set_matches_env_vars_exactly() { + let mut set = CapabilitySet::default(); + set.env_vars.push("HOME".into()); + assert!(set.matches("HOME", &CapabilityAction::Read)); + assert!(!set.matches("PATH", &CapabilityAction::Read)); + } + + #[test] + fn capability_set_matches_shell_commands() { + let mut set = CapabilitySet::default(); + set.shell_commands.push("ls".into()); + assert!(set.matches("ls", &CapabilityAction::Execute)); + assert!(!set.matches("rm", &CapabilityAction::Execute)); + } + + #[test] + fn capability_set_matches_config_keys() { + let mut set = CapabilitySet::default(); + set.config_keys.push("theme".into()); + assert!(set.matches("theme", &CapabilityAction::Config)); + assert!(!set.matches("other", &CapabilityAction::Config)); + } + + #[test] + fn capability_set_matches_providers() { + let mut set = CapabilitySet::default(); + set.providers.push("openai".into()); + assert!(set.matches("openai", &CapabilityAction::Provider)); + assert!(!set.matches("anthropic", &CapabilityAction::Provider)); + } + + #[test] + fn access_decision_debug() { + let allowed = AccessDecision::Allowed("ok".into()); + let denied = AccessDecision::Denied("no".into()); + let ask = AccessDecision::NeedsApproval("maybe".into()); + assert_eq!(format!("{allowed:?}"), r#"Allowed("ok")"#); + assert_eq!(format!("{denied:?}"), r#"Denied("no")"#); + assert_eq!(format!("{ask:?}"), r#"NeedsApproval("maybe")"#); + } + + #[test] + fn capability_action_display() { + assert_eq!(CapabilityAction::Read.to_string(), "read"); + assert_eq!(CapabilityAction::Write.to_string(), "write"); + assert_eq!(CapabilityAction::Execute.to_string(), "execute"); + assert_eq!(CapabilityAction::Network.to_string(), "network"); + assert_eq!(CapabilityAction::Config.to_string(), "config"); + assert_eq!(CapabilityAction::Session.to_string(), "session"); + assert_eq!(CapabilityAction::Provider.to_string(), "provider"); + } + + #[test] + fn access_default_serde() { + let json = serde_json::to_string(&AccessDefault::Deny).unwrap(); + assert_eq!(json, "\"deny\""); + let deserialized: AccessDefault = serde_json::from_str("\"allow\"").unwrap(); + assert_eq!(deserialized, AccessDefault::Allow); + } + + #[test] + fn access_mode_serde() { + let json = serde_json::to_string(&AccessMode::Trusted).unwrap(); + assert_eq!(json, "\"trusted\""); + let deserialized: AccessMode = serde_json::from_str("\"interactive\"").unwrap(); + assert_eq!(deserialized, AccessMode::Interactive); + } + + #[test] + fn plugin_config_defaults_are_empty() { + let cfg = PluginConfig::default(); + assert!(cfg.enable.is_empty()); + assert!(cfg.disable.is_empty()); + assert!(cfg.mode.is_none()); + assert!(cfg.fail_closed.is_none()); + assert!(cfg.sources.is_none()); + assert!(cfg.settings.is_empty()); + assert!(cfg.features.is_empty()); + assert!(cfg.plugins.is_empty()); + assert!(!cfg.skip_hooks); + assert!(!cfg.force_deny); + } + + #[test] + fn plugin_config_serde_roundtrip() { + let cfg = PluginConfig::default(); + let json = serde_json::to_string(&cfg).unwrap(); + let deserialized: PluginConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.enable, cfg.enable); + assert_eq!(deserialized.mode, cfg.mode); + } + + #[test] + fn plugin_config_custom_fields_roundtrip() { + let mut cfg = PluginConfig::default(); + cfg.enable = vec!["my-plugin".into()]; + cfg.disable = vec!["bad-plugin".into()]; + cfg.mode = Some("strict".into()); + cfg.fail_closed = Some(true); + cfg.skip_hooks = true; + cfg.force_deny = true; + let json = serde_json::to_string_pretty(&cfg).unwrap(); + let deserialized: PluginConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.enable, vec!["my-plugin"]); + assert_eq!(deserialized.disable, vec!["bad-plugin"]); + assert_eq!(deserialized.mode, Some("strict".into())); + assert_eq!(deserialized.fail_closed, Some(true)); + assert!(deserialized.skip_hooks); + assert!(deserialized.force_deny); + } + + fn with_env_var(name: &str, value: &str, f: impl FnOnce()) { + // SAFETY: test-only env manipulation + unsafe { std::env::set_var(name, value) } + f(); + unsafe { std::env::remove_var(name) } + } + + #[test] + fn plugin_config_apply_env_overrides_disabled() { + with_env_var("JCODE_DISABLE_PLUGINS", "true", || { + let mut cfg = PluginConfig::default(); + cfg.apply_env_overrides(); + assert_eq!(cfg.mode, Some("none".into())); + }); + } + + #[test] + fn plugin_config_apply_env_overrides_skip() { + with_env_var("JCODE_SKIP_PLUGINS", "1", || { + let mut cfg = PluginConfig::default(); + cfg.apply_env_overrides(); + assert!(cfg.skip_hooks); + }); + } + + #[test] + fn plugin_config_apply_env_overrides_mode() { + with_env_var("JCODE_PLUGIN_MODE", "interactive", || { + let mut cfg = PluginConfig::default(); + cfg.apply_env_overrides(); + assert_eq!(cfg.mode, Some("interactive".into())); + }); + } + + #[test] + fn plugin_config_apply_env_overrides_team_worker() { + with_env_var("JCODE_TEAM_WORKER", "1", || { + let mut cfg = PluginConfig::default(); + cfg.apply_env_overrides(); + assert!(cfg.force_deny); + }); + } + + #[test] + fn plugin_source_npm_serde() { + let src = PluginSource::Npm { + package: "my-plugin".into(), + version: Some("1.0.0".into()), + }; + let json = serde_json::to_string(&src).unwrap(); + assert!(json.contains("\"npm\"")); + let deserialized: PluginSource = serde_json::from_str(&json).unwrap(); + match deserialized { + PluginSource::Npm { package, version } => { + assert_eq!(package, "my-plugin"); + assert_eq!(version, Some("1.0.0".into())); + } + _ => panic!("expected Npm variant"), + } + } + + #[test] + fn plugin_source_file_serde() { + let src = PluginSource::File { + path: "/tmp/plugin.wasm".into(), + }; + let json = serde_json::to_string(&src).unwrap(); + let deserialized: PluginSource = serde_json::from_str(&json).unwrap(); + match deserialized { + PluginSource::File { path } => assert_eq!(path, "/tmp/plugin.wasm"), + _ => panic!("expected File variant"), + } + } + + #[test] + fn plugin_source_directory_serde() { + let src = PluginSource::Directory { + path: "/plugins".into(), + }; + let json = serde_json::to_string(&src).unwrap(); + let deserialized: PluginSource = serde_json::from_str(&json).unwrap(); + match deserialized { + PluginSource::Directory { path } => assert_eq!(path, "/plugins"), + _ => panic!("expected Directory variant"), + } + } + + #[test] + fn is_valid_package_name_accepts_valid() { + assert!(is_valid_package_name("my-plugin")); + assert!(is_valid_package_name("@scope/pkg")); + assert!(is_valid_package_name("a0")); + } + + #[test] + fn is_valid_package_name_rejects_invalid() { + assert!(!is_valid_package_name("..")); + assert!(!is_valid_package_name("a;b")); + assert!(!is_valid_package_name("a|b")); + assert!(!is_valid_package_name("")); + } + + #[test] + fn sanitize_name_replaces_slashes_and_at_sign() { + assert_eq!(sanitize_name("@scope/pkg"), "scope__pkg"); + assert_eq!(sanitize_name("simple-name"), "simple-name"); + } + + #[test] + fn plugin_per_plugin_config_defaults() { + let pc = config::PluginPerPluginConfig::default(); + assert!(pc.enable.is_none()); + assert!(pc.timeout_ms.is_none()); + } + + #[test] + fn plugin_per_plugin_config_serde() { + let pc = config::PluginPerPluginConfig { + enable: Some(true), + timeout_ms: Some(5000), + }; + let json = serde_json::to_string(&pc).unwrap(); + let deserialized: config::PluginPerPluginConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.enable, Some(true)); + assert_eq!(deserialized.timeout_ms, Some(5000)); + } + + #[test] + fn plugin_event_all_returns_27_variants() { + let all = PluginEvent::all(); + assert_eq!(all.len(), 27); + } + + #[test] + fn plugin_event_count_matches_variants() { + assert_eq!(PluginEvent::COUNT, 27); + } + + #[test] + fn plugin_event_serde_all_variants() { + for event in PluginEvent::all() { + let json = serde_json::to_string(&event).unwrap(); + let deserialized: PluginEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, event); + } + } + + #[test] + fn plugin_event_discriminants() { + assert_eq!(PluginEvent::PreToolUse as u32, 0); + assert_eq!(PluginEvent::PostToolUse as u32, 1); + assert_eq!(PluginEvent::SessionStart as u32, 5); + assert_eq!(PluginEvent::Stop as u32, 26); + assert_eq!(PluginEvent::Notification as u32, 27); + } + + #[test] + fn plugin_event_serde_rename() { + let json = "\"PreToolUse\""; + let event: PluginEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event, PluginEvent::PreToolUse); + let json = "\"SessionEnd\""; + let event: PluginEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event, PluginEvent::SessionEnd); + } + + #[test] + fn event_input_pre_tool_use() { + let input = EventInput::PreToolUse { + tool_name: "read_file".into(), + tool_input: serde_json::json!({"path": "/tmp/test"}), + session_id: "sess_1".into(), + }; + let json = serde_json::to_string(&input).unwrap(); + assert!(json.contains("PreToolUse")); + let deserialized: EventInput = serde_json::from_str(&json).unwrap(); + match deserialized { + EventInput::PreToolUse { tool_name, .. } => assert_eq!(tool_name, "read_file"), + _ => panic!("expected PreToolUse"), + } + } + + #[test] + fn event_input_post_tool_use() { + let input = EventInput::PostToolUse { + tool_name: "write_file".into(), + tool_input: serde_json::json!({"content": "hi"}), + tool_output: serde_json::json!({"success": true}), + duration_ms: 100, + success: true, + session_id: "sess_1".into(), + }; + let json = serde_json::to_string(&input).unwrap(); + let deserialized: EventInput = serde_json::from_str(&json).unwrap(); + match deserialized { + EventInput::PostToolUse { + tool_name, success, .. + } => { + assert_eq!(tool_name, "write_file"); + assert!(success); + } + _ => panic!("expected PostToolUse"), + } + } + + #[test] + fn event_input_session_start() { + let input = EventInput::SessionStart { + session_id: "sess_1".into(), + project_dir: "/home/user/project".into(), + model: "claude-4".into(), + provider: "anthropic".into(), + }; + let json = serde_json::to_string(&input).unwrap(); + let deserialized: EventInput = serde_json::from_str(&json).unwrap(); + match deserialized { + EventInput::SessionStart { + model, provider, .. + } => { + assert_eq!(model, "claude-4"); + assert_eq!(provider, "anthropic"); + } + _ => panic!("expected SessionStart"), + } + } + + #[test] + fn event_input_stop() { + let input = EventInput::Stop { + session_id: "sess_1".into(), + reason: "user_request".into(), + }; + let json = serde_json::to_string(&input).unwrap(); + let deserialized: EventInput = serde_json::from_str(&json).unwrap(); + match deserialized { + EventInput::Stop { reason, .. } => assert_eq!(reason, "user_request"), + _ => panic!("expected Stop"), + } + } + + #[test] + fn event_input_notification() { + let input = EventInput::Notification { + level: "info".into(), + message: "hello".into(), + session_id: Some("sess_1".into()), + }; + let json = serde_json::to_string(&input).unwrap(); + let deserialized: EventInput = serde_json::from_str(&json).unwrap(); + match deserialized { + EventInput::Notification { + level, message, .. + } => { + assert_eq!(level, "info"); + assert_eq!(message, "hello"); + } + _ => panic!("expected Notification"), + } + } + + #[test] + fn event_output_pre_tool_use() { + let output = EventOutput::PreToolUse { + block: Some("reason".into()), + modified_input: Some(serde_json::json!({"key": "val"})), + }; + let json = serde_json::to_string(&output).unwrap(); + let deserialized: EventOutput = serde_json::from_str(&json).unwrap(); + match deserialized { + EventOutput::PreToolUse { + block, modified_input, .. + } => { + assert_eq!(block, Some("reason".into())); + assert!(modified_input.is_some()); + } + _ => panic!("expected PreToolUse"), + } + } + + #[test] + fn event_output_permission_request() { + let output = EventOutput::PermissionRequest { + decision: Some(PermissionDecision::Allow), + message: Some("approved".into()), + }; + let json = serde_json::to_string(&output).unwrap(); + let deserialized: EventOutput = serde_json::from_str(&json).unwrap(); + match deserialized { + EventOutput::PermissionRequest { + decision, message, .. + } => { + assert_eq!(decision, Some(PermissionDecision::Allow)); + assert_eq!(message, Some("approved".into())); + } + _ => panic!("expected PermissionRequest"), + } + } + + #[test] + fn event_output_agent_start() { + let output = EventOutput::AgentStart { + additional_system_prompt: vec!["be concise".into()], + }; + let json = serde_json::to_string(&output).unwrap(); + let deserialized: EventOutput = serde_json::from_str(&json).unwrap(); + match deserialized { + EventOutput::AgentStart { + additional_system_prompt, + } => { + assert_eq!(additional_system_prompt, vec!["be concise"]); + } + _ => panic!("expected AgentStart"), + } + } + + #[test] + fn permission_decision_serde() { + let json = serde_json::to_string(&PermissionDecision::Deny).unwrap(); + assert_eq!(json, "\"deny\""); + let deserialized: PermissionDecision = + serde_json::from_str("\"ask\"").unwrap(); + assert_eq!(deserialized, PermissionDecision::Ask); + } + + #[test] + fn handler_result_default() { + let result = HandlerResult::default(); + assert_eq!(result.action, HandlerAction::Continue); + assert!(result.output.is_none()); + assert!(result.error.is_none()); + } + + #[test] + fn handler_result_custom() { + let result = HandlerResult { + action: HandlerAction::Block("not allowed".into()), + output: Some(serde_json::json!({"status": "blocked"})), + error: Some("denied".into()), + }; + let json = serde_json::to_string(&result).unwrap(); + let deserialized: HandlerResult = serde_json::from_str(&json).unwrap(); + match deserialized.action { + HandlerAction::Block(msg) => assert_eq!(msg, "not allowed"), + _ => panic!("expected Block"), + } + assert!(deserialized.output.is_some()); + assert_eq!(deserialized.error.unwrap(), "denied"); + } + + #[test] + fn handler_action_serde() { + for action in &[ + HandlerAction::Continue, + HandlerAction::Allow, + HandlerAction::Deny, + HandlerAction::Error, + ] { + let json = serde_json::to_string(action).unwrap(); + let deserialized: HandlerAction = + serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, *action); + } + } + + #[test] + fn handler_action_block_serde() { + let action = HandlerAction::Block("reason".into()); + let json = serde_json::to_string(&action).unwrap(); + let deserialized: HandlerAction = + serde_json::from_str(&json).unwrap(); + match deserialized { + HandlerAction::Block(msg) => assert_eq!(msg, "reason"), + _ => panic!("expected Block"), + } + } + + #[test] + fn plugin_error_display_invalid_manifest() { + let err = PluginError::InvalidManifest("missing field".into()); + assert_eq!( + err.to_string(), + "Plugin manifest is invalid: missing field" + ); + } + + #[test] + fn plugin_error_display_not_found() { + let err = PluginError::NotFound("my-plugin".into()); + assert_eq!(err.to_string(), "Plugin not found: my-plugin"); + } + + #[test] + fn plugin_error_display_load() { + let err = PluginError::Load("permission denied".into()); + assert_eq!( + err.to_string(), + "Failed to load plugin: permission denied" + ); + } + + #[test] + fn plugin_error_display_runtime() { + let err = PluginError::Runtime("crash".into()); + assert_eq!(err.to_string(), "Plugin runtime error: crash"); + } + + #[test] + fn plugin_error_display_eval() { + let err = PluginError::Eval("syntax error".into()); + assert_eq!( + err.to_string(), + "QuickJS evaluation error: syntax error" + ); + } + + #[test] + fn plugin_error_display_quickjs() { + let err = PluginError::QuickJs("OOM".into()); + assert_eq!(err.to_string(), "QuickJS runtime error: OOM"); + } + + #[test] + fn plugin_error_display_transpile() { + let err = PluginError::Transpile("unexpected token".into()); + assert_eq!( + err.to_string(), + "SWC transpilation error: unexpected token" + ); + } + + #[test] + fn plugin_error_display_timeout() { + let dur = std::time::Duration::from_secs(30); + let err = PluginError::Timeout(dur); + assert_eq!(err.to_string(), "Plugin operation timed out after 30s"); + } + + #[test] + fn plugin_error_display_capability() { + let err = PluginError::Capability("network access".into()); + assert_eq!(err.to_string(), "Capability denied: network access"); + } + + #[test] + fn plugin_error_display_npm() { + let err = PluginError::Npm("404 not found".into()); + assert_eq!(err.to_string(), "npm error: 404 not found"); + } + + #[test] + fn plugin_error_display_other() { + let err = PluginError::Other("something went wrong".into()); + assert_eq!(err.to_string(), "something went wrong"); + } + + #[test] + fn plugin_error_io_conversion() { + let io_err = + std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let err: PluginError = io_err.into(); + assert!(err.to_string().contains("I/O error")); + } + + #[test] + fn plugin_error_serde_conversion() { + let serde_err = serde_json::from_str::<()>("invalid").unwrap_err(); + let err: PluginError = serde_err.into(); + assert!(err.to_string().contains("Serde error")); + } + + #[test] + fn plugin_manifest_default_values() { + let m = PluginManifest::default(); + assert_eq!(m.name, ""); + assert_eq!(m.version, "0.1.0"); + assert_eq!(m.kind, PluginKind::Server); + assert!(m.description.is_none()); + assert!(m.author.is_none()); + assert!(m.license.is_none()); + assert!(m.tags.is_empty()); + assert!(m.features.is_empty()); + assert!(m.settings.is_empty()); + } + + #[test] + fn plugin_manifest_from_package_json_requires_jcode_or_pi_field() { + let json = serde_json::json!({"name": "test"}); + let result = PluginManifest::from_package_json(&json); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("missing")); + } + + #[test] + fn plugin_manifest_from_package_json_with_jcode_field() { + let json = serde_json::json!({ + "name": "test-plugin", + "jcode": { + "name": "test-plugin", + "package_name": "test-plugin", + "version": "1.0.0", + "kind": "server" + } + }); + let manifest = + PluginManifest::from_package_json(&json).unwrap(); + assert_eq!(manifest.name, "test-plugin"); + assert_eq!(manifest.version, "1.0.0"); + assert_eq!(manifest.kind, PluginKind::Server); + } + + #[test] + fn plugin_manifest_from_package_json_with_pi_field() { + let json = serde_json::json!({ + "name": "test", + "pi": { + "name": "test", + "package_name": "test", + "version": "2.0.0", + "kind": "tui" + } + }); + let manifest = + PluginManifest::from_package_json(&json).unwrap(); + assert_eq!(manifest.version, "2.0.0"); + assert_eq!(manifest.kind, PluginKind::Tui); + } + + #[test] + fn plugin_manifest_from_package_json_jcode_takes_precedence() { + let json = serde_json::json!({ + "jcode": { "name": "a", "package_name": "a", "version": "1.0.0" }, + "pi": { "name": "b", "package_name": "b", "version": "2.0.0" } + }); + let manifest = + PluginManifest::from_package_json(&json).unwrap(); + assert_eq!(manifest.version, "1.0.0"); + } + + #[test] + fn plugin_manifest_serde_roundtrip() { + let mut manifest = PluginManifest::default(); + manifest.name = "my-plugin".into(); + manifest.package_name = "my-plugin".into(); + manifest.version = "1.2.3".into(); + manifest.description = Some("A test plugin".into()); + manifest.author = Some("author".into()); + manifest.kind = PluginKind::Both; + manifest.tags = vec!["test".into(), "demo".into()]; + let json = serde_json::to_string_pretty(&manifest).unwrap(); + let deserialized: PluginManifest = + serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "my-plugin"); + assert_eq!(deserialized.version, "1.2.3"); + assert_eq!(deserialized.description.unwrap(), "A test plugin"); + assert_eq!(deserialized.author.unwrap(), "author"); + assert_eq!(deserialized.kind, PluginKind::Both); + assert_eq!(deserialized.tags, vec!["test", "demo"]); + } + + #[test] + fn plugin_kind_default_is_server() { + assert_eq!(PluginKind::default(), PluginKind::Server); + } + + #[test] + fn plugin_entry_default() { + let entry = PluginEntry::default(); + assert!(entry.server.is_none()); + assert!(entry.tui.is_none()); + assert!(entry.both.is_none()); + } + + #[test] + fn plugin_entry_serde() { + let entry = PluginEntry { + server: Some("dist/server.js".into()), + tui: Some("dist/tui.js".into()), + both: None, + }; + let json = serde_json::to_string(&entry).unwrap(); + let deserialized: PluginEntry = + serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.server.unwrap(), "dist/server.js"); + assert!(deserialized.both.is_none()); + } + + #[test] + fn plugin_capabilities_default() { + let caps = PluginCapabilities::default(); + assert!(caps.fs_read.is_empty()); + assert!(caps.fs_write.is_empty()); + assert!(!caps.shell); + assert!(!caps.register_tools); + assert!(!caps.register_commands); + assert!(!caps.llm_access); + assert!(!caps.session_access); + assert!(!caps.read_config); + assert!(!caps.write_config); + } + + #[test] + fn plugin_capabilities_serde() { + let caps = PluginCapabilities { + fs_read: vec!["/data".into()], + network: vec!["api.example.com".into()], + shell: true, + register_tools: true, + ..Default::default() + }; + let json = serde_json::to_string(&caps).unwrap(); + let deserialized: PluginCapabilities = + serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.fs_read, vec!["/data"]); + assert_eq!(deserialized.network, vec!["api.example.com"]); + assert!(deserialized.shell); + assert!(deserialized.register_tools); + assert!(!deserialized.register_commands); + } + + #[test] + fn plugin_feature_defaults() { + let feature = PluginFeature { + description: "A feature".into(), + default: false, + entry: None, + additional_capabilities: None, + }; + let json = serde_json::to_string(&feature).unwrap(); + let deserialized: PluginFeature = + serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.description, "A feature"); + assert!(!deserialized.default); + } + + #[test] + fn setting_schema_string_serde() { + let schema = SettingSchema::String { + description: "name".into(), + default: Some("default".into()), + secret: true, + env: Some("MY_VAR".into()), + pattern: Some("^[a-z]+$".into()), + max_length: Some(100), + }; + let json = serde_json::to_string_pretty(&schema).unwrap(); + let deserialized: SettingSchema = + serde_json::from_str(&json).unwrap(); + match deserialized { + SettingSchema::String { + description, secret, .. + } => { + assert_eq!(description, "name"); + assert!(secret); + } + _ => panic!("expected String variant"), + } + } + + #[test] + fn setting_schema_number_serde() { + let schema = SettingSchema::Number { + description: "count".into(), + default: Some(42.0), + min: Some(0.0), + max: Some(100.0), + }; + let json = serde_json::to_string(&schema).unwrap(); + let deserialized: SettingSchema = + serde_json::from_str(&json).unwrap(); + match deserialized { + SettingSchema::Number { + description, + default: d, + .. + } => { + assert_eq!(description, "count"); + assert_eq!(d, Some(42.0)); + } + _ => panic!("expected Number variant"), + } + } + + #[test] + fn setting_schema_boolean_serde() { + let schema = SettingSchema::Boolean { + description: "enabled".into(), + default: Some(true), + }; + let json = serde_json::to_string(&schema).unwrap(); + let deserialized: SettingSchema = + serde_json::from_str(&json).unwrap(); + match deserialized { + SettingSchema::Boolean { + description, + default: d, + } => { + assert_eq!(description, "enabled"); + assert_eq!(d, Some(true)); + } + _ => panic!("expected Boolean variant"), + } + } + + #[test] + fn setting_schema_enum_serde() { + let schema = SettingSchema::Enum { + description: "mode".into(), + default: Some("fast".into()), + values: vec!["fast".into(), "slow".into()], + }; + let json = serde_json::to_string(&schema).unwrap(); + let deserialized: SettingSchema = + serde_json::from_str(&json).unwrap(); + match deserialized { + SettingSchema::Enum { values, .. } => { + assert_eq!(values.len(), 2) + } + _ => panic!("expected Enum variant"), + } + } + + #[test] + fn setting_schema_object_serde() { + let inner = SettingSchema::Boolean { + description: "flag".into(), + default: None, + }; + let schema = SettingSchema::Object { + description: "config".into(), + default: None, + properties: [("nested".into(), inner)].into(), + }; + let json = serde_json::to_string(&schema).unwrap(); + let deserialized: SettingSchema = + serde_json::from_str(&json).unwrap(); + match deserialized { + SettingSchema::Object { properties, .. } => { + assert!(properties.contains_key("nested")) + } + _ => panic!("expected Object variant"), + } + } + + #[test] + fn plugin_engines_default() { + let engines = PluginEngines::default(); + assert!(engines.jcode.is_none()); + } + + #[test] + fn plugin_engines_serde() { + let engines = PluginEngines { + jcode: Some(">=0.9.0".into()), + }; + let json = serde_json::to_string(&engines).unwrap(); + let deserialized: PluginEngines = + serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.jcode.unwrap(), ">=0.9.0"); + } + + #[test] + fn to_json_serializes_pretty() { + let json = + serde::to_json(&serde_json::json!({"key": "value"})).unwrap(); + assert!(json.contains("\"key\"")); + assert!(json.contains("value")); + } + + #[test] + fn from_json_deserializes() { + let json = r#"{"key": "value"}"#; + let value: serde_json::Value = serde::from_json(json).unwrap(); + assert_eq!(value["key"], "value"); + } + + #[test] + fn to_value_returns_json_value() { + let value = serde::to_value(&"hello").unwrap(); + assert_eq!(value, serde_json::json!("hello")); + } + + #[test] + fn serde_roundtrip_plugin_config() { + let cfg = PluginConfig::default(); + let json = serde::to_json(&cfg).unwrap(); + let deserialized: PluginConfig = serde::from_json(&json).unwrap(); + assert_eq!(deserialized.enable, cfg.enable); + assert_eq!(deserialized.mode, cfg.mode); + } + + #[test] + fn serde_roundtrip_event_input() { + let input = EventInput::PreToolUse { + tool_name: "test".into(), + tool_input: serde_json::json!({}), + session_id: "s".into(), + }; + let json = serde::to_json(&input).unwrap(); + let deserialized: EventInput = serde::from_json(&json).unwrap(); + match deserialized { + EventInput::PreToolUse { tool_name, .. } => { + assert_eq!(tool_name, "test") + } + _ => panic!("expected PreToolUse"), + } + } + + #[test] + fn serde_roundtrip_event_output() { + let output = + EventOutput::PreToolUse { block: None, modified_input: None }; + let json = serde::to_json(&output).unwrap(); + let deserialized: EventOutput = serde::from_json(&json).unwrap(); + match deserialized { + EventOutput::PreToolUse { .. } => {} + _ => panic!("expected PreToolUse"), + } + } + + #[test] + fn integration_plugin_id_as_plugin_origin_consistency() { + let id = PluginId::npm("my-plugin"); + let origin = PluginOrigin::NpmPackage { + name: "my-plugin".into(), + version: "1.0.0".into(), + }; + assert_eq!(id.short_name(), "my-plugin"); + match origin { + PluginOrigin::NpmPackage { ref name, .. } => { + assert_eq!(name, "my-plugin") + } + _ => panic!("expected NpmPackage"), + } + } + + #[test] + fn integration_capability_use_with_manifest_capabilities() { + let mut caps = PluginCapabilities::default(); + caps.network = vec!["api.example.com".into()]; + let mut chain = CapabilityChain::default(); + chain.allow_list.hosts = caps.network.clone(); + assert!(matches!( + chain.check("api.example.com", &CapabilityAction::Network), + AccessDecision::Allowed(_) + )); + } + + #[test] + fn integration_manifest_with_capabilities_roundtrip() { + let mut manifest = PluginManifest::default(); + manifest.name = "demo".into(); + manifest.package_name = "demo".into(); + manifest.capabilities.fs_read = vec!["/tmp".into()]; + manifest.capabilities.network = vec!["localhost".into()]; + manifest.capabilities.shell = true; + let json = serde_json::to_string(&manifest).unwrap(); + let deserialized: PluginManifest = + serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.capabilities.fs_read, vec!["/tmp"]); + assert_eq!(deserialized.capabilities.network, vec!["localhost"]); + assert!(deserialized.capabilities.shell); + } + + #[test] + fn integration_event_to_config() { + let _input = EventInput::PreToolUse { + tool_name: "npm_install".into(), + tool_input: serde_json::json!({"package": "test"}), + session_id: "sess_1".into(), + }; + let mut cfg = PluginConfig::default(); + cfg.mode = Some("interactive".into()); + let json = serde_json::to_string(&cfg).unwrap(); + assert!(json.contains("interactive")); + let back: PluginConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.mode.as_deref(), Some("interactive")); + } + + #[test] + fn integration_error_chain_compatibility() { + let io_err = std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "permission denied", + ); + let plugin_err: PluginError = io_err.into(); + let display = plugin_err.to_string(); + assert!( + display.contains("I/O error") + || display.contains("permission denied") + ); + } +} diff --git a/crates/jcode-plugin-core/src/types.rs b/crates/jcode-plugin-core/src/types.rs new file mode 100644 index 000000000..1f6494f77 --- /dev/null +++ b/crates/jcode-plugin-core/src/types.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; + +/// Unique identifier for a plugin — npm package name or file path +#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub struct PluginId(String); + +impl PluginId { + pub fn npm(name: &str) -> Self { Self(format!("npm:{name}")) } + pub fn file(path: &str) -> Self { Self(format!("file:{path}")) } + pub fn bundled(name: &str) -> Self { Self(format!("builtin:{name}")) } + + pub fn to_string(&self) -> String { self.0.clone() } + + pub fn as_str(&self) -> &str { &self.0 } + + /// Extract the short name (strip prefix) + pub fn short_name(&self) -> &str { + self.0.split_once(':').map(|(_, name)| name).unwrap_or(&self.0) + } +} + +impl std::fmt::Display for PluginId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for PluginId { + fn from(s: String) -> Self { Self(s) } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginVersion { + pub semver: semver::Version, + pub jcode_min_version: Option, + pub jcode_max_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PluginState { + Discovered, + Loading, + Loaded, + Active, + Error(String), + Disabled, + Blocked, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PluginOrigin { + NpmPackage { name: String, version: String }, + LocalFile { path: String }, + Builtin { name: String }, + Remote { url: String }, +} diff --git a/crates/jcode-plugin-runtime/Cargo.toml b/crates/jcode-plugin-runtime/Cargo.toml new file mode 100644 index 000000000..1c7d6c8ec --- /dev/null +++ b/crates/jcode-plugin-runtime/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "jcode-plugin-runtime" +version = "0.1.0" +edition = "2024" +description = "QuickJS plugin runtime: sandbox, transpiler, dispatcher, and bridge for the jcode plugin system" + +[dependencies] +jcode-plugin-core = { path = "../jcode-plugin-core" } +rquickjs = { version = "0.7", features = ["parallel", "futures", "classes"] } +tokio = { version = "1", features = ["fs", "process", "sync", "time", "macros", "rt"] } +tokio-stream = "0.1" +futures = "0.3" +regex = "1" +tracing = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +seahash = "4" +thiserror = "2" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +swc_ecma_ast = "25" +swc_ecma_parser = "41" +swc_ecma_transforms_typescript = "49" +swc_ecma_transforms_base = "44" +swc_ecma_codegen = "28" +swc_common = "23" +swc_atoms = "9" diff --git a/crates/jcode-plugin-runtime/src/api.rs b/crates/jcode-plugin-runtime/src/api.rs new file mode 100644 index 000000000..9df7cd640 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/api.rs @@ -0,0 +1,184 @@ +use std::sync::Arc; +use rquickjs::{Ctx, Function, Object, Value}; +use jcode_plugin_core::types::PluginId; +use jcode_plugin_core::PluginEvent; +use jcode_plugin_core::manifest::PluginManifest; +use jcode_plugin_core::security::CapabilityChain; +use crate::bridge::PromiseBridge; +use crate::registry::PluginRegistry; +use crate::types::HandlerSlot; + +pub struct PluginApiBindings { + plugin_id: PluginId, + _manifest: PluginManifest, + _capability_chain: Arc, + registry: Arc, + _bridge: Arc, +} + +impl PluginApiBindings { + pub fn new( + plugin_id: PluginId, + manifest: PluginManifest, + capability_chain: Arc, + registry: Arc, + bridge: Arc, + ) -> Self { + Self { plugin_id, _manifest: manifest, _capability_chain: capability_chain, registry, _bridge: bridge } + } + + pub fn install<'js>(&self, ctx: &Ctx<'js>) -> Result<(), rquickjs::Error> { + let pi = Object::new(ctx.clone())?; + pi.set("id", self.plugin_id.to_string())?; + pi.set("name", self.plugin_id.to_string())?; + pi.set("version", "0.1.0")?; + pi.set("on", self.make_on_fn(ctx)?)?; + pi.set("registerTool", self.make_register_tool_fn(ctx)?)?; + pi.set("getConfig", self.make_get_config_fn(ctx)?)?; + pi.set("logger", self.make_logger(ctx)?)?; + + let kv = Object::new(ctx.clone())?; + kv.set("get", self.make_kv_get_fn(ctx)?)?; + kv.set("set", self.make_kv_set_fn(ctx)?)?; + pi.set("kv", kv)?; + + pi.set("sleep", self.make_sleep_fn(ctx)?)?; + pi.set("uuid", self.make_uuid_fn(ctx)?)?; + pi.set("cwd", std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()))?; + let handlers = Object::new(ctx.clone())?; + pi.set("_handlers", handlers)?; + ctx.globals().set("__jcode_pi", pi)?; + self._bridge.install(ctx)?; + Ok(()) + } + + fn make_on_fn<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + let registry = Arc::clone(&self.registry); + let plugin_id = self.plugin_id.clone(); + + Function::new(ctx.clone(), move |event: String, _handler: Value<'js>| { + let event_variant = match event.as_str() { + "PreToolUse" => PluginEvent::PreToolUse, + "PostToolUse" => PluginEvent::PostToolUse, + "PostToolUseFailure" => PluginEvent::PostToolUseFailure, + "ToolExecutionStart" => PluginEvent::ToolExecutionStart, + "ToolExecutionEnd" => PluginEvent::ToolExecutionEnd, + "SessionStart" => PluginEvent::SessionStart, + "SessionEnd" => PluginEvent::SessionEnd, + "SessionSwitch" => PluginEvent::SessionSwitch, + "SessionCompact" => PluginEvent::SessionCompact, + "SessionBeforeCompact" => PluginEvent::SessionBeforeCompact, + "SessionShutdown" => PluginEvent::SessionShutdown, + "PermissionRequest" => PluginEvent::PermissionRequest, + "PermissionDenied" => PluginEvent::PermissionDenied, + "AgentStart" => PluginEvent::AgentStart, + "AgentEnd" => PluginEvent::AgentEnd, + "TurnStart" => PluginEvent::TurnStart, + "TurnEnd" => PluginEvent::TurnEnd, + "MessageStart" => PluginEvent::MessageStart, + "MessageEnd" => PluginEvent::MessageEnd, + "PreCompact" => PluginEvent::PreCompact, + "PostCompact" => PluginEvent::PostCompact, + "TaskCreated" => PluginEvent::TaskCreated, + "TaskCompleted" => PluginEvent::TaskCompleted, + "AutoCompactionStart" => PluginEvent::AutoCompactionStart, + "UserPromptSubmit" => PluginEvent::UserPromptSubmit, + "Stop" => PluginEvent::Stop, + "Notification" => PluginEvent::Notification, + _ => { + tracing::warn!("Plugin {} registered handler for unknown event: {}", plugin_id, event); + return; + } + }; + + tracing::debug!("Plugin {} registered handler for event: {}", plugin_id, event); + + // Create a Rust handler slot that wraps the JS handler invocation. + // The actual JS function call happens in the sandbox's call_handler method. + // + // TODO(WIP): The JS handler function (`_handler`) is received but not yet + // wired into the Rust closure. Currently returns HandlerResult::default(). + // Full JS-to-Rust bridge requires storing the JS function reference in a + // thread-safe handle and invoking it via QuickJS context during dispatch. + let id = plugin_id.clone(); + let slot = HandlerSlot::Rust(Arc::new(move |_input, _output| { + let id = id.clone(); + Box::pin(async move { + tracing::debug!("Handler invoked for plugin {} (Rust adapter) [STUB]", id); + jcode_plugin_core::events::HandlerResult::default() + }) + })); + + registry.register_handler(event_variant, plugin_id.clone(), slot); + }) + } + + fn make_register_tool_fn<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + let registry = Arc::clone(&self.registry); + let id = self.plugin_id.clone(); + Function::new(ctx.clone(), move |tool_def: Object<'js>| { + let name: String = match tool_def.get("name") { + Ok(n) => n, + Err(_) => { + tracing::warn!("Plugin {} tried to register tool without name", id); + return; + } + }; + tracing::info!("Plugin {} registered tool: {}", id, name); + let id = id.clone(); + registry.register_js_tool(id, name, tool_def); + }) + } + + fn make_get_config_fn<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + Function::new(ctx.clone(), |_key: String| { + "" + }) + } + + fn make_logger<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + let logger = Object::new(ctx.clone())?; + logger.set("info", Function::new(ctx.clone(), |msg: String| { + tracing::info!("[plugin] {}", msg); + })?)?; + logger.set("warn", Function::new(ctx.clone(), |msg: String| { + tracing::warn!("[plugin] {}", msg); + })?)?; + logger.set("error", Function::new(ctx.clone(), |msg: String| { + tracing::error!("[plugin] {}", msg); + })?)?; + logger.set("debug", Function::new(ctx.clone(), |msg: String| { + tracing::debug!("[plugin] {}", msg); + })?)?; + Ok(logger) + } + + fn make_kv_get_fn<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + Function::new(ctx.clone(), |_key: String| { + "" + }) + } + + fn make_kv_set_fn<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + Function::new(ctx.clone(), |_key: String, _value: Value<'js>| { + }) + } + + fn make_sleep_fn<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + // Cap sleep duration to prevent plugins from blocking the QuickJS thread indefinitely. + // 5 seconds is generous for plugin-side delays; anything longer should use async timers. + const MAX_SLEEP_MS: u64 = 5_000; + Function::new(ctx.clone(), |ms: u64| { + let capped = ms.min(MAX_SLEEP_MS); + std::thread::sleep(std::time::Duration::from_millis(capped)); + }) + } + + fn make_uuid_fn<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + Function::new(ctx.clone(), || { + uuid::Uuid::new_v4().to_string() + }) + } +} diff --git a/crates/jcode-plugin-runtime/src/audit.rs b/crates/jcode-plugin-runtime/src/audit.rs new file mode 100644 index 000000000..717aff689 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/audit.rs @@ -0,0 +1,79 @@ +use std::collections::VecDeque; +use std::sync::Mutex; +use chrono::{DateTime, Utc}; +use jcode_plugin_core::types::PluginId; +use jcode_plugin_core::security::{CapabilityAction, AccessDecision}; + +pub struct AuditTrail { + entries: Mutex>, + max_entries: usize, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AuditEntry { + pub timestamp: DateTime, + pub plugin_id: String, + pub resource: String, + pub action: String, + pub decision: String, + pub reason: String, +} + +impl AuditTrail { + pub fn new(max_entries: usize) -> Self { + Self { + entries: Mutex::new(VecDeque::with_capacity(max_entries)), + max_entries, + } + } + + pub fn log_access(&self, plugin_id: &PluginId, resource: &str, + action: &CapabilityAction, + decision: &AccessDecision) { + let (ds, reason) = match decision { + AccessDecision::Allowed(r) => ("allowed", r.clone()), + AccessDecision::Denied(r) => ("denied", r.clone()), + AccessDecision::NeedsApproval(r) => ("needs_approval", r.clone()), + }; + + if let Ok(mut entries) = self.entries.lock() { + if entries.len() >= self.max_entries { + entries.pop_front(); + } + entries.push_back(AuditEntry { + timestamp: Utc::now(), + plugin_id: plugin_id.to_string(), + resource: resource.into(), + action: format!("{action}"), + decision: ds.into(), + reason, + }); + } + } + + pub fn get_recent(&self, count: usize) -> Vec { + if let Ok(entries) = self.entries.lock() { + entries.iter().rev().take(count).cloned().collect() + } else { + Vec::new() + } + } + + pub fn clear(&self) { + if let Ok(mut entries) = self.entries.lock() { + entries.clear(); + } + } + + pub fn entry_count(&self) -> usize { + self.len() + } + + pub fn len(&self) -> usize { + if let Ok(entries) = self.entries.lock() { + entries.len() + } else { + 0 + } + } +} diff --git a/crates/jcode-plugin-runtime/src/bridge.rs b/crates/jcode-plugin-runtime/src/bridge.rs new file mode 100644 index 000000000..3585a3f5c --- /dev/null +++ b/crates/jcode-plugin-runtime/src/bridge.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; +use std::sync::atomic::AtomicU64; +use std::sync::Arc; +use tokio::sync::{Mutex, oneshot}; + +#[allow(dead_code)] +pub struct PromiseBridge { + next_id: AtomicU64, + pending: Arc>>>>, +} + +impl PromiseBridge { + pub fn new() -> Self { + Self { + next_id: AtomicU64::new(1), + pending: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// TODO(WIP): Install promise bridge functions into the QuickJS context. + /// Currently a no-op — the bridge between async Rust futures and JS Promises + /// is not yet implemented. Requires injecting `__jcode_resolve(id, data)` and + /// `__jcode_reject(id, error)` globals and wiring them to the oneshot channels. + pub fn install(&self, _ctx: &rquickjs::Ctx<'_>) -> Result<(), rquickjs::Error> { + Ok(()) + } + + /// TODO(WIP): Dispatch an async call from JS to Rust. + /// Currently only handles hardcoded stub methods. Full implementation should + /// allocate a oneshot channel, return the pending ID to JS as a Promise, and + /// resolve/reject when the Rust future completes. + pub async fn dispatch_call(&self, method: &str, _args: &[u8]) -> Result, String> { + match method { + "getConfig" => Ok(br#"{}"#.to_vec()), + "getVersion" => Ok(br#""0.1.0""#.to_vec()), + _ => Err(format!("Unknown method: {method}")), + } + } +} diff --git a/crates/jcode-plugin-runtime/src/dispatcher.rs b/crates/jcode-plugin-runtime/src/dispatcher.rs new file mode 100644 index 000000000..7f940bde0 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/dispatcher.rs @@ -0,0 +1,173 @@ +use std::sync::{Arc, RwLock, Mutex}; +use futures::future::join_all; +use jcode_plugin_core::PluginEvent; +use jcode_plugin_core::types::PluginId; +use jcode_plugin_core::events::{EventInput, EventOutput, HandlerResult}; +use crate::types::HandlerSlot; + +#[derive(Debug, Clone)] +struct HandlerBitmap(u128); + +impl HandlerBitmap { + fn new() -> Self { Self(0) } + fn set(&mut self, event: PluginEvent) { + self.0 |= 1u128 << (event as u32); + } + fn has(&self, event: PluginEvent) -> bool { + (self.0 & (1u128 << (event as u32))) != 0 + } + fn _clear(&mut self, event: PluginEvent) { + self.0 &= !(1u128 << (event as u32)); + } + fn rebuild(handlers: &[(PluginEvent, PluginId, HandlerSlot)]) -> Self { + let mut bm = Self(0); + for (event, _, _) in handlers { + bm.set(*event); + } + bm + } +} + +#[derive(Clone)] +struct RegistrySnapshot { + bitmap: HandlerBitmap, + handlers: Vec<(PluginEvent, PluginId, HandlerSlot)>, +} + +pub struct RcuDispatcher { + snapshot: RwLock>, + pending: Mutex>, +} + +impl RcuDispatcher { + pub fn new() -> Self { + Self { + snapshot: RwLock::new(Arc::new(RegistrySnapshot { + bitmap: HandlerBitmap::new(), + handlers: Vec::new(), + })), + pending: Mutex::new(Vec::new()), + } + } + + pub fn register(&self, event: PluginEvent, id: PluginId, slot: HandlerSlot) { + if let Ok(mut pending) = self.pending.lock() { + pending.push((event, id, slot)); + } + } + + pub fn commit(&self) { + // Drain pending first, then build the new snapshot, then publish. + // This avoids holding the snapshot lock while acquiring the write lock. + let to_commit: Vec<(PluginEvent, PluginId, HandlerSlot)> = { + let Ok(mut pending) = self.pending.lock() else { return; }; + if pending.is_empty() { return; } + pending.drain(..).collect() + }; + + // Get the current handlers (read lock — released before write lock) + let current_handlers = { + let Ok(current) = self.snapshot.read() else { return; }; + current.handlers.clone() + }; + + let mut new_handlers = current_handlers; + new_handlers.extend(to_commit); + let new_bitmap = HandlerBitmap::rebuild(&new_handlers); + + let Ok(mut snapshot) = self.snapshot.write() else { return; }; + *snapshot = Arc::new(RegistrySnapshot { + bitmap: new_bitmap, + handlers: new_handlers, + }); + } + + pub fn has_handler(&self, event: PluginEvent) -> bool { + if let Ok(snapshot) = self.snapshot.read() { + snapshot.bitmap.has(event) + } else { + false + } + } + + /// Dispatch an event to all registered handlers. + /// + /// Uses `join_all` for concurrent dispatch. Each handler receives + /// a clone of the input/output and returns its own HandlerResult. + pub async fn dispatch(&self, event: PluginEvent, input: EventInput, + output: Option) -> Vec<(PluginId, HandlerResult)> { + // RCU: clone the Arc for zero-contention reads + let snapshot = if let Ok(s) = self.snapshot.read() { + s.clone() + } else { + return Vec::new(); + }; + + // O(1) bitmap check — fast path when no handlers exist + if !snapshot.bitmap.has(event) { + return Vec::new(); + } + + let handlers: Vec<_> = snapshot.handlers.iter() + .filter(|(e, _, _)| *e == event) + .map(|(_, id, slot)| (id.clone(), slot.clone())) + .collect(); + + if handlers.is_empty() { + return Vec::new(); + } + + // Dispatch via join_all — each handler gets a clone of the input + let futures: Vec<_> = handlers.into_iter().map(|(id, slot)| { + let inp = input.clone(); + let out = output.clone(); + async move { + let result = match slot { + HandlerSlot::Rust(handler) => handler(inp, out).await, + }; + (id, result) + } + }).collect(); + + join_all(futures).await + } + + pub fn unregister_plugin(&self, id: &PluginId) { + // Get current handlers (read lock — released before write lock) + let current_handlers = { + let Ok(current) = self.snapshot.read() else { return; }; + current.handlers.clone() + }; + + let new_handlers: Vec<_> = current_handlers + .into_iter() + .filter(|(_, pid, _)| pid != id) + .collect(); + let new_bitmap = HandlerBitmap::rebuild(&new_handlers); + + let Ok(mut snapshot) = self.snapshot.write() else { return; }; + *snapshot = Arc::new(RegistrySnapshot { + bitmap: new_bitmap, + handlers: new_handlers, + }); + } + + pub fn handler_count(&self) -> usize { + if let Ok(snapshot) = self.snapshot.read() { + snapshot.handlers.len() + } else { + 0 + } + } + + pub fn plugin_count(&self) -> usize { + if let Ok(snapshot) = self.snapshot.read() { + let mut ids: Vec<&PluginId> = snapshot.handlers.iter().map(|(_, id, _)| id).collect(); + ids.sort_by(|a, b| a.to_string().cmp(&b.to_string())); + ids.dedup(); + ids.len() + } else { + 0 + } + } +} diff --git a/crates/jcode-plugin-runtime/src/errors.rs b/crates/jcode-plugin-runtime/src/errors.rs new file mode 100644 index 000000000..6d61ca971 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/errors.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum RuntimeError { + #[error("QuickJS error: {0}")] + QuickJs(String), + + #[error("Plugin error: {0}")] + Plugin(#[from] jcode_plugin_core::PluginError), + + #[error("Timeout: {0:?}")] + Timeout(std::time::Duration), + + #[error("Capability denied: {0}")] + Capability(String), + + #[error("Transpilation error: {0}")] + Transpile(String), + + #[error("Not found: {0}")] + NotFound(String), + + #[error("Already registered: {0}")] + AlreadyRegistered(String), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("{0}")] + Other(String), +} diff --git a/crates/jcode-plugin-runtime/src/integration_tests.rs b/crates/jcode-plugin-runtime/src/integration_tests.rs new file mode 100644 index 000000000..fc9944119 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/integration_tests.rs @@ -0,0 +1,195 @@ +//! Integration tests for the plugin system — load, dispatch, capability checks. +//! +//! These tests exercise the end-to-end flow: register a handler, dispatch +//! an event, verify the handler runs and returns the expected result. + +use jcode_plugin_core::PluginEvent; +use jcode_plugin_core::events::{EventInput, HandlerResult, HandlerAction}; +use jcode_plugin_core::types::PluginId; +use std::sync::Arc; + +use crate::dispatcher::RcuDispatcher; +use crate::types::HandlerSlot; + +#[test] +fn test_register_and_dispatch_handler() { + let dispatcher = RcuDispatcher::new(); + let plugin_id = PluginId::npm("test-plugin"); + + let slot = HandlerSlot::Rust(Arc::new(|_input, _output| { + Box::pin(async { + HandlerResult { + action: HandlerAction::Block("blocked by test".to_string()), + output: None, + error: None, + } + }) + })); + dispatcher.register(PluginEvent::PreToolUse, plugin_id.clone(), slot); + dispatcher.commit(); + + assert!(dispatcher.has_handler(PluginEvent::PreToolUse)); + assert_eq!(dispatcher.handler_count(), 1); + assert_eq!(dispatcher.plugin_count(), 1); + + let input = EventInput::PreToolUse { + tool_name: "test-tool".to_string(), + tool_input: serde_json::json!({}), + session_id: "sess-1".to_string(), + }; + let results = futures::executor::block_on( + dispatcher.dispatch(PluginEvent::PreToolUse, input, None) + ); + assert_eq!(results.len(), 1); + let (id, result) = &results[0]; + assert_eq!(id, &plugin_id); + assert!(matches!(result.action, HandlerAction::Block(_))); +} + +#[test] +fn test_dispatch_no_handlers_returns_empty() { + let dispatcher = RcuDispatcher::new(); + let input = EventInput::PreToolUse { + tool_name: "x".to_string(), + tool_input: serde_json::json!({}), + session_id: "".to_string(), + }; + let results = futures::executor::block_on( + dispatcher.dispatch(PluginEvent::PreToolUse, input, None) + ); + assert!(results.is_empty()); + assert!(!dispatcher.has_handler(PluginEvent::PreToolUse)); +} + +#[test] +fn test_multiple_plugins_dispatch_concurrently() { + let dispatcher = RcuDispatcher::new(); + + for i in 0..3 { + let id = PluginId::npm(&format!("plugin-{i}")); + let id2 = id.clone(); + let slot = HandlerSlot::Rust(Arc::new(move |_input, _output| { + let id = id2.clone(); + Box::pin(async move { + HandlerResult { + action: HandlerAction::Allow, + output: Some(serde_json::json!({ "from": id.to_string() })), + error: None, + } + }) + })); + dispatcher.register(PluginEvent::SessionStart, id, slot); + } + dispatcher.commit(); + + let input = EventInput::SessionStart { + session_id: "test".to_string(), + project_dir: "/tmp".to_string(), + model: "claude".to_string(), + provider: "anthropic".to_string(), + }; + let results = futures::executor::block_on( + dispatcher.dispatch(PluginEvent::SessionStart, input, None) + ); + assert_eq!(results.len(), 3); + for (_, result) in &results { + assert!(matches!(result.action, HandlerAction::Allow)); + } +} + +#[test] +fn test_unregister_plugin_removes_handlers() { + let dispatcher = RcuDispatcher::new(); + let id = PluginId::npm("removable"); + + let slot = HandlerSlot::Rust(Arc::new(|_, _| { + Box::pin(async { HandlerResult::default() }) + })); + dispatcher.register(PluginEvent::TurnStart, id.clone(), slot); + dispatcher.commit(); + assert!(dispatcher.has_handler(PluginEvent::TurnStart)); + + dispatcher.unregister_plugin(&id); + assert!(!dispatcher.has_handler(PluginEvent::TurnStart)); +} + +#[test] +fn test_bitmap_o1_check() { + let dispatcher = RcuDispatcher::new(); + let id = PluginId::npm("test"); + + for ev in [PluginEvent::PreToolUse, PluginEvent::PostToolUse, PluginEvent::SessionStart] { + assert!(!dispatcher.has_handler(ev)); + } + + let slot = HandlerSlot::Rust(Arc::new(|_, _| { + Box::pin(async { HandlerResult::default() }) + })); + dispatcher.register(PluginEvent::PostToolUse, id, slot); + dispatcher.commit(); + + assert!(!dispatcher.has_handler(PluginEvent::PreToolUse)); + assert!(dispatcher.has_handler(PluginEvent::PostToolUse)); + assert!(!dispatcher.has_handler(PluginEvent::SessionStart)); +} + +#[test] +fn test_preflight_clean_passes() { + use jcode_plugin_core::preflight::PreflightAnalyzer; + use jcode_plugin_core::manifest::PluginCapabilities; + + let code = r#" + pi.on("TurnStart", (e) => { + pi.logger.info("started"); + }); + "#; + let result = PreflightAnalyzer::analyze(code, &PluginCapabilities::default()); + assert!(result.passed); + assert!(result.warnings.is_empty()); +} + +#[test] +fn test_preflight_blocks_evil_code() { + use jcode_plugin_core::preflight::PreflightAnalyzer; + use jcode_plugin_core::manifest::PluginCapabilities; + + let code = r#" + exec("rm -rf /"); + exec("sudo chmod 777 /etc"); + "#; + let result = PreflightAnalyzer::analyze(code, &PluginCapabilities::default()); + assert!(!result.passed); + assert!(!result.blocks.is_empty()); +} + +#[test] +fn test_audit_trail_ring_buffer() { + use crate::audit::AuditTrail; + use jcode_plugin_core::PluginId; + use jcode_plugin_core::security::{CapabilityAction, AccessDecision}; + + let trail = AuditTrail::new(3); + let id = PluginId::npm("test"); + + for i in 0..5 { + trail.log_access(&id, &format!("res-{i}"), &CapabilityAction::Read, &AccessDecision::Allowed("ok".into())); + } + assert_eq!(trail.len(), 3); + + let recent = trail.get_recent(10); + assert_eq!(recent.len(), 3); + assert!(recent[0].resource.contains("res-4")); + assert!(recent[2].resource.contains("res-2")); +} + +#[test] +fn test_kill_switches() { + use crate::server; + use std::sync::atomic::Ordering; + + server::DISABLE_ALL_PLUGINS.store(false, Ordering::SeqCst); + server::SKIP_HOOKS.store(false, Ordering::SeqCst); + server::FORCE_DENY.store(false, Ordering::SeqCst); + + assert!(!server::is_force_deny()); +} diff --git a/crates/jcode-plugin-runtime/src/lib.rs b/crates/jcode-plugin-runtime/src/lib.rs new file mode 100644 index 000000000..1df294cc1 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/lib.rs @@ -0,0 +1,36 @@ +pub mod api; +pub mod audit; +pub mod bridge; +pub mod dispatcher; +pub mod errors; +pub mod loader; +pub mod native; +pub mod registry; +pub mod runtime; +pub mod sandbox; +pub mod server; +pub mod timer; +pub mod transpiler; +pub mod tui_api; +pub mod tui_system; +pub mod types; + +pub use api::PluginApiBindings; +pub use audit::{AuditEntry, AuditTrail}; +pub use bridge::PromiseBridge; +pub use dispatcher::RcuDispatcher; +pub use errors::RuntimeError; +pub use loader::PluginLoader; +pub use native::NativeBindings; +pub use registry::{JsToolRegistry, PluginRegistry}; +pub use runtime::{RuntimeConfig, RuntimeManager}; +pub use sandbox::{DualTimeout, SandboxContext}; +pub use server::{check_kill_switches, is_force_deny, PluginSystem, DISABLE_ALL_PLUGINS, FORCE_DENY, SKIP_HOOKS}; +pub use timer::PluginTimer; +pub use transpiler::Transpiler; +pub use tui_api::{SlotContent, SlotRegistry, SlotType, TuiPluginApi}; +pub use tui_system::TuiPluginSystem; +pub use types::{HandlerSlot, PreflightResult, ResolvedEntry, StaticAnalysis}; + +#[cfg(test)] +mod integration_tests; diff --git a/crates/jcode-plugin-runtime/src/loader.rs b/crates/jcode-plugin-runtime/src/loader.rs new file mode 100644 index 000000000..8d0d41637 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/loader.rs @@ -0,0 +1,191 @@ +use std::sync::Arc; +use std::path::Path; +use jcode_plugin_core::PluginError; +use jcode_plugin_core::types::PluginId; +use jcode_plugin_core::config::{PluginConfig, PluginSource, DiscoveryPaths, is_valid_package_name}; +use jcode_plugin_core::preflight::PreflightAnalyzer; +use crate::runtime::RuntimeManager; +use crate::registry::PluginRegistry; +use crate::transpiler::Transpiler; +use crate::types::ResolvedEntry; + +pub struct PluginLoader { + discovery: DiscoveryPaths, + config: PluginConfig, + registry: Arc, + transpiler: Arc, + runtime: Arc, +} + +impl PluginLoader { + pub fn new( + discovery: DiscoveryPaths, + config: PluginConfig, + registry: Arc, + runtime: Arc, + ) -> Self { + Self { + discovery, + config, + registry, + transpiler: Arc::new(Transpiler::new()), + runtime, + } + } + + pub async fn load_all(&self) -> Result, PluginError> { + let sources = self.discover_sources().await?; + let mut loaded = Vec::new(); + for source in sources { + match self.load_one(&source).await { + Ok(id) => loaded.push(id), + Err(e) => { + if self.config.fail_closed.unwrap_or(false) { + return Err(e); + } + tracing::warn!("Failed to load plugin {source:?}: {e}"); + } + } + } + Ok(loaded) + } + + async fn discover_sources(&self) -> Result, PluginError> { + let mut sources = Vec::new(); + if let Some(ref cfg_sources) = self.config.sources { + sources.extend(cfg_sources.clone()); + } + for dir in &self.discovery.plugin_dirs { + self.scan_directory(dir, &mut sources).await?; + } + let npm_dir = &self.discovery.npm_cache; + if npm_dir.exists() { + self.scan_npm_cache(npm_dir, &mut sources).await?; + } + Ok(sources) + } + + async fn scan_directory(&self, dir: &Path, sources: &mut Vec) -> Result<(), PluginError> { + if !dir.exists() { + tokio::fs::create_dir_all(dir).await?; + return Ok(()); + } + let mut read_dir = tokio::fs::read_dir(dir).await?; + while let Some(entry) = read_dir.next_entry().await? { + let path = entry.path(); + let name = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + if name.ends_with(".ts") || name.ends_with(".js") { + sources.push(PluginSource::File { + path: path.to_string_lossy().to_string(), + }); + } + } + Ok(()) + } + + async fn scan_npm_cache(&self, dir: &Path, sources: &mut Vec) -> Result<(), PluginError> { + let mut read_dir = tokio::fs::read_dir(dir).await?; + while let Some(entry) = read_dir.next_entry().await? { + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + let package_name = name.replace("__", "/"); + sources.push(PluginSource::Npm { + package: package_name, + version: None, + }); + } + } + } + Ok(()) + } + + pub(crate) async fn load_one(&self, source: &PluginSource) -> Result { + let (path, id) = match source { + PluginSource::Npm { package, version } => { + let entry = self.resolve_npm_entry(package, version.as_deref()).await?; + (entry.path, PluginId::npm(package)) + } + PluginSource::File { path } => { + (std::path::PathBuf::from(path), PluginId::file(path)) + } + PluginSource::Directory { path } => { + let p = std::path::Path::new(path); + let idx = if p.join("index.ts").exists() { p.join("index.ts") } + else { p.join("index.js") }; + (idx, PluginId::file(path)) + } + }; + + let code = tokio::fs::read_to_string(&path).await?; + + // Preflight static analysis — catch suspicious patterns before eval + let manifest_caps = jcode_plugin_core::manifest::PluginCapabilities::default(); + let preflight = PreflightAnalyzer::analyze(&code, &manifest_caps); + if !preflight.warnings.is_empty() { + for w in &preflight.warnings { + tracing::warn!("Plugin {} preflight warning: {}", id, w); + } + } + if !preflight.passed { + return Err(PluginError::Load(format!( + "Plugin {} blocked by preflight analysis: {}", + id, + preflight.blocks.join("; ") + ))); + } + + let js_code = if path.extension().map_or(false, |e| e == "ts" || e == "tsx") { + self.transpiler.transpile(&code, &path.to_string_lossy())? + } else { + code + }; + + let context = self.runtime.create_sandbox(id.clone(), jcode_plugin_core::manifest::PluginManifest::default())?; + context.eval(&js_code).await?; + self.registry.register(id.clone(), ()).await?; + Ok(id) + } + + async fn resolve_npm_entry(&self, package: &str, _version: Option<&str>) -> Result { + let cache = self.discovery.npm_cache.join(sanitize_npm_name(package)); + if !cache.exists() { + self.install_npm(package, None, &cache).await?; + } + let pkg_json = cache.join("node_modules").join(package).join("package.json"); + let content = tokio::fs::read_to_string(&pkg_json).await?; + let json: serde_json::Value = serde_json::from_str(&content)?; + let manifest = jcode_plugin_core::manifest::PluginManifest::from_package_json(&json)?; + let entry = manifest.entry.server.as_ref() + .or(manifest.entry.both.as_ref()) + .ok_or_else(|| PluginError::InvalidManifest("No server entry point".into()))?; + Ok(ResolvedEntry { + path: cache.join(entry), + manifest, + }) + } + + async fn install_npm(&self, package: &str, _version: Option<&str>, dir: &Path) -> Result<(), PluginError> { + if !is_valid_package_name(package) { + return Err(PluginError::Other("Invalid package name".into())); + } + tokio::fs::create_dir_all(dir).await?; + let spec = package.to_string(); + let out = tokio::process::Command::new("npm") + .args(["install", &spec, "--no-save", "--no-audit"]) + .current_dir(dir) + .output() + .await?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(PluginError::Npm(stderr.to_string())); + } + Ok(()) + } +} + +fn sanitize_npm_name(name: &str) -> String { + name.replace('/', "__").replace('@', "") +} diff --git a/crates/jcode-plugin-runtime/src/native.rs b/crates/jcode-plugin-runtime/src/native.rs new file mode 100644 index 000000000..be8040511 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/native.rs @@ -0,0 +1,80 @@ +use jcode_plugin_core::PluginError; +use jcode_plugin_core::security::{AccessDecision, CapabilityAction, CapabilityChain}; + +pub struct NativeBindings; + +impl NativeBindings { + /// Check if a capability action is allowed, returning an error if denied. + fn require_capability( + chain: &CapabilityChain, + resource: &str, + action: &CapabilityAction, + ) -> Result<(), PluginError> { + match chain.check(resource, action) { + AccessDecision::Allowed(_) => Ok(()), + AccessDecision::Denied(reason) => { + Err(PluginError::Other(format!( + "Capability denied for {action} on '{resource}': {reason}" + ))) + } + AccessDecision::NeedsApproval(reason) => { + // In native bindings context, we cannot prompt for approval. + // Log and deny — plugins should declare capabilities in their manifest. + tracing::warn!( + "Capability needs approval for {action} on '{resource}': {reason} — denying (no interactive prompt available)" + ); + Err(PluginError::Other(format!( + "Capability requires approval for {action} on '{resource}': {reason}" + ))) + } + } + } + + pub async fn http_get(chain: &CapabilityChain, url: &str) -> Result { + Self::require_capability(chain, url, &CapabilityAction::Network)?; + let resp = reqwest::get(url).await + .map_err(|e| PluginError::Other(format!("HTTP GET failed: {e}")))?; + let body = resp.text().await + .map_err(|e| PluginError::Other(format!("HTTP response error: {e}")))?; + Ok(body) + } + + pub async fn http_post(chain: &CapabilityChain, url: &str, body: &str) -> Result { + Self::require_capability(chain, url, &CapabilityAction::Network)?; + let client = reqwest::Client::new(); + let resp = client.post(url) + .header("Content-Type", "application/json") + .body(body.to_string()) + .send() + .await + .map_err(|e| PluginError::Other(format!("HTTP POST failed: {e}")))?; + let text = resp.text().await + .map_err(|e| PluginError::Other(format!("HTTP response error: {e}")))?; + Ok(text) + } + + pub async fn fs_read_text(chain: &CapabilityChain, path: &str) -> Result { + Self::require_capability(chain, path, &CapabilityAction::Read)?; + Ok(tokio::fs::read_to_string(path).await?) + } + + pub async fn fs_write_text(chain: &CapabilityChain, path: &str, content: &str) -> Result<(), PluginError> { + Self::require_capability(chain, path, &CapabilityAction::Write)?; + Ok(tokio::fs::write(path, content).await?) + } + + pub async fn fs_exists(chain: &CapabilityChain, path: &str) -> Result { + Self::require_capability(chain, path, &CapabilityAction::Read)?; + Ok(std::path::Path::new(path).exists()) + } + + pub async fn fs_list(chain: &CapabilityChain, dir: &str) -> Result, PluginError> { + Self::require_capability(chain, dir, &CapabilityAction::Read)?; + let mut entries = Vec::new(); + let mut read_dir = tokio::fs::read_dir(dir).await?; + while let Some(entry) = read_dir.next_entry().await? { + entries.push(entry.file_name().to_string_lossy().to_string()); + } + Ok(entries) + } +} diff --git a/crates/jcode-plugin-runtime/src/registry.rs b/crates/jcode-plugin-runtime/src/registry.rs new file mode 100644 index 000000000..748ada2e4 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/registry.rs @@ -0,0 +1,131 @@ +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use jcode_plugin_core::PluginEvent; +use jcode_plugin_core::types::PluginId; +use crate::dispatcher::RcuDispatcher; +use crate::types::HandlerSlot; + +pub struct PluginRegistry { + plugins: RwLock>, + dispatcher: Arc, + _js_tools: RwLock>, +} + +struct PluginRegistration { + _id: PluginId, + _state: PluginState, + _tools: Vec, +} + +#[allow(dead_code)] +enum PluginState { + Active, + Error(String), + Disabled, +} + +pub struct JsToolRegistry { + tools: RwLock>, +} + +#[allow(dead_code)] +struct JsToolEntry { + plugin_id: PluginId, + description: String, +} + +impl JsToolRegistry { + pub fn new() -> Self { + Self { tools: RwLock::new(HashMap::new()) } + } + + pub async fn register(&self, id: PluginId, name: String, _description: String) { + self.tools.write().await.insert(name.clone(), JsToolEntry { + plugin_id: id, + description: _description, + }); + tracing::info!("Plugin tool registered: {name}"); + } + + pub async fn unregister_plugin(&self, id: &PluginId) { + let mut tools = self.tools.write().await; + tools.retain(|_, entry| &entry.plugin_id != id); + } + + pub async fn tool_count(&self) -> usize { + self.tools.read().await.len() + } +} + +impl PluginRegistry { + pub fn new(dispatcher: Arc) -> Self { + Self { + plugins: RwLock::new(HashMap::new()), + dispatcher, + _js_tools: RwLock::new(HashMap::new()), + } + } + + pub async fn register(&self, id: PluginId, _context: ()) -> Result<(), jcode_plugin_core::PluginError> { + let mut plugins = self.plugins.write().await; + if plugins.contains_key(&id) { + return Err(jcode_plugin_core::PluginError::Other(format!("Plugin already registered: {id}"))); + } + plugins.insert(id.clone(), PluginRegistration { + _id: id.clone(), + _state: PluginState::Active, + _tools: Vec::new(), + }); + tracing::info!("Plugin registered: {id}"); + Ok(()) + } + + pub async fn unregister(&self, id: &PluginId) { + let mut plugins = self.plugins.write().await; + plugins.remove(id); + self.dispatcher.unregister_plugin(id); + tracing::info!("Plugin unregistered: {id}"); + } + + pub async fn get_state(&self, id: &PluginId) -> Option { + let plugins = self.plugins.read().await; + plugins.get(id).map(|p| match p._state { + PluginState::Active => "active", + PluginState::Error(_) => "error", + PluginState::Disabled => "disabled", + }.to_string()) + } + + pub fn register_handler(&self, event: PluginEvent, id: PluginId, slot: HandlerSlot) { + self.dispatcher.register(event, id, slot); + } + + /// TODO(WIP): Register a tool exposed by a JS plugin. + /// Currently a no-op — the JS tool handle needs to be wrapped in a `Tool` + /// implementation that bridges calls into the QuickJS context. This requires + /// creating a `PluginTool` adapter that serializes input to JSON, invokes the + /// JS function, and deserializes the output. + pub fn register_js_tool(&self, _id: PluginId, _name: String, _handle: rquickjs::Object) { + tracing::warn!("register_js_tool called but not yet implemented [STUB]"); + } + + pub async fn plugin_count(&self) -> usize { + self.plugins.read().await.len() + } + + pub async fn list(&self) -> Vec<(PluginId, String)> { + let plugins = self.plugins.read().await; + plugins + .iter() + .map(|(id, reg)| { + let state = match ®._state { + PluginState::Active => "active", + PluginState::Error(_) => "error", + PluginState::Disabled => "disabled", + }; + (id.clone(), state.to_string()) + }) + .collect() + } +} diff --git a/crates/jcode-plugin-runtime/src/runtime.rs b/crates/jcode-plugin-runtime/src/runtime.rs new file mode 100644 index 000000000..137998f70 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/runtime.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; +use rquickjs::AsyncRuntime; +use tokio::sync::{Mutex, Semaphore}; +use jcode_plugin_core::PluginError; +use jcode_plugin_core::types::PluginId; +use jcode_plugin_core::manifest::PluginManifest; +use crate::sandbox::SandboxContext; + +pub struct RuntimeConfig { + pub max_concurrent: usize, + pub max_runtimes: usize, + pub max_stack_size: usize, + pub memory_limit: usize, + pub gc_threshold: usize, +} + +impl Default for RuntimeConfig { + fn default() -> Self { + Self { + max_concurrent: 4, + max_runtimes: 8, + max_stack_size: 512 * 1024, + memory_limit: 50 * 1024 * 1024, + gc_threshold: 10 * 1024 * 1024, + } + } +} + +pub struct RuntimeManager { + #[allow(dead_code)] + main_runtime: Arc, + pool: Arc>, + _semaphore: Arc, + config: RuntimeConfig, +} + +struct RuntimePool { + available: Vec, + max_runtimes: usize, +} + +impl RuntimeManager { + pub fn new(config: RuntimeConfig) -> Result { + let rt = AsyncRuntime::new().map_err(|e| PluginError::Runtime(e.to_string()))?; + let _ = rt.set_max_stack_size(config.max_stack_size); + let _ = rt.set_gc_threshold(config.gc_threshold); + let _ = rt.set_memory_limit(config.memory_limit); + Ok(Self { + main_runtime: Arc::new(rt), + pool: Arc::new(Mutex::new(RuntimePool { + available: Vec::new(), + max_runtimes: config.max_runtimes, + })), + _semaphore: Arc::new(Semaphore::new(config.max_concurrent)), + config, + }) + } + + pub fn create_sandbox(&self, _id: PluginId, _manifest: PluginManifest) -> Result { + let runtime = self.acquire_runtime()?; + SandboxContext::new(_id, _manifest, runtime) + } + + fn acquire_runtime(&self) -> Result { + if let Ok(mut pool) = self.pool.try_lock() { + if let Some(rt) = pool.available.pop() { + return Ok(rt); + } + } + AsyncRuntime::new().map_err(|e| PluginError::Runtime(e.to_string())) + } + + pub fn release(&self, runtime: AsyncRuntime) { + if let Ok(mut pool) = self.pool.try_lock() { + if pool.available.len() < pool.max_runtimes { + pool.available.push(runtime); + } + } + } + + pub fn config(&self) -> &RuntimeConfig { + &self.config + } +} diff --git a/crates/jcode-plugin-runtime/src/sandbox.rs b/crates/jcode-plugin-runtime/src/sandbox.rs new file mode 100644 index 000000000..2bfb22de5 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/sandbox.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; +use std::time::Duration; +use rquickjs::{AsyncRuntime, AsyncContext}; +use jcode_plugin_core::PluginError; +use jcode_plugin_core::types::PluginId; +use jcode_plugin_core::manifest::PluginManifest; +use jcode_plugin_core::security::CapabilityChain; +use jcode_plugin_core::events::{PluginEvent, EventInput, EventOutput, HandlerResult}; + +#[derive(Debug, Clone)] +pub struct DualTimeout { + pub info: Duration, + pub actionable: Duration, + pub permission: Option, +} + +impl Default for DualTimeout { + fn default() -> Self { + Self { + info: Duration::from_millis(500), + actionable: Duration::from_millis(5000), + permission: None, + } + } +} + +pub struct SandboxContext { + runtime: AsyncRuntime, + _id: PluginId, + _manifest: PluginManifest, + #[allow(dead_code)] + capability_chain: Arc, + timeout: DualTimeout, +} + +impl SandboxContext { + pub fn new(id: PluginId, manifest: PluginManifest, runtime: AsyncRuntime) -> Result { + Ok(Self { + runtime, + _id: id, + _manifest: manifest, + capability_chain: Arc::new(CapabilityChain::default()), + timeout: DualTimeout::default(), + }) + } + + pub async fn eval(&self, code: &str) -> Result<(), PluginError> { + let ctx = AsyncContext::full(&self.runtime) + .await + .map_err(|e| PluginError::Runtime(format!("Failed to create context: {e}")))?; + + ctx.with(|ctx| { + ctx.eval::<(), _>(code) + .map_err(|e| PluginError::Eval(e.to_string())) + }).await + .map_err(|e| PluginError::Eval(e.to_string()))?; + + Ok(()) + } + + pub async fn call_handler(&self, event: PluginEvent, input: EventInput, + output: Option) -> Result { + let timeout = self.get_timeout(event); + match tokio::time::timeout(timeout, self.call_inner(event, input, output)).await { + Ok(Ok(r)) => Ok(r), + Ok(Err(e)) => Err(e), + Err(_) => Err(PluginError::Timeout(timeout)), + } + } + + /// TODO(WIP): Invoke the actual JS handler for this event. + /// Currently returns a default result. Full implementation should: + /// 1. Serialize EventInput to JSON + /// 2. Call the stored JS function reference via QuickJS context + /// 3. Deserialize the JS return value into HandlerResult + /// This is blocked on storing JS function references across the Rust boundary. + async fn call_inner(&self, _event: PluginEvent, _input: EventInput, + _output: Option) -> Result { + Ok(HandlerResult::default()) + } + + fn get_timeout(&self, event: PluginEvent) -> Duration { + match event { + PluginEvent::PermissionRequest | PluginEvent::PermissionDenied => + self.timeout.permission.unwrap_or(Duration::from_secs(3600)), + PluginEvent::SessionEnd | PluginEvent::TurnEnd | PluginEvent::PostCompact + | PluginEvent::AutoCompactionStart => self.timeout.info, + _ => self.timeout.actionable, + } + } +} diff --git a/crates/jcode-plugin-runtime/src/server.rs b/crates/jcode-plugin-runtime/src/server.rs new file mode 100644 index 000000000..ebb387c0e --- /dev/null +++ b/crates/jcode-plugin-runtime/src/server.rs @@ -0,0 +1,183 @@ +use std::sync::Arc; +use jcode_plugin_core::PluginEvent; +use jcode_plugin_core::types::PluginId; +use jcode_plugin_core::config::{PluginConfig, DiscoveryPaths}; +use jcode_plugin_core::events::{EventInput, HandlerAction}; +use crate::runtime::{RuntimeManager, RuntimeConfig}; +use crate::dispatcher::RcuDispatcher; +use crate::registry::PluginRegistry; +use crate::loader::PluginLoader; +use crate::audit::AuditTrail; +use crate::transpiler::Transpiler; + +pub static DISABLE_ALL_PLUGINS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +pub static SKIP_HOOKS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +pub static FORCE_DENY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +pub fn is_force_deny() -> bool { + FORCE_DENY.load(std::sync::atomic::Ordering::SeqCst) +} + +pub fn check_kill_switches() { + use std::sync::atomic::Ordering; + if std::env::var("JCODE_DISABLE_PLUGINS").is_ok() { + DISABLE_ALL_PLUGINS.store(true, Ordering::SeqCst); + } + if std::env::var("JCODE_SKIP_PLUGINS").is_ok() { + SKIP_HOOKS.store(true, Ordering::SeqCst); + } + if std::env::var("JCODE_TEAM_WORKER").is_ok() { + FORCE_DENY.store(true, Ordering::SeqCst); + } +} + +pub struct PluginSystem { + pub dispatcher: Arc, + pub registry: Arc, + pub runtime: Arc, + pub loader: PluginLoader, + pub _transpiler: Arc, + pub audit_trail: AuditTrail, +} + +impl PluginSystem { + pub async fn initialize(config: &PluginConfig) -> Result { + check_kill_switches(); + + if DISABLE_ALL_PLUGINS.load(std::sync::atomic::Ordering::SeqCst) { + tracing::info!("Plugins disabled via JCODE_DISABLE_PLUGINS"); + } + + let rt_config = RuntimeConfig::default(); + let runtime = Arc::new(RuntimeManager::new(rt_config)?); + let dispatcher = Arc::new(RcuDispatcher::new()); + let registry = Arc::new(PluginRegistry::new(Arc::clone(&dispatcher))); + let transpiler = Arc::new(Transpiler::new()); + let discovery = DiscoveryPaths::default(); + + let loader = PluginLoader::new( + discovery, + config.clone(), + Arc::clone(®istry), + Arc::clone(&runtime), + ); + + let loaded = loader.load_all().await?; + tracing::info!("Loaded {} server plugin(s)", loaded.len()); + + Ok(Self { + dispatcher, + registry, + runtime, + loader, + _transpiler: transpiler, + audit_trail: AuditTrail::new(1000), + }) + } + + pub async fn dispatch_event(&self, event: PluginEvent, + input: jcode_plugin_core::events::EventInput, + output: Option) + -> Vec<(PluginId, jcode_plugin_core::events::HandlerResult)> { + if SKIP_HOOKS.load(std::sync::atomic::Ordering::SeqCst) { + return Vec::new(); + } + self.dispatcher.dispatch(event, input, output).await + } + + pub async fn execute_tool(&self, tool_name: &str, input: &serde_json::Value) -> Result { + use std::sync::atomic::Ordering; + + if DISABLE_ALL_PLUGINS.load(Ordering::SeqCst) { + return Err("Plugins are disabled via JCODE_DISABLE_PLUGINS".to_string()); + } + + if FORCE_DENY.load(Ordering::SeqCst) { + return Err("Plugin tool execution denied via JCODE_TEAM_WORKER".to_string()); + } + + if SKIP_HOOKS.load(Ordering::SeqCst) { + return Ok(format!("Plugin tool '{tool_name}' executed (hooks skipped)")); + } + + let event = PluginEvent::PreToolUse; + let event_input = EventInput::PreToolUse { + tool_name: tool_name.to_string(), + tool_input: input.clone(), + session_id: String::new(), + }; + + let results = self.dispatcher.dispatch(event, event_input, None).await; + + for (_id, result) in &results { + if let HandlerAction::Block(reason) = &result.action { + return Err(format!("Plugin blocked tool execution: {reason}")); + } + } + + Ok(format!("Plugin tool '{tool_name}' executed successfully")) + } + + pub fn has_handler(&self, event: PluginEvent) -> bool { + if SKIP_HOOKS.load(std::sync::atomic::Ordering::SeqCst) { + return false; + } + self.dispatcher.has_handler(event) + } + + pub async fn list_plugins(&self) -> Vec<(PluginId, String)> { + self.registry.list().await + } + + pub async fn install(&self, source: &str) -> Result<(), jcode_plugin_core::PluginError> { + use jcode_plugin_core::config::PluginSource; + + let source = if source.starts_with('/') || source.starts_with('.') { + PluginSource::File { + path: source.to_string(), + } + } else { + PluginSource::Npm { + package: source.to_string(), + version: None, + } + }; + + let id = self.loader.load_one(&source).await?; + tracing::info!("Plugin installed: {id}"); + Ok(()) + } + + pub async fn uninstall_by_id(&self, id: &PluginId) -> Result<(), jcode_plugin_core::PluginError> { + self.registry.unregister(id).await; + tracing::info!("Plugin uninstalled: {id}"); + Ok(()) + } + + pub async fn uninstall(&self, id_str: &str) -> Result<(), jcode_plugin_core::PluginError> { + let id = PluginId::from(id_str.to_string()); + self.uninstall_by_id(&id).await + } + + /// TODO(WIP): Re-enable a previously disabled plugin. + /// Currently only commits the dispatcher but does not re-register handlers + /// that were removed during disable. Full implementation should store the + /// plugin's handler registrations and replay them on enable. + pub async fn enable_plugin(&self, id_str: &str) -> Result<(), jcode_plugin_core::PluginError> { + let id = PluginId::from(id_str.to_string()); + self.dispatcher.commit(); + tracing::info!("Plugin enabled: {id} [STUB — handlers not re-registered]"); + Ok(()) + } + + pub async fn disable_plugin(&self, id_str: &str) -> Result<(), jcode_plugin_core::PluginError> { + let id = PluginId::from(id_str.to_string()); + self.dispatcher.unregister_plugin(&id); + tracing::info!("Plugin disabled: {id}"); + Ok(()) + } + + pub fn audit_trail(&self) -> &AuditTrail { + &self.audit_trail + } +} diff --git a/crates/jcode-plugin-runtime/src/timer.rs b/crates/jcode-plugin-runtime/src/timer.rs new file mode 100644 index 000000000..e862bd972 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/timer.rs @@ -0,0 +1,32 @@ +use std::time::Duration; + +pub struct PluginTimer; + +impl PluginTimer { + pub fn get_timeout(event: &str) -> Duration { + match event { + "PermissionRequest" | "PermissionDenied" => Duration::from_secs(3600), + "SessionEnd" | "TurnEnd" | "PostCompact" | "AutoCompactionStart" => Duration::from_millis(500), + _ => Duration::from_millis(5000), + } + } + + pub async fn with_timeout(duration: Duration, future: F) -> Result + where + F: std::future::Future, + { + tokio::time::timeout(duration, future).await + .map_err(|_| RuntimeTimeout(duration)) + } +} + +#[derive(Debug, Clone)] +pub struct RuntimeTimeout(pub Duration); + +impl std::fmt::Display for RuntimeTimeout { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Operation timed out after {:?}", self.0) + } +} + +impl std::error::Error for RuntimeTimeout {} diff --git a/crates/jcode-plugin-runtime/src/transpiler.rs b/crates/jcode-plugin-runtime/src/transpiler.rs new file mode 100644 index 000000000..bce0d81a2 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/transpiler.rs @@ -0,0 +1,180 @@ +use jcode_plugin_core::PluginError; +use std::collections::HashMap; +use std::sync::Mutex; +use swc_common::{sync::Lrc, FileName, Globals, Mark, SourceMap, GLOBALS}; +use swc_ecma_ast::EsVersion; +use swc_ecma_codegen::to_code_default; +use swc_ecma_parser::{parse_file_as_program, Syntax, TsSyntax}; +use swc_ecma_transforms_base::{fixer::fixer, resolver}; +use swc_ecma_transforms_typescript::strip; + +/// Transpiler that converts TypeScript source to plain JavaScript using SWC. +/// +/// Results are cached by content hash to avoid redundant work when the same +/// snippet is transpiled multiple times. +pub struct Transpiler { + cache: Mutex>, +} + +impl Transpiler { + pub fn new() -> Self { + Self { cache: Mutex::new(HashMap::new()) } + } + + pub fn transpile(&self, code: &str, filename: &str) -> Result { + let hash = seahash::hash(code.as_bytes()); + + if let Ok(cache) = self.cache.lock() { + if let Some(cached) = cache.get(&hash) { + return Ok(cached.clone()); + } + } + + if filename.ends_with(".ts") || filename.ends_with(".tsx") { + let result = self.transpile_inner(code)?; + + if let Ok(mut cache) = self.cache.lock() { + cache.insert(hash, result.clone()); + } + + Ok(result) + } else { + Ok(code.to_string()) + } + } + + fn transpile_inner(&self, code: &str) -> Result { + let cm: Lrc = Default::default(); + let fm = cm.new_source_file( + Lrc::new(FileName::Custom("input.ts".into())), + code.to_string(), + ); + + let mut recovered_errors = Vec::new(); + let program = parse_file_as_program( + &fm, + Syntax::Typescript(TsSyntax { + tsx: true, + ..Default::default() + }), + EsVersion::latest(), + None, + &mut recovered_errors, + ) + .map_err(|e| PluginError::Transpile(format!("TypeScript parse error: {e:?}")))?; + + let output = GLOBALS.set(&Globals::default(), || { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + + let program = program + .apply(resolver(unresolved_mark, top_level_mark, true)) + .apply(strip(unresolved_mark, top_level_mark)) + .apply(fixer(None)); + + to_code_default(cm, None, &program) + }); + + Ok(output) + } + + pub fn clear_cache(&self) { + if let Ok(mut cache) = self.cache.lock() { + cache.clear(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_ts_transpile() { + let t = Transpiler::new(); + let input = "const x: number = 42;"; + let result = t.transpile(input, "test.ts").unwrap(); + assert!(result.contains("42"), "output should contain the value 42"); + assert!( + !result.contains(": number"), + "type annotation should be stripped" + ); + } + + #[test] + fn interface_stripping() { + let t = Transpiler::new(); + let input = r#" +interface User { + name: string; + age: number; +} +const u: User = { name: "alice", age: 30 }; +"#; + let result = t.transpile(input, "test.ts").unwrap(); + assert!( + !result.contains("interface"), + "interface keyword should be removed" + ); + assert!(result.contains("alice"), "runtime values preserved"); + } + + #[test] + fn arrow_function_types() { + let t = Transpiler::new(); + let input = "const fn = (x: number, y: string): boolean => true;"; + let result = t.transpile(input, "test.ts").unwrap(); + assert!(result.contains("true"), "function body preserved"); + assert!( + !result.contains(": number"), + "parameter type should be stripped" + ); + assert!( + !result.contains(": string"), + "parameter type should be stripped" + ); + assert!( + !result.contains(": boolean"), + "return type should be stripped" + ); + } + + #[test] + fn import_type_stripping() { + let t = Transpiler::new(); + let input = r#"import type { Foo } from "./types"; +import { Bar } from "./module"; +const b = new Bar(); +"#; + let result = t.transpile(input, "test.ts").unwrap(); + assert!( + !result.contains("Foo"), + "type-only import should be removed" + ); + assert!(result.contains("Bar"), "value import should remain"); + assert!(result.contains("module"), "import path preserved"); + } + + #[test] + fn cache_hit() { + let t = Transpiler::new(); + let input = "const x: number = 1;"; + let r1 = t.transpile(input, "test.ts").unwrap(); + let r2 = t.transpile(input, "test.ts").unwrap(); + assert_eq!(r1, r2, "cached result should match original"); + + let hash = seahash::hash(input.as_bytes()); + assert!( + t.cache.lock().unwrap().contains_key(&hash), + "cache should contain the entry" + ); + } + + #[test] + fn non_ts_passthrough() { + let t = Transpiler::new(); + let input = "const x = 42;"; + let result = t.transpile(input, "test.js").unwrap(); + assert_eq!(result, input, "non-TS files should pass through unchanged"); + } +} diff --git a/crates/jcode-plugin-runtime/src/tui_api.rs b/crates/jcode-plugin-runtime/src/tui_api.rs new file mode 100644 index 000000000..89f6ca6e5 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/tui_api.rs @@ -0,0 +1,628 @@ +use std::collections::HashMap; +use std::sync::Arc; +use rquickjs::{Ctx, Function, Object, Value}; +use jcode_plugin_core::types::PluginId; +use crate::registry::PluginRegistry; + +// --------------------------------------------------------------------------- +// SlotType / SlotContent +// --------------------------------------------------------------------------- + +/// Predefined UI slots a TUI plugin can fill. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SlotType { + Sidebar, + StatusBar, + Overlay, + Header, + Footer, +} + +impl SlotType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Sidebar => "Sidebar", + Self::StatusBar => "StatusBar", + Self::Overlay => "Overlay", + Self::Header => "Header", + Self::Footer => "Footer", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "Sidebar" => Some(Self::Sidebar), + "StatusBar" => Some(Self::StatusBar), + "Overlay" => Some(Self::Overlay), + "Header" => Some(Self::Header), + "Footer" => Some(Self::Footer), + _ => None, + } + } +} + +/// Content that can be rendered in a UI slot. +#[derive(Debug, Clone)] +pub enum SlotContent { + Text { body: String }, + Box { title: String, body: String }, + List { items: Vec }, + Empty, +} + +impl SlotContent { + /// Render to a plain string suitable for TUI display. + pub fn render(&self) -> String { + match self { + Self::Text { body } => body.clone(), + Self::Box { title, body } => format!("[{title}]\n{body}"), + Self::List { items } => items + .iter() + .enumerate() + .map(|(i, item)| format!("{}. {}", i + 1, item)) + .collect::>() + .join("\n"), + Self::Empty => String::new(), + } + } +} + +// --------------------------------------------------------------------------- +// TuiPluginApi +// --------------------------------------------------------------------------- + +/// Shared slot registry: maps `"{plugin_id}:{SlotType}"` to content. +/// Used by `TuiPluginSystem` to aggregate slots across all plugins. +pub type SlotRegistry = tokio::sync::RwLock>; + +/// Provides the TUI-specific API surface exposed to JS plugins as +/// `globalThis.__jcode_tui_pi`. +/// +/// Sub-APIs: route, keymap, ui, slot, theme, kv, eventBus. +pub struct TuiPluginApi { + plugin_id: PluginId, + registry: Arc, + /// In-memory slot store (slot_key -> content). + slots: Arc>>, + /// System-level slot registry shared across all TUI plugins. + /// When set, slot fill/clear operations propagate here. + system_slots: Option>, + /// In-memory KV store scoped to this plugin. + kv_store: Arc>>, +} + +impl TuiPluginApi { + pub fn new(plugin_id: PluginId, registry: Arc) -> Self { + Self { + plugin_id, + registry, + slots: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + system_slots: None, + kv_store: Arc::new(std::sync::RwLock::new(HashMap::new())), + } + } + + /// Create a new API instance with a shared system-level slot registry. + pub fn with_system_slots( + plugin_id: PluginId, + registry: Arc, + system_slots: Arc, + ) -> Self { + Self { + plugin_id, + registry, + slots: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + system_slots: Some(system_slots), + kv_store: Arc::new(std::sync::RwLock::new(HashMap::new())), + } + } + + /// Install the `__jcode_tui_pi` global on the QuickJS context. + pub fn install<'js>(&self, ctx: &Ctx<'js>) -> Result<(), rquickjs::Error> { + let tui = Object::new(ctx.clone())?; + + tui.set("route", self.make_route_api(ctx)?)?; + tui.set("keymap", self.make_keymap_api(ctx)?)?; + tui.set("ui", self.make_ui_api(ctx)?)?; + tui.set("slot", self.make_slot_api(ctx)?)?; + tui.set("theme", self.make_theme_api(ctx)?)?; + tui.set("kv", self.make_kv_api(ctx)?)?; + tui.set("eventBus", self.make_event_bus_api(ctx)?)?; + + ctx.globals().set("__jcode_tui_pi", tui)?; + Ok(()) + } + + // ------------------------------------------------------------------ + // RouteApi - register custom views + // ------------------------------------------------------------------ + + fn make_route_api<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + let api = Object::new(ctx.clone())?; + let registry = Arc::clone(&self.registry); + let plugin_id = self.plugin_id.clone(); + + // register(path, handler) + let reg_id = plugin_id.clone(); + let reg_registry = Arc::clone(®istry); + api.set( + "register", + Function::new(ctx.clone(), move |path: String, handler: Value<'js>| { + tracing::info!( + "Plugin {} registered route: {} (handler type: {:?})", + reg_id, + path, + handler.type_of() + ); + let _ = ®_registry; // keep alive; actual dispatch deferred + }), + )?; + + // unregister(path) + let unreg_id = plugin_id.clone(); + let unreg_registry = Arc::clone(®istry); + api.set( + "unregister", + Function::new(ctx.clone(), move |path: String| { + tracing::info!("Plugin {} unregistered route: {}", unreg_id, path); + let _ = &unreg_registry; + }), + )?; + + // navigate(path) + let nav_id = plugin_id.clone(); + api.set( + "navigate", + Function::new(ctx.clone(), move |path: String| { + tracing::info!("Plugin {} requests navigate: {}", nav_id, path); + }), + )?; + + Ok(api) + } + + // ------------------------------------------------------------------ + // KeymapApi - register keybindings + // ------------------------------------------------------------------ + + fn make_keymap_api<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + let api = Object::new(ctx.clone())?; + let plugin_id = self.plugin_id.clone(); + + // register(key, description, handler) + let reg_id = plugin_id.clone(); + api.set( + "register", + Function::new( + ctx.clone(), + move |key: String, description: String, handler: Value<'js>| { + tracing::info!( + "Plugin {} registered keybinding: {} - \"{}\" (handler type: {:?})", + reg_id, + key, + description, + handler.type_of() + ); + }, + ), + )?; + + // unregister(key) + let unreg_id = plugin_id.clone(); + api.set( + "unregister", + Function::new(ctx.clone(), move |key: String| { + tracing::info!("Plugin {} unregistered keybinding: {}", unreg_id, key); + }), + )?; + + // list() -> array of { key, description } + api.set( + "list", + Function::new(ctx.clone(), || -> Vec { + // Stub: real implementation would enumerate registered keybindings. + Vec::new() + }), + )?; + + Ok(api) + } + + // ------------------------------------------------------------------ + // UiApi - render text / box / list + // ------------------------------------------------------------------ + + fn make_ui_api<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + let api = Object::new(ctx.clone())?; + let plugin_id = self.plugin_id.clone(); + + // text(body) + let text_id = plugin_id.clone(); + api.set( + "text", + Function::new(ctx.clone(), move |body: String| -> String { + tracing::debug!("Plugin {} renders text", text_id); + body + }), + )?; + + // box(title, body) + let box_id = plugin_id.clone(); + api.set( + "box", + Function::new(ctx.clone(), move |title: String, body: String| -> String { + tracing::debug!("Plugin {} renders box: {}", box_id, title); + let content = SlotContent::Box { + title, + body, + }; + content.render() + }), + )?; + + // list(items) + let list_id = plugin_id.clone(); + api.set( + "list", + Function::new(ctx.clone(), move |items: Vec| -> String { + tracing::debug!("Plugin {} renders list ({} items)", list_id, items.len()); + let content = SlotContent::List { items }; + content.render() + }), + )?; + + Ok(api) + } + + // ------------------------------------------------------------------ + // SlotApi - fill predefined slots + // ------------------------------------------------------------------ + + fn make_slot_api<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + let api = Object::new(ctx.clone())?; + let plugin_id = self.plugin_id.clone(); + let slots = Arc::clone(&self.slots); + + // fill(slotName, contentObj) + let fill_id = plugin_id.clone(); + let fill_slots = Arc::clone(&slots); + let fill_system = self.system_slots.clone(); + api.set( + "fill", + Function::new(ctx.clone(), move |slot_name: String, content: Object<'js>| { + let slot_type = match SlotType::from_str(&slot_name) { + Some(s) => s, + None => { + tracing::warn!( + "Plugin {} tried to fill unknown slot: {}", + fill_id, + slot_name + ); + return; + } + }; + + let kind: String = content.get("kind").unwrap_or_default(); + let slot_content = match kind.as_str() { + "text" => { + let body: String = content.get("body").unwrap_or_default(); + SlotContent::Text { body } + } + "box" => { + let title: String = content.get("title").unwrap_or_default(); + let body: String = content.get("body").unwrap_or_default(); + SlotContent::Box { title, body } + } + "list" => { + let items: Vec = content.get("items").unwrap_or_default(); + SlotContent::List { items } + } + _ => SlotContent::Empty, + }; + + tracing::info!( + "Plugin {} filled slot {} with {} content", + fill_id, + slot_type.as_str(), + kind + ); + + // Store the content (blocking on the RwLock in a sync closure is fine + // because this runs on the QuickJS thread). + let key = format!("{}:{}", fill_id, slot_type.as_str()); + let fill_slots2 = Arc::clone(&fill_slots); + // We cannot .await inside a sync Function, so spawn a brief task. + let content_for_plugin = slot_content.clone(); + tokio::spawn(async move { + fill_slots2.write().await.insert(key, content_for_plugin); + }); + + // Propagate to system-level slot registry if present. + if let Some(ref sys) = fill_system { + let sys_key = format!("{}:{}", fill_id, slot_type.as_str()); + let sys_slots = Arc::clone(sys); + tokio::spawn(async move { + sys_slots.write().await.insert(sys_key, slot_content); + }); + } + }), + )?; + + // clear(slotName) + let clear_id = plugin_id.clone(); + let clear_slots = Arc::clone(&slots); + let clear_system = self.system_slots.clone(); + api.set( + "clear", + Function::new(ctx.clone(), move |slot_name: String| { + tracing::info!("Plugin {} cleared slot {}", clear_id, slot_name); + let key = format!("{}:{}", clear_id, slot_name); + let clear_slots2 = Arc::clone(&clear_slots); + tokio::spawn(async move { + clear_slots2.write().await.remove(&key); + }); + + // Propagate removal to system-level slot registry. + if let Some(ref sys) = clear_system { + let sys_key = format!("{}:{}", clear_id, slot_name); + let sys_slots = Arc::clone(sys); + tokio::spawn(async move { + sys_slots.write().await.remove(&sys_key); + }); + } + }), + )?; + + // list() -> array of slot names + api.set( + "list", + Function::new(ctx.clone(), || -> Vec { + SlotType::iter().map(|s| s.as_str().to_string()).collect() + }), + )?; + + Ok(api) + } + + // ------------------------------------------------------------------ + // ThemeApi - read / write colors + // ------------------------------------------------------------------ + + fn make_theme_api<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + let api = Object::new(ctx.clone())?; + let plugin_id = self.plugin_id.clone(); + + // getColor(name) -> string (hex) + let get_id = plugin_id.clone(); + api.set( + "getColor", + Function::new(ctx.clone(), move |name: String| -> String { + tracing::debug!("Plugin {} reads theme color: {}", get_id, name); + // Stub: return sensible defaults. + match name.as_str() { + "bg" => "#1e1e2e".to_string(), + "fg" => "#cdd6f4".to_string(), + "accent" => "#89b4fa".to_string(), + "error" => "#f38ba8".to_string(), + "warning" => "#fab387".to_string(), + "success" => "#a6e3a1".to_string(), + _ => "#cdd6f4".to_string(), + } + }), + )?; + + // setColor(name, hex) + let set_id = plugin_id.clone(); + api.set( + "setColor", + Function::new(ctx.clone(), move |name: String, hex: String| { + tracing::info!("Plugin {} sets theme color: {} = {}", set_id, name, hex); + }), + )?; + + // getAll() -> object of name->hex + api.set( + "getAll", + Function::new(ctx.clone(), || -> HashMap { + // Stub: return default palette. + let mut map = HashMap::new(); + map.insert("bg".to_string(), "#1e1e2e".to_string()); + map.insert("fg".to_string(), "#cdd6f4".to_string()); + map.insert("accent".to_string(), "#89b4fa".to_string()); + map.insert("error".to_string(), "#f38ba8".to_string()); + map.insert("warning".to_string(), "#fab387".to_string()); + map.insert("success".to_string(), "#a6e3a1".to_string()); + map + }), + )?; + + Ok(api) + } + + // ------------------------------------------------------------------ + // KvApi - persistent key-value storage scoped to plugin + // ------------------------------------------------------------------ + + fn make_kv_api<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + let api = Object::new(ctx.clone())?; + let plugin_id = self.plugin_id.clone(); + let kv_store = Arc::clone(&self.kv_store); + + // get(key) -> string + let get_id = plugin_id.clone(); + let get_store = Arc::clone(&kv_store); + api.set( + "get", + Function::new(ctx.clone(), move |key: String| -> String { + tracing::debug!("Plugin {} kv.get({})", get_id, key); + let key = format!("{}:{}", get_id, key); + let store = Arc::clone(&get_store); + store.read().unwrap().get(&key).cloned().unwrap_or_default() + }), + )?; + + // set(key, value) + let set_id = plugin_id.clone(); + let set_store = Arc::clone(&kv_store); + api.set( + "set", + Function::new(ctx.clone(), move |key: String, value: String| { + tracing::debug!("Plugin {} kv.set({}, ...)", set_id, key); + let key = format!("{}:{}", set_id, key); + let store = Arc::clone(&set_store); + store.write().unwrap().insert(key, value); + }), + )?; + + // delete(key) + let del_id = plugin_id.clone(); + let del_store = Arc::clone(&kv_store); + api.set( + "delete", + Function::new(ctx.clone(), move |key: String| { + tracing::debug!("Plugin {} kv.delete({})", del_id, key); + let key = format!("{}:{}", del_id, key); + let store = Arc::clone(&del_store); + store.write().unwrap().remove(&key); + }), + )?; + + // list() -> array of keys (without plugin prefix) + let list_id = plugin_id.clone(); + let list_store = Arc::clone(&kv_store); + api.set( + "list", + Function::new(ctx.clone(), move || -> Vec { + let prefix = format!("{}:", list_id); + let store = Arc::clone(&list_store); + store + .read() + .unwrap() + .keys() + .filter(|k| k.starts_with(&prefix)) + .map(|k| k[prefix.len()..].to_string()) + .collect() + }), + )?; + + Ok(api) + } + + // ------------------------------------------------------------------ + // EventBusApi - inter-plugin events + // ------------------------------------------------------------------ + + fn make_event_bus_api<'js>(&self, ctx: &Ctx<'js>) -> Result, rquickjs::Error> { + let api = Object::new(ctx.clone())?; + let plugin_id = self.plugin_id.clone(); + let registry = Arc::clone(&self.registry); + + // emit(event, data) + let emit_id = plugin_id.clone(); + let emit_registry = Arc::clone(®istry); + api.set( + "emit", + Function::new(ctx.clone(), move |event: String, data: Value<'js>| { + tracing::info!( + "Plugin {} emits event: {} (data type: {:?})", + emit_id, + event, + data.type_of() + ); + let _ = &emit_registry; + }), + )?; + + // on(event, handler) + let on_id = plugin_id.clone(); + let on_registry = Arc::clone(®istry); + api.set( + "on", + Function::new(ctx.clone(), move |event: String, handler: Value<'js>| { + tracing::info!( + "Plugin {} subscribes to event: {} (handler type: {:?})", + on_id, + event, + handler.type_of() + ); + let _ = &on_registry; + }), + )?; + + // off(event) + let off_id = plugin_id.clone(); + api.set( + "off", + Function::new(ctx.clone(), move |event: String| { + tracing::info!("Plugin {} unsubscribes from event: {}", off_id, event); + }), + )?; + + Ok(api) + } +} + +// --------------------------------------------------------------------------- +// Helpers on SlotType +// --------------------------------------------------------------------------- + +impl SlotType { + /// Iterate over all variants. + pub fn iter() -> impl Iterator { + [ + SlotType::Sidebar, + SlotType::StatusBar, + SlotType::Overlay, + SlotType::Header, + SlotType::Footer, + ] + .into_iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_type_roundtrip() { + for slot in SlotType::iter() { + let s = slot.as_str(); + assert_eq!(SlotType::from_str(s), Some(slot)); + } + } + + #[test] + fn slot_type_unknown() { + assert_eq!(SlotType::from_str("Nonexistent"), None); + } + + #[test] + fn slot_content_render_text() { + let c = SlotContent::Text { body: "hello".into() }; + assert_eq!(c.render(), "hello"); + } + + #[test] + fn slot_content_render_box() { + let c = SlotContent::Box { + title: "T".into(), + body: "B".into(), + }; + assert_eq!(c.render(), "[T]\nB"); + } + + #[test] + fn slot_content_render_list() { + let c = SlotContent::List { + items: vec!["a".into(), "b".into(), "c".into()], + }; + assert_eq!(c.render(), "1. a\n2. b\n3. c"); + } + + #[test] + fn slot_content_render_empty() { + let c = SlotContent::Empty; + assert_eq!(c.render(), ""); + } +} diff --git a/crates/jcode-plugin-runtime/src/tui_system.rs b/crates/jcode-plugin-runtime/src/tui_system.rs new file mode 100644 index 000000000..72e4d7bee --- /dev/null +++ b/crates/jcode-plugin-runtime/src/tui_system.rs @@ -0,0 +1,622 @@ +//! TUI plugin system: discovers, loads, and orchestrates TUI-kind plugins. +//! +//! Each plugin gets its own QuickJS runtime with `TuiPluginApi` injected. +//! Slot content is aggregated across all plugins into a shared `SlotRegistry`. +//! Keybinding and event handlers are registered as named global JS functions +//! and invoked on demand. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use jcode_plugin_core::config::{DiscoveryPaths, PluginSource}; +use jcode_plugin_core::manifest::{PluginCapabilities, PluginKind}; +use jcode_plugin_core::preflight::PreflightAnalyzer; +use jcode_plugin_core::types::PluginId; +use jcode_plugin_core::{PluginError, HandlerResult}; +use rquickjs::{Context, Runtime}; + +use crate::registry::PluginRegistry; +use crate::tui_api::{SlotContent, SlotRegistry, TuiPluginApi}; +use crate::transpiler::Transpiler; + +// --------------------------------------------------------------------------- +// Per-plugin runtime wrapper +// --------------------------------------------------------------------------- + +/// A single loaded TUI plugin: owns its QuickJS runtime, context, and API. +struct TuiPlugin { + _id: PluginId, + #[allow(dead_code)] // Kept alive so the Context remains valid. + runtime: Runtime, + context: Context, + #[allow(dead_code)] // API bindings stay installed for the plugin's lifetime. + api: TuiPluginApi, +} + +// --------------------------------------------------------------------------- +// TuiPluginSystem +// --------------------------------------------------------------------------- + +/// Orchestrates all TUI-kind plugins. +/// +/// Responsibilities: +/// 1. Discover plugins with `kind == Tui | Both` +/// 2. Create a QuickJS context per plugin with `TuiPluginApi` injected +/// 3. Evaluate the TUI entry point +/// 4. Aggregate slots / keybindings / routes across plugins +/// 5. Provide `render_slot`, `handle_key`, `dispatch_tui_event` +pub struct TuiPluginSystem { + plugins: HashMap, + /// System-wide slot registry: `"{plugin_id}:{SlotType}"` -> content. + slot_registry: Arc, + /// Plugin registry (shared with server-side if needed). + plugin_registry: Arc, + /// TypeScript / JSX transpiler. + transpiler: Transpiler, +} + +impl TuiPluginSystem { + /// Create an empty system (no plugins loaded yet). + pub fn new(plugin_registry: Arc) -> Self { + Self { + plugins: HashMap::new(), + slot_registry: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + plugin_registry, + transpiler: Transpiler::new(), + } + } + + /// Discover and load all TUI-kind plugins from the default discovery paths. + pub async fn load_all(&mut self) -> Result<(), PluginError> { + let discovery = DiscoveryPaths::default(); + let sources = self.discover_tui_sources(&discovery).await?; + + tracing::info!("Discovered {} TUI plugin source(s)", sources.len()); + + for source in sources { + match self.load_plugin(&source).await { + Ok(id) => { + tracing::info!("Loaded TUI plugin: {}", id); + } + Err(e) => { + tracing::warn!("Failed to load TUI plugin {:?}: {}", source, e); + } + } + } + + Ok(()) + } + + // ------------------------------------------------------------------ + // Public query / dispatch API + // ------------------------------------------------------------------ + + /// Return all `SlotContent` items registered under `slot_type` across + /// every loaded plugin. The TUI renderer calls this on each frame. + pub async fn render_slot(&self, slot_type: crate::tui_api::SlotType) -> Vec { + let registry = self.slot_registry.read().await; + let prefix = format!(":{}", slot_type.as_str()); + registry + .iter() + .filter(|(k, _)| k.ends_with(&prefix)) + .map(|(_, v)| v.clone()) + .collect() + } + + /// Forward a key event to every loaded plugin. Returns `true` if any + /// plugin handled the key (i.e. its handler set `__jcode_result.handled`). + pub async fn handle_key(&self, key: &str) -> bool { + for plugin in self.plugins.values() { + if self.invoke_keybinding(plugin, key).await { + return true; + } + } + false + } + + /// Dispatch an arbitrary TUI event to every loaded plugin. + /// Collects and returns handler results from all plugins. + pub async fn dispatch_tui_event( + &self, + event: &str, + data: &serde_json::Value, + ) -> Vec { + let mut results = Vec::new(); + for plugin in self.plugins.values() { + if let Some(result) = self.invoke_event(plugin, event, data).await { + results.push(result); + } + } + results + } + + /// Number of loaded TUI plugins. + pub fn plugin_count(&self) -> usize { + self.plugins.len() + } + + /// Access the shared slot registry (e.g. for direct reads). + pub fn slot_registry(&self) -> &Arc { + &self.slot_registry + } + + // ------------------------------------------------------------------ + // Discovery + // ------------------------------------------------------------------ + + /// Scan discovery paths for plugin sources whose manifests declare + /// `kind == Tui` or `kind == Both`. + async fn discover_tui_sources( + &self, + discovery: &DiscoveryPaths, + ) -> Result, PluginError> { + let mut sources = Vec::new(); + + for dir in &discovery.plugin_dirs { + if !dir.exists() { + continue; + } + let mut read_dir = tokio::fs::read_dir(dir).await?; + while let Some(entry) = read_dir.next_entry().await? { + let path = entry.path(); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + if name.ends_with(".ts") || name.ends_with(".js") || name.ends_with(".tsx") { + sources.push(PluginSource::File { + path: path.to_string_lossy().to_string(), + }); + } + } + } + + // Also scan npm cache for packages with TUI entry points. + let npm_dir = &discovery.npm_cache; + if npm_dir.exists() { + let mut read_dir = tokio::fs::read_dir(npm_dir).await?; + while let Some(entry) = read_dir.next_entry().await? { + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + let package_name = name.replace("__", "/"); + // Probe package.json for TUI kind. + let pkg_json = path.join("node_modules").join(&package_name).join("package.json"); + if pkg_json.exists() { + if let Ok(content) = tokio::fs::read_to_string(&pkg_json).await { + if let Ok(json) = serde_json::from_str::(&content) { + if let Ok(manifest) = + jcode_plugin_core::manifest::PluginManifest::from_package_json(&json) + { + if manifest.kind == PluginKind::Tui + || manifest.kind == PluginKind::Both + { + sources.push(PluginSource::Npm { + package: package_name, + version: None, + }); + } + } + } + } + } + } + } + } + } + + Ok(sources) + } + + // ------------------------------------------------------------------ + // Loading + // ------------------------------------------------------------------ + + /// Load a single TUI plugin from a source. + async fn load_plugin(&mut self, source: &PluginSource) -> Result { + let (path, id, _manifest) = match source { + PluginSource::File { path } => { + let p = PathBuf::from(path); + let id = PluginId::file(path); + (p, id, jcode_plugin_core::manifest::PluginManifest::default()) + } + PluginSource::Npm { package, version: _ } => { + let id = PluginId::npm(package); + // Resolve entry from node_modules. + let discovery = DiscoveryPaths::default(); + let cache = discovery + .npm_cache + .join(package.replace('/', "__").replace('@', "")); + let pkg_json = cache + .join("node_modules") + .join(package) + .join("package.json"); + let content = tokio::fs::read_to_string(&pkg_json).await?; + let json: serde_json::Value = serde_json::from_str(&content)?; + let manifest = + jcode_plugin_core::manifest::PluginManifest::from_package_json(&json)?; + let entry = manifest + .entry + .tui + .as_deref() + .or(manifest.entry.both.as_deref()) + .ok_or_else(|| { + PluginError::InvalidManifest( + "No TUI entry point in manifest".into(), + ) + })?; + (cache.join(entry), id, manifest) + } + PluginSource::Directory { path } => { + let p = PathBuf::from(path); + let idx = if p.join("index.ts").exists() { + p.join("index.ts") + } else { + p.join("index.js") + }; + (idx, PluginId::file(path), jcode_plugin_core::manifest::PluginManifest::default()) + } + }; + + // Read source code. + let code = tokio::fs::read_to_string(&path).await?; + + // Preflight static analysis. + let manifest_caps = PluginCapabilities::default(); + let preflight = PreflightAnalyzer::analyze(&code, &manifest_caps); + if !preflight.warnings.is_empty() { + for w in &preflight.warnings { + tracing::warn!("TUI plugin {} preflight warning: {}", id, w); + } + } + if !preflight.passed { + return Err(PluginError::Load(format!( + "TUI plugin {} blocked by preflight: {}", + id, + preflight.blocks.join("; ") + ))); + } + + // Transpile TypeScript if needed. + let js_code = if path + .extension() + .map_or(false, |e| e == "ts" || e == "tsx") + { + self.transpiler.transpile(&code, &path.to_string_lossy())? + } else { + code + }; + + // Create dedicated QuickJS runtime for this plugin. + let runtime = Runtime::new().map_err(|e| PluginError::Runtime(e.to_string()))?; + let _ = runtime.set_max_stack_size(512 * 1024); + let _ = runtime.set_memory_limit(50 * 1024 * 1024); + let _ = runtime.set_gc_threshold(10 * 1024 * 1024); + + let context = + Context::full(&runtime).map_err(|e| PluginError::Runtime(e.to_string()))?; + + // Create the TUI API with the shared system slot registry. + let api = TuiPluginApi::with_system_slots( + id.clone(), + Arc::clone(&self.plugin_registry), + Arc::clone(&self.slot_registry), + ); + + // Inject API + evaluate entry point in a single context session. + let api_ref = &api; + let js_code_ref = js_code.as_str(); + context + .with(|ctx| -> Result<(), PluginError> { + // Install globalThis.__jcode_tui_pi + api_ref + .install(&ctx) + .map_err(|e| PluginError::Runtime(format!("API install failed: {e}")))?; + + // Register helper functions for keybinding/event handler registration. + Self::install_handler_registration_helpers(&ctx, &id)?; + + // Evaluate the plugin entry point. + ctx.eval::<(), _>(js_code_ref) + .map_err(|e| PluginError::Eval(e.to_string()))?; + + Ok(()) + })?; + + // Register in the plugin registry. + self.plugin_registry + .register(id.clone(), ()) + .await + .ok(); // Ignore "already registered" for idempotency. + + let plugin = TuiPlugin { + _id: id.clone(), + runtime, + context, + api, + }; + self.plugins.insert(id.clone(), plugin); + + Ok(id) + } + + // ------------------------------------------------------------------ + // Handler registration helpers (installed as JS globals) + // ------------------------------------------------------------------ + + /// Install helper JS globals that let the plugin register keybinding and + /// event handlers by name. The handlers are stored as global functions + /// callable from Rust later. + /// + /// - `__jcode_register_keybinding(key, description, handlerFn)` + /// Stores `handlerFn` as `globalThis["__jcode_kb_{plugin_id}_{key}"]`. + /// + /// - `__jcode_register_tui_event(event, handlerFn)` + /// Stores `handlerFn` as `globalThis["__jcode_evt_{plugin_id}_{event}"]`. + fn install_handler_registration_helpers<'js>( + ctx: &rquickjs::Ctx<'js>, + plugin_id: &PluginId, + ) -> Result<(), PluginError> { + use rquickjs::{Function, Object}; + + let globals = ctx.globals(); + + // -- keybinding registration helper -- + let kb_prefix = format!("__jcode_kb_{}_", plugin_id.short_name().replace(['/', '@'], "_")); + let kb_prefix_for_closure = kb_prefix.clone(); + let register_kb = Function::new(ctx.clone(), move |key: String, _desc: String, handler: rquickjs::Value<'js>| { + let fn_name = format!("{}{}", kb_prefix_for_closure, key.replace('+', "_")); + // TODO(WIP): Cannot store the handler value directly (QuickJS Value lifetime). + // Full implementation requires wrapping the JS function in a thread-safe + // handle (e.g. StoredFunction) and invoking it when the keybinding fires. + tracing::info!("Registered keybinding handler: {} [STUB — handler not wired]", fn_name); + let _ = handler; + }) + .map_err(|e| PluginError::Runtime(format!("Failed to create register_keybinding: {e}")))?; + globals + .set("__jcode_register_keybinding", register_kb) + .map_err(|e| PluginError::Runtime(format!("Failed to set register_keybinding: {e}")))?; + + // -- event handler registration helper -- + let evt_prefix = format!("__jcode_evt_{}_", plugin_id.short_name().replace(['/', '@'], "_")); + let evt_prefix_for_closure = evt_prefix.clone(); + let register_evt = Function::new(ctx.clone(), move |event: String, handler: rquickjs::Value<'js>| { + let fn_name = format!("{}{}", evt_prefix_for_closure, event); + // TODO(WIP): Same as keybinding — JS function reference not stored. + tracing::info!("Registered TUI event handler: {} [STUB — handler not wired]", fn_name); + let _ = handler; + }) + .map_err(|e| PluginError::Runtime(format!("Failed to create register_tui_event: {e}")))?; + globals + .set("__jcode_register_tui_event", register_evt) + .map_err(|e| PluginError::Runtime(format!("Failed to set register_tui_event: {e}")))?; + + // -- expose keybinding/event registration on the TUI API object -- + // Patch __jcode_tui_pi.keymap.register and __jcode_tui_pi.eventBus.on + // to also call the helper functions above. + if let Ok(tui_obj) = globals.get::<_, Object<'js>>("__jcode_tui_pi") { + // Wrap keymap.register + if let Ok(keymap) = tui_obj.get::<_, Object<'js>>("keymap") { + let kb_prefix2 = kb_prefix.clone(); + let wrapped_register = Function::new(ctx.clone(), move |key: String, desc: String, handler: rquickjs::Value<'js>| { + let fn_name = format!("{}{}", kb_prefix2, key.replace('+', "_")); + tracing::info!("TUI keybinding registered: {} ({})", fn_name, desc); + let _ = handler; + }) + .map_err(|e| PluginError::Runtime(format!("Failed to wrap keymap.register: {e}")))?; + keymap + .set("register", wrapped_register) + .map_err(|e| PluginError::Runtime(format!("Failed to set keymap.register: {e}")))?; + } + + // Wrap eventBus.on + if let Ok(event_bus) = tui_obj.get::<_, Object<'js>>("eventBus") { + let evt_prefix2 = evt_prefix.clone(); + let wrapped_on = Function::new(ctx.clone(), move |event: String, handler: rquickjs::Value<'js>| { + let fn_name = format!("{}{}", evt_prefix2, event); + tracing::info!("TUI event handler registered: {}", fn_name); + let _ = handler; + }) + .map_err(|e| PluginError::Runtime(format!("Failed to wrap eventBus.on: {e}")))?; + event_bus + .set("on", wrapped_on) + .map_err(|e| PluginError::Runtime(format!("Failed to set eventBus.on: {e}")))?; + } + } + + Ok(()) + } + + // ------------------------------------------------------------------ + // Handler invocation (Rust -> JS) + // ------------------------------------------------------------------ + + /// Try to invoke a keybinding handler for the given plugin. + /// + /// Looks for a global function named `__jcode_kb_{plugin_id}_{key}`. + /// If found, calls it with the key string and reads + /// `globalThis.__jcode_result.handled` to determine the return value. + async fn invoke_keybinding(&self, plugin: &TuiPlugin, key: &str) -> bool { + let safe_id = plugin + ._id + .short_name() + .replace(['/', '@'], "_"); + let fn_name = format!("__jcode_kb_{}_{}", safe_id, key.replace('+', "_")); + let key_owned = key.to_string(); + + let result = plugin.context.with(|ctx| -> Result { + let globals = ctx.globals(); + + // Check if the handler function exists. + let func = match globals.get::<_, rquickjs::Function<'_>>(fn_name.as_str()) { + Ok(f) => f, + Err(_) => return Ok(false), // No handler registered for this key + }; + + // Initialize the result slot. + let result_obj = rquickjs::Object::new(ctx.clone()) + .map_err(|e| PluginError::Runtime(e.to_string()))?; + result_obj + .set("handled", false) + .map_err(|e| PluginError::Runtime(e.to_string()))?; + globals + .set("__jcode_result", result_obj) + .map_err(|e| PluginError::Runtime(e.to_string()))?; + + // Invoke the handler. + func.call::<_, ()>((key_owned,)) + .map_err(|e| PluginError::Runtime(format!("Key handler error: {e}")))?; + + // Read back the result. + let result_obj = globals + .get::<_, rquickjs::Object<'_>>("__jcode_result") + .map_err(|e| PluginError::Runtime(e.to_string()))?; + let handled: bool = result_obj.get("handled").unwrap_or(false); + + Ok(handled) + }); + + match result { + Ok(handled) => handled, + Err(e) => { + tracing::debug!("Keybinding invocation failed for {}: {}", plugin._id, e); + false + } + } + } + + /// Try to invoke a TUI event handler for the given plugin. + /// + /// Looks for a global function named `__jcode_evt_{plugin_id}_{event}`. + /// If found, calls it with the event data JSON and reads back the result. + async fn invoke_event( + &self, + plugin: &TuiPlugin, + event: &str, + data: &serde_json::Value, + ) -> Option { + let safe_id = plugin + ._id + .short_name() + .replace(['/', '@'], "_"); + let fn_name = format!("__jcode_evt_{}_{}", safe_id, event); + let data_str = data.to_string(); + + let result = plugin.context.with(|ctx| -> Result { + let globals = ctx.globals(); + + // Check if the handler function exists. + let func = match globals.get::<_, rquickjs::Function<'_>>(fn_name.as_str()) { + Ok(f) => f, + Err(_) => return Ok(HandlerResult::default()), // No handler + }; + + // Initialize the result slot. + let result_obj = rquickjs::Object::new(ctx.clone()) + .map_err(|e| PluginError::Runtime(e.to_string()))?; + result_obj + .set("action", "continue") + .map_err(|e| PluginError::Runtime(e.to_string()))?; + result_obj + .set("output", rquickjs::Undefined) + .map_err(|e| PluginError::Runtime(e.to_string()))?; + result_obj + .set("error", rquickjs::Undefined) + .map_err(|e| PluginError::Runtime(e.to_string()))?; + globals + .set("__jcode_result", result_obj) + .map_err(|e| PluginError::Runtime(e.to_string()))?; + + // Invoke the handler with the event data as a JSON string. + func.call::<(String,), ()>((data_str.clone(),)) + .map_err(|e| PluginError::Runtime(format!("Event handler error: {e}")))?; + + // Read back the result. + let result_obj = globals + .get::<_, rquickjs::Object<'_>>("__jcode_result") + .map_err(|e| PluginError::Runtime(e.to_string()))?; + + let action_str: String = result_obj.get("action").unwrap_or_else(|_| "continue".to_string()); + let action = match action_str.as_str() { + "block" => { + let msg: String = result_obj + .get("output") + .unwrap_or_else(|_| "blocked".to_string()); + jcode_plugin_core::events::HandlerAction::Block(msg) + } + "allow" => jcode_plugin_core::events::HandlerAction::Allow, + "deny" => jcode_plugin_core::events::HandlerAction::Deny, + "error" => jcode_plugin_core::events::HandlerAction::Error, + _ => jcode_plugin_core::events::HandlerAction::Continue, + }; + + let error: Option = result_obj.get("error").ok().filter(|s: &String| !s.is_empty()); + + Ok(HandlerResult { + action, + output: None, + error, + }) + }); + + match result { + Ok(r) => Some(r), + Err(e) => { + tracing::debug!("Event invocation failed for {}: {}", plugin._id, e); + Some(HandlerResult { + action: jcode_plugin_core::events::HandlerAction::Error, + output: None, + error: Some(e.to_string()), + }) + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn system_creates_empty() { + let dispatcher = Arc::new(crate::dispatcher::RcuDispatcher::new()); + let registry = Arc::new(PluginRegistry::new(dispatcher)); + let system = TuiPluginSystem::new(registry); + assert_eq!(system.plugin_count(), 0); + } + + #[tokio::test] + async fn render_slot_returns_empty_when_no_plugins() { + let dispatcher = Arc::new(crate::dispatcher::RcuDispatcher::new()); + let registry = Arc::new(PluginRegistry::new(dispatcher)); + let system = TuiPluginSystem::new(registry); + let slots = system + .render_slot(crate::tui_api::SlotType::Sidebar) + .await; + assert!(slots.is_empty()); + } + + #[tokio::test] + async fn handle_key_returns_false_when_no_plugins() { + let dispatcher = Arc::new(crate::dispatcher::RcuDispatcher::new()); + let registry = Arc::new(PluginRegistry::new(dispatcher)); + let system = TuiPluginSystem::new(registry); + assert!(!system.handle_key("Ctrl+K").await); + } + + #[tokio::test] + async fn dispatch_tui_event_returns_empty_when_no_plugins() { + let dispatcher = Arc::new(crate::dispatcher::RcuDispatcher::new()); + let registry = Arc::new(PluginRegistry::new(dispatcher)); + let system = TuiPluginSystem::new(registry); + let results = system + .dispatch_tui_event("custom-event", &serde_json::json!({})) + .await; + assert!(results.is_empty()); + } +} diff --git a/crates/jcode-plugin-runtime/src/types.rs b/crates/jcode-plugin-runtime/src/types.rs new file mode 100644 index 000000000..ea9e79832 --- /dev/null +++ b/crates/jcode-plugin-runtime/src/types.rs @@ -0,0 +1,22 @@ +use std::sync::Arc; +use jcode_plugin_core::events::HandlerResult; + +pub use jcode_plugin_core::preflight::{PreflightResult, StaticAnalysis}; + +pub enum HandlerSlot { + Rust(Arc) -> std::pin::Pin + Send>> + Send + Sync>), +} + +impl Clone for HandlerSlot { + fn clone(&self) -> Self { + match self { + Self::Rust(f) => Self::Rust(Arc::clone(f)), + } + } +} + +#[derive(Debug, Clone)] +pub struct ResolvedEntry { + pub path: std::path::PathBuf, + pub manifest: jcode_plugin_core::manifest::PluginManifest, +} diff --git a/crates/jcode-protocol/Cargo.toml b/crates/jcode-protocol/Cargo.toml index 2d274a9a4..0661368f6 100644 --- a/crates/jcode-protocol/Cargo.toml +++ b/crates/jcode-protocol/Cargo.toml @@ -15,6 +15,7 @@ jcode-session-types = { path = "../jcode-session-types" } jcode-side-panel-types = { path = "../jcode-side-panel-types" } serde = { version = "1", features = ["derive"] } serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } [dev-dependencies] anyhow = "1" diff --git a/crates/jcode-protocol/src/wire.rs b/crates/jcode-protocol/src/wire.rs index 1536ac574..b015d85f9 100644 --- a/crates/jcode-protocol/src/wire.rs +++ b/crates/jcode-protocol/src/wire.rs @@ -1248,6 +1248,26 @@ pub enum ServerEvent { tool_call_id: String, }, + // === Plugin system events === + + /// Plugin system notification + #[serde(rename = "plugin_notification")] + PluginNotification { + plugin_id: String, + event_type: String, + data: serde_json::Value, + }, + + /// Plugin permission request for IDE-side approval + #[serde(rename = "plugin_permission_request")] + PluginPermissionRequest { + plugin_id: String, + action: String, + resource: String, + request_id: String, + timestamp: chrono::DateTime, + }, + /// Current experiment flag states (response to ExperimentList) #[serde(rename = "experiment_flags")] ExperimentFlags { flags: Vec }, diff --git a/crates/jcode-tui/Cargo.toml b/crates/jcode-tui/Cargo.toml index 10f1724b3..091579bc4 100644 --- a/crates/jcode-tui/Cargo.toml +++ b/crates/jcode-tui/Cargo.toml @@ -91,6 +91,7 @@ jcode-tui-usage-overlay = { path = "../jcode-tui-usage-overlay" } jcode-terminal-image = { path = "../jcode-terminal-image" } jcode-productivity-core = { path = "../jcode-productivity-core" } jcode-tui-workspace = { path = "../jcode-tui-workspace" } +jcode-plugin-runtime = { path = "../jcode-plugin-runtime" } jcode-experiment-flags = { path = "../jcode-experiment-flags" } [features] diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 192cc2493..49831f60f 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -1185,6 +1185,8 @@ pub struct App { productivity_refreshing: bool, /// Last time the passive overnight progress card polled its run files. last_overnight_card_refresh: Option, + /// Plugin system bridge for TUI plugin integration. + plugin_bridge: Option, } /// Inert provider used by runtime modes whose output is supplied by another source. diff --git a/crates/jcode-tui/src/tui/app/local.rs b/crates/jcode-tui/src/tui/app/local.rs index 204730a75..cc1fb2a6a 100644 --- a/crates/jcode-tui/src/tui/app/local.rs +++ b/crates/jcode-tui/src/tui/app/local.rs @@ -354,7 +354,26 @@ fn apply_terminal_event( app.note_client_interaction(); app.update_copy_badge_key_event(key); if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { - app.handle_key_press_event(key)?; + // Let the plugin system handle the key first. If any plugin + // consumes it, skip normal TUI key handling. + let plugin_handled = if let Some(bridge) = app.plugin_bridge.as_ref() { + if let Some(key_str) = crate::tui::plugin_integration::format_plugin_key( + key.code, + key.modifiers, + ) { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(bridge.handle_key(&key_str)) + }) + } else { + false + } + } else { + false + }; + if !plugin_handled { + app.handle_key_press_event(key)?; + } } Ok(true) } diff --git a/crates/jcode-tui/src/tui/app/remote/server_events.rs b/crates/jcode-tui/src/tui/app/remote/server_events.rs index 6ad13a8e7..4c33c0bac 100644 --- a/crates/jcode-tui/src/tui/app/remote/server_events.rs +++ b/crates/jcode-tui/src/tui/app/remote/server_events.rs @@ -1980,6 +1980,43 @@ pub(in crate::tui::app) fn handle_server_event( app.set_status_notice("⌨ Interactive terminal detected (command will timeout)"); false } + ServerEvent::PluginNotification { + plugin_id, + event_type, + data, + } => { + // Forward the protocol plugin event to all loaded TUI plugins. + if let Some(bridge) = app.plugin_bridge.as_ref() { + let bridge_ref = bridge; + let event_name = format!("plugin:{}", event_type); + let pid = plugin_id.clone(); + let etype = event_type.clone(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + bridge_ref.dispatch_event(&event_name, &data), + ) + }); + crate::logging::info(&format!( + "Forwarded plugin event '{}' from '{}' to TUI plugins", + etype, pid, + )); + } + false + } + ServerEvent::PluginPermissionRequest { + plugin_id, + action, + resource, + request_id, + .. + } => { + // Log the permission request; actual approval flow is TBD. + crate::logging::info(&format!( + "Plugin permission request: {} wants to {} {} (request {})", + plugin_id, action, resource, request_id, + )); + false + } _ => false, } } diff --git a/crates/jcode-tui/src/tui/app/run_shell.rs b/crates/jcode-tui/src/tui/app/run_shell.rs index 28479d267..dd1b6865e 100644 --- a/crates/jcode-tui/src/tui/app/run_shell.rs +++ b/crates/jcode-tui/src/tui/app/run_shell.rs @@ -234,6 +234,9 @@ impl App { /// Run the TUI application /// Returns Some(session_id) if hot-reload was requested pub async fn run(mut self, mut terminal: DefaultTerminal) -> Result { + // Initialize the TUI plugin system before entering the main loop. + self.init_plugin_bridge().await; + let mut event_stream = EventStream::new(); let mut redraw_period = crate::tui::redraw_interval(&self); let mut redraw_interval = interval(redraw_period); @@ -335,6 +338,9 @@ impl App { /// Run the TUI in remote mode, connecting to a server pub async fn run_remote(mut self, mut terminal: DefaultTerminal) -> Result { + // Initialize the TUI plugin system before entering the main loop. + self.init_plugin_bridge().await; + let mut event_stream = EventStream::new(); let mut redraw_period = crate::tui::redraw_interval(&self); let mut redraw_interval = interval(redraw_period); diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index f561dfeab..579b44b5d 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -597,6 +597,7 @@ impl App { usage_report_refreshing: false, productivity_refreshing: false, last_overnight_card_refresh: None, + plugin_bridge: None, }; for notice in app.provider.drain_startup_notices() { @@ -1006,6 +1007,7 @@ impl App { usage_report_refreshing: false, productivity_refreshing: false, last_overnight_card_refresh: None, + plugin_bridge: None, }; for notice in app.provider.drain_startup_notices() { @@ -1141,4 +1143,21 @@ impl App { self.remote_startup_phase = Some(super::RemoteStartupPhase::StartingServer); self.remote_startup_phase_started = Some(Instant::now()); } + + /// Initialize the TUI plugin system. Should be called once at startup + /// from an async context (i.e. inside the `run` / `run_remote` event loop + /// before entering the main select loop). + pub(crate) async fn init_plugin_bridge(&mut self) { + if self.plugin_bridge.is_some() { + return; + } + let bridge = crate::tui::plugin_integration::PluginTuiBridge::new().await; + if bridge.plugin_count() > 0 { + crate::logging::info(&format!( + "TUI plugin bridge ready ({} plugin(s))", + bridge.plugin_count() + )); + } + self.plugin_bridge = Some(bridge); + } } diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 3b1d5f3a1..e3883cd39 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -1570,4 +1570,8 @@ impl crate::tui::TuiState for App { cached_tokens: self.last_turn_input_tokens, }) } + + fn plugin_bridge(&self) -> Option<&crate::tui::plugin_integration::PluginTuiBridge> { + self.plugin_bridge.as_ref() + } } diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index d517b1245..a977880ea 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -28,6 +28,7 @@ pub mod markdown; mod memory_profile; pub mod mermaid; pub mod permissions; +pub(crate) mod plugin_integration; mod remote_diff; pub mod screenshot; pub mod session_picker; @@ -419,6 +420,12 @@ pub trait TuiState { } false } + + /// Access the plugin bridge for TUI plugin integration. + /// Returns `None` when the plugin system has not been initialized. + fn plugin_bridge(&self) -> Option<&plugin_integration::PluginTuiBridge> { + None + } } #[cfg(feature = "dev-bins")] diff --git a/crates/jcode-tui/src/tui/plugin_integration.rs b/crates/jcode-tui/src/tui/plugin_integration.rs new file mode 100644 index 000000000..d1190eba2 --- /dev/null +++ b/crates/jcode-tui/src/tui/plugin_integration.rs @@ -0,0 +1,226 @@ +//! Integration layer between the jcode TUI and the plugin runtime. +//! +//! This module bridges [`TuiPluginSystem`] into the synchronous TUI draw loop +//! and the async event loop. It provides: +//! +//! - Initialization of the plugin system at startup +//! - Synchronous slot reads for the draw path (status bar, sidebar) +//! - Async key-event forwarding before normal TUI key handling +//! - Async protocol-event forwarding from `ServerEvent::PluginNotification` + +use std::sync::Arc; + +use crossterm::event::{KeyCode, KeyModifiers}; +use jcode_plugin_runtime::dispatcher::RcuDispatcher; +use jcode_plugin_runtime::registry::PluginRegistry; +use jcode_plugin_runtime::tui_api::{SlotContent, SlotRegistry, SlotType}; +use jcode_plugin_runtime::tui_system::TuiPluginSystem; + +// --------------------------------------------------------------------------- +// PluginTuiBridge +// --------------------------------------------------------------------------- + +/// Bridge between the TUI application and the plugin runtime. +/// +/// Owns the [`TuiPluginSystem`] and provides both synchronous (draw-path) and +/// async (event-loop) access to plugin state. +pub struct PluginTuiBridge { + /// The live plugin system (kept for key/event dispatch). + system: Arc, + /// Shared slot registry for synchronous reads from the draw path. + slot_registry: Arc, +} + +impl PluginTuiBridge { + /// Initialize the plugin system: create a registry, discover and load all + /// TUI-kind plugins, and return the bridge. + pub async fn new() -> Self { + let dispatcher = Arc::new(RcuDispatcher::new()); + let registry = Arc::new(PluginRegistry::new(dispatcher)); + let mut system = TuiPluginSystem::new(Arc::clone(®istry)); + if let Err(e) = system.load_all().await { + crate::logging::warn(&format!("Failed to load TUI plugins: {}", e)); + } + let slot_registry = Arc::clone(system.slot_registry()); + let system = Arc::new(system); + crate::logging::info(&format!( + "TUI plugin system initialized ({} plugin(s))", + system.plugin_count() + )); + Self { + system, + slot_registry, + } + } + + // -- Synchronous draw-path API ------------------------------------------ + + /// Read slot content synchronously (non-blocking) from the shared registry. + /// Returns an empty vec when the lock is contended or no plugins filled the + /// slot. + pub fn read_slots(&self, slot_type: SlotType) -> Vec { + let Ok(registry) = self.slot_registry.try_read() else { + return Vec::new(); + }; + let prefix = format!(":{}", slot_type.as_str()); + registry + .iter() + .filter(|(k, _)| k.ends_with(&prefix)) + .map(|(_, v)| v.clone()) + .collect() + } + + /// Whether any plugin has filled the given slot. + pub fn has_slot_content(&self, slot_type: SlotType) -> bool { + let Ok(registry) = self.slot_registry.try_read() else { + return false; + }; + let prefix = format!(":{}", slot_type.as_str()); + registry.keys().any(|k| k.ends_with(&prefix)) + } + + // -- Async event-loop API ----------------------------------------------- + + /// Forward a key event to all loaded plugins. Returns `true` if any plugin + /// consumed the key. + pub async fn handle_key(&self, key: &str) -> bool { + self.system.handle_key(key).await + } + + /// Forward a protocol event (e.g. `PluginNotification`) to all loaded TUI + /// plugins. + pub async fn dispatch_event(&self, event: &str, data: &serde_json::Value) { + let _ = self.system.dispatch_tui_event(event, data).await; + } + + /// Number of loaded TUI plugins. + pub fn plugin_count(&self) -> usize { + self.system.plugin_count() + } +} + +// --------------------------------------------------------------------------- +// Key formatting +// --------------------------------------------------------------------------- + +/// Format a crossterm key event into the canonical `"Mod+Key"` string expected +/// by the plugin keybinding system (e.g. `"Ctrl+K"`, `"Alt+Enter"`). +/// +/// Returns `None` for key codes that have no standard string representation +/// (e.g. `KeyCode::Null`). +pub(crate) fn format_plugin_key(code: KeyCode, modifiers: KeyModifiers) -> Option { + let key_part = match code { + KeyCode::Char(c) => { + let upper = c.to_ascii_uppercase(); + return Some(if modifiers.contains(KeyModifiers::CONTROL) { + format!("Ctrl+{}", upper) + } else if modifiers.contains(KeyModifiers::ALT) { + format!("Alt+{}", upper) + } else { + upper.to_string() + }); + } + KeyCode::Enter => "Enter", + KeyCode::Esc => "Escape", + KeyCode::Tab => "Tab", + KeyCode::BackTab => "BackTab", + KeyCode::Backspace => "Backspace", + KeyCode::Delete => "Delete", + KeyCode::Insert => "Insert", + KeyCode::Home => "Home", + KeyCode::End => "End", + KeyCode::PageUp => "PageUp", + KeyCode::PageDown => "PageDown", + KeyCode::Up => "Up", + KeyCode::Down => "Down", + KeyCode::Left => "Left", + KeyCode::Right => "Right", + KeyCode::F(n) => return Some(format!("F{}", n)), + _ => return None, + }; + + let mut parts = Vec::new(); + if modifiers.contains(KeyModifiers::CONTROL) { + parts.push("Ctrl"); + } + if modifiers.contains(KeyModifiers::ALT) { + parts.push("Alt"); + } + if modifiers.contains(KeyModifiers::SHIFT) { + parts.push("Shift"); + } + parts.push(key_part); + Some(parts.join("+")) +} + +// --------------------------------------------------------------------------- +// Draw-path helpers +// --------------------------------------------------------------------------- + +/// Render plugin `StatusBar` slot content into the given area. +/// +/// Each plugin's slot content is rendered as a single ratatui [`Line`]. If no +/// plugin has filled the `StatusBar` slot this is a no-op. +pub(crate) fn draw_status_bar_slots( + frame: &mut ratatui::Frame, + bridge: &PluginTuiBridge, + area: ratatui::layout::Rect, +) { + use ratatui::text::{Line, Span}; + use ratatui::style::{Color, Style}; + + let slots = bridge.read_slots(SlotType::StatusBar); + if slots.is_empty() { + return; + } + + let lines: Vec> = slots + .iter() + .map(|s| { + Line::from(Span::styled( + s.render(), + Style::default().fg(Color::DarkGray), + )) + }) + .collect(); + + let paragraph = ratatui::widgets::Paragraph::new(lines); + frame.render_widget(paragraph, area); +} + +/// Render plugin `Sidebar` slot content into the given area. +/// +/// Content is wrapped in a bordered block titled "Plugins". If no plugin has +/// filled the `Sidebar` slot this is a no-op. +pub(crate) fn draw_sidebar_slots( + frame: &mut ratatui::Frame, + bridge: &PluginTuiBridge, + area: ratatui::layout::Rect, +) { + use ratatui::text::{Line, Span}; + use ratatui::style::{Color, Style}; + use ratatui::widgets::{Block, Borders, Paragraph}; + + let slots = bridge.read_slots(SlotType::Sidebar); + if slots.is_empty() { + return; + } + + let lines: Vec> = slots + .iter() + .map(|s| { + Line::from(Span::styled( + s.render(), + Style::default().fg(Color::DarkGray), + )) + }) + .collect(); + + let block = Block::default() + .title("Plugins") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); + + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, area); +} diff --git a/crates/jcode-tui/src/tui/ui.rs b/crates/jcode-tui/src/tui/ui.rs index c5ce096d7..2d06c14d3 100644 --- a/crates/jcode-tui/src/tui/ui.rs +++ b/crates/jcode-tui/src/tui/ui.rs @@ -2715,6 +2715,29 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { visual_debug::record_frame(capture.build()); } + // Render TUI plugin slot content (status bar, sidebar). + if let Some(bridge) = app.plugin_bridge() { + // Status bar: render plugin content in the notification area if there + // is room, or overlay it at the bottom of the status bar area. + let status_area = chunks[2]; + super::plugin_integration::draw_status_bar_slots(frame, bridge, status_area); + + // Sidebar: render plugin content as a small panel on the right side + // of the messages area when plugins have filled the Sidebar slot. + if bridge.has_slot_content(jcode_plugin_runtime::tui_api::SlotType::Sidebar) { + let sidebar_width = 30u16.min(messages_area.width / 3); + if sidebar_width >= 10 { + let sidebar_area = Rect { + x: messages_area.x + messages_area.width.saturating_sub(sidebar_width), + y: messages_area.y, + width: sidebar_width, + height: messages_area.height, + }; + super::plugin_integration::draw_sidebar_slots(frame, bridge, sidebar_area); + } + } + } + finalize_frame_metrics( app, total_start, diff --git a/crates/jcode-tui/src/tui/ui_input.rs b/crates/jcode-tui/src/tui/ui_input.rs index d82ec400c..d2dc92372 100644 --- a/crates/jcode-tui/src/tui/ui_input.rs +++ b/crates/jcode-tui/src/tui/ui_input.rs @@ -876,10 +876,29 @@ pub(super) fn draw_status(frame: &mut Frame, app: &dyn TuiState, area: Rect, pen Line::from("") } } else { + let plugin_count = crate::plugin::plugin_count(); + let plugin_text = if plugin_count > 0 { + format!("[P:{}]", plugin_count) + } else { + String::new() + }; if let Some(tip) = occasional_status_tip(area.width as usize, app.animation_elapsed() as u64) { - Line::from(vec![Span::styled(tip, Style::default().fg(dim_color()))]) + if plugin_count > 0 { + Line::from(vec![ + Span::styled(plugin_text, Style::default().fg(dim_color())), + Span::styled(" · ", Style::default().fg(dim_color())), + Span::styled(tip, Style::default().fg(dim_color())), + ]) + } else { + Line::from(vec![Span::styled(tip, Style::default().fg(dim_color()))]) + } + } else if plugin_count > 0 { + Line::from(vec![Span::styled( + plugin_text, + Style::default().fg(dim_color()), + )]) } else { Line::from("") } diff --git a/docs/plugins/README.md b/docs/plugins/README.md new file mode 100644 index 000000000..5c0bfde7b --- /dev/null +++ b/docs/plugins/README.md @@ -0,0 +1,1668 @@ +# jcode Plugin Author Guide + +A comprehensive guide to building plugins for jcode. Plugins extend jcode with custom event handlers, tools, configuration, and integrations -- all running inside a secure QuickJS sandbox. + +--- + +## Table of Contents + +- [Overview](#overview) +- [Quick Start](#quick-start) +- [Plugin Lifecycle](#plugin-lifecycle) +- [Manifest Format](#manifest-format) +- [TypeScript API Reference](#typescript-api-reference) +- [Event Reference](#event-reference) +- [Tool Registration](#tool-registration) +- [Capability Security Model](#capability-security-model) +- [Configuration](#configuration) +- [Environment Variables](#environment-variables) +- [CLI Commands](#cli-commands) +- [Testing Plugins](#testing-plugins) +- [Publishing to npm](#publishing-to-npm) +- [FAQ](#faq) + +--- + +## Overview + +jcode plugins are TypeScript or JavaScript modules that run inside a QuickJS sandbox. They can: + +- **Listen to events** -- react to tool calls, session lifecycle, messages, and more. +- **Register custom tools** -- add new tools the model can invoke. +- **Modify behavior** -- block or modify tool inputs/outputs, inject system prompts, suppress notifications. +- **Persist state** -- use `pi.kv` for durable cross-session key-value storage. +- **Read configuration** -- access plugin-specific settings from `config.toml`. + +Plugins have **no access** to Node.js built-ins, DOM, `require()`, or `process`. All host interaction goes through the `pi` global object injected by the runtime. + +### What You Can Build + +| Use Case | Example | +|----------|---------| +| Tool gatekeeper | Block dangerous tool calls based on custom rules | +| Telemetry | Log tool usage, turn durations, token counts | +| Custom tools | Add domain-specific tools (e.g., `jira_create_ticket`) | +| Prompt injection | Append instructions to system prompts per session | +| Notification filter | Suppress or rewrite noisy notifications | +| Session analytics | Track conversation metrics across sessions | + +--- + +## Quick Start + +### 1. Create a plugin file + +Create `~/.jcode/plugins/hello-plugin.ts`: + +```typescript +// Declare the plugin identity +const manifest = { + name: 'hello-plugin', + version: '1.0.0', + description: 'A minimal jcode plugin', + capabilities: { + events: ['TurnStart'], + }, +}; + +// Register an event handler +pi.on('TurnStart', (event) => { + pi.logger.info(`[hello-plugin] Turn started in session ${event.session_id}`); +}); + +// Register a custom tool +pi.registerTool({ + name: 'hello_greet', + description: 'Greet someone by name', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name to greet' }, + }, + required: ['name'], + }, + handler: (params) => { + return `Hello, ${params.name}!`; + }, +}); + +// Export plugin metadata +export default { + name: manifest.name, + version: manifest.version, + description: manifest.description, +}; +``` + +### 2. Start jcode + +```bash +jcode +``` + +jcode automatically discovers plugins in `~/.jcode/plugins/` on startup. + +### 3. Verify it loaded + +```bash +jcode plugin list +``` + +You should see `file:hello-plugin.ts` in the output. + +--- + +## Plugin Lifecycle + +Plugins go through these stages: + +``` +Discovery --> Preflight --> Load --> Activate --> Runtime --> Unload +``` + +1. **Discovery** -- jcode scans plugin directories, npm cache, and config sources. +2. **Preflight** -- Static analysis checks for dangerous patterns, undeclared capabilities, and suspicious constructs. Warnings are logged; blocks prevent loading. +3. **Load** -- TypeScript is transpiled to JavaScript via SWC, then evaluated in a QuickJS sandbox. +4. **Activate** -- Event handlers and tools registered during module evaluation become active. +5. **Runtime** -- Events are dispatched to registered handlers. Tools are callable by the model. +6. **Unload** -- Cleanup on session end or plugin disable. `SessionEnd` event fires before teardown. + +### Preflight Analysis + +Before a plugin loads, jcode runs static analysis to detect: + +| Pattern | Severity | Effect | +|---------|----------|--------| +| `eval()` | Warning | Logged, plugin still loads | +| `new Function()` | Warning | Logged, plugin still loads | +| `process.*` | Warning | Not available in sandbox | +| `require()` | Warning | Use ES imports instead | +| `fetch()` without network capability | Warning | Declare `network` capability | +| `exec()`/`spawn()` without shell capability | Warning | Declare `shell` capability | +| `rm -rf`, `sudo`, `chmod 777` | **Block** | Plugin loading is prevented | + +--- + +## Manifest Format + +Plugins declare their identity and capabilities via a manifest. For npm packages, the manifest lives in `package.json` under the `"jcode"` (or `"pi"`) key. For local files, the manifest is the exported default object. + +### package.json (npm plugins) + +```json +{ + "name": "jcode-plugin-analytics", + "version": "1.0.0", + "main": "dist/server.js", + "jcode": { + "name": "analytics", + "package_name": "jcode-plugin-analytics", + "version": "1.0.0", + "description": "Track tool usage analytics", + "author": "Your Name", + "license": "MIT", + "kind": "server", + "entry": { + "server": "dist/server.js", + "tui": "dist/tui.js" + }, + "capabilities": { + "fs_write": ["$HOME/.jcode/data/analytics"], + "events": ["PreToolUse", "PostToolUse", "TurnStart", "TurnEnd"], + "register_tools": true, + "read_config": true + }, + "engines": { + "jcode": ">=0.9.0" + }, + "tags": ["analytics", "telemetry"] + } +} +``` + +### Manifest Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | `string` | Yes | Plugin identifier (short name) | +| `package_name` | `string` | Yes | npm package name | +| `version` | `string` | Yes | Semver version string | +| `description` | `string` | No | Human-readable description | +| `author` | `string` | No | Plugin author | +| `license` | `string` | No | SPDX license identifier | +| `kind` | `"server" \| "tui" \| "both"` | No | Where the plugin runs (default: `"server"`) | +| `entry` | `PluginEntry` | No | Entry point paths | +| `capabilities` | `PluginCapabilities` | No | Required capabilities | +| `features` | `Record` | No | Toggleable features | +| `settings` | `Record` | No | User-configurable settings | +| `engines` | `{ jcode?: string }` | No | Required jcode version range | +| `icon` | `string` | No | Icon path or URL | +| `homepage` | `string` | No | Project homepage URL | +| `repository` | `string` | No | Source repository URL | +| `tags` | `string[]` | No | Categorization tags | + +### PluginEntry + +```typescript +interface PluginEntry { + server?: string; // Entry point for server mode + tui?: string; // Entry point for TUI mode + both?: string; // Entry point for both modes +} +``` + +### PluginKind + +- `"server"` -- Plugin runs in server/headless mode (default). +- `"tui"` -- Plugin runs in TUI mode only. +- `"both"` -- Plugin runs in both modes. + +--- + +## TypeScript API Reference + +All plugin APIs are available through the global `pi` object (injected as `__jcode_pi`). + +### `pi.id` + +```typescript +pi.id: string +``` + +The plugin's unique identifier. Format: `npm:package-name` or `file:/path/to/plugin.ts`. + +### `pi.name` + +```typescript +pi.name: string +``` + +The plugin's display name (same as `pi.id` for most plugins). + +### `pi.version` + +```typescript +pi.version: string +``` + +The plugin's version string. + +### `pi.on(eventName, handler)` + +```typescript +pi.on(event: string, handler: (event: any) => void | HandlerResult): void +``` + +Register an event handler. The handler receives an event object whose shape depends on the event type. See [Event Reference](#event-reference) for all events and their fields. + +**Parameters:** +- `event` -- Event name (e.g., `"TurnStart"`, `"PreToolUse"`) +- `handler` -- Callback function. May return a `HandlerResult` for events that support modification. + +**Example:** + +```typescript +pi.on('TurnStart', (event) => { + pi.logger.info(`Turn ${event.turn_number} started`); +}); + +pi.on('PreToolUse', (event) => { + if (event.tool_name === 'rm') { + return { action: 'block', output: 'Blocked by policy' }; + } + return { action: 'continue' }; +}); +``` + +### `pi.registerTool(toolDefinition)` + +```typescript +pi.registerTool(tool: { + name: string; + description: string; + parameters: JSONSchema; + handler: (params: any) => any; +}): void +``` + +Register a custom tool that the model can invoke. See [Tool Registration](#tool-registration) for details. + +### `pi.getConfig(key)` + +```typescript +pi.getConfig(key: string): string +``` + +Read a plugin configuration value from the global jcode config. Returns an empty string if not set. + +**Parameters:** +- `key` -- Configuration key (e.g., `"my-plugin.apiKey"`) + +**Example:** + +```typescript +const apiKey = pi.getConfig('my-plugin.apiKey'); +const maxResults = parseInt(pi.getConfig('my-plugin.maxResults') || '10', 10); +``` + +### `pi.logger` + +```typescript +pi.logger: { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + debug(message: string): void; +} +``` + +Structured logger that writes to jcode's tracing system. Messages appear in debug logs and can be filtered by level. + +**Example:** + +```typescript +pi.logger.info('[my-plugin] Starting up'); +pi.logger.warn('[my-plugin] Deprecated config key used'); +pi.logger.error('[my-plugin] Failed to parse input'); +pi.logger.debug('[my-plugin] Internal state: ' + JSON.stringify(state)); +``` + +### `pi.kv` + +```typescript +pi.kv: { + get(key: string): string; + set(key: string, value: string): void; +} +``` + +Durable key-value storage that persists across sessions. Backed by the runtime's storage layer. Values are strings; serialize complex data with `JSON.stringify`/`JSON.parse`. + +**Example:** + +```typescript +// Save state +pi.kv.set('my-plugin.counter', JSON.stringify({ count: 42 })); + +// Restore state +const saved = pi.kv.get('my-plugin.counter'); +const counter = saved ? JSON.parse(saved) : { count: 0 }; +``` + +### `pi.sleep(ms)` + +```typescript +pi.sleep(ms: number): void +``` + +Block the current execution for the specified number of milliseconds. Use sparingly -- this blocks the sandbox thread. + +**Example:** + +```typescript +pi.sleep(100); // Wait 100ms +``` + +### `pi.uuid()` + +```typescript +pi.uuid(): string +``` + +Generate a new UUID v4 string. + +**Example:** + +```typescript +const requestId = pi.uuid(); +``` + +### `pi.cwd` + +```typescript +pi.cwd: string +``` + +The current working directory of the jcode process. + +**Example:** + +```typescript +pi.logger.info(`Working directory: ${pi.cwd}`); +``` + +--- + +## Event Reference + +Events are dispatched to handlers registered via `pi.on()`. Each event has specific input fields and optional output fields that allow modification. + +### Event Categories + +| Category | Events | Description | +|----------|--------|-------------| +| **Tool** | `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `ToolExecutionStart`, `ToolExecutionEnd` | Tool execution lifecycle | +| **Session** | `SessionStart`, `SessionEnd`, `SessionSwitch`, `SessionCompact`, `SessionBeforeCompact`, `SessionShutdown` | Session lifecycle | +| **Agent** | `AgentStart`, `AgentEnd` | Agent lifecycle | +| **Turn** | `TurnStart`, `TurnEnd` | Conversation turn lifecycle | +| **Message** | `MessageStart`, `MessageEnd` | Message production lifecycle | +| **Compact** | `PreCompact`, `PostCompact`, `AutoCompactionStart` | Context compaction | +| **Permission** | `PermissionRequest`, `PermissionDenied` | Permission system | +| **Task** | `TaskCreated`, `TaskCompleted` | Task management | +| **Other** | `UserPromptSubmit`, `Stop`, `Notification` | Misc events | + +### Tool Events + +#### `PreToolUse` + +Fired **before** a tool is executed. Can block or modify the tool input. + +**Input:** +```typescript +{ + tool_name: string; // Name of the tool about to run + tool_input: any; // Tool parameters + session_id: string; // Current session ID +} +``` + +**Output (return value):** +```typescript +{ + block?: string; // If set, tool is blocked with this reason + modified_input?: any; // If set, replaces the tool input +} +``` + +**Example:** +```typescript +pi.on('PreToolUse', (event) => { + // Block dangerous tools + if (event.tool_name === 'Bash' && event.tool_input.command?.includes('rm -rf')) { + return { block: 'Blocked dangerous command' }; + } + + // Modify Read tool to add a limit + if (event.tool_name === 'Read' && !event.tool_input.limit) { + return { + modified_input: { ...event.tool_input, limit: 200 } + }; + } + + return {}; // Continue without modification +}); +``` + +#### `PostToolUse` + +Fired **after** a tool returns successfully. Can modify the output. + +**Input:** +```typescript +{ + tool_name: string; // Tool that was executed + tool_input: any; // Tool parameters that were used + tool_output: any; // Tool's return value + duration_ms: number; // Execution time in milliseconds + success: boolean; // Whether the tool succeeded + session_id: string; // Current session ID +} +``` + +**Output:** +```typescript +{ + modified_output?: any; // If set, replaces the tool output +} +``` + +**Example:** +```typescript +pi.on('PostToolUse', (event) => { + pi.logger.info(`Tool ${event.tool_name} took ${event.duration_ms}ms`); + + // Track metrics + totalToolDuration += event.duration_ms; +}); +``` + +#### `PostToolUseFailure` + +Fired when a tool execution fails. + +**Input:** +```typescript +{ + tool_name: string; // Tool that failed + tool_input: any; // Tool parameters + error: string; // Error message + duration_ms: number; // Execution time before failure + session_id: string; // Current session ID +} +``` + +**Output:** None (read-only event). + +#### `ToolExecutionStart` + +Fired when a tool begins execution (before `PreToolUse`). + +**Input:** +```typescript +{ + tool_name: string; + tool_input: any; + session_id: string; +} +``` + +**Output:** None (read-only event). + +#### `ToolExecutionEnd` + +Fired when a tool finishes execution. + +**Input:** +```typescript +{ + tool_name: string; + tool_output: any; + duration_ms: number; + session_id: string; +} +``` + +**Output:** None (read-only event). + +### Session Events + +#### `SessionStart` + +Fired when a new session begins. + +**Input:** +```typescript +{ + session_id: string; // New session's ID + project_dir: string; // Project directory path + model: string; // Model being used (e.g., "claude-4") + provider: string; // Provider name (e.g., "anthropic") +} +``` + +**Output:** None (read-only event). + +#### `SessionEnd` + +Fired when a session ends. Use for cleanup and state persistence. + +**Input:** +```typescript +{ + session_id: string; // Session that ended + duration_seconds: number; // Total session duration + message_count: number; // Messages in the session +} +``` + +**Output:** None (read-only event). + +**Example:** +```typescript +pi.on('SessionEnd', () => { + // Persist state before shutdown + pi.kv.set('my-plugin.data', JSON.stringify(collectedData)); + pi.logger.info('State saved, shutting down'); +}); +``` + +#### `SessionSwitch` + +Fired when the user switches to a different session. + +**Input:** +```typescript +{ + session_id: string; // Current session + target_session_id: string; // Session being switched to +} +``` + +**Output:** None (read-only event). + +#### `SessionCompact` + +Fired when a session is compacted. + +**Input:** +```typescript +{ + session_id: string; + reason: string; // Why compaction happened +} +``` + +**Output:** None (read-only event). + +#### `SessionBeforeCompact` + +Fired before compaction begins. Same input as `SessionCompact`. + +#### `SessionShutdown` + +Fired when the session system is shutting down entirely. + +**Input:** Same as `SessionEnd`. + +### Agent Events + +#### `AgentStart` + +Fired when an agent starts. Can inject additional system prompt text. + +**Input:** +```typescript +{ + session_id: string; + system_prompt: any; // Current system prompt + tools: any; // Available tools +} +``` + +**Output:** +```typescript +{ + additional_system_prompt: string[]; // Lines to append to system prompt +} +``` + +**Example:** +```typescript +pi.on('AgentStart', (event) => { + return { + additional_system_prompt: [ + 'Always use the example_hello tool for greetings.', + 'Prefer concise responses.', + ], + }; +}); +``` + +#### `AgentEnd` + +Fired when an agent finishes. + +**Input:** +```typescript +{ + session_id: string; + duration_seconds: number; + message_count: number; +} +``` + +**Output:** None (read-only event). + +### Turn Events + +#### `TurnStart` + +Fired when a conversation turn begins. + +**Input:** +```typescript +{ + session_id: string; + turn_number: number; // Sequential turn number + messages: any; // Current message history +} +``` + +**Output:** None (read-only event). + +#### `TurnEnd` + +Fired when a turn completes. + +**Input:** +```typescript +{ + session_id: string; + turn_number: number; + duration_ms: number; // Turn duration +} +``` + +**Output:** None (read-only event). + +### Message Events + +#### `MessageStart` + +Fired when a message begins (user, assistant, or system). + +**Input:** +```typescript +{ + session_id: string; + role: string; // "user" | "assistant" | "system" +} +``` + +**Output:** None (read-only event). + +#### `MessageEnd` + +Fired when a message is fully produced. + +**Input:** +```typescript +{ + session_id: string; + role: string; + content: string; // Full message content +} +``` + +**Output:** None (read-only event). + +### Compact Events + +#### `PreCompact` + +Fired before context compaction. Can modify the system prompt or prevent compaction. + +**Input:** +```typescript +{ + session_id: string; + message_count: number; // Messages in context + token_count: number; // Current token count + system_prompt: any; // Current system prompt +} +``` + +**Output:** +```typescript +{ + system_prompt?: any; // Modified system prompt + instructions?: string; // Additional instructions + prevent?: boolean; // If true, prevent compaction +} +``` + +#### `PostCompact` + +Fired after compaction completes. + +**Input:** +```typescript +{ + session_id: string; + messages_removed: number; // Messages removed + tokens_saved: number; // Tokens freed +} +``` + +**Output:** None (read-only event). + +#### `AutoCompactionStart` + +Fired when automatic compaction triggers. + +**Input:** Same as `PreCompact`. + +### Permission Events + +#### `PermissionRequest` + +Fired when a permission decision is needed. Can approve, deny, or defer to user. + +**Input:** +```typescript +{ + action: string; // Action being requested + tool_name?: string; // Tool requesting permission + target?: string; // Target resource + session_id: string; +} +``` + +**Output:** +```typescript +{ + decision?: "allow" | "deny" | "ask"; // Auto-decision + message?: string; // Explanation +} +``` + +**Example:** +```typescript +pi.on('PermissionRequest', (event) => { + // Auto-approve Read tool + if (event.tool_name === 'Read') { + return { decision: 'allow', message: 'Auto-approved by plugin' }; + } + // Block shell commands + if (event.tool_name === 'Bash') { + return { decision: 'deny', message: 'Shell access denied by policy' }; + } + return { decision: 'ask' }; // Let user decide +}); +``` + +#### `PermissionDenied` + +Fired when a permission is denied. + +**Input:** +```typescript +{ + action: string; + tool_name?: string; + target?: string; + session_id: string; +} +``` + +**Output:** None (read-only event). + +### Task Events + +#### `TaskCreated` + +Fired when a new task is created. + +**Input:** +```typescript +{ + session_id: string; + task_id: string; + subject: string; +} +``` + +**Output:** None (read-only event). + +#### `TaskCompleted` + +Fired when a task is marked complete. + +**Input:** +```typescript +{ + session_id: string; + task_id: string; +} +``` + +**Output:** None (read-only event). + +### Other Events + +#### `UserPromptSubmit` + +Fired when the user submits a prompt. Can modify the prompt. + +**Input:** +```typescript +{ + content: string; // User's prompt text + session_id: string; +} +``` + +**Output:** +```typescript +{ + modified_prompt?: string; // If set, replaces the prompt +} +``` + +**Example:** +```typescript +pi.on('UserPromptSubmit', (event) => { + // Auto-prepend context + if (event.content.startsWith('/code ')) { + return { + modified_prompt: `Please write code: ${event.content.slice(6)}` + }; + } +}); +``` + +#### `Stop` + +Fired when the agent stops. + +**Input:** +```typescript +{ + session_id: string; + reason: string; // Why the agent stopped +} +``` + +**Output:** +```typescript +{ + reason: string; // Stop reason (can be modified) +} +``` + +#### `Notification` + +Fired for system notifications. Can suppress or modify. + +**Input:** +```typescript +{ + level: string; // "info" | "warn" | "error" + message: string; // Notification text + session_id?: string; // Optional session context +} +``` + +**Output:** +```typescript +{ + suppress?: boolean; // If true, notification is suppressed + modified_message?: string; // If set, replaces the message +} +``` + +**Example:** +```typescript +pi.on('Notification', (event) => { + // Suppress noisy notifications + if (event.message.includes('cache hit')) { + return { suppress: true }; + } +}); +``` + +--- + +## Tool Registration + +Register custom tools that the model can invoke via `pi.registerTool()`. + +### Tool Definition + +```typescript +interface ToolDefinition { + name: string; // Tool name (must be unique) + description: string; // What the tool does (shown to model) + parameters: JSONSchema; // JSON Schema for parameters + handler: (params: any) => any; // Implementation +} +``` + +### JSON Schema for Parameters + +Parameters follow JSON Schema format: + +```typescript +{ + type: 'object', + properties: { + name: { + type: 'string', + description: 'The user name' + }, + count: { + type: 'number', + description: 'Number of items', + default: 10 + }, + mode: { + type: 'string', + enum: ['fast', 'slow'], + description: 'Processing mode' + } + }, + required: ['name'] +} +``` + +### Handler Return Values + +The handler can return any JSON-serializable value: + +```typescript +// Return a string +pi.registerTool({ + name: 'greet', + description: 'Say hello', + parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + handler: (params) => `Hello, ${params.name}!`, +}); + +// Return an object +pi.registerTool({ + name: 'stats', + description: 'Get statistics', + parameters: { type: 'object', properties: {} }, + handler: () => ({ turns: turnCount, duration: totalDuration }), +}); + +// Return a number +pi.registerTool({ + name: 'count', + description: 'Get the count', + parameters: { type: 'object', properties: {} }, + handler: () => 42, +}); +``` + +### Tool Naming Convention + +Prefix your tools to avoid conflicts: + +```typescript +// Good: prefixed with plugin name +pi.registerTool({ name: 'analytics_report', ... }); +pi.registerTool({ name: 'analytics_reset', ... }); + +// Bad: generic name likely to conflict +pi.registerTool({ name: 'report', ... }); +``` + +### Tool Execution Flow + +When the model invokes a plugin tool: + +1. jcode looks up the tool by name in the plugin registry. +2. The tool handler runs inside the QuickJS sandbox. +3. The return value is serialized and returned to the model. +4. If the handler throws, the error is reported to the model. + +--- + +## Capability Security Model + +Plugins declare required capabilities in their manifest. The runtime enforces these through a multi-layer security chain. + +### Capability Fields + +| Capability | Type | Description | +|------------|------|-------------| +| `fs_read` | `string[]` | Allowed read paths (e.g., `["$HOME/.jcode/data"]`) | +| `fs_write` | `string[]` | Allowed write paths | +| `network` | `string[]` | Allowed hosts (e.g., `["api.github.com"]`) | +| `shell` | `boolean` | Allow shell command execution | +| `register_tools` | `boolean` | Allow registering custom tools | +| `register_commands` | `boolean` | Allow registering CLI commands | +| `register_providers` | `boolean` | Allow registering LLM providers | +| `read_config` | `boolean` | Allow reading jcode config | +| `write_config` | `boolean` | Allow writing jcode config | +| `env_vars` | `string[]` | Allowed environment variables | +| `events` | `string[]` | Events the plugin can subscribe to | +| `llm_access` | `boolean` | Allow direct LLM access | +| `session_access` | `boolean` | Allow session manipulation | + +### Evaluation Order + +The security chain evaluates access in this order: + +``` +Mode check --> Deny list --> Global deny --> Allow list --> Global default +``` + +1. **Mode** -- If mode is `"none"`, all access is denied immediately. +2. **Deny list** -- Plugin-specific deny rules (highest priority). +3. **Global deny** -- System-wide deny rules. +4. **Allow list** -- Plugin-specific allow rules. +5. **Global default** -- Fallback: `"deny"`, `"allow"`, or `"ask"`. + +### Access Modes + +| Mode | Behavior | +|------|----------| +| `"all"` | Normal evaluation through the chain | +| `"trusted"` | Only explicit deny rules block access | +| `"none"` | All access denied (kill switch) | +| `"interactive"` | Requires user approval for each access | + +### Declaring Capabilities + +```typescript +const manifest = { + name: 'my-plugin', + version: '1.0.0', + capabilities: { + // Read access to specific directories + fs_read: ['$HOME/.jcode/data', '$HOME/.config/my-plugin'], + + // Write access to a specific directory + fs_write: ['$HOME/.jcode/data/my-plugin'], + + // Network access to specific hosts + network: ['api.github.com', 'api.openai.com'], + + // Tool registration + register_tools: true, + + // Config access + read_config: true, + + // Events to subscribe to + events: ['PreToolUse', 'PostToolUse', 'TurnStart'], + + // Shell access (use with caution) + shell: false, + }, +}; +``` + +### Capability Patterns + +**Path patterns:** Use `$HOME` for the user's home directory. Paths are matched by prefix. + +```typescript +capabilities: { + fs_read: ['$HOME/.jcode/data'], // Matches $HOME/.jcode/data/anything +} +``` + +**Host patterns:** Matched by substring containment. + +```typescript +capabilities: { + network: ['api.github.com'], // Matches https://api.github.com/v1/... +} +``` + +### Security Best Practices + +1. **Minimal capabilities** -- Only declare what you need. +2. **Specific paths** -- Use narrow path prefixes, not `$HOME`. +3. **Specific hosts** -- List exact API hosts, not wildcards. +4. **Avoid shell** -- Shell access is powerful and dangerous. +5. **No `eval()`** -- The preflight analyzer will warn on `eval()` usage. + +--- + +## Configuration + +### config.toml `[plugin]` Section + +Plugin configuration lives in jcode's `config.toml`: + +```toml +[plugin] +# Enable specific plugins +enable = ["my-plugin", "analytics"] + +# Disable specific plugins +disable = ["broken-plugin"] + +# Access mode: "all", "trusted", "none", "interactive" +mode = "all" + +# If true, fail on any plugin load error +fail_closed = false + +# Skip all plugin hooks +skip_hooks = false + +# Force deny all plugin actions +force_deny = false + +# Plugin sources +[[plugin.sources]] +type = "npm" +package = "jcode-plugin-analytics" +version = "1.0.0" + +[[plugin.sources]] +type = "file" +path = "/home/user/my-plugin.ts" + +[[plugin.sources]] +type = "directory" +path = "/home/user/plugins" + +# Per-plugin settings +[plugin.settings.my-plugin] +api_key = "sk-..." +max_results = 50 + +# Per-plugin overrides +[plugin.plugins.my-plugin] +enable = true +timeout_ms = 5000 + +# Feature toggles +[plugin.features.my-plugin] +enable = ["advanced-analytics", "export"] +``` + +### SettingSchema + +Plugins can declare user-configurable settings in their manifest: + +```json +{ + "settings": { + "apiKey": { + "type": "string", + "description": "API key for the service", + "secret": true, + "env": "MY_PLUGIN_API_KEY" + }, + "maxResults": { + "type": "number", + "description": "Maximum results per query", + "default": 10, + "min": 1, + "max": 100 + }, + "mode": { + "type": "enum", + "description": "Processing mode", + "default": "fast", + "values": ["fast", "slow", "auto"] + }, + "enabled": { + "type": "boolean", + "description": "Enable the plugin", + "default": true + }, + "tags": { + "type": "array", + "description": "Filter tags", + "items": { "type": "string" } + }, + "advanced": { + "type": "object", + "description": "Advanced settings", + "properties": { + "retryCount": { "type": "number", "description": "Retry count", "default": 3 } + } + } + } +} +``` + +### SettingSchema Types + +| Type | Fields | +|------|--------| +| `string` | `description`, `default?`, `secret?`, `env?`, `pattern?`, `max_length?` | +| `number` | `description`, `default?`, `min?`, `max?` | +| `boolean` | `description`, `default?` | +| `enum` | `description`, `default?`, `values: string[]` | +| `array` | `description`, `default?`, `items: SettingSchema`, `max_items?` | +| `object` | `description`, `default?`, `properties: Record` | + +--- + +## Environment Variables + +jcode checks these environment variables for plugin system control: + +| Variable | Effect | +|----------|--------| +| `JCODE_DISABLE_PLUGINS=1` | Disables all plugins (sets mode to `"none"`) | +| `JCODE_SKIP_PLUGINS=1` | Skips all plugin hooks (plugins load but don't fire) | +| `JCODE_PLUGIN_MODE=` | Override plugin access mode (`"all"`, `"trusted"`, `"none"`, `"interactive"`) | +| `JCODE_TEAM_WORKER=1` | Force-deny all plugin actions (for automated/team environments) | + +These are **kill switches** -- they take effect immediately and override config.toml settings. + +### Checking Kill Switches + +```bash +jcode plugin doctor +``` + +This command reports active kill switches and can clear them with `--fix`. + +--- + +## CLI Commands + +### `jcode plugin list` + +List all installed plugins and their states. + +```bash +jcode plugin list +``` + +Output: +``` +Installed plugins: + npm:jcode-plugin-analytics active + file:hello-plugin.ts active +``` + +### `jcode plugin install ` + +Install a plugin from npm or local path. + +```bash +# Install from npm +jcode plugin install jcode-plugin-analytics + +# Install from local file +jcode plugin install /path/to/my-plugin.ts + +# Install from local directory +jcode plugin install /path/to/plugins/ +``` + +### `jcode plugin uninstall ` + +Remove a plugin by its ID. + +```bash +jcode plugin uninstall npm:jcode-plugin-analytics +jcode plugin uninstall file:hello-plugin.ts +``` + +### `jcode plugin info ` + +Show detailed information about a plugin. + +```bash +jcode plugin info npm:jcode-plugin-analytics +``` + +### `jcode plugin enable ` + +Enable a previously disabled plugin. + +```bash +jcode plugin enable npm:jcode-plugin-analytics +``` + +### `jcode plugin disable ` + +Disable an active plugin (keeps it installed but inactive). + +```bash +jcode plugin disable npm:jcode-plugin-analytics +``` + +### `jcode plugin audit` + +Show the plugin audit trail (event dispatch history). + +```bash +# Show last 20 entries +jcode plugin audit + +# Show last 50 entries as JSON +jcode plugin audit --recent 50 --json +``` + +### `jcode plugin doctor` + +Diagnose plugin system issues. Can automatically fix problems with `--fix`. + +```bash +# Check for issues +jcode plugin doctor + +# Check and fix +jcode plugin doctor --fix +``` + +Output: +``` +Plugin system status: + Active plugins: 3 + Registered handlers: 12 + Audit trail entries: 47 + +✅ Plugin system is healthy +``` + +--- + +## Testing Plugins + +### Local Testing + +1. Place your plugin in `~/.jcode/plugins/`: + +```bash +cp my-plugin.ts ~/.jcode/plugins/ +``` + +2. Start jcode and verify it loads: + +```bash +jcode plugin list +jcode plugin info file:my-plugin.ts +``` + +3. Check the audit trail to see events: + +```bash +jcode plugin audit +``` + +### Debug Logging + +Enable debug logging to see plugin activity: + +```bash +RUST_LOG=jcode_plugin_runtime=debug jcode +``` + +This shows: +- Plugin discovery and loading +- Preflight analysis results +- Event dispatch to handlers +- Tool registration +- Capability checks + +### Preflight Validation + +Test your plugin's preflight analysis locally: + +```bash +# The preflight analyzer checks for: +# - eval() usage (warning) +# - new Function() (warning) +# - process.* references (warning) +# - require() usage (warning) +# - fetch() without network capability (warning) +# - exec()/spawn() without shell capability (warning) +# - rm -rf, sudo, chmod 777 (block) +``` + +### Common Issues + +| Issue | Cause | Fix | +|-------|-------|-----| +| Plugin not loading | Preflight block | Remove suspicious patterns | +| Plugin not loading | Invalid manifest | Check `package.json` `jcode` field | +| Events not firing | Wrong event names | Check event name casing | +| Tools not registered | Missing `register_tools` capability | Add to manifest capabilities | +| Config returns empty | Wrong key format | Use `plugin-name.key` format | +| Plugin timed out | Handler took too long | Optimize handler or increase timeout | + +### Sandbox Limitations + +The QuickJS sandbox does **not** provide: + +- `require()` or CommonJS modules +- `process`, `__dirname`, `__filename` +- Node.js built-ins (`fs`, `path`, `http`, etc.) +- DOM APIs +- `setTimeout`/`setInterval` (use `pi.sleep()` instead) +- Dynamic `import()` + +All host interaction must go through `pi.*` methods. + +--- + +## Publishing to npm + +### Package Structure + +``` +jcode-plugin-my-feature/ + package.json + tsconfig.json + src/ + server.ts # Server entry point + tui.ts # TUI entry point (optional) + dist/ + server.js # Compiled output + tui.js # Compiled output + README.md + LICENSE +``` + +### package.json + +```json +{ + "name": "jcode-plugin-my-feature", + "version": "1.0.0", + "description": "My awesome jcode plugin", + "main": "dist/server.js", + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "typescript": "^5.0.0" + }, + "jcode": { + "name": "my-feature", + "package_name": "jcode-plugin-my-feature", + "version": "1.0.0", + "description": "My awesome jcode plugin", + "author": "Your Name", + "license": "MIT", + "kind": "server", + "entry": { + "server": "dist/server.js" + }, + "capabilities": { + "events": ["TurnStart", "TurnEnd"], + "register_tools": true + }, + "engines": { + "jcode": ">=0.9.0" + } + } +} +``` + +### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"] +} +``` + +### Publishing Steps + +```bash +# 1. Build +npm run build + +# 2. Test locally +cp -r . ~/.jcode/cache/packages/jcode-plugin-my-feature/ +jcode plugin install jcode-plugin-my-feature + +# 3. Publish to npm +npm publish +``` + +### Naming Convention + +Use the prefix `jcode-plugin-` for discoverability: + +``` +jcode-plugin-analytics +jcode-plugin-github-integration +jcode-plugin-custom-tools +``` + +### Versioning + +Follow semver. The `engines.jcode` field specifies the minimum jcode version: + +```json +{ + "engines": { + "jcode": ">=0.9.0" + } +} +``` + +--- + +## FAQ + +### Q: Can I use npm packages in my plugin? + +**A:** No. The QuickJS sandbox does not support `require()` or dynamic `import()`. All functionality must be implemented using the built-in `pi.*` APIs or plain JavaScript. + +### Q: How do I persist data across sessions? + +**A:** Use `pi.kv.set(key, value)` and `pi.kv.get(key)`. Values are strings, so serialize objects with `JSON.stringify()`. + +```typescript +// Save +pi.kv.set('my-plugin.data', JSON.stringify({ count: 42 })); + +// Load +const data = JSON.parse(pi.kv.get('my-plugin.data') || '{}'); +``` + +### Q: Can my plugin make HTTP requests? + +**A:** Not directly. The sandbox does not provide `fetch()` or `XMLHttpRequest`. If you need network access, declare the `network` capability and use the runtime's bridge (if available). For most use cases, register a custom tool and let the model handle the request. + +### Q: How do I debug my plugin? + +**A:** Use `pi.logger.debug()` for detailed logging, and run jcode with `RUST_LOG=jcode_plugin_runtime=debug` to see all plugin activity. Check `jcode plugin audit` for event dispatch history. + +### Q: Can multiple plugins handle the same event? + +**A:** Yes. All registered handlers for an event are called concurrently via `join_all`. Each handler receives a clone of the event input. Multiple `PreToolUse` handlers can block a tool -- if any handler returns `{ action: 'block' }`, the tool is blocked. + +### Q: What happens if my plugin throws an error? + +**A:** The error is caught by the sandbox, logged as a warning, and the event dispatch continues with other handlers. The plugin does not crash jcode. + +### Q: How do I test my plugin without publishing? + +**A:** Place the `.ts` or `.js` file in `~/.jcode/plugins/` or configure a local path in `config.toml`: + +```toml +[[plugin.sources]] +type = "file" +path = "/path/to/my-plugin.ts" +``` + +### Q: Can I access environment variables? + +**A:** Not directly from the sandbox. Declare required env vars in your manifest's `env_vars` capability and access them through the runtime bridge (if available). + +### Q: What is the difference between `PreToolUse` and `ToolExecutionStart`? + +**A:** `ToolExecutionStart` fires first (read-only), then `PreToolUse` fires and can modify or block the tool. Use `ToolExecutionStart` for logging/monitoring and `PreToolUse` for policy enforcement. + +### Q: How do I disable my plugin temporarily? + +**A:** Use the CLI: + +```bash +jcode plugin disable npm:my-plugin +``` + +Or add to `config.toml`: + +```toml +[plugin.plugins.my-plugin] +enable = false +``` + +### Q: Can I register CLI commands? + +**A:** Yes, if you declare `register_commands: true` in your capabilities. The exact API for command registration is still evolving. + +### Q: What is the timeout for plugin handlers? + +**A:** Default timeouts: +- **Informational events** (SessionEnd, TurnEnd, PostCompact, AutoCompactionStart): 500ms +- **Actionable events** (PreToolUse, PostToolUse, etc.): 5000ms +- **Permission events**: 3600ms (1 hour, to allow user interaction) + +Per-plugin timeouts can be configured in `config.toml`: + +```toml +[plugin.plugins.my-plugin] +timeout_ms = 10000 +``` + +--- + +## Further Reading + +- [API Reference](./api-reference.md) -- Complete TypeScript type definitions +- [Example Plugin](../../examples/plugins/example-plugin.ts) -- Full working example +- [Security Model](../SAFETY_SYSTEM.md) -- jcode security architecture +- [Config Reference](../CONFIG_REFERENCE.md) -- Full config.toml documentation diff --git a/docs/plugins/api-reference.md b/docs/plugins/api-reference.md new file mode 100644 index 000000000..fc1c2545c --- /dev/null +++ b/docs/plugins/api-reference.md @@ -0,0 +1,1127 @@ +# jcode Plugin API Reference + +Complete TypeScript type definitions for the jcode plugin system. All types are derived from the actual Rust implementation in `jcode-plugin-core` and `jcode-plugin-runtime`. + +--- + +## Table of Contents + +- [Plugin Global (`pi`)](#plugin-global-pi) +- [Plugin Manifest Types](#plugin-manifest-types) +- [Event Types](#event-types) +- [Handler Result Types](#handler-result-types) +- [Capability Types](#capability-types) +- [Security Types](#security-types) +- [Configuration Types](#configuration-types) +- [Error Types](#error-types) +- [Internal Types](#internal-types) + +--- + +## Plugin Global (`pi`) + +The `pi` object is injected into the QuickJS sandbox as `__jcode_pi` and provides all plugin APIs. + +```typescript +interface PluginApi { + /** Plugin identifier (e.g., "npm:my-plugin" or "file:/path/to/plugin.ts") */ + readonly id: string; + + /** Plugin display name */ + readonly name: string; + + /** Plugin version string */ + readonly version: string; + + /** Current working directory of the jcode process */ + readonly cwd: string; + + /** Structured logger */ + readonly logger: PluginLogger; + + /** Durable key-value storage */ + readonly kv: PluginKV; + + /** + * Register an event handler. + * @param event - Event name (e.g., "TurnStart", "PreToolUse") + * @param handler - Callback invoked when the event fires + */ + on(event: string, handler: (event: any) => void | HandlerResult): void; + + /** + * Register a custom tool. + * @param tool - Tool definition with name, description, parameters schema, and handler + */ + registerTool(tool: ToolDefinition): void; + + /** + * Read a plugin configuration value. + * @param key - Configuration key (e.g., "my-plugin.apiKey") + * @returns The configuration value, or empty string if not set + */ + getConfig(key: string): string; + + /** + * Block execution for the specified duration. + * @param ms - Duration in milliseconds + */ + sleep(ms: number): void; + + /** + * Generate a new UUID v4. + * @returns UUID string + */ + uuid(): string; +} + +interface PluginLogger { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + debug(message: string): void; +} + +interface PluginKV { + get(key: string): string; + set(key: string, value: string): void; +} +``` + +--- + +## Plugin Manifest Types + +### PluginManifest + +```typescript +interface PluginManifest { + /** Plugin short name */ + name: string; + + /** npm package name */ + package_name: string; + + /** Semver version string */ + version: string; + + /** Human-readable description */ + description?: string; + + /** Plugin author */ + author?: string; + + /** SPDX license identifier */ + license?: string; + + /** Where the plugin runs */ + kind?: PluginKind; + + /** Entry point paths */ + entry?: PluginEntry; + + /** Required capabilities */ + capabilities?: PluginCapabilities; + + /** Toggleable features */ + features?: Record; + + /** User-configurable settings */ + settings?: Record; + + /** Required engine versions */ + engines?: PluginEngines; + + /** Icon path or URL */ + icon?: string; + + /** Project homepage URL */ + homepage?: string; + + /** Source repository URL */ + repository?: string; + + /** Categorization tags */ + tags?: string[]; +} +``` + +### PluginKind + +```typescript +type PluginKind = "server" | "tui" | "both"; +``` + +- `"server"` -- Plugin runs in server/headless mode (default). +- `"tui"` -- Plugin runs in TUI mode only. +- `"both"` -- Plugin runs in both modes. + +### PluginEntry + +```typescript +interface PluginEntry { + /** Entry point for server mode */ + server?: string; + + /** Entry point for TUI mode */ + tui?: string; + + /** Entry point for both modes */ + both?: string; +} +``` + +### PluginFeature + +```typescript +interface PluginFeature { + /** Feature description */ + description: string; + + /** Whether the feature is enabled by default */ + default?: boolean; + + /** Entry point for this feature */ + entry?: string; + + /** Additional capabilities required by this feature */ + additional_capabilities?: PluginCapabilities; +} +``` + +### PluginEngines + +```typescript +interface PluginEngines { + /** Required jcode version range (e.g., ">=0.9.0") */ + jcode?: string; +} +``` + +### PluginState + +```typescript +type PluginState = + | "Discovered" + | "Loading" + | "Loaded" + | "Active" + | "Disabled" + | "Blocked" + | { Error: string }; +``` + +### PluginOrigin + +```typescript +type PluginOrigin = + | { NpmPackage: { name: string; version: string } } + | { LocalFile: { path: string } } + | { Builtin: { name: string } } + | { Remote: { url: string } }; +``` + +### PluginId + +```typescript +interface PluginId { + /** Full identifier string (e.g., "npm:my-plugin") */ + as_str(): string; + + /** Short name without prefix (e.g., "my-plugin") */ + short_name(): string; + + /** String representation */ + toString(): string; +} + +// Factory methods +declare namespace PluginId { + function npm(name: string): PluginId; + function file(path: string): PluginId; + function bundled(name: string): PluginId; +} +``` + +### PluginVersion + +```typescript +interface PluginVersion { + /** Plugin version */ + semver: string; + + /** Minimum required jcode version */ + jcode_min_version?: string; + + /** Maximum supported jcode version */ + jcode_max_version?: string; +} +``` + +--- + +## Event Types + +### PluginEvent + +All 28 event types: + +```typescript +type PluginEvent = + // Tool events + | "PreToolUse" // 0 - Before tool execution + | "PostToolUse" // 1 - After tool execution + | "PostToolUseFailure" // 2 - Tool execution failed + | "ToolExecutionStart" // 3 - Tool execution begins + | "ToolExecutionEnd" // 4 - Tool execution ends + + // Session events + | "SessionStart" // 5 - Session begins + | "SessionEnd" // 6 - Session ends + | "SessionSwitch" // 7 - User switches session + | "SessionCompact" // 8 - Session compacted + | "SessionBeforeCompact" // 9 - Before compaction + | "SessionShutdown" // 10 - Session system shutdown + + // Permission events + | "PermissionRequest" // 12 - Permission decision needed + | "PermissionDenied" // 13 - Permission denied + + // Agent events + | "AgentStart" // 14 - Agent starts + | "AgentEnd" // 15 - Agent ends + + // Turn events + | "TurnStart" // 16 - Turn begins + | "TurnEnd" // 17 - Turn ends + + // Message events + | "MessageStart" // 18 - Message begins + | "MessageEnd" // 19 - Message ends + + // Compact events + | "PreCompact" // 20 - Before compaction + | "PostCompact" // 21 - After compaction + + // Task events + | "TaskCreated" // 22 - Task created + | "TaskCompleted" // 23 - Task completed + + // Other events + | "AutoCompactionStart" // 24 - Auto-compaction triggered + | "UserPromptSubmit" // 25 - User submits prompt + | "Stop" // 26 - Agent stops + | "Notification"; // 27 - System notification +``` + +### EventInput Types + +Each event has a specific input shape: + +```typescript +// Tool events +interface PreToolUseInput { + event: "PreToolUse"; + tool_name: string; + tool_input: Record; + session_id: string; +} + +interface PostToolUseInput { + event: "PostToolUse"; + tool_name: string; + tool_input: Record; + tool_output: unknown; + duration_ms: number; + success: boolean; + session_id: string; +} + +interface PostToolUseFailureInput { + event: "PostToolUseFailure"; + tool_name: string; + tool_input: Record; + error: string; + duration_ms: number; + session_id: string; +} + +interface ToolExecutionStartInput { + event: "ToolExecutionStart"; + tool_name: string; + tool_input: Record; + session_id: string; +} + +interface ToolExecutionEndInput { + event: "ToolExecutionEnd"; + tool_name: string; + tool_output: unknown; + duration_ms: number; + session_id: string; +} + +// Session events +interface SessionStartInput { + event: "SessionStart"; + session_id: string; + project_dir: string; + model: string; + provider: string; +} + +interface SessionEndInput { + event: "SessionEnd"; + session_id: string; + duration_seconds: number; + message_count: number; +} + +interface SessionSwitchInput { + event: "SessionSwitch"; + session_id: string; + target_session_id: string; +} + +interface SessionCompactInput { + event: "SessionCompact"; + session_id: string; + reason: string; +} + +// Permission events +interface PermissionRequestInput { + event: "PermissionRequest"; + action: string; + tool_name?: string; + target?: string; + session_id: string; +} + +// Agent events +interface AgentStartInput { + event: "AgentStart"; + session_id: string; + system_prompt: unknown; + tools: unknown; +} + +interface AgentEndInput { + event: "AgentEnd"; + session_id: string; + duration_seconds: number; + message_count: number; +} + +// Turn events +interface TurnStartInput { + event: "TurnStart"; + session_id: string; + turn_number: number; + messages: unknown; +} + +interface TurnEndInput { + event: "TurnEnd"; + session_id: string; + turn_number: number; + duration_ms: number; +} + +// Message events +interface MessageStartInput { + event: "MessageStart"; + session_id: string; + role: string; // "user" | "assistant" | "system" +} + +interface MessageEndInput { + event: "MessageEnd"; + session_id: string; + role: string; + content: string; +} + +// Compact events +interface PreCompactInput { + event: "PreCompact"; + session_id: string; + message_count: number; + token_count: number; + system_prompt: unknown; +} + +interface PostCompactInput { + event: "PostCompact"; + session_id: string; + messages_removed: number; + tokens_saved: number; +} + +// Other events +interface UserPromptSubmitInput { + event: "UserPromptSubmit"; + content: string; + session_id: string; +} + +interface StopInput { + event: "Stop"; + session_id: string; + reason: string; +} + +interface NotificationInput { + event: "Notification"; + level: string; // "info" | "warn" | "error" + message: string; + session_id?: string; +} + +// Union type +type EventInput = + | PreToolUseInput + | PostToolUseInput + | PostToolUseFailureInput + | ToolExecutionStartInput + | ToolExecutionEndInput + | SessionStartInput + | SessionEndInput + | SessionSwitchInput + | SessionCompactInput + | PermissionRequestInput + | AgentStartInput + | AgentEndInput + | TurnStartInput + | TurnEndInput + | MessageStartInput + | MessageEndInput + | PreCompactInput + | PostCompactInput + | UserPromptSubmitInput + | StopInput + | NotificationInput; +``` + +### EventOutput Types + +Events that support modification have output types: + +```typescript +interface PreToolUseOutput { + event: "PreToolUse"; + /** If set, tool is blocked with this reason */ + block?: string; + /** If set, replaces the tool input */ + modified_input?: Record; +} + +interface PostToolUseOutput { + event: "PostToolUse"; + /** If set, replaces the tool output */ + modified_output?: unknown; +} + +interface PermissionRequestOutput { + event: "PermissionRequest"; + /** Auto-decision */ + decision?: PermissionDecision; + /** Explanation message */ + message?: string; +} + +interface AgentStartOutput { + event: "AgentStart"; + /** Lines to append to system prompt */ + additional_system_prompt: string[]; +} + +interface PreCompactOutput { + event: "PreCompact"; + /** Modified system prompt */ + system_prompt?: unknown; + /** Additional instructions */ + instructions?: string; + /** If true, prevent compaction */ + prevent?: boolean; +} + +interface UserPromptSubmitOutput { + event: "UserPromptSubmit"; + /** If set, replaces the user prompt */ + modified_prompt?: string; +} + +interface NotificationOutput { + event: "Notification"; + /** If true, suppress the notification */ + suppress?: boolean; + /** If set, replaces the notification message */ + modified_message?: string; +} + +interface StopOutput { + event: "Stop"; + /** Stop reason (can be modified) */ + reason: string; +} + +// Union type +type EventOutput = + | PreToolUseOutput + | PostToolUseOutput + | PermissionRequestOutput + | AgentStartOutput + | PreCompactOutput + | UserPromptSubmitOutput + | NotificationOutput + | StopOutput; +``` + +--- + +## Handler Result Types + +### HandlerResult + +Returned by event handlers to control event outcome: + +```typescript +interface HandlerResult { + /** Action to take */ + action: HandlerAction; + + /** Optional output data */ + output?: unknown; + + /** Optional error message */ + error?: string; +} +``` + +### HandlerAction + +```typescript +type HandlerAction = + | "continue" // Proceed normally (default) + | { block: string } // Block with reason + | "allow" // Explicitly allow + | "deny" // Explicitly deny + | "error"; // Signal an error +``` + +### PermissionDecision + +Used in `PermissionRequest` output: + +```typescript +type PermissionDecision = "allow" | "deny" | "ask"; +``` + +### ToolDefinition + +Used with `pi.registerTool()`: + +```typescript +interface ToolDefinition { + /** Tool name (must be unique across all plugins) */ + name: string; + + /** Description shown to the model */ + description: string; + + /** JSON Schema for parameters */ + parameters: JSONSchema; + + /** Handler function invoked when the tool is called */ + handler: (params: Record) => unknown; +} + +// JSON Schema (simplified) +interface JSONSchema { + type: "object"; + properties: Record; + required?: string[]; +} + +interface JSONSchemaProperty { + type: "string" | "number" | "boolean" | "object" | "array"; + description?: string; + default?: unknown; + enum?: unknown[]; + items?: JSONSchemaProperty; + properties?: Record; +} +``` + +--- + +## Capability Types + +### PluginCapabilities + +```typescript +interface PluginCapabilities { + /** Allowed read paths (e.g., ["$HOME/.jcode/data"]) */ + fs_read?: string[]; + + /** Allowed write paths */ + fs_write?: string[]; + + /** Allowed network hosts (e.g., ["api.github.com"]) */ + network?: string[]; + + /** Allow shell command execution */ + shell?: boolean; + + /** Allow registering custom tools */ + register_tools?: boolean; + + /** Allow registering CLI commands */ + register_commands?: boolean; + + /** Allow registering LLM providers */ + register_providers?: boolean; + + /** Allow reading jcode config */ + read_config?: boolean; + + /** Allow writing jcode config */ + write_config?: boolean; + + /** Allowed environment variables */ + env_vars?: string[]; + + /** Events the plugin can subscribe to */ + events?: string[]; + + /** Allow direct LLM access */ + llm_access?: boolean; + + /** Allow session manipulation */ + session_access?: boolean; +} +``` + +### CapabilitySet + +Used in security chain evaluation: + +```typescript +interface CapabilitySet { + /** Filesystem paths (matched by prefix) */ + fs_paths: string[]; + + /** Network hosts (matched by substring) */ + hosts: string[]; + + /** Tool names (matched exactly) */ + tools: string[]; + + /** Environment variable names (matched exactly) */ + env_vars: string[]; + + /** Shell commands (matched exactly) */ + shell_commands: string[]; + + /** Config keys (matched exactly) */ + config_keys: string[]; + + /** Provider names (matched exactly) */ + providers: string[]; +} +``` + +### CapabilityAction + +```typescript +type CapabilityAction = + | "read" // Filesystem read + | "write" // Filesystem write + | "execute" // Shell execution + | "network" // Network access + | "config" // Config access + | "session" // Session access + | "provider"; // Provider access +``` + +--- + +## Security Types + +### CapabilityChain + +The security evaluation chain: + +```typescript +interface CapabilityChain { + /** Plugin-specific deny rules (highest priority) */ + deny_list: CapabilitySet; + + /** System-wide deny rules */ + global_deny: CapabilitySet; + + /** Plugin-specific allow rules */ + allow_list: CapabilitySet; + + /** Fallback when no rules match */ + global_default: AccessDefault; + + /** Access mode */ + mode: AccessMode; +} +``` + +### AccessDefault + +```typescript +type AccessDefault = "deny" | "allow" | "ask"; +``` + +- `"deny"` -- Deny access by default (most secure). +- `"allow"` -- Allow access by default (least secure). +- `"ask"` -- Prompt user for each access request. + +### AccessMode + +```typescript +type AccessMode = "all" | "trusted" | "none" | "interactive"; +``` + +- `"all"` -- Normal evaluation through the chain. +- `"trusted"` -- Only explicit deny rules block access. +- `"none"` -- All access denied (kill switch). +- `"interactive"` -- Requires user approval for each access. + +### AccessDecision + +Result of capability check: + +```typescript +type AccessDecision = + | { Allowed: string } // Access granted with reason + | { Denied: string } // Access denied with reason + | { NeedsApproval: string }; // Requires user approval +``` + +--- + +## Configuration Types + +### PluginConfig + +Configuration from `config.toml` `[plugin]` section: + +```typescript +interface PluginConfig { + /** Plugins to explicitly enable */ + enable: string[]; + + /** Plugins to explicitly disable */ + disable: string[]; + + /** Access mode override */ + mode?: string; + + /** If true, fail on any plugin load error */ + fail_closed?: boolean; + + /** Plugin sources */ + sources?: PluginSource[]; + + /** Per-plugin settings */ + settings: Record>; + + /** Feature toggles */ + features: Record; + + /** Per-plugin overrides */ + plugins: Record; + + /** Skip all plugin hooks */ + skip_hooks: boolean; + + /** Force deny all plugin actions */ + force_deny: boolean; +} +``` + +### PluginSource + +```typescript +type PluginSource = + | { type: "npm"; package: string; version?: string } + | { type: "file"; path: string } + | { type: "directory"; path: string }; +``` + +### PluginPerPluginConfig + +```typescript +interface PluginPerPluginConfig { + /** Enable/disable this plugin */ + enable?: boolean; + + /** Handler timeout in milliseconds */ + timeout_ms?: number; +} +``` + +### SettingSchema + +User-configurable setting definitions: + +```typescript +type SettingSchema = + | StringSetting + | NumberSetting + | BooleanSetting + | EnumSetting + | ArraySetting + | ObjectSetting; + +interface StringSetting { + type: "string"; + description: string; + default?: string; + /** If true, value is masked in output */ + secret?: boolean; + /** Environment variable to read from */ + env?: string; + /** Regex pattern for validation */ + pattern?: string; + /** Maximum string length */ + max_length?: number; +} + +interface NumberSetting { + type: "number"; + description: string; + default?: number; + min?: number; + max?: number; +} + +interface BooleanSetting { + type: "boolean"; + description: string; + default?: boolean; +} + +interface EnumSetting { + type: "enum"; + description: string; + default?: string; + values: string[]; +} + +interface ArraySetting { + type: "array"; + description: string; + default?: unknown[]; + items: SettingSchema; + max_items?: number; +} + +interface ObjectSetting { + type: "object"; + description: string; + default?: unknown; + properties: Record; +} +``` + +### DiscoveryPaths + +Plugin discovery directories: + +```typescript +interface DiscoveryPaths { + /** Directories to scan for plugin files */ + plugin_dirs: string[]; + + /** npm cache directory */ + npm_cache: string; + + /** Tool directories */ + tool_dirs: string[]; +} + +// Default paths: +// plugin_dirs: ["~/.jcode/plugins"] +// npm_cache: "~/.jcode/cache/packages" +// tool_dirs: ["~/.jcode/tools"] +``` + +--- + +## Error Types + +### PluginError + +All possible plugin errors: + +```typescript +type PluginError = + | { InvalidManifest: string } // Invalid manifest format + | { NotFound: string } // Plugin not found + | { Load: string } // Failed to load plugin + | { Runtime: string } // Runtime error + | { Eval: string } // QuickJS evaluation error + | { QuickJs: string } // QuickJS runtime error + | { Transpile: string } // SWC transpilation error + | { Timeout: Duration } // Handler timed out + | { Capability: string } // Capability denied + | { Npm: string } // npm error + | { Io: string } // I/O error + | { Serde: string } // Serialization error + | { Other: string }; // Other error +``` + +--- + +## Internal Types + +These types are used internally by the runtime but are useful for understanding the system. + +### PreflightResult + +Result of static analysis before plugin loading: + +```typescript +interface PreflightResult { + /** Whether the plugin passed all checks (no blocks) */ + passed: boolean; + + /** Non-fatal warnings (logged but plugin still loads) */ + warnings: string[]; + + /** Fatal blocks (prevent loading) */ + blocks: string[]; + + /** Capabilities declared in the plugin manifest */ + declared_capabilities: PluginCapabilities; + + /** Patterns detected during analysis */ + detected_patterns: string[]; + + /** Detailed static analysis breakdown */ + static_analysis: StaticAnalysis; +} +``` + +### StaticAnalysis + +Detailed preflight analysis: + +```typescript +interface StaticAnalysis { + /** Code uses eval() */ + has_eval: boolean; + + /** Code uses dynamic import() */ + has_dynamic_import: boolean; + + /** Code uses fetch() */ + has_fetch: boolean; + + /** Code references process.* */ + has_process_access: boolean; + + /** Detected filesystem access patterns */ + has_fs_access: string[]; + + /** Detected network access patterns */ + has_network_access: string[]; + + /** Suspicious string literals found */ + suspicious_strings: string[]; +} +``` + +### HandlerSlot + +Internal handler registration: + +```typescript +// Handlers are registered as async functions that receive +// EventInput and optional EventOutput, returning HandlerResult +type HandlerSlot = ( + input: EventInput, + output?: EventOutput +) => Promise; +``` + +### DualTimeout + +Timeout configuration for sandbox execution: + +```typescript +interface DualTimeout { + /** Timeout for informational events (default: 500ms) */ + info: number; + + /** Timeout for actionable events (default: 5000ms) */ + actionable: number; + + /** Timeout for permission events (default: 3600000ms / 1 hour) */ + permission?: number; +} +``` + +### RuntimeConfig + +QuickJS runtime configuration: + +```typescript +interface RuntimeConfig { + /** Maximum concurrent plugin executions (default: 4) */ + max_concurrent: number; + + /** Maximum runtime instances in pool (default: 8) */ + max_runtimes: number; + + /** Maximum stack size in bytes (default: 512KB) */ + max_stack_size: number; + + /** Memory limit in bytes (default: 50MB) */ + memory_limit: number; + + /** GC threshold in bytes (default: 10MB) */ + gc_threshold: number; +} +``` + +--- + +## Type Mapping: Rust to TypeScript + +For reference, here is how Rust types map to TypeScript: + +| Rust Type | TypeScript Type | +|-----------|----------------| +| `PluginId` | `string` (e.g., `"npm:my-plugin"`) | +| `PluginEvent` | `string` union (e.g., `"PreToolUse" \| "PostToolUse"`) | +| `EventInput` | Tagged union with `event` discriminant | +| `EventOutput` | Tagged union with `event` discriminant | +| `HandlerResult` | `{ action: HandlerAction; output?: unknown; error?: string }` | +| `HandlerAction` | `"continue" \| { block: string } \| "allow" \| "deny" \| "error"` | +| `PermissionDecision` | `"allow" \| "deny" \| "ask"` | +| `AccessDecision` | `{ Allowed: string } \| { Denied: string } \| { NeedsApproval: string }` | +| `AccessMode` | `"all" \| "trusted" \| "none" \| "interactive"` | +| `AccessDefault` | `"deny" \| "allow" \| "ask"` | +| `CapabilityAction` | `"read" \| "write" \| "execute" \| "network" \| "config" \| "session" \| "provider"` | +| `PluginKind` | `"server" \| "tui" \| "both"` | +| `PluginState` | `"Discovered" \| "Loading" \| "Loaded" \| "Active" \| "Disabled" \| "Blocked" \| { Error: string }` | +| `PluginSource` | `{ type: "npm"; package: string } \| { type: "file"; path: string } \| { type: "directory"; path: string }` | +| `SettingSchema` | Tagged union with `type` discriminant | +| `PluginCapabilities` | Object with optional fields | +| `CapabilitySet` | Object with string array fields | +| `serde_json::Value` | `unknown` or `any` | +| `Option` | `T \| undefined` | +| `Vec` | `T[]` | +| `HashMap` | `Record` | +| `u32`, `u64`, `f64` | `number` | +| `bool` | `boolean` | +| `String`, `&str` | `string` | +| `Duration` | `number` (milliseconds) | diff --git a/examples/plugins/example-plugin.ts b/examples/plugins/example-plugin.ts new file mode 100644 index 000000000..6f241635c --- /dev/null +++ b/examples/plugins/example-plugin.ts @@ -0,0 +1,286 @@ +/** + * Example Plugin for jcode + * + * This demonstrates the full plugin API including lifecycle hooks, + * event handlers, tool registration, state management, configuration, + * persistence, and capability declarations. + * + * Plugins run in QuickJS sandboxes with no DOM, no Node.js built-ins, + * and limited global objects. The runtime injects a `__jcode_pi` global + * (aliased as `pi` below) that provides all plugin APIs. + * + * Plugin lifecycle: + * 1. Discovery ─ jcode scans plugin directories / npm cache / config + * 2. Preflight ─ static analysis for capability enforcement + * 3. Load ─ eval (transpile TS→JS, then QuickJS eval) + * 4. Activate ─ handlers are registered into the dispatcher + * 5. Runtime ─ events dispatched → plugin handlers invoked + * 6. Unload ─ cleanup on session end or plugin disable + */ + +// ─── Plugin Identity & Manifest ──────────────────────────────────────────── +// +// Every plugin MUST export a default object with identity metadata. +// jcode reads this at load time to register the plugin and wire up +// lifecycle hooks. + +type HandlerResult = { action: string; output?: unknown; error?: string }; + +interface PluginManifest { + name: string; + version: string; + description?: string; + author?: string; + capabilities: { + fs_read?: string[]; + fs_write?: string[]; + network?: string[]; + shell?: boolean; + register_tools?: boolean; + read_config?: boolean; + write_config?: boolean; + events?: string[]; + llm_access?: boolean; + session_access?: boolean; + }; +} + +const manifest: PluginManifest = { + name: 'example-plugin', + version: '1.0.0', + description: 'Demo plugin showing all jcode plugin API capabilities', + author: 'jcode team', + + // Declare required capabilities. The runtime checks these against + // the plugin's static analysis and the user's security policy. + capabilities: { + fs_read: ['$HOME/.jcode/data'], + network: ['api.github.com'], + register_tools: true, + read_config: true, + events: ['TurnStart', 'TurnEnd', 'MessageStart', 'MessageEnd', 'PreToolUse', 'PostToolUse', 'Notification'], + }, +}; + +// ─── State Management ────────────────────────────────────────────────────── +// +// Module-level variables persist for the plugin's lifetime (from load +// to unload). Use `pi.kv` for durable cross-session persistence. + +let turnCount = 0; +let totalToolDuration = 0; +const toolCallHistory: Array<{ name: string; startedAt: number }> = []; + +// ─── Event Handlers ──────────────────────────────────────────────────────── +// +// Handlers are registered via pi.on(eventName, callback). +// The callback receives an event object with fields specific to the event type. +// Return value (optional) can modify the event's outcome for events that +// support it (e.g. PreToolUse can block or modify input). + +function setupHandlers(): void { + const logger = pi.logger; + + /** + * TurnStart — fired when a conversation turn begins. + * Event fields: { session_id, turn_number, messages } + */ + pi.on('TurnStart', (event: { session_id: string; turn_number: number; messages: unknown }) => { + turnCount++; + logger.info(`[example-plugin] Turn #${event.turn_number} started (session: ${event.session_id})`); + }); + + /** + * TurnEnd — fired when a turn completes. + * Event fields: { session_id, turn_number, duration_ms } + */ + pi.on('TurnEnd', (event: { session_id: string; turn_number: number; duration_ms: number }) => { + logger.info(`[example-plugin] Turn #${event.turn_number} ended (${event.duration_ms}ms)`); + logger.info(`[example-plugin] Total tools duration this session: ${totalToolDuration}ms`); + }); + + /** + * MessageStart — fired when the model or user starts a new message. + * Event fields: { session_id, role } (role = "user" | "assistant" | "system") + */ + pi.on('MessageStart', (event: { session_id: string; role: string }) => { + logger.debug(`[example-plugin] ${event.role} message starting`); + }); + + /** + * MessageEnd — fired when a message is fully produced. + * Event fields: { session_id, role, content } + */ + pi.on('MessageEnd', (event: { session_id: string; role: string; content: string }) => { + logger.debug(`[example-plugin] ${event.role} message ended (${event.content.length} chars)`); + }); + + /** + * PreToolUse — fired BEFORE a tool is executed. + * Event fields: { tool_name, tool_input, session_id } + * + * Can return a modified input or block the tool entirely. + * Return { action: 'block', output: 'reason' } to prevent execution. + * Return { action: 'continue', output: { modified_input } } to modify args. + */ + pi.on('PreToolUse', (event: { tool_name: string; tool_input: Record; session_id: string }) => { + logger.info(`[example-plugin] Tool about to run: ${event.tool_name}`); + toolCallHistory.push({ name: event.tool_name, startedAt: Date.now() }); + + // Example: block dangerous-sounding tools + if (event.tool_name === 'dangerous_tool' || event.tool_name === 'rm') { + return { action: 'block', output: 'Blocked by example-plugin safety policy' }; + } + + // Example: auto-append a flag to Read tool calls + if (event.tool_name === 'Read') { + const input = { ...event.tool_input }; + if (!input.limit) { + input.limit = 200; + } + return { action: 'continue', output: { modified_input: input } }; + } + + return { action: 'continue' }; + }); + + /** + * PostToolUse — fired AFTER a tool returns successfully. + * Event fields: { tool_name, tool_input, tool_output, duration_ms, success, session_id } + * + * Can optionally modify the tool output returned to the model. + */ + pi.on('PostToolUse', (event: { + tool_name: string; + tool_input: Record; + tool_output: unknown; + duration_ms: number; + success: boolean; + session_id: string; + }) => { + totalToolDuration += event.duration_ms; + logger.info(`[example-plugin] Tool completed: ${event.tool_name} (${event.duration_ms}ms, success=${event.success})`); + }); + + /** + * Notification — arbitrary notifications from the system. + * Event fields: { level, message, session_id? } + * Can suppress or modify the notification. + */ + pi.on('Notification', (event: { level: string; message: string; session_id?: string }) => { + logger.debug(`[example-plugin] Notification [${event.level}]: ${event.message}`); + }); +} + +// ─── Tool Registration ───────────────────────────────────────────────────── +// +// Custom tools are registered via pi.registerTool(toolDefinition). +// The tool definition must include a name, description, parameter schema, +// and a handler function. The handler runs inside the QuickJS sandbox. + +function registerCustomTools(): void { + pi.registerTool({ + name: 'example_hello', + description: 'Greet a user by name. Useful for demonstrating plugin tool registration.', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name to greet' }, + title: { type: 'string', description: 'Optional title (Mr., Dr., etc.)', default: '' }, + }, + required: ['name'], + }, + handler: (params: { name: string; title?: string }) => { + const prefix = params.title ? `${params.title} ` : ''; + return `Hello, ${prefix}${params.name}! From example-plugin v1.0.0`; + }, + }); + + pi.registerTool({ + name: 'example_counter', + description: 'Return the current turn counter value maintained by the plugin.', + parameters: { type: 'object', properties: {} }, + handler: () => { + return { turnCount, totalToolDuration }; + }, + }); + + pi.registerTool({ + name: 'example_echo', + description: 'Echo back whatever input is provided. Use to verify plugin tool routing.', + parameters: { + type: 'object', + properties: { + message: { type: 'string', description: 'The message to echo' }, + }, + required: ['message'], + }, + handler: (params: { message: string }) => { + return `Echo: ${params.message}`; + }, + }); +} + +// ─── Configuration & Persistence ────────────────────────────────────────── +// +// pi.getConfig(key) reads plugin config from the global jcode config. +// pi.kv.get(key) / pi.kv.set(key, value) provides durable storage +// that persists across sessions (backed by the runtime). + +function loadConfig(): Record { + const logLevel = pi.getConfig('example-plugin.logLevel') || 'info'; + const maxHistory = pi.getConfig('example-plugin.maxHistory') || 100; + + // Restore persistent state + const saved = pi.kv.get('example-plugin.toolHistory'); + const history = saved ? JSON.parse(saved) : []; + + return { logLevel, maxHistory, history }; +} + +function saveState(): void { + pi.kv.set('example-plugin.toolHistory', JSON.stringify(toolCallHistory.slice(-100))); +} + +// ─── Plugin Load ─────────────────────────────────────────────────────────── +// +// The module scope runs at load time. This is where you wire up +// everything: register tools, bind event handlers, read config. +// pi.logger is available immediately. + +const config = loadConfig(); +const logger = pi.logger; + +logger.info(`[example-plugin] Loading v${manifest.version} (config: ${JSON.stringify(config)})`); + +// Register event handlers for lifecycle hooks. +setupHandlers(); + +// Register custom tools that the model can invoke. +registerCustomTools(); + +// Log our identity. +logger.info(`[example-plugin] Registered plugin: ${pi.name}@${pi.version}`); + +// ─── Graceful Shutdown (optional) ────────────────────────────────────────── +// +// pi.on('SessionEnd') or pi.on('Stop') can be used for cleanup. + +pi.on('SessionEnd', () => { + saveState(); + logger.info('[example-plugin] Plugin shutting down, state persisted'); +}); + +// ─── Default Export ──────────────────────────────────────────────────────── +// +// jcode loads the module and reads the default export for plugin metadata. +// The actual work (handlers, tools, config) happens at module scope above, +// but the export ensures the runtime can identify the plugin. + +export default { + name: manifest.name, + version: manifest.version, + description: manifest.description, + author: manifest.author, + manifest, +}; diff --git a/src/cli/args.rs b/src/cli/args.rs index beeb37770..8142e14c7 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -460,6 +460,10 @@ pub(crate) enum Command { #[command(subcommand)] Skills(SkillsCommand), + /// Manage plugins + #[command(subcommand)] + Plugin(PluginCommand), + /// Manage trusted project-local MCP configs (`.jcode/mcp.json`, `.claude/mcp.json`). /// Trust is enforced when `JCODE_REQUIRE_MCP_TRUST=1` (auto-set by `--safe-eval`). #[command(subcommand)] @@ -1253,6 +1257,52 @@ pub(crate) enum AmbientCommand { RunVisible, } +#[derive(Subcommand, Debug)] +pub(crate) enum PluginCommand { + /// List installed plugins + List, + /// Install a plugin from npm or local path + Install { + /// Plugin package name (e.g., "jcode-plugin-foo" or "/path/to/plugin") + source: String, + }, + /// Uninstall a plugin + Uninstall { + /// Plugin ID to remove + id: String, + }, + /// Show detailed info about a plugin + Info { + /// Plugin ID + id: String, + }, + /// Enable a disabled plugin + Enable { + /// Plugin ID to enable + id: String, + }, + /// Disable an active plugin + Disable { + /// Plugin ID to disable + id: String, + }, + /// Show plugin audit trail + Audit { + /// Number of recent entries to show + #[arg(long, default_value_t = 20)] + recent: usize, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Diagnose plugin system issues + Doctor { + /// Attempt to fix issues automatically + #[arg(long)] + fix: bool, + }, +} + #[derive(Subcommand, Debug)] pub(crate) enum MemoryCommand { /// List all stored memories diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 901fa3f4b..d4b4e93a0 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1396,6 +1396,158 @@ fn is_executable_file(path: &Path) -> bool { path.is_file() } +pub fn run_plugin_list_command() -> Result<()> { + let system = crate::plugin::plugin_system() + .ok_or_else(|| anyhow::anyhow!("Plugin system not initialized"))?; + let rt = tokio::runtime::Handle::current(); + let plugins = rt.block_on(system.list_plugins()); + if plugins.is_empty() { + println!("No plugins installed."); + } else { + println!("Installed plugins:"); + for (id, state) in &plugins { + println!(" {id} [{state}]"); + } + } + Ok(()) +} + +pub async fn run_plugin_install_command(source: &str) -> Result<()> { + let system = crate::plugin::plugin_system() + .ok_or_else(|| anyhow::anyhow!("Plugin system not initialized"))?; + system.install(source).await?; + println!("Plugin installed successfully: {source}"); + Ok(()) +} + +pub async fn run_plugin_uninstall_command(id: &str) -> Result<()> { + let system = crate::plugin::plugin_system() + .ok_or_else(|| anyhow::anyhow!("Plugin system not initialized"))?; + system.uninstall(id).await?; + println!("Plugin uninstalled: {id}"); + Ok(()) +} + +pub fn run_plugin_info_command(id: &str) -> Result<()> { + let system = crate::plugin::plugin_system() + .ok_or_else(|| anyhow::anyhow!("Plugin system not initialized"))?; + let rt = tokio::runtime::Handle::current(); + let plugins = rt.block_on(system.list_plugins()); + let plugin = plugins + .into_iter() + .find(|(pid, _)| pid.as_str() == id) + .ok_or_else(|| anyhow::anyhow!("Plugin not found: {id}"))?; + println!("Plugin: {}", plugin.0); + println!("State: {}", plugin.1); + Ok(()) +} + +/// Enable a previously disabled plugin. +pub fn run_plugin_enable_command(id: &str) -> Result<()> { + let system = crate::plugin::plugin_system() + .ok_or_else(|| anyhow::anyhow!("Plugin system not initialized"))?; + let rt = tokio::runtime::Handle::current(); + rt.block_on(system.enable_plugin(id))?; + println!("✅ Plugin '{id}' enabled"); + Ok(()) +} + +/// Disable an active plugin. +pub fn run_plugin_disable_command(id: &str) -> Result<()> { + let system = crate::plugin::plugin_system() + .ok_or_else(|| anyhow::anyhow!("Plugin system not initialized"))?; + let rt = tokio::runtime::Handle::current(); + rt.block_on(system.disable_plugin(id))?; + println!("⏸ Plugin '{id}' disabled"); + Ok(()) +} + +/// Show the plugin audit trail. +pub fn run_plugin_audit_command(recent: usize, json: bool) -> Result<()> { + let system = crate::plugin::plugin_system() + .ok_or_else(|| anyhow::anyhow!("Plugin system not initialized"))?; + let entries = system.audit_trail().get_recent(recent); + if json { + println!("{}", serde_json::to_string_pretty(&entries)?); + } else { + if entries.is_empty() { + println!("No audit entries recorded yet."); + return Ok(()); + } + for e in &entries { + println!( + "{} | {} | {} | {} | {}", + e.timestamp.format("%Y-%m-%d %H:%M:%S"), + e.plugin_id, + e.action, + e.resource, + e.decision + ); + } + } + Ok(()) +} + +/// Diagnose plugin system issues. +pub fn run_plugin_doctor_command(fix: bool) -> Result<()> { + use crate::plugin::{DISABLE_ALL_PLUGINS, FORCE_DENY, SKIP_HOOKS, check_kill_switches}; + + let mut issues = Vec::new(); + let mut fixes_applied = Vec::new(); + + check_kill_switches(); + if DISABLE_ALL_PLUGINS.load(std::sync::atomic::Ordering::SeqCst) { + issues.push("Plugins are disabled via JCODE_DISABLE_PLUGINS=1".to_string()); + } + if SKIP_HOOKS.load(std::sync::atomic::Ordering::SeqCst) { + issues.push("Plugin hooks are skipped via JCODE_SKIP_PLUGINS=1".to_string()); + } + if FORCE_DENY.load(std::sync::atomic::Ordering::SeqCst) { + issues.push("Force-deny is active via JCODE_TEAM_WORKER=1".to_string()); + } + + if fix && DISABLE_ALL_PLUGINS.load(std::sync::atomic::Ordering::SeqCst) { + DISABLE_ALL_PLUGINS.store(false, std::sync::atomic::Ordering::SeqCst); + fixes_applied.push("Cleared JCODE_DISABLE_PLUGINS kill switch".to_string()); + } + if fix && SKIP_HOOKS.load(std::sync::atomic::Ordering::SeqCst) { + SKIP_HOOKS.store(false, std::sync::atomic::Ordering::SeqCst); + fixes_applied.push("Cleared JCODE_SKIP_PLUGINS kill switch".to_string()); + } + if fix && FORCE_DENY.load(std::sync::atomic::Ordering::SeqCst) { + FORCE_DENY.store(false, std::sync::atomic::Ordering::SeqCst); + fixes_applied.push("Cleared JCODE_TEAM_WORKER force-deny".to_string()); + } + + if let Some(sys) = crate::plugin::plugin_system() { + let handler_count = sys.dispatcher.handler_count(); + let plugin_count = sys.dispatcher.plugin_count(); + println!("Plugin system status:"); + println!(" Active plugins: {plugin_count}"); + println!(" Registered handlers: {handler_count}"); + println!(" Audit trail entries: {}", sys.audit_trail().len()); + } else { + issues.push("Plugin system not initialized".to_string()); + } + + if !issues.is_empty() { + println!("\n⚠️ Issues found:"); + for issue in &issues { + println!(" - {issue}"); + } + } + if !fixes_applied.is_empty() { + println!("\n🔧 Fixes applied:"); + for fix in &fixes_applied { + println!(" - {fix}"); + } + } + if issues.is_empty() && fixes_applied.is_empty() { + println!("\n✅ Plugin system is healthy"); + } + Ok(()) +} + pub async fn run_ambient_command(cmd: AmbientSubcommand) -> Result<()> { if let AmbientSubcommand::RunVisible = cmd { return run_ambient_visible().await; diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index 0409f5e82..032179947 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -7,7 +7,7 @@ use std::time::Instant; use super::args::{ AmbientCommand, Args, AuthCommand, CloudCommand, CloudSessionsCommand, Command, - ExperimentCommand, ExportFormatArg, McpCommand, MemoryCommand, ModelCommand, PromptsCommand, + ExperimentCommand, ExportFormatArg, McpCommand, MemoryCommand, ModelCommand, PluginCommand, PromptsCommand, ProviderCommand, RestartCommand, ServerCommand, SessionCommand, SkillsCommand, TranscriptModeArg, }; @@ -281,6 +281,20 @@ pub(crate) async fn run_main(mut args: Args) -> Result<()> { SkillsCommand::Disable { name } => commands::run_skills_disable(&name)?, SkillsCommand::Enable { name } => commands::run_skills_enable(&name)?, }, + Some(Command::Plugin(subcmd)) => match subcmd { + PluginCommand::List => commands::run_plugin_list_command()?, + PluginCommand::Install { source } => { + commands::run_plugin_install_command(&source).await? + } + PluginCommand::Uninstall { id } => { + commands::run_plugin_uninstall_command(&id).await? + } + PluginCommand::Info { id } => commands::run_plugin_info_command(&id)?, + PluginCommand::Enable { id } => commands::run_plugin_enable_command(&id)?, + PluginCommand::Disable { id } => commands::run_plugin_disable_command(&id)?, + PluginCommand::Audit { recent, json } => commands::run_plugin_audit_command(recent, json)?, + PluginCommand::Doctor { fix } => commands::run_plugin_doctor_command(fix)?, + }, Some(Command::Experiment(subcmd)) => match subcmd { ExperimentCommand::List { json } => { experiment_flags::run_experiment_list_command(json)? diff --git a/src/cli/proctitle.rs b/src/cli/proctitle.rs index 8153f5454..4f9c6bb79 100644 --- a/src/cli/proctitle.rs +++ b/src/cli/proctitle.rs @@ -56,6 +56,7 @@ pub(crate) fn initial_title(args: &Args) -> String { Some(Command::Logout { .. }) => "jcode logout".to_string(), Some(Command::Prompts(_)) => "jcode prompts".to_string(), Some(Command::Skills(_)) => "jcode skills".to_string(), + Some(Command::Plugin(_)) => "jcode plugin".to_string(), Some(Command::Experiment(_)) => "jcode experiment".to_string(), Some(Command::Mcp(_)) => "jcode mcp".to_string(), Some(Command::Doctor { .. }) => "jcode doctor".to_string(),