From 241cced780745cce9894780a86a58e6dedfaf99f Mon Sep 17 00:00:00 2001 From: hzhang Date: Mon, 25 May 2026 10:19:39 +0100 Subject: [PATCH 1/3] refactor(prism-facet): become a pure registration framework Strip all Hangman-Lab-specific content out of PrismFacet so it can be reused by any project. Content (always router, pcexec-convention prompt, fabric-chat-injector hook) moves to the new sibling plugin ClawPrompts. Mechanism additions: - `globalThis.__prismFacet` cross-plugin API installed at module-import time (so consumers loaded before PrismFacet can still register): .addRouter(name, resolveFn) .addRule(router, key, { file }) - core/rule-store: tier rules into `persistent` (rules.json, mutated by the prompt-rules admin tool) and `external` (in-memory, registered by other plugins via the API). Persistent overrides external on conflict. - core/router-loader: addExternalRouter() for programmatic registration into the same map the file-based loader uses. - index.ts: drops registerFabricChatInjector wiring, registerBeforePromptBuild remains. Removed (now shipped from ClawPrompts): - plugin/routers/always.ts - plugin/hooks/fabric-chat-injector.ts - plugin/prompts/pcexec-convention.md - plugin/rules.json: now `{}`; ClawPrompts registers its rule externally What still lives in PrismFacet: - before_prompt_build hook (the wiring between routers/rules and the agent's system prompt) - prompt-rules admin tool (lists + mutates persistent rules) - file-based routersDir / rulesFile scanning (kept for operator ad-hoc use; ClawPrompts uses the API instead) Co-Authored-By: Claude Opus 4.7 (1M context) --- plugin/core/cross-plugin-api.ts | 83 +++++++++++++++++++++++++++ plugin/core/router-loader.ts | 18 ++++++ plugin/core/rule-store.ts | 60 ++++++++++++++++---- plugin/hooks/fabric-chat-injector.ts | 85 ---------------------------- plugin/index.ts | 27 ++++++++- plugin/prompts/.gitkeep | 0 plugin/prompts/pcexec-convention.md | 31 ---------- plugin/routers/.gitkeep | 0 plugin/routers/always.ts | 11 ---- plugin/rules.json | 4 +- rules.json | 2 +- 11 files changed, 175 insertions(+), 146 deletions(-) create mode 100644 plugin/core/cross-plugin-api.ts delete mode 100644 plugin/hooks/fabric-chat-injector.ts create mode 100644 plugin/prompts/.gitkeep delete mode 100644 plugin/prompts/pcexec-convention.md create mode 100644 plugin/routers/.gitkeep delete mode 100644 plugin/routers/always.ts diff --git a/plugin/core/cross-plugin-api.ts b/plugin/core/cross-plugin-api.ts new file mode 100644 index 0000000..e6ac5cb --- /dev/null +++ b/plugin/core/cross-plugin-api.ts @@ -0,0 +1,83 @@ +/** + * Cross-plugin API: globalThis.__prismFacet + * + * PrismFacet is the registration mechanism (routers, rules, the + * before_prompt_build hook that resolves them); other plugins (notably + * ClawPrompts) supply the actual content by calling into this API at + * their own register() time. + * + * Installation idempotence: safe to call install() multiple times — the + * function attaches the same set of methods to globalThis.__prismFacet + * if not already present. Plugin reload doesn't break consumers. + * + * If a consumer plugin (ClawPrompts) loads BEFORE PrismFacet, the + * `globalThis.__prismFacet` slot will be undefined at the consumer's + * register() time. To make load-order independence work, this module + * pre-creates the slot at module-import time too — consumers can call + * .addRouter / .addRule even if PrismFacet's register() hasn't run yet + * (the registration still lands in the shared maps). + */ +import { addExternalRouter, type RouterContext } from "./router-loader.js"; +import { addExternalRule } from "./rule-store.js"; + +const _G = globalThis as Record; + +export interface PrismFacetCrossPluginApi { + /** + * Register a router programmatically. + * @param name router name (matches the "router:" prefix of rule keys) + * @param resolve function returning a key string for the given context + * (or empty/null to skip this router for this context) + */ + addRouter( + name: string, + resolve: (ctx: RouterContext) => string | Promise, + ): void; + + /** + * Register a rule programmatically (in-memory; persistent rules.json + * entries set by the prompt-rules tool take precedence on conflict). + * @param routerName router this rule binds to + * @param key value the router must resolve to for this rule to fire + * @param prompt either { file: "/abs/path.md" } or { text: "..." } + */ + addRule( + routerName: string, + key: string, + prompt: { file?: string; text?: string }, + ): void; +} + +/** Idempotent install. Called from PrismFacet's register(). Also runs + * at module-import time below so that consumer plugins loading before + * PrismFacet still find a usable API. */ +export function installCrossPluginApi(): PrismFacetCrossPluginApi { + const existing = _G["__prismFacet"] as PrismFacetCrossPluginApi | undefined; + if (existing && typeof existing.addRouter === "function") return existing; + + const api: PrismFacetCrossPluginApi = { + addRouter(name, resolve) { + addExternalRouter(name, resolve); + }, + addRule(routerName, key, prompt) { + // Phase 1: only file-based prompts are wired through the existing + // injector. `text` inline prompts will need a tmpfile-or-store + // backend; surfaced as a TODO so an early caller failing loudly + // beats silently dropping the prompt. + if (!prompt.file) { + throw new Error( + "__prismFacet.addRule: only { file: '/abs/path.md' } is supported today; " + + "inline { text } pending injector update", + ); + } + addExternalRule(routerName, key, prompt.file); + }, + }; + _G["__prismFacet"] = api; + return api; +} + +// Eager install on module load so a consumer plugin importing into the +// same gateway process can register even if it runs before PrismFacet's +// register() does. +installCrossPluginApi(); diff --git a/plugin/core/router-loader.ts b/plugin/core/router-loader.ts index a7bf157..4dd4279 100644 --- a/plugin/core/router-loader.ts +++ b/plugin/core/router-loader.ts @@ -75,3 +75,21 @@ export function getRouters(): LoadedRouter[] { export function getRouterNames(): string[] { return Array.from(getRouterMap().keys()); } + +/** + * Cross-plugin API: register a router programmatically. Other plugins + * (e.g. ClawPrompts) call this via globalThis.__prismFacet.addRouter + * to publish a router without dropping a .ts file in PrismFacet's + * routersDir. Replaces any existing router of the same name. + */ +export function addExternalRouter( + name: string, + resolveFn: (ctx: RouterContext) => string | Promise, +): void { + const map = getRouterMap(); + map.set(name, { + name, + filePath: ``, + module: { resolve: resolveFn }, + }); +} diff --git a/plugin/core/rule-store.ts b/plugin/core/rule-store.ts index 1e1bb9b..cb543a3 100644 --- a/plugin/core/rule-store.ts +++ b/plugin/core/rule-store.ts @@ -1,16 +1,34 @@ +/** + * Two-tier rule store: persistent (operator/admin rules loaded from + * rules.json, mutated by the prompt-rules tool) and external (in-memory + * rules registered by other plugins via the __prismFacet cross-plugin + * API). External rules are NOT saved to disk — they belong to the + * registering plugin and are re-registered on every gateway start. + * + * Lookup precedence: persistent > external (operator wins over plugin + * defaults so an explicit rules.json override is honored). + */ import { readFileSync, writeFileSync } from "node:fs"; export type RuleStore = Record; // "router:key" → prompt file path const _G = globalThis as Record; -const RULES_KEY = "_prismFacetRules"; +const PERSISTENT_KEY = "_prismFacetRules"; +const EXTERNAL_KEY = "_prismFacetExternalRules"; const RULES_PATH_KEY = "_prismFacetRulesPath"; -function getRules(): RuleStore { - if (!_G[RULES_KEY] || typeof _G[RULES_KEY] !== "object") { - _G[RULES_KEY] = {}; +function getPersistent(): RuleStore { + if (!_G[PERSISTENT_KEY] || typeof _G[PERSISTENT_KEY] !== "object") { + _G[PERSISTENT_KEY] = {}; } - return _G[RULES_KEY] as RuleStore; + return _G[PERSISTENT_KEY] as RuleStore; +} + +function getExternal(): RuleStore { + if (!_G[EXTERNAL_KEY] || typeof _G[EXTERNAL_KEY] !== "object") { + _G[EXTERNAL_KEY] = {}; + } + return _G[EXTERNAL_KEY] as RuleStore; } function getRulesPath(): string { @@ -20,26 +38,32 @@ function getRulesPath(): string { function save(): void { const p = getRulesPath(); if (!p) return; - writeFileSync(p, JSON.stringify(getRules(), null, 2) + "\n", "utf8"); + writeFileSync(p, JSON.stringify(getPersistent(), null, 2) + "\n", "utf8"); } export function initRuleStore(filePath: string): void { _G[RULES_PATH_KEY] = filePath; try { const raw = readFileSync(filePath, "utf8"); - _G[RULES_KEY] = JSON.parse(raw) as RuleStore; + _G[PERSISTENT_KEY] = JSON.parse(raw) as RuleStore; } catch { - _G[RULES_KEY] = {}; + _G[PERSISTENT_KEY] = {}; } } +/** Operator-facing addRule (writes through to rules.json on disk). */ export function addRule(router: string, key: string, promptFile: string): void { - getRules()[`${router}:${key}`] = promptFile; + getPersistent()[`${router}:${key}`] = promptFile; save(); } +/** Cross-plugin API: register a rule that lives in memory only. */ +export function addExternalRule(router: string, key: string, promptFile: string): void { + getExternal()[`${router}:${key}`] = promptFile; +} + export function removeRule(router: string, key: string): boolean { - const rules = getRules(); + const rules = getPersistent(); const ruleKey = `${router}:${key}`; if (!(ruleKey in rules)) return false; delete rules[ruleKey]; @@ -47,10 +71,22 @@ export function removeRule(router: string, key: string): boolean { return true; } +/** Lookup. Persistent (rules.json) overrides external (plugin defaults). */ export function getRule(router: string, key: string): string | undefined { - return getRules()[`${router}:${key}`]; + const persistentValue = getPersistent()[`${router}:${key}`]; + if (persistentValue) return persistentValue; + return getExternal()[`${router}:${key}`]; } +/** Merged view for the admin tool. Persistent overrides external. */ export function listRules(): Record { - return { ...getRules() }; + return { ...getExternal(), ...getPersistent() }; +} + +/** Admin tool needs to know which tier a rule came from. */ +export function listRulesTiered(): { + persistent: RuleStore; + external: RuleStore; +} { + return { persistent: { ...getPersistent() }, external: { ...getExternal() } }; } diff --git a/plugin/hooks/fabric-chat-injector.ts b/plugin/hooks/fabric-chat-injector.ts deleted file mode 100644 index e1a4e31..0000000 --- a/plugin/hooks/fabric-chat-injector.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Inject a "start the chat workflow" hint into the agent's system prompt - * when this turn was triggered by a message in a fabric channel. - * - * Two cases: - * - no prior chat journal for this (agent, channel) → suggest - * `workflow_start chat` (fresh). - * - mapping exists → suggest `workflow_start chat from ` - * so the conversation continues in the same linear journal. - * - * The (channel → journal) mapping is owned by Meridian and exposed via - * globalThis.__meridian.getChatJournalForChannel. Meridian writes the - * entry the first time the agent starts a chat workflow on a channel - * with no `from` argument. - * - * TODO(phase-2): once Fabric.OpenclawPlugin exposes per-channel type - * info (DM / group / triage) via a cross-plugin API, narrow this hook - * to xType === 'dm' only. Today we inject for any fabric channel — chat - * workflow itself is a no-op outside DMs, but the suggestion is noise. - */ -const _G = globalThis as Record; -const DEDUP_KEY = '_prismFacetFabricChatDedup'; - -interface MeridianBridge { - getChatJournalForChannel?: (agentId: string, channelId: string) => Promise; -} - -interface PromptCtx { - agentId?: string; - channelId?: string; - messageProvider?: string; -} - -export function registerFabricChatInjector(api: { - on(hook: string, handler: (...args: any[]) => any): void; - logger: { info(msg: string): void; warn(msg: string): void }; -}): void { - if (!(_G[DEDUP_KEY] instanceof WeakSet)) _G[DEDUP_KEY] = new WeakSet(); - const dedup = _G[DEDUP_KEY] as WeakSet; - - api.on('before_prompt_build', async (event: unknown, ctx: PromptCtx) => { - if (dedup.has(event as object)) return; - dedup.add(event as object); - - const agentId = ctx.agentId || ''; - const channelId = (ctx.channelId || '').trim(); - const provider = (ctx.messageProvider || '').toLowerCase(); - if (!agentId || !channelId) return; - if (provider && provider !== 'fabric') return; - // Empty provider also accepted — gateway sometimes omits the field - // even when the trigger was a fabric channel; channelId presence is - // the load-bearing signal. - - let journalId: string | null = null; - const meridian = _G['__meridian'] as MeridianBridge | undefined; - if (typeof meridian?.getChatJournalForChannel === 'function') { - try { - journalId = await meridian.getChatJournalForChannel(agentId, channelId); - } catch (err) { - api.logger.warn( - `[prism-facet] fabric-chat-injector: meridian lookup failed for ` + - `agent=${agentId} channel=${channelId}: ${String(err)}`, - ); - } - } - - const cmd = journalId - ? `\`workflow_start\` with \`workflow="chat"\` and \`from="${journalId}"\`` - : `\`workflow_start\` with \`workflow="chat"\``; - - const continuationLine = journalId - ? `This channel already has an open chat journal (\`${journalId}\`). Resume it with the \`from\` argument so the conversation history stays in one file.` - : `No prior chat journal exists for this channel yet — Meridian will create a fresh one and remember the channel→journal mapping for future turns.`; - - const segment = - `# Chat channel context\n` + - `\n` + - `This turn was triggered by a message in a fabric channel (\`${channelId}\`).\n` + - `${continuationLine}\n` + - `\n` + - `**Next action:** call ${cmd} to enter the chat workflow before doing anything else.\n`; - - return { appendSystemContext: segment }; - }); -} diff --git a/plugin/index.ts b/plugin/index.ts index 5bcb5e8..aefd1e1 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,8 +1,30 @@ +/** + * PrismFacet — prompt-rule registration framework. + * + * Provides: + * - router loader (file-based: routersDir + programmatic: __prismFacet.addRouter) + * - rule store (persistent rules.json + in-memory external rules) + * - before_prompt_build hook that resolves all loaded routers/rules + * and appends the matching prompt fragments to the agent's system + * prompt + * - prompt-rules admin tool to inspect / mutate persistent rules + * - cross-plugin API installed at module load time (globalThis.__prismFacet) + * + * PrismFacet ships NO content of its own — the routers/, prompts/, and + * rules.json directories are empty placeholders. Hangman-Lab-specific + * routers and rules live in the ClawPrompts plugin, which registers + * them at startup via the cross-plugin API. + */ import path from "node:path"; +// Importing core/cross-plugin-api.ts has a side-effect: it installs +// globalThis.__prismFacet at module-import time. Doing this side-effect +// import early — before any other plugin's register() — means a +// consumer plugin (ClawPrompts) loaded before us still finds a working +// __prismFacet to call into. +import "./core/cross-plugin-api.js"; import { loadRouters } from "./core/router-loader.js"; import { initRuleStore } from "./core/rule-store.js"; import { registerBeforePromptBuild } from "./hooks/before-prompt-build.js"; -import { registerFabricChatInjector } from "./hooks/fabric-chat-injector.js"; import { registerPromptRulesTool } from "./tools/prompt-rules.js"; interface PluginConfig { @@ -58,11 +80,10 @@ export default { // Agent session hooks: register every time (dedup inside handler) registerBeforePromptBuild(api); - registerFabricChatInjector(api); // Tools registerPromptRulesTool(api, routersDir); - api.logger.info("[prism-facet] plugin registered"); + api.logger.info("[prism-facet] plugin registered (framework only — content via ClawPrompts)"); }, }; diff --git a/plugin/prompts/.gitkeep b/plugin/prompts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/prompts/pcexec-convention.md b/plugin/prompts/pcexec-convention.md deleted file mode 100644 index 1f66186..0000000 --- a/plugin/prompts/pcexec-convention.md +++ /dev/null @@ -1,31 +0,0 @@ -# Hangman-Lab Site Convention — Shell Execution - -This claw (sim or prod) keeps Hangman-Lab site binaries at `~/.openclaw/bin/` -and **does not** symlink them into `/usr/local/bin`. Your shell tool's PATH -does not include them by default, so calling them with the codex built-in -shell yields `command not found`. - -**Rule:** any command that invokes one of these binaries MUST be run through -the `pcexec` tool, not the codex built-in shell: - -- `hf` (HarborForge CLI) -- `secret-mgr` (per-agent secret store) -- `ego-mgr` (per-agent identity store; reads `role`, `position`, `default-username`, etc.) -- `fabric-register` (Fabric account provisioning) -- `pcguard` (PaddedCell guard) -- `lock-mgr` -- `tea` - -`pcexec` injects `~/.openclaw/bin` into PATH and also wires the -`AGENT_ID`, `AGENT_WORKSPACE`, and `AGENT_VERIFY` env vars that -`secret-mgr` / `ego-mgr` need to authenticate as the calling agent. - -## Examples - -- ✅ Call the `pcexec` tool with `command: "hf calendar show --json"` -- ✅ Call the `pcexec` tool with `command: "HFT=$(secret-mgr get-secret --key hf-token); hf task list --token \"$HFT\" --json"` (the whole pipeline goes in one `pcexec` call) -- ❌ Sending `hf calendar show` to the codex built-in shell → `command not found` - -If a workflow's `Procedure` shows a raw shell snippet involving these CLIs, -pass the **whole snippet** as a single `command:` argument to `pcexec` — -don't split into multiple non-pcexec calls. diff --git a/plugin/routers/.gitkeep b/plugin/routers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/routers/always.ts b/plugin/routers/always.ts deleted file mode 100644 index 78d7e44..0000000 --- a/plugin/routers/always.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * `always` router — resolves to the constant key `"always"` for every - * agent. Pair with a rule like `always:always → ` to - * inject a prompt fragment into every agent's system prompt - * unconditionally (no ego / role / position lookup needed). - */ -import type { RouterContext } from "../core/router-loader.js"; - -export function resolve(_ctx: RouterContext): string { - return "always"; -} diff --git a/plugin/rules.json b/plugin/rules.json index 46bfccb..0967ef4 100644 --- a/plugin/rules.json +++ b/plugin/rules.json @@ -1,3 +1 @@ -{ - "always:always": "/root/.openclaw/plugins/prism-facet/prompts/pcexec-convention.md" -} +{} diff --git a/rules.json b/rules.json index 9e26dfe..0967ef4 100644 --- a/rules.json +++ b/rules.json @@ -1 +1 @@ -{} \ No newline at end of file +{} From cfd2ec445b73a6762b869cd3a67129a87ed62fc5 Mon Sep 17 00:00:00 2001 From: hzhang Date: Mon, 25 May 2026 10:25:41 +0100 Subject: [PATCH 2/3] fix(cross-plugin-api): drain __prismFacetPending queue from early consumers Plugin load order is undefined. If a consumer (ClawPrompts) loads before PrismFacet, it can't call __prismFacet.addRouter directly (slot is undefined). Now it pushes ops onto the well-known __prismFacetPending array; PrismFacet's installCrossPluginApi() drains the queue when it runs. Marker __real: true on the installed API so consumers can tell stub from real. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugin/core/cross-plugin-api.ts | 51 +++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/plugin/core/cross-plugin-api.ts b/plugin/core/cross-plugin-api.ts index e6ac5cb..4526304 100644 --- a/plugin/core/cross-plugin-api.ts +++ b/plugin/core/cross-plugin-api.ts @@ -48,22 +48,34 @@ export interface PrismFacetCrossPluginApi { ): void; } -/** Idempotent install. Called from PrismFacet's register(). Also runs - * at module-import time below so that consumer plugins loading before - * PrismFacet still find a usable API. */ +type PendingOp = + | { type: "router"; name: string; resolve: (ctx: RouterContext) => string | Promise } + | { type: "rule"; routerName: string; key: string; prompt: { file?: string; text?: string } }; + +/** + * Idempotent install. Called from PrismFacet's register(). Also runs + * at module-import time below so consumer plugins loading before + * PrismFacet still find a usable API. + * + * Plugin load order is undefined — if ClawPrompts loads before + * PrismFacet, the `_G['__prismFacet']` slot stays at the buffering stub + * created by ClawPrompts (or, if neither has installed yet, missing). + * To make this race-free, we install the real API on first call from + * either side AND drain any queued ops a buffering caller may have + * accumulated via the well-known `__prismFacetPending` array. ClawPrompts + * (and any future consumer) only needs to: push ops onto the pending + * array when `__prismFacet?.addRouter` isn't callable; PrismFacet's + * install() will drain them when it runs. + */ export function installCrossPluginApi(): PrismFacetCrossPluginApi { const existing = _G["__prismFacet"] as PrismFacetCrossPluginApi | undefined; - if (existing && typeof existing.addRouter === "function") return existing; + if (existing && typeof existing.addRouter === "function" && (existing as any).__real) return existing; - const api: PrismFacetCrossPluginApi = { + const api: PrismFacetCrossPluginApi & { __real: true } = { addRouter(name, resolve) { addExternalRouter(name, resolve); }, addRule(routerName, key, prompt) { - // Phase 1: only file-based prompts are wired through the existing - // injector. `text` inline prompts will need a tmpfile-or-store - // backend; surfaced as a TODO so an early caller failing loudly - // beats silently dropping the prompt. if (!prompt.file) { throw new Error( "__prismFacet.addRule: only { file: '/abs/path.md' } is supported today; " + @@ -72,12 +84,27 @@ export function installCrossPluginApi(): PrismFacetCrossPluginApi { } addExternalRule(routerName, key, prompt.file); }, + __real: true, }; _G["__prismFacet"] = api; + + // Drain any ops queued by consumer plugins that ran before us. + const pending = _G["__prismFacetPending"] as PendingOp[] | undefined; + if (Array.isArray(pending) && pending.length > 0) { + for (const op of pending) { + try { + if (op.type === "router") api.addRouter(op.name, op.resolve); + else api.addRule(op.routerName, op.key, op.prompt); + } catch { + // Swallow per-op errors so one bad pending entry doesn't block + // the rest. Consumer plugin logs already fired at queue time. + } + } + _G["__prismFacetPending"] = []; + } + return api; } -// Eager install on module load so a consumer plugin importing into the -// same gateway process can register even if it runs before PrismFacet's -// register() does. +// Eager install on module load. installCrossPluginApi(); From 6dca427187e527f668b67fd5339187e1109235e9 Mon Sep 17 00:00:00 2001 From: hzhang Date: Mon, 25 May 2026 10:31:18 +0100 Subject: [PATCH 3/3] fix: loadRouters preserves API-registered routers; install.mjs chowns root Two related fixes: 1. core/router-loader.loadRouters() previously called map.clear() before scanning routersDir, which wiped routers registered via __prismFacet.addRouter (the cross-plugin API). Now: track which entries in the map came from a file vs API (filePath sentinel), only delete file-based ones that disappeared between loads. External routers are never touched. 2. scripts/install.mjs: chown installed plugin files to root when the installer is running as root. openclaw 2026.5+ blocks plugins whose files are owned by non-root; rsync/tar from a developer laptop silently broke prism-facet on the next gateway restart. Matches the Meridian + ClawPrompts install.mjs fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugin/core/router-loader.ts | 20 +++++++++++++++++++- scripts/install.mjs | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/plugin/core/router-loader.ts b/plugin/core/router-loader.ts index 4dd4279..1a515f3 100644 --- a/plugin/core/router-loader.ts +++ b/plugin/core/router-loader.ts @@ -35,7 +35,16 @@ export async function loadRouters( log: { info(msg: string): void; warn(msg: string): void } ): Promise { const map = getRouterMap(); - map.clear(); + // Don't clear() — that would wipe externally-registered routers added + // by other plugins via __prismFacet.addRouter. Instead, track which + // routers came from the filesystem this run and remove only those + // that disappeared. External routers (filePath sentinel) are never + // touched here. + const FILE_SENTINEL_PREFIX = "(); + for (const [name, r] of map.entries()) { + if (!r.filePath.startsWith(FILE_SENTINEL_PREFIX)) fileRoutersBefore.add(name); + } if (typeof _G[LOAD_COUNTER_KEY] !== "number") _G[LOAD_COUNTER_KEY] = 0; (_G[LOAD_COUNTER_KEY] as number)++; @@ -48,9 +57,12 @@ export async function loadRouters( ); } catch { log.warn(`[prism-facet] routers directory not found: ${routersDir}`); + // No file-based routers this run — drop any leftover file routers. + for (const name of fileRoutersBefore) map.delete(name); return; } + const seenThisRun = new Set(); for (const file of files) { const name = routerNameFromFile(file); const filePath = path.resolve(routersDir, file); @@ -61,11 +73,17 @@ export async function loadRouters( continue; } map.set(name, { name, filePath, module: mod }); + seenThisRun.add(name); log.info(`[prism-facet] router loaded: ${name}`); } catch (err) { log.warn(`[prism-facet] router ${name}: failed to load — ${String(err)}`); } } + // Drop file-based routers that disappeared between loads. Untouched + // routers stay. + for (const stale of fileRoutersBefore) { + if (!seenThisRun.has(stale)) map.delete(stale); + } } export function getRouters(): LoadedRouter[] { diff --git a/scripts/install.mjs b/scripts/install.mjs index 3723d91..e402a5d 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -130,6 +130,24 @@ function copyDirRecursive(src, dest, excludeExts = []) { fs.copyFileSync(srcPath, destPath); } } + // openclaw 2026.5+ refuses to load plugins whose files are owned by + // non-root ("suspicious ownership"). fs.copyFileSync preserves source + // ownership, so rsync/tar from a developer laptop (uid 1000) breaks + // the plugin on next gateway restart. Force chown to root when we + // can (root-only); silently skip otherwise (dev mode under + // unprivileged user — uid checks don't apply there). + try { + if (process.getuid && process.getuid() === 0) chownRecursive(dest); + } catch {} +} + +function chownRecursive(dir) { + fs.chownSync(dir, 0, 0); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) chownRecursive(p); + else fs.chownSync(p, 0, 0); + } } switch (action) {