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

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:
2026-07-20 13:12:40 -04:00
parent 4e92dde4e4
commit 27aad04984
10 changed files with 750 additions and 49 deletions

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import { installSessionStartHooks } from "axi-sdk-js";
import type { CliDeps } from "../deps.js";
import { axiError } from "../errors.js";
import { pruneDuplicateManagedHooks, resolveEntrypointOnPath } from "../hooks.js";
import { renderDetail } from "../render.js";
export const SETUP_HELP = `usage: gitea-axi setup [hooks]
@@ -18,14 +19,6 @@ Install gitea-axi's ambient context for agent sessions.
Both are idempotent: re-running updates the managed files in place rather than
failing. There is no postinstall script — installation is always explicit.
Re-run "setup hooks" after upgrading gitea-axi. The hook records an absolute
path to the entrypoint, which moves when the install location changes, and a
session-start hook that cannot be executed fails silently rather than warning.
This matters most for immutable installs such as Nix, where every rebuild lands
the entrypoint at a fresh path and the old one is eventually collected. When the
path moves, the re-run may leave the stale entry behind instead of replacing it;
remove it by hand if a duplicate appears.
flags:
--help Show this help
`;
@@ -38,6 +31,11 @@ const SKILL_NAME = "gitea-axi";
const SKILL_SOURCE = new URL("../../skills/gitea-axi/SKILL.md", import.meta.url);
const EXEC_PATH = fileURLToPath(new URL("../main.js", import.meta.url));
// The same string as SKILL_NAME, kept apart because it names a different thing:
// the executable as it is spelled on PATH, which is what the SessionStart hook
// records. They are free to diverge; the SDK's marker follows the skill.
const BINARY_NAME = "gitea-axi";
const HOOK_INTEGRATIONS = ["Claude Code", "Codex", "OpenCode"];
/** The home directory, from the injected env first so tests can point at a temp HOME. */
@@ -93,13 +91,66 @@ async function setupSkill(deps: CliDeps): Promise<string> {
});
}
// The two files the SDK writes the SessionStart hook array into. Its third
// integration, OpenCode, is a whole plugin file it rewrites wholesale behind
// its own managed marker, so that one cannot accumulate duplicates.
const HOOK_SETTINGS_FILES = [
[".claude", "settings.json"],
[".codex", "hooks.json"],
];
/**
* Collapse any duplicate managed entry the SDK's own recognition missed.
*
* It identifies its hook by finding the marker inside the recorded command, so
* an entrypoint path that does not happen to contain "gitea-axi" makes a re-run
* append a second entry rather than update the first. Matching the exact
* command this run records makes idempotency independent of the recorded
* command's shape — and cannot mistake another tool's hook for ours the way a
* substring test can.
*/
function pruneHookSettingsFiles(home: string, command: string, errors: string[]): void {
const isManaged = (recorded: string) => recorded === command;
for (const segments of HOOK_SETTINGS_FILES) {
const target = join(home, ...segments);
if (!existsSync(target)) {
continue;
}
try {
const current = JSON.parse(readFileSync(target, "utf8"));
const { settings, changed } = pruneDuplicateManagedHooks(current, isManaged);
if (changed) {
writeFileSync(target, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
}
} catch (error) {
errors.push(`${target}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
async function setupHooks(deps: CliDeps): Promise<string> {
const home = resolveHome(deps);
const errors: string[] = [];
// ADR 0019: record a search-path name, not an install-tree path. Handing the
// SDK where the binary resolves on PATH — rather than the module-relative
// entrypoint — is what lets its realpath test succeed for a wrapper-based
// install, so it records the bare, upgrade-stable name. When the name
// resolves to no wrapper or symlink of ours, the entrypoint stands as the
// fallback and the absolute path is recorded exactly as before.
//
// PATH is read from the process rather than the injected environment on
// purpose: it has to be the same PATH the SDK itself probes, and the SDK
// reads its own.
const onPath = resolveEntrypointOnPath(BINARY_NAME, EXEC_PATH, process.env.PATH);
const execPath = onPath ?? EXEC_PATH;
const command = onPath ? BINARY_NAME : EXEC_PATH;
installSessionStartHooks({
marker: SKILL_NAME,
binaryNames: [SKILL_NAME],
execPath: EXEC_PATH,
execPath,
homeDir: home,
// This is an explicit user command, so install unconditionally rather than
// deferring to the SDK's auto-install safety gate (which is tuned for the
@@ -108,6 +159,8 @@ async function setupHooks(deps: CliDeps): Promise<string> {
onError: (message) => errors.push(message),
});
pruneHookSettingsFiles(home, command, errors);
if (errors.length > 0) {
throw axiError(`Failed to install session hooks: ${errors.join("; ")}`, "UNKNOWN");
}

217
src/hooks.ts Normal file
View File

@@ -0,0 +1,217 @@
import { readFileSync, realpathSync, statSync } from "node:fs";
import { delimiter, join } from "node:path";
/** Filesystem reads {@link resolveEntrypointOnPath} needs, injectable for tests. */
export interface PathProbe {
/** The resolved real path of `candidate`, or `undefined` if it is not a file. */
realPath: (candidate: string) => string | undefined;
/** The contents of `candidate`, or `undefined` if it cannot be read as text. */
readText: (candidate: string) => string | undefined;
}
/**
* Where `name` resolves on `pathValue` *to the program running `entrypoint`*, or
* `undefined` when it resolves nowhere, or resolves to some other program.
*
* This exists so `setup hooks` can hand the agent SDK the location the binary
* actually resolves to on `PATH` rather than the module-relative entrypoint.
* The SDK records a bare, upgrade-stable name only when a `PATH` entry
* realpath-matches the path it is given, and from the entrypoint's side that
* can only ever succeed under npm, which symlinks its `bin` entry straight at
* it. A wrapper-based install cannot: a script that *invokes* a file never
* resolves *to* that file.
*
* The two install shapes are therefore recognised on their own terms:
*
* - a **symlink** to the entrypoint, matched by realpath — npm's shape;
* - a **generated wrapper** naming the entrypoint in its text, matched by
* containment — the shape Nix, shims and `.cmd` launchers all produce.
*
* Wrappers chain, so containment follows the references a wrapper makes to
* other files rather than only reading the first one. A Nix install is two
* hops: `bin/gitea-axi` sets `PATH` and execs `bin/.gitea-axi-wrapped`, which
* is what actually names the entrypoint.
*
* Requiring one of these is what keeps the answer honest. Accepting any file
* that merely *bears the name* would hand the SDK a path that trivially
* realpath-matches itself, turning its check into a tautology and recording a
* bare name that resolves to a different program than the one asked.
*
* Windows `PATHEXT` suffixes are deliberately not tried. The package supports
* Linux and macOS, and wherever the lookup misses — an unreadable wrapper, a
* compiled launcher, a chain deeper than {@link MAX_WRAPPER_HOPS} — the
* caller's absolute-path fallback still produces a working hook.
*/
export function resolveEntrypointOnPath(
name: string,
entrypoint: string,
pathValue: string | undefined,
probe: PathProbe = defaultPathProbe,
): string | undefined {
const entrypointReal = probe.realPath(entrypoint);
for (const dir of (pathValue ?? "").split(delimiter)) {
if (!dir) {
continue;
}
const candidate = join(dir, name);
const candidateReal = probe.realPath(candidate);
if (!candidateReal) {
continue;
}
if (entrypointReal !== undefined && candidateReal === entrypointReal) {
return candidate;
}
if (wrapperLeadsTo(candidate, entrypoint, probe)) {
return candidate;
}
}
return undefined;
}
/** How many wrapper-to-wrapper hops to follow. Nix needs two; the cap is slack. */
const MAX_WRAPPER_HOPS = 4;
/** How many files one lookup may read before giving up, so a dense chain cannot run away. */
const MAX_WRAPPER_FILES = 32;
/** Absolute paths appearing in a script's text, stopping at shell quoting and separators. */
function absolutePathsIn(text: string): string[] {
return text.match(/\/[^\s"';|&()]+/g) ?? [];
}
/** Whether `start`, followed through the files it names, ends up naming `entrypoint`. */
function wrapperLeadsTo(start: string, entrypoint: string, probe: PathProbe): boolean {
const seen = new Set<string>();
let frontier = [start];
for (let hop = 0; hop <= MAX_WRAPPER_HOPS && frontier.length > 0; hop++) {
const next: string[] = [];
for (const file of frontier) {
if (seen.has(file) || seen.size >= MAX_WRAPPER_FILES) {
continue;
}
seen.add(file);
const text = probe.readText(file);
if (text === undefined) {
continue;
}
if (text.includes(entrypoint)) {
return true;
}
for (const referenced of absolutePathsIn(text)) {
if (!seen.has(referenced) && probe.realPath(referenced)) {
next.push(referenced);
}
}
}
frontier = next;
}
return false;
}
/**
* Anything much larger than a wrapper script is not one. The cap keeps a chain
* that happens to name `node` or `bash` from reading whole binaries back.
*/
const MAX_WRAPPER_BYTES = 64 * 1024;
const defaultPathProbe: PathProbe = {
realPath: (candidate) => {
try {
return statSync(candidate).isFile() ? realpathSync(candidate) : undefined;
} catch {
return undefined;
}
},
readText: (candidate) => {
try {
if (statSync(candidate).size > MAX_WRAPPER_BYTES) {
return undefined;
}
return readFileSync(candidate, "utf8");
} catch {
return undefined;
}
},
};
interface HookEntry {
command?: unknown;
}
interface HookGroup {
hooks?: unknown;
}
interface HookSettings {
hooks?: { SessionStart?: unknown };
}
/**
* Collapse repeated managed SessionStart entries down to the last one, which is
* the entry the SDK has just written or refreshed.
*
* The SDK recognises its own hook by testing whether the recorded command
* *contains* the marker, so an entrypoint path that happens not to contain
* "gitea-axi" makes a re-run append a second entry instead of updating the
* first — contradicting the idempotency `setup` promises. Recognition here does
* not depend on the command's shape: callers pass an `isManaged` that matches
* the exact command this run records, so a re-run identifies its own previous
* entry by equality rather than by a substring accident — and a hook belonging
* to another tool is never a candidate, whatever its command happens to spell.
*
* The *last* match survives. Every match holds the identical command, so the
* choice can only affect `type` and `timeout`, and the last is the entry the
* SDK has just appended in the case this exists to repair.
*
* Groups left with no hooks are dropped rather than kept as empty objects.
*/
export function pruneDuplicateManagedHooks(
settings: unknown,
isManaged: (command: string) => boolean,
): { settings: unknown; changed: boolean } {
const pruned = structuredClone(settings) as HookSettings | null;
const groups = pruned?.hooks?.SessionStart;
if (!Array.isArray(groups)) {
return { settings, changed: false };
}
const isManagedHook = (hook: HookEntry) =>
typeof hook?.command === "string" && isManaged(hook.command);
const managedCount = (groups as HookGroup[]).reduce(
(total, group) =>
total +
(Array.isArray(group?.hooks) ? (group.hooks as HookEntry[]).filter(isManagedHook).length : 0),
0,
);
if (managedCount < 2) {
return { settings, changed: false };
}
// Every managed entry but the last is a leftover from an earlier run.
let remaining = managedCount - 1;
const kept: HookGroup[] = [];
for (const group of groups as HookGroup[]) {
if (!Array.isArray(group?.hooks)) {
kept.push(group);
continue;
}
const survivors = (group.hooks as HookEntry[]).filter((hook) => {
if (remaining > 0 && isManagedHook(hook)) {
remaining--;
return false;
}
return true;
});
if (survivors.length > 0) {
group.hooks = survivors;
kept.push(group);
}
}
(pruned as HookSettings).hooks = { ...pruned?.hooks, SessionStart: kept };
return { settings: pruned, changed: true };
}