Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/browser/stealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -350,5 +356,5 @@ export function generateStealthJs(): string {

return 'applied';
})()
`;
`);
}
18 changes: 14 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
115 changes: 85 additions & 30 deletions src/pipeline/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object, string>();

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 {};
}
Expand All @@ -212,44 +232,79 @@ 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<string, unknown> | null = null;
let _reusableContext: vm.Context | null = null;

function getReusableContext(): { sandbox: Record<string, unknown>; 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;

// 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;
}
Expand Down
Loading