fix(workbench): enforce project-scoped session views
This commit is contained in:
@@ -78,6 +78,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
}
|
||||
|
||||
if (path === "/auth/session" || path === "/auth/bootstrap") return json(response, 200, authPayload());
|
||||
if (path === "/auth/login" && method === "POST") return authLoginResponse(response);
|
||||
if (path === "/v1/workbench/events" && method === "GET") return sse(response);
|
||||
if (path === "/v1/workbench/workspace" && method === "GET") return json(response, 200, { workspace: workspacePayload() });
|
||||
if (/^\/v1\/workbench\/workspace\/[^/]+$/u.test(path) && method === "PATCH") {
|
||||
@@ -104,12 +105,15 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
if (path === "/v1/agent/conversations" && method === "GET") {
|
||||
await delay(state.conversationDelayMs);
|
||||
const include = url.searchParams.get("includeConversationId");
|
||||
const conversations = state.listOmitSelected && include ? state.conversations.filter((item) => item.conversationId !== include) : state.conversations;
|
||||
const projectId = requestedProjectId(url);
|
||||
const visibleConversations = state.conversations.filter((item) => conversationProjectId(item) === projectId);
|
||||
const conversations = state.listOmitSelected && include ? visibleConversations.filter((item) => item.conversationId !== include) : visibleConversations;
|
||||
return json(response, 200, { conversations: conversations.map((conversation) => conversationSummary(conversation)) });
|
||||
}
|
||||
const conversationMatch = path.match(/^\/v1\/agent\/conversations\/([^/]+)$/u);
|
||||
if (conversationMatch && method === "GET") {
|
||||
const conversation = conversationById(decodeURIComponent(conversationMatch[1] ?? ""));
|
||||
if (conversation && conversationProjectMismatch(conversation, url)) return json(response, 404, { ok: false, status: 404, error: { code: "conversation_project_mismatch" } });
|
||||
return conversation ? json(response, 200, { conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "conversation_not_found" } });
|
||||
}
|
||||
if (conversationMatch && ["PUT", "DELETE"].includes(method)) return json(response, 200, { ok: true, workspace: workspacePayload() });
|
||||
@@ -121,6 +125,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
if (turnMatch && method === "GET") {
|
||||
const traceId = decodeURIComponent(turnMatch[1] ?? "");
|
||||
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
|
||||
if (traceProjectMismatch(traceId, url)) return json(response, 404, { ok: false, status: 404, error: { code: "trace_project_mismatch" } });
|
||||
return json(response, 200, turnPayload(traceId));
|
||||
}
|
||||
|
||||
@@ -128,6 +133,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
if (traceMatch && method === "GET") {
|
||||
const traceId = decodeURIComponent(traceMatch[1] ?? "");
|
||||
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
|
||||
if (traceProjectMismatch(traceId, url)) return json(response, 404, { ok: false, status: 404, error: { code: "trace_project_mismatch" } });
|
||||
return json(response, 200, tracePayload(traceId, url));
|
||||
}
|
||||
|
||||
@@ -144,8 +150,17 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
function createScenarioState(scenarioId: string): ScenarioState {
|
||||
const base = structuredClone(capture.scenario);
|
||||
const id = scenarioId || "baseline";
|
||||
const conversations = base.conversations;
|
||||
const traces = base.traces;
|
||||
if (id === "cross-project-detail-boundary") {
|
||||
conversations.push(crossProjectConversation());
|
||||
traces.trc_cross_project = crossProjectTrace();
|
||||
}
|
||||
if (id === "session-switch-empty-reload") conversations.push(emptyConversation());
|
||||
const selectedConversationId = id === "deep-link" || id === "stale-nested-trace"
|
||||
? "cnv_failed"
|
||||
: id === "terminal-turn-stale-session-active"
|
||||
? "cnv_completed"
|
||||
: id === "deep-link-stale-workspace-authority" || id === "stale-submit-restore"
|
||||
? "cnv_running"
|
||||
: base.selectedConversationId;
|
||||
@@ -156,9 +171,9 @@ function createScenarioState(scenarioId: string): ScenarioState {
|
||||
workspaceId: base.workspaceId,
|
||||
providerProfile: base.providerProfile,
|
||||
selectedConversationId,
|
||||
workspaceJson: initialWorkspaceJson(base.projectId, base.providerProfile, selectedConversationId, base.conversations, staleNestedTraceId),
|
||||
conversations: base.conversations,
|
||||
traces: base.traces,
|
||||
workspaceJson: initialWorkspaceJson(base.projectId, base.providerProfile, selectedConversationId, conversations, staleNestedTraceId),
|
||||
conversations,
|
||||
traces,
|
||||
selectRequests: [],
|
||||
workspacePatchRequests: [],
|
||||
chatRequests: [],
|
||||
@@ -245,6 +260,36 @@ function createConversationFromSelect(body: JsonRecord, conversationId: string):
|
||||
};
|
||||
}
|
||||
|
||||
function crossProjectConversation(): ConversationRecord {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
conversationId: "cnv_cross_project",
|
||||
projectId: "prj_v02_code_agent",
|
||||
sessionId: "ses_cross_project",
|
||||
threadId: "thr_cross_project",
|
||||
status: "completed",
|
||||
lastTraceId: "trc_cross_project",
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
messageCount: 2,
|
||||
firstUserMessagePreview: "跨项目会话不应泄露",
|
||||
session: { sessionId: "ses_cross_project", threadId: "thr_cross_project", status: "completed" },
|
||||
messages: [
|
||||
{ id: "msg_cross_project_user", role: "user", title: "用户", text: "读取另一个项目", status: "sent", createdAt: now, conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", traceId: "trc_cross_project" },
|
||||
{ id: "msg_cross_project_agent", role: "agent", title: "Code Agent", text: "另一个项目的结果", status: "completed", createdAt: now, conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", traceId: "trc_cross_project", runnerTrace: crossProjectTrace() }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function crossProjectTrace(): JsonRecord {
|
||||
return { traceId: "trc_cross_project", projectId: "prj_v02_code_agent", conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", status: "completed", events: [], eventCount: 0, fullTraceLoaded: true, hasMore: false };
|
||||
}
|
||||
|
||||
function emptyConversation(): ConversationRecord {
|
||||
const now = new Date().toISOString();
|
||||
return { conversationId: "cnv_empty", projectId: state?.projectId ?? capture.scenario.projectId, sessionId: "ses_empty", threadId: "thr_empty", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "空白会话", session: { sessionId: "ses_empty", threadId: "thr_empty", status: "active" }, messages: [] };
|
||||
}
|
||||
|
||||
function acceptChatTurn(body: JsonRecord): JsonRecord {
|
||||
const conversationId = typeof body.conversationId === "string" ? body.conversationId : state.selectedConversationId;
|
||||
const sessionId = typeof body.sessionId === "string" ? body.sessionId : conversationById(conversationId)?.sessionId ?? `ses_${conversationId.slice(4)}`;
|
||||
@@ -278,8 +323,30 @@ function conversationById(id: string): ConversationRecord | null {
|
||||
return state.conversations.find((conversation) => conversation.conversationId === id) ?? null;
|
||||
}
|
||||
|
||||
function requestedProjectId(url: URL): string {
|
||||
return url.searchParams.get("projectId") || state.projectId;
|
||||
}
|
||||
|
||||
function conversationProjectId(conversation: ConversationRecord): string {
|
||||
return typeof conversation.projectId === "string" && conversation.projectId.trim() ? conversation.projectId : state.projectId;
|
||||
}
|
||||
|
||||
function conversationProjectMismatch(conversation: ConversationRecord, url: URL): boolean {
|
||||
const projectId = url.searchParams.get("projectId");
|
||||
return Boolean(projectId && conversationProjectId(conversation) !== projectId);
|
||||
}
|
||||
|
||||
function traceProjectMismatch(traceId: string, url: URL): boolean {
|
||||
const projectId = url.searchParams.get("projectId");
|
||||
if (!projectId) return false;
|
||||
const trace = state.traces[traceId];
|
||||
const traceProjectId = typeof trace?.projectId === "string" && trace.projectId.trim() ? trace.projectId : state.conversations.find((conversation) => conversation.lastTraceId === traceId)?.projectId;
|
||||
return Boolean(traceProjectId && traceProjectId !== projectId);
|
||||
}
|
||||
|
||||
function sessionPayload(sessionId: string): JsonRecord {
|
||||
const conversation = state.conversations.find((item) => item.sessionId === sessionId);
|
||||
if (state.scenarioId === "terminal-turn-stale-session-active" && sessionId === "ses_completed") return { sessionId, status: "active", lastTraceId: "trc_completed", updatedAt: new Date().toISOString() };
|
||||
return { sessionId, status: conversation?.status ?? "unknown", lastTraceId: conversation?.lastTraceId ?? null, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@@ -356,9 +423,15 @@ function stateSummary(): JsonRecord {
|
||||
}
|
||||
|
||||
function authPayload(): JsonRecord {
|
||||
if (state.scenarioId === "auth-upstream-unavailable" || state.scenarioId === "auth-invalid-credentials") return { authenticated: false, mode: "server", actor: null, user: null, expiresAt: null };
|
||||
return { authenticated: true, mode: "server", actor: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, user: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, expiresAt: null };
|
||||
}
|
||||
|
||||
function authLoginResponse(response: ServerResponse): void {
|
||||
if (state.scenarioId === "auth-upstream-unavailable") return json(response, 502, { ok: false, status: "failed", error: { code: "upstream_unavailable", message: "auth upstream unavailable", retryable: true, layer: "proxy" } });
|
||||
return json(response, 401, { ok: false, status: "failed", error: { code: "invalid_credentials", message: "invalid credentials" } });
|
||||
}
|
||||
|
||||
function redactRequestBody(value: JsonRecord): JsonRecord {
|
||||
const output: JsonRecord = {};
|
||||
for (const [key, item] of Object.entries(value)) output[key] = /secret|token|key|password|authorization/iu.test(key) ? "<redacted>" : item;
|
||||
|
||||
Reference in New Issue
Block a user