diff --git a/skills/scripts/e2e/agent-runner-release-preflight.mjs b/skills/scripts/e2e/agent-runner-release-preflight.mjs index e383ca197..ac47f612c 100755 --- a/skills/scripts/e2e/agent-runner-release-preflight.mjs +++ b/skills/scripts/e2e/agent-runner-release-preflight.mjs @@ -6,6 +6,7 @@ import { resolve } from "node:path"; import { env } from "node:process"; import { createBrowser, + ensureBrowserWorkspace, ensureEvidence, evidencePaths, exitCode, @@ -124,6 +125,12 @@ async function run() { const { page } = browser; await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + const workspace = await ensureBrowserWorkspace(page, backendUrl); + if (workspace.status !== "pass") { + result.status = workspace.status; + result.reason = workspace.reason; + return; + } const diagnostic = await page.evaluate(async ({ backendUrl, targets, testModels }) => { const blockers = []; @@ -150,6 +157,7 @@ async function run() { const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", }; const getJson = async (path) => { const response = await fetch(`${backendUrl}${path}`, { headers }); diff --git a/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs b/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs index 86b157d2c..3e4698c26 100755 --- a/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs +++ b/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs @@ -78,6 +78,14 @@ try { provider: "claude-code", location: "remote-ssh", workspace: remoteWorkspace, + "env-json": JSON.stringify({ + ALL_PROXY: "", + all_proxy: "", + HTTP_PROXY: "", + http_proxy: "", + HTTPS_PROXY: "", + https_proxy: "", + }), "ssh-target": sshTarget, "ssh-port": Number.parseInt(sshPort, 10), "ssh-identity-file": sshIdentityFile, diff --git a/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs b/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs index f7c757a6d..8ee7ad6a4 100755 --- a/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs +++ b/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs @@ -4,6 +4,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { env } from "node:process"; import { + authenticatedApiHeaders, apiJson, ensureEvidence, evidencePaths, @@ -99,7 +100,15 @@ try { } result.store_task_id = store.json.data?.task_id || ""; - const ready = await waitForSentinel(backendUrl, auth.token, result.kb_uuid, query, expectedText, waitMs); + const ready = await waitForSentinel( + backendUrl, + auth.token, + result.kb_uuid, + query, + expectedText, + waitMs, + upload.fileId, + ); result.file_statuses = ready.fileStatuses; if (ready.matched) { result.checked_bases.push(ready.checked); @@ -209,9 +218,7 @@ async function uploadDocument(backendUrl, token, path) { form.append("file", new Blob([bytes], { type: "text/plain" }), "sentinel-doc.txt"); const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/files/documents`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, + headers: await authenticatedApiHeaders(backendUrl, token, { contentType: "" }), body: form, }); const json = await response.json().catch(() => ({})); @@ -222,13 +229,14 @@ async function uploadDocument(backendUrl, token, path) { return { fileId }; } -async function waitForSentinel(backendUrl, token, kbUuid, query, expectedText, timeoutMs) { +async function waitForSentinel(backendUrl, token, kbUuid, query, expectedText, timeoutMs, uploadedFileId) { const started = Date.now(); let fileStatuses = []; let lastChecked = null; while (Date.now() - started < timeoutMs) { const files = await apiJson(backendUrl, `/api/v1/knowledge/bases/${encodeURIComponent(kbUuid)}/files`, { token }); - fileStatuses = files.json.data?.files || fileStatuses; + const listedFiles = files.json.data?.files || []; + fileStatuses = listedFiles.filter((item) => item.file_name === uploadedFileId); lastChecked = await retrieveSentinel(backendUrl, token, kbUuid, kbName, query, expectedText); if (lastChecked.matched) { return { matched: true, fileStatuses, checked: lastChecked }; diff --git a/skills/scripts/e2e/install-qa-plugin-smoke.mjs b/skills/scripts/e2e/install-qa-plugin-smoke.mjs index 18c2b27d9..f88f3b6aa 100755 --- a/skills/scripts/e2e/install-qa-plugin-smoke.mjs +++ b/skills/scripts/e2e/install-qa-plugin-smoke.mjs @@ -4,9 +4,12 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { env } from "node:process"; import { + authenticatedApiHeaders, apiJson, ensureEvidence, evidencePaths, + isTaskComplete, + isTaskFailed, loadEnvFiles, resetAndAuthLocalUser, writeResult, @@ -138,7 +141,7 @@ async function installPlugin(backendUrl, token, bytes, packagePath) { form.set("file", new Blob([bytes]), packagePath.split("/").pop()); const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local`, { method: "POST", - headers: { Authorization: `Bearer ${token}` }, + headers: await authenticatedApiHeaders(backendUrl, token, { contentType: "" }), body: form, }); const json = await response.json().catch(() => ({})); @@ -173,7 +176,7 @@ async function previewPackage(backendUrl, token, bytes, packagePath) { form.set("file", new Blob([bytes]), packagePath.split("/").pop()); const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local/preview`, { method: "POST", - headers: { Authorization: `Bearer ${token}` }, + headers: await authenticatedApiHeaders(backendUrl, token, { contentType: "" }), body: form, }); const json = await response.json().catch(() => ({})); @@ -214,32 +217,12 @@ async function waitForTask(backendUrl, token, taskId) { while (Date.now() < deadline) { const response = await apiJson(backendUrl, `/api/v1/system/tasks/${encodeURIComponent(taskId)}`, { token }); last = response.json.data || response.json; - if (isTaskComplete(last) || isTaskFailed(last)) return last; + if (isTaskFailed(last) || isTaskComplete(last)) return last; await sleep(1000); } return last; } -function isTaskComplete(task) { - const status = String(task?.status || task?.state || "").toLowerCase(); - const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase(); - return ["done", "completed", "success", "succeeded", "finished"].includes(status) - || ["done", "completed", "success", "succeeded", "finished"].includes(runtimeStatus) - || task?.done === true - || task?.completed === true - || (task?.runtime?.done === true && !task?.runtime?.exception); -} - -function isTaskFailed(task) { - const status = String(task?.status || task?.state || "").toLowerCase(); - const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase(); - return ["failed", "error", "cancelled", "canceled"].includes(status) - || ["failed", "error", "cancelled", "canceled"].includes(runtimeStatus) - || task?.failed === true - || Boolean(task?.error) - || Boolean(task?.runtime?.exception); -} - function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/skills/scripts/e2e/langrag-kb-retrieve.mjs b/skills/scripts/e2e/langrag-kb-retrieve.mjs index a2b489a60..bc0778c5f 100755 --- a/skills/scripts/e2e/langrag-kb-retrieve.mjs +++ b/skills/scripts/e2e/langrag-kb-retrieve.mjs @@ -3,6 +3,7 @@ import { bodyText, createBrowser, + ensureBrowserWorkspace, ensureEvidence, evidencePaths, exitCode, @@ -57,6 +58,12 @@ try { const { page } = browser; await page.goto(`${frontendUrl.replace(/\/$/, "")}/home/knowledge`, { waitUntil: "domcontentloaded" }); await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + const workspace = await ensureBrowserWorkspace(page, backendUrl); + if (workspace.status !== "pass") { + result.status = workspace.status; + result.reason = workspace.reason; + throw new Error(result.reason); + } result.url = page.url(); const text = await bodyText(page); @@ -77,6 +84,7 @@ try { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", }, body: JSON.stringify({ query }), }); diff --git a/skills/scripts/e2e/lib/debug-chat.mjs b/skills/scripts/e2e/lib/debug-chat.mjs index 33163dfbd..d8717da9e 100755 --- a/skills/scripts/e2e/lib/debug-chat.mjs +++ b/skills/scripts/e2e/lib/debug-chat.mjs @@ -424,18 +424,33 @@ async function fetchDebugChatHistory(page, { backendUrl, pipelineId, sessionType } return await page.evaluate(async ({ backendUrl, pipelineId, sessionType }) => { const token = localStorage.getItem("token") || ""; - const response = await fetch( - `${backendUrl.replace(/\/$/, "")}/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/messages/${encodeURIComponent(sessionType)}`, - { headers: token ? { Authorization: `Bearer ${token}` } : {} }, - ); - const json = await response.json().catch(() => ({})); - return { - status: response.ok && json.code === 0 ? "ready" : "fail", - http_status: response.status, - code: json.code ?? null, - messages: json.data?.messages || [], - reason: response.ok && json.code === 0 ? "" : json.msg || `Debug Chat history returned HTTP ${response.status}.`, - }; + try { + const response = await fetch( + `${backendUrl.replace(/\/$/, "")}/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/messages/${encodeURIComponent(sessionType)}`, + { + headers: token + ? { + Authorization: `Bearer ${token}`, + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", + } + : {}, + }, + ); + const json = await response.json().catch(() => ({})); + return { + status: response.ok && json.code === 0 ? "ready" : "fail", + http_status: response.status, + code: json.code ?? null, + messages: json.data?.messages || [], + reason: response.ok && json.code === 0 ? "" : json.msg || `Debug Chat history returned HTTP ${response.status}.`, + }; + } catch (error) { + return { + status: "retryable", + messages: [], + reason: `Debug Chat history request failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } }, { backendUrl, pipelineId, sessionType }); } @@ -454,6 +469,10 @@ async function waitForFinalDebugChatAssistant(page, { while (Date.now() < deadline) { lastHistory = await fetchDebugChatHistory(page, { backendUrl, pipelineId, sessionType }); if (lastHistory.status === "fail") return lastHistory; + if (lastHistory.status === "retryable") { + await page.waitForTimeout(Math.min(250, Math.max(1, deadline - Date.now()))); + continue; + } const assistants = lastHistory.messages.filter((message) => message.role === "assistant"); const latest = assistants.at(-1); if (assistants.length > beforeAssistantCount && latest?.is_final === true) { @@ -480,12 +499,23 @@ export async function attachDebugChatImage(page, imagePath) { if (!await input.count()) { return { status: "fail", reason: "Could not find a Debug Chat image upload input." }; } + const previews = page.locator('[data-debug-chat-attachment-preview="true"]'); + const beforePreviewCount = await previews.count(); await input.setInputFiles(imagePath); - await page.locator("img").last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {}); + const previewVisible = await previews + .nth(beforePreviewCount) + .waitFor({ state: "visible", timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!previewVisible) { + return { status: "fail", reason: "Debug Chat did not show the selected image preview." }; + } return { status: "ready", reason: `Attached image fixture: ${imagePath}` }; } export async function sendDebugChatPrompt(page, prompt, imagePath = "") { + const sentImages = page.locator('[data-debug-chat-message-image="true"]'); + const beforeSentImageCount = imagePath ? await sentImages.count() : 0; const imageResult = await attachDebugChatImage(page, imagePath); if (imageResult.status === "fail") return imageResult; @@ -500,6 +530,16 @@ export async function sendDebugChatPrompt(page, prompt, imagePath = "") { const clickedSend = await clickFirstVisible(page, ["Send", "发送", "提交"], 1_500); if (!clickedSend) await page.keyboard.press("Enter"); await page.getByText(prompt, { exact: false }).last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {}); + if (imagePath) { + const sentImageVisible = await sentImages + .nth(beforeSentImageCount) + .waitFor({ state: "visible", timeout: 15_000 }) + .then(() => true) + .catch(() => false); + if (!sentImageVisible) { + return { status: "fail", reason: "The sent Debug Chat message did not render its image attachment." }; + } + } return true; } @@ -535,7 +575,7 @@ export async function runDebugChatPrompt(page, { } const responseStartedAt = Date.now(); - await waitForExpectedDebugChatText(page, { + const expectedTextPromise = waitForExpectedDebugChatText(page, { expectedText, expectedTexts: requiredExpectedTexts, minExpectedCount, @@ -545,13 +585,19 @@ export async function runDebugChatPrompt(page, { beforeText, failureSignals, }); - const finalAssistant = await waitForFinalDebugChatAssistant(page, { + const finalAssistantPromise = waitForFinalDebugChatAssistant(page, { backendUrl, pipelineId, sessionType, beforeAssistantCount: beforeHistoryAssistantCount, timeoutMs: Math.max(1, responseTimeoutMs - (Date.now() - responseStartedAt)), }); + if (backendUrl && pipelineId && sessionType) { + await Promise.race([expectedTextPromise, finalAssistantPromise]); + } else { + await expectedTextPromise; + } + const finalAssistant = await finalAssistantPromise; await waitForDebugChatTextStable(page); const afterText = await bodyText(page); diff --git a/skills/scripts/e2e/lib/langbot-e2e.mjs b/skills/scripts/e2e/lib/langbot-e2e.mjs index d3f1ba7bc..5d637a9a4 100755 --- a/skills/scripts/e2e/lib/langbot-e2e.mjs +++ b/skills/scripts/e2e/lib/langbot-e2e.mjs @@ -1,8 +1,11 @@ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { basename, join, resolve } from "node:path"; import { env } from "node:process"; const secretRe = /(?:authorization|bearer|token|secret|password|api[_-]?key|jwt|oauth)\s*[:=]\s*["']?[^"',\s]+/gi; +const ACTIVE_WORKSPACE_STORAGE_KEY = "langbot_active_workspace_uuid"; +const workspaceUuidCache = new Map(); export function redact(text) { return String(text ?? "") @@ -30,6 +33,19 @@ export function localIsoWithOffset(date = new Date()) { return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}.${ms}${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`; } +export function boxWorkspaceNamespace(instanceUuid, workspaceUuid) { + const instance = String(instanceUuid || "").trim(); + const workspace = String(workspaceUuid || "").trim(); + if (!instance || !workspace) { + throw new Error("Box Workspace namespace requires instance and Workspace UUIDs."); + } + const digest = createHash("sha256") + .update(`${instance}\0${workspace}`, "utf8") + .digest("hex") + .slice(0, 24); + return `ws-${digest}`; +} + export function evidencePaths(caseId) { const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join("reports", "evidence", runId)); @@ -103,6 +119,27 @@ export async function writeResult(paths, result) { } } +export function isTaskFailed(task) { + const status = String(task?.status || task?.state || "").toLowerCase(); + const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase(); + return ["failed", "error", "cancelled", "canceled"].includes(status) + || ["failed", "error", "cancelled", "canceled"].includes(runtimeStatus) + || task?.failed === true + || Boolean(task?.error) + || Boolean(task?.runtime?.exception); +} + +export function isTaskComplete(task) { + if (isTaskFailed(task)) return false; + const status = String(task?.status || task?.state || "").toLowerCase(); + const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase(); + return ["done", "completed", "success", "succeeded", "finished"].includes(status) + || ["done", "completed", "success", "succeeded", "finished"].includes(runtimeStatus) + || task?.done === true + || task?.completed === true + || task?.runtime?.done === true; +} + function browserDiagnosticFindings(source, text) { const findings = []; const lines = String(text || "").split(/\r?\n/); @@ -231,11 +268,98 @@ export async function readRecoveryKey(repo = env.LANGBOT_REPO || "") { return match?.[1] || ""; } -export async function apiJson(backendUrl, path, { method = "GET", token = "", body } = {}) { +function workspaceCacheKey(backendUrl, token) { + return `${backendUrl.replace(/\/$/, "")}\0${token}`; +} + +function isAccountScopedApi(path, method) { + const pathname = path.split("?", 1)[0]; + if (pathname === "/api/v1/workspaces/bootstrap") return true; + if (pathname === "/api/v1/workspaces" && method === "GET") return true; + return [ + "/api/v1/user/check-token", + "/api/v1/user/info", + "/api/v1/user/account-info", + "/api/v1/user/change-password", + ].includes(pathname); +} + +export async function resolveWorkspaceUuid( + backendUrl, + token, + preferredWorkspaceUuid = env.LANGBOT_WORKSPACE_UUID || "", +) { + if (!token) throw new Error("A user token is required to resolve the active Workspace."); + + const normalizedBackendUrl = backendUrl.replace(/\/$/, ""); + const normalizedPreferred = preferredWorkspaceUuid.trim(); + const cacheKey = workspaceCacheKey(normalizedBackendUrl, token); + const cached = workspaceUuidCache.get(cacheKey); + if (cached && (!normalizedPreferred || cached === normalizedPreferred)) return cached; + + const response = await fetch(`${normalizedBackendUrl}/api/v1/workspaces/bootstrap`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + const json = await response.json().catch(() => ({})); + if (response.status >= 400 || json.code !== 0) { + throw new Error(json.msg || `Workspace bootstrap failed with HTTP ${response.status}.`); + } + + const workspaces = json.data?.workspaces || []; + const workspaceUuids = workspaces + .map((entry) => entry.workspace?.uuid || entry.uuid || "") + .filter(Boolean); + let workspaceUuid = normalizedPreferred; + if (workspaceUuid && !workspaceUuids.includes(workspaceUuid)) { + throw new Error(`Configured Workspace ${workspaceUuid} is not available to the authenticated Account.`); + } + if (!workspaceUuid && workspaceUuids.length === 1) { + [workspaceUuid] = workspaceUuids; + } + if (!workspaceUuid && workspaceUuids.length === 0) { + throw new Error("The authenticated Account has no available Workspace."); + } + if (!workspaceUuid) { + throw new Error( + "The authenticated Account has multiple Workspaces; set LANGBOT_WORKSPACE_UUID for this QA run.", + ); + } + + workspaceUuidCache.set(cacheKey, workspaceUuid); + return workspaceUuid; +} + +export async function authenticatedApiHeaders( + backendUrl, + token, + { contentType = "application/json", workspaceUuid = "" } = {}, +) { + const resolvedWorkspaceUuid = await resolveWorkspaceUuid(backendUrl, token, workspaceUuid); + return { + Authorization: `Bearer ${token}`, + ...(contentType ? { "Content-Type": contentType } : {}), + "X-Workspace-Id": resolvedWorkspaceUuid, + }; +} + +export async function apiJson( + backendUrl, + path, + { method = "GET", token = "", body, skipWorkspace = false, workspaceUuid = "" } = {}, +) { + const normalizedMethod = method.toUpperCase(); const headers = { "Content-Type": "application/json" }; - if (token) headers.Authorization = `Bearer ${token}`; + if (token) { + headers.Authorization = `Bearer ${token}`; + if (!skipWorkspace && !isAccountScopedApi(path, normalizedMethod)) { + headers["X-Workspace-Id"] = await resolveWorkspaceUuid(backendUrl, token, workspaceUuid); + } + } const response = await fetch(`${backendUrl.replace(/\/$/, "")}${path}`, { - method, + method: normalizedMethod, headers, body: body === undefined ? undefined : JSON.stringify(body), }); @@ -301,6 +425,38 @@ export async function setBrowserToken(page, frontendUrl, token) { await page.evaluate((value) => localStorage.setItem("token", value), token); } +export async function ensureBrowserWorkspace( + page, + backendUrl, + preferredWorkspaceUuid = env.LANGBOT_WORKSPACE_UUID || "", +) { + const browserContext = await page.evaluate((storageKey) => ({ + token: localStorage.getItem("token") || "", + workspaceUuid: localStorage.getItem(storageKey) || "", + }), ACTIVE_WORKSPACE_STORAGE_KEY); + if (!browserContext.token) { + return { status: "blocked", reason: "Browser profile has no localStorage token.", workspace_uuid: "" }; + } + + try { + const workspaceUuid = await resolveWorkspaceUuid( + backendUrl, + browserContext.token, + preferredWorkspaceUuid || browserContext.workspaceUuid, + ); + await page.evaluate(({ storageKey, workspaceUuid: value }) => { + localStorage.setItem(storageKey, value); + }, { storageKey: ACTIVE_WORKSPACE_STORAGE_KEY, workspaceUuid }); + return { + status: "pass", + reason: "Browser Workspace selection is initialized.", + workspace_uuid: workspaceUuid, + }; + } catch (error) { + return { status: "blocked", reason: error.message, workspace_uuid: "" }; + } +} + export async function verifyBrowserToken(page, backendUrl) { return await page.evaluate(async (baseUrl) => { const token = localStorage.getItem("token"); @@ -349,11 +505,23 @@ export async function ensureAuthenticatedBrowser(page, { reason: error.message, })); if (current.authenticated) { + const workspace = await ensureBrowserWorkspace(page, backendUrl); + if (workspace.status !== "pass") { + return { + status: workspace.status, + reason: workspace.reason, + backend_token_check: null, + browser_token_check: current, + workspace, + injected: false, + }; + } return { status: "pass", reason: "Existing browser token is valid.", backend_token_check: null, browser_token_check: current, + workspace, injected: false, }; } @@ -381,11 +549,24 @@ export async function ensureAuthenticatedBrowser(page, { }; } + const workspace = await ensureBrowserWorkspace(page, backendUrl); + if (workspace.status !== "pass") { + return { + status: workspace.status, + reason: workspace.reason, + backend_token_check: auth.check, + browser_token_check: browserCheck, + workspace, + injected: true, + }; + } + return { status: "pass", reason: "Browser token injected from local recovery login.", backend_token_check: auth.check, browser_token_check: browserCheck, + workspace, injected: true, }; } diff --git a/skills/scripts/e2e/local-agent-steering-debug-chat.mjs b/skills/scripts/e2e/local-agent-steering-debug-chat.mjs index fb8225d51..56d389d21 100755 --- a/skills/scripts/e2e/local-agent-steering-debug-chat.mjs +++ b/skills/scripts/e2e/local-agent-steering-debug-chat.mjs @@ -161,7 +161,8 @@ try { result.status = resetDiagnostic.status; result.reason = resetDiagnostic.reason || "Debug Chat reset failed."; } else { - await page.waitForTimeout(1000); + await page.reload({ waitUntil: "commit" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); const reopenResult = await openPipelineDebugChat(page, { pipelineUrl, pipelineName, @@ -406,6 +407,7 @@ async function inspectPipeline(page, { backendUrl, pipelineUrl, pipelineName, ex headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", }, }); return { @@ -508,6 +510,7 @@ async function inspectToolNames(page, { backendUrl }) { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", }, }); const json = await response.json().catch(() => ({})); @@ -545,6 +548,7 @@ async function resetPipelineDebugChat(page, { backendUrl, pipelineId, sessionTyp headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", }, }, ); diff --git a/skills/scripts/e2e/mcp-stdio-fixture.mjs b/skills/scripts/e2e/mcp-stdio-fixture.mjs index 894101301..eaf432604 100755 --- a/skills/scripts/e2e/mcp-stdio-fixture.mjs +++ b/skills/scripts/e2e/mcp-stdio-fixture.mjs @@ -35,22 +35,15 @@ await ensureEvidence(paths); const startedAt = new Date(); const fixturePath = resolve(env.LANGBOT_MCP_FIXTURE_PATH || "skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py"); const langbotRepo = env.LANGBOT_REPO ? resolve(env.LANGBOT_REPO) : ""; -const uvCandidates = [ - env.LANGBOT_MCP_FIXTURE_UV, - "uv", -].filter(Boolean); -const uv = uvCandidates.find((candidate) => candidate === "uv" || existsSync(candidate)); const pythonCandidates = [ env.LANGBOT_MCP_FIXTURE_PYTHON, langbotRepo ? `${langbotRepo}/.venv/bin/python` : "", "python3", ].filter(Boolean); const python = pythonCandidates.find((candidate) => candidate === "python3" || existsSync(candidate)); -const command = langbotRepo && uv - ? { executable: uv, args: ["run", "python", fixturePath], cwd: langbotRepo, mode: "uv" } - : python - ? { executable: python, args: [fixturePath], cwd: resolve("."), mode: "python" } - : null; +const command = python + ? { executable: python, args: [fixturePath], cwd: resolve("."), mode: "python" } + : null; const expectedText = "qa_mcp_echo:mcp-stdio-fixture-ok"; const result = { @@ -94,7 +87,7 @@ async function request(child, id, method, params) { async function run() { if (!command) { result.status = "env_issue"; - result.reason = "No uv or Python interpreter found. Set LANGBOT_REPO, LANGBOT_MCP_FIXTURE_UV, or LANGBOT_MCP_FIXTURE_PYTHON."; + result.reason = "No Python interpreter found. Set LANGBOT_REPO or LANGBOT_MCP_FIXTURE_PYTHON."; return; } if (!existsSync(fixturePath)) { diff --git a/skills/scripts/e2e/mcp-stdio-register.mjs b/skills/scripts/e2e/mcp-stdio-register.mjs index 017b14305..aa6a53838 100755 --- a/skills/scripts/e2e/mcp-stdio-register.mjs +++ b/skills/scripts/e2e/mcp-stdio-register.mjs @@ -6,6 +6,7 @@ import { resolve } from "node:path"; import { env } from "node:process"; import { createBrowser, + ensureBrowserWorkspace, ensureEvidence, evidencePaths, exitCode, @@ -91,6 +92,12 @@ async function run() { const { page } = browser; await page.goto(env.LANGBOT_FRONTEND_URL, { waitUntil: "domcontentloaded" }); await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + const workspace = await ensureBrowserWorkspace(page, backendUrl); + if (workspace.status !== "pass") { + result.status = workspace.status; + result.reason = workspace.reason; + return; + } const diagnostic = await page.evaluate(async ({ backendUrl, @@ -101,6 +108,7 @@ async function run() { fixtureArgs, startupTimeoutSec, readyTimeoutMs, + workspaceUuid, }) => { const token = localStorage.getItem("token"); if (!token) { @@ -120,6 +128,7 @@ async function run() { const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": workspaceUuid, }; const serverConfig = { name: serverName, @@ -193,7 +202,17 @@ async function run() { runtime_tool_count: lastRuntime?.tool_count ?? null, runtime_error: lastRuntime?.error_message || "", }; - }, { backendUrl, serverName, expectedTool, fixturePath, fixtureCommand, fixtureArgs, startupTimeoutSec, readyTimeoutMs }); + }, { + backendUrl, + serverName, + expectedTool, + fixturePath, + fixtureCommand, + fixtureArgs, + startupTimeoutSec, + readyTimeoutMs, + workspaceUuid: workspace.workspace_uuid, + }); await writeFile(apiDiagnosticPath, `${JSON.stringify(diagnostic, null, 2)}\n`, "utf8"); await safeScreenshot(page, paths.screenshot); diff --git a/skills/scripts/e2e/pipeline-debug-chat.mjs b/skills/scripts/e2e/pipeline-debug-chat.mjs index 1001ac2b8..6e6b3cca6 100755 --- a/skills/scripts/e2e/pipeline-debug-chat.mjs +++ b/skills/scripts/e2e/pipeline-debug-chat.mjs @@ -401,6 +401,7 @@ async function inspectAndPatchPipelineConfig(page, { const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", }; const getJson = async (path) => { const response = await fetch(`${backendUrl}${path}`, { headers }); @@ -607,6 +608,7 @@ async function restorePipelineConfig(page, { backendUrl, pipelineId, config }) { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", }, body: JSON.stringify({ config }), }); @@ -645,6 +647,7 @@ async function inspectAndPatchPipelineExtensions(page, { const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", }; const getJson = async (path) => { const response = await fetch(`${backendUrl}${path}`, { headers }); @@ -800,6 +803,7 @@ async function restorePipelineExtensions(page, { backendUrl, pipelineId, extensi headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", }, body: JSON.stringify(extensions), }); @@ -836,6 +840,7 @@ async function resetPipelineDebugChat(page, { backendUrl, pipelineId, sessionTyp headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", }, }, ); @@ -965,7 +970,8 @@ try { result.status = resetDiagnostic.status; result.reason = resetDiagnostic.reason || "Debug Chat reset failed."; } else { - await page.waitForTimeout(1000); + await page.reload({ waitUntil: "commit" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); const reopenResult = await openPipelineDebugChat(page, { pipelineUrl, pipelineName, diff --git a/skills/scripts/e2e/reset-complex-agent-task.mjs b/skills/scripts/e2e/reset-complex-agent-task.mjs index 5d90da860..65f17d18d 100644 --- a/skills/scripts/e2e/reset-complex-agent-task.mjs +++ b/skills/scripts/e2e/reset-complex-agent-task.mjs @@ -1,17 +1,23 @@ #!/usr/bin/env node -import { cp, mkdir, rm } from "node:fs/promises"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { env } from "node:process"; import { spawnSync } from "node:child_process"; import { + apiJson, + boxWorkspaceNamespace, ensureEvidence, evidencePaths, loadEnvFiles, + resetAndAuthLocalUser, + resolveWorkspaceUuid, writeResult, } from "./lib/langbot-e2e.mjs"; +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; + await loadEnvFiles(); const paths = evidencePaths("reset-complex-agent-task"); await ensureEvidence(paths); @@ -22,7 +28,7 @@ const source = resolve( "../../skills/langbot-testing/fixtures/complex-agent-task/workspace", ); const repo = env.LANGBOT_REPO || ""; -const target = repo ? resolve(repo, "data/box/default/order-orchestrator") : ""; +let target = ""; const result = { source: "setup_automation", case_id: "reset-complex-agent-task", @@ -36,6 +42,32 @@ const result = { try { if (!repo) throw new Error("LANGBOT_REPO is required."); + const backendUrl = env.LANGBOT_BACKEND_URL || ""; + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is required."); + if (!user) throw new Error("LANGBOT_E2E_LOGIN_USER is required."); + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + const workspaceUuid = await resolveWorkspaceUuid(backendUrl, auth.token); + const bootstrap = await apiJson(backendUrl, "/api/v1/workspaces/bootstrap", { + token: auth.token, + skipWorkspace: true, + }); + const workspaceAccess = (bootstrap.json.data?.workspaces || []).find( + (entry) => (entry.workspace?.uuid || entry.uuid) === workspaceUuid, + ); + const instanceUuid = workspaceAccess?.workspace?.instance_uuid || workspaceAccess?.instance_uuid || ""; + if (!instanceUuid) throw new Error("Workspace bootstrap did not include instance_uuid."); + + const boxWorkspace = env.LANGBOT_BOX_WORKSPACE_HOST_PATH || resolve( + repo, + "data/box/default/tenants", + boxWorkspaceNamespace(instanceUuid, workspaceUuid), + ); + target = resolve(boxWorkspace, "order-orchestrator"); + result.target = target; + result.workspace_uuid = workspaceUuid; await rm(target, { recursive: true, force: true }); await mkdir(dirname(target), { recursive: true }); await cp(source, target, { recursive: true }); @@ -50,6 +82,10 @@ try { if (baseline.error) throw baseline.error; if (baseline.status === 0) throw new Error("Complex task baseline unexpectedly passes; the fixture must start failing."); + await upsertEnvLocal(resolve("skills/.env.local"), { + LANGBOT_COMPLEX_AGENT_WORKSPACE: target, + }); + result.status = "pass"; result.reason = "Complex task workspace reset and failing baseline confirmed."; } catch (error) { @@ -60,3 +96,20 @@ try { await writeResult(paths, result); console.log(JSON.stringify(result, null, 2)); process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +async function upsertEnvLocal(path, updates) { + let content = ""; + try { + content = await readFile(path, "utf8"); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + const lines = content ? content.split(/\r?\n/) : []; + for (const [key, value] of Object.entries(updates)) { + const replacement = `${key}=${value}`; + const index = lines.findIndex((line) => line.startsWith(`${key}=`)); + if (index >= 0) lines[index] = replacement; + else lines.push(replacement); + } + await writeFile(path, `${lines.filter(Boolean).join("\n")}\n`, "utf8"); +} diff --git a/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs b/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs index 1c6c9b4a7..de4932091 100644 --- a/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs +++ b/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs @@ -81,6 +81,7 @@ async function api(page, path, options = {}) { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", }, body: options.body === undefined ? undefined : JSON.stringify(options.body), diff --git a/skills/skills.index.json b/skills/skills.index.json index b14f77397..d8290a6e8 100644 --- a/skills/skills.index.json +++ b/skills/skills.index.json @@ -156,6 +156,8 @@ "agent-runner-runtime-chaos", "bot-event-routing-product-flow", "box-mcp-heartbeat-recovery", + "claude-code-agent-debug-chat", + "codex-agent-debug-chat", "dify-agent-debug-chat", "langbot-fake-provider-debug-chat-cross-pipeline-isolation", "langbot-fake-provider-debug-chat-fault-recovery", @@ -601,6 +603,58 @@ "filesystem" ] }, + { + "id": "claude-code-agent-debug-chat", + "title": "Claude Code Agent can retrieve LangBot history through MCP", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p2", + "risk": "high", + "ci_eligible": false, + "tags": [ + "agent-runner", + "claude-code", + "external-runner", + "mcp", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, + { + "id": "codex-agent-debug-chat", + "title": "Codex Agent can retrieve LangBot history through MCP", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p2", + "risk": "high", + "ci_eligible": false, + "tags": [ + "agent-runner", + "codex", + "external-runner", + "mcp", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, { "id": "dify-agent-debug-chat", "title": "Dify AgentRunner returns a response through Pipeline Debug Chat", @@ -1097,7 +1151,8 @@ ], "setup_provides_env": [ "LANGBOT_LOCAL_AGENT_PIPELINE_URL", - "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_COMPLEX_AGENT_WORKSPACE" ], "evidence_required": [ "ui", diff --git a/skills/skills/langbot-testing/cases/claude-code-agent-debug-chat.yaml b/skills/skills/langbot-testing/cases/claude-code-agent-debug-chat.yaml new file mode 100644 index 000000000..d695229f9 --- /dev/null +++ b/skills/skills/langbot-testing/cases/claude-code-agent-debug-chat.yaml @@ -0,0 +1,72 @@ +id: claude-code-agent-debug-chat +title: "Claude Code Agent can retrieve LangBot history through MCP" +mode: agent-browser +area: pipeline +type: regression +priority: p2 +risk: high +ci_eligible: false +tags: + - agent-runner + - claude-code + - external-runner + - mcp + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +env_any: + - LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_URL|LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_URL + - LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot-team/ClaudeCodeAgent/default" +automation_reset_debug_chat: "1" +automation_prompts_json: '[{"prompt":"Remember this exact passcode for the LangBot conversation: claude-history-proof-6384. Do not repeat the passcode. Reply exactly CLAUDE_HISTORY_SEEDED with no other text.","expected_text":"CLAUDE_HISTORY_SEEDED","response_timeout_ms":"600000"},{"prompt":"This CLI turn has no reused Claude session. Recover the passcode from LangBot history by calling mcp__langbot_agent__langbot_history_page exactly once. If ToolSearch is available here, first load the tool with select:mcp__langbot_agent__langbot_history_page and call it directly. If this is a coordinator session that exposes only delegation tools, delegate to exactly one worker and require that worker to perform the same ToolSearch-then-call flow, then wait for its result. Use only the returned LangBot history. If the MCP call fails, do not guess or claim success. Reply with only the recovered passcode.","expected_text":"claude-history-proof-6384","response_timeout_ms":"600000"}]' +automation_expected_text: "claude-history-proof-6384" +automation_response_timeout_ms: "600000" +preconditions: + - "Claude Code CLI is installed, authenticated, and runnable non-interactively by the LangBot plugin process." + - "The configured workspace exists and is writable by the Claude Code CLI." + - "The pipeline has reuse-session=false so the second turn cannot use Claude CLI session memory." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Open the Claude Code Agent QA pipeline." + - "Confirm the pipeline runner is plugin:langbot-team/ClaudeCodeAgent/default." + - "Open Debug Chat, seed the passcode, then ask a fresh Claude CLI turn to recover it through mcp__langbot_agent__langbot_history_page." +checks: + - "UI: The first Bot message contains CLAUDE_HISTORY_SEEDED and the second contains claude-history-proof-6384." + - "Runner evidence: The second run records a real LangBot history MCP tool call in the main session or one delegated worker, not only model text." + - "Logs: The request completes without claude_code process, MCP, approval, or runtime errors." + - "Console: No unexpected frontend errors appear during Debug Chat." +evidence_required: + - ui + - screenshot + - console + - backend_log +success_patterns: + - "CLAUDE_HISTORY_SEEDED" + - "claude-history-proof-6384" + - "Streaming completed" +failure_patterns: + - "claude_code.command_not_found" + - "claude_code.process_exited" + - "not available among the connected MCP tools" + - "not accessible in this session" + - "No such tool available" + - "Agent runner not found" + - "Agent runner plugin:langbot-team/ClaudeCodeAgent/default execution failed" +troubleshooting: + - backend-not-listening + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/codex-agent-debug-chat.yaml b/skills/skills/langbot-testing/cases/codex-agent-debug-chat.yaml new file mode 100644 index 000000000..856580062 --- /dev/null +++ b/skills/skills/langbot-testing/cases/codex-agent-debug-chat.yaml @@ -0,0 +1,71 @@ +id: codex-agent-debug-chat +title: "Codex Agent can retrieve LangBot history through MCP" +mode: agent-browser +area: pipeline +type: regression +priority: p2 +risk: high +ci_eligible: false +tags: + - agent-runner + - codex + - external-runner + - mcp + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +env_any: + - LANGBOT_CODEX_AGENT_PIPELINE_URL|LANGBOT_CODEX_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_CODEX_AGENT_PIPELINE_URL + - LANGBOT_CODEX_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_CODEX_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_CODEX_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot-team/CodexAgent/default" +automation_reset_debug_chat: "1" +automation_prompts_json: '[{"prompt":"Do not launch a subagent. Remember this exact passcode for the LangBot conversation: codex-history-proof-9157. Do not repeat the passcode. Reply exactly CODEX_HISTORY_SEEDED with no other text.","expected_text":"CODEX_HISTORY_SEEDED","response_timeout_ms":"600000"},{"prompt":"Do not launch a subagent. This CLI turn has no reused Codex session. Directly call the langbot_history_page tool exposed by the langbot_agent MCP server exactly once and wait for its result. Use only the returned LangBot history to recover the passcode from the previous user turn. If the tool is unavailable, do not guess or claim success. Reply with only the recovered passcode.","expected_text":"codex-history-proof-9157","response_timeout_ms":"600000"}]' +automation_expected_text: "codex-history-proof-9157" +automation_response_timeout_ms: "600000" +preconditions: + - "Codex CLI is installed, authenticated, and runnable non-interactively by the LangBot plugin process." + - "The configured workspace exists and is writable, with approval policy never and sandbox mode appropriate for the QA host." + - "The pipeline has reuse-session=false so the second turn cannot use Codex CLI thread memory." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Open the Codex Agent QA pipeline." + - "Confirm the pipeline runner is plugin:langbot-team/CodexAgent/default." + - "Open Debug Chat, seed the passcode, then ask a fresh Codex CLI turn to recover it through langbot_agent.langbot_history_page." +checks: + - "UI: The first Bot message contains CODEX_HISTORY_SEEDED and the second contains codex-history-proof-9157." + - "Runner evidence: The second run records a real LangBot history MCP tool call, not only model text." + - "Logs: The request completes without codex process, MCP, approval, sandbox, or runtime errors." + - "Console: No unexpected frontend errors appear during Debug Chat." +evidence_required: + - ui + - screenshot + - console + - backend_log +success_patterns: + - "CODEX_HISTORY_SEEDED" + - "codex-history-proof-9157" + - "Streaming completed" +failure_patterns: + - "codex.command_not_found" + - "codex.process_exited" + - "not available among the connected MCP tools" + - "not accessible in this session" + - "Agent runner not found" + - "Agent runner plugin:langbot-team/CodexAgent/default execution failed" +troubleshooting: + - backend-not-listening + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/local-agent-complex-coding-task-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-complex-coding-task-debug-chat.yaml index 0bed19c51..7e9581131 100644 --- a/skills/skills/langbot-testing/cases/local-agent-complex-coding-task-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-complex-coding-task-debug-chat.yaml @@ -44,15 +44,16 @@ automation_prompt: "Work autonomously on the project at /workspace/order-orchest automation_expected_text: "COMPLEX_AGENT_TASK_OK tests=12 acceptance=PASS" automation_response_timeout_ms: "900000" automation_stream_output: "1" -automation_filesystem_checks_json: '[{"argv":["python3","skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py","--workspace","${LANGBOT_REPO}/data/box/default/order-orchestrator"],"cwd":"${LANGBOT_REPO}","stdout_contains":"HOST_VERIFY_PASS tests=12 acceptance=PASS protected=PASS","exit_code":0,"timeout_ms":120000}]' +automation_filesystem_checks_json: '[{"argv":["python3","skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py","--workspace","${LANGBOT_COMPLEX_AGENT_WORKSPACE}"],"cwd":"${LANGBOT_REPO}","stdout_contains":"HOST_VERIFY_PASS tests=12 acceptance=PASS protected=PASS","exit_code":0,"timeout_ms":120000}]' setup_automation: - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" - "node:scripts/e2e/reset-complex-agent-task.mjs" setup_provides_env: - LANGBOT_LOCAL_AGENT_PIPELINE_URL - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_COMPLEX_AGENT_WORKSPACE preconditions: - - "The local Box workspace is mounted at LANGBOT_REPO/data/box/default and exposed to native tools as /workspace." + - "The active tenant Box workspace is available on the host and exposed to native tools as /workspace." - "The selected real model supports sustained function calling across file inspection, edits, and test reruns." steps: - "Reset the isolated order-orchestrator workspace and confirm its baseline test suite fails." diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg index aba6ead2d..fc7568d14 100644 Binary files a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg and b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg differ diff --git a/skills/test/lbs-cli.test.ts b/skills/test/lbs-cli.test.ts index ba28d241a..1afab7342 100644 --- a/skills/test/lbs-cli.test.ts +++ b/skills/test/lbs-cli.test.ts @@ -59,16 +59,56 @@ import { waitForDebugChatReady, } from "../scripts/e2e/lib/debug-chat.mjs"; import { + apiJson, beginBackendLogCapture, + boxWorkspaceNamespace, clickFirstVisible, ensureAuthenticatedBrowser, finishBackendLogCapture, + isTaskComplete, + isTaskFailed, resolveLangBotRepo, scanBrowserDiagnostics, } from "../scripts/e2e/lib/langbot-e2e.mjs"; const root = process.cwd(); +test("Box workspace namespace matches the SDK tenancy contract", () => { + assert.equal( + boxWorkspaceNamespace( + "instance_8e83c14e-db31-4746-97f4-2dc165136a18", + "29907f84-54a8-4da9-903f-04d84ff8793a", + ), + "ws-8410680088facf21327b7542", + ); +}); + +test("e2e task helpers reject finished tasks with runtime exceptions", () => { + const task = { + runtime: { + done: true, + state: "FINISHED", + exception: "ValueError: plugin not found", + }, + }; + + assert.equal(isTaskFailed(task), true); + assert.equal(isTaskComplete(task), false); +}); + +test("e2e task helpers accept successful finished tasks", () => { + const task = { + runtime: { + done: true, + state: "FINISHED", + exception: null, + }, + }; + + assert.equal(isTaskFailed(task), false); + assert.equal(isTaskComplete(task), true); +}); + test("clickFirstVisible waits for a later visible DOM match", async () => { let pollCount = 0; let clickedIndex = -1; @@ -218,19 +258,36 @@ test("e2e helper can inject a local login token into a fresh browser context", a { status: auth === "Bearer fresh-token" ? 200 : 401 }, ); } + if (url.endsWith("/api/v1/workspaces/bootstrap")) { + return new Response( + JSON.stringify({ + code: 0, + data: { workspaces: [{ workspace: { uuid: "workspace-test" } }] }, + }), + { status: 200 }, + ); + } return new Response(JSON.stringify({ code: -1, msg: "unexpected" }), { status: 404, }); }) as typeof fetch; let token = ""; + let workspaceUuid = ""; const page = { addInitScript: async () => {}, goto: async () => {}, - evaluate: async (fn: unknown, arg: string) => { + evaluate: async (fn: unknown, arg: unknown) => { const text = String(fn); + if (text.includes("workspaceUuid: localStorage.getItem")) { + return { token, workspaceUuid }; + } + if (text.includes("workspaceUuid: value")) { + workspaceUuid = (arg as { workspaceUuid: string }).workspaceUuid; + return undefined; + } if (text.includes("localStorage.setItem")) { - token = arg; + token = String(arg); return undefined; } if (text.includes("localStorage.getItem")) { @@ -263,6 +320,7 @@ test("e2e helper can inject a local login token into a fresh browser context", a assert.equal(result.status, "pass"); assert.equal(result.injected, true); assert.equal(token, "fresh-token"); + assert.equal(workspaceUuid, "workspace-test"); } finally { globalThis.fetch = previousFetch; if (previousRepo === undefined) delete process.env.LANGBOT_REPO; @@ -271,6 +329,43 @@ test("e2e helper can inject a local login token into a fresh browser context", a } }); +test("apiJson bootstraps and sends the selected Workspace for scoped APIs", async () => { + const previousFetch = globalThis.fetch; + const requests: Array<{ url: string; headers: Record }> = []; + try { + globalThis.fetch = (async (url: string, init?: RequestInit) => { + const headers = (init?.headers || {}) as Record; + requests.push({ url, headers }); + if (url.endsWith("/api/v1/workspaces/bootstrap")) { + return new Response( + JSON.stringify({ + code: 0, + data: { workspaces: [{ workspace: { uuid: "workspace-api-test" } }] }, + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ code: 0, data: { tools: [] } }), { + status: 200, + }); + }) as typeof fetch; + + const response = await apiJson( + "http://127.0.0.1:5300", + "/api/v1/tools", + { token: "workspace-api-token" }, + ); + + assert.equal(response.status, 200); + assert.equal(requests.length, 2); + assert.equal(requests[0].url, "http://127.0.0.1:5300/api/v1/workspaces/bootstrap"); + assert.equal(requests[0].headers["X-Workspace-Id"], undefined); + assert.equal(requests[1].headers["X-Workspace-Id"], "workspace-api-test"); + } finally { + globalThis.fetch = previousFetch; + } +}); + function ctx(args: string[]): CommandContext { return { root, args }; } diff --git a/src/langbot/pkg/agent/runner/registry.py b/src/langbot/pkg/agent/runner/registry.py index 497cdf537..1a98187d3 100644 --- a/src/langbot/pkg/agent/runner/registry.py +++ b/src/langbot/pkg/agent/runner/registry.py @@ -235,6 +235,15 @@ class AgentRunnerRegistry: runners = await self.list_runners(context, bound_plugins=None) descriptor = next((item for item in runners if item.id == runner_id), None) + if descriptor is None: + # The runtime launches installed plugins asynchronously, so an + # early non-empty discovery can still be only a partial snapshot. + runners = await self.list_runners( + context, + bound_plugins=None, + use_cache=False, + ) + descriptor = next((item for item in runners if item.id == runner_id), None) if descriptor is None: raise RunnerNotFoundError(runner_id) @@ -255,7 +264,11 @@ class AgentRunnerRegistry: Returns runner options and their config schemas for the DynamicForm. """ # Get all runners (no bound plugin filter for metadata listing) - runners = await self.list_runners(context, bound_plugins=None) + runners = await self.list_runners( + context, + bound_plugins=None, + use_cache=False, + ) options = [] stages = [] diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index ae3ef7b1a..9b9ee5993 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -559,6 +559,11 @@ class BoxService: namespace = box_namespace(self._action_context(context)) return os.path.join(self.default_workspace, 'tenants', namespace) + def workspace_host_path(self, context: TenantContext) -> str | None: + """Return the host path mounted as /workspace for one execution context.""" + + return self._tenant_workspace(context) + async def execute_spec_payload( self, spec_payload: dict, diff --git a/src/langbot/pkg/persistence/alembic/versions/0017_repair_local_workspace_owner.py b/src/langbot/pkg/persistence/alembic/versions/0017_repair_local_workspace_owner.py new file mode 100644 index 000000000..c6d5ed36c --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0017_repair_local_workspace_owner.py @@ -0,0 +1,149 @@ +"""repair an ownerless local Workspace after tenancy migration + +Revision ID: 0017_local_owner_repair +Revises: 0016_agent_workspace +Create Date: 2026-07-31 +""" + +from __future__ import annotations + +import datetime +import uuid + +import sqlalchemy as sa +from alembic import op + + +revision = '0017_local_owner_repair' +down_revision = '0016_agent_workspace' +branch_labels = None +depends_on = None + + +def _table_names(conn: sa.Connection) -> set[str]: + return set(sa.inspect(conn).get_table_names()) + + +def upgrade() -> None: + conn = op.get_bind() + required = {'metadata', 'users', 'workspaces', 'workspace_memberships'} + if not required.issubset(_table_names(conn)): + return + + metadata = sa.table( + 'metadata', + sa.column('key', sa.String(255)), + sa.column('value', sa.String(255)), + ) + instance_uuid = conn.execute( + sa.select(metadata.c.value).where(metadata.c.key == 'instance_uuid') + ).scalar_one_or_none() + if not isinstance(instance_uuid, str) or not instance_uuid.strip(): + return + + workspaces = sa.table( + 'workspaces', + sa.column('uuid', sa.String(36)), + sa.column('instance_uuid', sa.String(255)), + sa.column('source', sa.String(32)), + sa.column('created_by_account_uuid', sa.String(36)), + ) + workspace_uuids = conn.execute( + sa.select(workspaces.c.uuid).where( + workspaces.c.instance_uuid == instance_uuid.strip(), + workspaces.c.source == 'local', + ) + ).scalars().all() + if not workspace_uuids: + return + if len(workspace_uuids) > 1: + raise RuntimeError(f'Multiple local Workspaces exist for instance {instance_uuid!r}') + workspace_uuid = workspace_uuids[0] + + if conn.dialect.name == 'postgresql': + conn.execute( + sa.text("SELECT set_config('langbot.workspace_uuid', :workspace_uuid, true)"), + {'workspace_uuid': workspace_uuid}, + ) + + memberships = sa.table( + 'workspace_memberships', + sa.column('uuid', sa.String(36)), + sa.column('workspace_uuid', sa.String(36)), + sa.column('account_uuid', sa.String(36)), + sa.column('role', sa.String(32)), + sa.column('status', sa.String(32)), + sa.column('joined_at', sa.DateTime()), + sa.column('projection_revision', sa.BigInteger()), + ) + active_owner = conn.execute( + sa.select(memberships.c.account_uuid).where( + memberships.c.workspace_uuid == workspace_uuid, + memberships.c.role == 'owner', + memberships.c.status == 'active', + ) + ).scalar_one_or_none() + if active_owner is not None: + conn.execute( + workspaces.update() + .where(workspaces.c.uuid == workspace_uuid) + .where(workspaces.c.created_by_account_uuid.is_(None)) + .values(created_by_account_uuid=active_owner) + ) + return + + users = sa.table( + 'users', + sa.column('id', sa.Integer()), + sa.column('uuid', sa.String(36)), + sa.column('status', sa.String(32)), + ) + owner_account_uuid = conn.execute( + sa.select(users.c.uuid) + .where(users.c.status == 'active') + .order_by(users.c.id) + .limit(1) + ).scalar_one_or_none() + if owner_account_uuid is None: + return + + now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) + membership = conn.execute( + sa.select(memberships.c.uuid, memberships.c.joined_at).where( + memberships.c.workspace_uuid == workspace_uuid, + memberships.c.account_uuid == owner_account_uuid, + ) + ).first() + if membership is None: + conn.execute( + memberships.insert().values( + uuid=str(uuid.uuid4()), + workspace_uuid=workspace_uuid, + account_uuid=owner_account_uuid, + role='owner', + status='active', + joined_at=now, + projection_revision=0, + ) + ) + else: + conn.execute( + memberships.update() + .where(memberships.c.uuid == membership.uuid) + .values( + role='owner', + status='active', + joined_at=membership.joined_at or now, + ) + ) + conn.execute( + workspaces.update() + .where(workspaces.c.uuid == workspace_uuid) + .values(created_by_account_uuid=owner_account_uuid) + ) + + +def downgrade() -> None: + # The repair restores a required invariant; downgrading the revision should + # not deliberately recreate an ownerless Workspace. + pass diff --git a/src/langbot/pkg/pipeline/controller.py b/src/langbot/pkg/pipeline/controller.py index 3ef5888bf..cda0a8478 100644 --- a/src/langbot/pkg/pipeline/controller.py +++ b/src/langbot/pkg/pipeline/controller.py @@ -180,9 +180,7 @@ class Controller: ) self.ap.query_pool.condition.notify_all() continue - if selected_query: # 找到了 - queries.remove(selected_query) - else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限 + if selected_query is None: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限 await self.ap.query_pool.condition.wait() continue diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py index 6c438d711..73740787d 100644 --- a/src/langbot/pkg/platform/sources/websocket_adapter.py +++ b/src/langbot/pkg/platform/sources/websocket_adapter.py @@ -537,7 +537,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) Image / Voice / File components uploaded from the web client carry a storage key in ``path``. Resolve it to a base64 data URI so downstream stages (multimodal LLM input and the Box sandbox inbox) have a usable - payload, then drop the now-consumed storage object. + payload. Keep the storage key for browser history; the configured + storage-retention cleanup removes expired uploads. Args: message_chain_obj: 消息链对象列表 @@ -592,12 +593,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream' component['base64'] = f'data:{mime_type};base64,{base64_str}' - await storage_mgr.delete_scoped_object_key( - execution_context, - comp_path, - expected_owner_type='upload_image', - ) - component['path'] = '' except Exception as e: await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}') raise diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 1b51ce54c..29a62a255 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -994,7 +994,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): self._connected.clear() runtime_handler = getattr(self, 'handler', None) if runtime_handler is not None: - with contextlib.suppress(Exception): + with contextlib.suppress(Exception, asyncio.CancelledError): await runtime_handler.close() if getattr(self, 'handler', None) is runtime_handler: del self.handler @@ -1015,7 +1015,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): del self.handler_task close_ctrl = getattr(getattr(self, 'ctrl', None), 'close', None) if close_ctrl is not None: - with contextlib.suppress(Exception): + with contextlib.suppress(Exception, asyncio.CancelledError): await close_ctrl() async def aclose(self) -> None: @@ -1717,6 +1717,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): except Exception: await self._delete_artifact_if_unreferenced(execution_context, artifact_digest) raise + if not previous_was_durable and self.runtime_profile == 'oss_dev': + bridge = self._legacy_oss_bridge_binding(execution_context) + try: + with runtime_handler.installation_scope(bridge): + async for _ in runtime_handler.delete_plugin(plugin_author, plugin_name): + pass + except Exception as exc: + self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}') runtime_handler.register_installation_binding( binding, plugin_author=plugin_author, @@ -1731,14 +1739,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): self._workspace_installations.setdefault(binding.workspace_uuid, set()).add(binding.installation_uuid) if previous_digest is not None and previous_digest != artifact_digest: await self._delete_artifact_if_unreferenced(execution_context, previous_digest) - if previous_digest is not None and not previous_was_durable and self.runtime_profile == 'oss_dev': - bridge = self._legacy_oss_bridge_binding(execution_context) - try: - with runtime_handler.installation_scope(bridge): - async for _ in runtime_handler.delete_plugin(plugin_author, plugin_name): - pass - except Exception as exc: - self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}') await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context) await self._refresh_agent_runner_registry() @@ -2213,7 +2213,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): ) if not isinstance(workspace_id, str) or not workspace_id.strip(): raise ValueError('AgentRunner execution requires a Workspace') - await self.require_workspace_context(workspace_id) + execution_context = await self._current_execution_context() + if workspace_id.strip() != execution_context.workspace_uuid: + raise WorkspaceNotFoundError('Plugin resource not found') + await self.require_workspace_context(execution_context) binding = await self._target_binding( plugin_author, plugin_name, diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 9097dbcc8..456cc9e5a 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -474,6 +474,7 @@ _RUNTIME_SCOPED_ACTIONS = frozenset( RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value, } ) +_OUTBOUND_INSTALLATION_CONTEXT_UNSET = object() class RuntimeConnectionHandler(handler.Handler): @@ -873,10 +874,12 @@ class RuntimeConnectionHandler(handler.Handler): ): super().__init__(connection, disconnect_callback) self.ap = ap - self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = ( + self._outbound_installation_context: contextvars.ContextVar[ + InstallationBinding | None | object + ] = ( contextvars.ContextVar( f'{self.__class__.__name__}_{id(self)}_outbound_installation', - default=None, + default=_OUTBOUND_INSTALLATION_CONTEXT_UNSET, ) ) self._installation_bindings: dict[ @@ -2075,15 +2078,27 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM) async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse: - action_context, _ = await self._require_plugin_action_context() + action_context, identity = await self._require_plugin_action_context() execution_context = self._execution_context(action_context) + installation_binding = InstallationBinding( + instance_uuid=action_context.instance_uuid, + workspace_uuid=action_context.workspace_uuid, + placement_generation=action_context.placement_generation, + installation_uuid=identity.installation_uuid, + runtime_revision=identity.runtime_revision, + artifact_digest=identity.artifact_digest, + ) storage_path = data['storage_path'] try: content_bytes = await self.ap.rag_runtime_service.get_file_stream( execution_context, storage_path, ) - file_key = await self.send_file(content_bytes, '') + file_key = await self.send_file( + content_bytes, + '', + action_context=installation_binding, + ) return handler.ActionResponse.success(data={'file_key': file_key}) except Exception as e: return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path) @@ -2427,10 +2442,13 @@ class RuntimeConnectionHandler(handler.Handler): ) -> InstallationBinding | ActionContext | None: if action_context is not None: return super().resolve_outbound_action_context(action_context) + scoped_context = self._outbound_installation_context.get() + if scoped_context is not _OUTBOUND_INSTALLATION_CONTEXT_UNSET: + return typing.cast(InstallationBinding | None, scoped_context) inbound_context = self.current_action_context if inbound_context is not None: return inbound_context - return self._outbound_installation_context.get() + return None def require_outbound_installation_context(self) -> InstallationBinding: binding = self._outbound_installation_context.get() diff --git a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py index 23499a316..3c123f39f 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py @@ -487,10 +487,15 @@ class BoxStdioSessionRuntime: ) def _shared_workspace_host_path(self) -> str: - default_workspace = getattr(self.ap.box_service, 'default_workspace', None) - if not default_workspace: - raise RuntimeError('Box default workspace is required for shared MCP host_path staging') - shared_host_path = normalize_host_path(default_workspace) + workspace_host_path = getattr(self.ap.box_service, 'workspace_host_path', None) + if callable(workspace_host_path): + shared_workspace = workspace_host_path(self.owner.execution_context) + else: + # Compatibility for older BoxService embedders used by plugins and tests. + shared_workspace = getattr(self.ap.box_service, 'default_workspace', None) + if not shared_workspace: + raise RuntimeError('Box Workspace host path is required for shared MCP host_path staging') + shared_host_path = normalize_host_path(shared_workspace) os.makedirs(shared_host_path, exist_ok=True) return shared_host_path diff --git a/tests/integration/persistence/test_workspace_migration.py b/tests/integration/persistence/test_workspace_migration.py index 4fc56eba5..12b42b50f 100644 --- a/tests/integration/persistence/test_workspace_migration.py +++ b/tests/integration/persistence/test_workspace_migration.py @@ -149,6 +149,35 @@ async def test_workspace_upgrade_is_idempotent_and_preserves_identifiers(legacy_ assert workspace_uuid_after == workspace_uuid_before +async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(legacy_engine): + await run_alembic_upgrade(legacy_engine, '0016_agent_workspace') + async with legacy_engine.begin() as conn: + owner_account_uuid = await conn.scalar(sa.text('SELECT uuid FROM users ORDER BY id LIMIT 1')) + workspace_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) + await conn.execute(sa.text('DELETE FROM workspace_memberships')) + await conn.execute( + sa.text('UPDATE workspaces SET created_by_account_uuid = NULL WHERE uuid = :workspace_uuid'), + {'workspace_uuid': workspace_uuid}, + ) + + await run_alembic_upgrade(legacy_engine, 'head') + + async with legacy_engine.connect() as conn: + workspace = ( + await conn.execute( + sa.text('SELECT created_by_account_uuid FROM workspaces WHERE uuid = :workspace_uuid'), + {'workspace_uuid': workspace_uuid}, + ) + ).mappings().one() + membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one() + + assert workspace['created_by_account_uuid'] == owner_account_uuid + assert membership['workspace_uuid'] == workspace_uuid + assert membership['account_uuid'] == owner_account_uuid + assert membership['role'] == 'owner' + assert membership['status'] == 'active' + + async def test_workspace_kernel_upgrade_downgrade_upgrade_round_trip(tmp_path): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}') try: diff --git a/tests/unit_tests/agent/test_registry.py b/tests/unit_tests/agent/test_registry.py index 7cd9eb541..9e1de5276 100644 --- a/tests/unit_tests/agent/test_registry.py +++ b/tests/unit_tests/agent/test_registry.py @@ -2,6 +2,8 @@ from __future__ import annotations +from unittest.mock import AsyncMock + import pytest from langbot.pkg.agent.runner.registry import AgentRunnerRegistry @@ -210,6 +212,27 @@ class TestRegistryGet: assert exc_info.value.runner_id == 'plugin:notexist/unknown/default' + @pytest.mark.asyncio + async def test_get_refreshes_partial_startup_cache_on_miss(self): + """A runner initialized after early discovery should become available.""" + ap = FakeApplication() + ap.plugin_connector.list_agent_runners = AsyncMock( + side_effect=ap.plugin_connector.list_agent_runners, + ) + registry = AgentRunnerRegistry(ap) + + await registry.list_runners(TEST_CONTEXT) + cache = registry._cache[('instance-test', 'workspace-test', 1)] + cache.pop('plugin:alice/my-agent/custom') + + descriptor = await registry.get( + TEST_CONTEXT, + 'plugin:alice/my-agent/custom', + ) + + assert descriptor.id == 'plugin:alice/my-agent/custom' + assert ap.plugin_connector.list_agent_runners.await_count == 2 + @pytest.mark.asyncio async def test_get_runner_with_bound_plugins_filter(self): """Get runner with bound plugins authorization.""" @@ -257,6 +280,27 @@ class TestRegistryMetadataForPipeline: assert stages[0]['config'][0]['type'] == 'string' assert stages[0]['config'][0]['id'] == 'plugin:alice/my-agent/custom.param1' + @pytest.mark.asyncio + async def test_metadata_refreshes_partial_startup_cache(self): + """Pipeline metadata should not preserve an early partial discovery.""" + ap = FakeApplication() + ap.plugin_connector.list_agent_runners = AsyncMock( + side_effect=ap.plugin_connector.list_agent_runners, + ) + registry = AgentRunnerRegistry(ap) + + await registry.list_runners(TEST_CONTEXT) + cache = registry._cache[('instance-test', 'workspace-test', 1)] + cache.pop('plugin:alice/my-agent/custom') + + options, _ = await registry.get_runner_metadata_for_pipeline(TEST_CONTEXT) + + assert {item['name'] for item in options} == { + 'plugin:langbot-team/LocalAgent/default', + 'plugin:alice/my-agent/custom', + } + assert ap.plugin_connector.list_agent_runners.await_count == 2 + class TestDescriptorValidation: """Tests for descriptor validation.""" diff --git a/tests/unit_tests/pipeline/test_controller.py b/tests/unit_tests/pipeline/test_controller.py index 3ce22f7ac..b0b10a4d6 100644 --- a/tests/unit_tests/pipeline/test_controller.py +++ b/tests/unit_tests/pipeline/test_controller.py @@ -1,13 +1,15 @@ from __future__ import annotations +import asyncio from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from langbot.pkg.api.http.context import ExecutionContext from langbot.pkg.agent.runner.errors import RunnerNotFoundError from langbot.pkg.pipeline.controller import Controller +from langbot.pkg.pipeline.pool import QueryPool def make_app(): @@ -77,3 +79,74 @@ async def test_try_claim_steering_sets_pipeline_context_before_claiming(): assert query.pipeline_config is pipeline.pipeline_entity.config assert query.variables['_pipeline_bound_plugins'] == ['test/runner'] app.agent_run_orchestrator.try_claim_steering_from_query.assert_awaited_once_with(query) + + +@pytest.mark.asyncio +async def test_consumer_transfers_query_from_queue_to_running_task(): + app = make_app() + app.query_pool = QueryPool() + session = SimpleNamespace(_semaphore=asyncio.Semaphore(1)) + app.sess_mgr.get_session = AsyncMock(return_value=session) + app.persistence_mgr = SimpleNamespace(mode=SimpleNamespace(value='oss')) + app.workspace_service = SimpleNamespace( + get_execution_binding=AsyncMock( + return_value=SimpleNamespace( + instance_uuid='instance-test', + workspace_uuid='workspace-test', + placement_generation=1, + ) + ) + ) + runtime_pipeline = SimpleNamespace(run=AsyncMock()) + app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=runtime_pipeline) + + worker_tasks = [] + task_created = asyncio.Event() + + def create_task(coro, **_kwargs): + task = asyncio.create_task(coro) + worker_tasks.append(task) + task_created.set() + return task + + app.task_mgr = SimpleNamespace(create_task=create_task) + + query = Mock() + query.query_id = 0 + query.bot_uuid = 'bot-test' + query.pipeline_uuid = 'pipeline-test' + context = ExecutionContext( + instance_uuid='instance-test', + workspace_uuid='workspace-test', + placement_generation=1, + ) + with patch('langbot.pkg.pipeline.pool.pipeline_query.Query', return_value=query): + query = await app.query_pool.add_query( + bot_uuid='bot-test', + launcher_type=Mock(), + launcher_id='launcher-test', + sender_id='sender-test', + message_event=Mock(), + message_chain=Mock(), + adapter=Mock(), + pipeline_uuid='pipeline-test', + execution_context=context, + ) + + controller = Controller(app) + consumer_task = asyncio.create_task(controller.consumer()) + try: + await asyncio.wait_for(task_created.wait(), timeout=1) + await asyncio.wait_for(worker_tasks[0], timeout=1) + finally: + consumer_task.cancel() + with pytest.raises(asyncio.CancelledError): + await consumer_task + + runtime_pipeline.run.assert_awaited_once_with(query) + assert app.query_pool.queries == [] + assert app.query_pool.cached_queries == {} + assert app.query_pool.active_query_count_by_workspace == {} + assert session._semaphore._value == 1 + assert controller.semaphore._value == 10 + app.logger.error.assert_not_called() diff --git a/tests/unit_tests/platform/test_websocket_adapter_attachments.py b/tests/unit_tests/platform/test_websocket_adapter_attachments.py index 35a6bb644..10ffefe70 100644 --- a/tests/unit_tests/platform/test_websocket_adapter_attachments.py +++ b/tests/unit_tests/platform/test_websocket_adapter_attachments.py @@ -2,9 +2,9 @@ The web debug client uploads Image / Voice / File components carrying a storage key in ``path``. This helper resolves each to a base64 data URI (so multimodal -LLM input and the Box sandbox inbox have usable bytes), then deletes the -consumed storage object and clears ``path``. Covers mimetype selection per -type and fail-closed error handling. +LLM input and the Box sandbox inbox have usable bytes) while retaining the key +for browser history. Covers mimetype selection per type and fail-closed error +handling. """ from __future__ import annotations @@ -52,7 +52,7 @@ def _make_adapter(load_return=b'hello', load_side_effect=None): @pytest.mark.asyncio -async def test_image_jpeg_mimetype_and_cleanup(): +async def test_image_jpeg_mimetype_and_retained_storage_key(): adapter, storage_mgr, _ = _make_adapter(load_return=b'\xff\xd8\xff') path = f'{_UPLOAD_PREFIX}photo.jpg' chain = [{'type': 'Image', 'path': path}] @@ -61,12 +61,24 @@ async def test_image_jpeg_mimetype_and_cleanup(): expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8') assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}' - assert chain[0]['path'] == '' # consumed - storage_mgr.delete_scoped_object_key.assert_awaited_once_with( - _CONTEXT, - path, - expected_owner_type='upload_image', - ) + assert chain[0]['path'] == path + storage_mgr.delete_scoped_object_key.assert_not_awaited() + + +def test_history_retains_storage_key_without_large_base64_payload(): + path = f'{_UPLOAD_PREFIX}photo.jpg' + chain = [ + { + 'type': 'Image', + 'path': path, + 'base64': 'data:image/jpeg;base64,large-payload', + } + ] + + history = WebSocketAdapter._history_message_chain(chain) + + assert history == [{'type': 'Image', 'path': path, 'base64': ''}] + assert chain[0]['base64'] == 'data:image/jpeg;base64,large-payload' @pytest.mark.asyncio diff --git a/tests/unit_tests/platform/test_websocket_session_isolation.py b/tests/unit_tests/platform/test_websocket_session_isolation.py index 63d45396a..31d1750e2 100644 --- a/tests/unit_tests/platform/test_websocket_session_isolation.py +++ b/tests/unit_tests/platform/test_websocket_session_isolation.py @@ -425,7 +425,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope(): await adapter._process_image_components(connection, message_chain) assert message_chain[0]['base64'].startswith('data:image/png;base64,') - assert message_chain[0]['path'] == '' + assert message_chain[0]['path'] == 'v1/current/upload_image/key.png' storage_mgr.scoped_prefix.assert_called_once_with( connection.execution_context, owner_type='upload_image', @@ -439,11 +439,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope(): 'v1/current/upload_image/key.png', expected_owner_type='upload_image', ) - storage_mgr.delete_scoped_object_key.assert_awaited_once_with( - connection.execution_context, - 'v1/current/upload_image/key.png', - expected_owner_type='upload_image', - ) + storage_mgr.delete_scoped_object_key.assert_not_awaited() with pytest.raises(ValueError, match='does not belong'): await adapter._process_image_components( diff --git a/tests/unit_tests/plugin/test_connector_methods.py b/tests/unit_tests/plugin/test_connector_methods.py index c5a2ffe15..eb97d548f 100644 --- a/tests/unit_tests/plugin/test_connector_methods.py +++ b/tests/unit_tests/plugin/test_connector_methods.py @@ -20,6 +20,7 @@ from tests.factories import text_query from langbot_plugin.entities.io.context import InstallationBinding from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.workspace.errors import WorkspaceNotFoundError TEST_EXECUTION_CONTEXT = ExecutionContext( @@ -76,6 +77,63 @@ def configure_handler(connector, runtime_handler): return runtime_handler +async def _collect_agent_results(connector, context): + return [ + result + async for result in connector.run_agent( + 'qa', + 'agent-runner', + 'default', + context, + ) + ] + + +class TestRunAgent: + @pytest.mark.asyncio + async def test_revalidates_trusted_execution_context(self): + connector = create_mock_connector() + connector._current_execution_context = AsyncMock( + return_value=TEST_EXECUTION_CONTEXT + ) + + class RuntimeHandler: + installation_scope = Mock( + side_effect=lambda _binding: nullcontext() + ) + + async def run_agent(self, *_args): + yield {'type': 'run.completed'} + + configure_handler(connector, RuntimeHandler()) + + results = await _collect_agent_results( + connector, + {'conversation': {'workspace_id': TEST_EXECUTION_CONTEXT.workspace_uuid}}, + ) + + assert results == [{'type': 'run.completed'}] + connector.require_workspace_context.assert_awaited_once_with( + TEST_EXECUTION_CONTEXT + ) + + @pytest.mark.asyncio + async def test_rejects_payload_workspace_mismatch(self): + connector = create_mock_connector() + connector._current_execution_context = AsyncMock( + return_value=TEST_EXECUTION_CONTEXT + ) + configure_handler(connector, AsyncMock()) + + with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'): + await _collect_agent_results( + connector, + {'conversation': {'workspace_id': 'workspace-other'}}, + ) + + connector.require_workspace_context.assert_not_awaited() + + class TestListPlugins: """Tests for list_plugins method.""" diff --git a/tests/unit_tests/plugin/test_connector_ping.py b/tests/unit_tests/plugin/test_connector_ping.py index b398840c7..2c43b0154 100644 --- a/tests/unit_tests/plugin/test_connector_ping.py +++ b/tests/unit_tests/plugin/test_connector_ping.py @@ -72,6 +72,16 @@ async def test_stop_transport_tolerates_handler_callback_removing_attribute(): assert not hasattr(connector, 'handler') +@pytest.mark.asyncio +async def test_stop_transport_tolerates_cancelled_controller_close(): + connector = make_connector() + connector.ctrl = SimpleNamespace(close=AsyncMock(side_effect=asyncio.CancelledError)) + + await connector._stop_transport() + + connector.ctrl.close.assert_awaited_once_with() + + @pytest.mark.asyncio async def test_stdio_runtime_connection_does_not_capture_unconsumed_stderr( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py index b22e8e13e..2b774e9bc 100644 --- a/tests/unit_tests/plugin/test_connector_reconcile.py +++ b/tests/unit_tests/plugin/test_connector_reconcile.py @@ -249,6 +249,57 @@ async def test_local_install_persists_verified_package_before_runtime_apply(): ) +@pytest.mark.asyncio +async def test_local_install_cleans_untracked_legacy_plugin_before_runtime_apply(): + package = b'local-lbpkg-bytes' + digest = hashlib.sha256(package).hexdigest() + execution_context = ExecutionContext( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, + ) + binding = InstallationBinding( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, + installation_uuid='00000000-0000-4000-8000-000000000001', + runtime_revision=1, + artifact_digest=digest, + ) + app = SimpleNamespace( + instance_config=SimpleNamespace(data={'plugin': {'enable': True}}), + deployment=SimpleNamespace(mode='oss'), + logger=Mock(), + ) + connector = PluginRuntimeConnector(app, AsyncMock()) + connector.handler = runtime_handler() + connector._current_execution_context = AsyncMock(return_value=execution_context) + connector._inspect_plugin_package = Mock(return_value=('author', 'plugin')) + connector._store_artifact_package = AsyncMock() + connector._persist_installation_package = AsyncMock(return_value=(binding, None, False)) + connector._wait_for_installed_plugin_ready = AsyncMock() + events: list[str] = [] + + async def delete_legacy_plugin(plugin_author: str, plugin_name: str): + assert (plugin_author, plugin_name) == ('author', 'plugin') + events.append('cleanup') + yield {'current_action': 'plugin deleted'} + + async def apply_installation(*_args, **_kwargs): + events.append('apply') + return {'state': 'starting'} + + connector.handler.delete_plugin = delete_legacy_plugin + connector.handler.apply_plugin_installation = AsyncMock(side_effect=apply_installation) + + await connector.install_plugin( + PluginInstallSource.LOCAL, + {'plugin_file': package}, + ) + + assert events == ['cleanup', 'apply'] + + @pytest.mark.asyncio @pytest.mark.parametrize(('remaining_references', 'statement_count'), [(1, 1), (0, 2)]) async def test_artifact_cleanup_is_reference_counted_within_workspace( diff --git a/tests/unit_tests/plugin/test_handler_tenancy.py b/tests/unit_tests/plugin/test_handler_tenancy.py index 777cef7d8..9b9cbf475 100644 --- a/tests/unit_tests/plugin/test_handler_tenancy.py +++ b/tests/unit_tests/plugin/test_handler_tenancy.py @@ -313,6 +313,21 @@ def test_runtime_connection_is_instance_scoped_and_unbound(): assert runtime_handler.bound_action_context is None +def test_explicit_installation_scope_overrides_nested_inbound_context(): + runtime_handler, _app, target_binding = make_handler() + caller_context = workspace_context().for_installation('legacy-caller') + token = runtime_handler._current_action_context.set(caller_context) + try: + assert runtime_handler.resolve_outbound_action_context(None) == caller_context + with runtime_handler.installation_scope(target_binding): + assert runtime_handler.resolve_outbound_action_context(None) == target_binding + with runtime_handler.installation_scope(None): + assert runtime_handler.resolve_outbound_action_context(None) is None + assert runtime_handler.resolve_outbound_action_context(None) == caller_context + finally: + runtime_handler._current_action_context.reset(token) + + def test_inbound_tenant_action_requires_complete_installation_envelope(): runtime_handler, _app, installation_context = make_handler() @@ -363,6 +378,44 @@ async def test_legacy_oss_worker_capability_remains_usable_after_identity_migrat assert response.code == 0 +@pytest.mark.asyncio +async def test_legacy_oss_knowledge_file_reply_uses_complete_installation_binding(): + runtime_handler, app, installation_context = make_handler() + app.deployment = SimpleNamespace(mode='oss') + setting = SimpleNamespace( + plugin_author='author-a', + plugin_name='plugin-a', + installation_uuid=installation_context.installation_uuid, + runtime_revision=installation_context.runtime_revision, + artifact_digest=installation_context.artifact_digest, + ) + result = Mock() + result.first.return_value = setting + app.persistence_mgr.execute_async.return_value = result + app.rag_runtime_service = SimpleNamespace( + get_file_stream=AsyncMock(return_value=b'knowledge-file'), + ) + runtime_handler.send_file = AsyncMock(return_value='knowledge-file-key') + legacy_context = workspace_context().for_installation( + installation_context.installation_uuid + ) + + response = await invoke_with_context( + runtime_handler, + legacy_context, + PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM, + {'storage_path': 'knowledge/file.txt'}, + ) + + assert response.code == 0 + assert response.data == {'file_key': 'knowledge-file-key'} + runtime_handler.send_file.assert_awaited_once_with( + b'knowledge-file', + '', + action_context=installation_context, + ) + + def test_installation_uuid_cannot_move_between_workspaces(): runtime_handler, _app, binding = make_handler() moved = binding.model_copy(update={'workspace_uuid': 'workspace-b', 'runtime_revision': 2}) diff --git a/tests/unit_tests/provider/test_mcp_box_integration.py b/tests/unit_tests/provider/test_mcp_box_integration.py index 6ca60858e..bc17c201c 100644 --- a/tests/unit_tests/provider/test_mcp_box_integration.py +++ b/tests/unit_tests/provider/test_mcp_box_integration.py @@ -1004,7 +1004,9 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo ap = _make_ap() ap.box_service.available = True + shared_workspace = tmp_path / 'shared-box-workspace' / 'tenants' / 'workspace-a' ap.box_service.default_workspace = str(tmp_path / 'shared-box-workspace') + ap.box_service.workspace_host_path = Mock(return_value=str(shared_workspace)) ap.box_service.create_session = AsyncMock(return_value={}) ap.box_service.build_spec = Mock(return_value='validated-spec') ap.box_service.client = SimpleNamespace( @@ -1053,8 +1055,9 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo assert ap.box_service.build_spec.call_args.kwargs.get('skip_host_mount_validation', False) is False assert ap.box_service.build_spec.call_args.args[0]['host_path'] == str(host_path) - staged_file = tmp_path / 'shared-box-workspace' / '.mcp' / 'u1' / 'workspace' / 'server.py' + staged_file = shared_workspace / '.mcp' / 'u1' / 'workspace' / 'server.py' assert staged_file.read_text(encoding='utf-8') == 'print("hello")\n' + ap.box_service.workspace_host_path.assert_called_with(session.execution_context) assert ap.box_service.start_managed_process.await_args.args[0] == session.execution_context process_payload = ap.box_service.start_managed_process.await_args.args[2] diff --git a/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx b/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx index b45e87dd1..ff3d7b39d 100644 --- a/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx +++ b/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx @@ -49,6 +49,68 @@ interface DebugDialogProps { onConnectionStatusChange?: (isConnected: boolean) => void; } +function AuthenticatedMessageImage({ + image, + onOpen, +}: { + image: Image; + onOpen: (imageUrl: string) => void; +}) { + const [downloadedUrl, setDownloadedUrl] = useState(''); + const directUrl = + image.url || + (image.base64 + ? image.base64.startsWith('data:') + ? image.base64 + : `data:image/jpeg;base64,${image.base64}` + : ''); + + useEffect(() => { + if (directUrl || !image.path) return; + + let disposed = false; + let objectUrl = ''; + const encodedPath = image.path + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); + + void httpClient + .downloadFile(`/api/v1/files/image/${encodedPath}`) + .then((response) => { + objectUrl = URL.createObjectURL(response.data); + if (disposed) { + URL.revokeObjectURL(objectUrl); + return; + } + setDownloadedUrl(objectUrl); + }) + .catch((error) => { + console.error('Failed to load Debug Chat image:', error); + }); + + return () => { + disposed = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [directUrl, image.path]); + + const imageUrl = directUrl || downloadedUrl; + if (!imageUrl) return null; + + return ( +
+ Image onOpen(imageUrl)} + /> +
+ ); +} + export default function DebugDialog({ open, pipelineId, @@ -477,22 +539,15 @@ export default function DebugDialog({ case 'Image': { const img = component as Image; - const imageUrl = img.url || (img.base64 ? img.base64 : ''); - - if (!imageUrl) return null; - return ( -
- Image { - setPreviewImageUrl(imageUrl); - setShowImagePreview(true); - }} - /> -
+ { + setPreviewImageUrl(imageUrl); + setShowImagePreview(true); + }} + /> ); } @@ -907,6 +962,7 @@ export default function DebugDialog({ {`preview-${index}`} ) : (