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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/registry/toolsets/gitops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,13 +228,14 @@ export const gitopsToolset: ToolsetDefinition = {
responseExtractor: passthrough,
description:
"Delete a GitOps agent. The agent identifier in the path is the raw identifier — NOT scope-prefixed.\n\n" +
"resource_scope is required because the same raw agent identifier can exist at account, org, and project scope.\n" +
"SCOPE DETERMINES WHICH ORG/PROJECT PARAMS ARE INJECTED:\n" +
" Account-level agent: resource_scope='account' — omit org_id and project_id\n" +
" harness_delete(resource_type='gitops_agent', resource_id='myagent', resource_scope='account')\n" +
" Org-level agent: resource_scope='org' — org_id is injected, no project_id\n" +
" harness_delete(resource_type='gitops_agent', resource_id='myagent', resource_scope='org')\n" +
" Project-level agent: resource_scope='project' — both org_id and project_id are injected\n" +
" harness_delete(resource_type='gitops_agent', resource_id='myagent')\n\n" +
" harness_delete(resource_type='gitops_agent', resource_id='myagent', resource_scope='project')\n\n" +
"NOTE: The path identifier is always the raw agent ID (e.g. 'myagent'), never scope-prefixed\n" +
"(unlike other GitOps APIs where agentIdentifier is prefixed with 'account.', 'org.', etc.).\n" +
"The backend derives the scope from the presence of orgIdentifier and projectIdentifier in the request.",
Expand All @@ -250,12 +251,12 @@ export const gitopsToolset: ToolsetDefinition = {
},
{
name: "resource_scope",
required: false,
required: true,
description:
"Controls which scope params are injected. " +
"Required top-level harness_delete field that controls which scope params are injected. " +
"'account' — account-level agent (no org/project). " +
"'org' — org-level agent (orgIdentifier injected). " +
"'project' — project-level agent (orgIdentifier + projectIdentifier injected, default when omitted).",
"'project' — project-level agent (orgIdentifier + projectIdentifier injected).",
},
],
} satisfies ParamsSchema,
Expand Down
17 changes: 17 additions & 0 deletions tasks/todo.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Harness MCP Server — Task Tracking

## Critical Bug Investigation Automation (2026-07-20)
- [x] Baseline branch state and identify recent behavioral commits
- [x] Review high-blast-radius diffs and trace concrete trigger scenarios
- [x] Implement a minimal fix only if a critical bug is proven
- [x] Validate any fix, or complete targeted sanity checks for a no-fix outcome
- [x] Commit/push/open PR if fixed; otherwise report no critical bugs in Slack

### Plan
- Review changes merged after the prior 2026-07-13 investigation, prioritizing runtime behavior over release metadata.
- Trace candidate failures through public tool handlers, registry dispatch, request construction, and response extraction.
- Require a concrete crash, data-loss, security, or major user-breakage trigger before modifying runtime code.

### Review
- Found a wrong-scope deletion hazard in the newly added `gitops_agent` delete operation. GitOps agent paths use raw, non-scope-prefixed IDs, and the backend chooses account/org/project scope from query params. Omitting `resource_scope` therefore sent an account-scoped DELETE, while the operation's project example explicitly omitted the field. If the same ID existed at account and project scope, a project-agent deletion request could delete the account agent.
- Changed the operation to require explicit `resource_scope`, corrected every example, and added registry plus public `harness_delete` regressions for fail-closed omission and exact account/project query scoping.
- Verification passed: focused GitOps deletion regressions (16 tests), complete GitOps/tool-handler suites (248 tests), `pnpm typecheck`, `pnpm build`, `pnpm test` (117 files / 2557 tests), `pnpm standards:check` (80 tests), `pnpm docs:check`, and `git diff --check HEAD`.

## Version Bump 3.2.12 (2026-07-19)
- [x] Update package and bundle manifest versions to 3.2.12
- [x] Update the release metadata version test
Expand Down
33 changes: 32 additions & 1 deletion tests/registry/gitops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,48 @@ describe("gitops_agent", () => {
expect(call.path).toBe("/gitops/api/v1/agents/account.myagent");
});

it("delete: raw agent_id → DELETE /gitops/api/v1/agents/{agentIdentifier}", async () => {
it("delete: requires an explicit resource_scope", async () => {
const mockRequest = vi.fn();
const client = makeClient(mockRequest);

await expect(
registry.dispatch(client, "gitops_agent", "delete", {
agent_id: "agent1779094157087",
}),
).rejects.toThrow(/Missing required param\(s\).*resource_scope/);
expect(mockRequest).not.toHaveBeenCalled();
});

it("delete: project scope injects org and project identifiers", async () => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, "gitops_agent", "delete", {
agent_id: "agent1779094157087",
resource_scope: "project",
});

const call = mockRequest.mock.calls[0][0];
expect(call.method).toBe("DELETE");
expect(call.path).toBe("/gitops/api/v1/agents/agent1779094157087");
expect(call.params.orgIdentifier).toBe("default");
expect(call.params.projectIdentifier).toBe("test-project");
});

it("delete: account scope omits org and project identifiers", async () => {
const mockRequest = vi.fn().mockResolvedValue({});
const client = makeClient(mockRequest);

await registry.dispatch(client, "gitops_agent", "delete", {
agent_id: "agent1779094157087",
resource_scope: "account",
});

const call = mockRequest.mock.calls[0][0];
expect(call.method).toBe("DELETE");
expect(call.path).toBe("/gitops/api/v1/agents/agent1779094157087");
expect(call.params.orgIdentifier).toBeUndefined();
expect(call.params.projectIdentifier).toBeUndefined();
});
});

Expand Down
37 changes: 37 additions & 0 deletions tests/tools/tool-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,43 @@ describe("harness_delete", () => {
expect(data.deleted).toBe(true);
});

it("requires explicit scope before deleting a GitOps agent", async () => {
const gitopsRegistry = new Registry(makeConfig({ HARNESS_TOOLSETS: "gitops" }));
const gitopsServer = makeMcpServer("accept");
const { registerDeleteTool } = await import("../../src/tools/harness-delete.js");
registerDeleteTool(gitopsServer, gitopsRegistry, client, makeConfig());

const result = await gitopsServer.call("harness_delete", {
resource_type: "gitops_agent",
resource_id: "shared-agent",
});

expect(result.isError).toBe(true);
expect(parseResult(result)).toMatchObject({
error: expect.stringContaining("resource_scope"),
});
expect(mockRequest).not.toHaveBeenCalled();
});

it("forwards explicit project scope when deleting a GitOps agent", async () => {
const gitopsRegistry = new Registry(makeConfig({ HARNESS_TOOLSETS: "gitops" }));
const gitopsServer = makeMcpServer("accept");
const { registerDeleteTool } = await import("../../src/tools/harness-delete.js");
registerDeleteTool(gitopsServer, gitopsRegistry, client, makeConfig());

const result = await gitopsServer.call("harness_delete", {
resource_type: "gitops_agent",
resource_id: "shared-agent",
resource_scope: "project",
});

expect(result.isError).toBeUndefined();
const callArgs = mockRequest.mock.calls[0]![0] as { params: Record<string, unknown>; path: string };
expect(callArgs.path).toBe("/gitops/api/v1/agents/shared-agent");
expect(callArgs.params.orgIdentifier).toBe("default");
expect(callArgs.params.projectIdentifier).toBe("test-project");
});

it("returns structured delete payload without spreading API fields at top level", async () => {
const templateRegistry = new Registry(makeConfig({ HARNESS_TOOLSETS: "templates" }));
const templateServer = makeMcpServer("accept");
Expand Down
Loading