feat(auth): validate agent context upstream
This commit is contained in:
@@ -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 过期或被后端拒绝,需要前端刷新登录态后重试 |
|
||||
|
||||
主要目录和文件:
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
|
||||
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<AuthContext>
|
||||
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<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);
|
||||
return scheme ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
@@ -86,4 +85,3 @@ export type HelpPayload =
|
||||
menu_level?: number;
|
||||
commands: Array<{ command: string; summary: string; usage?: string; example?: string }>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
+89
-3
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
+1
-1
@@ -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"),
|
||||
|
||||
+51
-20
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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) => {
|
||||
contexts.delete(sessionId);
|
||||
};
|
||||
|
||||
+60
-6
@@ -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<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) => {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user