mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 20:34:30 +00:00
255 lines
8.9 KiB
TypeScript
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.`);
|
|
}
|
|
}
|