* fix(server): no-op agent hooks when PASEO_TERMINAL_ID is unset
The shell command generated by `buildAgentHookShellCommand` short-circuited
on `[ -n "$PASEO_TERMINAL_ID" ] && ...` and exited 1 when the terminal
ID was absent, so Claude Code reported every Notification/Stop/StopFailure/
SessionEnd/UserPromptSubmit event as `Failed with non-blocking status code`.
The same shape affected the Codex provider.
Switch the gate to `if [ -n ... ]; then ...; fi` so the hook is a true
no-op when the terminal id is unset and preserves the underlying CLI's
exit code when it actually runs. Mirrors `opencode-plugin.ts`, which
already early-returns on the same env var. Adds the matching
`else (exit /b 0)` branch to `buildAgentHookWindowsCommand`.
Updates the literal-string assertions in the existing Claude and Codex
hook tests, and adds an executable regression that runs the generated
shell command through `/bin/sh` and asserts exit 0 across all five
Claude hook events.
* test(server): skip POSIX hook regression on Windows
The /bin/sh exit-code regression spawns /bin/sh -c <posix command>,
which has no equivalent on the Windows runner, so spawnSync returned
status: null and the five Claude events failed in CI.
Gate the it.each block with skipIf(isPlatform("win32")), matching the
pattern already used in worker-terminal-manager.test.ts and
executable-resolution.test.ts. The remaining literal-string and
buildTerminalEnvironment assertions in this file keep running on
every platform.
209 lines
6.8 KiB
TypeScript
209 lines
6.8 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { isPlatform } from "../../../test-utils/platform.js";
|
|
import { buildTerminalEnvironment } from "../../terminal.js";
|
|
import { buildAgentHookShellCommand } from "../agent-hook-installer.js";
|
|
import {
|
|
AGENT_HOOK_PROVIDERS,
|
|
installRegisteredAgentHooks,
|
|
registeredAgentHooksAreInstalled,
|
|
uninstallRegisteredAgentHooks,
|
|
} from "../provider-registry.js";
|
|
|
|
const temporaryDirs: string[] = [];
|
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../../..");
|
|
|
|
afterEach(() => {
|
|
while (temporaryDirs.length > 0) {
|
|
const dir = temporaryDirs.pop();
|
|
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function createTempDir(prefix: string): string {
|
|
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
temporaryDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function createFakeCliBinDir(): string {
|
|
const dir = createTempDir("paseo-cli-bin-");
|
|
writeFileSync(join(dir, "paseo"), "");
|
|
return dir;
|
|
}
|
|
|
|
interface TestClaudeSettings {
|
|
hooks?: Record<string, unknown>;
|
|
theme?: string;
|
|
}
|
|
|
|
function readSettings(configDir: string): TestClaudeSettings {
|
|
return JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8")) as {
|
|
hooks?: Record<string, unknown>;
|
|
theme?: string;
|
|
};
|
|
}
|
|
|
|
function hookCommands(settings: { hooks?: Record<string, unknown> }, event: string): string[] {
|
|
const entries = settings.hooks?.[event];
|
|
if (!Array.isArray(entries)) {
|
|
return [];
|
|
}
|
|
return entries.flatMap((entry) => {
|
|
if (!isRecord(entry) || !Array.isArray(entry.hooks)) {
|
|
return [];
|
|
}
|
|
return entry.hooks
|
|
.map((hook) => (isRecord(hook) ? hook.command : undefined))
|
|
.filter((command): command is string => typeof command === "string");
|
|
});
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
describe("Claude terminal agent hooks", () => {
|
|
it("installs registered provider hooks idempotently", () => {
|
|
const configDir = createTempDir("paseo-claude-config-");
|
|
const provider = AGENT_HOOK_PROVIDERS.claude;
|
|
const install = provider.install;
|
|
|
|
installRegisteredAgentHooks({ configDir });
|
|
installRegisteredAgentHooks({ configDir });
|
|
|
|
const settings = readSettings(configDir);
|
|
for (const event of provider.events) {
|
|
const paseoCommands = hookCommands(settings, event.event).filter((command) =>
|
|
command.includes(install.hookMarker),
|
|
);
|
|
expect(paseoCommands).toHaveLength(1);
|
|
expect(paseoCommands[0]).toBe(
|
|
`if [ -n "$PASEO_TERMINAL_ID" ]; then "\${PASEO_HOOK_CLI:-paseo}" hooks ${provider.id} ${event.event}; fi`,
|
|
);
|
|
}
|
|
expect(registeredAgentHooksAreInstalled({ configDir })).toBe(true);
|
|
});
|
|
|
|
it("preserves unrelated user hooks", () => {
|
|
const configDir = createTempDir("paseo-claude-config-preserve-");
|
|
writeFileSync(
|
|
join(configDir, "settings.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
theme: "dark",
|
|
hooks: {
|
|
Stop: [
|
|
{
|
|
matcher: "",
|
|
hooks: [{ type: "command", command: "say done", timeout: 5 }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
|
|
installRegisteredAgentHooks({ configDir });
|
|
|
|
const settings = readSettings(configDir);
|
|
expect(settings.theme).toBe("dark");
|
|
expect(hookCommands(settings, "Stop")).toContain("say done");
|
|
expect(
|
|
hookCommands(settings, "Stop").some((command) => command.includes("hooks claude Stop")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("uninstalls only marker-matched hooks", () => {
|
|
const configDir = createTempDir("paseo-claude-config-uninstall-");
|
|
installRegisteredAgentHooks({ configDir });
|
|
const settings = readSettings(configDir);
|
|
settings.hooks = {
|
|
...settings.hooks,
|
|
Stop: [
|
|
...(Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : []),
|
|
{
|
|
matcher: "",
|
|
hooks: [{ type: "command", command: "say still-here", timeout: 5 }],
|
|
},
|
|
],
|
|
};
|
|
writeFileSync(join(configDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
|
|
|
|
uninstallRegisteredAgentHooks({ configDir });
|
|
|
|
const nextSettings = readSettings(configDir);
|
|
expect(hookCommands(nextSettings, "Stop")).toEqual(["say still-here"]);
|
|
expect(registeredAgentHooksAreInstalled({ configDir })).toBe(false);
|
|
});
|
|
|
|
it("builds a minimal gated hook command", () => {
|
|
const provider = AGENT_HOOK_PROVIDERS.claude;
|
|
const command = buildAgentHookShellCommand(provider, provider.events[0]);
|
|
|
|
expect(command).toBe(
|
|
'if [ -n "$PASEO_TERMINAL_ID" ]; then "${PASEO_HOOK_CLI:-paseo}" hooks claude UserPromptSubmit; fi',
|
|
);
|
|
});
|
|
|
|
it.skipIf(isPlatform("win32")).each(AGENT_HOOK_PROVIDERS.claude.events)(
|
|
"$event hook command exits 0 when PASEO_TERMINAL_ID is unset",
|
|
(event) => {
|
|
const provider = AGENT_HOOK_PROVIDERS.claude;
|
|
const command = buildAgentHookShellCommand(provider, event);
|
|
|
|
const result = spawnSync("/bin/sh", ["-c", command], {
|
|
env: { PATH: process.env.PATH ?? "", PASEO_HOOK_CLI: "paseo" },
|
|
stdio: "ignore",
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("keeps provider names out of generic CLI and bootstrap integration points", () => {
|
|
const genericFiles = [
|
|
join(repositoryRoot, "packages", "cli", "src", "commands", "hooks.ts"),
|
|
join(repositoryRoot, "packages", "server", "src", "server", "bootstrap.ts"),
|
|
];
|
|
|
|
for (const filePath of genericFiles) {
|
|
const contents = readFileSync(filePath, "utf8").toLowerCase();
|
|
for (const providerId of Object.keys(AGENT_HOOK_PROVIDERS)) {
|
|
expect(contents).not.toContain(providerId);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("prepends the paseo CLI directory and injects the hook CLI path", () => {
|
|
const cliBinDir = createFakeCliBinDir();
|
|
const hookCliPath = join(cliBinDir, "paseo");
|
|
|
|
const env = buildTerminalEnvironment({
|
|
shell: "/bin/sh",
|
|
env: { PATH: ["/usr/bin", "/bin"].join(delimiter) },
|
|
paseoCliBinDir: cliBinDir,
|
|
paseoHookCliPath: hookCliPath,
|
|
});
|
|
|
|
expect(env.PATH?.split(delimiter)).toEqual([cliBinDir, "/usr/bin", "/bin"]);
|
|
expect(env.PASEO_HOOK_CLI).toBe(hookCliPath);
|
|
});
|
|
|
|
it("leaves terminal PATH unchanged when the CLI directory cannot be resolved", () => {
|
|
const env = buildTerminalEnvironment({
|
|
shell: "/bin/sh",
|
|
env: { PATH: ["/usr/bin", "/bin"].join(delimiter) },
|
|
paseoCliBinDir: null,
|
|
});
|
|
|
|
expect(env.PATH?.split(delimiter)).toEqual(["/usr/bin", "/bin"]);
|
|
});
|
|
});
|