diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cbfa17..e4bebe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,10 @@ jobs: shell: pwsh run: ./skills/scripts/verify-routing-coherence.ps1 + - name: Bootstrap supply-chain regression + shell: pwsh + run: ./skills/scripts/test-bootstrap-supply-chain.ps1 + - name: Smoke (verify + parse + quick route) shell: pwsh run: ./skills/scripts/smoke.ps1 diff --git a/skills/scripts/bootstrap-reverse.ps1 b/skills/scripts/bootstrap-reverse.ps1 index 212b450..c764186 100644 --- a/skills/scripts/bootstrap-reverse.ps1 +++ b/skills/scripts/bootstrap-reverse.ps1 @@ -23,6 +23,7 @@ $ErrorActionPreference = 'Stop' $OutputEncoding = [System.Text.UTF8Encoding]::new($false) . (Join-Path $PSScriptRoot 'lib\ToolDiscovery.ps1') +. (Join-Path $PSScriptRoot 'lib\BootstrapSupplyChain.ps1') function Get-BootstrapDependency { param([Parameter(Mandatory = $true)][string]$Name) @@ -164,29 +165,6 @@ function Ensure-JavaRuntime { } } -function Ensure-Pnpm { - Ensure-NodeRuntime - $dependency = Get-BootstrapDependency -Name 'pnpm' - $pnpm = Get-NodeCommandPath -Name 'pnpm' - $currentVersion = '' - if ($pnpm) { - $versionLine = & $pnpm --version 2>$null | Select-Object -First 1 - if ($LASTEXITCODE -eq 0 -and $null -ne $versionLine) { - $currentVersion = ([string]$versionLine).Trim() - } - } - if ($currentVersion -ne [string]$dependency.version) { - $npm = Get-NodeCommandPath -Name 'npm' - if ([string]::IsNullOrWhiteSpace($npm)) { - throw 'npm is not available after Node.js installation.' - } - & $npm install -g ([string]$dependency.package) - if ($LASTEXITCODE -ne 0) { - throw "Failed to install pinned pnpm dependency $($dependency.package)." - } - } -} - function Get-AnythingAnalyzerUserDataPaths { $candidates = @( (Join-Path $env:APPDATA 'anything-analyzer'), @@ -801,54 +779,9 @@ if (Test-ReverseIsWindows) { if ([string]::IsNullOrWhiteSpace($pnpm)) { throw 'pnpm is not available after installation.' } - - $workspacePath = Join-Path $repoDir 'pnpm-workspace.yaml' - $workspaceExisted = Test-Path -LiteralPath $workspacePath -PathType Leaf - $workspaceBytes = if ($workspaceExisted) { [IO.File]::ReadAllBytes($workspacePath) } else { $null } - - Push-Location $repoDir - try { - Approve-AnythingAnalyzerBuildScripts -RepoDir $repoDir - - if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $repoDir -PnpmPath $pnpm)) { - $nodeModules = Join-Path $repoDir 'node_modules' - if (Test-Path -LiteralPath $nodeModules) { - Remove-Item -LiteralPath $nodeModules -Recurse -Force - } - } - - & $pnpm install --frozen-lockfile - if ($LASTEXITCODE -ne 0) { - if (-not [string]::IsNullOrWhiteSpace($vsBuildToolsError)) { - throw "pnpm install failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $vsBuildToolsError" - } - throw 'pnpm install failed for anything-analyzer.' - } - - & $pnpm rebuild electron esbuild better-sqlite3 - if ($LASTEXITCODE -ne 0) { - if (-not [string]::IsNullOrWhiteSpace($vsBuildToolsError)) { - throw "pnpm rebuild failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $vsBuildToolsError" - } - throw 'pnpm rebuild failed for anything-analyzer.' - } - - if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $repoDir -PnpmPath $pnpm)) { - throw 'Electron is still not healthy after reinstall/rebuild.' - } - } - finally { - Pop-Location - if ($workspaceExisted) { - [IO.File]::WriteAllBytes($workspacePath, $workspaceBytes) - } - elseif (Test-Path -LiteralPath $workspacePath) { - Remove-Item -LiteralPath $workspacePath -Force - } - } - $git = Get-FirstCommandPath -Names @('git') - Assert-GitCheckoutState -GitPath $git -CheckoutPath $repoDir -PinnedCommit ([string]$Definition.pinnedCommit) + Invoke-AnythingAnalyzerPinnedInstall -RepoDir $repoDir -PnpmPath $pnpm -GitPath $git ` + -PinnedCommit ([string]$Definition.pinnedCommit) -VsBuildToolsError $vsBuildToolsError $stdoutLog = Join-Path $repoDir 'anything-analyzer-dev.log' $stderrLog = Join-Path $repoDir 'anything-analyzer-dev.err.log' @@ -889,82 +822,6 @@ function Ensure-AndroidPlatformTools { return (Resolve-ReverseToolSpec -Name 'adb') } -function Assert-GitCheckoutState { - param( - [Parameter(Mandatory = $true)][string]$GitPath, - [Parameter(Mandatory = $true)][string]$CheckoutPath, - [Parameter(Mandatory = $true)][string]$PinnedCommit - ) - - $resolvedLine = & $GitPath -C $CheckoutPath rev-parse HEAD 2>$null | Select-Object -First 1 - $resolvedCommit = if ($null -eq $resolvedLine) { '' } else { ([string]$resolvedLine).Trim() } - if ($LASTEXITCODE -ne 0 -or $resolvedCommit -ne $PinnedCommit) { - throw "Checkout verification failed: expected $PinnedCommit, got $resolvedCommit ($CheckoutPath)" - } - $status = @(& $GitPath -C $CheckoutPath status --porcelain --untracked-files=all 2>&1) - if ($LASTEXITCODE -ne 0) { - throw "Cannot inspect checkout state: $CheckoutPath" - } - if ($status.Count -gt 0) { - throw "Checkout has local changes; refusing to execute it: $CheckoutPath" - } -} - -function Ensure-GitCloneInstall { - param( - [Parameter(Mandatory = $true)]$Definition, - [Parameter(Mandatory = $true)][string]$TargetPath - ) - - $pinnedCommit = if ($Definition.PSObject.Properties['pinnedCommit']) { [string]$Definition.pinnedCommit } else { '' } - $git = Get-FirstCommandPath -Names @('git') - if ([string]::IsNullOrWhiteSpace($git)) { - throw "Cannot clone $($Definition.repo) because git is not available." - } - - if ((Test-Path -LiteralPath $TargetPath -PathType Container) -and (Test-Path -LiteralPath (Join-Path $TargetPath '.git'))) { - if ([string]::IsNullOrWhiteSpace($pinnedCommit)) { - throw "Git capability $($Definition.repo) must define pinnedCommit before an existing checkout can be used." - } - Assert-GitCheckoutState -GitPath $git -CheckoutPath $TargetPath -PinnedCommit $pinnedCommit - return $true - } - - if (Test-Path -LiteralPath $TargetPath) { - throw "Install path exists but is not a git checkout: $TargetPath" - } - if ([string]::IsNullOrWhiteSpace($pinnedCommit)) { - throw "Git capability $($Definition.repo) must define pinnedCommit." - } - - $parent = Split-Path -Path $TargetPath -Parent - Ensure-DownloadDirectory -Path $parent - $stagePath = Join-Path $parent ('.reverse-bootstrap-{0}' -f [Guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $stagePath | Out-Null - try { - & $git init --quiet $stagePath - if ($LASTEXITCODE -ne 0) { throw 'git init failed' } - & $git -C $stagePath remote add origin $Definition.repo - if ($LASTEXITCODE -ne 0) { throw 'git remote add failed' } - & $git -C $stagePath fetch --depth 1 origin $pinnedCommit - if ($LASTEXITCODE -ne 0) { throw 'git fetch failed' } - & $git -C $stagePath checkout --quiet --detach FETCH_HEAD - if ($LASTEXITCODE -ne 0) { throw 'git checkout failed' } - Assert-GitCheckoutState -GitPath $git -CheckoutPath $stagePath -PinnedCommit $pinnedCommit - Move-Item -LiteralPath $stagePath -Destination $TargetPath - if ((Test-Path -LiteralPath $stagePath) -or -not (Test-Path -LiteralPath (Join-Path $TargetPath '.git') -PathType Container)) { - throw "Failed to promote staged checkout to $TargetPath" - } - } - finally { - if (Test-Path -LiteralPath $stagePath) { - Remove-Item -LiteralPath $stagePath -Recurse -Force - } - } - - return $true -} - function Ensure-Capability { param([Parameter(Mandatory = $true)][string]$Name) diff --git a/skills/scripts/bootstrap-reverse.sh b/skills/scripts/bootstrap-reverse.sh index a8016fa..c7f63ca 100644 --- a/skills/scripts/bootstrap-reverse.sh +++ b/skills/scripts/bootstrap-reverse.sh @@ -224,13 +224,7 @@ install_brew_cask() { } ensure_python_runtime() { - if ! has_cmd python3; then - case "$PLATFORM" in - macos) install_brew python ;; - linux) install_apt python3 ;; - *) log_err "Install Python 3 manually. See $(platform_doc)"; return 1 ;; - esac - fi + ensure_python_interpreter || return 1 local pipx_package pipx_version current_version pipx_package=$(manifest_dependency pipx package) || return 1 pipx_version=$(manifest_dependency pipx version) || return 1 @@ -245,6 +239,17 @@ ensure_python_runtime() { export PATH="$HOME/.local/bin:$PATH" } +ensure_python_interpreter() { + if ! has_cmd python3; then + case "$PLATFORM" in + macos) install_brew python ;; + linux) install_apt python3 ;; + *) log_err "Install Python 3 manually. See $(platform_doc)"; return 1 ;; + esac + fi + has_cmd python3 || { log_err "Python 3 installation completed without a usable python3 command."; return 1; } +} + ensure_node_runtime() { if has_cmd node && has_cmd npm && has_cmd npx; then return 0; fi case "$PLATFORM" in @@ -567,10 +572,10 @@ ensure_jadx() { if has_cmd jadx; then log_ok "jadx ready: $(cmd_path jadx)"; return 0; fi ensure_java_runtime local repo re tag sha - repo=$(manifest_field jadx repo) - re=$(manifest_field jadx assetRegex) - tag=$(manifest_field jadx releaseTag) - sha=$(manifest_field jadx assetSha256) + repo=$(manifest_field jadx repo) || return 1 + re=$(manifest_field jadx assetRegex) || return 1 + tag=$(manifest_field jadx releaseTag) || return 1 + sha=$(manifest_field jadx assetSha256) || return 1 case "$PLATFORM" in macos) install_brew jadx || install_github_release "$repo" "$re" "$TOOLS_ROOT/jadx" "$tag" "$sha" ;; linux) install_github_release "$repo" "$re" "$TOOLS_ROOT/jadx" "$tag" "$sha" ;; @@ -587,10 +592,10 @@ ensure_apktool() { ensure_dir "$TOOLS_ROOT/apktool" local meta url digest jar wrapper local repo tag sha re - repo=$(manifest_field apktool repo) - tag=$(manifest_field apktool releaseTag) - sha=$(manifest_field apktool assetSha256) - re=$(manifest_field apktool assetRegex) + repo=$(manifest_field apktool repo) || return 1 + tag=$(manifest_field apktool releaseTag) || return 1 + sha=$(manifest_field apktool assetSha256) || return 1 + re=$(manifest_field apktool assetRegex) || return 1 meta=$(latest_github_asset_meta "$repo" "$re" "$tag") url=$(printf '%s' "$meta" | cut -f1) digest=$(printf '%s' "$meta" | cut -f2) @@ -609,7 +614,7 @@ ensure_frida_tools() { ensure_python_runtime || return 1 if has_cmd frida && has_cmd frida-ps; then log_ok "frida-tools ready"; return 0; fi local package - package=$(manifest_field frida pipPackage) + package=$(manifest_field frida pipPackage) || return 1 pipx install --force "$package" || return 1 export PATH="$HOME/.local/bin:$PATH" } @@ -618,7 +623,7 @@ ensure_idalib_mcp() { ensure_python_runtime || return 1 if has_cmd ida-pro-mcp; then log_ok "ida-pro-mcp ready: $(cmd_path ida-pro-mcp)"; return 0; fi local source - source=$(manifest_field idalib-mcp pipSource) + source=$(manifest_field idalib-mcp pipSource) || return 1 pipx install --force "$source" || return 1 export PATH="$HOME/.local/bin:$PATH" log_warn "Post-install: run 'ida-pro-mcp --install', choose Streamable HTTP + Global, then restart IDA Pro." @@ -627,7 +632,7 @@ ensure_idalib_mcp() { ensure_jshookmcp() { ensure_node_runtime || return 1 local package - package=$(manifest_field jshookmcp npmPackage) + package=$(manifest_field jshookmcp npmPackage) || return 1 write_mcp_server "jshook" "$(python3 - "$package" <<'PY' import json, sys print(json.dumps({'command':'npx','args':['-y',sys.argv[1]],'env':{'JSHOOK_BASE_PROFILE':'search'}})) @@ -638,7 +643,7 @@ PY ensure_reqable_mcp() { ensure_node_runtime || return 1 local package - package=$(manifest_field reqable-mcp npmPackage) + package=$(manifest_field reqable-mcp npmPackage) || return 1 write_mcp_server "reqable-mcp" "$(python3 - "$package" <<'PY' import json, sys print(json.dumps({'command':'npx','args':['-y',sys.argv[1]]})) @@ -650,8 +655,8 @@ PY ensure_anything_analyzer() { local dir="$TOOLS_ROOT/anything-analyzer" local repo commit - repo=$(manifest_field anything-analyzer repoUrl) - commit=$(manifest_field anything-analyzer pinnedCommit) + repo=$(manifest_field anything-analyzer repoUrl) || return 1 + commit=$(manifest_field anything-analyzer pinnedCommit) || return 1 if ! has_cmd git; then case "$PLATFORM" in macos) install_brew git ;; linux) install_apt git ;; esac fi @@ -719,7 +724,7 @@ ensure_agent_browser() { ensure_node_runtime || return 1 if has_cmd agent-browser; then log_ok "agent-browser ready"; return 0; fi local package - package=$(manifest_field agent-browser npmPackage) + package=$(manifest_field agent-browser npmPackage) || return 1 npm install -g "$package" || return 1 if has_cmd npx; then npx playwright install chromium || true; fi local setup="$SKILL_ROOT/browser-automation/scripts/setup.sh" @@ -729,8 +734,8 @@ ensure_agent_browser() { ensure_ghidra_mcp() { ensure_java_runtime || return 1 local repo regex - repo=$(manifest_field ghidra-mcp repo) - regex=$(manifest_field ghidra-mcp assetRegex) + repo=$(manifest_field ghidra-mcp repo) || return 1 + regex=$(manifest_field ghidra-mcp assetRegex) || return 1 case "$PLATFORM" in macos) if ! has_cmd ghidraRun && [[ ! -d /Applications/Ghidra.app ]]; then @@ -752,8 +757,8 @@ ensure_seclists() { if [[ -d /usr/share/seclists ]]; then log_ok "SecLists ready"; return 0; fi if ! has_cmd git; then case "$PLATFORM" in macos) install_brew git ;; linux) install_apt git ;; esac; fi local repo commit - repo=$(manifest_field seclists repo) - commit=$(manifest_field seclists pinnedCommit) + repo=$(manifest_field seclists repo) || return 1 + commit=$(manifest_field seclists pinnedCommit) || return 1 install_git_commit "$repo" "$commit" "$dir" || return 1 } @@ -761,8 +766,8 @@ ensure_proxycat() { ensure_python_runtime || return 1 if has_cmd proxycat; then log_ok "proxycat ready"; return 0; fi local repo commit - repo=$(manifest_field proxycat repo) - commit=$(manifest_field proxycat pinnedCommit) + repo=$(manifest_field proxycat repo) || return 1 + commit=$(manifest_field proxycat pinnedCommit) || return 1 pipx install "git+${repo}@${commit}" || { manual_required proxycat "Clone/install ProxyCat manually; verify command 'proxycat'." LAST_CAPABILITY_MANUAL=true @@ -810,8 +815,8 @@ ensure_pentestswarm() { case "$PLATFORM" in macos) install_brew go ;; linux) install_apt golang-go ;; esac fi local go_package docker_image - go_package=$(manifest_field pentestswarm goPackage) - docker_image=$(manifest_field pentestswarm dockerImage) + go_package=$(manifest_field pentestswarm goPackage) || return 1 + docker_image=$(manifest_field pentestswarm dockerImage) || return 1 if go install "$go_package"; then local go_bin go_bin="$(go env GOBIN 2>/dev/null || true)" @@ -857,7 +862,7 @@ ensure_pwntools() { ensure_python_runtime || return 1 if python3 -c "import pwn" 2>/dev/null; then log_ok "pwntools ready"; return 0; fi local package - package=$(manifest_field pwntools pipPackage) + package=$(manifest_field pwntools pipPackage) || return 1 pipx install "$package" || python3 -m pip install --user "$package" || return 1 } @@ -937,6 +942,11 @@ done < <(expand_capabilities "${CAPABILITIES[@]}") log_info "platform=$PLATFORM doc=$(platform_doc) tools_root=$TOOLS_ROOT" +if ! ensure_python_interpreter; then + log_err "Python 3 is required to read bootstrap-manifest.json; no capability was executed." + exit 1 +fi + for cap in "${EXPANDED[@]}"; do log_info "ensure $cap" LAST_CAPABILITY_MANUAL=false diff --git a/skills/scripts/lib/BootstrapSupplyChain.ps1 b/skills/scripts/lib/BootstrapSupplyChain.ps1 new file mode 100644 index 0000000..6e6c4c8 --- /dev/null +++ b/skills/scripts/lib/BootstrapSupplyChain.ps1 @@ -0,0 +1,151 @@ +function Ensure-Pnpm { + Ensure-NodeRuntime + $dependency = Get-BootstrapDependency -Name 'pnpm' + $pnpm = Get-NodeCommandPath -Name 'pnpm' + $currentVersion = '' + if ($pnpm) { + $versionLine = & $pnpm --version 2>$null | Select-Object -First 1 + if ($LASTEXITCODE -eq 0 -and $null -ne $versionLine) { + $currentVersion = ([string]$versionLine).Trim() + } + } + if ($currentVersion -ne [string]$dependency.version) { + $npm = Get-NodeCommandPath -Name 'npm' + if ([string]::IsNullOrWhiteSpace($npm)) { + throw 'npm is not available after Node.js installation.' + } + & $npm install -g ([string]$dependency.package) + if ($LASTEXITCODE -ne 0) { + throw "Failed to install pinned pnpm dependency $($dependency.package)." + } + } +} + +function Assert-GitCheckoutState { + param( + [Parameter(Mandatory = $true)][string]$GitPath, + [Parameter(Mandatory = $true)][string]$CheckoutPath, + [Parameter(Mandatory = $true)][string]$PinnedCommit + ) + + $resolvedLine = & $GitPath -C $CheckoutPath rev-parse HEAD 2>$null | Select-Object -First 1 + $resolvedCommit = if ($null -eq $resolvedLine) { '' } else { ([string]$resolvedLine).Trim() } + if ($LASTEXITCODE -ne 0 -or $resolvedCommit -ne $PinnedCommit) { + throw "Checkout verification failed: expected $PinnedCommit, got $resolvedCommit ($CheckoutPath)" + } + $status = @(& $GitPath -C $CheckoutPath status --porcelain --untracked-files=all 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Cannot inspect checkout state: $CheckoutPath" + } + if ($status.Count -gt 0) { + throw "Checkout has local changes; refusing to execute it: $CheckoutPath" + } +} + +function Move-BootstrapDirectory { + param( + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$Destination + ) + [IO.Directory]::Move($Source, $Destination) +} + +function Ensure-GitCloneInstall { + param( + [Parameter(Mandatory = $true)]$Definition, + [Parameter(Mandatory = $true)][string]$TargetPath + ) + + $git = Get-FirstCommandPath -Names @('git') + if ([string]::IsNullOrWhiteSpace($git)) { + throw 'git is required for git-clone bootstrap definitions.' + } + + $pinnedCommit = if ($Definition.PSObject.Properties['pinnedCommit']) { [string]$Definition.pinnedCommit } else { '' } + if ([string]::IsNullOrWhiteSpace($pinnedCommit)) { + throw "Git capability $($Definition.repo) must define pinnedCommit." + } + + if ((Test-Path -LiteralPath $TargetPath -PathType Container) -and (Test-Path -LiteralPath (Join-Path $TargetPath '.git'))) { + Assert-GitCheckoutState -GitPath $git -CheckoutPath $TargetPath -PinnedCommit $pinnedCommit + return $true + } + if (Test-Path -LiteralPath $TargetPath) { + throw "Install path exists but is not a git checkout: $TargetPath" + } + + $parent = Split-Path -Path $TargetPath -Parent + Ensure-DownloadDirectory -Path $parent + $stagePath = Join-Path $parent ('.reverse-bootstrap-{0}' -f [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $stagePath | Out-Null + try { + & $git init --quiet $stagePath + if ($LASTEXITCODE -ne 0) { throw 'git init failed' } + & $git -C $stagePath remote add origin $Definition.repo + if ($LASTEXITCODE -ne 0) { throw 'git remote add failed' } + & $git -C $stagePath fetch --depth 1 origin $pinnedCommit + if ($LASTEXITCODE -ne 0) { throw 'git fetch failed' } + & $git -C $stagePath checkout --quiet --detach FETCH_HEAD + if ($LASTEXITCODE -ne 0) { throw 'git checkout failed' } + Assert-GitCheckoutState -GitPath $git -CheckoutPath $stagePath -PinnedCommit $pinnedCommit + Move-BootstrapDirectory -Source $stagePath -Destination $TargetPath + if (-not (Test-Path -LiteralPath (Join-Path $TargetPath '.git') -PathType Container)) { + throw "Failed to promote staged checkout to $TargetPath" + } + } + finally { + if (Test-Path -LiteralPath $stagePath) { + Remove-Item -LiteralPath $stagePath -Recurse -Force + } + } + + return $true +} + +function Invoke-AnythingAnalyzerPinnedInstall { + param( + [Parameter(Mandatory = $true)][string]$RepoDir, + [Parameter(Mandatory = $true)][string]$PnpmPath, + [Parameter(Mandatory = $true)][string]$GitPath, + [Parameter(Mandatory = $true)][string]$PinnedCommit, + [string]$VsBuildToolsError = '' + ) + + $workspacePath = Join-Path $RepoDir 'pnpm-workspace.yaml' + $workspaceExisted = Test-Path -LiteralPath $workspacePath -PathType Leaf + $workspaceBytes = if ($workspaceExisted) { [IO.File]::ReadAllBytes($workspacePath) } else { $null } + + Push-Location $RepoDir + try { + Approve-AnythingAnalyzerBuildScripts -RepoDir $RepoDir + if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $RepoDir -PnpmPath $PnpmPath)) { + $nodeModules = Join-Path $RepoDir 'node_modules' + Remove-Item -LiteralPath $nodeModules -Recurse -Force -ErrorAction SilentlyContinue + } + + & $PnpmPath install --frozen-lockfile + if ($LASTEXITCODE -ne 0) { + if ($VsBuildToolsError) { throw "pnpm install failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $VsBuildToolsError" } + throw 'pnpm install failed for anything-analyzer.' + } + & $PnpmPath rebuild electron esbuild better-sqlite3 + if ($LASTEXITCODE -ne 0) { + if ($VsBuildToolsError) { throw "pnpm rebuild failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $VsBuildToolsError" } + throw 'pnpm rebuild failed for anything-analyzer.' + } + if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $RepoDir -PnpmPath $PnpmPath)) { + throw 'anything-analyzer Electron dependency is still unhealthy after pnpm rebuild.' + } + } + finally { + Pop-Location + if ($workspaceExisted) { + [IO.File]::WriteAllBytes($workspacePath, $workspaceBytes) + } + elseif (Test-Path -LiteralPath $workspacePath) { + Remove-Item -LiteralPath $workspacePath -Force + } + } + + Assert-GitCheckoutState -GitPath $GitPath -CheckoutPath $RepoDir -PinnedCommit $PinnedCommit +} diff --git a/skills/scripts/test-bootstrap-manifest.sh b/skills/scripts/test-bootstrap-manifest.sh index e5a0bed..7909484 100644 --- a/skills/scripts/test-bootstrap-manifest.sh +++ b/skills/scripts/test-bootstrap-manifest.sh @@ -19,6 +19,11 @@ name="$(basename "$0")" case "$name:${1:-}" in pipx:--version) printf '%s\n' "${STUB_PIPX_VERSION:-0}" ;; pnpm:--version) printf '%s\n' "${STUB_PNPM_VERSION:-0}" ;; + brew:install) + if [[ "${2:-}" == python ]]; then + ln -sf "$STUB_PYTHON_SOURCE" "$STUB_ACTIVE_BIN/python3" + fi + ;; git:init) target="${!#}"; mkdir -p "$target/.git"; printf '%s\n' unpinned-head > "$target/.stub-head" ;; @@ -43,6 +48,7 @@ esac STUB chmod +x "$STUB_BIN/command-stub" for name in git node npm npx pipx pnpm sleep nc; do ln -s command-stub "$STUB_BIN/$name"; done +ln -s command-stub "$STUB_BIN/brew" cat > "$STUB_BIN/python3" < "$CALL_LOG" +env PATH="$NO_PYTHON_BIN" HOME="$SCRATCH/home" CALL_LOG="$CALL_LOG" \ + STUB_ACTIVE_BIN="$NO_PYTHON_BIN" STUB_PYTHON_SOURCE="$STUB_BIN/python3" \ + REVERSE_SKILL_TOOLS_DIR="$SCRATCH/tools" CLAUDE_MCP_CONFIG="$SCRATCH/home/mcp.json" \ + bash "$PARSER_FIXTURE/bootstrap-reverse.sh" agent-browser --skip-refresh >/dev/null +expect_line 'brew|install|python' +expect_line "npm|install|-g|$(json_value agent-browser npmPackage)" +! grep -Fq '|pip|install|' "$CALL_LOG" + +# A required empty manifest field fails before any package-manager sink. +BROKEN_DIR="$SCRATCH/broken-bootstrap" +mkdir -p "$BROKEN_DIR" +cp "$BOOTSTRAP" "$BROKEN_DIR/bootstrap-reverse.sh" +"$REAL_PYTHON" - "$MANIFEST" "$BROKEN_DIR/bootstrap-manifest.json" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +next(x for x in data['capabilities'] if x['name'] == 'agent-browser')['npmPackage'] = '' +pathlib.Path(sys.argv[2]).write_text(json.dumps(data)) +PY +: > "$CALL_LOG" +set +e +env PATH="$STUB_BIN:/usr/bin:/bin" HOME="$SCRATCH/home" CALL_LOG="$CALL_LOG" \ + STUB_ACTIVE_BIN="$STUB_BIN" STUB_PYTHON_SOURCE="$STUB_BIN/python3" \ + REVERSE_SKILL_TOOLS_DIR="$SCRATCH/tools" CLAUDE_MCP_CONFIG="$SCRATCH/home/mcp.json" \ + bash "$BROKEN_DIR/bootstrap-reverse.sh" agent-browser --skip-refresh >/dev/null 2>&1 +broken_rc=$? +set -e +[[ $broken_rc -ne 0 ]] +! grep -Eq '^npm\|install\|-g(\||$)' "$CALL_LOG" + # Table: each generic package-manager sink receives its canonical manifest value. while IFS='|' read -r capability field expected; do : > "$CALL_LOG" diff --git a/skills/scripts/test-bootstrap-supply-chain.ps1 b/skills/scripts/test-bootstrap-supply-chain.ps1 new file mode 100644 index 0000000..315e0df --- /dev/null +++ b/skills/scripts/test-bootstrap-supply-chain.ps1 @@ -0,0 +1,103 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$scratch = Join-Path ([IO.Path]::GetTempPath()) ('reverse-bootstrap-ps-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $scratch | Out-Null + +function Ensure-DownloadDirectory { param([string]$Path) New-Item -ItemType Directory -Path $Path -Force | Out-Null } +function Get-FirstCommandPath { param([string[]]$Names) return (Get-Command $Names[0]).Source } +function Ensure-NodeRuntime {} +function Get-NodeCommandPath { param([string]$Name) $command = Get-Command $Name -ErrorAction SilentlyContinue; if ($command) { return $command.Source } } +function Get-BootstrapDependency { return [pscustomobject]@{ package = 'pnpm@10.24.0'; version = '10.24.0' } } +function Approve-AnythingAnalyzerBuildScripts { param([string]$RepoDir) Set-Content (Join-Path $RepoDir 'pnpm-workspace.yaml') 'generated' } +function Test-AnythingAnalyzerElectronHealthy { return $true } + +. (Join-Path $PSScriptRoot 'lib/BootstrapSupplyChain.ps1') + +function Assert-True { param([bool]$Condition, [string]$Message) if (-not $Condition) { throw $Message } } +function Invoke-Git { param([string[]]$Arguments) & git @Arguments; if ($LASTEXITCODE -ne 0) { throw "git failed: $Arguments" } } + +try { + $source = Join-Path $scratch 'source' + New-Item -ItemType Directory -Path $source | Out-Null + Invoke-Git -Arguments @('-C', $source, 'init', '--quiet') + Invoke-Git -Arguments @('-C', $source, 'config', 'user.email', 'test@example.invalid') + Invoke-Git -Arguments @('-C', $source, 'config', 'user.name', 'test') + Set-Content (Join-Path $source 'package.json') '{}' + Invoke-Git -Arguments @('-C', $source, 'add', 'package.json') + Invoke-Git -Arguments @('-C', $source, 'commit', '--quiet', '-m', 'fixture') + $pin = (& git -C $source rev-parse HEAD).Trim() + $definition = [pscustomobject]@{ repo = $source; pinnedCommit = $pin } + + $target = Join-Path $scratch 'installed' + Ensure-GitCloneInstall -Definition $definition -TargetPath $target | Out-Null + Assert-True ((& git -C $target rev-parse HEAD).Trim() -eq $pin) 'pinned checkout was not promoted' + Set-Content (Join-Path $target 'package.json') '{"dirty":true}' + try { Ensure-GitCloneInstall -Definition $definition -TargetPath $target | Out-Null; throw 'dirty checkout accepted' } catch { Assert-True ($_.Exception.Message -match 'local changes') 'dirty rejection reason changed' } + + $failedTarget = Join-Path $scratch 'failed' + $badDefinition = [pscustomobject]@{ repo = (Join-Path $scratch 'missing'); pinnedCommit = $pin } + try { Ensure-GitCloneInstall -Definition $badDefinition -TargetPath $failedTarget | Out-Null; throw 'failed fetch accepted' } catch {} + Assert-True (-not (Test-Path $failedTarget)) 'failed fetch poisoned final path' + Assert-True (@(Get-ChildItem $scratch -Filter '.reverse-bootstrap-*').Count -eq 0) 'failed fetch left staging path' + + $raceTarget = Join-Path $scratch 'race' + $raceStage = Join-Path $scratch '.reverse-bootstrap-race' + New-Item -ItemType Directory -Path $raceTarget, $raceStage | Out-Null + Set-Content (Join-Path $raceTarget 'owner.txt') owner + $raceRejected = $false + try { Move-BootstrapDirectory -Source $raceStage -Destination $raceTarget } catch { $raceRejected = $true } + Assert-True $raceRejected 'promotion race accepted' + Assert-True ((Get-Content (Join-Path $raceTarget 'owner.txt')) -eq 'owner') 'promotion race modified concurrent target' + Remove-Item -LiteralPath $raceStage -Recurse -Force + + $bin = Join-Path $scratch 'bin' + New-Item -ItemType Directory -Path $bin | Out-Null + $env:PATH = "$bin$([IO.Path]::PathSeparator)$env:PATH" + $env:BOOTSTRAP_PS_LOG = Join-Path $scratch 'commands.log' + $isWindowsHost = $env:OS -eq 'Windows_NT' + $stub = Join-Path $bin ($(if ($isWindowsHost) { 'npm.cmd' } else { 'npm' })) + if ($isWindowsHost) { + Set-Content $stub @' +@echo off +echo npm^|%*>>"%BOOTSTRAP_PS_LOG%" +'@ + } + else { + Set-Content $stub @' +#!/bin/sh +printf "npm|%s\n" "$*" >> "$BOOTSTRAP_PS_LOG" +'@ + & chmod +x $stub + } + $pnpm = Join-Path $bin ($(if ($isWindowsHost) { 'pnpm.cmd' } else { 'pnpm' })) + if ($isWindowsHost) { Set-Content $pnpm "@echo off`r`necho 0" } + else { Set-Content $pnpm "#!/bin/sh`necho 0"; & chmod +x $pnpm } + Ensure-Pnpm + Assert-True ((Get-Content $env:BOOTSTRAP_PS_LOG) -match 'npm\|install -g pnpm@10.24.0') 'pnpm install was not pinned' + + Invoke-Git -Arguments @('-C', $target, 'checkout', '--quiet', '--', 'package.json') + if ($isWindowsHost) { + Set-Content $pnpm @' +@echo off +if "%1"=="--version" (echo 10.24.0) else (echo pnpm^|%*>>"%BOOTSTRAP_PS_LOG%") +'@ + } + else { + Set-Content $pnpm @' +#!/bin/sh +[ "$1" = --version ] && { echo 10.24.0; exit; } +printf "pnpm|%s\n" "$*" >> "$BOOTSTRAP_PS_LOG" +'@ + & chmod +x $pnpm + } + function Approve-AnythingAnalyzerBuildScripts { param([string]$RepoDir) Set-Content (Join-Path $RepoDir 'pnpm-workspace.yaml') 'generated'; Set-Content (Join-Path $RepoDir 'package.json') '{"mutated":true}' } + $dirtyRejected = $false + try { Invoke-AnythingAnalyzerPinnedInstall -RepoDir $target -PnpmPath $pnpm -GitPath (Get-Command git).Source -PinnedCommit $pin } catch { $dirtyRejected = $_.Exception.Message -match 'local changes' } + Assert-True $dirtyRejected 'post-install dirty checkout accepted or rejection reason changed' + Assert-True (-not (Test-Path (Join-Path $target 'pnpm-workspace.yaml'))) 'generated workspace file was not removed' + + Write-Host 'PowerShell bootstrap supply-chain regression passed' +} +finally { + Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue +}