From 599e7accf60b76cb00f0bd1952664f63aa032bac Mon Sep 17 00:00:00 2001 From: shuage Date: Sun, 28 Jun 2026 16:33:03 +0800 Subject: [PATCH] fix: improve opencode session diagnostics --- src/services/ai/opencode-provider.ts | 68 +++++++++++++++++-- tests/opencode-provider.test.ts | 97 ++++++++++++++++++++++++++-- 2 files changed, 156 insertions(+), 9 deletions(-) diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index f812212..baa3443 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -15,6 +15,60 @@ import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/c let _connectedProviders: Set = new Set(); let _v2Client: OpencodeClient | undefined; +const _clientBaseUrls = new WeakMap(); +const REDACTED = "[REDACTED]"; +const SENSITIVE_KEY_PATTERN = + /(authorization|cookie|token|password|passwd|secret|apikey|privatekey)/; + +function getClientBaseUrl(client: OpencodeClient): string | undefined { + return _clientBaseUrls.get(client); +} + +function sanitizeBaseUrl(baseUrl: string): string { + try { + const url = new URL(baseUrl); + return `${url.origin}${url.pathname}`; + } catch { + return baseUrl; + } +} + +function isSensitiveKey(key: string): boolean { + const normalizedKey = key.replace(/[^a-z0-9]/gi, "").toLowerCase(); + return normalizedKey.length > 0 && SENSITIVE_KEY_PATTERN.test(normalizedKey); +} + +function summarizeValue(value: unknown, maxLength = 300): string { + const seen = new WeakSet(); + const replacer = (key: string, currentValue: unknown): unknown => { + if (isSensitiveKey(key)) { + return REDACTED; + } + if (currentValue instanceof Error) { + return { + name: currentValue.name, + message: currentValue.message, + }; + } + if (typeof currentValue === "object" && currentValue !== null) { + if (seen.has(currentValue)) { + return "[Circular]"; + } + seen.add(currentValue); + } + return currentValue; + }; + + try { + const json = JSON.stringify(value, replacer); + if (json === undefined) { + return String(value); + } + return json.length > maxLength ? `${json.slice(0, maxLength)}…` : json; + } catch (error) { + return `[unserializable: ${error instanceof Error ? error.message : String(error)}]`; + } +} export function setConnectedProviders(providers: string[]): void { _connectedProviders = new Set(providers); @@ -34,7 +88,9 @@ export function getV2Client(): OpencodeClient | undefined { export function createV2Client(serverUrl: URL | string): OpencodeClient { const baseUrl = typeof serverUrl === "string" ? serverUrl : serverUrl.toString(); - return createOpencodeClient({ baseUrl }); + const client = createOpencodeClient({ baseUrl }); + _clientBaseUrls.set(client, baseUrl); + return client; } export interface StructuredOutputOptions { @@ -74,9 +130,13 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< }); const sessionID = (created as { data?: { id?: string } })?.data?.id; if (!sessionID) { - throw new Error( - "opencode-mem: session.create returned no session id; cannot generate structured output" - ); + const diagnostics = ["opencode-mem: session.create returned no session id"]; + const baseUrl = getClientBaseUrl(client); + if (baseUrl) { + diagnostics.push(`baseUrl=${sanitizeBaseUrl(baseUrl)}`); + } + diagnostics.push(`response=${summarizeValue(created)}`); + throw new Error(`${diagnostics.join("; ")}; cannot generate structured output`); } try { diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index fc71ab8..e1b0a07 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -225,22 +225,109 @@ describe("generateStructuredOutput", () => { it("rejects when session.create returns no id", async () => { mock = installFetchMock((call) => { if (call.method === "POST" && call.url.endsWith("/session")) { - return { body: {} }; + return { + body: { + error: {}, + request: { + timeout: false, + authorization: "Bearer super-secret-auth", + token: "top-level-token", + cookie: "session=super-cookie", + "set-cookie": "refresh=super-set-cookie", + accessToken: "access-token-value", + refreshToken: "refresh-token-value", + }, + nested: { + secret: "nested-secret-value", + password: "nested-password-value", + clientSecret: "nested-client-secret", + secretKey: "nested-secret-key", + privateKey: "nested-private-key", + "private-key": "nested-private-key-dashed", + }, + }, + }; + } + throw new Error(`unexpected fetch: ${call.method} ${call.url}`); + }); + + const client = createV2Client("http://user:pass@127.0.0.1:9999/api?v=1#frag"); + try { + await generateStructuredOutput({ + client, + providerID: "anthropic", + modelID: "claude-haiku-4-5", + systemPrompt: "s", + userPrompt: "u", + schema, + }); + throw new Error("expected generateStructuredOutput to throw"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toMatch(/session\.create returned no session id/); + expect(message).toContain("http://127.0.0.1:9999/api"); + expect(message).not.toContain("user:pass"); + expect(message).not.toContain("?v=1"); + expect(message).not.toContain("#frag"); + expect(message).toContain('"error":{}'); + expect(message).toContain('"authorization":"[REDACTED]"'); + expect(message).toContain('"token":"[REDACTED]"'); + expect(message).toContain('"cookie":"[REDACTED]"'); + expect(message).toContain('"set-cookie":"[REDACTED]"'); + expect(message).toContain('"accessToken":"[REDACTED]"'); + expect(message).toContain('"refreshToken":"[REDACTED]"'); + expect(message).toContain('"secret":"[REDACTED]"'); + expect(message).toContain('"password":"[REDACTED]"'); + expect(message).toContain('"clientSecret":"[REDACTED]"'); + expect(message).not.toContain("super-secret-auth"); + expect(message).not.toContain("top-level-token"); + expect(message).not.toContain("super-cookie"); + expect(message).not.toContain("super-set-cookie"); + expect(message).not.toContain("access-token-value"); + expect(message).not.toContain("refresh-token-value"); + expect(message).not.toContain("nested-secret-value"); + expect(message).not.toContain("nested-password-value"); + expect(message).not.toContain("nested-client-secret"); + expect(message).not.toContain("nested-secret-key"); + expect(message).not.toContain("nested-private-key"); + expect(message).not.toContain("nested-private-key-dashed"); + expect(message).toContain("[REDACTED]"); + } + }); + + it("redacts private key variants in session.create diagnostics", async () => { + mock = installFetchMock((call) => { + if (call.method === "POST" && call.url.endsWith("/session")) { + return { + body: { + privateKey: "top-level-private-key", + "private-key": "dashed-private-key", + }, + }; } throw new Error(`unexpected fetch: ${call.method} ${call.url}`); }); const client = createV2Client("http://127.0.0.1:9999"); - await expect( - generateStructuredOutput({ + try { + await generateStructuredOutput({ client, providerID: "anthropic", modelID: "claude-haiku-4-5", systemPrompt: "s", userPrompt: "u", schema, - }) - ).rejects.toThrow(/session\.create returned no session id/); + }); + throw new Error("expected generateStructuredOutput to throw"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain('"privateKey":"[REDACTED]"'); + expect(message).toContain('"private-key":"[REDACTED]"'); + expect(message).not.toContain("top-level-private-key"); + expect(message).not.toContain("dashed-private-key"); + } }); it("swallows session.delete failure and still returns success", async () => {