diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 1363e1b649..4691c0aeac 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -1232,6 +1232,11 @@ const STDIO_TOOL_DESCRIPTORS = [ // #6152 — the maintain CLI's REST surface, exposed as tools so an agent can drive it without shelling out. // Categories mirror the remote server's MCP_TOOL_CATEGORIES entries for the same names, so a caller sees one // consistent grouping across both surfaces. + { + name: "loopover_get_automation_state", + category: "agent", + description: "Return a repo's agent automation state: the per-action autonomy levels, kill-switch / dry-run mode, GitHub write-permission readiness, and how many auto_with_approval actions are awaiting a maintainer decision — the read-side counterpart to `loopover-mcp maintain pause|resume|set-level`. Maintainer access required.", + }, { name: "loopover_list_pending_actions", category: "agent", @@ -2407,6 +2412,18 @@ function toolRepoBase(owner, repo) { return `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; } +registerStdioTool( + "loopover_get_automation_state", + { + description: stdioToolDescription("loopover_get_automation_state"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }) => { + const payload = await apiGet(`${toolRepoBase(owner, repo)}/automation-state`); + return toolResult(`Agent automation for ${owner}/${repo}: mode=${payload.mode ?? "unknown"}.`, payload); + }, +); + registerStdioTool( "loopover_list_pending_actions", { diff --git a/src/api/routes.ts b/src/api/routes.ts index 1f02055a5a..80261d852f 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -205,6 +205,7 @@ import { buildStructuralImprovementAssessment } from "../signals/improvement"; import { evaluateEscalation } from "../loop-escalation"; import { buildResultsPayload } from "../results-payload"; import { buildProgressSnapshot } from "../loop-progress"; +import { buildAutomationStateResponse } from "../automation-state"; import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake"; import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings"; import { @@ -2503,6 +2504,23 @@ export function createApp() { return c.json(response); }); + // #6742: REST mirror of the loopover_get_automation_state MCP tool (src/mcp/server.ts). Same + // buildAutomationStateResponse the tool calls, and the same session/static-mcp repo-access gate as + // issue-quality above, so the two surfaces can never drift on either the data or who may read it. + app.get("/v1/repos/:owner/:repo/automation-state", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const identity = await authenticateRequestIdentity(c); + /* v8 ignore next -- Protected middleware rejects unauthenticated private routes before route-specific repo guards. */ + if (!identity) return c.json({ error: "unauthorized" }, 401); + const repo = identity.kind === "session" ? await getRepository(c.env, fullName) : null; + if (identity.kind === "session") { + const forbidden = await requireSessionRepoAccess(c, identity, fullName, repo); + if (forbidden) return forbidden; + } + if (identity.kind === "static" && identity.actor === "mcp" && !(await import("../auth/security")).isMcpReadRepoAllowed(c.env.MCP_READ_REPO_ALLOWLIST, fullName)) return c.json({ error: "forbidden_repo" }, 403); + return c.json(await buildAutomationStateResponse(c.env, fullName)); + }); + app.post("/v1/repos/:owner/:repo/validate-linked-issue", async (c) => { const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; const identity = await authenticateRequestIdentity(c); @@ -6023,6 +6041,7 @@ function canSessionAccessPath(env: Env, identity: Extract { + const [repo, settings, pendingActionCount] = await Promise.all([ + getRepository(env, repoFullName), + resolveRepositorySettings(env, repoFullName), + countPendingAgentActions(env, { repoFullName, status: "pending" }), + ]); + const autonomy = settings.autonomy; + const actingActionClasses = AGENT_ACTION_CLASSES.filter((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); + const installation = repo?.installationId ? await getInstallation(env, repo.installationId) : null; + const mode = resolveAgentActionMode({ + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + agentPaused: settings.agentPaused, + agentDryRun: settings.agentDryRun, + }); + const permissionReadiness = resolveAgentPermissionReadiness({ autonomy, installationPermissions: installation?.permissions ?? null }); + return { + repoFullName, + configured: actingActionClasses.length > 0, + autonomy, + autoMaintain: settings.autoMaintain, + agentPaused: settings.agentPaused === true, + agentDryRun: settings.agentDryRun === true, + mode, + permissionReadiness, + actingActionClasses, + pendingActionCount, + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5310504255..c49a9d005a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -33,14 +33,12 @@ import { import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; import { countOpenIssues, - countPendingAgentActions, countOpenPullRequests, createPendingAgentActionIfAbsent, getBounty, listBountiesByRepo, getContributorEvidence, getLatestRepoGithubTotalsSnapshot, - getInstallation, getIssue, getPendingAgentAction, getPullRequest, @@ -159,9 +157,7 @@ import { import { buildTestEvidenceReport, classifyTestCoverage, hasLocalTestEvidence, isCodeFile, isTestPath, TEST_FRAMEWORKS } from "../signals/test-evidence"; import { applyStepResult, buildPlanDag, nextReadySteps, planProgress, validatePlanDag, type PlanDag } from "../services/plan-dag"; import { buildFocusManifestValidation } from "../services/focus-manifest-validation"; -import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution"; -import { AGENT_ACTION_CLASSES, AUTONOMY_LEVELS, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy"; -import { resolveRepositorySettings } from "../settings/repository-settings"; +import { AUTONOMY_LEVELS } from "../settings/autonomy"; import { MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest"; import { loadPublicRepoFocusManifest, loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildPredictedGateVerdict, type PredictedGateVerdict } from "../rules/predicted-gate"; @@ -171,6 +167,7 @@ import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea- import { buildResultsPayload } from "../results-payload"; import { buildProgressSnapshot } from "../loop-progress"; import { evaluateEscalation } from "../loop-escalation"; +import { buildAutomationStateResponse } from "../automation-state"; import { buildStructuralImprovementAssessment } from "../signals/improvement"; import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation"; import { buildRepoDataQuality } from "../signals/data-quality"; @@ -4023,33 +4020,14 @@ export class LoopoverMcp { // #784 — read the agent automation state for a repo. Repo-access scoped; surfaces the count (not the // details) of the approval queue — the full queue + accept/reject stay behind the maintainer-authed REST API. + // The response shape is built by the shared buildAutomationStateResponse (#6742), also used by the REST mirror. private async getAutomationState(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoAccess(fullName); - const [repo, settings, pendingActionCount] = await Promise.all([ - getRepository(this.env, fullName), - resolveRepositorySettings(this.env, fullName), - countPendingAgentActions(this.env, { repoFullName: fullName, status: "pending" }), - ]); - const autonomy = settings.autonomy; - const actingActionClasses = AGENT_ACTION_CLASSES.filter((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); - const installation = repo?.installationId ? await getInstallation(this.env, repo.installationId) : null; - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(this.env) || (await isGlobalAgentFrozen(this.env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); - const permissionReadiness = resolveAgentPermissionReadiness({ autonomy, installationPermissions: installation?.permissions ?? null }); + const data = await buildAutomationStateResponse(this.env, fullName); return { - summary: `Agent automation for ${fullName}: mode=${mode}, ${actingActionClasses.length} acting class(es), ${pendingActionCount} pending approval(s).`, - data: { - repoFullName: fullName, - configured: actingActionClasses.length > 0, - autonomy, - autoMaintain: settings.autoMaintain, - agentPaused: settings.agentPaused === true, - agentDryRun: settings.agentDryRun === true, - mode, - permissionReadiness, - actingActionClasses, - pendingActionCount, - }, + summary: `Agent automation for ${fullName}: mode=${data.mode}, ${data.actingActionClasses.length} acting class(es), ${data.pendingActionCount} pending approval(s).`, + data, }; } diff --git a/test/unit/mcp-cli-automation-state.test.ts b/test/unit/mcp-cli-automation-state.test.ts new file mode 100644 index 0000000000..023834d45d --- /dev/null +++ b/test/unit/mcp-cli-automation-state.test.ts @@ -0,0 +1,94 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, run, startFixtureServer } from "./support/mcp-cli-harness"; + +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); +const FORBIDDEN_PUBLIC_TERMS = /wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i; + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; +let apiUrl: string; +let capturedRequests: Array<{ url: string; method: string }>; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "loopover-automation-state-")); + capturedRequests = []; + apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/automation-state")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + LOOPOVER_CONFIG_DIR: configDir, + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_API_TIMEOUT_MS: "5000", + }, + }); + client = new Client({ name: "automation-state-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +} + +describe("loopover_get_automation_state stdio proxy (#6742)", () => { + beforeEach(connect); + afterEach(disconnect); + + it("registers the tool in the stdio server tool list", async () => { + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toContain("loopover_get_automation_state"); + }); + + it("proxies the call to /automation-state via apiGet and returns the payload", async () => { + const result = await client.callTool({ + name: "loopover_get_automation_state", + arguments: { owner: "owner", repo: "repo" }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/repos/owner/repo/automation-state"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + expect(text).toContain("owner/repo"); + expect(text).toContain("permissionReadiness"); + expect(text).toContain("live"); + }); + + it("url-encodes an owner/repo with reserved characters before hitting the route", async () => { + await client.callTool({ name: "loopover_get_automation_state", arguments: { owner: "acme", repo: "widgets 7" } }).catch(() => undefined); + expect(capturedRequests[0]?.url).toContain("/v1/repos/acme/widgets%207/automation-state"); + }); + + it("rejects a missing owner/repo at the input-schema boundary, never issuing a request", async () => { + const result = await client.callTool({ name: "loopover_get_automation_state", arguments: { owner: "", repo: "repo" } }); + expect(result.isError).toBe(true); + expect(capturedRequests.length).toBe(0); + }); + + it("lists the tool via loopover-mcp tools", () => { + const payload = JSON.parse(run(["tools", "--json"])) as { + tools: Array<{ name: string; description: string }>; + }; + const tool = payload.tools.find((entry) => entry.name === "loopover_get_automation_state"); + expect(tool?.description).toMatch(/autonomy levels/i); + expect(tool?.description.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 991ae1d501..d39d0feefd 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -17,6 +17,7 @@ // (#6942 registered loopover_get_maintainer_lane without bumping this pin — live count became 72.) // (#6756 registered the loopover_plan_idea_claims CLI mirror, taking the count from 72 to 73.) // (#6734 registered the loopover_get_repo_outcome_patterns CLI mirror, taking the count from 74 to 75.) +// (#6742 registered the loopover_get_automation_state CLI mirror, taking the count from 75 to 76.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -64,14 +65,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 75 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 76 loopover_ tools and zero gittensory_-prefixed aliases", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); const primary = names.filter((n) => n.startsWith("loopover_")); const legacy = names.filter((n) => n.startsWith("gittensory_")); - expect(primary.length).toBe(75); + expect(primary.length).toBe(76); expect(legacy.length).toBe(0); - expect(names.length).toBe(75); + expect(names.length).toBe(76); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -83,17 +84,15 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 75-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 76-tool count the live server registers", async () => { const { tools } = await client.listTools(); const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }>; }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(75); - expect([...payload.tools.map((t) => t.name)].sort()).toEqual( - [...tools.map((t) => t.name)].sort(), - ); + expect(payload.count).toBe(76); + expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort()); }); }); diff --git a/test/unit/routes-automation-state.test.ts b/test/unit/routes-automation-state.test.ts new file mode 100644 index 0000000000..42bd1e9938 --- /dev/null +++ b/test/unit/routes-automation-state.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { createPendingAgentActionIfAbsent, upsertInstallation, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +function stubMinerDetection(): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("gittensor.io")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); +} + +async function seedOwnedRepo(env: Env, owner: string, name: string, installationId: number): Promise { + await upsertInstallation(env, { + installation: { id: installationId, account: { login: owner, id: installationId, type: "User" }, repository_selection: "selected", permissions: { metadata: "read" }, events: ["repository"] }, + }); + await upsertRepositoryFromGitHub(env, { name, full_name: `${owner}/${name}`, private: false, owner: { login: owner } }, installationId); +} + +describe("automation-state route (#6742)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("rejects unauthenticated access", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, "owner", "repo", 101); + + const res = await app.request("/v1/repos/owner/repo/automation-state", {}, env); + + expect(res.status).toBe(401); + }); + + it("allows a repository owner session to read the automation state, matching buildAutomationStateResponse's shape", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, "owner", "repo", 101); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto", label: "auto_with_approval" }, agentDryRun: true }); + await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 101, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" }); + stubMinerDetection(); + const { token } = await createSessionForGitHubUser(env, { login: "owner", id: 101 }); + + const res = await app.request("/v1/repos/owner/repo/automation-state", { headers: { cookie: `loopover_session=${token}` } }, env); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + repoFullName: "owner/repo", + configured: true, + mode: "dry_run", + actingActionClasses: expect.arrayContaining(["merge", "label"]), + pendingActionCount: 1, + }); + }); + + it("reports unconfigured + not_required readiness and zero pending actions for a repo with no settings row", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, "owner", "repo", 101); + stubMinerDetection(); + const { token } = await createSessionForGitHubUser(env, { login: "owner", id: 101 }); + + const res = await app.request("/v1/repos/owner/repo/automation-state", { headers: { cookie: `loopover_session=${token}` } }, env); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + configured: false, + actingActionClasses: [], + permissionReadiness: "not_required", + pendingActionCount: 0, + mode: "live", + }); + }); + + it("forbids a maintainer of repo A from reading repo B's automation state", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, "alice", "repo-a", 101); + await seedOwnedRepo(env, "bob", "repo-b", 102); + stubMinerDetection(); + const { token } = await createSessionForGitHubUser(env, { login: "alice", id: 101 }); + + const res = await app.request("/v1/repos/bob/repo-b/automation-state", { headers: { cookie: `loopover_session=${token}` } }, env); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ error: "forbidden_repo" }); + }); + + it("never leaks a wallet/hotkey/reward term regardless of pending-action detail", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, "owner", "repo", 101); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto_with_approval" } }); + await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 101, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "reward estimate leaked" }); + stubMinerDetection(); + const { token } = await createSessionForGitHubUser(env, { login: "owner", id: 101 }); + + const res = await app.request("/v1/repos/owner/repo/automation-state", { headers: { cookie: `loopover_session=${token}` } }, env); + + const body = await res.text(); + expect(body).not.toMatch(/wallet|hotkey|coldkey|reward|payout|trust score/i); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 8a2134d910..34a8ca7e43 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -460,6 +460,23 @@ export async function startFixtureServer( ); return; } + if (request.url === "/v1/repos/owner/repo/automation-state" && request.method === "GET") { + response.end( + JSON.stringify({ + repoFullName: "owner/repo", + configured: true, + autonomy: { merge: "auto" }, + autoMaintain: { requireApprovals: 1, mergeMethod: "squash" }, + agentPaused: false, + agentDryRun: false, + mode: "live", + permissionReadiness: "ready", + actingActionClasses: ["merge"], + pendingActionCount: 2, + }), + ); + return; + } if (request.url === "/v1/repos/owner/repo/maintainer-noise" && request.method === "GET") { response.end( JSON.stringify({