From 00f33c256aa9fb8e993020d89b3ac58487793d23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:35:28 +0000 Subject: [PATCH 1/2] Add auth API seamline and Claude proxy Agent-Logs-Url: https://github.com/OpenKnots/okcode/sessions/6cd8bf08-8ada-493c-b8a5-fc5b9a543673 Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> --- apps/server/src/api/authRouter.ts | 255 +++++++++++++++++++++++++++++ apps/server/src/api/router.test.ts | 207 +++++++++++++++++++++++ apps/server/src/api/router.ts | 44 +++++ apps/server/src/wsServer.ts | 38 +---- 4 files changed, 514 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/api/authRouter.ts create mode 100644 apps/server/src/api/router.test.ts create mode 100644 apps/server/src/api/router.ts diff --git a/apps/server/src/api/authRouter.ts b/apps/server/src/api/authRouter.ts new file mode 100644 index 000000000..058451a40 --- /dev/null +++ b/apps/server/src/api/authRouter.ts @@ -0,0 +1,255 @@ +import http from "node:http"; +import type { TokenManager } from "../tokenManager.ts"; + +const PAIRING_PATHS = new Set(["/api/pairing", "/api/auth/pairing"]); +const ANTHROPIC_PROXY_PREFIX = "/api/auth/anthropic"; +const ANTHROPIC_MESSAGES_PATH_PREFIX = `${ANTHROPIC_PROXY_PREFIX}/v1/messages`; +const CLAUDE_CODE_BETA = "claude-code-20250219"; +const CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."; +const DEFAULT_ANTHROPIC_VERSION = "2023-06-01"; +const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com"; + +interface AuthApiRouterOptions { + readonly authToken: string | undefined; + readonly host: string | undefined; + readonly port: number; + readonly tokenManager: TokenManager; + readonly fetchImpl?: typeof fetch; + readonly anthropicBaseUrl?: string; +} + +function respondJson( + res: http.ServerResponse, + statusCode: number, + body: unknown, + headers?: Record, +): void { + res.writeHead(statusCode, { + "Content-Type": "application/json", + ...(headers ?? {}), + }); + res.end(JSON.stringify(body)); +} + +function buildServerUrl(host: string | undefined, port: number): string { + const effectiveHost = + !host || host === "0.0.0.0" || host === "::" || host === "[::]" ? "localhost" : host; + const formattedHost = + effectiveHost.includes(":") && !effectiveHost.startsWith("[") + ? `[${effectiveHost}]` + : effectiveHost; + return `http://${formattedHost}:${port}`; +} + +function mergeAnthropicBetaHeader(value: string | null): string { + const parts = (value ?? "") + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + if (!parts.includes(CLAUDE_CODE_BETA)) { + parts.unshift(CLAUDE_CODE_BETA); + } + return parts.join(","); +} + +async function readJsonRequestBody(req: http.IncomingMessage): Promise | null> { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk)); + } + + if (chunks.length === 0) { + return null; + } + + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; +} + +function injectClaudeCodeSystemPrompt(body: Record): Record { + const systemBlock = { type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }; + const existingSystem = body.system; + + if (existingSystem === undefined) { + return { + ...body, + system: [systemBlock], + }; + } + + if (typeof existingSystem === "string") { + return { + ...body, + system: [systemBlock, { type: "text", text: existingSystem }], + }; + } + + if (Array.isArray(existingSystem)) { + return { + ...body, + system: [systemBlock, ...existingSystem], + }; + } + + return { + ...body, + system: [systemBlock], + }; +} + +export function createAuthApiRouter(options: AuthApiRouterOptions) { + const fetchImpl = options.fetchImpl ?? fetch; + const anthropicBaseUrl = new URL(options.anthropicBaseUrl ?? DEFAULT_ANTHROPIC_BASE_URL); + const serverUrl = buildServerUrl(options.host, options.port); + let requestCount = 0; + + return async function tryHandleAuthApiRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + ): Promise { + if (PAIRING_PATHS.has(url.pathname) && req.method === "GET") { + if (!options.authToken) { + respondJson( + res, + 200, + { error: "Auth is not enabled on this server." }, + { "Access-Control-Allow-Origin": "*" }, + ); + return true; + } + + const ttlParam = url.searchParams.get("ttl"); + const ttlSeconds = ttlParam ? Math.min(Math.max(Number(ttlParam), 30), 3600) : 300; + const record = options.tokenManager.generatePairingToken({ ttlSeconds, label: "http-api" }); + const pairingUrl = `okcode://pair?server=${encodeURIComponent(serverUrl)}&token=${encodeURIComponent(record.tokenValue)}`; + respondJson( + res, + 200, + { + pairingUrl, + expiresAt: record.expiresAt, + serverUrl, + }, + { "Access-Control-Allow-Origin": "*" }, + ); + return true; + } + + if (url.pathname === `${ANTHROPIC_PROXY_PREFIX}/health` && req.method === "GET") { + respondJson(res, 200, { + status: "ok", + proxy: "anthropic", + upstreamOrigin: anthropicBaseUrl.origin, + }); + return true; + } + + if (url.pathname === `${ANTHROPIC_PROXY_PREFIX}/status` && req.method === "GET") { + respondJson(res, 200, { + status: "running", + proxy: "anthropic", + upstreamOrigin: anthropicBaseUrl.origin, + requestsServed: requestCount, + }); + return true; + } + + if (req.method !== "POST" || !url.pathname.startsWith(ANTHROPIC_MESSAGES_PATH_PREFIX)) { + return false; + } + + const apiKey = req.headers["x-api-key"]; + if (typeof apiKey !== "string" || apiKey.trim().length === 0) { + respondJson(res, 401, { error: "Missing x-api-key header." }); + return true; + } + + let body: Record | null; + try { + body = await readJsonRequestBody(req); + } catch { + respondJson(res, 400, { error: "Invalid JSON body." }); + return true; + } + + if (!body) { + respondJson(res, 400, { error: "Request body must be a JSON object." }); + return true; + } + + const proxiedBody = injectClaudeCodeSystemPrompt(body); + const upstreamPath = `${url.pathname.slice(ANTHROPIC_PROXY_PREFIX.length)}${url.search}`; + const upstreamUrl = new URL(upstreamPath, anthropicBaseUrl); + const payload = JSON.stringify(proxiedBody); + const headers = new Headers({ + "content-type": "application/json", + "anthropic-version": + typeof req.headers["anthropic-version"] === "string" + ? req.headers["anthropic-version"] + : DEFAULT_ANTHROPIC_VERSION, + "anthropic-beta": mergeAnthropicBetaHeader( + typeof req.headers["anthropic-beta"] === "string" ? req.headers["anthropic-beta"] : null, + ), + "x-api-key": apiKey, + }); + if (typeof req.headers.accept === "string" && req.headers.accept.length > 0) { + headers.set("accept", req.headers.accept); + } + if ( + typeof req.headers["anthropic-dangerous-direct-browser-access-control"] === "string" && + req.headers["anthropic-dangerous-direct-browser-access-control"].length > 0 + ) { + headers.set( + "anthropic-dangerous-direct-browser-access-control", + req.headers["anthropic-dangerous-direct-browser-access-control"], + ); + } + + let upstreamResponse: Response; + try { + upstreamResponse = await fetchImpl(upstreamUrl, { + method: "POST", + headers, + body: payload, + }); + requestCount += 1; + } catch (error) { + respondJson(res, 502, { + error: `Upstream error: ${error instanceof Error ? error.message : String(error)}`, + }); + return true; + } + + const responseHeaders = Object.fromEntries(upstreamResponse.headers.entries()); + res.writeHead(upstreamResponse.status, responseHeaders); + if (!upstreamResponse.body) { + res.end(); + return true; + } + + try { + const reader = upstreamResponse.body.getReader(); + while (true) { + const next = await reader.read(); + if (next.done) { + break; + } + if (!res.writableEnded) { + res.write(next.value); + } + } + if (!res.writableEnded) { + res.end(); + } + } catch { + if (!res.destroyed) { + res.destroy(); + } + } + return true; + }; +} diff --git a/apps/server/src/api/router.test.ts b/apps/server/src/api/router.test.ts new file mode 100644 index 000000000..b0ff1f7ce --- /dev/null +++ b/apps/server/src/api/router.test.ts @@ -0,0 +1,207 @@ +import http from "node:http"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createApiRouter } from "./router"; +import { TokenManager } from "../tokenManager"; + +interface HttpResponse { + readonly statusCode: number; + readonly body: string; +} + +async function withServer( + handler: http.RequestListener, + run: (baseUrl: string) => Promise, +): Promise { + const server = http.createServer(handler); + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", (error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + const address = server.address(); + if (typeof address !== "object" || address === null) { + throw new Error("Expected server address"); + } + + try { + await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +async function request( + baseUrl: string, + pathname: string, + init?: RequestInit, +): Promise { + const response = await fetch(`${baseUrl}${pathname}`, init); + return { + statusCode: response.status, + body: await response.text(), + }; +} + +describe("createApiRouter", () => { + const openServers = new Set(); + + afterEach(async () => { + await Promise.all( + Array.from(openServers).map( + (server) => + new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }), + ), + ); + openServers.clear(); + }); + + it("keeps the legacy pairing route working through the auth router seamline", async () => { + const tokenManager = new TokenManager("server-auth-token"); + const tryHandleApiRequest = createApiRouter({ + authToken: "server-auth-token", + host: "127.0.0.1", + port: 31337, + tokenManager, + }); + + await withServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + void tryHandleApiRequest(req, res, url); + }, async (baseUrl) => { + const response = await request(baseUrl, "/api/pairing?ttl=300"); + const body = JSON.parse(response.body) as { + pairingUrl: string; + serverUrl: string; + expiresAt: string; + }; + expect(response.statusCode).toBe(200); + expect(body.serverUrl).toBe("http://127.0.0.1:31337"); + expect(body.pairingUrl).toContain("okcode://pair?server="); + expect(body.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + }); + + it("proxies Anthropic message requests with the Claude Code envelope", async () => { + let upstreamHeaders: http.IncomingHttpHeaders | null = null; + let upstreamBody: Record | null = null; + + const upstreamServer = http.createServer(async (req, res) => { + upstreamHeaders = req.headers; + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk)); + } + upstreamBody = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, proxied: true })); + }); + openServers.add(upstreamServer); + await new Promise((resolve, reject) => { + upstreamServer.listen(0, "127.0.0.1", (error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + const upstreamAddress = upstreamServer.address(); + if (typeof upstreamAddress !== "object" || upstreamAddress === null) { + throw new Error("Expected upstream server address"); + } + + const tryHandleApiRequest = createApiRouter({ + authToken: undefined, + host: "127.0.0.1", + port: 31337, + tokenManager: new TokenManager(undefined), + anthropicBaseUrl: `http://127.0.0.1:${upstreamAddress.port}`, + }); + + await withServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + void tryHandleApiRequest(req, res, url); + }, async (baseUrl) => { + const response = await request(baseUrl, "/api/auth/anthropic/v1/messages?beta=true", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + "anthropic-version": "2023-06-01", + "anthropic-beta": "tools-2024-04-04", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-20250514", + max_tokens: 64, + system: "Original system prompt", + messages: [{ role: "user", content: "Hello" }], + stream: true, + }), + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual({ ok: true, proxied: true }); + expect(upstreamHeaders?.["x-api-key"]).toBe("test-key"); + expect(upstreamHeaders?.["anthropic-version"]).toBe("2023-06-01"); + expect(upstreamHeaders?.["anthropic-beta"]).toBe( + "claude-code-20250219,tools-2024-04-04", + ); + expect(upstreamBody).toMatchObject({ + model: "claude-sonnet-4-20250514", + stream: true, + }); + expect(upstreamBody?.system).toEqual([ + { + type: "text", + text: "You are Claude Code, Anthropic's official CLI for Claude.", + }, + { + type: "text", + text: "Original system prompt", + }, + ]); + }); + }); + + it("returns a JSON 404 for unknown API routes", async () => { + const tryHandleApiRequest = createApiRouter({ + authToken: undefined, + host: "127.0.0.1", + port: 31337, + tokenManager: new TokenManager(undefined), + }); + + await withServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + void tryHandleApiRequest(req, res, url); + }, async (baseUrl) => { + const response = await request(baseUrl, "/api/unknown"); + expect(response.statusCode).toBe(404); + expect(JSON.parse(response.body)).toEqual({ error: "Not Found" }); + }); + }); +}); diff --git a/apps/server/src/api/router.ts b/apps/server/src/api/router.ts new file mode 100644 index 000000000..d83c350b7 --- /dev/null +++ b/apps/server/src/api/router.ts @@ -0,0 +1,44 @@ +import http from "node:http"; + +import { tryHandleProjectFaviconRequest } from "../projectFaviconRoute.ts"; +import type { TokenManager } from "../tokenManager.ts"; +import { createAuthApiRouter } from "./authRouter.ts"; + +interface ApiRouterOptions { + readonly authToken: string | undefined; + readonly host: string | undefined; + readonly port: number; + readonly tokenManager: TokenManager; + readonly fetchImpl?: typeof fetch; + readonly anthropicBaseUrl?: string; +} + +function isApiPath(pathname: string): boolean { + return pathname === "/api" || pathname.startsWith("/api/"); +} + +export function createApiRouter(options: ApiRouterOptions) { + const tryHandleAuthApiRequest = createAuthApiRouter(options); + + return async function tryHandleApiRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + ): Promise { + if (!isApiPath(url.pathname)) { + return false; + } + + if (await tryHandleAuthApiRequest(req, res, url)) { + return true; + } + + if (tryHandleProjectFaviconRequest(url, res)) { + return true; + } + + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not Found" })); + return true; + }; +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 509c9b11f..4b16bffd1 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -72,7 +72,6 @@ import { Open, resolveAvailableEditors } from "./open"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; import { collectMergedWorktreeCleanupCandidates } from "./git/worktreeCleanup.ts"; -import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; import { ATTACHMENTS_ROUTE_PREFIX, normalizeAttachmentRelativePath, @@ -104,6 +103,7 @@ import { TerminalRuntimeEnvResolver } from "./terminal/Services/RuntimeEnvResolv import { version as serverVersion } from "../package.json" with { type: "json" }; import { serverBuildInfo } from "./buildInfo"; import { runOpenclawGatewayTest } from "./openclawGatewayTest.ts"; +import { createApiRouter } from "./api/router.ts"; // ── OpenClaw Gateway Connection Test ────────────────────────────────── @@ -358,6 +358,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } = serverConfig; const availableEditors = resolveAvailableEditors(); const tokenManager = new TokenManager(authToken); + const tryHandleApiRequest = createApiRouter({ + authToken, + host, + port, + tokenManager, + }); const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; @@ -651,35 +657,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< void Effect.runPromise( Effect.gen(function* () { const url = new URL(req.url ?? "/", `http://localhost:${port}`); - if (tryHandleProjectFaviconRequest(url, res)) { - return; - } - - // ── Pairing API endpoint ────────────────────────────────── - if (url.pathname === "/api/pairing" && req.method === "GET") { - if (!authToken) { - respond( - 200, - { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, - JSON.stringify({ error: "Auth is not enabled on this server." }), - ); - return; - } - - const ttlParam = url.searchParams.get("ttl"); - const ttlSeconds = ttlParam ? Math.min(Math.max(Number(ttlParam), 30), 3600) : 300; - const record = tokenManager.generatePairingToken({ ttlSeconds, label: "http-api" }); - const serverUrl = `http://${host ?? "localhost"}:${port}`; - const pairingUrl = `okcode://pair?server=${encodeURIComponent(serverUrl)}&token=${encodeURIComponent(record.tokenValue)}`; - respond( - 200, - { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, - JSON.stringify({ - pairingUrl, - expiresAt: record.expiresAt, - serverUrl, - }), - ); + if (await tryHandleApiRequest(req, res, url)) { return; } From 6e1bdc86ef4205f83003d3858d56576fe7b4f698 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:39:57 +0000 Subject: [PATCH 2/2] Tighten anthropic proxy URL handling Agent-Logs-Url: https://github.com/OpenKnots/okcode/sessions/6cd8bf08-8ada-493c-b8a5-fc5b9a543673 Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> --- apps/server/src/api/authRouter.ts | 9 +++++---- apps/server/src/api/router.test.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/server/src/api/authRouter.ts b/apps/server/src/api/authRouter.ts index 058451a40..0d04fc96e 100644 --- a/apps/server/src/api/authRouter.ts +++ b/apps/server/src/api/authRouter.ts @@ -3,7 +3,7 @@ import type { TokenManager } from "../tokenManager.ts"; const PAIRING_PATHS = new Set(["/api/pairing", "/api/auth/pairing"]); const ANTHROPIC_PROXY_PREFIX = "/api/auth/anthropic"; -const ANTHROPIC_MESSAGES_PATH_PREFIX = `${ANTHROPIC_PROXY_PREFIX}/v1/messages`; +const ANTHROPIC_MESSAGES_PATH = `${ANTHROPIC_PROXY_PREFIX}/v1/messages`; const CLAUDE_CODE_BETA = "claude-code-20250219"; const CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."; const DEFAULT_ANTHROPIC_VERSION = "2023-06-01"; @@ -158,7 +158,7 @@ export function createAuthApiRouter(options: AuthApiRouterOptions) { return true; } - if (req.method !== "POST" || !url.pathname.startsWith(ANTHROPIC_MESSAGES_PATH_PREFIX)) { + if (req.method !== "POST" || url.pathname !== ANTHROPIC_MESSAGES_PATH) { return false; } @@ -182,8 +182,9 @@ export function createAuthApiRouter(options: AuthApiRouterOptions) { } const proxiedBody = injectClaudeCodeSystemPrompt(body); - const upstreamPath = `${url.pathname.slice(ANTHROPIC_PROXY_PREFIX.length)}${url.search}`; - const upstreamUrl = new URL(upstreamPath, anthropicBaseUrl); + const upstreamUrl = new URL(anthropicBaseUrl); + upstreamUrl.pathname = "/v1/messages"; + upstreamUrl.search = ""; const payload = JSON.stringify(proxiedBody); const headers = new Headers({ "content-type": "application/json", diff --git a/apps/server/src/api/router.test.ts b/apps/server/src/api/router.test.ts index b0ff1f7ce..7c5fbb89b 100644 --- a/apps/server/src/api/router.test.ts +++ b/apps/server/src/api/router.test.ts @@ -146,7 +146,7 @@ describe("createApiRouter", () => { const url = new URL(req.url ?? "/", "http://127.0.0.1"); void tryHandleApiRequest(req, res, url); }, async (baseUrl) => { - const response = await request(baseUrl, "/api/auth/anthropic/v1/messages?beta=true", { + const response = await request(baseUrl, "/api/auth/anthropic/v1/messages", { method: "POST", headers: { "Content-Type": "application/json",