From 854b4bcac4ca146e8ef0bca4c2eb5c2445425b61 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:44:09 -0600 Subject: [PATCH] fix(opencode): report a provider 401 from the hello probe as missing authentication (#20878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Each agent has a runtime readiness test ("test environment") that runs a short hello probe through its harness > - The OpenCode probe sorts failures into model, timeout, authentication and generic errors > - OpenRouter rejects an empty or unknown key with HTTP 401 and the messages "Missing Authentication header" or "User not found." > - The probe matched authentication only by wording, so these 401 replies became a generic `opencode_hello_probe_failed` error > - The server maps `*_hello_probe_auth_required` to the missing-account state, so the user saw a generic failure instead of "connect an account" > - This pull request classifies the structured APIError status 401 as `opencode_hello_probe_auth_required` > - The benefit is that a member without a working account shows the missing-account state and its fix ## Linked Issues or Issue Description **What happened** An OpenCode (native runner) agent with an empty OpenRouter key failed its readiness test with the generic error `opencode_hello_probe_failed`. The chat eval `teams.configure-team` could not form its "one member is missing an account, one is ready" fixture because of this. **Expected behavior** The readiness test reports `opencode_hello_probe_auth_required` (warn) when the provider rejects authentication with HTTP 401, whatever the provider's wording. **Steps to reproduce** 1. Configure a native runner OpenCode agent with model `openrouter/deepseek/deepseek-v4.1-flash-1017` and `OPENROUTER_API_KEY` set to an empty string. 2. Run the agent's environment test. 3. OpenCode 1.18.34 prints `{"type":"error","error":{"name":"APIError","data":{"message":"Missing Authentication header","statusCode":401}}}`. 4. The result is `opencode_hello_probe_failed` (error), not the missing-authentication check. ## What Changed - `parse.ts`: add `openCodeErrorStatusCodes`, which reads the HTTP status from OpenCode error events. - `test.ts`: treat a 401 status on the hello probe as `opencode_hello_probe_auth_required`, in addition to the existing wording match. - `test.remote.test.ts`: regression tests with the two recorded OpenRouter 401 outputs, and a negative control (500 and 429 stay generic failures). ## Verification - `pnpm exec vitest run packages/adapters/opencode-local/src/server/test.remote.test.ts packages/adapters/opencode-local/src/server/parse.test.ts` passes (28 tests). - Red check: without the classifier change, the two new 401 tests fail. - `npx tsc --noEmit -p packages/adapters/opencode-local` passes. - I recorded the probe output from the pinned OpenCode 1.18.34 binary against OpenRouter with an empty key and with an unknown key. ## Risks - Low risk. Only a 401 status changes classification. A 401 means the provider rejected the credential. Other statuses keep their current result. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../opencode-local/src/server/parse.ts | 11 +++++++++ .../src/server/test.remote.test.ts | 23 +++++++++++++++++++ .../opencode-local/src/server/test.ts | 5 ++-- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/parse.ts b/packages/adapters/opencode-local/src/server/parse.ts index c244fa0e102..db2d1e1e7cc 100644 --- a/packages/adapters/opencode-local/src/server/parse.ts +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -106,6 +106,17 @@ export function createOpenCodeJsonlParser() { }; } +export function openCodeErrorStatusCodes(stdout: string): number[] { + const statuses: number[] = []; + for (const rawLine of stdout.split(/\r?\n/)) { + const event = parseJson(rawLine.trim()); + if (!event || asString(event.type, "") !== "error") continue; + const status = parseObject(parseObject(event.error).data).statusCode; + if (typeof status === "number" && Number.isInteger(status)) statuses.push(status); + } + return statuses; +} + export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean { const haystack = `${stdout}\n${stderr}` .split(/\r?\n/) diff --git a/packages/adapters/opencode-local/src/server/test.remote.test.ts b/packages/adapters/opencode-local/src/server/test.remote.test.ts index e83848e3f15..7a73d363edd 100644 --- a/packages/adapters/opencode-local/src/server/test.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/test.remote.test.ts @@ -119,6 +119,29 @@ describe("opencode remote environment diagnostics", () => { }, ); + it.each(["Missing Authentication header", "User not found."])( + "reports a provider 401 as missing authentication, not a generic failure (%s)", async message => { + const successfulProbe = await runAdapterExecutionTargetProcess.getMockImplementation()!(); + runAdapterExecutionTargetProcess.mockResolvedValueOnce({ ...successfulProbe, exitCode: 1, stderr: "", + stdout: JSON.stringify({ type: "error", sessionID: "session-1", error: { name: "APIError", + data: { message, statusCode: 401, isRetryable: false, responseHeaders: { "access-control-expose-headers": "WWW-Authenticate" } } } }) }); + const result = await testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", + config: { model: "openrouter/deepseek/deepseek-v4-flash-0731", env: { XDG_CONFIG_HOME: configHome } } }); + expect(result.status).toBe("warn"); + expect(result.checks.map(check => [check.code, check.level])).toContainEqual(["opencode_hello_probe_auth_required", "warn"]); + expect(result.checks.some(check => check.level === "error")).toBe(false); + }, + ); + + it.each([500, 429])("keeps other provider statuses a generic probe failure (%s)", async statusCode => { + const successfulProbe = await runAdapterExecutionTargetProcess.getMockImplementation()!(); + runAdapterExecutionTargetProcess.mockResolvedValueOnce({ ...successfulProbe, exitCode: 1, stderr: "", + stdout: JSON.stringify({ type: "error", error: { name: "APIError", data: { message: "Upstream error", statusCode } } }) }); + const result = await testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", + config: { model: "openrouter/deepseek/deepseek-v4-flash-0731", env: { XDG_CONFIG_HOME: configHome } } }); + expect(result.checks).toContainEqual(expect.objectContaining({ code: "opencode_hello_probe_failed", level: "error" })); + }); + it("gives each concurrent native probe its own OpenCode data directory", async () => { vi.stubEnv("HOME", "/host/user"); vi.stubEnv("XDG_DATA_HOME", "/host/user/.local/share"); diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index aad64db7234..367ac7c6aa1 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -27,7 +27,7 @@ import { overrideAdapterExecutionTargetRemoteCwd, } from "@paperclipai/adapter-utils/execution-target"; import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable, requireOpenCodeModelId } from "./models.js"; -import { parseOpenCodeJsonl } from "./parse.js"; +import { openCodeErrorStatusCodes, parseOpenCodeJsonl } from "./parse.js"; import { SANDBOX_INSTALL_COMMAND } from "../index.js"; import { prepareOpenCodeRuntimeConfig, prepareManagedOpenCodeRemoteHomes } from "./runtime-config.js"; @@ -418,6 +418,7 @@ export async function testEnvironment( const parsed = parseOpenCodeJsonl(probe.stdout); const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + const providerRejectedAuth = openCodeErrorStatusCodes(probe.stdout).includes(401); if (probe.timedOut) { checks.push({ @@ -450,7 +451,7 @@ export async function testEnvironment( ...(detail ? { detail } : {}), hint: "Run `opencode models` and choose an available provider/model ID.", }); - } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { + } else if (providerRejectedAuth || OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { checks.push({ code: "opencode_hello_probe_auth_required", level: "warn",