diff --git a/README.md b/README.md index 0c10824c..4d84e8fe 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 +作品入口按项目记住已确认的本机页面和受限的打开方式。应用重启后先检查并复用原服务;服务停止时,可直接重新启动已有的 Vite 工程或普通静态网页,再显示实际可访问的页面,不要求当前对话选择模型,也不自动新建对话。旧项目可从已成功打开页面的工具回执恢复入口。空项目仍显示原有空状态;无法确定打开方式、缺少运行组件或入口改变时说明原因,由用户明确选择「让助手检查」后才调用编程助手。后台恢复只访问本机地址,保存的打开方式按项目真实目录校验,不保存或重放任意终端命令。 + 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill,无需伙伴分配;它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 创建智能体时,如果本次新操作被服务明确拒绝,会显示失败原因、保留名称和头像,并允许修改后重新提交;不会留下“结果尚未确认”提醒。断网、响应异常或已有待确认操作的重试失败仍保留原创建请求,可重试确认结果,避免重复创建。当前创建表单一旦出现结果不确定,即使移除提醒后重试被拒绝,也继续保留原请求;移除提醒不代表取消创建。创建页面会及时刷新本机恢复记录。服务器的字段校验错误只转换为固定中文提示,不展示内部错误或提交原文。一句话介绍仍可留空;创建与保存需要配套的智能体云端服务支持。 diff --git a/electron/api/routes/agent-browser.ts b/electron/api/routes/agent-browser.ts index 0a1e6abc..2c9eb575 100644 --- a/electron/api/routes/agent-browser.ts +++ b/electron/api/routes/agent-browser.ts @@ -9,6 +9,7 @@ import { parseJsonBody, sendJson } from '../route-utils'; type AgentBrowserBody = { conversation_id?: unknown; + allow_assistant?: unknown; request_id?: unknown; project_id?: unknown; project_path?: unknown; @@ -42,6 +43,7 @@ function workPreviewService(ctx: HostApiContext): CodingWorkPreviewService | und conversations: ctx.codingProducts.conversations, runtime: ctx.codingProducts.runtime, ensureActive: (target) => ensureProjectStillActive(ctx, target), + storedSnapshot: (id) => ctx.codingProducts!.conversations.getStoredSnapshot(id), }); workPreviewServices.set(ctx, preview); } @@ -264,11 +266,16 @@ export async function handleAgentBrowserRoutes( if (!requestId || !/^[a-zA-Z0-9-]{1,80}$/.test(requestId)) { throw new AgentBrowserRouteError('INVALID_REQUEST', '作品请求无效。'); } + if (body.allow_assistant !== undefined && typeof body.allow_assistant !== 'boolean') { + throw new AgentBrowserRouteError('INVALID_REQUEST', '作品请求无效。'); + } if (!ctx.codingProducts) throw new AgentBrowserRouteError('CLOSED', '本地编程服务暂时不可用。', 503); const preview = workPreviewService(ctx)!; const release = ctx.lifecycle?.acquireLease({ id: `work-preview:${project.id}:${requestId}`, kind: 'work-preview' }); try { - const result = await preview.ensure(project, requestId, nonEmptyString(body.conversation_id)); + const result = body.allow_assistant === true + ? await preview.ensure(project, requestId, nonEmptyString(body.conversation_id), true) + : await preview.ensure(project, requestId, nonEmptyString(body.conversation_id)); await ensureProjectStillActive(ctx, project); sendJson(res, 200, { success: true, ...result }); } finally { @@ -313,7 +320,7 @@ export async function handleAgentBrowserRoutes( ...(body.inject_project_data === true ? { injectProjectData: true } : {}), }); await ensureProjectStillActive(ctx, project); - workPreviewService(ctx)?.remember(project, browser); + await workPreviewService(ctx)?.remember(project, browser).catch(() => undefined); emitState(ctx, 'agent-browser:show', browser); sendJson(res, 200, { success: true, browser }); return true; diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index 2c42d482..e505abad 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -411,6 +411,13 @@ export class CodingConversationService { } } + /** Read existing facts for work restoration without opening a model session. */ + async getStoredSnapshot(conversationId: string): Promise { + const { project, conversation } = await this.projects.findActiveConversation(conversationId); + if (!conversation.sessionKey || !this.options.readHistory) return null; + return await this.options.readHistory(project.id, conversation); + } + async acceptPrompt(input: { conversationId: string; clientRequestId: unknown; diff --git a/electron/coding-runtime/work-entry-contracts.ts b/electron/coding-runtime/work-entry-contracts.ts new file mode 100644 index 00000000..6edc29bb --- /dev/null +++ b/electron/coding-runtime/work-entry-contracts.ts @@ -0,0 +1,14 @@ +/** Project-relative, app-owned launch recipes. Never persisted shell commands. */ +export type WorkLaunchSpec = + | { kind: 'vite'; directory: string; args: string[] } + | { kind: 'static'; directory: string; entry: string }; + +export type WorkEntryProject = { id: string; path: string }; +export type WorkEntry = { + version: 1; + projectId: string; + projectPath: string; + url: string; + documentHash?: string; + launch?: WorkLaunchSpec; +}; diff --git a/electron/coding-runtime/work-entry-store.ts b/electron/coding-runtime/work-entry-store.ts new file mode 100644 index 00000000..aab046ab --- /dev/null +++ b/electron/coding-runtime/work-entry-store.ts @@ -0,0 +1,233 @@ +import { randomUUID } from 'node:crypto'; +import { constants, type Stats } from 'node:fs'; +import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'; +import path from 'node:path'; +import type { WorkEntry, WorkEntryProject, WorkLaunchSpec } from './work-entry-contracts'; + +const ENTRY_FILENAME = 'work-entry.json'; +const MAX_ENTRY_BYTES = 16 * 1024; +const MAX_URL_LENGTH = 2_048; +const MAX_PATH_LENGTH = 1_024; +const NO_FOLLOW = constants.O_NOFOLLOW ?? 0; + +type ProjectRoot = { root: string; rootStat: Stats }; +type Locations = ProjectRoot & { directory: string; file: string; directoryStat: Stats }; + +function invalid(): never { + throw new Error('Invalid project work entry'); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function hasOnlyKeys(value: Record, keys: string[]): boolean { + return Object.keys(value).every((key) => keys.includes(key)); +} + +function samePath(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function sameNode(left: Stats, right: Stats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function containsControl(value: string, includeSpace = false): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code < (includeSpace ? 33 : 32) || code === 127) return true; + } + return false; +} + +async function projectRoot(project: WorkEntryProject): Promise { + if (typeof project.id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(project.id) + || typeof project.path !== 'string' || !path.isAbsolute(project.path)) invalid(); + const root = await realpath(project.path); + const rootStat = await lstat(root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) invalid(); + return { root, rootStat }; +} + +async function assertRoot(target: ProjectRoot): Promise { + const stat = await lstat(target.root); + if (!stat.isDirectory() || stat.isSymbolicLink() || !sameNode(stat, target.rootStat) + || !samePath(await realpath(target.root), target.root)) invalid(); +} + +async function locations(project: WorkEntryProject, createMetadata = false, verifiedRoot?: ProjectRoot): Promise { + const { root, rootStat } = verifiedRoot ?? await projectRoot(project); + const directory = path.join(root, '.makelore'); + const directoryStat = await lstat(directory).catch(async (error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT' || !createMetadata) throw error; + await assertRoot({ root, rootStat }); + // Imported folders may not have metadata yet. Create only this direct child; + // a concurrent creator must still pass the same directory/link checks below. + await mkdir(directory, { mode: 0o700 }).catch((mkdirError: NodeJS.ErrnoException) => { + if (mkdirError.code !== 'EEXIST') throw mkdirError; + }); + return await lstat(directory); + }); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() + || !samePath(await realpath(directory), directory)) invalid(); + await assertRoot({ root, rootStat }); + return { root, directory, file: path.join(directory, ENTRY_FILENAME), rootStat, directoryStat }; +} + +async function assertLocations(target: Locations): Promise { + await assertRoot(target); + const directoryStat = await lstat(target.directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || !sameNode(directoryStat, target.directoryStat) + || !samePath(await realpath(target.directory), target.directory)) invalid(); +} + +function relativePath(value: unknown): string { + if (typeof value !== 'string' || !value || value.length > MAX_PATH_LENGTH + || containsControl(value) || /[\\:]/u.test(value) || value.trim() !== value + || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) invalid(); + const pieces = value.split('/'); + if (pieces.length > 32 || pieces.includes('..')) invalid(); + return path.posix.normalize(value); +} + +async function relativeTarget(root: string, value: unknown, kind: 'directory' | 'file'): Promise { + const relative = relativePath(value); + let current = root; + for (const piece of relative.split('/').filter((piece) => piece !== '.')) { + current = path.join(current, piece); + if ((await lstat(current)).isSymbolicLink()) invalid(); + } + const stat = await lstat(current); + if (!(kind === 'directory' ? stat.isDirectory() : stat.isFile()) + || !samePath(await realpath(current), current)) invalid(); + return relative; +} + +function localUrl(value: unknown): string { + if (typeof value !== 'string' || value.length > MAX_URL_LENGTH + || containsControl(value, true)) invalid(); + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) + || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + || url.username || url.password) invalid(); + return value; +} + +/** Only structured Vite options are persisted; the launcher supplies the loopback host. */ +function viteArgs(value: unknown): string[] { + if (!Array.isArray(value) || value.length > 16) invalid(); + const result: string[] = []; + const seen = new Set(); + for (let index = 0; index < value.length; index += 1) { + const flag: unknown = value[index]; + if (typeof flag !== 'string' || seen.has(flag)) invalid(); + seen.add(flag); + if (flag === '--strictPort' || flag === '--force') { + result.push(flag); + continue; + } + const parameter: unknown = value[++index]; + if (typeof parameter !== 'string') invalid(); + if (flag === '--port') { + if (!/^[1-9][0-9]{0,4}$/u.test(parameter) || Number(parameter) > 65_535) invalid(); + } else if (flag === '--mode') { + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u.test(parameter)) invalid(); + } else if (flag === '--base') { + if (parameter.length > MAX_PATH_LENGTH || !/^(?:\/(?!\/)|\.\/)/u.test(parameter) + || containsControl(parameter, true) || /[\\?#:]/u.test(parameter) + || parameter.split('/').includes('..')) invalid(); + } else invalid(); + result.push(flag, parameter); + } + return result; +} + +async function launchSpec(root: string, value: unknown): Promise { + if (!isRecord(value)) invalid(); + if (value.kind === 'vite' && hasOnlyKeys(value, ['kind', 'directory', 'args'])) { + return { kind: 'vite', directory: await relativeTarget(root, value.directory, 'directory'), args: viteArgs(value.args) }; + } + if (value.kind === 'static' && hasOnlyKeys(value, ['kind', 'directory', 'entry'])) { + const directory = await relativeTarget(root, value.directory, 'directory'); + const entry = await relativeTarget(path.join(root, directory), value.entry, 'file'); + return { kind: 'static', directory, entry }; + } + return invalid(); +} + +async function validatedEntry(project: WorkEntryProject, root: string, value: unknown, saving = false): Promise { + if (!isRecord(value) || !hasOnlyKeys(value, ['version', 'projectId', 'projectPath', 'url', 'launch', 'documentHash']) + || value.version !== 1 || value.projectId !== project.id || typeof value.projectPath !== 'string' + || !path.isAbsolute(value.projectPath)) invalid(); + const boundPath = saving ? await realpath(value.projectPath) : value.projectPath; + if (!samePath(boundPath, root)) invalid(); + if (value.documentHash !== undefined + && (typeof value.documentHash !== 'string' || !/^[a-f0-9]{64}$/u.test(value.documentHash))) invalid(); + return { + version: 1, projectId: project.id, projectPath: root, url: localUrl(value.url), + ...(value.launch === undefined ? {} : { launch: await launchSpec(root, value.launch) }), + ...(value.documentHash === undefined ? {} : { documentHash: value.documentHash }), + }; +} + +/** Reads bounded project-owned metadata without following links or starting any work. */ +export async function loadWorkEntry(project: WorkEntryProject): Promise { + try { + const target = await locations(project); + const fileStat = await lstat(target.file); + if (!fileStat.isFile() || fileStat.isSymbolicLink() || fileStat.size > MAX_ENTRY_BYTES) return null; + const handle = await open(target.file, constants.O_RDONLY | NO_FOLLOW); + try { + const openedStat = await handle.stat(); + if (!openedStat.isFile() || openedStat.size > MAX_ENTRY_BYTES || !sameNode(fileStat, openedStat)) return null; + const bytes = Buffer.alloc(MAX_ENTRY_BYTES + 1); + let size = 0; + while (size < bytes.length) { + const chunk = await handle.read(bytes, size, bytes.length - size, size); + if (!chunk.bytesRead) break; + size += chunk.bytesRead; + } + if (size > MAX_ENTRY_BYTES) return null; + await assertLocations(target); + const currentStat = await lstat(target.file); + if (currentStat.isSymbolicLink() || !sameNode(openedStat, currentStat)) return null; + const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size)); + return await validatedEntry(project, target.root, JSON.parse(source)); + } finally { + await handle.close(); + } + } catch { + // Missing, copied, changed or corrupt metadata is never launch authority. + return null; + } +} + +/** Publishes one complete entry in project metadata, using an exclusive temp file. */ +export async function saveWorkEntry(project: WorkEntryProject, entry: WorkEntry): Promise { + const root = await projectRoot(project); + const value = await validatedEntry(project, root.root, entry, true); + const source = `${JSON.stringify(value, null, 2)}\n`; + if (Buffer.byteLength(source, 'utf8') > MAX_ENTRY_BYTES) invalid(); + const target = await locations(project, true, root); + await assertLocations(target); + const temporary = path.join(target.directory, `.${ENTRY_FILENAME}.${randomUUID()}.tmp`); + try { + const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, 0o600); + try { + await handle.writeFile(source, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await assertLocations(target); + const existing = await lstat(target.file).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + return null; + }); + if (existing && (!existing.isFile() || existing.isSymbolicLink())) invalid(); + await rename(temporary, target.file); + } finally { + await assertLocations(target).then(() => rm(temporary, { force: true })).catch(() => undefined); + } +} diff --git a/electron/coding-runtime/work-launcher.ts b/electron/coding-runtime/work-launcher.ts new file mode 100644 index 00000000..3ea0d291 --- /dev/null +++ b/electron/coding-runtime/work-launcher.ts @@ -0,0 +1,551 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { constants } from 'node:fs'; +import { lstat, mkdir, open, readFile, readdir, realpath } from 'node:fs/promises'; +import { get, type IncomingHttpHeaders } from 'node:http'; +import { createServer } from 'node:net'; +import path from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import type { WorkEntryProject, WorkLaunchSpec } from './work-entry-contracts'; + +export type WorkLaunchErrorCode = + | 'WORK_PREVIEW_DEPENDENCIES_MISSING' + | 'WORK_PREVIEW_START_FAILED' + | 'WORK_PREVIEW_ENTRY_MISSING' + | 'WORK_PREVIEW_NEEDS_HELP'; + +export class WorkLaunchError extends Error { + constructor(readonly code: WorkLaunchErrorCode, message: string) { + super(message); + this.name = 'WorkLaunchError'; + } +} + +const MAX_DEPTH = 2; +const MAX_DIRECTORIES = 160; +const START_TIMEOUT_MS = 15_000; +const ownedLaunches = new Map(); +const SKIP_DIRECTORIES = new Set(['node_modules', '.git', '.makelore', '.codex']); +const LOCAL_BASE = /^(?:\/(?!\/)[A-Za-z0-9_./-]*|\.\/?[A-Za-z0-9_./-]*)$/; + +/** Only a small Vite CLI vocabulary is replayable; package scripts never enter a shell. */ +function viteArgs(script: unknown): string[] | null { + if (typeof script !== 'string' || script.length > 1_000) return null; + const tokens = script.trim().split(/\s+/); + if (tokens.shift() !== 'vite') return null; + const args: string[] = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + const equal = token.indexOf('='); + const flag = equal < 0 ? token : token.slice(0, equal); + const inline = equal < 0 ? undefined : token.slice(equal + 1); + if (flag === '--strictPort' || flag === '--force') { + if (inline !== undefined) return null; + args.push(flag); + continue; + } + if (flag === '--host') { + const host = inline ?? (!tokens[index + 1]?.startsWith('--') ? tokens[++index] : undefined); + if (host !== undefined && !['127.0.0.1', 'localhost', '0.0.0.0'].includes(host)) return null; + continue; // The app supplies the loopback binding. + } + if (!['--port', '--base', '--mode'].includes(flag)) return null; + const value = inline ?? tokens[++index]; + if (!value) return null; + if (flag === '--port' && (!/^\d{1,5}$/.test(value) || Number(value) < 1 || Number(value) > 65_535)) return null; + if (flag === '--mode' && !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value)) return null; + if (flag === '--base' && (!LOCAL_BASE.test(value) || value.split('/').includes('..'))) return null; + if (args.includes(flag)) return null; + args.push(flag, flag === '--port' ? String(Number(value)) : value); + } + return args; +} + +function within(root: string, target: string): boolean { + const relative = path.relative(root, target); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +} + +function relativeParts(value: string): string[] { + if (typeof value !== 'string' || !value || value.includes('\\') || value.includes('\0') || path.isAbsolute(value)) { + throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的启动位置已改变,请重新检查作品。'); + } + if (value === '.') return []; + const parts = value.split('/'); + if (parts.some((part) => !part || part === '.' || part === '..' || part.startsWith('.'))) { + throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的启动位置不在项目中。'); + } + return parts; +} + +async function safeExistingPath(root: string, relative: string, kind: 'directory' | 'file'): Promise { + const parts = relativeParts(relative); + let current = root; + try { + for (const part of parts) { + current = path.join(current, part); + if ((await lstat(current)).isSymbolicLink()) throw new Error('symlink'); + } + const stat = await lstat(current); + const resolved = await realpath(current); + if (!within(root, resolved) || (kind === 'file' ? !stat.isFile() : !stat.isDirectory())) throw new Error('outside project'); + return resolved; + } catch { + throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的文件或启动位置找不到了。'); + } +} + +async function projectRoot(projectPath: string): Promise { + try { + const root = await realpath(projectPath); + if (!(await lstat(root)).isDirectory()) throw new Error('not a directory'); + return root; + } catch { + throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '项目文件夹找不到了。'); + } +} + +async function packageRecord(directory: string): Promise | null> { + const file = path.join(directory, 'package.json'); + const stat = await lstat(file).catch(() => null); + if (!stat) return null; + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_000_000) { + throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。'); + } + try { + const value: unknown = JSON.parse(await readFile(file, 'utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid package'); + return value as Record; + } catch { + throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。'); + } +} + +function devArgs(pkg: Record): string[] | null { + const scripts = pkg.scripts; + return viteArgs(scripts && typeof scripts === 'object' && !Array.isArray(scripts) + ? (scripts as Record).dev + : undefined); +} + +export async function discoverWorkLaunch(projectPath: string): Promise { + const root = await projectRoot(projectPath); + const candidates: WorkLaunchSpec[] = []; + let inspected = 0; + let needsHelp = false; + const visit = async (directory: string, depth: number): Promise => { + if (++inspected > MAX_DIRECTORIES) { + needsHelp = true; + return; + } + const relative = path.relative(root, directory).split(path.sep).join('/') || '.'; + let pkg: Record | null; + try { pkg = await packageRecord(directory); } catch { needsHelp = true; return; } + if (pkg) { + const args = devArgs(pkg); + if (args) candidates.push({ kind: 'vite', directory: relative, args }); + else needsHelp = true; + // Do not mistake a framework's public/dist/template files for a plain website. + return; + } + const index = await lstat(path.join(directory, 'index.html')).catch(() => null); + if (index?.isFile() && !index.isSymbolicLink()) { + candidates.push({ kind: 'static', directory: relative, entry: 'index.html' }); + return; + } + if (depth >= MAX_DEPTH || candidates.length > 1) return; + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith('.') || SKIP_DIRECTORIES.has(entry.name)) continue; + await visit(path.join(directory, entry.name), depth + 1); + if (candidates.length > 1 || inspected > MAX_DIRECTORIES) break; + } + }; + await visit(root, 0); + if (candidates.length > 1 || needsHelp) { + throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '需要帮忙确认打开哪个作品,以及怎样启动它。'); + } + return candidates[0] ?? null; +} + +async function viteCli(root: string, directory: string): Promise { + for (let current = directory; within(root, current); current = path.dirname(current)) { + const packagePath = path.join(current, 'node_modules', 'vite', 'package.json'); + try { + const pkg = JSON.parse(await readFile(packagePath, 'utf8')) as { name?: string }; + const cli = await realpath(path.join(current, 'node_modules', 'vite', 'bin', 'vite.js')); + const api = path.resolve(path.dirname(cli), '../dist/node/index.js'); + if (pkg.name === 'vite' && (await lstat(cli)).isFile() && (await lstat(api)).isFile()) return cli; + } catch { /* Try installed dependencies at a containing project directory. */ } + if (current === root) break; + } + throw new WorkLaunchError('WORK_PREVIEW_DEPENDENCIES_MISSING', '作品的启动工具还没准备好,需要帮忙检查。'); +} + +function argumentValue(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + return index < 0 ? undefined : args[index + 1]; +} + +function previousPort(previousUrl: string | undefined): number | undefined { + try { + const url = new URL(previousUrl ?? ''); + if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) + || url.username || url.password || !url.port) return undefined; + return Number(url.port); + } catch { return undefined; } +} + +async function availablePort(preferred?: number): Promise { + const reserve = (port: number) => new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { server.close(); reject(new Error('port unavailable')); return; } + server.close((error) => error ? reject(error) : resolve(address.port)); + }); + }); + if (preferred) { + try { return await reserve(preferred); } catch { /* Never kill whoever already owns the old port. */ } + } + return await reserve(0); +} + +// App-owned code, passed as a Node argument. No project script or shell is evaluated. +const STATIC_RUNNER = String.raw` +const http = require('node:http'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const [root, entry, port, token, identity] = process.argv.slice(1); +const mime = {'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.mjs':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json','.svg':'image/svg+xml','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.gif':'image/gif','.ico':'image/x-icon','.mp3':'audio/mpeg','.wav':'audio/wav','.ogg':'audio/ogg','.mp4':'video/mp4','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf','.wasm':'application/wasm','.txt':'text/plain; charset=utf-8'}; +async function safeFile(parts) { + let file = root; + for (const part of parts) { + file = path.join(file, part); + if ((await fs.lstat(file)).isSymbolicLink()) throw new Error('forbidden'); + } + const resolved = await fs.realpath(file); + const relative = path.relative(root, resolved); + if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw new Error('forbidden'); + if (!(await fs.stat(resolved)).isFile()) throw new Error('missing'); + return resolved; +} +http.createServer(async (req, res) => { + try { + if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); res.end(); return; } + const pathname = decodeURIComponent((req.url || '/').split('?')[0]); + if (!pathname.startsWith('/') || pathname.includes('\\') || pathname.includes('\0')) throw new Error('forbidden'); + let parts = pathname.slice(1).split('/').filter(Boolean); + if (parts.some(part => part === '..' || part.startsWith('.'))) throw new Error('forbidden'); + if (parts.length === 0) parts = entry.split('/'); + else if (pathname.endsWith('/')) parts.push('index.html'); + const file = await safeFile(parts); + const bytes = await fs.readFile(file); + res.writeHead(200, {'content-type':mime[path.extname(file).toLowerCase()] || 'application/octet-stream','content-length':bytes.length,'cache-control':'no-store','x-makelore-work':token,'x-makelore-work-project':identity,'x-content-type-options':'nosniff'}); + res.end(req.method === 'HEAD' ? undefined : bytes); + } catch (error) { + res.writeHead(error.message === 'forbidden' ? 403 : 404); res.end(); + } +}).listen(Number(port), '127.0.0.1', () => console.log('Makelore preview pid=' + process.pid)); +`; + +// Use the installed Vite runtime/config with app-owned options and report its resolved base. +const VITE_RUNNER = String.raw` +const http = require('node:http'); +const {pathToFileURL} = require('node:url'); +const path = require('node:path'); +const fs = require('node:fs/promises'); +const [token, identity, cli, projectRoot, ...args] = process.argv.slice(1); +const emit = http.Server.prototype.emit; +http.Server.prototype.emit = function(event, ...values) { + if (event === 'request') { + values[1].setHeader('x-makelore-work', token); + values[1].setHeader('x-makelore-work-project', identity); + } + return emit.call(this, event, ...values); +}; +const value = flag => args[args.indexOf(flag) + 1]; +(async () => { + const vite = await import(pathToFileURL(path.resolve(path.dirname(cli), '../dist/node/index.js')).href); + const options = {clearScreen:false,server:{host:'127.0.0.1',port:Number(value('--port')),strictPort:true,open:false},optimizeDeps:{force:args.includes('--force')}}; + if (args.includes('--base')) options.base = value('--base'); + if (args.includes('--mode')) options.mode = value('--mode'); + const server = await vite.createServer(options); + const root = await fs.realpath(server.config.root); + const relative = path.relative(projectRoot, root); + if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw new Error('Vite root is outside this project'); + await server.listen(); + const origin = 'http://127.0.0.1:' + value('--port'); + const url = new URL(server.config.base || '/', origin + '/'); + if (url.origin !== origin || url.username || url.password) throw new Error('Vite base is outside this local server'); + console.log('Makelore ready pid=' + process.pid + ' url=' + url.href); + if (!process.send) throw new Error('Missing launch readiness channel'); + process.send({token,url:url.href}, () => process.disconnect()); +})().catch(error => { console.error(error); process.exit(1); }); +`; + +async function previewLog(root: string, kind: WorkLaunchSpec['kind']) { + let directory = root; + for (const part of ['.makelore', 'logs']) { + directory = path.join(directory, part); + await mkdir(directory).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error; }); + const stat = await lstat(directory); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('unsafe log directory'); + } + const logPath = path.join(directory, `${kind}-preview.log`); + const previous = await lstat(logPath).catch(() => null); + if (previous && (previous.isSymbolicLink() || !previous.isFile())) throw new Error('unsafe log file'); + return await open(logPath, + constants.O_CREAT | constants.O_APPEND | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0), 0o600); +} + +function childEnvironment(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ELECTRON_RUN_AS_NODE: '1', CI: '1', NO_COLOR: '1' }; + for (const key of ['PATH', 'HOME', 'USERPROFILE', 'SYSTEMROOT', 'COMSPEC', 'TEMP', 'TMP', 'APPDATA', 'LOCALAPPDATA']) { + if (process.env[key] !== undefined) env[key] = process.env[key]; + } + return env; +} + +type PreviewPage = { status?: number; headers: IncomingHttpHeaders; body: string }; + +function readPage(url: string, maxBytes = 256 * 1024): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (page: PreviewPage | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(page); + }; + const timer = setTimeout(() => { request.destroy(); finish(null); }, 800); + const request = get(url, (response) => { + let body = ''; + let receivedBytes = 0; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + receivedBytes += Buffer.byteLength(chunk, 'utf8'); + if (receivedBytes > maxBytes) { request.destroy(); finish(null); return; } + body += chunk; + }); + response.once('error', () => finish(null)); + response.once('end', () => finish({ status: response.statusCode, headers: response.headers, body })); + }); + request.once('error', () => finish(null)); + }); +} + +function workIdentity(root: string, directory: string): string { + return createHash('sha256').update(`${root}\0${directory}`).digest('hex'); +} + +function hasExactModuleSource(body: string, source: string): boolean { + const fullSource = source.trim(); + if (!fullSource) return false; + if (body.includes(fullSource)) return true; + const maps = [...body.matchAll(/\/\/[#@]\s*sourceMappingURL=data:application\/json(?:;charset=[\w-]+)?;base64,([A-Za-z0-9+/=]+)/g)]; + const lastMap = maps.at(-1)?.[1]; + if (!lastMap || lastMap.length > 2 * 1024 * 1024) return false; + try { + const map = JSON.parse(Buffer.from(lastMap, 'base64').toString('utf8')) as { sourcesContent?: unknown }; + return Array.isArray(map.sourcesContent) && map.sourcesContent.some((content) => content === source); + } catch { return false; } +} + +function exactRawModule(body: string, source: string): boolean { + const exported = body.match(/^\s*export\s+default\s+("(?:[^"\\]|\\.)*")\s*;?\s*(?:\/\/[#@]\s*sourceMappingURL=[^\r\n]*\s*)?$/); + if (!exported) return false; + try { return JSON.parse(exported[1]) === source; } catch { return false; } +} + +const TEXT_MODULE_EXTENSION = /\.(?:[cm]?js|jsx|[cm]?ts|tsx|css|json|svg|glsl|html?|txt)$/i; + +async function matchesLocalModule( + directory: string, + relative: string, + address: URL, + visited: Set, + depth: number, +): Promise { + if (visited.has(relative)) return true; + if (depth > 4 || visited.size >= 20) return false; + visited.add(relative); + try { + const file = await safeExistingPath(directory, relative, 'file'); + if ((await lstat(file)).size > 512 * 1024) return false; + const source = await readFile(file, 'utf8'); + const rawAddress = new URL(address); + rawAddress.search = '?raw'; + const raw = await readPage(rawAddress.href, 2 * 1024 * 1024); + if (!raw || raw.status !== 200 || !exactRawModule(raw.body, source)) { + const response = await readPage(address.href, 2 * 1024 * 1024); + if (!response || response.status !== 200 || !hasExactModuleSource(response.body, source)) return false; + } + // Exact entry text alone is insufficient when another local module contains the game. + const specifiers = [...source.matchAll(/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)["']([^"']+)["']/g)].map((match) => match[1]); + if (specifiers.some((specifier) => /^(?:@\/|~\/|\/)/.test(specifier)) + || /\bimport\s*\(\s*([^'"\s])/.test(source)) return false; + const imports = specifiers.filter((specifier) => /^\.{1,2}\//.test(specifier)); + const matches = await Promise.all(imports.map(async (specifier) => { + const local = decodeURIComponent(specifier.split(/[?#]/)[0]); + let imported = path.posix.normalize(path.posix.join(path.posix.dirname(relative), local)); + const extension = path.posix.extname(imported); + if (extension && !TEXT_MODULE_EXTENSION.test(imported)) return true; + if (!extension) { + const candidates = [imported, ...['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'].flatMap((suffix) => [imported + suffix, `${imported}/index${suffix}`])]; + const present: string[] = []; + for (const candidate of candidates) { + try { await safeExistingPath(directory, candidate, 'file'); present.push(candidate); } catch { /* Unknown imports cannot establish identity. */ } + } + if (present.length !== 1) return false; + imported = present[0]; + } + const importedAddress = new URL(local, address); + if (!extension) { + const original = path.posix.normalize(path.posix.join(path.posix.dirname(relative), local)); + importedAddress.pathname += imported.slice(original.length); + } + return await matchesLocalModule(directory, imported, importedAddress, visited, depth + 1); + })); + return matches.every(Boolean); + } catch { return false; } +} + +async function matchesEntryModule(directory: string, module: string, pageUrl: string, spec: WorkLaunchSpec, visited: Set): Promise { + try { + const address = new URL(module, pageUrl); + if (address.origin !== new URL(pageUrl).origin || address.username || address.password) return false; + let relative = decodeURIComponent(module.split(/[?#]/)[0]); + if (relative.startsWith('/')) { + const base = spec.kind === 'vite' ? argumentValue(spec.args, '--base') : undefined; + if (base?.startsWith('/') && relative.startsWith(base)) relative = relative.slice(base.length); + else relative = relative.slice(1); + } + if (relative.startsWith('./')) relative = relative.slice(2); + if (!/\.(?:[cm]?js|jsx|ts|tsx)$/.test(relative)) return false; + return await matchesLocalModule(directory, relative, address, visited, 0); + } catch { return false; } +} + +/** Check a saved address against this project's files before reusing its local service. */ +export async function workLaunchMatches(project: WorkEntryProject, spec: WorkLaunchSpec, url: string): Promise { + if (previousPort(url) === undefined) return false; + try { + const root = await projectRoot(project.path); + const directory = await safeExistingPath(root, spec.directory, 'directory'); + const entry = await safeExistingPath(directory, spec.kind === 'static' ? spec.entry : 'index.html', 'file'); + const page = await readPage(url); + if (!page || page.status !== 200 || !String(page.headers['content-type']).includes('text/html')) return false; + if (page.headers['x-makelore-work-project'] === workIdentity(root, directory)) return true; + const source = await readFile(entry, 'utf8'); + if (spec.kind === 'static') return page.body === source; + // Older, manually opened Vite services do not yet have the app's ownership header. + const title = source.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim(); + const actualTitle = page.body.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim(); + const modules = [...source.matchAll(/]*)>/gi)].flatMap((match) => { + if (!/\btype\s*=\s*["']module["']/i.test(match[1])) return []; + const module = match[1].match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1]; + return module ? [module] : []; + }); + if (!title || title !== actualTitle || modules.length === 0 || modules.length > 8 || !page.body.includes('/@vite/client')) return false; + const visited = new Set(); + const matches = await Promise.all(modules.map((module) => matchesEntryModule(directory, module, url, spec, visited))); + return matches.every(Boolean); + } catch { return false; } +} + +function terminateOwnedProcess(child: ChildProcess): void { + if (!child.pid || child.exitCode !== null) return; + if (process.platform !== 'win32') { + try { process.kill(-child.pid, 'SIGTERM'); return; } catch { /* The child may already have exited. */ } + } + child.kill(); +} + +/** Cancel only a launch owned by this Main process, never an arbitrary saved address. */ +export async function stopWorkLaunch(project: WorkEntryProject, url: string): Promise { + const root = await projectRoot(project.path).catch(() => null); + if (!root) return; + const key = `${root}\0${url}`; + const child = ownedLaunches.get(key); + if (!child) return; + ownedLaunches.delete(key); + terminateOwnedProcess(child); +} + +export async function startWorkLaunch(project: WorkEntryProject, spec: WorkLaunchSpec, previousUrl?: string): Promise { + const root = await projectRoot(project.path); + const directory = await safeExistingPath(root, spec.directory, 'directory'); + let args: string[]; + let base = '/'; + const token = randomUUID(); + const identity = workIdentity(root, directory); + if (spec.kind === 'vite') { + const pkg = await packageRecord(directory); + const recognized = pkg && devArgs(pkg); + if (!recognized || JSON.stringify(recognized) !== JSON.stringify(spec.args)) { + throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '作品的启动方式变了,需要帮忙检查。'); + } + await safeExistingPath(directory, 'index.html', 'file'); + const cli = await viteCli(root, directory); + args = ['-e', VITE_RUNNER, token, identity, cli, root, + ...spec.args.filter((value, index) => value !== '--port' && spec.args[index - 1] !== '--port' && value !== '--strictPort')]; + const configuredBase = argumentValue(spec.args, '--base'); + if (configuredBase?.startsWith('/')) base = configuredBase.endsWith('/') ? configuredBase : `${configuredBase}/`; + } else if (spec.kind === 'static') { + if (await packageRecord(directory)) throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品需要先确认启动方式。'); + await safeExistingPath(directory, spec.entry, 'file'); + if (!/\.html?$/i.test(spec.entry)) throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的网页入口找不到了。'); + args = ['-e', STATIC_RUNNER, directory, spec.entry]; + } else { + throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。'); + } + let child: ChildProcess | undefined; + try { + const port = await availablePort(previousPort(previousUrl) ?? (spec.kind === 'vite' ? Number(argumentValue(spec.args, '--port')) || undefined : undefined)); + let url = `http://127.0.0.1:${port}${base}`; + let readyUrl = spec.kind === 'static' ? url : undefined; + args.push(...(spec.kind === 'vite' ? ['--host', '127.0.0.1', '--port', String(port), '--strictPort'] : [String(port), token, identity])); + const log = await previewLog(root, spec.kind); + try { + child = spawn(process.execPath, args, { + cwd: directory, env: childEnvironment(), shell: false, detached: true, + windowsHide: true, stdio: spec.kind === 'vite' ? ['ignore', log.fd, log.fd, 'ipc'] : ['ignore', log.fd, log.fd], + }); + child.on('message', (message: unknown) => { + if (!message || typeof message !== 'object') return; + const value = message as { token?: unknown; url?: unknown }; + if (value.token !== token || typeof value.url !== 'string' || value.url.length > 4_096) return; + try { + const address = new URL(value.url); + if (address.origin === `http://127.0.0.1:${port}` && !address.username && !address.password) readyUrl = address.href; + } catch { /* Ignore untrusted or malformed child messages. */ } + }); + await new Promise((resolve, reject) => { child!.once('spawn', resolve); child!.once('error', reject); }); + await log.write(`\nMakelore launch pid=${child.pid} url=${url}\n`); + } finally { await log.close(); } + let failed = false; + child.once('error', () => { failed = true; }); + const deadline = Date.now() + START_TIMEOUT_MS; + while (Date.now() < deadline) { + if (failed || child.exitCode !== null || child.signalCode !== null) break; + if (readyUrl) url = readyUrl; + const page = readyUrl ? await readPage(url) : null; + if (page?.status === 200 && String(page.headers['content-type']).includes('text/html') + && page.headers['x-makelore-work'] === token) { + const key = `${root}\0${url}`; + const ownedChild = child; + ownedLaunches.set(key, ownedChild); + ownedChild.once('exit', () => { if (ownedLaunches.get(key) === ownedChild) ownedLaunches.delete(key); }); + child.unref(); + return url; + } + await new Promise((resolve) => setTimeout(resolve, 80)); + } + throw new Error('preview did not become ready'); + } catch { + if (child) terminateOwnedProcess(child); + throw new WorkLaunchError('WORK_PREVIEW_START_FAILED', '作品没有成功启动,可以帮忙检查启动记录。'); + } +} diff --git a/electron/coding-runtime/work-preview.ts b/electron/coding-runtime/work-preview.ts index a674e1b1..85d616c4 100644 --- a/electron/coding-runtime/work-preview.ts +++ b/electron/coding-runtime/work-preview.ts @@ -5,6 +5,10 @@ import { OPEN_WORK_PROMPT, type WorkPreviewResult } from '../../shared/coding-wo import { codingRecovery } from '../../shared/coding-recovery'; import type { AgentBrowserSnapshot } from '../../shared/agent-browser'; import { inspectWorkPresence, type WorkPresence } from './work-presence'; +import { loadWorkEntry, saveWorkEntry } from './work-entry-store'; +import { discoverWorkLaunch, startWorkLaunch, stopWorkLaunch, workLaunchMatches } from './work-launcher'; +import type { WorkEntry, WorkLaunchSpec } from './work-entry-contracts'; +import { createHash } from 'node:crypto'; type Project = { id: string; path: string }; type Attempt = { @@ -16,6 +20,15 @@ type Attempt = { result?: WorkPreviewResult; }; +function sameWorkServer(left: string, right: string): boolean { + try { + const a = new URL(left); + const b = new URL(right); + const local = (url: URL) => ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + return local(a) && local(b) && a.protocol === b.protocol && a.port === b.port; + } catch { return false; } +} + /** Only probe a known loopback work address; never scan ports or follow redirects. */ export async function workPageResponds(address: string): Promise { try { @@ -31,12 +44,42 @@ export async function workPageResponds(address: string): Promise { } } +/** Identity for pages whose launch method is unknown; only bounded local HTML. */ +export async function workPageSignature(address: string): Promise { + try { + const url = new URL(address); + if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + || url.username || url.password) return null; + const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) }); + if (!response.ok || !response.headers.get('content-type')?.includes('text/html') || !response.body) { + await response.body?.cancel(); + return null; + } + const reader = response.body.getReader(); + const hash = createHash('sha256'); + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return hash.digest('hex'); + bytes += value.byteLength; + if (bytes > 256 * 1024) return null; + hash.update(value); + } + } finally { await reader.cancel().catch(() => undefined); } + } catch { return null; } +} + export class CodingWorkPreviewService { private readonly attempts = new Map(); private readonly flights = new Map>(); + private readonly flightHelp = new Map(); // Native views are disposable during background sleep; the running project // server and its verified address have a separate lifetime. private readonly pages = new Map(); + private readonly pageRevisions = new Map(); + private readonly rememberedWrites = new Map>(); + private readonly loaded = new Map(); constructor(private readonly deps: { browser: Pick; @@ -45,55 +88,106 @@ export class CodingWorkPreviewService { ensureActive(project: Project): Promise; responds?(url: string): Promise; inspectWork?(projectPath: string): Promise; + entries?: { load: typeof loadWorkEntry; save: typeof saveWorkEntry }; + launcher?: { discover: typeof discoverWorkLaunch; start: typeof startWorkLaunch; matches?: typeof workLaunchMatches; stop?: typeof stopWorkLaunch }; + storedSnapshot?: CodingConversationService['getStoredSnapshot']; + signature?(url: string): Promise; }) {} - remember(project: Project, snapshot: AgentBrowserSnapshot): void { + remember(project: Project, snapshot: AgentBrowserSnapshot, launch?: WorkLaunchSpec): Promise { if (snapshot.projectId !== project.id || !snapshot.url || !snapshot.browserId - || snapshot.state !== 'attached' || snapshot.error) return; + || snapshot.state !== 'attached' || snapshot.error) return Promise.resolve(); this.pages.delete(project.id); if (this.pages.size >= 100) this.pages.delete(this.pages.keys().next().value!); this.pages.set(project.id, { path: project.path, url: snapshot.url }); + this.pageRevisions.set(project.id, (this.pageRevisions.get(project.id) ?? 0) + 1); this.attempts.delete(project.id); // A successful manual/Agent open supersedes a slow older startup check. this.flights.delete(project.id); + const previous = this.rememberedWrites.get(project.id); + const write = (async () => { + await previous?.catch(() => undefined); + const recipe = launch ?? await (this.deps.launcher?.discover ?? discoverWorkLaunch)(project.path).catch(() => null); + const documentHash = await (this.deps.signature ?? workPageSignature)(snapshot.url); + const entry: WorkEntry = { version: 1, projectId: project.id, projectPath: project.path, url: snapshot.url, + ...(documentHash ? { documentHash } : {}), + ...(recipe ? { launch: recipe } : {}) }; + this.loaded.set(project.id, entry); + await (this.deps.entries?.save ?? saveWorkEntry)(project, entry); + })(); + this.rememberedWrites.set(project.id, write); + // Manual opens remain usable if project metadata temporarily cannot be saved. + void write.catch(() => undefined); + return write; } - async ensure(project: Project, requestId: string, conversationId?: string): Promise { + async ensure(project: Project, requestId: string, conversationId?: string, allowAssistant = false): Promise { await this.deps.ensureActive(project); // Serialize browser checks as well as prompt submission for each project. const existing = this.flights.get(project.id); - if (existing) return existing; - const flight = this.check(project, requestId, conversationId); + if (existing) { + if (allowAssistant && !this.flightHelp.get(project.id)) { + await existing; + return this.ensure(project, requestId, conversationId, true); + } + return existing; + } + const flight = this.check(project, requestId, conversationId, allowAssistant).catch((error: unknown) => { + const code = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code : 'WORK_PREVIEW_START_FAILED'; + if (['PROJECT_NOT_ACTIVE', 'PROJECT_MISMATCH'].includes(code)) throw error; + const result = { status: 'failed' as const, errorCode: code, message: codingRecovery(code, true).message }; + const attempt = this.attempts.get(project.id); + if (attempt?.requestId === requestId) attempt.result = result; + return result; + }); this.flights.set(project.id, flight); + this.flightHelp.set(project.id, allowAssistant); try { return await flight; } - finally { if (this.flights.get(project.id) === flight) this.flights.delete(project.id); } + finally { if (this.flights.get(project.id) === flight) { this.flights.delete(project.id); this.flightHelp.delete(project.id); } } } - private async check(project: Project, requestId: string, conversationId?: string): Promise { + private async check(project: Project, requestId: string, conversationId?: string, allowAssistant = false): Promise { const { browser, conversations } = this.deps; + const pageRevision = this.pageRevisions.get(project.id) ?? 0; + const superseded = () => (this.pageRevisions.get(project.id) ?? 0) !== pageRevision; + await this.rememberedWrites.get(project.id)?.catch(() => undefined); + if (!this.loaded.has(project.id) || this.loaded.get(project.id)?.projectPath !== project.path) { + const loaded = await (this.deps.entries?.load ?? loadWorkEntry)(project); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); + this.loaded.set(project.id, loaded); + } + const saved = this.loaded.get(project.id); const snapshot = await browser.getSnapshot(project.path); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); const responds = this.deps.responds ?? workPageResponds; const remembered = this.pages.get(project.id); const address = snapshot.projectId === project.id && snapshot.url ? snapshot.url - : remembered?.path === project.path ? remembered.url : undefined; - if (address && await responds(address)) { + : remembered?.path === project.path ? remembered.url : saved?.url; + const matches = this.deps.launcher?.matches ?? workLaunchMatches; + const addressMatches = async () => saved?.launch ? await matches(project, saved.launch, address!) + : saved?.documentHash ? await (this.deps.signature ?? workPageSignature)(address!) === saved.documentHash + : Boolean(remembered?.path === project.path || (snapshot.projectId === project.id && snapshot.url)); + if (address && await responds(address) && await addressMatches()) { await this.deps.ensureActive(project); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); const ready = snapshot.projectId === project.id && snapshot.url === address && snapshot.browserId && snapshot.state === 'attached' && !snapshot.error ? snapshot : await browser.open({ projectId: project.id, projectPath: project.path, url: address, visible: false }); await this.deps.ensureActive(project); - this.remember(project, ready); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); + await this.remember(project, ready, saved?.launch).catch(() => undefined); return { status: 'ready', browser: ready }; } - if (this.pages.has(project.id) && this.pages.get(project.id) !== remembered) return this.check(project, requestId, conversationId); + if (this.pages.has(project.id) && this.pages.get(project.id) !== remembered) return this.check(project, requestId, conversationId, allowAssistant); this.pages.delete(project.id); await this.deps.ensureActive(project); let attempt = this.attempts.get(project.id); if (attempt?.submitted && attempt.conversationId) { const current = await conversations.getSnapshot(attempt.conversationId); - if (this.pages.has(project.id)) return this.check(project, requestId, conversationId); + if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); if (!['idle', 'error'].includes(current.run.status)) { return { status: 'starting', conversation: await conversations.getConversation(attempt.conversationId) }; } @@ -120,13 +214,94 @@ export class CodingWorkPreviewService { if (busy()) return { status: 'waiting' }; const presence = await (this.deps.inspectWork ?? inspectWorkPresence)(project.path); await this.deps.ensureActive(project); - if (this.pages.has(project.id)) return this.check(project, requestId, conversationId); + if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); if (busy()) return { status: 'waiting' }; if (presence === 'empty') { // No cached terminal attempt: the same tab/request can discover newly made work. this.attempts.delete(project.id); return { status: 'empty' }; } + // Upgrade existing projects from successful browser tool receipts, never + // assistant prose or an arbitrary link, and never start Pi to read them. + let discoveryError: unknown; + const launch = saved?.launch ?? await (this.deps.launcher?.discover ?? discoverWorkLaunch)(project.path).catch((error: unknown) => { + discoveryError = error; + return null; + }); + if (!saved && this.deps.storedSnapshot) { + for (const item of list.slice(0, 20)) { + const history = await this.deps.storedSnapshot(item.id).catch(() => null); + await this.deps.ensureActive(project); + if (!history || history.conversation.projectId !== project.id) continue; + for (const node of [...history.nodes].reverse()) { + if (node.kind !== 'tool' || node.status !== 'complete' || node.toolName !== 'agent_browser' + || node.details?.schema !== 'agent-browser.v1' || node.details.action !== 'open') continue; + const text = node.output.filter((block) => block.kind === 'text').map((block) => block.text).join(''); + if (text.length > 16_384) continue; + let known: AgentBrowserSnapshot; + try { known = JSON.parse(text); } catch { continue; } + if (!known || typeof known !== 'object') continue; + if (known.projectId !== project.id || known.state !== 'attached' || !known.browserId || !known.url + || !launch || !(await responds(known.url)) || !(await matches(project, launch, known.url))) continue; + await this.deps.ensureActive(project); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); + const restored = await browser.open({ projectId: project.id, projectPath: project.path, url: known.url, visible: false }); + await this.deps.ensureActive(project); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); + await this.remember(project, restored, launch ?? undefined).catch(() => undefined); + return { status: 'ready', browser: restored }; + } + } + } + if (discoveryError && !allowAssistant) throw discoveryError; + await this.deps.ensureActive(project); + if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); + if (busy()) return { status: 'waiting' }; + if (launch) { + try { + const url = await (this.deps.launcher?.start ?? startWorkLaunch)(project, launch, address); + const stopUnused = async () => { + const newer = this.pages.get(project.id); + if (!newer || !sameWorkServer(newer.url, url)) await (this.deps.launcher?.stop ?? stopWorkLaunch)(project, url); + }; + if (superseded()) { + await stopUnused(); + return this.check(project, requestId, conversationId, allowAssistant); + } + // Keep the verified server associated with its own project even if the + // student switches projects while it starts. Only UI attachment is gated. + const startedEntry: WorkEntry = { version: 1, projectId: project.id, projectPath: project.path, url, launch }; + this.loaded.set(project.id, startedEntry); + const previousWrite = this.rememberedWrites.get(project.id); + const startedWrite = (async () => { + await previousWrite?.catch(() => undefined); + await (this.deps.entries?.save ?? saveWorkEntry)(project, startedEntry); + })(); + this.rememberedWrites.set(project.id, startedWrite); + await startedWrite.catch(() => undefined); + if (superseded()) { + await stopUnused(); + return this.check(project, requestId, conversationId, allowAssistant); + } + await this.deps.ensureActive(project); + if (superseded()) { + await stopUnused(); + return this.check(project, requestId, conversationId, allowAssistant); + } + const opened = await browser.open({ projectId: project.id, projectPath: project.path, url, visible: false }); + await this.deps.ensureActive(project); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); + await this.remember(project, opened, launch).catch(() => undefined); + return { status: 'ready', browser: opened }; + } catch (error) { + if (!allowAssistant || (error && typeof error === 'object' && 'code' in error + && ['PROJECT_NOT_ACTIVE', 'PROJECT_MISMATCH'].includes(String(error.code)))) throw error; + // The user chose assistance for a known launch failure. The assistant + // may inspect it; merely reopening the tab never takes this path. + } + } + if (!allowAssistant) return { status: 'failed', errorCode: 'WORK_PREVIEW_NEEDS_HELP', + message: codingRecovery('WORK_PREVIEW_NEEDS_HELP', true).message }; let conversation = attempt.conversationId ? await conversations.getConversation(attempt.conversationId) : list.find((item) => !item.archivedAt); @@ -134,7 +309,7 @@ export class CodingWorkPreviewService { conversation ??= await conversations.createConversation({ projectId: project.id, title: '新对话' }); attempt.conversationId = conversation.id; const current = await conversations.getSnapshot(conversation.id); - if (this.pages.has(project.id)) return this.check(project, requestId, conversationId); + if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); if (!['idle', 'error'].includes(current.run.status)) return { status: 'waiting', conversation }; await this.deps.ensureActive(project); // Mark before dispatch: uncertain acceptance must never cause automatic resubmission. diff --git a/shared/coding-recovery.ts b/shared/coding-recovery.ts index d5a3d6a8..2d19b0c7 100644 --- a/shared/coding-recovery.ts +++ b/shared/coding-recovery.ts @@ -1,7 +1,19 @@ -export type CodingRecoveryAction = 'login' | 'continue' | 'check-work' | 'refresh' | 'settings' | 'account'; +export type CodingRecoveryAction = 'login' | 'continue' | 'check-work' | 'assist-work' | 'refresh' | 'settings' | 'account'; /** Product copy is intentionally independent of raw provider/tool diagnostics. */ export function codingRecovery(errorCode?: string, work = false) { + if (work && errorCode === 'WORK_PREVIEW_NEEDS_HELP') { + return { action: 'assist-work' as const, label: '让助手检查', message: '还没找到这个作品的打开方式,可以让助手检查已有文件和启动情况。' }; + } + if (work && errorCode === 'WORK_PREVIEW_DEPENDENCIES_MISSING') { + return { action: 'assist-work' as const, label: '让助手检查', message: '这个作品缺少运行组件,可以让助手检查并帮你准备。' }; + } + if (work && errorCode === 'WORK_PREVIEW_ENTRY_MISSING') { + return { action: 'assist-work' as const, label: '让助手检查', message: '之前的作品入口找不到了,可能移动或修改过文件,可以让助手重新检查。' }; + } + if (work && errorCode === 'WORK_PREVIEW_START_FAILED') { + return { action: 'check-work' as const, label: '重新打开作品', message: '作品没能启动。可以重新打开,或让助手检查原因。' }; + } if (errorCode === 'CODING_PROVIDER_AUTH_REQUIRED' || errorCode === 'AUTH_INVALID') { return { action: 'login' as const, label: '登录并继续', message: '登录已失效,重新登录后就能接着完成这一步。' }; } diff --git a/src/lib/coding-work-preview.ts b/src/lib/coding-work-preview.ts index da5ed50c..4f962365 100644 --- a/src/lib/coding-work-preview.ts +++ b/src/lib/coding-work-preview.ts @@ -1,9 +1,10 @@ import { hostApiFetch } from './host-api'; import type { WorkPreviewResult } from '../../shared/coding-work-preview'; -export function ensureWorkPreview(projectId: string, requestId: string, conversationId?: string | null): Promise { +export function ensureWorkPreview(projectId: string, requestId: string, conversationId?: string | null, allowAssistant = false): Promise { return hostApiFetch('/api/agent-browser/ensure-work', { method: 'POST', - body: JSON.stringify({ project_id: projectId, request_id: requestId, conversation_id: conversationId }), + body: JSON.stringify({ project_id: projectId, request_id: requestId, conversation_id: conversationId, + ...(allowAssistant ? { allow_assistant: true } : {}) }), }); } diff --git a/src/pages/Chat/AgentBrowserPanel.tsx b/src/pages/Chat/AgentBrowserPanel.tsx index f2384c77..d4996ffa 100644 --- a/src/pages/Chat/AgentBrowserPanel.tsx +++ b/src/pages/Chat/AgentBrowserPanel.tsx @@ -567,10 +567,12 @@ export function AgentBrowserPanel({ : '输入 URL,或等待智能体打开调试页面'}

} {embedded &&
{!workEmpty && (presentationFailed ? : work.result?.status === 'failed' && )} + {!workEmpty && work.result?.errorCode === 'WORK_PREVIEW_START_FAILED' && }
} diff --git a/src/pages/Chat/use-work-preview.ts b/src/pages/Chat/use-work-preview.ts index d6b352e4..44faccf2 100644 --- a/src/pages/Chat/use-work-preview.ts +++ b/src/pages/Chat/use-work-preview.ts @@ -17,6 +17,7 @@ export function useWorkPreview(input: { const onResult = useRef(input.onResult); const conversationId = useRef(input.conversationId); const manualRevision = useRef(0); + const helpAttempt = useRef<{ projectId: string | null; version: string } | null>(null); const checkRef = useRef<(() => Promise) | null>(null); useEffect(() => { onResult.current = input.onResult; @@ -26,6 +27,7 @@ export function useWorkPreview(input: { if (!input.enabled || !input.projectId) return; const projectId = input.projectId; const version = `${revision}:${input.revision ?? 0}`; + const allowAssistant = helpAttempt.current?.projectId === projectId && helpAttempt.current.version === version; if (attempt.current?.projectId !== projectId || attempt.current.revision !== version) { attempt.current = { projectId, revision: version, requestId: crypto.randomUUID(), transportFailures: 0 }; } @@ -43,7 +45,9 @@ export function useWorkPreview(input: { const requestedAtRevision = manualRevision.current; inFlightRevision = requestedAtRevision; try { - const next = await ensureWorkPreview(projectId, requestId, conversationId.current); + const next = allowAssistant + ? await ensureWorkPreview(projectId, requestId, conversationId.current, true) + : await ensureWorkPreview(projectId, requestId, conversationId.current); if (cancelled || manualRevision.current !== requestedAtRevision) return; attempt.current!.transportFailures = 0; setResult(next); @@ -52,15 +56,18 @@ export function useWorkPreview(input: { } catch (error) { if (cancelled || manualRevision.current !== requestedAtRevision) return; const failure = normalizeAppError(error); - const transient = failure.code === 'NETWORK' || failure.code === 'TIMEOUT'; + const backendCode = failure.details?.backendCode; + const errorCode = typeof backendCode === 'string' && /^(CODING_|WORK_PREVIEW_)/.test(backendCode) + ? backendCode : failure.code; + const transient = errorCode === 'NETWORK' || errorCode === 'TIMEOUT'; const failures = ++attempt.current!.transportFailures; if (transient && failures <= 2) { setResult({ status: 'starting', message: '连接暂时断开,正在重新连接…' }); timer = setTimeout(check, failures * 2_000); } else { - setResult({ status: 'failed', errorCode: failure.code, message: transient + setResult({ status: 'failed', errorCode, message: transient ? '连接还没有恢复。检查网络后,点“再试一次”即可。' - : codingRecovery(failure.code, true).message }); + : codingRecovery(errorCode, true).message }); } } finally { if (inFlightRevision === requestedAtRevision) inFlightRevision = null; @@ -95,5 +102,9 @@ export function useWorkPreview(input: { void checkRef.current?.(); } else setRevision((value) => value + 1); }; - return { result, showBrowser, restore, retry }; + const requestHelp = () => { + helpAttempt.current = { projectId: input.projectId, version: `${revision + 1}:${input.revision ?? 0}` }; + setRevision((value) => value + 1); + }; + return { result, showBrowser, restore, retry, requestHelp }; } diff --git a/tests/e2e/work-entry-restoration.spec.ts b/tests/e2e/work-entry-restoration.spec.ts new file mode 100644 index 00000000..0f6faeb4 --- /dev/null +++ b/tests/e2e/work-entry-restoration.spec.ts @@ -0,0 +1,74 @@ +import type { Page } from '@playwright/test'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { expect, getStableWindow, test } from './fixtures/electron'; +import type { WorkPreviewResult } from '../../shared/coding-work-preview'; + +async function hostRequest(page: Page, path: string, body?: unknown): Promise { + const response = await page.evaluate(async (request) => await window.electron.ipcRenderer.invoke('hostapi:fetch', request) as { + ok: boolean; data?: { ok: boolean; json?: unknown }; + }, { path, method: body === undefined ? 'GET' : 'POST', body }); + expect(response, `${path}: ${JSON.stringify(response)}`).toMatchObject({ ok: true, data: { ok: true } }); + return response.data!.json as T; +} + +test('existing work survives a full app restart and restarts a stopped service without choosing a model', async ({ launchElectronApp, electronTestResources }) => { + const projectPath = join(electronTestResources.rootDir, 'remembered-work'); + await mkdir(projectPath); + const html = 'Remembered project work

My existing game

'; + await writeFile(join(projectPath, 'index.html'), html); + const server = createServer((_request, response) => { + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + response.end(html); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing test port'); + const url = `http://127.0.0.1:${address.port}/`; + let serverClosed = false; + try { + const first = await launchElectronApp({ skipSetup: true }); + let page = await getStableWindow(first); + const { project } = await hostRequest<{ project: { id: string } }>(page, '/api/coding/projects/open', { projectPath }); + const listPath = `/api/coding/projects/conversations?project_id=${project.id}`; + const before = await hostRequest<{ conversations: unknown[] }>(page, listPath); + await page.evaluate(() => window.electron.ipcRenderer.invoke('lifecycle:activity', { visible: true, module: 'programming' })); + await hostRequest(page, '/api/agent-browser/open', { project_id: project.id, url, visible: false, + bounds: { x: 20, y: 70, width: 420, height: 300 } }); + const record = JSON.parse(await readFile(join(projectPath, '.makelore/work-entry.json'), 'utf8')); + expect(record).toMatchObject({ projectId: project.id, url, launch: { kind: 'static', directory: '.', entry: 'index.html' } }); + await first.close(); + + const restarted = await launchElectronApp({ skipSetup: true }); + page = await getStableWindow(restarted); + await hostRequest(page, '/api/coding/projects/open', { projectPath }); + const restored = await hostRequest(page, '/api/agent-browser/ensure-work', { project_id: project.id, request_id: 'app-restarted' }); + expect(restored).toMatchObject({ status: 'ready', browser: { url, state: 'attached' } }); + expect(await hostRequest(page, listPath)).toEqual(before); + expect(await restarted.evaluate(({ webContents }, expectedUrl) => webContents.getAllWebContents().some((contents) => contents.getURL() === expectedUrl && contents.getTitle() === 'Remembered project work'), url)).toBe(true); + + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + serverClosed = true; + const started = await hostRequest(page, '/api/agent-browser/ensure-work', { project_id: project.id, request_id: 'service-stopped' }); + expect(started).toMatchObject({ status: 'ready', browser: { state: 'attached' } }); + expect(await (await fetch(started.browser!.url)).text()).toBe(html); + expect(await hostRequest(page, listPath)).toEqual(before); + // Use the real native presentation route; this fixture has no authenticated + // cloud account and must not fabricate one to enter the guarded Code UI. + await hostRequest(page, '/api/agent-browser/present', { project_id: project.id, visible: true, + bounds: { x: 20, y: 70, width: 420, height: 300 } }); + expect(await restarted.evaluate(async ({ webContents }, expectedUrl) => { + const contents = webContents.getAllWebContents().find((item) => item.getURL() === expectedUrl); + return await contents?.executeJavaScript('document.querySelector("h1")?.textContent'); + }, started.browser!.url)).toBe('My existing game'); + await page.screenshot({ path: test.info().outputPath('restored-work-without-model.png') }); + } finally { + if (!serverClosed) await new Promise((resolve) => server.close(() => resolve())); + const log = await readFile(join(projectPath, '.makelore/logs/static-preview.log'), 'utf8').catch(() => ''); + for (const match of log.matchAll(/Makelore launch pid=(\d+) /g)) { + const pid = Number(match[1]); + try { process.kill(process.platform === 'win32' ? pid : -pid, 'SIGTERM'); } catch { /* Test child has already exited. */ } + } + } +}); diff --git a/tests/unit/coding-work-preview.test.ts b/tests/unit/coding-work-preview.test.ts index f9e0d31d..eaea6f7f 100644 --- a/tests/unit/coding-work-preview.test.ts +++ b/tests/unit/coding-work-preview.test.ts @@ -9,6 +9,7 @@ import { firstMessageTitle } from '../../electron/coding-runtime/conversation-ti import type { AgentBrowserSnapshot } from '../../shared/agent-browser'; import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts'; import type { CodingConversationV2 } from '../../electron/coding-projects/conversation-store'; +import type { WorkEntry, WorkLaunchSpec } from '../../electron/coding-runtime/work-entry-contracts'; const project = { id: 'p1', path: '/project' }; const conversation = { id: 'c1', archivedAt: null } as CodingConversationV2; @@ -26,8 +27,11 @@ function setup() { const ensureActive = vi.fn(async () => {}); const responds = vi.fn(async () => false); const inspectWork = vi.fn(async (_projectPath: string): Promise => 'possible'); - const service = new CodingWorkPreviewService({ browser, conversations, runtime, ensureActive, responds, inspectWork }); - return { service, browser, conversations, runtime, ensureActive, responds, inspectWork, snapshot }; + const entries = { load: vi.fn(async (): Promise => null), save: vi.fn(async () => {}) }; + const launcher = { discover: vi.fn(async (): Promise => null), start: vi.fn(async () => 'http://127.0.0.1:5173/'), matches: vi.fn(async () => true), stop: vi.fn(async () => {}) }; + const signature = vi.fn(async () => 'a'.repeat(64)); + const service = new CodingWorkPreviewService({ browser, conversations, runtime, ensureActive, responds, inspectWork, entries, launcher, signature }); + return { service, browser, conversations, runtime, ensureActive, responds, inspectWork, snapshot, entries, launcher, signature }; } const directories: string[] = []; @@ -37,6 +41,173 @@ afterEach(async () => { }); describe('opening a work preview', () => { + it('restores a persisted project page in a new service before any model or conversation access', async () => { + const s = setup(); + s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000', documentHash: 'a'.repeat(64) }); + s.responds.mockResolvedValue(true); + s.browser.open.mockResolvedValue({ ...s.snapshot, browserId: 'restored', state: 'attached', url: 'http://localhost:3000' }); + s.conversations.getSnapshot.mockRejectedValue(Object.assign(new Error('no model'), { code: 'CODING_MIGRATION_MODEL_REQUIRED' })); + expect(await s.service.ensure(project, 'after-restart')).toMatchObject({ status: 'ready' }); + expect(s.conversations.listConversations).not.toHaveBeenCalled(); + expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); + expect(s.conversations.createConversation).not.toHaveBeenCalled(); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('restarts saved existing work locally once across simultaneous clicks without a model', async () => { + const s = setup(); + const launch: WorkLaunchSpec = { kind: 'vite', directory: 'game', args: [] }; + s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000', launch }); + s.browser.open.mockResolvedValue({ ...s.snapshot, browserId: 'new', state: 'attached', url: 'http://127.0.0.1:5173/' }); + const results = await Promise.all([s.service.ensure(project, 'one'), s.service.ensure(project, 'two')]); + expect(results.every((result) => result.status === 'ready')).toBe(true); + expect(s.launcher.start).toHaveBeenCalledOnce(); + expect(s.launcher.start).toHaveBeenCalledWith(project, launch, 'http://localhost:3000'); + expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ launch })); + expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); + expect(s.conversations.createConversation).not.toHaveBeenCalled(); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('offers explicit help for an unknown entry and preserves the model-required error when help is chosen', async () => { + const s = setup(); + expect(await s.service.ensure(project, 'automatic', 'c1')).toMatchObject({ status: 'failed', errorCode: 'WORK_PREVIEW_NEEDS_HELP' }); + expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + expect(s.conversations.createConversation).not.toHaveBeenCalled(); + s.conversations.getSnapshot.mockRejectedValue(Object.assign(new Error('no model'), { code: 'CODING_MIGRATION_MODEL_REQUIRED' })); + expect(await s.service.ensure(project, 'help', 'c1', true)).toMatchObject({ status: 'failed', errorCode: 'CODING_MIGRATION_MODEL_REQUIRED', message: expect.stringContaining('选择一个可用模型') }); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('upgrades a legacy project from a successful browser receipt without starting its Agent', async () => { + const s = setup(); + s.launcher.discover.mockResolvedValue({ kind: 'static', directory: '.', entry: 'index.html' }); + const receipt = { ...s.snapshot, browserId: 'previous', state: 'attached', url: 'http://localhost:3000' }; + const storedSnapshot = vi.fn(async () => ({ conversation: { projectId: project.id }, nodes: [{ kind: 'tool', status: 'complete', toolName: 'agent_browser', details: { schema: 'agent-browser.v1', action: 'open' }, output: [{ kind: 'text', text: JSON.stringify(receipt) }] }] }) as ConversationSnapshot); + const service = new CodingWorkPreviewService({ ...s, entries: s.entries, launcher: s.launcher, storedSnapshot }); + s.responds.mockResolvedValue(true); + s.browser.open.mockResolvedValue(receipt as AgentBrowserSnapshot); + expect(await service.ensure(project, 'upgrade')).toMatchObject({ status: 'ready' }); + expect(storedSnapshot).toHaveBeenCalledWith('c1'); + expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ url: receipt.url })); + expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('reports a local startup failure without dispatching a model request', async () => { + const s = setup(); + s.launcher.discover.mockResolvedValue({ kind: 'static', directory: '.', entry: 'index.html' }); + s.launcher.start.mockRejectedValue(Object.assign(new Error('missing entry'), { code: 'WORK_PREVIEW_ENTRY_MISSING' })); + expect(await s.service.ensure(project, 'open')).toMatchObject({ status: 'failed', errorCode: 'WORK_PREVIEW_ENTRY_MISSING', message: expect.stringContaining('入口找不到') }); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('saves a verified address even when its launch method cannot be inferred', async () => { + const s = setup(); + s.launcher.discover.mockRejectedValue(Object.assign(new Error('two works'), { code: 'WORK_PREVIEW_NEEDS_HELP' })); + await s.service.remember(project, { ...s.snapshot, browserId: 'open', state: 'attached', url: 'http://localhost:3000' }); + expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ url: 'http://localhost:3000' })); + }); + + it('lets explicit assistant help inspect an ambiguous or failed local launch', async () => { + const s = setup(); + s.launcher.discover.mockRejectedValue(Object.assign(new Error('two works'), { code: 'WORK_PREVIEW_NEEDS_HELP' })); + expect(await s.service.ensure(project, 'automatic', 'c1')).toMatchObject({ errorCode: 'WORK_PREVIEW_NEEDS_HELP' }); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + expect(await s.service.ensure(project, 'help', 'c1', true)).toMatchObject({ status: 'starting' }); + expect(s.conversations.acceptPrompt).toHaveBeenCalledOnce(); + }); + + it('keeps a newly started address for its own project when the student switches away', async () => { + const s = setup(); + s.launcher.discover.mockResolvedValue({ kind: 'static', directory: '.', entry: 'index.html' }); + s.launcher.start.mockImplementation(async () => { + s.ensureActive.mockRejectedValue(Object.assign(new Error('project changed'), { code: 'PROJECT_NOT_ACTIVE' })); + return 'http://127.0.0.1:5173/'; + }); + await expect(s.service.ensure(project, 'open')).rejects.toThrow('project changed'); + expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ url: 'http://127.0.0.1:5173/' })); + expect(s.browser.open).not.toHaveBeenCalled(); + s.ensureActive.mockResolvedValue(); + s.responds.mockResolvedValue(true); + s.browser.open.mockResolvedValue({ ...s.snapshot, browserId: 'restored', state: 'attached', url: 'http://127.0.0.1:5173/' }); + expect(await s.service.ensure(project, 'return')).toMatchObject({ status: 'ready' }); + expect(s.launcher.start).toHaveBeenCalledOnce(); + }); + + it('preserves a manual open that supersedes a slow local startup', async () => { + const s = setup(); + const launch: WorkLaunchSpec = { kind: 'static', directory: '.', entry: 'index.html' }; + s.launcher.discover.mockResolvedValue(launch); + let finish!: (url: string) => void; + s.launcher.start.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const pending = s.service.ensure(project, 'open'); + await vi.waitFor(() => expect(s.launcher.start).toHaveBeenCalled()); + const manual = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000' }; + await s.service.remember(project, manual, launch); + s.responds.mockResolvedValue(true); + s.browser.open.mockResolvedValue(manual); + finish('http://127.0.0.1:5173/'); + expect(await pending).toMatchObject({ status: 'ready', browser: { url: manual.url } }); + expect(s.launcher.stop).toHaveBeenCalledWith(project, 'http://127.0.0.1:5173/'); + expect(s.browser.open).toHaveBeenCalledWith(expect.objectContaining({ url: manual.url })); + }); + + it('queues an explicit helper choice behind an already pending automatic check', async () => { + const s = setup(); + let finish!: () => void; + s.inspectWork.mockImplementationOnce(() => new Promise((resolve) => { finish = () => resolve('possible'); })); + const automatic = s.service.ensure(project, 'automatic', 'c1'); + await vi.waitFor(() => expect(s.inspectWork).toHaveBeenCalled()); + const help = s.service.ensure(project, 'help', 'c1', true); + finish(); + expect(await automatic).toMatchObject({ errorCode: 'WORK_PREVIEW_NEEDS_HELP' }); + expect(await help).toMatchObject({ status: 'starting' }); + expect(s.conversations.acceptPrompt).toHaveBeenCalledOnce(); + }); + + it('does not restore an unknown saved page whose content no longer matches', async () => { + const s = setup(); + s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000', documentHash: 'b'.repeat(64) }); + s.responds.mockResolvedValue(true); + expect(await s.service.ensure(project, 'open')).toMatchObject({ errorCode: 'WORK_PREVIEW_NEEDS_HELP' }); + expect(s.browser.open).not.toHaveBeenCalled(); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('keeps a newer manual page when a slow saved-address probe completes later', async () => { + const s = setup(); + s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000/old', documentHash: 'a'.repeat(64) }); + let finish!: (value: boolean) => void; + s.responds.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })).mockResolvedValue(true); + const pending = s.service.ensure(project, 'restore'); + await vi.waitFor(() => expect(s.responds).toHaveBeenCalled()); + const manual = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000/new' }; + await s.service.remember(project, manual); + s.browser.open.mockResolvedValue(manual); + finish(true); + expect(await pending).toMatchObject({ status: 'ready', browser: { url: manual.url } }); + expect(s.browser.open).toHaveBeenCalledWith(expect.objectContaining({ url: manual.url })); + expect(s.browser.open).not.toHaveBeenCalledWith(expect.objectContaining({ url: 'http://localhost:3000/old' })); + }); + + it('does not stop the new server when a manual page uses another path on that same server', async () => { + const s = setup(); + const launch: WorkLaunchSpec = { kind: 'static', directory: '.', entry: 'index.html' }; + s.launcher.discover.mockResolvedValue(launch); + let finish!: (url: string) => void; + s.launcher.start.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const pending = s.service.ensure(project, 'open'); + await vi.waitFor(() => expect(s.launcher.start).toHaveBeenCalled()); + const manual = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://127.0.0.1:5173/play?level=2' }; + await s.service.remember(project, manual, launch); + s.responds.mockResolvedValue(true); + s.browser.open.mockResolvedValue(manual); + finish('http://127.0.0.1:5173/'); + expect(await pending).toMatchObject({ status: 'ready', browser: { url: manual.url } }); + expect(s.launcher.stop).not.toHaveBeenCalled(); + }); it('keeps repeated empty-project clicks out of conversations and discovers work made later', async () => { const s = setup(); const path = await mkdtemp(join(tmpdir(), 'makelore-empty-preview-')); @@ -59,7 +230,7 @@ describe('opening a work preview', () => { await mkdir(join(path, 'game')); await writeFile(join(path, 'game/play.html'), '

Game

'); - expect(await s.service.ensure(emptyProject, 'new-click')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(emptyProject, 'new-click', undefined, true)).toMatchObject({ status: 'starting' }); expect(s.conversations.createConversation).toHaveBeenCalledTimes(1); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); @@ -67,7 +238,7 @@ describe('opening a work preview', () => { it('preserves startup when a local check cannot establish absence', async () => { const s = setup(); s.inspectWork.mockResolvedValue('unknown'); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'starting' }); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); @@ -75,19 +246,19 @@ describe('opening a work preview', () => { const s = setup(); s.inspectWork.mockResolvedValue('empty'); s.runtime.getDiagnostics.mockReturnValueOnce({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never); - expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'waiting' }); expect(s.inspectWork).not.toHaveBeenCalled(); - expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'empty' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'empty' }); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); it('revalidates the active project after an asynchronous presence check', async () => { const s = setup(); s.inspectWork.mockImplementation(async () => { - s.ensureActive.mockRejectedValue(new Error('project changed')); + s.ensureActive.mockRejectedValue(Object.assign(new Error('project changed'), { code: 'PROJECT_NOT_ACTIVE' })); return 'empty'; }); - await expect(s.service.ensure(project, 'req', 'c1')).rejects.toThrow('project changed'); + await expect(s.service.ensure(project, 'req', 'c1', true)).rejects.toThrow('project changed'); expect(s.conversations.createConversation).not.toHaveBeenCalled(); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); @@ -98,7 +269,7 @@ describe('opening a work preview', () => { s.runtime.getDiagnostics.mockReturnValue({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never); return 'empty'; }); - expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'waiting' }); expect(s.conversations.createConversation).not.toHaveBeenCalled(); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); @@ -107,13 +278,13 @@ describe('opening a work preview', () => { const s = setup(); let finish!: (value: ConversationSnapshot) => void; s.conversations.getSnapshot.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); - const pending = s.service.ensure(project, 'req', 'c1'); + const pending = s.service.ensure(project, 'req', 'c1', true); await vi.waitFor(() => expect(s.conversations.getSnapshot).toHaveBeenCalled()); const healthy = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000' }; s.service.remember(project, healthy); s.responds.mockResolvedValue(true); s.browser.open.mockResolvedValue(healthy); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready' }); finish({ run: { status: 'idle' } } as ConversationSnapshot); expect(await pending).toMatchObject({ status: 'ready' }); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); @@ -126,7 +297,7 @@ describe('opening a work preview', () => { s.browser.open.mockResolvedValue({ ...healthy, generation: 2 }); s.responds.mockResolvedValue(true); s.conversations.getSnapshot.mockRejectedValue(new Error('login required')); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready' }); expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); @@ -142,19 +313,19 @@ describe('opening a work preview', () => { it('returns the login recovery cause after an accepted action fails, without automatically resending', async () => { const s = setup(); - await s.service.ensure(project, 'req', 'c1'); + await s.service.ensure(project, 'req', 'c1', true); s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'error', runId: 'work-run', error: { code: 'CODING_PROVIDER_AUTH_REQUIRED' } } } as ConversationSnapshot); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed', errorCode: 'CODING_PROVIDER_AUTH_REQUIRED', message: expect.stringContaining('重新登录') }); - await s.service.ensure(project, 'req', 'c1'); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed', errorCode: 'CODING_PROVIDER_AUTH_REQUIRED', message: expect.stringContaining('重新登录') }); + await s.service.ensure(project, 'req', 'c1', true); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); - await s.service.ensure(project, 'after-login', 'c1'); + await s.service.ensure(project, 'after-login', 'c1', true); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(2); }); it('reuses a healthy existing page without invoking the Agent or reopening the browser', async () => { const s = setup(); Object.assign(s.snapshot, { browserId: 'b1', state: 'attached', url: 'http://localhost:3000' }); s.responds.mockResolvedValue(true); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready', browser: s.snapshot }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready', browser: s.snapshot }); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); expect(s.browser.open).not.toHaveBeenCalled(); expect(s.inspectWork).not.toHaveBeenCalled(); @@ -164,7 +335,7 @@ describe('opening a work preview', () => { const s = setup(); Object.assign(s.snapshot, { state: 'crashed', url: 'http://localhost:3000' }); s.responds.mockResolvedValue(true); - await s.service.ensure(project, 'req', 'c1'); + await s.service.ensure(project, 'req', 'c1', true); expect(s.browser.open).toHaveBeenCalledWith(expect.objectContaining({ url: s.snapshot.url, projectPath: project.path })); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); @@ -174,10 +345,10 @@ describe('opening a work preview', () => { const healthy = { ...s.snapshot, browserId: 'b1', state: 'attached' as const, url: 'http://localhost:3000' }; s.browser.getSnapshot.mockResolvedValue(healthy); s.responds.mockResolvedValue(true); - await s.service.ensure(project, 'req', 'c1'); + await s.service.ensure(project, 'req', 'c1', true); s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, projectId: null }); s.browser.open.mockResolvedValue({ ...healthy, browserId: 'restored', generation: 2 }); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready', browser: { browserId: 'restored' } }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready', browser: { browserId: 'restored' } }); expect(s.browser.open).toHaveBeenCalledWith({ projectId: 'p1', projectPath: '/project', url: healthy.url, visible: false }); expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); @@ -187,72 +358,72 @@ describe('opening a work preview', () => { const s = setup(); s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, browserId: 'b1', state: 'attached', url: 'http://localhost:3000' }); s.responds.mockResolvedValue(true); - await s.service.ensure(project, 'req', 'c1'); + await s.service.ensure(project, 'req', 'c1', true); s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, projectId: null }); s.responds.mockResolvedValue(false); - expect(await s.service.ensure(project, 'retry', 'c1')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(project, 'retry', 'c1', true)).toMatchObject({ status: 'starting' }); expect(s.browser.open).not.toHaveBeenCalled(); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); it('sends the fixed action once across simultaneous clicks and later polls', async () => { const s = setup(); - await Promise.all([s.service.ensure(project, 'req', 'c1'), s.service.ensure(project, 'other', 'c1')]); + await Promise.all([s.service.ensure(project, 'req', 'c1', true), s.service.ensure(project, 'other', 'c1', true)]); s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'running', runId: 'work-run' } } as ConversationSnapshot); - expect(await s.service.ensure(project, 'another', 'c1')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(project, 'another', 'c1', true)).toMatchObject({ status: 'starting' }); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); expect(s.conversations.acceptPrompt).toHaveBeenCalledWith({ conversationId: 'c1', clientRequestId: 'work-preview-req', mode: 'prompt', text: OPEN_WORK_PROMPT }); Object.assign(s.snapshot, { browserId: 'b1', state: 'attached', url: 'http://localhost:3000' }); s.responds.mockResolvedValue(true); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready' }); }); it('waits for current project work and then starts without steering the active run', async () => { const s = setup(); s.runtime.getDiagnostics.mockReturnValueOnce({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never); - expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'waiting' }); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'starting' }); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); it('settles failures without resubmission until an explicit retry', async () => { const s = setup(); - await s.service.ensure(project, 'req', 'c1'); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' }); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' }); + await s.service.ensure(project, 'req', 'c1', true); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed' }); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); - await s.service.ensure(project, 'retry', 'c1'); + await s.service.ensure(project, 'retry', 'c1', true); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(2); }); it('does not automatically resend an uncertain prompt acceptance', async () => { const s = setup(); s.conversations.acceptPrompt.mockRejectedValue(new Error('unknown receipt')); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' }); - await s.service.ensure(project, 'req', 'c1'); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed' }); + await s.service.ensure(project, 'req', 'c1', true); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); it('waits for the accepted run instead of mistaking the preceding idle snapshot for completion', async () => { const s = setup(); - await s.service.ensure(project, 'req', 'c1'); + await s.service.ensure(project, 'req', 'c1', true); s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'idle', runId: 'old-run' } } as ConversationSnapshot); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'starting' }); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); it('creates a conversation if needed without submitting a student draft', async () => { const s = setup(); s.conversations.listConversations.mockResolvedValue([]); - expect(await s.service.ensure(project, 'req')).toMatchObject({ status: 'starting', conversation }); + expect(await s.service.ensure(project, 'req', undefined, true)).toMatchObject({ status: 'starting', conversation }); expect(s.conversations.createConversation).toHaveBeenCalledWith({ projectId: 'p1', title: '新对话' }); }); it('cancels dispatch when the project changes during a browser check', async () => { const s = setup(); - s.ensureActive.mockResolvedValueOnce().mockRejectedValue(new Error('project changed')); - await expect(s.service.ensure(project, 'req', 'c1')).rejects.toThrow('project changed'); + s.ensureActive.mockResolvedValueOnce().mockRejectedValue(Object.assign(new Error('project changed'), { code: 'PROJECT_NOT_ACTIVE' })); + await expect(s.service.ensure(project, 'req', 'c1', true)).rejects.toThrow('project changed'); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); diff --git a/tests/unit/use-work-preview.test.tsx b/tests/unit/use-work-preview.test.tsx index b252c48e..6e8ebe55 100644 --- a/tests/unit/use-work-preview.test.tsx +++ b/tests/unit/use-work-preview.test.tsx @@ -1,12 +1,32 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useWorkPreview } from '@/pages/Chat/use-work-preview'; +import { AppError } from '@/lib/error-model'; const ensure = vi.hoisted(() => vi.fn()); vi.mock('@/lib/coding-work-preview', () => ({ ensureWorkPreview: ensure })); beforeEach(() => { vi.useFakeTimers(); ensure.mockReset(); }); afterEach(() => vi.useRealTimers()); describe('work preview request lifecycle', () => { + it('keeps the real model-required reason from the Host error instead of a generic retry', async () => { + ensure.mockRejectedValue(new AppError('UNKNOWN', 'backend failure', undefined, { backendCode: 'CODING_MIGRATION_MODEL_REQUIRED' })); + const { result } = renderHook(() => useWorkPreview({ projectId: 'p1', enabled: true, onResult: vi.fn() })); + await act(async () => {}); + expect(result.current.result).toMatchObject({ status: 'failed', errorCode: 'CODING_MIGRATION_MODEL_REQUIRED', message: expect.stringContaining('选择一个可用模型') }); + await act(async () => vi.advanceTimersByTimeAsync(60_000)); + expect(ensure).toHaveBeenCalledOnce(); + }); + + it('permits assistant help only after an explicit choice and only for that action', async () => { + ensure.mockResolvedValue({ status: 'failed', errorCode: 'WORK_PREVIEW_NEEDS_HELP' }); + const { result, rerender } = renderHook(({ revision }) => useWorkPreview({ projectId: 'p1', conversationId: 'c1', enabled: true, revision, onResult: vi.fn() }), { initialProps: { revision: 0 } }); + await act(async () => {}); + expect(ensure.mock.calls[0]).toHaveLength(3); + await act(async () => result.current.requestHelp()); + expect(ensure.mock.calls[1]).toEqual(['p1', expect.any(String), 'c1', true]); + await act(async () => rerender({ revision: 1 })); + expect(ensure.mock.calls[2]).toHaveLength(3); + }); it('stops polling an empty project and checks again when the work tab is reopened', async () => { ensure.mockResolvedValue({ status: 'empty' }); const onResult = vi.fn(); diff --git a/tests/unit/work-entry-store.test.ts b/tests/unit/work-entry-store.test.ts new file mode 100644 index 00000000..3ccbe2b4 --- /dev/null +++ b/tests/unit/work-entry-store.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { loadWorkEntry, saveWorkEntry } from '../../electron/coding-runtime/work-entry-store'; +import type { WorkEntry, WorkEntryProject } from '../../electron/coding-runtime/work-entry-contracts'; + +const directories: string[] = []; + +async function project(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'makelore-work-entry-')); + directories.push(directory); + await mkdir(path.join(directory, '.makelore')); + return { id: `p-${directories.length}`, path: await realpath(directory) }; +} + +function entry(target: WorkEntryProject, patch: Partial = {}): WorkEntry { + return { + version: 1, projectId: target.id, projectPath: target.path, + url: 'http://127.0.0.1:5179/play?level=2#game', ...patch, + }; +} + +function entryFile(target: WorkEntryProject): string { + return path.join(target.path, '.makelore/work-entry.json'); +} + +async function corrupt(target: WorkEntryProject, value: unknown): Promise { + await writeFile(entryFile(target), JSON.stringify(value)); +} + +afterEach(async () => { + vi.resetModules(); + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe('project work entry persistence', () => { + it('reloads a project-bound work address from disk after a fresh module instance', async () => { + const target = await project(); + const value = entry(target); + await saveWorkEntry(target, value); + vi.resetModules(); + const fresh = await import('../../electron/coding-runtime/work-entry-store'); + expect(await fresh.loadWorkEntry(target)).toEqual(value); + expect(JSON.parse(await readFile(entryFile(target), 'utf8'))).toEqual(value); + expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']); + }); + + it('preserves an optional document fingerprint through an atomic save and fresh reload', async () => { + const target = await project(); + const value = { ...entry(target), documentHash: '0123456789abcdef'.repeat(4) }; + await saveWorkEntry(target, value); + vi.resetModules(); + const fresh = await import('../../electron/coding-runtime/work-entry-store'); + expect(await fresh.loadWorkEntry(target)).toEqual(value); + expect(JSON.parse(await readFile(entryFile(target), 'utf8')).documentHash).toBe(value.documentHash); + }); + + it.each([null, 123, '', 'a'.repeat(63), 'a'.repeat(65), 'A'.repeat(64), 'g'.repeat(64)])('rejects an invalid document fingerprint: %j', async (documentHash) => { + const target = await project(); + const value = { ...entry(target), documentHash }; + await corrupt(target, value); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, value as unknown as WorkEntry)).rejects.toThrow(); + }); + + it('binds the saved entry to the canonical project root', async () => { + const target = await project(); + const alias = path.join(target.path, 'project-alias'); + await symlink(target.path, alias, 'dir'); + const viaAlias = { ...target, path: alias }; + await saveWorkEntry(viaAlias, entry(viaAlias)); + expect(await loadWorkEntry(target)).toEqual(entry(target)); + expect(await loadWorkEntry(viaAlias)).toEqual(entry(target)); + }); + + it('persists safe Vite options for a nested work directory', async () => { + const target = await project(); + await mkdir(path.join(target.path, 'Alice 的游戏')); + const value = entry(target, { launch: { + kind: 'vite', directory: './Alice 的游戏', + args: ['--base', '/game/', '--mode', 'development', '--port', '5179', '--strictPort', '--force'], + } }); + await saveWorkEntry(target, value); + expect(await loadWorkEntry(target)).toEqual({ ...value, launch: { ...value.launch, directory: 'Alice 的游戏' } }); + }); + + it('accepts a root Vite work and a nested static entry without executing a recipe', async () => { + const target = await project(); + const vite = entry(target, { launch: { kind: 'vite', directory: '.', args: ['--base', './'] } }); + await saveWorkEntry(target, vite); + expect(await loadWorkEntry(target)).toEqual(vite); + await writeFile(path.join(target.path, 'index.html'), '

Root

'); + const rootStatic = entry(target, { launch: { kind: 'static', directory: '.', entry: 'index.html' } }); + await saveWorkEntry(target, rootStatic); + expect(await loadWorkEntry(target)).toEqual(rootStatic); + await mkdir(path.join(target.path, 'game/pages'), { recursive: true }); + await writeFile(path.join(target.path, 'game/pages/index.html'), '

Alice

'); + const staticEntry = entry(target, { launch: { kind: 'static', directory: 'game', entry: 'pages/index.html' } }); + await saveWorkEntry(target, staticEntry); + expect(await loadWorkEntry(target)).toEqual(staticEntry); + }); + + it('rejects copied entries with a different project id or root', async () => { + const source = await project(); + const destination = await project(); + await saveWorkEntry(source, entry(source)); + await copyFile(entryFile(source), entryFile(destination)); + expect(await loadWorkEntry(destination)).toBeNull(); + await corrupt(destination, entry(source, { projectId: destination.id })); + expect(await loadWorkEntry(destination)).toBeNull(); + await expect(saveWorkEntry(destination, entry(source))).rejects.toThrow(); + await expect(saveWorkEntry(destination, entry(source, { projectId: destination.id }))).rejects.toThrow(); + }); + + it.each([ + 'https://example.com/game', 'http://localhost.example.com/game', 'file:///project/index.html', + 'http://user:password@localhost:5179/', 'http://192.168.1.2:5179/', 'http://localhost:5179/\n', + `http://localhost:5179/${'a'.repeat(2_048)}`, + ])('rejects an unsafe work URL: %s', async (url) => { + const target = await project(); + const value = entry(target, { url }); + await corrupt(target, value); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, value)).rejects.toThrow(); + }); + + it.each(['http://localhost:5179/', 'https://127.0.0.1:5179/', 'http://[::1]:5179/index.html'])('accepts loopback HTTP addresses: %s', async (url) => { + const target = await project(); + await saveWorkEntry(target, entry(target, { url })); + expect((await loadWorkEntry(target))?.url).toBe(url); + }); + + it('ignores missing, malformed, unsupported and oversized records', async () => { + const target = await project(); + expect(await loadWorkEntry(target)).toBeNull(); + for (const text of ['not JSON', '{}', JSON.stringify({ ...entry(target), version: 2 }), ' '.repeat(16 * 1024 + 1)]) { + await writeFile(entryFile(target), text); + expect(await loadWorkEntry(target)).toBeNull(); + } + await writeFile(entryFile(target), Buffer.from([0xff, 0xfe])); + expect(await loadWorkEntry(target)).toBeNull(); + await rm(entryFile(target)); + await mkdir(entryFile(target)); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entry(target))).rejects.toThrow(); + }); + + it('leaves missing metadata absent on reads and rejects arbitrary launch commands', async () => { + const target = await project(); + await corrupt(target, { ...entry(target), launch: { kind: 'shell', command: 'npm run dev' } }); + expect(await loadWorkEntry(target)).toBeNull(); + await corrupt(target, { ...entry(target), launch: { kind: 'vite', directory: '.', args: [], command: 'npm run dev' } }); + expect(await loadWorkEntry(target)).toBeNull(); + await rm(path.join(target.path, '.makelore'), { recursive: true }); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entry(target, { url: 'https://example.com/' }))).rejects.toThrow(); + expect(await readdir(target.path)).toEqual([]); + }); + + it('creates metadata safely when saving an imported folder and reloads the entry', async () => { + const target = await project(); + await rm(path.join(target.path, '.makelore'), { recursive: true }); + const value = entry(target); + expect(await loadWorkEntry(target)).toBeNull(); + expect(await readdir(target.path)).toEqual([]); + await saveWorkEntry(target, value); + expect(await loadWorkEntry(target)).toEqual(value); + expect(await readdir(target.path)).toEqual(['.makelore']); + expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']); + }); + + it('rejects a colliding metadata file instead of replacing it with a directory', async () => { + const target = await project(); + await rm(path.join(target.path, '.makelore'), { recursive: true }); + await writeFile(path.join(target.path, '.makelore'), 'Existing user file'); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entry(target))).rejects.toThrow(); + expect(await readFile(path.join(target.path, '.makelore'), 'utf8')).toBe('Existing user file'); + }); + + it('rejects metadata directory and entry symlinks without changing the outside file', async () => { + const target = await project(); + const outside = await project(); + await saveWorkEntry(outside, entry(outside)); + const outsideBefore = await readFile(entryFile(outside), 'utf8'); + await symlink(entryFile(outside), entryFile(target), 'file'); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entry(target))).rejects.toThrow(); + await rm(entryFile(target)); + await rm(path.join(target.path, '.makelore'), { recursive: true }); + await symlink(path.join(outside.path, '.makelore'), path.join(target.path, '.makelore'), 'dir'); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entry(target))).rejects.toThrow(); + expect(await readFile(entryFile(outside), 'utf8')).toBe(outsideBefore); + }); + + it.each(['../outside', '/absolute', 'C:/outside', 'game/../../outside', 'game\\outside'])('rejects recipe directory path escape: %s', async (directory) => { + const target = await project(); + const value = entry(target, { launch: { kind: 'vite', directory, args: [] } }); + await corrupt(target, value); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, value)).rejects.toThrow(); + }); + + it('rejects launch directory and static entry symlinks, including in-project links', async () => { + const target = await project(); + await mkdir(path.join(target.path, 'game')); + await writeFile(path.join(target.path, 'game/index.html'), '

Game

'); + await symlink(path.join(target.path, 'game'), path.join(target.path, 'linked-game'), 'dir'); + const directoryLink = entry(target, { launch: { kind: 'vite', directory: 'linked-game', args: [] } }); + await corrupt(target, directoryLink); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, directoryLink)).rejects.toThrow(); + await symlink(path.join(target.path, 'game/index.html'), path.join(target.path, 'game/linked.html'), 'file'); + const entryLink = entry(target, { launch: { kind: 'static', directory: 'game', entry: 'linked.html' } }); + await corrupt(target, entryLink); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entryLink)).rejects.toThrow(); + }); + + it.each([ + ['--config', 'outside.config.js'], ['--host', '0.0.0.0'], ['--open'], ['--port', '0'], + ['--port', '65536'], ['--mode', 'dev; touch outside'], ['--base', 'https://example.com/'], + ['--base', '//example.com/'], ['--base', '/../outside'], ['--port', '5179', '--port', '5199'], + ['--mode'], ['game'], + ])('rejects unsupported or unsafe persisted Vite args: %j', async (...args) => { + const target = await project(); + const value = entry(target, { launch: { kind: 'vite', directory: '.', args } }); + await corrupt(target, value); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, value)).rejects.toThrow(); + }); + + it('rejects a static entry escaping its directory and preserves the last complete valid entry', async () => { + const target = await project(); + await mkdir(path.join(target.path, 'game')); + await writeFile(path.join(target.path, 'index.html'), '

Root

'); + const valid = entry(target); + await saveWorkEntry(target, valid); + const invalid = entry(target, { launch: { kind: 'static', directory: 'game', entry: '../index.html' } }); + await expect(saveWorkEntry(target, invalid)).rejects.toThrow(); + expect(await loadWorkEntry(target)).toEqual(valid); + expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']); + }); + + it('publishes complete records during concurrent writes and removes temporary files', async () => { + const target = await project(); + await rm(path.join(target.path, '.makelore'), { recursive: true }); + await Promise.all([5179, 5199, 5200].map((port) => saveWorkEntry(target, entry(target, { url: `http://localhost:${port}/` })))); + const loaded = await loadWorkEntry(target); + expect(loaded).toMatchObject({ projectId: target.id, projectPath: target.path }); + expect(['http://localhost:5179/', 'http://localhost:5199/', 'http://localhost:5200/']).toContain(loaded?.url); + expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']); + }); +}); diff --git a/tests/unit/work-launcher.test.ts b/tests/unit/work-launcher.test.ts new file mode 100644 index 00000000..38896318 --- /dev/null +++ b/tests/unit/work-launcher.test.ts @@ -0,0 +1,325 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { createServer, get, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { once } from 'node:events'; +import { spawn } from 'node:child_process'; +import { discoverWorkLaunch, startWorkLaunch, stopWorkLaunch, workLaunchMatches } from '../../electron/coding-runtime/work-launcher'; +import type { WorkLaunchSpec } from '../../electron/coding-runtime/work-entry-contracts'; + +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, spawn: vi.fn(original.spawn) }; +}); + +const directories: string[] = []; +const servers: Server[] = []; +const html = 'Saved game

Play

'; + +async function fixture(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'makelore-work-launch-')); + directories.push(directory); + return directory; +} + +async function staticFixture(root: string, directory = '.'): Promise { + const target = path.resolve(root, directory); + await mkdir(target, { recursive: true }); + await writeFile(path.join(target, 'index.html'), html); + await writeFile(path.join(target, 'main.js'), 'console.log("play")'); + return { kind: 'static', directory, entry: 'index.html' }; +} + +async function viteFixture(root: string, directory = '.', dev = 'vite --host 0.0.0.0 --port 5199'): Promise { + const target = path.resolve(root, directory); + await mkdir(path.join(target, 'node_modules', 'vite', 'bin'), { recursive: true }); + await mkdir(path.join(target, 'node_modules', 'vite', 'dist', 'node'), { recursive: true }); + await writeFile(path.join(target, 'index.html'), html); + await writeFile(path.join(target, 'package.json'), JSON.stringify({ scripts: { dev } })); + await writeFile(path.join(target, 'node_modules', 'vite', 'package.json'), JSON.stringify({ name: 'vite', type: 'module' })); + await writeFile(path.join(target, 'node_modules', 'vite', 'bin', 'vite.js'), '// installed CLI entry'); + await writeFile(path.join(target, 'node_modules', 'vite', 'dist', 'node', 'index.js'), ` + import http from 'node:http'; + export async function createServer(options) { + const server = http.createServer((_req,res) => { + res.setHeader('content-type','text/html'); + res.end(${JSON.stringify(html.replace('', ''))}); + }); + return {config:{root:process.cwd(),base:options.base || '/'},async listen() { + await new Promise(resolve => server.listen(options.server.port,options.server.host,resolve)); + }}; + } + `); +} + +async function listen(server: Server, port = 0): Promise { + servers.push(server); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('port unavailable'); + return `http://127.0.0.1:${address.port}/`; +} + +function request(url: string, requestPath?: string): Promise<{ status?: number; contentType?: string; body: string }> { + return new Promise((resolve, reject) => { + const address = new URL(url); + get({ hostname: address.hostname, port: address.port, path: requestPath ?? address.pathname }, (response) => { + let body = ''; + response.on('data', (bytes: Buffer) => { body += bytes.toString('utf8'); }); + response.on('end', () => resolve({ status: response.statusCode, contentType: response.headers['content-type'], body })); + }).on('error', reject); + }); +} + +afterEach(async () => { + for (const result of vi.mocked(spawn).mock.results) { + if (result.type !== 'return') continue; + const child = result.value; + if (!child.pid || child.exitCode !== null || child.signalCode !== null) continue; + const exited = once(child, 'exit').catch(() => undefined); + if (process.platform !== 'win32') { + try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill(); } + } else child.kill(); + await exited; + } + vi.mocked(spawn).mockClear(); + for (const server of servers.splice(0)) await new Promise((resolve) => server.close(() => resolve())); + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); +}); + +describe('existing project work discovery', () => { + it('finds a single nested Vite work, normalizing only recognized flags without needing installed dependencies', async () => { + const root = await fixture(); + await mkdir(path.join(root, 'game')); + await writeFile(path.join(root, 'game', 'package.json'), JSON.stringify({ scripts: { dev: 'vite --host=0.0.0.0 --port=5179 --mode play --strictPort' } })); + expect(await discoverWorkLaunch(root)).toEqual({ kind: 'vite', directory: 'game', args: ['--port', '5179', '--mode', 'play', '--strictPort'] }); + }); + + it('does not discover generated nested copies underneath a recognized work', async () => { + const root = await fixture(); + await viteFixture(root, 'game'); + await staticFixture(root, 'game/dist'); + expect(await discoverWorkLaunch(root)).toMatchObject({ kind: 'vite', directory: 'game' }); + }); + + it('requires help when separate existing works are ambiguous', async () => { + const root = await fixture(); + await staticFixture(root, 'first'); + await staticFixture(root, 'second'); + await expect(discoverWorkLaunch(root)).rejects.toMatchObject({ code: 'WORK_PREVIEW_NEEDS_HELP' }); + }); + + it('ignores private directories and symlinked projects', async () => { + const root = await fixture(); + const outside = await fixture(); + await staticFixture(root, '.makelore/cache'); + await staticFixture(root, 'node_modules/template'); + await staticFixture(outside); + await symlink(outside, path.join(root, 'linked-game'), 'dir'); + expect(await discoverWorkLaunch(root)).toBeNull(); + }); + + it.each(['vite && touch owned', 'vite --open', 'npm run serve', 'vite --config ../../other.js'])('does not reinterpret unsupported script %s as a static website', async (dev) => { + const root = await fixture(); + await viteFixture(root, '.', dev); + await expect(discoverWorkLaunch(root)).rejects.toMatchObject({ code: 'WORK_PREVIEW_NEEDS_HELP' }); + expect(spawn).not.toHaveBeenCalled(); + }); +}); + +describe('model-free work launch', () => { + it('reopens a stopped static work on the same saved local port with real HTTP readiness', async () => { + const root = await fixture(); + const spec = await staticFixture(root); + const oldServer = createServer(); + const previousUrl = await listen(oldServer); + await new Promise((resolve) => oldServer.close(() => resolve())); + const url = await startWorkLaunch({ id: 'game', path: root }, spec, previousUrl); + expect(url).toBe(previousUrl); + expect(await request(url)).toMatchObject({ status: 200, body: html }); + expect(await request(url, '/main.js')).toMatchObject({ contentType: 'text/javascript; charset=utf-8', body: 'console.log("play")' }); + expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(true); + expect(await readFile(path.join(root, '.makelore', 'logs', 'static-preview.log'), 'utf8')).toContain('Makelore launch pid='); + }); + + it('starts the installed Vite runtime directly without a shell and forces a loopback-only server', async () => { + const root = await fixture(); + await viteFixture(root); + const spec = await discoverWorkLaunch(root); + if (!spec) throw new Error('missing recipe'); + const url = await startWorkLaunch({ id: 'game', path: root }, spec); + expect(await request(url)).toMatchObject({ status: 200 }); + expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(true); + const canonicalRoot = await realpath(root); + expect(spawn).toHaveBeenCalledWith(process.execPath, expect.any(Array), expect.objectContaining({ shell: false, detached: true, cwd: canonicalRoot })); + const args = vi.mocked(spawn).mock.calls[0][1] as string[]; + expect(args).toContain(path.join(canonicalRoot, 'node_modules', 'vite', 'bin', 'vite.js')); + expect(args.slice(-5)).toEqual(['--host', '127.0.0.1', '--port', String(new URL(url).port), '--strictPort']); + expect(args).not.toContain('npm'); + }); + + it('serves a real installed Vite project using its configured base through the owned Node launcher', async () => { + const root = await fixture(); + await viteFixture(root, '.', 'vite'); + await rm(path.join(root, 'node_modules', 'vite'), { recursive: true }); + await symlink(await realpath(path.resolve('node_modules/vite')), path.join(root, 'node_modules', 'vite'), 'dir'); + await writeFile(path.join(root, 'main.js'), 'document.body.dataset.playable = "yes"'); + await writeFile(path.join(root, 'vite.config.js'), 'export default { base: "/game/" }'); + const spec = await discoverWorkLaunch(root); + const url = await startWorkLaunch({ id: 'game', path: root }, spec!); + expect(new URL(url).pathname).toBe('/game/'); + expect((await request(url)).body).toContain('/@vite/client'); + expect((await request(url, '/game/main.js')).body).toContain('dataset.playable'); + expect(await workLaunchMatches({ id: 'game', path: root }, spec!, url)).toBe(true); + }); + + it('uses a free port and leaves an unrelated service at the saved port untouched', async () => { + const root = await fixture(); + const spec = await staticFixture(root); + const unrelated = await listen(createServer((_req, response) => { response.setHeader('content-type', 'text/html'); response.end('Other project'); })); + expect(await workLaunchMatches({ id: 'game', path: root }, spec, unrelated)).toBe(false); + const url = await startWorkLaunch({ id: 'game', path: root }, spec, unrelated); + expect(url).not.toBe(unrelated); + expect((await request(unrelated)).body).toContain('Other project'); + expect((await request(url)).body).toBe(html); + }); + + it('reports missing dependencies without attempting an installation', async () => { + const root = await fixture(); + await viteFixture(root); + const spec = await discoverWorkLaunch(root); + await rm(path.join(root, 'node_modules'), { recursive: true }); + await expect(startWorkLaunch({ id: 'game', path: root }, spec!)).rejects.toMatchObject({ code: 'WORK_PREVIEW_DEPENDENCIES_MISSING' }); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('reports a failed local start rather than returning an address for an unready process', async () => { + const root = await fixture(); + await viteFixture(root); + const spec = await discoverWorkLaunch(root); + await writeFile(path.join(root, 'node_modules', 'vite', 'dist', 'node', 'index.js'), 'process.exit(12)'); + await expect(startWorkLaunch({ id: 'game', path: root }, spec!)).rejects.toMatchObject({ code: 'WORK_PREVIEW_START_FAILED' }); + }); + + it('rejects recipes after their package command or file location changes', async () => { + const root = await fixture(); + await viteFixture(root); + const spec = await discoverWorkLaunch(root); + await writeFile(path.join(root, 'package.json'), JSON.stringify({ scripts: { dev: 'vite && touch changed' } })); + await expect(startWorkLaunch({ id: 'game', path: root }, spec!)).rejects.toMatchObject({ code: 'WORK_PREVIEW_NEEDS_HELP' }); + await expect(startWorkLaunch({ id: 'game', path: root }, { kind: 'static', directory: '../outside', entry: 'index.html' })).rejects.toMatchObject({ code: 'WORK_PREVIEW_ENTRY_MISSING' }); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('blocks traversal, private data and symlinked files in the static server', async () => { + const root = await fixture(); + const outside = await fixture(); + const spec = await staticFixture(root); + await writeFile(path.join(outside, 'secret.txt'), 'private'); + await symlink(path.join(outside, 'secret.txt'), path.join(root, 'linked.txt')); + const url = await startWorkLaunch({ id: 'game', path: root }, spec); + expect((await request(url, '/%2e%2e/secret.txt')).status).toBe(403); + expect((await request(url, '/linked.txt')).status).toBe(403); + expect((await request(url, '/.makelore/logs/static-preview.log')).status).toBe(403); + await rm(path.join(root, 'index.html')); + await symlink(path.join(outside, 'secret.txt'), path.join(root, 'index.html')); + await expect(startWorkLaunch({ id: 'game', path: root }, spec)).rejects.toMatchObject({ code: 'WORK_PREVIEW_ENTRY_MISSING' }); + }); + + it('only stops its own newly started service and leaves unrelated local addresses alone', async () => { + const root = await fixture(); + const spec = await staticFixture(root); + const project = { id: 'game', path: root }; + const unrelated = await listen(createServer((_req, response) => response.end('Other work'))); + await stopWorkLaunch(project, unrelated); + expect((await request(unrelated)).body).toBe('Other work'); + const url = await startWorkLaunch(project, spec); + const child = vi.mocked(spawn).mock.results.at(-1)!.value; + const exited = once(child, 'exit'); + await stopWorkLaunch(project, url); + await exited; + await expect(request(url)).rejects.toBeDefined(); + expect((await request(unrelated)).body).toBe('Other work'); + }); +}); + +describe('legacy Vite address identity', () => { + it('rejects a different game sharing title and module URL, while accepting exact original TSX sourcesContent', async () => { + const root = await fixture(); + const expected = 'const App = () =>
My saved game
; export default App;'; + const other = 'const App = () =>
A different game
; export default App;'; + await writeFile(path.join(root, 'index.html'), html.replace('/main.js', '/main.tsx')); + await writeFile(path.join(root, 'main.tsx'), expected); + const spec: WorkLaunchSpec = { kind: 'vite', directory: '.', args: [] }; + let servedSource = other; + const url = await listen(createServer((req, response) => { + if (req.url === '/main.tsx') { + const map = Buffer.from(JSON.stringify({ sourcesContent: [servedSource] })).toString('base64'); + response.setHeader('content-type', 'text/javascript'); + response.end(`const App = transformedJSX();\n//# sourceMappingURL=data:application/json;base64,${map}`); + } else { + response.setHeader('content-type', 'text/html'); + response.end(html.replace('/main.js', '/main.tsx').replace('', '')); + } + })); + expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(false); + servedSource = expected; + expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(true); + }); + + it('declines a shared bootstrap whose imported local game has not been verified', async () => { + const root = await fixture(); + const shared = 'import App from "./App.js"; document.body.append(App());'; + await writeFile(path.join(root, 'index.html'), html); + await writeFile(path.join(root, 'main.js'), shared); + const url = await listen(createServer((req, response) => { + response.setHeader('content-type', req.url === '/main.js' ? 'text/javascript' : 'text/html'); + response.end(req.url === '/main.js' ? shared : html.replace('', '')); + })); + expect(await workLaunchMatches({ id: 'game', path: root }, { kind: 'vite', directory: '.', args: [] }, url)).toBe(false); + }); + + it('verifies original raw sources and rejects a different imported game behind an identical bootstrap', async () => { + const root = await fixture(); + const bootstrap = 'import App from "./App.js"; document.body.append(App());'; + const expectedApp = 'export default () => "My saved game";'; + let servedApp = 'export default () => "A different game";'; + await writeFile(path.join(root, 'index.html'), html); + await writeFile(path.join(root, 'main.js'), bootstrap); + await writeFile(path.join(root, 'App.js'), expectedApp); + const url = await listen(createServer((req, response) => { + if (req.url?.endsWith('?raw')) { + response.setHeader('content-type', 'text/javascript'); + const original = req.url.startsWith('/main.js') ? bootstrap : servedApp; + response.end(`export default ${JSON.stringify(original)};`); + } else { + response.setHeader('content-type', 'text/html'); + response.end(html.replace('', '')); + } + })); + const project = { id: 'game', path: root }; + const spec: WorkLaunchSpec = { kind: 'vite', directory: '.', args: [] }; + expect(await workLaunchMatches(project, spec, url)).toBe(false); + servedApp = expectedApp; + expect(await workLaunchMatches(project, spec, url)).toBe(true); + }); + + it('declines unresolved local aliases instead of accepting an identical bootstrap for another game', async () => { + const root = await fixture(); + const source = 'import Game from "@/game"; document.body.append(Game());'; + await writeFile(path.join(root, 'index.html'), html); + await writeFile(path.join(root, 'main.js'), source); + const url = await listen(createServer((req, response) => { + if (req.url === '/main.js?raw') { + response.setHeader('content-type', 'text/javascript'); + response.end(`export default ${JSON.stringify(source)};`); + } else { + response.setHeader('content-type', 'text/html'); + response.end(html.replace('', '')); + } + })); + expect(await workLaunchMatches({ id: 'game', path: root }, { kind: 'vite', directory: '.', args: [] }, url)).toBe(false); + }); +});