@geraldmaron/construct 1.4.1 → 1.5.0
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/.env.example +36 -0
- package/README.md +41 -7
- package/bin/construct +1029 -65
- package/bin/construct-postinstall.mjs +27 -2
- package/config/tag-vocabulary.json +264 -0
- package/examples/distribution/sources/deck-one-pager.md +1 -1
- package/lib/acp/server.mjs +21 -7
- package/lib/adapters-sync.mjs +2 -1
- package/lib/audit-trail.mjs +185 -2
- package/lib/beads/auto-close.mjs +2 -1
- package/lib/beads/drift.mjs +2 -1
- package/lib/bridges/copilot-proxy.mjs +20 -5
- package/lib/certification/skill-inventory.mjs +10 -1
- package/lib/cli/approvals.mjs +147 -0
- package/lib/cli-commands.mjs +105 -14
- package/lib/comment-lint.mjs +127 -8
- package/lib/config/schema.mjs +7 -30
- package/lib/config/source-target-registry.mjs +70 -0
- package/lib/config/source-targets.mjs +179 -205
- package/lib/contracts/coverage.mjs +77 -0
- package/lib/contracts/validate.mjs +102 -13
- package/lib/contracts/violation-log.mjs +50 -4
- package/lib/db/migrate.mjs +69 -0
- package/lib/db/migrations/001_orchestration_runs.sql +9 -0
- package/lib/db/migrations/002_queue_provider.sql +50 -0
- package/lib/db/migrations/003_worker_registry.sql +17 -0
- package/lib/db/migrations/004_trace_events.sql +16 -0
- package/lib/db/migrations/005_shared_memory.sql +15 -0
- package/lib/db/migrations/006_orchestration_runs_tenant.sql +5 -0
- package/lib/decisions/enforced-baseline.json +0 -2
- package/lib/decisions/registry.mjs +3 -2
- package/lib/deployment/parity-contract.mjs +1 -1
- package/lib/deployment-mode.mjs +78 -2
- package/lib/diagram-export.mjs +10 -1
- package/lib/distill.mjs +5 -2
- package/lib/docs-verify.mjs +2 -1
- package/lib/doctor/cli.mjs +1 -0
- package/lib/doctor/command-on-path.mjs +24 -0
- package/lib/doctor/diagnosis.mjs +109 -0
- package/lib/doctor/embedding-health.mjs +50 -0
- package/lib/doctor/engine-health.mjs +93 -0
- package/lib/doctor/graph-validate.mjs +47 -0
- package/lib/doctor/index.mjs +23 -4
- package/lib/doctor/sidecar-providers.mjs +56 -0
- package/lib/doctor/watchers/consistency.mjs +93 -1
- package/lib/doctor/watchers/cx-budget.mjs +1 -1
- package/lib/doctor/watchers/graph-staleness.mjs +1 -1
- package/lib/doctor/watchers/mcp-protocol.mjs +17 -0
- package/lib/doctor/watchers/oracle-liveness.mjs +92 -0
- package/lib/doctor/watchers/orchestration-runs.mjs +108 -0
- package/lib/doctor/watchers/provider-breaker.mjs +78 -0
- package/lib/document-export.mjs +52 -27
- package/lib/document-extract/docling-client.mjs +9 -5
- package/lib/document-extract/docling-sidecar.py +30 -0
- package/lib/document-extract.mjs +1 -1
- package/lib/document-ingest.mjs +9 -0
- package/lib/embed/approval-queue.mjs +184 -88
- package/lib/embed/authority-guard.mjs +65 -2
- package/lib/embed/capability-jobs.mjs +365 -0
- package/lib/embed/capability-lifecycle.mjs +219 -0
- package/lib/embed/capability-loader.mjs +303 -0
- package/lib/embed/capability-runtime.mjs +58 -0
- package/lib/embed/cli.mjs +185 -8
- package/lib/embed/config.mjs +4 -0
- package/lib/embed/daemon.mjs +125 -6
- package/lib/embed/demand-fetch.mjs +190 -54
- package/lib/embed/inbox.mjs +12 -10
- package/lib/embed/presets/ops-triage.mjs +236 -0
- package/lib/embed/presets/pm-feedback.mjs +341 -0
- package/lib/embed/presets/tpm.mjs +401 -0
- package/lib/embed/providers/jira.mjs +72 -1
- package/lib/embed/providers/registry.mjs +140 -33
- package/lib/embed/worker.mjs +5 -0
- package/lib/embedded-contract/capability.mjs +4 -0
- package/lib/embedded-contract/execution.mjs +1 -1
- package/lib/embedded-contract/model-resolve.mjs +53 -3
- package/lib/embedded-contract/workflow-defs.mjs +48 -73
- package/lib/embedded-contract/workflow-invoke.mjs +144 -4
- package/lib/embedded-contract/workflows/architecture-review.manifest.json +15 -0
- package/lib/embedded-contract/workflows/data-structure.manifest.json +14 -0
- package/lib/embedded-contract/workflows/evidence-ingest.manifest.json +14 -0
- package/lib/embedded-contract/workflows/memo-draft.manifest.json +14 -0
- package/lib/embedded-contract/workflows/operations-triage.manifest.json +18 -0
- package/lib/embedded-contract/workflows/operations.manifest.json +18 -0
- package/lib/embedded-contract/workflows/pm-feedback.manifest.json +18 -0
- package/lib/embedded-contract/workflows/prd-draft.manifest.json +15 -0
- package/lib/embedded-contract/workflows/proposal-review.manifest.json +15 -0
- package/lib/embedded-contract/workflows/research-synthesis.manifest.json +14 -0
- package/lib/embedded-contract/workflows/risk-review.manifest.json +15 -0
- package/lib/embedded-contract/workflows/structure-notes.manifest.json +14 -0
- package/lib/embedded-contract/workflows/transcript-process.manifest.json +14 -0
- package/lib/embedded-contract/workflows/triage.manifest.json +14 -0
- package/lib/env-config.mjs +60 -11
- package/lib/export-validate.mjs +34 -2
- package/lib/extensions/index.mjs +27 -0
- package/lib/extensions/loader.mjs +120 -0
- package/lib/extensions/manifest-schema.mjs +141 -0
- package/lib/extensions/manifests/anthropic.manifest.json +12 -0
- package/lib/extensions/manifests/atlassian-confluence.manifest.json +12 -0
- package/lib/extensions/manifests/atlassian-jira.manifest.json +53 -0
- package/lib/extensions/manifests/directory.manifest.json +12 -0
- package/lib/extensions/manifests/docling.manifest.json +37 -0
- package/lib/extensions/manifests/echo.manifest.json +8 -0
- package/lib/extensions/manifests/feedback.manifest.json +12 -0
- package/lib/extensions/manifests/github-copilot.manifest.json +12 -0
- package/lib/extensions/manifests/github.manifest.json +52 -0
- package/lib/extensions/manifests/linear.manifest.json +50 -0
- package/lib/extensions/manifests/local.manifest.json +12 -0
- package/lib/extensions/manifests/ollama.manifest.json +12 -0
- package/lib/extensions/manifests/openai.manifest.json +12 -0
- package/lib/extensions/manifests/openrouter-anthropic.manifest.json +12 -0
- package/lib/extensions/manifests/openrouter-deepseek.manifest.json +12 -0
- package/lib/extensions/manifests/openrouter-google.manifest.json +12 -0
- package/lib/extensions/manifests/openrouter-llama.manifest.json +12 -0
- package/lib/extensions/manifests/openrouter-qwen.manifest.json +12 -0
- package/lib/extensions/manifests/openrouter.manifest.json +12 -0
- package/lib/extensions/manifests/postgres.manifest.json +12 -0
- package/lib/extensions/manifests/salesforce.manifest.json +12 -0
- package/lib/extensions/manifests/slack.manifest.json +58 -0
- package/lib/extensions/manifests/whisper.manifest.json +36 -0
- package/lib/extensions/validate.mjs +175 -0
- package/lib/features.mjs +13 -9
- package/lib/flows/checkpoint.mjs +184 -0
- package/lib/flows/constants.mjs +27 -0
- package/lib/flows/define.mjs +146 -0
- package/lib/flows/engine.mjs +249 -0
- package/lib/flows/errors.mjs +19 -0
- package/lib/flows/index.mjs +27 -0
- package/lib/flows/joins.mjs +18 -0
- package/lib/flows/schema.mjs +90 -0
- package/lib/flows/state.mjs +31 -0
- package/lib/frameworks/loader.mjs +99 -0
- package/lib/frameworks/schema.mjs +157 -0
- package/lib/graph/build-from-corpus.mjs +67 -0
- package/lib/graph/build-from-embed.mjs +82 -0
- package/lib/graph/build-from-registry.mjs +164 -3
- package/lib/graph/cli.mjs +456 -12
- package/lib/graph/gap-queries.mjs +156 -0
- package/lib/graph/gaps.mjs +41 -0
- package/lib/graph/impacted.mjs +129 -0
- package/lib/graph/runtime-evidence.mjs +177 -0
- package/lib/graph/security-coverage.mjs +113 -0
- package/lib/graph/staleness.mjs +241 -10
- package/lib/graph/store.mjs +24 -7
- package/lib/graph/validate.mjs +161 -0
- package/lib/headhunt.mjs +9 -8
- package/lib/health-check.mjs +15 -7
- package/lib/hook-health.mjs +1 -1
- package/lib/hooks/agent-tracker.mjs +10 -4
- package/lib/hooks/edit-guard.mjs +3 -3
- package/lib/hooks/graph-impact-advisory.mjs +2 -2
- package/lib/hooks/guard-bash.mjs +41 -15
- package/lib/hooks/mcp-health-check.mjs +11 -4
- package/lib/hooks/model-fallback.mjs +50 -6
- package/lib/hooks/orchestration-dispatch-guard.mjs +14 -3
- package/lib/hooks/pre-compact.mjs +181 -173
- package/lib/hooks/rule-verifier.mjs +2 -1
- package/lib/hooks/session-reflect.mjs +2 -1
- package/lib/hooks/session-start.mjs +3 -3
- package/lib/host/readiness.mjs +109 -0
- package/lib/host-capabilities.mjs +13 -2
- package/lib/host-disposition.mjs +29 -8
- package/lib/identity.mjs +92 -0
- package/lib/ingest/degraded-extract.mjs +96 -0
- package/lib/ingest/docling-remote.mjs +2 -1
- package/lib/ingest/provider-extract.mjs +7 -19
- package/lib/ingest/sidecar-providers.mjs +196 -0
- package/lib/ingest-tooling.mjs +11 -5
- package/lib/init-unified.mjs +55 -38
- package/lib/init-update.mjs +2 -1
- package/lib/install/legacy-global-cleanup.mjs +25 -0
- package/lib/install/stage-project.mjs +41 -4
- package/lib/intake/daemon.mjs +99 -8
- package/lib/intake/git-queue.mjs +110 -38
- package/lib/intake/prepare.mjs +2 -0
- package/lib/intake/queue-registry.mjs +50 -0
- package/lib/intake/queue.mjs +133 -17
- package/lib/intake/session-prelude.mjs +57 -8
- package/lib/integrations/intake-integrations.mjs +61 -111
- package/lib/intent-classifier.mjs +43 -5
- package/lib/libreoffice-export.mjs +8 -8
- package/lib/logging/rotate.mjs +5 -4
- package/lib/mcp/broker.mjs +332 -17
- package/lib/mcp/denied-store.mjs +95 -0
- package/lib/mcp/destructive-approval.mjs +57 -0
- package/lib/mcp/destructive-gate.mjs +30 -0
- package/lib/mcp/dispatch-envelope.mjs +199 -0
- package/lib/mcp/memory-bridge.mjs +2 -1
- package/lib/mcp/server.mjs +185 -1268
- package/lib/mcp/tool-definitions-memory.mjs +376 -0
- package/lib/mcp/tool-definitions-project.mjs +271 -0
- package/lib/mcp/tool-definitions-skills.mjs +311 -0
- package/lib/mcp/tool-definitions-workflow.mjs +398 -0
- package/lib/mcp/tool-definitions.mjs +25 -0
- package/lib/mcp/tool-rate-limit.mjs +47 -0
- package/lib/mcp/tool-registry.mjs +107 -0
- package/lib/mcp/tool-safety.mjs +95 -0
- package/lib/mcp/tool-surface-parity.mjs +60 -0
- package/lib/mcp/tools/orchestration-delegation-next.tool.mjs +68 -0
- package/lib/mcp/tools/orchestration-run.mjs +197 -19
- package/lib/mcp/tools/orchestration-task-result.tool.mjs +77 -0
- package/lib/mcp/tools/project.mjs +25 -8
- package/lib/mcp/tools/provider-write.mjs +187 -0
- package/lib/mcp/tools/scope.mjs +0 -3
- package/lib/mcp/tools/skills.mjs +1 -1
- package/lib/mcp/tools/storage.mjs +2 -2
- package/lib/mcp/tools/web-search-governance.mjs +96 -0
- package/lib/mcp/tools/web-search.mjs +5 -76
- package/lib/mcp/transport/auth.mjs +238 -0
- package/lib/mcp/transport/http.mjs +136 -0
- package/lib/mcp/transport/mode.mjs +31 -0
- package/lib/mcp/transport/stdio.mjs +22 -0
- package/lib/mcp-catalog.json +4 -4
- package/lib/mcp-manager.mjs +34 -23
- package/lib/mcp-platform-config.mjs +53 -20
- package/lib/mode-capabilities.mjs +109 -0
- package/lib/model-router.mjs +42 -9
- package/lib/models/catalog.mjs +40 -1
- package/lib/net-guard.mjs +247 -0
- package/lib/observation-store.mjs +6 -2
- package/lib/ollama/provision-context.mjs +2 -1
- package/lib/opencode-config.mjs +22 -3
- package/lib/opencode-runtime-plugin.mjs +120 -2
- package/lib/oracle/actions.mjs +204 -10
- package/lib/oracle/cli.mjs +24 -3
- package/lib/oracle/daemon-entry.mjs +6 -0
- package/lib/oracle/dispatch.mjs +2 -1
- package/lib/oracle/execute.mjs +2 -2
- package/lib/oracle/gaps.mjs +3 -3
- package/lib/oracle/heartbeat.mjs +53 -0
- package/lib/oracle/read-model.mjs +69 -13
- package/lib/oracle/reconcile.mjs +3 -46
- package/lib/oracle/routing.mjs +37 -31
- package/lib/oracle/synthesize.mjs +1 -1
- package/lib/orchestration/classification.mjs +434 -0
- package/lib/orchestration/delegation-flow.mjs +132 -0
- package/lib/orchestration/flow-selection.mjs +418 -0
- package/lib/orchestration/gates.mjs +244 -0
- package/lib/orchestration/host-sampling.mjs +121 -0
- package/lib/orchestration/policy-constants.mjs +48 -0
- package/lib/orchestration/provider-outcome.mjs +197 -0
- package/lib/orchestration/readiness.mjs +114 -13
- package/lib/orchestration/run-store-postgres.mjs +32 -26
- package/lib/orchestration/run-store-sqlite.mjs +8 -14
- package/lib/orchestration/run-store.mjs +25 -12
- package/lib/orchestration/runtime.mjs +508 -62
- package/lib/orchestration/store.mjs +87 -12
- package/lib/orchestration/trace-store.mjs +125 -0
- package/lib/orchestration/web-capability.mjs +60 -0
- package/lib/orchestration/worker-runtime.mjs +162 -0
- package/lib/orchestration/worker.mjs +763 -80
- package/lib/orchestration-policy.mjs +67 -1111
- package/lib/output-quality.mjs +61 -2
- package/lib/packs/cli.mjs +144 -0
- package/lib/packs/core-pack.mjs +112 -0
- package/lib/packs/enablement.mjs +187 -0
- package/lib/packs/index.mjs +23 -0
- package/lib/packs/loader.mjs +176 -0
- package/lib/packs/manifest-schema.mjs +58 -0
- package/lib/packs/prompts.mjs +120 -0
- package/lib/packs/validate.mjs +208 -0
- package/lib/path-policy.mjs +56 -0
- package/lib/plugin-registry.mjs +4 -5
- package/lib/policy/audit-gate.mjs +42 -0
- package/lib/policy/consumption-budget.mjs +149 -0
- package/lib/policy/engine.mjs +81 -6
- package/lib/policy/role-authority.mjs +89 -0
- package/lib/prompt-composer.js +19 -3
- package/lib/providers/contract/adapters/confluence/governed-write.mjs +215 -0
- package/lib/providers/contract/adapters/confluence/manifest.json +17 -0
- package/lib/providers/contract/adapters/confluence/transport.mjs +135 -0
- package/lib/providers/contract/adapters/github/governed-write.mjs +121 -0
- package/lib/providers/contract/adapters/github/index.mjs +38 -3
- package/lib/providers/contract/adapters/jira/adf.mjs +151 -0
- package/lib/providers/contract/adapters/jira/createmeta.mjs +143 -0
- package/lib/providers/contract/adapters/jira/governed-write.mjs +182 -0
- package/lib/providers/contract/adapters/jira/manifest.json +17 -0
- package/lib/providers/contract/adapters/jira/transport.mjs +119 -0
- package/lib/providers/contract.mjs +207 -0
- package/lib/providers/credential-bootstrap.mjs +7 -4
- package/lib/providers/credential-sources.mjs +14 -2
- package/lib/providers/directory/index.mjs +261 -0
- package/lib/providers/feedback/index.mjs +392 -0
- package/lib/providers/filter-audit.mjs +68 -0
- package/lib/providers/filter-schema.mjs +54 -0
- package/lib/providers/github/index.mjs +31 -0
- package/lib/providers/instance-config.mjs +215 -0
- package/lib/providers/op-locate.mjs +96 -0
- package/lib/providers/op-run.mjs +64 -9
- package/lib/providers/registry.mjs +26 -3
- package/lib/providers/secret-audit-wiring.mjs +41 -18
- package/lib/providers/secret-resolver.mjs +263 -31
- package/lib/publish-template.mjs +6 -3
- package/lib/publish-tooling.mjs +4 -0
- package/lib/publish.mjs +32 -4
- package/lib/queue/pg-queue.mjs +395 -0
- package/lib/reflect.mjs +2 -1
- package/lib/registry/assemble.mjs +28 -2
- package/lib/registry/catalog.mjs +6 -0
- package/lib/registry/consolidation.mjs +8 -1
- package/lib/registry/custom-scaffold.mjs +200 -0
- package/lib/registry/custom-schema.mjs +137 -0
- package/lib/registry/loader.mjs +15 -4
- package/lib/registry/manifests/format-engines.default.json +15 -0
- package/lib/registry/manifests/surface-map.default.json +46 -0
- package/lib/registry/retired-paths.mjs +1 -0
- package/lib/registry/surface-map.mjs +100 -50
- package/lib/registry/validate.mjs +3 -3
- package/lib/render-visual-check.mjs +240 -0
- package/lib/resources/budget.mjs +40 -17
- package/lib/roles/flavor-bindings.mjs +36 -14
- package/lib/roles/gateway.mjs +15 -8
- package/lib/roots.mjs +107 -0
- package/lib/runtime/uv-bootstrap.mjs +32 -6
- package/lib/runtime/whisper-bootstrap.mjs +11 -3
- package/lib/runtime-env.mjs +5 -2
- package/lib/sandbox.mjs +1 -1
- package/lib/scheduler/index.mjs +8 -20
- package/lib/schema-infer.mjs +31 -2
- package/lib/scopes/lifecycle.mjs +29 -25
- package/lib/scopes/loader.mjs +49 -12
- package/lib/scopes/teams.mjs +1 -1
- package/lib/security/ingest-boundary.mjs +80 -0
- package/lib/security/recall-wrapper.mjs +99 -0
- package/lib/security/trust.mjs +132 -0
- package/lib/service-manager.mjs +93 -24
- package/lib/setup.mjs +41 -23
- package/lib/skills-apply.mjs +4 -2
- package/lib/specialists/postconditions.mjs +2 -2
- package/lib/state-root.mjs +147 -0
- package/lib/status.mjs +597 -6
- package/lib/storage/admin.mjs +84 -7
- package/lib/storage/backend-registry.mjs +73 -0
- package/lib/storage/backend.mjs +63 -9
- package/lib/storage/embeddings-openai.mjs +12 -2
- package/lib/storage/hybrid-query.mjs +11 -3
- package/lib/storage/retrieval-hardening.mjs +384 -0
- package/lib/storage/shared-memory.mjs +158 -0
- package/lib/storage/vector-client.mjs +69 -2
- package/lib/team/health.mjs +72 -0
- package/lib/telemetry/backends/local.mjs +9 -3
- package/lib/telemetry/client.mjs +3 -7
- package/lib/template-registry.mjs +10 -12
- package/lib/tenant/context.mjs +82 -0
- package/lib/tenant/isolation.mjs +129 -0
- package/lib/test-corpus-inventory.mjs +103 -2
- package/lib/uninstall/uninstall.mjs +78 -12
- package/lib/validator.mjs +4 -6
- package/lib/worker/entrypoint.mjs +2 -1
- package/lib/worker/run.mjs +2 -2
- package/lib/worker/trace.mjs +5 -11
- package/lib/workflow-state.mjs +52 -8
- package/lib/workflows/liveness.mjs +203 -0
- package/lib/workflows/loader.mjs +177 -0
- package/lib/workflows/manifest-schema.mjs +71 -0
- package/lib/workflows/surface-parity.mjs +118 -0
- package/lib/workflows/validate.mjs +127 -0
- package/lib/writes/control-plane.mjs +140 -0
- package/lib/writes/envelope.mjs +206 -0
- package/lib/writes/sent-log.mjs +86 -0
- package/lib/writes/write-intent.mjs +109 -0
- package/package.json +20 -7
- package/personas/construct.md +12 -3
- package/registry/agent-manifest.json +117 -0
- package/registry/capabilities.json +1987 -0
- package/rules/common/comments.md +1 -0
- package/schemas/brand-voice.schema.json +24 -0
- package/schemas/capability-registry.schema.json +72 -0
- package/schemas/certification-run.schema.json +130 -0
- package/schemas/demo-recording.schema.json +46 -0
- package/schemas/eval-dataset.schema.json +79 -0
- package/schemas/execution-capability-profile.schema.json +46 -0
- package/schemas/execution-policy.schema.json +114 -0
- package/schemas/improvement-proposal.schema.json +65 -0
- package/schemas/mcp-tool-output.schema.json +61 -0
- package/schemas/platform-capabilities.schema.json +83 -0
- package/schemas/project-config.schema.json +215 -0
- package/schemas/project-demo.schema.json +60 -0
- package/schemas/provider-behavior-matrix.schema.json +91 -0
- package/schemas/scope.schema.json +197 -0
- package/schemas/specialist-trace.schema.json +107 -0
- package/schemas/team.schema.json +99 -0
- package/schemas/unified-registry.schema.json +546 -0
- package/scripts/sync-specialists.mjs +336 -106
- package/skills/docs/adr-workflow.md +1 -1
- package/skills/docs/codebase-research-workflow.md +3 -3
- package/skills/docs/prd-workflow.md +5 -6
- package/skills/docs/runbook-workflow.md +2 -2
- package/skills/docs/user-research-workflow.md +3 -3
- package/skills/operating/fleet-health-routing.md +77 -0
- package/skills/roles/ai-engineer.md +1 -1
- package/skills/roles/architect.md +0 -1
- package/skills/roles/business-strategist.md +1 -1
- package/skills/roles/data-analyst.telemetry.md +1 -1
- package/skills/roles/data-engineer.md +1 -1
- package/skills/roles/data-engineer.pipeline.md +1 -1
- package/skills/roles/data-engineer.vector-retrieval.md +1 -2
- package/skills/roles/data-engineer.warehouse.md +1 -1
- package/skills/roles/designer.accessibility.md +1 -1
- package/skills/roles/designer.md +0 -1
- package/skills/roles/devil-advocate.md +1 -1
- package/skills/roles/docs-keeper.md +1 -1
- package/skills/roles/evaluator.md +1 -1
- package/skills/roles/explorer.md +1 -1
- package/skills/roles/platform-engineer.md +1 -1
- package/skills/roles/qa.ai-eval.md +1 -2
- package/skills/roles/qa.api-contract.md +0 -1
- package/skills/roles/qa.data-pipeline.md +0 -1
- package/skills/roles/qa.md +0 -1
- package/skills/roles/qa.web-ui.md +0 -1
- package/skills/roles/release-manager.md +1 -1
- package/skills/roles/researcher.md +0 -2
- package/skills/roles/reviewer.md +0 -3
- package/skills/roles/security.ai.md +1 -1
- package/skills/roles/security.legal-compliance.md +1 -1
- package/skills/roles/security.md +0 -1
- package/skills/roles/security.privacy.md +0 -1
- package/skills/roles/security.supply-chain.md +1 -1
- package/skills/roles/sre.md +1 -1
- package/skills/roles/test-automation.md +1 -1
- package/skills/roles/trace-reviewer.md +1 -1
- package/skills/roles/ux-researcher.md +1 -1
- package/specialists/artifact-manifest.json +36 -36
- package/specialists/org/contracts/accessibility-to-qa.json +11 -3
- package/specialists/org/contracts/any-to-business-strategist.json +19 -7
- package/specialists/org/contracts/any-to-debugger.json +7 -1
- package/specialists/org/contracts/any-to-designer.json +7 -1
- package/specialists/org/contracts/any-to-docs-keeper.json +18 -4
- package/specialists/org/contracts/any-to-explorer.json +13 -4
- package/specialists/org/contracts/any-to-sre-incident.json +6 -2
- package/specialists/org/contracts/any-to-trace-reviewer.json +16 -4
- package/specialists/org/contracts/architect-to-ai-engineer.json +6 -2
- package/specialists/org/contracts/architect-to-data-engineer.json +17 -5
- package/specialists/org/contracts/architect-to-devil-advocate.json +11 -3
- package/specialists/org/contracts/architect-to-engineer.json +20 -4
- package/specialists/org/contracts/architect-to-evaluator.json +15 -5
- package/specialists/org/contracts/architect-to-legal-compliance.json +15 -5
- package/specialists/org/contracts/architect-to-operations.json +17 -4
- package/specialists/org/contracts/architect-to-platform-engineer.json +20 -4
- package/specialists/org/contracts/construct-to-orchestrator.json +10 -2
- package/specialists/org/contracts/data-analyst-to-product-manager.json +11 -2
- package/specialists/org/contracts/engineer-to-qa.json +20 -4
- package/specialists/org/contracts/engineer-to-reviewer.json +29 -5
- package/specialists/org/contracts/explorer-to-engineer.json +11 -3
- package/specialists/org/contracts/legal-compliance-to-release-manager.json +12 -4
- package/specialists/org/contracts/operations-to-user.json +53 -0
- package/specialists/org/contracts/operations-triage-output.json +53 -0
- package/specialists/org/contracts/pm-requirements-candidates.json +53 -0
- package/specialists/org/contracts/product-manager-to-architect.json +30 -10
- package/specialists/org/contracts/product-manager-to-data-analyst.json +13 -3
- package/specialists/org/contracts/product-manager-to-ux-researcher.json +15 -5
- package/specialists/org/contracts/qa-to-release-manager.json +11 -3
- package/specialists/org/contracts/researcher-to-architect.json +10 -2
- package/specialists/org/contracts/researcher-to-product-manager.json +16 -5
- package/specialists/org/contracts/reviewer-to-security.json +17 -3
- package/specialists/org/contracts/test-automation-to-engineer.json +11 -3
- package/specialists/org/contracts/trace-reviewer-to-sre.json +12 -4
- package/specialists/org/contracts/user-to-construct.json +11 -4
- package/specialists/org/frameworks/cx-architect-constraint-option-failure.md +66 -0
- package/specialists/org/frameworks/cx-engineer-feasibility-blast-radius.md +66 -0
- package/specialists/org/frameworks/cx-ops-dependency-sequencing.md +74 -0
- package/specialists/org/frameworks/cx-pm-value-tradeoff.md +66 -0
- package/specialists/org/frameworks/cx-qa-risk-based-coverage.md +69 -0
- package/specialists/org/groups/engineering-group.json +2 -6
- package/specialists/org/groups/governance-group.json +2 -3
- package/specialists/org/groups/operations-group.json +5 -8
- package/specialists/org/groups/product-group.json +4 -8
- package/specialists/org/groups/quality-group.json +3 -7
- package/specialists/org/groups/strategy-group.json +6 -9
- package/specialists/org/models.json.example +8 -0
- package/specialists/org/scopes/creative.json +6 -1
- package/specialists/org/scopes/operations.json +7 -1
- package/specialists/org/scopes/research.json +6 -1
- package/specialists/org/scopes/rnd.json +7 -1
- package/specialists/org/specialists/cx-architect.json +27 -8
- package/specialists/org/specialists/cx-designer.json +22 -8
- package/specialists/org/specialists/cx-engineer.json +71 -7
- package/specialists/org/specialists/cx-operations.json +89 -15
- package/specialists/org/specialists/cx-orchestrator.json +16 -4
- package/specialists/org/specialists/cx-product-manager.json +28 -9
- package/specialists/org/specialists/cx-qa.json +16 -6
- package/specialists/org/specialists/cx-researcher.json +41 -7
- package/specialists/org/specialists/cx-reviewer.json +61 -11
- package/specialists/org/specialists/cx-security.json +30 -10
- package/specialists/org/teams/design-team.json +3 -4
- package/specialists/org/teams/engineering-team.json +3 -10
- package/specialists/org/teams/governance-team.json +3 -5
- package/specialists/org/teams/operations-team.json +6 -12
- package/specialists/org/teams/product-management-team.json +2 -3
- package/specialists/org/teams/quality-team.json +4 -12
- package/specialists/org/teams/research-team.json +3 -3
- package/specialists/org/teams/strategy-team.json +7 -14
- package/specialists/prompts/cx-architect.md +20 -1
- package/specialists/prompts/cx-data-analyst.md +1 -1
- package/specialists/prompts/cx-designer.md +12 -4
- package/specialists/prompts/cx-engineer.md +15 -1
- package/specialists/prompts/cx-operations.md +14 -2
- package/specialists/prompts/cx-orchestrator.md +9 -5
- package/specialists/prompts/cx-product-manager.md +5 -1
- package/specialists/prompts/cx-qa.md +6 -2
- package/specialists/prompts/cx-researcher.md +27 -31
- package/specialists/prompts/cx-reviewer.md +19 -1
- package/specialists/prompts/cx-security.md +5 -1
- package/templates/distribution/construct-brand.typ +123 -59
- package/templates/distribution/construct-decision.typ +6 -3
- package/templates/distribution/construct-pdf.typ +11 -4
- package/templates/distribution/construct-prd.typ +6 -3
- package/templates/distribution/construct-research.typ +7 -4
- package/vendor/pandoc-ext/README.md +3 -0
- package/vendor/pandoc-ext/diagram.lua +687 -0
- package/lib/providers/contract/adapters/git/index.mjs +0 -115
- package/lib/providers/contract/adapters/slack/index.mjs +0 -175
- package/specialists/org/contracts/business-strategist-to-product-manager.json +0 -39
- package/specialists/org/contracts/construct-to-rd-lead.json +0 -53
- package/specialists/org/contracts/data-engineer-to-platform-engineer.json +0 -36
- package/specialists/org/contracts/designer-to-accessibility.json +0 -36
- package/specialists/org/contracts/platform-engineer-to-engineer.json +0 -31
- package/specialists/org/contracts/qa-to-test-automation.json +0 -42
- package/specialists/org/contracts/rd-lead-to-architect.json +0 -38
- package/specialists/org/contracts/sre-to-release-manager.json +0 -36
- package/specialists/org/specialists/cx-accessibility.json +0 -69
- package/specialists/org/specialists/cx-ai-engineer.json +0 -75
- package/specialists/org/specialists/cx-business-strategist.json +0 -72
- package/specialists/org/specialists/cx-data-engineer.json +0 -71
- package/specialists/org/specialists/cx-devil-advocate.json +0 -71
- package/specialists/org/specialists/cx-docs-keeper.json +0 -82
- package/specialists/org/specialists/cx-evaluator.json +0 -69
- package/specialists/org/specialists/cx-explorer.json +0 -69
- package/specialists/org/specialists/cx-legal-compliance.json +0 -74
- package/specialists/org/specialists/cx-oracle.json +0 -46
- package/specialists/org/specialists/cx-platform-engineer.json +0 -80
- package/specialists/org/specialists/cx-rd-lead.json +0 -73
- package/specialists/org/specialists/cx-release-manager.json +0 -77
- package/specialists/org/specialists/cx-sre.json +0 -94
- package/specialists/org/specialists/cx-test-automation.json +0 -69
- package/specialists/org/specialists/cx-trace-reviewer.json +0 -69
- package/specialists/org/specialists/cx-ux-researcher.json +0 -68
- package/specialists/org/teams/accessibility-team.json +0 -39
- package/specialists/org/teams/ux-research-team.json +0 -39
- package/specialists/prompts/cx-accessibility.md +0 -55
- package/specialists/prompts/cx-ai-engineer.md +0 -124
- package/specialists/prompts/cx-business-strategist.md +0 -58
- package/specialists/prompts/cx-data-engineer.md +0 -55
- package/specialists/prompts/cx-devil-advocate.md +0 -59
- package/specialists/prompts/cx-docs-keeper.md +0 -164
- package/specialists/prompts/cx-evaluator.md +0 -49
- package/specialists/prompts/cx-explorer.md +0 -71
- package/specialists/prompts/cx-legal-compliance.md +0 -58
- package/specialists/prompts/cx-oracle.md +0 -98
- package/specialists/prompts/cx-platform-engineer.md +0 -97
- package/specialists/prompts/cx-rd-lead.md +0 -59
- package/specialists/prompts/cx-release-manager.md +0 -54
- package/specialists/prompts/cx-sre.md +0 -111
- package/specialists/prompts/cx-test-automation.md +0 -55
- package/specialists/prompts/cx-trace-reviewer.md +0 -101
- package/specialists/prompts/cx-ux-researcher.md +0 -53
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { dirname, resolve } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { memoryPort } from "./home-namespace.mjs";
|
|
10
11
|
|
|
11
12
|
const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
13
|
|
|
@@ -31,18 +32,16 @@ function resolveTemplateObject(input, resolvedValues, fallback) {
|
|
|
31
32
|
// A value is unsafe to write into a host MCP env block when it is an unresolved
|
|
32
33
|
// `__NAME__` template or a 1Password `op://` reference. The op:// form must never
|
|
33
34
|
// be written verbatim: a host that cannot run `op read` would pass the literal
|
|
34
|
-
// reference to the child process, and writing it persists secret topology
|
|
35
|
+
// reference to the child process, and writing it persists secret topology into a
|
|
36
|
+
// host-readable MCP config file on disk (claude_desktop_config.json, OpenCode
|
|
37
|
+
// settings) — a different sink from Construct's own audit JSONL, where recording
|
|
38
|
+
// the ref (not the value) is the accepted design (secret-audit-wiring.mjs,
|
|
39
|
+
// remediation plan Epic 6, construct-trxz.6).
|
|
35
40
|
|
|
36
41
|
function isUnwritableEnvValue(value) {
|
|
37
42
|
return typeof value !== "string" || value.includes("__") || value.startsWith("op://");
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
function stripUnresolvedValues(input) {
|
|
41
|
-
return Object.fromEntries(
|
|
42
|
-
Object.entries(input).filter(([, value]) => !isUnwritableEnvValue(value)),
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
45
|
function resolveArgs(args, resolvedValues) {
|
|
47
46
|
return (args ?? []).map((arg) =>
|
|
48
47
|
typeof arg === "string" ? resolveTemplateString(arg, resolvedValues) : arg,
|
|
@@ -61,26 +60,60 @@ const SECRET_REF = {
|
|
|
61
60
|
opencode: (name) => `{env:${name}}`,
|
|
62
61
|
};
|
|
63
62
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
63
|
+
// A stdio MCP env value that is a whole-value secret template (`__NAME__` as the
|
|
64
|
+
// entire string) is a credential carrier: emit the host's env-reference form so the
|
|
65
|
+
// live value never lands on disk, exactly as the remote `headers` path does. Claude
|
|
66
|
+
// Code (`${VAR}`, documented at code.claude.com/docs/en/mcp under "Environment
|
|
67
|
+
// variable expansion in .mcp.json," confirmed 2026-07 for construct-trxz.12 --
|
|
68
|
+
// expansion applies to the file format, independent of which host-config file
|
|
69
|
+
// Construct currently targets; see construct-ranh) and OpenCode (`{env:VAR}`, live-
|
|
70
|
+
// validated against the installed binary for construct-trxz.12) each expand their
|
|
71
|
+
// reference syntax inside a stdio env block, and the child still inherits the
|
|
72
|
+
// resolved env from the parent (config.env -> loadConstructEnv -> process.env) as a
|
|
73
|
+
// backstop. A composed value (a URL with an embedded port template, a plain literal)
|
|
74
|
+
// is not a secret and is materialized so the child receives the concrete string;
|
|
75
|
+
// op:// references and unresolved embedded templates are stripped, never persisted.
|
|
76
|
+
|
|
77
|
+
const WHOLE_SECRET_TEMPLATE = /^__([A-Z0-9_]+)__$/;
|
|
78
|
+
|
|
79
|
+
// VS Code's own MCP configuration reference (code.visualstudio.com/docs/agents/
|
|
80
|
+
// reference/mcp-configuration, confirmed 2026-07 for construct-trxz.12) documents only
|
|
81
|
+
// `${workspaceFolder}`-style predefined variables and `${input:id}` prompts for
|
|
82
|
+
// mcp.json — no `${env:VAR}` substitution is listed as supported. Writing `${env:NAME}`
|
|
83
|
+
// into a VS Code stdio `env` block would hand the child process that literal,
|
|
84
|
+
// unexpanded string instead of the credential, so VS Code's local/stdio env keeps the
|
|
85
|
+
// Codex-style materialize-or-strip treatment (flipWholeSecret: false) rather than the
|
|
86
|
+
// reference flip Claude and OpenCode confirm via their own documented `${VAR}` /
|
|
87
|
+
// `{env:VAR}` expansion.
|
|
88
|
+
|
|
89
|
+
function buildLocalEnvironment(mcpDef, resolvedValues, secretRef, { flipWholeSecret = true } = {}) {
|
|
90
|
+
const source = mcpDef.env ?? {};
|
|
91
|
+
const out = {};
|
|
92
|
+
for (const [key, raw] of Object.entries(source)) {
|
|
93
|
+
const whole = flipWholeSecret && typeof raw === "string" ? raw.match(WHOLE_SECRET_TEMPLATE) : null;
|
|
94
|
+
if (whole) {
|
|
95
|
+
out[key] = secretRef(whole[1]);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const materialized = typeof raw === "string" ? resolveTemplateString(raw, resolvedValues) : raw;
|
|
99
|
+
if (!isUnwritableEnvValue(materialized)) out[key] = materialized;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
74
102
|
}
|
|
75
103
|
|
|
76
104
|
function buildRemoteHeaders(mcpDef, secretRef) {
|
|
77
105
|
return resolveTemplateObject(mcpDef.headers ?? {}, {}, secretRef);
|
|
78
106
|
}
|
|
79
107
|
|
|
108
|
+
// The memory bridge URL is templated from MEMORY_PORT; when a caller does not supply
|
|
109
|
+
// one (an entry built outside a populated config.env), fall back to the derived per-home
|
|
110
|
+
// port from home-namespace, never the dead legacy 8765 literal that produced the
|
|
111
|
+
// split-brain against the actually-allocated port.
|
|
112
|
+
|
|
80
113
|
function withMemoryDefaults(id, values) {
|
|
81
114
|
if (id !== "memory") return values;
|
|
82
115
|
if (values.MEMORY_PORT) return values;
|
|
83
|
-
return { ...values, MEMORY_PORT:
|
|
116
|
+
return { ...values, MEMORY_PORT: String(memoryPort()) };
|
|
84
117
|
}
|
|
85
118
|
|
|
86
119
|
// auth defaults to "oauth": remote MCP servers (e.g. GitHub's hosted server) are
|
|
@@ -106,7 +139,7 @@ export function buildClaudeMcpEntry(id, mcpDef, resolvedValues = {}, { host = "c
|
|
|
106
139
|
};
|
|
107
140
|
}
|
|
108
141
|
|
|
109
|
-
const env = buildLocalEnvironment(mcpDef, values);
|
|
142
|
+
const env = buildLocalEnvironment(mcpDef, values, secretRef, { flipWholeSecret: host !== "vscode" });
|
|
110
143
|
return {
|
|
111
144
|
command: mcpDef.command,
|
|
112
145
|
args: resolveArgs(mcpDef.args, values),
|
|
@@ -137,7 +170,7 @@ export function buildOpenCodeMcpEntry(id, mcpDef, resolvedValues = {}, { auth =
|
|
|
137
170
|
};
|
|
138
171
|
}
|
|
139
172
|
|
|
140
|
-
const environment = buildLocalEnvironment(mcpDef, runtimeValues);
|
|
173
|
+
const environment = buildLocalEnvironment(mcpDef, runtimeValues, SECRET_REF.opencode);
|
|
141
174
|
return {
|
|
142
175
|
id: openCodeId,
|
|
143
176
|
entry: {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployment-mode capability registry.
|
|
3
|
+
*
|
|
4
|
+
* Maps each deployment mode (solo / team / enterprise) to its declared capabilities
|
|
5
|
+
* and their current implementation status. This is the single authoritative source
|
|
6
|
+
* for what a mode promises versus what is actually wired up at runtime.
|
|
7
|
+
*
|
|
8
|
+
* Status values:
|
|
9
|
+
* 'implemented' — fully wired; production-safe.
|
|
10
|
+
* 'stub' — code path exists but returns null / falls back silently.
|
|
11
|
+
* 'not-implemented' — no code path exists yet; will throw or degrade if invoked.
|
|
12
|
+
*
|
|
13
|
+
* How to add a new capability:
|
|
14
|
+
* 1. Add an entry to the relevant mode array in CAPABILITY_REGISTRY below.
|
|
15
|
+
* 2. Set status to 'not-implemented' or 'stub' until the implementation lands.
|
|
16
|
+
* 3. Flip to 'implemented' in the same PR that wires the real code.
|
|
17
|
+
* 4. Add a CHANGELOG entry referencing this file and the wiring commit.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const CAPABILITY_REGISTRY = {
|
|
21
|
+
solo: [
|
|
22
|
+
{ id: 'filesystem-queue', label: 'Filesystem task queue', status: 'implemented' },
|
|
23
|
+
{ id: 'local-memory', label: 'Local memory', status: 'implemented' },
|
|
24
|
+
{ id: 'embedded-lancedb', label: 'Embedded LanceDB vector store', status: 'implemented' },
|
|
25
|
+
{ id: 'direct-mcp', label: 'Direct MCP dispatch', status: 'implemented' },
|
|
26
|
+
],
|
|
27
|
+
team: [
|
|
28
|
+
{ id: 'postgres-queue', label: 'Postgres task queue', status: 'implemented' },
|
|
29
|
+
{ id: 'shared-memory', label: 'Shared memory store', status: 'stub' },
|
|
30
|
+
{ id: 'worker-heartbeat', label: 'Worker identity and heartbeat', status: 'implemented' },
|
|
31
|
+
{ id: 'docker-workers', label: 'Docker worker pool', status: 'not-implemented' },
|
|
32
|
+
{ id: 'central-telemetry', label: 'Central telemetry', status: 'stub' },
|
|
33
|
+
{ id: 'brokered-mcp', label: 'Brokered MCP dispatch', status: 'stub' },
|
|
34
|
+
],
|
|
35
|
+
enterprise: [
|
|
36
|
+
{ id: 'tenant-isolation', label: 'Tenant isolation', status: 'not-implemented' },
|
|
37
|
+
{ id: 'rbac', label: 'RBAC/ABAC', status: 'not-implemented' },
|
|
38
|
+
{ id: 'worker-heartbeat', label: 'Worker identity and heartbeat', status: 'implemented' },
|
|
39
|
+
{ id: 'isolated-workers', label: 'Isolated worker containers', status: 'not-implemented' },
|
|
40
|
+
{ id: 'signed-mcp-allowlists', label: 'Signed MCP allowlists', status: 'not-implemented' },
|
|
41
|
+
{ id: 'mandatory-audit', label: 'Mandatory audit log', status: 'implemented' },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Returns the capability list for the given mode, or an empty array for unknown modes.
|
|
46
|
+
|
|
47
|
+
export function getCapabilities(mode) {
|
|
48
|
+
return CAPABILITY_REGISTRY[mode] ?? [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Aggregates the capability list into a single readiness label.
|
|
52
|
+
// 'fully-implemented' — every capability is 'implemented'.
|
|
53
|
+
// 'degraded' — at least one is 'stub' (and none are 'not-implemented').
|
|
54
|
+
// 'unsupported' — at least one is 'not-implemented'.
|
|
55
|
+
|
|
56
|
+
export function getModeCapabilityStatus(mode) {
|
|
57
|
+
const caps = getCapabilities(mode);
|
|
58
|
+
if (caps.length === 0) return 'unsupported';
|
|
59
|
+
if (caps.some(c => c.status === 'not-implemented')) return 'unsupported';
|
|
60
|
+
if (caps.some(c => c.status === 'stub')) return 'degraded';
|
|
61
|
+
return 'fully-implemented';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Returns capabilities whose status is not 'implemented'.
|
|
65
|
+
|
|
66
|
+
export function getUnsupportedCapabilities(mode) {
|
|
67
|
+
return getCapabilities(mode).filter(c => c.status !== 'implemented');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The three valid status values, as a constant so callers don't string-compare.
|
|
71
|
+
|
|
72
|
+
export const CAPABILITY_STATUSES = ['implemented', 'stub', 'not-implemented'];
|
|
73
|
+
|
|
74
|
+
// ADR-0057 (LMCP-A7) partitions enterprise capabilities beyond the three raw
|
|
75
|
+
// statuses above into four status-output categories: 'active' (implemented),
|
|
76
|
+
// 'fail-closed' (architecturally non-trivial — must hard-error rather than
|
|
77
|
+
// degrade silently), 'later' (no near-term implementation path, no runtime
|
|
78
|
+
// effect), and 'absent' (not advertised at all, the default for any id in
|
|
79
|
+
// neither set). construct status and construct doctor both key off this so a
|
|
80
|
+
// fail-closed gap is never visually or behaviorally confused with a later one.
|
|
81
|
+
|
|
82
|
+
export const ENTERPRISE_FAIL_CLOSED_IDS = new Set(['tenant-isolation', 'isolated-workers']);
|
|
83
|
+
export const ENTERPRISE_LATER_IDS = new Set(['rbac', 'signed-mcp-allowlists']);
|
|
84
|
+
|
|
85
|
+
export function categorizeEnterpriseCapability(cap) {
|
|
86
|
+
if (cap.status === 'implemented') return 'active';
|
|
87
|
+
if (ENTERPRISE_FAIL_CLOSED_IDS.has(cap.id)) return 'fail-closed';
|
|
88
|
+
if (ENTERPRISE_LATER_IDS.has(cap.id)) return 'later';
|
|
89
|
+
return 'absent';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Test-only injection: temporarily append a capability to the given mode(s) and return a cleanup fn.
|
|
93
|
+
|
|
94
|
+
export function _injectCapabilityForTesting({ id, label = id, modes = {} }) {
|
|
95
|
+
const injected = [];
|
|
96
|
+
for (const [mode, status] of Object.entries(modes)) {
|
|
97
|
+
if (!CAPABILITY_REGISTRY[mode]) CAPABILITY_REGISTRY[mode] = [];
|
|
98
|
+
const entry = { id, label, status };
|
|
99
|
+
CAPABILITY_REGISTRY[mode].push(entry);
|
|
100
|
+
injected.push({ mode, entry });
|
|
101
|
+
}
|
|
102
|
+
return function cleanup() {
|
|
103
|
+
for (const { mode, entry } of injected) {
|
|
104
|
+
const arr = CAPABILITY_REGISTRY[mode];
|
|
105
|
+
const idx = arr.indexOf(entry);
|
|
106
|
+
if (idx !== -1) arr.splice(idx, 1);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
package/lib/model-router.mjs
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import fs from "node:fs";
|
|
25
25
|
import path from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
26
27
|
import { spawnSync } from "child_process";
|
|
27
28
|
import { hasAnySecret } from "./providers/secret-resolver.mjs";
|
|
28
29
|
import { isOllamaModelInstalled, toOllamaNativeModelId } from "./ollama/installed-models.mjs";
|
|
@@ -177,6 +178,21 @@ export function selectLocalEditorModel(candidates = []) {
|
|
|
177
178
|
* - `resolve`: Function that maps selected tier values to concrete model IDs.
|
|
178
179
|
*
|
|
179
180
|
* Families are consulted in order; the first match wins.
|
|
181
|
+
*
|
|
182
|
+
* FALLBACK CATALOG ONLY (construct-at27): these dated ids are the last-resort
|
|
183
|
+
* defaults when a tier is unconfigured — runtime call sites must prefer the
|
|
184
|
+
* user's configured/selected models and degrade gracefully when a catalog id
|
|
185
|
+
* has been retired by the provider; never treat an entry here as guaranteed
|
|
186
|
+
* to exist.
|
|
187
|
+
*
|
|
188
|
+
* OpenRouter's free-tier rate limit is enforced per account across every
|
|
189
|
+
* `:free`-suffixed model, not per model id — pinning a different `:free`
|
|
190
|
+
* model does not avoid a 429 by itself. The `:free` ids below are
|
|
191
|
+
* cross-checked against a live `GET https://openrouter.ai/api/v1/models`
|
|
192
|
+
* response (construct-r7bp) so at least one alternate candidate remains
|
|
193
|
+
* live if a specific model is retired or under its own provider-side quota;
|
|
194
|
+
* durable retry/fallback handling of the shared account limit is
|
|
195
|
+
* construct-5wkl's scope, not this catalog.
|
|
180
196
|
*/
|
|
181
197
|
const PROVIDER_FAMILY_TIERS = [
|
|
182
198
|
{
|
|
@@ -236,7 +252,7 @@ const PROVIDER_FAMILY_TIERS = [
|
|
|
236
252
|
options: {
|
|
237
253
|
reasoning: ["openrouter/deepseek/deepseek-r1", "openrouter/deepseek/deepseek-v3"],
|
|
238
254
|
standard: ["openrouter/deepseek/deepseek-v3", "openrouter/deepseek/deepseek-r1"],
|
|
239
|
-
fast: ["openrouter/qwen/qwen3-coder:free", "openrouter/deepseek/deepseek-v3"],
|
|
255
|
+
fast: ["openrouter/qwen/qwen3-coder:free", "openrouter/meta-llama/llama-3.3-70b-instruct:free", "openrouter/deepseek/deepseek-v3"],
|
|
240
256
|
},
|
|
241
257
|
},
|
|
242
258
|
{
|
|
@@ -246,12 +262,12 @@ const PROVIDER_FAMILY_TIERS = [
|
|
|
246
262
|
resolve: ({ reasoning, standard, fast }) => ({
|
|
247
263
|
reasoning: reasoning ?? "openrouter/qwen/qwen3-coder",
|
|
248
264
|
standard: standard ?? "openrouter/qwen/qwen3-coder:free",
|
|
249
|
-
fast: fast ?? "openrouter/qwen/
|
|
265
|
+
fast: fast ?? "openrouter/qwen/qwen-2.5-coder-32b-instruct",
|
|
250
266
|
}),
|
|
251
267
|
options: {
|
|
252
268
|
reasoning: ["openrouter/qwen/qwen3-coder", "openrouter/qwen/qwen3-coder:free"],
|
|
253
|
-
standard: ["openrouter/qwen/qwen3-coder:free", "openrouter/qwen/qwen3-coder"],
|
|
254
|
-
fast: ["openrouter/qwen/
|
|
269
|
+
standard: ["openrouter/qwen/qwen3-coder:free", "openrouter/qwen/qwen3-next-80b-a3b-instruct:free", "openrouter/qwen/qwen3-coder"],
|
|
270
|
+
fast: ["openrouter/qwen/qwen-2.5-coder-32b-instruct", "openrouter/qwen/qwen3-coder:free"],
|
|
255
271
|
},
|
|
256
272
|
},
|
|
257
273
|
{
|
|
@@ -313,7 +329,7 @@ const PROVIDER_FAMILY_TIERS = [
|
|
|
313
329
|
}),
|
|
314
330
|
options: {
|
|
315
331
|
reasoning: ["openrouter/openrouter/free", "openrouter/qwen/qwen3-coder", "openrouter/deepseek/deepseek-r1"],
|
|
316
|
-
standard: ["openrouter/openrouter/free", "openrouter/qwen/qwen3-coder:free", "openrouter/google/gemini-2.0-flash-001"],
|
|
332
|
+
standard: ["openrouter/openrouter/free", "openrouter/qwen/qwen3-coder:free", "openrouter/openai/gpt-oss-120b:free", "openrouter/google/gemini-2.0-flash-001"],
|
|
317
333
|
fast: ["openrouter/openrouter/free", "openrouter/qwen/qwen3-coder:free", "openrouter/meta-llama/llama-3.3-70b-instruct:free"],
|
|
318
334
|
},
|
|
319
335
|
},
|
|
@@ -413,8 +429,10 @@ function isProviderConfigured(familyId, env, { allowAmbient = true } = {}) {
|
|
|
413
429
|
const varNames = PROVIDER_ENV_MAP[familyId];
|
|
414
430
|
if (!varNames?.length) return false;
|
|
415
431
|
|
|
416
|
-
// Special case: Ollama on default port (no OLLAMA_BASE_URL needed)
|
|
417
|
-
|
|
432
|
+
// Special case: Ollama on default port (no OLLAMA_BASE_URL needed). Ambient-gated:
|
|
433
|
+
// a hermetic caller (allowAmbient: false — functional tests, credential-fallback
|
|
434
|
+
// resolution against a synthetic env) must not probe the machine's local daemon.
|
|
435
|
+
if (familyId === 'ollama' && allowAmbient) {
|
|
418
436
|
try {
|
|
419
437
|
const r = spawnSync('curl', ['-s', '--connect-timeout', '1', '-o', '/dev/null', '-w', '%{http_code}', 'http://localhost:11434/api/tags'], { encoding: 'utf8', timeout: 3000 });
|
|
420
438
|
if (r.status === 0 && r.stdout?.trim() === '200') return true;
|
|
@@ -431,8 +449,9 @@ function isProviderConfigured(familyId, env, { allowAmbient = true } = {}) {
|
|
|
431
449
|
if (hasAnySecret(varNames, { env, allowAmbient })) return true;
|
|
432
450
|
|
|
433
451
|
// GitHub Copilot authenticates via OAuth rather than an API key: a stored
|
|
434
|
-
// device-flow credential, or a gh CLI session as a fallback, both count.
|
|
435
|
-
|
|
452
|
+
// device-flow credential, or a gh CLI session as a fallback, both count. Both
|
|
453
|
+
// checks read machine-local state outside `env`, so they are ambient-gated too.
|
|
454
|
+
if (familyId === 'github-copilot' && allowAmbient) {
|
|
436
455
|
if (hasCopilotCredential()) return true;
|
|
437
456
|
try {
|
|
438
457
|
const r = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8', timeout: 3000 });
|
|
@@ -1282,6 +1301,20 @@ export function setModelWithTierInference(envPath, tier, modelId, registryModels
|
|
|
1282
1301
|
return resolved;
|
|
1283
1302
|
}
|
|
1284
1303
|
|
|
1304
|
+
// Optional operator-supplied tier defaults, resolved under the toolkit dir
|
|
1305
|
+
// (CX_TOOLKIT_DIR wins, else the installed package root). No active registry
|
|
1306
|
+
// ships — a starter template lives at specialists/org/models.json.example — so
|
|
1307
|
+
// an unconfigured machine degrades honestly (no implicit model defaults) rather
|
|
1308
|
+
// than resolving a model whose credentials may be absent. A dropped-in
|
|
1309
|
+
// models.json supplies registry-sourced tier defaults that an env pin overrides.
|
|
1310
|
+
|
|
1311
|
+
const MODEL_REGISTRY_MODULE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
1312
|
+
|
|
1313
|
+
export function defaultModelRegistryPath(env = process.env) {
|
|
1314
|
+
const root = env.CX_TOOLKIT_DIR || MODEL_REGISTRY_MODULE_ROOT;
|
|
1315
|
+
return path.join(root, "specialists", "org", "models.json");
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1285
1318
|
export function resolveModelTiers(options = {}) {
|
|
1286
1319
|
const {
|
|
1287
1320
|
env = process.env,
|
package/lib/models/catalog.mjs
CHANGED
|
@@ -11,9 +11,10 @@ import { loadRegistry } from '../registry/loader.mjs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import { pollFreeModels } from '../model-free-selector.mjs';
|
|
14
|
-
import { resolveFirstSecret } from '../providers/secret-resolver.mjs';
|
|
14
|
+
import { resolveFirstSecret, hasAnySecret } from '../providers/secret-resolver.mjs';
|
|
15
15
|
import { loadProjectConfig } from '../config/project-config.mjs';
|
|
16
16
|
import { doctorRoot } from '../config/xdg.mjs';
|
|
17
|
+
import { loadManifestsFromDir, mergeManifests, resolveManifestDirs } from '../extensions/loader.mjs';
|
|
17
18
|
|
|
18
19
|
export const MODEL_VISIBILITY_MODES = ['all_configured', 'tier_defaults', 'explicit'];
|
|
19
20
|
|
|
@@ -210,6 +211,44 @@ export function loadModelsCatalogContext({ cwd = process.cwd(), env = process.en
|
|
|
210
211
|
return { modelsConfig, registryModels, liveModels };
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
// Bridge onto the unified extension registry (LMCP-B1/B4): model provider
|
|
215
|
+
// families register `kind: 'model'` manifests under lib/extensions/manifests/
|
|
216
|
+
// alongside data-source/queue/storage manifests, so `construct provider list`
|
|
217
|
+
// can enumerate them through the same substrate. This is additive — it does
|
|
218
|
+
// not replace PROVIDER_FAMILY_TIERS (lib/model-router.mjs), which still owns
|
|
219
|
+
// tier resolution, live polling, and visibility filtering above.
|
|
220
|
+
|
|
221
|
+
export const MODEL_MANIFEST_KIND = 'model';
|
|
222
|
+
|
|
223
|
+
export function listModelProviderManifests({ rootDir = process.cwd(), homeDir } = {}) {
|
|
224
|
+
const dirs = resolveManifestDirs({ rootDir, homeDir });
|
|
225
|
+
const builtin = loadManifestsFromDir(dirs.builtin).manifests;
|
|
226
|
+
const user = loadManifestsFromDir(dirs.user).manifests;
|
|
227
|
+
const project = loadManifestsFromDir(dirs.project).manifests;
|
|
228
|
+
return mergeManifests(builtin, user, project).filter((m) => m.kind === MODEL_MANIFEST_KIND);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function describeModelProviders({ rootDir = process.cwd(), homeDir, env = process.env, allowAmbient = true } = {}) {
|
|
232
|
+
return listModelProviderManifests({ rootDir, homeDir }).map((manifest) => {
|
|
233
|
+
const secretEnvKeys = Array.isArray(manifest.secretEnvKeys) ? manifest.secretEnvKeys : [];
|
|
234
|
+
const configured = secretEnvKeys.length === 0 || hasAnySecret(secretEnvKeys, { env, allowAmbient });
|
|
235
|
+
return {
|
|
236
|
+
id: manifest.id,
|
|
237
|
+
displayName: manifest.docs || manifest.id,
|
|
238
|
+
description: manifest.docs || null,
|
|
239
|
+
capabilities: Array.isArray(manifest.capabilities) ? manifest.capabilities : [],
|
|
240
|
+
kind: manifest.kind,
|
|
241
|
+
source: 'model-catalog',
|
|
242
|
+
health: {
|
|
243
|
+
ok: configured,
|
|
244
|
+
detail: configured
|
|
245
|
+
? 'credentials configured'
|
|
246
|
+
: `missing env: ${secretEnvKeys.join(', ') || 'unknown'}`,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
213
252
|
export function isModelVisible(modelId, {
|
|
214
253
|
visibility = DEFAULT_MODELS_CONFIG.visibility,
|
|
215
254
|
registryModels = {},
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/net-guard.mjs — shared SSRF / DNS-rebinding egress guard (LMCP-N7).
|
|
3
|
+
*
|
|
4
|
+
* Every outbound HTTP call Construct makes on behalf of a manifest, provider,
|
|
5
|
+
* or url-type MCP entry is attacker-influenced in its destination: a malicious
|
|
6
|
+
* manifest URL or a rebinding DNS record can point an otherwise-trusted call at
|
|
7
|
+
* a loopback or private-range service and pivot into the host. This guard is
|
|
8
|
+
* the single chokepoint that prevents that.
|
|
9
|
+
*
|
|
10
|
+
* Two properties, enforced together:
|
|
11
|
+
* 1. Address policy — the destination hostname is resolved and EVERY returned
|
|
12
|
+
* address is classified; loopback, private, link-local (incl. cloud
|
|
13
|
+
* metadata 169.254.169.254), CGNAT, and unspecified ranges are denied by
|
|
14
|
+
* default. `allowPrivate: true` is the explicit, audited opt-out for
|
|
15
|
+
* on-host/dev use.
|
|
16
|
+
* 2. Rebinding pinning — the address validated at check time is the exact
|
|
17
|
+
* address the socket connects to. `resolveGuardedTarget` resolves ONCE and
|
|
18
|
+
* returns a pinned IP; `guardedFetch` connects to that pinned IP with the
|
|
19
|
+
* original hostname preserved as TLS servername + Host header, so a DNS
|
|
20
|
+
* record that flips to a private address between check and connect can
|
|
21
|
+
* never move the connection — the socket never re-resolves.
|
|
22
|
+
*
|
|
23
|
+
* The security decision lives entirely in the pure, network-free
|
|
24
|
+
* `classifyAddress` / `resolveGuardedTarget` (DNS is injectable), so the policy
|
|
25
|
+
* is unit-testable without a socket; `guardedFetch` is the thin I/O shell.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { request as httpsRequest } from 'node:https';
|
|
29
|
+
import { request as httpRequest } from 'node:http';
|
|
30
|
+
import { lookup as dnsLookup } from 'node:dns';
|
|
31
|
+
import { isIP } from 'node:net';
|
|
32
|
+
import { promisify } from 'node:util';
|
|
33
|
+
import { gunzipSync, inflateSync, brotliDecompressSync } from 'node:zlib';
|
|
34
|
+
|
|
35
|
+
const dnsLookupAll = promisify(dnsLookup);
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_ALLOWED_SCHEMES = Object.freeze(['https:', 'http:']);
|
|
38
|
+
|
|
39
|
+
/** Named egress-policy rejection; `.reason` is a stable machine tag. */
|
|
40
|
+
export class SsrfBlockedError extends Error {
|
|
41
|
+
constructor(message, reason) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'SsrfBlockedError';
|
|
44
|
+
this.reason = reason;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ipv4ToInt(ip) {
|
|
49
|
+
const parts = ip.split('.').map((n) => Number(n));
|
|
50
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
|
51
|
+
return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function inCidr4(ipInt, baseIp, prefix) {
|
|
55
|
+
const base = ipv4ToInt(baseIp);
|
|
56
|
+
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
|
57
|
+
return (ipInt & mask) === (base & mask);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const BLOCKED_V4 = [
|
|
61
|
+
['0.0.0.0', 8, 'unspecified'],
|
|
62
|
+
['10.0.0.0', 8, 'private-address'],
|
|
63
|
+
['100.64.0.0', 10, 'cgnat'],
|
|
64
|
+
['127.0.0.0', 8, 'loopback'],
|
|
65
|
+
['169.254.0.0', 16, 'link-local'],
|
|
66
|
+
['172.16.0.0', 12, 'private-address'],
|
|
67
|
+
['192.0.0.0', 24, 'ietf-protocol'],
|
|
68
|
+
['192.168.0.0', 16, 'private-address'],
|
|
69
|
+
['198.18.0.0', 15, 'benchmark'],
|
|
70
|
+
['224.0.0.0', 4, 'multicast'],
|
|
71
|
+
['240.0.0.0', 4, 'reserved'],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Classify a raw IP literal. Returns a stable reason tag when the address is in
|
|
76
|
+
* a range that must not be reached from an attacker-influenced URL, or null
|
|
77
|
+
* when the address is a routable public address.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} ip
|
|
80
|
+
* @returns {string|null}
|
|
81
|
+
*/
|
|
82
|
+
export function classifyAddress(ip) {
|
|
83
|
+
const family = isIP(ip);
|
|
84
|
+
if (family === 4) {
|
|
85
|
+
const asInt = ipv4ToInt(ip);
|
|
86
|
+
if (asInt === null) return 'unparseable';
|
|
87
|
+
for (const [base, prefix, reason] of BLOCKED_V4) {
|
|
88
|
+
if (inCidr4(asInt, base, prefix)) return reason;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (family === 6) {
|
|
93
|
+
const norm = ip.toLowerCase().replace(/^\[|\]$/g, '');
|
|
94
|
+
if (norm === '::1') return 'loopback';
|
|
95
|
+
if (norm === '::' ) return 'unspecified';
|
|
96
|
+
if (norm.startsWith('fe80')) return 'link-local';
|
|
97
|
+
if (norm.startsWith('fc') || norm.startsWith('fd')) return 'unique-local';
|
|
98
|
+
// IPv4-mapped (::ffff:a.b.c.d) is classified on its embedded v4 address.
|
|
99
|
+
const mapped = norm.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
100
|
+
if (mapped) return classifyAddress(mapped[1]);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return 'not-an-ip';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate a URL's scheme + host and resolve its hostname to a single pinned,
|
|
108
|
+
* policy-checked address. Pure with respect to the network: DNS is injected via
|
|
109
|
+
* `lookup`. Throws SsrfBlockedError on any violation.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} rawUrl
|
|
112
|
+
* @param {object} [opts]
|
|
113
|
+
* @param {(hostname: string) => Promise<Array<{address: string, family: number}>>} [opts.lookup]
|
|
114
|
+
* @param {boolean} [opts.allowPrivate] - permit private/loopback ranges (audited opt-out)
|
|
115
|
+
* @param {string[]} [opts.allowedSchemes]
|
|
116
|
+
* @returns {Promise<{ url: URL, pinnedAddress: string, family: number, port: number, servername: string }>}
|
|
117
|
+
*/
|
|
118
|
+
export async function resolveGuardedTarget(rawUrl, opts = {}) {
|
|
119
|
+
const { allowPrivate = false, allowedSchemes = DEFAULT_ALLOWED_SCHEMES } = opts;
|
|
120
|
+
const lookup = opts.lookup ?? ((hostname) => dnsLookupAll(hostname, { all: true }));
|
|
121
|
+
|
|
122
|
+
let url;
|
|
123
|
+
try {
|
|
124
|
+
url = new URL(rawUrl);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new SsrfBlockedError(`net-guard: not a valid URL: ${String(rawUrl).slice(0, 120)}`, 'invalid-url');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!allowedSchemes.includes(url.protocol)) {
|
|
130
|
+
throw new SsrfBlockedError(`net-guard: scheme '${url.protocol}' is not allowed`, 'blocked-scheme');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '');
|
|
134
|
+
|
|
135
|
+
// A URL that already carries an IP literal skips DNS but is still classified —
|
|
136
|
+
// http://127.0.0.1 must be blocked exactly like a hostname resolving to it.
|
|
137
|
+
const literalFamily = isIP(hostname);
|
|
138
|
+
let addresses;
|
|
139
|
+
if (literalFamily) {
|
|
140
|
+
addresses = [{ address: hostname, family: literalFamily }];
|
|
141
|
+
} else {
|
|
142
|
+
addresses = await lookup(hostname);
|
|
143
|
+
if (!Array.isArray(addresses) || addresses.length === 0) {
|
|
144
|
+
throw new SsrfBlockedError(`net-guard: '${hostname}' did not resolve to any address`, 'no-address');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ALL resolved addresses must pass — a hostname that returns one public and
|
|
149
|
+
// one private address is rejected, never partially trusted.
|
|
150
|
+
for (const { address } of addresses) {
|
|
151
|
+
if (allowPrivate) continue;
|
|
152
|
+
const reason = classifyAddress(address);
|
|
153
|
+
if (reason) {
|
|
154
|
+
throw new SsrfBlockedError(`net-guard: '${hostname}' resolves to a blocked address (${address}: ${reason})`, reason);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const pinned = addresses[0];
|
|
159
|
+
const port = url.port ? Number(url.port) : (url.protocol === 'https:' ? 443 : 80);
|
|
160
|
+
return { url, pinnedAddress: pinned.address, family: pinned.family, port, servername: hostname };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function decodeBody(buffer, encoding) {
|
|
164
|
+
if (!buffer.length) return buffer;
|
|
165
|
+
try {
|
|
166
|
+
if (encoding === 'gzip') return gunzipSync(buffer);
|
|
167
|
+
if (encoding === 'deflate') return inflateSync(buffer);
|
|
168
|
+
if (encoding === 'br') return brotliDecompressSync(buffer);
|
|
169
|
+
} catch {
|
|
170
|
+
return buffer;
|
|
171
|
+
}
|
|
172
|
+
return buffer;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function makeResponse({ statusCode, headers, bodyBuffer, url }) {
|
|
176
|
+
const decoded = decodeBody(bodyBuffer, String(headers['content-encoding'] ?? '').toLowerCase());
|
|
177
|
+
const headerMap = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), Array.isArray(v) ? v.join(', ') : v]));
|
|
178
|
+
return {
|
|
179
|
+
status: statusCode,
|
|
180
|
+
ok: statusCode >= 200 && statusCode < 300,
|
|
181
|
+
url,
|
|
182
|
+
headers: { get: (name) => headerMap.get(String(name).toLowerCase()) ?? null },
|
|
183
|
+
async text() { return decoded.toString('utf8'); },
|
|
184
|
+
async json() { return JSON.parse(decoded.toString('utf8') || 'null'); },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* fetch-compatible egress call that enforces the guard and connects to the
|
|
190
|
+
* pinned, validated address (rebinding-proof). Follows redirects manually,
|
|
191
|
+
* re-validating every hop through the same guard. Returns a minimal Response
|
|
192
|
+
* ({ status, ok, headers.get, text, json }) covering what the provider
|
|
193
|
+
* transports use.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} rawUrl
|
|
196
|
+
* @param {{ method?: string, headers?: Record<string,string>, body?: string }} [init]
|
|
197
|
+
* @param {object} [opts] - forwarded to resolveGuardedTarget, plus maxRedirects
|
|
198
|
+
* @returns {Promise<object>}
|
|
199
|
+
*/
|
|
200
|
+
export async function guardedFetch(rawUrl, init = {}, opts = {}) {
|
|
201
|
+
const maxRedirects = opts.maxRedirects ?? 5;
|
|
202
|
+
let currentUrl = rawUrl;
|
|
203
|
+
|
|
204
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
205
|
+
const target = await resolveGuardedTarget(currentUrl, opts);
|
|
206
|
+
const res = await requestPinned(target, init);
|
|
207
|
+
|
|
208
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
209
|
+
currentUrl = new URL(res.headers.location, target.url).href;
|
|
210
|
+
if (res.statusCode === 303) { init = { ...init, method: 'GET', body: undefined }; }
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
return makeResponse({ statusCode: res.statusCode, headers: res.headers, bodyBuffer: res.bodyBuffer, url: target.url.href });
|
|
214
|
+
}
|
|
215
|
+
throw new SsrfBlockedError(`net-guard: exceeded ${maxRedirects} redirects for ${rawUrl}`, 'too-many-redirects');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function requestPinned(target, init) {
|
|
219
|
+
const { url, pinnedAddress, family, port, servername } = target;
|
|
220
|
+
const doRequest = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
221
|
+
|
|
222
|
+
// The socket connects to the pinned IP; the hostname survives only as TLS
|
|
223
|
+
// servername + Host header. The custom lookup guarantees the pinned address
|
|
224
|
+
// is the only one the agent can dial — DNS is never consulted again here.
|
|
225
|
+
const pinnedLookup = (_hostname, _options, cb) => cb(null, pinnedAddress, family);
|
|
226
|
+
|
|
227
|
+
const options = {
|
|
228
|
+
method: init.method ?? 'GET',
|
|
229
|
+
host: servername,
|
|
230
|
+
port,
|
|
231
|
+
path: `${url.pathname}${url.search}`,
|
|
232
|
+
headers: { Host: url.host, ...(init.headers ?? {}) },
|
|
233
|
+
servername,
|
|
234
|
+
lookup: pinnedLookup,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
const req = doRequest(options, (res) => {
|
|
239
|
+
const chunks = [];
|
|
240
|
+
res.on('data', (c) => chunks.push(c));
|
|
241
|
+
res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, bodyBuffer: Buffer.concat(chunks) }));
|
|
242
|
+
});
|
|
243
|
+
req.on('error', reject);
|
|
244
|
+
if (init.body) req.write(init.body);
|
|
245
|
+
req.end();
|
|
246
|
+
});
|
|
247
|
+
}
|