@descryy/mcp 0.11.5 → 0.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action-handshake.d.ts +38 -0
- package/dist/action-handshake.d.ts.map +1 -0
- package/dist/action-handshake.js +127 -0
- package/dist/action-handshake.js.map +1 -0
- package/dist/bin/descry-mcp.d.ts +7 -0
- package/dist/bin/descry-mcp.d.ts.map +1 -0
- package/dist/bin/descry-mcp.js +73 -0
- package/dist/bin/descry-mcp.js.map +1 -0
- package/dist/browser/auth.d.ts +84 -0
- package/dist/browser/auth.d.ts.map +1 -0
- package/dist/browser/auth.js +233 -0
- package/dist/browser/auth.js.map +1 -0
- package/dist/browser/capability-probe.d.ts +109 -0
- package/dist/browser/capability-probe.d.ts.map +1 -0
- package/dist/browser/capability-probe.js +201 -0
- package/dist/browser/capability-probe.js.map +1 -0
- package/dist/browser/driver.d.ts +591 -0
- package/dist/browser/driver.d.ts.map +1 -0
- package/dist/browser/driver.js +116 -0
- package/dist/browser/driver.js.map +1 -0
- package/dist/browser/evidence.d.ts +28 -0
- package/dist/browser/evidence.d.ts.map +1 -0
- package/dist/browser/evidence.js +82 -0
- package/dist/browser/evidence.js.map +1 -0
- package/dist/browser/fake-driver.d.ts +116 -0
- package/dist/browser/fake-driver.d.ts.map +1 -0
- package/dist/browser/fake-driver.js +416 -0
- package/dist/browser/fake-driver.js.map +1 -0
- package/dist/browser/fault-attribution.d.ts +178 -0
- package/dist/browser/fault-attribution.d.ts.map +1 -0
- package/dist/browser/fault-attribution.js +266 -0
- package/dist/browser/fault-attribution.js.map +1 -0
- package/dist/browser/graph-write.d.ts +45 -0
- package/dist/browser/graph-write.d.ts.map +1 -0
- package/dist/browser/graph-write.js +103 -0
- package/dist/browser/graph-write.js.map +1 -0
- package/dist/browser/identity-graph-write.d.ts +55 -0
- package/dist/browser/identity-graph-write.d.ts.map +1 -0
- package/dist/browser/identity-graph-write.js +45 -0
- package/dist/browser/identity-graph-write.js.map +1 -0
- package/dist/browser/page-probe.d.ts +81 -0
- package/dist/browser/page-probe.d.ts.map +1 -0
- package/dist/browser/page-probe.js +202 -0
- package/dist/browser/page-probe.js.map +1 -0
- package/dist/browser/playwright-driver.d.ts +115 -0
- package/dist/browser/playwright-driver.d.ts.map +1 -0
- package/dist/browser/playwright-driver.js +1545 -0
- package/dist/browser/playwright-driver.js.map +1 -0
- package/dist/browser/provider.d.ts +26 -0
- package/dist/browser/provider.d.ts.map +1 -0
- package/dist/browser/provider.js +14 -0
- package/dist/browser/provider.js.map +1 -0
- package/dist/browser/reach-recording-session.d.ts +44 -0
- package/dist/browser/reach-recording-session.d.ts.map +1 -0
- package/dist/browser/reach-recording-session.js +151 -0
- package/dist/browser/reach-recording-session.js.map +1 -0
- package/dist/browser/reachability.d.ts +168 -0
- package/dist/browser/reachability.d.ts.map +1 -0
- package/dist/browser/reachability.js +294 -0
- package/dist/browser/reachability.js.map +1 -0
- package/dist/browser/registry.d.ts +126 -0
- package/dist/browser/registry.d.ts.map +1 -0
- package/dist/browser/registry.js +177 -0
- package/dist/browser/registry.js.map +1 -0
- package/dist/browser/scenario-provenance.d.ts +23 -0
- package/dist/browser/scenario-provenance.d.ts.map +1 -0
- package/dist/browser/scenario-provenance.js +73 -0
- package/dist/browser/scenario-provenance.js.map +1 -0
- package/dist/browser/scenario-resolve.d.ts +28 -0
- package/dist/browser/scenario-resolve.d.ts.map +1 -0
- package/dist/browser/scenario-resolve.js +96 -0
- package/dist/browser/scenario-resolve.js.map +1 -0
- package/dist/browser/scenario-runner.d.ts +72 -0
- package/dist/browser/scenario-runner.d.ts.map +1 -0
- package/dist/browser/scenario-runner.js +353 -0
- package/dist/browser/scenario-runner.js.map +1 -0
- package/dist/browser/stack-parser.d.ts +19 -0
- package/dist/browser/stack-parser.d.ts.map +1 -0
- package/dist/browser/stack-parser.js +85 -0
- package/dist/browser/stack-parser.js.map +1 -0
- package/dist/browser/tool-support.d.ts +71 -0
- package/dist/browser/tool-support.d.ts.map +1 -0
- package/dist/browser/tool-support.js +216 -0
- package/dist/browser/tool-support.js.map +1 -0
- package/dist/browser/url-scheme.d.ts +14 -0
- package/dist/browser/url-scheme.d.ts.map +1 -0
- package/dist/browser/url-scheme.js +38 -0
- package/dist/browser/url-scheme.js.map +1 -0
- package/dist/browser/wait-target.d.ts +37 -0
- package/dist/browser/wait-target.d.ts.map +1 -0
- package/dist/browser/wait-target.js +54 -0
- package/dist/browser/wait-target.js.map +1 -0
- package/dist/cancellation.d.ts +20 -0
- package/dist/cancellation.d.ts.map +1 -0
- package/dist/cancellation.js +41 -0
- package/dist/cancellation.js.map +1 -0
- package/dist/capped.d.ts +39 -0
- package/dist/capped.d.ts.map +1 -0
- package/dist/capped.js +43 -0
- package/dist/capped.js.map +1 -0
- package/dist/credential-store.d.ts +22 -0
- package/dist/credential-store.d.ts.map +1 -0
- package/dist/credential-store.js +33 -0
- package/dist/credential-store.js.map +1 -0
- package/dist/disclosure-ledger.d.ts +14 -0
- package/dist/disclosure-ledger.d.ts.map +1 -0
- package/dist/disclosure-ledger.js +18 -0
- package/dist/disclosure-ledger.js.map +1 -0
- package/dist/evidence/observed-route-evidence.d.ts +25 -0
- package/dist/evidence/observed-route-evidence.d.ts.map +1 -0
- package/dist/evidence/observed-route-evidence.js +76 -0
- package/dist/evidence/observed-route-evidence.js.map +1 -0
- package/dist/heap.d.ts +61 -0
- package/dist/heap.d.ts.map +1 -0
- package/dist/heap.js +82 -0
- package/dist/heap.js.map +1 -0
- package/dist/heartbeat.d.ts +46 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +66 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/module-trust.d.ts +37 -0
- package/dist/module-trust.d.ts.map +1 -0
- package/dist/module-trust.js +77 -0
- package/dist/module-trust.js.map +1 -0
- package/dist/path-confinement.d.ts +31 -0
- package/dist/path-confinement.d.ts.map +1 -0
- package/dist/path-confinement.js +44 -0
- package/dist/path-confinement.js.map +1 -0
- package/dist/planner/predict-then-propose.d.ts +134 -0
- package/dist/planner/predict-then-propose.d.ts.map +1 -0
- package/dist/planner/predict-then-propose.js +138 -0
- package/dist/planner/predict-then-propose.js.map +1 -0
- package/dist/planner/propose-journey.d.ts +75 -0
- package/dist/planner/propose-journey.d.ts.map +1 -0
- package/dist/planner/propose-journey.js +96 -0
- package/dist/planner/propose-journey.js.map +1 -0
- package/dist/protocol.d.ts +57 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +90 -0
- package/dist/protocol.js.map +1 -0
- package/dist/ready-checks.d.ts +12 -0
- package/dist/ready-checks.d.ts.map +1 -0
- package/dist/ready-checks.js +43 -0
- package/dist/ready-checks.js.map +1 -0
- package/dist/registry.d.ts +103 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +327 -0
- package/dist/registry.js.map +1 -0
- package/dist/render.d.ts +623 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +968 -0
- package/dist/render.js.map +1 -0
- package/dist/runtime-registry.d.ts +22 -0
- package/dist/runtime-registry.d.ts.map +1 -0
- package/dist/runtime-registry.js +142 -0
- package/dist/runtime-registry.js.map +1 -0
- package/dist/sandbox-defaults.d.ts +41 -0
- package/dist/sandbox-defaults.d.ts.map +1 -0
- package/dist/sandbox-defaults.js +14 -0
- package/dist/sandbox-defaults.js.map +1 -0
- package/dist/scenarios/browser-run-plan-projection.d.ts +161 -0
- package/dist/scenarios/browser-run-plan-projection.d.ts.map +1 -0
- package/dist/scenarios/browser-run-plan-projection.js +264 -0
- package/dist/scenarios/browser-run-plan-projection.js.map +1 -0
- package/dist/scenarios/credential-ref.d.ts +102 -0
- package/dist/scenarios/credential-ref.d.ts.map +1 -0
- package/dist/scenarios/credential-ref.js +148 -0
- package/dist/scenarios/credential-ref.js.map +1 -0
- package/dist/scenarios/index.d.ts +7 -0
- package/dist/scenarios/index.d.ts.map +1 -0
- package/dist/scenarios/index.js +7 -0
- package/dist/scenarios/index.js.map +1 -0
- package/dist/scenarios/parse.d.ts +18 -0
- package/dist/scenarios/parse.d.ts.map +1 -0
- package/dist/scenarios/parse.js +212 -0
- package/dist/scenarios/parse.js.map +1 -0
- package/dist/scenarios/scenario.d.ts +93 -0
- package/dist/scenarios/scenario.d.ts.map +1 -0
- package/dist/scenarios/scenario.js +27 -0
- package/dist/scenarios/scenario.js.map +1 -0
- package/dist/scenarios/secret-ref.d.ts +86 -0
- package/dist/scenarios/secret-ref.d.ts.map +1 -0
- package/dist/scenarios/secret-ref.js +125 -0
- package/dist/scenarios/secret-ref.js.map +1 -0
- package/dist/scenarios/storage.d.ts +21 -0
- package/dist/scenarios/storage.d.ts.map +1 -0
- package/dist/scenarios/storage.js +103 -0
- package/dist/scenarios/storage.js.map +1 -0
- package/dist/server.d.ts +61 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +642 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +356 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +779 -0
- package/dist/session.js.map +1 -0
- package/dist/tools/alias-kit.d.ts +29 -0
- package/dist/tools/alias-kit.d.ts.map +1 -0
- package/dist/tools/alias-kit.js +21 -0
- package/dist/tools/alias-kit.js.map +1 -0
- package/dist/tools/aliases.d.ts +42 -0
- package/dist/tools/aliases.d.ts.map +1 -0
- package/dist/tools/aliases.js +52 -0
- package/dist/tools/aliases.js.map +1 -0
- package/dist/tools/analyze-workspace.d.ts +52 -0
- package/dist/tools/analyze-workspace.d.ts.map +1 -0
- package/dist/tools/analyze-workspace.js +207 -0
- package/dist/tools/analyze-workspace.js.map +1 -0
- package/dist/tools/analyze.d.ts +163 -0
- package/dist/tools/analyze.d.ts.map +1 -0
- package/dist/tools/analyze.js +724 -0
- package/dist/tools/analyze.js.map +1 -0
- package/dist/tools/browser-click.d.ts +18 -0
- package/dist/tools/browser-click.d.ts.map +1 -0
- package/dist/tools/browser-click.js +110 -0
- package/dist/tools/browser-click.js.map +1 -0
- package/dist/tools/browser-close-session.d.ts +39 -0
- package/dist/tools/browser-close-session.d.ts.map +1 -0
- package/dist/tools/browser-close-session.js +120 -0
- package/dist/tools/browser-close-session.js.map +1 -0
- package/dist/tools/browser-fill.d.ts +18 -0
- package/dist/tools/browser-fill.d.ts.map +1 -0
- package/dist/tools/browser-fill.js +117 -0
- package/dist/tools/browser-fill.js.map +1 -0
- package/dist/tools/browser-navigate.d.ts +15 -0
- package/dist/tools/browser-navigate.d.ts.map +1 -0
- package/dist/tools/browser-navigate.js +106 -0
- package/dist/tools/browser-navigate.js.map +1 -0
- package/dist/tools/browser-read.d.ts +33 -0
- package/dist/tools/browser-read.d.ts.map +1 -0
- package/dist/tools/browser-read.js +105 -0
- package/dist/tools/browser-read.js.map +1 -0
- package/dist/tools/browser-run-scenario.d.ts +28 -0
- package/dist/tools/browser-run-scenario.d.ts.map +1 -0
- package/dist/tools/browser-run-scenario.js +238 -0
- package/dist/tools/browser-run-scenario.js.map +1 -0
- package/dist/tools/browser-save-scenario.d.ts +19 -0
- package/dist/tools/browser-save-scenario.d.ts.map +1 -0
- package/dist/tools/browser-save-scenario.js +447 -0
- package/dist/tools/browser-save-scenario.js.map +1 -0
- package/dist/tools/browser-select.d.ts +20 -0
- package/dist/tools/browser-select.d.ts.map +1 -0
- package/dist/tools/browser-select.js +120 -0
- package/dist/tools/browser-select.js.map +1 -0
- package/dist/tools/browser-snapshot.d.ts +46 -0
- package/dist/tools/browser-snapshot.d.ts.map +1 -0
- package/dist/tools/browser-snapshot.js +127 -0
- package/dist/tools/browser-snapshot.js.map +1 -0
- package/dist/tools/browser-start-session.d.ts +24 -0
- package/dist/tools/browser-start-session.d.ts.map +1 -0
- package/dist/tools/browser-start-session.js +653 -0
- package/dist/tools/browser-start-session.js.map +1 -0
- package/dist/tools/browser-submit.d.ts +18 -0
- package/dist/tools/browser-submit.d.ts.map +1 -0
- package/dist/tools/browser-submit.js +106 -0
- package/dist/tools/browser-submit.js.map +1 -0
- package/dist/tools/browser-type.d.ts +18 -0
- package/dist/tools/browser-type.d.ts.map +1 -0
- package/dist/tools/browser-type.js +115 -0
- package/dist/tools/browser-type.js.map +1 -0
- package/dist/tools/browser-wait-for.d.ts +45 -0
- package/dist/tools/browser-wait-for.d.ts.map +1 -0
- package/dist/tools/browser-wait-for.js +164 -0
- package/dist/tools/browser-wait-for.js.map +1 -0
- package/dist/tools/browser.d.ts +69 -0
- package/dist/tools/browser.d.ts.map +1 -0
- package/dist/tools/browser.js +326 -0
- package/dist/tools/browser.js.map +1 -0
- package/dist/tools/chain.d.ts +41 -0
- package/dist/tools/chain.d.ts.map +1 -0
- package/dist/tools/chain.js +101 -0
- package/dist/tools/chain.js.map +1 -0
- package/dist/tools/change-scope.d.ts +25 -0
- package/dist/tools/change-scope.d.ts.map +1 -0
- package/dist/tools/change-scope.js +103 -0
- package/dist/tools/change-scope.js.map +1 -0
- package/dist/tools/code-context.d.ts +83 -0
- package/dist/tools/code-context.d.ts.map +1 -0
- package/dist/tools/code-context.js +432 -0
- package/dist/tools/code-context.js.map +1 -0
- package/dist/tools/contracts.d.ts +74 -0
- package/dist/tools/contracts.d.ts.map +1 -0
- package/dist/tools/contracts.js +214 -0
- package/dist/tools/contracts.js.map +1 -0
- package/dist/tools/cross-pr.d.ts +178 -0
- package/dist/tools/cross-pr.d.ts.map +1 -0
- package/dist/tools/cross-pr.js +362 -0
- package/dist/tools/cross-pr.js.map +1 -0
- package/dist/tools/disclosure.d.ts +75 -0
- package/dist/tools/disclosure.d.ts.map +1 -0
- package/dist/tools/disclosure.js +325 -0
- package/dist/tools/disclosure.js.map +1 -0
- package/dist/tools/draw-conclusion.d.ts +55 -0
- package/dist/tools/draw-conclusion.d.ts.map +1 -0
- package/dist/tools/draw-conclusion.js +243 -0
- package/dist/tools/draw-conclusion.js.map +1 -0
- package/dist/tools/git-context.d.ts +39 -0
- package/dist/tools/git-context.d.ts.map +1 -0
- package/dist/tools/git-context.js +374 -0
- package/dist/tools/git-context.js.map +1 -0
- package/dist/tools/git-diff.d.ts +45 -0
- package/dist/tools/git-diff.d.ts.map +1 -0
- package/dist/tools/git-diff.js +338 -0
- package/dist/tools/git-diff.js.map +1 -0
- package/dist/tools/git-history.d.ts +16 -0
- package/dist/tools/git-history.d.ts.map +1 -0
- package/dist/tools/git-history.js +106 -0
- package/dist/tools/git-history.js.map +1 -0
- package/dist/tools/history.d.ts +34 -0
- package/dist/tools/history.d.ts.map +1 -0
- package/dist/tools/history.js +121 -0
- package/dist/tools/history.js.map +1 -0
- package/dist/tools/impact.d.ts +46 -0
- package/dist/tools/impact.d.ts.map +1 -0
- package/dist/tools/impact.js +225 -0
- package/dist/tools/impact.js.map +1 -0
- package/dist/tools/index.d.ts +59 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +70 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/kit.d.ts +165 -0
- package/dist/tools/kit.d.ts.map +1 -0
- package/dist/tools/kit.js +153 -0
- package/dist/tools/kit.js.map +1 -0
- package/dist/tools/link-workspace.d.ts +24 -0
- package/dist/tools/link-workspace.d.ts.map +1 -0
- package/dist/tools/link-workspace.js +131 -0
- package/dist/tools/link-workspace.js.map +1 -0
- package/dist/tools/lookup.d.ts +19 -0
- package/dist/tools/lookup.d.ts.map +1 -0
- package/dist/tools/lookup.js +63 -0
- package/dist/tools/lookup.js.map +1 -0
- package/dist/tools/mark-incident.d.ts +28 -0
- package/dist/tools/mark-incident.d.ts.map +1 -0
- package/dist/tools/mark-incident.js +171 -0
- package/dist/tools/mark-incident.js.map +1 -0
- package/dist/tools/merged-browser.d.ts +11 -0
- package/dist/tools/merged-browser.d.ts.map +1 -0
- package/dist/tools/merged-browser.js +20 -0
- package/dist/tools/merged-browser.js.map +1 -0
- package/dist/tools/merged-context.d.ts +11 -0
- package/dist/tools/merged-context.d.ts.map +1 -0
- package/dist/tools/merged-context.js +21 -0
- package/dist/tools/merged-context.js.map +1 -0
- package/dist/tools/merged-observe.d.ts +13 -0
- package/dist/tools/merged-observe.d.ts.map +1 -0
- package/dist/tools/merged-observe.js +21 -0
- package/dist/tools/merged-observe.js.map +1 -0
- package/dist/tools/merged-workspace.d.ts +14 -0
- package/dist/tools/merged-workspace.d.ts.map +1 -0
- package/dist/tools/merged-workspace.js +21 -0
- package/dist/tools/merged-workspace.js.map +1 -0
- package/dist/tools/observe-runtime.d.ts +262 -0
- package/dist/tools/observe-runtime.d.ts.map +1 -0
- package/dist/tools/observe-runtime.js +1966 -0
- package/dist/tools/observe-runtime.js.map +1 -0
- package/dist/tools/observe-tests.d.ts +57 -0
- package/dist/tools/observe-tests.d.ts.map +1 -0
- package/dist/tools/observe-tests.js +391 -0
- package/dist/tools/observe-tests.js.map +1 -0
- package/dist/tools/observe.d.ts +28 -0
- package/dist/tools/observe.d.ts.map +1 -0
- package/dist/tools/observe.js +291 -0
- package/dist/tools/observe.js.map +1 -0
- package/dist/tools/pr-analysis.d.ts +189 -0
- package/dist/tools/pr-analysis.d.ts.map +1 -0
- package/dist/tools/pr-analysis.js +365 -0
- package/dist/tools/pr-analysis.js.map +1 -0
- package/dist/tools/pre-push.d.ts +168 -0
- package/dist/tools/pre-push.d.ts.map +1 -0
- package/dist/tools/pre-push.js +416 -0
- package/dist/tools/pre-push.js.map +1 -0
- package/dist/tools/predict-reach.d.ts +57 -0
- package/dist/tools/predict-reach.d.ts.map +1 -0
- package/dist/tools/predict-reach.js +183 -0
- package/dist/tools/predict-reach.js.map +1 -0
- package/dist/tools/propagation.d.ts +37 -0
- package/dist/tools/propagation.d.ts.map +1 -0
- package/dist/tools/propagation.js +162 -0
- package/dist/tools/propagation.js.map +1 -0
- package/dist/tools/propose-journey.d.ts +16 -0
- package/dist/tools/propose-journey.d.ts.map +1 -0
- package/dist/tools/propose-journey.js +132 -0
- package/dist/tools/propose-journey.js.map +1 -0
- package/dist/tools/prove-reach.d.ts +74 -0
- package/dist/tools/prove-reach.d.ts.map +1 -0
- package/dist/tools/prove-reach.js +274 -0
- package/dist/tools/prove-reach.js.map +1 -0
- package/dist/tools/questions.d.ts +30 -0
- package/dist/tools/questions.d.ts.map +1 -0
- package/dist/tools/questions.js +230 -0
- package/dist/tools/questions.js.map +1 -0
- package/dist/tools/refusal-fetch.d.ts +35 -0
- package/dist/tools/refusal-fetch.d.ts.map +1 -0
- package/dist/tools/refusal-fetch.js +123 -0
- package/dist/tools/refusal-fetch.js.map +1 -0
- package/dist/tools/runtime-incident.d.ts +33 -0
- package/dist/tools/runtime-incident.d.ts.map +1 -0
- package/dist/tools/runtime-incident.js +67 -0
- package/dist/tools/runtime-incident.js.map +1 -0
- package/dist/tools/runtime-journey-drive.d.ts +102 -0
- package/dist/tools/runtime-journey-drive.d.ts.map +1 -0
- package/dist/tools/runtime-journey-drive.js +247 -0
- package/dist/tools/runtime-journey-drive.js.map +1 -0
- package/dist/tools/scope.d.ts +45 -0
- package/dist/tools/scope.d.ts.map +1 -0
- package/dist/tools/scope.js +217 -0
- package/dist/tools/scope.js.map +1 -0
- package/dist/tools/similar-incidents.d.ts +43 -0
- package/dist/tools/similar-incidents.d.ts.map +1 -0
- package/dist/tools/similar-incidents.js +190 -0
- package/dist/tools/similar-incidents.js.map +1 -0
- package/dist/tools/validate.d.ts +133 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +488 -0
- package/dist/tools/validate.js.map +1 -0
- package/dist/tools/verb.d.ts +46 -0
- package/dist/tools/verb.d.ts.map +1 -0
- package/dist/tools/verb.js +78 -0
- package/dist/tools/verb.js.map +1 -0
- package/dist/tools/verification-status.d.ts +38 -0
- package/dist/tools/verification-status.d.ts.map +1 -0
- package/dist/tools/verification-status.js +203 -0
- package/dist/tools/verification-status.js.map +1 -0
- package/dist/tools/verify-claim.d.ts +29 -0
- package/dist/tools/verify-claim.d.ts.map +1 -0
- package/dist/tools/verify-claim.js +218 -0
- package/dist/tools/verify-claim.js.map +1 -0
- package/dist/tools/workspace.d.ts +28 -0
- package/dist/tools/workspace.d.ts.map +1 -0
- package/dist/tools/workspace.js +94 -0
- package/dist/tools/workspace.js.map +1 -0
- package/dist/transport.d.ts +32 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +85 -0
- package/dist/transport.js.map +1 -0
- package/dist/workspace-index.d.ts +164 -0
- package/dist/workspace-index.d.ts.map +1 -0
- package/dist/workspace-index.js +381 -0
- package/dist/workspace-index.js.map +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,1966 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `observe_runtime` — boot or attach to a real application, write what was witnessed into the
|
|
3
|
+
* graph as R4 facts. Composes existing descry-runtime stages, no new mechanism (DEC-NEXT-mcp-
|
|
4
|
+
* runtime-dependency-boundary-for-r4-evidence). `action`/`evidence` (DEC-278), gated by
|
|
5
|
+
* confirmToken + profile safetyLevel (DEC-270). Never emits a denial. */
|
|
6
|
+
import { mkdir } from "node:fs/promises";
|
|
7
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
8
|
+
import { independentSignalTypes } from "@descryy/ir";
|
|
9
|
+
import { checkNotSensitivePath, checkPathConfinement } from "../path-confinement.js";
|
|
10
|
+
/** A-F6 escape hatches — see path-confinement.ts. */
|
|
11
|
+
const ALLOW_EVIDENCE_PATH_OUTSIDE_REPO_ENV_VAR = "DESCRY_ALLOW_EVIDENCE_PATH_OUTSIDE_REPO";
|
|
12
|
+
const ALLOW_SENSITIVE_LOG_PATH_ENV_VAR = "DESCRY_ALLOW_SENSITIVE_LOG_PATH";
|
|
13
|
+
import { buildGraph, counts, createConfirmedIncidentSource, evictStaleConfirmedIncidentNodes, persistGraph, } from "@descryy/core";
|
|
14
|
+
import { createDatabaseQueryCollector, createPostgresLogSource, createPostgresQueryCollector, } from "@descryy/runtime-database-observation";
|
|
15
|
+
import { evaluateAction, PROFILE_MODES, SAFETY_LEVELS, validateProfile } from "@descryy/runtime-environment-profile";
|
|
16
|
+
import { correlateExecution } from "@descryy/runtime-evidence-correlation";
|
|
17
|
+
import { EvidenceStore } from "@descryy/runtime-evidence-store";
|
|
18
|
+
import { confirmObservedFrontendCaller } from "@descryy/runtime-graph-correlator";
|
|
19
|
+
import { createSourceRootResolver, runInstrumentedExecution } from "@descryy/runtime-orchestrator";
|
|
20
|
+
import { chromiumLaunchCheck } from "../ready-checks.js";
|
|
21
|
+
import { answer, optionalBoolean, optionalEnum, optionalInteger, optionalString, ToolInputError, } from "./kit.js";
|
|
22
|
+
import { cancellationHeadline, cancellationNotes, whenAborted } from "../cancellation.js";
|
|
23
|
+
import { readScenario } from "../scenarios/index.js";
|
|
24
|
+
import { createRuntimeJourneyDrive, projectScenarioToRunPlan, } from "./runtime-journey-drive.js";
|
|
25
|
+
import { capBySize, sizeCapNote } from "../capped.js";
|
|
26
|
+
import { loadRuntimeAdapter, RuntimeAdapterLoadError } from "../runtime-registry.js";
|
|
27
|
+
import { writeConfirmedIncident, writeSavedStart } from "../session.js";
|
|
28
|
+
import { runtimeObservedIncident } from "./runtime-incident.js";
|
|
29
|
+
/** Where evidence lands when the call does not say. Beside the graph, not inside it. */
|
|
30
|
+
export const DEFAULT_EVIDENCE_RELATIVE_PATH = join(".descry", "evidence.db");
|
|
31
|
+
/** How long collectors are drained after the services report ready, when unstated. */
|
|
32
|
+
const DEFAULT_OBSERVE_MS = 5_000;
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
34
|
+
const DEFAULT_READINESS_TIMEOUT_MS = 30_000;
|
|
35
|
+
function fitObserveRuntime(rest, wrote, budgetBytes) {
|
|
36
|
+
const emptyWrote = {
|
|
37
|
+
...wrote,
|
|
38
|
+
promoted: [],
|
|
39
|
+
created: [],
|
|
40
|
+
confirmed: [],
|
|
41
|
+
refused: [],
|
|
42
|
+
staleR4: [],
|
|
43
|
+
confirmedFactConflict: [],
|
|
44
|
+
};
|
|
45
|
+
const scaffoldBytes = Buffer.byteLength(JSON.stringify({ ...rest, wrote: emptyWrote }), "utf8");
|
|
46
|
+
let budget = Math.max(budgetBytes - scaffoldBytes, 0);
|
|
47
|
+
const take = (items) => {
|
|
48
|
+
const fit = capBySize(items, budget, 0);
|
|
49
|
+
budget = Math.max(budget - Buffer.byteLength(JSON.stringify(fit.shown), "utf8"), 0);
|
|
50
|
+
return fit;
|
|
51
|
+
};
|
|
52
|
+
const promotedFit = take(wrote.promoted);
|
|
53
|
+
const createdFit = take(wrote.created);
|
|
54
|
+
const confirmedFit = take(wrote.confirmed);
|
|
55
|
+
const refusedFit = take(wrote.refused);
|
|
56
|
+
const staleR4Fit = take(wrote.staleR4);
|
|
57
|
+
const conflictFit = take(wrote.confirmedFactConflict);
|
|
58
|
+
const notes = [];
|
|
59
|
+
const noteIfDropped = (noun, fit, original) => {
|
|
60
|
+
if (fit.droppedForSize > 0)
|
|
61
|
+
notes.push(sizeCapNote(noun, fit.shown.length, original.length, fit.droppedForSize));
|
|
62
|
+
};
|
|
63
|
+
noteIfDropped("promoted edge", promotedFit, wrote.promoted);
|
|
64
|
+
noteIfDropped("created edge", createdFit, wrote.created);
|
|
65
|
+
noteIfDropped("confirmed edge", confirmedFit, wrote.confirmed);
|
|
66
|
+
noteIfDropped("refused observation", refusedFit, wrote.refused);
|
|
67
|
+
noteIfDropped("stale-R4 denial", staleR4Fit, wrote.staleR4);
|
|
68
|
+
noteIfDropped("confirmed-fact conflict", conflictFit, wrote.confirmedFactConflict);
|
|
69
|
+
const fits = [promotedFit, createdFit, confirmedFit, refusedFit, staleR4Fit, conflictFit];
|
|
70
|
+
const droppedNow = fits.reduce((n, f) => n + f.droppedForSize, 0);
|
|
71
|
+
const shownNow = fits.reduce((n, f) => n + f.shown.length, 0);
|
|
72
|
+
const preFitTotal = wrote.promoted.length +
|
|
73
|
+
wrote.created.length +
|
|
74
|
+
wrote.confirmed.length +
|
|
75
|
+
wrote.refused.length +
|
|
76
|
+
wrote.staleR4.length +
|
|
77
|
+
wrote.confirmedFactConflict.length;
|
|
78
|
+
return {
|
|
79
|
+
data: {
|
|
80
|
+
...rest,
|
|
81
|
+
wrote: {
|
|
82
|
+
...wrote,
|
|
83
|
+
promoted: promotedFit.shown,
|
|
84
|
+
created: createdFit.shown,
|
|
85
|
+
confirmed: confirmedFit.shown,
|
|
86
|
+
refused: refusedFit.shown,
|
|
87
|
+
staleR4: staleR4Fit.shown,
|
|
88
|
+
confirmedFactConflict: conflictFit.shown,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
notes,
|
|
92
|
+
...(droppedNow === 0 ? {} : { truncatedDetail: { shown: shownNow, total: preFitTotal, more: null } }),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const READINESS_KINDS = ["http", "tcp-port", "command"];
|
|
96
|
+
const SCHEMA = {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: {
|
|
99
|
+
profile: {
|
|
100
|
+
type: "object",
|
|
101
|
+
description: "The environment this run targets. Every field is declared by you and never inferred from " +
|
|
102
|
+
"any other (DEC-270): a profile named \"staging\" with safetyLevel \"readOnly\" is read-only, " +
|
|
103
|
+
"and a profile named \"local\" with safetyLevel \"readOnly\" is too.",
|
|
104
|
+
properties: {
|
|
105
|
+
name: { type: "string", description: "Free-form. Matched against no vocabulary anywhere." },
|
|
106
|
+
url: { type: "string", description: "The target's base URL. Must parse." },
|
|
107
|
+
safetyLevel: {
|
|
108
|
+
type: "string",
|
|
109
|
+
// Same constant `describeProfileError` maps INVALID_SAFETY_LEVEL's
|
|
110
|
+
// legal values from — one source, so schema and refusal cannot
|
|
111
|
+
// drift apart (UAT phase 5, F10).
|
|
112
|
+
enum: [...SAFETY_LEVELS],
|
|
113
|
+
description: "Booting a service is a write against the target, so \"readOnly\" refuses a run that " +
|
|
114
|
+
"spawns anything. A run in which every service uses \"attach\" spawns nothing and is " +
|
|
115
|
+
"permitted under \"readOnly\".",
|
|
116
|
+
},
|
|
117
|
+
credentialRef: {
|
|
118
|
+
type: "string",
|
|
119
|
+
description: "An opaque key into a credential store — never the secret itself.",
|
|
120
|
+
},
|
|
121
|
+
mode: { type: "string", enum: [...PROFILE_MODES] },
|
|
122
|
+
},
|
|
123
|
+
required: ["name", "url", "safetyLevel", "credentialRef", "mode"],
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
},
|
|
126
|
+
services: {
|
|
127
|
+
type: "object",
|
|
128
|
+
description: "One entry per service, keyed by the name evidence will be attributed to. Exactly one of " +
|
|
129
|
+
"\"command\" or \"attach\" per service. Optional: when omitted, a repository that has a " +
|
|
130
|
+
"prior successful run's services saved reuses them (disclosed on the reply) — otherwise " +
|
|
131
|
+
"this call refuses and names that omission as the reason. Supplying this always overrides " +
|
|
132
|
+
"whatever is saved, and a run that reaches readiness replaces the saved services with " +
|
|
133
|
+
"this call's own, whichever source they came from.",
|
|
134
|
+
additionalProperties: {
|
|
135
|
+
type: "object",
|
|
136
|
+
properties: {
|
|
137
|
+
command: { type: "string", description: "How to start it. Omit when using \"attach\"." },
|
|
138
|
+
cwd: {
|
|
139
|
+
type: "string",
|
|
140
|
+
description: "The directory this service is started in. Relative paths resolve against the " +
|
|
141
|
+
"repository root. REQUIRED with \"command\". With \"attach\" it is required only " +
|
|
142
|
+
"for a \"command\" readiness check, which is an executable Descry runs in it; for " +
|
|
143
|
+
"an \"http\" or \"tcp-port\" check it is not needed at all, because nothing is " +
|
|
144
|
+
"started and Descry never executes code in a process it did not spawn.",
|
|
145
|
+
},
|
|
146
|
+
// Fixes the tool's costliest ergonomic gap: 3 failed runs in one real investigation,
|
|
147
|
+
// same root cause, explanation previously living only in a source comment.
|
|
148
|
+
port: {
|
|
149
|
+
type: "integer",
|
|
150
|
+
description: "The port readiness checks against, and the two modes need opposite things from you. " +
|
|
151
|
+
"With \"attach\": REQUIRED whenever readiness is \"http\" or \"tcp-port\" — the target " +
|
|
152
|
+
"chose its port before Descry saw it, and nothing in a pid or a log path reveals which, " +
|
|
153
|
+
"so this is refused up front rather than guessed. With \"command\": omit to get an " +
|
|
154
|
+
"ephemeral port, which is passed to your command as PORT; set it only if your command " +
|
|
155
|
+
"hardcodes a port, and then it must be THAT port — a readiness check against a port " +
|
|
156
|
+
"your command did not bind fails while the service is perfectly healthy.",
|
|
157
|
+
},
|
|
158
|
+
dependsOn: {
|
|
159
|
+
type: "array",
|
|
160
|
+
items: { type: "string" },
|
|
161
|
+
description: "Service names that must be ready first. Declared, never inferred.",
|
|
162
|
+
},
|
|
163
|
+
env: { type: "object", additionalProperties: { type: "string" } },
|
|
164
|
+
attach: {
|
|
165
|
+
type: "object",
|
|
166
|
+
description: "Observe a process that is already running instead of spawning one. Descry never " +
|
|
167
|
+
"executes code in, signals, or applies resource limits to a process it did not spawn.",
|
|
168
|
+
properties: {
|
|
169
|
+
pid: { type: "integer" },
|
|
170
|
+
logFilePath: {
|
|
171
|
+
type: "string",
|
|
172
|
+
description: "A file the target already writes its stdout/stderr to.",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
required: ["pid", "logFilePath"],
|
|
176
|
+
additionalProperties: false,
|
|
177
|
+
},
|
|
178
|
+
readiness: {
|
|
179
|
+
type: "object",
|
|
180
|
+
description: "Required per service — a run refuses rather than treat \"the process started\" as " +
|
|
181
|
+
"\"the service is up\". Only the three mechanisms expressible as JSON are offered here; " +
|
|
182
|
+
"\"log-pattern\" and \"custom-hook\" need a function and are not reachable through this " +
|
|
183
|
+
"tool, which is disclosed on every call rather than left to be discovered.",
|
|
184
|
+
properties: {
|
|
185
|
+
kind: { type: "string", enum: [...READINESS_KINDS] },
|
|
186
|
+
path: {
|
|
187
|
+
type: "string",
|
|
188
|
+
description: "kind \"http\": path appended to http://127.0.0.1:<resolved port>. Defaults to \"/\".",
|
|
189
|
+
},
|
|
190
|
+
expectedStatus: { type: "integer", description: "kind \"http\": defaults to any 2xx/3xx." },
|
|
191
|
+
host: { type: "string", description: "kind \"tcp-port\": defaults to 127.0.0.1." },
|
|
192
|
+
command: { type: "string", description: "kind \"command\": the executable to run." },
|
|
193
|
+
args: { type: "array", items: { type: "string" }, description: "kind \"command\"." },
|
|
194
|
+
timeoutMs: { type: "integer", description: `Defaults to ${DEFAULT_READINESS_TIMEOUT_MS}.` },
|
|
195
|
+
},
|
|
196
|
+
required: ["kind"],
|
|
197
|
+
additionalProperties: false,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
required: ["readiness"],
|
|
201
|
+
additionalProperties: false,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
adapter: {
|
|
205
|
+
type: "object",
|
|
206
|
+
description: "The runtime adapter to observe with, named as a module specifier and imported at run time. " +
|
|
207
|
+
"This server depends on none of descry-runtime's per-language runtime adapters by design, and " +
|
|
208
|
+
"names none of them anywhere — including here, which is why this description carries no " +
|
|
209
|
+
"example specifier. Install the one matching the service's runtime alongside this server and " +
|
|
210
|
+
"name its package here; descry-runtime publishes one runtime adapter package per supported " +
|
|
211
|
+
"runtime, and its README lists them.",
|
|
212
|
+
properties: {
|
|
213
|
+
module: { type: "string" },
|
|
214
|
+
export: {
|
|
215
|
+
type: "string",
|
|
216
|
+
description: "Defaults to the single export matching create*RuntimeAdapter. Two matches is an error, " +
|
|
217
|
+
"not a coin toss — name one here.",
|
|
218
|
+
},
|
|
219
|
+
options: { type: "object", description: "Passed to the factory. Adapter-specific and opaque here." },
|
|
220
|
+
},
|
|
221
|
+
required: ["module"],
|
|
222
|
+
additionalProperties: false,
|
|
223
|
+
},
|
|
224
|
+
scopeByService: {
|
|
225
|
+
type: "object",
|
|
226
|
+
description: "Service name → which repository its symbols resolve in. A service with no entry has its " +
|
|
227
|
+
"symbol evidence left alone and its name reported, never resolved against a repository " +
|
|
228
|
+
"nobody named. Defaults to this session's own repo for every declared service.",
|
|
229
|
+
additionalProperties: {
|
|
230
|
+
type: "object",
|
|
231
|
+
properties: {
|
|
232
|
+
repo: { type: "string" },
|
|
233
|
+
repoRoot: { type: "string", description: "Absolute on-disk root, so observed absolute paths translate exactly." },
|
|
234
|
+
cwd: { type: "string" },
|
|
235
|
+
},
|
|
236
|
+
required: ["repo"],
|
|
237
|
+
additionalProperties: false,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
observeForMs: {
|
|
241
|
+
type: "integer",
|
|
242
|
+
description: `How long to drain collector output after the services are up. Defaults to ${DEFAULT_OBSERVE_MS}. ` +
|
|
243
|
+
"There is no \"the application is done\" signal at this layer — a server runs until stopped — " +
|
|
244
|
+
"so you state the observation window rather than this tool guessing at one.",
|
|
245
|
+
},
|
|
246
|
+
timeoutMs: { type: "integer", description: `Whole-execution budget. Defaults to ${DEFAULT_TIMEOUT_MS}.` },
|
|
247
|
+
environmentTier: {
|
|
248
|
+
type: "string",
|
|
249
|
+
enum: ["tier-0-ci-attached", "tier-1-preview", "tier-2-container", "tier-2b-api-only", "tier-3-static-only"],
|
|
250
|
+
description: "Recorded on the execution. Defaults to \"tier-2-container\" and is deliberately not derived " +
|
|
251
|
+
"from \"profile.mode\" — DEC-270's rule is that a declared field is declared, not inferred " +
|
|
252
|
+
"from a neighbouring one.",
|
|
253
|
+
},
|
|
254
|
+
fidelityLevel: {
|
|
255
|
+
type: "integer",
|
|
256
|
+
enum: [1, 2, 3, 4],
|
|
257
|
+
description: "1 rule-aware stub · 2 real code + disposable DB · 3 real code + redacted recordings · " +
|
|
258
|
+
"4 real staging. Defaults to 2. Not derived from anything else, same reason as environmentTier.",
|
|
259
|
+
},
|
|
260
|
+
evidencePath: {
|
|
261
|
+
type: "string",
|
|
262
|
+
description: `Where the evidence database lives. Defaults to ${DEFAULT_EVIDENCE_RELATIVE_PATH} under the repository root.`,
|
|
263
|
+
},
|
|
264
|
+
resourceLimits: {
|
|
265
|
+
type: "object",
|
|
266
|
+
description: "A-F5: caps on a spawned service, enforced by the OS (prlimit) — never applied to an attached " +
|
|
267
|
+
"service, since Descry did not start it. Absent means unconstrained, which every reply discloses.",
|
|
268
|
+
properties: {
|
|
269
|
+
maxMemoryBytes: { type: "integer", description: "Virtual address space cap (prlimit --as)." },
|
|
270
|
+
maxCpuSeconds: { type: "integer", description: "CPU time cap, in seconds (prlimit --cpu)." },
|
|
271
|
+
maxProcesses: { type: "integer", description: "Process count cap, per real uid (prlimit --nproc)." },
|
|
272
|
+
},
|
|
273
|
+
additionalProperties: false,
|
|
274
|
+
},
|
|
275
|
+
filesystemPolicy: {
|
|
276
|
+
type: "object",
|
|
277
|
+
description: "A-F5: confines a spawned service's filesystem view to its own cwd plus these roots — real on " +
|
|
278
|
+
"Linux (a bwrap mount namespace; everything else is not merely unreadable, it is not mounted at " +
|
|
279
|
+
"all), refused rather than silently unenforced elsewhere. Absent means unconstrained.",
|
|
280
|
+
properties: {
|
|
281
|
+
allowedRoots: {
|
|
282
|
+
type: "array",
|
|
283
|
+
items: { type: "string" },
|
|
284
|
+
description: "Absolute paths visible read-write in addition to the service's own cwd.",
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
required: ["allowedRoots"],
|
|
288
|
+
additionalProperties: false,
|
|
289
|
+
},
|
|
290
|
+
networkPolicy: {
|
|
291
|
+
type: "object",
|
|
292
|
+
description: 'A-F5: only { mode: "allow", hosts: [] } (full denial) is actually enforced today — a network ' +
|
|
293
|
+
"namespace holding nothing but an unreachable loopback. Any other shape refuses the run rather " +
|
|
294
|
+
"than starting unconstrained under a policy nobody enforced. Absent means unconstrained.",
|
|
295
|
+
properties: {
|
|
296
|
+
mode: { type: "string", enum: ["allow", "deny"] },
|
|
297
|
+
hosts: { type: "array", items: { type: "string" } },
|
|
298
|
+
},
|
|
299
|
+
required: ["mode", "hosts"],
|
|
300
|
+
additionalProperties: false,
|
|
301
|
+
},
|
|
302
|
+
sandboxBackend: {
|
|
303
|
+
type: "string",
|
|
304
|
+
enum: ["bwrap", "container"],
|
|
305
|
+
description: 'Which mechanism enforces filesystemPolicy/networkPolicy. Defaults to "bwrap" (Linux-native). ' +
|
|
306
|
+
'"container" routes through a real Docker container instead — the only option on macOS/Windows, ' +
|
|
307
|
+
"and it does not compose with resourceLimits (disclosed on the reply when both are declared).",
|
|
308
|
+
},
|
|
309
|
+
confirmToken: {
|
|
310
|
+
type: "string",
|
|
311
|
+
description: "The token returned by an unconfirmed call. This tool performs nothing without it: the first " +
|
|
312
|
+
"call describes what running would do and returns a token, and only a second call presenting " +
|
|
313
|
+
"that exact token runs anything — with the arguments frozen when the token was minted, never " +
|
|
314
|
+
"whatever the second call supplies. A conflicting argument on the confirming call is " +
|
|
315
|
+
"discarded rather than applied, and the reply discloses which ones were.",
|
|
316
|
+
},
|
|
317
|
+
journey: {
|
|
318
|
+
type: "object",
|
|
319
|
+
description: "RG-4 — drive a real browser inside THIS run's readiness window, so browser and backend " +
|
|
320
|
+
"evidence land in the same evidence store under the same executionId, rather than requiring " +
|
|
321
|
+
"a separate browser_start_session call against an app that may no longer be in the state this " +
|
|
322
|
+
"run put it in. Optional: omitted means backend-only, exactly as before. Declare exactly one " +
|
|
323
|
+
"of \"scenario\" or \"startUrl\". No journey is invented — a scenario with no steps, or " +
|
|
324
|
+
"startUrl alone, navigates and observes without interacting with anything.",
|
|
325
|
+
properties: {
|
|
326
|
+
service: {
|
|
327
|
+
type: "string",
|
|
328
|
+
description: "Which declared service this browser evidence is attributed to. Optional.",
|
|
329
|
+
},
|
|
330
|
+
scenario: {
|
|
331
|
+
type: "string",
|
|
332
|
+
description: "The name of a committed scenario under descry/scenarios/ (see browser_save_scenario), " +
|
|
333
|
+
"replayed inside this run's readiness window. Its own targetUrl is used as the start URL.",
|
|
334
|
+
},
|
|
335
|
+
startUrl: {
|
|
336
|
+
type: "string",
|
|
337
|
+
description: "Navigate here and observe, with no saved scenario and no steps. Mutually exclusive with \"scenario\".",
|
|
338
|
+
},
|
|
339
|
+
recordVideo: {
|
|
340
|
+
type: "boolean",
|
|
341
|
+
description: "RG-6. Off by default. Captured after the drive completes and returned as a VIDEO evidence artifact.",
|
|
342
|
+
},
|
|
343
|
+
headless: { type: "boolean", description: "Defaults to true." },
|
|
344
|
+
settleForMs: {
|
|
345
|
+
type: "integer",
|
|
346
|
+
description: "How long to keep the page alive after the last step so async console/network activity it provoked actually arrives. Defaults to 1000.",
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
additionalProperties: false,
|
|
350
|
+
},
|
|
351
|
+
database: {
|
|
352
|
+
type: "object",
|
|
353
|
+
description: "RG-5(c) — the database channel. Node services get the node:sqlite observation preload " +
|
|
354
|
+
"automatically (no declaration needed); this argument is for Postgres, whose statement log " +
|
|
355
|
+
"lives on the server, not in a spawned service's own output. Declares where to tail it from, " +
|
|
356
|
+
"so real queries issued during this run's observation window become DATABASE_QUERY evidence. " +
|
|
357
|
+
"Omit entirely for no Postgres channel — the reply discloses that rather than staying silent.",
|
|
358
|
+
properties: {
|
|
359
|
+
statementLog: {
|
|
360
|
+
type: "object",
|
|
361
|
+
description: "Never turns Postgres logging on — reads whatever the deployment already produces.",
|
|
362
|
+
properties: {
|
|
363
|
+
kind: { type: "string", enum: ["file", "dockerContainer"] },
|
|
364
|
+
path: { type: "string", description: "kind \"file\": a log file the deployment already writes to." },
|
|
365
|
+
container: { type: "string", description: "kind \"dockerContainer\": tails `docker logs -f` for this container." },
|
|
366
|
+
},
|
|
367
|
+
required: ["kind"],
|
|
368
|
+
additionalProperties: false,
|
|
369
|
+
},
|
|
370
|
+
service: {
|
|
371
|
+
type: "string",
|
|
372
|
+
description: "Which declared service this DATABASE_QUERY evidence is attributed to. Optional.",
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
required: ["statementLog"],
|
|
376
|
+
additionalProperties: false,
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
required: ["profile", "adapter"],
|
|
380
|
+
additionalProperties: false,
|
|
381
|
+
};
|
|
382
|
+
function asRecord(value, what) {
|
|
383
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
384
|
+
throw new ToolInputError(`"${what}" must be an object`);
|
|
385
|
+
}
|
|
386
|
+
return value;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* `validateProfile`'s bare machine codes, mapped to a sentence naming what would
|
|
390
|
+
* satisfy them — generated from `SAFETY_LEVELS`/`PROFILE_MODES`, the same constants
|
|
391
|
+
* the schema's own `enum`s are built from above, so the two cannot drift (UAT phase
|
|
392
|
+
* 5, F10). The codes without a closed value set (`EMPTY_NAME`, `INVALID_URL`, …)
|
|
393
|
+
* still get a plain-language sentence rather than reaching the caller verbatim —
|
|
394
|
+
* consistency, not just the two enum cases.
|
|
395
|
+
*/
|
|
396
|
+
function describeProfileError(code) {
|
|
397
|
+
switch (code) {
|
|
398
|
+
case "EMPTY_NAME":
|
|
399
|
+
return '"profile.name" must not be empty';
|
|
400
|
+
case "EMPTY_CREDENTIAL_REF":
|
|
401
|
+
return '"profile.credentialRef" must not be empty';
|
|
402
|
+
case "INVALID_URL":
|
|
403
|
+
return '"profile.url" must be a URL the URL constructor can parse';
|
|
404
|
+
case "INVALID_SAFETY_LEVEL":
|
|
405
|
+
return `"profile.safetyLevel" must be one of: ${SAFETY_LEVELS.join(", ")}`;
|
|
406
|
+
case "INVALID_MODE":
|
|
407
|
+
return `"profile.mode" must be one of: ${PROFILE_MODES.join(", ")}`;
|
|
408
|
+
case "REMOTE_LOG_SOURCE_NOT_ALLOWED_FOR_MODE":
|
|
409
|
+
return ('"profile.remoteLogSource" is not allowed when "profile.mode" is "localBooted" or ' +
|
|
410
|
+
'"localAttached" — DEC-271\'s remote-log-ingestion mechanism only applies to "remote"/"production"');
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function readProfile(args) {
|
|
414
|
+
const raw = asRecord(args["profile"], "profile");
|
|
415
|
+
// Collect, don't short-circuit (UAT phase 5, F10): the old `field()` threw on
|
|
416
|
+
// the first key that was not a string, costing up to five round trips to
|
|
417
|
+
// enumerate five required fields ("seven refusals, one field at a time" in
|
|
418
|
+
// one real investigation). Every field is checked before anything is thrown,
|
|
419
|
+
// so a caller learns the whole shape of what is wrong in one call.
|
|
420
|
+
const shapeErrors = [];
|
|
421
|
+
const field = (key) => {
|
|
422
|
+
const value = raw[key];
|
|
423
|
+
if (typeof value !== "string") {
|
|
424
|
+
shapeErrors.push(`"profile.${key}" must be a string`);
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
return value;
|
|
428
|
+
};
|
|
429
|
+
const name = field("name");
|
|
430
|
+
const url = field("url");
|
|
431
|
+
const safetyLevel = field("safetyLevel");
|
|
432
|
+
const credentialRef = field("credentialRef");
|
|
433
|
+
const mode = field("mode");
|
|
434
|
+
if (shapeErrors.length > 0) {
|
|
435
|
+
throw new ToolInputError(`"profile" is not valid: ${shapeErrors.join("; ")}`);
|
|
436
|
+
}
|
|
437
|
+
const candidate = {
|
|
438
|
+
name: name,
|
|
439
|
+
url: url,
|
|
440
|
+
safetyLevel: safetyLevel,
|
|
441
|
+
credentialRef: credentialRef,
|
|
442
|
+
mode: mode,
|
|
443
|
+
};
|
|
444
|
+
const errors = validateProfile(candidate);
|
|
445
|
+
if (errors.length > 0) {
|
|
446
|
+
throw new ToolInputError(`"profile" is not valid: ${errors.map(describeProfileError).join("; ")}`);
|
|
447
|
+
}
|
|
448
|
+
return candidate;
|
|
449
|
+
}
|
|
450
|
+
function readAdapterSpec(args) {
|
|
451
|
+
const raw = asRecord(args["adapter"], "adapter");
|
|
452
|
+
const module = raw["module"];
|
|
453
|
+
if (typeof module !== "string" || module === "") {
|
|
454
|
+
throw new ToolInputError('"adapter.module" is required and must be a non-empty string');
|
|
455
|
+
}
|
|
456
|
+
const exportName = raw["export"];
|
|
457
|
+
if (exportName !== undefined && typeof exportName !== "string") {
|
|
458
|
+
throw new ToolInputError('"adapter.export" must be a string');
|
|
459
|
+
}
|
|
460
|
+
const options = raw["options"];
|
|
461
|
+
if (options !== undefined && (typeof options !== "object" || options === null)) {
|
|
462
|
+
throw new ToolInputError('"adapter.options" must be an object');
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
module,
|
|
466
|
+
...(typeof exportName === "string" ? { export: exportName } : {}),
|
|
467
|
+
...(options === undefined ? {} : { options: options }),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
/** `readiness`'s raw JSON, captured as `SavedServiceStart.readiness` — before `readReadiness`
|
|
471
|
+
* turns it into a closure that cannot be serialised. Reads the same fields `readReadiness`
|
|
472
|
+
* itself validates, so this can only be called after that validation has already run. */
|
|
473
|
+
function toSavedReadiness(raw) {
|
|
474
|
+
const entry = asRecord(raw, "readiness");
|
|
475
|
+
const kind = entry["kind"];
|
|
476
|
+
const path = typeof entry["path"] === "string" ? entry["path"] : undefined;
|
|
477
|
+
const expectedStatus = typeof entry["expectedStatus"] === "number" ? entry["expectedStatus"] : undefined;
|
|
478
|
+
const host = typeof entry["host"] === "string" ? entry["host"] : undefined;
|
|
479
|
+
const command = typeof entry["command"] === "string" ? entry["command"] : undefined;
|
|
480
|
+
const rawArgs = entry["args"];
|
|
481
|
+
const args = Array.isArray(rawArgs) ? rawArgs : undefined;
|
|
482
|
+
const timeoutMs = typeof entry["timeoutMs"] === "number" ? entry["timeoutMs"] : undefined;
|
|
483
|
+
return {
|
|
484
|
+
kind,
|
|
485
|
+
...(path === undefined ? {} : { path }),
|
|
486
|
+
...(expectedStatus === undefined ? {} : { expectedStatus }),
|
|
487
|
+
...(host === undefined ? {} : { host }),
|
|
488
|
+
...(command === undefined ? {} : { command }),
|
|
489
|
+
...(args === undefined ? {} : { args }),
|
|
490
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
/** Rebuilds `DeclaredService[]` from what a prior successful run saved — the reuse path
|
|
494
|
+
* (build item 3). `envNames` are re-read from THIS process's own environment, never from
|
|
495
|
+
* disk: nothing here ever held a value to begin with. A name with nothing currently set
|
|
496
|
+
* under it is reported back via `missingSavedEnv` rather than silently passing `undefined`
|
|
497
|
+
* through — the caller (`run`) discloses it, per rule 7. */
|
|
498
|
+
function declaredFromSaved(saved, repoPath) {
|
|
499
|
+
const missingSavedEnv = [];
|
|
500
|
+
const declared = Object.entries(saved.services).map(([name, svc]) => {
|
|
501
|
+
const cwd = isAbsolute(svc.cwd) ? svc.cwd : join(repoPath, svc.cwd);
|
|
502
|
+
const env = {};
|
|
503
|
+
for (const key of svc.envNames ?? []) {
|
|
504
|
+
const value = process.env[key];
|
|
505
|
+
if (value === undefined)
|
|
506
|
+
missingSavedEnv.push(`${name}.${key}`);
|
|
507
|
+
else
|
|
508
|
+
env[key] = value;
|
|
509
|
+
}
|
|
510
|
+
const configuration = {
|
|
511
|
+
...(svc.command === undefined ? {} : { command: svc.command }),
|
|
512
|
+
cwd,
|
|
513
|
+
...(svc.port === undefined ? {} : { port: svc.port }),
|
|
514
|
+
...(svc.dependsOn === undefined ? {} : { dependsOn: svc.dependsOn }),
|
|
515
|
+
...(Object.keys(env).length === 0 ? {} : { env }),
|
|
516
|
+
...(svc.attach === undefined ? {} : { attach: svc.attach }),
|
|
517
|
+
};
|
|
518
|
+
return {
|
|
519
|
+
name,
|
|
520
|
+
configuration,
|
|
521
|
+
readiness: readReadiness(svc.readiness, name, cwd),
|
|
522
|
+
attached: svc.attach !== undefined,
|
|
523
|
+
savable: svc,
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
return { declared, missingSavedEnv };
|
|
527
|
+
}
|
|
528
|
+
/** A saved command or attach target, named plainly for a refusal/failure note (build item
|
|
529
|
+
* 4) — never the whole saved shape, which would bury the one fact ("this is what didn't
|
|
530
|
+
* work") a caller needs to read at a glance. */
|
|
531
|
+
function describeSavedCommands(declared) {
|
|
532
|
+
return declared
|
|
533
|
+
.map((service) => service.attached
|
|
534
|
+
? `${service.name}: attach to pid ${String(service.configuration.attach?.pid ?? "?")}`
|
|
535
|
+
: `${service.name}: "${service.configuration.command ?? "?"}"`)
|
|
536
|
+
.join("; ");
|
|
537
|
+
}
|
|
538
|
+
function readServices(args, repoPath, savedFallback) {
|
|
539
|
+
const rawServices = args["services"];
|
|
540
|
+
if (rawServices === undefined) {
|
|
541
|
+
if (savedFallback === undefined || Object.keys(savedFallback.services).length === 0) {
|
|
542
|
+
throw new ToolInputError('"services" must declare at least one service, and no start settings have been saved yet ' +
|
|
543
|
+
"for this repository. Declare it once with a run that reaches readiness and this tool " +
|
|
544
|
+
"remembers it — after that, \"services\" can be omitted and the saved settings are " +
|
|
545
|
+
"reused, disclosed on every call that reuses them.");
|
|
546
|
+
}
|
|
547
|
+
const { declared, missingSavedEnv } = declaredFromSaved(savedFallback, repoPath);
|
|
548
|
+
return { declared, usedSaved: true, missingSavedEnv };
|
|
549
|
+
}
|
|
550
|
+
const raw = asRecord(rawServices, "services");
|
|
551
|
+
const names = Object.keys(raw);
|
|
552
|
+
if (names.length === 0)
|
|
553
|
+
throw new ToolInputError('"services" must declare at least one service');
|
|
554
|
+
const declared = names.map((name) => {
|
|
555
|
+
const entry = asRecord(raw[name], `services.${name}`);
|
|
556
|
+
const command = entry["command"];
|
|
557
|
+
const attachRaw = entry["attach"];
|
|
558
|
+
if ((command === undefined) === (attachRaw === undefined)) {
|
|
559
|
+
throw new ToolInputError(`services.${name} must declare exactly one of "command" or "attach" — ` +
|
|
560
|
+
(command === undefined ? "it declares neither" : "it declares both"));
|
|
561
|
+
}
|
|
562
|
+
if (command !== undefined && typeof command !== "string") {
|
|
563
|
+
throw new ToolInputError(`"services.${name}.command" must be a string`);
|
|
564
|
+
}
|
|
565
|
+
// Read once: two rules below need it (whether cwd is required, whether attach needs a port).
|
|
566
|
+
const readinessRaw = entry["readiness"];
|
|
567
|
+
const readinessKind = typeof readinessRaw === "object" && readinessRaw !== null && !Array.isArray(readinessRaw)
|
|
568
|
+
? readinessRaw["kind"]
|
|
569
|
+
: undefined;
|
|
570
|
+
// cwd required only where something executes in it: attach never runs code Descry didn't
|
|
571
|
+
// spawn, so http/tcp-port checks leave it inert; a "command" check does execute (DEC-388).
|
|
572
|
+
const cwdRaw = entry["cwd"];
|
|
573
|
+
if (cwdRaw !== undefined && (typeof cwdRaw !== "string" || cwdRaw === "")) {
|
|
574
|
+
throw new ToolInputError(`"services.${name}.cwd" must be a non-empty string`);
|
|
575
|
+
}
|
|
576
|
+
if (cwdRaw === undefined && attachRaw === undefined) {
|
|
577
|
+
throw new ToolInputError(`"services.${name}.cwd" is required and must be a non-empty string: it is the directory ` +
|
|
578
|
+
`"${name}" is started in.`);
|
|
579
|
+
}
|
|
580
|
+
if (cwdRaw === undefined && attachRaw !== undefined && readinessKind === "command") {
|
|
581
|
+
throw new ToolInputError(`"services.${name}.cwd" is required when "${name}" uses "attach" with a "command" ` +
|
|
582
|
+
"readiness check: the check is an executable Descry runs, and it runs in this " +
|
|
583
|
+
"directory. Attaching needs no cwd otherwise — nothing is started.");
|
|
584
|
+
}
|
|
585
|
+
const cwd = cwdRaw ?? repoPath;
|
|
586
|
+
const port = entry["port"];
|
|
587
|
+
if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 0)) {
|
|
588
|
+
throw new ToolInputError(`"services.${name}.port" must be a non-negative integer`);
|
|
589
|
+
}
|
|
590
|
+
const dependsOn = entry["dependsOn"];
|
|
591
|
+
if (dependsOn !== undefined &&
|
|
592
|
+
(!Array.isArray(dependsOn) || dependsOn.some((d) => typeof d !== "string"))) {
|
|
593
|
+
throw new ToolInputError(`"services.${name}.dependsOn" must be an array of strings`);
|
|
594
|
+
}
|
|
595
|
+
const env = entry["env"];
|
|
596
|
+
if (env !== undefined) {
|
|
597
|
+
const record = asRecord(env, `services.${name}.env`);
|
|
598
|
+
for (const [key, value] of Object.entries(record)) {
|
|
599
|
+
if (typeof value !== "string") {
|
|
600
|
+
throw new ToolInputError(`"services.${name}.env.${key}" must be a string`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
let attach;
|
|
605
|
+
if (attachRaw !== undefined) {
|
|
606
|
+
const a = asRecord(attachRaw, `services.${name}.attach`);
|
|
607
|
+
const pid = a["pid"];
|
|
608
|
+
const logFilePath = a["logFilePath"];
|
|
609
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
|
|
610
|
+
throw new ToolInputError(`"services.${name}.attach.pid" must be a positive integer`);
|
|
611
|
+
}
|
|
612
|
+
if (typeof logFilePath !== "string" || logFilePath === "") {
|
|
613
|
+
throw new ToolInputError(`"services.${name}.attach.logFilePath" is required`);
|
|
614
|
+
}
|
|
615
|
+
// A-F6: logFilePath is read in full on every poll and can't be root-confined (a real
|
|
616
|
+
// log lives anywhere) — narrower, disclosed name-based check instead (path-confinement.ts).
|
|
617
|
+
const sensitivity = checkNotSensitivePath(logFilePath, ALLOW_SENSITIVE_LOG_PATH_ENV_VAR);
|
|
618
|
+
if (sensitivity.sensitive) {
|
|
619
|
+
throw new ToolInputError(`"services.${name}.attach.logFilePath": ${sensitivity.reason}`);
|
|
620
|
+
}
|
|
621
|
+
attach = { pid, logFilePath };
|
|
622
|
+
// An attached service's port can't be allocated or inferred — nothing in a pid or log
|
|
623
|
+
// path reveals it. Left as `port ?? 0` it silently times out against port 0; refused
|
|
624
|
+
// here by name instead (cost a full failed run to rediscover, twice). Not for "command"
|
|
625
|
+
// checks, which never ask where the service listens.
|
|
626
|
+
if ((readinessKind === "http" || readinessKind === "tcp-port") && port === undefined) {
|
|
627
|
+
throw new ToolInputError(`"services.${name}.port" is required when "${name}" uses "attach" with a ` +
|
|
628
|
+
`"${readinessKind}" readiness check: the check needs a port and an attached target's ` +
|
|
629
|
+
"port cannot be allocated or inferred — it is whatever the already-running process " +
|
|
630
|
+
"bound. State it, or use a \"command\" readiness check, which needs none.");
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const configuration = {
|
|
634
|
+
...(typeof command === "string" ? { command } : {}),
|
|
635
|
+
cwd: isAbsolute(cwd) ? cwd : join(repoPath, cwd),
|
|
636
|
+
...(port === undefined ? {} : { port: port }),
|
|
637
|
+
...(dependsOn === undefined ? {} : { dependsOn: dependsOn }),
|
|
638
|
+
...(env === undefined ? {} : { env: env }),
|
|
639
|
+
...(attach === undefined ? {} : { attach }),
|
|
640
|
+
};
|
|
641
|
+
const savable = {
|
|
642
|
+
...(typeof command === "string" ? { command } : {}),
|
|
643
|
+
cwd: configuration.cwd,
|
|
644
|
+
...(port === undefined ? {} : { port: port }),
|
|
645
|
+
...(dependsOn === undefined ? {} : { dependsOn: dependsOn }),
|
|
646
|
+
// Names only — see this file's own header and SavedServiceStart's doc. Never `env` itself.
|
|
647
|
+
...(env === undefined ? {} : { envNames: Object.keys(env) }),
|
|
648
|
+
...(attach === undefined ? {} : { attach }),
|
|
649
|
+
readiness: toSavedReadiness(entry["readiness"]),
|
|
650
|
+
};
|
|
651
|
+
return {
|
|
652
|
+
name,
|
|
653
|
+
configuration,
|
|
654
|
+
readiness: readReadiness(entry["readiness"], name, configuration.cwd),
|
|
655
|
+
attached: attach !== undefined,
|
|
656
|
+
savable,
|
|
657
|
+
};
|
|
658
|
+
});
|
|
659
|
+
return { declared, usedSaved: false, missingSavedEnv: [] };
|
|
660
|
+
}
|
|
661
|
+
/** JSON→ReadinessCheck mapping. `log-pattern`/`custom-hook` can't survive a JSON boundary
|
|
662
|
+
* (one closes over a live process, the other is a function) — absent from the enum and
|
|
663
|
+
* stated in disclosures instead (rule 7, applied to a capability, not just a result). */
|
|
664
|
+
function readReadiness(raw, service, cwd) {
|
|
665
|
+
const entry = asRecord(raw, `services.${service}.readiness`);
|
|
666
|
+
const kind = entry["kind"];
|
|
667
|
+
if (typeof kind !== "string" || !READINESS_KINDS.includes(kind)) {
|
|
668
|
+
throw new ToolInputError(`"services.${service}.readiness.kind" must be one of: ${READINESS_KINDS.join(", ")}`);
|
|
669
|
+
}
|
|
670
|
+
const timeoutMs = entry["timeoutMs"];
|
|
671
|
+
if (timeoutMs !== undefined &&
|
|
672
|
+
(typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs) || timeoutMs < 1)) {
|
|
673
|
+
throw new ToolInputError(`"services.${service}.readiness.timeoutMs" must be a positive integer`);
|
|
674
|
+
}
|
|
675
|
+
// Validated here, not lazily inside checks() — the controller only calls checks() after
|
|
676
|
+
// spawning, so a bad argument caught late means a live process to clean up.
|
|
677
|
+
const path = typeof entry["path"] === "string" ? entry["path"] : "/";
|
|
678
|
+
const expectedStatus = entry["expectedStatus"];
|
|
679
|
+
if (expectedStatus !== undefined && typeof expectedStatus !== "number") {
|
|
680
|
+
throw new ToolInputError(`"services.${service}.readiness.expectedStatus" must be a number`);
|
|
681
|
+
}
|
|
682
|
+
const host = typeof entry["host"] === "string" ? entry["host"] : "127.0.0.1";
|
|
683
|
+
const command = entry["command"];
|
|
684
|
+
const commandArgs = entry["args"];
|
|
685
|
+
if (kind === "command") {
|
|
686
|
+
if (typeof command !== "string" || command === "") {
|
|
687
|
+
throw new ToolInputError(`"services.${service}.readiness.command" is required for kind "command"`);
|
|
688
|
+
}
|
|
689
|
+
if (commandArgs !== undefined &&
|
|
690
|
+
(!Array.isArray(commandArgs) || commandArgs.some((a) => typeof a !== "string"))) {
|
|
691
|
+
throw new ToolInputError(`"services.${service}.readiness.args" must be an array of strings`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const checks = (info) => {
|
|
695
|
+
if (kind === "http") {
|
|
696
|
+
return [
|
|
697
|
+
{
|
|
698
|
+
kind: "http",
|
|
699
|
+
url: `http://127.0.0.1:${String(info.port)}${path.startsWith("/") ? path : `/${path}`}`,
|
|
700
|
+
...(typeof expectedStatus === "number" ? { expectedStatus } : {}),
|
|
701
|
+
},
|
|
702
|
+
];
|
|
703
|
+
}
|
|
704
|
+
if (kind === "tcp-port") {
|
|
705
|
+
return [{ kind: "tcp-port", host, port: info.port }];
|
|
706
|
+
}
|
|
707
|
+
return [
|
|
708
|
+
{
|
|
709
|
+
kind: "command",
|
|
710
|
+
command: command,
|
|
711
|
+
...(commandArgs === undefined ? {} : { args: commandArgs }),
|
|
712
|
+
cwd,
|
|
713
|
+
},
|
|
714
|
+
];
|
|
715
|
+
};
|
|
716
|
+
return {
|
|
717
|
+
checks,
|
|
718
|
+
timeoutMs: typeof timeoutMs === "number" ? timeoutMs : DEFAULT_READINESS_TIMEOUT_MS,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
function readScopes(args, declared, fallback) {
|
|
722
|
+
const scopes = {};
|
|
723
|
+
for (const service of declared)
|
|
724
|
+
scopes[service.name] = fallback;
|
|
725
|
+
const raw = args["scopeByService"];
|
|
726
|
+
if (raw === undefined)
|
|
727
|
+
return scopes;
|
|
728
|
+
for (const [name, value] of Object.entries(asRecord(raw, "scopeByService"))) {
|
|
729
|
+
const entry = asRecord(value, `scopeByService.${name}`);
|
|
730
|
+
const repo = entry["repo"];
|
|
731
|
+
if (typeof repo !== "string" || repo === "") {
|
|
732
|
+
throw new ToolInputError(`"scopeByService.${name}.repo" is required and must be a non-empty string`);
|
|
733
|
+
}
|
|
734
|
+
const repoRoot = entry["repoRoot"];
|
|
735
|
+
const cwd = entry["cwd"];
|
|
736
|
+
if (repoRoot !== undefined && typeof repoRoot !== "string") {
|
|
737
|
+
throw new ToolInputError(`"scopeByService.${name}.repoRoot" must be a string`);
|
|
738
|
+
}
|
|
739
|
+
if (cwd !== undefined && typeof cwd !== "string") {
|
|
740
|
+
throw new ToolInputError(`"scopeByService.${name}.cwd" must be a string`);
|
|
741
|
+
}
|
|
742
|
+
scopes[name] = {
|
|
743
|
+
repo,
|
|
744
|
+
...(repoRoot === undefined ? {} : { repoRoot }),
|
|
745
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
return scopes;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* `Execution.state`'s five terminal values (`@descryy/runtime-contracts`:
|
|
752
|
+
* `COMPLETED`, `FAILED_START`, `FAILED`, `TIMED_OUT`, `CANCELLED`), reduced
|
|
753
|
+
* to this reply's own three-way outcome. Was `state === "TIMED_OUT" ?
|
|
754
|
+
* "timed_out" : "ok"` — checked for exactly one non-success terminal state
|
|
755
|
+
* and read every other one, `FAILED_START` included, as success. A post-spawn
|
|
756
|
+
* readiness failure sets `FAILED_START` without throwing (the service never
|
|
757
|
+
* became ready, but nothing raised), so it fell straight through that
|
|
758
|
+
* `: "ok"` default and reported a success-shaped headline over whatever
|
|
759
|
+
* evidence happened to exist — real, but no proof the boot itself worked, and
|
|
760
|
+
* sometimes none of it about this failure at all.
|
|
761
|
+
*
|
|
762
|
+
* Inverted here on purpose: only `COMPLETED` reads as `"ok"`. `TIMED_OUT`
|
|
763
|
+
* keeps its own state (a partial answer, not a failure). Everything else —
|
|
764
|
+
* `FAILED_START`, `FAILED`, `CANCELLED`, and any terminal state added later —
|
|
765
|
+
* reads as `"failed"` by default, so a new failure mode can only ever under-
|
|
766
|
+
* report as `failed` (safe) rather than silently rejoin `"ok"` (the bug).
|
|
767
|
+
*/
|
|
768
|
+
function deriveRunOutcome(executionState) {
|
|
769
|
+
if (executionState === "TIMED_OUT")
|
|
770
|
+
return { state: "timed_out", failed: false, timedOut: true };
|
|
771
|
+
if (executionState === "COMPLETED")
|
|
772
|
+
return { state: "ok", failed: false, timedOut: false };
|
|
773
|
+
return { state: "failed", failed: true, timedOut: false };
|
|
774
|
+
}
|
|
775
|
+
const EMPTY_WRITE = {
|
|
776
|
+
promoted: [],
|
|
777
|
+
created: [],
|
|
778
|
+
confirmed: [],
|
|
779
|
+
refused: [],
|
|
780
|
+
contradictions: [],
|
|
781
|
+
staleR4: [],
|
|
782
|
+
confirmedFactConflict: [],
|
|
783
|
+
unjoinedSplitEvidence: null,
|
|
784
|
+
};
|
|
785
|
+
/** Stated unconditionally on every call, not only when it bites: a caller unaware
|
|
786
|
+
* log-pattern readiness is unreachable will misread a tcp-port check's empty evidence
|
|
787
|
+
* as "the service produced nothing". */
|
|
788
|
+
const STANDING_NOTES = [
|
|
789
|
+
"Readiness here offers only the three mechanisms JSON can state — http, tcp-port and command. " +
|
|
790
|
+
"log-pattern and custom-hook need a function and are unreachable through this tool; a run that " +
|
|
791
|
+
"needs one of those is not degraded here, it is unsupported here.",
|
|
792
|
+
"Edges are written only where an observation named both endpoints itself: a captured call-site " +
|
|
793
|
+
"stack resolving to a function, against an endpoint the same observation named. Every other " +
|
|
794
|
+
"correlated evidence item resolves a node and produces no edge, which is a gap in what this run " +
|
|
795
|
+
"could prove rather than evidence that no such edge exists.",
|
|
796
|
+
"That combination — HTTP evidence carrying a call-site stack — is produced here by the outbound-fetch " +
|
|
797
|
+
"instrumentation in @descryy/runtime-external-service-observation, which this server installs into a " +
|
|
798
|
+
"spawned service before any application code runs. So an observed outbound call CAN write this edge, " +
|
|
799
|
+
"and a run that makes none writes none: the backend collectors attach a stack to log and error lines " +
|
|
800
|
+
"(resolving a function) and none to inbound HTTP traffic (resolving an endpoint), so a service that " +
|
|
801
|
+
"never calls out resolves both kinds of node and still writes nothing. A zero here means this run " +
|
|
802
|
+
"observed no outbound call it could attribute, not that no such call exists in your code.",
|
|
803
|
+
"One thing this still cannot witness. An ATTACHED service is not launched by Descry, so the client " +
|
|
804
|
+
"instrumentation this outbound-fetch edge needs cannot be installed into it, and its outbound calls " +
|
|
805
|
+
"carry no call-site stack — an attached run is never reported as instrumented the way a spawned one " +
|
|
806
|
+
"is, whatever else it observes. @descryy/runtime-browser IS in this server's closure: declare " +
|
|
807
|
+
"\"journey\" on this call to drive a browser inside this run's own readiness window, so browser and " +
|
|
808
|
+
"backend evidence land under one executionId (RG-4). The separate browser_* tools are a different " +
|
|
809
|
+
"lifetime — they require the application to already be running when the session starts, which this " +
|
|
810
|
+
"run's own boot does not change.",
|
|
811
|
+
"No denial is ever recorded. A run establishes that a call happened; it cannot establish that one " +
|
|
812
|
+
"did not, because it exercises only the paths it took. Nothing here demotes an edge.",
|
|
813
|
+
];
|
|
814
|
+
/** A-F5: honest degradation about real isolation, said on every reply rather than left for
|
|
815
|
+
* the caller to discover. applySandbox enforces resourceLimits/filesystemPolicy/networkPolicy
|
|
816
|
+
* for real on Linux when declared; never applied to an attached service (Descry didn't spawn it). */
|
|
817
|
+
function sandboxDisclosure(options) {
|
|
818
|
+
const { resourceLimits, filesystemPolicy, networkPolicy, sandboxBackend } = options;
|
|
819
|
+
// True regardless of policy: spawnProcess merges the full ambient env into every spawned
|
|
820
|
+
// process and bwrap doesn't clear it — out of this repo's reach (DEC-NEXT-runtime-sandbox-residual-gaps).
|
|
821
|
+
const envCaveat = "This is unaffected by any policy above: the spawned process still receives this operator's full " +
|
|
822
|
+
"environment, secrets included — the merge happens in @descryy/runtime-controller's spawnProcess and " +
|
|
823
|
+
"cannot be narrowed from this server.";
|
|
824
|
+
if (resourceLimits === undefined && filesystemPolicy === undefined && networkPolicy === undefined) {
|
|
825
|
+
return ("No resourceLimits, filesystemPolicy or networkPolicy was declared for this run. Every spawned " +
|
|
826
|
+
"service therefore ran with no OS-enforced isolation: it can read and write anything this operator's " +
|
|
827
|
+
"account can, use as much memory/CPU/process count as the host allows, and reach any network this " +
|
|
828
|
+
"operator's account can reach, and it received this operator's full environment. Attached services " +
|
|
829
|
+
"are never sandboxed regardless — Descry did not spawn them. Declare resourceLimits/filesystemPolicy/" +
|
|
830
|
+
"networkPolicy to change the filesystem/network/resource part of this for a spawned service.");
|
|
831
|
+
}
|
|
832
|
+
const applied = [];
|
|
833
|
+
if (resourceLimits !== undefined)
|
|
834
|
+
applied.push("resourceLimits");
|
|
835
|
+
if (filesystemPolicy !== undefined)
|
|
836
|
+
applied.push(`filesystemPolicy (allowedRoots: ${filesystemPolicy.allowedRoots.join(", ") || "none beyond cwd"})`);
|
|
837
|
+
if (networkPolicy !== undefined)
|
|
838
|
+
applied.push(`networkPolicy (${networkPolicy.mode}: ${networkPolicy.hosts.join(", ") || "none"})`);
|
|
839
|
+
const backend = sandboxBackend ?? "bwrap";
|
|
840
|
+
return (`Declared for this run, applied to every spawned (never attached) service via the "${backend}" ` +
|
|
841
|
+
`backend: ${applied.join(", ")}. If any of these could not actually be enforced (wrong platform, an ` +
|
|
842
|
+
"unsupported networkPolicy shape, bwrap/Docker unavailable), the run refused to start rather than " +
|
|
843
|
+
`running unconstrained under a policy nobody enforced — see the headline if this reply is a refusal. ${envCaveat}`);
|
|
844
|
+
}
|
|
845
|
+
/** Said on every attaching run. Measured: a redirected process's stdout is block-buffered,
|
|
846
|
+
* not line-buffered — 3 lines written over 0.6s were still absent from the file 3s later.
|
|
847
|
+
* Nothing in Descry can see those bytes; a boundary to state, not a gap to close. */
|
|
848
|
+
const ATTACH_BUFFERING_NOTE = "Attaching reads a file the target writes; it can only see what the target has already flushed " +
|
|
849
|
+
"there. A process whose output is redirected to a file is usually block-buffered rather than " +
|
|
850
|
+
"line-buffered — its own runtime holds whole lines in a userspace buffer, invisible from outside, " +
|
|
851
|
+
"until the buffer fills or the process flushes. Output produced during this window may therefore " +
|
|
852
|
+
"arrive in the file after the window closed and be absent here, which is a fact about the " +
|
|
853
|
+
"target's buffering and not evidence that it did nothing. Run the target with its output " +
|
|
854
|
+
"unbuffered or line-buffered if the timing matters.";
|
|
855
|
+
/**
|
|
856
|
+
* RT-024, restated where a caller can actually see it.
|
|
857
|
+
*
|
|
858
|
+
* `attachToRunningProcess` (descry-runtime) already discloses this in its own
|
|
859
|
+
* source comment — `isProcessAlive` confirms *a* process with this pid
|
|
860
|
+
* exists, not that it is the one that wrote `logFilePath`, because pids are
|
|
861
|
+
* reused by the OS and nothing in a bare pid or path proves provenance. That
|
|
862
|
+
* comment reaches nobody calling this tool. A real run hit exactly the gap it
|
|
863
|
+
* describes: an unrelated orphaned process from an earlier failed boot wrote
|
|
864
|
+
* a crash trace into the same log path a live attach target used, and it was
|
|
865
|
+
* attributed to the live run with no indication anything could be wrong.
|
|
866
|
+
*/
|
|
867
|
+
const ATTACH_IDENTITY_NOTE = "Attaching verifies the declared pid is alive and reads the declared log file; it does not verify " +
|
|
868
|
+
"the lines in that file actually came from that pid. A pid can be reused by the OS, and nothing in " +
|
|
869
|
+
"a bare pid or file path proves which process is writing to it — an unrelated process writing to " +
|
|
870
|
+
"the same path would be attributed to this run with no way to tell the two apart from here.";
|
|
871
|
+
/** Every argument this tool takes (readArguments, below), extracted so describeAction can
|
|
872
|
+
* call it too — catches a bad argument before minting a token for a run that could never
|
|
873
|
+
* happen (DEC-387). Touches nothing; exported for its own test. */
|
|
874
|
+
/** A-F5: ResourceLimits, straight through to ExecutionConfiguration. applyResourceLimits
|
|
875
|
+
* already refuses rather than silently running unconstrained; this only reads the shape. */
|
|
876
|
+
function readResourceLimits(args) {
|
|
877
|
+
const raw = args["resourceLimits"];
|
|
878
|
+
if (raw === undefined || raw === null)
|
|
879
|
+
return undefined;
|
|
880
|
+
const record = asRecord(raw, "resourceLimits");
|
|
881
|
+
const maxMemoryBytes = optionalInteger(record, "maxMemoryBytes", 1);
|
|
882
|
+
const maxCpuSeconds = optionalInteger(record, "maxCpuSeconds", 1);
|
|
883
|
+
const maxProcesses = optionalInteger(record, "maxProcesses", 1);
|
|
884
|
+
return {
|
|
885
|
+
...(maxMemoryBytes === undefined ? {} : { maxMemoryBytes }),
|
|
886
|
+
...(maxCpuSeconds === undefined ? {} : { maxCpuSeconds }),
|
|
887
|
+
...(maxProcesses === undefined ? {} : { maxProcesses }),
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
/** A-F5: FilesystemPolicy. allowedRoots is required whenever filesystemPolicy is present —
|
|
891
|
+
* an empty-roots policy silently means "cwd only", and that must be stated, not defaulted into. */
|
|
892
|
+
function readFilesystemPolicy(args) {
|
|
893
|
+
const raw = args["filesystemPolicy"];
|
|
894
|
+
if (raw === undefined || raw === null)
|
|
895
|
+
return undefined;
|
|
896
|
+
const record = asRecord(raw, "filesystemPolicy");
|
|
897
|
+
const allowedRoots = record["allowedRoots"];
|
|
898
|
+
if (!Array.isArray(allowedRoots) || allowedRoots.some((r) => typeof r !== "string")) {
|
|
899
|
+
throw new ToolInputError('"filesystemPolicy.allowedRoots" must be an array of strings');
|
|
900
|
+
}
|
|
901
|
+
return { allowedRoots: allowedRoots };
|
|
902
|
+
}
|
|
903
|
+
/** A-F5: NetworkPolicy. Only `{mode:"allow", hosts:[]}` (full denial) is actually enforced
|
|
904
|
+
* by applySandbox today; any other shape is accepted here and refused downstream by the
|
|
905
|
+
* mechanism itself (execution.validationError), never pre-judged here (rule 1). */
|
|
906
|
+
function readNetworkPolicy(args) {
|
|
907
|
+
const raw = args["networkPolicy"];
|
|
908
|
+
if (raw === undefined || raw === null)
|
|
909
|
+
return undefined;
|
|
910
|
+
const record = asRecord(raw, "networkPolicy");
|
|
911
|
+
const mode = optionalEnum(record, "mode", ["allow", "deny"]);
|
|
912
|
+
if (mode === undefined)
|
|
913
|
+
throw new ToolInputError('"networkPolicy.mode" is required');
|
|
914
|
+
const hosts = record["hosts"];
|
|
915
|
+
if (!Array.isArray(hosts) || hosts.some((h) => typeof h !== "string")) {
|
|
916
|
+
throw new ToolInputError('"networkPolicy.hosts" must be an array of strings');
|
|
917
|
+
}
|
|
918
|
+
return { mode, hosts: hosts };
|
|
919
|
+
}
|
|
920
|
+
function readSandboxBackend(args) {
|
|
921
|
+
return optionalEnum(args, "sandboxBackend", ["bwrap", "container"]);
|
|
922
|
+
}
|
|
923
|
+
function readJourney(args) {
|
|
924
|
+
const raw = args["journey"];
|
|
925
|
+
if (raw === undefined || raw === null)
|
|
926
|
+
return undefined;
|
|
927
|
+
const record = asRecord(raw, "journey");
|
|
928
|
+
const service = optionalString(record, "service");
|
|
929
|
+
const scenarioName = optionalString(record, "scenario");
|
|
930
|
+
const startUrl = optionalString(record, "startUrl");
|
|
931
|
+
if ((scenarioName === undefined) === (startUrl === undefined)) {
|
|
932
|
+
throw new ToolInputError('journey must declare exactly one of "scenario" or "startUrl" — ' +
|
|
933
|
+
(scenarioName === undefined ? "it declares neither" : "it declares both"));
|
|
934
|
+
}
|
|
935
|
+
const recordVideo = optionalBoolean(record, "recordVideo");
|
|
936
|
+
const headless = optionalBoolean(record, "headless");
|
|
937
|
+
const settleForMs = optionalInteger(record, "settleForMs", 0);
|
|
938
|
+
return { service, scenarioName, startUrl, recordVideo, headless, settleForMs };
|
|
939
|
+
}
|
|
940
|
+
function readDatabaseChannel(args) {
|
|
941
|
+
const raw = args["database"];
|
|
942
|
+
if (raw === undefined || raw === null)
|
|
943
|
+
return undefined;
|
|
944
|
+
const record = asRecord(raw, "database");
|
|
945
|
+
const statementLogRaw = record["statementLog"];
|
|
946
|
+
const statementLogRecord = asRecord(statementLogRaw, "database.statementLog");
|
|
947
|
+
const kind = statementLogRecord["kind"];
|
|
948
|
+
const service = optionalString(record, "service");
|
|
949
|
+
if (kind === "file") {
|
|
950
|
+
const path = statementLogRecord["path"];
|
|
951
|
+
if (typeof path !== "string" || path === "") {
|
|
952
|
+
throw new ToolInputError('"database.statementLog.path" is required and must be a non-empty string for kind "file"');
|
|
953
|
+
}
|
|
954
|
+
// A-F6: same narrower, name-based check `attach.logFilePath` uses — a real statement log
|
|
955
|
+
// lives anywhere and can't be root-confined.
|
|
956
|
+
const sensitivity = checkNotSensitivePath(path, ALLOW_SENSITIVE_LOG_PATH_ENV_VAR);
|
|
957
|
+
if (sensitivity.sensitive)
|
|
958
|
+
throw new ToolInputError(`"database.statementLog.path": ${sensitivity.reason}`);
|
|
959
|
+
return { statementLog: { kind: "file", path }, service };
|
|
960
|
+
}
|
|
961
|
+
if (kind === "dockerContainer") {
|
|
962
|
+
const container = statementLogRecord["container"];
|
|
963
|
+
if (typeof container !== "string" || container === "") {
|
|
964
|
+
throw new ToolInputError('"database.statementLog.container" is required and must be a non-empty string for kind "dockerContainer"');
|
|
965
|
+
}
|
|
966
|
+
return { statementLog: { kind: "dockerContainer", container }, service };
|
|
967
|
+
}
|
|
968
|
+
throw new ToolInputError('"database.statementLog.kind" must be "file" or "dockerContainer"');
|
|
969
|
+
}
|
|
970
|
+
export function readArguments(args, repoPath, savedStart) {
|
|
971
|
+
const profile = readProfile(args);
|
|
972
|
+
const adapterSpec = readAdapterSpec(args);
|
|
973
|
+
const { declared, usedSaved: usedSavedServices, missingSavedEnv } = readServices(args, repoPath, savedStart);
|
|
974
|
+
const observeForMs = optionalInteger(args, "observeForMs", 1) ?? DEFAULT_OBSERVE_MS;
|
|
975
|
+
const timeoutMs = optionalInteger(args, "timeoutMs", 1) ?? DEFAULT_TIMEOUT_MS;
|
|
976
|
+
const fidelityRaw = optionalInteger(args, "fidelityLevel", 1) ?? 2;
|
|
977
|
+
if (fidelityRaw > 4)
|
|
978
|
+
throw new ToolInputError('"fidelityLevel" must be 1, 2, 3 or 4');
|
|
979
|
+
const environmentTier = optionalString(args, "environmentTier") ?? "tier-2-container";
|
|
980
|
+
const evidenceArg = optionalString(args, "evidencePath");
|
|
981
|
+
const evidenceCandidate = evidenceArg === undefined
|
|
982
|
+
? join(repoPath, DEFAULT_EVIDENCE_RELATIVE_PATH)
|
|
983
|
+
: isAbsolute(evidenceArg)
|
|
984
|
+
? evidenceArg
|
|
985
|
+
: join(repoPath, evidenceArg);
|
|
986
|
+
// A-F6: join() doesn't stop ".." escaping repoPath — confined by default (path-confinement.ts).
|
|
987
|
+
const confinement = checkPathConfinement({
|
|
988
|
+
candidate: evidenceCandidate,
|
|
989
|
+
allowedRoots: [repoPath],
|
|
990
|
+
envVar: ALLOW_EVIDENCE_PATH_OUTSIDE_REPO_ENV_VAR,
|
|
991
|
+
what: "evidencePath",
|
|
992
|
+
});
|
|
993
|
+
if (!confinement.allowed)
|
|
994
|
+
throw new ToolInputError(confinement.reason);
|
|
995
|
+
const evidencePath = confinement.resolved;
|
|
996
|
+
const resourceLimits = readResourceLimits(args);
|
|
997
|
+
const filesystemPolicy = readFilesystemPolicy(args);
|
|
998
|
+
const networkPolicy = readNetworkPolicy(args);
|
|
999
|
+
const sandboxBackend = readSandboxBackend(args);
|
|
1000
|
+
const journey = readJourney(args);
|
|
1001
|
+
const database = readDatabaseChannel(args);
|
|
1002
|
+
return {
|
|
1003
|
+
profile,
|
|
1004
|
+
adapterSpec,
|
|
1005
|
+
declared,
|
|
1006
|
+
usedSavedServices,
|
|
1007
|
+
missingSavedEnv,
|
|
1008
|
+
observeForMs,
|
|
1009
|
+
timeoutMs,
|
|
1010
|
+
fidelityRaw,
|
|
1011
|
+
environmentTier,
|
|
1012
|
+
evidencePath,
|
|
1013
|
+
resourceLimits,
|
|
1014
|
+
filesystemPolicy,
|
|
1015
|
+
networkPolicy,
|
|
1016
|
+
sandboxBackend,
|
|
1017
|
+
journey,
|
|
1018
|
+
database,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* RG-5(a) — every service this tool spawns gets whatever database-observation
|
|
1023
|
+
* launch its own adapter declares, injected automatically with no caller
|
|
1024
|
+
* declaration needed. Reads `RuntimeAdapter.databaseObservationLaunch()`
|
|
1025
|
+
* (`@descryy/runtime-backend-observation`) — the same language-neutral seam
|
|
1026
|
+
* `outboundHttpLaunch()` already established for outbound-fetch
|
|
1027
|
+
* instrumentation — rather than comparing `adapter.language` against a name:
|
|
1028
|
+
* this file sits above the Canonical IR, where deciding behaviour from which
|
|
1029
|
+
* language an adapter is for is the exact leak architecture principle 7
|
|
1030
|
+
* forbids (enforced here by `eslint-plugin-descry-boundary`'s
|
|
1031
|
+
* `no-language-vocabulary`, which is what caught the first draft of this
|
|
1032
|
+
* function). The per-language answer — Node gets `node:sqlite`'s preload,
|
|
1033
|
+
* most runtimes get nothing yet — lives in each adapter package instead.
|
|
1034
|
+
*
|
|
1035
|
+
* Attach-mode services (no `command`) are untouched — Descry did not spawn
|
|
1036
|
+
* them, so there is no command line to inject into.
|
|
1037
|
+
*/
|
|
1038
|
+
function withDatabaseObservationPreload(service, adapter) {
|
|
1039
|
+
if (service.command === undefined)
|
|
1040
|
+
return service;
|
|
1041
|
+
const launch = adapter.databaseObservationLaunch?.();
|
|
1042
|
+
if (launch === undefined || launch === null)
|
|
1043
|
+
return service;
|
|
1044
|
+
return {
|
|
1045
|
+
...service,
|
|
1046
|
+
...(launch.interpreterArgs === undefined ? {} : { interpreterArgs: [...(service.interpreterArgs ?? []), ...launch.interpreterArgs] }),
|
|
1047
|
+
...(launch.env === undefined ? {} : { env: { ...service.env, ...launch.env } }),
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* RG-5(a) read-back half — desktop's `observeDatabaseQueries`, same shape:
|
|
1052
|
+
* replays each service's own already-captured output (the preload's
|
|
1053
|
+
* `DESCRY_DB_QUERY` marker lines) through `createDatabaseQueryCollector` and
|
|
1054
|
+
* writes real `DATABASE_QUERY` evidence into this run's store. Returns how
|
|
1055
|
+
* many records it wrote, so the caller can fold it into one disclosed count
|
|
1056
|
+
* alongside the Postgres channel's.
|
|
1057
|
+
*/
|
|
1058
|
+
async function observeSqliteQueries(store, executionId, configuration, execution) {
|
|
1059
|
+
const serviceOutput = capturedServiceOutput(execution);
|
|
1060
|
+
if (serviceOutput === null)
|
|
1061
|
+
return 0;
|
|
1062
|
+
let written = 0;
|
|
1063
|
+
await Promise.all(Object.entries(serviceOutput).map(async ([serviceName, output]) => {
|
|
1064
|
+
const source = {
|
|
1065
|
+
processId: `${executionId}:${serviceName}`,
|
|
1066
|
+
serviceName,
|
|
1067
|
+
lines: (async function* () {
|
|
1068
|
+
for (const text of output.split("\n")) {
|
|
1069
|
+
if (text.length === 0)
|
|
1070
|
+
continue;
|
|
1071
|
+
yield { text, stream: "combined", observedAt: new Date().toISOString() };
|
|
1072
|
+
}
|
|
1073
|
+
})(),
|
|
1074
|
+
};
|
|
1075
|
+
const context = {
|
|
1076
|
+
executionId,
|
|
1077
|
+
configuration,
|
|
1078
|
+
emit: (evidenceInput) => {
|
|
1079
|
+
store.write({ ...evidenceInput, executionId });
|
|
1080
|
+
written += 1;
|
|
1081
|
+
},
|
|
1082
|
+
};
|
|
1083
|
+
const collector = createDatabaseQueryCollector({ source, service: serviceName });
|
|
1084
|
+
await collector.start(context);
|
|
1085
|
+
await collector.stop();
|
|
1086
|
+
}));
|
|
1087
|
+
return written;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* RG-5(c) — the Postgres statement-log channel's own start/stop pair, same
|
|
1091
|
+
* design as the desktop's `beginPostgresChannel`: started before
|
|
1092
|
+
* `runInstrumentedExecution` (the server keeps running independently of
|
|
1093
|
+
* whatever this tool spawns or tears down, so the only way to capture
|
|
1094
|
+
* queries issued *during* the observation window is to already be tailing
|
|
1095
|
+
* when they happen), buffered until a real `executionId` exists to attribute
|
|
1096
|
+
* it to, and idempotent to `stop()` because the caller's own `finally` may
|
|
1097
|
+
* call it a second time after an already-successful stop.
|
|
1098
|
+
*/
|
|
1099
|
+
function beginPostgresChannel(database, processId) {
|
|
1100
|
+
if (database === undefined)
|
|
1101
|
+
return { kind: "not-declared" };
|
|
1102
|
+
const captured = [];
|
|
1103
|
+
const controller = new AbortController();
|
|
1104
|
+
const serviceName = database.service ?? "postgres";
|
|
1105
|
+
const source = createPostgresLogSource(database.statementLog, processId, serviceName, { signal: controller.signal });
|
|
1106
|
+
const collector = createPostgresQueryCollector({ source, service: serviceName });
|
|
1107
|
+
const context = {
|
|
1108
|
+
executionId: processId,
|
|
1109
|
+
configuration: { environmentTier: "tier-2-container", fidelityLevel: 2, timeoutMs: 0, services: {} },
|
|
1110
|
+
emit: (evidence) => {
|
|
1111
|
+
captured.push(evidence);
|
|
1112
|
+
},
|
|
1113
|
+
};
|
|
1114
|
+
const started = collector.start(context);
|
|
1115
|
+
let stopped;
|
|
1116
|
+
return {
|
|
1117
|
+
kind: "observing",
|
|
1118
|
+
stop: () => {
|
|
1119
|
+
if (stopped === undefined) {
|
|
1120
|
+
stopped = (async () => {
|
|
1121
|
+
await started;
|
|
1122
|
+
controller.abort();
|
|
1123
|
+
await collector.stop();
|
|
1124
|
+
return captured;
|
|
1125
|
+
})();
|
|
1126
|
+
}
|
|
1127
|
+
return stopped;
|
|
1128
|
+
},
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
/** Exported so `observe.ts` can dispatch the "runtime" verb to this exact body — the merge
|
|
1132
|
+
* reuses this function rather than reimplementing it (mcp-surface-consolidation.md). */
|
|
1133
|
+
export async function run(args, ctx) {
|
|
1134
|
+
const session = ctx.session;
|
|
1135
|
+
const { profile, adapterSpec, declared, usedSavedServices, missingSavedEnv, observeForMs, timeoutMs, fidelityRaw, environmentTier, evidencePath, resourceLimits, filesystemPolicy, networkPolicy, sandboxBackend, journey, database, } = readArguments(args, session.repoPath, session.config.savedStart);
|
|
1136
|
+
const notes = [...STANDING_NOTES, sandboxDisclosure({ resourceLimits, filesystemPolicy, networkPolicy, sandboxBackend })];
|
|
1137
|
+
// RG-5(c). Stated on every call, success or refusal, same reasoning as the
|
|
1138
|
+
// sandbox disclosure above: a caller unaware the Postgres channel needs an
|
|
1139
|
+
// explicit statement-log source would misread a "channel absent" run as
|
|
1140
|
+
// "this application makes no database calls."
|
|
1141
|
+
notes.push(database === undefined
|
|
1142
|
+
? "database channel: not observed — no statement log configured. Declare \"database.statementLog\" " +
|
|
1143
|
+
"(a file path, or a Docker container's logs) to capture real Postgres queries issued during this " +
|
|
1144
|
+
"run. Node services using node:sqlite are observed automatically, with no declaration needed."
|
|
1145
|
+
: `database channel: tailing the Postgres statement log via ${database.statementLog.kind === "file" ? `file "${database.statementLog.path}"` : `docker container "${database.statementLog.container}"`}.`);
|
|
1146
|
+
// Added on refusal paths too — telling the caller only on success means telling them
|
|
1147
|
+
// after the run whose result it would have explained (same reasoning as the line above).
|
|
1148
|
+
if (usedSavedServices) {
|
|
1149
|
+
const savedAt = session.config.savedStart?.savedAt ?? "an earlier run";
|
|
1150
|
+
notes.push(`No "services" was supplied on this call, so it reused the start settings a prior successful run ` +
|
|
1151
|
+
`saved (${savedAt}): ${describeSavedCommands(declared)}. Declare "services" explicitly to override ` +
|
|
1152
|
+
"them — only another run that reaches readiness replaces what is saved.");
|
|
1153
|
+
if (missingSavedEnv.length > 0) {
|
|
1154
|
+
notes.push(`These environment variables were declared on the run that saved these settings, but nothing is ` +
|
|
1155
|
+
`set under their names in this process's environment now, so they were not passed this time: ` +
|
|
1156
|
+
`${missingSavedEnv.join(", ")}. Only variable names are ever saved, never their values.`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
if (declared.some((service) => service.attached)) {
|
|
1160
|
+
notes.push(ATTACH_BUFFERING_NOTE, ATTACH_IDENTITY_NOTE);
|
|
1161
|
+
}
|
|
1162
|
+
const base = session.provider().baseStamp();
|
|
1163
|
+
const refuse = (headline, data = {}, extraNotes = []) => answer({
|
|
1164
|
+
headline,
|
|
1165
|
+
state: "refused",
|
|
1166
|
+
nameLevel: true,
|
|
1167
|
+
// Nothing ran, so nothing was resolved — never leak the tier a successful path would reach.
|
|
1168
|
+
resolutionFloor: 0,
|
|
1169
|
+
commitSha: base.commitSha,
|
|
1170
|
+
graphBuiltAt: base.graphBuiltAt,
|
|
1171
|
+
irSchemaVersion: base.irSchemaVersion,
|
|
1172
|
+
commitSpread: base.commitSpread,
|
|
1173
|
+
repoCount: base.repoCount,
|
|
1174
|
+
notes: [...notes, ...extraNotes],
|
|
1175
|
+
data: {
|
|
1176
|
+
executionId: null,
|
|
1177
|
+
executionState: null,
|
|
1178
|
+
adapterLanguage: null,
|
|
1179
|
+
evidencePath,
|
|
1180
|
+
services: [],
|
|
1181
|
+
evidenceByType: {},
|
|
1182
|
+
correlation: null,
|
|
1183
|
+
wrote: EMPTY_WRITE,
|
|
1184
|
+
journey: null,
|
|
1185
|
+
database: null,
|
|
1186
|
+
...data,
|
|
1187
|
+
},
|
|
1188
|
+
});
|
|
1189
|
+
// Spawning is a write against the target; attaching is not (Descry never executes code in,
|
|
1190
|
+
// signals, or limits a process it didn't spawn). The profile's declared level decides, never its name.
|
|
1191
|
+
const spawns = declared.some((service) => !service.attached);
|
|
1192
|
+
const action = { write: spawns, destructive: false };
|
|
1193
|
+
const decision = evaluateAction(profile, action);
|
|
1194
|
+
if (decision !== "allow") {
|
|
1195
|
+
return refuse(`Profile "${profile.name}" declares safetyLevel "${profile.safetyLevel}", which does not permit ` +
|
|
1196
|
+
`${spawns ? "spawning a service" : "this run"}. Nothing was started and nothing was written. ` +
|
|
1197
|
+
(spawns
|
|
1198
|
+
? "A run in which every service uses \"attach\" spawns nothing and is permitted under readOnly."
|
|
1199
|
+
: ""));
|
|
1200
|
+
}
|
|
1201
|
+
if (!spawns) {
|
|
1202
|
+
notes.push("Every declared service is attached to rather than spawned, so this run started nothing and " +
|
|
1203
|
+
"applied no resource, filesystem or network policy to any process — Descry does not constrain " +
|
|
1204
|
+
"a process it did not spawn.");
|
|
1205
|
+
}
|
|
1206
|
+
// Correlation resolves evidence against this graph; an empty graph resolving nothing must
|
|
1207
|
+
// not be reported as a clean run with no findings — the "empty means broken" collapse.
|
|
1208
|
+
const driver = session.store().driver;
|
|
1209
|
+
const stored = counts(driver);
|
|
1210
|
+
if (stored.nodes === 0) {
|
|
1211
|
+
return refuse("This repository has no graph yet, so there is nothing for a run's evidence to be resolved " +
|
|
1212
|
+
"against. Run analyze first — an observation that cannot name a node cannot become a fact.");
|
|
1213
|
+
}
|
|
1214
|
+
// --- the adapter ----------------------------------------------------------
|
|
1215
|
+
ctx.progress(`Loading runtime adapter ${adapterSpec.module}`);
|
|
1216
|
+
let adapter;
|
|
1217
|
+
try {
|
|
1218
|
+
adapter = await loadRuntimeAdapter(adapterSpec, session.repoPath);
|
|
1219
|
+
}
|
|
1220
|
+
catch (error) {
|
|
1221
|
+
if (error instanceof RuntimeAdapterLoadError) {
|
|
1222
|
+
notes.push("This server depends on none of descry-runtime's language adapters by design, so the adapter " +
|
|
1223
|
+
"must be installed alongside it and named in the call. Nothing was started.");
|
|
1224
|
+
return refuse(error.message);
|
|
1225
|
+
}
|
|
1226
|
+
throw error;
|
|
1227
|
+
}
|
|
1228
|
+
notes.push(`Observed with the runtime adapter for "${adapter.language}", loaded from ${adapterSpec.module}.`);
|
|
1229
|
+
const root = await session.root();
|
|
1230
|
+
const scopes = readScopes(args, declared, {
|
|
1231
|
+
repo: root.repo,
|
|
1232
|
+
repoRoot: root.absolutePath,
|
|
1233
|
+
});
|
|
1234
|
+
const services = {};
|
|
1235
|
+
const readiness = {};
|
|
1236
|
+
for (const service of declared) {
|
|
1237
|
+
services[service.name] = withDatabaseObservationPreload(service.configuration, adapter);
|
|
1238
|
+
readiness[service.name] = service.readiness;
|
|
1239
|
+
}
|
|
1240
|
+
const configuration = {
|
|
1241
|
+
environmentTier: environmentTier,
|
|
1242
|
+
fidelityLevel: fidelityRaw,
|
|
1243
|
+
timeoutMs,
|
|
1244
|
+
services,
|
|
1245
|
+
// A-F5: wired through to the controller's real bwrap/container isolation
|
|
1246
|
+
// — see sandboxDisclosure() above for what this reply says about it.
|
|
1247
|
+
...(resourceLimits === undefined ? {} : { resourceLimits }),
|
|
1248
|
+
...(filesystemPolicy === undefined ? {} : { filesystemPolicy }),
|
|
1249
|
+
...(networkPolicy === undefined ? {} : { networkPolicy }),
|
|
1250
|
+
...(sandboxBackend === undefined ? {} : { sandboxBackend }),
|
|
1251
|
+
};
|
|
1252
|
+
// Before anything is spawned — a caller who's already gone would leave a real process
|
|
1253
|
+
// running with nothing left to ever stop it.
|
|
1254
|
+
if (ctx.signal.aborted) {
|
|
1255
|
+
return refuse(cancellationHeadline("no service was started") + " " + cancellationNotes("Nothing was spawned.")[0]);
|
|
1256
|
+
}
|
|
1257
|
+
// RG-4/RG-6 — resolve the declared journey into a drive-ready plan before anything is
|
|
1258
|
+
// spawned. A scenario that cannot be read or projected (e.g. an unresolvable secret
|
|
1259
|
+
// reference) degrades this run to backend-only with a named disclosure; it never fails
|
|
1260
|
+
// the whole call, because the journey is additive to what observe_runtime already does.
|
|
1261
|
+
let journeyPlan = null;
|
|
1262
|
+
if (journey !== undefined) {
|
|
1263
|
+
if (journey.scenarioName !== undefined) {
|
|
1264
|
+
const stored = await readScenario(session.repoPath, journey.scenarioName);
|
|
1265
|
+
if (!stored.valid) {
|
|
1266
|
+
notes.push(`journey.scenario "${journey.scenarioName}" could not be read, so this run is backend-only: ` +
|
|
1267
|
+
stored.problems.join(" "));
|
|
1268
|
+
}
|
|
1269
|
+
else {
|
|
1270
|
+
const projected = projectScenarioToRunPlan(stored.scenario);
|
|
1271
|
+
if (!projected.ok) {
|
|
1272
|
+
notes.push(`journey.scenario "${journey.scenarioName}" could not be driven, so this run is backend-only: ` +
|
|
1273
|
+
`${projected.reason} (verb "${projected.verb}")`);
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
const MUTATING_KINDS = new Set(["click", "type", "select", "submit"]);
|
|
1277
|
+
const mutates = projected.plan.steps.some((s) => MUTATING_KINDS.has(s.kind));
|
|
1278
|
+
const readOnly = profile.safetyLevel === "readOnly";
|
|
1279
|
+
const steps = mutates && readOnly ? projected.plan.steps.filter((s) => !MUTATING_KINDS.has(s.kind)) : projected.plan.steps;
|
|
1280
|
+
if (mutates && readOnly) {
|
|
1281
|
+
notes.push(`journey.scenario "${journey.scenarioName}" declares steps that would change the application ` +
|
|
1282
|
+
`(click/type/select/submit), and profile "${profile.name}" declares safetyLevel "readOnly" — ` +
|
|
1283
|
+
"those steps were not driven. The scenario's own navigation and any assertions still ran and were observed.");
|
|
1284
|
+
}
|
|
1285
|
+
journeyPlan = {
|
|
1286
|
+
startUrl: projected.plan.startUrl,
|
|
1287
|
+
steps,
|
|
1288
|
+
...(journey.service === undefined ? {} : { service: journey.service }),
|
|
1289
|
+
...(journey.recordVideo === undefined ? {} : { recordVideo: journey.recordVideo }),
|
|
1290
|
+
...(journey.headless === undefined ? {} : { headless: journey.headless }),
|
|
1291
|
+
...(journey.settleForMs === undefined ? {} : { settleForMs: journey.settleForMs }),
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
else if (journey.startUrl !== undefined) {
|
|
1297
|
+
journeyPlan = {
|
|
1298
|
+
startUrl: journey.startUrl,
|
|
1299
|
+
...(journey.service === undefined ? {} : { service: journey.service }),
|
|
1300
|
+
...(journey.recordVideo === undefined ? {} : { recordVideo: journey.recordVideo }),
|
|
1301
|
+
...(journey.headless === undefined ? {} : { headless: journey.headless }),
|
|
1302
|
+
...(journey.settleForMs === undefined ? {} : { settleForMs: journey.settleForMs }),
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
await mkdir(dirname(evidencePath), { recursive: true });
|
|
1307
|
+
const evidenceStore = new EvidenceStore({ path: evidencePath });
|
|
1308
|
+
/** Processes **this run started**, and only those — an attach-mode service is the
|
|
1309
|
+
* developer's own process; killing it on cancellation would destroy something this
|
|
1310
|
+
* call never created. Only the spawn path is cleaned up. */
|
|
1311
|
+
const attached = new Set(declared.filter((service) => service.attached).map((service) => service.name));
|
|
1312
|
+
const spawnedPids = new Set();
|
|
1313
|
+
const abort = whenAborted(ctx.signal);
|
|
1314
|
+
void abort.promise.then(() => {
|
|
1315
|
+
for (const pid of spawnedPids)
|
|
1316
|
+
terminateSpawnedProcess(pid);
|
|
1317
|
+
});
|
|
1318
|
+
// Set inside onReady below, read after the execution returns — the drive is the only
|
|
1319
|
+
// thing that can report what it did, and it runs deep inside the orchestrator's own
|
|
1320
|
+
// callback. `onReady`'s own contract says a throwing callback is NOT caught by the
|
|
1321
|
+
// orchestrator — it would reject the whole execution and discard every piece of backend
|
|
1322
|
+
// evidence already collected — so the drive itself never throws and this is a second belt.
|
|
1323
|
+
let journeyOutcome;
|
|
1324
|
+
// RG-5(c). Started before `runInstrumentedExecution`, not after: a Postgres
|
|
1325
|
+
// statement log is the server's own, and the server keeps running
|
|
1326
|
+
// independently of whatever this run spawns or tears down — the only way
|
|
1327
|
+
// to capture queries issued *during* this run's observation window is to
|
|
1328
|
+
// already be tailing when they happen (same reasoning as the journey drive
|
|
1329
|
+
// being wired through `onReady` rather than run after the fact).
|
|
1330
|
+
const postgresChannel = beginPostgresChannel(database, `postgres:${root.repo}`);
|
|
1331
|
+
try {
|
|
1332
|
+
ctx.progress(`Running ${declared.length} service(s), observing for ${String(observeForMs)}ms`);
|
|
1333
|
+
const execution = await runInstrumentedExecution({
|
|
1334
|
+
execution: {
|
|
1335
|
+
application: root.repo,
|
|
1336
|
+
repository: root.repo,
|
|
1337
|
+
commit: root.commitSha,
|
|
1338
|
+
configuration,
|
|
1339
|
+
},
|
|
1340
|
+
runOptions: {
|
|
1341
|
+
readiness,
|
|
1342
|
+
// The only channel naming a process while still alive — Execution.processes is
|
|
1343
|
+
// complete only once the whole window has already slept, past where a cancel lands.
|
|
1344
|
+
onProcessLifecycleEvent: (event) => {
|
|
1345
|
+
if (event.kind !== "process-started" || attached.has(event.serviceName))
|
|
1346
|
+
return;
|
|
1347
|
+
if (event.handle.pid !== null)
|
|
1348
|
+
spawnedPids.add(event.handle.pid);
|
|
1349
|
+
},
|
|
1350
|
+
},
|
|
1351
|
+
adapter,
|
|
1352
|
+
store: evidenceStore,
|
|
1353
|
+
observeForMs,
|
|
1354
|
+
...(journeyPlan === null ? {} : { extraPreflightChecks: [chromiumLaunchCheck()] }),
|
|
1355
|
+
// RG-4 — the one moment in a run when every service's readiness has already
|
|
1356
|
+
// succeeded and nothing has been torn down yet. Browser evidence therefore lands
|
|
1357
|
+
// in the same store under the same executionId as the backend's — the desktop
|
|
1358
|
+
// composes identically (packages/session/src/pipeline/runtime-execution.ts).
|
|
1359
|
+
...(journeyPlan === null
|
|
1360
|
+
? {}
|
|
1361
|
+
: {
|
|
1362
|
+
onReady: async ({ execution: readyExecution }) => {
|
|
1363
|
+
try {
|
|
1364
|
+
const drive = createRuntimeJourneyDrive(journeyPlan);
|
|
1365
|
+
journeyOutcome = await drive({
|
|
1366
|
+
store: evidenceStore,
|
|
1367
|
+
executionId: readyExecution.executionId,
|
|
1368
|
+
configuration,
|
|
1369
|
+
resolveSourceRoot: createSourceRootResolver(readyExecution.processes, configuration),
|
|
1370
|
+
stackTraceParser: adapter.stackTraceParser,
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
catch (error) {
|
|
1374
|
+
const cause = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
1375
|
+
evidenceStore.write({
|
|
1376
|
+
executionId: readyExecution.executionId,
|
|
1377
|
+
timestamp: new Date().toISOString(),
|
|
1378
|
+
source: "harness",
|
|
1379
|
+
service: journeyPlan?.service ?? null,
|
|
1380
|
+
process: null,
|
|
1381
|
+
eventType: "COLLECTOR_ERROR",
|
|
1382
|
+
payload: { raw: `the journey drive threw instead of degrading: ${cause}`, error: cause, collectorId: "runtime-journey-drive" },
|
|
1383
|
+
traceId: null,
|
|
1384
|
+
requestId: null,
|
|
1385
|
+
correlationId: null,
|
|
1386
|
+
graphNodeId: null,
|
|
1387
|
+
sourceLocation: null,
|
|
1388
|
+
stackTrace: null,
|
|
1389
|
+
confidence: 1,
|
|
1390
|
+
collectorVersion: "descry-mcp-observe-runtime@1",
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
},
|
|
1394
|
+
}),
|
|
1395
|
+
});
|
|
1396
|
+
// The kill above doesn't shorten the window — this is where a cancelled run actually
|
|
1397
|
+
// stops. Nothing is correlated or written: an R4 edge can't be re-derived and checked
|
|
1398
|
+
// later, so minting one from a run nobody watched to the end would be unquestionable.
|
|
1399
|
+
// RG-5(c): the postgres tail is stopped here too (releasing its child process) but its
|
|
1400
|
+
// capture is discarded, same "nothing correlated or written" rule as everything else.
|
|
1401
|
+
if (ctx.signal.aborted) {
|
|
1402
|
+
if (postgresChannel.kind === "observing")
|
|
1403
|
+
await postgresChannel.stop().catch(() => undefined);
|
|
1404
|
+
return refuse(cancellationHeadline("the observed application was stopped"), {
|
|
1405
|
+
executionId: execution.execution.executionId,
|
|
1406
|
+
executionState: execution.execution.state,
|
|
1407
|
+
adapterLanguage: adapter.language,
|
|
1408
|
+
services: describeServices(execution.execution.processes, declared),
|
|
1409
|
+
evidenceByType: tally(execution.evidence),
|
|
1410
|
+
journey: journeyOutcome ?? null,
|
|
1411
|
+
}, cancellationNotes(`Every service this run spawned was killed; ${String(attached.size)} attached service(s) were ` +
|
|
1412
|
+
"left alone, because this call did not start them.", "No evidence was correlated and no edge was written. Evidence already collected is still on disk " +
|
|
1413
|
+
`at ${evidencePath}, so nothing witnessed was thrown away.`));
|
|
1414
|
+
}
|
|
1415
|
+
// RG-5(a)/(c) — stop the postgres tail now that the observation window has closed and
|
|
1416
|
+
// replay the sqlite preload's captured output, attributing both to this run's real
|
|
1417
|
+
// executionId (unknown until now, which is why the postgres collector above buffered
|
|
1418
|
+
// rather than wrote directly). Must happen before correlation, same ordering the desktop
|
|
1419
|
+
// uses, so a database-node resolver can attribute what is already in the store.
|
|
1420
|
+
const executionId = execution.execution.executionId;
|
|
1421
|
+
let databaseRecordCount = 0;
|
|
1422
|
+
if (postgresChannel.kind === "observing") {
|
|
1423
|
+
const capturedPostgres = await postgresChannel.stop();
|
|
1424
|
+
for (const evidence of capturedPostgres) {
|
|
1425
|
+
evidenceStore.write({ ...evidence, executionId });
|
|
1426
|
+
databaseRecordCount += 1;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
databaseRecordCount += await observeSqliteQueries(evidenceStore, executionId, configuration, execution);
|
|
1430
|
+
const databaseObservation = database === undefined
|
|
1431
|
+
? {
|
|
1432
|
+
observed: false,
|
|
1433
|
+
disclosure: "database channel: not observed — no statement log configured",
|
|
1434
|
+
recordCount: databaseRecordCount,
|
|
1435
|
+
}
|
|
1436
|
+
: { observed: true, disclosure: null, recordCount: databaseRecordCount };
|
|
1437
|
+
const observed = describeServices(execution.execution.processes, declared);
|
|
1438
|
+
const evidenceByType = tally(execution.evidence);
|
|
1439
|
+
// RG-4 — `execution.evidence` is `runInstrumentedExecution`'s own returned snapshot and
|
|
1440
|
+
// never includes what the journey drive wrote directly into `evidenceStore` inside
|
|
1441
|
+
// `onReady` (it isn't tracked by the orchestrator's own `emitted` array). Re-read from
|
|
1442
|
+
// the store, scoped to this executionId, only when a journey actually ran — so the
|
|
1443
|
+
// headline/notes below (which read `execution.evidence` directly, unchanged) still pin
|
|
1444
|
+
// exactly what they always have on every backend-only run.
|
|
1445
|
+
// RG-5(c): also re-read when the database channel wrote anything — same reasoning as the
|
|
1446
|
+
// journey case, a different write path `execution.evidence` was already materialised
|
|
1447
|
+
// before this run's own DATABASE_QUERY records ever reached the store.
|
|
1448
|
+
const evidenceIncludingJourney = journeyOutcome === undefined && databaseRecordCount === 0
|
|
1449
|
+
? execution.evidence
|
|
1450
|
+
: [...evidenceStore.getByExecution(execution.execution.executionId)];
|
|
1451
|
+
if (execution.validationError !== null) {
|
|
1452
|
+
// Refused before spawning — a fact about the declaration, not the application;
|
|
1453
|
+
// emphatically not "the service is clean".
|
|
1454
|
+
return refuse(`The execution refused to start: ${execution.validationError}. Nothing was spawned, no ` +
|
|
1455
|
+
"evidence was collected, and no graph edge was written.", {
|
|
1456
|
+
executionState: execution.execution.state,
|
|
1457
|
+
adapterLanguage: adapter.language,
|
|
1458
|
+
database: databaseObservation,
|
|
1459
|
+
services: observed,
|
|
1460
|
+
evidenceByType,
|
|
1461
|
+
}, usedSavedServices
|
|
1462
|
+
? [
|
|
1463
|
+
`These start settings came from a prior successful run — "services" was omitted on this ` +
|
|
1464
|
+
`call — and they no longer work: ${describeSavedCommands(declared)}. Call observe_runtime ` +
|
|
1465
|
+
'again with "services" declared explicitly to replace what is saved; only another run ' +
|
|
1466
|
+
"that reaches readiness updates it.",
|
|
1467
|
+
]
|
|
1468
|
+
: []);
|
|
1469
|
+
}
|
|
1470
|
+
// See deriveRunOutcome for the full rule. Budget-expired case measured pre-clamp: a 1.5s
|
|
1471
|
+
// budget over a 12s window returned at 12.3s with zero evidence (DEC-NEXT-observe-runtime-
|
|
1472
|
+
// timeout-budget-is-not-enforced.md). Reported `timed_out`, not `ok` (§6) — real evidence,
|
|
1473
|
+
// just less of it. A post-spawn readiness failure (FAILED_START) and any other non-success
|
|
1474
|
+
// terminal state are reported `failed`, never `ok`, whatever evidence happens to exist.
|
|
1475
|
+
const outcome = deriveRunOutcome(execution.execution.state);
|
|
1476
|
+
const { failed, timedOut } = outcome;
|
|
1477
|
+
if (timedOut) {
|
|
1478
|
+
notes.push(`This run hit its ${String(timeoutMs)}ms whole-execution budget before the ` +
|
|
1479
|
+
`${String(observeForMs)}ms observation window closed, so every service was stopped early and ` +
|
|
1480
|
+
"the evidence below is partial — every count is a floor, not a total. Nothing here says the " +
|
|
1481
|
+
"application produced no further output; only that this run stopped listening for it. Raise " +
|
|
1482
|
+
'"timeoutMs" clear of the observation window, or shorten "observeForMs".');
|
|
1483
|
+
}
|
|
1484
|
+
const capturedOutput = capturedServiceOutput(execution);
|
|
1485
|
+
// Only on failure: a healthy boot log is large and would crowd out every
|
|
1486
|
+
// other field, and nobody is asking why a service that worked worked.
|
|
1487
|
+
const services = failed ? describeServices(execution.execution.processes, declared, capturedOutput ?? {}) : observed;
|
|
1488
|
+
if (failed) {
|
|
1489
|
+
const outputNote = startupOutputNote(services, capturedOutput);
|
|
1490
|
+
if (outputNote !== null)
|
|
1491
|
+
notes.push(outputNote);
|
|
1492
|
+
notes.push(`This run's execution ended in state "${execution.execution.state}", not "COMPLETED" — the ` +
|
|
1493
|
+
"application did not finish booting successfully. Any evidence below may be partial, or " +
|
|
1494
|
+
"left over from earlier in this same run, and does not mean the service ever became ready.");
|
|
1495
|
+
if (usedSavedServices) {
|
|
1496
|
+
notes.push(`These start settings came from a prior successful run — "services" was omitted on this call ` +
|
|
1497
|
+
`— and they did not work this time: ${describeSavedCommands(declared)}. Call observe_runtime ` +
|
|
1498
|
+
'again with "services" declared explicitly to replace what is saved.');
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
ctx.progress(`Correlating ${String(execution.evidence.length)} evidence item(s) against the graph`);
|
|
1502
|
+
const pass = correlateExecution({
|
|
1503
|
+
store: evidenceStore,
|
|
1504
|
+
driver,
|
|
1505
|
+
executionId: execution.execution.executionId,
|
|
1506
|
+
scopeByService: scopes,
|
|
1507
|
+
});
|
|
1508
|
+
const correlation = {
|
|
1509
|
+
considered: pass.considered,
|
|
1510
|
+
skipped: pass.skipped,
|
|
1511
|
+
attributed: pass.attributed.length,
|
|
1512
|
+
refused: pass.refusals.length,
|
|
1513
|
+
unscopedServices: pass.unscopedServices,
|
|
1514
|
+
harnessErrors: pass.harnessErrors.map((e) => `${e.detail} (${String(e.occurrences)}×)`),
|
|
1515
|
+
};
|
|
1516
|
+
if (pass.unscopedServices.length > 0) {
|
|
1517
|
+
// Two sentences, not one: a named service with no scope is fixable by the caller; the
|
|
1518
|
+
// "(no service)" sentinel is not — no collector stamps one, so there's no key to supply.
|
|
1519
|
+
const named = pass.unscopedServices.filter((s) => s !== "(no service)");
|
|
1520
|
+
const anonymous = pass.unscopedServices.length - named.length;
|
|
1521
|
+
if (named.length > 0) {
|
|
1522
|
+
notes.push(`Symbol evidence from ${named.join(", ")} was left unresolved: no scope named which ` +
|
|
1523
|
+
"repository those symbols belong to, and resolving them against a repository nobody " +
|
|
1524
|
+
"named would resolve the wrong one's identically-named file. Supply " +
|
|
1525
|
+
'"scopeByService" for those services to have them resolved.');
|
|
1526
|
+
}
|
|
1527
|
+
if (anonymous > 0) {
|
|
1528
|
+
notes.push("Some evidence carried a source location but no service name, so no scope could be " +
|
|
1529
|
+
"looked up for it and its symbols were never resolved. This is not a missing argument: " +
|
|
1530
|
+
"nothing in this server's runtime closure stamps a service name onto collector " +
|
|
1531
|
+
"evidence, so there is no key a caller could supply a scope under. The items are " +
|
|
1532
|
+
"counted as skipped rather than dropped, and what they would have resolved to is " +
|
|
1533
|
+
"unknown rather than absent.");
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
if (pass.harnessErrors.length > 0) {
|
|
1537
|
+
notes.push(`${String(pass.harnessErrors.length)} correlation failure(s) were the machinery breaking rather ` +
|
|
1538
|
+
"than a resolver honestly declining — each is written into the evidence stream as a " +
|
|
1539
|
+
"COLLECTOR_ERROR, and the counts below are correspondingly incomplete.");
|
|
1540
|
+
}
|
|
1541
|
+
// Recorded before the edge write so a failure in one doesn't silently cost the other.
|
|
1542
|
+
// See runtime-incident.ts for why an EXCEPTION alone is not an incident.
|
|
1543
|
+
const incident = runtimeObservedIncident({
|
|
1544
|
+
repo: root.repo,
|
|
1545
|
+
repoRoot: root.absolutePath,
|
|
1546
|
+
runId: execution.execution.executionId,
|
|
1547
|
+
services,
|
|
1548
|
+
exceptionLocations: execution.evidence
|
|
1549
|
+
.filter((e) => e.eventType === "EXCEPTION")
|
|
1550
|
+
.map((e) => ({ file: e.sourceLocation?.file ?? null })),
|
|
1551
|
+
exceptionTexts: execution.evidence
|
|
1552
|
+
.filter((e) => e.eventType === "EXCEPTION")
|
|
1553
|
+
.map((e) => (typeof e.payload === "string" ? e.payload : JSON.stringify(e.payload)))
|
|
1554
|
+
.map((text) => text.split("\n")[0] ?? "")
|
|
1555
|
+
.filter((line) => line !== ""),
|
|
1556
|
+
});
|
|
1557
|
+
if (incident !== null) {
|
|
1558
|
+
ctx.progress("Recording the observed failure as an incident");
|
|
1559
|
+
// `written.records` — the already-deduped list on file, not
|
|
1560
|
+
// `session.config.confirmedIncidents` plus `incident` — the same
|
|
1561
|
+
// resubmission hazard `mark_incident` has (UAT phase 5, F9): a run
|
|
1562
|
+
// observing the identical failure twice must merge, not mint a second
|
|
1563
|
+
// `INCIDENT` node sharing the first's id.
|
|
1564
|
+
const written = await writeConfirmedIncident(session.repoPath, incident);
|
|
1565
|
+
const projected = await createConfirmedIncidentSource({
|
|
1566
|
+
repo: root.repo,
|
|
1567
|
+
incidents: written.records,
|
|
1568
|
+
}).emit({ root });
|
|
1569
|
+
const built = buildGraph([projected], {
|
|
1570
|
+
nodeExists: (id) => session.provider().node(id) !== undefined,
|
|
1571
|
+
});
|
|
1572
|
+
persistGraph(driver, [projected], built);
|
|
1573
|
+
// Same fix as mark_incident's write path: this source's output is a
|
|
1574
|
+
// small, fully-specified list every call, so a deleted incident whose
|
|
1575
|
+
// files went with it is never caught by the shared eviction's file
|
|
1576
|
+
// overlap check.
|
|
1577
|
+
evictStaleConfirmedIncidentNodes(driver, projected.producedBy, new Set(built.nodes.filter((n) => n.producedBy === projected.producedBy).map((n) => n.id)));
|
|
1578
|
+
notes.push(`A service died during this run (${incident.summary}), so it was recorded as an incident ` +
|
|
1579
|
+
`correlated to ${String(incident.files.length)} file(s) this repository owns, and written ` +
|
|
1580
|
+
"durably to .descry/config.json — a run is gone once the process exits. The correlation " +
|
|
1581
|
+
"is every file an exception stack named during the run, which is not a claim about the " +
|
|
1582
|
+
"cause: nothing here knows which exception killed the process, and choosing the last one " +
|
|
1583
|
+
"would be recency standing in for causality.");
|
|
1584
|
+
}
|
|
1585
|
+
ctx.progress("Writing observed edges into the graph");
|
|
1586
|
+
const wrote = writeObservations({
|
|
1587
|
+
driver,
|
|
1588
|
+
pass,
|
|
1589
|
+
evidenceStore,
|
|
1590
|
+
repo: root.repo,
|
|
1591
|
+
repoRoot: root.absolutePath,
|
|
1592
|
+
runId: execution.execution.executionId,
|
|
1593
|
+
commitSha: root.commitSha,
|
|
1594
|
+
});
|
|
1595
|
+
const written = wrote.promoted.length + wrote.created.length;
|
|
1596
|
+
if (written === 0) {
|
|
1597
|
+
notes.push("No edge was written. Either no observation carried a call-site stack that resolved to a " +
|
|
1598
|
+
"function this graph holds, or every one it did carry was already at R4. Both are real " +
|
|
1599
|
+
"outcomes of this run, and neither says the graph's existing edges are wrong.");
|
|
1600
|
+
}
|
|
1601
|
+
if (wrote.unjoinedSplitEvidence !== null)
|
|
1602
|
+
notes.push(wrote.unjoinedSplitEvidence);
|
|
1603
|
+
notes.push(...wrote.confirmedFactConflict);
|
|
1604
|
+
// Build item 2: save only on a real success, whichever source these services came
|
|
1605
|
+
// from (fresh args or the saved settings this same call may have reused) — an
|
|
1606
|
+
// idempotent overwrite either way. Never on `timedOut` or `failed`: neither
|
|
1607
|
+
// establishes that every declared service actually reached readiness.
|
|
1608
|
+
if (outcome.state === "ok") {
|
|
1609
|
+
const toSave = {};
|
|
1610
|
+
for (const service of declared)
|
|
1611
|
+
toSave[service.name] = service.savable;
|
|
1612
|
+
try {
|
|
1613
|
+
const saved = await writeSavedStart(session.repoPath, toSave);
|
|
1614
|
+
// Written to disk above; `reloadConfig()` (session.ts's own sole mutator of
|
|
1615
|
+
// `session.config`) mirrors it into this live session immediately, so a second
|
|
1616
|
+
// call on this same connection sees it with no explicit reload in between.
|
|
1617
|
+
// Without this, `session.config.savedStart` stays whatever it was when this
|
|
1618
|
+
// session was constructed — stale for the rest of the process's life — and this
|
|
1619
|
+
// same write would also show up as spurious drift on this very reply, since
|
|
1620
|
+
// `checkConfigDrift` (server.ts) diffs on-disk config against `session.config`
|
|
1621
|
+
// right after this tool returns.
|
|
1622
|
+
await session.reloadConfig();
|
|
1623
|
+
notes.push(`This run's services were saved to ${saved.path} (auto-action, disclosed per rule 7 — never a ` +
|
|
1624
|
+
"secret value, since environment variables are saved by name only). A later observe_runtime " +
|
|
1625
|
+
'call on this repository may omit "services" and these settings will be reused.');
|
|
1626
|
+
}
|
|
1627
|
+
catch (error) {
|
|
1628
|
+
notes.push(`This run's start settings could not be saved (${error instanceof Error ? error.message : String(error)}); ` +
|
|
1629
|
+
'a later call must still declare "services" explicitly.');
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
const headline = failed
|
|
1633
|
+
? `The run failed to complete: execution ended in state "${execution.execution.state}", not ` +
|
|
1634
|
+
`"COMPLETED". ${String(execution.evidence.length)} evidence item(s) were collected across ` +
|
|
1635
|
+
`${String(declared.length)} service(s), but that does not mean the boot succeeded — see the ` +
|
|
1636
|
+
"notes for what this run could and could not establish."
|
|
1637
|
+
: `Observed ${String(execution.evidence.length)} evidence item(s) across ${String(declared.length)} ` +
|
|
1638
|
+
`service(s); ${String(pass.attributed.length)} resolved to graph nodes; ` +
|
|
1639
|
+
`${String(wrote.promoted.length)} edge(s) promoted to R4 and ${String(wrote.created.length)} minted at R4.`;
|
|
1640
|
+
const evidenceByTypeShaped = journeyOutcome === undefined && databaseRecordCount === 0 ? evidenceByType : tally(evidenceIncludingJourney);
|
|
1641
|
+
const restOfObserveRuntimeData = {
|
|
1642
|
+
executionId: execution.execution.executionId,
|
|
1643
|
+
executionState: execution.execution.state,
|
|
1644
|
+
adapterLanguage: adapter.language,
|
|
1645
|
+
evidencePath,
|
|
1646
|
+
services,
|
|
1647
|
+
evidenceByType: evidenceByTypeShaped,
|
|
1648
|
+
correlation,
|
|
1649
|
+
journey: journeyOutcome ?? null,
|
|
1650
|
+
database: databaseObservation,
|
|
1651
|
+
};
|
|
1652
|
+
return answer({
|
|
1653
|
+
headline,
|
|
1654
|
+
// Not `empty` when nothing was witnessed — `empty` claims the population, and "this run
|
|
1655
|
+
// took no path exercising the code" isn't "this code does nothing". `timed_out` when the
|
|
1656
|
+
// budget bound this run, `failed` when the execution never reached `COMPLETED` at all
|
|
1657
|
+
// (see deriveRunOutcome): `empty`/`ok` both claim the window ran to its end on a run that
|
|
1658
|
+
// actually finished.
|
|
1659
|
+
state: outcome.state,
|
|
1660
|
+
nameLevel: true,
|
|
1661
|
+
// R4 unconditionally: every `wrote` fact was witnessed at runtime (DEC-115, no inference).
|
|
1662
|
+
// A run that wrote nothing reports R0 rather than borrowing the tier a success would reach.
|
|
1663
|
+
resolutionFloor: (written > 0 ? 4 : 0),
|
|
1664
|
+
// G3/G4's E, declared only when evidence actually came back — not gated on `written > 0`:
|
|
1665
|
+
// rule 3 already caps the category via resolutionFloor above, so a run that saw channels
|
|
1666
|
+
// but wrote no edge is `unconfirmed` by that cap, not by pretending it saw nothing.
|
|
1667
|
+
...(evidenceIncludingJourney.length === 0
|
|
1668
|
+
? {}
|
|
1669
|
+
: { runtimeEvidence: { independentSignalTypes: witnessedSignalTypes(evidenceIncludingJourney) } }),
|
|
1670
|
+
commitSha: base.commitSha,
|
|
1671
|
+
graphBuiltAt: base.graphBuiltAt,
|
|
1672
|
+
irSchemaVersion: base.irSchemaVersion,
|
|
1673
|
+
commitSpread: base.commitSpread,
|
|
1674
|
+
repoCount: base.repoCount,
|
|
1675
|
+
notes,
|
|
1676
|
+
data: { ...restOfObserveRuntimeData, wrote },
|
|
1677
|
+
fitToBudget: (budgetBytes) => fitObserveRuntime(restOfObserveRuntimeData, wrote, budgetBytes),
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
finally {
|
|
1681
|
+
// Best-effort: normally already stopped above. Only still "observing" here when something
|
|
1682
|
+
// threw before that point (e.g. runInstrumentedExecution itself), in which case the
|
|
1683
|
+
// captured evidence has no real executionId to attribute to and is discarded — the same
|
|
1684
|
+
// "no fabrication" posture as the cancellation path.
|
|
1685
|
+
if (postgresChannel.kind === "observing")
|
|
1686
|
+
await postgresChannel.stop().catch(() => undefined);
|
|
1687
|
+
abort.dispose();
|
|
1688
|
+
evidenceStore.close();
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
/** Stop a process this run spawned, and the group it leads — signalling the leader alone
|
|
1692
|
+
* would leave forked children holding the port. ESRCH is ordinary: it may have already exited. */
|
|
1693
|
+
function terminateSpawnedProcess(pid) {
|
|
1694
|
+
try {
|
|
1695
|
+
process.kill(-pid, "SIGTERM");
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
catch (error) {
|
|
1699
|
+
if (error.code === "ESRCH")
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
try {
|
|
1703
|
+
process.kill(pid, "SIGTERM");
|
|
1704
|
+
}
|
|
1705
|
+
catch {
|
|
1706
|
+
// Already gone. Nothing to report and nothing to do.
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* The per-service captured output, when the installed runtime supplies it.
|
|
1711
|
+
*
|
|
1712
|
+
* Read structurally rather than off the declared type. `serviceOutput` is newer
|
|
1713
|
+
* than the `@descryy/runtime-orchestrator` this package is pinned to, and the
|
|
1714
|
+
* pin is exact while the engine is on 0.x (DEC-395), so the field is present at
|
|
1715
|
+
* runtime only once that pin moves. An optional read means this half works the
|
|
1716
|
+
* day the runtime ships it and degrades honestly — not silently — before then:
|
|
1717
|
+
* `startupOutputNote` says so to the user rather than letting an empty field
|
|
1718
|
+
* read as "the service printed nothing".
|
|
1719
|
+
*/
|
|
1720
|
+
function capturedServiceOutput(execution) {
|
|
1721
|
+
const candidate = execution.serviceOutput;
|
|
1722
|
+
if (candidate === null || typeof candidate !== "object" || candidate === undefined)
|
|
1723
|
+
return null;
|
|
1724
|
+
const entries = Object.entries(candidate).filter((entry) => typeof entry[1] === "string");
|
|
1725
|
+
return Object.fromEntries(entries);
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* What to tell the user when a run failed and no output came back with it.
|
|
1729
|
+
*
|
|
1730
|
+
* An exit code with no explanation is a fallback, and rule 7 says every
|
|
1731
|
+
* fallback is labelled. The cost of not labelling this one is measured: a bare
|
|
1732
|
+
* `126` was read as a nested-sandbox incompatibility and the wrong root cause
|
|
1733
|
+
* outlived the session that filed it.
|
|
1734
|
+
*/
|
|
1735
|
+
function startupOutputNote(services, captured) {
|
|
1736
|
+
const failedSilently = services.filter((service) => service.started && service.startupOutput === null);
|
|
1737
|
+
if (failedSilently.length === 0)
|
|
1738
|
+
return null;
|
|
1739
|
+
const names = failedSilently.map((service) => service.service).join(", ");
|
|
1740
|
+
if (captured === null) {
|
|
1741
|
+
return (`No startup output is shown for ${names} because the installed ` +
|
|
1742
|
+
"@descryy/runtime-orchestrator does not return it — not because the service printed nothing. " +
|
|
1743
|
+
"An exit code on its own does not say why a service failed, and can mislead: 126 means the " +
|
|
1744
|
+
"command was found and could not be executed, which is also what a perfectly working sandbox " +
|
|
1745
|
+
"reports. Upgrade the runtime to see the reason the process actually gave.");
|
|
1746
|
+
}
|
|
1747
|
+
return (`${names} produced no captured output before failing, so this run cannot say why beyond the exit ` +
|
|
1748
|
+
"code and signal recorded above. That is an absence of evidence about the failure, not evidence " +
|
|
1749
|
+
"that the service failed quietly.");
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* The tail of a failed service's captured output that survives into the reply.
|
|
1753
|
+
*
|
|
1754
|
+
* Tail rather than head: a process that dies prints its reason last, so
|
|
1755
|
+
* clipping from the front is the one choice guaranteed to discard the line this
|
|
1756
|
+
* field exists to carry.
|
|
1757
|
+
*/
|
|
1758
|
+
export const STARTUP_OUTPUT_TAIL_LIMIT = 4_000;
|
|
1759
|
+
/** One row per **declared** service, not per spawned process — a never-started service
|
|
1760
|
+
* appears with `started: false` rather than vanishing ("it did nothing" vs "it never ran").
|
|
1761
|
+
*
|
|
1762
|
+
* `startupOutput` is keyed by service name. Callers pass `{}` for a run that did
|
|
1763
|
+
* not fail; the policy lives at the call site, where the outcome is known, rather
|
|
1764
|
+
* than in here. Exported for `observe-runtime-startup-output.test.ts`, which pins
|
|
1765
|
+
* the mapping without needing a runtime new enough to produce real output. */
|
|
1766
|
+
export function describeServices(processes, declared, startupOutput = {}) {
|
|
1767
|
+
const byService = new Map();
|
|
1768
|
+
for (const handle of processes) {
|
|
1769
|
+
if (handle.serviceName !== null)
|
|
1770
|
+
byService.set(handle.serviceName, handle);
|
|
1771
|
+
}
|
|
1772
|
+
return declared.map((service) => {
|
|
1773
|
+
const handle = byService.get(service.name);
|
|
1774
|
+
// A service with no process never ran, so anything keyed under its name is
|
|
1775
|
+
// not its output — reporting it would attribute one service's words to
|
|
1776
|
+
// another.
|
|
1777
|
+
const captured = handle === undefined ? undefined : startupOutput[service.name];
|
|
1778
|
+
const truncated = captured !== undefined && captured.length > STARTUP_OUTPUT_TAIL_LIMIT;
|
|
1779
|
+
return {
|
|
1780
|
+
service: service.name,
|
|
1781
|
+
started: handle !== undefined,
|
|
1782
|
+
attached: service.attached,
|
|
1783
|
+
port: handle?.port ?? null,
|
|
1784
|
+
exitedAt: handle?.exitedAt ?? null,
|
|
1785
|
+
exitCode: handle?.exitCode ?? null,
|
|
1786
|
+
signal: handle?.signal ?? null,
|
|
1787
|
+
startupOutput: captured === undefined ? null : truncated ? captured.slice(-STARTUP_OUTPUT_TAIL_LIMIT) : captured,
|
|
1788
|
+
startupOutputTruncated: truncated,
|
|
1789
|
+
};
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
function tally(evidence) {
|
|
1793
|
+
const byType = {};
|
|
1794
|
+
for (const item of evidence)
|
|
1795
|
+
byType[item.eventType] = (byType[item.eventType] ?? 0) + 1;
|
|
1796
|
+
return byType;
|
|
1797
|
+
}
|
|
1798
|
+
/** One evidence row onto one of @descryy/ir's six RUNTIME_SIGNAL_TYPES, or null. Decided by
|
|
1799
|
+
* `eventType`; `source` consulted only for the ambiguous EXCEPTION/STACK_TRACE pair (reading
|
|
1800
|
+
* `source` alone was measured wrong). Unlisted kinds fail closed to null, never guess-mapped. */
|
|
1801
|
+
function signalOf(item) {
|
|
1802
|
+
switch (item.eventType) {
|
|
1803
|
+
case "CONSOLE_MESSAGE":
|
|
1804
|
+
return "browser-console";
|
|
1805
|
+
case "NETWORK_REQUEST":
|
|
1806
|
+
case "NETWORK_RESPONSE":
|
|
1807
|
+
case "HTTP_ERROR":
|
|
1808
|
+
case "WEBSOCKET_CLOSED":
|
|
1809
|
+
return "network";
|
|
1810
|
+
case "SCREENSHOT":
|
|
1811
|
+
case "VIDEO":
|
|
1812
|
+
return "browser-visual";
|
|
1813
|
+
case "BACKEND_LOG":
|
|
1814
|
+
return "backend-log";
|
|
1815
|
+
case "DATABASE_QUERY":
|
|
1816
|
+
return "database";
|
|
1817
|
+
case "EXTERNAL_REQUEST":
|
|
1818
|
+
return "external-service";
|
|
1819
|
+
// The ambiguous pair: a thrown error reaches Descry through whichever collector saw it.
|
|
1820
|
+
case "EXCEPTION":
|
|
1821
|
+
case "STACK_TRACE":
|
|
1822
|
+
return item.source === "browser-console" ? "browser-console" : "backend-log";
|
|
1823
|
+
default:
|
|
1824
|
+
return null;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
/** `E` for this run — channels actually seen, via @descryy/ir's independentSignalTypes
|
|
1828
|
+
* (clamp-to-six and null-drop are that function's rules, not duplicated here). Exported for
|
|
1829
|
+
* its own test — this mapping decides `strongly supported` vs `unconfirmed`. */
|
|
1830
|
+
export function witnessedSignalTypes(evidence) {
|
|
1831
|
+
return independentSignalTypes(evidence.map((item) => ({ signal: signalOf(item), detail: item.eventType })));
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* The R4 write, and the one join this tool performs: for every evidence item resolved to an
|
|
1835
|
+
* endpoint that also carries a call-site stack, ask confirmObservedFrontendCaller. Both come
|
|
1836
|
+
* from one observation — nothing here pairs two separate evidence items, on purpose.
|
|
1837
|
+
*
|
|
1838
|
+
* A stack resolved on some OTHER evidence item (`family === "symbol"`, e.g. a BACKEND_LOG or
|
|
1839
|
+
* EXCEPTION line elsewhere in the same run) is never joined to an endpoint item that lacks its
|
|
1840
|
+
* own stack, even when both exist in the same run's evidence. `resolveObservedFrontendCaller`
|
|
1841
|
+
* takes a stack on faith as "the call site that reached this endpoint" — it does not itself
|
|
1842
|
+
* verify the direction, so the caller supplying the wrong stack is exactly how a wrong edge
|
|
1843
|
+
* gets minted at R4 with full confidence. Nothing on `Evidence` disambiguates that direction
|
|
1844
|
+
* for a cross-record pair: `traceId`/`requestId` can say two items belong to the same request,
|
|
1845
|
+
* never that the stack-bearing one is the endpoint's CALLER rather than code running inside its
|
|
1846
|
+
* own handling of that same request — which is the common shape (a handler's own exception
|
|
1847
|
+
* stack, correlated to the request it was handling, not to whoever issued it). Joining on that
|
|
1848
|
+
* key would systematically mint edges in the SERVES direction mislabelled as CALLS/USES_API —
|
|
1849
|
+
* worse than the gap disclosed below (rule 2). So this is measured and reported via
|
|
1850
|
+
* `unjoinedSplitEvidence`, never inferred into an edge.
|
|
1851
|
+
*/
|
|
1852
|
+
export function writeObservations(input) {
|
|
1853
|
+
const promoted = [];
|
|
1854
|
+
const created = [];
|
|
1855
|
+
const confirmed = [];
|
|
1856
|
+
const refused = [];
|
|
1857
|
+
const contradictions = [];
|
|
1858
|
+
const staleR4 = [];
|
|
1859
|
+
const confirmedFactConflict = [];
|
|
1860
|
+
// One evidence item can be attributed twice; keyed on both so the same (evidence,
|
|
1861
|
+
// endpoint) pair is never confirmed twice within one run.
|
|
1862
|
+
const seen = new Set();
|
|
1863
|
+
// Split-evidence gap, tracked rather than acted on — see this function's own doc.
|
|
1864
|
+
let unpairedEndpoints = 0;
|
|
1865
|
+
let resolvedSymbolsElsewhere = 0;
|
|
1866
|
+
for (const attribution of input.pass.attributed) {
|
|
1867
|
+
if (attribution.family === "symbol") {
|
|
1868
|
+
const evidence = input.evidenceStore.getById(attribution.evidenceId);
|
|
1869
|
+
if (evidence !== null && evidence.stackTrace !== null)
|
|
1870
|
+
resolvedSymbolsElsewhere += 1;
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
// "endpoint" only — "log-text-endpoint" fires when a log line mentions a route, which
|
|
1874
|
+
// means the function SERVES it, not calls it; minting from that could point the wrong
|
|
1875
|
+
// way. Rule 2: a wrong edge is worse than a missing (disclosed) one. Omitted.
|
|
1876
|
+
if (attribution.family !== "endpoint")
|
|
1877
|
+
continue;
|
|
1878
|
+
const key = `${attribution.evidenceId}::${attribution.graphNodeId}`;
|
|
1879
|
+
if (seen.has(key))
|
|
1880
|
+
continue;
|
|
1881
|
+
seen.add(key);
|
|
1882
|
+
const evidence = input.evidenceStore.getById(attribution.evidenceId);
|
|
1883
|
+
if (evidence === null || evidence.stackTrace === null) {
|
|
1884
|
+
unpairedEndpoints += 1;
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
const outcome = confirmObservedFrontendCaller(input.driver, {
|
|
1888
|
+
endpointNodeId: attribution.graphNodeId,
|
|
1889
|
+
stackTrace: evidence.stackTrace,
|
|
1890
|
+
repo: input.repo,
|
|
1891
|
+
runId: input.runId,
|
|
1892
|
+
commitSha: input.commitSha,
|
|
1893
|
+
repoRoot: input.repoRoot,
|
|
1894
|
+
});
|
|
1895
|
+
promoted.push(...outcome.confirmation.promoted);
|
|
1896
|
+
created.push(...outcome.confirmation.created);
|
|
1897
|
+
confirmed.push(...outcome.confirmation.confirmed);
|
|
1898
|
+
refused.push(...outcome.confirmation.refused.map((r) => r.reason));
|
|
1899
|
+
contradictions.push(...outcome.confirmation.contradictions.map((c) => c.detail));
|
|
1900
|
+
staleR4.push(...outcome.confirmation.staleR4.map((s) => s.detail));
|
|
1901
|
+
// `confirmedFactConflict` cannot be read here yet: `outcome.confirmation` is typed
|
|
1902
|
+
// against `@descryy/runtime-graph-correlator`'s OWN pinned, nested `@descryy/core`
|
|
1903
|
+
// (0.5.2 at last check — DEC-395's exact cross-repo pins), which predates this field.
|
|
1904
|
+
// Also moot in practice today: this call path never emits a denial at all (see this
|
|
1905
|
+
// function's own header doc, and `contradictions`/`staleR4` above, which are "always
|
|
1906
|
+
// empty today" for the same reason) — so there is nothing for a human-confirmed edge
|
|
1907
|
+
// to collide with here regardless. Once the correlator republishes against a core
|
|
1908
|
+
// carrying `confirmedFactConflict`, thread it through the same way as the lines above.
|
|
1909
|
+
}
|
|
1910
|
+
const unjoinedSplitEvidence = unpairedEndpoints > 0 && resolvedSymbolsElsewhere > 0
|
|
1911
|
+
? `This run reached ${String(unpairedEndpoints)} endpoint attribution(s) carrying no call-site ` +
|
|
1912
|
+
`stack of their own, and separately resolved ${String(resolvedSymbolsElsewhere)} call-site ` +
|
|
1913
|
+
"stack(s) to a real function elsewhere in this run's evidence. The two are not joined: this " +
|
|
1914
|
+
"tool only promotes an edge from one observation that names both the endpoint and the stack " +
|
|
1915
|
+
"itself, because a stack found on a different evidence record cannot be shown to be the " +
|
|
1916
|
+
"endpoint's CALLER rather than code that merely ran during its own handling of the same " +
|
|
1917
|
+
"request — minting from that risks an edge pointing the wrong way, which this project's rules " +
|
|
1918
|
+
"treat as worse than the gap this note discloses. No edge was written for this pairing; it is " +
|
|
1919
|
+
"real signal this run could not safely turn into a fact, not evidence that no such call exists."
|
|
1920
|
+
: null;
|
|
1921
|
+
return { promoted, created, confirmed, refused, contradictions, staleR4, confirmedFactConflict, unjoinedSplitEvidence };
|
|
1922
|
+
}
|
|
1923
|
+
/** §7's `willDo` — and, since readArguments runs first, where an invalid call is refused.
|
|
1924
|
+
* Always returns a string, never undefined: unlike `questions`, no call here has nothing
|
|
1925
|
+
* to confirm. Reads the *parsed* services so the confirmation sentence matches the run exactly. */
|
|
1926
|
+
/** Exported for the same reason as `run` above — `observe.ts` dispatches the "runtime" verb's
|
|
1927
|
+
* consent sentence to this exact function. */
|
|
1928
|
+
export function describeAction(args, ctx) {
|
|
1929
|
+
const { declared } = readArguments(args, ctx.session.repoPath);
|
|
1930
|
+
const spawned = declared.filter((service) => !service.attached).map((service) => service.name);
|
|
1931
|
+
const attached = declared.filter((service) => service.attached).map((service) => service.name);
|
|
1932
|
+
const parts = [];
|
|
1933
|
+
if (spawned.length > 0)
|
|
1934
|
+
parts.push(`start ${String(spawned.length)} service(s) (${spawned.join(", ")})`);
|
|
1935
|
+
if (attached.length > 0) {
|
|
1936
|
+
parts.push(`attach to ${String(attached.length)} already-running service(s) (${attached.join(", ")})`);
|
|
1937
|
+
}
|
|
1938
|
+
return (`${parts.join(" and ")}, observe them, and write any edge the run witnesses into this ` +
|
|
1939
|
+
"repository's graph at R4 — a durable fact that raises every later finding resting on it to " +
|
|
1940
|
+
"reliability class A, and that survives re-indexing. Nothing is ever demoted or deleted.");
|
|
1941
|
+
}
|
|
1942
|
+
export const observeRuntimeTool = {
|
|
1943
|
+
name: "observe_runtime",
|
|
1944
|
+
class: "action",
|
|
1945
|
+
tier: "evidence",
|
|
1946
|
+
version: "1.0.0",
|
|
1947
|
+
title: "Run the application and record what was observed",
|
|
1948
|
+
description: "Boot or attach to the declared services, watch them with a runtime adapter, resolve what was " +
|
|
1949
|
+
"observed against this repository's graph, and record the edges the run actually witnessed at R4 " +
|
|
1950
|
+
"— the one resolution level static analysis cannot reach. Promoted and newly minted edges become " +
|
|
1951
|
+
"visible through impact, propagation and every other tool immediately, with no second call: they " +
|
|
1952
|
+
"already read the resolution field. Running takes a two-call confirmation — the first call " +
|
|
1953
|
+
"performs nothing and returns a token describing what it would do; call again with " +
|
|
1954
|
+
"\"confirmToken\" to actually run it. The environment profile's declared safetyLevel is checked " +
|
|
1955
|
+
"before anything starts: booting is a write, attaching is not. " +
|
|
1956
|
+
"BEFORE CALLING: this observes an application you can already run — it does not help you get to " +
|
|
1957
|
+
"a runnable state, and that boundary is real rather than apologetic. Its dependencies must be up, " +
|
|
1958
|
+
"its environment set, and its migrations applied, all by you. One trap worth stating because it " +
|
|
1959
|
+
"is invisible: overriding some environment variables does not isolate a run from the " +
|
|
1960
|
+
"application's own configuration file — anything you did not explicitly override is still read " +
|
|
1961
|
+
"from it, including values naming environments you did not intend to touch.",
|
|
1962
|
+
inputSchema: SCHEMA,
|
|
1963
|
+
run,
|
|
1964
|
+
describeAction,
|
|
1965
|
+
};
|
|
1966
|
+
//# sourceMappingURL=observe-runtime.js.map
|