Skip to content

Commit 68a79cb

Browse files
authored
feat(selfhost): App checks:write + an instance-wide write kill switch (#1288)
Two cloud→self-host migration prerequisites (Phase 2), gated so the live cloud Worker is byte-identical (it never sets the new env): - The /setup App manifest declared checks:read, but the gate posts a check-run (POST /check-runs needs checks:write); GitHub 403s that write and the engine swallows it as a permission_missing warning — a silent first-review failure. The rest of the system already asserts checks:write as required (backfill/data-spine tests). Fix the manifest; existing self-host Apps must be recreated. - Add SELFHOST_DEPLOYMENT_MODE=dry-run|disabled as an instance-wide kill switch in the makeInstallationOctokit chokepoint: it forces write suppression for EVERY installation write regardless of the per-call mode, so a self-host running in parallel with the cloud App can receive the same webhooks but provably post nothing (check-run/comment/label/ merge) until cutover — without relying on every call site threading the repo mode. Unset (cloud) → no forcing → behavior unchanged. Advances the self-host migration (Phase 2 of the cutover plan).
1 parent a3edd8e commit 68a79cb

5 files changed

Lines changed: 63 additions & 6 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@
2626
# Example: GITTENSORY_REVIEW_REPOS="JSONbored/gittensory,JSONbored/awesome-claude"
2727
GITTENSORY_REVIEW_REPOS=
2828

29+
# Instance-wide write kill switch for the cloud→self-host parallel-run migration. When set to "dry-run"
30+
# (or "disabled"), EVERY GitHub write from this instance is suppressed regardless of per-repo settings —
31+
# the instance can receive webhooks and compute verdicts but posts NOTHING (no check-run/comment/label/
32+
# merge), so it can shadow the live cloud App safely until cutover. "dry-run" audits as completed-shadow;
33+
# "disabled" audits as denied. Leave empty (= live) for normal operation. Flip to live only at cutover.
34+
# SELFHOST_DEPLOYMENT_MODE=dry-run
35+
2936
# --- Per-PR capabilities (also require the repo in GITTENSORY_REVIEW_REPOS) ---
3037

3138
# Safety scan: defangs untrusted PR title/body/diff (prompt-injection

src/env.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ declare global {
5151
SCORING_TIME_DECAY_ENABLED?: string;
5252
/** #776 agent-layer GLOBAL kill-switch — when truthy, halts ALL agent actions across every repo. */
5353
AGENT_ACTIONS_PAUSED?: string;
54+
/** Self-host instance-wide write switch: "dry-run" | "disabled" forces EVERY installation write to be
55+
* suppressed regardless of per-repo mode (the cloud→self-host parallel-run kill switch). Unset = live. */
56+
SELFHOST_DEPLOYMENT_MODE?: string;
5457
GITTENSORY_AUTO_FILE_DRIFT_ISSUES?: string;
5558
GITTENSORY_DRIFT_ISSUE_REPO?: string;
5659
GITTENSORY_DRIFT_ISSUE_TOKEN?: string;

src/github/client.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,31 @@ function syntheticWriteResponse(url: string): { status: number; url: string; hea
5555
return { ...base, data: { dryRunSuppressed: true } };
5656
}
5757

58+
/**
59+
* Instance-wide self-host kill switch (#selfhost-deployment-mode). SELFHOST_DEPLOYMENT_MODE=dry-run|disabled
60+
* forces write suppression for the WHOLE instance regardless of the per-call mode — so a self-host running in
61+
* PARALLEL with the live cloud App can receive the same webhooks but provably post NOTHING (no check-run /
62+
* comment / label / merge) until an explicit cutover, without relying on every call site threading the repo mode.
63+
* Unset (the cloud Worker never sets it) → null → behavior is byte-identical to today.
64+
*/
65+
export function forcedSelfhostMode(env: { SELFHOST_DEPLOYMENT_MODE?: string | undefined }): AgentActionMode | null {
66+
const m = (env.SELFHOST_DEPLOYMENT_MODE ?? "").trim().toLowerCase();
67+
if (m === "disabled") return "paused"; // suppress + audit as denied
68+
if (m === "dry-run" || m === "dry_run") return "dry_run"; // suppress + audit as completed-shadow
69+
return null; // "live" / unset → no forcing
70+
}
71+
5872
/**
5973
* Build an installation Octokit from an ALREADY-minted token. Takes the token (not the installationId) so this
6074
* module never imports createInstallationToken — the mint stays in app.ts via raw fetch and can never be reached
6175
* by the suppression hook. `mode` defaults to "live", so the action helpers (pr-actions) that are already gated by
6276
* the executor are not double-denied; surface callers (check-run / comment / label) pass the resolved repo mode.
77+
* A SELFHOST_DEPLOYMENT_MODE override beats the per-call mode so the whole instance can be forced non-actuating.
6378
*/
6479
export function makeInstallationOctokit(env: Env, token: string, mode: AgentActionMode = "live"): Octokit {
6580
const octokit = new Octokit({ auth: token, request: { fetch: timeoutFetch } });
66-
if (mode !== "live") {
81+
const effectiveMode = forcedSelfhostMode(env) ?? mode;
82+
if (effectiveMode !== "live") {
6783
octokit.hook.wrap("request", async (request, options) => {
6884
const method = options.method.toUpperCase();
6985
if (!WRITE_METHODS.has(method)) return request(options); // reads + create-vs-update probes always run
@@ -72,9 +88,9 @@ export function makeInstallationOctokit(env: Env, token: string, mode: AgentActi
7288
eventType: "github.write.suppressed",
7389
actor: "gittensory",
7490
targetKey: url,
75-
outcome: mode === "dry_run" ? "completed" : "denied",
76-
detail: `${mode}: suppressed ${method} ${url}`,
77-
metadata: { method, url, mode },
91+
outcome: effectiveMode === "dry_run" ? "completed" : "denied",
92+
detail: `${effectiveMode}: suppressed ${method} ${url}`,
93+
metadata: { method, url, mode: effectiveMode },
7894
}).catch(
7995
/* v8 ignore next -- fail-safe: an audit-write failure never blocks the suppression itself */
8096
() => undefined,

src/selfhost/setup-wizard.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export function buildManifest(origin: string, state: string): Record<string, unk
2727
pull_requests: "write",
2828
contents: "write",
2929
issues: "write",
30-
checks: "read",
30+
// checks:write — the gate posts a check-run (POST /repos/{o}/{r}/check-runs in src/github/app.ts);
31+
// checks:read would 403 that write (swallowed as a permission_missing warning → silent first-review failure).
32+
checks: "write",
3133
metadata: "read",
3234
statuses: "read",
3335
},

test/unit/github-client.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2-
import { makeInstallationOctokit, resolveRepoActionMode, timeoutFetch } from "../../src/github/client";
2+
import { forcedSelfhostMode, makeInstallationOctokit, resolveRepoActionMode, timeoutFetch } from "../../src/github/client";
33
import { setGlobalAgentFrozen } from "../../src/db/repositories";
44
import { createTestEnv } from "../helpers/d1";
55

@@ -76,6 +76,35 @@ describe("makeInstallationOctokit", () => {
7676
});
7777
});
7878

79+
describe("forcedSelfhostMode (instance-wide self-host kill switch)", () => {
80+
it("maps SELFHOST_DEPLOYMENT_MODE to a forced action mode (else null)", () => {
81+
expect(forcedSelfhostMode({ SELFHOST_DEPLOYMENT_MODE: "dry-run" })).toBe("dry_run");
82+
expect(forcedSelfhostMode({ SELFHOST_DEPLOYMENT_MODE: "dry_run" })).toBe("dry_run"); // underscore variant
83+
expect(forcedSelfhostMode({ SELFHOST_DEPLOYMENT_MODE: "DISABLED" })).toBe("paused"); // case-insensitive
84+
expect(forcedSelfhostMode({ SELFHOST_DEPLOYMENT_MODE: "live" })).toBeNull();
85+
expect(forcedSelfhostMode({})).toBeNull();
86+
});
87+
88+
it("forces suppression for the WHOLE instance even when the caller passes mode=live", async () => {
89+
const calls: RecordedCall[] = [];
90+
stubFetchRecording(calls);
91+
const env = { ...createTestEnv(), SELFHOST_DEPLOYMENT_MODE: "dry-run" };
92+
const octokit = makeInstallationOctokit(env, "tok", "live"); // a LIVE caller…
93+
const r = await octokit.request("POST /repos/{owner}/{repo}/check-runs", { owner: "o", repo: "r", name: "Gate", head_sha: "abc" });
94+
expect(calls.some((c) => c.method === "POST")).toBe(false); // …but the instance switch suppresses it anyway
95+
expect((r.data as unknown as { id: number }).id).toBe(-1);
96+
});
97+
98+
it("'disabled' forces suppression audited as denied (vs dry-run's completed-shadow)", async () => {
99+
stubFetchRecording([]);
100+
const env = { ...createTestEnv(), SELFHOST_DEPLOYMENT_MODE: "disabled" };
101+
const octokit = makeInstallationOctokit(env, "tok", "live");
102+
await octokit.request("POST /repos/{owner}/{repo}/check-runs", { owner: "o", repo: "r", name: "Gate", head_sha: "abc" });
103+
const audit = await env.DB.prepare("SELECT outcome FROM audit_events WHERE event_type = ?").bind("github.write.suppressed").first<{ outcome: string }>();
104+
expect(audit?.outcome).toBe("denied");
105+
});
106+
});
107+
79108
describe("resolveRepoActionMode", () => {
80109
it("maps the env brake, DB freeze, per-repo pause and dry-run to the same modes the executor uses", async () => {
81110
const env = createTestEnv();

0 commit comments

Comments
 (0)