@quickchatai/quickreview 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +385 -2
- package/THIRD-PARTY-NOTICES.md +134 -0
- package/agents/pass-abstraction.md +64 -0
- package/agents/pass-ask-human.md +134 -0
- package/agents/pass-backcompat.md +67 -0
- package/agents/pass-baseline.md +61 -0
- package/agents/pass-behaviour-delta.md +64 -0
- package/agents/pass-bounds.md +36 -0
- package/agents/pass-claims.md +31 -0
- package/agents/pass-concurrency.md +168 -0
- package/agents/pass-consistency.md +57 -0
- package/agents/pass-contracts.md +30 -0
- package/agents/pass-data-safety.md +54 -0
- package/agents/pass-deferred.md +35 -0
- package/agents/pass-delivery.md +37 -0
- package/agents/pass-duplication.md +60 -0
- package/agents/pass-intent.md +63 -0
- package/agents/pass-lint-conformance.md +46 -0
- package/agents/pass-maintainability.md +67 -0
- package/agents/pass-placement.md +68 -0
- package/agents/pass-prod-risk.md +24 -0
- package/agents/pass-rollout.md +32 -0
- package/agents/pass-roundtrip.md +46 -0
- package/agents/pass-security.md +31 -0
- package/agents/pass-simplicity.md +68 -0
- package/agents/pass-statelessness.md +27 -0
- package/agents/pass-style.md +37 -0
- package/agents/pass-sweep.md +57 -0
- package/agents/pass-tests.md +58 -0
- package/agents/pass-ui.md +23 -0
- package/agents/pass-validate.md +50 -0
- package/agents/pass-widened.md +46 -0
- package/agents/scan/pass-scan.md +86 -0
- package/bin/qcr.js +11 -0
- package/config.example.toml +1067 -0
- package/deploy/ansible/README.md +356 -0
- package/deploy/ansible/group_vars/all.yml +212 -0
- package/deploy/ansible/inventory.example.ini +25 -0
- package/deploy/ansible/playbook.yml +360 -0
- package/deploy/collect-secrets.sh +289 -0
- package/deploy/collect-slack-secrets.mjs +1190 -0
- package/deploy/github-app-manifest.json +31 -0
- package/deploy/manifest.schema.json +75 -0
- package/deploy/qcr-update.sh +803 -0
- package/deploy/reviewer/AGENTS.override.md +12 -0
- package/dist/extensions/cli/bash-guard-ambient.js +42 -0
- package/dist/extensions/cli/bash-guard-ambient.js.map +1 -0
- package/dist/extensions/cli/bash-guard.js +5 -0
- package/dist/extensions/cli/bash-guard.js.map +1 -0
- package/dist/extensions/cli/companion-guard.js +5 -0
- package/dist/extensions/cli/companion-guard.js.map +1 -0
- package/dist/extensions/cli/debug-round.js +8 -0
- package/dist/extensions/cli/debug-round.js.map +1 -0
- package/dist/extensions/cli/inject-bridge.js +5 -0
- package/dist/extensions/cli/inject-bridge.js.map +1 -0
- package/dist/extensions/cli/ledger-pin.js +5 -0
- package/dist/extensions/cli/ledger-pin.js.map +1 -0
- package/dist/extensions/cli/lens.js +13 -0
- package/dist/extensions/cli/lens.js.map +1 -0
- package/dist/extensions/cli/muted-theme.js +5 -0
- package/dist/extensions/cli/muted-theme.js.map +1 -0
- package/dist/extensions/cli/onboarding.js +5 -0
- package/dist/extensions/cli/onboarding.js.map +1 -0
- package/dist/extensions/cli/setup.js +5 -0
- package/dist/extensions/cli/setup.js.map +1 -0
- package/dist/extensions/cli/tui-view.js +5 -0
- package/dist/extensions/cli/tui-view.js.map +1 -0
- package/dist/extensions/companion-guard.js +361 -0
- package/dist/extensions/companion-guard.js.map +1 -0
- package/dist/extensions/debug-round.js +204 -0
- package/dist/extensions/debug-round.js.map +1 -0
- package/dist/extensions/inject-bridge.js +603 -0
- package/dist/extensions/inject-bridge.js.map +1 -0
- package/dist/extensions/muted-theme.js +12 -0
- package/dist/extensions/muted-theme.js.map +1 -0
- package/dist/extensions/onboarding.js +175 -0
- package/dist/extensions/onboarding.js.map +1 -0
- package/dist/extensions/setup-replay.js +269 -0
- package/dist/extensions/setup-replay.js.map +1 -0
- package/dist/extensions/setup-scan.js +998 -0
- package/dist/extensions/setup-scan.js.map +1 -0
- package/dist/extensions/setup-triage.js +175 -0
- package/dist/extensions/setup-triage.js.map +1 -0
- package/dist/extensions/setup.js +1160 -0
- package/dist/extensions/setup.js.map +1 -0
- package/dist/extensions/tui-view.js +389 -0
- package/dist/extensions/tui-view.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/client.js +376 -0
- package/dist/extensions/vendor/openviking-pi/client.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/config.js +189 -0
- package/dist/extensions/vendor/openviking-pi/config.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/index.js +421 -0
- package/dist/extensions/vendor/openviking-pi/index.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/lib/capture-adapter.mjs +86 -0
- package/dist/extensions/vendor/openviking-pi/lib/capture-adapter.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/lib/takeover-core.mjs +408 -0
- package/dist/extensions/vendor/openviking-pi/lib/takeover-core.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs +41 -0
- package/dist/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-hint.js +290 -0
- package/dist/extensions/vendor/openviking-pi/qcr-hint.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-honest.js +148 -0
- package/dist/extensions/vendor/openviking-pi/qcr-honest.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +68 -0
- package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-leaf.js +77 -0
- package/dist/extensions/vendor/openviking-pi/qcr-leaf.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-lesson.js +216 -0
- package/dist/extensions/vendor/openviking-pi/qcr-lesson.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-namespaces.js +154 -0
- package/dist/extensions/vendor/openviking-pi/qcr-namespaces.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-recall.js +254 -0
- package/dist/extensions/vendor/openviking-pi/qcr-recall.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-shim.js +94 -0
- package/dist/extensions/vendor/openviking-pi/qcr-shim.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-write.js +333 -0
- package/dist/extensions/vendor/openviking-pi/qcr-write.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/recall.js +108 -0
- package/dist/extensions/vendor/openviking-pi/recall.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/capture-utils.mjs +529 -0
- package/dist/extensions/vendor/openviking-pi/shared/capture-utils.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/credentials.mjs +205 -0
- package/dist/extensions/vendor/openviking-pi/shared/credentials.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/pending-queue.mjs +442 -0
- package/dist/extensions/vendor/openviking-pi/shared/pending-queue.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/profile-inject.mjs +286 -0
- package/dist/extensions/vendor/openviking-pi/shared/profile-inject.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs +189 -0
- package/dist/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/recall-core.mjs +580 -0
- package/dist/extensions/vendor/openviking-pi/shared/recall-core.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/retryable.mjs +11 -0
- package/dist/extensions/vendor/openviking-pi/shared/retryable.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/session-model.mjs +63 -0
- package/dist/extensions/vendor/openviking-pi/shared/session-model.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/uri-guard.mjs +58 -0
- package/dist/extensions/vendor/openviking-pi/shared/uri-guard.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/workspace-peer.mjs +16 -0
- package/dist/extensions/vendor/openviking-pi/shared/workspace-peer.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/sync.js +127 -0
- package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/takeover.js +25 -0
- package/dist/extensions/vendor/openviking-pi/takeover.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/tools.js +417 -0
- package/dist/extensions/vendor/openviking-pi/tools.js.map +1 -0
- package/dist/src/anomalies.js +483 -0
- package/dist/src/anomalies.js.map +1 -0
- package/dist/src/answerworker.js +180 -0
- package/dist/src/answerworker.js.map +1 -0
- package/dist/src/appstamp.js +68 -0
- package/dist/src/appstamp.js.map +1 -0
- package/dist/src/archive/admission.js +3 -0
- package/dist/src/archive/admission.js.map +1 -0
- package/dist/src/archive/archivedb.js +474 -0
- package/dist/src/archive/archivedb.js.map +1 -0
- package/dist/src/archive/archiver.js +1770 -0
- package/dist/src/archive/archiver.js.map +1 -0
- package/dist/src/archive/archivewrite.js +198 -0
- package/dist/src/archive/archivewrite.js.map +1 -0
- package/dist/src/archive/bundle.js +266 -0
- package/dist/src/archive/bundle.js.map +1 -0
- package/dist/src/archive/dbmove.js +638 -0
- package/dist/src/archive/dbmove.js.map +1 -0
- package/dist/src/archive/duplicatecounts.js +80 -0
- package/dist/src/archive/duplicatecounts.js.map +1 -0
- package/dist/src/archive/eligibility.js +113 -0
- package/dist/src/archive/eligibility.js.map +1 -0
- package/dist/src/archive/extract.js +288 -0
- package/dist/src/archive/extract.js.map +1 -0
- package/dist/src/archive/fsutil.js +330 -0
- package/dist/src/archive/fsutil.js.map +1 -0
- package/dist/src/archive/gzfile.js +277 -0
- package/dist/src/archive/gzfile.js.map +1 -0
- package/dist/src/archive/kinds.js +22 -0
- package/dist/src/archive/kinds.js.map +1 -0
- package/dist/src/archive/livecheckpoint.js +178 -0
- package/dist/src/archive/livecheckpoint.js.map +1 -0
- package/dist/src/archive/liveretention.js +145 -0
- package/dist/src/archive/liveretention.js.map +1 -0
- package/dist/src/archive/manifest.js +284 -0
- package/dist/src/archive/manifest.js.map +1 -0
- package/dist/src/archive/overseer.js +283 -0
- package/dist/src/archive/overseer.js.map +1 -0
- package/dist/src/archive/piglobal.js +129 -0
- package/dist/src/archive/piglobal.js.map +1 -0
- package/dist/src/archive/plan.js +398 -0
- package/dist/src/archive/plan.js.map +1 -0
- package/dist/src/archive/prarchive.js +831 -0
- package/dist/src/archive/prarchive.js.map +1 -0
- package/dist/src/archive/prfiles.js +303 -0
- package/dist/src/archive/prfiles.js.map +1 -0
- package/dist/src/archive/report.js +169 -0
- package/dist/src/archive/report.js.map +1 -0
- package/dist/src/archive/restore.js +276 -0
- package/dist/src/archive/restore.js.map +1 -0
- package/dist/src/archive/rows.js +106 -0
- package/dist/src/archive/rows.js.map +1 -0
- package/dist/src/archive/seal.js +480 -0
- package/dist/src/archive/seal.js.map +1 -0
- package/dist/src/archive/status.js +528 -0
- package/dist/src/archive/status.js.map +1 -0
- package/dist/src/archive/storepass.js +409 -0
- package/dist/src/archive/storepass.js.map +1 -0
- package/dist/src/backfill.js +217 -0
- package/dist/src/backfill.js.map +1 -0
- package/dist/src/bench/cost.js +87 -0
- package/dist/src/bench/exec.js +76 -0
- package/dist/src/bench/values.js +14 -0
- package/dist/src/botcontrol.js +127 -0
- package/dist/src/botcontrol.js.map +1 -0
- package/dist/src/botworker.js +805 -0
- package/dist/src/botworker.js.map +1 -0
- package/dist/src/capture.js +234 -0
- package/dist/src/capture.js.map +1 -0
- package/dist/src/cases.js +1491 -0
- package/dist/src/cases.js.map +1 -0
- package/dist/src/channelfs.js +27 -0
- package/dist/src/channelfs.js.map +1 -0
- package/dist/src/checklist.js +312 -0
- package/dist/src/checklist.js.map +1 -0
- package/dist/src/ciretry.js +91 -0
- package/dist/src/ciretry.js.map +1 -0
- package/dist/src/cli.js +1916 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/cliops.js +333 -0
- package/dist/src/cliops.js.map +1 -0
- package/dist/src/clonedir.js +15 -0
- package/dist/src/clonedir.js.map +1 -0
- package/dist/src/commandeffects.js +43 -0
- package/dist/src/commandeffects.js.map +1 -0
- package/dist/src/commands.js +1312 -0
- package/dist/src/commands.js.map +1 -0
- package/dist/src/companioninbox.js +203 -0
- package/dist/src/companioninbox.js.map +1 -0
- package/dist/src/compare.js +673 -0
- package/dist/src/compare.js.map +1 -0
- package/dist/src/config.js +1027 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/configcheck.js +111 -0
- package/dist/src/configcheck.js.map +1 -0
- package/dist/src/coreext.js +43 -0
- package/dist/src/coreext.js.map +1 -0
- package/dist/src/daemon.js +929 -0
- package/dist/src/daemon.js.map +1 -0
- package/dist/src/debuground.js +731 -0
- package/dist/src/debuground.js.map +1 -0
- package/dist/src/debugroundpi.js +219 -0
- package/dist/src/debugroundpi.js.map +1 -0
- package/dist/src/diagnostics.js +14 -0
- package/dist/src/diagnostics.js.map +1 -0
- package/dist/src/digest.js +139 -0
- package/dist/src/digest.js.map +1 -0
- package/dist/src/dispatch.js +1726 -0
- package/dist/src/dispatch.js.map +1 -0
- package/dist/src/engine/answerguard.js +18 -0
- package/dist/src/engine/answerguard.js.map +1 -0
- package/dist/src/engine/botsdk.js +262 -0
- package/dist/src/engine/botsdk.js.map +1 -0
- package/dist/src/engine/caserunner-sdk.js +332 -0
- package/dist/src/engine/caserunner-sdk.js.map +1 -0
- package/dist/src/engine/debugpi.js +194 -0
- package/dist/src/engine/debugpi.js.map +1 -0
- package/dist/src/eod.js +473 -0
- package/dist/src/eod.js.map +1 -0
- package/dist/src/eodpinwatch.js +391 -0
- package/dist/src/eodpinwatch.js.map +1 -0
- package/dist/src/escalation.js +1009 -0
- package/dist/src/escalation.js.map +1 -0
- package/dist/src/eventloop.js +779 -0
- package/dist/src/eventloop.js.map +1 -0
- package/dist/src/execboundary.js +430 -0
- package/dist/src/execboundary.js.map +1 -0
- package/dist/src/feedback.js +915 -0
- package/dist/src/feedback.js.map +1 -0
- package/dist/src/findingscheck.js +200 -0
- package/dist/src/findingscheck.js.map +1 -0
- package/dist/src/fsdenied.js +26 -0
- package/dist/src/fsdenied.js.map +1 -0
- package/dist/src/ghread.js +181 -0
- package/dist/src/ghread.js.map +1 -0
- package/dist/src/github.js +1063 -0
- package/dist/src/github.js.map +1 -0
- package/dist/src/homes.js +103 -0
- package/dist/src/homes.js.map +1 -0
- package/dist/src/hostroute.js +24 -0
- package/dist/src/hostroute.js.map +1 -0
- package/dist/src/improvements.js +625 -0
- package/dist/src/improvements.js.map +1 -0
- package/dist/src/install.js +946 -0
- package/dist/src/install.js.map +1 -0
- package/dist/src/installkind.js +40 -0
- package/dist/src/installkind.js.map +1 -0
- package/dist/src/ipc/capability.js +286 -0
- package/dist/src/ipc/capability.js.map +1 -0
- package/dist/src/ipc/client.js +226 -0
- package/dist/src/ipc/client.js.map +1 -0
- package/dist/src/ipc/control.js +70 -0
- package/dist/src/ipc/control.js.map +1 -0
- package/dist/src/ipc/ghreadshim.js +77 -0
- package/dist/src/ipc/ghreadshim.js.map +1 -0
- package/dist/src/ipc/launcher.js +65 -0
- package/dist/src/ipc/launcher.js.map +1 -0
- package/dist/src/ipc/permits.js +293 -0
- package/dist/src/ipc/permits.js.map +1 -0
- package/dist/src/ipc/postshim.js +137 -0
- package/dist/src/ipc/postshim.js.map +1 -0
- package/dist/src/ipc/protocol.js +239 -0
- package/dist/src/ipc/protocol.js.map +1 -0
- package/dist/src/ipc/server.js +312 -0
- package/dist/src/ipc/server.js.map +1 -0
- package/dist/src/ipc/workerspec.js +148 -0
- package/dist/src/ipc/workerspec.js.map +1 -0
- package/dist/src/learner/learner.js +1272 -0
- package/dist/src/learner/learner.js.map +1 -0
- package/dist/src/learner/miner.js +948 -0
- package/dist/src/learner/miner.js.map +1 -0
- package/dist/src/learner/packet.js +216 -0
- package/dist/src/learner/packet.js.map +1 -0
- package/dist/src/learner/receipt.js +135 -0
- package/dist/src/learner/receipt.js.map +1 -0
- package/dist/src/learner/status.js +38 -0
- package/dist/src/learner/status.js.map +1 -0
- package/dist/src/ledgercheck.js +120 -0
- package/dist/src/ledgercheck.js.map +1 -0
- package/dist/src/lessonledger.js +451 -0
- package/dist/src/lessonledger.js.map +1 -0
- package/dist/src/lessonselect.js +639 -0
- package/dist/src/lessonselect.js.map +1 -0
- package/dist/src/lessonshadow.js +540 -0
- package/dist/src/lessonshadow.js.map +1 -0
- package/dist/src/levers.js +302 -0
- package/dist/src/levers.js.map +1 -0
- package/dist/src/live/projection.js +450 -0
- package/dist/src/live/projection.js.map +1 -0
- package/dist/src/live/publish.js +83 -0
- package/dist/src/live/publish.js.map +1 -0
- package/dist/src/live/read.js +60 -0
- package/dist/src/live/read.js.map +1 -0
- package/dist/src/livecli.js +288 -0
- package/dist/src/livecli.js.map +1 -0
- package/dist/src/loginhint.js +42 -0
- package/dist/src/loginhint.js.map +1 -0
- package/dist/src/loginrefresh/atomic.js +66 -0
- package/dist/src/loginrefresh/atomic.js.map +1 -0
- package/dist/src/loginrefresh/lock.js +93 -0
- package/dist/src/loginrefresh/lock.js.map +1 -0
- package/dist/src/loginrefresh/main.js +58 -0
- package/dist/src/loginrefresh/main.js.map +1 -0
- package/dist/src/loginrefresh/pirefresh.js +49 -0
- package/dist/src/loginrefresh/pirefresh.js.map +1 -0
- package/dist/src/loginrefresh/refresher.js +286 -0
- package/dist/src/loginrefresh/refresher.js.map +1 -0
- package/dist/src/loginrefresh/supervisor.js +253 -0
- package/dist/src/loginrefresh/supervisor.js.map +1 -0
- package/dist/src/loginrefresh/types.js +4 -0
- package/dist/src/loginrefresh/types.js.map +1 -0
- package/dist/src/memory/context.js +251 -0
- package/dist/src/memory/context.js.map +1 -0
- package/dist/src/memory/datafence.js +5 -0
- package/dist/src/memory/datafence.js.map +1 -0
- package/dist/src/memory/delivery.js +131 -0
- package/dist/src/memory/delivery.js.map +1 -0
- package/dist/src/memory/eventtags.js +81 -0
- package/dist/src/memory/eventtags.js.map +1 -0
- package/dist/src/memory/health.js +713 -0
- package/dist/src/memory/health.js.map +1 -0
- package/dist/src/memory/import.js +504 -0
- package/dist/src/memory/import.js.map +1 -0
- package/dist/src/memory/layout.js +252 -0
- package/dist/src/memory/layout.js.map +1 -0
- package/dist/src/memory/legacy-file.js +100 -0
- package/dist/src/memory/legacy-file.js.map +1 -0
- package/dist/src/memory/lessons.js +370 -0
- package/dist/src/memory/lessons.js.map +1 -0
- package/dist/src/memory/ovsession.js +449 -0
- package/dist/src/memory/ovsession.js.map +1 -0
- package/dist/src/memory/relayout.js +259 -0
- package/dist/src/memory/relayout.js.map +1 -0
- package/dist/src/memory/roundactivity.js +180 -0
- package/dist/src/memory/roundactivity.js.map +1 -0
- package/dist/src/memory/selection.js +690 -0
- package/dist/src/memory/selection.js.map +1 -0
- package/dist/src/memory/shadowstatus.js +69 -0
- package/dist/src/memory/shadowstatus.js.map +1 -0
- package/dist/src/memory/store.js +83 -0
- package/dist/src/memory/store.js.map +1 -0
- package/dist/src/memory/textidentity.js +8 -0
- package/dist/src/memory/textidentity.js.map +1 -0
- package/dist/src/metrics.js +773 -0
- package/dist/src/metrics.js.map +1 -0
- package/dist/src/missingconfig.js +8 -0
- package/dist/src/missingconfig.js.map +1 -0
- package/dist/src/npmverbs.js +226 -0
- package/dist/src/npmverbs.js.map +1 -0
- package/dist/src/onboard/choices.js +191 -0
- package/dist/src/onboard/choices.js.map +1 -0
- package/dist/src/onboard/engine.js +344 -0
- package/dist/src/onboard/engine.js.map +1 -0
- package/dist/src/onboard/launch.js +83 -0
- package/dist/src/onboard/launch.js.map +1 -0
- package/dist/src/onboard/steps/appslug.js +211 -0
- package/dist/src/onboard/steps/appslug.js.map +1 -0
- package/dist/src/onboard/steps/bringup.js +408 -0
- package/dist/src/onboard/steps/bringup.js.map +1 -0
- package/dist/src/onboard/steps/compose.js +343 -0
- package/dist/src/onboard/steps/compose.js.map +1 -0
- package/dist/src/onboard/steps/configdelivery.js +50 -0
- package/dist/src/onboard/steps/configdelivery.js.map +1 -0
- package/dist/src/onboard/steps/configfill.js +1148 -0
- package/dist/src/onboard/steps/configfill.js.map +1 -0
- package/dist/src/onboard/steps/deploysh.js +80 -0
- package/dist/src/onboard/steps/deploysh.js.map +1 -0
- package/dist/src/onboard/steps/embeddings.js +472 -0
- package/dist/src/onboard/steps/embeddings.js.map +1 -0
- package/dist/src/onboard/steps/escalation.js +571 -0
- package/dist/src/onboard/steps/escalation.js.map +1 -0
- package/dist/src/onboard/steps/githubapp.js +1092 -0
- package/dist/src/onboard/steps/githubapp.js.map +1 -0
- package/dist/src/onboard/steps/golive.js +506 -0
- package/dist/src/onboard/steps/golive.js.map +1 -0
- package/dist/src/onboard/steps/index.js +67 -0
- package/dist/src/onboard/steps/index.js.map +1 -0
- package/dist/src/onboard/steps/ingress.js +762 -0
- package/dist/src/onboard/steps/ingress.js.map +1 -0
- package/dist/src/onboard/steps/inventory.js +420 -0
- package/dist/src/onboard/steps/inventory.js.map +1 -0
- package/dist/src/onboard/steps/llmauth.js +893 -0
- package/dist/src/onboard/steps/llmauth.js.map +1 -0
- package/dist/src/onboard/steps/memory.js +1060 -0
- package/dist/src/onboard/steps/memory.js.map +1 -0
- package/dist/src/onboard/steps/memorykeys.js +43 -0
- package/dist/src/onboard/steps/memorykeys.js.map +1 -0
- package/dist/src/onboard/steps/preflight.js +172 -0
- package/dist/src/onboard/steps/preflight.js.map +1 -0
- package/dist/src/onboard/steps/proof.js +502 -0
- package/dist/src/onboard/steps/proof.js.map +1 -0
- package/dist/src/onboard/steps/ssh.js +300 -0
- package/dist/src/onboard/steps/ssh.js.map +1 -0
- package/dist/src/onboard/steps/topology.js +291 -0
- package/dist/src/onboard/steps/topology.js.map +1 -0
- package/dist/src/onboard/steps/transport.js +511 -0
- package/dist/src/onboard/steps/transport.js.map +1 -0
- package/dist/src/onboard/steps/verifyup.js +362 -0
- package/dist/src/onboard/steps/verifyup.js.map +1 -0
- package/dist/src/onboard/steps/vmdeploy.js +368 -0
- package/dist/src/onboard/steps/vmdeploy.js.map +1 -0
- package/dist/src/onboard/toml.js +53 -0
- package/dist/src/onboard/toml.js.map +1 -0
- package/dist/src/opencli.js +508 -0
- package/dist/src/opencli.js.map +1 -0
- package/dist/src/openpr.js +457 -0
- package/dist/src/openpr.js.map +1 -0
- package/dist/src/openviking/client.js +821 -0
- package/dist/src/openviking/client.js.map +1 -0
- package/dist/src/openviking/context.js +218 -0
- package/dist/src/openviking/context.js.map +1 -0
- package/dist/src/openviking/embedder.js +457 -0
- package/dist/src/openviking/embedder.js.map +1 -0
- package/dist/src/openviking/gapsink.js +38 -0
- package/dist/src/openviking/gapsink.js.map +1 -0
- package/dist/src/openviking/health.js +69 -0
- package/dist/src/openviking/health.js.map +1 -0
- package/dist/src/openviking/index.js +24 -0
- package/dist/src/openviking/index.js.map +1 -0
- package/dist/src/openviking/lessonfind.js +54 -0
- package/dist/src/openviking/lessonfind.js.map +1 -0
- package/dist/src/openviking/probe.js +89 -0
- package/dist/src/openviking/probe.js.map +1 -0
- package/dist/src/openviking/proc.js +95 -0
- package/dist/src/openviking/proc.js.map +1 -0
- package/dist/src/openviking/provisionreport.js +144 -0
- package/dist/src/openviking/provisionreport.js.map +1 -0
- package/dist/src/openviking/reembed.js +317 -0
- package/dist/src/openviking/reembed.js.map +1 -0
- package/dist/src/openviking/relayout.js +153 -0
- package/dist/src/openviking/relayout.js.map +1 -0
- package/dist/src/openviking/sidecar-main.js +39 -0
- package/dist/src/openviking/sidecar-main.js.map +1 -0
- package/dist/src/openviking/sidecar.js +79 -0
- package/dist/src/openviking/sidecar.js.map +1 -0
- package/dist/src/openviking/store.js +1758 -0
- package/dist/src/openviking/store.js.map +1 -0
- package/dist/src/openviking/supervisor.js +422 -0
- package/dist/src/openviking/supervisor.js.map +1 -0
- package/dist/src/openviking/types.js +5 -0
- package/dist/src/openviking/types.js.map +1 -0
- package/dist/src/operator-recovery.js +172 -0
- package/dist/src/operator-recovery.js.map +1 -0
- package/dist/src/operatormessage.js +44 -0
- package/dist/src/operatormessage.js.map +1 -0
- package/dist/src/outcomes.js +1361 -0
- package/dist/src/outcomes.js.map +1 -0
- package/dist/src/piauthcopy.js +5 -0
- package/dist/src/piauthcopy.js.map +1 -0
- package/dist/src/picli.js +45 -0
- package/dist/src/picli.js.map +1 -0
- package/dist/src/picwd.js +25 -0
- package/dist/src/picwd.js.map +1 -0
- package/dist/src/pilogin.js +35 -0
- package/dist/src/pilogin.js.map +1 -0
- package/dist/src/pingpong.js +1028 -0
- package/dist/src/pingpong.js.map +1 -0
- package/dist/src/posting-journal.js +441 -0
- package/dist/src/posting-journal.js.map +1 -0
- package/dist/src/posting-outbox.js +662 -0
- package/dist/src/posting-outbox.js.map +1 -0
- package/dist/src/posting-receipt.js +203 -0
- package/dist/src/posting-receipt.js.map +1 -0
- package/dist/src/posting.js +348 -0
- package/dist/src/posting.js.map +1 -0
- package/dist/src/postops.js +637 -0
- package/dist/src/postops.js.map +1 -0
- package/dist/src/pr-activity.js +11 -0
- package/dist/src/pr-activity.js.map +1 -0
- package/dist/src/pr-author.js +5 -0
- package/dist/src/pr-author.js.map +1 -0
- package/dist/src/pr-conversation.js +355 -0
- package/dist/src/pr-conversation.js.map +1 -0
- package/dist/src/pr-session-lifecycle.js +542 -0
- package/dist/src/pr-session-lifecycle.js.map +1 -0
- package/dist/src/preflight.js +341 -0
- package/dist/src/preflight.js.map +1 -0
- package/dist/src/priorresolve.js +275 -0
- package/dist/src/priorresolve.js.map +1 -0
- package/dist/src/qcrbin.js +180 -0
- package/dist/src/qcrbin.js.map +1 -0
- package/dist/src/recheck.js +1398 -0
- package/dist/src/recheck.js.map +1 -0
- package/dist/src/reconcile.js +820 -0
- package/dist/src/reconcile.js.map +1 -0
- package/dist/src/recordedfindings.js +15 -0
- package/dist/src/recordedfindings.js.map +1 -0
- package/dist/src/regression.js +546 -0
- package/dist/src/regression.js.map +1 -0
- package/dist/src/replay.js +598 -0
- package/dist/src/replay.js.map +1 -0
- package/dist/src/requiredpasses.js +113 -0
- package/dist/src/requiredpasses.js.map +1 -0
- package/dist/src/resumecheck.js +168 -0
- package/dist/src/resumecheck.js.map +1 -0
- package/dist/src/reviewcli.js +671 -0
- package/dist/src/reviewcli.js.map +1 -0
- package/dist/src/reviewcontext.js +22 -0
- package/dist/src/reviewcontext.js.map +1 -0
- package/dist/src/reviewdetach.js +574 -0
- package/dist/src/reviewdetach.js.map +1 -0
- package/dist/src/reviewhistory.js +284 -0
- package/dist/src/reviewhistory.js.map +1 -0
- package/dist/src/reviewhome.js +26 -0
- package/dist/src/reviewhome.js.map +1 -0
- package/dist/src/reviewmemory.js +110 -0
- package/dist/src/reviewmemory.js.map +1 -0
- package/dist/src/reviewrendercli.js +104 -0
- package/dist/src/reviewrendercli.js.map +1 -0
- package/dist/src/round-work.js +20 -0
- package/dist/src/round-work.js.map +1 -0
- package/dist/src/runhost.js +72 -0
- package/dist/src/runhost.js.map +1 -0
- package/dist/src/scanauth.js +25 -0
- package/dist/src/scanauth.js.map +1 -0
- package/dist/src/scanbox.js +846 -0
- package/dist/src/scanbox.js.map +1 -0
- package/dist/src/scancli.js +1074 -0
- package/dist/src/scancli.js.map +1 -0
- package/dist/src/scandriverlog.js +30 -0
- package/dist/src/scandriverlog.js.map +1 -0
- package/dist/src/session-bridge.js +75 -0
- package/dist/src/session-bridge.js.map +1 -0
- package/dist/src/session-command-payload.js +29 -0
- package/dist/src/session-command-payload.js.map +1 -0
- package/dist/src/session-command-supervisor.js +939 -0
- package/dist/src/session-command-supervisor.js.map +1 -0
- package/dist/src/sessionjsonl.js +85 -0
- package/dist/src/sessionjsonl.js.map +1 -0
- package/dist/src/sessions.js +541 -0
- package/dist/src/sessions.js.map +1 -0
- package/dist/src/setup/adapters.js +330 -0
- package/dist/src/setup/adapters.js.map +1 -0
- package/dist/src/setup/baselines.js +64 -0
- package/dist/src/setup/baselines.js.map +1 -0
- package/dist/src/setup/caserunner.js +554 -0
- package/dist/src/setup/caserunner.js.map +1 -0
- package/dist/src/setup/configcommit.js +358 -0
- package/dist/src/setup/configcommit.js.map +1 -0
- package/dist/src/setup/engine.js +672 -0
- package/dist/src/setup/engine.js.map +1 -0
- package/dist/src/setup/facts.js +593 -0
- package/dist/src/setup/facts.js.map +1 -0
- package/dist/src/setup/findings.js +948 -0
- package/dist/src/setup/findings.js.map +1 -0
- package/dist/src/setup/history.js +338 -0
- package/dist/src/setup/history.js.map +1 -0
- package/dist/src/setup/interview.js +185 -0
- package/dist/src/setup/interview.js.map +1 -0
- package/dist/src/setup/ledger.js +207 -0
- package/dist/src/setup/ledger.js.map +1 -0
- package/dist/src/setup/pins.js +43 -0
- package/dist/src/setup/pins.js.map +1 -0
- package/dist/src/setup/portrait.js +432 -0
- package/dist/src/setup/portrait.js.map +1 -0
- package/dist/src/setup/presentation.js +9 -0
- package/dist/src/setup/presentation.js.map +1 -0
- package/dist/src/setup/profile.js +417 -0
- package/dist/src/setup/profile.js.map +1 -0
- package/dist/src/setup/replay.js +240 -0
- package/dist/src/setup/replay.js.map +1 -0
- package/dist/src/setup/replaybuild.js +311 -0
- package/dist/src/setup/replaybuild.js.map +1 -0
- package/dist/src/setup/replayrun.js +806 -0
- package/dist/src/setup/replayrun.js.map +1 -0
- package/dist/src/setup/replaysource.js +169 -0
- package/dist/src/setup/replaysource.js.map +1 -0
- package/dist/src/setup/retune.js +333 -0
- package/dist/src/setup/retune.js.map +1 -0
- package/dist/src/setup/retunelog.js +51 -0
- package/dist/src/setup/retunelog.js.map +1 -0
- package/dist/src/setup/scan.js +725 -0
- package/dist/src/setup/scan.js.map +1 -0
- package/dist/src/setup/scanbuild.js +133 -0
- package/dist/src/setup/scanbuild.js.map +1 -0
- package/dist/src/setup/scanfacts.js +96 -0
- package/dist/src/setup/scanfacts.js.map +1 -0
- package/dist/src/setup/scanlease.js +251 -0
- package/dist/src/setup/scanlease.js.map +1 -0
- package/dist/src/setup/scanreport.js +289 -0
- package/dist/src/setup/scanreport.js.map +1 -0
- package/dist/src/setup/scanrun.js +1733 -0
- package/dist/src/setup/scanrun.js.map +1 -0
- package/dist/src/setup/scantree.js +257 -0
- package/dist/src/setup/scantree.js.map +1 -0
- package/dist/src/setup/stage2.js +617 -0
- package/dist/src/setup/stage2.js.map +1 -0
- package/dist/src/setup/stage2request.js +259 -0
- package/dist/src/setup/stage2request.js.map +1 -0
- package/dist/src/setup/survey.js +307 -0
- package/dist/src/setup/survey.js.map +1 -0
- package/dist/src/setup/surveyrun.js +549 -0
- package/dist/src/setup/surveyrun.js.map +1 -0
- package/dist/src/setup/textsniff.js +89 -0
- package/dist/src/setup/textsniff.js.map +1 -0
- package/dist/src/setup/trackersink.js +395 -0
- package/dist/src/setup/trackersink.js.map +1 -0
- package/dist/src/setup/triage.js +636 -0
- package/dist/src/setup/triage.js.map +1 -0
- package/dist/src/setup/tuning.js +585 -0
- package/dist/src/setup/tuning.js.map +1 -0
- package/dist/src/setup/unitcut.js +437 -0
- package/dist/src/setup/unitcut.js.map +1 -0
- package/dist/src/singleflight.js +72 -0
- package/dist/src/singleflight.js.map +1 -0
- package/dist/src/slack/announce.js +725 -0
- package/dist/src/slack/announce.js.map +1 -0
- package/dist/src/slack/api.js +227 -0
- package/dist/src/slack/api.js.map +1 -0
- package/dist/src/slack/messages.js +158 -0
- package/dist/src/slack/messages.js.map +1 -0
- package/dist/src/slack/origin.js +550 -0
- package/dist/src/slack/origin.js.map +1 -0
- package/dist/src/slack/policy.js +225 -0
- package/dist/src/slack/policy.js.map +1 -0
- package/dist/src/slack/reply.js +153 -0
- package/dist/src/slack/reply.js.map +1 -0
- package/dist/src/slack/router.js +189 -0
- package/dist/src/slack/router.js.map +1 -0
- package/dist/src/slack/socket.js +323 -0
- package/dist/src/slack/socket.js.map +1 -0
- package/dist/src/slack/types.js +11 -0
- package/dist/src/slack/types.js.map +1 -0
- package/dist/src/sqlitehint.js +30 -0
- package/dist/src/sqlitehint.js.map +1 -0
- package/dist/src/ssh-observation.js +25 -0
- package/dist/src/ssh-observation.js.map +1 -0
- package/dist/src/state.js +5869 -0
- package/dist/src/state.js.map +1 -0
- package/dist/src/statemachine.js +220 -0
- package/dist/src/statemachine.js.map +1 -0
- package/dist/src/stubs.js +52 -0
- package/dist/src/stubs.js.map +1 -0
- package/dist/src/takeover.js +506 -0
- package/dist/src/takeover.js.map +1 -0
- package/dist/src/takeoverpi.js +249 -0
- package/dist/src/takeoverpi.js.map +1 -0
- package/dist/src/terminal-failure.js +26 -0
- package/dist/src/terminal-failure.js.map +1 -0
- package/dist/src/thread-resolution-refusal.js +4 -0
- package/dist/src/thread-resolution-refusal.js.map +1 -0
- package/dist/src/tmux.js +126 -0
- package/dist/src/tmux.js.map +1 -0
- package/dist/src/trackerid.js +43 -0
- package/dist/src/trackerid.js.map +1 -0
- package/dist/src/transcriptindex.js +512 -0
- package/dist/src/transcriptindex.js.map +1 -0
- package/dist/src/tui/actions.js +404 -0
- package/dist/src/tui/actions.js.map +1 -0
- package/dist/src/tui/alerts.js +523 -0
- package/dist/src/tui/alerts.js.map +1 -0
- package/dist/src/tui/app.js +1835 -0
- package/dist/src/tui/app.js.map +1 -0
- package/dist/src/tui/archiveops.js +164 -0
- package/dist/src/tui/archiveops.js.map +1 -0
- package/dist/src/tui/autofile.js +191 -0
- package/dist/src/tui/autofile.js.map +1 -0
- package/dist/src/tui/board.js +1143 -0
- package/dist/src/tui/board.js.map +1 -0
- package/dist/src/tui/candidates.js +699 -0
- package/dist/src/tui/candidates.js.map +1 -0
- package/dist/src/tui/cellwidth.js +151 -0
- package/dist/src/tui/cellwidth.js.map +1 -0
- package/dist/src/tui/changelog-store.js +404 -0
- package/dist/src/tui/changelog-store.js.map +1 -0
- package/dist/src/tui/chord.js +26 -0
- package/dist/src/tui/chord.js.map +1 -0
- package/dist/src/tui/clipboard.js +81 -0
- package/dist/src/tui/clipboard.js.map +1 -0
- package/dist/src/tui/cmdbar.js +253 -0
- package/dist/src/tui/cmdbar.js.map +1 -0
- package/dist/src/tui/companion.js +1275 -0
- package/dist/src/tui/companion.js.map +1 -0
- package/dist/src/tui/companionplan.js +200 -0
- package/dist/src/tui/companionplan.js.map +1 -0
- package/dist/src/tui/companiontab.js +61 -0
- package/dist/src/tui/companiontab.js.map +1 -0
- package/dist/src/tui/confighome.js +105 -0
- package/dist/src/tui/confighome.js.map +1 -0
- package/dist/src/tui/connect.js +44 -0
- package/dist/src/tui/connect.js.map +1 -0
- package/dist/src/tui/deploy-ceremony.js +75 -0
- package/dist/src/tui/deploy-ceremony.js.map +1 -0
- package/dist/src/tui/deploy-drain.js +93 -0
- package/dist/src/tui/deploy-drain.js.map +1 -0
- package/dist/src/tui/deploy-process.js +234 -0
- package/dist/src/tui/deploy-process.js.map +1 -0
- package/dist/src/tui/deploytab.js +1263 -0
- package/dist/src/tui/deploytab.js.map +1 -0
- package/dist/src/tui/design.js +111 -0
- package/dist/src/tui/design.js.map +1 -0
- package/dist/src/tui/digesttab.js +148 -0
- package/dist/src/tui/digesttab.js.map +1 -0
- package/dist/src/tui/emptystate.js +32 -0
- package/dist/src/tui/emptystate.js.map +1 -0
- package/dist/src/tui/evidence.js +790 -0
- package/dist/src/tui/evidence.js.map +1 -0
- package/dist/src/tui/feed.js +415 -0
- package/dist/src/tui/feed.js.map +1 -0
- package/dist/src/tui/feedbacktab.js +907 -0
- package/dist/src/tui/feedbacktab.js.map +1 -0
- package/dist/src/tui/filters.js +72 -0
- package/dist/src/tui/filters.js.map +1 -0
- package/dist/src/tui/ghread.js +272 -0
- package/dist/src/tui/ghread.js.map +1 -0
- package/dist/src/tui/historypull.js +61 -0
- package/dist/src/tui/historypull.js.map +1 -0
- package/dist/src/tui/inboxpull.js +197 -0
- package/dist/src/tui/inboxpull.js.map +1 -0
- package/dist/src/tui/index.js +1425 -0
- package/dist/src/tui/index.js.map +1 -0
- package/dist/src/tui/issuesink.js +206 -0
- package/dist/src/tui/issuesink.js.map +1 -0
- package/dist/src/tui/jsonfile.js +32 -0
- package/dist/src/tui/jsonfile.js.map +1 -0
- package/dist/src/tui/keys.js +97 -0
- package/dist/src/tui/keys.js.map +1 -0
- package/dist/src/tui/layout.js +70 -0
- package/dist/src/tui/layout.js.map +1 -0
- package/dist/src/tui/learning.js +469 -0
- package/dist/src/tui/learning.js.map +1 -0
- package/dist/src/tui/lessonswitch.js +103 -0
- package/dist/src/tui/lessonswitch.js.map +1 -0
- package/dist/src/tui/livecontrol.js +167 -0
- package/dist/src/tui/livecontrol.js.map +1 -0
- package/dist/src/tui/memforward.js +310 -0
- package/dist/src/tui/memforward.js.map +1 -0
- package/dist/src/tui/memincident.js +287 -0
- package/dist/src/tui/memincident.js.map +1 -0
- package/dist/src/tui/memory.js +485 -0
- package/dist/src/tui/memory.js.map +1 -0
- package/dist/src/tui/memtab.js +513 -0
- package/dist/src/tui/memtab.js.map +1 -0
- package/dist/src/tui/metricstab.js +979 -0
- package/dist/src/tui/metricstab.js.map +1 -0
- package/dist/src/tui/model.js +104 -0
- package/dist/src/tui/model.js.map +1 -0
- package/dist/src/tui/mouse.js +83 -0
- package/dist/src/tui/mouse.js.map +1 -0
- package/dist/src/tui/msg.js +196 -0
- package/dist/src/tui/msg.js.map +1 -0
- package/dist/src/tui/nativecompanion.js +321 -0
- package/dist/src/tui/nativecompanion.js.map +1 -0
- package/dist/src/tui/openurl.js +17 -0
- package/dist/src/tui/openurl.js.map +1 -0
- package/dist/src/tui/paint.js +501 -0
- package/dist/src/tui/paint.js.map +1 -0
- package/dist/src/tui/panes.js +74 -0
- package/dist/src/tui/panes.js.map +1 -0
- package/dist/src/tui/pipane.js +166 -0
- package/dist/src/tui/pipane.js.map +1 -0
- package/dist/src/tui/poll-hooks.js +11 -0
- package/dist/src/tui/poll-hooks.js.map +1 -0
- package/dist/src/tui/poll.js +113 -0
- package/dist/src/tui/poll.js.map +1 -0
- package/dist/src/tui/proposals.js +715 -0
- package/dist/src/tui/proposals.js.map +1 -0
- package/dist/src/tui/prpause.js +132 -0
- package/dist/src/tui/prpause.js.map +1 -0
- package/dist/src/tui/reload.js +351 -0
- package/dist/src/tui/reload.js.map +1 -0
- package/dist/src/tui/remote.js +312 -0
- package/dist/src/tui/remote.js.map +1 -0
- package/dist/src/tui/rollover.js +767 -0
- package/dist/src/tui/rollover.js.map +1 -0
- package/dist/src/tui/scantab.js +1053 -0
- package/dist/src/tui/scantab.js.map +1 -0
- package/dist/src/tui/setupentry.js +81 -0
- package/dist/src/tui/setupentry.js.map +1 -0
- package/dist/src/tui/sidebar.js +602 -0
- package/dist/src/tui/sidebar.js.map +1 -0
- package/dist/src/tui/sshtriage.js +393 -0
- package/dist/src/tui/sshtriage.js.map +1 -0
- package/dist/src/tui/startup.js +172 -0
- package/dist/src/tui/startup.js.map +1 -0
- package/dist/src/tui/tabs.js +41 -0
- package/dist/src/tui/tabs.js.map +1 -0
- package/dist/src/tui/termmirror.js +217 -0
- package/dist/src/tui/termmirror.js.map +1 -0
- package/dist/src/tui/theme.js +95 -0
- package/dist/src/tui/theme.js.map +1 -0
- package/dist/src/tui/ticketstab.js +224 -0
- package/dist/src/tui/ticketstab.js.map +1 -0
- package/dist/src/tui/tmuxsocket.js +101 -0
- package/dist/src/tui/tmuxsocket.js.map +1 -0
- package/dist/src/tui/trackertickets.js +77 -0
- package/dist/src/tui/trackertickets.js.map +1 -0
- package/dist/src/tui/unwedge.js +245 -0
- package/dist/src/tui/unwedge.js.map +1 -0
- package/dist/src/tui/viewer.js +479 -0
- package/dist/src/tui/viewer.js.map +1 -0
- package/dist/src/tui/viewerinput.js +125 -0
- package/dist/src/tui/viewerinput.js.map +1 -0
- package/dist/src/updatenotice.js +143 -0
- package/dist/src/updatenotice.js.map +1 -0
- package/dist/src/usage.js +1007 -0
- package/dist/src/usage.js.map +1 -0
- package/dist/src/verdicts.js +3848 -0
- package/dist/src/verdicts.js.map +1 -0
- package/dist/src/version.js +58 -0
- package/dist/src/version.js.map +1 -0
- package/dist/src/wakes.js +411 -0
- package/dist/src/wakes.js.map +1 -0
- package/dist/src/webhook.js +725 -0
- package/dist/src/webhook.js.map +1 -0
- package/dist/src/webhookheal.js +238 -0
- package/dist/src/webhookheal.js.map +1 -0
- package/dist/src/wire.js +1801 -0
- package/dist/src/wire.js.map +1 -0
- package/dist/src/worker-session-host.js +531 -0
- package/dist/src/worker-session-host.js.map +1 -0
- package/dist/src/workerbreaker.js +112 -0
- package/dist/src/workerbreaker.js.map +1 -0
- package/dist/src/workercontrol.js +49 -0
- package/dist/src/workercontrol.js.map +1 -0
- package/dist/src/workerlease.js +351 -0
- package/dist/src/workerlease.js.map +1 -0
- package/dist/src/workerliveness.js +84 -0
- package/dist/src/workerliveness.js.map +1 -0
- package/dist/src/workerlog.js +65 -0
- package/dist/src/workerlog.js.map +1 -0
- package/dist/src/workerruntime.js +10 -0
- package/dist/src/workerruntime.js.map +1 -0
- package/dist/src/workersupervisor.js +1206 -0
- package/dist/src/workersupervisor.js.map +1 -0
- package/dist/src/workerusage.js +23 -0
- package/dist/src/workerusage.js.map +1 -0
- package/dist/src/workerworld.js +332 -0
- package/dist/src/workerworld.js.map +1 -0
- package/dist/src/worktrees.js +984 -0
- package/dist/src/worktrees.js.map +1 -0
- package/docker/Dockerfile +284 -0
- package/docker/Dockerfile.dockerignore +35 -0
- package/docker/README.md +221 -0
- package/docker/companion/Dockerfile +139 -0
- package/docker/companion/agents/evidence.md +34 -0
- package/docker/companion/agents/explore.md +26 -0
- package/docker/companion/agents/pass-survey.md +49 -0
- package/docker/companion/agents/study.md +26 -0
- package/docker/companion/entrypoint.sh +343 -0
- package/docker/companion/memory-health.mjs +269 -0
- package/docker/entrypoint.sh +782 -0
- package/docker/memory/Dockerfile +191 -0
- package/docker/memory/Dockerfile.dockerignore +20 -0
- package/docker/memory/README.md +60 -0
- package/docker/memory/entrypoint.sh +163 -0
- package/docker/setid/qcr-setid.c +109 -0
- package/docker/tunnel/Dockerfile.cloudflared +51 -0
- package/docker/tunnel/Dockerfile.ngrok +40 -0
- package/docker/tunnel/sidecar.mjs +411 -0
- package/docker/vm/tmux.conf +26 -0
- package/docker-compose.yml +348 -0
- package/extensions/cli/bash-guard-ambient.ts +71 -0
- package/extensions/cli/bash-guard.ts +12 -0
- package/extensions/cli/companion-guard.ts +8 -0
- package/extensions/cli/debug-round.ts +9 -0
- package/extensions/cli/inject-bridge.ts +8 -0
- package/extensions/cli/ledger-pin.ts +13 -0
- package/extensions/cli/lens.ts +22 -0
- package/extensions/cli/muted-theme.ts +9 -0
- package/extensions/cli/onboarding.ts +8 -0
- package/extensions/cli/setup.ts +8 -0
- package/extensions/cli/tui-view.ts +8 -0
- package/extensions/companion-guard.ts +411 -0
- package/extensions/debug-round.ts +281 -0
- package/extensions/inject-bridge.ts +630 -0
- package/extensions/inline-bash.ts +102 -0
- package/extensions/muted-theme.json +68 -0
- package/extensions/muted-theme.ts +23 -0
- package/extensions/onboarding.ts +233 -0
- package/extensions/setup-replay.ts +336 -0
- package/extensions/setup-scan.ts +1236 -0
- package/extensions/setup-triage.ts +190 -0
- package/extensions/setup.ts +1428 -0
- package/extensions/tui-view.ts +489 -0
- package/extensions/vendor/openviking-pi/LICENSE +201 -0
- package/extensions/vendor/openviking-pi/NOTICE +68 -0
- package/extensions/vendor/openviking-pi/README.md +550 -0
- package/extensions/vendor/openviking-pi/client.ts +588 -0
- package/extensions/vendor/openviking-pi/config.json +28 -0
- package/extensions/vendor/openviking-pi/config.ts +271 -0
- package/extensions/vendor/openviking-pi/index.ts +523 -0
- package/extensions/vendor/openviking-pi/lib/capture-adapter.d.mts +10 -0
- package/extensions/vendor/openviking-pi/lib/capture-adapter.mjs +88 -0
- package/extensions/vendor/openviking-pi/lib/takeover-core.d.mts +80 -0
- package/extensions/vendor/openviking-pi/lib/takeover-core.mjs +406 -0
- package/extensions/vendor/openviking-pi/lib/uri-guard-adapter.d.mts +1 -0
- package/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs +42 -0
- package/extensions/vendor/openviking-pi/patches/0000-vendoring-and-build-conformance.patch +726 -0
- package/extensions/vendor/openviking-pi/patches/0001-remove-session-before-compact-handler.patch +101 -0
- package/extensions/vendor/openviking-pi/patches/0002-no-ambient-work-in-subagent-leaves.patch +202 -0
- package/extensions/vendor/openviking-pi/patches/0003-never-auto-create-openviking-sessions.patch +120 -0
- package/extensions/vendor/openviking-pi/patches/0004-viking-write-durable-batch-write.patch +521 -0
- package/extensions/vendor/openviking-pi/patches/0005-second-memory-namespace.patch +786 -0
- package/extensions/vendor/openviking-pi/patches/0006-writes-that-stick-and-honest-errors.patch +996 -0
- package/extensions/vendor/openviking-pi/patches/0007-bounded-recall-no-commit.patch +794 -0
- package/extensions/vendor/openviking-pi/patches/0008-pr-derived-recall.patch +379 -0
- package/extensions/vendor/openviking-pi/patches/0009-pr-session-tools-in-round-areas.patch +375 -0
- package/extensions/vendor/openviking-pi/patches/0010-typed-lessons.patch +448 -0
- package/extensions/vendor/openviking-pi/patches/0011-review-session-memory-boundary.patch +382 -0
- package/extensions/vendor/openviking-pi/patches/0012-typecheck-against-the-real-pi.patch +129 -0
- package/extensions/vendor/openviking-pi/patches/0013-createopenviking-factory.patch +1168 -0
- package/extensions/vendor/openviking-pi/qcr-hint.ts +313 -0
- package/extensions/vendor/openviking-pi/qcr-honest.ts +183 -0
- package/extensions/vendor/openviking-pi/qcr-hooks.ts +87 -0
- package/extensions/vendor/openviking-pi/qcr-leaf.ts +78 -0
- package/extensions/vendor/openviking-pi/qcr-lesson.ts +231 -0
- package/extensions/vendor/openviking-pi/qcr-namespaces.ts +198 -0
- package/extensions/vendor/openviking-pi/qcr-recall.ts +288 -0
- package/extensions/vendor/openviking-pi/qcr-shim.ts +142 -0
- package/extensions/vendor/openviking-pi/qcr-write.ts +427 -0
- package/extensions/vendor/openviking-pi/recall.ts +149 -0
- package/extensions/vendor/openviking-pi/shared/capture-utils.d.mts +9 -0
- package/extensions/vendor/openviking-pi/shared/capture-utils.mjs +515 -0
- package/extensions/vendor/openviking-pi/shared/credentials.d.mts +14 -0
- package/extensions/vendor/openviking-pi/shared/credentials.mjs +228 -0
- package/extensions/vendor/openviking-pi/shared/pending-queue.d.mts +19 -0
- package/extensions/vendor/openviking-pi/shared/pending-queue.mjs +458 -0
- package/extensions/vendor/openviking-pi/shared/profile-inject.d.mts +17 -0
- package/extensions/vendor/openviking-pi/shared/profile-inject.mjs +294 -0
- package/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs +195 -0
- package/extensions/vendor/openviking-pi/shared/recall-core.d.mts +13 -0
- package/extensions/vendor/openviking-pi/shared/recall-core.mjs +609 -0
- package/extensions/vendor/openviking-pi/shared/retryable.mjs +9 -0
- package/extensions/vendor/openviking-pi/shared/session-model.d.mts +3 -0
- package/extensions/vendor/openviking-pi/shared/session-model.mjs +57 -0
- package/extensions/vendor/openviking-pi/shared/uri-guard.mjs +56 -0
- package/extensions/vendor/openviking-pi/shared/workspace-peer.d.mts +5 -0
- package/extensions/vendor/openviking-pi/shared/workspace-peer.mjs +16 -0
- package/extensions/vendor/openviking-pi/sync.ts +163 -0
- package/extensions/vendor/openviking-pi/takeover.ts +36 -0
- package/extensions/vendor/openviking-pi/tools.ts +479 -0
- package/extensions/vendor/openviking-pi/upstream.json +48 -0
- package/package.json +138 -4
- package/profiles/default/README.md +21 -0
- package/profiles/default/addenda/.gitkeep +0 -0
- package/profiles/default/profile.toml +17 -0
- package/profiles/example-django/README.md +21 -0
- package/profiles/example-django/addenda/10-migration-discipline.md +13 -0
- package/profiles/example-django/addenda/20-new-column-nullability.md +12 -0
- package/profiles/example-django/addenda/30-test-doctrine.md +12 -0
- package/profiles/example-django/addenda/40-orm-usage.md +19 -0
- package/profiles/example-django/addenda/50-declared-shape.md +10 -0
- package/profiles/example-django/addenda/60-config-placement.md +14 -0
- package/profiles/example-django/heuristics.toml +24 -0
- package/profiles/example-django/profile.toml +9 -0
- package/profiles/fast/README.md +24 -0
- package/profiles/fast/profile.toml +59 -0
- package/profiles/minimal/README.md +17 -0
- package/profiles/minimal/addenda/.gitkeep +0 -0
- package/profiles/minimal/profile.toml +8 -0
- package/profiles/snappy/README.md +24 -0
- package/profiles/snappy/addenda/10-race-as-question.md +6 -0
- package/profiles/snappy/profile.toml +46 -0
- package/prompts/companion-day-open.md +46 -0
- package/prompts/companion-first-run.md +104 -0
- package/prompts/companion.md +821 -0
- package/prompts/conversation-system.md +117 -0
- package/prompts/eod-sweep.md +120 -0
- package/prompts/onboarding.md +74 -0
- package/prompts/pass-common.md +52 -0
- package/prompts/pass-quality-common.md +22 -0
- package/prompts/round-first.md +874 -0
- package/prompts/round-follow-up.md +991 -0
- package/prompts/round-re-review.md +889 -0
- package/prompts/round-recheck.md +196 -0
- package/prompts/round-staleness.md +856 -0
- package/schemas/review-result.v1.json +391 -0
- package/schemas/scan-result.v1.json +421 -0
- package/schemas/version.v1.json +57 -0
- package/scripts/approve-pr.sh +16 -0
- package/scripts/build-stamp.mjs +67 -0
- package/scripts/commit-verdict.sh +155 -0
- package/scripts/companion-e2e.sh +250 -0
- package/scripts/companion-memory-forward.mjs +1966 -0
- package/scripts/config-fingerprint.sh +28 -0
- package/scripts/deploy-manifest.mjs +141 -0
- package/scripts/deploy.sh +1435 -0
- package/scripts/directory-lock.mjs +193 -0
- package/scripts/heartbeat-fresh.mjs +54 -0
- package/scripts/install-locator.sh +328 -0
- package/scripts/install-wrapper.sh +76 -0
- package/scripts/memory-search-check.mjs +98 -0
- package/scripts/mint-token.sh +212 -0
- package/scripts/openviking-embedder.py +392 -0
- package/scripts/openviking-ov-conf.mjs +161 -0
- package/scripts/openviking-provision.mjs +1122 -0
- package/scripts/openviking-reembed.mjs +1154 -0
- package/scripts/post-review.sh +16 -0
- package/scripts/pr-checks.sh +256 -0
- package/scripts/pr-context.py +174 -0
- package/scripts/pr-context.sh +63 -0
- package/scripts/pr-self.sh +144 -0
- package/scripts/pr-threads.sh +145 -0
- package/scripts/qcr +15 -0
- package/scripts/qcr-host.sh +769 -0
- package/scripts/render-review.sh +24 -0
- package/scripts/reply-thread.sh +16 -0
- package/scripts/required-passes.py +112 -0
- package/scripts/smoke-companion.sh +211 -0
- package/scripts/tui-deploy-runner.mjs +61 -0
- package/scripts/verify-dist.mjs +201 -0
- package/scripts/vm-ghcr-login.sh +188 -0
- package/seeds/agents/pass-baseline.md +61 -0
- package/seeds/agents/pass-contracts.md +30 -0
- package/seeds/agents/pass-data-safety.md +54 -0
- package/seeds/agents/pass-delivery.md +37 -0
- package/seeds/agents/pass-prod-risk.md +24 -0
- package/seeds/agents/pass-security.md +31 -0
- package/seeds/agents/pass-statelessness.md +27 -0
- package/seeds/agents/pass-ui.md +23 -0
- package/seeds/agents/pass-validate.md +50 -0
- package/seeds/prompts/conversation-system.md +117 -0
- package/seeds/prompts/eod-sweep.md +120 -0
- package/seeds/prompts/pass-common.md +52 -0
- package/skills/companion/adjudicate/SKILL.md +247 -0
- package/skills/companion/evidence/SKILL.md +150 -0
- package/skills/companion/land/SKILL.md +200 -0
- package/skills/companion/replay/SKILL.md +138 -0
- package/skills/companion/setup/SKILL.md +167 -0
- package/skills/companion/survey/SKILL.md +160 -0
- package/skills/selfimprove/README.md +34 -0
- package/skills/selfimprove/SKILL.md +127 -0
- package/skills/selfimprove/agents/openai.yaml +2 -0
- package/skills/selfimprove/commands/selfimprove.md +6 -0
- package/skills/selfimprove/references/fix-loop.md +49 -0
- package/skills/selfimprove/references/ledger.md +20 -0
- package/skills/selfimprove/references/setup.md +40 -0
- package/src/anomalies.ts +566 -0
- package/src/answerworker.ts +251 -0
- package/src/appstamp.ts +65 -0
- package/src/archive/admission.ts +3 -0
- package/src/archive/archivedb.ts +548 -0
- package/src/archive/archiver.ts +1963 -0
- package/src/archive/archivewrite.ts +279 -0
- package/src/archive/bundle.ts +333 -0
- package/src/archive/dbmove.ts +911 -0
- package/src/archive/duplicatecounts.ts +98 -0
- package/src/archive/eligibility.ts +181 -0
- package/src/archive/extract.ts +342 -0
- package/src/archive/fsutil.ts +415 -0
- package/src/archive/gzfile.ts +370 -0
- package/src/archive/kinds.ts +21 -0
- package/src/archive/livecheckpoint.ts +217 -0
- package/src/archive/liveretention.ts +180 -0
- package/src/archive/manifest.ts +519 -0
- package/src/archive/overseer.ts +336 -0
- package/src/archive/piglobal.ts +174 -0
- package/src/archive/plan.ts +612 -0
- package/src/archive/prarchive.ts +984 -0
- package/src/archive/prfiles.ts +406 -0
- package/src/archive/report.ts +252 -0
- package/src/archive/restore.ts +330 -0
- package/src/archive/rows.ts +187 -0
- package/src/archive/seal.ts +630 -0
- package/src/archive/status.ts +719 -0
- package/src/archive/storepass.ts +581 -0
- package/src/backfill.ts +362 -0
- package/src/bench/cost.ts +113 -0
- package/src/bench/exec.ts +90 -0
- package/src/bench/values.ts +13 -0
- package/src/botcontrol.ts +176 -0
- package/src/botworker.ts +953 -0
- package/src/capture.ts +321 -0
- package/src/cases.ts +1789 -0
- package/src/channelfs.ts +26 -0
- package/src/checklist.ts +506 -0
- package/src/ciretry.ts +141 -0
- package/src/cli.ts +2271 -0
- package/src/cliops.ts +384 -0
- package/src/clonedir.ts +15 -0
- package/src/commandeffects.ts +57 -0
- package/src/commands.ts +1678 -0
- package/src/companioninbox.ts +239 -0
- package/src/compare.ts +957 -0
- package/src/config.ts +2050 -0
- package/src/configcheck.ts +133 -0
- package/src/coreext.ts +47 -0
- package/src/daemon.ts +1302 -0
- package/src/debuground.ts +927 -0
- package/src/debugroundpi.ts +262 -0
- package/src/diagnostics.ts +14 -0
- package/src/digest.ts +183 -0
- package/src/dispatch.ts +2250 -0
- package/src/engine/answerguard.ts +27 -0
- package/src/engine/botsdk.ts +336 -0
- package/src/engine/caserunner-sdk.ts +423 -0
- package/src/engine/debugpi.ts +235 -0
- package/src/eod.ts +669 -0
- package/src/eodpinwatch.ts +556 -0
- package/src/escalation.ts +1253 -0
- package/src/eventloop.ts +1045 -0
- package/src/execboundary.ts +633 -0
- package/src/feedback.ts +1162 -0
- package/src/findingscheck.ts +298 -0
- package/src/fsdenied.ts +24 -0
- package/src/ghread.ts +242 -0
- package/src/github.ts +1497 -0
- package/src/homes.ts +175 -0
- package/src/hostroute.ts +53 -0
- package/src/improvements.ts +787 -0
- package/src/install.ts +1103 -0
- package/src/installkind.ts +50 -0
- package/src/ipc/capability.ts +333 -0
- package/src/ipc/client.ts +269 -0
- package/src/ipc/control.ts +90 -0
- package/src/ipc/ghreadshim.ts +94 -0
- package/src/ipc/launcher.ts +65 -0
- package/src/ipc/permits.ts +362 -0
- package/src/ipc/postshim.ts +164 -0
- package/src/ipc/protocol.ts +342 -0
- package/src/ipc/server.ts +371 -0
- package/src/ipc/workerspec.ts +218 -0
- package/src/learner/learner.ts +1655 -0
- package/src/learner/miner.ts +1290 -0
- package/src/learner/packet.ts +340 -0
- package/src/learner/receipt.ts +168 -0
- package/src/learner/status.ts +107 -0
- package/src/ledgercheck.ts +157 -0
- package/src/lessonledger.ts +703 -0
- package/src/lessonselect.ts +888 -0
- package/src/lessonshadow.ts +810 -0
- package/src/levers.ts +393 -0
- package/src/live/projection.ts +491 -0
- package/src/live/publish.ts +92 -0
- package/src/live/read.ts +86 -0
- package/src/livecli.ts +336 -0
- package/src/loginhint.ts +56 -0
- package/src/loginrefresh/atomic.ts +76 -0
- package/src/loginrefresh/lock.ts +104 -0
- package/src/loginrefresh/main.ts +59 -0
- package/src/loginrefresh/pirefresh.ts +57 -0
- package/src/loginrefresh/refresher.ts +326 -0
- package/src/loginrefresh/supervisor.ts +273 -0
- package/src/loginrefresh/types.ts +35 -0
- package/src/memory/context.ts +385 -0
- package/src/memory/datafence.ts +9 -0
- package/src/memory/delivery.ts +171 -0
- package/src/memory/eventtags.ts +80 -0
- package/src/memory/health.ts +953 -0
- package/src/memory/import.ts +664 -0
- package/src/memory/layout.ts +261 -0
- package/src/memory/legacy-file.ts +104 -0
- package/src/memory/lessons.ts +447 -0
- package/src/memory/ovsession.ts +601 -0
- package/src/memory/relayout.ts +335 -0
- package/src/memory/roundactivity.ts +209 -0
- package/src/memory/selection.ts +989 -0
- package/src/memory/shadowstatus.ts +125 -0
- package/src/memory/store.ts +275 -0
- package/src/memory/textidentity.ts +8 -0
- package/src/metrics.ts +1056 -0
- package/src/missingconfig.ts +7 -0
- package/src/npmverbs.ts +264 -0
- package/src/onboard/choices.ts +222 -0
- package/src/onboard/engine.ts +518 -0
- package/src/onboard/launch.ts +115 -0
- package/src/onboard/steps/appslug.ts +265 -0
- package/src/onboard/steps/bringup.ts +482 -0
- package/src/onboard/steps/compose.ts +459 -0
- package/src/onboard/steps/configdelivery.ts +58 -0
- package/src/onboard/steps/configfill.ts +1352 -0
- package/src/onboard/steps/deploysh.ts +105 -0
- package/src/onboard/steps/embeddings.ts +550 -0
- package/src/onboard/steps/escalation.ts +640 -0
- package/src/onboard/steps/githubapp.ts +1414 -0
- package/src/onboard/steps/golive.ts +577 -0
- package/src/onboard/steps/index.ts +71 -0
- package/src/onboard/steps/ingress.ts +907 -0
- package/src/onboard/steps/inventory.ts +526 -0
- package/src/onboard/steps/llmauth.ts +1095 -0
- package/src/onboard/steps/memory.ts +1290 -0
- package/src/onboard/steps/memorykeys.ts +85 -0
- package/src/onboard/steps/preflight.ts +191 -0
- package/src/onboard/steps/proof.ts +624 -0
- package/src/onboard/steps/ssh.ts +377 -0
- package/src/onboard/steps/topology.ts +337 -0
- package/src/onboard/steps/transport.ts +665 -0
- package/src/onboard/steps/verifyup.ts +448 -0
- package/src/onboard/steps/vmdeploy.ts +442 -0
- package/src/onboard/toml.ts +50 -0
- package/src/opencli.ts +591 -0
- package/src/openpr.ts +568 -0
- package/src/openviking/client.ts +1131 -0
- package/src/openviking/context.ts +263 -0
- package/src/openviking/embedder.ts +516 -0
- package/src/openviking/gapsink.ts +63 -0
- package/src/openviking/health.ts +83 -0
- package/src/openviking/index.ts +119 -0
- package/src/openviking/lessonfind.ts +92 -0
- package/src/openviking/probe.ts +129 -0
- package/src/openviking/proc.ts +110 -0
- package/src/openviking/provisionreport.ts +160 -0
- package/src/openviking/reembed.ts +412 -0
- package/src/openviking/relayout.ts +177 -0
- package/src/openviking/sidecar-main.ts +38 -0
- package/src/openviking/sidecar.ts +106 -0
- package/src/openviking/store.ts +2025 -0
- package/src/openviking/supervisor.ts +474 -0
- package/src/openviking/types.ts +174 -0
- package/src/operator-recovery.ts +237 -0
- package/src/operatormessage.ts +52 -0
- package/src/outcomes.ts +1670 -0
- package/src/piauthcopy.ts +12 -0
- package/src/picli.ts +49 -0
- package/src/picwd.ts +25 -0
- package/src/pilogin.ts +37 -0
- package/src/pingpong.ts +1339 -0
- package/src/posting-journal.ts +549 -0
- package/src/posting-outbox.ts +802 -0
- package/src/posting-receipt.ts +324 -0
- package/src/posting.ts +603 -0
- package/src/postops.ts +832 -0
- package/src/pr-activity.ts +20 -0
- package/src/pr-author.ts +6 -0
- package/src/pr-conversation.ts +432 -0
- package/src/pr-session-lifecycle.ts +631 -0
- package/src/preflight.ts +391 -0
- package/src/priorresolve.ts +343 -0
- package/src/qcrbin.ts +186 -0
- package/src/recheck.ts +1775 -0
- package/src/reconcile.ts +1030 -0
- package/src/recordedfindings.ts +27 -0
- package/src/regression.ts +833 -0
- package/src/replay.ts +816 -0
- package/src/requiredpasses.ts +148 -0
- package/src/resumecheck.ts +246 -0
- package/src/reviewcli.ts +787 -0
- package/src/reviewcontext.ts +35 -0
- package/src/reviewdetach.ts +734 -0
- package/src/reviewhistory.ts +427 -0
- package/src/reviewhome.ts +36 -0
- package/src/reviewmemory.ts +133 -0
- package/src/reviewrendercli.ts +139 -0
- package/src/round-work.ts +42 -0
- package/src/runhost.ts +108 -0
- package/src/scanauth.ts +36 -0
- package/src/scanbox.ts +1081 -0
- package/src/scancli.ts +1296 -0
- package/src/scandriverlog.ts +32 -0
- package/src/session-bridge.ts +139 -0
- package/src/session-command-payload.ts +35 -0
- package/src/session-command-supervisor.ts +1142 -0
- package/src/sessionjsonl.ts +88 -0
- package/src/sessions.ts +659 -0
- package/src/setup/adapters.ts +335 -0
- package/src/setup/baselines.ts +110 -0
- package/src/setup/caserunner.ts +611 -0
- package/src/setup/configcommit.ts +443 -0
- package/src/setup/engine.ts +1001 -0
- package/src/setup/facts.ts +627 -0
- package/src/setup/findings.ts +1264 -0
- package/src/setup/history.ts +443 -0
- package/src/setup/interview.ts +243 -0
- package/src/setup/ledger.ts +324 -0
- package/src/setup/pins.ts +66 -0
- package/src/setup/portrait.ts +635 -0
- package/src/setup/presentation.ts +18 -0
- package/src/setup/profile.ts +508 -0
- package/src/setup/replay.ts +370 -0
- package/src/setup/replaybuild.ts +392 -0
- package/src/setup/replayrun.ts +1096 -0
- package/src/setup/replaysource.ts +198 -0
- package/src/setup/retune.ts +415 -0
- package/src/setup/retunelog.ts +95 -0
- package/src/setup/scan.ts +1033 -0
- package/src/setup/scanbuild.ts +168 -0
- package/src/setup/scanfacts.ts +107 -0
- package/src/setup/scanlease.ts +297 -0
- package/src/setup/scanreport.ts +373 -0
- package/src/setup/scanrun.ts +2268 -0
- package/src/setup/scantree.ts +259 -0
- package/src/setup/stage2.ts +926 -0
- package/src/setup/stage2request.ts +347 -0
- package/src/setup/survey.ts +458 -0
- package/src/setup/surveyrun.ts +758 -0
- package/src/setup/textsniff.ts +95 -0
- package/src/setup/trackersink.ts +433 -0
- package/src/setup/triage.ts +768 -0
- package/src/setup/tuning.ts +766 -0
- package/src/setup/unitcut.ts +523 -0
- package/src/singleflight.ts +86 -0
- package/src/slack/announce.ts +911 -0
- package/src/slack/api.ts +303 -0
- package/src/slack/messages.ts +288 -0
- package/src/slack/origin.ts +599 -0
- package/src/slack/policy.ts +354 -0
- package/src/slack/reply.ts +201 -0
- package/src/slack/router.ts +285 -0
- package/src/slack/socket.ts +384 -0
- package/src/slack/types.ts +108 -0
- package/src/sqlitehint.ts +32 -0
- package/src/ssh-observation.ts +30 -0
- package/src/state.ts +8243 -0
- package/src/statemachine.ts +369 -0
- package/src/stubs.ts +68 -0
- package/src/takeover.ts +634 -0
- package/src/takeoverpi.ts +293 -0
- package/src/terminal-failure.ts +41 -0
- package/src/thread-resolution-refusal.ts +2 -0
- package/src/tmux.ts +153 -0
- package/src/trackerid.ts +41 -0
- package/src/transcriptindex.ts +665 -0
- package/src/tui/actions.ts +484 -0
- package/src/tui/alerts.ts +666 -0
- package/src/tui/app.ts +1979 -0
- package/src/tui/archiveops.ts +192 -0
- package/src/tui/autofile.ts +233 -0
- package/src/tui/board.ts +1432 -0
- package/src/tui/candidates.ts +889 -0
- package/src/tui/cellwidth.ts +148 -0
- package/src/tui/changelog-store.ts +503 -0
- package/src/tui/chord.ts +25 -0
- package/src/tui/clipboard.ts +92 -0
- package/src/tui/cmdbar.ts +296 -0
- package/src/tui/companion.ts +1427 -0
- package/src/tui/companionplan.ts +247 -0
- package/src/tui/companiontab.ts +83 -0
- package/src/tui/confighome.ts +155 -0
- package/src/tui/connect.ts +68 -0
- package/src/tui/deploy-ceremony.ts +104 -0
- package/src/tui/deploy-drain.ts +129 -0
- package/src/tui/deploy-process.ts +265 -0
- package/src/tui/deploytab.ts +1523 -0
- package/src/tui/design.ts +125 -0
- package/src/tui/digesttab.ts +214 -0
- package/src/tui/emptystate.ts +39 -0
- package/src/tui/evidence.ts +999 -0
- package/src/tui/feed.ts +604 -0
- package/src/tui/feedbacktab.ts +1077 -0
- package/src/tui/filters.ts +90 -0
- package/src/tui/ghread.ts +321 -0
- package/src/tui/historypull.ts +81 -0
- package/src/tui/inboxpull.ts +236 -0
- package/src/tui/index.ts +1565 -0
- package/src/tui/issuesink.ts +238 -0
- package/src/tui/jsonfile.ts +42 -0
- package/src/tui/keys.ts +109 -0
- package/src/tui/layout.ts +108 -0
- package/src/tui/learning.ts +627 -0
- package/src/tui/lessonswitch.ts +118 -0
- package/src/tui/livecontrol.ts +228 -0
- package/src/tui/memforward.ts +443 -0
- package/src/tui/memincident.ts +363 -0
- package/src/tui/memory.ts +620 -0
- package/src/tui/memtab.ts +606 -0
- package/src/tui/metricstab.ts +1177 -0
- package/src/tui/model.ts +131 -0
- package/src/tui/mouse.ts +98 -0
- package/src/tui/msg.ts +268 -0
- package/src/tui/nativecompanion.ts +417 -0
- package/src/tui/openurl.ts +21 -0
- package/src/tui/paint.ts +511 -0
- package/src/tui/panes.ts +98 -0
- package/src/tui/pipane.ts +219 -0
- package/src/tui/poll-hooks.ts +12 -0
- package/src/tui/poll.ts +147 -0
- package/src/tui/proposals.ts +838 -0
- package/src/tui/prpause.ts +150 -0
- package/src/tui/reload.ts +436 -0
- package/src/tui/remote.ts +413 -0
- package/src/tui/rollover.ts +969 -0
- package/src/tui/scantab.ts +1290 -0
- package/src/tui/setupentry.ts +93 -0
- package/src/tui/sidebar.ts +749 -0
- package/src/tui/sshtriage.ts +510 -0
- package/src/tui/startup.ts +231 -0
- package/src/tui/tabs.ts +45 -0
- package/src/tui/termmirror.ts +224 -0
- package/src/tui/theme.ts +95 -0
- package/src/tui/ticketstab.ts +310 -0
- package/src/tui/tmuxsocket.ts +129 -0
- package/src/tui/trackertickets.ts +84 -0
- package/src/tui/unwedge.ts +286 -0
- package/src/tui/viewer.ts +575 -0
- package/src/tui/viewerinput.ts +173 -0
- package/src/updatenotice.ts +178 -0
- package/src/usage.ts +1154 -0
- package/src/verdicts.ts +4689 -0
- package/src/version.ts +80 -0
- package/src/wakes.ts +476 -0
- package/src/webhook.ts +942 -0
- package/src/webhookheal.ts +276 -0
- package/src/wire.ts +2153 -0
- package/src/worker-session-host.ts +602 -0
- package/src/workerbreaker.ts +156 -0
- package/src/workercontrol.ts +104 -0
- package/src/workerlease.ts +441 -0
- package/src/workerliveness.ts +103 -0
- package/src/workerlog.ts +79 -0
- package/src/workerruntime.ts +30 -0
- package/src/workersupervisor.ts +1538 -0
- package/src/workerusage.ts +42 -0
- package/src/workerworld.ts +427 -0
- package/src/worktrees.ts +1138 -0
- package/tests/fixtures/regression-cases.json +795 -0
- package/tests/fixtures/seed-manifest.json +19 -0
|
@@ -0,0 +1,1966 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The companion's memory forward — host-owned, TUI-independent.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS
|
|
5
|
+
// ---------------
|
|
6
|
+
// The local pi companion must read and write memory that lives on the review VM,
|
|
7
|
+
// while holding no VM credential (the companion boundary). So the ssh runs
|
|
8
|
+
// here, on the operator's PC, as a systemd --user service the operator owns:
|
|
9
|
+
//
|
|
10
|
+
// companion container ──▶ 198.51.100.1:1933 (docker bridge `qcr-memory`)
|
|
11
|
+
// │ this process, on the host
|
|
12
|
+
// ▼
|
|
13
|
+
// ssh <vm-host> ──▶ sudo docker exec -i quickreview-memory
|
|
14
|
+
// ▼
|
|
15
|
+
// node stdio↔TCP bridge ──▶ 127.0.0.1:1933 (OpenViking)
|
|
16
|
+
//
|
|
17
|
+
// The target is the memory SIDECAR (docker/memory/): once the VM runs
|
|
18
|
+
// `[openviking] endpoint = "remote"` the server lives in that container, not
|
|
19
|
+
// in the daemon's. Rolling the VM back to `endpoint = "local"` moves OpenViking
|
|
20
|
+
// back into quickreview-sandbox, and the forward follows with
|
|
21
|
+
// `--container quickreview-sandbox` (or QCR_VM_CONTAINER).
|
|
22
|
+
//
|
|
23
|
+
// Two properties make it worth the machinery:
|
|
24
|
+
//
|
|
25
|
+
// * the container never sees an ssh key, an agent socket or a host known_hosts
|
|
26
|
+
// file — it sees one TCP endpoint on a bridge, and nothing else;
|
|
27
|
+
// * nothing on the VM is published. OpenViking's port is reachable only on
|
|
28
|
+
// the sidecar's container network and its own loopback; the last hop is a
|
|
29
|
+
// `docker exec` inside the container's netns, so no port is opened on the
|
|
30
|
+
// VM, on the VM's docker bridge, or anywhere reachable from the network.
|
|
31
|
+
//
|
|
32
|
+
// It is deliberately not part of the TUI: `qcr tui` starting and stopping is
|
|
33
|
+
// not an event memory should notice. systemd owns the process (Restart=always,
|
|
34
|
+
// enabled at login, `loginctl enable-linger` for boot), so the companion keeps
|
|
35
|
+
// its memory while the TUI is closed — which is most of the day.
|
|
36
|
+
//
|
|
37
|
+
// LOCAL MODE. On a one-machine (laptop) topology the daemon
|
|
38
|
+
// container runs on this host, so `--local` (or QCR_MEMORY_LOCAL=1, or an
|
|
39
|
+
// explicit empty --ssh-host) replaces the ssh hop with a plain local
|
|
40
|
+
// `docker exec` into the same container netns. Everything else — the
|
|
41
|
+
// `qcr-memory` bridge + loopback binds, the listener contract, `verify`,
|
|
42
|
+
// adopt-or-bind from the TUI — is identical: this file stays the one relay
|
|
43
|
+
// implementation for both topologies.
|
|
44
|
+
//
|
|
45
|
+
// RELAY CAP. Every relay is one ssh session on the forward's one
|
|
46
|
+
// shared connection, and sshd refuses sessions past its MaxSessions (10) —
|
|
47
|
+
// each refused relay then quietly opens a full ssh login of its own. So at
|
|
48
|
+
// most `--max-relays` (6) relays are open at once; a request beyond that
|
|
49
|
+
// waits its turn, a relay between requests hands its slot over, and a request
|
|
50
|
+
// that waits too long gets an honest 503 — see "the relay cap" below. The
|
|
51
|
+
// systemd unit and the TUI both parse through `parseArgs`, so both carry it.
|
|
52
|
+
//
|
|
53
|
+
// SUBCOMMANDS
|
|
54
|
+
// run the forwarder itself (what the systemd unit executes)
|
|
55
|
+
// install create the docker network + write/enable the user unit
|
|
56
|
+
// uninstall stop + disable the unit (network and keys left alone)
|
|
57
|
+
// status unit state, network, endpoint, the relay cap and the
|
|
58
|
+
// running forward's live counts, one live probe
|
|
59
|
+
// ensure-network idempotent docker network create (the unit's ExecStartPre)
|
|
60
|
+
// verify the two-key reach + confinement proof (see docs/)
|
|
61
|
+
//
|
|
62
|
+
// Zero dependencies, no repo build needed: systemd must be able to run this
|
|
63
|
+
// from a bare checkout at boot.
|
|
64
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
65
|
+
import {
|
|
66
|
+
existsSync,
|
|
67
|
+
mkdirSync,
|
|
68
|
+
readFileSync,
|
|
69
|
+
realpathSync,
|
|
70
|
+
renameSync,
|
|
71
|
+
unlinkSync,
|
|
72
|
+
writeFileSync,
|
|
73
|
+
} from "node:fs";
|
|
74
|
+
import { createServer } from "node:net";
|
|
75
|
+
import { homedir } from "node:os";
|
|
76
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
77
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
78
|
+
|
|
79
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
80
|
+
export const ROOT = resolve(HERE, "..");
|
|
81
|
+
|
|
82
|
+
// ---- defaults ---------------------------------------------------------------
|
|
83
|
+
// These mirror src/tui/companion.ts MEMORY.* — the forward and the container
|
|
84
|
+
// must agree on network, subnet and port or the companion talks to nothing.
|
|
85
|
+
// tests/companion-memory.test.ts fails on drift.
|
|
86
|
+
|
|
87
|
+
export const DEFAULTS = {
|
|
88
|
+
network: "qcr-memory",
|
|
89
|
+
subnet: "198.51.100.0/24",
|
|
90
|
+
gateway: "198.51.100.1",
|
|
91
|
+
port: 1933,
|
|
92
|
+
sshHost: "my-vm",
|
|
93
|
+
// The memory sidecar (deploy/ansible `memory_container_name`). Under
|
|
94
|
+
// `[openviking] endpoint = "local"` OpenViking is the daemon container's
|
|
95
|
+
// child instead: pass `--container quickreview-sandbox` / QCR_VM_CONTAINER.
|
|
96
|
+
container: "quickreview-memory",
|
|
97
|
+
// `--local` (a one-machine topology): the sidecar is opt-in there, so the
|
|
98
|
+
// default stays the daemon container until `--container` names another.
|
|
99
|
+
localContainer: "quickreview-sandbox",
|
|
100
|
+
remoteHost: "127.0.0.1",
|
|
101
|
+
remotePort: 1933,
|
|
102
|
+
sudo: true,
|
|
103
|
+
unit: "qcr-memory-forward",
|
|
104
|
+
// The relay cap. Every relay is one ssh session on the
|
|
105
|
+
// forward's one shared connection, and sshd refuses the session past its
|
|
106
|
+
// `MaxSessions` (10 on the VM, the OpenSSH default). 6 leaves room for
|
|
107
|
+
// relays still closing and for anything else that shares the ControlPath.
|
|
108
|
+
maxRelays: 6,
|
|
109
|
+
/** Requests that may wait for a relay; one more is refused at once. */
|
|
110
|
+
queueMax: 64,
|
|
111
|
+
/** How long one request may wait for a relay before it is refused. */
|
|
112
|
+
queueTimeoutS: 15,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** OpenSSH's default sshd `MaxSessions`: session channels per connection.
|
|
116
|
+
* A stock VM runs it (`sshd -T | grep maxsessions` → 10). */
|
|
117
|
+
export const SSHD_DEFAULT_MAX_SESSIONS = 10;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The forward's own ssh control socket. Its relays multiplex
|
|
121
|
+
* over one master connection, and every relay is a session on it; a path of
|
|
122
|
+
* its own keeps the operator's interactive ssh — whose `~/.ssh/config`
|
|
123
|
+
* ControlPath is a different file — off that connection's session budget.
|
|
124
|
+
* `%C` (a hash of the local host, remote host, port and user) keeps the path
|
|
125
|
+
* short enough for a unix socket, and a name no stock ssh config shares.
|
|
126
|
+
*/
|
|
127
|
+
export function defaultControlPath(home = homedir()) {
|
|
128
|
+
return join(home, ".ssh", "qcr-memfwd-%C");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The keys file (0600, two user keys) — the install locator's rule
|
|
133
|
+
* (`locateKeysFile` in src/install.ts), the same path
|
|
134
|
+
* src/tui/companion.ts reads: `QCR_OPENVIKING_KEYS`, else the operator home
|
|
135
|
+
* `$XDG_CONFIG_HOME/qcr-operator/<install>/` (the old
|
|
136
|
+
* `~/.config/quickreview-bot/` copy is never read). Mirrored rather than
|
|
137
|
+
* imported because this script runs from a checkout with no dist/;
|
|
138
|
+
* tests/companion-memory.test.ts runs both over the same temp homes.
|
|
139
|
+
*/
|
|
140
|
+
export function keysFilePath(env = process.env, home = homedir()) {
|
|
141
|
+
const override = (env.QCR_OPENVIKING_KEYS ?? "").trim();
|
|
142
|
+
if (override !== "") return override;
|
|
143
|
+
const install = (env.QCR_INSTALL ?? "").trim() || "default";
|
|
144
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(install) || install.includes("..")) {
|
|
145
|
+
throw new Error(`QCR_INSTALL ${JSON.stringify(install)} is not a plain name`);
|
|
146
|
+
}
|
|
147
|
+
const xdg = (env.XDG_CONFIG_HOME ?? "").trim();
|
|
148
|
+
const base = xdg !== "" && isAbsolute(xdg) ? xdg : join(home, ".config");
|
|
149
|
+
return join(base, "qcr-operator", install, "openviking-keys.env");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- the remote half --------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The stdio↔TCP bridge that runs INSIDE the VM container. It is a node
|
|
156
|
+
* one-liner because node is the one interpreter the quickreview image is
|
|
157
|
+
* guaranteed to have (it runs pi); `nc` is not installed there.
|
|
158
|
+
*
|
|
159
|
+
* Single quotes are forbidden in this string: the whole program is passed to
|
|
160
|
+
* the remote shell inside single quotes.
|
|
161
|
+
*/
|
|
162
|
+
export function remoteBridgeJs(host, port) {
|
|
163
|
+
if (/'/.test(host)) throw new Error("remote host must not contain a quote");
|
|
164
|
+
return (
|
|
165
|
+
`const n=require("net");const s=n.connect(${Number(port)},"${host}");` +
|
|
166
|
+
`s.on("error",()=>process.exit(1));s.on("close",()=>process.exit(0));` +
|
|
167
|
+
`process.stdin.on("end",()=>s.end());process.stdin.pipe(s);s.pipe(process.stdout);`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `ssh …` argv for ONE relayed connection. Pure — the test asserts it. */
|
|
172
|
+
export function relayArgv(cfg) {
|
|
173
|
+
const docker = cfg.sudo ? "sudo docker" : "docker";
|
|
174
|
+
const remote = `${docker} exec -i ${cfg.container} node -e '${remoteBridgeJs(cfg.remoteHost, cfg.remotePort)}'`;
|
|
175
|
+
return [
|
|
176
|
+
"-q",
|
|
177
|
+
"-T",
|
|
178
|
+
"-o",
|
|
179
|
+
"BatchMode=yes",
|
|
180
|
+
"-o",
|
|
181
|
+
"ConnectTimeout=10",
|
|
182
|
+
"-o",
|
|
183
|
+
"ServerAliveInterval=30",
|
|
184
|
+
"-o",
|
|
185
|
+
"ServerAliveCountMax=3",
|
|
186
|
+
// A shared control connection turns a per-request ssh handshake into a
|
|
187
|
+
// channel open on an existing socket. Without it every HTTP connection
|
|
188
|
+
// would pay a full login. Each relay is one session on that connection,
|
|
189
|
+
// and sshd caps sessions per connection (`MaxSessions`) — which is what
|
|
190
|
+
// `createForwarder`'s relay cap stays under.
|
|
191
|
+
"-o",
|
|
192
|
+
"ControlMaster=auto",
|
|
193
|
+
"-o",
|
|
194
|
+
`ControlPath=${cfg.controlPath}`,
|
|
195
|
+
"-o",
|
|
196
|
+
"ControlPersist=4h",
|
|
197
|
+
...(cfg.identity ? ["-i", cfg.identity] : []),
|
|
198
|
+
cfg.sshHost,
|
|
199
|
+
remote,
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- repeat-failure collapse -------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The likely cause of a relay failure, in the operator's terms, with the exact
|
|
207
|
+
* command that fixes it — the sentence a bare `ssh` exit 255 never says.
|
|
208
|
+
*/
|
|
209
|
+
export function relayFailureCause(cfg, text, deps = {}) {
|
|
210
|
+
const s = String(text ?? "");
|
|
211
|
+
// The docker check comes first and is not interchangeable with the ssh one:
|
|
212
|
+
// "permission denied while trying to connect to the Docker daemon" also
|
|
213
|
+
// matches /permission denied/, and answering it with `ssh-add` would send the
|
|
214
|
+
// operator hunting for a key problem that does not exist.
|
|
215
|
+
if (
|
|
216
|
+
/permission denied while trying to connect to the docker daemon|cannot connect to the docker daemon|docker: command not found/i.test(
|
|
217
|
+
s,
|
|
218
|
+
)
|
|
219
|
+
) {
|
|
220
|
+
// …and the docker in question is the VM's, not this laptop's: the relay is
|
|
221
|
+
// `ssh <host> sudo docker exec …`, so a local `systemctl status docker`
|
|
222
|
+
// would point at the wrong machine. With `sudo docker` the usual fault is
|
|
223
|
+
// the sudo/group grant, not the unit being down.
|
|
224
|
+
const where = cfg.local ? "the local docker daemon" : `docker ON ${cfg.sshHost}`;
|
|
225
|
+
return {
|
|
226
|
+
key: "docker",
|
|
227
|
+
cause: `${where} refused the exec`,
|
|
228
|
+
remedy: cfg.local ? "docker ps" : `ssh ${cfg.sshHost} ${cfg.sudo ? "sudo " : ""}docker ps`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (/permission denied|publickey|too many authentication failures|agent refused/i.test(s)) {
|
|
232
|
+
// The key, derived the same way `src/tui/sshtriage.ts` derives it — from
|
|
233
|
+
// `ssh -G <host>`, never guessed: a hard-coded path would make the remedy
|
|
234
|
+
// a confidently wrong exact command.
|
|
235
|
+
const key = cfg.identity ?? sshIdentityFor(cfg, deps);
|
|
236
|
+
return {
|
|
237
|
+
key: "auth",
|
|
238
|
+
cause: `ssh to ${cfg.sshHost} has no usable key — the agent is empty or does not hold this host's key`,
|
|
239
|
+
remedy: `ssh-add ${key}`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
/could not resolve|name or service not known|no route to host|network is unreachable|connection timed out|operation timed out/i.test(
|
|
244
|
+
s,
|
|
245
|
+
)
|
|
246
|
+
) {
|
|
247
|
+
return {
|
|
248
|
+
key: "host",
|
|
249
|
+
cause: `${cfg.sshHost} did not answer`,
|
|
250
|
+
remedy: `ssh -v ${cfg.sshHost} true`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// There is deliberately no cause for sshd refusing a session past its
|
|
254
|
+
// MaxSessions: the mux client does not fail on that —
|
|
255
|
+
// it gives up on the shared connection and opens a full ssh login of its
|
|
256
|
+
// own, and under `-q` it says neither. Nothing reaches stderr to match; the
|
|
257
|
+
// relay cap is what keeps relays inside the shared connection.
|
|
258
|
+
if (/control socket|mux_client|multiplexing/i.test(s)) {
|
|
259
|
+
// The forward's master lives at its own ControlPath, so a bare
|
|
260
|
+
// `ssh -O exit <host>` would address the operator's master (the one in
|
|
261
|
+
// ~/.ssh/config) and leave the wedged one standing.
|
|
262
|
+
return {
|
|
263
|
+
key: "mux",
|
|
264
|
+
cause: `the forward's own ssh ControlMaster for ${cfg.sshHost}${cfg.controlPath ? ` (${cfg.controlPath})` : ""} is wedged`,
|
|
265
|
+
remedy: cfg.controlPath
|
|
266
|
+
? `ssh -o 'ControlPath=${cfg.controlPath}' -O exit ${cfg.sshHost}`
|
|
267
|
+
: `ssh -O exit ${cfg.sshHost}`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
key: "unknown",
|
|
272
|
+
cause: cfg.local
|
|
273
|
+
? `the local relay to ${cfg.container} exited without connecting`
|
|
274
|
+
: `the relay to ${cfg.container} on ${cfg.sshHost} exited without connecting`,
|
|
275
|
+
remedy: cfg.local ? "docker ps" : `ssh -v ${cfg.sshHost} true`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Cache: `ssh -G` is local and cheap, but this runs on a failure path that can
|
|
280
|
+
* fire once per connection. */
|
|
281
|
+
let identityCache;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The key ssh would actually offer for this host, from `ssh -G` — the same
|
|
285
|
+
* source `src/tui/sshtriage.ts` uses. Local only (no network). Falls back to
|
|
286
|
+
* the conventional GCP metadata key when ssh tells us nothing, and is never
|
|
287
|
+
* consulted in `--local` mode, where there is no ssh at all.
|
|
288
|
+
*/
|
|
289
|
+
export function sshIdentityFor(cfg, deps = {}) {
|
|
290
|
+
if (cfg.local) return "~/.ssh/id_ed25519";
|
|
291
|
+
if (identityCache !== undefined && deps.run === undefined) return identityCache;
|
|
292
|
+
const runner = deps.run ?? ((bin, args) => run(bin, args));
|
|
293
|
+
const home = deps.home ?? homedir();
|
|
294
|
+
const fileExists = deps.exists ?? existsSync;
|
|
295
|
+
let picked = "";
|
|
296
|
+
try {
|
|
297
|
+
const res = runner("ssh", ["-G", cfg.sshHost]);
|
|
298
|
+
const files = String(res.stdout ?? "")
|
|
299
|
+
.split("\n")
|
|
300
|
+
.map((l) => l.trim())
|
|
301
|
+
.filter((l) => /^identityfile /i.test(l))
|
|
302
|
+
.map((l) => l.slice("identityfile ".length).trim())
|
|
303
|
+
.map((f) => (f.startsWith("~/") ? join(home, f.slice(2)) : f));
|
|
304
|
+
picked = files.find((f) => fileExists(f)) ?? files[0] ?? "";
|
|
305
|
+
} catch {
|
|
306
|
+
picked = "";
|
|
307
|
+
}
|
|
308
|
+
const answer = picked || join(home, ".ssh", "google_compute_engine");
|
|
309
|
+
if (deps.run === undefined) identityCache = answer;
|
|
310
|
+
return answer;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** How long one cause stays collapsed before it is worth saying again. */
|
|
314
|
+
export const FAILURE_REPEAT_MS = 60_000;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Collapses a repeating relay failure into one actionable line, rate-limited,
|
|
318
|
+
* and says so out loud when it recovers.
|
|
319
|
+
*
|
|
320
|
+
* Every HTTP connection the companion or the TUI makes can spawn a relay, so
|
|
321
|
+
* one missing agent key would otherwise print thousands of `relay #N exited
|
|
322
|
+
* 255` lines — a symptom, repeated, with no cause and no remedy. One line per
|
|
323
|
+
* cause per window, a count of what was suppressed, and a `recovered` line
|
|
324
|
+
* that proves the self-healing actually happened: nothing here needs a restart, the next
|
|
325
|
+
* successful relay clears the state on its own.
|
|
326
|
+
*
|
|
327
|
+
* @param {{ log?: (line: string) => void, now?: () => number, windowMs?: number }} [opts]
|
|
328
|
+
* `log` is declared here rather than defaulted on purpose: it has no
|
|
329
|
+
* sensible default (a collapser that swallows its own output is worse than
|
|
330
|
+
* one that throws), but without the annotation TypeScript infers the option
|
|
331
|
+
* bag from the defaulted keys only and `tsconfig.tests.json` then rejects
|
|
332
|
+
* every caller that passes it.
|
|
333
|
+
*/
|
|
334
|
+
export function createFailureCollapser({
|
|
335
|
+
log,
|
|
336
|
+
now = () => Date.now(),
|
|
337
|
+
windowMs = FAILURE_REPEAT_MS,
|
|
338
|
+
} = {}) {
|
|
339
|
+
let key = null;
|
|
340
|
+
let lastAt = 0;
|
|
341
|
+
// Suppressed since the last printed line, and suppressed since the last
|
|
342
|
+
// recovery. Two counters because they answer different questions: with one,
|
|
343
|
+
// a cause change would reset it and a recovery could report "0 suppressed"
|
|
344
|
+
// after hiding several failures.
|
|
345
|
+
let suppressedSincePrint = 0;
|
|
346
|
+
let suppressedTotal = 0;
|
|
347
|
+
let total = 0;
|
|
348
|
+
const emit = (cause, t) => {
|
|
349
|
+
const hidden = suppressedSincePrint;
|
|
350
|
+
const sameCause = cause.key === key;
|
|
351
|
+
const ago = Math.round((t - lastAt) / 1000);
|
|
352
|
+
const repeat =
|
|
353
|
+
hidden > 0
|
|
354
|
+
? ` (${hidden} similar failure${hidden === 1 ? "" : "s"} suppressed${sameCause ? ` in the last ${ago}s` : ""})`
|
|
355
|
+
: "";
|
|
356
|
+
key = cause.key;
|
|
357
|
+
lastAt = t;
|
|
358
|
+
suppressedSincePrint = 0;
|
|
359
|
+
log(`relay failing: ${cause.cause} — fix: ${cause.remedy}${repeat}`);
|
|
360
|
+
};
|
|
361
|
+
return {
|
|
362
|
+
fail(cause) {
|
|
363
|
+
total += 1;
|
|
364
|
+
const t = now();
|
|
365
|
+
if (cause.key === key && t - lastAt < windowMs) {
|
|
366
|
+
suppressedSincePrint += 1;
|
|
367
|
+
suppressedTotal += 1;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
emit(cause, t);
|
|
371
|
+
},
|
|
372
|
+
ok() {
|
|
373
|
+
if (key === null) return;
|
|
374
|
+
const hidden = suppressedTotal;
|
|
375
|
+
const seen = total;
|
|
376
|
+
key = null;
|
|
377
|
+
suppressedSincePrint = 0;
|
|
378
|
+
suppressedTotal = 0;
|
|
379
|
+
total = 0;
|
|
380
|
+
log(
|
|
381
|
+
`relay recovered — the forward is working again (${seen} failure${seen === 1 ? "" : "s"}, ${hidden} suppressed)`,
|
|
382
|
+
);
|
|
383
|
+
},
|
|
384
|
+
/** Test handle: what is currently collapsed. */
|
|
385
|
+
state: () => ({ key, suppressed: suppressedSincePrint, hidden: suppressedTotal, total }),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---- the forwarder ----------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Local relay argv (`--local`): the same stdio↔TCP bridge, reached by
|
|
393
|
+
* a plain `docker exec` on this machine — the daemon container runs here, so
|
|
394
|
+
* there is no ssh hop. The bridge program rides as a real argv element (no
|
|
395
|
+
* shell re-parse, so no quote wrapper); `--no-sudo` drops the sudo prefix.
|
|
396
|
+
*/
|
|
397
|
+
export function localRelayArgv(cfg) {
|
|
398
|
+
return [
|
|
399
|
+
...(cfg.sudo ? ["sudo"] : []),
|
|
400
|
+
"docker",
|
|
401
|
+
"exec",
|
|
402
|
+
"-i",
|
|
403
|
+
cfg.container,
|
|
404
|
+
"node",
|
|
405
|
+
"-e",
|
|
406
|
+
remoteBridgeJs(cfg.remoteHost, cfg.remotePort),
|
|
407
|
+
];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The full relay command, binary first — the transport decision in one place.
|
|
411
|
+
* Pure; tests assert both shapes. */
|
|
412
|
+
export function relaySpawnCommand(cfg) {
|
|
413
|
+
return cfg.local ? localRelayArgv(cfg) : ["ssh", ...relayArgv(cfg)];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Spawn one relay child. Injectable so the test drives streams, not ssh. */
|
|
417
|
+
export function realRelaySpawner(cfg) {
|
|
418
|
+
const cmd = relaySpawnCommand(cfg);
|
|
419
|
+
return () => spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "pipe", "pipe"] });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---- the relay cap ----------------------------------------------------------
|
|
423
|
+
//
|
|
424
|
+
// WHY. Every relay — one `ssh … docker exec …` — is one session on the
|
|
425
|
+
// forward's one shared ssh connection. sshd allows `MaxSessions` (10) of
|
|
426
|
+
// those per connection and refuses the rest. A refused relay does not stop
|
|
427
|
+
// there: the mux client gives up on the shared connection and opens a full
|
|
428
|
+
// ssh login of its own through the bastion, saying nothing under `-q`, and on
|
|
429
|
+
// the VM those extra logins fail (docs/memory-forward-connection-research.md).
|
|
430
|
+
// Uncapped, a burst — the store's 8-way read sweep beside the TUI's polls and
|
|
431
|
+
// the companion — has its extra connections closed under it, and the callers
|
|
432
|
+
// see `fetch failed`.
|
|
433
|
+
//
|
|
434
|
+
// THE CAP. At most `maxRelays` relays are open at once, counted from spawn to
|
|
435
|
+
// the child's exit (a killed relay still holds its session until ssh is gone).
|
|
436
|
+
// A connection that has a request to send and no relay waits in a FIFO queue
|
|
437
|
+
// (`queueMax` deep); a request that waits `queueTimeoutMs` — or arrives at a
|
|
438
|
+
// full queue — gets an HTTP 503 in OpenViking's own error envelope and a clean
|
|
439
|
+
// close, never a hang and never a bare reset.
|
|
440
|
+
//
|
|
441
|
+
// WHY A QUEUE ALONE IS NOT ENOUGH. Every client here keeps its connections
|
|
442
|
+
// alive between requests (undici, browsers). A connection holding a relay and
|
|
443
|
+
// looping on it never closes, so a queue that only advanced on a close would
|
|
444
|
+
// starve whoever waited behind a busy sweep. So a relay yields: when its
|
|
445
|
+
// connection is between requests and somebody is waiting, the relay is
|
|
446
|
+
// stopped and its slot goes to the head of the queue; the connection itself
|
|
447
|
+
// stays open, and its next request waits its turn for a new relay. "Between
|
|
448
|
+
// requests" is read off the bytes by `createExchangeTracker` — a whole
|
|
449
|
+
// request, then a whole Content-Length response, and nothing since — never
|
|
450
|
+
// guessed from a quiet line. A connection that is not plain request/response
|
|
451
|
+
// HTTP/1.1 (a websocket, a chunked or close-delimited body, a pipelined
|
|
452
|
+
// request) is never stopped: it keeps its relay to the end.
|
|
453
|
+
//
|
|
454
|
+
// A relay that yields has served its connection for `minLeaseMs` (so a hot
|
|
455
|
+
// loop pays one relay start per second, not one per request), or has been
|
|
456
|
+
// quiet for `idleYieldMs` (an idle keep-alive connection, which may never send
|
|
457
|
+
// again). No bytes ever cross from one connection's relay to another's: a
|
|
458
|
+
// yielded relay is killed, and the next request starts a fresh one.
|
|
459
|
+
|
|
460
|
+
/** Internal timings. `opts.timings` overrides them (tests). */
|
|
461
|
+
export const RELAY_TIMINGS = {
|
|
462
|
+
/** A relay serving a busy connection yields at a request boundary only
|
|
463
|
+
* after this long — bounds the cost of fairness to one relay start a second. */
|
|
464
|
+
minLeaseMs: 1_000,
|
|
465
|
+
/** …or as soon as its connection has been idle (between requests) this long. */
|
|
466
|
+
idleYieldMs: 200,
|
|
467
|
+
/** SIGTERM → SIGKILL, and the slot is counted free at the SIGKILL. */
|
|
468
|
+
killGraceMs: 3_000,
|
|
469
|
+
/** While anyone waits, how often idle relays are looked for. */
|
|
470
|
+
rebalanceMs: 100,
|
|
471
|
+
/** A refused connection: how long its client gets to read the 503. */
|
|
472
|
+
refuseLingerMs: 2_000,
|
|
473
|
+
/** A relay that exited mid-exchange: how long to wait for its last bytes. */
|
|
474
|
+
relayCloseGraceMs: 1_000,
|
|
475
|
+
/** A waiting connection's held bytes past which its socket is paused. */
|
|
476
|
+
holdLimitBytes: 256 * 1024,
|
|
477
|
+
/** At most one stats-file write per this window. */
|
|
478
|
+
statsEveryMs: 500,
|
|
479
|
+
/** One "queueing" line per window, however many bursts. */
|
|
480
|
+
logWindowMs: 60_000,
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The cap, the queue depth and the wait, off `cfg` (`parseArgs` fills all
|
|
485
|
+
* three — so the systemd unit and the TUI, which both parse through it, carry
|
|
486
|
+
* the same cap; a cfg built by hand falls back to the defaults).
|
|
487
|
+
*/
|
|
488
|
+
export function forwardLimits(cfg) {
|
|
489
|
+
return {
|
|
490
|
+
maxRelays: cfg?.maxRelays ?? DEFAULTS.maxRelays,
|
|
491
|
+
queueMax: cfg?.queueMax ?? DEFAULTS.queueMax,
|
|
492
|
+
queueTimeoutMs: cfg?.queueTimeoutMs ?? DEFAULTS.queueTimeoutS * 1000,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Longest message head the tracker reads before it calls the stream opaque. */
|
|
497
|
+
const MAX_HEAD_BYTES = 64 * 1024;
|
|
498
|
+
|
|
499
|
+
/** `name: value` lines → lowercase name → values; null when malformed. */
|
|
500
|
+
function parseHeaderLines(lines) {
|
|
501
|
+
const headers = new Map();
|
|
502
|
+
for (const line of lines) {
|
|
503
|
+
const i = line.indexOf(":");
|
|
504
|
+
if (i <= 0 || /^[ \t]/.test(line)) return null; // obs-fold or garbage
|
|
505
|
+
const name = line.slice(0, i).trim().toLowerCase();
|
|
506
|
+
const list = headers.get(name) ?? [];
|
|
507
|
+
list.push(line.slice(i + 1).trim());
|
|
508
|
+
headers.set(name, list);
|
|
509
|
+
}
|
|
510
|
+
return headers;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** A body length the bytes declare: a number, `undefined` when nothing is
|
|
514
|
+
* declared, `null` when it is chunked, malformed or contradictory. */
|
|
515
|
+
function declaredLength(headers) {
|
|
516
|
+
if (headers.has("transfer-encoding")) return null;
|
|
517
|
+
const values = headers.get("content-length");
|
|
518
|
+
if (!values) return undefined;
|
|
519
|
+
const all = new Set(values.flatMap((v) => v.split(",")).map((v) => v.trim()));
|
|
520
|
+
if (all.size !== 1) return null;
|
|
521
|
+
const [only] = [...all];
|
|
522
|
+
return /^\d{1,15}$/.test(only) ? Number(only) : null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Where one connection's HTTP/1.1 exchange stands, read off its bytes — just
|
|
527
|
+
* enough to know when its relay is BETWEEN requests and may be handed over.
|
|
528
|
+
*
|
|
529
|
+
* It frames only what it can frame exactly: a request (Content-Length body or
|
|
530
|
+
* none) and its response (a Content-Length body; none for HEAD, 204, 304).
|
|
531
|
+
* Anything else — chunked or close-delimited bodies, a 1xx or 101 upgrade, an
|
|
532
|
+
* `Upgrade` or `Expect` request, a second request sent before the first
|
|
533
|
+
* response came back, a server speaking unasked, a head over 64 KiB — makes
|
|
534
|
+
* the stream OPAQUE for good, and an opaque connection is never stopped. The
|
|
535
|
+
* tracker never changes a byte; it only watches.
|
|
536
|
+
*/
|
|
537
|
+
export function createExchangeTracker() {
|
|
538
|
+
/** idle | request-head | request-body | response-wait | response-head |
|
|
539
|
+
* response-body | opaque */
|
|
540
|
+
let phase = "idle";
|
|
541
|
+
let head = "";
|
|
542
|
+
let remaining = 0;
|
|
543
|
+
let method = "";
|
|
544
|
+
let exchanges = 0;
|
|
545
|
+
|
|
546
|
+
const opaque = () => {
|
|
547
|
+
phase = "opaque";
|
|
548
|
+
head = "";
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
/** Consume head bytes from `off`; the offset past the head once it is
|
|
552
|
+
* whole (after `onHead`), else the end of the buffer. */
|
|
553
|
+
const takeHead = (buf, off, onHead) => {
|
|
554
|
+
const window = buf.toString("latin1", off, Math.min(buf.length, off + MAX_HEAD_BYTES + 4));
|
|
555
|
+
const combined = head + window;
|
|
556
|
+
const end = combined.indexOf("\r\n\r\n", Math.max(0, head.length - 3));
|
|
557
|
+
if (end === -1) {
|
|
558
|
+
head = combined;
|
|
559
|
+
if (head.length > MAX_HEAD_BYTES) opaque();
|
|
560
|
+
return buf.length;
|
|
561
|
+
}
|
|
562
|
+
const consumed = end + 4 - head.length;
|
|
563
|
+
const text = combined.slice(0, end);
|
|
564
|
+
head = "";
|
|
565
|
+
onHead(text);
|
|
566
|
+
return off + consumed;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const startRequest = (text) => {
|
|
570
|
+
const lines = text.split("\r\n");
|
|
571
|
+
const m = /^([A-Z]+) \S+ HTTP\/1\.[01]$/.exec(lines[0] ?? "");
|
|
572
|
+
const headers = parseHeaderLines(lines.slice(1));
|
|
573
|
+
if (!m || !headers || headers.has("upgrade") || headers.has("expect")) return opaque();
|
|
574
|
+
const len = declaredLength(headers);
|
|
575
|
+
if (len === null) return opaque();
|
|
576
|
+
method = m[1];
|
|
577
|
+
remaining = len ?? 0; // a request that declares nothing has no body
|
|
578
|
+
phase = remaining > 0 ? "request-body" : "response-wait";
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const startResponse = (text) => {
|
|
582
|
+
const lines = text.split("\r\n");
|
|
583
|
+
const m = /^HTTP\/1\.[01] (\d{3})(?: |$)/.exec(lines[0] ?? "");
|
|
584
|
+
const headers = parseHeaderLines(lines.slice(1));
|
|
585
|
+
if (!m || !headers) return opaque();
|
|
586
|
+
const status = Number(m[1]);
|
|
587
|
+
if (status < 200) return opaque(); // 1xx, 101 upgrade: not one request, one answer
|
|
588
|
+
let len = 0;
|
|
589
|
+
if (method !== "HEAD" && status !== 204 && status !== 304) {
|
|
590
|
+
const declared = declaredLength(headers);
|
|
591
|
+
// No length is a body that ends at the close: nothing marks its end.
|
|
592
|
+
if (declared === null || declared === undefined) return opaque();
|
|
593
|
+
len = declared;
|
|
594
|
+
}
|
|
595
|
+
remaining = len;
|
|
596
|
+
if (remaining > 0) phase = "response-body";
|
|
597
|
+
else {
|
|
598
|
+
phase = "idle";
|
|
599
|
+
exchanges += 1;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
/** Bytes the client sent. */
|
|
605
|
+
client(buf) {
|
|
606
|
+
let off = 0;
|
|
607
|
+
while (off < buf.length) {
|
|
608
|
+
if (phase === "opaque") return;
|
|
609
|
+
if (phase === "idle") {
|
|
610
|
+
phase = "request-head";
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (phase === "request-head") {
|
|
614
|
+
off = takeHead(buf, off, startRequest);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (phase === "request-body") {
|
|
618
|
+
const n = Math.min(remaining, buf.length - off);
|
|
619
|
+
remaining -= n;
|
|
620
|
+
off += n;
|
|
621
|
+
if (remaining === 0) phase = "response-wait";
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
// Client bytes while a response is owed: pipelining, or a protocol
|
|
625
|
+
// that talks both ways at once. Either way, no boundary to trust.
|
|
626
|
+
return opaque();
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
/** Bytes the server (the relay) sent. */
|
|
630
|
+
server(buf) {
|
|
631
|
+
let off = 0;
|
|
632
|
+
while (off < buf.length) {
|
|
633
|
+
if (phase === "opaque") return;
|
|
634
|
+
if (phase === "response-wait") {
|
|
635
|
+
phase = "response-head";
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (phase === "response-head") {
|
|
639
|
+
off = takeHead(buf, off, startResponse);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (phase === "response-body") {
|
|
643
|
+
const n = Math.min(remaining, buf.length - off);
|
|
644
|
+
remaining -= n;
|
|
645
|
+
off += n;
|
|
646
|
+
if (remaining === 0) {
|
|
647
|
+
phase = "idle";
|
|
648
|
+
exchanges += 1;
|
|
649
|
+
}
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
// The server spoke unasked, or before the request was whole.
|
|
653
|
+
return opaque();
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
/** Nothing in flight: no request begun, or the last response is whole. */
|
|
657
|
+
atRest: () => phase === "idle",
|
|
658
|
+
opaque: () => phase === "opaque",
|
|
659
|
+
phase: () => phase,
|
|
660
|
+
exchanges: () => exchanges,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The answer a refused request gets: an HTTP 503 in OpenViking's own
|
|
666
|
+
* `{status, error}` envelope, so a caller's client reports WHY ("the memory
|
|
667
|
+
* forward is busy …") where a bare close read as `fetch failed`.
|
|
668
|
+
*/
|
|
669
|
+
export function busyResponse(message) {
|
|
670
|
+
const body = JSON.stringify({ status: "error", error: { code: "FORWARD_BUSY", message } });
|
|
671
|
+
return (
|
|
672
|
+
"HTTP/1.1 503 Service Unavailable\r\n" +
|
|
673
|
+
"Content-Type: application/json\r\n" +
|
|
674
|
+
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
|
|
675
|
+
"Retry-After: 1\r\n" +
|
|
676
|
+
"Connection: close\r\n" +
|
|
677
|
+
"\r\n" +
|
|
678
|
+
body
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const seconds = (ms) => `${Math.round(ms / 100) / 10}s`;
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The listener. One TCP server per bind address; every connection that has
|
|
686
|
+
* a request to send gets a relay child — at most `maxRelays` at once (above) —
|
|
687
|
+
* and either side closing tears the pair down.
|
|
688
|
+
*
|
|
689
|
+
* Failure is always local and always survivable: a relay that cannot start
|
|
690
|
+
* closes that one connection (the extension sees a connection error, degrades,
|
|
691
|
+
* and the in-container watcher alerts) — the forwarder keeps listening.
|
|
692
|
+
*
|
|
693
|
+
* @param {Record<string, any>} cfg
|
|
694
|
+
* @param {{
|
|
695
|
+
* log?: (line: string) => void,
|
|
696
|
+
* spawnRelay?: () => any,
|
|
697
|
+
* now?: () => number,
|
|
698
|
+
* collapser?: { fail: (cause: { key: string, cause: string, remedy: string }) => void, ok: () => void },
|
|
699
|
+
* timings?: Partial<typeof RELAY_TIMINGS>,
|
|
700
|
+
* }} [opts]
|
|
701
|
+
*/
|
|
702
|
+
export function createForwarder(cfg, opts = {}) {
|
|
703
|
+
const log = opts.log ?? ((line) => console.log(`[memory-forward] ${line}`));
|
|
704
|
+
const spawnRelay = opts.spawnRelay ?? realRelaySpawner(cfg);
|
|
705
|
+
const now = opts.now ?? (() => Date.now());
|
|
706
|
+
const limits = forwardLimits(cfg);
|
|
707
|
+
const t = { ...RELAY_TIMINGS, ...(opts.timings ?? {}) };
|
|
708
|
+
const servers = [];
|
|
709
|
+
// Every accepted connection not yet closed. Tracked so close() can tear
|
|
710
|
+
// them down: `server.close()` only stops accepting, and each relay is an ssh
|
|
711
|
+
// + a remote docker exec, so a caller that stops the forwarder and keeps
|
|
712
|
+
// running would strand one process trio per open connection. That caller
|
|
713
|
+
// exists — the TUI owns a forward for as long as it is up
|
|
714
|
+
// (src/tui/memforward.ts).
|
|
715
|
+
const conns = new Set();
|
|
716
|
+
/** Connections with a request waiting for a relay, oldest first. */
|
|
717
|
+
let queue = [];
|
|
718
|
+
/** Relay children spawned and not yet exited: the sessions on the master. */
|
|
719
|
+
let open = 0;
|
|
720
|
+
/** Of those, the ones already told to stop — slots on their way back. */
|
|
721
|
+
let retiring = 0;
|
|
722
|
+
let closed = false;
|
|
723
|
+
let ticker = null;
|
|
724
|
+
const counters = {
|
|
725
|
+
accepted: 0,
|
|
726
|
+
/** Requests that ever had to wait (the live count is `queued`). */
|
|
727
|
+
enqueued: 0,
|
|
728
|
+
servedFromQueue: 0,
|
|
729
|
+
yielded: 0,
|
|
730
|
+
refusedTimeout: 0,
|
|
731
|
+
refusedFull: 0,
|
|
732
|
+
longestWaitMs: 0,
|
|
733
|
+
peakOpen: 0,
|
|
734
|
+
peakQueued: 0,
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
// One actionable line per cause, not one symptom line per relay.
|
|
738
|
+
const collapser =
|
|
739
|
+
opts.collapser ?? createFailureCollapser({ log, ...(opts.now ? { now: opts.now } : {}) });
|
|
740
|
+
|
|
741
|
+
// ---- what the operator reads ------------------------------------------
|
|
742
|
+
let episode = false;
|
|
743
|
+
let lastQueueLogAt = Number.NEGATIVE_INFINITY;
|
|
744
|
+
let lastRefusalLogAt = Number.NEGATIVE_INFINITY;
|
|
745
|
+
let summaryOwed = false;
|
|
746
|
+
let summaryBase = { ...counters };
|
|
747
|
+
let longestSinceSummary = 0;
|
|
748
|
+
const startEpisode = () => {
|
|
749
|
+
if (episode) return;
|
|
750
|
+
episode = true;
|
|
751
|
+
const at = now();
|
|
752
|
+
if (at - lastQueueLogAt < t.logWindowMs) return;
|
|
753
|
+
lastQueueLogAt = at;
|
|
754
|
+
summaryOwed = true;
|
|
755
|
+
log(
|
|
756
|
+
`relay cap reached: ${open}/${limits.maxRelays} relays open — requests wait (up to ${limits.queueMax}, ${seconds(limits.queueTimeoutMs)} each) and a relay between requests hands its slot over`,
|
|
757
|
+
);
|
|
758
|
+
};
|
|
759
|
+
const endEpisode = () => {
|
|
760
|
+
if (!episode) return;
|
|
761
|
+
episode = false;
|
|
762
|
+
if (!summaryOwed) return;
|
|
763
|
+
summaryOwed = false;
|
|
764
|
+
const d = (k) => counters[k] - summaryBase[k];
|
|
765
|
+
const refused = d("refusedTimeout") + d("refusedFull");
|
|
766
|
+
log(
|
|
767
|
+
`relay queue drained: ${d("servedFromQueue")} request(s) waited for a relay (longest ${longestSinceSummary}ms), ${d("yielded")} relay(s) handed over between requests, ${refused} refused`,
|
|
768
|
+
);
|
|
769
|
+
summaryBase = { ...counters };
|
|
770
|
+
longestSinceSummary = 0;
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// ---- stats: the `status` subcommand's live view -------------------------
|
|
774
|
+
let statsTimer = null;
|
|
775
|
+
let statsWritten = false;
|
|
776
|
+
const stats = () => {
|
|
777
|
+
let leased = 0;
|
|
778
|
+
let unyielding = 0;
|
|
779
|
+
for (const c of conns) {
|
|
780
|
+
if (!c.lease) continue;
|
|
781
|
+
leased += 1;
|
|
782
|
+
if (c.tracker.opaque()) unyielding += 1;
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
785
|
+
...counters,
|
|
786
|
+
maxRelays: limits.maxRelays,
|
|
787
|
+
queueMax: limits.queueMax,
|
|
788
|
+
queueTimeoutMs: limits.queueTimeoutMs,
|
|
789
|
+
open,
|
|
790
|
+
leased,
|
|
791
|
+
unyielding,
|
|
792
|
+
queued: queue.length,
|
|
793
|
+
connections: conns.size,
|
|
794
|
+
};
|
|
795
|
+
};
|
|
796
|
+
const writeStats = () => {
|
|
797
|
+
statsTimer = null;
|
|
798
|
+
if (closed || !cfg.statsFile) return;
|
|
799
|
+
const snapshot = {
|
|
800
|
+
pid: process.pid,
|
|
801
|
+
port: cfg.port,
|
|
802
|
+
transport: cfg.local ? "local docker" : cfg.sshHost,
|
|
803
|
+
...(cfg.local ? {} : { controlPath: cfg.controlPath }),
|
|
804
|
+
updatedAt: new Date(now()).toISOString(),
|
|
805
|
+
...stats(),
|
|
806
|
+
};
|
|
807
|
+
try {
|
|
808
|
+
const tmp = `${cfg.statsFile}.${process.pid}.tmp`;
|
|
809
|
+
writeFileSync(tmp, `${JSON.stringify(snapshot)}\n`, { mode: 0o600 });
|
|
810
|
+
renameSync(tmp, cfg.statsFile);
|
|
811
|
+
statsWritten = true;
|
|
812
|
+
} catch (err) {
|
|
813
|
+
if (!statsWritten) log(`cannot write the stats file ${cfg.statsFile}: ${errText(err)}`);
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
const scheduleStats = () => {
|
|
817
|
+
if (closed || !cfg.statsFile || statsTimer) return;
|
|
818
|
+
statsTimer = setTimeout(writeStats, t.statsEveryMs);
|
|
819
|
+
statsTimer.unref?.();
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
// ---- the slots ------------------------------------------------------------
|
|
823
|
+
const startTicker = () => {
|
|
824
|
+
if (ticker || closed) return;
|
|
825
|
+
ticker = setInterval(rebalance, t.rebalanceMs);
|
|
826
|
+
ticker.unref?.();
|
|
827
|
+
};
|
|
828
|
+
const stopTicker = () => {
|
|
829
|
+
if (ticker) clearInterval(ticker);
|
|
830
|
+
ticker = null;
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
/** Stop a relay. Its slot is free once the child has EXITED — the ssh
|
|
834
|
+
* session is gone then — or at the SIGKILL, whichever comes first. */
|
|
835
|
+
const retire = (lease) => {
|
|
836
|
+
if (lease.retiring) return;
|
|
837
|
+
lease.retiring = true;
|
|
838
|
+
if (lease.released) return;
|
|
839
|
+
retiring += 1;
|
|
840
|
+
try {
|
|
841
|
+
lease.child.kill();
|
|
842
|
+
} catch {
|
|
843
|
+
/* already gone */
|
|
844
|
+
}
|
|
845
|
+
lease.killTimer = setTimeout(() => {
|
|
846
|
+
try {
|
|
847
|
+
lease.child.kill("SIGKILL");
|
|
848
|
+
} catch {
|
|
849
|
+
/* already gone */
|
|
850
|
+
}
|
|
851
|
+
lease.release();
|
|
852
|
+
}, t.killGraceMs);
|
|
853
|
+
lease.killTimer.unref?.();
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
const dequeue = (conn) => {
|
|
857
|
+
if (!conn.queued) return;
|
|
858
|
+
conn.queued = false;
|
|
859
|
+
if (conn.queueTimer) clearTimeout(conn.queueTimer);
|
|
860
|
+
conn.queueTimer = null;
|
|
861
|
+
queue = queue.filter((c) => c !== conn);
|
|
862
|
+
if (queue.length === 0) {
|
|
863
|
+
stopTicker();
|
|
864
|
+
endEpisode();
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
const pump = () => {
|
|
869
|
+
if (closed) return;
|
|
870
|
+
while (queue.length > 0 && open < limits.maxRelays) {
|
|
871
|
+
const conn = queue.shift();
|
|
872
|
+
conn.queued = false;
|
|
873
|
+
if (conn.queueTimer) clearTimeout(conn.queueTimer);
|
|
874
|
+
conn.queueTimer = null;
|
|
875
|
+
const waited = now() - conn.queuedAt;
|
|
876
|
+
counters.servedFromQueue += 1;
|
|
877
|
+
counters.longestWaitMs = Math.max(counters.longestWaitMs, waited);
|
|
878
|
+
longestSinceSummary = Math.max(longestSinceSummary, waited);
|
|
879
|
+
grant(conn);
|
|
880
|
+
}
|
|
881
|
+
if (queue.length === 0) {
|
|
882
|
+
stopTicker();
|
|
883
|
+
endEpisode();
|
|
884
|
+
} else rebalance();
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
/** Hand waiters the slots of relays that are between requests: the
|
|
888
|
+
* longest-quiet first, and only as many as are still needed. */
|
|
889
|
+
function rebalance() {
|
|
890
|
+
if (closed || queue.length === 0) return stopTicker();
|
|
891
|
+
const at = now();
|
|
892
|
+
const ready = [...conns]
|
|
893
|
+
.filter(
|
|
894
|
+
(c) =>
|
|
895
|
+
c.lease &&
|
|
896
|
+
!c.lease.retiring &&
|
|
897
|
+
!c.lease.released &&
|
|
898
|
+
c.tracker.atRest() &&
|
|
899
|
+
(at - c.lease.startedAt >= t.minLeaseMs || at - c.lastActive >= t.idleYieldMs),
|
|
900
|
+
)
|
|
901
|
+
.sort((a, b) => a.lastActive - b.lastActive);
|
|
902
|
+
for (const c of ready) {
|
|
903
|
+
if (queue.length <= retiring) break;
|
|
904
|
+
park(c);
|
|
905
|
+
}
|
|
906
|
+
startTicker();
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** A connection that just lost its relay between requests reads again. A
|
|
910
|
+
* pause the old relay's full stdin set would otherwise stand for good: its
|
|
911
|
+
* lift is that child's 'drain', which never comes from a child that is gone,
|
|
912
|
+
* so the next request would be neither read nor queued. The hold limit's
|
|
913
|
+
* own pause (`heldBytes` over it) is left standing. */
|
|
914
|
+
const resumeAfterRelay = (conn) => {
|
|
915
|
+
if (conn.socket.isPaused?.() && conn.heldBytes <= t.holdLimitBytes) conn.socket.resume();
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
/** Take a connection's relay away BETWEEN requests. The connection stays
|
|
919
|
+
* open; its next request asks for a relay again. */
|
|
920
|
+
const park = (conn) => {
|
|
921
|
+
const lease = conn.lease;
|
|
922
|
+
conn.lease = null;
|
|
923
|
+
counters.yielded += 1;
|
|
924
|
+
retire(lease);
|
|
925
|
+
resumeAfterRelay(conn);
|
|
926
|
+
scheduleStats();
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
/** A connection with bytes to send and no relay asks for one. */
|
|
930
|
+
const wantRelay = (conn) => {
|
|
931
|
+
if (conn.queued || conn.closed || conn.refused || conn.finishing || conn.lease) return;
|
|
932
|
+
if (queue.length === 0 && open < limits.maxRelays) return grant(conn);
|
|
933
|
+
if (queue.length >= limits.queueMax) return refuse(conn, "full");
|
|
934
|
+
conn.queued = true;
|
|
935
|
+
conn.queuedAt = now();
|
|
936
|
+
queue.push(conn);
|
|
937
|
+
counters.enqueued += 1;
|
|
938
|
+
counters.peakQueued = Math.max(counters.peakQueued, queue.length);
|
|
939
|
+
conn.queueTimer = setTimeout(() => {
|
|
940
|
+
if (!conn.queued) return;
|
|
941
|
+
refuse(conn, "timeout");
|
|
942
|
+
}, limits.queueTimeoutMs);
|
|
943
|
+
conn.queueTimer.unref?.();
|
|
944
|
+
startEpisode();
|
|
945
|
+
rebalance();
|
|
946
|
+
scheduleStats();
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
/** A clean "no": the 503 envelope, a FIN, and the rest of the request read
|
|
950
|
+
* and dropped — closing with unread bytes would RESET the answer away. */
|
|
951
|
+
const refuse = (conn, why) => {
|
|
952
|
+
if (conn.refused || conn.closed) return;
|
|
953
|
+
conn.refused = true;
|
|
954
|
+
if (why === "full") counters.refusedFull += 1;
|
|
955
|
+
else counters.refusedTimeout += 1;
|
|
956
|
+
dequeue(conn);
|
|
957
|
+
const state = `${open}/${limits.maxRelays} relays open, ${queue.length} waiting`;
|
|
958
|
+
const message =
|
|
959
|
+
why === "full"
|
|
960
|
+
? `the memory forward's wait queue is full (${state}) — retry shortly`
|
|
961
|
+
: `the memory forward is at its relay cap and this request waited ${seconds(limits.queueTimeoutMs)} for a relay (${state}) — retry shortly`;
|
|
962
|
+
const at = now();
|
|
963
|
+
if (at - lastRefusalLogAt >= t.logWindowMs) {
|
|
964
|
+
lastRefusalLogAt = at;
|
|
965
|
+
log(
|
|
966
|
+
`refused a request — ${why === "full" ? `the wait queue is full (${limits.queueMax})` : `it waited ${seconds(limits.queueTimeoutMs)} for a relay`}; ${state} (more refusals are counted, not logged, for a minute)`,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
conn.held = [];
|
|
970
|
+
conn.heldBytes = 0;
|
|
971
|
+
try {
|
|
972
|
+
if (!conn.tracker.opaque() && conn.socket.writable) conn.socket.write(busyResponse(message));
|
|
973
|
+
conn.socket.end();
|
|
974
|
+
} catch {
|
|
975
|
+
/* the client is already gone */
|
|
976
|
+
}
|
|
977
|
+
if (conn.socket.isPaused?.()) conn.socket.resume();
|
|
978
|
+
conn.lingerTimer = setTimeout(() => closeConn(conn), t.refuseLingerMs);
|
|
979
|
+
conn.lingerTimer.unref?.();
|
|
980
|
+
scheduleStats();
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const closeConn = (conn) => {
|
|
984
|
+
if (conn.closed) return;
|
|
985
|
+
conn.closed = true;
|
|
986
|
+
conns.delete(conn);
|
|
987
|
+
dequeue(conn);
|
|
988
|
+
for (const k of ["lingerTimer", "finishTimer"]) if (conn[k]) clearTimeout(conn[k]);
|
|
989
|
+
if (conn.lease) {
|
|
990
|
+
retire(conn.lease);
|
|
991
|
+
conn.lease = null;
|
|
992
|
+
}
|
|
993
|
+
conn.socket.destroy();
|
|
994
|
+
scheduleStats();
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const writeToRelay = (conn, lease, chunk) => {
|
|
998
|
+
let ok = true;
|
|
999
|
+
try {
|
|
1000
|
+
ok = lease.child.stdin.write(chunk);
|
|
1001
|
+
} catch {
|
|
1002
|
+
return; // a dying relay; its exit settles the connection
|
|
1003
|
+
}
|
|
1004
|
+
if (ok) return;
|
|
1005
|
+
conn.socket.pause();
|
|
1006
|
+
lease.child.stdin.once?.("drain", () => {
|
|
1007
|
+
if (conn.lease === lease && !conn.closed) conn.socket.resume();
|
|
1008
|
+
});
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
/** The current relay ended. Between requests (the server closed an idle
|
|
1012
|
+
* keep-alive connection) the connection simply lives on without one;
|
|
1013
|
+
* mid-exchange the client must see the loss, so its side ends too — after
|
|
1014
|
+
* the relay's last bytes have reached it. */
|
|
1015
|
+
const relayEnded = (conn, lease) => {
|
|
1016
|
+
if (conn.closed || conn.lease !== lease) return;
|
|
1017
|
+
if (conn.tracker.atRest()) {
|
|
1018
|
+
conn.lease = null;
|
|
1019
|
+
resumeAfterRelay(conn);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
conn.finishing = true;
|
|
1023
|
+
const finish = () => {
|
|
1024
|
+
if (conn.closed || conn.lease !== lease) return;
|
|
1025
|
+
conn.lease = null;
|
|
1026
|
+
try {
|
|
1027
|
+
conn.socket.end();
|
|
1028
|
+
} catch {
|
|
1029
|
+
/* already gone */
|
|
1030
|
+
}
|
|
1031
|
+
conn.lingerTimer = setTimeout(() => closeConn(conn), t.refuseLingerMs);
|
|
1032
|
+
conn.lingerTimer.unref?.();
|
|
1033
|
+
};
|
|
1034
|
+
if (lease.stdioClosed) return finish();
|
|
1035
|
+
lease.onStdioClosed = finish;
|
|
1036
|
+
conn.finishTimer = setTimeout(finish, t.relayCloseGraceMs);
|
|
1037
|
+
conn.finishTimer.unref?.();
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
const onRelayData = (conn, lease, chunk) => {
|
|
1041
|
+
if (conn.closed || conn.lease !== lease) return; // a stopped relay's late bytes go nowhere
|
|
1042
|
+
// The first byte back from the container is the proof that the whole chain
|
|
1043
|
+
// works, so it is what clears a collapsed failure — self-recovery with no
|
|
1044
|
+
// restart.
|
|
1045
|
+
if (!lease.moved) {
|
|
1046
|
+
lease.moved = true;
|
|
1047
|
+
collapser.ok();
|
|
1048
|
+
}
|
|
1049
|
+
conn.lastActive = now();
|
|
1050
|
+
conn.tracker.server(chunk);
|
|
1051
|
+
if (!conn.socket.write(chunk)) {
|
|
1052
|
+
lease.child.stdout.pause?.();
|
|
1053
|
+
conn.socket.once("drain", () => lease.child.stdout.resume?.());
|
|
1054
|
+
}
|
|
1055
|
+
if (queue.length > 0 && conn.tracker.atRest()) rebalance();
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
function grant(conn) {
|
|
1059
|
+
let child;
|
|
1060
|
+
try {
|
|
1061
|
+
child = spawnRelay();
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
collapser.fail(relayFailureCause(cfg, errText(err)));
|
|
1064
|
+
closeConn(conn);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
open += 1;
|
|
1068
|
+
counters.peakOpen = Math.max(counters.peakOpen, open);
|
|
1069
|
+
const lease = {
|
|
1070
|
+
child,
|
|
1071
|
+
startedAt: now(),
|
|
1072
|
+
moved: false,
|
|
1073
|
+
// Evidence for the cause: ssh writes the reason to stderr and then exits
|
|
1074
|
+
// 255, so the exit code alone can never name it.
|
|
1075
|
+
stderrText: "",
|
|
1076
|
+
retiring: false,
|
|
1077
|
+
released: false,
|
|
1078
|
+
stdioClosed: false,
|
|
1079
|
+
onStdioClosed: null,
|
|
1080
|
+
killTimer: null,
|
|
1081
|
+
release: () => {},
|
|
1082
|
+
};
|
|
1083
|
+
lease.release = () => {
|
|
1084
|
+
if (lease.released) return;
|
|
1085
|
+
lease.released = true;
|
|
1086
|
+
open -= 1;
|
|
1087
|
+
if (lease.retiring) retiring -= 1;
|
|
1088
|
+
if (lease.killTimer) clearTimeout(lease.killTimer);
|
|
1089
|
+
pump();
|
|
1090
|
+
scheduleStats();
|
|
1091
|
+
};
|
|
1092
|
+
conn.lease = lease;
|
|
1093
|
+
conn.lastActive = now();
|
|
1094
|
+
child.on?.("error", (err) => {
|
|
1095
|
+
if (!lease.retiring) collapser.fail(relayFailureCause(cfg, errText(err)));
|
|
1096
|
+
lease.release();
|
|
1097
|
+
if (conn.lease === lease) {
|
|
1098
|
+
conn.lease = null;
|
|
1099
|
+
closeConn(conn);
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
child.on?.("exit", (code) => {
|
|
1103
|
+
// A relay that carried bytes and then exited is a closed connection, not
|
|
1104
|
+
// a failure — only one that never moved anything is worth a cause. One
|
|
1105
|
+
// WE stopped is neither.
|
|
1106
|
+
if (code && !lease.moved && !lease.retiring)
|
|
1107
|
+
collapser.fail(relayFailureCause(cfg, lease.stderrText));
|
|
1108
|
+
lease.release();
|
|
1109
|
+
relayEnded(conn, lease);
|
|
1110
|
+
});
|
|
1111
|
+
child.on?.("close", () => {
|
|
1112
|
+
lease.stdioClosed = true;
|
|
1113
|
+
lease.onStdioClosed?.();
|
|
1114
|
+
});
|
|
1115
|
+
child.stderr?.on?.("data", (d) => {
|
|
1116
|
+
lease.stderrText = `${lease.stderrText}${String(d)}`.slice(-2000);
|
|
1117
|
+
});
|
|
1118
|
+
// An EPIPE on a dying relay surfaces through its exit, never as a throw.
|
|
1119
|
+
child.stdin?.on?.("error", () => {});
|
|
1120
|
+
child.stdout?.on?.("error", () => {});
|
|
1121
|
+
child.stdout.on("data", (d) => onRelayData(conn, lease, d));
|
|
1122
|
+
const held = conn.held;
|
|
1123
|
+
conn.held = [];
|
|
1124
|
+
conn.heldBytes = 0;
|
|
1125
|
+
// Lift the hold limit's pause BEFORE the flush, so a pause the flush
|
|
1126
|
+
// itself sets (the relay's stdin full) stands. Resuming only flows on the
|
|
1127
|
+
// next tick, after the held bytes are written: the order holds.
|
|
1128
|
+
if (conn.socket.isPaused?.()) conn.socket.resume();
|
|
1129
|
+
for (const chunk of held) writeToRelay(conn, lease, chunk);
|
|
1130
|
+
if (conn.clientEnded) {
|
|
1131
|
+
try {
|
|
1132
|
+
child.stdin.end();
|
|
1133
|
+
} catch {
|
|
1134
|
+
/* already gone */
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
scheduleStats();
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const onConnection = (socket) => {
|
|
1141
|
+
if (closed) {
|
|
1142
|
+
socket.destroy();
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
counters.accepted += 1;
|
|
1146
|
+
const conn = {
|
|
1147
|
+
socket,
|
|
1148
|
+
tracker: createExchangeTracker(),
|
|
1149
|
+
lease: null,
|
|
1150
|
+
held: [],
|
|
1151
|
+
heldBytes: 0,
|
|
1152
|
+
queued: false,
|
|
1153
|
+
queuedAt: 0,
|
|
1154
|
+
queueTimer: null,
|
|
1155
|
+
lingerTimer: null,
|
|
1156
|
+
finishTimer: null,
|
|
1157
|
+
lastActive: now(),
|
|
1158
|
+
refused: false,
|
|
1159
|
+
finishing: false,
|
|
1160
|
+
clientEnded: false,
|
|
1161
|
+
closed: false,
|
|
1162
|
+
};
|
|
1163
|
+
conns.add(conn);
|
|
1164
|
+
socket.on("data", (chunk) => {
|
|
1165
|
+
if (conn.closed || conn.refused || conn.finishing) return; // read and dropped
|
|
1166
|
+
conn.lastActive = now();
|
|
1167
|
+
conn.tracker.client(chunk);
|
|
1168
|
+
const lease = conn.lease;
|
|
1169
|
+
if (lease && !lease.retiring) {
|
|
1170
|
+
writeToRelay(conn, lease, chunk);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
conn.held.push(chunk);
|
|
1174
|
+
conn.heldBytes += chunk.length;
|
|
1175
|
+
if (conn.heldBytes > t.holdLimitBytes) socket.pause();
|
|
1176
|
+
wantRelay(conn);
|
|
1177
|
+
});
|
|
1178
|
+
socket.on("end", () => {
|
|
1179
|
+
conn.clientEnded = true;
|
|
1180
|
+
if (conn.lease && !conn.lease.retiring) {
|
|
1181
|
+
try {
|
|
1182
|
+
conn.lease.child.stdin.end();
|
|
1183
|
+
} catch {
|
|
1184
|
+
/* already gone */
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
socket.on("error", () => closeConn(conn));
|
|
1189
|
+
socket.on("close", () => closeConn(conn));
|
|
1190
|
+
// A free slot is taken at once; with none free, nothing is owed until the
|
|
1191
|
+
// client actually asks.
|
|
1192
|
+
if (queue.length === 0 && open < limits.maxRelays) grant(conn);
|
|
1193
|
+
else scheduleStats();
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
return {
|
|
1197
|
+
async listen() {
|
|
1198
|
+
for (const address of cfg.binds) {
|
|
1199
|
+
const server = createServer(onConnection);
|
|
1200
|
+
servers.push(server);
|
|
1201
|
+
await new Promise((res, rej) => {
|
|
1202
|
+
server.once("error", rej);
|
|
1203
|
+
server.listen(cfg.port, address, () => res(undefined));
|
|
1204
|
+
}).catch((err) => {
|
|
1205
|
+
// A bind that fails (gateway not up yet) must not take the loopback
|
|
1206
|
+
// listener down with it — systemd restarts us either way.
|
|
1207
|
+
log(`cannot bind ${address}:${cfg.port}: ${errText(err)}`);
|
|
1208
|
+
});
|
|
1209
|
+
const addr = server.address();
|
|
1210
|
+
if (addr)
|
|
1211
|
+
log(
|
|
1212
|
+
`listening on ${addr.address}:${addr.port} → ${cfg.local ? "local docker" : cfg.sshHost} ${cfg.container} ${cfg.remoteHost}:${cfg.remotePort} — at most ${limits.maxRelays} relays at once, up to ${limits.queueMax} requests wait ${seconds(limits.queueTimeoutMs)}`,
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
const bound = servers.filter((s) => s.address() !== null).length;
|
|
1216
|
+
if (bound > 0 && !cfg.local && limits.maxRelays >= SSHD_DEFAULT_MAX_SESSIONS)
|
|
1217
|
+
log(
|
|
1218
|
+
`WARNING: --max-relays ${limits.maxRelays} is not under sshd's default MaxSessions (${SSHD_DEFAULT_MAX_SESSIONS}) — a relay past the server's limit is refused on the shared connection and quietly opens a full ssh login of its own, which can fail; check \`ssh ${cfg.sshHost} sudo sshd -T | grep -i maxsessions\``,
|
|
1219
|
+
);
|
|
1220
|
+
if (bound > 0) writeStats();
|
|
1221
|
+
return bound;
|
|
1222
|
+
},
|
|
1223
|
+
/** Stop accepting AND tear down what is still in flight. Both halves are
|
|
1224
|
+
* required: without the second, a still-running caller leaks one ssh +
|
|
1225
|
+
* remote node per open connection. */
|
|
1226
|
+
close() {
|
|
1227
|
+
for (const s of servers) s.close();
|
|
1228
|
+
closed = true;
|
|
1229
|
+
stopTicker();
|
|
1230
|
+
if (statsTimer) clearTimeout(statsTimer);
|
|
1231
|
+
statsTimer = null;
|
|
1232
|
+
for (const conn of [...conns]) {
|
|
1233
|
+
if (conn.queueTimer) clearTimeout(conn.queueTimer);
|
|
1234
|
+
conn.queued = false;
|
|
1235
|
+
closeConn(conn);
|
|
1236
|
+
}
|
|
1237
|
+
queue = [];
|
|
1238
|
+
if (statsWritten && cfg.statsFile) {
|
|
1239
|
+
// Only OUR file: another forward may have taken the port since.
|
|
1240
|
+
try {
|
|
1241
|
+
const mine = JSON.parse(readFileSync(cfg.statsFile, "utf8")).pid === process.pid;
|
|
1242
|
+
if (mine) unlinkSync(cfg.statsFile);
|
|
1243
|
+
} catch {
|
|
1244
|
+
/* gone already, or not ours to read */
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
},
|
|
1248
|
+
/** Connections holding a relay — the test's handle on the teardown above. */
|
|
1249
|
+
liveRelays: () => [...conns].filter((c) => c.lease).length,
|
|
1250
|
+
/** The cap, the queue and what they have done. */
|
|
1251
|
+
stats,
|
|
1252
|
+
/** Bound ports (the test asks for the ephemeral one). */
|
|
1253
|
+
addresses: () => servers.map((s) => s.address()).filter(Boolean),
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Where the running forward writes its live counts for `status`:
|
|
1259
|
+
* `$XDG_RUNTIME_DIR/qcr-memory-forward-<port>.json` — the same directory the
|
|
1260
|
+
* systemd unit's `%t` names, so the CLI and the unit agree. No runtime dir, no
|
|
1261
|
+
* file: `status` then says the counts are unknown rather than guessing.
|
|
1262
|
+
*/
|
|
1263
|
+
export function statsFilePath(env = process.env, port = DEFAULTS.port) {
|
|
1264
|
+
const dir = (env.XDG_RUNTIME_DIR ?? "").trim();
|
|
1265
|
+
if (dir === "" || !isAbsolute(dir)) return null;
|
|
1266
|
+
return join(dir, `qcr-memory-forward-${port}.json`);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function pidAlive(pid) {
|
|
1270
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1271
|
+
try {
|
|
1272
|
+
process.kill(pid, 0);
|
|
1273
|
+
return true;
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
return err?.code === "EPERM";
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Read the running forward's stats file: `live`, `stale` (its pid is gone),
|
|
1281
|
+
* `absent`, `unreadable`, or `off` (no path) — never a guessed zero.
|
|
1282
|
+
*
|
|
1283
|
+
* @param {string | null | undefined} path
|
|
1284
|
+
* @param {{ read?: (p: string) => string, alive?: (pid: number) => boolean }} [deps]
|
|
1285
|
+
*/
|
|
1286
|
+
export function readForwardStats(path, deps = {}) {
|
|
1287
|
+
if (!path) return { state: "off" };
|
|
1288
|
+
let text;
|
|
1289
|
+
try {
|
|
1290
|
+
text = (deps.read ?? ((p) => readFileSync(p, "utf8")))(path);
|
|
1291
|
+
} catch (err) {
|
|
1292
|
+
return err?.code === "ENOENT"
|
|
1293
|
+
? { state: "absent" }
|
|
1294
|
+
: { state: "unreadable", error: errText(err) };
|
|
1295
|
+
}
|
|
1296
|
+
let snapshot;
|
|
1297
|
+
try {
|
|
1298
|
+
snapshot = JSON.parse(text);
|
|
1299
|
+
} catch {
|
|
1300
|
+
return { state: "unreadable", error: "not JSON" };
|
|
1301
|
+
}
|
|
1302
|
+
if (!snapshot || typeof snapshot !== "object")
|
|
1303
|
+
return { state: "unreadable", error: "not a stats object" };
|
|
1304
|
+
return { state: (deps.alive ?? pidAlive)(snapshot.pid) ? "live" : "stale", snapshot };
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** What `status` prints about the cap — the running forward's own numbers
|
|
1308
|
+
* when it is live, this command's settings otherwise, never a made-up count. */
|
|
1309
|
+
export function describeForwardStats(cfg, read) {
|
|
1310
|
+
const lim = forwardLimits(cfg);
|
|
1311
|
+
const capLine = (s, whose) =>
|
|
1312
|
+
`relay cap${whose}: ${s.maxRelays} at once (sshd's default MaxSessions is ${SSHD_DEFAULT_MAX_SESSIONS}); up to ${s.queueMax} requests wait ${seconds(s.queueTimeoutMs)} each`;
|
|
1313
|
+
if (read.state !== "live") {
|
|
1314
|
+
const why = {
|
|
1315
|
+
off: "no stats file (XDG_RUNTIME_DIR is not set)",
|
|
1316
|
+
absent: `no forward has written ${cfg.statsFile} (none running on this port, or one too old to write a stats file)`,
|
|
1317
|
+
unreadable: `${cfg.statsFile} is unreadable (${read.error})`,
|
|
1318
|
+
stale: `${cfg.statsFile} names pid ${read.snapshot?.pid}, which is not running`,
|
|
1319
|
+
}[read.state];
|
|
1320
|
+
return [capLine(lim, " (this command's settings)"), `live relays: unknown — ${why}`];
|
|
1321
|
+
}
|
|
1322
|
+
const s = read.snapshot;
|
|
1323
|
+
const lines = [capLine(s, "")];
|
|
1324
|
+
lines.push(
|
|
1325
|
+
`live relays (pid ${s.pid}, as of ${s.updatedAt}): ${s.open}/${s.maxRelays} open, ${s.queued} waiting, ${s.unyielding ?? 0} held by connections that cannot hand over (peak ${s.peakOpen} open, ${s.peakQueued} waiting)`,
|
|
1326
|
+
);
|
|
1327
|
+
lines.push(
|
|
1328
|
+
`since start: ${s.servedFromQueue} request(s) waited for a relay (longest ${s.longestWaitMs}ms), ${s.yielded} relay(s) handed over between requests, refused ${s.refusedTimeout} on timeout and ${s.refusedFull} on a full queue`,
|
|
1329
|
+
);
|
|
1330
|
+
if (s.maxRelays !== lim.maxRelays)
|
|
1331
|
+
lines.push(
|
|
1332
|
+
`(this command would use ${lim.maxRelays}; the running forward was started with other settings)`,
|
|
1333
|
+
);
|
|
1334
|
+
return lines;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// ---- systemd ----------------------------------------------------------------
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* SSH_AUTH_SOCK for the unit. A path under $XDG_RUNTIME_DIR is rewritten to
|
|
1341
|
+
* systemd's `%t` so it survives a reboot; a per-session /tmp agent path cannot
|
|
1342
|
+
* survive one and is reported as such by the caller.
|
|
1343
|
+
*/
|
|
1344
|
+
export function unitAuthSock(env = process.env) {
|
|
1345
|
+
const sock = (env.SSH_AUTH_SOCK ?? "").trim();
|
|
1346
|
+
if (sock === "") return { value: "", stable: false, why: "no SSH_AUTH_SOCK in this shell" };
|
|
1347
|
+
const runtime = (env.XDG_RUNTIME_DIR ?? "").trim();
|
|
1348
|
+
if (runtime !== "" && sock.startsWith(`${runtime}/`)) {
|
|
1349
|
+
return { value: `%t/${sock.slice(runtime.length + 1)}`, stable: true, why: "" };
|
|
1350
|
+
}
|
|
1351
|
+
return {
|
|
1352
|
+
value: sock,
|
|
1353
|
+
stable: false,
|
|
1354
|
+
why: "the agent socket path looks per-session; prefer --identity <key> or a keyring agent",
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** The systemd --user unit. Pure so the test can read it. */
|
|
1359
|
+
export function unitText(cfg, env = process.env) {
|
|
1360
|
+
const arg = (value) => {
|
|
1361
|
+
const text = String(value).replaceAll("$", "$$");
|
|
1362
|
+
return /[\s"\\]/.test(text) ? JSON.stringify(text) : text;
|
|
1363
|
+
};
|
|
1364
|
+
const pathArg = (value) => arg(String(value).replaceAll("%", "%%"));
|
|
1365
|
+
const script = join(ROOT, "scripts", "companion-memory-forward.mjs");
|
|
1366
|
+
const auth = unitAuthSock(env);
|
|
1367
|
+
const runArgs =
|
|
1368
|
+
[
|
|
1369
|
+
"run",
|
|
1370
|
+
...(cfg.local ? ["--local"] : ["--ssh-host", cfg.sshHost]),
|
|
1371
|
+
"--container",
|
|
1372
|
+
cfg.container,
|
|
1373
|
+
"--port",
|
|
1374
|
+
String(cfg.port),
|
|
1375
|
+
"--gateway",
|
|
1376
|
+
cfg.gateway,
|
|
1377
|
+
...(cfg.sudo ? [] : ["--no-sudo"]),
|
|
1378
|
+
...(cfg.identity ? ["--identity", cfg.identity] : []),
|
|
1379
|
+
"--remote-host",
|
|
1380
|
+
cfg.remoteHost,
|
|
1381
|
+
"--remote-port",
|
|
1382
|
+
String(cfg.remotePort),
|
|
1383
|
+
"--bind",
|
|
1384
|
+
cfg.binds.join(","),
|
|
1385
|
+
// The cap and the control socket ride the unit only when they
|
|
1386
|
+
// differ from the defaults, so a later default reaches an installed unit.
|
|
1387
|
+
]
|
|
1388
|
+
.map(pathArg)
|
|
1389
|
+
.join(" ") +
|
|
1390
|
+
unitLimitArgs(cfg)
|
|
1391
|
+
.map((value) => ` ${arg(value)}`)
|
|
1392
|
+
.join("");
|
|
1393
|
+
// Name the transport truthfully — a local unit that claimed to
|
|
1394
|
+
// forward "OpenViking on <ssh host>" is the line an operator reads first in
|
|
1395
|
+
// `systemctl --user status` when the laptop route misbehaves.
|
|
1396
|
+
const where = cfg.local ? "local docker" : cfg.sshHost;
|
|
1397
|
+
return `[Unit]
|
|
1398
|
+
Description=quickreview companion memory forward (OpenViking on ${where} -> ${cfg.gateway}:${cfg.port})
|
|
1399
|
+
Documentation=file://${join(ROOT, "docs", "companion-memory-access.md")}
|
|
1400
|
+
After=default.target
|
|
1401
|
+
|
|
1402
|
+
[Service]
|
|
1403
|
+
Type=simple
|
|
1404
|
+
${auth.value === "" ? "" : `Environment=SSH_AUTH_SOCK=${auth.value}\n`}ExecStartPre=${pathArg(process.execPath)} ${pathArg(script)} ensure-network --network ${arg(cfg.network)} --subnet ${arg(cfg.subnet)} --gateway ${arg(cfg.gateway)}
|
|
1405
|
+
ExecStart=${pathArg(process.execPath)} ${pathArg(script)} ${runArgs}
|
|
1406
|
+
Restart=always
|
|
1407
|
+
RestartSec=5
|
|
1408
|
+
# This process is the ONLY holder of a VM credential in the companion path.
|
|
1409
|
+
# It runs as the desktop user, never inside a container (the companion boundary).
|
|
1410
|
+
NoNewPrivileges=${cfg.local && cfg.sudo ? "no" : "yes"}
|
|
1411
|
+
|
|
1412
|
+
[Install]
|
|
1413
|
+
WantedBy=default.target
|
|
1414
|
+
`;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* The non-default relay settings as `run` flags for the unit's ExecStart.
|
|
1419
|
+
* A control path is written with `%` doubled: systemd expands `%C`, `%h`,
|
|
1420
|
+
* `%r`, `%p` itself in ExecStart, and those are also ssh's tokens.
|
|
1421
|
+
*/
|
|
1422
|
+
export function unitLimitArgs(cfg, home = homedir()) {
|
|
1423
|
+
const lim = forwardLimits(cfg);
|
|
1424
|
+
const out = [];
|
|
1425
|
+
if (lim.maxRelays !== DEFAULTS.maxRelays) out.push("--max-relays", String(lim.maxRelays));
|
|
1426
|
+
if (lim.queueMax !== DEFAULTS.queueMax) out.push("--queue-max", String(lim.queueMax));
|
|
1427
|
+
if (lim.queueTimeoutMs !== DEFAULTS.queueTimeoutS * 1000)
|
|
1428
|
+
out.push("--queue-timeout", String(Math.round(lim.queueTimeoutMs / 1000)));
|
|
1429
|
+
if (!cfg.local && cfg.controlPath && cfg.controlPath !== defaultControlPath(home))
|
|
1430
|
+
out.push("--control-path", cfg.controlPath.replaceAll("%", "%%"));
|
|
1431
|
+
if (cfg.statsFile && cfg.statsFile !== statsFilePath(process.env, cfg.port))
|
|
1432
|
+
out.push("--stats-file", cfg.statsFile.replaceAll("%", "%%"));
|
|
1433
|
+
return out;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
export function unitPath(cfg, home = homedir()) {
|
|
1437
|
+
return join(home, ".config", "systemd", "user", `${cfg.unit}.service`);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// ---- the confinement proof --------------------------------------------------
|
|
1441
|
+
//
|
|
1442
|
+
// The reverse direction is the property that makes two user keys safe: the
|
|
1443
|
+
// reviewer key (which every automated review session holds) must be unable to
|
|
1444
|
+
// read or write companion memory. Server-side confinement was verified against
|
|
1445
|
+
// a running 0.4.15 server; this turns it into something re-runnable on demand.
|
|
1446
|
+
//
|
|
1447
|
+
// The request shapes below are the adjustable part — src/memory is
|
|
1448
|
+
// the authority for what OpenViking's read/write endpoints look like. The
|
|
1449
|
+
// verdict logic (what each status code proves) is what the unit test pins, and
|
|
1450
|
+
// anything that is neither reach nor refusal is reported "inconclusive", never
|
|
1451
|
+
// as a pass.
|
|
1452
|
+
|
|
1453
|
+
export const PATHS = {
|
|
1454
|
+
/** A directory listing at the user root: exists for every user, and needs no
|
|
1455
|
+
* knowledge of which memory "space" that user happens to have. */
|
|
1456
|
+
read: (env = process.env) => (env.QCR_OV_READ_PATH ?? "/api/v1/fs/ls").trim(),
|
|
1457
|
+
// `{user}` is substituted with the namespace being probed. Verified live in
|
|
1458
|
+
// api_key mode: the server derives identity from the key and confines by
|
|
1459
|
+
// URI — `X-OpenViking-User` does not select the namespace, so a bare
|
|
1460
|
+
// `viking://user/` read answers 200 for every key and the cross-cells could
|
|
1461
|
+
// never fail. The namespace must live in the URI.
|
|
1462
|
+
readUri: (env = process.env, user = "") =>
|
|
1463
|
+
(env.QCR_OV_READ_URI ?? "viking://user/{user}/").trim().replaceAll("{user}", user),
|
|
1464
|
+
/** The ONE durable write path (see the vendored client's PATCH 4 note). */
|
|
1465
|
+
write: (env = process.env) => (env.QCR_OV_WRITE_PATH ?? "/api/v1/content/batch-write").trim(),
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Auth + the cross-user selector.
|
|
1470
|
+
*
|
|
1471
|
+
* The namespace being addressed is named by the URI (see PATHS); the server
|
|
1472
|
+
* does not select it from `X-OpenViking-User`. The Bearer key identifies the
|
|
1473
|
+
* caller, and the header states which user the caller claims to be. Confinement
|
|
1474
|
+
* is proved by the probe's URIs: the reviewer key reading or writing a
|
|
1475
|
+
* `viking://user/companion/` URI must be refused.
|
|
1476
|
+
*/
|
|
1477
|
+
export function authHeaders(key, user, env = process.env) {
|
|
1478
|
+
const name = (env.QCR_OV_AUTH_HEADER ?? "Authorization").trim();
|
|
1479
|
+
const prefix = (env.QCR_OV_AUTH_PREFIX ?? "Bearer ").replace(/\\s/g, " ");
|
|
1480
|
+
return {
|
|
1481
|
+
[name]: `${prefix}${key}`,
|
|
1482
|
+
"X-OpenViking-User": user,
|
|
1483
|
+
"X-OpenViking-Actor-Peer": user,
|
|
1484
|
+
"Content-Type": "application/json",
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const REFUSAL_WORDS = /forbid|permission|unauthor|denied|not allowed|access.*(denied|refused)/i;
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* How one probe reads.
|
|
1492
|
+
*
|
|
1493
|
+
* OpenViking answers some errors with HTTP 200 and `{status:"error"}` in the
|
|
1494
|
+
* envelope, so the status code alone is not enough: a 200 whose body says
|
|
1495
|
+
* "permission denied" is a refusal, and a 200 whose body says something else
|
|
1496
|
+
* entirely is INCONCLUSIVE — never a pass.
|
|
1497
|
+
*/
|
|
1498
|
+
export function classify(status, body) {
|
|
1499
|
+
if (status === 401 || status === 403) return "refused";
|
|
1500
|
+
if (status >= 200 && status < 300) {
|
|
1501
|
+
const envelope = body && typeof body === "object" ? body : {};
|
|
1502
|
+
if (envelope.status === "error") {
|
|
1503
|
+
const message = String(envelope.error?.message ?? envelope.error ?? "");
|
|
1504
|
+
return REFUSAL_WORDS.test(message) ? "refused" : "inconclusive";
|
|
1505
|
+
}
|
|
1506
|
+
return "reach";
|
|
1507
|
+
}
|
|
1508
|
+
return "inconclusive"; // 404 (wrong uri), 5xx, transport — cannot tell
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** The four (key, namespace) pairs and what each MUST show. */
|
|
1512
|
+
export function expectations() {
|
|
1513
|
+
return [
|
|
1514
|
+
{
|
|
1515
|
+
who: "companion",
|
|
1516
|
+
ns: "companion",
|
|
1517
|
+
want: "reach",
|
|
1518
|
+
why: "the companion writes and reads its own memory",
|
|
1519
|
+
},
|
|
1520
|
+
{
|
|
1521
|
+
who: "reviewer",
|
|
1522
|
+
ns: "reviewer",
|
|
1523
|
+
want: "reach",
|
|
1524
|
+
why: "the companion holds the reviewer key too — full reach over review memory",
|
|
1525
|
+
},
|
|
1526
|
+
{
|
|
1527
|
+
who: "reviewer",
|
|
1528
|
+
ns: "companion",
|
|
1529
|
+
want: "refused",
|
|
1530
|
+
why: "REVERSE DIRECTION: review sessions hold only this key and must not see companion memory",
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
who: "companion",
|
|
1534
|
+
ns: "reviewer",
|
|
1535
|
+
want: "refused",
|
|
1536
|
+
why: "user keys are confined both ways (the companion uses the reviewer key for that namespace)",
|
|
1537
|
+
},
|
|
1538
|
+
];
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
export function verdictFor(expected, observed) {
|
|
1542
|
+
if (observed === "inconclusive") return "INCONCLUSIVE";
|
|
1543
|
+
return observed === expected ? "PASS" : "FAIL";
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/** Read the two keys. Values are used, never printed or logged. */
|
|
1547
|
+
export function readKeys(path) {
|
|
1548
|
+
const out = {};
|
|
1549
|
+
let text;
|
|
1550
|
+
try {
|
|
1551
|
+
text = readFileSync(path, "utf8");
|
|
1552
|
+
} catch {
|
|
1553
|
+
return out;
|
|
1554
|
+
}
|
|
1555
|
+
for (const line of text.split("\n")) {
|
|
1556
|
+
const m = /^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
|
|
1557
|
+
if (!m) continue;
|
|
1558
|
+
// The TUI's parseKeysEnv policy (src/tui/memory.ts): only a matching quote
|
|
1559
|
+
// pair is stripped, and an empty assignment is ignored.
|
|
1560
|
+
let value = m[2].trim();
|
|
1561
|
+
if (value.length >= 2 && /^(["']).*\1$/.test(value)) value = value.slice(1, -1);
|
|
1562
|
+
if (value !== "") out[m[1]] = value;
|
|
1563
|
+
}
|
|
1564
|
+
return out;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
async function probePair(base, key, ns, env, fetchFn, doWrite) {
|
|
1568
|
+
const headers = authHeaders(key, ns, env);
|
|
1569
|
+
const at = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1570
|
+
const init = doWrite
|
|
1571
|
+
? {
|
|
1572
|
+
method: "POST",
|
|
1573
|
+
headers,
|
|
1574
|
+
body: JSON.stringify({
|
|
1575
|
+
// Namespace in the URI, same reason as the read probe: the server
|
|
1576
|
+
// confines by URI, not by the X-OpenViking-User header.
|
|
1577
|
+
// "batch-write root must be inside a memory type directory" — the
|
|
1578
|
+
// root needs the category level under memories/ (the store's
|
|
1579
|
+
// DEFAULT_CATEGORY, src/openviking/store.ts). Verified live.
|
|
1580
|
+
root_uri: `viking://user/${ns}/memories/experiences`,
|
|
1581
|
+
operations: [
|
|
1582
|
+
{
|
|
1583
|
+
uri: `viking://user/${ns}/memories/experiences/qcr-confinement-probe-${at}.md`,
|
|
1584
|
+
content: `confinement probe ${at}\n`,
|
|
1585
|
+
// Mandatory on the live 0.4.15: an operation without a
|
|
1586
|
+
// precondition is HTTP 400 (mirrors src/openviking/client.ts).
|
|
1587
|
+
precondition: { kind: "create_if_absent" },
|
|
1588
|
+
},
|
|
1589
|
+
],
|
|
1590
|
+
wait: false,
|
|
1591
|
+
}),
|
|
1592
|
+
}
|
|
1593
|
+
: { method: "GET", headers };
|
|
1594
|
+
const url = doWrite
|
|
1595
|
+
? `${base}${PATHS.write(env)}`
|
|
1596
|
+
: `${base}${PATHS.read(env)}?uri=${encodeURIComponent(PATHS.readUri(env, ns))}`;
|
|
1597
|
+
try {
|
|
1598
|
+
// 30s: the forward path is ssh + docker exec per request, and the local
|
|
1599
|
+
// dense embedder answers slowly from cold — 8s produced false timeouts.
|
|
1600
|
+
const res = await fetchFn(url, { ...init, signal: AbortSignal.timeout(30000) });
|
|
1601
|
+
const body = res.json ? await res.json().catch(() => ({})) : {};
|
|
1602
|
+
return { status: res.status, classification: classify(res.status, body) };
|
|
1603
|
+
} catch (err) {
|
|
1604
|
+
return { status: 0, classification: "inconclusive", error: errText(err) };
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Run the matrix. Resolves a report; never throws on a probe failure.
|
|
1610
|
+
* `keys` maps role → key value.
|
|
1611
|
+
*
|
|
1612
|
+
* `fetchFn` is declared by the MINIMUM the probe reads — a status and an
|
|
1613
|
+
* optional `json()` — not as `typeof fetch`, which is what the default binds
|
|
1614
|
+
* it to. A seam typed by its implementation makes every test double fabricate
|
|
1615
|
+
* a whole `Response` for no gain.
|
|
1616
|
+
*
|
|
1617
|
+
* @param {{
|
|
1618
|
+
* base: string,
|
|
1619
|
+
* keys: Record<string, string>,
|
|
1620
|
+
* env?: Record<string, string | undefined>,
|
|
1621
|
+
* fetchFn?: (url: string, init?: { method?: string, headers?: Record<string, string>, body?: string, signal?: AbortSignal }) => Promise<{ status: number, json?: () => Promise<unknown> }>,
|
|
1622
|
+
* write?: boolean,
|
|
1623
|
+
* log?: (line: string) => void,
|
|
1624
|
+
* }} opts
|
|
1625
|
+
*/
|
|
1626
|
+
export async function runVerify({
|
|
1627
|
+
base,
|
|
1628
|
+
keys,
|
|
1629
|
+
env = process.env,
|
|
1630
|
+
fetchFn = fetch,
|
|
1631
|
+
write = false,
|
|
1632
|
+
log = console.log,
|
|
1633
|
+
}) {
|
|
1634
|
+
const rows = [];
|
|
1635
|
+
for (const e of expectations()) {
|
|
1636
|
+
const key = e.who === "companion" ? keys.companion : keys.reviewer;
|
|
1637
|
+
if (!key) {
|
|
1638
|
+
rows.push({
|
|
1639
|
+
...e,
|
|
1640
|
+
observed: "inconclusive",
|
|
1641
|
+
verdict: "INCONCLUSIVE",
|
|
1642
|
+
detail: `no ${e.who} key configured`,
|
|
1643
|
+
});
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
const read = await probePair(base, key, e.ns, env, fetchFn, false);
|
|
1647
|
+
const written = write ? await probePair(base, key, e.ns, env, fetchFn, true) : null;
|
|
1648
|
+
const probes = written ? [read, written] : [read];
|
|
1649
|
+
// A pair passes only when every requested operation proves its confinement.
|
|
1650
|
+
const r =
|
|
1651
|
+
probes.find((probe) => verdictFor(e.want, probe.classification) === "FAIL") ??
|
|
1652
|
+
probes.find((probe) => probe.classification === "inconclusive") ??
|
|
1653
|
+
read;
|
|
1654
|
+
rows.push({
|
|
1655
|
+
...e,
|
|
1656
|
+
observed: r.classification,
|
|
1657
|
+
verdict: verdictFor(e.want, r.classification),
|
|
1658
|
+
detail: r.error ?? `HTTP ${r.status}`,
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
for (const r of rows) {
|
|
1662
|
+
log(
|
|
1663
|
+
`${r.verdict.padEnd(13)} ${r.who} key → user/${r.ns} memory: expected ${r.want}, got ${r.observed} (${r.detail})`,
|
|
1664
|
+
);
|
|
1665
|
+
if (r.verdict !== "PASS") log(` ${r.why}`);
|
|
1666
|
+
}
|
|
1667
|
+
const failed = rows.filter((r) => r.verdict === "FAIL").length;
|
|
1668
|
+
const unclear = rows.filter((r) => r.verdict === "INCONCLUSIVE").length;
|
|
1669
|
+
log(`\n${rows.length - failed - unclear} pass, ${failed} fail, ${unclear} inconclusive`);
|
|
1670
|
+
if (unclear > 0) {
|
|
1671
|
+
log("Inconclusive rows mean the request shape did not match this server — adjust");
|
|
1672
|
+
log(
|
|
1673
|
+
"QCR_OV_READ_PATH / QCR_OV_READ_URI / QCR_OV_WRITE_PATH / QCR_OV_AUTH_HEADER (see docs/companion-memory-access.md).",
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
return { rows, failed, unclear };
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Success means a conclusive passing matrix: an inconclusive row proves
|
|
1680
|
+
// nothing, so it fails the command as surely as a failed one.
|
|
1681
|
+
export function verifyExitCode(report) {
|
|
1682
|
+
return report.failed > 0 || report.unclear > 0 ? 1 : 0;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// ---- cli --------------------------------------------------------------------
|
|
1686
|
+
|
|
1687
|
+
export function parseArgs(argv, env = process.env) {
|
|
1688
|
+
const cmd = argv[0] && !argv[0].startsWith("--") ? argv[0] : "run";
|
|
1689
|
+
const rest = argv[0] && !argv[0].startsWith("--") ? argv.slice(1) : argv;
|
|
1690
|
+
const flags = new Map();
|
|
1691
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1692
|
+
const a = rest[i];
|
|
1693
|
+
if (!a.startsWith("--")) continue;
|
|
1694
|
+
const [name, inline] = a.slice(2).split("=", 2);
|
|
1695
|
+
if (inline !== undefined) flags.set(name, inline);
|
|
1696
|
+
else if (rest[i + 1] && !rest[i + 1].startsWith("--")) flags.set(name, rest[++i]);
|
|
1697
|
+
else flags.set(name, "true");
|
|
1698
|
+
}
|
|
1699
|
+
const num = (k, d) => (flags.has(k) ? Number(flags.get(k)) : d);
|
|
1700
|
+
// A positive whole number from a flag, else the env, else the
|
|
1701
|
+
// default. A bad value refuses rather than falling back: a cap silently
|
|
1702
|
+
// replaced by something else is the defect this exists to prevent.
|
|
1703
|
+
const count = (flag, envName, d) => {
|
|
1704
|
+
const fromEnv = (env[envName] ?? "").trim();
|
|
1705
|
+
const raw = flags.has(flag) ? String(flags.get(flag)) : fromEnv !== "" ? fromEnv : undefined;
|
|
1706
|
+
if (raw === undefined) return d;
|
|
1707
|
+
if (!/^\d+$/.test(raw) || Number(raw) < 1)
|
|
1708
|
+
throw new Error(
|
|
1709
|
+
`--${flag} (${envName}) must be a whole number of at least 1, got ${JSON.stringify(raw)}`,
|
|
1710
|
+
);
|
|
1711
|
+
return Number(raw);
|
|
1712
|
+
};
|
|
1713
|
+
const gateway = flags.get("gateway") ?? env.QCR_MEMORY_GATEWAY ?? DEFAULTS.gateway;
|
|
1714
|
+
const port = num("port", Number(env.QCR_MEMORY_PORT ?? DEFAULTS.port));
|
|
1715
|
+
const sshHost = flags.get("ssh-host") ?? env.QCR_SSH_HOST ?? DEFAULTS.sshHost;
|
|
1716
|
+
// One-machine topology — the relay is a plain local `docker exec`,
|
|
1717
|
+
// no ssh hop. `--local` (or QCR_MEMORY_LOCAL=1) selects it; an explicit
|
|
1718
|
+
// empty --ssh-host means the same thing (there is no host to ssh to).
|
|
1719
|
+
const local =
|
|
1720
|
+
flags.has("local") ||
|
|
1721
|
+
["1", "true"].includes((env.QCR_MEMORY_LOCAL ?? "").trim()) ||
|
|
1722
|
+
sshHost === "";
|
|
1723
|
+
const cfg = {
|
|
1724
|
+
network: flags.get("network") ?? DEFAULTS.network,
|
|
1725
|
+
subnet: flags.get("subnet") ?? DEFAULTS.subnet,
|
|
1726
|
+
gateway,
|
|
1727
|
+
port,
|
|
1728
|
+
// Loopback too: `verify`, curl and any host-side tool use it, and it is the
|
|
1729
|
+
// half that keeps working when docker is down.
|
|
1730
|
+
binds: (flags.get("bind") ?? `${gateway},127.0.0.1`)
|
|
1731
|
+
.split(",")
|
|
1732
|
+
.map((s) => s.trim())
|
|
1733
|
+
.filter(Boolean),
|
|
1734
|
+
sshHost,
|
|
1735
|
+
local,
|
|
1736
|
+
container:
|
|
1737
|
+
flags.get("container") ??
|
|
1738
|
+
env.QCR_VM_CONTAINER ??
|
|
1739
|
+
(local ? DEFAULTS.localContainer : DEFAULTS.container),
|
|
1740
|
+
remoteHost: flags.get("remote-host") ?? DEFAULTS.remoteHost,
|
|
1741
|
+
remotePort: num("remote-port", DEFAULTS.remotePort),
|
|
1742
|
+
sudo: !flags.has("no-sudo"),
|
|
1743
|
+
identity: flags.get("identity"),
|
|
1744
|
+
controlPath: flags.get("control-path") ?? defaultControlPath(),
|
|
1745
|
+
// One parser, so the systemd unit and the TUI (which runs
|
|
1746
|
+
// this same parseArgs) carry the same cap.
|
|
1747
|
+
maxRelays: count("max-relays", "QCR_MEMORY_MAX_RELAYS", DEFAULTS.maxRelays),
|
|
1748
|
+
queueMax: count("queue-max", "QCR_MEMORY_QUEUE_MAX", DEFAULTS.queueMax),
|
|
1749
|
+
queueTimeoutMs:
|
|
1750
|
+
count("queue-timeout", "QCR_MEMORY_QUEUE_TIMEOUT_S", DEFAULTS.queueTimeoutS) * 1000,
|
|
1751
|
+
statsFile: flags.get("stats-file") ?? statsFilePath(env, port),
|
|
1752
|
+
unit: flags.get("unit") ?? DEFAULTS.unit,
|
|
1753
|
+
write: flags.has("write"),
|
|
1754
|
+
base: flags.get("base") ?? `http://127.0.0.1:${port}`,
|
|
1755
|
+
keysFile: flags.get("keys") ?? keysFilePath(env),
|
|
1756
|
+
};
|
|
1757
|
+
return { cmd, cfg, flags };
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function errText(err) {
|
|
1761
|
+
if (err instanceof Error)
|
|
1762
|
+
return err.name === "TimeoutError" || err.name === "AbortError" ? "timeout" : err.message;
|
|
1763
|
+
return String(err);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function run(bin, args) {
|
|
1767
|
+
return spawnSync(bin, args, { encoding: "utf8" });
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/** @param {any} cfg
|
|
1771
|
+
* @param {(bin: string, args: string[]) => {status: number | null, stdout: string, stderr: string}} execute */
|
|
1772
|
+
function ensureNetwork(cfg, execute = run) {
|
|
1773
|
+
const inspect = execute("docker", [
|
|
1774
|
+
"network",
|
|
1775
|
+
"inspect",
|
|
1776
|
+
"--format",
|
|
1777
|
+
"{{range .IPAM.Config}}{{.Gateway}}{{end}}",
|
|
1778
|
+
cfg.network,
|
|
1779
|
+
]);
|
|
1780
|
+
if (inspect.status === 0 && inspect.stdout.trim() !== "") {
|
|
1781
|
+
console.log(`docker network ${cfg.network} present (gateway ${inspect.stdout.trim()})`);
|
|
1782
|
+
return 0;
|
|
1783
|
+
}
|
|
1784
|
+
const created = execute("docker", [
|
|
1785
|
+
"network",
|
|
1786
|
+
"create",
|
|
1787
|
+
"--subnet",
|
|
1788
|
+
cfg.subnet,
|
|
1789
|
+
...(cfg.gateway ? ["--gateway", cfg.gateway] : []),
|
|
1790
|
+
cfg.network,
|
|
1791
|
+
]);
|
|
1792
|
+
if (created.status !== 0) {
|
|
1793
|
+
console.error(
|
|
1794
|
+
`could not create docker network ${cfg.network}: ${(created.stderr || "").trim()}`,
|
|
1795
|
+
);
|
|
1796
|
+
return 1;
|
|
1797
|
+
}
|
|
1798
|
+
console.log(`docker network ${cfg.network} created (${cfg.subnet})`);
|
|
1799
|
+
return 0;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/** @param {any} cfg
|
|
1803
|
+
* @param {{execute?: (bin: string, args: string[]) => {status: number | null, stdout: string, stderr: string}, home?: string}} deps */
|
|
1804
|
+
export function install(cfg, { execute = run, home = homedir() } = {}) {
|
|
1805
|
+
if (ensureNetwork(cfg, execute) !== 0) return 1;
|
|
1806
|
+
const path = unitPath(cfg, home);
|
|
1807
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1808
|
+
writeFileSync(path, unitText(cfg));
|
|
1809
|
+
console.log(`wrote ${path}`);
|
|
1810
|
+
const auth = unitAuthSock();
|
|
1811
|
+
if (!auth.stable) console.log(`WARNING: ${auth.why}`);
|
|
1812
|
+
const keys = cfg.keysFile;
|
|
1813
|
+
if (!existsSync(keys)) {
|
|
1814
|
+
console.log(`\nNEXT: create ${keys} (chmod 600) with the two OpenViking USER keys:`);
|
|
1815
|
+
console.log(" OPENVIKING_API_KEY=<companion user key>");
|
|
1816
|
+
console.log(" OPENVIKING_COMPANION_KEY=<companion user key>");
|
|
1817
|
+
console.log(" OPENVIKING_REVIEWER_KEY=<reviewer user key>");
|
|
1818
|
+
}
|
|
1819
|
+
for (const args of [
|
|
1820
|
+
["--user", "daemon-reload"],
|
|
1821
|
+
["--user", "enable", `${cfg.unit}.service`],
|
|
1822
|
+
["--user", "restart", `${cfg.unit}.service`],
|
|
1823
|
+
]) {
|
|
1824
|
+
const r = execute("systemctl", args);
|
|
1825
|
+
if (r.status !== 0) {
|
|
1826
|
+
console.error(`systemctl ${args.join(" ")} failed: ${(r.stderr || "").trim()}`);
|
|
1827
|
+
return 1;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
console.log(`systemd unit ${cfg.unit}.service enabled and started`);
|
|
1831
|
+
console.log(
|
|
1832
|
+
`run once so it survives logout/reboot: loginctl enable-linger ${process.env.USER ?? ""}`,
|
|
1833
|
+
);
|
|
1834
|
+
return 0;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/** @param {any} cfg
|
|
1838
|
+
* @param {(bin: string, args: string[]) => {status: number | null, stdout: string, stderr: string}} execute */
|
|
1839
|
+
export function uninstall(cfg, execute = run) {
|
|
1840
|
+
const result = execute("systemctl", ["--user", "disable", "--now", `${cfg.unit}.service`]);
|
|
1841
|
+
if (result.status !== 0) {
|
|
1842
|
+
console.error(`could not stop ${cfg.unit}.service: ${(result.stderr || "").trim()}`);
|
|
1843
|
+
return 1;
|
|
1844
|
+
}
|
|
1845
|
+
console.log(`${cfg.unit}.service disabled (unit file, docker network and keys left in place)`);
|
|
1846
|
+
return 0;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
async function status(cfg) {
|
|
1850
|
+
const unit = run("systemctl", ["--user", "is-active", `${cfg.unit}.service`]);
|
|
1851
|
+
console.log(
|
|
1852
|
+
`unit ${cfg.unit}.service: ${(unit.stdout || unit.stderr || "").trim() || "unknown"}`,
|
|
1853
|
+
);
|
|
1854
|
+
const net = run("docker", [
|
|
1855
|
+
"network",
|
|
1856
|
+
"inspect",
|
|
1857
|
+
"--format",
|
|
1858
|
+
"{{range .IPAM.Config}}{{.Gateway}}{{end}}",
|
|
1859
|
+
cfg.network,
|
|
1860
|
+
]);
|
|
1861
|
+
console.log(
|
|
1862
|
+
`docker network ${cfg.network}: ${net.status === 0 ? `gateway ${net.stdout.trim()}` : "absent"}`,
|
|
1863
|
+
);
|
|
1864
|
+
console.log(`endpoint for the container: http://${cfg.gateway}:${cfg.port}`);
|
|
1865
|
+
// The cap, and the running forward's own live counts.
|
|
1866
|
+
for (const line of describeForwardStats(cfg, readForwardStats(cfg.statsFile))) console.log(line);
|
|
1867
|
+
for (const probe of ["/health", "/ready"]) {
|
|
1868
|
+
try {
|
|
1869
|
+
const res = await fetch(`http://127.0.0.1:${cfg.port}${probe}`, {
|
|
1870
|
+
signal: AbortSignal.timeout(5000),
|
|
1871
|
+
});
|
|
1872
|
+
console.log(` ${probe}: HTTP ${res.status}`);
|
|
1873
|
+
} catch (err) {
|
|
1874
|
+
console.log(` ${probe}: ${errText(err)}`);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
const keys = readKeys(cfg.keysFile);
|
|
1878
|
+
const have = ["OPENVIKING_API_KEY", "OPENVIKING_COMPANION_KEY", "OPENVIKING_REVIEWER_KEY"].filter(
|
|
1879
|
+
(n) => keys[n],
|
|
1880
|
+
);
|
|
1881
|
+
console.log(
|
|
1882
|
+
`keys file ${cfg.keysFile}: ${have.length > 0 ? `defines ${have.join(", ")}` : "missing or empty"}`,
|
|
1883
|
+
);
|
|
1884
|
+
return 0;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
async function verify(cfg) {
|
|
1888
|
+
const raw = readKeys(cfg.keysFile);
|
|
1889
|
+
const keys = {
|
|
1890
|
+
companion: raw.OPENVIKING_COMPANION_KEY ?? raw.OPENVIKING_API_KEY,
|
|
1891
|
+
reviewer: raw.OPENVIKING_REVIEWER_KEY,
|
|
1892
|
+
};
|
|
1893
|
+
if (!keys.companion || !keys.reviewer) {
|
|
1894
|
+
console.error(
|
|
1895
|
+
`both keys are required in ${cfg.keysFile} (OPENVIKING_COMPANION_KEY / OPENVIKING_API_KEY and OPENVIKING_REVIEWER_KEY)`,
|
|
1896
|
+
);
|
|
1897
|
+
return 2;
|
|
1898
|
+
}
|
|
1899
|
+
console.log(`probing ${cfg.base} (${cfg.write ? "read + write" : "read-only"})\n`);
|
|
1900
|
+
const report = await runVerify({ base: cfg.base, keys, write: cfg.write });
|
|
1901
|
+
return verifyExitCode(report);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
async function main(argv) {
|
|
1905
|
+
const { cmd, cfg } = parseArgs(argv);
|
|
1906
|
+
switch (cmd) {
|
|
1907
|
+
case "ensure-network":
|
|
1908
|
+
return ensureNetwork(cfg);
|
|
1909
|
+
case "install":
|
|
1910
|
+
return install(cfg);
|
|
1911
|
+
case "uninstall":
|
|
1912
|
+
return uninstall(cfg);
|
|
1913
|
+
case "status":
|
|
1914
|
+
return status(cfg);
|
|
1915
|
+
case "verify":
|
|
1916
|
+
return verify(cfg);
|
|
1917
|
+
case "run": {
|
|
1918
|
+
const fwd = createForwarder(cfg);
|
|
1919
|
+
const bound = await fwd.listen();
|
|
1920
|
+
if (bound < cfg.binds.length) {
|
|
1921
|
+
// Half a forward is not a forward: the bridge address is the one the
|
|
1922
|
+
// container uses. Exit and let systemd retry in RestartSec rather than
|
|
1923
|
+
// sit there pretending to serve.
|
|
1924
|
+
console.error(
|
|
1925
|
+
`[memory-forward] bound ${bound}/${cfg.binds.length} addresses (${cfg.binds.join(", ")}) — is the docker network up?`,
|
|
1926
|
+
);
|
|
1927
|
+
fwd.close();
|
|
1928
|
+
return 1;
|
|
1929
|
+
}
|
|
1930
|
+
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
1931
|
+
process.on(sig, () => {
|
|
1932
|
+
fwd.close();
|
|
1933
|
+
process.exit(0);
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
return new Promise(() => {});
|
|
1937
|
+
}
|
|
1938
|
+
default:
|
|
1939
|
+
console.error(`unknown command: ${cmd}`);
|
|
1940
|
+
console.error(
|
|
1941
|
+
"usage: companion-memory-forward.mjs [run|install|uninstall|status|ensure-network|verify] [--flags]",
|
|
1942
|
+
);
|
|
1943
|
+
return 2;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
const invokedDirectly = (() => {
|
|
1948
|
+
try {
|
|
1949
|
+
return (
|
|
1950
|
+
process.argv[1] !== undefined &&
|
|
1951
|
+
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
|
|
1952
|
+
);
|
|
1953
|
+
} catch {
|
|
1954
|
+
return false;
|
|
1955
|
+
}
|
|
1956
|
+
})();
|
|
1957
|
+
if (invokedDirectly) {
|
|
1958
|
+
main(process.argv.slice(2))
|
|
1959
|
+
.then((code) => {
|
|
1960
|
+
if (typeof code === "number" && code !== 0) process.exit(code);
|
|
1961
|
+
})
|
|
1962
|
+
.catch((err) => {
|
|
1963
|
+
console.error(`[memory-forward] ${errText(err)}`);
|
|
1964
|
+
process.exit(1);
|
|
1965
|
+
});
|
|
1966
|
+
}
|