fix(setup): record the session-start hook as a search-path name (task 0043)
All checks were successful
CI / test (22) (pull_request) Successful in 47s
CI / test (true, 24) (pull_request) Successful in 1m6s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 49s
CI / test (true, 24) (push) Successful in 1m5s
CI / flake (push) Successful in 2s
All checks were successful
CI / test (22) (pull_request) Successful in 47s
CI / test (true, 24) (pull_request) Successful in 1m6s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 49s
CI / test (true, 24) (push) Successful in 1m5s
CI / flake (push) Successful in 2s
The SDK records a bare, upgrade-stable binary name only when a PATH entry realpath-matches the execPath it is handed. From the module-relative entrypoint that can only succeed under npm, which symlinks its bin entry straight at it; a wrapper-based install never can, because a script that invokes a file does not resolve to that file. So every wrapper install -- Nix, a shim, a generated .cmd -- recorded an absolute path that moves on upgrade, and a session-start hook that cannot execute fails silently. `setup hooks` now resolves gitea-axi on PATH itself and hands that location to the SDK, so the bare name is recorded. A candidate qualifies only if it resolves to the running entrypoint -- by realpath for a symlink, or by naming it in its text for a wrapper, following the chain, since a Nix install is two hops. A same-named binary that is some other program does not qualify, and the absolute entrypoint path stands as the fallback exactly as before. The related defect on the same line goes too: the SDK recognises its hook by finding the marker inside the recorded command, so an entrypoint path without "gitea-axi" in it made a re-run append a duplicate rather than update in place. `setup hooks` now prunes duplicates by matching the exact command it records, which is independent of that command's shape and cannot mistake another tool's hook for its own. With the coupling gone, package.nix no longer renames its build tree; the fast tier runs from /build/source and its idempotency test passes there. The help text's instruction to re-run hooks after an upgrade is deleted, having become false. Verified against the built Nix binary and a globally npm-installed pack: both record the bare name, both fall back to the absolute path when the name is absent, an impostor on PATH is refused, and re-runs leave one entry. Decision recorded as ADR 0019; ADR 0009's addendum is amended.
This commit was merged in pull request #52.
This commit is contained in:
263
test/hooks.test.ts
Normal file
263
test/hooks.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { delimiter } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type PathProbe,
|
||||
pruneDuplicateManagedHooks,
|
||||
resolveEntrypointOnPath,
|
||||
} from "../src/hooks.js";
|
||||
|
||||
describe("resolveEntrypointOnPath", () => {
|
||||
const ENTRYPOINT = "/opt/gitea-axi/dist/main.js";
|
||||
|
||||
/** A probe over a fake filesystem: real paths, plus text for wrapper scripts. */
|
||||
const probeOver = (
|
||||
realPaths: Record<string, string>,
|
||||
texts: Record<string, string> = {},
|
||||
): PathProbe => ({
|
||||
realPath: (candidate) => realPaths[candidate],
|
||||
readText: (candidate) => texts[candidate],
|
||||
});
|
||||
|
||||
const path = ["/empty", "/usr/local/bin", "/usr/bin"].join(delimiter);
|
||||
|
||||
it("matches a symlink to the entrypoint by real path — npm's install shape", () => {
|
||||
const probe = probeOver({
|
||||
[ENTRYPOINT]: ENTRYPOINT,
|
||||
"/usr/local/bin/gitea-axi": ENTRYPOINT,
|
||||
});
|
||||
|
||||
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
|
||||
"/usr/local/bin/gitea-axi",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches a wrapper naming the entrypoint in its text — the Nix install shape", () => {
|
||||
// The wrapper's own realpath is the wrapper, never the entrypoint, which is
|
||||
// precisely why the realpath test alone cannot see this install.
|
||||
const probe = probeOver(
|
||||
{
|
||||
[ENTRYPOINT]: ENTRYPOINT,
|
||||
"/usr/local/bin/gitea-axi": "/usr/local/bin/gitea-axi",
|
||||
},
|
||||
{ "/usr/local/bin/gitea-axi": `#!/bin/sh\nexec node ${ENTRYPOINT} "$@"\n` },
|
||||
);
|
||||
|
||||
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
|
||||
"/usr/local/bin/gitea-axi",
|
||||
);
|
||||
});
|
||||
|
||||
it("follows a chained wrapper to the entrypoint — the real Nix shape", () => {
|
||||
// Nix is two hops: bin/gitea-axi sets PATH and execs bin/.gitea-axi-wrapped,
|
||||
// and only that second script names the entrypoint.
|
||||
const wrapped = "/usr/local/bin/.gitea-axi-wrapped";
|
||||
const probe = probeOver(
|
||||
{
|
||||
[ENTRYPOINT]: ENTRYPOINT,
|
||||
"/usr/local/bin/gitea-axi": "/usr/local/bin/gitea-axi",
|
||||
[wrapped]: wrapped,
|
||||
},
|
||||
{
|
||||
"/usr/local/bin/gitea-axi": `#!/bin/bash -e\nexport PATH\nexec -a "$0" "${wrapped}" "$@"\n`,
|
||||
[wrapped]: `#!/bin/bash -e\nexec "/usr/bin/node" ${ENTRYPOINT} "$@"\n`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
|
||||
"/usr/local/bin/gitea-axi",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives up on a wrapper chain that never names the entrypoint", () => {
|
||||
// A cycle: it must terminate rather than following the loop forever.
|
||||
const other = "/usr/local/bin/other";
|
||||
const probe = probeOver(
|
||||
{
|
||||
[ENTRYPOINT]: ENTRYPOINT,
|
||||
"/usr/local/bin/gitea-axi": "/usr/local/bin/gitea-axi",
|
||||
[other]: other,
|
||||
},
|
||||
{
|
||||
"/usr/local/bin/gitea-axi": `exec "${other}"\n`,
|
||||
[other]: `exec "/usr/local/bin/gitea-axi"\n`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a same-named binary that is some other program", () => {
|
||||
// Accepting this would hand the SDK a path that realpath-matches itself,
|
||||
// making its check a tautology and recording a name for a program the
|
||||
// caller never asked about.
|
||||
const probe = probeOver(
|
||||
{
|
||||
[ENTRYPOINT]: ENTRYPOINT,
|
||||
"/usr/local/bin/gitea-axi": "/somewhere/else/gitea-axi",
|
||||
},
|
||||
{ "/usr/local/bin/gitea-axi": "#!/bin/sh\nexec node /somewhere/else/dist/main.js\n" },
|
||||
);
|
||||
|
||||
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the name resolves nowhere on PATH", () => {
|
||||
const probe = probeOver({ [ENTRYPOINT]: ENTRYPOINT });
|
||||
|
||||
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an unset or empty PATH", () => {
|
||||
const probe = probeOver({ [ENTRYPOINT]: ENTRYPOINT });
|
||||
|
||||
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, undefined, probe)).toBeUndefined();
|
||||
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, "", probe)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips empty PATH entries rather than probing the working directory", () => {
|
||||
const probed: string[] = [];
|
||||
resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, ["", "/usr/bin", ""].join(delimiter), {
|
||||
realPath: (candidate) => {
|
||||
probed.push(candidate);
|
||||
return undefined;
|
||||
},
|
||||
readText: () => undefined,
|
||||
});
|
||||
|
||||
expect(probed).toEqual([ENTRYPOINT, "/usr/bin/gitea-axi"]);
|
||||
});
|
||||
|
||||
it("takes the first PATH entry that matches, not a later one", () => {
|
||||
const probe = probeOver({
|
||||
[ENTRYPOINT]: ENTRYPOINT,
|
||||
"/usr/local/bin/gitea-axi": ENTRYPOINT,
|
||||
"/usr/bin/gitea-axi": ENTRYPOINT,
|
||||
});
|
||||
|
||||
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
|
||||
"/usr/local/bin/gitea-axi",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneDuplicateManagedHooks", () => {
|
||||
const settingsWith = (...commands: string[]) => ({
|
||||
hooks: {
|
||||
SessionStart: commands.map((command) => ({
|
||||
matcher: "",
|
||||
hooks: [{ type: "command", command, timeout: 10 }],
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
interface ReadBack {
|
||||
hooks: { SessionStart: { hooks: { command: string; timeout?: number }[] }[] };
|
||||
}
|
||||
|
||||
const commandsOf = (settings: unknown) =>
|
||||
(settings as ReadBack).hooks.SessionStart.flatMap((group) =>
|
||||
group.hooks.map((hook) => hook.command),
|
||||
);
|
||||
|
||||
it("collapses a duplicated entry whose command does not contain the marker", () => {
|
||||
// The case the SDK cannot handle: it recognises its own hook by finding the
|
||||
// marker inside the recorded command, so an entrypoint path without
|
||||
// "gitea-axi" in it makes a re-run append rather than update.
|
||||
const entrypoint = "/build/source/dist/main.js";
|
||||
const result = pruneDuplicateManagedHooks(
|
||||
settingsWith(entrypoint, entrypoint),
|
||||
(command) => command === entrypoint,
|
||||
);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(commandsOf(result.settings)).toEqual([entrypoint]);
|
||||
});
|
||||
|
||||
it("keeps the last managed entry, which is the one the SDK just appended", () => {
|
||||
// Both entries carry the same command, so the survivor is identified by the
|
||||
// rest of its shape: the stale one has a timeout the SDK no longer writes.
|
||||
const result = pruneDuplicateManagedHooks(
|
||||
{
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{ matcher: "", hooks: [{ type: "command", command: "gitea-axi", timeout: 99 }] },
|
||||
{ matcher: "", hooks: [{ type: "command", command: "gitea-axi", timeout: 10 }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
(command) => command === "gitea-axi",
|
||||
);
|
||||
|
||||
const settings = result.settings as ReadBack;
|
||||
expect(settings.hooks.SessionStart).toHaveLength(1);
|
||||
expect(settings.hooks.SessionStart[0]?.hooks[0]).toMatchObject({ timeout: 10 });
|
||||
});
|
||||
|
||||
it("leaves hooks belonging to other tools untouched", () => {
|
||||
const result = pruneDuplicateManagedHooks(
|
||||
settingsWith("other-tool", "gitea-axi", "another-tool", "gitea-axi"),
|
||||
(command) => command === "gitea-axi",
|
||||
);
|
||||
|
||||
expect(commandsOf(result.settings)).toEqual(["other-tool", "another-tool", "gitea-axi"]);
|
||||
});
|
||||
|
||||
it("prunes duplicates that share a single group", () => {
|
||||
const result = pruneDuplicateManagedHooks(
|
||||
{
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: "",
|
||||
hooks: [
|
||||
{ type: "command", command: "gitea-axi", timeout: 10 },
|
||||
{ type: "command", command: "other-tool", timeout: 10 },
|
||||
{ type: "command", command: "gitea-axi", timeout: 10 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
(command) => command === "gitea-axi",
|
||||
);
|
||||
|
||||
expect(commandsOf(result.settings)).toEqual(["other-tool", "gitea-axi"]);
|
||||
});
|
||||
|
||||
it("reports no change and returns the input when there is nothing to prune", () => {
|
||||
const single = settingsWith("gitea-axi");
|
||||
const result = pruneDuplicateManagedHooks(single, (command) => command === "gitea-axi");
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.settings).toBe(single);
|
||||
});
|
||||
|
||||
it("preserves sibling settings keys and other hook events", () => {
|
||||
const result = pruneDuplicateManagedHooks(
|
||||
{
|
||||
model: "opus",
|
||||
hooks: {
|
||||
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "audit" }] }],
|
||||
SessionStart: settingsWith("gitea-axi", "gitea-axi").hooks.SessionStart,
|
||||
},
|
||||
},
|
||||
(command) => command === "gitea-axi",
|
||||
);
|
||||
|
||||
const settings = result.settings as ReadBack & {
|
||||
model: string;
|
||||
hooks: { PreToolUse: unknown[] };
|
||||
};
|
||||
expect(settings.model).toBe("opus");
|
||||
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
||||
expect(commandsOf(settings)).toEqual(["gitea-axi"]);
|
||||
});
|
||||
|
||||
it("tolerates settings with no SessionStart hooks at all", () => {
|
||||
for (const input of [{}, { hooks: {} }, { hooks: { SessionStart: "nonsense" } }, null]) {
|
||||
const result = pruneDuplicateManagedHooks(input, () => true);
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.settings).toBe(input);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user