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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions src/registry/toolsets/idp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,32 @@ const idpEntityMutateBodySchema: BodySchema = {
],
};

const buildIdpEntityScopePath = (input: Record<string, unknown>): string => {
const buildIdpEntityScopePath = (input: Record<string, unknown>, config: PathBuilderConfig): string => {
let scope = "account";
const orgId = input.org_id as string | undefined;
const projectId = input.project_id as string | undefined;
if (orgId) {
scope += `.${orgId}`;
if (projectId) {
scope += `.${projectId}`;
}
const requestedScope = typeof input.resource_scope === "string" ? input.resource_scope : undefined;
const effectiveOrgId = (input.org_id as string | undefined) || config.HARNESS_ORG;
const effectiveProjectId = (input.project_id as string | undefined) || config.HARNESS_PROJECT;
const shouldUseProject =
requestedScope === "project" ||
(!requestedScope && Boolean(effectiveOrgId && effectiveProjectId));
const shouldUseOrg =
requestedScope === "org" ||
shouldUseProject ||
(!requestedScope && Boolean(effectiveOrgId));

if (requestedScope === "project" && (!effectiveOrgId || !effectiveProjectId)) {
throw new Error(
"resource_scope='project' requires org_id and project_id, or HARNESS_ORG and HARNESS_PROJECT defaults.",
);
}
if (requestedScope === "org" && !effectiveOrgId) {
throw new Error("resource_scope='org' requires org_id, or a HARNESS_ORG default.");
}

const orgId = shouldUseOrg ? effectiveOrgId : undefined;
const projectId = shouldUseProject ? effectiveProjectId : undefined;
if (orgId) scope += `.${orgId}`;
if (projectId) scope += `.${projectId}`;

const kind = input.kind as string | undefined;
const entityId = input.entity_id as string | undefined;
Expand All @@ -89,7 +105,9 @@ const buildIdpEntityScopePath = (input: Record<string, unknown>): string => {
}

if (orgId) input.org_id = orgId;
else delete input.org_id;
if (projectId) input.project_id = projectId;
else delete input.project_id;

return `/v1/entities/${encodeURIComponent(scope)}/${encodeURIComponent(kind)}/${encodeURIComponent(entityId)}`;
};
Expand Down
2 changes: 1 addition & 1 deletion src/tools/harness-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ export function registerExecuteTool(server: McpServer, registry: Registry, clien
pipelineId,
orgId: asString(input.org_id) || registry.orgId,
projectId: asString(input.project_id) || registry.projectId,
branch: asString(input.branch),
branch: asString(input.pipeline_branch) ?? asString(input.branch),
};
resolved = materializedInputSetYaml
? await resolveRuntimeInputsWithBaseYaml(client, inputsToResolve, resolveOptions, materializedInputSetYaml)
Expand Down
8 changes: 8 additions & 0 deletions tasks/lessons.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Lessons Learned

## Scope Defaults and Remote Branch Context Must Stay End-to-End
- **Issue**: Multi-scope path builders can bypass the registry's usual config defaulting when they construct path segments themselves. For IDP entities, list used configured `HARNESS_ORG` / `HARNESS_PROJECT`, while get/update path construction fell back to account scope, so a list -> update flow could target a different entity with the same kind/id.
- **Fix**: Path builders that encode scope in the path must accept `PathBuilderConfig`, honor explicit `resource_scope`, use configured org/project defaults when scope is omitted and the resource's list/default behavior does so, and clear unused scope fields so query params match the path.
- **Rule**: For multi-scope resources with custom path builders, test omitted-scope defaulting and explicit account/org/project overrides through the public tool handler when writes are supported.
- **Issue**: Remote pipeline execution has multiple helper calls before the final run. Passing branch context to the input-set GET but not to the runtime-template fetch can silently resolve inline overrides against a different Git branch.
- **Fix**: Use one normalized branch source (`pipeline_branch ?? branch`) for every pre-execute pipeline helper call and the final execute request.
- **Rule**: When adding or fixing remote pipeline branch handling, assert every request in the chain (input set GET, runtime input template, execute POST) carries the same branch/repo/connector/store context.

## Read Cache Signals Must Not Block Execute Paths
- **Issue**: A remote pipeline `pipeline.get` response can report `cacheResponse.cacheState=STALE_CACHE` and old YAML from the read/UI cache, while pipeline execution is documented to fetch entities from Git for the selected pipeline branch.
- **Fix**: Do not fail-close `harness_execute` based on `pipeline.get` cache metadata. Preserve explicit branch selection by sending `pipelineBranchName` for remote executions, and only block execution on signals from the execute path itself.
Expand Down
19 changes: 19 additions & 0 deletions tasks/todo.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Harness MCP Server — Task Tracking

## Critical Bug Investigation Automation (2026-07-13)
- [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] Run focused verification for any fix, or sanity checks for no-fix outcome
- [x] Commit/push/open PR if fixed; otherwise report no critical bugs in Slack

### Plan
- Treat the current branch against `origin/main` and recent main history as the investigation window.
- Prioritize behavioral paths that can cause data loss, crashes, security issues, or significant user-facing breakage.
- Avoid changes unless a concrete trigger and high-confidence minimal fix are both established.

### Review
- Found an IDP entity update correctness bug in the new mutate path: `idp_entity` list defaults to configured org/project scope, but the get/update path builder ignored `HARNESS_ORG` / `HARNESS_PROJECT` when callers omitted explicit `org_id` / `project_id`. A common `harness_list` -> `harness_update(resource_id, params.kind)` flow could PUT `/v1/entities/account/...` and overwrite an account-scoped entity with the same kind/id instead of the project entity the agent had just listed.
- Fixed IDP entity path construction to use explicit `resource_scope` when supplied, otherwise default to configured org/project scope when available, and to suppress org/project query params for explicit account-scope calls.
- Found a remote pipeline input override bug: when a run combined `input_set_ids`, flat inline overrides, and `pipeline_branch` (without `branch`), the input-set GET used the remote branch but the runtime-template POST did not. Overrides could be resolved against the default-branch template and execute with incorrect runtime values.
- Fixed runtime-template resolution to use `pipeline_branch ?? branch`, matching input-set materialization and execute dispatch.
- Verification passed: focused Vitest regression filter, `pnpm typecheck`, `pnpm build`, `pnpm test` (115 files / 2501 tests), `pnpm standards:check`, and `pnpm docs:check`.

## PR 569 Review Automation (2026-07-07)
- [x] Read Slack trigger thread and confirm report context
- [x] Inspect PR #569 diff, CI/review state, and affected code paths
Expand Down
41 changes: 41 additions & 0 deletions tests/registry/idp-entity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,47 @@ describe("idp_entity mutate operations", () => {
expect(call.body).toEqual({ yaml: SAMPLE_YAML });
});

it("update: defaults to configured project scope when org_id/project_id are omitted", async () => {
const mockRequest = vi.fn().mockResolvedValue({ identifier: "boutique-service" });
const client = makeClient(mockRequest);

await registry.dispatch(client, "idp_entity", "update", {
kind: "component",
entity_id: "boutique-service",
body: { yaml: SAMPLE_YAML },
});

const call = mockRequest.mock.calls[0][0] as {
path: string;
params: Record<string, string>;
};
expect(call.path).toBe("/v1/entities/account.default.test-project/component/boutique-service");
expect(call.params.orgIdentifier).toBe("default");
expect(call.params.projectIdentifier).toBe("test-project");
});

it("update: explicit account resource_scope suppresses configured org/project defaults", async () => {
const mockRequest = vi.fn().mockResolvedValue({ identifier: "boutique-service" });
const client = makeClient(mockRequest);

await registry.dispatch(client, "idp_entity", "update", {
resource_scope: "account",
org_id: "my_org",
project_id: "my_project",
kind: "component",
entity_id: "boutique-service",
body: { yaml: SAMPLE_YAML },
});

const call = mockRequest.mock.calls[0][0] as {
path: string;
params: Record<string, string | undefined>;
};
expect(call.path).toBe("/v1/entities/account/component/boutique-service");
expect(call.params.orgIdentifier).toBeUndefined();
expect(call.params.projectIdentifier).toBeUndefined();
});

it("update: requires kind and entity_id", async () => {
const client = makeClient();

Expand Down
106 changes: 106 additions & 0 deletions tests/tools/tool-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,35 @@ describe("harness_update", () => {
});
});

it("updates IDP entities at the configured project scope when only resource_id and kind are provided", async () => {
registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "idp" }));
mockRequest = vi.fn().mockResolvedValue({ identifier: "boutique-service" });
client = makeClient(mockRequest);
const idpServer = makeMcpServer("accept");
const { registerUpdateTool } = await import("../../src/tools/harness-update.js");
registerUpdateTool(idpServer, registry, client);

const result = await idpServer.call("harness_update", {
resource_type: "idp_entity",
resource_id: "boutique-service",
params: { kind: "component" },
body: { yaml: "apiVersion: harness.io/v1\nkind: component\nmetadata:\n name: boutique-service\nspec: {}" },
});

expect(result.isError).toBeUndefined();
const callArgs = mockRequest.mock.calls[0]![0] as {
path: string;
params: Record<string, string>;
body: Record<string, unknown>;
};
expect(callArgs.path).toBe("/v1/entities/account.default.test-project/component/boutique-service");
expect(callArgs.params.orgIdentifier).toBe("default");
expect(callArgs.params.projectIdentifier).toBe("test-project");
expect(callArgs.body).toEqual({
yaml: "apiVersion: harness.io/v1\nkind: component\nmetadata:\n name: boutique-service\nspec: {}",
});
});

