feat(context): verify product session scope
This commit is contained in:
@@ -15,10 +15,12 @@ export type SessionBinding = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SessionContext = {
|
export type SessionContext = {
|
||||||
|
actorKey?: string;
|
||||||
clientSessionId: string;
|
clientSessionId: string;
|
||||||
network?: string;
|
network?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
projectKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatRequestContext = SessionContext & {
|
export type ChatRequestContext = SessionContext & {
|
||||||
@@ -38,6 +40,8 @@ export class ChatSessionBridge {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
traceId?: string;
|
traceId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
actorKey?: string;
|
||||||
|
projectKey?: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
binding: SessionBinding;
|
binding: SessionBinding;
|
||||||
requestContext: ChatRequestContext;
|
requestContext: ChatRequestContext;
|
||||||
@@ -139,19 +143,21 @@ export class ChatSessionBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildRequestContext(context: {
|
private buildRequestContext(context: {
|
||||||
|
actorKey?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
network?: string;
|
network?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
traceId?: string;
|
traceId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
projectKey?: string;
|
||||||
}): ChatRequestContext {
|
}): ChatRequestContext {
|
||||||
const sessionId = context.sessionId?.trim();
|
const sessionId = context.sessionId?.trim();
|
||||||
return {
|
return {
|
||||||
clientSessionId: sessionId || this.createClientSessionId(),
|
clientSessionId: sessionId || this.createClientSessionId(),
|
||||||
actorKey: toActorKey(context.userId),
|
actorKey: context.actorKey ?? toActorKey(`local:${context.userId ?? "local-user"}`),
|
||||||
network: context.network,
|
network: context.network,
|
||||||
projectId: context.projectId,
|
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)}`,
|
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
||||||
userId: context.userId?.trim(),
|
userId: context.userId?.trim(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const envSchema = z
|
|||||||
PORT: z.coerce.number().int().positive().default(8787),
|
PORT: z.coerce.number().int().positive().default(8787),
|
||||||
// HTTP 服务监听地址。
|
// HTTP 服务监听地址。
|
||||||
HOST: z.string().default("0.0.0.0"),
|
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 日志级别。
|
// Pino 日志级别。
|
||||||
LOG_LEVEL: z.string().default("info"),
|
LOG_LEVEL: z.string().default("info"),
|
||||||
// LLM 工具/技能调用审计日志路径。
|
// LLM 工具/技能调用审计日志路径。
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
|
import { ProductServerClient } from "../product/server-auth-client.js";
|
||||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||||
|
|
||||||
export type LocalAgentContext = {
|
export type LocalAgentContext = {
|
||||||
@@ -15,17 +16,69 @@ export type LocalAgentContext = {
|
|||||||
const DEFAULT_USER_ID = process.env.AGENT_LOCAL_USER_ID ?? "local-user";
|
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_PROJECT_ID = process.env.AGENT_LOCAL_PROJECT_ID ?? "local-project";
|
||||||
const DEFAULT_NETWORK = process.env.AGENT_LOCAL_NETWORK ?? "local-network";
|
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 => {
|
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 userId = req?.header("x-agent-local-user")?.trim() || DEFAULT_USER_ID;
|
||||||
const projectId =
|
const projectId =
|
||||||
req?.header("x-agent-local-project")?.trim() || DEFAULT_PROJECT_ID;
|
req?.header("x-agent-local-project")?.trim() || DEFAULT_PROJECT_ID;
|
||||||
return {
|
return {
|
||||||
actorKey: toActorKey(userId),
|
actorKey: toActorKey(`local:${userId}`),
|
||||||
network: req?.header("x-agent-local-network")?.trim() || DEFAULT_NETWORK,
|
network: req?.header("x-agent-local-network")?.trim() || DEFAULT_NETWORK,
|
||||||
projectId,
|
projectId,
|
||||||
projectKey: toProjectKey(projectId),
|
projectKey: toProjectKey(`local:${projectId}`),
|
||||||
traceId: req?.header("x-trace-id")?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
traceId: req?.header("x-trace-id")?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
||||||
userId,
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
@@ -13,6 +13,11 @@ import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
|||||||
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||||
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||||
import { uiRegistryResponse } from "../uiEnvelope/registry.js";
|
import { uiRegistryResponse } from "../uiEnvelope/registry.js";
|
||||||
|
import {
|
||||||
|
attachTrustedAgentContext,
|
||||||
|
getLocalAgentContext,
|
||||||
|
ProductContextError,
|
||||||
|
} from "../context/localContext.js";
|
||||||
import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
|
import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
|
||||||
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
|
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
|
||||||
import { createChatRunState } from "./chatRunState.js";
|
import { createChatRunState } from "./chatRunState.js";
|
||||||
@@ -48,6 +53,18 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
|
|||||||
const chatRouter = Router();
|
const chatRouter = Router();
|
||||||
const { activeRuns, lastRunStatuses } = runState;
|
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) => {
|
chatRouter.get("/models", (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
default_model: resolveDefaultModel(config.OPENCODE_MODEL),
|
default_model: resolveDefaultModel(config.OPENCODE_MODEL),
|
||||||
@@ -63,13 +80,27 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
|
|||||||
res.json(frontendActionRegistryResponse);
|
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() ?? "";
|
const sessionId = req.header("x-agent-session-id")?.trim() ?? "";
|
||||||
if (!sessionId || req.params.action_id !== req.body?.actionId) {
|
if (!sessionId || req.params.action_id !== req.body?.actionId) {
|
||||||
res.status(400).json({ message: "invalid frontend action result" });
|
res.status(400).json({ message: "invalid frontend action result" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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);
|
const outcome = frontendActionCoordinator.submitResult(sessionId, req.body);
|
||||||
res.json({
|
res.json({
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const registerChatAuxiliaryRoutes = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await resultReferenceResolver.getFullAuthorized(resultRef, {
|
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 });
|
}, { expectedKind: RESULT_REFERENCE_KIND.serverApiPayload });
|
||||||
if (!result) {
|
if (!result) {
|
||||||
res.status(404).json({ message: "result_ref not found" });
|
res.status(404).json({ message: "result_ref not found" });
|
||||||
@@ -84,23 +84,25 @@ export const registerChatAuxiliaryRoutes = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
frontendActionCoordinator.cancelSession(parsed.data.session_id);
|
|
||||||
const authContext = getLocalAgentContext(req);
|
const authContext = getLocalAgentContext(req);
|
||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
const sessionRecord = await sessionMetadataStore.get(
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
{ actorKey, projectId, projectKey, userId },
|
{ actorKey, projectId, projectKey, userId },
|
||||||
parsed.data.session_id,
|
parsed.data.session_id,
|
||||||
);
|
);
|
||||||
const binding = sessionRecord
|
if (!sessionRecord) {
|
||||||
? await sessionBridge.abort({
|
res.status(204).end();
|
||||||
clientSessionId: sessionRecord.sessionId,
|
return;
|
||||||
sessionId: sessionRecord.sessionId,
|
}
|
||||||
})
|
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
|
||||||
: null;
|
const binding = await sessionBridge.abort({
|
||||||
const run = activeRuns.get(parsed.data.session_id);
|
clientSessionId: sessionRecord.sessionId,
|
||||||
|
sessionId: sessionRecord.sessionId,
|
||||||
|
});
|
||||||
|
const run = activeRuns.get(sessionRecord.sessionId);
|
||||||
if (run && run.status === "running") {
|
if (run && run.status === "running") {
|
||||||
run.status = "aborted";
|
run.status = "aborted";
|
||||||
lastRunStatuses.set(parsed.data.session_id, "aborted");
|
lastRunStatuses.set(parsed.data.session_id, "aborted");
|
||||||
@@ -115,16 +117,14 @@ export const registerChatAuxiliaryRoutes = (
|
|||||||
progress: completeBackendProgress(message.progress),
|
progress: completeBackendProgress(message.progress),
|
||||||
todos: cancelBackendTodos(message.todos),
|
todos: cancelBackendTodos(message.todos),
|
||||||
}));
|
}));
|
||||||
if (sessionRecord) {
|
const currentState = await sessionUiStateStore.read(
|
||||||
const currentState = await sessionUiStateStore.read(
|
toSessionUiStateContext(sessionRecord.sessionId),
|
||||||
toSessionUiStateContext(sessionRecord.sessionId),
|
);
|
||||||
);
|
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
|
||||||
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
|
sessionId: sessionRecord.sessionId,
|
||||||
sessionId: sessionRecord.sessionId,
|
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||||
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
messages: run.messages,
|
||||||
messages: run.messages,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const subscriber of run.subscribers) {
|
for (const subscriber of run.subscribers) {
|
||||||
subscriber.write("error", {
|
subscriber.write("error", {
|
||||||
session_id: parsed.data.session_id,
|
session_id: parsed.data.session_id,
|
||||||
@@ -135,11 +135,6 @@ export const registerChatAuxiliaryRoutes = (
|
|||||||
run.subscribers.clear();
|
run.subscribers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!binding && !run) {
|
|
||||||
res.status(204).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
clientSessionId: parsed.data.session_id,
|
clientSessionId: parsed.data.session_id,
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ export const registerChatInteractionRoutes = (
|
|||||||
const authContext = getLocalAgentContext(req);
|
const authContext = getLocalAgentContext(req);
|
||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
const sessionRecord = await sessionMetadataStore.get(
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
{ actorKey, projectId, projectKey, userId },
|
{ actorKey, projectId, projectKey, userId },
|
||||||
parsed.data.session_id,
|
parsed.data.session_id,
|
||||||
@@ -216,8 +216,8 @@ export const registerChatInteractionRoutes = (
|
|||||||
const authContext = getLocalAgentContext(req);
|
const authContext = getLocalAgentContext(req);
|
||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
const sessionRecord = await sessionMetadataStore.get(
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
{ actorKey, projectId, projectKey, userId },
|
{ actorKey, projectId, projectKey, userId },
|
||||||
parsed.data.session_id,
|
parsed.data.session_id,
|
||||||
@@ -348,8 +348,8 @@ export const registerChatInteractionRoutes = (
|
|||||||
const authContext = getLocalAgentContext(req);
|
const authContext = getLocalAgentContext(req);
|
||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
const sessionRecord = await sessionMetadataStore.get(
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
{ actorKey, projectId, projectKey, userId },
|
{ actorKey, projectId, projectKey, userId },
|
||||||
parsed.data.session_id,
|
parsed.data.session_id,
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export const registerChatSessionRoutes = (
|
|||||||
const authContext = getLocalAgentContext(req);
|
const authContext = getLocalAgentContext(req);
|
||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
const requestedSessionId = parsed.data.session_id?.trim();
|
const requestedSessionId = parsed.data.session_id?.trim();
|
||||||
let sessionId = requestedSessionId;
|
let sessionId = requestedSessionId;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -112,8 +112,8 @@ export const registerChatSessionRoutes = (
|
|||||||
const authContext = getLocalAgentContext(req);
|
const authContext = getLocalAgentContext(req);
|
||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
const records = await sessionMetadataStore.list({
|
const records = await sessionMetadataStore.list({
|
||||||
actorKey,
|
actorKey,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -139,8 +139,8 @@ export const registerChatSessionRoutes = (
|
|||||||
const authContext = getLocalAgentContext(req);
|
const authContext = getLocalAgentContext(req);
|
||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
res.status(400).json({ message: "session_id is required" });
|
res.status(400).json({ message: "session_id is required" });
|
||||||
return;
|
return;
|
||||||
@@ -183,8 +183,8 @@ export const registerChatSessionRoutes = (
|
|||||||
const authContext = getLocalAgentContext(req);
|
const authContext = getLocalAgentContext(req);
|
||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
res.status(400).json({ message: "session_id is required" });
|
res.status(400).json({ message: "session_id is required" });
|
||||||
return;
|
return;
|
||||||
@@ -213,7 +213,7 @@ export const registerChatSessionRoutes = (
|
|||||||
session_id: sessionRecord.sessionId,
|
session_id: sessionRecord.sessionId,
|
||||||
messages: state?.messages ?? run?.messages ?? [],
|
messages: state?.messages ?? run?.messages ?? [],
|
||||||
is_streaming: run?.status === "running",
|
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 authContext = getLocalAgentContext(req);
|
||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
if (!sessionId || !title) {
|
if (!sessionId || !title) {
|
||||||
res.status(400).json({ message: "session_id and title are required" });
|
res.status(400).json({ message: "session_id and title are required" });
|
||||||
return;
|
return;
|
||||||
@@ -296,8 +296,8 @@ export const registerChatSessionRoutes = (
|
|||||||
const authContext = getLocalAgentContext(req);
|
const authContext = getLocalAgentContext(req);
|
||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
res.status(400).json({ message: "session_id is required" });
|
res.status(400).json({ message: "session_id is required" });
|
||||||
return;
|
return;
|
||||||
@@ -338,8 +338,8 @@ export const registerChatSessionRoutes = (
|
|||||||
const traceId = req.header("x-trace-id") ?? undefined;
|
const traceId = req.header("x-trace-id") ?? undefined;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
|
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
const sourceSessionId = parsed.data.session_id?.trim();
|
const sourceSessionId = parsed.data.session_id?.trim();
|
||||||
const sourceSessionRecord = sourceSessionId
|
const sourceSessionRecord = sourceSessionId
|
||||||
? await sessionMetadataStore.get(
|
? await sessionMetadataStore.get(
|
||||||
|
|||||||
@@ -197,8 +197,8 @@ export const registerChatStreamRoute = (
|
|||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const traceId = req.header("x-trace-id") ?? undefined;
|
const traceId = req.header("x-trace-id") ?? undefined;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = authContext.actorKey;
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = authContext.projectKey;
|
||||||
const requestUiMessages = toUiMessages(parsed.data.messages);
|
const requestUiMessages = toUiMessages(parsed.data.messages);
|
||||||
const promptText =
|
const promptText =
|
||||||
parsed.data.message?.trim() || readLatestUserText(requestUiMessages);
|
parsed.data.message?.trim() || readLatestUserText(requestUiMessages);
|
||||||
@@ -219,6 +219,10 @@ export const registerChatStreamRoute = (
|
|||||||
requestedSessionId,
|
requestedSessionId,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
if (requestedSessionId && !existingSessionRecord) {
|
||||||
|
res.status(404).json({ message: "session not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const hadExistingRuntimeSession = Boolean(existingSessionRecord);
|
const hadExistingRuntimeSession = Boolean(existingSessionRecord);
|
||||||
|
|
||||||
const { binding, requestContext, created } = await sessionBridge.resolve({
|
const { binding, requestContext, created } = await sessionBridge.resolve({
|
||||||
@@ -227,6 +231,8 @@ export const registerChatStreamRoute = (
|
|||||||
projectId,
|
projectId,
|
||||||
traceId,
|
traceId,
|
||||||
userId,
|
userId,
|
||||||
|
actorKey,
|
||||||
|
projectKey,
|
||||||
});
|
});
|
||||||
setRuntimeSessionContext({
|
setRuntimeSessionContext({
|
||||||
...(getRuntimeSessionContext(binding.sessionId)!),
|
...(getRuntimeSessionContext(binding.sessionId)!),
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export class SessionMetadataStore {
|
|||||||
this.filePath(sessionId),
|
this.filePath(sessionId),
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (!matchesContext(existing, input)) {
|
||||||
|
throw new SessionContextMismatchError();
|
||||||
|
}
|
||||||
return { created: false, record: existing };
|
return { created: false, record: existing };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,9 +83,10 @@ export class SessionMetadataStore {
|
|||||||
if (!normalizedSessionId) {
|
if (!normalizedSessionId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await readJsonFile<SessionRecord>(
|
const record = await readJsonFile<SessionRecord>(
|
||||||
this.filePath(normalizedSessionId),
|
this.filePath(normalizedSessionId),
|
||||||
);
|
);
|
||||||
|
return record && matchesContext(record, context) ? record : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async touch(
|
async touch(
|
||||||
@@ -132,6 +136,18 @@ const normalizeSessionId = (value?: string) => {
|
|||||||
return normalized ? normalized.slice(0, 128) : undefined;
|
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 = (
|
const normalizeSessionUpdates = (
|
||||||
updates: Partial<Pick<SessionRecord, "title" | "status">>,
|
updates: Partial<Pick<SessionRecord, "title" | "status">>,
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "bun:test";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Request } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
attachTrustedAgentContext,
|
||||||
|
getLocalAgentContext,
|
||||||
|
ProductContextError,
|
||||||
|
} from "../../src/context/localContext.js";
|
||||||
|
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestWithHeaders = (headers: Record<string, string>) => ({
|
||||||
|
header: (name: string) => headers[name.toLowerCase()],
|
||||||
|
}) as Request;
|
||||||
|
|
||||||
|
const authResponse = (userId: string, projectId: string) => ({
|
||||||
|
is_superuser: false,
|
||||||
|
keycloak_sub: `sub-${userId}`,
|
||||||
|
network: "supply",
|
||||||
|
permissions: ["analysis:read"],
|
||||||
|
project_id: projectId,
|
||||||
|
project_role: "member",
|
||||||
|
role: "user",
|
||||||
|
token_expires_at: "2026-09-17T00:00:00Z",
|
||||||
|
user_id: userId,
|
||||||
|
username: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trusted product chat context", () => {
|
||||||
|
it("uses verified product users and projects instead of spoofable local headers", async () => {
|
||||||
|
const identities = [authResponse("product-user-1", "project-1"), authResponse("product-user-2", "project-2")];
|
||||||
|
globalThis.fetch = (async () => Response.json(identities.shift())) as unknown as typeof fetch;
|
||||||
|
const first = requestWithHeaders({
|
||||||
|
authorization: "Bearer product-token-1",
|
||||||
|
"x-project-id": "project-1",
|
||||||
|
"x-agent-local-user": "spoofed-user",
|
||||||
|
"x-agent-local-project": "spoofed-project",
|
||||||
|
});
|
||||||
|
const second = requestWithHeaders({
|
||||||
|
authorization: "Bearer product-token-2",
|
||||||
|
"x-project-id": "project-2",
|
||||||
|
"x-agent-local-user": "spoofed-user",
|
||||||
|
"x-agent-local-project": "spoofed-project",
|
||||||
|
});
|
||||||
|
await attachTrustedAgentContext(first);
|
||||||
|
await attachTrustedAgentContext(second);
|
||||||
|
|
||||||
|
const firstContext = getLocalAgentContext(first);
|
||||||
|
const secondContext = getLocalAgentContext(second);
|
||||||
|
expect(firstContext.userId).toBe("product-user-1");
|
||||||
|
expect(firstContext.projectId).toBe("project-1");
|
||||||
|
expect(secondContext.userId).toBe("product-user-2");
|
||||||
|
expect(secondContext.projectId).toBe("project-2");
|
||||||
|
expect(firstContext.actorKey).not.toBe(secondContext.actorKey);
|
||||||
|
expect(firstContext.projectKey).not.toBe(secondContext.projectKey);
|
||||||
|
|
||||||
|
const localSpoof = requestWithHeaders({
|
||||||
|
"x-agent-local-user": "product-user-1",
|
||||||
|
"x-agent-local-project": "project-1",
|
||||||
|
});
|
||||||
|
await attachTrustedAgentContext(localSpoof);
|
||||||
|
const localContext = getLocalAgentContext(localSpoof);
|
||||||
|
expect(localContext.actorKey).not.toBe(firstContext.actorKey);
|
||||||
|
expect(localContext.projectKey).not.toBe(firstContext.projectKey);
|
||||||
|
|
||||||
|
const tempDir = await mkdtemp(join(tmpdir(), "tjwater-product-session-"));
|
||||||
|
try {
|
||||||
|
const store = new SessionMetadataStore(tempDir);
|
||||||
|
await store.initialize();
|
||||||
|
await store.ensure({ ...firstContext, sessionId: "private-session" });
|
||||||
|
expect(await store.get(secondContext, "private-session")).toBeNull();
|
||||||
|
expect((await store.get(firstContext, "private-session"))?.sessionId).toBe("private-session");
|
||||||
|
} finally {
|
||||||
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when any supplied product credential cannot be verified", async () => {
|
||||||
|
globalThis.fetch = (async () => Response.json({ detail: "expired" }, { status: 401 })) as unknown as typeof fetch;
|
||||||
|
const request = requestWithHeaders({
|
||||||
|
authorization: "Bearer expired-token",
|
||||||
|
"x-project-id": "project-1",
|
||||||
|
});
|
||||||
|
expect(attachTrustedAgentContext(request)).rejects.toBeInstanceOf(ProductContextError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,10 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
import {
|
||||||
|
SessionContextMismatchError,
|
||||||
|
SessionMetadataStore,
|
||||||
|
} from "../../src/sessions/metadataStore.js";
|
||||||
|
|
||||||
describe("SessionMetadataStore", () => {
|
describe("SessionMetadataStore", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -15,6 +18,37 @@ describe("SessionMetadataStore", () => {
|
|||||||
await store.initialize();
|
await store.initialize();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not return or ensure a session owned by another context", async () => {
|
||||||
|
await store.ensure({
|
||||||
|
actorKey: "actor-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectKey: "project-key-1",
|
||||||
|
sessionId: "shared-id",
|
||||||
|
userId: "user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await store.get(
|
||||||
|
{
|
||||||
|
actorKey: "actor-2",
|
||||||
|
projectId: "project-2",
|
||||||
|
projectKey: "project-key-2",
|
||||||
|
userId: "user-2",
|
||||||
|
},
|
||||||
|
"shared-id",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
store.ensure({
|
||||||
|
actorKey: "actor-2",
|
||||||
|
projectId: "project-2",
|
||||||
|
projectKey: "project-key-2",
|
||||||
|
sessionId: "shared-id",
|
||||||
|
userId: "user-2",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(SessionContextMismatchError);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await rm(tempDir, { force: true, recursive: true });
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user