Files
TencentDB-Agent-Memory/src/utils/ensure-hook-policy.ts
T

255 lines
8.9 KiB
TypeScript

/**
* ensure-hook-policy.ts
*
* Auto-patches openclaw.json to add `hooks.allowConversationAccess: true`
* for our plugin. Without it, the gateway silently blocks agent_end hooks
* for non-bundled plugins (v2026.4.23+, PR #70786).
*/
import fs from "node:fs";
import path from "node:path";
import { getEnv } from "./env.js";
import JSON5 from "json5";
const PLUGIN_ID = "memory-tencentdb";
/**
* Minimum host version at which `hooks.allowConversationAccess` is both
* recognised by the schema and enforced. See header comment.
*/
export const HOOK_POLICY_MIN_VERSION: readonly [number, number, number] = [
2026, 4, 24,
];
/**
* Parse the leading `x.y.z` numeric prefix from a version string.
*
* Accepts:
* "2026.4.24" -> [2026, 4, 24]
* "2026.4.24-beta.1" -> [2026, 4, 24]
* "2026.5.3-1" -> [2026, 5, 3]
* "2026.4.24.4" -> [2026, 4, 24] (extra segments ignored)
*
* Rejects (returns null):
* - Non-string values (undefined / null / number / etc.)
* - "unknown" / "" (no clean numeric prefix)
* - "2026.4" (must have all three segments)
* - "v2026.4.24" (no leading non-digit allowed — keep strict)
*/
export function parseVersionXYZ(v: unknown): [number, number, number] | null {
if (typeof v !== "string") {
return null;
}
const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:[-.].*)?$/);
if (!m) {
return null;
}
const [, a, b, c] = m;
return [Number(a), Number(b), Number(c)];
}
/**
* Compare two `[x, y, z]` tuples. Returns negative / 0 / positive like a
* standard comparator (a - b).
*/
export function compareVersionXYZ(
a: readonly [number, number, number],
b: readonly [number, number, number],
): number {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
}
/**
* Structured outcome of the hook-policy version gate.
*
* Exposed so callers (e.g. index.ts) can log exactly what was compared
* (`original` raw input, parsed `x.y.z`, and the `min` threshold) without
* having to re-implement the parse step themselves.
*/
export interface HookPolicyDecision {
/** Whether the auto-patch should be applied. */
apply: boolean;
/** The raw value passed in (useful for logging verbatim). */
rawVersion: unknown;
/** Parsed `[x, y, z]`, or `null` if the input was unparsable. */
parsedXYZ: [number, number, number] | null;
/** The minimum version threshold the decision was made against. */
minXYZ: readonly [number, number, number];
}
/**
* Decide whether we should apply the `allowConversationAccess` auto-patch
* for the given host version, returning a structured result that callers
* can log verbatim.
*
* Policy:
* - Extract the leading `x.y.z` prefix from `rawVersion` (ignoring any
* pre-release suffix like `-beta.N`, `-1`, `-alpha.N`, etc.).
* - If the prefix is >= {@link HOOK_POLICY_MIN_VERSION}, `apply = true`.
* - If the prefix cannot be parsed (unknown / empty / non-string /
* undefined — typical on hosts that don't expose `api.runtime.version`),
* `apply = false`. This is the safe default: old hosts don't have the
* gate and don't need patching.
*
* NOTE: Very early pre-releases of the MIN version itself (e.g.
* `2026.4.24-beta.1`) will satisfy the predicate. This is intentional —
* the field was already recognised in those builds and the usage base is
* negligible.
*/
export function decideHookPolicy(rawVersion: unknown): HookPolicyDecision {
const parsedXYZ = parseVersionXYZ(rawVersion);
const apply =
parsedXYZ !== null &&
compareVersionXYZ(parsedXYZ, HOOK_POLICY_MIN_VERSION) >= 0;
return {
apply,
rawVersion,
parsedXYZ,
minXYZ: HOOK_POLICY_MIN_VERSION,
};
}
/**
* Thin boolean wrapper around {@link decideHookPolicy} for callers that
* only need the yes/no answer.
*/
export function shouldApplyHookPolicy(rawVersion: unknown): boolean {
return decideHookPolicy(rawVersion).apply;
}
interface Logger {
info: (msg: string) => void;
warn: (msg: string) => void;
debug?: (msg: string) => void;
}
function isObj(v: unknown): v is Record<string, unknown> {
return v != null && typeof v === "object" && !Array.isArray(v);
}
function isGatewayStart(): boolean {
const args = process.argv.map((v) => String(v || "").toLowerCase());
const idx = args.findIndex((a) =>
a.endsWith("openclaw") || a.endsWith("openclaw.mjs") || a.endsWith("entry.js"),
);
if (idx < 0) return true;
const cmd = args.slice(idx + 1).filter((a) => !a.startsWith("-"))[0];
if (!cmd) return true;
const skip = ["plugins", "plugin", "install", "uninstall", "update", "doctor", "security", "config", "onboard", "setup", "status", "version", "help"];
return !skip.includes(cmd);
}
function resolveConfigPath(): string | null {
// 1. OPENCLAW_CONFIG_PATH env override (same as core uses)
const envPath = getEnv("OPENCLAW_CONFIG_PATH")?.trim();
if (envPath && fs.existsSync(envPath)) return envPath;
// 2. OPENCLAW_STATE_DIR override
const stateDir = getEnv("OPENCLAW_STATE_DIR")?.trim();
if (stateDir) {
const p = path.join(stateDir, "openclaw.json");
if (fs.existsSync(p)) return p;
}
// 3. Standard location: ~/.openclaw/openclaw.json
const home = getEnv("HOME") ?? getEnv("USERPROFILE") ?? "";
if (!home) return null;
const p = path.join(home, ".openclaw", "openclaw.json");
return fs.existsSync(p) ? p : null;
}
function hasPolicyAlready(root: unknown): boolean {
if (!isObj(root)) return false;
const entry = (root as any)?.plugins?.entries?.[PLUGIN_ID];
return isObj(entry) && isObj(entry.hooks) && entry.hooks.allowConversationAccess === true;
}
/**
* Call early in register(). Patches config if missing, triggers restart.
*
* Strategy:
* 1. Try SDK mutateConfigFile (handles path resolution, $include, atomic write,
* and triggers gateway restart via afterWrite).
* 2. Fallback to manual file write if SDK is unavailable or fails.
*/
export function ensurePluginHookPolicy(params: {
rootConfig?: unknown;
runtimeConfig?: {
mutateConfigFile?: (p: any) => Promise<any>;
};
logger: Logger;
}): void {
const { logger } = params;
const TAG = "[memory-tdai] [hook-policy]";
if (!isGatewayStart()) return;
if (hasPolicyAlready(params.rootConfig)) return;
// Try SDK path first (handles everything + triggers restart)
if (params.runtimeConfig?.mutateConfigFile) {
logger.info(`${TAG} Missing allowConversationAccess, patching via SDK...`);
params.runtimeConfig.mutateConfigFile({
afterWrite: { mode: "restart", reason: "memory-tencentdb hook policy auto-patch" },
mutate: (draft: any) => {
if (!draft.plugins) draft.plugins = {};
if (!draft.plugins.entries) draft.plugins.entries = {};
if (!draft.plugins.entries[PLUGIN_ID]) draft.plugins.entries[PLUGIN_ID] = {};
if (!draft.plugins.entries[PLUGIN_ID].hooks) draft.plugins.entries[PLUGIN_ID].hooks = {};
draft.plugins.entries[PLUGIN_ID].hooks.allowConversationAccess = true;
},
}).then(() => {
logger.info(`${TAG} ✅ Patched via SDK — gateway will restart automatically.`);
}).catch((err: unknown) => {
logger.warn(`${TAG} SDK mutateConfigFile failed: ${err instanceof Error ? err.message : String(err)}, trying manual fallback...`);
manualPatch(logger);
});
return;
}
// Fallback: manual file write
manualPatch(logger);
}
function manualPatch(logger: Logger): void {
const TAG = "[memory-tdai] [hook-policy]";
const configPath = resolveConfigPath();
if (!configPath) {
logger.warn(`${TAG} Cannot locate openclaw.json — please add hooks.allowConversationAccess manually`);
return;
}
let parsed: Record<string, unknown>;
try {
const raw = fs.readFileSync(configPath, "utf-8");
parsed = JSON5.parse(raw);
} catch {
logger.warn(`${TAG} Failed to parse ${configPath} — please add hooks.allowConversationAccess manually`);
return;
}
if (hasPolicyAlready(parsed)) return;
if ("$include" in parsed || (isObj(parsed.plugins) && "$include" in parsed.plugins)) {
logger.warn(`${TAG} Config uses $include — please add manually: plugins.entries.${PLUGIN_ID}.hooks.allowConversationAccess = true`);
return;
}
if (!isObj(parsed.plugins)) parsed.plugins = {};
const plugins = parsed.plugins as Record<string, unknown>;
if (!isObj(plugins.entries)) plugins.entries = {};
const entries = plugins.entries as Record<string, unknown>;
if (!isObj(entries[PLUGIN_ID])) entries[PLUGIN_ID] = {};
const entry = entries[PLUGIN_ID] as Record<string, unknown>;
if (!isObj(entry.hooks)) entry.hooks = {};
(entry.hooks as Record<string, unknown>).allowConversationAccess = true;
try {
fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2) + "\n");
logger.info(`${TAG} ✅ Auto-added hooks.allowConversationAccess to ${configPath}`);
logger.warn(`${TAG} ⚠️ Gateway restart required. Run: openclaw gateway restart`);
} catch (err) {
logger.warn(`${TAG} Failed to write ${configPath}: ${err instanceof Error ? err.message : String(err)}. Add manually.`);
}
}