#!/usr/bin/env node import { spawn } from "node:child_process"; import { open, readFile, mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { env } from "node:process"; import { apiJson, ensureEvidence, evidencePaths, loadEnvFiles, redact, resetAndAuthLocalUser, writeResult, } from "./lib/langbot-e2e.mjs"; const RUNNER_ID = "local-agent"; const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; const DEFAULT_PIPELINE_NAME = "LangBot QA Fake Provider Debug Chat"; const DEFAULT_PROVIDER_NAME = "LangBot QA Fake OpenAI Provider"; const QA_RESOURCE_DESCRIPTION = "Managed by LangBot skills QA automation for controlled fake-provider Debug Chat tests. Safe to delete when local QA fixtures are no longer needed."; const DEFAULT_MODEL_NAME = "gpt-4o-mini"; const DEFAULT_REQUESTER = "openai-chat-completions"; const caseId = "ensure-fake-provider-pipeline"; await loadEnvFiles(); const paths = evidencePaths(caseId); await ensureEvidence(paths); const writeEnv = process.argv.includes("--write-env"); const frontendUrl = env.LANGBOT_FRONTEND_URL || ""; const backendUrl = env.LANGBOT_BACKEND_URL || ""; const envLocalPath = resolve("skills/.env.local"); const repoRoot = resolve(env.LANGBOT_REPO || ".."); const fakeStateDir = resolve(env.LANGBOT_FAKE_PROVIDER_STATE_DIR || resolve(repoRoot, ".qa/fake-provider")); const fakeStatePath = resolve(fakeStateDir, "state.json"); const fakeStdoutPath = resolve(fakeStateDir, "fake-provider.stdout.log"); const fakeStderrPath = resolve(fakeStateDir, "fake-provider.stderr.log"); const pipelineName = env.LANGBOT_FAKE_PROVIDER_PIPELINE_NAME || DEFAULT_PIPELINE_NAME; const providerName = env.LANGBOT_FAKE_PROVIDER_NAME || DEFAULT_PROVIDER_NAME; const requester = env.LANGBOT_FAKE_PROVIDER_REQUESTER || DEFAULT_REQUESTER; const modelName = env.LANGBOT_FAKE_PROVIDER_MODEL_NAME || DEFAULT_MODEL_NAME; const result = { source: "automation", case_id: caseId, run_id: paths.runId, status: "fail", reason: "", frontend_url: frontendUrl, backend_url: backendUrl, fake_provider: { url: "", base_url: "", pid: null, reused: false, config: {}, state_file: fakeStatePath, stdout_log: fakeStdoutPath, stderr_log: fakeStderrPath, }, provider: { uuid: "", name: providerName, requester, created: false, updated: false, }, model: { uuid: "", name: modelName, created: false, updated: false, test_status: "not_run", test_reason: "", }, pipeline_id: "", pipeline_name: pipelineName, pipeline_url: "", created: false, updated: false, wrote_env: false, evidence: { console_log: paths.consoleLog, network_log: paths.networkLog, automation_result_json: paths.automationResultJson, result_json: paths.resultJson, }, evidence_collected: ["api_diagnostic", "network", "filesystem"], }; try { console.error(`[langbot-qa] configuring QA-owned fake-provider fixtures: provider=\"${providerName}\", pipeline=\"${pipelineName}\"`); console.error("[langbot-qa] this setup may create or update local QA provider/model/pipeline resources on the selected backend."); if (!backendUrl) { result.status = "env_issue"; throw new Error("LANGBOT_BACKEND_URL is not configured."); } if (!frontendUrl) { result.status = "env_issue"; throw new Error("LANGBOT_FRONTEND_URL is not configured."); } const fakeProvider = await ensureFakeProvider(); const setupConfig = await configureFakeProvider(fakeProvider.url, healthyFakeProviderConfig(), true); result.fake_provider = { ...result.fake_provider, ...fakeProvider, config: setupConfig.config || healthyFakeProviderConfig(), }; const user = env.LANGBOT_E2E_LOGIN_USER || ""; const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; if (!user) { result.status = "env_issue"; throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the fake provider pipeline."); } const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); const wizard = await skipWizard({ backendUrl, token: auth.token }); if (wizard.status !== "pass") { result.status = "fail"; throw new Error(wizard.reason || "Failed to mark the local QA wizard as skipped."); } const provider = await ensureProvider({ backendUrl, token: auth.token, name: providerName, requester, baseUrl: fakeProvider.base_url, }); result.provider = provider; const model = await ensureModel({ backendUrl, token: auth.token, providerUuid: provider.uuid, name: modelName, }); result.model = model; const pipeline = await ensurePipeline({ backendUrl, token: auth.token, name: pipelineName, modelUuid: model.uuid, }); Object.assign(result, pipeline); result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(pipeline.pipeline_id)}`; const runConfig = await configureFakeProvider(fakeProvider.url, targetFakeProviderConfig(), true); result.fake_provider.config = runConfig.config || targetFakeProviderConfig(); if (writeEnv) { await upsertEnvLocal(envLocalPath, { LANGBOT_E2E_LOGIN_USER: user, LANGBOT_FAKE_PROVIDER_URL: fakeProvider.url, LANGBOT_FAKE_PROVIDER_BASE_URL: fakeProvider.base_url, LANGBOT_FAKE_PROVIDER_PID: fakeProvider.pid ? String(fakeProvider.pid) : "", LANGBOT_FAKE_PROVIDER_PROVIDER_UUID: provider.uuid, LANGBOT_FAKE_PROVIDER_MODEL_UUID: model.uuid, LANGBOT_FAKE_PROVIDER_PIPELINE_URL: result.pipeline_url, LANGBOT_FAKE_PROVIDER_PIPELINE_NAME: pipelineName, }); result.wrote_env = true; } result.status = "pass"; result.reason = `Fake provider pipeline is configured with ${requester}/${modelName}.`; } catch (error) { result.status = result.status === "env_issue" ? "env_issue" : "fail"; result.reason = result.reason || safeReason(error.message); } finally { 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 ensureFakeProvider() { const envUrl = normalizeProviderRootUrl(env.LANGBOT_FAKE_PROVIDER_URL || ""); if (envUrl && await fakeProviderHealthy(envUrl) && await fakeProviderConfigurable(envUrl)) { return { url: envUrl, base_url: `${envUrl}/v1`, pid: null, reused: true, }; } const state = await readState(fakeStatePath); const stateUrl = normalizeProviderRootUrl(state.url || ""); if (stateUrl && await fakeProviderHealthy(stateUrl)) { if (await fakeProviderConfigurable(stateUrl)) { return { url: stateUrl, base_url: state.base_url || `${stateUrl}/v1`, pid: Number.isInteger(state.pid) ? state.pid : null, reused: true, }; } if (Number.isInteger(state.pid)) await stopProcess(state.pid); } await mkdir(fakeStateDir, { recursive: true }); await writeFile(fakeStatePath, `${JSON.stringify({ status: "starting", started_at: new Date().toISOString() }, null, 2)}\n`, "utf8"); const stdout = await open(fakeStdoutPath, "a"); const stderr = await open(fakeStderrPath, "a"); const scriptPath = resolve("scripts/e2e/fake-openai-provider.mjs"); const host = env.LANGBOT_FAKE_PROVIDER_HOST || "127.0.0.1"; const port = env.LANGBOT_FAKE_PROVIDER_PORT || "0"; const child = spawn(process.execPath, [ scriptPath, `--host=${host}`, `--port=${port}`, `--state-file=${fakeStatePath}`, ], { cwd: resolve("."), detached: true, env: { ...env, LANGBOT_FAKE_PROVIDER_MODEL_NAME: modelName, }, stdio: ["ignore", stdout.fd, stderr.fd], }); child.unref(); await stdout.close(); await stderr.close(); const started = await waitForFakeProviderState(fakeStatePath, child.pid, 10_000); if (!started.url || !await fakeProviderHealthy(started.url) || !await fakeProviderConfigurable(started.url)) { throw new Error(`Fake provider did not become healthy. See ${fakeStderrPath}`); } return { url: started.url, base_url: started.base_url || `${started.url}/v1`, pid: child.pid ?? started.pid ?? null, reused: false, }; } async function configureFakeProvider(rootUrl, config, resetRequestCount) { const response = await fetch(`${normalizeProviderRootUrl(rootUrl)}/__qa/config`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ config, reset_request_count: resetRequestCount, }), signal: AbortSignal.timeout(3000), }); const json = await response.json().catch(() => ({})); if (!response.ok || json.ok !== true) { throw new Error(`Fake provider config failed with HTTP ${response.status}.`); } return json; } async function fakeProviderHealthy(rootUrl) { try { const response = await fetch(`${rootUrl.replace(/\/$/, "")}/healthz`, { signal: AbortSignal.timeout(2000), }); if (!response.ok) return false; const json = await response.json().catch(() => ({})); return json.ok === true; } catch { return false; } } async function fakeProviderConfigurable(rootUrl) { try { const response = await fetch(`${rootUrl.replace(/\/$/, "")}/__qa/config`, { signal: AbortSignal.timeout(2000), }); if (!response.ok) return false; const json = await response.json().catch(() => ({})); return json.ok === true && json.config && typeof json.config === "object"; } catch { return false; } } async function stopProcess(pid) { try { process.kill(pid, "SIGTERM"); } catch { return; } await sleep(500); } async function waitForFakeProviderState(path, expectedPid, timeoutMs) { const startedAt = Date.now(); let lastState = {}; while (Date.now() - startedAt < timeoutMs) { const state = await readState(path); if (state.url && (!expectedPid || state.pid === expectedPid)) return state; lastState = state; await sleep(150); } return lastState; } async function readState(path) { try { return JSON.parse(await readFile(path, "utf8")); } catch { return {}; } } function normalizeProviderRootUrl(value) { const trimmed = String(value || "").trim().replace(/\/$/, ""); return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed; } function healthyFakeProviderConfig() { return { response_text: "OK", first_token_delay_ms: 25, chunk_delay_ms: 10, chunk_count: 0, fault_status: 500, fail_first_n: 0, fail_every_n: 0, fail_after_first_chunk: false, dynamic_response: true, }; } function targetFakeProviderConfig() { return { response_text: env.LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT || "OK", first_token_delay_ms: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS, 25), chunk_delay_ms: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS, 10), chunk_count: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_CHUNK_COUNT, 0), fault_status: httpFaultStatus(env.LANGBOT_FAKE_PROVIDER_FAULT_STATUS, 500), fail_first_n: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N, 0), fail_every_n: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N, 0), fail_after_first_chunk: envBool(env.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK, false), dynamic_response: envBool(env.LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE, true), }; } async function skipWizard({ backendUrl, token }) { const response = await apiJson(backendUrl, "/api/v1/system/wizard/completed", { method: "POST", token, body: { status: "skipped" }, }); const ok = response.status < 400 && response.json.code === 0; return { status: ok ? "pass" : "fail", http_status: response.status, code: response.json.code ?? null, reason: ok ? "Wizard marked skipped for local QA." : response.json.msg || "Wizard status update failed.", }; } async function ensureProvider({ backendUrl, token, name, requester, baseUrl }) { const list = await apiJson(backendUrl, "/api/v1/provider/providers", { token }); if (isApiFailure(list)) { throw new Error(list.json.msg || "Failed to list providers."); } const providers = list.json.data?.providers || []; const existing = providers.find((provider) => ( provider.name === name || (provider.requester === requester && String(provider.base_url || "").replace(/\/$/, "") === baseUrl.replace(/\/$/, "")) )); const body = { name, requester, base_url: baseUrl, api_keys: [env.LANGBOT_FAKE_PROVIDER_API_KEY || "langbot-fake-provider-key"], }; if (existing?.uuid) { const update = await apiJson(backendUrl, `/api/v1/provider/providers/${encodeURIComponent(existing.uuid)}`, { method: "PUT", token, body, }); if (isApiFailure(update)) { throw new Error(update.json.msg || "Failed to update fake provider."); } return { uuid: existing.uuid, name, requester, created: false, updated: true, }; } const create = await apiJson(backendUrl, "/api/v1/provider/providers", { method: "POST", token, body, }); const uuid = create.json.data?.uuid || ""; if (isApiFailure(create) || !uuid) { throw new Error(create.json.msg || "Failed to create fake provider."); } return { uuid, name, requester, created: true, updated: false, }; } async function ensureModel({ backendUrl, token, providerUuid, name }) { const list = await apiJson(backendUrl, `/api/v1/provider/models/llm?provider_uuid=${encodeURIComponent(providerUuid)}`, { token }); if (isApiFailure(list)) { throw new Error(list.json.msg || "Failed to list fake provider models."); } const models = list.json.data?.models || []; const existing = models.find((model) => model.name === name); const body = { name, provider_uuid: providerUuid, abilities: [], context_length: positiveInteger(env.LANGBOT_FAKE_PROVIDER_CONTEXT_LENGTH, 8192), extra_args: {}, prefered_ranking: 0, }; let modelUuid = existing?.uuid || ""; let created = false; let updated = false; if (modelUuid) { const update = await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}`, { method: "PUT", token, body, }); if (isApiFailure(update)) { throw new Error(update.json.msg || "Failed to update fake provider model."); } updated = true; } else { const create = await apiJson(backendUrl, "/api/v1/provider/models/llm", { method: "POST", token, body, }); modelUuid = create.json.data?.uuid || ""; if (isApiFailure(create) || !modelUuid) { throw new Error(create.json.msg || "Failed to create fake provider model."); } created = true; } const test = await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}/test`, { method: "POST", token, body: { extra_args: {} }, }); if (isApiFailure(test)) { throw new Error(safeReason(test.json.msg || test.json.message || "Fake provider model test failed.")); } return { uuid: modelUuid, name, created, updated, test_status: "pass", test_reason: "", }; } async function ensurePipeline({ backendUrl, token, name, modelUuid }) { const list = await apiJson(backendUrl, "/api/v1/pipelines", { token }); if (isApiFailure(list)) { throw new Error(list.json.msg || "Failed to list pipelines."); } const pipelines = list.json.data?.pipelines || []; let pipeline = pipelines.find((item) => item.name === name) || null; let created = false; if (!pipeline) { const create = await apiJson(backendUrl, "/api/v1/pipelines", { method: "POST", token, body: { name, description: QA_RESOURCE_DESCRIPTION, emoji: "QA", }, }); const pipelineId = create.json.data?.uuid || ""; if (isApiFailure(create) || !pipelineId) { throw new Error(create.json.msg || "Failed to create fake provider pipeline."); } created = true; pipeline = { uuid: pipelineId }; } const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token }); pipeline = loaded.json.data?.pipeline || null; if (isApiFailure(loaded) || !pipeline?.uuid) { throw new Error(loaded.json.msg || "Failed to load fake provider pipeline."); } const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {}; const ai = config.ai && typeof config.ai === "object" ? config.ai : {}; const existingLocalAgentConfig = ai["local-agent"] && typeof ai["local-agent"] === "object" ? ai["local-agent"] : {}; const localAgentConfig = { timeout: 60, prompt: [{ role: "system", content: "You are a deterministic QA assistant. Reply exactly as instructed." }], "remove-think": false, "knowledge-bases": [], "box-session-id-template": "{launcher_type}_{launcher_id}", "retrieval-top-k": 5, "rerank-model": "", "rerank-top-k": 5, "max-tool-iterations": 20, "tool-execution-mode": "parallel", "max-tool-result-chars": 20000, "context-history-fetch-limit": 20, "context-window-tokens": 8192, "context-reserve-tokens": 1024, "context-keep-recent-tokens": 2048, "context-summary-tokens": 1024, ...existingLocalAgentConfig, // Current backend truncation still reads this field directly. "max-round": positiveInteger(existingLocalAgentConfig["max-round"], 10), model: { primary: modelUuid, fallbacks: [], }, }; const updatedConfig = { ...config, ai: { ...ai, runner: { ...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}), id: RUNNER_ID, runner: RUNNER_ID, "expire-time": 0, }, "local-agent": localAgentConfig, }, }; const update = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { method: "PUT", token, body: { name, description: QA_RESOURCE_DESCRIPTION, emoji: "QA", config: updatedConfig, }, }); if (isApiFailure(update)) { throw new Error(update.json.msg || "Failed to update fake provider pipeline."); } return { pipeline_id: pipeline.uuid, pipeline_name: name, created, updated: true, }; } function isApiFailure(response) { return response.status >= 400 || (response.json.code !== undefined && response.json.code !== 0); } function positiveInteger(value, fallback) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } function nonNegativeInteger(value, fallback) { const parsed = Number(value); return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; } function httpFaultStatus(value, fallback) { const parsed = Number(value); return Number.isInteger(parsed) && parsed >= 400 && parsed <= 599 ? parsed : fallback; } function envBool(value, fallback) { if (value === undefined || value === "") return fallback; if (/^(1|true|yes|on)$/i.test(String(value))) return true; if (/^(0|false|no|off)$/i.test(String(value))) return false; return fallback; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function safeReason(value) { return redact(String(value || "")).slice(0, 1000); } async function upsertEnvLocal(path, updates) { await mkdir(dirname(path), { recursive: true }); let text = ""; try { text = await readFile(path, "utf8"); } catch { text = ""; } const lines = text.split(/\r?\n/); const seen = new Set(); const next = lines.map((line) => { const trimmed = line.trim(); const equals = trimmed.indexOf("="); if (equals <= 0 || trimmed.startsWith("#")) return line; const key = trimmed.slice(0, equals).trim(); if (!(key in updates)) return line; seen.add(key); return `${key}=${updates[key]}`; }); for (const [key, value] of Object.entries(updates)) { if (!seen.has(key)) next.push(`${key}=${value}`); } await writeFile(path, `${next.filter((line, index) => line !== "" || index < next.length - 1).join("\n")}\n`, "utf8"); }