diff --git a/CHANGELOG.md b/CHANGELOG.md index 16132d9..3283450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to `@inceptionstack/roundhouse` are documented here. +## [0.5.34] — 2026-05-14 + +### Added +- **`/toggle-enforce-branches`** — immediate runtime kill-switch for `pi-branch-enforcer` + - `/toggle-enforce-branches` — toggle (on ↔ off) + - `/toggle-enforce-branches on|off|status` — explicit / query + - Persists across agent restarts (writes `~/.pi-branch-enforcer/disabled` marker file) + - **Effect is immediate** — next bash tool call sees the new state, no `/restart` needed + - Requires `@inceptionstack/pi-branch-enforcer` >= 3.3.0 (which honors the marker) + - 10 unit tests covering toggle/explicit/status/aliases/idempotence/unknown-arg paths + ## [0.5.32] — 2026-05-14 ### Fixed diff --git a/package-lock.json b/package-lock.json index 774705b..20350a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@inceptionstack/roundhouse", - "version": "0.5.11", + "version": "0.5.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@inceptionstack/roundhouse", - "version": "0.5.11", + "version": "0.5.33", "license": "MIT", "dependencies": { "@chat-adapter/state-memory": "^4.26.0", diff --git a/package.json b/package.json index 7ba3118..d1e99a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inceptionstack/roundhouse", - "version": "0.5.32", + "version": "0.5.34", "type": "module", "description": "Multi-platform chat gateway that routes messages through a configured AI agent", "license": "MIT", diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index e52b936..863da03 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -26,6 +26,7 @@ import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, ha import { handleModel, handleModelAction, MODEL_ACTION_ID } from "./model-command"; import { handleLater } from "./later-command"; import { handleTopic, handleTopicAction, TOPIC_ACTION_ID, applyTopicOverride } from "./topic-command"; +import { handleToggleEnforceBranches } from "./toggle-enforce-branches-command"; import { type CommandDescriptor, type CommandInvocation, @@ -787,6 +788,13 @@ export class Gateway { acceptsArgs: true, invoke: ({ thread, text }) => handleLater({ thread, text, postWithFallback: post }), }, + { + triggers: ["/toggle-enforce-branches"], + acceptsArgs: true, + invoke: ({ thread, text }) => handleToggleEnforceBranches({ + thread, text, postWithFallback: post, + }), + }, { triggers: ["/topic"], acceptsArgs: true, diff --git a/src/gateway/toggle-enforce-branches-command.ts b/src/gateway/toggle-enforce-branches-command.ts new file mode 100644 index 0000000..f7f8b7d --- /dev/null +++ b/src/gateway/toggle-enforce-branches-command.ts @@ -0,0 +1,107 @@ +/** + * gateway/toggle-enforce-branches-command.ts — /toggle-enforce-branches command + * + * Toggles the runtime kill-switch for `@inceptionstack/pi-branch-enforcer`. + * + * The extension (v3.3.0+) checks for `~/.pi-branch-enforcer/disabled` on every + * `tool_call`. Creating the file disables enforcement; removing it re-enables. + * Effect is **immediate** — no agent restart needed; the next bash command + * sees the new state. + * + * Usage: + * /toggle-enforce-branches → toggle (off→on or on→off) + * /toggle-enforce-branches on → force enable + * /toggle-enforce-branches off → force disable + * /toggle-enforce-branches status → just report current state + * + * The command intentionally lives in roundhouse rather than the extension so + * that the user-facing surface (Telegram) stays in one place. The extension + * just exposes the marker file as a stable contract. + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; + +/** Stable contract with pi-branch-enforcer >=3.3.0. */ +const MARKER_DIR = join(homedir(), ".pi-branch-enforcer"); +const MARKER_PATH = join(MARKER_DIR, "disabled"); + +export interface ToggleEnforceBranchesContext { + thread: any; + text: string; + postWithFallback: (thread: any, text: string) => Promise; +} + +/** True if enforcement is currently disabled (marker file present). */ +function isDisabled(): boolean { + try { return existsSync(MARKER_PATH); } catch { return false; } +} + +/** Create the marker file (disable enforcement). Idempotent. */ +function disable(): void { + mkdirSync(MARKER_DIR, { recursive: true }); + writeFileSync(MARKER_PATH, `disabled at ${new Date().toISOString()}\n`); +} + +/** Remove the marker file (re-enable enforcement). Idempotent. */ +function enable(): void { + try { unlinkSync(MARKER_PATH); } catch (err: any) { + if (err?.code !== "ENOENT") throw err; + } +} + +function statusLine(disabled: boolean): string { + return disabled + ? "🔓 *Branch enforcer:* DISABLED — pushes to `main`/`master` are allowed" + : "🔒 *Branch enforcer:* ENABLED — pushes to `main`/`master` are blocked"; +} + +export async function handleToggleEnforceBranches(ctx: ToggleEnforceBranchesContext): Promise { + const { thread, text, postWithFallback } = ctx; + const arg = text.split(/\s+/)[1]?.toLowerCase() ?? ""; + + // Pure status query — no state change. + if (arg === "status") { + await postWithFallback(thread, statusLine(isDisabled())); + return; + } + + // Resolve target state. + const currentlyDisabled = isDisabled(); + let targetDisabled: boolean; + if (arg === "on" || arg === "enable") { + targetDisabled = false; + } else if (arg === "off" || arg === "disable") { + targetDisabled = true; + } else if (arg === "" || arg === "toggle") { + targetDisabled = !currentlyDisabled; + } else { + await postWithFallback( + thread, + "Usage: `/toggle-enforce-branches [on|off|status]`\n\n" + + "_(no arg toggles the current state)_", + ); + return; + } + + // Apply. + try { + if (targetDisabled) disable(); else enable(); + } catch (err) { + await postWithFallback(thread, `⚠️ Failed to toggle: ${(err as Error).message}`); + return; + } + + const noChange = targetDisabled === currentlyDisabled; + const prefix = noChange ? "ℹ️ Already in target state.\n\n" : "✅ Updated.\n\n"; + const detail = targetDisabled + ? "\n\n_Effect:_ next `bash` tool call will skip enforcement. Re-enable with `/toggle-enforce-branches on`." + : "\n\n_Effect:_ next `bash` tool call will resume enforcement."; + await postWithFallback(thread, prefix + statusLine(targetDisabled) + detail); + + console.log( + `[roundhouse] /toggle-enforce-branches: ${currentlyDisabled ? "disabled" : "enabled"} → ` + + `${targetDisabled ? "disabled" : "enabled"}`, + ); +} diff --git a/src/transports/telegram/bot-commands.ts b/src/transports/telegram/bot-commands.ts index 965eb71..0c7b02f 100644 --- a/src/transports/telegram/bot-commands.ts +++ b/src/transports/telegram/bot-commands.ts @@ -24,4 +24,5 @@ export const BOT_COMMANDS: BotCommand[] = [ { command: "doctor", description: "Run diagnostics" }, { command: "crons", description: "List scheduled cron jobs" }, { command: "jobs", description: "Show running jobs" }, + { command: "toggle-enforce-branches", description: "Toggle branch-protection enforcement" }, ]; diff --git a/test/toggle-enforce-branches.test.ts b/test/toggle-enforce-branches.test.ts new file mode 100644 index 0000000..d5ecd1e --- /dev/null +++ b/test/toggle-enforce-branches.test.ts @@ -0,0 +1,123 @@ +/** + * Tests for /toggle-enforce-branches command + * + * Strategy: characterization tests against the real filesystem using a + * dedicated tmp HOME so we don't touch ~/.pi-branch-enforcer/disabled on + * the dev machine. The handler reads HOME via os.homedir() — which on + * Linux respects $HOME — so overriding $HOME redirects all marker writes. + * + * We import the command module fresh per test (vitest module reset) so + * the homedir() value captured at module-load time picks up the new $HOME. + */ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; + +let tmpHome: string; +let originalHome: string | undefined; +let posts: string[]; +let postWithFallback: (thread: any, text: string) => Promise; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "roundhouse-toggle-test-")); + originalHome = process.env.HOME; + process.env.HOME = tmpHome; + posts = []; + postWithFallback = async (_t, text) => { posts.push(text); }; + // Force re-resolution of homedir() inside the command module + vi.resetModules(); + // Sanity: confirm os.homedir() now returns our tmpHome + expect(homedir()).toBe(tmpHome); +}); + +afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + rmSync(tmpHome, { recursive: true, force: true }); +}); + +const markerPath = () => join(tmpHome, ".pi-branch-enforcer", "disabled"); + +async function callHandler(text: string): Promise { + const mod = await import("../src/gateway/toggle-enforce-branches-command"); + await mod.handleToggleEnforceBranches({ + thread: { id: "test" }, + text, + postWithFallback, + }); +} + +describe("/toggle-enforce-branches", () => { + test("status_initialState_reportsEnabled", async () => { + await callHandler("/toggle-enforce-branches status"); + expect(posts).toHaveLength(1); + expect(posts[0]).toContain("ENABLED"); + expect(existsSync(markerPath())).toBe(false); + }); + + test("status_markerExists_reportsDisabled", async () => { + mkdirSync(join(tmpHome, ".pi-branch-enforcer"), { recursive: true }); + writeFileSync(markerPath(), "test"); + await callHandler("/toggle-enforce-branches status"); + expect(posts[0]).toContain("DISABLED"); + }); + + test("toggle_fromEnabled_disablesAndCreatesMarker", async () => { + await callHandler("/toggle-enforce-branches"); + expect(existsSync(markerPath())).toBe(true); + expect(posts[0]).toContain("DISABLED"); + expect(posts[0]).toContain("Updated"); + }); + + test("toggle_fromDisabled_enablesAndRemovesMarker", async () => { + mkdirSync(join(tmpHome, ".pi-branch-enforcer"), { recursive: true }); + writeFileSync(markerPath(), "x"); + await callHandler("/toggle-enforce-branches"); + expect(existsSync(markerPath())).toBe(false); + expect(posts[0]).toContain("ENABLED"); + }); + + test("explicitOff_whenAlreadyEnabled_disables", async () => { + await callHandler("/toggle-enforce-branches off"); + expect(existsSync(markerPath())).toBe(true); + expect(posts[0]).toContain("DISABLED"); + }); + + test("explicitOff_whenAlreadyDisabled_isIdempotentNoChange", async () => { + mkdirSync(join(tmpHome, ".pi-branch-enforcer"), { recursive: true }); + writeFileSync(markerPath(), "x"); + await callHandler("/toggle-enforce-branches off"); + expect(existsSync(markerPath())).toBe(true); + expect(posts[0]).toContain("Already in target state"); + expect(posts[0]).toContain("DISABLED"); + }); + + test("explicitOn_whenDisabled_enables", async () => { + mkdirSync(join(tmpHome, ".pi-branch-enforcer"), { recursive: true }); + writeFileSync(markerPath(), "x"); + await callHandler("/toggle-enforce-branches on"); + expect(existsSync(markerPath())).toBe(false); + expect(posts[0]).toContain("ENABLED"); + }); + + test("explicitOn_whenAlreadyEnabled_isIdempotentNoChange", async () => { + await callHandler("/toggle-enforce-branches on"); + expect(existsSync(markerPath())).toBe(false); + expect(posts[0]).toContain("Already in target state"); + }); + + test("aliases_enableAndDisable_work", async () => { + await callHandler("/toggle-enforce-branches disable"); + expect(existsSync(markerPath())).toBe(true); + posts.length = 0; + await callHandler("/toggle-enforce-branches enable"); + expect(existsSync(markerPath())).toBe(false); + }); + + test("unknownArg_postsUsageWithoutChanging", async () => { + await callHandler("/toggle-enforce-branches blah"); + expect(existsSync(markerPath())).toBe(false); + expect(posts[0]).toContain("Usage:"); + }); +});