fix: 修复云端聊天鉴权及容器兼容并支持3200原地升级

This commit is contained in:
NathanAlex7
2026-09-14 11:53:29 +08:00
parent af1a1027d7
commit c556172a1d
11 changed files with 97 additions and 13 deletions
+26 -4
View File
@@ -5,15 +5,37 @@ export const runtime = "nodejs";
const allowed = new Set(["chat", "sessions", "files", "skills", "tokens", "config", "xingbang"]);
async function proxy(request:NextRequest, {params}:{params:{path:string[]}}) {
// General chat has filesystem/tools, unlike the scoped project APIs: local only.
if (!["localhost","127.0.0.1","::1","[::1]"].includes(request.nextUrl.hostname))
return NextResponse.json({detail:"原始开发聊天仅允许本机访问"},{status:403});
if (!allowed.has(params.path[0]) || params.path.some(p=>!p || p==="." || p===".." || /[\\/]/.test(p)))
return NextResponse.json({detail:"接口不存在"},{status:404});
const base = process.env.CORE_BACKEND_URL || process.env.RISK_AGENT_BACKEND_URL || "http://127.0.0.1:8002";
try {
// Production nextUrl can contain the bind address (0.0.0.0), not the client Host.
// Never use Host as authentication: all production core/tool access requires admin.
const authorization = request.headers.get("authorization") || "";
const host = request.headers.get("host") || "";
const requiresAuth = process.env.NODE_ENV === "production" || process.env.XINGBANG_REQUIRE_AUTH === "1" || !!process.env.XINGBANG_AUTH_USERS || !!authorization;
if (requiresAuth) {
if (!authorization.startsWith("Bearer ") || !authorization.slice(7).trim())
return NextResponse.json({detail:"请先在风险研判页面连接管理员访问身份(401)"},{status:401});
const identity = await fetch(`${base}/api/xingbang/risk-agent/access/context`, {
headers:{Authorization:authorization,"X-Xingbang-Client-Host":host},
cache:"no-store",redirect:"error",signal:request.signal,
});
if (!identity.ok) return NextResponse.json({detail:"访问身份校验失败"},{status:identity.status === 401 || identity.status === 403 ? identity.status : 503});
const user = await identity.json();
if (user.role !== "admin" || user.preview || !user.id)
return NextResponse.json({detail:"通用聊天与文件工具仅限已认证管理员"},{status:403});
} else {
let hostname = "";
try { hostname = new URL(`http://${host}`).hostname; } catch { /* reject malformed Host */ }
if (!["localhost","127.0.0.1","::1","[::1]"].includes(hostname))
return NextResponse.json({detail:"未配置身份认证,仅允许本机开发访问"},{status:403});
const origin = request.headers.get("origin");
if ((origin && new URL(origin).host !== host) || request.headers.get("sec-fetch-site") === "cross-site")
return NextResponse.json({detail:"不允许跨站访问开发工具"},{status:403});
}
const upstream = await fetch(`${base}/api/${params.path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`, {
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json"},
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json",Authorization:authorization},
body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(),
cache:"no-store",redirect:"error",signal:request.signal,
});
+2 -1
View File
@@ -3,6 +3,7 @@
import {useEffect, useRef, useState} from "react";
import {Activity, ArrowRight, CheckCircle2, Database, FlaskConical, Play, RefreshCw, ShieldCheck, SlidersHorizontal, Square, X} from "lucide-react";
import AgentNav from "@/components/xingbang/AgentNav";
import {newRequestId} from "@/lib/request-id";
import "./operations.css";
const ROOT = "/api/risk-agent/project-assistant/operations";
@@ -53,7 +54,7 @@ export default function OperationsCenter() {
async function act(fn:()=>Promise<void>){setBusy(true);setError("");setNotice("");try{await fn();setOverview(await api(`/overview?domain=${domain}`));}catch(e:any){setError(e.message);}finally{setBusy(false);}}
function edit(key:string,value:any){setForm((f:any)=>({...f,[key]:value}));setDirty(true);}
function chooseView(next:string){setView(next);history.replaceState({},"",`?domain=${domain}&view=${next}`);}
async function submit(){await act(async()=>{const run=await api("/lab/runs",{source_revision:source.project.revision,model,requirement:model?requirement:""},crypto.randomUUID());setSelected(run.id);setView("monitor");await loadSource();setNotice("真实任务已入队。执行记录会自动刷新;历史快照保持不变。");});}
async function submit(){await act(async()=>{const run=await api("/lab/runs",{source_revision:source.project.revision,model,requirement:model?requirement:""},newRequestId());setSelected(run.id);setView("monitor");await loadSource();setNotice("真实任务已入队。执行记录会自动刷新;历史快照保持不变。");});}
const runs=(overview?.runs||[]).filter((r:any)=>(status==="all"||r.status===status)&&(!search||`${r.project_id} ${r.id}`.toLowerCase().includes(search.toLowerCase())));
const run=detail?.run;
const oldChecks=new Map<string,any>((comparison?.run?.checks||[]).map((c:any)=>[c.code,c]));
+2 -1
View File
@@ -4,6 +4,7 @@ import {ArrowRight, FileText, RefreshCw, ShieldCheck} from "lucide-react";
import AgentNav from "@/components/xingbang/AgentNav";
import AssistantConfiguration from "@/components/xingbang/AssistantConfiguration";
import AssistantHistory from "@/components/xingbang/AssistantHistory";
import {newRequestId} from "@/lib/request-id";
import "../data-access/workspace.css";
const tabs = [["overview", "概览"], ["findings", "材料与问题"], ["scenario", "方案比较"], ["report", "报告底稿"], ["experience", "反馈与经验"]];
@@ -22,7 +23,7 @@ export default function XingbangWorkspace() {
const selected = useRef("");
const headers = (json = true) => {const token = sessionStorage.getItem("xingbang-access-token"); return {...(json ? {"Content-Type": "application/json"} : {}), ...(token ? {Authorization: `Bearer ${token}`} : {})};};
async function request(path: string, body?: any) {
const response = await fetch(`/api/risk-agent/project-assistant${path}`, {method: body === undefined ? "GET" : "POST", headers: {...headers(), ...(/\/(runs|reports)$/.test(path) && body ? {"Idempotency-Key": crypto.randomUUID()} : {})}, body: body === undefined ? undefined : JSON.stringify(body)});
const response = await fetch(`/api/risk-agent/project-assistant${path}`, {method: body === undefined ? "GET" : "POST", headers: {...headers(), ...(/\/(runs|reports)$/.test(path) && body ? {"Idempotency-Key": newRequestId()} : {})}, body: body === undefined ? undefined : JSON.stringify(body)});
const data = await response.json();
if (!response.ok) throw Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || "操作失败"));
return data;
+1
View File
@@ -49,6 +49,7 @@ export default function ChatInput() {
</div>
<p className="text-center text-[10px] text-gray-400/70 mt-2">
Powered by DeepSeek · mini OpenClaw v0.1
{" · "}<a href="/data-access" className="underline">云端聊天:先连接管理员访问身份</a>
</p>
</div>
);
+2 -1
View File
@@ -4,6 +4,7 @@
*/
import {readChatStream} from "./chat-stream";
import {coreFetch as fetch} from "./core-fetch";
// Same-origin streaming proxy shares the current workbench backend configuration.
const API_BASE = "/api/core";
@@ -21,7 +22,7 @@ export async function* streamChat(
message: string,
sessionId: string
): AsyncGenerator<SSEEvent> {
yield* readChatStream(`${API_BASE}/chat`, {message,session_id:sessionId,stream:true});
yield* readChatStream(`${API_BASE}/chat`, {message,session_id:sessionId,stream:true}, {fetcher:fetch});
}
/**
+7
View File
@@ -0,0 +1,7 @@
/** Reuse the existing access identity without putting credentials in URLs or logs. */
export const coreFetch: typeof fetch = (input, init) => {
const headers = new Headers(init?.headers);
const token = typeof window === "undefined" ? null : window.sessionStorage.getItem("xingbang-access-token");
if (token) headers.set("Authorization", `Bearer ${token}`);
return globalThis.fetch(input, {...init, headers});
};
+8
View File
@@ -0,0 +1,8 @@
/** getRandomValues also works on HTTP intranet pages where randomUUID is unavailable. */
export function newRequestId(): string {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 15) | 64;
bytes[8] = (bytes[8] & 63) | 128;
const hex = Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}