@soleur/opencode 0.0.0-stage → 3.354.2
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/.claude-plugin/agents.manifest.json +476 -0
- package/.claude-plugin/plugin.json +42 -0
- package/README.md +44 -2
- package/agents/community/.gitkeep +0 -0
- package/agents/engineering/cto.md +87 -0
- package/agents/engineering/design/ddd-architect.md +95 -0
- package/agents/engineering/discovery/agent-finder.md +214 -0
- package/agents/engineering/discovery/functional-discovery.md +182 -0
- package/agents/engineering/infra/infra-security.md +151 -0
- package/agents/engineering/infra/platform-strategist.md +109 -0
- package/agents/engineering/infra/terraform-architect.md +171 -0
- package/agents/engineering/prompt-engineer.md +37 -0
- package/agents/engineering/research/best-practices-researcher.md +115 -0
- package/agents/engineering/research/framework-docs-researcher.md +93 -0
- package/agents/engineering/research/git-history-analyzer.md +53 -0
- package/agents/engineering/research/learnings-researcher.md +285 -0
- package/agents/engineering/research/repo-research-analyst.md +118 -0
- package/agents/engineering/review/agent-native-reviewer.md +279 -0
- package/agents/engineering/review/architecture-strategist.md +61 -0
- package/agents/engineering/review/code-quality-analyst.md +92 -0
- package/agents/engineering/review/code-simplicity-reviewer.md +109 -0
- package/agents/engineering/review/data-integrity-guardian.md +82 -0
- package/agents/engineering/review/data-migration-expert.md +98 -0
- package/agents/engineering/review/deployment-verification-agent.md +183 -0
- package/agents/engineering/review/dhh-rails-reviewer.md +46 -0
- package/agents/engineering/review/kieran-rails-reviewer.md +87 -0
- package/agents/engineering/review/legacy-code-expert.md +55 -0
- package/agents/engineering/review/observability-coverage-reviewer.md +147 -0
- package/agents/engineering/review/pattern-recognition-specialist.md +59 -0
- package/agents/engineering/review/performance-oracle.md +130 -0
- package/agents/engineering/review/security-sentinel.md +167 -0
- package/agents/engineering/review/semgrep-sast.md +36 -0
- package/agents/engineering/review/test-design-reviewer.md +150 -0
- package/agents/engineering/review/user-impact-reviewer.md +70 -0
- package/agents/engineering/workflow/pr-comment-resolver.md +69 -0
- package/agents/finance/budget-analyst.md +29 -0
- package/agents/finance/cfo.md +44 -0
- package/agents/finance/financial-reporter.md +30 -0
- package/agents/finance/revenue-analyst.md +30 -0
- package/agents/legal/clo.md +115 -0
- package/agents/legal/legal-compliance-auditor.md +110 -0
- package/agents/legal/legal-document-generator.md +134 -0
- package/agents/marketing/analytics-analyst.md +25 -0
- package/agents/marketing/brand-architect.md +181 -0
- package/agents/marketing/cmo.md +76 -0
- package/agents/marketing/conversion-optimizer.md +19 -0
- package/agents/marketing/copywriter.md +20 -0
- package/agents/marketing/fact-checker.md +70 -0
- package/agents/marketing/growth-strategist.md +145 -0
- package/agents/marketing/paid-media-strategist.md +28 -0
- package/agents/marketing/pricing-strategist.md +17 -0
- package/agents/marketing/programmatic-seo-specialist.md +25 -0
- package/agents/marketing/retention-strategist.md +23 -0
- package/agents/marketing/seo-aeo-analyst.md +164 -0
- package/agents/operations/coo.md +50 -0
- package/agents/operations/ops-advisor.md +78 -0
- package/agents/operations/ops-provisioner.md +101 -0
- package/agents/operations/ops-research.md +52 -0
- package/agents/operations/service-automator.md +278 -0
- package/agents/product/business-validator.md +206 -0
- package/agents/product/competitive-intelligence.md +89 -0
- package/agents/product/cpo.md +63 -0
- package/agents/product/design/ux-design-lead.md +209 -0
- package/agents/product/spec-flow-analyzer.md +122 -0
- package/agents/sales/cro.md +44 -0
- package/agents/sales/deal-architect.md +29 -0
- package/agents/sales/outbound-strategist.md +29 -0
- package/agents/sales/pipeline-analyst.md +29 -0
- package/agents/support/cco.md +48 -0
- package/agents/support/community-manager.md +570 -0
- package/agents/support/ticket-triage.md +70 -0
- package/bin/soleur-opencode.js +29 -0
- package/commands/go.md +660 -0
- package/commands/help.md +273 -0
- package/commands/sync.md +977 -0
- package/commands/version.md +103 -0
- package/hooks/antigravity-hooks.json +84 -0
- package/hooks/antigravity-session-start.sh +15 -0
- package/hooks/bare-root-cwd-guard.sh +131 -0
- package/hooks/browser-snapshot-credential-guard.sh +200 -0
- package/hooks/codex-session-start.sh +9 -0
- package/hooks/compaction-state.sh +525 -0
- package/hooks/destructive-command-guard.sh +1387 -0
- package/hooks/devin-session-start.sh +90 -0
- package/hooks/ext-filter-detect.sh +171 -0
- package/hooks/hooks.json +133 -0
- package/hooks/lib/hook-tool-kind.sh +24 -0
- package/hooks/lib/log-rotation.sh +176 -0
- package/hooks/lib/shell-argv.pl +576 -0
- package/hooks/memory-backstop.sh +1114 -0
- package/hooks/operator-stage-approval.sh +391 -0
- package/hooks/stop-hook.sh +367 -0
- package/hooks/unkept-promise-hook.sh +383 -0
- package/hooks/version-banner.sh +127 -0
- package/hooks/welcome-hook.sh +50 -0
- package/install.js +29 -0
- package/lib/agent-registry.ts +154 -0
- package/lib/c4-canonical-cli.mjs +34 -0
- package/lib/c4-canonical.mjs +46 -0
- package/lib/c4-from-components.ts +554 -0
- package/lib/go-routing.ts +81 -0
- package/lib/grok-inspect-contract.ts +311 -0
- package/lib/harness-model-map.ts +101 -0
- package/lib/harness-parity.ts +726 -0
- package/lib/harness-tool-map.ts +180 -0
- package/lib/harness.ts +716 -0
- package/lib/heartbeat-live-reconcile.ts +1153 -0
- package/lib/heartbeat-manifest.ts +361 -0
- package/lib/kb-coverage.ts +252 -0
- package/lib/pr-merge-poll.ts +103 -0
- package/lib/workflow-fidelity.ts +388 -0
- package/opencode/INSTRUCTIONS.md +171 -0
- package/opencode/agents/engineering/cto.md +10 -0
- package/opencode/agents/engineering/design/ddd-architect.md +10 -0
- package/opencode/agents/engineering/discovery/agent-finder.md +10 -0
- package/opencode/agents/engineering/discovery/functional-discovery.md +10 -0
- package/opencode/agents/engineering/infra/infra-security.md +10 -0
- package/opencode/agents/engineering/infra/platform-strategist.md +10 -0
- package/opencode/agents/engineering/infra/terraform-architect.md +10 -0
- package/opencode/agents/engineering/prompt-engineer.md +10 -0
- package/opencode/agents/engineering/research/best-practices-researcher.md +10 -0
- package/opencode/agents/engineering/research/framework-docs-researcher.md +10 -0
- package/opencode/agents/engineering/research/git-history-analyzer.md +10 -0
- package/opencode/agents/engineering/research/learnings-researcher.md +10 -0
- package/opencode/agents/engineering/research/repo-research-analyst.md +10 -0
- package/opencode/agents/engineering/review/agent-native-reviewer.md +10 -0
- package/opencode/agents/engineering/review/architecture-strategist.md +10 -0
- package/opencode/agents/engineering/review/code-quality-analyst.md +10 -0
- package/opencode/agents/engineering/review/code-simplicity-reviewer.md +10 -0
- package/opencode/agents/engineering/review/data-integrity-guardian.md +10 -0
- package/opencode/agents/engineering/review/data-migration-expert.md +10 -0
- package/opencode/agents/engineering/review/deployment-verification-agent.md +10 -0
- package/opencode/agents/engineering/review/dhh-rails-reviewer.md +10 -0
- package/opencode/agents/engineering/review/kieran-rails-reviewer.md +10 -0
- package/opencode/agents/engineering/review/legacy-code-expert.md +10 -0
- package/opencode/agents/engineering/review/observability-coverage-reviewer.md +10 -0
- package/opencode/agents/engineering/review/pattern-recognition-specialist.md +10 -0
- package/opencode/agents/engineering/review/performance-oracle.md +10 -0
- package/opencode/agents/engineering/review/security-sentinel.md +10 -0
- package/opencode/agents/engineering/review/semgrep-sast.md +10 -0
- package/opencode/agents/engineering/review/test-design-reviewer.md +10 -0
- package/opencode/agents/engineering/review/user-impact-reviewer.md +10 -0
- package/opencode/agents/engineering/workflow/pr-comment-resolver.md +10 -0
- package/opencode/agents/finance/budget-analyst.md +10 -0
- package/opencode/agents/finance/cfo.md +10 -0
- package/opencode/agents/finance/financial-reporter.md +10 -0
- package/opencode/agents/finance/revenue-analyst.md +10 -0
- package/opencode/agents/legal/clo.md +10 -0
- package/opencode/agents/legal/legal-compliance-auditor.md +10 -0
- package/opencode/agents/legal/legal-document-generator.md +10 -0
- package/opencode/agents/marketing/analytics-analyst.md +10 -0
- package/opencode/agents/marketing/brand-architect.md +10 -0
- package/opencode/agents/marketing/cmo.md +10 -0
- package/opencode/agents/marketing/conversion-optimizer.md +10 -0
- package/opencode/agents/marketing/copywriter.md +10 -0
- package/opencode/agents/marketing/fact-checker.md +10 -0
- package/opencode/agents/marketing/growth-strategist.md +10 -0
- package/opencode/agents/marketing/paid-media-strategist.md +10 -0
- package/opencode/agents/marketing/pricing-strategist.md +10 -0
- package/opencode/agents/marketing/programmatic-seo-specialist.md +10 -0
- package/opencode/agents/marketing/retention-strategist.md +10 -0
- package/opencode/agents/marketing/seo-aeo-analyst.md +10 -0
- package/opencode/agents/operations/coo.md +10 -0
- package/opencode/agents/operations/ops-advisor.md +10 -0
- package/opencode/agents/operations/ops-provisioner.md +10 -0
- package/opencode/agents/operations/ops-research.md +10 -0
- package/opencode/agents/operations/service-automator.md +10 -0
- package/opencode/agents/product/business-validator.md +10 -0
- package/opencode/agents/product/competitive-intelligence.md +10 -0
- package/opencode/agents/product/cpo.md +10 -0
- package/opencode/agents/product/design/ux-design-lead.md +10 -0
- package/opencode/agents/product/spec-flow-analyzer.md +10 -0
- package/opencode/agents/sales/cro.md +10 -0
- package/opencode/agents/sales/deal-architect.md +10 -0
- package/opencode/agents/sales/outbound-strategist.md +10 -0
- package/opencode/agents/sales/pipeline-analyst.md +10 -0
- package/opencode/agents/support/cco.md +10 -0
- package/opencode/agents/support/community-manager.md +10 -0
- package/opencode/agents/support/ticket-triage.md +10 -0
- package/opencode/commands/go.md +10 -0
- package/opencode/commands/help.md +10 -0
- package/opencode/commands/sync.md +10 -0
- package/opencode/commands/version.md +11 -0
- package/opencode/opencode.json +8 -0
- package/opencode/plugin.ts +818 -0
- package/package.json +36 -4
- package/scripts/admin-merge-ready.sh +545 -0
- package/scripts/admin-merge-ready.test.sh +753 -0
- package/scripts/affected-tests.sh +908 -0
- package/scripts/affected-tests.test.sh +1797 -0
- package/scripts/alpha-metrics.sh +78 -0
- package/scripts/alpha-metrics.test.sh +115 -0
- package/scripts/ancestor-signal-helpers.baseline.txt +16 -0
- package/scripts/audit-flag-flip.sh +107 -0
- package/scripts/check-red-on-main.sh +316 -0
- package/scripts/ci-head-verdict.sh +411 -0
- package/scripts/cloud-detect.sh +193 -0
- package/scripts/delegation-verify.sh +657 -0
- package/scripts/delegation-verify.test.sh +1424 -0
- package/scripts/deploy-arm.sh +475 -0
- package/scripts/domain-model-drift.sh +419 -0
- package/scripts/emit-decision.sh +144 -0
- package/scripts/emit-decision.test.sh +144 -0
- package/scripts/ext-filter-divergence-corpus.txt +15 -0
- package/scripts/ext-filter-divergence.sh +189 -0
- package/scripts/generate-c4-from-components.ts +442 -0
- package/scripts/grok-fidelity-gate.sh +68 -0
- package/scripts/grok-pre-push-gate.sh +232 -0
- package/scripts/harness-discovery-smoke.ts +906 -0
- package/scripts/harness-parity-census.ts +92 -0
- package/scripts/inngest-luks-alert-armed-probe.ts +29 -0
- package/scripts/install-opencode-adapter.ts +509 -0
- package/scripts/lib/affected-tests/common.sh +365 -0
- package/scripts/lib/affected-tests/go.sh +157 -0
- package/scripts/lib/affected-tests/js.sh +188 -0
- package/scripts/lib/affected-tests/nx-turbo.sh +98 -0
- package/scripts/lib/affected-tests/python.sh +185 -0
- package/scripts/lib/context-sanitize.sh +49 -0
- package/scripts/lib/domain-model-lib.sh +180 -0
- package/scripts/lib/ext-filter-grammar.tsv +34 -0
- package/scripts/lib/operator-script.sh +1236 -0
- package/scripts/lib/proc.sh +580 -0
- package/scripts/lib/session-state.sh +748 -0
- package/scripts/lib/tmp-classify.sh +843 -0
- package/scripts/lib/wrapper-boundary.sh +47 -0
- package/scripts/lint-ratchet.py +1547 -0
- package/scripts/monitor-pr-checks.sh +441 -0
- package/scripts/pipeline-tally.sh +756 -0
- package/scripts/pipeline-tally.test.sh +740 -0
- package/scripts/plugin-version.sh +528 -0
- package/scripts/precommit-guard.sh +115 -0
- package/scripts/precommit-guard.test.sh +254 -0
- package/scripts/reconcile-live-heartbeats.ts +705 -0
- package/scripts/render-c4-model.sh +217 -0
- package/scripts/resolve-git-root.sh +55 -0
- package/scripts/resolve-regenerable-conflicts.sh +363 -0
- package/scripts/resolve-regenerable-conflicts.test.sh +780 -0
- package/scripts/ruff-requirements.txt +22 -0
- package/scripts/run-in-pid-namespace.sh +98 -0
- package/scripts/run-in-pid-namespace.test.sh +290 -0
- package/scripts/scan-ancestor-signal-helpers.py +556 -0
- package/scripts/scan-ancestor-signal-helpers.test.sh +670 -0
- package/scripts/setup-ralph-loop.sh +238 -0
- package/scripts/sync-cursor-name-map.ts +316 -0
- package/scripts/sync-grok-agent-compat.ts +188 -0
- package/scripts/sync-npm-package.ts +165 -0
- package/scripts/sync-opencode-adapter.ts +498 -0
- package/scripts/sync-pr-behind.sh +647 -0
- package/scripts/sync-pr-behind.test.sh +252 -0
- package/scripts/taste-profile-update.sh +180 -0
- package/scripts/taste-profile-update.test.sh +167 -0
- package/scripts/write-budget-marker.sh +23 -0
- package/scripts/write-kb-coverage.ts +113 -0
- package/skills/admin-ip-refresh/SKILL.md +81 -0
- package/skills/admin-ip-refresh/references/admin-ip-refresh-procedure.md +201 -0
- package/skills/agent-browser/SKILL.md +698 -0
- package/skills/agent-browser/scripts/playwright-mcp-profile-slot.sh +304 -0
- package/skills/agent-browser/scripts/playwright-mcp-redact-proxy.py +964 -0
- package/skills/agent-browser/scripts/redact-a11y-snapshot.py +443 -0
- package/skills/agent-browser/test/fixtures/capture-playwright-mcp-fixtures.py +217 -0
- package/skills/agent-browser/test/fixtures/fake-passthrough-proxy.py +24 -0
- package/skills/agent-browser/test/fixtures/fake-playwright-mcp.py +131 -0
- package/skills/agent-browser/test/fixtures/make-proxy-mutant.py +48 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/dialog-link-injection.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/empty-content.json +5 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-and-result.json +4 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-code-bool.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-code-string.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-message-tree.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-no-data.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-string.json +4 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-with-data.json +9 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/evaluate-escaped-object.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/invalid-utf8.json +4 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/link-result.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/list-two-results.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/meta-in-result.json +13 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/no-content.json +5 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/quoted-key.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/resource-block.json +18 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/result-and-method.json +11 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/result-string.json +3 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped-one-row.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/structured-content.json +13 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/tools-list-no-tools.json +5 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/tree-no-credential.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/two-text-blocks.json +14 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/unparsable-line.json +3 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-brace.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-link-substring.json +10 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/close.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/evaluate.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/find.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/framing.json +6 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/initialize.json +14 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate-link.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/roots-list-request.json +5 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/screenshot.json +17 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-filename.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-json.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-raw.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/tools-list.json +827 -0
- package/skills/agent-browser/test/fixtures/proxy-session-driver.py +138 -0
- package/skills/agent-browser/test/fixtures/session-facts.py +102 -0
- package/skills/agent-browser/test/playwright-mcp-lifetime.test.sh +1620 -0
- package/skills/agent-browser/test/playwright-mcp-redact-proxy.test.sh +1599 -0
- package/skills/agent-browser/test/redact-a11y-snapshot.test.sh +527 -0
- package/skills/agent-native-architecture/SKILL.md +479 -0
- package/skills/agent-native-architecture/references/action-parity-discipline.md +430 -0
- package/skills/agent-native-architecture/references/agent-execution-patterns.md +483 -0
- package/skills/agent-native-architecture/references/agent-native-testing.md +605 -0
- package/skills/agent-native-architecture/references/architecture-patterns.md +493 -0
- package/skills/agent-native-architecture/references/dynamic-context-injection.md +358 -0
- package/skills/agent-native-architecture/references/files-universal-interface.md +314 -0
- package/skills/agent-native-architecture/references/from-primitives-to-domain-tools.md +381 -0
- package/skills/agent-native-architecture/references/mcp-tool-design.md +524 -0
- package/skills/agent-native-architecture/references/mobile-patterns.md +897 -0
- package/skills/agent-native-architecture/references/product-implications.md +478 -0
- package/skills/agent-native-architecture/references/refactoring-to-prompt-native.md +342 -0
- package/skills/agent-native-architecture/references/self-modification.md +294 -0
- package/skills/agent-native-architecture/references/shared-workspace-architecture.md +700 -0
- package/skills/agent-native-architecture/references/system-prompt-design.md +263 -0
- package/skills/agent-native-audit/SKILL.md +300 -0
- package/skills/agent-native-audit/workflows/agent-native-audit.workflow.js +423 -0
- package/skills/andrew-kane-gem-writer/SKILL.md +189 -0
- package/skills/andrew-kane-gem-writer/references/database-adapters.md +231 -0
- package/skills/andrew-kane-gem-writer/references/module-organization.md +121 -0
- package/skills/andrew-kane-gem-writer/references/rails-integration.md +183 -0
- package/skills/andrew-kane-gem-writer/references/resources.md +126 -0
- package/skills/andrew-kane-gem-writer/references/testing-patterns.md +261 -0
- package/skills/architecture/SKILL.md +436 -0
- package/skills/architecture/references/adr-template.md +154 -0
- package/skills/architecture/references/likec4-reference.md +136 -0
- package/skills/architecture/references/nfr-reference.md +126 -0
- package/skills/archive-kb/SKILL.md +86 -0
- package/skills/archive-kb/scripts/archive-kb.sh +300 -0
- package/skills/archive-kb/test/archive-kb-partial-run.test.sh +276 -0
- package/skills/atdd-developer/SKILL.md +65 -0
- package/skills/brainstorm/SKILL.md +701 -0
- package/skills/brainstorm/references/brainstorm-brand-workshop.md +153 -0
- package/skills/brainstorm/references/brainstorm-domain-config.md +63 -0
- package/skills/brainstorm/references/brainstorm-validation-workshop.md +65 -0
- package/skills/brainstorm/references/ui-surface-terms.md +42 -0
- package/skills/brainstorm-techniques/SKILL.md +231 -0
- package/skills/brainstorm-techniques/references/decision-principles.md +150 -0
- package/skills/brainstorm-techniques/references/phase-boundaries.md +103 -0
- package/skills/brainstorm-techniques/references/standing-delegation.md +407 -0
- package/skills/brainstorm-techniques/references/technical-ruling-delegation.md +174 -0
- package/skills/campaign-calendar/SKILL.md +145 -0
- package/skills/cf-token-scope/SKILL.md +164 -0
- package/skills/cf-token-scope/references/widen-playbook.md +114 -0
- package/skills/cf-token-scope/scripts/cf-token-scope.sh +253 -0
- package/skills/changelog/SKILL.md +142 -0
- package/skills/code-to-prd/SKILL.md +66 -0
- package/skills/code-to-prd/references/banner-template.md +12 -0
- package/skills/code-to-prd/references/prd-template.md +41 -0
- package/skills/code-to-prd/scripts/code-to-prd.sh +634 -0
- package/skills/code-to-prd/test/code-to-prd.test.sh +447 -0
- package/skills/code-to-prd/test/fixture/.env.example +7 -0
- package/skills/code-to-prd/test/fixture/app/about/page.tsx +11 -0
- package/skills/code-to-prd/test/fixture/app/api/health/route.ts +10 -0
- package/skills/code-to-prd/test/fixture/app/page.tsx +14 -0
- package/skills/code-to-prd/test/fixture/next.config.mjs +3 -0
- package/skills/code-to-prd/test/fixture/package.json +6 -0
- package/skills/cohort-status/SKILL.md +62 -0
- package/skills/community/SKILL.md +210 -0
- package/skills/community/scripts/bsky-community.sh +644 -0
- package/skills/community/scripts/bsky-setup.sh +631 -0
- package/skills/community/scripts/community-router.sh +93 -0
- package/skills/community/scripts/discord-community.sh +487 -0
- package/skills/community/scripts/discord-setup.sh +784 -0
- package/skills/community/scripts/github-community.sh +706 -0
- package/skills/community/scripts/hn-community.sh +233 -0
- package/skills/community/scripts/lib/hmac-sha1-b64.sh +126 -0
- package/skills/community/scripts/linkedin-community.sh +750 -0
- package/skills/community/scripts/linkedin-setup.sh +758 -0
- package/skills/community/scripts/x-community.sh +890 -0
- package/skills/community/scripts/x-setup.sh +713 -0
- package/skills/community/test/community-argv.test.sh +2922 -0
- package/skills/community/test/github-community.test.sh +981 -0
- package/skills/competitive-analysis/SKILL.md +69 -0
- package/skills/competitive-analysis/references/peer-plugin-audit.md +218 -0
- package/skills/compound/SKILL.md +673 -0
- package/skills/compound/scripts/token-efficiency-report.sh +334 -0
- package/skills/compound/test/phase-16.test.sh +379 -0
- package/skills/compound-capture/SKILL.md +791 -0
- package/skills/compound-capture/assets/critical-pattern-template.md +37 -0
- package/skills/compound-capture/assets/resolution-template.md +116 -0
- package/skills/compound-capture/references/yaml-schema.md +67 -0
- package/skills/compound-capture/schema.yaml +181 -0
- package/skills/constraint-scaffold/SKILL.md +175 -0
- package/skills/constraint-scaffold/references/boundary-readme.template +35 -0
- package/skills/constraint-scaffold/references/constraint-gates-workflow.template +69 -0
- package/skills/constraint-scaffold/references/depcruise-config.template +230 -0
- package/skills/constraint-scaffold/references/fix-constraints-stage-a.template +240 -0
- package/skills/constraint-scaffold/references/fix-constraints-stage-b.template +313 -0
- package/skills/constraint-scaffold/references/shared-runner.template +135 -0
- package/skills/constraint-scaffold/scripts/constraint-scaffold.sh +589 -0
- package/skills/constraint-scaffold/test/bite-proof.test.sh +1338 -0
- package/skills/constraint-scaffold/test/boundary.test.sh +734 -0
- package/skills/constraint-scaffold/test/emit-fix-constraints.test.sh +193 -0
- package/skills/constraint-scaffold/test/generator.test.sh +93 -0
- package/skills/constraint-scaffold/test/parity.test.sh +285 -0
- package/skills/content-writer/SKILL.md +242 -0
- package/skills/content-writer/scripts/blog-jargon-scan.sh +42 -0
- package/skills/cron-delete/SKILL.md +56 -0
- package/skills/cron-list/SKILL.md +54 -0
- package/skills/deepen-plan/SKILL.md +937 -0
- package/skills/deepen-plan/workflows/deepen-plan.workflow.js +488 -0
- package/skills/deploy/SKILL.md +115 -0
- package/skills/deploy/references/hetzner-setup.md +67 -0
- package/skills/deploy/scripts/deploy.sh +37 -0
- package/skills/deploy-docs/SKILL.md +95 -0
- package/skills/dhh-rails-style/SKILL.md +203 -0
- package/skills/dhh-rails-style/references/architecture.md +717 -0
- package/skills/dhh-rails-style/references/controllers.md +323 -0
- package/skills/dhh-rails-style/references/frontend.md +556 -0
- package/skills/dhh-rails-style/references/gems.md +316 -0
- package/skills/dhh-rails-style/references/models.md +388 -0
- package/skills/dhh-rails-style/references/testing.md +354 -0
- package/skills/discord-content/SKILL.md +126 -0
- package/skills/docs-site/SKILL.md +214 -0
- package/skills/drain-labeled-backlog/SKILL.md +203 -0
- package/skills/drain-labeled-backlog/scripts/group-by-area.sh +208 -0
- package/skills/drain-labeled-backlog/workflows/drain-labeled-backlog.workflow.js +433 -0
- package/skills/drain-prs/SKILL.md +143 -0
- package/skills/drain-prs/scripts/triage-prs.sh +192 -0
- package/skills/drain-prs/test/argv-ceiling.test.sh +183 -0
- package/skills/dspy-ruby/SKILL.md +634 -0
- package/skills/dspy-ruby/assets/config-template.rb +359 -0
- package/skills/dspy-ruby/assets/module-template.rb +326 -0
- package/skills/dspy-ruby/assets/signature-template.rb +143 -0
- package/skills/dspy-ruby/references/core-concepts.md +268 -0
- package/skills/dspy-ruby/references/optimization.md +623 -0
- package/skills/dspy-ruby/references/providers.md +335 -0
- package/skills/eval-harness/README.md +194 -0
- package/skills/eval-harness/SKILL.md +157 -0
- package/skills/eval-harness/enums/go-routes.json +13 -0
- package/skills/eval-harness/enums/incident-threshold.json +5 -0
- package/skills/eval-harness/enums/lane.json +5 -0
- package/skills/eval-harness/enums/tool-selection.json +20 -0
- package/skills/eval-harness/enums/triage-levels.json +5 -0
- package/skills/eval-harness/gated-skills.json +34 -0
- package/skills/eval-harness/models.generated.json +5 -0
- package/skills/eval-harness/promptfooconfig-go-routing.yaml +35 -0
- package/skills/eval-harness/promptfooconfig-incident-threshold.yaml +32 -0
- package/skills/eval-harness/promptfooconfig-lane-inference.yaml +33 -0
- package/skills/eval-harness/promptfooconfig-ticket-triage.yaml +34 -0
- package/skills/eval-harness/promptfooconfig-tool-selection.yaml +33 -0
- package/skills/eval-harness/prompts/go-baseline.txt +6 -0
- package/skills/eval-harness/prompts/go-skill.txt +21 -0
- package/skills/eval-harness/prompts/incident-threshold-baseline.txt +6 -0
- package/skills/eval-harness/prompts/incident-threshold-skill.txt +12 -0
- package/skills/eval-harness/prompts/lane-inference-baseline.txt +6 -0
- package/skills/eval-harness/prompts/lane-inference-skill.txt +15 -0
- package/skills/eval-harness/prompts/tool-selection-baseline.txt +19 -0
- package/skills/eval-harness/prompts/tool-selection-skill.txt +22 -0
- package/skills/eval-harness/prompts/triage-baseline.txt +7 -0
- package/skills/eval-harness/prompts/triage-skill.txt +10 -0
- package/skills/eval-harness/scripts/eval-gate.cjs +318 -0
- package/skills/eval-harness/scripts/extract-block.cjs +87 -0
- package/skills/eval-harness/scripts/gate-classification.cjs +28 -0
- package/skills/eval-harness/scripts/gen-models.sh +41 -0
- package/skills/eval-harness/scripts/gen-skill-prompt.cjs +177 -0
- package/skills/eval-harness/scripts/measure-classification.cjs +30 -0
- package/skills/eval-harness/scripts/parse-label.cjs +93 -0
- package/skills/eval-harness/scripts/verdict.cjs +132 -0
- package/skills/eval-harness/tasks/go-routing.jsonl +8 -0
- package/skills/eval-harness/tasks/incident-threshold.jsonl +7 -0
- package/skills/eval-harness/tasks/lane-inference.jsonl +7 -0
- package/skills/eval-harness/tasks/ticket-triage.jsonl +6 -0
- package/skills/eval-harness/tasks/tool-selection.jsonl +15 -0
- package/skills/eval-harness/test/eval-gate.test.sh +132 -0
- package/skills/eval-harness/test/extract-block.test.sh +87 -0
- package/skills/eval-harness/test/gate-classification.test.sh +77 -0
- package/skills/eval-harness/test/gen-models.test.sh +75 -0
- package/skills/eval-harness/test/measure-classification.test.sh +74 -0
- package/skills/eval-harness/test/parse-label.test.sh +129 -0
- package/skills/eval-harness/test/registry-completeness.test.sh +109 -0
- package/skills/eval-harness/test/verdict.test.sh +123 -0
- package/skills/every-style-editor/SKILL.md +142 -0
- package/skills/every-style-editor/references/EVERY_WRITE_STYLE.md +529 -0
- package/skills/feature-tweet/SKILL.md +199 -0
- package/skills/feature-video/SKILL.md +412 -0
- package/skills/feature-video/scripts/check_deps.sh +281 -0
- package/skills/file-todos/SKILL.md +267 -0
- package/skills/file-todos/assets/todo-template.md +166 -0
- package/skills/fix-issue/SKILL.md +278 -0
- package/skills/fix-issue/references/agent-authored-exclusion.md +120 -0
- package/skills/fix-issue/references/exclude-label-jq-snippet.md +97 -0
- package/skills/flag-bootstrap/SETUP.md +142 -0
- package/skills/flag-create/SKILL.md +145 -0
- package/skills/flag-create/scripts/create.sh +273 -0
- package/skills/flag-delete/SKILL.md +167 -0
- package/skills/flag-delete/scripts/delete.sh +288 -0
- package/skills/flag-list/SKILL.md +99 -0
- package/skills/flag-list/scripts/list.sh +275 -0
- package/skills/flag-set-role/SKILL.md +229 -0
- package/skills/flag-set-role/scripts/flip.sh +774 -0
- package/skills/frontend-anti-slop/SKILL.md +106 -0
- package/skills/frontend-anti-slop/references/anti-patterns.md +199 -0
- package/skills/frontend-anti-slop/references/slop-rules.md +65 -0
- package/skills/frontend-anti-slop/scripts/tier1-scan.ts +525 -0
- package/skills/frontend-design/SKILL.md +77 -0
- package/skills/gdpr-gate/NOTICE +136 -0
- package/skills/gdpr-gate/SKILL.md +341 -0
- package/skills/gdpr-gate/references/fields.md +160 -0
- package/skills/gdpr-gate/references/layers/api-layer.md +355 -0
- package/skills/gdpr-gate/references/layers/auth-sessions.md +290 -0
- package/skills/gdpr-gate/references/layers/data-in-transit.md +331 -0
- package/skills/gdpr-gate/references/layers/data-lifecycle.md +341 -0
- package/skills/gdpr-gate/references/layers/frontend.md +249 -0
- package/skills/gdpr-gate/references/layers/testing-seeding.md +256 -0
- package/skills/gdpr-gate/references/leakage-vectors.md +248 -0
- package/skills/gdpr-gate/references/legacy/legal-consent-v1-prose.md +48 -0
- package/skills/gdpr-gate/references/legal-consent.md +140 -0
- package/skills/gdpr-gate/references/non-negotiables.md +60 -0
- package/skills/gdpr-gate/scripts/gdpr-gate.sh +271 -0
- package/skills/gdpr-gate/scripts/notice-frontmatter.sh +337 -0
- package/skills/gdpr-gate/scripts/path-denylist.txt +39 -0
- package/skills/gdpr-gate/scripts/repo-scan.sh +158 -0
- package/skills/gdpr-gate/scripts/vendor-drift-classify.sh +120 -0
- package/skills/gdpr-gate/scripts/vendor-pin-integrity.sh +237 -0
- package/skills/gemini-imagegen/SKILL.md +283 -0
- package/skills/gemini-imagegen/requirements.txt +5 -0
- package/skills/gemini-imagegen/scripts/_error_handling.py +146 -0
- package/skills/gemini-imagegen/scripts/compose_images.py +150 -0
- package/skills/gemini-imagegen/scripts/edit_image.py +137 -0
- package/skills/gemini-imagegen/scripts/gemini_images.py +256 -0
- package/skills/gemini-imagegen/scripts/generate_image.py +147 -0
- package/skills/gemini-imagegen/scripts/multi_turn_chat.py +223 -0
- package/skills/gemini-imagegen/scripts/test_error_handling.py +179 -0
- package/skills/git-worktree/SKILL.md +534 -0
- package/skills/git-worktree/scripts/git-repo-readiness-diag.sh +98 -0
- package/skills/git-worktree/scripts/worktree-manager.sh +4773 -0
- package/skills/git-worktree/test/create-from-origin-main.test.sh +116 -0
- package/skills/git-worktree/test/lease-protects-active.test.sh +1056 -0
- package/skills/git-worktree/test/no-repo-fail-loud.test.sh +88 -0
- package/skills/git-worktree/test/orphan-reaper-honest-count.test.sh +379 -0
- package/skills/git-worktree/test/reap-archive-persistence.test.sh +725 -0
- package/skills/git-worktree/test/stale-lock-sweep.test.sh +176 -0
- package/skills/go/SKILL.md +54 -0
- package/skills/growth/SKILL.md +164 -0
- package/skills/harvest-debt/SKILL.md +78 -0
- package/skills/harvest-debt/scripts/harvest-debt.sh +114 -0
- package/skills/heal-skill/SKILL.md +173 -0
- package/skills/help/SKILL.md +46 -0
- package/skills/incident/NOTICE +39 -0
- package/skills/incident/SKILL.md +395 -0
- package/skills/incident/scripts/dry-run.sh +448 -0
- package/skills/incident/scripts/redact-engine.py +412 -0
- package/skills/incident/scripts/redact-sentinel.sh +44 -0
- package/skills/incident/templates/pir.md +139 -0
- package/skills/incident/test/fixtures/dry-run-incident.json +26 -0
- package/skills/incident/test/fixtures/dry-run-secret-leak.json +26 -0
- package/skills/incident/test/fixtures/legacy-bash-scanner.sh +82 -0
- package/skills/incident/test/fixtures/positive-corpus.md +87 -0
- package/skills/incident/test/redact-sentinel.test.sh +1420 -0
- package/skills/invoice/SKILL.md +206 -0
- package/skills/kb-glossary/SKILL.md +71 -0
- package/skills/kb-glossary/references/glossary-format.md +111 -0
- package/skills/kb-glossary/references/rejected-request-register.md +115 -0
- package/skills/kb-search/SKILL.md +276 -0
- package/skills/kb-search/scripts/kb-search-cache.sh +123 -0
- package/skills/legal-audit/SKILL.md +161 -0
- package/skills/legal-generate/NOTICE +242 -0
- package/skills/legal-generate/SKILL.md +244 -0
- package/skills/legal-generate/references/templates/advisor-agreement/template.md +12 -0
- package/skills/legal-generate/references/templates/business-associate-agreement/template.md +112 -0
- package/skills/legal-generate/references/templates/cookie-notice/template.md +82 -0
- package/skills/legal-generate/references/templates/dpa-global/template.md +439 -0
- package/skills/legal-generate/references/templates/dpa-us/template.md +222 -0
- package/skills/legal-generate/references/templates/employee-offer-letter/template.md +75 -0
- package/skills/legal-generate/references/templates/master-services-agreement/template.md +252 -0
- package/skills/legal-generate/references/templates/mutual-nda/template.md +66 -0
- package/skills/legal-generate/references/templates/one-way-nda/template.md +38 -0
- package/skills/legal-generate/references/templates/privacy-policy-gdpr/template.md +427 -0
- package/skills/legal-generate/references/templates/privacy-policy-us/template.md +313 -0
- package/skills/legal-generate/references/templates/terms-of-use/template.md +75 -0
- package/skills/legal-generate/scripts/strip-vendor-credit.sh +96 -0
- package/skills/linear-fetch/SKILL.md +247 -0
- package/skills/linear-fetch/scripts/assert-no-linear-telemetry.sh +66 -0
- package/skills/linear-fetch/scripts/redact-linear-urls.sh +51 -0
- package/skills/linear-fetch/scripts/render-caller-template.sh +36 -0
- package/skills/linear-fetch/test/assert-no-linear-telemetry.test.sh +113 -0
- package/skills/linear-fetch/test/parity.test.sh +112 -0
- package/skills/linear-fetch/test/persist-safe-integration.test.sh +191 -0
- package/skills/linear-fetch/test/redact-linear-urls.test.sh +226 -0
- package/skills/merge-pr/SKILL.md +670 -0
- package/skills/model-launch-review/SKILL.md +255 -0
- package/skills/model-launch-review/scripts/audit-models.sh +501 -0
- package/skills/one-shot/SKILL.md +316 -0
- package/skills/one-shot/references/token-discipline.md +74 -0
- package/skills/operator-bootstrap/SKILL.md +412 -0
- package/skills/operator-bootstrap/template.sh +439 -0
- package/skills/operator-digest/SKILL.md +287 -0
- package/skills/operator-digest/assets/operator-digest.workflow.yml +175 -0
- package/skills/operator-digest/scripts/digest-scrub.sh +119 -0
- package/skills/operator-digest/scripts/provision-operator-digest-repo.sh +147 -0
- package/skills/operator-rephrase/SKILL.md +98 -0
- package/skills/pencil-setup/SKILL.md +201 -0
- package/skills/pencil-setup/scripts/check_deps.sh +556 -0
- package/skills/pencil-setup/scripts/copy_adapter.sh +80 -0
- package/skills/pencil-setup/scripts/package-lock.json +1172 -0
- package/skills/pencil-setup/scripts/package.json +14 -0
- package/skills/pencil-setup/scripts/pencil-error-enrichment.mjs +21 -0
- package/skills/pencil-setup/scripts/pencil-mcp-adapter.mjs +733 -0
- package/skills/pencil-setup/scripts/pencil-response-classify.mjs +38 -0
- package/skills/pencil-setup/scripts/pencil-save-gate.mjs +26 -0
- package/skills/pencil-setup/scripts/sanitize-filename.mjs +25 -0
- package/skills/plan/SKILL.md +1140 -0
- package/skills/plan/references/differential-migration-testing.md +185 -0
- package/skills/plan/references/plan-community-discovery.md +42 -0
- package/skills/plan/references/plan-founder-check.md +137 -0
- package/skills/plan/references/plan-functional-overlap.md +25 -0
- package/skills/plan/references/plan-host-fanout.md +51 -0
- package/skills/plan/references/plan-issue-templates.md +779 -0
- package/skills/plan/references/plan-network-outage-checklist.md +131 -0
- package/skills/plan/references/plan-scope-check.md +126 -0
- package/skills/plan/references/plan-sharp-edges.md +331 -0
- package/skills/plan/references/plan-signoff-lifecycle.md +12 -0
- package/skills/plan/references/webapp-cwv-observability.md +143 -0
- package/skills/plan-review/SKILL.md +91 -0
- package/skills/plan-review/lib/named-panel.mjs +58 -0
- package/skills/plan-review/workflows/plan-review.workflow.js +477 -0
- package/skills/postmerge/SKILL.md +642 -0
- package/skills/postmerge/references/deploy-status-debugging.md +141 -0
- package/skills/postmerge/references/graceful-degradation.md +20 -0
- package/skills/postmerge/references/sentry-error-count-delta.md +137 -0
- package/skills/preflight/SKILL.md +1553 -0
- package/skills/preflight/references/check-13-founder-check.md +286 -0
- package/skills/preflight/scripts/founder-check.py +1640 -0
- package/skills/preflight/scripts/parse-form-a.awk +107 -0
- package/skills/preflight/scripts/probe-verb-gate.sh +108 -0
- package/skills/product-roadmap/SKILL.md +357 -0
- package/skills/product-roadmap/scripts/roadmap-reconcile.sh +327 -0
- package/skills/provision-cloudflare/SKILL.md +67 -0
- package/skills/provision-cloudflare/scripts/provision-cloudflare.sh +271 -0
- package/skills/provision-doppler/SKILL.md +58 -0
- package/skills/provision-doppler/scripts/provision-doppler.sh +358 -0
- package/skills/provision-github/SKILL.md +59 -0
- package/skills/provision-github/scripts/provision-github.sh +300 -0
- package/skills/provision-hetzner/SKILL.md +76 -0
- package/skills/provision-hetzner/scripts/provision-hetzner.sh +262 -0
- package/skills/provision-hetzner/test/fixture/knowledge-base/legal/tenant-dpa-register.md +26 -0
- package/skills/provision-hetzner/test/fixture/provision-hetzner-dry-run.golden +34 -0
- package/skills/provision-hetzner/test/provision-hetzner-characterization.test.sh +227 -0
- package/skills/qa/SKILL.md +263 -0
- package/skills/qa/references/browser-preflight.md +22 -0
- package/skills/questionnaire-generate/SKILL.md +328 -0
- package/skills/questionnaire-generate/references/questionnaire.template +60 -0
- package/skills/rclone/SKILL.md +162 -0
- package/skills/rclone/scripts/check_setup.sh +63 -0
- package/skills/release-announce/SKILL.md +65 -0
- package/skills/release-docs/SKILL.md +92 -0
- package/skills/reproduce-bug/SKILL.md +358 -0
- package/skills/resolve-debt/SKILL.md +85 -0
- package/skills/resolve-debt/scripts/resolve-debt.py +584 -0
- package/skills/resolve-parallel/SKILL.md +45 -0
- package/skills/resolve-parallel/workflows/resolve-parallel.workflow.js +415 -0
- package/skills/resolve-pr-parallel/SKILL.md +68 -0
- package/skills/resolve-pr-parallel/workflows/resolve-pr-parallel.workflow.js +419 -0
- package/skills/resolve-todo-parallel/SKILL.md +57 -0
- package/skills/resolve-todo-parallel/workflows/resolve-todo-parallel.workflow.js +398 -0
- package/skills/review/SKILL.md +1620 -0
- package/skills/review/references/anti-slop-scanner-hook.md +40 -0
- package/skills/review/references/defect-classes-migrations.md +9 -0
- package/skills/review/references/generated-input-oracle-surfaces.md +15 -0
- package/skills/review/references/review-e2e-testing.md +116 -0
- package/skills/review/references/review-evidence-comment.md +36 -0
- package/skills/review/references/review-todo-structure.md +165 -0
- package/skills/review/references/risk-tier-and-fix-rounds.md +97 -0
- package/skills/review/references/semgrep-custom-rules.yaml +174 -0
- package/skills/review/references/ultrathink-phases.md +98 -0
- package/skills/review/references/wfs.md +6 -0
- package/skills/review/scripts/emit-review-trailer.sh +499 -0
- package/skills/review/scripts/ensure-semgrep.sh +99 -0
- package/skills/review/scripts/fix-round-seats.sh +197 -0
- package/skills/review/test/emit-review-trailer.test.sh +387 -0
- package/skills/review/test/ensure-semgrep.test.sh +111 -0
- package/skills/review/test/fix-round-seats.test.sh +237 -0
- package/skills/review/workflows/README.md +147 -0
- package/skills/review/workflows/review.workflow.js +667 -0
- package/skills/schedule/SKILL.md +796 -0
- package/skills/seo-aeo/SKILL.md +139 -0
- package/skills/seo-aeo/scripts/validate-csp.sh +233 -0
- package/skills/seo-aeo/scripts/validate-seo.sh +262 -0
- package/skills/ship/SKILL.md +3023 -0
- package/skills/ship/references/adr-ordinal-collision.md +47 -0
- package/skills/ship/references/affected-gate-contract.md +115 -0
- package/skills/ship/references/ci-run-diagnosis.md +29 -0
- package/skills/ship/references/ci-workflow-authoring.md +68 -0
- package/skills/ship/references/cleanup-merged-archival.md +39 -0
- package/skills/ship/references/counsel-review-clo-attestation.md +38 -0
- package/skills/ship/references/cto-ruling-armed-push.md +5 -0
- package/skills/ship/references/decision-challenges-render.md +71 -0
- package/skills/ship/references/delegated-act-authoring-form.md +7 -0
- package/skills/ship/references/detached-battery-verdict.md +13 -0
- package/skills/ship/references/epic-close-sweep.md +62 -0
- package/skills/ship/references/feature-tweet-draft.md +42 -0
- package/skills/ship/references/followthrough-issue-body-template.md +47 -0
- package/skills/ship/references/followthrough-stub-template.sh +92 -0
- package/skills/ship/references/merge-queue-dequeue.md +39 -0
- package/skills/ship/references/operator-holds.md +179 -0
- package/skills/ship/references/pipeline-tally-render.md +43 -0
- package/skills/ship/references/pr-overlap.md +153 -0
- package/skills/ship/references/queue-mode.md +7 -0
- package/skills/ship/references/ready-run-wait.md +36 -0
- package/skills/ship/references/red-on-main-quarantine.md +110 -0
- package/skills/ship/references/register-update-pr-pattern.md +88 -0
- package/skills/ship/references/settle-then-admin-merge.md +130 -0
- package/skills/ship/references/supabase-column-probe.md +13 -0
- package/skills/ship/scripts/auto-close-scan.sh +76 -0
- package/skills/ship/scripts/battery-owed.sh +403 -0
- package/skills/ship/scripts/hold-gate-eval.sh +410 -0
- package/skills/ship/scripts/net-issue-flow.sh +665 -0
- package/skills/ship/scripts/pr-overlap-preflight.sh +483 -0
- package/skills/ship/scripts/ship-pir-action-items-gate.sh +246 -0
- package/skills/ship/scripts/technical-ruling-gate.sh +444 -0
- package/skills/skill-creator/SKILL.md +299 -0
- package/skills/skill-creator/references/api-security.md +248 -0
- package/skills/skill-creator/references/authoring-levers.md +110 -0
- package/skills/skill-creator/references/be-clear-and-direct.md +587 -0
- package/skills/skill-creator/references/best-practices.md +431 -0
- package/skills/skill-creator/references/common-patterns.md +639 -0
- package/skills/skill-creator/references/core-principles.md +193 -0
- package/skills/skill-creator/references/executable-code.md +188 -0
- package/skills/skill-creator/references/iteration-and-testing.md +509 -0
- package/skills/skill-creator/references/official-spec.md +193 -0
- package/skills/skill-creator/references/recommended-structure.md +180 -0
- package/skills/skill-creator/references/skill-structure.md +396 -0
- package/skills/skill-creator/references/using-scripts.md +122 -0
- package/skills/skill-creator/references/using-templates.md +120 -0
- package/skills/skill-creator/references/workflows-and-validation.md +541 -0
- package/skills/skill-creator/scripts/init_skill.py +322 -0
- package/skills/skill-creator/scripts/package_skill.py +110 -0
- package/skills/skill-creator/scripts/quick_validate.py +64 -0
- package/skills/skill-creator/templates/router-skill.md +73 -0
- package/skills/skill-creator/templates/simple-skill.md +34 -0
- package/skills/skill-creator/workflows/add-reference.md +96 -0
- package/skills/skill-creator/workflows/add-script.md +104 -0
- package/skills/skill-creator/workflows/add-template.md +83 -0
- package/skills/skill-creator/workflows/add-workflow.md +128 -0
- package/skills/skill-creator/workflows/audit-skill.md +159 -0
- package/skills/skill-creator/workflows/create-domain-expertise-skill.md +623 -0
- package/skills/skill-creator/workflows/create-new-skill.md +207 -0
- package/skills/skill-creator/workflows/get-guidance.md +134 -0
- package/skills/skill-creator/workflows/upgrade-to-router.md +170 -0
- package/skills/skill-creator/workflows/verify-skill.md +226 -0
- package/skills/skill-security-scan/SKILL.md +145 -0
- package/skills/skill-security-scan/references/disclaimer.md +21 -0
- package/skills/skill-security-scan/references/first-party-allowlist.yaml +38 -0
- package/skills/skill-security-scan/references/override-artifact-schema.json +68 -0
- package/skills/skill-security-scan/references/override-mechanism.md +171 -0
- package/skills/skill-security-scan/references/redirect-domains.yaml +27 -0
- package/skills/skill-security-scan/references/regex-patterns.md +136 -0
- package/skills/skill-security-scan/references/rules/code-exec.yaml +70 -0
- package/skills/skill-security-scan/references/rules/filesystem-boundary.yaml +29 -0
- package/skills/skill-security-scan/references/rules/manifest.yaml +44 -0
- package/skills/skill-security-scan/references/rules/supply-chain.yaml +36 -0
- package/skills/skill-security-scan/references/scan-meta-schema.json +63 -0
- package/skills/skill-security-scan/references/test-fixtures/clean-soleur-style.skill.md +23 -0
- package/skills/skill-security-scan/references/test-fixtures/clean-third-party.skill.md +26 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-codeexec.skill.md +23 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-curl-pipe-bash.skill.md +69 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-prompt-injection.skill.md +17 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-telemetry-beacon.skill.md +23 -0
- package/skills/skill-security-scan/references/typosquat-targets.yaml +65 -0
- package/skills/skill-security-scan/scripts/check-codeexec.sh +41 -0
- package/skills/skill-security-scan/scripts/check-filesystem-boundary.sh +19 -0
- package/skills/skill-security-scan/scripts/check-prompt-injection.sh +95 -0
- package/skills/skill-security-scan/scripts/check-supply-chain.sh +219 -0
- package/skills/skill-security-scan/scripts/check-telemetry-surface.sh +126 -0
- package/skills/skill-security-scan/scripts/lib.sh +197 -0
- package/skills/skill-security-scan/scripts/parse-override.sh +231 -0
- package/skills/skill-security-scan/scripts/run-scan.sh +342 -0
- package/skills/skill-security-scan/scripts/run-self-test.sh +95 -0
- package/skills/skill-security-scan/test/argv-ceiling.test.sh +180 -0
- package/skills/skill-security-scan/test/meta-dir-reap.test.sh +411 -0
- package/skills/skill-security-scan/test/parse-override.test.sh +607 -0
- package/skills/skill-security-scan/test/redact-pii-ghs-shape.test.sh +251 -0
- package/skills/social-distribute/SKILL.md +485 -0
- package/skills/spec-templates/SKILL.md +274 -0
- package/skills/sync/SKILL.md +49 -0
- package/skills/test-browser/SKILL.md +464 -0
- package/skills/test-browser/references/feature-map.md +161 -0
- package/skills/test-fix-loop/SKILL.md +159 -0
- package/skills/test-fix-loop/references/determinism-checklist.md +154 -0
- package/skills/triage/SKILL.md +402 -0
- package/skills/trigger-cron/SKILL.md +136 -0
- package/skills/trigger-cron/scripts/trigger.sh +198 -0
- package/skills/user-set-role/SKILL.md +147 -0
- package/skills/user-set-role/scripts/set-role.sh +218 -0
- package/skills/user-story-writer/SKILL.md +77 -0
- package/skills/ux-audit/SKILL.md +251 -0
- package/skills/ux-audit/references/finding.schema.json +62 -0
- package/skills/ux-audit/references/route-list.yaml +95 -0
- package/skills/ux-audit/scripts/bot-fixture.ts +270 -0
- package/skills/ux-audit/scripts/bot-signin.ts +135 -0
- package/skills/ux-audit/scripts/dedup-hash.ts +46 -0
- package/skills/verification-scaffold/SKILL.md +73 -0
- package/skills/verification-scaffold/references/gitleaks.toml.template +13 -0
- package/skills/verification-scaffold/references/guardrail-audit.md +156 -0
- package/skills/verification-scaffold/references/guards/block-stash-hook.sh.template +72 -0
- package/skills/verification-scaffold/references/guards/commit-on-main-hook.sh.template +82 -0
- package/skills/verification-scaffold/references/guards/orphan-test-check.sh.template +102 -0
- package/skills/verification-scaffold/references/guards/precommit-guard.sh.template +115 -0
- package/skills/verification-scaffold/references/guards/secret-scan-hook.sh.template +176 -0
- package/skills/verification-scaffold/references/guards/verification-gates.yml.template +49 -0
- package/skills/verification-scaffold/references/guards/wrapper-boundary.sh.template +47 -0
- package/skills/verification-scaffold/references/guards.tsv +21 -0
- package/skills/verification-scaffold/references/verification-readme.template +55 -0
- package/skills/verification-scaffold/scripts/verification-scaffold.sh +785 -0
- package/skills/verification-scaffold/test/bite-proof.test.sh +159 -0
- package/skills/verification-scaffold/test/generator.test.sh +319 -0
- package/skills/verification-scaffold/test/manifest.test.sh +144 -0
- package/skills/work/SKILL.md +1480 -0
- package/skills/work/references/end-of-work-resume.md +7 -0
- package/skills/work/references/hook-test-main-cwd.md +24 -0
- package/skills/work/references/key-principles.md +83 -0
- package/skills/work/references/property-testing.md +122 -0
- package/skills/work/references/sql-security-definer-rpc-scaffold.sql +78 -0
- package/skills/work/references/work-agent-teams.md +97 -0
- package/skills/work/references/work-lifecycle-parallel.md +147 -0
- package/skills/work/references/work-lockfile-bumps.md +97 -0
- package/skills/work/references/work-scratch-sandboxes.md +212 -0
- package/skills/work/references/work-subagent-fanout.md +61 -0
- package/skills/work/references/work-test-harness-sharp-edges.md +73 -0
- package/skills/xcode-test/SKILL.md +360 -0
|
@@ -0,0 +1,1620 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Guard-contract suite for the Playwright MCP browser lifetime (feat-one-shot-playwright-mcp-stability).
|
|
3
|
+
#
|
|
4
|
+
# The defect: a plugin Stop hook SIGTERMed every Playwright Chrome on the host at the end of
|
|
5
|
+
# EVERY assistant turn (Stop fires per turn, not per session), and the project .mcp.json
|
|
6
|
+
# launch string pkill -9'd the proxy, the server and Chrome of every sibling session. The
|
|
7
|
+
# fix removes both killers and replaces the launch-time reaper with a kernel flock slot
|
|
8
|
+
# lease (scripts/playwright-mcp-profile-slot.sh). This suite pins the properties:
|
|
9
|
+
#
|
|
10
|
+
# Guard 1 -- no hook registered in plugins/soleur/hooks/hooks.json or .claude/settings.json and no
|
|
11
|
+
# MCP launch string names a browser with a PID-selecting tool (population derived by
|
|
12
|
+
# parsing the registries at test time, plus one hop of sourced scripts), and the
|
|
13
|
+
# plugin hook scripts, executed under a pgrep/pkill PATH shim, leave a live decoy alone.
|
|
14
|
+
# This is a text-and-shim property of THIS tree's registries: other plugins, older
|
|
15
|
+
# checkouts, the installed plugin copy and tools the shim does not cover are outside it.
|
|
16
|
+
# Project hooks (.claude/hooks) are scanned, never executed: running them is not safe;
|
|
17
|
+
# Guard 2 -- a launch claims a slot no live launch holds and kills nothing (the real
|
|
18
|
+
# launch string, executed under a scratch HOME with an npx shim);
|
|
19
|
+
# Guard 3 -- both registrations carry the ping-timeout setting (an INERT latent-hazard
|
|
20
|
+
# guard on stdio, not the fix) and the plugin registration carries
|
|
21
|
+
# --chromium-fallback.
|
|
22
|
+
#
|
|
23
|
+
# Verification hygiene (mirrors playwright-mcp-redact-proxy.test.sh): ok/bad helpers, an
|
|
24
|
+
# instrument self-test and a helper control that must REJECT (for every verdict-owning helper,
|
|
25
|
+
# including the inline verdicts of the mutant helpers), mutants asserted to have LANDED against a
|
|
26
|
+
# pristine copy, an anti-vacuity floor bound adjacent to the floor block and reported with printf +
|
|
27
|
+
# exit (never through the helpers it backstops).
|
|
28
|
+
#
|
|
29
|
+
# SAFETY. No row here terminates a process this suite did not start. Every decoy, holder
|
|
30
|
+
# and sleeper is recorded in $OWN when it is spawned and only those pids are ever signalled.
|
|
31
|
+
# The old hook text is executed ONLY under a scratch PATH whose pgrep/pidof print the decoy
|
|
32
|
+
# pid and nothing else, and whose pkill/killall signal the decoy and nothing else (ps, fuser and lsof
|
|
33
|
+
# print nothing). Every mutated pkill pattern embeds the unique scratch HOME, so it cannot select a
|
|
34
|
+
# real process. The slot script only ever runs with HOME pointed at a scratch directory.
|
|
35
|
+
# The suite's OWN fixtures must not match the retired hook's pattern (chrome.*--remote-debugging-pipe): a host that still
|
|
36
|
+
# has the old Stop hook installed kills any process of that shape within seconds, with no suite running, and a Guard 1
|
|
37
|
+
# decoy lost that way reads as a dead scanner. The shim selects by PID, so the Guard 1 decoys carry an argv the old pattern
|
|
38
|
+
# cannot match (start_g1_decoy refuses one that does); the Guard 2 decoys carry no --remote-debugging-pipe either.
|
|
39
|
+
# Known gap, by design: no decoy has the production Chrome shape (--remote-debugging-pipe), so a killer keyed on it is not
|
|
40
|
+
# modelled here; the static scan and the retired pattern itself are the cover for that shape.
|
|
41
|
+
set -Eeuo pipefail
|
|
42
|
+
trap 'printf "[ABORT] line %s: %s (rc=%s)\n" "$LINENO" "$BASH_COMMAND" "$?" >&2' ERR
|
|
43
|
+
export TMPDIR="${TMPDIR:-/var/tmp}"
|
|
44
|
+
export PLAYWRIGHT_MCP_PROXY_GRACE_S=1
|
|
45
|
+
|
|
46
|
+
# Refuses an empty, relative, root or synthetic-fs fixture dir (byte-identical copy; the
|
|
47
|
+
# fixture-dir-operand-assert suite pins every tracked copy).
|
|
48
|
+
assert_fixture_dir() {
|
|
49
|
+
case "${1-}" in
|
|
50
|
+
"") printf 'FATAL: fixture dir is EMPTY; git -C "" would operate on %s\n' "$PWD" >&2; exit 2 ;;
|
|
51
|
+
*/../*|*/..) printf 'FATAL: fixture dir %s contains ..; refusing\n' "$1" >&2; exit 2 ;;
|
|
52
|
+
/proc/*|/sys/*|/dev/*) printf 'FATAL: fixture dir %s is a synthetic-fs path; refusing\n' "$1" >&2; exit 2 ;;
|
|
53
|
+
/|//|/.) printf 'FATAL: fixture dir resolves to the filesystem root; refusing\n' >&2; exit 2 ;;
|
|
54
|
+
/*) : ;;
|
|
55
|
+
*) printf 'FATAL: fixture dir %s is RELATIVE; refusing\n' "$1" >&2; exit 2 ;;
|
|
56
|
+
esac
|
|
57
|
+
}
|
|
58
|
+
REPO_ROOT="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)"
|
|
59
|
+
assert_fixture_dir "$REPO_ROOT"
|
|
60
|
+
SKILL_REL=plugins/soleur/skills/agent-browser
|
|
61
|
+
SLOT_REL="$SKILL_REL/scripts/playwright-mcp-profile-slot.sh"
|
|
62
|
+
PROXY_REL_PATH="$SKILL_REL/scripts/playwright-mcp-redact-proxy.py"
|
|
63
|
+
REDACTOR_REL="$SKILL_REL/scripts/redact-a11y-snapshot.py"
|
|
64
|
+
LOCKNAME=.pwslot.lock
|
|
65
|
+
WORK="$(mktemp -d -t lifetime-suite.XXXXXXXX)"
|
|
66
|
+
assert_fixture_dir "$WORK"
|
|
67
|
+
OWN="$WORK/own-pids"; : > "$OWN"
|
|
68
|
+
own() { printf '%s\n' "$1" >> "$OWN"; }
|
|
69
|
+
# Only pids this suite spawned are ever recorded in $OWN, and each is re-verified before the signal: a direct child
|
|
70
|
+
# of this shell, or (the npx shim's `sleep 300`) a process whose argv is exactly that and whose pid file lives in $WORK.
|
|
71
|
+
# reap_list <file of pids>: signal each recorded pid that is a direct child of this shell
|
|
72
|
+
reap_list() {
|
|
73
|
+
local p pp
|
|
74
|
+
while read -r p; do
|
|
75
|
+
[[ -n "$p" ]] || continue
|
|
76
|
+
pp="$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ' || true)"
|
|
77
|
+
if [[ "$pp" == "$$" ]]; then kill -KILL "$p" 2>/dev/null || true; fi
|
|
78
|
+
done < "$1"
|
|
79
|
+
}
|
|
80
|
+
reap_own() {
|
|
81
|
+
local p a
|
|
82
|
+
reap_list "$OWN"
|
|
83
|
+
while read -r p; do
|
|
84
|
+
[[ -n "$p" ]] || continue
|
|
85
|
+
a="$(ps -o args= -p "$p" 2>/dev/null || true)"
|
|
86
|
+
if [[ "$a" == "sleep 300" ]]; then kill -KILL "$p" 2>/dev/null || true; fi
|
|
87
|
+
done < <(cat "$WORK"/g2/*.jsonl.pid 2>/dev/null || true)
|
|
88
|
+
}
|
|
89
|
+
trap 'reap_own; rm -rf "$WORK"' EXIT
|
|
90
|
+
|
|
91
|
+
pass=0; fail=0; cases=0; mutants_declared=0; red_rows=0
|
|
92
|
+
ok() { printf 'ok - %s\n' "$1"; pass=$((pass + 1)); }
|
|
93
|
+
bad() { printf 'FAIL - %s\n' "$1"; fail=$((fail + 1)); }
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Instrument self-test (runs BEFORE any real row): both verdict helpers must
|
|
97
|
+
# move their counters or the suite refuses to continue.
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
_p0=$pass; _f0=$fail
|
|
100
|
+
ok "instrument self-test: ok() increments"
|
|
101
|
+
bad "instrument self-test: bad() increments (EXPECTED, not a real failure)"
|
|
102
|
+
if [[ $pass -ne $((_p0 + 1)) || $fail -ne $((_f0 + 1)) ]]; then
|
|
103
|
+
printf 'INSTRUMENT BROKEN: ok()/bad() did not both move\n' >&2; exit 1
|
|
104
|
+
fi
|
|
105
|
+
pass=0; fail=0; cases=0
|
|
106
|
+
|
|
107
|
+
# assert_true <label> <shell-test...> — one observable, one verdict
|
|
108
|
+
assert_true() {
|
|
109
|
+
local label="$1"; shift
|
|
110
|
+
cases=$((cases + 1))
|
|
111
|
+
if "$@"; then ok "$label"; else bad "$label"; fi
|
|
112
|
+
}
|
|
113
|
+
# red <label> <shell-test...> — a mutation row is ok when the DEFECT is observable
|
|
114
|
+
red() { local label="$1"; shift; cases=$((cases + 1)); red_rows=$((red_rows + 1)); if "$@"; then ok "$label"; else bad "$label — mutant survived"; fi; }
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Process helpers (every pid they touch was spawned by this suite)
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# alive <pid>: exists and is not a zombie
|
|
120
|
+
alive() { local s; s="$(ps -o stat= -p "${1:-0}" 2>/dev/null || true)"; [[ -n "$s" && "$s" != Z* ]]; }
|
|
121
|
+
wait_dead() { # <pid> <seconds>
|
|
122
|
+
local i=0
|
|
123
|
+
while alive "$1" && [[ $i -lt $(( $2 * 10 )) ]]; do sleep 0.1; i=$((i + 1)); done
|
|
124
|
+
if alive "$1"; then return 1; fi
|
|
125
|
+
return 0
|
|
126
|
+
}
|
|
127
|
+
# lock_held <lockfile>: a DIFFERENT open file description cannot take the lock (rc 75 = conflict)
|
|
128
|
+
lock_held() {
|
|
129
|
+
local rc=0
|
|
130
|
+
[[ -e "$1" ]] || return 1
|
|
131
|
+
flock -n -E 75 "$1" true || rc=$?
|
|
132
|
+
[[ $rc -eq 75 ]]
|
|
133
|
+
}
|
|
134
|
+
lock_free() { if [[ -e "$1" ]] && flock -n "$1" true; then return 0; fi; return 1; }
|
|
135
|
+
# landed <pristine> <mutated>: the mutation changed the tree
|
|
136
|
+
landed() { ! diff -rq "$1" "$2" >/dev/null 2>&1; }
|
|
137
|
+
now_ms() { printf '%s' "$(( ${EPOCHREALTIME/./} / 1000 ))"; }
|
|
138
|
+
# Spawned in the CURRENT shell (a $(...) subshell would orphan them, and reap_own only signals direct children); the pid
|
|
139
|
+
# lands in SPAWNED. A decoy's argv looks like the Chrome a pattern-reaper would select.
|
|
140
|
+
SPAWNED=""
|
|
141
|
+
start_decoy() { exec_a="$1"; ( exec -a "$exec_a" sleep 300 ) >/dev/null 2>&1 & SPAWNED=$!; own "$SPAWNED"; disown "$SPAWNED"; }
|
|
142
|
+
start_sleeper() { sleep 300 >/dev/null 2>&1 & SPAWNED=$!; own "$SPAWNED"; disown "$SPAWNED"; }
|
|
143
|
+
# the retired Stop hook's own selector (a PID-selecting pattern kill of every pipe-driven Chrome)
|
|
144
|
+
old_pattern_selects() { [[ "$1" =~ chrome.*--remote-debugging-pipe ]]; }
|
|
145
|
+
proc_argv() { tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null || true; }
|
|
146
|
+
# start_g1_decoy <tag>: a Guard 1 decoy whose argv the retired hook's pattern cannot match (the shim finds it by pid); sets SPAWNED
|
|
147
|
+
start_g1_decoy() {
|
|
148
|
+
local i a
|
|
149
|
+
start_decoy "sleep-decoy-$$-$1"
|
|
150
|
+
for i in $(seq 1 60); do a="$(proc_argv "$SPAWNED")"; if [[ "$a" == "sleep-decoy-$$-$1 "* ]]; then break; fi; sleep 0.05; done
|
|
151
|
+
if [[ "$a" != "sleep-decoy-$$-$1 "* ]] || old_pattern_selects "$a"; then printf 'HARNESS BROKEN: the Guard 1 decoy %s does not carry its own argv, or carries one the retired Stop hook would kill on its own (%s)\n' "$1" "$a" >&2; exit 1; fi
|
|
152
|
+
}
|
|
153
|
+
# a decoy that holds <file> open (fd 7) and carries <argv>: the shape a file-handle-selecting killer targets
|
|
154
|
+
start_holder() { local a="$1" f="$2"; ( exec 7> "$f"; exec -a "$a" sleep 300 ) >/dev/null 2>&1 & SPAWNED=$!; own "$SPAWNED"; disown "$SPAWNED"; }
|
|
155
|
+
# a process whose comm really is "chrome" (a symlink to sleep): the only way a pid probe reads a Chrome-like comm
|
|
156
|
+
CHROMEBIN="$WORK/chromebin"; mkdir -p "$CHROMEBIN"; ln -sf "$(command -v sleep)" "$CHROMEBIN/chrome"
|
|
157
|
+
start_comm_owner() { ln -sf "$(command -v sleep)" "$CHROMEBIN/$1"; "$CHROMEBIN/$1" 300 >/dev/null 2>&1 & SPAWNED=$!; own "$SPAWNED"; disown "$SPAWNED"; }
|
|
158
|
+
start_chrome_owner() { start_comm_owner chrome; }
|
|
159
|
+
# hold_lock <profile-dir> <seconds> [owner-pid]: a holder (own child, no-fork flock) on <dir>/.pwslot.lock; the owner file records [owner-pid]
|
|
160
|
+
hold_lock() {
|
|
161
|
+
local d="$1" secs="$2" i
|
|
162
|
+
assert_fixture_dir "$d"
|
|
163
|
+
mkdir -p "$d"; : > "$d/$LOCKNAME"
|
|
164
|
+
if [[ -n "${3:-}" ]]; then printf '%s\n' "$3" > "$d/.pwslot.owner"; fi
|
|
165
|
+
flock -F "$d/$LOCKNAME" sleep "$secs" >/dev/null 2>&1 & SPAWNED=$!; own "$SPAWNED"; disown "$SPAWNED"
|
|
166
|
+
for i in $(seq 1 50); do if lock_held "$d/$LOCKNAME"; then break; fi; sleep 0.1; done
|
|
167
|
+
}
|
|
168
|
+
HOST_NOW="$(uname -n)"
|
|
169
|
+
RN_PROFS=""; RN_RAN=0; RN_OVERLAP=0; RN_MS=0 # the run_n result variables (defined here so the helper control can drive the verdict helpers below)
|
|
170
|
+
# one launch ran and landed on <profile>
|
|
171
|
+
prof_is() { [[ "$RN_RAN" -eq 1 && "$(head -n1 <<< "$RN_PROFS")" == "$1" ]]; }
|
|
172
|
+
# <profile-dir>/SingletonLock is a symlink naming <target>
|
|
173
|
+
link_is() { [[ -L "$1/SingletonLock" && "$(readlink "$1/SingletonLock")" == "$2" ]]; }
|
|
174
|
+
no_link() { [[ ! -e "$1/SingletonLock" && ! -L "$1/SingletonLock" ]]; }
|
|
175
|
+
mode_is() { [[ "$(stat -c %a "$1" 2>/dev/null)" == "$2" ]]; }
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Guard 1 scanner (python, written once). Population is DERIVED by parsing the
|
|
179
|
+
# files at test time: every command of every event of plugins/soleur/hooks/hooks.json and of
|
|
180
|
+
# .claude/settings.json, the script file each one invokes, one hop of scripts those scripts
|
|
181
|
+
# source or run, the args of .mcp.json and plugins/soleur/.mcp.json, and every script those launch
|
|
182
|
+
# strings source.
|
|
183
|
+
# rc 0 clean | 1 a browser/MCP kill by name or pattern was found | 3 population below a floor
|
|
184
|
+
# Modes: scan (default) | list-exec (the scripts the dynamic rows execute under the shim)
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
HELP="$WORK/helpers"; mkdir -p "$HELP"
|
|
187
|
+
cat > "$HELP/g1_scan.py" <<'PY'
|
|
188
|
+
import json, os, re, sys
|
|
189
|
+
|
|
190
|
+
root = sys.argv[1]
|
|
191
|
+
floor_vals = [int(x) for x in sys.argv[2:6]]
|
|
192
|
+
mode = sys.argv[6] if len(sys.argv) > 6 else "scan"
|
|
193
|
+
FLOOR_NAMES = [("commands", "G1_FLOOR_CMDS"), ("scripts", "G1_FLOOR_SCRIPTS"), ("mcp", "G1_FLOOR_MCP"), ("sourced", "G1_FLOOR_SOURCED")]
|
|
194
|
+
BROWSER = re.compile(r"chrom|playwright|mcp|remote-debugging|user-data-dir|headless_shell", re.I)
|
|
195
|
+
SELECTORS = re.compile(r"\b(pkill|killall|pgrep|pidof)\b")
|
|
196
|
+
KILLISH = re.compile(r"(?<![\w-])kill(?![\w-])")
|
|
197
|
+
SCRIPT_TOKEN = re.compile(r"[^\s\"']+\.(?:sh|py|js|mjs|cjs|ts)\b")
|
|
198
|
+
HOP_TOKEN = re.compile(r"(?:^|[;&|(\s])(?:\.|source|bash|sh|exec)\s+[\"']?([^\s;&|\"'<>)]+\.(?:sh|py))")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def reasons_for(text):
|
|
202
|
+
"""(tool, segment) pairs where a process is selected by a browser/MCP name or pattern and killed."""
|
|
203
|
+
found = []
|
|
204
|
+
text = re.sub(r"\\\n[ \t]*", " ", text) # a backslash-continued command is one command
|
|
205
|
+
has_kill = bool(KILLISH.search(text))
|
|
206
|
+
for seg in re.split(r"[;\n]|&&|\|\||\|", text):
|
|
207
|
+
norm = re.sub(r"\[(\w)\]", r"\1", seg) # the [c]hrome self-match trick hides the name
|
|
208
|
+
if not BROWSER.search(norm):
|
|
209
|
+
continue
|
|
210
|
+
m = SELECTORS.search(norm)
|
|
211
|
+
if m and (m.group(1) in ("pkill", "killall") or has_kill):
|
|
212
|
+
found.append((m.group(1), norm.strip()[:90]))
|
|
213
|
+
elif re.search(r"\bgrep\b", norm) and has_kill:
|
|
214
|
+
found.append(("grep", norm.strip()[:90]))
|
|
215
|
+
return found
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def load_json(path):
|
|
219
|
+
try:
|
|
220
|
+
return json.load(open(path))
|
|
221
|
+
except Exception:
|
|
222
|
+
return {}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def code_of(path):
|
|
226
|
+
return "\n".join(l for l in open(path, errors="replace").read().splitlines() if not l.lstrip().startswith("#"))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
plugin_root = os.path.join(root, "plugins/soleur")
|
|
230
|
+
VARS = [
|
|
231
|
+
('"$CLAUDE_PROJECT_DIR"', root), ('"${CLAUDE_PROJECT_DIR}"', root), ("${CLAUDE_PROJECT_DIR}", root), ("$CLAUDE_PROJECT_DIR", root),
|
|
232
|
+
('"${CLAUDE_PLUGIN_ROOT}"', plugin_root), ("${CLAUDE_PLUGIN_ROOT}", plugin_root), ("$CLAUDE_PLUGIN_ROOT", plugin_root),
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def expand(s):
|
|
237
|
+
for k, v in VARS:
|
|
238
|
+
s = s.replace(k, v)
|
|
239
|
+
return s
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def commands_of(path):
|
|
243
|
+
out = []
|
|
244
|
+
for event, groups in (load_json(path).get("hooks") or {}).items():
|
|
245
|
+
for g in groups or []:
|
|
246
|
+
for h in g.get("hooks", []):
|
|
247
|
+
if h.get("type") == "command" and isinstance(h.get("command"), str):
|
|
248
|
+
out.append((event, h["command"]))
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def scripts_of(commands):
|
|
253
|
+
found = {}
|
|
254
|
+
for _event, cmd in commands:
|
|
255
|
+
for tok in SCRIPT_TOKEN.findall(expand(cmd)):
|
|
256
|
+
p = tok if os.path.isabs(tok) else os.path.join(root, tok)
|
|
257
|
+
if os.path.isfile(p):
|
|
258
|
+
found[os.path.realpath(p)] = p
|
|
259
|
+
return found
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def resolve_hop(tok, script):
|
|
263
|
+
sdir = os.path.dirname(script)
|
|
264
|
+
names = {"SCRIPT_DIR": sdir, "HOOK_DIR": sdir, "HOOKS_DIR": sdir, "HERE": sdir, "DIR": sdir,
|
|
265
|
+
"PLUGIN_ROOT": plugin_root, "CLAUDE_PLUGIN_ROOT": plugin_root,
|
|
266
|
+
"CLAUDE_PROJECT_DIR": root, "REPO_ROOT": root, "PROJECT_ROOT": root, "GIT_ROOT": root}
|
|
267
|
+
t = re.sub(r"\$\(dirname[^)]*\)", sdir, tok)
|
|
268
|
+
t = re.sub(r"\$\{?(\w+)\}?", lambda m: names.get(m.group(1), m.group(0)), t)
|
|
269
|
+
if "$" in t:
|
|
270
|
+
tail = re.split(r"\$\{?\w+\}?", t)[-1].lstrip("/")
|
|
271
|
+
cands = [os.path.join(b, tail) for b in (sdir, os.path.join(sdir, ".."), plugin_root, root)]
|
|
272
|
+
else:
|
|
273
|
+
cands = [t if os.path.isabs(t) else os.path.join(sdir, t), t if os.path.isabs(t) else os.path.join(root, t)]
|
|
274
|
+
for c in cands:
|
|
275
|
+
c = os.path.normpath(c)
|
|
276
|
+
if os.path.isfile(c):
|
|
277
|
+
return c
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def hops_of(files):
|
|
282
|
+
found = {}
|
|
283
|
+
for p in files:
|
|
284
|
+
for tok in HOP_TOKEN.findall(code_of(p)):
|
|
285
|
+
r = resolve_hop(tok, p)
|
|
286
|
+
if r:
|
|
287
|
+
found[os.path.realpath(r)] = r
|
|
288
|
+
return found
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
commands = commands_of(os.path.join(plugin_root, "hooks/hooks.json"))
|
|
292
|
+
settings_commands = commands_of(os.path.join(root, ".claude/settings.json"))
|
|
293
|
+
scripts = scripts_of(commands)
|
|
294
|
+
settings_scripts = scripts_of(settings_commands)
|
|
295
|
+
units = [] # (label, text)
|
|
296
|
+
for event, cmd in commands:
|
|
297
|
+
units.append((f"hooks.json {event} command", cmd))
|
|
298
|
+
for event, cmd in settings_commands:
|
|
299
|
+
units.append((f"settings.json {event} command", cmd))
|
|
300
|
+
mcp_strings = 0
|
|
301
|
+
sourced = {}
|
|
302
|
+
for rel in (".mcp.json", "plugins/soleur/.mcp.json"):
|
|
303
|
+
for name, srv in (load_json(os.path.join(root, rel)).get("mcpServers") or {}).items():
|
|
304
|
+
text = " ".join([str(srv.get("command", ""))] + [str(a) for a in srv.get("args", [])])
|
|
305
|
+
mcp_strings += 1
|
|
306
|
+
units.append((f"{rel}:{name}", text))
|
|
307
|
+
for tok in re.findall(r"(?:^|[;&|(\s])(?:\.|source)\s+([^\s;&|\"'<>]+\.sh)\b", text):
|
|
308
|
+
p = tok.replace("${CLAUDE_PLUGIN_ROOT}", plugin_root)
|
|
309
|
+
p = p if os.path.isabs(p) else os.path.join(root, p)
|
|
310
|
+
if os.path.isfile(p):
|
|
311
|
+
sourced[os.path.realpath(p)] = p
|
|
312
|
+
plugin_side = {**scripts, **sourced}
|
|
313
|
+
hops_plugin = {k: v for k, v in hops_of(plugin_side.values()).items() if k not in plugin_side}
|
|
314
|
+
hops_settings = {k: v for k, v in hops_of(settings_scripts.values()).items() if k not in settings_scripts and k not in plugin_side}
|
|
315
|
+
everything = {**plugin_side, **settings_scripts, **hops_plugin, **hops_settings}
|
|
316
|
+
for real, p in everything.items():
|
|
317
|
+
units.append((os.path.relpath(p, root), code_of(p)))
|
|
318
|
+
|
|
319
|
+
flagged = []
|
|
320
|
+
for label, text in units:
|
|
321
|
+
for tool, seg in reasons_for(text):
|
|
322
|
+
flagged.append((label, tool, seg))
|
|
323
|
+
|
|
324
|
+
pop = {"commands": len(commands), "scripts": len(scripts), "mcp": mcp_strings, "sourced": len(sourced)}
|
|
325
|
+
print(f"POP commands={len(commands)} scripts={len(scripts)} mcp={mcp_strings} sourced={len(sourced)} "
|
|
326
|
+
f"settings={len(settings_commands)} settings_scripts={len(settings_scripts)} hops={len(hops_plugin) + len(hops_settings)}")
|
|
327
|
+
if mode == "list-exec":
|
|
328
|
+
# Plugin hook scripts and what they pull in, plus the scripts the launch strings source. Project hooks are NOT executed.
|
|
329
|
+
for p in scripts.values():
|
|
330
|
+
print(("RUN\t" if p.endswith((".sh", ".py")) else "STATIC\t") + p)
|
|
331
|
+
for p in list(sourced.values()) + list(hops_plugin.values()):
|
|
332
|
+
print(("SRC\t" if p.endswith(".sh") else "STATIC\t") + p)
|
|
333
|
+
sys.exit(0)
|
|
334
|
+
for label, tool, seg in flagged:
|
|
335
|
+
print(f"FLAG {label} [{tool}] {seg}")
|
|
336
|
+
low = [f"{key}={pop[key]} < {name}={floor}" for (key, name), floor in zip(FLOOR_NAMES, floor_vals) if pop[key] < floor]
|
|
337
|
+
if low:
|
|
338
|
+
print("VACUOUS population below the floor: " + ", ".join(low)
|
|
339
|
+
+ "; lower the named constant in playwright-mcp-lifetime.test.sh only when hooks or registrations were legitimately removed")
|
|
340
|
+
sys.exit(3)
|
|
341
|
+
sys.exit(1 if flagged else 0)
|
|
342
|
+
PY
|
|
343
|
+
# g1 <root> [floor-cmds] [floor-scripts] [floor-mcp] [floor-sourced] -> sets G1_RC and G1_OUT
|
|
344
|
+
# The two hook floors are SLACK on purpose (proof the scan ran, not a pin of someone else's registry); the planted-fixture
|
|
345
|
+
# rows below are the real positive control. When one fires the message names the constant to lower.
|
|
346
|
+
G1_FLOOR_CMDS=6
|
|
347
|
+
G1_FLOOR_SCRIPTS=5
|
|
348
|
+
G1_FLOOR_MCP=2 # .mcp.json plus plugins/soleur/.mcp.json
|
|
349
|
+
G1_FLOOR_SOURCED=1 # the slot script, sourced by the project launch string
|
|
350
|
+
G1_FLOOR_RUN=5 # population scripts the dynamic rows must have executed under the shim
|
|
351
|
+
G1_RC=0; G1_OUT=""
|
|
352
|
+
g1() {
|
|
353
|
+
G1_RC=0
|
|
354
|
+
G1_OUT="$(python3 "$HELP/g1_scan.py" "$1" "${2:-$G1_FLOOR_CMDS}" "${3:-$G1_FLOOR_SCRIPTS}" "${4:-$G1_FLOOR_MCP}" "${5:-$G1_FLOOR_SOURCED}" 2>&1)" || G1_RC=$?
|
|
355
|
+
}
|
|
356
|
+
g1_rc_is() { [[ "$G1_RC" == "$1" ]]; }
|
|
357
|
+
|
|
358
|
+
# mkroot1 <dir>: the Guard 1 surface of a tree (both hook registries and their scripts, both registrations, the slot script) under the same relative paths
|
|
359
|
+
mkroot1() {
|
|
360
|
+
local d="$1"
|
|
361
|
+
assert_fixture_dir "$d"
|
|
362
|
+
mkdir -p "$d/plugins/soleur" "$d/$SKILL_REL/scripts" "$d/.claude" "$d/scripts"
|
|
363
|
+
cp -R "$REPO_ROOT/plugins/soleur/hooks" "$d/plugins/soleur/"
|
|
364
|
+
cp -R "$REPO_ROOT/plugins/soleur/scripts" "$d/plugins/soleur/"
|
|
365
|
+
cp -R "$REPO_ROOT/.claude/hooks" "$d/.claude/"
|
|
366
|
+
cp "$REPO_ROOT/.claude/settings.json" "$d/.claude/settings.json"
|
|
367
|
+
cp "$REPO_ROOT/scripts/ensure-kb-index.sh" "$d/scripts/ensure-kb-index.sh"
|
|
368
|
+
cp "$REPO_ROOT/.mcp.json" "$d/.mcp.json"
|
|
369
|
+
cp "$REPO_ROOT/plugins/soleur/.mcp.json" "$d/plugins/soleur/.mcp.json"
|
|
370
|
+
if [[ -f "$REPO_ROOT/$SLOT_REL" ]]; then cp "$REPO_ROOT/$SLOT_REL" "$d/$SLOT_REL"; fi
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
# A synthesized stand-in for the removed hook (cq-test-fixtures-synthesized-only): pattern-select every
|
|
374
|
+
# remote-debugging Chrome on the host and signal each, with no regard to who launched it.
|
|
375
|
+
OLD_HOOK="$HELP/old-stop-hook.sh"
|
|
376
|
+
cat > "$OLD_HOOK" <<'HOOK'
|
|
377
|
+
#!/usr/bin/env bash
|
|
378
|
+
set -euo pipefail
|
|
379
|
+
cat > /dev/null
|
|
380
|
+
PIDS=$(pgrep -f 'chrome.*--remote-debugging-pipe' 2>/dev/null || true)
|
|
381
|
+
if [[ -n "$PIDS" ]]; then
|
|
382
|
+
for pid in $PIDS; do kill "$pid" 2>/dev/null || true; done
|
|
383
|
+
fi
|
|
384
|
+
exit 0
|
|
385
|
+
HOOK
|
|
386
|
+
chmod +x "$OLD_HOOK"
|
|
387
|
+
|
|
388
|
+
# Python helper that edits a Guard 1 scratch root. <root> <mutation> <old-hook>
|
|
389
|
+
# Mutations that plant a killer body: vocab-<word> | killall-chrome | pidof-chrome | ps-grep-kill | continuation | indirection | split-word
|
|
390
|
+
# Other mutations: sourced-split-word (a split-word killer inside the SOURCED slot script, before its final return) | settings-hop-killer
|
|
391
|
+
cat > "$HELP/g1_mut.py" <<'PY'
|
|
392
|
+
import json, os, shutil, sys
|
|
393
|
+
|
|
394
|
+
root, mutation, old_hook = sys.argv[1:4]
|
|
395
|
+
if mutation == "noop":
|
|
396
|
+
sys.exit(0)
|
|
397
|
+
hj = os.path.join(root, "plugins/soleur/hooks/hooks.json")
|
|
398
|
+
d = json.load(open(hj))
|
|
399
|
+
KILLER = '#!/usr/bin/env bash\nset -euo pipefail\npkill -9 -f "chrome.*--remote-debugging-pipe" 2>/dev/null || true\nexit 0\n'
|
|
400
|
+
BODIES = {
|
|
401
|
+
"killall-chrome": 'killall -9 chrome 2>/dev/null || true',
|
|
402
|
+
"pidof-chrome": 'kill $(pidof chrome) 2>/dev/null || true',
|
|
403
|
+
"ps-grep-kill": "ps aux | grep chrome | awk '{print $2}' | xargs kill 2>/dev/null || true",
|
|
404
|
+
"continuation": 'pkill -9 -f \\\n "chrome.*--remote-debugging-pipe" 2>/dev/null || true',
|
|
405
|
+
"indirection": 'PAT="chrome.*--remote-debugging-pipe"\npkill -9 -f "$PAT" 2>/dev/null || true',
|
|
406
|
+
"split-word": 'pkill -9 -f "chr""ome" 2>/dev/null || true',
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def add_group(event, command):
|
|
411
|
+
d["hooks"].setdefault(event, []).append({"hooks": [{"type": "command", "command": command}]})
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def write(path, text, mode=0o755):
|
|
415
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
416
|
+
open(path, "w").write(text)
|
|
417
|
+
os.chmod(path, mode)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def plant(body):
|
|
421
|
+
write(os.path.join(root, "plugins/soleur/hooks/zz-killer.sh"), "#!/usr/bin/env bash\n" + body + "\nexit 0\n")
|
|
422
|
+
add_group("Stop", "${CLAUDE_PLUGIN_ROOT}/hooks/zz-killer.sh")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
if mutation == "restore-old-hook":
|
|
426
|
+
shutil.copy(old_hook, os.path.join(root, "plugins/soleur/hooks/browser-cleanup-hook.sh"))
|
|
427
|
+
cmds = [h["command"] for g in d["hooks"].get("Stop", []) for h in g["hooks"]]
|
|
428
|
+
if not any("browser-cleanup-hook.sh" in c for c in cmds):
|
|
429
|
+
add_group("Stop", "${CLAUDE_PLUGIN_ROOT}/hooks/browser-cleanup-hook.sh")
|
|
430
|
+
elif mutation == "killer-under-sessionstart":
|
|
431
|
+
open(os.path.join(root, "plugins/soleur/hooks/zz-killer.sh"), "w").write(KILLER)
|
|
432
|
+
add_group("SessionStart", "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/zz-killer.sh\"")
|
|
433
|
+
elif mutation == "second-killer-same-event":
|
|
434
|
+
open(os.path.join(root, "plugins/soleur/hooks/zz-killer.sh"), "w").write(KILLER)
|
|
435
|
+
add_group("Stop", "${CLAUDE_PLUGIN_ROOT}/hooks/zz-killer.sh")
|
|
436
|
+
elif mutation == "second-in-group":
|
|
437
|
+
open(os.path.join(root, "plugins/soleur/hooks/zz-killer.sh"), "w").write(KILLER)
|
|
438
|
+
d["hooks"]["Stop"][0]["hooks"].append({"type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/zz-killer.sh"})
|
|
439
|
+
elif mutation == "empty-commands":
|
|
440
|
+
d["hooks"] = {}
|
|
441
|
+
elif mutation == "mcp-pkill":
|
|
442
|
+
p = os.path.join(root, ".mcp.json")
|
|
443
|
+
m = json.load(open(p))
|
|
444
|
+
a = m["mcpServers"]["playwright"]["args"]
|
|
445
|
+
a[1] = 'pkill -9 -f "[c]hrome.*$prof" 2>/dev/null || true; ' + a[1]
|
|
446
|
+
json.dump(m, open(p, "w"))
|
|
447
|
+
elif mutation == "plugin-mcp-pkill":
|
|
448
|
+
p = os.path.join(root, "plugins/soleur/.mcp.json")
|
|
449
|
+
m = json.load(open(p))
|
|
450
|
+
m["mcpServers"]["reaper"] = {"command": "bash", "args": ["-c", "pkill -9 -f chrome; exec true"]}
|
|
451
|
+
json.dump(m, open(p, "w"))
|
|
452
|
+
elif mutation == "drop-plugin-mcp":
|
|
453
|
+
p = os.path.join(root, "plugins/soleur/.mcp.json")
|
|
454
|
+
json.dump({"mcpServers": {}}, open(p, "w"))
|
|
455
|
+
elif mutation == "drop-sourced":
|
|
456
|
+
p = os.path.join(root, ".mcp.json")
|
|
457
|
+
m = json.load(open(p))
|
|
458
|
+
a = m["mcpServers"]["playwright"]["args"]
|
|
459
|
+
a[1] = a[1].replace(". plugins/soleur/skills/agent-browser/scripts/playwright-mcp-profile-slot.sh || exit 1;", "prof=$HOME/.cache/playwright-mcp-profile;")
|
|
460
|
+
json.dump(m, open(p, "w"))
|
|
461
|
+
elif mutation == "sourced-killer":
|
|
462
|
+
p = os.path.join(root, "plugins/soleur/skills/agent-browser/scripts/playwright-mcp-profile-slot.sh")
|
|
463
|
+
open(p, "a").write('\npkill -9 -f "chrome.*$prof" 2>/dev/null || true\n')
|
|
464
|
+
elif mutation == "settings-killer":
|
|
465
|
+
write(os.path.join(root, ".claude/hooks/zz-killer.sh"), KILLER)
|
|
466
|
+
sp = os.path.join(root, ".claude/settings.json")
|
|
467
|
+
s = json.load(open(sp))
|
|
468
|
+
s["hooks"].setdefault("PreToolUse", []).append({"matcher": "Bash", "hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/zz-killer.sh"}]})
|
|
469
|
+
json.dump(s, open(sp, "w"))
|
|
470
|
+
elif mutation == "sourced-split-word":
|
|
471
|
+
p = os.path.join(root, "plugins/soleur/skills/agent-browser/scripts/playwright-mcp-profile-slot.sh")
|
|
472
|
+
src = open(p).read()
|
|
473
|
+
i = src.rindex("\nreturn 0")
|
|
474
|
+
open(p, "w").write(src[:i] + '\npkill -9 -f "chr""ome" 2>/dev/null || true' + src[i:])
|
|
475
|
+
elif mutation == "settings-hop-killer":
|
|
476
|
+
write(os.path.join(root, ".claude/hooks/lib/zz-helper.sh"), KILLER, 0o644)
|
|
477
|
+
write(os.path.join(root, ".claude/hooks/zz-hook.sh"),
|
|
478
|
+
'#!/usr/bin/env bash\nSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\nsource "$SCRIPT_DIR/lib/zz-helper.sh"\nexit 0\n')
|
|
479
|
+
sp = os.path.join(root, ".claude/settings.json")
|
|
480
|
+
s = json.load(open(sp))
|
|
481
|
+
s["hooks"].setdefault("PreToolUse", []).append({"matcher": "Bash", "hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/zz-hook.sh"}]})
|
|
482
|
+
json.dump(s, open(sp, "w"))
|
|
483
|
+
elif mutation == "hop-killer":
|
|
484
|
+
write(os.path.join(root, "plugins/soleur/hooks/lib/zz-helper.sh"), KILLER, 0o644)
|
|
485
|
+
write(os.path.join(root, "plugins/soleur/hooks/zz-hook.sh"),
|
|
486
|
+
'#!/usr/bin/env bash\nSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\nsource "$SCRIPT_DIR/lib/zz-helper.sh"\nexit 0\n')
|
|
487
|
+
add_group("Stop", "${CLAUDE_PLUGIN_ROOT}/hooks/zz-hook.sh")
|
|
488
|
+
elif mutation.startswith("vocab-"):
|
|
489
|
+
plant("pkill -9 -f " + mutation[len("vocab-"):] + " 2>/dev/null || true")
|
|
490
|
+
elif mutation in BODIES:
|
|
491
|
+
plant(BODIES[mutation])
|
|
492
|
+
elif mutation == "kill-own-child-only":
|
|
493
|
+
open(os.path.join(root, "plugins/soleur/hooks/zz-own.sh"), "w").write('#!/usr/bin/env bash\nsleep 1 &\nkill "$!"\n')
|
|
494
|
+
add_group("Stop", "${CLAUDE_PLUGIN_ROOT}/hooks/zz-own.sh")
|
|
495
|
+
elif mutation == "pkill-non-browser":
|
|
496
|
+
open(os.path.join(root, "plugins/soleur/hooks/zz-other.sh"), "w").write('#!/usr/bin/env bash\npkill -f my-unrelated-daemon 2>/dev/null || true\n')
|
|
497
|
+
add_group("Stop", "${CLAUDE_PLUGIN_ROOT}/hooks/zz-other.sh")
|
|
498
|
+
else:
|
|
499
|
+
sys.exit(9)
|
|
500
|
+
json.dump(d, open(hj, "w"))
|
|
501
|
+
PY
|
|
502
|
+
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
# Guard 1 dynamic harness: a scratch PATH whose pgrep/pidof print ONLY the decoy pid and whose pkill/killall signal ONLY the
|
|
505
|
+
# decoy; ps, fuser and lsof print nothing (so a ps|grep|kill pipeline selects nothing real).
|
|
506
|
+
# ---------------------------------------------------------------------------
|
|
507
|
+
SHIM="$WORK/shim"; mkdir -p "$SHIM"
|
|
508
|
+
cat > "$SHIM/pgrep" <<'SHIMSRC'
|
|
509
|
+
#!/usr/bin/env bash
|
|
510
|
+
# prints ONLY the decoy pid (or nothing when decoy.pid is empty)
|
|
511
|
+
d="$(cat "$(dirname "$0")/decoy.pid")"
|
|
512
|
+
if [[ -n "$d" ]]; then printf '%s\n' "$d"; fi
|
|
513
|
+
exit 0
|
|
514
|
+
SHIMSRC
|
|
515
|
+
cp "$SHIM/pgrep" "$SHIM/pidof"
|
|
516
|
+
cat > "$SHIM/pkill" <<'SHIMSRC'
|
|
517
|
+
#!/usr/bin/env bash
|
|
518
|
+
# signals ONLY the decoy, whatever pattern it was handed
|
|
519
|
+
d="$(cat "$(dirname "$0")/decoy.pid")"
|
|
520
|
+
if [[ -n "$d" ]]; then kill "$d" 2>/dev/null || true; fi
|
|
521
|
+
exit 0
|
|
522
|
+
SHIMSRC
|
|
523
|
+
cp "$SHIM/pkill" "$SHIM/killall"
|
|
524
|
+
printf '#!/usr/bin/env bash\nexit 0\n' > "$SHIM/ps"
|
|
525
|
+
cp "$SHIM/ps" "$SHIM/fuser"; cp "$SHIM/ps" "$SHIM/lsof"
|
|
526
|
+
chmod +x "$SHIM"/pgrep "$SHIM"/pidof "$SHIM"/pkill "$SHIM"/killall "$SHIM"/ps "$SHIM"/fuser "$SHIM"/lsof
|
|
527
|
+
DYN="$WORK/dyn"; mkdir -p "$DYN/home" "$DYN/proj" "$DYN/tmp"
|
|
528
|
+
# run_under_shim <script> <pid-the-shim-targets|""> <pid-to-watch> -> 0 when the watched pid is still alive afterwards
|
|
529
|
+
run_under_shim() {
|
|
530
|
+
local script="$1" target="$2" watch="$3"
|
|
531
|
+
printf '%s' "$target" > "$SHIM/decoy.pid"
|
|
532
|
+
( cd "$WORK" && PATH="$SHIM:$PATH" CLAUDE_PLUGIN_ROOT="$REPO_ROOT/plugins/soleur" bash "$script" </dev/null >/dev/null 2>&1 ) || true
|
|
533
|
+
wait_dead "$watch" 1 || true
|
|
534
|
+
alive "$watch"
|
|
535
|
+
}
|
|
536
|
+
# dyn_run <RUN|SRC> <path> <plugin-root>: one population script, executed (RUN) or sourced (SRC) under the shim, with a scratch
|
|
537
|
+
# HOME, cwd and project dir, a scrubbed environment, no stdin and a time bound
|
|
538
|
+
dyn_run() {
|
|
539
|
+
local kind="$1" path="$2" pr="$3"
|
|
540
|
+
local -a runner
|
|
541
|
+
case "$kind:$path" in
|
|
542
|
+
RUN:*.py) runner=(python3 "$path") ;;
|
|
543
|
+
RUN:*) runner=(bash "$path") ;;
|
|
544
|
+
*) runner=(bash -c '. "$1"' _ "$path") ;;
|
|
545
|
+
esac
|
|
546
|
+
( cd "$DYN" && exec env -i PATH="$SHIM:/usr/bin:/bin" HOME="$DYN/home" TMPDIR="$DYN/tmp" CLAUDE_PLUGIN_ROOT="$pr" CLAUDE_PROJECT_DIR="$DYN/proj" timeout 20 "${runner[@]}" </dev/null >/dev/null 2>&1 ) || true
|
|
547
|
+
}
|
|
548
|
+
# g1_dynamic <root> <decoy-pid>: every executable population script of <root> run under the shim; sets DYN_RAN and DYN_ALIVE
|
|
549
|
+
DYN_RAN=0; DYN_ALIVE=1
|
|
550
|
+
g1_dynamic() {
|
|
551
|
+
local root="$1" decoy="$2" kind path
|
|
552
|
+
DYN_RAN=0
|
|
553
|
+
printf '%s' "$decoy" > "$SHIM/decoy.pid"
|
|
554
|
+
while IFS=$'\t' read -r kind path; do
|
|
555
|
+
case "$kind" in RUN|SRC) : ;; *) continue ;; esac
|
|
556
|
+
dyn_run "$kind" "$path" "$root/plugins/soleur"
|
|
557
|
+
DYN_RAN=$((DYN_RAN + 1))
|
|
558
|
+
done < <(python3 "$HELP/g1_scan.py" "$root" 0 0 0 0 list-exec)
|
|
559
|
+
wait_dead "$decoy" 1 || true
|
|
560
|
+
if alive "$decoy"; then DYN_ALIVE=1; else DYN_ALIVE=0; fi
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
# Verdict helpers of the mutation matrices (defined before the helper control so the control can drive them)
|
|
565
|
+
# ---------------------------------------------------------------------------
|
|
566
|
+
G1="$WORK/g1"; mkdir -p "$G1"
|
|
567
|
+
G2="$WORK/g2"; mkdir -p "$G2/bin"
|
|
568
|
+
# g1_mutant <name> <mutation> <expected-rc> <label> [<flag-substring>]: static verdict; the FLAG line must name the planted unit
|
|
569
|
+
g1_mutant() {
|
|
570
|
+
local name="$1" mutation="$2" want="$3" label="$4" flag="${5:-}"
|
|
571
|
+
cases=$((cases + 1)); mutants_declared=$((mutants_declared + 1))
|
|
572
|
+
mkroot1 "$G1/m-$name"
|
|
573
|
+
python3 "$HELP/g1_mut.py" "$G1/m-$name" "$mutation" "$OLD_HOOK"
|
|
574
|
+
if ! landed "$G1/real" "$G1/m-$name"; then bad "Guard 1 mutant $name did NOT land"; return 0; fi
|
|
575
|
+
ok "Guard 1 mutant $name landed"
|
|
576
|
+
g1 "$G1/m-$name"
|
|
577
|
+
cases=$((cases + 1)); red_rows=$((red_rows + 1))
|
|
578
|
+
# rc 1 rows name the planted unit on a FLAG line; rc 3 rows name the floor constant that fired on the VACUOUS line
|
|
579
|
+
if [[ "$G1_RC" == "$want" && ( -z "$flag" || "$G1_OUT" == *"FLAG"*"$flag"* || ( "$want" == 3 && "$G1_OUT" == *"VACUOUS"*"$flag"* ) ) ]]; then ok "$label"; else bad "$label — mutant survived (scanner rc $G1_RC, expected $want${flag:+, naming $flag})"; fi
|
|
580
|
+
}
|
|
581
|
+
# g1_dyn_mutant <name> <mutation> <label>: dynamic verdict — the planted tree's scripts, executed under the shim, kill the decoy
|
|
582
|
+
g1_dyn_mutant() {
|
|
583
|
+
local name="$1" mutation="$2" label="$3" d
|
|
584
|
+
cases=$((cases + 1)); mutants_declared=$((mutants_declared + 1))
|
|
585
|
+
mkroot1 "$G1/m-$name"
|
|
586
|
+
python3 "$HELP/g1_mut.py" "$G1/m-$name" "$mutation" "$OLD_HOOK"
|
|
587
|
+
if ! landed "$G1/real" "$G1/m-$name"; then bad "Guard 1 mutant $name did NOT land"; return 0; fi
|
|
588
|
+
ok "Guard 1 mutant $name landed"
|
|
589
|
+
start_g1_decoy "$name"; d=$SPAWNED
|
|
590
|
+
g1_dynamic "$G1/m-$name" "$d"
|
|
591
|
+
cases=$((cases + 1)); red_rows=$((red_rows + 1))
|
|
592
|
+
if [[ "$DYN_ALIVE" -eq 0 && "$DYN_RAN" -ge "$G1_FLOOR_RUN" ]]; then ok "$label"; else bad "$label — mutant survived (decoy alive=$DYN_ALIVE after $DYN_RAN scripts)"; fi
|
|
593
|
+
}
|
|
594
|
+
mkroot() { # <dir>: a scratch tree holding the launch string's file dependencies
|
|
595
|
+
local d="$1"
|
|
596
|
+
assert_fixture_dir "$d"
|
|
597
|
+
mkdir -p "$d/$SKILL_REL/scripts" "$d/.claude"
|
|
598
|
+
cp "$REPO_ROOT/$PROXY_REL_PATH" "$d/$SKILL_REL/scripts/"
|
|
599
|
+
cp "$REPO_ROOT/$REDACTOR_REL" "$d/$SKILL_REL/scripts/"
|
|
600
|
+
cp "$REPO_ROOT/.claude/playwright-mcp.config.json" "$d/.claude/"
|
|
601
|
+
if [[ -f "$REPO_ROOT/$SLOT_REL" ]]; then cp "$REPO_ROOT/$SLOT_REL" "$d/$SLOT_REL"; fi
|
|
602
|
+
}
|
|
603
|
+
# slot_mutant <name> <old> <new> [<old2> <new2>] — an edited COPY of the slot script in a scratch tree; every edit must
|
|
604
|
+
# occur exactly once in the pristine script and the copy must differ (landed). Sets MROOT (empty when it did not land).
|
|
605
|
+
MROOT=""
|
|
606
|
+
slot_mutant() {
|
|
607
|
+
local name="$1"; shift
|
|
608
|
+
cases=$((cases + 1)); mutants_declared=$((mutants_declared + 1)); MROOT=""
|
|
609
|
+
local root="$G2/mut-$name"
|
|
610
|
+
mkroot "$root"
|
|
611
|
+
if python3 - "$REPO_ROOT/$SLOT_REL" "$root/$SLOT_REL" "$@" <<'PY'
|
|
612
|
+
import sys
|
|
613
|
+
src_path, out_path, *edits = sys.argv[1:]
|
|
614
|
+
try:
|
|
615
|
+
src = open(src_path).read()
|
|
616
|
+
except OSError:
|
|
617
|
+
sys.exit(2)
|
|
618
|
+
out = src
|
|
619
|
+
for old, new in zip(edits[0::2], edits[1::2]):
|
|
620
|
+
if out.count(old) != 1:
|
|
621
|
+
sys.stderr.write(f"edit target occurs {out.count(old)} times: {old!r}\n"); sys.exit(3)
|
|
622
|
+
out = out.replace(old, new)
|
|
623
|
+
if out == src:
|
|
624
|
+
sys.exit(4)
|
|
625
|
+
open(out_path, "w").write(out)
|
|
626
|
+
PY
|
|
627
|
+
then ok "Guard 2 mutant $name landed"; MROOT="$root"; else bad "Guard 2 mutant $name did NOT land"; fi
|
|
628
|
+
}
|
|
629
|
+
PLUGIN_MCP="$REPO_ROOT/plugins/soleur/.mcp.json"
|
|
630
|
+
PROJECT_MCP="$REPO_ROOT/.mcp.json"
|
|
631
|
+
# reg_ok <file> <project|plugin>: the ping-timeout env is "0" (an inert stdio guard; see the ADR), and the plugin carries --chromium-fallback
|
|
632
|
+
reg_ok() {
|
|
633
|
+
python3 - "$1" "$2" <<'PY'
|
|
634
|
+
import json, sys
|
|
635
|
+
path, kind = sys.argv[1:]
|
|
636
|
+
e = json.load(open(path))["mcpServers"]["playwright"]
|
|
637
|
+
ok = e.get("env") == {"PLAYWRIGHT_MCP_PING_TIMEOUT_MS": "0"}
|
|
638
|
+
if kind == "plugin":
|
|
639
|
+
ok = ok and e["args"].count("--chromium-fallback") == 1 and e["args"].index("--chromium-fallback") < e["args"].index("--")
|
|
640
|
+
sys.exit(0 if ok else 1)
|
|
641
|
+
PY
|
|
642
|
+
}
|
|
643
|
+
reg_mutant() { # <label> <file> <kind> <python-mutation-src>
|
|
644
|
+
cases=$((cases + 1)); mutants_declared=$((mutants_declared + 1))
|
|
645
|
+
local f="$WORK/reg-mut.json"
|
|
646
|
+
if ! MUTATION="$4" python3 - "$2" "$f" 2>/dev/null <<'PY'
|
|
647
|
+
import json, os, sys
|
|
648
|
+
d = json.load(open(sys.argv[1]))
|
|
649
|
+
e = d["mcpServers"]["playwright"]
|
|
650
|
+
exec(os.environ["MUTATION"])
|
|
651
|
+
json.dump(d, open(sys.argv[2], "w"))
|
|
652
|
+
PY
|
|
653
|
+
then bad "$1 did NOT land (the edit target is absent)"; return 0; fi
|
|
654
|
+
if cmp -s "$2" "$f" || diff -q <(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1])), sort_keys=True))' "$2") <(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1])), sort_keys=True))' "$f") >/dev/null; then
|
|
655
|
+
bad "$1 did NOT land"; return 0
|
|
656
|
+
fi
|
|
657
|
+
ok "$1 landed"
|
|
658
|
+
cases=$((cases + 1)); red_rows=$((red_rows + 1))
|
|
659
|
+
if reg_ok "$f" "$3"; then bad "$1 — mutant survived"; else ok "$1 → RED"; fi
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
# ---------------------------------------------------------------------------
|
|
663
|
+
# Helper control: every verdict-owning helper driven with an input it MUST reject.
|
|
664
|
+
# Counters are unwound afterwards; the suite refuses to continue unless all rejected. The controls
|
|
665
|
+
# report with printf + exit, never through the helper under test.
|
|
666
|
+
# ---------------------------------------------------------------------------
|
|
667
|
+
hc_broken() { printf 'HELPER CONTROL BROKEN: %s\n' "$1" >&2; exit 1; }
|
|
668
|
+
_helper_control() {
|
|
669
|
+
local _p=$pass _f=$fail _c=$cases _r=$red_rows _m=$mutants_declared
|
|
670
|
+
local planted="$WORK/hc-planted"; mkroot1 "$planted"
|
|
671
|
+
python3 "$HELP/g1_mut.py" "$planted" killer-under-sessionstart "$OLD_HOOK"
|
|
672
|
+
assert_true 'helper control: assert_true must REJECT false (EXPECTED)' false
|
|
673
|
+
red 'helper control: red must REJECT an unobservable defect (EXPECTED)' false
|
|
674
|
+
g1 "$planted" 1 1 0 0
|
|
675
|
+
assert_true 'helper control: a planted killer must make the scanner rc 1, so rc 0 is REJECTED (EXPECTED)' g1_rc_is 0
|
|
676
|
+
g1 "$planted" 999 999 0 0
|
|
677
|
+
assert_true 'helper control: a population below the floor must be rc 3, so rc 1 is REJECTED (EXPECTED)' g1_rc_is 1
|
|
678
|
+
assert_true 'helper control: lock_held must be FALSE on a missing lock file (EXPECTED)' lock_held "$WORK/no-such.lock"
|
|
679
|
+
assert_true 'helper control: alive must be FALSE on a pid that does not exist (EXPECTED)' alive 2147483646
|
|
680
|
+
if [[ $fail -ne $((_f + 6)) ]]; then
|
|
681
|
+
hc_broken "expected 6 rejections, fail $_f->$fail"
|
|
682
|
+
fi
|
|
683
|
+
# lock_held / lock_free: an existing UNLOCKED file and a LOCKED one, both ways
|
|
684
|
+
local unlocked="$WORK/hc-unlocked.lock" locked="$WORK/hc-locked.lock" hp i rc
|
|
685
|
+
: > "$unlocked"; : > "$locked"
|
|
686
|
+
flock -F "$locked" sleep 30 >/dev/null 2>&1 & hp=$!; own "$hp"; disown "$hp"
|
|
687
|
+
for i in $(seq 1 50); do rc=0; flock -n -E 75 "$locked" true || rc=$?; if [[ $rc -eq 75 ]]; then break; fi; sleep 0.1; done
|
|
688
|
+
if lock_held "$unlocked"; then hc_broken "lock_held accepted an existing but UNLOCKED file"; fi
|
|
689
|
+
if ! lock_held "$locked"; then hc_broken "lock_held rejected a LOCKED file"; fi
|
|
690
|
+
if lock_free "$locked"; then hc_broken "lock_free accepted a LOCKED file"; fi
|
|
691
|
+
if ! lock_free "$unlocked"; then hc_broken "lock_free rejected an UNLOCKED file"; fi
|
|
692
|
+
kill -KILL "$hp" 2>/dev/null || true
|
|
693
|
+
# landed: identical trees are not a landing, differing trees are
|
|
694
|
+
mkdir -p "$WORK/hc-l1" "$WORK/hc-l2"; : > "$WORK/hc-l1/a"; : > "$WORK/hc-l2/a"
|
|
695
|
+
if landed "$WORK/hc-l1" "$WORK/hc-l2"; then hc_broken "landed accepted identical trees"; fi
|
|
696
|
+
: > "$WORK/hc-l2/b"
|
|
697
|
+
if ! landed "$WORK/hc-l1" "$WORK/hc-l2"; then hc_broken "landed rejected differing trees"; fi
|
|
698
|
+
# g1_mutant: a no-op mutation must be reported as not landed; a landed mutant the scanner does not flag must be reported as surviving
|
|
699
|
+
local f0=$fail p0=$pass r0=$red_rows
|
|
700
|
+
g1_mutant ctl-noop noop 1 'helper control: no-op' >/dev/null
|
|
701
|
+
if [[ $fail -ne $((f0 + 1)) || $red_rows -ne $r0 ]]; then hc_broken "g1_mutant accepted a no-op mutation"; fi
|
|
702
|
+
f0=$fail; p0=$pass
|
|
703
|
+
g1_mutant ctl-survive pkill-non-browser 1 'helper control: unflagged mutant' >/dev/null
|
|
704
|
+
if [[ $fail -ne $((f0 + 1)) || $pass -ne $((p0 + 1)) ]]; then hc_broken "g1_mutant passed a landed mutant the scanner does not flag"; fi
|
|
705
|
+
f0=$fail; p0=$pass
|
|
706
|
+
g1_mutant ctl-flag second-killer-same-event 1 'helper control: wrong unit named' 'no-such-unit' >/dev/null
|
|
707
|
+
if [[ $fail -ne $((f0 + 1)) ]]; then hc_broken "g1_mutant passed a mutant flagged for the wrong unit"; fi
|
|
708
|
+
f0=$fail; p0=$pass
|
|
709
|
+
g1_mutant ctl-floor-key empty-commands 3 'helper control: the wrong floor named' 'G1_FLOOR_MCP' >/dev/null
|
|
710
|
+
if [[ $fail -ne $((f0 + 1)) ]]; then hc_broken "g1_mutant passed a floor row that names a floor that did not fire"; fi
|
|
711
|
+
p0=$pass
|
|
712
|
+
g1_mutant ctl-floor-ok empty-commands 3 'helper control: the right floor named' 'G1_FLOOR_CMDS' >/dev/null
|
|
713
|
+
if [[ $pass -ne $((p0 + 2)) ]]; then hc_broken "g1_mutant rejected a floor row that names the floor that fired"; fi
|
|
714
|
+
# g1_dyn_mutant: a no-op is not a landing, and a landed tree whose scripts kill nothing under the shim is a surviving mutant
|
|
715
|
+
f0=$fail
|
|
716
|
+
g1_dyn_mutant ctl-dyn-noop noop 'helper control: dynamic no-op' >/dev/null
|
|
717
|
+
if [[ $fail -ne $((f0 + 1)) ]]; then hc_broken "g1_dyn_mutant accepted a no-op mutation"; fi
|
|
718
|
+
f0=$fail; p0=$pass
|
|
719
|
+
g1_dyn_mutant ctl-dyn-survive kill-own-child-only 'helper control: dynamic survivor' >/dev/null
|
|
720
|
+
if [[ $fail -ne $((f0 + 1)) || $pass -ne $((p0 + 1)) ]]; then hc_broken "g1_dyn_mutant passed a landed tree that kills nothing"; fi
|
|
721
|
+
# the Guard 2 state verdicts: each must accept the state it names and REJECT every other
|
|
722
|
+
local sd="$WORK/hc-state" _rr=$RN_RAN _rp=$RN_PROFS
|
|
723
|
+
mkdir -p "$sd/slot"; chmod 755 "$sd/slot"
|
|
724
|
+
RN_RAN=1; RN_PROFS=$'/p/a\n'
|
|
725
|
+
if ! prof_is /p/a; then hc_broken "prof_is rejected the profile the launch ran on"; fi
|
|
726
|
+
if prof_is /p/b; then hc_broken "prof_is accepted a different profile"; fi
|
|
727
|
+
RN_RAN=2; if prof_is /p/a; then hc_broken "prof_is accepted two launches as one"; fi
|
|
728
|
+
RN_RAN=0; if prof_is /p/a; then hc_broken "prof_is accepted a launch that never ran"; fi
|
|
729
|
+
RN_RAN=$_rr; RN_PROFS=$_rp
|
|
730
|
+
if link_is "$sd/slot" tgt; then hc_broken "link_is accepted a directory with no SingletonLock"; fi
|
|
731
|
+
if ! no_link "$sd/slot"; then hc_broken "no_link rejected a directory with no SingletonLock"; fi
|
|
732
|
+
ln -s tgt "$sd/slot/SingletonLock"
|
|
733
|
+
if ! link_is "$sd/slot" tgt; then hc_broken "link_is rejected the link it names"; fi
|
|
734
|
+
if link_is "$sd/slot" other; then hc_broken "link_is accepted a link with another target"; fi
|
|
735
|
+
if no_link "$sd/slot"; then hc_broken "no_link accepted a (dangling) SingletonLock symlink"; fi
|
|
736
|
+
if ! mode_is "$sd/slot" 755; then hc_broken "mode_is rejected the mode the directory has"; fi
|
|
737
|
+
if mode_is "$sd/slot" 700; then hc_broken "mode_is accepted a 755 directory as 700"; fi
|
|
738
|
+
# old_pattern_selects: the retired hook's selector matches the old decoy shape and not the Guard 1 decoy shape
|
|
739
|
+
if ! old_pattern_selects 'chrome --remote-debugging-pipe --user-data-dir=/x'; then hc_broken "old_pattern_selects missed the pipe-shaped Chrome argv"; fi
|
|
740
|
+
if old_pattern_selects "sleep-decoy-$$-x 300"; then hc_broken "old_pattern_selects matched a Guard 1 decoy argv"; fi
|
|
741
|
+
# reg_mutant
|
|
742
|
+
f0=$fail; r0=$red_rows
|
|
743
|
+
reg_mutant 'helper control: reg no-op' "$PROJECT_MCP" project 'pass' >/dev/null
|
|
744
|
+
if [[ $fail -ne $((f0 + 1)) || $red_rows -ne $r0 ]]; then hc_broken "reg_mutant accepted a no-op mutation"; fi
|
|
745
|
+
f0=$fail; p0=$pass
|
|
746
|
+
reg_mutant 'helper control: reg benign' "$PROJECT_MCP" project 'e["note"] = "benign"' >/dev/null
|
|
747
|
+
if [[ $fail -ne $((f0 + 1)) || $pass -ne $((p0 + 1)) ]]; then hc_broken "reg_mutant passed a landed mutant reg_ok does not reject"; fi
|
|
748
|
+
# slot_mutant: an edit that changes nothing is not a landing
|
|
749
|
+
f0=$fail
|
|
750
|
+
slot_mutant ctl-noop '_pwslot_max=32' '_pwslot_max=32' >/dev/null
|
|
751
|
+
if [[ $fail -ne $((f0 + 1)) || -n "$MROOT" ]]; then hc_broken "slot_mutant accepted an edit that changes nothing"; fi
|
|
752
|
+
f0=$fail
|
|
753
|
+
slot_mutant ctl-absent 'no such text in the script' 'x' >/dev/null 2>&1
|
|
754
|
+
if [[ $fail -ne $((f0 + 1)) || -n "$MROOT" ]]; then hc_broken "slot_mutant accepted an edit whose target is absent"; fi
|
|
755
|
+
pass=$_p; fail=$_f; cases=$_c; red_rows=$_r; mutants_declared=$_m
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
# ===========================================================================
|
|
759
|
+
# Guard 1 — no registered hook or launch command names a browser with a PID-selecting tool
|
|
760
|
+
# ===========================================================================
|
|
761
|
+
# The two real-tree verdicts run BEFORE the helper control: the control builds its fixtures from the real tree's hooks and slot
|
|
762
|
+
# script, so a pattern kill in the real tree would otherwise abort the suite as "HELPER CONTROL BROKEN" before the row that
|
|
763
|
+
# names the culprit could report. A defect in the tree is attributed here; a defect in a helper still aborts in the control.
|
|
764
|
+
mkroot1 "$G1/real"
|
|
765
|
+
g1 "$G1/real"
|
|
766
|
+
assert_true 'Guard 1: the real tree has no hook or launch string that kills a browser or MCP process by name or pattern' g1_rc_is 0
|
|
767
|
+
# --- live decoy: every executable population script of the real tree (plugin hook scripts, the sourced slot script, one hop of
|
|
768
|
+
# sourced libs) is run under the shim; the decoy must survive each, and the loop must have run (not zero iterations)
|
|
769
|
+
start_g1_decoy pop; DECOY=$SPAWNED
|
|
770
|
+
g1_dynamic "$G1/real" "$DECOY"
|
|
771
|
+
assert_true 'Guard 1 live: every executable population script of the real tree ran under the shim (at least the floor), not zero iterations' test "$DYN_RAN" -ge "$G1_FLOOR_RUN"
|
|
772
|
+
assert_true 'Guard 1 live: the decoy survived every population script of the real tree (the positive control below proves the decoy is killable)' test "$DYN_ALIVE" -eq 1
|
|
773
|
+
_helper_control
|
|
774
|
+
g1 "$G1/real"
|
|
775
|
+
pop_reported() { [[ "$G1_OUT" == *"POP commands="* && "$G1_OUT" != *VACUOUS* ]]; }
|
|
776
|
+
assert_true 'Guard 1: the scan examined a population at or above every floor (not vacuous)' pop_reported
|
|
777
|
+
pop_axes_covered() {
|
|
778
|
+
if [[ "$G1_OUT" =~ settings=([0-9]+)\ settings_scripts=([0-9]+)\ hops=([0-9]+) ]] && [[ "${BASH_REMATCH[1]}" -ge 10 && "${BASH_REMATCH[2]}" -ge 10 && "${BASH_REMATCH[3]}" -ge 1 ]]; then return 0; fi
|
|
779
|
+
return 1
|
|
780
|
+
}
|
|
781
|
+
assert_true 'Guard 1: the population includes the .claude/settings.json hooks and scripts sourced one hop from hook scripts' pop_axes_covered
|
|
782
|
+
assert_true 'Guard 1: hooks.json still parses and keeps stop-hook.sh and unkept-promise-hook.sh (positive proof the Stop event was scanned)' python3 - "$REPO_ROOT/plugins/soleur/hooks/hooks.json" <<'PY'
|
|
783
|
+
import json, sys
|
|
784
|
+
cmds = [h["command"] for g in json.load(open(sys.argv[1]))["hooks"]["Stop"] for h in g["hooks"]]
|
|
785
|
+
sys.exit(0 if any(c.endswith("/stop-hook.sh") for c in cmds) and any(c.endswith("/unkept-promise-hook.sh") for c in cmds) and not any("browser-cleanup" in c for c in cmds) else 1)
|
|
786
|
+
PY
|
|
787
|
+
assert_true 'Guard 1: plugins/soleur/hooks/browser-cleanup-hook.sh is absent' test ! -e "$REPO_ROOT/plugins/soleur/hooks/browser-cleanup-hook.sh"
|
|
788
|
+
|
|
789
|
+
# Must-PASS non-canonical inputs (static scan only: the shim kills the decoy for ANY pkill, so these are never executed):
|
|
790
|
+
# a hook that kills only a pid it spawned, and pkill on a non-browser name.
|
|
791
|
+
mkroot1 "$G1/ok-own"; python3 "$HELP/g1_mut.py" "$G1/ok-own" kill-own-child-only "$OLD_HOOK"
|
|
792
|
+
g1 "$G1/ok-own"
|
|
793
|
+
assert_true 'Guard 1 must-PASS: a hook that backgrounds and kills its own child (kill "$!") passes the static scan (the scan does not distinguish own from foreign pids; the shim rows do not run it)' g1_rc_is 0
|
|
794
|
+
mkroot1 "$G1/ok-other"; python3 "$HELP/g1_mut.py" "$G1/ok-other" pkill-non-browser "$OLD_HOOK"
|
|
795
|
+
g1 "$G1/ok-other"
|
|
796
|
+
assert_true 'Guard 1 must-PASS: pkill on a non-browser name passes' g1_rc_is 0
|
|
797
|
+
|
|
798
|
+
# Positive proof the scanner can see a killer at all: the synthesized old hook, planted under Stop.
|
|
799
|
+
mkroot1 "$G1/planted"; python3 "$HELP/g1_mut.py" "$G1/planted" restore-old-hook "$OLD_HOOK"
|
|
800
|
+
g1 "$G1/planted"
|
|
801
|
+
assert_true 'Guard 1 positive control: the scanner flags the old hook text registered under Stop (and names pgrep)' bash -c '[[ "$1" == 1 && "$2" == *"FLAG"*"[pgrep]"* ]]' _ "$G1_RC" "$G1_OUT"
|
|
802
|
+
|
|
803
|
+
# --- live decoy: the old hook text, executed under the shim, DOES kill a pattern-matched Chrome ---
|
|
804
|
+
start_g1_decoy live; DECOY=$SPAWNED
|
|
805
|
+
printf '%s' "$DECOY" > "$SHIM/decoy.pid"
|
|
806
|
+
shim_resolves() { local out; out="$(PATH="$SHIM:$PATH" pgrep anything)"; [[ "$(PATH="$SHIM:$PATH" command -v pgrep)" == "$SHIM/pgrep" && "$out" == "$DECOY" ]]; }
|
|
807
|
+
assert_true 'Guard 1 harness: under the scratch PATH pgrep resolves to the shim and prints only the decoy pid' shim_resolves
|
|
808
|
+
assert_true 'Guard 1 harness: the decoy is a live process before any hook runs' alive "$DECOY"
|
|
809
|
+
decoy_not_old_shaped() { local a; a="$(proc_argv "$DECOY")"; if [[ "$a" == "sleep-decoy-"* ]] && ! old_pattern_selects "$a"; then return 0; fi; return 1; }
|
|
810
|
+
assert_true 'Guard 1 harness: the decoy argv (read from /proc) cannot be selected by the retired Stop hook'"'"'s own pattern, so a host that still has that hook installed cannot kill it' decoy_not_old_shaped
|
|
811
|
+
cases=$((cases + 1))
|
|
812
|
+
if run_under_shim "$OLD_HOOK" "$DECOY" "$DECOY"; then bad 'Guard 1 positive control: the old hook text killed the decoy'; else ok 'Guard 1 positive control: the old hook text killed the decoy (the decoy is a real target)'; fi
|
|
813
|
+
# (the live rows over EVERY executable population script of the real tree ran above, before the helper control)
|
|
814
|
+
|
|
815
|
+
# --- Guard 1 mutation matrix: each mutated copy must be flagged (or hit a vacuity floor, or kill the decoy under the shim) ---
|
|
816
|
+
g1_mutant 1-restore-old-hook restore-old-hook 1 'Guard 1 mutant 1: the old hook and its Stop registration restored → RED' 'browser-cleanup-hook.sh'
|
|
817
|
+
g1_mutant 2-other-event killer-under-sessionstart 1 'Guard 1 mutant 2: a pattern-killing script registered under SessionStart → RED' 'zz-killer.sh'
|
|
818
|
+
g1_mutant 3-second-killer second-killer-same-event 1 'Guard 1 mutant 3: a second killing hook after the compliant ones in the same Stop array → RED' 'zz-killer.sh'
|
|
819
|
+
g1_mutant 4-mcp-pkill mcp-pkill 1 'Guard 1 mutant 4: pkill -9 -f "[c]hrome.*$prof" back in the .mcp.json launch string → RED' ' .mcp.json:playwright'
|
|
820
|
+
g1_mutant 5-empty-population empty-commands 3 'Guard 1 mutant 5: hooks.json command population emptied → RED (vacuity floor)' 'G1_FLOOR_CMDS='
|
|
821
|
+
g1_mutant 6-second-in-group second-in-group 1 'Guard 1 mutant 6: a killer as the SECOND hook inside one hooks.json group → RED' 'zz-killer.sh'
|
|
822
|
+
g1_mutant 7-plugin-mcp plugin-mcp-pkill 1 'Guard 1 mutant 7: a pkill in plugins/soleur/.mcp.json → RED' 'plugins/soleur/.mcp.json:reaper'
|
|
823
|
+
g1_mutant 8-sourced-killer sourced-killer 1 'Guard 1 mutant 8: a pattern kill appended to the sourced slot script → RED' 'playwright-mcp-profile-slot.sh'
|
|
824
|
+
g1_mutant 9-settings-killer settings-killer 1 'Guard 1 mutant 9: a killer script registered in .claude/settings.json → RED' '.claude/hooks/zz-killer.sh'
|
|
825
|
+
g1_mutant 10-hop-killer hop-killer 1 'Guard 1 mutant 10: a killer in a lib sourced one hop from a registered hook → RED' 'hooks/lib/zz-helper.sh'
|
|
826
|
+
for _w in headless_shell playwright remote-debugging user-data-dir mcp; do
|
|
827
|
+
g1_mutant "11-vocab-$_w" "vocab-$_w" 1 "Guard 1 mutant 11: pkill -f $_w (browser vocabulary without the word chrome) → RED" 'zz-killer.sh'
|
|
828
|
+
done
|
|
829
|
+
g1_mutant 12-killall killall-chrome 1 'Guard 1 mutant 12: killall -9 chrome → RED' 'zz-killer.sh'
|
|
830
|
+
g1_mutant 13-pidof pidof-chrome 1 'Guard 1 mutant 13: kill $(pidof chrome) → RED' 'zz-killer.sh'
|
|
831
|
+
g1_mutant 14-ps-grep-kill ps-grep-kill 1 'Guard 1 mutant 14: ps | grep chrome | xargs kill (the grep branch) → RED' 'zz-killer.sh'
|
|
832
|
+
g1_mutant 15-continuation continuation 1 'Guard 1 mutant 15: a backslash-continued pkill -f \<newline> "chrome..." → RED' 'zz-killer.sh'
|
|
833
|
+
g1_mutant 16-drop-plugin-mcp drop-plugin-mcp 3 'Guard 1 mutant 16: plugins/soleur/.mcp.json emptied → RED (mcp floor)' 'G1_FLOOR_MCP='
|
|
834
|
+
g1_mutant 17-drop-sourced drop-sourced 3 'Guard 1 mutant 17: the launch string no longer sources the slot script → RED (sourced floor)' 'G1_FLOOR_SOURCED='
|
|
835
|
+
# the shim run catches what the static text scan cannot see: indirection, split words, and (as a known positive) the old hook
|
|
836
|
+
g1_dyn_mutant 18-dyn-old-hook restore-old-hook 'Guard 1 mutant 18 (dynamic): the old hook, registered under Stop, kills the decoy when the population is executed under the shim → RED'
|
|
837
|
+
g1_dyn_mutant 19-dyn-indirection indirection 'Guard 1 mutant 19 (dynamic): pkill -f "$PAT" with the pattern in a variable (invisible to the static scan) kills the decoy → RED'
|
|
838
|
+
g1_dyn_mutant 20-dyn-split-word split-word 'Guard 1 mutant 20 (dynamic): pkill -f "chr""ome" (split word, invisible to the static scan) kills the decoy → RED'
|
|
839
|
+
g1_dyn_mutant 21-dyn-sourced-split-word sourced-split-word 'Guard 1 mutant 21 (dynamic): a split-word pkill planted in the SOURCED slot script (executed by sourcing under the shim) kills the decoy → RED'
|
|
840
|
+
g1_mutant 22-settings-hop-killer settings-hop-killer 1 'Guard 1 mutant 22: a killer in a lib sourced one hop from a .claude/settings.json hook script → RED' '.claude/hooks/lib/zz-helper.sh'
|
|
841
|
+
# Mutation (harness): the shim prints nothing, so the old hook kills nothing — the positive control must then FAIL
|
|
842
|
+
start_g1_decoy harness; DECOY=$SPAWNED
|
|
843
|
+
red 'Guard 1 mutant (harness): a pgrep shim that prints nothing → the old hook kills nothing, so the positive control would fail (the decoy is what makes it bite)' run_under_shim "$OLD_HOOK" "" "$DECOY"
|
|
844
|
+
|
|
845
|
+
# ===========================================================================
|
|
846
|
+
# Guard 2 — a launch claims a slot no live launch holds, and kills nothing
|
|
847
|
+
# ===========================================================================
|
|
848
|
+
MCP_ARGS="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['mcpServers']['playwright']['args'][1])" "$REPO_ROOT/.mcp.json")"
|
|
849
|
+
MCP_ARG0="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['mcpServers']['playwright']['args'][0])" "$REPO_ROOT/.mcp.json")"
|
|
850
|
+
# The npx shim records its argv (one JSON line), records its own pid, and STAYS ALIVE until the proxy ends it.
|
|
851
|
+
cat > "$G2/bin/npx" <<'SHIMSRC'
|
|
852
|
+
#!/usr/bin/env bash
|
|
853
|
+
python3 -c 'import json,sys; print(json.dumps(sys.argv[1:]))' "$@" >> "$SHIM_OUT"
|
|
854
|
+
echo $$ >> "$SHIM_OUT.pid"
|
|
855
|
+
exec sleep 300
|
|
856
|
+
SHIMSRC
|
|
857
|
+
chmod +x "$G2/bin/npx"
|
|
858
|
+
|
|
859
|
+
assert_true 'Guard 2: .mcp.json playwright command is bash -c <string>' test "$MCP_ARG0" = "-c"
|
|
860
|
+
assert_true 'Guard 2: the launch string carries no pkill, killall or pgrep (no pattern kill anywhere)' bash -c '! grep -Eq "pkill|killall|pgrep" <<< "$1"' _ "$MCP_ARGS"
|
|
861
|
+
assert_true 'Guard 2: the launch string sources the slot script, then execs env and the proxy with --user-data-dir="$prof" (quoted, once), --config and exactly one pin, and carries no unreachable [ -n "$prof" ] clause' python3 - "$MCP_ARGS" "$SLOT_REL" "$PROXY_REL_PATH" <<'PY'
|
|
862
|
+
import re, sys
|
|
863
|
+
s, slot, proxy = sys.argv[1:]
|
|
864
|
+
ok = (
|
|
865
|
+
s.startswith(". " + slot + " || exit 1; exec env ")
|
|
866
|
+
and proxy in s
|
|
867
|
+
and s.count('--user-data-dir="$prof"') == 1
|
|
868
|
+
and "--user-data-dir=$prof" not in s
|
|
869
|
+
and s.count("--config=.claude/playwright-mcp.config.json") == 1
|
|
870
|
+
and len(re.findall(r"@playwright/mcp@[0-9.]+", s)) == 1
|
|
871
|
+
and '[ -n "$prof"' not in s
|
|
872
|
+
)
|
|
873
|
+
sys.exit(0 if ok else 1)
|
|
874
|
+
PY
|
|
875
|
+
assert_true 'Guard 2: the slot script exists, uses return (never exit), sets no errexit/nounset, never removes recursively, assigns prof, and holds fd 9' python3 - "$REPO_ROOT/$SLOT_REL" <<'PY'
|
|
876
|
+
import re, sys
|
|
877
|
+
try:
|
|
878
|
+
src = open(sys.argv[1]).read()
|
|
879
|
+
except OSError:
|
|
880
|
+
sys.exit(1)
|
|
881
|
+
code = "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
|
|
882
|
+
sys.exit(0 if (
|
|
883
|
+
re.search(r"^\s*exit\b", code, re.M) is None
|
|
884
|
+
and re.search(r"\bset\s+-[a-zA-Z]*[eu]", code) is None
|
|
885
|
+
and re.search(r"\brm\s+(-[a-zA-Z]*[rR]|--recursive)", code) is None
|
|
886
|
+
and re.search(r"\breturn\b", code)
|
|
887
|
+
and re.search(r"\bprof=", code)
|
|
888
|
+
and re.search(r"9>", code)
|
|
889
|
+
) else 1)
|
|
890
|
+
PY
|
|
891
|
+
# default_wait_is <slot-script> <n>: the ${PW_PROFILE_SLOT0_WAIT_S:-N} default, read from CODE (comment lines and trailing comments stripped)
|
|
892
|
+
default_wait_is() {
|
|
893
|
+
python3 - "$1" "$2" <<'PY'
|
|
894
|
+
import re, sys
|
|
895
|
+
code = "\n".join(re.sub(r"\s#.*$", "", l) for l in open(sys.argv[1]).read().splitlines() if not l.lstrip().startswith("#"))
|
|
896
|
+
sys.exit(0 if re.findall(r"PW_PROFILE_SLOT0_WAIT_S:-([0-9.]+)\}", code) == [sys.argv[2]] else 1)
|
|
897
|
+
PY
|
|
898
|
+
}
|
|
899
|
+
# wait_pairs_grace <slot-script> <proxy>: the slot-0 wait covers the proxy teardown (GRACE_S + STDIN_CLOSE_WAIT_S) with at least a second to spare
|
|
900
|
+
wait_pairs_grace() {
|
|
901
|
+
python3 - "$1" "$2" <<'PY'
|
|
902
|
+
import re, sys
|
|
903
|
+
slot = "\n".join(re.sub(r"\s#.*$", "", l) for l in open(sys.argv[1]).read().splitlines() if not l.lstrip().startswith("#"))
|
|
904
|
+
proxy = open(sys.argv[2]).read()
|
|
905
|
+
w = float(re.findall(r"PW_PROFILE_SLOT0_WAIT_S:-([0-9.]+)\}", slot)[0])
|
|
906
|
+
g = float(re.search(r"^GRACE_S = ([0-9.]+)", proxy, re.M).group(1))
|
|
907
|
+
s = float(re.search(r"^STDIN_CLOSE_WAIT_S = ([0-9.]+)", proxy, re.M).group(1))
|
|
908
|
+
sys.exit(0 if w >= g + s + 1 else 1)
|
|
909
|
+
PY
|
|
910
|
+
}
|
|
911
|
+
assert_true 'Guard 2: the slot-0 reconnect wait defaults to 7 seconds (read from code, comments stripped)' default_wait_is "$REPO_ROOT/$SLOT_REL" 7
|
|
912
|
+
assert_true 'Guard 2: the slot-0 default wait covers the proxy teardown: >= GRACE_S + STDIN_CLOSE_WAIT_S + 1, both read from the proxy' wait_pairs_grace "$REPO_ROOT/$SLOT_REL" "$REPO_ROOT/$PROXY_REL_PATH"
|
|
913
|
+
|
|
914
|
+
# launch <tag> <root> <home> <cmd> [VAR=val...] — the launch string under bash -c, stdin held open by a sleeper
|
|
915
|
+
# (LAUNCH_PATH, when set, replaces the PATH the launch runs under)
|
|
916
|
+
launch() {
|
|
917
|
+
local tag="$1" root="$2" home="$3" cmd="$4"; shift 4
|
|
918
|
+
local fifo="$G2/$tag.fifo"
|
|
919
|
+
: > "$G2/$tag.jsonl"; rm -f "$G2/$tag.jsonl.pid" "$fifo"
|
|
920
|
+
mkdir -p "$home"; mkfifo "$fifo"
|
|
921
|
+
sleep 600 > "$fifo" &
|
|
922
|
+
printf '%s' "$!" > "$G2/$tag.sleeper"; own "$!"; disown "$!"
|
|
923
|
+
( cd "$root" && exec env "$@" SHIM_OUT="$G2/$tag.jsonl" HOME="$home" PATH="${LAUNCH_PATH:-$G2/bin:$PATH}" bash -c "$cmd" ) < "$fifo" > /dev/null 2> "$G2/$tag.err" &
|
|
924
|
+
printf '%s' "$!" > "$G2/$tag.pid"; own "$!"; disown "$!"
|
|
925
|
+
}
|
|
926
|
+
# wait_ready <tag> — 0 when the shim ran (its argv is recorded), 1 when the launch died first (a FAIL for the calling row). A launch
|
|
927
|
+
# that is STILL RUNNING after 30 s without reaching the shim is UNRESOLVED (the host is too loaded to judge the row, or the slot script
|
|
928
|
+
# hangs; not a verdict on any guard): the suite stops with that message, rc 2, instead of letting the row read as "mutant survived".
|
|
929
|
+
WAIT_READY_TICKS=300 # 0.1 s ticks; a helper control shortens it in a subshell
|
|
930
|
+
wait_ready() {
|
|
931
|
+
local tag="$1" i=0
|
|
932
|
+
while [[ ! -s "$G2/$tag.jsonl" && $i -lt $WAIT_READY_TICKS ]]; do
|
|
933
|
+
if ! alive "$(cat "$G2/$tag.pid")"; then break; fi
|
|
934
|
+
sleep 0.1; i=$((i + 1))
|
|
935
|
+
done
|
|
936
|
+
if [[ -s "$G2/$tag.jsonl" ]]; then return 0; fi
|
|
937
|
+
if alive "$(cat "$G2/$tag.pid")"; then
|
|
938
|
+
printf 'UNRESOLVED: launch %s was still running after %d s without reaching the server shim: the host is too loaded to judge this row, or the slot script hangs; rerun when the host is idle, and read the slot script if it still hangs\n' "$tag" "$((WAIT_READY_TICKS / 10))" >&2
|
|
939
|
+
exit 2
|
|
940
|
+
fi
|
|
941
|
+
return 1
|
|
942
|
+
}
|
|
943
|
+
end_launch() { # <tag> — close the launch's stdin (kill its sleeper), wait for it to exit
|
|
944
|
+
local tag="$1" s
|
|
945
|
+
s="$(cat "$G2/$tag.sleeper" 2>/dev/null || true)"
|
|
946
|
+
if [[ -n "$s" ]]; then kill "$s" 2>/dev/null || true; fi
|
|
947
|
+
wait_dead "$(cat "$G2/$tag.pid")" 8 || true
|
|
948
|
+
}
|
|
949
|
+
prof_of() { # <tag> — the --user-data-dir the server was launched with
|
|
950
|
+
python3 -c 'import json,sys
|
|
951
|
+
for l in open(sys.argv[1]):
|
|
952
|
+
for a in json.loads(l):
|
|
953
|
+
if a.startswith("--user-data-dir="): print(a.split("=", 1)[1]); sys.exit(0)' "$G2/$1.jsonl"
|
|
954
|
+
}
|
|
955
|
+
# run_n <pfx> <root> <home> <cmd> <n> <overlap|sequential> [VAR=val...]
|
|
956
|
+
# sets RN_PROFS (one per line), RN_RAN (shims that ran), RN_OVERLAP (every launch alive AND every distinct profile's lock held at one instant),
|
|
957
|
+
# RN_MS (milliseconds from starting the first launch until its server shim ran)
|
|
958
|
+
run_n() {
|
|
959
|
+
local pfx="$1" root="$2" home="$3" cmd="$4" n="$5" mode="$6" i tag p t0; shift 6
|
|
960
|
+
RN_PROFS=""; RN_RAN=0; RN_OVERLAP=1; RN_MS=0
|
|
961
|
+
for ((i = 1; i <= n; i++)); do
|
|
962
|
+
tag="$pfx-$i"
|
|
963
|
+
t0="$(now_ms)"
|
|
964
|
+
launch "$tag" "$root" "$home" "$cmd" "$@"
|
|
965
|
+
if wait_ready "$tag"; then RN_RAN=$((RN_RAN + 1)); RN_PROFS+="$(prof_of "$tag")"$'\n'; else RN_OVERLAP=0; fi
|
|
966
|
+
if [[ $i -eq 1 ]]; then RN_MS=$(( $(now_ms) - t0 )); fi
|
|
967
|
+
if [[ "$mode" == sequential ]]; then end_launch "$tag"; fi
|
|
968
|
+
done
|
|
969
|
+
for ((i = 1; i <= n; i++)); do
|
|
970
|
+
if ! alive "$(cat "$G2/$pfx-$i.pid")"; then RN_OVERLAP=0; fi
|
|
971
|
+
done
|
|
972
|
+
while read -r p; do
|
|
973
|
+
if [[ -n "$p" ]] && ! lock_held "$p/$LOCKNAME"; then RN_OVERLAP=0; fi
|
|
974
|
+
done <<< "$RN_PROFS"
|
|
975
|
+
for ((i = 1; i <= n; i++)); do end_launch "$pfx-$i"; done
|
|
976
|
+
}
|
|
977
|
+
rn_distinct() { [[ "$RN_RAN" -eq "$1" && "$(sort -u <<< "$RN_PROFS" | grep -c . || true)" -eq "$1" ]]; }
|
|
978
|
+
rn_overlap() { [[ "$RN_OVERLAP" -eq 1 ]]; }
|
|
979
|
+
prof0() { printf '%s/.cache/playwright-mcp-profile' "$1"; }
|
|
980
|
+
FAST=PW_PROFILE_SLOT0_WAIT_S=0.3
|
|
981
|
+
|
|
982
|
+
# --- real tree: overlapped launches ---
|
|
983
|
+
H="$G2/h-two"
|
|
984
|
+
run_n real2 "$REPO_ROOT" "$H" "$MCP_ARGS" 2 overlap "$FAST"
|
|
985
|
+
assert_true 'Guard 2: two overlapped launches under one HOME both ran the server and resolve DISTINCT --user-data-dir values' rn_distinct 2
|
|
986
|
+
assert_true 'Guard 2: the first launch took the persistent slot 0 profile' bash -c '[[ "$(head -n1 <<< "$1")" == "$2" ]]' _ "$RN_PROFS" "$(prof0 "$H")"
|
|
987
|
+
assert_true 'Guard 2: overlap proven — both launches were alive and both slot locks were held at the same instant' rn_overlap
|
|
988
|
+
assert_true 'Guard 2: the slot-0 lease wrote its owner file (the launcher PPID) with mode 600 and the slot directory is mode 700' bash -c '[[ "$(stat -c %a "$1/.pwslot.owner")" == 600 && "$(stat -c %a "$1")" == 700 && "$(cat "$1/.pwslot.owner")" == "$2" ]]' _ "$(prof0 "$H")" "$$"
|
|
989
|
+
H="$G2/h-three"
|
|
990
|
+
run_n real3 "$REPO_ROOT" "$H" "$MCP_ARGS" 3 overlap "$FAST"
|
|
991
|
+
assert_true 'Guard 2: a third concurrent launch also gets its own directory (three distinct)' rn_distinct 3
|
|
992
|
+
assert_true 'Guard 2: overlap proven for three launches' rn_overlap
|
|
993
|
+
assert_true 'Guard 2: after every launch exited, slot 0 is free again (the kernel released the lock)' lock_free "$(prof0 "$H")/$LOCKNAME"
|
|
994
|
+
|
|
995
|
+
# --- the lease belongs to the proxy process: fd 9 is held by it and is NOT inherited by the server child ---
|
|
996
|
+
H="$G2/h-fd"
|
|
997
|
+
launch fd1 "$REPO_ROOT" "$H" "$MCP_ARGS" "$FAST"
|
|
998
|
+
fd_ready=0; if wait_ready fd1; then fd_ready=1; fi
|
|
999
|
+
fd_proxy="$(cat "$G2/fd1.pid")"; fd_child="$(head -n1 "$G2/fd1.jsonl.pid" 2>/dev/null || true)"
|
|
1000
|
+
lease_in_proxy_only() { [[ "$fd_ready" -eq 1 && -n "$fd_child" && "$(readlink "/proc/$fd_proxy/fd/9" 2>/dev/null)" == *"/$LOCKNAME" && ! -e "/proc/$fd_child/fd/9" ]]; }
|
|
1001
|
+
assert_true 'Guard 2: the lease (fd 9 on the slot lock) is held by the proxy process and is not inherited by the server child, so it dies with the proxy' lease_in_proxy_only
|
|
1002
|
+
end_launch fd1
|
|
1003
|
+
|
|
1004
|
+
# --- SIGKILL of the proxy under lease: the kernel releases the lock (the server child does not hold it) ---
|
|
1005
|
+
H="$G2/h-kill"
|
|
1006
|
+
launch kill1 "$REPO_ROOT" "$H" "$MCP_ARGS" "$FAST"
|
|
1007
|
+
kill_ready=0; if wait_ready kill1; then kill_ready=1; fi
|
|
1008
|
+
kill_held=0; if lock_held "$(prof0 "$H")/$LOCKNAME"; then kill_held=1; fi
|
|
1009
|
+
kill_proxy="$(cat "$G2/kill1.pid")"
|
|
1010
|
+
kill -KILL "$kill_proxy" 2>/dev/null || true
|
|
1011
|
+
wait_dead "$kill_proxy" 3 || true
|
|
1012
|
+
sigkill_released() { if [[ "$kill_ready" -eq 1 && "$kill_held" -eq 1 ]] && ! alive "$kill_proxy" && lock_free "$(prof0 "$H")/$LOCKNAME"; then return 0; fi; return 1; }
|
|
1013
|
+
assert_true 'Guard 2: the lock was held while the proxy ran, and a SIGKILL of the proxy released it (the lease dies with the proxy)' sigkill_released
|
|
1014
|
+
end_launch kill1
|
|
1015
|
+
|
|
1016
|
+
# --- a launch kills nothing: four decoy shapes, each a different selector, all survive the launch and its teardown ---
|
|
1017
|
+
# D_SLOT0: chrome on the claimed slot's profile (the old reaper's own pattern) D_OTHER: chrome on ANOTHER slot's profile
|
|
1018
|
+
# D_SRV: a sibling playwright-mcp server (node .../bin/playwright-mcp --user-data-dir=<other slot>) D_FD: a Chrome holding a file open inside the claimed slot
|
|
1019
|
+
D_SLOT0=0; D_OTHER=0; D_SRV=0; D_FD=0
|
|
1020
|
+
spawn_decoys() { # <home>
|
|
1021
|
+
mkdir -p "$(prof0 "$1")"
|
|
1022
|
+
start_decoy "chrome --user-data-dir=$(prof0 "$1")"; D_SLOT0=$SPAWNED
|
|
1023
|
+
start_decoy "chrome --user-data-dir=$(prof0 "$1")-1"; D_OTHER=$SPAWNED
|
|
1024
|
+
start_decoy "node /synthetic/node_modules/@playwright/mcp/bin/playwright-mcp --user-data-dir=$(prof0 "$1")-2"; D_SRV=$SPAWNED
|
|
1025
|
+
start_holder "chrome --type=renderer" "$(prof0 "$1")/Cookies"; D_FD=$SPAWNED
|
|
1026
|
+
}
|
|
1027
|
+
H="$G2/h-decoy"
|
|
1028
|
+
spawn_decoys "$H"
|
|
1029
|
+
assert_true 'Guard 2 harness: the slot-0 decoy argv matches the pattern the old reaper used (chrome.*<slot0>), read from /proc, nothing signalled' python3 - "$D_SLOT0" "$(prof0 "$H")" <<'PY'
|
|
1030
|
+
import re, sys
|
|
1031
|
+
cmd = open(f"/proc/{sys.argv[1]}/cmdline", "rb").read().replace(b"\0", b" ").decode()
|
|
1032
|
+
sys.exit(0 if re.search(r"chrome.*" + re.escape(sys.argv[2]), cmd) else 1)
|
|
1033
|
+
PY
|
|
1034
|
+
fd_holder_holds() { [[ "$(readlink "/proc/$D_FD/fd/7" 2>/dev/null)" == "$(prof0 "$H")/Cookies" ]]; }
|
|
1035
|
+
assert_true 'Guard 2 harness: the file-holder decoy really holds a file open inside the claimed slot (fd 7 -> <slot0>/Cookies)' fd_holder_holds
|
|
1036
|
+
run_n decoy "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1037
|
+
assert_true 'Guard 2: the launch ran (the shim recorded its argv)' test "$RN_RAN" -eq 1
|
|
1038
|
+
assert_true 'Guard 2: the decoy Chrome on the claimed profile is still alive after the launch and its teardown' alive "$D_SLOT0"
|
|
1039
|
+
assert_true 'Guard 2: a decoy Chrome on ANOTHER slot'"'"'s profile is still alive after the launch and its teardown' alive "$D_OTHER"
|
|
1040
|
+
assert_true 'Guard 2: a decoy sibling playwright-mcp server (node .../bin/playwright-mcp --user-data-dir=<other slot>) is still alive after the launch and its teardown' alive "$D_SRV"
|
|
1041
|
+
assert_true 'Guard 2: a decoy holding a file open inside the claimed slot is still alive after the launch and its teardown' alive "$D_FD"
|
|
1042
|
+
|
|
1043
|
+
# --- stale SingletonLock (dead owner) is cleared and slot 0 reused ---
|
|
1044
|
+
H="$G2/h-stale"; mkdir -p "$(prof0 "$H")"
|
|
1045
|
+
sleep 0 & dead=$!; wait "$dead" || true
|
|
1046
|
+
if alive "$dead"; then dead=2147483646; fi
|
|
1047
|
+
ln -s "$HOST_NOW-$dead" "$(prof0 "$H")/SingletonLock"; : > "$(prof0 "$H")/SingletonCookie"; : > "$(prof0 "$H")/SingletonSocket"
|
|
1048
|
+
run_n stale "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1049
|
+
stale_state() { prof_is "$(prof0 "$H")" && no_link "$(prof0 "$H")" && [[ ! -e "$(prof0 "$H")/SingletonCookie" && ! -e "$(prof0 "$H")/SingletonSocket" ]]; }
|
|
1050
|
+
assert_true 'Guard 2 must-PASS: a stale SingletonLock naming a dead pid on this host is cleared (lock, cookie, socket) and slot 0 is reused' stale_state
|
|
1051
|
+
|
|
1052
|
+
# --- a live CHROME owner with a FREE flock (proxy gone, Chrome alive): the slot is skipped, the lock left alone ---
|
|
1053
|
+
H="$G2/h-owner"; mkdir -p "$(prof0 "$H")"
|
|
1054
|
+
start_chrome_owner; OWNER=$SPAWNED
|
|
1055
|
+
ln -s "$HOST_NOW-$OWNER" "$(prof0 "$H")/SingletonLock"
|
|
1056
|
+
run_n owner "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1057
|
+
owner_skipped() { prof_is "$(prof0 "$H")-1"; }
|
|
1058
|
+
owner_lock_kept() { link_is "$(prof0 "$H")" "$HOST_NOW-$OWNER" && alive "$OWNER"; }
|
|
1059
|
+
assert_true 'Guard 2 must-PASS: a free lock but a live-Chrome-owner SingletonLock skips the slot (the launch lands on slot 1)' owner_skipped
|
|
1060
|
+
assert_true 'Guard 2 must-PASS: the live owner'"'"'s SingletonLock was left in place and its owner was not touched' owner_lock_kept
|
|
1061
|
+
assert_true 'Guard 2: skipping slot 0 prints exactly ONE stderr line, naming the live Chrome and that the persistent profile'"'"'s logins are not available in this session' bash -c '[[ "$(grep -c "^playwright-mcp-profile-slot:" "$1")" == 1 && "$(grep "^playwright-mcp-profile-slot:" "$1")" == *"live Chrome"*"logins are not available in this session"* ]]' _ "$G2/owner-1.err"
|
|
1062
|
+
|
|
1063
|
+
# --- a recycled pid: the lock names a live pid that is NOT a Chrome -> stale, cleared, slot 0 reused ---
|
|
1064
|
+
H="$G2/h-recycled"; mkdir -p "$(prof0 "$H")"
|
|
1065
|
+
start_sleeper; RECYCLED=$SPAWNED
|
|
1066
|
+
ln -s "$HOST_NOW-$RECYCLED" "$(prof0 "$H")/SingletonLock"
|
|
1067
|
+
run_n recycled "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1068
|
+
recycled_state() { prof_is "$(prof0 "$H")" && no_link "$(prof0 "$H")" && alive "$RECYCLED"; }
|
|
1069
|
+
assert_true 'Guard 2 must-PASS: a SingletonLock naming a live pid whose comm is not Chrome (a recycled pid) is stale: cleared, slot 0 reused, the unrelated process untouched' recycled_state
|
|
1070
|
+
|
|
1071
|
+
# --- a live Chromium derivative (comm msedge, brave, vivaldi-bin, opera) or headless build (comm headless_shell) owner is a browser too: kept, never read as a recycled pid ---
|
|
1072
|
+
for _c in msedge brave vivaldi-bin opera headless_shell; do
|
|
1073
|
+
H="$G2/h-comm-$_c"; mkdir -p "$(prof0 "$H")"
|
|
1074
|
+
start_comm_owner "$_c"; COMM_OWNER=$SPAWNED
|
|
1075
|
+
ln -s "$HOST_NOW-$COMM_OWNER" "$(prof0 "$H")/SingletonLock"
|
|
1076
|
+
run_n "comm-$_c" "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1077
|
+
comm_kept() { prof_is "$(prof0 "$H")-1" && link_is "$(prof0 "$H")" "$HOST_NOW-$COMM_OWNER" && alive "$COMM_OWNER"; }
|
|
1078
|
+
assert_true "Guard 2 must-PASS: a free lock but a live owner whose comm is $_c skips the slot and keeps its SingletonLock (a browser, not a recycled pid)" comm_kept
|
|
1079
|
+
done
|
|
1080
|
+
|
|
1081
|
+
# --- a recycled pid whose comm merely CONTAINS a derivative's name ("operator", "knowledge-bas": opera / edge) is not a browser: stale, slot 0 reclaimed ---
|
|
1082
|
+
for _c in operator knowledge-bas; do
|
|
1083
|
+
H="$G2/h-notcomm-$_c"; mkdir -p "$(prof0 "$H")"
|
|
1084
|
+
start_comm_owner "$_c"; COMM_OWNER=$SPAWNED
|
|
1085
|
+
ln -s "$HOST_NOW-$COMM_OWNER" "$(prof0 "$H")/SingletonLock"
|
|
1086
|
+
run_n "notcomm-$_c" "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1087
|
+
notcomm_stale() { prof_is "$(prof0 "$H")" && no_link "$(prof0 "$H")" && alive "$COMM_OWNER"; }
|
|
1088
|
+
assert_true "Guard 2 must-PASS: a SingletonLock naming a live pid whose comm is $_c (a substring of a derivative's name, not a browser) is stale: cleared, slot 0 reused, the process untouched" notcomm_stale
|
|
1089
|
+
done
|
|
1090
|
+
|
|
1091
|
+
# --- a lock naming ANOTHER HOST is never removed, whatever the local pid says ---
|
|
1092
|
+
H="$G2/h-foreign"; mkdir -p "$(prof0 "$H")"
|
|
1093
|
+
ln -s "otherhost-$dead" "$(prof0 "$H")/SingletonLock"
|
|
1094
|
+
run_n foreign "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1095
|
+
foreign_state() { prof_is "$(prof0 "$H")-1" && link_is "$(prof0 "$H")" "otherhost-$dead"; }
|
|
1096
|
+
assert_true 'Guard 2 must-PASS: a SingletonLock naming another host (even with a pid that is dead here) is kept and the slot skipped' foreign_state
|
|
1097
|
+
|
|
1098
|
+
# --- a reconnect (the old holder exits within the 7 s wait) reacquires slot 0 rather than moving to slot 1 ---
|
|
1099
|
+
H="$G2/h-reconnect"
|
|
1100
|
+
launch rc-old "$REPO_ROOT" "$H" "$MCP_ARGS"
|
|
1101
|
+
wait_ready rc-old || true
|
|
1102
|
+
launch rc-new "$REPO_ROOT" "$H" "$MCP_ARGS"
|
|
1103
|
+
sleep 1
|
|
1104
|
+
end_launch rc-old
|
|
1105
|
+
rc_got=0; if wait_ready rc-new; then rc_got=1; fi
|
|
1106
|
+
rc_prof="$(if [[ $rc_got -eq 1 ]]; then prof_of rc-new; fi)"
|
|
1107
|
+
end_launch rc-new
|
|
1108
|
+
assert_true 'Guard 2 must-PASS: a reconnect — the old holder (same launcher) leaves within the wait — reacquires slot 0, not slot 1' test "$rc_got:$rc_prof" = "1:$(prof0 "$H")"
|
|
1109
|
+
|
|
1110
|
+
# --- owner-aware wait: the same session's predecessor is waited for, a DIFFERENT session's lock is not, an unknown owner is ---
|
|
1111
|
+
OTHER_SESSION=2147483646
|
|
1112
|
+
H="$G2/h-same"; hold_lock "$(prof0 "$H")" 2 "$$"
|
|
1113
|
+
run_n same "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap PW_PROFILE_SLOT0_WAIT_S=4
|
|
1114
|
+
same_waits() { prof_is "$(prof0 "$H")" && [[ "$RN_MS" -ge 1200 ]]; }
|
|
1115
|
+
assert_true 'Guard 2: slot 0 held by this session'"'"'s own launcher (owner file == $PPID) is waited for and reacquired when the holder leaves' same_waits
|
|
1116
|
+
H="$G2/h-other"; hold_lock "$(prof0 "$H")" 9 "$OTHER_SESSION"
|
|
1117
|
+
run_n other "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap PW_PROFILE_SLOT0_WAIT_S=4
|
|
1118
|
+
other_no_wait() { prof_is "$(prof0 "$H")-1" && [[ "$RN_MS" -lt 2500 ]]; }
|
|
1119
|
+
assert_true 'Guard 2: slot 0 held by a DIFFERENT session is skipped at once, without the wait (slot 1 in under 2.5 s with a 4 s budget)' other_no_wait
|
|
1120
|
+
assert_true 'Guard 2: that skip printed exactly ONE stderr line saying slot 0 was skipped and its logins are not available' bash -c '[[ "$(grep -c "^playwright-mcp-profile-slot:" "$1")" == 1 && "$(grep "^playwright-mcp-profile-slot:" "$1")" == *"slot 0"*"was skipped"*"logins are not available in this session"* ]]' _ "$G2/other-1.err"
|
|
1121
|
+
H="$G2/h-unknown"; hold_lock "$(prof0 "$H")" 2
|
|
1122
|
+
run_n unknown "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap PW_PROFILE_SLOT0_WAIT_S=4
|
|
1123
|
+
unknown_waits() { prof_is "$(prof0 "$H")" && [[ "$RN_MS" -ge 1200 ]]; }
|
|
1124
|
+
assert_true 'Guard 2: slot 0 held with NO owner file (unknown owner) is waited for (fails toward the persistent profile)' unknown_waits
|
|
1125
|
+
# a live-Chrome SingletonLock whose Chrome exits within the wait: same session -> slot 0 is reacquired after the recheck
|
|
1126
|
+
H="$G2/h-chromeexit"; mkdir -p "$(prof0 "$H")"; printf '%s\n' "$$" > "$(prof0 "$H")/.pwslot.owner"
|
|
1127
|
+
start_chrome_owner; CEXIT=$SPAWNED
|
|
1128
|
+
ln -s "$HOST_NOW-$CEXIT" "$(prof0 "$H")/SingletonLock"
|
|
1129
|
+
( sleep 1.2; kill -KILL "$CEXIT" 2>/dev/null || true ) >/dev/null 2>&1 & disown "$!"
|
|
1130
|
+
run_n chromeexit "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap PW_PROFILE_SLOT0_WAIT_S=4
|
|
1131
|
+
chromeexit_rechecks() { prof_is "$(prof0 "$H")" && no_link "$(prof0 "$H")" && [[ "$RN_MS" -ge 900 ]]; }
|
|
1132
|
+
assert_true 'Guard 2: a live-Chrome SingletonLock on slot 0 gets the same wait-then-recheck: the Chrome exits within the wait and slot 0 is reacquired' chromeexit_rechecks
|
|
1133
|
+
|
|
1134
|
+
# --- a non-numeric PW_PROFILE_SLOT0_WAIT_S is noted and replaced by the default; it never degrades the lease ---
|
|
1135
|
+
H="$G2/h-badwait"; hold_lock "$(prof0 "$H")" 2 "$$"
|
|
1136
|
+
run_n badwait "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap PW_PROFILE_SLOT0_WAIT_S=abc
|
|
1137
|
+
badwait_state() { prof_is "$(prof0 "$H")" && grep -q "ignored PW_PROFILE_SLOT0_WAIT_S='abc'" "$G2/badwait-1.err"; }
|
|
1138
|
+
assert_true 'Guard 2: PW_PROFILE_SLOT0_WAIT_S=abc is noted on stderr and the 7 s default applies (the launch still reacquires slot 0)' badwait_state
|
|
1139
|
+
|
|
1140
|
+
# --- an unusable middle slot does not end the search: slot 1 is a regular file, the launch continues to slot 2 ---
|
|
1141
|
+
H="$G2/h-mid"; hold_lock "$(prof0 "$H")" 30 "$OTHER_SESSION"; : > "$(prof0 "$H")-1"
|
|
1142
|
+
run_n mid "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1143
|
+
assert_true 'Guard 2: slot 0 busy and slot 1 unusable (a regular file) -> the launch continues to slot 2 instead of the unique fallback' prof_is "$(prof0 "$H")-2"
|
|
1144
|
+
|
|
1145
|
+
# --- restricted PATHs: no flock, and no ps ---
|
|
1146
|
+
mkbin() { # <dir> [extra tool...]: the tools the launch string needs, plus the npx shim, and nothing else
|
|
1147
|
+
local d="$1" t tp; shift
|
|
1148
|
+
assert_fixture_dir "$d"
|
|
1149
|
+
mkdir -p "$d"
|
|
1150
|
+
for t in bash env python3 mkdir rm readlink sleep cat dirname basename head sed grep tr ls uname "$@"; do
|
|
1151
|
+
tp="$(command -v "$t" 2>/dev/null || true)"
|
|
1152
|
+
if [[ -n "$tp" ]]; then ln -sf "$tp" "$d/$t"; fi
|
|
1153
|
+
done
|
|
1154
|
+
cp "$G2/bin/npx" "$d/npx"
|
|
1155
|
+
}
|
|
1156
|
+
NOFLOCK="$G2/noflock-bin"; mkbin "$NOFLOCK" ps
|
|
1157
|
+
NOPS="$G2/nops-bin"; mkbin "$NOPS" flock
|
|
1158
|
+
no_flock_on_path() { ! PATH="$NOFLOCK" command -v flock >/dev/null 2>&1; }
|
|
1159
|
+
assert_true 'Guard 2 harness: flock really was absent from the restricted PATH' no_flock_on_path
|
|
1160
|
+
no_ps_on_path() { ! PATH="$NOPS" command -v ps >/dev/null 2>&1 && PATH="$NOPS" command -v flock >/dev/null 2>&1; }
|
|
1161
|
+
assert_true 'Guard 2 harness: ps really was absent (and flock present) on the second restricted PATH' no_ps_on_path
|
|
1162
|
+
|
|
1163
|
+
# no flock: the FIRST session still gets the persistent profile (slot 0 claimed without a lease) and says so
|
|
1164
|
+
H="$G2/h-noflock"
|
|
1165
|
+
LAUNCH_PATH="$NOFLOCK" run_n nf1 "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap
|
|
1166
|
+
assert_true 'Guard 2 must-PASS: with no flock on PATH the first launch starts on the persistent slot 0 profile (claimed without a lease)' prof_is "$(prof0 "$H")"
|
|
1167
|
+
assert_true 'Guard 2: that no-flock launch noted on stderr that it runs without a lease' grep -q 'without a lease' "$G2/nf1-1.err"
|
|
1168
|
+
# no flock and slot 0 owned by a live Chrome: the unique fallback $base-p<pid>, created mode 700
|
|
1169
|
+
start_chrome_owner; NFOWNER=$SPAWNED
|
|
1170
|
+
ln -s "$HOST_NOW-$NFOWNER" "$(prof0 "$H")/SingletonLock"
|
|
1171
|
+
LAUNCH_PATH="$NOFLOCK" run_n nf2 "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap
|
|
1172
|
+
nf2_state() { local pid; pid="$(cat "$G2/nf2-1.pid")"; prof_is "$(prof0 "$H")-p$pid" && mode_is "$(prof0 "$H")-p$pid" 700 && link_is "$(prof0 "$H")" "$HOST_NOW-$NFOWNER"; }
|
|
1173
|
+
assert_true 'Guard 2 must-PASS: with no flock and a live-Chrome-owned slot 0 the launch uses a unique $base-p<pid> directory (mode 700) and leaves the lock alone' nf2_state
|
|
1174
|
+
|
|
1175
|
+
# no ps: a live Chrome-owned lock is KEPT (the probe never needs ps), and a dead owner's lock is still cleared
|
|
1176
|
+
H="$G2/h-nops"; mkdir -p "$(prof0 "$H")"
|
|
1177
|
+
start_chrome_owner; NPOWNER=$SPAWNED
|
|
1178
|
+
ln -s "$HOST_NOW-$NPOWNER" "$(prof0 "$H")/SingletonLock"
|
|
1179
|
+
LAUNCH_PATH="$NOPS" run_n nops "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1180
|
+
nops_kept() { prof_is "$(prof0 "$H")-1" && link_is "$(prof0 "$H")" "$HOST_NOW-$NPOWNER" && alive "$NPOWNER"; }
|
|
1181
|
+
assert_true 'Guard 2 must-PASS: with ps absent a live Chrome-owned SingletonLock is kept and the slot skipped (an unprobeable owner is never treated as stale)' nops_kept
|
|
1182
|
+
H="$G2/h-nops-dead"; mkdir -p "$(prof0 "$H")"
|
|
1183
|
+
ln -s "$HOST_NOW-$dead" "$(prof0 "$H")/SingletonLock"
|
|
1184
|
+
LAUNCH_PATH="$NOPS" run_n nopsd "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1185
|
+
nopsd_cleared() { prof_is "$(prof0 "$H")" && no_link "$(prof0 "$H")"; }
|
|
1186
|
+
assert_true 'Guard 2 must-PASS: with ps absent a dead owner'"'"'s lock is still cleared (/proc and kill -0 answer without ps)' nopsd_cleared
|
|
1187
|
+
|
|
1188
|
+
# --- launches released at the SAME instant (a barrier): the flock capability probe must not make them contend ---
|
|
1189
|
+
# The rows above start their launches one after another, which is why a probe that locked one shared inode (/dev/null) and
|
|
1190
|
+
# read a lost race as "no usable flock" stayed green: concurrent launches then took the no-lease branch and shared slot 0.
|
|
1191
|
+
# bar_launch <tag> <home> <go-microsecond-epoch> <script> [VAR=val...]: sources <script>, records $prof, holds fd 9 (exec sleep) like the proxy
|
|
1192
|
+
BAR_PIDS="$WORK/bar-pids"; : > "$BAR_PIDS"
|
|
1193
|
+
bar_launch() {
|
|
1194
|
+
local tag="$1" home="$2" go="$3" script="$4"; shift 4
|
|
1195
|
+
rm -f "$G2/$tag.prof" "$G2/$tag.err"
|
|
1196
|
+
mkdir -p "$home"
|
|
1197
|
+
( cd "$REPO_ROOT" && exec env "$@" HOME="$home" PW_PROFILE_SLOT0_WAIT_S=0.3 bash -c 'r=$(( ($1 - ${EPOCHREALTIME/./}) / 1000 - 150 )); if [ "$r" -gt 0 ]; then printf -v s "%d.%03d" $((r / 1000)) $((r % 1000)); sleep "$s"; fi; while [ "${EPOCHREALTIME/./}" -lt "$1" ]; do :; done; . "$2" || exit 1; printf "%s\n" "$prof" > "$3"; exec sleep 12' _ "$go" "$script" "$G2/$tag.prof" ) > /dev/null 2> "$G2/$tag.err" &
|
|
1198
|
+
printf '%s\n' "$!" >> "$BAR_PIDS"; own "$!"; disown "$!"
|
|
1199
|
+
}
|
|
1200
|
+
# barrier_run <pfx> <home> <n> <script> [VAR=val...]: n launches released together; sets BAR_RAN (profiles recorded), BAR_DISTINCT, BAR_NOTES ("no usable flock" notes)
|
|
1201
|
+
# A launcher that EXITED without recording a profile is a failure of the slot script (as wait_ready treats a dead launch): BAR_DEAD counts
|
|
1202
|
+
# them, each is named on stderr with the head of its error output, and the row reading BAR_RAN fails. Only a launcher still RUNNING after
|
|
1203
|
+
# 60 s is UNRESOLVED (the host is too loaded to judge the row, or the slot script hangs): the suite stops with that message, rc 2.
|
|
1204
|
+
BAR_RAN=0; BAR_DISTINCT=0; BAR_NOTES=0; BAR_DEAD=0
|
|
1205
|
+
BAR_WAIT_TICKS=600 # 0.1 s ticks; a helper control shortens it in a subshell
|
|
1206
|
+
barrier_run() {
|
|
1207
|
+
local pfx="$1" home="$2" n="$3" script="$4" i t=0 go pid; shift 4
|
|
1208
|
+
: > "$BAR_PIDS"; BAR_RAN=0; BAR_DISTINCT=0; BAR_NOTES=0; BAR_DEAD=0
|
|
1209
|
+
go=$(( ${EPOCHREALTIME/./} + 2500000 ))
|
|
1210
|
+
for ((i = 1; i <= n; i++)); do bar_launch "$pfx-$i" "$home" "$go" "$script" "$@"; done
|
|
1211
|
+
while [[ $t -lt $BAR_WAIT_TICKS ]]; do
|
|
1212
|
+
BAR_RAN=0; BAR_DEAD=0
|
|
1213
|
+
for ((i = 1; i <= n; i++)); do
|
|
1214
|
+
if [[ -s "$G2/$pfx-$i.prof" ]]; then BAR_RAN=$((BAR_RAN + 1))
|
|
1215
|
+
else
|
|
1216
|
+
pid="$(sed -n "${i}p" "$BAR_PIDS")"
|
|
1217
|
+
if ! alive "$pid"; then BAR_DEAD=$((BAR_DEAD + 1)); fi
|
|
1218
|
+
fi
|
|
1219
|
+
done
|
|
1220
|
+
if [[ $((BAR_RAN + BAR_DEAD)) -eq $n ]]; then break; fi
|
|
1221
|
+
sleep 0.1; t=$((t + 1))
|
|
1222
|
+
done
|
|
1223
|
+
if [[ $BAR_DEAD -gt 0 ]]; then
|
|
1224
|
+
for ((i = 1; i <= n; i++)); do
|
|
1225
|
+
if [[ ! -s "$G2/$pfx-$i.prof" ]]; then printf 'FAIL: barrier launcher %s-%d exited without recording a profile: %s\n' "$pfx" "$i" "$(head -c 200 "$G2/$pfx-$i.err" | tr '\n' ' ')" >&2; fi
|
|
1226
|
+
done
|
|
1227
|
+
elif [[ $BAR_RAN -ne $n ]]; then
|
|
1228
|
+
printf 'UNRESOLVED: only %d of %d barrier launches (%s) recorded a profile within %d s and the rest are still running: the host is too loaded to judge this row, or the slot script hangs; rerun when the host is idle\n' "$BAR_RAN" "$n" "$pfx" "$((BAR_WAIT_TICKS / 10))" >&2; exit 2
|
|
1229
|
+
fi
|
|
1230
|
+
BAR_DISTINCT="$(cat "$G2/$pfx"-*.prof 2> /dev/null | sort -u | grep -c . || true)"
|
|
1231
|
+
BAR_NOTES="$(cat "$G2/$pfx"-*.err 2> /dev/null | grep -c 'no usable flock' || true)"
|
|
1232
|
+
reap_list "$BAR_PIDS"
|
|
1233
|
+
}
|
|
1234
|
+
# controls for the abort paths of wait_ready and barrier_run, each driven by a stub in a subshell (a regression that made them never fire
|
|
1235
|
+
# would otherwise go unnoticed): a launch that DIED is rc 1 (a FAIL for its row), never rc 2; one STILL RUNNING past the limit is UNRESOLVED, rc 2
|
|
1236
|
+
CTL_SLOT_FAIL="$G2/ctl-slot-fail.sh"; printf 'return 1\n' > "$CTL_SLOT_FAIL"
|
|
1237
|
+
CTL_SLOT_HANG="$G2/ctl-slot-hang.sh"; printf 'exec sleep 6\n' > "$CTL_SLOT_HANG"
|
|
1238
|
+
launch ctlwr1 "$REPO_ROOT" "$G2/h-ctlwr1" 'exec sleep 5'
|
|
1239
|
+
ctl_wr1_rc=0; ( WAIT_READY_TICKS=10; wait_ready ctlwr1 ) 2> "$G2/ctlwr1.msg" || ctl_wr1_rc=$?
|
|
1240
|
+
end_launch ctlwr1
|
|
1241
|
+
ctl_wr_hang() { if [[ "$ctl_wr1_rc" -eq 2 ]] && grep -q '^UNRESOLVED: launch ctlwr1 was still running after 1 s .*or the slot script hangs' "$G2/ctlwr1.msg"; then return 0; fi; return 1; }
|
|
1242
|
+
assert_true 'Guard 2 control: wait_ready on a launch still running past its limit stops the suite with UNRESOLVED (rc 2) and names a hang as well as a loaded host' ctl_wr_hang
|
|
1243
|
+
launch ctlwr2 "$REPO_ROOT" "$G2/h-ctlwr2" 'exit 3'
|
|
1244
|
+
ctl_wr2_rc=0; ( WAIT_READY_TICKS=100; wait_ready ctlwr2 ) 2> /dev/null || ctl_wr2_rc=$?
|
|
1245
|
+
end_launch ctlwr2
|
|
1246
|
+
assert_true 'Guard 2 control: wait_ready on a launch that died is rc 1 (a FAIL for its row), never the UNRESOLVED rc 2' test "$ctl_wr2_rc" -eq 1
|
|
1247
|
+
ctl_bd_rc=0; ( BAR_WAIT_TICKS=100; barrier_run ctlbd "$G2/h-ctlbd" 2 "$CTL_SLOT_FAIL"; printf '%s:%s:%s\n' "$BAR_RAN" "$BAR_DEAD" "$BAR_DISTINCT" > "$G2/ctlbd.out" ) 2> "$G2/ctlbd.msg" || ctl_bd_rc=$?
|
|
1248
|
+
ctl_bd_dead() { if [[ "$ctl_bd_rc" -eq 0 && "$(cat "$G2/ctlbd.out" 2> /dev/null)" == "0:2:0" ]] && grep -q '^FAIL: barrier launcher ctlbd-1 exited without recording a profile' "$G2/ctlbd.msg" && ! grep -q UNRESOLVED "$G2/ctlbd.msg"; then return 0; fi; return 1; }
|
|
1249
|
+
assert_true 'Guard 2 control: barrier_run counts launchers that exited without recording a profile (BAR_DEAD=2, BAR_RAN=0), names each one as a FAIL and does not stop the suite' ctl_bd_dead
|
|
1250
|
+
ctl_bh_rc=0; ( BAR_WAIT_TICKS=40; barrier_run ctlbh "$G2/h-ctlbh" 2 "$CTL_SLOT_HANG" ) 2> "$G2/ctlbh.msg" || ctl_bh_rc=$?
|
|
1251
|
+
ctl_bh_hang() { if [[ "$ctl_bh_rc" -eq 2 ]] && grep -q '^UNRESOLVED: only 0 of 2 barrier launches (ctlbh) recorded a profile within 4 s and the rest are still running: .*or the slot script hangs' "$G2/ctlbh.msg"; then return 0; fi; return 1; }
|
|
1252
|
+
assert_true 'Guard 2 control: barrier_run with launchers still running past the limit stops the suite with UNRESOLVED (rc 2) and names a hang as well as a loaded host' ctl_bh_hang
|
|
1253
|
+
BAR_N=12
|
|
1254
|
+
barrier_run bar "$G2/h-bar" "$BAR_N" "$REPO_ROOT/$SLOT_REL"
|
|
1255
|
+
assert_true "Guard 2: $BAR_N launches released at the same instant under one HOME resolve $BAR_N DISTINCT profiles (no two share a slot)" test "$BAR_RAN:$BAR_DISTINCT" = "$BAR_N:$BAR_N"
|
|
1256
|
+
assert_true 'Guard 2: those simultaneous launches printed ZERO "no usable flock" notes (flock is present, so a lost probe race must not take the no-lease branch)' test "$BAR_NOTES" -eq 0
|
|
1257
|
+
# the probe's own contention, made deterministic: a flock whose capability probe (-E 75 -w 0 8) answers 75, as util-linux does when another launch holds /dev/null
|
|
1258
|
+
PROBEBUSY="$G2/probe-busy-bin"; mkdir -p "$PROBEBUSY"
|
|
1259
|
+
printf '#!/usr/bin/env bash\nif [[ "$*" == "-E 75 -w 0 8" ]]; then exit 75; fi\nexec %q "$@"\n' "$(command -v flock)" > "$PROBEBUSY/flock"; chmod +x "$PROBEBUSY/flock"
|
|
1260
|
+
probe_busy_state() { [[ "$(PATH="$PROBEBUSY:$PATH" flock -E 75 -w 0 8 8< /dev/null; echo $?)" == 75 && "$(PATH="$PROBEBUSY:$PATH" flock -n -E 75 "$G2/hc-unlocked-probe" true; echo $?)" == 0 ]]; }
|
|
1261
|
+
: > "$G2/hc-unlocked-probe"
|
|
1262
|
+
assert_true 'Guard 2 harness: the probe-busy flock answers 75 to the capability probe only and runs every other flock call for real' probe_busy_state
|
|
1263
|
+
barrier_run pb "$G2/h-pb" 2 "$REPO_ROOT/$SLOT_REL" "PATH=$PROBEBUSY:$PATH"
|
|
1264
|
+
assert_true 'Guard 2: a capability probe that answers 75 (a concurrent probe held the lock) still means flock works: two launches get two leased profiles and no "no usable flock" note' test "$BAR_RAN:$BAR_DISTINCT:$BAR_NOTES" = "2:2:0"
|
|
1265
|
+
# the other side of the probe: a flock without -E support (busybox, a brew flock) rejects the probe with an error that is neither 0 nor 75
|
|
1266
|
+
# (rc 1 here) and must still read as unusable; every other call goes to the real flock, so only the probe can tell
|
|
1267
|
+
NOE="$G2/no-e-bin"; mkdir -p "$NOE"
|
|
1268
|
+
printf '#!/usr/bin/env bash\nfor a in "$@"; do if [[ "$a" == -E ]]; then exit 1; fi; done\nexec %q "$@"\n' "$(command -v flock)" > "$NOE/flock"; chmod +x "$NOE/flock"
|
|
1269
|
+
noe_state() { [[ "$(PATH="$NOE:$PATH" flock -E 75 -w 0 8 8< /dev/null; echo $?)" == 1 && "$(PATH="$NOE:$PATH" flock -n "$G2/hc-unlocked-probe" true; echo $?)" == 0 ]]; }
|
|
1270
|
+
assert_true 'Guard 2 harness: the no--E flock exits 1 for any argv containing -E and runs every other flock call for real' noe_state
|
|
1271
|
+
H="$G2/h-noe"
|
|
1272
|
+
LAUNCH_PATH="$NOE:$G2/bin:$PATH" run_n noe "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1273
|
+
noe_claim() { prof_is "$(prof0 "$H")" && grep -q 'no usable flock' "$G2/noe-1.err" && grep -q 'without a lease' "$G2/noe-1.err"; }
|
|
1274
|
+
assert_true 'Guard 2 must-PASS: a flock that errors out on -E reads as unusable: the "no usable flock" note, and slot 0 claimed without a lease' noe_claim
|
|
1275
|
+
|
|
1276
|
+
# --- all 32 slots busy: the launch falls back to a unique directory, never blocking, never killing ---
|
|
1277
|
+
H="$G2/h-full"; mkdir -p "$H/.cache"
|
|
1278
|
+
cat > "$HELP/holdn.py" <<'PY'
|
|
1279
|
+
import fcntl, os, sys, time
|
|
1280
|
+
base, lockname, ready, n = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
|
|
1281
|
+
fds = []
|
|
1282
|
+
for i in range(n):
|
|
1283
|
+
d = base if i == 0 else f"{base}-{i}"
|
|
1284
|
+
os.makedirs(d, exist_ok=True)
|
|
1285
|
+
fd = os.open(os.path.join(d, lockname), os.O_RDWR | os.O_CREAT, 0o644)
|
|
1286
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
1287
|
+
fds.append(fd)
|
|
1288
|
+
open(ready, "w").write("ready")
|
|
1289
|
+
time.sleep(300)
|
|
1290
|
+
PY
|
|
1291
|
+
# hold_slots <home> <n> <ready-file>: a lock holder (own child) on slots 0..n-1 of <home>
|
|
1292
|
+
hold_slots() {
|
|
1293
|
+
mkdir -p "$1/.cache"
|
|
1294
|
+
python3 "$HELP/holdn.py" "$(prof0 "$1")" "$LOCKNAME" "$3" "$2" >/dev/null 2>&1 &
|
|
1295
|
+
own "$!"; disown "$!"
|
|
1296
|
+
local _i; for _i in $(seq 1 50); do if [[ -e "$3" ]]; then break; fi; sleep 0.1; done
|
|
1297
|
+
}
|
|
1298
|
+
hold_slots "$H" 32 "$G2/hold32.ready"
|
|
1299
|
+
run_n full "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1300
|
+
full_state() { local p b; p="$(head -n1 <<< "$RN_PROFS")"; b="$(prof0 "$H")"; if [[ "$RN_RAN" -eq 1 && "$p" =~ ^"$b"-p[0-9]+$ ]] && mode_is "$p" 700 && grep -q 'all 32 slots are busy' "$G2/full-1.err"; then return 0; fi; return 1; }
|
|
1301
|
+
assert_true 'Guard 2 must-PASS: with all 32 slots busy the launch starts on a unique $base-p<pid> directory (non-numeric infix, mode 700, note naming the cause)' full_state
|
|
1302
|
+
# slots >= 1 never wait: 8 busy slots with a 0.3 s slot-0 budget -> slot 8 in well under the 31 x 0.3 s a waiting loop would take
|
|
1303
|
+
H="$G2/h-timing"
|
|
1304
|
+
hold_slots "$H" 8 "$G2/hold8.ready"
|
|
1305
|
+
run_n timing "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1306
|
+
# timing_ok / timing2_ok are wall-clock rows with thin margins (a launch must resolve in under 1.5 s / 2.5 s while a waiting loop would take
|
|
1307
|
+
# over 2.7 s / 4 s). They are the only observable of "slots 1+ never wait", so they stay, but a host running at several times normal
|
|
1308
|
+
# latency (CPU oversubscription) can trip them without any defect in the slot script: rerun on an idle host before reading them as a regression.
|
|
1309
|
+
timing_ok() { prof_is "$(prof0 "$H")-8" && [[ "$RN_MS" -lt 1500 ]]; }
|
|
1310
|
+
assert_true 'Guard 2: slots 1+ never wait — eight busy slots resolve to slot 8 in under 1.5 s (a loop that waited on each slot would take over 2.7 s)' timing_ok
|
|
1311
|
+
|
|
1312
|
+
# slots >= 1 never wait on a live-Chrome SingletonLock either: slot 0 belongs to another session (no wait), slot 1 has a live Chrome and no
|
|
1313
|
+
# owner file (an unknown owner would be waited for if slots 1+ waited), so the launch resolves to slot 2 at once despite a 4 s budget
|
|
1314
|
+
H="$G2/h-timing2"; mkdir -p "$(prof0 "$H")" "$(prof0 "$H")-1"
|
|
1315
|
+
printf '%s\n' "$OTHER_SESSION" > "$(prof0 "$H")/.pwslot.owner"
|
|
1316
|
+
start_chrome_owner; T2A=$SPAWNED; start_chrome_owner; T2B=$SPAWNED
|
|
1317
|
+
ln -s "$HOST_NOW-$T2A" "$(prof0 "$H")/SingletonLock"; ln -s "$HOST_NOW-$T2B" "$(prof0 "$H")-1/SingletonLock"
|
|
1318
|
+
run_n timing2 "$REPO_ROOT" "$H" "$MCP_ARGS" 1 overlap PW_PROFILE_SLOT0_WAIT_S=4
|
|
1319
|
+
# (same wall-clock caveat as timing_ok above)
|
|
1320
|
+
timing2_ok() { prof_is "$(prof0 "$H")-2" && [[ "$RN_MS" -lt 2500 ]]; }
|
|
1321
|
+
assert_true 'Guard 2: slots 1+ never wait on a live-Chrome SingletonLock — slots 0 and 1 held that way resolve to slot 2 in under 2.5 s with a 4 s budget' timing2_ok
|
|
1322
|
+
|
|
1323
|
+
# --- sourced-script hygiene: nothing leaks into the exec'd shell, no errexit/nounset, fd 9 stays open ---
|
|
1324
|
+
H="$G2/h-hygiene"; mkdir -p "$H" "$G2/hyg"
|
|
1325
|
+
( cd "$REPO_ROOT" && HOME="$H" PW_PROFILE_SLOT0_WAIT_S=0.3 bash -c ': | :; compgen -v | sort > "$1/v0"; declare -F | sort > "$1/f0"; export -p | grep -v "^declare -x _=" | sort > "$1/e0"; . "$2" || exit 1; compgen -v | sort > "$1/v1"; declare -F | sort > "$1/f1"; export -p | grep -v "^declare -x _=" | sort > "$1/e1"; printf "flags=%s fd9=%s\n" "$-" "$([ -e /proc/self/fd/9 ] && echo open || echo closed)"' _ "$G2/hyg" "$SLOT_REL" ) > "$G2/hyg/out" 2>&1 || true
|
|
1326
|
+
hyg_flags() { if [[ "$(cat "$G2/hyg/out")" =~ ^flags=([a-zA-Z]*)\ fd9=open$ ]] && [[ "${BASH_REMATCH[1]}" != *e* && "${BASH_REMATCH[1]}" != *u* ]]; then return 0; fi; return 1; }
|
|
1327
|
+
assert_true 'Guard 2: after sourcing, errexit/nounset are off and fd 9 (the lease) is open in the surviving shell' hyg_flags
|
|
1328
|
+
assert_true 'Guard 2: sourcing adds no shell function (declare -F before and after are identical)' cmp -s "$G2/hyg/f0" "$G2/hyg/f1"
|
|
1329
|
+
assert_true 'Guard 2: sourcing exports nothing (export -p before and after are identical)' cmp -s "$G2/hyg/e0" "$G2/hyg/e1"
|
|
1330
|
+
only_prof_new() { [[ "$(comm -13 "$G2/hyg/v0" "$G2/hyg/v1")" == "prof" && -z "$(comm -23 "$G2/hyg/v0" "$G2/hyg/v1")" ]]; }
|
|
1331
|
+
assert_true 'Guard 2: sourcing adds exactly one shell variable, prof (compgen -v before and after differ by that name only)' only_prof_new
|
|
1332
|
+
|
|
1333
|
+
# --- Guard 2 mutation matrix (every edit lands exactly once in the pristine script; each row has a pristine counterpart above) ---
|
|
1334
|
+
A_END='for _pwslot_v in $(compgen -A function _pwslot_); do unset -f "$_pwslot_v"; done'
|
|
1335
|
+
# 1 — the contention flock deleted: every launch lands on slot 0
|
|
1336
|
+
slot_mutant 1-no-flock 'flock -E 75 -n 9' 'true'
|
|
1337
|
+
if [[ -n "$MROOT" ]]; then
|
|
1338
|
+
run_n g2m1 "$MROOT" "$G2/h-m1" "$MCP_ARGS" 2 overlap "$FAST"
|
|
1339
|
+
m1_shared() { if [[ "$RN_RAN" -eq 2 ]] && ! rn_distinct 2; then return 0; fi; return 1; }
|
|
1340
|
+
red 'Guard 2 mutant 1: the flock deleted → two launches share one --user-data-dir' m1_shared
|
|
1341
|
+
fi
|
|
1342
|
+
# 2 — a pattern kill of the decoy re-added at the end of the script
|
|
1343
|
+
slot_mutant 2-pkill-decoy "$A_END" 'pkill -9 -f "[c]hrome.*$prof" 2>/dev/null || true; '"$A_END"
|
|
1344
|
+
if [[ -n "$MROOT" ]]; then
|
|
1345
|
+
H="$G2/h-m2"; spawn_decoys "$H"
|
|
1346
|
+
run_n g2m2 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1347
|
+
m2_decoy_dead() { if [[ "$RN_RAN" -eq 1 ]] && wait_dead "$D_SLOT0" 3; then return 0; fi; return 1; }
|
|
1348
|
+
red 'Guard 2 mutant 2: a pkill of chrome.*<slot0> re-added → the decoy dies' m2_decoy_dead
|
|
1349
|
+
fi
|
|
1350
|
+
# 3 — a live-owner SingletonLock treated as stale (the owner-alive test inverted): the lock is removed
|
|
1351
|
+
slot_mutant 3-live-owner-stale 'if [ "$_pwslot_alive" -eq 0 ]; then return 1; fi' 'if [ "$_pwslot_alive" -ne 0 ]; then return 1; fi'
|
|
1352
|
+
if [[ -n "$MROOT" ]]; then
|
|
1353
|
+
H="$G2/h-m3"; mkdir -p "$(prof0 "$H")"; start_chrome_owner; OWNER=$SPAWNED; ln -s "$HOST_NOW-$OWNER" "$(prof0 "$H")/SingletonLock"
|
|
1354
|
+
run_n g2m3 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1355
|
+
m3_lock_gone() { if [[ "$RN_RAN" -eq 1 ]] && no_link "$(prof0 "$H")"; then return 0; fi; return 1; }
|
|
1356
|
+
red 'Guard 2 mutant 3: a live-owner SingletonLock treated as stale → the lock file is removed' m3_lock_gone
|
|
1357
|
+
fi
|
|
1358
|
+
# 4 — exhaustion made non-unique (two slots, then slot 0 is reused): a third concurrent launch collides
|
|
1359
|
+
slot_mutant 4-third-collides '_pwslot_max=32' '_pwslot_max=2' '_pwslot_fallback="$_pwslot_base-p$$"' '_pwslot_fallback="$_pwslot_base"'
|
|
1360
|
+
if [[ -n "$MROOT" ]]; then
|
|
1361
|
+
run_n g2m4 "$MROOT" "$G2/h-m4" "$MCP_ARGS" 3 overlap "$FAST"
|
|
1362
|
+
m4_collides() { if [[ "$RN_RAN" -eq 3 ]] && ! rn_distinct 3; then return 0; fi; return 1; }
|
|
1363
|
+
red 'Guard 2 mutant 4: the third concurrent launch collides after two compliant ones → fewer than three distinct directories' m4_collides
|
|
1364
|
+
fi
|
|
1365
|
+
# 5 — dispatch: the slot script is not sourced (the string reduced to the old literal profile)
|
|
1366
|
+
cases=$((cases + 1)); mutants_declared=$((mutants_declared + 1))
|
|
1367
|
+
M5_ARGS="$(python3 - "$MCP_ARGS" "$SLOT_REL" <<'PY'
|
|
1368
|
+
import re, sys
|
|
1369
|
+
s, slot = sys.argv[1:]
|
|
1370
|
+
print(re.sub(r"\.\s+" + re.escape(slot) + r"\s*\|\|\s*exit 1;", "prof=$HOME/.cache/playwright-mcp-profile;", s))
|
|
1371
|
+
PY
|
|
1372
|
+
)"
|
|
1373
|
+
if [[ "$M5_ARGS" != "$MCP_ARGS" && "$M5_ARGS" == *'prof=$HOME/.cache/playwright-mcp-profile;'* ]]; then
|
|
1374
|
+
ok 'Guard 2 mutant 5-no-source landed'
|
|
1375
|
+
H="$G2/h-m5"
|
|
1376
|
+
run_n g2m5 "$REPO_ROOT" "$H" "$M5_ARGS" 2 overlap "$FAST"
|
|
1377
|
+
m5_unslotted() { if [[ "$RN_RAN" -eq 2 ]] && ! rn_distinct 2 && ! [[ -e "$(prof0 "$H")/$LOCKNAME" ]]; then return 0; fi; return 1; }
|
|
1378
|
+
red 'Guard 2 mutant 5: the slot script not sourced → both launches share the profile and no slot lock exists' m5_unslotted
|
|
1379
|
+
else
|
|
1380
|
+
bad 'Guard 2 mutant 5-no-source did NOT land'
|
|
1381
|
+
fi
|
|
1382
|
+
# 6 (harness) — the two launches run sequentially: the overlap proof must be FALSE, proving it can tell the difference
|
|
1383
|
+
run_n g2m6 "$REPO_ROOT" "$G2/h-m6" "$MCP_ARGS" 2 sequential "$FAST"
|
|
1384
|
+
m6_no_overlap() { if [[ "$RN_RAN" -eq 2 ]] && ! rn_overlap; then return 0; fi; return 1; }
|
|
1385
|
+
red 'Guard 2 mutant 6 (harness): launches run sequentially instead of overlapped → the overlap proof fails (it observes liveness and held locks)' m6_no_overlap
|
|
1386
|
+
# 7 — the owner-alive check deleted: a free lock with a live-owner SingletonLock reuses the slot
|
|
1387
|
+
slot_mutant 7-no-owner-check 'elif ! _pwslot_owner_busy "$_pwslot_tg"; then' 'elif true; then'
|
|
1388
|
+
if [[ -n "$MROOT" ]]; then
|
|
1389
|
+
H="$G2/h-m7"; mkdir -p "$(prof0 "$H")"; start_chrome_owner; OWNER=$SPAWNED; ln -s "$HOST_NOW-$OWNER" "$(prof0 "$H")/SingletonLock"
|
|
1390
|
+
run_n g2m7 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1391
|
+
red 'Guard 2 mutant 7: a free lock but a live-owner SingletonLock reuses the slot → the launch lands on the owned profile' prof_is "$(prof0 "$H")"
|
|
1392
|
+
fi
|
|
1393
|
+
# 8 — kills every OTHER slot's Chrome (a pattern held in a variable, so Guard 1's text scan cannot see it)
|
|
1394
|
+
slot_mutant 8-kill-other-slot "$A_END" '_pwslot_pat="chrome.*$_pwslot_base-[0-9]"; pkill -9 -f "$_pwslot_pat"; '"$A_END"
|
|
1395
|
+
if [[ -n "$MROOT" ]]; then
|
|
1396
|
+
H="$G2/h-m8"; spawn_decoys "$H"
|
|
1397
|
+
run_n g2m8 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1398
|
+
m8_other_dead() { if [[ "$RN_RAN" -eq 1 ]] && wait_dead "$D_OTHER" 3; then return 0; fi; return 1; }
|
|
1399
|
+
red 'Guard 2 mutant 8: a pkill of every other slot'"'"'s Chrome → the other-slot decoy dies' m8_other_dead
|
|
1400
|
+
fi
|
|
1401
|
+
# 9 — kills sibling Playwright servers
|
|
1402
|
+
slot_mutant 9-kill-sibling-server "$A_END" 'pkill -9 -f "bin/playwright-mcp.*$HOME"; '"$A_END"
|
|
1403
|
+
if [[ -n "$MROOT" ]]; then
|
|
1404
|
+
H="$G2/h-m9"; spawn_decoys "$H"
|
|
1405
|
+
run_n g2m9 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1406
|
+
m9_srv_dead() { if [[ "$RN_RAN" -eq 1 ]] && wait_dead "$D_SRV" 3; then return 0; fi; return 1; }
|
|
1407
|
+
red 'Guard 2 mutant 9: a pkill of bin/playwright-mcp servers → the sibling-server decoy dies' m9_srv_dead
|
|
1408
|
+
fi
|
|
1409
|
+
# 10 — kills whatever holds a file open inside the claimed profile (one bounded /proc fd selector: no pkill, no pgrep, no process name;
|
|
1410
|
+
# a per-process readlink walk costs ~18 s at ~600 host processes and outlasts the launch window, so the selector must not scale with the host)
|
|
1411
|
+
slot_mutant 10-kill-fd-holders "$A_END" 'for _pwslot_h in $(find /proc/[0-9]*/fd -maxdepth 1 -lname "$prof/*" 2>/dev/null | cut -d/ -f3); do if [ "$_pwslot_h" != "$$" ]; then kill -9 "$_pwslot_h"; fi; done; '"$A_END"
|
|
1412
|
+
if [[ -n "$MROOT" ]]; then
|
|
1413
|
+
H="$G2/h-m10"; spawn_decoys "$H"
|
|
1414
|
+
run_n g2m10 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1415
|
+
m10_fd_dead() { if [[ "$RN_RAN" -eq 1 ]] && wait_dead "$D_FD" 3; then return 0; fi; return 1; }
|
|
1416
|
+
red 'Guard 2 mutant 10: a kill of every process holding a file open in the claimed slot → the file-holding decoy dies' m10_fd_dead
|
|
1417
|
+
fi
|
|
1418
|
+
# 11 — the hostname field ignored: a lock naming another host is judged by the local pid alone
|
|
1419
|
+
slot_mutant 11-foreign-host 'if [ -z "$_pwslot_me" ] || [ "${_pwslot_t%-*}" != "$_pwslot_me" ]; then' 'if false; then'
|
|
1420
|
+
if [[ -n "$MROOT" ]]; then
|
|
1421
|
+
H="$G2/h-m11"; mkdir -p "$(prof0 "$H")"; ln -s "otherhost-$dead" "$(prof0 "$H")/SingletonLock"
|
|
1422
|
+
run_n g2m11 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1423
|
+
m11_removed() { if [[ "$RN_RAN" -eq 1 ]] && no_link "$(prof0 "$H")"; then return 0; fi; return 1; }
|
|
1424
|
+
red 'Guard 2 mutant 11: the hostname ignored → a foreign host'"'"'s lock with a locally dead pid is removed' m11_removed
|
|
1425
|
+
fi
|
|
1426
|
+
# 12 — any live pid counts as a Chrome owner (the pre-review behaviour): a recycled pid blocks slot 0
|
|
1427
|
+
slot_mutant 12-any-live-pid "*[Cc]hrom* | *headless_shell* | msedge* | [Mm]icrosoft\\ [Ee]dge* | [Bb]rave* | [Vv]ivaldi* | [Oo]pera | [Oo]pera-* | [Oo]pera\\ * | '')" '*)'
|
|
1428
|
+
if [[ -n "$MROOT" ]]; then
|
|
1429
|
+
H="$G2/h-m12"; mkdir -p "$(prof0 "$H")"; start_sleeper; RECYCLED=$SPAWNED; ln -s "$HOST_NOW-$RECYCLED" "$(prof0 "$H")/SingletonLock"
|
|
1430
|
+
run_n g2m12 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1431
|
+
red 'Guard 2 mutant 12: every live pid counts as a Chrome owner → a recycled non-Chrome pid pushes the launch off slot 0' prof_is "$(prof0 "$H")-1"
|
|
1432
|
+
fi
|
|
1433
|
+
# 13 — liveness by ps alone (the fail-OPEN probe the review found): with ps absent every owner is judged dead
|
|
1434
|
+
slot_mutant 13-ps-fails-open $' _pwslot_alive=1\n if ! [ -d "/proc/$_pwslot_p" ]; then' $' _pwslot_alive=0\n if ps -p "$_pwslot_p" >/dev/null 2>&1; then _pwslot_alive=1; fi\n if false; then'
|
|
1435
|
+
if [[ -n "$MROOT" ]]; then
|
|
1436
|
+
H="$G2/h-m13"; mkdir -p "$(prof0 "$H")"; start_chrome_owner; OWNER=$SPAWNED; ln -s "$HOST_NOW-$OWNER" "$(prof0 "$H")/SingletonLock"
|
|
1437
|
+
LAUNCH_PATH="$NOPS" run_n g2m13 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1438
|
+
m13_removed() { if [[ "$RN_RAN" -eq 1 ]] && no_link "$(prof0 "$H")"; then return 0; fi; return 1; }
|
|
1439
|
+
red 'Guard 2 mutant 13: liveness decided by ps alone, ps absent → a live Chrome'"'"'s lock is removed (the fail-open defect)' m13_removed
|
|
1440
|
+
fi
|
|
1441
|
+
# 14 — the owner file ignored: every contended slot-0 launch waits, whoever holds it
|
|
1442
|
+
slot_mutant 14-owner-ignored '[ "$_pwslot_ow" = "$_pwslot_self" ]' 'true'
|
|
1443
|
+
if [[ -n "$MROOT" ]]; then
|
|
1444
|
+
H="$G2/h-m14"; hold_lock "$(prof0 "$H")" 9 "$OTHER_SESSION"
|
|
1445
|
+
run_n g2m14 "$MROOT" "$H" "$MCP_ARGS" 1 overlap PW_PROFILE_SLOT0_WAIT_S=4
|
|
1446
|
+
m14_waited() { [[ "$RN_RAN" -eq 1 && "$RN_MS" -ge 2500 ]]; }
|
|
1447
|
+
red 'Guard 2 mutant 14: the owner file ignored → a different session'"'"'s lock is waited for the full budget' m14_waited
|
|
1448
|
+
fi
|
|
1449
|
+
# 15 — slots >= 1 wait too
|
|
1450
|
+
slot_mutant 15-slots-wait 'if [ "$_pwslot_n" -eq 0 ] && _pwslot_owner_wait "$_pwslot_d"; then' 'if _pwslot_owner_wait "$_pwslot_d"; then'
|
|
1451
|
+
if [[ -n "$MROOT" ]]; then
|
|
1452
|
+
H="$G2/h-m15"
|
|
1453
|
+
hold_slots "$H" 8 "$G2/hold8m.ready"
|
|
1454
|
+
run_n g2m15 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1455
|
+
m15_slow() { [[ "$RN_RAN" -eq 1 && "$RN_MS" -ge 1500 ]]; }
|
|
1456
|
+
red 'Guard 2 mutant 15: slots 1+ wait on their locks → the eight-busy launch takes 1.5 s or more' m15_slow
|
|
1457
|
+
fi
|
|
1458
|
+
# 16 — one unusable slot aborts the whole search
|
|
1459
|
+
slot_mutant 16-rc2-aborts 'if [ "$_pwslot_i" -eq 0 ] || [ "$_pwslot_flockbad" -eq 1 ]; then break; fi' 'break'
|
|
1460
|
+
if [[ -n "$MROOT" ]]; then
|
|
1461
|
+
H="$G2/h-m16"; hold_lock "$(prof0 "$H")" 30 "$OTHER_SESSION"; : > "$(prof0 "$H")-1"
|
|
1462
|
+
run_n g2m16 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1463
|
+
m16_fallback() { [[ "$RN_RAN" -eq 1 && "$(head -n1 <<< "$RN_PROFS")" =~ ^"$(prof0 "$H")"-p[0-9]+$ ]]; }
|
|
1464
|
+
red 'Guard 2 mutant 16: an unusable slot ends the search → the launch lands on the unique fallback instead of slot 2' m16_fallback
|
|
1465
|
+
fi
|
|
1466
|
+
# 17 — PW_PROFILE_SLOT0_WAIT_S not validated: a typo reaches flock and degrades the lease
|
|
1467
|
+
slot_mutant 17-wait-unvalidated "'' | . | *[!0-9.]* | *.*.*)" '__never__)'
|
|
1468
|
+
if [[ -n "$MROOT" ]]; then
|
|
1469
|
+
H="$G2/h-m17"; hold_lock "$(prof0 "$H")" 2 "$$"
|
|
1470
|
+
run_n g2m17 "$MROOT" "$H" "$MCP_ARGS" 1 overlap PW_PROFILE_SLOT0_WAIT_S=abc
|
|
1471
|
+
m17_degraded() { [[ "$RN_RAN" -eq 1 && "$(head -n1 <<< "$RN_PROFS")" =~ ^"$(prof0 "$H")"-p[0-9]+$ ]]; }
|
|
1472
|
+
red 'Guard 2 mutant 17: the wait not validated → a non-numeric value sends the launch to the unique fallback' m17_degraded
|
|
1473
|
+
fi
|
|
1474
|
+
# 18 — the no-flock host loses the persistent profile (the pre-review behaviour)
|
|
1475
|
+
slot_mutant 18-noflock-no-slot0 $'if [ -z "$_pwslot_skip0" ]; then\n rm -f' $'if false; then\n rm -f'
|
|
1476
|
+
if [[ -n "$MROOT" ]]; then
|
|
1477
|
+
H="$G2/h-m18"
|
|
1478
|
+
LAUNCH_PATH="$NOFLOCK" run_n g2m18 "$MROOT" "$H" "$MCP_ARGS" 1 overlap
|
|
1479
|
+
m18_lost() { [[ "$RN_RAN" -eq 1 && "$(head -n1 <<< "$RN_PROFS")" =~ ^"$(prof0 "$H")"-p[0-9]+$ ]]; }
|
|
1480
|
+
red 'Guard 2 mutant 18: no slot-0 claim without flock → the first session on a no-flock host gets a unique empty profile' m18_lost
|
|
1481
|
+
fi
|
|
1482
|
+
# 19 — the fallback name aliases a slot directory
|
|
1483
|
+
slot_mutant 19-fallback-aliases '_pwslot_fallback="$_pwslot_base-p$$"' '_pwslot_fallback="$_pwslot_base-$$"'
|
|
1484
|
+
if [[ -n "$MROOT" ]]; then
|
|
1485
|
+
H="$G2/h-m19"
|
|
1486
|
+
hold_slots "$H" 32 "$G2/hold32m.ready"
|
|
1487
|
+
run_n g2m19 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1488
|
+
m19_numeric() { [[ "$RN_RAN" -eq 1 && "$(head -n1 <<< "$RN_PROFS")" =~ ^"$(prof0 "$H")"-[0-9]+$ ]]; }
|
|
1489
|
+
red 'Guard 2 mutant 19: the fallback named $base-$$ → its name is a number a slot could also have' m19_numeric
|
|
1490
|
+
fi
|
|
1491
|
+
# 20 — the lease leaks into a child (a lingering process holding fd 9): a SIGKILL of the proxy no longer frees the slot
|
|
1492
|
+
slot_mutant 20-lease-leaks "$A_END" 'sleep 3 9>&9 & '"$A_END"
|
|
1493
|
+
if [[ -n "$MROOT" ]]; then
|
|
1494
|
+
H="$G2/h-m20"
|
|
1495
|
+
launch g2m20 "$MROOT" "$H" "$MCP_ARGS" "$FAST"
|
|
1496
|
+
m20_ready=0; if wait_ready g2m20; then m20_ready=1; fi
|
|
1497
|
+
m20_proxy="$(cat "$G2/g2m20.pid")"
|
|
1498
|
+
kill -KILL "$m20_proxy" 2>/dev/null || true
|
|
1499
|
+
wait_dead "$m20_proxy" 3 || true
|
|
1500
|
+
m20_still_held() { if [[ "$m20_ready" -eq 1 ]] && lock_held "$(prof0 "$H")/$LOCKNAME"; then return 0; fi; return 1; }
|
|
1501
|
+
red 'Guard 2 mutant 20: a child inherits the lease → after a SIGKILL of the proxy the slot lock is still held' m20_still_held
|
|
1502
|
+
end_launch g2m20
|
|
1503
|
+
sleep 3.2 # the leaked holder is a 3 s sleep the mutated script started; let it expire
|
|
1504
|
+
fi
|
|
1505
|
+
# 21 — the default wait lowered, the old token kept in a trailing comment (a grep over the whole file still finds it)
|
|
1506
|
+
slot_mutant 21-default-3 'PW_PROFILE_SLOT0_WAIT_S:-7}"' 'PW_PROFILE_SLOT0_WAIT_S:-3}" # PW_PROFILE_SLOT0_WAIT_S:-7}'
|
|
1507
|
+
if [[ -n "$MROOT" ]]; then
|
|
1508
|
+
m21_default_gone() { ! default_wait_is "$MROOT/$SLOT_REL" 7; }
|
|
1509
|
+
red 'Guard 2 mutant 21: the default lowered to 3 with the old token left in a comment → the comment-stripped default row fails' m21_default_gone
|
|
1510
|
+
m21_pair_broken() { ! wait_pairs_grace "$MROOT/$SLOT_REL" "$REPO_ROOT/$PROXY_REL_PATH"; }
|
|
1511
|
+
red 'Guard 2 mutant 21: the same lowered default no longer covers the proxy teardown → the pairing row fails' m21_pair_broken
|
|
1512
|
+
fi
|
|
1513
|
+
# 22 — the capability probe read strictly (rc 0 only): a probe that loses the shared /dev/null lock (rc 75) means "no usable flock"
|
|
1514
|
+
slot_mutant 22-probe-strict $' 0 | 75) return 0 ;;' $' 0) return 0 ;;'
|
|
1515
|
+
if [[ -n "$MROOT" ]]; then
|
|
1516
|
+
# Only the deterministic form is a mutation row. A "real race" row (the strict probe among $BAR_N simultaneous launches) is red only
|
|
1517
|
+
# when the race actually happens: on a loaded host about one trial in four stays clean and the suite would go red for a reason that is
|
|
1518
|
+
# not in the code. The real-tree $BAR_N-barrier rows above prove the fixed probe; this row proves the strict one dies.
|
|
1519
|
+
barrier_run g2m22b "$G2/h-m22b" 2 "$MROOT/$SLOT_REL" "PATH=$PROBEBUSY:$PATH"
|
|
1520
|
+
m22_probe_busy() { [[ "$BAR_RAN" -eq 2 && "$BAR_NOTES" -ge 1 ]]; }
|
|
1521
|
+
red 'Guard 2 mutant 22: the strict rc-0 probe, a probe answering 75 → "no usable flock" is printed and the launch takes the no-lease branch (the deterministic form)' m22_probe_busy
|
|
1522
|
+
fi
|
|
1523
|
+
# 23 — the headless build dropped from the Chrome-owner comm list: a live headless_shell owner reads as a recycled pid
|
|
1524
|
+
slot_mutant 23-comm-no-headless '*headless_shell* | ' ''
|
|
1525
|
+
if [[ -n "$MROOT" ]]; then
|
|
1526
|
+
H="$G2/h-m23"; mkdir -p "$(prof0 "$H")"; start_comm_owner headless_shell; OWNER=$SPAWNED; ln -s "$HOST_NOW-$OWNER" "$(prof0 "$H")/SingletonLock"
|
|
1527
|
+
run_n g2m23 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1528
|
+
m23_removed() { if [[ "$RN_RAN" -eq 1 ]] && no_link "$(prof0 "$H")"; then return 0; fi; return 1; }
|
|
1529
|
+
red 'Guard 2 mutant 23: headless_shell dropped from the owner comm list → a live headless owner'"'"'s lock is removed' m23_removed
|
|
1530
|
+
fi
|
|
1531
|
+
# 24 — the Chromium derivatives dropped from the comm list: a live msedge owner reads as a recycled pid
|
|
1532
|
+
slot_mutant 24-comm-no-derivatives 'msedge* | [Mm]icrosoft\ [Ee]dge* | [Bb]rave* | [Vv]ivaldi* | [Oo]pera | [Oo]pera-* | [Oo]pera\ * | ' ''
|
|
1533
|
+
if [[ -n "$MROOT" ]]; then
|
|
1534
|
+
H="$G2/h-m24"; mkdir -p "$(prof0 "$H")"; start_comm_owner msedge; OWNER=$SPAWNED; ln -s "$HOST_NOW-$OWNER" "$(prof0 "$H")/SingletonLock"
|
|
1535
|
+
run_n g2m24 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1536
|
+
m24_removed() { if [[ "$RN_RAN" -eq 1 ]] && no_link "$(prof0 "$H")"; then return 0; fi; return 1; }
|
|
1537
|
+
red 'Guard 2 mutant 24: the Chromium derivatives dropped from the owner comm list → a live msedge owner'"'"'s lock is removed' m24_removed
|
|
1538
|
+
fi
|
|
1539
|
+
# 25 — slots >= 1 wait in the SingletonLock re-check loop (the slot-0-only guard dropped there; mutant 15 covers the flock path)
|
|
1540
|
+
slot_mutant 25-recheck-waits 'if [ "$_pwslot_n" -eq 0 ] && [ "$_pwslot_waitok" -eq 0 ] && [ "$_pwslot_left" -lt 0 ]; then' 'if [ "$_pwslot_waitok" -eq 0 ] && [ "$_pwslot_left" -lt 0 ]; then'
|
|
1541
|
+
if [[ -n "$MROOT" ]]; then
|
|
1542
|
+
H="$G2/h-m25"; mkdir -p "$(prof0 "$H")" "$(prof0 "$H")-1"
|
|
1543
|
+
printf '%s\n' "$OTHER_SESSION" > "$(prof0 "$H")/.pwslot.owner"
|
|
1544
|
+
start_chrome_owner; M25A=$SPAWNED; start_chrome_owner; M25B=$SPAWNED
|
|
1545
|
+
ln -s "$HOST_NOW-$M25A" "$(prof0 "$H")/SingletonLock"; ln -s "$HOST_NOW-$M25B" "$(prof0 "$H")-1/SingletonLock"
|
|
1546
|
+
run_n g2m25 "$MROOT" "$H" "$MCP_ARGS" 1 overlap PW_PROFILE_SLOT0_WAIT_S=4
|
|
1547
|
+
m25_slow() { [[ "$RN_RAN" -eq 1 && "$RN_MS" -ge 2500 ]]; }
|
|
1548
|
+
red 'Guard 2 mutant 25: slots 1+ wait in the SingletonLock re-check → the launch takes 2.5 s or more' m25_slow
|
|
1549
|
+
fi
|
|
1550
|
+
# 26 — the probe accepts any exit status: a flock that errors out on -E (busybox) reads as usable, so the lease path runs on a flock that cannot take it
|
|
1551
|
+
slot_mutant 26-probe-any-rc $' 0 | 75) return 0 ;;' $' *) return 0 ;;'
|
|
1552
|
+
if [[ -n "$MROOT" ]]; then
|
|
1553
|
+
H="$G2/h-m26"
|
|
1554
|
+
LAUNCH_PATH="$NOE:$G2/bin:$PATH" run_n g2m26 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1555
|
+
m26_lease_path() { [[ "$RN_RAN" -eq 1 && "$(head -n1 <<< "$RN_PROFS")" =~ ^"$(prof0 "$H")"-p[0-9]+$ ]]; }
|
|
1556
|
+
red 'Guard 2 mutant 26: any probe status reads as usable → a flock without -E takes the lease path and the launch lands on the unique fallback instead of slot 0' m26_lease_path
|
|
1557
|
+
fi
|
|
1558
|
+
# 27 — the derivative globs unanchored (substrings again): a recycled pid named operator reads as an Opera
|
|
1559
|
+
slot_mutant 27-comm-substring '[Oo]pera | [Oo]pera-* | [Oo]pera\ * | ' '*[Oo]pera* | '
|
|
1560
|
+
if [[ -n "$MROOT" ]]; then
|
|
1561
|
+
H="$G2/h-m27"; mkdir -p "$(prof0 "$H")"; start_comm_owner operator; OWNER=$SPAWNED; ln -s "$HOST_NOW-$OWNER" "$(prof0 "$H")/SingletonLock"
|
|
1562
|
+
run_n g2m27 "$MROOT" "$H" "$MCP_ARGS" 1 overlap "$FAST"
|
|
1563
|
+
red 'Guard 2 mutant 27: the derivative globs unanchored → a recycled pid named operator pushes the launch off slot 0' prof_is "$(prof0 "$H")-1"
|
|
1564
|
+
fi
|
|
1565
|
+
|
|
1566
|
+
# ===========================================================================
|
|
1567
|
+
# Guard 3 — the registrations
|
|
1568
|
+
# ===========================================================================
|
|
1569
|
+
assert_true 'Guard 3: .mcp.json carries "env": {"PLAYWRIGHT_MCP_PING_TIMEOUT_MS": "0"} (a guard for future pin bumps: inert on stdio in 0.0.78/0.0.83)' reg_ok "$PROJECT_MCP" project
|
|
1570
|
+
assert_true 'Guard 3: plugins/soleur/.mcp.json carries the same env and a single --chromium-fallback before the -- separator' reg_ok "$PLUGIN_MCP" plugin
|
|
1571
|
+
reg_mutant 'Guard 3 mutant 1: the project registration loses its env' "$PROJECT_MCP" project 'e.pop("env", None)'
|
|
1572
|
+
reg_mutant 'Guard 3 mutant 2: the project env value drifts from 0' "$PROJECT_MCP" project 'e["env"] = {"PLAYWRIGHT_MCP_PING_TIMEOUT_MS": "30000"}'
|
|
1573
|
+
reg_mutant 'Guard 3 mutant 3: the plugin registration loses its env' "$PLUGIN_MCP" plugin 'e.pop("env", None)'
|
|
1574
|
+
reg_mutant 'Guard 3 mutant 4: the plugin env key is misspelled' "$PLUGIN_MCP" plugin 'e["env"] = {"PLAYWRIGHT_MCP_PING_TIMEOUT": "0"}'
|
|
1575
|
+
reg_mutant 'Guard 3 mutant 5: the plugin registration loses --chromium-fallback' "$PLUGIN_MCP" plugin 'e["args"].remove("--chromium-fallback")'
|
|
1576
|
+
|
|
1577
|
+
# ---------------------------------------------------------------------------
|
|
1578
|
+
# Harness hygiene: every process this suite started is gone after reap_own, and the hygiene predicate can tell
|
|
1579
|
+
# ---------------------------------------------------------------------------
|
|
1580
|
+
reap_own
|
|
1581
|
+
# own_all_dead <file of pids>: none of the recorded pids is alive
|
|
1582
|
+
own_all_dead() { local p; while read -r p; do if [[ -n "$p" ]] && alive "$p"; then return 1; fi; done < "$1"; return 0; }
|
|
1583
|
+
npx_pids_dead() { local p; while read -r p; do if [[ -n "$p" ]] && alive "$p"; then return 1; fi; done < <(cat "$WORK"/g2/*.jsonl.pid 2>/dev/null || true); return 0; }
|
|
1584
|
+
assert_true 'Hygiene: every pid this suite recorded is dead after reap_own' own_all_dead "$OWN"
|
|
1585
|
+
assert_true 'Hygiene: every npx-shim server pid this suite started is dead after reap_own' npx_pids_dead
|
|
1586
|
+
# mutation (harness): a neutered reaper leaves the recorded pid alive, so the predicate above is false — it can tell
|
|
1587
|
+
leak_observed() {
|
|
1588
|
+
local f="$WORK/hc-leak-own" pid
|
|
1589
|
+
: > "$f"
|
|
1590
|
+
sleep 300 >/dev/null 2>&1 & pid=$!; printf '%s\n' "$pid" >> "$f"; disown "$pid"
|
|
1591
|
+
# the neutered reaper does nothing here
|
|
1592
|
+
if own_all_dead "$f"; then kill -KILL "$pid" 2>/dev/null || true; return 1; fi
|
|
1593
|
+
reap_list "$f"
|
|
1594
|
+
wait_dead "$pid" 3 || true
|
|
1595
|
+
own_all_dead "$f"
|
|
1596
|
+
}
|
|
1597
|
+
red 'Hygiene mutant (harness): a neutered reap_own leaves a recorded pid alive, so own_all_dead is false; the real reaper then clears it' leak_observed
|
|
1598
|
+
|
|
1599
|
+
# ---------------------------------------------------------------------------
|
|
1600
|
+
# Verdict. Reported with printf + exit, never through ok()/bad() (ADR-193).
|
|
1601
|
+
# ---------------------------------------------------------------------------
|
|
1602
|
+
printf '\n%d passed, %d failed, %d cases (%d mutants, %d mutation rows)\n' "$pass" "$fail" "$cases" "$mutants_declared" "$red_rows"
|
|
1603
|
+
|
|
1604
|
+
if [[ $((pass + fail)) -ne $cases ]]; then
|
|
1605
|
+
printf '[FATAL] vacuity accounting: pass+fail (%d) != cases (%d) — a row did not report\n' "$((pass + fail))" "$cases" >&2
|
|
1606
|
+
exit 1
|
|
1607
|
+
fi
|
|
1608
|
+
EXPECTED_MUTANTS=57 # Guard 1: 22 static + 4 dynamic mutants; Guard 2: mutants 1-5 and 7-27 (6 is a harness row); Guard 3: mutants 1-5
|
|
1609
|
+
EXPECTED_RED_ROWS=61 # the 57 mutants, one extra row for Guard 2 mutant 21 (mutant 22 has the one deterministic row), plus the three harness rows (Guard 1, Guard 2 #6, Hygiene)
|
|
1610
|
+
if [[ $mutants_declared -ne $EXPECTED_MUTANTS || $red_rows -ne $EXPECTED_RED_ROWS ]]; then
|
|
1611
|
+
printf '[FATAL] mutation matrix: %d mutants / %d mutation rows ran, expected %d / %d — a row vanished\n' "$mutants_declared" "$red_rows" "$EXPECTED_MUTANTS" "$EXPECTED_RED_ROWS" >&2
|
|
1612
|
+
exit 1
|
|
1613
|
+
fi
|
|
1614
|
+
MIN_ASSERTIONS=203
|
|
1615
|
+
if [[ $cases -lt $MIN_ASSERTIONS ]]; then
|
|
1616
|
+
printf '[FATAL] vacuity floor: only %d cases executed, expected at least %d\n' "$cases" "$MIN_ASSERTIONS" >&2
|
|
1617
|
+
exit 1
|
|
1618
|
+
fi
|
|
1619
|
+
[[ $fail -eq 0 ]] || exit 1
|
|
1620
|
+
exit 0
|