Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/loopover-ui/src/lib/selfhost-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "LOOPOVER_MCP_TOKEN",
firstReference: "src/selfhost/preflight.ts",
},
{
name: "LOOPOVER_METRICS_REPO_LABELS",
firstReference: "src/server.ts",
},
{
name: "LOOPOVER_REPO_CONFIG_DIR",
firstReference: "src/server.ts",
Expand Down Expand Up @@ -481,6 +485,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "POSTHOG_API_KEY",
firstReference: "src/selfhost/otel.ts",
},
{
name: "POSTHOG_DISABLED",
firstReference: "src/selfhost/posthog.ts",
},
{
name: "POSTHOG_ENVIRONMENT",
firstReference: "src/selfhost/otel.ts",
Expand Down Expand Up @@ -703,6 +711,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `LOOPOVER_ENABLE_PAGERDUTY` | `src/services/notify-pagerduty.ts` |",
"| `LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER` | `src/selfhost/ai.ts` |",
"| `LOOPOVER_MCP_TOKEN` | `src/selfhost/preflight.ts` |",
"| `LOOPOVER_METRICS_REPO_LABELS` | `src/server.ts` |",
"| `LOOPOVER_REPO_CONFIG_DIR` | `src/server.ts` |",
"| `LOOPOVER_REVIEW_CONTINUOUS` | `src/queue/processors.ts` |",
"| `LOOPOVER_REVIEW_RAG` | `src/selfhost/ai.ts` |",
Expand Down Expand Up @@ -753,6 +762,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `PGVECTOR_ENABLED` | `src/server.ts` |",
"| `PORT` | `src/server.ts` |",
"| `POSTHOG_API_KEY` | `src/selfhost/otel.ts` |",
"| `POSTHOG_DISABLED` | `src/selfhost/posthog.ts` |",
"| `POSTHOG_ENVIRONMENT` | `src/selfhost/otel.ts` |",
"| `POSTHOG_HOST` | `src/selfhost/otel.ts` |",
"| `POSTHOG_MIN_SEVERITY` | `src/selfhost/posthog.ts` |",
Expand Down
4 changes: 4 additions & 0 deletions src/db/retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export const RETENTION_POLICY: readonly RetentionRule[] = [
{ table: "webhook_events", column: "received_at", days: 90 },
// One row per outbound notification delivery (#8899); same append-only log shape as webhook_events.
{ table: "notification_deliveries", column: "created_at", days: 90 },
// #9138: one row per loopover_predict_gate/explain_gate_disposition MCP call (unbounded, contributor-
// driven growth -- predicted-gate-calls.ts deliberately never dedups at write time, see that file's own
// header comment) -- same 90-day append-only-log window as audit_events/ai_usage_events above.
{ table: "predicted_gate_calls", column: "created_at", days: 90 },
];

export type PruneResult = { table: string; column: string; cutoff: string; deleted: number };
Expand Down
68 changes: 66 additions & 2 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
MAX_NOTIFICATION_DELIVERY_ID_LENGTH,
MAX_NOTIFICATION_MARK_READ_IDS,
markNotificationDeliveriesRead,
recordAuditEvent,
recordProductUsageEvent,
} from "../db/repositories";
import { decidePendingAgentAction } from "../services/agent-approval-queue";
Expand Down Expand Up @@ -4045,6 +4046,21 @@ export class LoopoverMcp {
}
const result =
input.scope === "global" ? await functions.writeGlobal(input.content) : await functions.writeRepo(input.repoFullName!, input.content);
// #9137: rewrites the instance's private .loopover.yml FLEET-WIDE (global scope) or per-repo -- the
// sharpest unaudited write this issue calls out (previously left only a `.bak-<timestamp>` file on disk).
// A dedicated event type, not repo.settings_updated: this is the raw config FILE the focus-manifest
// loader reads, not the DB-backed RepositorySettings row. Audited on failure too, so "who attempted a
// write, and when" is answerable even when the write itself was rejected.
await recordAuditEvent(this.env, {
eventType: "config.private_write",
actor: this.identity.actor,
targetKey: input.scope === "global" ? "global" : input.repoFullName!,
outcome: result.ok ? "success" : "error",
detail: result.ok
? `Wrote ${input.scope} config to ${result.path}${result.backupPath ? ` (backed up to ${result.backupPath})` : ""}.`
: `Failed to write ${input.scope} config: ${result.error}`,
metadata: { scope: input.scope, ...(input.repoFullName ? { repoFullName: input.repoFullName } : {}), ok: result.ok },
});
if (!result.ok) {
return {
summary: `LoopOver admin config write failed: ${result.error}`,
Expand Down Expand Up @@ -4088,6 +4104,18 @@ export class LoopoverMcp {
}
try {
const result = await trigger(input.image);
// #9137: redeploys the instance -- audited regardless of outcome so "who triggered a redeploy, and
// when" is answerable even when the companion itself reports a failed run.
await recordAuditEvent(this.env, {
eventType: "instance.redeploy_triggered",
actor: this.identity.actor,
targetKey: input.image ?? "default",
outcome: result.ok ? "success" : "error",
detail: result.ok
? `Redeploy completed successfully${input.image ? ` (${input.image})` : ""}.`
: `Redeploy failed (exit ${result.exitCode ?? "unknown"}): ${result.error ?? "see log"}.`,
metadata: { image: input.image ?? null, ok: result.ok, exitCode: result.exitCode ?? null },
});
return {
summary: result.ok
? `LoopOver redeploy: completed successfully${input.image ? ` (${input.image})` : ""}.`
Expand All @@ -4097,9 +4125,18 @@ export class LoopoverMcp {
} catch (error) {
// A connection/protocol failure to the companion itself (socket missing, timeout, unauthorized) --
// distinct from a redeploy that ran and failed (handled above via result.ok === false).
const message = error instanceof Error ? error.message : String(error);
await recordAuditEvent(this.env, {
eventType: "instance.redeploy_triggered",
actor: this.identity.actor,
targetKey: input.image ?? "default",
outcome: "error",
detail: `Could not reach the host companion: ${message}`,
metadata: { image: input.image ?? null, ok: false, exitCode: null },
});
return {
summary: `LoopOver redeploy trigger: could not reach the host companion: ${error instanceof Error ? error.message : String(error)}`,
data: { configured: true, ok: false, exitCode: null, error: error instanceof Error ? error.message : String(error) },
summary: `LoopOver redeploy trigger: could not reach the host companion: ${message}`,
data: { configured: true, ok: false, exitCode: null, error: message },
};
}
}
Expand Down Expand Up @@ -4497,6 +4534,10 @@ export class LoopoverMcp {
private async computePredictedGateVerdict(
input: z.infer<z.ZodObject<typeof predictGateShape>>,
): Promise<{ repoFullName: string; verdict: PredictedGateVerdict }> {
// #9138: shared by both loopover_predict_gate and loopover_explain_gate_disposition -- neither previously
// called enforceToolRateLimit, so the only ceiling was the shared /mcp route class (120/min), well above
// what's needed to flood predicted_gate_calls and drive the agreement metric toward 100%.
await this.enforceToolRateLimit("loopover_predict_gate");
this.requireContributorAccess(input.login);
const repoFullName = `${input.owner}/${input.repo}`;
await this.requireRepoAccess(repoFullName);
Expand Down Expand Up @@ -4873,6 +4914,19 @@ export class LoopoverMcp {
await this.requireRepoManageAccess(fullName);
const current = await getRepositorySettings(this.env, fullName);
const updated = await upsertRepositorySettings(this.env, { ...current, agentPaused: input.paused });
// #9137: mirror PUT /v1/repos/:owner/:repo/settings' own audit (src/api/routes.ts) -- this is the kill
// switch, and was previously the only mutating write in this file with no audit_events row at all.
await recordAuditEvent(this.env, {
eventType: "repo.settings_updated",
actor: this.identity.actor,
targetKey: fullName,
outcome: "success",
detail: `Agent actions ${input.paused ? "paused" : "resumed"} for ${fullName} via MCP.`,
// input.paused (never undefined, per the Zod-validated shape), not updated.agentPaused (typed
// boolean | undefined for other RepositorySettings read paths) -- upsertRepositorySettings persists
// exactly this value, so recording the request avoids an always-false `?? fallback` for TS alone.
metadata: { repoFullName: fullName, fields: ["agentPaused"], agentPaused: input.paused },
});
// #9018: a paused->live transition performs no catch-up by default. A PR that went GREEN during the pause
// window (CI-completion passes plan-and-suppress the whole time) can be permanently stranded: if it was
// ALSO already regated once before the pause, agent-sweep.ts's #never-endless-reregate rule excludes it
Expand All @@ -4897,6 +4951,16 @@ export class LoopoverMcp {
const current = await getRepositorySettings(this.env, fullName);
const autonomy = { ...current.autonomy, [input.action]: input.level };
const updated = await upsertRepositorySettings(this.env, { ...current, autonomy });
// #9137: same audit gap as setAgentPaused above -- the autonomy dial (e.g. merge: auto) is the other half
// of the kill-switch/autonomy pair this issue calls out by name.
await recordAuditEvent(this.env, {
eventType: "repo.settings_updated",
actor: this.identity.actor,
targetKey: fullName,
outcome: "success",
detail: `Set ${input.action} autonomy to ${input.level} for ${fullName} via MCP.`,
metadata: { repoFullName: fullName, fields: ["autonomy"], action: input.action, level: input.level },
});
return {
summary: `Set ${input.action} autonomy to ${input.level} for ${fullName}.`,
data: { repoFullName: fullName, action: input.action, level: input.level, autonomy: updated.autonomy },
Expand Down
Binary file modified src/review/predicted-gate-agreement.ts
Binary file not shown.
34 changes: 30 additions & 4 deletions src/selfhost/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,23 @@ export function setSelfHostedMetricsMode(isSelfHosted: boolean): void {
selfHostedMetricsMode = isSelfHosted;
}

// #9142: self-host mode used to serve the RAW repo label unchanged on PRIVATE_REPO_LABEL_METRICS -- /metrics
// is commonly scraped by an operator's reverse proxy before any application auth, and every self-hosted
// instance calls setSelfHostedMetricsMode(true) unconditionally at boot (server.ts), so every self-host
// deployment shipped raw private repo names on an effectively-unauthenticated endpoint by default. The
// pseudonym scheme below (stable per-repo redacted-N label, the SAME one ALWAYS_REDACT_REPO_LABEL_METRICS
// already uses) is now the default instead -- an operator's own dashboards still get a stable per-repo series
// to group by, just not the real name. An operator who has verified /metrics never leaves their private
// network and wants real repo names can opt back in with LOOPOVER_METRICS_REPO_LABELS=raw.
let rawSelfHostedRepoLabels = false;

/** Call ONCE at boot (self-host entrypoint only, alongside setSelfHostedMetricsMode) to opt back into raw
* (non-pseudonymized) repo labels on PRIVATE_REPO_LABEL_METRICS -- an explicit operator choice, never the
* default. Never called on the shared cloud worker. */
export function setSelfHostedRawRepoLabels(allowRaw: boolean): void {
rawSelfHostedRepoLabels = allowRaw;
}

const PRIVATE_REPO_LABEL_METRICS = new Set([
"loopover_gate_decisions_total",
"loopover_reviews_published_total",
Expand All @@ -216,7 +233,11 @@ function redactedRepoLabel(repo: string): string {
function publicLabelsForMetric(name: string, labels?: Labels): Labels | undefined {
if (!labels || !("repo" in labels)) return labels;
if (ALWAYS_REDACT_REPO_LABEL_METRICS.has(name)) return { ...labels, repo: redactedRepoLabel(labels.repo) };
if (selfHostedMetricsMode || !PRIVATE_REPO_LABEL_METRICS.has(name)) return labels;
if (!PRIVATE_REPO_LABEL_METRICS.has(name)) return labels;
// #9142: self-host mode defaults to the SAME pseudonym scheme as ALWAYS_REDACT_REPO_LABEL_METRICS (not the
// cloud path's outright strip below -- a self-hosted operator legitimately wants a stable per-repo series
// on their OWN metrics), unless the operator explicitly opted into raw labels.
if (selfHostedMetricsMode) return rawSelfHostedRepoLabels ? labels : { ...labels, repo: redactedRepoLabel(labels.repo) };
const publicLabels = { ...labels };
delete publicLabels.repo;
return Object.keys(publicLabels).length > 0 ? publicLabels : undefined;
Expand Down Expand Up @@ -299,12 +320,17 @@ export function gaugeVector(name: string, sample: GaugeVectorSample): void {
gaugeVectors.set(name, sample);
}

/** Observe a value into a histogram (created on first use). `buckets` must be ascending upper bounds. */
/** Observe a value into a histogram (created on first use). `buckets` must be ascending upper bounds.
* #9142: labels are routed through publicLabelsForMetric (the same redaction incr()/gaugeVector() already
* apply) BEFORE being used as the series key or stored on the histogram -- previously this was the one
* metric-recording path that bypassed redaction entirely, including the ALWAYS_REDACT set. No histogram
* carries a `repo` label today, so this changes nothing observable yet; it closes the gap before one does. */
export function observe(name: string, value: number, labels?: Labels, buckets: number[] = DEFAULT_BUCKETS): void {
const k = seriesKey(name, labels);
const publicLabels = publicLabelsForMetric(name, labels);
const k = seriesKey(name, publicLabels);
let h = histograms.get(k);
if (!h) {
h = { name, labels, buckets, counts: new Array(buckets.length).fill(0), sum: 0, count: 0 };
h = { name, labels: publicLabels, buckets, counts: new Array(buckets.length).fill(0), sum: 0, count: 0 };
histograms.set(k, h);
}
// Cumulative bucketing: bump every bucket whose upper bound is >= the value.
Expand Down
Loading
Loading