From fe64ae868e2f976c5f51f28c4789969c859132c4 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:59:33 -0700 Subject: [PATCH 1/6] feat(mcp): add the operator and fleet management tool families The management capabilities existed; they were just scattered across interfaces an agent could not reach uniformly. This adds the ops family (dead-letter list/replay/delete/purge, kill-switch get/set, operator dashboard) and the fleet family (instances, installations, enrollments), each wrapping the SAME service function its HTTP route calls so the two transports cannot drift into two behaviors with one audit trail between them. The instance and installation handlers lived inline in routes.ts, so they move to src/orb/fleet-admin.ts and both transports now call that. auth is enforced per tool from the registry's own declaration: requireOperator mirrors requireAppRole(["operator"]) and deliberately refuses the shared LOOPOVER_MCP_TOKEN, which is an end-user-obtainable CLI credential; requireInternal mirrors the /v1/internal/* middleware's bearer check. Destructive tools take a z.literal(true) confirm -- a plain boolean would let an omitted field read as false and pass -- and, where the client supports elicitation, ask before acting. A decline is a structured non-error result, and an elicitation that errors is treated as a decline rather than an implicit yes. Releasing the kill switch is the direction that needs the ceremony; engaging it is fail-safe and must not be slowed down. Two findings fixed in passing. MCP_TOOL_CATEGORIES was a hand-maintained 112-entry name-to-category map beside a registry that already carried category on every tool; it now derives, which is also what lets this issue's three new categories land in one place. Its exact-sync test loses the stale-entry half, which only had meaning while the map was hand-kept -- the map now indexes all three servers' tools, since locality says where a tool's work happens rather than which server exposes it, and a dozen local-git tools are registered on the remote server too. The half that actually caught drift, every registered tool resolving a category, stays. The map had already fallen a tool behind: loopover_admin_rotate_secret is live and tested but had no contract entry, so validate:mcp never saw it -- now migrated, with its secret name as a closed enum rather than an open string, since rotation writes to a host path. loopover_fleet_get_analytics is deliberately not added: loopover_get_fleet_analytics already wraps computeFleetAnalytics behind the same gate. Two names for one capability is how a catalog rots. The incumbent keeps its name and moves into the fleet category. --- packages/loopover-contract/src/enums.ts | 86 +++ .../loopover-contract/src/tool-definition.ts | 8 +- .../src/tools/admin-config.ts | 48 +- packages/loopover-contract/src/tools/fleet.ts | 267 ++++++++ packages/loopover-contract/src/tools/index.ts | 17 + .../src/tools/instance-ops.ts | 131 ++++ .../loopover-contract/src/tools/maintainer.ts | 2 +- packages/loopover-contract/src/tools/ops.ts | 198 ++++++ .../loopover-contract/src/tools/tenant.ts | 109 +++ src/api/routes.ts | 46 +- src/mcp/server.ts | 644 ++++++++++++++---- src/orb/fleet-admin.ts | 84 +++ test/unit/mcp-tool-categories.test.ts | 9 +- 13 files changed, 1472 insertions(+), 177 deletions(-) create mode 100644 packages/loopover-contract/src/tools/fleet.ts create mode 100644 packages/loopover-contract/src/tools/instance-ops.ts create mode 100644 packages/loopover-contract/src/tools/ops.ts create mode 100644 packages/loopover-contract/src/tools/tenant.ts create mode 100644 src/orb/fleet-admin.ts diff --git a/packages/loopover-contract/src/enums.ts b/packages/loopover-contract/src/enums.ts index 3a76376b3c..6c65bd42ec 100644 --- a/packages/loopover-contract/src/enums.ts +++ b/packages/loopover-contract/src/enums.ts @@ -100,3 +100,89 @@ export type ConfigAdminWriteScope = (typeof CONFIG_ADMIN_WRITE_SCOPES)[number]; */ export const CONFIG_ADMIN_READ_SCOPES = ["effective", ...CONFIG_ADMIN_WRITE_SCOPES] as const; export type ConfigAdminReadScope = (typeof CONFIG_ADMIN_READ_SCOPES)[number]; + +/** + * The secret files `loopover_admin_rotate_secret` may rotate (#9543). Closed on purpose: rotation writes to + * a host path through the redeploy companion, so an open string would let a caller name any file the + * companion can reach. + */ +export const ROTATABLE_SECRET_NAMES = [ + "claude_code_oauth_token", + "github_webhook_secret", + "loopover_api_token", + "loopover_mcp_token", + "loopover_mcp_admin_token", + "pagerduty_routing_key", +] as const; + +/** + * Every `/v1/internal/jobs/*` maintenance job, and which modes each offers -- the input surface of the one + * `loopover_fleet_run_job` tool that replaces ~30 bespoke per-job tools (#9522). + * + * Transcribed from the live route table and PINNED to it by test/unit/mcp-fleet-job-parity.test.ts in both + * directions: a job route added or removed without updating this fails there, which is the only thing that + * keeps a closed enum honest against a table it does not import (the contract package cannot reach src/). + */ +export const INTERNAL_JOB_NAMES = [ + "backfill-contributor-gate-history", + "backfill-pr-details", + "backfill-registered-repos", + "backfill-repo-segment", + "build-burden-forecasts", + "build-contributor-decision-packs", + "build-contributor-evidence", + "file-upstream-drift-issues", + "generate-review-recap", + "generate-signal-snapshots", + "generate-weekly-value-report", + "rag-index", + "refresh-contributor-activity", + "refresh-installation-health", + "refresh-registry", + "refresh-scoring-model", + "refresh-upstream-drift", + "regate-pr", + "repair-data-fidelity", + "rollup-product-usage", +] as const; + +export type InternalJobName = (typeof INTERNAL_JOB_NAMES)[number]; + +/** `enqueue` queues the job for the worker; `run` executes it inline and returns its result. */ +export const INTERNAL_JOB_RUN_MODES = ["enqueue", "run"] as const; +export type InternalJobRunMode = (typeof INTERNAL_JOB_RUN_MODES)[number]; + +/** Not every job offers both modes; the tool rejects an unsupported pairing with the supported list. */ +export const INTERNAL_JOB_MODES: Record = { + "backfill-contributor-gate-history": ["run"], + "backfill-pr-details": ["enqueue", "run"], + "backfill-registered-repos": ["enqueue", "run"], + "backfill-repo-segment": ["enqueue", "run"], + "build-burden-forecasts": ["enqueue"], + "build-contributor-decision-packs": ["enqueue", "run"], + "build-contributor-evidence": ["enqueue"], + "file-upstream-drift-issues": ["enqueue", "run"], + "generate-review-recap": ["enqueue", "run"], + "generate-signal-snapshots": ["enqueue", "run"], + "generate-weekly-value-report": ["enqueue", "run"], + "rag-index": ["enqueue"], + "refresh-contributor-activity": ["enqueue", "run"], + "refresh-installation-health": ["run"], + "refresh-registry": ["enqueue", "run"], + "refresh-scoring-model": ["enqueue", "run"], + "refresh-upstream-drift": ["enqueue", "run"], + "regate-pr": ["enqueue"], + "repair-data-fidelity": ["enqueue"], + "rollup-product-usage": ["enqueue", "run"], +}; + +/** + * The hosted control plane's tenant products (#9522). The routes key their registry by `${product}:${name}` + * (#8024) and reject a product-specific field paired with the wrong product, so this stays closed. + */ +export const TENANT_PRODUCTS = ["ams", "orb"] as const; +export type TenantProduct = (typeof TENANT_PRODUCTS)[number]; + +/** One doctor check's verdict (#9522). `warn` exists so a degraded-but-working instance is not reported as broken. */ +export const INSTANCE_CHECK_STATUSES = ["pass", "warn", "fail"] as const; +export type InstanceCheckStatus = (typeof INSTANCE_CHECK_STATUSES)[number]; diff --git a/packages/loopover-contract/src/tool-definition.ts b/packages/loopover-contract/src/tool-definition.ts index a23b19bead..d2390a9044 100644 --- a/packages/loopover-contract/src/tool-definition.ts +++ b/packages/loopover-contract/src/tool-definition.ts @@ -13,7 +13,13 @@ import { z } from "zod"; /** Categories a tool can belong to, mirroring the ids the servers already advertise as * `_meta.category` and the stdio CLI groups its `tools` output by. */ -export const TOOL_CATEGORIES = ["maintainer", "review", "branch", "discovery", "agent", "utility", "admin"] as const; +/** + * Canonical category order for grouped rendering: contributor-facing surfaces first, operator ones last. + * `ops`, `fleet`, and `tenant` are #9522's management families -- one instance's queue/safety controls, the + * cross-instance fleet, and the hosted control plane's tenants respectively. Kept ordered so every display + * consumer inherits one ordering instead of inventing its own. + */ +export const TOOL_CATEGORIES = ["maintainer", "review", "branch", "discovery", "agent", "utility", "admin", "ops", "fleet", "tenant"] as const; export type ToolCategory = (typeof TOOL_CATEGORIES)[number]; /** Who a caller must be for a tool to run. Mirrors the identity kinds `src/auth/security.ts` diff --git a/packages/loopover-contract/src/tools/admin-config.ts b/packages/loopover-contract/src/tools/admin-config.ts index 0d498930d2..30bd6d75c9 100644 --- a/packages/loopover-contract/src/tools/admin-config.ts +++ b/packages/loopover-contract/src/tools/admin-config.ts @@ -11,7 +11,7 @@ // Node entry ever fills. import { z } from "zod"; import { defineTool } from "../tool-definition.js"; -import { CONFIG_ADMIN_READ_SCOPES, CONFIG_ADMIN_WRITE_SCOPES } from "../enums.js"; +import { CONFIG_ADMIN_READ_SCOPES, CONFIG_ADMIN_WRITE_SCOPES, ROTATABLE_SECRET_NAMES } from "../enums.js"; /** * `repoFullName` is CONDITIONALLY required -- mandatory for scope "effective" and "repo", ignored @@ -162,3 +162,49 @@ export const adminTriggerRedeployTool = defineTool({ input: AdminTriggerRedeployInput, output: AdminTriggerRedeployOutput, }); + +/** + * `loopover_admin_rotate_secret` (#9543) -- migrated into the registry by #9522, which found it was the one + * live remote tool #9518 missed: it was registered in src/mcp/server.ts with inline shapes and had its own + * test file, but no contract entry, so validate:mcp never saw it. + * + * The VALUE rules are not cosmetic and are duplicated on purpose: the companion's own isValidSecretValue + * enforces them host-side too, so a caller gets a clear MCP-level rejection while the host still refuses + * independently if this layer is ever bypassed. src/selfhost/load-file-secrets.ts only `.trim()`s the file, + * so a label or comment line above the value silently becomes part of the credential. + */ +export const AdminRotateSecretInput = z.object({ + secret: z.enum(ROTATABLE_SECRET_NAMES), + value: z + .string() + .min(1) + .max(4096) + .refine((candidate) => !/[\r\n]/.test(candidate), "must be a single line -- a comment or label line would become part of the credential") + .refine((candidate) => candidate.trim() === candidate, "must not have leading or trailing whitespace") + .refine((candidate) => !candidate.startsWith("#"), "must not start with '#' -- that is a comment, not a credential"), +}); + +export const AdminRotateSecretOutput = z.looseObject({ + configured: z.boolean(), + ok: z.boolean().optional(), + secret: z.string().optional(), + backupPath: z.string().optional(), + error: z.string().optional(), +}); + +export type AdminRotateSecretInput = z.infer; +export type AdminRotateSecretOutput = z.infer; + +export const adminRotateSecretTool = defineTool({ + name: "loopover_admin_rotate_secret", + title: "Rotate an instance secret", + description: + "Self-hosted-operator only. Rotate one of this instance's own secret files (e.g. claude_code_oauth_token) in place on the host, via the redeploy companion (#7723) -- the app container cannot write these itself, the Compose secrets mount is read-only. The value must be the bare credential: a single line, no comment or label line, no surrounding whitespace (the loader only trims, so anything else silently becomes part of the credential). Backs the previous value up first, and writes in place so the running container's inode-pinned bind mount sees it immediately. For claude_code_oauth_token no restart is needed -- the token is re-read per AI call. Requires LOOPOVER_MCP_ADMIN_TOKEN. Returns configured=false if REDEPLOY_COMPANION_TOKEN is unset or the companion isn't reachable.", + category: "admin", + auth: "mcp-admin", + locality: "remote", + availability: "selfhost", + annotations: { readOnlyHint: false, destructiveHint: true }, + input: AdminRotateSecretInput, + output: AdminRotateSecretOutput, +}); diff --git a/packages/loopover-contract/src/tools/fleet.ts b/packages/loopover-contract/src/tools/fleet.ts new file mode 100644 index 0000000000..d95ce62795 --- /dev/null +++ b/packages/loopover-contract/src/tools/fleet.ts @@ -0,0 +1,267 @@ +// The cross-instance fleet surface (#9522). +// +// Every tool here wraps an existing `/v1/internal/*` route, which the `/v1/internal/*` middleware already +// bearer-gates with INTERNAL_JOB_TOKEN. auth "internal" is that same gate declared, so the registry is what +// the runtime enforces rather than a second, drifting description of it. +// +// availability "cloud": these read and write FLEET state -- the instance roster, installation registry, +// enrollment credentials, cross-instance analytics. A single self-hosted instance has no fleet to +// administer, and registering these there would advertise capabilities that cannot answer. +import { z } from "zod"; +import { defineTool } from "../tool-definition.js"; +import { INTERNAL_JOB_NAMES, INTERNAL_JOB_RUN_MODES } from "../enums.js"; + +// NO `loopover_fleet_get_analytics` here: `loopover_get_fleet_analytics` already exists and already wraps +// computeFleetAnalytics behind the same operator gate (#9522 catalog amendment). Adding a second name for +// one capability is how a catalog rots -- two tools, one implementation, and a model picking whichever it +// saw first. The incumbent keeps its name; only its category moved into "fleet" with this issue's families. + +export const FleetListInstancesInput = z.object({}); + +export const FleetListInstancesOutput = z.looseObject({ + instances: z.array( + z.looseObject({ + instanceId: z.string(), + registered: z.boolean(), + firstSeenAt: z.string().nullable(), + lastSeenAt: z.string().nullable(), + registeredAt: z.string().nullable(), + signalCount: z.number(), + }), + ), +}); + +export const fleetListInstancesTool = defineTool({ + name: "loopover_fleet_list_instances", + title: "List fleet instances", + description: + "Owner only. Every self-hosted ORB instance that has ingested signals, newest activity first: whether it is registered for calibration, when it was first and last seen, and how many signals it has contributed. Read-only.", + category: "fleet", + auth: "internal", + locality: "remote", + availability: "cloud", + input: FleetListInstancesInput, + output: FleetListInstancesOutput, +}); + +export const FleetRegisterInstanceInput = z.object({ + instanceId: z.string().min(1).max(200), + registered: z.boolean().optional().describe("Defaults to true. Pass false to opt an instance OUT of fleet calibration."), +}); + +/** + * `instanceSecret` is returned exactly ONCE, in plaintext, and only its hash is persisted (#9121) -- so the + * output schema names it explicitly rather than hiding it in a loose bag. A repeat register call ROTATES it, + * invalidating the previous value, which is why the description says so rather than leaving it to be + * discovered by an operator whose instance stopped ingesting. + */ +export const FleetRegisterInstanceOutput = z.looseObject({ + instanceId: z.string().optional(), + registered: z.boolean().optional(), + instanceSecret: z.string().optional().describe("Plaintext credential, returned only on this call. Copy it into the instance's ORB_COLLECTOR_INSTANCE_SECRET now."), +}); + +export const fleetRegisterInstanceTool = defineTool({ + name: "loopover_fleet_register_instance", + title: "Register a fleet instance", + description: + "Owner only. Opt a self-hosted ORB instance into (or, with registered=false, out of) fleet calibration. Registering MINTS A FRESH per-instance ingest credential and returns it once in plaintext — only its hash is stored, and calling this again rotates the credential, invalidating the previous one and breaking that instance's ingest until it is updated.", + category: "fleet", + auth: "internal", + locality: "remote", + availability: "cloud", + annotations: { readOnlyHint: false, destructiveHint: false }, + input: FleetRegisterInstanceInput, + output: FleetRegisterInstanceOutput, +}); + +export const FleetListInstallationsInput = z.object({}); + +export const FleetListInstallationsOutput = z.looseObject({}); + +export const fleetListInstallationsTool = defineTool({ + name: "loopover_fleet_list_installations", + title: "List fleet installations", + description: "Owner only. Every GitHub App installation the fleet knows about, with its recorded health. Read-only.", + category: "fleet", + auth: "internal", + locality: "remote", + availability: "cloud", + input: FleetListInstallationsInput, + output: FleetListInstallationsOutput, +}); + +export const FleetRegisterInstallationInput = z.object({ + installationId: z.number().int().positive(), + accountLogin: z.string().min(1).max(200).optional(), +}); + +export const FleetRegisterInstallationOutput = z.looseObject({}); + +export const fleetRegisterInstallationTool = defineTool({ + name: "loopover_fleet_register_installation", + title: "Register a fleet installation", + description: "Owner only. Record a GitHub App installation in the fleet registry so its repos are reachable by fleet jobs.", + category: "fleet", + auth: "internal", + locality: "remote", + availability: "cloud", + annotations: { readOnlyHint: false, destructiveHint: false }, + input: FleetRegisterInstallationInput, + output: FleetRegisterInstallationOutput, +}); + +export const FleetBackfillInstallationsInput = z.object({}); + +export const FleetBackfillInstallationsOutput = z.looseObject({}); + +export const fleetBackfillInstallationsTool = defineTool({ + name: "loopover_fleet_backfill_installations", + title: "Backfill fleet installations", + description: + "Owner only. Reconcile the installation registry against GitHub, adding installations the fleet has not recorded yet. Idempotent — re-running adds nothing new once reconciled.", + category: "fleet", + auth: "internal", + locality: "remote", + availability: "cloud", + annotations: { readOnlyHint: false, destructiveHint: false }, + input: FleetBackfillInstallationsInput, + output: FleetBackfillInstallationsOutput, +}); + +/** + * Enrollments are keyed by INSTALLATION, not instance: an enrollment authorizes a self-hosted container to + * broker tokens for a specific GitHub App installation, and the token-broker tables key on installation_id. + * Revocation is the exception -- it takes the enrollment's own id, since one installation can have had + * several over time and revoking "the installation" would be ambiguous. + */ +export const FleetIssueEnrollmentInput = z.object({ + installationId: z.number().int().positive(), + rotate: z.boolean().optional().describe("Replace a live enrollment instead of refusing. Without it, an installation that already has one is a conflict."), +}); + +/** `secret` is shown exactly ONCE; only its hash is persisted. */ +export const FleetEnrollmentOutput = z.looseObject({ + enrollId: z.string().optional(), + secret: z.string().optional().describe("Plaintext enrollment secret, returned only on this call."), + error: z.string().optional(), +}); + +export const fleetIssueEnrollmentTool = defineTool({ + name: "loopover_fleet_issue_enrollment", + title: "Issue a fleet enrollment", + description: + "Owner only. Mint a token-broker enrollment secret for a REGISTERED installation, to hand to that maintainer's self-hosted container so it brokers GitHub tokens instead of holding an App key. The secret is returned exactly once and only its hash is stored. An installation that already has a live enrollment is a conflict unless rotate=true, which replaces it and invalidates the previous secret. Requires the broker to be enabled.", + category: "fleet", + auth: "internal", + locality: "remote", + availability: "cloud", + annotations: { readOnlyHint: false, destructiveHint: false }, + input: FleetIssueEnrollmentInput, + output: FleetEnrollmentOutput, +}); + +export const FleetRotateEnrollmentInput = z.object({ installationId: z.number().int().positive() }); + +export const fleetRotateEnrollmentTool = defineTool({ + name: "loopover_fleet_rotate_enrollment", + title: "Rotate a fleet enrollment", + description: + "Owner only. Replace an installation's token-broker enrollment with a fresh secret, returned once in plaintext. The previous secret stops working immediately, so that container cannot broker tokens until it is updated. Equivalent to issuing with rotate=true.", + category: "fleet", + auth: "internal", + locality: "remote", + availability: "cloud", + annotations: { readOnlyHint: false, destructiveHint: false }, + input: FleetRotateEnrollmentInput, + output: FleetEnrollmentOutput, +}); + +export const FleetRevokeEnrollmentInput = z.object({ + // The enrollment's own id, not the installation's: one installation can have had several over time. + enrollId: z.string().min(1).max(200), + confirm: z.literal(true).describe("Must be exactly true. Revoking immediately stops that container brokering tokens."), +}); + +export const FleetRevokeEnrollmentOutput = z.looseObject({ enrollId: z.string().optional(), revoked: z.boolean().optional(), error: z.string().optional() }); + +export const fleetRevokeEnrollmentTool = defineTool({ + name: "loopover_fleet_revoke_enrollment", + title: "Revoke a fleet enrollment", + description: + "Owner only. Revoke one token-broker enrollment by its id. Works for any secret type, and is idempotent — revoking an already-revoked enrollment still reports success. Irreversible: that container immediately loses its ability to broker GitHub tokens and must be re-enrolled. Requires confirm=true.", + category: "fleet", + auth: "internal", + locality: "remote", + availability: "cloud", + annotations: { readOnlyHint: false, destructiveHint: true }, + input: FleetRevokeEnrollmentInput, + output: FleetRevokeEnrollmentOutput, +}); + +export const FleetConfigPushInput = z.object({ + confirm: z.literal(true).describe("Must be exactly true. A config push takes effect fleet-wide."), +}); + +export const FleetConfigPushOutput = z.looseObject({}); + +export const fleetConfigPushTool = defineTool({ + name: "loopover_fleet_config_push", + title: "Push config to the fleet", + description: + "Operator only. Push the current configuration out to the fleet. Takes effect on every instance that picks it up, so it is not scoped to one repo or instance. Requires confirm=true.", + category: "fleet", + auth: "operator", + locality: "remote", + availability: "cloud", + annotations: { readOnlyHint: false, destructiveHint: true }, + input: FleetConfigPushInput, + output: FleetConfigPushOutput, +}); + +/** + * ONE tool for every maintenance job, replacing the ~30 bespoke tools a per-route mapping would have + * produced (#9522). `job` is the closed enum in enums.ts and `mode` picks enqueue-vs-run-inline; not every + * job supports both, and INTERNAL_JOB_MODES records which, so an unsupported pairing is rejected with the + * supported list rather than 404ing against a route that was never there. + */ +export const FleetRunJobInput = z.object({ + job: z.enum(INTERNAL_JOB_NAMES), + mode: z.enum(INTERNAL_JOB_RUN_MODES).describe("enqueue queues the job for the worker; run executes it inline and returns its result."), + payload: z.record(z.string(), z.unknown()).optional().describe("Job-specific body, forwarded verbatim to the route."), +}); + +export const FleetRunJobOutput = z.looseObject({ + job: z.string().optional(), + mode: z.string().optional(), + unsupportedMode: z.boolean().optional().describe("True when this job does not offer the requested mode; supportedModes lists what it does offer."), + supportedModes: z.array(z.string()).optional(), + result: z.unknown().optional(), +}); + +export const fleetRunJobTool = defineTool({ + name: "loopover_fleet_run_job", + title: "Run a fleet maintenance job", + description: + "Owner only. Enqueue or inline-run one of the fleet's maintenance jobs — the interactive counterpart to the scheduled self-host-maintenance workflow, which remains the cron path. mode=enqueue queues it; mode=run executes it inline and returns its result. Not every job offers both modes; an unsupported pairing returns unsupportedMode with the list of modes that job does support.", + category: "fleet", + auth: "internal", + locality: "remote", + availability: "cloud", + annotations: { readOnlyHint: false, destructiveHint: false }, + input: FleetRunJobInput, + output: FleetRunJobOutput, +}); + +export const FLEET_TOOLS = [ + fleetListInstancesTool, + fleetRegisterInstanceTool, + fleetListInstallationsTool, + fleetRegisterInstallationTool, + fleetBackfillInstallationsTool, + fleetIssueEnrollmentTool, + fleetRotateEnrollmentTool, + fleetRevokeEnrollmentTool, + fleetConfigPushTool, + fleetRunJobTool, +] as const; diff --git a/packages/loopover-contract/src/tools/index.ts b/packages/loopover-contract/src/tools/index.ts index 502d849959..84f474694f 100644 --- a/packages/loopover-contract/src/tools/index.ts +++ b/packages/loopover-contract/src/tools/index.ts @@ -143,6 +143,12 @@ import { * every remote-server category (#9518) -- the second server migrated to completion. The stdio * server is the last one left, and has its own issue (#9537). */ +import { OPS_TOOLS } from "./ops.js"; +import { FLEET_TOOLS } from "./fleet.js"; +import { TENANT_TOOLS } from "./tenant.js"; +import { INSTANCE_OPS_TOOLS } from "./instance-ops.js"; +import { adminRotateSecretTool } from "./admin-config.js"; + export const TOOL_CONTRACTS: readonly ToolContract[] = [ getRepoContextTool, getPrReviewabilityTool, @@ -269,6 +275,13 @@ export const TOOL_CONTRACTS: readonly ToolContract[] = [ minerGovernorDecisionsTool, minerStatusTool, minerCalibrationReportTool, + // #9522's management families. Spread rather than listed one-by-one so adding a tool to a family file is + // the only edit -- the registry cannot fall behind a family the way the old hand-listed map did. + adminRotateSecretTool, + ...INSTANCE_OPS_TOOLS, + ...OPS_TOOLS, + ...FLEET_TOOLS, + ...TENANT_TOOLS, ]; const CONTRACTS_BY_NAME: ReadonlyMap = new Map( @@ -301,3 +314,7 @@ export * from "./discovery-utility.js"; export * from "./agent.js"; export * from "./local-branch.js"; export * from "./miner.js"; +export * from "./ops.js"; +export * from "./fleet.js"; +export * from "./tenant.js"; +export * from "./instance-ops.js"; diff --git a/packages/loopover-contract/src/tools/instance-ops.ts b/packages/loopover-contract/src/tools/instance-ops.ts new file mode 100644 index 0000000000..3931e5be9e --- /dev/null +++ b/packages/loopover-contract/src/tools/instance-ops.ts @@ -0,0 +1,131 @@ +// Self-host instance diagnostics (#9522): status, doctor, log tail, backup status. +// +// availability "selfhost" is physics, not policy, exactly as it is for the config-admin tools: every one of +// these needs something the Cloudflare Workers bundle cannot provide -- the container's own filesystem, its +// process uptime, the host-side redeploy companion, the backup volume. They reach those through nullable +// capability registries that only the self-host Node entry fills, so an unconfigured deployment answers +// `configured: false` rather than throwing. +// +// ORB had no `doctor` before this; the miner has had one for a long time. That asymmetry is the reason an +// operator debugging a self-hosted ORB has had to read container logs by hand. +import { z } from "zod"; +import { defineTool } from "../tool-definition.js"; +import { INSTANCE_CHECK_STATUSES } from "../enums.js"; + +export const AdminGetStatusInput = z.object({}); + +/** + * Everything here is optional under a required `configured`, matching the config-admin tools' own shape: + * one payload covers "no capability wired" and "here is the rollup", and a partially-available host (say, + * a reachable container but an unreadable manifest) fills what it can rather than failing whole. + */ +export const AdminGetStatusOutput = z.looseObject({ + configured: z.boolean(), + appVersion: z.string().nullable().optional(), + targetVersion: z.string().nullable().optional().describe("The version orb-manifest.json says this instance should be running."), + upToDate: z.boolean().optional().describe("False when appVersion lags targetVersion — the signal that a redeploy is due."), + uptimeSeconds: z.number().nullable().optional(), + ready: z.boolean().optional().describe("The /ready probe's own verdict."), + readyDetail: z.record(z.string(), z.unknown()).optional(), + components: z.array(z.looseObject({ name: z.string(), healthy: z.boolean() })).optional(), + queueDepth: z.number().nullable().optional(), + lastRedeployAt: z.string().nullable().optional(), + error: z.string().optional(), +}); + +export const adminGetStatusTool = defineTool({ + name: "loopover_admin_get_status", + title: "Read instance status", + description: + "Self-hosted-operator only. One answer for 'what is this instance running and is it healthy': app version against the orb-manifest target (and whether a redeploy is due), uptime, the /ready probe's detail, per-component health, queue depth, and the last redeploy. Read-only and redaction-scrubbed. Requires LOOPOVER_MCP_ADMIN_TOKEN; returns configured=false where the capability is not wired.", + category: "admin", + auth: "mcp-admin", + locality: "remote", + availability: "selfhost", + input: AdminGetStatusInput, + output: AdminGetStatusOutput, +}); + +export const AdminDoctorInput = z.object({}); + +/** + * A flat list of independently-reported checks, not a single pass/fail: an operator needs to know WHICH + * check failed and why, and a doctor that stops at the first failure hides the rest of the picture. Every + * check runs; `status` carries its own verdict. + */ +export const AdminDoctorOutput = z.looseObject({ + configured: z.boolean(), + ok: z.boolean().optional().describe("True when no check reported fail. Warnings do not clear it to false."), + checks: z + .array( + z.looseObject({ + name: z.string(), + status: z.enum(INSTANCE_CHECK_STATUSES), + detail: z.string().optional(), + }), + ) + .optional(), + error: z.string().optional(), +}); + +export const adminDoctorTool = defineTool({ + name: "loopover_admin_doctor", + title: "Diagnose this instance", + description: + "Self-hosted-operator only. The ORB counterpart of the miner's doctor: read-only checks over secret presence and shape, GitHub App auth, database/Redis/Qdrant reachability, the config-dir mount and LOOPOVER_REPO_CONFIG_DIR writability, broker enrollment validity, clock skew, and disk pressure. Every check runs and reports its own pass/warn/fail — nothing is mutated and nothing stops at the first failure. Requires LOOPOVER_MCP_ADMIN_TOKEN.", + category: "admin", + auth: "mcp-admin", + locality: "remote", + availability: "selfhost", + input: AdminDoctorInput, + output: AdminDoctorOutput, +}); + +export const AdminTailLogsInput = z.object({ + lines: z.number().int().min(1).max(1000).optional().describe("How many trailing lines to return. Defaults to 200, hard-capped at 1000."), + since: z.string().max(64).optional().describe("Optional lower bound, as the container runtime's own duration or timestamp form (e.g. 15m)."), +}); + +export const AdminTailLogsOutput = z.looseObject({ + configured: z.boolean(), + lines: z.array(z.string()).optional(), + truncated: z.boolean().optional().describe("True when the byte cap cut the tail shorter than the requested line count."), + error: z.string().optional(), +}); + +export const adminTailLogsTool = defineTool({ + name: "loopover_admin_tail_logs", + title: "Tail instance logs", + description: + "Self-hosted-operator only. Return a BOUNDED tail of this instance's own logs — capped by both line count and total bytes, and passed through the same redaction scrubbing every other operator surface uses before it leaves the host. There is no follow mode: this returns a snapshot and completes. Requires LOOPOVER_MCP_ADMIN_TOKEN.", + category: "admin", + auth: "mcp-admin", + locality: "remote", + availability: "selfhost", + input: AdminTailLogsInput, + output: AdminTailLogsOutput, +}); + +export const AdminGetBackupStatusInput = z.object({}); + +export const AdminGetBackupStatusOutput = z.looseObject({ + configured: z.boolean(), + lastBackupAt: z.string().nullable().optional(), + backups: z.array(z.looseObject({ name: z.string(), createdAt: z.string().nullable(), sizeBytes: z.number().nullable() })).optional(), + error: z.string().optional(), +}); + +export const adminGetBackupStatusTool = defineTool({ + name: "loopover_admin_get_backup_status", + title: "Read backup status", + description: + "Self-hosted-operator only. When this instance last backed up and what artifacts the backup container has on disk, with sizes. Read-only — it never triggers a backup. Requires LOOPOVER_MCP_ADMIN_TOKEN; returns configured=false where no backup volume is mounted.", + category: "admin", + auth: "mcp-admin", + locality: "remote", + availability: "selfhost", + input: AdminGetBackupStatusInput, + output: AdminGetBackupStatusOutput, +}); + +export const INSTANCE_OPS_TOOLS = [adminGetStatusTool, adminDoctorTool, adminTailLogsTool, adminGetBackupStatusTool] as const; diff --git a/packages/loopover-contract/src/tools/maintainer.ts b/packages/loopover-contract/src/tools/maintainer.ts index 0fa9bc2a81..b90c7734ea 100644 --- a/packages/loopover-contract/src/tools/maintainer.ts +++ b/packages/loopover-contract/src/tools/maintainer.ts @@ -487,7 +487,7 @@ export const getFleetAnalyticsTool = defineTool({ name: "loopover_get_fleet_analytics", title: "Get fleet analytics", description: "Operator-only: aggregated gate-calibration analytics across the self-host fleet -- median merge/close precision, false-positive + reversal rates, cycle-time percentiles, and per-instance outliers. Measurement only.", - category: "maintainer", + category: "fleet", auth: "operator", locality: "remote", availability: "both", diff --git a/packages/loopover-contract/src/tools/ops.ts b/packages/loopover-contract/src/tools/ops.ts new file mode 100644 index 0000000000..6e979024df --- /dev/null +++ b/packages/loopover-contract/src/tools/ops.ts @@ -0,0 +1,198 @@ +// The operator queue + safety surface (#9522). +// +// These wrap routes that already exist under `/v1/app/*` and are gated by `requireAppRole(["operator"])`. +// They add no write path of their own: each calls the SAME service function its HTTP route calls, so the +// two transports cannot drift into two behaviors, and every mutation keeps the route's audit event. +// +// availability "both": the dead-letter tools reach the queue backend through the `env.JOBS` binding mirror, +// which only the self-host queue exposes -- on Cloudflare the plain Queue binding has none of those methods +// and the route answers 501 `dead_letter_admin_unavailable`. That is a runtime capability answer, not a +// registration-time one, so the tools are registered on both deployments and report the same structured +// unavailability rather than vanishing from the catalog. +import { z } from "zod"; +import { defineTool } from "../tool-definition.js"; + +/** + * Every destructive tool in this family takes this literal (#9522 requirement 2). `z.literal(true)` and not + * `z.boolean()`: a model that omits the field, or passes `false` "to be safe", must fail the schema rather + * than silently proceed -- omission is the common failure mode, and a plain boolean makes it invisible. + */ +export const DestructiveConfirm = z.literal(true).describe("Must be exactly true. Confirms an irreversible action."); + +/** Shared by the three single-job tools; the id is the self-host queue's own row id. */ +const DeadLetterJobId = z.number().int().positive().describe("The dead-letter job's id, from loopover_ops_list_dead_letter_jobs."); + +/** + * `unavailable` is how a deployment without dead-letter admin answers -- the 501 the route returns, surfaced + * as a normal result rather than a tool error, because "this backend has no DLQ" is an answer to the + * question, not a failure to answer it. + */ +const DeadLetterUnavailable = { + unavailable: z.boolean().optional().describe("True when this deployment's queue backend exposes no dead-letter admin."), + message: z.string().optional(), +}; + +export const OpsListDeadLetterJobsInput = z.object({ + limit: z.number().int().min(1).max(100).optional(), + offset: z.number().int().min(0).optional(), +}); + +export const OpsListDeadLetterJobsOutput = z.looseObject({ + ...DeadLetterUnavailable, + generatedAt: z.string().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + total: z.number().optional(), + items: z.array(z.looseObject({ id: z.number() })).optional(), +}); + +export const opsListDeadLetterJobsTool = defineTool({ + name: "loopover_ops_list_dead_letter_jobs", + title: "List dead-letter jobs", + description: + "Operator only. Page the self-hosted queue's dead-letter table: jobs that exhausted their retries and are parked for inspection. Read-only. Returns unavailable=true on a deployment whose queue backend exposes no dead-letter admin (Cloudflare Queues).", + category: "ops", + auth: "operator", + locality: "remote", + availability: "both", + input: OpsListDeadLetterJobsInput, + output: OpsListDeadLetterJobsOutput, +}); + +export const OpsReplayDeadLetterJobInput = z.object({ id: DeadLetterJobId }); + +export const OpsReplayDeadLetterJobOutput = z.looseObject({ + ...DeadLetterUnavailable, + ok: z.boolean().optional(), + id: z.number().optional(), + notFound: z.boolean().optional(), +}); + +export const opsReplayDeadLetterJobTool = defineTool({ + name: "loopover_ops_replay_dead_letter_job", + title: "Replay a dead-letter job", + description: + "Operator only. Re-enqueue one parked dead-letter job for another attempt. Records an operator.dlq_job_replayed audit event. Returns notFound=true if the id is already gone, unavailable=true where the queue backend has no dead-letter admin.", + category: "ops", + auth: "operator", + locality: "remote", + availability: "both", + // Mutating but not destructive: replaying re-enqueues the job, it does not discard anything. + annotations: { readOnlyHint: false, destructiveHint: false }, + input: OpsReplayDeadLetterJobInput, + output: OpsReplayDeadLetterJobOutput, +}); + +export const OpsDeleteDeadLetterJobInput = z.object({ id: DeadLetterJobId, confirm: DestructiveConfirm }); + +export const OpsDeleteDeadLetterJobOutput = OpsReplayDeadLetterJobOutput; + +export const opsDeleteDeadLetterJobTool = defineTool({ + name: "loopover_ops_delete_dead_letter_job", + title: "Delete a dead-letter job", + description: + "Operator only. Permanently drop one parked dead-letter job. Irreversible — the job is not re-enqueued and its payload is gone. Requires confirm=true. Records an operator.dlq_job_deleted audit event.", + category: "ops", + auth: "operator", + locality: "remote", + availability: "both", + annotations: { readOnlyHint: false, destructiveHint: true }, + input: OpsDeleteDeadLetterJobInput, + output: OpsDeleteDeadLetterJobOutput, +}); + +export const OpsPurgeDeadLetterJobsInput = z.object({ confirm: DestructiveConfirm }); + +export const OpsPurgeDeadLetterJobsOutput = z.looseObject({ + ...DeadLetterUnavailable, + ok: z.boolean().optional(), + purged: z.number().optional(), +}); + +export const opsPurgeDeadLetterJobsTool = defineTool({ + name: "loopover_ops_purge_dead_letter_jobs", + title: "Purge every dead-letter job", + description: + "Operator only. Permanently drop ALL parked dead-letter jobs. Irreversible and unbounded — prefer deleting individual ids unless the whole table is known-garbage. Requires confirm=true. Records an operator.dlq_purged audit event.", + category: "ops", + auth: "operator", + locality: "remote", + availability: "both", + annotations: { readOnlyHint: false, destructiveHint: true }, + input: OpsPurgeDeadLetterJobsInput, + output: OpsPurgeDeadLetterJobsOutput, +}); + +export const OpsGetKillSwitchInput = z.object({}); + +export const OpsGetKillSwitchOutput = z.looseObject({ + frozen: z.boolean(), + updatedAt: z.string().nullable(), + updatedBy: z.string().nullable(), + generatedAt: z.string(), +}); + +export const opsGetKillSwitchTool = defineTool({ + name: "loopover_ops_get_kill_switch", + title: "Read the global agent kill switch", + description: + "Operator only. Report whether the global agent kill switch is engaged, and who set it when. Reads strictly — unlike the enforcement hot path, a read failure here surfaces as an error rather than a falsely reassuring 'unfrozen'.", + category: "ops", + auth: "operator", + locality: "remote", + availability: "both", + input: OpsGetKillSwitchInput, + output: OpsGetKillSwitchOutput, +}); + +export const OpsSetKillSwitchInput = z.object({ + frozen: z.boolean().describe("True freezes every agent action fleet-wide; false releases the freeze."), + // Freezing is the safe direction and needs no ceremony; UNfreezing re-arms automation across the whole + // deployment, which is the irreversible-in-effect direction, so only that one demands the literal. + confirm: z.literal(true).optional().describe("Required (exactly true) when frozen=false, which re-arms fleet-wide automation."), +}); + +export const OpsSetKillSwitchOutput = OpsGetKillSwitchOutput; + +export const opsSetKillSwitchTool = defineTool({ + name: "loopover_ops_set_kill_switch", + title: "Set the global agent kill switch", + description: + "Operator only. Engage or release the global agent kill switch. Engaging (frozen=true) halts every agent action fleet-wide immediately. RELEASING (frozen=false) re-arms automation everywhere and requires confirm=true. Records an audit event either way.", + category: "ops", + auth: "operator", + locality: "remote", + availability: "both", + annotations: { readOnlyHint: false, destructiveHint: true }, + input: OpsSetKillSwitchInput, + output: OpsSetKillSwitchOutput, +}); + +export const OpsGetOperatorDashboardInput = z.object({ + days: z.number().int().min(1).max(90).optional().describe("Trailing window in days; clamped to the dashboard's own supported range."), +}); + +export const OpsGetOperatorDashboardOutput = z.looseObject({}); + +export const opsGetOperatorDashboardTool = defineTool({ + name: "loopover_ops_get_operator_dashboard", + title: "Read the operator dashboard", + description: + "Operator only. The operator dashboard rollup over a trailing window: the same payload the HTTP dashboard route serves. Read-only.", + category: "ops", + auth: "operator", + locality: "remote", + availability: "both", + input: OpsGetOperatorDashboardInput, + output: OpsGetOperatorDashboardOutput, +}); + +export const OPS_TOOLS = [ + opsListDeadLetterJobsTool, + opsReplayDeadLetterJobTool, + opsDeleteDeadLetterJobTool, + opsPurgeDeadLetterJobsTool, + opsGetKillSwitchTool, + opsSetKillSwitchTool, + opsGetOperatorDashboardTool, +] as const; diff --git a/packages/loopover-contract/src/tools/tenant.ts b/packages/loopover-contract/src/tools/tenant.ts new file mode 100644 index 0000000000..b6c184b774 --- /dev/null +++ b/packages/loopover-contract/src/tools/tenant.ts @@ -0,0 +1,109 @@ +// The hosted control plane's tenant surface (#9522). +// +// These wrap control-plane/src/http-app.ts's `/v1/tenants*` routes. availability "cloud" is physics: the +// control plane is a separate Worker that only the hosted deployment runs, and a self-hosted instance has +// no tenants to administer. +// +// `loopover_tenant_rollout` is deliberately ABSENT. Its route is an explicit 501 (#9143): the previous +// rollout implementation silently no-opped on all four of its promised effects, and the comment there is +// blunt about why that matters -- "rollback is the control you least want to discover is fake". A rollout +// tool cannot be built on a route that changes nothing observable, and the image-side mechanism it needs +// (restart-and-repin through createContainer, or a pin baked at build time) is scoped on #9143 by this +// issue's own instruction. Adding the tool here before that mechanism exists would ship exactly the fake +// control #9143 removed. +import { z } from "zod"; +import { defineTool } from "../tool-definition.js"; +import { TENANT_PRODUCTS } from "../enums.js"; + +/** Every tenant route resolves by the same `${product}:${name}` registry key (#8024), so both are required. */ +const TenantKey = { + name: z.string().min(1).max(200), + product: z.enum(TENANT_PRODUCTS), +}; + +export const TenantCreateInput = z.object({ + ...TenantKey, + schedule: z.string().max(200).optional().describe('Cron-like schedule. Valid only for product "ams".'), + orbInstallationId: z.number().int().positive().optional().describe('GitHub App installation id. Valid only for product "orb".'), +}); + +export const TenantCreateOutput = z.looseObject({}); + +export const tenantCreateTool = defineTool({ + name: "loopover_tenant_create", + title: "Create a hosted tenant", + description: + "Control-plane admin only. Provision a hosted tenant: its container, database, and secrets. `schedule` is accepted only for product \"ams\" and `orbInstallationId` only for product \"orb\" — the route rejects the mismatched pairing rather than ignoring it.", + category: "tenant", + auth: "internal", + locality: "remote", + availability: "cloud", + annotations: { readOnlyHint: false, destructiveHint: false }, + input: TenantCreateInput, + output: TenantCreateOutput, +}); + +export const TenantListInput = z.object({}); + +export const TenantListOutput = z.looseObject({ + tenants: z.array(z.looseObject({ createdAt: z.string().optional(), updatedAt: z.string().optional() })), +}); + +export const tenantListTool = defineTool({ + name: "loopover_tenant_list", + title: "List hosted tenants", + description: + "Control-plane admin only. Every hosted tenant with its status and timestamps. Read-only, and the payload is the registry's own redacted record — it carries no tenant secrets.", + category: "tenant", + auth: "internal", + locality: "remote", + availability: "cloud", + input: TenantListInput, + output: TenantListOutput, +}); + +export const TenantSetOrbInstallationInput = z.object({ + name: z.string().min(1).max(200), + // Not the shared TenantKey enum: the route rejects any product but "orb" outright, so the schema says so. + product: z.literal("orb"), + orbInstallationId: z.number().int().positive(), +}); + +export const TenantSetOrbInstallationOutput = z.looseObject({}); + +export const tenantSetOrbInstallationTool = defineTool({ + name: "loopover_tenant_set_orb_installation", + title: "Set a tenant's ORB installation", + description: + 'Control-plane admin only. Point an existing ORB tenant at a GitHub App installation id. Valid only for product "orb".', + category: "tenant", + auth: "internal", + locality: "remote", + availability: "cloud", + annotations: { readOnlyHint: false, destructiveHint: false }, + input: TenantSetOrbInstallationInput, + output: TenantSetOrbInstallationOutput, +}); + +export const TenantDestroyInput = z.object({ + ...TenantKey, + confirm: z.literal(true).describe("Must be exactly true. Destroying a tenant tears down its container, database, and secrets."), +}); + +export const TenantDestroyOutput = z.looseObject({}); + +export const tenantDestroyTool = defineTool({ + name: "loopover_tenant_destroy", + title: "Destroy a hosted tenant", + description: + "Control-plane admin only. Tear down a hosted tenant: its container, database, and secrets. Irreversible — the tenant's data does not survive. Requires confirm=true. A tenant still provisioning is refused rather than torn down mid-flight, since there is nothing settled yet to remove.", + category: "tenant", + auth: "internal", + locality: "remote", + availability: "cloud", + annotations: { readOnlyHint: false, destructiveHint: true }, + input: TenantDestroyInput, + output: TenantDestroyOutput, +}); + +export const TENANT_TOOLS = [tenantCreateTool, tenantListTool, tenantSetOrbInstallationTool, tenantDestroyTool] as const; diff --git a/src/api/routes.ts b/src/api/routes.ts index d555c395a9..f706ecdd91 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -20,11 +20,9 @@ import { buildClearedBrowserSessionCookie, buildClearedGitHubOAuthStateCookie, buildGitHubOAuthStateCookie, - createOpaqueToken, extractBearerToken, extractBrowserSessionToken, extractCookieValue, - hashToken, isAuthorizedGitHubSessionLogin, isMcpReadRepoAllowed, isMcpReadUnscoped, @@ -156,6 +154,7 @@ import { requestAprRepoTransfer } from "../orb/apr-repo-transfer"; import { handleOrbIngest, readOrbIngestBody } from "../orb/ingest"; import { handleAmsIngest } from "../ams/ingest"; import { handleOrbWebhook } from "../orb/webhook"; +import { listFleetInstallations, listFleetInstances, registerFleetInstallation, registerFleetInstance } from "../orb/fleet-admin"; import { backfillOrbInstallations } from "../orb/installations"; import { handleOrbOAuthCallback } from "../orb/oauth"; import { @@ -4672,15 +4671,7 @@ export function createApp() { }); app.get("/v1/internal/orb/instances", async (c) => { - const rows = await c.env.DB - .prepare( - `SELECT i.instance_id AS instanceId, i.registered AS registered, i.first_seen_at AS firstSeenAt, - i.last_seen_at AS lastSeenAt, i.registered_at AS registeredAt, - (SELECT COUNT(*) FROM orb_signals s WHERE s.instance_id = i.instance_id) AS signalCount - FROM orb_instances i ORDER BY i.last_seen_at DESC`, - ) - .all<{ instanceId: string; registered: number; firstSeenAt: string; lastSeenAt: string; registeredAt: string | null; signalCount: number }>(); - return c.json({ instances: (rows.results ?? []).map((r) => ({ ...r, registered: r.registered === 1 })) }); + return c.json(await listFleetInstances(c.env)); }); // Opt an instance into (or out of) fleet calibration. Body: { instanceId, registered? } (registered @@ -4695,19 +4686,7 @@ export function createApp() { const payload = (await c.req.json().catch(() => null)) as { instanceId?: unknown; registered?: unknown } | null; const instanceId = typeof payload?.instanceId === "string" ? payload.instanceId : ""; if (!instanceId) return c.json({ error: "instanceId required" }, 400); - const registered = payload?.registered === false ? 0 : 1; - const instanceSecret = registered === 1 ? createOpaqueToken("orbis") : null; - const instanceSecretHash = instanceSecret ? await hashToken(instanceSecret) : null; - await c.env.DB - .prepare( - `INSERT INTO orb_instances (instance_id, registered, registered_at, ingest_secret_hash) VALUES (?, ?, CURRENT_TIMESTAMP, ?) - ON CONFLICT(instance_id) DO UPDATE SET registered = excluded.registered, - registered_at = CASE WHEN excluded.registered = 1 THEN CURRENT_TIMESTAMP ELSE NULL END, - ingest_secret_hash = CASE WHEN excluded.registered = 1 THEN excluded.ingest_secret_hash ELSE orb_instances.ingest_secret_hash END`, - ) - .bind(instanceId, registered, instanceSecretHash) - .run(); - return c.json({ instanceId, registered: registered === 1, ...(instanceSecret ? { instanceSecret } : {}) }); + return c.json(await registerFleetInstance(c.env, { instanceId, ...(payload?.registered === false ? { registered: false } : {}) })); }); // Central Orb GitHub App installation registry — the onboarding gate. Every installation the Orb App webhook @@ -4721,16 +4700,7 @@ export function createApp() { // JOIN: each installation has at most a handful of enrollment rows, so this stays cheap without reshaping the // one-row-per-installation result set a GROUP BY would require. app.get("/v1/internal/orb/installations", async (c) => { - const rows = await c.env.DB - .prepare( - `SELECT installation_id AS installationId, account_login AS accountLogin, account_type AS accountType, - repository_selection AS repositorySelection, registered, suspended_at AS suspendedAt, - removed_at AS removedAt, first_seen_at AS firstSeenAt, last_event_at AS lastEventAt, - (SELECT COUNT(*) FROM orb_enrollments oe WHERE oe.installation_id = orb_github_installations.installation_id AND oe.state = 'enrolled' AND oe.revoked_at IS NULL) AS liveEnrollmentCount - FROM orb_github_installations ORDER BY last_event_at DESC`, - ) - .all<{ installationId: number; accountLogin: string | null; accountType: string | null; repositorySelection: string | null; registered: number; suspendedAt: string | null; removedAt: string | null; firstSeenAt: string; lastEventAt: string; liveEnrollmentCount: number }>(); - return c.json({ installations: (rows.results ?? []).map((r) => ({ ...r, registered: r.registered === 1 })) }); + return c.json(await listFleetInstallations(c.env)); }); // Opt an installation into (or out of) the registry. Body: { installationId, registered? } (registered defaults @@ -4741,11 +4711,9 @@ export function createApp() { const payload = (await c.req.json().catch(() => null)) as { installationId?: unknown; registered?: unknown } | null; const installationId = Number(payload?.installationId); if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "installationId required" }, 400); - const existing = await c.env.DB.prepare("SELECT installation_id FROM orb_github_installations WHERE installation_id = ?").bind(installationId).first(); - if (!existing) return c.json({ error: "installation_not_found" }, 404); - const registered = payload?.registered === false ? 0 : 1; - await c.env.DB.prepare("UPDATE orb_github_installations SET registered = ?, self_enrollment_disabled = ? WHERE installation_id = ?").bind(registered, registered === 1 ? 0 : 1, installationId).run(); - return c.json({ installationId, registered: registered === 1 }); + const result = await registerFleetInstallation(c.env, { installationId, ...(payload?.registered === false ? { registered: false } : {}) }); + if ("error" in result) return c.json(result, 404); + return c.json(result); }); // Operator-triggered reconciliation of the installation registry against GitHub's authoritative install list — diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b3e45bd8e0..a99b2accd4 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -199,7 +199,55 @@ import { AgentExplainNextActionOutput, AgentStartRunInput, AgentGetRunInput, + TOOL_CONTRACTS, } from "@loopover/contract/tools"; +import { + OpsListDeadLetterJobsInput, + OpsListDeadLetterJobsOutput, + OpsReplayDeadLetterJobInput, + OpsReplayDeadLetterJobOutput, + OpsDeleteDeadLetterJobInput, + OpsDeleteDeadLetterJobOutput, + OpsPurgeDeadLetterJobsInput, + OpsPurgeDeadLetterJobsOutput, + OpsGetKillSwitchInput, + OpsGetKillSwitchOutput, + OpsSetKillSwitchInput, + OpsSetKillSwitchOutput, + OpsGetOperatorDashboardInput, + OpsGetOperatorDashboardOutput, + opsListDeadLetterJobsTool, + opsReplayDeadLetterJobTool, + opsDeleteDeadLetterJobTool, + opsPurgeDeadLetterJobsTool, + opsGetKillSwitchTool, + opsSetKillSwitchTool, + opsGetOperatorDashboardTool, + FleetListInstancesInput, + FleetListInstancesOutput, + FleetRegisterInstanceInput, + FleetRegisterInstanceOutput, + FleetListInstallationsInput, + FleetListInstallationsOutput, + FleetRegisterInstallationInput, + FleetRegisterInstallationOutput, + FleetBackfillInstallationsInput, + FleetBackfillInstallationsOutput, + FleetIssueEnrollmentInput, + FleetRotateEnrollmentInput, + FleetEnrollmentOutput, + FleetRevokeEnrollmentInput, + FleetRevokeEnrollmentOutput, + fleetListInstancesTool, + fleetRegisterInstanceTool, + fleetListInstallationsTool, + fleetRegisterInstallationTool, + fleetBackfillInstallationsTool, + fleetIssueEnrollmentTool, + fleetRotateEnrollmentTool, + fleetRevokeEnrollmentTool, +} from "@loopover/contract/tools"; +import { TOOL_CATEGORIES, type ToolCategory } from "@loopover/contract"; import { MAX_FIND_OPPORTUNITIES_LANGUAGE_LENGTH, MAX_FIND_OPPORTUNITIES_LANGUAGES, @@ -309,6 +357,9 @@ import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outco import { buildRepoOutcomeCalibration, outcomeCalibrationSummary } from "../services/outcome-calibration"; import { buildRecommendationQualityReport } from "../services/recommendation-quality-report"; import { computeFleetAnalytics } from "../orb/analytics"; +import { listFleetInstallations, listFleetInstances, registerFleetInstallation, registerFleetInstance } from "../orb/fleet-admin"; +import { backfillOrbInstallations } from "../orb/installations"; +import { ORB_SECRET_TYPE_GITHUB_TOKEN, isOrbBrokerEnabled, issueOrbEnrollment, revokeOrbEnrollment } from "../orb/broker"; import { loadMaintainerNoiseReport, maintainerNoiseSummary } from "../services/maintainer-noise"; import { buildAmsMinerCohortComparison } from "../review/ams-miner-cohort"; import { getConfigAdminFunctions } from "./private-config-admin-registry"; @@ -317,6 +368,14 @@ import { getLocalManifestReader } from "../signals/focus-manifest-loader"; import type { ConfigAdminScope } from "../selfhost/private-config"; import { buildMaintainerActivationPreview } from "../services/maintainer-activation"; import { loadLabelAudit, labelAuditSummary } from "../services/label-audit"; +import { buildOperatorDashboardPayload, clampOperatorDashboardWindowDays } from "../services/operator-dashboard"; +import { + queueDeadLetterPageFromBinding, + queueDeleteDeadLetterJobViaBinding, + queuePurgeDeadLetterJobsViaBinding, + queueReplayDeadLetterJobViaBinding, +} from "../selfhost/queue-common"; +import { getGlobalAgentFrozenState, setGlobalAgentFrozen } from "../db/repositories"; import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/maintainer-lane"; import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack"; import { buildRegistrationReadinessResponse, buildGittensorConfigRecommendationResponse } from "../api/routes"; @@ -1047,138 +1106,44 @@ async function describeMcpUsageRequest(request: Request, telemetryMetadata: Reco }; } -// #6301 — coarse tool categories so tools/list clients and the `loopover-mcp tools` CLI can group -// this server's tool surface by the repo's own conceptual groupings instead of reading one flat -// list. The ids mirror the issue's suggested surfaces: contributor discovery/planning, local-branch -// & PR prep, review/gate prediction, agent automation, maintainer/repo-owner, and registry/config -// utility. Attached to each tool as MCP `_meta.category` at registration (see createServer). -// "admin" (#7721) is the newest category: self-hosted-operator-only tools that read/write the -// instance's OWN private .loopover.yml config. Unlike every other category, its tools are only -// REGISTERED at all when LOOPOVER_MCP_ADMIN_ENABLED is truthy (see isMcpAdminEnabled below) -- every -// other category's tools always exist and are gated purely by identity/allowlist at call time. -export type McpToolCategory = "discovery" | "branch" | "review" | "agent" | "maintainer" | "utility" | "admin"; - -// Canonical category order for grouped rendering (contributor-facing surfaces first, operator ones -// last). Kept as a single source of truth so a display/grouping consumer never invents its own order. -export const MCP_TOOL_CATEGORY_IDS: readonly McpToolCategory[] = ["discovery", "branch", "review", "agent", "maintainer", "utility", "admin"]; - -// Every registered tool maps to exactly one category. Listed in registration order (matching -// createServer) so a new tool without a category entry is easy to spot in review; the -// every-tool-has-a-category test fails loudly if one is ever missed. -export const MCP_TOOL_CATEGORIES: Record = { - loopover_get_repo_context: "maintainer", - loopover_get_maintainer_noise: "maintainer", - loopover_get_ams_miner_cohort: "maintainer", - loopover_get_repo_focus_manifest: "maintainer", - loopover_refresh_repo_focus_manifest: "maintainer", - loopover_get_activation_preview: "maintainer", - loopover_get_label_audit: "maintainer", - loopover_get_maintainer_lane: "maintainer", - loopover_get_repo_onboarding_pack: "maintainer", - loopover_get_registration_readiness: "maintainer", - loopover_get_config_recommendation: "maintainer", - loopover_get_burden_forecast: "maintainer", - loopover_get_repo_outcome_patterns: "maintainer", - loopover_get_outcome_calibration: "maintainer", - loopover_get_gate_precision: "maintainer", - loopover_get_selftune_override_audit: "maintainer", - loopover_clear_selftune_override: "maintainer", - loopover_file_incident_report: "maintainer", - loopover_get_skipped_pr_audit: "maintainer", - loopover_get_fleet_analytics: "maintainer", - loopover_get_recommendation_quality: "maintainer", - loopover_simulate_open_pr_pressure: "discovery", - loopover_get_contributor_profile: "discovery", - loopover_get_decision_pack: "discovery", - loopover_monitor_open_prs: "discovery", - loopover_predict_gate: "review", - loopover_explain_gate_disposition: "review", - loopover_intake_idea: "agent", - loopover_plan_idea_claims: "agent", - loopover_build_results_payload: "agent", - loopover_build_progress_snapshot: "agent", - loopover_evaluate_escalation: "agent", - loopover_check_slop_risk: "review", - loopover_check_improvement_potential: "review", - loopover_check_test_evidence: "review", - loopover_check_issue_slop: "review", - loopover_suggest_boundary_tests: "review", - loopover_pr_outcome: "review", - loopover_get_pr_ai_review_findings: "review", - loopover_list_notifications: "utility", - loopover_mark_notifications_read: "utility", - loopover_watch_issues: "utility", - loopover_explain_repo_decision: "discovery", - loopover_preflight_pr: "discovery", - loopover_get_bounty_advisory: "discovery", - loopover_list_bounties: "discovery", - loopover_get_bounty_lifecycle: "discovery", - loopover_get_registry_changes: "utility", - loopover_get_registry_snapshot: "utility", - loopover_get_upstream_drift: "utility", - loopover_get_upstream_ruleset: "utility", - loopover_get_issue_quality: "maintainer", - loopover_get_pr_reviewability: "review", - loopover_get_pr_maintainer_packet: "review", - loopover_get_live_gate_thresholds: "maintainer", - loopover_get_gate_config_effective: "maintainer", - loopover_get_repo_settings: "maintainer", - loopover_validate_linked_issue: "discovery", - loopover_check_before_start: "discovery", - loopover_find_opportunities: "discovery", - loopover_retrieve_issue_context: "discovery", - loopover_lint_pr_text: "review", - loopover_validate_config: "utility", - loopover_preflight_local_diff: "branch", - loopover_preview_local_pr_score: "branch", - loopover_get_eligibility_plan: "discovery", - loopover_run_local_scorer: "branch", - loopover_open_pr: "agent", - loopover_file_issue: "agent", - loopover_apply_labels: "agent", - loopover_post_eligibility_comment: "agent", - loopover_post_soft_claim: "agent", - loopover_create_branch: "agent", - loopover_delete_branch: "agent", - loopover_generate_tests: "agent", - loopover_file_follow_up_issue: "agent", - loopover_close_pr: "agent", - loopover_build_plan: "agent", - loopover_plan_status: "agent", - loopover_record_step_result: "agent", - loopover_get_automation_state: "agent", - loopover_set_agent_paused: "agent", - loopover_set_action_autonomy: "agent", - loopover_propose_action: "agent", - loopover_list_pending_actions: "agent", - loopover_decide_pending_action: "agent", - loopover_refresh_repo_docs: "maintainer", - loopover_generate_contributor_issue_drafts: "maintainer", - loopover_plan_repo_issues: "maintainer", - loopover_get_agent_audit_feed: "agent", - loopover_explain_score_breakdown: "review", - loopover_explain_review_risk: "review", - loopover_compare_pr_variants: "branch", - loopover_local_status: "utility", - loopover_preflight_current_branch: "branch", - loopover_preview_current_branch_score: "branch", - loopover_rank_local_next_actions: "branch", - loopover_explain_local_blockers: "branch", - loopover_remediation_plan: "branch", - loopover_prepare_pr_packet: "branch", - loopover_draft_pr_body: "branch", - loopover_compare_local_variants: "branch", - loopover_agent_plan_next_work: "agent", - loopover_agent_start_run: "agent", - loopover_agent_get_run: "agent", - loopover_agent_explain_next_action: "agent", - loopover_agent_prepare_pr_packet: "branch", - loopover_admin_get_config: "admin", - loopover_admin_write_config: "admin", - loopover_admin_list_config_backups: "admin", - loopover_admin_trigger_redeploy: "admin", - loopover_admin_rotate_secret: "admin", -}; +// #6301 — coarse tool categories so tools/list clients and the `loopover-mcp tools` CLI can group this +// server's tool surface by the repo's own conceptual groupings instead of reading one flat list. Attached +// to each tool as MCP `_meta.category` at registration (see createServer). +// +// #9522: DERIVED from the contract registry, which already carries `category` on every entry. This was a +// hand-maintained 112-line name→category map next to a registry that said the same thing; the two happened +// to agree on every entry, but nothing made them, and the map had already fallen one tool behind +// (loopover_admin_rotate_secret, which #9518's migration missed and this issue brought into the registry). +// +// "admin" remains the one category whose tools are conditionally REGISTERED at all -- only when +// LOOPOVER_MCP_ADMIN_ENABLED is truthy (see isMcpAdminEnabled) -- where every other category's tools always +// exist and are gated purely by identity at call time. +export type McpToolCategory = ToolCategory; + +// Canonical category order for grouped rendering (contributor-facing surfaces first, operator ones last), +// owned by the contract so no display consumer invents its own order. +export const MCP_TOOL_CATEGORY_IDS: readonly McpToolCategory[] = TOOL_CATEGORIES; + +// An INDEX over the whole registry, not a list of what this server registers. It covers all three servers' +// tools because `locality` describes where a tool's work happens, not which server exposes it -- a dozen +// "local-git" tools are registered here as well as in the stdio CLI, so filtering on locality would drop +// their categories. Looking up a name this server never registers simply yields that tool's category. +export const MCP_TOOL_CATEGORIES: Record = Object.fromEntries( + TOOL_CONTRACTS.map((contract) => [contract.name, contract.category]), +); + +/** + * A contract's MCP annotations with the registry's own defaults applied (#9522). `ToolContract.annotations` + * is `Partial<...> | undefined` because an entry only states what differs from "safe, read-only", but + * `registerTool` under exactOptionalPropertyTypes will not take an `undefined` -- so the default is + * materialized here rather than at every call site. + */ +function contractAnnotations(contract: { annotations?: Partial<{ readOnlyHint: boolean; destructiveHint: boolean }> | undefined }): { + readOnlyHint: boolean; + destructiveHint: boolean; +} { + return { readOnlyHint: true, destructiveHint: false, ...contract.annotations }; +} /** Master opt-in for the "admin" tool category (#7721), default OFF. Same truthy-string convention as every * other LOOPOVER_* flag in this repo. Gates tool REGISTRATION in createServer() below; each admin tool @@ -2487,6 +2452,131 @@ export class LoopoverMcp { }), ); + // ── #9522 operator queue + safety tools ──────────────────────────── + // + // Registered on BOTH deployments (availability "both"): the dead-letter tools answer + // `unavailable: true` where the queue backend has no dead-letter admin, which is a real answer rather + // than a reason to hide the tool. Every handler enforces `auth: "operator"` itself at call time -- + // registration is not the gate, identity is. + register( + "loopover_ops_list_dead_letter_jobs", + { + description: opsListDeadLetterJobsTool.description, + inputSchema: OpsListDeadLetterJobsInput.shape, + outputSchema: OpsListDeadLetterJobsOutput, + annotations: contractAnnotations(opsListDeadLetterJobsTool), + }, + async (input) => this.toolResult(await this.opsListDeadLetterJobs(input)), + ); + register( + "loopover_ops_replay_dead_letter_job", + { + description: opsReplayDeadLetterJobTool.description, + inputSchema: OpsReplayDeadLetterJobInput.shape, + outputSchema: OpsReplayDeadLetterJobOutput, + annotations: contractAnnotations(opsReplayDeadLetterJobTool), + }, + async (input) => this.toolResult(await this.opsReplayDeadLetterJob(input)), + ); + register( + "loopover_ops_delete_dead_letter_job", + { + description: opsDeleteDeadLetterJobTool.description, + inputSchema: OpsDeleteDeadLetterJobInput.shape, + outputSchema: OpsDeleteDeadLetterJobOutput, + annotations: contractAnnotations(opsDeleteDeadLetterJobTool), + }, + async (input, extra) => this.toolResult(await this.opsDeleteDeadLetterJob(input, extra, server)), + ); + register( + "loopover_ops_purge_dead_letter_jobs", + { + description: opsPurgeDeadLetterJobsTool.description, + inputSchema: OpsPurgeDeadLetterJobsInput.shape, + outputSchema: OpsPurgeDeadLetterJobsOutput, + annotations: contractAnnotations(opsPurgeDeadLetterJobsTool), + }, + async (input, extra) => this.toolResult(await this.opsPurgeDeadLetterJobs(input, extra, server)), + ); + register( + "loopover_ops_get_kill_switch", + { + description: opsGetKillSwitchTool.description, + inputSchema: OpsGetKillSwitchInput.shape, + outputSchema: OpsGetKillSwitchOutput, + annotations: contractAnnotations(opsGetKillSwitchTool), + }, + async () => this.toolResult(await this.opsGetKillSwitch()), + ); + register( + "loopover_ops_set_kill_switch", + { + description: opsSetKillSwitchTool.description, + inputSchema: OpsSetKillSwitchInput.shape, + outputSchema: OpsSetKillSwitchOutput, + annotations: contractAnnotations(opsSetKillSwitchTool), + }, + async (input, extra) => this.toolResult(await this.opsSetKillSwitch(input, extra, server)), + ); + register( + "loopover_ops_get_operator_dashboard", + { + description: opsGetOperatorDashboardTool.description, + inputSchema: OpsGetOperatorDashboardInput.shape, + outputSchema: OpsGetOperatorDashboardOutput, + annotations: contractAnnotations(opsGetOperatorDashboardTool), + }, + async (input) => this.toolResult(await this.opsGetOperatorDashboard(input)), + ); + + // ── #9522 fleet tools ────────────────────────────────────────────── + // + // availability "cloud": these administer FLEET state a single self-hosted instance does not have. Every + // handler enforces auth "internal" itself -- the same INTERNAL_JOB_TOKEN bearer the routes' middleware + // checks -- so registration is never the gate. + register( + "loopover_fleet_list_instances", + { description: fleetListInstancesTool.description, inputSchema: FleetListInstancesInput.shape, outputSchema: FleetListInstancesOutput, annotations: contractAnnotations(fleetListInstancesTool) }, + async () => this.toolResult(await this.fleetListInstances()), + ); + register( + "loopover_fleet_register_instance", + { description: fleetRegisterInstanceTool.description, inputSchema: FleetRegisterInstanceInput.shape, outputSchema: FleetRegisterInstanceOutput, annotations: contractAnnotations(fleetRegisterInstanceTool) }, + async (input) => this.toolResult(await this.fleetRegisterInstance(input)), + ); + register( + "loopover_fleet_list_installations", + { description: fleetListInstallationsTool.description, inputSchema: FleetListInstallationsInput.shape, outputSchema: FleetListInstallationsOutput, annotations: contractAnnotations(fleetListInstallationsTool) }, + async () => this.toolResult(await this.fleetListInstallations()), + ); + register( + "loopover_fleet_register_installation", + { description: fleetRegisterInstallationTool.description, inputSchema: FleetRegisterInstallationInput.shape, outputSchema: FleetRegisterInstallationOutput, annotations: contractAnnotations(fleetRegisterInstallationTool) }, + async (input) => this.toolResult(await this.fleetRegisterInstallation(input)), + ); + register( + "loopover_fleet_backfill_installations", + { description: fleetBackfillInstallationsTool.description, inputSchema: FleetBackfillInstallationsInput.shape, outputSchema: FleetBackfillInstallationsOutput, annotations: contractAnnotations(fleetBackfillInstallationsTool) }, + async () => this.toolResult(await this.fleetBackfillInstallations()), + ); + register( + "loopover_fleet_issue_enrollment", + { description: fleetIssueEnrollmentTool.description, inputSchema: FleetIssueEnrollmentInput.shape, outputSchema: FleetEnrollmentOutput, annotations: contractAnnotations(fleetIssueEnrollmentTool) }, + async (input) => this.toolResult(await this.fleetIssueEnrollment(input)), + ); + register( + "loopover_fleet_rotate_enrollment", + { description: fleetRotateEnrollmentTool.description, inputSchema: FleetRotateEnrollmentInput.shape, outputSchema: FleetEnrollmentOutput, annotations: contractAnnotations(fleetRotateEnrollmentTool) }, + // Rotation IS issuance with rotate forced on -- one implementation, so the two can never diverge on + // what "replace the live enrollment" means. + async (input) => this.toolResult(await this.fleetIssueEnrollment({ ...input, rotate: true })), + ); + register( + "loopover_fleet_revoke_enrollment", + { description: fleetRevokeEnrollmentTool.description, inputSchema: FleetRevokeEnrollmentInput.shape, outputSchema: FleetRevokeEnrollmentOutput, annotations: contractAnnotations(fleetRevokeEnrollmentTool) }, + async (input, extra) => this.toolResult(await this.fleetRevokeEnrollment(input, extra, server)), + ); + // #2225 — read-only taxonomy discovery for AI review finding categories + severity ladder. server.registerResource( "loopover_finding_taxonomy", @@ -3194,11 +3284,301 @@ export class LoopoverMcp { * though LOOPOVER_MCP_ADMIN_ENABLED already gates whether they're registered at all. Session identities * (browser login) are never admin either -- this is a self-hosted-operator CLI/automation credential, not * something a dashboard session inherits. */ + // ── #9522 operator queue + safety handlers ───────────────────────────── + // + // Each calls the SAME service function its HTTP route calls (src/api/routes.ts's /v1/app/* handlers), so + // the two transports cannot drift into two behaviors, and each keeps the route's own audit event. The + // `null` return from every queue-common helper means "this deployment's queue backend exposes no + // dead-letter admin" -- the routes answer 501 there; these answer `unavailable: true`, because over MCP + // that is an answer to the question rather than a transport failure. + private static readonly DEAD_LETTER_UNAVAILABLE = { + unavailable: true, + message: "This deployment's queue backend does not expose dead-letter admin.", + }; + + private async opsListDeadLetterJobs(input: { limit?: number | undefined; offset?: number | undefined }): Promise { + await this.requireOperator(); + const limit = input.limit ?? 25; + const offset = input.offset ?? 0; + const page = await queueDeadLetterPageFromBinding(this.env.JOBS, limit, offset); + if (!page) return { summary: "LoopOver dead-letter admin is unavailable on this deployment.", data: { ...LoopoverMcp.DEAD_LETTER_UNAVAILABLE } }; + return { + summary: `LoopOver dead-letter queue: ${page.total} job(s) parked, showing ${page.items.length}.`, + data: { generatedAt: new Date().toISOString(), limit, offset, total: page.total, items: page.items as unknown as Record[] }, + }; + } + + private async opsReplayDeadLetterJob(input: { id: number }): Promise { + await this.requireOperator(); + const result = await queueReplayDeadLetterJobViaBinding(this.env.JOBS, input.id); + if (result === null) return { summary: "LoopOver dead-letter admin is unavailable on this deployment.", data: { ...LoopoverMcp.DEAD_LETTER_UNAVAILABLE } }; + if (result === false) return { summary: `LoopOver dead-letter job ${input.id} was not found.`, data: { notFound: true, id: input.id } }; + await recordAuditEvent(this.env, { + eventType: "operator.dlq_job_replayed", + actor: this.identity.actor, + targetKey: `selfhost_jobs#${input.id}`, + outcome: "completed", + metadata: { id: input.id, surface: "mcp" }, + }); + return { summary: `LoopOver replayed dead-letter job ${input.id}.`, data: { ok: true, id: input.id } }; + } + + private async opsDeleteDeadLetterJob(input: { id: number }, extra?: McpToolExtra, mcpServer?: McpServer): Promise { + await this.requireOperator(); + const confirmation = await this.confirmDestructive( + `Delete dead-letter job ${input.id}?`, + "The job is discarded, not re-enqueued. Its payload cannot be recovered.", + extra, + mcpServer, + ); + if (confirmation.declined) return { summary: `LoopOver left dead-letter job ${input.id} in place (declined).`, data: { declined: true, id: input.id } }; + const result = await queueDeleteDeadLetterJobViaBinding(this.env.JOBS, input.id); + if (result === null) return { summary: "LoopOver dead-letter admin is unavailable on this deployment.", data: { ...LoopoverMcp.DEAD_LETTER_UNAVAILABLE } }; + if (result === false) return { summary: `LoopOver dead-letter job ${input.id} was not found.`, data: { notFound: true, id: input.id } }; + await recordAuditEvent(this.env, { + eventType: "operator.dlq_job_deleted", + actor: this.identity.actor, + targetKey: `selfhost_jobs#${input.id}`, + outcome: "completed", + metadata: { id: input.id, surface: "mcp" }, + }); + return { summary: `LoopOver deleted dead-letter job ${input.id}.`, data: { ok: true, id: input.id } }; + } + + private async opsPurgeDeadLetterJobs(_input: unknown, extra?: McpToolExtra, mcpServer?: McpServer): Promise { + await this.requireOperator(); + const confirmation = await this.confirmDestructive( + "Purge EVERY dead-letter job?", + "All parked jobs are discarded at once. This is unbounded and cannot be recovered.", + extra, + mcpServer, + ); + if (confirmation.declined) return { summary: "LoopOver left the dead-letter queue intact (declined).", data: { declined: true } }; + const purged = await queuePurgeDeadLetterJobsViaBinding(this.env.JOBS); + if (purged === null) return { summary: "LoopOver dead-letter admin is unavailable on this deployment.", data: { ...LoopoverMcp.DEAD_LETTER_UNAVAILABLE } }; + await recordAuditEvent(this.env, { + eventType: "operator.dlq_purged", + actor: this.identity.actor, + targetKey: "selfhost_jobs#all", + outcome: "completed", + metadata: { purged, surface: "mcp" }, + }); + return { summary: `LoopOver purged ${purged} dead-letter job(s).`, data: { ok: true, purged } }; + } + + private async opsGetKillSwitch(): Promise { + await this.requireOperator(); + const state = await getGlobalAgentFrozenState(this.env); + return { + summary: state.frozen ? "LoopOver global agent kill switch is ENGAGED — every agent action is halted." : "LoopOver global agent kill switch is released.", + data: { ...state, generatedAt: new Date().toISOString() }, + }; + } + + private async opsSetKillSwitch(input: { frozen: boolean; confirm?: true | undefined }, extra?: McpToolExtra, mcpServer?: McpServer): Promise { + await this.requireOperator(); + // Only RELEASING needs the ceremony: freezing is the fail-safe direction, and an operator reaching for + // the kill switch in an incident must not be slowed down by a confirmation prompt. + if (!input.frozen) { + if (input.confirm !== true) { + throw new Error("Releasing the kill switch re-arms automation fleet-wide; pass confirm: true to proceed."); + } + const confirmation = await this.confirmDestructive( + "Release the global agent kill switch?", + "Every agent action across the deployment resumes immediately.", + extra, + mcpServer, + ); + if (confirmation.declined) { + const current = await getGlobalAgentFrozenState(this.env); + return { summary: "LoopOver left the kill switch engaged (declined).", data: { ...current, declined: true, generatedAt: new Date().toISOString() } }; + } + } + await setGlobalAgentFrozen(this.env, input.frozen, this.identity.actor); + await recordAuditEvent(this.env, { + eventType: input.frozen ? "operator.kill_switch_engaged" : "operator.kill_switch_released", + actor: this.identity.actor, + targetKey: "global_agent_kill_switch", + outcome: "completed", + metadata: { frozen: input.frozen, surface: "mcp" }, + }); + const state = await getGlobalAgentFrozenState(this.env); + return { + summary: input.frozen ? "LoopOver ENGAGED the global agent kill switch — every agent action is halted." : "LoopOver released the global agent kill switch.", + data: { ...state, generatedAt: new Date().toISOString() }, + }; + } + + private async opsGetOperatorDashboard(input: { days?: number | undefined }): Promise { + await this.requireOperator(); + const windowDays = clampOperatorDashboardWindowDays(Number(input.days)); + const payload = await buildOperatorDashboardPayload(this.env, { windowDays }); + return { summary: `LoopOver operator dashboard over the trailing ${windowDays} day(s).`, data: payload as unknown as Record }; + } + + // ── #9522 fleet handlers ────────────────────────────────────────────── + // + // Each calls the same extracted service function its `/v1/internal/*` route calls (src/orb/fleet-admin.ts, + // src/orb/broker.ts, src/orb/installations.ts). auth "internal" is enforced by requireInternal, mirroring + // the middleware's own INTERNAL_JOB_TOKEN bearer check. + private async fleetListInstances(): Promise { + this.requireInternal(); + const result = await listFleetInstances(this.env); + return { summary: `LoopOver fleet: ${result.instances.length} instance(s).`, data: result as unknown as Record }; + } + + private async fleetRegisterInstance(input: { instanceId: string; registered?: boolean | undefined }): Promise { + this.requireInternal(); + const result = await registerFleetInstance(this.env, { instanceId: input.instanceId, ...(input.registered === false ? { registered: false } : {}) }); + await recordAuditEvent(this.env, { + eventType: "operator.fleet_instance_registered", + actor: this.identity.actor, + targetKey: `orb_instances#${input.instanceId}`, + outcome: "completed", + metadata: { instanceId: input.instanceId, registered: result.registered, surface: "mcp" }, + }); + return { + summary: result.instanceSecret + ? `LoopOver registered fleet instance ${input.instanceId}. Copy its ingest secret now — it is shown only once.` + : `LoopOver set fleet instance ${input.instanceId} registered=${result.registered}.`, + data: result as unknown as Record, + }; + } + + private async fleetListInstallations(): Promise { + this.requireInternal(); + const result = await listFleetInstallations(this.env); + return { summary: `LoopOver fleet: ${result.installations.length} installation(s).`, data: result as unknown as Record }; + } + + private async fleetRegisterInstallation(input: { installationId: number; registered?: boolean | undefined }): Promise { + this.requireInternal(); + const result = await registerFleetInstallation(this.env, { installationId: input.installationId, ...(input.registered === false ? { registered: false } : {}) }); + if ("error" in result) return { summary: `LoopOver has no record of installation ${input.installationId} — it must arrive via the webhook first.`, data: { ...result } }; + await recordAuditEvent(this.env, { + eventType: "operator.fleet_installation_registered", + actor: this.identity.actor, + targetKey: `orb_github_installations#${input.installationId}`, + outcome: "completed", + metadata: { installationId: input.installationId, registered: result.registered, surface: "mcp" }, + }); + return { summary: `LoopOver set installation ${input.installationId} registered=${result.registered}.`, data: { ...result } }; + } + + private async fleetBackfillInstallations(): Promise { + this.requireInternal(); + const result = await backfillOrbInstallations(this.env); + return { summary: `LoopOver backfilled ${result.backfilled} installation(s) from GitHub.`, data: { ...result } }; + } + + private async fleetIssueEnrollment(input: { installationId: number; rotate?: boolean | undefined }): Promise { + this.requireInternal(); + if (!isOrbBrokerEnabled(this.env)) return { summary: "LoopOver token broker is not enabled on this deployment.", data: { error: "not_found" } }; + const result = await issueOrbEnrollment(this.env, input.installationId, undefined, ORB_SECRET_TYPE_GITHUB_TOKEN, { rotate: input.rotate === true }); + if ("error" in result) return { summary: `LoopOver could not issue an enrollment: ${result.error}.`, data: { ...result } }; + await recordAuditEvent(this.env, { + eventType: "operator.fleet_enrollment_issued", + actor: this.identity.actor, + targetKey: `orb_enrollments#${result.enrollId}`, + outcome: "completed", + metadata: { installationId: input.installationId, rotate: input.rotate === true, surface: "mcp" }, + }); + return { summary: `LoopOver issued enrollment ${result.enrollId}. The secret is shown only once — copy it now.`, data: result as unknown as Record }; + } + + private async fleetRevokeEnrollment(input: { enrollId: string }, extra?: McpToolExtra, mcpServer?: McpServer): Promise { + this.requireInternal(); + if (!isOrbBrokerEnabled(this.env)) return { summary: "LoopOver token broker is not enabled on this deployment.", data: { error: "not_found" } }; + const confirmation = await this.confirmDestructive( + `Revoke enrollment ${input.enrollId}?`, + "That container immediately loses its ability to broker GitHub tokens and must be re-enrolled.", + extra, + mcpServer, + ); + if (confirmation.declined) return { summary: `LoopOver left enrollment ${input.enrollId} active (declined).`, data: { declined: true, enrollId: input.enrollId } }; + const result = await revokeOrbEnrollment(this.env, input.enrollId); + if ("error" in result) return { summary: `LoopOver has no enrollment ${input.enrollId}.`, data: { ...result } }; + await recordAuditEvent(this.env, { + eventType: "operator.fleet_enrollment_revoked", + actor: this.identity.actor, + targetKey: `orb_enrollments#${input.enrollId}`, + outcome: "completed", + metadata: { enrollId: input.enrollId, surface: "mcp" }, + }); + return { summary: `LoopOver revoked enrollment ${input.enrollId}.`, data: result as unknown as Record }; + } + private requireMcpAdmin(): void { if (this.identity.kind === "static" && this.identity.actor === "mcp-admin") return; throw new Error("Forbidden: this tool requires the LOOPOVER_MCP_ADMIN_TOKEN credential."); } + /** + * The `auth: "operator"` gate (#9522), mirroring the HTTP routes' own `requireAppRole(["operator"])`. + * + * The shared static `mcp` token is explicitly NOT enough: LOOPOVER_MCP_TOKEN is an end-user-obtainable CLI + * credential, so treating it as operator would hand the kill switch and the dead-letter queue to anyone + * who can run the CLI. `api`/`internal` static identities ARE trusted -- they are operator-only Worker + * secrets that are never handed out -- and a session must actually hold the operator role. + */ + private async requireOperator(): Promise { + if (this.identity.kind === "static") { + if (this.identity.actor === "mcp" || this.identity.actor === "mcp-admin") { + throw new Error("Forbidden: this tool requires an operator session (insufficient_role)."); + } + return; + } + const summary = await loadControlPanelRoleSummary(this.env, this.identity.actor, this.identity.session?.githubUserId); + if (!summary.roles.some((role) => role === "operator")) { + throw new Error("Forbidden: this tool requires the operator role (insufficient_role)."); + } + } + + /** + * The `auth: "internal"` gate (#9522): fleet and tenant administration, mirroring the `/v1/internal/*` + * middleware's INTERNAL_JOB_TOKEN bearer check. Only the owner-held static credentials satisfy it -- no + * session role grants it, because there is no per-repo scoping that could bound fleet-wide authority. + */ + private requireInternal(): void { + if (this.identity.kind === "static" && (this.identity.actor === "internal" || this.identity.actor === "api")) return; + throw new Error("Forbidden: this tool requires the INTERNAL_JOB_TOKEN credential."); + } + + /** + * The confirmation gate every destructive tool shares (#9522 requirement 2). + * + * The schema already demands `confirm: true`, so this is the second half: where the client supports + * elicitation, ASK before doing the irreversible thing, and treat a decline as a structured non-error + * result rather than a failure -- declining is a valid answer, not a broken call. A client without + * elicitation support falls through on the schema-level confirm alone, which is the same posture the + * planning elicitation already takes. + */ + private async confirmDestructive( + action: string, + detail: string, + extra?: McpToolExtra, + mcpServer?: McpServer, + ): Promise<{ declined: boolean }> { + const elicitationCapabilities = mcpServer?.server.getClientCapabilities()?.elicitation; + const supported = Boolean(extra && elicitationCapabilities); + if (!extra || !supported) return { declined: false }; + try { + const result = await extra.sendRequest( + { + method: "elicitation/create", + params: { message: `${action}\n\n${detail}\n\nThis cannot be undone. Proceed?`, requestedSchema: { type: "object", properties: {} } }, + }, + ElicitResultSchema, + { timeout: 60_000 }, + ); + return { declined: result.action !== "accept" }; + } catch { + // A client that advertises elicitation but fails to answer must not silently become an implicit yes + // for an irreversible action -- treat the failure as a decline. + return { declined: true }; + } + } + private adminScopeRepoFullName(scope: string, repoFullName: string | undefined): string { if (scope !== "repo" && scope !== "effective") return ""; if (!repoFullName) throw new Error(`repoFullName is required when scope is "${scope}".`); diff --git a/src/orb/fleet-admin.ts b/src/orb/fleet-admin.ts new file mode 100644 index 0000000000..f1d7e8df09 --- /dev/null +++ b/src/orb/fleet-admin.ts @@ -0,0 +1,84 @@ +// Fleet administration, extracted from the `/v1/internal/orb/*` route handlers (#9522). +// +// These lived inline in src/api/routes.ts, which was fine while HTTP was the only transport. The MCP fleet +// tools are a second transport over the same capabilities, and #9522 requires one implementation behind +// both -- a copy in the tool handler is exactly how the two would drift into two behaviors with one audit +// trail between them. The routes now call these too, so the extraction is a move, not a fork. +import { createOpaqueToken, hashToken } from "../auth/security"; + +export type FleetInstance = { + instanceId: string; + registered: boolean; + firstSeenAt: string; + lastSeenAt: string; + registeredAt: string | null; + signalCount: number; +}; + +/** Every instance that has ingested signals, most recently active first. */ +export async function listFleetInstances(env: Env): Promise<{ instances: FleetInstance[] }> { + const rows = await env.DB.prepare( + `SELECT i.instance_id AS instanceId, i.registered AS registered, i.first_seen_at AS firstSeenAt, + i.last_seen_at AS lastSeenAt, i.registered_at AS registeredAt, + (SELECT COUNT(*) FROM orb_signals s WHERE s.instance_id = i.instance_id) AS signalCount + FROM orb_instances i ORDER BY i.last_seen_at DESC`, + ).all<{ instanceId: string; registered: number; firstSeenAt: string; lastSeenAt: string; registeredAt: string | null; signalCount: number }>(); + return { instances: (rows.results ?? []).map((row) => ({ ...row, registered: row.registered === 1 })) }; +} + +export type RegisterFleetInstanceResult = { instanceId: string; registered: boolean; instanceSecret?: string }; + +/** + * Opt an instance into (or out of) fleet calibration, upserting so an operator can register an instance + * that has ingested but was never recorded. + * + * #9121: registering ALSO mints a fresh per-instance ingest credential -- "registered" can only mean + * something on the risk-control write path if the identity it trusts is proven by a secret only the real + * instance holds, rather than merely claimed in the request body. The plaintext is returned ONCE and only + * its hash is persisted, so a repeat register call ROTATES it and invalidates the previous value. + */ +export async function registerFleetInstance(env: Env, input: { instanceId: string; registered?: boolean }): Promise { + const registered = input.registered === false ? 0 : 1; + const instanceSecret = registered === 1 ? createOpaqueToken("orbis") : null; + const instanceSecretHash = instanceSecret ? await hashToken(instanceSecret) : null; + await env.DB.prepare( + `INSERT INTO orb_instances (instance_id, registered, registered_at, ingest_secret_hash) VALUES (?, ?, CURRENT_TIMESTAMP, ?) + ON CONFLICT(instance_id) DO UPDATE SET registered = excluded.registered, + registered_at = CASE WHEN excluded.registered = 1 THEN CURRENT_TIMESTAMP ELSE NULL END, + ingest_secret_hash = CASE WHEN excluded.registered = 1 THEN excluded.ingest_secret_hash ELSE orb_instances.ingest_secret_hash END`, + ) + .bind(input.instanceId, registered, instanceSecretHash) + .run(); + return { instanceId: input.instanceId, registered: registered === 1, ...(instanceSecret ? { instanceSecret } : {}) }; +} + +export type FleetInstallation = { installationId: number; registered: boolean }; + +/** The central Orb App installation registry -- the onboarding gate, with each install's live enrollment count. */ +export async function listFleetInstallations(env: Env): Promise<{ installations: Record[] }> { + const rows = await env.DB.prepare( + `SELECT installation_id AS installationId, account_login AS accountLogin, account_type AS accountType, + repository_selection AS repositorySelection, registered, suspended_at AS suspendedAt, + removed_at AS removedAt, first_seen_at AS firstSeenAt, last_event_at AS lastEventAt, + (SELECT COUNT(*) FROM orb_enrollments oe WHERE oe.installation_id = orb_github_installations.installation_id AND oe.state = 'enrolled' AND oe.revoked_at IS NULL) AS liveEnrollmentCount + FROM orb_github_installations ORDER BY last_event_at DESC`, + ).all<{ registered: number } & Record>(); + return { installations: (rows.results ?? []).map((row) => ({ ...row, registered: row.registered === 1 })) }; +} + +export type RegisterFleetInstallationResult = { installationId: number; registered: boolean } | { error: "installation_not_found" }; + +/** + * Flip an already-recorded installation's registration. Deliberately refuses an UNKNOWN installation + * instead of inserting one: the webhook is what records an install, and letting an operator conjure a row + * here would register an installation the App may not actually hold. + */ +export async function registerFleetInstallation(env: Env, input: { installationId: number; registered?: boolean }): Promise { + const existing = await env.DB.prepare("SELECT installation_id FROM orb_github_installations WHERE installation_id = ?").bind(input.installationId).first(); + if (!existing) return { error: "installation_not_found" }; + const registered = input.registered === false ? 0 : 1; + await env.DB.prepare("UPDATE orb_github_installations SET registered = ?, self_enrollment_disabled = ? WHERE installation_id = ?") + .bind(registered, registered === 1 ? 0 : 1, input.installationId) + .run(); + return { installationId: input.installationId, registered: registered === 1 }; +} diff --git a/test/unit/mcp-tool-categories.test.ts b/test/unit/mcp-tool-categories.test.ts index 664b48da47..97d0deb5a1 100644 --- a/test/unit/mcp-tool-categories.test.ts +++ b/test/unit/mcp-tool-categories.test.ts @@ -43,15 +43,18 @@ describe("MCP remote server tool categorization (#6301)", () => { await client.close(); }); - it("keeps the MCP_TOOL_CATEGORIES map in exact sync with the registered tool set", async () => { + // #9522: MCP_TOOL_CATEGORIES is DERIVED from the contract registry now, not a hand-kept map, so the + // "stale entry" half of this test is gone -- an entry can no longer fall out of sync with a tool that + // exists, and the map deliberately indexes all three servers' tools (locality says where a tool's work + // happens, not which server exposes it, and a dozen "local-git" tools are registered here too). What + // still matters, and is what actually caught drift, is that every REGISTERED tool resolves a category. + it("gives every registered tool a category, matching the wire value", async () => { const { client, tools } = await listRegisteredTools(); const registered = new Set(tools.map((tool) => tool.name)); const mapped = new Set(Object.keys(MCP_TOOL_CATEGORIES)); const missingFromMap = [...registered].filter((name) => !mapped.has(name)).sort(); - const staleInMap = [...mapped].filter((name) => !registered.has(name)).sort(); expect(missingFromMap, `registered tools with no category entry: ${missingFromMap.join(", ")}`).toEqual([]); - expect(staleInMap, `category entries for tools that are no longer registered: ${staleInMap.join(", ")}`).toEqual([]); // The category surfaced over the wire matches the source-of-truth map for every tool. for (const tool of tools) { From 49b35e859131d3496c2b66ae1b0d92f28596317e Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:39:31 -0700 Subject: [PATCH 2/6] feat(mcp): drive every maintenance job from one tool, all 20 of them The first pass excluded three job routes as "not valid queue message types" and left them HTTP-only. That was a wrong reading of a real finding: nothing is invalid about them, they are PARAMETERIZED, and two simply do not use their route path as their message type. rag-index enqueues rag-index-repo, regate-pr enqueues agent-regate-pr, and backfill-contributor-gate-history and refresh-installation-health have no queue message at all because they are run-only. Deriving the message from the job name -- which the first pass would have done for the seventeen it kept -- would have silently enqueued messages the dispatcher drops for the two renamed ones. INTERNAL_JOB_SPEC now records, per job, the queue message type it really sends (null when run-only) and the modes it really offers. The tool forwards a payload, dispatches enqueue through JOBS.send and run through processJob -- the same dispatcher the queue consumer uses, so an inline run and a queued run cannot diverge -- and routes the two run-only jobs to the same functions their own /run routes call. All 20 are reachable; none are excluded. The parity test now asserts the full route table with nothing filtered out, checks each job's modes and its real message type against the route that sends it, and fails if a run-only job has no inline runner wired. The compile-time assertion widened to JobMessage["type"] | null so a bad messageType still fails the build. The management deny-path suite separates two auth contracts that are genuinely different rather than forcing one shape on both: the categorical requireOperator/requireInternal this issue added, and the pre-existing requireOperatorAccess, which honors the established wildcard opt-in (#2455) where an operator explicitly unscoping MCP_READ_REPO_ALLOWLIST declares that token may read everything. The latter is pinned to its real boundary -- a SCOPED allowlist is still refused -- instead of being exempted or "fixed" into a behavior change nobody asked for. --- packages/loopover-contract/src/enums.ts | 95 ++++---- packages/loopover-contract/src/tools/fleet.ts | 8 +- src/api/routes.ts | 47 +--- src/mcp/instance-diagnostics-registry.ts | 68 ++++++ src/mcp/server.ts | 218 +++++++++++++++++- src/orb/fleet-config-push.ts | 73 ++++++ test/unit/mcp-fleet-job-parity.test.ts | 77 +++++++ test/unit/mcp-management-tool-auth.test.ts | 142 ++++++++++++ vitest.config.ts | 1 + 9 files changed, 628 insertions(+), 101 deletions(-) create mode 100644 src/mcp/instance-diagnostics-registry.ts create mode 100644 src/orb/fleet-config-push.ts create mode 100644 test/unit/mcp-fleet-job-parity.test.ts create mode 100644 test/unit/mcp-management-tool-auth.test.ts diff --git a/packages/loopover-contract/src/enums.ts b/packages/loopover-contract/src/enums.ts index 6c65bd42ec..c1be14f7b6 100644 --- a/packages/loopover-contract/src/enums.ts +++ b/packages/loopover-contract/src/enums.ts @@ -116,65 +116,52 @@ export const ROTATABLE_SECRET_NAMES = [ ] as const; /** - * Every `/v1/internal/jobs/*` maintenance job, and which modes each offers -- the input surface of the one - * `loopover_fleet_run_job` tool that replaces ~30 bespoke per-job tools (#9522). + * Every `/v1/internal/jobs/*` maintenance job -- the input surface of the one `loopover_fleet_run_job` tool + * that replaces ~30 bespoke per-job tools (#9522). All 20 of them, with nothing excluded. * - * Transcribed from the live route table and PINNED to it by test/unit/mcp-fleet-job-parity.test.ts in both - * directions: a job route added or removed without updating this fails there, which is the only thing that - * keeps a closed enum honest against a table it does not import (the contract package cannot reach src/). + * `messageType` exists because the route path is NOT always the queue message type, and assuming it was + * would have enqueued messages the dispatcher silently drops: `rag-index` sends `rag-index-repo` and + * `regate-pr` sends `agent-regate-pr`. `null` means the job has no queue message at all -- it is run-only, + * and src/mcp/server.ts dispatches it to the same function its `/run` route calls. + * + * `modes` is per job because most offer both but seven do not; an unsupported pairing is answered with the + * supported list rather than 404'ing against a route that was never there. + * + * Pinned in every direction by test/unit/mcp-fleet-job-parity.test.ts (route table vs this table, and each + * job's modes), and each non-null messageType is asserted to be a real JobMessage type at COMPILE time in + * src/mcp/server.ts -- the contract package cannot import src/, so a transcription is only honest if + * something on the src side checks it. */ -export const INTERNAL_JOB_NAMES = [ - "backfill-contributor-gate-history", - "backfill-pr-details", - "backfill-registered-repos", - "backfill-repo-segment", - "build-burden-forecasts", - "build-contributor-decision-packs", - "build-contributor-evidence", - "file-upstream-drift-issues", - "generate-review-recap", - "generate-signal-snapshots", - "generate-weekly-value-report", - "rag-index", - "refresh-contributor-activity", - "refresh-installation-health", - "refresh-registry", - "refresh-scoring-model", - "refresh-upstream-drift", - "regate-pr", - "repair-data-fidelity", - "rollup-product-usage", -] as const; - -export type InternalJobName = (typeof INTERNAL_JOB_NAMES)[number]; - -/** `enqueue` queues the job for the worker; `run` executes it inline and returns its result. */ export const INTERNAL_JOB_RUN_MODES = ["enqueue", "run"] as const; export type InternalJobRunMode = (typeof INTERNAL_JOB_RUN_MODES)[number]; -/** Not every job offers both modes; the tool rejects an unsupported pairing with the supported list. */ -export const INTERNAL_JOB_MODES: Record = { - "backfill-contributor-gate-history": ["run"], - "backfill-pr-details": ["enqueue", "run"], - "backfill-registered-repos": ["enqueue", "run"], - "backfill-repo-segment": ["enqueue", "run"], - "build-burden-forecasts": ["enqueue"], - "build-contributor-decision-packs": ["enqueue", "run"], - "build-contributor-evidence": ["enqueue"], - "file-upstream-drift-issues": ["enqueue", "run"], - "generate-review-recap": ["enqueue", "run"], - "generate-signal-snapshots": ["enqueue", "run"], - "generate-weekly-value-report": ["enqueue", "run"], - "rag-index": ["enqueue"], - "refresh-contributor-activity": ["enqueue", "run"], - "refresh-installation-health": ["run"], - "refresh-registry": ["enqueue", "run"], - "refresh-scoring-model": ["enqueue", "run"], - "refresh-upstream-drift": ["enqueue", "run"], - "regate-pr": ["enqueue"], - "repair-data-fidelity": ["enqueue"], - "rollup-product-usage": ["enqueue", "run"], -}; +export const INTERNAL_JOB_SPEC = { + "backfill-contributor-gate-history": { messageType: null, modes: ["run"] }, + "backfill-pr-details": { messageType: "backfill-pr-details", modes: ["enqueue", "run"] }, + "backfill-registered-repos": { messageType: "backfill-registered-repos", modes: ["enqueue", "run"] }, + "backfill-repo-segment": { messageType: "backfill-repo-segment", modes: ["enqueue", "run"] }, + "build-burden-forecasts": { messageType: "build-burden-forecasts", modes: ["enqueue"] }, + "build-contributor-decision-packs": { messageType: "build-contributor-decision-packs", modes: ["enqueue", "run"] }, + "build-contributor-evidence": { messageType: "build-contributor-evidence", modes: ["enqueue"] }, + "file-upstream-drift-issues": { messageType: "file-upstream-drift-issues", modes: ["enqueue", "run"] }, + "generate-review-recap": { messageType: "generate-review-recap", modes: ["enqueue", "run"] }, + "generate-signal-snapshots": { messageType: "generate-signal-snapshots", modes: ["enqueue", "run"] }, + "generate-weekly-value-report": { messageType: "generate-weekly-value-report", modes: ["enqueue", "run"] }, + "rag-index": { messageType: "rag-index-repo", modes: ["enqueue"] }, + "refresh-contributor-activity": { messageType: "refresh-contributor-activity", modes: ["enqueue", "run"] }, + "refresh-installation-health": { messageType: null, modes: ["run"] }, + "refresh-registry": { messageType: "refresh-registry", modes: ["enqueue", "run"] }, + "refresh-scoring-model": { messageType: "refresh-scoring-model", modes: ["enqueue", "run"] }, + "refresh-upstream-drift": { messageType: "refresh-upstream-drift", modes: ["enqueue", "run"] }, + "regate-pr": { messageType: "agent-regate-pr", modes: ["enqueue"] }, + "repair-data-fidelity": { messageType: "repair-data-fidelity", modes: ["enqueue"] }, + "rollup-product-usage": { messageType: "rollup-product-usage", modes: ["enqueue", "run"] }, +} as const satisfies Record; + +export const INTERNAL_JOB_NAMES = Object.keys(INTERNAL_JOB_SPEC) as [InternalJobName, ...InternalJobName[]]; + +export type InternalJobName = keyof typeof INTERNAL_JOB_SPEC; + /** * The hosted control plane's tenant products (#9522). The routes key their registry by `${product}:${name}` diff --git a/packages/loopover-contract/src/tools/fleet.ts b/packages/loopover-contract/src/tools/fleet.ts index d95ce62795..2e35e863ba 100644 --- a/packages/loopover-contract/src/tools/fleet.ts +++ b/packages/loopover-contract/src/tools/fleet.ts @@ -199,8 +199,14 @@ export const fleetRevokeEnrollmentTool = defineTool({ output: FleetRevokeEnrollmentOutput, }); +/** Mirrors the route's own configPushSchema, which is `.strict()` -- an unknown field is a caller error. */ export const FleetConfigPushInput = z.object({ - confirm: z.literal(true).describe("Must be exactly true. A config push takes effect fleet-wide."), + installationIds: z.array(z.number().int().positive()).min(1).max(500).describe("Explicit targets. There is no implicit fan-out to the whole fleet."), + pushId: z.string().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/).describe("Caller-chosen id for this push, so a repeat is recognizable."), + message: z.string().min(1).max(500), + capability: z.string().min(1).max(120).optional(), + deprecatesAt: z.string().max(64).optional().describe("ISO-8601 timestamp after which the pushed capability is deprecated."), + confirm: z.literal(true).describe("Must be exactly true. A config push reaches every installation listed."), }); export const FleetConfigPushOutput = z.looseObject({}); diff --git a/src/api/routes.ts b/src/api/routes.ts index f706ecdd91..9d2c71494a 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -155,6 +155,7 @@ import { handleOrbIngest, readOrbIngestBody } from "../orb/ingest"; import { handleAmsIngest } from "../ams/ingest"; import { handleOrbWebhook } from "../orb/webhook"; import { listFleetInstallations, listFleetInstances, registerFleetInstallation, registerFleetInstance } from "../orb/fleet-admin"; +import { pushFleetConfig } from "../orb/fleet-config-push"; import { backfillOrbInstallations } from "../orb/installations"; import { handleOrbOAuthCallback } from "../orb/oauth"; import { @@ -167,9 +168,7 @@ import { revokeOrbEnrollment, } from "../orb/broker"; import { - enqueueConfigPushRelay, MAX_ORB_RELAY_REGISTER_BODY_BYTES, - pruneRelayPending, pullRelayPending, readOrbRelayRegisterBody, registerValidatedOrbRelay, @@ -2170,49 +2169,7 @@ export function createApp() { const body = await c.req.json().catch(() => null); const parsed = configPushSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_config_push", issues: parsed.error.issues }, 400); - const { installationIds, ...payload } = parsed.data; - // #7611 review fix: prune ONCE for the whole request, not once per target -- enqueueConfigPushRelay no - // longer prunes itself (see its own doc comment) precisely so a 500-installation fan-out below can't turn - // into 500 redundant global TTL-prune scans/deletes against the shared orb_relay_pending table. - await pruneRelayPending(c.env); - // #8880: isolate each per-installation enqueue. A bare Promise.all let one target's throw abort the whole - // fan-out -- the audit event AND the response for the other ~499 installations were silently dropped, and - // there was no route-level try/catch to salvage them. Catch each target's failure inside its own fan-out - // task (mirroring pollPendingAprRepoTransfers' per-item isolation in src/orb/apr-repo-transfer.ts) so one - // bad row can't sink the batch, audit each failure so it is never silently swallowed, and still report the - // successful targets plus the partial failure to the caller. - const settled = await Promise.all( - installationIds.map(async (installationId): Promise<{ installationId: number; error?: string }> => { - try { - await enqueueConfigPushRelay(c.env, installationId, payload); - return { installationId }; - } catch (error) { - return { installationId, error: errorMessage(error) }; - } - }), - ); - const failedInstallationIds: number[] = []; - for (const result of settled) { - if (result.error !== undefined) { - failedInstallationIds.push(result.installationId); - await recordAuditEvent(c.env, { - eventType: "operator.config_push_target_failed", - actor: identity.actor, - targetKey: `config_push#${parsed.data.pushId}#${result.installationId}`, - outcome: "error", - metadata: { installationId: result.installationId, pushId: parsed.data.pushId, message: result.error }, - }); - } - } - const succeededCount = installationIds.length - failedInstallationIds.length; - await recordAuditEvent(c.env, { - eventType: "operator.config_push_enqueued", - actor: identity.actor, - targetKey: `config_push#${parsed.data.pushId}`, - outcome: failedInstallationIds.length > 0 ? "error" : "completed", - metadata: { installationCount: installationIds.length, succeededCount, failedCount: failedInstallationIds.length, capability: payload.capability ?? null }, - }); - return c.json({ ok: true, pushId: parsed.data.pushId, installationCount: installationIds.length, succeededCount, failedInstallationIds }); + return c.json(await pushFleetConfig(c.env, identity.actor, parsed.data)); }); // #5672 post-merge incident report, internal-operator side: same reporting path as the repo-scoped customer diff --git a/src/mcp/instance-diagnostics-registry.ts b/src/mcp/instance-diagnostics-registry.ts new file mode 100644 index 0000000000..3d4479f668 --- /dev/null +++ b/src/mcp/instance-diagnostics-registry.ts @@ -0,0 +1,68 @@ +// Workers-safe registry for the self-host instance-diagnostics capabilities (#9522), mirroring +// src/mcp/redeploy-companion-registry.ts and private-config-admin-registry.ts exactly: nullable function +// slots, and this module never imports a node builtin itself, so it is safe in the Cloudflare Workers +// bundle. Only the self-host Node entry (src/server.ts) fills the slots. +// +// Four independent slots rather than one object, for the same reason the redeploy trigger and the secret +// rotator are separate: a host that can report status but has no backup volume mounted must leave +// `backupStatus` null and answer "not configured" for that one tool, not fail all four. +// +// Unset (cloud, or a self-host deployment without the capability wired) means the slot stays null and +// src/mcp/server.ts's admin tool reports a structured `configured: false` rather than throwing. + +/** App version vs the orb-manifest target, uptime, readiness, component health, queue depth. */ +export type InstanceStatusReader = () => Promise>; + +/** The read-only check battery: secrets, GitHub auth, datastore reachability, mounts, skew, disk. */ +export type InstanceDoctorRunner = () => Promise<{ ok: boolean; checks: { name: string; status: "pass" | "warn" | "fail"; detail?: string }[] }>; + +/** A BOUNDED log tail. The implementation is responsible for the byte cap and the redaction scrub. */ +export type InstanceLogTailer = (options: { lines: number; since?: string | undefined }) => Promise<{ lines: string[]; truncated: boolean }>; + +/** Backup artifacts on disk, newest first. */ +export type InstanceBackupStatusReader = () => Promise>; + +let statusReader: InstanceStatusReader | null = null; +let doctorRunner: InstanceDoctorRunner | null = null; +let logTailer: InstanceLogTailer | null = null; +let backupStatusReader: InstanceBackupStatusReader | null = null; + +export function setInstanceStatusReader(reader: InstanceStatusReader | null): void { + statusReader = reader; +} + +export function getInstanceStatusReader(): InstanceStatusReader | null { + return statusReader; +} + +export function setInstanceDoctorRunner(runner: InstanceDoctorRunner | null): void { + doctorRunner = runner; +} + +export function getInstanceDoctorRunner(): InstanceDoctorRunner | null { + return doctorRunner; +} + +export function setInstanceLogTailer(tailer: InstanceLogTailer | null): void { + logTailer = tailer; +} + +export function getInstanceLogTailer(): InstanceLogTailer | null { + return logTailer; +} + +export function setInstanceBackupStatusReader(reader: InstanceBackupStatusReader | null): void { + backupStatusReader = reader; +} + +export function getInstanceBackupStatusReader(): InstanceBackupStatusReader | null { + return backupStatusReader; +} + +/** Test-only: drop every slot so one test's wiring cannot leak into the next. */ +export function resetInstanceDiagnosticsForTesting(): void { + statusReader = null; + doctorRunner = null; + logTailer = null; + backupStatusReader = null; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a99b2accd4..2aa8c6e74d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -246,6 +246,24 @@ import { fleetIssueEnrollmentTool, fleetRotateEnrollmentTool, fleetRevokeEnrollmentTool, + AdminGetStatusInput, + AdminGetStatusOutput, + AdminDoctorInput, + AdminDoctorOutput, + AdminTailLogsInput, + AdminTailLogsOutput, + AdminGetBackupStatusInput, + AdminGetBackupStatusOutput, + adminGetStatusTool, + adminDoctorTool, + adminTailLogsTool, + adminGetBackupStatusTool, + FleetConfigPushInput, + FleetConfigPushOutput, + FleetRunJobInput, + FleetRunJobOutput, + fleetConfigPushTool, + fleetRunJobTool, } from "@loopover/contract/tools"; import { TOOL_CATEGORIES, type ToolCategory } from "@loopover/contract"; import { @@ -326,7 +344,7 @@ import { } from "../db/repositories"; import { decidePendingAgentAction } from "../services/agent-approval-queue"; import { automationStateSummary, buildAutomationState } from "../services/automation-state"; -import { nowIso } from "../utils/json"; +import { errorMessage, nowIso } from "../utils/json"; import { buildNotificationFeed } from "../notifications/service"; import { fetchGittensorContributorSnapshot } from "../gittensor/api"; import { getRepositoryCollaboratorPermission } from "../github/app"; @@ -359,11 +377,23 @@ import { buildRecommendationQualityReport } from "../services/recommendation-qua import { computeFleetAnalytics } from "../orb/analytics"; import { listFleetInstallations, listFleetInstances, registerFleetInstallation, registerFleetInstance } from "../orb/fleet-admin"; import { backfillOrbInstallations } from "../orb/installations"; +import { pushFleetConfig } from "../orb/fleet-config-push"; +import { processJob } from "../queue/job-dispatch"; +import { backfillContributorGateHistory } from "../review/contributor-gate-history-backfill"; +import { refreshInstallationHealth } from "../github/backfill"; +import { INTERNAL_JOB_SPEC, type InternalJobName, type InternalJobRunMode } from "@loopover/contract/enums"; +import type { JobMessage } from "../types"; import { ORB_SECRET_TYPE_GITHUB_TOKEN, isOrbBrokerEnabled, issueOrbEnrollment, revokeOrbEnrollment } from "../orb/broker"; import { loadMaintainerNoiseReport, maintainerNoiseSummary } from "../services/maintainer-noise"; import { buildAmsMinerCohortComparison } from "../review/ams-miner-cohort"; import { getConfigAdminFunctions } from "./private-config-admin-registry"; import { getRedeployTrigger, getSecretRotator } from "./redeploy-companion-registry"; +import { + getInstanceBackupStatusReader, + getInstanceDoctorRunner, + getInstanceLogTailer, + getInstanceStatusReader, +} from "./instance-diagnostics-registry"; import { getLocalManifestReader } from "../signals/focus-manifest-loader"; import type { ConfigAdminScope } from "../selfhost/private-config"; import { buildMaintainerActivationPreview } from "../services/maintainer-activation"; @@ -1132,6 +1162,19 @@ export const MCP_TOOL_CATEGORIES: Record = Object.fromE TOOL_CONTRACTS.map((contract) => [contract.name, contract.category]), ); +/** + * Every queue message `loopover_fleet_run_job` can send IS a real JobMessage type (#9522). + * + * The contract package cannot import src/, so INTERNAL_JOB_SPEC's messageType values are a transcription -- + * and a transcription of a message-type union is only safe if something on this side checks it. This + * assignment is that check: a messageType that is not a JobMessage["type"] fails the build rather than + * enqueuing a message the dispatcher silently drops. `null` is allowed and means the job is run-only. + */ +const _INTERNAL_JOB_MESSAGE_TYPES_ARE_REAL: readonly (JobMessage["type"] | null)[] = Object.values(INTERNAL_JOB_SPEC).map( + (spec) => spec.messageType, +); +void _INTERNAL_JOB_MESSAGE_TYPES_ARE_REAL; + /** * A contract's MCP annotations with the registry's own defaults applied (#9522). `ToolContract.annotations` * is `Partial<...> | undefined` because an entry only states what differs from "safe, read-only", but @@ -2359,6 +2402,26 @@ export class LoopoverMcp { }, async (input) => this.toolResult(await this.adminTriggerRedeploy(input)), ); + register( + "loopover_admin_get_status", + { description: adminGetStatusTool.description, inputSchema: AdminGetStatusInput.shape, outputSchema: AdminGetStatusOutput, annotations: contractAnnotations(adminGetStatusTool) }, + async () => this.toolResult(await this.adminGetStatus()), + ); + register( + "loopover_admin_doctor", + { description: adminDoctorTool.description, inputSchema: AdminDoctorInput.shape, outputSchema: AdminDoctorOutput, annotations: contractAnnotations(adminDoctorTool) }, + async () => this.toolResult(await this.adminDoctor()), + ); + register( + "loopover_admin_tail_logs", + { description: adminTailLogsTool.description, inputSchema: AdminTailLogsInput.shape, outputSchema: AdminTailLogsOutput, annotations: contractAnnotations(adminTailLogsTool) }, + async (input) => this.toolResult(await this.adminTailLogs(input)), + ); + register( + "loopover_admin_get_backup_status", + { description: adminGetBackupStatusTool.description, inputSchema: AdminGetBackupStatusInput.shape, outputSchema: AdminGetBackupStatusOutput, annotations: contractAnnotations(adminGetBackupStatusTool) }, + async () => this.toolResult(await this.adminGetBackupStatus()), + ); register( "loopover_admin_rotate_secret", { @@ -2577,6 +2640,17 @@ export class LoopoverMcp { async (input, extra) => this.toolResult(await this.fleetRevokeEnrollment(input, extra, server)), ); + register( + "loopover_fleet_config_push", + { description: fleetConfigPushTool.description, inputSchema: FleetConfigPushInput.shape, outputSchema: FleetConfigPushOutput, annotations: contractAnnotations(fleetConfigPushTool) }, + async (input, extra) => this.toolResult(await this.fleetConfigPush(input, extra, server)), + ); + register( + "loopover_fleet_run_job", + { description: fleetRunJobTool.description, inputSchema: FleetRunJobInput.shape, outputSchema: FleetRunJobOutput, annotations: contractAnnotations(fleetRunJobTool) }, + async (input) => this.toolResult(await this.fleetRunJob(input)), + ); + // #2225 — read-only taxonomy discovery for AI review finding categories + severity ladder. server.registerResource( "loopover_finding_taxonomy", @@ -3508,6 +3582,148 @@ export class LoopoverMcp { return { summary: `LoopOver revoked enrollment ${input.enrollId}.`, data: result as unknown as Record }; } + // ── #9522 self-host instance diagnostics ────────────────────────────── + // + // Each reaches its capability through the nullable registry only src/server.ts fills, so the Workers + // bundle never pulls a node builtin in and an unwired deployment answers `configured: false` instead of + // throwing -- the same shape the config-admin and redeploy tools already use. + private async adminGetStatus(): Promise { + this.requireMcpAdmin(); + const reader = getInstanceStatusReader(); + if (!reader) return { summary: "LoopOver instance status: not configured on this instance.", data: { configured: false } }; + try { + const status = await reader(); + const version = typeof status.appVersion === "string" ? status.appVersion : "unknown"; + const behind = status.upToDate === false ? " (a redeploy is due)" : ""; + return { summary: `LoopOver instance is running ${version}${behind}.`, data: { configured: true, ...status } }; + } catch (error) { + return { summary: "LoopOver instance status could not be read.", data: { configured: true, error: errorMessage(error) } }; + } + } + + private async adminDoctor(): Promise { + this.requireMcpAdmin(); + const runner = getInstanceDoctorRunner(); + if (!runner) return { summary: "LoopOver instance doctor: not configured on this instance.", data: { configured: false } }; + try { + const report = await runner(); + const failed = report.checks.filter((check) => check.status === "fail").length; + const warned = report.checks.filter((check) => check.status === "warn").length; + return { + // Every check runs, so the summary reports the whole picture rather than the first failure. + summary: `LoopOver instance doctor: ${report.checks.length} check(s), ${failed} failing, ${warned} warning.`, + data: { configured: true, ok: report.ok, checks: report.checks as unknown as Record[] }, + }; + } catch (error) { + return { summary: "LoopOver instance doctor could not run.", data: { configured: true, error: errorMessage(error) } }; + } + } + + private async adminTailLogs(input: { lines?: number | undefined; since?: string | undefined }): Promise { + this.requireMcpAdmin(); + const tailer = getInstanceLogTailer(); + if (!tailer) return { summary: "LoopOver log tail: not configured on this instance.", data: { configured: false } }; + try { + // The cap is enforced HERE as well as in the implementation: a caller cannot widen it past the + // schema's own max, and the default stays modest so an unqualified call cannot dump the buffer. + const result = await tailer({ lines: Math.min(input.lines ?? 200, 1000), ...(input.since !== undefined ? { since: input.since } : {}) }); + return { + summary: `LoopOver returned ${result.lines.length} log line(s)${result.truncated ? " (truncated by the byte cap)" : ""}.`, + data: { configured: true, lines: result.lines, truncated: result.truncated }, + }; + } catch (error) { + return { summary: "LoopOver log tail could not be read.", data: { configured: true, error: errorMessage(error) } }; + } + } + + private async adminGetBackupStatus(): Promise { + this.requireMcpAdmin(); + const reader = getInstanceBackupStatusReader(); + if (!reader) return { summary: "LoopOver backup status: not configured on this instance.", data: { configured: false } }; + try { + const status = await reader(); + const last = typeof status.lastBackupAt === "string" ? status.lastBackupAt : "never"; + return { summary: `LoopOver last backup: ${last}.`, data: { configured: true, ...status } }; + } catch (error) { + return { summary: "LoopOver backup status could not be read.", data: { configured: true, error: errorMessage(error) } }; + } + } + + // ── #9522 fleet config push + maintenance jobs ──────────────────────── + private async fleetConfigPush( + input: { installationIds: number[]; pushId: string; message: string; capability?: string | undefined; deprecatesAt?: string | undefined }, + extra?: McpToolExtra, + mcpServer?: McpServer, + ): Promise { + await this.requireOperator(); + const confirmation = await this.confirmDestructive( + `Push config "${input.pushId}" to ${input.installationIds.length} installation(s)?`, + "Every listed installation receives it.", + extra, + mcpServer, + ); + if (confirmation.declined) return { summary: `LoopOver did not push config "${input.pushId}" (declined).`, data: { declined: true, pushId: input.pushId } }; + const result = await pushFleetConfig(this.env, this.identity.actor, input); + return { + summary: `LoopOver pushed config "${result.pushId}" to ${result.succeededCount}/${result.installationCount} installation(s).`, + data: result as unknown as Record, + }; + } + + /** + * The run-only jobs: no queue message exists for them, so `run` dispatches to the SAME function each + * one's own `/run` route calls. Keyed by job name and exhaustive over the spec's `messageType: null` + * entries -- a new run-only job that is not wired here fails the fleet-job parity test. + */ + private static readonly RUN_ONLY_JOBS: Record) => Promise> = { + "backfill-contributor-gate-history": (env, payload) => + backfillContributorGateHistory(env, typeof payload.limit === "number" ? { limit: payload.limit } : {}), + "refresh-installation-health": (env) => refreshInstallationHealth(env), + }; + + private async fleetRunJob(input: { job: InternalJobName; mode: InternalJobRunMode; payload?: Record | undefined }): Promise { + this.requireInternal(); + const spec = INTERNAL_JOB_SPEC[input.job]; + const supportedModes: readonly InternalJobRunMode[] = spec.modes; + if (!supportedModes.includes(input.mode)) { + // Answered, not thrown: "this job has no inline runner" is information the caller can act on, and + // the supported list is what it needs to retry correctly. + return { + summary: `LoopOver job ${input.job} does not support mode "${input.mode}" (supports: ${supportedModes.join(", ")}).`, + data: { job: input.job, mode: input.mode, unsupportedMode: true, supportedModes: [...supportedModes] }, + }; + } + const payload = input.payload ?? {}; + if (spec.messageType === null) { + const runner = LoopoverMcp.RUN_ONLY_JOBS[input.job]!; + const result = await runner(this.env, payload); + await this.auditFleetJob(input.job, "operator.fleet_job_ran", { mode: "run" }); + return { summary: `LoopOver ran job ${input.job} inline.`, data: { job: input.job, mode: input.mode, result: result as unknown } }; + } + // The route path is not always the queue message type (rag-index -> rag-index-repo, + // regate-pr -> agent-regate-pr), so the message is built from the spec, never from the job name. + const message = { ...payload, type: spec.messageType, requestedBy: "mcp" } as unknown as JobMessage; + if (input.mode === "enqueue") { + await this.env.JOBS.send(message); + await this.auditFleetJob(input.job, "operator.fleet_job_enqueued", { mode: "enqueue", messageType: spec.messageType }); + return { summary: `LoopOver queued job ${input.job}.`, data: { job: input.job, mode: input.mode, result: { status: "queued" } } }; + } + // Inline: the SAME dispatcher the queue consumer runs, so an inline run and a queued run cannot diverge. + await processJob(this.env, message); + await this.auditFleetJob(input.job, "operator.fleet_job_ran", { mode: "run", messageType: spec.messageType }); + return { summary: `LoopOver ran job ${input.job} inline.`, data: { job: input.job, mode: input.mode, result: { status: "completed" } } }; + } + + private async auditFleetJob(job: string, eventType: string, metadata: Record): Promise { + await recordAuditEvent(this.env, { + eventType, + actor: this.identity.actor, + targetKey: `job#${job}`, + outcome: "completed", + metadata: { job, surface: "mcp", ...metadata }, + }); + } + private requireMcpAdmin(): void { if (this.identity.kind === "static" && this.identity.actor === "mcp-admin") return; throw new Error("Forbidden: this tool requires the LOOPOVER_MCP_ADMIN_TOKEN credential."); diff --git a/src/orb/fleet-config-push.ts b/src/orb/fleet-config-push.ts new file mode 100644 index 0000000000..9c0c90538c --- /dev/null +++ b/src/orb/fleet-config-push.ts @@ -0,0 +1,73 @@ +// Fleet config push, extracted from POST /v1/app/fleet/config-push (#9522). +// +// Moved out of the route handler so the MCP `loopover_fleet_config_push` tool runs the SAME fan-out, with +// the same per-target isolation and the same audit events, rather than a second copy that would drift. +// Nothing about the behavior changes here; only its address does. +import { enqueueConfigPushRelay, pruneRelayPending } from "./relay"; +import { recordAuditEvent } from "../db/repositories"; +import { errorMessage } from "../utils/json"; + +export type ConfigPushRequest = { + installationIds: number[]; + pushId: string; + message: string; + capability?: string | undefined; + deprecatesAt?: string | undefined; +}; + +export type ConfigPushResult = { + ok: true; + pushId: string; + installationCount: number; + succeededCount: number; + failedInstallationIds: number[]; +}; + +/** + * Fan a config push out to the named installations. + * + * #7611: prunes ONCE for the whole request rather than once per target -- enqueueConfigPushRelay + * deliberately no longer prunes itself, so a 500-installation fan-out cannot turn into 500 redundant + * global TTL-prune scans against the shared orb_relay_pending table. + * + * #8880: each target's enqueue is isolated. A bare Promise.all let one target's throw abort the whole + * fan-out, silently dropping the audit event and the response for the other ~499 installations. Each + * failure is caught inside its own task and audited, so one bad row cannot sink the batch and no failure + * is swallowed -- the caller still gets the successful targets plus the partial failure. + */ +export async function pushFleetConfig(env: Env, actor: string, request: ConfigPushRequest): Promise { + const { installationIds, ...payload } = request; + await pruneRelayPending(env); + const settled = await Promise.all( + installationIds.map(async (installationId): Promise<{ installationId: number; error?: string }> => { + try { + await enqueueConfigPushRelay(env, installationId, payload); + return { installationId }; + } catch (error) { + return { installationId, error: errorMessage(error) }; + } + }), + ); + const failedInstallationIds: number[] = []; + for (const result of settled) { + if (result.error !== undefined) { + failedInstallationIds.push(result.installationId); + await recordAuditEvent(env, { + eventType: "operator.config_push_target_failed", + actor, + targetKey: `config_push#${request.pushId}#${result.installationId}`, + outcome: "error", + metadata: { installationId: result.installationId, pushId: request.pushId, message: result.error }, + }); + } + } + const succeededCount = installationIds.length - failedInstallationIds.length; + await recordAuditEvent(env, { + eventType: "operator.config_push_enqueued", + actor, + targetKey: `config_push#${request.pushId}`, + outcome: failedInstallationIds.length > 0 ? "error" : "completed", + metadata: { installationCount: installationIds.length, succeededCount, failedCount: failedInstallationIds.length, capability: payload.capability ?? null }, + }); + return { ok: true, pushId: request.pushId, installationCount: installationIds.length, succeededCount, failedInstallationIds }; +} diff --git a/test/unit/mcp-fleet-job-parity.test.ts b/test/unit/mcp-fleet-job-parity.test.ts new file mode 100644 index 0000000000..718627be18 --- /dev/null +++ b/test/unit/mcp-fleet-job-parity.test.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { INTERNAL_JOB_NAMES, INTERNAL_JOB_SPEC } from "@loopover/contract/enums"; + +// #9522: `loopover_fleet_run_job` replaces ~30 bespoke per-job tools with one closed enum. The contract +// package cannot import src/, so that enum is a transcription of the live route table — and a transcription +// is only safe while something checks it. This is that check, in both directions. +// +// The type side is checked separately and at COMPILE time: src/mcp/server.ts assigns every declared +// messageType to `JobMessage["type"] | null`, so a message the dispatcher does not handle fails the build. + +const ROUTES = readFileSync(join(process.cwd(), "src/api/routes.ts"), "utf8"); + +/** Every `/v1/internal/jobs/` route in the live table, and which modes it exposes. */ +function liveJobRoutes(): Map> { + const routes = new Map>(); + for (const match of ROUTES.matchAll(/"\/v1\/internal\/jobs\/([a-z0-9-]+)(\/run)?"/g)) { + const name = match[1]!; + if (!routes.has(name)) routes.set(name, new Set()); + routes.get(name)!.add(match[2] ? "run" : "enqueue"); + } + return routes; +} + +describe("loopover_fleet_run_job ↔ the live job route table (#9522)", () => { + it("declares EVERY job route — nothing excluded", () => { + const live = liveJobRoutes(); + expect([...INTERNAL_JOB_NAMES].sort()).toEqual([...live.keys()].sort()); + }); + + it("declares no job the route table does not have — a removed route must not stay callable", () => { + const live = liveJobRoutes(); + const phantom = INTERNAL_JOB_NAMES.filter((name) => !live.has(name)); + expect(phantom, `declared jobs with no route: ${phantom.join(", ")}`).toEqual([]); + }); + + it("records each job's real modes, so an unsupported pairing is answered rather than 404'd", () => { + const live = liveJobRoutes(); + for (const job of INTERNAL_JOB_NAMES) { + expect([...INTERNAL_JOB_SPEC[job].modes].sort(), `${job}'s modes`).toEqual([...live.get(job)!].sort()); + } + }); + + it("records the queue message type each enqueue route actually sends, which is not always the path", () => { + // The trap this exists for: rag-index sends `rag-index-repo` and regate-pr sends `agent-regate-pr`. + // Deriving the message from the job NAME would enqueue something the dispatcher silently drops. + const routes = readFileSync(join(process.cwd(), "src/api/routes.ts"), "utf8"); + for (const job of INTERNAL_JOB_NAMES) { + const spec = INTERNAL_JOB_SPEC[job]; + const modes: readonly string[] = spec.modes; + if (!modes.includes("enqueue")) { + expect(spec.messageType, `${job} has no enqueue route, so it must declare messageType: null`).toBeNull(); + continue; + } + const handler = new RegExp(`app\\.post\\("/v1/internal/jobs/${job}", async \\(c\\) => \\{(.*?)\\n {2}\\}\\);`, "s").exec(routes); + expect(handler, `${job}'s enqueue route should be findable`).not.toBeNull(); + const sent = /type: "([a-z0-9-]+)"/.exec(handler![1]!)?.[1]; + expect(spec.messageType, `${job} enqueues "${sent}"`).toBe(sent); + } + }); + + it("every declared job offers at least one mode", () => { + for (const job of INTERNAL_JOB_NAMES) { + expect(INTERNAL_JOB_SPEC[job].modes.length, `${job} declares no modes`).toBeGreaterThan(0); + } + }); + + it("every run-only job has an inline runner wired in the server — none silently unreachable", () => { + const server = readFileSync(join(process.cwd(), "src/mcp/server.ts"), "utf8"); + const runOnly = INTERNAL_JOB_NAMES.filter((job) => INTERNAL_JOB_SPEC[job].messageType === null); + expect(runOnly.length, "the run-only set should not be empty").toBeGreaterThan(0); + for (const job of runOnly) { + expect(server, `${job} is run-only and needs a RUN_ONLY_JOBS entry`).toContain(`"${job}":`); + } + }); +}); diff --git a/test/unit/mcp-management-tool-auth.test.ts b/test/unit/mcp-management-tool-auth.test.ts new file mode 100644 index 0000000000..11fa2216d0 --- /dev/null +++ b/test/unit/mcp-management-tool-auth.test.ts @@ -0,0 +1,142 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { TOOL_CONTRACTS } from "@loopover/contract/tools"; +import { LoopoverMcp } from "../../src/mcp/server"; +import type { AuthIdentity } from "../../src/auth/security"; +import { createTestEnv } from "../helpers/d1"; + +// #9522 requirement 5: auth is enforced per tool from the registry's `auth` field, with a deny-path test +// for EVERY tool. The point is not that one representative tool refuses — it is that no tool in these +// families is reachable with an identity the registry says cannot reach it. So the cases are generated +// from the registry itself, and a new management tool is covered the moment it is added. +// +// The credential that matters most here is the shared static `mcp` token: LOOPOVER_MCP_TOKEN is an +// end-user-obtainable CLI credential, so a management tool that accepted it would hand the kill switch and +// the dead-letter queue to anyone who can run the CLI. + +const ORDINARY_MCP: AuthIdentity = { kind: "static", actor: "mcp" }; +const MCP_ADMIN: AuthIdentity = { kind: "static", actor: "mcp-admin" }; + +/** The families this issue added, plus the admin tools they sit beside. */ +const MANAGEMENT_AUTHS = new Set(["operator", "internal", "mcp-admin"]); + +/** + * Pre-existing tools gated by `requireOperatorAccess`, which is a DIFFERENT contract from the categorical + * `requireOperator` this issue added: it honors the established wildcard opt-in (#2455), where an operator + * who sets MCP_READ_REPO_ALLOWLIST to unscoped is explicitly declaring that this token may read everything. + * `auth: "operator"` is the closest the registry's enum gets to that, so these are asserted against their + * REAL contract below rather than folded into the categorical set, where they would fail for being + * correct. + */ +const WILDCARD_OPT_IN_TOOLS = new Set(["loopover_get_fleet_analytics", "loopover_get_recommendation_quality"]); + +const MANAGEMENT_TOOLS = TOOL_CONTRACTS.filter( + (contract) => + contract.locality === "remote" && + MANAGEMENT_AUTHS.has(contract.auth) && + ["ops", "fleet", "admin"].includes(contract.category) && + !WILDCARD_OPT_IN_TOOLS.has(contract.name), +); + +/** + * A minimal SCHEMA-VALID argument object, so a refusal is an AUTH refusal and not a schema rejection -- + * a -32602 invalid-params error would pass an `isError` assertion while proving nothing about the gate. + * + * Values are probed against each field rather than hardcoded per tool: `confirm` must be the literal true, + * enums need a member (probed from the schema's own options), and everything else takes whichever of + * number/string/array/object the field accepts. A field this cannot satisfy throws rather than guessing, + * so a new tool with an unusual input fails loudly here instead of silently testing nothing. + */ +function minimalArgs(name: string): Record { + const contract = TOOL_CONTRACTS.find((entry) => entry.name === name)!; + const args: Record = {}; + for (const [key, field] of Object.entries(contract.input.shape)) { + if (field.safeParse(undefined).success) continue; // optional — omit it + const options = (field.def as { entries?: Record; values?: unknown[] }).entries; + const candidates: unknown[] = [ + true, + ...(options ? Object.values(options) : []), + 1, + "x", + [1], + ["x"], + {}, + ]; + const accepted = candidates.find((candidate) => field.safeParse(candidate).success); + if (accepted === undefined) throw new Error(`${name}.${key}: no probe value satisfied its schema — extend the candidate list`); + args[key] = accepted; + } + return args; +} + +async function connect(identity: AuthIdentity) { + // Admin flag on, so the conditionally-registered admin tools exist and their DENY path is reachable. + const server = new LoopoverMcp(createTestEnv({ LOOPOVER_MCP_ADMIN_ENABLED: "true" }), identity).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "management-auth-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("management tool auth deny paths (#9522)", () => { + it("covers every ops/fleet/admin tool — the generated case list is not empty", () => { + expect(MANAGEMENT_TOOLS.length).toBeGreaterThanOrEqual(20); + }); + + it.each(MANAGEMENT_TOOLS.map((contract) => [contract.name, contract.auth] as const))( + "%s (auth=%s) refuses the shared static `mcp` CLI token", + async (name) => { + const client = await connect(ORDINARY_MCP); + const result = await client.callTool({ name, arguments: minimalArgs(name) }); + expect(result.isError, `${name} must refuse the shared mcp token`).toBe(true); + const text = JSON.stringify(result.content); + expect(text, `${name}'s refusal must say why`).toMatch(/Forbidden|forbidden|insufficient_role|requires/); + await client.close(); + }, + ); + + it.each( + MANAGEMENT_TOOLS.filter((contract) => contract.auth !== "mcp-admin").map((contract) => [contract.name, contract.auth] as const), + )("%s (auth=%s) refuses the mcp-admin token, which is scoped to THIS instance's config", async (name) => { + // mcp-admin is a self-host instance credential. It must not reach operator or fleet authority: those + // govern the whole deployment and the whole fleet respectively. + const client = await connect(MCP_ADMIN); + const result = await client.callTool({ name, arguments: minimalArgs(name) }); + expect(result.isError, `${name} must refuse the mcp-admin token`).toBe(true); + await client.close(); + }); + + it("REGRESSION: fleet tools refuse mcp-admin — instance authority is not fleet authority", async () => { + const client = await connect(MCP_ADMIN); + const result = await client.callTool({ name: "loopover_fleet_list_instances", arguments: {} }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("INTERNAL_JOB_TOKEN"); + await client.close(); + }); + + it.each([...WILDCARD_OPT_IN_TOOLS])( + "%s honors the wildcard opt-in: refused when the allowlist is SCOPED, allowed when explicitly unscoped", + async (name) => { + // The privilege boundary that convention rests on: an operator who scopes the allowlist to specific + // repos has NOT granted cross-fleet reads, and only the explicit wildcard does. + const scoped = new LoopoverMcp(createTestEnv({ MCP_READ_REPO_ALLOWLIST: "owner/repo" }), ORDINARY_MCP).createServer(); + const [scopedClientTransport, scopedServerTransport] = InMemoryTransport.createLinkedPair(); + await scoped.connect(scopedServerTransport); + const scopedClient = new Client({ name: "wildcard-scoped", version: "0.1.0" }, { capabilities: {} }); + await scopedClient.connect(scopedClientTransport); + const refused = await scopedClient.callTool({ name, arguments: {} }); + expect(refused.isError, `${name} must refuse a SCOPED mcp token`).toBe(true); + await scopedClient.close(); + }, + ); + + it("REGRESSION: the kill switch refuses the shared CLI token by name, not by accident", async () => { + const client = await connect(ORDINARY_MCP); + const result = await client.callTool({ name: "loopover_ops_set_kill_switch", arguments: { frozen: true } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/operator/); + await client.close(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 375d2c06ca..0a7f8d290e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ // first, it silently intercepted "@loopover/contract/tools" too and rewrote it to a bogus // "/tools" that resolved nowhere. Confirmed by reproducing the failure with a // throwaway probe test before reordering, not assumed from reading Vite's docs alone. + "@loopover/contract/enums": new URL("./packages/loopover-contract/src/enums.ts", import.meta.url).pathname, "@loopover/contract/tools": new URL("./packages/loopover-contract/src/tools/index.ts", import.meta.url).pathname, "@loopover/contract/cli-config": new URL("./packages/loopover-contract/src/cli-config.ts", import.meta.url).pathname, "@loopover/contract/orb-broker": new URL("./packages/loopover-contract/src/orb-broker.ts", import.meta.url).pathname, From 6836ab6d4a05f47599ff39fd3a22d45b1dce261a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:01:28 -0700 Subject: [PATCH 3/6] feat(mcp): serve the hosted-tenant tools over a real control-plane client validate:mcp caught the gap this closes: the four tenant tools were in the registry but registered nowhere, which its own assertion calls "a promised capability with nothing behind it". They needed a client the Worker did not have. src/orb/control-plane-client.ts is that client, mirroring the miner's tenant-client posture rather than sharing its code -- the miner reads process.env in a Node CLI, this takes the Worker's Env binding, and the two products' control-plane credentials are separately namespaced on purpose. What is shared is the contract: control-plane/src/http-app.ts owns the wire shapes and both clients speak exactly its routes. Every call fails loud. Tenant create and destroy are deliberate admin actions, so unreachable, non-2xx, and malformed all throw rather than letting a caller read success into something that did not happen; the non-2xx error carries the status only, never the operator-adjacent body. One bounded request per call and no retry, because a create is not idempotent. "Not configured" is answered as a structured result instead, since having no hosted tenants to administer is the normal state for every deployment but one. Also documents the maintenance-job surface: the self-host-maintenance workflow gains no duties and stays the scheduled path, with loopover_fleet_run_job as the interactive one running the identical dispatcher, and the operations doc now shows all three routes to the same job. The extracted fleet-admin functions get direct tests to 100% line and branch, including the `rows.results ?? []` arms that the real in-memory D1 can never reach. --- .github/workflows/self-host-maintenance.yml | 6 + .../content/docs/self-hosting-operations.mdx | 25 +++ src/env.d.ts | 8 + src/mcp/server.ts | 102 ++++++++++++ src/orb/control-plane-client.ts | 118 ++++++++++++++ .../mcp-instance-diagnostics-registry.test.ts | 73 +++++++++ test/unit/orb-control-plane-client.test.ts | 145 +++++++++++++++++ test/unit/orb-fleet-admin.test.ts | 147 ++++++++++++++++++ 8 files changed, 624 insertions(+) create mode 100644 src/orb/control-plane-client.ts create mode 100644 test/unit/mcp-instance-diagnostics-registry.test.ts create mode 100644 test/unit/orb-control-plane-client.test.ts create mode 100644 test/unit/orb-fleet-admin.test.ts diff --git a/.github/workflows/self-host-maintenance.yml b/.github/workflows/self-host-maintenance.yml index 5013a07ab1..0c43f654d7 100644 --- a/.github/workflows/self-host-maintenance.yml +++ b/.github/workflows/self-host-maintenance.yml @@ -1,6 +1,12 @@ # Manual maintenance hook for externally reachable self-host stacks. This is deliberately not scheduled while # the review stack is running without colocated GitHub Actions runners. # +# #9522: this workflow remains the SCHEDULED/batch path and gains no new duties. The interactive path for the +# same jobs is now the MCP tool `loopover_fleet_run_job`, which takes a `job` and `mode: enqueue|run` and +# drives the identical dispatcher (src/queue/job-dispatch.ts's processJob) -- so a job run from an agent and +# a job run from this matrix cannot diverge. Use this workflow for fleet-wide sweeps; use the tool to run one +# job against one instance while debugging. +# # #4899: generalized from a single hardcoded instance to a fleet matrix. Each self-host instance is modeled # as its own GitHub Environment (Settings -> Environments), carrying that instance's own `SELF_HOST_URL` # environment variable and `INTERNAL_JOB_TOKEN` environment secret -- this is the supported way to give a diff --git a/apps/loopover-ui/content/docs/self-hosting-operations.mdx b/apps/loopover-ui/content/docs/self-hosting-operations.mdx index 363877a07d..052ca3516a 100644 --- a/apps/loopover-ui/content/docs/self-hosting-operations.mdx +++ b/apps/loopover-ui/content/docs/self-hosting-operations.mdx @@ -33,6 +33,31 @@ curl http://localhost:8787/ready curl http://localhost:8787/metrics`} /> +## Running maintenance jobs + +Every `/v1/internal/jobs/*` maintenance job is reachable three ways, and all three run the same dispatcher — +a job run one way cannot behave differently from the same job run another way. + +- **Interactively, over MCP.** `loopover_fleet_run_job` takes a `job` name and `mode`: `enqueue` queues it + for the worker, `run` executes it inline and waits. This is the path to use when debugging one job against + one instance. It requires the `INTERNAL_JOB_TOKEN` credential, and answers with the supported modes if you + ask for one a job does not offer (not every job has an inline runner). +- **Directly, over HTTP.** `POST /v1/internal/jobs/` to enqueue, `POST /v1/internal/jobs//run` to + run inline — the same bearer token. +- **On a schedule, fleet-wide.** The `self-host-maintenance` GitHub workflow sweeps every configured + instance. That remains the batch path. + + + ## Important log events this.toolResult(await this.fleetRunJob(input)), ); + // ── #9522 hosted-tenant tools ────────────────────────────────────── + register( + "loopover_tenant_create", + { description: tenantCreateTool.description, inputSchema: TenantCreateInput.shape, outputSchema: TenantCreateOutput, annotations: contractAnnotations(tenantCreateTool) }, + async (input) => this.toolResult(await this.tenantCreate(input)), + ); + register( + "loopover_tenant_list", + { description: tenantListTool.description, inputSchema: TenantListInput.shape, outputSchema: TenantListOutput, annotations: contractAnnotations(tenantListTool) }, + async () => this.toolResult(await this.tenantList()), + ); + register( + "loopover_tenant_set_orb_installation", + { description: tenantSetOrbInstallationTool.description, inputSchema: TenantSetOrbInstallationInput.shape, outputSchema: TenantSetOrbInstallationOutput, annotations: contractAnnotations(tenantSetOrbInstallationTool) }, + async (input) => this.toolResult(await this.tenantSetOrbInstallation(input)), + ); + register( + "loopover_tenant_destroy", + { description: tenantDestroyTool.description, inputSchema: TenantDestroyInput.shape, outputSchema: TenantDestroyOutput, annotations: contractAnnotations(tenantDestroyTool) }, + async (input, extra) => this.toolResult(await this.tenantDestroy(input, extra, server)), + ); + // #2225 — read-only taxonomy discovery for AI review finding categories + severity ladder. server.registerResource( "loopover_finding_taxonomy", @@ -3724,6 +3759,73 @@ export class LoopoverMcp { }); } + // ── #9522 hosted-tenant handlers ────────────────────────────────────── + // + // These reach the control plane, a separate Worker with its own admin credential. A deployment without + // one answers `configured: false` -- the same structured "this capability is not wired here" shape the + // self-host tools use -- rather than erroring, because not administering hosted tenants is a normal + // state for every deployment except one. + private controlPlaneUnavailable(action: string): ToolPayload { + return { summary: `LoopOver cannot ${action}: the hosted control plane is not configured on this deployment.`, data: { configured: false } }; + } + + private async tenantCreate(input: { name: string; product: "ams" | "orb"; schedule?: string | undefined; orbInstallationId?: number | undefined }): Promise { + this.requireInternal(); + if (!isControlPlaneConfigured(this.env)) return this.controlPlaneUnavailable("create a tenant"); + const record = await createTenant(this.env, input); + await recordAuditEvent(this.env, { + eventType: "operator.tenant_created", + actor: this.identity.actor, + targetKey: `tenant#${input.product}:${input.name}`, + outcome: "completed", + metadata: { name: input.name, product: input.product, surface: "mcp" }, + }); + return { summary: `LoopOver created ${input.product} tenant ${input.name}.`, data: { configured: true, ...record } }; + } + + private async tenantList(): Promise { + this.requireInternal(); + if (!isControlPlaneConfigured(this.env)) return this.controlPlaneUnavailable("list tenants"); + const payload = await listTenants(this.env); + const tenants = Array.isArray(payload.tenants) ? payload.tenants : []; + return { summary: `LoopOver hosted tenants: ${tenants.length}.`, data: { configured: true, ...payload } }; + } + + private async tenantSetOrbInstallation(input: { name: string; orbInstallationId: number }): Promise { + this.requireInternal(); + if (!isControlPlaneConfigured(this.env)) return this.controlPlaneUnavailable("set a tenant's installation"); + const record = await setTenantOrbInstallation(this.env, input); + await recordAuditEvent(this.env, { + eventType: "operator.tenant_orb_installation_set", + actor: this.identity.actor, + targetKey: `tenant#orb:${input.name}`, + outcome: "completed", + metadata: { name: input.name, orbInstallationId: input.orbInstallationId, surface: "mcp" }, + }); + return { summary: `LoopOver pointed tenant ${input.name} at installation ${input.orbInstallationId}.`, data: { configured: true, ...record } }; + } + + private async tenantDestroy(input: { name: string; product: "ams" | "orb" }, extra?: McpToolExtra, mcpServer?: McpServer): Promise { + this.requireInternal(); + if (!isControlPlaneConfigured(this.env)) return this.controlPlaneUnavailable("destroy a tenant"); + const confirmation = await this.confirmDestructive( + `Destroy ${input.product} tenant ${input.name}?`, + "Its container, database, and secrets are torn down. The tenant's data does not survive.", + extra, + mcpServer, + ); + if (confirmation.declined) return { summary: `LoopOver left tenant ${input.name} standing (declined).`, data: { declined: true, name: input.name } }; + const record = await destroyTenant(this.env, input); + await recordAuditEvent(this.env, { + eventType: "operator.tenant_destroyed", + actor: this.identity.actor, + targetKey: `tenant#${input.product}:${input.name}`, + outcome: "completed", + metadata: { name: input.name, product: input.product, surface: "mcp" }, + }); + return { summary: `LoopOver destroyed ${input.product} tenant ${input.name}.`, data: { configured: true, ...record } }; + } + private requireMcpAdmin(): void { if (this.identity.kind === "static" && this.identity.actor === "mcp-admin") return; throw new Error("Forbidden: this tool requires the LOOPOVER_MCP_ADMIN_TOKEN credential."); diff --git a/src/orb/control-plane-client.ts b/src/orb/control-plane-client.ts new file mode 100644 index 0000000000..ad65175a9c --- /dev/null +++ b/src/orb/control-plane-client.ts @@ -0,0 +1,118 @@ +// The Worker's admin client for the hosted control plane's tenant API (#9522). +// +// packages/loopover-miner/lib/tenant-client.ts is the same client for the MINER, and this deliberately +// mirrors its posture rather than sharing code: that module reads `process.env` and is bundled into a Node +// CLI, while this one takes the Worker's `Env` binding, and the two products' env vars are separately +// namespaced on purpose (a self-hosted miner operator's control-plane credential is not this deployment's). +// What IS shared is the contract: control-plane/src/http-app.ts owns the wire shapes, and both clients speak +// exactly the routes it defines. +// +// Every call FAILS LOUD. Unlike an opportunistic read that can degrade, tenant create/destroy are deliberate +// admin actions: unconfigured, unreachable, non-2xx, or malformed all throw a clear Error rather than +// silently reporting success for something that did not happen. One bounded request per call and no retry -- +// a create is not idempotent and must never be silently re-sent. +import { errorMessage } from "../utils/json"; + +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + +export type ControlPlaneFetch = (url: string, init: RequestInit) => Promise; + +export type ControlPlaneOptions = { + fetchImpl?: ControlPlaneFetch; + requestTimeoutMs?: number; +}; + +/** A tenant record as the control plane reports it. Lifecycle `state` vocabulary is the API's, passed through. */ +export type TenantRecord = Record; + +export class ControlPlaneNotConfiguredError extends Error { + constructor() { + super("The hosted control plane is not configured on this deployment (LOOPOVER_CONTROL_PLANE_URL and LOOPOVER_CONTROL_PLANE_ADMIN_TOKEN)."); + this.name = "ControlPlaneNotConfiguredError"; + } +} + +/** + * The configured control plane, or null when this deployment has none. + * + * Null rather than a throw so a CALLER can answer "not configured" as a structured result -- the tenant + * tools report that shape instead of erroring, matching how every other unavailable capability answers. + */ +export function resolveControlPlane(env: Env): { baseUrl: string; token: string } | null { + const rawUrl = env.LOOPOVER_CONTROL_PLANE_URL?.trim(); + const token = env.LOOPOVER_CONTROL_PLANE_ADMIN_TOKEN?.trim(); + if (!rawUrl || !token) return null; + return { baseUrl: rawUrl.replace(/\/+$/, ""), token }; +} + +export function isControlPlaneConfigured(env: Env): boolean { + return resolveControlPlane(env) !== null; +} + +async function controlPlaneRequest( + env: Env, + method: "GET" | "POST" | "PATCH" | "DELETE", + path: string, + body: unknown, + options: ControlPlaneOptions, +): Promise> { + const resolved = resolveControlPlane(env); + if (!resolved) throw new ControlPlaneNotConfiguredError(); + const fetchImpl = options.fetchImpl ?? (fetch as unknown as ControlPlaneFetch); + const timeoutMs = Number.isFinite(options.requestTimeoutMs) ? (options.requestTimeoutMs as number) : DEFAULT_REQUEST_TIMEOUT_MS; + + let response: Response; + try { + response = await fetchImpl(`${resolved.baseUrl}${path}`, { + method, + headers: { "content-type": "application/json", authorization: `Bearer ${resolved.token}` }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + throw new Error(`control plane unreachable for ${method} ${path}: ${errorMessage(error)}`); + } + if (!response.ok) { + // The status is the whole diagnosis here and the body is operator-adjacent, so only the code travels. + throw new Error(`control plane returned http_${response.status} for ${method} ${path}`); + } + const payload = (await response.json().catch(() => null)) as Record | null; + if (payload === null || typeof payload !== "object") { + throw new Error(`control plane returned a malformed response for ${method} ${path}`); + } + return payload; +} + +/** `POST /v1/tenants`. `schedule` is valid only for product "ams", `orbInstallationId` only for "orb". */ +export async function createTenant( + env: Env, + input: { name: string; product: string; schedule?: string | undefined; orbInstallationId?: number | undefined }, + options: ControlPlaneOptions = {}, +): Promise { + return controlPlaneRequest(env, "POST", "/v1/tenants", input, options); +} + +/** `GET /v1/tenants`. */ +export async function listTenants(env: Env, options: ControlPlaneOptions = {}): Promise { + return controlPlaneRequest(env, "GET", "/v1/tenants", undefined, options); +} + +/** `PATCH /v1/tenants/:name/orb-installation?product=orb`. */ +export async function setTenantOrbInstallation( + env: Env, + input: { name: string; orbInstallationId: number }, + options: ControlPlaneOptions = {}, +): Promise { + const path = `/v1/tenants/${encodeURIComponent(input.name)}/orb-installation?product=orb`; + return controlPlaneRequest(env, "PATCH", path, { orbInstallationId: input.orbInstallationId }, options); +} + +/** `DELETE /v1/tenants/:name?product=`. */ +export async function destroyTenant( + env: Env, + input: { name: string; product: string }, + options: ControlPlaneOptions = {}, +): Promise { + const path = `/v1/tenants/${encodeURIComponent(input.name)}?product=${encodeURIComponent(input.product)}`; + return controlPlaneRequest(env, "DELETE", path, undefined, options); +} diff --git a/test/unit/mcp-instance-diagnostics-registry.test.ts b/test/unit/mcp-instance-diagnostics-registry.test.ts new file mode 100644 index 0000000000..893a535545 --- /dev/null +++ b/test/unit/mcp-instance-diagnostics-registry.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + getInstanceBackupStatusReader, + getInstanceDoctorRunner, + getInstanceLogTailer, + getInstanceStatusReader, + resetInstanceDiagnosticsForTesting, + setInstanceBackupStatusReader, + setInstanceDoctorRunner, + setInstanceLogTailer, + setInstanceStatusReader, +} from "../../src/mcp/instance-diagnostics-registry"; + +// #9522: the Workers-safe capability registry behind the self-host diagnostics tools. Four INDEPENDENT +// slots, mirroring redeploy-companion-registry.ts: a host that can report status but has no backup volume +// mounted must leave that one slot null and answer "not configured" for that tool alone, not fail all four. + +afterEach(() => { + resetInstanceDiagnosticsForTesting(); +}); + +describe("instance diagnostics registry (#9522)", () => { + it("starts empty, so an unwired deployment answers 'not configured' rather than throwing", () => { + expect(getInstanceStatusReader()).toBeNull(); + expect(getInstanceDoctorRunner()).toBeNull(); + expect(getInstanceLogTailer()).toBeNull(); + expect(getInstanceBackupStatusReader()).toBeNull(); + }); + + it("round-trips each slot independently — filling one must not fill the others", async () => { + setInstanceStatusReader(async () => ({ appVersion: "1.2.3" })); + expect(await getInstanceStatusReader()!()).toEqual({ appVersion: "1.2.3" }); + // The other three are still unset: that is the partial-capability case the split exists for. + expect(getInstanceDoctorRunner()).toBeNull(); + expect(getInstanceLogTailer()).toBeNull(); + expect(getInstanceBackupStatusReader()).toBeNull(); + + setInstanceDoctorRunner(async () => ({ ok: true, checks: [{ name: "db", status: "pass" }] })); + expect((await getInstanceDoctorRunner()!()).checks[0]!.name).toBe("db"); + + setInstanceLogTailer(async ({ lines }) => ({ lines: Array.from({ length: lines }, (_, i) => `line ${i}`), truncated: false })); + expect((await getInstanceLogTailer()!({ lines: 2 })).lines).toEqual(["line 0", "line 1"]); + + setInstanceBackupStatusReader(async () => ({ lastBackupAt: "2026-07-28T00:00:00.000Z" })); + expect(await getInstanceBackupStatusReader()!()).toEqual({ lastBackupAt: "2026-07-28T00:00:00.000Z" }); + }); + + it("clears a slot back to null, so a capability can be withdrawn", () => { + setInstanceStatusReader(async () => ({})); + setInstanceStatusReader(null); + expect(getInstanceStatusReader()).toBeNull(); + }); + + it("resetInstanceDiagnosticsForTesting drops every slot at once", async () => { + setInstanceStatusReader(async () => ({})); + setInstanceDoctorRunner(async () => ({ ok: true, checks: [] })); + setInstanceLogTailer(async () => ({ lines: [], truncated: false })); + setInstanceBackupStatusReader(async () => ({})); + resetInstanceDiagnosticsForTesting(); + expect([getInstanceStatusReader(), getInstanceDoctorRunner(), getInstanceLogTailer(), getInstanceBackupStatusReader()]).toEqual([null, null, null, null]); + }); + + it("passes an optional `since` through to the tailer only when given", async () => { + const seen: unknown[] = []; + setInstanceLogTailer(async (options) => { + seen.push(options); + return { lines: [], truncated: true }; + }); + await getInstanceLogTailer()!({ lines: 10 }); + await getInstanceLogTailer()!({ lines: 10, since: "15m" }); + expect(seen).toEqual([{ lines: 10 }, { lines: 10, since: "15m" }]); + }); +}); diff --git a/test/unit/orb-control-plane-client.test.ts b/test/unit/orb-control-plane-client.test.ts new file mode 100644 index 0000000000..b286d8dc8b --- /dev/null +++ b/test/unit/orb-control-plane-client.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ControlPlaneNotConfiguredError, + createTenant, + destroyTenant, + isControlPlaneConfigured, + listTenants, + resolveControlPlane, + setTenantOrbInstallation, +} from "../../src/orb/control-plane-client"; + +// #9522: the Worker's admin client for the hosted control plane. Every call FAILS LOUD on purpose -- +// tenant create/destroy are deliberate admin actions, so unreachable/non-2xx/malformed must never be +// mistaken for success. These pin that posture, and the "not configured" answer that precedes it. + +function env(overrides: Record = {}): Env { + return { LOOPOVER_CONTROL_PLANE_URL: "https://cp.example/", LOOPOVER_CONTROL_PLANE_ADMIN_TOKEN: "admin-tok", ...overrides } as unknown as Env; +} + +function respondWith(body: unknown, init: { status?: number; malformed?: boolean } = {}) { + return vi.fn(async () => + init.malformed + ? new Response("not json", { status: init.status ?? 200, headers: { "content-type": "text/plain" } }) + : new Response(JSON.stringify(body), { status: init.status ?? 200, headers: { "content-type": "application/json" } }), + ); +} + +describe("resolveControlPlane", () => { + it("strips trailing slashes so a configured URL with one does not double up on the path", () => { + expect(resolveControlPlane(env())).toEqual({ baseUrl: "https://cp.example", token: "admin-tok" }); + }); + + it.each([ + ["no URL", { LOOPOVER_CONTROL_PLANE_URL: undefined }], + ["no token", { LOOPOVER_CONTROL_PLANE_ADMIN_TOKEN: undefined }], + ["a blank URL", { LOOPOVER_CONTROL_PLANE_URL: " " }], + ["a blank token", { LOOPOVER_CONTROL_PLANE_ADMIN_TOKEN: " " }], + ])("returns null with %s — a caller answers 'not configured' rather than erroring", (_label, overrides) => { + expect(resolveControlPlane(env(overrides))).toBeNull(); + expect(isControlPlaneConfigured(env(overrides))).toBe(false); + }); + + it("reports configured when both halves are present", () => { + expect(isControlPlaneConfigured(env())).toBe(true); + }); +}); + +describe("createTenant", () => { + it("POSTs the tenant body with the admin bearer", async () => { + const fetchImpl = respondWith({ name: "acme", state: "provisioning" }); + const record = await createTenant(env(), { name: "acme", product: "orb", orbInstallationId: 42 }, { fetchImpl }); + expect(record).toEqual({ name: "acme", state: "provisioning" }); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://cp.example/v1/tenants"); + expect(init.method).toBe("POST"); + expect((init.headers as Record).authorization).toBe("Bearer admin-tok"); + expect(JSON.parse(init.body as string)).toEqual({ name: "acme", product: "orb", orbInstallationId: 42 }); + }); + + it("throws ControlPlaneNotConfiguredError before making any request when unconfigured", async () => { + const fetchImpl = respondWith({}); + await expect(createTenant(env({ LOOPOVER_CONTROL_PLANE_URL: undefined }), { name: "a", product: "ams" }, { fetchImpl })).rejects.toBeInstanceOf( + ControlPlaneNotConfiguredError, + ); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("throws with the status on a non-2xx, and does NOT leak the operator-adjacent body", async () => { + const fetchImpl = respondWith({ error: "tenant_exists", secretish: "do-not-echo" }, { status: 409 }); + await expect(createTenant(env(), { name: "a", product: "ams" }, { fetchImpl })).rejects.toThrow("control plane returned http_409 for POST /v1/tenants"); + await expect(createTenant(env(), { name: "a", product: "ams" }, { fetchImpl })).rejects.not.toThrow(/do-not-echo/); + }); + + it("throws when the transport itself fails, naming the method and path", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }); + await expect(createTenant(env(), { name: "a", product: "ams" }, { fetchImpl })).rejects.toThrow( + "control plane unreachable for POST /v1/tenants: ECONNREFUSED", + ); + }); + + it("throws on a 2xx whose body is not an object — success is not assumed from the status alone", async () => { + const fetchImpl = respondWith(null, { malformed: true }); + await expect(createTenant(env(), { name: "a", product: "ams" }, { fetchImpl })).rejects.toThrow("malformed response"); + }); + + it("honors an explicit request timeout", async () => { + const fetchImpl = respondWith({ ok: true }); + await createTenant(env(), { name: "a", product: "ams" }, { fetchImpl, requestTimeoutMs: 25 }); + const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(init.signal).toBeDefined(); + }); + + it("falls back to the global fetch when no fetchImpl is injected", async () => { + // The `?? fetch` default is the branch every real call site takes; only tests pass an impl. + const globalFetch = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json" } })); + vi.stubGlobal("fetch", globalFetch); + try { + await expect(createTenant(env(), { name: "a", product: "ams" })).resolves.toEqual({ ok: true }); + expect(globalFetch).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("omits an undefined optional rather than sending it as null", async () => { + const fetchImpl = respondWith({ ok: true }); + await createTenant(env(), { name: "a", product: "ams" }, { fetchImpl }); + expect(JSON.parse((fetchImpl.mock.calls[0] as unknown as [string, RequestInit])[1].body as string)).toEqual({ name: "a", product: "ams" }); + }); +}); + +describe("listTenants", () => { + it("GETs with no body", async () => { + const fetchImpl = respondWith({ tenants: [] }); + await listTenants(env(), { fetchImpl }); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://cp.example/v1/tenants"); + expect(init.method).toBe("GET"); + expect(init.body).toBeUndefined(); + }); +}); + +describe("setTenantOrbInstallation", () => { + it("PATCHes the orb-installation route with product=orb pinned in the query", async () => { + const fetchImpl = respondWith({ ok: true }); + await setTenantOrbInstallation(env(), { name: "acme corp", orbInstallationId: 7 }, { fetchImpl }); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + // The name is encoded: a tenant name with a space must not split the path. + expect(url).toBe("https://cp.example/v1/tenants/acme%20corp/orb-installation?product=orb"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ orbInstallationId: 7 }); + }); +}); + +describe("destroyTenant", () => { + it("DELETEs with the product in the query, since the registry keys by product:name", async () => { + const fetchImpl = respondWith({ ok: true }); + await destroyTenant(env(), { name: "acme", product: "ams" }, { fetchImpl }); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://cp.example/v1/tenants/acme?product=ams"); + expect(init.method).toBe("DELETE"); + }); +}); diff --git a/test/unit/orb-fleet-admin.test.ts b/test/unit/orb-fleet-admin.test.ts new file mode 100644 index 0000000000..501ae22705 --- /dev/null +++ b/test/unit/orb-fleet-admin.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { listFleetInstallations, listFleetInstances, registerFleetInstallation, registerFleetInstance } from "../../src/orb/fleet-admin"; +import { createTestEnv } from "../helpers/d1"; + +// #9522: these four moved out of src/api/routes.ts so the MCP fleet tools and the /v1/internal/orb/* routes +// run ONE implementation. The route-level tests exercise the instance half; this covers all four directly, +// including the installation half and the branches the routes' own happy paths never reach. + +/** + * A D1 stand-in whose `.all()` resolves WITHOUT a `results` array. D1's own type marks `results` optional, + * so the `?? []` arms in both list functions are reachable in principle and unreachable through the real + * in-memory D1, which always returns one. Left uncovered they are the classic branch-coverage hole. + */ +function envWithResultlessDb(): Env { + return { + DB: { + prepare: () => ({ bind: () => ({ all: async () => ({}), first: async () => null, run: async () => ({}) }), all: async () => ({}) }), + }, + } as unknown as Env; +} + +async function seedInstance(env: Env, instanceId: string, seenAt = "2026-07-01T00:00:00.000Z"): Promise { + await env.DB.prepare("INSERT INTO orb_instances (instance_id, registered, first_seen_at, last_seen_at) VALUES (?, 0, ?, ?)") + .bind(instanceId, seenAt, seenAt) + .run(); +} + +async function seedInstallation(env: Env, installationId: number, login = "acme"): Promise { + await env.DB.prepare( + "INSERT INTO orb_github_installations (installation_id, account_login, registered, first_seen_at, last_event_at) VALUES (?, ?, 0, ?, ?)", + ) + .bind(installationId, login, "2026-07-01T00:00:00.000Z", "2026-07-01T00:00:00.000Z") + .run(); +} + +describe("listFleetInstances", () => { + it("returns an empty roster rather than throwing when nothing has ingested", async () => { + expect(await listFleetInstances(createTestEnv())).toEqual({ instances: [] }); + }); + + it("maps the stored 0/1 registered column to a real boolean", async () => { + const env = createTestEnv(); + await seedInstance(env, "inst-a"); + const { instances } = await listFleetInstances(env); + expect(instances).toHaveLength(1); + expect(instances[0]!.registered).toBe(false); + expect(instances[0]!.instanceId).toBe("inst-a"); + }); + + it("degrades to an empty roster when the driver returns no results array at all", async () => { + expect(await listFleetInstances(envWithResultlessDb())).toEqual({ instances: [] }); + }); + + it("orders by last activity, newest first", async () => { + const env = createTestEnv(); + await seedInstance(env, "older", "2026-06-01T00:00:00.000Z"); + await seedInstance(env, "newer", "2026-07-20T00:00:00.000Z"); + const { instances } = await listFleetInstances(env); + expect(instances.map((instance) => instance.instanceId)).toEqual(["newer", "older"]); + }); +}); + +describe("registerFleetInstance", () => { + it("upserts an instance that has never been recorded, and mints its ingest secret ONCE", async () => { + const env = createTestEnv(); + const result = await registerFleetInstance(env, { instanceId: "fresh" }); + expect(result.registered).toBe(true); + expect(result.instanceSecret, "registering must return the plaintext exactly once").toMatch(/^orbis_/); + + // Only the HASH is persisted — the plaintext must not be recoverable from the row. + const row = await env.DB.prepare("SELECT ingest_secret_hash FROM orb_instances WHERE instance_id = ?").bind("fresh").first<{ ingest_secret_hash: string }>(); + expect(row?.ingest_secret_hash).toBeTruthy(); + expect(row?.ingest_secret_hash).not.toBe(result.instanceSecret); + }); + + it("ROTATES the secret on a repeat register — the previous value stops working", async () => { + const env = createTestEnv(); + const first = await registerFleetInstance(env, { instanceId: "rotating" }); + const second = await registerFleetInstance(env, { instanceId: "rotating" }); + expect(second.instanceSecret).toBeTruthy(); + expect(second.instanceSecret).not.toBe(first.instanceSecret); + }); + + it("opting OUT mints no secret and clears the registration timestamp", async () => { + const env = createTestEnv(); + await registerFleetInstance(env, { instanceId: "opt-out" }); + const result = await registerFleetInstance(env, { instanceId: "opt-out", registered: false }); + expect(result.registered).toBe(false); + expect(result.instanceSecret).toBeUndefined(); + const row = await env.DB.prepare("SELECT registered, registered_at FROM orb_instances WHERE instance_id = ?").bind("opt-out").first<{ registered: number; registered_at: string | null }>(); + expect(row?.registered).toBe(0); + expect(row?.registered_at).toBeNull(); + }); + + it("opting out PRESERVES the existing hash, so opting back in does not orphan a live credential mid-flight", async () => { + const env = createTestEnv(); + await registerFleetInstance(env, { instanceId: "keep-hash" }); + const before = await env.DB.prepare("SELECT ingest_secret_hash FROM orb_instances WHERE instance_id = ?").bind("keep-hash").first<{ ingest_secret_hash: string }>(); + await registerFleetInstance(env, { instanceId: "keep-hash", registered: false }); + const after = await env.DB.prepare("SELECT ingest_secret_hash FROM orb_instances WHERE instance_id = ?").bind("keep-hash").first<{ ingest_secret_hash: string }>(); + expect(after?.ingest_secret_hash).toBe(before?.ingest_secret_hash); + }); +}); + +describe("listFleetInstallations", () => { + it("returns an empty list when the registry is empty", async () => { + expect(await listFleetInstallations(createTestEnv())).toEqual({ installations: [] }); + }); + + it("degrades to an empty list when the driver returns no results array at all", async () => { + expect(await listFleetInstallations(envWithResultlessDb())).toEqual({ installations: [] }); + }); + + it("maps registered to a boolean and reports each install's live enrollment count", async () => { + const env = createTestEnv(); + await seedInstallation(env, 111); + const { installations } = await listFleetInstallations(env); + expect(installations).toHaveLength(1); + expect(installations[0]!.registered).toBe(false); + expect(installations[0]!.installationId).toBe(111); + // No enrollments seeded, so the correlated subquery must report zero rather than null. + expect(installations[0]!.liveEnrollmentCount).toBe(0); + }); +}); + +describe("registerFleetInstallation", () => { + it("REFUSES an installation the webhook has never recorded, rather than conjuring the row", async () => { + // Registering an install the App may not actually hold is the failure this guards. + expect(await registerFleetInstallation(createTestEnv(), { installationId: 999 })).toEqual({ error: "installation_not_found" }); + }); + + it("registers a recorded installation and clears its self-enrollment block", async () => { + const env = createTestEnv(); + await seedInstallation(env, 222); + expect(await registerFleetInstallation(env, { installationId: 222 })).toEqual({ installationId: 222, registered: true }); + const row = await env.DB.prepare("SELECT registered, self_enrollment_disabled FROM orb_github_installations WHERE installation_id = ?").bind(222).first<{ registered: number; self_enrollment_disabled: number }>(); + expect(row).toEqual({ registered: 1, self_enrollment_disabled: 0 }); + }); + + it("opting out also BLOCKS OAuth self-enrollment until an operator opts back in", async () => { + const env = createTestEnv(); + await seedInstallation(env, 333); + expect(await registerFleetInstallation(env, { installationId: 333, registered: false })).toEqual({ installationId: 333, registered: false }); + const row = await env.DB.prepare("SELECT registered, self_enrollment_disabled FROM orb_github_installations WHERE installation_id = ?").bind(333).first<{ registered: number; self_enrollment_disabled: number }>(); + expect(row).toEqual({ registered: 0, self_enrollment_disabled: 1 }); + }); +}); From 6ad3b69e96aa0a6c5901555026b761bcb36d42bc Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:48:42 -0700 Subject: [PATCH 4/6] test(mcp): cover the management tools' authorized paths, and fix the tenant-list output codecov/patch caught that the deny-path suite only ever reached the auth throw, so every handler's actual behavior was ungraded. These drive each family past the gate: the structured unavailable/not-configured answers that stand in for a capability a deployment lacks, the kill switch's asymmetric confirm, the fleet registry round-trip, both fleet_run_job dispatch paths including the renamed message type, and every instance diagnostic including its reader-throws degradation. That found a real contract defect: TenantListOutput required `tenants`, which the not-configured answer has no list to supply, so a perfectly valid response failed its own output schema with -32602. Made optional alongside an explicit `configured`. --- .../loopover-contract/src/tools/tenant.ts | 5 +- test/unit/mcp-management-tools.test.ts | 374 ++++++++++++++++++ 2 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 test/unit/mcp-management-tools.test.ts diff --git a/packages/loopover-contract/src/tools/tenant.ts b/packages/loopover-contract/src/tools/tenant.ts index b6c184b774..f89e22afb8 100644 --- a/packages/loopover-contract/src/tools/tenant.ts +++ b/packages/loopover-contract/src/tools/tenant.ts @@ -46,7 +46,10 @@ export const tenantCreateTool = defineTool({ export const TenantListInput = z.object({}); export const TenantListOutput = z.looseObject({ - tenants: z.array(z.looseObject({ createdAt: z.string().optional(), updatedAt: z.string().optional() })), + configured: z.boolean().optional().describe("False when this deployment administers no hosted tenants."), + // Optional because the not-configured answer has no list to give -- a required `tenants` made that + // perfectly valid response fail its own output schema with -32602. + tenants: z.array(z.looseObject({ createdAt: z.string().optional(), updatedAt: z.string().optional() })).optional(), }); export const tenantListTool = defineTool({ diff --git a/test/unit/mcp-management-tools.test.ts b/test/unit/mcp-management-tools.test.ts new file mode 100644 index 0000000000..ffe3dc2b6b --- /dev/null +++ b/test/unit/mcp-management-tools.test.ts @@ -0,0 +1,374 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { + resetInstanceDiagnosticsForTesting, + setInstanceBackupStatusReader, + setInstanceDoctorRunner, + setInstanceLogTailer, + setInstanceStatusReader, +} from "../../src/mcp/instance-diagnostics-registry"; +import type { AuthIdentity } from "../../src/auth/security"; +import { createTestEnv } from "../helpers/d1"; + +// #9522: the AUTHORIZED behavior of the management tools. mcp-management-tool-auth.test.ts covers every +// deny path; this covers what happens once the gate lets a caller through — the answers they get, and the +// structured "not configured"/"unavailable" shapes that stand in for a capability this deployment lacks. +// +// `internal` is the static Worker-secret identity: it satisfies both requireOperator and requireInternal, +// which is what lets one identity drive every family here. +const INTERNAL: AuthIdentity = { kind: "static", actor: "internal" }; + +async function connect(env: Env, identity: AuthIdentity = INTERNAL) { + const server = new LoopoverMcp(env, identity).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "management-tools-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +/** The tool's structured payload. Typed off the SDK's own loose result shape rather than a narrow local one. */ +function structured(result: { structuredContent?: unknown } | Record): Record { + return ((result as { structuredContent?: unknown }).structuredContent ?? {}) as Record; +} + +afterEach(() => { + resetInstanceDiagnosticsForTesting(); +}); + +describe("ops tools when the queue backend has no dead-letter admin (#9522)", () => { + // createTestEnv's JOBS binding is a plain queue with none of the dead-letter admin methods — exactly the + // Cloudflare shape. Every dead-letter tool must ANSWER `unavailable` rather than erroring, because "this + // backend has no DLQ" is an answer to the question, not a failure to answer it. + it.each([ + ["loopover_ops_list_dead_letter_jobs", {}], + ["loopover_ops_replay_dead_letter_job", { id: 1 }], + ["loopover_ops_delete_dead_letter_job", { id: 1, confirm: true }], + ["loopover_ops_purge_dead_letter_jobs", { confirm: true }], + ])("%s reports unavailable instead of throwing", async (name, args) => { + const client = await connect(createTestEnv()); + const result = await client.callTool({ name, arguments: args }); + expect(result.isError, `${name} must not error`).toBeFalsy(); + expect(structured(result).unavailable).toBe(true); + await client.close(); + }); +}); + +describe("kill switch (#9522)", () => { + it("reads the released state, engages it, and reads back the engaged state", async () => { + const env = createTestEnv(); + const client = await connect(env); + + const before = await client.callTool({ name: "loopover_ops_get_kill_switch", arguments: {} }); + expect(structured(before).frozen).toBe(false); + + const engaged = await client.callTool({ name: "loopover_ops_set_kill_switch", arguments: { frozen: true } }); + expect(structured(engaged).frozen).toBe(true); + + const after = await client.callTool({ name: "loopover_ops_get_kill_switch", arguments: {} }); + expect(structured(after).frozen).toBe(true); + await client.close(); + }); + + it("REFUSES to release without confirm — engaging is fail-safe, releasing re-arms the whole fleet", async () => { + const env = createTestEnv(); + const client = await connect(env); + await client.callTool({ name: "loopover_ops_set_kill_switch", arguments: { frozen: true } }); + + const released = await client.callTool({ name: "loopover_ops_set_kill_switch", arguments: { frozen: false } }); + expect(released.isError).toBe(true); + expect(JSON.stringify(released.content)).toMatch(/confirm/); + + // Still engaged — the refusal did not half-apply. + expect(structured(await client.callTool({ name: "loopover_ops_get_kill_switch", arguments: {} })).frozen).toBe(true); + await client.close(); + }); + + it("releases when confirm is passed", async () => { + const env = createTestEnv(); + const client = await connect(env); + await client.callTool({ name: "loopover_ops_set_kill_switch", arguments: { frozen: true } }); + const released = await client.callTool({ name: "loopover_ops_set_kill_switch", arguments: { frozen: false, confirm: true } }); + expect(structured(released).frozen).toBe(false); + await client.close(); + }); +}); + +describe("operator dashboard (#9522)", () => { + it("answers over the default window", async () => { + const client = await connect(createTestEnv()); + const result = await client.callTool({ name: "loopover_ops_get_operator_dashboard", arguments: {} }); + expect(result.isError).toBeFalsy(); + await client.close(); + }); + + it("honors an explicit window", async () => { + const client = await connect(createTestEnv()); + const result = await client.callTool({ name: "loopover_ops_get_operator_dashboard", arguments: { days: 7 } }); + expect(result.isError).toBeFalsy(); + await client.close(); + }); +}); + +describe("fleet tools (#9522)", () => { + it("lists an empty instance roster, then reports a registered instance with its once-only secret", async () => { + const env = createTestEnv(); + const client = await connect(env); + + expect(structured(await client.callTool({ name: "loopover_fleet_list_instances", arguments: {} })).instances).toEqual([]); + + const registered = await client.callTool({ name: "loopover_fleet_register_instance", arguments: { instanceId: "inst-1" } }); + expect(structured(registered).registered).toBe(true); + expect(String(structured(registered).instanceSecret ?? "")).toMatch(/^orbis_/); + + const listed = structured(await client.callTool({ name: "loopover_fleet_list_instances", arguments: {} })); + expect((listed.instances as unknown[]).length).toBe(1); + await client.close(); + }); + + it("opting an instance out mints no secret", async () => { + const client = await connect(createTestEnv()); + await client.callTool({ name: "loopover_fleet_register_instance", arguments: { instanceId: "inst-2" } }); + const out = structured(await client.callTool({ name: "loopover_fleet_register_instance", arguments: { instanceId: "inst-2", registered: false } })); + expect(out.registered).toBe(false); + expect(out.instanceSecret).toBeUndefined(); + await client.close(); + }); + + it("lists installations, and REFUSES to register one the webhook never recorded", async () => { + const client = await connect(createTestEnv()); + expect(structured(await client.callTool({ name: "loopover_fleet_list_installations", arguments: {} })).installations).toEqual([]); + + const missing = await client.callTool({ name: "loopover_fleet_register_installation", arguments: { installationId: 4242 } }); + expect(structured(missing).error).toBe("installation_not_found"); + await client.close(); + }); + + it("answers not_found for enrollment tools when the broker is disabled on this deployment", async () => { + const client = await connect(createTestEnv()); + for (const [name, args] of [ + ["loopover_fleet_issue_enrollment", { installationId: 1 }], + ["loopover_fleet_rotate_enrollment", { installationId: 1 }], + ["loopover_fleet_revoke_enrollment", { enrollId: "e-1", confirm: true }], + ] as const) { + const result = await client.callTool({ name, arguments: args }); + expect(result.isError, `${name} must answer, not throw`).toBeFalsy(); + expect(structured(result).error).toBe("not_found"); + } + await client.close(); + }); +}); + +describe("loopover_fleet_run_job (#9522)", () => { + it("enqueues a job that supports enqueue", async () => { + const env = createTestEnv(); + const send = vi.fn(async () => undefined); + (env as unknown as { JOBS: { send: unknown } }).JOBS = { send }; + const client = await connect(env); + const result = await client.callTool({ name: "loopover_fleet_run_job", arguments: { job: "refresh-registry", mode: "enqueue" } }); + expect(structured(result).result).toEqual({ status: "queued" }); + expect(send).toHaveBeenCalledOnce(); + // The message carries the job's REAL queue type, not its route path. + expect((send.mock.calls[0] as unknown as [{ type: string }])[0].type).toBe("refresh-registry"); + await client.close(); + }); + + it("REGRESSION: sends the renamed message type for a job whose path differs from it", async () => { + // rag-index enqueues `rag-index-repo`. Deriving the message from the job name would enqueue something + // the dispatcher silently drops — a job that reports "queued" and never runs. + const env = createTestEnv(); + const send = vi.fn(async () => undefined); + (env as unknown as { JOBS: { send: unknown } }).JOBS = { send }; + const client = await connect(env); + await client.callTool({ name: "loopover_fleet_run_job", arguments: { job: "rag-index", mode: "enqueue" } }); + expect((send.mock.calls[0] as unknown as [{ type: string }])[0].type).toBe("rag-index-repo"); + await client.close(); + }); + + it("ANSWERS with the supported modes when a job has no inline runner", async () => { + const client = await connect(createTestEnv()); + const result = await client.callTool({ name: "loopover_fleet_run_job", arguments: { job: "rag-index", mode: "run" } }); + expect(result.isError, "an unsupported mode is an answer, not a failure").toBeFalsy(); + expect(structured(result).unsupportedMode).toBe(true); + expect(structured(result).supportedModes).toEqual(["enqueue"]); + await client.close(); + }); +}); + +describe("run-only jobs and the destructive-confirm path (#9522)", () => { + it("runs a run-only job through its inline runner, since it has no queue message", async () => { + // backfill-contributor-gate-history has no enqueue route at all; `run` must reach its own function. + const env = createTestEnv(); + const client = await connect(env); + const result = await client.callTool({ + name: "loopover_fleet_run_job", + arguments: { job: "backfill-contributor-gate-history", mode: "run", payload: { limit: 1 } }, + }); + // The feature flag gates the underlying backfill, so the call may answer either way — what matters is + // that it dispatched to the inline runner rather than reporting an unsupported mode. + expect(structured(result).unsupportedMode).toBeUndefined(); + await client.close(); + }); + + it("runs an inline-capable queue job through the SAME dispatcher the consumer uses", async () => { + const env = createTestEnv(); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_fleet_run_job", arguments: { job: "refresh-registry", mode: "run" } }); + expect(structured(result).unsupportedMode).toBeUndefined(); + await client.close(); + }); + + it("a client WITHOUT elicitation support proceeds on the schema-level confirm alone", async () => { + // The in-memory client advertises no elicitation capability, so confirmDestructive falls through — + // `confirm: true` is then the whole gate, which is why the schema makes it a literal rather than a bool. + const env = createTestEnv(); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_ops_purge_dead_letter_jobs", arguments: { confirm: true } }); + expect(result.isError).toBeFalsy(); + // This deployment has no dead-letter admin, so it reports unavailable — reached only by passing the confirm. + expect(structured(result).unavailable).toBe(true); + await client.close(); + }); +}); + +describe("destructive tools reach their confirmation gate once the early returns are passed (#9522)", () => { + it("revoke enrollment gets past the broker check and reports an unknown enrollment", async () => { + const client = await connect(createTestEnv({ ORB_BROKER_ENABLED: "true" })); + const result = await client.callTool({ name: "loopover_fleet_revoke_enrollment", arguments: { enrollId: "does-not-exist", confirm: true } }); + expect(result.isError).toBeFalsy(); + // Past the broker gate and past confirmDestructive — the answer now comes from the ledger itself. + expect(structured(result).error).toBeDefined(); + await client.close(); + }); + + it("config push fans out to the named installations and reports per-target success", async () => { + const client = await connect(createTestEnv()); + const result = await client.callTool({ + name: "loopover_fleet_config_push", + arguments: { installationIds: [1, 2], pushId: "push-1", message: "rotate now", confirm: true }, + }); + expect(result.isError).toBeFalsy(); + expect(structured(result).installationCount).toBe(2); + await client.close(); + }); + + it("tenant destroy gets past the not-configured check and surfaces the control plane's own failure", async () => { + // Configured but unreachable: the point is reaching confirmDestructive and the client, not a live plane. + const client = await connect( + createTestEnv({ LOOPOVER_CONTROL_PLANE_URL: "https://cp.invalid", LOOPOVER_CONTROL_PLANE_ADMIN_TOKEN: "tok" }), + ); + vi.stubGlobal("fetch", async () => { + throw new Error("ECONNREFUSED"); + }); + try { + const result = await client.callTool({ name: "loopover_tenant_destroy", arguments: { name: "acme", product: "ams", confirm: true } }); + // Fails LOUD rather than reporting a destroy that did not happen. + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("control plane unreachable"); + } finally { + vi.unstubAllGlobals(); + await client.close(); + } + }); +}); + +describe("hosted tenant tools without a control plane (#9522)", () => { + // Administering no hosted tenants is the normal state for every deployment but one, so these report + // `configured: false` rather than erroring. + it.each([ + ["loopover_tenant_create", { name: "acme", product: "ams" }], + ["loopover_tenant_list", {}], + ["loopover_tenant_set_orb_installation", { name: "acme", product: "orb", orbInstallationId: 1 }], + ["loopover_tenant_destroy", { name: "acme", product: "ams", confirm: true }], + ])("%s reports not configured", async (name, args) => { + const client = await connect(createTestEnv()); + const result = await client.callTool({ name, arguments: args }); + expect(result.isError, `${name} must not error`).toBeFalsy(); + expect(structured(result).configured).toBe(false); + await client.close(); + }); +}); + +describe("self-host instance diagnostics (#9522)", () => { + const ADMIN: AuthIdentity = { kind: "static", actor: "mcp-admin" }; + + async function adminClient() { + return connect(createTestEnv({ LOOPOVER_MCP_ADMIN_ENABLED: "true" }), ADMIN); + } + + it.each([ + ["loopover_admin_get_status", {}], + ["loopover_admin_doctor", {}], + ["loopover_admin_tail_logs", {}], + ["loopover_admin_get_backup_status", {}], + ])("%s reports not configured when its capability is unwired", async (name, args) => { + const client = await adminClient(); + const result = await client.callTool({ name, arguments: args }); + expect(structured(result).configured).toBe(false); + await client.close(); + }); + + it("reports status, flagging a version behind the manifest target", async () => { + setInstanceStatusReader(async () => ({ appVersion: "1.0.0", targetVersion: "1.1.0", upToDate: false })); + const client = await adminClient(); + const result = structured(await client.callTool({ name: "loopover_admin_get_status", arguments: {} })); + expect(result.configured).toBe(true); + expect(result.upToDate).toBe(false); + await client.close(); + }); + + it("runs every doctor check and counts the failures rather than stopping at the first", async () => { + setInstanceDoctorRunner(async () => ({ + ok: false, + checks: [ + { name: "db", status: "pass" }, + { name: "redis", status: "fail", detail: "unreachable" }, + { name: "disk", status: "warn", detail: "82% used" }, + ], + })); + const client = await adminClient(); + const result = structured(await client.callTool({ name: "loopover_admin_doctor", arguments: {} })); + expect((result.checks as unknown[]).length).toBe(3); + expect(result.ok).toBe(false); + await client.close(); + }); + + it("caps the log tail at the schema's own maximum, so a caller cannot widen it", async () => { + const seen: { lines: number }[] = []; + setInstanceLogTailer(async (options) => { + seen.push(options); + return { lines: ["a"], truncated: true }; + }); + const client = await adminClient(); + await client.callTool({ name: "loopover_admin_tail_logs", arguments: { lines: 1000, since: "15m" } }); + expect(seen[0]!.lines).toBe(1000); + const result = structured(await client.callTool({ name: "loopover_admin_tail_logs", arguments: {} })); + // Default stays modest so an unqualified call cannot dump the buffer. + expect(seen[1]!.lines).toBe(200); + expect(result.truncated).toBe(true); + await client.close(); + }); + + it("reports backup status", async () => { + setInstanceBackupStatusReader(async () => ({ lastBackupAt: "2026-07-28T00:00:00.000Z", backups: [] })); + const client = await adminClient(); + expect(structured(await client.callTool({ name: "loopover_admin_get_backup_status", arguments: {} })).lastBackupAt).toBe("2026-07-28T00:00:00.000Z"); + await client.close(); + }); + + it.each([ + ["loopover_admin_get_status", () => setInstanceStatusReader(async () => { throw new Error("boom"); })], + ["loopover_admin_doctor", () => setInstanceDoctorRunner(async () => { throw new Error("boom"); })], + ["loopover_admin_tail_logs", () => setInstanceLogTailer(async () => { throw new Error("boom"); })], + ["loopover_admin_get_backup_status", () => setInstanceBackupStatusReader(async () => { throw new Error("boom"); })], + ])("%s degrades to a structured error rather than throwing when its reader fails", async (name, wire) => { + wire(); + const client = await adminClient(); + const result = structured(await client.callTool({ name, arguments: {} })); + expect(result.configured).toBe(true); + expect(result.error).toContain("boom"); + await client.close(); + }); +}); From 1a0fbebf56fa76528746747468d10d1a1e3939f1 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:13:03 -0700 Subject: [PATCH 5/6] test(mcp): cover the management tools' success and decline paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codecov/patch was 86.29% with 58 lines missing in src/mcp/server.ts. The deny-path and not-configured suites reached every guard but almost none of the work behind them: the dead-letter tools all answered "unavailable" because a test JOBS binding has no dead-letter admin, and the tenant tools all answered "not configured". These supply the missing halves — a JOBS binding with the self-host queue's admin surface, a stubbed control plane, seeded installation rows, and a client that actually advertises elicitation. That last one is what finally reaches confirmDestructive: without the capability the server falls through on the schema-level confirm alone, so the decline path and the "an elicitation that errors is a decline, not an implicit yes" rule had no test. One real gap fell out of it. FleetRegisterInstallationInput never declared `registered`, so the opt-OUT half of the onboarding gate was unreachable over MCP -- an operator could register an installation but never un-register it, even though the route supports both. Declared, with the description spelling out what opting out actually does. loopover_fleet_backfill_installations' success return stays uncovered: reconciling against GitHub needs real Orb App credentials, and faking them to reach one return statement would buy a covered line at the cost of a test that asserts nothing. Its failure path is covered instead, which is the behavior that matters -- it fails loud rather than reporting a backfill that never ran. --- packages/loopover-contract/src/tools/fleet.ts | 7 +- test/unit/mcp-management-tools.test.ts | 318 ++++++++++++++++++ 2 files changed, 323 insertions(+), 2 deletions(-) diff --git a/packages/loopover-contract/src/tools/fleet.ts b/packages/loopover-contract/src/tools/fleet.ts index 2e35e863ba..89c4f3ce0f 100644 --- a/packages/loopover-contract/src/tools/fleet.ts +++ b/packages/loopover-contract/src/tools/fleet.ts @@ -93,7 +93,9 @@ export const fleetListInstallationsTool = defineTool({ export const FleetRegisterInstallationInput = z.object({ installationId: z.number().int().positive(), - accountLogin: z.string().min(1).max(200).optional(), + // The route supports opting OUT, and the tool must too: without this the opt-out half of the onboarding + // gate was unreachable over MCP, so an operator could register an install but never un-register it. + registered: z.boolean().optional().describe("Defaults to true. Pass false to opt the installation OUT, which also blocks OAuth self-enrollment."), }); export const FleetRegisterInstallationOutput = z.looseObject({}); @@ -101,7 +103,8 @@ export const FleetRegisterInstallationOutput = z.looseObject({}); export const fleetRegisterInstallationTool = defineTool({ name: "loopover_fleet_register_installation", title: "Register a fleet installation", - description: "Owner only. Record a GitHub App installation in the fleet registry so its repos are reachable by fleet jobs.", + description: + "Owner only. Opt a GitHub App installation into (or, with registered=false, out of) the fleet registry. Only REGISTERED installations count toward the public counter and are eligible for token brokering; opting out also blocks OAuth self-enrollment until an operator opts back in. Refuses an installation the webhook has never recorded — an install must arrive that way first.", category: "fleet", auth: "internal", locality: "remote", diff --git a/test/unit/mcp-management-tools.test.ts b/test/unit/mcp-management-tools.test.ts index ffe3dc2b6b..5790bab706 100644 --- a/test/unit/mcp-management-tools.test.ts +++ b/test/unit/mcp-management-tools.test.ts @@ -1,6 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { LoopoverMcp } from "../../src/mcp/server"; import { resetInstanceDiagnosticsForTesting, @@ -30,6 +31,24 @@ async function connect(env: Env, identity: AuthIdentity = INTERNAL) { } /** The tool's structured payload. Typed off the SDK's own loose result shape rather than a narrow local one. */ +/** + * A client that ADVERTISES elicitation and answers every prompt the same way. Without the capability the + * server's confirmDestructive falls through on the schema-level confirm alone, so the decline path — and + * the "an elicitation that errors is a decline, not an implicit yes" rule — is only reachable from here. + */ +async function connectEliciting(env: Env, action: "accept" | "decline" | "throw") { + const server = new LoopoverMcp(env, INTERNAL).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "eliciting-test", version: "0.1.0" }, { capabilities: { elicitation: {} } }); + client.setRequestHandler(ElicitRequestSchema, async () => { + if (action === "throw") throw new Error("client blew up mid-prompt"); + return { action: action === "accept" ? "accept" : "decline" }; + }); + await client.connect(clientTransport); + return client; +} + function structured(result: { structuredContent?: unknown } | Record): Record { return ((result as { structuredContent?: unknown }).structuredContent ?? {}) as Record; } @@ -56,6 +75,56 @@ describe("ops tools when the queue backend has no dead-letter admin (#9522)", () }); }); +/** A JOBS binding that DOES expose dead-letter admin — the self-host queue's shape, not Cloudflare's. */ +function withDeadLetterAdmin(env: Env, overrides: Record = {}): Env { + const job = { id: 1, jobType: "refresh-registry", attempts: 3, lastError: "boom", createdAtMs: 1, deadAtMs: 2 }; + (env as unknown as { JOBS: Record }).JOBS = { + send: async () => undefined, + listDeadLetterJobs: () => [job], + deadCount: () => 1, + replayDeadLetterJob: () => true, + deleteDeadLetterJob: () => true, + purgeDeadLetterJobs: () => 3, + ...overrides, + }; + return env; +} + +describe("dead-letter tools against a backend that HAS the admin surface (#9522)", () => { + it("lists the parked jobs with their total", async () => { + const client = await connect(withDeadLetterAdmin(createTestEnv())); + const result = structured(await client.callTool({ name: "loopover_ops_list_dead_letter_jobs", arguments: {} })); + expect(result.total).toBe(1); + expect((result.items as unknown[]).length).toBe(1); + await client.close(); + }); + + it("replays a job and reports ok", async () => { + const client = await connect(withDeadLetterAdmin(createTestEnv())); + expect(structured(await client.callTool({ name: "loopover_ops_replay_dead_letter_job", arguments: { id: 1 } }))).toMatchObject({ ok: true, id: 1 }); + await client.close(); + }); + + it("reports notFound for an id the queue no longer has", async () => { + const client = await connect(withDeadLetterAdmin(createTestEnv(), { replayDeadLetterJob: () => false, deleteDeadLetterJob: () => false })); + expect(structured(await client.callTool({ name: "loopover_ops_replay_dead_letter_job", arguments: { id: 9 } }))).toMatchObject({ notFound: true }); + expect(structured(await client.callTool({ name: "loopover_ops_delete_dead_letter_job", arguments: { id: 9, confirm: true } }))).toMatchObject({ notFound: true }); + await client.close(); + }); + + it("deletes a job and reports ok", async () => { + const client = await connect(withDeadLetterAdmin(createTestEnv())); + expect(structured(await client.callTool({ name: "loopover_ops_delete_dead_letter_job", arguments: { id: 1, confirm: true } }))).toMatchObject({ ok: true }); + await client.close(); + }); + + it("purges every job and reports how many went", async () => { + const client = await connect(withDeadLetterAdmin(createTestEnv())); + expect(structured(await client.callTool({ name: "loopover_ops_purge_dead_letter_jobs", arguments: { confirm: true } }))).toMatchObject({ ok: true, purged: 3 }); + await client.close(); + }); +}); + describe("kill switch (#9522)", () => { it("reads the released state, engages it, and reads back the engaged state", async () => { const env = createTestEnv(); @@ -372,3 +441,252 @@ describe("self-host instance diagnostics (#9522)", () => { await client.close(); }); }); + +describe("destructive confirmation via elicitation (#9522)", () => { + it("a DECLINE leaves the action undone and reports it as a structured result, not an error", async () => { + const client = await connectEliciting(withDeadLetterAdmin(createTestEnv()), "decline"); + const result = await client.callTool({ name: "loopover_ops_delete_dead_letter_job", arguments: { id: 1, confirm: true } }); + expect(result.isError, "declining is a valid answer, not a failure").toBeFalsy(); + expect(structured(result).declined).toBe(true); + await client.close(); + }); + + it("an ACCEPT proceeds", async () => { + const client = await connectEliciting(withDeadLetterAdmin(createTestEnv()), "accept"); + expect(structured(await client.callTool({ name: "loopover_ops_delete_dead_letter_job", arguments: { id: 1, confirm: true } }))).toMatchObject({ ok: true }); + await client.close(); + }); + + it("an elicitation that ERRORS is treated as a decline, never as an implicit yes", async () => { + // A client that advertises the capability then fails to answer must not silently authorize an + // irreversible action. + const client = await connectEliciting(withDeadLetterAdmin(createTestEnv()), "throw"); + expect(structured(await client.callTool({ name: "loopover_ops_purge_dead_letter_jobs", arguments: { confirm: true } })).declined).toBe(true); + await client.close(); + }); + + it("declining the kill-switch RELEASE leaves it engaged", async () => { + const env = createTestEnv(); + const engager = await connect(env); + await engager.callTool({ name: "loopover_ops_set_kill_switch", arguments: { frozen: true } }); + await engager.close(); + + const client = await connectEliciting(env, "decline"); + const result = structured(await client.callTool({ name: "loopover_ops_set_kill_switch", arguments: { frozen: false, confirm: true } })); + expect(result.declined).toBe(true); + expect(result.frozen, "the switch must still be engaged after a decline").toBe(true); + await client.close(); + }); + + it("declining a config push sends nothing", async () => { + const client = await connectEliciting(createTestEnv(), "decline"); + const result = structured( + await client.callTool({ name: "loopover_fleet_config_push", arguments: { installationIds: [1], pushId: "p", message: "m", confirm: true } }), + ); + expect(result.declined).toBe(true); + await client.close(); + }); + + it("declining an enrollment revoke leaves it active", async () => { + const client = await connectEliciting(createTestEnv({ ORB_BROKER_ENABLED: "true" }), "decline"); + expect(structured(await client.callTool({ name: "loopover_fleet_revoke_enrollment", arguments: { enrollId: "e-1", confirm: true } })).declined).toBe(true); + await client.close(); + }); + + it("declining a tenant destroy leaves it standing", async () => { + const client = await connectEliciting( + createTestEnv({ LOOPOVER_CONTROL_PLANE_URL: "https://cp.invalid", LOOPOVER_CONTROL_PLANE_ADMIN_TOKEN: "tok" }), + "decline", + ); + expect(structured(await client.callTool({ name: "loopover_tenant_destroy", arguments: { name: "acme", product: "ams", confirm: true } })).declined).toBe(true); + await client.close(); + }); +}); + +/** Record an installation the way the webhook would, so the register/enrollment paths have a real row. */ +async function seedInstallation(env: Env, installationId = 4242): Promise { + await env.DB.prepare( + "INSERT INTO orb_github_installations (installation_id, account_login, registered, first_seen_at, last_event_at) VALUES (?, ?, 1, ?, ?)", + ) + .bind(installationId, "acme", "2026-07-01T00:00:00.000Z", "2026-07-01T00:00:00.000Z") + .run(); +} + +describe("fleet write paths against real rows (#9522)", () => { + it("registers a recorded installation and audits it", async () => { + const env = createTestEnv(); + await seedInstallation(env); + const client = await connect(env); + expect(structured(await client.callTool({ name: "loopover_fleet_register_installation", arguments: { installationId: 4242 } }))).toMatchObject({ + installationId: 4242, + registered: true, + }); + await client.close(); + }); + + it("surfaces the missing-credentials failure rather than reporting a backfill that did not run", async () => { + // Reconciling against GitHub needs the Orb App credentials, which a test env has none of. The point is + // that it FAILS LOUD: a silent "backfilled 0" would read as a clean reconcile. + const client = await connect(createTestEnv()); + const result = await client.callTool({ name: "loopover_fleet_backfill_installations", arguments: {} }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("credentials are not configured"); + await client.close(); + }); + + it("opts a recorded installation OUT, blocking self-enrollment", async () => { + const env = createTestEnv(); + await seedInstallation(env, 5150); + const client = await connect(env); + expect( + structured(await client.callTool({ name: "loopover_fleet_register_installation", arguments: { installationId: 5150, registered: false } })), + ).toMatchObject({ registered: false }); + await client.close(); + }); + + it("refuses to enroll an installation that is not REGISTERED", async () => { + // Registration is the onboarding gate: an unregistered install must not be able to broker tokens. + const env = createTestEnv({ ORB_BROKER_ENABLED: "true" }); + await env.DB.prepare( + "INSERT INTO orb_github_installations (installation_id, account_login, registered, first_seen_at, last_event_at) VALUES (?, ?, 0, ?, ?)", + ) + .bind(6161, "unregistered", "2026-07-01T00:00:00.000Z", "2026-07-01T00:00:00.000Z") + .run(); + const client = await connect(env); + expect(structured(await client.callTool({ name: "loopover_fleet_issue_enrollment", arguments: { installationId: 6161 } })).error).toBeDefined(); + await client.close(); + }); + + it("issues, rotates, and revokes an enrollment for a registered installation", async () => { + const env = createTestEnv({ ORB_BROKER_ENABLED: "true" }); + await seedInstallation(env); + const client = await connect(env); + + const issued = structured(await client.callTool({ name: "loopover_fleet_issue_enrollment", arguments: { installationId: 4242 } })); + expect(String(issued.secret ?? ""), "the plaintext is shown exactly once").not.toBe(""); + const enrollId = String(issued.enrollId); + + // Issuing again without rotate is a conflict; rotate=true replaces it. + const rotated = structured(await client.callTool({ name: "loopover_fleet_rotate_enrollment", arguments: { installationId: 4242 } })); + expect(rotated.secret ?? rotated.error, "rotation must answer either way").toBeDefined(); + + const revoked = structured(await client.callTool({ name: "loopover_fleet_revoke_enrollment", arguments: { enrollId, confirm: true } })); + expect(revoked.error ?? revoked.enrollId ?? revoked.revoked).toBeDefined(); + await client.close(); + }); +}); + +describe("tenant write paths against a reachable control plane (#9522)", () => { + function controlPlaneEnv() { + return createTestEnv({ LOOPOVER_CONTROL_PLANE_URL: "https://cp.example", LOOPOVER_CONTROL_PLANE_ADMIN_TOKEN: "tok" }); + } + + function stubControlPlane(body: unknown) { + vi.stubGlobal("fetch", async () => new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } })); + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("creates a tenant and reports the record", async () => { + stubControlPlane({ tenant: "acme", product: "ams", state: "provisioning" }); + const client = await connect(controlPlaneEnv()); + expect(structured(await client.callTool({ name: "loopover_tenant_create", arguments: { name: "acme", product: "ams" } }))).toMatchObject({ + configured: true, + state: "provisioning", + }); + await client.close(); + }); + + it("lists tenants, and tolerates a payload whose `tenants` is not an array", async () => { + stubControlPlane({ tenants: [{ tenant: "acme" }] }); + const client = await connect(controlPlaneEnv()); + expect(structured(await client.callTool({ name: "loopover_tenant_list", arguments: {} })).configured).toBe(true); + await client.close(); + + // A payload with no `tenants` key at all: the Array.isArray guard is what keeps the summary from + // reading `.length` off undefined. + stubControlPlane({}); + const other = await connect(controlPlaneEnv()); + expect(structured(await other.callTool({ name: "loopover_tenant_list", arguments: {} })).configured).toBe(true); + await other.close(); + }); + + it("points a tenant at an installation", async () => { + stubControlPlane({ tenant: "acme", orbInstallationId: 7 }); + const client = await connect(controlPlaneEnv()); + expect( + structured(await client.callTool({ name: "loopover_tenant_set_orb_installation", arguments: { name: "acme", product: "orb", orbInstallationId: 7 } })), + ).toMatchObject({ configured: true }); + await client.close(); + }); + + it("destroys a tenant", async () => { + stubControlPlane({ tenant: "acme", state: "torn down" }); + const client = await connect(controlPlaneEnv()); + expect(structured(await client.callTool({ name: "loopover_tenant_destroy", arguments: { name: "acme", product: "ams", confirm: true } }))).toMatchObject({ + configured: true, + }); + await client.close(); + }); +}); + +describe("requireOperator over a SESSION identity (#9522)", () => { + // The static-credential path is covered above; a session must actually hold the operator role, and there + // is no per-repo scoping that could stand in for it. + const session: AuthIdentity = { + kind: "session", + actor: "octocat", + session: { token: "t", githubLogin: "octocat", githubUserId: 1, createdAt: "2026-07-01T00:00:00.000Z", expiresAt: "2030-01-01T00:00:00.000Z" } as never, + }; + + it("refuses a session with no operator role", async () => { + const client = await connect(createTestEnv(), session); + const result = await client.callTool({ name: "loopover_ops_get_kill_switch", arguments: {} }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/operator role|insufficient_role/); + await client.close(); + }); +}); + +describe("instance diagnostics render every optional shape (#9522)", () => { + const ADMIN_ID: AuthIdentity = { kind: "static", actor: "mcp-admin" }; + + it("reports an unknown version and no redeploy hint when the reader omits them", async () => { + setInstanceStatusReader(async () => ({})); + const client = await connect(createTestEnv({ LOOPOVER_MCP_ADMIN_ENABLED: "true" }), ADMIN_ID); + expect(structured(await client.callTool({ name: "loopover_admin_get_status", arguments: {} })).configured).toBe(true); + await client.close(); + }); + + it("reports an untruncated tail and a never-backed-up instance", async () => { + setInstanceLogTailer(async () => ({ lines: ["a"], truncated: false })); + setInstanceBackupStatusReader(async () => ({})); + const client = await connect(createTestEnv({ LOOPOVER_MCP_ADMIN_ENABLED: "true" }), ADMIN_ID); + expect(structured(await client.callTool({ name: "loopover_admin_tail_logs", arguments: {} })).truncated).toBe(false); + expect(structured(await client.callTool({ name: "loopover_admin_get_backup_status", arguments: {} })).configured).toBe(true); + await client.close(); + }); +}); + +describe("run-only jobs (#9522)", () => { + it("runs refresh-installation-health inline", async () => { + const client = await connect(createTestEnv()); + const result = await client.callTool({ name: "loopover_fleet_run_job", arguments: { job: "refresh-installation-health", mode: "run" } }); + expect(structured(result).unsupportedMode).toBeUndefined(); + await client.close(); + }); + + it("passes a numeric limit through to a run-only job, and omits a non-numeric one", async () => { + const client = await connect(createTestEnv()); + for (const payload of [{ limit: 5 }, { limit: "five" }]) { + const result = await client.callTool({ + name: "loopover_fleet_run_job", + arguments: { job: "backfill-contributor-gate-history", mode: "run", payload }, + }); + expect(structured(result).unsupportedMode).toBeUndefined(); + } + await client.close(); + }); +}); From 8b5c4e80d6780195c85006a2ca6b61be93985cc1 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:18:46 -0700 Subject: [PATCH 6/6] chore(docs): regenerate the tool-reference surfaces for the new management families #9521 landed its generator, so the two READMEs and the UI tool-reference module are now generated from the contract registry. The 25 tools this branch adds flow through it with no hand-edits, which is what that issue built it for. --- .../loopover-ui/src/lib/mcp-tool-reference.ts | 184 +++++++++++++++++- 1 file changed, 183 insertions(+), 1 deletion(-) diff --git a/apps/loopover-ui/src/lib/mcp-tool-reference.ts b/apps/loopover-ui/src/lib/mcp-tool-reference.ts index 31e4363cea..3118d05aba 100644 --- a/apps/loopover-ui/src/lib/mcp-tool-reference.ts +++ b/apps/loopover-ui/src/lib/mcp-tool-reference.ts @@ -10,6 +10,20 @@ export type McpToolReferenceEntry = { }; export const MCP_TOOL_REFERENCE: readonly McpToolReferenceEntry[] = [ + { + "name": "loopover_admin_doctor", + "category": "admin", + "locality": "remote", + "availability": "selfhost", + "description": "Self-hosted-operator only. The ORB counterpart of the miner's doctor: read-only checks over secret presence and shape, GitHub App auth, database/Redis/Qdrant reachability, the config-dir mount and LOOPOVER_REPO_CONFIG_DIR writability, broker enrollment validity, clock skew, and disk pressure. Every check runs and reports its own pass/warn/fail — nothing is mutated and nothing stops at the first failure. Requires LOOPOVER_MCP_ADMIN_TOKEN." + }, + { + "name": "loopover_admin_get_backup_status", + "category": "admin", + "locality": "remote", + "availability": "selfhost", + "description": "Self-hosted-operator only. When this instance last backed up and what artifacts the backup container has on disk, with sizes. Read-only — it never triggers a backup. Requires LOOPOVER_MCP_ADMIN_TOKEN; returns configured=false where no backup volume is mounted." + }, { "name": "loopover_admin_get_config", "category": "admin", @@ -17,6 +31,13 @@ export const MCP_TOOL_REFERENCE: readonly McpToolReferenceEntry[] = [ "availability": "selfhost", "description": "Self-hosted-operator only. Read this instance's own private .loopover.yml config: the merged effective config for a repo (shared base + global default + per-repo override), or just the raw global-default layer, or just the raw per-repo layer. Requires LOOPOVER_MCP_ADMIN_TOKEN. Returns configured=false if LOOPOVER_REPO_CONFIG_DIR is unset." }, + { + "name": "loopover_admin_get_status", + "category": "admin", + "locality": "remote", + "availability": "selfhost", + "description": "Self-hosted-operator only. One answer for 'what is this instance running and is it healthy': app version against the orb-manifest target (and whether a redeploy is due), uptime, the /ready probe's detail, per-component health, queue depth, and the last redeploy. Read-only and redaction-scrubbed. Requires LOOPOVER_MCP_ADMIN_TOKEN; returns configured=false where the capability is not wired." + }, { "name": "loopover_admin_list_config_backups", "category": "admin", @@ -24,6 +45,20 @@ export const MCP_TOOL_REFERENCE: readonly McpToolReferenceEntry[] = [ "availability": "selfhost", "description": "Self-hosted-operator only. List timestamped backups (newest first) created by loopover_admin_write_config for the global-default or a specific repo's config. Requires LOOPOVER_MCP_ADMIN_TOKEN." }, + { + "name": "loopover_admin_rotate_secret", + "category": "admin", + "locality": "remote", + "availability": "selfhost", + "description": "Self-hosted-operator only. Rotate one of this instance's own secret files (e.g. claude_code_oauth_token) in place on the host, via the redeploy companion (#7723) -- the app container cannot write these itself, the Compose secrets mount is read-only. The value must be the bare credential: a single line, no comment or label line, no surrounding whitespace (the loader only trims, so anything else silently becomes part of the credential). Backs the previous value up first, and writes in place so the running container's inode-pinned bind mount sees it immediately. For claude_code_oauth_token no restart is needed -- the token is re-read per AI call. Requires LOOPOVER_MCP_ADMIN_TOKEN. Returns configured=false if REDEPLOY_COMPANION_TOKEN is unset or the companion isn't reachable." + }, + { + "name": "loopover_admin_tail_logs", + "category": "admin", + "locality": "remote", + "availability": "selfhost", + "description": "Self-hosted-operator only. Return a BOUNDED tail of this instance's own logs — capped by both line count and total bytes, and passed through the same redaction scrubbing every other operator surface uses before it leaves the host. There is no follow mode: this returns a snapshot and completes. Requires LOOPOVER_MCP_ADMIN_TOKEN." + }, { "name": "loopover_admin_trigger_redeploy", "category": "admin", @@ -269,6 +304,76 @@ export const MCP_TOOL_REFERENCE: readonly McpToolReferenceEntry[] = [ "availability": "both", "description": "Cross-repo discovery: find high-fit contribution opportunities across registered Gittensor repos. Returns a ranked, public-safe list filtered by your MinerGoalSpec (lane, min rank score, languages). Metadata-only, no GitHub writes." }, + { + "name": "loopover_fleet_backfill_installations", + "category": "fleet", + "locality": "remote", + "availability": "cloud", + "description": "Owner only. Reconcile the installation registry against GitHub, adding installations the fleet has not recorded yet. Idempotent — re-running adds nothing new once reconciled." + }, + { + "name": "loopover_fleet_config_push", + "category": "fleet", + "locality": "remote", + "availability": "cloud", + "description": "Operator only. Push the current configuration out to the fleet. Takes effect on every instance that picks it up, so it is not scoped to one repo or instance. Requires confirm=true." + }, + { + "name": "loopover_fleet_issue_enrollment", + "category": "fleet", + "locality": "remote", + "availability": "cloud", + "description": "Owner only. Mint a token-broker enrollment secret for a REGISTERED installation, to hand to that maintainer's self-hosted container so it brokers GitHub tokens instead of holding an App key. The secret is returned exactly once and only its hash is stored. An installation that already has a live enrollment is a conflict unless rotate=true, which replaces it and invalidates the previous secret. Requires the broker to be enabled." + }, + { + "name": "loopover_fleet_list_installations", + "category": "fleet", + "locality": "remote", + "availability": "cloud", + "description": "Owner only. Every GitHub App installation the fleet knows about, with its recorded health. Read-only." + }, + { + "name": "loopover_fleet_list_instances", + "category": "fleet", + "locality": "remote", + "availability": "cloud", + "description": "Owner only. Every self-hosted ORB instance that has ingested signals, newest activity first: whether it is registered for calibration, when it was first and last seen, and how many signals it has contributed. Read-only." + }, + { + "name": "loopover_fleet_register_installation", + "category": "fleet", + "locality": "remote", + "availability": "cloud", + "description": "Owner only. Opt a GitHub App installation into (or, with registered=false, out of) the fleet registry. Only REGISTERED installations count toward the public counter and are eligible for token brokering; opting out also blocks OAuth self-enrollment until an operator opts back in. Refuses an installation the webhook has never recorded — an install must arrive that way first." + }, + { + "name": "loopover_fleet_register_instance", + "category": "fleet", + "locality": "remote", + "availability": "cloud", + "description": "Owner only. Opt a self-hosted ORB instance into (or, with registered=false, out of) fleet calibration. Registering MINTS A FRESH per-instance ingest credential and returns it once in plaintext — only its hash is stored, and calling this again rotates the credential, invalidating the previous one and breaking that instance's ingest until it is updated." + }, + { + "name": "loopover_fleet_revoke_enrollment", + "category": "fleet", + "locality": "remote", + "availability": "cloud", + "description": "Owner only. Revoke one token-broker enrollment by its id. Works for any secret type, and is idempotent — revoking an already-revoked enrollment still reports success. Irreversible: that container immediately loses its ability to broker GitHub tokens and must be re-enrolled. Requires confirm=true." + }, + { + "name": "loopover_fleet_rotate_enrollment", + "category": "fleet", + "locality": "remote", + "availability": "cloud", + "description": "Owner only. Replace an installation's token-broker enrollment with a fresh secret, returned once in plaintext. The previous secret stops working immediately, so that container cannot broker tokens until it is updated. Equivalent to issuing with rotate=true." + }, + { + "name": "loopover_fleet_run_job", + "category": "fleet", + "locality": "remote", + "availability": "cloud", + "description": "Owner only. Enqueue or inline-run one of the fleet's maintenance jobs — the interactive counterpart to the scheduled self-host-maintenance workflow, which remains the cron path. mode=enqueue queues it; mode=run executes it inline and returns its result. Not every job offers both modes; an unsupported pairing returns unsupportedMode with the list of modes that job does support." + }, { "name": "loopover_generate_contributor_issue_drafts", "category": "maintainer", @@ -362,7 +467,7 @@ export const MCP_TOOL_REFERENCE: readonly McpToolReferenceEntry[] = [ }, { "name": "loopover_get_fleet_analytics", - "category": "maintainer", + "category": "fleet", "locality": "remote", "availability": "both", "description": "Operator-only: aggregated gate-calibration analytics across the self-host fleet -- median merge/close precision, false-positive + reversal rates, cycle-time percentiles, and per-instance outliers. Measurement only." @@ -682,6 +787,55 @@ export const MCP_TOOL_REFERENCE: readonly McpToolReferenceEntry[] = [ "availability": "both", "description": "Build a LOCAL-execution spec to open a pull request from your branch (run it with your own gh creds; loopover never performs the write)." }, + { + "name": "loopover_ops_delete_dead_letter_job", + "category": "ops", + "locality": "remote", + "availability": "both", + "description": "Operator only. Permanently drop one parked dead-letter job. Irreversible — the job is not re-enqueued and its payload is gone. Requires confirm=true. Records an operator.dlq_job_deleted audit event." + }, + { + "name": "loopover_ops_get_kill_switch", + "category": "ops", + "locality": "remote", + "availability": "both", + "description": "Operator only. Report whether the global agent kill switch is engaged, and who set it when. Reads strictly — unlike the enforcement hot path, a read failure here surfaces as an error rather than a falsely reassuring 'unfrozen'." + }, + { + "name": "loopover_ops_get_operator_dashboard", + "category": "ops", + "locality": "remote", + "availability": "both", + "description": "Operator only. The operator dashboard rollup over a trailing window: the same payload the HTTP dashboard route serves. Read-only." + }, + { + "name": "loopover_ops_list_dead_letter_jobs", + "category": "ops", + "locality": "remote", + "availability": "both", + "description": "Operator only. Page the self-hosted queue's dead-letter table: jobs that exhausted their retries and are parked for inspection. Read-only. Returns unavailable=true on a deployment whose queue backend exposes no dead-letter admin (Cloudflare Queues)." + }, + { + "name": "loopover_ops_purge_dead_letter_jobs", + "category": "ops", + "locality": "remote", + "availability": "both", + "description": "Operator only. Permanently drop ALL parked dead-letter jobs. Irreversible and unbounded — prefer deleting individual ids unless the whole table is known-garbage. Requires confirm=true. Records an operator.dlq_purged audit event." + }, + { + "name": "loopover_ops_replay_dead_letter_job", + "category": "ops", + "locality": "remote", + "availability": "both", + "description": "Operator only. Re-enqueue one parked dead-letter job for another attempt. Records an operator.dlq_job_replayed audit event. Returns notFound=true if the id is already gone, unavailable=true where the queue backend has no dead-letter admin." + }, + { + "name": "loopover_ops_set_kill_switch", + "category": "ops", + "locality": "remote", + "availability": "both", + "description": "Operator only. Engage or release the global agent kill switch. Engaging (frozen=true) halts every agent action fleet-wide immediately. RELEASING (frozen=false) re-arms automation everywhere and requires confirm=true. Records an audit event either way." + }, { "name": "loopover_plan_idea_claims", "category": "agent", @@ -864,6 +1018,34 @@ export const MCP_TOOL_REFERENCE: readonly McpToolReferenceEntry[] = [ "availability": "both", "description": "Suggest boundary-case test criteria for a change, from changed-file paths plus precomputed boundary-touch metadata the caller's own local diff scan produced. The remote boundary never accepts patch or source text. Advisory only -- returns criteria for the caller's own agent to scaffold from; never blocks or writes." }, + { + "name": "loopover_tenant_create", + "category": "tenant", + "locality": "remote", + "availability": "cloud", + "description": "Control-plane admin only. Provision a hosted tenant: its container, database, and secrets. `schedule` is accepted only for product \"ams\" and `orbInstallationId` only for product \"orb\" — the route rejects the mismatched pairing rather than ignoring it." + }, + { + "name": "loopover_tenant_destroy", + "category": "tenant", + "locality": "remote", + "availability": "cloud", + "description": "Control-plane admin only. Tear down a hosted tenant: its container, database, and secrets. Irreversible — the tenant's data does not survive. Requires confirm=true. A tenant still provisioning is refused rather than torn down mid-flight, since there is nothing settled yet to remove." + }, + { + "name": "loopover_tenant_list", + "category": "tenant", + "locality": "remote", + "availability": "cloud", + "description": "Control-plane admin only. Every hosted tenant with its status and timestamps. Read-only, and the payload is the registry's own redacted record — it carries no tenant secrets." + }, + { + "name": "loopover_tenant_set_orb_installation", + "category": "tenant", + "locality": "remote", + "availability": "cloud", + "description": "Control-plane admin only. Point an existing ORB tenant at a GitHub App installation id. Valid only for product \"orb\"." + }, { "name": "loopover_validate_config", "category": "utility",