feat(context): verify product session scope

This commit is contained in:
2026-09-22 10:43:36 +08:00
parent 88b41ba04c
commit 9faed59f23
12 changed files with 358 additions and 56 deletions
+8 -2
View File
@@ -15,10 +15,12 @@ export type SessionBinding = {
};
export type SessionContext = {
actorKey?: string;
clientSessionId: string;
network?: string;
projectId?: string;
userId?: string;
projectKey?: string;
};
export type ChatRequestContext = SessionContext & {
@@ -38,6 +40,8 @@ export class ChatSessionBridge {
projectId?: string;
traceId?: string;
userId?: string;
actorKey?: string;
projectKey?: string;
}): Promise<{
binding: SessionBinding;
requestContext: ChatRequestContext;
@@ -139,19 +143,21 @@ export class ChatSessionBridge {
}
private buildRequestContext(context: {
actorKey?: string;
sessionId?: string;
network?: string;
projectId?: string;
traceId?: string;
userId?: string;
projectKey?: string;
}): ChatRequestContext {
const sessionId = context.sessionId?.trim();
return {
clientSessionId: sessionId || this.createClientSessionId(),
actorKey: toActorKey(context.userId),
actorKey: context.actorKey ?? toActorKey(`local:${context.userId ?? "local-user"}`),
network: context.network,
projectId: context.projectId,
projectKey: toProjectKey(context.projectId),
projectKey: context.projectKey ?? toProjectKey(`local:${context.projectId ?? "local-project"}`),
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: context.userId?.trim(),
};
+2
View File
@@ -46,6 +46,8 @@ const envSchema = z
PORT: z.coerce.number().int().positive().default(8787),
// HTTP 服务监听地址。
HOST: z.string().default("0.0.0.0"),
// Product Server base URL used to verify browser identity and project scope.
TJWATER_SERVER_BASE_URL: z.string().url().default("http://127.0.0.1:8000"),
// Pino 日志级别。
LOG_LEVEL: z.string().default("info"),
// LLM 工具/技能调用审计日志路径。
+55 -2
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import type { Request } from "express";
import { ProductServerClient } from "../product/server-auth-client.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
export type LocalAgentContext = {
@@ -15,17 +16,69 @@ export type LocalAgentContext = {
const DEFAULT_USER_ID = process.env.AGENT_LOCAL_USER_ID ?? "local-user";
const DEFAULT_PROJECT_ID = process.env.AGENT_LOCAL_PROJECT_ID ?? "local-project";
const DEFAULT_NETWORK = process.env.AGENT_LOCAL_NETWORK ?? "local-network";
const trustedContexts = new WeakMap<Request, LocalAgentContext>();
const productAuthClient = new ProductServerClient();
export const getLocalAgentContext = (req?: Request): LocalAgentContext => {
if (req) {
const trusted = trustedContexts.get(req);
if (trusted) return { ...trusted };
}
const userId = req?.header("x-agent-local-user")?.trim() || DEFAULT_USER_ID;
const projectId =
req?.header("x-agent-local-project")?.trim() || DEFAULT_PROJECT_ID;
return {
actorKey: toActorKey(userId),
actorKey: toActorKey(`local:${userId}`),
network: req?.header("x-agent-local-network")?.trim() || DEFAULT_NETWORK,
projectId,
projectKey: toProjectKey(projectId),
projectKey: toProjectKey(`local:${projectId}`),
traceId: req?.header("x-trace-id")?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId,
};
};
export class ProductContextError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
export const attachTrustedAgentContext = async (req: Request) => {
const authorizationHeader = req.header("authorization")?.trim();
const projectHeader = req.header("x-project-id")?.trim();
const hasProductCredential = Boolean(authorizationHeader || projectHeader);
if (!hasProductCredential) {
trustedContexts.set(req, getLocalAgentContext());
return;
}
const authorization = normalizeBearerAuthorization(authorizationHeader);
if (!authorization || !projectHeader) {
throw new ProductContextError(401, "valid Bearer authorization and X-Project-Id are required");
}
try {
const verified = await productAuthClient.authenticate({
authorization,
projectId: projectHeader,
});
trustedContexts.set(req, {
actorKey: toActorKey(`product:${verified.user_id}`),
network: verified.network,
projectId: verified.project_id,
projectKey: toProjectKey(`product:${verified.project_id}`),
traceId: req.header("x-trace-id")?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: verified.user_id,
});
} catch (error) {
const status = error && typeof error === "object" && "status" in error &&
typeof error.status === "number" ? error.status : 503;
throw new ProductContextError(status, "product identity verification failed");
}
};
const normalizeBearerAuthorization = (value?: string) => {
const normalized = value?.trim();
return normalized && /^Bearer\s+\S+$/i.test(normalized) ? normalized : undefined;
};
+65
View File
@@ -0,0 +1,65 @@
import { config } from "../config.js";
import type { components } from "../generated/server-api.js";
export type ProductCredential = {
authorization: string;
projectId: string;
};
export type AgentAuthContext = components["schemas"]["AgentAuthContextResponse"];
export class ProductServerClient {
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
async authenticate(credential: ProductCredential): Promise<AgentAuthContext> {
const url = new URL("/api/v1/agent-auth-context", this.baseUrl);
let response: Response;
try {
response = await fetch(url, {
headers: {
Accept: "application/json",
Authorization: credential.authorization,
"X-Project-Id": credential.projectId,
},
});
} catch {
throw new ProductServerClientError(503, "TJWater Server unavailable");
}
const text = await response.text();
const parsed = parseJson(text);
if (!response.ok) {
throw new ProductServerClientError(
response.status,
readErrorMessage(parsed, "TJWater Server rejected identity verification"),
);
}
if (!parsed || typeof parsed !== "object") {
throw new ProductServerClientError(502, "TJWater Server returned invalid JSON");
}
return parsed as AgentAuthContext;
}
}
export class ProductServerClientError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
const parseJson = (text: string): unknown => {
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
};
const readErrorMessage = (value: unknown, fallback: string) => {
if (!value || typeof value !== "object") return fallback;
const record = value as Record<string, unknown>;
if (typeof record.detail === "string") return record.detail;
if (typeof record.message === "string") return record.message;
return fallback;
};
+32 -1
View File
@@ -13,6 +13,11 @@ import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { uiRegistryResponse } from "../uiEnvelope/registry.js";
import {
attachTrustedAgentContext,
getLocalAgentContext,
ProductContextError,
} from "../context/localContext.js";
import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
import { createChatRunState } from "./chatRunState.js";
@@ -48,6 +53,18 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
const chatRouter = Router();
const { activeRuns, lastRunStatuses } = runState;
chatRouter.use((req, res, next) => {
void attachTrustedAgentContext(req)
.then(() => next())
.catch((error) => {
const status = error instanceof ProductContextError ? error.status : 503;
res.status(status).json({
code: "PRODUCT_CONTEXT_UNAVAILABLE",
message: error instanceof Error ? error.message : "product identity verification failed",
});
});
});
chatRouter.get("/models", (_req, res) => {
res.json({
default_model: resolveDefaultModel(config.OPENCODE_MODEL),
@@ -63,13 +80,27 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
res.json(frontendActionRegistryResponse);
});
chatRouter.post("/frontend-actions/:action_id/result", (req, res) => {
chatRouter.post("/frontend-actions/:action_id/result", async (req, res) => {
const sessionId = req.header("x-agent-session-id")?.trim() ?? "";
if (!sessionId || req.params.action_id !== req.body?.actionId) {
res.status(400).json({ message: "invalid frontend action result" });
return;
}
try {
const context = getLocalAgentContext(req);
const sessionRecord = await sessionMetadataStore.get(
{
actorKey: context.actorKey,
projectId: context.projectId,
projectKey: context.projectKey,
userId: context.userId,
},
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const outcome = frontendActionCoordinator.submitResult(sessionId, req.body);
res.json({
accepted: true,
+21 -26
View File
@@ -59,7 +59,7 @@ export const registerChatAuxiliaryRoutes = (
return;
}
const result = await resultReferenceResolver.getFullAuthorized(resultRef, {
actorKey: toActorKey(context.userId), clientSessionId, projectId: context.projectId,
actorKey: context.actorKey, clientSessionId, projectId: context.projectId,
}, { expectedKind: RESULT_REFERENCE_KIND.serverApiPayload });
if (!result) {
res.status(404).json({ message: "result_ref not found" });
@@ -84,23 +84,25 @@ export const registerChatAuxiliaryRoutes = (
}
try {
frontendActionCoordinator.cancelSession(parsed.data.session_id);
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
const binding = sessionRecord
? await sessionBridge.abort({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
})
: null;
const run = activeRuns.get(parsed.data.session_id);
if (!sessionRecord) {
res.status(204).end();
return;
}
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
const binding = await sessionBridge.abort({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
});
const run = activeRuns.get(sessionRecord.sessionId);
if (run && run.status === "running") {
run.status = "aborted";
lastRunStatuses.set(parsed.data.session_id, "aborted");
@@ -115,16 +117,14 @@ export const registerChatAuxiliaryRoutes = (
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
if (sessionRecord) {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
}
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
for (const subscriber of run.subscribers) {
subscriber.write("error", {
session_id: parsed.data.session_id,
@@ -135,11 +135,6 @@ export const registerChatAuxiliaryRoutes = (
run.subscribers.clear();
}
if (!binding && !run) {
res.status(204).end();
return;
}
logger.info(
{
clientSessionId: parsed.data.session_id,
+6 -6
View File
@@ -91,8 +91,8 @@ export const registerChatInteractionRoutes = (
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
@@ -216,8 +216,8 @@ export const registerChatInteractionRoutes = (
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
@@ -348,8 +348,8 @@ export const registerChatInteractionRoutes = (
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
+15 -15
View File
@@ -73,8 +73,8 @@ export const registerChatSessionRoutes = (
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const requestedSessionId = parsed.data.session_id?.trim();
let sessionId = requestedSessionId;
if (!sessionId) {
@@ -112,8 +112,8 @@ export const registerChatSessionRoutes = (
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const records = await sessionMetadataStore.list({
actorKey,
projectId,
@@ -139,8 +139,8 @@ export const registerChatSessionRoutes = (
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
@@ -183,8 +183,8 @@ export const registerChatSessionRoutes = (
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
@@ -213,7 +213,7 @@ export const registerChatSessionRoutes = (
session_id: sessionRecord.sessionId,
messages: state?.messages ?? run?.messages ?? [],
is_streaming: run?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId) ?? "completed",
run_status: getSessionRunStatus(sessionRecord.sessionId),
}),
);
@@ -254,8 +254,8 @@ export const registerChatSessionRoutes = (
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId || !title) {
res.status(400).json({ message: "session_id and title are required" });
return;
@@ -296,8 +296,8 @@ export const registerChatSessionRoutes = (
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
@@ -338,8 +338,8 @@ export const registerChatSessionRoutes = (
const traceId = req.header("x-trace-id") ?? undefined;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const sourceSessionId = parsed.data.session_id?.trim();
const sourceSessionRecord = sourceSessionId
? await sessionMetadataStore.get(
+8 -2
View File
@@ -197,8 +197,8 @@ export const registerChatStreamRoute = (
const projectId = authContext.projectId;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const requestUiMessages = toUiMessages(parsed.data.messages);
const promptText =
parsed.data.message?.trim() || readLatestUserText(requestUiMessages);
@@ -219,6 +219,10 @@ export const registerChatStreamRoute = (
requestedSessionId,
)
: null;
if (requestedSessionId && !existingSessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const hadExistingRuntimeSession = Boolean(existingSessionRecord);
const { binding, requestContext, created } = await sessionBridge.resolve({
@@ -227,6 +231,8 @@ export const registerChatStreamRoute = (
projectId,
traceId,
userId,
actorKey,
projectKey,
});
setRuntimeSessionContext({
...(getRuntimeSessionContext(binding.sessionId)!),
+17 -1
View File
@@ -53,6 +53,9 @@ export class SessionMetadataStore {
this.filePath(sessionId),
);
if (existing) {
if (!matchesContext(existing, input)) {
throw new SessionContextMismatchError();
}
return { created: false, record: existing };
}
@@ -80,9 +83,10 @@ export class SessionMetadataStore {
if (!normalizedSessionId) {
return null;
}
return await readJsonFile<SessionRecord>(
const record = await readJsonFile<SessionRecord>(
this.filePath(normalizedSessionId),
);
return record && matchesContext(record, context) ? record : null;
}
async touch(
@@ -132,6 +136,18 @@ const normalizeSessionId = (value?: string) => {
return normalized ? normalized.slice(0, 128) : undefined;
};
export class SessionContextMismatchError extends Error {
constructor() {
super("session not found");
}
}
const matchesContext = (record: SessionRecord, context: SessionMetadataContext) =>
record.actorKey === context.actorKey &&
record.projectKey === context.projectKey &&
(context.userId === undefined || record.ownerUserId === context.userId) &&
(context.projectId === undefined || record.projectId === context.projectId);
const normalizeSessionUpdates = (
updates: Partial<Pick<SessionRecord, "title" | "status">>,
) => {