diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 93ec502e..b6f07b95 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -1204,6 +1204,54 @@ test("cloud api stores and restores Code Agent conversations by authenticated ac } }); +test("cloud api conversation detail rejects a mismatched requested project", async () => { + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + }, + now: () => "2026-06-17T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue1429", password: "alice-pass" }, adminLogin.cookie); + assert.equal(aliceCreate.status, 201); + const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue1429", password: "alice-pass" }); + + const stored = await putJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope", { + projectId: "prj_v02_code_agent", + sessionId: "ses_issue1429_project_scope", + threadId: "thread-issue1429-project-scope", + lastTraceId: "trc_issue1429_project_scope", + sessionStatus: "completed", + messages: [ + { id: "msg_issue1429_user", role: "user", title: "用户", text: "跨项目读取", status: "sent", conversationId: "cnv_issue1429_project_scope", sessionId: "ses_issue1429_project_scope", threadId: "thread-issue1429-project-scope" }, + { id: "msg_issue1429_agent", role: "agent", title: "Agent", text: "ok", status: "completed", traceId: "trc_issue1429_project_scope", conversationId: "cnv_issue1429_project_scope", sessionId: "ses_issue1429_project_scope", threadId: "thread-issue1429-project-scope" } + ] + }, aliceLogin.cookie); + assert.equal(stored.status, 200); + + const wrongProject = await getJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope?projectId=prj_hwpod_workbench", aliceLogin.cookie); + assert.equal(wrongProject.status, 404); + assert.equal(wrongProject.body.error.code, "conversation_project_mismatch"); + + const rightProject = await getJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope?projectId=prj_v02_code_agent", aliceLogin.cookie); + assert.equal(rightProject.status, 200); + assert.equal(rightProject.body.conversation.projectId, "prj_v02_code_agent"); + + const compatibilityDirect = await getJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope", aliceLogin.cookie); + assert.equal(compatibilityDirect.status, 200); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api includes an explicitly requested conversation in the conversations list API", async () => { let now = "2026-06-16T08:00:00.000Z"; const server = createCloudApiServer({ diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index cb94ad78..461c9340 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1073,11 +1073,18 @@ class AccessController { actorRole: auth.actor.role, ownerScoped: true, conversationId, + projectId, limit: 20, includeArchived: true, now: this.now() }) ?? []; - const conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; + let conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; + if (!conversation && projectId) { + const visibleElsewhere = await this.visibleConversationForActor(auth.actor, conversationId, "", { includeArchived: true }); + if (visibleElsewhere) { + return sendJson(response, 404, errorPayload("conversation_project_mismatch", "Agent conversation is not visible in the requested project", 404)); + } + } if (!conversation) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404)); return sendJson(response, 200, { ok: true, status: "found", contractVersion: "agent-conversations-v1", actor: publicActor(auth.actor), conversation }); } diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 33ed2455..729f212d 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -99,6 +99,44 @@ test("cloud api trace resource paginates events by sinceSeq", async () => { } }); +test("cloud api turn and trace endpoints reject mismatched requested project", async () => { + const traceStore = createCodeAgentTraceStore(); + const traceId = "trc_issue1429_project_scope"; + traceStore.append(traceId, { type: "trace", status: "completed", label: "turn:completed", terminal: true }); + const server = createCloudApiServer({ + traceStore, + accessController: { + required: false, + async authenticate() { + return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; + }, + async getAgentSessionByTraceId(requestedTraceId) { + assert.equal(requestedTraceId, traceId); + return testAgentSessionRecord({ sessionId: "ses_issue1429_project_scope", projectId: "prj_v02_code_agent", traceId, status: "completed" }); + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const wrongTurn = await fetch(`http://127.0.0.1:${port}/v1/agent/turns/${traceId}?projectId=prj_hwpod_workbench`); + assert.equal(wrongTurn.status, 404); + assert.equal((await wrongTurn.json()).error.code, "trace_project_mismatch"); + + const wrongTrace = await fetch(`http://127.0.0.1:${port}/v1/agent/traces/${traceId}?projectId=prj_hwpod_workbench`); + assert.equal(wrongTrace.status, 404); + assert.equal((await wrongTrace.json()).error.code, "trace_project_mismatch"); + + const rightTrace = await fetch(`http://127.0.0.1:${port}/v1/agent/traces/${traceId}?projectId=prj_v02_code_agent`); + assert.equal(rightTrace.status, 200); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("AgentRun adapter filters resource tools and credentials through access capabilities", async () => { const calls = []; const agentRunServer = createHttpServer(async (request, response) => { diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index bec7d471..683e621d 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -1190,6 +1190,11 @@ export async function handleCodeAgentTurnHttp(request, response, url, options) { }); return; } + const projectMismatch = await traceProjectMismatchSnapshot(traceId, url, options, "turn"); + if (projectMismatch) { + sendJson(response, projectMismatch.statusCode, projectMismatch.body); + return; + } const resolved = await resolveCodeAgentTurnStatusSnapshot(traceId, options); sendJson(response, resolved.statusCode, resolved.body); } @@ -1281,6 +1286,30 @@ function forbiddenTurnSnapshot(traceId) { }; } +async function traceProjectMismatchSnapshot(traceId, url, options, resourceKind) { + const requestedProjectId = textValue(url.searchParams.get("projectId")); + if (!requestedProjectId || !options.accessController?.getAgentSessionByTraceId) return null; + const session = await options.accessController.getAgentSessionByTraceId(traceId); + if (!session) return null; + if (session.ownerUserId && options.actor?.role !== "admin" && session.ownerUserId !== options.actor?.id) return forbiddenTurnSnapshot(traceId); + const actualProjectId = textValue(session.projectId); + if (!actualProjectId || actualProjectId === requestedProjectId) return null; + return { + statusCode: 404, + body: { + ok: false, + status: "not_found", + running: false, + terminal: false, + traceId, + error: { + code: "trace_project_mismatch", + message: `${resourceKind === "turn" ? "Agent turn" : "Agent trace"} is not visible in the requested project` + } + } + }; +} + function codeAgentTurnStatusPayload({ traceId, result, snapshot, resultPollError, refreshError, options }) { const resultObject = result && typeof result === "object" ? result : null; const snapshotObject = snapshot && typeof snapshot === "object" ? snapshot : null; @@ -2552,6 +2581,11 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) }); return; } + const projectMismatch = await traceProjectMismatchSnapshot(traceId, url, options, "trace"); + if (projectMismatch) { + sendJson(response, projectMismatch.statusCode, projectMismatch.body); + return; + } const result = options.codeAgentChatResults?.get(traceId) ?? null; if (result && !canAccessOwnedResult(result, options.actor)) { sendJson(response, 403, { diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index d29d21d9..6c0cf5a8 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -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) ? "" : item; diff --git a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts index 7272195c..abede9c6 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts @@ -219,6 +219,24 @@ test("R2 session tabs keep trace metadata but status comes only from session aut assert.equal(authorityTabs[0]?.running, false); }); +test("R2 terminal conversation evidence overrides stale active session authority", () => { + const conversation: ConversationRecord = { + conversationId: "cnv_terminal", + sessionId: "ses_terminal", + status: "completed", + lastTraceId: "trc_terminal", + firstUserMessagePreview: "终端状态会话", + messages: [ + userMessage({ conversationId: "cnv_terminal", createdAt: "2026-01-04T00:00:00.000Z" }), + agentMessage({ conversationId: "cnv_terminal", sessionId: "ses_terminal", traceId: "trc_terminal", status: "completed", updatedAt: "2026-01-04T00:01:00.000Z" }) + ] + }; + const tabs = sortSessionTabs([conversation], "cnv_terminal", { ses_terminal: { sessionId: "ses_terminal", status: "active" } }); + assert.equal(tabs[0]?.status, "completed"); + assert.equal(tabs[0]?.running, false); + assert.match(tabs[0]?.subtitle ?? "", /trace trc_term/u); +}); + test("R2 agent message titles drop transient running wording", () => { assert.equal(normalizeWorkbenchMessageTitle("agent", "Code Agent 处理中"), "Code Agent"); assert.equal(normalizeWorkbenchMessageTitle("agent", "Code Agent running"), "Code Agent"); diff --git a/web/hwlab-cloud-web/src/stores/auth.ts b/web/hwlab-cloud-web/src/stores/auth.ts index fe7612ae..b2a11514 100644 --- a/web/hwlab-cloud-web/src/stores/auth.ts +++ b/web/hwlab-cloud-web/src/stores/auth.ts @@ -39,7 +39,7 @@ export const useAuthStore = defineStore("auth", () => { error.value = null; const response = await authAPI.login(username.trim(), password); if (!response.ok || response.data?.authenticated !== true) { - error.value = "账号或密码不正确,请重新输入。"; + error.value = loginFailureMessage(response); authState.value = "login"; return; } @@ -93,3 +93,24 @@ function workspaceFromUnknown(value: unknown): { workspaceId: string } | null { const workspaceId = nonEmptyString(record?.workspaceId); return workspaceId ? { ...record, workspaceId } : null; } + +function loginFailureMessage(response: Awaited>): string { + const code = loginErrorCode(response.data) ?? nonEmptyString(response.error); + const normalizedCode = code?.trim().toLowerCase().replace(/_/gu, "-") ?? ""; + if (response.status === 401 || response.status === 403 || ["invalid-credentials", "unauthorized", "forbidden"].includes(normalizedCode)) { + return "账号或密码不正确,请重新输入。"; + } + if (response.status === 0) { + return "登录服务暂时不可达,请稍后重试。"; + } + if (response.status >= 500 || /(?:upstream|unavailable|timeout|proxy|bad-gateway)/u.test(normalizedCode)) { + return code ? `登录服务暂时不可用,请稍后重试。错误码:${code}` : "登录服务暂时不可用,请稍后重试。"; + } + return response.error ? `登录失败:${response.error}` : "登录失败,请稍后重试。"; +} + +function loginErrorCode(payload: unknown): string | null { + const record = asRecord(payload); + const error = asRecord(record?.error); + return nonEmptyString(error?.code) ?? nonEmptyString(record?.code); +} diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index c5f834b7..71baf4c3 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -147,7 +147,7 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId); const conversationId = conversation.conversationId; const updatedAt = conversationDisplayUpdatedAt(conversation); - const status = normalizeSessionStatus(sessionId ? sessionStatusAuthority[sessionId]?.status : null) ?? "unknown"; + const status = resolveSessionTabStatus(conversation, sessionId ? sessionStatusAuthority[sessionId] : null); const trace = firstNonEmptyString(latestAgentMessage(conversation.messages)?.traceId, conversation.messages?.at(-1)?.traceId, conversation.lastTraceId); const userMessage = conversation.messages?.find((message) => message.role === "user"); const running = isActiveStatus(status); @@ -276,6 +276,23 @@ function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage | return [...(messages ?? [])].reverse().find((message) => message.role === "agent") ?? null; } +function resolveSessionTabStatus(conversation: ConversationRecord, authority: SessionStatusAuthority | null | undefined): string { + const authorityStatus = normalizeSessionStatus(authority?.status); + const terminalStatus = terminalStatusFromConversation(conversation); + if (authorityStatus && isActiveStatus(authorityStatus) && terminalStatus) return terminalStatus; + return authorityStatus ?? terminalStatus ?? "unknown"; +} + +function terminalStatusFromConversation(conversation: ConversationRecord): string | null { + const agentMessage = latestAgentMessage(conversation.messages); + const candidates = [conversation.status, conversation.session?.status, agentMessage?.status, agentMessage?.runnerTrace?.status]; + for (const candidate of candidates) { + const status = normalizeSessionStatus(candidate); + if (status && isTerminalStatus(status)) return status; + } + return null; +} + function messageBelongsToCancelTarget(message: ChatMessage, input: { activeConversationId: string | null; targetSessionId?: string | null; targetThreadId?: string | null }): boolean { const activeConversationId = firstNonEmptyString(input.activeConversationId); const messageConversationId = firstNonEmptyString(message.conversationId); @@ -300,7 +317,7 @@ function latestConversationMessage(messages: ChatMessage[], activeConversationId function isActiveStatus(value: unknown): boolean { const status = normalizeSessionStatus(value); - return ["running", "pending", "accepted", "processing", "busy", "creating"].includes(status ?? ""); + return ["active", "running", "pending", "accepted", "processing", "busy", "creating"].includes(status ?? ""); } function isTerminalStatus(value: unknown): boolean { diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/auth-login.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/auth-login.spec.ts new file mode 100644 index 00000000..f2a77f33 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/auth-login.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "../fixtures/test"; + +test.describe("login error mapping", () => { + test.use({ scenarioId: "auth-upstream-unavailable" }); + + test("shows service unavailable copy for upstream 502", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("账号或邮箱").fill("alice@example.com"); + await page.getByLabel("密码").fill("not-the-issue"); + await page.getByRole("button", { name: "登录" }).click(); + await expect(page.locator(".form-error")).toContainText("登录服务暂时不可用"); + await expect(page.locator(".form-error")).toContainText("upstream_unavailable"); + await expect(page.locator(".form-error")).not.toContainText("账号或密码不正确"); + }); +}); + +test.describe("login invalid credentials", () => { + test.use({ scenarioId: "auth-invalid-credentials" }); + + test("keeps credential copy for 401", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("账号或邮箱").fill("alice@example.com"); + await page.getByLabel("密码").fill("wrong-password"); + await page.getByRole("button", { name: "登录" }).click(); + await expect(page.locator(".form-error")).toContainText("账号或密码不正确"); + }); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/project-boundary.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/project-boundary.spec.ts new file mode 100644 index 00000000..f4dff7d9 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/project-boundary.spec.ts @@ -0,0 +1,26 @@ +import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test"; +import { selectors, sessionTab } from "../fixtures/selectors"; + +test.describe("project-scoped conversation details", () => { + test.use({ scenarioId: "cross-project-detail-boundary" }); + + test("rejects conversation, turn and trace details outside the requested project", async ({ page }, testInfo) => { + const projectId = "prj_hwpod_workbench"; + const conversation = await page.request.get(`/v1/agent/conversations/cnv_cross_project?projectId=${projectId}`); + expect(conversation.status()).toBe(404); + await expect((await conversation.json()).error.code).toBe("conversation_project_mismatch"); + + const turn = await page.request.get(`/v1/agent/turns/trc_cross_project?projectId=${projectId}`); + expect(turn.status()).toBe(404); + await expect((await turn.json()).error.code).toBe("trace_project_mismatch"); + + const trace = await page.request.get(`/v1/agent/traces/trc_cross_project?projectId=${projectId}`); + expect(trace.status()).toBe(404); + await expect((await trace.json()).error.code).toBe("trace_project_mismatch"); + + await gotoWorkbench(page, `/workbench/sessions/cnv_cross_project?projectId=${projectId}`); + await expect(page.locator(sessionTab("cnv_cross_project"))).toHaveCount(0); + await expect(page.locator(`${selectors.messageCard}[data-conversation-id="cnv_cross_project"]`)).toHaveCount(0); + await saveScreenshot(page, testInfo, "project-boundary-cross-detail"); + }); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts index be246490..f50bdd84 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts @@ -19,3 +19,24 @@ test("session switch persists active conversation after reload", async ({ page } await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible(); await saveScreenshot(page, testInfo, "session-switch-reload"); }); + +test.describe("empty session switch target", () => { + test.use({ scenarioId: "session-switch-empty-reload" }); + + test("switching from a filled session to an empty session updates URL and reload target", async ({ page }, testInfo) => { + await gotoWorkbench(page, "/workbench/sessions/cnv_completed?projectId=prj_hwpod_workbench"); + await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toBeVisible(); + + await page.locator(sessionTab("cnv_empty")).click(); + await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_empty(?:\?projectId=prj_hwpod_workbench)?$/u); + await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(selectors.messageCard)).toHaveCount(0); + await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_empty"); + + await page.reload(); + await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(selectors.messageCard)).toHaveCount(0); + await saveScreenshot(page, testInfo, "session-switch-empty-reload"); + }); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/status-consistency.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/status-consistency.spec.ts index 75184977..18698cdf 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/status-consistency.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/status-consistency.spec.ts @@ -21,3 +21,15 @@ test("session rail, messages, composer and trace status stay consistent", async await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("构建和 Trace 渲染检查完成"); await saveScreenshot(page, testInfo, "status-consistency-completed"); }); + +test.describe("terminal turn with stale active session authority", () => { + test.use({ scenarioId: "terminal-turn-stale-session-active" }); + + test("session rail shows terminal status instead of stale active", async ({ page }, testInfo) => { + await gotoWorkbench(page); + await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-status", "completed"); + await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-running", "false"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("构建和 Trace 渲染检查完成"); + await saveScreenshot(page, testInfo, "stale-active-session-terminal-tab"); + }); +});