diff --git a/src/browser/stealth.ts b/src/browser/stealth.ts index bb70186f..2c7da0b1 100644 --- a/src/browser/stealth.ts +++ b/src/browser/stealth.ts @@ -13,9 +13,15 @@ * Return a self-contained JS string that, when evaluated in a page context, * applies all stealth patches. Safe to call multiple times — the guard flag * ensures patches are applied only once. + * + * The generated string is pure static (no dynamic parameters), so we cache + * it after the first call to avoid re-building ~350 lines on every goto(). */ +let _cachedStealthJs: string | undefined; + export function generateStealthJs(): string { - return ` + if (_cachedStealthJs !== undefined) return _cachedStealthJs; + return (_cachedStealthJs = ` (() => { // Guard: prevent double-injection across separate CDP evaluations. // We cannot use a closure variable (each eval is a fresh scope), and @@ -350,5 +356,5 @@ export function generateStealthJs(): string { return 'applied'; })() - `; + `); } diff --git a/src/main.ts b/src/main.ts index 2484d201..9c2fd521 100644 --- a/src/main.ts +++ b/src/main.ts @@ -86,10 +86,20 @@ const { registerUpdateNoticeOnExit, checkForUpdateBackground } = await import('. installNodeNetwork(); -// Sequential: plugins must run after built-in discovery so they can override built-in commands. -await ensureUserCliCompatShims(); -await ensureUserAdapters(); -await discoverClis(BUILTIN_CLIS, USER_CLIS); +// Parallelise independent startup I/O: +// - Built-in adapter discovery has no dependency on user-dir setup. +// - ensureUserCliCompatShims and ensureUserAdapters operate on different paths +// (~/.opencli/node_modules/ vs ~/.opencli/clis/ + adapter-manifest.json). +// - registerCommand() overwrites on name collision (see registry.ts), so +// user-CLI discovery MUST run after built-in discovery to preserve the +// intended override order (user adapters override built-in ones). +// - discoverPlugins runs last: plugins may override both built-in and user CLIs. +const [, ,] = await Promise.all([ + ensureUserCliCompatShims(), + ensureUserAdapters(), + discoverClis(BUILTIN_CLIS), +]); +await discoverClis(USER_CLIS); await discoverPlugins(); // Register exit hook: notice appears after command output (same as npm/gh/yarn) diff --git a/src/pipeline/template.ts b/src/pipeline/template.ts index ec4a932d..f0159673 100644 --- a/src/pipeline/template.ts +++ b/src/pipeline/template.ts @@ -182,12 +182,32 @@ const FORBIDDEN_EXPR_PATTERNS = /\b(constructor|__proto__|prototype|globalThis|p /** * Deep-copy plain data to sever prototype chains, preventing sandbox escape * via `args.constructor.constructor('return process')()` etc. + * + * Uses a WeakMap cache keyed by object reference: when the same object + * (e.g. `args` or `data`) is passed repeatedly across loop iterations, + * the expensive JSON round-trip is performed only once. The WeakMap + * lets entries be GC'd when the source object is no longer referenced. + */ +/** + * Cache serialized JSON strings (not parsed objects) by source reference. + * Caching the parsed object would be unsafe: the VM sandbox could mutate it, + * and the polluted version would leak to subsequent calls. By caching the + * string and returning a fresh JSON.parse() each time, every evaluation gets + * its own clean deep-copy while still avoiding redundant JSON.stringify() + * for the same unchanged source object across loop iterations. */ +const _sanitizeCache = new WeakMap(); + function sanitizeContext(obj: unknown): unknown { if (obj === null || obj === undefined) return obj; if (typeof obj !== 'object' && typeof obj !== 'function') return obj; + const objRef = obj as object; + const cached = _sanitizeCache.get(objRef); + if (cached !== undefined) return JSON.parse(cached); try { - return JSON.parse(JSON.stringify(obj)); + const jsonStr = JSON.stringify(obj); + _sanitizeCache.set(objRef, jsonStr); + return JSON.parse(jsonStr); } catch { return {}; } @@ -212,6 +232,54 @@ function getOrCompileScript(expr: string): vm.Script { return script; } +/** + * Reusable VM sandbox context. + * + * vm.createContext() is expensive (~0.3ms per call) because it creates a new + * V8 context with its own global object. In pipeline loops (map/filter over + * hundreds of items), this adds up to significant overhead. + * + * Instead, we create the context once and mutate the sandbox properties + * before each evaluation. This is safe because: + * 1. Sandbox properties are sanitized (deep-copied) before assignment + * 2. Scripts run with a 50ms timeout + * 3. codeGeneration is disabled (no eval/Function inside the sandbox) + */ +let _reusableSandbox: Record | null = null; +let _reusableContext: vm.Context | null = null; + +function getReusableContext(): { sandbox: Record; context: vm.Context } { + if (_reusableSandbox && _reusableContext) { + return { sandbox: _reusableSandbox, context: _reusableContext }; + } + _reusableSandbox = { + args: {}, + item: {}, + data: null, + index: 0, + encodeURIComponent, + decodeURIComponent, + JSON, + Math, + Number, + String, + Boolean, + Array, + Date, + }; + _reusableContext = vm.createContext(_reusableSandbox, { + codeGeneration: { strings: false, wasm: false }, + }); + return { sandbox: _reusableSandbox, context: _reusableContext }; +} + +/** Properties that are part of the sandbox's initial shape and safe to keep. */ +const SANDBOX_WHITELIST = new Set([ + 'args', 'item', 'data', 'index', + 'encodeURIComponent', 'decodeURIComponent', + 'JSON', 'Math', 'Number', 'String', 'Boolean', 'Array', 'Date', +]); + function evalJsExpr(expr: string, ctx: RenderContext): unknown { // Guard against absurdly long expressions that could indicate injection. if (expr.length > 2000) return undefined; @@ -219,37 +287,24 @@ function evalJsExpr(expr: string, ctx: RenderContext): unknown { // Block obvious sandbox escape attempts. if (FORBIDDEN_EXPR_PATTERNS.test(expr)) return undefined; - const args = sanitizeContext(ctx.args ?? {}); - const item = sanitizeContext(ctx.item ?? {}); - const data = sanitizeContext(ctx.data); - const index = ctx.index ?? 0; - try { const script = getOrCompileScript(expr); - const sandbox = vm.createContext( - { - args, - item, - data, - index, - encodeURIComponent, - decodeURIComponent, - JSON, - Math, - Number, - String, - Boolean, - Array, - Date, - }, - { - codeGeneration: { - strings: false, - wasm: false, - }, - }, - ); - return script.runInContext(sandbox, { timeout: 50 }); + const { sandbox, context } = getReusableContext(); + + // Clean non-whitelisted properties that a previous script may have added. + // Without this, `${{ x = 42 }}` would leak `x` into subsequent evaluations. + for (const key of Object.keys(sandbox)) { + if (!SANDBOX_WHITELIST.has(key)) { + delete sandbox[key]; + } + } + + // Update mutable sandbox properties — sanitizeContext severs prototype chains. + sandbox.args = sanitizeContext(ctx.args ?? {}); + sandbox.item = sanitizeContext(ctx.item ?? {}); + sandbox.data = sanitizeContext(ctx.data); + sandbox.index = ctx.index ?? 0; + return script.runInContext(context, { timeout: 50 }); } catch { return undefined; }