From 281311e184f9414faa9bf2a9e954ff266919363c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=91=E6=A5=A0?= <17256544+zsn12@user.noreply.gitee.com> Date: Fri, 21 Aug 2026 10:16:28 +0800 Subject: [PATCH 1/3] feat(workbuddy): add experimental desktop OAuth provider Import WorkBuddy desktop sessions for the console proxy path (daily credits, not TokenHub keys), force upstream streaming, and sanitize WorkBuddy-only SSE noise before OpenAI clients parse it. Co-authored-by: Cursor --- src/adapters/registry.ts | 5 + src/adapters/workbuddy.ts | 196 +++++++++++++++++++++++++++++ src/oauth/index.ts | 8 ++ src/oauth/types.ts | 8 ++ src/oauth/workbuddy-credentials.ts | 166 ++++++++++++++++++++++++ src/oauth/workbuddy.ts | 76 +++++++++++ src/providers/registry.ts | 13 ++ tests/workbuddy-adapter.test.ts | 143 +++++++++++++++++++++ tests/workbuddy-oauth.test.ts | 149 ++++++++++++++++++++++ 9 files changed, 764 insertions(+) create mode 100644 src/adapters/workbuddy.ts create mode 100644 src/oauth/workbuddy-credentials.ts create mode 100644 src/oauth/workbuddy.ts create mode 100644 tests/workbuddy-adapter.test.ts create mode 100644 tests/workbuddy-oauth.test.ts diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index e9f54bcb8a..2d697a1247 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -7,6 +7,7 @@ import { createCursorAdapter } from "./cursor"; import { createGoogleAdapter } from "./google"; import { createKiroAdapter } from "./kiro"; import { createMimoFreeAdapter } from "./mimo-free"; +import { createWorkBuddyAdapter } from "./workbuddy"; import { createOpenAIChatAdapter } from "./openai-chat"; import { createResponsesPassthroughAdapter } from "./openai-responses"; import type { OcxProviderConfig } from "../types"; @@ -101,6 +102,10 @@ export const ADAPTER_REGISTRY = { contractParent: "openai-chat", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createMimoFreeAdapter(provider), }, + workbuddy: { + contractParent: "openai-chat", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createWorkBuddyAdapter(provider), + }, } as const satisfies Record; export type AdapterId = keyof typeof ADAPTER_REGISTRY; diff --git a/src/adapters/workbuddy.ts b/src/adapters/workbuddy.ts new file mode 100644 index 0000000000..ec7dd7b951 --- /dev/null +++ b/src/adapters/workbuddy.ts @@ -0,0 +1,196 @@ +import type { OcxParsedRequest, OcxProviderConfig } from "../types"; +import { createOpenAIChatAdapter } from "./openai-chat"; +import type { AdapterFetchContext, AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; +import type { TranslatorBudget } from "../lib/translator-budget"; +import type { AdapterTierMetadata } from "../providers/fastwire"; +import { + WORKBUDDY_UPSTREAM_CHAT_URL, + readWorkBuddyAuthHeaders, + runtimeWorkBuddyNativeInputs, +} from "../oauth/workbuddy-credentials"; + +export const WORKBUDDY_MODELS = [ + "workbuddy/deepseek-v4-flash", + "workbuddy/glm-5.3", + "workbuddy/kimi-k3", + "workbuddy/auto", +] as const; + +const UPSTREAM_MODEL_BY_ID: Readonly> = { + "workbuddy/deepseek-v4-flash": "deepseek-v4-flash", + "workbuddy/glm-5.3": "glm-5.3", + "workbuddy/kimi-k3": "kimi-k3", + "workbuddy/auto": "auto", + "deepseek-v4-flash": "deepseek-v4-flash", + "glm-5.3": "glm-5.3", + "kimi-k3": "kimi-k3", + auto: "auto", +}; + +function isCanonicalWorkBuddyEndpoint(baseUrl: string): boolean { + try { + const actual = new URL(baseUrl.trim()); + const expected = new URL(WORKBUDDY_UPSTREAM_CHAT_URL); + actual.pathname = actual.pathname.replace(/\/+$/, "") || "/"; + expected.pathname = expected.pathname.replace(/\/+$/, "") || "/"; + return actual.origin === expected.origin && actual.pathname === expected.pathname; + } catch { + return false; + } +} + +/** Map a routed WorkBuddy model id to the upstream console proxy model slug. */ +export function resolveWorkBuddyUpstreamModel(modelId: string): string { + const trimmed = modelId.trim(); + if (Object.hasOwn(UPSTREAM_MODEL_BY_ID, trimmed)) return UPSTREAM_MODEL_BY_ID[trimmed]!; + const bare = trimmed.startsWith("workbuddy/") ? trimmed.slice("workbuddy/".length) : trimmed; + return UPSTREAM_MODEL_BY_ID[bare] ?? bare; +} + +/** + * Strip WorkBuddy-only SSE events that break OpenAI-compatible clients: + * `event: conversationId` followed by a non-JSON `data: conv-*` line. + */ +export function sanitizeWorkBuddySseBlock(rawText: string): string { + const out: string[] = []; + const lines = rawText.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + if (line.startsWith("event:")) { + const eventName = line.slice(6).trim(); + if (eventName === "conversationId") { + if (i + 1 < lines.length && lines[i + 1]!.startsWith("data:")) i++; + } + continue; + } + if (!line.startsWith("data:")) continue; + const payload = line.slice(5).trim(); + if (payload === "[DONE]") { + out.push("data: [DONE]"); + continue; + } + try { + JSON.parse(payload); + out.push(`data: ${payload}`); + } catch { + /* drop non-JSON data lines such as conv-* ids */ + } + } + return out.length > 0 ? `${out.join("\n\n")}\n\n` : ""; +} + +function sanitizedSseReadableStream(source: ReadableStream): ReadableStream { + const reader = source.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + return new ReadableStream({ + async pull(controller) { + while (true) { + while (buffer.includes("\n\n")) { + const splitAt = buffer.indexOf("\n\n"); + const part = buffer.slice(0, splitAt); + buffer = buffer.slice(splitAt + 2); + const cleaned = sanitizeWorkBuddySseBlock(part); + if (cleaned) controller.enqueue(encoder.encode(cleaned)); + } + const { done, value } = await reader.read(); + if (done) { + if (buffer.trim()) { + const cleaned = sanitizeWorkBuddySseBlock(buffer); + if (cleaned) controller.enqueue(encoder.encode(cleaned)); + } + controller.close(); + return; + } + buffer += decoder.decode(value, { stream: true }); + } + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); +} + +function workBuddyHeadersFromProvider(provider: OcxProviderConfig): Record { + const authHeaders = readWorkBuddyAuthHeaders(runtimeWorkBuddyNativeInputs()); + if (provider.apiKey && provider.apiKey !== authHeaders.Authorization.slice("Bearer ".length)) { + authHeaders.Authorization = `Bearer ${provider.apiKey}`; + } + return authHeaders; +} + +/** + * WorkBuddy console proxy adapter. Imports the desktop OAuth session, forces upstream + * streaming (non-stream requests return error 11101), and sanitizes WorkBuddy-only SSE noise. + */ +export function createWorkBuddyAdapter(provider: OcxProviderConfig): ProviderAdapter { + if (!isCanonicalWorkBuddyEndpoint(provider.baseUrl)) { + throw new Error( + "The workbuddy adapter only supports the canonical WorkBuddy console proxy endpoint.", + ); + } + const base = createOpenAIChatAdapter(provider); + + return { + ...base, + name: "workbuddy", + + buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta): AdapterRequest { + const baseReq = base.buildRequest(parsed, incoming) as AdapterRequest; + const body = JSON.parse(baseReq.body as string) as Record; + body.model = resolveWorkBuddyUpstreamModel(String(body.model ?? parsed.modelId)); + body.stream = true; + const authHeaders = workBuddyHeadersFromProvider(provider); + const headers: Record = { + ...authHeaders, + Accept: "text/event-stream", + }; + return { + url: WORKBUDDY_UPSTREAM_CHAT_URL, + method: "POST", + headers, + body: JSON.stringify(body), + ...(baseReq.reasoningLog ? { reasoningLog: baseReq.reasoningLog } : {}), + ...(baseReq.tierLog ? { tierLog: baseReq.tierLog } : {}), + }; + }, + + async fetchResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise { + const response = await fetch(request.url, { + method: request.method, + headers: request.headers as Record, + body: request.body, + signal: ctx?.abortSignal, + }); + if (!response.ok || !response.body) return response; + const headers = new Headers(response.headers); + headers.set("Content-Type", "text/event-stream"); + return new Response(sanitizedSseReadableStream(response.body), { + status: response.status, + statusText: response.statusText, + headers, + }); + }, + + parseStream( + response: Response, + budget: TranslatorBudget, + tierMetadata?: AdapterTierMetadata, + ): ReturnType> { + return base.parseStream(response, budget, tierMetadata); + }, + + async parseResponse( + response: Response, + budget: TranslatorBudget, + tierMetadata?: AdapterTierMetadata, + ) { + const events = []; + for await (const event of base.parseStream(response, budget, tierMetadata)) { + events.push(event); + } + return events; + }, + }; +} diff --git a/src/oauth/index.ts b/src/oauth/index.ts index fe3abe7656..b0b3c4a782 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -14,6 +14,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity" import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { loginCommandCode, refreshCommandCodeToken } from "./command-code"; +import { loginWorkBuddy, refreshWorkBuddyToken } from "./workbuddy"; import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; @@ -217,6 +218,13 @@ export const OAUTH_PROVIDERS: Record = { providerConfig: oauthConfig("kiro"), defaultModel: oauthDefaultModel("kiro"), }, + workbuddy: { + login: (ctrl, opts) => loginWorkBuddy(ctrl, { importLocal: opts?.forceLogin ? "off" : "fallback" }), + refresh: (rt, signal, credential) => refreshWorkBuddyToken(rt, signal, credential), + providerConfig: oauthConfig("workbuddy"), + defaultModel: oauthDefaultModel("workbuddy"), + defaultRefreshPolicy: "lazy-only", + }, "google-antigravity": { login: (ctrl, opts) => loginAntigravity(ctrl, { forceAccountSelect: opts?.forceLogin === true }), refresh: refreshAntigravityToken, diff --git a/src/oauth/types.ts b/src/oauth/types.ts index e712f5b2a5..3073a76181 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -10,6 +10,12 @@ export interface KiroOAuthMetadata { clientSecret?: string; } +/** Account-scoped WorkBuddy headers required by the console proxy. */ +export interface WorkBuddyOAuthMetadata { + domain?: string; + enterpriseId?: string; +} + export type OAuthCredentials = { refresh: string; access: string; @@ -27,6 +33,8 @@ export type OAuthCredentials = { apiBaseUrl?: string; /** Never returned by management APIs; persisted only inside the protected auth-store boundary. */ kiro?: KiroOAuthMetadata; + /** WorkBuddy console proxy routing headers (domain / enterprise tenant). */ + workbuddy?: WorkBuddyOAuthMetadata; }; /** One logged-in account inside a provider's account set (multiauth). */ diff --git a/src/oauth/workbuddy-credentials.ts b/src/oauth/workbuddy-credentials.ts new file mode 100644 index 0000000000..79181430ba --- /dev/null +++ b/src/oauth/workbuddy-credentials.ts @@ -0,0 +1,166 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, win32 as win32Path } from "node:path"; +import type { OAuthCredentials } from "./types"; + +export const WORKBUDDY_UPSTREAM_CHAT_URL = "https://www.codebuddy.cn/console/as/chat/completions"; + +export interface WorkBuddyNativeInputs { + env: NodeJS.ProcessEnv; + platform: NodeJS.Platform; + home: string; +} + +interface WorkBuddyAuthFile { + auth?: { + accessToken?: unknown; + refreshToken?: unknown; + expiresAt?: unknown; + domain?: unknown; + }; + account?: { + uid?: unknown; + enterpriseId?: unknown; + }; +} + +export interface WorkBuddySessionSnapshot { + accessToken: string; + refreshToken: string; + /** Epoch milliseconds. */ + expires: number; + uid: string; + domain?: string; + enterpriseId?: string; +} + +export interface WorkBuddyAuthHeaders extends Record { + Authorization: string; + "X-User-Id": string; + "Content-Type": string; +} + +let cachedAuth: { path: string; mtimeMs: number; headers: WorkBuddyAuthHeaders } | null = null; + +/** Resolve the WorkBuddy desktop OAuth session file for the current platform. */ +export function resolveWorkBuddyAuthFilePath(inputs: WorkBuddyNativeInputs): string { + const override = inputs.env.WORKBUDDY_AUTH_FILE?.trim(); + if (override) return override; + if (inputs.platform === "darwin") { + return join( + inputs.home, + "Library/Application Support/CodeBuddyExtension/Data/Public/auth/workbuddy-desktop.info", + ); + } + if (inputs.platform === "win32") { + const appData = inputs.env.APPDATA?.trim() + || (inputs.env.USERPROFILE?.trim() + ? win32Path.join(inputs.env.USERPROFILE.trim(), "AppData", "Roaming") + : "") + || win32Path.join(inputs.home, "AppData", "Roaming"); + return win32Path.join(appData, "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info"); + } + const configHome = inputs.env.XDG_CONFIG_HOME?.trim() || join(inputs.home, ".config"); + return join(configHome, "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info"); +} + +function parseExpiresAt(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return value < 1_000_000_000_000 ? value * 1000 : value; +} + +/** Parse a WorkBuddy desktop session JSON payload into a normalized snapshot. */ +export function parseWorkBuddyAuthFile(raw: string): WorkBuddySessionSnapshot | null { + let parsed: WorkBuddyAuthFile; + try { + parsed = JSON.parse(raw) as WorkBuddyAuthFile; + } catch { + return null; + } + const accessToken = parsed.auth?.accessToken; + const refreshToken = parsed.auth?.refreshToken; + const uid = parsed.account?.uid; + if (typeof accessToken !== "string" || !accessToken.trim()) return null; + if (typeof refreshToken !== "string" || !refreshToken.trim()) return null; + if (typeof uid !== "string" || !uid.trim()) return null; + const domain = typeof parsed.auth?.domain === "string" && parsed.auth.domain.trim() + ? parsed.auth.domain.trim() + : undefined; + const enterpriseId = typeof parsed.account?.enterpriseId === "string" && parsed.account.enterpriseId.trim() + ? parsed.account.enterpriseId.trim() + : undefined; + return { + accessToken: accessToken.trim(), + refreshToken: refreshToken.trim(), + expires: parseExpiresAt(parsed.auth?.expiresAt), + uid: uid.trim(), + ...(domain ? { domain } : {}), + ...(enterpriseId ? { enterpriseId } : {}), + }; +} + +export function readWorkBuddySessionSnapshot(inputs: WorkBuddyNativeInputs): WorkBuddySessionSnapshot | null { + const path = resolveWorkBuddyAuthFilePath(inputs); + if (!existsSync(path)) return null; + try { + return parseWorkBuddyAuthFile(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +export function workBuddySessionToCredential(snapshot: WorkBuddySessionSnapshot): OAuthCredentials { + return { + access: snapshot.accessToken, + refresh: snapshot.refreshToken, + expires: snapshot.expires, + accountId: snapshot.uid, + source: "local-cli", + workbuddy: { + ...(snapshot.domain ? { domain: snapshot.domain } : {}), + ...(snapshot.enterpriseId ? { enterpriseId: snapshot.enterpriseId } : {}), + }, + }; +} + +export function buildWorkBuddyAuthHeaders(snapshot: WorkBuddySessionSnapshot): WorkBuddyAuthHeaders { + const headers: WorkBuddyAuthHeaders = { + Authorization: `Bearer ${snapshot.accessToken}`, + "X-User-Id": snapshot.uid, + "Content-Type": "application/json", + }; + if (snapshot.domain) headers["X-Domain"] = snapshot.domain; + if (snapshot.enterpriseId) { + headers["X-Enterprise-Id"] = snapshot.enterpriseId; + headers["X-Tenant-Id"] = snapshot.enterpriseId; + } + return headers; +} + +export function readWorkBuddyAuthHeaders(inputs: WorkBuddyNativeInputs): WorkBuddyAuthHeaders { + const path = resolveWorkBuddyAuthFilePath(inputs); + if (!existsSync(path)) { + throw new Error("WorkBuddy not logged in (auth file missing). Sign in to the WorkBuddy desktop app first."); + } + const stat = statSync(path); + if (cachedAuth && cachedAuth.path === path && cachedAuth.mtimeMs === stat.mtimeMs) { + return cachedAuth.headers; + } + const snapshot = readWorkBuddySessionSnapshot(inputs); + if (!snapshot) throw new Error("Invalid WorkBuddy desktop session"); + const headers = buildWorkBuddyAuthHeaders(snapshot); + cachedAuth = { path, mtimeMs: stat.mtimeMs, headers }; + return headers; +} + +/** Test hook: clear the in-process auth header cache. */ +export function resetWorkBuddyAuthCache(): void { + cachedAuth = null; +} + +export function runtimeWorkBuddyNativeInputs(): WorkBuddyNativeInputs { + const home = process.platform === "win32" + ? (process.env.USERPROFILE || homedir()) + : (process.env.HOME || homedir()); + return { env: process.env, platform: process.platform, home }; +} diff --git a/src/oauth/workbuddy.ts b/src/oauth/workbuddy.ts new file mode 100644 index 0000000000..76011c0455 --- /dev/null +++ b/src/oauth/workbuddy.ts @@ -0,0 +1,76 @@ +import type { OAuthController, OAuthCredentials } from "./types"; +import { + readWorkBuddySessionSnapshot, + resolveWorkBuddyAuthFilePath, + runtimeWorkBuddyNativeInputs, + workBuddySessionToCredential, + type WorkBuddyNativeInputs, +} from "./workbuddy-credentials"; + +export interface WorkBuddyLoginOptions { + /** Add-account flows must not silently reimport the current desktop session. */ + importLocal?: "fallback" | "off"; +} + +export function shouldImportLocalWorkBuddyAuth(options: WorkBuddyLoginOptions = {}): boolean { + return options.importLocal !== "off"; +} + +function nativeInputs(): WorkBuddyNativeInputs { + return runtimeWorkBuddyNativeInputs(); +} + +export function importLocalWorkBuddyAuth(inputs: WorkBuddyNativeInputs = nativeInputs()): OAuthCredentials | null { + const snapshot = readWorkBuddySessionSnapshot(inputs); + return snapshot ? workBuddySessionToCredential(snapshot) : null; +} + +export function workBuddyLoginGuidance(inputs: WorkBuddyNativeInputs = nativeInputs()): string { + return `Sign in to the WorkBuddy desktop app, then run \`ocx login workbuddy\` to import the session from ${resolveWorkBuddyAuthFilePath(inputs)}.`; +} + +export async function loginWorkBuddy( + ctrl: OAuthController, + options: WorkBuddyLoginOptions = {}, +): Promise { + if (ctrl.signal?.aborted) { + throw ctrl.signal.reason ?? new DOMException("WorkBuddy login aborted", "AbortError"); + } + if (shouldImportLocalWorkBuddyAuth(options)) { + const local = importLocalWorkBuddyAuth(); + if (local) { + ctrl.onProgress?.("Imported WorkBuddy desktop session."); + return local; + } + } + throw new Error( + `WorkBuddy desktop session not found. ${workBuddyLoginGuidance()}`, + ); +} + +export async function refreshWorkBuddyToken( + _refreshToken: string, + signal?: AbortSignal, + credential?: OAuthCredentials, +): Promise { + if (signal?.aborted) { + throw signal.reason ?? new DOMException("WorkBuddy refresh aborted", "AbortError"); + } + const fresh = importLocalWorkBuddyAuth(); + if (!fresh) { + throw new Error(`WorkBuddy desktop session missing. ${workBuddyLoginGuidance()}`); + } + if (credential?.accountId && fresh.accountId && credential.accountId !== fresh.accountId) { + throw new Error("WorkBuddy desktop session belongs to a different account; run ocx login workbuddy"); + } + return fresh; +} + +/** Convenience for tests that need a stable home without touching the real auth file. */ +export function workBuddyNativeInputsForHome(home: string, platform: NodeJS.Platform = process.platform): WorkBuddyNativeInputs { + return { + env: { ...process.env, HOME: platform === "win32" ? undefined : home, USERPROFILE: platform === "win32" ? home : undefined }, + platform, + home, + }; +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index bd25a5ea3f..17769031dd 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1182,6 +1182,19 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelContextWindows: KIRO_MODEL_CONTEXT_WINDOWS, modelReasoningEfforts: KIRO_MODEL_REASONING_EFFORTS, }, + { + id: "workbuddy", + label: "WorkBuddy (experimental)", + adapter: "workbuddy", + baseUrl: "https://www.codebuddy.cn/console/as/chat/completions", + authKind: "oauth", + oauthId: "workbuddy", + featured: false, + note: "Experimental WorkBuddy bridge. Import-first: sign in to the WorkBuddy desktop app, then run `ocx login workbuddy` to reuse its OAuth session and daily credits via the console proxy (not TokenHub API keys). Uses streaming upstream only.", + models: ["workbuddy/deepseek-v4-flash", "workbuddy/glm-5.3", "workbuddy/kimi-k3", "workbuddy/auto"], + defaultModel: "workbuddy/deepseek-v4-flash", + liveModels: false, + }, { // Nous Portal — Nous Research subscription gateway (same backend Hermes Agent // uses). OAuth is a device grant (src/oauth/nous.ts): the access token IS the diff --git a/tests/workbuddy-adapter.test.ts b/tests/workbuddy-adapter.test.ts new file mode 100644 index 0000000000..89069998af --- /dev/null +++ b/tests/workbuddy-adapter.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { deriveOAuthIds } from "../src/providers/derive"; +import { + createWorkBuddyAdapter, + resolveWorkBuddyUpstreamModel, + sanitizeWorkBuddySseBlock, + WORKBUDDY_MODELS, +} from "../src/adapters/workbuddy"; +import { WORKBUDDY_UPSTREAM_CHAT_URL, resetWorkBuddyAuthCache } from "../src/oauth/workbuddy-credentials"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +function minimalProvider(): OcxProviderConfig { + return { + adapter: "workbuddy", + baseUrl: WORKBUDDY_UPSTREAM_CHAT_URL, + authMode: "oauth", + apiKey: "stored-access-token", + models: [...WORKBUDDY_MODELS], + defaultModel: "workbuddy/deepseek-v4-flash", + }; +} + +function minimalRequest(model = "workbuddy/deepseek-v4-flash", stream = true): OcxParsedRequest { + return { + modelId: model, + stream, + context: { messages: [{ role: "user", content: "hello" }], tools: [] }, + options: {}, + }; +} + +describe("workbuddy provider registry", () => { + const entry = PROVIDER_REGISTRY.find(provider => provider.id === "workbuddy"); + + test("registry entry exists with expected shape", () => { + expect(entry).toBeDefined(); + expect(entry?.adapter).toBe("workbuddy"); + expect(entry?.authKind).toBe("oauth"); + expect(entry?.oauthId).toBe("workbuddy"); + expect(entry?.baseUrl).toBe(WORKBUDDY_UPSTREAM_CHAT_URL); + expect(entry?.defaultModel).toBe("workbuddy/deepseek-v4-flash"); + expect(entry?.liveModels).toBe(false); + expect(entry?.featured).toBe(false); + }); + + test("oauth id is registered for login", () => { + expect(deriveOAuthIds()).toContain("workbuddy"); + }); +}); + +describe("workbuddy model mapping", () => { + test("maps namespaced ids to upstream slugs", () => { + expect(resolveWorkBuddyUpstreamModel("workbuddy/deepseek-v4-flash")).toBe("deepseek-v4-flash"); + expect(resolveWorkBuddyUpstreamModel("workbuddy/auto")).toBe("auto"); + expect(resolveWorkBuddyUpstreamModel("deepseek-v4-flash")).toBe("deepseek-v4-flash"); + }); +}); + +describe("workbuddy SSE sanitize", () => { + test("drops conversationId events and non-JSON data lines", () => { + const raw = [ + "event: conversationId", + "data: conv-abc123", + "", + 'data: {"choices":[{"delta":{"content":"hi"}}]}', + "data: [DONE]", + ].join("\n"); + expect(sanitizeWorkBuddySseBlock(raw)).toBe( + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n', + ); + }); +}); + +describe("workbuddy adapter buildRequest", () => { + let tempHome = ""; + + beforeEach(() => { + resetWorkBuddyAuthCache(); + tempHome = mkdtempSync(join(tmpdir(), "workbuddy-adapter-")); + const authDir = join(tempHome, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth"); + mkdirSync(authDir, { recursive: true }); + process.env.WORKBUDDY_AUTH_FILE = join(authDir, "workbuddy-desktop.info"); + writeFileSync(process.env.WORKBUDDY_AUTH_FILE, JSON.stringify({ + auth: { + accessToken: "desktop-access-token", + refreshToken: "desktop-refresh-token", + expiresAt: 4_102_444_800_000, + domain: "personal.example.cn", + }, + account: { uid: "desktop-user" }, + }), "utf8"); + }); + + afterEach(() => { + delete process.env.WORKBUDDY_AUTH_FILE; + resetWorkBuddyAuthCache(); + rmSync(tempHome, { recursive: true, force: true }); + }); + + test("forces upstream streaming and injects WorkBuddy headers", () => { + const adapter = createWorkBuddyAdapter(minimalProvider()); + const request = adapter.buildRequest(minimalRequest(), { inboundWire: "chat" }); + expect(request.url).toBe(WORKBUDDY_UPSTREAM_CHAT_URL); + expect(request.headers?.Accept).toBe("text/event-stream"); + expect(request.headers?.Authorization).toBe("Bearer stored-access-token"); + expect(request.headers?.["X-User-Id"]).toBe("desktop-user"); + expect(request.headers?.["X-Domain"]).toBe("personal.example.cn"); + const body = JSON.parse(String(request.body)) as { model?: string; stream?: boolean }; + expect(body.model).toBe("deepseek-v4-flash"); + expect(body.stream).toBe(true); + }); + + test("fetchResponse sanitizes upstream SSE before parseStream", async () => { + const adapter = createWorkBuddyAdapter(minimalProvider()); + const upstream = [ + "event: conversationId", + "data: conv-should-drop", + "", + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + "data: [DONE]", + "", + ].join("\n"); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(upstream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + try { + const request = adapter.buildRequest(minimalRequest(), { inboundWire: "chat" }); + const response = await adapter.fetchResponse!(request, {}); + const text = await response.text(); + expect(text).toContain('"content":"ok"'); + expect(text).not.toContain("conv-should-drop"); + expect(text).not.toContain("conversationId"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/tests/workbuddy-oauth.test.ts b/tests/workbuddy-oauth.test.ts new file mode 100644 index 0000000000..d3609214d6 --- /dev/null +++ b/tests/workbuddy-oauth.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + buildWorkBuddyAuthHeaders, + parseWorkBuddyAuthFile, + readWorkBuddySessionSnapshot, + resetWorkBuddyAuthCache, + resolveWorkBuddyAuthFilePath, +} from "../src/oauth/workbuddy-credentials"; +import { + importLocalWorkBuddyAuth, + loginWorkBuddy, + refreshWorkBuddyToken, + workBuddyNativeInputsForHome, +} from "../src/oauth/workbuddy"; + +const SAMPLE_SESSION = { + auth: { + accessToken: "access-token-123", + refreshToken: "refresh-token-456", + expiresAt: 4_102_444_800_000, + domain: "personal.example.cn", + }, + account: { + uid: "user-uid-789", + enterpriseId: "ent-001", + }, +}; + +function writeSessionFile(dir: string, payload: unknown = SAMPLE_SESSION): string { + const authDir = join(dir, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth"); + mkdirSync(authDir, { recursive: true }); + const path = join(authDir, "workbuddy-desktop.info"); + writeFileSync(path, `${JSON.stringify(payload)}\n`, "utf8"); + return path; +} + +describe("workbuddy auth file resolution", () => { + test("darwin resolves Application Support path", () => { + expect(resolveWorkBuddyAuthFilePath({ + env: {}, + platform: "darwin", + home: "/Users/x", + })).toBe("/Users/x/Library/Application Support/CodeBuddyExtension/Data/Public/auth/workbuddy-desktop.info"); + }); + + test("win32 resolves APPDATA CodeBuddyExtension path", () => { + expect(resolveWorkBuddyAuthFilePath({ + env: { APPDATA: "C:\\Users\\u\\AppData\\Roaming" }, + platform: "win32", + home: "C:\\Users\\u", + })).toBe("C:\\Users\\u\\AppData\\Roaming\\CodeBuddyExtension\\Data\\Public\\auth\\workbuddy-desktop.info"); + }); + + test("WORKBUDDY_AUTH_FILE override wins", () => { + expect(resolveWorkBuddyAuthFilePath({ + env: { WORKBUDDY_AUTH_FILE: "/tmp/custom.info" }, + platform: "linux", + home: "/home/u", + })).toBe("/tmp/custom.info"); + }); +}); + +describe("workbuddy auth parsing", () => { + test("parseWorkBuddyAuthFile normalizes session fields", () => { + const snapshot = parseWorkBuddyAuthFile(JSON.stringify(SAMPLE_SESSION)); + expect(snapshot).toEqual({ + accessToken: "access-token-123", + refreshToken: "refresh-token-456", + expires: 4_102_444_800_000, + uid: "user-uid-789", + domain: "personal.example.cn", + enterpriseId: "ent-001", + }); + }); + + test("buildWorkBuddyAuthHeaders includes enterprise tenant headers", () => { + const snapshot = parseWorkBuddyAuthFile(JSON.stringify(SAMPLE_SESSION))!; + expect(buildWorkBuddyAuthHeaders(snapshot)).toEqual({ + Authorization: "Bearer access-token-123", + "X-User-Id": "user-uid-789", + "Content-Type": "application/json", + "X-Domain": "personal.example.cn", + "X-Enterprise-Id": "ent-001", + "X-Tenant-Id": "ent-001", + }); + }); +}); + +describe("workbuddy oauth login", () => { + let tempHome = ""; + + beforeEach(() => { + resetWorkBuddyAuthCache(); + tempHome = mkdtempSync(join(tmpdir(), "workbuddy-oauth-")); + process.env.WORKBUDDY_AUTH_FILE = writeSessionFile(tempHome); + }); + + afterEach(() => { + delete process.env.WORKBUDDY_AUTH_FILE; + resetWorkBuddyAuthCache(); + rmSync(tempHome, { recursive: true, force: true }); + }); + + test("importLocalWorkBuddyAuth reads desktop session", () => { + const cred = importLocalWorkBuddyAuth(workBuddyNativeInputsForHome(tempHome, "darwin")); + expect(cred?.access).toBe("access-token-123"); + expect(cred?.refresh).toBe("refresh-token-456"); + expect(cred?.accountId).toBe("user-uid-789"); + expect(cred?.source).toBe("local-cli"); + expect(cred?.workbuddy).toEqual({ + domain: "personal.example.cn", + enterpriseId: "ent-001", + }); + }); + + test("loginWorkBuddy imports local session", async () => { + const messages: string[] = []; + const cred = await loginWorkBuddy({ + onProgress: message => messages.push(message), + }); + expect(cred.access).toBe("access-token-123"); + expect(messages).toContain("Imported WorkBuddy desktop session."); + }); + + test("refreshWorkBuddyToken re-reads the desktop session", async () => { + const initial = await loginWorkBuddy({}); + const refreshed = await refreshWorkBuddyToken(initial.refresh, undefined, initial); + expect(refreshed.access).toBe("access-token-123"); + expect(refreshed.accountId).toBe("user-uid-789"); + }); + + test("refresh rejects a mismatched desktop account", async () => { + const initial = await loginWorkBuddy({}); + writeFileSync(process.env.WORKBUDDY_AUTH_FILE!, JSON.stringify({ + ...SAMPLE_SESSION, + account: { uid: "other-user" }, + }), "utf8"); + resetWorkBuddyAuthCache(); + await expect(refreshWorkBuddyToken(initial.refresh, undefined, initial)).rejects.toThrow(/different account/); + }); + + test("readWorkBuddySessionSnapshot returns null for invalid JSON", () => { + writeFileSync(process.env.WORKBUDDY_AUTH_FILE!, "{not-json", "utf8"); + expect(readWorkBuddySessionSnapshot(workBuddyNativeInputsForHome(tempHome, "darwin"))).toBeNull(); + }); +}); From 2821852d849c7d6a07b8ce2c58bde2e7ff175239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=91=E6=A5=A0?= <17256544+zsn12@user.noreply.gitee.com> Date: Fri, 21 Aug 2026 10:31:54 +0800 Subject: [PATCH 2/3] fix(workbuddy): address auth-boundary review feedback Recognize CRLF SSE delimiters, merge base request headers, keep forceLogin on local import, and reject malformed desktop session expiry before caching credentials. Co-authored-by: Cursor --- src/adapters/workbuddy.ts | 24 ++++++++++++--- src/oauth/index.ts | 2 +- src/oauth/workbuddy-credentials.ts | 12 +++++--- tests/workbuddy-adapter.test.ts | 49 ++++++++++++++++++++++++++++++ tests/workbuddy-oauth.test.ts | 31 +++++++++++++++++++ 5 files changed, 108 insertions(+), 10 deletions(-) diff --git a/src/adapters/workbuddy.ts b/src/adapters/workbuddy.ts index ec7dd7b951..b873ed6f97 100644 --- a/src/adapters/workbuddy.ts +++ b/src/adapters/workbuddy.ts @@ -53,7 +53,7 @@ export function resolveWorkBuddyUpstreamModel(modelId: string): string { */ export function sanitizeWorkBuddySseBlock(rawText: string): string { const out: string[] = []; - const lines = rawText.split("\n"); + const lines = rawText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]!; if (line.startsWith("event:")) { @@ -79,6 +79,16 @@ export function sanitizeWorkBuddySseBlock(rawText: string): string { return out.length > 0 ? `${out.join("\n\n")}\n\n` : ""; } +/** Locate the next SSE record delimiter (`\\n\\n` or `\\r\\n\\r\\n`). */ +export function findWorkBuddySseRecordEnd(buffer: string): { end: number; delimiterLength: number } | null { + const lf = buffer.indexOf("\n\n"); + const crlf = buffer.indexOf("\r\n\r\n"); + if (lf === -1 && crlf === -1) return null; + if (lf === -1) return { end: crlf, delimiterLength: 4 }; + if (crlf === -1) return { end: lf, delimiterLength: 2 }; + return crlf < lf ? { end: crlf, delimiterLength: 4 } : { end: lf, delimiterLength: 2 }; +} + function sanitizedSseReadableStream(source: ReadableStream): ReadableStream { const reader = source.getReader(); const decoder = new TextDecoder(); @@ -87,12 +97,13 @@ function sanitizedSseReadableStream(source: ReadableStream): Readabl return new ReadableStream({ async pull(controller) { while (true) { - while (buffer.includes("\n\n")) { - const splitAt = buffer.indexOf("\n\n"); - const part = buffer.slice(0, splitAt); - buffer = buffer.slice(splitAt + 2); + let boundary = findWorkBuddySseRecordEnd(buffer); + while (boundary) { + const part = buffer.slice(0, boundary.end); + buffer = buffer.slice(boundary.end + boundary.delimiterLength); const cleaned = sanitizeWorkBuddySseBlock(part); if (cleaned) controller.enqueue(encoder.encode(cleaned)); + boundary = findWorkBuddySseRecordEnd(buffer); } const { done, value } = await reader.read(); if (done) { @@ -141,9 +152,12 @@ export function createWorkBuddyAdapter(provider: OcxProviderConfig): ProviderAda const body = JSON.parse(baseReq.body as string) as Record; body.model = resolveWorkBuddyUpstreamModel(String(body.model ?? parsed.modelId)); body.stream = true; + const baseHeaders = (baseReq.headers ?? {}) as Record; const authHeaders = workBuddyHeadersFromProvider(provider); const headers: Record = { + ...baseHeaders, ...authHeaders, + "Content-Type": "application/json", Accept: "text/event-stream", }; return { diff --git a/src/oauth/index.ts b/src/oauth/index.ts index b0b3c4a782..320b7613fe 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -219,7 +219,7 @@ export const OAUTH_PROVIDERS: Record = { defaultModel: oauthDefaultModel("kiro"), }, workbuddy: { - login: (ctrl, opts) => loginWorkBuddy(ctrl, { importLocal: opts?.forceLogin ? "off" : "fallback" }), + login: (ctrl) => loginWorkBuddy(ctrl), refresh: (rt, signal, credential) => refreshWorkBuddyToken(rt, signal, credential), providerConfig: oauthConfig("workbuddy"), defaultModel: oauthDefaultModel("workbuddy"), diff --git a/src/oauth/workbuddy-credentials.ts b/src/oauth/workbuddy-credentials.ts index 79181430ba..5fe84e49ae 100644 --- a/src/oauth/workbuddy-credentials.ts +++ b/src/oauth/workbuddy-credentials.ts @@ -64,9 +64,11 @@ export function resolveWorkBuddyAuthFilePath(inputs: WorkBuddyNativeInputs): str return join(configHome, "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info"); } -function parseExpiresAt(value: unknown): number { - if (typeof value !== "number" || !Number.isFinite(value)) return 0; - return value < 1_000_000_000_000 ? value * 1000 : value; +function parseExpiresAt(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; + const normalized = value < 1_000_000_000_000 ? value * 1000 : value; + if (!Number.isFinite(normalized) || normalized <= 0) return null; + return normalized; } /** Parse a WorkBuddy desktop session JSON payload into a normalized snapshot. */ @@ -89,10 +91,12 @@ export function parseWorkBuddyAuthFile(raw: string): WorkBuddySessionSnapshot | const enterpriseId = typeof parsed.account?.enterpriseId === "string" && parsed.account.enterpriseId.trim() ? parsed.account.enterpriseId.trim() : undefined; + const expires = parseExpiresAt(parsed.auth?.expiresAt); + if (expires === null) return null; return { accessToken: accessToken.trim(), refreshToken: refreshToken.trim(), - expires: parseExpiresAt(parsed.auth?.expiresAt), + expires, uid: uid.trim(), ...(domain ? { domain } : {}), ...(enterpriseId ? { enterpriseId } : {}), diff --git a/tests/workbuddy-adapter.test.ts b/tests/workbuddy-adapter.test.ts index 89069998af..ef8a66498a 100644 --- a/tests/workbuddy-adapter.test.ts +++ b/tests/workbuddy-adapter.test.ts @@ -6,6 +6,7 @@ import { PROVIDER_REGISTRY } from "../src/providers/registry"; import { deriveOAuthIds } from "../src/providers/derive"; import { createWorkBuddyAdapter, + findWorkBuddySseRecordEnd, resolveWorkBuddyUpstreamModel, sanitizeWorkBuddySseBlock, WORKBUDDY_MODELS, @@ -73,6 +74,11 @@ describe("workbuddy SSE sanitize", () => { 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n', ); }); + + test("findWorkBuddySseRecordEnd recognizes LF and CRLF delimiters", () => { + expect(findWorkBuddySseRecordEnd('data: {"x":1}\n\nrest')).toEqual({ end: 13, delimiterLength: 2 }); + expect(findWorkBuddySseRecordEnd('data: {"x":1}\r\n\r\nrest')).toEqual({ end: 13, delimiterLength: 4 }); + }); }); describe("workbuddy adapter buildRequest", () => { @@ -106,6 +112,7 @@ describe("workbuddy adapter buildRequest", () => { const request = adapter.buildRequest(minimalRequest(), { inboundWire: "chat" }); expect(request.url).toBe(WORKBUDDY_UPSTREAM_CHAT_URL); expect(request.headers?.Accept).toBe("text/event-stream"); + expect(request.headers?.["Content-Type"]).toBe("application/json"); expect(request.headers?.Authorization).toBe("Bearer stored-access-token"); expect(request.headers?.["X-User-Id"]).toBe("desktop-user"); expect(request.headers?.["X-Domain"]).toBe("personal.example.cn"); @@ -114,6 +121,48 @@ describe("workbuddy adapter buildRequest", () => { expect(body.stream).toBe(true); }); + test("merges configured provider headers from the base openai-chat request", () => { + const adapter = createWorkBuddyAdapter({ + ...minimalProvider(), + headers: { "X-Custom-Trace": "keep-me" }, + }); + const request = adapter.buildRequest(minimalRequest(), { inboundWire: "chat" }); + expect(request.headers?.["X-Custom-Trace"]).toBe("keep-me"); + expect(request.headers?.["Content-Type"]).toBe("application/json"); + expect(request.headers?.Accept).toBe("text/event-stream"); + }); + + test("fetchResponse emits CRLF-framed SSE before upstream EOF", async () => { + const adapter = createWorkBuddyAdapter(minimalProvider()); + const encoder = new TextEncoder(); + let sent = false; + const upstream = new ReadableStream({ + pull(controller) { + if (sent) { + controller.close(); + return; + } + sent = true; + controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"early"}}]}\r\n\r\n')); + }, + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(upstream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + try { + const request = adapter.buildRequest(minimalRequest(), { inboundWire: "chat" }); + const response = await adapter.fetchResponse!(request, {}); + const reader = response.body!.getReader(); + const first = await reader.read(); + expect(new TextDecoder().decode(first.value)).toContain('"content":"early"'); + reader.releaseLock(); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("fetchResponse sanitizes upstream SSE before parseStream", async () => { const adapter = createWorkBuddyAdapter(minimalProvider()); const upstream = [ diff --git a/tests/workbuddy-oauth.test.ts b/tests/workbuddy-oauth.test.ts index d3609214d6..b261b59d49 100644 --- a/tests/workbuddy-oauth.test.ts +++ b/tests/workbuddy-oauth.test.ts @@ -15,6 +15,7 @@ import { refreshWorkBuddyToken, workBuddyNativeInputsForHome, } from "../src/oauth/workbuddy"; +import { OAUTH_PROVIDERS } from "../src/oauth/index"; const SAMPLE_SESSION = { auth: { @@ -87,6 +88,30 @@ describe("workbuddy auth parsing", () => { "X-Tenant-Id": "ent-001", }); }); + + test("parseWorkBuddyAuthFile rejects malformed expiresAt values", () => { + const withoutExpires = { + ...SAMPLE_SESSION, + auth: { + accessToken: SAMPLE_SESSION.auth.accessToken, + refreshToken: SAMPLE_SESSION.auth.refreshToken, + domain: SAMPLE_SESSION.auth.domain, + }, + }; + expect(parseWorkBuddyAuthFile(JSON.stringify(withoutExpires))).toBeNull(); + expect(parseWorkBuddyAuthFile(JSON.stringify({ + ...SAMPLE_SESSION, + auth: { ...SAMPLE_SESSION.auth, expiresAt: "not-a-number" }, + }))).toBeNull(); + expect(parseWorkBuddyAuthFile(JSON.stringify({ + ...SAMPLE_SESSION, + auth: { ...SAMPLE_SESSION.auth, expiresAt: 0 }, + }))).toBeNull(); + expect(parseWorkBuddyAuthFile(JSON.stringify({ + ...SAMPLE_SESSION, + auth: { ...SAMPLE_SESSION.auth, expiresAt: Number.POSITIVE_INFINITY }, + }))).toBeNull(); + }); }); describe("workbuddy oauth login", () => { @@ -125,6 +150,12 @@ describe("workbuddy oauth login", () => { expect(messages).toContain("Imported WorkBuddy desktop session."); }); + test("registered forceLogin still imports the desktop session", async () => { + const cred = await OAUTH_PROVIDERS.workbuddy!.login({}, { forceLogin: true }); + expect(cred.access).toBe("access-token-123"); + expect(cred.accountId).toBe("user-uid-789"); + }); + test("refreshWorkBuddyToken re-reads the desktop session", async () => { const initial = await loginWorkBuddy({}); const refreshed = await refreshWorkBuddyToken(initial.refresh, undefined, initial); From 67acb3313e8f8f9c6c6ee24fb06e881f8fcaf965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=91=E6=A5=A0?= <17256544+zsn12@user.noreply.gitee.com> Date: Fri, 21 Aug 2026 11:09:36 +0800 Subject: [PATCH 3/3] fix(workbuddy): merge request headers with case-insensitive Headers Replace object spread with Headers.set so a configured lowercase authorization cannot coexist with the WorkBuddy bearer on the wire. Co-authored-by: Cursor --- src/adapters/workbuddy.ts | 38 ++++++++++++++++++++++------ tests/workbuddy-adapter.test.ts | 45 +++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/adapters/workbuddy.ts b/src/adapters/workbuddy.ts index b873ed6f97..382b21bab4 100644 --- a/src/adapters/workbuddy.ts +++ b/src/adapters/workbuddy.ts @@ -131,6 +131,29 @@ function workBuddyHeadersFromProvider(provider: OcxProviderConfig): Record | undefined, + authHeaders: Record, +): Record { + const merged = new Headers(); + if (baseHeaders) { + for (const [name, value] of Object.entries(baseHeaders)) { + merged.set(name, value); + } + } + for (const [name, value] of Object.entries(authHeaders)) { + merged.set(name, value); + } + merged.set("Content-Type", "application/json"); + merged.set("Accept", "text/event-stream"); + const out: Record = {}; + merged.forEach((value, name) => { + out[name] = value; + }); + return out; +} + /** * WorkBuddy console proxy adapter. Imports the desktop OAuth session, forces upstream * streaming (non-stream requests return error 11101), and sanitizes WorkBuddy-only SSE noise. @@ -152,14 +175,10 @@ export function createWorkBuddyAdapter(provider: OcxProviderConfig): ProviderAda const body = JSON.parse(baseReq.body as string) as Record; body.model = resolveWorkBuddyUpstreamModel(String(body.model ?? parsed.modelId)); body.stream = true; - const baseHeaders = (baseReq.headers ?? {}) as Record; - const authHeaders = workBuddyHeadersFromProvider(provider); - const headers: Record = { - ...baseHeaders, - ...authHeaders, - "Content-Type": "application/json", - Accept: "text/event-stream", - }; + const headers = mergeWorkBuddyRequestHeaders( + baseReq.headers as Record | undefined, + workBuddyHeadersFromProvider(provider), + ); return { url: WORKBUDDY_UPSTREAM_CHAT_URL, method: "POST", @@ -200,6 +219,9 @@ export function createWorkBuddyAdapter(provider: OcxProviderConfig): ProviderAda budget: TranslatorBudget, tierMetadata?: AdapterTierMetadata, ) { + // Upstream rejects non-stream requests with WorkBuddy error 11101, so the wire + // always streams; drain the sanitized SSE through parseStream for callers that + // requested a non-streaming completion. const events = []; for await (const event of base.parseStream(response, budget, tierMetadata)) { events.push(event); diff --git a/tests/workbuddy-adapter.test.ts b/tests/workbuddy-adapter.test.ts index ef8a66498a..211982d079 100644 --- a/tests/workbuddy-adapter.test.ts +++ b/tests/workbuddy-adapter.test.ts @@ -7,6 +7,7 @@ import { deriveOAuthIds } from "../src/providers/derive"; import { createWorkBuddyAdapter, findWorkBuddySseRecordEnd, + mergeWorkBuddyRequestHeaders, resolveWorkBuddyUpstreamModel, sanitizeWorkBuddySseBlock, WORKBUDDY_MODELS, @@ -111,11 +112,10 @@ describe("workbuddy adapter buildRequest", () => { const adapter = createWorkBuddyAdapter(minimalProvider()); const request = adapter.buildRequest(minimalRequest(), { inboundWire: "chat" }); expect(request.url).toBe(WORKBUDDY_UPSTREAM_CHAT_URL); - expect(request.headers?.Accept).toBe("text/event-stream"); - expect(request.headers?.["Content-Type"]).toBe("application/json"); - expect(request.headers?.Authorization).toBe("Bearer stored-access-token"); - expect(request.headers?.["X-User-Id"]).toBe("desktop-user"); - expect(request.headers?.["X-Domain"]).toBe("personal.example.cn"); + expect(request.headers?.Accept ?? request.headers?.accept).toBe("text/event-stream"); + expect(request.headers?.Authorization ?? request.headers?.authorization).toBe("Bearer stored-access-token"); + expect(request.headers?.["X-User-Id"] ?? request.headers?.["x-user-id"]).toBe("desktop-user"); + expect(request.headers?.["X-Domain"] ?? request.headers?.["x-domain"]).toBe("personal.example.cn"); const body = JSON.parse(String(request.body)) as { model?: string; stream?: boolean }; expect(body.model).toBe("deepseek-v4-flash"); expect(body.stream).toBe(true); @@ -127,9 +127,38 @@ describe("workbuddy adapter buildRequest", () => { headers: { "X-Custom-Trace": "keep-me" }, }); const request = adapter.buildRequest(minimalRequest(), { inboundWire: "chat" }); - expect(request.headers?.["X-Custom-Trace"]).toBe("keep-me"); - expect(request.headers?.["Content-Type"]).toBe("application/json"); - expect(request.headers?.Accept).toBe("text/event-stream"); + expect(request.headers?.["X-Custom-Trace"] ?? request.headers?.["x-custom-trace"]).toBe("keep-me"); + expect(request.headers?.["Content-Type"] ?? request.headers?.["content-type"]).toBe("application/json"); + expect(request.headers?.Accept ?? request.headers?.accept).toBe("text/event-stream"); + }); + + test("replaces a lowercase authorization header with the WorkBuddy bearer", () => { + const adapter = createWorkBuddyAdapter({ + ...minimalProvider(), + headers: { authorization: "Bearer stale-key" }, + }); + const request = adapter.buildRequest(minimalRequest(), { inboundWire: "chat" }); + const authKeys = Object.keys(request.headers ?? {}).filter(key => key.toLowerCase() === "authorization"); + expect(authKeys).toHaveLength(1); + const bearer = request.headers?.[authKeys[0]!]; + expect(bearer).toBe("Bearer stored-access-token"); + }); + + test("mergeWorkBuddyRequestHeaders collapses case-insensitive duplicates", () => { + const merged = mergeWorkBuddyRequestHeaders( + { authorization: "Bearer stale", "x-trace": "keep" }, + { + Authorization: "Bearer fresh", + "X-User-Id": "uid-1", + "Content-Type": "application/json", + }, + ); + const authKeys = Object.keys(merged).filter(key => key.toLowerCase() === "authorization"); + expect(authKeys).toHaveLength(1); + expect(merged[authKeys[0]!]).toBe("Bearer fresh"); + expect(merged["x-trace"]).toBe("keep"); + expect(merged["Content-Type"] ?? merged["content-type"]).toBe("application/json"); + expect(merged.Accept ?? merged.accept).toBe("text/event-stream"); }); test("fetchResponse emits CRLF-framed SSE before upstream EOF", async () => {