From d11690b65391411d9991c022ceeacfd6baf7adf0 Mon Sep 17 00:00:00 2001 From: PanAchy Date: Sun, 18 Oct 2026 03:11:59 +0000 Subject: [PATCH] fix(zen): authenticate System One requests with bearer tokens Zen's System One route reads Authorization rather than the x-api-key header used by the Messages route. Send the key as a bearer token while preserving model selection and permission failure behavior. --- .changeset/zen-bearer-auth.md | 7 ++++ packages/core/src/zen.ts | 2 +- packages/core/test/zen.test.ts | 36 ++++++++++++++++++++- packages/permissions/test/engine.test.ts | 41 +++++++++++++++++++++++- 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 .changeset/zen-bearer-auth.md diff --git a/.changeset/zen-bearer-auth.md b/.changeset/zen-bearer-auth.md new file mode 100644 index 0000000..7e15b38 --- /dev/null +++ b/.changeset/zen-bearer-auth.md @@ -0,0 +1,7 @@ +--- +"@jevvy/permissions": patch +--- + +## Fixes + +Fix Jevvy Permissions authentication with OpenCode Zen by sending API keys as bearer tokens. Keep the default model and configured model selection unchanged. diff --git a/packages/core/src/zen.ts b/packages/core/src/zen.ts index cccc9ce..e434300 100644 --- a/packages/core/src/zen.ts +++ b/packages/core/src/zen.ts @@ -28,5 +28,5 @@ export const createZenClient = ( provider: "zen", url: ZEN_SYSTEMONE_URL, model, - headers: { "x-api-key": apiKey }, + headers: { Authorization: `Bearer ${apiKey}` }, }) diff --git a/packages/core/test/zen.test.ts b/packages/core/test/zen.test.ts index a101edf..0e80f3d 100644 --- a/packages/core/test/zen.test.ts +++ b/packages/core/test/zen.test.ts @@ -29,7 +29,8 @@ describe("Zen client", () => { expect(headers.get("accept")).toBe("application/json") expect(headers.get("content-type")).toBe("application/json") - expect(headers.get("x-api-key")).toBe("secret") + expect(headers.get("authorization")).toBe("Bearer secret") + expect(headers.has("x-api-key")).toBe(false) const body = init?.body @@ -44,6 +45,39 @@ describe("Zen client", () => { }) })) + it.effect("preserves the default model and complete shell command with bearer authentication", () => Effect.gen(function*() { + const transport = vi.fn().mockResolvedValue(Response.json({ + model: "jev-1.13-free", + answers: { routine: { type: "noul", noul: 0.9 } }, + })) + + const command = "cd packages/core && npm test > result.txt 2>&1; git status | cat" + + yield* createZenClient("secret").evaluate({ + state: { action: "shell", resource: command }, + questions: { routine: { type: "noul", instructions: "Is this routine?" } }, + }).pipe(Effect.provideService(FetchHttpClient.Fetch, transport)) + + expect(transport).toHaveBeenCalledOnce() + + const [url, init] = transport.mock.calls[0] ?? [] + const headers = new Headers(init?.headers) + + expect(String(url)).toBe(ZEN_SYSTEMONE_URL) + expect(init?.method).toBe("POST") + expect(headers.get("authorization")).toBe("Bearer secret") + expect(headers.has("x-api-key")).toBe(false) + expect(init?.body).toBeInstanceOf(Uint8Array) + + if (!(init?.body instanceof Uint8Array)) return + + expect(JSON.parse(new TextDecoder().decode(init.body))).toEqual({ + model: "jev-1.13-free", + state: { action: "shell", resource: command }, + questions: { routine: { type: "noul", instructions: "Is this routine?" } }, + }) + })) + it("rejects missing, malformed, and out-of-range answers", () => { const questions = { routine: { type: "noul" as const, instructions: "Routine?" } } diff --git a/packages/permissions/test/engine.test.ts b/packages/permissions/test/engine.test.ts index 931501a..e602468 100644 --- a/packages/permissions/test/engine.test.ts +++ b/packages/permissions/test/engine.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "@effect/vitest" import { Cause, Effect, Exit, Fiber } from "effect" import { TestClock } from "effect/testing" -import { JevProviderError } from "@jevvy/core" +import * as FetchHttpClient from "effect/http/FetchHttpClient" +import { createZenClient, JevProviderError } from "@jevvy/core" import type { JevClient, JevProviderError as JevProviderErrorType, JevRequest, JevResult } from "@jevvy/core" import { createPermissionReviewer } from "../src/engine.ts" import type { ApprovalQuestions } from "../src/questions.ts" @@ -21,6 +22,44 @@ const fakeClient = ( ): JevClient => ({ evaluate }) describe("permission reviewer", () => { + it.effect("abstains on Zen authentication failure without caching it and retries the complete command", () => Effect.gen(function*() { + const transport = vi.fn() + .mockResolvedValueOnce(Response.json({ + type: "error", + error: { type: "AuthError", message: "Invalid API key" }, + }, { status: 401 })) + .mockResolvedValue(Response.json({ answers: answer(0.01).answers, model: "jev-1.13-free" })) + + const reviewer = yield* createPermissionReviewer({ + evaluate: (request) => createZenClient("secret").evaluate(request).pipe(Effect.provideService(FetchHttpClient.Fetch, transport)), + }) + + const command = "cd packages/core && npm test > result.txt 2>&1; git status | cat" + + expect(yield* reviewer.review({ action: "shell", resources: [command] })).toMatchObject({ + effect: "ask", + reason: "unavailable", + failure: { provider: "zen", kind: "authentication", status: 401, code: "AuthError" }, + }) + expect(yield* reviewer.review({ action: "shell", resources: [command] })).toMatchObject({ effect: "allow" }) + expect(transport).toHaveBeenCalledTimes(2) + + for (const [, init] of transport.mock.calls) { + const headers = new Headers(init?.headers) + + expect(headers.get("authorization")).toBe("Bearer secret") + expect(headers.has("x-api-key")).toBe(false) + expect(init?.body).toBeInstanceOf(Uint8Array) + + if (!(init?.body instanceof Uint8Array)) continue + + expect(JSON.parse(new TextDecoder().decode(init.body))).toMatchObject({ + model: "jev-1.13-free", + state: { action: "shell", resource: command }, + }) + } + })) + it.effect("allows only when every resource independently passes every question", () => Effect.gen(function*() { const requests: JevRequest[] = []