From e47969bd2b6b768439d2c4cd6c3ae3f25ff0cc8d Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:46:59 -0700 Subject: [PATCH] feat(agent-actions): add an operator route for the global kill-switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setGlobalAgentFrozen (the write side of the DB-backed global agent kill-switch, documented as "an operator flips with one row, no redeploy") had zero callers anywhere in src/ — no API route, no MCP tool, no admin surface. The only way to actually flip global_agent_controls.frozen was a direct SQL statement against D1. Found while fixing #2125: closing that issue's fail-open observability gap doesn't help much if there's no application-level way to set the switch in the first place. Add GET/POST /v1/app/kill-switch, gated by the same requireAppRole(..., ["operator"]) check used by the other operator-only routes: - GET returns the current { frozen, updatedAt, updatedBy } via a new getGlobalAgentFrozenState — a strict, non-fail-open read distinct from isGlobalAgentFrozen (which stays fail-open on the enforcement hot path so a D1 hiccup never silently freezes the fleet). A read failure here surfaces as a clear 503 instead of a falsely reassuring "unfrozen". - POST validates a { frozen: boolean } body, calls setGlobalAgentFrozen, then re-reads via getGlobalAgentFrozenState to confirm the write actually landed before reporting success — a verify failure returns 503, an observed value that doesn't match the request returns 502 — and records an operator.kill_switch_set audit event on success. --- src/api/routes.ts | 55 ++++++++++ src/db/repositories.ts | 15 +++ test/unit/routes-kill-switch.test.ts | 145 +++++++++++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 test/unit/routes-kill-switch.test.ts diff --git a/src/api/routes.ts b/src/api/routes.ts index 3d386c5344..d2ca92d523 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -100,6 +100,8 @@ import { getRepositoryAiKeyStatus, upsertRepositoryAiKey, deleteRepositoryAiKey, + getGlobalAgentFrozenState, + setGlobalAgentFrozen, } from "../db/repositories"; import { pruneExpiredRecords, RETENTION_POLICY } from "../db/retention"; import { @@ -769,6 +771,12 @@ const commandFeedbackSchema = z }) .strict(); +const killSwitchUpdateSchema = z + .object({ + frozen: z.boolean(), + }) + .strict(); + const digestSubscriptionSchema = z .object({ email: z.string().email().max(320), @@ -1332,6 +1340,53 @@ export function createApp() { return c.json(await buildOperatorDashboardPayload(c.env)); }); + // Global agent kill-switch (#2359): the write side (setGlobalAgentFrozen) previously had zero callers — the + // only way to flip it was raw SQL. isGlobalAgentFrozen's fail-open read is right for the enforcement hot path, + // but wrong here: getGlobalAgentFrozenState throws instead, so a read failure surfaces as a clear error rather + // than a falsely reassuring "unfrozen". + app.get("/v1/app/kill-switch", async (c) => { + const forbidden = await requireAppRole(c, ["operator"]); + if (forbidden) return forbidden; + try { + const state = await getGlobalAgentFrozenState(c.env); + return c.json({ ...state, generatedAt: nowIso() }); + } catch (error) { + return c.json({ error: "kill_switch_read_failed", message: errorMessage(error) }, 503); + } + }); + + app.post("/v1/app/kill-switch", async (c) => { + const forbidden = await requireAppRole(c, ["operator"]); + if (forbidden) return forbidden; + const identity = await authenticateRequestIdentity(c); + /* v8 ignore next -- requireAppRole already rejects an unauthenticated caller before this handler runs. */ + if (!identity) return c.json({ error: "unauthorized" }, 401); + const body = await c.req.json().catch(() => null); + const parsed = killSwitchUpdateSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_kill_switch_update", issues: parsed.error.issues }, 400); + const actorLogin = identity.actor; + await setGlobalAgentFrozen(c.env, parsed.data.frozen, actorLogin); + // Read-after-write verification (#2359): confirm the write actually landed before telling the caller it + // succeeded, rather than trusting the INSERT/UPDATE call not to have silently no-opped under a degraded D1. + let verified: { frozen: boolean; updatedAt: string | null; updatedBy: string | null }; + try { + verified = await getGlobalAgentFrozenState(c.env); + } catch (error) { + return c.json({ error: "kill_switch_verify_failed", message: errorMessage(error) }, 503); + } + if (verified.frozen !== parsed.data.frozen) { + return c.json({ error: "kill_switch_write_unconfirmed", requested: parsed.data.frozen, observed: verified.frozen }, 502); + } + await recordAuditEvent(c.env, { + eventType: "operator.kill_switch_set", + actor: actorLogin, + targetKey: "global_agent_controls#singleton", + outcome: "completed", + metadata: { frozen: verified.frozen, identityKind: identity.kind }, + }); + return c.json({ ok: true, ...verified }); + }); + app.get("/v1/app/notification-model", async (c) => { const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]); if (forbidden) return forbidden; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 87f325f2dd..c18a9f59f4 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -2110,6 +2110,21 @@ export async function setGlobalAgentFrozen(env: Env, frozen: boolean, updatedBy? .run(); } +/** Strict (non-fail-open) read of the kill-switch row, for the operator route's read-after-write verification + * (#2359) and for surfacing current state. Unlike {@link isGlobalAgentFrozen} — deliberately fail-open on the + * enforcement hot path so a D1 hiccup never silently freezes the fleet — this THROWS on a driver error or a + * missing singleton row, because here a swallowed error must surface as "could not verify", never be silently + * reported as "unfrozen". */ +export async function getGlobalAgentFrozenState(env: Env): Promise<{ frozen: boolean; updatedAt: string | null; updatedBy: string | null }> { + const row = await env.DB.prepare("SELECT frozen, updated_at, updated_by FROM global_agent_controls WHERE id = 'singleton'").first<{ + frozen: number; + updated_at: string | null; + updated_by: string | null; + }>(); + if (!row) throw new Error("global_agent_controls has no singleton row — re-run migrations or re-seed the row"); + return { frozen: row.frozen === 1, updatedAt: row.updated_at, updatedBy: row.updated_by }; +} + export async function recordAuditEvent(env: Env, event: AuditEventRecord): Promise { const db = getDb(env.DB); await db.insert(auditEvents).values({ diff --git a/test/unit/routes-kill-switch.test.ts b/test/unit/routes-kill-switch.test.ts new file mode 100644 index 0000000000..1843b44093 --- /dev/null +++ b/test/unit/routes-kill-switch.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/db/repositories", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getGlobalAgentFrozenState: vi.fn(actual.getGlobalAgentFrozenState), + setGlobalAgentFrozen: vi.fn(actual.setGlobalAgentFrozen), + }; +}); + +import { createApp } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { getGlobalAgentFrozenState, setGlobalAgentFrozen } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +// #2359: setGlobalAgentFrozen previously had zero callers anywhere in src/ — the only way to flip the DB-backed +// global kill-switch was raw SQL. These tests cover the new operator-only route pair that makes it operable. + +function apiHeaders(env: Env): Record { + return { authorization: `Bearer ${env.GITTENSORY_API_TOKEN}`, "content-type": "application/json" }; +} + +async function auditRows(env: Env): Promise> { + const result = (await env.DB.prepare("select actor, outcome, metadata_json from audit_events where event_type = 'operator.kill_switch_set' order by created_at desc").all()) as { + results: Array<{ actor: string; outcome: string; metadata_json: string }>; + }; + return result.results; +} + +describe("kill-switch operator route (#2359)", () => { + beforeEach(() => { + vi.mocked(getGlobalAgentFrozenState).mockClear(); + vi.mocked(setGlobalAgentFrozen).mockClear(); + }); + + it("GET returns the seeded-default unfrozen state for a trusted static token", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/v1/app/kill-switch", { headers: apiHeaders(env) }, env); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ frozen: false, updatedBy: null }); + }); + + it("GET is forbidden for an authenticated session without the operator role", async () => { + const app = createApp(); + const env = createTestEnv(); + const { token } = await createSessionForGitHubUser(env, { login: "not-an-operator", id: 501 }); + const res = await app.request("/v1/app/kill-switch", { headers: { cookie: `gittensory_session=${token}` } }, env); + expect(res.status).toBe(403); + }); + + it("GET is unauthorized with no identity at all", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/v1/app/kill-switch", {}, env); + expect(res.status).toBe(401); + }); + + it("GET surfaces a clear 503 (never a falsely reassuring unfrozen) when the singleton row is missing", async () => { + const app = createApp(); + const env = createTestEnv(); + await env.DB.prepare("DELETE FROM global_agent_controls WHERE id = 'singleton'").run(); + const res = await app.request("/v1/app/kill-switch", { headers: apiHeaders(env) }, env); + expect(res.status).toBe(503); + await expect(res.json()).resolves.toMatchObject({ error: "kill_switch_read_failed" }); + }); + + it("POST freezes and unfreezes the fleet for an operator session, verifying the write and auditing it", async () => { + const app = createApp(); + const env = createTestEnv(); + const { token } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 1 }); + const headers = { cookie: `gittensory_session=${token}`, "content-type": "application/json" }; + + const freeze = await app.request("/v1/app/kill-switch", { method: "POST", headers, body: JSON.stringify({ frozen: true }) }, env); + expect(freeze.status).toBe(200); + await expect(freeze.json()).resolves.toMatchObject({ ok: true, frozen: true, updatedBy: "jsonbored" }); + + const readBack = await app.request("/v1/app/kill-switch", { headers: apiHeaders(env) }, env); + await expect(readBack.json()).resolves.toMatchObject({ frozen: true }); + + const unfreeze = await app.request("/v1/app/kill-switch", { method: "POST", headers, body: JSON.stringify({ frozen: false }) }, env); + expect(unfreeze.status).toBe(200); + await expect(unfreeze.json()).resolves.toMatchObject({ ok: true, frozen: false, updatedBy: "jsonbored" }); + + const audits = await auditRows(env); + expect(audits).toHaveLength(2); + expect(audits.map((row) => JSON.parse(row.metadata_json).frozen)).toEqual([false, true]); + expect(audits.every((row) => row.actor === "jsonbored" && row.outcome === "completed")).toBe(true); + }); + + it("POST rejects a schema-invalid body instead of silently coercing it", async () => { + const app = createApp(); + const env = createTestEnv(); + const { token } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 1 }); + const headers = { cookie: `gittensory_session=${token}`, "content-type": "application/json" }; + const res = await app.request("/v1/app/kill-switch", { method: "POST", headers, body: JSON.stringify({ frozen: "yes" }) }, env); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: "invalid_kill_switch_update" }); + expect(setGlobalAgentFrozen).not.toHaveBeenCalled(); + }); + + it("POST rejects a body that isn't valid JSON at all", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/v1/app/kill-switch", { method: "POST", headers: apiHeaders(env), body: "{" }, env); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: "invalid_kill_switch_update" }); + expect(setGlobalAgentFrozen).not.toHaveBeenCalled(); + }); + + it("POST is forbidden for a non-operator session and unauthorized with no identity", async () => { + const app = createApp(); + const env = createTestEnv(); + const { token } = await createSessionForGitHubUser(env, { login: "not-an-operator", id: 501 }); + const forbidden = await app.request( + "/v1/app/kill-switch", + { method: "POST", headers: { cookie: `gittensory_session=${token}`, "content-type": "application/json" }, body: JSON.stringify({ frozen: true }) }, + env, + ); + expect(forbidden.status).toBe(403); + const unauthorized = await app.request("/v1/app/kill-switch", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ frozen: true }) }, env); + expect(unauthorized.status).toBe(401); + expect(setGlobalAgentFrozen).not.toHaveBeenCalled(); + }); + + it("POST surfaces a 503 (not a false success) when the post-write verification read fails", async () => { + const app = createApp(); + const env = createTestEnv(); + vi.mocked(getGlobalAgentFrozenState).mockRejectedValueOnce(new Error("D1 hiccup")); + const res = await app.request("/v1/app/kill-switch", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ frozen: true }) }, env); + expect(res.status).toBe(503); + await expect(res.json()).resolves.toMatchObject({ error: "kill_switch_verify_failed" }); + expect(setGlobalAgentFrozen).toHaveBeenCalledTimes(1); + }); + + it("POST surfaces a 502 (not a false success) when the read-after-write observes a value that doesn't match the write", async () => { + const app = createApp(); + const env = createTestEnv(); + vi.mocked(getGlobalAgentFrozenState).mockResolvedValueOnce({ frozen: false, updatedAt: null, updatedBy: null }); + const res = await app.request("/v1/app/kill-switch", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ frozen: true }) }, env); + expect(res.status).toBe(502); + await expect(res.json()).resolves.toMatchObject({ error: "kill_switch_write_unconfirmed", requested: true, observed: false }); + }); +});