feat(auth): validate agent context upstream
This commit is contained in:
@@ -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 过期或被后端拒绝,需要前端刷新登录态后重试 |
|
||||||
|
|
||||||
主要目录和文件:
|
主要目录和文件:
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 用户状态和项目 membership,Agent 只信任后端返回的 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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user