refactor(hooks): dedupe install parameter wiring

This commit is contained in:
Peter Steinberger
2026-03-03 01:05:17 +00:00
parent 1bd20dbdb6
commit c3d5159121
2 changed files with 177 additions and 143 deletions

View File

@@ -87,6 +87,43 @@ function expectInstallFailureContains(
} }
} }
function writeHookPackManifest(params: {
pkgDir: string;
hooks: string[];
dependencies?: Record<string, string>;
}) {
fs.writeFileSync(
path.join(params.pkgDir, "package.json"),
JSON.stringify({
name: "@openclaw/test-hooks",
version: "0.0.1",
openclaw: { hooks: params.hooks },
...(params.dependencies ? { dependencies: params.dependencies } : {}),
}),
"utf-8",
);
}
async function installArchiveFixture(params: { fileName: string; contents: Buffer }) {
const fixture = writeArchiveFixture(params);
const result = await installHooksFromArchive({
archivePath: fixture.archivePath,
hooksDir: fixture.hooksDir,
});
return { fixture, result };
}
function expectPathInstallFailureContains(
result: Awaited<ReturnType<typeof installHooksFromPath>>,
snippet: string,
) {
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected install failure");
}
expect(result.error).toContain(snippet);
}
describe("installHooksFromArchive", () => { describe("installHooksFromArchive", () => {
it.each([ it.each([
{ {
@@ -104,10 +141,9 @@ describe("installHooksFromArchive", () => {
expectedHook: "tar-hook", expectedHook: "tar-hook",
}, },
])("installs hook packs from $name archives", async (tc) => { ])("installs hook packs from $name archives", async (tc) => {
const fixture = writeArchiveFixture({ fileName: tc.fileName, contents: tc.contents }); const { fixture, result } = await installArchiveFixture({
const result = await installHooksFromArchive({ fileName: tc.fileName,
archivePath: fixture.archivePath, contents: tc.contents,
hooksDir: fixture.hooksDir,
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
@@ -136,10 +172,9 @@ describe("installHooksFromArchive", () => {
expectedDetail: "escapes destination", expectedDetail: "escapes destination",
}, },
])("rejects $name archives with traversal entries", async (tc) => { ])("rejects $name archives with traversal entries", async (tc) => {
const fixture = writeArchiveFixture({ fileName: tc.fileName, contents: tc.contents }); const { result } = await installArchiveFixture({
const result = await installHooksFromArchive({ fileName: tc.fileName,
archivePath: fixture.archivePath, contents: tc.contents,
hooksDir: fixture.hooksDir,
}); });
expectInstallFailureContains(result, ["failed to extract archive", tc.expectedDetail]); expectInstallFailureContains(result, ["failed to extract archive", tc.expectedDetail]);
}); });
@@ -154,10 +189,9 @@ describe("installHooksFromArchive", () => {
contents: tarReservedIdBuffer, contents: tarReservedIdBuffer,
}, },
])("rejects hook packs with $name", async (tc) => { ])("rejects hook packs with $name", async (tc) => {
const fixture = writeArchiveFixture({ fileName: "hooks.tar", contents: tc.contents }); const { result } = await installArchiveFixture({
const result = await installHooksFromArchive({ fileName: "hooks.tar",
archivePath: fixture.archivePath, contents: tc.contents,
hooksDir: fixture.hooksDir,
}); });
expectInstallFailureContains(result, ["reserved path segment"]); expectInstallFailureContains(result, ["reserved path segment"]);
}); });
@@ -169,16 +203,11 @@ describe("installHooksFromPath", () => {
const stateDir = makeTempDir(); const stateDir = makeTempDir();
const pkgDir = path.join(workDir, "package"); const pkgDir = path.join(workDir, "package");
fs.mkdirSync(path.join(pkgDir, "hooks", "one-hook"), { recursive: true }); fs.mkdirSync(path.join(pkgDir, "hooks", "one-hook"), { recursive: true });
fs.writeFileSync( writeHookPackManifest({
path.join(pkgDir, "package.json"), pkgDir,
JSON.stringify({ hooks: ["./hooks/one-hook"],
name: "@openclaw/test-hooks", dependencies: { "left-pad": "1.3.0" },
version: "0.0.1", });
openclaw: { hooks: ["./hooks/one-hook"] },
dependencies: { "left-pad": "1.3.0" },
}),
"utf-8",
);
fs.writeFileSync( fs.writeFileSync(
path.join(pkgDir, "hooks", "one-hook", "HOOK.md"), path.join(pkgDir, "hooks", "one-hook", "HOOK.md"),
[ [
@@ -249,15 +278,10 @@ describe("installHooksFromPath", () => {
const outsideHookDir = path.join(workDir, "outside"); const outsideHookDir = path.join(workDir, "outside");
fs.mkdirSync(pkgDir, { recursive: true }); fs.mkdirSync(pkgDir, { recursive: true });
fs.mkdirSync(outsideHookDir, { recursive: true }); fs.mkdirSync(outsideHookDir, { recursive: true });
fs.writeFileSync( writeHookPackManifest({
path.join(pkgDir, "package.json"), pkgDir,
JSON.stringify({ hooks: ["../outside"],
name: "@openclaw/test-hooks", });
version: "0.0.1",
openclaw: { hooks: ["../outside"] },
}),
"utf-8",
);
fs.writeFileSync(path.join(outsideHookDir, "HOOK.md"), "---\nname: outside\n---\n", "utf-8"); fs.writeFileSync(path.join(outsideHookDir, "HOOK.md"), "---\nname: outside\n---\n", "utf-8");
fs.writeFileSync(path.join(outsideHookDir, "handler.ts"), "export default async () => {};\n"); fs.writeFileSync(path.join(outsideHookDir, "handler.ts"), "export default async () => {};\n");
@@ -266,11 +290,7 @@ describe("installHooksFromPath", () => {
hooksDir: path.join(stateDir, "hooks"), hooksDir: path.join(stateDir, "hooks"),
}); });
expect(result.ok).toBe(false); expectPathInstallFailureContains(result, "openclaw.hooks entry escapes package directory");
if (result.ok) {
return;
}
expect(result.error).toContain("openclaw.hooks entry escapes package directory");
}); });
it("rejects hook pack entries that escape via symlink", async () => { it("rejects hook pack entries that escape via symlink", async () => {
@@ -288,26 +308,20 @@ describe("installHooksFromPath", () => {
} catch { } catch {
return; return;
} }
fs.writeFileSync( writeHookPackManifest({
path.join(pkgDir, "package.json"), pkgDir,
JSON.stringify({ hooks: ["./linked"],
name: "@openclaw/test-hooks", });
version: "0.0.1",
openclaw: { hooks: ["./linked"] },
}),
"utf-8",
);
const result = await installHooksFromPath({ const result = await installHooksFromPath({
path: pkgDir, path: pkgDir,
hooksDir: path.join(stateDir, "hooks"), hooksDir: path.join(stateDir, "hooks"),
}); });
expect(result.ok).toBe(false); expectPathInstallFailureContains(
if (result.ok) { result,
return; "openclaw.hooks entry resolves outside package directory",
} );
expect(result.error).toContain("openclaw.hooks entry resolves outside package directory");
}); });
}); });

View File

@@ -58,6 +58,30 @@ export type HookNpmIntegrityDriftParams = {
const defaultLogger: HookInstallLogger = {}; const defaultLogger: HookInstallLogger = {};
type HookInstallForwardParams = {
hooksDir?: string;
timeoutMs?: number;
logger?: HookInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedHookPackId?: string;
};
type HookPackageInstallParams = { packageDir: string } & HookInstallForwardParams;
type HookArchiveInstallParams = { archivePath: string } & HookInstallForwardParams;
type HookPathInstallParams = { path: string } & HookInstallForwardParams;
function buildHookInstallForwardParams(params: HookInstallForwardParams): HookInstallForwardParams {
return {
hooksDir: params.hooksDir,
timeoutMs: params.timeoutMs,
logger: params.logger,
mode: params.mode,
dryRun: params.dryRun,
expectedHookPackId: params.expectedHookPackId,
};
}
function validateHookId(hookId: string): string | null { function validateHookId(hookId: string): string | null {
if (!hookId) { if (!hookId) {
return "invalid hook name: missing"; return "invalid hook name: missing";
@@ -113,6 +137,54 @@ async function resolveInstallTargetDir(
}); });
} }
async function resolveAvailableHookInstallTarget(params: {
id: string;
hooksDir?: string;
mode: "install" | "update";
alreadyExistsError: (targetDir: string) => string;
}): Promise<{ ok: true; targetDir: string } | { ok: false; error: string }> {
const targetDirResult = await resolveInstallTargetDir(params.id, params.hooksDir);
if (!targetDirResult.ok) {
return targetDirResult;
}
const targetDir = targetDirResult.targetDir;
const availability = await ensureInstallTargetAvailable({
mode: params.mode,
targetDir,
alreadyExistsError: params.alreadyExistsError(targetDir),
});
if (!availability.ok) {
return availability;
}
return { ok: true, targetDir };
}
async function installFromResolvedHookDir(
resolvedDir: string,
params: HookInstallForwardParams,
): Promise<InstallHooksResult> {
const manifestPath = path.join(resolvedDir, "package.json");
if (await fileExists(manifestPath)) {
return await installHookPackageFromDir({
packageDir: resolvedDir,
hooksDir: params.hooksDir,
timeoutMs: params.timeoutMs,
logger: params.logger,
mode: params.mode,
dryRun: params.dryRun,
expectedHookPackId: params.expectedHookPackId,
});
}
return await installHookFromDir({
hookDir: resolvedDir,
hooksDir: params.hooksDir,
logger: params.logger,
mode: params.mode,
dryRun: params.dryRun,
expectedHookPackId: params.expectedHookPackId,
});
}
async function resolveHookNameFromDir(hookDir: string): Promise<string> { async function resolveHookNameFromDir(hookDir: string): Promise<string> {
const hookMdPath = path.join(hookDir, "HOOK.md"); const hookMdPath = path.join(hookDir, "HOOK.md");
if (!(await fileExists(hookMdPath))) { if (!(await fileExists(hookMdPath))) {
@@ -139,15 +211,9 @@ async function validateHookDir(hookDir: string): Promise<void> {
} }
} }
async function installHookPackageFromDir(params: { async function installHookPackageFromDir(
packageDir: string; params: HookPackageInstallParams,
hooksDir?: string; ): Promise<InstallHooksResult> {
timeoutMs?: number;
logger?: HookInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedHookPackId?: string;
}): Promise<InstallHooksResult> {
const { logger, timeoutMs, mode, dryRun } = resolveTimedInstallModeOptions(params, defaultLogger); const { logger, timeoutMs, mode, dryRun } = resolveTimedInstallModeOptions(params, defaultLogger);
const manifestPath = path.join(params.packageDir, "package.json"); const manifestPath = path.join(params.packageDir, "package.json");
@@ -182,19 +248,16 @@ async function installHookPackageFromDir(params: {
}; };
} }
const targetDirResult = await resolveInstallTargetDir(hookPackId, params.hooksDir); const target = await resolveAvailableHookInstallTarget({
if (!targetDirResult.ok) { id: hookPackId,
return { ok: false, error: targetDirResult.error }; hooksDir: params.hooksDir,
}
const targetDir = targetDirResult.targetDir;
const availability = await ensureInstallTargetAvailable({
mode, mode,
targetDir, alreadyExistsError: (targetDir) => `hook pack already exists: ${targetDir} (delete it first)`,
alreadyExistsError: `hook pack already exists: ${targetDir} (delete it first)`,
}); });
if (!availability.ok) { if (!target.ok) {
return availability; return target;
} }
const targetDir = target.targetDir;
const resolvedHooks = [] as string[]; const resolvedHooks = [] as string[];
for (const entry of hookEntries) { for (const entry of hookEntries) {
@@ -277,19 +340,16 @@ async function installHookFromDir(params: {
}; };
} }
const targetDirResult = await resolveInstallTargetDir(hookName, params.hooksDir); const target = await resolveAvailableHookInstallTarget({
if (!targetDirResult.ok) { id: hookName,
return { ok: false, error: targetDirResult.error }; hooksDir: params.hooksDir,
}
const targetDir = targetDirResult.targetDir;
const availability = await ensureInstallTargetAvailable({
mode, mode,
targetDir, alreadyExistsError: (targetDir) => `hook already exists: ${targetDir} (delete it first)`,
alreadyExistsError: `hook already exists: ${targetDir} (delete it first)`,
}); });
if (!availability.ok) { if (!target.ok) {
return availability; return target;
} }
const targetDir = target.targetDir;
if (dryRun) { if (dryRun) {
return { ok: true, hookPackId: hookName, hooks: [hookName], targetDir }; return { ok: true, hookPackId: hookName, hooks: [hookName], targetDir };
@@ -312,15 +372,9 @@ async function installHookFromDir(params: {
return { ok: true, hookPackId: hookName, hooks: [hookName], targetDir }; return { ok: true, hookPackId: hookName, hooks: [hookName], targetDir };
} }
export async function installHooksFromArchive(params: { export async function installHooksFromArchive(
archivePath: string; params: HookArchiveInstallParams,
hooksDir?: string; ): Promise<InstallHooksResult> {
timeoutMs?: number;
logger?: HookInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedHookPackId?: string;
}): Promise<InstallHooksResult> {
const logger = params.logger ?? defaultLogger; const logger = params.logger ?? defaultLogger;
const timeoutMs = params.timeoutMs ?? 120_000; const timeoutMs = params.timeoutMs ?? 120_000;
const archivePathResult = await resolveArchiveSourcePath(params.archivePath); const archivePathResult = await resolveArchiveSourcePath(params.archivePath);
@@ -334,29 +388,18 @@ export async function installHooksFromArchive(params: {
tempDirPrefix: "openclaw-hook-", tempDirPrefix: "openclaw-hook-",
timeoutMs, timeoutMs,
logger, logger,
onExtracted: async (rootDir) => { onExtracted: async (rootDir) =>
const manifestPath = path.join(rootDir, "package.json"); await installFromResolvedHookDir(
if (await fileExists(manifestPath)) { rootDir,
return await installHookPackageFromDir({ buildHookInstallForwardParams({
packageDir: rootDir,
hooksDir: params.hooksDir, hooksDir: params.hooksDir,
timeoutMs, timeoutMs,
logger, logger,
mode: params.mode, mode: params.mode,
dryRun: params.dryRun, dryRun: params.dryRun,
expectedHookPackId: params.expectedHookPackId, expectedHookPackId: params.expectedHookPackId,
}); }),
} ),
return await installHookFromDir({
hookDir: rootDir,
hooksDir: params.hooksDir,
logger,
mode: params.mode,
dryRun: params.dryRun,
expectedHookPackId: params.expectedHookPackId,
});
},
}); });
} }
@@ -386,54 +429,36 @@ export async function installHooksFromNpmSpec(params: {
logger.warn?.(message); logger.warn?.(message);
}, },
installFromArchive: installHooksFromArchive, installFromArchive: installHooksFromArchive,
archiveInstallParams: { archiveInstallParams: buildHookInstallForwardParams({
hooksDir: params.hooksDir, hooksDir: params.hooksDir,
timeoutMs, timeoutMs,
logger, logger,
mode, mode,
dryRun, dryRun,
expectedHookPackId, expectedHookPackId,
}, }),
}); });
} }
export async function installHooksFromPath(params: { export async function installHooksFromPath(
path: string; params: HookPathInstallParams,
hooksDir?: string; ): Promise<InstallHooksResult> {
timeoutMs?: number;
logger?: HookInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedHookPackId?: string;
}): Promise<InstallHooksResult> {
const pathResult = await resolveExistingInstallPath(params.path); const pathResult = await resolveExistingInstallPath(params.path);
if (!pathResult.ok) { if (!pathResult.ok) {
return pathResult; return pathResult;
} }
const { resolvedPath: resolved, stat } = pathResult; const { resolvedPath: resolved, stat } = pathResult;
const forwardParams = buildHookInstallForwardParams({
hooksDir: params.hooksDir,
timeoutMs: params.timeoutMs,
logger: params.logger,
mode: params.mode,
dryRun: params.dryRun,
expectedHookPackId: params.expectedHookPackId,
});
if (stat.isDirectory()) { if (stat.isDirectory()) {
const manifestPath = path.join(resolved, "package.json"); return await installFromResolvedHookDir(resolved, forwardParams);
if (await fileExists(manifestPath)) {
return await installHookPackageFromDir({
packageDir: resolved,
hooksDir: params.hooksDir,
timeoutMs: params.timeoutMs,
logger: params.logger,
mode: params.mode,
dryRun: params.dryRun,
expectedHookPackId: params.expectedHookPackId,
});
}
return await installHookFromDir({
hookDir: resolved,
hooksDir: params.hooksDir,
logger: params.logger,
mode: params.mode,
dryRun: params.dryRun,
expectedHookPackId: params.expectedHookPackId,
});
} }
if (!resolveArchiveKind(resolved)) { if (!resolveArchiveKind(resolved)) {
@@ -442,11 +467,6 @@ export async function installHooksFromPath(params: {
return await installHooksFromArchive({ return await installHooksFromArchive({
archivePath: resolved, archivePath: resolved,
hooksDir: params.hooksDir, ...forwardParams,
timeoutMs: params.timeoutMs,
logger: params.logger,
mode: params.mode,
dryRun: params.dryRun,
expectedHookPackId: params.expectedHookPackId,
}); });
} }