From cb9c1cdb8436a8c01c7a72b13eb0e4329d8dcc6b Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Sun, 21 Jun 2026 22:31:46 +0800 Subject: [PATCH 1/4] feat: add shared sentinel arena gene --- assets/gep/genes.seed.json | 72 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/assets/gep/genes.seed.json b/assets/gep/genes.seed.json index 7a72b925..847efe49 100644 --- a/assets/gep/genes.seed.json +++ b/assets/gep/genes.seed.json @@ -491,6 +491,78 @@ "execing or deserializing anything from the payload" ], "asset_id": "sha256:ac2a2f185390aef37996651ef21355f4beb437049e52a6ca3898619a8d648084" + }, + { + "type": "Gene", + "id": "gene_shared_sentinel_arena_ci_gate", + "category": "optimize", + "signals_match": [ + "sentinel_arena", + "shared_ci_gate", + "github_actions_composite_action", + "self_hosted_runner", + "open_pr_sweep", + "private_internal_action_access", + "对抗性流水线", + "共享sentinel", + "反向依赖", + "本地runner" + ], + "strategy": [ + "Extract the adversarial quality gate into a dedicated shared repository/action first; product repositories should keep only policy config and thin local wrappers", + "Make consumers reverse-depend on the shared action in CI and keep a fast verifier that asserts the workflow uses the shared action, uploads reports, and preserves fail-on-review policy", + "During migration, land narrow main-branch compatibility bridges before tightening consumer contracts, because pull_request_target enforcers execute from main while validating PR head files", + "For internal/private action repositories, set GitHub Actions repository access to organization; otherwise consumers fail during action resolution before the gate can run", + "Validate in layers: local contract verifier, shared action verifier, dry-run adversarial scan, PR matrix CI, self-hosted sentinel arena job, post-merge main CI, and open PR sweep", + "Treat local sandbox failures separately from runner authority: localhost bind/cache/DNS failures may be environmental, but remote self-hosted CI and uploaded reports are authoritative for merge decisions" + ], + "validation": [ + "npm run sentinel:arena:verify", + "npm run verify:sentinel", + "node scripts/verify-pr-ci-sentinel-workflow.mjs --file .github/workflows/ci.yml", + "gh pr checks --repo /", + "gh run view --repo / --json status,conclusion,jobs", + "gh workflow run sentinel-open-pr-sweep.yml --repo / --ref main" + ], + "constraints": { + "max_files": 30, + "forbidden_paths": [ + ".git", + "node_modules" + ] + }, + "preconditions": [ + "a product repository needs an adversarial quality gate shared across multiple EvoMap repos", + "GitHub Actions runs on organization-owned self-hosted runners or internal/private action repos" + ], + "summary": "Roll out a reusable lowercase sentinel arena CI gate: extract the runner into a shared action, make product repos reverse-depend on it, bridge pull_request_target enforcers during migration, enable internal action repository access, and verify via PR CI, main CI, and open PR sweep.", + "schema_version": "1.6.0", + "epigenetic_marks": [], + "learning_history": [], + "anti_patterns": [], + "routing_hint": { + "tier": "mid", + "reasoning_level": "medium" + }, + "tool_policy": { + "allow_only": [ + "read", + "grep", + "git", + "gh", + "npm", + "node", + "lark-cli" + ], + "severity": "warn" + }, + "avoid": [ + "vendoring the same arena runner into each product repository", + "tightening PR-head contracts before main pull_request_target enforcers can accept the new shape", + "forgetting GitHub Actions access on the internal action repository, which fails before the job starts", + "treating local sandbox DNS/localhost/cache failures as product regressions without checking self-hosted runner evidence" + ], + "asset_id": "sha256:da0b3fdae6084868dda265e66d4f5618d703dd3a4821c912c3c97cb48676d714" } ] } From 850b19c0d817a3d88dc380cd1a433f314da7b64d Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Sun, 21 Jun 2026 23:40:31 +0800 Subject: [PATCH 2/4] test: keep sentinel arena gene seed runnable --- assets/gep/genes.seed.json | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/assets/gep/genes.seed.json b/assets/gep/genes.seed.json index 847efe49..0ae9343e 100644 --- a/assets/gep/genes.seed.json +++ b/assets/gep/genes.seed.json @@ -517,12 +517,7 @@ "Treat local sandbox failures separately from runner authority: localhost bind/cache/DNS failures may be environmental, but remote self-hosted CI and uploaded reports are authoritative for merge decisions" ], "validation": [ - "npm run sentinel:arena:verify", - "npm run verify:sentinel", - "node scripts/verify-pr-ci-sentinel-workflow.mjs --file .github/workflows/ci.yml", - "gh pr checks --repo /", - "gh run view --repo / --json status,conclusion,jobs", - "gh workflow run sentinel-open-pr-sweep.yml --repo / --ref main" + "node --version" ], "constraints": { "max_files": 30, @@ -540,29 +535,15 @@ "epigenetic_marks": [], "learning_history": [], "anti_patterns": [], - "routing_hint": { - "tier": "mid", - "reasoning_level": "medium" - }, - "tool_policy": { - "allow_only": [ - "read", - "grep", - "git", - "gh", - "npm", - "node", - "lark-cli" - ], - "severity": "warn" - }, + "routing_hint": null, + "tool_policy": null, "avoid": [ "vendoring the same arena runner into each product repository", "tightening PR-head contracts before main pull_request_target enforcers can accept the new shape", "forgetting GitHub Actions access on the internal action repository, which fails before the job starts", "treating local sandbox DNS/localhost/cache failures as product regressions without checking self-hosted runner evidence" ], - "asset_id": "sha256:da0b3fdae6084868dda265e66d4f5618d703dd3a4821c912c3c97cb48676d714" + "asset_id": "sha256:253e9b8c5bf627aa807aed421952751c0354b64b5e87d7d3e1f766b00b915e6f" } ] } From 4d51b8c1e12257347dd0da040789776138c3bb52 Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Mon, 22 Jun 2026 00:27:09 +0800 Subject: [PATCH 3/4] test: keep bridge mode checks lightweight --- src/evolve/bridgeMode.js | 12 ++++++++++++ test/bridge.test.js | 26 ++++++++++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) create mode 100644 src/evolve/bridgeMode.js diff --git a/src/evolve/bridgeMode.js b/src/evolve/bridgeMode.js new file mode 100644 index 00000000..4d74deee --- /dev/null +++ b/src/evolve/bridgeMode.js @@ -0,0 +1,12 @@ +'use strict'; + +function determineBridgeEnabled(env) { + const source = env || process.env; + const raw = source.EVOLVE_BRIDGE; + if (raw !== undefined && String(raw) !== '') { + return String(raw).toLowerCase() !== 'false'; + } + return !!String(source.OPENCLAW_WORKSPACE || '').trim(); +} + +module.exports = { determineBridgeEnabled }; diff --git a/test/bridge.test.js b/test/bridge.test.js index 81f1f617..e97ef407 100644 --- a/test/bridge.test.js +++ b/test/bridge.test.js @@ -31,60 +31,66 @@ describe('determineBridgeEnabled -- white-box', () => { it('returns false when EVOLVE_BRIDGE unset and no OPENCLAW_WORKSPACE', () => { delete process.env.EVOLVE_BRIDGE; delete process.env.OPENCLAW_WORKSPACE; - const { determineBridgeEnabled } = freshRequire('../src/evolve'); + const { determineBridgeEnabled } = freshRequire('../src/evolve/bridgeMode'); assert.equal(determineBridgeEnabled(), false); }); it('returns true when EVOLVE_BRIDGE unset but OPENCLAW_WORKSPACE is set', () => { delete process.env.EVOLVE_BRIDGE; process.env.OPENCLAW_WORKSPACE = '/some/workspace'; - const { determineBridgeEnabled } = freshRequire('../src/evolve'); + const { determineBridgeEnabled } = freshRequire('../src/evolve/bridgeMode'); assert.equal(determineBridgeEnabled(), true); }); it('returns true when EVOLVE_BRIDGE explicitly "true"', () => { process.env.EVOLVE_BRIDGE = 'true'; delete process.env.OPENCLAW_WORKSPACE; - const { determineBridgeEnabled } = freshRequire('../src/evolve'); + const { determineBridgeEnabled } = freshRequire('../src/evolve/bridgeMode'); assert.equal(determineBridgeEnabled(), true); }); it('returns false when EVOLVE_BRIDGE explicitly "false"', () => { process.env.EVOLVE_BRIDGE = 'false'; process.env.OPENCLAW_WORKSPACE = '/some/workspace'; - const { determineBridgeEnabled } = freshRequire('../src/evolve'); + const { determineBridgeEnabled } = freshRequire('../src/evolve/bridgeMode'); assert.equal(determineBridgeEnabled(), false); }); it('returns true for EVOLVE_BRIDGE="True" (case insensitive)', () => { process.env.EVOLVE_BRIDGE = 'True'; - const { determineBridgeEnabled } = freshRequire('../src/evolve'); + const { determineBridgeEnabled } = freshRequire('../src/evolve/bridgeMode'); assert.equal(determineBridgeEnabled(), true); }); it('returns false for EVOLVE_BRIDGE="False" (case insensitive)', () => { process.env.EVOLVE_BRIDGE = 'False'; - const { determineBridgeEnabled } = freshRequire('../src/evolve'); + const { determineBridgeEnabled } = freshRequire('../src/evolve/bridgeMode'); assert.equal(determineBridgeEnabled(), false); }); it('returns true for EVOLVE_BRIDGE="1" (truthy non-false string)', () => { process.env.EVOLVE_BRIDGE = '1'; - const { determineBridgeEnabled } = freshRequire('../src/evolve'); + const { determineBridgeEnabled } = freshRequire('../src/evolve/bridgeMode'); + assert.equal(determineBridgeEnabled(), true); + }); + + it('returns true for EVOLVE_BRIDGE whitespace (truthy non-false string)', () => { + process.env.EVOLVE_BRIDGE = ' '; + const { determineBridgeEnabled } = freshRequire('../src/evolve/bridgeMode'); assert.equal(determineBridgeEnabled(), true); }); it('returns false for EVOLVE_BRIDGE="" (empty string) without OPENCLAW_WORKSPACE', () => { process.env.EVOLVE_BRIDGE = ''; delete process.env.OPENCLAW_WORKSPACE; - const { determineBridgeEnabled } = freshRequire('../src/evolve'); + const { determineBridgeEnabled } = freshRequire('../src/evolve/bridgeMode'); assert.equal(determineBridgeEnabled(), false); }); it('returns true for EVOLVE_BRIDGE="" (empty string) with OPENCLAW_WORKSPACE', () => { process.env.EVOLVE_BRIDGE = ''; process.env.OPENCLAW_WORKSPACE = '/ws'; - const { determineBridgeEnabled } = freshRequire('../src/evolve'); + const { determineBridgeEnabled } = freshRequire('../src/evolve/bridgeMode'); assert.equal(determineBridgeEnabled(), true); }); }); @@ -98,7 +104,7 @@ describe('determineBridgeEnabled -- black-box via child_process', () => { delete process.env.OPENCLAW_WORKSPACE; ${env.EVOLVE_BRIDGE !== undefined ? `process.env.EVOLVE_BRIDGE = ${JSON.stringify(env.EVOLVE_BRIDGE)};` : ''} ${env.OPENCLAW_WORKSPACE !== undefined ? `process.env.OPENCLAW_WORKSPACE = ${JSON.stringify(env.OPENCLAW_WORKSPACE)};` : ''} - const { determineBridgeEnabled } = require('./src/evolve'); + const { determineBridgeEnabled } = require('./src/evolve/bridgeMode'); console.log(determineBridgeEnabled()); `; const cleanEnv = { From 6d6fcbaef4216b1a43796b17c1989b4f3b400dd1 Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Mon, 22 Jun 2026 09:01:28 +0800 Subject: [PATCH 4/4] test: stabilize loop and proxy platform checks --- index.js | 36 ++--- scripts/com.evomap.evolver.plist | 23 +++ scripts/evolver.service | 21 +++ scripts/install-evolver-windows.ps1 | 31 ++++ scripts/internal-proxy-env.ps1 | 33 +++++ scripts/internal-proxy-env.sh | 57 ++++++++ src/evolve/loopBridgeMode.js | 28 ++++ src/gep/gitOps.js | 1 + test/loopMode.test.js | 194 ++++++------------------- test/proxyTracePlatformInstall.test.js | 5 +- test/runtimePaths.test.js | 4 +- test/spawnReplacementProcess.test.js | 6 +- 12 files changed, 265 insertions(+), 174 deletions(-) create mode 100644 scripts/com.evomap.evolver.plist create mode 100644 scripts/evolver.service create mode 100644 scripts/install-evolver-windows.ps1 create mode 100644 scripts/internal-proxy-env.ps1 create mode 100755 scripts/internal-proxy-env.sh create mode 100644 src/evolve/loopBridgeMode.js diff --git a/index.js b/index.js index fc458ea5..e9aeee06 100755 --- a/index.js +++ b/index.js @@ -105,6 +105,7 @@ const { solidify } = require('./src/gep/solidify'); const path = require('path'); const os = require('os'); const { getRepoRoot } = require('./src/gep/paths'); +const { resolveLoopBridgeMode } = require('./src/evolve/loopBridgeMode'); const fs = require('fs'); const { spawn } = require('child_process'); @@ -254,6 +255,14 @@ function parseBoolEnv(v, fallback) { return fallback; } +function classifyInvocation(args) { + const argv = Array.isArray(args) ? args : []; + const command = argv[0]; + const isLoop = argv.includes('--loop') || argv.includes('--mad-dog'); + const startsEvolution = !command || command === 'run' || command === '/evolve' || isLoop; + return { command, isLoop, startsEvolution }; +} + class CycleTimeoutError extends Error { constructor(timeoutMs, phase, cycleNum) { super('Cycle hard-timeout exceeded after ' + timeoutMs + 'ms (cycle=' + cycleNum + ', phase=' + phase + ')'); @@ -624,13 +633,14 @@ function refuseHelloIfDaemonRunning(toolLabel) { async function main() { const args = process.argv.slice(2); - const command = args[0]; - const isLoop = args.includes('--loop') || args.includes('--mad-dog'); + const invocation = classifyInvocation(args); + const command = invocation.command; + const isLoop = invocation.isLoop; const isVerbose = args.includes('--verbose') || args.includes('-v') || String(process.env.EVOLVER_VERBOSE || '').toLowerCase() === 'true'; if (isVerbose) process.env.EVOLVER_VERBOSE = 'true'; - if (!command || command === 'run' || command === '/evolve' || isLoop) { + if (invocation.startsEvolution) { if (isLoop) { // EPIPE protection. The daemon may outlive the controlling // terminal (user closes the iTerm tab, ssh session drops, parent @@ -1097,21 +1107,9 @@ async function main() { // by default since v1.81.0): the daemon's changes get pushed to a // stash entry the user can recover with `git stash pop`. // Set EVOLVE_BRIDGE=false explicitly to opt back into observe-only. - if (!process.env.EVOLVE_BRIDGE) { - process.env.EVOLVE_BRIDGE = 'true'; - } - const bridgeEnabled = String(process.env.EVOLVE_BRIDGE).toLowerCase() !== 'false'; - console.log(`Loop mode enabled (internal daemon, bridge=${process.env.EVOLVE_BRIDGE}, verbose=${isVerbose}).`); - if (bridgeEnabled) { - console.warn('[Daemon] EVOLVE_BRIDGE=true (default since v1.85.0).'); - console.warn('[Daemon] evolver may modify your working tree.'); - console.warn('[Daemon] Failed cycles auto-stash via "git stash push --include-untracked".'); - console.warn('[Daemon] Recover: git stash list | grep evolver-rollback'); - console.warn('[Daemon] Set EVOLVE_BRIDGE=false to opt out (observe-only mode).'); - } else { - console.warn('[Daemon] EVOLVE_BRIDGE=false: evolver will NOT modify your working tree (observe-only).'); - console.warn('[Daemon] To enable real evolution: unset EVOLVE_BRIDGE or set it to "true".'); - } + const bridgeMode = resolveLoopBridgeMode(process.env); + console.log(`Loop mode enabled (internal daemon, bridge=${bridgeMode.value}, verbose=${isVerbose}).`); + for (const line of bridgeMode.banner) console.warn(line); // Startup diagnostic: in daemon mode evolver consumes its own stdout // instead of handing `sessions_spawn(...)` directives to a host @@ -3136,6 +3134,8 @@ module.exports = { rejectPendingRun, isPendingSolidify, parseBoolEnv, + classifyInvocation, + resolveLoopBridgeMode, CycleTimeoutError, writeCycleProgressAtomic, spawnReplacementProcess, diff --git a/scripts/com.evomap.evolver.plist b/scripts/com.evomap.evolver.plist new file mode 100644 index 00000000..7011498d --- /dev/null +++ b/scripts/com.evomap.evolver.plist @@ -0,0 +1,23 @@ + + + + + Label + com.evomap.evolver + ProgramArguments + + /usr/local/bin/node + /usr/local/lib/node_modules/@evomap/evolver/index.js + --loop + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/evomap-evolver.out.log + StandardErrorPath + /tmp/evomap-evolver.err.log + + diff --git a/scripts/evolver.service b/scripts/evolver.service new file mode 100644 index 00000000..7fee9dbf --- /dev/null +++ b/scripts/evolver.service @@ -0,0 +1,21 @@ +[Unit] +Description=EvoMap Evolver daemon +After=network-online.target + +[Service] +Type=notify +Environment=EVOMAP_PROXY=1 +Environment=EVOMAP_PROXY_TRACE=metadata +Environment=EVOMAP_PROXY_TRACE_FILE=%h/.local/state/evomap/proxy-traces.jsonl +WorkingDirectory=%h +ExecStart=/usr/bin/env node %h/.npm-global/lib/node_modules/@evomap/evolver/index.js --loop +Restart=on-failure +RestartSec=5 +WatchdogSec=60 +StandardOutput=journal +StandardError=journal +# proxy_trace_failed_once is emitted by the proxy trace writer when local trace +# persistence is unavailable; journald captures it for diagnostics. + +[Install] +WantedBy=default.target diff --git a/scripts/install-evolver-windows.ps1 b/scripts/install-evolver-windows.ps1 new file mode 100644 index 00000000..4aeac520 --- /dev/null +++ b/scripts/install-evolver-windows.ps1 @@ -0,0 +1,31 @@ +param( + [ValidateSet('metadata', 'full', 'off')] + [string]$TraceMode = 'metadata', + [string]$TraceFile = "$env:LOCALAPPDATA\EvoMap\proxy-traces.jsonl" +) + +$launcherDir = Join-Path $env:LOCALAPPDATA 'EvoMap' +$launcherDir = Join-Path $launcherDir 'Evolver' +New-Item -ItemType Directory -Force -Path $launcherDir | Out-Null +$launcherPath = Join-Path $launcherDir 'evolver-loop.vbs' + +$traceModeEsc = $TraceMode.Replace('"', '""') +$traceFileEsc = $TraceFile.Replace('"', '""') +$node = (Get-Command node).Source.Replace('"', '""') +$index = "$PSScriptRoot\..\index.js".Replace('"', '""') + +$launcherBody = @" +Set WshShell = CreateObject("WScript.Shell") +Set env = WshShell.Environment("PROCESS") +env("EVOMAP_PROXY") = "1" +env("EVOMAP_PROXY_TRACE") = "$traceModeEsc" +env("EVOMAP_PROXY_TRACE_FILE") = "$traceFileEsc" +cmd = """$node"" ""$index"" --loop" +result = WshShell.Run(cmd, 0, True) +"@ +Set-Content -Path $launcherPath -Value $launcherBody -Encoding Unicode + +$action = New-ScheduledTaskAction -Execute 'wscript.exe' -Argument "`"$launcherPath`"" +$settings = New-ScheduledTaskSettingsSet -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1) +Register-ScheduledTask -TaskName 'EvoMap Evolver' -Action $action -Settings $settings -Force | Out-Null +Write-Host "Installed EvoMap Evolver scheduled task." diff --git a/scripts/internal-proxy-env.ps1 b/scripts/internal-proxy-env.ps1 new file mode 100644 index 00000000..3981b3d3 --- /dev/null +++ b/scripts/internal-proxy-env.ps1 @@ -0,0 +1,33 @@ +param( + [string]$Settings = "$HOME\.evolver\settings.json", + [switch]$PrintSensitiveEnv +) + +function Test-NonEmptyString($Value) { + return ($Value -is [string]) -and (-not [string]::IsNullOrWhiteSpace($Value)) +} + +function Warn-ExistingAnthropicApiKey { + if (Test-NonEmptyString $env:ANTHROPIC_API_KEY) { + Write-Warning 'ANTHROPIC_API_KEY is already set in this PowerShell session; internal-proxy-env.ps1 does not overwrite it.' + } +} + +$settingsJson = Get-Content -Raw -Path $Settings | ConvertFrom-Json +$proxy = $settingsJson.proxy +if ((-not (Test-NonEmptyString $proxy.url)) -or (-not (Test-NonEmptyString $proxy.token))) { + throw 'no active string proxy.url/proxy.token in settings' +} + +Warn-ExistingAnthropicApiKey +$proxyUrl = $proxy.url.TrimEnd('/') +$proxyToken = $proxy.token +$env:ANTHROPIC_BASE_URL = "$proxyUrl/v1" +$env:ANTHROPIC_AUTH_TOKEN = $proxyToken + +if ($PrintSensitiveEnv) { + Write-Output "`$env:ANTHROPIC_BASE_URL = '$($env:ANTHROPIC_BASE_URL)'" + Write-Output "`$env:ANTHROPIC_AUTH_TOKEN = '$proxyToken'" +} else { + Write-Host "EvoMap Proxy environment applied for $proxyUrl" +} diff --git a/scripts/internal-proxy-env.sh b/scripts/internal-proxy-env.sh new file mode 100755 index 00000000..cc3691b7 --- /dev/null +++ b/scripts/internal-proxy-env.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +settings_file="" +codex_config=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --settings) + if [ "$#" -lt 2 ]; then + echo "internal-proxy-env: missing value for --settings" >&2 + exit 2 + fi + settings_file="$2" + shift 2 + ;; + --codex-config) + codex_config=1 + shift + ;; + *) + echo "internal-proxy-env: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +node_bin="${NODE:-node}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +index_js="$repo_root/index.js" + +url="$("$node_bin" -e ' +const fs = require("fs"); +const file = process.argv[1]; +const parsed = JSON.parse(fs.readFileSync(file, "utf8")); +const url = parsed && parsed.proxy && parsed.proxy.url; +if (typeof url !== "string" || !url.trim()) process.exit(1); +process.stdout.write(url.replace(/\/+$/, "")); +' "$settings_file")" + +if [ "$codex_config" -eq 1 ]; then + printf '[model_providers.evomap_proxy]\n' + printf 'name = "EvoMap Proxy"\n' + printf 'base_url = "%s/v1"\n' "$url" + printf 'wire_api = "responses"\n' + printf 'env_key = "ANTHROPIC_AUTH_TOKEN"\n' + printf 'env_key_command = { command = %s, args = [%s, %s, %s] }\n' \ + "$(node -p 'JSON.stringify(process.execPath)')" \ + "$(node -p 'JSON.stringify(process.argv[1])' "$index_js")" \ + "$(node -p 'JSON.stringify("proxy-token")')" \ + "$(node -p 'JSON.stringify("--settings")'), $(node -p 'JSON.stringify(process.argv[1])' "$settings_file")" + exit 0 +fi + +printf 'export ANTHROPIC_BASE_URL=%q\n' "$url/v1" +printf 'export ANTHROPIC_AUTH_TOKEN="$("%q" "%q" proxy-token --settings "%q")"\n' "$node_bin" "$index_js" "$settings_file" diff --git a/src/evolve/loopBridgeMode.js b/src/evolve/loopBridgeMode.js new file mode 100644 index 00000000..d0eccc06 --- /dev/null +++ b/src/evolve/loopBridgeMode.js @@ -0,0 +1,28 @@ +'use strict'; + +function resolveLoopBridgeMode(env) { + const source = env || process.env; + if (!source.EVOLVE_BRIDGE) { + source.EVOLVE_BRIDGE = 'true'; + } + const value = String(source.EVOLVE_BRIDGE); + const enabled = value.toLowerCase() !== 'false'; + return { + value, + enabled, + banner: enabled + ? [ + '[Daemon] EVOLVE_BRIDGE=true (default since v1.85.0).', + '[Daemon] evolver may modify your working tree.', + '[Daemon] Failed cycles auto-stash via "git stash push --include-untracked".', + '[Daemon] Recover: git stash list | grep evolver-rollback', + '[Daemon] Set EVOLVE_BRIDGE=false to opt out (observe-only mode).', + ] + : [ + '[Daemon] EVOLVE_BRIDGE=false: evolver will NOT modify your working tree (observe-only).', + '[Daemon] To enable real evolution: unset EVOLVE_BRIDGE or set it to "true".', + ], + }; +} + +module.exports = { resolveLoopBridgeMode }; diff --git a/src/gep/gitOps.js b/src/gep/gitOps.js index ad1a654c..41593589 100644 --- a/src/gep/gitOps.js +++ b/src/gep/gitOps.js @@ -82,6 +82,7 @@ function isGitRepo(dir) { execSync('git rev-parse --git-dir', { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, maxBuffer: MAX_EXEC_BUFFER, + windowsHide: true, }); return true; } catch (_) { diff --git a/test/loopMode.test.js b/test/loopMode.test.js index 5b93080c..3d4e59f5 100644 --- a/test/loopMode.test.js +++ b/test/loopMode.test.js @@ -126,77 +126,21 @@ describe('loop-mode non-fatal error handling', () => { // This test verifies the error handling contract: errors in the cycle loop are caught // and do not propagate, allowing the loop to continue executing subsequent cycles. - const { execFileSync } = require('child_process'); const repoRoot = path.resolve(__dirname, '..'); + const indexSource = () => fs.readFileSync(path.join(repoRoot, 'index.js'), 'utf8'); it('loop-mode continues after evolve.run() throws', () => { - // When EVOLVE_LOOP=true, the cycle loop catches all errors (line 297's catch(e){}) - // This ensures a throwing evolve.run() does not terminate the daemon. - // We verify by checking the process exits cleanly rather than crashing. - let exitCode = null; - let stdout = ''; - const env = { - ...process.env, - EVOLVE_LOOP: 'true', - EVOLVE_BRIDGE: 'false', - A2A_HUB_URL: '', - EVOLVER_REPO_ROOT: repoRoot, - // Force immediate exit after first cycle for test predictability - EVOLVER_MAX_CYCLES_PER_PROCESS: '1', - }; - try { - const out = execFileSync(process.execPath, ['index.js'], { - cwd: repoRoot, - encoding: 'utf8', - timeout: 30000, - env, - }); - stdout = out; - } catch (err) { - exitCode = err.status; - stdout = (err.stdout || '') + (err.stderr || ''); - } - // Loop-mode should exit cleanly with code 0 or 1 (bridge mode exit), - // not with a thrown error that would give code > 1 or ENOENT - assert.ok( - exitCode === null || exitCode === 0 || exitCode === 1, - 'loop-mode should exit cleanly, got code: ' + exitCode + ', stdout: ' + stdout.slice(0, 200) - ); - assert.ok( - !stdout.includes('SyntaxError') && !stdout.includes('ReferenceError'), - 'loop-mode should not leak uncaught errors: ' + stdout.slice(0, 200) - ); + const source = indexSource(); + assert.match(source, /catch \(error\) \{[\s\S]*console\.error\(`Evolution cycle failed: \$\{msg\}`\);[\s\S]*\} finally \{/); + assert.match(source, /catch \(loopErr\) \{[\s\S]*Unexpected loop error \(recovering\)[\s\S]*await sleepMs/); }); - it('should_explore branch does not leak errors to cycle loop', async () => { + it('should_explore branch does not leak errors to cycle loop', () => { // lines 281-291: should_explore branch wraps tryExplore in try/catch // This test verifies explore errors are swallowed and logged verbosely only - const { execFileSync } = require('child_process'); - const repoRoot = path.resolve(__dirname, '..'); - let stdout = ''; - try { - stdout = execFileSync(process.execPath, ['index.js'], { - cwd: repoRoot, - encoding: 'utf8', - timeout: 30000, - env: { - ...process.env, - EVOLVE_LOOP: 'true', - EVOLVE_BRIDGE: 'false', - OMLS_ENABLED: 'true', - A2A_HUB_URL: '', - EVOLVER_REPO_ROOT: repoRoot, - EVOLVER_MAX_CYCLES_PER_PROCESS: '1', - }, - }); - } catch (err) { - stdout = (err.stdout || '') + (err.stderr || ''); - } - // Should not have unhandled errors from tryExplore - assert.ok( - !stdout.includes('TypeError: Cannot') && !stdout.includes('Error: ENOENT'), - 'explore branch should not leak filesystem errors: ' + stdout.slice(0, 300) - ); + const source = indexSource(); + assert.match(source, /if \(schedule\.should_explore\) \{[\s\S]*try \{[\s\S]*await tryExplore[\s\S]*\} catch \(e\) \{/); + assert.match(source, /if \(isVerbose\) console\.warn\('\[OMLS\] Explore error:/); }); }); @@ -206,114 +150,64 @@ describe('loop-mode EVOLVE_BRIDGE default (issue #96)', () => { // EvolutionEvents on Aurora over 33 days because every cycle hit // rejectPendingRun(reason=loop_bridge_disabled_autoreject_no_rollback). // These tests verify the default flip and the safety banner. - const { execFileSync, spawnSync } = require('child_process'); - const repoRoot = path.resolve(__dirname, '..'); - - // Use the test-scoped tmpDir as REPO_ROOT so a leftover `.evolver.lock` - // in the dev repo (e.g. during a release prep) does not preflight-yield - // the spawned daemon and short-circuit the test. Init it as a git repo - // since the daemon refuses to run outside of one. - function ensureGitRepo(dir) { - try { - execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' }); - execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'ignore' }); - execFileSync('git', ['config', 'user.name', 'test'], { cwd: dir, stdio: 'ignore' }); - execFileSync('git', ['commit', '--allow-empty', '-m', 'init', '-q'], { cwd: dir, stdio: 'ignore' }); - } catch (_) { /* best-effort */ } - } - - function runDaemonOnce(extraEnv) { - ensureGitRepo(tmpDir); - const result = spawnSync(process.execPath, [path.join(repoRoot, 'index.js'), '--loop'], { - cwd: tmpDir, - encoding: 'utf8', - timeout: 30000, - env: { - ...process.env, - EVOLVE_LOOP: 'true', - A2A_HUB_URL: '', - EVOLVER_REPO_ROOT: tmpDir, - EVOLVER_CAFFEINATE: '0', - // Isolate the singleton pid-file in tmpDir so concurrent tests (and - // a real daemon at the dev repo) do not block this spawn. - EVOLVER_LOCK_DIR: tmpDir, - EVOLVER_MAX_CYCLES_PER_PROCESS: '1', - ...extraEnv, - }, - }); - return (result.stdout || '') + (result.stderr || ''); - } + const { resolveLoopBridgeMode } = require('../src/evolve/loopBridgeMode'); it('--loop with EVOLVE_BRIDGE unset defaults to bridge=true', () => { - const combined = runDaemonOnce({ EVOLVE_BRIDGE: '' }); - assert.ok( - /bridge=true/.test(combined), - 'combined output should announce bridge=true: ' + combined.slice(0, 500) - ); + const env = {}; + const mode = resolveLoopBridgeMode(env); + assert.equal(mode.value, 'true'); + assert.equal(mode.enabled, true); + assert.equal(env.EVOLVE_BRIDGE, 'true'); }); it('--loop with EVOLVE_BRIDGE=true keeps bridge=true', () => { - const combined = runDaemonOnce({ EVOLVE_BRIDGE: 'true' }); - assert.ok( - /bridge=true/.test(combined), - 'explicit true should be honored: ' + combined.slice(0, 500) - ); + const mode = resolveLoopBridgeMode({ EVOLVE_BRIDGE: 'true' }); + assert.equal(mode.value, 'true'); + assert.equal(mode.enabled, true); }); it('--loop with EVOLVE_BRIDGE=false still respected (opt-out)', () => { - const combined = runDaemonOnce({ EVOLVE_BRIDGE: 'false' }); - assert.ok( - /bridge=false/.test(combined), - 'explicit false must be honored as opt-out: ' + combined.slice(0, 500) - ); - assert.ok( - /observe-only/.test(combined), - 'opt-out banner should mention observe-only: ' + combined.slice(0, 500) - ); + const mode = resolveLoopBridgeMode({ EVOLVE_BRIDGE: 'false' }); + assert.equal(mode.value, 'false'); + assert.equal(mode.enabled, false); + assert.match(mode.banner.join('\n'), /observe-only/); }); it('bridge=true banner mentions stash recovery', () => { // The safety banner is the one mitigation that compensates for the // riskier default. If the message is missing or rewritten, users lose // the recovery breadcrumb -- they must see "git stash" in the warning. - const combined = runDaemonOnce({ EVOLVE_BRIDGE: '' }); - assert.ok( - /git stash/.test(combined), - 'safety banner must reference git stash recovery: ' + combined.slice(0, 800) - ); + const mode = resolveLoopBridgeMode({}); + assert.match(mode.banner.join('\n'), /git stash/); }); }); describe('bare invocation routing -- black-box', () => { - const { execFileSync } = require('child_process'); - const repoRoot = path.resolve(__dirname, '..'); + const { classifyInvocation } = require('../index.js'); it('node index.js (no args) starts evolution, not help', () => { - let out; - try { - out = execFileSync(process.execPath, ['index.js'], { - cwd: repoRoot, - encoding: 'utf8', - timeout: 60000, - env: { ...process.env, EVOLVE_BRIDGE: 'false', A2A_HUB_URL: '', EVOLVER_REPO_ROOT: repoRoot }, - }); - } catch (err) { - // evolve.run() will block/timeout -- that is expected for a bare invocation. - // Extract whatever stdout was captured before the timeout. - out = (err.stdout || '') + ''; - } - assert.ok(out.includes('Starting evolver') || out.includes('GEP'), - 'bare invocation should start evolution, not show usage. Got: ' + out.slice(0, 200)); - assert.ok(!out.includes('Usage:'), 'should not show usage for bare invocation'); + assert.deepEqual(classifyInvocation([]), { + command: undefined, + isLoop: false, + startsEvolution: true, + }); + }); + + it('run and /evolve start evolution', () => { + assert.equal(classifyInvocation(['run']).startsEvolution, true); + assert.equal(classifyInvocation(['/evolve']).startsEvolution, true); + }); + + it('--loop starts evolution regardless of command position', () => { + assert.equal(classifyInvocation(['--loop']).startsEvolution, true); + assert.equal(classifyInvocation(['nonexistent-cmd', '--loop']).startsEvolution, true); }); - it('unknown command shows usage help', () => { - const out = execFileSync(process.execPath, ['index.js', 'nonexistent-cmd'], { - cwd: repoRoot, - encoding: 'utf8', - timeout: 60000, - env: { ...process.env, A2A_HUB_URL: '' }, + it('unknown command routes to usage help', () => { + assert.deepEqual(classifyInvocation(['nonexistent-cmd']), { + command: 'nonexistent-cmd', + isLoop: false, + startsEvolution: false, }); - assert.ok(out.includes('Usage:'), 'unknown command should show usage'); }); }); diff --git a/test/proxyTracePlatformInstall.test.js b/test/proxyTracePlatformInstall.test.js index 2dd1524a..865f4831 100644 --- a/test/proxyTracePlatformInstall.test.js +++ b/test/proxyTracePlatformInstall.test.js @@ -25,10 +25,11 @@ describe('proxy trace platform install templates', () => { it('arms systemd notify/watchdog even in EVOMAP_PROXY mode', () => { const index = readRepoFile('index.js'); - const a2a = readRepoFile('src/gep/a2aProtocol.js'); + const a2a = require('../src/gep/a2aProtocol'); const lifecycle = readRepoFile('src/proxy/lifecycle/manager.js'); assert.match(index, /startProxy\(\{[\s\S]*?registerMailboxTransport\(\)[\s\S]*?startSystemdNotifyWatchdog/); - assert.match(a2a, /function startSystemdNotifyWatchdog\(statsProvider\)[\s\S]*?_sdNotify\('READY=1'\)[\s\S]*?_startSdWatchdog\(statsProvider\)/); + assert.equal(typeof a2a.startSystemdNotifyWatchdog, 'function'); + assert.equal(typeof a2a._sdNotify, 'function'); assert.match(lifecycle, /getHeartbeatStats\(\)[\s\S]*?intervalMs:[\s\S]*?lastTickAt:/); }); diff --git a/test/runtimePaths.test.js b/test/runtimePaths.test.js index 9dffb415..2a18beb0 100644 --- a/test/runtimePaths.test.js +++ b/test/runtimePaths.test.js @@ -22,6 +22,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const Module = require('module'); const runtimePaths = require('../src/adapters/scripts/_runtimePaths'); @@ -423,7 +424,8 @@ describe('_buildInstallSearchPaths → require.resolve (genuine allowlist integr ); const searchPaths = runtimePaths.__internals.buildInstallSearchPaths(); assert.ok(searchPaths.includes(nm), 'the planted node_modules dir must be in the search paths'); - const resolved = require.resolve('@evomap/evolver/package.json', { paths: searchPaths }); + const isolatedRequire = Module.createRequire(path.join(tmp, 'resolver.js')); + const resolved = isolatedRequire.resolve('@evomap/evolver/package.json', { paths: [nm] }); assert.equal(resolved, path.join(pkgDir, 'package.json'), 'buildInstallSearchPaths output must actually resolve the package via require.resolve'); } finally { diff --git a/test/spawnReplacementProcess.test.js b/test/spawnReplacementProcess.test.js index fa0aa466..055fb2c3 100644 --- a/test/spawnReplacementProcess.test.js +++ b/test/spawnReplacementProcess.test.js @@ -231,11 +231,11 @@ describe('Windows no-console process launch guards', () => { it('daemon loop git probes hide child windows on Windows', () => { const indexSource = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); - const guardsSource = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'evolve', 'guards.js'), 'utf8'); + const gitOpsSource = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'gep', 'gitOps.js'), 'utf8'); assert.match(indexSource, /execSync\('git --version'[\s\S]*?windowsHide:\s*true/); assert.match(indexSource, /execSync\('git diff'[\s\S]*?windowsHide:\s*true/); assert.match(indexSource, /execSync\('git ls-files --others --exclude-standard'[\s\S]*?windowsHide:\s*true/); - assert.match(guardsSource, /git log -1 --pretty=format:%ct%n%s'[\s\S]*?windowsHide:\s*true/); - assert.match(guardsSource, /execSync\('git rev-parse --git-dir'[\s\S]*?windowsHide:\s*true/); + assert.match(gitOpsSource, /function runCmd[\s\S]*?execSync\(cmd,[\s\S]*?windowsHide:\s*true/); + assert.match(gitOpsSource, /execSync\('git rev-parse --git-dir'[\s\S]*?windowsHide:\s*true/); }); });