feat: update desktop workflows and app center

This commit is contained in:
inman
2026-05-13 19:14:56 +08:00
parent 20b5aff4ad
commit 7c8781a6e3
160 changed files with 55492 additions and 1423 deletions

View File

@@ -37,8 +37,11 @@ interface CronRunLogEntry {
jobId?: string;
action?: string;
status?: string;
resolvedStatus?: string;
error?: string;
summary?: string;
resolvedSummary?: string;
recoveredError?: string;
sessionId?: string;
sessionKey?: string;
ts?: number;
@@ -56,12 +59,17 @@ interface CronSessionKeyParts {
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
isError?: boolean;
}
interface CronTrajectoryArtifacts {
finalStatus?: string;
assistantText?: string;
}
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
@@ -102,12 +110,22 @@ function formatDuration(durationMs: number | undefined): string | null {
return `${Math.round(durationMs / 1000)}s`;
}
function getCronRunStatus(entry: CronRunLogEntry): string {
const status = entry.resolvedStatus || entry.status || '';
return typeof status === 'string' ? status.toLowerCase() : '';
}
function getCronRunSummary(entry: CronRunLogEntry): string {
const summary = entry.resolvedSummary || entry.summary || '';
return typeof summary === 'string' ? summary.trim() : '';
}
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
if (!timestamp) return null;
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
const status = getCronRunStatus(entry);
const summary = getCronRunSummary(entry);
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
let content = summary || error;
@@ -135,7 +153,7 @@ function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSession
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
role: 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
@@ -163,6 +181,65 @@ async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
return entries;
}
function extractTrajectoryArtifacts(raw: string): CronTrajectoryArtifacts | undefined {
let latest: CronTrajectoryArtifacts | undefined;
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as Record<string, unknown>;
if (entry.type !== 'trace.artifacts' || !entry.data || typeof entry.data !== 'object') {
continue;
}
const data = entry.data as Record<string, unknown>;
const finalStatus = typeof data.finalStatus === 'string'
? data.finalStatus
: undefined;
const assistantTexts = Array.isArray(data.assistantTexts)
? data.assistantTexts.filter((text): text is string => typeof text === 'string' && text.trim().length > 0)
: [];
latest = {
...(finalStatus ? { finalStatus } : {}),
...(assistantTexts.length > 0 ? { assistantText: assistantTexts[assistantTexts.length - 1].trim() } : {}),
};
} catch {
// Ignore malformed trajectory lines; cron logs are still useful without them.
}
}
return latest;
}
async function readCronTrajectoryArtifacts(agentId: string, sessionId: string | undefined): Promise<CronTrajectoryArtifacts | undefined> {
if (!sessionId) return undefined;
const trajectoryPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.trajectory.jsonl`);
const raw = await readFile(trajectoryPath, 'utf8').catch(() => '');
if (!raw.trim()) return undefined;
return extractTrajectoryArtifacts(raw);
}
async function resolveCronRunEntry(agentId: string, entry: CronRunLogEntry): Promise<CronRunLogEntry> {
if (getCronRunStatus(entry) !== 'error') return entry;
const artifacts = await readCronTrajectoryArtifacts(agentId, entry.sessionId);
if (artifacts?.finalStatus?.toLowerCase() !== 'success') return entry;
const recoveredError = entry.error || entry.summary;
return {
...entry,
resolvedStatus: 'ok',
...(artifacts.assistantText ? { resolvedSummary: artifacts.assistantText } : {}),
...(recoveredError ? { recoveredError } : {}),
};
}
async function resolveCronRunEntries(agentId: string, entries: CronRunLogEntry[]): Promise<CronRunLogEntry[]> {
return Promise.all(entries.map((entry) => resolveCronRunEntry(agentId, entry)));
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
@@ -227,13 +304,12 @@ export function buildCronSessionFallbackMessages(params: {
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
if (taskName || prompt) {
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
if (prompt) lines.push(`Prompt: ${prompt}`);
if (prompt || taskName) {
const content = prompt || `Scheduled task: ${taskName}`;
messages.push({
id: `cron-meta-${parsed.jobId}`,
role: 'system',
content: lines.join('\n'),
id: `cron-query-${parsed.jobId}-${firstRelevantTimestamp ?? 'pending'}`,
role: 'user',
content,
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
@@ -248,15 +324,15 @@ export function buildCronSessionFallbackMessages(params: {
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
role: 'assistant',
content: '任务正在执行,完成后会自动在这里显示结果。',
timestamp: runningAt,
});
} else if (messages.length === 0) {
messages.push({
id: `cron-empty-${parsed.jobId}`,
role: 'system',
content: 'No chat transcript is available for this scheduled task yet.',
role: 'assistant',
content: '这个任务还没有可显示的执行记录。',
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
});
}
@@ -364,7 +440,53 @@ function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, un
return patch;
}
function transformCronJob(job: GatewayCronJob) {
function findLatestCronRunEntry(runs: CronRunLogEntry[], lastRunAtMs: number | undefined): CronRunLogEntry | undefined {
const withTime = runs
.map((entry) => ({
entry,
timestamp: normalizeTimestampMs(entry.runAtMs) ?? normalizeTimestampMs(entry.ts) ?? 0,
}))
.filter((item) => item.timestamp > 0);
if (withTime.length === 0) return runs[runs.length - 1];
if (lastRunAtMs) {
const exact = withTime.find((item) => item.timestamp === lastRunAtMs);
if (exact) return exact.entry;
}
return withTime.sort((a, b) => b.timestamp - a.timestamp)[0]?.entry;
}
async function resolveLatestCronRun(job: GatewayCronJob, agentId: string): Promise<CronRunLogEntry | undefined> {
if (!job.state?.lastRunAtMs || job.state.lastStatus === 'ok') return undefined;
const runs = await readCronRunLog(job.id);
const latest = findLatestCronRunEntry(runs, job.state.lastRunAtMs);
return latest ? resolveCronRunEntry(agentId, latest) : undefined;
}
async function resolveRecoveredCronRunAfterError(ctx: HostApiContext, jobId: string): Promise<Record<string, unknown> | undefined> {
const jobsResult = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] }));
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === jobId);
const agentId = (job as unknown as { agentId?: string } | undefined)?.agentId || 'main';
const runs = await readCronRunLog(jobId);
const latest = findLatestCronRunEntry(runs, job?.state?.lastRunAtMs);
if (!latest) return undefined;
const resolvedRun = await resolveCronRunEntry(agentId, latest);
if (getCronRunStatus(resolvedRun) !== 'ok') return undefined;
return {
success: true,
recovered: true,
...(getCronRunSummary(resolvedRun) ? { summary: getCronRunSummary(resolvedRun) } : {}),
...(resolvedRun.recoveredError ? { warning: resolvedRun.recoveredError } : {}),
...(job ? { job: transformCronJob(job, resolvedRun) } : {}),
};
}
function transformCronJob(job: GatewayCronJob, resolvedLastRun?: CronRunLogEntry) {
const message = job.payload?.message || job.payload?.text || '';
const gatewayDelivery = normalizeCronDelivery(job.delivery);
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
@@ -379,12 +501,15 @@ function transformCronJob(job: GatewayCronJob) {
recipient: delivery.to,
}
: undefined;
const resolvedRunStatus = resolvedLastRun ? getCronRunStatus(resolvedLastRun) : '';
const effectiveLastStatus = resolvedRunStatus || job.state?.lastStatus;
const lastRun = job.state?.lastRunAtMs
? {
time: new Date(job.state.lastRunAtMs).toISOString(),
success: job.state.lastStatus === 'ok',
error: job.state.lastError,
success: effectiveLastStatus === 'ok',
...(effectiveLastStatus === 'ok' ? {} : { error: job.state.lastError }),
duration: job.state.lastDurationMs,
...(resolvedLastRun?.recoveredError ? { warning: resolvedLastRun.recoveredError, reconciled: true } : {}),
}
: undefined;
const nextRun = job.state?.nextRunAtMs
@@ -430,12 +555,13 @@ export async function handleCronRoutes(
: 200;
try {
const [jobsResult, runs, sessionEntry] = await Promise.all([
const [jobsResult, rawRuns, sessionEntry] = await Promise.all([
ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
readCronRunLog(parsedSession.jobId),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const runs = await resolveCronRunEntries(parsedSession.agentId, rawRuns);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === parsedSession.jobId);
@@ -589,7 +715,13 @@ export async function handleCronRoutes(
}
}
sendJson(res, 200, jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) })));
const responseJobs = await Promise.all(jobs.map(async (job) => {
const agentId = (job as unknown as { agentId?: string }).agentId || 'main';
const resolvedLastRun = await resolveLatestCronRun(job, agentId);
return { ...transformCronJob(job, resolvedLastRun), ...(usedFallback ? { _fromFallback: true } : {}) };
}));
sendJson(res, 200, responseJobs);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
@@ -684,7 +816,16 @@ export async function handleCronRoutes(
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
try {
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
} catch (runError) {
const recovered = await resolveRecoveredCronRunAfterError(ctx, body.id);
if (recovered) {
sendJson(res, 200, { ...recovered, triggerWarning: String(runError) });
return true;
}
throw runError;
}
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}

View File

@@ -0,0 +1,28 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getAllLocalPreferences, patchLocalPreferences, type AppLocalPreferences } from '../../utils/local-preferences';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleLocalPreferenceRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/local-preferences' && req.method === 'GET') {
sendJson(res, 200, await getAllLocalPreferences());
return true;
}
if (url.pathname === '/api/local-preferences' && req.method === 'PUT') {
try {
const patch = await parseJsonBody<AppLocalPreferences>(req);
sendJson(res, 200, await patchLocalPreferences(patch));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}

View File

@@ -19,6 +19,7 @@ import { handleKnowledgeRoutes } from './routes/knowledge';
import { handleSessionRoutes } from './routes/sessions';
import { handleCronRoutes } from './routes/cron';
import { handleDiagnosticsRoutes } from './routes/diagnostics';
import { handleLocalPreferenceRoutes } from './routes/local-preferences';
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
type RouteHandler = (
@@ -41,6 +42,7 @@ const coreRouteHandlers: RouteHandler[] = [
handleKnowledgeRoutes,
handleSessionRoutes,
handleCronRoutes,
handleLocalPreferenceRoutes,
handleDiagnosticsRoutes,
handleLogRoutes,
handleUsageRoutes,

View File

@@ -37,6 +37,7 @@ import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
import { buildDotnetEnv } from '../utils/dotnet-runtime';
import { buildPlaywrightRuntimeEnv, ensureYinianPlaywrightRuntimeDirs } from '../utils/playwright-runtime';
import { copyPluginFromNodeModules, ensureCloudSyncPluginInstalled, fixupPluginManifest, cpSyncSafe } from '../utils/plugin-install';
import { stripSystemdSupervisorEnv } from './config-sync-env';
import { ensureYinianModelRuntimeConfigured } from '../utils/model-diagnostics';
@@ -556,6 +557,8 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
? prependPathEntry(baseEnvRecord, binPath).env
: baseEnvRecord;
const baseEnvWithDotnet = buildDotnetEnv(baseEnvPatched);
ensureYinianPlaywrightRuntimeDirs();
const playwrightEnv = buildPlaywrightRuntimeEnv(baseEnvWithDotnet);
const nodePath = buildNodePathEnv(baseEnvPatched, [
join(process.cwd(), 'node_modules'),
join(app.getAppPath(), 'node_modules'),
@@ -567,6 +570,7 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
...providerEnv,
...uvEnv,
...proxyEnv,
...playwrightEnv,
...bundledPackageManagerEnv,
...(nodePath ? { NODE_PATH: nodePath } : {}),
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,

View File

@@ -0,0 +1,41 @@
/**
* Application-level local preferences.
*
* Renderer localStorage is origin-scoped, so dev server port changes can make
* user preferences appear to disappear. Keep business-local preferences here
* as the stable source shared by all renderer origins.
*/
export interface AppLocalPreferences {
quickTasks?: unknown[];
channelAccountRemarks?: Record<string, string>;
desktopUserName?: string;
workspaceDisplayName?: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let localPreferencesStoreInstance: any = null;
async function getLocalPreferencesStore() {
if (!localPreferencesStoreInstance) {
const Store = (await import('electron-store')).default;
localPreferencesStoreInstance = new Store<AppLocalPreferences>({
name: 'local-preferences',
});
}
return localPreferencesStoreInstance;
}
export async function getAllLocalPreferences(): Promise<AppLocalPreferences> {
const store = await getLocalPreferencesStore();
return { ...(store.store ?? {}) };
}
export async function patchLocalPreferences(patch: AppLocalPreferences): Promise<AppLocalPreferences> {
const store = await getLocalPreferencesStore();
for (const [key, value] of Object.entries(patch) as Array<[keyof AppLocalPreferences, unknown]>) {
if (value === undefined) continue;
store.set(key, value);
}
return { ...(store.store ?? {}) };
}

View File

@@ -12,6 +12,11 @@ import {
import { logger } from './logger';
import { getOpenClawConfigDir, needsWinShell, quoteForCmd } from './paths';
import { buildDotnetEnv, resolveDotnetExecutable } from './dotnet-runtime';
import {
buildPlaywrightRuntimeEnv,
ensureYinianPlaywrightRuntimeDirs,
resolveYinianPlaywrightBrowsersPath,
} from './playwright-runtime';
export type OfficeRuntimeStatus = 'ok' | 'warning' | 'error';
@@ -45,6 +50,15 @@ export interface OfficeSkillRuntimeDiagnostics {
available: boolean;
version: string | null;
};
playwright: {
moduleInstalled: boolean;
moduleName: string | null;
moduleResolvedPath: string | null;
browsersPath: string;
chromiumExecutablePath: string | null;
chromiumInstalled: boolean;
error: string | null;
};
checks: OfficeRuntimeCheck[];
}
@@ -74,6 +88,8 @@ const NODE_MODULES = [
'sharp',
] as const;
const PLAYWRIGHT_MODULE_CANDIDATES = ['playwright', 'playwright-core'] as const;
const PYTHON_INSTALL_TIMEOUT_MS = 10 * 60_000;
const COMMAND_TIMEOUT_MS = 20_000;
@@ -194,6 +210,66 @@ function resolveNodeModule(name: string): string | null {
return null;
}
function checkPlaywrightRuntime(): OfficeSkillRuntimeDiagnostics['playwright'] {
const runtimeEnv = buildPlaywrightRuntimeEnv();
const browsersPath = runtimeEnv.PLAYWRIGHT_BROWSERS_PATH || resolveYinianPlaywrightBrowsersPath();
const previousBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
try {
for (const req of buildRequireCandidates()) {
for (const moduleName of PLAYWRIGHT_MODULE_CANDIDATES) {
try {
const moduleResolvedPath = req.resolve(moduleName);
const loaded = req(moduleName) as {
chromium?: {
executablePath?: () => string;
};
};
const chromiumExecutablePath = loaded.chromium?.executablePath?.() ?? null;
return {
moduleInstalled: true,
moduleName,
moduleResolvedPath,
browsersPath,
chromiumExecutablePath,
chromiumInstalled: Boolean(chromiumExecutablePath && existsSync(chromiumExecutablePath)),
error: null,
};
} catch {
// Try the next module/root.
}
}
}
return {
moduleInstalled: false,
moduleName: null,
moduleResolvedPath: null,
browsersPath,
chromiumExecutablePath: null,
chromiumInstalled: false,
error: 'Playwright runtime module not found',
};
} catch (error) {
return {
moduleInstalled: false,
moduleName: null,
moduleResolvedPath: null,
browsersPath,
chromiumExecutablePath: null,
chromiumInstalled: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
if (previousBrowsersPath === undefined) {
delete process.env.PLAYWRIGHT_BROWSERS_PATH;
} else {
process.env.PLAYWRIGHT_BROWSERS_PATH = previousBrowsersPath;
}
}
}
async function checkPythonPackage(pythonPath: string | null, importName: string): Promise<boolean> {
if (!pythonPath) return false;
const result = await runCommand(pythonPath, ['-c', `__import__(${JSON.stringify(importName)})`]);
@@ -245,6 +321,7 @@ export async function buildOfficeSkillRuntimeDiagnostics(options: {
if (repairAttempted) {
await setupManagedPython();
ensureYinianPlaywrightRuntimeDirs();
}
let pythonPath = await findManagedPythonPath();
@@ -281,6 +358,7 @@ export async function buildOfficeSkillRuntimeDiagnostics(options: {
};
});
const dotnet = await checkDotnet();
const playwright = checkPlaywrightRuntime();
const missingPythonPackages = packageResults.filter((pkg) => !pkg.installed);
const missingNodeModules = nodeModules.filter((mod) => !mod.installed);
@@ -315,6 +393,16 @@ export async function buildOfficeSkillRuntimeDiagnostics(options: {
? `.NET ${dotnet.version}`
: '未找到 .NETdocx 高级 OpenXML 生成可能受限',
},
{
id: 'playwright-chromium',
label: '幻灯片浏览器预览',
status: playwright.moduleInstalled && playwright.chromiumInstalled ? 'ok' : 'warning',
detail: playwright.moduleInstalled
? (playwright.chromiumInstalled
? `Chromium 可用:${playwright.chromiumExecutablePath}`
: `未找到 Chromium 二进制文件;浏览器预览应跳过,不要在任务中运行 npx playwright install chromium。缓存路径${playwright.browsersPath}`)
: `未找到 Playwright 运行时;浏览器预览应跳过。${playwright.error ?? ''}`,
},
];
return {
@@ -329,6 +417,7 @@ export async function buildOfficeSkillRuntimeDiagnostics(options: {
modules: nodeModules,
},
dotnet,
playwright,
checks,
};
}

View File

@@ -0,0 +1,74 @@
import { existsSync, mkdirSync, readdirSync, symlinkSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { getOpenClawConfigDir } from './paths';
const PLAYWRIGHT_BROWSERS_DIR = 'ms-playwright';
const PLAYWRIGHT_LOCKS_DIR = 'locks';
const PLAYWRIGHT_COMPLETE_MARKER = 'INSTALLATION_COMPLETE';
function resolveDefaultPlaywrightBrowsersPath(): string | null {
if (process.platform === 'darwin') {
return path.join(homedir(), 'Library', 'Caches', 'ms-playwright');
}
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA?.trim();
return localAppData ? path.join(localAppData, 'ms-playwright') : null;
}
return path.join(homedir(), '.cache', 'ms-playwright');
}
export function resolveYinianPlaywrightBrowsersPath(): string {
return path.join(getOpenClawConfigDir(), 'runtime', PLAYWRIGHT_BROWSERS_DIR);
}
export function resolveYinianPlaywrightInstallLockPath(): string {
return path.join(getOpenClawConfigDir(), 'runtime', PLAYWRIGHT_LOCKS_DIR, 'playwright-chromium-install.lock');
}
function mirrorCompletedDefaultBrowsers(targetRoot: string): void {
const defaultRoot = resolveDefaultPlaywrightBrowsersPath();
if (!defaultRoot || defaultRoot === targetRoot || !existsSync(defaultRoot)) return;
let entries;
try {
entries = readdirSync(defaultRoot, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith('chromium')) continue;
const sourceDir = path.join(defaultRoot, entry.name);
if (!existsSync(path.join(sourceDir, PLAYWRIGHT_COMPLETE_MARKER))) continue;
const targetDir = path.join(targetRoot, entry.name);
if (existsSync(targetDir)) continue;
try {
symlinkSync(sourceDir, targetDir, process.platform === 'win32' ? 'junction' : 'dir');
} catch {
// Best effort. A missing browser remains a warning-level diagnostic.
}
}
}
export function ensureYinianPlaywrightRuntimeDirs(): void {
const browsersPath = resolveYinianPlaywrightBrowsersPath();
mkdirSync(browsersPath, { recursive: true });
mkdirSync(path.dirname(resolveYinianPlaywrightInstallLockPath()), { recursive: true });
mirrorCompletedDefaultBrowsers(browsersPath);
}
export function buildPlaywrightRuntimeEnv(
baseEnv: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
): Record<string, string> {
const explicitBrowsersPath = baseEnv.PLAYWRIGHT_BROWSERS_PATH?.trim();
return {
PLAYWRIGHT_BROWSERS_PATH: explicitBrowsersPath || resolveYinianPlaywrightBrowsersPath(),
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: baseEnv.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD ?? '1',
YINIAN_PLAYWRIGHT_INSTALL_LOCK_PATH: baseEnv.YINIAN_PLAYWRIGHT_INSTALL_LOCK_PATH
?? resolveYinianPlaywrightInstallLockPath(),
};
}