feat(auth): validate agent context upstream

This commit is contained in:
2026-06-12 10:18:41 +08:00
parent 4b572d9264
commit 8857b18dc9
13 changed files with 247 additions and 57 deletions
+2 -1
View File
@@ -30,7 +30,7 @@ TJWaterAgent/
1. 启动 HTTP 服务。 1. 启动 HTTP 服务。
2. 通过 `@opencode-ai/sdk` 启动内嵌 opencode server,或连接外部 opencode server。 2. 通过 `@opencode-ai/sdk` 启动内嵌 opencode server,或连接外部 opencode server。
3. 管理前端 `session_id -> opencode sessionId` 的映射。 3. 管理前端 `session_id -> opencode sessionId` 的映射。
4. 保存并传递用户 `Authorization``x-user-id``x-project-id``x-trace-id` 4. 保存并传递后端认证后的用户 token、metadata 用户 ID、项目 ID 和 trace
5. 把 opencode 输出适配成前端需要的 SSE 事件。 5. 把 opencode 输出适配成前端需要的 SSE 事件。
6.`.opencode/tools/tjwater_cli.ts` 提供内部回调接口。 6.`.opencode/tools/tjwater_cli.ts` 提供内部回调接口。
7. 代理调用真实 TJWater 后端 API。 7. 代理调用真实 TJWater 后端 API。
@@ -50,6 +50,7 @@ POST /api/v1/agent/chat/stream
| `tool_call` | 前端地图/面板/图表动作 | | `tool_call` | 前端地图/面板/图表动作 |
| `done` | 当前轮完成 | | `done` | 当前轮完成 |
| `error` | 当前轮失败 | | `error` | 当前轮失败 |
| `auth_required` | 运行中 access token 过期或被后端拒绝,需要前端刷新登录态后重试 |
主要目录和文件: 主要目录和文件:
-2
View File
@@ -19,7 +19,6 @@ function headers(ctx: RuntimeContext, requireAuth: boolean, requireProject: bool
if (!ctx.auth.projectId) throw new CliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3, false, null, ["add project_id to auth context"]); if (!ctx.auth.projectId) throw new CliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3, false, null, ["add project_id to auth context"]);
out["X-Project-Id"] = ctx.auth.projectId; out["X-Project-Id"] = ctx.auth.projectId;
} else if (ctx.auth.projectId) out["X-Project-Id"] = ctx.auth.projectId; } else if (ctx.auth.projectId) out["X-Project-Id"] = ctx.auth.projectId;
if (ctx.auth.userId) out["X-User-Id"] = ctx.auth.userId;
return out; return out;
} }
@@ -93,4 +92,3 @@ export async function emitApi(ctx: RuntimeContext, summary: string, request: Req
const [data, durationMs] = await requestJson(ctx, request); const [data, durationMs] = await requestJson(ctx, request);
success(summary, data, ctx, durationMs, nextCommands); success(summary, data, ctx, durationMs, nextCommands);
} }
-3
View File
@@ -31,7 +31,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
server: process.env.TJWATER_SERVER, server: process.env.TJWATER_SERVER,
access_token: process.env.TJWATER_ACCESS_TOKEN, access_token: process.env.TJWATER_ACCESS_TOKEN,
project_id: process.env.TJWATER_PROJECT_ID, project_id: process.env.TJWATER_PROJECT_ID,
user_id: process.env.TJWATER_USER_ID,
username: process.env.TJWATER_USERNAME, username: process.env.TJWATER_USERNAME,
network: process.env.TJWATER_NETWORK, network: process.env.TJWATER_NETWORK,
headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {}, headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {},
@@ -44,7 +43,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
server: pick(raw, "server", "base_url"), server: pick(raw, "server", "base_url"),
accessToken: pick(raw, "access_token", "token", "accessToken"), accessToken: pick(raw, "access_token", "token", "accessToken"),
projectId: pick(raw, "project_id", "projectId", "x_project_id"), projectId: pick(raw, "project_id", "projectId", "x_project_id"),
userId: pick(raw, "user_id", "userId", "x_user_id"),
username: pick(raw, "username", "preferred_username"), username: pick(raw, "username", "preferred_username"),
network: pick(raw, "network", "project_code", "projectCode", "project"), network: pick(raw, "network", "project_code", "projectCode", "project"),
headers: Object.fromEntries(Object.entries(headers as Record<string, unknown>).map(([key, value]) => [String(key), String(value)])), headers: Object.fromEntries(Object.entries(headers as Record<string, unknown>).map(([key, value]) => [String(key), String(value)])),
@@ -98,4 +96,3 @@ export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined,
if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2); if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2);
return scheme ?? null; return scheme ?? null;
} }
-2
View File
@@ -6,7 +6,6 @@ export interface AuthContext {
server: string | null; server: string | null;
accessToken: string | null; accessToken: string | null;
projectId: string | null; projectId: string | null;
userId: string | null;
username: string | null; username: string | null;
network: string | null; network: string | null;
headers: Record<string, string>; headers: Record<string, string>;
@@ -86,4 +85,3 @@ export type HelpPayload =
menu_level?: number; menu_level?: number;
commands: Array<{ command: string; summary: string; usage?: string; example?: string }>; commands: Array<{ command: string; summary: string; usage?: string; example?: string }>;
}; };
-2
View File
@@ -83,7 +83,6 @@ function normalizeSeenRequest(request) {
headers: { headers: {
authorization: request.headers.authorization, authorization: request.headers.authorization,
"x-project-id": request.headers["x-project-id"], "x-project-id": request.headers["x-project-id"],
"x-user-id": request.headers["x-user-id"],
}, },
method: request.method, method: request.method,
path: url.pathname, path: url.pathname,
@@ -280,7 +279,6 @@ test("matches Python CLI backend request shape for every command and key variant
access_token: "token", access_token: "token",
network: "tjwater", network: "tjwater",
project_id: "project-1", project_id: "project-1",
user_id: "user-1",
username: "alice", username: "alice",
headers: { "x-extra": "extra" }, headers: { "x-extra": "extra" },
}; };
+89 -3
View File
@@ -3,6 +3,37 @@ import type { NextFunction, Request, Response } from "express";
import { config } from "../config.js"; import { config } from "../config.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
export type AgentAuthContext = {
accessToken: string;
userId: string;
keycloakSub: string;
username: string;
role: string;
isSuperuser: boolean;
projectId: string;
projectRole: string;
tokenExpiresAt?: string;
};
type AgentAuthContextResponse = {
user_id?: unknown;
keycloak_sub?: unknown;
username?: unknown;
role?: unknown;
is_superuser?: unknown;
project_id?: unknown;
project_role?: unknown;
token_expires_at?: unknown;
};
declare global {
namespace Express {
interface Request {
agentAuth?: AgentAuthContext;
}
}
}
export const extractBearerToken = (authorization?: string) => { export const extractBearerToken = (authorization?: string) => {
const value = authorization?.trim(); const value = authorization?.trim();
if (!value) { if (!value) {
@@ -11,8 +42,39 @@ export const extractBearerToken = (authorization?: string) => {
return value.replace(/^Bearer\s+/i, "").trim(); return value.replace(/^Bearer\s+/i, "").trim();
}; };
// Agent API 复用 TJWater 后端的登录态:每个请求都向 /auth/me 校验 Bearer token const normalizeAgentAuthContext = (
// 成功后才允许进入会话路由,避免 Agent 服务维护第二套用户体系。 payload: AgentAuthContextResponse,
accessToken: string,
): AgentAuthContext | null => {
if (
typeof payload.user_id !== "string" ||
typeof payload.keycloak_sub !== "string" ||
typeof payload.username !== "string" ||
typeof payload.role !== "string" ||
typeof payload.is_superuser !== "boolean" ||
typeof payload.project_id !== "string" ||
typeof payload.project_role !== "string"
) {
return null;
}
return {
accessToken,
userId: payload.user_id,
keycloakSub: payload.keycloak_sub,
username: payload.username,
role: payload.role,
isSuperuser: payload.is_superuser,
projectId: payload.project_id,
projectRole: payload.project_role,
tokenExpiresAt:
typeof payload.token_expires_at === "string"
? payload.token_expires_at
: undefined,
};
};
// Agent API 使用 TJWater 后端专用认证上下文:后端负责校验 Keycloak token、
// metadata 用户状态和项目 membershipAgent 只信任后端返回的 user/project。
export const requireAgentAuth = async ( export const requireAgentAuth = async (
req: Request, req: Request,
res: Response, res: Response,
@@ -24,20 +86,37 @@ export const requireAgentAuth = async (
return; return;
} }
const projectId = req.header("x-project-id")?.trim();
if (!projectId) {
res.status(400).json({ message: "x-project-id is required" });
return;
}
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS); const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS);
try { try {
const response = await fetch(new URL("/api/v1/auth/me", config.TJWATER_API_BASE_URL), { const response = await fetch(new URL("/api/v1/agent/auth/context", config.TJWATER_API_BASE_URL), {
method: "GET", method: "GET",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
"X-Project-Id": projectId,
...(req.header("x-trace-id") ? { "X-Trace-Id": req.header("x-trace-id") as string } : {}),
}, },
signal: controller.signal, signal: controller.signal,
}); });
if (response.ok) { if (response.ok) {
const context = normalizeAgentAuthContext(
(await response.json()) as AgentAuthContextResponse,
token,
);
if (!context) {
res.status(502).json({ message: "invalid authentication context" });
return;
}
req.agentAuth = context;
next(); next();
return; return;
} }
@@ -58,3 +137,10 @@ export const requireAgentAuth = async (
clearTimeout(timer); clearTimeout(timer);
} }
}; };
export const getAgentAuthContext = (req: Request) => {
if (!req.agentAuth) {
throw new Error("agent auth context is missing");
}
return req.agentAuth;
};
+5
View File
@@ -18,6 +18,7 @@ export type SessionContext = {
clientSessionId: string; clientSessionId: string;
accessToken?: string; accessToken?: string;
projectId?: string; projectId?: string;
tokenExpiresAt?: string;
userId?: string; userId?: string;
}; };
@@ -37,6 +38,7 @@ export class ChatSessionBridge {
accessToken?: string; accessToken?: string;
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
tokenExpiresAt?: string;
userId?: string; userId?: string;
}): Promise<{ }): Promise<{
binding: SessionBinding; binding: SessionBinding;
@@ -72,6 +74,7 @@ export class ChatSessionBridge {
projectId: requestContext.projectId, projectId: requestContext.projectId,
projectKey: requestContext.projectKey, projectKey: requestContext.projectKey,
sessionId, sessionId,
tokenExpiresAt: requestContext.tokenExpiresAt,
traceId: requestContext.traceId, traceId: requestContext.traceId,
}); });
@@ -143,6 +146,7 @@ export class ChatSessionBridge {
accessToken?: string; accessToken?: string;
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
tokenExpiresAt?: string;
userId?: string; userId?: string;
}): ChatRequestContext { }): ChatRequestContext {
const sessionId = context.sessionId?.trim(); const sessionId = context.sessionId?.trim();
@@ -152,6 +156,7 @@ export class ChatSessionBridge {
actorKey: toActorKey(context.userId), actorKey: toActorKey(context.userId),
projectId: context.projectId, projectId: context.projectId,
projectKey: toProjectKey(context.projectId), projectKey: toProjectKey(context.projectId),
tokenExpiresAt: context.tokenExpiresAt,
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`, traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: context.userId?.trim(), userId: context.userId?.trim(),
}; };
+1 -1
View File
@@ -39,7 +39,7 @@ const envSchema = z
.default("./logs/llm-request-audit.log"), .default("./logs/llm-request-audit.log"),
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。 // 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
AGENT_INTERNAL_TOKEN: optionalString(), AGENT_INTERNAL_TOKEN: optionalString(),
// Agent 前置认证调用后端 /api/v1/auth/me 的超时时间(毫秒)。 // Agent 前置认证调用后端 /api/v1/agent/auth/context 的超时时间(毫秒)。
AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。 // opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
+51 -20
View File
@@ -1,6 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { getAgentAuthContext } from "../auth/agentAuth.js";
import { import {
agentModelOptions, agentModelOptions,
isSupportedModel, isSupportedModel,
@@ -18,6 +19,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
import { import {
type OpencodeRuntimeAdapter, type OpencodeRuntimeAdapter,
} from "../runtime/opencode.js"; } from "../runtime/opencode.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { type SessionRecord } from "../sessions/metadataStore.js"; import { type SessionRecord } from "../sessions/metadataStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
@@ -139,8 +141,9 @@ export const buildChatRouter = (
return; return;
} }
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim(); const requestedSessionId = parsed.data.session_id?.trim();
@@ -166,8 +169,9 @@ export const buildChatRouter = (
}); });
chatRouter.get("/sessions", async (req, res) => { chatRouter.get("/sessions", async (req, res) => {
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const records = await sessionMetadataStore.list({ const records = await sessionMetadataStore.list({
@@ -192,8 +196,9 @@ export const buildChatRouter = (
chatRouter.get("/session/:sessionId", async (req, res) => { chatRouter.get("/session/:sessionId", async (req, res) => {
const sessionId = req.params.sessionId?.trim(); const sessionId = req.params.sessionId?.trim();
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
if (!sessionId) { if (!sessionId) {
@@ -235,8 +240,9 @@ export const buildChatRouter = (
chatRouter.get("/session/:sessionId/stream", async (req, res) => { chatRouter.get("/session/:sessionId/stream", async (req, res) => {
const sessionId = req.params.sessionId?.trim(); const sessionId = req.params.sessionId?.trim();
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
if (!sessionId) { if (!sessionId) {
@@ -305,8 +311,9 @@ export const buildChatRouter = (
typeof req.body?.is_title_manually_edited === "boolean" typeof req.body?.is_title_manually_edited === "boolean"
? req.body.is_title_manually_edited ? req.body.is_title_manually_edited
: undefined; : undefined;
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
if (!sessionId || !title) { if (!sessionId || !title) {
@@ -344,8 +351,9 @@ export const buildChatRouter = (
chatRouter.delete("/session/:sessionId", async (req, res) => { chatRouter.delete("/session/:sessionId", async (req, res) => {
const sessionId = req.params.sessionId?.trim(); const sessionId = req.params.sessionId?.trim();
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
if (!sessionId) { if (!sessionId) {
@@ -398,9 +406,10 @@ export const buildChatRouter = (
} }
try { try {
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const traceId = req.header("x-trace-id") ?? undefined; const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
@@ -497,13 +506,11 @@ export const buildChatRouter = (
} }
try { try {
const authHeader = req.header("authorization"); const authContext = getAgentAuthContext(req);
const accessToken = authHeader?.startsWith("Bearer ") const accessToken = authContext.accessToken;
? authHeader.slice("Bearer ".length) const projectId = authContext.projectId;
: authHeader;
const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined; const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim(); const requestedSessionId = parsed.data.session_id?.trim();
@@ -520,6 +527,7 @@ export const buildChatRouter = (
accessToken, accessToken,
projectId, projectId,
traceId, traceId,
tokenExpiresAt: authContext.tokenExpiresAt,
userId, userId,
}); });
const { record: ensuredSessionRecord, created: sessionCreated } = const { record: ensuredSessionRecord, created: sessionCreated } =
@@ -676,6 +684,19 @@ export const buildChatRouter = (
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos), todos: cancelBackendTodos(message.todos),
})); }));
} else if (event === "auth_required") {
activeRun.status = "error";
lastRunStatuses.set(clientSessionId, "error");
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof data.message === "string"
? `⚠️ **${data.message}**`
: "⚠️ **登录态已过期,请刷新登录后重试**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "permission_request") { } else if (event === "permission_request") {
const payload = data as PermissionRequestPayload; const payload = data as PermissionRequestPayload;
activeRun.pendingPermissions.set(payload.request_id, payload); activeRun.pendingPermissions.set(payload.request_id, payload);
@@ -804,6 +825,16 @@ export const buildChatRouter = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
}); });
const latestRuntimeContext = getRuntimeSessionContext(binding.sessionId);
if (latestRuntimeContext?.authExpired) {
publish("auth_required", {
session_id: clientSessionId,
reason: latestRuntimeContext.authExpired.reason,
message: latestRuntimeContext.authExpired.message,
});
return;
}
if (!streamResult.aborted && !streamResult.failed) { if (!streamResult.aborted && !streamResult.failed) {
const messages = await runtime.messages(binding.sessionId, 60); const messages = await runtime.messages(binding.sessionId, 60);
const assistantMessage = [...messages] const assistantMessage = [...messages]
+7 -11
View File
@@ -1,6 +1,7 @@
import { type Router } from "express"; import { type Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { getAgentAuthContext } from "../auth/agentAuth.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { type ResultReferenceResolver } from "../results/resolver.js"; import { type ResultReferenceResolver } from "../results/resolver.js";
@@ -46,20 +47,14 @@ export const registerChatAuxiliaryRoutes = (
) => { ) => {
chatRouter.get("/render-ref/:renderRef", async (req, res) => { chatRouter.get("/render-ref/:renderRef", async (req, res) => {
const renderRef = req.params.renderRef?.trim(); const renderRef = req.params.renderRef?.trim();
const userId = req.header("x-user-id")?.trim(); const authContext = getAgentAuthContext(req);
const projectId = req.header("x-project-id") ?? undefined; const userId = authContext.userId;
const projectId = authContext.projectId;
const clientSessionId = const clientSessionId =
typeof req.query.session_id === "string" typeof req.query.session_id === "string"
? req.query.session_id.trim() ? req.query.session_id.trim()
: undefined; : undefined;
if (!userId) {
res.status(400).json({
message: "x-user-id is required",
});
return;
}
if (!renderRef) { if (!renderRef) {
res.status(400).json({ res.status(400).json({
message: "render_ref is required", message: "render_ref is required",
@@ -98,8 +93,9 @@ export const registerChatAuxiliaryRoutes = (
} }
try { try {
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get( const sessionRecord = await sessionMetadataStore.get(
+10 -6
View File
@@ -1,6 +1,7 @@
import { type Router } from "express"; import { type Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { getAgentAuthContext } from "../auth/agentAuth.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js"; import { type SessionMetadataStore } from "../sessions/metadataStore.js";
@@ -64,8 +65,9 @@ export const registerChatInteractionRoutes = (
} }
try { try {
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get( const sessionRecord = await sessionMetadataStore.get(
@@ -188,8 +190,9 @@ export const registerChatInteractionRoutes = (
} }
try { try {
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get( const sessionRecord = await sessionMetadataStore.get(
@@ -319,8 +322,9 @@ export const registerChatInteractionRoutes = (
} }
try { try {
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get( const sessionRecord = await sessionMetadataStore.get(
+22
View File
@@ -1,6 +1,10 @@
export type RuntimeSessionContext = { export type RuntimeSessionContext = {
accessToken?: string; accessToken?: string;
actorKey: string; actorKey: string;
authExpired?: {
message: string;
reason: "access_token_expired" | "access_token_rejected";
};
allowLearningWrite?: boolean; allowLearningWrite?: boolean;
clientSessionId: string; clientSessionId: string;
learningMode?: "interactive" | "review"; learningMode?: "interactive" | "review";
@@ -8,6 +12,7 @@ export type RuntimeSessionContext = {
projectId?: string; projectId?: string;
projectKey: string; projectKey: string;
sessionId: string; sessionId: string;
tokenExpiresAt?: string;
traceId: string; traceId: string;
}; };
@@ -22,6 +27,23 @@ export const getRuntimeSessionContext = (sessionId: string) => {
return context ? { ...context } : null; return context ? { ...context } : null;
}; };
export const markRuntimeSessionAuthExpired = (
sessionId: string,
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
message = "登录态已过期,请刷新登录后重试",
) => {
const context = contexts.get(sessionId);
if (!context) {
return null;
}
const nextContext: RuntimeSessionContext = {
...context,
authExpired: { message, reason },
};
contexts.set(sessionId, nextContext);
return { ...nextContext };
};
export const removeRuntimeSessionContext = (sessionId: string) => { export const removeRuntimeSessionContext = (sessionId: string) => {
contexts.delete(sessionId); contexts.delete(sessionId);
}; };
+60 -6
View File
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
import cors from "cors"; import cors from "cors";
import express from "express"; import express from "express";
import { requireAgentAuth } from "./auth/agentAuth.js";
import { SessionTranscriptStore } from "./sessions/transcriptStore.js"; import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
import { ChatSessionBridge } from "./chat/sessionBridge.js"; import { ChatSessionBridge } from "./chat/sessionBridge.js";
import { config } from "./config.js"; import { config } from "./config.js";
@@ -18,7 +19,11 @@ import {
} from "./results/store.js"; } from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js"; import { buildChatRouter } from "./routes/chat.js";
import { opencodeRuntime } from "./runtime/opencode.js"; import { opencodeRuntime } from "./runtime/opencode.js";
import { getRuntimeSessionContext } from "./runtime/sessionContext.js"; import {
getRuntimeSessionContext,
markRuntimeSessionAuthExpired,
type RuntimeSessionContext,
} from "./runtime/sessionContext.js";
const app = express(); const app = express();
@@ -78,6 +83,14 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
}); });
return; return;
} }
if (isRuntimeAuthExpired(context)) {
markAuthExpired(context, "access_token_expired");
res.status(401).json({
message: "access token expired; refresh chat context",
detail: sessionId,
});
return;
}
const command = typeof req.body?.command === "string" ? req.body.command.trim() : ""; const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
if (!command) { if (!command) {
@@ -252,9 +265,20 @@ app.post("/internal/tools/session-search", async (req, res) => {
const callBackendJson = async ( const callBackendJson = async (
path: string, path: string,
accessToken: string | undefined, context: RuntimeSessionContext,
payload: unknown, payload: unknown,
) => { ) => {
if (isRuntimeAuthExpired(context)) {
markAuthExpired(context, "access_token_expired");
return {
ok: false,
status: 401,
text: JSON.stringify({
message: "access token expired; refresh chat context",
}),
};
}
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS); const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
try { try {
@@ -262,8 +286,8 @@ const callBackendJson = async (
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
if (accessToken) { if (context.accessToken) {
headers.Authorization = `Bearer ${accessToken}`; headers.Authorization = `Bearer ${context.accessToken}`;
} }
const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), { const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), {
method: "POST", method: "POST",
@@ -272,6 +296,9 @@ const callBackendJson = async (
signal: controller.signal, signal: controller.signal,
}); });
const text = await response.text(); const text = await response.text();
if (response.status === 401) {
markAuthExpired(context, "access_token_rejected");
}
return { return {
ok: response.ok, ok: response.ok,
status: response.status, status: response.status,
@@ -287,6 +314,32 @@ const parseStringArray = (value: unknown) =>
? value.filter((item): item is string => typeof item === "string") ? value.filter((item): item is string => typeof item === "string")
: undefined; : undefined;
const AUTH_EXPIRY_SKEW_MS = 30_000;
function isRuntimeAuthExpired(context: RuntimeSessionContext) {
if (!context.tokenExpiresAt) {
return false;
}
const expiresAt = Date.parse(context.tokenExpiresAt);
if (!Number.isFinite(expiresAt)) {
return false;
}
return Date.now() >= expiresAt - AUTH_EXPIRY_SKEW_MS;
}
function markAuthExpired(
context: RuntimeSessionContext,
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
) {
markRuntimeSessionAuthExpired(context.sessionId, reason);
void opencodeRuntime.abortSession(context.sessionId).catch((error) => {
logger.warn(
{ err: error, sessionId: context.sessionId },
"failed to abort runtime after auth expired",
);
});
}
app.post("/internal/tools/web-search", async (req, res) => { app.post("/internal/tools/web-search", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) { if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" }); res.status(403).json({ message: "forbidden" });
@@ -328,7 +381,7 @@ app.post("/internal/tools/web-search", async (req, res) => {
try { try {
const response = await callBackendJson( const response = await callBackendJson(
"/api/v1/web-search", "/api/v1/web-search",
context.accessToken, context,
payload, payload,
); );
res res
@@ -371,7 +424,7 @@ app.post("/internal/tools/geocode", async (req, res) => {
try { try {
const response = await callBackendJson( const response = await callBackendJson(
"/api/v1/tianditu/geocode", "/api/v1/tianditu/geocode",
context.accessToken, context,
{ keyword }, { keyword },
); );
res res
@@ -389,6 +442,7 @@ app.post("/internal/tools/geocode", async (req, res) => {
app.use( app.use(
"/api/v1/agent/chat", "/api/v1/agent/chat",
requireAgentAuth,
buildChatRouter( buildChatRouter(
sessionBridge, sessionBridge,
opencodeRuntime, opencodeRuntime,