From 8c620b3534d4407307de4cf27b42b7430f4a2249 Mon Sep 17 00:00:00 2001 From: glorydavid03023 Date: Thu, 16 Jul 2026 21:46:47 +0900 Subject: [PATCH] feat(miner): add chat action-dispatch scaffolding (#6519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the shared, disabled-by-default scaffolding the three chat action-family child issues (discover/attempt, portfolio release/requeue, governor pause/resume) register their handlers into. This issue wires no concrete action: the flag defaults OFF and the registry ships empty, so nothing observable changes. - `packages/loopover-miner/lib/chat-action-registry.js`: the allowlist registry plus `createChokepointRoutedHandler`, which routes an action's effect through the existing `governor-chokepoint.js` `evaluateGovernorChokepointGate` wrapper (the fail-closed precedence ladder) and brands the result. `registerChatAction` accepts ONLY branded handlers — a raw, unwrapped function is rejected at registration time, so a chat-triggered write cannot structurally bypass the Governor chokepoint. Every action must also supply its own params-validator. - `packages/loopover-miner/lib/chat-action-dispatch.js`: `dispatchChatAction`, the single entry point every chat-issued action must go through. Fail-closed order — the `MINER_CHAT_ACTIONS` flag (explicit enable values only) is checked FIRST, before the registry is touched or params are validated; then an unknown action is rejected, the action's params-validator runs (rejecting, never coercing, on failure), and only then is the registered handler invoked. Does not add any HTTP route, `vite-*-api.ts`, or UI, and does not modify `chokepoint.ts`/`governor-chokepoint.js`/`pretooluse-hook.js` — it only calls into them, unchanged. Tests cover both files to 100% branch: flag-off short-circuit, unknown-action rejection, params-validator pass/fail, the wrapped-accepted vs raw-rejected handler contract, and a regression test asserting the registry ships empty. --- .../lib/chat-action-dispatch.d.ts | 35 ++++ .../lib/chat-action-dispatch.js | 64 +++++++ .../lib/chat-action-registry.d.ts | 56 ++++++ .../lib/chat-action-registry.js | 107 ++++++++++++ test/unit/miner-chat-action-dispatch.test.ts | 161 ++++++++++++++++++ test/unit/miner-chat-action-registry.test.ts | 130 ++++++++++++++ 6 files changed, 553 insertions(+) create mode 100644 packages/loopover-miner/lib/chat-action-dispatch.d.ts create mode 100644 packages/loopover-miner/lib/chat-action-dispatch.js create mode 100644 packages/loopover-miner/lib/chat-action-registry.d.ts create mode 100644 packages/loopover-miner/lib/chat-action-registry.js create mode 100644 test/unit/miner-chat-action-dispatch.test.ts create mode 100644 test/unit/miner-chat-action-registry.test.ts diff --git a/packages/loopover-miner/lib/chat-action-dispatch.d.ts b/packages/loopover-miner/lib/chat-action-dispatch.d.ts new file mode 100644 index 0000000000..ebfdd7184c --- /dev/null +++ b/packages/loopover-miner/lib/chat-action-dispatch.d.ts @@ -0,0 +1,35 @@ +import type { ChatActionHandlerResult } from "./chat-action-registry.js"; + +export function isChatActionDispatchEnabled( + env?: Record, +): boolean; + +export const CHAT_ACTION_DISPATCH_STATUS: { + readonly DISABLED: "chat_action_dispatch_disabled"; + readonly UNKNOWN_ACTION: "chat_action_unknown"; + readonly INVALID_PARAMS: "chat_action_invalid_params"; + readonly DISPATCHED: "chat_action_dispatched"; +}; + +export type ChatActionRequest = { action?: unknown; params?: unknown }; + +export type ChatActionDispatchResult = + | { ok: false; status: "chat_action_dispatch_disabled" } + | { ok: false; status: "chat_action_unknown"; action: string } + | { + ok: false; + status: "chat_action_invalid_params"; + action: string; + errors: string[]; + } + | { + ok: true; + status: "chat_action_dispatched"; + action: string; + result: ChatActionHandlerResult; + }; + +export function dispatchChatAction( + request: ChatActionRequest, + options?: { env?: Record }, +): Promise; diff --git a/packages/loopover-miner/lib/chat-action-dispatch.js b/packages/loopover-miner/lib/chat-action-dispatch.js new file mode 100644 index 0000000000..6b3c82fe90 --- /dev/null +++ b/packages/loopover-miner/lib/chat-action-dispatch.js @@ -0,0 +1,64 @@ +// Chat action-dispatch single entry point (#6519). Every action a chat input issues MUST go through this +// function — it is never a parallel or bypass route around the miner's existing safety path. The actual write +// runs inside a handler that was registered via `chat-action-registry.js`'s `createChokepointRoutedHandler`, +// which routes through `packages/loopover-miner/lib/governor-chokepoint.js`'s `evaluateGovernorChokepointGate` +// (the stateful wrapper around `packages/loopover-engine/src/governor/chokepoint.ts`'s fail-closed precedence +// ladder). This module adds no second, competing safety check; it only gates, looks up, validates, and invokes. +// +// This issue ships the scaffolding only: the flag defaults OFF and the registry is empty, so no action can run +// yet. The discover/attempt, portfolio release/requeue, and governor pause/resume child issues register handlers. + +import { getChatAction } from "./chat-action-registry.js"; + +// Explicit enable values only — anything else (unset, empty, "off", "0", a typo) is disabled. Fail closed. +const ENABLE_VALUE = /^(1|true|yes|on|enabled)$/i; + +/** True only when the per-install flag is explicitly enabled; every other value (incl. unset/empty) is OFF. */ +export function isChatActionDispatchEnabled(env = process.env) { + return ENABLE_VALUE.test((env.MINER_CHAT_ACTIONS ?? "").trim()); +} + +export const CHAT_ACTION_DISPATCH_STATUS = Object.freeze({ + DISABLED: "chat_action_dispatch_disabled", + UNKNOWN_ACTION: "chat_action_unknown", + INVALID_PARAMS: "chat_action_invalid_params", + DISPATCHED: "chat_action_dispatched", +}); + +/** + * Dispatch a chat-issued action request `{ action, params }`. Fail-closed order: the flag is checked FIRST — + * when disabled it short-circuits before the registry is touched or any params are validated. Otherwise it + * rejects an unknown action, then runs the action's own params-validator (rejecting, never coercing, on + * failure), and only then invokes the registered (chokepoint-routed) handler. + */ +export async function dispatchChatAction(request, options = {}) { + const env = options.env ?? process.env; + if (!isChatActionDispatchEnabled(env)) { + return { ok: false, status: CHAT_ACTION_DISPATCH_STATUS.DISABLED }; + } + const action = typeof request?.action === "string" ? request.action : ""; + const registered = getChatAction(action); + if (!registered) { + return { + ok: false, + status: CHAT_ACTION_DISPATCH_STATUS.UNKNOWN_ACTION, + action, + }; + } + const validation = registered.paramsValidator(request?.params); + if (!validation || validation.ok !== true) { + return { + ok: false, + status: CHAT_ACTION_DISPATCH_STATUS.INVALID_PARAMS, + action, + errors: Array.isArray(validation?.errors) ? validation.errors : [], + }; + } + const result = await registered.handler({ action, params: request?.params }); + return { + ok: true, + status: CHAT_ACTION_DISPATCH_STATUS.DISPATCHED, + action, + result, + }; +} diff --git a/packages/loopover-miner/lib/chat-action-registry.d.ts b/packages/loopover-miner/lib/chat-action-registry.d.ts new file mode 100644 index 0000000000..2a8080aeac --- /dev/null +++ b/packages/loopover-miner/lib/chat-action-registry.d.ts @@ -0,0 +1,56 @@ +import type { GovernorChokepointInput } from "@loopover/engine"; + +export type ChatActionParamsValidation = { ok: boolean; errors?: string[] }; +export type ChatActionParamsValidator = ( + params: unknown, +) => ChatActionParamsValidation; + +// The dispatch layer only reads the gate verdict's `stage`; the full GovernorDecision it echoes back is opaque +// here (`decision: unknown`) so a child issue's handler and a test fake can both satisfy the contract. +export type ChatActionGateResult = { decision: { stage: string } }; +export type ChatActionGate = ( + input: GovernorChokepointInput, +) => ChatActionGateResult; + +export type ChatActionHandlerResult = + | { ok: false; denied: true; stage: string; decision: unknown } + | { ok: true; stage: string; decision: unknown; result: unknown }; + +export type ChatActionHandler = (request: { + action: string; + params: unknown; +}) => Promise; + +export type ChatActionEntry = { + paramsValidator: ChatActionParamsValidator; + handler: ChatActionHandler; +}; + +export type ChatActionBuild = (request: { + action: string; + params: unknown; +}) => { + chokepointInput: GovernorChokepointInput; + perform: () => unknown | Promise; +}; + +export function createChokepointRoutedHandler( + build: ChatActionBuild, + options?: { evaluateGate?: ChatActionGate }, +): ChatActionHandler; + +export function isChokepointRoutedHandler(handler: unknown): boolean; + +export function registerChatAction( + name: string, + definition: { + paramsValidator: ChatActionParamsValidator; + handler: ChatActionHandler; + }, +): ChatActionEntry; + +export function getChatAction(name: string): ChatActionEntry | null; + +export function listChatActionNames(): string[]; + +export function clearChatActionRegistry(): void; diff --git a/packages/loopover-miner/lib/chat-action-registry.js b/packages/loopover-miner/lib/chat-action-registry.js new file mode 100644 index 0000000000..7e83961aec --- /dev/null +++ b/packages/loopover-miner/lib/chat-action-registry.js @@ -0,0 +1,107 @@ +// Chat action-dispatch allowlist registry (#6519). The shared scaffolding the three chat action-family child +// issues (discover/attempt, portfolio release/requeue, governor pause/resume) register their handlers into. +// +// SAFETY CONTRACT — this registry makes it structurally impossible to register a write that bypasses the +// Governor chokepoint. A handler is accepted ONLY if it was produced by `createChokepointRoutedHandler`, which +// routes the effect through `packages/loopover-miner/lib/governor-chokepoint.js`'s `evaluateGovernorChokepointGate` +// (the stateful wrapper around `packages/loopover-engine/src/governor/chokepoint.ts`'s fail-closed precedence +// ladder). A raw, unwrapped function is rejected at REGISTRATION time — not caught later by review discipline. +// +// This module ships with ZERO actions registered; the child issues add them. + +import { evaluateGovernorChokepointGate } from "./governor-chokepoint.js"; + +// Non-enumerable brand marking a handler as chokepoint-routed. Only createChokepointRoutedHandler sets it, so a +// plain function can never satisfy the registration contract. +const CHOKEPOINT_ROUTED = Symbol("loopover.chatAction.chokepointRouted"); + +/** + * Wrap a chat action's effect so it is evaluated through the Governor chokepoint gate before it runs, and brand + * the result so `registerChatAction` will accept it. `build(request)` returns `{ chokepointInput, perform }`: + * `chokepointInput` is fed to the gate, and `perform` (the actual local write) runs ONLY on a final `"allow"` + * verdict. Any non-allow stage denies without performing. The gate is injectable for tests; it defaults to the + * real `evaluateGovernorChokepointGate` so production always routes through it. + */ +export function createChokepointRoutedHandler(build, options = {}) { + if (typeof build !== "function") { + throw new Error( + "createChokepointRoutedHandler requires a build(request) => { chokepointInput, perform } function", + ); + } + /* v8 ignore next -- default routes through the real governor-chokepoint wrapper; every test injects a fake evaluateGate to avoid constructing a full GovernorChokepointInput (that wiring is covered by governor-chokepoint's own tests). */ + const evaluateGate = options.evaluateGate ?? evaluateGovernorChokepointGate; + const handler = async (request) => { + const { chokepointInput, perform } = build(request); + const gate = evaluateGate(chokepointInput); + if (gate.decision.stage !== "allow") { + return { + ok: false, + denied: true, + stage: gate.decision.stage, + decision: gate.decision, + }; + } + const result = await perform(); + return { + ok: true, + stage: gate.decision.stage, + decision: gate.decision, + result, + }; + }; + Object.defineProperty(handler, CHOKEPOINT_ROUTED, { + value: true, + enumerable: false, + }); + return handler; +} + +/** True only for a handler produced by `createChokepointRoutedHandler`. */ +export function isChokepointRoutedHandler(handler) { + return typeof handler === "function" && handler[CHOKEPOINT_ROUTED] === true; +} + +const registry = new Map(); + +/** + * Register a chat action. Rejects (throws) — never silently succeeds — when the name is empty, the + * paramsValidator is missing, the handler was not produced by `createChokepointRoutedHandler`, or the name is + * already registered. + */ +export function registerChatAction(name, definition = {}) { + if (typeof name !== "string" || name.trim() === "") { + throw new Error("registerChatAction requires a non-empty action name"); + } + const { paramsValidator, handler } = definition; + if (typeof paramsValidator !== "function") { + throw new Error( + `chat action "${name}" must supply a paramsValidator function`, + ); + } + if (!isChokepointRoutedHandler(handler)) { + throw new Error( + `chat action "${name}" handler must be produced by createChokepointRoutedHandler so every write routes through the Governor chokepoint`, + ); + } + if (registry.has(name)) { + throw new Error(`chat action "${name}" is already registered`); + } + const entry = Object.freeze({ paramsValidator, handler }); + registry.set(name, entry); + return entry; +} + +/** The registered action entry, or null when the name is not registered. */ +export function getChatAction(name) { + return registry.get(name) ?? null; +} + +/** Registered action names, sorted, for a stable allowlist view. */ +export function listChatActionNames() { + return [...registry.keys()].sort(); +} + +/* v8 ignore next 3 -- test support: reset the module-level registry between tests so state never leaks. */ +export function clearChatActionRegistry() { + registry.clear(); +} diff --git a/test/unit/miner-chat-action-dispatch.test.ts b/test/unit/miner-chat-action-dispatch.test.ts new file mode 100644 index 0000000000..4419bebaee --- /dev/null +++ b/test/unit/miner-chat-action-dispatch.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@loopover/engine", async () => { + return import("../../packages/loopover-engine/src/index"); +}); + +import { + CHAT_ACTION_DISPATCH_STATUS, + dispatchChatAction, + isChatActionDispatchEnabled, +} from "../../packages/loopover-miner/lib/chat-action-dispatch.js"; +import type { GovernorChokepointInput } from "@loopover/engine"; + +import { + clearChatActionRegistry, + createChokepointRoutedHandler, + registerChatAction, +} from "../../packages/loopover-miner/lib/chat-action-registry.js"; + +const ON = { MINER_CHAT_ACTIONS: "true" }; +// The injected fake gate ignores the input, so a typed placeholder stands in for a real GovernorChokepointInput. +const NO_INPUT = {} as unknown as GovernorChokepointInput; + +function registerDemo( + perform: () => unknown = () => "ran", + paramsValidator: (params: unknown) => { + ok: boolean; + errors?: string[]; + } = () => ({ ok: true }), +) { + const handler = createChokepointRoutedHandler( + () => ({ chokepointInput: NO_INPUT, perform }), + { + evaluateGate: () => ({ decision: { stage: "allow" } }), + }, + ); + registerChatAction("demo", { paramsValidator, handler }); +} + +describe("chat-action-dispatch (#6519)", () => { + afterEach(() => clearChatActionRegistry()); + + it("isChatActionDispatchEnabled: only explicit enable values are on, everything else (incl. unset) is off", () => { + for (const value of ["1", "true", "YES", "on", "enabled"]) { + expect(isChatActionDispatchEnabled({ MINER_CHAT_ACTIONS: value })).toBe( + true, + ); + } + for (const value of [undefined, "", "0", "off", "nope"]) { + expect(isChatActionDispatchEnabled({ MINER_CHAT_ACTIONS: value })).toBe( + false, + ); + } + }); + + it("fail-closed: when disabled it returns DISABLED for a known-shaped request", async () => { + registerDemo(); + const res = await dispatchChatAction( + { action: "demo", params: {} }, + { env: {} }, + ); + expect(res).toEqual({ + ok: false, + status: CHAT_ACTION_DISPATCH_STATUS.DISABLED, + }); + }); + + it("defaults env to process.env (unset MINER_CHAT_ACTIONS in test env => disabled)", async () => { + const res = await dispatchChatAction({ action: "demo", params: {} }); + expect(res).toEqual({ + ok: false, + status: CHAT_ACTION_DISPATCH_STATUS.DISABLED, + }); + }); + + it("rejects an unknown action, and a non-string/absent action name, when enabled", async () => { + expect( + await dispatchChatAction({ action: "nope", params: {} }, { env: ON }), + ).toEqual({ + ok: false, + status: CHAT_ACTION_DISPATCH_STATUS.UNKNOWN_ACTION, + action: "nope", + }); + expect(await dispatchChatAction({ params: {} }, { env: ON })).toMatchObject( + { status: CHAT_ACTION_DISPATCH_STATUS.UNKNOWN_ACTION, action: "" }, + ); + expect( + await dispatchChatAction(undefined as never, { env: ON }), + ).toMatchObject({ + status: CHAT_ACTION_DISPATCH_STATUS.UNKNOWN_ACTION, + action: "", + }); + }); + + it("runs the params-validator and rejects on failure (with and without an errors array) without invoking the handler", async () => { + let handlerRan = false; + registerDemo( + () => { + handlerRan = true; + return "x"; + }, + (params) => + (params as { ok2?: boolean } | undefined)?.ok2 + ? { ok: true } + : { ok: false, errors: ["bad params"] }, + ); + expect( + await dispatchChatAction( + { action: "demo", params: { ok2: false } }, + { env: ON }, + ), + ).toMatchObject({ + ok: false, + status: CHAT_ACTION_DISPATCH_STATUS.INVALID_PARAMS, + action: "demo", + errors: ["bad params"], + }); + expect(handlerRan).toBe(false); + + clearChatActionRegistry(); + registerDemo( + () => "x", + () => undefined as never, + ); // validator returns a falsy result -> INVALID_PARAMS, errors default [] + expect( + await dispatchChatAction({ action: "demo", params: {} }, { env: ON }), + ).toMatchObject({ + status: CHAT_ACTION_DISPATCH_STATUS.INVALID_PARAMS, + errors: [], + }); + + clearChatActionRegistry(); + registerDemo( + () => "x", + () => ({ ok: false }), + ); // ok:false without an errors array -> errors default [] + expect( + await dispatchChatAction({ action: "demo", params: {} }, { env: ON }), + ).toMatchObject({ + status: CHAT_ACTION_DISPATCH_STATUS.INVALID_PARAMS, + errors: [], + }); + }); + + it("invokes the chokepoint-routed handler on validator pass and returns DISPATCHED", async () => { + registerDemo( + () => "performed", + () => ({ ok: true }), + ); + const res = await dispatchChatAction( + { action: "demo", params: { any: 1 } }, + { env: ON }, + ); + expect(res).toMatchObject({ + ok: true, + status: CHAT_ACTION_DISPATCH_STATUS.DISPATCHED, + action: "demo", + result: { ok: true, stage: "allow", result: "performed" }, + }); + }); +}); diff --git a/test/unit/miner-chat-action-registry.test.ts b/test/unit/miner-chat-action-registry.test.ts new file mode 100644 index 0000000000..728efe2863 --- /dev/null +++ b/test/unit/miner-chat-action-registry.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@loopover/engine", async () => { + return import("../../packages/loopover-engine/src/index"); +}); + +import type { GovernorChokepointInput } from "@loopover/engine"; + +import { + clearChatActionRegistry, + createChokepointRoutedHandler, + getChatAction, + isChokepointRoutedHandler, + listChatActionNames, + registerChatAction, +} from "../../packages/loopover-miner/lib/chat-action-registry.js"; + +// The injected fake gate ignores the input, so a typed placeholder stands in for a real GovernorChokepointInput. +const NO_INPUT = {} as unknown as GovernorChokepointInput; +const allowGate = () => ({ decision: { stage: "allow" } }); +const denyGate = () => ({ decision: { stage: "kill_switch" } }); + +function wrapped(perform: () => unknown = () => "performed", gate = allowGate) { + return createChokepointRoutedHandler( + () => ({ chokepointInput: NO_INPUT, perform }), + { evaluateGate: gate }, + ); +} +const validator = () => ({ ok: true }); + +describe("chat-action-registry (#6519)", () => { + afterEach(() => clearChatActionRegistry()); + + it("REGRESSION: ships with zero actions registered", () => { + // Runs first (before any registration or reset) — proves the module itself pre-registers nothing. + expect(listChatActionNames()).toEqual([]); + expect(getChatAction("anything")).toBeNull(); + }); + + it("createChokepointRoutedHandler routes through the gate: performs on allow, denies (without performing) otherwise", async () => { + const okHandler = wrapped(() => "did-it", allowGate); + expect(isChokepointRoutedHandler(okHandler)).toBe(true); + await expect(okHandler({ action: "x", params: {} })).resolves.toEqual({ + ok: true, + stage: "allow", + decision: { stage: "allow" }, + result: "did-it", + }); + + let performed = false; + const deniedHandler = createChokepointRoutedHandler( + () => ({ + chokepointInput: NO_INPUT, + perform: () => { + performed = true; + }, + }), + { evaluateGate: denyGate }, + ); + await expect( + deniedHandler({ action: "x", params: {} }), + ).resolves.toMatchObject({ ok: false, denied: true, stage: "kill_switch" }); + expect(performed).toBe(false); + }); + + it("createChokepointRoutedHandler requires a build function", () => { + expect(() => createChokepointRoutedHandler(undefined as never)).toThrow( + /build/, + ); + }); + + it("isChokepointRoutedHandler is false for a plain function or a non-function", () => { + expect(isChokepointRoutedHandler(() => {})).toBe(false); + expect(isChokepointRoutedHandler("nope")).toBe(false); + }); + + it("registers a wrapped handler and looks it up", () => { + const handler = wrapped(); + registerChatAction("demo.action", { paramsValidator: validator, handler }); + expect(listChatActionNames()).toEqual(["demo.action"]); + expect(getChatAction("demo.action")).toMatchObject({ + paramsValidator: validator, + handler, + }); + }); + + it("rejects a raw (unwrapped) handler at registration time, not silently", () => { + expect(() => + registerChatAction("bad", { + paramsValidator: validator, + handler: (async () => ({})) as never, + }), + ).toThrow(/createChokepointRoutedHandler/); + expect(listChatActionNames()).toEqual([]); + }); + + it("rejects an empty name, a missing paramsValidator, and a duplicate registration", () => { + expect(() => + registerChatAction("", { + paramsValidator: validator, + handler: wrapped(), + }), + ).toThrow(/non-empty/); + expect(() => + registerChatAction("x", { handler: wrapped() } as never), + ).toThrow(/paramsValidator/); + registerChatAction("dup", { + paramsValidator: validator, + handler: wrapped(), + }); + expect(() => + registerChatAction("dup", { + paramsValidator: validator, + handler: wrapped(), + }), + ).toThrow(/already registered/); + }); + + it("listChatActionNames returns a sorted view", () => { + registerChatAction("z.action", { + paramsValidator: validator, + handler: wrapped(), + }); + registerChatAction("a.action", { + paramsValidator: validator, + handler: wrapped(), + }); + expect(listChatActionNames()).toEqual(["a.action", "z.action"]); + }); +});