Compare commits

...

8 Commits

Author SHA1 Message Date
kdletters 7092689b36 收口 AGC 生命周期超时隔离与建项恢复
Project CI / Repository checks (push) Successful in 3m11s
Project CI / Frontend tests (push) Successful in 4m4s
Project CI / Backend tests (push) Successful in 6m14s
Project CI / Native shell tests (push) Failing after 14m20s
## 变更内容

在 master(0f829cd25)上用故障注入复现出四类"每走一步都会卡住"的生命周期残留,本轮按"超时后真正隔离并核对底层操作"收口,不再新增 operation 字段:

- 本地会话写入队列从"无限等上一次完成"改为带解围期限的闸门(60s)。Rust 侧 install/clear 本来就按 generation 单调拒绝更旧写入,所以渲染层只需保证新 generation 不被卡死的调用永久挡住;
- generation floor 读取不再把一次瞬时失败缓存成永久失败(原先 `??=` 缓存了已 reject 的 promise,导致同一渲染进程内后续登录/退出全部失败);
- 登录 UI 的 45 秒围栏只放弃等待、不放弃结果:本地运行时确实装好会话时界面跟随进入工作区,且迟到结果不会覆盖更新的登录尝试;
- 首页自动建项从 `deadlineMs: null` 改为 10 分钟兜底期限:到点解围并提示(底层创建继续在后台跑,迟到成功照常进项目),失败时保留 `scope.projectPath`、登记最近项目并新增「打开已创建的工作区」入口;底层创建未返回期间只挡"再建一个",不挡打开已有项目;
- 同步 `project.bootstrap` 权限文案断言、生命周期技术方案状态与 shared pitfalls。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`(含 skill-pack、check-config)
- `vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(428/428,含新增 5 个故障注入回归;`project.bootstrap` 文案断言在此前 master 上确定性失败)
- 定向 `clientHttp`、`clientApi`、`clientOperation`、`recentProjectsModel`、`clientRuntimeErrorBoundary`、`sessionPreview`、`start-dev-stack`、`dev-port`、`start-tauri-dev`(全部通过)
- `npm run check:doc-index`、`npm run check:encoding`、`git diff --check`

