From bf1bb8c46d967ab6d0009a09ac8ec3968341fd89 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 00:15:04 -0400 Subject: [PATCH 01/29] test(setup): characterize existing hook configuration Plan: .omo/plans/native-harness-setup-remove.md --- tests/setup.test.ts | 54 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 8811854..0ade973 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -44,6 +44,7 @@ test("setup leaves invalid owned configuration untouched", () => { new RegExp(setupPath.replace(/[\\^$.*+?()[\]{}|]/gu, "\\$&"), "u"), ); assert.deepEqual(fs.readFileSync(setupPath), original); + assert.deepEqual(fs.readdirSync(path.dirname(setupPath)), ["agent-lcm.json"]); }); test("setup rejects malformed Kiro schema without changing the owned file", () => { @@ -55,6 +56,7 @@ test("setup rejects malformed Kiro schema without changing the owned file", () = assert.throws(() => setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }), /invalid setup configuration/u); assert.deepEqual(fs.readFileSync(setupPath), original); + assert.deepEqual(fs.readdirSync(path.dirname(setupPath)), ["agent-lcm.json"]); }); test("setup rejects malformed Codex custom events without changing or backing up the file", () => { @@ -117,7 +119,12 @@ test("shared setup replaces older Agent LCM registrations after a binary move wi }, { type: "command", command: "\"/opt/custom-agent-lcm\" capture --harness vscode UserPromptSubmit" }, ], - sessionStart: [{ type: "command", command: "other-hook", timeout: 30 }], + sessionStart: [{ + type: "command", + command: 'node "/opt/not-agent-lcm" capture --harness auto sessionStart', + timeout: 30, + metadata: { owner: "user" }, + }], customEvent: [{ type: "command", command: "custom-hook", custom: true }], customCaptureEvent: [{ type: "command", @@ -137,7 +144,12 @@ test("shared setup replaces older Agent LCM registrations after a binary move wi assert.deepEqual(configuration.hooks.UserPromptSubmit, [ { type: "command", command: "\"/opt/custom-agent-lcm\" capture --harness vscode UserPromptSubmit" }, ]); - assert.deepEqual(configuration.hooks.sessionStart[0], { type: "command", command: "other-hook", timeout: 30 }); + assert.deepEqual(configuration.hooks.sessionStart[0], { + type: "command", + command: 'node "/opt/not-agent-lcm" capture --harness auto sessionStart', + timeout: 30, + metadata: { owner: "user" }, + }); assert.equal(configuration.hooks.sessionStart[1].command, "node \"/new-location/bin/agent-lcm\" capture --harness auto sessionStart"); assert.deepEqual(configuration.hooks.userPromptSubmitted[0], { type: "command", @@ -173,7 +185,12 @@ test("Codex setup replaces its old Agent LCM commands and preserves unrelated ho { type: "command", command: "other-post-compact-hook", timeout: 30 }, ] }], PreToolUse: [{ matcher: "Read", hooks: [ - { type: "command", command: "other-pre-tool-hook", timeout: 30 }, + { + type: "command", + command: 'node "/opt/not-agent-lcm" capture --harness codex PreToolUse', + timeout: 30, + metadata: { owner: "user" }, + }, ] }], CustomEvent: [{ hooks: [{ type: "command", @@ -208,7 +225,12 @@ test("Codex setup replaces its old Agent LCM commands and preserves unrelated ho { type: "command", command: "other-post-compact-hook", timeout: 30 }, ] }]); assert.deepEqual(configuration.hooks.PreToolUse, [{ matcher: "Read", hooks: [ - { type: "command", command: "other-pre-tool-hook", timeout: 30 }, + { + type: "command", + command: 'node "/opt/not-agent-lcm" capture --harness codex PreToolUse', + timeout: 30, + metadata: { owner: "user" }, + }, ] }, { matcher: ".*", hooks: [ { type: "command", command: 'node "/new/bin/agent-lcm" hook PreToolUse' }, ] }]); @@ -228,7 +250,11 @@ test("Cursor setup writes the user hooks file in Cursor's native schema", () => const clientHome = tempHome("agent-lcm-cursor-"); const hooksPath = path.join(clientHome, "hooks.json"); fs.writeFileSync(hooksPath, JSON.stringify({ version: 1, owner: "user", hooks: { - stop: [{ command: "other-hook", timeout: 30 }], + stop: [{ + command: 'node "/opt/not-agent-lcm" capture --harness cursor Stop', + timeout: 30, + metadata: { owner: "user" }, + }], } })); const report = setupHarness("cursor", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm" }); @@ -241,7 +267,11 @@ test("Cursor setup writes the user hooks file in Cursor's native schema", () => beforeSubmitPrompt: [{ command: 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness cursor UserPromptSubmit' }], postToolUse: [{ command: 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness cursor PostToolUse' }], stop: [ - { command: "other-hook", timeout: 30 }, + { + command: 'node "/opt/not-agent-lcm" capture --harness cursor Stop', + timeout: 30, + metadata: { owner: "user" }, + }, { command: 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness cursor Stop' }, ], }, @@ -337,7 +367,11 @@ test("Kiro setup updates its owned hooks after a binary move", () => { { name: "agent-lcm-kiro-PostToolUse", trigger: "PostToolUse", - action: { type: "command", command: "user-owned-command", timeout: 45 }, + action: { + type: "command", + command: 'node "/opt/not-agent-lcm" capture --harness kiro PostToolUse', + timeout: 45, + }, metadata: { owner: "user" }, }, { @@ -367,7 +401,11 @@ test("Kiro setup updates its owned hooks after a binary move", () => { assert.deepEqual(configuration.hooks[2], { name: "agent-lcm-kiro-PostToolUse", trigger: "PostToolUse", - action: { type: "command", command: "user-owned-command", timeout: 45 }, + action: { + type: "command", + command: 'node "/opt/not-agent-lcm" capture --harness kiro PostToolUse', + timeout: 45, + }, metadata: { owner: "user" }, }); assert.equal(configuration.hooks[3].action.command, "node \"/new/bin/agent-lcm\" capture --harness kiro SessionStart"); From d833c702ee051c5fab67abc56ed4c6e68172dc64 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 00:19:26 -0400 Subject: [PATCH 02/29] feat(setup): add native harness lifecycle adapters Plan: .omo/plans/native-harness-setup-remove.md --- src/setup-adapters.ts | 157 +++++++++++++++++++++++++++ tests/setup-adapters.test.ts | 201 +++++++++++++++++++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 src/setup-adapters.ts create mode 100644 tests/setup-adapters.test.ts diff --git a/src/setup-adapters.ts b/src/setup-adapters.ts new file mode 100644 index 0000000..8f7a5e5 --- /dev/null +++ b/src/setup-adapters.ts @@ -0,0 +1,157 @@ +import { spawnSync } from "node:child_process"; + +import type { CaptureHarness } from "./harnesses.ts"; + +export type HarnessLifecycleAction = "setup" | "remove"; + +export type HarnessLifecycleOutcome = { + readonly harness: CaptureHarness; + readonly action: HarnessLifecycleAction; + readonly status: "native-complete" | "manual-required" | "shared-retained"; + readonly nativeCli: "codex" | "copilot" | null; + readonly guide: string; +}; + +export class NativeLifecycleCommandError extends Error { + readonly name = "NativeLifecycleCommandError"; + readonly executable: "codex" | "copilot"; + readonly argv: readonly string[]; + readonly status: number | null; + readonly stderr: string; + + constructor( + executable: "codex" | "copilot", + argv: readonly string[], + status: number | null, + stderr: string, + ) { + super(`Native lifecycle command failed: executable=${executable} argv=${argv.join(" ")} status=${String(status)} stderr=${stderr}`); + this.executable = executable; + this.argv = argv; + this.status = status; + this.stderr = stderr; + } +} + +type CodexLifecycleAdapter = { + readonly kind: "codex"; + readonly executable: "codex"; + readonly guide: string; + readonly probeArgv: readonly string[]; + readonly setupArgv: readonly (readonly string[])[]; + readonly removeArgv: readonly string[]; +}; + +type CopilotLifecycleAdapter = { + readonly kind: "copilot"; + readonly executable: "copilot"; + readonly guide: string; + readonly probeArgv: readonly string[]; + readonly setupArgv: readonly (readonly string[])[]; +}; + +type ManualLifecycleAdapter = { + readonly kind: "manual"; + readonly guide: string; +}; + +export type HarnessLifecycleAdapter = CodexLifecycleAdapter | CopilotLifecycleAdapter | ManualLifecycleAdapter; + +const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; +const MAX_STDERR_CHARS = 4_096; + +export const HARNESS_LIFECYCLE_ADAPTERS = { + codex: { + kind: "codex", + executable: "codex", + guide: `${GUIDE_ROOT}/codex.md`, + probeArgv: ["plugin", "list"], + setupArgv: [ + ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "add", "agent-lcm@agent-lcm"], + ], + removeArgv: ["plugin", "remove", "agent-lcm@agent-lcm"], + }, + cursor: { kind: "manual", guide: `${GUIDE_ROOT}/cursor.md` }, + vscode: { + kind: "copilot", + executable: "copilot", + guide: `${GUIDE_ROOT}/vscode.md`, + probeArgv: ["plugin", "list"], + setupArgv: [["plugin", "install", "Team-Volt/agent-lcm"]], + }, + copilot: { + kind: "copilot", + executable: "copilot", + guide: `${GUIDE_ROOT}/copilot.md`, + probeArgv: ["plugin", "list"], + setupArgv: [["plugin", "install", "Team-Volt/agent-lcm"]], + }, + kiro: { kind: "manual", guide: `${GUIDE_ROOT}/kiro.md` }, +} satisfies Record; + +export function runHarnessLifecycle( + harness: CaptureHarness, + action: HarnessLifecycleAction, + options: { readonly env?: NodeJS.ProcessEnv } = {}, +): HarnessLifecycleOutcome { + const adapter = HARNESS_LIFECYCLE_ADAPTERS[harness]; + switch (adapter.kind) { + case "manual": + return outcome(harness, action, "manual-required", null, adapter.guide); + case "copilot": + if (action === "remove") return outcome(harness, action, "shared-retained", "copilot", adapter.guide); + return runNative(harness, action, adapter, options.env); + case "codex": + return runNative(harness, action, adapter, options.env); + default: + return assertNever(adapter); + } +} + +function runNative( + harness: CaptureHarness, + action: HarnessLifecycleAction, + adapter: CodexLifecycleAdapter | CopilotLifecycleAdapter, + env: NodeJS.ProcessEnv | undefined, +): HarnessLifecycleOutcome { + const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + if (isEnoent(probe.error) || probe.status !== 0) { + return outcome(harness, action, "manual-required", adapter.executable, adapter.guide); + } + + const commands = action === "setup" + ? adapter.setupArgv + : adapter.kind === "codex" ? [adapter.removeArgv] : []; + for (const argv of commands) runNativeCommand(adapter.executable, argv, env); + return outcome(harness, action, "native-complete", adapter.executable, adapter.guide); +} + +function runNativeCommand(executable: "codex" | "copilot", argv: readonly string[], env: NodeJS.ProcessEnv | undefined): void { + const result = spawnSync(executable, argv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + if (result.status === 0) return; + throw new NativeLifecycleCommandError(executable, argv, result.status, boundedStderr(result.stderr)); +} + +function outcome( + harness: CaptureHarness, + action: HarnessLifecycleAction, + status: HarnessLifecycleOutcome["status"], + nativeCli: HarnessLifecycleOutcome["nativeCli"], + guide: string, +): HarnessLifecycleOutcome { + return { harness, action, status, nativeCli, guide }; +} + +function boundedStderr(stderr: string | Buffer | null | undefined): string { + const value = typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? ""; + return value.slice(0, MAX_STDERR_CHARS).trimEnd(); +} + +function isEnoent(error: Error | undefined): boolean { + return error !== undefined && "code" in error && error.code === "ENOENT"; +} + +function assertNever(value: never): never { + throw new Error(`Unexpected lifecycle adapter: ${JSON.stringify(value)}`); +} diff --git a/tests/setup-adapters.test.ts b/tests/setup-adapters.test.ts new file mode 100644 index 0000000..da3e451 --- /dev/null +++ b/tests/setup-adapters.test.ts @@ -0,0 +1,201 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { NativeLifecycleCommandError, runHarnessLifecycle } from "../src/setup-adapters.ts"; + +const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; + +test("Codex setup and remove send the exact argv", (t) => { + // Given: a capable fake Codex CLI that records each argv vector. + const fake = fakeCli(t, "codex"); + + // When: Agent LCM sets up and removes Codex. + const setup = runHarnessLifecycle("codex", "setup", { env: fake.env }); + const remove = runHarnessLifecycle("codex", "remove", { env: fake.env }); + + // Then: only the documented argv arrays reached the CLI. + assert.deepEqual(setup, { + harness: "codex", + action: "setup", + status: "native-complete", + nativeCli: "codex", + guide: `${GUIDE_ROOT}/codex.md`, + }); + assert.deepEqual(remove, { + harness: "codex", + action: "remove", + status: "native-complete", + nativeCli: "codex", + guide: `${GUIDE_ROOT}/codex.md`, + }); + assert.deepEqual(readCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "add", "agent-lcm@agent-lcm"], + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ]); +}); + +test("Copilot and VS Code setup send the exact Copilot argv", (t) => { + // Given: a capable fake Copilot CLI that records each argv vector. + const fake = fakeCli(t, "copilot"); + + // When: Agent LCM sets up Copilot and VS Code. + const copilot = runHarnessLifecycle("copilot", "setup", { env: fake.env }); + const vscode = runHarnessLifecycle("vscode", "setup", { env: fake.env }); + + // Then: both use the shared Copilot store and keep their own guide. + assert.deepEqual(copilot, { + harness: "copilot", + action: "setup", + status: "native-complete", + nativeCli: "copilot", + guide: `${GUIDE_ROOT}/copilot.md`, + }); + assert.deepEqual(vscode, { + harness: "vscode", + action: "setup", + status: "native-complete", + nativeCli: "copilot", + guide: `${GUIDE_ROOT}/vscode.md`, + }); + assert.deepEqual(readCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "install", "Team-Volt/agent-lcm"], + ["plugin", "list"], + ["plugin", "install", "Team-Volt/agent-lcm"], + ]); +}); + +test("manual-required outcomes do not probe unsupported harnesses", (t) => { + // Given: an empty PATH with no harness CLIs. + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-empty-bin-")); + t.after(() => fs.rmSync(bin, { recursive: true, force: true })); + + // When: native setup is requested for every unsupported or unavailable harness. + const cursor = runHarnessLifecycle("cursor", "setup", { env: { PATH: bin } }); + const kiro = runHarnessLifecycle("kiro", "remove", { env: { PATH: bin } }); + const codex = runHarnessLifecycle("codex", "setup", { env: { PATH: bin } }); + const incapable = fakeCli(t, "copilot", ["plugin", "list"]); + const copilot = runHarnessLifecycle("copilot", "setup", { env: incapable.env }); + + // Then: each reports its canonical guide without a native success claim. + assert.deepEqual(cursor, { + harness: "cursor", + action: "setup", + status: "manual-required", + nativeCli: null, + guide: `${GUIDE_ROOT}/cursor.md`, + }); + assert.deepEqual(kiro, { + harness: "kiro", + action: "remove", + status: "manual-required", + nativeCli: null, + guide: `${GUIDE_ROOT}/kiro.md`, + }); + assert.deepEqual(codex, { + harness: "codex", + action: "setup", + status: "manual-required", + nativeCli: "codex", + guide: `${GUIDE_ROOT}/codex.md`, + }); + assert.deepEqual(copilot, { + harness: "copilot", + action: "setup", + status: "manual-required", + nativeCli: "copilot", + guide: `${GUIDE_ROOT}/copilot.md`, + }); + assert.deepEqual(readCalls(incapable.log), [["plugin", "list"]]); +}); + +test("shared-retained removal does not spawn Copilot uninstall", (t) => { + // Given: a fake Copilot CLI that would record any spawned process. + const fake = fakeCli(t, "copilot"); + + // When: either shared-store harness is removed alone. + const vscode = runHarnessLifecycle("vscode", "remove", { env: fake.env }); + const copilot = runHarnessLifecycle("copilot", "remove", { env: fake.env }); + + // Then: no probe or uninstall runs and both outcomes retain the shared store. + assert.deepEqual(vscode, { + harness: "vscode", + action: "remove", + status: "shared-retained", + nativeCli: "copilot", + guide: `${GUIDE_ROOT}/vscode.md`, + }); + assert.deepEqual(copilot, { + harness: "copilot", + action: "remove", + status: "shared-retained", + nativeCli: "copilot", + guide: `${GUIDE_ROOT}/copilot.md`, + }); + assert.equal(fs.existsSync(fake.log), false); +}); + +test("mutating command failure is typed and cannot report completion", (t) => { + // Given: a capable fake Codex CLI that fails its marketplace mutation. + const fake = fakeCli(t, "codex", ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"]); + + // When: Agent LCM attempts Codex setup. + const run = () => runHarnessLifecycle("codex", "setup", { env: fake.env }); + + // Then: the mutation failure includes its executable, argv, status, and bounded stderr. + assert.throws(run, (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.equal(error.executable, "codex"); + assert.deepEqual(error.argv, ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"]); + assert.equal(error.status, 23); + assert.equal(error.stderr, "mutation failed"); + assert.equal( + error.message, + "Native lifecycle command failed: executable=codex argv=plugin marketplace add Team-Volt/agent-lcm status=23 stderr=mutation failed", + ); + return true; + }); + assert.deepEqual(readCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ]); +}); + +function fakeCli( + t: test.TestContext, + name: "codex" | "copilot", + fails?: readonly string[], +): { readonly env: NodeJS.ProcessEnv; readonly log: string } { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-fake-cli-")); + const log = path.join(bin, "calls.jsonl"); + const failure = fails ? JSON.stringify(fails) : ""; + const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst args = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(args) + "\\n");\nif (${JSON.stringify(failure)} && JSON.stringify(args) === ${JSON.stringify(failure)}) { process.stderr.write("mutation failed\\n"); process.exit(23); }\n`; + fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); + t.after(() => fs.rmSync(bin, { recursive: true, force: true })); + return { + env: { + AGENT_LCM_FAKE_LOG: log, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + }, + log, + }; +} + +function readCalls(log: string): string[][] { + const contents = fs.readFileSync(log, "utf8").trim(); + return contents.length === 0 ? [] : contents.split("\n").map(readCall); +} + +function readCall(line: string): string[] { + const value: unknown = JSON.parse(line); + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { + throw new Error("Fake CLI log entry must be a string array"); + } + return value; +} From 215194bd96f3cd2642e26e34377157ae93121859 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 00:27:15 -0400 Subject: [PATCH 03/29] fix(setup): serialize safe configuration updates Plan: .omo/plans/native-harness-setup-remove.md --- src/setup-files.ts | 153 +++++++++++++++++++++++++++++++----- src/setup.ts | 10 +-- tests/setup-files.test.ts | 160 ++++++++++++++++++++++++++++++++++++++ tests/setup.test.ts | 6 +- 4 files changed, 301 insertions(+), 28 deletions(-) create mode 100644 tests/setup-files.test.ts diff --git a/src/setup-files.ts b/src/setup-files.ts index c8755db..5d5e582 100644 --- a/src/setup-files.ts +++ b/src/setup-files.ts @@ -1,18 +1,48 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; -export function readSetupConfiguration(target: string): Record | undefined { - let text: string; - try { - text = fs.readFileSync(target, "utf8"); - } catch (error) { - if (hasCode(error, "ENOENT")) return undefined; - throw error; +const SETUP_LOCK_TIMEOUT_MS = 10_000; +const SETUP_LOCK_POLL_MS = 10; +const SETUP_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); + +export class SetupFileLockTimeoutError extends Error { + readonly lockPath: string; + + constructor(lockPath: string) { + super(`agent-lcm: setup file lock timeout: ${lockPath}`); + this.name = "SetupFileLockTimeoutError"; + this.lockPath = lockPath; } +} + +export function mutateSetupConfiguration( + target: string, + transform: (configuration: Record | undefined) => Record, +): boolean { + ensureSetupDirectory(path.dirname(target)); + return withSetupFileLock(target, () => { + const current = readSetupFile(target); + const existing = current ? parseSetupConfiguration(current, target) : undefined; + const next = transform(existing); + if (existing && JSON.stringify(existing) === JSON.stringify(next)) return false; + if (current) backupSetupBytes(target, current); + writeSetupBytes(target, Buffer.from(`${JSON.stringify(next, null, 2)}\n`)); + return true; + }); +} + +export function readSetupConfiguration(target: string): Record | undefined { + const bytes = readSetupFile(target); + if (!bytes) return undefined; + return parseSetupConfiguration(bytes, target); +} + +function parseSetupConfiguration(bytes: Buffer, target: string): Record { let value: unknown; try { - value = JSON.parse(text); + value = JSON.parse(bytes.toString("utf8")); } catch { throw new Error(`Cannot update invalid setup configuration: ${target}`); } @@ -21,36 +51,123 @@ export function readSetupConfiguration(target: string): Record } export function writeSetupConfiguration(target: string, configuration: Record): void { - const directory = path.dirname(target); - fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); - fs.chmodSync(directory, 0o700); + ensureSetupDirectory(path.dirname(target)); + writeSetupBytes(target, Buffer.from(`${JSON.stringify(configuration, null, 2)}\n`)); +} + +function writeSetupBytes(target: string, bytes: Buffer): void { const temporary = `${target}.${randomUUID()}.tmp`; - const descriptor = fs.openSync(temporary, "wx", 0o600); + let descriptor: number | undefined; try { - fs.writeFileSync(descriptor, `${JSON.stringify(configuration, null, 2)}\n`); + descriptor = fs.openSync(temporary, "wx", 0o600); + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); fs.fsyncSync(descriptor); - } finally { fs.closeSync(descriptor); + descriptor = undefined; + fs.renameSync(temporary, target); + if (process.platform !== "win32") fsyncPath(path.dirname(target)); + } catch (error) { + if (descriptor !== undefined) fs.closeSync(descriptor); + try { + fs.unlinkSync(temporary); + } catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) throw new AggregateError([error, cleanupError], "Setup publication and cleanup failed."); + } + throw error; } - fs.renameSync(temporary, target); } export function backupSetupConfiguration(target: string): void { + backupSetupBytes(target, fs.readFileSync(target)); +} + +function backupSetupBytes(target: string, bytes: Buffer): void { const extension = path.extname(target); const stem = extension ? target.slice(0, -extension.length) : target; const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); for (let suffix = 0; ; suffix += 1) { const candidate = `${stem}-pre-agent-lcm-${timestamp}${suffix ? `-${suffix}` : ""}${extension}`; + let descriptor: number; + try { + descriptor = fs.openSync(candidate, "wx", 0o600); + } catch (error) { + if (hasCode(error, "EEXIST")) continue; + throw error; + } try { - fs.copyFileSync(target, candidate, fs.constants.COPYFILE_EXCL); - fs.chmodSync(candidate, 0o600); + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); return; } catch (error) { - if (!hasCode(error, "EEXIST")) throw error; + fs.closeSync(descriptor); + try { + fs.unlinkSync(candidate); + } catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) throw new AggregateError([error, cleanupError], "Setup backup and cleanup failed."); + } + throw error; } } } +function withSetupFileLock(target: string, callback: () => T): T { + const lockPath = `${target}.lock.sqlite`; + const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; + const coordinator = new DatabaseSync(lockPath, { timeout: SETUP_LOCK_POLL_MS }); + let transactionOpen = false; + try { + fs.chmodSync(lockPath, 0o600); + while (!transactionOpen) { + try { + coordinator.exec("BEGIN IMMEDIATE"); + transactionOpen = true; + } catch (error) { + if (!isSqliteBusy(error)) throw error; + if (Date.now() >= deadline) throw new SetupFileLockTimeoutError(lockPath); + Atomics.wait(SETUP_LOCK_WAIT, 0, 0, SETUP_LOCK_POLL_MS); + } + } + return callback(); + } finally { + if (transactionOpen) coordinator.exec("ROLLBACK"); + coordinator.close(); + } +} + +function readSetupFile(target: string): Buffer | undefined { + let status: fs.Stats; + try { + status = fs.lstatSync(target); + } catch (error) { + if (hasCode(error, "ENOENT")) return undefined; + throw error; + } + if (status.isSymbolicLink()) throw new Error(`Refusing setup configuration symlink: ${target}`); + if (!status.isFile()) throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); + return fs.readFileSync(target); +} + +function ensureSetupDirectory(directory: string): void { + const created = fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (created !== undefined) fs.chmodSync(directory, 0o700); +} + +function fsyncPath(target: string): void { + const descriptor = fs.openSync(target, "r"); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function isSqliteBusy(error: unknown): boolean { + return error instanceof Error && Reflect.get(error, "errcode") === 5; +} + function hasCode(error: unknown, code: string): boolean { return error instanceof Error && Reflect.get(error, "code") === code; } diff --git a/src/setup.ts b/src/setup.ts index 7b7fbc7..e0c72b7 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,7 +1,7 @@ import path from "node:path"; import type { CaptureHarness } from "./harnesses.ts"; -import { backupSetupConfiguration, readSetupConfiguration, writeSetupConfiguration } from "./setup-files.ts"; +import { mutateSetupConfiguration, readSetupConfiguration } from "./setup-files.ts"; import { SETUP_HARNESSES, setupPath } from "./setup-targets.ts"; export type SetupOptions = { home?: string; command: string }; @@ -16,12 +16,8 @@ export function setupHarness(harness: CaptureHarness, options: SetupOptions): Se const target = setupPath(harness, options.home); const command = options.command.trim(); assertSafeCommand(command); - const existing = readSetupConfiguration(target); - const next = mergeConfiguration(existing, harness, command, target); - if (existing && JSON.stringify(existing) === JSON.stringify(next)) return { harness, path: target, changed: false }; - if (existing) backupSetupConfiguration(target); - writeSetupConfiguration(target, next); - return { harness, path: target, changed: true }; + const changed = mutateSetupConfiguration(target, (existing) => mergeConfiguration(existing, harness, command, target)); + return { harness, path: target, changed }; } export function setupStatus(options: SetupStatusOptions = {}): Record { diff --git a/tests/setup-files.test.ts b/tests/setup-files.test.ts new file mode 100644 index 0000000..79a2c8f --- /dev/null +++ b/tests/setup-files.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { mutateSetupConfiguration } from "../src/setup-files.ts"; +import { setupHarness } from "../src/setup.ts"; +import { tempHome } from "./helpers.ts"; + +test("setup refuses a target symlink without changing the victim", { skip: process.platform === "win32" }, () => { + // Given: a setup target points at an unrelated valid configuration. + const home = tempHome("agent-lcm-setup-symlink-"); + const target = path.join(home, "hooks.json"); + const victim = path.join(home, "victim.json"); + const original = Buffer.from('{"hooks":{}}\n'); + fs.writeFileSync(victim, original); + fs.symlinkSync(victim, target); + + // When: setup tries to mutate the target. + assert.throws( + () => setupHarness("codex", { home, command: "/opt/agent-lcm/bin/agent-lcm" }), + /Refus.*symlink/u, + ); + + // Then: neither the link nor its victim is changed and no setup artifact appears. + assert.equal(fs.lstatSync(target).isSymbolicLink(), true); + assert.deepEqual(fs.readFileSync(victim), original); + assert.deepEqual(setupArtifacts(home), []); +}); + +test("invalid setup bytes remain unchanged without backup or temporary files", () => { + // Given: an existing target contains invalid JSON bytes. + const home = tempHome("agent-lcm-setup-invalid-"); + const target = path.join(home, "hooks.json"); + const original = Buffer.from("{not json\n"); + fs.writeFileSync(target, original); + + // When: a serialized mutation tries to read it. + assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /invalid setup configuration/u); + + // Then: the source bytes remain exact and no publication artifact exists. + assert.deepEqual(fs.readFileSync(target), original); + assert.deepEqual(setupArtifacts(home), []); +}); + +test("no-op setup mutations do not rewrite or back up the target", () => { + // Given: a valid setup target with a fixed modification time. + const home = tempHome("agent-lcm-setup-noop-"); + const target = path.join(home, "hooks.json"); + const original = Buffer.from('{"hooks":{}}\n'); + fs.writeFileSync(target, original); + const fixed = new Date("2026-01-01T00:00:00.000Z"); + fs.utimesSync(target, fixed, fixed); + + // When: the transform returns an equivalent configuration. + const changed = mutateSetupConfiguration(target, (configuration) => configuration ?? { hooks: {} }); + + // Then: no write or backup occurs. + assert.equal(changed, false); + assert.deepEqual(fs.readFileSync(target), original); + assert.equal(fs.statSync(target).mtimeMs, fixed.getTime()); + assert.deepEqual(setupArtifacts(home), []); +}); + +test("publication failure preserves the target, cleans temporary files, and keeps parent mode", { skip: process.platform === "win32" }, () => { + // Given: an existing setup file in a deliberately non-private existing directory. + const home = tempHome("agent-lcm-setup-publish-"); + const directory = path.join(home, "existing"); + const target = path.join(directory, "hooks.json"); + const original = Buffer.from('{"hooks":{}}\n'); + fs.mkdirSync(directory, { mode: 0o755 }); + fs.chmodSync(directory, 0o755); + fs.writeFileSync(target, original); + const originalRename = fs.renameSync; + fs.renameSync = () => { + throw new Error("injected publication failure"); + }; + + // When: atomic publication fails at rename. + try { + assert.throws( + () => mutateSetupConfiguration(target, (configuration) => ({ ...configuration, added: true })), + /injected publication failure/u, + ); + } finally { + fs.renameSync = originalRename; + } + + // Then: the old target survives, temporary files are gone, and the directory mode is unchanged. + assert.deepEqual(fs.readFileSync(target), original); + assert.equal(fs.statSync(directory).mode & 0o777, 0o755); + assert.deepEqual(fs.readdirSync(directory).filter((name) => name.endsWith(".tmp")), []); +}); + +test("new setup directories and exact-byte backups use private modes", { skip: process.platform === "win32" }, () => { + // Given: a missing setup directory and then a valid target with non-canonical bytes. + const home = tempHome("agent-lcm-setup-modes-"); + const directory = path.join(home, "new", "hooks"); + const target = path.join(directory, "hooks.json"); + mutateSetupConfiguration(target, () => ({ hooks: {} })); + const original = Buffer.from('{ "hooks": {}, "keep": true }\n'); + fs.writeFileSync(target, original); + + // When: a changed transform publishes a new configuration. + mutateSetupConfiguration(target, (configuration) => ({ ...configuration, added: true })); + + // Then: the new directory is private and the backup retains exact bytes at a private mode. + const backup = fs.readdirSync(directory).find((name) => name.startsWith("hooks-pre-agent-lcm-")); + assert.notEqual(backup, undefined); + if (backup === undefined) throw new Error("expected setup backup"); + assert.equal(fs.statSync(directory).mode & 0o777, 0o700); + assert.equal(fs.statSync(path.join(directory, backup)).mode & 0o777, 0o600); + assert.deepEqual(fs.readFileSync(path.join(directory, backup)), original); +}); + +test("serializes concurrent setup mutations", async () => { + // Given: one child holds the target lock while a second child reaches the mutation boundary. + const home = tempHome("agent-lcm-setup-concurrent-"); + const target = path.join(home, "hooks.json"); + fs.writeFileSync(target, '{"base":true}\n'); + const moduleUrl = new URL("../src/setup-files.ts", import.meta.url).href; + const holder = spawn(process.execPath, ["--no-warnings", "--input-type=module", "--eval", ` + const fs = (await import("node:fs")).default; + const { mutateSetupConfiguration } = await import(process.env.SETUP_FILES_URL); + mutateSetupConfiguration(process.env.SETUP_TARGET, (configuration) => { + process.stdout.write("locked\\n"); + fs.readSync(0, Buffer.alloc(1), 0, 1, null); + return { ...configuration, holder: true }; + }); + `], { + env: { ...process.env, SETUP_FILES_URL: moduleUrl, SETUP_TARGET: target }, + stdio: ["pipe", "pipe", "pipe"], + }); + await once(holder.stdout, "data"); + const waiter = spawn(process.execPath, ["--no-warnings", "--input-type=module", "--eval", ` + const { mutateSetupConfiguration } = await import(process.env.SETUP_FILES_URL); + process.stdout.write("waiting\\n"); + mutateSetupConfiguration(process.env.SETUP_TARGET, (configuration) => ({ ...configuration, waiter: true })); + `], { + env: { ...process.env, SETUP_FILES_URL: moduleUrl, SETUP_TARGET: target }, + stdio: ["ignore", "pipe", "pipe"], + }); + await once(waiter.stdout, "data"); + + // When: the holder releases after the waiter has started its mutation call. + holder.stdin.end("x"); + const [holderExit, waiterExit] = await Promise.all([once(holder, "exit"), once(waiter, "exit")]); + + // Then: both transforms survive and neither process leaves a temporary file. + assert.equal(holderExit[0], 0); + assert.equal(waiterExit[0], 0); + assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), { base: true, holder: true, waiter: true }); + assert.deepEqual(fs.readdirSync(home).filter((name) => name.endsWith(".tmp")), []); +}); + +function setupArtifacts(directory: string): string[] { + return fs.readdirSync(directory).filter((name) => name.includes("pre-agent-lcm") || name.endsWith(".tmp")); +} diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 0ade973..6051c2e 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -44,7 +44,7 @@ test("setup leaves invalid owned configuration untouched", () => { new RegExp(setupPath.replace(/[\\^$.*+?()[\]{}|]/gu, "\\$&"), "u"), ); assert.deepEqual(fs.readFileSync(setupPath), original); - assert.deepEqual(fs.readdirSync(path.dirname(setupPath)), ["agent-lcm.json"]); + assert.deepEqual(fs.readdirSync(path.dirname(setupPath)).sort(), ["agent-lcm.json", "agent-lcm.json.lock.sqlite"]); }); test("setup rejects malformed Kiro schema without changing the owned file", () => { @@ -56,7 +56,7 @@ test("setup rejects malformed Kiro schema without changing the owned file", () = assert.throws(() => setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }), /invalid setup configuration/u); assert.deepEqual(fs.readFileSync(setupPath), original); - assert.deepEqual(fs.readdirSync(path.dirname(setupPath)), ["agent-lcm.json"]); + assert.deepEqual(fs.readdirSync(path.dirname(setupPath)).sort(), ["agent-lcm.json", "agent-lcm.json.lock.sqlite"]); }); test("setup rejects malformed Codex custom events without changing or backing up the file", () => { @@ -70,7 +70,7 @@ test("setup rejects malformed Codex custom events without changing or backing up /invalid setup configuration/u, ); assert.deepEqual(fs.readFileSync(setupPath), original); - assert.deepEqual(fs.readdirSync(clientHome), ["hooks.json"]); + assert.deepEqual(fs.readdirSync(clientHome).sort(), ["hooks.json", "hooks.json.lock.sqlite"]); }); test("Copilot and VS Code converge on one lower-camel shared user hook configuration", () => { From 53f07213081978a2d872ce1c16868bc6d2ef62c2 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 00:29:22 -0400 Subject: [PATCH 04/29] fix(plugin): validate the native Codex package Plan: .omo/plans/native-harness-setup-remove.md --- .codex-plugin/plugin.json | 12 ++++++++++-- .mcp.json | 9 +++++++++ package.json | 1 + tests/distribution.test.ts | 1 + tests/plugin-manifest.test.ts | 17 ++++++++++++++++- 5 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 .mcp.json diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index b21873e..3402d44 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -8,6 +8,14 @@ "homepage": "https://github.com/Team-Volt/agent-lcm", "license": "MIT", "skills": "./skills/", - "mcpServers": "./mcp.json", - "hooks": "./hooks/codex.json" + "interface": { + "displayName": "Agent LCM", + "shortDescription": "Use shared local context memory in Codex.", + "longDescription": "Agent LCM captures and recalls coding-agent sessions from one local store.", + "developerName": "Team Volt", + "category": "Developer Tools", + "capabilities": [], + "defaultPrompt": "Recall relevant work from earlier coding sessions." + }, + "mcpServers": "./.mcp.json" } diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..de2e47d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "agent-lcm": { + "type": "stdio", + "command": "node", + "args": ["${PLUGIN_ROOT}/bin/agent-lcm", "mcp"] + } + } +} diff --git a/package.json b/package.json index a14c5dc..6cae759 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ ".agents/plugins/marketplace.json", ".codex-plugin/", ".cursor-plugin/", + ".mcp.json", "bin/", "dist/", "hooks/", diff --git a/tests/distribution.test.ts b/tests/distribution.test.ts index ca7af1e..942f0a5 100644 --- a/tests/distribution.test.ts +++ b/tests/distribution.test.ts @@ -25,6 +25,7 @@ test("the npm package contains the complete plugin and no development files", (t ".codex-plugin/plugin.json", ".cursor-plugin/marketplace.json", ".cursor-plugin/plugin.json", + ".mcp.json", "LICENSE", "README.md", "bin/agent-lcm", diff --git a/tests/plugin-manifest.test.ts b/tests/plugin-manifest.test.ts index 9a6fd02..4d43ebe 100644 --- a/tests/plugin-manifest.test.ts +++ b/tests/plugin-manifest.test.ts @@ -20,8 +20,23 @@ test("root is an Agent Plugins 1.0 package", () => { test("client hook manifests invoke explicit or detected harness capture", () => { const codex = readJson(".codex-plugin/plugin.json"); - assert.equal(codex.hooks, "./hooks/codex.json"); + assert.equal(codex.hooks, undefined); + assert.equal(codex.mcpServers, "./.mcp.json"); assert.equal(codex.homepage, "https://github.com/Team-Volt/agent-lcm"); + assert.deepEqual(codex.interface, { + displayName: "Agent LCM", + shortDescription: "Use shared local context memory in Codex.", + longDescription: "Agent LCM captures and recalls coding-agent sessions from one local store.", + developerName: "Team Volt", + category: "Developer Tools", + capabilities: [], + defaultPrompt: "Recall relevant work from earlier coding sessions.", + }); + assert.deepEqual(readJson(".mcp.json").mcpServers["agent-lcm"], { + type: "stdio", + command: "node", + args: ["${PLUGIN_ROOT}/bin/agent-lcm", "mcp"], + }); const codexManifest = readJson("hooks/codex.json"); const codexHooks = JSON.stringify(codexManifest); assert.deepEqual(Object.keys(codexManifest.hooks).sort(), [ From f93e934bc34391a26a753e7afb079f2574465668 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 00:30:37 -0400 Subject: [PATCH 05/29] docs(setup): add per-harness install guides Plan: .omo/plans/native-harness-setup-remove.md --- README.md | 63 +++++++++++++++++++++++------------------ docs/install/codex.md | 50 ++++++++++++++++++++++++++++++++ docs/install/copilot.md | 52 ++++++++++++++++++++++++++++++++++ docs/install/cursor.md | 41 +++++++++++++++++++++++++++ docs/install/kiro.md | 36 +++++++++++++++++++++++ docs/install/vscode.md | 56 ++++++++++++++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 27 deletions(-) create mode 100644 docs/install/codex.md create mode 100644 docs/install/copilot.md create mode 100644 docs/install/cursor.md create mode 100644 docs/install/kiro.md create mode 100644 docs/install/vscode.md diff --git a/README.md b/README.md index 60ee15c..6e052d0 100644 --- a/README.md +++ b/README.md @@ -71,22 +71,25 @@ you never need to find or reference a harness cache path. ## Install in each harness -Agent Plugins 1.0 defines the package, not one shared installer. Use the native -flow for each harness: - -| Harness | Install | -| --- | --- | -| Codex | `codex plugin marketplace add Team-Volt/agent-lcm`, then `codex plugin add agent-lcm@agent-lcm` | -| GitHub Copilot CLI | `copilot plugin install Team-Volt/agent-lcm` | -| VS Code | Run `Chat: Install Plugin From Source` and enter `https://github.com/Team-Volt/agent-lcm`; VS Code also discovers the Copilot CLI install | -| Cursor | Use `/add-plugin` after Agent LCM is listed in the Cursor Marketplace, or ask an admin to add the repository to your Team Marketplace | -| Kiro IDE | Open Powers, choose the GitHub import option, and enter `https://github.com/Team-Volt/agent-lcm` | - -These flows follow the current [Codex plugin](https://help.openai.com/en/articles/20001256-plugins-in-codex/), -[Copilot CLI plugin](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-finding-installing), +Agent Plugins 1.0 defines the package, not one shared installer. Run the setup +command for each harness you use, then follow its guide for native installation, +trust, and removal: + +| Harness | Setup command | Guide | +| --- | --- | --- | +| Codex | `agent-lcm setup codex` | [Codex guide](docs/install/codex.md) | +| Cursor | `agent-lcm setup cursor` | [Cursor guide](docs/install/cursor.md) | +| VS Code | `agent-lcm setup vscode` | [VS Code guide](docs/install/vscode.md) | +| GitHub Copilot CLI | `agent-lcm setup copilot` | [Copilot guide](docs/install/copilot.md) | +| Kiro IDE | `agent-lcm setup kiro` | [Kiro guide](docs/install/kiro.md) | + +The guides follow the current [Codex plugin](https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md), +[Copilot CLI plugin](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference), [VS Code agent plugin](https://code.visualstudio.com/docs/agent-customization/agent-plugins), -[Cursor marketplace](https://cursor.com/blog/marketplace), and -[Kiro Powers](https://kiro.dev/docs/powers/) documentation. +[Cursor Marketplace](https://cursor.com/marketplace), and +[Kiro Powers](https://kiro.dev/docs/powers/) documentation. If setup cannot run a +supported native command, it reports the guide and uses the manual hook path +when that harness needs one. Compatible clients discover the same portable components: @@ -105,14 +108,16 @@ layouts. If a client cannot install the plugin, add this stdio MCP server: The harness must inherit a `PATH` that contains the npm global binary. Native plugin installation is more reliable for GUI apps because it uses the bundled -command. Restart the harness after installation. +command. Use the relevant guide's trust or refresh note after installation; a +restart is not a general requirement documented by every harness. ## Enable automatic capture `agent-lcm setup all` detects the harnesses installed under your home directory -and installs or repairs hooks only for those harnesses. It does not create -configuration directories for clients you do not use. To configure a harness -that setup cannot detect, run its command directly: +and completes native setup where supported, with manual hook wiring only where +that harness needs it. It does not create configuration directories for clients +you do not use. To configure a harness that setup cannot detect, run its +command directly: ```sh agent-lcm setup codex @@ -122,14 +127,15 @@ agent-lcm setup copilot agent-lcm setup kiro ``` -Run only the commands for the harnesses you use. VS Code and GitHub Copilot -share `~/.copilot/hooks/agent-lcm.json`; either setup command installs the same -auto-detecting hooks. Setup preserves unrelated hook entries, is safe to run -again, and writes private files containing the absolute Agent LCM command. If a -target file already exists and needs changes, setup first saves a timestamped -`-pre-agent-lcm-` backup beside it. +Run only the commands for the harnesses you use. The manual VS Code and GitHub +Copilot fallback shares `~/.copilot/hooks/agent-lcm.json`; native plugin hooks +are loaded from the plugin store instead of being duplicated there. Setup +preserves unrelated hook entries, is safe to run again, and writes private +files containing the absolute Agent LCM command when manual wiring is needed. +If a target file already exists and needs changes, setup first saves a +timestamped `-pre-agent-lcm-` backup beside it. -The user hook locations are: +Setup-managed and legacy user hook locations are: | Harness | Hook file | | --- | --- | @@ -139,7 +145,10 @@ The user hook locations are: | GitHub Copilot | `~/.copilot/hooks/agent-lcm.json` | | Kiro | `~/.kiro/hooks/agent-lcm.json` | -Check the result, then restart each harness: +Cursor, Copilot, and VS Code native plugins carry their own hooks. Setup does +not add a second user-level copy after native installation. + +Check the result: ```sh agent-lcm setup status diff --git a/docs/install/codex.md b/docs/install/codex.md new file mode 100644 index 0000000..5a7c40f --- /dev/null +++ b/docs/install/codex.md @@ -0,0 +1,50 @@ +# Install Agent LCM in Codex + +## What setup does + +Run: + +```sh +agent-lcm setup codex +``` + +This runs the Codex native lifecycle when the CLI is available and its plugin +probe succeeds. Codex does not accept hooks in its plugin manifest, so setup +also installs or repairs the capture hooks at `~/.codex/hooks.json`. If the native +probe is unavailable, setup reports `manual-required` with this guide while +keeping that hook path available for capture. + +## Native install and inspection + +Use the documented Codex plugin flow: + +```sh +codex plugin marketplace add Team-Volt/agent-lcm +codex plugin add agent-lcm@agent-lcm +codex plugin list +``` + +The first two commands add Agent LCM and the last command lists installed +plugins. See the [Codex plugin installation reference](https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md). + +After installation, start a new Codex thread so it picks up the plugin. If +Codex asks you to trust plugin-owned commands, review the commands and approve +them only if you expect them. + +## Remove Agent LCM + +The Agent LCM command removes the native plugin and only Agent LCM's entries +from `~/.codex/hooks.json`: + +```sh +agent-lcm remove codex +``` + +If the Codex CLI is unavailable, the command removes the hooks, reports +`manual-required`, and links back here. Finish the native removal with: + +```sh +codex plugin remove agent-lcm@agent-lcm +``` + +Check the result with `agent-lcm setup status` and `agent-lcm doctor --json`. diff --git a/docs/install/copilot.md b/docs/install/copilot.md new file mode 100644 index 0000000..462fe15 --- /dev/null +++ b/docs/install/copilot.md @@ -0,0 +1,52 @@ +# Install Agent LCM in GitHub Copilot CLI + +## What setup does + +Run: + +```sh +agent-lcm setup copilot +``` + +This runs the Copilot native lifecycle when the CLI is available and its plugin +probe succeeds. Copilot CLI auto-loads the bundled hooks, so setup does not add +duplicate shared hooks after native installation. The manual fallback path is +`~/.copilot/hooks/agent-lcm.json`; setup preserves an existing fallback when +native installation is unavailable, adds no new duplicate, and reports +`manual-required` with this guide. + +## Native install and inspection + +Use the documented Copilot CLI commands: + +```sh +copilot plugin install Team-Volt/agent-lcm +copilot plugin list +``` + +The list command shows installed plugins. See the [Copilot CLI plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference) for the command set and plugin specification. + +The Copilot CLI reference does not require a restart after installation. If a +new plugin is not visible, start a new Copilot session as troubleshooting and +run `copilot plugin list` again. + +> Warning: GitHub Copilot CLI and VS Code share the Copilot plugin store and the hook file `~/.copilot/hooks/agent-lcm.json`. A deliberate native uninstall affects both harnesses. Do not uninstall the shared plugin when you mean to remove only Copilot CLI. + +## Remove Agent LCM + +The safe single-harness command retains the shared plugin and hook resources: + +```sh +agent-lcm remove copilot +``` + +It reports `shared-retained` and leaves the shared store and hook file +unchanged. To deliberately remove the shared native installation from both +harnesses, run: + +```sh +copilot plugin uninstall agent-lcm +``` + +Review both harnesses before removing the shared hook file. Do not delete +unrelated entries from `~/.copilot/hooks/agent-lcm.json`. diff --git a/docs/install/cursor.md b/docs/install/cursor.md new file mode 100644 index 0000000..3e246c2 --- /dev/null +++ b/docs/install/cursor.md @@ -0,0 +1,41 @@ +# Install Agent LCM in Cursor + +## What setup does + +Run: + +```sh +agent-lcm setup cursor +``` + +Cursor plugins package their hooks. Setup validates and preserves any legacy +capture file at `~/.cursor/hooks.json`, but it does not add another copy of the +same hooks. Cursor has no stable native install or remove CLI in the supported +documentation, so setup reports `manual-required` for the native step. + +## Native install and inspection + +Open the official [Cursor Marketplace](https://cursor.com/marketplace), find +Agent LCM when it is listed, and use the Marketplace UI to install it. Use the +Cursor plugin or Marketplace view to inspect its installed state. Do not use a +shell command for this step. + +If Cursor shows a trust prompt, review the plugin source before accepting it. +The Marketplace documentation does not establish a required restart. If the +plugin or hooks look stale, close and reopen Cursor as troubleshooting, then +check `agent-lcm setup status`. + +If an older Agent LCM version already configured `~/.cursor/hooks.json`, run +`agent-lcm remove cursor` immediately before installing the native plugin. That +removes only the legacy Agent LCM entries and avoids running both copies. + +## Remove Agent LCM + +Use Cursor's installed-plugin UI to remove Agent LCM. Then remove only its +capture hooks: + +```sh +agent-lcm remove cursor +``` + +The command preserves unrelated entries in `~/.cursor/hooks.json`. diff --git a/docs/install/kiro.md b/docs/install/kiro.md new file mode 100644 index 0000000..eecaceb --- /dev/null +++ b/docs/install/kiro.md @@ -0,0 +1,36 @@ +# Install Agent LCM in Kiro + +## What setup does + +Run: + +```sh +agent-lcm setup kiro +``` + +This installs or repairs the Kiro capture hooks at +`~/.kiro/hooks/agent-lcm.json`. Kiro's native Power install remains a manual UI +step, so setup reports `manual-required` for that step. + +## Native install and inspection + +Open [Kiro Powers](https://kiro.dev/docs/powers/) in Kiro or on kiro.dev. Use +the Powers UI to browse the marketplace or install from a GitHub repository, +enter `https://github.com/Team-Volt/agent-lcm`, and click Install. The same +Powers UI shows the installed Power state. + +The official Powers guide does not define a command-line install or a required +restart. If the Power or hooks look stale, close and reopen Kiro as +troubleshooting, then check `agent-lcm setup status`. + +## Remove Agent LCM + +Use the Powers UI's documented management control to remove Agent LCM. Kiro's +official Powers documentation does not define a native removal CLI. Then remove +only the Agent LCM hooks: + +```sh +agent-lcm remove kiro +``` + +The command preserves unrelated entries in `~/.kiro/hooks/agent-lcm.json`. diff --git a/docs/install/vscode.md b/docs/install/vscode.md new file mode 100644 index 0000000..0a04b07 --- /dev/null +++ b/docs/install/vscode.md @@ -0,0 +1,56 @@ +# Install Agent LCM in VS Code + +## What setup does + +Run: + +```sh +agent-lcm setup vscode +``` + +This runs the Copilot native lifecycle when the CLI is available and its plugin +probe succeeds. VS Code auto-loads hooks from the Copilot plugin store, so setup +does not add duplicate shared hooks after native installation. The manual +fallback path is `~/.copilot/hooks/agent-lcm.json`; setup preserves an existing +fallback when native installation is unavailable, adds no new duplicate, and +reports `manual-required` with this guide. + +## Native install and inspection + +VS Code automatically discovers plugins installed by Copilot CLI from +`~/.copilot/installed-plugins/`. To install through that shared store, run: + +```sh +copilot plugin install Team-Volt/agent-lcm +copilot plugin list +``` + +You can install from the VS Code UI instead. Open Extensions and search for +`@agentPlugins`, or run `Chat: Install Plugin From Source` from the Command +Palette and enter `https://github.com/Team-Volt/agent-lcm`. Inspect the result in +the Agent Plugins - Installed view. See the [VS Code agent plugin guide](https://code.visualstudio.com/docs/agent-customization/agent-plugins). + +If VS Code asks you to trust a new marketplace or repository, review the source +before confirming. The official guide does not require a restart. If the plugin +does not appear, use the documented Installed view and Command Palette refresh +actions as troubleshooting. + +> Warning: VS Code and GitHub Copilot share the Copilot plugin store and the hook file `~/.copilot/hooks/agent-lcm.json`. A deliberate native uninstall affects both harnesses. Do not uninstall the shared plugin when you mean to remove only VS Code. + +## Remove Agent LCM + +The safe single-harness command retains the shared plugin and hook resources: + +```sh +agent-lcm remove vscode +``` + +It reports `shared-retained` and leaves the shared store and hook file +unchanged. To deliberately remove the shared native installation from both +harnesses, use the Copilot command: + +```sh +copilot plugin uninstall agent-lcm +``` + +Then manage the shared hook file only after reviewing both harnesses' needs. From 2e2c32529a8fe762294e8f56c2c0951dc5016389 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 00:41:16 -0400 Subject: [PATCH 06/29] feat(setup): install native harness plugins when supported Plan: .omo/plans/native-harness-setup-remove.md --- src/cli.ts | 4 +- src/setup-hook-status.ts | 130 +++++++++++++++ src/setup-hooks.ts | 199 +++++++++++++++++++++++ src/setup.ts | 330 +++++---------------------------------- tests/setup.test.ts | 275 +++++++++++++++++++++++--------- 5 files changed, 569 insertions(+), 369 deletions(-) create mode 100644 src/setup-hook-status.ts create mode 100644 src/setup-hooks.ts diff --git a/src/cli.ts b/src/cli.ts index 6d0ffc6..583bc1c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -357,7 +357,7 @@ function printSetupReports(value: SetupReport | SetupReport[], json: boolean): v return; } for (const report of reports) { - const state = report.changed ? "have been configured" : "are already configured"; - process.stdout.write(`${report.harness} hooks ${state}: ${report.path}\n`); + const state = report.hooks.changed ? "have been configured" : "are already configured"; + process.stdout.write(`${report.harness} hooks ${state}: ${report.hooks.path}\n`); } } diff --git a/src/setup-hook-status.ts b/src/setup-hook-status.ts new file mode 100644 index 0000000..f53a16c --- /dev/null +++ b/src/setup-hook-status.ts @@ -0,0 +1,130 @@ +import path from "node:path"; + +import type { CaptureHarness } from "./harnesses.ts"; + +export const CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "SubagentStop", "Stop"] as const; + +export type KiroHook = { name: string; trigger: string; action: { type: "command"; command: string } }; + +export function setupHooksConfigured( + harness: CaptureHarness, + configuration: Record | undefined, +): boolean { + if (!configuration) return false; + if (harness === "kiro") { + const hooks = configuration.hooks; + if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) return false; + return eventsFor(harness).every((event) => hooks.some((hook) => isExpectedKiroHook(hook, event))); + } + if (harness !== "codex" && configuration.version !== 1) return false; + const hooksByEvent = configuration.hooks; + if (!isRecord(hooksByEvent)) return false; + if (harness === "codex") return CODEX_EVENTS.every((event) => { + const selectors = hooksByEvent[event]; + return Array.isArray(selectors) && selectors.some((selector) => isRecord(selector) + && Array.isArray(selector.hooks) + && selector.hooks.some((hook) => isCodexNativeHook(event) + ? isAgentLcmCodexHook(hook, event) + : isExpectedCommandHook(hook, harness, event))); + }); + if (isSharedHookHarness(harness) && hasSharedPascalRegistration(hooksByEvent)) return false; + return setupEvents(harness).every(([event, captureEvent]) => { + const hooks = hooksByEvent[event]; + return Array.isArray(hooks) && hooks.some((entry) => isExpectedCommandHook(entry, setupCaptureHarness(harness), captureEvent)); + }); +} + +export function eventsFor(harness: CaptureHarness): string[] { + return isSharedHookHarness(harness) + ? ["sessionStart", "userPromptSubmitted", "postToolUse", "sessionEnd"] + : ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]; +} + +export function setupEvents(harness: "cursor" | "vscode" | "copilot"): ReadonlyArray { + return harness === "cursor" + ? [["sessionStart", "SessionStart"], ["beforeSubmitPrompt", "UserPromptSubmit"], ["postToolUse", "PostToolUse"], ["stop", "Stop"]] + : [["sessionStart", "sessionStart"], ["userPromptSubmitted", "userPromptSubmitted"], ["postToolUse", "postToolUse"], ["sessionEnd", "sessionEnd"]]; +} + +export function isSharedHookHarness(harness: CaptureHarness): harness is "copilot" | "vscode" { + return harness === "copilot" || harness === "vscode"; +} + +export function setupCaptureHarness(harness: CaptureHarness): CaptureHarness | "auto" { + return isSharedHookHarness(harness) ? "auto" : harness; +} + +export function isAgentLcmHook(value: Record, event: string, harness: CaptureHarness): boolean { + if ((value.type !== undefined && value.type !== "command") || typeof value.command !== "string") return false; + const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" capture --harness (auto|codex|cursor|copilot|vscode|kiro) (sessionStart|userPromptSubmitted|postToolUse|sessionEnd|SessionStart|UserPromptSubmit|PostToolUse|Stop)$/u + .exec(value.command); + const captureEvent = harness === "cursor" + ? setupEvents("cursor").find(([hookEvent]) => hookEvent === event)?.[1] + : event; + if (!match || match[2] !== captureEvent) return false; + return isSharedHookHarness(harness) + ? match[1] === "auto" || match[1] === "copilot" || match[1] === "vscode" + : match[1] === harness; +} + +export function isAgentLcmCodexHook(value: unknown, event: string): boolean { + if (!isRecord(value) || (value.type !== undefined && value.type !== "command") || typeof value.command !== "string") return false; + const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" hook (PreToolUse|PreCompact|PostCompact|SubagentStop)$/u.exec(value.command); + return match?.[1] === event; +} + +export function isCodexNativeHook(event: string): boolean { + return event === "PreToolUse" || event === "PreCompact" || event === "PostCompact" || event === "SubagentStop"; +} + +export function isKiroHook(value: unknown): value is KiroHook { + return isRecord(value) + && typeof value.name === "string" + && typeof value.trigger === "string" + && isRecord(value.action) + && value.action.type === "command" + && typeof value.action.command === "string"; +} + +export function assertSafeSetupCommand(command: string): void { + if (!command) throw new Error("setup command must not be empty"); + if (!path.isAbsolute(command) && !/^[A-Za-z]:[\\/]/u.test(command)) throw new Error("setup command must be an absolute binary path"); + if (/["'`$;&|<>\n\r%^]/u.test(command) || command.endsWith("\\")) throw new Error("setup command contains unsafe shell characters"); +} + +function isExpectedCommandHook(value: unknown, harness: CaptureHarness | "auto", event: string): boolean { + return isRecord(value) && (value.type === undefined || value.type === "command") && isCaptureCommand(value.command, harness, event); +} + +function hasSharedPascalRegistration(hooksByEvent: Record): boolean { + return ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"].some((event) => { + const hooks = hooksByEvent[event]; + return Array.isArray(hooks) && hooks.some((hook) => isRecord(hook) && hook.type === "command" && isAgentLcmHook(hook, event, "vscode")); + }); +} + +function isExpectedKiroHook(value: unknown, event: string): boolean { + return isKiroHook(value) + && value.name === `agent-lcm-kiro-${event}` + && value.trigger === event + && isCaptureCommand(value.action.command, "kiro", event); +} + +function isCaptureCommand(value: unknown, harness: CaptureHarness | "auto", event: string): boolean { + if (typeof value !== "string") return false; + const prefix = 'node "'; + const suffix = ` capture --harness ${harness} ${event}`; + if (!value.startsWith(prefix) || !value.endsWith(suffix)) return false; + const quoteEnd = value.length - suffix.length - 1; + if (value[quoteEnd] !== "\"") return false; + try { + assertSafeSetupCommand(value.slice(prefix.length, quoteEnd)); + return true; + } catch { + return false; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/setup-hooks.ts b/src/setup-hooks.ts new file mode 100644 index 0000000..ba86829 --- /dev/null +++ b/src/setup-hooks.ts @@ -0,0 +1,199 @@ +import type { CaptureHarness } from "./harnesses.ts"; +import { + CODEX_EVENTS, + eventsFor, + isAgentLcmCodexHook, + isAgentLcmHook, + isCodexNativeHook, + isKiroHook, + isSharedHookHarness, + setupCaptureHarness, + setupEvents, + type KiroHook, +} from "./setup-hook-status.ts"; + +export function mergeSetupHooks( + existing: Record | undefined, + harness: CaptureHarness, + command: string, + target: string, +): Record { + if (harness === "kiro") return mergeKiroConfiguration(existing, command, target); + if (harness === "codex") return mergeCodexConfiguration(existing, command, target); + return mergeFlatConfiguration(existing, harness, command, target); +} + +function mergeCodexConfiguration( + existing: Record | undefined, + command: string, + target: string, +): Record { + const configuration = existing ? structuredClone(existing) : { hooks: {} }; + if (!isRecord(configuration.hooks)) throw invalidConfiguration(target); + if (!Object.values(configuration.hooks).every(isCodexSelectors)) throw invalidConfiguration(target); + + for (const event of CODEX_EVENTS) { + const expectedCommand = isCodexNativeHook(event) + ? `node "${command}" hook ${event}` + : captureCommand(command, "codex", event); + const selectors = configuration.hooks[event]; + if (selectors === undefined) { + configuration.hooks[event] = [{ + ...(event === "PreToolUse" ? { matcher: ".*" } : {}), + hooks: [{ type: "command", command: expectedCommand }], + }]; + continue; + } + if (!isCodexSelectors(selectors)) throw invalidConfiguration(target); + let found = false; + for (const selector of selectors) { + if (!Array.isArray(selector.hooks) || !selector.hooks.every(isRecord)) throw invalidConfiguration(target); + for (const hook of selector.hooks) { + if (!(isCodexNativeHook(event) ? isAgentLcmCodexHook(hook, event) : isAgentLcmHook(hook, event, "codex"))) continue; + hook.type = "command"; + hook.command = expectedCommand; + found = true; + } + } + if (!found) selectors.push({ + ...(event === "PreToolUse" ? { matcher: ".*" } : {}), + hooks: [{ type: "command", command: expectedCommand }], + }); + } + return configuration; +} + +function mergeFlatConfiguration( + existing: Record | undefined, + harness: "cursor" | "vscode" | "copilot", + command: string, + target: string, +): Record { + const configuration = existing ? structuredClone(existing) : { version: 1, hooks: {} }; + if (configuration.version !== 1 || !isRecord(configuration.hooks)) throw invalidConfiguration(target); + if (!Object.values(configuration.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { + throw invalidConfiguration(target); + } + for (const [event, captureEvent] of setupEvents(harness)) { + const expectedHooks = takeAgentLcmHooks(configuration.hooks, harness, event); + if (expectedHooks.length === 0) expectedHooks.push({}); + for (const expected of expectedHooks) { + if (harness !== "cursor") expected.type = "command"; + expected.command = captureCommand(command, setupCaptureHarness(harness), captureEvent); + } + const hooks = configuration.hooks[event] as Record[] | undefined; + if (hooks === undefined) configuration.hooks[event] = expectedHooks; + else hooks.push(...expectedHooks); + } + return configuration; +} + +function mergeKiroConfiguration(existing: Record | undefined, command: string, target: string): Record { + const configuration: Record = existing ? structuredClone(existing) : { version: "v1", hooks: [] }; + const hooks = configuration.hooks; + if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) { + throw invalidConfiguration(target); + } + const kiroHooks = hooks as KiroHook[]; + for (const event of eventsFor("kiro")) { + const expected = kiroHook(command, event); + const owned = kiroHooks.filter((hook) => hook.name === expected.name + && hook.trigger === event + && isAgentLcmHook(hook.action, event, "kiro")); + if (owned.length === 0) kiroHooks.push(expected); + for (const hook of owned) { + hook.action.type = "command"; + hook.action.command = expected.action.command; + } + } + return configuration; +} + +function takeAgentLcmHooks( + hooksByEvent: Record, + harness: "cursor" | "vscode" | "copilot", + event: string, +): Record[] { + const found: Record[] = []; + const candidates = isSharedHookHarness(harness) ? [event, sharedLegacyEvent(event)] : [event]; + for (const candidate of candidates) { + const hooks = hooksByEvent[candidate]; + if (!Array.isArray(hooks)) continue; + const kept = hooks.filter((hook) => { + if (!isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)) return true; + found.push(hook); + return false; + }); + if (kept.length === 0) delete hooksByEvent[candidate]; + else hooksByEvent[candidate] = kept; + } + return found; +} + +function sharedLegacyEvent(event: string): string { + return ({ + sessionStart: "SessionStart", + userPromptSubmitted: "UserPromptSubmit", + postToolUse: "PostToolUse", + sessionEnd: "Stop", + } as Record)[event] ?? event; +} + +function kiroHook(command: string, event: string): KiroHook { + return { + name: `agent-lcm-kiro-${event}`, + trigger: event, + action: { type: "command", command: captureCommand(command, "kiro", event) }, + }; +} + +function captureCommand(command: string, harness: CaptureHarness | "auto", event: string): string { + return `node "${command}" capture --harness ${harness} ${event}`; +} + +function isCodexSelectors(value: unknown): value is Array & { hooks: Record[] }> { + return Array.isArray(value) && value.every((selector) => isRecord(selector) + && Array.isArray(selector.hooks) + && selector.hooks.every(isRecord)); +} + +function invalidConfiguration(target: string): Error { + return new Error(`Cannot update invalid setup configuration: ${target}`); +} + +export function validateSetupHooks( + harness: CaptureHarness, + configuration: Record | undefined, + target: string, +): void { + if (configuration !== undefined) mergeSetupHooks(configuration, harness, "/agent-lcm", target); +} + +export function removeSharedSetupHooks( + configuration: Record, + harness: "copilot" | "vscode", + target: string, +): Record { + const next = structuredClone(configuration); + if (next.version !== 1 || !isRecord(next.hooks) + || !Object.values(next.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { + throw invalidConfiguration(target); + } + for (const [event] of setupEvents(harness)) removeSharedHooks(next.hooks, harness, event); + return next; +} + +function removeSharedHooks( + hooksByEvent: Record, + harness: "copilot" | "vscode", + event: string, +): void { + for (const candidate of [event, sharedLegacyEvent(event)]) { + const hooks = hooksByEvent[candidate]; + if (Array.isArray(hooks)) hooksByEvent[candidate] = hooks.filter((hook) => !isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/setup.ts b/src/setup.ts index e0c72b7..850079b 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,226 +1,65 @@ -import path from "node:path"; - import type { CaptureHarness } from "./harnesses.ts"; +import { runHarnessLifecycle } from "./setup-adapters.ts"; import { mutateSetupConfiguration, readSetupConfiguration } from "./setup-files.ts"; +import { assertSafeSetupCommand, setupHooksConfigured } from "./setup-hook-status.ts"; +import { + mergeSetupHooks, + removeSharedSetupHooks, + validateSetupHooks, +} from "./setup-hooks.ts"; import { SETUP_HARNESSES, setupPath } from "./setup-targets.ts"; -export type SetupOptions = { home?: string; command: string }; -export type SetupReport = { harness: CaptureHarness; path: string; changed: boolean }; -export type SetupStatusOptions = { home?: string }; - -export type HarnessSetupStatus = { configured: boolean; path: string }; - -const CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "SubagentStop", "Stop"] as const; +export type SetupOptions = { readonly home?: string; readonly command: string; readonly env?: NodeJS.ProcessEnv }; +export type SetupReport = { + readonly harness: CaptureHarness; + readonly action: "setup"; + readonly status: "complete" | "manual-required"; + readonly nativeCli: "codex" | "copilot" | null; + readonly hooks: { readonly path: string; readonly changed: boolean }; + readonly guide: string; +}; +export type SetupStatusOptions = { readonly home?: string }; +export type HarnessSetupStatus = { readonly configured: boolean; readonly path: string }; export function setupHarness(harness: CaptureHarness, options: SetupOptions): SetupReport { const target = setupPath(harness, options.home); const command = options.command.trim(); - assertSafeCommand(command); - const changed = mutateSetupConfiguration(target, (existing) => mergeConfiguration(existing, harness, command, target)); - return { harness, path: target, changed }; + assertSafeSetupCommand(command); + const existing = readSetupConfiguration(target); + validateSetupHooks(harness, existing, target); + const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env } : {}); + const changed = updateHooks(harness, native.status, target, command, existing !== undefined); + return { + harness, + action: "setup", + status: native.status === "native-complete" ? "complete" : "manual-required", + nativeCli: native.nativeCli, + hooks: { path: target, changed }, + guide: native.guide, + }; } export function setupStatus(options: SetupStatusOptions = {}): Record { return Object.fromEntries(SETUP_HARNESSES.map((harness) => { const target = setupPath(harness, options.home); - return [harness, { configured: configured(harness, target), path: target }]; + return [harness, { configured: setupHooksConfigured(harness, readConfigurationForStatus(target)), path: target }]; })) as Record; } -function mergeConfiguration( - existing: Record | undefined, +function updateHooks( harness: CaptureHarness, - command: string, - target: string, -): Record { - if (harness === "kiro") return mergeKiroConfiguration(existing, command, target); - if (harness === "codex") return mergeCodexConfiguration(existing, command, target); - return mergeFlatConfiguration(existing, harness, command, target); -} - -function mergeCodexConfiguration( - existing: Record | undefined, - command: string, + nativeStatus: "native-complete" | "manual-required" | "shared-retained", target: string, -): Record { - const configuration = existing ? structuredClone(existing) : { hooks: {} }; - if (!isRecord(configuration.hooks)) throw invalidConfiguration(target); - if (!Object.values(configuration.hooks).every(isCodexSelectors)) throw invalidConfiguration(target); - - for (const event of CODEX_EVENTS) { - const expectedCommand = isCodexNativeHook(event) - ? `node "${command}" hook ${event}` - : captureCommand(command, "codex", event); - const selectors = configuration.hooks[event]; - if (selectors === undefined) { - configuration.hooks[event] = [{ - ...(event === "PreToolUse" ? { matcher: ".*" } : {}), - hooks: [{ type: "command", command: expectedCommand }], - }]; - continue; - } - if (!isCodexSelectors(selectors)) throw invalidConfiguration(target); - let found = false; - for (const selector of selectors) { - if (!Array.isArray(selector.hooks) || !selector.hooks.every(isRecord)) throw invalidConfiguration(target); - for (const hook of selector.hooks) { - if (!(isCodexNativeHook(event) ? isAgentLcmCodexHook(hook, event) : isAgentLcmHook(hook, event, "codex"))) continue; - hook.type = "command"; - hook.command = expectedCommand; - found = true; - } - } - if (!found) selectors.push({ - ...(event === "PreToolUse" ? { matcher: ".*" } : {}), - hooks: [{ type: "command", command: expectedCommand }], - }); - } - return configuration; -} - -function mergeFlatConfiguration( - existing: Record | undefined, - harness: "cursor" | "vscode" | "copilot", command: string, - target: string, -): Record { - const configuration = existing ? structuredClone(existing) : { version: 1, hooks: {} }; - if (configuration.version !== 1 || !isRecord(configuration.hooks)) throw invalidConfiguration(target); - if (!Object.values(configuration.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { - throw invalidConfiguration(target); - } - for (const [event, captureEvent] of setupEvents(harness)) { - const expectedHooks = takeAgentLcmHooks(configuration.hooks, harness, event); - if (expectedHooks.length === 0) expectedHooks.push({}); - for (const expected of expectedHooks) { - if (harness !== "cursor") expected.type = "command"; - expected.command = captureCommand(command, setupCaptureHarness(harness), captureEvent); - } - const hooks = configuration.hooks[event] as Record[] | undefined; - if (hooks === undefined) configuration.hooks[event] = expectedHooks; - else hooks.push(...expectedHooks); - } - return configuration; -} - -function mergeKiroConfiguration(existing: Record | undefined, command: string, target: string): Record { - const configuration: Record = existing ? structuredClone(existing) : { version: "v1", hooks: [] }; - const hooks = configuration.hooks; - if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) { - throw invalidConfiguration(target); - } - const kiroHooks = hooks as KiroHook[]; - for (const event of eventsFor("kiro")) { - const expected = kiroHook(command, event); - const owned = kiroHooks.filter((hook) => hook.name === expected.name - && hook.trigger === event - && isAgentLcmHook(hook.action, event, "kiro")); - if (owned.length === 0) kiroHooks.push(expected); - for (const hook of owned) { - hook.action.type = "command"; - hook.action.command = expected.action.command; - } - } - return configuration; -} - -function eventsFor(harness: CaptureHarness): string[] { - return isSharedHookHarness(harness) - ? ["sessionStart", "userPromptSubmitted", "postToolUse", "sessionEnd"] - : ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]; -} - -function setupEvents(harness: "cursor" | "vscode" | "copilot"): ReadonlyArray { - return harness === "cursor" - ? [["sessionStart", "SessionStart"], ["beforeSubmitPrompt", "UserPromptSubmit"], ["postToolUse", "PostToolUse"], ["stop", "Stop"]] - : [["sessionStart", "sessionStart"], ["userPromptSubmitted", "userPromptSubmitted"], ["postToolUse", "postToolUse"], ["sessionEnd", "sessionEnd"]]; -} - -function isSharedHookHarness(harness: CaptureHarness): harness is "copilot" | "vscode" { - return harness === "copilot" || harness === "vscode"; -} - -function setupCaptureHarness(harness: CaptureHarness): CaptureHarness | "auto" { - return isSharedHookHarness(harness) ? "auto" : harness; -} - -function takeAgentLcmHooks( - hooksByEvent: Record, - harness: "cursor" | "vscode" | "copilot", - event: string, -): Record[] { - const found: Record[] = []; - const candidates = isSharedHookHarness(harness) ? [event, sharedLegacyEvent(event)] : [event]; - for (const candidate of candidates) { - const hooks = hooksByEvent[candidate]; - if (!Array.isArray(hooks)) continue; - const kept = hooks.filter((hook) => { - if (!isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)) return true; - found.push(hook); - return false; - }); - if (kept.length === 0) delete hooksByEvent[candidate]; - else hooksByEvent[candidate] = kept; - } - return found; -} - -function sharedLegacyEvent(event: string): string { - return ({ - sessionStart: "SessionStart", - userPromptSubmitted: "UserPromptSubmit", - postToolUse: "PostToolUse", - sessionEnd: "Stop", - } as Record)[event] ?? event; -} - -function kiroHook(command: string, event: string): KiroHook { - return { - name: `agent-lcm-kiro-${event}`, - trigger: event, - action: { type: "command", command: captureCommand(command, "kiro", event) }, - }; -} - -function captureCommand(command: string, harness: CaptureHarness | "auto", event: string): string { - return `node "${command}" capture --harness ${harness} ${event}`; -} - -function assertSafeCommand(command: string): void { - if (!command) throw new Error("setup command must not be empty"); - if (!path.isAbsolute(command) && !/^[A-Za-z]:[\\/]/u.test(command)) { - throw new Error("setup command must be an absolute binary path"); - } - if (/["'`$;&|<>\n\r%^]/u.test(command) || command.endsWith("\\")) { - throw new Error("setup command contains unsafe shell characters"); + targetExists: boolean, +): boolean { + if (harness === "codex" || harness === "kiro") { + return mutateSetupConfiguration(target, (existing) => mergeSetupHooks(existing, harness, command, target)); } -} - -function configured(harness: CaptureHarness, target: string): boolean { - const configuration = readConfigurationForStatus(target); - if (!configuration) return false; - if (harness === "kiro") { - const hooks = configuration.hooks; - if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) return false; - const kiroHooks = hooks as KiroHook[]; - return eventsFor(harness).every((event) => kiroHooks.some((hook) => isExpectedKiroHook(hook, event))); + if ((harness === "copilot" || harness === "vscode") && nativeStatus === "native-complete" && targetExists) { + return mutateSetupConfiguration(target, (existing) => removeSharedSetupHooks(existing ?? {}, harness, target)); } - if (harness !== "codex" && configuration.version !== 1) return false; - const hooksByEvent = configuration.hooks; - if (!isRecord(hooksByEvent)) return false; - if (harness === "codex") return CODEX_EVENTS.every((event) => { - const selectors = hooksByEvent[event]; - return Array.isArray(selectors) && selectors.some((selector) => isRecord(selector) - && Array.isArray(selector.hooks) - && selector.hooks.some((hook) => isCodexNativeHook(event) - ? isAgentLcmCodexHook(hook, event) - : isExpectedCommandHook(hook, harness, event))); - }); - if (isSharedHookHarness(harness) && hasSharedPascalRegistration(hooksByEvent)) return false; - return setupEvents(harness).every(([event, captureEvent]) => { - const hooks = hooksByEvent[event]; - return Array.isArray(hooks) && hooks.some((entry) => isExpectedCommandHook(entry, setupCaptureHarness(harness), captureEvent)); - }); + return false; } function readConfigurationForStatus(target: string): Record | undefined { @@ -230,90 +69,3 @@ function readConfigurationForStatus(target: string): Record | u return undefined; } } - -function isExpectedCommandHook(value: unknown, harness: CaptureHarness | "auto", event: string): boolean { - return isRecord(value) && (value.type === undefined || value.type === "command") && isCaptureCommand(value.command, harness, event); -} - -function isCodexSelectors(value: unknown): value is Array & { hooks: Record[] }> { - return Array.isArray(value) && value.every((selector) => isRecord(selector) - && Array.isArray(selector.hooks) - && selector.hooks.every(isRecord)); -} - -function isAgentLcmHook(value: Record, event: string, harness: CaptureHarness): boolean { - if ((value.type !== undefined && value.type !== "command") || typeof value.command !== "string") return false; - const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" capture --harness (auto|codex|cursor|copilot|vscode|kiro) (sessionStart|userPromptSubmitted|postToolUse|sessionEnd|SessionStart|UserPromptSubmit|PostToolUse|Stop)$/u - .exec(value.command); - const captureEvent = harness === "cursor" - ? setupEvents("cursor").find(([hookEvent]) => hookEvent === event)?.[1] - : event; - if (!match || match[2] !== captureEvent) return false; - return isSharedHookHarness(harness) - ? match[1] === "auto" || match[1] === "copilot" || match[1] === "vscode" - : match[1] === harness; -} - -function isAgentLcmCodexHook(value: unknown, event: string): boolean { - if (!isRecord(value) || (value.type !== undefined && value.type !== "command") || typeof value.command !== "string") { - return false; - } - const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" hook (PreToolUse|PreCompact|PostCompact|SubagentStop)$/u.exec(value.command); - return match?.[1] === event; -} - -function isCodexNativeHook(event: string): boolean { - return event === "PreToolUse" || event === "PreCompact" || event === "PostCompact" || event === "SubagentStop"; -} - -function hasSharedPascalRegistration(hooksByEvent: Record): boolean { - return ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"].some((event) => { - const hooks = hooksByEvent[event]; - return Array.isArray(hooks) && hooks.some((hook) => { - if (!isRecord(hook) || hook.type !== "command" || typeof hook.command !== "string") return false; - return isAgentLcmHook(hook, event, "vscode"); - }); - }); -} - -function isExpectedKiroHook(value: unknown, event: string): boolean { - return isKiroHook(value) - && value.name === `agent-lcm-kiro-${event}` - && value.trigger === event - && isCaptureCommand(value.action.command, "kiro", event); -} - -type KiroHook = { name: string; trigger: string; action: { type: "command"; command: string } }; - -function isKiroHook(value: unknown): value is KiroHook { - return isRecord(value) - && typeof value.name === "string" - && typeof value.trigger === "string" - && isRecord(value.action) - && value.action.type === "command" - && typeof value.action.command === "string"; -} - -function isCaptureCommand(value: unknown, harness: CaptureHarness | "auto", event: string): boolean { - if (typeof value !== "string") return false; - const prefix = 'node "'; - const suffix = ` capture --harness ${harness} ${event}`; - if (!value.startsWith(prefix) || !value.endsWith(suffix)) return false; - const quoteEnd = value.length - suffix.length - 1; - if (value[quoteEnd] !== "\"") return false; - const command = value.slice(prefix.length, quoteEnd); - try { - assertSafeCommand(command); - return true; - } catch { - return false; - } -} - -function invalidConfiguration(target: string): Error { - return new Error(`Cannot update invalid setup configuration: ${target}`); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 6051c2e..8d8b2f0 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -7,6 +7,131 @@ import { writeSetupConfiguration } from "../src/setup-files.ts"; import { setupHarness, setupStatus } from "../src/setup.ts"; import { assertCliOk, runCli, tempHome } from "./helpers.ts"; +const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; + +test("setup validates an existing hook schema before starting the native CLI", (t) => { + // Given: malformed Codex hooks and a fake CLI that records every process start. + const fake = fakeSetupCli(t, "codex"); + const clientHome = tempHome("agent-lcm-setup-order-"); + fs.writeFileSync(path.join(clientHome, "hooks.json"), '{"hooks":{"CustomEvent":"invalid"}}\n'); + + // When: Codex setup is requested. + const run = () => setupHarness("codex", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: fake.env, + }); + + // Then: validation fails and no native process starts. + assert.throws(run, /invalid setup configuration/u); + assert.equal(fs.existsSync(fake.log), false); +}); + +test("Codex setup runs native setup and still installs user hooks", (t) => { + // Given: a capable native Codex CLI and an empty Codex home. + const fake = fakeSetupCli(t, "codex"); + const clientHome = tempHome("agent-lcm-codex-native-"); + + // When: Codex setup completes. + const report = setupHarness("codex", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: fake.env, + }); + + // Then: the stable report records both native and user-hook completion. + assert.deepEqual(report, { + harness: "codex", + action: "setup", + status: "complete", + nativeCli: "codex", + hooks: { path: path.join(clientHome, "hooks.json"), changed: true }, + guide: `${GUIDE_ROOT}/codex.md`, + }); + assert.deepEqual(readSetupCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "add", "agent-lcm@agent-lcm"], + ]); + assert.equal(fs.existsSync(path.join(clientHome, "hooks.json")), true); +}); + +test("successful Copilot setup removes only legacy shared Agent LCM hooks", (t) => { + // Given: a capable Copilot CLI and a shared hook file with owned and unrelated hooks. + const fake = fakeSetupCli(t, "copilot"); + const clientHome = tempHome("agent-lcm-copilot-native-"); + const hooksPath = path.join(clientHome, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(hooksPath), { recursive: true }); + fs.writeFileSync(hooksPath, JSON.stringify({ version: 1, hooks: { + sessionStart: [ + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness auto sessionStart' }, + { type: "command", command: "keep-me" }, + ], + userPromptSubmitted: [ + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness auto userPromptSubmitted' }, + ], + PostToolUse: [ + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness vscode PostToolUse' }, + ], + custom: [{ type: "command", command: "also-keep-me" }], + } })); + + // When: native Copilot setup succeeds. + const report = setupHarness("copilot", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: fake.env, + }); + + // Then: the legacy registration is removed without touching sibling hooks. + assert.deepEqual(report, { + harness: "copilot", + action: "setup", + status: "complete", + nativeCli: "copilot", + hooks: { path: hooksPath, changed: true }, + guide: `${GUIDE_ROOT}/copilot.md`, + }); + assert.deepEqual(JSON.parse(fs.readFileSync(hooksPath, "utf8")), { version: 1, hooks: { + sessionStart: [{ type: "command", command: "keep-me" }], + userPromptSubmitted: [], + PostToolUse: [], + custom: [{ type: "command", command: "also-keep-me" }], + } }); +}); + +test("manual setup preserves legacy shared hooks and creates no target", (t) => { + // Given: an empty PATH, one existing shared target, and one absent Cursor target. + const emptyBin = fs.mkdtempSync(path.join(tempHome("agent-lcm-empty-cli-"), "bin")); + t.after(() => fs.rmSync(path.dirname(emptyBin), { recursive: true, force: true })); + const clientHome = tempHome("agent-lcm-manual-shared-"); + const hooksPath = path.join(clientHome, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(hooksPath), { recursive: true }); + const original = '{"version":1,"hooks":{"sessionStart":[{"command":"node \\"/old/bin/agent-lcm\\" capture --harness auto sessionStart"}]}}\n'; + fs.writeFileSync(hooksPath, original); + + // When: shared and Cursor setup both require manual work. + const shared = setupHarness("vscode", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: { PATH: emptyBin }, + }); + const cursorHome = tempHome("agent-lcm-manual-cursor-"); + const cursor = setupHarness("cursor", { + home: cursorHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: { PATH: emptyBin }, + }); + + // Then: both report manual work, preserving or omitting files as found. + assert.equal(shared.status, "manual-required"); + assert.deepEqual(shared.hooks, { path: hooksPath, changed: false }); + assert.equal(fs.readFileSync(hooksPath, "utf8"), original); + assert.equal(cursor.status, "manual-required"); + assert.deepEqual(cursor.hooks, { path: path.join(cursorHome, "hooks.json"), changed: false }); + assert.equal(fs.existsSync(path.join(cursorHome, "hooks.json")), false); +}); + test("Kiro setup uses the native array schema, is repeatable, and leaves sibling hooks unchanged", () => { const kiroHome = tempHome("agent-lcm-kiro-"); const unrelatedKiroHook = path.join(kiroHome, "hooks", "other.json"); @@ -17,11 +142,11 @@ test("Kiro setup uses the native array schema, is repeatable, and leaves sibling const first = setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }); const second = setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }); - assert.equal(first.changed, true); - assert.equal(second.changed, false); - assert.equal(first.path, path.join(kiroHome, "hooks", "agent-lcm.json")); + assert.equal(first.hooks.changed, true); + assert.equal(second.hooks.changed, false); + assert.equal(first.hooks.path, path.join(kiroHome, "hooks", "agent-lcm.json")); assert.deepEqual(fs.readFileSync(unrelatedKiroHook), original); - const configuration = JSON.parse(fs.readFileSync(first.path, "utf8")); + const configuration = JSON.parse(fs.readFileSync(first.hooks.path, "utf8")); assert.equal(configuration.version, "v1"); assert.equal(Array.isArray(configuration.hooks), true); assert.equal(configuration.hooks.length, 4); @@ -44,7 +169,7 @@ test("setup leaves invalid owned configuration untouched", () => { new RegExp(setupPath.replace(/[\\^$.*+?()[\]{}|]/gu, "\\$&"), "u"), ); assert.deepEqual(fs.readFileSync(setupPath), original); - assert.deepEqual(fs.readdirSync(path.dirname(setupPath)).sort(), ["agent-lcm.json", "agent-lcm.json.lock.sqlite"]); + assert.deepEqual(fs.readdirSync(path.dirname(setupPath)).sort(), ["agent-lcm.json"]); }); test("setup rejects malformed Kiro schema without changing the owned file", () => { @@ -56,7 +181,7 @@ test("setup rejects malformed Kiro schema without changing the owned file", () = assert.throws(() => setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }), /invalid setup configuration/u); assert.deepEqual(fs.readFileSync(setupPath), original); - assert.deepEqual(fs.readdirSync(path.dirname(setupPath)).sort(), ["agent-lcm.json", "agent-lcm.json.lock.sqlite"]); + assert.deepEqual(fs.readdirSync(path.dirname(setupPath)).sort(), ["agent-lcm.json"]); }); test("setup rejects malformed Codex custom events without changing or backing up the file", () => { @@ -70,34 +195,26 @@ test("setup rejects malformed Codex custom events without changing or backing up /invalid setup configuration/u, ); assert.deepEqual(fs.readFileSync(setupPath), original); - assert.deepEqual(fs.readdirSync(clientHome).sort(), ["hooks.json", "hooks.json.lock.sqlite"]); + assert.deepEqual(fs.readdirSync(clientHome).sort(), ["hooks.json"]); }); -test("Copilot and VS Code converge on one lower-camel shared user hook configuration", () => { +test("manual Copilot and VS Code setup creates no shared user hook configuration", () => { const clientHome = tempHome("agent-lcm-copilot-"); - const copilot = setupHarness("copilot", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm" }); - const vscode = setupHarness("vscode", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm" }); - - assert.equal(copilot.path, path.join(clientHome, "hooks", "agent-lcm.json")); - assert.equal(vscode.path, copilot.path); - assert.equal(vscode.changed, false); - const configuration = JSON.parse(fs.readFileSync(copilot.path, "utf8")); - assert.equal(configuration.version, 1); - assert.equal(configuration.hooks.userPromptSubmitted[0].type, "command"); - assert.equal( - configuration.hooks.userPromptSubmitted[0].command, - 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness auto userPromptSubmitted', - ); - assert.deepEqual(Object.keys(configuration.hooks).sort(), ["postToolUse", "sessionEnd", "sessionStart", "userPromptSubmitted"]); - assert.equal(setupStatus({ home: clientHome }).copilot.configured, true); - assert.equal(setupStatus({ home: clientHome }).vscode.configured, true); - - fs.writeFileSync(copilot.path, '{"version":1,"hooks":{"userPromptSubmitted":[{"command":"agent-lcm"}]}}\n'); - assert.equal(setupStatus({ home: clientHome }).copilot.configured, false); + const env = { PATH: path.join(clientHome, "empty-bin") }; + const copilot = setupHarness("copilot", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm", env }); + const vscode = setupHarness("vscode", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm", env }); + + assert.equal(copilot.status, "manual-required"); + assert.equal(vscode.status, "manual-required"); + assert.equal(copilot.hooks.path, path.join(clientHome, "hooks", "agent-lcm.json")); + assert.equal(vscode.hooks.path, copilot.hooks.path); + assert.equal(vscode.hooks.changed, false); + assert.equal(fs.existsSync(copilot.hooks.path), false); }); -test("shared setup replaces older Agent LCM registrations after a binary move without touching sibling hooks", () => { +test("native shared setup removes older Agent LCM registrations without touching sibling hooks", (t) => { const clientHome = tempHome("agent-lcm-copilot-legacy-"); + const fake = fakeSetupCli(t, "copilot"); const setupPath = path.join(clientHome, "hooks", "agent-lcm.json"); fs.mkdirSync(path.dirname(setupPath), { recursive: true }); fs.writeFileSync(setupPath, JSON.stringify({ @@ -134,12 +251,12 @@ test("shared setup replaces older Agent LCM registrations after a binary move wi }, })); - const first = setupHarness("vscode", { home: clientHome, command: "/new-location/bin/agent-lcm" }); - const second = setupHarness("copilot", { home: clientHome, command: "/new-location/bin/agent-lcm" }); + const first = setupHarness("vscode", { home: clientHome, command: "/new-location/bin/agent-lcm", env: fake.env }); + const second = setupHarness("copilot", { home: clientHome, command: "/new-location/bin/agent-lcm", env: fake.env }); const configuration = JSON.parse(fs.readFileSync(setupPath, "utf8")); - assert.equal(first.changed, true); - assert.equal(second.changed, false); + assert.equal(first.hooks.changed, true); + assert.equal(second.hooks.changed, false); assert.equal(configuration.owner, "user"); assert.deepEqual(configuration.hooks.UserPromptSubmit, [ { type: "command", command: "\"/opt/custom-agent-lcm\" capture --harness vscode UserPromptSubmit" }, @@ -150,19 +267,8 @@ test("shared setup replaces older Agent LCM registrations after a binary move wi timeout: 30, metadata: { owner: "user" }, }); - assert.equal(configuration.hooks.sessionStart[1].command, "node \"/new-location/bin/agent-lcm\" capture --harness auto sessionStart"); - assert.deepEqual(configuration.hooks.userPromptSubmitted[0], { - type: "command", - command: 'node "/new-location/bin/agent-lcm" capture --harness auto userPromptSubmitted', - timeout: 45, - metadata: { keep: true }, - }); - assert.deepEqual(configuration.hooks.userPromptSubmitted[1], { - type: "command", - command: 'node "/new-location/bin/agent-lcm" capture --harness auto userPromptSubmitted', - timeout: 60, - metadata: { keep: "duplicate" }, - }); + assert.equal(configuration.hooks.sessionStart.length, 1); + assert.equal(configuration.hooks.userPromptSubmitted, undefined); assert.deepEqual(configuration.hooks.customEvent, [{ type: "command", command: "custom-hook", custom: true }]); assert.deepEqual(configuration.hooks.customCaptureEvent, [{ type: "command", @@ -171,8 +277,9 @@ test("shared setup replaces older Agent LCM registrations after a binary move wi }]); }); -test("Codex setup replaces its old Agent LCM commands and preserves unrelated hooks", () => { +test("Codex setup replaces its old Agent LCM commands and preserves unrelated hooks", (t) => { const clientHome = tempHome("agent-lcm-codex-legacy-"); + const fake = fakeSetupCli(t, "codex"); const setupPath = path.join(clientHome, "hooks.json"); fs.mkdirSync(path.dirname(setupPath), { recursive: true }); const original = JSON.stringify({ owner: "user", hooks: { @@ -200,12 +307,12 @@ test("Codex setup replaces its old Agent LCM commands and preserves unrelated ho } }); fs.writeFileSync(setupPath, original); - const first = setupHarness("codex", { home: clientHome, command: "/new/bin/agent-lcm" }); - const second = setupHarness("codex", { home: clientHome, command: "/new/bin/agent-lcm" }); + const first = setupHarness("codex", { home: clientHome, command: "/new/bin/agent-lcm", env: fake.env }); + const second = setupHarness("codex", { home: clientHome, command: "/new/bin/agent-lcm", env: fake.env }); const configuration = JSON.parse(fs.readFileSync(setupPath, "utf8")); - assert.equal(first.changed, true); - assert.equal(second.changed, false); + assert.equal(first.hooks.changed, true); + assert.equal(second.hooks.changed, false); assert.equal(configuration.owner, "user"); assert.deepEqual(configuration.hooks.SessionStart, [{ matcher: "*", hooks: [ { @@ -246,7 +353,7 @@ test("Codex setup replaces its old Agent LCM commands and preserves unrelated ho assert.equal(fs.readFileSync(path.join(clientHome, backups[0] ?? ""), "utf8"), original); }); -test("Cursor setup writes the user hooks file in Cursor's native schema", () => { +test("Cursor setup validates and preserves its legacy user hooks", () => { const clientHome = tempHome("agent-lcm-cursor-"); const hooksPath = path.join(clientHome, "hooks.json"); fs.writeFileSync(hooksPath, JSON.stringify({ version: 1, owner: "user", hooks: { @@ -256,38 +363,27 @@ test("Cursor setup writes the user hooks file in Cursor's native schema", () => metadata: { owner: "user" }, }], } })); + const original = fs.readFileSync(hooksPath); const report = setupHarness("cursor", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm" }); - assert.equal(report.path, path.join(clientHome, "hooks.json")); - assert.deepEqual(JSON.parse(fs.readFileSync(report.path, "utf8")), { - version: 1, - owner: "user", - hooks: { - sessionStart: [{ command: 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness cursor SessionStart' }], - beforeSubmitPrompt: [{ command: 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness cursor UserPromptSubmit' }], - postToolUse: [{ command: 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness cursor PostToolUse' }], - stop: [ - { - command: 'node "/opt/not-agent-lcm" capture --harness cursor Stop', - timeout: 30, - metadata: { owner: "user" }, - }, - { command: 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness cursor Stop' }, - ], - }, - }); + assert.equal(report.hooks.path, path.join(clientHome, "hooks.json")); + assert.equal(report.hooks.changed, false); + assert.deepEqual(fs.readFileSync(report.hooks.path), original); }); test("setup all configures only harnesses already installed for the user", () => { const userHome = tempHome("agent-lcm-detected-"); fs.mkdirSync(path.join(userHome, ".codex")); - const result = runCli(["setup", "all", "--json"], { env: { HOME: userHome, USERPROFILE: userHome } }); + const result = runCli(["setup", "all", "--json"], { env: { HOME: userHome, USERPROFILE: userHome, PATH: "" } }); assertCliOk(result); assert.deepEqual(JSON.parse(result.stdout), [{ harness: "codex", - path: path.join(userHome, ".codex", "hooks.json"), - changed: true, + action: "setup", + status: "manual-required", + nativeCli: "codex", + hooks: { path: path.join(userHome, ".codex", "hooks.json"), changed: true }, + guide: `${GUIDE_ROOT}/codex.md`, }]); assert.equal(fs.existsSync(path.join(userHome, ".cursor")), false); assert.equal(fs.existsSync(path.join(userHome, ".copilot")), false); @@ -296,21 +392,25 @@ test("setup all configures only harnesses already installed for the user", () => test("setup prints a clear result for people and keeps JSON output for scripts", () => { const userHome = tempHome("agent-lcm-output-"); - const text = runCli(["setup", "codex", "--home", userHome]); + const text = runCli(["setup", "codex", "--home", userHome], { env: { PATH: "" } }); assertCliOk(text); assert.equal(text.stdout, `codex hooks have been configured: ${path.join(userHome, "hooks.json")}\n`); - const json = runCli(["setup", "codex", "--home", userHome, "--json"]); + const json = runCli(["setup", "codex", "--home", userHome, "--json"], { env: { PATH: "" } }); assertCliOk(json); assert.deepEqual(JSON.parse(json.stdout), { harness: "codex", - path: path.join(userHome, "hooks.json"), - changed: false, + action: "setup", + status: "manual-required", + nativeCli: "codex", + hooks: { path: path.join(userHome, "hooks.json"), changed: false }, + guide: `${GUIDE_ROOT}/codex.md`, }); }); -test("setup never overwrites an existing timestamped backup", () => { +test("setup never overwrites an existing timestamped backup", (t) => { const clientHome = tempHome("agent-lcm-backup-collision-"); + const fake = fakeSetupCli(t, "codex"); const setupPath = path.join(clientHome, "hooks.json"); const original = '{"hooks":{}}\n'; const timestamp = "2026-08-07T12-34-56-789Z"; @@ -321,7 +421,7 @@ test("setup never overwrites an existing timestamped backup", () => { const originalToISOString = Date.prototype.toISOString; Date.prototype.toISOString = () => "2026-08-07T12:34:56.789Z"; try { - setupHarness("codex", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm" }); + setupHarness("codex", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm", env: fake.env }); } finally { Date.prototype.toISOString = originalToISOString; } @@ -433,3 +533,22 @@ test("setup requires an absolute installed binary path", () => { ); assert.equal(fs.existsSync(path.join(clientHome, "hooks", "agent-lcm.json")), false); }); + +function fakeSetupCli( + t: test.TestContext, + name: "codex" | "copilot", +): { readonly env: NodeJS.ProcessEnv; readonly log: string } { + const bin = fs.mkdtempSync(path.join(tempHome("agent-lcm-setup-cli-parent-"), "bin-")); + const log = path.join(bin, "calls.jsonl"); + const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");\n`; + fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); + t.after(() => fs.rmSync(path.dirname(bin), { recursive: true, force: true })); + return { + env: { AGENT_LCM_FAKE_LOG: log, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}` }, + log, + }; +} + +function readSetupCalls(log: string): unknown[] { + return fs.readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line)); +} From 470dae1d88e668b1f6a033705075f04b1a061fad Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 00:46:51 -0400 Subject: [PATCH 07/29] feat(setup): remove only owned harness hooks --- src/setup-files.ts | 3 +- src/setup-hooks.ts | 49 +++++++++++++++++++ src/setup.ts | 33 +++++++++++++ tests/setup.test.ts | 114 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/setup-files.ts b/src/setup-files.ts index 5d5e582..8665c57 100644 --- a/src/setup-files.ts +++ b/src/setup-files.ts @@ -19,13 +19,14 @@ export class SetupFileLockTimeoutError extends Error { export function mutateSetupConfiguration( target: string, - transform: (configuration: Record | undefined) => Record, + transform: (configuration: Record | undefined) => Record | undefined, ): boolean { ensureSetupDirectory(path.dirname(target)); return withSetupFileLock(target, () => { const current = readSetupFile(target); const existing = current ? parseSetupConfiguration(current, target) : undefined; const next = transform(existing); + if (next === undefined) return false; if (existing && JSON.stringify(existing) === JSON.stringify(next)) return false; if (current) backupSetupBytes(target, current); writeSetupBytes(target, Buffer.from(`${JSON.stringify(next, null, 2)}\n`)); diff --git a/src/setup-hooks.ts b/src/setup-hooks.ts index ba86829..76e6b02 100644 --- a/src/setup-hooks.ts +++ b/src/setup-hooks.ts @@ -183,6 +183,55 @@ export function removeSharedSetupHooks( return next; } +export function removeSetupHooks( + configuration: Record, + harness: "codex" | "cursor" | "kiro", + target: string, +): Record { + validateSetupHooks(harness, configuration, target); + if (harness === "codex") return removeCodexHooks(configuration); + if (harness === "cursor") return removeCursorHooks(configuration); + return removeKiroHooks(configuration); +} + +function removeCodexHooks(configuration: Record): Record { + const next = structuredClone(configuration); + if (!isRecord(next.hooks)) return next; + for (const event of CODEX_EVENTS) { + const selectors = next.hooks[event]; + if (!Array.isArray(selectors)) continue; + for (const selector of selectors) { + if (!isRecord(selector) || !Array.isArray(selector.hooks)) continue; + selector.hooks = selector.hooks.filter((hook) => isCodexNativeHook(event) + ? !isAgentLcmCodexHook(hook, event) + : !isRecord(hook) || !isAgentLcmHook(hook, event, "codex")); + } + } + return next; +} + +function removeCursorHooks(configuration: Record): Record { + const next = structuredClone(configuration); + if (!isRecord(next.hooks)) return next; + for (const [event] of setupEvents("cursor")) { + const hooks = next.hooks[event]; + if (Array.isArray(hooks)) { + next.hooks[event] = hooks.filter((hook) => !isRecord(hook) || !isAgentLcmHook(hook, event, "cursor")); + } + } + return next; +} + +function removeKiroHooks(configuration: Record): Record { + const next = structuredClone(configuration); + if (!Array.isArray(next.hooks)) return next; + next.hooks = next.hooks.filter((hook) => !isKiroHook(hook) + || !eventsFor("kiro").some((event) => hook.name === `agent-lcm-kiro-${event}` + && hook.trigger === event + && isAgentLcmHook(hook.action, event, "kiro"))); + return next; +} + function removeSharedHooks( hooksByEvent: Record, harness: "copilot" | "vscode", diff --git a/src/setup.ts b/src/setup.ts index 850079b..5e9dad1 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -4,6 +4,7 @@ import { mutateSetupConfiguration, readSetupConfiguration } from "./setup-files. import { assertSafeSetupCommand, setupHooksConfigured } from "./setup-hook-status.ts"; import { mergeSetupHooks, + removeSetupHooks, removeSharedSetupHooks, validateSetupHooks, } from "./setup-hooks.ts"; @@ -18,6 +19,15 @@ export type SetupReport = { readonly hooks: { readonly path: string; readonly changed: boolean }; readonly guide: string; }; +export type RemoveOptions = { readonly home?: string; readonly env?: NodeJS.ProcessEnv }; +export type RemoveReport = { + readonly harness: CaptureHarness; + readonly action: "remove"; + readonly status: "complete" | "manual-required" | "shared-retained"; + readonly nativeCli: "codex" | "copilot" | null; + readonly hooks: { readonly path: string; readonly changed: boolean }; + readonly guide: string; +}; export type SetupStatusOptions = { readonly home?: string }; export type HarnessSetupStatus = { readonly configured: boolean; readonly path: string }; @@ -39,6 +49,22 @@ export function setupHarness(harness: CaptureHarness, options: SetupOptions): Se }; } +export function removeHarness(harness: CaptureHarness, options: RemoveOptions = {}): RemoveReport { + const target = setupPath(harness, options.home); + const existing = readSetupConfiguration(target); + validateSetupHooks(harness, existing, target); + const native = runHarnessLifecycle(harness, "remove", options.env ? { env: options.env } : {}); + const changed = removeHooks(harness, target, existing !== undefined); + return { + harness, + action: "remove", + status: native.status === "native-complete" ? "complete" : native.status, + nativeCli: native.nativeCli, + hooks: { path: target, changed }, + guide: native.guide, + }; +} + export function setupStatus(options: SetupStatusOptions = {}): Record { return Object.fromEntries(SETUP_HARNESSES.map((harness) => { const target = setupPath(harness, options.home); @@ -62,6 +88,13 @@ function updateHooks( return false; } +function removeHooks(harness: CaptureHarness, target: string, targetExists: boolean): boolean { + if (harness === "copilot" || harness === "vscode" || !targetExists) return false; + return mutateSetupConfiguration(target, (existing) => existing === undefined + ? undefined + : removeSetupHooks(existing, harness, target)); +} + function readConfigurationForStatus(target: string): Record | undefined { try { return readSetupConfiguration(target); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 8d8b2f0..f2b7b9b 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -4,11 +4,123 @@ import path from "node:path"; import test from "node:test"; import { writeSetupConfiguration } from "../src/setup-files.ts"; -import { setupHarness, setupStatus } from "../src/setup.ts"; +import { removeHarness, setupHarness, setupStatus } from "../src/setup.ts"; import { assertCliOk, runCli, tempHome } from "./helpers.ts"; const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; +test("remove Codex deletes only exact owned hooks and is repeatable", (t) => { + const fake = fakeSetupCli(t, "codex"); + const home = tempHome("agent-lcm-remove-codex-"); + const target = path.join(home, "hooks.json"); + const original = JSON.stringify({ owner: "user", hooks: { + SessionStart: [{ matcher: "*", hooks: [ + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart' }, + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart extra', keep: true }, + ] }], + PreToolUse: [{ matcher: ".*", hooks: [ + { type: "command", command: 'node "/old/bin/agent-lcm" hook PreToolUse' }, + { type: "command", command: 'node "/old/bin/agent-lcm" hook PostCompact', keep: true }, + ] }], + CustomEvent: [{ hooks: [{ type: "command", command: "keep-custom" }] }], + } }); + fs.writeFileSync(target, original); + + const first = removeHarness("codex", { home, env: fake.env }); + const second = removeHarness("codex", { home, env: fake.env }); + + assert.deepEqual(first, { + harness: "codex", + action: "remove", + status: "complete", + nativeCli: "codex", + hooks: { path: target, changed: true }, + guide: `${GUIDE_ROOT}/codex.md`, + }); + assert.equal(second.hooks.changed, false); + const configuration = JSON.parse(fs.readFileSync(target, "utf8")); + assert.deepEqual(configuration, { owner: "user", hooks: { + SessionStart: [{ matcher: "*", hooks: [ + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart extra', keep: true }, + ] }], + PreToolUse: [{ matcher: ".*", hooks: [ + { type: "command", command: 'node "/old/bin/agent-lcm" hook PostCompact', keep: true }, + ] }], + CustomEvent: [{ hooks: [{ type: "command", command: "keep-custom" }] }], + } }); + assert.deepEqual(readSetupCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ]); + assert.equal(fs.readdirSync(home).filter((name) => name.startsWith("hooks-pre-agent-lcm-")).length, 1); + assert.equal(fs.readFileSync(path.join(home, fs.readdirSync(home).find((name) => name.startsWith("hooks-pre-agent-lcm-")) ?? ""), "utf8"), original); +}); + +test("remove Cursor and Kiro preserves adversarial near matches", () => { + const cursorHome = tempHome("agent-lcm-remove-cursor-"); + const cursorTarget = path.join(cursorHome, "hooks.json"); + fs.writeFileSync(cursorTarget, JSON.stringify({ version: 1, hooks: { + sessionStart: [ + { command: 'node "/old/bin/agent-lcm" capture --harness cursor SessionStart' }, + { command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart', keep: true }, + ], + } })); + const cursor = removeHarness("cursor", { home: cursorHome }); + assert.equal(cursor.status, "manual-required"); + assert.equal(cursor.hooks.changed, true); + assert.deepEqual(JSON.parse(fs.readFileSync(cursorTarget, "utf8")).hooks.sessionStart, [ + { command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart', keep: true }, + ]); + + const kiroHome = tempHome("agent-lcm-remove-kiro-"); + const kiroTarget = path.join(kiroHome, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(kiroTarget), { recursive: true }); + fs.writeFileSync(kiroTarget, JSON.stringify({ version: "v1", hooks: [ + { name: "agent-lcm-kiro-SessionStart", trigger: "SessionStart", action: { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness kiro SessionStart' } }, + { name: "agent-lcm-kiro-SessionStart", trigger: "Stop", action: { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness kiro SessionStart' }, keep: true }, + { name: "agent-lcm-kiro-Stop", trigger: "Stop", action: { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness kiro Stop extra' }, keep: true }, + ] })); + const kiro = removeHarness("kiro", { home: kiroHome }); + assert.equal(kiro.status, "manual-required"); + assert.equal(kiro.hooks.changed, true); + assert.equal(JSON.parse(fs.readFileSync(kiroTarget, "utf8")).hooks.length, 2); +}); + +test("remove shared harnesses retains byte-identical resources without spawning", (t) => { + const fake = fakeSetupCli(t, "copilot"); + const home = tempHome("agent-lcm-remove-shared-"); + const target = path.join(home, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const original = Buffer.from('{"version":1,"hooks":{"sessionStart":[{"command":"node \\"/old/bin/agent-lcm\\" capture --harness auto sessionStart"}]}}\n'); + fs.writeFileSync(target, original); + + const report = removeHarness("vscode", { home, env: fake.env }); + + assert.equal(report.status, "shared-retained"); + assert.deepEqual(report.hooks, { path: target, changed: false }); + assert.deepEqual(fs.readFileSync(target), original); + assert.equal(fs.existsSync(fake.log), false); +}); + +test("remove validates before native work and missing targets stay missing", (t) => { + const fake = fakeSetupCli(t, "codex"); + const invalidHome = tempHome("agent-lcm-remove-invalid-"); + const invalidTarget = path.join(invalidHome, "hooks.json"); + const original = Buffer.from('{"hooks":{"SessionStart":"invalid"}}\n'); + fs.writeFileSync(invalidTarget, original); + assert.throws(() => removeHarness("codex", { home: invalidHome, env: fake.env }), /invalid setup configuration/u); + assert.deepEqual(fs.readFileSync(invalidTarget), original); + assert.equal(fs.existsSync(fake.log), false); + assert.deepEqual(fs.readdirSync(invalidHome), ["hooks.json"]); + + const missingHome = tempHome("agent-lcm-remove-missing-"); + const missing = removeHarness("kiro", { home: missingHome }); + assert.equal(missing.hooks.changed, false); + assert.equal(fs.existsSync(path.join(missingHome, "hooks")), false); +}); + test("setup validates an existing hook schema before starting the native CLI", (t) => { // Given: malformed Codex hooks and a fake CLI that records every process start. const fake = fakeSetupCli(t, "codex"); From 2ce1ac05ae0aba32f138a21cd2b8296ae1d58ec7 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 00:49:51 -0400 Subject: [PATCH 08/29] feat(cli): add safe harness removal --- src/cli.ts | 54 +++++++++++++++++++++++-------- tests/setup.test.ts | 79 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 583bc1c..2431746 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import type { CaptureHarness } from "./harnesses.ts"; import { readStatus } from "./installer.ts"; import { startMcpServer } from "./mcp.ts"; import { packageVersion } from "./release.ts"; -import { setupHarness, setupStatus, type SetupReport } from "./setup.ts"; +import { removeHarness, setupHarness, setupStatus, type RemoveReport, type SetupReport } from "./setup.ts"; import { detectedHarnesses } from "./setup-targets.ts"; type DaemonCliParams = @@ -87,9 +87,20 @@ export async function main(argv: string[]): Promise { return; } const harness = captureHarness(rest[0]); + const home = optionValue(rest, "--home"); printSetupReports(setupHarness(harness, { - home: optionValue(rest, "--home"), + home, command: commandPath, + ...(home ? { env: lifecycleEnvironment(home) } : {}), + }), rest.includes("--json")); + return; + } + if (command === "remove") { + const harness = captureHarness(rest[0], "remove"); + const home = optionValue(rest, "--home"); + printSetupReports(removeHarness(harness, { + home, + ...(home ? { env: lifecycleEnvironment(home) } : {}), }), rest.includes("--json")); return; } @@ -300,6 +311,7 @@ Commands: agent-lcm setup all agent-lcm setup [--home PATH] agent-lcm setup status + agent-lcm remove [--home PATH] agent-lcm status [--codex-home PATH] [--json] agent-lcm doctor [--codex-home PATH] [--json] Diagnose install, storage, and capture state agent-lcm health [--json] @@ -316,9 +328,9 @@ Commands: `); } -function captureHarness(value: string | undefined): CaptureHarness { +function captureHarness(value: string | undefined, action: "setup" | "remove" = "setup"): CaptureHarness { if (value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro") return value; - throw new Error("Usage: agent-lcm setup [--home PATH]"); + throw new Error(`Usage: agent-lcm ${action} [--home PATH]`); } function importHarness(value: string | undefined): ImportHarness { @@ -346,18 +358,32 @@ function printObjectOrText(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } -function printSetupReports(value: SetupReport | SetupReport[], json: boolean): void { +function printSetupReports(value: SetupReport | RemoveReport | SetupReport[], json: boolean): void { if (json) { printObjectOrText(value); - return; + } else { + const reports = Array.isArray(value) ? value : [value]; + if (reports.length === 0) { + process.stdout.write("No supported harnesses were detected. Configure one with agent-lcm setup .\n"); + return; + } + for (const report of reports) { + process.stdout.write(`${report.harness} ${report.action}: ${report.status}\n`); + process.stdout.write(`Hooks ${report.hooks.changed ? "changed" : "unchanged"}: ${report.hooks.path}\n`); + if (report.status !== "complete") process.stdout.write(`Manual steps: ${report.guide}\n`); + } } const reports = Array.isArray(value) ? value : [value]; - if (reports.length === 0) { - process.stdout.write("No supported harnesses were detected. Configure one with agent-lcm setup .\n"); - return; - } - for (const report of reports) { - const state = report.hooks.changed ? "have been configured" : "are already configured"; - process.stdout.write(`${report.harness} hooks ${state}: ${report.hooks.path}\n`); - } + if (reports.some((report) => report.status !== "complete")) process.exitCode = 2; +} + +function lifecycleEnvironment(home: string): NodeJS.ProcessEnv { + return { + ...process.env, + HOME: home, + USERPROFILE: home, + CODEX_HOME: home, + COPILOT_HOME: home, + AGENT_LCM_HOME: path.join(home, "agent-lcm"), + }; } diff --git a/tests/setup.test.ts b/tests/setup.test.ts index f2b7b9b..21e5c9c 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -488,7 +488,7 @@ test("setup all configures only harnesses already installed for the user", () => fs.mkdirSync(path.join(userHome, ".codex")); const result = runCli(["setup", "all", "--json"], { env: { HOME: userHome, USERPROFILE: userHome, PATH: "" } }); - assertCliOk(result); + assert.equal(result.status, 2, result.stderr); assert.deepEqual(JSON.parse(result.stdout), [{ harness: "codex", action: "setup", @@ -505,11 +505,12 @@ test("setup all configures only harnesses already installed for the user", () => test("setup prints a clear result for people and keeps JSON output for scripts", () => { const userHome = tempHome("agent-lcm-output-"); const text = runCli(["setup", "codex", "--home", userHome], { env: { PATH: "" } }); - assertCliOk(text); - assert.equal(text.stdout, `codex hooks have been configured: ${path.join(userHome, "hooks.json")}\n`); + assert.equal(text.status, 2, text.stderr); + assert.match(text.stdout, /codex setup: manual-required/u); + assert.match(text.stdout, new RegExp(`${GUIDE_ROOT}/codex\\.md`, "u")); const json = runCli(["setup", "codex", "--home", userHome, "--json"], { env: { PATH: "" } }); - assertCliOk(json); + assert.equal(json.status, 2, json.stderr); assert.deepEqual(JSON.parse(json.stdout), { harness: "codex", action: "setup", @@ -520,6 +521,58 @@ test("setup prints a clear result for people and keeps JSON output for scripts", }); }); +test("CLI setup and remove use native Codex with an isolated explicit home", (t) => { + const home = tempHome("agent-lcm-cli-native-"); + const fake = fakeLifecycleCli(t, "codex"); + const env = { PATH: fake.path, AGENT_LCM_FAKE_LOG: fake.log }; + + const setup = runCli(["setup", "codex", "--home", home, "--json"], { env }); + assertCliOk(setup); + assert.equal(JSON.parse(setup.stdout).status, "complete"); + + const remove = runCli(["remove", "codex", "--home", home, "--json"], { env }); + assertCliOk(remove); + assert.deepEqual(JSON.parse(remove.stdout), { + harness: "codex", + action: "remove", + status: "complete", + nativeCli: "codex", + hooks: { path: path.join(home, "hooks.json"), changed: true }, + guide: `${GUIDE_ROOT}/codex.md`, + }); + + const calls = fs.readFileSync(fake.log, "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual(calls.map((call) => call.argv), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "add", "agent-lcm@agent-lcm"], + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ]); + for (const call of calls) assert.deepEqual(call.env, { + HOME: home, + USERPROFILE: home, + CODEX_HOME: home, + COPILOT_HOME: home, + AGENT_LCM_HOME: path.join(home, "agent-lcm"), + }); +}); + +test("CLI remove reports unsupported native removal without changing shared resources", () => { + const home = tempHome("agent-lcm-cli-shared-"); + const target = path.join(home, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const original = Buffer.from('{"version":1,"hooks":{}}\n'); + fs.writeFileSync(target, original); + + const result = runCli(["remove", "vscode", "--home", home]); + + assert.equal(result.status, 2, result.stderr); + assert.match(result.stdout, /vscode remove: shared-retained/u); + assert.match(result.stdout, new RegExp(`${GUIDE_ROOT}/vscode\\.md`, "u")); + assert.deepEqual(fs.readFileSync(target), original); +}); + test("setup never overwrites an existing timestamped backup", (t) => { const clientHome = tempHome("agent-lcm-backup-collision-"); const fake = fakeSetupCli(t, "codex"); @@ -661,6 +714,24 @@ function fakeSetupCli( }; } +function fakeLifecycleCli( + t: test.TestContext, + name: "codex" | "copilot", +): { readonly path: string; readonly log: string } { + const bin = fs.mkdtempSync(path.join(tempHome("agent-lcm-lifecycle-cli-parent-"), "bin-")); + const log = path.join(bin, "calls.jsonl"); + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +fs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify({ argv: process.argv.slice(2), env: { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, CODEX_HOME: process.env.CODEX_HOME, COPILOT_HOME: process.env.COPILOT_HOME, AGENT_LCM_HOME: process.env.AGENT_LCM_HOME } }) + "\\n"); +`; + fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); + t.after(() => fs.rmSync(path.dirname(bin), { recursive: true, force: true })); + return { + path: `${bin}${path.delimiter}${path.dirname(process.execPath)}`, + log, + }; +} + function readSetupCalls(log: string): unknown[] { return fs.readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line)); } From 1c31ce9b1e1596bdf8997827f1402c61fb2797db Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 01:00:51 -0400 Subject: [PATCH 09/29] fix(setup): report native client capability accurately --- src/cli.ts | 5 +++++ src/installer.ts | 4 ++-- src/setup-adapters.ts | 26 ++++++++++++++++++++------ src/setup.ts | 6 +++--- tests/distribution.test.ts | 32 +++++++++++++++++++++++++++++++- tests/doctor-import.test.ts | 3 ++- tests/installer.test.ts | 6 +++--- tests/setup-adapters.test.ts | 36 ++++++++++++++++++++++++------------ tests/setup.test.ts | 24 +++++++++++++----------- 9 files changed, 103 insertions(+), 39 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 2431746..4c4c7d1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -370,6 +370,11 @@ function printSetupReports(value: SetupReport | RemoveReport | SetupReport[], js for (const report of reports) { process.stdout.write(`${report.harness} ${report.action}: ${report.status}\n`); process.stdout.write(`Hooks ${report.hooks.changed ? "changed" : "unchanged"}: ${report.hooks.path}\n`); + if (report.status === "manual-required") { + process.stdout.write(report.nativeCli === null + ? "Native CLI unavailable or its capability probe failed.\n" + : `${report.nativeCli} is installed, but it has no supported noninteractive plugin ${report.action} command.\n`); + } if (report.status !== "complete") process.stdout.write(`Manual steps: ${report.guide}\n`); } } diff --git a/src/installer.ts b/src/installer.ts index ba97f0b..e4bd0f3 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -17,7 +17,7 @@ export function readStatus(options: InstallerOptions = {}) { const hooksText = readOptional(hooksPath); const pluginManifestText = readOptional(path.join(root, ".codex-plugin", "plugin.json")); const pluginManifestAvailable = pluginManifestText !== undefined; - const mcpManifestAvailable = fs.existsSync(path.join(root, "mcp.json")); + const mcpManifestAvailable = fs.existsSync(path.join(root, ".mcp.json")); const hookManifestAvailable = fs.existsSync(path.join(root, "hooks", "codex.json")); const pluginDeclaresMcp = pluginManifestText !== undefined && /"mcpServers"\s*:/u.test(pluginManifestText); const pluginDeclaresHooks = pluginManifestText !== undefined && /"hooks"\s*:/u.test(pluginManifestText); @@ -41,7 +41,7 @@ export function readStatus(options: InstallerOptions = {}) { manual_mcp_configured: manualMcpConfigured, manual_hooks_configured: manualHooksConfigured, mcp_configured: manualMcpConfigured || (pluginOwnedWiringAvailable && pluginDeclaresMcp && mcpManifestAvailable), - hooks_configured: manualHooksConfigured || (pluginOwnedWiringAvailable && pluginDeclaresHooks && hookManifestAvailable), + hooks_configured: manualHooksConfigured, recall_skill_available: fs.existsSync(path.join(root, "skills", "lcm-recall", "SKILL.md")), }; } diff --git a/src/setup-adapters.ts b/src/setup-adapters.ts index 8f7a5e5..a612a54 100644 --- a/src/setup-adapters.ts +++ b/src/setup-adapters.ts @@ -3,12 +3,13 @@ import { spawnSync } from "node:child_process"; import type { CaptureHarness } from "./harnesses.ts"; export type HarnessLifecycleAction = "setup" | "remove"; +export type HarnessCli = "codex" | "copilot" | "cursor-agent" | "kiro-cli"; export type HarnessLifecycleOutcome = { readonly harness: CaptureHarness; readonly action: HarnessLifecycleAction; readonly status: "native-complete" | "manual-required" | "shared-retained"; - readonly nativeCli: "codex" | "copilot" | null; + readonly nativeCli: HarnessCli | null; readonly guide: string; }; @@ -52,6 +53,8 @@ type CopilotLifecycleAdapter = { type ManualLifecycleAdapter = { readonly kind: "manual"; + readonly executable: "cursor-agent" | "kiro-cli"; + readonly probeArgv: readonly string[]; readonly guide: string; }; @@ -72,7 +75,7 @@ export const HARNESS_LIFECYCLE_ADAPTERS = { ], removeArgv: ["plugin", "remove", "agent-lcm@agent-lcm"], }, - cursor: { kind: "manual", guide: `${GUIDE_ROOT}/cursor.md` }, + cursor: { kind: "manual", executable: "cursor-agent", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/cursor.md` }, vscode: { kind: "copilot", executable: "copilot", @@ -87,7 +90,7 @@ export const HARNESS_LIFECYCLE_ADAPTERS = { probeArgv: ["plugin", "list"], setupArgv: [["plugin", "install", "Team-Volt/agent-lcm"]], }, - kiro: { kind: "manual", guide: `${GUIDE_ROOT}/kiro.md` }, + kiro: { kind: "manual", executable: "kiro-cli", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/kiro.md` }, } satisfies Record; export function runHarnessLifecycle( @@ -98,9 +101,9 @@ export function runHarnessLifecycle( const adapter = HARNESS_LIFECYCLE_ADAPTERS[harness]; switch (adapter.kind) { case "manual": - return outcome(harness, action, "manual-required", null, adapter.guide); + return manualOutcome(harness, action, adapter, options.env); case "copilot": - if (action === "remove") return outcome(harness, action, "shared-retained", "copilot", adapter.guide); + if (action === "remove") return outcome(harness, action, "shared-retained", null, adapter.guide); return runNative(harness, action, adapter, options.env); case "codex": return runNative(harness, action, adapter, options.env); @@ -117,7 +120,7 @@ function runNative( ): HarnessLifecycleOutcome { const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); if (isEnoent(probe.error) || probe.status !== 0) { - return outcome(harness, action, "manual-required", adapter.executable, adapter.guide); + return outcome(harness, action, "manual-required", null, adapter.guide); } const commands = action === "setup" @@ -127,6 +130,17 @@ function runNative( return outcome(harness, action, "native-complete", adapter.executable, adapter.guide); } +function manualOutcome( + harness: CaptureHarness, + action: HarnessLifecycleAction, + adapter: ManualLifecycleAdapter, + env: NodeJS.ProcessEnv | undefined, +): HarnessLifecycleOutcome { + const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + const nativeCli = isEnoent(probe.error) || probe.status !== 0 ? null : adapter.executable; + return outcome(harness, action, "manual-required", nativeCli, adapter.guide); +} + function runNativeCommand(executable: "codex" | "copilot", argv: readonly string[], env: NodeJS.ProcessEnv | undefined): void { const result = spawnSync(executable, argv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); if (result.status === 0) return; diff --git a/src/setup.ts b/src/setup.ts index 5e9dad1..ae1e733 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,5 +1,5 @@ import type { CaptureHarness } from "./harnesses.ts"; -import { runHarnessLifecycle } from "./setup-adapters.ts"; +import { runHarnessLifecycle, type HarnessCli } from "./setup-adapters.ts"; import { mutateSetupConfiguration, readSetupConfiguration } from "./setup-files.ts"; import { assertSafeSetupCommand, setupHooksConfigured } from "./setup-hook-status.ts"; import { @@ -15,7 +15,7 @@ export type SetupReport = { readonly harness: CaptureHarness; readonly action: "setup"; readonly status: "complete" | "manual-required"; - readonly nativeCli: "codex" | "copilot" | null; + readonly nativeCli: HarnessCli | null; readonly hooks: { readonly path: string; readonly changed: boolean }; readonly guide: string; }; @@ -24,7 +24,7 @@ export type RemoveReport = { readonly harness: CaptureHarness; readonly action: "remove"; readonly status: "complete" | "manual-required" | "shared-retained"; - readonly nativeCli: "codex" | "copilot" | null; + readonly nativeCli: HarnessCli | null; readonly hooks: { readonly path: string; readonly changed: boolean }; readonly guide: string; }; diff --git a/tests/distribution.test.ts b/tests/distribution.test.ts index 942f0a5..816432f 100644 --- a/tests/distribution.test.ts +++ b/tests/distribution.test.ts @@ -36,6 +36,9 @@ test("the npm package contains the complete plugin and no development files", (t "plugin.json", "skills/lcm-recall/SKILL.md", "dist/cli.js", + "dist/setup-adapters.js", + "dist/setup-hook-status.js", + "dist/setup-hooks.js", ]) assert.ok(names.includes(required), `missing ${required}`); assert.equal(names.some((name) => /^(?:\.github|docs|scripts|tests)\//u.test(name)), false); assert.equal(fs.existsSync(path.join(root, filename)), true); @@ -135,7 +138,24 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", assert.equal(install.status, 0, install.stderr); const executable = process.platform === "win32" ? path.join(prefix, "agent-lcm.cmd") : path.join(prefix, "bin", "agent-lcm"); const home = path.join(root, "home"); - const env = { ...process.env, HOME: home, USERPROFILE: home, AGENT_LCM_HOME: path.join(home, ".agent-lcm") }; + const fakeBin = path.join(root, "fake-bin"); + const fakeLog = path.join(root, "codex-calls.jsonl"); + const fakeScript = path.join(root, "fake-codex.cjs"); + fs.mkdirSync(fakeBin); + fs.writeFileSync(fakeScript, 'require("node:fs").appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");\n'); + if (process.platform === "win32") { + fs.writeFileSync(path.join(fakeBin, "codex.cmd"), `@"${process.execPath}" "${fakeScript}" %*\r\n`); + } else { + fs.writeFileSync(path.join(fakeBin, "codex"), `#!${process.execPath}\nrequire(${JSON.stringify(fakeScript)});\n`, { mode: 0o755 }); + } + const env = { + ...process.env, + HOME: home, + USERPROFILE: home, + AGENT_LCM_HOME: path.join(home, ".agent-lcm"), + AGENT_LCM_FAKE_LOG: fakeLog, + PATH: `${fakeBin}${path.delimiter}${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ""}`, + }; const runInstalled = (args: string[], input?: string) => spawnSync(executable, args, { cwd: root, encoding: "utf8", @@ -218,5 +238,15 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", assert.equal(JSON.parse(daemon.stdout).running, true); const stopped = runInstalled(["daemon", "stop", "--json"]); assert.equal(stopped.status, 0, stopped.stderr); + const removed = runInstalled(["remove", "codex", "--json"]); + assert.equal(removed.status, 0, removed.stderr); + assert.equal(JSON.parse(removed.stdout).hooks.changed, true); + assert.deepEqual(fs.readFileSync(fakeLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "add", "agent-lcm@agent-lcm"], + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ]); assert.equal(JSON.parse(stopped.stdout).running, false); }); diff --git a/tests/doctor-import.test.ts b/tests/doctor-import.test.ts index 5e7bbf2..49aff6e 100644 --- a/tests/doctor-import.test.ts +++ b/tests/doctor-import.test.ts @@ -47,10 +47,11 @@ test("CLI storage commands use the daemon and daemon status controls its lifetim test("doctor reports actionable recommendations for an unwired empty install", () => { const codexHome = tempHome("codex-home-"); const lcmHome = tempHome("agent-lcm-home-"); + const harnessHome = tempHome("agent-lcm-harness-home-"); fs.writeFileSync(path.join(codexHome, "config.toml"), ""); const result = runCli(["doctor", "--codex-home", codexHome, "--json"], { - env: { AGENT_LCM_HOME: lcmHome }, + env: { AGENT_LCM_HOME: lcmHome, HOME: harnessHome, USERPROFILE: harnessHome }, }); assertCliOk(result); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index eb0d0e1..675f34f 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -21,7 +21,7 @@ test("status reads Codex home and reports absent wiring", () => { assert.equal(status.plugin_configured, false); assert.equal(status.plugin_manifest_available, true); assert.equal(status.plugin_declares_mcp, true); - assert.equal(status.plugin_declares_hooks, true); + assert.equal(status.plugin_declares_hooks, false); assert.equal(status.mcp_manifest_available, true); assert.equal(status.hook_manifest_available, true); assert.equal(status.manual_mcp_configured, false); @@ -59,13 +59,13 @@ test("status recognizes Codex-native plugin wiring", () => { assert.equal(status.plugin_configured, true); assert.equal(status.plugin_manifest_available, true); assert.equal(status.plugin_declares_mcp, true); - assert.equal(status.plugin_declares_hooks, true); + assert.equal(status.plugin_declares_hooks, false); assert.equal(status.mcp_manifest_available, true); assert.equal(status.hook_manifest_available, true); assert.equal(status.manual_mcp_configured, false); assert.equal(status.manual_hooks_configured, false); assert.equal(status.mcp_configured, true); - assert.equal(status.hooks_configured, true); + assert.equal(status.hooks_configured, false); assert.equal(status.recall_skill_available, true); }); diff --git a/tests/setup-adapters.test.ts b/tests/setup-adapters.test.ts index da3e451..2a6159f 100644 --- a/tests/setup-adapters.test.ts +++ b/tests/setup-adapters.test.ts @@ -71,14 +71,17 @@ test("Copilot and VS Code setup send the exact Copilot argv", (t) => { ]); }); -test("manual-required outcomes do not probe unsupported harnesses", (t) => { - // Given: an empty PATH with no harness CLIs. +test("manual-required outcomes probe only documented harness version commands", (t) => { + // Given: installed Cursor and Kiro CLIs, plus unavailable Codex and Copilot CLIs. const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-empty-bin-")); t.after(() => fs.rmSync(bin, { recursive: true, force: true })); + const cursorCli = fakeCli(t, "cursor-agent"); + const kiroCli = fakeCli(t, "kiro-cli"); - // When: native setup is requested for every unsupported or unavailable harness. - const cursor = runHarnessLifecycle("cursor", "setup", { env: { PATH: bin } }); - const kiro = runHarnessLifecycle("kiro", "remove", { env: { PATH: bin } }); + // When: native setup is requested for every manual or unavailable harness. + const cursor = runHarnessLifecycle("cursor", "setup", { env: cursorCli.env }); + const missingCursor = runHarnessLifecycle("cursor", "remove", { env: { PATH: bin } }); + const kiro = runHarnessLifecycle("kiro", "remove", { env: kiroCli.env }); const codex = runHarnessLifecycle("codex", "setup", { env: { PATH: bin } }); const incapable = fakeCli(t, "copilot", ["plugin", "list"]); const copilot = runHarnessLifecycle("copilot", "setup", { env: incapable.env }); @@ -88,30 +91,39 @@ test("manual-required outcomes do not probe unsupported harnesses", (t) => { harness: "cursor", action: "setup", status: "manual-required", - nativeCli: null, + nativeCli: "cursor-agent", guide: `${GUIDE_ROOT}/cursor.md`, }); assert.deepEqual(kiro, { harness: "kiro", action: "remove", status: "manual-required", - nativeCli: null, + nativeCli: "kiro-cli", guide: `${GUIDE_ROOT}/kiro.md`, }); + assert.deepEqual(missingCursor, { + harness: "cursor", + action: "remove", + status: "manual-required", + nativeCli: null, + guide: `${GUIDE_ROOT}/cursor.md`, + }); assert.deepEqual(codex, { harness: "codex", action: "setup", status: "manual-required", - nativeCli: "codex", + nativeCli: null, guide: `${GUIDE_ROOT}/codex.md`, }); assert.deepEqual(copilot, { harness: "copilot", action: "setup", status: "manual-required", - nativeCli: "copilot", + nativeCli: null, guide: `${GUIDE_ROOT}/copilot.md`, }); + assert.deepEqual(readCalls(cursorCli.log), [["--version"]]); + assert.deepEqual(readCalls(kiroCli.log), [["--version"]]); assert.deepEqual(readCalls(incapable.log), [["plugin", "list"]]); }); @@ -128,14 +140,14 @@ test("shared-retained removal does not spawn Copilot uninstall", (t) => { harness: "vscode", action: "remove", status: "shared-retained", - nativeCli: "copilot", + nativeCli: null, guide: `${GUIDE_ROOT}/vscode.md`, }); assert.deepEqual(copilot, { harness: "copilot", action: "remove", status: "shared-retained", - nativeCli: "copilot", + nativeCli: null, guide: `${GUIDE_ROOT}/copilot.md`, }); assert.equal(fs.existsSync(fake.log), false); @@ -169,7 +181,7 @@ test("mutating command failure is typed and cannot report completion", (t) => { function fakeCli( t: test.TestContext, - name: "codex" | "copilot", + name: "codex" | "copilot" | "cursor-agent" | "kiro-cli", fails?: readonly string[], ): { readonly env: NodeJS.ProcessEnv; readonly log: string } { const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-fake-cli-")); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 21e5c9c..e62cdac 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -8,6 +8,7 @@ import { removeHarness, setupHarness, setupStatus } from "../src/setup.ts"; import { assertCliOk, runCli, tempHome } from "./helpers.ts"; const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; +const NO_CLI_ENV = { PATH: "" }; test("remove Codex deletes only exact owned hooks and is repeatable", (t) => { const fake = fakeSetupCli(t, "codex"); @@ -67,7 +68,7 @@ test("remove Cursor and Kiro preserves adversarial near matches", () => { { command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart', keep: true }, ], } })); - const cursor = removeHarness("cursor", { home: cursorHome }); + const cursor = removeHarness("cursor", { home: cursorHome, env: NO_CLI_ENV }); assert.equal(cursor.status, "manual-required"); assert.equal(cursor.hooks.changed, true); assert.deepEqual(JSON.parse(fs.readFileSync(cursorTarget, "utf8")).hooks.sessionStart, [ @@ -82,7 +83,7 @@ test("remove Cursor and Kiro preserves adversarial near matches", () => { { name: "agent-lcm-kiro-SessionStart", trigger: "Stop", action: { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness kiro SessionStart' }, keep: true }, { name: "agent-lcm-kiro-Stop", trigger: "Stop", action: { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness kiro Stop extra' }, keep: true }, ] })); - const kiro = removeHarness("kiro", { home: kiroHome }); + const kiro = removeHarness("kiro", { home: kiroHome, env: NO_CLI_ENV }); assert.equal(kiro.status, "manual-required"); assert.equal(kiro.hooks.changed, true); assert.equal(JSON.parse(fs.readFileSync(kiroTarget, "utf8")).hooks.length, 2); @@ -116,7 +117,7 @@ test("remove validates before native work and missing targets stay missing", (t) assert.deepEqual(fs.readdirSync(invalidHome), ["hooks.json"]); const missingHome = tempHome("agent-lcm-remove-missing-"); - const missing = removeHarness("kiro", { home: missingHome }); + const missing = removeHarness("kiro", { home: missingHome, env: NO_CLI_ENV }); assert.equal(missing.hooks.changed, false); assert.equal(fs.existsSync(path.join(missingHome, "hooks")), false); }); @@ -251,8 +252,8 @@ test("Kiro setup uses the native array schema, is repeatable, and leaves sibling fs.writeFileSync(unrelatedKiroHook, '{"version":"v1","hooks":[{"name":"other","trigger":"SessionStart","action":{"type":"command","command":"other"}}]}\n'); const original = fs.readFileSync(unrelatedKiroHook); - const first = setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }); - const second = setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }); + const first = setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm", env: NO_CLI_ENV }); + const second = setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm", env: NO_CLI_ENV }); assert.equal(first.hooks.changed, true); assert.equal(second.hooks.changed, false); @@ -277,7 +278,7 @@ test("setup leaves invalid owned configuration untouched", () => { const original = fs.readFileSync(setupPath); assert.throws( - () => setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }), + () => setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm", env: NO_CLI_ENV }), new RegExp(setupPath.replace(/[\\^$.*+?()[\]{}|]/gu, "\\$&"), "u"), ); assert.deepEqual(fs.readFileSync(setupPath), original); @@ -291,7 +292,7 @@ test("setup rejects malformed Kiro schema without changing the owned file", () = fs.writeFileSync(setupPath, '{"version":"v1","hooks":{}}\n'); const original = fs.readFileSync(setupPath); - assert.throws(() => setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }), /invalid setup configuration/u); + assert.throws(() => setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm", env: NO_CLI_ENV }), /invalid setup configuration/u); assert.deepEqual(fs.readFileSync(setupPath), original); assert.deepEqual(fs.readdirSync(path.dirname(setupPath)).sort(), ["agent-lcm.json"]); }); @@ -476,7 +477,7 @@ test("Cursor setup validates and preserves its legacy user hooks", () => { }], } })); const original = fs.readFileSync(hooksPath); - const report = setupHarness("cursor", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm" }); + const report = setupHarness("cursor", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm", env: NO_CLI_ENV }); assert.equal(report.hooks.path, path.join(clientHome, "hooks.json")); assert.equal(report.hooks.changed, false); @@ -493,7 +494,7 @@ test("setup all configures only harnesses already installed for the user", () => harness: "codex", action: "setup", status: "manual-required", - nativeCli: "codex", + nativeCli: null, hooks: { path: path.join(userHome, ".codex", "hooks.json"), changed: true }, guide: `${GUIDE_ROOT}/codex.md`, }]); @@ -507,6 +508,7 @@ test("setup prints a clear result for people and keeps JSON output for scripts", const text = runCli(["setup", "codex", "--home", userHome], { env: { PATH: "" } }); assert.equal(text.status, 2, text.stderr); assert.match(text.stdout, /codex setup: manual-required/u); + assert.match(text.stdout, /Native CLI unavailable/u); assert.match(text.stdout, new RegExp(`${GUIDE_ROOT}/codex\\.md`, "u")); const json = runCli(["setup", "codex", "--home", userHome, "--json"], { env: { PATH: "" } }); @@ -515,7 +517,7 @@ test("setup prints a clear result for people and keeps JSON output for scripts", harness: "codex", action: "setup", status: "manual-required", - nativeCli: "codex", + nativeCli: null, hooks: { path: path.join(userHome, "hooks.json"), changed: false }, guide: `${GUIDE_ROOT}/codex.md`, }); @@ -651,7 +653,7 @@ test("Kiro setup updates its owned hooks after a binary move", () => { }, ] })); - setupHarness("kiro", { home: clientHome, command: "/new/bin/agent-lcm" }); + setupHarness("kiro", { home: clientHome, command: "/new/bin/agent-lcm", env: NO_CLI_ENV }); const configuration = JSON.parse(fs.readFileSync(setupPath, "utf8")); assert.equal(configuration.owner, "user"); assert.equal(configuration.hooks[0].action.command, "node \"/new/bin/agent-lcm\" capture --harness kiro SessionStart"); From b008f739fdc4ede58d6d78f0f17b7b22cae59cc2 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 01:01:00 -0400 Subject: [PATCH 10/29] docs(setup): document native lifecycle safety --- AGENTS.md | 15 +++++++++++++++ README.md | 31 +++++++++++++++++++++++++++++++ docs/architecture.md | 29 +++++++++++++++++++++++++++++ docs/install/cursor.md | 26 +++++++++++++++++++------- docs/install/kiro.md | 9 +++++---- docs/troubleshooting.md | 39 +++++++++++++++++++++++++++++++++++++++ src/AGENTS.md | 10 ++++++++++ tests/AGENTS.md | 10 ++++++++++ 8 files changed, 158 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c3a91c4..d3ff719 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,21 @@ docs/ architecture and troubleshooting provenance in every result. - Do not add `lcm_record_note`; Agent LCM has no note-writing MCP tool. +## Harness setup and removal + +- `agent-lcm setup ` uses native lifecycle commands only for Codex and + the shared Copilot/VS Code store; Cursor Marketplace and Kiro Powers remain + manual. `agent-lcm remove ` removes only exact Agent LCM-owned hook + entries. +- Setup reports `complete` with exit `0`; `manual-required` and + `shared-retained` use exit `2`; command errors use exit `1`. +- Copilot and VS Code share native plugin and hook resources. Single-harness + removal must retain those resources and must not invoke an uninstall. +- Validate existing setup JSON before native work. Preserve unrelated and + near-matching hooks, reject symlinked or non-regular targets, and publish + changes under the per-file SQLite lock through a unique fsynced temporary + file and rename. + See `src/AGENTS.md` and `tests/AGENTS.md` for more specific rules. ## Commands diff --git a/README.md b/README.md index 6e052d0..511ff63 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,37 @@ agent-lcm setup status agent-lcm doctor --json ``` +Setup and removal print one report per harness. Exit status `0` means the +requested native work and hook work completed. Exit status `2` means a manual +native step remains (`manual-required`) or a shared Copilot resource was +deliberately retained (`shared-retained`). Exit status `1` means the command +failed; inspect stderr before retrying. Add `--json` when a script needs the +report fields. + +Native lifecycle support is limited to the commands that each client documents: + +- Codex probes with `codex plugin list`, then runs `codex plugin marketplace add + Team-Volt/agent-lcm` and `codex plugin add agent-lcm@agent-lcm`. Removal runs + `codex plugin remove agent-lcm@agent-lcm`. +- GitHub Copilot CLI and VS Code share the Copilot plugin store. Setup probes + with `copilot plugin list` and runs `copilot plugin install + Team-Volt/agent-lcm`. `agent-lcm remove copilot` and `agent-lcm remove vscode` + return `shared-retained` without uninstalling that shared plugin; use the + documented Copilot uninstall command only after reviewing both clients. +- Cursor and Kiro are probed with `cursor-agent --version` and `kiro-cli + --version`. Neither CLI documents a noninteractive plugin install or removal + command, so their Marketplace or Powers steps remain manual while Agent LCM + manages only its legacy capture hooks. + +Setup validates an existing hook file before invoking a native CLI, preserves +unrelated entries, and changes only exact Agent LCM-owned registrations. It +backs up a changed file as `*-pre-agent-lcm-*.json`, uses a per-file SQLite +lock at `.lock.sqlite`, and publishes through a unique `wx` temporary +file, `fsync`, and rename. Symlinked or non-regular targets are refused, and +hook commands must be absolute paths without shell metacharacters. These rules +make repeated setup and removal safe while avoiding a second user-level hook +copy after native installation. + Hooks start the daemon on demand. You can also manage it directly: ```sh diff --git a/docs/architecture.md b/docs/architecture.md index a43b136..880780e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,6 +26,35 @@ running daemon can be reused. This prevents independently cached plugin versions from replacing one another while preserving orderly replacement for an incompatible protocol. +## Harness lifecycle + +`agent-lcm setup ` probes and, where supported, runs the client's native +plugin commands before updating legacy capture hooks. Codex uses `codex plugin +list`, then `codex plugin marketplace add Team-Volt/agent-lcm` and `codex plugin +add agent-lcm@agent-lcm`. Copilot CLI and VS Code use the shared Copilot store: +`copilot plugin list` followed by `copilot plugin install Team-Volt/agent-lcm`. +Cursor and Kiro run version-only probes for `cursor-agent` and `kiro-cli`. +Their Marketplace or Powers steps remain manual, so their native result is +`manual-required`. + +`agent-lcm remove ` removes only exact Agent LCM-owned legacy hooks. +Codex runs `codex plugin remove agent-lcm@agent-lcm`. Copilot and VS Code share a +native store, so either single-harness removal returns `shared-retained` and +does not invoke an uninstall. Deliberate shared removal remains a documented +manual Copilot action after both clients are reviewed. + +Lifecycle reports use exit status `0` for `complete`, `2` for +`manual-required` or `shared-retained`, and `1` for an error. Existing hook +configuration is validated before native work. Unrelated entries and +near-matching commands remain untouched; only an exact harness/event/command +registration is changed. + +Setup files use `.lock.sqlite` with a SQLite `BEGIN IMMEDIATE` lock +(bounded to ten seconds). Publication writes a unique `wx` temporary file with +restrictive permissions, fsyncs it, renames it, and fsyncs the parent directory. +Symlinked or non-regular targets are refused, hook commands must be absolute and +shell-safe, and changed files receive a collision-safe `-pre-agent-lcm-` backup. + ## Capture and retrieval flow 1. A harness invokes `agent-lcm capture --harness ...` with a lifecycle event. diff --git a/docs/install/cursor.md b/docs/install/cursor.md index 3e246c2..109926e 100644 --- a/docs/install/cursor.md +++ b/docs/install/cursor.md @@ -8,17 +8,28 @@ Run: agent-lcm setup cursor ``` -Cursor plugins package their hooks. Setup validates and preserves any legacy +Cursor plugins package their hooks. Setup probes `cursor-agent --version`, then +validates and preserves any legacy capture file at `~/.cursor/hooks.json`, but it does not add another copy of the same hooks. Cursor has no stable native install or remove CLI in the supported documentation, so setup reports `manual-required` for the native step. ## Native install and inspection -Open the official [Cursor Marketplace](https://cursor.com/marketplace), find -Agent LCM when it is listed, and use the Marketplace UI to install it. Use the -Cursor plugin or Marketplace view to inspect its installed state. Do not use a -shell command for this step. +Open Cursor's Customize page and install Agent LCM if it is available in a +marketplace you trust. Until it is listed, Cursor documents this local plugin +path for development installs: + +```sh +mkdir -p ~/.cursor/plugins/local +git clone https://github.com/Team-Volt/agent-lcm.git ~/.cursor/plugins/local/agent-lcm +``` + +On Windows, use `%USERPROFILE%\.cursor\plugins\local\agent-lcm` as the target. +If the target already exists, inspect it instead of replacing it. Run +`Developer: Reload Window`, then verify Agent LCM under Customize. Cursor's +[plugin guide](https://cursor.com/docs/plugins) documents the local path and +reload step. If Cursor shows a trust prompt, review the plugin source before accepting it. The Marketplace documentation does not establish a required restart. If the @@ -31,8 +42,9 @@ removes only the legacy Agent LCM entries and avoids running both copies. ## Remove Agent LCM -Use Cursor's installed-plugin UI to remove Agent LCM. Then remove only its -capture hooks: +Use Customize to remove a marketplace install. For a local install, move the +`~/.cursor/plugins/local/agent-lcm` directory out of the local plugin folder and +reload Cursor. Then remove only its capture hooks: ```sh agent-lcm remove cursor diff --git a/docs/install/kiro.md b/docs/install/kiro.md index eecaceb..d86bfdc 100644 --- a/docs/install/kiro.md +++ b/docs/install/kiro.md @@ -8,7 +8,7 @@ Run: agent-lcm setup kiro ``` -This installs or repairs the Kiro capture hooks at +This probes `kiro-cli --version`, then installs or repairs the Kiro capture hooks at `~/.kiro/hooks/agent-lcm.json`. Kiro's native Power install remains a manual UI step, so setup reports `manual-required` for that step. @@ -25,9 +25,10 @@ troubleshooting, then check `agent-lcm setup status`. ## Remove Agent LCM -Use the Powers UI's documented management control to remove Agent LCM. Kiro's -official Powers documentation does not define a native removal CLI. Then remove -only the Agent LCM hooks: +Kiro's official Powers installation page does not publish a CLI or a fixed UI +sequence for uninstalling a Power. Open the Powers panel, select Agent LCM, and +use the removal control shown by your installed Kiro version. Then remove only +the Agent LCM hooks: ```sh agent-lcm remove kiro diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 70fc1c8..b595dcf 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -66,6 +66,45 @@ A nonzero `queue_depth` means capture succeeded but the daemon has not drained the inbox. A nonzero `quarantine_count` means the daemon rejected one or more queue records; inspect `~/.agent-lcm/quarantine/` before removing them. +## Setup or removal needs manual work + +Run the harness-specific command and read its report: + +```sh +agent-lcm setup codex +agent-lcm setup copilot +agent-lcm setup vscode +agent-lcm setup cursor +agent-lcm setup kiro +agent-lcm remove codex +``` + +Replace `codex` with the harness you want to remove. + +Exit status `0` means the requested work completed. Exit status `2` means the +native step is `manual-required`, or Copilot/VS Code removal returned +`shared-retained` so the shared plugin was left in place. Exit status `1` means +the command failed; its stderr is the error record. Use `--json` for stable +automation fields. + +Codex setup probes `codex plugin list`, then runs the marketplace-add and +plugin-add commands. Removal runs `codex plugin remove agent-lcm@agent-lcm`. +Copilot and VS Code probe and install through `copilot plugin`; they share the +same plugin store and `~/.copilot/hooks/agent-lcm.json`, so either +`agent-lcm remove copilot` or `agent-lcm remove vscode` is intentionally +conservative and does not uninstall the shared plugin. Review both clients +before using the documented Copilot uninstall command. Cursor Marketplace and +Kiro Powers installation/removal stay manual. + +Setup validates the existing JSON before starting a native CLI. It changes only +exact Agent LCM-owned hook entries and preserves unrelated or near-matching +entries. A changed file gets a collision-safe `-pre-agent-lcm-` backup. Setup +also refuses symlinked or non-regular targets and uses a per-file SQLite lock +at `.lock.sqlite`, with a ten-second bound, plus unique, fsynced +temporary publication; a predictable temporary symlink cannot redirect the +write. Hook commands must use an absolute shell-safe binary path. If validation +fails, the original file and native CLI invocation remain unchanged. + ## Isolate a storage problem Use a temporary home so tests do not touch your normal store: diff --git a/src/AGENTS.md b/src/AGENTS.md index 7124625..4bf81fe 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -10,6 +10,8 @@ - `storage-graph.ts` derives bounded graph slices from indexed events and summary lineage; it does not persist a graph projection. - `overflow.ts` owns bounded, content-addressed overflow storage and recovery checks. +- `setup.ts`, `setup-adapters.ts`, `setup-hooks.ts`, and `setup-hook-status.ts` own harness lifecycle reports, native CLI adapters, exact-owned hook edits, and setup status. `setup-files.ts` owns validation, per-file locks, backups, and atomic setup-file publication. + ## Storage invariants - Sanitize and normalize input before inbox publication. Only the daemon drains inbox files into storage. @@ -39,6 +41,14 @@ - Do not let a derived-index transaction cover raw appends, or keep the raw-log lock during expensive work. - Do not turn a read path into an implicit migration or backfill. +## Harness setup safety + +- Probe and invoke only documented commands: Codex uses `codex plugin`; Copilot and VS Code use the shared `copilot plugin` store; Cursor and Kiro use version-only probes and keep plugin changes manual. +- Validate existing setup JSON before starting a native process. Match owned hooks by harness, event, and command shape; preserve unrelated and near-matching entries. +- Keep setup-file writes under `.lock.sqlite` with a bounded SQLite `BEGIN IMMEDIATE` lock. Refuse symlinked or non-regular targets. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and parent-directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. +- Require an absolute hook binary path and reject shell metacharacters before writing configuration. +- Never uninstall the shared Copilot plugin for a single `copilot` or `vscode` removal; report `shared-retained` instead. + ## Test routing - Storage, raw durability, reconciliation, locks, read-only behavior, cleanup, and overflow: `tests/storage.test.ts`. diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 43df197..aa1cc2b 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -12,6 +12,10 @@ framework, fixture library, or custom runner. - Route tests by surface: storage behavior belongs in `storage.test.ts` or `storage-api.test.ts`, protocol behavior in `mcp.test.ts`, and real command or hook behavior in `hook-cli.test.ts`. +- Native lifecycle adapter contracts and fake-CLI argv/outcome checks belong + in `setup-adapters.test.ts`. End-to-end setup/remove reports, exit statuses, + exact-owned hook edits, shared-retained behavior, lock handling, backups, + and symlink-safe publication belong in `setup.test.ts`. - Keep manifest, event, redaction, summary, and import coverage in their existing focused files instead of growing a catch-all suite. @@ -35,6 +39,9 @@ framework, fixture library, or custom runner. - Use `runCli()` for the actual `bin/agent-lcm` subprocess and assert its exit status, stderr, and parsed stdout. Set a bounded timeout for lock or worker scenarios. +- Fake `codex`, `copilot`, `cursor-agent`, and `kiro-cli` executables must record + argv and fail on demand; use them to prove the exact documented command + vectors without touching a user's installed clients. - Use `runMcp()` or `runCli(["mcp"], ...)` with newline or framed JSON to test the real stdio MCP server. Assert response IDs, errors, and continuation after malformed input; do not call dispatch functions directly for protocol @@ -62,5 +69,8 @@ framework, fixture library, or custom runner. when the test claims to cover that boundary. - Do not leave open `DatabaseSync` handles, workers, subprocesses, or lock files; leaked resources make later suites flaky. +- Do not claim native setup or removal coverage from unit calls alone: include + the real CLI boundary and assert `0` for `complete`, `2` for + `manual-required` or `shared-retained`, and `1` for command errors. - Do not add generated databases, raw logs, overflow files, or ad hoc scripts to the checkout. Keep changes and fixtures local to the test that needs them. From 76f67da5ac0617a8183b16d154a54be89f1c9759 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 01:01:12 -0400 Subject: [PATCH 11/29] build: refresh setup distribution --- dist/cli.js | 59 +++++-- dist/installer.js | 4 +- dist/setup-adapters.js | 97 ++++++++++++ dist/setup-files.js | 167 +++++++++++++++++--- dist/setup-hook-status.js | 123 +++++++++++++++ dist/setup-hooks.js | 211 +++++++++++++++++++++++++ dist/setup.js | 319 +++++--------------------------------- 7 files changed, 667 insertions(+), 313 deletions(-) create mode 100644 dist/setup-adapters.js create mode 100644 dist/setup-hook-status.js create mode 100644 dist/setup-hooks.js diff --git a/dist/cli.js b/dist/cli.js index 43995fb..06b44a6 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -9,7 +9,7 @@ import { runCapture, runHook } from "./hook.js"; import { readStatus } from "./installer.js"; import { startMcpServer } from "./mcp.js"; import { packageVersion } from "./release.js"; -import { setupHarness, setupStatus } from "./setup.js"; +import { removeHarness, setupHarness, setupStatus } from "./setup.js"; import { detectedHarnesses } from "./setup-targets.js"; export async function main(argv) { const [command, ...rest] = argv; @@ -46,9 +46,20 @@ export async function main(argv) { return; } const harness = captureHarness(rest[0]); + const home = optionValue(rest, "--home"); printSetupReports(setupHarness(harness, { - home: optionValue(rest, "--home"), + home, command: commandPath, + ...(home ? { env: lifecycleEnvironment(home) } : {}), + }), rest.includes("--json")); + return; + } + if (command === "remove") { + const harness = captureHarness(rest[0], "remove"); + const home = optionValue(rest, "--home"); + printSetupReports(removeHarness(harness, { + home, + ...(home ? { env: lifecycleEnvironment(home) } : {}), }), rest.includes("--json")); return; } @@ -258,6 +269,7 @@ Commands: agent-lcm setup all agent-lcm setup [--home PATH] agent-lcm setup status + agent-lcm remove [--home PATH] agent-lcm status [--codex-home PATH] [--json] agent-lcm doctor [--codex-home PATH] [--json] Diagnose install, storage, and capture state agent-lcm health [--json] @@ -273,10 +285,10 @@ Commands: agent-lcm import-codex-sessions [--from PATH] [--dry-run] [--progress] [--json] `); } -function captureHarness(value) { +function captureHarness(value, action = "setup") { if (value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro") return value; - throw new Error("Usage: agent-lcm setup [--home PATH]"); + throw new Error(`Usage: agent-lcm ${action} [--home PATH]`); } function importHarness(value) { if (value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro") @@ -307,15 +319,36 @@ function printObjectOrText(value) { function printSetupReports(value, json) { if (json) { printObjectOrText(value); - return; - } - const reports = Array.isArray(value) ? value : [value]; - if (reports.length === 0) { - process.stdout.write("No supported harnesses were detected. Configure one with agent-lcm setup .\n"); - return; } - for (const report of reports) { - const state = report.changed ? "have been configured" : "are already configured"; - process.stdout.write(`${report.harness} hooks ${state}: ${report.path}\n`); + else { + const reports = Array.isArray(value) ? value : [value]; + if (reports.length === 0) { + process.stdout.write("No supported harnesses were detected. Configure one with agent-lcm setup .\n"); + return; + } + for (const report of reports) { + process.stdout.write(`${report.harness} ${report.action}: ${report.status}\n`); + process.stdout.write(`Hooks ${report.hooks.changed ? "changed" : "unchanged"}: ${report.hooks.path}\n`); + if (report.status === "manual-required") { + process.stdout.write(report.nativeCli === null + ? "Native CLI unavailable or its capability probe failed.\n" + : `${report.nativeCli} is installed, but it has no supported noninteractive plugin ${report.action} command.\n`); + } + if (report.status !== "complete") + process.stdout.write(`Manual steps: ${report.guide}\n`); + } } + const reports = Array.isArray(value) ? value : [value]; + if (reports.some((report) => report.status !== "complete")) + process.exitCode = 2; +} +function lifecycleEnvironment(home) { + return { + ...process.env, + HOME: home, + USERPROFILE: home, + CODEX_HOME: home, + COPILOT_HOME: home, + AGENT_LCM_HOME: path.join(home, "agent-lcm"), + }; } diff --git a/dist/installer.js b/dist/installer.js index 263a4cf..046dbf5 100644 --- a/dist/installer.js +++ b/dist/installer.js @@ -10,7 +10,7 @@ export function readStatus(options = {}) { const hooksText = readOptional(hooksPath); const pluginManifestText = readOptional(path.join(root, ".codex-plugin", "plugin.json")); const pluginManifestAvailable = pluginManifestText !== undefined; - const mcpManifestAvailable = fs.existsSync(path.join(root, "mcp.json")); + const mcpManifestAvailable = fs.existsSync(path.join(root, ".mcp.json")); const hookManifestAvailable = fs.existsSync(path.join(root, "hooks", "codex.json")); const pluginDeclaresMcp = pluginManifestText !== undefined && /"mcpServers"\s*:/u.test(pluginManifestText); const pluginDeclaresHooks = pluginManifestText !== undefined && /"hooks"\s*:/u.test(pluginManifestText); @@ -33,7 +33,7 @@ export function readStatus(options = {}) { manual_mcp_configured: manualMcpConfigured, manual_hooks_configured: manualHooksConfigured, mcp_configured: manualMcpConfigured || (pluginOwnedWiringAvailable && pluginDeclaresMcp && mcpManifestAvailable), - hooks_configured: manualHooksConfigured || (pluginOwnedWiringAvailable && pluginDeclaresHooks && hookManifestAvailable), + hooks_configured: manualHooksConfigured, recall_skill_available: fs.existsSync(path.join(root, "skills", "lcm-recall", "SKILL.md")), }; } diff --git a/dist/setup-adapters.js b/dist/setup-adapters.js new file mode 100644 index 0000000..0fcc886 --- /dev/null +++ b/dist/setup-adapters.js @@ -0,0 +1,97 @@ +import { spawnSync } from "node:child_process"; +export class NativeLifecycleCommandError extends Error { + name = "NativeLifecycleCommandError"; + executable; + argv; + status; + stderr; + constructor(executable, argv, status, stderr) { + super(`Native lifecycle command failed: executable=${executable} argv=${argv.join(" ")} status=${String(status)} stderr=${stderr}`); + this.executable = executable; + this.argv = argv; + this.status = status; + this.stderr = stderr; + } +} +const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; +const MAX_STDERR_CHARS = 4_096; +export const HARNESS_LIFECYCLE_ADAPTERS = { + codex: { + kind: "codex", + executable: "codex", + guide: `${GUIDE_ROOT}/codex.md`, + probeArgv: ["plugin", "list"], + setupArgv: [ + ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "add", "agent-lcm@agent-lcm"], + ], + removeArgv: ["plugin", "remove", "agent-lcm@agent-lcm"], + }, + cursor: { kind: "manual", executable: "cursor-agent", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/cursor.md` }, + vscode: { + kind: "copilot", + executable: "copilot", + guide: `${GUIDE_ROOT}/vscode.md`, + probeArgv: ["plugin", "list"], + setupArgv: [["plugin", "install", "Team-Volt/agent-lcm"]], + }, + copilot: { + kind: "copilot", + executable: "copilot", + guide: `${GUIDE_ROOT}/copilot.md`, + probeArgv: ["plugin", "list"], + setupArgv: [["plugin", "install", "Team-Volt/agent-lcm"]], + }, + kiro: { kind: "manual", executable: "kiro-cli", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/kiro.md` }, +}; +export function runHarnessLifecycle(harness, action, options = {}) { + const adapter = HARNESS_LIFECYCLE_ADAPTERS[harness]; + switch (adapter.kind) { + case "manual": + return manualOutcome(harness, action, adapter, options.env); + case "copilot": + if (action === "remove") + return outcome(harness, action, "shared-retained", null, adapter.guide); + return runNative(harness, action, adapter, options.env); + case "codex": + return runNative(harness, action, adapter, options.env); + default: + return assertNever(adapter); + } +} +function runNative(harness, action, adapter, env) { + const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + if (isEnoent(probe.error) || probe.status !== 0) { + return outcome(harness, action, "manual-required", null, adapter.guide); + } + const commands = action === "setup" + ? adapter.setupArgv + : adapter.kind === "codex" ? [adapter.removeArgv] : []; + for (const argv of commands) + runNativeCommand(adapter.executable, argv, env); + return outcome(harness, action, "native-complete", adapter.executable, adapter.guide); +} +function manualOutcome(harness, action, adapter, env) { + const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + const nativeCli = isEnoent(probe.error) || probe.status !== 0 ? null : adapter.executable; + return outcome(harness, action, "manual-required", nativeCli, adapter.guide); +} +function runNativeCommand(executable, argv, env) { + const result = spawnSync(executable, argv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + if (result.status === 0) + return; + throw new NativeLifecycleCommandError(executable, argv, result.status, boundedStderr(result.stderr)); +} +function outcome(harness, action, status, nativeCli, guide) { + return { harness, action, status, nativeCli, guide }; +} +function boundedStderr(stderr) { + const value = typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? ""; + return value.slice(0, MAX_STDERR_CHARS).trimEnd(); +} +function isEnoent(error) { + return error !== undefined && "code" in error && error.code === "ENOENT"; +} +function assertNever(value) { + throw new Error(`Unexpected lifecycle adapter: ${JSON.stringify(value)}`); +} diff --git a/dist/setup-files.js b/dist/setup-files.js index 93523fc..7b127e2 100644 --- a/dist/setup-files.js +++ b/dist/setup-files.js @@ -1,19 +1,44 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -export function readSetupConfiguration(target) { - let text; - try { - text = fs.readFileSync(target, "utf8"); - } - catch (error) { - if (hasCode(error, "ENOENT")) - return undefined; - throw error; +import { DatabaseSync } from "node:sqlite"; +const SETUP_LOCK_TIMEOUT_MS = 10_000; +const SETUP_LOCK_POLL_MS = 10; +const SETUP_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); +export class SetupFileLockTimeoutError extends Error { + lockPath; + constructor(lockPath) { + super(`agent-lcm: setup file lock timeout: ${lockPath}`); + this.name = "SetupFileLockTimeoutError"; + this.lockPath = lockPath; } +} +export function mutateSetupConfiguration(target, transform) { + ensureSetupDirectory(path.dirname(target)); + return withSetupFileLock(target, () => { + const current = readSetupFile(target); + const existing = current ? parseSetupConfiguration(current, target) : undefined; + const next = transform(existing); + if (next === undefined) + return false; + if (existing && JSON.stringify(existing) === JSON.stringify(next)) + return false; + if (current) + backupSetupBytes(target, current); + writeSetupBytes(target, Buffer.from(`${JSON.stringify(next, null, 2)}\n`)); + return true; + }); +} +export function readSetupConfiguration(target) { + const bytes = readSetupFile(target); + if (!bytes) + return undefined; + return parseSetupConfiguration(bytes, target); +} +function parseSetupConfiguration(bytes, target) { let value; try { - value = JSON.parse(text); + value = JSON.parse(bytes.toString("utf8")); } catch { throw new Error(`Cannot update invalid setup configuration: ${target}`); @@ -23,37 +48,135 @@ export function readSetupConfiguration(target) { return value; } export function writeSetupConfiguration(target, configuration) { - const directory = path.dirname(target); - fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); - fs.chmodSync(directory, 0o700); + ensureSetupDirectory(path.dirname(target)); + writeSetupBytes(target, Buffer.from(`${JSON.stringify(configuration, null, 2)}\n`)); +} +function writeSetupBytes(target, bytes) { const temporary = `${target}.${randomUUID()}.tmp`; - const descriptor = fs.openSync(temporary, "wx", 0o600); + let descriptor; try { - fs.writeFileSync(descriptor, `${JSON.stringify(configuration, null, 2)}\n`); + descriptor = fs.openSync(temporary, "wx", 0o600); + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); fs.fsyncSync(descriptor); - } - finally { fs.closeSync(descriptor); + descriptor = undefined; + fs.renameSync(temporary, target); + if (process.platform !== "win32") + fsyncPath(path.dirname(target)); + } + catch (error) { + if (descriptor !== undefined) + fs.closeSync(descriptor); + try { + fs.unlinkSync(temporary); + } + catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) + throw new AggregateError([error, cleanupError], "Setup publication and cleanup failed."); + } + throw error; } - fs.renameSync(temporary, target); } export function backupSetupConfiguration(target) { + backupSetupBytes(target, fs.readFileSync(target)); +} +function backupSetupBytes(target, bytes) { const extension = path.extname(target); const stem = extension ? target.slice(0, -extension.length) : target; const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); for (let suffix = 0;; suffix += 1) { const candidate = `${stem}-pre-agent-lcm-${timestamp}${suffix ? `-${suffix}` : ""}${extension}`; + let descriptor; + try { + descriptor = fs.openSync(candidate, "wx", 0o600); + } + catch (error) { + if (hasCode(error, "EEXIST")) + continue; + throw error; + } try { - fs.copyFileSync(target, candidate, fs.constants.COPYFILE_EXCL); - fs.chmodSync(candidate, 0o600); + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); return; } catch (error) { - if (!hasCode(error, "EEXIST")) - throw error; + fs.closeSync(descriptor); + try { + fs.unlinkSync(candidate); + } + catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) + throw new AggregateError([error, cleanupError], "Setup backup and cleanup failed."); + } + throw error; + } + } +} +function withSetupFileLock(target, callback) { + const lockPath = `${target}.lock.sqlite`; + const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; + const coordinator = new DatabaseSync(lockPath, { timeout: SETUP_LOCK_POLL_MS }); + let transactionOpen = false; + try { + fs.chmodSync(lockPath, 0o600); + while (!transactionOpen) { + try { + coordinator.exec("BEGIN IMMEDIATE"); + transactionOpen = true; + } + catch (error) { + if (!isSqliteBusy(error)) + throw error; + if (Date.now() >= deadline) + throw new SetupFileLockTimeoutError(lockPath); + Atomics.wait(SETUP_LOCK_WAIT, 0, 0, SETUP_LOCK_POLL_MS); + } } + return callback(); + } + finally { + if (transactionOpen) + coordinator.exec("ROLLBACK"); + coordinator.close(); + } +} +function readSetupFile(target) { + let status; + try { + status = fs.lstatSync(target); + } + catch (error) { + if (hasCode(error, "ENOENT")) + return undefined; + throw error; + } + if (status.isSymbolicLink()) + throw new Error(`Refusing setup configuration symlink: ${target}`); + if (!status.isFile()) + throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); + return fs.readFileSync(target); +} +function ensureSetupDirectory(directory) { + const created = fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (created !== undefined) + fs.chmodSync(directory, 0o700); +} +function fsyncPath(target) { + const descriptor = fs.openSync(target, "r"); + try { + fs.fsyncSync(descriptor); + } + finally { + fs.closeSync(descriptor); } } +function isSqliteBusy(error) { + return error instanceof Error && Reflect.get(error, "errcode") === 5; +} function hasCode(error, code) { return error instanceof Error && Reflect.get(error, "code") === code; } diff --git a/dist/setup-hook-status.js b/dist/setup-hook-status.js new file mode 100644 index 0000000..8884ce5 --- /dev/null +++ b/dist/setup-hook-status.js @@ -0,0 +1,123 @@ +import path from "node:path"; +export const CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "SubagentStop", "Stop"]; +export function setupHooksConfigured(harness, configuration) { + if (!configuration) + return false; + if (harness === "kiro") { + const hooks = configuration.hooks; + if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) + return false; + return eventsFor(harness).every((event) => hooks.some((hook) => isExpectedKiroHook(hook, event))); + } + if (harness !== "codex" && configuration.version !== 1) + return false; + const hooksByEvent = configuration.hooks; + if (!isRecord(hooksByEvent)) + return false; + if (harness === "codex") + return CODEX_EVENTS.every((event) => { + const selectors = hooksByEvent[event]; + return Array.isArray(selectors) && selectors.some((selector) => isRecord(selector) + && Array.isArray(selector.hooks) + && selector.hooks.some((hook) => isCodexNativeHook(event) + ? isAgentLcmCodexHook(hook, event) + : isExpectedCommandHook(hook, harness, event))); + }); + if (isSharedHookHarness(harness) && hasSharedPascalRegistration(hooksByEvent)) + return false; + return setupEvents(harness).every(([event, captureEvent]) => { + const hooks = hooksByEvent[event]; + return Array.isArray(hooks) && hooks.some((entry) => isExpectedCommandHook(entry, setupCaptureHarness(harness), captureEvent)); + }); +} +export function eventsFor(harness) { + return isSharedHookHarness(harness) + ? ["sessionStart", "userPromptSubmitted", "postToolUse", "sessionEnd"] + : ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]; +} +export function setupEvents(harness) { + return harness === "cursor" + ? [["sessionStart", "SessionStart"], ["beforeSubmitPrompt", "UserPromptSubmit"], ["postToolUse", "PostToolUse"], ["stop", "Stop"]] + : [["sessionStart", "sessionStart"], ["userPromptSubmitted", "userPromptSubmitted"], ["postToolUse", "postToolUse"], ["sessionEnd", "sessionEnd"]]; +} +export function isSharedHookHarness(harness) { + return harness === "copilot" || harness === "vscode"; +} +export function setupCaptureHarness(harness) { + return isSharedHookHarness(harness) ? "auto" : harness; +} +export function isAgentLcmHook(value, event, harness) { + if ((value.type !== undefined && value.type !== "command") || typeof value.command !== "string") + return false; + const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" capture --harness (auto|codex|cursor|copilot|vscode|kiro) (sessionStart|userPromptSubmitted|postToolUse|sessionEnd|SessionStart|UserPromptSubmit|PostToolUse|Stop)$/u + .exec(value.command); + const captureEvent = harness === "cursor" + ? setupEvents("cursor").find(([hookEvent]) => hookEvent === event)?.[1] + : event; + if (!match || match[2] !== captureEvent) + return false; + return isSharedHookHarness(harness) + ? match[1] === "auto" || match[1] === "copilot" || match[1] === "vscode" + : match[1] === harness; +} +export function isAgentLcmCodexHook(value, event) { + if (!isRecord(value) || (value.type !== undefined && value.type !== "command") || typeof value.command !== "string") + return false; + const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" hook (PreToolUse|PreCompact|PostCompact|SubagentStop)$/u.exec(value.command); + return match?.[1] === event; +} +export function isCodexNativeHook(event) { + return event === "PreToolUse" || event === "PreCompact" || event === "PostCompact" || event === "SubagentStop"; +} +export function isKiroHook(value) { + return isRecord(value) + && typeof value.name === "string" + && typeof value.trigger === "string" + && isRecord(value.action) + && value.action.type === "command" + && typeof value.action.command === "string"; +} +export function assertSafeSetupCommand(command) { + if (!command) + throw new Error("setup command must not be empty"); + if (!path.isAbsolute(command) && !/^[A-Za-z]:[\\/]/u.test(command)) + throw new Error("setup command must be an absolute binary path"); + if (/["'`$;&|<>\n\r%^]/u.test(command) || command.endsWith("\\")) + throw new Error("setup command contains unsafe shell characters"); +} +function isExpectedCommandHook(value, harness, event) { + return isRecord(value) && (value.type === undefined || value.type === "command") && isCaptureCommand(value.command, harness, event); +} +function hasSharedPascalRegistration(hooksByEvent) { + return ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"].some((event) => { + const hooks = hooksByEvent[event]; + return Array.isArray(hooks) && hooks.some((hook) => isRecord(hook) && hook.type === "command" && isAgentLcmHook(hook, event, "vscode")); + }); +} +function isExpectedKiroHook(value, event) { + return isKiroHook(value) + && value.name === `agent-lcm-kiro-${event}` + && value.trigger === event + && isCaptureCommand(value.action.command, "kiro", event); +} +function isCaptureCommand(value, harness, event) { + if (typeof value !== "string") + return false; + const prefix = 'node "'; + const suffix = ` capture --harness ${harness} ${event}`; + if (!value.startsWith(prefix) || !value.endsWith(suffix)) + return false; + const quoteEnd = value.length - suffix.length - 1; + if (value[quoteEnd] !== "\"") + return false; + try { + assertSafeSetupCommand(value.slice(prefix.length, quoteEnd)); + return true; + } + catch { + return false; + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/dist/setup-hooks.js b/dist/setup-hooks.js new file mode 100644 index 0000000..6104953 --- /dev/null +++ b/dist/setup-hooks.js @@ -0,0 +1,211 @@ +import { CODEX_EVENTS, eventsFor, isAgentLcmCodexHook, isAgentLcmHook, isCodexNativeHook, isKiroHook, isSharedHookHarness, setupCaptureHarness, setupEvents, } from "./setup-hook-status.js"; +export function mergeSetupHooks(existing, harness, command, target) { + if (harness === "kiro") + return mergeKiroConfiguration(existing, command, target); + if (harness === "codex") + return mergeCodexConfiguration(existing, command, target); + return mergeFlatConfiguration(existing, harness, command, target); +} +function mergeCodexConfiguration(existing, command, target) { + const configuration = existing ? structuredClone(existing) : { hooks: {} }; + if (!isRecord(configuration.hooks)) + throw invalidConfiguration(target); + if (!Object.values(configuration.hooks).every(isCodexSelectors)) + throw invalidConfiguration(target); + for (const event of CODEX_EVENTS) { + const expectedCommand = isCodexNativeHook(event) + ? `node "${command}" hook ${event}` + : captureCommand(command, "codex", event); + const selectors = configuration.hooks[event]; + if (selectors === undefined) { + configuration.hooks[event] = [{ + ...(event === "PreToolUse" ? { matcher: ".*" } : {}), + hooks: [{ type: "command", command: expectedCommand }], + }]; + continue; + } + if (!isCodexSelectors(selectors)) + throw invalidConfiguration(target); + let found = false; + for (const selector of selectors) { + if (!Array.isArray(selector.hooks) || !selector.hooks.every(isRecord)) + throw invalidConfiguration(target); + for (const hook of selector.hooks) { + if (!(isCodexNativeHook(event) ? isAgentLcmCodexHook(hook, event) : isAgentLcmHook(hook, event, "codex"))) + continue; + hook.type = "command"; + hook.command = expectedCommand; + found = true; + } + } + if (!found) + selectors.push({ + ...(event === "PreToolUse" ? { matcher: ".*" } : {}), + hooks: [{ type: "command", command: expectedCommand }], + }); + } + return configuration; +} +function mergeFlatConfiguration(existing, harness, command, target) { + const configuration = existing ? structuredClone(existing) : { version: 1, hooks: {} }; + if (configuration.version !== 1 || !isRecord(configuration.hooks)) + throw invalidConfiguration(target); + if (!Object.values(configuration.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { + throw invalidConfiguration(target); + } + for (const [event, captureEvent] of setupEvents(harness)) { + const expectedHooks = takeAgentLcmHooks(configuration.hooks, harness, event); + if (expectedHooks.length === 0) + expectedHooks.push({}); + for (const expected of expectedHooks) { + if (harness !== "cursor") + expected.type = "command"; + expected.command = captureCommand(command, setupCaptureHarness(harness), captureEvent); + } + const hooks = configuration.hooks[event]; + if (hooks === undefined) + configuration.hooks[event] = expectedHooks; + else + hooks.push(...expectedHooks); + } + return configuration; +} +function mergeKiroConfiguration(existing, command, target) { + const configuration = existing ? structuredClone(existing) : { version: "v1", hooks: [] }; + const hooks = configuration.hooks; + if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) { + throw invalidConfiguration(target); + } + const kiroHooks = hooks; + for (const event of eventsFor("kiro")) { + const expected = kiroHook(command, event); + const owned = kiroHooks.filter((hook) => hook.name === expected.name + && hook.trigger === event + && isAgentLcmHook(hook.action, event, "kiro")); + if (owned.length === 0) + kiroHooks.push(expected); + for (const hook of owned) { + hook.action.type = "command"; + hook.action.command = expected.action.command; + } + } + return configuration; +} +function takeAgentLcmHooks(hooksByEvent, harness, event) { + const found = []; + const candidates = isSharedHookHarness(harness) ? [event, sharedLegacyEvent(event)] : [event]; + for (const candidate of candidates) { + const hooks = hooksByEvent[candidate]; + if (!Array.isArray(hooks)) + continue; + const kept = hooks.filter((hook) => { + if (!isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)) + return true; + found.push(hook); + return false; + }); + if (kept.length === 0) + delete hooksByEvent[candidate]; + else + hooksByEvent[candidate] = kept; + } + return found; +} +function sharedLegacyEvent(event) { + return { + sessionStart: "SessionStart", + userPromptSubmitted: "UserPromptSubmit", + postToolUse: "PostToolUse", + sessionEnd: "Stop", + }[event] ?? event; +} +function kiroHook(command, event) { + return { + name: `agent-lcm-kiro-${event}`, + trigger: event, + action: { type: "command", command: captureCommand(command, "kiro", event) }, + }; +} +function captureCommand(command, harness, event) { + return `node "${command}" capture --harness ${harness} ${event}`; +} +function isCodexSelectors(value) { + return Array.isArray(value) && value.every((selector) => isRecord(selector) + && Array.isArray(selector.hooks) + && selector.hooks.every(isRecord)); +} +function invalidConfiguration(target) { + return new Error(`Cannot update invalid setup configuration: ${target}`); +} +export function validateSetupHooks(harness, configuration, target) { + if (configuration !== undefined) + mergeSetupHooks(configuration, harness, "/agent-lcm", target); +} +export function removeSharedSetupHooks(configuration, harness, target) { + const next = structuredClone(configuration); + if (next.version !== 1 || !isRecord(next.hooks) + || !Object.values(next.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { + throw invalidConfiguration(target); + } + for (const [event] of setupEvents(harness)) + removeSharedHooks(next.hooks, harness, event); + return next; +} +export function removeSetupHooks(configuration, harness, target) { + validateSetupHooks(harness, configuration, target); + if (harness === "codex") + return removeCodexHooks(configuration); + if (harness === "cursor") + return removeCursorHooks(configuration); + return removeKiroHooks(configuration); +} +function removeCodexHooks(configuration) { + const next = structuredClone(configuration); + if (!isRecord(next.hooks)) + return next; + for (const event of CODEX_EVENTS) { + const selectors = next.hooks[event]; + if (!Array.isArray(selectors)) + continue; + for (const selector of selectors) { + if (!isRecord(selector) || !Array.isArray(selector.hooks)) + continue; + selector.hooks = selector.hooks.filter((hook) => isCodexNativeHook(event) + ? !isAgentLcmCodexHook(hook, event) + : !isRecord(hook) || !isAgentLcmHook(hook, event, "codex")); + } + } + return next; +} +function removeCursorHooks(configuration) { + const next = structuredClone(configuration); + if (!isRecord(next.hooks)) + return next; + for (const [event] of setupEvents("cursor")) { + const hooks = next.hooks[event]; + if (Array.isArray(hooks)) { + next.hooks[event] = hooks.filter((hook) => !isRecord(hook) || !isAgentLcmHook(hook, event, "cursor")); + } + } + return next; +} +function removeKiroHooks(configuration) { + const next = structuredClone(configuration); + if (!Array.isArray(next.hooks)) + return next; + next.hooks = next.hooks.filter((hook) => !isKiroHook(hook) + || !eventsFor("kiro").some((event) => hook.name === `agent-lcm-kiro-${event}` + && hook.trigger === event + && isAgentLcmHook(hook.action, event, "kiro"))); + return next; +} +function removeSharedHooks(hooksByEvent, harness, event) { + for (const candidate of [event, sharedLegacyEvent(event)]) { + const hooks = hooksByEvent[candidate]; + if (Array.isArray(hooks)) + hooksByEvent[candidate] = hooks.filter((hook) => !isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)); + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/dist/setup.js b/dist/setup.js index 53f45af..9ab1f46 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -1,213 +1,61 @@ -import path from "node:path"; -import { backupSetupConfiguration, readSetupConfiguration, writeSetupConfiguration } from "./setup-files.js"; +import { runHarnessLifecycle } from "./setup-adapters.js"; +import { mutateSetupConfiguration, readSetupConfiguration } from "./setup-files.js"; +import { assertSafeSetupCommand, setupHooksConfigured } from "./setup-hook-status.js"; +import { mergeSetupHooks, removeSetupHooks, removeSharedSetupHooks, validateSetupHooks, } from "./setup-hooks.js"; import { SETUP_HARNESSES, setupPath } from "./setup-targets.js"; -const CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "SubagentStop", "Stop"]; export function setupHarness(harness, options) { const target = setupPath(harness, options.home); const command = options.command.trim(); - assertSafeCommand(command); + assertSafeSetupCommand(command); const existing = readSetupConfiguration(target); - const next = mergeConfiguration(existing, harness, command, target); - if (existing && JSON.stringify(existing) === JSON.stringify(next)) - return { harness, path: target, changed: false }; - if (existing) - backupSetupConfiguration(target); - writeSetupConfiguration(target, next); - return { harness, path: target, changed: true }; -} -export function setupStatus(options = {}) { - return Object.fromEntries(SETUP_HARNESSES.map((harness) => { - const target = setupPath(harness, options.home); - return [harness, { configured: configured(harness, target), path: target }]; - })); -} -function mergeConfiguration(existing, harness, command, target) { - if (harness === "kiro") - return mergeKiroConfiguration(existing, command, target); - if (harness === "codex") - return mergeCodexConfiguration(existing, command, target); - return mergeFlatConfiguration(existing, harness, command, target); -} -function mergeCodexConfiguration(existing, command, target) { - const configuration = existing ? structuredClone(existing) : { hooks: {} }; - if (!isRecord(configuration.hooks)) - throw invalidConfiguration(target); - if (!Object.values(configuration.hooks).every(isCodexSelectors)) - throw invalidConfiguration(target); - for (const event of CODEX_EVENTS) { - const expectedCommand = isCodexNativeHook(event) - ? `node "${command}" hook ${event}` - : captureCommand(command, "codex", event); - const selectors = configuration.hooks[event]; - if (selectors === undefined) { - configuration.hooks[event] = [{ - ...(event === "PreToolUse" ? { matcher: ".*" } : {}), - hooks: [{ type: "command", command: expectedCommand }], - }]; - continue; - } - if (!isCodexSelectors(selectors)) - throw invalidConfiguration(target); - let found = false; - for (const selector of selectors) { - if (!Array.isArray(selector.hooks) || !selector.hooks.every(isRecord)) - throw invalidConfiguration(target); - for (const hook of selector.hooks) { - if (!(isCodexNativeHook(event) ? isAgentLcmCodexHook(hook, event) : isAgentLcmHook(hook, event, "codex"))) - continue; - hook.type = "command"; - hook.command = expectedCommand; - found = true; - } - } - if (!found) - selectors.push({ - ...(event === "PreToolUse" ? { matcher: ".*" } : {}), - hooks: [{ type: "command", command: expectedCommand }], - }); - } - return configuration; -} -function mergeFlatConfiguration(existing, harness, command, target) { - const configuration = existing ? structuredClone(existing) : { version: 1, hooks: {} }; - if (configuration.version !== 1 || !isRecord(configuration.hooks)) - throw invalidConfiguration(target); - if (!Object.values(configuration.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { - throw invalidConfiguration(target); - } - for (const [event, captureEvent] of setupEvents(harness)) { - const expectedHooks = takeAgentLcmHooks(configuration.hooks, harness, event); - if (expectedHooks.length === 0) - expectedHooks.push({}); - for (const expected of expectedHooks) { - if (harness !== "cursor") - expected.type = "command"; - expected.command = captureCommand(command, setupCaptureHarness(harness), captureEvent); - } - const hooks = configuration.hooks[event]; - if (hooks === undefined) - configuration.hooks[event] = expectedHooks; - else - hooks.push(...expectedHooks); - } - return configuration; -} -function mergeKiroConfiguration(existing, command, target) { - const configuration = existing ? structuredClone(existing) : { version: "v1", hooks: [] }; - const hooks = configuration.hooks; - if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) { - throw invalidConfiguration(target); - } - const kiroHooks = hooks; - for (const event of eventsFor("kiro")) { - const expected = kiroHook(command, event); - const owned = kiroHooks.filter((hook) => hook.name === expected.name - && hook.trigger === event - && isAgentLcmHook(hook.action, event, "kiro")); - if (owned.length === 0) - kiroHooks.push(expected); - for (const hook of owned) { - hook.action.type = "command"; - hook.action.command = expected.action.command; - } - } - return configuration; -} -function eventsFor(harness) { - return isSharedHookHarness(harness) - ? ["sessionStart", "userPromptSubmitted", "postToolUse", "sessionEnd"] - : ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]; -} -function setupEvents(harness) { - return harness === "cursor" - ? [["sessionStart", "SessionStart"], ["beforeSubmitPrompt", "UserPromptSubmit"], ["postToolUse", "PostToolUse"], ["stop", "Stop"]] - : [["sessionStart", "sessionStart"], ["userPromptSubmitted", "userPromptSubmitted"], ["postToolUse", "postToolUse"], ["sessionEnd", "sessionEnd"]]; -} -function isSharedHookHarness(harness) { - return harness === "copilot" || harness === "vscode"; -} -function setupCaptureHarness(harness) { - return isSharedHookHarness(harness) ? "auto" : harness; -} -function takeAgentLcmHooks(hooksByEvent, harness, event) { - const found = []; - const candidates = isSharedHookHarness(harness) ? [event, sharedLegacyEvent(event)] : [event]; - for (const candidate of candidates) { - const hooks = hooksByEvent[candidate]; - if (!Array.isArray(hooks)) - continue; - const kept = hooks.filter((hook) => { - if (!isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)) - return true; - found.push(hook); - return false; - }); - if (kept.length === 0) - delete hooksByEvent[candidate]; - else - hooksByEvent[candidate] = kept; - } - return found; -} -function sharedLegacyEvent(event) { + validateSetupHooks(harness, existing, target); + const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env } : {}); + const changed = updateHooks(harness, native.status, target, command, existing !== undefined); return { - sessionStart: "SessionStart", - userPromptSubmitted: "UserPromptSubmit", - postToolUse: "PostToolUse", - sessionEnd: "Stop", - }[event] ?? event; + harness, + action: "setup", + status: native.status === "native-complete" ? "complete" : "manual-required", + nativeCli: native.nativeCli, + hooks: { path: target, changed }, + guide: native.guide, + }; } -function kiroHook(command, event) { +export function removeHarness(harness, options = {}) { + const target = setupPath(harness, options.home); + const existing = readSetupConfiguration(target); + validateSetupHooks(harness, existing, target); + const native = runHarnessLifecycle(harness, "remove", options.env ? { env: options.env } : {}); + const changed = removeHooks(harness, target, existing !== undefined); return { - name: `agent-lcm-kiro-${event}`, - trigger: event, - action: { type: "command", command: captureCommand(command, "kiro", event) }, + harness, + action: "remove", + status: native.status === "native-complete" ? "complete" : native.status, + nativeCli: native.nativeCli, + hooks: { path: target, changed }, + guide: native.guide, }; } -function captureCommand(command, harness, event) { - return `node "${command}" capture --harness ${harness} ${event}`; +export function setupStatus(options = {}) { + return Object.fromEntries(SETUP_HARNESSES.map((harness) => { + const target = setupPath(harness, options.home); + return [harness, { configured: setupHooksConfigured(harness, readConfigurationForStatus(target)), path: target }]; + })); } -function assertSafeCommand(command) { - if (!command) - throw new Error("setup command must not be empty"); - if (!path.isAbsolute(command) && !/^[A-Za-z]:[\\/]/u.test(command)) { - throw new Error("setup command must be an absolute binary path"); +function updateHooks(harness, nativeStatus, target, command, targetExists) { + if (harness === "codex" || harness === "kiro") { + return mutateSetupConfiguration(target, (existing) => mergeSetupHooks(existing, harness, command, target)); } - if (/["'`$;&|<>\n\r%^]/u.test(command) || command.endsWith("\\")) { - throw new Error("setup command contains unsafe shell characters"); + if ((harness === "copilot" || harness === "vscode") && nativeStatus === "native-complete" && targetExists) { + return mutateSetupConfiguration(target, (existing) => removeSharedSetupHooks(existing ?? {}, harness, target)); } + return false; } -function configured(harness, target) { - const configuration = readConfigurationForStatus(target); - if (!configuration) +function removeHooks(harness, target, targetExists) { + if (harness === "copilot" || harness === "vscode" || !targetExists) return false; - if (harness === "kiro") { - const hooks = configuration.hooks; - if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) - return false; - const kiroHooks = hooks; - return eventsFor(harness).every((event) => kiroHooks.some((hook) => isExpectedKiroHook(hook, event))); - } - if (harness !== "codex" && configuration.version !== 1) - return false; - const hooksByEvent = configuration.hooks; - if (!isRecord(hooksByEvent)) - return false; - if (harness === "codex") - return CODEX_EVENTS.every((event) => { - const selectors = hooksByEvent[event]; - return Array.isArray(selectors) && selectors.some((selector) => isRecord(selector) - && Array.isArray(selector.hooks) - && selector.hooks.some((hook) => isCodexNativeHook(event) - ? isAgentLcmCodexHook(hook, event) - : isExpectedCommandHook(hook, harness, event))); - }); - if (isSharedHookHarness(harness) && hasSharedPascalRegistration(hooksByEvent)) - return false; - return setupEvents(harness).every(([event, captureEvent]) => { - const hooks = hooksByEvent[event]; - return Array.isArray(hooks) && hooks.some((entry) => isExpectedCommandHook(entry, setupCaptureHarness(harness), captureEvent)); - }); + return mutateSetupConfiguration(target, (existing) => existing === undefined + ? undefined + : removeSetupHooks(existing, harness, target)); } function readConfigurationForStatus(target) { try { @@ -217,84 +65,3 @@ function readConfigurationForStatus(target) { return undefined; } } -function isExpectedCommandHook(value, harness, event) { - return isRecord(value) && (value.type === undefined || value.type === "command") && isCaptureCommand(value.command, harness, event); -} -function isCodexSelectors(value) { - return Array.isArray(value) && value.every((selector) => isRecord(selector) - && Array.isArray(selector.hooks) - && selector.hooks.every(isRecord)); -} -function isAgentLcmHook(value, event, harness) { - if ((value.type !== undefined && value.type !== "command") || typeof value.command !== "string") - return false; - const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" capture --harness (auto|codex|cursor|copilot|vscode|kiro) (sessionStart|userPromptSubmitted|postToolUse|sessionEnd|SessionStart|UserPromptSubmit|PostToolUse|Stop)$/u - .exec(value.command); - const captureEvent = harness === "cursor" - ? setupEvents("cursor").find(([hookEvent]) => hookEvent === event)?.[1] - : event; - if (!match || match[2] !== captureEvent) - return false; - return isSharedHookHarness(harness) - ? match[1] === "auto" || match[1] === "copilot" || match[1] === "vscode" - : match[1] === harness; -} -function isAgentLcmCodexHook(value, event) { - if (!isRecord(value) || (value.type !== undefined && value.type !== "command") || typeof value.command !== "string") { - return false; - } - const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" hook (PreToolUse|PreCompact|PostCompact|SubagentStop)$/u.exec(value.command); - return match?.[1] === event; -} -function isCodexNativeHook(event) { - return event === "PreToolUse" || event === "PreCompact" || event === "PostCompact" || event === "SubagentStop"; -} -function hasSharedPascalRegistration(hooksByEvent) { - return ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"].some((event) => { - const hooks = hooksByEvent[event]; - return Array.isArray(hooks) && hooks.some((hook) => { - if (!isRecord(hook) || hook.type !== "command" || typeof hook.command !== "string") - return false; - return isAgentLcmHook(hook, event, "vscode"); - }); - }); -} -function isExpectedKiroHook(value, event) { - return isKiroHook(value) - && value.name === `agent-lcm-kiro-${event}` - && value.trigger === event - && isCaptureCommand(value.action.command, "kiro", event); -} -function isKiroHook(value) { - return isRecord(value) - && typeof value.name === "string" - && typeof value.trigger === "string" - && isRecord(value.action) - && value.action.type === "command" - && typeof value.action.command === "string"; -} -function isCaptureCommand(value, harness, event) { - if (typeof value !== "string") - return false; - const prefix = 'node "'; - const suffix = ` capture --harness ${harness} ${event}`; - if (!value.startsWith(prefix) || !value.endsWith(suffix)) - return false; - const quoteEnd = value.length - suffix.length - 1; - if (value[quoteEnd] !== "\"") - return false; - const command = value.slice(prefix.length, quoteEnd); - try { - assertSafeCommand(command); - return true; - } - catch { - return false; - } -} -function invalidConfiguration(target) { - return new Error(`Cannot update invalid setup configuration: ${target}`); -} -function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} From a787681bf2fddeffaf4453d65c086171218751ba Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 01:07:45 -0400 Subject: [PATCH 12/29] fix(setup): prepare new native client homes --- dist/setup-files.js | 2 +- dist/setup.js | 4 +++- src/setup-files.ts | 2 +- src/setup.ts | 5 ++++- tests/setup.test.ts | 3 ++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/dist/setup-files.js b/dist/setup-files.js index 7b127e2..185fbdf 100644 --- a/dist/setup-files.js +++ b/dist/setup-files.js @@ -160,7 +160,7 @@ function readSetupFile(target) { throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); return fs.readFileSync(target); } -function ensureSetupDirectory(directory) { +export function ensureSetupDirectory(directory) { const created = fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); if (created !== undefined) fs.chmodSync(directory, 0o700); diff --git a/dist/setup.js b/dist/setup.js index 9ab1f46..0791219 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -1,5 +1,6 @@ +import path from "node:path"; import { runHarnessLifecycle } from "./setup-adapters.js"; -import { mutateSetupConfiguration, readSetupConfiguration } from "./setup-files.js"; +import { ensureSetupDirectory, mutateSetupConfiguration, readSetupConfiguration } from "./setup-files.js"; import { assertSafeSetupCommand, setupHooksConfigured } from "./setup-hook-status.js"; import { mergeSetupHooks, removeSetupHooks, removeSharedSetupHooks, validateSetupHooks, } from "./setup-hooks.js"; import { SETUP_HARNESSES, setupPath } from "./setup-targets.js"; @@ -9,6 +10,7 @@ export function setupHarness(harness, options) { assertSafeSetupCommand(command); const existing = readSetupConfiguration(target); validateSetupHooks(harness, existing, target); + ensureSetupDirectory(path.dirname(target)); const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env } : {}); const changed = updateHooks(harness, native.status, target, command, existing !== undefined); return { diff --git a/src/setup-files.ts b/src/setup-files.ts index 8665c57..7efdd72 100644 --- a/src/setup-files.ts +++ b/src/setup-files.ts @@ -151,7 +151,7 @@ function readSetupFile(target: string): Buffer | undefined { return fs.readFileSync(target); } -function ensureSetupDirectory(directory: string): void { +export function ensureSetupDirectory(directory: string): void { const created = fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); if (created !== undefined) fs.chmodSync(directory, 0o700); } diff --git a/src/setup.ts b/src/setup.ts index ae1e733..a3a7b6e 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,6 +1,8 @@ +import path from "node:path"; + import type { CaptureHarness } from "./harnesses.ts"; import { runHarnessLifecycle, type HarnessCli } from "./setup-adapters.ts"; -import { mutateSetupConfiguration, readSetupConfiguration } from "./setup-files.ts"; +import { ensureSetupDirectory, mutateSetupConfiguration, readSetupConfiguration } from "./setup-files.ts"; import { assertSafeSetupCommand, setupHooksConfigured } from "./setup-hook-status.ts"; import { mergeSetupHooks, @@ -37,6 +39,7 @@ export function setupHarness(harness: CaptureHarness, options: SetupOptions): Se assertSafeSetupCommand(command); const existing = readSetupConfiguration(target); validateSetupHooks(harness, existing, target); + ensureSetupDirectory(path.dirname(target)); const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env } : {}); const changed = updateHooks(harness, native.status, target, command, existing !== undefined); return { diff --git a/tests/setup.test.ts b/tests/setup.test.ts index e62cdac..5f78506 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -524,7 +524,7 @@ test("setup prints a clear result for people and keeps JSON output for scripts", }); test("CLI setup and remove use native Codex with an isolated explicit home", (t) => { - const home = tempHome("agent-lcm-cli-native-"); + const home = path.join(tempHome("agent-lcm-cli-native-"), "new-codex-home"); const fake = fakeLifecycleCli(t, "codex"); const env = { PATH: fake.path, AGENT_LCM_FAKE_LOG: fake.log }; @@ -724,6 +724,7 @@ function fakeLifecycleCli( const log = path.join(bin, "calls.jsonl"); const script = `#!/usr/bin/env node const fs = require("node:fs"); +if (!fs.existsSync(process.env.CODEX_HOME)) process.exit(24); fs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify({ argv: process.argv.slice(2), env: { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, CODEX_HOME: process.env.CODEX_HOME, COPILOT_HOME: process.env.COPILOT_HOME, AGENT_LCM_HOME: process.env.AGENT_LCM_HOME } }) + "\\n"); `; fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); From 41f869f7af57d85afa64dd2bb692ab2a8b5ee10a Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 01:24:02 -0400 Subject: [PATCH 13/29] fix(setup): harden native installation boundary --- README.md | 15 +++++----- dist/setup-adapters.js | 22 +++++++------- dist/setup-files.js | 53 +++++++++++++++++++++++++++++++++- docs/architecture.md | 7 +++-- docs/install/codex.md | 16 +++++++---- docs/install/copilot.md | 15 ++++++---- docs/install/vscode.md | 14 ++++++--- docs/troubleshooting.md | 10 ++++--- src/AGENTS.md | 3 +- src/setup-adapters.ts | 23 ++++++++------- src/setup-files.ts | 46 ++++++++++++++++++++++++++++- tests/distribution.test.ts | 2 +- tests/setup-adapters.test.ts | 56 ++++++++++++++++++++++++------------ tests/setup-files.test.ts | 27 +++++++++++++++++ tests/setup.test.ts | 21 ++++++++++++-- 15 files changed, 256 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 511ff63..63af13e 100644 --- a/README.md +++ b/README.md @@ -164,12 +164,12 @@ report fields. Native lifecycle support is limited to the commands that each client documents: -- Codex probes with `codex plugin list`, then runs `codex plugin marketplace add - Team-Volt/agent-lcm` and `codex plugin add agent-lcm@agent-lcm`. Removal runs - `codex plugin remove agent-lcm@agent-lcm`. +- Codex probes with `codex plugin list`, adds the installed Agent LCM package + directory as a local marketplace, then runs `codex plugin add + agent-lcm@agent-lcm`. Removal runs `codex plugin remove agent-lcm@agent-lcm`. - GitHub Copilot CLI and VS Code share the Copilot plugin store. Setup probes - with `copilot plugin list` and runs `copilot plugin install - Team-Volt/agent-lcm`. `agent-lcm remove copilot` and `agent-lcm remove vscode` + with `copilot plugin list` and installs the same local Agent LCM package + directory. `agent-lcm remove copilot` and `agent-lcm remove vscode` return `shared-retained` without uninstalling that shared plugin; use the documented Copilot uninstall command only after reviewing both clients. - Cursor and Kiro are probed with `cursor-agent --version` and `kiro-cli @@ -181,8 +181,9 @@ Setup validates an existing hook file before invoking a native CLI, preserves unrelated entries, and changes only exact Agent LCM-owned registrations. It backs up a changed file as `*-pre-agent-lcm-*.json`, uses a per-file SQLite lock at `.lock.sqlite`, and publishes through a unique `wx` temporary -file, `fsync`, and rename. Symlinked or non-regular targets are refused, and -hook commands must be absolute paths without shell metacharacters. These rules +file, `fsync`, and rename. Symlinked directory components, lock files, targets, +and non-regular files are refused. Hook commands must be absolute paths without +shell metacharacters. These rules make repeated setup and removal safe while avoiding a second user-level hook copy after native installation. diff --git a/dist/setup-adapters.js b/dist/setup-adapters.js index 0fcc886..907e6e9 100644 --- a/dist/setup-adapters.js +++ b/dist/setup-adapters.js @@ -1,4 +1,6 @@ import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; export class NativeLifecycleCommandError extends Error { name = "NativeLifecycleCommandError"; executable; @@ -14,7 +16,8 @@ export class NativeLifecycleCommandError extends Error { } } const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; -const MAX_STDERR_CHARS = 4_096; +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SUPPRESSED_STDERR = "suppressed"; export const HARNESS_LIFECYCLE_ADAPTERS = { codex: { kind: "codex", @@ -22,7 +25,7 @@ export const HARNESS_LIFECYCLE_ADAPTERS = { guide: `${GUIDE_ROOT}/codex.md`, probeArgv: ["plugin", "list"], setupArgv: [ - ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], ["plugin", "add", "agent-lcm@agent-lcm"], ], removeArgv: ["plugin", "remove", "agent-lcm@agent-lcm"], @@ -33,14 +36,14 @@ export const HARNESS_LIFECYCLE_ADAPTERS = { executable: "copilot", guide: `${GUIDE_ROOT}/vscode.md`, probeArgv: ["plugin", "list"], - setupArgv: [["plugin", "install", "Team-Volt/agent-lcm"]], + setupArgv: [["plugin", "install", PACKAGE_ROOT]], }, copilot: { kind: "copilot", executable: "copilot", guide: `${GUIDE_ROOT}/copilot.md`, probeArgv: ["plugin", "list"], - setupArgv: [["plugin", "install", "Team-Volt/agent-lcm"]], + setupArgv: [["plugin", "install", PACKAGE_ROOT]], }, kiro: { kind: "manual", executable: "kiro-cli", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/kiro.md` }, }; @@ -61,9 +64,12 @@ export function runHarnessLifecycle(harness, action, options = {}) { } function runNative(harness, action, adapter, env) { const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); - if (isEnoent(probe.error) || probe.status !== 0) { + if (isEnoent(probe.error)) { return outcome(harness, action, "manual-required", null, adapter.guide); } + if (probe.error !== undefined || probe.status !== 0) { + throw new NativeLifecycleCommandError(adapter.executable, adapter.probeArgv, probe.status, SUPPRESSED_STDERR); + } const commands = action === "setup" ? adapter.setupArgv : adapter.kind === "codex" ? [adapter.removeArgv] : []; @@ -80,15 +86,11 @@ function runNativeCommand(executable, argv, env) { const result = spawnSync(executable, argv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); if (result.status === 0) return; - throw new NativeLifecycleCommandError(executable, argv, result.status, boundedStderr(result.stderr)); + throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); } function outcome(harness, action, status, nativeCli, guide) { return { harness, action, status, nativeCli, guide }; } -function boundedStderr(stderr) { - const value = typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? ""; - return value.slice(0, MAX_STDERR_CHARS).trimEnd(); -} function isEnoent(error) { return error !== undefined && "code" in error && error.code === "ENOENT"; } diff --git a/dist/setup-files.js b/dist/setup-files.js index 185fbdf..2501459 100644 --- a/dist/setup-files.js +++ b/dist/setup-files.js @@ -119,10 +119,10 @@ function backupSetupBytes(target, bytes) { function withSetupFileLock(target, callback) { const lockPath = `${target}.lock.sqlite`; const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; + ensureRegularLockFile(lockPath); const coordinator = new DatabaseSync(lockPath, { timeout: SETUP_LOCK_POLL_MS }); let transactionOpen = false; try { - fs.chmodSync(lockPath, 0o600); while (!transactionOpen) { try { coordinator.exec("BEGIN IMMEDIATE"); @@ -161,10 +161,61 @@ function readSetupFile(target) { return fs.readFileSync(target); } export function ensureSetupDirectory(directory) { + assertSafeDirectoryPath(directory); const created = fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + assertSafeDirectoryPath(directory); if (created !== undefined) fs.chmodSync(directory, 0o700); } +function ensureRegularLockFile(lockPath) { + let descriptor; + try { + descriptor = fs.openSync(lockPath, "wx", 0o600); + fs.fchmodSync(descriptor, 0o600); + fs.fsyncSync(descriptor); + } + catch (error) { + if (!hasCode(error, "EEXIST")) + throw error; + } + finally { + if (descriptor !== undefined) + fs.closeSync(descriptor); + } + const status = fs.lstatSync(lockPath); + if (status.isSymbolicLink()) + throw new Error(`Refusing setup lock symlink: ${lockPath}`); + if (!status.isFile()) + throw new Error(`Cannot use setup lock that is not a regular file: ${lockPath}`); + fs.chmodSync(lockPath, 0o600); +} +function assertSafeDirectoryPath(directory) { + const resolved = path.resolve(directory); + const root = path.parse(resolved).root; + let current = root; + for (const part of path.relative(root, resolved).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + let status; + try { + status = fs.lstatSync(current); + } + catch (error) { + if (hasCode(error, "ENOENT")) + continue; + throw error; + } + if (status.isSymbolicLink()) { + if (isDarwinSystemAlias(current)) + continue; + throw new Error(`Refusing setup directory symlink: ${current}`); + } + if (!status.isDirectory()) + throw new Error(`Cannot use setup path through a non-directory: ${current}`); + } +} +function isDarwinSystemAlias(target) { + return process.platform === "darwin" && (target === "/etc" || target === "/tmp" || target === "/var"); +} function fsyncPath(target) { const descriptor = fs.openSync(target, "r"); try { diff --git a/docs/architecture.md b/docs/architecture.md index 880780e..b14737a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,9 +30,10 @@ incompatible protocol. `agent-lcm setup ` probes and, where supported, runs the client's native plugin commands before updating legacy capture hooks. Codex uses `codex plugin -list`, then `codex plugin marketplace add Team-Volt/agent-lcm` and `codex plugin -add agent-lcm@agent-lcm`. Copilot CLI and VS Code use the shared Copilot store: -`copilot plugin list` followed by `copilot plugin install Team-Volt/agent-lcm`. +list`, adds the installed package directory as a local marketplace, then runs +`codex plugin add agent-lcm@agent-lcm`. Copilot CLI and VS Code use the shared +Copilot store and install that same local package directory after `copilot +plugin list` succeeds. Cursor and Kiro run version-only probes for `cursor-agent` and `kiro-cli`. Their Marketplace or Powers steps remain manual, so their native result is `manual-required`. diff --git a/docs/install/codex.md b/docs/install/codex.md index 5a7c40f..8169bc3 100644 --- a/docs/install/codex.md +++ b/docs/install/codex.md @@ -8,24 +8,28 @@ Run: agent-lcm setup codex ``` -This runs the Codex native lifecycle when the CLI is available and its plugin -probe succeeds. Codex does not accept hooks in its plugin manifest, so setup +This runs the Codex native lifecycle against the installed Agent LCM package +directory when the CLI is available and its plugin probe succeeds. Codex does +not accept hooks in its plugin manifest, so setup also installs or repairs the capture hooks at `~/.codex/hooks.json`. If the native probe is unavailable, setup reports `manual-required` with this guide while keeping that hook path available for capture. ## Native install and inspection -Use the documented Codex plugin flow: +Use the documented Codex plugin flow with the local directory that contains +Agent LCM's `plugin.json`. For a global npm install, `npm root --global` prints +the `` part of this path: ```sh -codex plugin marketplace add Team-Volt/agent-lcm +codex plugin marketplace add /@team-volt/agent-lcm codex plugin add agent-lcm@agent-lcm codex plugin list ``` -The first two commands add Agent LCM and the last command lists installed -plugins. See the [Codex plugin installation reference](https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md). +Do not type the angle-bracket placeholder as written. If you run Agent LCM from +a source checkout, use that checkout's root instead. The first two commands add +the local package and the last command lists installed plugins. See the [Codex plugin installation reference](https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md). After installation, start a new Codex thread so it picks up the plugin. If Codex asks you to trust plugin-owned commands, review the commands and approve diff --git a/docs/install/copilot.md b/docs/install/copilot.md index 462fe15..41f9087 100644 --- a/docs/install/copilot.md +++ b/docs/install/copilot.md @@ -8,8 +8,9 @@ Run: agent-lcm setup copilot ``` -This runs the Copilot native lifecycle when the CLI is available and its plugin -probe succeeds. Copilot CLI auto-loads the bundled hooks, so setup does not add +This runs the Copilot native lifecycle against the installed Agent LCM package +directory when the CLI is available and its plugin probe succeeds. Copilot CLI +auto-loads the bundled hooks, so setup does not add duplicate shared hooks after native installation. The manual fallback path is `~/.copilot/hooks/agent-lcm.json`; setup preserves an existing fallback when native installation is unavailable, adds no new duplicate, and reports @@ -17,14 +18,18 @@ native installation is unavailable, adds no new duplicate, and reports ## Native install and inspection -Use the documented Copilot CLI commands: +Use the documented Copilot CLI commands with the local directory that contains +Agent LCM's `plugin.json`. For a global npm install, `npm root --global` prints +the `` part of this path: ```sh -copilot plugin install Team-Volt/agent-lcm +copilot plugin install /@team-volt/agent-lcm copilot plugin list ``` -The list command shows installed plugins. See the [Copilot CLI plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference) for the command set and plugin specification. +Do not type the angle-bracket placeholder as written. If you run Agent LCM from +a source checkout, use that checkout's root instead. The list command shows +installed plugins. See the [Copilot CLI plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference) for the command set and plugin specification. The Copilot CLI reference does not require a restart after installation. If a new plugin is not visible, start a new Copilot session as troubleshooting and diff --git a/docs/install/vscode.md b/docs/install/vscode.md index 0a04b07..aaefc26 100644 --- a/docs/install/vscode.md +++ b/docs/install/vscode.md @@ -8,8 +8,9 @@ Run: agent-lcm setup vscode ``` -This runs the Copilot native lifecycle when the CLI is available and its plugin -probe succeeds. VS Code auto-loads hooks from the Copilot plugin store, so setup +This runs the Copilot native lifecycle against the installed Agent LCM package +directory when the CLI is available and its plugin probe succeeds. VS Code +auto-loads hooks from the Copilot plugin store, so setup does not add duplicate shared hooks after native installation. The manual fallback path is `~/.copilot/hooks/agent-lcm.json`; setup preserves an existing fallback when native installation is unavailable, adds no new duplicate, and @@ -18,13 +19,18 @@ reports `manual-required` with this guide. ## Native install and inspection VS Code automatically discovers plugins installed by Copilot CLI from -`~/.copilot/installed-plugins/`. To install through that shared store, run: +`~/.copilot/installed-plugins/`. To install through that shared store, use the +local directory that contains Agent LCM's `plugin.json`. For a global npm +install, `npm root --global` prints the `` part: ```sh -copilot plugin install Team-Volt/agent-lcm +copilot plugin install /@team-volt/agent-lcm copilot plugin list ``` +Do not type the angle-bracket placeholder as written. If you run Agent LCM from +a source checkout, use that checkout's root instead. + You can install from the VS Code UI instead. Open Extensions and search for `@agentPlugins`, or run `Chat: Install Plugin From Source` from the Command Palette and enter `https://github.com/Team-Volt/agent-lcm`. Inspect the result in diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b595dcf..0cb5a17 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -84,8 +84,9 @@ Replace `codex` with the harness you want to remove. Exit status `0` means the requested work completed. Exit status `2` means the native step is `manual-required`, or Copilot/VS Code removal returned `shared-retained` so the shared plugin was left in place. Exit status `1` means -the command failed; its stderr is the error record. Use `--json` for stable -automation fields. +the command failed; Agent LCM reports the fixed command, exit status, and a +suppressed-stderr marker so a client cannot leak secrets into logs. Use +`--json` for stable automation fields. Codex setup probes `codex plugin list`, then runs the marketplace-add and plugin-add commands. Removal runs `codex plugin remove agent-lcm@agent-lcm`. @@ -99,8 +100,9 @@ Kiro Powers installation/removal stay manual. Setup validates the existing JSON before starting a native CLI. It changes only exact Agent LCM-owned hook entries and preserves unrelated or near-matching entries. A changed file gets a collision-safe `-pre-agent-lcm-` backup. Setup -also refuses symlinked or non-regular targets and uses a per-file SQLite lock -at `.lock.sqlite`, with a ten-second bound, plus unique, fsynced +also refuses symlinked directory components, lock files, targets, and +non-regular files. It uses a per-file SQLite lock at `.lock.sqlite`, +with a ten-second bound, plus unique, fsynced temporary publication; a predictable temporary symlink cannot redirect the write. Hook commands must use an absolute shell-safe binary path. If validation fails, the original file and native CLI invocation remain unchanged. diff --git a/src/AGENTS.md b/src/AGENTS.md index 4bf81fe..0a0c7b0 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -45,7 +45,8 @@ - Probe and invoke only documented commands: Codex uses `codex plugin`; Copilot and VS Code use the shared `copilot plugin` store; Cursor and Kiro use version-only probes and keep plugin changes manual. - Validate existing setup JSON before starting a native process. Match owned hooks by harness, event, and command shape; preserve unrelated and near-matching entries. -- Keep setup-file writes under `.lock.sqlite` with a bounded SQLite `BEGIN IMMEDIATE` lock. Refuse symlinked or non-regular targets. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and parent-directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. +- Keep setup-file writes under `.lock.sqlite` with a bounded SQLite `BEGIN IMMEDIATE` lock. Refuse symlinked directory components, lock files, targets, and non-regular files. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and parent-directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. +- Install native plugins from the current package directory, never from a mutable remote ref. Treat only `ENOENT` as an unavailable CLI; all other native probe or command failures must stop before hook mutation and must not echo client stderr. - Require an absolute hook binary path and reject shell metacharacters before writing configuration. - Never uninstall the shared Copilot plugin for a single `copilot` or `vscode` removal; report `shared-retained` instead. diff --git a/src/setup-adapters.ts b/src/setup-adapters.ts index a612a54..b9e0cc4 100644 --- a/src/setup-adapters.ts +++ b/src/setup-adapters.ts @@ -1,4 +1,6 @@ import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { CaptureHarness } from "./harnesses.ts"; @@ -61,7 +63,8 @@ type ManualLifecycleAdapter = { export type HarnessLifecycleAdapter = CodexLifecycleAdapter | CopilotLifecycleAdapter | ManualLifecycleAdapter; const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; -const MAX_STDERR_CHARS = 4_096; +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SUPPRESSED_STDERR = "suppressed"; export const HARNESS_LIFECYCLE_ADAPTERS = { codex: { @@ -70,7 +73,7 @@ export const HARNESS_LIFECYCLE_ADAPTERS = { guide: `${GUIDE_ROOT}/codex.md`, probeArgv: ["plugin", "list"], setupArgv: [ - ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], ["plugin", "add", "agent-lcm@agent-lcm"], ], removeArgv: ["plugin", "remove", "agent-lcm@agent-lcm"], @@ -81,14 +84,14 @@ export const HARNESS_LIFECYCLE_ADAPTERS = { executable: "copilot", guide: `${GUIDE_ROOT}/vscode.md`, probeArgv: ["plugin", "list"], - setupArgv: [["plugin", "install", "Team-Volt/agent-lcm"]], + setupArgv: [["plugin", "install", PACKAGE_ROOT]], }, copilot: { kind: "copilot", executable: "copilot", guide: `${GUIDE_ROOT}/copilot.md`, probeArgv: ["plugin", "list"], - setupArgv: [["plugin", "install", "Team-Volt/agent-lcm"]], + setupArgv: [["plugin", "install", PACKAGE_ROOT]], }, kiro: { kind: "manual", executable: "kiro-cli", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/kiro.md` }, } satisfies Record; @@ -119,9 +122,12 @@ function runNative( env: NodeJS.ProcessEnv | undefined, ): HarnessLifecycleOutcome { const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); - if (isEnoent(probe.error) || probe.status !== 0) { + if (isEnoent(probe.error)) { return outcome(harness, action, "manual-required", null, adapter.guide); } + if (probe.error !== undefined || probe.status !== 0) { + throw new NativeLifecycleCommandError(adapter.executable, adapter.probeArgv, probe.status, SUPPRESSED_STDERR); + } const commands = action === "setup" ? adapter.setupArgv @@ -144,7 +150,7 @@ function manualOutcome( function runNativeCommand(executable: "codex" | "copilot", argv: readonly string[], env: NodeJS.ProcessEnv | undefined): void { const result = spawnSync(executable, argv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); if (result.status === 0) return; - throw new NativeLifecycleCommandError(executable, argv, result.status, boundedStderr(result.stderr)); + throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); } function outcome( @@ -157,11 +163,6 @@ function outcome( return { harness, action, status, nativeCli, guide }; } -function boundedStderr(stderr: string | Buffer | null | undefined): string { - const value = typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? ""; - return value.slice(0, MAX_STDERR_CHARS).trimEnd(); -} - function isEnoent(error: Error | undefined): boolean { return error !== undefined && "code" in error && error.code === "ENOENT"; } diff --git a/src/setup-files.ts b/src/setup-files.ts index 7efdd72..6dec75c 100644 --- a/src/setup-files.ts +++ b/src/setup-files.ts @@ -117,10 +117,10 @@ function backupSetupBytes(target: string, bytes: Buffer): void { function withSetupFileLock(target: string, callback: () => T): T { const lockPath = `${target}.lock.sqlite`; const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; + ensureRegularLockFile(lockPath); const coordinator = new DatabaseSync(lockPath, { timeout: SETUP_LOCK_POLL_MS }); let transactionOpen = false; try { - fs.chmodSync(lockPath, 0o600); while (!transactionOpen) { try { coordinator.exec("BEGIN IMMEDIATE"); @@ -152,10 +152,54 @@ function readSetupFile(target: string): Buffer | undefined { } export function ensureSetupDirectory(directory: string): void { + assertSafeDirectoryPath(directory); const created = fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + assertSafeDirectoryPath(directory); if (created !== undefined) fs.chmodSync(directory, 0o700); } +function ensureRegularLockFile(lockPath: string): void { + let descriptor: number | undefined; + try { + descriptor = fs.openSync(lockPath, "wx", 0o600); + fs.fchmodSync(descriptor, 0o600); + fs.fsyncSync(descriptor); + } catch (error) { + if (!hasCode(error, "EEXIST")) throw error; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } + const status = fs.lstatSync(lockPath); + if (status.isSymbolicLink()) throw new Error(`Refusing setup lock symlink: ${lockPath}`); + if (!status.isFile()) throw new Error(`Cannot use setup lock that is not a regular file: ${lockPath}`); + fs.chmodSync(lockPath, 0o600); +} + +function assertSafeDirectoryPath(directory: string): void { + const resolved = path.resolve(directory); + const root = path.parse(resolved).root; + let current = root; + for (const part of path.relative(root, resolved).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + let status: fs.Stats; + try { + status = fs.lstatSync(current); + } catch (error) { + if (hasCode(error, "ENOENT")) continue; + throw error; + } + if (status.isSymbolicLink()) { + if (isDarwinSystemAlias(current)) continue; + throw new Error(`Refusing setup directory symlink: ${current}`); + } + if (!status.isDirectory()) throw new Error(`Cannot use setup path through a non-directory: ${current}`); + } +} + +function isDarwinSystemAlias(target: string): boolean { + return process.platform === "darwin" && (target === "/etc" || target === "/tmp" || target === "/var"); +} + function fsyncPath(target: string): void { const descriptor = fs.openSync(target, "r"); try { diff --git a/tests/distribution.test.ts b/tests/distribution.test.ts index 816432f..b42c3a8 100644 --- a/tests/distribution.test.ts +++ b/tests/distribution.test.ts @@ -243,7 +243,7 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", assert.equal(JSON.parse(removed.stdout).hooks.changed, true); assert.deepEqual(fs.readFileSync(fakeLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)), [ ["plugin", "list"], - ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "marketplace", "add", packageRoot], ["plugin", "add", "agent-lcm@agent-lcm"], ["plugin", "list"], ["plugin", "remove", "agent-lcm@agent-lcm"], diff --git a/tests/setup-adapters.test.ts b/tests/setup-adapters.test.ts index 2a6159f..43b59af 100644 --- a/tests/setup-adapters.test.ts +++ b/tests/setup-adapters.test.ts @@ -3,10 +3,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { NativeLifecycleCommandError, runHarnessLifecycle } from "../src/setup-adapters.ts"; const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); test("Codex setup and remove send the exact argv", (t) => { // Given: a capable fake Codex CLI that records each argv vector. @@ -33,7 +35,7 @@ test("Codex setup and remove send the exact argv", (t) => { }); assert.deepEqual(readCalls(fake.log), [ ["plugin", "list"], - ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], ["plugin", "add", "agent-lcm@agent-lcm"], ["plugin", "list"], ["plugin", "remove", "agent-lcm@agent-lcm"], @@ -65,9 +67,9 @@ test("Copilot and VS Code setup send the exact Copilot argv", (t) => { }); assert.deepEqual(readCalls(fake.log), [ ["plugin", "list"], - ["plugin", "install", "Team-Volt/agent-lcm"], + ["plugin", "install", PACKAGE_ROOT], ["plugin", "list"], - ["plugin", "install", "Team-Volt/agent-lcm"], + ["plugin", "install", PACKAGE_ROOT], ]); }); @@ -83,8 +85,6 @@ test("manual-required outcomes probe only documented harness version commands", const missingCursor = runHarnessLifecycle("cursor", "remove", { env: { PATH: bin } }); const kiro = runHarnessLifecycle("kiro", "remove", { env: kiroCli.env }); const codex = runHarnessLifecycle("codex", "setup", { env: { PATH: bin } }); - const incapable = fakeCli(t, "copilot", ["plugin", "list"]); - const copilot = runHarnessLifecycle("copilot", "setup", { env: incapable.env }); // Then: each reports its canonical guide without a native success claim. assert.deepEqual(cursor, { @@ -115,16 +115,36 @@ test("manual-required outcomes probe only documented harness version commands", nativeCli: null, guide: `${GUIDE_ROOT}/codex.md`, }); - assert.deepEqual(copilot, { - harness: "copilot", - action: "setup", - status: "manual-required", - nativeCli: null, - guide: `${GUIDE_ROOT}/copilot.md`, - }); assert.deepEqual(readCalls(cursorCli.log), [["--version"]]); assert.deepEqual(readCalls(kiroCli.log), [["--version"]]); - assert.deepEqual(readCalls(incapable.log), [["plugin", "list"]]); +}); + +test("a failing native probe is a command error, not an unavailable CLI", (t) => { + const fake = fakeCli(t, "copilot", ["plugin", "list"]); + + assert.throws(() => runHarnessLifecycle("copilot", "setup", { env: fake.env }), (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.equal(error.executable, "copilot"); + assert.deepEqual(error.argv, ["plugin", "list"]); + assert.equal(error.status, 23); + assert.equal(error.stderr, "suppressed"); + return true; + }); + assert.deepEqual(readCalls(fake.log), [["plugin", "list"]]); +}); + +test("a native probe permission error is not treated as a missing CLI", { skip: process.platform === "win32" }, (t) => { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-denied-cli-")); + fs.writeFileSync(path.join(bin, "codex"), "denied\n", { mode: 0o600 }); + t.after(() => fs.rmSync(bin, { recursive: true, force: true })); + + assert.throws(() => runHarnessLifecycle("codex", "setup", { env: { PATH: bin } }), (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.deepEqual(error.argv, ["plugin", "list"]); + assert.equal(error.status, null); + assert.equal(error.stderr, "suppressed"); + return true; + }); }); test("shared-retained removal does not spawn Copilot uninstall", (t) => { @@ -155,7 +175,7 @@ test("shared-retained removal does not spawn Copilot uninstall", (t) => { test("mutating command failure is typed and cannot report completion", (t) => { // Given: a capable fake Codex CLI that fails its marketplace mutation. - const fake = fakeCli(t, "codex", ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"]); + const fake = fakeCli(t, "codex", ["plugin", "marketplace", "add", PACKAGE_ROOT]); // When: Agent LCM attempts Codex setup. const run = () => runHarnessLifecycle("codex", "setup", { env: fake.env }); @@ -164,18 +184,18 @@ test("mutating command failure is typed and cannot report completion", (t) => { assert.throws(run, (error: unknown) => { assert.ok(error instanceof NativeLifecycleCommandError); assert.equal(error.executable, "codex"); - assert.deepEqual(error.argv, ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"]); + assert.deepEqual(error.argv, ["plugin", "marketplace", "add", PACKAGE_ROOT]); assert.equal(error.status, 23); - assert.equal(error.stderr, "mutation failed"); + assert.equal(error.stderr, "suppressed"); assert.equal( error.message, - "Native lifecycle command failed: executable=codex argv=plugin marketplace add Team-Volt/agent-lcm status=23 stderr=mutation failed", + `Native lifecycle command failed: executable=codex argv=plugin marketplace add ${PACKAGE_ROOT} status=23 stderr=suppressed`, ); return true; }); assert.deepEqual(readCalls(fake.log), [ ["plugin", "list"], - ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], ]); }); diff --git a/tests/setup-files.test.ts b/tests/setup-files.test.ts index 79a2c8f..97e2c34 100644 --- a/tests/setup-files.test.ts +++ b/tests/setup-files.test.ts @@ -30,6 +30,33 @@ test("setup refuses a target symlink without changing the victim", { skip: proce assert.deepEqual(setupArtifacts(home), []); }); +test("setup refuses a symlinked lock without changing the victim", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-lock-symlink-"); + const target = path.join(home, "hooks.json"); + const victim = path.join(home, "victim.sqlite"); + fs.writeFileSync(victim, ""); + fs.chmodSync(victim, 0o644); + fs.symlinkSync(victim, `${target}.lock.sqlite`); + + assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /lock.*symlink/u); + + assert.equal(fs.lstatSync(`${target}.lock.sqlite`).isSymbolicLink(), true); + assert.equal(fs.statSync(victim).mode & 0o777, 0o644); + assert.equal(fs.existsSync(target), false); +}); + +test("setup refuses a symlinked parent directory", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-parent-symlink-"); + const victim = path.join(home, "victim"); + const linked = path.join(home, "linked"); + fs.mkdirSync(victim); + fs.symlinkSync(victim, linked); + + assert.throws(() => mutateSetupConfiguration(path.join(linked, "hooks.json"), () => ({ hooks: {} })), /directory symlink/u); + + assert.deepEqual(fs.readdirSync(victim), []); +}); + test("invalid setup bytes remain unchanged without backup or temporary files", () => { // Given: an existing target contains invalid JSON bytes. const home = tempHome("agent-lcm-setup-invalid-"); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 5f78506..fbc4ab7 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -9,6 +9,7 @@ import { assertCliOk, runCli, tempHome } from "./helpers.ts"; const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; const NO_CLI_ENV = { PATH: "" }; +const PACKAGE_ROOT = path.resolve("."); test("remove Codex deletes only exact owned hooks and is repeatable", (t) => { const fake = fakeSetupCli(t, "codex"); @@ -163,7 +164,7 @@ test("Codex setup runs native setup and still installs user hooks", (t) => { }); assert.deepEqual(readSetupCalls(fake.log), [ ["plugin", "list"], - ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], ["plugin", "add", "agent-lcm@agent-lcm"], ]); assert.equal(fs.existsSync(path.join(clientHome, "hooks.json")), true); @@ -546,7 +547,7 @@ test("CLI setup and remove use native Codex with an isolated explicit home", (t) const calls = fs.readFileSync(fake.log, "utf8").trim().split("\n").map((line) => JSON.parse(line)); assert.deepEqual(calls.map((call) => call.argv), [ ["plugin", "list"], - ["plugin", "marketplace", "add", "Team-Volt/agent-lcm"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], ["plugin", "add", "agent-lcm@agent-lcm"], ["plugin", "list"], ["plugin", "remove", "agent-lcm@agent-lcm"], @@ -560,6 +561,20 @@ test("CLI setup and remove use native Codex with an isolated explicit home", (t) }); }); +test("CLI reports a native probe failure without writing hooks or leaking stderr", (t) => { + const home = path.join(tempHome("agent-lcm-cli-probe-failure-"), "new-codex-home"); + const fake = fakeLifecycleCli(t, "codex", true); + + const result = runCli(["setup", "codex", "--home", home, "--json"], { + env: { PATH: fake.path, AGENT_LCM_FAKE_LOG: fake.log }, + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /status=23 stderr=suppressed/u); + assert.doesNotMatch(result.stderr, /secret-token/u); + assert.equal(fs.existsSync(path.join(home, "hooks.json")), false); +}); + test("CLI remove reports unsupported native removal without changing shared resources", () => { const home = tempHome("agent-lcm-cli-shared-"); const target = path.join(home, "hooks", "agent-lcm.json"); @@ -719,6 +734,7 @@ function fakeSetupCli( function fakeLifecycleCli( t: test.TestContext, name: "codex" | "copilot", + failProbe = false, ): { readonly path: string; readonly log: string } { const bin = fs.mkdtempSync(path.join(tempHome("agent-lcm-lifecycle-cli-parent-"), "bin-")); const log = path.join(bin, "calls.jsonl"); @@ -726,6 +742,7 @@ function fakeLifecycleCli( const fs = require("node:fs"); if (!fs.existsSync(process.env.CODEX_HOME)) process.exit(24); fs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify({ argv: process.argv.slice(2), env: { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, CODEX_HOME: process.env.CODEX_HOME, COPILOT_HOME: process.env.COPILOT_HOME, AGENT_LCM_HOME: process.env.AGENT_LCM_HOME } }) + "\\n"); +if (${String(failProbe)} && JSON.stringify(process.argv.slice(2)) === JSON.stringify(["plugin", "list"])) { process.stderr.write("secret-token\\n"); process.exit(23); } `; fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); t.after(() => fs.rmSync(path.dirname(bin), { recursive: true, force: true })); From 557cffde2c3e26a54d887fe2b8057bd900fb5ae5 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 01:27:06 -0400 Subject: [PATCH 14/29] test(distribution): compare canonical package path --- tests/distribution.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/distribution.test.ts b/tests/distribution.test.ts index b42c3a8..2d8e185 100644 --- a/tests/distribution.test.ts +++ b/tests/distribution.test.ts @@ -243,7 +243,7 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", assert.equal(JSON.parse(removed.stdout).hooks.changed, true); assert.deepEqual(fs.readFileSync(fakeLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)), [ ["plugin", "list"], - ["plugin", "marketplace", "add", packageRoot], + ["plugin", "marketplace", "add", fs.realpathSync(packageRoot)], ["plugin", "add", "agent-lcm@agent-lcm"], ["plugin", "list"], ["plugin", "remove", "agent-lcm@agent-lcm"], From 9fef60a09679c0f19d2b788673bb89f75ba754b1 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 01:42:06 -0400 Subject: [PATCH 15/29] fix(setup): close file races and status gaps --- README.md | 2 +- dist/cli.js | 2 +- dist/doctor.js | 17 ++++++-- dist/setup-files.js | 82 ++++++++++++++++++++++++++---------- dist/setup.js | 2 +- docs/install/copilot.md | 4 ++ docs/install/vscode.md | 5 +++ docs/troubleshooting.md | 20 +++++---- src/AGENTS.md | 3 +- src/cli.ts | 2 +- src/doctor.ts | 21 +++++++--- src/setup-files.ts | 68 ++++++++++++++++++++++-------- src/setup.ts | 4 +- tests/AGENTS.md | 3 ++ tests/doctor-import.test.ts | 10 +++++ tests/setup-files.test.ts | 83 +++++++++++++++++++++++++++++++++++++ tests/setup.test.ts | 6 ++- 17 files changed, 268 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 63af13e..12104e9 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Setup-managed and legacy user hook locations are: Cursor, Copilot, and VS Code native plugins carry their own hooks. Setup does not add a second user-level copy after native installation. -Check the result: +Check setup-managed and legacy hook files, then run the broader doctor checks: ```sh agent-lcm setup status diff --git a/dist/cli.js b/dist/cli.js index 06b44a6..1c0f496 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -331,7 +331,7 @@ function printSetupReports(value, json) { process.stdout.write(`Hooks ${report.hooks.changed ? "changed" : "unchanged"}: ${report.hooks.path}\n`); if (report.status === "manual-required") { process.stdout.write(report.nativeCli === null - ? "Native CLI unavailable or its capability probe failed.\n" + ? "Native CLI unavailable.\n" : `${report.nativeCli} is installed, but it has no supported noninteractive plugin ${report.action} command.\n`); } if (report.status !== "complete") diff --git a/dist/doctor.js b/dist/doctor.js index 7b878d1..b9121c6 100644 --- a/dist/doctor.js +++ b/dist/doctor.js @@ -35,10 +35,19 @@ function adapterStatus(status) { detail: codexConfigured ? "Codex MCP and hooks are configured." : "Codex MCP or hooks are not configured.", ...(codexConfigured ? {} : { setup_gap: "Install the Agent LCM plugin and restart Codex." }), }, - cursor: setupAdapter("cursor", setups.cursor.configured), - vscode: setupAdapter("vscode", setups.vscode.configured), - copilot: setupAdapter("copilot", setups.copilot.configured), - kiro: setupAdapter("kiro", setups.kiro.configured), + cursor: setupAdapter("cursor", setups.cursor.hooksConfigured), + vscode: nativePluginAdapter("VS Code", setups.vscode.hooksConfigured), + copilot: nativePluginAdapter("Copilot", setups.copilot.hooksConfigured), + kiro: setupAdapter("kiro", setups.kiro.hooksConfigured), + }; +} +function nativePluginAdapter(harness, legacyHooksConfigured) { + if (legacyHooksConfigured) + return setupAdapter(harness.toLowerCase(), true); + return { + configured: null, + state: "unknown", + detail: `${harness} native plugin health is not checked by doctor. Run \`copilot plugin list\` or use the client's installed-plugin view.`, }; } function setupAdapter(harness, configured) { diff --git a/dist/setup-files.js b/dist/setup-files.js index 2501459..4461232 100644 --- a/dist/setup-files.js +++ b/dist/setup-files.js @@ -119,10 +119,12 @@ function backupSetupBytes(target, bytes) { function withSetupFileLock(target, callback) { const lockPath = `${target}.lock.sqlite`; const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; - ensureRegularLockFile(lockPath); - const coordinator = new DatabaseSync(lockPath, { timeout: SETUP_LOCK_POLL_MS }); + const lockDescriptor = openRegularLockFile(lockPath); + let coordinator; let transactionOpen = false; try { + coordinator = new DatabaseSync(lockPath, { timeout: SETUP_LOCK_POLL_MS }); + assertSameFile(lockPath, fs.fstatSync(lockDescriptor)); while (!transactionOpen) { try { coordinator.exec("BEGIN IMMEDIATE"); @@ -140,25 +142,32 @@ function withSetupFileLock(target, callback) { } finally { if (transactionOpen) - coordinator.exec("ROLLBACK"); - coordinator.close(); + coordinator?.exec("ROLLBACK"); + coordinator?.close(); + fs.closeSync(lockDescriptor); } } function readSetupFile(target) { - let status; + let descriptor; try { - status = fs.lstatSync(target); + descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollowFlag()); } catch (error) { if (hasCode(error, "ENOENT")) return undefined; + if (hasCode(error, "ELOOP")) + throw new Error(`Refusing setup configuration symlink: ${target}`); throw error; } - if (status.isSymbolicLink()) - throw new Error(`Refusing setup configuration symlink: ${target}`); - if (!status.isFile()) - throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); - return fs.readFileSync(target); + try { + const status = fs.fstatSync(descriptor); + if (!status.isFile()) + throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); + return fs.readFileSync(descriptor); + } + finally { + fs.closeSync(descriptor); + } } export function ensureSetupDirectory(directory) { assertSafeDirectoryPath(directory); @@ -167,27 +176,54 @@ export function ensureSetupDirectory(directory) { if (created !== undefined) fs.chmodSync(directory, 0o700); } -function ensureRegularLockFile(lockPath) { +function openRegularLockFile(lockPath) { let descriptor; try { - descriptor = fs.openSync(lockPath, "wx", 0o600); + try { + descriptor = fs.openSync(lockPath, fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollowFlag(), 0o600); + } + catch (error) { + if (!hasCode(error, "EEXIST")) + throw error; + descriptor = fs.openSync(lockPath, fs.constants.O_RDWR | noFollowFlag()); + } + const status = fs.fstatSync(descriptor); + if (!status.isFile()) + throw new Error(`Cannot use setup lock that is not a regular file: ${lockPath}`); fs.fchmodSync(descriptor, 0o600); fs.fsyncSync(descriptor); + return descriptor; } catch (error) { - if (!hasCode(error, "EEXIST")) - throw error; - } - finally { if (descriptor !== undefined) fs.closeSync(descriptor); + if (hasCode(error, "ELOOP")) + throw new Error(`Refusing setup lock symlink: ${lockPath}`); + throw error; } - const status = fs.lstatSync(lockPath); - if (status.isSymbolicLink()) - throw new Error(`Refusing setup lock symlink: ${lockPath}`); - if (!status.isFile()) - throw new Error(`Cannot use setup lock that is not a regular file: ${lockPath}`); - fs.chmodSync(lockPath, 0o600); +} +function assertSameFile(target, expected) { + let descriptor; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollowFlag()); + } + catch (error) { + if (hasCode(error, "ELOOP")) + throw new Error(`Setup lock path changed while opening: ${target}`); + throw error; + } + try { + const actual = fs.fstatSync(descriptor); + if (!actual.isFile() || actual.dev !== expected.dev || actual.ino !== expected.ino) { + throw new Error(`Setup lock path changed while opening: ${target}`); + } + } + finally { + fs.closeSync(descriptor); + } +} +function noFollowFlag() { + return fs.constants.O_NOFOLLOW ?? 0; } function assertSafeDirectoryPath(directory) { const resolved = path.resolve(directory); diff --git a/dist/setup.js b/dist/setup.js index 0791219..cad9289 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -40,7 +40,7 @@ export function removeHarness(harness, options = {}) { export function setupStatus(options = {}) { return Object.fromEntries(SETUP_HARNESSES.map((harness) => { const target = setupPath(harness, options.home); - return [harness, { configured: setupHooksConfigured(harness, readConfigurationForStatus(target)), path: target }]; + return [harness, { hooksConfigured: setupHooksConfigured(harness, readConfigurationForStatus(target)), path: target }]; })); } function updateHooks(harness, nativeStatus, target, command, targetExists) { diff --git a/docs/install/copilot.md b/docs/install/copilot.md index 41f9087..2b62681 100644 --- a/docs/install/copilot.md +++ b/docs/install/copilot.md @@ -35,6 +35,10 @@ The Copilot CLI reference does not require a restart after installation. If a new plugin is not visible, start a new Copilot session as troubleshooting and run `copilot plugin list` again. +`agent-lcm setup status` reports legacy `hooksConfigured` state, not live +native plugin health. `false` is expected after a successful native install; +use `copilot plugin list` for the native check. + > Warning: GitHub Copilot CLI and VS Code share the Copilot plugin store and the hook file `~/.copilot/hooks/agent-lcm.json`. A deliberate native uninstall affects both harnesses. Do not uninstall the shared plugin when you mean to remove only Copilot CLI. ## Remove Agent LCM diff --git a/docs/install/vscode.md b/docs/install/vscode.md index aaefc26..891d6f7 100644 --- a/docs/install/vscode.md +++ b/docs/install/vscode.md @@ -41,6 +41,11 @@ before confirming. The official guide does not require a restart. If the plugin does not appear, use the documented Installed view and Command Palette refresh actions as troubleshooting. +`agent-lcm setup status` reports legacy `hooksConfigured` state, not live +native plugin health. `false` is expected after a successful native install; +use the Agent Plugins - Installed view or `copilot plugin list` for the native +check. + > Warning: VS Code and GitHub Copilot share the Copilot plugin store and the hook file `~/.copilot/hooks/agent-lcm.json`. A deliberate native uninstall affects both harnesses. Do not uninstall the shared plugin when you mean to remove only VS Code. ## Remove Agent LCM diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0cb5a17..5e22e09 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -10,7 +10,8 @@ agent-lcm daemon status `doctor` checks Codex plugin wiring, the recall skill, the shared daemon, the capture queue, quarantine, SQLite, and summary indexing. `setup status` reports -the harness hook files separately. +`hooksConfigured` for setup-managed or legacy hook files; it does not claim to +check native plugin health. ## The MCP server is missing @@ -32,7 +33,7 @@ prefer native plugin installation when that happens. ## Hooks are not capturing -Install or repair the harness hook file, then restart the harness: +Install or repair Agent LCM, then restart the harness: ```sh agent-lcm setup all @@ -45,13 +46,16 @@ home, configure it explicitly and pass that directory: agent-lcm setup codex --home /path/to/codex-home ``` -Codex loads `~/.codex/hooks.json`; Cursor loads `~/.cursor/hooks.json`. VS Code and GitHub Copilot share -`~/.copilot/hooks/agent-lcm.json`, and the generated hook detects which one sent -the event. Setup refuses malformed existing JSON instead of overwriting it. -Before changing a valid existing file, setup saves a timestamped -`-pre-agent-lcm-` backup in the same directory. +Codex loads setup-managed hooks from `~/.codex/hooks.json`; Kiro uses +`~/.kiro/hooks/agent-lcm.json`. Cursor, VS Code, and GitHub Copilot native +plugins carry their own hooks. Cursor setup preserves an older +`~/.cursor/hooks.json` fallback until native installation is complete. A +successful Copilot or VS Code native setup removes only exact older Agent LCM +entries from the shared fallback so capture does not run twice. Setup refuses +malformed existing JSON instead of overwriting it. Before changing a valid +existing file, setup saves a timestamped `-pre-agent-lcm-` backup beside it. -Codex and Cursor may ask you to review or trust plugin-owned commands. Capture +Codex, Cursor, VS Code, and Copilot may ask you to review or trust plugin-owned commands. Capture will not run until the harness allows those hooks. Check whether events reach the queue and daemon: diff --git a/src/AGENTS.md b/src/AGENTS.md index 0a0c7b0..24d12b5 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -45,10 +45,11 @@ - Probe and invoke only documented commands: Codex uses `codex plugin`; Copilot and VS Code use the shared `copilot plugin` store; Cursor and Kiro use version-only probes and keep plugin changes manual. - Validate existing setup JSON before starting a native process. Match owned hooks by harness, event, and command shape; preserve unrelated and near-matching entries. -- Keep setup-file writes under `.lock.sqlite` with a bounded SQLite `BEGIN IMMEDIATE` lock. Refuse symlinked directory components, lock files, targets, and non-regular files. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and parent-directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. +- Keep setup-file writes under `.lock.sqlite` with a bounded SQLite `BEGIN IMMEDIATE` lock. Open lock and target files through descriptor-bound no-follow checks; never validate by path and then read or chmod that path. Refuse symlinked directory components, lock files, targets, and non-regular files. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and parent-directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. - Install native plugins from the current package directory, never from a mutable remote ref. Treat only `ENOENT` as an unavailable CLI; all other native probe or command failures must stop before hook mutation and must not echo client stderr. - Require an absolute hook binary path and reject shell metacharacters before writing configuration. - Never uninstall the shared Copilot plugin for a single `copilot` or `vscode` removal; report `shared-retained` instead. +- `setup status` reports legacy/setup-managed `hooksConfigured` state only. Doctor must report native Copilot/VS Code health as unknown unless it has direct native evidence; it must not recommend setup from a missing legacy hook file. ## Test routing diff --git a/src/cli.ts b/src/cli.ts index 4c4c7d1..95340ab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -372,7 +372,7 @@ function printSetupReports(value: SetupReport | RemoveReport | SetupReport[], js process.stdout.write(`Hooks ${report.hooks.changed ? "changed" : "unchanged"}: ${report.hooks.path}\n`); if (report.status === "manual-required") { process.stdout.write(report.nativeCli === null - ? "Native CLI unavailable or its capability probe failed.\n" + ? "Native CLI unavailable.\n" : `${report.nativeCli} is installed, but it has no supported noninteractive plugin ${report.action} command.\n`); } if (report.status !== "complete") process.stdout.write(`Manual steps: ${report.guide}\n`); diff --git a/src/doctor.ts b/src/doctor.ts index db9653f..f38043e 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -20,8 +20,8 @@ export type DoctorReport = { }; export type AdapterStatus = { - configured: boolean; - state: "configured" | "not_configured"; + configured: boolean | null; + state: "configured" | "not_configured" | "unknown"; detail: string; setup_gap?: string; }; @@ -117,10 +117,19 @@ function adapterStatus(status: Record): Record(target: string, callback: () => T): T { const lockPath = `${target}.lock.sqlite`; const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; - ensureRegularLockFile(lockPath); - const coordinator = new DatabaseSync(lockPath, { timeout: SETUP_LOCK_POLL_MS }); + const lockDescriptor = openRegularLockFile(lockPath); + let coordinator: DatabaseSync | undefined; let transactionOpen = false; try { + coordinator = new DatabaseSync(lockPath, { timeout: SETUP_LOCK_POLL_MS }); + assertSameFile(lockPath, fs.fstatSync(lockDescriptor)); while (!transactionOpen) { try { coordinator.exec("BEGIN IMMEDIATE"); @@ -133,22 +135,28 @@ function withSetupFileLock(target: string, callback: () => T): T { } return callback(); } finally { - if (transactionOpen) coordinator.exec("ROLLBACK"); - coordinator.close(); + if (transactionOpen) coordinator?.exec("ROLLBACK"); + coordinator?.close(); + fs.closeSync(lockDescriptor); } } function readSetupFile(target: string): Buffer | undefined { - let status: fs.Stats; + let descriptor: number; try { - status = fs.lstatSync(target); + descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollowFlag()); } catch (error) { if (hasCode(error, "ENOENT")) return undefined; + if (hasCode(error, "ELOOP")) throw new Error(`Refusing setup configuration symlink: ${target}`); throw error; } - if (status.isSymbolicLink()) throw new Error(`Refusing setup configuration symlink: ${target}`); - if (!status.isFile()) throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); - return fs.readFileSync(target); + try { + const status = fs.fstatSync(descriptor); + if (!status.isFile()) throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); + return fs.readFileSync(descriptor); + } finally { + fs.closeSync(descriptor); + } } export function ensureSetupDirectory(directory: string): void { @@ -158,21 +166,47 @@ export function ensureSetupDirectory(directory: string): void { if (created !== undefined) fs.chmodSync(directory, 0o700); } -function ensureRegularLockFile(lockPath: string): void { +function openRegularLockFile(lockPath: string): number { let descriptor: number | undefined; try { - descriptor = fs.openSync(lockPath, "wx", 0o600); + try { + descriptor = fs.openSync(lockPath, fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollowFlag(), 0o600); + } catch (error) { + if (!hasCode(error, "EEXIST")) throw error; + descriptor = fs.openSync(lockPath, fs.constants.O_RDWR | noFollowFlag()); + } + const status = fs.fstatSync(descriptor); + if (!status.isFile()) throw new Error(`Cannot use setup lock that is not a regular file: ${lockPath}`); fs.fchmodSync(descriptor, 0o600); fs.fsyncSync(descriptor); + return descriptor; } catch (error) { - if (!hasCode(error, "EEXIST")) throw error; - } finally { if (descriptor !== undefined) fs.closeSync(descriptor); + if (hasCode(error, "ELOOP")) throw new Error(`Refusing setup lock symlink: ${lockPath}`); + throw error; + } +} + +function assertSameFile(target: string, expected: fs.Stats): void { + let descriptor: number; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollowFlag()); + } catch (error) { + if (hasCode(error, "ELOOP")) throw new Error(`Setup lock path changed while opening: ${target}`); + throw error; } - const status = fs.lstatSync(lockPath); - if (status.isSymbolicLink()) throw new Error(`Refusing setup lock symlink: ${lockPath}`); - if (!status.isFile()) throw new Error(`Cannot use setup lock that is not a regular file: ${lockPath}`); - fs.chmodSync(lockPath, 0o600); + try { + const actual = fs.fstatSync(descriptor); + if (!actual.isFile() || actual.dev !== expected.dev || actual.ino !== expected.ino) { + throw new Error(`Setup lock path changed while opening: ${target}`); + } + } finally { + fs.closeSync(descriptor); + } +} + +function noFollowFlag(): number { + return fs.constants.O_NOFOLLOW ?? 0; } function assertSafeDirectoryPath(directory: string): void { diff --git a/src/setup.ts b/src/setup.ts index a3a7b6e..d8fd012 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -31,7 +31,7 @@ export type RemoveReport = { readonly guide: string; }; export type SetupStatusOptions = { readonly home?: string }; -export type HarnessSetupStatus = { readonly configured: boolean; readonly path: string }; +export type HarnessSetupStatus = { readonly hooksConfigured: boolean; readonly path: string }; export function setupHarness(harness: CaptureHarness, options: SetupOptions): SetupReport { const target = setupPath(harness, options.home); @@ -71,7 +71,7 @@ export function removeHarness(harness: CaptureHarness, options: RemoveOptions = export function setupStatus(options: SetupStatusOptions = {}): Record { return Object.fromEntries(SETUP_HARNESSES.map((harness) => { const target = setupPath(harness, options.home); - return [harness, { configured: setupHooksConfigured(harness, readConfigurationForStatus(target)), path: target }]; + return [harness, { hooksConfigured: setupHooksConfigured(harness, readConfigurationForStatus(target)), path: target }]; })) as Record; } diff --git a/tests/AGENTS.md b/tests/AGENTS.md index aa1cc2b..195c9ee 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -59,6 +59,9 @@ framework, fixture library, or custom runner. - For fsync or publication faults, assert the failed subprocess leaves no acknowledged event, then run the normal retry and verify one raw and one indexed event. +- For setup-file symlink races, swap the path at the old path-check/read or + chmod boundary and prove descriptor-bound I/O neither reads nor changes the + victim. - Prefer direct assertions on counts, IDs, paths, and JSON fields over broad snapshots; preserve security checks that prove secrets are absent. diff --git a/tests/doctor-import.test.ts b/tests/doctor-import.test.ts index 49aff6e..5fca602 100644 --- a/tests/doctor-import.test.ts +++ b/tests/doctor-import.test.ts @@ -65,6 +65,16 @@ test("doctor reports actionable recommendations for an unwired empty install", ( detail: "Not configured.", setup_gap: "Run `agent-lcm setup cursor`, then restart Cursor.", }); + assert.deepEqual(report.adapter_status.vscode, { + configured: null, + state: "unknown", + detail: "VS Code native plugin health is not checked by doctor. Run `copilot plugin list` or use the client's installed-plugin view.", + }); + assert.deepEqual(report.adapter_status.copilot, { + configured: null, + state: "unknown", + detail: "Copilot native plugin health is not checked by doctor. Run `copilot plugin list` or use the client's installed-plugin view.", + }); assert.equal(report.adapter_status.codex.configured, false); assert.equal(report.recommendations.some((text: string) => text.includes("Install the Agent LCM plugin")), true); assert.equal(report.recommendations.some((text: string) => text.includes("import --all")), true); diff --git a/tests/setup-files.test.ts b/tests/setup-files.test.ts index 97e2c34..9588e35 100644 --- a/tests/setup-files.test.ts +++ b/tests/setup-files.test.ts @@ -57,6 +57,89 @@ test("setup refuses a symlinked parent directory", { skip: process.platform === assert.deepEqual(fs.readdirSync(victim), []); }); +test("a lock-path swap cannot change a symlink victim", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-lock-race-"); + const target = path.join(home, "hooks.json"); + const lock = `${target}.lock.sqlite`; + const victim = path.join(home, "victim.sqlite"); + fs.writeFileSync(lock, ""); + fs.writeFileSync(victim, ""); + fs.chmodSync(victim, 0o644); + const originalOpen = fs.openSync; + let lockOpens = 0; + const swappedOpen = ((candidate: fs.PathLike, flags: string | number, mode?: fs.Mode) => { + if (candidate.toString() === lock && (lockOpens += 1) === 2) { + fs.unlinkSync(lock); + fs.symlinkSync(victim, lock); + } + return originalOpen(candidate, flags, mode); + }) as typeof fs.openSync; + Object.defineProperty(fs, "openSync", { configurable: true, value: swappedOpen }); + try { + assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /lock (?:path changed|symlink)/u); + } finally { + Object.defineProperty(fs, "openSync", { configurable: true, value: originalOpen }); + } + assert.equal(fs.statSync(victim).mode & 0o777, 0o644); +}); + +test("a lock swap after a path check cannot chmod the victim", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-lock-chmod-race-"); + const target = path.join(home, "hooks.json"); + const lock = `${target}.lock.sqlite`; + const victim = path.join(home, "victim.sqlite"); + fs.writeFileSync(lock, ""); + fs.writeFileSync(victim, ""); + fs.chmodSync(victim, 0o644); + const originalLstat = fs.lstatSync; + let swapped = false; + const swappedLstat = ((candidate: fs.PathLike) => { + const status = originalLstat(candidate); + if (!swapped && candidate.toString() === lock) { + swapped = true; + fs.unlinkSync(lock); + fs.symlinkSync(victim, lock); + } + return status; + }) as typeof fs.lstatSync; + Object.defineProperty(fs, "lstatSync", { configurable: true, value: swappedLstat }); + try { + mutateSetupConfiguration(target, () => ({ hooks: {} })); + } finally { + Object.defineProperty(fs, "lstatSync", { configurable: true, value: originalLstat }); + } + assert.equal(fs.statSync(victim).mode & 0o777, 0o644); +}); + +test("a target-path swap cannot copy symlink-victim bytes", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-target-race-"); + const target = path.join(home, "hooks.json"); + const victim = path.join(home, "victim.json"); + fs.writeFileSync(target, '{"hooks":{}}\n'); + fs.writeFileSync(victim, '{"isolated-secret":true}\n'); + const originalRead = fs.readFileSync; + let swapped = false; + const swappedRead = ((candidate: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if (!swapped && typeof candidate === "string" && candidate === target) { + swapped = true; + fs.unlinkSync(target); + fs.symlinkSync(victim, target); + } + return Reflect.apply(originalRead, fs, [candidate, ...args]); + }) as typeof fs.readFileSync; + Object.defineProperty(fs, "readFileSync", { configurable: true, value: swappedRead }); + try { + mutateSetupConfiguration(target, (configuration) => ({ ...configuration, added: true })); + } finally { + Object.defineProperty(fs, "readFileSync", { configurable: true, value: originalRead }); + } + const backups = fs.readdirSync(home).filter((name) => name.startsWith("hooks-pre-agent-lcm-")); + assert.equal(backups.length, 1); + assert.doesNotMatch(fs.readFileSync(path.join(home, backups[0] ?? ""), "utf8"), /isolated-secret/u); + assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), { hooks: {}, added: true }); + assert.deepEqual(JSON.parse(fs.readFileSync(victim, "utf8")), { "isolated-secret": true }); +}); + test("invalid setup bytes remain unchanged without backup or temporary files", () => { // Given: an existing target contains invalid JSON bytes. const home = tempHome("agent-lcm-setup-invalid-"); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index fbc4ab7..0e4cea4 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -212,6 +212,10 @@ test("successful Copilot setup removes only legacy shared Agent LCM hooks", (t) PostToolUse: [], custom: [{ type: "command", command: "also-keep-me" }], } }); + assert.deepEqual(setupStatus({ home: clientHome }).copilot, { + hooksConfigured: false, + path: hooksPath, + }); }); test("manual setup preserves legacy shared hooks and creates no target", (t) => { @@ -461,7 +465,7 @@ test("Codex setup replaces its old Agent LCM commands and preserves unrelated ho assert.deepEqual(configuration.hooks.SubagentStop, [{ hooks: [ { type: "command", command: 'node "/new/bin/agent-lcm" hook SubagentStop' }, ] }]); - assert.equal(setupStatus({ home: clientHome }).codex.configured, true); + assert.equal(setupStatus({ home: clientHome }).codex.hooksConfigured, true); const backups = fs.readdirSync(clientHome).filter((name) => name.startsWith("hooks-pre-agent-lcm-")); assert.equal(backups.length, 1); assert.equal(fs.readFileSync(path.join(clientHome, backups[0] ?? ""), "utf8"), original); From 21abe1577bc83b302c2611d372f0a999853b9267 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 02:07:30 -0400 Subject: [PATCH 16/29] fix(setup): replace mutable lock files --- AGENTS.md | 8 +-- README.md | 14 ++-- dist/setup-files.js | 133 +++++++++++++++----------------------- docs/architecture.md | 4 +- docs/install/copilot.md | 10 +-- docs/install/vscode.md | 13 ++-- docs/troubleshooting.md | 21 +++--- src/AGENTS.md | 2 +- src/setup-files.ts | 118 +++++++++++++-------------------- tests/setup-files.test.ts | 85 ++++++++++++------------ tests/setup.test.ts | 4 +- 11 files changed, 183 insertions(+), 229 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d3ff719..20e7c5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,12 +39,12 @@ docs/ architecture and troubleshooting entries. - Setup reports `complete` with exit `0`; `manual-required` and `shared-retained` use exit `2`; command errors use exit `1`. -- Copilot and VS Code share native plugin and hook resources. Single-harness - removal must retain those resources and must not invoke an uninstall. +- Copilot and VS Code share the native plugin store. Single-harness removal + must retain that plugin and leave any legacy fallback hook file unchanged. - Validate existing setup JSON before native work. Preserve unrelated and near-matching hooks, reject symlinked or non-regular targets, and publish - changes under the per-file SQLite lock through a unique fsynced temporary - file and rename. + changes under an atomic `.lock` directory through a unique fsynced + temporary file and rename. See `src/AGENTS.md` and `tests/AGENTS.md` for more specific rules. diff --git a/README.md b/README.md index 12104e9..01b71c0 100644 --- a/README.md +++ b/README.md @@ -179,13 +179,13 @@ Native lifecycle support is limited to the commands that each client documents: Setup validates an existing hook file before invoking a native CLI, preserves unrelated entries, and changes only exact Agent LCM-owned registrations. It -backs up a changed file as `*-pre-agent-lcm-*.json`, uses a per-file SQLite -lock at `.lock.sqlite`, and publishes through a unique `wx` temporary -file, `fsync`, and rename. Symlinked directory components, lock files, targets, -and non-regular files are refused. Hook commands must be absolute paths without -shell metacharacters. These rules -make repeated setup and removal safe while avoiding a second user-level hook -copy after native installation. +backs up a changed file as `*-pre-agent-lcm-*.json`, holds an atomic lock +directory at `.lock` for at most ten seconds, and publishes through a +unique `wx` temporary file, `fsync`, and rename. Symlinked directory +components, lock paths, targets, and non-regular files are refused. Hook +commands must be absolute paths without shell metacharacters. These rules make +repeated setup and removal safe while avoiding a second user-level hook copy +after native installation. Hooks start the daemon on demand. You can also manage it directly: diff --git a/dist/setup-files.js b/dist/setup-files.js index 4461232..cc3f754 100644 --- a/dist/setup-files.js +++ b/dist/setup-files.js @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; const SETUP_LOCK_TIMEOUT_MS = 10_000; const SETUP_LOCK_POLL_MS = 10; const SETUP_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); @@ -14,8 +13,13 @@ export class SetupFileLockTimeoutError extends Error { } } export function mutateSetupConfiguration(target, transform) { - ensureSetupDirectory(path.dirname(target)); - return withSetupFileLock(target, () => { + const directory = path.dirname(target); + ensureSetupDirectory(directory); + const directoryIdentity = fs.lstatSync(directory); + if (!directoryIdentity.isDirectory()) + throw new Error(`Setup directory changed while updating: ${directory}`); + return withSetupFileLock(target, directoryIdentity, () => { + assertDirectoryIdentity(directory, directoryIdentity); const current = readSetupFile(target); const existing = current ? parseSetupConfiguration(current, target) : undefined; const next = transform(existing); @@ -23,9 +27,12 @@ export function mutateSetupConfiguration(target, transform) { return false; if (existing && JSON.stringify(existing) === JSON.stringify(next)) return false; + assertDirectoryIdentity(directory, directoryIdentity); if (current) backupSetupBytes(target, current); + assertDirectoryIdentity(directory, directoryIdentity); writeSetupBytes(target, Buffer.from(`${JSON.stringify(next, null, 2)}\n`)); + assertDirectoryIdentity(directory, directoryIdentity); return true; }); } @@ -47,10 +54,6 @@ function parseSetupConfiguration(bytes, target) { throw new Error(`Cannot update invalid setup configuration: ${target}`); return value; } -export function writeSetupConfiguration(target, configuration) { - ensureSetupDirectory(path.dirname(target)); - writeSetupBytes(target, Buffer.from(`${JSON.stringify(configuration, null, 2)}\n`)); -} function writeSetupBytes(target, bytes) { const temporary = `${target}.${randomUUID()}.tmp`; let descriptor; @@ -78,9 +81,6 @@ function writeSetupBytes(target, bytes) { throw error; } } -export function backupSetupConfiguration(target) { - backupSetupBytes(target, fs.readFileSync(target)); -} function backupSetupBytes(target, bytes) { const extension = path.extname(target); const stem = extension ? target.slice(0, -extension.length) : target; @@ -116,114 +116,88 @@ function backupSetupBytes(target, bytes) { } } } -function withSetupFileLock(target, callback) { - const lockPath = `${target}.lock.sqlite`; +function withSetupFileLock(target, directoryIdentity, callback) { + const lockPath = `${target}.lock`; const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; - const lockDescriptor = openRegularLockFile(lockPath); - let coordinator; - let transactionOpen = false; + let acquired = false; + // ponytail: a crashed setup leaves this empty directory; recover it manually + // rather than guessing whether another setup process is still alive. try { - coordinator = new DatabaseSync(lockPath, { timeout: SETUP_LOCK_POLL_MS }); - assertSameFile(lockPath, fs.fstatSync(lockDescriptor)); - while (!transactionOpen) { + while (!acquired) { + assertDirectoryIdentity(path.dirname(target), directoryIdentity); try { - coordinator.exec("BEGIN IMMEDIATE"); - transactionOpen = true; + fs.mkdirSync(lockPath, { mode: 0o700 }); + acquired = true; } catch (error) { - if (!isSqliteBusy(error)) + if (!hasCode(error, "EEXIST")) throw error; + const status = fs.lstatSync(lockPath); + if (status.isSymbolicLink()) + throw new Error(`Refusing setup lock symlink: ${lockPath}`); + if (!status.isDirectory()) + throw new Error(`Cannot use setup lock that is not a directory: ${lockPath}`); if (Date.now() >= deadline) throw new SetupFileLockTimeoutError(lockPath); Atomics.wait(SETUP_LOCK_WAIT, 0, 0, SETUP_LOCK_POLL_MS); } } + assertDirectoryIdentity(path.dirname(target), directoryIdentity); return callback(); } finally { - if (transactionOpen) - coordinator?.exec("ROLLBACK"); - coordinator?.close(); - fs.closeSync(lockDescriptor); + if (acquired) + fs.rmdirSync(lockPath); } } function readSetupFile(target) { - let descriptor; + let pathStatus; try { - descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollowFlag()); + pathStatus = fs.lstatSync(target); } catch (error) { if (hasCode(error, "ENOENT")) return undefined; - if (hasCode(error, "ELOOP")) - throw new Error(`Refusing setup configuration symlink: ${target}`); - throw error; - } - try { - const status = fs.fstatSync(descriptor); - if (!status.isFile()) - throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); - return fs.readFileSync(descriptor); - } - finally { - fs.closeSync(descriptor); - } -} -export function ensureSetupDirectory(directory) { - assertSafeDirectoryPath(directory); - const created = fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); - assertSafeDirectoryPath(directory); - if (created !== undefined) - fs.chmodSync(directory, 0o700); -} -function openRegularLockFile(lockPath) { - let descriptor; - try { - try { - descriptor = fs.openSync(lockPath, fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollowFlag(), 0o600); - } - catch (error) { - if (!hasCode(error, "EEXIST")) - throw error; - descriptor = fs.openSync(lockPath, fs.constants.O_RDWR | noFollowFlag()); - } - const status = fs.fstatSync(descriptor); - if (!status.isFile()) - throw new Error(`Cannot use setup lock that is not a regular file: ${lockPath}`); - fs.fchmodSync(descriptor, 0o600); - fs.fsyncSync(descriptor); - return descriptor; - } - catch (error) { - if (descriptor !== undefined) - fs.closeSync(descriptor); - if (hasCode(error, "ELOOP")) - throw new Error(`Refusing setup lock symlink: ${lockPath}`); throw error; } -} -function assertSameFile(target, expected) { + if (pathStatus.isSymbolicLink()) + throw new Error(`Refusing setup configuration symlink: ${target}`); + if (!pathStatus.isFile()) + throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); let descriptor; try { descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollowFlag()); } catch (error) { if (hasCode(error, "ELOOP")) - throw new Error(`Setup lock path changed while opening: ${target}`); + throw new Error(`Refusing setup configuration symlink: ${target}`); throw error; } try { - const actual = fs.fstatSync(descriptor); - if (!actual.isFile() || actual.dev !== expected.dev || actual.ino !== expected.ino) { - throw new Error(`Setup lock path changed while opening: ${target}`); + const opened = fs.fstatSync(descriptor); + const current = fs.lstatSync(target); + if (!opened.isFile() || current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) { + throw new Error(`Setup configuration path changed while opening: ${target}`); } + return fs.readFileSync(descriptor); } finally { fs.closeSync(descriptor); } } +export function ensureSetupDirectory(directory) { + assertSafeDirectoryPath(directory); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + assertSafeDirectoryPath(directory); +} function noFollowFlag() { - return fs.constants.O_NOFOLLOW ?? 0; + return process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW; +} +function assertDirectoryIdentity(directory, expected) { + const actual = fs.lstatSync(directory); + if (!actual.isDirectory() || actual.dev !== expected.dev || actual.ino !== expected.ino) { + throw new Error(`Setup directory changed while updating: ${directory}`); + } } function assertSafeDirectoryPath(directory) { const resolved = path.resolve(directory); @@ -261,9 +235,6 @@ function fsyncPath(target) { fs.closeSync(descriptor); } } -function isSqliteBusy(error) { - return error instanceof Error && Reflect.get(error, "errcode") === 5; -} function hasCode(error, code) { return error instanceof Error && Reflect.get(error, "code") === code; } diff --git a/docs/architecture.md b/docs/architecture.md index b14737a..1bdf97f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,8 +50,8 @@ configuration is validated before native work. Unrelated entries and near-matching commands remain untouched; only an exact harness/event/command registration is changed. -Setup files use `.lock.sqlite` with a SQLite `BEGIN IMMEDIATE` lock -(bounded to ten seconds). Publication writes a unique `wx` temporary file with +Setup files use an atomic `.lock` directory (bounded to ten seconds). +Publication writes a unique `wx` temporary file with restrictive permissions, fsyncs it, renames it, and fsyncs the parent directory. Symlinked or non-regular targets are refused, hook commands must be absolute and shell-safe, and changed files receive a collision-safe `-pre-agent-lcm-` backup. diff --git a/docs/install/copilot.md b/docs/install/copilot.md index 2b62681..e68267b 100644 --- a/docs/install/copilot.md +++ b/docs/install/copilot.md @@ -39,19 +39,19 @@ run `copilot plugin list` again. native plugin health. `false` is expected after a successful native install; use `copilot plugin list` for the native check. -> Warning: GitHub Copilot CLI and VS Code share the Copilot plugin store and the hook file `~/.copilot/hooks/agent-lcm.json`. A deliberate native uninstall affects both harnesses. Do not uninstall the shared plugin when you mean to remove only Copilot CLI. +> Warning: GitHub Copilot CLI and VS Code share the native Copilot plugin store. A deliberate native uninstall affects both harnesses. Do not uninstall the shared plugin when you mean to remove only Copilot CLI. A legacy `~/.copilot/hooks/agent-lcm.json` fallback, if present, is separate. ## Remove Agent LCM -The safe single-harness command retains the shared plugin and hook resources: +The safe single-harness command retains the shared native plugin: ```sh agent-lcm remove copilot ``` -It reports `shared-retained` and leaves the shared store and hook file -unchanged. To deliberately remove the shared native installation from both -harnesses, run: +It reports `shared-retained`, leaves the shared store unchanged, and does not +edit a legacy fallback hook file. To deliberately remove the shared native +installation from both harnesses, run: ```sh copilot plugin uninstall agent-lcm diff --git a/docs/install/vscode.md b/docs/install/vscode.md index 891d6f7..d0f182c 100644 --- a/docs/install/vscode.md +++ b/docs/install/vscode.md @@ -46,22 +46,23 @@ native plugin health. `false` is expected after a successful native install; use the Agent Plugins - Installed view or `copilot plugin list` for the native check. -> Warning: VS Code and GitHub Copilot share the Copilot plugin store and the hook file `~/.copilot/hooks/agent-lcm.json`. A deliberate native uninstall affects both harnesses. Do not uninstall the shared plugin when you mean to remove only VS Code. +> Warning: VS Code and GitHub Copilot share the native Copilot plugin store. A deliberate native uninstall affects both harnesses. Do not uninstall the shared plugin when you mean to remove only VS Code. A legacy `~/.copilot/hooks/agent-lcm.json` fallback, if present, is separate. ## Remove Agent LCM -The safe single-harness command retains the shared plugin and hook resources: +The safe single-harness command retains the shared native plugin: ```sh agent-lcm remove vscode ``` -It reports `shared-retained` and leaves the shared store and hook file -unchanged. To deliberately remove the shared native installation from both -harnesses, use the Copilot command: +It reports `shared-retained`, leaves the shared store unchanged, and does not +edit a legacy fallback hook file. To deliberately remove the shared native +installation from both harnesses, use the Copilot command: ```sh copilot plugin uninstall agent-lcm ``` -Then manage the shared hook file only after reviewing both harnesses' needs. +Then manage any legacy fallback hook file only after reviewing both harnesses' +needs. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5e22e09..db0b391 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -95,22 +95,27 @@ suppressed-stderr marker so a client cannot leak secrets into logs. Use Codex setup probes `codex plugin list`, then runs the marketplace-add and plugin-add commands. Removal runs `codex plugin remove agent-lcm@agent-lcm`. Copilot and VS Code probe and install through `copilot plugin`; they share the -same plugin store and `~/.copilot/hooks/agent-lcm.json`, so either -`agent-lcm remove copilot` or `agent-lcm remove vscode` is intentionally -conservative and does not uninstall the shared plugin. Review both clients -before using the documented Copilot uninstall command. Cursor Marketplace and -Kiro Powers installation/removal stay manual. +same native plugin store, so either `agent-lcm remove copilot` or `agent-lcm +remove vscode` is intentionally conservative and does not uninstall the shared +plugin. A legacy `~/.copilot/hooks/agent-lcm.json` fallback is separate and is +left unchanged. Review both clients before using the documented Copilot +uninstall command. Cursor Marketplace and Kiro Powers installation/removal +stay manual. Setup validates the existing JSON before starting a native CLI. It changes only exact Agent LCM-owned hook entries and preserves unrelated or near-matching entries. A changed file gets a collision-safe `-pre-agent-lcm-` backup. Setup -also refuses symlinked directory components, lock files, targets, and -non-regular files. It uses a per-file SQLite lock at `.lock.sqlite`, -with a ten-second bound, plus unique, fsynced +also refuses symlinked directory components, lock paths, targets, and +non-regular files. It uses an atomic lock directory at `.lock`, with a +ten-second bound, plus unique, fsynced temporary publication; a predictable temporary symlink cannot redirect the write. Hook commands must use an absolute shell-safe binary path. If validation fails, the original file and native CLI invocation remain unchanged. +If setup times out on `.lock`, first confirm that no Agent LCM setup or +remove process is running. You may then remove that empty lock directory and +retry. Do not remove it while another process is active. + ## Isolate a storage problem Use a temporary home so tests do not touch your normal store: diff --git a/src/AGENTS.md b/src/AGENTS.md index 24d12b5..f452597 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -45,7 +45,7 @@ - Probe and invoke only documented commands: Codex uses `codex plugin`; Copilot and VS Code use the shared `copilot plugin` store; Cursor and Kiro use version-only probes and keep plugin changes manual. - Validate existing setup JSON before starting a native process. Match owned hooks by harness, event, and command shape; preserve unrelated and near-matching entries. -- Keep setup-file writes under `.lock.sqlite` with a bounded SQLite `BEGIN IMMEDIATE` lock. Open lock and target files through descriptor-bound no-follow checks; never validate by path and then read or chmod that path. Refuse symlinked directory components, lock files, targets, and non-regular files. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and parent-directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. +- Keep setup-file writes under a bounded atomic `.lock` directory. Open targets through descriptor-bound no-follow checks; never validate by path and then read or chmod that path. Refuse symlinked directory components, lock paths, targets, and non-regular files. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and parent-directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. - Install native plugins from the current package directory, never from a mutable remote ref. Treat only `ENOENT` as an unavailable CLI; all other native probe or command failures must stop before hook mutation and must not echo client stderr. - Require an absolute hook binary path and reject shell metacharacters before writing configuration. - Never uninstall the shared Copilot plugin for a single `copilot` or `vscode` removal; report `shared-retained` instead. diff --git a/src/setup-files.ts b/src/setup-files.ts index 90d65d1..478b587 100644 --- a/src/setup-files.ts +++ b/src/setup-files.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; const SETUP_LOCK_TIMEOUT_MS = 10_000; const SETUP_LOCK_POLL_MS = 10; @@ -21,15 +20,22 @@ export function mutateSetupConfiguration( target: string, transform: (configuration: Record | undefined) => Record | undefined, ): boolean { - ensureSetupDirectory(path.dirname(target)); - return withSetupFileLock(target, () => { + const directory = path.dirname(target); + ensureSetupDirectory(directory); + const directoryIdentity = fs.lstatSync(directory); + if (!directoryIdentity.isDirectory()) throw new Error(`Setup directory changed while updating: ${directory}`); + return withSetupFileLock(target, directoryIdentity, () => { + assertDirectoryIdentity(directory, directoryIdentity); const current = readSetupFile(target); const existing = current ? parseSetupConfiguration(current, target) : undefined; const next = transform(existing); if (next === undefined) return false; if (existing && JSON.stringify(existing) === JSON.stringify(next)) return false; + assertDirectoryIdentity(directory, directoryIdentity); if (current) backupSetupBytes(target, current); + assertDirectoryIdentity(directory, directoryIdentity); writeSetupBytes(target, Buffer.from(`${JSON.stringify(next, null, 2)}\n`)); + assertDirectoryIdentity(directory, directoryIdentity); return true; }); } @@ -51,11 +57,6 @@ function parseSetupConfiguration(bytes: Buffer, target: string): Record): void { - ensureSetupDirectory(path.dirname(target)); - writeSetupBytes(target, Buffer.from(`${JSON.stringify(configuration, null, 2)}\n`)); -} - function writeSetupBytes(target: string, bytes: Buffer): void { const temporary = `${target}.${randomUUID()}.tmp`; let descriptor: number | undefined; @@ -79,10 +80,6 @@ function writeSetupBytes(target: string, bytes: Buffer): void { } } -export function backupSetupConfiguration(target: string): void { - backupSetupBytes(target, fs.readFileSync(target)); -} - function backupSetupBytes(target: string, bytes: Buffer): void { const extension = path.extname(target); const stem = extension ? target.slice(0, -extension.length) : target; @@ -114,45 +111,57 @@ function backupSetupBytes(target: string, bytes: Buffer): void { } } -function withSetupFileLock(target: string, callback: () => T): T { - const lockPath = `${target}.lock.sqlite`; +function withSetupFileLock(target: string, directoryIdentity: fs.Stats, callback: () => T): T { + const lockPath = `${target}.lock`; const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; - const lockDescriptor = openRegularLockFile(lockPath); - let coordinator: DatabaseSync | undefined; - let transactionOpen = false; + let acquired = false; + // ponytail: a crashed setup leaves this empty directory; recover it manually + // rather than guessing whether another setup process is still alive. try { - coordinator = new DatabaseSync(lockPath, { timeout: SETUP_LOCK_POLL_MS }); - assertSameFile(lockPath, fs.fstatSync(lockDescriptor)); - while (!transactionOpen) { + while (!acquired) { + assertDirectoryIdentity(path.dirname(target), directoryIdentity); try { - coordinator.exec("BEGIN IMMEDIATE"); - transactionOpen = true; + fs.mkdirSync(lockPath, { mode: 0o700 }); + acquired = true; } catch (error) { - if (!isSqliteBusy(error)) throw error; + if (!hasCode(error, "EEXIST")) throw error; + const status = fs.lstatSync(lockPath); + if (status.isSymbolicLink()) throw new Error(`Refusing setup lock symlink: ${lockPath}`); + if (!status.isDirectory()) throw new Error(`Cannot use setup lock that is not a directory: ${lockPath}`); if (Date.now() >= deadline) throw new SetupFileLockTimeoutError(lockPath); Atomics.wait(SETUP_LOCK_WAIT, 0, 0, SETUP_LOCK_POLL_MS); } } + assertDirectoryIdentity(path.dirname(target), directoryIdentity); return callback(); } finally { - if (transactionOpen) coordinator?.exec("ROLLBACK"); - coordinator?.close(); - fs.closeSync(lockDescriptor); + if (acquired) fs.rmdirSync(lockPath); } } function readSetupFile(target: string): Buffer | undefined { + let pathStatus: fs.Stats; + try { + pathStatus = fs.lstatSync(target); + } catch (error) { + if (hasCode(error, "ENOENT")) return undefined; + throw error; + } + if (pathStatus.isSymbolicLink()) throw new Error(`Refusing setup configuration symlink: ${target}`); + if (!pathStatus.isFile()) throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); let descriptor: number; try { descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollowFlag()); } catch (error) { - if (hasCode(error, "ENOENT")) return undefined; if (hasCode(error, "ELOOP")) throw new Error(`Refusing setup configuration symlink: ${target}`); throw error; } try { - const status = fs.fstatSync(descriptor); - if (!status.isFile()) throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); + const opened = fs.fstatSync(descriptor); + const current = fs.lstatSync(target); + if (!opened.isFile() || current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) { + throw new Error(`Setup configuration path changed while opening: ${target}`); + } return fs.readFileSync(descriptor); } finally { fs.closeSync(descriptor); @@ -161,54 +170,21 @@ function readSetupFile(target: string): Buffer | undefined { export function ensureSetupDirectory(directory: string): void { assertSafeDirectoryPath(directory); - const created = fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); assertSafeDirectoryPath(directory); - if (created !== undefined) fs.chmodSync(directory, 0o700); } -function openRegularLockFile(lockPath: string): number { - let descriptor: number | undefined; - try { - try { - descriptor = fs.openSync(lockPath, fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollowFlag(), 0o600); - } catch (error) { - if (!hasCode(error, "EEXIST")) throw error; - descriptor = fs.openSync(lockPath, fs.constants.O_RDWR | noFollowFlag()); - } - const status = fs.fstatSync(descriptor); - if (!status.isFile()) throw new Error(`Cannot use setup lock that is not a regular file: ${lockPath}`); - fs.fchmodSync(descriptor, 0o600); - fs.fsyncSync(descriptor); - return descriptor; - } catch (error) { - if (descriptor !== undefined) fs.closeSync(descriptor); - if (hasCode(error, "ELOOP")) throw new Error(`Refusing setup lock symlink: ${lockPath}`); - throw error; - } +function noFollowFlag(): number { + return process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW; } -function assertSameFile(target: string, expected: fs.Stats): void { - let descriptor: number; - try { - descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollowFlag()); - } catch (error) { - if (hasCode(error, "ELOOP")) throw new Error(`Setup lock path changed while opening: ${target}`); - throw error; - } - try { - const actual = fs.fstatSync(descriptor); - if (!actual.isFile() || actual.dev !== expected.dev || actual.ino !== expected.ino) { - throw new Error(`Setup lock path changed while opening: ${target}`); - } - } finally { - fs.closeSync(descriptor); +function assertDirectoryIdentity(directory: string, expected: fs.Stats): void { + const actual = fs.lstatSync(directory); + if (!actual.isDirectory() || actual.dev !== expected.dev || actual.ino !== expected.ino) { + throw new Error(`Setup directory changed while updating: ${directory}`); } } -function noFollowFlag(): number { - return fs.constants.O_NOFOLLOW ?? 0; -} - function assertSafeDirectoryPath(directory: string): void { const resolved = path.resolve(directory); const root = path.parse(resolved).root; @@ -243,10 +219,6 @@ function fsyncPath(target: string): void { } } -function isSqliteBusy(error: unknown): boolean { - return error instanceof Error && Reflect.get(error, "errcode") === 5; -} - function hasCode(error: unknown, code: string): boolean { return error instanceof Error && Reflect.get(error, "code") === code; } diff --git a/tests/setup-files.test.ts b/tests/setup-files.test.ts index 9588e35..3c40b01 100644 --- a/tests/setup-files.test.ts +++ b/tests/setup-files.test.ts @@ -33,15 +33,14 @@ test("setup refuses a target symlink without changing the victim", { skip: proce test("setup refuses a symlinked lock without changing the victim", { skip: process.platform === "win32" }, () => { const home = tempHome("agent-lcm-setup-lock-symlink-"); const target = path.join(home, "hooks.json"); - const victim = path.join(home, "victim.sqlite"); - fs.writeFileSync(victim, ""); - fs.chmodSync(victim, 0o644); - fs.symlinkSync(victim, `${target}.lock.sqlite`); + const victim = path.join(home, "victim"); + fs.mkdirSync(victim); + fs.symlinkSync(victim, `${target}.lock`); assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /lock.*symlink/u); - assert.equal(fs.lstatSync(`${target}.lock.sqlite`).isSymbolicLink(), true); - assert.equal(fs.statSync(victim).mode & 0o777, 0o644); + assert.equal(fs.lstatSync(`${target}.lock`).isSymbolicLink(), true); + assert.deepEqual(fs.readdirSync(victim), []); assert.equal(fs.existsSync(target), false); }); @@ -57,36 +56,39 @@ test("setup refuses a symlinked parent directory", { skip: process.platform === assert.deepEqual(fs.readdirSync(victim), []); }); -test("a lock-path swap cannot change a symlink victim", { skip: process.platform === "win32" }, () => { - const home = tempHome("agent-lcm-setup-lock-race-"); - const target = path.join(home, "hooks.json"); - const lock = `${target}.lock.sqlite`; - const victim = path.join(home, "victim.sqlite"); - fs.writeFileSync(lock, ""); - fs.writeFileSync(victim, ""); - fs.chmodSync(victim, 0o644); - const originalOpen = fs.openSync; - let lockOpens = 0; - const swappedOpen = ((candidate: fs.PathLike, flags: string | number, mode?: fs.Mode) => { - if (candidate.toString() === lock && (lockOpens += 1) === 2) { - fs.unlinkSync(lock); - fs.symlinkSync(victim, lock); +test("a parent swap during lock acquisition cannot redirect setup", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-parent-race-"); + const safe = path.join(home, "safe"); + const originalSafe = path.join(home, "safe-original"); + const outside = path.join(home, "outside"); + const target = path.join(safe, "hooks.json"); + const lock = `${target}.lock`; + fs.mkdirSync(safe); + fs.mkdirSync(outside); + const originalMkdir = fs.mkdirSync; + let swapped = false; + const swappedMkdir = ((candidate: fs.PathLike, options?: fs.MakeDirectoryOptions & { recursive?: false }) => { + if (!swapped && candidate.toString() === lock) { + swapped = true; + fs.renameSync(safe, originalSafe); + fs.symlinkSync(outside, safe); } - return originalOpen(candidate, flags, mode); - }) as typeof fs.openSync; - Object.defineProperty(fs, "openSync", { configurable: true, value: swappedOpen }); + return originalMkdir(candidate, options); + }) as typeof fs.mkdirSync; + Object.defineProperty(fs, "mkdirSync", { configurable: true, value: swappedMkdir }); try { - assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /lock (?:path changed|symlink)/u); + assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /directory changed/u); } finally { - Object.defineProperty(fs, "openSync", { configurable: true, value: originalOpen }); + Object.defineProperty(fs, "mkdirSync", { configurable: true, value: originalMkdir }); } - assert.equal(fs.statSync(victim).mode & 0o777, 0o644); + assert.equal(fs.existsSync(path.join(outside, "hooks.json")), false); + assert.deepEqual(fs.readdirSync(outside), []); }); test("a lock swap after a path check cannot chmod the victim", { skip: process.platform === "win32" }, () => { const home = tempHome("agent-lcm-setup-lock-chmod-race-"); const target = path.join(home, "hooks.json"); - const lock = `${target}.lock.sqlite`; + const lock = `${target}.lock`; const victim = path.join(home, "victim.sqlite"); fs.writeFileSync(lock, ""); fs.writeFileSync(victim, ""); @@ -104,39 +106,42 @@ test("a lock swap after a path check cannot chmod the victim", { skip: process.p }) as typeof fs.lstatSync; Object.defineProperty(fs, "lstatSync", { configurable: true, value: swappedLstat }); try { - mutateSetupConfiguration(target, () => ({ hooks: {} })); + assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /not a directory/u); } finally { Object.defineProperty(fs, "lstatSync", { configurable: true, value: originalLstat }); } assert.equal(fs.statSync(victim).mode & 0o777, 0o644); }); -test("a target-path swap cannot copy symlink-victim bytes", { skip: process.platform === "win32" }, () => { +test("a target swap after opening cannot copy symlink-victim bytes", { skip: process.platform === "win32" }, () => { const home = tempHome("agent-lcm-setup-target-race-"); const target = path.join(home, "hooks.json"); const victim = path.join(home, "victim.json"); fs.writeFileSync(target, '{"hooks":{}}\n'); fs.writeFileSync(victim, '{"isolated-secret":true}\n'); - const originalRead = fs.readFileSync; + const originalOpen = fs.openSync; let swapped = false; - const swappedRead = ((candidate: fs.PathOrFileDescriptor, ...args: unknown[]) => { - if (!swapped && typeof candidate === "string" && candidate === target) { + const swappedOpen = ((candidate: fs.PathLike, flags: string | number, mode?: fs.Mode) => { + const descriptor = originalOpen(candidate, flags, mode); + if (!swapped && candidate.toString() === target) { swapped = true; fs.unlinkSync(target); fs.symlinkSync(victim, target); } - return Reflect.apply(originalRead, fs, [candidate, ...args]); - }) as typeof fs.readFileSync; - Object.defineProperty(fs, "readFileSync", { configurable: true, value: swappedRead }); + return descriptor; + }) as typeof fs.openSync; + Object.defineProperty(fs, "openSync", { configurable: true, value: swappedOpen }); try { - mutateSetupConfiguration(target, (configuration) => ({ ...configuration, added: true })); + assert.throws( + () => mutateSetupConfiguration(target, (configuration) => ({ ...configuration, added: true })), + /path changed/u, + ); } finally { - Object.defineProperty(fs, "readFileSync", { configurable: true, value: originalRead }); + Object.defineProperty(fs, "openSync", { configurable: true, value: originalOpen }); } const backups = fs.readdirSync(home).filter((name) => name.startsWith("hooks-pre-agent-lcm-")); - assert.equal(backups.length, 1); - assert.doesNotMatch(fs.readFileSync(path.join(home, backups[0] ?? ""), "utf8"), /isolated-secret/u); - assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), { hooks: {}, added: true }); + assert.deepEqual(backups, []); + assert.equal(fs.lstatSync(target).isSymbolicLink(), true); assert.deepEqual(JSON.parse(fs.readFileSync(victim, "utf8")), { "isolated-secret": true }); }); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 0e4cea4..91d04f5 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import test from "node:test"; -import { writeSetupConfiguration } from "../src/setup-files.ts"; +import { mutateSetupConfiguration } from "../src/setup-files.ts"; import { removeHarness, setupHarness, setupStatus } from "../src/setup.ts"; import { assertCliOk, runCli, tempHome } from "./helpers.ts"; @@ -623,7 +623,7 @@ test("setup writes never follow a predictable temporary symlink", { skip: proces fs.writeFileSync(victim, "do not overwrite"); fs.symlinkSync(victim, `${setupPath}.${process.pid}.tmp`); - writeSetupConfiguration(setupPath, { hooks: {} }); + mutateSetupConfiguration(setupPath, () => ({ hooks: {} })); assert.equal(fs.readFileSync(victim, "utf8"), "do not overwrite"); assert.deepEqual(JSON.parse(fs.readFileSync(setupPath, "utf8")), { hooks: {} }); From af60658d58386f08e319446c3f001f82fc173125 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 06:37:24 -0400 Subject: [PATCH 17/29] fix(setup): anchor configuration publication --- dist/setup-file-worker.js | 200 ++++++++++++++++++++++++++++++++++++++ dist/setup-files.js | 166 ++++++++++++------------------- src/setup-file-worker.ts | 182 ++++++++++++++++++++++++++++++++++ src/setup-files.ts | 164 ++++++++++++++----------------- tests/setup-files.test.ts | 105 ++++++++++++-------- 5 files changed, 579 insertions(+), 238 deletions(-) create mode 100644 dist/setup-file-worker.js create mode 100644 src/setup-file-worker.ts diff --git a/dist/setup-file-worker.js b/dist/setup-file-worker.js new file mode 100644 index 0000000..80ce171 --- /dev/null +++ b/dist/setup-file-worker.js @@ -0,0 +1,200 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +const BUSY_EXIT = 75; +const MISSING_EXIT = 66; +try { + process.exitCode = main(); +} +catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} +function main() { + const operation = requiredArgument(2); + const name = requiredArgument(3); + const expectedDevice = requiredArgument(4); + const expectedInode = requiredArgument(5); + if (path.basename(name) !== name || name === "." || name === "..") + throw new Error("Invalid setup file name."); + assertDirectoryIdentity(expectedDevice, expectedInode); + switch (operation) { + case "lock": return acquireLock(name); + case "unlock": + fs.rmdirSync(`${name}.lock`); + return 0; + case "read": return writeCurrentFile(name); + case "write": + writeChangedFile(name, requiredArgument(6), requiredArgument(7), fs.readFileSync(0)); + return 0; + default: throw new Error(`Unknown setup file operation: ${operation}`); + } +} +function acquireLock(name) { + const lock = `${name}.lock`; + try { + fs.mkdirSync(lock, { mode: 0o700 }); + return 0; + } + catch (error) { + if (!hasCode(error, "EEXIST")) + throw error; + } + let status; + try { + status = fs.lstatSync(lock); + } + catch (error) { + if (hasCode(error, "ENOENT")) + return BUSY_EXIT; + throw error; + } + if (status.isSymbolicLink()) + throw new Error(`Refusing setup lock symlink: ${path.resolve(lock)}`); + if (!status.isDirectory()) + throw new Error(`Cannot use setup lock that is not a directory: ${path.resolve(lock)}`); + return BUSY_EXIT; +} +function writeCurrentFile(name) { + const bytes = readAnchoredFile(name); + if (bytes === undefined) + return MISSING_EXIT; + process.stdout.write(bytes); + return 0; +} +function writeChangedFile(name, expectedHash, timestamp, next) { + const current = readAnchoredFile(name); + const actualHash = current === undefined ? "missing" : hash(current); + if (actualHash !== expectedHash) + throw new Error(`Setup configuration changed while updating: ${path.resolve(name)}`); + if (current !== undefined) + backupAnchoredFile(name, current, timestamp); + writeAnchoredFile(name, next); +} +function readAnchoredFile(name) { + let pathStatus; + try { + pathStatus = fs.lstatSync(name); + } + catch (error) { + if (hasCode(error, "ENOENT")) + return undefined; + throw error; + } + if (pathStatus.isSymbolicLink()) + throw new Error(`Refusing setup configuration symlink: ${path.resolve(name)}`); + if (!pathStatus.isFile()) + throw new Error(`Cannot update setup configuration that is not a regular file: ${path.resolve(name)}`); + let descriptor; + try { + descriptor = fs.openSync(name, fs.constants.O_RDONLY | noFollowFlag()); + } + catch (error) { + if (hasCode(error, "ELOOP")) + throw new Error(`Refusing setup configuration symlink: ${path.resolve(name)}`); + throw error; + } + try { + const opened = fs.fstatSync(descriptor); + const current = fs.lstatSync(name); + if (!opened.isFile() || current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) { + throw new Error(`Setup configuration path changed while opening: ${path.resolve(name)}`); + } + return fs.readFileSync(descriptor); + } + finally { + fs.closeSync(descriptor); + } +} +function writeAnchoredFile(name, bytes) { + const temporary = `${name}.${randomUUID()}.tmp`; + let descriptor; + try { + descriptor = fs.openSync(temporary, "wx", 0o600); + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + fs.renameSync(temporary, name); + if (process.platform !== "win32") + fsyncDirectory(); + } + catch (error) { + if (descriptor !== undefined) + fs.closeSync(descriptor); + try { + fs.unlinkSync(temporary); + } + catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) + throw new AggregateError([error, cleanupError], "Setup publication and cleanup failed."); + } + throw error; + } +} +function backupAnchoredFile(name, bytes, timestampValue) { + const extension = path.extname(name); + const stem = extension ? name.slice(0, -extension.length) : name; + const timestamp = timestampValue.replace(/[:.]/gu, "-"); + for (let suffix = 0;; suffix += 1) { + const candidate = `${stem}-pre-agent-lcm-${timestamp}${suffix ? `-${suffix}` : ""}${extension}`; + let descriptor; + try { + descriptor = fs.openSync(candidate, "wx", 0o600); + } + catch (error) { + if (hasCode(error, "EEXIST")) + continue; + throw error; + } + try { + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + return; + } + catch (error) { + fs.closeSync(descriptor); + try { + fs.unlinkSync(candidate); + } + catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) + throw new AggregateError([error, cleanupError], "Setup backup and cleanup failed."); + } + throw error; + } + } +} +function assertDirectoryIdentity(expectedDevice, expectedInode) { + const actual = fs.statSync("."); + if (!actual.isDirectory() || String(actual.dev) !== expectedDevice || String(actual.ino) !== expectedInode) { + throw new Error(`Setup directory changed while updating: ${process.cwd()}`); + } +} +function fsyncDirectory() { + const descriptor = fs.openSync(".", "r"); + try { + fs.fsyncSync(descriptor); + } + finally { + fs.closeSync(descriptor); + } +} +function hash(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} +function noFollowFlag() { + return process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW; +} +function requiredArgument(index) { + const value = process.argv[index]; + if (value === undefined) + throw new Error("Missing setup file worker argument."); + return value; +} +function hasCode(error, code) { + return error instanceof Error && Reflect.get(error, "code") === code; +} diff --git a/dist/setup-files.js b/dist/setup-files.js index cc3f754..27aea33 100644 --- a/dist/setup-files.js +++ b/dist/setup-files.js @@ -1,9 +1,14 @@ -import { randomUUID } from "node:crypto"; +import childProcess from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; const SETUP_LOCK_TIMEOUT_MS = 10_000; const SETUP_LOCK_POLL_MS = 10; const SETUP_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); +const WORKER_BUSY_EXIT = 75; +const WORKER_MISSING_EXIT = 66; +const SETUP_FILE_WORKER = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./setup-file-worker.ts" : "./setup-file-worker.js", import.meta.url)); export class SetupFileLockTimeoutError extends Error { lockPath; constructor(lockPath) { @@ -19,20 +24,14 @@ export function mutateSetupConfiguration(target, transform) { if (!directoryIdentity.isDirectory()) throw new Error(`Setup directory changed while updating: ${directory}`); return withSetupFileLock(target, directoryIdentity, () => { - assertDirectoryIdentity(directory, directoryIdentity); - const current = readSetupFile(target); + const current = readAnchoredSetupFile(target, directoryIdentity); const existing = current ? parseSetupConfiguration(current, target) : undefined; const next = transform(existing); if (next === undefined) return false; if (existing && JSON.stringify(existing) === JSON.stringify(next)) return false; - assertDirectoryIdentity(directory, directoryIdentity); - if (current) - backupSetupBytes(target, current); - assertDirectoryIdentity(directory, directoryIdentity); - writeSetupBytes(target, Buffer.from(`${JSON.stringify(next, null, 2)}\n`)); - assertDirectoryIdentity(directory, directoryIdentity); + writeAnchoredSetupFile(target, directoryIdentity, current === undefined ? "missing" : createHash("sha256").update(current).digest("hex"), Buffer.from(`${JSON.stringify(next, null, 2)}\n`)); return true; }); } @@ -54,102 +53,76 @@ function parseSetupConfiguration(bytes, target) { throw new Error(`Cannot update invalid setup configuration: ${target}`); return value; } -function writeSetupBytes(target, bytes) { - const temporary = `${target}.${randomUUID()}.tmp`; - let descriptor; - try { - descriptor = fs.openSync(temporary, "wx", 0o600); - fs.fchmodSync(descriptor, 0o600); - fs.writeFileSync(descriptor, bytes); - fs.fsyncSync(descriptor); - fs.closeSync(descriptor); - descriptor = undefined; - fs.renameSync(temporary, target); - if (process.platform !== "win32") - fsyncPath(path.dirname(target)); - } - catch (error) { - if (descriptor !== undefined) - fs.closeSync(descriptor); - try { - fs.unlinkSync(temporary); - } - catch (cleanupError) { - if (!hasCode(cleanupError, "ENOENT")) - throw new AggregateError([error, cleanupError], "Setup publication and cleanup failed."); - } - throw error; - } -} -function backupSetupBytes(target, bytes) { - const extension = path.extname(target); - const stem = extension ? target.slice(0, -extension.length) : target; - const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); - for (let suffix = 0;; suffix += 1) { - const candidate = `${stem}-pre-agent-lcm-${timestamp}${suffix ? `-${suffix}` : ""}${extension}`; - let descriptor; - try { - descriptor = fs.openSync(candidate, "wx", 0o600); - } - catch (error) { - if (hasCode(error, "EEXIST")) - continue; - throw error; - } - try { - fs.fchmodSync(descriptor, 0o600); - fs.writeFileSync(descriptor, bytes); - fs.fsyncSync(descriptor); - fs.closeSync(descriptor); - return; - } - catch (error) { - fs.closeSync(descriptor); - try { - fs.unlinkSync(candidate); - } - catch (cleanupError) { - if (!hasCode(cleanupError, "ENOENT")) - throw new AggregateError([error, cleanupError], "Setup backup and cleanup failed."); - } - throw error; - } - } -} function withSetupFileLock(target, directoryIdentity, callback) { const lockPath = `${target}.lock`; const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; let acquired = false; + let failed = false; // ponytail: a crashed setup leaves this empty directory; recover it manually // rather than guessing whether another setup process is still alive. try { while (!acquired) { - assertDirectoryIdentity(path.dirname(target), directoryIdentity); - try { - fs.mkdirSync(lockPath, { mode: 0o700 }); + const result = runSetupFileWorker("lock", target, directoryIdentity); + if (result.status === 0) acquired = true; - } - catch (error) { - if (!hasCode(error, "EEXIST")) - throw error; - const status = fs.lstatSync(lockPath); - if (status.isSymbolicLink()) - throw new Error(`Refusing setup lock symlink: ${lockPath}`); - if (!status.isDirectory()) - throw new Error(`Cannot use setup lock that is not a directory: ${lockPath}`); - if (Date.now() >= deadline) - throw new SetupFileLockTimeoutError(lockPath); + else if (result.status !== WORKER_BUSY_EXIT) + throw setupFileWorkerError(result); + else if (Date.now() >= deadline) + throw new SetupFileLockTimeoutError(lockPath); + else Atomics.wait(SETUP_LOCK_WAIT, 0, 0, SETUP_LOCK_POLL_MS); - } } - assertDirectoryIdentity(path.dirname(target), directoryIdentity); return callback(); } + catch (error) { + failed = true; + throw error; + } finally { - if (acquired) - fs.rmdirSync(lockPath); + if (acquired) { + const result = runSetupFileWorker("unlock", target, directoryIdentity); + if (!failed && result.status !== 0) + throw setupFileWorkerError(result); + } } } +function readAnchoredSetupFile(target, directoryIdentity) { + const result = runSetupFileWorker("read", target, directoryIdentity); + if (result.status === WORKER_MISSING_EXIT) + return undefined; + if (result.status !== 0) + throw setupFileWorkerError(result); + return result.stdout; +} +function writeAnchoredSetupFile(target, directoryIdentity, expectedHash, bytes) { + const result = runSetupFileWorker("write", target, directoryIdentity, [expectedHash, new Date().toISOString()], bytes); + if (result.status !== 0) + throw setupFileWorkerError(result); +} +function runSetupFileWorker(operation, target, directoryIdentity, extraArguments = [], input) { + const result = childProcess.spawnSync(process.execPath, [ + "--no-warnings", + SETUP_FILE_WORKER, + operation, + path.basename(target), + String(directoryIdentity.dev), + String(directoryIdentity.ino), + ...extraArguments, + ], { + cwd: path.dirname(target), + input, + maxBuffer: 16 * 1024 * 1024, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.error !== undefined) + throw result.error; + return result; +} +function setupFileWorkerError(result) { + const message = result.stderr.toString("utf8").trim(); + return new Error(message || `Setup file worker failed with status ${String(result.status)}.`); +} function readSetupFile(target) { let pathStatus; try { @@ -193,12 +166,6 @@ export function ensureSetupDirectory(directory) { function noFollowFlag() { return process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW; } -function assertDirectoryIdentity(directory, expected) { - const actual = fs.lstatSync(directory); - if (!actual.isDirectory() || actual.dev !== expected.dev || actual.ino !== expected.ino) { - throw new Error(`Setup directory changed while updating: ${directory}`); - } -} function assertSafeDirectoryPath(directory) { const resolved = path.resolve(directory); const root = path.parse(resolved).root; @@ -226,15 +193,6 @@ function assertSafeDirectoryPath(directory) { function isDarwinSystemAlias(target) { return process.platform === "darwin" && (target === "/etc" || target === "/tmp" || target === "/var"); } -function fsyncPath(target) { - const descriptor = fs.openSync(target, "r"); - try { - fs.fsyncSync(descriptor); - } - finally { - fs.closeSync(descriptor); - } -} function hasCode(error, code) { return error instanceof Error && Reflect.get(error, "code") === code; } diff --git a/src/setup-file-worker.ts b/src/setup-file-worker.ts new file mode 100644 index 0000000..f21549e --- /dev/null +++ b/src/setup-file-worker.ts @@ -0,0 +1,182 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const BUSY_EXIT = 75; +const MISSING_EXIT = 66; + +try { + process.exitCode = main(); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} + +function main(): number { + const operation = requiredArgument(2); + const name = requiredArgument(3); + const expectedDevice = requiredArgument(4); + const expectedInode = requiredArgument(5); + if (path.basename(name) !== name || name === "." || name === "..") throw new Error("Invalid setup file name."); + assertDirectoryIdentity(expectedDevice, expectedInode); + + switch (operation) { + case "lock": return acquireLock(name); + case "unlock": fs.rmdirSync(`${name}.lock`); return 0; + case "read": return writeCurrentFile(name); + case "write": writeChangedFile(name, requiredArgument(6), requiredArgument(7), fs.readFileSync(0)); return 0; + default: throw new Error(`Unknown setup file operation: ${operation}`); + } +} + +function acquireLock(name: string): number { + const lock = `${name}.lock`; + try { + fs.mkdirSync(lock, { mode: 0o700 }); + return 0; + } catch (error) { + if (!hasCode(error, "EEXIST")) throw error; + } + let status: fs.Stats; + try { + status = fs.lstatSync(lock); + } catch (error) { + if (hasCode(error, "ENOENT")) return BUSY_EXIT; + throw error; + } + if (status.isSymbolicLink()) throw new Error(`Refusing setup lock symlink: ${path.resolve(lock)}`); + if (!status.isDirectory()) throw new Error(`Cannot use setup lock that is not a directory: ${path.resolve(lock)}`); + return BUSY_EXIT; +} + +function writeCurrentFile(name: string): number { + const bytes = readAnchoredFile(name); + if (bytes === undefined) return MISSING_EXIT; + process.stdout.write(bytes); + return 0; +} + +function writeChangedFile(name: string, expectedHash: string, timestamp: string, next: Buffer): void { + const current = readAnchoredFile(name); + const actualHash = current === undefined ? "missing" : hash(current); + if (actualHash !== expectedHash) throw new Error(`Setup configuration changed while updating: ${path.resolve(name)}`); + if (current !== undefined) backupAnchoredFile(name, current, timestamp); + writeAnchoredFile(name, next); +} + +function readAnchoredFile(name: string): Buffer | undefined { + let pathStatus: fs.Stats; + try { + pathStatus = fs.lstatSync(name); + } catch (error) { + if (hasCode(error, "ENOENT")) return undefined; + throw error; + } + if (pathStatus.isSymbolicLink()) throw new Error(`Refusing setup configuration symlink: ${path.resolve(name)}`); + if (!pathStatus.isFile()) throw new Error(`Cannot update setup configuration that is not a regular file: ${path.resolve(name)}`); + let descriptor: number; + try { + descriptor = fs.openSync(name, fs.constants.O_RDONLY | noFollowFlag()); + } catch (error) { + if (hasCode(error, "ELOOP")) throw new Error(`Refusing setup configuration symlink: ${path.resolve(name)}`); + throw error; + } + try { + const opened = fs.fstatSync(descriptor); + const current = fs.lstatSync(name); + if (!opened.isFile() || current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) { + throw new Error(`Setup configuration path changed while opening: ${path.resolve(name)}`); + } + return fs.readFileSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function writeAnchoredFile(name: string, bytes: Buffer): void { + const temporary = `${name}.${randomUUID()}.tmp`; + let descriptor: number | undefined; + try { + descriptor = fs.openSync(temporary, "wx", 0o600); + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + fs.renameSync(temporary, name); + if (process.platform !== "win32") fsyncDirectory(); + } catch (error) { + if (descriptor !== undefined) fs.closeSync(descriptor); + try { + fs.unlinkSync(temporary); + } catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) throw new AggregateError([error, cleanupError], "Setup publication and cleanup failed."); + } + throw error; + } +} + +function backupAnchoredFile(name: string, bytes: Buffer, timestampValue: string): void { + const extension = path.extname(name); + const stem = extension ? name.slice(0, -extension.length) : name; + const timestamp = timestampValue.replace(/[:.]/gu, "-"); + for (let suffix = 0; ; suffix += 1) { + const candidate = `${stem}-pre-agent-lcm-${timestamp}${suffix ? `-${suffix}` : ""}${extension}`; + let descriptor: number; + try { + descriptor = fs.openSync(candidate, "wx", 0o600); + } catch (error) { + if (hasCode(error, "EEXIST")) continue; + throw error; + } + try { + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + return; + } catch (error) { + fs.closeSync(descriptor); + try { + fs.unlinkSync(candidate); + } catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) throw new AggregateError([error, cleanupError], "Setup backup and cleanup failed."); + } + throw error; + } + } +} + +function assertDirectoryIdentity(expectedDevice: string, expectedInode: string): void { + const actual = fs.statSync("."); + if (!actual.isDirectory() || String(actual.dev) !== expectedDevice || String(actual.ino) !== expectedInode) { + throw new Error(`Setup directory changed while updating: ${process.cwd()}`); + } +} + +function fsyncDirectory(): void { + const descriptor = fs.openSync(".", "r"); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function hash(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function noFollowFlag(): number { + return process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW; +} + +function requiredArgument(index: number): string { + const value = process.argv[index]; + if (value === undefined) throw new Error("Missing setup file worker argument."); + return value; +} + +function hasCode(error: unknown, code: string): boolean { + return error instanceof Error && Reflect.get(error, "code") === code; +} diff --git a/src/setup-files.ts b/src/setup-files.ts index 478b587..15a5678 100644 --- a/src/setup-files.ts +++ b/src/setup-files.ts @@ -1,10 +1,18 @@ -import { randomUUID } from "node:crypto"; +import childProcess from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; const SETUP_LOCK_TIMEOUT_MS = 10_000; const SETUP_LOCK_POLL_MS = 10; const SETUP_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); +const WORKER_BUSY_EXIT = 75; +const WORKER_MISSING_EXIT = 66; +const SETUP_FILE_WORKER = fileURLToPath(new URL( + import.meta.url.endsWith(".ts") ? "./setup-file-worker.ts" : "./setup-file-worker.js", + import.meta.url, +)); export class SetupFileLockTimeoutError extends Error { readonly lockPath: string; @@ -25,17 +33,17 @@ export function mutateSetupConfiguration( const directoryIdentity = fs.lstatSync(directory); if (!directoryIdentity.isDirectory()) throw new Error(`Setup directory changed while updating: ${directory}`); return withSetupFileLock(target, directoryIdentity, () => { - assertDirectoryIdentity(directory, directoryIdentity); - const current = readSetupFile(target); + const current = readAnchoredSetupFile(target, directoryIdentity); const existing = current ? parseSetupConfiguration(current, target) : undefined; const next = transform(existing); if (next === undefined) return false; if (existing && JSON.stringify(existing) === JSON.stringify(next)) return false; - assertDirectoryIdentity(directory, directoryIdentity); - if (current) backupSetupBytes(target, current); - assertDirectoryIdentity(directory, directoryIdentity); - writeSetupBytes(target, Buffer.from(`${JSON.stringify(next, null, 2)}\n`)); - assertDirectoryIdentity(directory, directoryIdentity); + writeAnchoredSetupFile( + target, + directoryIdentity, + current === undefined ? "missing" : createHash("sha256").update(current).digest("hex"), + Buffer.from(`${JSON.stringify(next, null, 2)}\n`), + ); return true; }); } @@ -57,88 +65,76 @@ function parseSetupConfiguration(bytes: Buffer, target: string): Record(target: string, directoryIdentity: fs.Stats, callback: () => T): T { const lockPath = `${target}.lock`; const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; let acquired = false; + let failed = false; // ponytail: a crashed setup leaves this empty directory; recover it manually // rather than guessing whether another setup process is still alive. try { while (!acquired) { - assertDirectoryIdentity(path.dirname(target), directoryIdentity); - try { - fs.mkdirSync(lockPath, { mode: 0o700 }); - acquired = true; - } catch (error) { - if (!hasCode(error, "EEXIST")) throw error; - const status = fs.lstatSync(lockPath); - if (status.isSymbolicLink()) throw new Error(`Refusing setup lock symlink: ${lockPath}`); - if (!status.isDirectory()) throw new Error(`Cannot use setup lock that is not a directory: ${lockPath}`); - if (Date.now() >= deadline) throw new SetupFileLockTimeoutError(lockPath); - Atomics.wait(SETUP_LOCK_WAIT, 0, 0, SETUP_LOCK_POLL_MS); - } + const result = runSetupFileWorker("lock", target, directoryIdentity); + if (result.status === 0) acquired = true; + else if (result.status !== WORKER_BUSY_EXIT) throw setupFileWorkerError(result); + else if (Date.now() >= deadline) throw new SetupFileLockTimeoutError(lockPath); + else Atomics.wait(SETUP_LOCK_WAIT, 0, 0, SETUP_LOCK_POLL_MS); } - assertDirectoryIdentity(path.dirname(target), directoryIdentity); return callback(); + } catch (error) { + failed = true; + throw error; } finally { - if (acquired) fs.rmdirSync(lockPath); + if (acquired) { + const result = runSetupFileWorker("unlock", target, directoryIdentity); + if (!failed && result.status !== 0) throw setupFileWorkerError(result); + } } } +function readAnchoredSetupFile(target: string, directoryIdentity: fs.Stats): Buffer | undefined { + const result = runSetupFileWorker("read", target, directoryIdentity); + if (result.status === WORKER_MISSING_EXIT) return undefined; + if (result.status !== 0) throw setupFileWorkerError(result); + return result.stdout; +} + +function writeAnchoredSetupFile(target: string, directoryIdentity: fs.Stats, expectedHash: string, bytes: Buffer): void { + const result = runSetupFileWorker("write", target, directoryIdentity, [expectedHash, new Date().toISOString()], bytes); + if (result.status !== 0) throw setupFileWorkerError(result); +} + +function runSetupFileWorker( + operation: "lock" | "unlock" | "read" | "write", + target: string, + directoryIdentity: fs.Stats, + extraArguments: readonly string[] = [], + input?: Buffer, +): childProcess.SpawnSyncReturns { + const result = childProcess.spawnSync(process.execPath, [ + "--no-warnings", + SETUP_FILE_WORKER, + operation, + path.basename(target), + String(directoryIdentity.dev), + String(directoryIdentity.ino), + ...extraArguments, + ], { + cwd: path.dirname(target), + input, + maxBuffer: 16 * 1024 * 1024, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.error !== undefined) throw result.error; + return result; +} + +function setupFileWorkerError(result: childProcess.SpawnSyncReturns): Error { + const message = result.stderr.toString("utf8").trim(); + return new Error(message || `Setup file worker failed with status ${String(result.status)}.`); +} + function readSetupFile(target: string): Buffer | undefined { let pathStatus: fs.Stats; try { @@ -178,13 +174,6 @@ function noFollowFlag(): number { return process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW; } -function assertDirectoryIdentity(directory: string, expected: fs.Stats): void { - const actual = fs.lstatSync(directory); - if (!actual.isDirectory() || actual.dev !== expected.dev || actual.ino !== expected.ino) { - throw new Error(`Setup directory changed while updating: ${directory}`); - } -} - function assertSafeDirectoryPath(directory: string): void { const resolved = path.resolve(directory); const root = path.parse(resolved).root; @@ -210,15 +199,6 @@ function isDarwinSystemAlias(target: string): boolean { return process.platform === "darwin" && (target === "/etc" || target === "/tmp" || target === "/var"); } -function fsyncPath(target: string): void { - const descriptor = fs.openSync(target, "r"); - try { - fs.fsyncSync(descriptor); - } finally { - fs.closeSync(descriptor); - } -} - function hasCode(error: unknown, code: string): boolean { return error instanceof Error && Reflect.get(error, "code") === code; } diff --git a/tests/setup-files.test.ts b/tests/setup-files.test.ts index 3c40b01..4d99bd0 100644 --- a/tests/setup-files.test.ts +++ b/tests/setup-files.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import childProcess, { spawn } from "node:child_process"; import { once } from "node:events"; import fs from "node:fs"; import path from "node:path"; @@ -62,29 +62,59 @@ test("a parent swap during lock acquisition cannot redirect setup", { skip: proc const originalSafe = path.join(home, "safe-original"); const outside = path.join(home, "outside"); const target = path.join(safe, "hooks.json"); - const lock = `${target}.lock`; fs.mkdirSync(safe); fs.mkdirSync(outside); - const originalMkdir = fs.mkdirSync; + const originalSpawnSync = childProcess.spawnSync; let swapped = false; - const swappedMkdir = ((candidate: fs.PathLike, options?: fs.MakeDirectoryOptions & { recursive?: false }) => { - if (!swapped && candidate.toString() === lock) { + const swappedSpawnSync = ((command: string, args?: readonly string[], options?: childProcess.SpawnSyncOptions) => { + if (!swapped && args?.includes("lock")) { swapped = true; fs.renameSync(safe, originalSafe); fs.symlinkSync(outside, safe); } - return originalMkdir(candidate, options); - }) as typeof fs.mkdirSync; - Object.defineProperty(fs, "mkdirSync", { configurable: true, value: swappedMkdir }); + return originalSpawnSync(command, args, options); + }) as typeof childProcess.spawnSync; + Object.defineProperty(childProcess, "spawnSync", { configurable: true, value: swappedSpawnSync }); try { assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /directory changed/u); } finally { - Object.defineProperty(fs, "mkdirSync", { configurable: true, value: originalMkdir }); + Object.defineProperty(childProcess, "spawnSync", { configurable: true, value: originalSpawnSync }); } assert.equal(fs.existsSync(path.join(outside, "hooks.json")), false); assert.deepEqual(fs.readdirSync(outside), []); }); +test("a parent swap during final publication cannot overwrite an outside target", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-publish-parent-race-"); + const safe = path.join(home, "safe"); + const moved = path.join(home, "safe-moved"); + const outside = path.join(home, "outside"); + const target = path.join(safe, "hooks.json"); + const victim = path.join(outside, "hooks.json"); + const original = Buffer.from('{"outside":true}\n'); + fs.mkdirSync(safe); + fs.mkdirSync(outside); + fs.writeFileSync(victim, original); + const originalSpawnSync = childProcess.spawnSync; + let swapped = false; + const swappedSpawnSync = ((command: string, args?: readonly string[], options?: childProcess.SpawnSyncOptions) => { + if (!swapped && args?.includes("write")) { + swapped = true; + fs.renameSync(safe, moved); + fs.symlinkSync(outside, safe); + } + return originalSpawnSync(command, args, options); + }) as typeof childProcess.spawnSync; + Object.defineProperty(childProcess, "spawnSync", { configurable: true, value: swappedSpawnSync }); + try { + assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /directory changed/u); + } finally { + Object.defineProperty(childProcess, "spawnSync", { configurable: true, value: originalSpawnSync }); + } + assert.equal(swapped, true); + assert.deepEqual(fs.readFileSync(victim), original); +}); + test("a lock swap after a path check cannot chmod the victim", { skip: process.platform === "win32" }, () => { const home = tempHome("agent-lcm-setup-lock-chmod-race-"); const target = path.join(home, "hooks.json"); @@ -93,22 +123,15 @@ test("a lock swap after a path check cannot chmod the victim", { skip: process.p fs.writeFileSync(lock, ""); fs.writeFileSync(victim, ""); fs.chmodSync(victim, 0o644); - const originalLstat = fs.lstatSync; - let swapped = false; - const swappedLstat = ((candidate: fs.PathLike) => { - const status = originalLstat(candidate); - if (!swapped && candidate.toString() === lock) { - swapped = true; - fs.unlinkSync(lock); - fs.symlinkSync(victim, lock); - } - return status; - }) as typeof fs.lstatSync; - Object.defineProperty(fs, "lstatSync", { configurable: true, value: swappedLstat }); + const preload = path.join(home, "swap-lock.cjs"); + fs.writeFileSync(preload, `const fs = require("node:fs");\nconst original = fs.lstatSync;\nfs.lstatSync = function(candidate, options) { const status = original(candidate, options); if (candidate === "hooks.json.lock") { fs.unlinkSync(candidate); fs.symlinkSync(${JSON.stringify(victim)}, candidate); } return status; };\n`); + const originalNodeOptions = process.env.NODE_OPTIONS; + process.env.NODE_OPTIONS = `--require=${preload}`; try { assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /not a directory/u); } finally { - Object.defineProperty(fs, "lstatSync", { configurable: true, value: originalLstat }); + if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalNodeOptions; } assert.equal(fs.statSync(victim).mode & 0o777, 0o644); }); @@ -119,25 +142,18 @@ test("a target swap after opening cannot copy symlink-victim bytes", { skip: pro const victim = path.join(home, "victim.json"); fs.writeFileSync(target, '{"hooks":{}}\n'); fs.writeFileSync(victim, '{"isolated-secret":true}\n'); - const originalOpen = fs.openSync; - let swapped = false; - const swappedOpen = ((candidate: fs.PathLike, flags: string | number, mode?: fs.Mode) => { - const descriptor = originalOpen(candidate, flags, mode); - if (!swapped && candidate.toString() === target) { - swapped = true; - fs.unlinkSync(target); - fs.symlinkSync(victim, target); - } - return descriptor; - }) as typeof fs.openSync; - Object.defineProperty(fs, "openSync", { configurable: true, value: swappedOpen }); + const preload = path.join(home, "swap-target.cjs"); + fs.writeFileSync(preload, `const fs = require("node:fs");\nconst original = fs.openSync;\nfs.openSync = function(candidate, flags, mode) { const descriptor = original(candidate, flags, mode); if (candidate === "hooks.json") { fs.unlinkSync(candidate); fs.symlinkSync(${JSON.stringify(victim)}, candidate); } return descriptor; };\n`); + const originalNodeOptions = process.env.NODE_OPTIONS; + process.env.NODE_OPTIONS = `--require=${preload}`; try { assert.throws( () => mutateSetupConfiguration(target, (configuration) => ({ ...configuration, added: true })), /path changed/u, ); } finally { - Object.defineProperty(fs, "openSync", { configurable: true, value: originalOpen }); + if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalNodeOptions; } const backups = fs.readdirSync(home).filter((name) => name.startsWith("hooks-pre-agent-lcm-")); assert.deepEqual(backups, []); @@ -188,10 +204,10 @@ test("publication failure preserves the target, cleans temporary files, and keep fs.mkdirSync(directory, { mode: 0o755 }); fs.chmodSync(directory, 0o755); fs.writeFileSync(target, original); - const originalRename = fs.renameSync; - fs.renameSync = () => { - throw new Error("injected publication failure"); - }; + const preload = path.join(home, "fail-rename.cjs"); + fs.writeFileSync(preload, `const fs = require("node:fs");\nconst original = fs.renameSync;\nfs.renameSync = function(source, target) { if (String(source).endsWith(".tmp") && target === "hooks.json") throw new Error("injected publication failure"); return original(source, target); };\n`); + const originalNodeOptions = process.env.NODE_OPTIONS; + process.env.NODE_OPTIONS = `--require=${preload}`; // When: atomic publication fails at rename. try { @@ -200,7 +216,8 @@ test("publication failure preserves the target, cleans temporary files, and keep /injected publication failure/u, ); } finally { - fs.renameSync = originalRename; + if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalNodeOptions; } // Then: the old target survives, temporary files are gone, and the directory mode is unchanged. @@ -257,6 +274,10 @@ test("serializes concurrent setup mutations", async () => { env: { ...process.env, SETUP_FILES_URL: moduleUrl, SETUP_TARGET: target }, stdio: ["ignore", "pipe", "pipe"], }); + const holderErrors: Buffer[] = []; + const waiterErrors: Buffer[] = []; + holder.stderr.on("data", (chunk: Buffer) => holderErrors.push(chunk)); + waiter.stderr.on("data", (chunk: Buffer) => waiterErrors.push(chunk)); await once(waiter.stdout, "data"); // When: the holder releases after the waiter has started its mutation call. @@ -264,8 +285,8 @@ test("serializes concurrent setup mutations", async () => { const [holderExit, waiterExit] = await Promise.all([once(holder, "exit"), once(waiter, "exit")]); // Then: both transforms survive and neither process leaves a temporary file. - assert.equal(holderExit[0], 0); - assert.equal(waiterExit[0], 0); + assert.equal(holderExit[0], 0, Buffer.concat(holderErrors).toString() || "holder failed"); + assert.equal(waiterExit[0], 0, Buffer.concat(waiterErrors).toString() || "waiter failed"); assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), { base: true, holder: true, waiter: true }); assert.deepEqual(fs.readdirSync(home).filter((name) => name.endsWith(".tmp")), []); }); From 7fbc5a606545e84237c3789b3b2eb277376a47a4 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 06:37:34 -0400 Subject: [PATCH 18/29] fix(setup): materialize native Copilot package --- AGENTS.md | 5 +++ README.md | 20 ++++++----- dist/copilot-plugin.js | 62 ++++++++++++++++++++++++++++++++ dist/setup-adapters.js | 33 +++++++++++------ dist/setup.js | 2 +- docs/architecture.md | 15 ++++---- docs/install/copilot.md | 26 +++++++------- docs/install/vscode.md | 29 ++++++++------- src/AGENTS.md | 5 +-- src/copilot-plugin.ts | 68 ++++++++++++++++++++++++++++++++++++ src/setup-adapters.ts | 34 +++++++++++------- src/setup.ts | 2 +- tests/AGENTS.md | 5 +++ tests/distribution.test.ts | 2 ++ tests/setup-adapters.test.ts | 62 ++++++++++++++++++++++++++++---- 15 files changed, 293 insertions(+), 77 deletions(-) create mode 100644 dist/copilot-plugin.js create mode 100644 src/copilot-plugin.ts diff --git a/AGENTS.md b/AGENTS.md index 20e7c5e..9844e5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,11 @@ docs/ architecture and troubleshooting `shared-retained` use exit `2`; command errors use exit `1`. - Copilot and VS Code share the native plugin store. Single-harness removal must retain that plugin and leave any legacy fallback hook file unchanged. +- Copilot-format plugins have no plugin-root command variable. Setup must + install the generated native package whose hook and MCP commands use the + absolute Agent LCM executable. +- Setup-file mutation runs through the directory-anchored helper. Do not + replace it with path checks followed by later path-based writes. - Validate existing setup JSON before native work. Preserve unrelated and near-matching hooks, reject symlinked or non-regular targets, and publish changes under an atomic `.lock` directory through a unique fsynced diff --git a/README.md b/README.md index 01b71c0..510ad19 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,8 @@ agent-lcm setup copilot agent-lcm setup kiro ``` -Run only the commands for the harnesses you use. The manual VS Code and GitHub -Copilot fallback shares `~/.copilot/hooks/agent-lcm.json`; native plugin hooks +Run only the commands for the harnesses you use. A legacy VS Code and GitHub +Copilot fallback may share `~/.copilot/hooks/agent-lcm.json`; native plugin hooks are loaded from the plugin store instead of being duplicated there. Setup preserves unrelated hook entries, is safe to run again, and writes private files containing the absolute Agent LCM command when manual wiring is needed. @@ -168,8 +168,9 @@ Native lifecycle support is limited to the commands that each client documents: directory as a local marketplace, then runs `codex plugin add agent-lcm@agent-lcm`. Removal runs `codex plugin remove agent-lcm@agent-lcm`. - GitHub Copilot CLI and VS Code share the Copilot plugin store. Setup probes - with `copilot plugin list` and installs the same local Agent LCM package - directory. `agent-lcm remove copilot` and `agent-lcm remove vscode` + with `copilot plugin list`, builds a private native package with absolute + Agent LCM hook and MCP commands, and installs it with `copilot plugin + install`. `agent-lcm remove copilot` and `agent-lcm remove vscode` return `shared-retained` without uninstalling that shared plugin; use the documented Copilot uninstall command only after reviewing both clients. - Cursor and Kiro are probed with `cursor-agent --version` and `kiro-cli @@ -179,11 +180,12 @@ Native lifecycle support is limited to the commands that each client documents: Setup validates an existing hook file before invoking a native CLI, preserves unrelated entries, and changes only exact Agent LCM-owned registrations. It -backs up a changed file as `*-pre-agent-lcm-*.json`, holds an atomic lock -directory at `.lock` for at most ten seconds, and publishes through a -unique `wx` temporary file, `fsync`, and rename. Symlinked directory -components, lock paths, targets, and non-regular files are refused. Hook -commands must be absolute paths without shell metacharacters. These rules make +backs up a changed file as `*-pre-agent-lcm-*.json` and holds an atomic lock +directory at `.lock` for at most ten seconds. A helper process anchors +its working directory to the checked target directory before it reads, backs +up, or publishes through a unique `wx` temporary file, `fsync`, and rename. +Symlinked directory components, lock paths, targets, and non-regular files are +refused. Hook commands must be absolute paths without shell metacharacters. These rules make repeated setup and removal safe while avoiding a second user-level hook copy after native installation. diff --git a/dist/copilot-plugin.js b/dist/copilot-plugin.js new file mode 100644 index 0000000..cdde464 --- /dev/null +++ b/dist/copilot-plugin.js @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { assertSafeSetupCommand } from "./setup-hook-status.js"; +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export function withCopilotPluginSource(command, callback) { + assertSafeSetupCommand(command); + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-copilot-plugin-")); + const source = path.join(temporary, "agent-lcm"); + try { + fs.mkdirSync(source, { mode: 0o700 }); + const packageJson = readPackageJson(); + writeJson(path.join(source, "plugin.json"), { + name: "agent-lcm", + version: packageJson.version, + description: packageJson.description, + author: { name: "Team Volt" }, + homepage: "https://github.com/Team-Volt/agent-lcm", + license: "MIT", + skills: "skills/", + hooks: "hooks.json", + mcpServers: ".mcp.json", + }); + writeJson(path.join(source, "hooks.json"), copilotHooks(command)); + writeJson(path.join(source, ".mcp.json"), { + mcpServers: { + "agent-lcm": { type: "stdio", command: "node", args: [command, "mcp"] }, + }, + }); + fs.cpSync(path.join(PACKAGE_ROOT, "skills"), path.join(source, "skills"), { recursive: true }); + return callback(source); + } + finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } +} +function copilotHooks(command) { + const capture = `node "${command}" capture --harness auto`; + return { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: capture }], + userPromptSubmitted: [{ type: "command", command: capture }], + postToolUse: [{ type: "command", command: capture }], + sessionEnd: [{ type: "command", command: capture }], + }, + }; +} +function readPackageJson() { + const value = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")); + if (!isRecord(value) || typeof value.version !== "string" || typeof value.description !== "string") { + throw new Error("Agent LCM package metadata is invalid."); + } + return { version: value.version, description: value.description }; +} +function writeJson(target, value) { + fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/dist/setup-adapters.js b/dist/setup-adapters.js index 907e6e9..842d510 100644 --- a/dist/setup-adapters.js +++ b/dist/setup-adapters.js @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { withCopilotPluginSource } from "./copilot-plugin.js"; export class NativeLifecycleCommandError extends Error { name = "NativeLifecycleCommandError"; executable; @@ -36,14 +37,12 @@ export const HARNESS_LIFECYCLE_ADAPTERS = { executable: "copilot", guide: `${GUIDE_ROOT}/vscode.md`, probeArgv: ["plugin", "list"], - setupArgv: [["plugin", "install", PACKAGE_ROOT]], }, copilot: { kind: "copilot", executable: "copilot", guide: `${GUIDE_ROOT}/copilot.md`, probeArgv: ["plugin", "list"], - setupArgv: [["plugin", "install", PACKAGE_ROOT]], }, kiro: { kind: "manual", executable: "kiro-cli", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/kiro.md` }, }; @@ -55,14 +54,14 @@ export function runHarnessLifecycle(harness, action, options = {}) { case "copilot": if (action === "remove") return outcome(harness, action, "shared-retained", null, adapter.guide); - return runNative(harness, action, adapter, options.env); + return runNative(harness, action, adapter, options.env, options.command); case "codex": return runNative(harness, action, adapter, options.env); default: return assertNever(adapter); } } -function runNative(harness, action, adapter, env) { +function runNative(harness, action, adapter, env, command) { const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); if (isEnoent(probe.error)) { return outcome(harness, action, "manual-required", null, adapter.guide); @@ -70,17 +69,29 @@ function runNative(harness, action, adapter, env) { if (probe.error !== undefined || probe.status !== 0) { throw new NativeLifecycleCommandError(adapter.executable, adapter.probeArgv, probe.status, SUPPRESSED_STDERR); } - const commands = action === "setup" - ? adapter.setupArgv - : adapter.kind === "codex" ? [adapter.removeArgv] : []; - for (const argv of commands) - runNativeCommand(adapter.executable, argv, env); + if (action === "setup" && adapter.kind === "copilot") { + withCopilotPluginSource(command ?? path.join(PACKAGE_ROOT, "bin", "agent-lcm"), (source) => { + runNativeCommand(adapter.executable, ["plugin", "install", source], env); + }); + } + else if (adapter.kind === "codex") { + const commands = action === "setup" ? adapter.setupArgv : [adapter.removeArgv]; + for (const argv of commands) + runNativeCommand(adapter.executable, argv, env); + } + else { + throw new Error("Copilot removal must retain the shared plugin."); + } return outcome(harness, action, "native-complete", adapter.executable, adapter.guide); } function manualOutcome(harness, action, adapter, env) { const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); - const nativeCli = isEnoent(probe.error) || probe.status !== 0 ? null : adapter.executable; - return outcome(harness, action, "manual-required", nativeCli, adapter.guide); + if (isEnoent(probe.error)) + return outcome(harness, action, "manual-required", null, adapter.guide); + if (probe.error !== undefined || probe.status !== 0) { + throw new NativeLifecycleCommandError(adapter.executable, adapter.probeArgv, probe.status, SUPPRESSED_STDERR); + } + return outcome(harness, action, "manual-required", adapter.executable, adapter.guide); } function runNativeCommand(executable, argv, env) { const result = spawnSync(executable, argv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); diff --git a/dist/setup.js b/dist/setup.js index cad9289..828bbfc 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -11,7 +11,7 @@ export function setupHarness(harness, options) { const existing = readSetupConfiguration(target); validateSetupHooks(harness, existing, target); ensureSetupDirectory(path.dirname(target)); - const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env } : {}); + const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env, command } : { command }); const changed = updateHooks(harness, native.status, target, command, existing !== undefined); return { harness, diff --git a/docs/architecture.md b/docs/architecture.md index 1bdf97f..a6afd06 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,8 +32,9 @@ incompatible protocol. plugin commands before updating legacy capture hooks. Codex uses `codex plugin list`, adds the installed package directory as a local marketplace, then runs `codex plugin add agent-lcm@agent-lcm`. Copilot CLI and VS Code use the shared -Copilot store and install that same local package directory after `copilot -plugin list` succeeds. +Copilot store. Setup generates a private Copilot-format package whose hooks and +MCP config contain the absolute installed Agent LCM command, then installs it +after `copilot plugin list` succeeds. Cursor and Kiro run version-only probes for `cursor-agent` and `kiro-cli`. Their Marketplace or Powers steps remain manual, so their native result is `manual-required`. @@ -51,10 +52,12 @@ near-matching commands remain untouched; only an exact harness/event/command registration is changed. Setup files use an atomic `.lock` directory (bounded to ten seconds). -Publication writes a unique `wx` temporary file with -restrictive permissions, fsyncs it, renames it, and fsyncs the parent directory. -Symlinked or non-regular targets are refused, hook commands must be absolute and -shell-safe, and changed files receive a collision-safe `-pre-agent-lcm-` backup. +A short-lived helper changes into the checked target directory and verifies its +device and inode before it reads, backs up, or publishes. Publication writes a +unique `wx` temporary file with restrictive permissions, fsyncs it, renames it, +and fsyncs the anchored directory. Symlinked or non-regular targets are +refused, hook commands must be absolute and shell-safe, and changed files +receive a collision-safe `-pre-agent-lcm-` backup. ## Capture and retrieval flow diff --git a/docs/install/copilot.md b/docs/install/copilot.md index e68267b..bf0132c 100644 --- a/docs/install/copilot.md +++ b/docs/install/copilot.md @@ -8,28 +8,30 @@ Run: agent-lcm setup copilot ``` -This runs the Copilot native lifecycle against the installed Agent LCM package -directory when the CLI is available and its plugin probe succeeds. Copilot CLI -auto-loads the bundled hooks, so setup does not add -duplicate shared hooks after native installation. The manual fallback path is -`~/.copilot/hooks/agent-lcm.json`; setup preserves an existing fallback when +When the CLI is available, setup builds a private Copilot-format package with +the absolute installed Agent LCM command, then runs `copilot plugin install`. +The package includes the recall skill, capture hooks, and MCP server. Copilot +CLI copies it into its plugin store, so setup removes the temporary source +after installation. Setup does not add duplicate shared hooks. The legacy +fallback path is `~/.copilot/hooks/agent-lcm.json`; setup preserves an existing fallback when native installation is unavailable, adds no new duplicate, and reports `manual-required` with this guide. ## Native install and inspection -Use the documented Copilot CLI commands with the local directory that contains -Agent LCM's `plugin.json`. For a global npm install, `npm root --global` prints -the `` part of this path: +If setup reports that `copilot` is missing, install Copilot CLI using GitHub's +[official install guide](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli), +then rerun setup: ```sh -copilot plugin install /@team-volt/agent-lcm +agent-lcm setup copilot copilot plugin list ``` -Do not type the angle-bracket placeholder as written. If you run Agent LCM from -a source checkout, use that checkout's root instead. The list command shows -installed plugins. See the [Copilot CLI plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference) for the command set and plugin specification. +Do not install the Agent LCM package root directly. Copilot-format plugins do +not define a plugin-root variable for hook commands, so setup must write the +absolute installed command into a native package first. The list command shows +the installed plugin. See the [Copilot CLI plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference) for the supported command set. The Copilot CLI reference does not require a restart after installation. If a new plugin is not visible, start a new Copilot session as troubleshooting and diff --git a/docs/install/vscode.md b/docs/install/vscode.md index d0f182c..f8098cb 100644 --- a/docs/install/vscode.md +++ b/docs/install/vscode.md @@ -8,10 +8,11 @@ Run: agent-lcm setup vscode ``` -This runs the Copilot native lifecycle against the installed Agent LCM package -directory when the CLI is available and its plugin probe succeeds. VS Code -auto-loads hooks from the Copilot plugin store, so setup -does not add duplicate shared hooks after native installation. The manual +When Copilot CLI is available, setup builds a private Copilot-format package +with the absolute installed Agent LCM command and installs it into the shared +plugin store. The package includes the recall skill, capture hooks, and MCP +server. VS Code discovers that store, so setup does not add duplicate shared +hooks after native installation. The legacy fallback path is `~/.copilot/hooks/agent-lcm.json`; setup preserves an existing fallback when native installation is unavailable, adds no new duplicate, and reports `manual-required` with this guide. @@ -19,22 +20,20 @@ reports `manual-required` with this guide. ## Native install and inspection VS Code automatically discovers plugins installed by Copilot CLI from -`~/.copilot/installed-plugins/`. To install through that shared store, use the -local directory that contains Agent LCM's `plugin.json`. For a global npm -install, `npm root --global` prints the `` part: +`~/.copilot/installed-plugins/`. If setup reports that `copilot` is missing, +install Copilot CLI using GitHub's [official install guide](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli), +then rerun: ```sh -copilot plugin install /@team-volt/agent-lcm +agent-lcm setup vscode copilot plugin list ``` -Do not type the angle-bracket placeholder as written. If you run Agent LCM from -a source checkout, use that checkout's root instead. - -You can install from the VS Code UI instead. Open Extensions and search for -`@agentPlugins`, or run `Chat: Install Plugin From Source` from the Command -Palette and enter `https://github.com/Team-Volt/agent-lcm`. Inspect the result in -the Agent Plugins - Installed view. See the [VS Code agent plugin guide](https://code.visualstudio.com/docs/agent-customization/agent-plugins). +Do not install the Agent LCM package root directly from the VS Code command +palette. Its portable manifest cannot hold the absolute local command that the +Copilot-format hook and MCP files need. Setup creates that native package. You +can inspect, enable, disable, or uninstall the installed package in VS Code's +Agent Plugins view. See the [VS Code agent plugin guide](https://code.visualstudio.com/docs/agent-customization/agent-plugins). If VS Code asks you to trust a new marketplace or repository, review the source before confirming. The official guide does not require a restart. If the plugin diff --git a/src/AGENTS.md b/src/AGENTS.md index f452597..15618e1 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -44,9 +44,10 @@ ## Harness setup safety - Probe and invoke only documented commands: Codex uses `codex plugin`; Copilot and VS Code use the shared `copilot plugin` store; Cursor and Kiro use version-only probes and keep plugin changes manual. +- Generate the Copilot-format package at setup time so hooks and MCP use the validated absolute Agent LCM command. Keep its source basename `agent-lcm` so repeat direct installs update one native plugin. - Validate existing setup JSON before starting a native process. Match owned hooks by harness, event, and command shape; preserve unrelated and near-matching entries. -- Keep setup-file writes under a bounded atomic `.lock` directory. Open targets through descriptor-bound no-follow checks; never validate by path and then read or chmod that path. Refuse symlinked directory components, lock paths, targets, and non-regular files. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and parent-directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. -- Install native plugins from the current package directory, never from a mutable remote ref. Treat only `ENOENT` as an unavailable CLI; all other native probe or command failures must stop before hook mutation and must not echo client stderr. +- Keep setup-file reads, backups, locks, and publication inside the helper process anchored to the validated target directory. A later path identity check does not make a path-based write safe. Refuse symlinked directory components, lock paths, targets, and non-regular files. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. +- Build native plugin sources only from the current local package, never from a mutable remote ref. Treat only `ENOENT` as an unavailable CLI; all other native probe or command failures must stop before hook mutation and must not echo client stderr. - Require an absolute hook binary path and reject shell metacharacters before writing configuration. - Never uninstall the shared Copilot plugin for a single `copilot` or `vscode` removal; report `shared-retained` instead. - `setup status` reports legacy/setup-managed `hooksConfigured` state only. Doctor must report native Copilot/VS Code health as unknown unless it has direct native evidence; it must not recommend setup from a missing legacy hook file. diff --git a/src/copilot-plugin.ts b/src/copilot-plugin.ts new file mode 100644 index 0000000..d0f0028 --- /dev/null +++ b/src/copilot-plugin.ts @@ -0,0 +1,68 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { assertSafeSetupCommand } from "./setup-hook-status.ts"; + +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export function withCopilotPluginSource(command: string, callback: (source: string) => T): T { + assertSafeSetupCommand(command); + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-copilot-plugin-")); + const source = path.join(temporary, "agent-lcm"); + try { + fs.mkdirSync(source, { mode: 0o700 }); + const packageJson = readPackageJson(); + writeJson(path.join(source, "plugin.json"), { + name: "agent-lcm", + version: packageJson.version, + description: packageJson.description, + author: { name: "Team Volt" }, + homepage: "https://github.com/Team-Volt/agent-lcm", + license: "MIT", + skills: "skills/", + hooks: "hooks.json", + mcpServers: ".mcp.json", + }); + writeJson(path.join(source, "hooks.json"), copilotHooks(command)); + writeJson(path.join(source, ".mcp.json"), { + mcpServers: { + "agent-lcm": { type: "stdio", command: "node", args: [command, "mcp"] }, + }, + }); + fs.cpSync(path.join(PACKAGE_ROOT, "skills"), path.join(source, "skills"), { recursive: true }); + return callback(source); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } +} + +function copilotHooks(command: string): Record { + const capture = `node "${command}" capture --harness auto`; + return { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: capture }], + userPromptSubmitted: [{ type: "command", command: capture }], + postToolUse: [{ type: "command", command: capture }], + sessionEnd: [{ type: "command", command: capture }], + }, + }; +} + +function readPackageJson(): { readonly version: string; readonly description: string } { + const value: unknown = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")); + if (!isRecord(value) || typeof value.version !== "string" || typeof value.description !== "string") { + throw new Error("Agent LCM package metadata is invalid."); + } + return { version: value.version, description: value.description }; +} + +function writeJson(target: string, value: unknown): void { + fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/setup-adapters.ts b/src/setup-adapters.ts index b9e0cc4..df8a9f6 100644 --- a/src/setup-adapters.ts +++ b/src/setup-adapters.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { withCopilotPluginSource } from "./copilot-plugin.ts"; import type { CaptureHarness } from "./harnesses.ts"; export type HarnessLifecycleAction = "setup" | "remove"; @@ -17,13 +18,13 @@ export type HarnessLifecycleOutcome = { export class NativeLifecycleCommandError extends Error { readonly name = "NativeLifecycleCommandError"; - readonly executable: "codex" | "copilot"; + readonly executable: HarnessCli; readonly argv: readonly string[]; readonly status: number | null; readonly stderr: string; constructor( - executable: "codex" | "copilot", + executable: HarnessCli, argv: readonly string[], status: number | null, stderr: string, @@ -50,7 +51,6 @@ type CopilotLifecycleAdapter = { readonly executable: "copilot"; readonly guide: string; readonly probeArgv: readonly string[]; - readonly setupArgv: readonly (readonly string[])[]; }; type ManualLifecycleAdapter = { @@ -84,14 +84,12 @@ export const HARNESS_LIFECYCLE_ADAPTERS = { executable: "copilot", guide: `${GUIDE_ROOT}/vscode.md`, probeArgv: ["plugin", "list"], - setupArgv: [["plugin", "install", PACKAGE_ROOT]], }, copilot: { kind: "copilot", executable: "copilot", guide: `${GUIDE_ROOT}/copilot.md`, probeArgv: ["plugin", "list"], - setupArgv: [["plugin", "install", PACKAGE_ROOT]], }, kiro: { kind: "manual", executable: "kiro-cli", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/kiro.md` }, } satisfies Record; @@ -99,7 +97,7 @@ export const HARNESS_LIFECYCLE_ADAPTERS = { export function runHarnessLifecycle( harness: CaptureHarness, action: HarnessLifecycleAction, - options: { readonly env?: NodeJS.ProcessEnv } = {}, + options: { readonly env?: NodeJS.ProcessEnv; readonly command?: string } = {}, ): HarnessLifecycleOutcome { const adapter = HARNESS_LIFECYCLE_ADAPTERS[harness]; switch (adapter.kind) { @@ -107,7 +105,7 @@ export function runHarnessLifecycle( return manualOutcome(harness, action, adapter, options.env); case "copilot": if (action === "remove") return outcome(harness, action, "shared-retained", null, adapter.guide); - return runNative(harness, action, adapter, options.env); + return runNative(harness, action, adapter, options.env, options.command); case "codex": return runNative(harness, action, adapter, options.env); default: @@ -120,6 +118,7 @@ function runNative( action: HarnessLifecycleAction, adapter: CodexLifecycleAdapter | CopilotLifecycleAdapter, env: NodeJS.ProcessEnv | undefined, + command?: string, ): HarnessLifecycleOutcome { const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); if (isEnoent(probe.error)) { @@ -129,10 +128,16 @@ function runNative( throw new NativeLifecycleCommandError(adapter.executable, adapter.probeArgv, probe.status, SUPPRESSED_STDERR); } - const commands = action === "setup" - ? adapter.setupArgv - : adapter.kind === "codex" ? [adapter.removeArgv] : []; - for (const argv of commands) runNativeCommand(adapter.executable, argv, env); + if (action === "setup" && adapter.kind === "copilot") { + withCopilotPluginSource(command ?? path.join(PACKAGE_ROOT, "bin", "agent-lcm"), (source) => { + runNativeCommand(adapter.executable, ["plugin", "install", source], env); + }); + } else if (adapter.kind === "codex") { + const commands = action === "setup" ? adapter.setupArgv : [adapter.removeArgv]; + for (const argv of commands) runNativeCommand(adapter.executable, argv, env); + } else { + throw new Error("Copilot removal must retain the shared plugin."); + } return outcome(harness, action, "native-complete", adapter.executable, adapter.guide); } @@ -143,8 +148,11 @@ function manualOutcome( env: NodeJS.ProcessEnv | undefined, ): HarnessLifecycleOutcome { const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); - const nativeCli = isEnoent(probe.error) || probe.status !== 0 ? null : adapter.executable; - return outcome(harness, action, "manual-required", nativeCli, adapter.guide); + if (isEnoent(probe.error)) return outcome(harness, action, "manual-required", null, adapter.guide); + if (probe.error !== undefined || probe.status !== 0) { + throw new NativeLifecycleCommandError(adapter.executable, adapter.probeArgv, probe.status, SUPPRESSED_STDERR); + } + return outcome(harness, action, "manual-required", adapter.executable, adapter.guide); } function runNativeCommand(executable: "codex" | "copilot", argv: readonly string[], env: NodeJS.ProcessEnv | undefined): void { diff --git a/src/setup.ts b/src/setup.ts index d8fd012..d4d0abf 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -40,7 +40,7 @@ export function setupHarness(harness: CaptureHarness, options: SetupOptions): Se const existing = readSetupConfiguration(target); validateSetupHooks(harness, existing, target); ensureSetupDirectory(path.dirname(target)); - const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env } : {}); + const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env, command } : { command }); const changed = updateHooks(harness, native.status, target, command, existing !== undefined); return { harness, diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 195c9ee..d04864a 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -42,6 +42,11 @@ framework, fixture library, or custom runner. - Fake `codex`, `copilot`, `cursor-agent`, and `kiro-cli` executables must record argv and fail on demand; use them to prove the exact documented command vectors without touching a user's installed clients. +- Copilot setup tests must inspect the generated package during the fake + install, assert its stable `agent-lcm` source basename and absolute hook/MCP + command, and then confirm the temporary source was removed. +- Setup-file race tests may inject faults into the helper only through a + disposable child-process preload; never add a production test bypass. - Use `runMcp()` or `runCli(["mcp"], ...)` with newline or framed JSON to test the real stdio MCP server. Assert response IDs, errors, and continuation after malformed input; do not call dispatch functions directly for protocol diff --git a/tests/distribution.test.ts b/tests/distribution.test.ts index 2d8e185..bc19a60 100644 --- a/tests/distribution.test.ts +++ b/tests/distribution.test.ts @@ -36,7 +36,9 @@ test("the npm package contains the complete plugin and no development files", (t "plugin.json", "skills/lcm-recall/SKILL.md", "dist/cli.js", + "dist/copilot-plugin.js", "dist/setup-adapters.js", + "dist/setup-file-worker.js", "dist/setup-hook-status.js", "dist/setup-hooks.js", ]) assert.ok(names.includes(required), `missing ${required}`); diff --git a/tests/setup-adapters.test.ts b/tests/setup-adapters.test.ts index 43b59af..6150bee 100644 --- a/tests/setup-adapters.test.ts +++ b/tests/setup-adapters.test.ts @@ -47,8 +47,9 @@ test("Copilot and VS Code setup send the exact Copilot argv", (t) => { const fake = fakeCli(t, "copilot"); // When: Agent LCM sets up Copilot and VS Code. - const copilot = runHarnessLifecycle("copilot", "setup", { env: fake.env }); - const vscode = runHarnessLifecycle("vscode", "setup", { env: fake.env }); + const command = "/opt/agent-lcm/bin/agent-lcm"; + const copilot = runHarnessLifecycle("copilot", "setup", { env: fake.env, command }); + const vscode = runHarnessLifecycle("vscode", "setup", { env: fake.env, command }); // Then: both use the shared Copilot store and keep their own guide. assert.deepEqual(copilot, { @@ -65,12 +66,31 @@ test("Copilot and VS Code setup send the exact Copilot argv", (t) => { nativeCli: "copilot", guide: `${GUIDE_ROOT}/vscode.md`, }); - assert.deepEqual(readCalls(fake.log), [ + const calls = readCalls(fake.log); + assert.deepEqual(calls.map((argv) => argv.slice(0, 2)), [ ["plugin", "list"], - ["plugin", "install", PACKAGE_ROOT], + ["plugin", "install"], ["plugin", "list"], - ["plugin", "install", PACKAGE_ROOT], + ["plugin", "install"], ]); + for (const argv of calls.filter((entry) => entry[1] === "install")) { + assert.equal(argv.length, 3); + assert.equal(path.basename(argv[2] ?? ""), "agent-lcm"); + assert.equal(fs.existsSync(argv[2] ?? ""), false); + } + for (const snapshot of readPluginSnapshots(fake.pluginLog)) { + assert.equal(snapshot.plugin.hooks, "hooks.json"); + assert.equal(snapshot.plugin.mcpServers, ".mcp.json"); + assert.equal(snapshot.skill, true); + assert.equal(JSON.stringify(snapshot.hooks).includes("${PLUGIN_ROOT}"), false); + assert.equal(JSON.stringify(snapshot.mcp).includes("${PLUGIN_ROOT}"), false); + assert.match(JSON.stringify(snapshot.hooks), new RegExp(command, "u")); + assert.deepEqual(snapshot.mcp.mcpServers["agent-lcm"], { + type: "stdio", + command: "node", + args: [command, "mcp"], + }); + } }); test("manual-required outcomes probe only documented harness version commands", (t) => { @@ -133,6 +153,20 @@ test("a failing native probe is a command error, not an unavailable CLI", (t) => assert.deepEqual(readCalls(fake.log), [["plugin", "list"]]); }); +test("a failing installed manual CLI probe is a command error", (t) => { + const fake = fakeCli(t, "kiro-cli", ["--version"]); + + assert.throws(() => runHarnessLifecycle("kiro", "setup", { env: fake.env }), (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.equal(error.executable, "kiro-cli"); + assert.deepEqual(error.argv, ["--version"]); + assert.equal(error.status, 23); + assert.equal(error.stderr, "suppressed"); + return true; + }); + assert.deepEqual(readCalls(fake.log), [["--version"]]); +}); + test("a native probe permission error is not treated as a missing CLI", { skip: process.platform === "win32" }, (t) => { const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-denied-cli-")); fs.writeFileSync(path.join(bin, "codex"), "denied\n", { mode: 0o600 }); @@ -203,22 +237,36 @@ function fakeCli( t: test.TestContext, name: "codex" | "copilot" | "cursor-agent" | "kiro-cli", fails?: readonly string[], -): { readonly env: NodeJS.ProcessEnv; readonly log: string } { +): { readonly env: NodeJS.ProcessEnv; readonly log: string; readonly pluginLog: string } { const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-fake-cli-")); const log = path.join(bin, "calls.jsonl"); + const pluginLog = path.join(bin, "plugins.jsonl"); const failure = fails ? JSON.stringify(fails) : ""; - const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst args = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(args) + "\\n");\nif (${JSON.stringify(failure)} && JSON.stringify(args) === ${JSON.stringify(failure)}) { process.stderr.write("mutation failed\\n"); process.exit(23); }\n`; + const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst path = require("node:path");\nconst args = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(args) + "\\n");\nif (args[0] === "plugin" && args[1] === "install" && args[2]) { const root = args[2]; fs.appendFileSync(process.env.AGENT_LCM_FAKE_PLUGIN_LOG, JSON.stringify({ plugin: JSON.parse(fs.readFileSync(path.join(root, "plugin.json"), "utf8")), hooks: JSON.parse(fs.readFileSync(path.join(root, "hooks.json"), "utf8")), mcp: JSON.parse(fs.readFileSync(path.join(root, ".mcp.json"), "utf8")), skill: fs.existsSync(path.join(root, "skills/lcm-recall/SKILL.md")) }) + "\\n"); }\nif (${JSON.stringify(failure)} && JSON.stringify(args) === ${JSON.stringify(failure)}) { process.stderr.write("mutation failed\\n"); process.exit(23); }\n`; fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); t.after(() => fs.rmSync(bin, { recursive: true, force: true })); return { env: { AGENT_LCM_FAKE_LOG: log, + AGENT_LCM_FAKE_PLUGIN_LOG: pluginLog, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, }, log, + pluginLog, }; } +type PluginSnapshot = { + readonly plugin: Record; + readonly hooks: Record; + readonly mcp: { readonly mcpServers: Record }; + readonly skill: boolean; +}; + +function readPluginSnapshots(log: string): PluginSnapshot[] { + return fs.readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line) as PluginSnapshot); +} + function readCalls(log: string): string[][] { const contents = fs.readFileSync(log, "utf8").trim(); return contents.length === 0 ? [] : contents.split("\n").map(readCall); From 139e999e8b0ad0e0b8915250ccfa59233bf4a49d Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 06:49:24 -0400 Subject: [PATCH 19/29] docs(setup): explain portable hook boundary --- AGENTS.md | 6 +++--- docs/install/copilot.md | 10 ++++++---- docs/install/vscode.md | 11 ++++++----- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9844e5f..9ee504a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,9 +41,9 @@ docs/ architecture and troubleshooting `shared-retained` use exit `2`; command errors use exit `1`. - Copilot and VS Code share the native plugin store. Single-harness removal must retain that plugin and leave any legacy fallback hook file unchanged. -- Copilot-format plugins have no plugin-root command variable. Setup must - install the generated native package whose hook and MCP commands use the - absolute Agent LCM executable. +- The root manifest declares Agent Plugins 1.0, whose portable components do + not include hooks. Copilot/VS Code setup must install the generated native + package whose hook and MCP commands use the absolute Agent LCM executable. - Setup-file mutation runs through the directory-anchored helper. Do not replace it with path checks followed by later path-based writes. - Validate existing setup JSON before native work. Preserve unrelated and diff --git a/docs/install/copilot.md b/docs/install/copilot.md index bf0132c..b5ffad1 100644 --- a/docs/install/copilot.md +++ b/docs/install/copilot.md @@ -28,10 +28,12 @@ agent-lcm setup copilot copilot plugin list ``` -Do not install the Agent LCM package root directly. Copilot-format plugins do -not define a plugin-root variable for hook commands, so setup must write the -absolute installed command into a native package first. The list command shows -the installed plugin. See the [Copilot CLI plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference) for the supported command set. +Do not use the Agent LCM package root as a substitute for setup. Its manifest +declares Agent Plugins 1.0, where skills and MCP are portable but hooks are not. +Setup writes the client-specific hooks and the absolute installed command into +a Copilot-format package. The list command shows the installed plugin. See the +[Copilot CLI plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference) +for the supported command set. The Copilot CLI reference does not require a restart after installation. If a new plugin is not visible, start a new Copilot session as troubleshooting and diff --git a/docs/install/vscode.md b/docs/install/vscode.md index f8098cb..86c6e75 100644 --- a/docs/install/vscode.md +++ b/docs/install/vscode.md @@ -29,11 +29,12 @@ agent-lcm setup vscode copilot plugin list ``` -Do not install the Agent LCM package root directly from the VS Code command -palette. Its portable manifest cannot hold the absolute local command that the -Copilot-format hook and MCP files need. Setup creates that native package. You -can inspect, enable, disable, or uninstall the installed package in VS Code's -Agent Plugins view. See the [VS Code agent plugin guide](https://code.visualstudio.com/docs/agent-customization/agent-plugins). +Do not use a package-root install from the VS Code command palette as a +substitute for setup. VS Code detects that manifest as Agent Plugins 1.0, whose +portable components do not include hooks. Setup creates the Copilot-format +package that includes capture hooks and absolute local commands. You can +inspect, enable, disable, or uninstall it in VS Code's Agent Plugins view. See +the [VS Code agent plugin guide](https://code.visualstudio.com/docs/agent-customization/agent-plugins). If VS Code asks you to trust a new marketplace or repository, review the source before confirming. The official guide does not require a restart. If the plugin From 84d9b1010e47e65a30cac3f6edd64e01c9c6ea25 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 10:04:08 -0400 Subject: [PATCH 20/29] fix(setup): load native Codex and Cursor hooks --- .codex-plugin/plugin.json | 3 ++- AGENTS.md | 10 +++++++--- README.md | 35 +++++++++++++++++++++-------------- dist/installer.js | 6 +++++- dist/setup.js | 7 ++++++- docs/architecture.md | 23 +++++++++++++++-------- docs/install/codex.md | 34 +++++++++++++++++++--------------- docs/install/copilot.md | 8 ++++---- docs/install/cursor.md | 32 +++++++++++++++++++------------- docs/install/kiro.md | 4 +++- docs/install/vscode.md | 8 ++++---- docs/troubleshooting.md | 10 ++++++---- package.json | 3 +-- src/AGENTS.md | 2 ++ src/installer.ts | 6 +++++- src/setup.ts | 7 ++++++- tests/AGENTS.md | 3 +++ tests/distribution.test.ts | 16 +++++++++++----- tests/installer.test.ts | 26 +++++++++++++++++++++++--- tests/plugin-manifest.test.ts | 2 +- tests/setup.test.ts | 34 +++++++++++----------------------- 21 files changed, 174 insertions(+), 105 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 3402d44..006b858 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -17,5 +17,6 @@ "capabilities": [], "defaultPrompt": "Recall relevant work from earlier coding sessions." }, - "mcpServers": "./.mcp.json" + "mcpServers": "./.mcp.json", + "hooks": "./hooks/codex.json" } diff --git a/AGENTS.md b/AGENTS.md index 9ee504a..91a698d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,9 +41,13 @@ docs/ architecture and troubleshooting `shared-retained` use exit `2`; command errors use exit `1`. - Copilot and VS Code share the native plugin store. Single-harness removal must retain that plugin and leave any legacy fallback hook file unchanged. -- The root manifest declares Agent Plugins 1.0, whose portable components do - not include hooks. Copilot/VS Code setup must install the generated native - package whose hook and MCP commands use the absolute Agent LCM executable. +- The repository root manifest declares Agent Plugins 1.0 for Kiro and portable + skills/MCP clients. The npm artifact must omit it so Codex and Cursor select + their native hook manifests. Copilot/VS Code setup installs the generated + native package whose hook and MCP commands use the absolute Agent LCM + executable. +- Successful native Codex setup must not create `~/.codex/hooks.json`; it may + remove only exact Agent LCM fallback entries from an existing file. - Setup-file mutation runs through the directory-anchored helper. Do not replace it with path checks followed by later path-based writes. - Validate existing setup JSON before native work. Preserve unrelated and diff --git a/README.md b/README.md index 510ad19..0131515 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,9 @@ you never need to find or reference a harness cache path. ## Install in each harness -Agent Plugins 1.0 defines the package, not one shared installer. Run the setup -command for each harness you use, then follow its guide for native installation, -trust, and removal: +Each harness selects its own native package format. Run the setup command for +each harness you use, then follow its guide for native installation, trust, and +removal: | Harness | Setup command | Guide | | --- | --- | --- | @@ -91,13 +91,16 @@ The guides follow the current [Codex plugin](https://github.com/openai/codex/blo supported native command, it reports the guide and uses the manual hook path when that harness needs one. -Compatible clients discover the same portable components: +The repository root remains an Agent Plugins package for Kiro and other +skills/MCP-only clients: - `skills/lcm-recall/SKILL.md` - the `agent-lcm` stdio server in `mcp.json` -Codex and Cursor compatibility manifests are included for their native plugin -layouts. If a client cannot install the plugin, add this stdio MCP server: +The published npm package omits that root manifest so Codex and Cursor select +their native compatibility manifests, which include hooks. Copilot and VS Code +use the native package generated by setup. If a client cannot install the +plugin, add this stdio MCP server: ```json { @@ -135,7 +138,7 @@ files containing the absolute Agent LCM command when manual wiring is needed. If a target file already exists and needs changes, setup first saves a timestamped `-pre-agent-lcm-` backup beside it. -Setup-managed and legacy user hook locations are: +Legacy or setup-managed user hook locations are: | Harness | Hook file | | --- | --- | @@ -145,8 +148,10 @@ Setup-managed and legacy user hook locations are: | GitHub Copilot | `~/.copilot/hooks/agent-lcm.json` | | Kiro | `~/.kiro/hooks/agent-lcm.json` | -Cursor, Copilot, and VS Code native plugins carry their own hooks. Setup does -not add a second user-level copy after native installation. +Codex, Cursor, Copilot, and VS Code native plugins carry their own hooks. Setup +does not add a second user-level copy after native installation. The Codex path +above exists only for older fallback entries, which setup removes after native +installation succeeds. Check setup-managed and legacy hook files, then run the broader doctor checks: @@ -164,9 +169,10 @@ report fields. Native lifecycle support is limited to the commands that each client documents: -- Codex probes with `codex plugin list`, adds the installed Agent LCM package - directory as a local marketplace, then runs `codex plugin add - agent-lcm@agent-lcm`. Removal runs `codex plugin remove agent-lcm@agent-lcm`. +- Codex probes with `codex plugin list`, adds the installed npm package as a + local marketplace, then runs `codex plugin add agent-lcm@agent-lcm`. The npm + artifact omits the portable root manifest so Codex loads the native manifest, + including hooks. Removal runs `codex plugin remove agent-lcm@agent-lcm`. - GitHub Copilot CLI and VS Code share the Copilot plugin store. Setup probes with `copilot plugin list`, builds a private native package with absolute Agent LCM hook and MCP commands, and installs it with `copilot plugin @@ -175,8 +181,9 @@ Native lifecycle support is limited to the commands that each client documents: documented Copilot uninstall command only after reviewing both clients. - Cursor and Kiro are probed with `cursor-agent --version` and `kiro-cli --version`. Neither CLI documents a noninteractive plugin install or removal - command, so their Marketplace or Powers steps remain manual while Agent LCM - manages only its legacy capture hooks. + command. Cursor must load the native npm package, not the repository-root + Agent Plugin, to get hooks. Kiro uses the repository-root Power and the + separate Kiro hook file. Setup validates an existing hook file before invoking a native CLI, preserves unrelated entries, and changes only exact Agent LCM-owned registrations. It diff --git a/dist/installer.js b/dist/installer.js index 046dbf5..0155d16 100644 --- a/dist/installer.js +++ b/dist/installer.js @@ -9,6 +9,7 @@ export function readStatus(options = {}) { const configText = readOptional(configPath); const hooksText = readOptional(hooksPath); const pluginManifestText = readOptional(path.join(root, ".codex-plugin", "plugin.json")); + const portableManifestText = readOptional(path.join(root, "plugin.json")); const pluginManifestAvailable = pluginManifestText !== undefined; const mcpManifestAvailable = fs.existsSync(path.join(root, ".mcp.json")); const hookManifestAvailable = fs.existsSync(path.join(root, "hooks", "codex.json")); @@ -19,6 +20,8 @@ export function readStatus(options = {}) { const manualMcpConfigured = configText !== undefined && /mcp_servers\.(?:"agent-lcm"|agent-lcm)|command\s*=\s*".*agent-lcm/u.test(configText); const manualHooksConfigured = hooksText !== undefined && hooksText.includes("agent-lcm"); const pluginOwnedWiringAvailable = pluginConfigured && pluginManifestAvailable; + const nativeHooksSelected = portableManifestText === undefined + || !/"\$schema"\s*:\s*"https:\/\/agent-plugins\.org\/schemas\//u.test(portableManifestText); return { codex_home: home, config_exists: configText !== undefined, @@ -33,7 +36,8 @@ export function readStatus(options = {}) { manual_mcp_configured: manualMcpConfigured, manual_hooks_configured: manualHooksConfigured, mcp_configured: manualMcpConfigured || (pluginOwnedWiringAvailable && pluginDeclaresMcp && mcpManifestAvailable), - hooks_configured: manualHooksConfigured, + hooks_configured: manualHooksConfigured + || (pluginOwnedWiringAvailable && nativeHooksSelected && pluginDeclaresHooks && hookManifestAvailable), recall_skill_available: fs.existsSync(path.join(root, "skills", "lcm-recall", "SKILL.md")), }; } diff --git a/dist/setup.js b/dist/setup.js index 828bbfc..ca91b47 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -44,9 +44,14 @@ export function setupStatus(options = {}) { })); } function updateHooks(harness, nativeStatus, target, command, targetExists) { - if (harness === "codex" || harness === "kiro") { + if (harness === "kiro") { return mutateSetupConfiguration(target, (existing) => mergeSetupHooks(existing, harness, command, target)); } + if (harness === "codex" && nativeStatus === "native-complete" && targetExists) { + return mutateSetupConfiguration(target, (existing) => existing === undefined + ? undefined + : removeSetupHooks(existing, harness, target)); + } if ((harness === "copilot" || harness === "vscode") && nativeStatus === "native-complete" && targetExists) { return mutateSetupConfiguration(target, (existing) => removeSharedSetupHooks(existing ?? {}, harness, target)); } diff --git a/docs/architecture.md b/docs/architecture.md index a6afd06..75c00f3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,10 +2,11 @@ ## Package surfaces -The repository root is an Agent Plugins 1.0 package: +The repository contains one shared implementation with client-specific package +surfaces: ```text -plugin.json portable package manifest +plugin.json Kiro and portable skills/MCP manifest mcp.json portable MCP server configuration skills/lcm-recall/ portable recall skill hooks.json shared lower-camel hook shape for client adapters @@ -16,9 +17,11 @@ bin/agent-lcm source and npm CLI entry point dist/ generated npm runtime ``` -Agent Plugins 1.0 standardizes skills and MCP servers. Hooks remain -client-specific, so `agent-lcm setup ` installs the matching capture -configuration when a client does not load a bundled hook manifest. +Agent Plugins 1.0 standardizes skills and MCP servers, not hooks. The npm +artifact therefore omits the root `plugin.json`: Codex and Cursor then select +their native manifests and load bundled hooks. The GitHub repository keeps the +root manifest for Kiro Powers. Copilot and VS Code receive a generated native +package with absolute local commands. The npm package and each native plugin copy can start the same per-user daemon. Daemon protocol compatibility, not package release version, decides whether a @@ -30,14 +33,18 @@ incompatible protocol. `agent-lcm setup ` probes and, where supported, runs the client's native plugin commands before updating legacy capture hooks. Codex uses `codex plugin -list`, adds the installed package directory as a local marketplace, then runs -`codex plugin add agent-lcm@agent-lcm`. Copilot CLI and VS Code use the shared +list`, adds the installed npm package as a local marketplace, then runs `codex +plugin add agent-lcm@agent-lcm`. Its native manifest includes hooks; successful +setup removes only exact older Agent LCM entries from `~/.codex/hooks.json`. +Copilot CLI and VS Code use the shared Copilot store. Setup generates a private Copilot-format package whose hooks and MCP config contain the absolute installed Agent LCM command, then installs it after `copilot plugin list` succeeds. Cursor and Kiro run version-only probes for `cursor-agent` and `kiro-cli`. Their Marketplace or Powers steps remain manual, so their native result is -`manual-required`. +`manual-required`. Cursor loads `.cursor-plugin/plugin.json` from the npm +package; Kiro loads the repository-root Agent Plugin and uses a separate Kiro +hook file because hooks are not portable. `agent-lcm remove ` removes only exact Agent LCM-owned legacy hooks. Codex runs `codex plugin remove agent-lcm@agent-lcm`. Copilot and VS Code share a diff --git a/docs/install/codex.md b/docs/install/codex.md index 8169bc3..ad3d767 100644 --- a/docs/install/codex.md +++ b/docs/install/codex.md @@ -9,17 +9,17 @@ agent-lcm setup codex ``` This runs the Codex native lifecycle against the installed Agent LCM package -directory when the CLI is available and its plugin probe succeeds. Codex does -not accept hooks in its plugin manifest, so setup -also installs or repairs the capture hooks at `~/.codex/hooks.json`. If the native -probe is unavailable, setup reports `manual-required` with this guide while -keeping that hook path available for capture. +directory when the CLI is available and its plugin probe succeeds. The Codex +manifest includes the recall skill, MCP server, and capture hooks. After native +installation succeeds, setup removes only exact Agent LCM entries left by older +versions in `~/.codex/hooks.json` so capture does not run twice. It does not +create that user hook file. If the native probe is unavailable, setup reports +`manual-required` and leaves any existing fallback untouched. ## Native install and inspection -Use the documented Codex plugin flow with the local directory that contains -Agent LCM's `plugin.json`. For a global npm install, `npm root --global` prints -the `` part of this path: +Use the documented Codex plugin flow with the installed npm package directory. +`npm root --global` prints the `` part of this path: ```sh codex plugin marketplace add /@team-volt/agent-lcm @@ -27,9 +27,12 @@ codex plugin add agent-lcm@agent-lcm codex plugin list ``` -Do not type the angle-bracket placeholder as written. If you run Agent LCM from -a source checkout, use that checkout's root instead. The first two commands add -the local package and the last command lists installed plugins. See the [Codex plugin installation reference](https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md). +Do not type the angle-bracket placeholder as written. The published npm package +omits the repository's portable root manifest so Codex selects +`.codex-plugin/plugin.json`, including its native hooks. Do not substitute a +source checkout: its root `plugin.json` is the Kiro/Agent Plugins package, which +Codex treats as skills and MCP only. The first two commands add the local npm +package and the last command lists installed plugins. See the [Codex plugin installation reference](https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md). After installation, start a new Codex thread so it picks up the plugin. If Codex asks you to trust plugin-owned commands, review the commands and approve @@ -37,18 +40,19 @@ them only if you expect them. ## Remove Agent LCM -The Agent LCM command removes the native plugin and only Agent LCM's entries -from `~/.codex/hooks.json`: +The Agent LCM command removes the native plugin and only exact legacy Agent LCM +entries from `~/.codex/hooks.json`, if that file exists: ```sh agent-lcm remove codex ``` -If the Codex CLI is unavailable, the command removes the hooks, reports +If the Codex CLI is unavailable, the command removes only those legacy hooks, reports `manual-required`, and links back here. Finish the native removal with: ```sh codex plugin remove agent-lcm@agent-lcm ``` -Check the result with `agent-lcm setup status` and `agent-lcm doctor --json`. +Check the native result with `codex plugin list` and `agent-lcm doctor --json`. +`agent-lcm setup status` reports only legacy hook-file state. diff --git a/docs/install/copilot.md b/docs/install/copilot.md index b5ffad1..3688c3b 100644 --- a/docs/install/copilot.md +++ b/docs/install/copilot.md @@ -28,10 +28,10 @@ agent-lcm setup copilot copilot plugin list ``` -Do not use the Agent LCM package root as a substitute for setup. Its manifest -declares Agent Plugins 1.0, where skills and MCP are portable but hooks are not. -Setup writes the client-specific hooks and the absolute installed command into -a Copilot-format package. The list command shows the installed plugin. See the +Do not use the repository root as a substitute for setup. Its Agent Plugins +manifest contains portable skills and MCP but no hooks. Setup writes the +client-specific hooks and the absolute installed command into a Copilot-format +package. The list command shows the installed plugin. See the [Copilot CLI plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference) for the supported command set. diff --git a/docs/install/cursor.md b/docs/install/cursor.md index 109926e..bb7213f 100644 --- a/docs/install/cursor.md +++ b/docs/install/cursor.md @@ -9,32 +9,38 @@ agent-lcm setup cursor ``` Cursor plugins package their hooks. Setup probes `cursor-agent --version`, then -validates and preserves any legacy -capture file at `~/.cursor/hooks.json`, but it does not add another copy of the -same hooks. Cursor has no stable native install or remove CLI in the supported -documentation, so setup reports `manual-required` for the native step. +validates and preserves any legacy capture file at `~/.cursor/hooks.json`, but +it does not add another copy. Cursor has no documented noninteractive plugin +install or remove command, so setup reports `manual-required` for the native +step. ## Native install and inspection Open Cursor's Customize page and install Agent LCM if it is available in a -marketplace you trust. Until it is listed, Cursor documents this local plugin -path for development installs: +marketplace you trust. Until it is listed, Cursor documents loading a plugin +from `~/.cursor/plugins/local`. First run `npm root --global` and confirm the +Agent LCM package exists below the printed directory. Then, only if the target +does not already exist, link it on macOS or Linux: ```sh mkdir -p ~/.cursor/plugins/local -git clone https://github.com/Team-Volt/agent-lcm.git ~/.cursor/plugins/local/agent-lcm +ln -s /@team-volt/agent-lcm ~/.cursor/plugins/local/agent-lcm ``` -On Windows, use `%USERPROFILE%\.cursor\plugins\local\agent-lcm` as the target. -If the target already exists, inspect it instead of replacing it. Run -`Developer: Reload Window`, then verify Agent LCM under Customize. Cursor's -[plugin guide](https://cursor.com/docs/plugins) documents the local path and -reload step. +Do not type the angle-bracket placeholder as written. On Windows, copy the +installed npm package into +`%USERPROFILE%\.cursor\plugins\local\agent-lcm` only after confirming the +target does not exist. Do not clone the repository root for this step: its +portable manifest contains skills and MCP only, so Cursor would not load the +native hook manifest. Run `Developer: Reload Window`, then verify Agent LCM +under Customize. Cursor's [plugin guide](https://cursor.com/docs/plugins) +documents the local path, both supported manifest formats, and the reload step. If Cursor shows a trust prompt, review the plugin source before accepting it. The Marketplace documentation does not establish a required restart. If the plugin or hooks look stale, close and reopen Cursor as troubleshooting, then -check `agent-lcm setup status`. +check the plugin in Customize. `agent-lcm setup status` reports only legacy +hook-file state. If an older Agent LCM version already configured `~/.cursor/hooks.json`, run `agent-lcm remove cursor` immediately before installing the native plugin. That diff --git a/docs/install/kiro.md b/docs/install/kiro.md index d86bfdc..02ca49d 100644 --- a/docs/install/kiro.md +++ b/docs/install/kiro.md @@ -17,7 +17,9 @@ step, so setup reports `manual-required` for that step. Open [Kiro Powers](https://kiro.dev/docs/powers/) in Kiro or on kiro.dev. Use the Powers UI to browse the marketplace or install from a GitHub repository, enter `https://github.com/Team-Volt/agent-lcm`, and click Install. The same -Powers UI shows the installed Power state. +Powers UI shows the installed Power state. For Kiro, the repository root is +intentional: its `plugin.json`, `skills/`, and `mcp.json` form the Power. Hooks +remain in Kiro's separate hook file because Agent Plugins does not define them. The official Powers guide does not define a command-line install or a required restart. If the Power or hooks look stale, close and reopen Kiro as diff --git a/docs/install/vscode.md b/docs/install/vscode.md index 86c6e75..930ce48 100644 --- a/docs/install/vscode.md +++ b/docs/install/vscode.md @@ -29,10 +29,10 @@ agent-lcm setup vscode copilot plugin list ``` -Do not use a package-root install from the VS Code command palette as a -substitute for setup. VS Code detects that manifest as Agent Plugins 1.0, whose -portable components do not include hooks. Setup creates the Copilot-format -package that includes capture hooks and absolute local commands. You can +Do not use the repository root from the VS Code command palette as a substitute +for setup. VS Code detects its Agent Plugins manifest, whose portable +components do not include hooks. Setup creates the Copilot-format package that +includes capture hooks and absolute local commands. You can inspect, enable, disable, or uninstall it in VS Code's Agent Plugins view. See the [VS Code agent plugin guide](https://code.visualstudio.com/docs/agent-customization/agent-plugins). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index db0b391..3e5476a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -46,9 +46,9 @@ home, configure it explicitly and pass that directory: agent-lcm setup codex --home /path/to/codex-home ``` -Codex loads setup-managed hooks from `~/.codex/hooks.json`; Kiro uses -`~/.kiro/hooks/agent-lcm.json`. Cursor, VS Code, and GitHub Copilot native -plugins carry their own hooks. Cursor setup preserves an older +Codex loads hooks from its native plugin; `~/.codex/hooks.json` is only an older +fallback. Kiro uses `~/.kiro/hooks/agent-lcm.json`. Cursor, VS Code, and GitHub +Copilot native plugins carry their own hooks. Cursor setup preserves an older `~/.cursor/hooks.json` fallback until native installation is complete. A successful Copilot or VS Code native setup removes only exact older Agent LCM entries from the shared fallback so capture does not run twice. Setup refuses @@ -93,7 +93,9 @@ suppressed-stderr marker so a client cannot leak secrets into logs. Use `--json` for stable automation fields. Codex setup probes `codex plugin list`, then runs the marketplace-add and -plugin-add commands. Removal runs `codex plugin remove agent-lcm@agent-lcm`. +plugin-add commands against the installed npm package. That package omits the +portable root manifest so Codex selects its native hook manifest. Removal runs +`codex plugin remove agent-lcm@agent-lcm`. Copilot and VS Code probe and install through `copilot plugin`; they share the same native plugin store, so either `agent-lcm remove copilot` or `agent-lcm remove vscode` is intentionally conservative and does not uninstall the shared diff --git a/package.json b/package.json index 6cae759..637e26d 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,7 @@ "skills/", "hooks.json", "mcp.cursor.json", - "mcp.json", - "plugin.json" + "mcp.json" ], "scripts": { "compile": "tsc --project tsconfig.build.json", diff --git a/src/AGENTS.md b/src/AGENTS.md index 15618e1..512065b 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -44,6 +44,8 @@ ## Harness setup safety - Probe and invoke only documented commands: Codex uses `codex plugin`; Copilot and VS Code use the shared `copilot plugin` store; Cursor and Kiro use version-only probes and keep plugin changes manual. +- Codex and Cursor native hooks depend on the packed npm artifact omitting the repository-root Agent Plugins manifest. Never add `plugin.json` back to `package.json#files` without redesigning native package selection. +- Native Codex setup removes exact legacy fallback hooks after install and never creates a user hook file. - Generate the Copilot-format package at setup time so hooks and MCP use the validated absolute Agent LCM command. Keep its source basename `agent-lcm` so repeat direct installs update one native plugin. - Validate existing setup JSON before starting a native process. Match owned hooks by harness, event, and command shape; preserve unrelated and near-matching entries. - Keep setup-file reads, backups, locks, and publication inside the helper process anchored to the validated target directory. A later path identity check does not make a path-based write safe. Refuse symlinked directory components, lock paths, targets, and non-regular files. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. diff --git a/src/installer.ts b/src/installer.ts index e4bd0f3..f2178d1 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -16,6 +16,7 @@ export function readStatus(options: InstallerOptions = {}) { const configText = readOptional(configPath); const hooksText = readOptional(hooksPath); const pluginManifestText = readOptional(path.join(root, ".codex-plugin", "plugin.json")); + const portableManifestText = readOptional(path.join(root, "plugin.json")); const pluginManifestAvailable = pluginManifestText !== undefined; const mcpManifestAvailable = fs.existsSync(path.join(root, ".mcp.json")); const hookManifestAvailable = fs.existsSync(path.join(root, "hooks", "codex.json")); @@ -27,6 +28,8 @@ export function readStatus(options: InstallerOptions = {}) { configText !== undefined && /mcp_servers\.(?:"agent-lcm"|agent-lcm)|command\s*=\s*".*agent-lcm/u.test(configText); const manualHooksConfigured = hooksText !== undefined && hooksText.includes("agent-lcm"); const pluginOwnedWiringAvailable = pluginConfigured && pluginManifestAvailable; + const nativeHooksSelected = portableManifestText === undefined + || !/"\$schema"\s*:\s*"https:\/\/agent-plugins\.org\/schemas\//u.test(portableManifestText); return { codex_home: home, config_exists: configText !== undefined, @@ -41,7 +44,8 @@ export function readStatus(options: InstallerOptions = {}) { manual_mcp_configured: manualMcpConfigured, manual_hooks_configured: manualHooksConfigured, mcp_configured: manualMcpConfigured || (pluginOwnedWiringAvailable && pluginDeclaresMcp && mcpManifestAvailable), - hooks_configured: manualHooksConfigured, + hooks_configured: manualHooksConfigured + || (pluginOwnedWiringAvailable && nativeHooksSelected && pluginDeclaresHooks && hookManifestAvailable), recall_skill_available: fs.existsSync(path.join(root, "skills", "lcm-recall", "SKILL.md")), }; } diff --git a/src/setup.ts b/src/setup.ts index d4d0abf..53c9ac9 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -82,9 +82,14 @@ function updateHooks( command: string, targetExists: boolean, ): boolean { - if (harness === "codex" || harness === "kiro") { + if (harness === "kiro") { return mutateSetupConfiguration(target, (existing) => mergeSetupHooks(existing, harness, command, target)); } + if (harness === "codex" && nativeStatus === "native-complete" && targetExists) { + return mutateSetupConfiguration(target, (existing) => existing === undefined + ? undefined + : removeSetupHooks(existing, harness, target)); + } if ((harness === "copilot" || harness === "vscode") && nativeStatus === "native-complete" && targetExists) { return mutateSetupConfiguration(target, (existing) => removeSharedSetupHooks(existing ?? {}, harness, target)); } diff --git a/tests/AGENTS.md b/tests/AGENTS.md index d04864a..0d329cc 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -42,6 +42,9 @@ framework, fixture library, or custom runner. - Fake `codex`, `copilot`, `cursor-agent`, and `kiro-cli` executables must record argv and fail on demand; use them to prove the exact documented command vectors without touching a user's installed clients. +- Distribution tests must prove the npm artifact omits root `plugin.json`, keeps + both native manifests, and runs bundled Codex hooks without creating a user + hook file. - Copilot setup tests must inspect the generated package during the fake install, assert its stable `agent-lcm` source basename and absolute hook/MCP command, and then confirm the temporary source was removed. diff --git a/tests/distribution.test.ts b/tests/distribution.test.ts index bc19a60..84a08d6 100644 --- a/tests/distribution.test.ts +++ b/tests/distribution.test.ts @@ -33,7 +33,6 @@ test("the npm package contains the complete plugin and no development files", (t "mcp.cursor.json", "mcp.json", "package.json", - "plugin.json", "skills/lcm-recall/SKILL.md", "dist/cli.js", "dist/copilot-plugin.js", @@ -42,6 +41,7 @@ test("the npm package contains the complete plugin and no development files", (t "dist/setup-hook-status.js", "dist/setup-hooks.js", ]) assert.ok(names.includes(required), `missing ${required}`); + assert.equal(names.includes("plugin.json"), false, "packed native clients must not select the portable manifest"); assert.equal(names.some((name) => /^(?:\.github|docs|scripts|tests)\//u.test(name)), false); assert.equal(fs.existsSync(path.join(root, filename)), true); @@ -181,6 +181,9 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", "@team-volt", "agent-lcm", ); + assert.equal(fs.existsSync(path.join(packageRoot, "plugin.json")), false); + assert.equal(JSON.parse(fs.readFileSync(path.join(packageRoot, ".codex-plugin/plugin.json"), "utf8")).hooks, "./hooks/codex.json"); + assert.equal(JSON.parse(fs.readFileSync(path.join(packageRoot, ".cursor-plugin/plugin.json"), "utf8")).hooks, "./hooks/cursor.json"); const mcpConfiguration = JSON.parse(fs.readFileSync(path.join(packageRoot, "mcp.json"), "utf8")) .mcpServers["agent-lcm"] as { command: string; args: string[] }; const mcp = spawnSync(mcpConfiguration.command, mcpConfiguration.args.map((arg) => arg.replaceAll("${PLUGIN_ROOT}", packageRoot)), { @@ -215,8 +218,10 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", assert.equal(fs.existsSync(path.join(home, ".cursor")), false); assert.equal(fs.existsSync(path.join(home, ".copilot")), false); assert.equal(fs.existsSync(path.join(home, ".kiro")), false); - const codexHooks = JSON.parse(fs.readFileSync(path.join(home, ".codex/hooks.json"), "utf8")); - const capture = spawnSync(codexHooks.hooks.UserPromptSubmit[0].hooks[0].command, { + assert.equal(fs.existsSync(path.join(home, ".codex/hooks.json")), false); + const codexHooks = JSON.parse(fs.readFileSync(path.join(packageRoot, "hooks/codex.json"), "utf8")); + const captureCommand = codexHooks.hooks.UserPromptSubmit[0].hooks[0].command.replaceAll("${PLUGIN_ROOT}", packageRoot); + const capture = spawnSync(captureCommand, { cwd: root, encoding: "utf8", env, @@ -225,7 +230,8 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", timeout: 15_000, }); assert.equal(capture.status, 0, capture.stderr); - const postCompact = spawnSync(codexHooks.hooks.PostCompact[0].hooks[0].command, { + const postCompactCommand = codexHooks.hooks.PostCompact[0].hooks[0].command.replaceAll("${PLUGIN_ROOT}", packageRoot); + const postCompact = spawnSync(postCompactCommand, { cwd: root, encoding: "utf8", env, @@ -242,7 +248,7 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", assert.equal(stopped.status, 0, stopped.stderr); const removed = runInstalled(["remove", "codex", "--json"]); assert.equal(removed.status, 0, removed.stderr); - assert.equal(JSON.parse(removed.stdout).hooks.changed, true); + assert.equal(JSON.parse(removed.stdout).hooks.changed, false); assert.deepEqual(fs.readFileSync(fakeLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)), [ ["plugin", "list"], ["plugin", "marketplace", "add", fs.realpathSync(packageRoot)], diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 675f34f..3d4aec2 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import test from "node:test"; +import { readStatus } from "../src/installer.ts"; import { assertCliOk, runCli, tempHome } from "./helpers.ts"; test("status reads Codex home and reports absent wiring", () => { @@ -21,7 +22,7 @@ test("status reads Codex home and reports absent wiring", () => { assert.equal(status.plugin_configured, false); assert.equal(status.plugin_manifest_available, true); assert.equal(status.plugin_declares_mcp, true); - assert.equal(status.plugin_declares_hooks, false); + assert.equal(status.plugin_declares_hooks, true); assert.equal(status.mcp_manifest_available, true); assert.equal(status.hook_manifest_available, true); assert.equal(status.manual_mcp_configured, false); @@ -31,7 +32,7 @@ test("status reads Codex home and reports absent wiring", () => { assert.equal(status.recall_skill_available, true); }); -test("status recognizes Codex-native plugin wiring", () => { +test("status does not claim native hooks when the portable manifest wins", () => { const codexHome = tempHome(); fs.writeFileSync( path.join(codexHome, "config.toml"), @@ -59,7 +60,7 @@ test("status recognizes Codex-native plugin wiring", () => { assert.equal(status.plugin_configured, true); assert.equal(status.plugin_manifest_available, true); assert.equal(status.plugin_declares_mcp, true); - assert.equal(status.plugin_declares_hooks, false); + assert.equal(status.plugin_declares_hooks, true); assert.equal(status.mcp_manifest_available, true); assert.equal(status.hook_manifest_available, true); assert.equal(status.manual_mcp_configured, false); @@ -69,6 +70,25 @@ test("status recognizes Codex-native plugin wiring", () => { assert.equal(status.recall_skill_available, true); }); +test("status recognizes packed Codex-native plugin wiring", () => { + const codexHome = tempHome(); + const root = tempHome(); + fs.mkdirSync(path.join(root, ".codex-plugin")); + fs.mkdirSync(path.join(root, "hooks")); + fs.mkdirSync(path.join(root, "skills", "lcm-recall"), { recursive: true }); + fs.copyFileSync(".codex-plugin/plugin.json", path.join(root, ".codex-plugin", "plugin.json")); + fs.copyFileSync(".mcp.json", path.join(root, ".mcp.json")); + fs.copyFileSync("hooks/codex.json", path.join(root, "hooks", "codex.json")); + fs.copyFileSync("skills/lcm-recall/SKILL.md", path.join(root, "skills", "lcm-recall", "SKILL.md")); + fs.writeFileSync(path.join(codexHome, "config.toml"), '[plugins."agent-lcm@agent-lcm"]\nenabled = true\n'); + + const status = readStatus({ codexHome, root }); + + assert.equal(status.plugin_configured, true); + assert.equal(status.mcp_configured, true); + assert.equal(status.hooks_configured, true); +}); + test("status treats an explicitly disabled native plugin as disabled", () => { const codexHome = tempHome(); fs.writeFileSync( diff --git a/tests/plugin-manifest.test.ts b/tests/plugin-manifest.test.ts index 4d43ebe..8e723a9 100644 --- a/tests/plugin-manifest.test.ts +++ b/tests/plugin-manifest.test.ts @@ -20,7 +20,7 @@ test("root is an Agent Plugins 1.0 package", () => { test("client hook manifests invoke explicit or detected harness capture", () => { const codex = readJson(".codex-plugin/plugin.json"); - assert.equal(codex.hooks, undefined); + assert.equal(codex.hooks, "./hooks/codex.json"); assert.equal(codex.mcpServers, "./.mcp.json"); assert.equal(codex.homepage, "https://github.com/Team-Volt/agent-lcm"); assert.deepEqual(codex.interface, { diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 91d04f5..8658c52 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -141,7 +141,7 @@ test("setup validates an existing hook schema before starting the native CLI", ( assert.equal(fs.existsSync(fake.log), false); }); -test("Codex setup runs native setup and still installs user hooks", (t) => { +test("Codex setup uses native plugin hooks without creating user hooks", (t) => { // Given: a capable native Codex CLI and an empty Codex home. const fake = fakeSetupCli(t, "codex"); const clientHome = tempHome("agent-lcm-codex-native-"); @@ -153,13 +153,13 @@ test("Codex setup runs native setup and still installs user hooks", (t) => { env: fake.env, }); - // Then: the stable report records both native and user-hook completion. + // Then: the native plugin owns hooks and no duplicate user hook file is created. assert.deepEqual(report, { harness: "codex", action: "setup", status: "complete", nativeCli: "codex", - hooks: { path: path.join(clientHome, "hooks.json"), changed: true }, + hooks: { path: path.join(clientHome, "hooks.json"), changed: false }, guide: `${GUIDE_ROOT}/codex.md`, }); assert.deepEqual(readSetupCalls(fake.log), [ @@ -167,7 +167,7 @@ test("Codex setup runs native setup and still installs user hooks", (t) => { ["plugin", "marketplace", "add", PACKAGE_ROOT], ["plugin", "add", "agent-lcm@agent-lcm"], ]); - assert.equal(fs.existsSync(path.join(clientHome, "hooks.json")), true); + assert.equal(fs.existsSync(path.join(clientHome, "hooks.json")), false); }); test("successful Copilot setup removes only legacy shared Agent LCM hooks", (t) => { @@ -395,7 +395,7 @@ test("native shared setup removes older Agent LCM registrations without touching }]); }); -test("Codex setup replaces its old Agent LCM commands and preserves unrelated hooks", (t) => { +test("Codex setup removes old Agent LCM commands and preserves unrelated hooks", (t) => { const clientHome = tempHome("agent-lcm-codex-legacy-"); const fake = fakeSetupCli(t, "codex"); const setupPath = path.join(clientHome, "hooks.json"); @@ -433,11 +433,6 @@ test("Codex setup replaces its old Agent LCM commands and preserves unrelated ho assert.equal(second.hooks.changed, false); assert.equal(configuration.owner, "user"); assert.deepEqual(configuration.hooks.SessionStart, [{ matcher: "*", hooks: [ - { - type: "command", - command: "node \"/new/bin/agent-lcm\" capture --harness codex SessionStart", - timeout: 15, - }, { type: "command", command: "other-hook", timeout: 30 }, ] }]); assert.deepEqual(configuration.hooks.CustomEvent, [{ hooks: [{ @@ -446,7 +441,6 @@ test("Codex setup replaces its old Agent LCM commands and preserves unrelated ho owner: "user", }] }]); assert.deepEqual(configuration.hooks.PostCompact, [{ matcher: "*", hooks: [ - { type: "command", command: 'node "/new/bin/agent-lcm" hook PostCompact', timeout: 15 }, { type: "command", command: "other-post-compact-hook", timeout: 30 }, ] }]); assert.deepEqual(configuration.hooks.PreToolUse, [{ matcher: "Read", hooks: [ @@ -456,16 +450,10 @@ test("Codex setup replaces its old Agent LCM commands and preserves unrelated ho timeout: 30, metadata: { owner: "user" }, }, - ] }, { matcher: ".*", hooks: [ - { type: "command", command: 'node "/new/bin/agent-lcm" hook PreToolUse' }, - ] }]); - assert.deepEqual(configuration.hooks.PreCompact, [{ hooks: [ - { type: "command", command: 'node "/new/bin/agent-lcm" hook PreCompact' }, - ] }]); - assert.deepEqual(configuration.hooks.SubagentStop, [{ hooks: [ - { type: "command", command: 'node "/new/bin/agent-lcm" hook SubagentStop' }, ] }]); - assert.equal(setupStatus({ home: clientHome }).codex.hooksConfigured, true); + assert.equal(configuration.hooks.PreCompact, undefined); + assert.equal(configuration.hooks.SubagentStop, undefined); + assert.equal(setupStatus({ home: clientHome }).codex.hooksConfigured, false); const backups = fs.readdirSync(clientHome).filter((name) => name.startsWith("hooks-pre-agent-lcm-")); assert.equal(backups.length, 1); assert.equal(fs.readFileSync(path.join(clientHome, backups[0] ?? ""), "utf8"), original); @@ -500,7 +488,7 @@ test("setup all configures only harnesses already installed for the user", () => action: "setup", status: "manual-required", nativeCli: null, - hooks: { path: path.join(userHome, ".codex", "hooks.json"), changed: true }, + hooks: { path: path.join(userHome, ".codex", "hooks.json"), changed: false }, guide: `${GUIDE_ROOT}/codex.md`, }]); assert.equal(fs.existsSync(path.join(userHome, ".cursor")), false); @@ -544,7 +532,7 @@ test("CLI setup and remove use native Codex with an isolated explicit home", (t) action: "remove", status: "complete", nativeCli: "codex", - hooks: { path: path.join(home, "hooks.json"), changed: true }, + hooks: { path: path.join(home, "hooks.json"), changed: false }, guide: `${GUIDE_ROOT}/codex.md`, }); @@ -598,7 +586,7 @@ test("setup never overwrites an existing timestamped backup", (t) => { const clientHome = tempHome("agent-lcm-backup-collision-"); const fake = fakeSetupCli(t, "codex"); const setupPath = path.join(clientHome, "hooks.json"); - const original = '{"hooks":{}}\n'; + const original = '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node \\\"/old/bin/agent-lcm\\\" capture --harness codex SessionStart"}]}]}}\n'; const timestamp = "2026-08-07T12-34-56-789Z"; const firstBackup = path.join(clientHome, `hooks-pre-agent-lcm-${timestamp}.json`); const nextBackup = path.join(clientHome, `hooks-pre-agent-lcm-${timestamp}-1.json`); From e53e772cad749678f64a802fa1da319787d9c973 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 10:13:18 -0400 Subject: [PATCH 21/29] fix(setup): report concurrent config changes --- AGENTS.md | 3 +++ README.md | 10 +++++++--- dist/setup.js | 15 +++++++++++++-- docs/architecture.md | 5 +++++ docs/troubleshooting.md | 6 ++++++ src/AGENTS.md | 1 + src/setup.ts | 27 +++++++++++++++++++++++++-- tests/AGENTS.md | 2 ++ tests/setup.test.ts | 25 ++++++++++++++++++++++++- 9 files changed, 86 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 91a698d..f0c26a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,9 @@ docs/ architecture and troubleshooting near-matching hooks, reject symlinked or non-regular targets, and publish changes under an atomic `.lock` directory through a unique fsynced temporary file and rename. +- If a hook file changes during native work, preserve the new bytes and report + the completed native action as recoverable partial state; never hide it behind + a generic file error. See `src/AGENTS.md` and `tests/AGENTS.md` for more specific rules. diff --git a/README.md b/README.md index 0131515..4c9f1f2 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,10 @@ Setup and removal print one report per harness. Exit status `0` means the requested native work and hook work completed. Exit status `2` means a manual native step remains (`manual-required`) or a shared Copilot resource was deliberately retained (`shared-retained`). Exit status `1` means the command -failed; inspect stderr before retrying. Add `--json` when a script needs the -report fields. +failed; inspect stderr before retrying. If stderr says the native action +completed but the hook file could not be updated safely, repair that file and +rerun the same command. Agent LCM leaves the changed bytes untouched. Add +`--json` when a script needs the report fields. Native lifecycle support is limited to the commands that each client documents: @@ -194,7 +196,9 @@ up, or publishes through a unique `wx` temporary file, `fsync`, and rename. Symlinked directory components, lock paths, targets, and non-regular files are refused. Hook commands must be absolute paths without shell metacharacters. These rules make repeated setup and removal safe while avoiding a second user-level hook copy -after native installation. +after native installation. A native client and a hook file cannot share one +transaction. If another process changes the hook file during native work, +Agent LCM reports that recoverable partial state instead of overwriting it. Hooks start the daemon on demand. You can also manage it directly: diff --git a/dist/setup.js b/dist/setup.js index ca91b47..0e0dd32 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -12,7 +12,7 @@ export function setupHarness(harness, options) { validateSetupHooks(harness, existing, target); ensureSetupDirectory(path.dirname(target)); const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env, command } : { command }); - const changed = updateHooks(harness, native.status, target, command, existing !== undefined); + const changed = finishHookUpdate("setup", harness, native.status, target, () => (updateHooks(harness, native.status, target, command, existing !== undefined))); return { harness, action: "setup", @@ -27,7 +27,7 @@ export function removeHarness(harness, options = {}) { const existing = readSetupConfiguration(target); validateSetupHooks(harness, existing, target); const native = runHarnessLifecycle(harness, "remove", options.env ? { env: options.env } : {}); - const changed = removeHooks(harness, target, existing !== undefined); + const changed = finishHookUpdate("remove", harness, native.status, target, () => (removeHooks(harness, target, existing !== undefined))); return { harness, action: "remove", @@ -64,6 +64,17 @@ function removeHooks(harness, target, targetExists) { ? undefined : removeSetupHooks(existing, harness, target)); } +function finishHookUpdate(action, harness, nativeStatus, target, update) { + try { + return update(); + } + catch (error) { + if (nativeStatus !== "native-complete") + throw error; + throw new Error(`Native ${harness} ${action} completed, but Agent LCM could not safely update ${target}. ` + + `The file was not overwritten. Repair it, then rerun agent-lcm ${action} ${harness}.`, { cause: error }); + } +} function readConfigurationForStatus(target) { try { return readSetupConfiguration(target); diff --git a/docs/architecture.md b/docs/architecture.md index 75c00f3..b0f5dca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,6 +58,11 @@ configuration is validated before native work. Unrelated entries and near-matching commands remain untouched; only an exact harness/event/command registration is changed. +Native client state and local hook JSON cannot share one transaction. If the +hook file changes after preflight while a native command runs, Agent LCM keeps +the changed bytes and exits with an explicit partial-state error. Repair the +file, then rerun the same setup or remove command. + Setup files use an atomic `.lock` directory (bounded to ten seconds). A short-lived helper changes into the checked target directory and verifies its device and inode before it reads, backs up, or publishes. Publication writes a diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3e5476a..3d27cad 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -114,6 +114,12 @@ temporary publication; a predictable temporary symlink cannot redirect the write. Hook commands must use an absolute shell-safe binary path. If validation fails, the original file and native CLI invocation remain unchanged. +If another process changes the hook file after that preflight while a native +command is running, Agent LCM does not overwrite the new bytes. It exits `1` +and states that the native action completed but hook cleanup did not. Repair +the named file, then rerun the same `agent-lcm setup ` or +`agent-lcm remove ` command. + If setup times out on `.lock`, first confirm that no Agent LCM setup or remove process is running. You may then remove that empty lock directory and retry. Do not remove it while another process is active. diff --git a/src/AGENTS.md b/src/AGENTS.md index 512065b..a5145ae 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -48,6 +48,7 @@ - Native Codex setup removes exact legacy fallback hooks after install and never creates a user hook file. - Generate the Copilot-format package at setup time so hooks and MCP use the validated absolute Agent LCM command. Keep its source basename `agent-lcm` so repeat direct installs update one native plugin. - Validate existing setup JSON before starting a native process. Match owned hooks by harness, event, and command shape; preserve unrelated and near-matching entries. +- Native lifecycle state and hook JSON are not one transaction. If the file changes during native work, preserve its bytes and throw an explicit error that says the native action completed and tells the user to repair and rerun. - Keep setup-file reads, backups, locks, and publication inside the helper process anchored to the validated target directory. A later path identity check does not make a path-based write safe. Refuse symlinked directory components, lock paths, targets, and non-regular files. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. - Build native plugin sources only from the current local package, never from a mutable remote ref. Treat only `ENOENT` as an unavailable CLI; all other native probe or command failures must stop before hook mutation and must not echo client stderr. - Require an absolute hook binary path and reject shell metacharacters before writing configuration. diff --git a/src/setup.ts b/src/setup.ts index 53c9ac9..d2b3cb5 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -41,7 +41,9 @@ export function setupHarness(harness: CaptureHarness, options: SetupOptions): Se validateSetupHooks(harness, existing, target); ensureSetupDirectory(path.dirname(target)); const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env, command } : { command }); - const changed = updateHooks(harness, native.status, target, command, existing !== undefined); + const changed = finishHookUpdate("setup", harness, native.status, target, () => ( + updateHooks(harness, native.status, target, command, existing !== undefined) + )); return { harness, action: "setup", @@ -57,7 +59,9 @@ export function removeHarness(harness: CaptureHarness, options: RemoveOptions = const existing = readSetupConfiguration(target); validateSetupHooks(harness, existing, target); const native = runHarnessLifecycle(harness, "remove", options.env ? { env: options.env } : {}); - const changed = removeHooks(harness, target, existing !== undefined); + const changed = finishHookUpdate("remove", harness, native.status, target, () => ( + removeHooks(harness, target, existing !== undefined) + )); return { harness, action: "remove", @@ -103,6 +107,25 @@ function removeHooks(harness: CaptureHarness, target: string, targetExists: bool : removeSetupHooks(existing, harness, target)); } +function finishHookUpdate( + action: "setup" | "remove", + harness: CaptureHarness, + nativeStatus: "native-complete" | "manual-required" | "shared-retained", + target: string, + update: () => boolean, +): boolean { + try { + return update(); + } catch (error) { + if (nativeStatus !== "native-complete") throw error; + throw new Error( + `Native ${harness} ${action} completed, but Agent LCM could not safely update ${target}. ` + + `The file was not overwritten. Repair it, then rerun agent-lcm ${action} ${harness}.`, + { cause: error }, + ); + } +} + function readConfigurationForStatus(target: string): Record | undefined { try { return readSetupConfiguration(target); diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 0d329cc..4c3ae2e 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -70,6 +70,8 @@ framework, fixture library, or custom runner. - For setup-file symlink races, swap the path at the old path-check/read or chmod boundary and prove descriptor-bound I/O neither reads nor changes the victim. +- For lifecycle/config races, change the target from a fake native probe and + assert the native argv, untouched new bytes, and explicit repair message. - Prefer direct assertions on counts, IDs, paths, and JSON fields over broad snapshots; preserve security checks that prove secrets are absent. diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 8658c52..b0de3f4 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -170,6 +170,29 @@ test("Codex setup uses native plugin hooks without creating user hooks", (t) => assert.equal(fs.existsSync(path.join(clientHome, "hooks.json")), false); }); +test("Codex setup reports recoverable partial state when hooks change during native install", (t) => { + const fake = fakeSetupCli(t, "codex"); + const clientHome = tempHome("agent-lcm-codex-race-"); + const target = path.join(clientHome, "hooks.json"); + fs.writeFileSync(target, JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ + type: "command", + command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart', + }] }] } })); + + assert.throws(() => setupHarness("codex", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: { ...fake.env, AGENT_LCM_FAKE_MUTATE_TARGET: target }, + }), /Native codex setup completed.*Repair.*rerun agent-lcm setup codex/u); + + assert.equal(fs.readFileSync(target, "utf8"), "{invalid\n"); + assert.deepEqual(readSetupCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], + ["plugin", "add", "agent-lcm@agent-lcm"], + ]); +}); + test("successful Copilot setup removes only legacy shared Agent LCM hooks", (t) => { // Given: a capable Copilot CLI and a shared hook file with owned and unrelated hooks. const fake = fakeSetupCli(t, "copilot"); @@ -714,7 +737,7 @@ function fakeSetupCli( ): { readonly env: NodeJS.ProcessEnv; readonly log: string } { const bin = fs.mkdtempSync(path.join(tempHome("agent-lcm-setup-cli-parent-"), "bin-")); const log = path.join(bin, "calls.jsonl"); - const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");\n`; + const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst argv = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(argv) + "\\n");\nif (process.env.AGENT_LCM_FAKE_MUTATE_TARGET && JSON.stringify(argv) === JSON.stringify(["plugin", "list"])) fs.writeFileSync(process.env.AGENT_LCM_FAKE_MUTATE_TARGET, "{invalid\\n");\n`; fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); t.after(() => fs.rmSync(path.dirname(bin), { recursive: true, force: true })); return { From 0e5b7f1c8f06aa0f76f8831be5982635a3304e0c Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 10:21:06 -0400 Subject: [PATCH 22/29] fix(setup): preserve concurrent config writes --- README.md | 5 +++-- dist/setup-files.js | 26 +++++++++++++++++++---- dist/setup.js | 46 +++++++++++++++++++++------------------- src/setup-files.ts | 33 +++++++++++++++++++++++++++-- src/setup.ts | 51 ++++++++++++++++++++++++++------------------- tests/setup.test.ts | 12 +++++++---- 6 files changed, 119 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 4c9f1f2..026e020 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,9 @@ native step remains (`manual-required`) or a shared Copilot resource was deliberately retained (`shared-retained`). Exit status `1` means the command failed; inspect stderr before retrying. If stderr says the native action completed but the hook file could not be updated safely, repair that file and -rerun the same command. Agent LCM leaves the changed bytes untouched. Add -`--json` when a script needs the report fields. +rerun the same command. A reported concurrent change is left untouched; for +other file errors, inspect the file because publication may have completed. +Add `--json` when a script needs the report fields. Native lifecycle support is limited to the commands that each client documents: diff --git a/dist/setup-files.js b/dist/setup-files.js index 27aea33..2b4dc4d 100644 --- a/dist/setup-files.js +++ b/dist/setup-files.js @@ -17,7 +17,15 @@ export class SetupFileLockTimeoutError extends Error { this.lockPath = lockPath; } } -export function mutateSetupConfiguration(target, transform) { +export class SetupConfigurationChangedError extends Error { + target; + constructor(target) { + super(`Setup configuration changed after preflight: ${target}`); + this.name = "SetupConfigurationChangedError"; + this.target = target; + } +} +export function mutateSetupConfiguration(target, transform, expectedHash) { const directory = path.dirname(target); ensureSetupDirectory(directory); const directoryIdentity = fs.lstatSync(directory); @@ -25,6 +33,9 @@ export function mutateSetupConfiguration(target, transform) { throw new Error(`Setup directory changed while updating: ${directory}`); return withSetupFileLock(target, directoryIdentity, () => { const current = readAnchoredSetupFile(target, directoryIdentity); + if (expectedHash !== undefined && setupConfigurationHash(current) !== expectedHash) { + throw new SetupConfigurationChangedError(target); + } const existing = current ? parseSetupConfiguration(current, target) : undefined; const next = transform(existing); if (next === undefined) @@ -36,10 +47,17 @@ export function mutateSetupConfiguration(target, transform) { }); } export function readSetupConfiguration(target) { + return readSetupConfigurationSnapshot(target).configuration; +} +export function readSetupConfigurationSnapshot(target) { const bytes = readSetupFile(target); - if (!bytes) - return undefined; - return parseSetupConfiguration(bytes, target); + return { + configuration: bytes ? parseSetupConfiguration(bytes, target) : undefined, + hash: setupConfigurationHash(bytes), + }; +} +function setupConfigurationHash(bytes) { + return bytes === undefined ? "missing" : createHash("sha256").update(bytes).digest("hex"); } function parseSetupConfiguration(bytes, target) { let value; diff --git a/dist/setup.js b/dist/setup.js index 0e0dd32..90e1df6 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -1,6 +1,6 @@ import path from "node:path"; import { runHarnessLifecycle } from "./setup-adapters.js"; -import { ensureSetupDirectory, mutateSetupConfiguration, readSetupConfiguration } from "./setup-files.js"; +import { ensureSetupDirectory, mutateSetupConfiguration, readSetupConfiguration, readSetupConfigurationSnapshot, SetupConfigurationChangedError, } from "./setup-files.js"; import { assertSafeSetupCommand, setupHooksConfigured } from "./setup-hook-status.js"; import { mergeSetupHooks, removeSetupHooks, removeSharedSetupHooks, validateSetupHooks, } from "./setup-hooks.js"; import { SETUP_HARNESSES, setupPath } from "./setup-targets.js"; @@ -8,11 +8,12 @@ export function setupHarness(harness, options) { const target = setupPath(harness, options.home); const command = options.command.trim(); assertSafeSetupCommand(command); - const existing = readSetupConfiguration(target); + const snapshot = readSetupConfigurationSnapshot(target); + const existing = snapshot.configuration; validateSetupHooks(harness, existing, target); ensureSetupDirectory(path.dirname(target)); const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env, command } : { command }); - const changed = finishHookUpdate("setup", harness, native.status, target, () => (updateHooks(harness, native.status, target, command, existing !== undefined))); + const changed = finishHookUpdate("setup", harness, native.status, target, () => (updateHooks(harness, native.status, target, command, snapshot.hash))); return { harness, action: "setup", @@ -24,10 +25,11 @@ export function setupHarness(harness, options) { } export function removeHarness(harness, options = {}) { const target = setupPath(harness, options.home); - const existing = readSetupConfiguration(target); + const snapshot = readSetupConfigurationSnapshot(target); + const existing = snapshot.configuration; validateSetupHooks(harness, existing, target); const native = runHarnessLifecycle(harness, "remove", options.env ? { env: options.env } : {}); - const changed = finishHookUpdate("remove", harness, native.status, target, () => (removeHooks(harness, target, existing !== undefined))); + const changed = finishHookUpdate("remove", harness, native.status, target, () => (removeHooks(harness, target, existing !== undefined, snapshot.hash))); return { harness, action: "remove", @@ -43,26 +45,25 @@ export function setupStatus(options = {}) { return [harness, { hooksConfigured: setupHooksConfigured(harness, readConfigurationForStatus(target)), path: target }]; })); } -function updateHooks(harness, nativeStatus, target, command, targetExists) { - if (harness === "kiro") { - return mutateSetupConfiguration(target, (existing) => mergeSetupHooks(existing, harness, command, target)); - } - if (harness === "codex" && nativeStatus === "native-complete" && targetExists) { - return mutateSetupConfiguration(target, (existing) => existing === undefined - ? undefined - : removeSetupHooks(existing, harness, target)); - } - if ((harness === "copilot" || harness === "vscode") && nativeStatus === "native-complete" && targetExists) { - return mutateSetupConfiguration(target, (existing) => removeSharedSetupHooks(existing ?? {}, harness, target)); - } - return false; +function updateHooks(harness, nativeStatus, target, command, expectedHash) { + return mutateSetupConfiguration(target, (existing) => { + if (harness === "kiro") + return mergeSetupHooks(existing, harness, command, target); + if (harness === "codex" && nativeStatus === "native-complete") { + return existing === undefined ? undefined : removeSetupHooks(existing, harness, target); + } + if ((harness === "copilot" || harness === "vscode") && nativeStatus === "native-complete") { + return existing === undefined ? undefined : removeSharedSetupHooks(existing, harness, target); + } + return existing; + }, expectedHash); } -function removeHooks(harness, target, targetExists) { +function removeHooks(harness, target, targetExists, expectedHash) { if (harness === "copilot" || harness === "vscode" || !targetExists) return false; return mutateSetupConfiguration(target, (existing) => existing === undefined ? undefined - : removeSetupHooks(existing, harness, target)); + : removeSetupHooks(existing, harness, target), expectedHash); } function finishHookUpdate(action, harness, nativeStatus, target, update) { try { @@ -71,8 +72,11 @@ function finishHookUpdate(action, harness, nativeStatus, target, update) { catch (error) { if (nativeStatus !== "native-complete") throw error; + const detail = error instanceof SetupConfigurationChangedError + ? "Agent LCM detected the concurrent change and did not overwrite it." + : "Inspect the hook file because the local update may have completed."; throw new Error(`Native ${harness} ${action} completed, but Agent LCM could not safely update ${target}. ` - + `The file was not overwritten. Repair it, then rerun agent-lcm ${action} ${harness}.`, { cause: error }); + + `${detail} Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, { cause: error }); } } function readConfigurationForStatus(target) { diff --git a/src/setup-files.ts b/src/setup-files.ts index 15a5678..d7121da 100644 --- a/src/setup-files.ts +++ b/src/setup-files.ts @@ -24,9 +24,25 @@ export class SetupFileLockTimeoutError extends Error { } } +export class SetupConfigurationChangedError extends Error { + readonly target: string; + + constructor(target: string) { + super(`Setup configuration changed after preflight: ${target}`); + this.name = "SetupConfigurationChangedError"; + this.target = target; + } +} + +export type SetupConfigurationSnapshot = { + readonly configuration: Record | undefined; + readonly hash: string; +}; + export function mutateSetupConfiguration( target: string, transform: (configuration: Record | undefined) => Record | undefined, + expectedHash?: string, ): boolean { const directory = path.dirname(target); ensureSetupDirectory(directory); @@ -34,6 +50,9 @@ export function mutateSetupConfiguration( if (!directoryIdentity.isDirectory()) throw new Error(`Setup directory changed while updating: ${directory}`); return withSetupFileLock(target, directoryIdentity, () => { const current = readAnchoredSetupFile(target, directoryIdentity); + if (expectedHash !== undefined && setupConfigurationHash(current) !== expectedHash) { + throw new SetupConfigurationChangedError(target); + } const existing = current ? parseSetupConfiguration(current, target) : undefined; const next = transform(existing); if (next === undefined) return false; @@ -49,9 +68,19 @@ export function mutateSetupConfiguration( } export function readSetupConfiguration(target: string): Record | undefined { + return readSetupConfigurationSnapshot(target).configuration; +} + +export function readSetupConfigurationSnapshot(target: string): SetupConfigurationSnapshot { const bytes = readSetupFile(target); - if (!bytes) return undefined; - return parseSetupConfiguration(bytes, target); + return { + configuration: bytes ? parseSetupConfiguration(bytes, target) : undefined, + hash: setupConfigurationHash(bytes), + }; +} + +function setupConfigurationHash(bytes: Buffer | undefined): string { + return bytes === undefined ? "missing" : createHash("sha256").update(bytes).digest("hex"); } function parseSetupConfiguration(bytes: Buffer, target: string): Record { diff --git a/src/setup.ts b/src/setup.ts index d2b3cb5..fa8a3da 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -2,7 +2,13 @@ import path from "node:path"; import type { CaptureHarness } from "./harnesses.ts"; import { runHarnessLifecycle, type HarnessCli } from "./setup-adapters.ts"; -import { ensureSetupDirectory, mutateSetupConfiguration, readSetupConfiguration } from "./setup-files.ts"; +import { + ensureSetupDirectory, + mutateSetupConfiguration, + readSetupConfiguration, + readSetupConfigurationSnapshot, + SetupConfigurationChangedError, +} from "./setup-files.ts"; import { assertSafeSetupCommand, setupHooksConfigured } from "./setup-hook-status.ts"; import { mergeSetupHooks, @@ -37,12 +43,13 @@ export function setupHarness(harness: CaptureHarness, options: SetupOptions): Se const target = setupPath(harness, options.home); const command = options.command.trim(); assertSafeSetupCommand(command); - const existing = readSetupConfiguration(target); + const snapshot = readSetupConfigurationSnapshot(target); + const existing = snapshot.configuration; validateSetupHooks(harness, existing, target); ensureSetupDirectory(path.dirname(target)); const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env, command } : { command }); const changed = finishHookUpdate("setup", harness, native.status, target, () => ( - updateHooks(harness, native.status, target, command, existing !== undefined) + updateHooks(harness, native.status, target, command, snapshot.hash) )); return { harness, @@ -56,11 +63,12 @@ export function setupHarness(harness: CaptureHarness, options: SetupOptions): Se export function removeHarness(harness: CaptureHarness, options: RemoveOptions = {}): RemoveReport { const target = setupPath(harness, options.home); - const existing = readSetupConfiguration(target); + const snapshot = readSetupConfigurationSnapshot(target); + const existing = snapshot.configuration; validateSetupHooks(harness, existing, target); const native = runHarnessLifecycle(harness, "remove", options.env ? { env: options.env } : {}); const changed = finishHookUpdate("remove", harness, native.status, target, () => ( - removeHooks(harness, target, existing !== undefined) + removeHooks(harness, target, existing !== undefined, snapshot.hash) )); return { harness, @@ -84,27 +92,25 @@ function updateHooks( nativeStatus: "native-complete" | "manual-required" | "shared-retained", target: string, command: string, - targetExists: boolean, + expectedHash: string, ): boolean { - if (harness === "kiro") { - return mutateSetupConfiguration(target, (existing) => mergeSetupHooks(existing, harness, command, target)); - } - if (harness === "codex" && nativeStatus === "native-complete" && targetExists) { - return mutateSetupConfiguration(target, (existing) => existing === undefined - ? undefined - : removeSetupHooks(existing, harness, target)); - } - if ((harness === "copilot" || harness === "vscode") && nativeStatus === "native-complete" && targetExists) { - return mutateSetupConfiguration(target, (existing) => removeSharedSetupHooks(existing ?? {}, harness, target)); - } - return false; + return mutateSetupConfiguration(target, (existing) => { + if (harness === "kiro") return mergeSetupHooks(existing, harness, command, target); + if (harness === "codex" && nativeStatus === "native-complete") { + return existing === undefined ? undefined : removeSetupHooks(existing, harness, target); + } + if ((harness === "copilot" || harness === "vscode") && nativeStatus === "native-complete") { + return existing === undefined ? undefined : removeSharedSetupHooks(existing, harness, target); + } + return existing; + }, expectedHash); } -function removeHooks(harness: CaptureHarness, target: string, targetExists: boolean): boolean { +function removeHooks(harness: CaptureHarness, target: string, targetExists: boolean, expectedHash: string): boolean { if (harness === "copilot" || harness === "vscode" || !targetExists) return false; return mutateSetupConfiguration(target, (existing) => existing === undefined ? undefined - : removeSetupHooks(existing, harness, target)); + : removeSetupHooks(existing, harness, target), expectedHash); } function finishHookUpdate( @@ -118,9 +124,12 @@ function finishHookUpdate( return update(); } catch (error) { if (nativeStatus !== "native-complete") throw error; + const detail = error instanceof SetupConfigurationChangedError + ? "Agent LCM detected the concurrent change and did not overwrite it." + : "Inspect the hook file because the local update may have completed."; throw new Error( `Native ${harness} ${action} completed, but Agent LCM could not safely update ${target}. ` - + `The file was not overwritten. Repair it, then rerun agent-lcm ${action} ${harness}.`, + + `${detail} Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, { cause: error }, ); } diff --git a/tests/setup.test.ts b/tests/setup.test.ts index b0de3f4..898a486 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -179,13 +179,17 @@ test("Codex setup reports recoverable partial state when hooks change during nat command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart', }] }] } })); + const concurrent = JSON.stringify({ metadata: { user: true }, hooks: { SessionStart: [{ hooks: [{ + type: "command", + command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart', + }] }] } }); assert.throws(() => setupHarness("codex", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm", - env: { ...fake.env, AGENT_LCM_FAKE_MUTATE_TARGET: target }, - }), /Native codex setup completed.*Repair.*rerun agent-lcm setup codex/u); + env: { ...fake.env, AGENT_LCM_FAKE_MUTATE_TARGET: target, AGENT_LCM_FAKE_MUTATE_CONTENT: concurrent }, + }), /Native codex setup completed.*concurrent change.*did not overwrite.*rerun agent-lcm setup codex/u); - assert.equal(fs.readFileSync(target, "utf8"), "{invalid\n"); + assert.equal(fs.readFileSync(target, "utf8"), concurrent); assert.deepEqual(readSetupCalls(fake.log), [ ["plugin", "list"], ["plugin", "marketplace", "add", PACKAGE_ROOT], @@ -737,7 +741,7 @@ function fakeSetupCli( ): { readonly env: NodeJS.ProcessEnv; readonly log: string } { const bin = fs.mkdtempSync(path.join(tempHome("agent-lcm-setup-cli-parent-"), "bin-")); const log = path.join(bin, "calls.jsonl"); - const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst argv = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(argv) + "\\n");\nif (process.env.AGENT_LCM_FAKE_MUTATE_TARGET && JSON.stringify(argv) === JSON.stringify(["plugin", "list"])) fs.writeFileSync(process.env.AGENT_LCM_FAKE_MUTATE_TARGET, "{invalid\\n");\n`; + const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst argv = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(argv) + "\\n");\nif (process.env.AGENT_LCM_FAKE_MUTATE_TARGET && JSON.stringify(argv) === JSON.stringify(["plugin", "list"])) fs.writeFileSync(process.env.AGENT_LCM_FAKE_MUTATE_TARGET, process.env.AGENT_LCM_FAKE_MUTATE_CONTENT);\n`; fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); t.after(() => fs.rmSync(path.dirname(bin), { recursive: true, force: true })); return { From 05a5e55580930659e0ae319a2b01bcef59c76977 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 10:25:06 -0400 Subject: [PATCH 23/29] fix(setup): detect hooks created during removal --- dist/setup.js | 2 +- src/setup.ts | 2 +- tests/setup.test.ts | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/dist/setup.js b/dist/setup.js index 90e1df6..3a5b274 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -59,7 +59,7 @@ function updateHooks(harness, nativeStatus, target, command, expectedHash) { }, expectedHash); } function removeHooks(harness, target, targetExists, expectedHash) { - if (harness === "copilot" || harness === "vscode" || !targetExists) + if (harness === "copilot" || harness === "vscode" || (!targetExists && harness !== "codex")) return false; return mutateSetupConfiguration(target, (existing) => existing === undefined ? undefined diff --git a/src/setup.ts b/src/setup.ts index fa8a3da..a24e1a7 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -107,7 +107,7 @@ function updateHooks( } function removeHooks(harness: CaptureHarness, target: string, targetExists: boolean, expectedHash: string): boolean { - if (harness === "copilot" || harness === "vscode" || !targetExists) return false; + if (harness === "copilot" || harness === "vscode" || (!targetExists && harness !== "codex")) return false; return mutateSetupConfiguration(target, (existing) => existing === undefined ? undefined : removeSetupHooks(existing, harness, target), expectedHash); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 898a486..b339119 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -197,6 +197,24 @@ test("Codex setup reports recoverable partial state when hooks change during nat ]); }); +test("Codex remove reports recoverable partial state when hooks appear during native removal", (t) => { + const fake = fakeSetupCli(t, "codex"); + const clientHome = tempHome("agent-lcm-codex-remove-race-"); + const target = path.join(clientHome, "hooks.json"); + const concurrent = JSON.stringify({ metadata: { user: true }, hooks: {} }); + + assert.throws(() => removeHarness("codex", { + home: clientHome, + env: { ...fake.env, AGENT_LCM_FAKE_MUTATE_TARGET: target, AGENT_LCM_FAKE_MUTATE_CONTENT: concurrent }, + }), /Native codex remove completed.*concurrent change.*did not overwrite.*rerun agent-lcm remove codex/u); + + assert.equal(fs.readFileSync(target, "utf8"), concurrent); + assert.deepEqual(readSetupCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ]); +}); + test("successful Copilot setup removes only legacy shared Agent LCM hooks", (t) => { // Given: a capable Copilot CLI and a shared hook file with owned and unrelated hooks. const fake = fakeSetupCli(t, "copilot"); From d48f43e79e33dfc87014dfc8b006b70c13656aad Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 10:27:00 -0400 Subject: [PATCH 24/29] fix(setup): leave manual hook rewrites untouched --- dist/setup.js | 2 ++ src/setup.ts | 1 + tests/setup.test.ts | 23 +++++++++++++++++++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/dist/setup.js b/dist/setup.js index 3a5b274..e34368b 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -46,6 +46,8 @@ export function setupStatus(options = {}) { })); } function updateHooks(harness, nativeStatus, target, command, expectedHash) { + if (harness !== "kiro" && nativeStatus !== "native-complete") + return false; return mutateSetupConfiguration(target, (existing) => { if (harness === "kiro") return mergeSetupHooks(existing, harness, command, target); diff --git a/src/setup.ts b/src/setup.ts index a24e1a7..7872881 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -94,6 +94,7 @@ function updateHooks( command: string, expectedHash: string, ): boolean { + if (harness !== "kiro" && nativeStatus !== "native-complete") return false; return mutateSetupConfiguration(target, (existing) => { if (harness === "kiro") return mergeSetupHooks(existing, harness, command, target); if (harness === "codex" && nativeStatus === "native-complete") { diff --git a/tests/setup.test.ts b/tests/setup.test.ts index b339119..e9a3e55 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -295,6 +295,25 @@ test("manual setup preserves legacy shared hooks and creates no target", (t) => assert.equal(fs.existsSync(path.join(cursorHome, "hooks.json")), false); }); +test("manual setup preserves a concurrent hook rewrite when no mutation is needed", (t) => { + const fake = fakeSetupCli(t, "cursor-agent"); + const clientHome = tempHome("agent-lcm-cursor-manual-race-"); + const target = path.join(clientHome, "hooks.json"); + fs.writeFileSync(target, JSON.stringify({ version: 1, hooks: {} })); + const concurrent = JSON.stringify({ version: 1, metadata: { user: true }, hooks: {} }); + + const report = setupHarness("cursor", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: { ...fake.env, AGENT_LCM_FAKE_MUTATE_TARGET: target, AGENT_LCM_FAKE_MUTATE_CONTENT: concurrent }, + }); + + assert.equal(report.status, "manual-required"); + assert.deepEqual(report.hooks, { path: target, changed: false }); + assert.equal(fs.readFileSync(target, "utf8"), concurrent); + assert.deepEqual(readSetupCalls(fake.log), [["--version"]]); +}); + test("Kiro setup uses the native array schema, is repeatable, and leaves sibling hooks unchanged", () => { const kiroHome = tempHome("agent-lcm-kiro-"); const unrelatedKiroHook = path.join(kiroHome, "hooks", "other.json"); @@ -755,11 +774,11 @@ test("setup requires an absolute installed binary path", () => { function fakeSetupCli( t: test.TestContext, - name: "codex" | "copilot", + name: "codex" | "copilot" | "cursor-agent", ): { readonly env: NodeJS.ProcessEnv; readonly log: string } { const bin = fs.mkdtempSync(path.join(tempHome("agent-lcm-setup-cli-parent-"), "bin-")); const log = path.join(bin, "calls.jsonl"); - const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst argv = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(argv) + "\\n");\nif (process.env.AGENT_LCM_FAKE_MUTATE_TARGET && JSON.stringify(argv) === JSON.stringify(["plugin", "list"])) fs.writeFileSync(process.env.AGENT_LCM_FAKE_MUTATE_TARGET, process.env.AGENT_LCM_FAKE_MUTATE_CONTENT);\n`; + const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst argv = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(argv) + "\\n");\nif (process.env.AGENT_LCM_FAKE_MUTATE_TARGET && (JSON.stringify(argv) === JSON.stringify(["plugin", "list"]) || JSON.stringify(argv) === JSON.stringify(["--version"]))) fs.writeFileSync(process.env.AGENT_LCM_FAKE_MUTATE_TARGET, process.env.AGENT_LCM_FAKE_MUTATE_CONTENT);\n`; fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); t.after(() => fs.rmSync(path.dirname(bin), { recursive: true, force: true })); return { From 3fd9695f2ee31899c1c00d6b00eea88a8cade4e7 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 10:41:54 -0400 Subject: [PATCH 25/29] fix(kiro): satisfy Power manifest requirements --- AGENTS.md | 4 ++-- README.md | 3 ++- dist/setup.js | 13 +++++++++---- docs/troubleshooting.md | 4 ++-- plugin.json | 4 ++++ src/AGENTS.md | 2 +- src/setup.ts | 16 ++++++++++++---- tests/plugin-manifest.test.ts | 4 +++- tests/setup.test.ts | 20 +++++++++++++++++++- 9 files changed, 54 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f0c26a6..e2cb263 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,8 +55,8 @@ docs/ architecture and troubleshooting changes under an atomic `.lock` directory through a unique fsynced temporary file and rename. - If a hook file changes during native work, preserve the new bytes and report - the completed native action as recoverable partial state; never hide it behind - a generic file error. + whether the native action completed or setup stopped; never hide it behind a + generic file error. See `src/AGENTS.md` and `tests/AGENTS.md` for more specific rules. diff --git a/README.md b/README.md index 026e020..2e6c50b 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,8 @@ refused. Hook commands must be absolute paths without shell metacharacters. Thes repeated setup and removal safe while avoiding a second user-level hook copy after native installation. A native client and a hook file cannot share one transaction. If another process changes the hook file during native work, -Agent LCM reports that recoverable partial state instead of overwriting it. +Agent LCM reports whether the native action completed or setup stopped, then +leaves the changed bytes untouched. Hooks start the daemon on demand. You can also manage it directly: diff --git a/dist/setup.js b/dist/setup.js index e34368b..95572d0 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -72,13 +72,18 @@ function finishHookUpdate(action, harness, nativeStatus, target, update) { return update(); } catch (error) { + if (error instanceof SetupConfigurationChangedError) { + const result = nativeStatus === "native-complete" + ? `Native ${harness} ${action} completed` + : `${harness} ${action} stopped`; + throw new Error(`${result}, but Agent LCM detected a concurrent change to ${target} and did not overwrite it. ` + + `Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, { cause: error }); + } if (nativeStatus !== "native-complete") throw error; - const detail = error instanceof SetupConfigurationChangedError - ? "Agent LCM detected the concurrent change and did not overwrite it." - : "Inspect the hook file because the local update may have completed."; throw new Error(`Native ${harness} ${action} completed, but Agent LCM could not safely update ${target}. ` - + `${detail} Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, { cause: error }); + + `Inspect the hook file because the local update may have completed. ` + + `Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, { cause: error }); } } function readConfigurationForStatus(target) { diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3d27cad..bc1633b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -116,8 +116,8 @@ fails, the original file and native CLI invocation remain unchanged. If another process changes the hook file after that preflight while a native command is running, Agent LCM does not overwrite the new bytes. It exits `1` -and states that the native action completed but hook cleanup did not. Repair -the named file, then rerun the same `agent-lcm setup ` or +and states whether the native action completed or setup stopped. Repair the +named file if needed, then rerun the same `agent-lcm setup ` or `agent-lcm remove ` command. If setup times out on `.lock`, first confirm that no Agent LCM setup or diff --git a/plugin.json b/plugin.json index f0c1cc7..55ddf55 100644 --- a/plugin.json +++ b/plugin.json @@ -3,5 +3,9 @@ "name": "agent-lcm", "version": "0.0.6", "description": "Shared local context memory for agent harnesses.", + "author": { + "name": "Team Volt" + }, + "keywords": ["agent-memory", "context", "recall", "sessions"], "homepage": "https://github.com/Team-Volt/agent-lcm" } diff --git a/src/AGENTS.md b/src/AGENTS.md index a5145ae..176d6e0 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -48,7 +48,7 @@ - Native Codex setup removes exact legacy fallback hooks after install and never creates a user hook file. - Generate the Copilot-format package at setup time so hooks and MCP use the validated absolute Agent LCM command. Keep its source basename `agent-lcm` so repeat direct installs update one native plugin. - Validate existing setup JSON before starting a native process. Match owned hooks by harness, event, and command shape; preserve unrelated and near-matching entries. -- Native lifecycle state and hook JSON are not one transaction. If the file changes during native work, preserve its bytes and throw an explicit error that says the native action completed and tells the user to repair and rerun. +- Native lifecycle state and hook JSON are not one transaction. If the file changes during native work, preserve its bytes and throw an explicit error that says whether the native action completed or setup stopped, then tells the user to repair and rerun. - Keep setup-file reads, backups, locks, and publication inside the helper process anchored to the validated target directory. A later path identity check does not make a path-based write safe. Refuse symlinked directory components, lock paths, targets, and non-regular files. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. - Build native plugin sources only from the current local package, never from a mutable remote ref. Treat only `ENOENT` as an unavailable CLI; all other native probe or command failures must stop before hook mutation and must not echo client stderr. - Require an absolute hook binary path and reject shell metacharacters before writing configuration. diff --git a/src/setup.ts b/src/setup.ts index 7872881..66b6210 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -124,13 +124,21 @@ function finishHookUpdate( try { return update(); } catch (error) { + if (error instanceof SetupConfigurationChangedError) { + const result = nativeStatus === "native-complete" + ? `Native ${harness} ${action} completed` + : `${harness} ${action} stopped`; + throw new Error( + `${result}, but Agent LCM detected a concurrent change to ${target} and did not overwrite it. ` + + `Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, + { cause: error }, + ); + } if (nativeStatus !== "native-complete") throw error; - const detail = error instanceof SetupConfigurationChangedError - ? "Agent LCM detected the concurrent change and did not overwrite it." - : "Inspect the hook file because the local update may have completed."; throw new Error( `Native ${harness} ${action} completed, but Agent LCM could not safely update ${target}. ` - + `${detail} Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, + + `Inspect the hook file because the local update may have completed. ` + + `Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, { cause: error }, ); } diff --git a/tests/plugin-manifest.test.ts b/tests/plugin-manifest.test.ts index 8e723a9..72bf494 100644 --- a/tests/plugin-manifest.test.ts +++ b/tests/plugin-manifest.test.ts @@ -4,9 +4,11 @@ import { readJson } from "./helpers.ts"; test("root is an Agent Plugins 1.0 package", () => { const plugin = readJson("plugin.json"); - assert.deepEqual(Object.keys(plugin).sort(), ["$schema", "description", "homepage", "name", "version"]); + assert.deepEqual(Object.keys(plugin).sort(), ["$schema", "author", "description", "homepage", "keywords", "name", "version"]); assert.equal(plugin.$schema, "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"); assert.equal(plugin.name, "agent-lcm"); + assert.deepEqual(plugin.author, { name: "Team Volt" }); + assert.deepEqual(plugin.keywords, ["agent-memory", "context", "recall", "sessions"]); assert.equal(plugin.homepage, "https://github.com/Team-Volt/agent-lcm"); const mcp = readJson("mcp.json"); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index e9a3e55..0db8d92 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -314,6 +314,24 @@ test("manual setup preserves a concurrent hook rewrite when no mutation is neede assert.deepEqual(readSetupCalls(fake.log), [["--version"]]); }); +test("Kiro setup reports a concurrent hook rewrite without claiming native install", (t) => { + const fake = fakeSetupCli(t, "kiro-cli"); + const clientHome = tempHome("agent-lcm-kiro-race-"); + const target = path.join(clientHome, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify({ version: "v1", hooks: [] })); + const concurrent = JSON.stringify({ version: "v1", metadata: { user: true }, hooks: [] }); + + assert.throws(() => setupHarness("kiro", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: { ...fake.env, AGENT_LCM_FAKE_MUTATE_TARGET: target, AGENT_LCM_FAKE_MUTATE_CONTENT: concurrent }, + }), /kiro setup stopped.*concurrent change.*did not overwrite.*rerun agent-lcm setup kiro/u); + + assert.equal(fs.readFileSync(target, "utf8"), concurrent); + assert.deepEqual(readSetupCalls(fake.log), [["--version"]]); +}); + test("Kiro setup uses the native array schema, is repeatable, and leaves sibling hooks unchanged", () => { const kiroHome = tempHome("agent-lcm-kiro-"); const unrelatedKiroHook = path.join(kiroHome, "hooks", "other.json"); @@ -774,7 +792,7 @@ test("setup requires an absolute installed binary path", () => { function fakeSetupCli( t: test.TestContext, - name: "codex" | "copilot" | "cursor-agent", + name: "codex" | "copilot" | "cursor-agent" | "kiro-cli", ): { readonly env: NodeJS.ProcessEnv; readonly log: string } { const bin = fs.mkdtempSync(path.join(tempHome("agent-lcm-setup-cli-parent-"), "bin-")); const log = path.join(bin, "calls.jsonl"); From fe5e6d5dd82bd6b0c51068252a4473ac939bd81d Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 11:04:54 -0400 Subject: [PATCH 26/29] fix(setup): run Windows CLI shims safely --- .github/workflows/ci.yml | 2 +- AGENTS.md | 3 +++ dist/setup-adapters.js | 51 +++++++++++++++++++++++++++++++++--- src/AGENTS.md | 1 + src/setup-adapters.ts | 51 +++++++++++++++++++++++++++++++++--- tests/AGENTS.md | 3 ++- tests/setup-adapters.test.ts | 12 ++++++++- tests/setup.test.ts | 14 ++++++++-- 8 files changed, 126 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feadc39..931821e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,5 +45,5 @@ jobs: cache: npm cache-dependency-path: package-lock.json - run: npm ci - - run: node --test tests/distribution.test.ts tests/setup.test.ts + - run: node --test tests/distribution.test.ts tests/setup-adapters.test.ts tests/setup.test.ts - run: node --test --test-name-pattern="raw-log workers with the same PID|hook recovers after its lock-owning worker terminates|concurrent single ingest writers append" tests/storage.test.ts tests/hook-cli.test.ts diff --git a/AGENTS.md b/AGENTS.md index e2cb263..75d5db9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,9 @@ docs/ architecture and troubleshooting near-matching hooks, reject symlinked or non-regular targets, and publish changes under an atomic `.lock` directory through a unique fsynced temporary file and rename. +- Keep native CLI argv shell-free. On Windows, resolve npm `.cmd` or `.bat` + shims from `PATH`, reject command-shell metacharacters, and invoke only that + resolved shim through `cmd.exe`. - If a hook file changes during native work, preserve the new bytes and report whether the native action completed or setup stopped; never hide it behind a generic file error. diff --git a/dist/setup-adapters.js b/dist/setup-adapters.js index 842d510..ea990fc 100644 --- a/dist/setup-adapters.js +++ b/dist/setup-adapters.js @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { withCopilotPluginSource } from "./copilot-plugin.js"; @@ -62,7 +63,7 @@ export function runHarnessLifecycle(harness, action, options = {}) { } } function runNative(harness, action, adapter, env, command) { - const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + const probe = spawnLifecycleCommand(adapter.executable, adapter.probeArgv, env); if (isEnoent(probe.error)) { return outcome(harness, action, "manual-required", null, adapter.guide); } @@ -85,7 +86,7 @@ function runNative(harness, action, adapter, env, command) { return outcome(harness, action, "native-complete", adapter.executable, adapter.guide); } function manualOutcome(harness, action, adapter, env) { - const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + const probe = spawnLifecycleCommand(adapter.executable, adapter.probeArgv, env); if (isEnoent(probe.error)) return outcome(harness, action, "manual-required", null, adapter.guide); if (probe.error !== undefined || probe.status !== 0) { @@ -94,17 +95,61 @@ function manualOutcome(harness, action, adapter, env) { return outcome(harness, action, "manual-required", adapter.executable, adapter.guide); } function runNativeCommand(executable, argv, env) { - const result = spawnSync(executable, argv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + const result = spawnLifecycleCommand(executable, argv, env); if (result.status === 0) return; throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); } +function spawnLifecycleCommand(executable, argv, env) { + const options = { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }; + const direct = spawnSync(executable, argv, options); + if (process.platform !== "win32" || !needsWindowsShim(direct.error)) + return direct; + const shim = resolveWindowsShim(executable, env); + if (shim === null) + return direct; + const command = quoteWindowsCommand([shim, ...argv], executable, argv); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], options); + if (isEnoent(result.error)) { + throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); + } + return result; +} +function resolveWindowsShim(executable, env) { + const commandEnv = env ?? process.env; + const searchPath = Object.entries(commandEnv).find(([key]) => key.toUpperCase() === "PATH")?.[1]; + if (searchPath === undefined) + return null; + for (const entry of searchPath.split(path.delimiter)) { + const directory = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry; + if (directory.length === 0) + continue; + for (const extension of [".cmd", ".bat"]) { + const candidate = path.join(directory, `${executable}${extension}`); + try { + if (fs.statSync(candidate).isFile()) + return candidate; + } + catch { } + } + } + return null; +} +function quoteWindowsCommand(values, executable, argv) { + if (values.some((value) => /["&|<>^%!\r\n]/u.test(value))) { + throw new NativeLifecycleCommandError(executable, argv, null, SUPPRESSED_STDERR); + } + return `"${values.map((value) => `"${value}"`).join(" ")}"`; +} function outcome(harness, action, status, nativeCli, guide) { return { harness, action, status, nativeCli, guide }; } function isEnoent(error) { return error !== undefined && "code" in error && error.code === "ENOENT"; } +function needsWindowsShim(error) { + return error !== undefined && "code" in error && (error.code === "ENOENT" || error.code === "EINVAL"); +} function assertNever(value) { throw new Error(`Unexpected lifecycle adapter: ${JSON.stringify(value)}`); } diff --git a/src/AGENTS.md b/src/AGENTS.md index 176d6e0..bf485c7 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -44,6 +44,7 @@ ## Harness setup safety - Probe and invoke only documented commands: Codex uses `codex plugin`; Copilot and VS Code use the shared `copilot plugin` store; Cursor and Kiro use version-only probes and keep plugin changes manual. +- Spawn native executables directly. On Windows only, resolve npm `.cmd` or `.bat` shims from `PATH`, reject command-shell metacharacters, and pass the resolved shim through `cmd.exe` with the shell option still disabled. - Codex and Cursor native hooks depend on the packed npm artifact omitting the repository-root Agent Plugins manifest. Never add `plugin.json` back to `package.json#files` without redesigning native package selection. - Native Codex setup removes exact legacy fallback hooks after install and never creates a user hook file. - Generate the Copilot-format package at setup time so hooks and MCP use the validated absolute Agent LCM command. Keep its source basename `agent-lcm` so repeat direct installs update one native plugin. diff --git a/src/setup-adapters.ts b/src/setup-adapters.ts index df8a9f6..6a54391 100644 --- a/src/setup-adapters.ts +++ b/src/setup-adapters.ts @@ -1,4 +1,6 @@ import { spawnSync } from "node:child_process"; +import type { SpawnSyncOptionsWithStringEncoding } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -120,7 +122,7 @@ function runNative( env: NodeJS.ProcessEnv | undefined, command?: string, ): HarnessLifecycleOutcome { - const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + const probe = spawnLifecycleCommand(adapter.executable, adapter.probeArgv, env); if (isEnoent(probe.error)) { return outcome(harness, action, "manual-required", null, adapter.guide); } @@ -147,7 +149,7 @@ function manualOutcome( adapter: ManualLifecycleAdapter, env: NodeJS.ProcessEnv | undefined, ): HarnessLifecycleOutcome { - const probe = spawnSync(adapter.executable, adapter.probeArgv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + const probe = spawnLifecycleCommand(adapter.executable, adapter.probeArgv, env); if (isEnoent(probe.error)) return outcome(harness, action, "manual-required", null, adapter.guide); if (probe.error !== undefined || probe.status !== 0) { throw new NativeLifecycleCommandError(adapter.executable, adapter.probeArgv, probe.status, SUPPRESSED_STDERR); @@ -156,11 +158,50 @@ function manualOutcome( } function runNativeCommand(executable: "codex" | "copilot", argv: readonly string[], env: NodeJS.ProcessEnv | undefined): void { - const result = spawnSync(executable, argv, { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + const result = spawnLifecycleCommand(executable, argv, env); if (result.status === 0) return; throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); } +function spawnLifecycleCommand(executable: HarnessCli, argv: readonly string[], env: NodeJS.ProcessEnv | undefined) { + const options: SpawnSyncOptionsWithStringEncoding = { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }; + const direct = spawnSync(executable, argv, options); + if (process.platform !== "win32" || !needsWindowsShim(direct.error)) return direct; + + const shim = resolveWindowsShim(executable, env); + if (shim === null) return direct; + const command = quoteWindowsCommand([shim, ...argv], executable, argv); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], options); + if (isEnoent(result.error)) { + throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); + } + return result; +} + +function resolveWindowsShim(executable: HarnessCli, env: NodeJS.ProcessEnv | undefined): string | null { + const commandEnv = env ?? process.env; + const searchPath = Object.entries(commandEnv).find(([key]) => key.toUpperCase() === "PATH")?.[1]; + if (searchPath === undefined) return null; + for (const entry of searchPath.split(path.delimiter)) { + const directory = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry; + if (directory.length === 0) continue; + for (const extension of [".cmd", ".bat"] as const) { + const candidate = path.join(directory, `${executable}${extension}`); + try { + if (fs.statSync(candidate).isFile()) return candidate; + } catch {} + } + } + return null; +} + +function quoteWindowsCommand(values: readonly string[], executable: HarnessCli, argv: readonly string[]): string { + if (values.some((value) => /["&|<>^%!\r\n]/u.test(value))) { + throw new NativeLifecycleCommandError(executable, argv, null, SUPPRESSED_STDERR); + } + return `"${values.map((value) => `"${value}"`).join(" ")}"`; +} + function outcome( harness: CaptureHarness, action: HarnessLifecycleAction, @@ -175,6 +216,10 @@ function isEnoent(error: Error | undefined): boolean { return error !== undefined && "code" in error && error.code === "ENOENT"; } +function needsWindowsShim(error: Error | undefined): boolean { + return error !== undefined && "code" in error && (error.code === "ENOENT" || error.code === "EINVAL"); +} + function assertNever(value: never): never { throw new Error(`Unexpected lifecycle adapter: ${JSON.stringify(value)}`); } diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 4c3ae2e..7a33978 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -41,7 +41,8 @@ framework, fixture library, or custom runner. scenarios. - Fake `codex`, `copilot`, `cursor-agent`, and `kiro-cli` executables must record argv and fail on demand; use them to prove the exact documented command - vectors without touching a user's installed clients. + vectors without touching a user's installed clients. Write `.cmd` shims on + Windows so tests exercise the same npm CLI boundary as users. - Distribution tests must prove the npm artifact omits root `plugin.json`, keeps both native manifests, and runs bundled Codex hooks without creating a user hook file. diff --git a/tests/setup-adapters.test.ts b/tests/setup-adapters.test.ts index 6150bee..1359c0c 100644 --- a/tests/setup-adapters.test.ts +++ b/tests/setup-adapters.test.ts @@ -243,7 +243,7 @@ function fakeCli( const pluginLog = path.join(bin, "plugins.jsonl"); const failure = fails ? JSON.stringify(fails) : ""; const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst path = require("node:path");\nconst args = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(args) + "\\n");\nif (args[0] === "plugin" && args[1] === "install" && args[2]) { const root = args[2]; fs.appendFileSync(process.env.AGENT_LCM_FAKE_PLUGIN_LOG, JSON.stringify({ plugin: JSON.parse(fs.readFileSync(path.join(root, "plugin.json"), "utf8")), hooks: JSON.parse(fs.readFileSync(path.join(root, "hooks.json"), "utf8")), mcp: JSON.parse(fs.readFileSync(path.join(root, ".mcp.json"), "utf8")), skill: fs.existsSync(path.join(root, "skills/lcm-recall/SKILL.md")) }) + "\\n"); }\nif (${JSON.stringify(failure)} && JSON.stringify(args) === ${JSON.stringify(failure)}) { process.stderr.write("mutation failed\\n"); process.exit(23); }\n`; - fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); + writeFakeCli(bin, name, script); t.after(() => fs.rmSync(bin, { recursive: true, force: true })); return { env: { @@ -256,6 +256,16 @@ function fakeCli( }; } +function writeFakeCli(bin: string, name: string, script: string): void { + if (process.platform === "win32") { + const source = path.join(bin, `${name}.cjs`); + fs.writeFileSync(source, script.replace(/^#![^\n]*\n/u, "")); + fs.writeFileSync(path.join(bin, `${name}.cmd`), `@"${process.execPath}" "${source}" %*\r\n`); + return; + } + fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); +} + type PluginSnapshot = { readonly plugin: Record; readonly hooks: Record; diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 0db8d92..b80def4 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -797,7 +797,7 @@ function fakeSetupCli( const bin = fs.mkdtempSync(path.join(tempHome("agent-lcm-setup-cli-parent-"), "bin-")); const log = path.join(bin, "calls.jsonl"); const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst argv = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(argv) + "\\n");\nif (process.env.AGENT_LCM_FAKE_MUTATE_TARGET && (JSON.stringify(argv) === JSON.stringify(["plugin", "list"]) || JSON.stringify(argv) === JSON.stringify(["--version"]))) fs.writeFileSync(process.env.AGENT_LCM_FAKE_MUTATE_TARGET, process.env.AGENT_LCM_FAKE_MUTATE_CONTENT);\n`; - fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); + writeFakeSetupCli(bin, name, script); t.after(() => fs.rmSync(path.dirname(bin), { recursive: true, force: true })); return { env: { AGENT_LCM_FAKE_LOG: log, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}` }, @@ -818,7 +818,7 @@ if (!fs.existsSync(process.env.CODEX_HOME)) process.exit(24); fs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify({ argv: process.argv.slice(2), env: { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, CODEX_HOME: process.env.CODEX_HOME, COPILOT_HOME: process.env.COPILOT_HOME, AGENT_LCM_HOME: process.env.AGENT_LCM_HOME } }) + "\\n"); if (${String(failProbe)} && JSON.stringify(process.argv.slice(2)) === JSON.stringify(["plugin", "list"])) { process.stderr.write("secret-token\\n"); process.exit(23); } `; - fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); + writeFakeSetupCli(bin, name, script); t.after(() => fs.rmSync(path.dirname(bin), { recursive: true, force: true })); return { path: `${bin}${path.delimiter}${path.dirname(process.execPath)}`, @@ -826,6 +826,16 @@ if (${String(failProbe)} && JSON.stringify(process.argv.slice(2)) === JSON.strin }; } +function writeFakeSetupCli(bin: string, name: string, script: string): void { + if (process.platform === "win32") { + const source = path.join(bin, `${name}.cjs`); + fs.writeFileSync(source, script.replace(/^#![^\n]*\n/u, "")); + fs.writeFileSync(path.join(bin, `${name}.cmd`), `@"${process.execPath}" "${source}" %*\r\n`); + return; + } + fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); +} + function readSetupCalls(log: string): unknown[] { return fs.readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line)); } From deb2f221e28af75f69be0cb6368ffc1b0b9b339c Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 11:08:07 -0400 Subject: [PATCH 27/29] fix(setup): pass Windows shim argv directly --- dist/setup-adapters.js | 7 +++---- src/setup-adapters.ts | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/dist/setup-adapters.js b/dist/setup-adapters.js index ea990fc..e7b2daa 100644 --- a/dist/setup-adapters.js +++ b/dist/setup-adapters.js @@ -108,8 +108,8 @@ function spawnLifecycleCommand(executable, argv, env) { const shim = resolveWindowsShim(executable, env); if (shim === null) return direct; - const command = quoteWindowsCommand([shim, ...argv], executable, argv); - const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], options); + assertSafeWindowsCommand([shim, ...argv], executable, argv); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/c", shim, ...argv], options); if (isEnoent(result.error)) { throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); } @@ -135,11 +135,10 @@ function resolveWindowsShim(executable, env) { } return null; } -function quoteWindowsCommand(values, executable, argv) { +function assertSafeWindowsCommand(values, executable, argv) { if (values.some((value) => /["&|<>^%!\r\n]/u.test(value))) { throw new NativeLifecycleCommandError(executable, argv, null, SUPPRESSED_STDERR); } - return `"${values.map((value) => `"${value}"`).join(" ")}"`; } function outcome(harness, action, status, nativeCli, guide) { return { harness, action, status, nativeCli, guide }; diff --git a/src/setup-adapters.ts b/src/setup-adapters.ts index 6a54391..53203c8 100644 --- a/src/setup-adapters.ts +++ b/src/setup-adapters.ts @@ -170,8 +170,8 @@ function spawnLifecycleCommand(executable: HarnessCli, argv: readonly string[], const shim = resolveWindowsShim(executable, env); if (shim === null) return direct; - const command = quoteWindowsCommand([shim, ...argv], executable, argv); - const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], options); + assertSafeWindowsCommand([shim, ...argv], executable, argv); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/c", shim, ...argv], options); if (isEnoent(result.error)) { throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); } @@ -195,11 +195,10 @@ function resolveWindowsShim(executable: HarnessCli, env: NodeJS.ProcessEnv | und return null; } -function quoteWindowsCommand(values: readonly string[], executable: HarnessCli, argv: readonly string[]): string { +function assertSafeWindowsCommand(values: readonly string[], executable: HarnessCli, argv: readonly string[]): void { if (values.some((value) => /["&|<>^%!\r\n]/u.test(value))) { throw new NativeLifecycleCommandError(executable, argv, null, SUPPRESSED_STDERR); } - return `"${values.map((value) => `"${value}"`).join(" ")}"`; } function outcome( From dd3f80038fa4e8e39f898a9a5ec934942a6bf712 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 11:14:10 -0400 Subject: [PATCH 28/29] fix(setup): stop on shim lookup errors --- dist/setup-adapters.js | 13 ++++++++++--- src/setup-adapters.ts | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/dist/setup-adapters.js b/dist/setup-adapters.js index e7b2daa..be250ba 100644 --- a/dist/setup-adapters.js +++ b/dist/setup-adapters.js @@ -105,7 +105,7 @@ function spawnLifecycleCommand(executable, argv, env) { const direct = spawnSync(executable, argv, options); if (process.platform !== "win32" || !needsWindowsShim(direct.error)) return direct; - const shim = resolveWindowsShim(executable, env); + const shim = resolveWindowsShim(executable, argv, env); if (shim === null) return direct; assertSafeWindowsCommand([shim, ...argv], executable, argv); @@ -115,7 +115,7 @@ function spawnLifecycleCommand(executable, argv, env) { } return result; } -function resolveWindowsShim(executable, env) { +function resolveWindowsShim(executable, argv, env) { const commandEnv = env ?? process.env; const searchPath = Object.entries(commandEnv).find(([key]) => key.toUpperCase() === "PATH")?.[1]; if (searchPath === undefined) @@ -130,7 +130,11 @@ function resolveWindowsShim(executable, env) { if (fs.statSync(candidate).isFile()) return candidate; } - catch { } + catch (error) { + if (isMissingPathError(error)) + continue; + throw new NativeLifecycleCommandError(executable, argv, null, SUPPRESSED_STDERR); + } } } return null; @@ -149,6 +153,9 @@ function isEnoent(error) { function needsWindowsShim(error) { return error !== undefined && "code" in error && (error.code === "ENOENT" || error.code === "EINVAL"); } +function isMissingPathError(error) { + return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR"); +} function assertNever(value) { throw new Error(`Unexpected lifecycle adapter: ${JSON.stringify(value)}`); } diff --git a/src/setup-adapters.ts b/src/setup-adapters.ts index 53203c8..6e61ea1 100644 --- a/src/setup-adapters.ts +++ b/src/setup-adapters.ts @@ -168,7 +168,7 @@ function spawnLifecycleCommand(executable: HarnessCli, argv: readonly string[], const direct = spawnSync(executable, argv, options); if (process.platform !== "win32" || !needsWindowsShim(direct.error)) return direct; - const shim = resolveWindowsShim(executable, env); + const shim = resolveWindowsShim(executable, argv, env); if (shim === null) return direct; assertSafeWindowsCommand([shim, ...argv], executable, argv); const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/c", shim, ...argv], options); @@ -178,7 +178,7 @@ function spawnLifecycleCommand(executable: HarnessCli, argv: readonly string[], return result; } -function resolveWindowsShim(executable: HarnessCli, env: NodeJS.ProcessEnv | undefined): string | null { +function resolveWindowsShim(executable: HarnessCli, argv: readonly string[], env: NodeJS.ProcessEnv | undefined): string | null { const commandEnv = env ?? process.env; const searchPath = Object.entries(commandEnv).find(([key]) => key.toUpperCase() === "PATH")?.[1]; if (searchPath === undefined) return null; @@ -189,7 +189,10 @@ function resolveWindowsShim(executable: HarnessCli, env: NodeJS.ProcessEnv | und const candidate = path.join(directory, `${executable}${extension}`); try { if (fs.statSync(candidate).isFile()) return candidate; - } catch {} + } catch (error) { + if (isMissingPathError(error)) continue; + throw new NativeLifecycleCommandError(executable, argv, null, SUPPRESSED_STDERR); + } } } return null; @@ -219,6 +222,10 @@ function needsWindowsShim(error: Error | undefined): boolean { return error !== undefined && "code" in error && (error.code === "ENOENT" || error.code === "EINVAL"); } +function isMissingPathError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR"); +} + function assertNever(value: never): never { throw new Error(`Unexpected lifecycle adapter: ${JSON.stringify(value)}`); } From e16d17fd201d21552c4264f9c9fd3c64566b709c Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 11:17:02 -0400 Subject: [PATCH 29/29] test(setup): stop Windows shim lookup fallthrough --- tests/setup-adapters.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/setup-adapters.test.ts b/tests/setup-adapters.test.ts index 1359c0c..d501fda 100644 --- a/tests/setup-adapters.test.ts +++ b/tests/setup-adapters.test.ts @@ -181,6 +181,26 @@ test("a native probe permission error is not treated as a missing CLI", { skip: }); }); +test("a Windows shim lookup error cannot fall through to a later PATH entry", { skip: process.platform !== "win32" }, (t) => { + const blocked = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-blocked-cli-")); + const fake = fakeCli(t, "codex"); + t.after(() => fs.rmSync(blocked, { recursive: true, force: true })); + t.mock.method(fs, "statSync", () => { + throw Object.assign(new Error("denied"), { code: "EACCES" }); + }); + + assert.throws( + () => runHarnessLifecycle("codex", "setup", { env: { ...fake.env, PATH: `${blocked}${path.delimiter}${fake.env.PATH ?? ""}` } }), + (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.equal(error.status, null); + assert.equal(error.stderr, "suppressed"); + return true; + }, + ); + assert.equal(fs.existsSync(fake.log), false); +}); + test("shared-retained removal does not spawn Copilot uninstall", (t) => { // Given: a fake Copilot CLI that would record any spawned process. const fake = fakeCli(t, "copilot");