From 2149dc6a6373a81cd255014a3b50ee0bad4f46c2 Mon Sep 17 00:00:00 2001
From: dashenbibi <15820727+dashenbibi@users.noreply.github.com>
Date: Wed, 22 Jul 2026 16:19:39 +0800
Subject: [PATCH 1/3] Add macOS release packaging workflow
Signed-off-by: dashenbibi <15820727+dashenbibi@users.noreply.github.com>
---
.github/workflows/macos-release.yml | 107 ++++++++++++++++++++++++++++
README.md | 2 +
README.zh-CN.md | 2 +
docs/releasing.md | 35 +++++++++
4 files changed, 146 insertions(+)
create mode 100644 .github/workflows/macos-release.yml
create mode 100644 docs/releasing.md
diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml
new file mode 100644
index 0000000..72f3243
--- /dev/null
+++ b/.github/workflows/macos-release.yml
@@ -0,0 +1,107 @@
+name: macOS package
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+concurrency:
+ group: macos-package-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: macOS ${{ matrix.name }}
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: Apple Silicon
+ runner: macos-15
+ target: aarch64-apple-darwin
+ - name: Intel
+ runner: macos-15-intel
+ target: x86_64-apple-darwin
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ cache-dependency-path: apps/desktop/package-lock.json
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - uses: Swatinem/rust-cache@v2
+
+ - name: Verify native runner architecture
+ env:
+ EXPECTED_TARGET: ${{ matrix.target }}
+ run: |
+ test "$(rustc -vV | sed -n 's/^host: //p')" = "$EXPECTED_TARGET"
+
+ - name: Install frontend dependencies
+ run: npm ci --prefix apps/desktop
+
+ - name: Verify release tag and signing configuration
+ if: startsWith(github.ref, 'refs/tags/v')
+ working-directory: apps/desktop
+ env:
+ RELEASE_TAG: ${{ github.ref_name }}
+ APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: |
+ node -e 'const fs = require("fs"); const pkg = require("./package.json"); const tauri = JSON.parse(fs.readFileSync("./src-tauri/tauri.conf.json", "utf8")); const tag = process.env.RELEASE_TAG.replace(/^v/, ""); if (pkg.version !== tag || tauri.version !== tag) { console.error(`tag ${tag}, package ${pkg.version}, tauri ${tauri.version}`); process.exit(1); }'
+ missing=()
+ for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do
+ if [ -z "${!name:-}" ]; then
+ missing+=("$name")
+ fi
+ done
+ if [ "${#missing[@]}" -ne 0 ]; then
+ echo "Missing release secrets: ${missing[*]}" >&2
+ exit 1
+ fi
+
+ - name: Prepare process launcher sidecar
+ run: ./apps/desktop/scripts/prepare-sidecar.sh release prepare
+
+ - name: Build TestHarbor
+ uses: tauri-apps/tauri-action@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ APPLE_CERTIFICATE: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPLE_CERTIFICATE || '' }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
+ APPLE_SIGNING_IDENTITY: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPLE_SIGNING_IDENTITY || '' }}
+ APPLE_ID: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPLE_ID || '' }}
+ APPLE_PASSWORD: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPLE_PASSWORD || '' }}
+ APPLE_TEAM_ID: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPLE_TEAM_ID || '' }}
+ with:
+ projectPath: apps/desktop
+ tauriScript: npm run tauri
+ args: >-
+ --target ${{ matrix.target }}
+ --config src-tauri/tauri.bundle.conf.json
+ ${{ github.event_name == 'workflow_dispatch' && '--no-sign' || '' }}
+ uploadWorkflowArtifacts: true
+ workflowArtifactNamePattern: testharbor-[platform]-[arch]-[bundle]
+ tagName: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
+ releaseName: ${{ startsWith(github.ref, 'refs/tags/v') && format('TestHarbor {0}', github.ref_name) || '' }}
+ releaseDraft: true
+ prerelease: ${{ contains(github.ref_name, '-') }}
+ generateReleaseNotes: true
+ releaseAssetNamePattern: TestHarbor_[version]_[arch].[ext]
diff --git a/README.md b/README.md
index 263a3c4..27c44a1 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,8 @@ cd apps/desktop
npm run tauri:bundle
```
+See [Release packaging](./docs/releasing.md) for GitHub-hosted macOS preview builds and signed draft releases.
+
## CLI quick start
```bash
diff --git a/README.zh-CN.md b/README.zh-CN.md
index d8f9002..c9ce44b 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -65,6 +65,8 @@ cd apps/desktop
npm run tauri:bundle
```
+GitHub 托管的 macOS 预览构建和签名 Draft Release 流程见[发布打包](./docs/releasing.md)。
+
## CLI 快速开始
```bash
diff --git a/docs/releasing.md b/docs/releasing.md
new file mode 100644
index 0000000..0d0f2d2
--- /dev/null
+++ b/docs/releasing.md
@@ -0,0 +1,35 @@
+# Release packaging
+
+TestHarbor uses `.github/workflows/macos-release.yml` for native macOS packaging.
+
+## Preview build
+
+Run **macOS package** manually from the GitHub Actions page, or with:
+
+```bash
+gh workflow run macos-release.yml --ref main
+```
+
+The workflow builds Apple Silicon and Intel packages on matching native runners and uploads them as workflow artifacts. Manual builds pass `--no-sign`; they are for packaging verification only and will trigger macOS trust warnings when downloaded.
+
+## Draft release
+
+Before creating a version tag, set these GitHub Actions secrets:
+
+- `APPLE_CERTIFICATE`: base64-encoded Developer ID Application certificate in PKCS #12 format;
+- `APPLE_CERTIFICATE_PASSWORD`: password for that certificate;
+- `APPLE_SIGNING_IDENTITY`: certificate signing identity;
+- `APPLE_ID`: Apple account used for notarization;
+- `APPLE_PASSWORD`: app-specific password for that account;
+- `APPLE_TEAM_ID`: Apple Developer team identifier.
+
+Keep `apps/desktop/package.json` and `apps/desktop/src-tauri/tauri.conf.json` on the same version, then push the matching tag:
+
+```bash
+git tag -s v0.1.0 -m "TestHarbor v0.1.0"
+git push origin v0.1.0
+```
+
+The workflow rejects a mismatched version or missing release secret. A successful tag build creates a draft GitHub Release and uploads the signed, notarized Apple Silicon and Intel bundles. Review the generated notes and assets before publishing the draft.
+
+Do not publish manual preview artifacts as a release. Checksums, an SBOM, complete third-party notices, clean-machine installation tests, and final release approval remain separate release gates.
From 59718741d6cd5fef464679b7bf224b706f35c7d0 Mon Sep 17 00:00:00 2001
From: dashenbibi <15820727+dashenbibi@users.noreply.github.com>
Date: Wed, 22 Jul 2026 16:32:11 +0800
Subject: [PATCH 2/3] Update sidebar brand initials
Signed-off-by: dashenbibi <15820727+dashenbibi@users.noreply.github.com>
---
apps/desktop/src/WorkspaceSidebar.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/desktop/src/WorkspaceSidebar.tsx b/apps/desktop/src/WorkspaceSidebar.tsx
index bb60232..3bae78d 100644
--- a/apps/desktop/src/WorkspaceSidebar.tsx
+++ b/apps/desktop/src/WorkspaceSidebar.tsx
@@ -212,7 +212,7 @@ export function WorkspaceSidebar({
{!sidebarCollapsed && (
<>
- ST
+ TH
TestHarbor
From 648763058d3eb344e3d8dfab081dd8e8c80e14ae Mon Sep 17 00:00:00 2001
From: dashenbibi <15820727+dashenbibi@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:45:53 +0800
Subject: [PATCH 3/3] fix E2E generation and review workflow
---
apps/desktop/src/App.tsx | 90 +++++--
apps/desktop/src/i18n.ts | 4 +-
crates/agent-drivers/src/codex.rs | 166 ++++++++++--
crates/application/src/actions.rs | 5 +-
crates/application/src/execution.rs | 12 +-
crates/application/src/generate.rs | 328 +++++++++++++++++-------
crates/application/src/lib.rs | 8 +
crates/application/src/result_review.rs | 16 +-
crates/application/src/workspace.rs | 308 +++++++++++++++++++++-
crates/git-adapter/src/lib.rs | 87 +++++++
crates/review/src/lib.rs | 46 +++-
11 files changed, 925 insertions(+), 145 deletions(-)
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index a2910e2..8fa991f 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -520,6 +520,13 @@ function operationErrorMessage(error: unknown) {
lower.includes("start execution blocked") && lower.includes("candidate")
) {
summary = tr("The approved isolated worktree changed, so TestHarbor blocked execution. Inspect the changed files; remove temporary files and retry, or regenerate/reapprove changed test source.");
+ } else if (
+ lower.includes("start execution blocked") &&
+ (lower.includes("wrongworkflowstate") ||
+ lower.includes("needs_human") ||
+ lower.includes("needshuman"))
+ ) {
+ summary = tr("This task requires human review. Resolve or accept the review findings, approve the case again, and then start formal execution.");
} else if (lower.includes("changes outside writable scope")) {
summary = tr("The generation service changed files outside the test scope, so TestHarbor blocked the candidate. Restrict output to test files or configure the required test paths/names.");
} else if (
@@ -612,6 +619,24 @@ type TestEnvironmentInput = {
variables: Record;
};
+const DEFAULT_TEST_OBJECTIVE = "编排接口 E2E 流程测试,验证完整业务链路";
+const DEFAULT_ACCEPTANCE_CRITERIA =
+ "通过 API/接口完成主要操作流程\n查询数据库或缓存验证关键状态变化\n测试数据隔离且按设置清理";
+
+function emptyTestEnvironment(): TestEnvironmentInput {
+ return {
+ context: "",
+ base_url: "",
+ health_url: "",
+ login_url: "",
+ startup_command: "",
+ startup_cwd: "",
+ username: "",
+ password: "",
+ variables: {},
+ };
+}
+
function normDisp(d: string) {
const x = d.toLowerCase();
if (x === "mustfix" || x === "must_fix") return "must_fix";
@@ -2090,10 +2115,8 @@ function App() {
// Run workflow messages
const [log, setLog] = useState([]);
- const [objective, setObjective] = useState("编排接口 E2E 流程测试,验证完整业务链路");
- const [criteriaText, setCriteriaText] = useState(
- "通过 API/接口完成主要操作流程\n查询数据库或缓存验证关键状态变化\n测试数据隔离且按设置清理",
- );
+ const [objective, setObjective] = useState(DEFAULT_TEST_OBJECTIVE);
+ const [criteriaText, setCriteriaText] = useState(DEFAULT_ACCEPTANCE_CRITERIA);
const [sourceAnchorsText] = useState("");
const [generationScopeText] = useState("");
const [pastedSpecLabel] = useState("补充说明");
@@ -2101,17 +2124,8 @@ function App() {
const [repoMarkdownText] = useState("");
const [testStrategy, setTestStrategy] = useState("e2e_api_flow");
const [cleanupTestData, setCleanupTestData] = useState(true);
- const [testEnvironment, setTestEnvironment] = useState({
- context: "",
- base_url: "",
- health_url: "",
- login_url: "",
- startup_command: "",
- startup_cwd: "",
- username: "",
- password: "",
- variables: {},
- });
+ const [testEnvironment, setTestEnvironment] =
+ useState(emptyTestEnvironment);
const [approvedCase, setApprovedCase] = useState(false);
const [trusted, setTrusted] = useState(false);
const [acceptInfo, setAcceptInfo] = useState(null);
@@ -2618,6 +2632,7 @@ function App() {
})
: null;
if (createdRun) {
+ resetNewRunForm();
activeRunIdRef.current = createdRun.run_id;
setRunId(createdRun.run_id);
setCaseDetail(null);
@@ -2639,6 +2654,32 @@ function App() {
}
}
+ function resetNewRunForm() {
+ setCaseDetail(null);
+ setObjective(DEFAULT_TEST_OBJECTIVE);
+ setCriteriaText(DEFAULT_ACCEPTANCE_CRITERIA);
+ setTestEnvironment(emptyTestEnvironment());
+ setTestStrategy("e2e_api_flow");
+ setCleanupTestData(true);
+ setAgentModel("");
+ setReuseAgentSession(false);
+ setApprovedCase(false);
+ setTrusted(false);
+ setAcceptInfo(null);
+ setAnalysisInfo(null);
+ setResultReviewInfo(null);
+ setExecution(null);
+ setExecutionComplete(false);
+ setCanAcceptPass(false);
+ setReadyToApply(false);
+ setGenerationProgress({ stdout_tail: "", stderr_tail: "" });
+ setGenerationRetryAvailable(false);
+ setRiskDrafts({});
+ setReviewDecisionOpen(false);
+ setEvents([]);
+ setLog([]);
+ }
+
async function pickAndOpen(createRun: boolean) {
let path = pathInput.trim();
if (!path || recent.some((item) => item.root_path === path)) {
@@ -4503,7 +4544,7 @@ function App() {
/>
- {t("Free-form text never supplies startup commands or persisted secrets. Use masked runtime inputs declared by the Profile; only keys and fingerprints are stored.")}
+ {t("Free-form text is saved with this task and sent to the CLI; avoid putting secrets in it. Username, password, and variables are only stored as fingerprints.")}
@@ -5089,7 +5130,10 @@ function App() {
{t("Disposition")}
onSetDisposition(finding.id, value)}
/>
@@ -5098,7 +5142,10 @@ function App() {
value={
riskDrafts[finding.id] ?? finding.risk_reason ?? ""
}
- disabled={busy || caseDetail.approved}
+ disabled={
+ busy ||
+ (caseDetail.approved && caseDetail.workflow_state !== "NeedsHuman")
+ }
placeholder={t("Explain why this risk is accepted")}
onChange={(event) =>
setRiskDrafts((previous) => ({
@@ -5143,7 +5190,7 @@ function App() {
{t("Generate the next revision")} →
- ) : caseDetail.approved || approvedCase ? (
+ ) : caseDetail.approved && caseDetail.workflow_state === "ExecutionReady" ? (
<>
→
>
+ ) : caseDetail.approved && caseDetail.workflow_state === "NeedsHuman" ? (
+
+
{t("Human review required")}
+
{t("Resolve or accept the review findings, approve the case again, and then start formal execution.")}
+
) : caseDetail.can_approve ? (
= {
"Optional": "可选",
"Environment information": "环境信息",
"Example:\nService URL: http://localhost:8300\nLogin endpoint: POST /api/login\nVerify MySQL and Redis state changes; test tenant: qa": "例如:\n服务地址:http://localhost:8300\n登录接口:POST /api/login\n需要验证 MySQL 和 Redis 中的状态变化,测试租户为 qa",
- "Free-form text never supplies startup commands or persisted secrets. Use masked runtime inputs declared by the Profile; only keys and fingerprints are stored.": "不会从自由文本提取或执行启动命令,也不会持久化自由文本。秘密应通过 Profile 声明的 masked runtime input 提供,数据库只记录键与指纹。",
+ "Free-form text is saved with this task and sent to the CLI; avoid putting secrets in it. Username, password, and variables are only stored as fingerprints.": "自由文本会随任务保存并发送给 CLI,请勿填写明文密钥;账号、密码和变量只保存指纹。",
"Test strategy": "测试策略",
"API E2E flow test": "接口 E2E 流程测试",
"Clean up test data after case execution": "用例执行后清理测试数据",
@@ -331,6 +331,8 @@ const zhCN: Record = {
"Changes required": "需要修改",
"Blocked": "阻塞",
"Human review required": "需要人工判断",
+ "This task requires human review. Resolve or accept the review findings, approve the case again, and then start formal execution.": "当前任务需要人工判断。请先处理或接受评审发现,重新批准用例后再开始正式执行。",
+ "Resolve or accept the review findings, approve the case again, and then start formal execution.": "请先处理或接受评审发现,重新批准用例后再开始正式执行。",
"Accept fail": "接受失败",
"Critical": "严重",
"High risk": "高风险",
diff --git a/crates/agent-drivers/src/codex.rs b/crates/agent-drivers/src/codex.rs
index daa0ef1..2db5d2a 100644
--- a/crates/agent-drivers/src/codex.rs
+++ b/crates/agent-drivers/src/codex.rs
@@ -12,6 +12,7 @@ use chrono::{DateTime, Utc};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
+use std::ffi::OsString;
use std::fs::OpenOptions;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
@@ -255,6 +256,15 @@ fn run_command_with_timeout_logged_cancelable_owned_in_dir_with_env(
.and_then(|path| path.parent())
.map(|dir| dir.join("generation.cancel"));
let mut command = Command::new(program);
+ let is_agent_cli = Path::new(program)
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| matches!(name, "codex" | "claude" | "grok"));
+ if is_agent_cli && environment.map_or(true, |values| !values.contains_key("PATH")) {
+ if let Some(path) = login_shell_path() {
+ command.env("PATH", path);
+ }
+ }
if Path::new(program)
.file_name()
.and_then(|name| name.to_str())
@@ -471,6 +481,7 @@ fn run_codex_prompt(
prompt: &str,
model: Option<&str>,
) -> std::result::Result {
+ let program = codex_command_path()?;
let mut full_args = args.to_vec();
if let Some(model) = model.filter(|model| !model.trim().is_empty()) {
if full_args.get(1) == Some(&"resume") && !full_args.is_empty() {
@@ -482,7 +493,7 @@ fn run_codex_prompt(
}
full_args.push("-");
run_command_with_timeout_logged_cancelable(
- "codex",
+ program.to_str().unwrap_or("codex"),
&full_args,
Some(prompt),
codex_total_timeout(),
@@ -501,6 +512,7 @@ fn run_codex_with_progress(
model: Option<&str>,
environment: Option<&HashMap>,
) -> std::result::Result {
+ let program = codex_command_path()?;
let (stdout_log, stderr_log) = progress_dir
.map(PathBuf::from)
.map(|dir| {
@@ -527,7 +539,7 @@ fn run_codex_with_progress(
}
full_args.push("-".into());
run_command_with_timeout_logged_cancelable_owned_in_dir_with_env(
- "codex",
+ program.to_str().unwrap_or("codex"),
&full_args,
Some(prompt),
codex_total_timeout(),
@@ -540,23 +552,90 @@ fn run_codex_with_progress(
)
}
-fn claude_command_path() -> std::result::Result {
- if let Ok(path) = std::env::var("TESTHARBOR_CLAUDE_BIN") {
- let path = PathBuf::from(path);
+fn resolve_cli_path(
+ name: &str,
+ override_env: &str,
+ display_name: &str,
+) -> std::result::Result {
+ if let Ok(path) = std::env::var(override_env) {
+ let path = PathBuf::from(path.trim());
if path.is_file() {
return Ok(path);
}
}
if let Some(path) = std::env::var_os("PATH").and_then(|value| {
std::env::split_paths(&value)
- .map(|dir| dir.join("claude"))
+ .map(|dir| dir.join(name))
.find(|candidate| candidate.is_file())
}) {
return Ok(path);
}
- Err(CodexError::Unavailable(
- "未找到 Claude Code CLI。请安装 claude,或设置 TESTHARBOR_CLAUDE_BIN".into(),
- ))
+
+ // Packaged GUI apps often start without the user's terminal PATH. Ask
+ // the user's login shell to resolve the same command instead of requiring
+ // an absolute path in the settings UI.
+ #[cfg(unix)]
+ {
+ for shell in login_shells() {
+ if let Ok(output) = Command::new(shell)
+ .args(["-lc", "command -v \"$1\"", "testharbor-resolve", name])
+ .output()
+ {
+ if let Some(path) = String::from_utf8_lossy(&output.stdout)
+ .lines()
+ .rev()
+ .map(str::trim)
+ .find(|line| Path::new(line).is_file())
+ {
+ return Ok(PathBuf::from(path));
+ }
+ }
+ }
+ }
+
+ Err(CodexError::Unavailable(format!(
+ "未找到 {display_name}。请安装 {name},或设置 {override_env}"
+ )))
+}
+
+fn codex_command_path() -> std::result::Result {
+ resolve_cli_path("codex", "TESTHARBOR_CODEX_BIN", "Codex CLI")
+}
+
+fn claude_command_path() -> std::result::Result {
+ resolve_cli_path("claude", "TESTHARBOR_CLAUDE_BIN", "Claude Code CLI")
+}
+
+#[cfg(unix)]
+fn login_shells() -> Vec {
+ let mut shells = Vec::new();
+ if let Some(shell) = std::env::var_os("SHELL") {
+ shells.push(shell);
+ }
+ for shell in ["/bin/zsh", "/bin/bash", "/bin/sh"] {
+ let shell = OsString::from(shell);
+ if !shells.iter().any(|candidate| candidate == &shell) {
+ shells.push(shell);
+ }
+ }
+ shells
+}
+
+#[cfg(unix)]
+fn login_shell_path() -> Option {
+ login_shells().into_iter().find_map(|shell| {
+ let output = Command::new(shell)
+ .args(["-lc", "printf '%s' \"$PATH\""])
+ .output()
+ .ok()?;
+ let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ (!path.is_empty()).then(|| OsString::from(path))
+ })
+}
+
+#[cfg(not(unix))]
+fn login_shell_path() -> Option {
+ None
}
fn run_claude_with_progress(
@@ -968,7 +1047,8 @@ fn extract_thread_id(stdout: &[u8]) -> Option {
}
pub fn probe_codex() -> std::result::Result {
- let output = Command::new("codex")
+ let program = codex_command_path()?;
+ let output = Command::new(&program)
.args(["--version"])
.output()
.map_err(|e| CodexError::Unavailable(e.to_string()))?;
@@ -978,7 +1058,7 @@ pub fn probe_codex() -> std::result::Result {
));
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
- let help = Command::new("codex")
+ let help = Command::new(&program)
.args(["exec", "--help"])
.output()
.map_err(|e| CodexError::Unavailable(e.to_string()))?;
@@ -988,7 +1068,7 @@ pub fn probe_codex() -> std::result::Result {
));
}
let help_txt = String::from_utf8_lossy(&help.stdout);
- let auth = Command::new("codex")
+ let auth = Command::new(&program)
.args(["login", "status"])
.output()
.map_err(|e| CodexError::Unavailable(e.to_string()))?;
@@ -1053,8 +1133,9 @@ struct CodexModelCatalogResponse {
}
pub fn list_codex_models() -> std::result::Result {
+ let program = codex_command_path()?;
let output = run_command_with_timeout_logged_cancelable_owned(
- "codex",
+ program.to_str().unwrap_or("codex"),
&["debug".into(), "models".into()],
None,
DEFAULT_MODEL_CATALOG_TIMEOUT,
@@ -1503,7 +1584,7 @@ fn generate_with_provider(req: &GenerateRequest, provider: CliProvider) -> Resul
prompt
};
let prompt = format!(
- "{prompt}\nFINAL OUTPUT CONTRACT: after editing, return exactly one complete JSON envelope with schema_version=1, task_kind=generate_tests, test_intent_hash={}, scope_hash={}, evidence_refs={:?}, status=completed, a non-empty summary, and payload {{written_files:[repository-relative paths], proposed_intended_names:[exact stable test identities], notes:[], test_runner:{{executable:\"\",args:[\"\"],cwd:\".\",parser:\"exit-code\",timeout_ms:3600000}}}}. Set test_runner to the repository-native runner selected after inspecting the project, its language/tooling, configuration, README, and existing tests; do not assume any specific language or framework. Use null only when TestHarbor already supplied a compatible confirmed runner. Do not return shell pipelines or a single shell command string: executable and args must be separate. Bare payloads, Markdown fences, missing intended identities, or mismatched hashes/evidence refs are invalid.",
+ "{prompt}\nFINAL OUTPUT CONTRACT: after editing, return exactly one complete JSON envelope with schema_version=1, task_kind=generate_tests, test_intent_hash={}, scope_hash={}, evidence_refs={:?}, status=completed, a non-empty summary, and payload {{written_files:[repository-relative paths], proposed_intended_names:[exact stable test identities], notes:[], test_runner:{{executable:\"\",args:[\"\"],cwd:\".\",parser:\"exit-code\",timeout_ms:3600000}}}}. Inspect the project, its language/tooling, configuration, README, and existing tests to select the best executable runner; do not assume any specific language or framework. Missing login credentials, a running service, a dedicated E2E harness, a test database, or persistence fixtures must not stop test generation: record those limits in summary/notes and still generate the best candidate. If a likely test entrypoint exists, return the best-effort command even when it is only command-level exit-code based; use null only when no plausible test command or test entrypoint can be found at all, or when TestHarbor already supplied a compatible confirmed runner. Do not return shell pipelines or a single shell command string: executable and args must be separate. Bare payloads, Markdown fences, missing intended identities, or mismatched hashes/evidence refs are invalid.",
req.test_intent_hash, req.scope_hash, req.evidence_refs
);
@@ -1697,6 +1778,36 @@ pub fn grok_review(req: &ReviewRequest) -> Result {
review_with_provider(req, CliProvider::Grok)
}
+fn repair_review_patch_bundle(bundle: &Path) -> Result<()> {
+ let full_patch_path = bundle.join("diff.patch");
+ let agent_patch_path = bundle.join("agent-review.patch");
+ let Ok(full_patch) = std::fs::read(&full_patch_path) else {
+ return Ok(());
+ };
+ let needs_refresh = std::fs::read(&agent_patch_path)
+ .map(|agent_patch| agent_patch != full_patch)
+ .unwrap_or(true);
+ if needs_refresh {
+ std::fs::write(&agent_patch_path, &full_patch)?;
+ }
+
+ let manifest_path = bundle.join("manifest.json");
+ if let Ok(body) = std::fs::read_to_string(&manifest_path) {
+ if let Ok(mut manifest) = serde_json::from_str::(&body) {
+ if let Some(object) = manifest.as_object_mut() {
+ object.insert(
+ "agent_review_patch_complete".into(),
+ serde_json::Value::Bool(true),
+ );
+ let bytes = serde_json::to_vec(&manifest)
+ .map_err(|error| AgentError::OutputInvalid(error.to_string()))?;
+ std::fs::write(&manifest_path, bytes)?;
+ }
+ }
+ }
+ Ok(())
+}
+
fn review_with_provider(req: &ReviewRequest, provider: CliProvider) -> Result {
match provider {
CliProvider::Codex => {
@@ -1738,6 +1849,7 @@ fn review_with_provider(req: &ReviewRequest, provider: CliProvider) -> Result Result u32 {
mod tests {
use super::*;
+ #[test]
+ fn repairs_truncated_review_patch_from_full_diff() {
+ let bundle =
+ std::env::temp_dir().join(format!("testharbor-agent-review-{}", uuid::Uuid::new_v4()));
+ std::fs::create_dir_all(&bundle).unwrap();
+ std::fs::write(bundle.join("diff.patch"), "full patch\nwith all lines\n").unwrap();
+ std::fs::write(bundle.join("agent-review.patch"), "truncated\n").unwrap();
+ std::fs::write(bundle.join("manifest.json"), r#"{"hash":"x"}"#).unwrap();
+
+ repair_review_patch_bundle(&bundle).unwrap();
+
+ assert_eq!(
+ std::fs::read_to_string(bundle.join("agent-review.patch")).unwrap(),
+ "full patch\nwith all lines\n"
+ );
+ let manifest: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(bundle.join("manifest.json")).unwrap())
+ .unwrap();
+ assert_eq!(manifest["agent_review_patch_complete"], true);
+ std::fs::remove_dir_all(bundle).unwrap();
+ }
+
fn assert_strict_objects(value: &serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
diff --git a/crates/application/src/actions.rs b/crates/application/src/actions.rs
index c3ed1b4..5b33f2f 100644
--- a/crates/application/src/actions.rs
+++ b/crates/application/src/actions.rs
@@ -99,7 +99,10 @@ pub fn get_run_actions(
.ok()
.flatten()
.and_then(|json| serde_json::from_str::(&json).ok())
- .map(|snapshot| snapshot.required_trust_scope_hash == grant.trust_scope_hash)
+ .map(|snapshot| {
+ snapshot.required_trust_scope_hash == grant.trust_scope_hash
+ || crate::is_deferred_runner_hash(&snapshot.required_trust_scope_hash)
+ })
})
.unwrap_or(false);
let accept_ready = crate::reevaluate_accept_pass(state, run_id)
diff --git a/crates/application/src/execution.rs b/crates/application/src/execution.rs
index bf7003e..ffff414 100644
--- a/crates/application/src/execution.rs
+++ b/crates/application/src/execution.rs
@@ -293,7 +293,9 @@ pub fn confirm_profile(
if let Some(snapshot_json) = state.with_store(|store| store.get_run_snapshot_json(run.id))? {
let snapshot: testharbor_domain::RunSnapshot = serde_json::from_str(&snapshot_json)
.map_err(|error| ExecutionError::Other(error.to_string()))?;
- if snapshot.required_trust_scope_hash != grant.trust_scope_hash {
+ if snapshot.required_trust_scope_hash != grant.trust_scope_hash
+ && !crate::is_deferred_runner_hash(&snapshot.required_trust_scope_hash)
+ {
return Err(ExecutionError::Other(
"RunSnapshot 已冻结;Profile/Trust scope 变化必须创建 successor Run".into(),
));
@@ -375,6 +377,14 @@ pub fn probe_profile_readiness(
&run.base_commit,
)
.map_err(|error| ExecutionError::Other(error.to_string()))?;
+ crate::workspace::sync_runtime_context(Path::new(&project.root_path), &worktree)
+ .map_err(|error| ExecutionError::Other(error.to_string()))?;
+ crate::workspace::sync_working_tree_overlay(
+ Path::new(&project.root_path),
+ &worktree,
+ &run.base_commit,
+ )
+ .map_err(|error| ExecutionError::Other(error.to_string()))?;
let log_root = state.paths().root.join("probes").join(&probe_id);
let mut stages = Vec::new();
let mut commands = Vec::new();
diff --git a/crates/application/src/generate.rs b/crates/application/src/generate.rs
index 7f3ad3b..4132d13 100644
--- a/crates/application/src/generate.rs
+++ b/crates/application/src/generate.rs
@@ -214,7 +214,7 @@ impl TestEnvironmentInput {
}
masked_inputs.sort_by(|left, right| left.key.cmp(&right.key));
Ok(TestEnvironmentConfig {
- context: String::new(),
+ context: self.context.trim().to_string(),
base_url: self.base_url.trim().to_string(),
health_url: self
.health_url
@@ -480,9 +480,13 @@ fn build_run_snapshot(
&& (req.generation_scope.is_empty()
|| existing.test_intent.generation_scope == req.generation_scope)
&& profile.as_ref().is_none_or(|profile| {
- existing.all_command_snapshots_hash == profile.all_command_snapshots_hash()
- && existing.required_trust_scope_hash
- == testharbor_trust::profile_trust_scope_hash(&project.root_path, profile)
+ let command_hash = profile.all_command_snapshots_hash();
+ let trust_scope_hash =
+ testharbor_trust::profile_trust_scope_hash(&project.root_path, profile);
+ (existing.all_command_snapshots_hash == command_hash
+ || crate::is_deferred_runner_hash(&existing.all_command_snapshots_hash))
+ && (existing.required_trust_scope_hash == trust_scope_hash
+ || crate::is_deferred_runner_hash(&existing.required_trust_scope_hash))
})
&& existing.agent_profile_hash == current_agent_hash;
if !immutable_fields_match || !req.spec_attachments.is_empty() {
@@ -663,15 +667,14 @@ fn build_run_snapshot(
.unwrap_or_default(),
)
.map_err(|error| GenerateError::Other(error.to_string()))?;
- let deferred_runner_hash = || testharbor_domain::canonical_hash(&["agent-selected-runner"]);
let all_command_snapshots_hash = profile
.as_ref()
.map(|profile| profile.all_command_snapshots_hash())
- .unwrap_or_else(deferred_runner_hash);
+ .unwrap_or_else(crate::deferred_runner_hash);
let required_trust_scope_hash = profile
.as_ref()
.map(|profile| testharbor_trust::profile_trust_scope_hash(&project.root_path, profile))
- .unwrap_or_else(deferred_runner_hash);
+ .unwrap_or_else(crate::deferred_runner_hash);
Ok(RunSnapshot {
test_intent,
base_commit: run.base_commit.clone(),
@@ -679,7 +682,7 @@ fn build_run_snapshot(
test_profile_hash: profile_json
.as_deref()
.map(sha256_hex)
- .unwrap_or_else(deferred_runner_hash),
+ .unwrap_or_else(crate::deferred_runner_hash),
all_command_snapshots_hash,
agent_profile_hash,
path_policy_hash: sha256_hex(&path_policy_json),
@@ -762,11 +765,12 @@ fn service_reachable(base_url: &str) -> bool {
fn ensure_test_environment(environment: &TestEnvironmentInput, progress_path: &Path) -> Result<()> {
let base_url = environment.base_url.trim();
if base_url.is_empty() {
- append_progress(
- progress_path,
- "testharbor.status",
- "未提供固定服务地址;生成器不会推断或执行服务启动命令",
- )?;
+ let message = if environment.context.trim().is_empty() {
+ "未提供固定服务地址;生成器不会推断或执行服务启动命令"
+ } else {
+ "已提供环境说明;将由 CLI 判断服务地址、登录方式和启动方式"
+ };
+ append_progress(progress_path, "testharbor.status", message)?;
return Ok(());
}
if service_reachable(base_url) {
@@ -1007,19 +1011,61 @@ fn agent_runner_profile(
})
}
+/// Keep generation permissive for repositories whose test tooling is not
+/// covered by the built-in project inspector. These are best-effort command
+/// candidates only; the CLI remains the primary source of the repository's
+/// native runner.
+fn fallback_agent_runner(project: &ProjectRecord) -> Option {
+ let root = Path::new(&project.root_path);
+ let candidates: &[(&str, &str, &[&str])] = &[
+ ("test.php", "php", &["test.php"]),
+ ("test.sh", "sh", &["test.sh"]),
+ ("test.py", "python3", &["test.py"]),
+ ("pyproject.toml", "python3", &["-m", "pytest"]),
+ ("package.json", "npm", &["test"]),
+ ("Makefile", "make", &["test"]),
+ ("Cargo.toml", "cargo", &["test"]),
+ ("go.mod", "go", &["test", "-json", "./..."]),
+ ("pom.xml", "mvn", &["test"]),
+ ];
+ for (marker, executable, args) in candidates {
+ if root.join(marker).is_file() && testharbor_trust::resolve_executable(executable).is_some()
+ {
+ return Some(AgentTestRunner {
+ executable: (*executable).into(),
+ args: args.iter().map(|arg| (*arg).into()).collect(),
+ cwd: Some(".".into()),
+ parser: "exit-code".into(),
+ timeout_ms: Some(3_600_000),
+ });
+ }
+ }
+ let phpunit = root.join("vendor/bin/phpunit");
+ if phpunit.is_file() {
+ return Some(AgentTestRunner {
+ executable: "vendor/bin/phpunit".into(),
+ args: vec![],
+ cwd: Some(".".into()),
+ parser: "exit-code".into(),
+ timeout_ms: Some(3_600_000),
+ });
+ }
+ None
+}
+
fn bind_agent_runner(
state: &AppState,
run: &RunRecord,
project: &ProjectRecord,
runner: Option,
-) -> Result<()> {
- let Some(runner) = runner else {
- if state.get_trust_grant(&run.id.to_string()).is_none() {
- return Err(GenerateError::Other(
- "Agent 未返回可执行的测试命令;请让 CLI 在生成结果中返回 test_runner".into(),
- ));
+) -> Result> {
+ if runner.is_none() {
+ if let Some(existing) = state.get_trust_grant(&run.id.to_string()) {
+ return Ok(Some(existing.profile));
}
- return Ok(());
+ }
+ let Some(runner) = runner.or_else(|| fallback_agent_runner(project)) else {
+ return Ok(None);
};
let profile = agent_runner_profile(project, &runner)?
.normalize()
@@ -1030,42 +1076,14 @@ fn bind_agent_runner(
"Agent 返回的测试命令与已绑定 Profile 不一致;请重新生成 successor Run".into(),
));
}
- return Ok(());
+ return Ok(Some(existing.profile));
}
- let mut snapshot = state
- .with_store(|store| store.get_run_snapshot_json(run.id))?
- .ok_or_else(|| {
- GenerateError::Other("RunSnapshot is required before binding test runner".into())
- })
- .and_then(|json| {
- serde_json::from_str::(&json)
- .map_err(|error| GenerateError::Other(error.to_string()))
- })?;
- let profile_json =
- serde_json::to_vec(&profile).map_err(|error| GenerateError::Other(error.to_string()))?;
- snapshot.test_profile_hash = sha256_hex(&profile_json);
- snapshot.all_command_snapshots_hash = profile.all_command_snapshots_hash();
- snapshot.env_policy_hash = sha256_hex(
- &serde_json::to_vec(&profile.env_policy)
- .map_err(|error| GenerateError::Other(error.to_string()))?,
- );
- snapshot.required_trust_scope_hash =
- testharbor_trust::profile_trust_scope_hash(&project.root_path, &profile);
- let snapshot_json =
- serde_json::to_vec(&snapshot).map_err(|error| GenerateError::Other(error.to_string()))?;
- let snapshot_hash = sha256_hex(&snapshot_json);
- state.with_store(|store| {
- store.set_run_snapshot(
- run.id,
- &snapshot.test_intent.hash,
- &snapshot_hash,
- &snapshot,
- )
- })?;
crate::confirm_profile(state, &run.id.to_string(), profile)
.map_err(|error| GenerateError::Other(error.to_string()))?;
- Ok(())
+ Ok(state
+ .get_trust_grant(&run.id.to_string())
+ .map(|grant| grant.profile))
}
pub(crate) fn freeze_and_review_worktree(
@@ -1093,25 +1111,26 @@ pub(crate) fn freeze_and_review_worktree(
base_commit: run.base_commit.clone(),
run_id: run.id.to_string(),
};
- bind_agent_runner(state, run, project, test_runner)?;
- let profile = state
- .get_trust_grant(&run.id.to_string())
- .map(|grant| grant.profile)
- .ok_or_else(|| {
- GenerateError::Other(
- "validation requires a confirmed TrustGrant for the frozen Profile".into(),
- )
- })?;
+ let profile = bind_agent_runner(state, run, project, test_runner)?;
+ if profile.is_none() {
+ append_progress(
+ progress_stdout,
+ "testharbor.status",
+ "未能自动确定测试命令,已保留候选进入用例评审;执行前需配置测试命令",
+ )?;
+ }
let mut validation_stages = Vec::new();
- if let Some(format) = &profile.format {
- validation_stages.push(run_validation_command(
- state,
- &run.id.to_string(),
- "format",
- format,
- &handle.worktree_path,
- profile.timeout_ms,
- )?);
+ if let Some(profile) = profile.as_ref() {
+ if let Some(format) = &profile.format {
+ validation_stages.push(run_validation_command(
+ state,
+ &run.id.to_string(),
+ "format",
+ format,
+ &handle.worktree_path,
+ profile.timeout_ms,
+ )?);
+ }
}
let snapshot_json = state
@@ -1151,27 +1170,32 @@ pub(crate) fn freeze_and_review_worktree(
&patterns,
&format!("testharbor generate r{seq}"),
)?;
- for (stage, command) in [
- ("collect", profile.collect.as_ref()),
- ("compile", profile.compile.as_ref()),
- ] {
- if let Some(command) = command {
- validation_stages.push(run_validation_command(
- state,
- &run.id.to_string(),
- stage,
- command,
- &handle.worktree_path,
- profile.timeout_ms,
- )?);
- if !ws::verify_integrity(&handle, &candidate.candidate_tree)? {
- return Err(GenerateError::Other(format!(
- "{stage} changed the frozen candidate source tree"
- )));
+ if let Some(profile) = profile.as_ref() {
+ for (stage, command) in [
+ ("collect", profile.collect.as_ref()),
+ ("compile", profile.compile.as_ref()),
+ ] {
+ if let Some(command) = command {
+ validation_stages.push(run_validation_command(
+ state,
+ &run.id.to_string(),
+ stage,
+ command,
+ &handle.worktree_path,
+ profile.timeout_ms,
+ )?);
+ if !ws::verify_integrity(&handle, &candidate.candidate_tree)? {
+ return Err(GenerateError::Other(format!(
+ "{stage} changed the frozen candidate source tree"
+ )));
+ }
}
}
}
- if profile.command.parser == "go-test-json" {
+ if profile
+ .as_ref()
+ .is_some_and(|profile| profile.command.parser == "go-test-json")
+ {
let inventory = collect_go_inventory(&handle.worktree_path, &["./..."])
.map_err(|error| GenerateError::Other(error.to_string()))?;
if proposed_intended_names.is_empty() {
@@ -1229,11 +1253,15 @@ pub(crate) fn freeze_and_review_worktree(
)
.unwrap_or_default();
let validation_summary = serde_json::json!({
- "policy": "passed",
+ "policy": if profile.is_some() { "passed" } else { "deferred" },
+ "runner_status": if profile.is_some() { "bound" } else { "unconfigured" },
"writable": true,
"stages": validation_stages,
"candidate_tree": candidate.candidate_tree,
- "all_command_snapshots_hash": profile.all_command_snapshots_hash(),
+ "all_command_snapshots_hash": profile
+ .as_ref()
+ .map(|profile| profile.all_command_snapshots_hash())
+ .unwrap_or_else(crate::deferred_runner_hash),
"human_change_request": pending_change.as_ref().map(|request| request.message.clone()),
})
.to_string();
@@ -1287,7 +1315,10 @@ pub(crate) fn freeze_and_review_worktree(
let scope_hash = testharbor_domain::canonical_hash(&[
test_intent_hash,
&candidate.candidate_tree,
- &profile.all_command_snapshots_hash(),
+ &profile
+ .as_ref()
+ .map(|profile| profile.all_command_snapshots_hash())
+ .unwrap_or_else(crate::deferred_runner_hash),
&validation_hash,
]);
let evidence_refs = vec![
@@ -1484,7 +1515,7 @@ pub(crate) fn freeze_and_review_worktree(
state
.get_trust_grant(&run.id.to_string())
.map(|g| g.trust_scope_hash)
- .unwrap_or_else(|| "no-trust".into())
+ .unwrap_or_else(crate::deferred_runner_hash)
.as_str(),
);
@@ -2707,6 +2738,7 @@ index 111..222 100644
let data = tempfile::tempdir().unwrap();
let state = AppState::open_at(data.path()).unwrap();
let input = TestEnvironmentInput {
+ context: "服务运行在项目配置指定的环境,使用 qa 租户".into(),
username: Some("qa-user".into()),
password: Some("do-not-store-this".into()),
variables: HashMap::from([("API_TOKEN".into(), "token-value".into())]),
@@ -2717,6 +2749,7 @@ index 111..222 100644
assert!(!encoded.contains("do-not-store-this"));
assert!(!encoded.contains("token-value"));
assert!(encoded.contains("hmac-sha256:"));
+ assert_eq!(first.context, input.context);
assert_eq!(first, input.config(&state).unwrap());
}
@@ -2797,6 +2830,117 @@ index 111..222 100644
.unwrap();
}
+ fn init_plain_repo(dir: &std::path::Path) {
+ Command::new("git")
+ .args(["init"])
+ .current_dir(dir)
+ .status()
+ .unwrap();
+ Command::new("git")
+ .args(["config", "user.email", "t@e.com"])
+ .current_dir(dir)
+ .status()
+ .unwrap();
+ Command::new("git")
+ .args(["config", "user.name", "T"])
+ .current_dir(dir)
+ .status()
+ .unwrap();
+ std::fs::write(dir.join("README.md"), "plain repository\n").unwrap();
+ Command::new("git")
+ .args(["add", "."])
+ .current_dir(dir)
+ .status()
+ .unwrap();
+ Command::new("git")
+ .args(["commit", "-m", "i"])
+ .current_dir(dir)
+ .status()
+ .unwrap();
+ }
+
+ #[test]
+ fn fallback_runner_uses_a_plain_script_when_cli_does_not_return_one() {
+ let repo = tempfile::tempdir().unwrap();
+ init_plain_repo(repo.path());
+ std::fs::write(repo.path().join("test.sh"), "#!/bin/sh\nexit 0\n").unwrap();
+ let data = tempfile::tempdir().unwrap();
+ let state = AppState::open_at(data.path()).unwrap();
+ let opened = open_project(&state, repo.path(), true).unwrap();
+ let run = state
+ .with_store(|store| {
+ store.get_run(parse_run_id(&opened.created_run.unwrap().run_id).unwrap())
+ })
+ .unwrap();
+ let project = state
+ .with_store(|store| store.get_project(run.project_id))
+ .unwrap();
+
+ let runner = fallback_agent_runner(&project).expect("sh should be available");
+ assert_eq!(runner.executable, "sh");
+ assert_eq!(runner.args, vec!["test.sh"]);
+ assert_eq!(runner.parser, "exit-code");
+ }
+
+ #[test]
+ fn agent_runner_binds_without_mutating_deferred_snapshot() {
+ let data = tempfile::tempdir().unwrap();
+ let repo = tempfile::tempdir().unwrap();
+ init_plain_repo(repo.path());
+ let state = AppState::open_at(data.path()).unwrap();
+ let opened = open_project(&state, repo.path(), true).unwrap();
+ let run_id = opened.created_run.unwrap().run_id;
+ let rid = parse_run_id(&run_id).unwrap();
+ let run = state.with_store(|store| store.get_run(rid)).unwrap();
+ let project = state
+ .with_store(|store| store.get_project(run.project_id))
+ .unwrap();
+ let request = GenerateTestsRequest {
+ run_id: run_id.clone(),
+ objective: "run the repository test command".into(),
+ acceptance_criteria: vec!["the command exits successfully".into()],
+ agent_mode: Some("codex".into()),
+ ..Default::default()
+ };
+ let snapshot =
+ build_run_snapshot(&state, &run, &project, &request, AgentMode::Codex).unwrap();
+ assert!(crate::is_deferred_runner_hash(
+ &snapshot.required_trust_scope_hash
+ ));
+ let snapshot_hash = sha256_hex(&serde_json::to_vec(&snapshot).unwrap());
+ state
+ .with_store(|store| {
+ store.set_run_snapshot(
+ run.id,
+ &snapshot.test_intent.hash,
+ &snapshot_hash,
+ &snapshot,
+ )
+ })
+ .unwrap();
+
+ bind_agent_runner(
+ &state,
+ &run,
+ &project,
+ Some(AgentTestRunner {
+ executable: "/bin/echo".into(),
+ args: vec!["testharbor".into()],
+ cwd: Some(".".into()),
+ parser: "exit-code".into(),
+ timeout_ms: Some(10_000),
+ }),
+ )
+ .unwrap();
+ assert!(state.get_trust_grant(&run_id).is_some());
+ let persisted = state
+ .with_store(|store| store.get_run_snapshot_json(run.id))
+ .unwrap()
+ .unwrap();
+ let persisted: RunSnapshot = serde_json::from_str(&persisted).unwrap();
+ assert_eq!(persisted, snapshot);
+ }
+
#[test]
fn case_review_detail_includes_patch() {
let data = tempfile::tempdir().unwrap();
diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs
index 5bfe31c..4589162 100644
--- a/crates/application/src/lib.rs
+++ b/crates/application/src/lib.rs
@@ -9,6 +9,14 @@ mod projects;
mod result_review;
mod workspace;
+pub(crate) fn deferred_runner_hash() -> String {
+ testharbor_domain::canonical_hash(&["agent-selected-runner"])
+}
+
+pub(crate) fn is_deferred_runner_hash(value: &str) -> bool {
+ value == deferred_runner_hash()
+}
+
pub use actions::{end_run, get_run_actions, stop_current_activity, ActivityActionResult};
pub use app_state::{AppState, SharedAppState};
pub use execution::{
diff --git a/crates/application/src/result_review.rs b/crates/application/src/result_review.rs
index efe91ea..98588c5 100644
--- a/crates/application/src/result_review.rs
+++ b/crates/application/src/result_review.rs
@@ -6,7 +6,7 @@ use crate::generate::{
use crate::AppState;
use chrono::Utc;
use serde::{Deserialize, Serialize};
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use testharbor_domain::{
can_apply, ApplyContext, DeliveryStatus, HumanResultDecision, ResultVerdict, RunId,
RunPointers, WorkflowState,
@@ -773,11 +773,19 @@ pub fn create_successor_run(state: &AppState, run_id: &str) -> Result Result Result {
.map_err(|_| WorkspaceAppError::InvalidRunId)
}
+const RUNTIME_CONTEXT_FILE_LIMIT: u64 = 2 * 1024 * 1024;
+const RUNTIME_CONTEXT_TOTAL_LIMIT: u64 = 16 * 1024 * 1024;
+
+fn is_runtime_context_path(path: &Path) -> bool {
+ let extension = path
+ .extension()
+ .and_then(|value| value.to_str())
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ let file_name = path
+ .file_name()
+ .and_then(|value| value.to_str())
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ let config_extension = matches!(
+ extension.as_str(),
+ "env" | "ini" | "json" | "php" | "properties" | "toml" | "yaml" | "yml"
+ );
+ let under_config_dir = path.components().any(|component| {
+ component
+ .as_os_str()
+ .to_string_lossy()
+ .eq_ignore_ascii_case("config")
+ });
+ let under_runtime_config = path.starts_with(Path::new("rtp/config"));
+ let under_runtime_db = path.starts_with(Path::new("rtp/db")) && extension == "sql";
+ let root_level_markdown = path
+ .parent()
+ .is_some_and(|parent| parent.as_os_str().is_empty())
+ && matches!(extension.as_str(), "md" | "markdown");
+ let env_file = file_name == ".env" || file_name.starts_with(".env.");
+ let named_config = file_name == "config"
+ || file_name.starts_with("config.")
+ || (file_name.contains("config") && config_extension);
+
+ under_runtime_config
+ || under_runtime_db
+ || root_level_markdown
+ || env_file
+ || (config_extension && (under_config_dir || named_config))
+}
+
+/// Mirror safe, local-only runtime context from the main worktree into the
+/// detached Run worktree. The copy is deliberately read-only to Git: every
+/// mirrored path is added to that worktree's local exclude file, so it can be
+/// read by a CLI/test runner without becoming candidate code or requiring a
+/// commit in the user's project.
+pub(crate) fn sync_runtime_context(repo_root: &Path, worktree: &Path) -> Result> {
+ let candidates = testharbor_git_adapter::list_runtime_context_paths(repo_root)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?;
+ let mut copied = Vec::new();
+ let mut total_bytes = 0_u64;
+
+ for relative in candidates {
+ let path = Path::new(&relative);
+ if !is_runtime_context_path(path) {
+ continue;
+ }
+ let source = repo_root.join(path);
+ let destination = worktree.join(path);
+ let metadata = match std::fs::symlink_metadata(&source) {
+ Ok(metadata) => metadata,
+ Err(_) => continue,
+ };
+ if !metadata.file_type().is_file() || metadata.len() > RUNTIME_CONTEXT_FILE_LIMIT {
+ continue;
+ }
+ if destination.exists() {
+ continue;
+ }
+ if total_bytes.saturating_add(metadata.len()) > RUNTIME_CONTEXT_TOTAL_LIMIT {
+ continue;
+ }
+ if let Some(parent) = destination.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?;
+ }
+ std::fs::copy(&source, &destination)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?;
+ let mut permissions = std::fs::metadata(&destination)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?
+ .permissions();
+ permissions.set_readonly(true);
+ std::fs::set_permissions(&destination, permissions)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?;
+ testharbor_git_adapter::ensure_info_exclude(
+ worktree,
+ &format!("/{}", relative.replace(std::path::MAIN_SEPARATOR, "/")),
+ )
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?;
+ total_bytes += metadata.len();
+ copied.push(relative);
+ }
+
+ Ok(copied)
+}
+
+const WORKING_TREE_OVERLAY_FILE_LIMIT: u64 = 4 * 1024 * 1024;
+const WORKING_TREE_OVERLAY_TOTAL_LIMIT: u64 = 32 * 1024 * 1024;
+
+fn is_likely_generated_test_path(path: &Path) -> bool {
+ let normalized = path
+ .to_string_lossy()
+ .replace('\\', "/")
+ .to_ascii_lowercase();
+ let file_name = path
+ .file_name()
+ .and_then(|value| value.to_str())
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ let in_test_directory = normalized == "test"
+ || normalized.starts_with("test/")
+ || normalized == "tests"
+ || normalized.starts_with("tests/");
+ let conventional_name = file_name == "test.php"
+ || file_name == "test.py"
+ || file_name.contains("_test.")
+ || file_name.contains(".test.")
+ || file_name.contains(".spec.")
+ || file_name.starts_with("test_")
+ || file_name.ends_with("test.java")
+ || file_name.ends_with("test.kt")
+ || file_name.ends_with("test.php");
+ in_test_directory || conventional_name
+}
+
+/// Overlay current uncommitted product/source files into the isolated Run so
+/// the Agent can test the user's working-tree changes without requiring a
+/// commit. Test-shaped files are left for normal candidate generation and
+/// remain writable; source overlays are read-only and locally excluded.
+pub(crate) fn sync_working_tree_overlay(
+ repo_root: &Path,
+ worktree: &Path,
+ base_commit: &str,
+) -> Result> {
+ let candidates = testharbor_git_adapter::list_working_tree_overlay_paths(repo_root)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?;
+ let existing_changes =
+ testharbor_git_adapter::list_changed_paths_from_base(worktree, base_commit)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?
+ .into_iter()
+ .map(|change| change.path)
+ .collect::>();
+ let mut copied = Vec::new();
+ let mut total_bytes = 0_u64;
+
+ for relative in candidates {
+ let path = Path::new(&relative);
+ if is_runtime_context_path(path)
+ || is_likely_generated_test_path(path)
+ || existing_changes.contains(&relative)
+ {
+ continue;
+ }
+ let source = repo_root.join(path);
+ let destination = worktree.join(path);
+ let metadata = match std::fs::symlink_metadata(&source) {
+ Ok(metadata) => metadata,
+ Err(_) => continue,
+ };
+ if !metadata.file_type().is_file()
+ || metadata.len() > WORKING_TREE_OVERLAY_FILE_LIMIT
+ || total_bytes.saturating_add(metadata.len()) > WORKING_TREE_OVERLAY_TOTAL_LIMIT
+ {
+ continue;
+ }
+ if let Some(parent) = destination.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?;
+ }
+ std::fs::copy(&source, &destination)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?;
+ let mut permissions = std::fs::metadata(&destination)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?
+ .permissions();
+ permissions.set_readonly(true);
+ std::fs::set_permissions(&destination, permissions)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?;
+ testharbor_git_adapter::mark_skip_worktree(worktree, &relative)
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?;
+ testharbor_git_adapter::ensure_info_exclude(
+ worktree,
+ &format!("/{}", relative.replace(std::path::MAIN_SEPARATOR, "/")),
+ )
+ .map_err(|error| WorkspaceAppError::Other(error.to_string()))?;
+ total_bytes += metadata.len();
+ copied.push(relative);
+ }
+
+ Ok(copied)
+}
+
fn handle_from_run(
state: &AppState,
run_id: RunId,
@@ -84,6 +276,26 @@ pub fn prepare_worktree(state: &AppState, run_id: &str) -> Result Result Result(())
@@ -329,4 +549,90 @@ mod tests {
discard_run_worktree(&state, &run_id).unwrap();
assert!(!PathBuf::from(prep.worktree_path).exists());
}
+
+ #[test]
+ fn prepare_mirrors_ignored_runtime_context_without_candidate_changes() {
+ let data = tempfile::tempdir().unwrap();
+ let repo = tempfile::tempdir().unwrap();
+ init_repo(repo.path());
+ std::fs::write(repo.path().join(".gitignore"), "rtp/config/\n").unwrap();
+ std::fs::create_dir_all(repo.path().join("rtp/config")).unwrap();
+ std::fs::write(
+ repo.path().join("rtp/config/eo_config.php"),
+ " 'local'];\n",
+ )
+ .unwrap();
+ std::fs::write(repo.path().join("技术实现方案.md"), "runtime notes\n").unwrap();
+
+ let state = AppState::open_at(data.path()).unwrap();
+ let opened = open_project(&state, repo.path(), true).unwrap();
+ let run_id = opened.created_run.unwrap().run_id;
+ let prep = prepare_worktree(&state, &run_id).unwrap();
+ let worktree = PathBuf::from(&prep.worktree_path);
+
+ assert_eq!(
+ std::fs::read_to_string(worktree.join("rtp/config/eo_config.php")).unwrap(),
+ " 'local'];\n"
+ );
+ assert!(worktree.join("技术实现方案.md").is_file());
+ assert!(std::fs::metadata(worktree.join("rtp/config/eo_config.php"))
+ .unwrap()
+ .permissions()
+ .readonly());
+ let status = get_worktree_status(&state, &run_id).unwrap();
+ assert!(!status
+ .changes
+ .iter()
+ .any(|change| change.path == "rtp/config/eo_config.php"));
+ assert!(!status
+ .changes
+ .iter()
+ .any(|change| change.path == "技术实现方案.md"));
+
+ discard_run_worktree(&state, &run_id).unwrap();
+ }
+
+ #[test]
+ fn prepare_overlays_uncommitted_product_sources_without_freezing_them() {
+ let data = tempfile::tempdir().unwrap();
+ let repo = tempfile::tempdir().unwrap();
+ init_repo(repo.path());
+ std::fs::create_dir_all(repo.path().join("server/admin")).unwrap();
+ std::fs::write(
+ repo.path().join("server/admin/Setting.php"),
+ " Result {
Ok(status.trim().is_empty())
}
+/// List files from the current working tree that may be used as local-only
+/// runtime context. This intentionally includes ignored and untracked files;
+/// callers still apply a narrow allowlist before copying anything.
+pub fn list_runtime_context_paths(cwd: &Path) -> Result> {
+ let mut paths = BTreeSet::new();
+ for args in [
+ ["ls-files", "--cached", "-z"].as_slice(),
+ ["ls-files", "--others", "--exclude-standard", "-z"].as_slice(),
+ [
+ "ls-files",
+ "--others",
+ "--ignored",
+ "--exclude-standard",
+ "-z",
+ ]
+ .as_slice(),
+ ] {
+ for path in git_capture(cwd, args)?.split('\0') {
+ let path = path.trim();
+ if path.is_empty() {
+ continue;
+ }
+ let relative = Path::new(path);
+ if relative.is_absolute()
+ || relative.components().any(|component| {
+ matches!(
+ component,
+ std::path::Component::ParentDir
+ | std::path::Component::RootDir
+ | std::path::Component::Prefix(_)
+ )
+ })
+ {
+ continue;
+ }
+ paths.insert(path.to_string());
+ }
+ }
+ Ok(paths.into_iter().collect())
+}
+
+/// List tracked files changed from HEAD plus ordinary untracked files in the
+/// current worktree. Ignored files are intentionally excluded here because
+/// runtime configuration is handled by the narrower runtime-context mirror.
+pub fn list_working_tree_overlay_paths(cwd: &Path) -> Result> {
+ let mut paths = BTreeSet::new();
+ for path in git_capture(cwd, &["diff", "--name-only", "HEAD"])?.lines() {
+ let path = path.trim();
+ if !path.is_empty() {
+ paths.insert(path.to_string());
+ }
+ }
+ for path in git_capture(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?.split('\0')
+ {
+ let path = path.trim();
+ if !path.is_empty() {
+ paths.insert(path.to_string());
+ }
+ }
+ Ok(paths.into_iter().collect())
+}
+
/// Read a regular UTF-8 blob from an exact Git revision. This never consults
/// the working tree, so repository-backed intent attachments remain bound to
/// the Run's base commit.
@@ -205,6 +268,30 @@ pub fn ensure_info_exclude(cwd: &Path, pattern: &str) -> Result<()> {
Ok(())
}
+/// Mark a tracked file as skip-worktree in this isolated worktree only. This
+/// is used for read-only overlays sourced from the user's dirty main tree so
+/// Git does not mistake the overlay for a candidate change.
+pub fn mark_skip_worktree(cwd: &Path, repo_relative_path: &str) -> Result<()> {
+ if git_capture(
+ cwd,
+ &["ls-files", "--error-unmatch", "--", repo_relative_path],
+ )
+ .is_err()
+ {
+ return Ok(());
+ }
+ let output = Command::new("git")
+ .args(["update-index", "--skip-worktree", "--", repo_relative_path])
+ .current_dir(cwd)
+ .output()?;
+ if !output.status.success() {
+ return Err(GitError::Command(
+ String::from_utf8_lossy(&output.stderr).trim().to_string(),
+ ));
+ }
+ Ok(())
+}
+
pub(crate) fn git_capture(cwd: &Path, args: &[&str]) -> Result {
let output = Command::new("git").args(args).current_dir(cwd).output()?;
if !output.status.success() {
diff --git a/crates/review/src/lib.rs b/crates/review/src/lib.rs
index 5735b79..e10fbf4 100644
--- a/crates/review/src/lib.rs
+++ b/crates/review/src/lib.rs
@@ -223,7 +223,6 @@ pub fn write_review_bundle(
patch: &str,
validation_summary: &str,
) -> std::io::Result<()> {
- const AGENT_REVIEW_PATCH_LIMIT: usize = 24_000;
std::fs::create_dir_all(bundle_dir)?;
std::fs::write(
bundle_dir.join("test-intent.json"),
@@ -235,16 +234,12 @@ pub fn write_review_bundle(
.unwrap(),
)?;
std::fs::write(bundle_dir.join("diff.patch"), patch)?;
- let agent_patch = patch
- .chars()
- .take(AGENT_REVIEW_PATCH_LIMIT)
- .collect::();
- let agent_patch = if agent_patch.chars().count() < patch.chars().count() {
- format!("{agent_patch}\n[agent-review patch truncated; use changed-files.json for scope]")
- } else {
- agent_patch
- };
- std::fs::write(bundle_dir.join("agent-review.patch"), agent_patch)?;
+ // The review agent must be able to inspect every changed test path. A
+ // character cap here can cut a large newly-added test file in half while
+ // changed-files.json still advertises the complete file, producing a
+ // misleading review. Keep the agent-facing patch complete; callers can
+ // still use diff.patch for export and the UI excerpt for display.
+ std::fs::write(bundle_dir.join("agent-review.patch"), patch)?;
std::fs::write(
bundle_dir.join("changed-files.json"),
serde_json::to_vec_pretty(changed_paths).unwrap(),
@@ -259,7 +254,7 @@ pub fn write_review_bundle(
let manifest = hex::encode(man.finalize());
std::fs::write(
bundle_dir.join("manifest.json"),
- format!("{{\"hash\":\"{manifest}\"}}"),
+ format!("{{\"hash\":\"{manifest}\",\"agent_review_patch_complete\":true}}"),
)?;
Ok(())
}
@@ -337,4 +332,31 @@ mod tests {
vec!["high", "medium", "low"]
);
}
+
+ #[test]
+ fn review_bundle_keeps_large_agent_patch_complete() {
+ let bundle = std::env::temp_dir().join(format!("testharbor-review-{}", Uuid::new_v4()));
+ let patch = "diff --git a/generated_e2e_test.php b/generated_e2e_test.php\n".to_string()
+ + &"+step\n".repeat(8_000);
+ write_review_bundle(
+ &bundle,
+ "cover the API flow",
+ &["the persisted state changes".into()],
+ "intent",
+ &["generated_e2e_test.php".into()],
+ &patch,
+ "{}",
+ )
+ .unwrap();
+
+ assert_eq!(
+ std::fs::read_to_string(bundle.join("agent-review.patch")).unwrap(),
+ patch
+ );
+ let manifest: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(bundle.join("manifest.json")).unwrap())
+ .unwrap();
+ assert_eq!(manifest["agent_review_patch_complete"], true);
+ std::fs::remove_dir_all(bundle).unwrap();
+ }
}