Files
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

721 lines
32 KiB
JavaScript

// @ts-check
/**
* plugins/_engine.mjs — the career-ops plugin engine.
*
* Generalizes the proven providers/ auto-loader (scan.mjs `loadProviders`) into
* a sibling `plugins/` layer for integrations that need a KEY or talk to an
* EXTERNAL service. The zero-key providers/ dir is untouched and stays pure.
*
* Design invariants (every one is asserted by test-all.mjs section 49):
* - ZERO module-level side effects. Importing this file reads no config, loads
* no dotenv, and mutates no process.env. scan.mjs imports `mergeProviderPlugins`
* on every run, and that import must be free — so a plain `node scan.mjs` with
* no config/plugins.yml behaves byte-identically to before this feature.
* - Fail-open everywhere. A malformed manifest, a throwing import, a missing
* key, or a hung hook logs a `⚠️` and is SKIPPED — never crashes the core.
* - Opt-in. Nothing loads unless config/plugins.yml enables it AND every
* declared requiredEnv var is present.
* - Least-privilege ctx is a CONVENIENCE, not a sandbox. Plain ESM can't
* VM-isolate a module's imports without a build step (forbidden). Containment
* is code review (bundled plugins, same gate as providers/) + user trust
* (plugins.local/). See plugins/README.md "Trust model".
*
* Pure + side-effect-free so scan.mjs, plugins.mjs, doctor.mjs and test-all.mjs
* all reuse it with no prod-vs-test drift.
*/
import { existsSync, readdirSync, readFileSync, realpathSync } from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';
import { resolveAndValidate } from './_net.mjs';
import { readLock, writeLockEntry, diffPlugin, hashPluginTree, consentSurface } from './_lock.mjs';
import { loadRegistry } from './_registry.mjs';
/** The complete, closed set of hook kinds. Anything else (apply/submit/…) is rejected. */
export const HOOK_KINDS = ['provider', 'ingest', 'search', 'notify', 'export'];
/**
* Env var names a plugin may NOT declare in requiredEnv/optionalEnv: core-owned
* secrets (so a plugin can't smuggle out GEMINI/OPENAI/… keys and have doctor
* render it as a legitimate ✓), plus process-shaping vars. AWS_* is matched by
* prefix below. This is a manifest-review backstop, not a runtime isolation
* boundary (process.env stays globally reachable — see the trust note).
*/
export const RESERVED_ENV = new Set([
'GEMINI_API_KEY', 'GEMINI_MODEL',
'OPENROUTER_API_KEY', 'CAREER_OPS_MODEL',
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
'ANTHROPIC_API_KEY',
'CAREER_OPS_PORTALS', 'CAREER_OPS_PROFILE',
'PATH', 'HOME', 'NODE_OPTIONS', 'LD_PRELOAD', 'NODE_EXTRA_CA_CERTS',
]);
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
const DEFAULT_HOOK_TIMEOUT_MS = 15_000;
const MAX_REDIRECTS = 5;
function isReservedEnv(name) {
return RESERVED_ENV.has(name) || /^AWS_/.test(name);
}
function warnSkip(label, reason) {
console.warn(`⚠️ ${label}: skipping — ${reason}`);
}
function isWithinDirectory(rootAbs, candidateAbs) {
const rel = path.relative(rootAbs, candidateAbs);
return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
}
function nearestExistingPath(absPath) {
let current = path.resolve(absPath);
while (!existsSync(current)) {
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
return current;
}
function isSafePluginPath(rootAbs, candidateAbs) {
const rootResolved = path.resolve(rootAbs);
const candidateResolved = path.resolve(candidateAbs);
if (!isWithinDirectory(rootResolved, candidateResolved)) return false;
const nearestExisting = nearestExistingPath(candidateResolved);
if (!nearestExisting) return false;
try {
return isWithinDirectory(realpathSync(rootResolved), realpathSync(nearestExisting));
} catch {
return false;
}
}
/**
* Resolve the config/plugins.yml path for a given project root.
* @param {string} root
*/
function pluginsConfigPath(root) {
return path.join(root, 'config', 'plugins.yml');
}
/**
* Read config/plugins.yml. Fail-open to {} if absent or malformed — exactly the
* graceful posture scan.mjs uses for its own config. js-yaml is imported lazily
* so this module stays side-effect-free at import time.
* @param {string} root
* @returns {Promise<object>}
*/
export async function loadPluginConfig(root) {
const file = pluginsConfigPath(root);
if (!existsSync(file)) return {};
try {
const yaml = (await import('js-yaml')).default;
const parsed = yaml.load(readFileSync(file, 'utf8'));
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (err) {
warnSkip('config/plugins.yml', `unreadable, ignoring — ${err.message}`);
return {};
}
}
/**
* Validate a parsed manifest object. Returns a normalized manifest on success,
* or null (with a ⚠️) on any violation. NEVER throws — identical fail-open to
* scan.mjs loadProviders.
*
* @param {any} m Parsed manifest.json.
* @param {string} dir Absolute plugin directory.
* @param {string} dirName Basename of dir (must equal m.id).
* @returns {(PluginManifestNormalized|null)}
* @typedef {object} PluginManifestNormalized
* @property {string} id
* @property {number} apiVersion
* @property {string} description
* @property {string[]} hooks
* @property {string[]} requiredEnv
* @property {string[]} optionalEnv
* @property {string[]} allowedHosts
* @property {string} entry
* @property {boolean} humanInTheLoop
* @property {string} [name]
* @property {string} [version]
* @property {string} [homepage]
* @property {string} dir
*/
export function validateManifest(m, dir, dirName) {
const label = dirName;
if (!m || typeof m !== 'object') { warnSkip(label, 'manifest.json is not an object'); return null; }
if (typeof m.id !== 'string' || !ID_RE.test(m.id)) { warnSkip(label, `invalid id ${JSON.stringify(m.id)} (need ^[a-z0-9][a-z0-9-]*$)`); return null; }
if (m.id !== dirName) { warnSkip(label, `id "${m.id}" must equal directory name "${dirName}"`); return null; }
if (m.apiVersion !== 1) { warnSkip(label, `unsupported apiVersion ${JSON.stringify(m.apiVersion)} (engine supports 1)`); return null; }
if (typeof m.description !== 'string' || !m.description.trim() || /[\r\n]/.test(m.description)) { warnSkip(label, 'description must be a non-empty single line'); return null; }
if (m.humanInTheLoop !== true) { warnSkip(label, 'humanInTheLoop must be true (no auto-submit plugins)'); return null; }
if (!Array.isArray(m.hooks) || m.hooks.length === 0) { warnSkip(label, 'hooks must be a non-empty array'); return null; }
for (const h of m.hooks) {
if (!HOOK_KINDS.includes(h)) { warnSkip(label, `unknown hook "${h}" (allowed: ${HOOK_KINDS.join(', ')})`); return null; }
}
const requiredEnv = m.requiredEnv === undefined ? [] : m.requiredEnv;
const optionalEnv = m.optionalEnv === undefined ? [] : m.optionalEnv;
if (!Array.isArray(requiredEnv) || requiredEnv.some(x => typeof x !== 'string')) { warnSkip(label, 'requiredEnv must be an array of strings'); return null; }
if (!Array.isArray(optionalEnv) || optionalEnv.some(x => typeof x !== 'string')) { warnSkip(label, 'optionalEnv must be an array of strings'); return null; }
for (const name of [...requiredEnv, ...optionalEnv]) {
if (isReservedEnv(name)) { warnSkip(label, `requiredEnv/optionalEnv may not declare reserved var "${name}" (core-owned or process-shaping)`); return null; }
}
const allowedHosts = m.allowedHosts === undefined ? [] : m.allowedHosts;
if (!Array.isArray(allowedHosts) || allowedHosts.some(x => typeof x !== 'string')) { warnSkip(label, 'allowedHosts must be an array of strings'); return null; }
// A keyed plugin (reads a secret) MUST declare an egress allowlist.
if (requiredEnv.length > 0 && allowedHosts.length === 0) { warnSkip(label, 'a plugin with requiredEnv must declare a non-empty allowedHosts egress allowlist'); return null; }
const allowsLocalhost = m.allowsLocalhost === true;
if (allowsLocalhost && allowedHosts.length === 0) { warnSkip(label, 'allowsLocalhost requires a non-empty allowedHosts'); return null; }
// Reject SSRF-shaped hosts in the allowlist (IP literals, cloud-metadata,
// *.internal, loopback) unless the plugin explicitly opted into localhost.
for (const h of allowedHosts) {
const isLoopback = /^(localhost|127\.\d+\.\d+\.\d+|::1|\[::1\])$/i.test(h);
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h) && !(allowsLocalhost && isLoopback)) { warnSkip(label, `allowedHosts must be hostnames, not IP literals: ${h}`); return null; }
if (/(^169\.254\.)|(^metadata\.google\.internal$)|(\.internal$)/i.test(h)) { warnSkip(label, `allowedHosts may not target metadata/internal hosts: ${h}`); return null; }
if (isLoopback && !allowsLocalhost) { warnSkip(label, `allowedHosts contains a loopback host without allowsLocalhost: ${h}`); return null; }
}
const entry = typeof m.entry === 'string' && m.entry.trim() ? m.entry : 'index.mjs';
if (!entry.endsWith('.mjs')) { warnSkip(label, `entry "${entry}" must be a .mjs file`); return null; }
const entryAbs = path.resolve(dir, entry);
if (!isSafePluginPath(dir, entryAbs)) { warnSkip(label, `entry "${entry}" escapes the plugin directory`); return null; }
// Optional companion skill (Open-Agent-Skill-Standard SKILL.md). Traversal-
// guarded like entry. Surfaced on-demand via `plugins.mjs skill <id>`; never
// auto-injected into AGENTS.md/modes (the data-vs-brain firewall).
let skill = null;
if (m.skill !== undefined) {
if (typeof m.skill !== 'string' || !m.skill.endsWith('.md')) { warnSkip(label, 'skill must be a relative .md path'); return null; }
const skillAbs = path.resolve(dir, m.skill);
if (!isSafePluginPath(dir, skillAbs)) { warnSkip(label, `skill "${m.skill}" escapes the plugin directory`); return null; }
if (!existsSync(skillAbs)) { warnSkip(label, `skill file not found: ${m.skill}`); return null; }
skill = m.skill;
}
return {
id: m.id,
apiVersion: 1,
description: m.description.trim(),
hooks: [...m.hooks],
requiredEnv,
optionalEnv,
allowedHosts,
allowsLocalhost,
entry,
skill,
humanInTheLoop: true,
name: typeof m.name === 'string' ? m.name : undefined,
version: typeof m.version === 'string' ? m.version : undefined,
homepage: typeof m.homepage === 'string' ? m.homepage : undefined,
dir,
};
}
/**
* Discover plugin directories across roots. Roots are scanned in order; the
* FIRST occurrence of an id wins (so bundled plugins/ shadow a same-id
* plugins.local/). existsSync-guarded (never mkdir — a fresh, plugin-free
* install stays filesystem-inert). Alphabetical within a root for determinism.
*
* `overrideIds` is the ONE controlled exception to first-root-wins: an id in
* this set lets a LATER root (plugins.local/) override an earlier one
* (bundled plugins/). It is computed ONLY by resolveSuccessorIds() — i.e. a
* community plugin the maintainer registered as the bundled plugin's
* `supersedesBundled` successor AND that the user installed at its exact
* pinned sha. A plain, unverified plugins.local/<id> is never in the set, so
* it can NEVER shadow a reviewed bundled plugin (the no-downgrade invariant).
* Default empty → identical to the original bundled-always-wins behavior.
*
* @param {string[]} roots Absolute directories to scan (e.g. [plugins/, plugins.local/]).
* @param {Set<string>} [overrideIds] ids whose later-root copy may override an earlier-root one.
* @returns {PluginManifestNormalized[]}
*/
export function discoverPlugins(roots, overrideIds = new Set()) {
const found = new Map();
for (const root of roots) {
if (!existsSync(root)) continue;
let entries;
try {
entries = readdirSync(root, { withFileTypes: true });
} catch (err) {
warnSkip(root, `unreadable — ${err.message}`);
continue;
}
const dirs = entries
.filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
.map(e => e.name)
.sort();
for (const name of dirs) {
const dir = path.join(root, name);
const manifestFile = path.join(dir, 'manifest.json');
if (!existsSync(manifestFile)) continue; // not a plugin dir (no manifest)
let parsed;
try {
parsed = JSON.parse(readFileSync(manifestFile, 'utf8'));
} catch (err) {
warnSkip(name, `manifest.json is invalid JSON — ${err.message}`);
continue;
}
const manifest = validateManifest(parsed, dir, name);
if (!manifest) continue;
if (found.has(manifest.id)) {
// earlier root wins (bundled > local) — EXCEPT an approved successor.
if (overrideIds.has(manifest.id)) found.set(manifest.id, manifest);
continue;
}
found.set(manifest.id, manifest);
}
}
return [...found.values()];
}
/**
* Compute the set of bundled ids that an APPROVED community successor should
* override. A successor qualifies only when ALL hold:
* (a) a registry entry declares `supersedesBundled: true` for that id
* (registry = SYSTEM file → a maintainer reviewed + merged it), AND
* (b) plugins.local/<id> is actually installed, AND
* (c) the installed sha === the registry's pinned sha (the user has the
* exact reviewed commit — the same bar classifySource() calls 'approved').
* Anything short of (c) — off-registry drift, unverified, not installed —
* leaves the bundled reference in charge. This is the trust hinge of the whole
* seed/successor model: only a reviewed, pinned, installed successor wins.
* @param {string} root
* @returns {Set<string>}
*/
export function resolveSuccessorIds(root) {
const ids = new Set();
try {
const reg = loadRegistry(root);
const lock = readLock(root);
for (const e of reg.plugins) {
if (e.supersedesBundled !== true || typeof e.id !== 'string') continue;
const localManifest = path.join(root, 'plugins.local', e.id, 'manifest.json');
const installedSha = lock?.plugins?.[e.id]?.sha;
if (existsSync(localManifest) && installedSha && installedSha === e.sha) ids.add(e.id);
}
} catch {
// Any registry/lock read problem → no overrides (bundled references stay in charge).
}
return ids;
}
/**
* The standard pair of roots for a project: bundled plugins/ then user
* plugins.local/ (the latter only matters when it exists).
* @param {string} root
*/
export function pluginRoots(root) {
return [path.join(root, 'plugins'), path.join(root, 'plugins.local')];
}
/**
* Is a plugin enabled? Requires config/plugins.yml { plugins: { <id>: { enabled: true } } }
* AND every requiredEnv var present in process.env.
* @param {PluginManifestNormalized} manifest
* @param {object} cfg Parsed config/plugins.yml.
* @returns {{ enabled: boolean, configured: boolean, missingEnv: string[] }}
*/
export function pluginStatus(manifest, cfg) {
const entry = cfg?.plugins?.[manifest.id];
const configured = entry?.enabled === true;
const missingEnv = manifest.requiredEnv.filter(name => !process.env[name]);
return { enabled: configured && missingEnv.length === 0, configured, missingEnv };
}
/**
* Per-plugin non-secret settings block from config/plugins.yml (e.g.
* { actor: "...", database_id: "..." }). Secrets never live here.
* @param {string} id
* @param {object} cfg
*/
export function pluginSettings(id, cfg) {
const entry = cfg?.plugins?.[id];
if (!entry || typeof entry !== 'object') return {};
const { enabled, ...rest } = entry;
return rest;
}
/**
* Build a guarded fetch that enforces HTTPS, an optional host allowlist, and
* manual redirect handling that re-validates EVERY hop's host and strips
* credential headers when a hop changes hostname. Returns a Response.
*
* Posture note: core providers use redirect:'error' (reject ANY redirect). This
* is the deliberately looser plugin posture — allowlist-pinned FOLLOW — because
* keyed APIs (Notion/Google/Apify) legitimately 30x within their own host set;
* the allowlist + per-hop re-validation + cross-host credential strip bound it.
*
* ADVISORY only: this binds a plugin that routes through ctx.fetch*, not one
* that calls global fetch directly (see the trust note in README.md).
*
* @param {string[]} allowedHosts
*/
function makeGuardedFetch(allowedHosts, { allowsLocalhost = false } = {}) {
const allow = new Set(allowedHosts);
const isLoopbackHost = (h) => /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?)$/i.test(h);
const hostOk = (u) => {
if (u.protocol !== 'https:') {
// Plain HTTP is allowed ONLY for an opted-in loopback host (local-AI
// providers like Ollama/LM Studio serve http://localhost:11434).
if (!(allowsLocalhost && u.protocol === 'http:' && isLoopbackHost(u.hostname))) {
throw new Error(`plugin egress must use HTTPS: ${u.href}`);
}
}
if (allow.size > 0 && !allow.has(u.hostname)) throw new Error(`plugin egress to "${u.hostname}" is not in allowedHosts [${[...allow].join(', ')}]`);
};
return async function guardedFetch(url, opts = {}) {
const { timeoutMs = 10_000, headers = {}, method = 'GET', body = null } = opts;
let current = new URL(url);
hostOk(current);
// SSRF: reject a host that resolves to a private/loopback/metadata address
// (re-checked on every redirect hop). Loopback allowed only when opted in.
await resolveAndValidate(current.hostname, { allowsLocalhost });
let reqHeaders = { ...headers };
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let res;
try {
res = await fetch(current.href, {
method, headers: reqHeaders, body, redirect: 'manual', signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
const next = new URL(res.headers.get('location'), current);
hostOk(next); // re-validate the redirect target before following it
await resolveAndValidate(next.hostname, { allowsLocalhost });
if (next.hostname !== current.hostname) {
// Don't forward credentials across a hostname change (what the
// platform fetch does for cross-origin redirects; we do it manually).
reqHeaders = Object.fromEntries(Object.entries(reqHeaders).filter(([k]) => !/^(authorization|cookie)$/i.test(k)));
}
current = next;
continue;
}
if (!res.ok) {
const snippet = (await res.text().catch(() => '')).replace(/\s+/g, ' ').trim().slice(0, 300);
const err = new Error(snippet ? `HTTP ${res.status}: ${snippet}` : `HTTP ${res.status}`);
// @ts-ignore
err.status = res.status;
throw err;
}
return res;
}
throw new Error(`too many redirects (>${MAX_REDIRECTS}) for ${url}`);
};
}
/**
* Build the least-privilege ctx for a plugin. The scoped frozen env is a
* CONVENIENCE (process.env is still globally reachable from any module) — the
* real boundary is code review + trust.
* @param {PluginManifestNormalized} manifest
* @param {{ dryRun?: boolean, settings?: object }} [opts]
* @returns {PluginContext}
*/
export function buildCtx(manifest, opts = {}) {
const scoped = {};
for (const name of [...manifest.requiredEnv, ...manifest.optionalEnv]) {
if (process.env[name] !== undefined) scoped[name] = process.env[name];
}
const env = Object.freeze({ ...scoped });
// Secret values long enough to be worth redacting (avoid no-op/over-redaction
// on empty or trivially short strings).
const secrets = manifest.requiredEnv
.map(n => process.env[n])
.filter(v => typeof v === 'string' && v.length >= 6);
const guarded = makeGuardedFetch(manifest.allowedHosts, { allowsLocalhost: manifest.allowsLocalhost === true });
const log = (...args) => {
const redact = (s) => {
let out = typeof s === 'string' ? s : String(s);
for (const sec of secrets) out = out.split(sec).join('«redacted»');
return out;
};
console.log(...args.map(redact));
};
return /** @type {PluginContext} */ ({
transport: 'http',
// The guarded primitive (HTTPS + allowedHosts + redirect re-validation).
// Bundled plugins should route their HTTP through this so the egress guard
// actually runs; fetchText/fetchJson are conveniences over it.
fetch: guarded,
fetchText: async (u, o) => (await guarded(u, o)).text(),
fetchJson: async (u, o) => (await guarded(u, o)).json(),
env,
settings: Object.freeze({ ...(opts.settings || {}) }),
log,
dryRun: opts.dryRun === true,
});
}
/**
* Dynamically import a plugin entry and return its validated hook object for the
* requested kind, or null on any failure (sandboxed — never throws to caller).
* @param {PluginManifestNormalized} manifest
* @param {string} kind
*/
async function importHook(manifest, kind) {
const entryAbs = path.join(manifest.dir, manifest.entry);
let mod;
try {
mod = await import(pathToFileURL(entryAbs).href);
} catch (err) {
warnSkip(manifest.id, `failed to import ${manifest.entry}${err.message}`);
return null;
}
const hooks = mod.default;
if (!hooks || typeof hooks !== 'object') { warnSkip(manifest.id, 'entry default export must be an object of hooks'); return null; }
const hook = hooks[kind];
if (kind === 'provider') {
if (!hook || typeof hook.fetch !== 'function' || typeof hook.id !== 'string') { warnSkip(manifest.id, 'provider hook must be { id, fetch }'); return null; }
} else if (typeof hook !== 'function') {
warnSkip(manifest.id, `${kind} hook must be a function`);
return null;
}
return hook;
}
/**
* Load all ENABLED plugins exposing `kind`, with their ctx ready. Skips any that
* fail to import. Caller is responsible for dotenv (loadDotenvOnce) before this
* if the keys live in .env.
* @param {string} kind
* @param {{ root: string, dryRun?: boolean }} opts
* @returns {Promise<Array<{ id: string, manifest: PluginManifestNormalized, hook: any, ctx: PluginContext }>>}
*/
// Phrases that suggest a skill is trying to hijack the agent rather than
// document a plugin. Built from fragments so the literals don't trip the repo's
// own firewall/injection greps. Surfaced as a ⚠️ at print time, not a block.
const SKILL_RED_FLAGS = [
/ignore\s+(all\s+)?(previous|prior|above)\s+instruction/i,
/disregard\s+(the\s+)?(system|above|previous)/i,
/you\s+are\s+now\b/i,
/exfiltrat/i,
new RegExp('modes/' + '_shared'),
new RegExp('AGENTS' + '\\.md'),
/human[- ]?in[- ]?the[- ]?loop\s*[:=]?\s*false/i,
/humanInTheLoop\s*[:=]\s*false/i,
];
/**
* Read a plugin's companion skill.md (on-demand; the agent PULLS it). Returns
* its body + injection red-flags + whether the source is trusted (bundled).
* NEVER auto-injected into AGENTS.md/modes — the data-vs-brain firewall.
* @returns {{ body: string, flags: string[], source: string } | null}
*/
export function loadSkill(manifest, root) {
if (!manifest.skill) return null;
let body;
try { body = readFileSync(path.join(manifest.dir, manifest.skill), 'utf8'); }
catch { return null; }
const flags = SKILL_RED_FLAGS.some(re => re.test(body))
? ['this skill contains phrases that resemble prompt-injection — review it before trusting']
: [];
return { body, flags, source: pluginSource(manifest, root) };
}
/**
* Derive a plugin's trust source from the FILESYSTEM, never from plugins.lock
* (a USER-writable file that could spoof `source: "bundled"` to dodge the
* rug-pull gate). A dir under plugins/ is bundled; anything else is local.
*/
function pluginSource(manifest, root) {
return manifest.dir.startsWith(path.join(root, 'plugins') + path.sep) ? 'bundled' : 'local';
}
/**
* Integrity gate run on an ENABLED plugin before importing it (the rug-pull
* defense). Returns { load }. Re-pins the lock on the benign cases (silent).
* Fail-open on the engine itself (a lock-read error never blocks).
*/
export function lockGate(manifest, root) {
const source = pluginSource(manifest, root);
let lock;
try { lock = readLock(root); } catch { return { load: true }; }
const entry = lock.plugins?.[manifest.id];
let d;
try { d = diffPlugin(manifest, entry); }
catch (err) { warnSkip(manifest.id, `integrity check failed — ${err.message}`); return { load: false }; }
const repin = () => {
try {
const tree = hashPluginTree(manifest.dir);
writeLockEntry(root, manifest.id, {
source, version: manifest.version || '0.0.0',
integrity: tree.integrity, files: tree.files, consent: consentSurface(manifest),
});
} catch { /* best-effort; never block load on a write failure */ }
};
switch (d.status) {
case 'unpinned': repin(); return { load: true }; // grandfathered hand-enable → pin so future tampering is caught
case 'match': return { load: true };
case 'legit-update': repin(); return { load: true }; // version bumped → honest update, re-pin quietly
case 'drift-nobump':
if (source === 'bundled') { repin(); return { load: true }; } // reviewed-by-construction (branch-protected checkout) → re-pin
warnSkip(manifest.id, `files changed since you trusted it without a version bump — possible tampering (${d.changedFiles.slice(0, 5).join(', ')}). Review, then \`node plugins.mjs trust ${manifest.id}\``);
return { load: false };
case 'surface-widened':
warnSkip(manifest.id, `capability surface expanded since you consented (${[...d.addedHosts, ...d.addedEnv].join(', ')}${manifest.allowsLocalhost ? ', localhost' : ''}) — re-consent: \`node plugins.mjs enable ${manifest.id}\``);
return { load: false };
default: return { load: true };
}
}
export async function loadPlugins(kind, { root, dryRun = false }) {
const cfg = await loadPluginConfig(root);
const manifests = discoverPlugins(pluginRoots(root), resolveSuccessorIds(root)).filter(m => m.hooks.includes(kind));
const out = [];
for (const manifest of manifests) {
if (!pluginStatus(manifest, cfg).enabled) continue;
if (!lockGate(manifest, root).load) continue;
const hook = await importHook(manifest, kind);
if (!hook) continue;
out.push({ id: manifest.id, manifest, hook, ctx: buildCtx(manifest, { dryRun, settings: pluginSettings(manifest.id, cfg) }) });
}
return out;
}
/** Lazily load dotenv exactly once (mirrors gemini-eval.mjs). Idempotent. */
let dotenvLoaded = false;
export async function loadDotenvOnce() {
if (dotenvLoaded) return;
dotenvLoaded = true;
try {
const { config } = await import('dotenv');
config();
} catch {
// dotenv optional — fall back to ambient process.env (CI, exported vars).
}
}
/**
* Run one hook across all enabled plugins, each call try/caught + timed out.
* Cooperative timeout only: Promise.race resolves the wait but does NOT abort a
* synchronous-hung or process.exit-ing plugin (plain ESM can't preempt without a
* worker — stated plainly in README). One throwing/slow plugin can't sink the
* batch; a sync-hang or a process.exit can. Bundled plugins are reviewed to
* avoid both, exactly like providers/.
*
* @param {string} kind
* @param {*} payload For provider this is unused; for ingest none; search a query; export a snapshot; notify a payload.
* @param {{ root: string, dryRun?: boolean, timeoutMs?: number }} opts
* @returns {Promise<Array<{ id: string, ok: boolean, result?: any, error?: string }>>}
*/
export async function runHook(kind, payload, { root, dryRun = false, timeoutMs = DEFAULT_HOOK_TIMEOUT_MS }) {
await loadDotenvOnce();
const loaded = await loadPlugins(kind, { root, dryRun });
const results = [];
for (const { id, hook, ctx } of loaded) {
const invoke = kind === 'search'
? Promise.resolve().then(() => hook(payload, ctx))
: kind === 'export' || kind === 'notify'
? Promise.resolve().then(() => hook(payload, ctx))
: Promise.resolve().then(() => hook(ctx)); // ingest
let timer;
const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs); });
try {
const result = await Promise.race([invoke, timeout]);
results.push({ id, ok: true, result });
} catch (err) {
warnSkip(id, `${kind} hook failed — ${err.message}`);
results.push({ id, ok: false, error: err.message });
} finally {
clearTimeout(timer);
}
}
return results;
}
/**
* Merge enabled provider-plugins into the core providers Map. THE one hot-path
* hook (scan.mjs calls this right after loadProviders). Critical guarantees:
*
* 1. BYTE-IDENTICAL when opted out: returns IMMEDIATELY if config/plugins.yml
* is absent — no discovery, no dotenv, no process.env mutation.
* 2. dotenv is loaded LAZILY and only after at least one enabled provider
* plugin is found, so a present-but-provider-less plugins.yml still touches
* no env.
* 3. Core providers ALWAYS win an id collision (a plugin can never shadow a
* bundled zero-key provider).
* 4. detect-EXEMPT: a merged provider's detect() is forced to null, so it fires
* ONLY on an explicit `provider: <id>` portals.yml entry — never via
* auto-detection (no surprise paid/keyed network during a plain scan).
* 5. A known-but-inactive provider plugin registers a STUB whose fetch throws
* an actionable message (disabled / missing key) — so `provider: apify` with
* the plugin off yields a helpful error, not a confusing "unknown provider".
*
* @param {Map<string, any>} providersMap The Map returned by scan.mjs loadProviders.
* @param {{ root: string }} opts
*/
// A detect-exempt provider whose fetch throws an actionable message — used when
// a known provider plugin is inactive (disabled / missing key / failed import)
// so an explicit `provider: <id>` portals.yml entry stays self-explaining.
function inactiveProviderStub(id, reason) {
return {
id,
detect: () => null,
fetch: async () => { throw new Error(`plugin "${id}" inactive: ${reason}. Run \`node doctor.mjs\` for setup.`); },
};
}
export async function mergeProviderPlugins(providersMap, { root }) {
if (!existsSync(pluginsConfigPath(root))) return; // (1) opted out → inert (no work, no env read)
// Everything past the opt-out gate is wrapped so an UNANTICIPATED throw
// (a callee regression) degrades to a ⚠️ and leaves the core providers Map
// untouched — fail-open is enforced structurally here, not just emergently.
try {
const cfg = await loadPluginConfig(root);
const providerManifests = discoverPlugins(pluginRoots(root), resolveSuccessorIds(root)).filter(m => m.hooks.includes('provider'));
if (providerManifests.length === 0) return;
// Only the plugins the user actually switched on in plugins.yml matter.
const configuredOn = providerManifests.filter(m => cfg?.plugins?.[m.id]?.enabled === true);
if (configuredOn.length === 0) return;
await loadDotenvOnce(); // (2) lazy, only now that an enabled provider plugin exists
for (const manifest of configuredOn) {
if (providersMap.has(manifest.id)) {
warnSkip(manifest.id, 'a core provider already owns this id — plugin not merged');
continue;
}
const { enabled, missingEnv } = pluginStatus(manifest, cfg);
if (!enabled) {
const reason = missingEnv.length ? `missing env ${missingEnv.join(', ')} — add to .env` : 'disabled in config/plugins.yml';
providersMap.set(manifest.id, inactiveProviderStub(manifest.id, reason)); // (5)
continue;
}
if (!lockGate(manifest, root).load) {
providersMap.set(manifest.id, inactiveProviderStub(manifest.id, 'integrity/consent check failed — see ⚠️ above; run `node plugins.mjs trust ' + manifest.id + '` or `enable ' + manifest.id + '`'));
continue;
}
const hook = await importHook(manifest, 'provider');
if (!hook) {
// enabled + keyed but the entry failed to import — keep the path self-explaining.
providersMap.set(manifest.id, inactiveProviderStub(manifest.id, 'failed to load — see ⚠️ above'));
continue;
}
const ctx = buildCtx(manifest, { settings: pluginSettings(manifest.id, cfg) });
providersMap.set(manifest.id, {
id: manifest.id,
detect: () => null, // (4) keyed providers never auto-detect
fetch: (entry) => hook.fetch(entry, ctx), // (3) ctx injection
});
}
} catch (err) {
warnSkip('plugins', `provider-plugin merge skipped this run — ${err.message}`);
}
}