From 8857b18dc9b7b315e37ea1b64f71686d720e5270 Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 12 Jun 2026 10:18:41 +0800 Subject: [PATCH] feat(auth): validate agent context upstream --- README.md | 3 +- cli/src/core/http.ts | 2 - cli/src/core/runtime.ts | 3 - cli/src/core/types.ts | 2 - node-tests/cli/tjwaterCli.node.mjs | 2 - src/auth/agentAuth.ts | 92 ++++++++++++++++++++++++++++- src/chat/sessionBridge.ts | 5 ++ src/config.ts | 2 +- src/routes/chat.ts | 71 +++++++++++++++------- src/routes/chatAuxiliaryRoutes.ts | 18 +++--- src/routes/chatInteractionRoutes.ts | 16 +++-- src/runtime/sessionContext.ts | 22 +++++++ src/server.ts | 66 +++++++++++++++++++-- 13 files changed, 247 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index c61c7bb..884c004 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ TJWaterAgent/ 1. 启动 HTTP 服务。 2. 通过 `@opencode-ai/sdk` 启动内嵌 opencode server,或连接外部 opencode server。 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 事件。 6. 为 `.opencode/tools/tjwater_cli.ts` 提供内部回调接口。 7. 代理调用真实 TJWater 后端 API。 @@ -50,6 +50,7 @@ POST /api/v1/agent/chat/stream | `tool_call` | 前端地图/面板/图表动作 | | `done` | 当前轮完成 | | `error` | 当前轮失败 | +| `auth_required` | 运行中 access token 过期或被后端拒绝,需要前端刷新登录态后重试 | 主要目录和文件: diff --git a/cli/src/core/http.ts b/cli/src/core/http.ts index 7733c97..05aaa48 100644 --- a/cli/src/core/http.ts +++ b/cli/src/core/http.ts @@ -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"]); 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; } @@ -93,4 +92,3 @@ export async function emitApi(ctx: RuntimeContext, summary: string, request: Req const [data, durationMs] = await requestJson(ctx, request); success(summary, data, ctx, durationMs, nextCommands); } - diff --git a/cli/src/core/runtime.ts b/cli/src/core/runtime.ts index 2b76333..4c44343 100644 --- a/cli/src/core/runtime.ts +++ b/cli/src/core/runtime.ts @@ -31,7 +31,6 @@ export async function loadAuthContext(authStdin: boolean): Promise server: process.env.TJWATER_SERVER, access_token: process.env.TJWATER_ACCESS_TOKEN, project_id: process.env.TJWATER_PROJECT_ID, - user_id: process.env.TJWATER_USER_ID, username: process.env.TJWATER_USERNAME, network: process.env.TJWATER_NETWORK, headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {}, @@ -44,7 +43,6 @@ export async function loadAuthContext(authStdin: boolean): Promise server: pick(raw, "server", "base_url"), accessToken: pick(raw, "access_token", "token", "accessToken"), projectId: pick(raw, "project_id", "projectId", "x_project_id"), - userId: pick(raw, "user_id", "userId", "x_user_id"), username: pick(raw, "username", "preferred_username"), network: pick(raw, "network", "project_code", "projectCode", "project"), headers: Object.fromEntries(Object.entries(headers as Record).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); return scheme ?? null; } - diff --git a/cli/src/core/types.ts b/cli/src/core/types.ts index caf22a2..f20fd89 100644 --- a/cli/src/core/types.ts +++ b/cli/src/core/types.ts @@ -6,7 +6,6 @@ export interface AuthContext { server: string | null; accessToken: string | null; projectId: string | null; - userId: string | null; username: string | null; network: string | null; headers: Record; @@ -86,4 +85,3 @@ export type HelpPayload = menu_level?: number; commands: Array<{ command: string; summary: string; usage?: string; example?: string }>; }; - diff --git a/node-tests/cli/tjwaterCli.node.mjs b/node-tests/cli/tjwaterCli.node.mjs index 770cf28..3a982bc 100644 --- a/node-tests/cli/tjwaterCli.node.mjs +++ b/node-tests/cli/tjwaterCli.node.mjs @@ -83,7 +83,6 @@ function normalizeSeenRequest(request) { headers: { authorization: request.headers.authorization, "x-project-id": request.headers["x-project-id"], - "x-user-id": request.headers["x-user-id"], }, method: request.method, path: url.pathname, @@ -280,7 +279,6 @@ test("matches Python CLI backend request shape for every command and key variant access_token: "token", network: "tjwater", project_id: "project-1", - user_id: "user-1", username: "alice", headers: { "x-extra": "extra" }, }; diff --git a/src/auth/agentAuth.ts b/src/auth/agentAuth.ts index 43821a0..d53193d 100644 --- a/src/auth/agentAuth.ts +++ b/src/auth/agentAuth.ts @@ -3,6 +3,37 @@ import type { NextFunction, Request, Response } from "express"; import { config } from "../config.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) => { const value = authorization?.trim(); if (!value) { @@ -11,8 +42,39 @@ export const extractBearerToken = (authorization?: string) => { return value.replace(/^Bearer\s+/i, "").trim(); }; -// Agent API 复用 TJWater 后端的登录态:每个请求都向 /auth/me 校验 Bearer token, -// 成功后才允许进入会话路由,避免 Agent 服务维护第二套用户体系。 +const normalizeAgentAuthContext = ( + 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 用户状态和项目 membership,Agent 只信任后端返回的 user/project。 export const requireAgentAuth = async ( req: Request, res: Response, @@ -24,20 +86,37 @@ export const requireAgentAuth = async ( 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 timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS); 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", headers: { Accept: "application/json", 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, }); 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(); return; } @@ -58,3 +137,10 @@ export const requireAgentAuth = async ( clearTimeout(timer); } }; + +export const getAgentAuthContext = (req: Request) => { + if (!req.agentAuth) { + throw new Error("agent auth context is missing"); + } + return req.agentAuth; +}; diff --git a/src/chat/sessionBridge.ts b/src/chat/sessionBridge.ts index 36ee347..92bed04 100644 --- a/src/chat/sessionBridge.ts +++ b/src/chat/sessionBridge.ts @@ -18,6 +18,7 @@ export type SessionContext = { clientSessionId: string; accessToken?: string; projectId?: string; + tokenExpiresAt?: string; userId?: string; }; @@ -37,6 +38,7 @@ export class ChatSessionBridge { accessToken?: string; projectId?: string; traceId?: string; + tokenExpiresAt?: string; userId?: string; }): Promise<{ binding: SessionBinding; @@ -72,6 +74,7 @@ export class ChatSessionBridge { projectId: requestContext.projectId, projectKey: requestContext.projectKey, sessionId, + tokenExpiresAt: requestContext.tokenExpiresAt, traceId: requestContext.traceId, }); @@ -143,6 +146,7 @@ export class ChatSessionBridge { accessToken?: string; projectId?: string; traceId?: string; + tokenExpiresAt?: string; userId?: string; }): ChatRequestContext { const sessionId = context.sessionId?.trim(); @@ -152,6 +156,7 @@ export class ChatSessionBridge { actorKey: toActorKey(context.userId), projectId: context.projectId, projectKey: toProjectKey(context.projectId), + tokenExpiresAt: context.tokenExpiresAt, traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`, userId: context.userId?.trim(), }; diff --git a/src/config.ts b/src/config.ts index c196d65..b7d8456 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,7 +39,7 @@ const envSchema = z .default("./logs/llm-request-audit.log"), // 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。 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), // opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。 OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), diff --git a/src/routes/chat.ts b/src/routes/chat.ts index c75db30..9ddc9a0 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { z } from "zod"; +import { getAgentAuthContext } from "../auth/agentAuth.js"; import { agentModelOptions, isSupportedModel, @@ -18,6 +19,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js"; import { type OpencodeRuntimeAdapter, } from "../runtime/opencode.js"; +import { getRuntimeSessionContext } from "../runtime/sessionContext.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { type SessionRecord } from "../sessions/metadataStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js"; @@ -139,8 +141,9 @@ export const buildChatRouter = ( return; } - const projectId = req.header("x-project-id") ?? undefined; - const userId = req.header("x-user-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const projectId = authContext.projectId; + const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); const requestedSessionId = parsed.data.session_id?.trim(); @@ -166,8 +169,9 @@ export const buildChatRouter = ( }); chatRouter.get("/sessions", async (req, res) => { - const projectId = req.header("x-project-id") ?? undefined; - const userId = req.header("x-user-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const projectId = authContext.projectId; + const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); const records = await sessionMetadataStore.list({ @@ -192,8 +196,9 @@ export const buildChatRouter = ( chatRouter.get("/session/:sessionId", async (req, res) => { const sessionId = req.params.sessionId?.trim(); - const projectId = req.header("x-project-id") ?? undefined; - const userId = req.header("x-user-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const projectId = authContext.projectId; + const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); if (!sessionId) { @@ -235,8 +240,9 @@ export const buildChatRouter = ( chatRouter.get("/session/:sessionId/stream", async (req, res) => { const sessionId = req.params.sessionId?.trim(); - const projectId = req.header("x-project-id") ?? undefined; - const userId = req.header("x-user-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const projectId = authContext.projectId; + const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); if (!sessionId) { @@ -305,8 +311,9 @@ export const buildChatRouter = ( typeof req.body?.is_title_manually_edited === "boolean" ? req.body.is_title_manually_edited : undefined; - const projectId = req.header("x-project-id") ?? undefined; - const userId = req.header("x-user-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const projectId = authContext.projectId; + const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); if (!sessionId || !title) { @@ -344,8 +351,9 @@ export const buildChatRouter = ( chatRouter.delete("/session/:sessionId", async (req, res) => { const sessionId = req.params.sessionId?.trim(); - const projectId = req.header("x-project-id") ?? undefined; - const userId = req.header("x-user-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const projectId = authContext.projectId; + const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); if (!sessionId) { @@ -398,9 +406,10 @@ export const buildChatRouter = ( } 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 userId = req.header("x-user-id") ?? undefined; + const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); @@ -497,13 +506,11 @@ export const buildChatRouter = ( } try { - const authHeader = req.header("authorization"); - const accessToken = authHeader?.startsWith("Bearer ") - ? authHeader.slice("Bearer ".length) - : authHeader; - const projectId = req.header("x-project-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const accessToken = authContext.accessToken; + const projectId = authContext.projectId; 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 projectKey = toProjectKey(projectId); const requestedSessionId = parsed.data.session_id?.trim(); @@ -520,6 +527,7 @@ export const buildChatRouter = ( accessToken, projectId, traceId, + tokenExpiresAt: authContext.tokenExpiresAt, userId, }); const { record: ensuredSessionRecord, created: sessionCreated } = @@ -676,6 +684,19 @@ export const buildChatRouter = ( progress: completeBackendProgress(message.progress), 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") { const payload = data as PermissionRequestPayload; 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"); }); + 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) { const messages = await runtime.messages(binding.sessionId, 60); const assistantMessage = [...messages] diff --git a/src/routes/chatAuxiliaryRoutes.ts b/src/routes/chatAuxiliaryRoutes.ts index 029078b..1eb4595 100644 --- a/src/routes/chatAuxiliaryRoutes.ts +++ b/src/routes/chatAuxiliaryRoutes.ts @@ -1,6 +1,7 @@ import { type Router } from "express"; import { z } from "zod"; +import { getAgentAuthContext } from "../auth/agentAuth.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { logger } from "../logger.js"; import { type ResultReferenceResolver } from "../results/resolver.js"; @@ -46,20 +47,14 @@ export const registerChatAuxiliaryRoutes = ( ) => { chatRouter.get("/render-ref/:renderRef", async (req, res) => { const renderRef = req.params.renderRef?.trim(); - const userId = req.header("x-user-id")?.trim(); - const projectId = req.header("x-project-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const userId = authContext.userId; + const projectId = authContext.projectId; const clientSessionId = typeof req.query.session_id === "string" ? req.query.session_id.trim() : undefined; - if (!userId) { - res.status(400).json({ - message: "x-user-id is required", - }); - return; - } - if (!renderRef) { res.status(400).json({ message: "render_ref is required", @@ -98,8 +93,9 @@ export const registerChatAuxiliaryRoutes = ( } try { - const projectId = req.header("x-project-id") ?? undefined; - const userId = req.header("x-user-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const projectId = authContext.projectId; + const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); const sessionRecord = await sessionMetadataStore.get( diff --git a/src/routes/chatInteractionRoutes.ts b/src/routes/chatInteractionRoutes.ts index 8278ab2..239455f 100644 --- a/src/routes/chatInteractionRoutes.ts +++ b/src/routes/chatInteractionRoutes.ts @@ -1,6 +1,7 @@ import { type Router } from "express"; import { z } from "zod"; +import { getAgentAuthContext } from "../auth/agentAuth.js"; import { logger } from "../logger.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type SessionMetadataStore } from "../sessions/metadataStore.js"; @@ -64,8 +65,9 @@ export const registerChatInteractionRoutes = ( } try { - const projectId = req.header("x-project-id") ?? undefined; - const userId = req.header("x-user-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const projectId = authContext.projectId; + const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); const sessionRecord = await sessionMetadataStore.get( @@ -188,8 +190,9 @@ export const registerChatInteractionRoutes = ( } try { - const projectId = req.header("x-project-id") ?? undefined; - const userId = req.header("x-user-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const projectId = authContext.projectId; + const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); const sessionRecord = await sessionMetadataStore.get( @@ -319,8 +322,9 @@ export const registerChatInteractionRoutes = ( } try { - const projectId = req.header("x-project-id") ?? undefined; - const userId = req.header("x-user-id") ?? undefined; + const authContext = getAgentAuthContext(req); + const projectId = authContext.projectId; + const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); const sessionRecord = await sessionMetadataStore.get( diff --git a/src/runtime/sessionContext.ts b/src/runtime/sessionContext.ts index 940fb4c..97cbd88 100644 --- a/src/runtime/sessionContext.ts +++ b/src/runtime/sessionContext.ts @@ -1,6 +1,10 @@ export type RuntimeSessionContext = { accessToken?: string; actorKey: string; + authExpired?: { + message: string; + reason: "access_token_expired" | "access_token_rejected"; + }; allowLearningWrite?: boolean; clientSessionId: string; learningMode?: "interactive" | "review"; @@ -8,6 +12,7 @@ export type RuntimeSessionContext = { projectId?: string; projectKey: string; sessionId: string; + tokenExpiresAt?: string; traceId: string; }; @@ -22,6 +27,23 @@ export const getRuntimeSessionContext = (sessionId: string) => { return context ? { ...context } : null; }; +export const markRuntimeSessionAuthExpired = ( + sessionId: string, + reason: NonNullable["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) => { contexts.delete(sessionId); }; diff --git a/src/server.ts b/src/server.ts index 0d24b94..d92ef1c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ import { spawn } from "node:child_process"; import cors from "cors"; import express from "express"; +import { requireAgentAuth } from "./auth/agentAuth.js"; import { SessionTranscriptStore } from "./sessions/transcriptStore.js"; import { ChatSessionBridge } from "./chat/sessionBridge.js"; import { config } from "./config.js"; @@ -18,7 +19,11 @@ import { } from "./results/store.js"; import { buildChatRouter } from "./routes/chat.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(); @@ -78,6 +83,14 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => { }); 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() : ""; if (!command) { @@ -252,9 +265,20 @@ app.post("/internal/tools/session-search", async (req, res) => { const callBackendJson = async ( path: string, - accessToken: string | undefined, + context: RuntimeSessionContext, 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 timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS); try { @@ -262,8 +286,8 @@ const callBackendJson = async ( Accept: "application/json", "Content-Type": "application/json", }; - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}`; + if (context.accessToken) { + headers.Authorization = `Bearer ${context.accessToken}`; } const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), { method: "POST", @@ -272,6 +296,9 @@ const callBackendJson = async ( signal: controller.signal, }); const text = await response.text(); + if (response.status === 401) { + markAuthExpired(context, "access_token_rejected"); + } return { ok: response.ok, status: response.status, @@ -287,6 +314,32 @@ const parseStringArray = (value: unknown) => ? value.filter((item): item is string => typeof item === "string") : 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["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) => { if (req.header("x-agent-internal-token") !== internalToken) { res.status(403).json({ message: "forbidden" }); @@ -328,7 +381,7 @@ app.post("/internal/tools/web-search", async (req, res) => { try { const response = await callBackendJson( "/api/v1/web-search", - context.accessToken, + context, payload, ); res @@ -371,7 +424,7 @@ app.post("/internal/tools/geocode", async (req, res) => { try { const response = await callBackendJson( "/api/v1/tianditu/geocode", - context.accessToken, + context, { keyword }, ); res @@ -389,6 +442,7 @@ app.post("/internal/tools/geocode", async (req, res) => { app.use( "/api/v1/agent/chat", + requireAgentAuth, buildChatRouter( sessionBridge, opencodeRuntime,