it("rejects raw YAML update bodies whose identifier conflicts with resource_id", async () => {
registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "connectors" }));
mockRequest = vi.fn().mockResolvedValue({ data: { identifier: "prod_connector" } });
Expand Down Expand Up @@ -2316,6 +2345,83 @@ pipeline:
expect(runCall.params?.inputSetIdentifiers).toBeUndefined();
});

it("uses pipeline_branch for both remote input-set materialization and runtime template resolution", async () => {
const inputSetYaml = `inputSet:
pipeline:
identifier: "remote_override_pipe"
variables:
- name: "environment"
type: "String"
value: "prod"
- name: "branch"
type: "String"
value: "main"
`;
const templateWithRequired = `pipeline:
identifier: "remote_override_pipe"
variables:
- name: "branch"
type: "String"
value: "<+input>"
- name: "environment"
type: "String"
value: "<+input>"
`;
mockRequest
.mockResolvedValueOnce({ status: "SUCCESS", data: { inputSetYaml } }) // input set
.mockResolvedValueOnce({ status: "SUCCESS", data: { inputSetTemplateYaml: templateWithRequired } }) // template
.mockResolvedValueOnce({ data: { planExecutionId: "exec-remote-override" } }); // execute

const result = await server.call("harness_execute", {
resource_type: "pipeline",
action: "run",
resource_id: "remote_override_pipe",
inputs: { environment: "staging" },
input_set_ids: ["remote-set"],
params: {
store_type: "REMOTE",
connector_ref: "gh_conn",
repo_name: "my-repo",
pipeline_branch: "feature/x",
},
});

expect(result.isError).toBeUndefined();
expect(mockRequest).toHaveBeenCalledTimes(3);

const getCall = mockRequest.mock.calls[0]![0] as {
method?: string;
params?: Record<string, string | undefined>;
};
expect(getCall.method).toBe("GET");
expect(getCall.params?.branch).toBe("feature/x");
expect(getCall.params?.repoName).toBe("my-repo");
expect(getCall.params?.connectorRef).toBe("gh_conn");
expect(getCall.params?.storeType).toBe("REMOTE");

const templateCall = mockRequest.mock.calls[1]![0] as {
method?: string;
path?: string;
params?: Record<string, string | undefined>;
};
expect(templateCall.method).toBe("POST");
expect(templateCall.path).toBe("/pipeline/api/inputSets/template");
expect(templateCall.params?.branch).toBe("feature/x");

const runCall = mockRequest.mock.calls[2]![0] as {
method?: string;
body?: string;
params?: Record<string, string | string[] | undefined>;
};
expect(runCall.method).toBe("POST");
expect(runCall.body).toContain("environment");
expect(runCall.body).toContain("staging");
expect(runCall.body).toContain("branch");
expect(runCall.body).toContain("main");
expect(runCall.params?.pipelineBranchName).toBe("feature/x");
expect(runCall.params?.inputSetIdentifiers).toBeUndefined();
});

it("merges input_set_ids into a full pipeline YAML STRING passed as inputs", async () => {
const inputSetYaml = `inputSet:
pipeline:
Expand Down
Loading