import { type IncomingMessage, type ServerResponse } from 'node:http'; import { type RunSessionDetailed } from '../core/process-state.js'; import { type FilesApi } from './files-api.js'; import type { ProjectView } from './projects-store.js'; import type { SettingsApi } from './settings-api.js'; /** 项目面板 store 注入面(spec 2026-09-15 §4;Task 5 接线传 ProjectsStore 四方法)。 * 缺席 → 涉清单的项目端点 503,hello 的 projects 字段为 null(v2/M6)。 */ export interface ProjectsApi { list(): Promise; scan(onProgress?: (found: number, scanned: number) => void): Promise<{ started: boolean; reason?: string; added?: number; }>; add(path: string): Promise<{ ok: boolean; reason?: 'not_a_project' | 'duplicate' | 'full' | undefined; }>; remove(path: string): Promise<{ ok: boolean; reason?: string; }>; } export interface WebGuiServerOptions { getSessions: () => RunSessionDetailed[]; getIndexHtml: () => string; /** 端口起点(默认 9550;0 = 系统随机分配,测试用)。 * env GODOT_MCP_WEB_GUI_PORT 为用户配置起点通道,'0' 落默认 9550 * (随机分配语义仅构造器 portStart 注入通道支持,见 start() 的 ?? 链)。 */ portStart?: number; /** token 注入(测试确定性);缺省 randomBytes(24) */ token?: string; /** 登记目录注入(测试隔离);缺省 ~/.godot-mcp/web-gui/(registry.js 默认) */ registryDir?: string; /** server 包根目录注入(测试隔离,2026-09-16 项目入口批);缺省从本模块位置向上三级 * 推导(build/web-gui/ 或 src/web-gui/ 的上两级 = 包根/仓库根)——入口页落包根用。 */ packageRootDir?: string; /** 资源工作台(spec 2026-09-15 §4):文件五方法注入;缺席 → files 端点 503。 */ files?: FilesApi; /** 静态资产目录注入(测试隔离);缺省 build/web-gui/assets/(构造器定 assetsRoot)。 */ assetsDir?: string; /** 日志目录注入(测试隔离);缺省 resolveLogDir()(logger 同款平台路径) */ logDir?: string; /** 面板控制(2026-09-14 批准设计):POST /api/sessions/stop 回调;未注入时端点 503。 */ stopSession?: (projectPath: string) => Promise<{ ok: boolean; reason?: string; }>; /** 面板控制:POST /api/sessions/remove 回调(移除 ended 态桶);未注入时端点 503。 */ removeSession?: (projectPath: string) => { ok: boolean; reason?: 'alive' | 'not_found'; }; /** 项目面板(spec 2026-09-15 §4):清单四方法注入;缺席 → 项目端点 503 + hello projects=null。 */ projects?: ProjectsApi; /** POST /api/sessions/start(mode=run)回调;缺席 → 503。 */ runProject?: (projectPath: string) => Promise; /** POST /api/sessions/start(mode=edit)回调;缺席 → 503。 */ editProject?: (projectPath: string) => Promise; /** READ_ONLY 判定(spec v2/IMP-3,接线层取 GODOT_MCP_READ_ONLY 同源状态);缺席视为非只读。 */ isReadOnly?: () => boolean; /** 设置面板(2026-09-29 设置批):get/verify/save 三方法注入;缺席 → 设置端点 503。 */ settings?: SettingsApi; /** 实例管理批(2026-09-30):重启"本实例"的有序退出回调(先响应 200 再走 close 链, * 登记文件随 stop() 清理);缺席兜底 process.exit(0)。构造器注入,不新增模块级 setter。 */ onSelfRestart?: () => void; /** daemon 批 B(2026-09-30 spec §3.7/Task 7):受控关停/重启回调(T2 CLI 通道,面板 * 亦可触发)。POST /api/shutdown(?restart=1 → mode 'restart',缺省 'stop')先 200 * 响应再异步 150ms 触发本回调(时序对齐 onSelfRestart 先例,防 stop 模式退出竞态 * 吃掉响应)。未注入时端点 503(stdio 实例不注入,端点不活跃);由 src/daemon/main.ts * 接线注入。构造器注入,不新增模块级 setter。 */ onControlledShutdown?: (mode: 'stop' | 'restart') => void; /** 实例管理批(2026-09-30):registry pid 探活注入(测试 mock);缺省 process.kill(pid,0)。 */ isPidAlive?: (pid: number) => boolean; /** daemon 批 A(2026-09-30 spec §3.4/M-2):严格端口。true = 只试 portStart 一个端口, * EADDRINUSE 直接 reject 不吃 20 次顺延——respawn 交接的端口不漂移不变式(daemon * 旧进程死后新进程必须回到同端口,客户端重连地址才不漂移;顺延成功反而是静默故障)。 */ strictPort?: boolean; /** daemon 批 A(spec §3.4):实例类型,登记进 registry kind 字段(stdio/daemon 区分, * 面板后续按 kind 渲染)。缺省不写 kind(JSON.stringify 跳过 undefined)——旧实例 * 语义,对齐 version 字段先例。 */ instanceKind?: 'stdio' | 'daemon'; /** daemon 批 B(2026-09-30 spec §3.7):受控交接关联——新 daemon 以 --respawn-of * 启动时登记写入此字段,是前端/daemon status 对旧条目显示"交接中"的标注数据源。 * 可选,旧登记无此字段向后兼容(parseRegistrationFile 不校验,对齐 kind 先例); * 由 src/daemon/main.ts 按 --respawn-of 参数透传。 */ respawnOf?: number; /** daemon 前端批 C(2026-09-30 Task 12):MCP 会话占用活性注入——GET /api/instances * 本实例行 sessionActive 字段的数据源(他实例行面板无从得知,恒不携带)。缺省 * 缺席 → 全部行无该字段(stdio 实例语义,前端不显示占用状态,不落误导性 false); * daemon 侧由 src/daemon/main.ts 接 endpoint.hasLiveSession()(活性双轨判定, * 非 SDK 裸计数)。构造器注入,不新增模块级 setter。 */ isMcpSessionActive?: () => boolean; /** daemon 批 A(spec §3.2 注入链):daemon 模式下 POST/GET/DELETE /mcp 三方法 * (Streamable HTTP transport 方法面)路由到它;缺席不挂该路由(/mcp 404)。 * 由 src/daemon/main.ts 构造 WebGuiServer 时直接注入(两段式接线闭包,批 A 审查 * N-2 后 ServerOptions 不再有同名字段——注入不走 GodotServer 透传); * web-gui 对 handler 内部零假设(鉴权/协议/响应头全归 handler)——本模块不 * import MCP SDK 的分层兑现,组装在 src/daemon/mcp-endpoint.ts(Task 4)。 */ mcpHandler?: (req: IncomingMessage, res: ServerResponse) => void; } /** * 面板主文档 CSP(导出供测试精确断言)。 * connect-src 除 'self' 外加 9550-9569 端口段(2026-09-16 入口简化批):前端自愈探测 * 跨端口 fetch /api/health——CSP 源表达式 'self' 含端口,跨端口即跨源被拦(真机实测)。 * 端口段与 html.ts recoverPanel 的扫描范围(9550..9569)一一对应,改动须两处同步; * 127.0.0.1 与 localhost 双 host 都放(用户书签可能用任一形态)。 * CSP 加固(审查 Low,2026-09-17 批 3):script-src 去 'unsafe-inline' 改 sha256 精确 * 放行 INDEX_HTML 内联脚本(hash 模块加载时计算,html.ts INDEX_SCRIPT_SHA256), * 注入的 HTML 与 INDEX_HTML 脚本不一致的测试场景仅 CSP 头与常量一致(浏览器才校验); * frame-ancestors 'none' 防被嵌入 iframe(clickjacking 面)。 */ export declare const WEB_GUI_CSP: string; export declare function isWebGuiActive(): boolean; export declare class WebGuiServer { readonly token: string; private readonly opts; private httpServer; private portValue; private startedAtIso; private sseClients; private reader; private aggregator; private pendingLogs; private logFlushTimer; private sessionsTimer; private statsTimer; private scanProgressLastPush; private readonly assetsRoot; private readonly packageRoot; constructor(opts: WebGuiServerOptions); get port(): number; /** daemon 批 B(2026-09-30 spec §3.7):登记 startedAt 的同源只读暴露——受控交接 * 删自身登记前 removeRegistrationVerified(pid, expectedStartedAt) 的期望值必须与 * 登记文件逐字一致,取本值(start() 定格并写进登记文件的同一 string 引用)而非 * 调用方另 new Date()(时间戳不同会导致 verified 永远 false)。 */ get registrationStartedAt(): string; start(): Promise; private listen; /** 关单个 HTTP server 的最小共享段(daemon 批 B 抽取):closeAllConnections 断 * keep-alive/SSE 已建连接(否则 close 回调被挂住、端口迟迟不释放)+ close 等完成。 * stop() 与 closeListener() 共用;与 stop 的边界:本段不动 _active/reader/定时器/ * SSE 集合/登记——那些是 stop 的全量收尾职责。 */ private closeServer; stop(): Promise; /** daemon 批 B(2026-09-30 spec §3.7 步骤 1):只关 HTTP listener 释放端口, * 进程继续活着——不清登记(旧登记保留 = 交接窗口"交接中"标注数据源)、不置 * _active(模块级激活标志仍真)、不停 reader/定时器(回滚路径 relisten 后服务 * 原样恢复)。SSE 连接随 closeAllConnections 的 socket 销毁摘除(req 'close' * 自动 delete,sendEvent 的 try/catch 吸收窗口期写入)。 */ closeListener(): Promise; /** closeListener 的逆操作(§3.7 步骤 4 回滚):同端口重新监听(不变式 1:端口跨 * 交接不漂移)。绑不上(EADDRINUSE,如新实例已占端口)时 reject——由受控交接 * 的回滚序列决策,本层不自作顺延(strictPort 语义同源)。幂等:已在监听则直接返回。 */ relisten(): Promise; /** M-1(2026-09-17 审查):token 比较恒定时间——长度先守卫防长度泄露, * timingSafeEqual 防逐前缀定时探测。全部 token 比较点(含 /api/auth 握手) * 必须经此方法,不得回退到 ===/!== 字面比较(源码契约测试锁定)。 */ private tokenEquals; private extractToken; private originAllowed; private authorized; private handle; /** POST body 读取共用(自原 handleSessionControl 的 for-await 模式抽出)。 * 上限双守卫(审查 Low,2026-09-17 批 3):content-length 头预检 + chunked(无 CL) * 累计字节断流——超限即弃读返回,不整读进内存。file save 调用方显式传 600KB * (I-6 语义),其余端点默认 64KB。 * 解码(终审 R1,2026-09-18):chunk 收集为 Buffer[],读完后 Buffer.concat * 一次 toString('utf8')——for-await 的每个 chunk 是 Buffer,若逐段 `+=`(逐段 * 隐式 toString)解码,多字节 UTF-8 序列(汉字 3 字节)跨 chunk 边界会被逐段 * 替换为 U+FFFD,而 U+FFFD 在 JSON 内合法 → JSON.parse 成功 → 损坏内容静默 * 落盘(file save 600KB 含中文注释场景)。 * JSON 解析失败 → reason bad_json(调用方 400);超限 → too_large(调用方 413)。 */ private readJsonBody; /** registry 调用参数归一(注入优先;start() 内 regOpts 同款字面量的读路径复用版)。 */ private regOpts; /** GET /api/instances:registry 直读活实例清单。version/kind/respawnOf null = 早期 * 实例(登记无对应字段,前端判据;kind/respawnOf 为 daemon 批 A/B 字段,向后兼容 * 同 version 先例);current 标记本实例(前端"重启自己"走特殊提示)。token 不外发 * (响应无凭据字段)。sessionActive(daemon 前端批 C):仅本实例行携带,且仅注入 * isMcpSessionActive 时(他实例的会话占用面板无从得知;注入缺席 → 全部行无字段, * 前端不显示而非误报空闲)。 */ private handleInstancesList; /** 实例操作审计:恒落机器级(~/.godot-mcp/machine-audit.jsonl)——实例重启无项目 * 归属,语义对齐批6-N-e"安全事件归机器"判例。不复用 auditWebGui:其 projectPath * 语义绑定项目目录,空路径的成功调用会被 existsSync 守卫整条丢弃(audit-helper * 批6-N-e 分支),实例操作走它会零留痕。best-effort,失败不阻断 HTTP 响应。 */ private auditInstanceAction; /** daemon 生命周期操作审计(daemon 批 B 2026-09-30):恒落机器级,形态对齐 * auditInstanceAction(不复用它——其 caller 绑定 instances 子系统);caller 取 * 'web-gui:daemon',与 'web-gui:instances'/'daemon:mcp' 同款「域:子系统」风格 * (见 src/daemon/mcp-endpoint.ts 同款注释)。成功路径落痕;鉴权拒绝走 * auditDaemonReject(N-1);503 未注入是配置态非安全事件,不落(对齐 * files/projects 未注入不落痕现状);best-effort,失败不阻断响应。 */ private auditDaemonAction; /** daemon 关停指令鉴权拒绝审计(N-1,批级审查 2026-09-30):spec §3.9 "401 拒绝→ * 机器级审计"。形态对齐批 A auditMcpReject(src/daemon/mcp-endpoint.ts):action * 'shutdown-auth-reject'、ok:false、details 不含 token 值(仅 error 语义 + hasAuth * 布尔)。403/401 分流:token 验对但 Origin 不符 → 'origin_forbidden',token 缺失 * 或错误 → 'unauthorized'。best-effort,失败不阻断拒绝响应。 */ private auditDaemonReject; private handleApiPost; /** GET /api/projects:list() 异步快照;注入缺席 503。鉴权已在 handle() 过。 */ private handleProjectsList; /** GET /api/settings:设置视图快照(2026-09-29 设置批);注入缺席 503。鉴权已在 handle() 过。 */ private handleSettingsGet; private filesErr; /** GET /api/projects/files:列目录({entries});注入缺席 503(对齐 projects 语义)。 */ private handleFilesList; /** GET /api/projects/file:按 mode 分派 text(JSON)/raw(带 CSP 防线)/hex(JSON)。 */ private handleFileGet; /** GET /assets/{name}:固定清单枚举(spec §4/I-2);同步读。鉴权已在 handle() 过。 */ private handleAsset; private handleSse; private sendHello; private startDataStream; private statsSnapshot; private sendEvent; private flushLogFrame; private broadcastSnapshot; /** 扫描进度节流推送(spec §5:500ms)。 */ private onScanProgress; /** 扫描完成:{scanning:false, added, total} + 最新快照(合并为单事件)。 */ private pushScanDone; /** 清单变更(add/remove/start 成功)→ 全量快照推送(spec §5)。best-effort,不抛。 */ private broadcastProjects; /** 注入缺席 → null;list() 失败 → warn + null(best-effort,不炸调用方)。 */ private safeProjectsList; /** 为包根、CWD 与全部登记项目目录刷新「面板入口.html」(项目入口批 2026-09-16)。 * fire-and-forget:start/add/scan 完成后调用,不阻塞响应;单目录写失败跳过其余继续。 * 包根版内嵌 token(零门槛授权,双击直达);CWD/登记项目走 project.godot 护栏不内嵌。 */ private refreshProjectEntries; private broadcastProjectsEvent; }