diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 41c18b8f..93ec502e 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -100,7 +100,11 @@ test("cloud api /auth/oidc/login stores only same-origin relative returnTo", asy now: () => "2026-06-03T00:00:00.000Z" }); const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + }, accessController, now: () => "2026-06-03T00:00:00.000Z" }); @@ -754,6 +758,81 @@ test("workbench workspace read preserves a newer selected conversation without t } }); +test("workbench selected conversation projection follows conversation project authority (#1413)", async () => { + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + }, + now: () => "2026-06-17T12:40:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + try { + const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-issue1413", password: "alice-pass" }, adminLogin.cookie); + assert.equal(alice.status, 201); + const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-issue1413", password: "alice-pass" }); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); + assert.equal(workspace.status, 200); + + const oldConversation = await putJson(port, "/v1/agent/conversations/cnv_issue1413_old", { + projectId: "prj_hwpod_workbench", + sessionId: "ses_issue1413_old", + threadId: "thread-issue-1413-old", + sessionStatus: "running", + lastTraceId: "trc_issue1413_old_running", + messages: [{ role: "agent", text: "old turn running", status: "running", traceId: "trc_issue1413_old_running" }] + }, aliceLogin.cookie); + assert.equal(oldConversation.status, 200); + const targetConversation = await putJson(port, "/v1/agent/conversations/cnv_issue1413_target", { + projectId: "prj_v02_code_agent", + sessionId: "ses_issue1413_target", + threadId: "thread-issue-1413-target", + sessionStatus: "thread-resume-failed", + lastTraceId: "trc_issue1413_target_failed", + messages: [{ role: "agent", text: "target thread resume failed", status: "failed", traceId: "trc_issue1413_target_failed" }] + }, aliceLogin.cookie); + assert.equal(targetConversation.status, 200); + + const oldActive = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { + selectedConversationId: "cnv_issue1413_old", + selectedAgentSessionId: "ses_issue1413_old", + activeTraceId: "trc_issue1413_old_running", + lastTraceId: "trc_issue1413_old_running", + sessionStatus: "running", + updatedByClient: "test-suite" + }, aliceLogin.cookie); + assert.equal(oldActive.status, 200); + + const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { + projectId: "prj_hwpod_workbench", + conversationId: "cnv_issue1413_target", + sessionId: "ses_issue1413_target", + updatedByClient: "test-suite" + }, aliceLogin.cookie); + assert.equal(selected.status, 200); + assert.equal(selected.body.workspace.selectedConversationId, "cnv_issue1413_target"); + assert.equal(selected.body.workspace.selectedConversation.conversationId, "cnv_issue1413_target"); + assert.equal(selected.body.workspace.selectedConversation.projectId, "prj_v02_code_agent"); + assert.equal(selected.body.workspace.workspace.selectedConversationId, "cnv_issue1413_target"); + assert.equal(selected.body.workspace.workspace.lastTraceId, undefined); + assert.equal(selected.body.workspace.workspace.sessionStatus, "thread-resume-failed"); + + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); + assert.equal(restored.status, 200); + assert.equal(restored.body.workspace.selectedConversationId, "cnv_issue1413_target"); + assert.equal(restored.body.workspace.selectedConversation.conversationId, "cnv_issue1413_target"); + assert.equal(restored.body.workspace.selectedConversation.projectId, "prj_v02_code_agent"); + assert.equal(restored.body.workspace.workspace.selectedConversationId, "cnv_issue1413_target"); + assert.equal(restored.body.workspace.workspace.lastTraceId, undefined); + assert.equal(restored.body.workspace.workspace.sessionStatus, "thread-resume-failed"); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("manual Code Agent session select restores running active trace", async () => { const accessController = createAccessController({ env: { diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index e4da7988..cb94ad78 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1196,10 +1196,13 @@ class AccessController { }); } const selectedConversationId = textOr(body.selectedConversationId ?? body.conversationId, current.selectedConversationId ?? ""); - if (selectedConversationId) { - const visible = await this.visibleConversationForActor(auth.actor, selectedConversationId, body.projectId ?? current.projectId); - if (!visible) return sendJson(response, 403, errorPayload("workspace_conversation_forbidden", "Selected conversation is not visible to the current actor", 403)); - } + const selectedConversation = selectedConversationId + ? await this.visibleConversationForWorkspaceAuthority(auth.actor, selectedConversationId, body.projectId ?? current.projectId) + : null; + if (selectedConversationId && !selectedConversation) return sendJson(response, 403, errorPayload("workspace_conversation_forbidden", "Selected conversation is not visible to the current actor", 403)); + const workspacePatchBody = selectedConversation + ? { ...body, selectedConversationProjectId: selectedConversation.projectId } + : body; const workspace = await this.store.updateWorkspace?.({ workspaceId, ownerUserId: auth.actor.id, @@ -1211,7 +1214,7 @@ class AccessController { selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId) || current.selectedAgentSessionId, activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId) || null, providerProfile: textOr(body.providerProfile, current.providerProfile ?? ""), - patch: normalizeWorkspacePatch(body, auth.actor), + patch: normalizeWorkspacePatch(workspacePatchBody, auth.actor), updatedBySessionId: currentSessionIdFromAuth(auth), updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"), now: this.now() @@ -1229,7 +1232,7 @@ class AccessController { const body = await jsonBody(request); const conversationId = textOr(body.conversationId, "") || `cnv_${randomUUID()}`; if (!safeConversationIdLocal(conversationId)) return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400)); - const existing = await this.visibleConversationForActor(auth.actor, conversationId, body.projectId ?? current.projectId); + const existing = await this.visibleConversationForWorkspaceAuthority(auth.actor, conversationId, body.projectId ?? current.projectId); if (!existing && body.create !== true) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404)); const sessionId = safeAgentSessionId(body.sessionId ?? existing?.sessionId) || `ses_${conversationId.slice(4)}`; if (!existing) { @@ -1252,7 +1255,7 @@ class AccessController { selectedConversationId: conversationId, selectedAgentSessionId: sessionId, activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId) || null, - patch: normalizeWorkspacePatch({ ...body, selectedConversationId: conversationId, sessionId }, auth.actor), + patch: normalizeWorkspacePatch({ ...body, selectedConversationId: conversationId, selectedConversationProjectId: existing?.projectId ?? body.projectId ?? current.projectId, sessionId }, auth.actor), updatedBySessionId: currentSessionIdFromAuth(auth), updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"), now: this.now() @@ -1318,12 +1321,19 @@ class AccessController { } async publicWorkbenchWorkspace(workspace, actor) { + const selectedConversationProjectId = textOr(workspace?.workspace?.selectedConversationProjectId ?? workspace?.workspace?.projectId, workspace?.projectId ?? ""); const conversation = workspace?.selectedConversationId - ? await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId) + ? await this.visibleConversationForWorkspaceAuthority(actor, workspace.selectedConversationId, selectedConversationProjectId) : null; return publicWorkbenchWorkspace(workspace, { conversation }); } + async visibleConversationForWorkspaceAuthority(actor, conversationId, projectId = "", options = {}) { + const visible = await this.visibleConversationForActor(actor, conversationId, projectId, options); + if (visible || !textOr(projectId, "")) return visible; + return this.visibleConversationForActor(actor, conversationId, "", options); + } + async visibleConversationForActor(actor, conversationId, projectId = "", options = {}) { if (!safeConversationIdLocal(conversationId)) return null; const sessions = await this.store.listAgentSessionsForUser?.({ diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 94e85913..d29d21d9 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -144,7 +144,11 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) function createScenarioState(scenarioId: string): ScenarioState { const base = structuredClone(capture.scenario); const id = scenarioId || "baseline"; - const selectedConversationId = id === "deep-link" || id === "stale-nested-trace" ? "cnv_failed" : id === "stale-submit-restore" ? "cnv_running" : base.selectedConversationId; + const selectedConversationId = id === "deep-link" || id === "stale-nested-trace" + ? "cnv_failed" + : id === "deep-link-stale-workspace-authority" || id === "stale-submit-restore" + ? "cnv_running" + : base.selectedConversationId; const staleNestedTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null; return { scenarioId: id, diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 14fc791d..1cecb92a 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -230,7 +230,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { setActiveConversationSelection(normalized); const current = await ensureWorkspace(); if (!current) return false; - if (activeConversationId.value === normalized && messages.value.length > 0) return true; + if (activeConversationId.value === normalized && messages.value.length > 0) { + const conversation = activeConversation.value ?? visibleConversations.value.find((item) => item.conversationId === normalized) ?? null; + if (conversation) await persistSelectedConversation(conversation, conversationProjectId(conversation, activeProjectId.value)); + return true; + } const requestEpoch = beginWorkspaceSelection(); startWorkbenchSessionSwitch({ conversationId: normalized, source: "deeplink", targetState: "unknown", cache: "cold" }); switchingConversationId.value = normalized; diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts index 0a52f317..5219ed4f 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts @@ -11,3 +11,17 @@ test("deep link hydrates the same authority path as tab selection", async ({ pag await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_failed"); await saveScreenshot(page, testInfo, "deep-link-failed"); }); + +test.describe("deep link stale workspace authority", () => { + test.use({ scenarioId: "deep-link-stale-workspace-authority" }); + + test("deep link persists route conversation over stale workspace selection", async ({ page }) => { + await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench"); + await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖"); + await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_failed"); + const state = await fakeServerState(page); + const selectRequests = state.selectRequests as Array<{ conversationId?: unknown }>; + expect(selectRequests.some((request) => request.conversationId === "cnv_failed")).toBeTruthy(); + }); +});