Merge pull request #2273 from pikasTech/issue-2272-opencode-auth-otel
fix: make OpenCode iframe auth traceable
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user