From 4e2aae4d4d53b71608ac924e678eec30f38a20e5 Mon Sep 17 00:00:00 2001 From: UniDesk Codex Date: Tue, 30 Jun 2026 09:32:26 +0800 Subject: [PATCH] fix: make opencode iframe auth traceable --- internal/dev-entrypoint/cloud-web-runtime.mjs | 226 ++++++++++++++++-- .../dev-entrypoint/cloud-web-runtime.test.mjs | 107 ++++++++- .../src/views/opencode/OpenCodeFrameView.vue | 174 +++++++++++++- 3 files changed, 482 insertions(+), 25 deletions(-) diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index ae7cb815..4c7afad3 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -20,10 +20,15 @@ const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-hand const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/opencode", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]); const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u; const OPENCODE_PROXY_PREFIX = "/_opencode"; +const OPENCODE_FRAME_URL_PATH = "/opencode/frame-url"; const OPENCODE_TICKET_QUERY = "hwlab_opencode_ticket"; const OPENCODE_TICKET_COOKIE = "hwlab_opencode_ticket"; const OPENCODE_TICKET_TTL_MS = 5 * 60 * 1000; const OPENCODE_TICKET_MAX_AGE_SECONDS = Math.floor(OPENCODE_TICKET_TTL_MS / 1000); +const ZERO_OTEL_TRACE_ID = "00000000000000000000000000000000"; +const ZERO_OTEL_SPAN_ID = "0000000000000000"; +const OTEL_TRACEPARENT_PATTERN = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/u; +const SAFE_REQUEST_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,160}$/u; const TRACE_EXPLORER_TEMPLATE_TOKEN = "{trace_id}"; const TRACE_EXPLORER_TEMPLATE_PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_:-]+)\}/gu; const opencodeTickets = new Map(); @@ -68,6 +73,10 @@ export function createCloudWebServer({ sendJson(response, 200, healthPayload()); return; } + if (url.pathname === OPENCODE_FRAME_URL_PATH && request.method === "GET") { + await handleOpencodeFrameUrlRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson }); + return; + } if (isOpencodeProxyRequest(request, url, opencodeProxyHost)) { await proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, serviceId, sendJson }); return; @@ -164,12 +173,83 @@ async function opencodeRuntimeConfigFromEnv(options = {}) { async function opencodeFrameUrl(value, { request = null, url = null, cloudApiBaseUrl = "", cloudApiProxyTimeoutMs = 0 } = {}) { if (!request || normalizeCloudWebRoutePath(url?.pathname || "") !== "/opencode" || !cloudApiBaseUrl || !hasCookieHeader(request.headers.cookie)) return value; - const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs }); - if (!session.ok || session.body?.authenticated !== true) return value; + const result = await mintOpencodeFrameUrl(value, { request, cloudApiBaseUrl, cloudApiProxyTimeoutMs, traceContext: cloudWebTraceContext(request) }); + return result.ok ? result.url : value; +} + +async function handleOpencodeFrameUrlRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson }) { + const traceContext = cloudWebTraceContext(request); + response.setHeader("cache-control", "no-store"); + if (!cloudApiBaseUrl) { + sendJsonWithTraceContext(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson); + return; + } + const configuredUrl = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_URL"); + if (!configuredUrl) { + sendJsonWithTraceContext(response, 503, { error: "opencode_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson); + return; + } + try { + validateOpencodeUrl(configuredUrl); + } catch (error) { + sendJsonWithTraceContext(response, 503, { error: "opencode_url_invalid", serviceId, path: url.pathname, message: error instanceof Error ? error.message : String(error), diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson); + return; + } + + const result = await mintOpencodeFrameUrl(configuredUrl, { request, cloudApiBaseUrl, cloudApiProxyTimeoutMs, traceContext, serviceId }); + if (!result.ok) { + logCloudWebRuntimeEvent("opencode_auth_required", { + serviceId, + request, + traceContext, + details: { + route: url.pathname, + sessionStatusCode: result.sessionStatusCode, + sessionAuthenticated: result.sessionAuthenticated, + ticketPresent: false, + ticketAccepted: false, + valuesPrinted: false + } + }); + sendJsonWithTraceContext(response, result.statusCode, { + authenticated: false, + error: "opencode_auth_required", + serviceId, + path: url.pathname, + diagnostic: opencodeTraceDiagnostic(traceContext, { sessionStatusCode: result.sessionStatusCode }) + }, traceContext, sendJson); + return; + } + + sendJsonWithTraceContext(response, 200, { + authenticated: true, + url: result.url, + expiresInMs: OPENCODE_TICKET_TTL_MS, + diagnostic: opencodeTraceDiagnostic(traceContext) + }, traceContext, sendJson); +} + +async function mintOpencodeFrameUrl(value, { request, cloudApiBaseUrl, cloudApiProxyTimeoutMs, traceContext = null, serviceId = "hwlab-cloud-web" } = {}) { + const session = await requestCloudApiJson({ + request, + method: "GET", + pathname: "/auth/session", + headers: cloudWebTraceHeaders(traceContext, serviceId), + cloudApiBaseUrl, + cloudApiProxyTimeoutMs + }); + if (!session.ok || session.body?.authenticated !== true) { + return { + ok: false, + statusCode: session.statusCode === 403 ? 403 : 401, + sessionStatusCode: session.statusCode || 0, + sessionAuthenticated: session.body?.authenticated === true + }; + } const ticket = issueOpencodeTicket(request.headers.cookie); const target = new URL(value); target.searchParams.set(OPENCODE_TICKET_QUERY, ticket); - return target.toString(); + return { ok: true, url: target.toString() }; } function displayTimeConfigFromEnv() { @@ -345,13 +425,15 @@ export function isClientDisconnectError(error) { return CLIENT_DISCONNECT_ERROR_CODES.has(code) || CLIENT_DISCONNECT_ERROR_PATTERN.test(candidate.message || ""); } -function logCloudWebRuntimeEvent(event, { serviceId, request = null, error = null, source = null } = {}) { +function logCloudWebRuntimeEvent(event, { serviceId, request = null, error = null, source = null, traceContext = null, details = null } = {}) { const payload = { event, serviceId: serviceId || "hwlab-cloud-web", source, method: request?.method || null, path: request?.url || null, + traceId: traceContext?.traceId || null, + requestId: traceContext?.requestId || null, error: error ? { name: error.name || "Error", @@ -359,6 +441,7 @@ function logCloudWebRuntimeEvent(event, { serviceId, request = null, error = nul message: error.message || String(error) } : null, + details: details && typeof details === "object" ? details : null, observedAt: new Date().toISOString() }; process.stderr.write(JSON.stringify(payload) + "\n"); @@ -581,29 +664,51 @@ export async function proxyCloudApi({ request, response, url, cloudApiBaseUrl, c } async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, serviceId, sendJson }) { + const traceContext = cloudWebTraceContext(request); if (!cloudApiBaseUrl) { - sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: url.pathname }); + sendJsonWithTraceContext(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson); return; } if (!opencodeUpstreamUrl) { - sendJson(response, 503, { error: "opencode_upstream_url_missing", serviceId, path: url.pathname }); + sendJsonWithTraceContext(response, 503, { error: "opencode_upstream_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson); return; } const authorization = opencodeBasicAuthorization(opencodeUsername, opencodePassword); if (!authorization) { - sendJson(response, 503, { error: "opencode_credentials_missing", serviceId, path: url.pathname }); + sendJsonWithTraceContext(response, 503, { error: "opencode_credentials_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson); return; } const ticketAuth = opencodeTicketAuthRequest(request, url); - const session = await requestCloudApiJson({ request: ticketAuth.request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs }); + const session = await requestCloudApiJson({ + request: ticketAuth.request, + method: "GET", + pathname: "/auth/session", + headers: cloudWebTraceHeaders(traceContext, serviceId), + cloudApiBaseUrl, + cloudApiProxyTimeoutMs + }); if (!session.ok || session.body?.authenticated !== true) { - sendJson(response, session.statusCode === 403 ? 403 : 401, { + logCloudWebRuntimeEvent("opencode_auth_required", { + serviceId, + request, + traceContext, + details: { + route: url.pathname, + sessionStatusCode: session.statusCode || 0, + sessionAuthenticated: session.body?.authenticated === true, + ticketPresent: ticketAuth.ticketPresent, + ticketAccepted: Boolean(ticketAuth.ticket), + valuesPrinted: false + } + }); + sendJsonWithTraceContext(response, session.statusCode === 403 ? 403 : 401, { authenticated: false, error: "opencode_auth_required", serviceId, - path: url.pathname - }); + path: url.pathname, + diagnostic: opencodeTraceDiagnostic(traceContext, { sessionStatusCode: session.statusCode || 0 }) + }, traceContext, sendJson); return; } @@ -613,10 +718,13 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c } const target = opencodeTargetUrl(url, opencodeUpstreamUrl); - const extraResponseHeaders = ticketAuth.ticket ? { "set-cookie": opencodeTicketSetCookie(ticketAuth.ticket) } : {}; + const extraResponseHeaders = { + ...cloudWebTraceHeaders(traceContext, serviceId), + ...(ticketAuth.ticket ? { "set-cookie": opencodeTicketSetCookie(ticketAuth.ticket) } : {}) + }; const upstreamRequest = { method: request.method, - headers: opencodeUpstreamHeaders(request, body, authorization) + headers: opencodeUpstreamHeaders({ headers: { ...request.headers, ...cloudWebTraceHeaders(traceContext, serviceId) } }, body, authorization) }; try { await proxyCloudApiRequest({ @@ -632,13 +740,14 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c if (!response.writableEnded) response.destroy(error); return; } - sendJson(response, 502, { + sendJsonWithTraceContext(response, 502, { status: "failed", error: "opencode_proxy_unavailable", serviceId, path: url.pathname, - reason: UPSTREAM_UNAVAILABLE_MESSAGE - }); + reason: UPSTREAM_UNAVAILABLE_MESSAGE, + diagnostic: opencodeTraceDiagnostic(traceContext) + }, traceContext, sendJson); } } @@ -673,10 +782,11 @@ function issueOpencodeTicket(cookieHeader) { function opencodeTicketAuthRequest(request, url) { const ticket = opencodeTicketFromRequest(request, url); const record = ticket ? opencodeTicketRecord(ticket) : null; - if (!record) return { request, ticket: "" }; + if (!record) return { request, ticket: "", ticketPresent: Boolean(ticket) }; return { request: { headers: { ...request.headers, cookie: record.cookieHeader } }, - ticket + ticket, + ticketPresent: true }; } @@ -724,6 +834,86 @@ function opencodeBasicAuthorization(username, password) { return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`; } +function cloudWebTraceContext(request) { + const parsed = parseOtelTraceparent(request?.headers?.traceparent); + const explicitTraceId = normalizedOtelTraceId(request?.headers?.["x-hwlab-otel-trace-id"]); + const traceId = parsed?.traceId || explicitTraceId || randomOtelTraceId(); + const spanId = randomOtelSpanId(); + return { + traceId, + parentSpanId: parsed?.spanId || null, + spanId, + traceparent: `00-${traceId}-${spanId}-01`, + requestId: safeRequestId(request?.headers?.["x-request-id"]) || `req_${randomBytes(16).toString("hex")}`, + valuesPrinted: false + }; +} + +function cloudWebTraceHeaders(traceContext, serviceId = "hwlab-cloud-web") { + if (!traceContext?.traceId || !traceContext?.traceparent) return {}; + return { + traceparent: traceContext.traceparent, + "x-hwlab-otel-trace-id": traceContext.traceId, + "x-request-id": traceContext.requestId, + "x-source-service-id": serviceId || "hwlab-cloud-web" + }; +} + +function sendJsonWithTraceContext(response, statusCode, body, traceContext, sendJson) { + for (const [name, value] of Object.entries(cloudWebTraceHeaders(traceContext))) { + if (value !== undefined && value !== null && value !== "") response.setHeader(name, value); + } + sendJson(response, statusCode, body); +} + +function opencodeTraceDiagnostic(traceContext, extra = {}) { + return { + traceId: traceContext?.traceId || null, + requestId: traceContext?.requestId || null, + traceparent: traceContext?.traceparent || null, + valuesPrinted: false, + ...extra + }; +} + +function parseOtelTraceparent(value) { + const match = firstHeaderValue(value).trim().match(OTEL_TRACEPARENT_PATTERN); + if (!match) return null; + const traceId = normalizedOtelTraceId(match[1]); + const spanId = normalizedOtelSpanId(match[2]); + return traceId && spanId ? { traceId, spanId } : null; +} + +function normalizedOtelTraceId(value) { + const text = firstHeaderValue(value).trim().toLowerCase(); + return /^[0-9a-f]{32}$/u.test(text) && text !== ZERO_OTEL_TRACE_ID ? text : ""; +} + +function normalizedOtelSpanId(value) { + const text = firstHeaderValue(value).trim().toLowerCase(); + return /^[0-9a-f]{16}$/u.test(text) && text !== ZERO_OTEL_SPAN_ID ? text : ""; +} + +function randomOtelTraceId() { + const value = randomBytes(16).toString("hex"); + return value === ZERO_OTEL_TRACE_ID ? "00000000000000000000000000000001" : value; +} + +function randomOtelSpanId() { + const value = randomBytes(8).toString("hex"); + return value === ZERO_OTEL_SPAN_ID ? "0000000000000001" : value; +} + +function safeRequestId(value) { + const text = firstHeaderValue(value).trim(); + return SAFE_REQUEST_ID_PATTERN.test(text) ? text : ""; +} + +function firstHeaderValue(value) { + if (Array.isArray(value)) return String(value[0] ?? ""); + return String(value ?? ""); +} + function requestHostName(request) { return normalizeHostName(String(request.headers.host || "").split(":")[0]); } diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index 5128d3d4..cdb2c8e9 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -500,10 +500,15 @@ test("cloud web injects trace explorer runtime config from env", async () => { }); test("cloud web OpenCode proxy requires an authenticated HWLAB session", async () => { - let authRequests = 0; + const authRequests = []; let opencodeRequests = 0; const cloudApi = createServer((request, response) => { - authRequests += 1; + authRequests.push({ + traceparent: request.headers.traceparent, + otelTraceId: request.headers["x-hwlab-otel-trace-id"], + requestId: request.headers["x-request-id"], + sourceServiceId: request.headers["x-source-service-id"] + }); request.resume(); response.writeHead(401, { "content-type": "application/json" }); response.end(JSON.stringify({ authenticated: false })); @@ -539,8 +544,19 @@ test("cloud web OpenCode proxy requires an authenticated HWLAB session", async ( try { const response = await fetch(`${serverUrl(cloudWeb)}/`, { headers: { accept: "text/html" } }); assert.equal(response.status, 401); - assert.equal((await response.json()).error, "opencode_auth_required"); - assert.equal(authRequests, 1); + const body = await response.json(); + assert.equal(body.error, "opencode_auth_required"); + assert.match(response.headers.get("traceparent") ?? "", /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/u); + assert.match(response.headers.get("x-hwlab-otel-trace-id") ?? "", /^[0-9a-f]{32}$/u); + assert.match(response.headers.get("x-request-id") ?? "", /^req_[0-9a-f]{32}$/u); + assert.equal(body.diagnostic.traceId, response.headers.get("x-hwlab-otel-trace-id")); + assert.equal(body.diagnostic.requestId, response.headers.get("x-request-id")); + assert.deepEqual(authRequests, [{ + traceparent: response.headers.get("traceparent"), + otelTraceId: response.headers.get("x-hwlab-otel-trace-id"), + requestId: response.headers.get("x-request-id"), + sourceServiceId: "hwlab-cloud-web" + }]); assert.equal(opencodeRequests, 0); } finally { await close(cloudWeb); @@ -549,6 +565,89 @@ test("cloud web OpenCode proxy requires an authenticated HWLAB session", async ( } }); +test("cloud web OpenCode frame-url endpoint mints traced tickets", async () => { + const restoreEnv = withEnv({ + HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: "Asia/Shanghai", + HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: "zh-CN", + HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL: "北京时间", + HWLAB_CLOUD_WEB_OPENCODE_URL: "https://opencode.example.test" + }); + const cloudApiRequests = []; + const opencodeRequests = []; + const cloudApi = createServer((request, response) => { + cloudApiRequests.push({ + cookie: request.headers.cookie, + traceparent: request.headers.traceparent, + otelTraceId: request.headers["x-hwlab-otel-trace-id"], + requestId: request.headers["x-request-id"], + sourceServiceId: request.headers["x-source-service-id"] + }); + request.resume(); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ authenticated: request.headers.cookie === "hwlab_session=session-a" })); + }); + const opencode = createServer((request, response) => { + opencodeRequests.push({ url: request.url, cookie: request.headers.cookie }); + request.resume(); + response.writeHead(200, { "content-type": "text/plain" }); + response.end("opencode ok\n"); + }); + await listen(cloudApi); + await listen(opencode); + + const cloudWeb = createCloudWebServer({ + serviceId: "hwlab-cloud-web", + roots: [], + cloudApiBaseUrl: serverUrl(cloudApi), + cloudApiProxyTimeoutMs: 1000, + opencodeUpstreamUrl: serverUrl(opencode), + opencodeProxyHost: "", + opencodeProxyTimeoutMs: 1000, + opencodeUsername: "oc_user", + opencodePassword: "oc_password", + healthPayload: () => ({ status: "ok" }), + sendJson(response, statusCode, body) { + const payload = JSON.stringify(body); + response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }); + response.end(payload); + } + }); + await listen(cloudWeb); + + try { + const response = await fetch(`${serverUrl(cloudWeb)}/opencode/frame-url`, { + headers: { accept: "application/json", cookie: "hwlab_session=session-a", "x-request-id": "req_opencode_frame_url" } + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get("x-request-id"), "req_opencode_frame_url"); + assert.match(response.headers.get("traceparent") ?? "", /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/u); + const body = await response.json(); + const frameUrl = new URL(body.url); + const ticket = frameUrl.searchParams.get("hwlab_opencode_ticket"); + assert.ok(ticket, "dynamic endpoint should append an OpenCode ticket"); + assert.equal(body.diagnostic.traceId, response.headers.get("x-hwlab-otel-trace-id")); + assert.deepEqual(cloudApiRequests, [{ + cookie: "hwlab_session=session-a", + traceparent: response.headers.get("traceparent"), + otelTraceId: response.headers.get("x-hwlab-otel-trace-id"), + requestId: "req_opencode_frame_url", + sourceServiceId: "hwlab-cloud-web" + }]); + + const proxied = await fetch(`${serverUrl(cloudWeb)}/_opencode/?hwlab_opencode_ticket=${encodeURIComponent(ticket)}`, { + headers: { accept: "text/plain" } + }); + assert.equal(proxied.status, 200); + assert.equal(await proxied.text(), "opencode ok\n"); + assert.deepEqual(opencodeRequests, [{ url: "/", cookie: undefined }]); + } finally { + restoreEnv(); + await close(cloudWeb); + await close(opencode); + await close(cloudApi); + } +}); + test("cloud web OpenCode proxy injects upstream Basic Auth without forwarding HWLAB cookie", async () => { const opencodeRequests = []; const cloudApi = createServer((request, response) => { diff --git a/web/hwlab-cloud-web/src/views/opencode/OpenCodeFrameView.vue b/web/hwlab-cloud-web/src/views/opencode/OpenCodeFrameView.vue index eeb8b454..a4d9d19d 100644 --- a/web/hwlab-cloud-web/src/views/opencode/OpenCodeFrameView.vue +++ b/web/hwlab-cloud-web/src/views/opencode/OpenCodeFrameView.vue @@ -1,14 +1,153 @@