diff --git a/README.md b/README.md index ac0a6ea..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. Set `coder-username` for service-account workflows. +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 @@ -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`. | +| `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` | Coder username the chat ran as. | +| `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`. | @@ -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. +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. 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 `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 @@ -162,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 }}. @@ -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 @@ -224,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. | @@ -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 `acting-coder-username` or `acting-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..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 to resolve to a Coder user. 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: "Coder username to use directly. Mutually exclusive with github-user-id; useful for service-account workflows." + 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,8 +76,8 @@ inputs: default: "false" outputs: - coder-username: - description: "The Coder username resolved from the GitHub user." + 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: description: "The chat ID." diff --git a/dist/index.js b/dist/index.js index 79d2000..ebd7b53 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); @@ -27076,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)}`); @@ -27086,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 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); @@ -27226,8 +27230,8 @@ async function upsertCommentByMarker(args) { logLabel: "failure comment" }); } -function buildDeploymentChatsUrl(coderURL) { - return `${normalizeBaseUrl(coderURL)}/chats`; +function buildDeploymentAgentsUrl(coderURL) { + 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,63 +27469,117 @@ 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); } 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 }; + return { + username: coderUser.username, + user: coderUser, + source: "acting-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: "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."); + } + } } - 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."); + 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)."); } - 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."); + return { + username: tokenOwner.username, + user: tokenOwner, + source: "token" + }; + } + tokenOwnerCache; + async getTokenOwner() { + if (this.tokenOwnerCache) { + return this.tokenOwnerCache; } - if (trust.kind === "trusted") { - core2.info(`Auto-resolve trust check passed: ${trust.reason}`); + 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; } - 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."); - } + 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; } - 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."); - } + 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) { @@ -27544,7 +27602,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; } @@ -27591,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 @@ -27608,12 +27666,21 @@ 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}`); 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); @@ -27797,7 +27864,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 }, @@ -27839,7 +27906,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); } } @@ -27864,7 +27931,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.", @@ -27909,7 +27976,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 }), @@ -27918,7 +27985,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 feb1730..e981745 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-]+$/, ); }); @@ -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({ @@ -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("coder-username"); - expect(message).toContain("github-user-id"); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); + expect(message).toContain("users/me"); + expect(message).toContain("401 Unauthorized"); + expect(message).toContain("acting-coder-username"); + expect(message).toContain("acting-github-user-id"); }); - 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("coder-username"); - expect(message).toContain("github-user-id"); - expect(coderClient.mockGetCoderUserByGithubID).not.toHaveBeenCalled(); - expect(octokit.rest.users.getByUsername).not.toHaveBeenCalled(); + 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 () => { @@ -1240,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 () => { @@ -1275,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(); }); @@ -1315,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 () => { @@ -1356,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(); }); @@ -1469,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(); }); @@ -1641,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 @@ -1680,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); @@ -1745,6 +1827,199 @@ describe("CoderAgentChatAction", () => { }); }); + 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(); + } + }); + + 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); + 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(); + }); + + 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", () => { test("wait=none honors the wait gate: no getChat, no clock sleep", async () => { coderClient.mockGetCoderUserByGithubID.mockResolvedValue(mockUser); @@ -2082,7 +2357,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 +2596,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 +2884,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( "", ); @@ -2650,8 +2925,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( "", ); @@ -2660,7 +2935,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( @@ -2692,7 +2967,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( "", ); @@ -3056,7 +3331,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); @@ -3763,7 +4038,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 a4e093f..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, @@ -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. @@ -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. `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. @@ -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,50 +596,50 @@ 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. + * 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. * - * `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; @@ -639,14 +653,18 @@ 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 }, ); } throw err; } - return { username: coderUser.username, user: coderUser }; + return { + username: coderUser.username, + user: coderUser, + source: "acting-coder-username", + }; } if (this.inputs.githubUserID !== undefined) { core.info( @@ -655,106 +673,205 @@ 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: "acting-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") { - 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.", - ); + // `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.", + ); + } + } } - // 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") { + // 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( - "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.", + `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).", ); } - if (trust.kind === "trusted") { - core.info(`Auto-resolve trust check passed: ${trust.reason}`); + 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; + } - // 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 !== "acting-coder-username" && + resolved.source !== "acting-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.", ); } @@ -765,15 +882,17 @@ 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 - * `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 * 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. @@ -808,7 +927,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) { @@ -821,7 +940,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 }, ); @@ -920,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, @@ -939,14 +1058,24 @@ 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}`); 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/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..44b1483 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; @@ -148,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( @@ -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..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/chats"; + 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,21 +242,21 @@ 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("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 }); + const body = buildFailureCommentBody(detail, { agentsUrl, 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); }); @@ -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); @@ -295,9 +295,9 @@ 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, + agentsUrl, chatUrl, marker, }); @@ -325,9 +325,9 @@ 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, + agentsUrl, chatUrl, marker, }); @@ -353,9 +353,9 @@ 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, + 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,19 +408,19 @@ describe("normalizeBaseUrl", () => { }); }); -describe("buildDeploymentChatsUrl", () => { - test("appends /chats to a clean base URL", () => { - expect(buildDeploymentChatsUrl("https://coder.test")).toBe( - "https://coder.test/chats", +describe("buildDeploymentAgentsUrl", () => { + test("appends /agents to a clean base URL", () => { + 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( - "https://coder.test/chats", + expect(buildDeploymentAgentsUrl("https://coder.test/?x=1")).toBe( + "https://coder.test/agents", ); - expect(buildDeploymentChatsUrl("https://coder.test/#a")).toBe( - "https://coder.test/chats", + expect(buildDeploymentAgentsUrl("https://coder.test/#a")).toBe( + "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..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( @@ -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,9 @@ 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 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 { - return `${normalizeBaseUrl(coderURL)}/chats`; +export function buildDeploymentAgentsUrl(coderURL: string): string { + return `${normalizeBaseUrl(coderURL)}/agents`; } 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 1e84dac..aaa98bc 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, }; @@ -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,20 +266,20 @@ 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); - 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"]); + 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..2d62959 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", () => { @@ -326,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, }; 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( 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); }