Skip to content

Commit e47969b

Browse files
committed
feat(agent-actions): add an operator route for the global kill-switch
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.
1 parent a8030ca commit e47969b

3 files changed

Lines changed: 215 additions & 0 deletions

File tree

src/api/routes.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ import {
100100
getRepositoryAiKeyStatus,
101101
upsertRepositoryAiKey,
102102
deleteRepositoryAiKey,
103+
getGlobalAgentFrozenState,
104+
setGlobalAgentFrozen,
103105
} from "../db/repositories";
104106
import { pruneExpiredRecords, RETENTION_POLICY } from "../db/retention";
105107
import {
@@ -769,6 +771,12 @@ const commandFeedbackSchema = z
769771
})
770772
.strict();
771773

774+
const killSwitchUpdateSchema = z
775+
.object({
776+
frozen: z.boolean(),
777+
})
778+
.strict();
779+
772780
const digestSubscriptionSchema = z
773781
.object({
774782
email: z.string().email().max(320),
@@ -1332,6 +1340,53 @@ export function createApp() {
13321340
return c.json(await buildOperatorDashboardPayload(c.env));
13331341
});
13341342

1343+
// Global agent kill-switch (#2359): the write side (setGlobalAgentFrozen) previously had zero callers — the
1344+
// only way to flip it was raw SQL. isGlobalAgentFrozen's fail-open read is right for the enforcement hot path,
1345+
// but wrong here: getGlobalAgentFrozenState throws instead, so a read failure surfaces as a clear error rather
1346+
// than a falsely reassuring "unfrozen".
1347+
app.get("/v1/app/kill-switch", async (c) => {
1348+
const forbidden = await requireAppRole(c, ["operator"]);
1349+
if (forbidden) return forbidden;
1350+
try {
1351+
const state = await getGlobalAgentFrozenState(c.env);
1352+
return c.json({ ...state, generatedAt: nowIso() });
1353+
} catch (error) {
1354+
return c.json({ error: "kill_switch_read_failed", message: errorMessage(error) }, 503);
1355+
}
1356+
});
1357+
1358+
app.post("/v1/app/kill-switch", async (c) => {
1359+
const forbidden = await requireAppRole(c, ["operator"]);
1360+
if (forbidden) return forbidden;
1361+
const identity = await authenticateRequestIdentity(c);
1362+
/* v8 ignore next -- requireAppRole already rejects an unauthenticated caller before this handler runs. */
1363+
if (!identity) return c.json({ error: "unauthorized" }, 401);
1364+
const body = await c.req.json().catch(() => null);
1365+
const parsed = killSwitchUpdateSchema.safeParse(body);
1366+
if (!parsed.success) return c.json({ error: "invalid_kill_switch_update", issues: parsed.error.issues }, 400);
1367+
const actorLogin = identity.actor;
1368+
await setGlobalAgentFrozen(c.env, parsed.data.frozen, actorLogin);
1369+
// Read-after-write verification (#2359): confirm the write actually landed before telling the caller it
1370+
// succeeded, rather than trusting the INSERT/UPDATE call not to have silently no-opped under a degraded D1.
1371+
let verified: { frozen: boolean; updatedAt: string | null; updatedBy: string | null };
1372+
try {
1373+
verified = await getGlobalAgentFrozenState(c.env);
1374+
} catch (error) {
1375+
return c.json({ error: "kill_switch_verify_failed", message: errorMessage(error) }, 503);
1376+
}
1377+
if (verified.frozen !== parsed.data.frozen) {
1378+
return c.json({ error: "kill_switch_write_unconfirmed", requested: parsed.data.frozen, observed: verified.frozen }, 502);
1379+
}
1380+
await recordAuditEvent(c.env, {
1381+
eventType: "operator.kill_switch_set",
1382+
actor: actorLogin,
1383+
targetKey: "global_agent_controls#singleton",
1384+
outcome: "completed",
1385+
metadata: { frozen: verified.frozen, identityKind: identity.kind },
1386+
});
1387+
return c.json({ ok: true, ...verified });
1388+
});
1389+
13351390
app.get("/v1/app/notification-model", async (c) => {
13361391
const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]);
13371392
if (forbidden) return forbidden;

src/db/repositories.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2110,6 +2110,21 @@ export async function setGlobalAgentFrozen(env: Env, frozen: boolean, updatedBy?
21102110
.run();
21112111
}
21122112

2113+
/** Strict (non-fail-open) read of the kill-switch row, for the operator route's read-after-write verification
2114+
* (#2359) and for surfacing current state. Unlike {@link isGlobalAgentFrozen} — deliberately fail-open on the
2115+
* enforcement hot path so a D1 hiccup never silently freezes the fleet — this THROWS on a driver error or a
2116+
* missing singleton row, because here a swallowed error must surface as "could not verify", never be silently
2117+
* reported as "unfrozen". */
2118+
export async function getGlobalAgentFrozenState(env: Env): Promise<{ frozen: boolean; updatedAt: string | null; updatedBy: string | null }> {
2119+
const row = await env.DB.prepare("SELECT frozen, updated_at, updated_by FROM global_agent_controls WHERE id = 'singleton'").first<{
2120+
frozen: number;
2121+
updated_at: string | null;
2122+
updated_by: string | null;
2123+
}>();
2124+
if (!row) throw new Error("global_agent_controls has no singleton row — re-run migrations or re-seed the row");
2125+
return { frozen: row.frozen === 1, updatedAt: row.updated_at, updatedBy: row.updated_by };
2126+
}
2127+
21132128
export async function recordAuditEvent(env: Env, event: AuditEventRecord): Promise<void> {
21142129
const db = getDb(env.DB);
21152130
await db.insert(auditEvents).values({
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../../src/db/repositories", async (importOriginal) => {
4+
const actual = await importOriginal<typeof import("../../src/db/repositories")>();
5+
return {
6+
...actual,
7+
getGlobalAgentFrozenState: vi.fn(actual.getGlobalAgentFrozenState),
8+
setGlobalAgentFrozen: vi.fn(actual.setGlobalAgentFrozen),
9+
};
10+
});
11+
12+
import { createApp } from "../../src/api/routes";
13+
import { createSessionForGitHubUser } from "../../src/auth/security";
14+
import { getGlobalAgentFrozenState, setGlobalAgentFrozen } from "../../src/db/repositories";
15+
import { createTestEnv } from "../helpers/d1";
16+
17+
// #2359: setGlobalAgentFrozen previously had zero callers anywhere in src/ — the only way to flip the DB-backed
18+
// global kill-switch was raw SQL. These tests cover the new operator-only route pair that makes it operable.
19+
20+
function apiHeaders(env: Env): Record<string, string> {
21+
return { authorization: `Bearer ${env.GITTENSORY_API_TOKEN}`, "content-type": "application/json" };
22+
}
23+
24+
async function auditRows(env: Env): Promise<Array<{ actor: string; outcome: string; metadata_json: string }>> {
25+
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 {
26+
results: Array<{ actor: string; outcome: string; metadata_json: string }>;
27+
};
28+
return result.results;
29+
}
30+
31+
describe("kill-switch operator route (#2359)", () => {
32+
beforeEach(() => {
33+
vi.mocked(getGlobalAgentFrozenState).mockClear();
34+
vi.mocked(setGlobalAgentFrozen).mockClear();
35+
});
36+
37+
it("GET returns the seeded-default unfrozen state for a trusted static token", async () => {
38+
const app = createApp();
39+
const env = createTestEnv();
40+
const res = await app.request("/v1/app/kill-switch", { headers: apiHeaders(env) }, env);
41+
expect(res.status).toBe(200);
42+
await expect(res.json()).resolves.toMatchObject({ frozen: false, updatedBy: null });
43+
});
44+
45+
it("GET is forbidden for an authenticated session without the operator role", async () => {
46+
const app = createApp();
47+
const env = createTestEnv();
48+
const { token } = await createSessionForGitHubUser(env, { login: "not-an-operator", id: 501 });
49+
const res = await app.request("/v1/app/kill-switch", { headers: { cookie: `gittensory_session=${token}` } }, env);
50+
expect(res.status).toBe(403);
51+
});
52+
53+
it("GET is unauthorized with no identity at all", async () => {
54+
const app = createApp();
55+
const env = createTestEnv();
56+
const res = await app.request("/v1/app/kill-switch", {}, env);
57+
expect(res.status).toBe(401);
58+
});
59+
60+
it("GET surfaces a clear 503 (never a falsely reassuring unfrozen) when the singleton row is missing", async () => {
61+
const app = createApp();
62+
const env = createTestEnv();
63+
await env.DB.prepare("DELETE FROM global_agent_controls WHERE id = 'singleton'").run();
64+
const res = await app.request("/v1/app/kill-switch", { headers: apiHeaders(env) }, env);
65+
expect(res.status).toBe(503);
66+
await expect(res.json()).resolves.toMatchObject({ error: "kill_switch_read_failed" });
67+
});
68+
69+
it("POST freezes and unfreezes the fleet for an operator session, verifying the write and auditing it", async () => {
70+
const app = createApp();
71+
const env = createTestEnv();
72+
const { token } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 1 });
73+
const headers = { cookie: `gittensory_session=${token}`, "content-type": "application/json" };
74+
75+
const freeze = await app.request("/v1/app/kill-switch", { method: "POST", headers, body: JSON.stringify({ frozen: true }) }, env);
76+
expect(freeze.status).toBe(200);
77+
await expect(freeze.json()).resolves.toMatchObject({ ok: true, frozen: true, updatedBy: "jsonbored" });
78+
79+
const readBack = await app.request("/v1/app/kill-switch", { headers: apiHeaders(env) }, env);
80+
await expect(readBack.json()).resolves.toMatchObject({ frozen: true });
81+
82+
const unfreeze = await app.request("/v1/app/kill-switch", { method: "POST", headers, body: JSON.stringify({ frozen: false }) }, env);
83+
expect(unfreeze.status).toBe(200);
84+
await expect(unfreeze.json()).resolves.toMatchObject({ ok: true, frozen: false, updatedBy: "jsonbored" });
85+
86+
const audits = await auditRows(env);
87+
expect(audits).toHaveLength(2);
88+
expect(audits.map((row) => JSON.parse(row.metadata_json).frozen)).toEqual([false, true]);
89+
expect(audits.every((row) => row.actor === "jsonbored" && row.outcome === "completed")).toBe(true);
90+
});
91+
92+
it("POST rejects a schema-invalid body instead of silently coercing it", async () => {
93+
const app = createApp();
94+
const env = createTestEnv();
95+
const { token } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 1 });
96+
const headers = { cookie: `gittensory_session=${token}`, "content-type": "application/json" };
97+
const res = await app.request("/v1/app/kill-switch", { method: "POST", headers, body: JSON.stringify({ frozen: "yes" }) }, env);
98+
expect(res.status).toBe(400);
99+
await expect(res.json()).resolves.toMatchObject({ error: "invalid_kill_switch_update" });
100+
expect(setGlobalAgentFrozen).not.toHaveBeenCalled();
101+
});
102+
103+
it("POST rejects a body that isn't valid JSON at all", async () => {
104+
const app = createApp();
105+
const env = createTestEnv();
106+
const res = await app.request("/v1/app/kill-switch", { method: "POST", headers: apiHeaders(env), body: "{" }, env);
107+
expect(res.status).toBe(400);
108+
await expect(res.json()).resolves.toMatchObject({ error: "invalid_kill_switch_update" });
109+
expect(setGlobalAgentFrozen).not.toHaveBeenCalled();
110+
});
111+
112+
it("POST is forbidden for a non-operator session and unauthorized with no identity", async () => {
113+
const app = createApp();
114+
const env = createTestEnv();
115+
const { token } = await createSessionForGitHubUser(env, { login: "not-an-operator", id: 501 });
116+
const forbidden = await app.request(
117+
"/v1/app/kill-switch",
118+
{ method: "POST", headers: { cookie: `gittensory_session=${token}`, "content-type": "application/json" }, body: JSON.stringify({ frozen: true }) },
119+
env,
120+
);
121+
expect(forbidden.status).toBe(403);
122+
const unauthorized = await app.request("/v1/app/kill-switch", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ frozen: true }) }, env);
123+
expect(unauthorized.status).toBe(401);
124+
expect(setGlobalAgentFrozen).not.toHaveBeenCalled();
125+
});
126+
127+
it("POST surfaces a 503 (not a false success) when the post-write verification read fails", async () => {
128+
const app = createApp();
129+
const env = createTestEnv();
130+
vi.mocked(getGlobalAgentFrozenState).mockRejectedValueOnce(new Error("D1 hiccup"));
131+
const res = await app.request("/v1/app/kill-switch", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ frozen: true }) }, env);
132+
expect(res.status).toBe(503);
133+
await expect(res.json()).resolves.toMatchObject({ error: "kill_switch_verify_failed" });
134+
expect(setGlobalAgentFrozen).toHaveBeenCalledTimes(1);
135+
});
136+
137+
it("POST surfaces a 502 (not a false success) when the read-after-write observes a value that doesn't match the write", async () => {
138+
const app = createApp();
139+
const env = createTestEnv();
140+
vi.mocked(getGlobalAgentFrozenState).mockResolvedValueOnce({ frozen: false, updatedAt: null, updatedBy: null });
141+
const res = await app.request("/v1/app/kill-switch", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ frozen: true }) }, env);
142+
expect(res.status).toBe(502);
143+
await expect(res.json()).resolves.toMatchObject({ error: "kill_switch_write_unconfirmed", requested: true, observed: false });
144+
});
145+
});

0 commit comments

Comments
 (0)