补齐Agent Git工作树审阅与聊天状态交互
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,491 @@
|
||||
use crate::project::{normalize_relative_path, reject_sensitive_project_file_read};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const GIT_INSPECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const GIT_INSPECT_OUTPUT_MAX_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct LocalGitWorktreeInspect {
|
||||
pub(crate) head: String,
|
||||
pub(crate) branch: Option<String>,
|
||||
pub(crate) staged: Vec<String>,
|
||||
pub(crate) unstaged: Vec<String>,
|
||||
pub(crate) untracked: Vec<String>,
|
||||
pub(crate) file_count: usize,
|
||||
pub(crate) truncated: bool,
|
||||
pub(crate) content: String,
|
||||
}
|
||||
|
||||
pub(crate) fn inspect_local_git_worktree_at(
|
||||
root: &Path,
|
||||
include_diff: bool,
|
||||
max_files: usize,
|
||||
max_chars: usize,
|
||||
) -> Result<LocalGitWorktreeInspect, String> {
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("读取项目目录失败:{error}"))?;
|
||||
ensure_git_top_level(&root)?;
|
||||
|
||||
let head = read_git_head(&root);
|
||||
let branch = read_git_branch(&root);
|
||||
|
||||
let status = run_git(
|
||||
&root,
|
||||
&[
|
||||
"status",
|
||||
"--porcelain=v1",
|
||||
"-z",
|
||||
"--untracked-files=all",
|
||||
"--ignore-submodules=all",
|
||||
"--no-renames",
|
||||
],
|
||||
)?;
|
||||
let (mut staged, mut unstaged, mut untracked) = parse_status(&root, &status);
|
||||
staged.sort();
|
||||
staged.dedup();
|
||||
unstaged.sort();
|
||||
unstaged.dedup();
|
||||
untracked.sort();
|
||||
untracked.dedup();
|
||||
|
||||
let all_paths = staged
|
||||
.iter()
|
||||
.chain(&unstaged)
|
||||
.chain(&untracked)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let file_count = all_paths.len();
|
||||
let selected_paths = all_paths
|
||||
.into_iter()
|
||||
.take(max_files)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut truncated = file_count > selected_paths.len();
|
||||
let mut content = String::new();
|
||||
append_path_section(&mut content, "staged files", &staged, &selected_paths);
|
||||
append_path_section(&mut content, "unstaged files", &unstaged, &selected_paths);
|
||||
append_path_section(&mut content, "untracked files", &untracked, &selected_paths);
|
||||
|
||||
let mut staged_diff = String::new();
|
||||
let mut unstaged_diff = String::new();
|
||||
if include_diff {
|
||||
let staged_paths = selected_paths
|
||||
.iter()
|
||||
.filter(|path| staged.contains(path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let unstaged_paths = selected_paths
|
||||
.iter()
|
||||
.filter(|path| unstaged.contains(path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
staged_diff = read_diff(&root, true, &staged_paths)?;
|
||||
unstaged_diff = read_diff(&root, false, &unstaged_paths)?;
|
||||
append_diff_section(&mut content, "staged diff", &staged_diff);
|
||||
append_diff_section(&mut content, "unstaged diff", &unstaged_diff);
|
||||
}
|
||||
|
||||
let status_after = run_git(
|
||||
&root,
|
||||
&[
|
||||
"status",
|
||||
"--porcelain=v1",
|
||||
"-z",
|
||||
"--untracked-files=all",
|
||||
"--ignore-submodules=all",
|
||||
"--no-renames",
|
||||
],
|
||||
)?;
|
||||
let staged_diff_after = if include_diff {
|
||||
let paths = selected_paths
|
||||
.iter()
|
||||
.filter(|path| staged.contains(path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
read_diff(&root, true, &paths)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let unstaged_diff_after = if include_diff {
|
||||
let paths = selected_paths
|
||||
.iter()
|
||||
.filter(|path| unstaged.contains(path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
read_diff(&root, false, &paths)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if status_after != status
|
||||
|| read_git_head(&root) != head
|
||||
|| read_git_branch(&root) != branch
|
||||
|| staged_diff_after != staged_diff
|
||||
|| unstaged_diff_after != unstaged_diff
|
||||
{
|
||||
return Err("Git 工作树在审阅过程中发生变化,请重试".to_string());
|
||||
}
|
||||
|
||||
if content.chars().count() > max_chars {
|
||||
content = content.chars().take(max_chars).collect();
|
||||
content.push_str("\n[git inspect output truncated]\n");
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
Ok(LocalGitWorktreeInspect {
|
||||
head,
|
||||
branch,
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
file_count,
|
||||
truncated,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_git_head(root: &Path) -> String {
|
||||
run_git(root, &["rev-parse", "--verify", "HEAD"])
|
||||
.map(|output| output.trim().to_string())
|
||||
.unwrap_or_else(|_| "(unborn)".to_string())
|
||||
}
|
||||
|
||||
fn read_git_branch(root: &Path) -> Option<String> {
|
||||
run_git(root, &["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
.ok()
|
||||
.map(|output| output.trim().to_string())
|
||||
.filter(|output| !output.is_empty())
|
||||
}
|
||||
|
||||
fn ensure_git_top_level(root: &Path) -> Result<(), String> {
|
||||
let top_level = run_git(root, &["rev-parse", "--show-toplevel"])
|
||||
.map_err(|_| "项目目录必须是 Git 仓库根目录".to_string())?;
|
||||
let top_level = Path::new(top_level.trim())
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("读取 Git 仓库根目录失败:{error}"))?;
|
||||
if top_level != root {
|
||||
return Err("项目目录必须是 Git 仓库根目录".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_status(root: &Path, output: &str) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
let mut staged = Vec::new();
|
||||
let mut unstaged = Vec::new();
|
||||
let mut untracked = Vec::new();
|
||||
for record in output.split('\0').filter(|record| !record.is_empty()) {
|
||||
let bytes = record.as_bytes();
|
||||
if bytes.len() < 4 || bytes[2] != b' ' {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = safe_git_path(root, &record[3..]) else {
|
||||
continue;
|
||||
};
|
||||
if bytes[0] == b'?' && bytes[1] == b'?' {
|
||||
untracked.push(path);
|
||||
continue;
|
||||
}
|
||||
if bytes[0] != b' ' && bytes[0] != b'?' {
|
||||
staged.push(path.clone());
|
||||
}
|
||||
if bytes[1] != b' ' && bytes[1] != b'?' {
|
||||
unstaged.push(path);
|
||||
}
|
||||
}
|
||||
(staged, unstaged, untracked)
|
||||
}
|
||||
|
||||
fn safe_git_path(root: &Path, path: &str) -> Option<String> {
|
||||
let normalized = normalize_relative_path(path).ok()?;
|
||||
reject_sensitive_project_file_read(&normalized).ok()?;
|
||||
let parts = normalized
|
||||
.split('/')
|
||||
.take(2)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect::<Vec<_>>();
|
||||
if parts.first().is_some_and(|part| part == ".git") {
|
||||
return None;
|
||||
}
|
||||
if parts.first().is_some_and(|part| part == ".agent")
|
||||
&& parts
|
||||
.get(1)
|
||||
.is_some_and(|part| matches!(part.as_str(), "runtime" | "checkpoints"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !git_worktree_path_is_safe(root, &normalized) {
|
||||
return None;
|
||||
}
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
fn git_worktree_path_is_safe(root: &Path, relative_path: &str) -> bool {
|
||||
let mut current = root.to_path_buf();
|
||||
let components = relative_path.split('/').collect::<Vec<_>>();
|
||||
for (index, component) in components.iter().enumerate() {
|
||||
current.push(component);
|
||||
match std::fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return false;
|
||||
}
|
||||
if index + 1 < components.len() && !metadata.is_dir() {
|
||||
return false;
|
||||
}
|
||||
if index + 1 == components.len()
|
||||
&& (!metadata.is_file() || metadata_has_multiple_hard_links(&metadata))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return index + 1 == components.len();
|
||||
}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn metadata_has_multiple_hard_links(metadata: &std::fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
metadata.nlink() > 1
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn metadata_has_multiple_hard_links(_metadata: &std::fs::Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn append_path_section(
|
||||
output: &mut String,
|
||||
title: &str,
|
||||
paths: &[String],
|
||||
selected_paths: &BTreeSet<String>,
|
||||
) {
|
||||
let selected = paths
|
||||
.iter()
|
||||
.filter(|path| selected_paths.contains(*path))
|
||||
.collect::<Vec<_>>();
|
||||
if selected.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(output, "\n## {title}");
|
||||
for path in selected {
|
||||
let _ = writeln!(output, "- {path}");
|
||||
}
|
||||
}
|
||||
|
||||
fn read_diff(root: &Path, cached: bool, paths: &[String]) -> Result<String, String> {
|
||||
if paths.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let mut args = vec![
|
||||
"diff".to_string(),
|
||||
"--no-ext-diff".to_string(),
|
||||
"--no-color".to_string(),
|
||||
"--no-renames".to_string(),
|
||||
"--unified=3".to_string(),
|
||||
];
|
||||
if cached {
|
||||
args.push("--cached".to_string());
|
||||
}
|
||||
args.push("--".to_string());
|
||||
args.extend(paths.iter().cloned());
|
||||
run_git_owned(root, &args)
|
||||
}
|
||||
|
||||
fn append_diff_section(output: &mut String, title: &str, diff: &str) {
|
||||
if !diff.is_empty() {
|
||||
let _ = write!(output, "\n## {title}\n{diff}");
|
||||
if !diff.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_git(root: &Path, args: &[&str]) -> Result<String, String> {
|
||||
run_git_owned(
|
||||
root,
|
||||
&args
|
||||
.iter()
|
||||
.map(|arg| (*arg).to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_git_owned(root: &Path, args: &[String]) -> Result<String, String> {
|
||||
let inherited_environment = ["PATH", "SystemRoot", "WINDIR", "PATHEXT"]
|
||||
.into_iter()
|
||||
.filter_map(|key| std::env::var_os(key).map(|value| (key, value)))
|
||||
.collect::<Vec<_>>();
|
||||
let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" };
|
||||
let mut command = Command::new("git");
|
||||
command.env_clear();
|
||||
for (key, value) in inherited_environment {
|
||||
command.env(key, value);
|
||||
}
|
||||
command
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_SYSTEM", null_device)
|
||||
.env("GIT_CONFIG_GLOBAL", null_device)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_PAGER", "cat")
|
||||
.env("PAGER", "cat")
|
||||
.env("TERM", "dumb")
|
||||
.current_dir(root)
|
||||
.arg("--no-pager")
|
||||
.arg("--no-optional-locks")
|
||||
.arg("-c")
|
||||
.arg("core.fsmonitor=false")
|
||||
.arg("-c")
|
||||
.arg(format!("core.hooksPath={null_device}"))
|
||||
.arg("-c")
|
||||
.arg("core.pager=cat")
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(parent) = root.parent() {
|
||||
command.env("GIT_CEILING_DIRECTORIES", parent);
|
||||
}
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("启动 Git 检查失败:{error}"))?;
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
let stdout_reader = thread::spawn(move || read_bounded(stdout));
|
||||
let stderr_reader = thread::spawn(move || read_bounded(stderr));
|
||||
let started = Instant::now();
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if started.elapsed() < GIT_INSPECT_TIMEOUT => {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err("Git 检查超时".to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("等待 Git 检查失败:{error}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
let stdout = stdout_reader.join().unwrap_or_default();
|
||||
let stderr = stderr_reader.join().unwrap_or_default();
|
||||
if !status.success() {
|
||||
let detail = String::from_utf8_lossy(&stderr).trim().to_string();
|
||||
return Err(if detail.is_empty() {
|
||||
"Git 检查失败".to_string()
|
||||
} else {
|
||||
format!("Git 检查失败:{detail}")
|
||||
});
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&stdout).into_owned())
|
||||
}
|
||||
|
||||
fn read_bounded<R: Read>(stream: Option<R>) -> Vec<u8> {
|
||||
let Some(mut stream) = stream else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut collected = Vec::new();
|
||||
let mut buffer = [0_u8; 8 * 1024];
|
||||
while let Ok(read) = stream.read(&mut buffer) {
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
let remaining = GIT_INSPECT_OUTPUT_MAX_BYTES.saturating_sub(collected.len());
|
||||
collected.extend_from_slice(&buffer[..read.min(remaining)]);
|
||||
}
|
||||
collected
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("run git fixture command");
|
||||
assert!(status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspects_bounded_changes_and_filters_sensitive_paths() {
|
||||
let fixture = tempfile::tempdir().expect("create fixture");
|
||||
let root = fixture.path();
|
||||
git(root, &["init", "-q"]);
|
||||
git(root, &["config", "user.name", "Test"]);
|
||||
git(root, &["config", "user.email", "test@example.invalid"]);
|
||||
fs::write(root.join("game.txt"), "first\n").expect("write tracked file");
|
||||
git(root, &["add", "game.txt"]);
|
||||
git(root, &["commit", "-qm", "initial"]);
|
||||
fs::write(root.join("game.txt"), "first\nsecond\n").expect("update tracked file");
|
||||
fs::write(root.join("new.txt"), "new\n").expect("write untracked file");
|
||||
fs::write(root.join(".env"), "SECRET=hidden\n").expect("write secret file");
|
||||
|
||||
let result =
|
||||
inspect_local_git_worktree_at(root, true, 20, 24_000).expect("inspect git worktree");
|
||||
|
||||
assert_eq!(result.unstaged, vec!["game.txt"]);
|
||||
assert_eq!(result.untracked, vec!["new.txt"]);
|
||||
assert!(result.content.contains("+second"));
|
||||
assert!(!result.content.contains("SECRET"));
|
||||
assert!(!result.content.contains(".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_project_nested_inside_another_repository() {
|
||||
let fixture = tempfile::tempdir().expect("create fixture");
|
||||
git(fixture.path(), &["init", "-q"]);
|
||||
let nested = fixture.path().join("nested");
|
||||
fs::create_dir(&nested).expect("create nested directory");
|
||||
|
||||
let error = inspect_local_git_worktree_at(&nested, false, 20, 24_000)
|
||||
.expect_err("nested project must not inspect parent repository");
|
||||
|
||||
assert!(error.contains("仓库根目录"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn omits_hard_linked_and_symlinked_worktree_paths() {
|
||||
let fixture = tempfile::tempdir().expect("create fixture");
|
||||
let outside = tempfile::tempdir().expect("create outside fixture");
|
||||
let root = fixture.path();
|
||||
git(root, &["init", "-q"]);
|
||||
fs::write(outside.path().join("shared.txt"), "outside hard link\n")
|
||||
.expect("write hard link source");
|
||||
fs::hard_link(
|
||||
outside.path().join("shared.txt"),
|
||||
root.join("hard-linked.txt"),
|
||||
)
|
||||
.expect("create hard link");
|
||||
std::os::unix::fs::symlink(
|
||||
outside.path().join("shared.txt"),
|
||||
root.join("symlinked.txt"),
|
||||
)
|
||||
.expect("create symlink");
|
||||
|
||||
let result =
|
||||
inspect_local_git_worktree_at(root, true, 20, 24_000).expect("inspect worktree");
|
||||
|
||||
assert!(!result.untracked.contains(&"hard-linked.txt".to_string()));
|
||||
assert!(!result.untracked.contains(&"symlinked.txt".to_string()));
|
||||
assert!(!result.content.contains("outside hard link"));
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ mod commands;
|
||||
mod config;
|
||||
#[cfg(all(debug_assertions, not(test)))]
|
||||
mod debug;
|
||||
mod git_inspect;
|
||||
mod isolated_agent;
|
||||
mod patchset;
|
||||
mod preview;
|
||||
@@ -64,6 +65,7 @@ use cli::*;
|
||||
use command_exec::*;
|
||||
use commands::*;
|
||||
use config::*;
|
||||
use git_inspect::*;
|
||||
use isolated_agent::*;
|
||||
use patchset::*;
|
||||
use preview::*;
|
||||
|
||||
@@ -944,17 +944,37 @@ function AgentRuntimeStatusPanel({
|
||||
<section className="agent-runtime-status" aria-label="Agent Runtime 状态">
|
||||
<header>
|
||||
<strong>Runtime 状态读取失败</strong>
|
||||
</header>
|
||||
<p>{error}</p>
|
||||
{onRefreshRuntime ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={controlBusy}
|
||||
onClick={onRefreshRuntime}
|
||||
className="agent-runtime-collapse-toggle"
|
||||
aria-label={collapsed ? '展开 Runtime 详情' : '折叠 Runtime 详情'}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? '展开 Runtime 详情' : '折叠 Runtime 详情'}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
>
|
||||
刷新状态
|
||||
{collapsed ? (
|
||||
<ChevronDown size={15} aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronUp size={15} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
{collapsed ? null : (
|
||||
<div className="agent-runtime-status-details">
|
||||
<p>{error}</p>
|
||||
{onRefreshRuntime ? (
|
||||
<div className="agent-runtime-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={controlBusy}
|
||||
onClick={onRefreshRuntime}
|
||||
>
|
||||
刷新状态
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3599,6 +3599,14 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
expect(await screen.findByText(/已读取 0 条/)).not.toBeNull();
|
||||
expect(await screen.findByText('Runtime 状态读取失败')).not.toBeNull();
|
||||
expect(screen.getByText('runtime json broken')).not.toBeNull();
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '折叠 Runtime 详情' }),
|
||||
);
|
||||
expect(screen.queryByText('runtime json broken')).toBeNull();
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '展开 Runtime 详情' }),
|
||||
);
|
||||
expect(screen.getByText('runtime json broken')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('persists developer agent chat reply failures after saving the user message', async () => {
|
||||
|
||||
@@ -4204,3 +4204,9 @@
|
||||
- 决策:patchset 预检通过后在同一项目写锁内自动 checkpoint,prepared 审计成功后才推进一次 revision 并应用全部变更。锁内再次校验 policy、pending 身份、revision / verification gate 和 repository fingerprint;应用中途失败必须回滚。revision / gate、回滚或完成审计不完整进入 `needs-reconciliation`,`executing` 恢复不得自动重放。成功审计只保存路径、operation、前后摘要与字节数,不保存源码正文。
|
||||
- 决策:`project.diff` 增加可选的有界内容 hunks;使用成熟文本 diff 库在项目锁内比较 checkpoint 与当前项目,生成后重算路径差异,不一致时拒绝混合快照;二进制、非 UTF-8、文件 / 总预算截断必须显式标记。最新内容 diff 在 128 KiB context bundle 中作为压缩保护项最多保留 24,256 字符,避免被普通 observation 的 1,600 字符上限截断。Agent 完成 patchset 后先用返回的 checkpointId 审查内容 diff,再执行可验证命令。
|
||||
- 验证:本地 441 项 Tauri 测试已覆盖多文件成功、预检全失败、大小写重复、敏感 / 链接路径、SHA 漂移、prepared / completed 审计失败、应用中断回滚和一次 revision。真实 `gpt-5.5` 的 `llm-runtime` 套件已形成唯一 patchset,同时更新 / 创建文件并读取绑定 checkpointId 的 2 项未截断内容 diff,通过最终命令、项目验证和桌面 / 移动浏览器验证;Runner 强杀恢复后副作用重放、重复 action / message / receipt、半完成文件和密钥 / 诱饵泄露均为 0。
|
||||
|
||||
## 2026-07-13 AI 游戏创作 Agent Runtime V1.4 Git 工作树审阅
|
||||
|
||||
- 决策:新增一等只读 `git.inspect`,共享 command id 为 `project.git_inspect`且默认 `auto`。工具只接受 `includeDiff / maxFiles / maxChars`,返回精确 Git top-level 的 HEAD / branch、staged / unstaged / untracked 安全路径和有界 staged / unstaged unified diff;不改项目 revision 或 verification gate。
|
||||
- 决策:Git 读取必须隔离 system/global config、hooks、fsmonitor、pager、external diff、textconv、optional locks、prompt 和网络;项目根必须就是 Git top-level。路径经可移植路径、项目边界、普通文件、硬 / 符号链接和敏感路径过滤;untracked 只列名不读正文,前后快照漂移时整次失败。
|
||||
- 决策:本轮明确不开放 Git 写操作。`add / commit / push / pull / fetch`、分支切换、merge / rebase / reset / stash / clean、tag、submodule 和 worktree 继续禁止;后续本地 commit 必须单独设计 HEAD / index / 文件快照、精确确认与 Runner 崩溃不重放,不复用通用 `command.exec`。
|
||||
|
||||
@@ -315,6 +315,30 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> -
|
||||
|
||||
发布 AppData 中配置的真实 `gpt-5.5` 已通过 V1.3 `llm-runtime` 套件:同一 run 先用 `file.read` 取得完整 SHA-256,只执行 1 次 `project.patchset`,同时更新 `game/index.html` 和创建第二个文件;prepared / completed 审计各 1 条,patchset 只推进 1 次 revision,随后用返回的 checkpointId 读取 2 项未截断内容 hunks。Runner 强制终止后恢复原 run / session,最终项目验证、桌面 / 移动浏览器验证、3 个隔离实例和唯一 join 均通过;87 条 task、144 条 event、130 条 Agent DB 记录中副作用重放、重复 action / message / receipt、半完成文件、密钥和诱饵泄露均为 0,临时项目已按 sentinel 清理。
|
||||
|
||||
## V1.4 Git 工作树安全审阅
|
||||
|
||||
### `git.inspect`
|
||||
|
||||
单 Agent 新增一等只读 `git.inspect`,用于在修改前后读取当前工作树状态和有界 unified diff,不再要求模型为常规 Git 审阅申请一次通用 `command.exec` 确认:
|
||||
|
||||
```json
|
||||
{
|
||||
"includeDiff": true,
|
||||
"maxFiles": 20,
|
||||
"maxChars": 24000
|
||||
}
|
||||
```
|
||||
|
||||
- 共享 command id 为 `project.git_inspect`,默认权限为 `auto`;项目或 per-Agent policy 仍可将它改为 `confirm` 或 `deny`。该工具只读,不推进 project revision,不改变 verification gate。
|
||||
- 项目目录必须精确等于 Git top-level;不向父目录探测仓库,不接受 nested root、bare repository 或不可用的 worktree。
|
||||
- Git 进程使用项目外受信可执行文件、清空环境和隔离 HOME;固定关闭 system/global config、hooks、fsmonitor、pager、external diff、textconv、optional locks、签名校验、交互 prompt 和网络代理。
|
||||
- status 使用 NUL 分隔格式解析,返回 HEAD / branch、staged / unstaged / untracked 的安全相对路径。所有路径必须通过可移植路径、项目边界、普通文件和敏感路径校验;`.agent`、VCS 控制面、`.env*`、密钥、凭据、数据库、dump、依赖、构建和缓存目录不得出现在返回值或 diff 中。
|
||||
- `includeDiff=true` 分别对安全 staged 和 unstaged tracked 路径生成内容 diff;untracked 只列路径,不读正文。默认 20 个文件、24,000 字符,公开上限固定为 50 个文件和 24,000 字符;超出上限必须显式返回 `truncated=true`。
|
||||
- status 和 diff 前后的安全快照不一致时整次失败,不得把并发混合状态作为完整审阅结果。最新成功 Git diff 在 context bundle 中按内容 diff 保护项最多保留 24,256 字符。
|
||||
- 本轮不实现 `git add/commit/push/pull/fetch`、分支切换、merge/rebase/reset/stash/clean、tag、submodule 或 worktree 操作;本地提交需要独立的 HEAD / index / 文件快照、准确确认和崩溃不重放设计,不从只读 inspect 工具顺带放开。
|
||||
|
||||
真实 Provider E2E 必须在 disposable Git 仓库中证明:Agent 先读取初始工作树,patchset 后读取同时包含 changed / untracked 的安全状态和 tracked content hunk,敏感诱饵路径与正文不出现在 observation、context bundle、Agent DB 或报告中,且 Git 审阅不增加 project revision。
|
||||
|
||||
## 验收命令
|
||||
|
||||
- `npm run ai-game-creator-shell:typecheck`
|
||||
|
||||
@@ -19,7 +19,8 @@ describe('AI 游戏创作 App 共享契约', () => {
|
||||
it('keeps command permissions explicit', () => {
|
||||
const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id);
|
||||
|
||||
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(53);
|
||||
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(54);
|
||||
expect(commandIds).toContain('project.git_inspect');
|
||||
expect(commandIds).toContain('project.patchset');
|
||||
expect(commandIds).toContain('command.exec');
|
||||
expect(commandIds.indexOf('command.exec')).toBe(
|
||||
@@ -39,6 +40,11 @@ describe('AI 游戏创作 App 共享契约', () => {
|
||||
(command) => command.id === 'command.exec',
|
||||
)?.permission,
|
||||
).toBe('confirm');
|
||||
expect(
|
||||
GAME_CREATION_APP_COMMANDS.find(
|
||||
(command) => command.id === 'project.git_inspect',
|
||||
)?.permission,
|
||||
).toBe('auto');
|
||||
expect(
|
||||
GAME_CREATION_APP_COMMANDS.find(
|
||||
(command) => command.id === 'project.patchset',
|
||||
|
||||
@@ -19,6 +19,7 @@ export const GAME_CREATION_APP_COMMANDS = [
|
||||
{ id: 'project.index', permission: 'auto' },
|
||||
{ id: 'project.checkpoint', permission: 'confirm' },
|
||||
{ id: 'project.diff', permission: 'auto' },
|
||||
{ id: 'project.git_inspect', permission: 'auto' },
|
||||
{ id: 'project.patchset', permission: 'confirm' },
|
||||
{ id: 'project.restore', permission: 'confirm' },
|
||||
{ id: 'project.verify', permission: 'confirm' },
|
||||
|
||||
@@ -21,13 +21,14 @@ pub struct GameCreationAppCommandDescriptor {
|
||||
pub permission: GameCreationAppPermission,
|
||||
}
|
||||
|
||||
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 53] = [
|
||||
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 54] = [
|
||||
command("help.show", GameCreationAppPermission::Auto),
|
||||
command("project.create", GameCreationAppPermission::Confirm),
|
||||
command("project.status", GameCreationAppPermission::Auto),
|
||||
command("project.index", GameCreationAppPermission::Auto),
|
||||
command("project.checkpoint", GameCreationAppPermission::Confirm),
|
||||
command("project.diff", GameCreationAppPermission::Auto),
|
||||
command("project.git_inspect", GameCreationAppPermission::Auto),
|
||||
command("project.patchset", GameCreationAppPermission::Confirm),
|
||||
command("project.restore", GameCreationAppPermission::Confirm),
|
||||
command("project.verify", GameCreationAppPermission::Confirm),
|
||||
@@ -638,7 +639,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn command_contract_keeps_expected_permissions() {
|
||||
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 53);
|
||||
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 54);
|
||||
|
||||
let command_ids = GAME_CREATION_APP_COMMANDS
|
||||
.iter()
|
||||
@@ -681,6 +682,15 @@ mod tests {
|
||||
GameCreationAppPermission::Confirm
|
||||
);
|
||||
|
||||
let project_git_inspect = GAME_CREATION_APP_COMMANDS
|
||||
.iter()
|
||||
.find(|command| command.id == "project.git_inspect")
|
||||
.expect("project.git_inspect command should exist");
|
||||
assert_eq!(
|
||||
project_git_inspect.permission,
|
||||
GameCreationAppPermission::Auto
|
||||
);
|
||||
|
||||
let project_verify = GAME_CREATION_APP_COMMANDS
|
||||
.iter()
|
||||
.find(|command| command.id == "project.verify")
|
||||
|
||||
Reference in New Issue
Block a user