原生 Runner/IPC 与真实 Provider 下的同一批时序未执行,本轮结论来自 deterministic surface 与 mock 故障注入。

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
2026-09-14 14:49:45 +08:00
kdletters 0f829cd252 完成 DirectProject Phaser 迁移闭环 (#353)
Project CI / Repository checks (push) Failing after 2m44s
Project CI / Frontend tests (push) Failing after 2m53s
Project CI / Native shell tests (push) Failing after 6m39s
Project CI / Backend tests (push) Successful in 8m7s
## 变更

单 HTML → Phaser 4 + Vite 的迁移链路现在有明确且可执行的工程合同:Phaser/npm 目标固定走 DirectProject,旧 JSON Generator 继续只处理单文件 HTML。

- 新增受控 `project.bootstrap`,仅允许项目内 `game` 目录执行无参数 `npm install`,校验 package/lock、路径、npm 管理器并记录依赖指纹。
- `project.verify` 支持相对 `cwd`,构建前诊断缺失依赖,`cwd=game` 的 build 必须产出 `game/dist/index.html`。
- npm bootstrap 使用受控网络沙箱;通用 `command.exec npm install` 仍被拒绝。
- 同步 Runtime 工具广告、执行分发、审计、权限策略、DirectProject/Generator 提示、前端权限面板、TypeScript/Rust 契约和项目文档。
- Phaser 迁移提示覆盖 Scene、输入、敌人/守卫、波次、胜负、重开和桌面/移动双视口验收。

## 验证

- `npm run ai-game-creator-shell:typecheck`
- `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
- `cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app`
- 定向 `project.verify` Rust 测试
- `npm run check:encoding`
- `npm run check:doc-index`
- `git diff --check`
- 真实 DirectProject:`npm install`、`npm run build` 成功,`game/dist/index.html` 存在。
- 浏览器试玩:桌面视口画布非空;移动视口触摸开火、键盘换道和重开逻辑均已观察确认。

真实项目没有纳入仓库提交;PR 只包含客户端合同、Runtime 能力和验证门禁。

Reviewed-on: #353
2026-09-14 13:57:24 +08:00
kdletters c146f7f99c 建立 AGC 稳定版生命周期基础合同 (#348)
Project CI / Repository checks (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
## 变更内容

这是基于 PR #346 的稳定版生命周期基础切换,承接已完成的 HTTP body timeout、Runner blocking worker、最近项目逐项刷新和首页跨页防重:

- 新增统一 `ClientOperation` 合同,固定 operationId、requestId、phase、deadline、scope、cancellable 和 stale 判定;
- 认证 refresh 投影 `auth-refresh` operation;登录、401 refresh、退出共用平台 session generation,并由 `auth-transition` 投影 Runner phase/成功/失败/不确定状态;
- 首页自动创建记录 draft、startMode、phase 和建项后的 project scope,页面卸载不会释放 operation;
- `.app/dev-stack.json` 增加 instanceId、repoRoot、服务级 dataDir/instanceId,AGC Vite marker 增加 repoRoot/processId/port;缺身份的旧状态拒绝 AGC 后端复用;
- 新增稳定版生命周期主规范、开发运维口径和 shared pitfalls。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/clientOperation.test.ts tests/clientApi.test.ts tests/clientHttp.test.ts tests/recentProjectsModel.test.ts tests/start-dev-stack.test.ts tests/dev-port.test.ts --reporter=dot`(61 passed,2 skipped)
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/appSurface.test.ts --reporter=dot`(423/423 passed)
- `node --check scripts/dev.mjs`
- `node --check apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`
- `npm run check:doc-index`
- `npm run check:encoding`
- `git diff --check`

`scripts/dev.test.ts` 全量仍有 1 个 Windows 文件权限相关既有失败:bootstrap secret 测试在 Windows 上无法通过 chmod 模拟 0600;本变更未触及该逻辑。

Reviewed-on: #348
2026-09-14 13:56:56 +08:00
kdletters 818cfdba50 收敛项目客户端占用锁语义 (#344)
Project CI / Repository checks (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
本 PR 收敛项目锁的同进程重入语义,避免客户端内部调用等待自身持有的 `.agent/project.lock`。

变更:
- 同进程嵌套项目锁返回 advisory guard,不删除真实持有者锁。
- 跨进程占用、残留回收和权限分类保持不变。
- 增加回归测试与锁收敛里程碑/实施计划文档。

验证:
- 定向 Rust 锁测试通过。
- Runner owner 定向测试通过。
- cargo fmt --check 通过。
- npm run check:encoding 通过。
- git diff --check 通过。

后续里程碑:Runner 的 `.agent/runtime/execution-owner.lock` 迁移到统一项目占用锁仍需单独完成跨进程生命周期验证。

Reviewed-on: #344
2026-09-14 13:56:23 +08:00
kdletters 86c1187ddc 放开 DirectProject Codex 沙箱权限 (#347)
Project CI / Repository checks (push) Successful in 3m5s
Project CI / Frontend tests (push) Successful in 3m39s
Project CI / Backend tests (push) Successful in 7m31s
Project CI / Native shell tests (push) Has been cancelled
## 变更内容

- DirectProject Codex app-server 使用 danger-full-access sandbox。
- 移除 workspaceWrite / writableRoots 与文件变更审批根白名单。
- DirectProject app-server 交互请求不再按 grant root 裁剪;首页只读对话、AGC 受控 MCP、凭据隔离和 Runtime 审计边界保持不变。
- 同步更新 DirectProject 提示词、技术方案和决策记录。

## 验证

- Rust 定向测试:49 passed,1 ignored。
- npm run check:encoding 通过。
- npm run check:doc-index 通过。
- git diff --check 通过。
- pre-push-master 门禁通过。

Reviewed-on: #347
2026-09-14 13:19:44 +08:00
kdletters 991b2a1104 修复 AGC 异步操作恢复闭环 (#346)
Project CI / Repository checks (push) Successful in 3m43s
Project CI / Backend tests (push) Successful in 6m16s
Project CI / Frontend tests (push) Successful in 3m54s
Project CI / Native shell tests (push) Successful in 20m0s
## 变更内容

AGC 登录、Runner 会话、最近项目检查和首页自动创建原先各自维护异步状态,响应体卡住、单目录变慢或切页会导致按钮长期 busy、项目列表整体不可用或重复创建项目。本 PR 将这些入口收口到可恢复的生命周期边界:

- 认证响应体读取增加独立超时,refresh singleflight 在失败后释放;
- Runner 会话安装/清除移到 blocking worker,登录/退出增加 45 秒 UI fence;
- 最近项目逐项检查并设置单项目超时,已完成行不受其它慢目录阻塞;
- 首页自动创建锁提升到 WorkspaceLauncher,切页后仍防重并保留状态;
- 新增异步闭环技术方案、body 卡住测试、逐行项目测试和跨页创建测试。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/clientHttp.test.ts tests/clientApi.test.ts tests/recentProjectsModel.test.ts --reporter=dot`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/appSurface.test.ts --reporter=dot`
- `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
- `npm run check:doc-index`
- `npm run check:encoding`
- `git diff --check`

`appSurface.test.ts` 390/390 通过;保留仓库既有 act/jsdom media warning。本 PR 未触发真实 Provider 或发布安装包。

Reviewed-on: #346
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-14 13:19:13 +08:00
lhk229 28e4d6c627 Merge pull request '修复策划产物归类为文档' (#343) from opt/design-docs into master
Project CI / Repository checks (push) Successful in 3m11s
Project CI / Frontend tests (push) Successful in 3m45s
Project CI / Backend tests (push) Successful in 6m28s
Project CI / Native shell tests (push) Successful in 20m48s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/343
2026-09-14 12:27:52 +08:00
lhk229 a7fa8e103a 修复策划产物归类为文档
Project CI / Repository checks (pull_request) Successful in 3m27s
Project CI / Frontend tests (pull_request) Successful in 4m7s
Project CI / Backend tests (pull_request) Successful in 6m50s
Project CI / Native shell tests (pull_request) Successful in 20m50s
将策划产物注册 kind 从 design-document 调整为 document

同步更新项目工具测试夹具
2026-09-14 03:57:20 +00:00
63 changed files with 2124 additions and 443 deletions
@@ -84,6 +84,7 @@ function resolveBackendTargetsFromState(
requireAgcBackend = false, requireAgcBackend = false,
expectedDatabase = backendDatabase, expectedDatabase = backendDatabase,
expectedSpacetimeDataDir = backendSpacetimeDataDir, expectedSpacetimeDataDir = backendSpacetimeDataDir,
expectedRepoRoot = repoRoot,
fallbackApiTarget = defaultApiTarget, fallbackApiTarget = defaultApiTarget,
} = {}, } = {},
) { ) {
@@ -101,7 +102,25 @@ function resolveBackendTargetsFromState(
const hasMatchingDataDir = const hasMatchingDataDir =
Boolean(spacetimeDataDir) && Boolean(spacetimeDataDir) &&
spacetimeDataDir === resolve(expectedSpacetimeDataDir); spacetimeDataDir === resolve(expectedSpacetimeDataDir);
const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir; const instanceId =
typeof state?.instanceId === 'string' ? state.instanceId.trim() : '';
const hasMatchingRepoRoot =
typeof state?.repoRoot === 'string' &&
resolve(state.repoRoot) === resolve(expectedRepoRoot);
const hasMatchingInstance =
Boolean(instanceId) &&
[apiServer, spacetime, bgfilterWorker]
.filter(Boolean)
.every(
(service) =>
service.repoRoot &&
resolve(service.repoRoot) === resolve(expectedRepoRoot) &&
service.instanceId === instanceId,
);
const hasMatchingBackend =
hasMatchingDatabase &&
hasMatchingDataDir &&
(!requireAgcBackend || (hasMatchingRepoRoot && hasMatchingInstance));
const canReuseState = !requireAgcBackend || hasMatchingBackend; const canReuseState = !requireAgcBackend || hasMatchingBackend;
const apiUrl = const apiUrl =
canReuseState && isActive(apiServer) && apiServer.url canReuseState && isActive(apiServer) && apiServer.url
@@ -127,6 +146,8 @@ function resolveBackendTargetsFromState(
spacetimeDataDir, spacetimeDataDir,
hasMatchingDatabase, hasMatchingDatabase,
hasMatchingDataDir, hasMatchingDataDir,
hasMatchingRepoRoot,
hasMatchingInstance,
hasMatchingBackend, hasMatchingBackend,
}; };
} }
@@ -1,6 +1,6 @@
处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;单文件小改优先使用 file.patch;涉及多个文件时优先使用 project.patchset,它会自动创建 checkpoint,无需额外调用 project.checkpoint,并在成功后用返回的 checkpointId 调用 project.diff(includeContent=true) 审查整体变更;只有确认文件已废弃时才删除。 处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;单文件小改优先使用 file.patch;涉及多个文件时优先使用 project.patchset,它会自动创建 checkpoint,无需额外调用 project.checkpoint,并在成功后用返回的 checkpointId 调用 project.diff(includeContent=true) 审查整体变更;只有确认文件已废弃时才删除。
每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.execcommand.start,都会产生新的项目 revision最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能调用 respond_to_user 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verifyscript 可以是 check、typecheck、test、lint、build,或使用 check:<name>、test:<name>(例如 test:unit)、lint:<name>、typecheck:<name>、build:<name>、verify:<name>、validate:<name> 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。 每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.execcommand.start 或 project.bootstrap,都会产生新的项目 revisionDirectProject 的 npm/Phaser 工程先用 project.bootstrap {cwd:"game"} 执行受控无参数 npm install,再用 project.verify {cwd:"game",script:"build",expectedCommand:从 game/package.json 原样读取} 构建,并确认 game/dist/index.html 存在。最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能调用 respond_to_user 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verifyscript 可以是 check、typecheck、test、lint、build,或使用 check:<name>、test:<name>(例如 test:unit)、lint:<name>、typecheck:<name>、build:<name>、verify:<name>、validate:<name> 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。
每 6 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。 每 6 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。
@@ -1378,15 +1378,19 @@ fn codex_app_server_thread_start_params(
base_instructions: String, base_instructions: String,
use_model_provider: bool, use_model_provider: bool,
) -> serde_json::Value { ) -> serde_json::Value {
// DirectProject is an autonomous Codex session. The app-server sandbox // DirectProject is an autonomous Codex session with explicit full OS
// remains the hard write boundary; approval prompts are not a second // access; approval prompts are not a second harness that can stall a turn.
// harness that can stall a turn. DirectHome/ToolHost stay passive. // DirectHome/ToolHost stay passive.
let approval_policy = "never"; let approval_policy = "never";
let mut params = serde_json::json!({ let mut params = serde_json::json!({
"model": model, "model": model,
"cwd": workspace_path, "cwd": workspace_path,
"approvalPolicy": approval_policy, "approvalPolicy": approval_policy,
"sandbox": if workspace_mode.allows_workspace_writes() { "workspace-write" } else { "read-only" }, "sandbox": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
"danger-full-access"
} else {
"read-only"
},
"ephemeral": true, "ephemeral": true,
"baseInstructions": base_instructions "baseInstructions": base_instructions
}); });
@@ -1404,7 +1408,6 @@ fn codex_app_server_turn_start_params(
thread_id: &str, thread_id: &str,
input: serde_json::Value, input: serde_json::Value,
model: &str, model: &str,
workspace_path: &std::path::Path,
workspace_mode: CodexAppServerWorkspaceMode, workspace_mode: CodexAppServerWorkspaceMode,
client_user_message_id: Option<&str>, client_user_message_id: Option<&str>,
) -> serde_json::Value { ) -> serde_json::Value {
@@ -1415,14 +1418,12 @@ fn codex_app_server_turn_start_params(
"model": model, "model": model,
"approvalPolicy": approval_policy, "approvalPolicy": approval_policy,
}); });
if workspace_mode.allows_workspace_writes() { if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
// npm install/build must resolve project dependencies. Network access // DirectProject is an explicitly user-selected local Codex session.
// is enabled only for DirectProject; writableRoots keeps the file-write // Give the native Codex tools the full OS sandbox profile so they are
// boundary at the real game workspace. // not narrowed by a project-root writableRoots allowlist.
params["sandboxPolicy"] = serde_json::json!({ params["sandboxPolicy"] = serde_json::json!({
"type": "workspaceWrite", "type": "dangerFullAccess"
"writableRoots": [workspace_path],
"networkAccess": true
}); });
} }
if let Some(client_user_message_id) = client_user_message_id if let Some(client_user_message_id) = client_user_message_id
@@ -1436,40 +1437,27 @@ fn codex_app_server_turn_start_params(
} }
fn game_creator_codex_app_server_interaction_response( fn game_creator_codex_app_server_interaction_response(
workspace_path: &std::path::Path,
workspace_mode: CodexAppServerWorkspaceMode, workspace_mode: CodexAppServerWorkspaceMode,
id: u64, id: u64,
method: &str, _method: &str,
requested_grant_root: Option<&str>, _requested_grant_root: Option<&str>,
) -> serde_json::Value { ) -> serde_json::Value {
let direct_workspace = workspace_mode.allows_workspace_writes(); if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
let file_change_within_workspace = game_creator_codex_file_change_request_is_allowed( // Full-access DirectProject sessions do not use a file-root allowlist
workspace_path, // or a second approval gate. The declared sandbox policy is the only
method, // capability boundary for native Codex operations.
requested_grant_root, return serde_json::json!({
);
if direct_workspace && file_change_within_workspace {
serde_json::json!({
"id": id, "id": id,
"result": { "decision": "accept" } "result": { "decision": "accept" }
}) });
} else if direct_workspace && method == "item/fileChange/requestApproval" {
serde_json::json!({
"id": id,
"error": {
"code": -32602,
"message": "Genarrative AGC 只允许当前项目工作区的文件变更审批"
}
})
} else {
serde_json::json!({
"id": id,
"error": {
"code": -32601,
"message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求"
}
})
} }
serde_json::json!({
"id": id,
"error": {
"code": -32601,
"message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求"
}
})
} }
#[cfg(test)] #[cfg(test)]
@@ -2734,7 +2722,6 @@ impl CodexAppServerConnection {
&thread_id, &thread_id,
input, input,
model, model,
&self.inner.workspace_path,
self.inner.workspace_mode, self.inner.workspace_mode,
direct_client_turn_id, direct_client_turn_id,
); );
@@ -3293,7 +3280,6 @@ async fn read_game_creator_codex_app_server_stdout(
); );
} }
let response = game_creator_codex_app_server_interaction_response( let response = game_creator_codex_app_server_interaction_response(
&inner.workspace_path,
inner.workspace_mode, inner.workspace_mode,
id, id,
method, method,
@@ -3635,82 +3621,6 @@ async fn read_game_creator_codex_app_server_stderr(
} }
} }
#[cfg(windows)]
fn game_creator_codex_workspace_path_key(path: &std::path::Path) -> String {
let value = path.to_string_lossy();
value
.strip_prefix("\\\\?\\")
.unwrap_or(value.as_ref())
.replace('/', "\\")
.trim_end_matches('\\')
.to_ascii_lowercase()
}
fn game_creator_codex_grant_root_is_within_workspace(
workspace: &std::path::Path,
grant_root: &str,
) -> bool {
fn canonicalize_with_missing_tail(path: &std::path::Path) -> Option<std::path::PathBuf> {
if !path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return None;
}
let mut existing = path.to_path_buf();
let mut missing_tail = Vec::new();
while !existing.exists() {
missing_tail.push(existing.file_name()?.to_os_string());
if !existing.pop() {
return None;
}
}
let mut normalized = existing.canonicalize().ok()?;
for component in missing_tail.iter().rev() {
normalized.push(component);
}
Some(normalized)
}
let workspace = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
let Some(grant_root) = canonicalize_with_missing_tail(std::path::Path::new(grant_root)) else {
return false;
};
#[cfg(windows)]
{
let workspace_key = game_creator_codex_workspace_path_key(&workspace);
let grant_key = game_creator_codex_workspace_path_key(&grant_root);
grant_key == workspace_key
|| grant_key
.strip_prefix(&workspace_key)
.is_some_and(|suffix| suffix.starts_with('\\'))
}
#[cfg(not(windows))]
{
grant_root == workspace || grant_root.starts_with(&workspace)
}
}
fn game_creator_codex_file_change_request_is_allowed(
workspace: &std::path::Path,
method: &str,
grant_root: Option<&str>,
) -> bool {
if method != "item/fileChange/requestApproval" {
return false;
}
// `grantRoot: null` means the already-declared turn sandbox root. It is
// valid only for file changes; it must never authorize another capability
// or a broader permission request.
grant_root.is_none()
|| grant_root.is_some_and(|grant_root| {
game_creator_codex_grant_root_is_within_workspace(workspace, grant_root)
})
}
async fn fail_game_creator_codex_app_server_connection( async fn fail_game_creator_codex_app_server_connection(
inner: &Weak<CodexAppServerInner>, inner: &Weak<CodexAppServerInner>,
error: String, error: String,
@@ -4481,7 +4391,6 @@ mod tests {
"home-thread", "home-thread",
serde_json::json!([{ "type": "text", "text": "你好" }]), serde_json::json!([{ "type": "text", "text": "你好" }]),
"fixture-model", "fixture-model",
workspace,
CodexAppServerWorkspaceMode::DirectHome, CodexAppServerWorkspaceMode::DirectHome,
None, None,
); );
@@ -4495,7 +4404,6 @@ mod tests {
"item/permissions/requestApproval", "item/permissions/requestApproval",
] { ] {
let response = game_creator_codex_app_server_interaction_response( let response = game_creator_codex_app_server_interaction_response(
workspace,
CodexAppServerWorkspaceMode::DirectHome, CodexAppServerWorkspaceMode::DirectHome,
7, 7,
method, method,
@@ -4513,14 +4421,10 @@ mod tests {
} }
#[test] #[test]
fn direct_project_protocol_and_interactions_expose_only_the_real_game_workspace() { fn direct_project_protocol_uses_full_access_without_a_root_allowlist() {
let temp = tempfile::tempdir().expect("temp dir"); let temp = tempfile::tempdir().expect("temp dir");
let project_root = temp.path().join("project"); let project_root = temp.path().join("project");
let assets = project_root.join("assets");
let agent = project_root.join(".agent");
std::fs::create_dir_all(&project_root).expect("project root"); std::fs::create_dir_all(&project_root).expect("project root");
std::fs::create_dir(&assets).expect("assets directory");
std::fs::create_dir(&agent).expect("agent directory");
let workspace = let workspace =
resolve_direct_codex_game_workspace(&project_root).expect("resolve project workspace"); resolve_direct_codex_game_workspace(&project_root).expect("resolve project workspace");
assert_eq!( assert_eq!(
@@ -4536,83 +4440,40 @@ mod tests {
true, true,
); );
assert_eq!(thread["cwd"], serde_json::json!(workspace)); assert_eq!(thread["cwd"], serde_json::json!(workspace));
assert_eq!(thread["sandbox"], "workspace-write"); assert_eq!(thread["sandbox"], "danger-full-access");
let turn = codex_app_server_turn_start_params( let turn = codex_app_server_turn_start_params(
"project-thread", "project-thread",
serde_json::json!([{ "type": "text", "text": "修复游戏" }]), serde_json::json!([{ "type": "text", "text": "修复游戏" }]),
"fixture-model", "fixture-model",
&workspace,
CodexAppServerWorkspaceMode::DirectProject, CodexAppServerWorkspaceMode::DirectProject,
Some("direct-turn-0001"), Some("direct-turn-0001"),
); );
assert_eq!(turn["clientUserMessageId"], "direct-turn-0001"); assert_eq!(turn["clientUserMessageId"], "direct-turn-0001");
assert_eq!( assert_eq!(
turn.pointer("/sandboxPolicy/writableRoots/0"), turn.pointer("/sandboxPolicy/type"),
Some(&serde_json::json!(workspace)) Some(&serde_json::json!("dangerFullAccess"))
);
assert_eq!(
turn.pointer("/sandboxPolicy/networkAccess"),
Some(&serde_json::json!(true))
);
let authority_paths = [
turn.get("cwd"),
turn.pointer("/sandboxPolicy/writableRoots/0"),
];
assert!(
authority_paths
.iter()
.flatten()
.all(|value| value.as_str() == Some(workspace.to_string_lossy().as_ref())),
"writable params must be exactly the project workspace"
); );
assert!(turn.pointer("/sandboxPolicy/writableRoots").is_none());
assert!(turn.pointer("/sandboxPolicy/networkAccess").is_none());
let workspace_string = workspace.to_string_lossy().into_owned(); for (id, method) in [
for allowed_root in [None, Some(workspace_string.as_str())] { (9, "item/fileChange/requestApproval"),
(10, "item/commandExecution/requestApproval"),
(11, "item/permissions/requestApproval"),
(12, "item/tool/call"),
] {
let response = game_creator_codex_app_server_interaction_response( let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject, CodexAppServerWorkspaceMode::DirectProject,
9, id,
"item/fileChange/requestApproval", method,
allowed_root, Some("C:\\outside-project"),
); );
assert_eq!( assert_eq!(
response.pointer("/result/decision"), response.pointer("/result/decision"),
Some(&serde_json::json!("accept")) Some(&serde_json::json!("accept"))
); );
} }
for forbidden_root in [assets, agent] {
let forbidden_root = forbidden_root.to_string_lossy().into_owned();
let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
10,
"item/fileChange/requestApproval",
Some(&forbidden_root),
);
assert_eq!(
response.pointer("/result/decision"),
Some(&serde_json::json!("accept")),
"project-root children must stay writable: {forbidden_root}"
);
}
for method in [
"item/commandExecution/requestApproval",
"item/permissions/requestApproval",
"item/tool/call",
] {
for requested_root in [None, Some(workspace_string.as_str())] {
let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
11,
method,
requested_root,
);
assert!(response.get("error").is_some());
assert!(response.get("result").is_none());
}
}
} }
#[test] #[test]
@@ -4630,26 +4491,6 @@ mod tests {
assert!(resolve_direct_codex_game_workspace(&file_root).is_err()); assert!(resolve_direct_codex_game_workspace(&file_root).is_err());
} }
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn direct_project_grant_root_comparison_remains_case_sensitive() {
let temp = tempfile::tempdir().expect("temp dir");
let project_root = temp.path().join("Project");
let child = project_root.join("assets");
let different_case = temp.path().join("project").join("assets");
std::fs::create_dir_all(&child).expect("child directory");
std::fs::create_dir_all(&different_case).expect("different-case directory");
assert!(game_creator_codex_grant_root_is_within_workspace(
&project_root,
project_root.to_string_lossy().as_ref()
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&project_root,
different_case.to_string_lossy().as_ref()
));
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn direct_project_rejects_a_non_directory_workspace() { fn direct_project_rejects_a_non_directory_workspace() {
@@ -5246,51 +5087,25 @@ while IFS= read -r line; do :; done
} }
#[test] #[test]
fn direct_file_change_approval_is_limited_to_workspace() { fn direct_project_interactions_accept_full_access_without_a_root_allowlist() {
let temp = tempfile::tempdir().expect("temp dir"); for method in [
let workspace = temp.path().join("demo");
let child = workspace.join("assets");
let sibling = temp.path().join("demolition");
std::fs::create_dir_all(&child).expect("workspace child");
std::fs::create_dir(&sibling).expect("sibling");
assert!(game_creator_codex_file_change_request_is_allowed(
&workspace,
"item/fileChange/requestApproval", "item/fileChange/requestApproval",
None "item/commandExecution/requestApproval",
)); "item/permissions/requestApproval",
assert!(game_creator_codex_grant_root_is_within_workspace( "item/tool/call",
&workspace, ] {
workspace.to_string_lossy().as_ref() let response = game_creator_codex_app_server_interaction_response(
)); CodexAppServerWorkspaceMode::DirectProject,
assert!(game_creator_codex_grant_root_is_within_workspace( 1,
&workspace, method,
child.to_string_lossy().as_ref() Some("C:\\outside-project"),
)); );
assert!(!game_creator_codex_grant_root_is_within_workspace( assert_eq!(
&workspace, response.pointer("/result/decision"),
sibling.to_string_lossy().as_ref() Some(&serde_json::json!("accept"))
)); );
assert!(!game_creator_codex_file_change_request_is_allowed( assert!(response.get("error").is_none());
&workspace, }
"item/fileChange/requestApproval",
Some(sibling.to_string_lossy().as_ref())
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&workspace,
sibling.join("missing").to_string_lossy().as_ref()
));
assert!(game_creator_codex_grant_root_is_within_workspace(
&workspace,
workspace.join("missing").to_string_lossy().as_ref()
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&workspace,
workspace
.join("..")
.join("outside")
.to_string_lossy()
.as_ref()
));
} }
#[test] #[test]
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@ JSON schema:
{ "group": "balance", "role": "Difficulty", "summary": "数值交接摘要", "outputs": ["game/balance.json"], "next": "交给程序组读取" }, { "group": "balance", "role": "Difficulty", "summary": "数值交接摘要", "outputs": ["game/balance.json"], "next": "交给程序组读取" },
{ "group": "art", "role": "Asset", "summary": "美术交接摘要", "outputs": ["assets/manifest.art.json"], "next": "进入画板或本地资产登记" }, { "group": "art", "role": "Asset", "summary": "美术交接摘要", "outputs": ["assets/manifest.art.json"], "next": "进入画板或本地资产登记" },
{ "group": "audio", "role": "SFX", "summary": "音乐音效交接摘要", "outputs": ["assets/manifest.audio.json"], "next": "进入画板音频链路" }, { "group": "audio", "role": "SFX", "summary": "音乐音效交接摘要", "outputs": ["assets/manifest.audio.json"], "next": "进入画板音频链路" },
{ "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html"], "next": "交给 Playtest" }, { "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html", "game/game.js", "game/package.json", "game/vite.config.js"], "next": "先 project.bootstrap,再 project.verify(build)交给 Playtest" },
{ "group": "publishing", "role": "Publish", "summary": "运营交接摘要", "outputs": ["exports/README.md"], "next": "等待预览验收" } { "group": "publishing", "role": "Publish", "summary": "运营交接摘要", "outputs": ["exports/README.md"], "next": "等待预览验收" }
], ],
"handoffSummary": "六组 agent 的交接摘要,每组一行", "handoffSummary": "六组 agent 的交接摘要,每组一行",
@@ -24,6 +24,7 @@ JSON schema:
} }
gameHtml 规则: gameHtml 规则:
- 本次若目标是 Phaser/npmworkspaceMode 必须为 DirectProject;先写入完整 game/ 工程文件,再由受控项目工具安装与构建。
- 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。 - 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。
- 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。 - 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。
- 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。 - 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。
@@ -1192,6 +1192,7 @@ pub(in crate::agent) fn agent_runtime_public_action_input_summary(
| "project.patchset" | "project.patchset"
| "project.search" | "project.search"
| "project.verify" | "project.verify"
| "project.bootstrap"
| "file.list" | "file.list"
| "file.read" | "file.read"
| "file.write" | "file.write"
@@ -1259,6 +1260,7 @@ pub(crate) fn agent_runtime_tool_action_fingerprint(
| "command.stdin" | "command.stdin"
| "command.terminate" | "command.terminate"
| "project.verify" | "project.verify"
| "project.bootstrap"
) )
.then(|| { .then(|| {
let metadata = command_sandbox_platform_metadata(); let metadata = command_sandbox_platform_metadata();
@@ -1468,11 +1470,23 @@ pub(crate) fn agent_runtime_tool_action_input_summary(
.and_then(|value| value.as_bool()) .and_then(|value| value.as_bool())
.unwrap_or(false) .unwrap_or(false)
), ),
"project.bootstrap" => {
format!(
"cwd={} · timeoutSeconds={}",
relative_path(&["cwd"]),
input
.get("timeoutSeconds")
.or_else(|| input.get("timeout_seconds"))
.and_then(|value| value.as_u64())
.unwrap_or(300)
)
}
"project.verify" => { "project.verify" => {
let expected_command = text(&["expectedCommand", "expected_command"]); let expected_command = text(&["expectedCommand", "expected_command"]);
let command_chars = expected_command.chars().count(); let command_chars = expected_command.chars().count();
format!( format!(
"script={} · expectedCommandSha256={:x} · expectedCommandChars={} · timeoutSeconds={}", "cwd={} · script={} · expectedCommandSha256={:x} · expectedCommandChars={} · timeoutSeconds={}",
relative_path(&["cwd"]),
text(&["script"]), text(&["script"]),
Sha256::digest(expected_command.as_bytes()), Sha256::digest(expected_command.as_bytes()),
command_chars, command_chars,
@@ -184,6 +184,17 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
) )
.await .await
} }
"project.bootstrap" => {
observe_agent_runtime_project_bootstrap(
root,
agent_id,
run_id,
action_id,
&action_fingerprint,
&action.input,
)
.await
}
"project.checkpoint" => observe_agent_runtime_project_checkpoint(root), "project.checkpoint" => observe_agent_runtime_project_checkpoint(root),
"project.restore" => { "project.restore" => {
observe_agent_runtime_project_restore(root, agent_id, run_id, &action.input) observe_agent_runtime_project_restore(root, agent_id, run_id, &action.input)
@@ -8,10 +8,15 @@ pub(crate) struct AgentRuntimeAutonomousSourcePayloadStats {
} }
pub(in crate::agent) fn agent_runtime_autonomous_project_verify_available(root: &Path) -> bool { pub(in crate::agent) fn agent_runtime_autonomous_project_verify_available(root: &Path) -> bool {
let package_path = root.join("package.json"); [root.join("package.json"), root.join("game/package.json")]
fs::symlink_metadata(package_path) .into_iter()
.map(|metadata| metadata.file_type().is_file() && !metadata.file_type().is_symlink()) .any(|package_path| {
.unwrap_or(false) fs::symlink_metadata(package_path)
.map(|metadata| {
metadata.file_type().is_file() && !metadata.file_type().is_symlink()
})
.unwrap_or(false)
})
} }
pub(in crate::agent) fn add_agent_runtime_autonomous_source_field( pub(in crate::agent) fn add_agent_runtime_autonomous_source_field(
@@ -345,7 +345,7 @@ fn build_game_creator_agent_background_tool_plan_request_at(
let project_tools_contract = if runtime_owner_artifact_validation_available { let project_tools_contract = if runtime_owner_artifact_validation_available {
"project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;当前固定 owner 的函数目录不广告 project.verifyproject.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpointproject.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpointproject.restore 使用 {\"checkpointId\":\"checkpoint id\"}project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;当前固定 owner 的函数目录不广告 project.verifyproject.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpointproject.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpointproject.restore 使用 {\"checkpointId\":\"checkpoint id\"}project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string()
} else { } else {
"project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.verify 使用 {\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpointproject.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpointproject.restore 使用 {\"checkpointId\":\"checkpoint id\"}project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.bootstrap 使用 {\"cwd\":\"game\",\"timeoutSeconds\":300},只执行 game 目录无参数 npm install 并记录 package/lock 指纹;project.verify 使用 {\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"对应 cwd 的 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120,\"cwd\":\"game\"},只执行对应 cwd package.json 中同名 npm 脚本,build 必须确认 game/dist/index.htmlexpectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpointproject.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpointproject.restore 使用 {\"checkpointId\":\"checkpoint id\"}project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string()
}; };
let prompt = format!( let prompt = format!(
concat!( concat!(
@@ -28,6 +28,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
"project.index", "project.index",
"project.search", "project.search",
"project.verify", "project.verify",
"project.bootstrap",
"project.checkpoint", "project.checkpoint",
"project.restore", "project.restore",
"project.diff", "project.diff",
@@ -83,6 +84,7 @@ pub(crate) fn agent_runtime_acceptance_evidence_tools() -> BTreeSet<&'static str
"project.index", "project.index",
"project.search", "project.search",
"project.verify", "project.verify",
"project.bootstrap",
"project.diff", "project.diff",
"git.inspect", "git.inspect",
"project.patchset", "project.patchset",
@@ -156,6 +156,7 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS: &[&str] =
"file.delete", "file.delete",
"project.patchset", "project.patchset",
"project.verify", "project.verify",
"project.bootstrap",
"command.run_limited", "command.run_limited",
"preview.validate", "preview.validate",
"canvas.asset_generate", "canvas.asset_generate",
@@ -109,6 +109,7 @@ pub(crate) fn agent_runtime_tool_requires_repository_context_fingerprint_gate(to
| "project.index" | "project.index"
| "project.search" | "project.search"
| "project.verify" | "project.verify"
| "project.bootstrap"
| "project.checkpoint" | "project.checkpoint"
| "project.patchset" | "project.patchset"
| "project.restore" | "project.restore"
@@ -857,6 +857,12 @@ pub(crate) async fn observe_agent_runtime_project_verify(
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
let cwd = input
.get("cwd")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or(".")
.to_string();
let timeout_seconds = let timeout_seconds =
agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"]) agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"])
.unwrap_or(AGENT_RUNTIME_PROJECT_VERIFY_DEFAULT_TIMEOUT_SECONDS); .unwrap_or(AGENT_RUNTIME_PROJECT_VERIFY_DEFAULT_TIMEOUT_SECONDS);
@@ -896,6 +902,7 @@ pub(crate) async fn observe_agent_runtime_project_verify(
script.as_str(), script.as_str(),
expected_command.as_str(), expected_command.as_str(),
timeout_seconds, timeout_seconds,
cwd.as_str(),
|| { || {
verification_state = Some(begin_agent_runtime_project_verification_locked( verification_state = Some(begin_agent_runtime_project_verification_locked(
root, root,
@@ -931,6 +938,7 @@ pub(crate) async fn observe_agent_runtime_project_verify(
"script": verification.script, "script": verification.script,
"expectedCommand": audit_expected_command, "expectedCommand": audit_expected_command,
"packageManager": verification.package_manager, "packageManager": verification.package_manager,
"cwd": verification.cwd_relative,
"status": verification.status, "status": verification.status,
"exitCode": verification.exit_code, "exitCode": verification.exit_code,
"timedOut": verification.timed_out, "timedOut": verification.timed_out,
@@ -1039,3 +1047,81 @@ pub(crate) async fn observe_agent_runtime_project_verify(
} }
} }
} }
pub(crate) async fn observe_agent_runtime_project_bootstrap(
root: &Path,
agent_id: &str,
run_id: &str,
action_id: Option<&str>,
action_fingerprint: &str,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let cwd = input
.get("cwd")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let timeout = agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"])
.unwrap_or(300);
if cwd != "game" {
return AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: "failed".to_string(),
summary: "project.bootstrap 只允许 cwd=game".to_string(),
detail: None,
};
}
let _lock = match acquire_project_write_lock(root, "project.bootstrap") {
Ok(lock) => lock,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: "failed".to_string(),
summary: "project.bootstrap 无法取得项目执行锁".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 240)),
}
}
};
let result = crate::project::run_project_bootstrap_at(root, timeout as u64).await;
match result {
Ok(value) if value.status == "completed" => {
if let Err(error) = append_agent_db_record(
root,
serde_json::json!({
"recordType":"agent.runtime.project.bootstrap", "agentId":agent_id, "runId":run_id,
"actionId":action_id, "actionFingerprint":action_fingerprint, "cwd":"game",
"packageSha256":value.package_sha256, "lockSha256":value.lock_sha256,
"status":value.status, "logPath":value.log_path, "output":value.output
}),
) {
return AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
summary: "project.bootstrap 已安装,但审计记录无法落盘".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: "ok".to_string(),
summary: "game 依赖安装已完成".to_string(),
detail: Some(format!(
"cwd=game · packageSha256={} · lockSha256={}",
value.package_sha256,
value.lock_sha256.as_deref().unwrap_or("none")
)),
}
}
Ok(value) => AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: "failed".to_string(),
summary: "game 依赖安装失败".to_string(),
detail: Some(value.output),
},
Err(error) => AgentRuntimeToolObservation {
tool: "project.bootstrap".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
},
}
}
@@ -1009,7 +1009,8 @@ fn runtime_tool_description(tool: &str) -> &'static str {
} }
"project.index" => "刷新并读取有界仓库启动上下文。", "project.index" => "刷新并读取有界仓库启动上下文。",
"project.search" => "在项目文本文件中做有界字面量搜索。", "project.search" => "在项目文本文件中做有界字面量搜索。",
"project.verify" => "运行 package.json 原样声明的验证脚本。", "project.verify" => "在项目或指定相对 cwd 中运行 package.json 原样声明的验证脚本,并检查构建产物",
"project.bootstrap" => "仅在项目 game 目录受控执行无参数 npm install,并记录依赖文件指纹。",
"project.checkpoint" => "创建项目本地 checkpoint。", "project.checkpoint" => "创建项目本地 checkpoint。",
"project.restore" => "从 checkpoint 恢复当前项目。", "project.restore" => "从 checkpoint 恢复当前项目。",
"project.diff" => "读取 checkpoint 与当前项目之间的有界差异。", "project.diff" => "读取 checkpoint 与当前项目之间的有界差异。",
@@ -1154,10 +1155,18 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
} }
}), }),
"project.verify" => json!({ "project.verify" => json!({
"type": "object", "required": ["script", "expectedCommand", "timeoutSeconds"], "additionalProperties": false, "type": "object", "required": ["script", "expectedCommand", "timeoutSeconds", "cwd"], "additionalProperties": false,
"properties": { "properties": {
"script": { "type": "string", "minLength": 1 }, "script": { "type": "string", "minLength": 1 },
"expectedCommand": { "type": "string", "minLength": 1 }, "expectedCommand": { "type": "string", "minLength": 1 },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 },
"cwd": { "type": ["string", "null"], "pattern": "^[A-Za-z0-9._/-]+$" }
}
}),
"project.bootstrap" => json!({
"type": "object", "required": ["cwd", "timeoutSeconds"], "additionalProperties": false,
"properties": {
"cwd": { "type": "string", "const": "game" },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 } "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 }
} }
}), }),
@@ -647,9 +647,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String>
register_local_asset_at( register_local_asset_at(
root, root,
&relative, &relative,
"design-document", "document",
media_type, media_type,
"design-document", "document",
GameCreationAppAssetSource { GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded, kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None, canvas_project_id: None,
@@ -321,6 +321,44 @@ pub(crate) fn resolve_project_command_spec_at(
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error)) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))
} }
/// Resolves the one privileged npm operation used to hydrate a DirectProject.
/// It intentionally bypasses the general command.exec npm allow-list: callers
/// must use the dedicated `project.bootstrap` action, which only accepts the
/// literal `npm install` in the project's `game` directory.
pub(crate) fn resolve_project_bootstrap_spec_at(
root: &Path,
timeout_seconds: u64,
) -> Result<ProjectCommandSpec, ProjectCommandError> {
validate_project_root(root)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
let cwd_relative = "game".to_string();
let cwd = resolve_local_project_path(root, &cwd_relative)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
validate_project_command_cwd_components(root, &cwd_relative)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
if !(PROJECT_COMMAND_MIN_TIMEOUT_SECONDS..=PROJECT_COMMAND_MAX_TIMEOUT_SECONDS)
.contains(&timeout_seconds)
{
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Validation,
format!("project.bootstrap timeoutSeconds 必须在 {PROJECT_COMMAND_MIN_TIMEOUT_SECONDS}-{PROJECT_COMMAND_MAX_TIMEOUT_SECONDS} 之间"),
));
}
let program = "npm".to_string();
let (executable, safe_path) = resolve_project_command_executable(root, &program)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
Ok(ProjectCommandSpec {
program,
executable,
safe_path,
arguments: vec!["install".to_string()],
cwd_relative,
cwd,
timeout_seconds,
verification_eligible: false,
})
}
fn resolve_project_command_spec_inner( fn resolve_project_command_spec_inner(
root: &Path, root: &Path,
program: &str, program: &str,
@@ -1202,6 +1240,19 @@ pub(crate) fn prepare_project_command_launch_spec(
(OsString::from("NO_PROXY"), OsString::new()), (OsString::from("NO_PROXY"), OsString::new()),
(OsString::from("PATH"), spec.safe_path.clone()), (OsString::from("PATH"), spec.safe_path.clone()),
]; ];
if spec
.arguments
.first()
.is_some_and(|argument| argument == "install")
{
for (name, value) in &mut environment {
match name.to_string_lossy().as_ref() {
"npm_config_offline" => *value = OsString::from("false"),
"HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" => *value = OsString::new(),
_ => {}
}
}
}
for name in ["SystemRoot", "PATHEXT", "RUSTUP_HOME"] { for name in ["SystemRoot", "PATHEXT", "RUSTUP_HOME"] {
if let Some(value) = std::env::var_os(name) { if let Some(value) = std::env::var_os(name) {
environment.push((OsString::from(name), value)); environment.push((OsString::from(name), value));
@@ -1998,6 +2049,16 @@ pub(crate) async fn run_project_command_with_output_at(
.await .await
} }
pub(crate) async fn run_project_bootstrap_command_at(
root: &Path,
timeout_seconds: u64,
) -> Result<ProjectCommandResult, ProjectCommandError> {
let spec = resolve_project_bootstrap_spec_at(root, timeout_seconds)?;
let launch = prepare_project_command_launch_spec(root, &spec)?;
let staged = stage_project_command_launch_spec(&spec, launch)?;
run_prepared_project_command_with_output_at(root, &spec, staged, None, || Ok(())).await
}
pub(crate) async fn run_prepared_project_command_with_output_at<F>( pub(crate) async fn run_prepared_project_command_with_output_at<F>(
root: &Path, root: &Path,
spec: &ProjectCommandSpec, spec: &ProjectCommandSpec,
@@ -302,7 +302,19 @@ mod linux {
cwd: &Path, cwd: &Path,
environment: &[(OsString, OsString)], environment: &[(OsString, OsString)],
) -> Result<CommandSandboxLaunch, CommandSandboxError> { ) -> Result<CommandSandboxLaunch, CommandSandboxError> {
let metadata = CommandSandboxMetadata::enforced_linux(); let mut metadata = CommandSandboxMetadata::enforced_linux();
let network_enabled = executable
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| {
name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd")
})
&& arguments
.first()
.is_some_and(|argument| argument == "install");
if network_enabled {
metadata.network = "enabled";
}
let bwrap = find_trusted_bwrap().map_err(|error| { let bwrap = find_trusted_bwrap().map_err(|error| {
CommandSandboxError::new( CommandSandboxError::new(
format!("command sandbox unavailable: {error}"), format!("command sandbox unavailable: {error}"),
@@ -363,7 +375,7 @@ mod linux {
)?; )?;
let fixed_system_read_only = collect_fixed_system_mounts(); let fixed_system_read_only = collect_fixed_system_mounts();
let launch = build_linux_bwrap_launch(LinuxSandboxPlan { let mut launch = build_linux_bwrap_launch(LinuxSandboxPlan {
bwrap, bwrap,
root, root,
cwd, cwd,
@@ -375,6 +387,7 @@ mod linux {
external_read_only, external_read_only,
fixed_system_read_only, fixed_system_read_only,
}); });
launch.metadata = metadata.clone();
run_project_mount_preflight(&launch).map_err(|error| { run_project_mount_preflight(&launch).map_err(|error| {
CommandSandboxError::new( CommandSandboxError::new(
format!("command sandbox project mount preflight 失败:{error}"), format!("command sandbox project mount preflight 失败:{error}"),
@@ -602,7 +615,19 @@ mod linux {
fn build_linux_bwrap_launch(plan: LinuxSandboxPlan) -> CommandSandboxLaunch { fn build_linux_bwrap_launch(plan: LinuxSandboxPlan) -> CommandSandboxLaunch {
let mut args = Vec::<OsString>::new(); let mut args = Vec::<OsString>::new();
push_namespace_arguments(&mut args); push_namespace_arguments(
&mut args,
plan.executable
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| {
name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd")
})
&& plan
.arguments
.first()
.is_some_and(|argument| argument == "install"),
);
push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr"));
for (target, destination) in &plan.merged_usr_links { for (target, destination) in &plan.merged_usr_links {
push_option( push_option(
@@ -673,7 +698,7 @@ mod linux {
} }
} }
fn push_namespace_arguments(args: &mut Vec<OsString>) { fn push_namespace_arguments(args: &mut Vec<OsString>, share_network: bool) {
for argument in [ for argument in [
"--die-with-parent", "--die-with-parent",
"--unshare-all", "--unshare-all",
@@ -686,6 +711,9 @@ mod linux {
] { ] {
args.push(OsString::from(argument)); args.push(OsString::from(argument));
} }
if share_network {
args.push(OsString::from("--share-net"));
}
} }
fn push_option<'a, I>(args: &mut Vec<OsString>, option: &str, values: I) fn push_option<'a, I>(args: &mut Vec<OsString>, option: &str, values: I)
@@ -829,7 +857,7 @@ mod linux {
merged_usr_links: &[(OsString, PathBuf)], merged_usr_links: &[(OsString, PathBuf)],
) -> Result<(), String> { ) -> Result<(), String> {
let mut args = Vec::<OsString>::new(); let mut args = Vec::<OsString>::new();
push_namespace_arguments(&mut args); push_namespace_arguments(&mut args, false);
push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr"));
for (target, destination) in merged_usr_links { for (target, destination) in merged_usr_links {
push_option( push_option(
@@ -1945,28 +1945,36 @@ pub(crate) fn read_platform_account_session_generation() -> u64 {
} }
#[tauri::command] #[tauri::command]
pub(crate) fn install_platform_account_session( pub(crate) async fn install_platform_account_session(
user_id: String, user_id: String,
access_token: String, access_token: String,
api_base_url: String, api_base_url: String,
generation: u64, generation: u64,
) -> Result<(), String> { ) -> Result<(), String> {
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; tokio::task::spawn_blocking(move || {
install_external_agent_runner_platform_session( validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
&user_id, install_external_agent_runner_platform_session(
&access_token, &user_id,
&api_base_url, &access_token,
generation, &api_base_url,
)?; generation,
install_platform_session(&user_id, &access_token, &api_base_url, generation) )?;
install_platform_session(&user_id, &access_token, &api_base_url, generation)
})
.await
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
} }
#[tauri::command] #[tauri::command]
pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> { pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> {
shutdown_game_creator_codex_app_servers()?; tokio::task::spawn_blocking(move || {
clear_external_agent_runner_platform_session(generation)?; shutdown_game_creator_codex_app_servers()?;
clear_platform_session(generation); clear_external_agent_runner_platform_session(generation)?;
Ok(()) clear_platform_session(generation);
Ok(())
})
.await
.map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))?
} }
#[tauri::command] #[tauri::command]
@@ -39,6 +39,7 @@ pub(crate) const ISOLATED_AGENT_PRIVATE_MEMORY_SCHEMA_VERSION: &str =
pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[
"project.verify", "project.verify",
"project.bootstrap",
"project.git_commit", "project.git_commit",
"command.exec", "command.exec",
"command.start", "command.start",
@@ -59,6 +60,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[
pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS: &[&str] = &[ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS: &[&str] = &[
"project.verify", "project.verify",
"project.bootstrap",
"project.git_commit", "project.git_commit",
"command.exec", "command.exec",
"command.start", "command.start",
@@ -6,6 +6,7 @@ use std::io::{Seek, SeekFrom};
mod agent_db; mod agent_db;
mod asset_export; mod asset_export;
mod asset_rename; mod asset_rename;
mod bootstrap;
mod checkpoint; mod checkpoint;
mod conversation; mod conversation;
mod export; mod export;
@@ -23,6 +24,7 @@ mod write_lock;
pub(crate) use agent_db::*; pub(crate) use agent_db::*;
pub(crate) use asset_export::*; pub(crate) use asset_export::*;
pub(crate) use asset_rename::*; pub(crate) use asset_rename::*;
pub(crate) use bootstrap::*;
pub(crate) use checkpoint::*; pub(crate) use checkpoint::*;
pub(crate) use conversation::*; pub(crate) use conversation::*;
pub(crate) use export::*; pub(crate) use export::*;
@@ -0,0 +1,113 @@
use super::*;
use sha2::{Digest, Sha256};
const BOOTSTRAP_PACKAGE_MAX_BYTES: u64 = 512 * 1024;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ProjectBootstrapResult {
pub(crate) status: String,
pub(crate) output: String,
pub(crate) package_sha256: String,
pub(crate) lock_sha256: Option<String>,
pub(crate) log_path: String,
pub(crate) updated_at: u64,
}
fn read_bootstrap_file(path: &Path, label: &str) -> Result<Vec<u8>, String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("读取 {label} 失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(format!("project.bootstrap 要求 {label} 是普通文件"));
}
if metadata.len() > BOOTSTRAP_PACKAGE_MAX_BYTES {
return Err(format!(
"project.bootstrap {label} 超过 {} 字节上限",
BOOTSTRAP_PACKAGE_MAX_BYTES
));
}
prepare_game_creator_private_path_for_read(path, false, label)?;
fs::read(path).map_err(|error| format!("读取 {label} 失败:{error}"))
}
pub(crate) async fn run_project_bootstrap_at(
root: &Path,
timeout_seconds: u64,
) -> Result<ProjectBootstrapResult, String> {
validate_project_root(root)?;
let game = resolve_local_project_path(root, "game")?;
if !game.is_dir() {
return Err("project.bootstrap 只允许项目内 game 目录".to_string());
}
let package = read_bootstrap_file(&game.join("package.json"), "game/package.json")?;
let package_json: serde_json::Value = serde_json::from_slice(&package)
.map_err(|error| format!("解析 game/package.json 失败:{error}"))?;
if let Some(manager) = package_json
.get("packageManager")
.and_then(serde_json::Value::as_str)
{
if !manager.trim().starts_with("npm@") && manager.trim() != "npm" {
return Err("project.bootstrap 当前只支持 npm packageManager".to_string());
}
}
for lock_name in ["pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] {
if game.join(lock_name).exists() {
return Err(format!("project.bootstrap 检测到非 npm 锁文件 {lock_name}"));
}
}
if !package_json
.get("scripts")
.is_some_and(serde_json::Value::is_object)
{
return Err("project.bootstrap 要求 game/package.json 包含 scripts 对象".to_string());
}
let lock = match fs::symlink_metadata(game.join("package-lock.json")) {
Ok(_) => Some(read_bootstrap_file(
&game.join("package-lock.json"),
"game/package-lock.json",
)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(format!("读取 game/package-lock.json 失败:{error}")),
};
let command = crate::command_exec::run_project_bootstrap_command_at(root, timeout_seconds)
.await
.map_err(|error| error.to_string())?;
let completed = command.exit_code == Some(0) && !command.timed_out;
let status = if completed { "completed" } else { "failed" };
let updated_at = unix_timestamp();
let log_path = resolve_local_project_path(root, ".agent/logs/command.log")?;
let package_sha256 = format!("{:x}", Sha256::digest(&package));
let lock_sha256 = lock
.as_ref()
.map(|bytes| format!("{:x}", Sha256::digest(bytes)));
let output = sanitize_project_verification_output(&command.output);
let line = format!(
"{updated_at} project.bootstrap status={} packageSha256={} lockSha256={} cwd=game\n{}\n",
status,
package_sha256,
lock_sha256.as_deref().unwrap_or("none"),
output
);
append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?;
record_command_run(
root,
GameCreationAppCommandRunState {
command_id: "project.bootstrap".to_string(),
status: if completed {
GameCreationAppCommandRunStatus::Completed
} else {
GameCreationAppCommandRunStatus::Failed
},
output: output.clone(),
log_path: ".agent/logs/command.log".to_string(),
updated_at,
},
)?;
Ok(ProjectBootstrapResult {
status: status.to_string(),
output,
package_sha256,
lock_sha256,
log_path: ".agent/logs/command.log".to_string(),
updated_at,
})
}
@@ -121,6 +121,7 @@ pub(crate) struct ProjectVerificationSpec {
pub(crate) program: String, pub(crate) program: String,
pub(crate) arguments: Vec<String>, pub(crate) arguments: Vec<String>,
pub(crate) timeout_seconds: u64, pub(crate) timeout_seconds: u64,
pub(crate) cwd_relative: String,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -129,6 +130,7 @@ pub(crate) struct ProjectVerificationResult {
pub(crate) script: String, pub(crate) script: String,
pub(crate) expected_command: String, pub(crate) expected_command: String,
pub(crate) package_manager: String, pub(crate) package_manager: String,
pub(crate) cwd_relative: String,
pub(crate) status: String, pub(crate) status: String,
pub(crate) exit_code: Option<i32>, pub(crate) exit_code: Option<i32>,
pub(crate) timed_out: bool, pub(crate) timed_out: bool,
@@ -290,9 +292,38 @@ pub(crate) fn resolve_project_verification_spec_at(
script: &str, script: &str,
expected_command: &str, expected_command: &str,
timeout_seconds: u64, timeout_seconds: u64,
) -> Result<ProjectVerificationSpec, String> {
resolve_project_verification_spec_with_cwd_at(
root,
script,
expected_command,
timeout_seconds,
".",
)
}
pub(crate) fn resolve_project_verification_spec_with_cwd_at(
root: &Path,
script: &str,
expected_command: &str,
timeout_seconds: u64,
cwd_relative: &str,
) -> Result<ProjectVerificationSpec, String> { ) -> Result<ProjectVerificationSpec, String> {
validate_project_root(root)?; validate_project_root(root)?;
ensure_project_verification_has_no_project_npmrc(root)?; let cwd_relative = if cwd_relative.trim().is_empty() || cwd_relative.trim() == "." {
".".to_string()
} else {
normalize_relative_path(cwd_relative)?
};
let package_root = if cwd_relative == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, &cwd_relative)?
};
if !package_root.is_dir() {
return Err("project.verify cwd 必须是项目内普通目录".to_string());
}
ensure_project_verification_has_no_project_npmrc(&package_root)?;
let script = script.trim(); let script = script.trim();
if script.chars().count() > PROJECT_VERIFICATION_SCRIPT_MAX_CHARS { if script.chars().count() > PROJECT_VERIFICATION_SCRIPT_MAX_CHARS {
return Err(format!( return Err(format!(
@@ -323,7 +354,7 @@ pub(crate) fn resolve_project_verification_spec_at(
)); ));
} }
let package_path = root.join("package.json"); let package_path = package_root.join("package.json");
let metadata = fs::symlink_metadata(&package_path).map_err(|error| { let metadata = fs::symlink_metadata(&package_path).map_err(|error| {
format!( format!(
"读取 package.json 失败:{}: {error}", "读取 package.json 失败:{}: {error}",
@@ -348,7 +379,7 @@ pub(crate) fn resolve_project_verification_spec_at(
})?; })?;
let package: serde_json::Value = serde_json::from_str(&package_content) let package: serde_json::Value = serde_json::from_str(&package_content)
.map_err(|error| format!("解析 package.json 失败:{error}"))?; .map_err(|error| format!("解析 package.json 失败:{error}"))?;
let package_manager = project_verification_package_manager_at(root, &package)?; let package_manager = project_verification_package_manager_at(&package_root, &package)?;
let actual_command = package let actual_command = package
.get("scripts") .get("scripts")
.and_then(serde_json::Value::as_object) .and_then(serde_json::Value::as_object)
@@ -360,6 +391,20 @@ pub(crate) fn resolve_project_verification_spec_at(
"package.json 中的 {script} 脚本已变化,请重新读取后再确认执行" "package.json 中的 {script} 脚本已变化,请重新读取后再确认执行"
)); ));
} }
if script == "build" && cwd_relative == "game" {
let modules_path = package_root.join("node_modules");
let modules_metadata = fs::symlink_metadata(&modules_path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
"project.verify build 前缺少 game/node_modules;请先执行 project.bootstrap"
.to_string()
} else {
format!("project.verify 检查 game/node_modules 失败:{error}")
}
})?;
if modules_metadata.file_type().is_symlink() || !modules_metadata.is_dir() {
return Err("project.verify build 前的 game/node_modules 不是普通目录;请重新执行 project.bootstrap".to_string());
}
}
Ok(ProjectVerificationSpec { Ok(ProjectVerificationSpec {
script: script.to_string(), script: script.to_string(),
@@ -373,6 +418,7 @@ pub(crate) fn resolve_project_verification_spec_at(
script.to_string(), script.to_string(),
], ],
timeout_seconds, timeout_seconds,
cwd_relative,
}) })
} }
@@ -485,10 +531,21 @@ async fn run_project_verification_process<F>(
where where
F: FnOnce() -> Result<(), String>, F: FnOnce() -> Result<(), String>,
{ {
ensure_project_verification_has_no_project_npmrc(root) let package_root = if spec.cwd_relative == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, &spec.cwd_relative)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?
};
ensure_project_verification_has_no_project_npmrc(&package_root)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
let command_spec = let command_spec = resolve_project_command_spec_at(
resolve_project_command_spec_at(root, "npm", &spec.arguments, ".", spec.timeout_seconds)?; root,
"npm",
&spec.arguments,
&spec.cwd_relative,
spec.timeout_seconds,
)?;
let launch = prepare_project_command_launch_spec(root, &command_spec)?; let launch = prepare_project_command_launch_spec(root, &command_spec)?;
let launch_metadata = launch.clone(); let launch_metadata = launch.clone();
let staged = stage_project_command_launch_spec(&command_spec, launch)?; let staged = stage_project_command_launch_spec(&command_spec, launch)?;
@@ -620,9 +677,14 @@ pub(crate) async fn run_project_verification_at(
expected_command: &str, expected_command: &str,
timeout_seconds: u64, timeout_seconds: u64,
) -> Result<ProjectVerificationResult, String> { ) -> Result<ProjectVerificationResult, String> {
run_project_verification_with_commit_at(root, script, expected_command, timeout_seconds, || { run_project_verification_with_commit_at(
Ok(()) root,
}) script,
expected_command,
timeout_seconds,
".",
|| Ok(()),
)
.await .await
} }
@@ -631,15 +693,21 @@ pub(crate) async fn run_project_verification_with_commit_at<F>(
script: &str, script: &str,
expected_command: &str, expected_command: &str,
timeout_seconds: u64, timeout_seconds: u64,
cwd_relative: &str,
durable_commit: F, durable_commit: F,
) -> Result<ProjectVerificationResult, String> ) -> Result<ProjectVerificationResult, String>
where where
F: FnOnce() -> Result<(), String>, F: FnOnce() -> Result<(), String>,
{ {
let spec = let spec = resolve_project_verification_spec_with_cwd_at(
resolve_project_verification_spec_at(root, script, expected_command, timeout_seconds)?; root,
script,
expected_command,
timeout_seconds,
cwd_relative,
)?;
let started_at = std::time::Instant::now(); let started_at = std::time::Instant::now();
let process = match run_project_verification_process(root, &spec, durable_commit).await { let mut process = match run_project_verification_process(root, &spec, durable_commit).await {
Ok(process) => process, Ok(process) => process,
Err(error) if error.needs_reconciliation() => { Err(error) if error.needs_reconciliation() => {
return Err(format!("project.verify 执行状态需要人工核对:{error}")); return Err(format!("project.verify 执行状态需要人工核对:{error}"));
@@ -658,7 +726,18 @@ where
}, },
}; };
let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
let completed = !process.timed_out && process.exit_code == Some(0); let mut completed = !process.timed_out && process.exit_code == Some(0);
if completed && spec.script == "build" && spec.cwd_relative == "game" {
let dist_entry = root.join("game").join("dist").join("index.html");
completed = fs::symlink_metadata(&dist_entry)
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink());
if !completed {
process.output = format!(
"{}\nproject.verify build 成功但缺少 game/dist/index.html",
process.output
);
}
}
let status = if completed { "completed" } else { "failed" }; let status = if completed { "completed" } else { "failed" };
let command_id = format!("project.verify.{}", spec.script); let command_id = format!("project.verify.{}", spec.script);
let updated_at = unix_timestamp(); let updated_at = unix_timestamp();
@@ -703,6 +782,7 @@ where
script: spec.script, script: spec.script,
expected_command: spec.expected_command, expected_command: spec.expected_command,
package_manager: spec.package_manager, package_manager: spec.package_manager,
cwd_relative: spec.cwd_relative,
status: status.to_string(), status: status.to_string(),
exit_code: process.exit_code, exit_code: process.exit_code,
timed_out: process.timed_out, timed_out: process.timed_out,
@@ -860,14 +860,11 @@ pub(crate) fn acquire_project_write_lock_failure(
} }
} }
} }
if crate::agent::autonomous_game_build_root_run_active_at(root) if project_write_lock_is_owned_by_current_process(&path) {
&& project_write_lock_is_owned_by_current_process(&path) // A project lock is the client-use lock. Nested calls in
{ // the same client process must reuse that ownership instead
// The autonomous game-build lane intentionally permits // of waiting on their own durable marker. Cross-process
// parallel specialist actions. If the durable lock belongs // contenders still take the normal retryable path.
// to this very process, contention is an in-process overlap,
// not another application editing the project. Return an
// advisory guard and leave the real lock untouched.
return Ok(ProjectWriteLock { return Ok(ProjectWriteLock {
path, path,
content: String::new(), content: String::new(),
@@ -5611,12 +5611,14 @@ fn local_project_checkpoint_diff_restore_and_index_are_recorded() {
} }
#[test] #[test]
fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() { fn project_write_lock_reuses_same_process_owner_and_releases_on_drop() {
let root = unique_project_path(); let root = unique_project_path();
let first = acquire_project_write_lock(&root, "file.write").expect("first lock"); let first = acquire_project_write_lock(&root, "file.write").expect("first lock");
let error = acquire_project_write_lock(&root, "file.delete").expect_err("second lock fails"); let nested = acquire_project_write_lock(&root, "file.delete")
assert!(error.contains("项目正在被其他写操作占用")); .expect("same process must reuse the client project lock");
drop(nested);
assert!(root.join(PROJECT_WRITE_LOCK_PATH).exists());
drop(first); drop(first);
acquire_project_write_lock(&root, "file.delete").expect("lock released"); acquire_project_write_lock(&root, "file.delete").expect("lock released");
@@ -6197,7 +6199,7 @@ fn local_project_resource_previews_require_registered_safe_resources() {
register_local_asset_at( register_local_asset_at(
&root, &root,
"game/design.md", "game/design.md",
"design-document", "document",
"text/markdown", "text/markdown",
"generated", "generated",
source(), source(),
@@ -389,7 +389,23 @@ fn collect_native_tool_absolute_path_findings(
} }
} }
} }
"project.bootstrap" => {
collect_native_string_field(
root,
input,
"cwd",
&format!("{input_pointer}/cwd"),
findings,
);
}
"project.verify" => { "project.verify" => {
collect_native_string_field(
root,
input,
"cwd",
&format!("{input_pointer}/cwd"),
findings,
);
collect_native_string_field( collect_native_string_field(
root, root,
input, input,

Some files were not shown because too many files have changed in this diff Show More