@herbertgao/pi-extensions 2026.8.4 → 2026.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +11 -3
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +11 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +53 -14
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +18 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
- package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +20 -4
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
- package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
- package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +15 -6
- package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
- package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
- package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +9 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
- package/node_modules/@narumitw/pi-btw/README.md +32 -24
- package/node_modules/@narumitw/pi-btw/package.json +6 -6
- package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
- package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -13
- package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
- package/node_modules/pi-lens/CHANGELOG.md +2535 -954
- package/node_modules/pi-lens/README.md +13 -0
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +314 -0
- package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
- package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
- package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
- package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
- package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
- package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
- package/node_modules/pi-lens/dist/clients/biome-client.js +136 -24
- package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
- package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
- package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
- package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
- package/node_modules/pi-lens/dist/clients/bus-publish.js +19 -4
- package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
- package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
- package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
- package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
- package/node_modules/pi-lens/dist/clients/cascade-format.js +57 -2
- package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
- package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
- package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
- package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/console-guard-install.js +26 -0
- package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +137 -31
- package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
- package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +361 -77
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +65 -13
- package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +18 -4
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +176 -29
- package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
- package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
- package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
- package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
- package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1172 -683
- package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
- package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
- package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +47 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +473 -42
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
- package/node_modules/pi-lens/dist/clients/disposition-publish.js +18 -4
- package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
- package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
- package/node_modules/pi-lens/dist/clients/extension-log.js +432 -0
- package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
- package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
- package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
- package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
- package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
- package/node_modules/pi-lens/dist/clients/format-events-publish.js +51 -7
- package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
- package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
- package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
- package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
- package/node_modules/pi-lens/dist/clients/git-guard.js +678 -11
- package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
- package/node_modules/pi-lens/dist/clients/go-client.js +23 -40
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +124 -7
- package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
- package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
- package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
- package/node_modules/pi-lens/dist/clients/installer/index.js +395 -100
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +27 -55
- package/node_modules/pi-lens/dist/clients/knip-client.js +58 -42
- package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
- package/node_modules/pi-lens/dist/clients/latency-logger.js +72 -1
- package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
- package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
- package/node_modules/pi-lens/dist/clients/lens-events.js +63 -9
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
- package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
- package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +73 -0
- package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
- package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +59 -14
- package/node_modules/pi-lens/dist/clients/lsp/client.js +662 -56
- package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +115 -0
- package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
- package/node_modules/pi-lens/dist/clients/lsp/index.js +2025 -800
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
- package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
- package/node_modules/pi-lens/dist/clients/lsp/server.js +437 -86
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
- package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
- package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +127 -32
- package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
- package/node_modules/pi-lens/dist/clients/mcp/session.js +235 -14
- package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
- package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
- package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
- package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
- package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
- package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
- package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
- package/node_modules/pi-lens/dist/clients/pipeline.js +76 -15
- package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
- package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
- package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
- package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
- package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
- package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
- package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
- package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
- package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
- package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
- package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
- package/node_modules/pi-lens/dist/clients/review-graph/query.js +24 -0
- package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
- package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
- package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
- package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
- package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +257 -77
- package/node_modules/pi-lens/dist/clients/runtime-context.js +129 -12
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +242 -26
- package/node_modules/pi-lens/dist/clients/runtime-session.js +428 -117
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +342 -33
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +619 -93
- package/node_modules/pi-lens/dist/clients/rust-client.js +23 -38
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +90 -6
- package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
- package/node_modules/pi-lens/dist/clients/sg-runner.js +424 -196
- package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +231 -0
- package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
- package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
- package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
- package/node_modules/pi-lens/dist/clients/startup-timing.js +7 -1
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +469 -32
- package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
- package/node_modules/pi-lens/dist/clients/tool-policy.js +390 -39
- package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
- package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
- package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
- package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
- package/node_modules/pi-lens/dist/clients/warm-attach.js +50 -13
- package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
- package/node_modules/pi-lens/dist/clients/word-index.js +680 -81
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
- package/node_modules/pi-lens/dist/index.js +51785 -36118
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
- package/node_modules/pi-lens/dist/mcp/server.js +219 -46
- package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
- package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
- package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
- package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +203 -38
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +210 -42
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
- package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
- package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
- package/node_modules/pi-lens/docs/agent-guide.md +40 -15
- package/node_modules/pi-lens/docs/agent-tools.md +21 -10
- package/node_modules/pi-lens/docs/analysisall.md +192 -0
- package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +14 -3
- package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
- package/node_modules/pi-lens/docs/environment-variables.md +44 -0
- package/node_modules/pi-lens/docs/fable.md +27 -0
- package/node_modules/pi-lens/docs/features.md +45 -3
- package/node_modules/pi-lens/docs/globalconfig.md +56 -1
- package/node_modules/pi-lens/docs/language-coverage.md +4 -3
- package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
- package/node_modules/pi-lens/docs/mcp.md +37 -4
- package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
- package/node_modules/pi-lens/docs/settings.md +18 -1
- package/node_modules/pi-lens/docs/tools.md +4 -1
- package/node_modules/pi-lens/docs/usage.md +41 -5
- package/node_modules/pi-lens/docs/word-index.md +35 -0
- package/node_modules/pi-lens/package.json +10 -10
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
- package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
- package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +134 -2
- package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
- package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
- package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +38 -0
- package/node_modules/pi-mcp-adapter/README.md +38 -4
- package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
- package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
- package/node_modules/pi-mcp-adapter/cli.js +25 -4
- package/node_modules/pi-mcp-adapter/commands.ts +15 -7
- package/node_modules/pi-mcp-adapter/config.ts +4 -4
- package/node_modules/pi-mcp-adapter/direct-tools.ts +58 -8
- package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
- package/node_modules/pi-mcp-adapter/index.ts +8 -3
- package/node_modules/pi-mcp-adapter/init.ts +29 -5
- package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
- package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
- package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
- package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
- package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +37 -4
- package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
- package/node_modules/pi-mcp-adapter/package.json +4 -3
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
- package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
- package/node_modules/pi-mcp-adapter/server-manager.ts +41 -7
- package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
- package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
- package/node_modules/pi-mcp-adapter/tool-metadata.ts +62 -4
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
- package/node_modules/pi-mcp-adapter/types.ts +176 -27
- package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
- package/node_modules/pi-web-access/CHANGELOG.md +14 -0
- package/node_modules/pi-web-access/README.md +18 -12
- package/node_modules/pi-web-access/auth-fetch.ts +148 -0
- package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
- package/node_modules/pi-web-access/curator-page.ts +5 -3
- package/node_modules/pi-web-access/curator-server.ts +2 -1
- package/node_modules/pi-web-access/extract.ts +106 -34
- package/node_modules/pi-web-access/fetch-params.ts +17 -3
- package/node_modules/pi-web-access/firecrawl.ts +172 -12
- package/node_modules/pi-web-access/gemini-search.ts +18 -4
- package/node_modules/pi-web-access/index.ts +120 -48
- package/node_modules/pi-web-access/package.json +2 -2
- package/node_modules/pi-web-access/summary-review.ts +11 -5
- package/node_modules/pi-web-access/youtube-extract.ts +2 -2
- package/package.json +12 -13
- package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
- package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
- package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
- package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
- package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
- package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
- package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
- package/node_modules/pi-lens/dist/clients/types.js +0 -11
|
@@ -17,7 +17,153 @@ import { spawn, spawnSync, } from "node:child_process";
|
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
18
|
import * as path from "node:path";
|
|
19
19
|
import { logLatency } from "./latency-logger.js";
|
|
20
|
+
import { recordDegradation } from "./degradation-ledger.js";
|
|
21
|
+
import { logExtension } from "./extension-log.js";
|
|
22
|
+
import { isFullyQualifiedWin32 } from "./path-utils.js";
|
|
20
23
|
import { startSpawnUsageSampler } from "./resource-sampler.js";
|
|
24
|
+
/** Intent-level spawn failure. `cause` retains the original OS Error/errno. */
|
|
25
|
+
export class SpawnFailureError extends Error {
|
|
26
|
+
kind;
|
|
27
|
+
cause;
|
|
28
|
+
name = "SpawnFailureError";
|
|
29
|
+
constructor(kind, message, cause) {
|
|
30
|
+
super(message, { cause });
|
|
31
|
+
this.kind = kind;
|
|
32
|
+
this.cause = cause;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function hasSpawnFailureKind(error, kind) {
|
|
36
|
+
return (error instanceof Error &&
|
|
37
|
+
"kind" in error &&
|
|
38
|
+
error.kind === kind);
|
|
39
|
+
}
|
|
40
|
+
function toError(error) {
|
|
41
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
42
|
+
}
|
|
43
|
+
function errorCode(error) {
|
|
44
|
+
return error.code;
|
|
45
|
+
}
|
|
46
|
+
async function cwdIsUnresolvable(cwd) {
|
|
47
|
+
if (cwd === undefined)
|
|
48
|
+
return false;
|
|
49
|
+
try {
|
|
50
|
+
return !(await fs.promises.stat(cwd)).isDirectory();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function cwdIsUnresolvableSync(cwd) {
|
|
57
|
+
if (cwd === undefined)
|
|
58
|
+
return false;
|
|
59
|
+
try {
|
|
60
|
+
return !fs.statSync(cwd).isDirectory();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Best-effort presence probe used ONLY to disambiguate ENOENT when the cwd is
|
|
68
|
+
* ALSO unresolvable (#1340 review): a genuinely missing tool must classify as
|
|
69
|
+
* tool-not-found even under a broken cwd, or auto-install can never repair it.
|
|
70
|
+
* Absolute commands are probed directly (with PATHEXT variants on Windows);
|
|
71
|
+
* bare names scan PATH. A relative-with-separator command under a broken cwd
|
|
72
|
+
* is genuinely ambiguous -- we err toward cwd-unresolvable there, because
|
|
73
|
+
* repairing the cwd is actionable while a reinstall loop (#1199) is not.
|
|
74
|
+
*/
|
|
75
|
+
function commandProbablyPresent(command) {
|
|
76
|
+
const exts = process.platform === "win32"
|
|
77
|
+
? ["", ...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)]
|
|
78
|
+
: [""];
|
|
79
|
+
const existsWithExt = (base) => {
|
|
80
|
+
for (const ext of exts) {
|
|
81
|
+
try {
|
|
82
|
+
if (fs.existsSync(base + ext))
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// unreadable candidate -- keep probing
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
};
|
|
91
|
+
if (path.isAbsolute(command))
|
|
92
|
+
return existsWithExt(command);
|
|
93
|
+
if (command.includes("/") || command.includes("\\"))
|
|
94
|
+
return "ambiguous";
|
|
95
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
96
|
+
if (dir && existsWithExt(path.join(dir, command)))
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Shared errno->bucket mapping. `cwdUnresolvable` is the only async-vs-sync
|
|
103
|
+
* difference between the two public classifiers, so it arrives as a resolved
|
|
104
|
+
* flag and everything else lives once (Sonar duplication finding, #1340).
|
|
105
|
+
*/
|
|
106
|
+
function classifyWithCwdFlag(cause, options, cwdUnresolvable) {
|
|
107
|
+
const code = errorCode(cause);
|
|
108
|
+
let failure;
|
|
109
|
+
if (code === "ENOENT" &&
|
|
110
|
+
cwdUnresolvable &&
|
|
111
|
+
commandProbablyPresent(options.command) !== false) {
|
|
112
|
+
failure = new SpawnFailureError("cwd-unresolvable", `Cannot spawn ${options.command}: working directory is unresolvable (${options.cwd})`, cause);
|
|
113
|
+
}
|
|
114
|
+
else if (code === "ENOENT") {
|
|
115
|
+
failure = new SpawnFailureError("tool-not-found", `Cannot spawn ${options.command}: tool not found (${cause.message})`, cause);
|
|
116
|
+
}
|
|
117
|
+
else if (code === "EACCES" || code === "EPERM") {
|
|
118
|
+
failure = new SpawnFailureError("permission-denied", `Cannot spawn ${options.command}: permission denied`, cause);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
failure = new SpawnFailureError("spawn-failed", `Cannot spawn ${options.command}: ${cause.message}`, cause);
|
|
122
|
+
}
|
|
123
|
+
recordSpawnClassification(failure, options);
|
|
124
|
+
return failure;
|
|
125
|
+
}
|
|
126
|
+
const loggedSpawnClassifications = new Set();
|
|
127
|
+
const SPAWN_CLASSIFICATION_LOG_CAP = 200;
|
|
128
|
+
function recordSpawnClassification(failure, options) {
|
|
129
|
+
const pair = `${failure.kind}\0${options.command}`;
|
|
130
|
+
if (!loggedSpawnClassifications.has(pair)) {
|
|
131
|
+
if (loggedSpawnClassifications.size >= SPAWN_CLASSIFICATION_LOG_CAP) {
|
|
132
|
+
loggedSpawnClassifications.clear();
|
|
133
|
+
}
|
|
134
|
+
loggedSpawnClassifications.add(pair);
|
|
135
|
+
logExtension({
|
|
136
|
+
subsystem: "safe-spawn",
|
|
137
|
+
level: "debug",
|
|
138
|
+
message: "spawn failure classified",
|
|
139
|
+
metadata: {
|
|
140
|
+
kind: failure.kind,
|
|
141
|
+
command: options.command,
|
|
142
|
+
cwd: options.cwd,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (failure.kind !== "tool-not-found") {
|
|
147
|
+
recordDegradation({
|
|
148
|
+
kind: "spawn-failure",
|
|
149
|
+
subject: options.command,
|
|
150
|
+
reason: `${failure.kind}${options.cwd ? ` in ${options.cwd}` : ""}`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Classify a raw Node spawn error without discarding its errno-bearing Error. */
|
|
155
|
+
export async function classifySpawnFailure(error, options) {
|
|
156
|
+
const cause = toError(error);
|
|
157
|
+
const needsCwdProbe = errorCode(cause) === "ENOENT";
|
|
158
|
+
const cwdUnresolvable = needsCwdProbe && (await cwdIsUnresolvable(options.cwd));
|
|
159
|
+
return classifyWithCwdFlag(cause, options, cwdUnresolvable);
|
|
160
|
+
}
|
|
161
|
+
function classifySpawnFailureSync(error, options) {
|
|
162
|
+
const cause = toError(error);
|
|
163
|
+
const needsCwdProbe = errorCode(cause) === "ENOENT";
|
|
164
|
+
const cwdUnresolvable = needsCwdProbe && cwdIsUnresolvableSync(options.cwd);
|
|
165
|
+
return classifyWithCwdFlag(cause, options, cwdUnresolvable);
|
|
166
|
+
}
|
|
21
167
|
// Vitest reloads modules inside a reused worker. Keep this registry on the
|
|
22
168
|
// process so those module instances share one signal/exit listener set.
|
|
23
169
|
const lifetimeStateKey = Symbol.for("pi-lens.safe-spawn.lifetime-state");
|
|
@@ -128,19 +274,271 @@ export function buildWindowsShellCommand(command, args) {
|
|
|
128
274
|
return `chcp 65001 >nul 2>&1 && ${[command, ...args].map(cmdEscapeArg).join(" ")}`;
|
|
129
275
|
}
|
|
130
276
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
277
|
+
* The extensions this resolver treats as executable suffixes on their own,
|
|
278
|
+
* independent of PATHEXT: `.exe`/`.com` and `.cmd`/`.bat`. An explicit suffix
|
|
279
|
+
* outside this set is not treated as a "known executable extension" unless
|
|
280
|
+
* the caller's own PATHEXT says so (#1201).
|
|
281
|
+
*
|
|
282
|
+
* This is a RESOLUTION set, not a dispatch whitelist — do not read it as "the
|
|
283
|
+
* only extensions this module can spawn". Dispatch in
|
|
284
|
+
* `safeSpawnAsync`/`safeSpawn` below routes `.cmd`/`.bat` through the cmd.exe
|
|
285
|
+
* wrapper and EVERYTHING ELSE to direct spawn, so a resolved path with any
|
|
286
|
+
* other suffix (reachable only when the caller's own PATHEXT lists it) is
|
|
287
|
+
* direct-spawned. That is deliberate and not a widening: CreateProcess does no
|
|
288
|
+
* ShellExecute file-association lookup, the default Windows PATHEXT already
|
|
289
|
+
* contains `.VBS`/`.JS`/`.WSF`/`.MSC`, and the set here is strictly narrower
|
|
290
|
+
* than the pre-#1201 behavior. Only the doc claim was ever wrong.
|
|
291
|
+
*/
|
|
292
|
+
const WINDOWS_DIRECT_RESOLVABLE_EXTS = new Set([
|
|
293
|
+
".exe",
|
|
294
|
+
".com",
|
|
295
|
+
".cmd",
|
|
296
|
+
".bat",
|
|
297
|
+
]);
|
|
298
|
+
/**
|
|
299
|
+
* Merge a parent and child environment using Windows' case-insensitive variable
|
|
300
|
+
* names. Node's JavaScript environment object can contain both `PATH` and
|
|
301
|
+
* `Path`, even though Windows treats them as one variable. Removing an older
|
|
302
|
+
* spelling before each assignment gives explicit child overrides precedence
|
|
303
|
+
* over every ambient spelling and ensures the environment passed to spawn has
|
|
304
|
+
* one unambiguous value.
|
|
305
|
+
*/
|
|
306
|
+
export function mergeWindowsEnvironment(base, overrides) {
|
|
307
|
+
const entries = new Map();
|
|
308
|
+
const assign = (source) => {
|
|
309
|
+
if (!source)
|
|
310
|
+
return;
|
|
311
|
+
for (const [key, value] of Object.entries(source)) {
|
|
312
|
+
const folded = key.toLowerCase();
|
|
313
|
+
// Delete before setting so the last explicit spelling wins while the
|
|
314
|
+
// folded map remains O(n) instead of scanning the merged object for
|
|
315
|
+
// every environment entry.
|
|
316
|
+
entries.delete(folded);
|
|
317
|
+
if (value !== undefined)
|
|
318
|
+
entries.set(folded, { key, value });
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
assign(base);
|
|
322
|
+
assign(overrides);
|
|
323
|
+
const merged = {};
|
|
324
|
+
for (const { key, value } of entries.values())
|
|
325
|
+
merged[key] = value;
|
|
326
|
+
return merged;
|
|
327
|
+
}
|
|
328
|
+
function getWindowsEnvironmentValue(env, name) {
|
|
329
|
+
let value;
|
|
330
|
+
for (const [key, entry] of Object.entries(env)) {
|
|
331
|
+
if (key.toLowerCase() === name.toLowerCase())
|
|
332
|
+
value = entry;
|
|
333
|
+
}
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
function driveLetter(value) {
|
|
337
|
+
const match = /^([A-Za-z]):/.exec(value);
|
|
338
|
+
return match?.[1]?.toUpperCase();
|
|
339
|
+
}
|
|
340
|
+
function isDriveAbsolute(value, drive) {
|
|
341
|
+
return (value.length >= 3 &&
|
|
342
|
+
value[0]?.toUpperCase() === drive.toUpperCase() &&
|
|
343
|
+
value[1] === ":" &&
|
|
344
|
+
(value[2] === "\\" || value[2] === "/"));
|
|
345
|
+
}
|
|
346
|
+
/** `X:\...` or `X:/...` — a drive-absolute path, for any drive letter. */
|
|
347
|
+
function isDriveAbsoluteAnyDrive(value) {
|
|
348
|
+
const drive = driveLetter(value);
|
|
349
|
+
return drive !== undefined && isDriveAbsolute(value, drive);
|
|
350
|
+
}
|
|
351
|
+
/** `\\server\share\...` — a UNC path. Two leading separators, not more. */
|
|
352
|
+
function isUncWindowsPath(value) {
|
|
353
|
+
return ((value[0] === "\\" || value[0] === "/") &&
|
|
354
|
+
(value[1] === "\\" || value[1] === "/") &&
|
|
355
|
+
value[2] !== "\\" &&
|
|
356
|
+
value[2] !== "/");
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* A path is "fully qualified" (self-contained, no ambient current-directory
|
|
360
|
+
* lookup required) only when it is drive-absolute (`X:\...`) or UNC
|
|
361
|
+
* (`\\server\share`). `path.win32.isAbsolute("\tools")` also returns `true`
|
|
362
|
+
* for a *rooted* path — one relative to the current drive's root rather than
|
|
363
|
+
* a specific drive — and treating that as fully qualified was the #1201
|
|
364
|
+
* bug: it got statSync'd against the *host* drive while a different
|
|
365
|
+
* `effectiveCwd`/`resolvedDrive` supplied the drive cmd.exe actually used to
|
|
366
|
+
* execute it, so a different file could be validated than executed.
|
|
367
|
+
*/
|
|
368
|
+
function isFullyQualifiedWindowsPath(value) {
|
|
369
|
+
return isFullyQualifiedWin32(value) && !isRootedWindowsPath(value);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* `\tools` or `/tools` — rooted at the current drive's root, but naming no
|
|
373
|
+
* drive of its own (distinct from both `X:\tools` fully-qualified and
|
|
374
|
+
* `X:tools` drive-relative-to-current-directory). Windows resolves this
|
|
375
|
+
* against the root of whichever drive is "current", not the current
|
|
376
|
+
* directory on that drive.
|
|
377
|
+
*/
|
|
378
|
+
function isRootedWindowsPath(value) {
|
|
379
|
+
return ((value[0] === "\\" || value[0] === "/") &&
|
|
380
|
+
driveLetter(value) === undefined &&
|
|
381
|
+
!isUncWindowsPath(value));
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Resolve a rooted path (`\tools`) against the ROOT of `driveSource`'s drive
|
|
385
|
+
* — not `driveSource` itself — matching Windows' own rooted-path semantics.
|
|
386
|
+
*
|
|
387
|
+
* When `driveSource` carries NO drive letter, this returns the normalized
|
|
388
|
+
* value rather than `undefined` (#1201, recurring defect shape 2). A
|
|
389
|
+
* drive-less "current drive" provenance means we are not on a drive-lettered
|
|
390
|
+
* filesystem at all — a POSIX host, or a UNC `process.cwd()` such as
|
|
391
|
+
* `\\server\share\...`, which Node permits on real Windows. In that namespace
|
|
392
|
+
* a rooted path is already as qualified as any path can be, so there is
|
|
393
|
+
* nothing to anchor and normalizing is the correct (and pre-#1201) answer.
|
|
394
|
+
* Returning `undefined` instead used to collapse the whole resolution to
|
|
395
|
+
* `null` without a single `statSync`, i.e. a false ENOENT — the exact failure
|
|
396
|
+
* signature #1199 exists to remove. This function must therefore never fail
|
|
397
|
+
* closed on the *shape* of a value that may not be a Windows path at all;
|
|
398
|
+
* the filesystem probe downstream is what decides whether it exists.
|
|
399
|
+
*/
|
|
400
|
+
function resolveRootedWindowsPath(value, driveSource) {
|
|
401
|
+
const drive = driveLetter(driveSource);
|
|
402
|
+
if (drive === undefined)
|
|
403
|
+
return path.win32.normalize(value);
|
|
404
|
+
return path.win32.normalize(path.win32.resolve(`${drive}:\\`, value));
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Return a validated Windows per-drive current directory. Windows exposes
|
|
408
|
+
* these as environment entries such as `=D:`; unlike ordinary environment
|
|
409
|
+
* variables they are not safe to synthesize from a drive letter. A malformed
|
|
410
|
+
* or wrong-drive value is deliberately ignored so drive-relative resolution
|
|
411
|
+
* fails closed instead of guessing a root.
|
|
412
|
+
*
|
|
413
|
+
* Node never surfaces `=X:` keys from the ambient `process.env` (verified:
|
|
414
|
+
* `Object.keys(process.env).filter(k => k.startsWith("="))` is empty), so
|
|
415
|
+
* this provenance is only ever available when a caller supplies it
|
|
416
|
+
* explicitly in an `env` override — e.g. a test, or a future integration
|
|
417
|
+
* that reads it from a lower-level Windows API. That's a real, if narrow,
|
|
418
|
+
* use: keep this path rather than deleting it, but never synthesize a value
|
|
419
|
+
* Node itself can't hand us.
|
|
420
|
+
*/
|
|
421
|
+
function getValidatedPerDriveCwd(env, drive) {
|
|
422
|
+
const value = getWindowsEnvironmentValue(env, `=${drive}:`);
|
|
423
|
+
if (value === undefined || !isDriveAbsolute(value, drive))
|
|
424
|
+
return undefined;
|
|
425
|
+
return path.win32.normalize(value);
|
|
426
|
+
}
|
|
427
|
+
function resolveEffectiveWindowsCwd(cwd, env) {
|
|
428
|
+
if (cwd === undefined) {
|
|
429
|
+
// No caller-supplied cwd: `process.cwd()` IS the effective cwd already —
|
|
430
|
+
// the real, canonical filesystem cwd of THIS process, not a string that
|
|
431
|
+
// needs (re)classifying. On a POSIX CI host it naturally carries no
|
|
432
|
+
// drive letter; that's fine, it never needs one here. Only a
|
|
433
|
+
// CALLER-SUPPLIED cwd string goes through the fully-qualified/rooted/
|
|
434
|
+
// drive-relative classification below — running `process.cwd()`'s own
|
|
435
|
+
// value through `isRootedWindowsPath` was itself a #1201 regression: a
|
|
436
|
+
// drive-less POSIX path (e.g. Linux CI's real cwd) satisfies that
|
|
437
|
+
// predicate too (single leading separator, no drive letter), which then
|
|
438
|
+
// sent it to `resolveRootedWindowsPath` looking for a drive letter on
|
|
439
|
+
// `process.cwd()` that — being the very same drive-less string — could
|
|
440
|
+
// never supply one, resolving to `undefined` (recurring defect shape
|
|
441
|
+
// 2/7: `path.win32.resolve` falls back to `process.cwd()`, which
|
|
442
|
+
// supplies a drive on Windows and none on Linux).
|
|
443
|
+
return path.win32.normalize(process.cwd());
|
|
444
|
+
}
|
|
445
|
+
const requestedDrive = driveLetter(cwd);
|
|
446
|
+
if (requestedDrive !== undefined && !isDriveAbsoluteAnyDrive(cwd)) {
|
|
447
|
+
// `D:foo` — drive-relative to that drive's own current directory.
|
|
448
|
+
const base = getValidatedPerDriveCwd(env, requestedDrive);
|
|
449
|
+
return base === undefined
|
|
450
|
+
? undefined
|
|
451
|
+
: path.win32.normalize(path.win32.resolve(base, cwd));
|
|
452
|
+
}
|
|
453
|
+
if (isFullyQualifiedWindowsPath(cwd))
|
|
454
|
+
return path.win32.normalize(cwd);
|
|
455
|
+
if (isRootedWindowsPath(cwd)) {
|
|
456
|
+
// `\tools` — rooted at the CURRENT drive's root.
|
|
457
|
+
//
|
|
458
|
+
// Note the deliberate asymmetry with `resolveWindowsPathEntry`, which
|
|
459
|
+
// anchors rooted PATH entries to `effectiveCwd`'s drive instead. Both
|
|
460
|
+
// use the SAME rule — "anchor to the best available "current drive"
|
|
461
|
+
// provenance at this point in the pipeline" — they just sit at
|
|
462
|
+
// different points. Here we are *computing* `effectiveCwd`, so it does
|
|
463
|
+
// not exist yet and the process's own cwd is the only provenance
|
|
464
|
+
// available (on real Windows it always carries a drive letter). By the
|
|
465
|
+
// time a PATH entry is resolved, `effectiveCwd` is known and is the
|
|
466
|
+
// strictly better answer, because it is the drive the child will
|
|
467
|
+
// actually run from — anchoring PATH entries to the host drive there
|
|
468
|
+
// would validate one file and execute another. Neither call can fail
|
|
469
|
+
// closed on a drive-less provenance; see `resolveRootedWindowsPath`.
|
|
470
|
+
return resolveRootedWindowsPath(cwd, process.cwd());
|
|
471
|
+
}
|
|
472
|
+
return path.win32.normalize(path.win32.resolve(process.cwd(), cwd));
|
|
473
|
+
}
|
|
474
|
+
function resolveDriveRelativeWindowsPath(value, effectiveCwd, env) {
|
|
475
|
+
const drive = driveLetter(value);
|
|
476
|
+
if (drive === undefined)
|
|
477
|
+
return undefined;
|
|
478
|
+
const cwdDrive = effectiveCwd === undefined ? undefined : driveLetter(effectiveCwd);
|
|
479
|
+
const base = cwdDrive?.toUpperCase() === drive
|
|
480
|
+
? effectiveCwd
|
|
481
|
+
: getValidatedPerDriveCwd(env, drive);
|
|
482
|
+
if (base === undefined)
|
|
483
|
+
return undefined;
|
|
484
|
+
return path.win32.normalize(path.win32.resolve(base, value));
|
|
485
|
+
}
|
|
486
|
+
function resolveWindowsPathEntry(entry, effectiveCwd, env) {
|
|
487
|
+
if (driveLetter(entry) !== undefined && !isDriveAbsoluteAnyDrive(entry)) {
|
|
488
|
+
return resolveDriveRelativeWindowsPath(entry, effectiveCwd, env);
|
|
489
|
+
}
|
|
490
|
+
if (isFullyQualifiedWindowsPath(entry))
|
|
491
|
+
return path.win32.normalize(entry);
|
|
492
|
+
if (isRootedWindowsPath(entry)) {
|
|
493
|
+
// `\tools` — rooted at the effective child cwd's drive root, not the
|
|
494
|
+
// host process's drive (#1201: a rooted PATH entry must resolve on
|
|
495
|
+
// the SAME drive the child will actually run cmd.exe/the resolved
|
|
496
|
+
// binary from). See `resolveEffectiveWindowsCwd`'s rooted branch for
|
|
497
|
+
// why that function anchors to `process.cwd()` instead — same rule,
|
|
498
|
+
// different point in the pipeline. `undefined` here means the cwd
|
|
499
|
+
// itself was unprovable, which is a genuinely different condition
|
|
500
|
+
// from "the provenance carries no drive letter".
|
|
501
|
+
return effectiveCwd === undefined
|
|
502
|
+
? undefined
|
|
503
|
+
: resolveRootedWindowsPath(entry, effectiveCwd);
|
|
504
|
+
}
|
|
505
|
+
if (effectiveCwd === undefined)
|
|
506
|
+
return undefined;
|
|
507
|
+
return path.win32.normalize(path.win32.resolve(effectiveCwd, entry));
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Cached PATH + PATHEXT resolution results. The effective child environment is
|
|
511
|
+
* part of the key, not the ambient process environment: one caller's managed
|
|
512
|
+
* bin directory must never poison another caller's resolution. The key also
|
|
513
|
+
* carries the canonical child cwd and Windows `=X:` per-drive cwd entries,
|
|
514
|
+
* because both affect relative PATH and drive-relative lookup. Session-lived
|
|
515
|
+
* (matches this repo's other resolution caches, e.g.
|
|
134
516
|
* `clients/workspace-topology.ts`'s `resetWorkspaceTopology`) — cleared via
|
|
135
|
-
* `resetSafeSpawnWindowsCommandCache()`, wired into `handleSessionStart`.
|
|
517
|
+
* `resetSafeSpawnWindowsCommandCache()`, wired into `handleSessionStart`. The
|
|
518
|
+
* cache is also count-bounded with oldest-entry eviction because a long-lived
|
|
519
|
+
* process can encounter unbounded cwd/environment combinations.
|
|
136
520
|
*/
|
|
521
|
+
const WINDOWS_COMMAND_CACHE_MAX_ENTRIES = 256;
|
|
522
|
+
const WINDOWS_COMMAND_NEGATIVE_CACHE_TTL_MS = 1000;
|
|
137
523
|
const windowsCommandCache = new Map();
|
|
138
|
-
/** Reset
|
|
524
|
+
/** Reset after session replacement or a successful managed install. */
|
|
139
525
|
export function resetSafeSpawnWindowsCommandCache() {
|
|
140
526
|
windowsCommandCache.clear();
|
|
527
|
+
loggedSpawnClassifications.clear();
|
|
141
528
|
}
|
|
142
|
-
function
|
|
143
|
-
|
|
529
|
+
function cacheWindowsCommandResult(key, resolved) {
|
|
530
|
+
if (windowsCommandCache.size >= WINDOWS_COMMAND_CACHE_MAX_ENTRIES) {
|
|
531
|
+
// Resolution entries are session-scoped and cheap to recompute. Evict the
|
|
532
|
+
// oldest insertion first so a long-lived process cannot retain every cwd /
|
|
533
|
+
// environment it has ever touched.
|
|
534
|
+
const oldest = windowsCommandCache.keys().next().value;
|
|
535
|
+
if (oldest !== undefined)
|
|
536
|
+
windowsCommandCache.delete(oldest);
|
|
537
|
+
}
|
|
538
|
+
windowsCommandCache.set(key, { resolved, checkedAt: Date.now() });
|
|
539
|
+
}
|
|
540
|
+
function getPathExts(env) {
|
|
541
|
+
const raw = getWindowsEnvironmentValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD";
|
|
144
542
|
return raw
|
|
145
543
|
.split(";")
|
|
146
544
|
.map((ext) => ext.trim().toLowerCase())
|
|
@@ -154,48 +552,145 @@ function statIsFile(candidate) {
|
|
|
154
552
|
return false;
|
|
155
553
|
}
|
|
156
554
|
}
|
|
157
|
-
function resolveWindowsCommandUncached(command,
|
|
158
|
-
const pathExts = getPathExts();
|
|
159
|
-
const existingExt = path.extname(command).toLowerCase();
|
|
160
|
-
const
|
|
555
|
+
function resolveWindowsCommandUncached(command, effectiveCwd, env) {
|
|
556
|
+
const pathExts = getPathExts(env);
|
|
557
|
+
const existingExt = path.win32.extname(command).toLowerCase();
|
|
558
|
+
const hasExplicitExt = existingExt.length > 0;
|
|
559
|
+
// A suffix is "known executable" when it's one of the four extensions this
|
|
560
|
+
// resolver actually knows how to run (direct .exe/.com, or the
|
|
561
|
+
// cmd.exe-wrapped .cmd/.bat) OR it's explicitly listed in the caller's own
|
|
562
|
+
// PATHEXT. Anything else with a dot is NOT necessarily "an extension" — a
|
|
563
|
+
// versioned interpreter (`python3.11`, `node-v20.1`) has an `extname()` of
|
|
564
|
+
// `.11`/`.1` that is part of the basename, not a suffix to match exactly
|
|
565
|
+
// (#1201).
|
|
566
|
+
const isKnownExecutableExt = (ext) => WINDOWS_DIRECT_RESOLVABLE_EXTS.has(ext) || pathExts.includes(ext);
|
|
161
567
|
const tryBase = (base) => {
|
|
162
|
-
|
|
163
|
-
|
|
568
|
+
// Additive, not exclusive: an explicit, known-executable suffix is
|
|
569
|
+
// tried as an exact candidate FIRST (this also lets a caller ask for
|
|
570
|
+
// `foo.cmd` even when the current PATHEXT doesn't happen to list
|
|
571
|
+
// `.CMD`). Whenever the suffix ISN'T a PATHEXT entry — no extension at
|
|
572
|
+
// all, or a versioned/unknown one like `.11` — the PATHEXT-append loop
|
|
573
|
+
// ALSO runs against the full base, so `python3.11` still finds
|
|
574
|
+
// `python3.11.exe`. An unknown suffix (e.g. `foo.txt`) never gets an
|
|
575
|
+
// exact-match short-circuit: that would let an arbitrary
|
|
576
|
+
// non-executable file resolve as "spawnable" and reach the
|
|
577
|
+
// direct-spawn branch downstream.
|
|
578
|
+
if (hasExplicitExt &&
|
|
579
|
+
isKnownExecutableExt(existingExt) &&
|
|
580
|
+
statIsFile(base)) {
|
|
581
|
+
return { resolvedPath: base, ext: existingExt };
|
|
164
582
|
}
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
583
|
+
if (!hasExplicitExt || !pathExts.includes(existingExt)) {
|
|
584
|
+
for (const ext of pathExts) {
|
|
585
|
+
const candidate = base + ext;
|
|
586
|
+
if (statIsFile(candidate))
|
|
587
|
+
return { resolvedPath: candidate, ext };
|
|
588
|
+
}
|
|
169
589
|
}
|
|
170
590
|
return null;
|
|
171
591
|
};
|
|
172
592
|
const hasPathSep = /[\\/]/.test(command);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
593
|
+
// `D:tool.exe` is drive-relative on Windows, not a bare PATH command. It
|
|
594
|
+
// uses the effective cwd only when that cwd is on D:. For another drive we
|
|
595
|
+
// require an explicit, validated `=D:` provenance entry; otherwise this
|
|
596
|
+
// resolver fails closed rather than guessing `D:\\` or searching PATH.
|
|
597
|
+
const hasDrivePrefix = driveLetter(command) !== undefined;
|
|
598
|
+
if (hasPathSep || hasDrivePrefix || isFullyQualifiedWindowsPath(command)) {
|
|
599
|
+
let base;
|
|
600
|
+
if (isFullyQualifiedWindowsPath(command)) {
|
|
601
|
+
base = path.win32.normalize(command);
|
|
602
|
+
}
|
|
603
|
+
else if (hasDrivePrefix) {
|
|
604
|
+
base = resolveDriveRelativeWindowsPath(command, effectiveCwd, env);
|
|
605
|
+
}
|
|
606
|
+
else if (isRootedWindowsPath(command)) {
|
|
607
|
+
base =
|
|
608
|
+
effectiveCwd === undefined
|
|
609
|
+
? undefined
|
|
610
|
+
: resolveRootedWindowsPath(command, effectiveCwd);
|
|
611
|
+
}
|
|
612
|
+
else if (effectiveCwd !== undefined) {
|
|
613
|
+
base = path.win32.normalize(path.win32.resolve(effectiveCwd, command));
|
|
614
|
+
}
|
|
615
|
+
return base === undefined ? null : tryBase(base);
|
|
178
616
|
}
|
|
179
|
-
|
|
617
|
+
// A bare command (no path separator) is a plain PATH search. Absolute PATH
|
|
618
|
+
// entries don't need `effectiveCwd` at all, so an unresolvable cwd must
|
|
619
|
+
// only skip the relative entries (handled per-entry in
|
|
620
|
+
// resolveWindowsPathEntry below), not abort the whole search (#1201: a bad
|
|
621
|
+
// cwd shouldn't make an otherwise-resolvable command look "not installed").
|
|
622
|
+
const pathValue = getWindowsEnvironmentValue(env, "PATH") ?? "";
|
|
623
|
+
const pathDirs = pathValue.split(path.win32.delimiter);
|
|
180
624
|
for (const dir of pathDirs) {
|
|
181
625
|
if (!dir)
|
|
182
626
|
continue;
|
|
183
|
-
const
|
|
627
|
+
const resolvedDir = resolveWindowsPathEntry(dir, effectiveCwd, env);
|
|
628
|
+
if (resolvedDir === undefined)
|
|
629
|
+
continue;
|
|
630
|
+
const found = tryBase(path.win32.join(resolvedDir, command));
|
|
184
631
|
if (found)
|
|
185
632
|
return found;
|
|
186
633
|
}
|
|
187
634
|
return null;
|
|
188
635
|
}
|
|
189
|
-
/**
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
636
|
+
/**
|
|
637
|
+
* Resolve a Windows command using the exact environment that will be passed to
|
|
638
|
+
* the child. Exported as a small platform-independent test/diagnostic seam;
|
|
639
|
+
* callers should pass a Windows-shaped environment and do not need to mutate
|
|
640
|
+
* `process.env` to exercise resolution. Drive-relative commands (`D:tool.exe`)
|
|
641
|
+
* use a same-drive effective cwd, or a validated absolute `=D:` entry for a
|
|
642
|
+
* different drive; without that provenance they fail closed and never search
|
|
643
|
+
* PATH. Relative PATH entries use the canonical effective child cwd.
|
|
644
|
+
*/
|
|
645
|
+
export function resolveWindowsCommandForEnvironment(command, cwd, env) {
|
|
646
|
+
const effectiveCwd = resolveEffectiveWindowsCwd(cwd, env);
|
|
647
|
+
const pathValue = getWindowsEnvironmentValue(env, "PATH");
|
|
648
|
+
const pathExtValue = getWindowsEnvironmentValue(env, "PATHEXT");
|
|
649
|
+
const perDriveCwds = Object.entries(env)
|
|
650
|
+
.flatMap(([key, value]) => /^=[A-Za-z]:$/.test(key)
|
|
651
|
+
? [[key.toLowerCase(), value]]
|
|
652
|
+
: [])
|
|
653
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
654
|
+
// Keep presence separate from value: absent PATHEXT means the Windows
|
|
655
|
+
// default extension list, while PATHEXT="" means no implicit extensions.
|
|
656
|
+
// The per-drive snapshot is equally important: it is provenance for
|
|
657
|
+
// drive-relative commands and PATH entries, not ambient decoration.
|
|
658
|
+
const cacheKey = JSON.stringify([
|
|
659
|
+
"win32",
|
|
660
|
+
command,
|
|
661
|
+
pathValue === undefined ? ["absent"] : ["present", pathValue],
|
|
662
|
+
pathExtValue === undefined ? ["absent"] : ["present", pathExtValue],
|
|
663
|
+
effectiveCwd === undefined ? ["unresolved"] : ["resolved", effectiveCwd],
|
|
664
|
+
perDriveCwds,
|
|
665
|
+
]);
|
|
666
|
+
const cached = windowsCommandCache.get(cacheKey);
|
|
667
|
+
if (cached) {
|
|
668
|
+
// Positive entries are revalidated on every hit so an executable deleted
|
|
669
|
+
// or replaced mid-session cannot remain spawnable through stale cache
|
|
670
|
+
// state. Negative entries are short-lived to discover external installs;
|
|
671
|
+
// pi-lens-managed installs also reset the cache immediately on success.
|
|
672
|
+
if (cached.resolved && statIsFile(cached.resolved.resolvedPath)) {
|
|
673
|
+
return cached.resolved;
|
|
674
|
+
}
|
|
675
|
+
if (!cached.resolved &&
|
|
676
|
+
Date.now() - cached.checkedAt <= WINDOWS_COMMAND_NEGATIVE_CACHE_TTL_MS) {
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
windowsCommandCache.delete(cacheKey);
|
|
194
680
|
}
|
|
195
|
-
const resolved = resolveWindowsCommandUncached(command,
|
|
196
|
-
|
|
681
|
+
const resolved = resolveWindowsCommandUncached(command, effectiveCwd, env);
|
|
682
|
+
cacheWindowsCommandResult(cacheKey, resolved);
|
|
197
683
|
return resolved;
|
|
198
684
|
}
|
|
685
|
+
/** Cached `where`-equivalent for the effective child environment. */
|
|
686
|
+
function resolveWindowsCommand(command, cwd, env) {
|
|
687
|
+
return resolveWindowsCommandForEnvironment(command, cwd, env);
|
|
688
|
+
}
|
|
689
|
+
function getSpawnEnvironment(overrides) {
|
|
690
|
+
return process.platform === "win32"
|
|
691
|
+
? mergeWindowsEnvironment(process.env, overrides)
|
|
692
|
+
: { ...process.env, ...overrides };
|
|
693
|
+
}
|
|
199
694
|
/**
|
|
200
695
|
* Characters that make a cmd.exe `/c` command line unsound to build from
|
|
201
696
|
* caller-tainted input: `%`/`!` expand even inside double quotes and cannot
|
|
@@ -215,10 +710,8 @@ function findCmdUnsafeValue(command, args) {
|
|
|
215
710
|
}
|
|
216
711
|
function synthesizeEnoentError(command) {
|
|
217
712
|
// Shaped like Node's native `spawn <cmd> ENOENT` error (message/code/
|
|
218
|
-
// syscall/path) so
|
|
219
|
-
//
|
|
220
|
-
// keep working now that Windows resolution happens before spawn instead
|
|
221
|
-
// of inside cmd.exe.
|
|
713
|
+
// syscall/path) so the typed classifier retains the same diagnostic cause
|
|
714
|
+
// now that Windows resolution happens before spawn instead of inside cmd.exe.
|
|
222
715
|
const err = new Error(`spawn ${command} ENOENT`);
|
|
223
716
|
err.code = "ENOENT";
|
|
224
717
|
err.syscall = "spawn";
|
|
@@ -251,11 +744,22 @@ function ensureUtf8ConsoleCodePageOnce() {
|
|
|
251
744
|
});
|
|
252
745
|
}
|
|
253
746
|
catch {
|
|
254
|
-
// Best-effort: worst case is non-ASCII tool output
|
|
747
|
+
// Best-effort: worst case is non-ASCII tool output decoded incorrectly, not a
|
|
255
748
|
// spawn failure — never let this block the real spawn.
|
|
256
749
|
}
|
|
257
750
|
}
|
|
258
|
-
/**
|
|
751
|
+
/**
|
|
752
|
+
* Test-only seam: clear the one-shot `chcp` memoization.
|
|
753
|
+
*
|
|
754
|
+
* No test observes it today — the memoized work only runs inside a real
|
|
755
|
+
* Windows direct spawn, so exercising it would mean a Windows-only test, and
|
|
756
|
+
* the state it guards is a console code page, not behavior any assertion
|
|
757
|
+
* depends on. This is kept purely so a future Windows-only test (or a second
|
|
758
|
+
* caller of `ensureUtf8ConsoleCodePageOnce`) can reset process-lifetime state
|
|
759
|
+
* without reaching into the module; it was dropped once as unrelated scope
|
|
760
|
+
* creep in an earlier #1199 revision and restored on review. Do not read the
|
|
761
|
+
* export as evidence of existing coverage (#1201).
|
|
762
|
+
*/
|
|
259
763
|
export function resetUtf8ConsoleCodePageStateForTests() {
|
|
260
764
|
utf8ConsoleCodePageApplied = false;
|
|
261
765
|
}
|
|
@@ -275,7 +779,15 @@ export function resetUtf8ConsoleCodePageStateForTests() {
|
|
|
275
779
|
* if (result.error) console.error("Failed:", result.error);
|
|
276
780
|
*/
|
|
277
781
|
export async function safeSpawnAsync(command, args, options) {
|
|
278
|
-
const
|
|
782
|
+
const configuredTimeout = options?.timeout !== undefined &&
|
|
783
|
+
Number.isFinite(options.timeout) &&
|
|
784
|
+
options.timeout >= 0
|
|
785
|
+
? options.timeout
|
|
786
|
+
: 30000;
|
|
787
|
+
const deadlineRemaining = options?.deadlineAt !== undefined && Number.isFinite(options.deadlineAt)
|
|
788
|
+
? options.deadlineAt - Date.now()
|
|
789
|
+
: Number.POSITIVE_INFINITY;
|
|
790
|
+
const timeout = Math.max(0, Math.min(configuredTimeout, deadlineRemaining));
|
|
279
791
|
// Fall back to the current turn's ambient signal (set from ctx.signal) so an
|
|
280
792
|
// Esc/abort mid-turn cancels dispatches that didn't thread a signal of their
|
|
281
793
|
// own — unless the caller opts out (installs, which must run to completion).
|
|
@@ -284,18 +796,72 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
284
796
|
return new Promise((resolve) => {
|
|
285
797
|
// Check for early abort
|
|
286
798
|
if (abortSignal?.aborted) {
|
|
799
|
+
const cause = new Error("Spawn aborted before start");
|
|
800
|
+
resolve({
|
|
801
|
+
stdout: "",
|
|
802
|
+
stderr: "",
|
|
803
|
+
status: null,
|
|
804
|
+
error: cause,
|
|
805
|
+
failure: "aborted",
|
|
806
|
+
spawnFailure: new SpawnFailureError("killed", cause.message, cause),
|
|
807
|
+
});
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (timeout <= 0) {
|
|
811
|
+
const cause = new Error(`Process timed out after ${timeout}ms`);
|
|
287
812
|
resolve({
|
|
288
813
|
stdout: "",
|
|
289
814
|
stderr: "",
|
|
290
815
|
status: null,
|
|
291
|
-
error:
|
|
816
|
+
error: cause,
|
|
817
|
+
failure: "timeout",
|
|
818
|
+
spawnFailure: new SpawnFailureError("timeout", cause.message, cause),
|
|
292
819
|
});
|
|
293
820
|
return;
|
|
294
821
|
}
|
|
295
822
|
let stdout = "";
|
|
296
823
|
let stderr = "";
|
|
297
824
|
let timedOut = false;
|
|
825
|
+
let aborted = false;
|
|
298
826
|
let killed = false;
|
|
827
|
+
let outputTruncated = false;
|
|
828
|
+
let spawnErrored = false;
|
|
829
|
+
// #1109: the non-Windows SIGTERM→SIGKILL escalation timer (armed in
|
|
830
|
+
// killTree below). Stored per-call (never shared) so the close/error
|
|
831
|
+
// handlers can clear it if the child exits before it fires — same
|
|
832
|
+
// uncleared-race-timeout class as the LSP client-wait leak (#1097):
|
|
833
|
+
// a ref'd 1s timer that outlives the child it was escalating against
|
|
834
|
+
// would keep a one-shot `pi --print` process alive for up to 1s.
|
|
835
|
+
let escalationTimer;
|
|
836
|
+
// #1114: `child.killed` is set by Node the moment `kill()` successfully
|
|
837
|
+
// SENDS a signal, not when the child actually dies — so gating the
|
|
838
|
+
// escalation on `!child.killed` right after a successful `SIGTERM` send
|
|
839
|
+
// is always false and the SIGKILL branch is unreachable. Track observed
|
|
840
|
+
// death via the close/error handlers instead (set synchronously, before
|
|
841
|
+
// any `await`, so a timer firing during the close handler's `await
|
|
842
|
+
// killPromise` still observes the flag correctly).
|
|
843
|
+
let closed = false;
|
|
844
|
+
const maxOutputBytes = options?.maxOutputBytes !== undefined &&
|
|
845
|
+
Number.isFinite(options.maxOutputBytes) &&
|
|
846
|
+
options.maxOutputBytes > 0
|
|
847
|
+
? Math.floor(options.maxOutputBytes)
|
|
848
|
+
: undefined;
|
|
849
|
+
const appendOutput = (current, chunk) => {
|
|
850
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString();
|
|
851
|
+
if (maxOutputBytes === undefined)
|
|
852
|
+
return current + text;
|
|
853
|
+
const used = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
|
|
854
|
+
const remaining = maxOutputBytes - used;
|
|
855
|
+
if (remaining <= 0) {
|
|
856
|
+
outputTruncated = true;
|
|
857
|
+
return current;
|
|
858
|
+
}
|
|
859
|
+
const bytes = Buffer.byteLength(text);
|
|
860
|
+
if (bytes <= remaining)
|
|
861
|
+
return current + text;
|
|
862
|
+
outputTruncated = true;
|
|
863
|
+
return current + Buffer.from(text).subarray(0, remaining).toString();
|
|
864
|
+
};
|
|
299
865
|
// Spawn the process (non-blocking). Keeping Node's `shell` option false
|
|
300
866
|
// is important on every path here: shell:true concatenates tainted
|
|
301
867
|
// arguments into a command line before spawning (CodeQL #17 / CWE-78).
|
|
@@ -313,12 +879,36 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
313
879
|
// - unresolvable → synthesize an ENOENT-shaped error instead of
|
|
314
880
|
// letting cmd.exe report "not recognized" from inside a shell.
|
|
315
881
|
const isWindows = process.platform === "win32";
|
|
882
|
+
const spawnEnv = getSpawnEnvironment(options?.env);
|
|
883
|
+
// The cwd handed to the CHILD process doesn't need OUR validation —
|
|
884
|
+
// Windows resolves it natively, exactly as it did before #817 ever
|
|
885
|
+
// touched this file. A drive-relative cwd (`D:work`) without a
|
|
886
|
+
// validated `=D:` env entry can't be canonicalized by us (Node never
|
|
887
|
+
// surfaces `=X:` keys from the ambient environment — see
|
|
888
|
+
// `getValidatedPerDriveCwd`'s doc comment), but that is a gap in OUR
|
|
889
|
+
// provenance, not evidence the cwd itself is bad. Fall back to the raw
|
|
890
|
+
// value instead of failing the whole spawn before ever attempting it
|
|
891
|
+
// (#1201) — command resolution just below still fails closed for any
|
|
892
|
+
// relative PATH entry that would need this cwd to be canonical.
|
|
893
|
+
// No `?? process.cwd()` third arm: `resolveEffectiveWindowsCwd(undefined,
|
|
894
|
+
// env)` always returns a string, so the only way to reach `undefined`
|
|
895
|
+
// here is a DEFINED-but-unprovable `options.cwd`, which `?? options?.cwd`
|
|
896
|
+
// already covers. A `process.cwd()` arm would be unreachable, and it
|
|
897
|
+
// would read like a live silent-wrong-directory hazard (#1201).
|
|
898
|
+
const spawnCwd = isWindows
|
|
899
|
+
? (resolveEffectiveWindowsCwd(options?.cwd, spawnEnv) ?? options?.cwd)
|
|
900
|
+
: options?.cwd;
|
|
316
901
|
let spawnCmd = command;
|
|
317
902
|
let spawnArgs = args;
|
|
318
903
|
let windowsVerbatimArguments = false;
|
|
319
904
|
let resolutionError;
|
|
320
905
|
if (isWindows) {
|
|
321
|
-
|
|
906
|
+
// Pass the ORIGINAL (unresolved) cwd here, not `spawnCwd` above — this
|
|
907
|
+
// seam intentionally fails closed on unprovable drive-relative
|
|
908
|
+
// provenance (matches `resolveWindowsCommandForEnvironment`'s
|
|
909
|
+
// documented contract and its dedicated test coverage), independent
|
|
910
|
+
// of the passthrough fallback the actual child cwd gets above.
|
|
911
|
+
const resolved = resolveWindowsCommand(command, options?.cwd, spawnEnv);
|
|
322
912
|
if (!resolved) {
|
|
323
913
|
resolutionError = synthesizeEnoentError(command);
|
|
324
914
|
}
|
|
@@ -359,14 +949,24 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
359
949
|
}
|
|
360
950
|
}
|
|
361
951
|
if (resolutionError) {
|
|
362
|
-
|
|
952
|
+
void classifySpawnFailure(resolutionError, {
|
|
953
|
+
command,
|
|
954
|
+
cwd: options?.cwd,
|
|
955
|
+
}).then((spawnFailure) => resolve({
|
|
956
|
+
stdout: "",
|
|
957
|
+
stderr: "",
|
|
958
|
+
status: null,
|
|
959
|
+
error: resolutionError,
|
|
960
|
+
failure: "spawn",
|
|
961
|
+
spawnFailure,
|
|
962
|
+
}));
|
|
363
963
|
return;
|
|
364
964
|
}
|
|
365
965
|
let child;
|
|
366
966
|
try {
|
|
367
967
|
child = spawn(spawnCmd, spawnArgs, {
|
|
368
|
-
cwd:
|
|
369
|
-
env:
|
|
968
|
+
cwd: spawnCwd,
|
|
969
|
+
env: spawnEnv,
|
|
370
970
|
windowsHide: true,
|
|
371
971
|
shell: false,
|
|
372
972
|
windowsVerbatimArguments,
|
|
@@ -380,12 +980,15 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
380
980
|
// a rejection here could surface as an unhandledRejection that crashes
|
|
381
981
|
// the host. Resolve the failure gracefully instead — same contract as an
|
|
382
982
|
// asynchronously-emitted `'error'` event (handled below).
|
|
383
|
-
|
|
983
|
+
const cause = toError(err);
|
|
984
|
+
void classifySpawnFailure(cause, { command, cwd: options?.cwd }).then((spawnFailure) => resolve({
|
|
384
985
|
stdout: "",
|
|
385
986
|
stderr: "",
|
|
386
987
|
status: null,
|
|
387
|
-
error:
|
|
388
|
-
|
|
988
|
+
error: cause,
|
|
989
|
+
failure: "spawn",
|
|
990
|
+
spawnFailure,
|
|
991
|
+
}));
|
|
389
992
|
return;
|
|
390
993
|
}
|
|
391
994
|
if (options?.lifetimeCoupled && child.pid) {
|
|
@@ -434,27 +1037,43 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
434
1037
|
}
|
|
435
1038
|
else {
|
|
436
1039
|
child.kill("SIGTERM");
|
|
437
|
-
setTimeout(() => {
|
|
438
|
-
if (!
|
|
1040
|
+
escalationTimer = setTimeout(() => {
|
|
1041
|
+
if (!closed)
|
|
439
1042
|
child.kill("SIGKILL");
|
|
440
1043
|
}, 1000);
|
|
441
1044
|
}
|
|
442
1045
|
};
|
|
443
1046
|
// Handle abort signal
|
|
444
1047
|
const onAbort = () => {
|
|
1048
|
+
aborted = true;
|
|
445
1049
|
if (!killed && !child.killed) {
|
|
446
1050
|
killed = true;
|
|
447
1051
|
void killTree();
|
|
448
1052
|
}
|
|
449
1053
|
};
|
|
450
1054
|
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
1055
|
+
// Output-cap kills are awaited by the close handler below, just like
|
|
1056
|
+
// timeout/abort kills. This keeps a noisy CLI from continuing in the
|
|
1057
|
+
// background after its retained output has been bounded.
|
|
1058
|
+
let killPromise;
|
|
1059
|
+
const stopForOutputLimit = () => {
|
|
1060
|
+
if (outputTruncated && !killed && !child.killed) {
|
|
1061
|
+
killed = true;
|
|
1062
|
+
killPromise = killTree();
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
451
1065
|
// Collect output
|
|
452
1066
|
child.stdout?.setEncoding("utf-8");
|
|
453
1067
|
child.stderr?.setEncoding("utf-8");
|
|
454
|
-
child.stdout?.on("data", (data) =>
|
|
455
|
-
|
|
1068
|
+
child.stdout?.on("data", (data) => {
|
|
1069
|
+
stdout = appendOutput(stdout, data);
|
|
1070
|
+
stopForOutputLimit();
|
|
1071
|
+
});
|
|
1072
|
+
child.stderr?.on("data", (data) => {
|
|
1073
|
+
stderr = appendOutput(stderr, data);
|
|
1074
|
+
stopForOutputLimit();
|
|
1075
|
+
});
|
|
456
1076
|
// Timeout handling - KILL the process, don't just abandon it
|
|
457
|
-
let killPromise;
|
|
458
1077
|
const timeoutId = setTimeout(() => {
|
|
459
1078
|
timedOut = true;
|
|
460
1079
|
if (!killed && !child.killed) {
|
|
@@ -488,41 +1107,99 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
488
1107
|
};
|
|
489
1108
|
// Process completion
|
|
490
1109
|
child.on("close", async (code, signal) => {
|
|
1110
|
+
if (spawnErrored)
|
|
1111
|
+
return;
|
|
1112
|
+
closed = true;
|
|
491
1113
|
clearTimeout(timeoutId);
|
|
492
1114
|
abortSignal?.removeEventListener("abort", onAbort);
|
|
493
1115
|
if (child.pid)
|
|
494
1116
|
lifetimeState.pids.delete(child.pid);
|
|
495
1117
|
await killPromise;
|
|
1118
|
+
// #1109: the child has exited — if killTree armed the non-Windows
|
|
1119
|
+
// SIGTERM→SIGKILL escalation timer and it hasn't fired yet, clear it
|
|
1120
|
+
// so it doesn't linger as a ref'd handle after this promise resolves.
|
|
1121
|
+
if (escalationTimer)
|
|
1122
|
+
clearTimeout(escalationTimer);
|
|
496
1123
|
const resourceUsage = finishResourceUsage();
|
|
1124
|
+
const outputInfo = outputTruncated ? { outputTruncated: true } : {};
|
|
497
1125
|
if (timedOut) {
|
|
1126
|
+
const cause = new Error(`Process timed out after ${timeout}ms (killed with ${signal || "SIGTERM"})`);
|
|
498
1127
|
resolve({
|
|
499
1128
|
stdout,
|
|
500
1129
|
stderr,
|
|
501
1130
|
status: null,
|
|
502
|
-
error:
|
|
1131
|
+
error: cause,
|
|
1132
|
+
failure: "timeout",
|
|
1133
|
+
spawnFailure: new SpawnFailureError("timeout", cause.message, cause),
|
|
1134
|
+
...outputInfo,
|
|
1135
|
+
resourceUsage,
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
else if (aborted) {
|
|
1139
|
+
const cause = new Error("Spawn aborted");
|
|
1140
|
+
resolve({
|
|
1141
|
+
stdout,
|
|
1142
|
+
stderr,
|
|
1143
|
+
status: null,
|
|
1144
|
+
error: cause,
|
|
1145
|
+
failure: "aborted",
|
|
1146
|
+
spawnFailure: new SpawnFailureError("killed", cause.message, cause),
|
|
1147
|
+
...outputInfo,
|
|
503
1148
|
resourceUsage,
|
|
504
1149
|
});
|
|
505
1150
|
}
|
|
506
1151
|
else if (signal) {
|
|
1152
|
+
const cause = new Error(`Process killed by signal: ${signal}`);
|
|
507
1153
|
resolve({
|
|
508
1154
|
stdout,
|
|
509
1155
|
stderr,
|
|
510
1156
|
status: null,
|
|
511
|
-
error:
|
|
1157
|
+
error: cause,
|
|
1158
|
+
failure: "signal",
|
|
1159
|
+
spawnFailure: new SpawnFailureError("killed", cause.message, cause),
|
|
1160
|
+
...outputInfo,
|
|
512
1161
|
resourceUsage,
|
|
513
1162
|
});
|
|
514
1163
|
}
|
|
515
1164
|
else {
|
|
516
|
-
resolve({ stdout, stderr, status: code, resourceUsage });
|
|
1165
|
+
resolve({ stdout, stderr, status: code, ...outputInfo, resourceUsage });
|
|
517
1166
|
}
|
|
518
1167
|
});
|
|
519
1168
|
child.on("error", (err) => {
|
|
1169
|
+
spawnErrored = true;
|
|
1170
|
+
closed = true;
|
|
520
1171
|
clearTimeout(timeoutId);
|
|
521
1172
|
abortSignal?.removeEventListener("abort", onAbort);
|
|
1173
|
+
if (escalationTimer)
|
|
1174
|
+
clearTimeout(escalationTimer);
|
|
522
1175
|
if (child.pid)
|
|
523
1176
|
lifetimeState.pids.delete(child.pid);
|
|
524
1177
|
const resourceUsage = finishResourceUsage();
|
|
525
|
-
|
|
1178
|
+
let failure = "spawn";
|
|
1179
|
+
if (aborted)
|
|
1180
|
+
failure = "aborted";
|
|
1181
|
+
else if (timedOut)
|
|
1182
|
+
failure = "timeout";
|
|
1183
|
+
const controlFailure = aborted
|
|
1184
|
+
? new SpawnFailureError("killed", err.message, err)
|
|
1185
|
+
: timedOut
|
|
1186
|
+
? new SpawnFailureError("timeout", err.message, err)
|
|
1187
|
+
: undefined;
|
|
1188
|
+
const finish = (spawnFailure) => resolve({
|
|
1189
|
+
stdout,
|
|
1190
|
+
stderr,
|
|
1191
|
+
status: null,
|
|
1192
|
+
error: err,
|
|
1193
|
+
failure,
|
|
1194
|
+
spawnFailure,
|
|
1195
|
+
...(outputTruncated ? { outputTruncated: true } : {}),
|
|
1196
|
+
resourceUsage,
|
|
1197
|
+
});
|
|
1198
|
+
if (controlFailure)
|
|
1199
|
+
finish(controlFailure);
|
|
1200
|
+
else {
|
|
1201
|
+
void classifySpawnFailure(err, { command, cwd: options?.cwd }).then(finish);
|
|
1202
|
+
}
|
|
526
1203
|
});
|
|
527
1204
|
});
|
|
528
1205
|
}
|
|
@@ -591,14 +1268,29 @@ export async function findCommandAsync(command) {
|
|
|
591
1268
|
* No `shell: true` anywhere.
|
|
592
1269
|
*/
|
|
593
1270
|
export function safeSpawn(command, args, options) {
|
|
1271
|
+
const spawnEnv = getSpawnEnvironment(options?.env);
|
|
594
1272
|
if (process.platform === "win32") {
|
|
595
|
-
|
|
1273
|
+
// See the matching comment in safeSpawnAsync: the child's cwd doesn't
|
|
1274
|
+
// need OUR validation (Windows resolves it natively), so an unprovable
|
|
1275
|
+
// drive-relative cwd falls back to the raw value instead of failing the
|
|
1276
|
+
// whole spawn before ever attempting it (#1201). Command resolution
|
|
1277
|
+
// below intentionally still uses the ORIGINAL raw cwd and fails closed
|
|
1278
|
+
// for PATH entries that would need it to be canonical.
|
|
1279
|
+
// See safeSpawnAsync for why there is no `?? process.cwd()` third arm.
|
|
1280
|
+
const spawnCwd = resolveEffectiveWindowsCwd(options?.cwd, spawnEnv) ?? options?.cwd;
|
|
1281
|
+
const resolved = resolveWindowsCommand(command, options?.cwd, spawnEnv);
|
|
596
1282
|
if (!resolved) {
|
|
1283
|
+
const error = synthesizeEnoentError(command);
|
|
597
1284
|
return {
|
|
598
1285
|
stdout: "",
|
|
599
1286
|
stderr: "",
|
|
600
1287
|
status: null,
|
|
601
|
-
error
|
|
1288
|
+
error,
|
|
1289
|
+
failure: "spawn",
|
|
1290
|
+
spawnFailure: classifySpawnFailureSync(error, {
|
|
1291
|
+
command,
|
|
1292
|
+
cwd: options?.cwd,
|
|
1293
|
+
}),
|
|
602
1294
|
};
|
|
603
1295
|
}
|
|
604
1296
|
let spawnCmd;
|
|
@@ -610,15 +1302,21 @@ export function safeSpawn(command, args, options) {
|
|
|
610
1302
|
// extensionless) `command` string — plus every arg.
|
|
611
1303
|
const unsafeValue = findCmdUnsafeValue(resolved.resolvedPath, args);
|
|
612
1304
|
if (unsafeValue !== undefined) {
|
|
1305
|
+
const error = new Error(`Refusing to spawn "${resolved.resolvedPath}" via cmd.exe: ` +
|
|
1306
|
+
`${JSON.stringify(unsafeValue)} contains a character ("` +
|
|
1307
|
+
`, %, !, or CR/LF) that cannot be safely escaped on a ` +
|
|
1308
|
+
`cmd.exe /c command line (CWE-78, #817). Rename/quote the ` +
|
|
1309
|
+
"value or invoke the tool without going through cmd.exe.");
|
|
613
1310
|
return {
|
|
614
1311
|
stdout: "",
|
|
615
1312
|
stderr: "",
|
|
616
1313
|
status: null,
|
|
617
|
-
error
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1314
|
+
error,
|
|
1315
|
+
failure: "spawn",
|
|
1316
|
+
spawnFailure: classifySpawnFailureSync(error, {
|
|
1317
|
+
command,
|
|
1318
|
+
cwd: options?.cwd,
|
|
1319
|
+
}),
|
|
622
1320
|
};
|
|
623
1321
|
}
|
|
624
1322
|
spawnCmd = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`;
|
|
@@ -637,29 +1335,46 @@ export function safeSpawn(command, args, options) {
|
|
|
637
1335
|
}
|
|
638
1336
|
const result = spawnSync(spawnCmd, spawnArgs, {
|
|
639
1337
|
...options,
|
|
1338
|
+
cwd: spawnCwd,
|
|
1339
|
+
env: spawnEnv,
|
|
640
1340
|
encoding: "utf-8",
|
|
641
1341
|
shell: false,
|
|
642
1342
|
windowsHide: true,
|
|
643
1343
|
windowsVerbatimArguments,
|
|
644
1344
|
});
|
|
1345
|
+
const spawnFailure = result.error
|
|
1346
|
+
? classifySpawnFailureSync(result.error, { command, cwd: options?.cwd })
|
|
1347
|
+
: undefined;
|
|
645
1348
|
return {
|
|
646
1349
|
stdout: result.stdout?.toString() || "",
|
|
647
1350
|
stderr: result.stderr?.toString() || "",
|
|
648
1351
|
status: result.status,
|
|
649
1352
|
error: result.error,
|
|
1353
|
+
...(spawnFailure ? { failure: "spawn", spawnFailure } : {}),
|
|
650
1354
|
};
|
|
651
1355
|
}
|
|
652
1356
|
const result = spawnSync(command, args, {
|
|
653
1357
|
...options,
|
|
1358
|
+
// Explicit override, not just the spread above: `options.env` alone
|
|
1359
|
+
// would otherwise reach the child as a full replacement (no
|
|
1360
|
+
// process.env merge, no PATH) instead of the merged environment the
|
|
1361
|
+
// resolver/Windows branch above both use (#1201) — the module's
|
|
1362
|
+
// "resolver and child receive the same merged environment" invariant
|
|
1363
|
+
// must hold on every platform, not just Windows.
|
|
1364
|
+
env: spawnEnv,
|
|
654
1365
|
encoding: "utf-8",
|
|
655
1366
|
shell: false,
|
|
656
1367
|
windowsHide: true,
|
|
657
1368
|
});
|
|
1369
|
+
const spawnFailure = result.error
|
|
1370
|
+
? classifySpawnFailureSync(result.error, { command, cwd: options?.cwd })
|
|
1371
|
+
: undefined;
|
|
658
1372
|
return {
|
|
659
1373
|
stdout: result.stdout?.toString() || "",
|
|
660
1374
|
stderr: result.stderr?.toString() || "",
|
|
661
1375
|
status: result.status,
|
|
662
1376
|
error: result.error,
|
|
1377
|
+
...(spawnFailure ? { failure: "spawn", spawnFailure } : {}),
|
|
663
1378
|
};
|
|
664
1379
|
}
|
|
665
1380
|
/**
|