From 8c073d6ad4d58c592ff1acd0fe35a40317d45200 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 15 May 2026 13:37:13 +0000 Subject: [PATCH 01/11] feat: derive chat owner from token, drop /chats path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat owner on `POST /api/experimental/chats` is always the `coder-token` holder; the API has no owner override. The action previously framed `coder-username` as picking "the Coder user the chat runs as", which is incorrect. What changed: - Add CoderClient.getAuthenticatedUser() backed by GET /api/v2/users/me. - resolveCoderUsername falls back to users/me when no input is set and the trust gate returns no-signal (schedule, workflow_dispatch with no sender or actor, custom repository_dispatch chains). The gate still refuses untrusted triggers and does not fall through to users/me. - Warn when the resolved acting user (from coder-username or github-user-id) differs from the token owner. Suppressed for auto-resolved sources. - Rewrite README "Security model" to make clear the trust gate protects the acting user (org pick, reuse label), not the chat owner. The chat owner is the token holder. - generateChatUrl and buildDeploymentChatsUrl now build /agents and /agents/ paths, matching the real Coder frontend routes (site/src/pages/AgentsPage/utils/navigation.ts). - Update inline docs on coder-username and github-user-id inputs and outputs to stop calling them "the chat runs as". Closes CODAGT-437 Closes CODAGT-394 Closes CODAGT-438 🤖 Authored by Coder Agents. --- README.md | 31 ++-- action.yaml | 6 +- dist/index.js | 148 ++++++++++++----- src/action.test.ts | 296 +++++++++++++++++++++++++++++---- src/action.ts | 342 ++++++++++++++++++++++++++------------- src/coder-client.test.ts | 38 +++++ src/coder-client.ts | 18 +++ src/comment.test.ts | 18 +-- src/comment.ts | 2 +- src/outputs.test.ts | 6 +- src/test-helpers.ts | 5 + 11 files changed, 696 insertions(+), 214 deletions(-) diff --git a/README.md b/README.md index ac0a6ea..494a50e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ jobs: github-token: ${{ github.token }} ``` -The chat runs under the Coder user linked to the GitHub user who applied the label. Set `coder-username` for service-account workflows. +The chat runs under the Coder user linked to the GitHub user who applied the label, while the chat itself is owned by the user the `coder-token` belongs to. Set `coder-username` to override the acting user (used for org pick and the per-user reuse label); see [Identity](#identity-resolution) and [Security](#security-model) for the full model. ## Inputs @@ -49,8 +49,8 @@ The chat runs under the Coder user linked to the GitHub user who applied the lab | `chat-prompt` | yes | | Prompt to send to the agent. | | `github-url` | yes | | Issue or pull request URL. | | `github-token` | yes | | Used to post and update comments. | -| `coder-username` | no | | Run the chat as this Coder user. Mutually exclusive with `github-user-id`. Bypasses the [trust gate](#security-model). | -| `github-user-id` | no | | Resolve to a Coder user by linked GitHub id. Mutually exclusive with `coder-username`. | +| `coder-username` | no | | Override the acting Coder user used for org pick and the per-user reuse label. Mutually exclusive with `github-user-id`. Bypasses the [trust gate](#security-model). Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. | +| `github-user-id` | no | | Resolve the acting Coder user from a linked GitHub id. Mutually exclusive with `coder-username`. Does NOT change the chat owner. | | `coder-organization` | no | | Coder organization name. Recommended for multi-org users. | | `workspace-id` | no | | Pin the chat to an existing workspace. | | `model-config-id` | no | | Model configuration to use. | @@ -70,7 +70,7 @@ The chat runs under the Coder user linked to the GitHub user who applied the lab | `chat-created` | `true` if newly created, `false` if a message was sent to an existing chat. | | `chat-status` | `waiting`, `pending`, `running`, `paused`, `completed`, `error`. | | `chat-title` | Chat title. | -| `coder-username` | Coder username the chat ran as. | +| `coder-username` | Acting Coder username (org pick, reuse label). The chat owner is the `coder-token` holder, which may differ. | | `workspace-id` | Workspace UUID. | | `pull-request-url` | PR or branch URL when the chat tracks changes. | | `pull-request-state` | `open`, `closed`, `merged`. | @@ -90,14 +90,15 @@ PR/diff outputs come from the chat's `diff_status` and are only reliable when th ### Identity resolution -The action picks the Coder user to run the chat as. First source wins: +The chat itself is always owned by the user the `coder-token` belongs to: `POST /api/experimental/chats` has no owner override, so the API binds ownership to the session. The action separately resolves an **acting user** used for org pick and the per-user reuse label (`coder-agents-chat-action-user`). First source wins: 1. `coder-username` input. Used directly. 2. `github-user-id` input. Looked up by linked GitHub id; deleted Coder users are filtered. 3. `github.context.payload.sender.id`. Available on most webhook events. -4. `github.context.actor`. Resolved to a GitHub id via Octokit. Excluded for `schedule` events (the actor is the workflow file editor, not a triggering user). +4. `github.context.actor`. Resolved to a GitHub id via Octokit. +5. `GET /api/v2/users/me` against the configured `coder-token`. Used when no input or workflow-context signal applies (`schedule` events, `workflow_dispatch` without sender or actor, custom `repository_dispatch` chains). -If nothing resolves, the action fails and names the inputs to set. +If the acting user resolves via `coder-username` or `github-user-id` and the result differs from the `coder-token` owner, the action emits a `core.warning` naming both usernames. The chat is still owned by the token holder; the warning surfaces the divergence so the workflow author can confirm the token belongs to the intended user. ### Organization resolution @@ -171,7 +172,7 @@ jobs: wait: complete ``` -`pull_request_target` runs against the base repo and has access to secrets even for fork PRs. The service-account identity bypasses the trust gate so fork PRs are reviewed under a known bot. +`pull_request_target` runs against the base repo and has access to secrets even for fork PRs. The service-account identity bypasses the trust gate so fork PRs are reviewed under the bot's organization and reuse scope. The chat itself is owned by the `coder-token` holder regardless. ### Send a follow-up @@ -239,16 +240,18 @@ Branch on the kind without parsing the message: ## Security model -Identity auto-resolve binds the Coder user matching the GitHub event sender to the chat. The trust gate refuses to auto-resolve when the trigger is untrusted: +The **chat owner** is fixed by the `coder-token`: `POST /api/experimental/chats` has no owner override, so every chat the action creates is owned by the user the token belongs to. Workflows running fork PRs with `secrets.CODER_TOKEN` available (the `pull_request_target` pattern) execute under the workflow's Coder identity, end of story. The primary mitigation against attacker-controlled prompts under your token is GitHub's own rule that `secrets.*` is unavailable to `pull_request` events from forks. Use `pull_request_target` only when you've gated execution accordingly. -- Fork PRs (`head.repo` null, `head.repo.fork === true`, or `head.repo.full_name !== base.repo.full_name`). -- Comment or review events whose `comment.author_association` or `review.author_association` is not `OWNER`, `MEMBER`, or `COLLABORATOR`. +The **acting user** is the Coder identity resolved for org pick and the per-user reuse label (`coder-agents-chat-action-user`). It is NOT the chat owner. The trust gate protects this acting user from pollution by untrusted triggers, layered on top of (not in place of) GitHub's event-permission model. The gate refuses to auto-resolve when: -The gate doesn't read `issue.author_association` or `pull_request.author_association` because those describe the resource opener, not the event sender (a MEMBER labeling a NONE user's issue is fine). +- The trigger is a fork pull request (`head.repo` null, `head.repo.fork === true`, or `head.repo.full_name !== base.repo.full_name`). +- The trigger is a comment or review whose `comment.author_association` or `review.author_association` is not `OWNER`, `MEMBER`, or `COLLABORATOR`. -For other events the action defers to GitHub's own event-permission model. Setting `coder-username` or `github-user-id` explicitly bypasses the gate; the workflow author has chosen the identity. +Without the gate, an attacker who happens to have a linked Coder identity could open a fork PR or drop a drive-by comment and the action would attribute the chat (org pick, reuse label) to that identity. On refusal, the action does not fall back to `users/me`: a hostile trigger should not silently collapse onto the token owner. Setting `coder-username` or `github-user-id` bypasses the gate; the workflow author has chosen the identity explicitly. -Independent of the gate: fork PRs that need secrets must run under `pull_request_target`, not `pull_request`. +The gate does not read `issue.author_association` or `pull_request.author_association` because those describe the resource opener, not the event sender (a MEMBER labeling a NONE user's issue is fine). + +Independent of the gate: if your workflow uses `pull_request_target` to run against fork PRs, gate execution on author trust separately (label gating, manual approval). The trust gate covers the auto-resolved acting user only. ## Limitations diff --git a/action.yaml b/action.yaml index 358bd46..87ea81a 100644 --- a/action.yaml +++ b/action.yaml @@ -28,11 +28,11 @@ inputs: required: true github-user-id: - description: "GitHub user ID to resolve to a Coder user. Mutually exclusive with coder-username." + description: "GitHub user ID. Resolves the acting Coder user (used for org pick and the per-user reuse label) by linked GitHub id. Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. Mutually exclusive with coder-username." required: false coder-username: - description: "Coder username to use directly. Mutually exclusive with github-user-id; useful for service-account workflows." + description: "Override the acting Coder user used for org pick and the per-user reuse label. Mutually exclusive with github-user-id. Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. Useful when the workflow's token belongs to one user but the action should attribute the run to another." required: false coder-organization: @@ -77,7 +77,7 @@ inputs: outputs: coder-username: - description: "The Coder username resolved from the GitHub user." + description: "The acting Coder username (used for org pick and the per-user reuse label). The chat owner is the `coder-token` holder, which may differ." chat-id: description: "The chat ID." diff --git a/dist/index.js b/dist/index.js index 79d2000..fe71e49 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26805,6 +26805,10 @@ class RealCoderClient { throw err; } } + async getAuthenticatedUser() { + const response = await this.request("/api/v2/users/me"); + return CoderSDKUserSchema.parse(response); + } async getOrganizationByName(name) { if (!name) { throw new CoderAPIError("Organization name cannot be empty", 400); @@ -27227,7 +27231,7 @@ async function upsertCommentByMarker(args) { }); } function buildDeploymentChatsUrl(coderURL) { - return `${normalizeBaseUrl(coderURL)}/chats`; + return `${normalizeBaseUrl(coderURL)}/agents`; } // src/action.ts @@ -27340,7 +27344,7 @@ class CoderAgentChatAction { }; } generateChatUrl(chatId) { - return `${normalizeBaseUrl(this.inputs.coderURL)}/chats/${chatId}`; + return `${normalizeBaseUrl(this.inputs.coderURL)}/agents/${chatId}`; } async commentOnIssue(args) { const workflow = process.env.GITHUB_WORKFLOW || undefined; @@ -27465,7 +27469,7 @@ class CoderAgentChatAction { } async resolveCoderUsername() { if (this.inputs.coderUsername) { - core2.info(`Using provided Coder username: ${this.inputs.coderUsername}`); + core2.info(`Using provided Coder username for acting user: ${this.inputs.coderUsername}`); let coderUser; try { coderUser = await this.coder.getCoderUserByUsername(this.inputs.coderUsername); @@ -27475,53 +27479,105 @@ class CoderAgentChatAction { } throw err; } - return { username: coderUser.username, user: coderUser }; + return { + username: coderUser.username, + user: coderUser, + source: "coder-username" + }; } if (this.inputs.githubUserID !== undefined) { core2.info(`Looking up Coder user by GitHub user ID: ${this.inputs.githubUserID}`); const coderUser = await this.coder.getCoderUserByGitHubId(this.inputs.githubUserID); - return { username: coderUser.username, user: coderUser }; + return { + username: coderUser.username, + user: coderUser, + source: "github-user-id" + }; } - if (this.context.eventName === "schedule") { - throw new Error("Cannot auto-resolve a GitHub identity for `schedule` events: " + "`github.context.actor` for cron-triggered runs is the workflow " + "file's last editor, not the triggering user. " + "Set the `coder-username` input to a Coder username, or set " + "`github-user-id` to the GitHub numeric user id of the user the " + "chat should run as."); + const isSchedule = this.context.eventName === "schedule"; + if (!isSchedule) { + const trust = classifyAutoResolveTrust(this.context); + if (trust.kind === "untrusted") { + throw new Error("Refusing to auto-resolve a GitHub identity: " + `${trust.reason}. ` + "Set the `coder-username` input to a Coder username, or set " + "`github-user-id` to the GitHub numeric user id of the user " + "the chat should run as."); + } + if (trust.kind === "trusted") { + core2.info(`Auto-resolve trust check passed: ${trust.reason}`); + } + const senderId = this.context.payload?.sender?.id; + if (typeof senderId === "number" && Number.isInteger(senderId) && senderId > 0) { + core2.info(`Auto-resolving Coder user from github.context.payload.sender.id: ${senderId}`); + try { + const coderUser = await this.coder.getCoderUserByGitHubId(senderId); + return { + username: coderUser.username, + user: coderUser, + source: "sender" + }; + } catch (err) { + throw new Error(`Failed to resolve Coder user from github.context.payload.sender.id (${senderId}): ${describeError(err)}. ` + "Set the `coder-username` input to bypass auto-resolution."); + } + } + const actor = this.context.actor; + if (actor) { + core2.info(`Auto-resolving Coder user from github.context.actor: ${actor}`); + let actorId; + try { + const { data } = await this.octokit.rest.users.getByUsername({ + username: actor + }); + actorId = data.id; + } catch (err) { + throw new Error(`Failed to resolve GitHub user id for github.context.actor (${actor}): ${describeError(err)}. ` + "Set the `coder-username` input to bypass auto-resolution."); + } + try { + const coderUser = await this.coder.getCoderUserByGitHubId(actorId); + return { + username: coderUser.username, + user: coderUser, + source: "actor" + }; + } catch (err) { + throw new Error(`Failed to resolve Coder user for github.context.actor (${actor}, GitHub user id ${actorId}): ${describeError(err)}. ` + "Set the `coder-username` input to bypass auto-resolution."); + } + } } - const trust = classifyAutoResolveTrust(this.context); - if (trust.kind === "untrusted") { - throw new Error("Refusing to auto-resolve a GitHub identity: " + `${trust.reason}. ` + "Set the `coder-username` input to a Coder username, or set " + "`github-user-id` to the GitHub numeric user id of the user " + "the chat should run as."); + core2.info("No GitHub identity input or workflow-context signal was usable; " + "falling back to the `coder-token` owner via GET /api/v2/users/me."); + let tokenOwner; + try { + tokenOwner = await this.getTokenOwner(); + } catch (err) { + throw new Error(`Failed to resolve the \`coder-token\` owner via GET /api/v2/users/me: ${describeError(err)}. ` + "Set the `coder-username` input to a Coder username, or set " + "`github-user-id` to the GitHub numeric user id of the user the " + "chat should run as."); } - if (trust.kind === "trusted") { - core2.info(`Auto-resolve trust check passed: ${trust.reason}`); + return { + username: tokenOwner.username, + user: tokenOwner, + source: "token" + }; + } + tokenOwnerCache; + async getTokenOwner() { + if (this.tokenOwnerCache) { + return this.tokenOwnerCache; } - const senderId = this.context.payload?.sender?.id; - if (typeof senderId === "number" && Number.isInteger(senderId) && senderId > 0) { - core2.info(`Auto-resolving Coder user from github.context.payload.sender.id: ${senderId}`); - try { - const coderUser = await this.coder.getCoderUserByGitHubId(senderId); - return { username: coderUser.username, user: coderUser }; - } catch (err) { - throw new Error(`Failed to resolve Coder user from github.context.payload.sender.id (${senderId}): ${describeError(err)}. ` + "Set the `coder-username` input to bypass auto-resolution."); - } + const user = await this.coder.getAuthenticatedUser(); + this.tokenOwnerCache = user; + return user; + } + async warnOnTokenOwnerDivergence(resolved) { + if (resolved.source !== "coder-username" && resolved.source !== "github-user-id") { + return; } - const actor = this.context.actor; - if (actor) { - core2.info(`Auto-resolving Coder user from github.context.actor: ${actor}`); - let actorId; - try { - const { data } = await this.octokit.rest.users.getByUsername({ - username: actor - }); - actorId = data.id; - } catch (err) { - throw new Error(`Failed to resolve GitHub user id for github.context.actor (${actor}): ${describeError(err)}. ` + "Set the `coder-username` input to bypass auto-resolution."); - } - try { - const coderUser = await this.coder.getCoderUserByGitHubId(actorId); - return { username: coderUser.username, user: coderUser }; - } catch (err) { - throw new Error(`Failed to resolve Coder user for github.context.actor (${actor}, GitHub user id ${actorId}): ${describeError(err)}. ` + "Set the `coder-username` input to bypass auto-resolution."); - } + let tokenOwner; + try { + tokenOwner = await this.getTokenOwner(); + } catch (err) { + core2.warning(`Could not fetch the \`coder-token\` owner for the token-owner divergence check: ${describeError(err)}. ` + "Continuing; the chat will still be owned by whoever the token belongs to."); + return; + } + if (tokenOwner.id === resolved.user.id) { + return; } - throw new Error("Could not auto-resolve a GitHub identity from the workflow context. " + "Set the `coder-username` input to a Coder username, or set " + "`github-user-id` to the GitHub numeric user id of the user the " + "chat should run as."); + core2.warning(`The resolved acting user '${resolved.username}' differs from the \`coder-token\` owner '${tokenOwner.username}'. ` + "The chat is owned by the token holder; the acting user only " + "selects the organization and the per-user reuse label. Confirm " + "the token belongs to the user you intended."); } async resolveOrganizationID(coderUsername, resolvedUser) { if (this.inputs.coderOrganization) { @@ -27608,7 +27664,17 @@ class CoderAgentChatAction { } async runInner() { this.warnUnwiredInputs(); - const { username: coderUsername, user: resolvedUser } = await this.resolveCoderUsername(); + const { + username: coderUsername, + user: resolvedUser, + source: identitySource + } = await this.resolveCoderUsername(); + core2.info(`Resolved acting Coder user: '${coderUsername}' (source: ${identitySource})`); + await this.warnOnTokenOwnerDivergence({ + username: coderUsername, + user: resolvedUser, + source: identitySource + }); const { githubOrg, githubRepo, githubIssueNumber } = this.parseGithubURL(); core2.info(`GitHub owner: ${githubOrg}`); core2.info(`GitHub repo: ${githubRepo}`); diff --git a/src/action.test.ts b/src/action.test.ts index feb1730..3f081a4 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -215,7 +215,7 @@ describe("CoderAgentChatAction", () => { const result = action.generateChatUrl(mockChat.id); - expect(result).toBe(`https://coder.test/chats/${mockChat.id}`); + expect(result).toBe(`https://coder.test/agents/${mockChat.id}`); }); test("handles URL with trailing junk", () => { @@ -231,7 +231,7 @@ describe("CoderAgentChatAction", () => { const result = action.generateChatUrl(mockChat.id); - expect(result).toBe(`https://coder.test/chats/${mockChat.id}`); + expect(result).toBe(`https://coder.test/agents/${mockChat.id}`); }); }); @@ -381,7 +381,7 @@ describe("CoderAgentChatAction", () => { expect(parsedResult.chatTitle).toBe("Test chat"); expect(parsedResult.workspaceId).toBe(mockChat.workspace_id ?? undefined); expect(parsedResult.chatUrl).toMatch( - /^https:\/\/coder\.test\/chats\/[a-f0-9-]+$/, + /^https:\/\/coder\.test\/agents\/[a-f0-9-]+$/, ); }); @@ -1108,10 +1108,18 @@ describe("CoderAgentChatAction", () => { expect(result.coderUsername).toBe(mockUser.username); }); - test("refuses to auto-resolve schedule events even when actor is present", async () => { + test("falls back to users/me on schedule events; actor is the workflow editor and is skipped", async () => { + // The actor on a cron run is the workflow file's last editor, not + // the triggering user. Sender is empty. The action falls back to + // the `coder-token` owner so the chat owner and the acting user + // match (the chat is already owned by the token holder). + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + const inputs = createMockInputs({ githubUserID: undefined, coderUsername: undefined, + commentOnIssue: false, }); const context = createMockContext({ eventName: "schedule", @@ -1125,29 +1133,30 @@ describe("CoderAgentChatAction", () => { context, ); - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - const message = (caught as Error).message; - expect(message).toContain("schedule"); - expect(message).toContain("coder-username"); - expect(message).toContain("github-user-id"); + const result = await action.run(); + + expect(result.coderUsername).toBe(mockUser.username); + expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalledTimes(1); + // The actor must not be consulted on schedule events. The + // GitHub-id fallback path is also unreachable when the actor is + // not even looked up, so no Coder-user-by-GitHub-id call either. expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); }); - test("refuses to auto-resolve schedule events even when sender.id is present", async () => { + test("falls back to users/me on schedule events even when sender.id is present", async () => { // The schedule guard must be semantic, not positional. Today's // `schedule` payloads omit `sender`, but if a future GHES extension - // or custom dispatch chain delivers `sender.id`, we still refuse - // rather than silently misattribute. + // or custom dispatch chain delivers `sender.id`, it still describes + // the underlying webhook trigger, not the cron run. Skip sender, + // fall back to the token owner. + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + const inputs = createMockInputs({ githubUserID: undefined, coderUsername: undefined, + commentOnIssue: false, }); const context = createMockContext({ eventName: "schedule", @@ -1161,6 +1170,71 @@ describe("CoderAgentChatAction", () => { context, ); + const result = await action.run(); + + expect(result.coderUsername).toBe(mockUser.username); + expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalledTimes(1); + expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); + expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); + }); + + test("falls back to users/me when neither sender.id nor actor are usable", async () => { + // `repository_dispatch` with no sender and no actor: no + // github.context signal at all. Trust gate returns `no-signal`. + // The action falls back to the token owner rather than failing. + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + + const inputs = createMockInputs({ + githubUserID: undefined, + coderUsername: undefined, + commentOnIssue: false, + }); + const context = createMockContext({ + eventName: "repository_dispatch", + actor: "", + payload: {}, + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + context, + ); + + const result = await action.run(); + + expect(result.coderUsername).toBe(mockUser.username); + expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalledTimes(1); + expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); + expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); + }); + + test("surfaces a clear error when users/me also fails", async () => { + // No inputs, no github.context signal, and the token-owner lookup + // itself fails (bad token, deployment unreachable). The action + // must surface a clear message naming `users/me`, the underlying + // failure, and the two input bypasses. + coderClient.mockGetAuthenticatedUser.mockRejectedValue( + new Error("401 Unauthorized"), + ); + + const inputs = createMockInputs({ + githubUserID: undefined, + coderUsername: undefined, + }); + const context = createMockContext({ + eventName: "repository_dispatch", + actor: "", + payload: {}, + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + context, + ); + let caught: unknown; try { await action.run(); @@ -1169,22 +1243,31 @@ describe("CoderAgentChatAction", () => { } expect(caught).toBeInstanceOf(Error); const message = (caught as Error).message; - expect(message).toContain("schedule"); + expect(message).toContain("users/me"); + expect(message).toContain("401 Unauthorized"); expect(message).toContain("coder-username"); expect(message).toContain("github-user-id"); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); }); - test("fails with a clear error when no source resolves", async () => { + test("does not fall back to users/me when the trust gate refuses", async () => { + // Fork PR: the gate refuses to auto-resolve. Falling through to + // the token owner would silently collapse a hostile-trigger event + // onto the workflow's own identity, defeating the gate. The + // failure must look exactly like the gate's pre-fallback refusal. const inputs = createMockInputs({ githubUserID: undefined, coderUsername: undefined, }); const context = createMockContext({ - eventName: "repository_dispatch", - actor: "", - payload: {}, + eventName: "pull_request", + actor: "attacker", + payload: { + sender: { id: 99999 }, + pull_request: { + head: { repo: { fork: true, full_name: "attacker/fork" } }, + base: { repo: { full_name: "owner/repo" } }, + }, + }, }); const action = new CoderAgentChatAction( coderClient, @@ -1201,10 +1284,9 @@ describe("CoderAgentChatAction", () => { } expect(caught).toBeInstanceOf(Error); const message = (caught as Error).message; + expect(message).toContain("fork"); expect(message).toContain("coder-username"); - expect(message).toContain("github-user-id"); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); + expect(coderClient.mockGetAuthenticatedUser).not.toHaveBeenCalled(); }); test("wraps sender lookup failure with source and bypass instructions", async () => { @@ -1745,6 +1827,158 @@ describe("CoderAgentChatAction", () => { }); }); + describe("Token-owner divergence", () => { + test("warns when coder-username differs from the coder-token owner", async () => { + const actingUser = { + ...mockUser, + id: "aa0e8400-e29b-41d4-a716-446655440099", + username: "acting-bot", + }; + const tokenOwner = { + ...mockUser, + id: "bb0e8400-e29b-41d4-a716-446655440099", + username: "token-owner", + }; + coderClient.mockGetCoderUserByUsername.mockResolvedValue(actingUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(tokenOwner); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + const warningSpy = spyOn(core, "warning").mockImplementation(() => {}); + + try { + const inputs = createMockInputs({ + githubUserID: undefined, + coderUsername: "acting-bot", + commentOnIssue: false, + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext({ eventName: "issues" }), + ); + + const result = await action.run(); + + expect(result.coderUsername).toBe("acting-bot"); + expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalledTimes(1); + const divergenceCalls = warningSpy.mock.calls.filter((args) => + String(args[0] ?? "").includes( + "differs from the `coder-token` owner", + ), + ); + expect(divergenceCalls.length).toBe(1); + const body = String(divergenceCalls[0][0]); + expect(body).toContain("acting-bot"); + expect(body).toContain("token-owner"); + } finally { + warningSpy.mockRestore(); + } + }); + + test("warns when github-user-id resolves to a user different from the token owner", async () => { + const actingUser = { + ...mockUser, + id: "aa0e8400-e29b-41d4-a716-44665544aaaa", + username: "github-acting", + }; + const tokenOwner = { + ...mockUser, + id: "bb0e8400-e29b-41d4-a716-44665544bbbb", + username: "token-owner", + }; + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(actingUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(tokenOwner); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + const warningSpy = spyOn(core, "warning").mockImplementation(() => {}); + + try { + const inputs = createMockInputs({ + githubUserID: 7777, + coderUsername: undefined, + commentOnIssue: false, + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext({ eventName: "issues" }), + ); + + await action.run(); + + const divergenceCalls = warningSpy.mock.calls.filter((args) => + String(args[0] ?? "").includes( + "differs from the `coder-token` owner", + ), + ); + expect(divergenceCalls.length).toBe(1); + } finally { + warningSpy.mockRestore(); + } + }); + + test("does not warn when coder-username matches the coder-token owner", async () => { + coderClient.mockGetCoderUserByUsername.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + const warningSpy = spyOn(core, "warning").mockImplementation(() => {}); + + try { + const inputs = createMockInputs({ + githubUserID: undefined, + coderUsername: mockUser.username, + commentOnIssue: false, + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext({ eventName: "issues" }), + ); + + await action.run(); + + const divergenceCalls = warningSpy.mock.calls.filter((args) => + String(args[0] ?? "").includes( + "differs from the `coder-token` owner", + ), + ); + expect(divergenceCalls.length).toBe(0); + } finally { + warningSpy.mockRestore(); + } + }); + + test("does not call users/me when auto-resolving from github.context (sender)", async () => { + // The divergence check is scoped to explicit identity inputs. + // Auto-resolved sources (sender, actor) cannot be cross-checked + // against the token without false alarms (the human triggerer is + // expected to differ from a service-account token). + coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + + const inputs = createMockInputs({ + githubUserID: undefined, + coderUsername: undefined, + commentOnIssue: false, + }); + const context = createMockContext({ + eventName: "issues", + payload: { sender: { id: 424242 } }, + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + context, + ); + + await action.run(); + + expect(coderClient.mockGetAuthenticatedUser).not.toHaveBeenCalled(); + }); + }); + describe("wait=complete polling", () => { test("wait=none honors the wait gate: no getChat, no clock sleep", async () => { coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); @@ -2082,7 +2316,7 @@ describe("CoderAgentChatAction", () => { expect(err.chat).toBeDefined(); expect(err.chat?.status).toBe("running"); expect(err.chatId).toBeDefined(); - expect(err.chatUrl).toContain("/chats/"); + expect(err.chatUrl).toContain("/agents/"); expect(err.coderUsername).toBe(mockUser.username); }); @@ -2321,7 +2555,7 @@ describe("CoderAgentChatAction", () => { expect(err.kind).toBe("api_error"); expect(err.chat).toBeUndefined(); expect(String(err.chatId)).toBe(existingChatId); - expect(err.chatUrl).toContain("/chats/"); + expect(err.chatUrl).toContain("/agents/"); expect(err.coderUsername).toBe(mockUser.username); }); @@ -2609,7 +2843,7 @@ describe("CoderAgentChatAction", () => { expect(call?.body).toContain("chat-error-kind=spend_exceeded"); expect(call?.body).toContain("$7.50"); expect(call?.body).toContain("$10.00"); - expect(call?.body).toContain("https://coder.test/chats"); + expect(call?.body).toContain("https://coder.test/agents"); expect(call?.body).toContain( "", ); diff --git a/src/action.ts b/src/action.ts index a4e093f..bbe9626 100644 --- a/src/action.ts +++ b/src/action.ts @@ -200,6 +200,20 @@ type TrustClassification = | { kind: "untrusted"; reason: string } | { kind: "no-signal" }; +/** + * Identity-resolution source labels the divergence check reads to decide + * whether to warn. `coder-username` and `github-user-id` are explicit + * workflow inputs; `sender` and `actor` are auto-resolved from + * `github.context`; `token` is the `users/me` fallback (same user as the + * token holder, so divergence is impossible by construction). + */ +type IdentitySource = + | "coder-username" + | "github-user-id" + | "sender" + | "actor" + | "token"; + /** * Classify whether the triggering identity from `context` is trusted for * auto-resolve. @@ -322,7 +336,7 @@ export class CoderAgentChatAction { * Generate chat URL. */ generateChatUrl(chatId: ChatId): string { - return `${normalizeBaseUrl(this.inputs.coderURL)}/chats/${chatId}`; + return `${normalizeBaseUrl(this.inputs.coderURL)}/agents/${chatId}`; } // Post or update the success comment on the linked issue or pull @@ -582,7 +596,8 @@ export class CoderAgentChatAction { } /** - * Resolve the Coder username to run as. Resolution order, high to low: + * Resolve the Coder username the action runs as for org-pick and the + * per-user reuse label. Resolution order, high to low: * * 1. `coder-username` input. * 2. `github-user-id` input. @@ -592,40 +607,39 @@ export class CoderAgentChatAction { * (partial sender objects, bot dispatches, custom dispatch chains). * Resolved to a numeric id via `octokit.rest.users.getByUsername`, * then to a Coder user. + * 5. `GET /api/v2/users/me` against the configured `coder-token`. The + * chat owner on `POST /api/experimental/chats` is always the token + * holder; for events with no usable github.context signal (schedule, + * a workflow_dispatch with no sender or actor), the token owner is + * the only identity we can attribute the run to. * - * `schedule` events are refused before any auto-resolve source: their - * `actor` is the workflow file's last editor, not a triggering identity. + * Sources 3 and 4 are gated by `classifyAutoResolveTrust`. Fork pull + * requests and triggering identities whose `comment.author_association` + * or `review.author_association` lacks repository write access cause the + * gate to refuse: the action throws and does NOT fall through to + * `users/me`, because a hostile-trigger event should not silently + * collapse onto the token owner. The gate protects the acting user used + * for org-pick and the per-user reuse label (`coder-agents-chat-action-user`), + * not the chat owner (which is fixed by the token). * - * Throws naming both inputs when no source resolves. Intermediate - * failures are wrapped to name the auto-resolved source, preserve the - * upstream error, and recommend `coder-username` as the bypass. + * `schedule` events skip sources 3 and 4 directly: their `actor` is the + * workflow file's last editor and their payload carries no triggering + * identity. They proceed to `users/me`. * - * Before sources 3 and 4, a trust gate (`classifyAutoResolveTrust`) - * refuses auto-resolve for fork pull requests and for triggering - * identities whose `comment.author_association` or - * `review.author_association` lacks repository write access (anything - * other than `OWNER`, `MEMBER`, `COLLABORATOR`). This prevents a - * hostile-trigger attack where an attacker who happens to have a - * Coder identity could open a fork PR or drop a comment to bind - * their Coder identity to the workflow and execute - * attacker-controlled prompts under the workflow's Coder session - * token. Setting `coder-username` or `github-user-id` bypasses the - * trust gate: the workflow author has explicitly chosen the identity. - * - * Returns `{ username, user? }`. `user` is set when the identity path - * fetched a `CoderSDKUser` (sources 2-4); the explicit `coder-username` - * path (source 1) always now also fetches the user via - * `getCoderUserByUsername` so `user.id` is available for the - * idempotency-by-label per-user scope. + * Returns `{ username, user, source }`. `source` lets the caller decide + * whether to run the token-owner vs acting-user divergence check. * `resolveOrganizationID` reuses `user` to read `organization_ids` * without a redundant lookup. */ async resolveCoderUsername(): Promise<{ username: string; user: CoderSDKUser; + source: IdentitySource; }> { if (this.inputs.coderUsername) { - core.info(`Using provided Coder username: ${this.inputs.coderUsername}`); + core.info( + `Using provided Coder username for acting user: ${this.inputs.coderUsername}`, + ); // Fetch the full user so `user.id` is available downstream for // the `coder-agents-chat-action-user` per-user reuse scope. let coderUser: CoderSDKUser; @@ -646,7 +660,11 @@ export class CoderAgentChatAction { } throw err; } - return { username: coderUser.username, user: coderUser }; + return { + username: coderUser.username, + user: coderUser, + source: "coder-username", + }; } if (this.inputs.githubUserID !== undefined) { core.info( @@ -655,106 +673,195 @@ export class CoderAgentChatAction { const coderUser = await this.coder.getCoderUserByGitHubId( this.inputs.githubUserID, ); - return { username: coderUser.username, user: coderUser }; + return { + username: coderUser.username, + user: coderUser, + source: "github-user-id", + }; } - // Refuse before any auto-resolve source so the exclusion is semantic, - // not an artifact of source ordering. Today's `schedule` payloads - // omit `sender`, but a future shape that delivered it would still - // describe the underlying webhook trigger, not the cron run. - if (this.context.eventName === "schedule") { + // `schedule` skips the sender/actor branches: the actor on a cron run + // is the workflow file's last editor, and the payload carries no + // triggering identity. The trust gate would return `no-signal` and + // the action proceeds to `users/me` below. + const isSchedule = this.context.eventName === "schedule"; + + if (!isSchedule) { + // Trust gate: before auto-resolving from `sender.id` or `actor`, + // refuse if the triggering identity comes from a fork PR or carries + // a low-trust `author_association`. This protects the acting user + // used for org-pick and the per-user reuse label + // (`coder-agents-chat-action-user`) from pollution by untrusted + // triggers. The chat owner is the `coder-token` holder regardless + // of the gate's verdict. Explicit `coder-username` and + // `github-user-id` inputs are handled above and bypass this gate by + // design; on refusal the action does NOT fall through to `users/me` + // because a hostile-trigger event should not silently collapse onto + // the token owner. + const trust = classifyAutoResolveTrust(this.context); + if (trust.kind === "untrusted") { + throw new Error( + "Refusing to auto-resolve a GitHub identity: " + + `${trust.reason}. ` + + "Set the `coder-username` input to a Coder username, or set " + + "`github-user-id` to the GitHub numeric user id of the user " + + "the chat should run as.", + ); + } + if (trust.kind === "trusted") { + core.info(`Auto-resolve trust check passed: ${trust.reason}`); + } + + // Prefer `sender.id` over `actor`: it's already numeric, no extra + // API call. The guard mirrors `z.number().int().positive()` on the + // `github-user-id` input. + const senderId = this.context.payload?.sender?.id; + if ( + typeof senderId === "number" && + Number.isInteger(senderId) && + senderId > 0 + ) { + core.info( + `Auto-resolving Coder user from github.context.payload.sender.id: ${senderId}`, + ); + try { + const coderUser = await this.coder.getCoderUserByGitHubId(senderId); + return { + username: coderUser.username, + user: coderUser, + source: "sender", + }; + } catch (err) { + throw new Error( + `Failed to resolve Coder user from github.context.payload.sender.id (${senderId}): ${describeError(err)}. ` + + "Set the `coder-username` input to bypass auto-resolution.", + ); + } + } + + // Actor fallback for events whose payload lacks a usable `sender.id`. + // `workflow_dispatch` payloads do include `sender.id`, so source 3 + // handles it; this branch covers partial sender objects, bot + // dispatches, and custom dispatch chains. + const actor = this.context.actor; + if (actor) { + core.info( + `Auto-resolving Coder user from github.context.actor: ${actor}`, + ); + let actorId: number; + try { + const { data } = await this.octokit.rest.users.getByUsername({ + username: actor, + }); + actorId = data.id; + } catch (err) { + throw new Error( + `Failed to resolve GitHub user id for github.context.actor (${actor}): ${describeError(err)}. ` + + "Set the `coder-username` input to bypass auto-resolution.", + ); + } + try { + const coderUser = await this.coder.getCoderUserByGitHubId(actorId); + return { + username: coderUser.username, + user: coderUser, + source: "actor", + }; + } catch (err) { + throw new Error( + `Failed to resolve Coder user for github.context.actor (${actor}, GitHub user id ${actorId}): ${describeError(err)}. ` + + "Set the `coder-username` input to bypass auto-resolution.", + ); + } + } + } + + // Final fallback: derive the acting user from the `coder-token` via + // `GET /api/v2/users/me`. The chat already runs as this user; using + // the same identity for org-pick and the per-user reuse label keeps + // runs without explicit inputs (and `schedule` runs) attributable. + core.info( + "No GitHub identity input or workflow-context signal was usable; " + + "falling back to the `coder-token` owner via GET /api/v2/users/me.", + ); + let tokenOwner: CoderSDKUser; + try { + tokenOwner = await this.getTokenOwner(); + } catch (err) { throw new Error( - "Cannot auto-resolve a GitHub identity for `schedule` events: " + - "`github.context.actor` for cron-triggered runs is the workflow " + - "file's last editor, not the triggering user. " + + `Failed to resolve the \`coder-token\` owner via GET /api/v2/users/me: ${describeError(err)}. ` + "Set the `coder-username` input to a Coder username, or set " + "`github-user-id` to the GitHub numeric user id of the user the " + "chat should run as.", ); } + return { + username: tokenOwner.username, + user: tokenOwner, + source: "token", + }; + } - // Trust gate: before auto-resolving from `sender.id` or `actor`, - // refuse if the triggering identity comes from a fork PR or carries a - // low-trust `author_association`. Without this gate, an attacker who - // happens to have a Coder identity could open a fork PR or drop an - // issue comment to bind their Coder identity to the workflow and - // execute attacker-controlled prompts under the workflow's Coder - // token. Explicit `coder-username` and `github-user-id` inputs are - // handled above and bypass this gate by design. - const trust = classifyAutoResolveTrust(this.context); - if (trust.kind === "untrusted") { - throw new Error( - "Refusing to auto-resolve a GitHub identity: " + - `${trust.reason}. ` + - "Set the `coder-username` input to a Coder username, or set " + - "`github-user-id` to the GitHub numeric user id of the user " + - "the chat should run as.", - ); - } - if (trust.kind === "trusted") { - core.info(`Auto-resolve trust check passed: ${trust.reason}`); + /** + * Lazily fetch and memoize the `coder-token` owner. Used both as the + * lowest-priority identity-resolution fallback and as the source of + * truth for the token-owner vs acting-user divergence warning. + */ + private tokenOwnerCache: CoderSDKUser | undefined; + private async getTokenOwner(): Promise { + if (this.tokenOwnerCache) { + return this.tokenOwnerCache; } + const user = await this.coder.getAuthenticatedUser(); + this.tokenOwnerCache = user; + return user; + } - // Prefer `sender.id` over `actor`: it's already numeric, no extra - // API call. The guard mirrors `z.number().int().positive()` on the - // `github-user-id` input. - const senderId = this.context.payload?.sender?.id; + /** + * When an explicit identity input was provided, compare the resolved + * acting user to the `coder-token` owner and warn on divergence. The + * chat is owned by the token holder regardless of the resolved acting + * user; if they differ, the trust gate, the per-user reuse label, and + * the org pick are all protecting an identity that is not the chat + * owner. The workflow author should know. + * + * Suppressed for sources `sender`, `actor`, and `token` itself: those + * paths either derive the user from event context (the divergence is + * informational, not a workflow-author error) or already match the + * token by definition. + */ + private async warnOnTokenOwnerDivergence(resolved: { + username: string; + user: CoderSDKUser; + source: IdentitySource; + }): Promise { if ( - typeof senderId === "number" && - Number.isInteger(senderId) && - senderId > 0 + resolved.source !== "coder-username" && + resolved.source !== "github-user-id" ) { - core.info( - `Auto-resolving Coder user from github.context.payload.sender.id: ${senderId}`, - ); - try { - const coderUser = await this.coder.getCoderUserByGitHubId(senderId); - return { username: coderUser.username, user: coderUser }; - } catch (err) { - throw new Error( - `Failed to resolve Coder user from github.context.payload.sender.id (${senderId}): ${describeError(err)}. ` + - "Set the `coder-username` input to bypass auto-resolution.", - ); - } + return; } - - // Actor fallback for events whose payload lacks a usable `sender.id`. - // `workflow_dispatch` payloads do include `sender.id`, so source 3 - // handles it; this branch covers partial sender objects, bot - // dispatches, and custom dispatch chains. - const actor = this.context.actor; - if (actor) { - core.info( - `Auto-resolving Coder user from github.context.actor: ${actor}`, + let tokenOwner: CoderSDKUser; + try { + tokenOwner = await this.getTokenOwner(); + } catch (err) { + // The divergence check is best-effort. A `users/me` failure here + // would also break createChat (same token), so let the action + // keep going and surface that failure at the createChat call site. + core.warning( + `Could not fetch the \`coder-token\` owner for the token-owner divergence check: ${describeError(err)}. ` + + "Continuing; the chat will still be owned by whoever the token belongs to.", ); - let actorId: number; - try { - const { data } = await this.octokit.rest.users.getByUsername({ - username: actor, - }); - actorId = data.id; - } catch (err) { - throw new Error( - `Failed to resolve GitHub user id for github.context.actor (${actor}): ${describeError(err)}. ` + - "Set the `coder-username` input to bypass auto-resolution.", - ); - } - try { - const coderUser = await this.coder.getCoderUserByGitHubId(actorId); - return { username: coderUser.username, user: coderUser }; - } catch (err) { - throw new Error( - `Failed to resolve Coder user for github.context.actor (${actor}, GitHub user id ${actorId}): ${describeError(err)}. ` + - "Set the `coder-username` input to bypass auto-resolution.", - ); - } + return; } - - throw new Error( - "Could not auto-resolve a GitHub identity from the workflow context. " + - "Set the `coder-username` input to a Coder username, or set " + - "`github-user-id` to the GitHub numeric user id of the user the " + - "chat should run as.", + if (tokenOwner.id === resolved.user.id) { + return; + } + core.warning( + `The resolved acting user '${resolved.username}' differs from the \`coder-token\` owner '${tokenOwner.username}'. ` + + "The chat is owned by the token holder; the acting user only " + + "selects the organization and the per-user reuse label. Confirm " + + "the token belongs to the user you intended.", ); } @@ -939,8 +1046,19 @@ export class CoderAgentChatAction { private async runInner(): Promise { this.warnUnwiredInputs(); - const { username: coderUsername, user: resolvedUser } = - await this.resolveCoderUsername(); + const { + username: coderUsername, + user: resolvedUser, + source: identitySource, + } = await this.resolveCoderUsername(); + core.info( + `Resolved acting Coder user: '${coderUsername}' (source: ${identitySource})`, + ); + await this.warnOnTokenOwnerDivergence({ + username: coderUsername, + user: resolvedUser, + source: identitySource, + }); const { githubOrg, githubRepo, githubIssueNumber } = this.parseGithubURL(); core.info(`GitHub owner: ${githubOrg}`); diff --git a/src/coder-client.test.ts b/src/coder-client.test.ts index 2df5fbd..3df402f 100644 --- a/src/coder-client.test.ts +++ b/src/coder-client.test.ts @@ -337,6 +337,44 @@ describe("CoderClient", () => { }); }); + describe("getAuthenticatedUser", () => { + test("returns the user behind the configured token", async () => { + mockFetch.mockResolvedValueOnce(createMockResponse(mockUser)); + + const result = await client.getAuthenticatedUser(); + + expect(result.id).toBe(mockUser.id); + expect(result.username).toBe(mockUser.username); + expect(mockFetch).toHaveBeenCalledWith( + "https://coder.test/api/v2/users/me", + expect.objectContaining({ + headers: expect.objectContaining({ + "Coder-Session-Token": "test-token", + }), + }), + ); + }); + + test("propagates 401 as CoderAPIError", async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + { error: "Unauthorized" }, + { ok: false, status: 401, statusText: "Unauthorized" }, + ), + ); + + let caught: unknown; + try { + await client.getAuthenticatedUser(); + } catch (e) { + caught = e; + } + + expect(caught).toBeInstanceOf(CoderAPIError); + expect((caught as CoderAPIError).statusCode).toBe(401); + }); + }); + describe("getOrganizationByName", () => { test("returns the organization when found", async () => { mockFetch.mockResolvedValueOnce(createMockResponse(mockOrganization)); diff --git a/src/coder-client.ts b/src/coder-client.ts index f581e92..afbed2a 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -14,6 +14,16 @@ export interface CoderClient { getCoderUserByUsername(username: string): Promise; + /** + * Resolve the Coder user the configured `coder-token` belongs to via + * `GET /api/v2/users/me`. The chat owner on `POST /api/experimental/chats` + * is always the token holder (the API has no owner override), so this is + * the identity the chat actually runs as. The action uses this as the + * lowest-priority identity-resolution fallback and as the source of truth + * for the token-owner vs acting-user divergence warning. + */ + getAuthenticatedUser(): Promise; + getOrganizationByName(name: string): Promise; createChat(params: CreateChatRequest): Promise; @@ -162,6 +172,14 @@ export class RealCoderClient implements CoderClient { } } + async getAuthenticatedUser(): Promise { + // `users/me` resolves the session token to its owning user. No + // caching here; callers memoize when they need to reference the + // result more than once per run. + const response = await this.request("/api/v2/users/me"); + return CoderSDKUserSchema.parse(response); + } + async getOrganizationByName(name: string): Promise { if (!name) { throw new CoderAPIError("Organization name cannot be empty", 400); diff --git a/src/comment.test.ts b/src/comment.test.ts index 7e30b5e..1d0a932 100644 --- a/src/comment.test.ts +++ b/src/comment.test.ts @@ -219,7 +219,7 @@ describe("classifyError", () => { describe("buildFailureCommentBody", () => { const marker = ""; - const chatsUrl = "https://coder.test/chats"; + const chatsUrl = "https://coder.test/agents"; test("spend_exceeded body includes kind, dollar amounts, deployment chat URL, and marker", () => { const detail: FailureDetail = { @@ -295,7 +295,7 @@ describe("buildFailureCommentBody", () => { "after 600s waiting for a terminal status", }; const chatUrl = - "https://coder.test/chats/990e8400-e29b-41d4-a716-446655440000"; + "https://coder.test/agents/990e8400-e29b-41d4-a716-446655440000"; const body = buildFailureCommentBody(detail, { chatsUrl, chatUrl, @@ -325,7 +325,7 @@ describe("buildFailureCommentBody", () => { "connection reset by peer", }; const chatUrl = - "https://coder.test/chats/990e8400-e29b-41d4-a716-446655440000"; + "https://coder.test/agents/990e8400-e29b-41d4-a716-446655440000"; const body = buildFailureCommentBody(detail, { chatsUrl, chatUrl, @@ -353,7 +353,7 @@ describe("buildFailureCommentBody", () => { message: "Anthropic 429 rate limit", }; const chatUrl = - "https://coder.test/chats/990e8400-e29b-41d4-a716-446655440000"; + "https://coder.test/agents/990e8400-e29b-41d4-a716-446655440000"; const body = buildFailureCommentBody(detail, { chatsUrl, chatUrl, @@ -409,18 +409,18 @@ describe("normalizeBaseUrl", () => { }); describe("buildDeploymentChatsUrl", () => { - test("appends /chats to a clean base URL", () => { + test("appends /agents to a clean base URL", () => { expect(buildDeploymentChatsUrl("https://coder.test")).toBe( - "https://coder.test/chats", + "https://coder.test/agents", ); }); test("normalizes trailing slash, query, and fragment before appending", () => { expect(buildDeploymentChatsUrl("https://coder.test/?x=1")).toBe( - "https://coder.test/chats", + "https://coder.test/agents", ); expect(buildDeploymentChatsUrl("https://coder.test/#a")).toBe( - "https://coder.test/chats", + "https://coder.test/agents", ); }); }); @@ -511,7 +511,7 @@ describe("findCommentByPredicate", () => { describe("buildSuccessCommentBody", () => { const marker = ""; const chatUrl = - "https://coder.test/chats/990e8400-e29b-41d4-a716-446655440000"; + "https://coder.test/agents/990e8400-e29b-41d4-a716-446655440000"; test( "wait=complete + completed body shows chat URL, status, PR URL, and " + diff --git a/src/comment.ts b/src/comment.ts index bad4fbe..fe5629c 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -546,5 +546,5 @@ export async function upsertCommentByMarker(args: { // Deployment-level chats URL for the "view chats" link in the failure body. // We use the deployment list because a creation failure has no chat ID. export function buildDeploymentChatsUrl(coderURL: string): string { - return `${normalizeBaseUrl(coderURL)}/chats`; + return `${normalizeBaseUrl(coderURL)}/agents`; } diff --git a/src/outputs.test.ts b/src/outputs.test.ts index 1e84dac..d705618 100644 --- a/src/outputs.test.ts +++ b/src/outputs.test.ts @@ -10,7 +10,7 @@ import { mockChat } from "./test-helpers"; const baseOutputs: ActionOutputs = { coderUsername: "u", chatId: "990e8400-e29b-41d4-a716-446655440000", - chatUrl: "https://coder.test/chats/990e8400-e29b-41d4-a716-446655440000", + chatUrl: "https://coder.test/agents/990e8400-e29b-41d4-a716-446655440000", chatCreated: true, }; @@ -270,14 +270,14 @@ describe("setFailureOutputs", () => { const cap = captureSetOutput(); try { const err = new ActionFailureError("timeout", "Timed out", mockChat); - err.chatUrl = "https://coder.test/chats/abc"; + err.chatUrl = "https://coder.test/agents/abc"; err.coderUsername = "testuser"; setFailureOutputs(err); expect(cap.calls).toContainEqual([ "chat-url", - "https://coder.test/chats/abc", + "https://coder.test/agents/abc", ]); expect(cap.calls).toContainEqual(["coder-username", "testuser"]); } finally { diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 811a7c8..c59d834 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -168,6 +168,7 @@ export class MockCoderClient implements CoderClient { public mockListChats = mock((_opts?: ListChatsOptions) => Promise.resolve([] as CoderChat[]), ); + public mockGetAuthenticatedUser = mock(() => Promise.resolve(mockUser)); async getCoderUserByGitHubId(githubUserId: number): Promise { return this.mockGetCoderUserByGithubID(githubUserId); @@ -177,6 +178,10 @@ export class MockCoderClient implements CoderClient { return this.mockGetCoderUserByUsername(username); } + async getAuthenticatedUser(): Promise { + return this.mockGetAuthenticatedUser(); + } + async getOrganizationByName(name: string): Promise { return this.mockGetOrganizationByName(name); } From ac35127d632e928f06d52148bf1ced47c056d7e9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 15 May 2026 14:07:16 +0000 Subject: [PATCH 02/11] refactor!: rename identity inputs to acting-coder-username and acting-github-user-id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `acting-` prefix is the unambiguous signal that these inputs select the acting user (org pick, per-user reuse label), not the chat owner. The chat owner is the `coder-token` holder and is not overridable. What changed: - Rename input `coder-username` -> `acting-coder-username` in action.yaml. - Rename input `github-user-id` -> `acting-github-user-id` in action.yaml. - Rename output `coder-username` -> `acting-coder-username` in action.yaml. - Update getInput and setOutput calls; update OUTPUT_MAP entry. - Update error message references, core.info lines, IdentitySource literal values, and failure-comment bodies. - README inputs table, outputs table, identity resolution narrative, troubleshooting table, security model, and the doc-check recipe yaml example all renamed. - Test names and string-contains assertions on the input keys updated. - New schemas.test.ts regex assertion pins the mutex error message shape after the rename. TS-internal field names (coderUsername, githubUserID) and the chat label key (coder-agents-chat-action-user) are unchanged. 🤖 Authored by Coder Agents. --- README.md | 22 +++++++++---------- action.yaml | 10 ++++----- dist/index.js | 34 ++++++++++++++--------------- src/action.test.ts | 52 ++++++++++++++++++++++----------------------- src/action.ts | 50 +++++++++++++++++++++---------------------- src/coder-client.ts | 2 +- src/comment.test.ts | 8 +++---- src/comment.ts | 8 +++---- src/index.ts | 8 ++++--- src/outputs.test.ts | 14 ++++++------ src/outputs.ts | 4 ++-- src/schemas.test.ts | 6 ++++-- src/schemas.ts | 11 +++++----- 13 files changed, 117 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 494a50e..66f5794 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ jobs: github-token: ${{ github.token }} ``` -The chat runs under the Coder user linked to the GitHub user who applied the label, while the chat itself is owned by the user the `coder-token` belongs to. Set `coder-username` to override the acting user (used for org pick and the per-user reuse label); see [Identity](#identity-resolution) and [Security](#security-model) for the full model. +The chat runs under the Coder user linked to the GitHub user who applied the label, while the chat itself is owned by the user the `coder-token` belongs to. Set `acting-coder-username` to override the acting user (used for org pick and the per-user reuse label); see [Identity](#identity-resolution) and [Security](#security-model) for the full model. ## Inputs @@ -49,8 +49,8 @@ The chat runs under the Coder user linked to the GitHub user who applied the lab | `chat-prompt` | yes | | Prompt to send to the agent. | | `github-url` | yes | | Issue or pull request URL. | | `github-token` | yes | | Used to post and update comments. | -| `coder-username` | no | | Override the acting Coder user used for org pick and the per-user reuse label. Mutually exclusive with `github-user-id`. Bypasses the [trust gate](#security-model). Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. | -| `github-user-id` | no | | Resolve the acting Coder user from a linked GitHub id. Mutually exclusive with `coder-username`. Does NOT change the chat owner. | +| `acting-coder-username` | no | | Override the acting Coder user used for org pick and the per-user reuse label. Mutually exclusive with `acting-github-user-id`. Bypasses the [trust gate](#security-model). Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. | +| `acting-github-user-id` | no | | Resolve the acting Coder user from a linked GitHub id. Mutually exclusive with `acting-coder-username`. Does NOT change the chat owner. | | `coder-organization` | no | | Coder organization name. Recommended for multi-org users. | | `workspace-id` | no | | Pin the chat to an existing workspace. | | `model-config-id` | no | | Model configuration to use. | @@ -70,7 +70,7 @@ The chat runs under the Coder user linked to the GitHub user who applied the lab | `chat-created` | `true` if newly created, `false` if a message was sent to an existing chat. | | `chat-status` | `waiting`, `pending`, `running`, `paused`, `completed`, `error`. | | `chat-title` | Chat title. | -| `coder-username` | Acting Coder username (org pick, reuse label). The chat owner is the `coder-token` holder, which may differ. | +| `acting-coder-username` | Acting Coder username (org pick, reuse label). The chat owner is the `coder-token` holder, which may differ. | | `workspace-id` | Workspace UUID. | | `pull-request-url` | PR or branch URL when the chat tracks changes. | | `pull-request-state` | `open`, `closed`, `merged`. | @@ -92,13 +92,13 @@ PR/diff outputs come from the chat's `diff_status` and are only reliable when th The chat itself is always owned by the user the `coder-token` belongs to: `POST /api/experimental/chats` has no owner override, so the API binds ownership to the session. The action separately resolves an **acting user** used for org pick and the per-user reuse label (`coder-agents-chat-action-user`). First source wins: -1. `coder-username` input. Used directly. -2. `github-user-id` input. Looked up by linked GitHub id; deleted Coder users are filtered. +1. `acting-coder-username` input. Used directly. +2. `acting-github-user-id` input. Looked up by linked GitHub id; deleted Coder users are filtered. 3. `github.context.payload.sender.id`. Available on most webhook events. 4. `github.context.actor`. Resolved to a GitHub id via Octokit. 5. `GET /api/v2/users/me` against the configured `coder-token`. Used when no input or workflow-context signal applies (`schedule` events, `workflow_dispatch` without sender or actor, custom `repository_dispatch` chains). -If the acting user resolves via `coder-username` or `github-user-id` and the result differs from the `coder-token` owner, the action emits a `core.warning` naming both usernames. The chat is still owned by the token holder; the warning surfaces the divergence so the workflow author can confirm the token belongs to the intended user. +If the acting user resolves via `acting-coder-username` or `acting-github-user-id` and the result differs from the `coder-token` owner, the action emits a `core.warning` naming both usernames. The chat is still owned by the token holder; the warning surfaces the divergence so the workflow author can confirm the token belongs to the intended user. ### Organization resolution @@ -163,7 +163,7 @@ jobs: coder-url: ${{ secrets.CODER_URL }} coder-token: ${{ secrets.CODER_TOKEN }} coder-organization: ${{ secrets.CODER_ORG }} # required if the bot belongs to more than one org - coder-username: doc-check-bot + acting-coder-username: doc-check-bot chat-prompt: | Use the doc-check skill to review PR ${{ github.event.pull_request.html_url }}. @@ -225,8 +225,8 @@ The action sets `chat-error-kind` and `chat-error-message` on failure, posts a c | `chat-error-kind` | What happened | What to do | | ----------------- | ------------- | ---------- | | `spend_exceeded` | Chat spend limit reached. Spent and limit are in the comment. | Wait for reset or raise the deployment's per-user limit. | -| `user_not_found` | No Coder user matched the GitHub identity. | Pass `coder-username`, or have the user link their GitHub account in Coder. | -| `user_ambiguous` | Multiple live Coder users share the GitHub id. | Set `coder-username` to disambiguate. | +| `user_not_found` | No Coder user matched the GitHub identity. | Pass `acting-coder-username`, or have the user link their GitHub account in Coder. | +| `user_ambiguous` | Multiple live Coder users share the GitHub id. | Set `acting-coder-username` to disambiguate. | | `org_not_found` | Org missing or the user has no memberships. The comment names which. | Fix or set `coder-organization`. | | `api_error` | Any other Coder API error. The comment includes the underlying message; wrapped errors carry the original `CoderAPIError` via `Error.cause` and the workflow log renders the full cause chain. | Common causes: bad token, bad `workspace-id`, deployment unreachable. | | `timeout` | `wait: complete` didn't reach terminal in time. | Raise `wait-timeout-seconds`, or split the work. | @@ -247,7 +247,7 @@ The **acting user** is the Coder identity resolved for org pick and the per-user - The trigger is a fork pull request (`head.repo` null, `head.repo.fork === true`, or `head.repo.full_name !== base.repo.full_name`). - The trigger is a comment or review whose `comment.author_association` or `review.author_association` is not `OWNER`, `MEMBER`, or `COLLABORATOR`. -Without the gate, an attacker who happens to have a linked Coder identity could open a fork PR or drop a drive-by comment and the action would attribute the chat (org pick, reuse label) to that identity. On refusal, the action does not fall back to `users/me`: a hostile trigger should not silently collapse onto the token owner. Setting `coder-username` or `github-user-id` bypasses the gate; the workflow author has chosen the identity explicitly. +Without the gate, an attacker who happens to have a linked Coder identity could open a fork PR or drop a drive-by comment and the action would attribute the chat (org pick, reuse label) to that identity. On refusal, the action does not fall back to `users/me`: a hostile trigger should not silently collapse onto the token owner. Setting `acting-coder-username` or `acting-github-user-id` bypasses the gate; the workflow author has chosen the identity explicitly. The gate does not read `issue.author_association` or `pull_request.author_association` because those describe the resource opener, not the event sender (a MEMBER labeling a NONE user's issue is fine). diff --git a/action.yaml b/action.yaml index 87ea81a..ec32653 100644 --- a/action.yaml +++ b/action.yaml @@ -27,12 +27,12 @@ inputs: description: "GitHub token used to post and update issue comments." required: true - github-user-id: - description: "GitHub user ID. Resolves the acting Coder user (used for org pick and the per-user reuse label) by linked GitHub id. Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. Mutually exclusive with coder-username." + acting-github-user-id: + description: "GitHub user ID. Resolves the acting Coder user (used for org pick and the per-user reuse label) by linked GitHub id. Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. Mutually exclusive with acting-coder-username." required: false - coder-username: - description: "Override the acting Coder user used for org pick and the per-user reuse label. Mutually exclusive with github-user-id. Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. Useful when the workflow's token belongs to one user but the action should attribute the run to another." + acting-coder-username: + description: "Override the acting Coder user used for org pick and the per-user reuse label. Mutually exclusive with acting-github-user-id. Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. Useful when the workflow's token belongs to one user but the action should attribute the run to another." required: false coder-organization: @@ -76,7 +76,7 @@ inputs: default: "false" outputs: - coder-username: + acting-coder-username: description: "The acting Coder username (used for org pick and the per-user reuse label). The chat owner is the `coder-token` holder, which may differ." chat-id: diff --git a/dist/index.js b/dist/index.js index fe71e49..9513b80 100644 --- a/dist/index.js +++ b/dist/index.js @@ -27090,10 +27090,10 @@ function buildFailureCommentBody(detail, ctx) { lines.push("", linkLine); break; case "user_not_found": - lines.push("No Coder user could be resolved for this run. Adjust either " + "the `github-user-id` input (the GitHub identity is not linked " + "to a Coder user) or pass `coder-username` directly.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); + lines.push("No Coder user could be resolved for this run. Adjust either " + "the `acting-github-user-id` input (the GitHub identity is not " + "linked to a Coder user) or pass `acting-coder-username` directly.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); break; case "user_ambiguous": - lines.push("Multiple Coder users matched the GitHub identity. Set the " + "`coder-username` input to the specific account this workflow " + "should run as.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); + lines.push("Multiple Coder users matched the GitHub identity. Set the " + "`acting-coder-username` input to the specific account this " + "workflow should run as.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); break; case "org_not_found": lines.push("The resolved Coder user has no matching organization. Set the " + "`coder-organization` input or grant the user a membership.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); @@ -27475,14 +27475,14 @@ class CoderAgentChatAction { coderUser = await this.coder.getCoderUserByUsername(this.inputs.coderUsername); } catch (err) { if (err instanceof CoderAPIError && err.statusCode === 404) { - throw new ActionFailureError("user_not_found", `Coder user '${this.inputs.coderUsername}' not found. ` + "Check the `coder-username` input value.", undefined, { cause: err }); + throw new ActionFailureError("user_not_found", `Coder user '${this.inputs.coderUsername}' not found. ` + "Check the `acting-coder-username` input value.", undefined, { cause: err }); } throw err; } return { username: coderUser.username, user: coderUser, - source: "coder-username" + source: "acting-coder-username" }; } if (this.inputs.githubUserID !== undefined) { @@ -27491,14 +27491,14 @@ class CoderAgentChatAction { return { username: coderUser.username, user: coderUser, - source: "github-user-id" + source: "acting-github-user-id" }; } const isSchedule = this.context.eventName === "schedule"; if (!isSchedule) { const trust = classifyAutoResolveTrust(this.context); if (trust.kind === "untrusted") { - throw new Error("Refusing to auto-resolve a GitHub identity: " + `${trust.reason}. ` + "Set the `coder-username` input to a Coder username, or set " + "`github-user-id` to the GitHub numeric user id of the user " + "the chat should run as."); + throw new Error("Refusing to auto-resolve a GitHub identity: " + `${trust.reason}. ` + "Set the `acting-coder-username` input to a Coder username, or set " + "`acting-github-user-id` to the GitHub numeric user id of the user " + "the chat should run as."); } if (trust.kind === "trusted") { core2.info(`Auto-resolve trust check passed: ${trust.reason}`); @@ -27514,7 +27514,7 @@ class CoderAgentChatAction { source: "sender" }; } catch (err) { - throw new Error(`Failed to resolve Coder user from github.context.payload.sender.id (${senderId}): ${describeError(err)}. ` + "Set the `coder-username` input to bypass auto-resolution."); + throw new Error(`Failed to resolve Coder user from github.context.payload.sender.id (${senderId}): ${describeError(err)}. ` + "Set the `acting-coder-username` input to bypass auto-resolution."); } } const actor = this.context.actor; @@ -27527,7 +27527,7 @@ class CoderAgentChatAction { }); actorId = data.id; } catch (err) { - throw new Error(`Failed to resolve GitHub user id for github.context.actor (${actor}): ${describeError(err)}. ` + "Set the `coder-username` input to bypass auto-resolution."); + throw new Error(`Failed to resolve GitHub user id for github.context.actor (${actor}): ${describeError(err)}. ` + "Set the `acting-coder-username` input to bypass auto-resolution."); } try { const coderUser = await this.coder.getCoderUserByGitHubId(actorId); @@ -27537,7 +27537,7 @@ class CoderAgentChatAction { source: "actor" }; } catch (err) { - throw new Error(`Failed to resolve Coder user for github.context.actor (${actor}, GitHub user id ${actorId}): ${describeError(err)}. ` + "Set the `coder-username` input to bypass auto-resolution."); + throw new Error(`Failed to resolve Coder user for github.context.actor (${actor}, GitHub user id ${actorId}): ${describeError(err)}. ` + "Set the `acting-coder-username` input to bypass auto-resolution."); } } } @@ -27546,7 +27546,7 @@ class CoderAgentChatAction { try { tokenOwner = await this.getTokenOwner(); } catch (err) { - throw new Error(`Failed to resolve the \`coder-token\` owner via GET /api/v2/users/me: ${describeError(err)}. ` + "Set the `coder-username` input to a Coder username, or set " + "`github-user-id` to the GitHub numeric user id of the user the " + "chat should run as."); + throw new Error(`Failed to resolve the \`coder-token\` owner via GET /api/v2/users/me: ${describeError(err)}. ` + "Set the `acting-coder-username` input to a Coder username, or set " + "`acting-github-user-id` to the GitHub numeric user id of the user the " + "chat should run as."); } return { username: tokenOwner.username, @@ -27564,7 +27564,7 @@ class CoderAgentChatAction { return user; } async warnOnTokenOwnerDivergence(resolved) { - if (resolved.source !== "coder-username" && resolved.source !== "github-user-id") { + if (resolved.source !== "acting-coder-username" && resolved.source !== "acting-github-user-id") { return; } let tokenOwner; @@ -27600,7 +27600,7 @@ class CoderAgentChatAction { user = await this.coder.getCoderUserByUsername(coderUsername); } catch (err) { if (err instanceof CoderAPIError && err.statusCode === 404) { - throw new ActionFailureError("user_not_found", `Coder user '${coderUsername}' not found. ` + "Check the `coder-username` input value.", undefined, { cause: err }); + throw new ActionFailureError("user_not_found", `Coder user '${coderUsername}' not found. ` + "Check the `acting-coder-username` input value.", undefined, { cause: err }); } throw err; } @@ -27863,7 +27863,7 @@ class CoderAgentChatAction { // src/outputs.ts var core3 = __toESM(require_core(), 1); var OUTPUT_MAP = [ - { name: "coder-username", prop: "coderUsername", required: true }, + { name: "acting-coder-username", prop: "coderUsername", required: true }, { name: "chat-id", prop: "chatId", required: true }, { name: "chat-url", prop: "chatUrl", required: true }, { name: "chat-created", prop: "chatCreated", required: true }, @@ -27905,7 +27905,7 @@ function setFailureOutputs(error3) { core3.setOutput("chat-url", error3.chatUrl); } if (error3.coderUsername) { - core3.setOutput("coder-username", error3.coderUsername); + core3.setOutput("acting-coder-username", error3.coderUsername); } } @@ -27930,7 +27930,7 @@ var ActionInputsObjectSchema = exports_external.object({ forceNewChat: exports_external.boolean().default(false) }); var ActionInputsSchema = ActionInputsObjectSchema.refine((data) => !(data.githubUserID !== undefined && data.coderUsername !== undefined), { - message: "Cannot set both github-user-id and coder-username; choose one.", + message: "Cannot set both acting-github-user-id and acting-coder-username; choose one.", path: ["coderUsername"] }).refine((data) => !(data.existingChatId !== undefined && data.forceNewChat === true), { message: "Cannot set both existing-chat-id and force-new-chat; choose one.", @@ -27975,7 +27975,7 @@ function parseGithubUserID(raw) { } async function main() { try { - const githubUserID = parseGithubUserID(core4.getInput("github-user-id")); + const githubUserID = parseGithubUserID(core4.getInput("acting-github-user-id")); const inputs = ActionInputsSchema.parse({ coderURL: core4.getInput("coder-url", { required: true }), coderToken: core4.getInput("coder-token", { required: true }), @@ -27984,7 +27984,7 @@ async function main() { githubURL: core4.getInput("github-url", { required: true }), githubToken: core4.getInput("github-token", { required: true }), githubUserID, - coderUsername: core4.getInput("coder-username") || undefined, + coderUsername: core4.getInput("acting-coder-username") || undefined, workspaceId: core4.getInput("workspace-id") || undefined, modelConfigId: core4.getInput("model-config-id") || undefined, existingChatId: core4.getInput("existing-chat-id") || undefined, diff --git a/src/action.test.ts b/src/action.test.ts index 3f081a4..4782bc8 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -603,7 +603,7 @@ describe("CoderAgentChatAction", () => { }); }); - test("creates chat using direct coder-username", async () => { + test("creates chat using direct acting-coder-username", async () => { coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ @@ -851,7 +851,7 @@ describe("CoderAgentChatAction", () => { }); describe("Identity resolution", () => { - test("uses coder-username directly without GitHub-id lookup", async () => { + test("uses acting-coder-username directly without GitHub-id lookup", async () => { coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ @@ -877,7 +877,7 @@ describe("CoderAgentChatAction", () => { expect(result.coderUsername).toBe(mockUser.username); }); - test("prefers coder-username over github-user-id when both bypass the schema", async () => { + test("prefers acting-coder-username over acting-github-user-id when both bypass the schema", async () => { // The Zod schema rejects setting both inputs simultaneously, but the // resolver is a unit and the precedence #1 vs #2 must hold even if a // future caller bypasses the schema. Constructing the action directly @@ -903,7 +903,7 @@ describe("CoderAgentChatAction", () => { expect(result.coderUsername).toBe(mockUser.username); }); - test("looks up by github-user-id when set", async () => { + test("looks up by acting-github-user-id when set", async () => { coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); @@ -998,7 +998,7 @@ describe("CoderAgentChatAction", () => { }); test("treats sender id of 0 as missing and falls through to actor", async () => { - // Mirrors the Zod schema's positive constraint on `github-user-id`. + // Mirrors the Zod schema's positive constraint on `acting-github-user-id`. // Without the guard, `0` reaches a bare-string throw inside the // Coder client and surfaces as "Unknown error occurred". octokit.rest.users.getByUsername.mockResolvedValue({ @@ -1036,7 +1036,7 @@ describe("CoderAgentChatAction", () => { }); test("treats non-integer sender id as missing and falls through to actor", async () => { - // Mirrors the Zod schema's `.int()` constraint on `github-user-id`. + // Mirrors the Zod schema's `.int()` constraint on `acting-github-user-id`. // GitHub user IDs are integers in practice, but the runtime guard // should match the schema's shape rather than admitting `1.5`. octokit.rest.users.getByUsername.mockResolvedValue({ @@ -1245,8 +1245,8 @@ describe("CoderAgentChatAction", () => { const message = (caught as Error).message; expect(message).toContain("users/me"); expect(message).toContain("401 Unauthorized"); - expect(message).toContain("coder-username"); - expect(message).toContain("github-user-id"); + expect(message).toContain("acting-coder-username"); + expect(message).toContain("acting-github-user-id"); }); test("does not fall back to users/me when the trust gate refuses", async () => { @@ -1285,7 +1285,7 @@ describe("CoderAgentChatAction", () => { expect(caught).toBeInstanceOf(Error); const message = (caught as Error).message; expect(message).toContain("fork"); - expect(message).toContain("coder-username"); + expect(message).toContain("acting-coder-username"); expect(coderClient.mockGetAuthenticatedUser).not.toHaveBeenCalled(); }); @@ -1322,7 +1322,7 @@ describe("CoderAgentChatAction", () => { expect(message).toContain( "No Coder user found with GitHub user ID 424242", ); - expect(message).toContain("coder-username"); + expect(message).toContain("acting-coder-username"); }); test("wraps actor getByUsername failure with source and bypass instructions", async () => { @@ -1357,7 +1357,7 @@ describe("CoderAgentChatAction", () => { expect(message).toContain("github.context.actor"); expect(message).toContain("missing-user"); expect(message).toContain("Not Found"); - expect(message).toContain("coder-username"); + expect(message).toContain("acting-coder-username"); expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); }); @@ -1397,7 +1397,7 @@ describe("CoderAgentChatAction", () => { expect(message).toContain("octocat"); expect(message).toContain("555"); expect(message).toContain("No Coder user found with GitHub user ID 555"); - expect(message).toContain("coder-username"); + expect(message).toContain("acting-coder-username"); }); test("refuses auto-resolve on a fork pull request even with a sender.id", async () => { @@ -1438,8 +1438,8 @@ describe("CoderAgentChatAction", () => { expect(caught).toBeInstanceOf(Error); const message = (caught as Error).message; expect(message).toContain("fork"); - expect(message).toContain("coder-username"); - expect(message).toContain("github-user-id"); + expect(message).toContain("acting-coder-username"); + expect(message).toContain("acting-github-user-id"); expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); }); @@ -1551,7 +1551,7 @@ describe("CoderAgentChatAction", () => { const message = (caught as Error).message; expect(message).toContain("CONTRIBUTOR"); expect(message).toContain("author_association"); - expect(message).toContain("coder-username"); + expect(message).toContain("acting-coder-username"); expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); }); @@ -1723,7 +1723,7 @@ describe("CoderAgentChatAction", () => { await expect(action.run()).rejects.toThrow(/NONE/); }); - test("coder-username bypasses the trust gate on a fork PR", async () => { + test("acting-coder-username bypasses the trust gate on a fork PR", async () => { // Workflow author explicitly opted into running as a known // service-account identity. The trust gate must not refuse: the // fork PR's prompt is still attacker-controlled, but the workflow @@ -1762,7 +1762,7 @@ describe("CoderAgentChatAction", () => { expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); }); - test("github-user-id bypasses the trust gate on a fork PR", async () => { + test("acting-github-user-id bypasses the trust gate on a fork PR", async () => { coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); @@ -1828,7 +1828,7 @@ describe("CoderAgentChatAction", () => { }); describe("Token-owner divergence", () => { - test("warns when coder-username differs from the coder-token owner", async () => { + test("warns when acting-coder-username differs from the coder-token owner", async () => { const actingUser = { ...mockUser, id: "aa0e8400-e29b-41d4-a716-446655440099", @@ -1875,7 +1875,7 @@ describe("CoderAgentChatAction", () => { } }); - test("warns when github-user-id resolves to a user different from the token owner", async () => { + test("warns when acting-github-user-id resolves to a user different from the token owner", async () => { const actingUser = { ...mockUser, id: "aa0e8400-e29b-41d4-a716-44665544aaaa", @@ -1917,7 +1917,7 @@ describe("CoderAgentChatAction", () => { } }); - test("does not warn when coder-username matches the coder-token owner", async () => { + test("does not warn when acting-coder-username matches the coder-token owner", async () => { coderClient.mockGetCoderUserByUsername.mockResolvedValue(mockUser); coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); @@ -2884,8 +2884,8 @@ describe("CoderAgentChatAction", () => { | { body: string } | undefined; expect(call?.body).toContain("chat-error-kind=user_not_found"); - expect(call?.body).toContain("github-user-id"); - expect(call?.body).toContain("coder-username"); + expect(call?.body).toContain("acting-github-user-id"); + expect(call?.body).toContain("acting-coder-username"); expect(call?.body).toContain( "", ); @@ -2894,7 +2894,7 @@ describe("CoderAgentChatAction", () => { test( "posts a failure comment with chat-error-kind=user_ambiguous and " + - "suggests coder-username", + "suggests acting-coder-username", async () => { coderClient.mockGetCoderUserByGithubID.mockRejectedValue( new CoderAPIError( @@ -2926,7 +2926,7 @@ describe("CoderAgentChatAction", () => { | { body: string } | undefined; expect(call?.body).toContain("chat-error-kind=user_ambiguous"); - expect(call?.body).toContain("coder-username"); + expect(call?.body).toContain("acting-coder-username"); expect(call?.body).toContain( "", ); @@ -3290,7 +3290,7 @@ describe("CoderAgentChatAction", () => { ); }); - test("defaults via getCoderUserByUsername when only coder-username is set", async () => { + test("defaults via getCoderUserByUsername when only acting-coder-username is set", async () => { coderClient.mockGetCoderUserByUsername.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); @@ -3997,7 +3997,7 @@ describe("CoderAgentChatAction", () => { ); }); - test("coder-username path: per-user scope is applied via getCoderUserByUsername", async () => { + test("acting-coder-username path: per-user scope is applied via getCoderUserByUsername", async () => { coderClient.mockGetCoderUserByUsername.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([]); coderClient.mockCreateChat.mockResolvedValue(mockChat); diff --git a/src/action.ts b/src/action.ts index bbe9626..f3e639b 100644 --- a/src/action.ts +++ b/src/action.ts @@ -88,7 +88,7 @@ export class ActionFailureError extends Error { // undefined (e.g. transport failure on the first getChat). readonly chatId?: ChatId; - // coder-username output. Decorated by run() once the user resolves. + // acting-coder-username output. Decorated by run() once the user resolves. coderUsername?: string; // chat-url output. Decorated by run() once the chat URL is built. @@ -202,14 +202,14 @@ type TrustClassification = /** * Identity-resolution source labels the divergence check reads to decide - * whether to warn. `coder-username` and `github-user-id` are explicit + * whether to warn. `acting-coder-username` and `acting-github-user-id` are explicit * workflow inputs; `sender` and `actor` are auto-resolved from * `github.context`; `token` is the `users/me` fallback (same user as the * token holder, so divergence is impossible by construction). */ type IdentitySource = - | "coder-username" - | "github-user-id" + | "acting-coder-username" + | "acting-github-user-id" | "sender" | "actor" | "token"; @@ -599,8 +599,8 @@ export class CoderAgentChatAction { * Resolve the Coder username the action runs as for org-pick and the * per-user reuse label. Resolution order, high to low: * - * 1. `coder-username` input. - * 2. `github-user-id` input. + * 1. `acting-coder-username` input. + * 2. `acting-github-user-id` input. * 3. `context.payload.sender.id` (issue, pull request, comment, and most * webhook-driven events that carry the triggering user under `sender`). * 4. `context.actor` for events whose payload lacks a usable `sender.id` @@ -653,7 +653,7 @@ export class CoderAgentChatAction { throw new ActionFailureError( "user_not_found", `Coder user '${this.inputs.coderUsername}' not found. ` + - "Check the `coder-username` input value.", + "Check the `acting-coder-username` input value.", undefined, { cause: err }, ); @@ -663,7 +663,7 @@ export class CoderAgentChatAction { return { username: coderUser.username, user: coderUser, - source: "coder-username", + source: "acting-coder-username", }; } if (this.inputs.githubUserID !== undefined) { @@ -676,7 +676,7 @@ export class CoderAgentChatAction { return { username: coderUser.username, user: coderUser, - source: "github-user-id", + source: "acting-github-user-id", }; } @@ -693,8 +693,8 @@ export class CoderAgentChatAction { // used for org-pick and the per-user reuse label // (`coder-agents-chat-action-user`) from pollution by untrusted // triggers. The chat owner is the `coder-token` holder regardless - // of the gate's verdict. Explicit `coder-username` and - // `github-user-id` inputs are handled above and bypass this gate by + // of the gate's verdict. Explicit `acting-coder-username` and + // `acting-github-user-id` inputs are handled above and bypass this gate by // design; on refusal the action does NOT fall through to `users/me` // because a hostile-trigger event should not silently collapse onto // the token owner. @@ -703,8 +703,8 @@ export class CoderAgentChatAction { throw new Error( "Refusing to auto-resolve a GitHub identity: " + `${trust.reason}. ` + - "Set the `coder-username` input to a Coder username, or set " + - "`github-user-id` to the GitHub numeric user id of the user " + + "Set the `acting-coder-username` input to a Coder username, or set " + + "`acting-github-user-id` to the GitHub numeric user id of the user " + "the chat should run as.", ); } @@ -714,7 +714,7 @@ export class CoderAgentChatAction { // Prefer `sender.id` over `actor`: it's already numeric, no extra // API call. The guard mirrors `z.number().int().positive()` on the - // `github-user-id` input. + // `acting-github-user-id` input. const senderId = this.context.payload?.sender?.id; if ( typeof senderId === "number" && @@ -734,7 +734,7 @@ export class CoderAgentChatAction { } catch (err) { throw new Error( `Failed to resolve Coder user from github.context.payload.sender.id (${senderId}): ${describeError(err)}. ` + - "Set the `coder-username` input to bypass auto-resolution.", + "Set the `acting-coder-username` input to bypass auto-resolution.", ); } } @@ -757,7 +757,7 @@ export class CoderAgentChatAction { } catch (err) { throw new Error( `Failed to resolve GitHub user id for github.context.actor (${actor}): ${describeError(err)}. ` + - "Set the `coder-username` input to bypass auto-resolution.", + "Set the `acting-coder-username` input to bypass auto-resolution.", ); } try { @@ -770,7 +770,7 @@ export class CoderAgentChatAction { } catch (err) { throw new Error( `Failed to resolve Coder user for github.context.actor (${actor}, GitHub user id ${actorId}): ${describeError(err)}. ` + - "Set the `coder-username` input to bypass auto-resolution.", + "Set the `acting-coder-username` input to bypass auto-resolution.", ); } } @@ -790,8 +790,8 @@ export class CoderAgentChatAction { } catch (err) { throw new Error( `Failed to resolve the \`coder-token\` owner via GET /api/v2/users/me: ${describeError(err)}. ` + - "Set the `coder-username` input to a Coder username, or set " + - "`github-user-id` to the GitHub numeric user id of the user the " + + "Set the `acting-coder-username` input to a Coder username, or set " + + "`acting-github-user-id` to the GitHub numeric user id of the user the " + "chat should run as.", ); } @@ -836,8 +836,8 @@ export class CoderAgentChatAction { source: IdentitySource; }): Promise { if ( - resolved.source !== "coder-username" && - resolved.source !== "github-user-id" + resolved.source !== "acting-coder-username" && + resolved.source !== "acting-github-user-id" ) { return; } @@ -874,13 +874,13 @@ export class CoderAgentChatAction { * is non-deterministic; a `core.warning` is emitted in that case. * 2. The resolved Coder user's `organization_ids[0]`. When identity was * resolved via the GitHub-id path the user object is reused; the - * `coder-username` path looks the user up here via + * `acting-coder-username` path looks the user up here via * `getCoderUserByUsername`. * * Throws `ActionFailureError("org_not_found")` when `coder-organization` * names an org that does not exist (HTTP 404) or the resolved user has no * org memberships. Throws `ActionFailureError("user_not_found")` when only - * `coder-username` is set and the user is missing (HTTP 404). Other API + * `acting-coder-username` is set and the user is missing (HTTP 404). Other API * errors propagate as `CoderAPIError`. The original error is attached via * `options.cause` on every wrap; `run()`'s `handleFailure` re-classifies * the failure into the failure-path comment. @@ -915,7 +915,7 @@ export class CoderAgentChatAction { } // Default to the user's first org membership. Fetch the user lazily - // when only `coder-username` was provided; wrap a 404 into + // when only `acting-coder-username` was provided; wrap a 404 into // `user_not_found` symmetrically with the named-org 404 above. let user: CoderSDKUser; if (resolvedUser) { @@ -928,7 +928,7 @@ export class CoderAgentChatAction { throw new ActionFailureError( "user_not_found", `Coder user '${coderUsername}' not found. ` + - "Check the `coder-username` input value.", + "Check the `acting-coder-username` input value.", undefined, { cause: err }, ); diff --git a/src/coder-client.ts b/src/coder-client.ts index afbed2a..44b1483 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -158,7 +158,7 @@ export class RealCoderClient implements CoderClient { return CoderSDKUserSchema.parse(response); } catch (err) { // Re-throw 404 with the `user_not_found` kind so `classifyError` - // routes a typo in `coder-username` to the helpful failure + // routes a typo in `acting-coder-username` to the helpful failure // comment rather than a generic `api_error`. if (err instanceof CoderAPIError && err.statusCode === 404) { throw new CoderAPIError( diff --git a/src/comment.test.ts b/src/comment.test.ts index 1d0a932..11b03aa 100644 --- a/src/comment.test.ts +++ b/src/comment.test.ts @@ -244,19 +244,19 @@ describe("buildFailureCommentBody", () => { }; const body = buildFailureCommentBody(detail, { chatsUrl, marker }); expect(body).toContain("chat-error-kind=user_not_found"); - expect(body).toContain("github-user-id"); - expect(body).toContain("coder-username"); + expect(body).toContain("acting-github-user-id"); + expect(body).toContain("acting-coder-username"); expect(body.endsWith(marker)).toBe(true); }); - test("user_ambiguous body suggests coder-username and ends with marker", () => { + test("user_ambiguous body suggests acting-coder-username and ends with marker", () => { const detail: FailureDetail = { kind: "user_ambiguous", message: "Multiple Coder users found with GitHub user ID 12345", }; const body = buildFailureCommentBody(detail, { chatsUrl, marker }); expect(body).toContain("chat-error-kind=user_ambiguous"); - expect(body).toContain("coder-username"); + expect(body).toContain("acting-coder-username"); expect(body.endsWith(marker)).toBe(true); }); diff --git a/src/comment.ts b/src/comment.ts index fe5629c..8649929 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -262,8 +262,8 @@ export function buildFailureCommentBody( case "user_not_found": lines.push( "No Coder user could be resolved for this run. Adjust either " + - "the `github-user-id` input (the GitHub identity is not linked " + - "to a Coder user) or pass `coder-username` directly.", + "the `acting-github-user-id` input (the GitHub identity is not " + + "linked to a Coder user) or pass `acting-coder-username` directly.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, @@ -274,8 +274,8 @@ export function buildFailureCommentBody( case "user_ambiguous": lines.push( "Multiple Coder users matched the GitHub identity. Set the " + - "`coder-username` input to the specific account this workflow " + - "should run as.", + "`acting-coder-username` input to the specific account this " + + "workflow should run as.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, diff --git a/src/index.ts b/src/index.ts index 08d3159..01c1846 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { RealCoderClient } from "./coder-client"; import { setActionOutputs, setFailureOutputs } from "./outputs"; import { ActionInputsSchema } from "./schemas"; -// Convert the `github-user-id` workflow input to a number, or return +// Convert the `acting-github-user-id` workflow input to a number, or return // undefined when unset. Returns NaN for anything that isn't a plain // decimal integer literal so it fails schema parse instead of silently // resolving to the wrong Coder user. `Number()` alone is too permissive: @@ -21,7 +21,9 @@ export function parseGithubUserID(raw: string): number | undefined { async function main() { try { - const githubUserID = parseGithubUserID(core.getInput("github-user-id")); + const githubUserID = parseGithubUserID( + core.getInput("acting-github-user-id"), + ); const inputs = ActionInputsSchema.parse({ coderURL: core.getInput("coder-url", { required: true }), @@ -31,7 +33,7 @@ async function main() { githubURL: core.getInput("github-url", { required: true }), githubToken: core.getInput("github-token", { required: true }), githubUserID, - coderUsername: core.getInput("coder-username") || undefined, + coderUsername: core.getInput("acting-coder-username") || undefined, workspaceId: core.getInput("workspace-id") || undefined, modelConfigId: core.getInput("model-config-id") || undefined, existingChatId: core.getInput("existing-chat-id") || undefined, diff --git a/src/outputs.test.ts b/src/outputs.test.ts index d705618..aaa98bc 100644 --- a/src/outputs.test.ts +++ b/src/outputs.test.ts @@ -35,7 +35,7 @@ function captureSetOutput(): { describe("OUTPUT_MAP", () => { test("declares an entry for every action.yaml output", () => { const expected = [ - "coder-username", + "acting-coder-username", "chat-id", "chat-url", "chat-created", @@ -61,7 +61,7 @@ describe("OUTPUT_MAP", () => { test("required entries are exactly the four base outputs", () => { const required = OUTPUT_MAP.filter((e) => e.required).map((e) => e.name); expect(required).toEqual([ - "coder-username", + "acting-coder-username", "chat-id", "chat-url", "chat-created", @@ -87,7 +87,7 @@ describe("setActionOutputs", () => { setActionOutputs(baseOutputs); const names = cap.calls.map(([n]) => n).sort(); expect(names).toEqual( - ["chat-created", "chat-id", "chat-url", "coder-username"].sort(), + ["chat-created", "chat-id", "chat-url", "acting-coder-username"].sort(), ); } finally { cap.restore(); @@ -167,7 +167,7 @@ describe("setActionOutputs", () => { ...baseOutputs, coderUsername: undefined as unknown as string, }); - const username = cap.calls.find(([n]) => n === "coder-username"); + const username = cap.calls.find(([n]) => n === "acting-coder-username"); expect(username).toBeDefined(); expect(username?.[1]).toBe(""); } finally { @@ -219,7 +219,7 @@ describe("setFailureOutputs", () => { expect(names).not.toContain("chat-id"); expect(names).not.toContain("chat-status"); expect(names).not.toContain("chat-url"); - expect(names).not.toContain("coder-username"); + expect(names).not.toContain("acting-coder-username"); } finally { cap.restore(); } @@ -266,7 +266,7 @@ describe("setFailureOutputs", () => { } }); - test("emits chat-url and coder-username when decorated", () => { + test("emits chat-url and acting-coder-username when decorated", () => { const cap = captureSetOutput(); try { const err = new ActionFailureError("timeout", "Timed out", mockChat); @@ -279,7 +279,7 @@ describe("setFailureOutputs", () => { "chat-url", "https://coder.test/agents/abc", ]); - expect(cap.calls).toContainEqual(["coder-username", "testuser"]); + expect(cap.calls).toContainEqual(["acting-coder-username", "testuser"]); } finally { cap.restore(); } diff --git a/src/outputs.ts b/src/outputs.ts index c744eda..4043b9e 100644 --- a/src/outputs.ts +++ b/src/outputs.ts @@ -9,7 +9,7 @@ export const OUTPUT_MAP: ReadonlyArray<{ prop: keyof ActionOutputs; required?: boolean; }> = [ - { name: "coder-username", prop: "coderUsername", required: true }, + { name: "acting-coder-username", prop: "coderUsername", required: true }, { name: "chat-id", prop: "chatId", required: true }, { name: "chat-url", prop: "chatUrl", required: true }, { name: "chat-created", prop: "chatCreated", required: true }, @@ -61,6 +61,6 @@ export function setFailureOutputs(error: ActionFailureError): void { core.setOutput("chat-url", error.chatUrl); } if (error.coderUsername) { - core.setOutput("coder-username", error.coderUsername); + core.setOutput("acting-coder-username", error.coderUsername); } } diff --git a/src/schemas.test.ts b/src/schemas.test.ts index a0f0860..2d35973 100644 --- a/src/schemas.test.ts +++ b/src/schemas.test.ts @@ -98,7 +98,7 @@ describe("ActionInputsSchema", () => { } }); - test("accepts both github-user-id and coder-username unset", () => { + test("accepts both acting-github-user-id and acting-coder-username unset", () => { const { githubUserID: _, ...withoutGithubUserID } = actionInputValid; const result = ActionInputsSchema.parse(withoutGithubUserID); expect(result.githubUserID).toBeUndefined(); @@ -181,7 +181,9 @@ describe("ActionInputsSchema", () => { ...actionInputValid, coderUsername: "testuser", }; - expect(() => ActionInputsSchema.parse(input)).toThrow(); + expect(() => ActionInputsSchema.parse(input)).toThrow( + /acting-github-user-id and acting-coder-username/, + ); }); test("rejects input with both existingChatId and forceNewChat", () => { diff --git a/src/schemas.ts b/src/schemas.ts index a50f847..88a9f03 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -4,10 +4,10 @@ import { z } from "zod"; // in sync if either changes. export const DEFAULT_WAIT_TIMEOUT_SECONDS = 600; -// Mutual exclusion of github-user-id and coder-username is enforced by -// the wrapper schema below. Both identity inputs are optional at the -// object level so the runtime can later auto-resolve from the workflow -// context. +// Mutual exclusion of acting-github-user-id and acting-coder-username is +// enforced by the wrapper schema below. Both identity inputs are optional +// at the object level so the runtime can later auto-resolve from the +// workflow context. const ActionInputsObjectSchema = z.object({ chatPrompt: z.string().min(1), coderToken: z.string().min(1), @@ -35,7 +35,8 @@ export const ActionInputsSchema = ActionInputsObjectSchema.refine( (data) => !(data.githubUserID !== undefined && data.coderUsername !== undefined), { - message: "Cannot set both github-user-id and coder-username; choose one.", + message: + "Cannot set both acting-github-user-id and acting-coder-username; choose one.", path: ["coderUsername"], }, ).refine( From b55b143f82573ef498d5e3368229f43e5691a289 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 15 May 2026 15:01:48 +0000 Subject: [PATCH 03/11] fix: align language with corrected ownership model (review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the round-1 review findings on PR #32. The PR's own error messages and one README paragraph still carried the pre-rename framing ("the user the chat should run as"), and the deployment-list URL identity ("chats") was not renamed when the URL migrated to `/agents`. What changed: - Rewrite three error-message strings to match the README's "acting user (for org pick and the per-user reuse label)" framing (DEREM-2: action.ts trust-gate refusal, action.ts users/me failure, comment.ts user_ambiguous). - Rename `buildDeploymentChatsUrl` -> `buildDeploymentAgentsUrl`, `chatsUrl` field on `FailureCommentContext` -> `agentsUrl`, "View chats in the Coder deployment" link text -> "View agents", and the surrounding comment (DEREM-3). Update the call site in action.ts and the comment.test.ts tests. - Rewrite the README quickstart paragraph to stop using "runs under" language for the acting user (DEREM-4). - Add a divergence catch-path test: when `getAuthenticatedUser` rejects, action.run completes, a `core.warning` is emitted naming the fetch failure, and createChat is still reached (DEREM-5). - Add a `core.info` for the `no-signal` trust-gate verdict so an operator debugging identity resolution can see the gate ran and deferred (DEREM-8). - Fix the stale doc comment on `resolveOrganizationID` that claimed the username lookup happens here (DEREM-9). The lookup now always happens in `resolveCoderUsername`. - Migrate the missed `/chats/` URL fixture in schemas.test.ts:331 to `/agents/` (DEREM-1). - Drop the duplicate `Coder username: ...` info log; the new `Resolved acting Coder user: ... (source: ...)` line above already covers it (DEREM-7). DEREM-6 (P4) declined: the `users/me` throw uses the same plain `new Error(...)` shape as the sender/actor sibling throws on purpose. Fixing only this site would create an inconsistency; the cleanup is best landed as a separate pass across all four throws. 🤖 Authored by Coder Agents. --- README.md | 2 +- dist/index.js | 15 ++++++++------- src/action.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ src/action.ts | 31 +++++++++++++++++++++---------- src/comment.test.ts | 36 ++++++++++++++++++------------------ src/comment.ts | 11 ++++++----- src/schemas.test.ts | 2 +- 7 files changed, 96 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 66f5794..5bc5564 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ jobs: github-token: ${{ github.token }} ``` -The chat runs under the Coder user linked to the GitHub user who applied the label, while the chat itself is owned by the user the `coder-token` belongs to. Set `acting-coder-username` to override the acting user (used for org pick and the per-user reuse label); see [Identity](#identity-resolution) and [Security](#security-model) for the full model. +The action resolves the acting user from the GitHub user who applied the label (used for org pick and the per-user reuse label). The chat itself is owned by the user the `coder-token` belongs to. Set `acting-coder-username` to override the acting user; see [Identity](#identity-resolution) and [Security](#security-model) for the full model. ## Inputs diff --git a/dist/index.js b/dist/index.js index 9513b80..ebd7b53 100644 --- a/dist/index.js +++ b/dist/index.js @@ -27080,7 +27080,7 @@ function buildFailureCommentBody(detail, ctx) { const runPhase = isRunPhaseFailure(detail.kind, ctx); const heading = runPhase ? "**Coder Agents Chat: failed**" : "**Coder Agents Chat: failed to start**"; const lines = [heading, ""]; - const linkLine = ctx.chatUrl ? `View the chat in the Coder deployment: ${ctx.chatUrl}` : `View chats in the Coder deployment: ${ctx.chatsUrl}`; + const linkLine = ctx.chatUrl ? `View the chat in the Coder deployment: ${ctx.chatUrl}` : `View agents in the Coder deployment: ${ctx.agentsUrl}`; switch (detail.kind) { case "spend_exceeded": lines.push("The Coder deployment's chat spend limit was reached, so this " + "chat could not be created.", "", `- chat-error-kind=${detail.kind}`, `- Spent: ${formatMicrosAsDollars(detail.spentMicros)}`, `- Limit: ${formatMicrosAsDollars(detail.limitMicros)}`); @@ -27093,7 +27093,7 @@ function buildFailureCommentBody(detail, ctx) { lines.push("No Coder user could be resolved for this run. Adjust either " + "the `acting-github-user-id` input (the GitHub identity is not " + "linked to a Coder user) or pass `acting-coder-username` directly.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); break; case "user_ambiguous": - lines.push("Multiple Coder users matched the GitHub identity. Set the " + "`acting-coder-username` input to the specific account this " + "workflow should run as.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); + lines.push("Multiple Coder users matched the GitHub identity. Set the " + "`acting-coder-username` input to the specific account this " + "workflow should use as the acting user (for org pick and the " + "per-user reuse label).", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); break; case "org_not_found": lines.push("The resolved Coder user has no matching organization. Set the " + "`coder-organization` input or grant the user a membership.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); @@ -27230,7 +27230,7 @@ async function upsertCommentByMarker(args) { logLabel: "failure comment" }); } -function buildDeploymentChatsUrl(coderURL) { +function buildDeploymentAgentsUrl(coderURL) { return `${normalizeBaseUrl(coderURL)}/agents`; } @@ -27498,10 +27498,12 @@ class CoderAgentChatAction { if (!isSchedule) { const trust = classifyAutoResolveTrust(this.context); if (trust.kind === "untrusted") { - throw new Error("Refusing to auto-resolve a GitHub identity: " + `${trust.reason}. ` + "Set the `acting-coder-username` input to a Coder username, or set " + "`acting-github-user-id` to the GitHub numeric user id of the user " + "the chat should run as."); + throw new Error("Refusing to auto-resolve a GitHub identity: " + `${trust.reason}. ` + "Set the `acting-coder-username` input to a Coder username, or set " + "`acting-github-user-id` to the GitHub numeric user id of the user " + "to use as the acting user (for org pick and the per-user reuse label)."); } if (trust.kind === "trusted") { core2.info(`Auto-resolve trust check passed: ${trust.reason}`); + } else { + core2.info("Auto-resolve trust gate found no signal in the event payload; " + "deferring to GitHub's event-permission model."); } const senderId = this.context.payload?.sender?.id; if (typeof senderId === "number" && Number.isInteger(senderId) && senderId > 0) { @@ -27546,7 +27548,7 @@ class CoderAgentChatAction { try { tokenOwner = await this.getTokenOwner(); } catch (err) { - throw new Error(`Failed to resolve the \`coder-token\` owner via GET /api/v2/users/me: ${describeError(err)}. ` + "Set the `acting-coder-username` input to a Coder username, or set " + "`acting-github-user-id` to the GitHub numeric user id of the user the " + "chat should run as."); + throw new Error(`Failed to resolve the \`coder-token\` owner via GET /api/v2/users/me: ${describeError(err)}. ` + "Set the `acting-coder-username` input to a Coder username, or set " + "`acting-github-user-id` to the GitHub numeric user id of the user to " + "use as the acting user (for org pick and the per-user reuse label)."); } return { username: tokenOwner.username, @@ -27647,7 +27649,7 @@ class CoderAgentChatAction { const workflow = process.env.GITHUB_WORKFLOW || undefined; const marker = buildCommentMarker(deriveCommentKey({ ...this.inputs, workflow })); const body = buildFailureCommentBody(detail, { - chatsUrl: buildDeploymentChatsUrl(this.inputs.coderURL), + agentsUrl: buildDeploymentAgentsUrl(this.inputs.coderURL), marker, chatUrl: failure.chatUrl, chatStatus: failure.chat?.status @@ -27679,7 +27681,6 @@ class CoderAgentChatAction { core2.info(`GitHub owner: ${githubOrg}`); core2.info(`GitHub repo: ${githubRepo}`); core2.info(`GitHub item number: ${githubIssueNumber}`); - core2.info(`Coder username: ${coderUsername}`); if (this.inputs.existingChatId) { core2.info(`Sending message to existing chat: ${this.inputs.existingChatId}`); const chatId = ChatIdSchema.parse(this.inputs.existingChatId); diff --git a/src/action.test.ts b/src/action.test.ts index 4782bc8..e981745 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -1977,6 +1977,47 @@ describe("CoderAgentChatAction", () => { expect(coderClient.mockGetAuthenticatedUser).not.toHaveBeenCalled(); }); + + test("continues with a soft warning when users/me itself rejects", async () => { + // The divergence check is best-effort. A `users/me` failure must + // not crash the action before createChat; the warning surfaces the + // fetch failure and the action proceeds with the resolved acting + // user. createChat is still reached and the chat is created. + coderClient.mockGetCoderUserByUsername.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockRejectedValue( + new Error("connection refused"), + ); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + const warningSpy = spyOn(core, "warning").mockImplementation(() => {}); + + try { + const inputs = createMockInputs({ + githubUserID: undefined, + coderUsername: mockUser.username, + commentOnIssue: false, + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext({ eventName: "issues" }), + ); + + const result = await action.run(); + + expect(result.coderUsername).toBe(mockUser.username); + expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); + const softWarnings = warningSpy.mock.calls.filter((args) => + String(args[0] ?? "").includes( + "Could not fetch the `coder-token` owner for the token-owner divergence check", + ), + ); + expect(softWarnings.length).toBe(1); + expect(String(softWarnings[0][0])).toContain("connection refused"); + } finally { + warningSpy.mockRestore(); + } + }); }); describe("wait=complete polling", () => { diff --git a/src/action.ts b/src/action.ts index f3e639b..7391067 100644 --- a/src/action.ts +++ b/src/action.ts @@ -16,7 +16,7 @@ import { } from "./sanitize-label-key"; import { buildCommentMarker, - buildDeploymentChatsUrl, + buildDeploymentAgentsUrl, buildFailureCommentBody, buildSuccessCommentBody, classifyError, @@ -705,11 +705,21 @@ export class CoderAgentChatAction { `${trust.reason}. ` + "Set the `acting-coder-username` input to a Coder username, or set " + "`acting-github-user-id` to the GitHub numeric user id of the user " + - "the chat should run as.", + "to use as the acting user (for org pick and the per-user reuse label).", ); } if (trust.kind === "trusted") { core.info(`Auto-resolve trust check passed: ${trust.reason}`); + } else { + // no-signal: events like `issues`, `push`, same-repo + // `pull_request`, and `workflow_dispatch` carry no sender- + // association data the gate can act on. Log so an operator + // debugging identity resolution can tell the gate ran and + // deferred, rather than being skipped. + core.info( + "Auto-resolve trust gate found no signal in the event payload; " + + "deferring to GitHub's event-permission model.", + ); } // Prefer `sender.id` over `actor`: it's already numeric, no extra @@ -791,8 +801,8 @@ export class CoderAgentChatAction { throw new Error( `Failed to resolve the \`coder-token\` owner via GET /api/v2/users/me: ${describeError(err)}. ` + "Set the `acting-coder-username` input to a Coder username, or set " + - "`acting-github-user-id` to the GitHub numeric user id of the user the " + - "chat should run as.", + "`acting-github-user-id` to the GitHub numeric user id of the user to " + + "use as the acting user (for org pick and the per-user reuse label).", ); } return { @@ -872,10 +882,12 @@ export class CoderAgentChatAction { * `GET /api/v2/organizations/{name}`. Recommended when the user * belongs to more than one organization, since the fallback choice * is non-deterministic; a `core.warning` is emitted in that case. - * 2. The resolved Coder user's `organization_ids[0]`. When identity was - * resolved via the GitHub-id path the user object is reused; the - * `acting-coder-username` path looks the user up here via - * `getCoderUserByUsername`. + * 2. The resolved Coder user's `organization_ids[0]`. `resolveCoderUsername` + * always returns a resolved user object (across every identity + * source); this helper reuses it. The lookup-by-username branch + * below is defensive: it only fires when a future caller passes + * `resolvedUser === undefined`, which the current code path does + * not do. * * Throws `ActionFailureError("org_not_found")` when `coder-organization` * names an org that does not exist (HTTP 404) or the resolved user has no @@ -1027,7 +1039,7 @@ export class CoderAgentChatAction { deriveCommentKey({ ...this.inputs, workflow }), ); const body = buildFailureCommentBody(detail, { - chatsUrl: buildDeploymentChatsUrl(this.inputs.coderURL), + agentsUrl: buildDeploymentAgentsUrl(this.inputs.coderURL), marker, chatUrl: failure.chatUrl, chatStatus: failure.chat?.status, @@ -1064,7 +1076,6 @@ export class CoderAgentChatAction { core.info(`GitHub owner: ${githubOrg}`); core.info(`GitHub repo: ${githubRepo}`); core.info(`GitHub item number: ${githubIssueNumber}`); - core.info(`Coder username: ${coderUsername}`); // If an existing chat ID is provided, send a message to it if (this.inputs.existingChatId) { diff --git a/src/comment.test.ts b/src/comment.test.ts index 11b03aa..d6b7a3b 100644 --- a/src/comment.test.ts +++ b/src/comment.test.ts @@ -2,7 +2,7 @@ import { describe, expect, mock, test } from "bun:test"; import { CoderAPIError } from "./coder-client"; import { buildCommentMarker, - buildDeploymentChatsUrl, + buildDeploymentAgentsUrl, buildFailureCommentBody, buildSuccessCommentBody, type ChatErrorKind, @@ -219,9 +219,9 @@ describe("classifyError", () => { describe("buildFailureCommentBody", () => { const marker = ""; - const chatsUrl = "https://coder.test/agents"; + const agentsUrl = "https://coder.test/agents"; - test("spend_exceeded body includes kind, dollar amounts, deployment chat URL, and marker", () => { + test("spend_exceeded body includes kind, dollar amounts, deployment agents URL, and marker", () => { const detail: FailureDetail = { kind: "spend_exceeded", message: "Chat usage limit exceeded.", @@ -229,11 +229,11 @@ describe("buildFailureCommentBody", () => { limitMicros: 5000000, resetsAt: "2026-05-01T00:00:00Z", }; - const body = buildFailureCommentBody(detail, { chatsUrl, marker }); + const body = buildFailureCommentBody(detail, { agentsUrl, marker }); expect(body).toContain("chat-error-kind=spend_exceeded"); expect(body).toContain("$1.23"); expect(body).toContain("$5.00"); - expect(body).toContain(chatsUrl); + expect(body).toContain(agentsUrl); expect(body.endsWith(marker)).toBe(true); }); @@ -242,7 +242,7 @@ describe("buildFailureCommentBody", () => { kind: "user_not_found", message: "No Coder user found with GitHub user ID 12345", }; - const body = buildFailureCommentBody(detail, { chatsUrl, marker }); + const body = buildFailureCommentBody(detail, { agentsUrl, marker }); expect(body).toContain("chat-error-kind=user_not_found"); expect(body).toContain("acting-github-user-id"); expect(body).toContain("acting-coder-username"); @@ -254,7 +254,7 @@ describe("buildFailureCommentBody", () => { kind: "user_ambiguous", message: "Multiple Coder users found with GitHub user ID 12345", }; - const body = buildFailureCommentBody(detail, { chatsUrl, marker }); + const body = buildFailureCommentBody(detail, { agentsUrl, marker }); expect(body).toContain("chat-error-kind=user_ambiguous"); expect(body).toContain("acting-coder-username"); expect(body.endsWith(marker)).toBe(true); @@ -265,7 +265,7 @@ describe("buildFailureCommentBody", () => { kind: "api_error", message: "Coder API error: Bad Gateway", }; - const body = buildFailureCommentBody(detail, { chatsUrl, marker }); + const body = buildFailureCommentBody(detail, { agentsUrl, marker }); expect(body).toContain("chat-error-kind=api_error"); expect(body).toContain("Coder API error: Bad Gateway"); expect(body.endsWith(marker)).toBe(true); @@ -279,7 +279,7 @@ describe("buildFailureCommentBody", () => { kind: "org_not_found", message: "Coder user has no organization memberships", }; - const body = buildFailureCommentBody(detail, { chatsUrl, marker }); + const body = buildFailureCommentBody(detail, { agentsUrl, marker }); expect(body).toContain("chat-error-kind=org_not_found"); expect(body).toContain("coder-organization"); expect(body.endsWith(marker)).toBe(true); @@ -297,7 +297,7 @@ describe("buildFailureCommentBody", () => { const chatUrl = "https://coder.test/agents/990e8400-e29b-41d4-a716-446655440000"; const body = buildFailureCommentBody(detail, { - chatsUrl, + agentsUrl, chatUrl, marker, }); @@ -327,7 +327,7 @@ describe("buildFailureCommentBody", () => { const chatUrl = "https://coder.test/agents/990e8400-e29b-41d4-a716-446655440000"; const body = buildFailureCommentBody(detail, { - chatsUrl, + agentsUrl, chatUrl, marker, }); @@ -355,7 +355,7 @@ describe("buildFailureCommentBody", () => { const chatUrl = "https://coder.test/agents/990e8400-e29b-41d4-a716-446655440000"; const body = buildFailureCommentBody(detail, { - chatsUrl, + agentsUrl, chatUrl, chatStatus: "error", marker, @@ -380,10 +380,10 @@ describe("buildFailureCommentBody", () => { kind: "api_error", message: "Coder API error: Bad Gateway", }; - const body = buildFailureCommentBody(detail, { chatsUrl, marker }); + const body = buildFailureCommentBody(detail, { agentsUrl, marker }); expect(body).toContain("**Coder Agents Chat: failed to start**"); expect(body).toContain("while running the action"); - expect(body).toContain(chatsUrl); + expect(body).toContain(agentsUrl); expect(body.endsWith(marker)).toBe(true); }, ); @@ -408,18 +408,18 @@ describe("normalizeBaseUrl", () => { }); }); -describe("buildDeploymentChatsUrl", () => { +describe("buildDeploymentAgentsUrl", () => { test("appends /agents to a clean base URL", () => { - expect(buildDeploymentChatsUrl("https://coder.test")).toBe( + expect(buildDeploymentAgentsUrl("https://coder.test")).toBe( "https://coder.test/agents", ); }); test("normalizes trailing slash, query, and fragment before appending", () => { - expect(buildDeploymentChatsUrl("https://coder.test/?x=1")).toBe( + expect(buildDeploymentAgentsUrl("https://coder.test/?x=1")).toBe( "https://coder.test/agents", ); - expect(buildDeploymentChatsUrl("https://coder.test/#a")).toBe( + expect(buildDeploymentAgentsUrl("https://coder.test/#a")).toBe( "https://coder.test/agents", ); }); diff --git a/src/comment.ts b/src/comment.ts index 8649929..8b736dd 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -213,7 +213,7 @@ function formatMicrosAsDollars(micros: number): string { } export interface FailureCommentContext { - chatsUrl: string; + agentsUrl: string; marker: string; // Chat-specific URL when the failure surfaced after the chat existed // (timeout, error-state terminal, polling-network blip). Flips the @@ -243,7 +243,7 @@ export function buildFailureCommentBody( const lines: string[] = [heading, ""]; const linkLine = ctx.chatUrl ? `View the chat in the Coder deployment: ${ctx.chatUrl}` - : `View chats in the Coder deployment: ${ctx.chatsUrl}`; + : `View agents in the Coder deployment: ${ctx.agentsUrl}`; switch (detail.kind) { case "spend_exceeded": lines.push( @@ -275,7 +275,8 @@ export function buildFailureCommentBody( lines.push( "Multiple Coder users matched the GitHub identity. Set the " + "`acting-coder-username` input to the specific account this " + - "workflow should run as.", + "workflow should use as the acting user (for org pick and the " + + "per-user reuse label).", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, @@ -543,8 +544,8 @@ export async function upsertCommentByMarker(args: { }); } -// Deployment-level chats URL for the "view chats" link in the failure body. +// Deployment-level agents URL for the "view agents" link in the failure body. // We use the deployment list because a creation failure has no chat ID. -export function buildDeploymentChatsUrl(coderURL: string): string { +export function buildDeploymentAgentsUrl(coderURL: string): string { return `${normalizeBaseUrl(coderURL)}/agents`; } diff --git a/src/schemas.test.ts b/src/schemas.test.ts index 2d35973..2d62959 100644 --- a/src/schemas.test.ts +++ b/src/schemas.test.ts @@ -328,7 +328,7 @@ describe("ActionOutputsSchema", () => { const minimalOutputs = { coderUsername: "testuser", chatId: "990e8400-e29b-41d4-a716-446655440000", - chatUrl: "https://coder.test/chats/990e8400-e29b-41d4-a716-446655440000", + chatUrl: "https://coder.test/agents/990e8400-e29b-41d4-a716-446655440000", chatCreated: true, }; From 25f97dd063dd8e23eb61a5922c06416487830d54 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 18 May 2026 10:04:58 +0000 Subject: [PATCH 04/11] refactor!: drop acting-user plumbing; trust gate top-level and fail-closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A security review of PR #32 found that the trust gate, the per-user reuse label, and the `acting-*` identity inputs each protect a different invariant. The orchestrator's earlier collapse treated them as one and lost both (F2 and F3 in the review). This rewrites the identity model so the gate stays top-level and fail-closed, the reuse-label query is simplified safely, and the `acting-*` surface is dropped. What changed: - Drop input `acting-coder-username` and input `acting-github-user-id`. The chats API binds chat ownership to the `coder-token` holder; the `acting-*` inputs only selected the org and the per-user reuse label, which post-rewrite the action no longer scopes by acting user. - Rename output `acting-coder-username` -> `coder-username`. The value now always reports the token owner from `users/me`. - Drop the sender/actor auto-resolve chain entirely. `users/me` is the only identity path. - Drop `warnOnTokenOwnerDivergence`, `getTokenOwner`, the tokenOwner memoization, the `IdentitySource` type, the `describeError` helper, and the `getCoderUserByGitHubId` / `getCoderUserByUsername` methods from `CoderClient`. - Move `classifyAutoResolveTrust` from inside `resolveCoderUsername` to a top-level `assertTrustedTrigger` call in `runInner`, executed before `users/me` and `createChat`. The gate is always-on; no input bypasses it. `untrusted` throws, `trusted` and `no-signal` log and proceed. - Drop the per-user reuse label (`coder-agents-chat-action-user`). All chats are owned by the token holder so per-actor labelling added no isolation. Workflows that want per-actor separation pass `idempotency-key: ${{ github.actor }}` themselves. - Restructure the idempotency label: the sanitized user value is now the VALUE of a fixed `coder-agents-chat-action-idempotency` key, not the key itself. User input can no longer collide with action- owned keys; the runtime `RESERVED_LABEL_KEYS` collision check is retained only as a defensive export for future keys. - Validate `github-url` host in `parseGithubURL` (F6). The shared `parseGithubItemURL` helper anchors the regex to `github.com` and rejects non-github hosts before either the action's create-comment call or `deriveCommentKey`'s marker derivation. - Wrap `detail.message` and `chat.last_error` in a 4-backtick fenced code block in failure comments (F9). The block strips C0 control bytes (keeping newline and tab), downgrades embedded 4+-backtick runs so the surrounding fence stays closable, and caps the message at 4000 chars. - Rewrite the README: identity-resolution section collapses to one paragraph; security-model section documents ownership, trust-gate, and indirect prompt injection (F1) as three separate concerns; the `pull_request_target` recipe gates fork PRs via an `if:` on `head.repo.full_name`; inputs and outputs tables match the new surface. Tests: - Drop the entire `Identity resolution` and `Token-owner divergence` describe blocks (sender/actor auto-resolve and divergence-warning tests). - Drop tests that exercised the now-removed `getCoderUserByGitHubId`, `getCoderUserByUsername`, and `parseGithubUserID` paths. - Update reuse-label tests: no per-user label is emitted; the new fixed-key idempotency scheme is asserted in place of the old user-input-as-key scheme; the reserved-key collision test is flipped to assert that collision is no longer possible. - Add a new `Trust gate (top-level, always-on)` describe block pinning fork-PR refusal, NONE-association refusal, trusted-MEMBER acceptance, no-signal acceptance, and the absence of an input bypass. - Add `parseGithubURL` tests for F6: non-github.com host refusal and attacker-redirect refusal. - Add `renderDetailBlock` tests for F9: plain wrap, fence neutralization, control-byte stripping, length cap. - Update the `deriveCommentKey` enterprise-URL test to assert the new raw-URL fallback (no parse on non-github hosts). Closes CODAGT-437 Closes CODAGT-394 Closes CODAGT-438 🤖 Authored by Coder Agents. --- README.md | 101 +- action.yaml | 12 +- dist/index.js | 385 ++------ src/action.test.ts | 1642 ++++++-------------------------- src/action.ts | 505 ++-------- src/coder-client.test.ts | 200 ---- src/coder-client.ts | 85 +- src/comment.test.ts | 48 +- src/comment.ts | 79 +- src/index.test.ts | 65 +- src/index.ts | 20 - src/outputs.test.ts | 14 +- src/outputs.ts | 4 +- src/sanitize-label-key.test.ts | 8 - src/sanitize-label-key.ts | 2 +- src/schemas.test.ts | 88 -- src/schemas.ts | 14 - src/test-helpers.ts | 33 - 18 files changed, 659 insertions(+), 2646 deletions(-) diff --git a/README.md b/README.md index 5bc5564..065129e 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ GitHub Action that starts a [Coder Agents](https://coder.com/docs/ai-coder/agents) chat against a GitHub issue or pull request, optionally waits for it to finish, and posts the result as a comment. Re-running the workflow on the same target continues the existing chat instead of duplicating. +The chat owner is always the user the `coder-token` belongs to. Read [Security model](#security-model) before adopting on a public repo. + ## Requirements - Coder deployment with Agents enabled (experimental). -- Coder session token with permission to read users in the target organization and to create chats. -- For GitHub-id identity resolution: deployment configured with [GitHub OAuth](https://coder.com/docs/admin/external-auth#configure-a-github-oauth-app) and the Coder user has linked their GitHub account. +- Coder session token belonging to the user the chats should run as. Treat this token as a high-value secret: anyone holding it acts as that Coder user via the agent's tool plane (see [Security model](#security-model)). ## Quickstart @@ -38,27 +39,25 @@ jobs: github-token: ${{ github.token }} ``` -The action resolves the acting user from the GitHub user who applied the label (used for org pick and the per-user reuse label). The chat itself is owned by the user the `coder-token` belongs to. Set `acting-coder-username` to override the acting user; see [Identity](#identity-resolution) and [Security](#security-model) for the full model. +The chat runs as whoever the `coder-token` belongs to; that identity is the only one the chats API supports. Workflows that target events that route to this action without `secrets.CODER_TOKEN` redaction (`issue_comment`, `pull_request_target`, etc.) must add their own `if:` gate; see [Security model](#security-model). ## Inputs | Name | Required | Default | Description | | ---------------------- | -------- | ------- | ----------- | | `coder-url` | yes | | Coder deployment URL. | -| `coder-token` | yes | | Coder session token. | +| `coder-token` | yes | | Coder session token. The user this token belongs to is the chat owner; the chats API has no owner override. | | `chat-prompt` | yes | | Prompt to send to the agent. | -| `github-url` | yes | | Issue or pull request URL. | +| `github-url` | yes | | Issue or pull request URL. Host is validated; only `https://github.com///issues/` and `https://github.com///pull/` are accepted. | | `github-token` | yes | | Used to post and update comments. | -| `acting-coder-username` | no | | Override the acting Coder user used for org pick and the per-user reuse label. Mutually exclusive with `acting-github-user-id`. Bypasses the [trust gate](#security-model). Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. | -| `acting-github-user-id` | no | | Resolve the acting Coder user from a linked GitHub id. Mutually exclusive with `acting-coder-username`. Does NOT change the chat owner. | -| `coder-organization` | no | | Coder organization name. Recommended for multi-org users. | +| `coder-organization` | no | | Coder organization name. Recommended for multi-org token owners. | | `workspace-id` | no | | Pin the chat to an existing workspace. | | `model-config-id` | no | | Model configuration to use. | | `existing-chat-id` | no | | Send a follow-up to a known chat. Skips chat-reuse lookup. Mutually exclusive with `force-new-chat`. | | `comment-on-issue` | no | `true` | Post the result on `github-url`. | | `wait` | no | `none` | `complete` polls every 5s until terminal status or `wait-timeout-seconds`. | | `wait-timeout-seconds` | no | `600` | Max wait when `wait: complete`. | -| `idempotency-key` | no | | Optional sharding key. See [Chat reuse](#chat-reuse). | +| `idempotency-key` | no | | Optional sharding key on the reuse scope. See [Chat reuse](#chat-reuse). | | `force-new-chat` | no | `false` | Skip chat-reuse lookup and always create. Mutually exclusive with `existing-chat-id`. | ## Outputs @@ -70,7 +69,7 @@ The action resolves the acting user from the GitHub user who applied the label ( | `chat-created` | `true` if newly created, `false` if a message was sent to an existing chat. | | `chat-status` | `waiting`, `pending`, `running`, `paused`, `completed`, `error`. | | `chat-title` | Chat title. | -| `acting-coder-username` | Acting Coder username (org pick, reuse label). The chat owner is the `coder-token` holder, which may differ. | +| `coder-username` | Coder username the `coder-token` belongs to (always the chat owner). | | `workspace-id` | Workspace UUID. | | `pull-request-url` | PR or branch URL when the chat tracks changes. | | `pull-request-state` | `open`, `closed`, `merged`. | @@ -90,40 +89,33 @@ PR/diff outputs come from the chat's `diff_status` and are only reliable when th ### Identity resolution -The chat itself is always owned by the user the `coder-token` belongs to: `POST /api/experimental/chats` has no owner override, so the API binds ownership to the session. The action separately resolves an **acting user** used for org pick and the per-user reuse label (`coder-agents-chat-action-user`). First source wins: - -1. `acting-coder-username` input. Used directly. -2. `acting-github-user-id` input. Looked up by linked GitHub id; deleted Coder users are filtered. -3. `github.context.payload.sender.id`. Available on most webhook events. -4. `github.context.actor`. Resolved to a GitHub id via Octokit. -5. `GET /api/v2/users/me` against the configured `coder-token`. Used when no input or workflow-context signal applies (`schedule` events, `workflow_dispatch` without sender or actor, custom `repository_dispatch` chains). - -If the acting user resolves via `acting-coder-username` or `acting-github-user-id` and the result differs from the `coder-token` owner, the action emits a `core.warning` naming both usernames. The chat is still owned by the token holder; the warning surfaces the divergence so the workflow author can confirm the token belongs to the intended user. +There is one Coder identity in play. `POST /api/experimental/chats` binds the chat owner to the user the session token belongs to; the API has no owner override. The action calls `GET /api/v2/users/me` once to read the token owner's username and organization memberships, then creates the chat. The `coder-username` output is the token owner. ### Organization resolution 1. `coder-organization` input. Looked up by name. -2. First org membership of the resolved user. Non-deterministic for multi-org users; the action warns and recommends pinning `coder-organization`. +2. First org membership of the token owner. Non-deterministic for multi-org users; the action warns and recommends pinning `coder-organization`. Either path fails with `chat-error-kind=org_not_found` when the org doesn't exist or the user has no memberships. ### Chat reuse -By default the action reuses the most recent non-archived chat scoped to the same `github-url`, the same Coder user, and (when `GITHUB_WORKFLOW` is set) the same workflow name. Two workflows targeting the same PR keep separate chats. Re-running the same workflow continues one chat. +By default the action reuses the most recent non-archived chat scoped to the same `github-url` and (when `GITHUB_WORKFLOW` is set) the same workflow name. Two workflows targeting the same PR keep separate chats. Re-running the same workflow continues one chat. -Opt out per call with `force-new-chat: true`. Shard the scope further with `idempotency-key` to maintain multiple parallel chats on one target/user/workflow (for example, one per matrix dimension). `existing-chat-id` takes priority over both and skips the lookup. +All chats this action creates are owned by the `coder-token` holder, so the reuse scope deliberately omits a per-actor label. Workflows that want per-actor separation pass it through `idempotency-key` themselves, for example `idempotency-key: ${{ github.actor }}`. + +Opt out per call with `force-new-chat: true`. Shard the scope further with `idempotency-key` to maintain multiple parallel chats on one target/workflow (for example, one per matrix dimension). `existing-chat-id` takes priority over both and skips the lookup. The action writes these labels on every chat it creates: -| Label | Value | -| ------------------------------------- | --------------------------- | -| `coder-agents-chat-action` | `"true"` | -| `gh-target` | `/#` | -| `coder-agents-chat-action-user` | `` | -| `coder-agents-chat-action-workflow` | `` (when set) | -| `` | `"true"` (when set) | +| Label | Value | +| -------------------------------------- | ------------------------------ | +| `coder-agents-chat-action` | `"true"` | +| `gh-target` | `/#` | +| `coder-agents-chat-action-workflow` | `` (when set) | +| `coder-agents-chat-action-idempotency` | `` (when set) | -The `idempotency-key` input is sanitized to fit the platform's label-key regex (`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`, 64 bytes): lowercased, characters outside `[a-z0-9._/-]` replaced with `-`, leading non-alphanumerics trimmed, truncated. A value that sanitizes to a reserved label key is rejected at startup. +The `idempotency-key` input is sanitized to fit the platform's label-value regex (`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`, 64 bytes): lowercased, characters outside `[a-z0-9._/-]` replaced with `-`, leading non-alphanumerics trimmed, truncated. The sanitizer is lossy: `MyKey!` and `MyKey?` both collapse to `mykey-`. Pass values you control (commit SHAs, label slugs, `github.actor`) rather than free-form titles. ### Wait mode @@ -141,7 +133,7 @@ The action maintains one comment per `github-url` per workflow using a hidden HT ## Recipes -### Doc-check on every PR, under a service account +### Doc-check on every PR (gated against fork PRs) ```yaml name: Doc check @@ -156,6 +148,12 @@ permissions: jobs: doc-check: + # Internal PRs only. `pull_request_target` exposes `secrets.*` to fork + # PRs by design, so the workflow must filter trust before invoking + # this action. Swap to a label-allowlist `if:` (for example, + # `contains(github.event.pull_request.labels.*.name, 'safe-to-review')`) + # if you want to gate via maintainer-applied labels instead. + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - uses: coder/agents-chat-action@v0 @@ -163,7 +161,6 @@ jobs: coder-url: ${{ secrets.CODER_URL }} coder-token: ${{ secrets.CODER_TOKEN }} coder-organization: ${{ secrets.CODER_ORG }} # required if the bot belongs to more than one org - acting-coder-username: doc-check-bot chat-prompt: | Use the doc-check skill to review PR ${{ github.event.pull_request.html_url }}. @@ -172,7 +169,7 @@ jobs: wait: complete ``` -`pull_request_target` runs against the base repo and has access to secrets even for fork PRs. The service-account identity bypasses the trust gate so fork PRs are reviewed under the bot's organization and reuse scope. The chat itself is owned by the `coder-token` holder regardless. +`pull_request_target` runs against the base repo and has access to secrets even for fork PRs. The action's trust gate refuses fork PRs anyway, but the workflow-level `if:` is the right place to make the trust decision because it short-circuits before the runner starts the step. The chat is owned by the `coder-token` holder; the prompt is benign, but the agent will read PR content with its tools (see [Security model](#security-model)). ### Send a follow-up @@ -225,10 +222,8 @@ The action sets `chat-error-kind` and `chat-error-message` on failure, posts a c | `chat-error-kind` | What happened | What to do | | ----------------- | ------------- | ---------- | | `spend_exceeded` | Chat spend limit reached. Spent and limit are in the comment. | Wait for reset or raise the deployment's per-user limit. | -| `user_not_found` | No Coder user matched the GitHub identity. | Pass `acting-coder-username`, or have the user link their GitHub account in Coder. | -| `user_ambiguous` | Multiple live Coder users share the GitHub id. | Set `acting-coder-username` to disambiguate. | -| `org_not_found` | Org missing or the user has no memberships. The comment names which. | Fix or set `coder-organization`. | -| `api_error` | Any other Coder API error. The comment includes the underlying message; wrapped errors carry the original `CoderAPIError` via `Error.cause` and the workflow log renders the full cause chain. | Common causes: bad token, bad `workspace-id`, deployment unreachable. | +| `org_not_found` | Org missing or the token owner has no memberships. The comment names which. | Fix or set `coder-organization`. | +| `api_error` | Any other Coder API error, including trust-gate refusal and `github-url` host validation. The comment includes the underlying message in a code block; wrapped errors carry the original `CoderAPIError` via `Error.cause`, and the workflow log renders the full cause chain. | Common causes: bad token, bad `workspace-id`, deployment unreachable, fork PR refused by the trust gate, non-github.com `github-url`. | | `timeout` | `wait: complete` didn't reach terminal in time. | Raise `wait-timeout-seconds`, or split the work. | Branch on the kind without parsing the message: @@ -240,18 +235,36 @@ Branch on the kind without parsing the message: ## Security model -The **chat owner** is fixed by the `coder-token`: `POST /api/experimental/chats` has no owner override, so every chat the action creates is owned by the user the token belongs to. Workflows running fork PRs with `secrets.CODER_TOKEN` available (the `pull_request_target` pattern) execute under the workflow's Coder identity, end of story. The primary mitigation against attacker-controlled prompts under your token is GitHub's own rule that `secrets.*` is unavailable to `pull_request` events from forks. Use `pull_request_target` only when you've gated execution accordingly. +### The chat owner is the `coder-token` holder + +`POST /api/experimental/chats` binds the chat owner to whoever the session token authenticates as. There is no owner override. Anyone who can read `secrets.CODER_TOKEN` acts as that Coder user end-to-end, including the agent's tool plane (shell, `gh`, `git push`, `coder external-auth`, MCP servers). Treat the token as a high-value secret. If your platform exposes per-user spend caps, template allowlists, tool allowlists, or scoped external_auth grants, use them on the token owner; this action cannot constrain what the agent can do once a chat exists. + +### Trust gate is fail-closed; no input bypass + +Before every chat creation, the action calls `classifyAutoResolveTrust` on the GitHub event payload and refuses untrusted triggers: + +- Fork pull requests (`head.repo` null, `head.repo.fork === true`, or `head.repo.full_name !== base.repo.full_name`). +- Comment or review events whose `comment.author_association` or `review.author_association` is not `OWNER`, `MEMBER`, or `COLLABORATOR`. + +There is no input bypass: dropping the previous `acting-*` overrides was deliberate. Workflows that target events where `secrets.CODER_TOKEN` is available alongside broad trigger access (`issue_comment`, `pull_request_review`, `pull_request_review_comment`, `pull_request_target`) must add their own `if:` gate before the step. Examples: + +- `if: github.event.pull_request.head.repo.full_name == github.repository` (internal PRs only). +- `if: contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)` (trusted commenters only). +- `if: contains(github.event.pull_request.labels.*.name, 'safe-to-review')` (label allowlist on a maintainer-applied label). + +The gate does not read `issue.author_association` or `pull_request.author_association` because those describe the resource opener, not the event sender (a `MEMBER` labeling a `NONE` user's issue is fine). -The **acting user** is the Coder identity resolved for org pick and the per-user reuse label (`coder-agents-chat-action-user`). It is NOT the chat owner. The trust gate protects this acting user from pollution by untrusted triggers, layered on top of (not in place of) GitHub's event-permission model. The gate refuses to auto-resolve when: +### Indirect prompt injection (F1) -- The trigger is a fork pull request (`head.repo` null, `head.repo.fork === true`, or `head.repo.full_name !== base.repo.full_name`). -- The trigger is a comment or review whose `comment.author_association` or `review.author_association` is not `OWNER`, `MEMBER`, or `COLLABORATOR`. +The agent reads attacker-authored content during its run: PR titles, PR bodies, issue comments, diffs, and anything else the prompt tells it to fetch (`gh pr view`, `gh issue view --comments`, `gh pr diff`). The agent is a language model; it will follow embedded instructions in that content if they look plausible. Treat any public-repo trigger as adversarial regardless of the trust gate's verdict, because the gate decides whether to create the chat but does not constrain what the chat reads once it runs. -Without the gate, an attacker who happens to have a linked Coder identity could open a fork PR or drop a drive-by comment and the action would attribute the chat (org pick, reuse label) to that identity. On refusal, the action does not fall back to `users/me`: a hostile trigger should not silently collapse onto the token owner. Setting `acting-coder-username` or `acting-github-user-id` bypasses the gate; the workflow author has chosen the identity explicitly. +The action ships no defense against this class. Mitigations live deployment-side: -The gate does not read `issue.author_association` or `pull_request.author_association` because those describe the resource opener, not the event sender (a MEMBER labeling a NONE user's issue is fine). +- Pin a hardened workspace template via `workspace-id` (minimal tools, no shell, scoped network egress). +- Use Coder's platform controls to allowlist templates, restrict tool registrations, and scope the token owner's `external_auth` grants. See [Coder Agents platform controls](https://coder.com/docs/ai-coder/agents/platform-controls). +- Keep `coder-token` on a dedicated, minimally-privileged Coder user. The chat's blast radius is whatever that user can reach inside Coder (workspaces, external auth grants, mounted secrets). -Independent of the gate: if your workflow uses `pull_request_target` to run against fork PRs, gate execution on author trust separately (label gating, manual approval). The trust gate covers the auto-resolved acting user only. +The single-most-impactful mitigation against attacker-controlled prompts on a public repo is GitHub's own rule that `secrets.*` is not available to `pull_request` events from forks; the trust gate is a second checkpoint on top of that, not a replacement. ## Limitations diff --git a/action.yaml b/action.yaml index ec32653..3a21f92 100644 --- a/action.yaml +++ b/action.yaml @@ -27,14 +27,6 @@ inputs: description: "GitHub token used to post and update issue comments." required: true - acting-github-user-id: - description: "GitHub user ID. Resolves the acting Coder user (used for org pick and the per-user reuse label) by linked GitHub id. Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. Mutually exclusive with acting-coder-username." - required: false - - acting-coder-username: - description: "Override the acting Coder user used for org pick and the per-user reuse label. Mutually exclusive with acting-github-user-id. Does NOT change the chat owner; the chat is always owned by the `coder-token` holder. Useful when the workflow's token belongs to one user but the action should attribute the run to another." - required: false - coder-organization: description: "Coder organization name. Looked up by name to resolve the organization UUID for chat creation. Recommended when the resolved Coder user belongs to more than one organization, since the fallback choice is non-deterministic." required: false @@ -76,8 +68,8 @@ inputs: default: "false" outputs: - acting-coder-username: - description: "The acting Coder username (used for org pick and the per-user reuse label). The chat owner is the `coder-token` holder, which may differ." + coder-username: + description: "The Coder username the `coder-token` belongs to (always the chat owner; the chats API has no owner override)." chat-id: description: "The chat ID." diff --git a/dist/index.js b/dist/index.js index ebd7b53..2afba9d 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2,7 +2,6 @@ var __create = Object.create; var __getProtoOf = Object.getPrototypeOf; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __hasOwnProp = Object.prototype.hasOwnProperty; function __accessProp(key) { return this[key]; @@ -29,23 +28,6 @@ var __toESM = (mod, isNodeMode, target) => { cache.set(mod, to); return to; }; -var __toCommonJS = (from) => { - var entry = (__moduleCache ??= new WeakMap).get(from), desc; - if (entry) - return entry; - entry = __defProp({}, "__esModule", { value: true }); - if (from && typeof from === "object" || typeof from === "function") { - for (var key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(entry, key)) - __defProp(entry, key, { - get: __accessProp.bind(from, key), - enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable - }); - } - __moduleCache.set(from, entry); - return entry; -}; -var __moduleCache; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); var __returnValue = (v) => v; function __exportSetter(name, newValue) { @@ -19128,7 +19110,7 @@ var require_before_after_hook = __commonJS((exports2, module2) => { // node_modules/@octokit/endpoint/dist-node/index.js var require_dist_node2 = __commonJS((exports2, module2) => { var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames2 = Object.getOwnPropertyNames; var __hasOwnProp2 = Object.prototype.hasOwnProperty; var __export2 = (target, all) => { @@ -19139,16 +19121,16 @@ var require_dist_node2 = __commonJS((exports2, module2) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames2(from)) if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); + var __toCommonJS = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); var dist_src_exports = {}; __export2(dist_src_exports, { endpoint: () => endpoint }); - module2.exports = __toCommonJS2(dist_src_exports); + module2.exports = __toCommonJS(dist_src_exports); var import_universal_user_agent = require_dist_node(); var VERSION = "9.0.6"; var userAgent = `octokit-endpoint.js/${VERSION} ${(0, import_universal_user_agent.getUserAgent)()}`; @@ -19542,7 +19524,7 @@ var require_once = __commonJS((exports2, module2) => { var require_dist_node4 = __commonJS((exports2, module2) => { var __create2 = Object.create; var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames2 = Object.getOwnPropertyNames; var __getProtoOf2 = Object.getPrototypeOf; var __hasOwnProp2 = Object.prototype.hasOwnProperty; @@ -19554,17 +19536,17 @@ var require_dist_node4 = __commonJS((exports2, module2) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames2(from)) if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM2 = (mod, isNodeMode, target) => (target = mod != null ? __create2(__getProtoOf2(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp2(target, "default", { value: mod, enumerable: true }) : target, mod)); - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); + var __toCommonJS = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); var dist_src_exports = {}; __export2(dist_src_exports, { RequestError: () => RequestError }); - module2.exports = __toCommonJS2(dist_src_exports); + module2.exports = __toCommonJS(dist_src_exports); var import_deprecation = require_dist_node3(); var import_once = __toESM2(require_once()); var logOnceCode = (0, import_once.default)((deprecation) => console.warn(deprecation)); @@ -19612,7 +19594,7 @@ var require_dist_node4 = __commonJS((exports2, module2) => { // node_modules/@octokit/request/dist-node/index.js var require_dist_node5 = __commonJS((exports2, module2) => { var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames2 = Object.getOwnPropertyNames; var __hasOwnProp2 = Object.prototype.hasOwnProperty; var __export2 = (target, all) => { @@ -19623,16 +19605,16 @@ var require_dist_node5 = __commonJS((exports2, module2) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames2(from)) if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); + var __toCommonJS = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); var dist_src_exports = {}; __export2(dist_src_exports, { request: () => request }); - module2.exports = __toCommonJS2(dist_src_exports); + module2.exports = __toCommonJS(dist_src_exports); var import_endpoint = require_dist_node2(); var import_universal_user_agent = require_dist_node(); var VERSION = "8.4.1"; @@ -19811,7 +19793,7 @@ var require_dist_node5 = __commonJS((exports2, module2) => { // node_modules/@octokit/graphql/dist-node/index.js var require_dist_node6 = __commonJS((exports2, module2) => { var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames2 = Object.getOwnPropertyNames; var __hasOwnProp2 = Object.prototype.hasOwnProperty; var __export2 = (target, all) => { @@ -19822,18 +19804,18 @@ var require_dist_node6 = __commonJS((exports2, module2) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames2(from)) if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); + var __toCommonJS = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); var index_exports = {}; __export2(index_exports, { GraphqlResponseError: () => GraphqlResponseError, graphql: () => graphql2, withCustomRequest: () => withCustomRequest }); - module2.exports = __toCommonJS2(index_exports); + module2.exports = __toCommonJS(index_exports); var import_request3 = require_dist_node5(); var import_universal_user_agent = require_dist_node(); var VERSION = "7.1.1"; @@ -19935,7 +19917,7 @@ var require_dist_node6 = __commonJS((exports2, module2) => { // node_modules/@octokit/auth-token/dist-node/index.js var require_dist_node7 = __commonJS((exports2, module2) => { var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames2 = Object.getOwnPropertyNames; var __hasOwnProp2 = Object.prototype.hasOwnProperty; var __export2 = (target, all) => { @@ -19946,16 +19928,16 @@ var require_dist_node7 = __commonJS((exports2, module2) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames2(from)) if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); + var __toCommonJS = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); var dist_src_exports = {}; __export2(dist_src_exports, { createTokenAuth: () => createTokenAuth }); - module2.exports = __toCommonJS2(dist_src_exports); + module2.exports = __toCommonJS(dist_src_exports); var REGEX_IS_INSTALLATION_LEGACY = /^v1\./; var REGEX_IS_INSTALLATION = /^ghs_/; var REGEX_IS_USER_TO_SERVER = /^ghu_/; @@ -19998,7 +19980,7 @@ var require_dist_node7 = __commonJS((exports2, module2) => { // node_modules/@octokit/core/dist-node/index.js var require_dist_node8 = __commonJS((exports2, module2) => { var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames2 = Object.getOwnPropertyNames; var __hasOwnProp2 = Object.prototype.hasOwnProperty; var __export2 = (target, all) => { @@ -20009,16 +19991,16 @@ var require_dist_node8 = __commonJS((exports2, module2) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames2(from)) if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); + var __toCommonJS = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); var index_exports = {}; __export2(index_exports, { Octokit: () => Octokit }); - module2.exports = __toCommonJS2(index_exports); + module2.exports = __toCommonJS(index_exports); var import_universal_user_agent = require_dist_node(); var import_before_after_hook = require_before_after_hook(); var import_request = require_dist_node5(); @@ -20134,7 +20116,7 @@ var require_dist_node8 = __commonJS((exports2, module2) => { // node_modules/@octokit/plugin-rest-endpoint-methods/dist-node/index.js var require_dist_node9 = __commonJS((exports2, module2) => { var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames2 = Object.getOwnPropertyNames; var __hasOwnProp2 = Object.prototype.hasOwnProperty; var __export2 = (target, all) => { @@ -20145,17 +20127,17 @@ var require_dist_node9 = __commonJS((exports2, module2) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames2(from)) if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); + var __toCommonJS = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); var dist_src_exports = {}; __export2(dist_src_exports, { legacyRestEndpointMethods: () => legacyRestEndpointMethods, restEndpointMethods: () => restEndpointMethods }); - module2.exports = __toCommonJS2(dist_src_exports); + module2.exports = __toCommonJS(dist_src_exports); var VERSION = "10.4.1"; var Endpoints = { actions: { @@ -22271,7 +22253,7 @@ var require_dist_node9 = __commonJS((exports2, module2) => { // node_modules/@octokit/plugin-paginate-rest/dist-node/index.js var require_dist_node10 = __commonJS((exports2, module2) => { var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames2 = Object.getOwnPropertyNames; var __hasOwnProp2 = Object.prototype.hasOwnProperty; var __export2 = (target, all) => { @@ -22282,11 +22264,11 @@ var require_dist_node10 = __commonJS((exports2, module2) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames2(from)) if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); + var __toCommonJS = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); var dist_src_exports = {}; __export2(dist_src_exports, { composePaginateRest: () => composePaginateRest, @@ -22294,7 +22276,7 @@ var require_dist_node10 = __commonJS((exports2, module2) => { paginateRest: () => paginateRest, paginatingEndpoints: () => paginatingEndpoints }); - module2.exports = __toCommonJS2(dist_src_exports); + module2.exports = __toCommonJS(dist_src_exports); var VERSION = "9.2.2"; function normalizePaginatedListResponse(response) { if (!response.data) { @@ -22744,11 +22726,6 @@ var require_github = __commonJS((exports2) => { }); // src/index.ts -var exports_src = {}; -__export(exports_src, { - parseGithubUserID: () => parseGithubUserID -}); -module.exports = __toCommonJS(exports_src); var core4 = __toESM(require_core(), 1); var github = __toESM(require_github(), 1); @@ -26770,41 +26747,6 @@ class RealCoderClient { } return response.json(); } - async getCoderUserByGitHubId(githubUserId) { - if (githubUserId === undefined) { - throw new CoderAPIError("GitHub user ID cannot be undefined", 400); - } - if (githubUserId === 0) { - throw new CoderAPIError("GitHub user ID cannot be 0", 400); - } - const filter = `github_com_user_id:${githubUserId}`; - const endpoint = `/api/v2/users?q=${encodeURIComponent(filter)}`; - const response = await this.request(endpoint); - const userList = CoderSDKGetUsersResponseSchema.parse(response); - const liveUsers = userList.users.filter((u) => !u.deleted); - if (liveUsers.length === 0) { - throw new CoderAPIError(`No Coder user found with GitHub user ID ${githubUserId}`, 404, undefined, "user_not_found"); - } - if (liveUsers.length > 1) { - throw new CoderAPIError(`Multiple Coder users found with GitHub user ID ${githubUserId}`, 409, undefined, "user_ambiguous"); - } - return CoderSDKUserSchema.parse(liveUsers[0]); - } - async getCoderUserByUsername(username) { - if (!username) { - throw new CoderAPIError("Coder username cannot be empty", 400); - } - const endpoint = `/api/v2/users/${encodeURIComponent(username)}`; - try { - const response = await this.request(endpoint); - return CoderSDKUserSchema.parse(response); - } catch (err) { - if (err instanceof CoderAPIError && err.statusCode === 404) { - throw new CoderAPIError(`No Coder user found with username "${username}"`, 404, err.response, "user_not_found"); - } - throw err; - } - } async getAuthenticatedUser() { const response = await this.request("/api/v2/users/me"); return CoderSDKUserSchema.parse(response); @@ -26865,9 +26807,6 @@ var CoderSDKUserSchema = exports_external.object({ github_com_user_id: exports_external.number().optional(), deleted: exports_external.boolean().optional() }); -var CoderSDKGetUsersResponseSchema = exports_external.object({ - users: exports_external.array(CoderSDKUserSchema) -}); var CoderOrganizationSchema = exports_external.object({ id: exports_external.string().uuid(), name: exports_external.string(), @@ -26962,8 +26901,8 @@ class CoderAPIError extends Error { var ACTION_LABEL_KEYS = { marker: "coder-agents-chat-action", target: "gh-target", - user: "coder-agents-chat-action-user", - workflow: "coder-agents-chat-action-workflow" + workflow: "coder-agents-chat-action-workflow", + idempotency: "coder-agents-chat-action-idempotency" }; var RESERVED_LABEL_KEYS = new Set(Object.values(ACTION_LABEL_KEYS)); function sanitizeLabelKey(input) { @@ -26976,7 +26915,21 @@ function sanitizeLabelKey(input) { // src/comment.ts var core = __toESM(require_core(), 1); -var GITHUB_URL_REGEX = /([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)\/?(?:[?#].*)?$/; +var GITHUB_URL_REGEX = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)\/?(?:[?#].*)?$/; +function parseGithubItemURL(input) { + if (!input) { + return; + } + const match = input.match(GITHUB_URL_REGEX); + if (!match) { + return; + } + return { + owner: match[1], + repo: match[2], + number: Number.parseInt(match[3], 10) + }; +} var COMMENT_MARKER_PREFIX = ""; function buildCommentMarker(key) { @@ -27076,6 +27029,15 @@ function formatMicrosAsDollars(micros) { const dollars = micros / 1e6; return `$${dollars.toFixed(2)}`; } +function renderDetailBlock(message) { + const stripped = message.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, ""); + const capped = stripped.length > 4000 ? stripped.slice(0, 4000) : stripped; + const safe = capped.replace(/`{4,}/g, "```"); + return `- Detail: +\`\`\`\` +${safe} +\`\`\`\``; +} function buildFailureCommentBody(detail, ctx) { const runPhase = isRunPhaseFailure(detail.kind, ctx); const heading = runPhase ? "**Coder Agents Chat: failed**" : "**Coder Agents Chat: failed to start**"; @@ -27090,24 +27052,24 @@ function buildFailureCommentBody(detail, ctx) { lines.push("", linkLine); break; case "user_not_found": - lines.push("No Coder user could be resolved for this run. Adjust either " + "the `acting-github-user-id` input (the GitHub identity is not " + "linked to a Coder user) or pass `acting-coder-username` directly.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); + lines.push("No Coder user could be resolved for this run. Adjust either " + "the `acting-github-user-id` input (the GitHub identity is not " + "linked to a Coder user) or pass `acting-coder-username` directly.", "", `- chat-error-kind=${detail.kind}`, renderDetailBlock(detail.message), "", linkLine); break; case "user_ambiguous": - lines.push("Multiple Coder users matched the GitHub identity. Set the " + "`acting-coder-username` input to the specific account this " + "workflow should use as the acting user (for org pick and the " + "per-user reuse label).", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); + lines.push("Multiple Coder users matched the GitHub identity. Set the " + "`acting-coder-username` input to the specific account this " + "workflow should use as the acting user (for org pick and the " + "per-user reuse label).", "", `- chat-error-kind=${detail.kind}`, renderDetailBlock(detail.message), "", linkLine); break; case "org_not_found": - lines.push("The resolved Coder user has no matching organization. Set the " + "`coder-organization` input or grant the user a membership.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); + lines.push("The resolved Coder user has no matching organization. Set the " + "`coder-organization` input or grant the user a membership.", "", `- chat-error-kind=${detail.kind}`, renderDetailBlock(detail.message), "", linkLine); break; case "api_error": lines.push(apiErrorPhrase(runPhase, ctx), ""); - lines.push(`- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`); + lines.push(`- chat-error-kind=${detail.kind}`, renderDetailBlock(detail.message)); if (ctx.chatStatus === "error") { lines.push("- Hint: the agent itself failed mid-run; inspect " + "`last_error` on the chat (e.g. provider rate limits) " + "rather than action connectivity."); } lines.push("", linkLine); break; case "timeout": - lines.push("`wait: complete` polling did not reach a terminal status within " + "`wait-timeout-seconds`.", "", `- chat-error-kind=${detail.kind}`, `- Detail: ${detail.message}`, "", linkLine); + lines.push("`wait: complete` polling did not reach a terminal status within " + "`wait-timeout-seconds`.", "", `- chat-error-kind=${detail.kind}`, renderDetailBlock(detail.message), "", linkLine); break; default: { const _exhaustive = detail; @@ -27261,19 +27223,6 @@ class ActionFailureError extends Error { coderUsername; chatUrl; } -function describeError(err) { - if (err instanceof Error) { - return err.message; - } - if (typeof err === "string") { - return err; - } - try { - return JSON.stringify(err); - } catch { - return String(err); - } -} var TRUSTED_AUTHOR_ASSOCIATIONS = new Set([ "OWNER", "MEMBER", @@ -27333,14 +27282,14 @@ class CoderAgentChatAction { if (!this.inputs.githubURL) { throw new Error("Missing GitHub URL"); } - const match = this.inputs.githubURL.match(GITHUB_URL_REGEX); - if (!match) { - throw new Error(`Invalid GitHub URL: ${this.inputs.githubURL}`); + const parsed = parseGithubItemURL(this.inputs.githubURL); + if (!parsed) { + throw new Error(`Invalid \`github-url\` input "${this.inputs.githubURL}". ` + "Expected `https://github.com///issues/` or " + "`https://github.com///pull/`. The action " + "rejects non-github.com hosts so a workflow that templates " + "user-controlled content into this input cannot redirect the " + "action to an attacker-chosen repository."); } return { - githubOrg: match[1], - githubRepo: match[2], - githubIssueNumber: parseInt(match[3], 10) + githubOrg: parsed.owner, + githubRepo: parsed.repo, + githubIssueNumber: parsed.number }; } generateChatUrl(chatId) { @@ -27467,121 +27416,18 @@ class CoderAgentChatAction { throw err; } } - async resolveCoderUsername() { - if (this.inputs.coderUsername) { - core2.info(`Using provided Coder username for acting user: ${this.inputs.coderUsername}`); - let coderUser; - try { - coderUser = await this.coder.getCoderUserByUsername(this.inputs.coderUsername); - } catch (err) { - if (err instanceof CoderAPIError && err.statusCode === 404) { - throw new ActionFailureError("user_not_found", `Coder user '${this.inputs.coderUsername}' not found. ` + "Check the `acting-coder-username` input value.", undefined, { cause: err }); - } - throw err; - } - return { - username: coderUser.username, - user: coderUser, - source: "acting-coder-username" - }; + assertTrustedTrigger() { + const trust = classifyAutoResolveTrust(this.context); + if (trust.kind === "untrusted") { + throw new Error("Refusing to act on an untrusted trigger: " + `${trust.reason}. ` + "Add an `if:` gate to the workflow step (for example, " + "`author_association` allowlist or a label allowlist on " + "`pull_request_target`) before invoking this action. See " + "the README security model for details."); } - if (this.inputs.githubUserID !== undefined) { - core2.info(`Looking up Coder user by GitHub user ID: ${this.inputs.githubUserID}`); - const coderUser = await this.coder.getCoderUserByGitHubId(this.inputs.githubUserID); - return { - username: coderUser.username, - user: coderUser, - source: "acting-github-user-id" - }; - } - const isSchedule = this.context.eventName === "schedule"; - if (!isSchedule) { - const trust = classifyAutoResolveTrust(this.context); - if (trust.kind === "untrusted") { - throw new Error("Refusing to auto-resolve a GitHub identity: " + `${trust.reason}. ` + "Set the `acting-coder-username` input to a Coder username, or set " + "`acting-github-user-id` to the GitHub numeric user id of the user " + "to use as the acting user (for org pick and the per-user reuse label)."); - } - if (trust.kind === "trusted") { - core2.info(`Auto-resolve trust check passed: ${trust.reason}`); - } else { - core2.info("Auto-resolve trust gate found no signal in the event payload; " + "deferring to GitHub's event-permission model."); - } - const senderId = this.context.payload?.sender?.id; - if (typeof senderId === "number" && Number.isInteger(senderId) && senderId > 0) { - core2.info(`Auto-resolving Coder user from github.context.payload.sender.id: ${senderId}`); - try { - const coderUser = await this.coder.getCoderUserByGitHubId(senderId); - return { - username: coderUser.username, - user: coderUser, - source: "sender" - }; - } catch (err) { - throw new Error(`Failed to resolve Coder user from github.context.payload.sender.id (${senderId}): ${describeError(err)}. ` + "Set the `acting-coder-username` input to bypass auto-resolution."); - } - } - const actor = this.context.actor; - if (actor) { - core2.info(`Auto-resolving Coder user from github.context.actor: ${actor}`); - let actorId; - try { - const { data } = await this.octokit.rest.users.getByUsername({ - username: actor - }); - actorId = data.id; - } catch (err) { - throw new Error(`Failed to resolve GitHub user id for github.context.actor (${actor}): ${describeError(err)}. ` + "Set the `acting-coder-username` input to bypass auto-resolution."); - } - try { - const coderUser = await this.coder.getCoderUserByGitHubId(actorId); - return { - username: coderUser.username, - user: coderUser, - source: "actor" - }; - } catch (err) { - throw new Error(`Failed to resolve Coder user for github.context.actor (${actor}, GitHub user id ${actorId}): ${describeError(err)}. ` + "Set the `acting-coder-username` input to bypass auto-resolution."); - } - } - } - core2.info("No GitHub identity input or workflow-context signal was usable; " + "falling back to the `coder-token` owner via GET /api/v2/users/me."); - let tokenOwner; - try { - tokenOwner = await this.getTokenOwner(); - } catch (err) { - throw new Error(`Failed to resolve the \`coder-token\` owner via GET /api/v2/users/me: ${describeError(err)}. ` + "Set the `acting-coder-username` input to a Coder username, or set " + "`acting-github-user-id` to the GitHub numeric user id of the user to " + "use as the acting user (for org pick and the per-user reuse label)."); - } - return { - username: tokenOwner.username, - user: tokenOwner, - source: "token" - }; - } - tokenOwnerCache; - async getTokenOwner() { - if (this.tokenOwnerCache) { - return this.tokenOwnerCache; - } - const user = await this.coder.getAuthenticatedUser(); - this.tokenOwnerCache = user; - return user; - } - async warnOnTokenOwnerDivergence(resolved) { - if (resolved.source !== "acting-coder-username" && resolved.source !== "acting-github-user-id") { - return; - } - let tokenOwner; - try { - tokenOwner = await this.getTokenOwner(); - } catch (err) { - core2.warning(`Could not fetch the \`coder-token\` owner for the token-owner divergence check: ${describeError(err)}. ` + "Continuing; the chat will still be owned by whoever the token belongs to."); - return; - } - if (tokenOwner.id === resolved.user.id) { - return; + if (trust.kind === "trusted") { + core2.info(`Trust gate passed: ${trust.reason}`); + } else { + core2.info("Trust gate found no signal in the event payload; deferring " + "to GitHub's event-permission model."); } - core2.warning(`The resolved acting user '${resolved.username}' differs from the \`coder-token\` owner '${tokenOwner.username}'. ` + "The chat is owned by the token holder; the acting user only " + "selects the organization and the per-user reuse label. Confirm " + "the token belongs to the user you intended."); } - async resolveOrganizationID(coderUsername, resolvedUser) { + async resolveOrganizationID(user) { if (this.inputs.coderOrganization) { core2.info(`Resolving Coder organization by name: ${this.inputs.coderOrganization}`); try { @@ -27594,19 +27440,6 @@ class CoderAgentChatAction { throw err; } } - let user; - if (resolvedUser) { - user = resolvedUser; - } else { - try { - user = await this.coder.getCoderUserByUsername(coderUsername); - } catch (err) { - if (err instanceof CoderAPIError && err.statusCode === 404) { - throw new ActionFailureError("user_not_found", `Coder user '${coderUsername}' not found. ` + "Check the `acting-coder-username` input value.", undefined, { cause: err }); - } - throw err; - } - } const orgID = user.organization_ids[0]; if (!orgID) { throw new ActionFailureError("org_not_found", `Coder user '${user.username}' has no organization memberships. ` + "Set the `coder-organization` input to the organization the chat " + "should run in."); @@ -27666,21 +27499,14 @@ class CoderAgentChatAction { } async runInner() { this.warnUnwiredInputs(); - const { - username: coderUsername, - user: resolvedUser, - source: identitySource - } = await this.resolveCoderUsername(); - core2.info(`Resolved acting Coder user: '${coderUsername}' (source: ${identitySource})`); - await this.warnOnTokenOwnerDivergence({ - username: coderUsername, - user: resolvedUser, - source: identitySource - }); const { githubOrg, githubRepo, githubIssueNumber } = this.parseGithubURL(); core2.info(`GitHub owner: ${githubOrg}`); core2.info(`GitHub repo: ${githubRepo}`); core2.info(`GitHub item number: ${githubIssueNumber}`); + this.assertTrustedTrigger(); + const tokenOwner = await this.coder.getAuthenticatedUser(); + const coderUsername = tokenOwner.username; + core2.info(`Resolved Coder user from \`coder-token\` (users/me): ${coderUsername}`); if (this.inputs.existingChatId) { core2.info(`Sending message to existing chat: ${this.inputs.existingChatId}`); const chatId = ChatIdSchema.parse(this.inputs.existingChatId); @@ -27694,15 +27520,12 @@ class CoderAgentChatAction { }); } const sanitizedKey = this.inputs.idempotencyKey ? sanitizeLabelKey(this.inputs.idempotencyKey) : undefined; - if (sanitizedKey && RESERVED_LABEL_KEYS.has(sanitizedKey)) { - throw new Error(`idempotency-key sanitizes to a reserved chat-label key ("${sanitizedKey}"). ` + `Reserved keys: ${[...RESERVED_LABEL_KEYS].join(", ")}. ` + "Choose a different idempotency-key value."); - } const ghTarget = `${githubOrg}/${githubRepo}#${githubIssueNumber}`; const workflow = process.env.GITHUB_WORKFLOW || undefined; if (this.inputs.forceNewChat) { core2.info("force-new-chat=true: skipping chat-reuse lookup"); } else { - const follow = await this.findReuseMatch(ghTarget, resolvedUser.id, workflow, sanitizedKey); + const follow = await this.findReuseMatch(ghTarget, workflow, sanitizedKey); if (follow) { core2.info(`Reusing existing chat: ${follow.id}`); return this.runFollowUp({ @@ -27716,13 +27539,13 @@ class CoderAgentChatAction { } } core2.info("Creating new agents chat..."); - const organizationID = await this.resolveOrganizationID(coderUsername, resolvedUser); + const organizationID = await this.resolveOrganizationID(tokenOwner); const req = { organization_id: organizationID, content: [{ type: "text", text: this.inputs.chatPrompt }], workspace_id: this.inputs.workspaceId, model_config_id: this.inputs.modelConfigId, - labels: this.buildChatLabels(ghTarget, resolvedUser.id, workflow, sanitizedKey) + labels: this.buildChatLabels(ghTarget, workflow, sanitizedKey) }; const createdChat = await this.coder.createChat(req); core2.info(`Agents chat created successfully (id: ${createdChat.id}, status: ${createdChat.status})`); @@ -27803,17 +27626,16 @@ class CoderAgentChatAction { chatCreated: false }; } - async findReuseMatch(ghTarget, coderUserId, workflow, sanitizedKey) { + async findReuseMatch(ghTarget, workflow, sanitizedKey) { const labels = [ `${ACTION_LABEL_KEYS.marker}:true`, - `${ACTION_LABEL_KEYS.target}:${ghTarget}`, - `${ACTION_LABEL_KEYS.user}:${coderUserId}` + `${ACTION_LABEL_KEYS.target}:${ghTarget}` ]; if (workflow) { labels.push(`${ACTION_LABEL_KEYS.workflow}:${workflow}`); } if (sanitizedKey) { - labels.push(`${sanitizedKey}:true`); + labels.push(`${ACTION_LABEL_KEYS.idempotency}:${sanitizedKey}`); } let chats; try { @@ -27842,20 +27664,16 @@ class CoderAgentChatAction { } return live[0]; } - buildChatLabels(ghTarget, coderUserId, workflow, sanitizedKey) { - if (sanitizedKey && RESERVED_LABEL_KEYS.has(sanitizedKey)) { - throw new Error(`idempotency-key sanitizes to a reserved chat-label key ("${sanitizedKey}"). ` + `Reserved keys: ${[...RESERVED_LABEL_KEYS].join(", ")}. ` + "Choose a different idempotency-key value."); - } + buildChatLabels(ghTarget, workflow, sanitizedKey) { const labels = { [ACTION_LABEL_KEYS.marker]: "true", - [ACTION_LABEL_KEYS.target]: ghTarget, - [ACTION_LABEL_KEYS.user]: coderUserId + [ACTION_LABEL_KEYS.target]: ghTarget }; if (workflow) { labels[ACTION_LABEL_KEYS.workflow] = workflow; } if (sanitizedKey) { - labels[sanitizedKey] = "true"; + labels[ACTION_LABEL_KEYS.idempotency] = sanitizedKey; } return labels; } @@ -27864,7 +27682,7 @@ class CoderAgentChatAction { // src/outputs.ts var core3 = __toESM(require_core(), 1); var OUTPUT_MAP = [ - { name: "acting-coder-username", prop: "coderUsername", required: true }, + { name: "coder-username", prop: "coderUsername", required: true }, { name: "chat-id", prop: "chatId", required: true }, { name: "chat-url", prop: "chatUrl", required: true }, { name: "chat-created", prop: "chatCreated", required: true }, @@ -27906,7 +27724,7 @@ function setFailureOutputs(error3) { core3.setOutput("chat-url", error3.chatUrl); } if (error3.coderUsername) { - core3.setOutput("acting-coder-username", error3.coderUsername); + core3.setOutput("coder-username", error3.coderUsername); } } @@ -27919,8 +27737,6 @@ var ActionInputsObjectSchema = exports_external.object({ coderOrganization: exports_external.string().min(1).optional(), githubURL: exports_external.string().url(), githubToken: exports_external.string().min(1), - githubUserID: exports_external.number().int().positive().optional(), - coderUsername: exports_external.string().min(1).optional(), workspaceId: exports_external.string().uuid().optional(), modelConfigId: exports_external.string().uuid().optional(), existingChatId: exports_external.string().uuid().optional(), @@ -27930,10 +27746,7 @@ var ActionInputsObjectSchema = exports_external.object({ idempotencyKey: exports_external.string().min(1).optional(), forceNewChat: exports_external.boolean().default(false) }); -var ActionInputsSchema = ActionInputsObjectSchema.refine((data) => !(data.githubUserID !== undefined && data.coderUsername !== undefined), { - message: "Cannot set both acting-github-user-id and acting-coder-username; choose one.", - path: ["coderUsername"] -}).refine((data) => !(data.existingChatId !== undefined && data.forceNewChat === true), { +var ActionInputsSchema = ActionInputsObjectSchema.refine((data) => !(data.existingChatId !== undefined && data.forceNewChat === true), { message: "Cannot set both existing-chat-id and force-new-chat; choose one.", path: ["forceNewChat"] }); @@ -27967,16 +27780,8 @@ var ActionOutputsSchema = exports_external.object({ }); // src/index.ts -function parseGithubUserID(raw) { - if (!raw) - return; - if (!/^\d+$/.test(raw)) - return Number.NaN; - return Number(raw); -} async function main() { try { - const githubUserID = parseGithubUserID(core4.getInput("acting-github-user-id")); const inputs = ActionInputsSchema.parse({ coderURL: core4.getInput("coder-url", { required: true }), coderToken: core4.getInput("coder-token", { required: true }), @@ -27984,8 +27789,6 @@ async function main() { coderOrganization: core4.getInput("coder-organization") || undefined, githubURL: core4.getInput("github-url", { required: true }), githubToken: core4.getInput("github-token", { required: true }), - githubUserID, - coderUsername: core4.getInput("acting-coder-username") || undefined, workspaceId: core4.getInput("workspace-id") || undefined, modelConfigId: core4.getInput("model-config-id") || undefined, existingChatId: core4.getInput("existing-chat-id") || undefined, diff --git a/src/action.test.ts b/src/action.test.ts index e981745..2b1c89a 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -11,7 +11,6 @@ import { CoderAPIError } from "./coder-client"; import { ChatIdSchema, type CoderChat, - type CoderSDKUser, } from "./coder-client"; import { ActionOutputsSchema } from "./schemas"; import { @@ -104,7 +103,7 @@ describe("CoderAgentChatAction", () => { ); expect(() => action.parseGithubURL()).toThrowError( - "Invalid GitHub URL: not-a-url", + /Invalid `github-url` input/, ); }); @@ -119,7 +118,9 @@ describe("CoderAgentChatAction", () => { createMockContext(), ); - expect(() => action.parseGithubURL()).toThrowError("Invalid GitHub URL"); + expect(() => action.parseGithubURL()).toThrowError( + /Invalid `github-url` input/, + ); }); test("accepts a trailing slash after the issue number", () => { @@ -182,7 +183,13 @@ describe("CoderAgentChatAction", () => { }); }); - test("handles non-github.com URL", () => { + test("rejects non-github.com hostnames (F6)", () => { + // F6 in the security review: the regex used to accept any host + // because it was end-anchored only. Coercing the action to + // comment on `https://attacker.example/coder/coder/issues/1` + // would have called `octokit.rest.issues.createComment` with + // owner=coder, repo=coder, number=1 under the workflow's + // `github-token`. The action now refuses. const inputs = createMockInputs({ githubURL: "https://code.acme.com/owner/repo/issues/123", }); @@ -193,13 +200,25 @@ describe("CoderAgentChatAction", () => { createMockContext(), ); - const result = action.parseGithubURL(); + expect(() => action.parseGithubURL()).toThrowError( + /non-github.com hosts/, + ); + }); - expect(result).toEqual({ - githubOrg: "owner", - githubRepo: "repo", - githubIssueNumber: 123, + test("rejects an attacker-redirect via a non-github host that mimics the issue path", () => { + const inputs = createMockInputs({ + githubURL: "https://attacker.example/coder/coder/issues/1", }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); + + expect(() => action.parseGithubURL()).toThrowError( + /Invalid `github-url` input/, + ); }); }); @@ -349,11 +368,10 @@ describe("CoderAgentChatAction", () => { }); test("creates new chat successfully", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: 12345, commentOnIssue: false, }); const action = new CoderAgentChatAction( @@ -365,7 +383,7 @@ describe("CoderAgentChatAction", () => { const result = await action.run(); - expect(coderClient.mockGetCoderUserByGithubID).toHaveBeenCalledWith(12345); + expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalled(); expect(coderClient.mockCreateChat).toHaveBeenCalledWith( expect.objectContaining({ content: [{ type: "text", text: "Test prompt" }], @@ -603,12 +621,11 @@ describe("CoderAgentChatAction", () => { }); }); - test("creates chat using direct acting-coder-username", async () => { + test("creates a chat under the token owner returned by users/me", async () => { + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: mockUser.username, commentOnIssue: false, }); const action = new CoderAgentChatAction( @@ -620,7 +637,9 @@ describe("CoderAgentChatAction", () => { const result = await action.run(); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); + // users/me is the single source of identity now; assert it was + // called and a chat was created under the resulting username. + expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalled(); expect(coderClient.mockCreateChat).toHaveBeenCalled(); const parsedResult = ActionOutputsSchema.parse(result); @@ -629,7 +648,7 @@ describe("CoderAgentChatAction", () => { }); test("sends message to existing chat", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); @@ -643,7 +662,6 @@ describe("CoderAgentChatAction", () => { const existingChatId = "990e8400-e29b-41d4-a716-446655440000"; const inputs = createMockInputs({ - githubUserID: 12345, existingChatId, }); const action = new CoderAgentChatAction( @@ -675,14 +693,13 @@ describe("CoderAgentChatAction", () => { }); test("rejects a malformed existing-chat-id at runtime (defense in depth past the schema)", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); // `createMockInputs` casts to `ActionInputs` without running // `ActionInputsSchema`. That lets this test prove the // `ChatIdSchema.parse` in the existing-chat branch refuses non-UUID // input even if a future caller skips the upstream schema parse. const inputs = createMockInputs({ - githubUserID: 12345, existingChatId: "not-a-uuid", }); const action = new CoderAgentChatAction( @@ -697,7 +714,7 @@ describe("CoderAgentChatAction", () => { }); test("falls back to minimal outputs when getChat fails after follow-up", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); @@ -705,7 +722,6 @@ describe("CoderAgentChatAction", () => { const existingChatId = "990e8400-e29b-41d4-a716-446655440000"; const inputs = createMockInputs({ - githubUserID: 12345, existingChatId, commentOnIssue: false, }); @@ -732,12 +748,11 @@ describe("CoderAgentChatAction", () => { }); test("creates chat with workspace-id", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); const workspaceId = "550e8400-e29b-41d4-a716-446655440000"; const inputs = createMockInputs({ - githubUserID: 12345, workspaceId, commentOnIssue: false, }); @@ -759,11 +774,10 @@ describe("CoderAgentChatAction", () => { describe("commentOnIssue toggle", () => { test("does not comment when commentOnIssue is false", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: 12345, commentOnIssue: false, }); const action = new CoderAgentChatAction( @@ -780,7 +794,7 @@ describe("CoderAgentChatAction", () => { }); test("comments when commentOnIssue is true", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); octokit.rest.issues.listComments.mockResolvedValue({ data: [], @@ -790,7 +804,6 @@ describe("CoderAgentChatAction", () => { ); const inputs = createMockInputs({ - githubUserID: 12345, githubURL: "https://github.com/owner/repo/issues/123", commentOnIssue: true, }); @@ -850,318 +863,22 @@ describe("CoderAgentChatAction", () => { }); }); - describe("Identity resolution", () => { - test("uses acting-coder-username directly without GitHub-id lookup", async () => { - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: mockUser.username, - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "issues", - payload: { sender: { id: 99999 } }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); - expect(result.coderUsername).toBe(mockUser.username); - }); - - test("prefers acting-coder-username over acting-github-user-id when both bypass the schema", async () => { - // The Zod schema rejects setting both inputs simultaneously, but the - // resolver is a unit and the precedence #1 vs #2 must hold even if a - // future caller bypasses the schema. Constructing the action directly - // pins the precedence in the unit's contract. - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = { - ...createMockInputs(), - coderUsername: mockUser.username, - githubUserID: 12345, - commentOnIssue: false, - }; - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - createMockContext({ eventName: "issues" }), - ); - - const result = await action.run(); - - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - expect(result.coderUsername).toBe(mockUser.username); - }); - - test("looks up by acting-github-user-id when set", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: 12345, - coderUsername: undefined, - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "issues", - payload: { sender: { id: 99999 } }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - - expect(coderClient.mockGetCoderUserByGithubID).toHaveBeenCalledWith( - 12345, - ); - expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); - expect(result.coderUsername).toBe(mockUser.username); - }); - - test("falls back to context.payload.sender.id when both inputs are unset", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "issues", - actor: "some-actor", - payload: { sender: { id: 424242 } }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - - expect(coderClient.mockGetCoderUserByGithubID).toHaveBeenCalledWith( - 424242, - ); - expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); - expect(result.coderUsername).toBe(mockUser.username); - }); - - test("falls through to actor when sender exists without a numeric id", async () => { - // Bot-triggered events sometimes deliver a partial sender object - // (e.g. `{ login: "bot" }` with no `id`). The resolver guards - // `sender.id` with `typeof === "number" && > 0` and falls through. - octokit.rest.users.getByUsername.mockResolvedValue({ - data: { id: 333 }, - } as unknown as ReturnType); - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "issues", - actor: "octocat", - payload: { sender: {} }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - - expect(octokit.rest.users.getByUsername).toHaveBeenCalledWith({ - username: "octocat", - }); - expect(result.coderUsername).toBe(mockUser.username); - }); - - test("treats sender id of 0 as missing and falls through to actor", async () => { - // Mirrors the Zod schema's positive constraint on `acting-github-user-id`. - // Without the guard, `0` reaches a bare-string throw inside the - // Coder client and surfaces as "Unknown error occurred". - octokit.rest.users.getByUsername.mockResolvedValue({ - data: { id: 444 }, - } as unknown as ReturnType); - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "issues", - actor: "octocat", - payload: { sender: { id: 0 } }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalledWith( - 0, - ); - expect(octokit.rest.users.getByUsername).toHaveBeenCalledWith({ - username: "octocat", - }); - expect(result.coderUsername).toBe(mockUser.username); - }); - - test("treats non-integer sender id as missing and falls through to actor", async () => { - // Mirrors the Zod schema's `.int()` constraint on `acting-github-user-id`. - // GitHub user IDs are integers in practice, but the runtime guard - // should match the schema's shape rather than admitting `1.5`. - octokit.rest.users.getByUsername.mockResolvedValue({ - data: { id: 444 }, - } as unknown as ReturnType); - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "issues", - actor: "octocat", - payload: { sender: { id: 1.5 } }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalledWith( - 1.5, - ); - expect(octokit.rest.users.getByUsername).toHaveBeenCalledWith({ - username: "octocat", - }); - expect(result.coderUsername).toBe(mockUser.username); - }); - - test("falls back to actor lookup for manual triggers", async () => { - octokit.rest.users.getByUsername.mockResolvedValue({ - data: { id: 555 }, - } as unknown as ReturnType); - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); - // `workflow_dispatch` payloads do include `sender`, so use a payload - // shape (sender absent) that genuinely forces the actor branch. - const context = createMockContext({ - eventName: "workflow_dispatch", - actor: "octocat", - payload: {}, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - - expect(octokit.rest.users.getByUsername).toHaveBeenCalledWith({ - username: "octocat", - }); - expect(coderClient.mockGetCoderUserByGithubID).toHaveBeenCalledWith(555); - expect(result.coderUsername).toBe(mockUser.username); - }); - - test("falls back to users/me on schedule events; actor is the workflow editor and is skipped", async () => { - // The actor on a cron run is the workflow file's last editor, not - // the triggering user. Sender is empty. The action falls back to - // the `coder-token` owner so the chat owner and the acting user - // match (the chat is already owned by the token holder). - coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "schedule", - actor: "workflow-editor", - payload: {}, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - - expect(result.coderUsername).toBe(mockUser.username); - expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalledTimes(1); - // The actor must not be consulted on schedule events. The - // GitHub-id fallback path is also unreachable when the actor is - // not even looked up, so no Coder-user-by-GitHub-id call either. - expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - }); - - test("falls back to users/me on schedule events even when sender.id is present", async () => { - // The schedule guard must be semantic, not positional. Today's - // `schedule` payloads omit `sender`, but if a future GHES extension - // or custom dispatch chain delivers `sender.id`, it still describes - // the underlying webhook trigger, not the cron run. Skip sender, - // fall back to the token owner. + describe("Trust gate (top-level, always-on)", () => { + test("refuses fork pull requests before any Coder API call", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); + const inputs = createMockInputs({ commentOnIssue: false }); const context = createMockContext({ - eventName: "schedule", - actor: "workflow-editor", - payload: { sender: { id: 12345 } }, + eventName: "pull_request", + actor: "attacker", + payload: { + sender: { id: 99999 }, + pull_request: { + head: { repo: { fork: true, full_name: "attacker/fork" } }, + base: { repo: { full_name: "owner/repo" } }, + }, + }, }); const action = new CoderAgentChatAction( coderClient, @@ -1170,63 +887,35 @@ describe("CoderAgentChatAction", () => { context, ); - const result = await action.run(); - - expect(result.coderUsername).toBe(mockUser.username); - expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalledTimes(1); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); + let caught: unknown; + try { + await action.run(); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + const message = (caught as Error).message; + expect(message).toContain("untrusted trigger"); + expect(message).toContain("fork"); + expect(message).toContain("if:"); + // Nothing was called: the gate is fail-closed before any + // API call, including users/me and createChat. + expect(coderClient.mockGetAuthenticatedUser).not.toHaveBeenCalled(); + expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); }); - test("falls back to users/me when neither sender.id nor actor are usable", async () => { - // `repository_dispatch` with no sender and no actor: no - // github.context signal at all. Trust gate returns `no-signal`. - // The action falls back to the token owner rather than failing. + test("refuses NONE-association comment events", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "repository_dispatch", - actor: "", - payload: {}, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - - expect(result.coderUsername).toBe(mockUser.username); - expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalledTimes(1); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); - }); - - test("surfaces a clear error when users/me also fails", async () => { - // No inputs, no github.context signal, and the token-owner lookup - // itself fails (bad token, deployment unreachable). The action - // must surface a clear message naming `users/me`, the underlying - // failure, and the two input bypasses. - coderClient.mockGetAuthenticatedUser.mockRejectedValue( - new Error("401 Unauthorized"), - ); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - }); + const inputs = createMockInputs({ commentOnIssue: false }); const context = createMockContext({ - eventName: "repository_dispatch", - actor: "", - payload: {}, + eventName: "issue_comment", + actor: "drive-by", + payload: { + sender: { id: 99999 }, + comment: { author_association: "NONE" }, + }, }); const action = new CoderAgentChatAction( coderClient, @@ -1242,544 +931,21 @@ describe("CoderAgentChatAction", () => { caught = e; } expect(caught).toBeInstanceOf(Error); - const message = (caught as Error).message; - expect(message).toContain("users/me"); - expect(message).toContain("401 Unauthorized"); - expect(message).toContain("acting-coder-username"); - expect(message).toContain("acting-github-user-id"); + expect((caught as Error).message).toContain("NONE"); + expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); }); - test("does not fall back to users/me when the trust gate refuses", async () => { - // Fork PR: the gate refuses to auto-resolve. Falling through to - // the token owner would silently collapse a hostile-trigger event - // onto the workflow's own identity, defeating the gate. The - // failure must look exactly like the gate's pre-fallback refusal. - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - }); - const context = createMockContext({ - eventName: "pull_request", - actor: "attacker", - payload: { - sender: { id: 99999 }, - pull_request: { - head: { repo: { fork: true, full_name: "attacker/fork" } }, - base: { repo: { full_name: "owner/repo" } }, - }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - const message = (caught as Error).message; - expect(message).toContain("fork"); - expect(message).toContain("acting-coder-username"); - expect(coderClient.mockGetAuthenticatedUser).not.toHaveBeenCalled(); - }); - - test("wraps sender lookup failure with source and bypass instructions", async () => { - coderClient.mockGetCoderUserByGithubID.mockRejectedValue( - new Error("No Coder user found with GitHub user ID 424242"), - ); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - }); - const context = createMockContext({ - eventName: "issues", - payload: { sender: { id: 424242 } }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - const message = (caught as Error).message; - expect(message).toContain("github.context.payload.sender.id"); - expect(message).toContain("424242"); - expect(message).toContain( - "No Coder user found with GitHub user ID 424242", - ); - expect(message).toContain("acting-coder-username"); - }); - - test("wraps actor getByUsername failure with source and bypass instructions", async () => { - octokit.rest.users.getByUsername.mockRejectedValue( - new Error("Not Found"), - ); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - }); - const context = createMockContext({ - eventName: "workflow_dispatch", - actor: "missing-user", - payload: {}, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - const message = (caught as Error).message; - expect(message).toContain("github.context.actor"); - expect(message).toContain("missing-user"); - expect(message).toContain("Not Found"); - expect(message).toContain("acting-coder-username"); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - }); - - test("wraps actor Coder lookup failure with source and bypass instructions", async () => { - octokit.rest.users.getByUsername.mockResolvedValue({ - data: { id: 555 }, - } as unknown as ReturnType); - coderClient.mockGetCoderUserByGithubID.mockRejectedValue( - new Error("No Coder user found with GitHub user ID 555"), - ); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - }); - const context = createMockContext({ - eventName: "workflow_dispatch", - actor: "octocat", - payload: {}, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - const message = (caught as Error).message; - expect(message).toContain("github.context.actor"); - expect(message).toContain("octocat"); - expect(message).toContain("555"); - expect(message).toContain("No Coder user found with GitHub user ID 555"); - expect(message).toContain("acting-coder-username"); - }); - - test("refuses auto-resolve on a fork pull request even with a sender.id", async () => { - // Hostile-trigger threat model: an attacker who happens to have a - // Coder identity could open a PR from a fork to bind their identity - // to the workflow's chat run. The trust gate refuses before any - // Coder API call. - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - }); - const context = createMockContext({ - eventName: "pull_request", - actor: "attacker", - payload: { - sender: { id: 99999 }, - pull_request: { - head: { - repo: { fork: true, full_name: "attacker/fork" }, - }, - base: { repo: { full_name: "owner/repo" } }, - }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - const message = (caught as Error).message; - expect(message).toContain("fork"); - expect(message).toContain("acting-coder-username"); - expect(message).toContain("acting-github-user-id"); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); - }); - - test("detects fork by head/base repo full_name mismatch when fork flag is absent", async () => { - // Some webhook deliveries omit `fork`. Fall back to comparing - // `full_name` so the gate still refuses cross-repo PRs. - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - }); - const context = createMockContext({ - eventName: "pull_request", - actor: "attacker", - payload: { - sender: { id: 99999 }, - pull_request: { - head: { repo: { full_name: "attacker/fork" } }, - base: { repo: { full_name: "owner/repo" } }, - }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - expect((caught as Error).message).toContain("fork"); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - }); - - test("refuses auto-resolve when head.repo is null (deleted fork)", async () => { - // When a fork's source repository is deleted, GitHub delivers - // `pull_request.head.repo` as `null`. The fork flag and the - // full_name comparison both yield falsy under optional chaining, - // so the gate must treat `null` head repo as a fork explicitly. - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - }); - const context = createMockContext({ - eventName: "pull_request", - actor: "attacker", - payload: { - sender: { id: 99999 }, - pull_request: { - head: { repo: null }, - base: { repo: { full_name: "owner/repo" } }, - }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - expect((caught as Error).message).toContain("fork"); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - }); - - test("refuses auto-resolve when comment.author_association is CONTRIBUTOR", async () => { - // Drive-by issue comment from a non-write user. The sender id - // would resolve under the old behavior; the trust gate must - // refuse before any Coder lookup. - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - }); - const context = createMockContext({ - eventName: "issue_comment", - actor: "drive-by", - payload: { - sender: { id: 99999 }, - comment: { author_association: "CONTRIBUTOR" }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - const message = (caught as Error).message; - expect(message).toContain("CONTRIBUTOR"); - expect(message).toContain("author_association"); - expect(message).toContain("acting-coder-username"); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - }); - - test("auto-resolves when MEMBER labels NONE opener's issue (sender is the labeler)", async () => { - // Realistic `issues: [labeled]` payload. The sender is a trusted - // MEMBER labeling an issue opened by a NONE user. The gate must - // NOT read `issue.author_association` (the opener) and refuse; - // it must auto-resolve the labeler's sender.id. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "issues", - actor: "member-labeler", - payload: { - sender: { id: 424242 }, - issue: { author_association: "NONE" }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - - expect(coderClient.mockGetCoderUserByGithubID).toHaveBeenCalledWith( - 424242, - ); - expect(result.coderUsername).toBe(mockUser.username); - }); - - test("allows auto-resolve when comment.author_association is MEMBER", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + test("trusted MEMBER comment proceeds and createChat is reached", async () => { + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); + const inputs = createMockInputs({ commentOnIssue: false }); const context = createMockContext({ eventName: "issue_comment", - actor: "member-user", + actor: "member", payload: { - sender: { id: 424242 }, - comment: { author_association: "MEMBER" }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - - expect(coderClient.mockGetCoderUserByGithubID).toHaveBeenCalledWith( - 424242, - ); - expect(result.coderUsername).toBe(mockUser.username); - }); - - test("allows auto-resolve via comment.author_association for OWNER and COLLABORATOR", async () => { - for (const association of ["OWNER", "COLLABORATOR"] as const) { - const freshClient = new MockCoderClient(); - freshClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - freshClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "issue_comment", - actor: "trusted", - payload: { - sender: { id: 7 }, - comment: { author_association: association }, - }, - }); - const action = new CoderAgentChatAction( - freshClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - expect(result.coderUsername).toBe(mockUser.username); - expect(freshClient.mockGetCoderUserByGithubID).toHaveBeenCalledWith(7); - } - }); - - test("refuses auto-resolve when review.author_association is NONE", async () => { - // On `pull_request_review`, the reviewer is the sender. A NONE - // reviewer should not be able to drive auto-resolve even when the - // PR is same-repo. - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - }); - const context = createMockContext({ - eventName: "pull_request_review", - actor: "drive-by-reviewer", - payload: { - sender: { id: 99999 }, - review: { author_association: "NONE" }, - pull_request: { - head: { repo: { fork: false, full_name: "owner/repo" } }, - base: { repo: { full_name: "owner/repo" } }, - }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - const message = (caught as Error).message; - expect(message).toContain("review.author_association"); - expect(message).toContain("NONE"); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - }); - - test("prefers comment.author_association over review.author_association", async () => { - // On `pull_request_review_comment`, both `comment` and `review` - // can appear in the payload. The comment is the more specific - // signal (it identifies the line-level commenter, not the - // containing review thread author), so comment wins. - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - }); - const context = createMockContext({ - eventName: "pull_request_review_comment", - actor: "drive-by", - payload: { - sender: { id: 99999 }, - comment: { author_association: "NONE" }, - review: { author_association: "MEMBER" }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - await expect(action.run()).rejects.toThrow(/NONE/); - }); - - test("acting-coder-username bypasses the trust gate on a fork PR", async () => { - // Workflow author explicitly opted into running as a known - // service-account identity. The trust gate must not refuse: the - // fork PR's prompt is still attacker-controlled, but the workflow - // author has accepted the responsibility of that opt-in. - coderClient.mockGetCoderUserByUsername.mockResolvedValue({ - ...mockUser, - username: "bot-user", - }); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: "bot-user", - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "pull_request", - actor: "attacker", - payload: { - sender: { id: 99999 }, - pull_request: { - head: { repo: { fork: true, full_name: "attacker/fork" } }, - base: { repo: { full_name: "owner/repo" } }, - }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - const result = await action.run(); - expect(result.coderUsername).toBe("bot-user"); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - }); - - test("acting-github-user-id bypasses the trust gate on a fork PR", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - githubUserID: 7, - coderUsername: undefined, - commentOnIssue: false, - }); - const context = createMockContext({ - eventName: "pull_request", - actor: "attacker", - payload: { - sender: { id: 99999 }, - pull_request: { - head: { repo: { fork: true, full_name: "attacker/fork" } }, - base: { repo: { full_name: "owner/repo" } }, - }, + sender: { id: 42 }, + comment: { author_association: "MEMBER" }, }, }); const action = new CoderAgentChatAction( @@ -1789,30 +955,18 @@ describe("CoderAgentChatAction", () => { context, ); - const result = await action.run(); - expect(result.coderUsername).toBe(mockUser.username); - expect(coderClient.mockGetCoderUserByGithubID).toHaveBeenCalledWith(7); + await action.run(); + expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); }); - test("workflow_dispatch carries no trust signal and auto-resolves", async () => { - // `workflow_dispatch` payloads carry neither pull_request nor - // author_association data. The gate returns `no-signal` and - // auto-resolve proceeds; GitHub already gates who can trigger - // `workflow_dispatch` (write access to the repo). - octokit.rest.users.getByUsername.mockResolvedValue({ - data: { id: 555 }, - } as unknown as ReturnType); - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + test("no-signal events (issues, push, workflow_dispatch) proceed", async () => { + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, - commentOnIssue: false, - }); + const inputs = createMockInputs({ commentOnIssue: false }); const context = createMockContext({ - eventName: "workflow_dispatch", - actor: "trusted-user", + eventName: "issues", + actor: "anyone", payload: {}, }); const action = new CoderAgentChatAction( @@ -1822,149 +976,34 @@ describe("CoderAgentChatAction", () => { context, ); - const result = await action.run(); - expect(result.coderUsername).toBe(mockUser.username); - }); - }); - - describe("Token-owner divergence", () => { - test("warns when acting-coder-username differs from the coder-token owner", async () => { - const actingUser = { - ...mockUser, - id: "aa0e8400-e29b-41d4-a716-446655440099", - username: "acting-bot", - }; - const tokenOwner = { - ...mockUser, - id: "bb0e8400-e29b-41d4-a716-446655440099", - username: "token-owner", - }; - coderClient.mockGetCoderUserByUsername.mockResolvedValue(actingUser); - coderClient.mockGetAuthenticatedUser.mockResolvedValue(tokenOwner); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - const warningSpy = spyOn(core, "warning").mockImplementation(() => {}); - - try { - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: "acting-bot", - commentOnIssue: false, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - createMockContext({ eventName: "issues" }), - ); - - const result = await action.run(); - - expect(result.coderUsername).toBe("acting-bot"); - expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalledTimes(1); - const divergenceCalls = warningSpy.mock.calls.filter((args) => - String(args[0] ?? "").includes( - "differs from the `coder-token` owner", - ), - ); - expect(divergenceCalls.length).toBe(1); - const body = String(divergenceCalls[0][0]); - expect(body).toContain("acting-bot"); - expect(body).toContain("token-owner"); - } finally { - warningSpy.mockRestore(); - } - }); - - test("warns when acting-github-user-id resolves to a user different from the token owner", async () => { - const actingUser = { - ...mockUser, - id: "aa0e8400-e29b-41d4-a716-44665544aaaa", - username: "github-acting", - }; - const tokenOwner = { - ...mockUser, - id: "bb0e8400-e29b-41d4-a716-44665544bbbb", - username: "token-owner", - }; - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(actingUser); - coderClient.mockGetAuthenticatedUser.mockResolvedValue(tokenOwner); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - const warningSpy = spyOn(core, "warning").mockImplementation(() => {}); - - try { - const inputs = createMockInputs({ - githubUserID: 7777, - coderUsername: undefined, - commentOnIssue: false, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - createMockContext({ eventName: "issues" }), - ); - - await action.run(); - - const divergenceCalls = warningSpy.mock.calls.filter((args) => - String(args[0] ?? "").includes( - "differs from the `coder-token` owner", - ), - ); - expect(divergenceCalls.length).toBe(1); - } finally { - warningSpy.mockRestore(); - } + await action.run(); + expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); }); - test("does not warn when acting-coder-username matches the coder-token owner", async () => { - coderClient.mockGetCoderUserByUsername.mockResolvedValue(mockUser); + test("the gate has no input bypass; idempotency-key cannot bypass it", async () => { + // Pre-rewrite, an explicit acting-coder-username or + // acting-github-user-id input bypassed the gate. Those inputs + // were dropped; no current input bypasses the gate. Setting + // every remaining input still refuses an untrusted trigger. coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); - const warningSpy = spyOn(core, "warning").mockImplementation(() => {}); - - try { - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: mockUser.username, - commentOnIssue: false, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - createMockContext({ eventName: "issues" }), - ); - - await action.run(); - - const divergenceCalls = warningSpy.mock.calls.filter((args) => - String(args[0] ?? "").includes( - "differs from the `coder-token` owner", - ), - ); - expect(divergenceCalls.length).toBe(0); - } finally { - warningSpy.mockRestore(); - } - }); - - test("does not call users/me when auto-resolving from github.context (sender)", async () => { - // The divergence check is scoped to explicit identity inputs. - // Auto-resolved sources (sender, actor) cannot be cross-checked - // against the token without false alarms (the human triggerer is - // expected to differ from a service-account token). - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: undefined, commentOnIssue: false, + idempotencyKey: "anything", + coderOrganization: "anything", + workspaceId: "11111111-1111-1111-1111-111111111111", }); const context = createMockContext({ - eventName: "issues", - payload: { sender: { id: 424242 } }, + eventName: "pull_request", + actor: "attacker", + payload: { + sender: { id: 99999 }, + pull_request: { + head: { repo: { fork: true, full_name: "attacker/fork" } }, + base: { repo: { full_name: "owner/repo" } }, + }, + }, }); const action = new CoderAgentChatAction( coderClient, @@ -1973,60 +1012,17 @@ describe("CoderAgentChatAction", () => { context, ); - await action.run(); - - expect(coderClient.mockGetAuthenticatedUser).not.toHaveBeenCalled(); - }); - - test("continues with a soft warning when users/me itself rejects", async () => { - // The divergence check is best-effort. A `users/me` failure must - // not crash the action before createChat; the warning surfaces the - // fetch failure and the action proceeds with the resolved acting - // user. createChat is still reached and the chat is created. - coderClient.mockGetCoderUserByUsername.mockResolvedValue(mockUser); - coderClient.mockGetAuthenticatedUser.mockRejectedValue( - new Error("connection refused"), - ); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - const warningSpy = spyOn(core, "warning").mockImplementation(() => {}); - - try { - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: mockUser.username, - commentOnIssue: false, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - createMockContext({ eventName: "issues" }), - ); - - const result = await action.run(); - - expect(result.coderUsername).toBe(mockUser.username); - expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); - const softWarnings = warningSpy.mock.calls.filter((args) => - String(args[0] ?? "").includes( - "Could not fetch the `coder-token` owner for the token-owner divergence check", - ), - ); - expect(softWarnings.length).toBe(1); - expect(String(softWarnings[0][0])).toContain("connection refused"); - } finally { - warningSpy.mockRestore(); - } + await expect(action.run()).rejects.toThrow(/untrusted trigger/); + expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); }); }); describe("wait=complete polling", () => { test("wait=none honors the wait gate: no getChat, no clock sleep", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: 12345, wait: "none", commentOnIssue: false, }); @@ -2050,7 +1046,7 @@ describe("CoderAgentChatAction", () => { }); test("wait=complete polls getChat every 5 seconds until terminal", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue({ ...mockChat, status: "running", @@ -2061,7 +1057,6 @@ describe("CoderAgentChatAction", () => { .mockResolvedValueOnce({ ...mockChat, status: "completed" }); const inputs = createMockInputs({ - githubUserID: 12345, wait: "complete", waitTimeoutSeconds: 600, commentOnIssue: false, @@ -2089,7 +1084,7 @@ describe("CoderAgentChatAction", () => { // Polling must complete before the comment goes out, otherwise a // failure mid-poll would leave a stale "Agents Chat:" comment on // the issue while the workflow step itself fails. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue({ ...mockChat, status: "running", @@ -2105,7 +1100,6 @@ describe("CoderAgentChatAction", () => { ); const inputs = createMockInputs({ - githubUserID: 12345, wait: "complete", waitTimeoutSeconds: 600, commentOnIssue: true, @@ -2135,7 +1129,7 @@ describe("CoderAgentChatAction", () => { }); test("wait=complete fails with chat-error-kind=timeout when timeout reached", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue({ ...mockChat, status: "running", @@ -2146,7 +1140,6 @@ describe("CoderAgentChatAction", () => { }); const inputs = createMockInputs({ - githubUserID: 12345, wait: "complete", waitTimeoutSeconds: 10, commentOnIssue: false, @@ -2176,7 +1169,7 @@ describe("CoderAgentChatAction", () => { }); test("wait=complete fails when chat enters error during polling", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue({ ...mockChat, status: "running", @@ -2190,7 +1183,6 @@ describe("CoderAgentChatAction", () => { }); const inputs = createMockInputs({ - githubUserID: 12345, wait: "complete", waitTimeoutSeconds: 600, commentOnIssue: false, @@ -2222,7 +1214,7 @@ describe("CoderAgentChatAction", () => { }); test("wait=complete reaches terminal status, outputs reflect final chat state", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); const initialChat = { ...mockChat, status: "running" as const, @@ -2239,7 +1231,6 @@ describe("CoderAgentChatAction", () => { .mockResolvedValueOnce(finalChat); const inputs = createMockInputs({ - githubUserID: 12345, wait: "complete", waitTimeoutSeconds: 600, commentOnIssue: false, @@ -2261,7 +1252,7 @@ describe("CoderAgentChatAction", () => { }); test("wait=complete also polls when existing-chat-id is set", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); @@ -2276,7 +1267,6 @@ describe("CoderAgentChatAction", () => { const existingChatId = "990e8400-e29b-41d4-a716-446655440000"; const inputs = createMockInputs({ - githubUserID: 12345, existingChatId, wait: "complete", waitTimeoutSeconds: 600, @@ -2307,7 +1297,7 @@ describe("CoderAgentChatAction", () => { }); test("wait=complete fails with chat-error-kind=api_error when getChat throws", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue({ ...mockChat, status: "running", @@ -2317,7 +1307,6 @@ describe("CoderAgentChatAction", () => { ); const inputs = createMockInputs({ - githubUserID: 12345, wait: "complete", waitTimeoutSeconds: 600, commentOnIssue: false, @@ -2365,7 +1354,7 @@ describe("CoderAgentChatAction", () => { // `waiting` is terminal but ambiguous (agent done vs agent // waiting for input); pin the success path explicitly so a // regression that drops it from TERMINAL_STATUSES fails here. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue({ ...mockChat, status: "running", @@ -2376,7 +1365,6 @@ describe("CoderAgentChatAction", () => { }); const inputs = createMockInputs({ - githubUserID: 12345, wait: "complete", waitTimeoutSeconds: 600, commentOnIssue: false, @@ -2398,7 +1386,7 @@ describe("CoderAgentChatAction", () => { test("wait=complete fails with default message when chat error has no last_error", async () => { // Covers the `last_error || fallback` branch. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue({ ...mockChat, status: "running", @@ -2410,7 +1398,6 @@ describe("CoderAgentChatAction", () => { }); const inputs = createMockInputs({ - githubUserID: 12345, wait: "complete", waitTimeoutSeconds: 600, commentOnIssue: false, @@ -2442,7 +1429,7 @@ describe("CoderAgentChatAction", () => { // not return immediately on the first poll. // requireNonTerminalFirst forces the loop to observe the // agent transitioning before accepting any terminal. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); @@ -2457,7 +1444,6 @@ describe("CoderAgentChatAction", () => { const existingChatId = "990e8400-e29b-41d4-a716-446655440000"; const inputs = createMockInputs({ - githubUserID: 12345, existingChatId, wait: "complete", waitTimeoutSeconds: 600, @@ -2486,7 +1472,7 @@ describe("CoderAgentChatAction", () => { // New-chat branch leaves requireNonTerminalFirst false: // createChat returns a fresh chat, so a terminal on the // first poll is real. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue({ ...mockChat, status: "running", @@ -2497,7 +1483,6 @@ describe("CoderAgentChatAction", () => { }); const inputs = createMockInputs({ - githubUserID: 12345, wait: "complete", waitTimeoutSeconds: 600, commentOnIssue: false, @@ -2521,7 +1506,7 @@ describe("CoderAgentChatAction", () => { // First two getChat calls reject (transient outage); the third // returns a terminal status. The loop must stay alive across // the failures rather than failing fast on the first one. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue({ ...mockChat, status: "running", @@ -2532,7 +1517,6 @@ describe("CoderAgentChatAction", () => { .mockResolvedValueOnce({ ...mockChat, status: "completed" }); const inputs = createMockInputs({ - githubUserID: 12345, wait: "complete", waitTimeoutSeconds: 600, commentOnIssue: false, @@ -2559,7 +1543,7 @@ describe("CoderAgentChatAction", () => { // atCreation, so the rewrap path is skipped: err.chat stays // undefined but err.chatId, chatUrl, and coderUsername are // still decorated for the failure outputs. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); @@ -2569,7 +1553,6 @@ describe("CoderAgentChatAction", () => { const existingChatId = "990e8400-e29b-41d4-a716-446655440000"; const inputs = createMockInputs({ - githubUserID: 12345, existingChatId, wait: "complete", waitTimeoutSeconds: 600, @@ -2605,7 +1588,7 @@ describe("CoderAgentChatAction", () => { // already in. The loop hits the timeout without ever // observing a non-terminal observation; the failure message // distinguishes this from a normal "ran out of time" timeout. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); @@ -2616,7 +1599,6 @@ describe("CoderAgentChatAction", () => { const existingChatId = "990e8400-e29b-41d4-a716-446655440000"; const inputs = createMockInputs({ - githubUserID: 12345, existingChatId, wait: "complete", waitTimeoutSeconds: 10, @@ -2653,7 +1635,7 @@ describe("CoderAgentChatAction", () => { // only `sawNonTerminal` would treat both polls as stale; the // loop must accept the second terminal because it differs from // the first. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); @@ -2667,7 +1649,6 @@ describe("CoderAgentChatAction", () => { const existingChatId = "990e8400-e29b-41d4-a716-446655440000"; const inputs = createMockInputs({ - githubUserID: 12345, existingChatId, wait: "complete", waitTimeoutSeconds: 600, @@ -2695,7 +1676,7 @@ describe("CoderAgentChatAction", () => { // Chat is in `waiting`, follow-up sent, agent fails within one // poll interval. Second poll sees `error` (different terminal): // the loop must reach throwOnChatError, not time out. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); @@ -2709,7 +1690,6 @@ describe("CoderAgentChatAction", () => { const existingChatId = "990e8400-e29b-41d4-a716-446655440000"; const inputs = createMockInputs({ - githubUserID: 12345, existingChatId, wait: "complete", waitTimeoutSeconds: 600, @@ -2742,7 +1722,7 @@ describe("CoderAgentChatAction", () => { // MAX_CONSECUTIVE_POLL_FAILURES is reached. latest stays // undefined, so error.chat is undefined too, but error.chatId // must be populated from the options so chat-id output is set. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); @@ -2750,7 +1730,6 @@ describe("CoderAgentChatAction", () => { const existingChatId = "990e8400-e29b-41d4-a716-446655440000"; const inputs = createMockInputs({ - githubUserID: 12345, existingChatId, wait: "complete", waitTimeoutSeconds: 4, @@ -2786,7 +1765,7 @@ describe("CoderAgentChatAction", () => { // CoderAPIError with status 404; the mock must match so // classifyError sees user_not_found rather than the api_error // fallback. - coderClient.mockGetCoderUserByGithubID.mockRejectedValue( + coderClient.mockGetAuthenticatedUser.mockRejectedValue( new CoderAPIError( "No Coder user found with GitHub user ID 12345", 404, @@ -2801,7 +1780,7 @@ describe("CoderAgentChatAction", () => { {} as ReturnType, ); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -2821,7 +1800,7 @@ describe("CoderAgentChatAction", () => { }); test("throws error when chat creation fails", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( new Error("Failed to create chat"), ); @@ -2832,7 +1811,7 @@ describe("CoderAgentChatAction", () => { {} as ReturnType, ); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -2847,7 +1826,7 @@ describe("CoderAgentChatAction", () => { "posts a failure comment with chat-error-kind=spend_exceeded " + "and spent/limit amounts on 409 spend-exceeded shape", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( new CoderAPIError( "Coder API error: Conflict", @@ -2867,7 +1846,7 @@ describe("CoderAgentChatAction", () => { {} as ReturnType, ); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -2895,7 +1874,7 @@ describe("CoderAgentChatAction", () => { "posts a failure comment with chat-error-kind=user_not_found and " + "names the input that needs adjusting", async () => { - coderClient.mockGetCoderUserByGithubID.mockRejectedValue( + coderClient.mockGetAuthenticatedUser.mockRejectedValue( new CoderAPIError( "No Coder user found with GitHub user ID 12345", 404, @@ -2910,7 +1889,7 @@ describe("CoderAgentChatAction", () => { {} as ReturnType, ); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -2937,7 +1916,7 @@ describe("CoderAgentChatAction", () => { "posts a failure comment with chat-error-kind=user_ambiguous and " + "suggests acting-coder-username", async () => { - coderClient.mockGetCoderUserByGithubID.mockRejectedValue( + coderClient.mockGetAuthenticatedUser.mockRejectedValue( new CoderAPIError( "Multiple Coder users found with GitHub user ID 12345", 409, @@ -2952,7 +1931,7 @@ describe("CoderAgentChatAction", () => { {} as ReturnType, ); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -2975,7 +1954,7 @@ describe("CoderAgentChatAction", () => { ); test("falls back to chat-error-kind=api_error for unknown 4xx shapes", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( new CoderAPIError("Coder API error: Bad Request", 400, ""), ); @@ -2986,7 +1965,7 @@ describe("CoderAgentChatAction", () => { {} as ReturnType, ); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3007,13 +1986,12 @@ describe("CoderAgentChatAction", () => { }); test("posts no failure comment when commentOnIssue=false", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( new CoderAPIError("Coder API error: Bad Request", 400, ""), ); const inputs = createMockInputs({ - githubUserID: 12345, commentOnIssue: false, }); const action = new CoderAgentChatAction( @@ -3034,7 +2012,7 @@ describe("CoderAgentChatAction", () => { "updates existing failure comment in place when re-run with the " + "same marker key", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( new CoderAPIError("Coder API error: Bad Request", 400, ""), ); @@ -3053,7 +2031,7 @@ describe("CoderAgentChatAction", () => { {} as ReturnType, ); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3078,7 +2056,7 @@ describe("CoderAgentChatAction", () => { async () => { process.env.GITHUB_WORKFLOW = "doc-check"; try { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( new CoderAPIError("Coder API error: Bad Request", 400, ""), ); @@ -3089,7 +2067,7 @@ describe("CoderAgentChatAction", () => { {} as ReturnType, ); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3116,7 +2094,7 @@ describe("CoderAgentChatAction", () => { "posts a failure comment when github-url is a pull request URL " + "(end-to-end PR support)", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( new CoderAPIError("Coder API error: Bad Request", 400, ""), ); @@ -3128,7 +2106,6 @@ describe("CoderAgentChatAction", () => { ); const inputs = createMockInputs({ - githubUserID: 12345, githubURL: "https://github.com/test-org/test-repo/pull/77", }); const action = new CoderAgentChatAction( @@ -3159,7 +2136,7 @@ describe("CoderAgentChatAction", () => { "throws ActionFailureError carrying chat-error-* outputs on " + "the failure path", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( new CoderAPIError("Coder API error: Bad Request", 400, ""), ); @@ -3170,7 +2147,7 @@ describe("CoderAgentChatAction", () => { {} as ReturnType, ); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3200,14 +2177,14 @@ describe("CoderAgentChatAction", () => { "propagates the classified error when GitHub comment posting " + "itself fails", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( new CoderAPIError("Coder API error: Bad Request", 400, ""), ); // paginate (which findCommentByPredicate uses) rejects. octokit.paginate.mockRejectedValue(new Error("boom")); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3226,65 +2203,50 @@ describe("CoderAgentChatAction", () => { }, ); - // `handleFailure`'s defensive catch around `parseGithubURL` keeps a - // malformed github-url from masking the original API error. The - // schema only validates URL syntax, so a URL like - // `https://github.com/foo` passes the schema but the regex does not - // match. - test( - "degrades gracefully when github-url passes schema but fails " + - "the issue/PR regex", - async () => { - // Failing the user lookup means parseGithubURL never runs in - // runInner; only handleFailure's defensive call hits the bad URL. - coderClient.mockGetCoderUserByGithubID.mockRejectedValue( - new CoderAPIError( - "No Coder user found with GitHub user ID 12345", - 404, - undefined, - "user_not_found", - ), - ); + // `parseGithubURL` runs first in `runInner` so a malformed + // `github-url` fails fast with a URL-parser error instead of + // masking some later API error. The schema only validates URL + // syntax, so a URL like `https://github.com/foo` passes the schema + // but the regex does not match. + test("fails fast when github-url passes schema but fails the issue/PR regex", async () => { + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); - const inputs = createMockInputs({ - githubUserID: 12345, - // Passes schema (.url()) but does not match the issue/PR regex. - githubURL: "https://github.com/owner-only", - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - createMockContext(), - ); + const inputs = createMockInputs({ + // Passes schema (.url()) but does not match the issue/PR regex. + githubURL: "https://github.com/owner-only", + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + createMockContext(), + ); - let caught: unknown; - try { - await action.run(); - } catch (error) { - caught = error; - } - // The classified error survived the parseGithubURL throw inside - // handleFailure: chat-error-kind is user_not_found, not the - // parser's "Invalid GitHub URL" string. - expect(caught).toBeInstanceOf(ActionFailureError); - expect((caught as ActionFailureError).kind).toBe("user_not_found"); - expect((caught as ActionFailureError).message).toContain( - "No Coder user found", - ); - // No comment posted because the parser rejected the URL. - expect(octokit.rest.issues.createComment).not.toHaveBeenCalled(); - }, - ); + let caught: unknown; + try { + await action.run(); + } catch (error) { + caught = error; + } + // runInner parses github-url before reaching users/me; + // `getAuthenticatedUser` is never called. + expect(coderClient.mockGetAuthenticatedUser).not.toHaveBeenCalled(); + expect(caught).toBeInstanceOf(ActionFailureError); + expect((caught as ActionFailureError).kind).toBe("api_error"); + expect((caught as ActionFailureError).message).toContain( + "Invalid `github-url`", + ); + // No comment posted because the parser rejected the URL. + expect(octokit.rest.issues.createComment).not.toHaveBeenCalled(); + }); }); describe("Organization resolution", () => { test("resolves org by name to a UUID when coder-organization is set", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: 12345, coderOrganization: "coder", }); const action = new CoderAgentChatAction( @@ -3307,11 +2269,10 @@ describe("CoderAgentChatAction", () => { }); test("defaults to the resolved user's first org membership when coder-organization is unset", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: 12345, coderOrganization: undefined, }); const action = new CoderAgentChatAction( @@ -3332,12 +2293,10 @@ describe("CoderAgentChatAction", () => { }); test("defaults via getCoderUserByUsername when only acting-coder-username is set", async () => { - coderClient.mockGetCoderUserByUsername.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: mockUser.username, coderOrganization: undefined, }); const action = new CoderAgentChatAction( @@ -3349,9 +2308,7 @@ describe("CoderAgentChatAction", () => { await action.run(); - expect(coderClient.mockGetCoderUserByUsername).toHaveBeenCalledWith( - mockUser.username, - ); + expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalled(); expect(coderClient.mockGetOrganizationByName).not.toHaveBeenCalled(); expect(coderClient.mockCreateChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -3361,10 +2318,9 @@ describe("CoderAgentChatAction", () => { }); test("fails with chat-error-kind=org_not_found when the resolved user has no org memberships", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUserNoOrgs); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUserNoOrgs); const inputs = createMockInputs({ - githubUserID: 12345, coderOrganization: undefined, }); const action = new CoderAgentChatAction( @@ -3387,13 +2343,12 @@ describe("CoderAgentChatAction", () => { }); test("wraps getOrganizationByName 404 in ActionFailureError(org_not_found)", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockGetOrganizationByName.mockRejectedValue( new CoderAPIError("Coder API error: Not Found", 404), ); const inputs = createMockInputs({ - githubUserID: 12345, coderOrganization: "does-not-exist", }); const action = new CoderAgentChatAction( @@ -3419,13 +2374,12 @@ describe("CoderAgentChatAction", () => { }); test("non-404 CoderAPIError from getOrganizationByName is not classified as org_not_found", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockGetOrganizationByName.mockRejectedValue( new CoderAPIError("Coder API error: Unauthorized", 401), ); const inputs = createMockInputs({ - githubUserID: 12345, coderOrganization: "coder", }); const action = new CoderAgentChatAction( @@ -3450,7 +2404,7 @@ describe("CoderAgentChatAction", () => { }); test("existing-chat-id flow does not resolve the organization", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUserNoOrgs); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUserNoOrgs); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); @@ -3460,7 +2414,6 @@ describe("CoderAgentChatAction", () => { // not trigger that resolution because createChatMessage inherits // the chat's organization. const inputs = createMockInputs({ - githubUserID: 12345, coderOrganization: undefined, existingChatId: "990e8400-e29b-41d4-a716-446655440000", }); @@ -3474,20 +2427,21 @@ describe("CoderAgentChatAction", () => { const result = await action.run(); expect(coderClient.mockGetOrganizationByName).not.toHaveBeenCalled(); - expect(coderClient.mockGetCoderUserByUsername).not.toHaveBeenCalled(); + // users/me is still called once (the action emits the username + // output regardless of the existing-chat-id path), but the + // organization-resolution branch is correctly skipped. + expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalledTimes(1); expect(coderClient.mockCreateChatMessage).toHaveBeenCalled(); expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); expect(result.chatCreated).toBe(false); }); - test("wraps getCoderUserByUsername 404 in ActionFailureError(user_not_found)", async () => { - coderClient.mockGetCoderUserByUsername.mockRejectedValue( + test("surfaces users/me 404 as api_error", async () => { + coderClient.mockGetAuthenticatedUser.mockRejectedValue( new CoderAPIError("Coder API error: Not Found", 404), ); const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: "missing-user", coderOrganization: undefined, }); const action = new CoderAgentChatAction( @@ -3504,10 +2458,11 @@ describe("CoderAgentChatAction", () => { caught = e; } + // users/me failures classify as api_error: a 404 here means the + // `coder-token` does not authenticate, not that some named user + // is missing. The original CoderAPIError is preserved on cause. expect(caught).toBeInstanceOf(ActionFailureError); - expect((caught as ActionFailureError).kind).toBe("user_not_found"); - expect((caught as ActionFailureError).message).toContain("missing-user"); - // Cause chain preserves the original CoderAPIError for debugging. + expect((caught as ActionFailureError).kind).toBe("api_error"); expect((caught as ActionFailureError).cause).toBeInstanceOf( CoderAPIError, ); @@ -3515,13 +2470,11 @@ describe("CoderAgentChatAction", () => { }); test("non-404 CoderAPIError from getCoderUserByUsername is not classified as user_not_found", async () => { - coderClient.mockGetCoderUserByUsername.mockRejectedValue( + coderClient.mockGetAuthenticatedUser.mockRejectedValue( new CoderAPIError("Coder API error: Unauthorized", 401), ); const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: "some-user", coderOrganization: undefined, }); const action = new CoderAgentChatAction( @@ -3553,13 +2506,12 @@ describe("CoderAgentChatAction", () => { "770e8400-e29b-41d4-a716-446655440000", ], }; - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(multiOrgUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(multiOrgUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); const warningSpy = spyOn(core, "warning").mockImplementation(() => {}); try { const inputs = createMockInputs({ - githubUserID: 12345, coderOrganization: undefined, }); const action = new CoderAgentChatAction( @@ -3587,13 +2539,12 @@ describe("CoderAgentChatAction", () => { }); test("single-org user does not emit a warning", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); const warningSpy = spyOn(core, "warning").mockImplementation(() => {}); try { const inputs = createMockInputs({ - githubUserID: 12345, coderOrganization: undefined, }); const action = new CoderAgentChatAction( @@ -3612,7 +2563,7 @@ describe("CoderAgentChatAction", () => { }); test("ActionFailureError preserves the original error as `cause` when wrapping a 404", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); const originalError = new CoderAPIError( "Coder API error: Not Found", 404, @@ -3620,7 +2571,6 @@ describe("CoderAgentChatAction", () => { coderClient.mockGetOrganizationByName.mockRejectedValue(originalError); const inputs = createMockInputs({ - githubUserID: 12345, coderOrganization: "does-not-exist", }); const action = new CoderAgentChatAction( @@ -3642,12 +2592,12 @@ describe("CoderAgentChatAction", () => { }); describe("Chat reuse", () => { - test("default: listChats is called with the gh-target and per-user scope before creating", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + test("default: listChats is called with the gh-target scope before creating", async () => { + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([]); coderClient.mockCreateChat.mockResolvedValue(mockChat); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3661,10 +2611,12 @@ describe("CoderAgentChatAction", () => { const arg = coderClient.mockListChats.mock.calls[0]?.[0] as | { label?: string[]; archived?: boolean } | undefined; + // The per-user label is intentionally absent: all chats this + // action creates are owned by the `coder-token` holder, so + // scoping by the resolved acting user added no isolation. expect(arg?.label).toEqual([ "coder-agents-chat-action:true", "gh-target:test-org/test-repo#123", - `coder-agents-chat-action-user:${mockUser.id}`, ]); expect(arg?.archived).toBe(false); expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); @@ -3673,11 +2625,11 @@ describe("CoderAgentChatAction", () => { test("default: GITHUB_WORKFLOW is included in the lookup and on the created chat", async () => { process.env.GITHUB_WORKFLOW = "doc-check"; try { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([]); coderClient.mockCreateChat.mockResolvedValue(mockChat); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3704,12 +2656,12 @@ describe("CoderAgentChatAction", () => { } }); - test("default: writes the three core labels on the new chat", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + test("default: writes the two core labels on the new chat", async () => { + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([]); coderClient.mockCreateChat.mockResolvedValue(mockChat); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3724,17 +2676,18 @@ describe("CoderAgentChatAction", () => { | undefined; expect(req?.labels?.["coder-agents-chat-action"]).toBe("true"); expect(req?.labels?.["gh-target"]).toBe("test-org/test-repo#123"); - expect(req?.labels?.["coder-agents-chat-action-user"]).toBe(mockUser.id); + // No per-user label: the chat owner is the token holder, not + // the resolved acting user. + expect(req?.labels?.["coder-agents-chat-action-user"]).toBeUndefined(); // Workflow env unset; no workflow label and no sharding key. expect(Object.keys(req?.labels ?? {}).sort()).toEqual([ "coder-agents-chat-action", - "coder-agents-chat-action-user", "gh-target", ]); }); test("default + match: sends a follow-up via createChatMessage and does not create", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([ { ...mockChat, archived: false }, ]); @@ -3751,7 +2704,6 @@ describe("CoderAgentChatAction", () => { const modelConfigId = "d3a2b1c4-5678-49ab-bcde-1234567890ab"; const inputs = createMockInputs({ - githubUserID: 12345, chatPrompt: "continue the work", modelConfigId, }); @@ -3795,7 +2747,7 @@ describe("CoderAgentChatAction", () => { // does. A reuse-path follow-up to a chat already in a terminal // status would otherwise return on the pre-message snapshot // before the agent transitions. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([ { ...mockChat, archived: false, status: "waiting" }, ]); @@ -3811,7 +2763,6 @@ describe("CoderAgentChatAction", () => { .mockResolvedValueOnce({ ...mockChat, status: "completed" }); const inputs = createMockInputs({ - githubUserID: 12345, wait: "complete", waitTimeoutSeconds: 600, commentOnIssue: false, @@ -3842,14 +2793,14 @@ describe("CoderAgentChatAction", () => { archived: false, status: "waiting" as const, }; - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([snapshot]); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); coderClient.mockGetChat.mockRejectedValue(new Error("network")); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3878,7 +2829,7 @@ describe("CoderAgentChatAction", () => { updated_at: "2026-02-01T00:00:00.000000Z", archived: false, }; - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([older, newer]); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, @@ -3886,7 +2837,7 @@ describe("CoderAgentChatAction", () => { coderClient.mockGetChat.mockResolvedValue(newer); const warnSpy = spyOn(core, "warning"); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3905,13 +2856,13 @@ describe("CoderAgentChatAction", () => { }); test("default + only archived match: creates a new chat (does not unarchive)", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([ { ...mockChat, archived: true }, ]); coderClient.mockCreateChat.mockResolvedValue(mockChat); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3926,14 +2877,13 @@ describe("CoderAgentChatAction", () => { }); test("existing-chat-id wins: lookup is skipped", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChatMessage.mockResolvedValue( mockChatMessageResponse, ); coderClient.mockGetChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: 12345, existingChatId: mockChat.id, }); const action = new CoderAgentChatAction( @@ -3951,11 +2901,10 @@ describe("CoderAgentChatAction", () => { }); test("force-new-chat: skips lookup and creates a new chat with the action labels", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: 12345, forceNewChat: true, }); const action = new CoderAgentChatAction( @@ -3978,14 +2927,13 @@ describe("CoderAgentChatAction", () => { | undefined; expect(req?.labels?.["coder-agents-chat-action"]).toBe("true"); expect(req?.labels?.["gh-target"]).toBe("test-org/test-repo#123"); - expect(req?.labels?.["coder-agents-chat-action-user"]).toBe(mockUser.id); }); test("listChats throws: error propagates with operation context", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockRejectedValue(new Error("boom")); - const inputs = createMockInputs({ githubUserID: 12345 }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -3998,55 +2946,18 @@ describe("CoderAgentChatAction", () => { ); }); - test("distinct users on the same target each get their own chat (no cross-user hijack)", async () => { - // User B's lookup must carry their own user label so the chats API - // cannot AND-match a chat created with mockUser.id, and the new - // chat must be stamped with User B's UUID. - const userB: CoderSDKUser = { - ...mockUser, - id: "770e8400-e29b-41d4-a716-446655440777", - username: "userB", - }; - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(userB); - coderClient.mockListChats.mockResolvedValue([]); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ githubUserID: 67890 }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - createMockContext(), - ); - - await action.run(); - - const listArg = coderClient.mockListChats.mock.calls[0]?.[0] as - | { label?: string[] } - | undefined; - expect(listArg?.label).toContain( - `coder-agents-chat-action-user:${userB.id}`, - ); - expect(listArg?.label).not.toContain( - `coder-agents-chat-action-user:${mockUser.id}`, - ); - const createReq = coderClient.mockCreateChat.mock.calls[0]?.[0] as - | { labels?: Record } - | undefined; - expect(createReq?.labels?.["coder-agents-chat-action-user"]).toBe( - userB.id, - ); - }); - - test("acting-coder-username path: per-user scope is applied via getCoderUserByUsername", async () => { - coderClient.mockGetCoderUserByUsername.mockResolvedValue(mockUser); + test("the reuse scope is intentionally not partitioned by acting user", async () => { + // Per the security-driven simplification, all chats this action + // creates are owned by the `coder-token` holder. The reuse scope + // does not include a per-actor label; workflows that want + // per-actor separation set `idempotency-key: ${{ github.actor }}` + // themselves. This test pins the absence of the per-user label so + // a regression that re-introduces it is caught. + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([]); coderClient.mockCreateChat.mockResolvedValue(mockChat); - const inputs = createMockInputs({ - githubUserID: undefined, - coderUsername: mockUser.username, - }); + const inputs = createMockInputs({}); const action = new CoderAgentChatAction( coderClient, octokit as unknown as Octokit, @@ -4056,31 +2967,27 @@ describe("CoderAgentChatAction", () => { await action.run(); - expect(coderClient.mockGetCoderUserByUsername).toHaveBeenCalledWith( - mockUser.username, - ); const listArg = coderClient.mockListChats.mock.calls[0]?.[0] as | { label?: string[] } | undefined; - expect(listArg?.label).toContain( - `coder-agents-chat-action-user:${mockUser.id}`, - ); + for (const label of listArg?.label ?? []) { + expect(label).not.toMatch(/^coder-agents-chat-action-user:/); + } const createReq = coderClient.mockCreateChat.mock.calls[0]?.[0] as | { labels?: Record } | undefined; - expect(createReq?.labels?.["coder-agents-chat-action-user"]).toBe( - mockUser.id, - ); + expect( + createReq?.labels?.["coder-agents-chat-action-user"], + ).toBeUndefined(); }); describe("idempotency-key sharding", () => { - test("adds the sanitized key to lookup and to the new chat's labels", async () => { - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + test("adds the sanitized key as the value of the fixed idempotency label", async () => { + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([]); coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: 12345, idempotencyKey: "My Custom Key!", }); const action = new CoderAgentChatAction( @@ -4095,31 +3002,30 @@ describe("CoderAgentChatAction", () => { const listArg = coderClient.mockListChats.mock.calls[0]?.[0] as | { label?: string[] } | undefined; - // The sanitized key is added as a `:true` filter. - expect( - listArg?.label?.some((l) => /^my-custom-key.*:true$/.test(l)), - ).toBe(true); + // The sanitized key is the value of a fixed key. User input + // cannot collide with an action-owned key under this scheme. + expect(listArg?.label).toContain( + "coder-agents-chat-action-idempotency:my-custom-key-", + ); const createReq = coderClient.mockCreateChat.mock.calls[0]?.[0] as | { labels?: Record } | undefined; - const extraKeys = Object.keys(createReq?.labels ?? {}).filter( - (k) => - k !== "coder-agents-chat-action" && - k !== "gh-target" && - k !== "coder-agents-chat-action-user", - ); - expect(extraKeys).toHaveLength(1); - expect(extraKeys[0]).toMatch(/^my-custom-key/); - expect(createReq?.labels?.[extraKeys[0]]).toBe("true"); + expect( + createReq?.labels?.["coder-agents-chat-action-idempotency"], + ).toBe("my-custom-key-"); }); - test("sharding key that sanitizes to a reserved label key is rejected fast", async () => { - // `idempotency-key: "gh-target"` would silently overwrite an - // action-owned scope label. Reject before any API call. - coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); + test("reserved-key collision is no longer possible with the fixed-key scheme", async () => { + // Pre-rewrite, a sanitized `idempotency-key` value was used as a + // label KEY and could collide with action-owned keys. The fixed + // key (`coder-agents-chat-action-idempotency`) makes the value + // always a value, so even an idempotency-key of `gh-target` now + // just sets `coder-agents-chat-action-idempotency: gh-target`. + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); + coderClient.mockListChats.mockResolvedValue([]); + coderClient.mockCreateChat.mockResolvedValue(mockChat); const inputs = createMockInputs({ - githubUserID: 12345, idempotencyKey: "gh-target", }); const action = new CoderAgentChatAction( @@ -4129,9 +3035,15 @@ describe("CoderAgentChatAction", () => { createMockContext(), ); - await expect(action.run()).rejects.toThrow(/reserved chat-label key/); - expect(coderClient.mockListChats).not.toHaveBeenCalled(); - expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); + await action.run(); + + const createReq = coderClient.mockCreateChat.mock.calls[0]?.[0] as + | { labels?: Record } + | undefined; + expect(createReq?.labels?.["gh-target"]).toBe("test-org/test-repo#123"); + expect( + createReq?.labels?.["coder-agents-chat-action-idempotency"], + ).toBe("gh-target"); }); }); }); diff --git a/src/action.ts b/src/action.ts index 7391067..93813fb 100644 --- a/src/action.ts +++ b/src/action.ts @@ -9,11 +9,7 @@ import type { CoderSDKUser, CreateChatRequest, } from "./coder-client"; -import { - ACTION_LABEL_KEYS, - RESERVED_LABEL_KEYS, - sanitizeLabelKey, -} from "./sanitize-label-key"; +import { ACTION_LABEL_KEYS, sanitizeLabelKey } from "./sanitize-label-key"; import { buildCommentMarker, buildDeploymentAgentsUrl, @@ -22,8 +18,8 @@ import { classifyError, deriveCommentKey, type FailureDetail, - GITHUB_URL_REGEX, normalizeBaseUrl, + parseGithubItemURL, upsertCommentByMarker, } from "./comment"; import type { ActionInputs, ActionOutputs, ChatErrorKind } from "./schemas"; @@ -88,31 +84,13 @@ export class ActionFailureError extends Error { // undefined (e.g. transport failure on the first getChat). readonly chatId?: ChatId; - // acting-coder-username output. Decorated by run() once the user resolves. + // coder-username output. Decorated by run() once the user resolves. coderUsername?: string; // chat-url output. Decorated by run() once the chat URL is built. chatUrl?: string; } -/** - * Stringify an unknown thrown value for a wrapping error message. Library - * code may throw `Error`s, bare strings, or arbitrary values. - */ -function describeError(err: unknown): string { - if (err instanceof Error) { - return err.message; - } - if (typeof err === "string") { - return err; - } - try { - return JSON.stringify(err); - } catch { - return String(err); - } -} - /** * GitHub `author_association` values that map to repository write access in * the action's auto-resolve trust model. `OWNER` and `MEMBER` cover org @@ -200,20 +178,6 @@ type TrustClassification = | { kind: "untrusted"; reason: string } | { kind: "no-signal" }; -/** - * Identity-resolution source labels the divergence check reads to decide - * whether to warn. `acting-coder-username` and `acting-github-user-id` are explicit - * workflow inputs; `sender` and `actor` are auto-resolved from - * `github.context`; `token` is the `users/me` fallback (same user as the - * token holder, so divergence is impossible by construction). - */ -type IdentitySource = - | "acting-coder-username" - | "acting-github-user-id" - | "sender" - | "actor" - | "token"; - /** * Classify whether the triggering identity from `context` is trusted for * auto-resolve. @@ -321,14 +285,21 @@ export class CoderAgentChatAction { throw new Error("Missing GitHub URL"); } - const match = this.inputs.githubURL.match(GITHUB_URL_REGEX); - if (!match) { - throw new Error(`Invalid GitHub URL: ${this.inputs.githubURL}`); + const parsed = parseGithubItemURL(this.inputs.githubURL); + if (!parsed) { + throw new Error( + `Invalid \`github-url\` input "${this.inputs.githubURL}". ` + + "Expected `https://github.com///issues/` or " + + "`https://github.com///pull/`. The action " + + "rejects non-github.com hosts so a workflow that templates " + + "user-controlled content into this input cannot redirect the " + + "action to an attacker-chosen repository.", + ); } return { - githubOrg: match[1], - githubRepo: match[2], - githubIssueNumber: parseInt(match[3], 10), + githubOrg: parsed.owner, + githubRepo: parsed.repo, + githubIssueNumber: parsed.number, }; } @@ -596,283 +567,43 @@ export class CoderAgentChatAction { } /** - * Resolve the Coder username the action runs as for org-pick and the - * per-user reuse label. Resolution order, high to low: - * - * 1. `acting-coder-username` input. - * 2. `acting-github-user-id` input. - * 3. `context.payload.sender.id` (issue, pull request, comment, and most - * webhook-driven events that carry the triggering user under `sender`). - * 4. `context.actor` for events whose payload lacks a usable `sender.id` - * (partial sender objects, bot dispatches, custom dispatch chains). - * Resolved to a numeric id via `octokit.rest.users.getByUsername`, - * then to a Coder user. - * 5. `GET /api/v2/users/me` against the configured `coder-token`. The - * chat owner on `POST /api/experimental/chats` is always the token - * holder; for events with no usable github.context signal (schedule, - * a workflow_dispatch with no sender or actor), the token owner is - * the only identity we can attribute the run to. - * - * Sources 3 and 4 are gated by `classifyAutoResolveTrust`. Fork pull - * requests and triggering identities whose `comment.author_association` - * or `review.author_association` lacks repository write access cause the - * gate to refuse: the action throws and does NOT fall through to - * `users/me`, because a hostile-trigger event should not silently - * collapse onto the token owner. The gate protects the acting user used - * for org-pick and the per-user reuse label (`coder-agents-chat-action-user`), - * not the chat owner (which is fixed by the token). + * Refuse fork pull requests and untrusted comments/reviews before any + * Coder API call. The chat owner is the `coder-token` holder regardless + * of who triggered the workflow; this gate's load-bearing job is the + * fail-closed refusal to call `createChat` on a hostile trigger. There + * is no input bypass: workflow authors targeting fork PRs or low-trust + * comment channels must add their own `if:` filter (see README\'s + * `pull_request_target` recipe and the security model section). * - * `schedule` events skip sources 3 and 4 directly: their `actor` is the - * workflow file's last editor and their payload carries no triggering - * identity. They proceed to `users/me`. - * - * Returns `{ username, user, source }`. `source` lets the caller decide - * whether to run the token-owner vs acting-user divergence check. - * `resolveOrganizationID` reuses `user` to read `organization_ids` - * without a redundant lookup. + * On `trusted` and `no-signal` verdicts the action proceeds; both are + * logged so an operator debugging identity resolution can tell which + * branch fired. */ - async resolveCoderUsername(): Promise<{ - username: string; - user: CoderSDKUser; - source: IdentitySource; - }> { - if (this.inputs.coderUsername) { - core.info( - `Using provided Coder username for acting user: ${this.inputs.coderUsername}`, - ); - // Fetch the full user so `user.id` is available downstream for - // the `coder-agents-chat-action-user` per-user reuse scope. - let coderUser: CoderSDKUser; - try { - coderUser = await this.coder.getCoderUserByUsername( - this.inputs.coderUsername, - ); - } catch (err) { - // Symmetric with the named-org 404 wrap in `resolveOrganizationID`. - if (err instanceof CoderAPIError && err.statusCode === 404) { - throw new ActionFailureError( - "user_not_found", - `Coder user '${this.inputs.coderUsername}' not found. ` + - "Check the `acting-coder-username` input value.", - undefined, - { cause: err }, - ); - } - throw err; - } - return { - username: coderUser.username, - user: coderUser, - source: "acting-coder-username", - }; - } - if (this.inputs.githubUserID !== undefined) { - core.info( - `Looking up Coder user by GitHub user ID: ${this.inputs.githubUserID}`, - ); - const coderUser = await this.coder.getCoderUserByGitHubId( - this.inputs.githubUserID, - ); - return { - username: coderUser.username, - user: coderUser, - source: "acting-github-user-id", - }; - } - - // `schedule` skips the sender/actor branches: the actor on a cron run - // is the workflow file's last editor, and the payload carries no - // triggering identity. The trust gate would return `no-signal` and - // the action proceeds to `users/me` below. - const isSchedule = this.context.eventName === "schedule"; - - if (!isSchedule) { - // Trust gate: before auto-resolving from `sender.id` or `actor`, - // refuse if the triggering identity comes from a fork PR or carries - // a low-trust `author_association`. This protects the acting user - // used for org-pick and the per-user reuse label - // (`coder-agents-chat-action-user`) from pollution by untrusted - // triggers. The chat owner is the `coder-token` holder regardless - // of the gate's verdict. Explicit `acting-coder-username` and - // `acting-github-user-id` inputs are handled above and bypass this gate by - // design; on refusal the action does NOT fall through to `users/me` - // because a hostile-trigger event should not silently collapse onto - // the token owner. - const trust = classifyAutoResolveTrust(this.context); - if (trust.kind === "untrusted") { - throw new Error( - "Refusing to auto-resolve a GitHub identity: " + - `${trust.reason}. ` + - "Set the `acting-coder-username` input to a Coder username, or set " + - "`acting-github-user-id` to the GitHub numeric user id of the user " + - "to use as the acting user (for org pick and the per-user reuse label).", - ); - } - if (trust.kind === "trusted") { - core.info(`Auto-resolve trust check passed: ${trust.reason}`); - } else { - // no-signal: events like `issues`, `push`, same-repo - // `pull_request`, and `workflow_dispatch` carry no sender- - // association data the gate can act on. Log so an operator - // debugging identity resolution can tell the gate ran and - // deferred, rather than being skipped. - core.info( - "Auto-resolve trust gate found no signal in the event payload; " + - "deferring to GitHub's event-permission model.", - ); - } - - // Prefer `sender.id` over `actor`: it's already numeric, no extra - // API call. The guard mirrors `z.number().int().positive()` on the - // `acting-github-user-id` input. - const senderId = this.context.payload?.sender?.id; - if ( - typeof senderId === "number" && - Number.isInteger(senderId) && - senderId > 0 - ) { - core.info( - `Auto-resolving Coder user from github.context.payload.sender.id: ${senderId}`, - ); - try { - const coderUser = await this.coder.getCoderUserByGitHubId(senderId); - return { - username: coderUser.username, - user: coderUser, - source: "sender", - }; - } catch (err) { - throw new Error( - `Failed to resolve Coder user from github.context.payload.sender.id (${senderId}): ${describeError(err)}. ` + - "Set the `acting-coder-username` input to bypass auto-resolution.", - ); - } - } - - // Actor fallback for events whose payload lacks a usable `sender.id`. - // `workflow_dispatch` payloads do include `sender.id`, so source 3 - // handles it; this branch covers partial sender objects, bot - // dispatches, and custom dispatch chains. - const actor = this.context.actor; - if (actor) { - core.info( - `Auto-resolving Coder user from github.context.actor: ${actor}`, - ); - let actorId: number; - try { - const { data } = await this.octokit.rest.users.getByUsername({ - username: actor, - }); - actorId = data.id; - } catch (err) { - throw new Error( - `Failed to resolve GitHub user id for github.context.actor (${actor}): ${describeError(err)}. ` + - "Set the `acting-coder-username` input to bypass auto-resolution.", - ); - } - try { - const coderUser = await this.coder.getCoderUserByGitHubId(actorId); - return { - username: coderUser.username, - user: coderUser, - source: "actor", - }; - } catch (err) { - throw new Error( - `Failed to resolve Coder user for github.context.actor (${actor}, GitHub user id ${actorId}): ${describeError(err)}. ` + - "Set the `acting-coder-username` input to bypass auto-resolution.", - ); - } - } - } - - // Final fallback: derive the acting user from the `coder-token` via - // `GET /api/v2/users/me`. The chat already runs as this user; using - // the same identity for org-pick and the per-user reuse label keeps - // runs without explicit inputs (and `schedule` runs) attributable. - core.info( - "No GitHub identity input or workflow-context signal was usable; " + - "falling back to the `coder-token` owner via GET /api/v2/users/me.", - ); - let tokenOwner: CoderSDKUser; - try { - tokenOwner = await this.getTokenOwner(); - } catch (err) { + assertTrustedTrigger(): void { + const trust = classifyAutoResolveTrust(this.context); + if (trust.kind === "untrusted") { throw new Error( - `Failed to resolve the \`coder-token\` owner via GET /api/v2/users/me: ${describeError(err)}. ` + - "Set the `acting-coder-username` input to a Coder username, or set " + - "`acting-github-user-id` to the GitHub numeric user id of the user to " + - "use as the acting user (for org pick and the per-user reuse label).", + "Refusing to act on an untrusted trigger: " + + `${trust.reason}. ` + + "Add an `if:` gate to the workflow step (for example, " + + "`author_association` allowlist or a label allowlist on " + + "`pull_request_target`) before invoking this action. See " + + "the README security model for details.", ); } - return { - username: tokenOwner.username, - user: tokenOwner, - source: "token", - }; - } - - /** - * Lazily fetch and memoize the `coder-token` owner. Used both as the - * lowest-priority identity-resolution fallback and as the source of - * truth for the token-owner vs acting-user divergence warning. - */ - private tokenOwnerCache: CoderSDKUser | undefined; - private async getTokenOwner(): Promise { - if (this.tokenOwnerCache) { - return this.tokenOwnerCache; - } - const user = await this.coder.getAuthenticatedUser(); - this.tokenOwnerCache = user; - return user; - } - - /** - * When an explicit identity input was provided, compare the resolved - * acting user to the `coder-token` owner and warn on divergence. The - * chat is owned by the token holder regardless of the resolved acting - * user; if they differ, the trust gate, the per-user reuse label, and - * the org pick are all protecting an identity that is not the chat - * owner. The workflow author should know. - * - * Suppressed for sources `sender`, `actor`, and `token` itself: those - * paths either derive the user from event context (the divergence is - * informational, not a workflow-author error) or already match the - * token by definition. - */ - private async warnOnTokenOwnerDivergence(resolved: { - username: string; - user: CoderSDKUser; - source: IdentitySource; - }): Promise { - if ( - resolved.source !== "acting-coder-username" && - resolved.source !== "acting-github-user-id" - ) { - return; - } - let tokenOwner: CoderSDKUser; - try { - tokenOwner = await this.getTokenOwner(); - } catch (err) { - // The divergence check is best-effort. A `users/me` failure here - // would also break createChat (same token), so let the action - // keep going and surface that failure at the createChat call site. - core.warning( - `Could not fetch the \`coder-token\` owner for the token-owner divergence check: ${describeError(err)}. ` + - "Continuing; the chat will still be owned by whoever the token belongs to.", + if (trust.kind === "trusted") { + core.info(`Trust gate passed: ${trust.reason}`); + } else { + // no-signal: events like `issues`, `push`, same-repo + // `pull_request`, and `workflow_dispatch` carry no + // sender-association data the gate can act on. Log so an + // operator debugging identity resolution can tell the gate ran + // and deferred, rather than being skipped. + core.info( + "Trust gate found no signal in the event payload; deferring " + + "to GitHub's event-permission model.", ); - return; } - if (tokenOwner.id === resolved.user.id) { - return; - } - core.warning( - `The resolved acting user '${resolved.username}' differs from the \`coder-token\` owner '${tokenOwner.username}'. ` + - "The chat is owned by the token holder; the acting user only " + - "selects the organization and the per-user reuse label. Confirm " + - "the token belongs to the user you intended.", - ); } /** @@ -882,25 +613,18 @@ export class CoderAgentChatAction { * `GET /api/v2/organizations/{name}`. Recommended when the user * belongs to more than one organization, since the fallback choice * is non-deterministic; a `core.warning` is emitted in that case. - * 2. The resolved Coder user's `organization_ids[0]`. `resolveCoderUsername` - * always returns a resolved user object (across every identity - * source); this helper reuses it. The lookup-by-username branch - * below is defensive: it only fires when a future caller passes - * `resolvedUser === undefined`, which the current code path does - * not do. + * 2. The resolved Coder user's `organization_ids[0]`. The action calls + * `users/me` once in `runInner` and threads the result here, so this + * helper never refetches. * * Throws `ActionFailureError("org_not_found")` when `coder-organization` - * names an org that does not exist (HTTP 404) or the resolved user has no - * org memberships. Throws `ActionFailureError("user_not_found")` when only - * `acting-coder-username` is set and the user is missing (HTTP 404). Other API - * errors propagate as `CoderAPIError`. The original error is attached via - * `options.cause` on every wrap; `run()`'s `handleFailure` re-classifies - * the failure into the failure-path comment. + * names an org that does not exist (HTTP 404) or the resolved user has + * no org memberships. Other API errors propagate as `CoderAPIError`. The + * original error is attached via `options.cause` on every wrap; + * `run()`\'s `handleFailure` re-classifies the failure into the + * failure-path comment. */ - async resolveOrganizationID( - coderUsername: string, - resolvedUser: CoderSDKUser | undefined, - ): Promise { + async resolveOrganizationID(user: CoderSDKUser): Promise { if (this.inputs.coderOrganization) { core.info( `Resolving Coder organization by name: ${this.inputs.coderOrganization}`, @@ -926,28 +650,6 @@ export class CoderAgentChatAction { } } - // Default to the user's first org membership. Fetch the user lazily - // when only `acting-coder-username` was provided; wrap a 404 into - // `user_not_found` symmetrically with the named-org 404 above. - let user: CoderSDKUser; - if (resolvedUser) { - user = resolvedUser; - } else { - try { - user = await this.coder.getCoderUserByUsername(coderUsername); - } catch (err) { - if (err instanceof CoderAPIError && err.statusCode === 404) { - throw new ActionFailureError( - "user_not_found", - `Coder user '${coderUsername}' not found. ` + - "Check the `acting-coder-username` input value.", - undefined, - { cause: err }, - ); - } - throw err; - } - } const orgID = user.organization_ids[0]; if (!orgID) { throw new ActionFailureError( @@ -959,8 +661,8 @@ export class CoderAgentChatAction { } if (user.organization_ids.length > 1) { // `organization_ids` is server-built via `array_agg` with no - // `ORDER BY`, so the choice is non-deterministic across vacuums and - // restarts. Recommend pinning via `coder-organization`. + // `ORDER BY`, so the choice is non-deterministic across vacuums + // and restarts. Recommend pinning via `coder-organization`. core.warning( `Coder user '${user.username}' has ${user.organization_ids.length} organization memberships; ` + `defaulting to ${orgID}. ` + @@ -1058,24 +760,26 @@ export class CoderAgentChatAction { private async runInner(): Promise { this.warnUnwiredInputs(); - const { - username: coderUsername, - user: resolvedUser, - source: identitySource, - } = await this.resolveCoderUsername(); - core.info( - `Resolved acting Coder user: '${coderUsername}' (source: ${identitySource})`, - ); - await this.warnOnTokenOwnerDivergence({ - username: coderUsername, - user: resolvedUser, - source: identitySource, - }); - + // Validate github-url and run the trust gate before any Coder API + // call. parseGithubURL rejects non-github.com hosts (F6); the trust + // gate fails closed on fork PRs and untrusted comment/review + // senders. Both are pre-`createChat` checkpoints. const { githubOrg, githubRepo, githubIssueNumber } = this.parseGithubURL(); core.info(`GitHub owner: ${githubOrg}`); core.info(`GitHub repo: ${githubRepo}`); core.info(`GitHub item number: ${githubIssueNumber}`); + this.assertTrustedTrigger(); + + // The chat owner on POST /api/experimental/chats is always the + // `coder-token` holder; the API has no owner override. The action + // fetches users/me once for the org pick and the `coder-username` + // output. The resulting username also tells the workflow author + // from the run log which Coder identity the chat ran as. + const tokenOwner = await this.coder.getAuthenticatedUser(); + const coderUsername = tokenOwner.username; + core.info( + `Resolved Coder user from \`coder-token\` (users/me): ${coderUsername}`, + ); // If an existing chat ID is provided, send a message to it if (this.inputs.existingChatId) { @@ -1099,22 +803,16 @@ export class CoderAgentChatAction { } // Chat reuse: the action reuses the most recent non-archived chat - // scoped to this `gh-target`, the resolved Coder user, and the - // workflow name (when `GITHUB_WORKFLOW` is set), so re-runs and - // follow-up triggers converge on one chat per target/user/workflow. + // scoped to this `gh-target` and the workflow name (when + // `GITHUB_WORKFLOW` is set). All chats are owned by the token + // holder so the per-user reuse label is not part of the scope. // `force-new-chat` skips the lookup; `idempotency-key` shards // further so two workflow runs with the same scope can maintain - // distinct chats. + // distinct chats. Workflows that want per-actor separation can + // set `idempotency-key: ${{ github.actor }}` themselves. const sanitizedKey = this.inputs.idempotencyKey ? sanitizeLabelKey(this.inputs.idempotencyKey) : undefined; - if (sanitizedKey && RESERVED_LABEL_KEYS.has(sanitizedKey)) { - throw new Error( - `idempotency-key sanitizes to a reserved chat-label key ("${sanitizedKey}"). ` + - `Reserved keys: ${[...RESERVED_LABEL_KEYS].join(", ")}. ` + - "Choose a different idempotency-key value.", - ); - } const ghTarget = `${githubOrg}/${githubRepo}#${githubIssueNumber}`; const workflow = process.env.GITHUB_WORKFLOW || undefined; @@ -1123,7 +821,6 @@ export class CoderAgentChatAction { } else { const follow = await this.findReuseMatch( ghTarget, - resolvedUser.id, workflow, sanitizedKey, ); @@ -1145,21 +842,13 @@ export class CoderAgentChatAction { // and resolving eagerly would fire an extra API call and a spurious // `org_not_found` failure for users with no org memberships. core.info("Creating new agents chat..."); - const organizationID = await this.resolveOrganizationID( - coderUsername, - resolvedUser, - ); + const organizationID = await this.resolveOrganizationID(tokenOwner); const req: CreateChatRequest = { organization_id: organizationID, content: [{ type: "text", text: this.inputs.chatPrompt }], workspace_id: this.inputs.workspaceId, model_config_id: this.inputs.modelConfigId, - labels: this.buildChatLabels( - ghTarget, - resolvedUser.id, - workflow, - sanitizedKey, - ), + labels: this.buildChatLabels(ghTarget, workflow, sanitizedKey), }; const createdChat = await this.coder.createChat(req); @@ -1295,8 +984,13 @@ export class CoderAgentChatAction { /** * Most-recent non-archived chat matching the reuse scope, or undefined. - * Scope: gh-target + coder-user; workflow when GITHUB_WORKFLOW is set; - * sanitized idempotency-key when set. Warns on multiple matches. + * Scope: gh-target; workflow when GITHUB_WORKFLOW is set; sanitized + * idempotency-key when set. Warns on multiple matches. + * + * All chats this action creates are owned by the `coder-token` holder + * (the chats API has no owner override), so the reuse scope does not + * include a per-actor label. Workflows that want per-actor separation + * pass `idempotency-key: ${{ github.actor }}` themselves. * * The label set must stay in sync with `buildChatLabels`: a key the * lookup queries but the create branch doesn't write (or vice versa) @@ -1305,20 +999,18 @@ export class CoderAgentChatAction { */ private async findReuseMatch( ghTarget: string, - coderUserId: string, workflow: string | undefined, sanitizedKey: string | undefined, ): Promise { const labels: string[] = [ `${ACTION_LABEL_KEYS.marker}:true`, `${ACTION_LABEL_KEYS.target}:${ghTarget}`, - `${ACTION_LABEL_KEYS.user}:${coderUserId}`, ]; if (workflow) { labels.push(`${ACTION_LABEL_KEYS.workflow}:${workflow}`); } if (sanitizedKey) { - labels.push(`${sanitizedKey}:true`); + labels.push(`${ACTION_LABEL_KEYS.idempotency}:${sanitizedKey}`); } let chats: CoderChat[]; try { @@ -1362,9 +1054,11 @@ export class CoderAgentChatAction { } /** - * Labels written on chat creation. Three are always written; the - * workflow label is added when GITHUB_WORKFLOW is set; the sanitized - * idempotency-key is added when set. + * Labels written on chat creation. The marker and gh-target labels + * are always written; the workflow label is added when + * `GITHUB_WORKFLOW` is set; the sanitized idempotency-key is added + * under the fixed `coder-agents-chat-action-idempotency` key when + * set. * * The label set must stay in sync with `findReuseMatch`: a key the * create branch writes but the lookup doesn't query (or vice versa) @@ -1373,29 +1067,18 @@ export class CoderAgentChatAction { */ private buildChatLabels( ghTarget: string, - coderUserId: string, workflow: string | undefined, sanitizedKey: string | undefined, ): Record { - // Defense in depth: `runInner` rejects collisions before any API - // call; this guards direct callers. - if (sanitizedKey && RESERVED_LABEL_KEYS.has(sanitizedKey)) { - throw new Error( - `idempotency-key sanitizes to a reserved chat-label key ("${sanitizedKey}"). ` + - `Reserved keys: ${[...RESERVED_LABEL_KEYS].join(", ")}. ` + - "Choose a different idempotency-key value.", - ); - } const labels: Record = { [ACTION_LABEL_KEYS.marker]: "true", [ACTION_LABEL_KEYS.target]: ghTarget, - [ACTION_LABEL_KEYS.user]: coderUserId, }; if (workflow) { labels[ACTION_LABEL_KEYS.workflow] = workflow; } if (sanitizedKey) { - labels[sanitizedKey] = "true"; + labels[ACTION_LABEL_KEYS.idempotency] = sanitizedKey; } return labels; } diff --git a/src/coder-client.test.ts b/src/coder-client.test.ts index 3df402f..7bed088 100644 --- a/src/coder-client.test.ts +++ b/src/coder-client.test.ts @@ -6,9 +6,6 @@ import { } from "./coder-client"; import { mockUser, - mockUserList, - mockUserListEmpty, - mockUserListDuplicate, mockChat, mockChatMessageResponse, mockOrganization, @@ -27,147 +24,6 @@ describe("CoderClient", () => { global.fetch = mockFetch as unknown as typeof fetch; }); - describe("getCoderUserByGitHubId", () => { - test("returns the user when found", async () => { - mockFetch.mockResolvedValue(createMockResponse(mockUserList)); - const result = await client.getCoderUserByGitHubId( - mockUser.github_com_user_id, - ); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringMatching( - new RegExp( - `^https://coder\\.test/api/v2/users\\?q=.*github_com_user_id%3A${mockUser.github_com_user_id}.*$`, - ), - ), - expect.objectContaining({ - headers: expect.objectContaining({ - "Coder-Session-Token": "test-token", - }), - }), - ); - expect(result.id).toBe(mockUser.id); - expect(result.username).toBe(mockUser.username); - }); - - test("throws when multiple users found", async () => { - mockFetch.mockResolvedValue(createMockResponse(mockUserListDuplicate)); - expect( - client.getCoderUserByGitHubId(mockUser.github_com_user_id ?? 0), - ).rejects.toThrow(CoderAPIError); - }); - - test("throws when no user found", async () => { - mockFetch.mockResolvedValue(createMockResponse(mockUserListEmpty)); - expect( - client.getCoderUserByGitHubId(mockUser.github_com_user_id ?? 0), - ).rejects.toThrow(CoderAPIError); - }); - - test("sends only the github_com_user_id filter (no status filter)", async () => { - mockFetch.mockResolvedValue(createMockResponse(mockUserList)); - await client.getCoderUserByGitHubId(mockUser.github_com_user_id ?? 0); - const calledUrl = mockFetch.mock.calls[0]?.[0] as string; - const rawQuery = decodeURIComponent(calledUrl.split("?q=")[1] ?? ""); - expect(rawQuery).toContain( - `github_com_user_id:${mockUser.github_com_user_id}`, - ); - // `status:` would over-filter dormant and suspended users. - expect(rawQuery).not.toContain("status:"); - }); - - test("returns the live user when a soft-deleted user shares the github id", async () => { - const liveUser = { ...mockUser }; - const deletedUser = { - ...mockUser, - id: "770e8400-e29b-41d4-a716-446655440002", - username: "olddeleteduser", - deleted: true, - }; - mockFetch.mockResolvedValue( - createMockResponse({ users: [deletedUser, liveUser] }), - ); - const result = await client.getCoderUserByGitHubId( - mockUser.github_com_user_id ?? 0, - ); - expect(result.id).toBe(liveUser.id); - expect(result.username).toBe(liveUser.username); - }); - - test("keeps a user with explicit deleted: false (locks in three-state semantics)", async () => { - const liveUser = { ...mockUser, deleted: false }; - mockFetch.mockResolvedValue(createMockResponse({ users: [liveUser] })); - const result = await client.getCoderUserByGitHubId( - mockUser.github_com_user_id ?? 0, - ); - expect(result.id).toBe(liveUser.id); - expect(result.username).toBe(liveUser.username); - }); - - test("errors with user_ambiguous kind when two live users share the github id", async () => { - mockFetch.mockResolvedValue(createMockResponse(mockUserListDuplicate)); - let caught: unknown; - try { - await client.getCoderUserByGitHubId(mockUser.github_com_user_id ?? 0); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(CoderAPIError); - expect((caught as CoderAPIError).kind).toBe("user_ambiguous"); - }); - - test("errors with user_not_found kind when all matching users are soft-deleted", async () => { - const deletedUser = { - ...mockUser, - id: "770e8400-e29b-41d4-a716-446655440003", - username: "olddeleteduser", - deleted: true, - }; - mockFetch.mockResolvedValue(createMockResponse({ users: [deletedUser] })); - let caught: unknown; - try { - await client.getCoderUserByGitHubId(mockUser.github_com_user_id ?? 0); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(CoderAPIError); - expect((caught as CoderAPIError).kind).toBe("user_not_found"); - }); - - test("errors with user_not_found kind when the response is empty", async () => { - mockFetch.mockResolvedValue(createMockResponse(mockUserListEmpty)); - let caught: unknown; - try { - await client.getCoderUserByGitHubId(mockUser.github_com_user_id ?? 0); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(CoderAPIError); - expect((caught as CoderAPIError).kind).toBe("user_not_found"); - }); - - test("throws on 401 unauthorized", async () => { - mockFetch.mockResolvedValue( - createMockResponse( - { error: "Unauthorized" }, - { ok: false, status: 401, statusText: "Unauthorized" }, - ), - ); - expect( - client.getCoderUserByGitHubId(mockUser.github_com_user_id ?? 0), - ).rejects.toThrow(CoderAPIError); - }); - - test("throws when GitHub user ID is 0", async () => { - await expect(client.getCoderUserByGitHubId(0)).rejects.toBeInstanceOf( - CoderAPIError, - ); - await expect(client.getCoderUserByGitHubId(0)).rejects.toThrow( - "GitHub user ID cannot be 0", - ); - expect(mockFetch).not.toHaveBeenCalled(); - }); - }); - describe("createChat", () => { test("normalizes a trailing slash on serverURL so the API URL has no double slash", async () => { const clientWithSlash = new RealCoderClient( @@ -281,62 +137,6 @@ describe("CoderClient", () => { }); }); - describe("getCoderUserByUsername", () => { - test("returns the user when found", async () => { - mockFetch.mockResolvedValueOnce(createMockResponse(mockUser)); - - const result = await client.getCoderUserByUsername(mockUser.username); - - expect(result.id).toBe(mockUser.id); - expect(result.username).toBe(mockUser.username); - expect(mockFetch).toHaveBeenCalledWith( - `https://coder.test/api/v2/users/${mockUser.username}`, - expect.objectContaining({ - headers: expect.objectContaining({ - "Coder-Session-Token": "test-token", - }), - }), - ); - }); - - test("encodes the username in the URL path", async () => { - mockFetch.mockResolvedValueOnce(createMockResponse(mockUser)); - - await client.getCoderUserByUsername("user with space"); - - expect(mockFetch).toHaveBeenCalledWith( - "https://coder.test/api/v2/users/user%20with%20space", - expect.anything(), - ); - }); - - test("throws CoderAPIError on empty username without making a request", async () => { - await expect(client.getCoderUserByUsername("")).rejects.toThrow( - CoderAPIError, - ); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - test("throws CoderAPIError with statusCode 404 on missing user", async () => { - mockFetch.mockResolvedValueOnce( - createMockResponse( - { error: "Not Found" }, - { ok: false, status: 404, statusText: "Not Found" }, - ), - ); - - let caught: unknown; - try { - await client.getCoderUserByUsername("missing"); - } catch (e) { - caught = e; - } - - expect(caught).toBeInstanceOf(CoderAPIError); - expect((caught as CoderAPIError).statusCode).toBe(404); - }); - }); - describe("getAuthenticatedUser", () => { test("returns the user behind the configured token", async () => { mockFetch.mockResolvedValueOnce(createMockResponse(mockUser)); diff --git a/src/coder-client.ts b/src/coder-client.ts index 44b1483..3875d3b 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -8,19 +8,11 @@ import { normalizeBaseUrl } from "./url"; export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; export interface CoderClient { - getCoderUserByGitHubId( - githubUserId: number | undefined, - ): Promise; - - getCoderUserByUsername(username: string): Promise; - /** * Resolve the Coder user the configured `coder-token` belongs to via * `GET /api/v2/users/me`. The chat owner on `POST /api/experimental/chats` * is always the token holder (the API has no owner override), so this is - * the identity the chat actually runs as. The action uses this as the - * lowest-priority identity-resolution fallback and as the source of truth - * for the token-owner vs acting-user divergence warning. + * the Coder identity the chat runs as. */ getAuthenticatedUser(): Promise; @@ -108,70 +100,6 @@ export class RealCoderClient implements CoderClient { return response.json() as Promise; } - async getCoderUserByGitHubId( - githubUserId: number | undefined, - ): Promise { - if (githubUserId === undefined) { - throw new CoderAPIError("GitHub user ID cannot be undefined", 400); - } - if (githubUserId === 0) { - // Defense in depth: the input schema rejects 0 upstream. Throw - // CoderAPIError so `instanceof` checks and classifyError routing - // downstream stay sound. - throw new CoderAPIError("GitHub user ID cannot be 0", 400); - } - // coderd's GetUsers SQL hardcodes `users.deleted = false`, so the - // response is already filtered server-side. The client-side - // filter below is forward-compatible defense in depth in case - // `codersdk.User` later starts serializing `deleted`. - const filter = `github_com_user_id:${githubUserId}`; - const endpoint = `/api/v2/users?q=${encodeURIComponent(filter)}`; - const response = await this.request(endpoint); - const userList = CoderSDKGetUsersResponseSchema.parse(response); - const liveUsers = userList.users.filter((u) => !u.deleted); - if (liveUsers.length === 0) { - throw new CoderAPIError( - `No Coder user found with GitHub user ID ${githubUserId}`, - 404, - undefined, - "user_not_found", - ); - } - if (liveUsers.length > 1) { - throw new CoderAPIError( - `Multiple Coder users found with GitHub user ID ${githubUserId}`, - 409, - undefined, - "user_ambiguous", - ); - } - return CoderSDKUserSchema.parse(liveUsers[0]); - } - - async getCoderUserByUsername(username: string): Promise { - if (!username) { - throw new CoderAPIError("Coder username cannot be empty", 400); - } - const endpoint = `/api/v2/users/${encodeURIComponent(username)}`; - try { - const response = await this.request(endpoint); - return CoderSDKUserSchema.parse(response); - } catch (err) { - // Re-throw 404 with the `user_not_found` kind so `classifyError` - // routes a typo in `acting-coder-username` to the helpful failure - // comment rather than a generic `api_error`. - if (err instanceof CoderAPIError && err.statusCode === 404) { - throw new CoderAPIError( - `No Coder user found with username "${username}"`, - 404, - err.response, - "user_not_found", - ); - } - throw err; - } - } - async getAuthenticatedUser(): Promise { // `users/me` resolves the session token to its owning user. No // caching here; callers memoize when they need to reference the @@ -242,8 +170,8 @@ export const ChatIdSchema = z.string().uuid().brand("ChatId"); export type ChatId = z.infer; // `deleted` is parsed leniently: `codersdk.User` does not currently -// serialize it, but we declare it so the filter in -// `getCoderUserByGitHubId` keeps working if the API exposes it later. +// serialize it. The field is retained for forward compatibility with a +// future server schema that exposes it. export const CoderSDKUserSchema = z.object({ id: z.string().uuid(), username: z.string(), @@ -254,13 +182,6 @@ export const CoderSDKUserSchema = z.object({ }); export type CoderSDKUser = z.infer; -export const CoderSDKGetUsersResponseSchema = z.object({ - users: z.array(CoderSDKUserSchema), -}); -export type CoderSDKGetUsersResponse = z.infer< - typeof CoderSDKGetUsersResponseSchema ->; - // Organization schema. Returned by `GET /api/v2/organizations/{name}` and // used to resolve the `coder-organization` input to a UUID for createChat. export const CoderOrganizationSchema = z.object({ diff --git a/src/comment.test.ts b/src/comment.test.ts index d6b7a3b..88c6c2d 100644 --- a/src/comment.test.ts +++ b/src/comment.test.ts @@ -11,6 +11,7 @@ import { type FailureDetail, findCommentByPredicate, normalizeBaseUrl, + renderDetailBlock, type SuccessCommentContext, } from "./comment"; @@ -63,12 +64,16 @@ describe("deriveCommentKey", () => { ).toBe("owner/repo#42"); }); - test("handles enterprise GitHub URLs", () => { + test("falls back to the raw URL for non-github.com hosts (host validation)", () => { + // F6 in the security review: the regex now anchors to github.com + // so an enterprise host (or attacker-chosen host) does not parse + // out a usable owner/repo. The marker still collapses identical + // URLs across re-runs, but does not pretend to know the target. expect( deriveCommentKey({ githubURL: "https://code.acme.com/owner/repo/issues/42", }), - ).toBe("owner/repo#42"); + ).toBe("https://code.acme.com/owner/repo/issues/42"); }); test("appends workflow suffix to the derived per-target key", () => { @@ -425,6 +430,45 @@ describe("buildDeploymentAgentsUrl", () => { }); }); +describe("renderDetailBlock", () => { + test("wraps a plain message in a 4-backtick fenced block", () => { + // F9 in the security review: attacker-influenced strings flowing + // through `detail.message` must not break out of the markdown + // list context. The body now renders inside a 4-backtick fence. + const body = renderDetailBlock("plain message"); + expect(body).toBe("- Detail:\n````\nplain message\n````"); + }); + + test("neutralizes a markdown-injection attempt with backtick fences", () => { + // An adversarial chat.last_error containing a 3-backtick block + // would close the surrounding fence and inject markdown after. + // 4-backtick fences keep the 3-backtick content inside the code + // block. + const body = renderDetailBlock("````\nclose-then-inject\n````"); + // The 4-backtick run inside the message is downgraded to 3 so + // the surrounding 4-backtick fence stays the only sequence that + // closes the block. + expect(body).toBe("- Detail:\n````\n```\nclose-then-inject\n```\n````"); + }); + + test("strips control bytes other than newline and tab", () => { + // CR-only line endings or ANSI escapes flow through agent error + // wrappers; stripping them keeps the comment renderer + // predictable and avoids terminal-escape leakage if an operator + // pipes the comment body to a terminal. + const body = renderDetailBlock("a\u0000b\u0007c\nd\te"); + expect(body).toBe("- Detail:\n````\nabc\nd\te\n````"); + }); + + test("caps the message at 4000 characters", () => { + const body = renderDetailBlock("x".repeat(10000)); + // Header + fence open + 4000 chars + fence close + 3 newlines. + expect(body.length).toBe( + "- Detail:\n````\n".length + 4000 + "\n````".length, + ); + }); +}); + describe("findCommentByPredicate", () => { test( "sweeps every page (octokit.paginate) and finds the marker even when " + diff --git a/src/comment.ts b/src/comment.ts index 8b736dd..e4fd35a 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -16,13 +16,49 @@ type Octokit = ReturnType; // Shared regex for GitHub issue and PR URLs. Used by `deriveCommentKey` and // `parseGithubURL` so adding another path (e.g. `/discussions/`) is one edit. -// Anchored at the tail so URLs with extra path segments after the number -// (e.g. `.../issues/123/files`) are rejected rather than silently truncated. -// The `(?:[?#].*)?` group keeps the anchor tolerant of query strings and -// fragments that real-world `github-url` inputs can carry (e.g. a URL copied -// while viewing a specific comment). +// Anchored at both ends so a non-github host or extra path segments +// (e.g. `.../issues/123/files`, `https://attacker.example/owner/repo/issues/1`) +// are rejected rather than silently truncated. The `(?:[?#].*)?` group keeps +// the anchor tolerant of query strings and fragments that real-world +// `github-url` inputs can carry (e.g. a URL copied while viewing a specific +// comment). export const GITHUB_URL_REGEX = - /([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)\/?(?:[?#].*)?$/; + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)\/?(?:[?#].*)?$/; + +/** + * Parsed components of a `github-url` input. Returned by + * `parseGithubItemURL` after host and path validation. + */ +export interface GithubItemURL { + owner: string; + repo: string; + number: number; +} + +/** + * Validate `input` as a `https://github.com///(issues|pull)/` + * URL and return its components, or `undefined` if it does not match. The + * host is anchored to `github.com` so a workflow that templates user- + * controlled content into `github-url` cannot coerce the action into + * commenting on an arbitrary attacker-chosen owner/repo (F6 in the + * security review). + */ +export function parseGithubItemURL( + input: string | undefined, +): GithubItemURL | undefined { + if (!input) { + return undefined; + } + const match = input.match(GITHUB_URL_REGEX); + if (!match) { + return undefined; + } + return { + owner: match[1], + repo: match[2], + number: Number.parseInt(match[3], 10), + }; +} // Discriminated union so spend-exceeded fields are only representable on the // spend-exceeded variant; the body builder reads them directly without a @@ -212,6 +248,27 @@ function formatMicrosAsDollars(micros: number): string { return `$${dollars.toFixed(2)}`; } +/** + * Render `detail.message` (or any externally-influenced string) inside a + * fenced code block so markdown syntax in the message body cannot break + * out of the failure-comment list. The fence is 4 backticks; any literal + * 4-or-more backtick run in the message is downgraded to 3 so the + * surrounding fence stays closable. Control bytes other than newline and + * tab are stripped so adversarial content cannot inject CR-only line + * endings or other terminal escapes. The result is capped at 4000 chars + * to bound comment size against runaway messages. + */ +export function renderDetailBlock(message: string): string { + const stripped = message.replace( + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping C0 controls is the point. + /[\x00-\x08\x0B-\x1F\x7F]/g, + "", + ); + const capped = stripped.length > 4000 ? stripped.slice(0, 4000) : stripped; + const safe = capped.replace(/`{4,}/g, "```"); + return `- Detail:\n\`\`\`\`\n${safe}\n\`\`\`\``; +} + export interface FailureCommentContext { agentsUrl: string; marker: string; @@ -266,7 +323,7 @@ export function buildFailureCommentBody( "linked to a Coder user) or pass `acting-coder-username` directly.", "", `- chat-error-kind=${detail.kind}`, - `- Detail: ${detail.message}`, + renderDetailBlock(detail.message), "", linkLine, ); @@ -279,7 +336,7 @@ export function buildFailureCommentBody( "per-user reuse label).", "", `- chat-error-kind=${detail.kind}`, - `- Detail: ${detail.message}`, + renderDetailBlock(detail.message), "", linkLine, ); @@ -290,7 +347,7 @@ export function buildFailureCommentBody( "`coder-organization` input or grant the user a membership.", "", `- chat-error-kind=${detail.kind}`, - `- Detail: ${detail.message}`, + renderDetailBlock(detail.message), "", linkLine, ); @@ -299,7 +356,7 @@ export function buildFailureCommentBody( lines.push(apiErrorPhrase(runPhase, ctx), ""); lines.push( `- chat-error-kind=${detail.kind}`, - `- Detail: ${detail.message}`, + renderDetailBlock(detail.message), ); if (ctx.chatStatus === "error") { lines.push( @@ -318,7 +375,7 @@ export function buildFailureCommentBody( "`wait-timeout-seconds`.", "", `- chat-error-kind=${detail.kind}`, - `- Detail: ${detail.message}`, + renderDetailBlock(detail.message), "", linkLine, ); diff --git a/src/index.test.ts b/src/index.test.ts index 4a81d3d..cd22596 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,58 +1,9 @@ -import { describe, expect, test } from "bun:test"; -import { parseGithubUserID } from "./index"; - -describe("parseGithubUserID", () => { - test("returns undefined when input is empty", () => { - expect(parseGithubUserID("")).toBeUndefined(); - }); - - test("parses a plain decimal integer", () => { - expect(parseGithubUserID("123")).toBe(123); - }); - - test("returns NaN for trailing non-numeric characters", () => { - // The original #16 bug: parseInt would return 123 here and the - // schema would happily resolve to user 123. - expect(parseGithubUserID("123abc")).toBe(Number.NaN); - }); - - test("returns NaN for hex literals", () => { - // `Number("0x1F")` is 31, which would pass `int().positive()`. - // The regex gate must reject every non-decimal numeric form so - // non-decimal input can never silently resolve to a user. - expect(parseGithubUserID("0x1F")).toBe(Number.NaN); - }); - - test("returns NaN for binary literals", () => { - expect(parseGithubUserID("0b101")).toBe(Number.NaN); - }); - - test("returns NaN for octal literals", () => { - expect(parseGithubUserID("0o7")).toBe(Number.NaN); - }); - - test("returns NaN for scientific notation", () => { - expect(parseGithubUserID("1e3")).toBe(Number.NaN); - }); - - test("returns NaN for decimals", () => { - // GitHub user IDs are integers. Rejecting at the parser keeps - // the runtime guard's shape aligned with the schema's - // `.int()` constraint. - expect(parseGithubUserID("12.5")).toBe(Number.NaN); - }); - - test("returns NaN for negative numbers", () => { - expect(parseGithubUserID("-1")).toBe(Number.NaN); - }); - - test("returns NaN for whitespace-wrapped input", () => { - // `Number(" 123 ")` is 123. Whitespace tolerance was never - // intentional behavior. The regex rejects it. - expect(parseGithubUserID(" 123 ")).toBe(Number.NaN); - }); - - test("returns NaN for purely non-numeric input", () => { - expect(parseGithubUserID("abc")).toBe(Number.NaN); - }); +// The acting-github-user-id input was dropped in the security-driven +// simplification; `parseGithubUserID` no longer exists. This file is +// retained as a placeholder so the test discovery glob still finds it +// without complaining about an empty module. +import { describe, test } from "bun:test"; + +describe("index", () => { + test("placeholder", () => {}); }); diff --git a/src/index.ts b/src/index.ts index 01c1846..1786ea8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,26 +5,8 @@ import { RealCoderClient } from "./coder-client"; import { setActionOutputs, setFailureOutputs } from "./outputs"; import { ActionInputsSchema } from "./schemas"; -// Convert the `acting-github-user-id` workflow input to a number, or return -// undefined when unset. Returns NaN for anything that isn't a plain -// decimal integer literal so it fails schema parse instead of silently -// resolving to the wrong Coder user. `Number()` alone is too permissive: -// it accepts hex (`"0x1F"` -> 31), binary (`"0b101"` -> 5), octal -// (`"0o7"` -> 7), and scientific notation (`"1e3"` -> 1000), all of -// which would pass `z.number().int().positive()`. The bare regex gate -// rejects every non-decimal form. See #16. -export function parseGithubUserID(raw: string): number | undefined { - if (!raw) return undefined; - if (!/^\d+$/.test(raw)) return Number.NaN; - return Number(raw); -} - async function main() { try { - const githubUserID = parseGithubUserID( - core.getInput("acting-github-user-id"), - ); - const inputs = ActionInputsSchema.parse({ coderURL: core.getInput("coder-url", { required: true }), coderToken: core.getInput("coder-token", { required: true }), @@ -32,8 +14,6 @@ async function main() { coderOrganization: core.getInput("coder-organization") || undefined, githubURL: core.getInput("github-url", { required: true }), githubToken: core.getInput("github-token", { required: true }), - githubUserID, - coderUsername: core.getInput("acting-coder-username") || undefined, workspaceId: core.getInput("workspace-id") || undefined, modelConfigId: core.getInput("model-config-id") || undefined, existingChatId: core.getInput("existing-chat-id") || undefined, diff --git a/src/outputs.test.ts b/src/outputs.test.ts index aaa98bc..d705618 100644 --- a/src/outputs.test.ts +++ b/src/outputs.test.ts @@ -35,7 +35,7 @@ function captureSetOutput(): { describe("OUTPUT_MAP", () => { test("declares an entry for every action.yaml output", () => { const expected = [ - "acting-coder-username", + "coder-username", "chat-id", "chat-url", "chat-created", @@ -61,7 +61,7 @@ describe("OUTPUT_MAP", () => { test("required entries are exactly the four base outputs", () => { const required = OUTPUT_MAP.filter((e) => e.required).map((e) => e.name); expect(required).toEqual([ - "acting-coder-username", + "coder-username", "chat-id", "chat-url", "chat-created", @@ -87,7 +87,7 @@ describe("setActionOutputs", () => { setActionOutputs(baseOutputs); const names = cap.calls.map(([n]) => n).sort(); expect(names).toEqual( - ["chat-created", "chat-id", "chat-url", "acting-coder-username"].sort(), + ["chat-created", "chat-id", "chat-url", "coder-username"].sort(), ); } finally { cap.restore(); @@ -167,7 +167,7 @@ describe("setActionOutputs", () => { ...baseOutputs, coderUsername: undefined as unknown as string, }); - const username = cap.calls.find(([n]) => n === "acting-coder-username"); + const username = cap.calls.find(([n]) => n === "coder-username"); expect(username).toBeDefined(); expect(username?.[1]).toBe(""); } finally { @@ -219,7 +219,7 @@ describe("setFailureOutputs", () => { expect(names).not.toContain("chat-id"); expect(names).not.toContain("chat-status"); expect(names).not.toContain("chat-url"); - expect(names).not.toContain("acting-coder-username"); + expect(names).not.toContain("coder-username"); } finally { cap.restore(); } @@ -266,7 +266,7 @@ describe("setFailureOutputs", () => { } }); - test("emits chat-url and acting-coder-username when decorated", () => { + test("emits chat-url and coder-username when decorated", () => { const cap = captureSetOutput(); try { const err = new ActionFailureError("timeout", "Timed out", mockChat); @@ -279,7 +279,7 @@ describe("setFailureOutputs", () => { "chat-url", "https://coder.test/agents/abc", ]); - expect(cap.calls).toContainEqual(["acting-coder-username", "testuser"]); + expect(cap.calls).toContainEqual(["coder-username", "testuser"]); } finally { cap.restore(); } diff --git a/src/outputs.ts b/src/outputs.ts index 4043b9e..c744eda 100644 --- a/src/outputs.ts +++ b/src/outputs.ts @@ -9,7 +9,7 @@ export const OUTPUT_MAP: ReadonlyArray<{ prop: keyof ActionOutputs; required?: boolean; }> = [ - { name: "acting-coder-username", prop: "coderUsername", required: true }, + { name: "coder-username", prop: "coderUsername", required: true }, { name: "chat-id", prop: "chatId", required: true }, { name: "chat-url", prop: "chatUrl", required: true }, { name: "chat-created", prop: "chatCreated", required: true }, @@ -61,6 +61,6 @@ export function setFailureOutputs(error: ActionFailureError): void { core.setOutput("chat-url", error.chatUrl); } if (error.coderUsername) { - core.setOutput("acting-coder-username", error.coderUsername); + core.setOutput("coder-username", error.coderUsername); } } diff --git a/src/sanitize-label-key.test.ts b/src/sanitize-label-key.test.ts index 5be6412..024280d 100644 --- a/src/sanitize-label-key.test.ts +++ b/src/sanitize-label-key.test.ts @@ -34,14 +34,6 @@ describe("sanitizeLabelKey", () => { }); describe("RESERVED_LABEL_KEYS", () => { - test("includes the per-user scope key that prevents cross-user hijack", () => { - // Without this entry, a sanitized idempotency-key value of - // "coder-agents-chat-action-user" would silently overwrite the - // per-user label and let any user impersonate any other on the - // same target. - expect(RESERVED_LABEL_KEYS.has("coder-agents-chat-action-user")).toBe(true); - }); - test("includes the per-workflow scope key that prevents reuse-scope hijack", () => { // Without this entry, a sanitized idempotency-key value of // "coder-agents-chat-action-workflow" would silently overwrite the diff --git a/src/sanitize-label-key.ts b/src/sanitize-label-key.ts index 1934dc7..b836ed2 100644 --- a/src/sanitize-label-key.ts +++ b/src/sanitize-label-key.ts @@ -8,8 +8,8 @@ export const ACTION_LABEL_KEYS = { marker: "coder-agents-chat-action", target: "gh-target", - user: "coder-agents-chat-action-user", workflow: "coder-agents-chat-action-workflow", + idempotency: "coder-agents-chat-action-idempotency", } as const; /** diff --git a/src/schemas.test.ts b/src/schemas.test.ts index 2d62959..10a2ddb 100644 --- a/src/schemas.test.ts +++ b/src/schemas.test.ts @@ -15,7 +15,6 @@ const actionInputValid: ActionInputs = { chatPrompt: "test prompt", githubURL: "https://github.com/owner/repo/issues/123", githubToken: "github-token", - githubUserID: 12345, commentOnIssue: true, wait: "none", waitTimeoutSeconds: DEFAULT_WAIT_TIMEOUT_SECONDS, @@ -32,7 +31,6 @@ describe("ActionInputsSchema", () => { expect(result.chatPrompt).toBe(actionInputValid.chatPrompt); expect(result.githubURL).toBe(actionInputValid.githubURL); expect(result.githubToken).toBe(actionInputValid.githubToken); - expect(result.githubUserID).toBe(actionInputValid.githubUserID); }); test("accepts optional workspace-id", () => { @@ -97,13 +95,6 @@ describe("ActionInputsSchema", () => { expect(result.coderURL).toBe(url); } }); - - test("accepts both acting-github-user-id and acting-coder-username unset", () => { - const { githubUserID: _, ...withoutGithubUserID } = actionInputValid; - const result = ActionInputsSchema.parse(withoutGithubUserID); - expect(result.githubUserID).toBeUndefined(); - expect(result.coderUsername).toBeUndefined(); - }); }); describe("Invalid Input Cases", () => { @@ -161,85 +152,6 @@ describe("ActionInputsSchema", () => { }); }); - describe("User Identification (Mutual Exclusion)", () => { - test("accepts input with only githubUserID", () => { - const result = ActionInputsSchema.parse(actionInputValid); - expect(result.githubUserID).toBe(12345); - expect(result.coderUsername).toBeUndefined(); - }); - - test("accepts input with only coderUsername", () => { - const { githubUserID: _, ...withoutGithubUserID } = actionInputValid; - const input = { ...withoutGithubUserID, coderUsername: "testuser" }; - const result = ActionInputsSchema.parse(input); - expect(result.coderUsername).toBe("testuser"); - expect(result.githubUserID).toBeUndefined(); - }); - - test("rejects input with both githubUserID and coderUsername", () => { - const input = { - ...actionInputValid, - coderUsername: "testuser", - }; - expect(() => ActionInputsSchema.parse(input)).toThrow( - /acting-github-user-id and acting-coder-username/, - ); - }); - - test("rejects input with both existingChatId and forceNewChat", () => { - const input = { - ...actionInputValid, - existingChatId: "00000000-0000-0000-0000-000000000001", - forceNewChat: true, - }; - expect(() => ActionInputsSchema.parse(input)).toThrow( - /existing-chat-id and force-new-chat/, - ); - }); - - test("rejects githubUserID of 0", () => { - const input = { - ...actionInputValid, - githubUserID: 0, - }; - expect(() => ActionInputsSchema.parse(input)).toThrow(); - }); - - test("rejects negative githubUserID", () => { - const input = { - ...actionInputValid, - githubUserID: -1, - }; - expect(() => ActionInputsSchema.parse(input)).toThrow(); - }); - - // The schema's `.int().positive()` already rejected NaN before - // this PR. We pin it here so the schema cannot silently relax - // to admit `NaN`, which is what the parser produces for any - // non-decimal input (see src/index.test.ts). - test("rejects NaN githubUserID", () => { - const input = { - ...actionInputValid, - githubUserID: Number.NaN, - }; - expect(() => ActionInputsSchema.parse(input)).toThrow(); - }); - - test("rejects non-integer githubUserID", () => { - const input = { - ...actionInputValid, - githubUserID: 12.5, - }; - expect(() => ActionInputsSchema.parse(input)).toThrow(); - }); - - test("rejects empty coderUsername", () => { - const { githubUserID: _, ...withoutGithubUserID } = actionInputValid; - const input = { ...withoutGithubUserID, coderUsername: "" }; - expect(() => ActionInputsSchema.parse(input)).toThrow(); - }); - }); - describe("Wait mode", () => { test("wait defaults to none when omitted", () => { const { wait: _, ...withoutWait } = actionInputValid; diff --git a/src/schemas.ts b/src/schemas.ts index 88a9f03..982efd4 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -4,10 +4,6 @@ import { z } from "zod"; // in sync if either changes. export const DEFAULT_WAIT_TIMEOUT_SECONDS = 600; -// Mutual exclusion of acting-github-user-id and acting-coder-username is -// enforced by the wrapper schema below. Both identity inputs are optional -// at the object level so the runtime can later auto-resolve from the -// workflow context. const ActionInputsObjectSchema = z.object({ chatPrompt: z.string().min(1), coderToken: z.string().min(1), @@ -15,8 +11,6 @@ const ActionInputsObjectSchema = z.object({ coderOrganization: z.string().min(1).optional(), githubURL: z.string().url(), githubToken: z.string().min(1), - githubUserID: z.number().int().positive().optional(), - coderUsername: z.string().min(1).optional(), workspaceId: z.string().uuid().optional(), modelConfigId: z.string().uuid().optional(), existingChatId: z.string().uuid().optional(), @@ -32,14 +26,6 @@ const ActionInputsObjectSchema = z.object({ }); export const ActionInputsSchema = ActionInputsObjectSchema.refine( - (data) => - !(data.githubUserID !== undefined && data.coderUsername !== undefined), - { - message: - "Cannot set both acting-github-user-id and acting-coder-username; choose one.", - path: ["coderUsername"], - }, -).refine( (data) => !(data.existingChatId !== undefined && data.forceNewChat === true), { message: "Cannot set both existing-chat-id and force-new-chat; choose one.", diff --git a/src/test-helpers.ts b/src/test-helpers.ts index c59d834..355b593 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -6,7 +6,6 @@ import { } from "./coder-client"; import type { CoderSDKUser, - CoderSDKGetUsersResponse, CoderChat, CoderOrganization, CreateChatRequest, @@ -49,25 +48,6 @@ export const mockUser: CoderSDKUser = { github_com_user_id: 12345, }; -export const mockUserList: CoderSDKGetUsersResponse = { - users: [mockUser], -}; - -export const mockUserListEmpty: CoderSDKGetUsersResponse = { - users: [], -}; - -export const mockUserListDuplicate: CoderSDKGetUsersResponse = { - users: [ - mockUser, - { - ...mockUser, - id: "660e8400-e29b-41d4-a716-446655440001", - username: "testuser2", - }, - ], -}; - // User with no organization memberships. export const mockUserNoOrgs: CoderSDKUser = { ...mockUser, @@ -142,7 +122,6 @@ export function createMockInputs( coderOrganization: undefined, githubToken: "github-token", githubURL: "https://github.com/test-org/test-repo/issues/123", - githubUserID: 12345, commentOnIssue: true, wait: "none", waitTimeoutSeconds: DEFAULT_WAIT_TIMEOUT_SECONDS, @@ -155,10 +134,6 @@ export function createMockInputs( * Mock CoderClient for testing */ export class MockCoderClient implements CoderClient { - public mockGetCoderUserByGithubID = mock(); - public mockGetCoderUserByUsername = mock((_username: string) => - Promise.resolve(mockUser), - ); public mockGetOrganizationByName = mock((_name: string) => Promise.resolve(mockOrganization), ); @@ -170,14 +145,6 @@ export class MockCoderClient implements CoderClient { ); public mockGetAuthenticatedUser = mock(() => Promise.resolve(mockUser)); - async getCoderUserByGitHubId(githubUserId: number): Promise { - return this.mockGetCoderUserByGithubID(githubUserId); - } - - async getCoderUserByUsername(username: string): Promise { - return this.mockGetCoderUserByUsername(username); - } - async getAuthenticatedUser(): Promise { return this.mockGetAuthenticatedUser(); } From 32d6d9393bd0e5de72bdaa657c3a0b542dd2d347 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 18 May 2026 10:07:10 +0000 Subject: [PATCH 05/11] fix: reformat collapsed import after CI fmt-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit removed a single named import. Biome's format check collapses 2-name imports onto one line. The local `bun run format` should have caught this but only ran on the file before the manual edit. CI caught it. 🤖 Authored by Coder Agents. --- src/action.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/action.test.ts b/src/action.test.ts index 2b1c89a..ba73d81 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -8,10 +8,7 @@ import { } from "./action"; import type { Octokit } from "./action"; import { CoderAPIError } from "./coder-client"; -import { - ChatIdSchema, - type CoderChat, -} from "./coder-client"; +import { ChatIdSchema, type CoderChat } from "./coder-client"; import { ActionOutputsSchema } from "./schemas"; import { MockCoderClient, From b36a92c86ff41cf7a487bec18b598d86c6c7e901 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 18 May 2026 10:09:36 +0000 Subject: [PATCH 06/11] docs(action.yaml): align idempotency-key description with the new reuse scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The input description still referenced "the resolved Coder user" as part of the default scope; that component was dropped when the per-user reuse label went away. Rewrite to describe the current scope (gh-target + workflow name) and point users at `${{ github.actor }}` for per-actor separation. 🤖 Authored by Coder Agents. --- action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yaml b/action.yaml index 3a21f92..d99fe33 100644 --- a/action.yaml +++ b/action.yaml @@ -59,7 +59,7 @@ inputs: default: "600" idempotency-key: - description: "Optional sharding key to narrow the default per-workflow scope. By default the action reuses the most recent non-archived chat scoped to `gh-target`, the resolved Coder user, and the workflow name. Set this to maintain multiple parallel chats on the same target/user/workflow (for example, one per matrix dimension)." + description: "Optional sharding key to narrow the default reuse scope. By default the action reuses the most recent non-archived chat scoped to `gh-target` and the workflow name (when `GITHUB_WORKFLOW` is set). All chats are owned by the `coder-token` holder, so the scope does not include a per-actor component. Set this to maintain multiple parallel chats on the same target/workflow (for example, one per matrix dimension). Pass `${{ github.actor }}` here if you want per-actor separation." required: false force-new-chat: From 30ca57e5715d8c01796013f96f2250205d9d6698 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 18 May 2026 11:03:33 +0000 Subject: [PATCH 07/11] refactor(src): drop dead user_not_found/user_ambiguous kinds and rename sanitizedKey Remove the user_not_found and user_ambiguous ChatErrorKind variants: the action no longer resolves an acting Coder user, so these branches were unreachable. Update the enum, the FailureDetail union, the classifyError fallthrough, mapErrorCodeToKind (removed), the two buildFailureCommentBody cases, the action.yaml output description, and the tests that exercised the dead paths. Rename sanitizedKey to sanitizedIdempotency in action.ts; with the fixed label key, the variable is an idempotency VALUE, not a key. Have deriveCommentKey go through parseGithubItemURL instead of matching GITHUB_URL_REGEX directly so host validation stays in one place. Fix two JSDoc/comment paragraphs that still mentioned the removed identity-resolution path, and replace stray escaped apostrophes in two JSDoc comments. Add a schema test for the existing-chat-id + force-new-chat mutex. --- action.yaml | 2 +- dist/index.js | 47 +++++----------- src/action.test.ts | 128 ++------------------------------------------ src/action.ts | 26 ++++----- src/coder-client.ts | 7 +-- src/comment.test.ts | 65 ---------------------- src/comment.ts | 61 ++------------------- src/schemas.test.ts | 22 ++++++++ src/schemas.ts | 2 - 9 files changed, 58 insertions(+), 302 deletions(-) diff --git a/action.yaml b/action.yaml index d99fe33..748f7de 100644 --- a/action.yaml +++ b/action.yaml @@ -117,7 +117,7 @@ outputs: description: "Base branch name when available." chat-error-kind: - description: "Machine-readable error kind when the chat fails (one of `spend_exceeded`, `user_not_found`, `user_ambiguous`, `org_not_found`, `api_error`, `timeout`)." + description: "Machine-readable error kind when the chat fails (one of `spend_exceeded`, `org_not_found`, `api_error`, `timeout`)." chat-error-message: description: "Human-readable error message when the chat fails." diff --git a/dist/index.js b/dist/index.js index 2afba9d..b091cb9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26876,8 +26876,6 @@ var CreateChatMessageResponseSchema = exports_external.object({ queued: exports_external.boolean() }); var ChatErrorKindSchema = exports_external.enum([ - "user_not_found", - "user_ambiguous", "org_not_found", "spend_exceeded", "api_error", @@ -26939,12 +26937,12 @@ function deriveCommentKey(inputs) { if (inputs.idempotencyKey) { return sanitizeLabelKey(inputs.idempotencyKey); } - const match = inputs.githubURL.match(GITHUB_URL_REGEX); + const parsed = parseGithubItemURL(inputs.githubURL); let base; - if (!match) { + if (!parsed) { base = inputs.githubURL; } else { - base = `${match[1]}/${match[2]}#${match[3]}`; + base = `${parsed.owner}/${parsed.repo}#${parsed.number}`; } if (inputs.workflow) { return `${base}:${inputs.workflow}`; @@ -26953,10 +26951,6 @@ function deriveCommentKey(inputs) { } function classifyError(err) { if (err instanceof CoderAPIError) { - const code = mapErrorCodeToKind(err.kind); - if (code) { - return { kind: code, message: err.message }; - } const spend = parseSpendExceededBody(err.response); if (err.statusCode === 409 && spend) { return { @@ -26977,15 +26971,6 @@ function classifyError(err) { } return { kind: "api_error", message: String(err) }; } -function mapErrorCodeToKind(code) { - switch (code) { - case "user_not_found": - case "user_ambiguous": - return code; - default: - return; - } -} function parseSpendExceededBody(response) { const obj = parseJSONObject(response); if (!obj) { @@ -27051,12 +27036,6 @@ function buildFailureCommentBody(detail, ctx) { } lines.push("", linkLine); break; - case "user_not_found": - lines.push("No Coder user could be resolved for this run. Adjust either " + "the `acting-github-user-id` input (the GitHub identity is not " + "linked to a Coder user) or pass `acting-coder-username` directly.", "", `- chat-error-kind=${detail.kind}`, renderDetailBlock(detail.message), "", linkLine); - break; - case "user_ambiguous": - lines.push("Multiple Coder users matched the GitHub identity. Set the " + "`acting-coder-username` input to the specific account this " + "workflow should use as the acting user (for org pick and the " + "per-user reuse label).", "", `- chat-error-kind=${detail.kind}`, renderDetailBlock(detail.message), "", linkLine); - break; case "org_not_found": lines.push("The resolved Coder user has no matching organization. Set the " + "`coder-organization` input or grant the user a membership.", "", `- chat-error-kind=${detail.kind}`, renderDetailBlock(detail.message), "", linkLine); break; @@ -27519,13 +27498,13 @@ class CoderAgentChatAction { githubIssueNumber }); } - const sanitizedKey = this.inputs.idempotencyKey ? sanitizeLabelKey(this.inputs.idempotencyKey) : undefined; + const sanitizedIdempotency = this.inputs.idempotencyKey ? sanitizeLabelKey(this.inputs.idempotencyKey) : undefined; const ghTarget = `${githubOrg}/${githubRepo}#${githubIssueNumber}`; const workflow = process.env.GITHUB_WORKFLOW || undefined; if (this.inputs.forceNewChat) { core2.info("force-new-chat=true: skipping chat-reuse lookup"); } else { - const follow = await this.findReuseMatch(ghTarget, workflow, sanitizedKey); + const follow = await this.findReuseMatch(ghTarget, workflow, sanitizedIdempotency); if (follow) { core2.info(`Reusing existing chat: ${follow.id}`); return this.runFollowUp({ @@ -27545,7 +27524,7 @@ class CoderAgentChatAction { content: [{ type: "text", text: this.inputs.chatPrompt }], workspace_id: this.inputs.workspaceId, model_config_id: this.inputs.modelConfigId, - labels: this.buildChatLabels(ghTarget, workflow, sanitizedKey) + labels: this.buildChatLabels(ghTarget, workflow, sanitizedIdempotency) }; const createdChat = await this.coder.createChat(req); core2.info(`Agents chat created successfully (id: ${createdChat.id}, status: ${createdChat.status})`); @@ -27626,7 +27605,7 @@ class CoderAgentChatAction { chatCreated: false }; } - async findReuseMatch(ghTarget, workflow, sanitizedKey) { + async findReuseMatch(ghTarget, workflow, sanitizedIdempotency) { const labels = [ `${ACTION_LABEL_KEYS.marker}:true`, `${ACTION_LABEL_KEYS.target}:${ghTarget}` @@ -27634,8 +27613,8 @@ class CoderAgentChatAction { if (workflow) { labels.push(`${ACTION_LABEL_KEYS.workflow}:${workflow}`); } - if (sanitizedKey) { - labels.push(`${ACTION_LABEL_KEYS.idempotency}:${sanitizedKey}`); + if (sanitizedIdempotency) { + labels.push(`${ACTION_LABEL_KEYS.idempotency}:${sanitizedIdempotency}`); } let chats; try { @@ -27664,7 +27643,7 @@ class CoderAgentChatAction { } return live[0]; } - buildChatLabels(ghTarget, workflow, sanitizedKey) { + buildChatLabels(ghTarget, workflow, sanitizedIdempotency) { const labels = { [ACTION_LABEL_KEYS.marker]: "true", [ACTION_LABEL_KEYS.target]: ghTarget @@ -27672,8 +27651,8 @@ class CoderAgentChatAction { if (workflow) { labels[ACTION_LABEL_KEYS.workflow] = workflow; } - if (sanitizedKey) { - labels[ACTION_LABEL_KEYS.idempotency] = sanitizedKey; + if (sanitizedIdempotency) { + labels[ACTION_LABEL_KEYS.idempotency] = sanitizedIdempotency; } return labels; } @@ -27752,8 +27731,6 @@ var ActionInputsSchema = ActionInputsObjectSchema.refine((data) => !(data.existi }); var ChatErrorKindSchema2 = exports_external.enum([ "spend_exceeded", - "user_not_found", - "user_ambiguous", "org_not_found", "api_error", "timeout" diff --git a/src/action.test.ts b/src/action.test.ts index ba73d81..686f5cc 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -1757,45 +1757,6 @@ describe("CoderAgentChatAction", () => { }); describe("Error Scenarios", () => { - test("throws error when Coder user not found", async () => { - // The real RealCoderClient.getCoderUserByGitHubId throws - // CoderAPIError with status 404; the mock must match so - // classifyError sees user_not_found rather than the api_error - // fallback. - coderClient.mockGetAuthenticatedUser.mockRejectedValue( - new CoderAPIError( - "No Coder user found with GitHub user ID 12345", - 404, - undefined, - "user_not_found", - ), - ); - octokit.rest.issues.listComments.mockResolvedValue({ - data: [], - } as ReturnType); - octokit.rest.issues.createComment.mockResolvedValue( - {} as ReturnType, - ); - - const inputs = createMockInputs({}); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - createMockContext(), - ); - - await expect(action.run()).rejects.toThrow( - "No Coder user found with GitHub user ID 12345", - ); - // Assert the failure went through user_not_found classification - // (the comment body kind line proves classifyError matched). - const call = octokit.rest.issues.createComment.mock.calls[0]?.[0] as - | { body: string } - | undefined; - expect(call?.body).toContain("chat-error-kind=user_not_found"); - }); - test("throws error when chat creation fails", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( @@ -1867,89 +1828,6 @@ describe("CoderAgentChatAction", () => { }, ); - test( - "posts a failure comment with chat-error-kind=user_not_found and " + - "names the input that needs adjusting", - async () => { - coderClient.mockGetAuthenticatedUser.mockRejectedValue( - new CoderAPIError( - "No Coder user found with GitHub user ID 12345", - 404, - undefined, - "user_not_found", - ), - ); - octokit.rest.issues.listComments.mockResolvedValue({ - data: [], - } as ReturnType); - octokit.rest.issues.createComment.mockResolvedValue( - {} as ReturnType, - ); - - const inputs = createMockInputs({}); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - createMockContext(), - ); - - await expect(action.run()).rejects.toThrow(); - - expect(octokit.rest.issues.createComment).toHaveBeenCalledTimes(1); - const call = octokit.rest.issues.createComment.mock.calls[0]?.[0] as - | { body: string } - | undefined; - expect(call?.body).toContain("chat-error-kind=user_not_found"); - expect(call?.body).toContain("acting-github-user-id"); - expect(call?.body).toContain("acting-coder-username"); - expect(call?.body).toContain( - "", - ); - }, - ); - - test( - "posts a failure comment with chat-error-kind=user_ambiguous and " + - "suggests acting-coder-username", - async () => { - coderClient.mockGetAuthenticatedUser.mockRejectedValue( - new CoderAPIError( - "Multiple Coder users found with GitHub user ID 12345", - 409, - undefined, - "user_ambiguous", - ), - ); - octokit.rest.issues.listComments.mockResolvedValue({ - data: [], - } as ReturnType); - octokit.rest.issues.createComment.mockResolvedValue( - {} as ReturnType, - ); - - const inputs = createMockInputs({}); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - createMockContext(), - ); - - await expect(action.run()).rejects.toThrow(); - - expect(octokit.rest.issues.createComment).toHaveBeenCalledTimes(1); - const call = octokit.rest.issues.createComment.mock.calls[0]?.[0] as - | { body: string } - | undefined; - expect(call?.body).toContain("chat-error-kind=user_ambiguous"); - expect(call?.body).toContain("acting-coder-username"); - expect(call?.body).toContain( - "", - ); - }, - ); - test("falls back to chat-error-kind=api_error for unknown 4xx shapes", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( @@ -2289,7 +2167,7 @@ describe("CoderAgentChatAction", () => { ); }); - test("defaults via getCoderUserByUsername when only acting-coder-username is set", async () => { + test("resolves the token owner via getAuthenticatedUser when coder-organization is unset", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); @@ -2466,7 +2344,7 @@ describe("CoderAgentChatAction", () => { expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); }); - test("non-404 CoderAPIError from getCoderUserByUsername is not classified as user_not_found", async () => { + test("users/me 401 (non-404) classifies as api_error", async () => { coderClient.mockGetAuthenticatedUser.mockRejectedValue( new CoderAPIError("Coder API error: Unauthorized", 401), ); @@ -2489,7 +2367,7 @@ describe("CoderAgentChatAction", () => { } expect(caught).toBeInstanceOf(ActionFailureError); - expect((caught as ActionFailureError).kind).not.toBe("user_not_found"); + expect((caught as ActionFailureError).kind).toBe("api_error"); expect((caught as ActionFailureError).cause).toBeInstanceOf( CoderAPIError, ); diff --git a/src/action.ts b/src/action.ts index 93813fb..a137846 100644 --- a/src/action.ts +++ b/src/action.ts @@ -572,11 +572,11 @@ export class CoderAgentChatAction { * of who triggered the workflow; this gate's load-bearing job is the * fail-closed refusal to call `createChat` on a hostile trigger. There * is no input bypass: workflow authors targeting fork PRs or low-trust - * comment channels must add their own `if:` filter (see README\'s + * comment channels must add their own `if:` filter (see README's * `pull_request_target` recipe and the security model section). * * On `trusted` and `no-signal` verdicts the action proceeds; both are - * logged so an operator debugging identity resolution can tell which + * logged so an operator debugging trust-gate behavior can tell which * branch fired. */ assertTrustedTrigger(): void { @@ -597,7 +597,7 @@ export class CoderAgentChatAction { // no-signal: events like `issues`, `push`, same-repo // `pull_request`, and `workflow_dispatch` carry no // sender-association data the gate can act on. Log so an - // operator debugging identity resolution can tell the gate ran + // operator debugging trust-gate behavior can tell the gate ran // and deferred, rather than being skipped. core.info( "Trust gate found no signal in the event payload; deferring " + @@ -621,7 +621,7 @@ export class CoderAgentChatAction { * names an org that does not exist (HTTP 404) or the resolved user has * no org memberships. Other API errors propagate as `CoderAPIError`. The * original error is attached via `options.cause` on every wrap; - * `run()`\'s `handleFailure` re-classifies the failure into the + * `run()`'s `handleFailure` re-classifies the failure into the * failure-path comment. */ async resolveOrganizationID(user: CoderSDKUser): Promise { @@ -810,7 +810,7 @@ export class CoderAgentChatAction { // further so two workflow runs with the same scope can maintain // distinct chats. Workflows that want per-actor separation can // set `idempotency-key: ${{ github.actor }}` themselves. - const sanitizedKey = this.inputs.idempotencyKey + const sanitizedIdempotency = this.inputs.idempotencyKey ? sanitizeLabelKey(this.inputs.idempotencyKey) : undefined; const ghTarget = `${githubOrg}/${githubRepo}#${githubIssueNumber}`; @@ -822,7 +822,7 @@ export class CoderAgentChatAction { const follow = await this.findReuseMatch( ghTarget, workflow, - sanitizedKey, + sanitizedIdempotency, ); if (follow) { core.info(`Reusing existing chat: ${follow.id}`); @@ -848,7 +848,7 @@ export class CoderAgentChatAction { content: [{ type: "text", text: this.inputs.chatPrompt }], workspace_id: this.inputs.workspaceId, model_config_id: this.inputs.modelConfigId, - labels: this.buildChatLabels(ghTarget, workflow, sanitizedKey), + labels: this.buildChatLabels(ghTarget, workflow, sanitizedIdempotency), }; const createdChat = await this.coder.createChat(req); @@ -1000,7 +1000,7 @@ export class CoderAgentChatAction { private async findReuseMatch( ghTarget: string, workflow: string | undefined, - sanitizedKey: string | undefined, + sanitizedIdempotency: string | undefined, ): Promise { const labels: string[] = [ `${ACTION_LABEL_KEYS.marker}:true`, @@ -1009,8 +1009,8 @@ export class CoderAgentChatAction { if (workflow) { labels.push(`${ACTION_LABEL_KEYS.workflow}:${workflow}`); } - if (sanitizedKey) { - labels.push(`${ACTION_LABEL_KEYS.idempotency}:${sanitizedKey}`); + if (sanitizedIdempotency) { + labels.push(`${ACTION_LABEL_KEYS.idempotency}:${sanitizedIdempotency}`); } let chats: CoderChat[]; try { @@ -1068,7 +1068,7 @@ export class CoderAgentChatAction { private buildChatLabels( ghTarget: string, workflow: string | undefined, - sanitizedKey: string | undefined, + sanitizedIdempotency: string | undefined, ): Record { const labels: Record = { [ACTION_LABEL_KEYS.marker]: "true", @@ -1077,8 +1077,8 @@ export class CoderAgentChatAction { if (workflow) { labels[ACTION_LABEL_KEYS.workflow] = workflow; } - if (sanitizedKey) { - labels[ACTION_LABEL_KEYS.idempotency] = sanitizedKey; + if (sanitizedIdempotency) { + labels[ACTION_LABEL_KEYS.idempotency] = sanitizedIdempotency; } return labels; } diff --git a/src/coder-client.ts b/src/coder-client.ts index 3875d3b..552c4eb 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -284,12 +284,9 @@ export type CreateChatMessageResponse = z.infer< typeof CreateChatMessageResponseSchema >; -// Full enum for the `chat-error-kind` action output. This client only -// raises `user_not_found` and `user_ambiguous`; the rest are populated -// downstream when API errors are mapped to outputs. +// Full enum for the `chat-error-kind` action output. The action populates +// these downstream when API errors are mapped to outputs. export const ChatErrorKindSchema = z.enum([ - "user_not_found", - "user_ambiguous", "org_not_found", "spend_exceeded", "api_error", diff --git a/src/comment.test.ts b/src/comment.test.ts index 88c6c2d..5256130 100644 --- a/src/comment.test.ts +++ b/src/comment.test.ts @@ -130,29 +130,6 @@ describe("classifyError", () => { }); }); - test("maps the user-not-found error from getCoderUserByGitHubId", () => { - const err = new CoderAPIError( - "No Coder user found with GitHub user ID 12345", - 404, - undefined, - "user_not_found", - ); - const result = classifyError(err); - expect(result.kind).toBe("user_not_found"); - expect(result.message).toContain("No Coder user found"); - }); - - test("maps the multi-user error from getCoderUserByGitHubId", () => { - const err = new CoderAPIError( - "Multiple Coder users found with GitHub user ID 12345", - 409, - undefined, - "user_ambiguous", - ); - const result = classifyError(err); - expect(result.kind).toBe("user_ambiguous"); - }); - test("falls back to api_error for unknown CoderAPIError shapes", () => { const err = new CoderAPIError("Coder API error: Bad Gateway", 502); const result = classifyError(err); @@ -201,25 +178,6 @@ describe("classifyError", () => { expect(result.kind).toBe("api_error"); expect(result.message).toBe("connection refused"); }); - - // errorCode takes precedence over the spend-exceeded body shape so the - // classifier never silently misclassifies a user-lookup error that - // happens to ride a 409 with a spend-shaped body. - test("errorCode takes precedence over a spend-shaped 409 body", () => { - const err = new CoderAPIError( - "Multiple Coder users found with GitHub user ID 12345", - 409, - JSON.stringify({ - message: "unrelated", - spent_micros: 1, - limit_micros: 2, - resets_at: "", - }), - "user_ambiguous", - ); - const result = classifyError(err); - expect(result.kind).toBe("user_ambiguous"); - }); }); describe("buildFailureCommentBody", () => { @@ -242,29 +200,6 @@ describe("buildFailureCommentBody", () => { expect(body.endsWith(marker)).toBe(true); }); - test("user_not_found body names both identity inputs and ends with marker", () => { - const detail: FailureDetail = { - kind: "user_not_found", - message: "No Coder user found with GitHub user ID 12345", - }; - const body = buildFailureCommentBody(detail, { agentsUrl, marker }); - expect(body).toContain("chat-error-kind=user_not_found"); - expect(body).toContain("acting-github-user-id"); - expect(body).toContain("acting-coder-username"); - expect(body.endsWith(marker)).toBe(true); - }); - - test("user_ambiguous body suggests acting-coder-username and ends with marker", () => { - const detail: FailureDetail = { - kind: "user_ambiguous", - message: "Multiple Coder users found with GitHub user ID 12345", - }; - const body = buildFailureCommentBody(detail, { agentsUrl, marker }); - expect(body).toContain("chat-error-kind=user_ambiguous"); - expect(body).toContain("acting-coder-username"); - expect(body.endsWith(marker)).toBe(true); - }); - test("api_error body includes the underlying message and ends with marker", () => { const detail: FailureDetail = { kind: "api_error", diff --git a/src/comment.ts b/src/comment.ts index e4fd35a..139fffe 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -72,12 +72,7 @@ export type FailureDetail = resetsAt: string; } | { - kind: - | "user_not_found" - | "user_ambiguous" - | "org_not_found" - | "api_error" - | "timeout"; + kind: "org_not_found" | "api_error" | "timeout"; message: string; }; @@ -113,16 +108,16 @@ export function deriveCommentKey( if (inputs.idempotencyKey) { return sanitizeLabelKey(inputs.idempotencyKey); } - const match = inputs.githubURL.match(GITHUB_URL_REGEX); + const parsed = parseGithubItemURL(inputs.githubURL); let base: string; - if (!match) { + if (!parsed) { // The action validates githubURL upstream; if we get here the input is // malformed and the failure-path comment cannot find a stable target. // Fall back to the URL itself so re-runs at least collapse on identical // URLs, even if the marker is uglier. base = inputs.githubURL; } else { - base = `${match[1]}/${match[2]}#${match[3]}`; + base = `${parsed.owner}/${parsed.repo}#${parsed.number}`; } if (inputs.workflow) { return `${base}:${inputs.workflow}`; @@ -132,10 +127,7 @@ export function deriveCommentKey( // Map a thrown error to a FailureDetail. // -// Classification keys on explicit signals so a message reword cannot demote -// a kind to `api_error`: -// - `kind` on CoderAPIError (set by the client) marks user-lookup -// failures. +// Classification: // - 409 with the spend-exceeded body shape (`spent_micros`, `limit_micros`, // `resets_at`) becomes `spend_exceeded`. // - Anything else becomes `api_error`. The message is the body's `message` @@ -143,12 +135,6 @@ export function deriveCommentKey( // falls back to `err.message` only when the body is empty. export function classifyError(err: unknown): FailureDetail { if (err instanceof CoderAPIError) { - // Check the explicit error-code discriminator first so a client error - // can never be misclassified by an unrelated 409 body shape. - const code = mapErrorCodeToKind(err.kind); - if (code) { - return { kind: code, message: err.message }; - } const spend = parseSpendExceededBody(err.response); if (err.statusCode === 409 && spend) { return { @@ -170,18 +156,6 @@ export function classifyError(err: unknown): FailureDetail { return { kind: "api_error", message: String(err) }; } -function mapErrorCodeToKind( - code: ChatErrorKind | undefined, -): "user_not_found" | "user_ambiguous" | undefined { - switch (code) { - case "user_not_found": - case "user_ambiguous": - return code; - default: - return undefined; - } -} - interface SpendExceededFields { message: string; spentMicros: number; @@ -316,31 +290,6 @@ export function buildFailureCommentBody( } lines.push("", linkLine); break; - case "user_not_found": - lines.push( - "No Coder user could be resolved for this run. Adjust either " + - "the `acting-github-user-id` input (the GitHub identity is not " + - "linked to a Coder user) or pass `acting-coder-username` directly.", - "", - `- chat-error-kind=${detail.kind}`, - renderDetailBlock(detail.message), - "", - linkLine, - ); - break; - case "user_ambiguous": - lines.push( - "Multiple Coder users matched the GitHub identity. Set the " + - "`acting-coder-username` input to the specific account this " + - "workflow should use as the acting user (for org pick and the " + - "per-user reuse label).", - "", - `- chat-error-kind=${detail.kind}`, - renderDetailBlock(detail.message), - "", - linkLine, - ); - break; case "org_not_found": lines.push( "The resolved Coder user has no matching organization. Set the " + diff --git a/src/schemas.test.ts b/src/schemas.test.ts index 10a2ddb..d144bfc 100644 --- a/src/schemas.test.ts +++ b/src/schemas.test.ts @@ -150,6 +150,28 @@ describe("ActionInputsSchema", () => { }; expect(() => ActionInputsSchema.parse(input)).toThrow(); }); + + test("rejects existing-chat-id combined with force-new-chat", () => { + const result = ActionInputsSchema.safeParse({ + ...actionInputValid, + existingChatId: "550e8400-e29b-41d4-a716-446655440000", + forceNewChat: true, + }); + expect(result.success).toBe(false); + if (result.success) { + return; + } + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ["forceNewChat"], + message: expect.stringMatching( + /existing-chat-id and force-new-chat/, + ), + }), + ]), + ); + }); }); describe("Wait mode", () => { diff --git a/src/schemas.ts b/src/schemas.ts index 982efd4..976f2eb 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -39,8 +39,6 @@ export type ActionInputs = z.infer; // branch on this enum without parsing the human-readable message. export const ChatErrorKindSchema = z.enum([ "spend_exceeded", - "user_not_found", - "user_ambiguous", "org_not_found", "api_error", "timeout", From 08a6f088e84a45b8290d2b279cb3848217a0e9b9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 18 May 2026 12:05:57 +0000 Subject: [PATCH 08/11] refactor(src): rename trust-gate function, drop dead exports/params, scrub stale doc language - Rename classifyAutoResolveTrust to classifyTriggerTrust; the action no longer auto-resolves anything. Update the four doc paragraphs that referenced the dead model, the fork error string, the call site, and the README reference. Replace 'resolved Coder user'/'resolved user' framing in comment.ts, action.yaml, action.ts JSDoc, and four action.test.ts sites; nothing resolves now, the chat owner is the token holder. - Rename sanitizeLabelKey to sanitizeLabelToken (file moved with git mv). The platform regex applies to label keys and values; one call site uses the result as a label value, the other as a comment-marker key. - Delete RESERVED_LABEL_KEYS export and its tautological tests. With the fixed-key idempotency scheme a value can never overwrite an action-owned key, so the runtime guard was already gone. - Delete the kind discriminator on CoderAPIError; classifyError no longer reads it and no caller sets it. - Extract DETAIL_BLOCK_MAX_CHARS for the failure-block 4000-char cap; function body and test both reference the named constant. - Unexport GITHUB_URL_REGEX and rewrite the docstring; the regex is internal to parseGithubItemURL now, not consumed by deriveCommentKey or any other caller. - Add two trust-gate tests: head.repo === null (deleted fork) and head.repo.full_name diverging from base.repo.full_name with fork === false. Both branches were code-only before. - Drop the placeholder src/index.test.ts; bun test discovery does not need a stub file. --- README.md | 2 +- action.yaml | 2 +- dist/index.js | 24 ++-- src/action.test.ts | 106 ++++++++++++++---- src/action.ts | 48 ++++---- src/coder-client.ts | 9 +- src/comment.test.ts | 9 +- src/comment.ts | 24 ++-- src/index.test.ts | 9 -- src/sanitize-label-key.test.ts | 57 ---------- src/sanitize-label-token.test.ts | 30 +++++ ...e-label-key.ts => sanitize-label-token.ts} | 18 +-- 12 files changed, 176 insertions(+), 162 deletions(-) delete mode 100644 src/index.test.ts delete mode 100644 src/sanitize-label-key.test.ts create mode 100644 src/sanitize-label-token.test.ts rename src/{sanitize-label-key.ts => sanitize-label-token.ts} (58%) diff --git a/README.md b/README.md index 065129e..43cad40 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ Branch on the kind without parsing the message: ### Trust gate is fail-closed; no input bypass -Before every chat creation, the action calls `classifyAutoResolveTrust` on the GitHub event payload and refuses untrusted triggers: +Before every chat creation, the action calls `classifyTriggerTrust` on the GitHub event payload and refuses untrusted triggers: - Fork pull requests (`head.repo` null, `head.repo.fork === true`, or `head.repo.full_name !== base.repo.full_name`). - Comment or review events whose `comment.author_association` or `review.author_association` is not `OWNER`, `MEMBER`, or `COLLABORATOR`. diff --git a/action.yaml b/action.yaml index 748f7de..8642a29 100644 --- a/action.yaml +++ b/action.yaml @@ -28,7 +28,7 @@ inputs: required: true coder-organization: - description: "Coder organization name. Looked up by name to resolve the organization UUID for chat creation. Recommended when the resolved Coder user belongs to more than one organization, since the fallback choice is non-deterministic." + description: "Coder organization name. Looked up by name to resolve the organization UUID for chat creation. Recommended when the Coder user belongs to more than one organization, since the fallback choice is non-deterministic." required: false workspace-id: diff --git a/dist/index.js b/dist/index.js index b091cb9..30abc74 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26885,25 +26885,22 @@ var ChatErrorKindSchema = exports_external.enum([ class CoderAPIError extends Error { statusCode; response; - kind; - constructor(message, statusCode, response, kind) { + constructor(message, statusCode, response) { super(message); this.statusCode = statusCode; this.response = response; - this.kind = kind; this.name = "CoderAPIError"; } } -// src/sanitize-label-key.ts +// src/sanitize-label-token.ts var ACTION_LABEL_KEYS = { marker: "coder-agents-chat-action", target: "gh-target", workflow: "coder-agents-chat-action-workflow", idempotency: "coder-agents-chat-action-idempotency" }; -var RESERVED_LABEL_KEYS = new Set(Object.values(ACTION_LABEL_KEYS)); -function sanitizeLabelKey(input) { +function sanitizeLabelToken(input) { const lowered = input.toLowerCase(); const replaced = lowered.replace(/[^a-z0-9._/-]/g, "-"); const trimmed = replaced.replace(/^[^a-z0-9]+/, ""); @@ -26935,7 +26932,7 @@ function buildCommentMarker(key) { } function deriveCommentKey(inputs) { if (inputs.idempotencyKey) { - return sanitizeLabelKey(inputs.idempotencyKey); + return sanitizeLabelToken(inputs.idempotencyKey); } const parsed = parseGithubItemURL(inputs.githubURL); let base; @@ -27014,9 +27011,10 @@ function formatMicrosAsDollars(micros) { const dollars = micros / 1e6; return `$${dollars.toFixed(2)}`; } +var DETAIL_BLOCK_MAX_CHARS = 4000; function renderDetailBlock(message) { const stripped = message.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, ""); - const capped = stripped.length > 4000 ? stripped.slice(0, 4000) : stripped; + const capped = stripped.length > DETAIL_BLOCK_MAX_CHARS ? stripped.slice(0, DETAIL_BLOCK_MAX_CHARS) : stripped; const safe = capped.replace(/`{4,}/g, "```"); return `- Detail: \`\`\`\` @@ -27037,7 +27035,7 @@ function buildFailureCommentBody(detail, ctx) { lines.push("", linkLine); break; case "org_not_found": - lines.push("The resolved Coder user has no matching organization. Set the " + "`coder-organization` input or grant the user a membership.", "", `- chat-error-kind=${detail.kind}`, renderDetailBlock(detail.message), "", linkLine); + lines.push("The Coder user has no matching organization. Set the " + "`coder-organization` input or grant the user a membership.", "", `- chat-error-kind=${detail.kind}`, renderDetailBlock(detail.message), "", linkLine); break; case "api_error": lines.push(apiErrorPhrase(runPhase, ctx), ""); @@ -27207,7 +27205,7 @@ var TRUSTED_AUTHOR_ASSOCIATIONS = new Set([ "MEMBER", "COLLABORATOR" ]); -function classifyAutoResolveTrust(context) { +function classifyTriggerTrust(context) { const pr = context.payload.pull_request; if (pr) { const headRepo = pr.head?.repo; @@ -27218,7 +27216,7 @@ function classifyAutoResolveTrust(context) { if (isFork) { return { kind: "untrusted", - reason: "the pull request is from a fork; auto-resolve refuses to bind " + "the workflow's Coder identity to a fork-PR author" + reason: "the pull request is from a fork" }; } } @@ -27396,7 +27394,7 @@ class CoderAgentChatAction { } } assertTrustedTrigger() { - const trust = classifyAutoResolveTrust(this.context); + const trust = classifyTriggerTrust(this.context); if (trust.kind === "untrusted") { throw new Error("Refusing to act on an untrusted trigger: " + `${trust.reason}. ` + "Add an `if:` gate to the workflow step (for example, " + "`author_association` allowlist or a label allowlist on " + "`pull_request_target`) before invoking this action. See " + "the README security model for details."); } @@ -27498,7 +27496,7 @@ class CoderAgentChatAction { githubIssueNumber }); } - const sanitizedIdempotency = this.inputs.idempotencyKey ? sanitizeLabelKey(this.inputs.idempotencyKey) : undefined; + const sanitizedIdempotency = this.inputs.idempotencyKey ? sanitizeLabelToken(this.inputs.idempotencyKey) : undefined; const ghTarget = `${githubOrg}/${githubRepo}#${githubIssueNumber}`; const workflow = process.env.GITHUB_WORKFLOW || undefined; if (this.inputs.forceNewChat) { diff --git a/src/action.test.ts b/src/action.test.ts index 686f5cc..c596078 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -901,6 +901,71 @@ describe("CoderAgentChatAction", () => { expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); }); + test("refuses pull_request from a deleted fork (head.repo === null)", async () => { + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + + const inputs = createMockInputs({ commentOnIssue: false }); + const context = createMockContext({ + eventName: "pull_request", + actor: "attacker", + payload: { + sender: { id: 99999 }, + pull_request: { + // GitHub returns head.repo === null when the source fork + // repo has been deleted. With head.repo missing the + // full_name comparison can't run, so the gate must treat + // the null itself as the fork signal. + head: { repo: null }, + base: { repo: { full_name: "owner/repo" } }, + }, + }, + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + context, + ); + + await expect(action.run()).rejects.toThrow(/untrusted trigger.*fork/); + expect(coderClient.mockGetAuthenticatedUser).not.toHaveBeenCalled(); + expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); + }); + + test("refuses pull_request when head.repo.full_name diverges from base.repo.full_name, even with fork === false", async () => { + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); + coderClient.mockCreateChat.mockResolvedValue(mockChat); + + const inputs = createMockInputs({ commentOnIssue: false }); + const context = createMockContext({ + eventName: "pull_request", + actor: "attacker", + payload: { + sender: { id: 99999 }, + pull_request: { + // Same-owner branch-rename mid-PR (or any payload where + // fork=false but full_name disagrees) must still be + // refused. Pins the third condition independently from + // the fork=true short-circuit. + head: { + repo: { fork: false, full_name: "attacker/fork" }, + }, + base: { repo: { full_name: "owner/repo" } }, + }, + }, + }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + context, + ); + + await expect(action.run()).rejects.toThrow(/untrusted trigger.*fork/); + expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); + }); + test("refuses NONE-association comment events", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); @@ -978,10 +1043,8 @@ describe("CoderAgentChatAction", () => { }); test("the gate has no input bypass; idempotency-key cannot bypass it", async () => { - // Pre-rewrite, an explicit acting-coder-username or - // acting-github-user-id input bypassed the gate. Those inputs - // were dropped; no current input bypasses the gate. Setting - // every remaining input still refuses an untrusted trigger. + // No action input bypasses the gate. Set every remaining input to + // confirm an untrusted trigger still refuses. coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); @@ -2143,7 +2206,7 @@ describe("CoderAgentChatAction", () => { ); }); - test("defaults to the resolved user's first org membership when coder-organization is unset", async () => { + test("defaults to the Coder user's first org membership when coder-organization is unset", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockResolvedValue(mockChat); @@ -2192,7 +2255,7 @@ describe("CoderAgentChatAction", () => { ); }); - test("fails with chat-error-kind=org_not_found when the resolved user has no org memberships", async () => { + test("fails with chat-error-kind=org_not_found when the Coder user has no org memberships", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUserNoOrgs); const inputs = createMockInputs({ @@ -2487,8 +2550,8 @@ describe("CoderAgentChatAction", () => { | { label?: string[]; archived?: boolean } | undefined; // The per-user label is intentionally absent: all chats this - // action creates are owned by the `coder-token` holder, so - // scoping by the resolved acting user added no isolation. + // action creates are owned by the `coder-token` holder, so a + // per-actor label would have added no isolation. expect(arg?.label).toEqual([ "coder-agents-chat-action:true", "gh-target:test-org/test-repo#123", @@ -2551,8 +2614,7 @@ describe("CoderAgentChatAction", () => { | undefined; expect(req?.labels?.["coder-agents-chat-action"]).toBe("true"); expect(req?.labels?.["gh-target"]).toBe("test-org/test-repo#123"); - // No per-user label: the chat owner is the token holder, not - // the resolved acting user. + // No per-user label: the chat owner is the token holder. expect(req?.labels?.["coder-agents-chat-action-user"]).toBeUndefined(); // Workflow env unset; no workflow label and no sharding key. expect(Object.keys(req?.labels ?? {}).sort()).toEqual([ @@ -2822,12 +2884,12 @@ describe("CoderAgentChatAction", () => { }); test("the reuse scope is intentionally not partitioned by acting user", async () => { - // Per the security-driven simplification, all chats this action - // creates are owned by the `coder-token` holder. The reuse scope - // does not include a per-actor label; workflows that want - // per-actor separation set `idempotency-key: ${{ github.actor }}` - // themselves. This test pins the absence of the per-user label so - // a regression that re-introduces it is caught. + // All chats this action creates are owned by the `coder-token` + // holder, so the reuse scope does not include a per-actor label; + // workflows that want per-actor separation set + // `idempotency-key: ${{ github.actor }}` themselves. This test + // pins the absence of the per-user label so a regression that + // re-introduces it is caught. coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([]); coderClient.mockCreateChat.mockResolvedValue(mockChat); @@ -2890,12 +2952,12 @@ describe("CoderAgentChatAction", () => { ).toBe("my-custom-key-"); }); - test("reserved-key collision is no longer possible with the fixed-key scheme", async () => { - // Pre-rewrite, a sanitized `idempotency-key` value was used as a - // label KEY and could collide with action-owned keys. The fixed - // key (`coder-agents-chat-action-idempotency`) makes the value - // always a value, so even an idempotency-key of `gh-target` now - // just sets `coder-agents-chat-action-idempotency: gh-target`. + test("idempotency-key cannot overwrite an action-owned label", async () => { + // The sanitized idempotency-key is the VALUE of the fixed + // `coder-agents-chat-action-idempotency` key, so even an + // `idempotency-key` of `gh-target` cannot displace the + // `gh-target` key; it just sets + // `coder-agents-chat-action-idempotency: gh-target`. coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([]); coderClient.mockCreateChat.mockResolvedValue(mockChat); diff --git a/src/action.ts b/src/action.ts index a137846..fffe0f8 100644 --- a/src/action.ts +++ b/src/action.ts @@ -9,7 +9,7 @@ import type { CoderSDKUser, CreateChatRequest, } from "./coder-client"; -import { ACTION_LABEL_KEYS, sanitizeLabelKey } from "./sanitize-label-key"; +import { ACTION_LABEL_KEYS, sanitizeLabelToken } from "./sanitize-label-token"; import { buildCommentMarker, buildDeploymentAgentsUrl, @@ -92,11 +92,11 @@ export class ActionFailureError extends Error { } /** - * GitHub `author_association` values that map to repository write access in - * the action's auto-resolve trust model. `OWNER` and `MEMBER` cover org - * and personal-repo owners; `COLLABORATOR` covers invited collaborators. - * Any other association (including `CONTRIBUTOR`, `FIRST_TIMER`, - * `FIRST_TIME_CONTRIBUTOR`, `MANNEQUIN`, `NONE`) is treated as untrusted. + * GitHub `author_association` values that map to repository write access for + * the trust gate. `OWNER` and `MEMBER` cover org and personal-repo owners; + * `COLLABORATOR` covers invited collaborators. Any other association + * (including `CONTRIBUTOR`, `FIRST_TIMER`, `FIRST_TIME_CONTRIBUTOR`, + * `MANNEQUIN`, `NONE`) is treated as untrusted. * * See: https://docs.github.com/en/graphql/reference/enums#commentauthorassociation */ @@ -111,7 +111,7 @@ const TRUSTED_AUTHOR_ASSOCIATIONS = new Set([ * action reads. Production callers pass `github.context`; tests build * fixtures via `createMockContext`. * - * The auto-resolve trust gate (`classifyAutoResolveTrust`) reads + * The trust gate (`classifyTriggerTrust`) reads * `pull_request.head.repo` / `pull_request.base.repo` for fork detection, * and `comment.author_association` / `review.author_association` as the * sender-reliable trust signals. `issue.author_association` and @@ -165,10 +165,10 @@ export interface ActionContext { } /** - * Outcome of the auto-resolve trust gate. `trusted` means the gate found a - * repository-write-level signal and auto-resolve may proceed. `untrusted` + * Outcome of the trust gate. `trusted` means the gate found a + * repository-write-level signal and the action may proceed. `untrusted` * means the gate found a signal that fails the bar (fork PR, low-trust - * association) and auto-resolve must refuse. `no-signal` means the + * association) and the action must refuse. `no-signal` means the * payload carried nothing the gate can act on, so the gate defers to * GitHub's underlying event-permission model (secret access, branch * protection, etc.). @@ -179,17 +179,15 @@ type TrustClassification = | { kind: "no-signal" }; /** - * Classify whether the triggering identity from `context` is trusted for - * auto-resolve. + * Classify whether the event in `context` is trusted to run the action. * * Two layers of signal, applied in order: * - * 1. Fork pull requests always refuse. An attacker who opens a PR from a - * fork must not be able to bind the workflow's Coder token to their - * own Coder identity (if they happen to have one) and execute - * attacker-controlled prompts. A `null` `head.repo` (deleted fork) is - * also treated as a fork: the only way `head.repo` becomes null is - * when the fork's source repository was deleted, which collapses the + * 1. Fork pull requests always refuse. The workflow's `coder-token` is a + * secret; a fork PR is attacker-controlled content and must not + * execute under it. A `null` `head.repo` (deleted fork) is also + * treated as a fork: the only way `head.repo` becomes null is when + * the fork's source repository was deleted, which collapses the * same-repo check below into a false negative. * * 2. `author_association` on `comment` or `review`, in that order. These @@ -209,7 +207,7 @@ type TrustClassification = * can trigger them. The trust gate is layered on top of, not in place * of, those controls. */ -function classifyAutoResolveTrust(context: ActionContext): TrustClassification { +function classifyTriggerTrust(context: ActionContext): TrustClassification { const pr = context.payload.pull_request; if (pr) { const headRepo = pr.head?.repo; @@ -225,9 +223,7 @@ function classifyAutoResolveTrust(context: ActionContext): TrustClassification { if (isFork) { return { kind: "untrusted", - reason: - "the pull request is from a fork; auto-resolve refuses to bind " + - "the workflow's Coder identity to a fork-PR author", + reason: "the pull request is from a fork", }; } } @@ -580,7 +576,7 @@ export class CoderAgentChatAction { * branch fired. */ assertTrustedTrigger(): void { - const trust = classifyAutoResolveTrust(this.context); + const trust = classifyTriggerTrust(this.context); if (trust.kind === "untrusted") { throw new Error( "Refusing to act on an untrusted trigger: " + @@ -613,12 +609,12 @@ export class CoderAgentChatAction { * `GET /api/v2/organizations/{name}`. Recommended when the user * belongs to more than one organization, since the fallback choice * is non-deterministic; a `core.warning` is emitted in that case. - * 2. The resolved Coder user's `organization_ids[0]`. The action calls + * 2. The Coder user's `organization_ids[0]`. The action calls * `users/me` once in `runInner` and threads the result here, so this * helper never refetches. * * Throws `ActionFailureError("org_not_found")` when `coder-organization` - * names an org that does not exist (HTTP 404) or the resolved user has + * names an org that does not exist (HTTP 404) or the Coder user has * no org memberships. Other API errors propagate as `CoderAPIError`. The * original error is attached via `options.cause` on every wrap; * `run()`'s `handleFailure` re-classifies the failure into the @@ -811,7 +807,7 @@ export class CoderAgentChatAction { // distinct chats. Workflows that want per-actor separation can // set `idempotency-key: ${{ github.actor }}` themselves. const sanitizedIdempotency = this.inputs.idempotencyKey - ? sanitizeLabelKey(this.inputs.idempotencyKey) + ? sanitizeLabelToken(this.inputs.idempotencyKey) : undefined; const ghTarget = `${githubOrg}/${githubRepo}#${githubIssueNumber}`; const workflow = process.env.GITHUB_WORKFLOW || undefined; diff --git a/src/coder-client.ts b/src/coder-client.ts index 552c4eb..6a6436b 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -296,18 +296,15 @@ export type ChatErrorKind = z.infer; /** * CoderAPIError carries the status code and raw response body from a Coder - * API failure plus an optional `kind` discriminator. The kind is the - * structural link from this client to the failure-path classifier in - * comment.ts: classifying on `kind` rather than `err.message` regex - * means a string reword in the error message cannot silently degrade the - * `chat-error-kind` output to `api_error`. + * API failure. The body is preserved verbatim so the failure-path + * classifier in `comment.ts` can pattern-match structured shapes (e.g. + * the spend-exceeded 409) without rerunning the request. */ export class CoderAPIError extends Error { constructor( message: string, public readonly statusCode: number, public readonly response?: unknown, - public readonly kind?: ChatErrorKind, ) { super(message); this.name = "CoderAPIError"; diff --git a/src/comment.test.ts b/src/comment.test.ts index 5256130..8c0f864 100644 --- a/src/comment.test.ts +++ b/src/comment.test.ts @@ -8,6 +8,7 @@ import { type ChatErrorKind, classifyError, deriveCommentKey, + DETAIL_BLOCK_MAX_CHARS, type FailureDetail, findCommentByPredicate, normalizeBaseUrl, @@ -395,11 +396,11 @@ describe("renderDetailBlock", () => { expect(body).toBe("- Detail:\n````\nabc\nd\te\n````"); }); - test("caps the message at 4000 characters", () => { - const body = renderDetailBlock("x".repeat(10000)); - // Header + fence open + 4000 chars + fence close + 3 newlines. + test("caps the message at DETAIL_BLOCK_MAX_CHARS", () => { + const body = renderDetailBlock("x".repeat(DETAIL_BLOCK_MAX_CHARS * 2)); + // Header + fence open + cap + fence close + 3 newlines. expect(body.length).toBe( - "- Detail:\n````\n".length + 4000 + "\n````".length, + "- Detail:\n````\n".length + DETAIL_BLOCK_MAX_CHARS + "\n````".length, ); }); }); diff --git a/src/comment.ts b/src/comment.ts index 139fffe..ca7fcfd 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -5,7 +5,7 @@ import { type ChatStatus, CoderAPIError, } from "./coder-client"; -import { sanitizeLabelKey } from "./sanitize-label-key"; +import { sanitizeLabelToken } from "./sanitize-label-token"; import type { ActionInputs } from "./schemas"; import { normalizeBaseUrl } from "./url"; @@ -14,15 +14,14 @@ export { normalizeBaseUrl } from "./url"; type Octokit = ReturnType; -// Shared regex for GitHub issue and PR URLs. Used by `deriveCommentKey` and -// `parseGithubURL` so adding another path (e.g. `/discussions/`) is one edit. -// Anchored at both ends so a non-github host or extra path segments +// Anchored regex for a GitHub issue or PR URL on `github.com`. Anchored +// at both ends so a non-github host or extra path segments // (e.g. `.../issues/123/files`, `https://attacker.example/owner/repo/issues/1`) // are rejected rather than silently truncated. The `(?:[?#].*)?` group keeps // the anchor tolerant of query strings and fragments that real-world // `github-url` inputs can carry (e.g. a URL copied while viewing a specific // comment). -export const GITHUB_URL_REGEX = +const GITHUB_URL_REGEX = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)\/?(?:[?#].*)?$/; /** @@ -106,7 +105,7 @@ export function deriveCommentKey( }, ): string { if (inputs.idempotencyKey) { - return sanitizeLabelKey(inputs.idempotencyKey); + return sanitizeLabelToken(inputs.idempotencyKey); } const parsed = parseGithubItemURL(inputs.githubURL); let base: string; @@ -229,16 +228,21 @@ function formatMicrosAsDollars(micros: number): string { * 4-or-more backtick run in the message is downgraded to 3 so the * surrounding fence stays closable. Control bytes other than newline and * tab are stripped so adversarial content cannot inject CR-only line - * endings or other terminal escapes. The result is capped at 4000 chars - * to bound comment size against runaway messages. + * endings or other terminal escapes. The result is capped at + * `DETAIL_BLOCK_MAX_CHARS` to bound comment size against runaway messages. */ +export const DETAIL_BLOCK_MAX_CHARS = 4000; + export function renderDetailBlock(message: string): string { const stripped = message.replace( // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping C0 controls is the point. /[\x00-\x08\x0B-\x1F\x7F]/g, "", ); - const capped = stripped.length > 4000 ? stripped.slice(0, 4000) : stripped; + const capped = + stripped.length > DETAIL_BLOCK_MAX_CHARS + ? stripped.slice(0, DETAIL_BLOCK_MAX_CHARS) + : stripped; const safe = capped.replace(/`{4,}/g, "```"); return `- Detail:\n\`\`\`\`\n${safe}\n\`\`\`\``; } @@ -292,7 +296,7 @@ export function buildFailureCommentBody( break; case "org_not_found": lines.push( - "The resolved Coder user has no matching organization. Set the " + + "The Coder user has no matching organization. Set the " + "`coder-organization` input or grant the user a membership.", "", `- chat-error-kind=${detail.kind}`, diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index cd22596..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -// The acting-github-user-id input was dropped in the security-driven -// simplification; `parseGithubUserID` no longer exists. This file is -// retained as a placeholder so the test discovery glob still finds it -// without complaining about an empty module. -import { describe, test } from "bun:test"; - -describe("index", () => { - test("placeholder", () => {}); -}); diff --git a/src/sanitize-label-key.test.ts b/src/sanitize-label-key.test.ts deleted file mode 100644 index 024280d..0000000 --- a/src/sanitize-label-key.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - ACTION_LABEL_KEYS, - RESERVED_LABEL_KEYS, - sanitizeLabelKey, -} from "./sanitize-label-key"; - -describe("sanitizeLabelKey", () => { - test("lowercases and replaces disallowed characters with '-'", () => { - expect(sanitizeLabelKey("My Custom Key!")).toBe("my-custom-key-"); - }); - - test("preserves the four punctuation classes the platform allows", () => { - expect(sanitizeLabelKey("a.b_c/d-e")).toBe("a.b_c/d-e"); - }); - - test("falls back to 'key' when the input sanitizes to empty", () => { - expect(sanitizeLabelKey("!@#$%")).toBe("key"); - expect(sanitizeLabelKey("")).toBe("key"); - }); - - test("trims leading non-alphanumeric characters before returning", () => { - expect(sanitizeLabelKey(".foo")).toBe("foo"); - expect(sanitizeLabelKey("---bar")).toBe("bar"); - expect(sanitizeLabelKey("/baz")).toBe("baz"); - }); - - test("truncates to 64 bytes", () => { - const seventy = "a".repeat(70); - const result = sanitizeLabelKey(seventy); - expect(result).toHaveLength(64); - expect(result).toBe("a".repeat(64)); - }); -}); - -describe("RESERVED_LABEL_KEYS", () => { - test("includes the per-workflow scope key that prevents reuse-scope hijack", () => { - // Without this entry, a sanitized idempotency-key value of - // "coder-agents-chat-action-workflow" would silently overwrite the - // per-workflow label and break per-workflow reuse isolation. - expect(RESERVED_LABEL_KEYS.has("coder-agents-chat-action-workflow")).toBe( - true, - ); - }); - - test("is derived from ACTION_LABEL_KEYS so neither table can drift", () => { - // `findReuseMatch` and `buildChatLabels` both reference - // `ACTION_LABEL_KEYS` for the action-owned label keys; the reserved - // set must reserve every one of them. If a developer adds an entry - // to `ACTION_LABEL_KEYS` without updating the reserved set, an - // `idempotency-key` value matching the new key could silently - // overwrite it. - for (const key of Object.values(ACTION_LABEL_KEYS)) { - expect(RESERVED_LABEL_KEYS.has(key)).toBe(true); - } - }); -}); diff --git a/src/sanitize-label-token.test.ts b/src/sanitize-label-token.test.ts new file mode 100644 index 0000000..944032b --- /dev/null +++ b/src/sanitize-label-token.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { sanitizeLabelToken } from "./sanitize-label-token"; + +describe("sanitizeLabelToken", () => { + test("lowercases and replaces disallowed characters with '-'", () => { + expect(sanitizeLabelToken("My Custom Key!")).toBe("my-custom-key-"); + }); + + test("preserves the four punctuation classes the platform allows", () => { + expect(sanitizeLabelToken("a.b_c/d-e")).toBe("a.b_c/d-e"); + }); + + test("falls back to 'key' when the input sanitizes to empty", () => { + expect(sanitizeLabelToken("!@#$%")).toBe("key"); + expect(sanitizeLabelToken("")).toBe("key"); + }); + + test("trims leading non-alphanumeric characters before returning", () => { + expect(sanitizeLabelToken(".foo")).toBe("foo"); + expect(sanitizeLabelToken("---bar")).toBe("bar"); + expect(sanitizeLabelToken("/baz")).toBe("baz"); + }); + + test("truncates to 64 bytes", () => { + const seventy = "a".repeat(70); + const result = sanitizeLabelToken(seventy); + expect(result).toHaveLength(64); + expect(result).toBe("a".repeat(64)); + }); +}); diff --git a/src/sanitize-label-key.ts b/src/sanitize-label-token.ts similarity index 58% rename from src/sanitize-label-key.ts rename to src/sanitize-label-token.ts index b836ed2..85079d5 100644 --- a/src/sanitize-label-key.ts +++ b/src/sanitize-label-token.ts @@ -13,20 +13,12 @@ export const ACTION_LABEL_KEYS = { } as const; /** - * Reserved label keys on chats this action creates. A sanitized - * `idempotency-key` matching one of these is rejected upstream so the - * user input cannot overwrite an action-owned label. + * Coerce an arbitrary string into a chat-label token the platform accepts. + * The platform applies the same regex to label keys and label values: + * `^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`, max 64 bytes. Empty results fall back + * to `"key"`. */ -export const RESERVED_LABEL_KEYS: ReadonlySet = new Set( - Object.values(ACTION_LABEL_KEYS), -); - -/** - * Coerce an arbitrary string into a chat-label key the platform accepts. - * Platform regex: `^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`, max 64 bytes. Empty - * results fall back to `"key"`. - */ -export function sanitizeLabelKey(input: string): string { +export function sanitizeLabelToken(input: string): string { const lowered = input.toLowerCase(); const replaced = lowered.replace(/[^a-z0-9._/-]/g, "-"); const trimmed = replaced.replace(/^[^a-z0-9]+/, ""); From 753feae428fd19f986f5dad16231c2527dae6121 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 18 May 2026 12:28:10 +0000 Subject: [PATCH 09/11] docs(src): strip internal review-finding identifiers from comments and docs Drop F1, F6, F9 (security-review tags), DEREM-2, and CODAGT-290 references from doc comments, test descriptions/comments, and a README heading. The technical justification stays; the internal-only identifiers do not survive on main. --- README.md | 2 +- src/action.test.ts | 27 +++++++++++++-------------- src/action.ts | 4 ++-- src/comment.test.ts | 14 +++++++------- src/comment.ts | 3 +-- 5 files changed, 24 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 43cad40..1fe14b3 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ There is no input bypass: dropping the previous `acting-*` overrides was deliber The gate does not read `issue.author_association` or `pull_request.author_association` because those describe the resource opener, not the event sender (a `MEMBER` labeling a `NONE` user's issue is fine). -### Indirect prompt injection (F1) +### Indirect prompt injection The agent reads attacker-authored content during its run: PR titles, PR bodies, issue comments, diffs, and anything else the prompt tells it to fetch (`gh pr view`, `gh issue view --comments`, `gh pr diff`). The agent is a language model; it will follow embedded instructions in that content if they look plausible. Treat any public-repo trigger as adversarial regardless of the trust gate's verdict, because the gate decides whether to create the chat but does not constrain what the chat reads once it runs. diff --git a/src/action.test.ts b/src/action.test.ts index c596078..6226510 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -180,13 +180,13 @@ describe("CoderAgentChatAction", () => { }); }); - test("rejects non-github.com hostnames (F6)", () => { - // F6 in the security review: the regex used to accept any host - // because it was end-anchored only. Coercing the action to - // comment on `https://attacker.example/coder/coder/issues/1` - // would have called `octokit.rest.issues.createComment` with - // owner=coder, repo=coder, number=1 under the workflow's - // `github-token`. The action now refuses. + test("rejects non-github.com hostnames", () => { + // The regex used to accept any host because it was end-anchored + // only. Coercing the action to comment on + // `https://attacker.example/coder/coder/issues/1` would have + // called `octokit.rest.issues.createComment` with owner=coder, + // repo=coder, number=1 under the workflow's `github-token`. The + // action now refuses. const inputs = createMockInputs({ githubURL: "https://code.acme.com/owner/repo/issues/123", }); @@ -1263,8 +1263,8 @@ describe("CoderAgentChatAction", () => { caught = err; } - // CODAGT-290 will refine last_error mapping; until then, - // every error terminal surfaces as api_error. + // Until last_error mapping is refined, every error terminal + // surfaces as api_error. expect(caught).toBeInstanceOf(ActionFailureError); const err = caught as ActionFailureError; expect(err.kind).toBe("api_error"); @@ -2679,11 +2679,10 @@ describe("CoderAgentChatAction", () => { }); test("default + match + wait=complete: polls until terminal status (no silent skip)", async () => { - // Regression test for DEREM-2: the reuse follow-up path must - // honor wait=complete the same way the existing-chat-id path - // does. A reuse-path follow-up to a chat already in a terminal - // status would otherwise return on the pre-message snapshot - // before the agent transitions. + // The reuse follow-up path must honor wait=complete the same way + // the existing-chat-id path does. A reuse-path follow-up to a + // chat already in a terminal status would otherwise return on + // the pre-message snapshot before the agent transitions. coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockListChats.mockResolvedValue([ { ...mockChat, archived: false, status: "waiting" }, diff --git a/src/action.ts b/src/action.ts index fffe0f8..936da19 100644 --- a/src/action.ts +++ b/src/action.ts @@ -512,7 +512,7 @@ export class CoderAgentChatAction { * Throw when a terminal chat ended in `error`; pass `waiting` and * `completed` through unchanged. The `api_error` kind is coarse: * a workflow branching on it cannot distinguish chat-level failures - * from polling-transport failures. CODAGT-290 will refine the + * from polling-transport failures. Future work may refine the * mapping by inspecting `last_error`. */ private throwOnChatError(chat: CoderChat): CoderChat { @@ -757,7 +757,7 @@ export class CoderAgentChatAction { this.warnUnwiredInputs(); // Validate github-url and run the trust gate before any Coder API - // call. parseGithubURL rejects non-github.com hosts (F6); the trust + // call. parseGithubURL rejects non-github.com hosts; the trust // gate fails closed on fork PRs and untrusted comment/review // senders. Both are pre-`createChat` checkpoints. const { githubOrg, githubRepo, githubIssueNumber } = this.parseGithubURL(); diff --git a/src/comment.test.ts b/src/comment.test.ts index 8c0f864..4f80fd3 100644 --- a/src/comment.test.ts +++ b/src/comment.test.ts @@ -66,10 +66,10 @@ describe("deriveCommentKey", () => { }); test("falls back to the raw URL for non-github.com hosts (host validation)", () => { - // F6 in the security review: the regex now anchors to github.com - // so an enterprise host (or attacker-chosen host) does not parse - // out a usable owner/repo. The marker still collapses identical - // URLs across re-runs, but does not pretend to know the target. + // The regex anchors to github.com so an enterprise host (or + // attacker-chosen host) does not parse out a usable owner/repo. + // The marker still collapses identical URLs across re-runs, but + // does not pretend to know the target. expect( deriveCommentKey({ githubURL: "https://code.acme.com/owner/repo/issues/42", @@ -368,9 +368,9 @@ describe("buildDeploymentAgentsUrl", () => { describe("renderDetailBlock", () => { test("wraps a plain message in a 4-backtick fenced block", () => { - // F9 in the security review: attacker-influenced strings flowing - // through `detail.message` must not break out of the markdown - // list context. The body now renders inside a 4-backtick fence. + // Attacker-influenced strings flowing through `detail.message` + // must not break out of the markdown list context. The body now + // renders inside a 4-backtick fence. const body = renderDetailBlock("plain message"); expect(body).toBe("- Detail:\n````\nplain message\n````"); }); diff --git a/src/comment.ts b/src/comment.ts index ca7fcfd..76d8046 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -39,8 +39,7 @@ export interface GithubItemURL { * URL and return its components, or `undefined` if it does not match. The * host is anchored to `github.com` so a workflow that templates user- * controlled content into `github-url` cannot coerce the action into - * commenting on an arbitrary attacker-chosen owner/repo (F6 in the - * security review). + * commenting on an arbitrary attacker-chosen owner/repo. */ export function parseGithubItemURL( input: string | undefined, From 92f67b2c27479fe0bf4feeeddd46659bc5e11233 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 18 May 2026 14:00:16 +0000 Subject: [PATCH 10/11] refactor!: drop trust gate; workflow author owns trigger policy The action no longer classifies trigger trust. GitHub's secrets-on-fork rule already gates the load-bearing case for `pull_request`; workflows that opt into broader trigger surfaces (`pull_request_target`, `issue_comment`, `pull_request_review`, `pull_request_review_comment`) restate policy with `if:` themselves. Empirical investigation showed the gate never reached community fork PRs in practice. - Delete classifyTriggerTrust, TrustClassification, TRUSTED_AUTHOR_ ASSOCIATIONS, and ActionContext from src/action.ts. - Delete assertTrustedTrigger and its runInner call site. - Drop the context parameter from CoderAgentChatAction's constructor; every test call site loses its 4th positional arg. - Delete the Trust gate describe block from src/action.test.ts (seven tests including DEREM-26 and DEREM-27). - Delete createMockContext and its ActionContext import from src/test-helpers.ts. - Rewrite the README "Security model" section as three subsections: Chat ownership, Trigger gating (with three `if:` patterns and a link to GitHub's events doc), Indirect prompt injection. Drop stale "trust gate" references in Quickstart, the doc-check recipe, and the troubleshooting table. --- README.md | 47 ++++--- dist/index.js | 59 +-------- src/action.test.ts | 305 -------------------------------------------- src/action.ts | 214 ------------------------------- src/index.ts | 7 +- src/test-helpers.ts | 16 --- 6 files changed, 31 insertions(+), 617 deletions(-) diff --git a/README.md b/README.md index 1fe14b3..dcfac39 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ jobs: github-token: ${{ github.token }} ``` -The chat runs as whoever the `coder-token` belongs to; that identity is the only one the chats API supports. Workflows that target events that route to this action without `secrets.CODER_TOKEN` redaction (`issue_comment`, `pull_request_target`, etc.) must add their own `if:` gate; see [Security model](#security-model). +The chat runs as whoever the `coder-token` belongs to; that identity is the only one the chats API supports. Workflows that gate triggers loosely (`issue_comment`, `pull_request_target`, etc.) own the trust decision via an `if:` filter; see [Security model](#security-model). ## Inputs @@ -149,10 +149,10 @@ permissions: jobs: doc-check: # Internal PRs only. `pull_request_target` exposes `secrets.*` to fork - # PRs by design, so the workflow must filter trust before invoking - # this action. Swap to a label-allowlist `if:` (for example, + # PRs, so the workflow filters trust before invoking the action. Swap + # to a label-allowlist `if:` (for example, # `contains(github.event.pull_request.labels.*.name, 'safe-to-review')`) - # if you want to gate via maintainer-applied labels instead. + # to gate on a maintainer-applied label instead. if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: @@ -169,7 +169,7 @@ jobs: wait: complete ``` -`pull_request_target` runs against the base repo and has access to secrets even for fork PRs. The action's trust gate refuses fork PRs anyway, but the workflow-level `if:` is the right place to make the trust decision because it short-circuits before the runner starts the step. The chat is owned by the `coder-token` holder; the prompt is benign, but the agent will read PR content with its tools (see [Security model](#security-model)). +`pull_request_target` runs against the base repo and exposes `secrets.*` to fork PRs. The workflow-level `if:` is the canonical place to gate trust: it short-circuits before the runner starts the step. The chat is owned by the `coder-token` holder; the prompt is benign, but the agent will read PR content with its tools (see [Security model](#security-model)). ### Send a follow-up @@ -223,7 +223,7 @@ The action sets `chat-error-kind` and `chat-error-message` on failure, posts a c | ----------------- | ------------- | ---------- | | `spend_exceeded` | Chat spend limit reached. Spent and limit are in the comment. | Wait for reset or raise the deployment's per-user limit. | | `org_not_found` | Org missing or the token owner has no memberships. The comment names which. | Fix or set `coder-organization`. | -| `api_error` | Any other Coder API error, including trust-gate refusal and `github-url` host validation. The comment includes the underlying message in a code block; wrapped errors carry the original `CoderAPIError` via `Error.cause`, and the workflow log renders the full cause chain. | Common causes: bad token, bad `workspace-id`, deployment unreachable, fork PR refused by the trust gate, non-github.com `github-url`. | +| `api_error` | Any other Coder API error. The comment includes the underlying message in a code block; wrapped errors carry the original `CoderAPIError` via `Error.cause`, and the workflow log renders the full cause chain. | Common causes: bad token, bad `workspace-id`, deployment unreachable, non-github.com `github-url`. | | `timeout` | `wait: complete` didn't reach terminal in time. | Raise `wait-timeout-seconds`, or split the work. | Branch on the kind without parsing the message: @@ -235,28 +235,39 @@ Branch on the kind without parsing the message: ## Security model -### The chat owner is the `coder-token` holder +### Chat ownership `POST /api/experimental/chats` binds the chat owner to whoever the session token authenticates as. There is no owner override. Anyone who can read `secrets.CODER_TOKEN` acts as that Coder user end-to-end, including the agent's tool plane (shell, `gh`, `git push`, `coder external-auth`, MCP servers). Treat the token as a high-value secret. If your platform exposes per-user spend caps, template allowlists, tool allowlists, or scoped external_auth grants, use them on the token owner; this action cannot constrain what the agent can do once a chat exists. -### Trust gate is fail-closed; no input bypass +### Trigger gating -Before every chat creation, the action calls `classifyTriggerTrust` on the GitHub event payload and refuses untrusted triggers: +The action does not gate triggers. The workflow author defines trigger policy with `if:`. GitHub already gates the load-bearing case: `secrets.*` is not exposed to `pull_request` runs from forks, so a fork-PR run that depends on `coder-token` cannot reach the action. Workflows that opt into broader trigger surfaces (`pull_request_target`, `issue_comment`, `pull_request_review`, `pull_request_review_comment`) opt out of that default and must restate the policy themselves. -- Fork pull requests (`head.repo` null, `head.repo.fork === true`, or `head.repo.full_name !== base.repo.full_name`). -- Comment or review events whose `comment.author_association` or `review.author_association` is not `OWNER`, `MEMBER`, or `COLLABORATOR`. +Patterns: -There is no input bypass: dropping the previous `acting-*` overrides was deliberate. Workflows that target events where `secrets.CODER_TOKEN` is available alongside broad trigger access (`issue_comment`, `pull_request_review`, `pull_request_review_comment`, `pull_request_target`) must add their own `if:` gate before the step. Examples: +```yaml +# Internal PRs only (on pull_request_target, which exposes secrets to fork PRs). +if: github.event.pull_request.head.repo.full_name == github.repository +``` + +```yaml +# Trusted commenters only (on issue_comment / pull_request_review_comment). +if: | + github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR' +``` -- `if: github.event.pull_request.head.repo.full_name == github.repository` (internal PRs only). -- `if: contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)` (trusted commenters only). -- `if: contains(github.event.pull_request.labels.*.name, 'safe-to-review')` (label allowlist on a maintainer-applied label). +```yaml +# Maintainer-applied label allowlist. +if: contains(github.event.pull_request.labels.*.name, 'safe-to-run') +``` -The gate does not read `issue.author_association` or `pull_request.author_association` because those describe the resource opener, not the event sender (a `MEMBER` labeling a `NONE` user's issue is fine). +See GitHub's [Events that trigger workflows](https://docs.github.com/en/actions/reference/events-that-trigger-workflows) for the full event matrix and the `pull_request_target` and secrets-on-forks rules. ### Indirect prompt injection -The agent reads attacker-authored content during its run: PR titles, PR bodies, issue comments, diffs, and anything else the prompt tells it to fetch (`gh pr view`, `gh issue view --comments`, `gh pr diff`). The agent is a language model; it will follow embedded instructions in that content if they look plausible. Treat any public-repo trigger as adversarial regardless of the trust gate's verdict, because the gate decides whether to create the chat but does not constrain what the chat reads once it runs. +The agent reads attacker-authored content during its run: PR titles, PR bodies, issue comments, diffs, and anything else the prompt tells it to fetch (`gh pr view`, `gh issue view --comments`, `gh pr diff`). The agent is a language model; it will follow embedded instructions in that content if they look plausible. Treat any public-repo trigger as adversarial; nothing in this action constrains what the chat reads once it runs. The action ships no defense against this class. Mitigations live deployment-side: @@ -264,8 +275,6 @@ The action ships no defense against this class. Mitigations live deployment-side - Use Coder's platform controls to allowlist templates, restrict tool registrations, and scope the token owner's `external_auth` grants. See [Coder Agents platform controls](https://coder.com/docs/ai-coder/agents/platform-controls). - Keep `coder-token` on a dedicated, minimally-privileged Coder user. The chat's blast radius is whatever that user can reach inside Coder (workspaces, external auth grants, mounted secrets). -The single-most-impactful mitigation against attacker-controlled prompts on a public repo is GitHub's own rule that `secrets.*` is not available to `pull_request` events from forks; the trust gate is a second checkpoint on top of that, not a replacement. - ## Limitations - `waiting` is ambiguous (agent finished vs. agent waiting for input). The action treats it as terminal under `wait: complete`. diff --git a/dist/index.js b/dist/index.js index 30abc74..120d1bb 100644 --- a/dist/index.js +++ b/dist/index.js @@ -27200,59 +27200,16 @@ class ActionFailureError extends Error { coderUsername; chatUrl; } -var TRUSTED_AUTHOR_ASSOCIATIONS = new Set([ - "OWNER", - "MEMBER", - "COLLABORATOR" -]); -function classifyTriggerTrust(context) { - const pr = context.payload.pull_request; - if (pr) { - const headRepo = pr.head?.repo; - const baseRepo = pr.base?.repo; - const headFullName = headRepo?.full_name; - const baseFullName = baseRepo?.full_name; - const isFork = headRepo === null || headRepo?.fork === true || typeof headFullName === "string" && typeof baseFullName === "string" && headFullName !== baseFullName; - if (isFork) { - return { - kind: "untrusted", - reason: "the pull request is from a fork" - }; - } - } - const associations = [ - { source: "comment", value: context.payload.comment?.author_association }, - { source: "review", value: context.payload.review?.author_association } - ]; - for (const { source, value } of associations) { - if (typeof value !== "string" || value.length === 0) { - continue; - } - if (TRUSTED_AUTHOR_ASSOCIATIONS.has(value)) { - return { - kind: "trusted", - reason: `${source}.author_association is ${value}` - }; - } - return { - kind: "untrusted", - reason: `${source}.author_association is ${value}, which lacks ` + "repository write access" - }; - } - return { kind: "no-signal" }; -} class CoderAgentChatAction { coder; octokit; inputs; - context; clock; - constructor(coder, octokit, inputs, context, clock = defaultClock) { + constructor(coder, octokit, inputs, clock = defaultClock) { this.coder = coder; this.octokit = octokit; this.inputs = inputs; - this.context = context; this.clock = clock; } parseGithubURL() { @@ -27393,17 +27350,6 @@ class CoderAgentChatAction { throw err; } } - assertTrustedTrigger() { - const trust = classifyTriggerTrust(this.context); - if (trust.kind === "untrusted") { - throw new Error("Refusing to act on an untrusted trigger: " + `${trust.reason}. ` + "Add an `if:` gate to the workflow step (for example, " + "`author_association` allowlist or a label allowlist on " + "`pull_request_target`) before invoking this action. See " + "the README security model for details."); - } - if (trust.kind === "trusted") { - core2.info(`Trust gate passed: ${trust.reason}`); - } else { - core2.info("Trust gate found no signal in the event payload; deferring " + "to GitHub's event-permission model."); - } - } async resolveOrganizationID(user) { if (this.inputs.coderOrganization) { core2.info(`Resolving Coder organization by name: ${this.inputs.coderOrganization}`); @@ -27480,7 +27426,6 @@ class CoderAgentChatAction { core2.info(`GitHub owner: ${githubOrg}`); core2.info(`GitHub repo: ${githubRepo}`); core2.info(`GitHub item number: ${githubIssueNumber}`); - this.assertTrustedTrigger(); const tokenOwner = await this.coder.getAuthenticatedUser(); const coderUsername = tokenOwner.username; core2.info(`Resolved Coder user from \`coder-token\` (users/me): ${coderUsername}`); @@ -27779,7 +27724,7 @@ async function main() { const coder = new RealCoderClient(inputs.coderURL, inputs.coderToken); const octokit = github.getOctokit(inputs.githubToken); core4.debug("Clients initialized"); - const action = new CoderAgentChatAction(coder, octokit, inputs, github.context); + const action = new CoderAgentChatAction(coder, octokit, inputs); const outputs = await action.run(); setActionOutputs(outputs); core4.debug("Action completed successfully"); diff --git a/src/action.test.ts b/src/action.test.ts index 6226510..d017839 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -15,7 +15,6 @@ import { createFakeClock, createMockOctokit, createMockInputs, - createMockContext, mockUser, mockUserNoOrgs, mockChat, @@ -46,7 +45,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const result = action.parseGithubURL(); @@ -66,7 +64,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const result = action.parseGithubURL(); @@ -84,7 +81,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); expect(() => action.parseGithubURL()).toThrowError("Missing GitHub URL"); @@ -96,7 +92,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); expect(() => action.parseGithubURL()).toThrowError( @@ -112,7 +107,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); expect(() => action.parseGithubURL()).toThrowError( @@ -128,7 +122,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const result = action.parseGithubURL(); @@ -148,7 +141,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const result = action.parseGithubURL(); @@ -168,7 +160,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const result = action.parseGithubURL(); @@ -194,7 +185,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); expect(() => action.parseGithubURL()).toThrowError( @@ -210,7 +200,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); expect(() => action.parseGithubURL()).toThrowError( @@ -226,7 +215,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const result = action.generateChatUrl(mockChat.id); @@ -242,7 +230,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const result = action.generateChatUrl(mockChat.id); @@ -265,7 +252,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.commentOnIssue({ @@ -310,7 +296,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.commentOnIssue({ @@ -343,7 +328,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await expect( @@ -375,7 +359,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const result = await action.run(); @@ -407,7 +390,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const out = action.buildOutputs(mockUser.username, mockChat, true); @@ -435,7 +417,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const out = action.buildOutputs( @@ -468,7 +449,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const diff = mockChatWithDiff.diff_status; if (!diff) { @@ -494,7 +474,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const diff = mockChatWithDiff.diff_status; if (!diff) { @@ -525,7 +504,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const diff = mockChatWithDiff.diff_status; if (!diff) { @@ -555,7 +533,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const diff = mockChatWithDiff.diff_status; if (!diff) { @@ -589,7 +566,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const chatWithError: typeof mockChat = { ...mockChat, @@ -609,7 +585,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const out = action.buildOutputs(mockUser.username, mockChat, true); @@ -629,7 +604,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const result = await action.run(); @@ -665,7 +639,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const result = await action.run(); @@ -703,7 +676,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await expect(action.run()).rejects.toThrow(); @@ -726,7 +698,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); // The follow-up message succeeded, so the action should not fail red @@ -757,7 +728,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -781,7 +751,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -808,7 +777,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -827,7 +795,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); action.warnUnwiredInputs(); @@ -848,7 +815,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); action.warnUnwiredInputs(); @@ -860,223 +826,6 @@ describe("CoderAgentChatAction", () => { }); }); - describe("Trust gate (top-level, always-on)", () => { - test("refuses fork pull requests before any Coder API call", async () => { - coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ commentOnIssue: false }); - const context = createMockContext({ - eventName: "pull_request", - actor: "attacker", - payload: { - sender: { id: 99999 }, - pull_request: { - head: { repo: { fork: true, full_name: "attacker/fork" } }, - base: { repo: { full_name: "owner/repo" } }, - }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - const message = (caught as Error).message; - expect(message).toContain("untrusted trigger"); - expect(message).toContain("fork"); - expect(message).toContain("if:"); - // Nothing was called: the gate is fail-closed before any - // API call, including users/me and createChat. - expect(coderClient.mockGetAuthenticatedUser).not.toHaveBeenCalled(); - expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); - }); - - test("refuses pull_request from a deleted fork (head.repo === null)", async () => { - coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ commentOnIssue: false }); - const context = createMockContext({ - eventName: "pull_request", - actor: "attacker", - payload: { - sender: { id: 99999 }, - pull_request: { - // GitHub returns head.repo === null when the source fork - // repo has been deleted. With head.repo missing the - // full_name comparison can't run, so the gate must treat - // the null itself as the fork signal. - head: { repo: null }, - base: { repo: { full_name: "owner/repo" } }, - }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - await expect(action.run()).rejects.toThrow(/untrusted trigger.*fork/); - expect(coderClient.mockGetAuthenticatedUser).not.toHaveBeenCalled(); - expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); - }); - - test("refuses pull_request when head.repo.full_name diverges from base.repo.full_name, even with fork === false", async () => { - coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ commentOnIssue: false }); - const context = createMockContext({ - eventName: "pull_request", - actor: "attacker", - payload: { - sender: { id: 99999 }, - pull_request: { - // Same-owner branch-rename mid-PR (or any payload where - // fork=false but full_name disagrees) must still be - // refused. Pins the third condition independently from - // the fork=true short-circuit. - head: { - repo: { fork: false, full_name: "attacker/fork" }, - }, - base: { repo: { full_name: "owner/repo" } }, - }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - await expect(action.run()).rejects.toThrow(/untrusted trigger.*fork/); - expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); - }); - - test("refuses NONE-association comment events", async () => { - coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ commentOnIssue: false }); - const context = createMockContext({ - eventName: "issue_comment", - actor: "drive-by", - payload: { - sender: { id: 99999 }, - comment: { author_association: "NONE" }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - let caught: unknown; - try { - await action.run(); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - expect((caught as Error).message).toContain("NONE"); - expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); - }); - - test("trusted MEMBER comment proceeds and createChat is reached", async () => { - coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ commentOnIssue: false }); - const context = createMockContext({ - eventName: "issue_comment", - actor: "member", - payload: { - sender: { id: 42 }, - comment: { author_association: "MEMBER" }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - await action.run(); - expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); - }); - - test("no-signal events (issues, push, workflow_dispatch) proceed", async () => { - coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ commentOnIssue: false }); - const context = createMockContext({ - eventName: "issues", - actor: "anyone", - payload: {}, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - await action.run(); - expect(coderClient.mockCreateChat).toHaveBeenCalledTimes(1); - }); - - test("the gate has no input bypass; idempotency-key cannot bypass it", async () => { - // No action input bypasses the gate. Set every remaining input to - // confirm an untrusted trigger still refuses. - coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - commentOnIssue: false, - idempotencyKey: "anything", - coderOrganization: "anything", - workspaceId: "11111111-1111-1111-1111-111111111111", - }); - const context = createMockContext({ - eventName: "pull_request", - actor: "attacker", - payload: { - sender: { id: 99999 }, - pull_request: { - head: { repo: { fork: true, full_name: "attacker/fork" } }, - base: { repo: { full_name: "owner/repo" } }, - }, - }, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - context, - ); - - await expect(action.run()).rejects.toThrow(/untrusted trigger/); - expect(coderClient.mockCreateChat).not.toHaveBeenCalled(); - }); - }); - describe("wait=complete polling", () => { test("wait=none honors the wait gate: no getChat, no clock sleep", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); @@ -1091,7 +840,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1126,7 +874,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1169,7 +916,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1209,7 +955,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1252,7 +997,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1300,7 +1044,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1337,7 +1080,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1376,7 +1118,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1434,7 +1175,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1467,7 +1207,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1514,7 +1253,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1552,7 +1290,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1586,7 +1323,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1623,7 +1359,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1669,7 +1404,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1719,7 +1453,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1760,7 +1493,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1800,7 +1532,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -1837,7 +1568,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await expect(action.run()).rejects.toThrow("Failed to create chat"); @@ -1872,7 +1602,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await expect(action.run()).rejects.toThrow(); @@ -1908,7 +1637,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await expect(action.run()).rejects.toThrow(); @@ -1936,7 +1664,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await expect(action.run()).rejects.toThrow(); @@ -1974,7 +1701,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await expect(action.run()).rejects.toThrow(); @@ -2010,7 +1736,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await expect(action.run()).rejects.toThrow(); @@ -2050,7 +1775,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await expect(action.run()).rejects.toThrow(); @@ -2090,7 +1814,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); let caught: unknown; @@ -2127,7 +1850,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); let caught: unknown; @@ -2157,7 +1879,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); let caught: unknown; @@ -2191,7 +1912,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2217,7 +1937,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2241,7 +1960,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2265,7 +1983,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); let caught: unknown; @@ -2293,7 +2010,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); let caught: unknown; @@ -2324,7 +2040,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); let caught: unknown; @@ -2359,7 +2074,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const result = await action.run(); @@ -2386,7 +2100,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); let caught: unknown; @@ -2419,7 +2132,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); let caught: unknown; @@ -2456,7 +2168,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2489,7 +2200,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2515,7 +2225,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); let caught: unknown; @@ -2540,7 +2249,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2572,7 +2280,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2604,7 +2311,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2648,7 +2354,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const outputs = await action.run(); @@ -2708,7 +2413,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), clock, ); @@ -2741,7 +2445,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); const outputs = await action.run(); @@ -2778,7 +2481,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2803,7 +2505,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2826,7 +2527,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2847,7 +2547,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2874,7 +2573,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await expect(action.run()).rejects.toThrow( @@ -2898,7 +2596,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2930,7 +2627,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); @@ -2968,7 +2664,6 @@ describe("CoderAgentChatAction", () => { coderClient, octokit as unknown as Octokit, inputs, - createMockContext(), ); await action.run(); diff --git a/src/action.ts b/src/action.ts index 936da19..efa6285 100644 --- a/src/action.ts +++ b/src/action.ts @@ -91,180 +91,11 @@ export class ActionFailureError extends Error { chatUrl?: string; } -/** - * GitHub `author_association` values that map to repository write access for - * the trust gate. `OWNER` and `MEMBER` cover org and personal-repo owners; - * `COLLABORATOR` covers invited collaborators. Any other association - * (including `CONTRIBUTOR`, `FIRST_TIMER`, `FIRST_TIME_CONTRIBUTOR`, - * `MANNEQUIN`, `NONE`) is treated as untrusted. - * - * See: https://docs.github.com/en/graphql/reference/enums#commentauthorassociation - */ -const TRUSTED_AUTHOR_ASSOCIATIONS = new Set([ - "OWNER", - "MEMBER", - "COLLABORATOR", -]); - -/** - * Structural subset of `@actions/github`'s `Context` covering the fields the - * action reads. Production callers pass `github.context`; tests build - * fixtures via `createMockContext`. - * - * The trust gate (`classifyTriggerTrust`) reads - * `pull_request.head.repo` / `pull_request.base.repo` for fork detection, - * and `comment.author_association` / `review.author_association` as the - * sender-reliable trust signals. `issue.author_association` and - * `pull_request.author_association` are typed on the payload for - * completeness but the gate deliberately does not read them (they - * describe the resource opener, not the event sender). Fields are - * typed loosely because the full webhook schemas are large and - * event-specific. - */ -export interface ActionContext { - eventName: string; - actor: string; - payload: { - sender?: { - id?: number; - [key: string]: unknown; - }; - pull_request?: { - author_association?: string; - head?: { - repo?: { - fork?: boolean; - full_name?: string; - [key: string]: unknown; - } | null; - [key: string]: unknown; - }; - base?: { - repo?: { - full_name?: string; - [key: string]: unknown; - } | null; - [key: string]: unknown; - }; - [key: string]: unknown; - }; - issue?: { - author_association?: string; - [key: string]: unknown; - }; - comment?: { - author_association?: string; - [key: string]: unknown; - }; - review?: { - author_association?: string; - [key: string]: unknown; - }; - [key: string]: unknown; - }; -} - -/** - * Outcome of the trust gate. `trusted` means the gate found a - * repository-write-level signal and the action may proceed. `untrusted` - * means the gate found a signal that fails the bar (fork PR, low-trust - * association) and the action must refuse. `no-signal` means the - * payload carried nothing the gate can act on, so the gate defers to - * GitHub's underlying event-permission model (secret access, branch - * protection, etc.). - */ -type TrustClassification = - | { kind: "trusted"; reason: string } - | { kind: "untrusted"; reason: string } - | { kind: "no-signal" }; - -/** - * Classify whether the event in `context` is trusted to run the action. - * - * Two layers of signal, applied in order: - * - * 1. Fork pull requests always refuse. The workflow's `coder-token` is a - * secret; a fork PR is attacker-controlled content and must not - * execute under it. A `null` `head.repo` (deleted fork) is also - * treated as a fork: the only way `head.repo` becomes null is when - * the fork's source repository was deleted, which collapses the - * same-repo check below into a false negative. - * - * 2. `author_association` on `comment` or `review`, in that order. These - * are the only fields where the association describes the event - * *sender* rather than the resource *author*. On `issue_comment`, - * `comment.user` is the sender; on `pull_request_review`, - * `review.user` is the sender. By contrast, `issue.author_association` - * and `pull_request.author_association` describe the resource opener, - * not the labeler / assigner / reviewer who actually triggered the - * event. Reading them would refuse a trusted MEMBER labeling an - * issue opened by a NONE user. - * - * Returning `no-signal` is deliberate: events like `issues`, - * `pull_request` (same-repo), `workflow_dispatch`, `push`, and - * `repository_dispatch` carry no sender-association data the gate can - * trust, and the underlying GitHub permission model already gates who - * can trigger them. The trust gate is layered on top of, not in place - * of, those controls. - */ -function classifyTriggerTrust(context: ActionContext): TrustClassification { - const pr = context.payload.pull_request; - if (pr) { - const headRepo = pr.head?.repo; - const baseRepo = pr.base?.repo; - const headFullName = headRepo?.full_name; - const baseFullName = baseRepo?.full_name; - const isFork = - headRepo === null || - headRepo?.fork === true || - (typeof headFullName === "string" && - typeof baseFullName === "string" && - headFullName !== baseFullName); - if (isFork) { - return { - kind: "untrusted", - reason: "the pull request is from a fork", - }; - } - } - - // Only read `author_association` from `comment` and `review`: those - // are the only payload fields where the association describes the - // event sender rather than the resource author. `issue` and - // `pull_request` `author_association` describe the opener, which is - // frequently NOT the sender (a MEMBER labeling an issue, an assignee - // receiving an assignment, etc.). - const associations: Array<{ source: string; value: unknown }> = [ - { source: "comment", value: context.payload.comment?.author_association }, - { source: "review", value: context.payload.review?.author_association }, - ]; - for (const { source, value } of associations) { - if (typeof value !== "string" || value.length === 0) { - continue; - } - if (TRUSTED_AUTHOR_ASSOCIATIONS.has(value)) { - return { - kind: "trusted", - reason: `${source}.author_association is ${value}`, - }; - } - return { - kind: "untrusted", - reason: - `${source}.author_association is ${value}, which lacks ` + - "repository write access", - }; - } - - return { kind: "no-signal" }; -} - export class CoderAgentChatAction { constructor( private readonly coder: CoderClient, private readonly octokit: Octokit, private readonly inputs: ActionInputs, - private readonly context: ActionContext, private readonly clock: Clock = defaultClock, ) {} @@ -562,46 +393,6 @@ export class CoderAgentChatAction { } } - /** - * Refuse fork pull requests and untrusted comments/reviews before any - * Coder API call. The chat owner is the `coder-token` holder regardless - * of who triggered the workflow; this gate's load-bearing job is the - * fail-closed refusal to call `createChat` on a hostile trigger. There - * is no input bypass: workflow authors targeting fork PRs or low-trust - * comment channels must add their own `if:` filter (see README's - * `pull_request_target` recipe and the security model section). - * - * On `trusted` and `no-signal` verdicts the action proceeds; both are - * logged so an operator debugging trust-gate behavior can tell which - * branch fired. - */ - assertTrustedTrigger(): void { - const trust = classifyTriggerTrust(this.context); - if (trust.kind === "untrusted") { - throw new Error( - "Refusing to act on an untrusted trigger: " + - `${trust.reason}. ` + - "Add an `if:` gate to the workflow step (for example, " + - "`author_association` allowlist or a label allowlist on " + - "`pull_request_target`) before invoking this action. See " + - "the README security model for details.", - ); - } - if (trust.kind === "trusted") { - core.info(`Trust gate passed: ${trust.reason}`); - } else { - // no-signal: events like `issues`, `push`, same-repo - // `pull_request`, and `workflow_dispatch` carry no - // sender-association data the gate can act on. Log so an - // operator debugging trust-gate behavior can tell the gate ran - // and deferred, rather than being skipped. - core.info( - "Trust gate found no signal in the event payload; deferring " + - "to GitHub's event-permission model.", - ); - } - } - /** * Resolve the organization id to send on createChat. Resolution order: * @@ -756,15 +547,10 @@ export class CoderAgentChatAction { private async runInner(): Promise { this.warnUnwiredInputs(); - // Validate github-url and run the trust gate before any Coder API - // call. parseGithubURL rejects non-github.com hosts; the trust - // gate fails closed on fork PRs and untrusted comment/review - // senders. Both are pre-`createChat` checkpoints. const { githubOrg, githubRepo, githubIssueNumber } = this.parseGithubURL(); core.info(`GitHub owner: ${githubOrg}`); core.info(`GitHub repo: ${githubRepo}`); core.info(`GitHub item number: ${githubIssueNumber}`); - this.assertTrustedTrigger(); // The chat owner on POST /api/experimental/chats is always the // `coder-token` holder; the API has no owner override. The action diff --git a/src/index.ts b/src/index.ts index 1786ea8..bd38911 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,12 +33,7 @@ async function main() { core.debug("Clients initialized"); - const action = new CoderAgentChatAction( - coder, - octokit, - inputs, - github.context, - ); + const action = new CoderAgentChatAction(coder, octokit, inputs); const outputs = await action.run(); setActionOutputs(outputs); diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 355b593..130a071 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -16,7 +16,6 @@ import type { import type { Clock } from "./action"; import type { ActionInputs } from "./schemas"; import { DEFAULT_WAIT_TIMEOUT_SECONDS } from "./schemas"; -import type { ActionContext } from "./action"; /** * Fake clock that records every sleep duration and treats sleeps as @@ -173,21 +172,6 @@ export class MockCoderClient implements CoderClient { } } -/** - * Build a minimal `ActionContext` shaped like `@actions/github`'s Context. - * Tests populate only the fields they exercise. - */ -export function createMockContext( - overrides?: Partial, -): ActionContext { - return { - eventName: "", - actor: "", - payload: {}, - ...overrides, - }; -} - /** * Mock Octokit for testing. Includes a `paginate` mock so tests for code * paths that walk every comment with `octokit.paginate(listComments, ...)` From 977728b3bde2d2f9cd6a6d8ccb6bf52941e851af Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 18 May 2026 14:29:19 +0000 Subject: [PATCH 11/11] fix(src): pass ActionFailureError kind into the failure comment body `classifyError` runs in comment.ts and cannot import ActionFailureError without a cycle, so an ActionFailureError reaching `handleFailure` fell through the `err instanceof Error` branch and rendered the api_error body even when the thrown kind was `org_not_found` or `timeout`. The `chat-error-kind` output already came from `ActionFailureError.kind`, so the comment disagreed with the output. Pre-check ActionFailureError in `handleFailure` and pass the kind through. `spend_exceeded` carries extra fields on the body variant and is only produced by `classifyError`, never thrown directly, so the narrow is exhaustive. New test pins the org_not_found body. Also drop dead weight surfaced by the R5 review: - Delete `warnUnwiredInputs` and its describe block. The method was a no-op and the two tests asserted nothing. - Delete the duplicate `creates a chat under the token owner returned by users/me` test; it was a strict subset of `creates new chat successfully` above it. - Delete `ChatErrorKindSchema` and `ChatErrorKind` from coder-client.ts. The canonical definition lives in schemas.ts; the duplicate was a sync hazard. `comment.ts` re-exports `ChatErrorKind` from schemas.ts now. --- dist/index.js | 14 ++----- src/action.test.ts | 100 ++++++++++++++++---------------------------- src/action.ts | 26 +++++------- src/coder-client.ts | 10 ----- src/comment.ts | 14 +++---- 5 files changed, 53 insertions(+), 111 deletions(-) diff --git a/dist/index.js b/dist/index.js index 120d1bb..dbe0b43 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26875,12 +26875,6 @@ var CreateChatMessageRequestSchema = exports_external.object({ var CreateChatMessageResponseSchema = exports_external.object({ queued: exports_external.boolean() }); -var ChatErrorKindSchema = exports_external.enum([ - "org_not_found", - "spend_exceeded", - "api_error", - "timeout" -]); class CoderAPIError extends Error { statusCode; @@ -27254,7 +27248,6 @@ class CoderAgentChatAction { marker }); } - warnUnwiredInputs() {} buildOutputs(coderUsername, chat, chatCreated) { const diff = chat.diff_status; const hasPR = diff?.pr_number != null; @@ -27388,7 +27381,7 @@ class CoderAgentChatAction { } } async handleFailure(error3) { - const detail = classifyError(error3); + const detail = error3 instanceof ActionFailureError && error3.kind !== "spend_exceeded" ? { kind: error3.kind, message: error3.message } : classifyError(error3); const failure = error3 instanceof ActionFailureError ? error3 : new ActionFailureError(detail.kind, detail.message, undefined, { cause: error3 }); @@ -27421,7 +27414,6 @@ class CoderAgentChatAction { return failure; } async runInner() { - this.warnUnwiredInputs(); const { githubOrg, githubRepo, githubIssueNumber } = this.parseGithubURL(); core2.info(`GitHub owner: ${githubOrg}`); core2.info(`GitHub repo: ${githubRepo}`); @@ -27672,7 +27664,7 @@ var ActionInputsSchema = ActionInputsObjectSchema.refine((data) => !(data.existi message: "Cannot set both existing-chat-id and force-new-chat; choose one.", path: ["forceNewChat"] }); -var ChatErrorKindSchema2 = exports_external.enum([ +var ChatErrorKindSchema = exports_external.enum([ "spend_exceeded", "org_not_found", "api_error", @@ -27695,7 +27687,7 @@ var ActionOutputsSchema = exports_external.object({ changedFiles: exports_external.number().optional(), headBranch: exports_external.string().optional(), baseBranch: exports_external.string().optional(), - chatErrorKind: ChatErrorKindSchema2.optional(), + chatErrorKind: ChatErrorKindSchema.optional(), chatErrorMessage: exports_external.string().optional() }); diff --git a/src/action.test.ts b/src/action.test.ts index d017839..ba9526f 100644 --- a/src/action.test.ts +++ b/src/action.test.ts @@ -593,31 +593,6 @@ describe("CoderAgentChatAction", () => { }); }); - test("creates a chat under the token owner returned by users/me", async () => { - coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); - coderClient.mockCreateChat.mockResolvedValue(mockChat); - - const inputs = createMockInputs({ - commentOnIssue: false, - }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - ); - - const result = await action.run(); - - // users/me is the single source of identity now; assert it was - // called and a chat was created under the resulting username. - expect(coderClient.mockGetAuthenticatedUser).toHaveBeenCalled(); - expect(coderClient.mockCreateChat).toHaveBeenCalled(); - - const parsedResult = ActionOutputsSchema.parse(result); - expect(parsedResult.coderUsername).toBe(mockUser.username); - expect(parsedResult.chatCreated).toBe(true); - }); - test("sends message to existing chat", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChatMessage.mockResolvedValue( @@ -786,46 +761,6 @@ describe("CoderAgentChatAction", () => { }); }); - describe("warnUnwiredInputs", () => { - test("does not warn for wait=complete", () => { - const warning = spyOn(core, "warning").mockImplementation(() => {}); - try { - const inputs = createMockInputs({ wait: "complete" }); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - ); - - action.warnUnwiredInputs(); - - expect(warning).not.toHaveBeenCalledWith( - expect.stringContaining("`wait: complete`"), - ); - } finally { - warning.mockRestore(); - } - }); - - test("does not warn at defaults", () => { - const warning = spyOn(core, "warning").mockImplementation(() => {}); - try { - const inputs = createMockInputs(); - const action = new CoderAgentChatAction( - coderClient, - octokit as unknown as Octokit, - inputs, - ); - - action.warnUnwiredInputs(); - - expect(warning).not.toHaveBeenCalled(); - } finally { - warning.mockRestore(); - } - }); - }); - describe("wait=complete polling", () => { test("wait=none honors the wait gate: no getChat, no clock sleep", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); @@ -1620,6 +1555,41 @@ describe("CoderAgentChatAction", () => { }, ); + test( + "posts a failure comment with the org_not_found template body when " + + "resolveOrganizationID throws ActionFailureError(org_not_found)", + async () => { + // Without the ActionFailureError pre-check in handleFailure, this + // path classifies the thrown ActionFailureError as `api_error` + // and renders the api_error template, so the comment body + // disagrees with the `chat-error-kind` output. + coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUserNoOrgs); + octokit.rest.issues.listComments.mockResolvedValue({ + data: [], + } as ReturnType); + octokit.rest.issues.createComment.mockResolvedValue( + {} as ReturnType, + ); + + const inputs = createMockInputs({ coderOrganization: undefined }); + const action = new CoderAgentChatAction( + coderClient, + octokit as unknown as Octokit, + inputs, + ); + + await expect(action.run()).rejects.toThrow(); + + expect(octokit.rest.issues.createComment).toHaveBeenCalledTimes(1); + const call = octokit.rest.issues.createComment.mock.calls[0]?.[0] as + | { body: string } + | undefined; + expect(call?.body).toContain("chat-error-kind=org_not_found"); + expect(call?.body).toContain("no matching organization"); + expect(call?.body).not.toContain("An unexpected error occurred"); + }, + ); + test("falls back to chat-error-kind=api_error for unknown 4xx shapes", async () => { coderClient.mockGetAuthenticatedUser.mockResolvedValue(mockUser); coderClient.mockCreateChat.mockRejectedValue( diff --git a/src/action.ts b/src/action.ts index efa6285..0f3f01a 100644 --- a/src/action.ts +++ b/src/action.ts @@ -186,17 +186,6 @@ export class CoderAgentChatAction { }); } - /** - * Warn loudly when the user opts in to inputs whose runtime behavior - * is not yet wired. The schema accepts these so the contract is stable; - * the warning prevents silent no-ops for workflow authors who explicitly - * opt in. - */ - warnUnwiredInputs(): void { - // All v0 inputs are now wired. The helper remains for the test - // suite import and future unwired inputs. - } - /** * Build a rich ActionOutputs from a Chat response. */ @@ -493,9 +482,16 @@ export class CoderAgentChatAction { // the failure-path output contract is uniform. private async handleFailure(error: unknown): Promise { // `detail` is the comment-body shape; `failure` is the thrown shape. - // Classify first so spend-exceeded fields land in the comment body - // for both raw-Error and ActionFailureError inputs. - const detail: FailureDetail = classifyError(error); + // Pass an ActionFailureError's `kind` through to the body so the + // posted comment matches `chat-error-kind`. `classifyError` lives + // in `comment.ts` and cannot import `ActionFailureError` (cycle). + // `spend_exceeded` carries extra fields on the body variant and is + // only ever produced by `classifyError`, never thrown as an + // ActionFailureError, so the narrow is exhaustive. + const detail: FailureDetail = + error instanceof ActionFailureError && error.kind !== "spend_exceeded" + ? { kind: error.kind, message: error.message } + : classifyError(error); const failure = error instanceof ActionFailureError ? error @@ -545,8 +541,6 @@ export class CoderAgentChatAction { } private async runInner(): Promise { - this.warnUnwiredInputs(); - const { githubOrg, githubRepo, githubIssueNumber } = this.parseGithubURL(); core.info(`GitHub owner: ${githubOrg}`); core.info(`GitHub repo: ${githubRepo}`); diff --git a/src/coder-client.ts b/src/coder-client.ts index 6a6436b..15b1ff7 100644 --- a/src/coder-client.ts +++ b/src/coder-client.ts @@ -284,16 +284,6 @@ export type CreateChatMessageResponse = z.infer< typeof CreateChatMessageResponseSchema >; -// Full enum for the `chat-error-kind` action output. The action populates -// these downstream when API errors are mapped to outputs. -export const ChatErrorKindSchema = z.enum([ - "org_not_found", - "spend_exceeded", - "api_error", - "timeout", -]); -export type ChatErrorKind = z.infer; - /** * CoderAPIError carries the status code and raw response body from a Coder * API failure. The body is preserved verbatim so the failure-path diff --git a/src/comment.ts b/src/comment.ts index 76d8046..fbb7aff 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -1,12 +1,8 @@ import * as core from "@actions/core"; import type { getOctokit } from "@actions/github"; -import { - type ChatErrorKind, - type ChatStatus, - CoderAPIError, -} from "./coder-client"; +import { type ChatStatus, CoderAPIError } from "./coder-client"; import { sanitizeLabelToken } from "./sanitize-label-token"; -import type { ActionInputs } from "./schemas"; +import type { ActionInputs, ChatErrorKind } from "./schemas"; import { normalizeBaseUrl } from "./url"; // Re-export so `action.ts` and tests keep their existing import sites. @@ -75,9 +71,9 @@ export type FailureDetail = }; // chat-error-kind enum surfaced as the action's `chat-error-kind` output. -// Re-exported from `coder-client.ts`; this re-export keeps the name local -// to `comment.ts` callers and `index.ts` for backward source compatibility. -export type { ChatErrorKind } from "./coder-client"; +// Re-exported so callers can import the type from `comment.ts` next to +// `FailureDetail` / `classifyError` / `buildFailureCommentBody`. +export type { ChatErrorKind } from "./schemas"; const COMMENT_MARKER_PREFIX = "";