Merge pull request #2273 from pikasTech/issue-2272-opencode-auth-otel

fix: make OpenCode iframe auth traceable
This commit is contained in:
Lyon
2026-06-30 09:33:10 +08:00
committed by GitHub
3 changed files with 482 additions and 25 deletions
+208 -18
View File
@@ -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]);
}
@@ -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) => {
@@ -1,14 +1,153 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { opencodeFrameUrl } from "@/config/runtime";
const frameUrl = computed(() => opencodeFrameUrl());
type LoadState = "idle" | "loading" | "ready" | "unconfigured" | "auth" | "error";
interface OpenCodeFrameUrlResponse {
authenticated?: boolean;
url?: string;
error?: string;
diagnostic?: {
traceId?: string | null;
requestId?: string | null;
};
}
const OPENCODE_FRAME_URL_ENDPOINT = "/opencode/frame-url";
const MAX_FRAME_URL_ATTEMPTS = 3;
const configuredFrameUrl = computed(() => opencodeFrameUrl());
const frameUrl = ref<string | null>(null);
const loadState = ref<LoadState>("idle");
const diagnosticText = ref("");
let activeController: AbortController | null = null;
let retryTimer: number | null = null;
let disposed = false;
const statusText = computed(() => {
if (loadState.value === "loading" || loadState.value === "idle") return "OpenCode 加载中";
if (loadState.value === "unconfigured") return "OpenCode 未配置";
if (loadState.value === "auth") return "OpenCode 鉴权失败";
return "OpenCode 暂不可用";
});
const canRetry = computed(() => loadState.value === "auth" || loadState.value === "error");
onMounted(() => {
void loadFrameUrl();
});
onBeforeUnmount(() => {
disposed = true;
activeController?.abort();
activeController = null;
if (retryTimer !== null) window.clearTimeout(retryTimer);
retryTimer = null;
});
async function loadFrameUrl(): Promise<void> {
activeController?.abort();
if (retryTimer !== null) window.clearTimeout(retryTimer);
retryTimer = null;
diagnosticText.value = "";
frameUrl.value = null;
if (!configuredFrameUrl.value) {
loadState.value = "unconfigured";
return;
}
loadState.value = "loading";
for (let attempt = 1; attempt <= MAX_FRAME_URL_ATTEMPTS; attempt += 1) {
if (disposed) return;
const controller = new AbortController();
activeController = controller;
try {
const response = await fetch(OPENCODE_FRAME_URL_ENDPOINT, {
method: "GET",
headers: { accept: "application/json" },
credentials: "include",
cache: "no-store",
signal: controller.signal
});
const body = await parseFrameUrlResponse(response);
if (response.ok && typeof body?.url === "string" && isSafeFrameUrl(body.url)) {
frameUrl.value = body.url;
loadState.value = "ready";
return;
}
const authPending = response.status === 401 || response.status === 403;
if (authPending && attempt < MAX_FRAME_URL_ATTEMPTS) {
await delay(250 * attempt);
continue;
}
loadState.value = body?.error === "opencode_url_missing" ? "unconfigured" : authPending ? "auth" : "error";
diagnosticText.value = diagnosticSummary(response, body);
return;
} catch (error) {
if (controller.signal.aborted) return;
if (attempt < MAX_FRAME_URL_ATTEMPTS) {
await delay(250 * attempt);
continue;
}
loadState.value = "error";
diagnosticText.value = error instanceof Error ? error.message : String(error);
return;
} finally {
if (activeController === controller) activeController = null;
}
}
}
async function parseFrameUrlResponse(response: Response): Promise<OpenCodeFrameUrlResponse | null> {
try {
return await response.json() as OpenCodeFrameUrlResponse;
} catch {
return null;
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
retryTimer = window.setTimeout(() => {
retryTimer = null;
resolve();
}, ms);
});
}
function diagnosticSummary(response: Response, body: OpenCodeFrameUrlResponse | null): string {
const traceId = firstNonEmpty(response.headers.get("x-hwlab-otel-trace-id"), body?.diagnostic?.traceId);
const requestId = firstNonEmpty(response.headers.get("x-request-id"), body?.diagnostic?.requestId);
return [traceId ? `traceId ${traceId}` : "", requestId ? `requestId ${requestId}` : ""].filter(Boolean).join(" · ");
}
function firstNonEmpty(...values: Array<string | null | undefined>): string {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) return value.trim();
}
return "";
}
function isSafeFrameUrl(value: string): boolean {
if (!value.trim() || value.trim().startsWith("//")) return false;
try {
const url = new URL(value, window.location.origin);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
</script>
<template>
<section class="opencode-frame-view" aria-label="OpenCode">
<iframe v-if="frameUrl" class="opencode-frame" :src="frameUrl" title="OpenCode" allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy="same-origin" />
<div v-else class="opencode-frame-empty" role="status">OpenCode 未配置</div>
<div v-else class="opencode-frame-empty" role="status">
<p>{{ statusText }}</p>
<button v-if="canRetry" class="opencode-frame-retry" type="button" @click="loadFrameUrl">重试</button>
<small v-if="diagnosticText">{{ diagnosticText }}</small>
</div>
</section>
</template>
@@ -34,10 +173,39 @@ const frameUrl = computed(() => opencodeFrameUrl());
.opencode-frame-empty {
display: grid;
flex: 1 1 auto;
gap: 12px;
place-items: center;
align-content: center;
min-height: 320px;
color: #dbe4f0;
font-size: 14px;
text-align: center;
}
.opencode-frame-empty p {
margin: 0;
}
.opencode-frame-empty small {
max-width: min(720px, 90vw);
overflow-wrap: anywhere;
color: #9fb1ca;
font-size: 12px;
}
.opencode-frame-retry {
min-width: 72px;
height: 32px;
border: 1px solid #94a3b8;
border-radius: 6px;
background: #ffffff;
color: #0f172a;
font-weight: 700;
cursor: pointer;
}
.opencode-frame-retry:hover {
background: #eef4ff;
}
@media (max-width: 720px) {