From e6dcef9bf5e0f907cc7a425df3e125855c807f2e Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 1 Oct 2026 19:21:19 +0000 Subject: [PATCH] dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x --- .env.example | 43 + .gitignore | 5 + LICENSE | 26 + README.md | 83 + docker-compose.yml | 105 + docs/UPDATE-MECHANISM.md | 74 + scripts/build-image.sh | 41 + scripts/update-dsh.sh | 77 + scripts/update-plugins.sh | 41 + upstream/.dockerignore | 16 + upstream/.env.example | 33 + upstream/.github/workflows/docker.yaml | 331 + .../workflows/dsh-candidate-validation.yml | 133 + .../.github/workflows/dsh-upstream-update.yml | 237 + .../.github/workflows/production-publish.yml | 289 + .../workflows/workflow-notifications.yml | 52 + upstream/.gitignore | 11 + upstream/Dockerfile | 174 + upstream/LICENSE | 21 + upstream/PROVENANCE.md | 11 + upstream/README.md | 220 + upstream/README_CN.md | 220 + upstream/container/README.md | 15 + .../container/control-plane/definition.json | 25 + .../control-plane/hooks/dsh-web-ready.mjs | 201 + .../hooks/recovery/component.json | 21 + .../control-plane/hooks/recovery/index.mjs | 29 + .../modules/file-manager/archives.mjs | 110 + .../modules/file-manager/editor.mjs | 78 + .../modules/file-manager/index.mjs | 518 ++ .../modules/file-manager/tasks.mjs | 645 ++ .../modules/file-manager/transfers.mjs | 205 + .../modules/log-manager/index.mjs | 473 ++ .../modules/patch-manager/index.mjs | 215 + .../modules/plugin-manager/system.mjs | 441 ++ .../modules/plugin-manager/user-inventory.mjs | 203 + .../modules/plugin-manager/user-journal.mjs | 116 + .../modules/plugin-manager/user-snapshots.mjs | 143 + .../modules/plugin-manager/user-state.mjs | 67 + .../plugin-manager/user-transaction.mjs | 414 ++ .../modules/skill-manager/index.mjs | 297 + .../modules/updater/lib/activator.mjs | 229 + .../modules/updater/lib/automatic-check.mjs | 170 + .../modules/updater/lib/channel-state.mjs | 169 + .../modules/updater/lib/client.mjs | 66 + .../modules/updater/lib/coordinator.mjs | 894 +++ .../modules/updater/lib/journal.mjs | 142 + .../modules/updater/lib/managed-store.mjs | 275 + .../modules/updater/lib/metadata.mjs | 180 + .../updater/lib/official-dsh-download.mjs | 192 + .../modules/updater/lib/preparer.mjs | 206 + .../modules/updater/lib/recovery.mjs | 82 + .../modules/updater/lib/rollback.mjs | 143 + .../modules/updater/lib/scheduler.mjs | 55 + .../modules/updater/lib/snapshot-client.mjs | 29 + .../modules/updater/lib/snapshots.mjs | 199 + .../modules/updater/lib/state.mjs | 32 + .../services/access-manager/component.json | 30 + .../services/access-manager/index.mjs | 53 + .../access-manager/lib/capabilities.mjs | 64 + .../access-manager/lib/credentials.mjs | 89 + .../services/access-manager/lib/errors.mjs | 19 + .../services/access-manager/lib/exchanges.mjs | 97 + .../access-manager/lib/rate-limiter.mjs | 241 + .../lib/runtime-capabilities.mjs | 62 + .../services/access-manager/lib/server.mjs | 1282 ++++ .../services/access-manager/lib/sessions.mjs | 301 + .../services/access-manager/lib/store.mjs | 249 + .../services/access-manager/lib/totp.mjs | 293 + .../access-manager/lib/transitions.mjs | 120 + .../services/access-manager/supervisor.mjs | 49 + .../services/gateway/component.json | 31 + .../control-plane/services/gateway/index.mjs | 38 + .../services/gateway/lib/availability.mjs | 151 + .../services/gateway/lib/browser-auth.mjs | 1088 ++++ .../services/gateway/lib/config.mjs | 75 + .../gateway/lib/dsh-upstream-auth.mjs | 119 + .../services/gateway/lib/errors.mjs | 7 + .../services/gateway/lib/lifecycle.mjs | 157 + .../services/gateway/lib/polyfill.mjs | 60 + .../services/gateway/lib/proxy.mjs | 1310 ++++ .../services/gateway/lib/trust.mjs | 56 + .../services/gateway/package.json | 11 + .../services/gateway/test/auth.test.mjs | 149 + .../gateway/test/availability.test.mjs | 486 ++ .../gateway/test/browser-auth.test.mjs | 1400 ++++ .../test/browser-loopback-patch.test.mjs | 40 + .../services/gateway/test/config.test.mjs | 85 + .../test/directory-picker-patch.test.mjs | 25 + .../gateway/test/dsh-upstream-auth.test.mjs | 84 + .../services/gateway/test/lifecycle.test.mjs | 127 + .../services/gateway/test/polyfill.test.mjs | 147 + .../services/gateway/test/proxy.test.mjs | 1142 ++++ .../services/gateway/test/trust.test.mjs | 86 + .../control-plane/services/management/cli.mjs | 442 ++ .../services/management/component.json | 30 + .../services/management/dsh-platform.mjs | 10 + .../services/management/healthcheck.mjs | 20 + .../services/management/index.mjs | 437 ++ .../management/outbound-proxy-client.mjs | 81 + .../services/management/package-lock.json | 84 + .../services/management/package.json | 13 + .../management/provider-inventory.mjs | 230 + .../services/management/public/app.js | 5735 +++++++++++++++++ .../services/management/public/index.html | 377 ++ .../services/management/public/style.css | 1062 +++ .../services/management/public/theme-init.js | 4 + .../services/management/restricted-cli.mjs | 55 + .../services/management/scoped-fetch.mjs | 55 + .../services/management/server.mjs | 1034 +++ .../services/management/settings-document.mjs | 82 + .../management/terminal/pty-helper.py | 109 + .../services/management/terminal/sessions.mjs | 288 + .../services/management/user-skills.mjs | 226 + .../services/outbound-proxy/component.json | 30 + .../services/outbound-proxy/index.mjs | 131 + .../services/outbound-proxy/lib/contracts.mjs | 185 + .../services/outbound-proxy/lib/control.mjs | 130 + .../outbound-proxy/lib/data-plane.mjs | 459 ++ .../services/outbound-proxy/lib/dns-cache.mjs | 80 + .../services/outbound-proxy/lib/errors.mjs | 26 + .../services/outbound-proxy/lib/policy.mjs | 47 + .../outbound-proxy/lib/provider-handles.mjs | 62 + .../services/outbound-proxy/lib/readiness.mjs | 29 + .../outbound-proxy/lib/route-health.mjs | 38 + .../services/outbound-proxy/lib/rules.mjs | 121 + .../outbound-proxy/lib/scope-catalog.mjs | 52 + .../services/outbound-proxy/lib/store.mjs | 195 + .../outbound-proxy/lib/test-manager.mjs | 455 ++ .../services/outbound-proxy/lib/transport.mjs | 423 ++ .../services/outbound-proxy/supervisor.mjs | 49 + .../container/environment/definition.json | 63 + .../environment/dsh-runtime/component.json | 43 + .../resources/patches/browser-loopback.mjs | 41 + .../resources/patches/directory-picker.mjs | 38 + .../resources/patches/managed-lifecycle.mjs | 550 ++ .../resources/patches/native-corners.mjs | 29 + .../package/cordis.patch.json | 10 + .../package/lib/client.bundle.js | 2818 ++++++++ .../platform-management/package/lib/client.js | 2804 ++++++++ .../platform-management/package/lib/index.js | 128 + .../package/lib/provider-routing.js | 248 + .../package/lib/style.module.css | 1122 ++++ .../package/package-lock.json | 32 + .../platform-management/package/package.json | 39 + .../package/cordis.patch.json | 10 + .../package/lib/client.bundle.js | 270 + .../package/lib/client.js | 256 + .../package/lib/index.js | 3 + .../package/lib/style.module.css | 215 + .../package/package.json | 37 + .../package/cordis.patch.json | 10 + .../package/lib/client.bundle.js | 267 + .../settings-navigation/package/lib/client.js | 253 + .../settings-navigation/package/lib/index.js | 3 + .../package/lib/style.module.css | 108 + .../settings-navigation/package/package.json | 32 + .../environment/resources/skills/catalog.json | 13 + .../skills/dsh-docker-operations/SKILL.md | 31 + .../references/diagnostics-and-boundaries.md | 44 + .../references/dsh-and-extensions.md | 47 + .../references/environment-and-permissions.md | 35 + .../references/files-and-development.md | 31 + .../references/lifecycle-and-logs.md | 59 + .../references/networking-and-auth.md | 63 + .../references/updates-and-recovery.md | 44 + upstream/container/platform/bootstrap/VERSION | 1 + .../container/platform/bootstrap/index.mjs | 454 ++ .../platform/bootstrap/lib/control.mjs | 170 + .../platform/bootstrap/lib/deployments.mjs | 808 +++ .../platform/bootstrap/lib/dsh-lifecycle.mjs | 206 + .../container/platform/bootstrap/lib/gc.mjs | 148 + .../platform/bootstrap/lib/lifecycle.mjs | 572 ++ .../platform/bootstrap/lib/runtime.mjs | 347 + .../container/platform/image-input/.gitkeep | 0 .../platform/lib/access-capability.mjs | 12 + .../platform/lib/access-evidence.mjs | 63 + .../platform/lib/artifact-contracts.mjs | 76 + upstream/container/platform/lib/atomic.mjs | 46 + .../platform/lib/bootstrap-version.mjs | 31 + .../container/platform/lib/canonical-json.mjs | 23 + upstream/container/platform/lib/contracts.mjs | 265 + .../platform/lib/deployment-contracts.mjs | 286 + .../platform/lib/deployment-status.mjs | 22 + .../platform/lib/dsh-compatibility.mjs | 143 + .../platform/lib/official-dsh-contracts.mjs | 15 + .../container/platform/lib/outbound-proxy.mjs | 98 + upstream/container/platform/lib/paths.mjs | 155 + .../platform/lib/supported-target.mjs | 92 + upstream/container/platform/lib/tree-hash.mjs | 45 + .../container/platform/lib/validation.mjs | 49 + upstream/container/platform/package.json | 11 + .../platform/seed/trust/DEVELOPMENT_FIXTURE | 2 + .../platform/seed/trust/keyring.json | 1 + .../platform/seed/trust/keyring.sig.json | 1 + .../seed/trust/recovery-root.spki.base64 | 1 + upstream/container/platform/stage0/index.mjs | 372 ++ .../stage0/lib/access-launch-server.mjs | 223 + .../platform/stage0/lib/artifacts.mjs | 625 ++ .../container/platform/stage0/lib/keyring.mjs | 76 + .../container/platform/stage0/lib/ledger.mjs | 244 + .../stage0/lib/maintenance-server.mjs | 270 + .../platform/stage0/lib/official-dsh.mjs | 89 + .../stage0/lib/proxy-launch-server.mjs | 232 + .../platform/stage0/lib/recovery-server.mjs | 58 + .../container/platform/stage0/lib/seed.mjs | 86 + .../platform/stage0/lib/signature.mjs | 65 + .../container/platform/stage0/lib/slots.mjs | 242 + .../platform/stage0/lib/snapshot-server.mjs | 73 + .../platform/stage0/lib/supervisor.mjs | 339 + .../container/platform/stage0/lib/target.mjs | 26 + .../platform/stage0/lib/trust-server.mjs | 121 + .../platform/stage0/lib/user-roots.mjs | 23 + .../platform/stage0/lib/user-skill-server.mjs | 67 + .../platform/test/access-manager.test.mjs | 2121 ++++++ .../platform/test/artifacts.test.mjs | 353 + .../platform/test/automatic-check.test.mjs | 80 + .../platform/test/bootstrap.test.mjs | 1318 ++++ .../platform/test/bump-environment.test.mjs | 67 + .../platform/test/channel-state.test.mjs | 73 + .../test/container-integration.test.mjs | 233 + .../platform/test/contracts.test.mjs | 197 + .../platform/test/deployments.test.mjs | 953 +++ .../platform/test/dsh-compatibility.test.mjs | 135 + .../platform/test/dsh-lifecycle.test.mjs | 145 + .../platform/test/dsh-web-ready.test.mjs | 303 + .../platform/test/file-editor.test.mjs | 42 + .../platform/test/file-manager.test.mjs | 146 + .../platform/test/file-tasks.test.mjs | 450 ++ .../platform/test/file-transfers.test.mjs | 92 + .../platform/test/fixtures/dsh-package.mjs | 30 + upstream/container/platform/test/helpers.mjs | 102 + .../container/platform/test/journal.test.mjs | 59 + .../container/platform/test/keyring.test.mjs | 50 + .../container/platform/test/ledger.test.mjs | 162 + .../container/platform/test/logging.test.mjs | 321 + .../test/managed-lifecycle-patch.test.mjs | 372 ++ .../platform/test/managed-store.test.mjs | 208 + .../platform/test/management.test.mjs | 3259 ++++++++++ .../test/native-corners-patch.test.mjs | 35 + .../platform/test/official-dsh.test.mjs | 57 + .../test/package-environment.test.mjs | 170 + .../container/platform/test/paths.test.mjs | 82 + .../test/platform-management.test.mjs | 1143 ++++ .../test/proxy-configuration.test.mjs | 424 ++ .../platform/test/proxy-data-plane.test.mjs | 1284 ++++ .../platform/test/proxy-environment.test.mjs | 159 + .../test/proxy-provider-inventory.test.mjs | 195 + .../platform/test/proxy-provider.test.mjs | 293 + .../platform/test/proxy-scope-client.test.mjs | 151 + .../platform/test/proxy-test-manager.test.mjs | 141 + .../platform/test/release-tools.test.mjs | 434 ++ .../platform/test/restricted-cli.test.mjs | 82 + .../container/platform/test/rollback.test.mjs | 148 + .../platform/test/runtime-builder.test.mjs | 245 + .../container/platform/test/seed.test.mjs | 156 + .../test/settings-document-editor.test.mjs | 143 + .../test/settings-navigation.test.mjs | 117 + .../platform/test/snapshots.test.mjs | 111 + .../container/platform/test/stage0.test.mjs | 811 +++ .../platform/test/supported-target.test.mjs | 64 + .../platform/test/system-plugins.test.mjs | 329 + .../platform/test/system-skills.test.mjs | 113 + .../platform/test/terminal-sessions.test.mjs | 248 + .../container/platform/test/totp.test.mjs | 105 + .../container/platform/test/updater.test.mjs | 1888 ++++++ .../test/user-plugin-transactions.test.mjs | 439 ++ .../platform/test/user-plugins.test.mjs | 183 + .../platform/test/user-roots.test.mjs | 33 + .../platform/test/user-skill-server.test.mjs | 72 + .../platform/test/user-skills.test.mjs | 149 + .../platform/test/workflows.test.mjs | 198 + .../platform/tools/build-release.mjs | 58 + .../container/platform/tools/build-seed.mjs | 190 + .../tools/build-system-plugin-client.mjs | 66 + .../platform/tools/dsh-platform-shim.sh | 12 + upstream/container/platform/tools/dsh-shim.sh | 35 + .../platform/tools/environment-release.mjs | 51 + upstream/container/platform/tools/keyring.mjs | 50 + .../platform/tools/package-environment.mjs | 156 + .../platform/tools/prepare-release.mjs | 249 + upstream/container/platform/tools/sign.mjs | 33 + .../platform/tools/supported-target.mjs | 45 + .../tools/verify-dsh-compatibility.mjs | 24 + .../platform/tools/verify-image-release.mjs | 131 + .../tools/verify-management-dependencies.mjs | 55 + upstream/container/test/compose-config.mjs | 75 + upstream/container/test/container-smoke.sh | 936 +++ upstream/container/test/devtools-smoke.sh | 41 + .../test/managed-lifecycle-restart-smoke.mjs | 126 + .../profile-package-compatibility-smoke.sh | 73 + .../test/standalone-file-management-smoke.mjs | 204 + .../test/standalone-recovery-smoke.mjs | 231 + .../test/system-skill-discovery-smoke.mjs | 37 + .../test/user-skill-watcher-smoke.mjs | 53 + upstream/docker-compose.yaml | 28 + upstream/docker-sudoers | 1 + upstream/docs/cn/guide.md | 632 ++ upstream/docs/en/guide.md | 630 ++ upstream/release/official-dsh-policy.json | 12 + upstream/release/supported-target.json | 5 + upstream/scripts/bump-environment.sh | 65 + 302 files changed, 75178 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docs/UPDATE-MECHANISM.md create mode 100644 scripts/build-image.sh create mode 100644 scripts/update-dsh.sh create mode 100644 scripts/update-plugins.sh create mode 100644 upstream/.dockerignore create mode 100644 upstream/.env.example create mode 100644 upstream/.github/workflows/docker.yaml create mode 100644 upstream/.github/workflows/dsh-candidate-validation.yml create mode 100644 upstream/.github/workflows/dsh-upstream-update.yml create mode 100644 upstream/.github/workflows/production-publish.yml create mode 100644 upstream/.github/workflows/workflow-notifications.yml create mode 100644 upstream/.gitignore create mode 100644 upstream/Dockerfile create mode 100644 upstream/LICENSE create mode 100644 upstream/PROVENANCE.md create mode 100644 upstream/README.md create mode 100644 upstream/README_CN.md create mode 100644 upstream/container/README.md create mode 100644 upstream/container/control-plane/definition.json create mode 100644 upstream/container/control-plane/hooks/dsh-web-ready.mjs create mode 100644 upstream/container/control-plane/hooks/recovery/component.json create mode 100644 upstream/container/control-plane/hooks/recovery/index.mjs create mode 100644 upstream/container/control-plane/modules/file-manager/archives.mjs create mode 100644 upstream/container/control-plane/modules/file-manager/editor.mjs create mode 100644 upstream/container/control-plane/modules/file-manager/index.mjs create mode 100644 upstream/container/control-plane/modules/file-manager/tasks.mjs create mode 100644 upstream/container/control-plane/modules/file-manager/transfers.mjs create mode 100644 upstream/container/control-plane/modules/log-manager/index.mjs create mode 100644 upstream/container/control-plane/modules/patch-manager/index.mjs create mode 100644 upstream/container/control-plane/modules/plugin-manager/system.mjs create mode 100644 upstream/container/control-plane/modules/plugin-manager/user-inventory.mjs create mode 100644 upstream/container/control-plane/modules/plugin-manager/user-journal.mjs create mode 100644 upstream/container/control-plane/modules/plugin-manager/user-snapshots.mjs create mode 100644 upstream/container/control-plane/modules/plugin-manager/user-state.mjs create mode 100644 upstream/container/control-plane/modules/plugin-manager/user-transaction.mjs create mode 100644 upstream/container/control-plane/modules/skill-manager/index.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/activator.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/automatic-check.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/channel-state.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/client.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/coordinator.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/journal.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/managed-store.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/metadata.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/official-dsh-download.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/preparer.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/recovery.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/rollback.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/scheduler.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/snapshot-client.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/snapshots.mjs create mode 100644 upstream/container/control-plane/modules/updater/lib/state.mjs create mode 100644 upstream/container/control-plane/services/access-manager/component.json create mode 100644 upstream/container/control-plane/services/access-manager/index.mjs create mode 100644 upstream/container/control-plane/services/access-manager/lib/capabilities.mjs create mode 100644 upstream/container/control-plane/services/access-manager/lib/credentials.mjs create mode 100644 upstream/container/control-plane/services/access-manager/lib/errors.mjs create mode 100644 upstream/container/control-plane/services/access-manager/lib/exchanges.mjs create mode 100644 upstream/container/control-plane/services/access-manager/lib/rate-limiter.mjs create mode 100644 upstream/container/control-plane/services/access-manager/lib/runtime-capabilities.mjs create mode 100644 upstream/container/control-plane/services/access-manager/lib/server.mjs create mode 100644 upstream/container/control-plane/services/access-manager/lib/sessions.mjs create mode 100644 upstream/container/control-plane/services/access-manager/lib/store.mjs create mode 100644 upstream/container/control-plane/services/access-manager/lib/totp.mjs create mode 100644 upstream/container/control-plane/services/access-manager/lib/transitions.mjs create mode 100644 upstream/container/control-plane/services/access-manager/supervisor.mjs create mode 100644 upstream/container/control-plane/services/gateway/component.json create mode 100644 upstream/container/control-plane/services/gateway/index.mjs create mode 100644 upstream/container/control-plane/services/gateway/lib/availability.mjs create mode 100644 upstream/container/control-plane/services/gateway/lib/browser-auth.mjs create mode 100644 upstream/container/control-plane/services/gateway/lib/config.mjs create mode 100644 upstream/container/control-plane/services/gateway/lib/dsh-upstream-auth.mjs create mode 100644 upstream/container/control-plane/services/gateway/lib/errors.mjs create mode 100644 upstream/container/control-plane/services/gateway/lib/lifecycle.mjs create mode 100644 upstream/container/control-plane/services/gateway/lib/polyfill.mjs create mode 100644 upstream/container/control-plane/services/gateway/lib/proxy.mjs create mode 100644 upstream/container/control-plane/services/gateway/lib/trust.mjs create mode 100644 upstream/container/control-plane/services/gateway/package.json create mode 100644 upstream/container/control-plane/services/gateway/test/auth.test.mjs create mode 100644 upstream/container/control-plane/services/gateway/test/availability.test.mjs create mode 100644 upstream/container/control-plane/services/gateway/test/browser-auth.test.mjs create mode 100644 upstream/container/control-plane/services/gateway/test/browser-loopback-patch.test.mjs create mode 100644 upstream/container/control-plane/services/gateway/test/config.test.mjs create mode 100644 upstream/container/control-plane/services/gateway/test/directory-picker-patch.test.mjs create mode 100644 upstream/container/control-plane/services/gateway/test/dsh-upstream-auth.test.mjs create mode 100644 upstream/container/control-plane/services/gateway/test/lifecycle.test.mjs create mode 100644 upstream/container/control-plane/services/gateway/test/polyfill.test.mjs create mode 100644 upstream/container/control-plane/services/gateway/test/proxy.test.mjs create mode 100644 upstream/container/control-plane/services/gateway/test/trust.test.mjs create mode 100644 upstream/container/control-plane/services/management/cli.mjs create mode 100644 upstream/container/control-plane/services/management/component.json create mode 100644 upstream/container/control-plane/services/management/dsh-platform.mjs create mode 100644 upstream/container/control-plane/services/management/healthcheck.mjs create mode 100644 upstream/container/control-plane/services/management/index.mjs create mode 100644 upstream/container/control-plane/services/management/outbound-proxy-client.mjs create mode 100644 upstream/container/control-plane/services/management/package-lock.json create mode 100644 upstream/container/control-plane/services/management/package.json create mode 100644 upstream/container/control-plane/services/management/provider-inventory.mjs create mode 100644 upstream/container/control-plane/services/management/public/app.js create mode 100644 upstream/container/control-plane/services/management/public/index.html create mode 100644 upstream/container/control-plane/services/management/public/style.css create mode 100644 upstream/container/control-plane/services/management/public/theme-init.js create mode 100644 upstream/container/control-plane/services/management/restricted-cli.mjs create mode 100644 upstream/container/control-plane/services/management/scoped-fetch.mjs create mode 100644 upstream/container/control-plane/services/management/server.mjs create mode 100644 upstream/container/control-plane/services/management/settings-document.mjs create mode 100644 upstream/container/control-plane/services/management/terminal/pty-helper.py create mode 100644 upstream/container/control-plane/services/management/terminal/sessions.mjs create mode 100644 upstream/container/control-plane/services/management/user-skills.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/component.json create mode 100644 upstream/container/control-plane/services/outbound-proxy/index.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/contracts.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/control.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/data-plane.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/dns-cache.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/errors.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/policy.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/provider-handles.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/readiness.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/route-health.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/rules.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/scope-catalog.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/store.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/test-manager.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/lib/transport.mjs create mode 100644 upstream/container/control-plane/services/outbound-proxy/supervisor.mjs create mode 100644 upstream/container/environment/definition.json create mode 100644 upstream/container/environment/dsh-runtime/component.json create mode 100644 upstream/container/environment/resources/patches/browser-loopback.mjs create mode 100644 upstream/container/environment/resources/patches/directory-picker.mjs create mode 100644 upstream/container/environment/resources/patches/managed-lifecycle.mjs create mode 100644 upstream/container/environment/resources/patches/native-corners.mjs create mode 100644 upstream/container/environment/resources/plugins/platform-management/package/cordis.patch.json create mode 100644 upstream/container/environment/resources/plugins/platform-management/package/lib/client.bundle.js create mode 100644 upstream/container/environment/resources/plugins/platform-management/package/lib/client.js create mode 100644 upstream/container/environment/resources/plugins/platform-management/package/lib/index.js create mode 100644 upstream/container/environment/resources/plugins/platform-management/package/lib/provider-routing.js create mode 100644 upstream/container/environment/resources/plugins/platform-management/package/lib/style.module.css create mode 100644 upstream/container/environment/resources/plugins/platform-management/package/package-lock.json create mode 100644 upstream/container/environment/resources/plugins/platform-management/package/package.json create mode 100644 upstream/container/environment/resources/plugins/settings-document-editor/package/cordis.patch.json create mode 100644 upstream/container/environment/resources/plugins/settings-document-editor/package/lib/client.bundle.js create mode 100644 upstream/container/environment/resources/plugins/settings-document-editor/package/lib/client.js create mode 100644 upstream/container/environment/resources/plugins/settings-document-editor/package/lib/index.js create mode 100644 upstream/container/environment/resources/plugins/settings-document-editor/package/lib/style.module.css create mode 100644 upstream/container/environment/resources/plugins/settings-document-editor/package/package.json create mode 100644 upstream/container/environment/resources/plugins/settings-navigation/package/cordis.patch.json create mode 100644 upstream/container/environment/resources/plugins/settings-navigation/package/lib/client.bundle.js create mode 100644 upstream/container/environment/resources/plugins/settings-navigation/package/lib/client.js create mode 100644 upstream/container/environment/resources/plugins/settings-navigation/package/lib/index.js create mode 100644 upstream/container/environment/resources/plugins/settings-navigation/package/lib/style.module.css create mode 100644 upstream/container/environment/resources/plugins/settings-navigation/package/package.json create mode 100644 upstream/container/environment/resources/skills/catalog.json create mode 100644 upstream/container/environment/resources/skills/dsh-docker-operations/SKILL.md create mode 100644 upstream/container/environment/resources/skills/dsh-docker-operations/references/diagnostics-and-boundaries.md create mode 100644 upstream/container/environment/resources/skills/dsh-docker-operations/references/dsh-and-extensions.md create mode 100644 upstream/container/environment/resources/skills/dsh-docker-operations/references/environment-and-permissions.md create mode 100644 upstream/container/environment/resources/skills/dsh-docker-operations/references/files-and-development.md create mode 100644 upstream/container/environment/resources/skills/dsh-docker-operations/references/lifecycle-and-logs.md create mode 100644 upstream/container/environment/resources/skills/dsh-docker-operations/references/networking-and-auth.md create mode 100644 upstream/container/environment/resources/skills/dsh-docker-operations/references/updates-and-recovery.md create mode 100644 upstream/container/platform/bootstrap/VERSION create mode 100644 upstream/container/platform/bootstrap/index.mjs create mode 100644 upstream/container/platform/bootstrap/lib/control.mjs create mode 100644 upstream/container/platform/bootstrap/lib/deployments.mjs create mode 100644 upstream/container/platform/bootstrap/lib/dsh-lifecycle.mjs create mode 100644 upstream/container/platform/bootstrap/lib/gc.mjs create mode 100644 upstream/container/platform/bootstrap/lib/lifecycle.mjs create mode 100644 upstream/container/platform/bootstrap/lib/runtime.mjs create mode 100644 upstream/container/platform/image-input/.gitkeep create mode 100644 upstream/container/platform/lib/access-capability.mjs create mode 100644 upstream/container/platform/lib/access-evidence.mjs create mode 100644 upstream/container/platform/lib/artifact-contracts.mjs create mode 100644 upstream/container/platform/lib/atomic.mjs create mode 100644 upstream/container/platform/lib/bootstrap-version.mjs create mode 100644 upstream/container/platform/lib/canonical-json.mjs create mode 100644 upstream/container/platform/lib/contracts.mjs create mode 100644 upstream/container/platform/lib/deployment-contracts.mjs create mode 100644 upstream/container/platform/lib/deployment-status.mjs create mode 100644 upstream/container/platform/lib/dsh-compatibility.mjs create mode 100644 upstream/container/platform/lib/official-dsh-contracts.mjs create mode 100644 upstream/container/platform/lib/outbound-proxy.mjs create mode 100644 upstream/container/platform/lib/paths.mjs create mode 100644 upstream/container/platform/lib/supported-target.mjs create mode 100644 upstream/container/platform/lib/tree-hash.mjs create mode 100644 upstream/container/platform/lib/validation.mjs create mode 100644 upstream/container/platform/package.json create mode 100644 upstream/container/platform/seed/trust/DEVELOPMENT_FIXTURE create mode 100644 upstream/container/platform/seed/trust/keyring.json create mode 100644 upstream/container/platform/seed/trust/keyring.sig.json create mode 100644 upstream/container/platform/seed/trust/recovery-root.spki.base64 create mode 100644 upstream/container/platform/stage0/index.mjs create mode 100644 upstream/container/platform/stage0/lib/access-launch-server.mjs create mode 100644 upstream/container/platform/stage0/lib/artifacts.mjs create mode 100644 upstream/container/platform/stage0/lib/keyring.mjs create mode 100644 upstream/container/platform/stage0/lib/ledger.mjs create mode 100644 upstream/container/platform/stage0/lib/maintenance-server.mjs create mode 100644 upstream/container/platform/stage0/lib/official-dsh.mjs create mode 100644 upstream/container/platform/stage0/lib/proxy-launch-server.mjs create mode 100644 upstream/container/platform/stage0/lib/recovery-server.mjs create mode 100644 upstream/container/platform/stage0/lib/seed.mjs create mode 100644 upstream/container/platform/stage0/lib/signature.mjs create mode 100644 upstream/container/platform/stage0/lib/slots.mjs create mode 100644 upstream/container/platform/stage0/lib/snapshot-server.mjs create mode 100644 upstream/container/platform/stage0/lib/supervisor.mjs create mode 100644 upstream/container/platform/stage0/lib/target.mjs create mode 100644 upstream/container/platform/stage0/lib/trust-server.mjs create mode 100644 upstream/container/platform/stage0/lib/user-roots.mjs create mode 100644 upstream/container/platform/stage0/lib/user-skill-server.mjs create mode 100644 upstream/container/platform/test/access-manager.test.mjs create mode 100644 upstream/container/platform/test/artifacts.test.mjs create mode 100644 upstream/container/platform/test/automatic-check.test.mjs create mode 100644 upstream/container/platform/test/bootstrap.test.mjs create mode 100644 upstream/container/platform/test/bump-environment.test.mjs create mode 100644 upstream/container/platform/test/channel-state.test.mjs create mode 100644 upstream/container/platform/test/container-integration.test.mjs create mode 100644 upstream/container/platform/test/contracts.test.mjs create mode 100644 upstream/container/platform/test/deployments.test.mjs create mode 100644 upstream/container/platform/test/dsh-compatibility.test.mjs create mode 100644 upstream/container/platform/test/dsh-lifecycle.test.mjs create mode 100644 upstream/container/platform/test/dsh-web-ready.test.mjs create mode 100644 upstream/container/platform/test/file-editor.test.mjs create mode 100644 upstream/container/platform/test/file-manager.test.mjs create mode 100644 upstream/container/platform/test/file-tasks.test.mjs create mode 100644 upstream/container/platform/test/file-transfers.test.mjs create mode 100644 upstream/container/platform/test/fixtures/dsh-package.mjs create mode 100644 upstream/container/platform/test/helpers.mjs create mode 100644 upstream/container/platform/test/journal.test.mjs create mode 100644 upstream/container/platform/test/keyring.test.mjs create mode 100644 upstream/container/platform/test/ledger.test.mjs create mode 100644 upstream/container/platform/test/logging.test.mjs create mode 100644 upstream/container/platform/test/managed-lifecycle-patch.test.mjs create mode 100644 upstream/container/platform/test/managed-store.test.mjs create mode 100644 upstream/container/platform/test/management.test.mjs create mode 100644 upstream/container/platform/test/native-corners-patch.test.mjs create mode 100644 upstream/container/platform/test/official-dsh.test.mjs create mode 100644 upstream/container/platform/test/package-environment.test.mjs create mode 100644 upstream/container/platform/test/paths.test.mjs create mode 100644 upstream/container/platform/test/platform-management.test.mjs create mode 100644 upstream/container/platform/test/proxy-configuration.test.mjs create mode 100644 upstream/container/platform/test/proxy-data-plane.test.mjs create mode 100644 upstream/container/platform/test/proxy-environment.test.mjs create mode 100644 upstream/container/platform/test/proxy-provider-inventory.test.mjs create mode 100644 upstream/container/platform/test/proxy-provider.test.mjs create mode 100644 upstream/container/platform/test/proxy-scope-client.test.mjs create mode 100644 upstream/container/platform/test/proxy-test-manager.test.mjs create mode 100644 upstream/container/platform/test/release-tools.test.mjs create mode 100644 upstream/container/platform/test/restricted-cli.test.mjs create mode 100644 upstream/container/platform/test/rollback.test.mjs create mode 100644 upstream/container/platform/test/runtime-builder.test.mjs create mode 100644 upstream/container/platform/test/seed.test.mjs create mode 100644 upstream/container/platform/test/settings-document-editor.test.mjs create mode 100644 upstream/container/platform/test/settings-navigation.test.mjs create mode 100644 upstream/container/platform/test/snapshots.test.mjs create mode 100644 upstream/container/platform/test/stage0.test.mjs create mode 100644 upstream/container/platform/test/supported-target.test.mjs create mode 100644 upstream/container/platform/test/system-plugins.test.mjs create mode 100644 upstream/container/platform/test/system-skills.test.mjs create mode 100644 upstream/container/platform/test/terminal-sessions.test.mjs create mode 100644 upstream/container/platform/test/totp.test.mjs create mode 100644 upstream/container/platform/test/updater.test.mjs create mode 100644 upstream/container/platform/test/user-plugin-transactions.test.mjs create mode 100644 upstream/container/platform/test/user-plugins.test.mjs create mode 100644 upstream/container/platform/test/user-roots.test.mjs create mode 100644 upstream/container/platform/test/user-skill-server.test.mjs create mode 100644 upstream/container/platform/test/user-skills.test.mjs create mode 100644 upstream/container/platform/test/workflows.test.mjs create mode 100644 upstream/container/platform/tools/build-release.mjs create mode 100644 upstream/container/platform/tools/build-seed.mjs create mode 100644 upstream/container/platform/tools/build-system-plugin-client.mjs create mode 100644 upstream/container/platform/tools/dsh-platform-shim.sh create mode 100644 upstream/container/platform/tools/dsh-shim.sh create mode 100644 upstream/container/platform/tools/environment-release.mjs create mode 100644 upstream/container/platform/tools/keyring.mjs create mode 100644 upstream/container/platform/tools/package-environment.mjs create mode 100644 upstream/container/platform/tools/prepare-release.mjs create mode 100644 upstream/container/platform/tools/sign.mjs create mode 100644 upstream/container/platform/tools/supported-target.mjs create mode 100644 upstream/container/platform/tools/verify-dsh-compatibility.mjs create mode 100644 upstream/container/platform/tools/verify-image-release.mjs create mode 100644 upstream/container/platform/tools/verify-management-dependencies.mjs create mode 100755 upstream/container/test/compose-config.mjs create mode 100755 upstream/container/test/container-smoke.sh create mode 100755 upstream/container/test/devtools-smoke.sh create mode 100644 upstream/container/test/managed-lifecycle-restart-smoke.mjs create mode 100644 upstream/container/test/profile-package-compatibility-smoke.sh create mode 100644 upstream/container/test/standalone-file-management-smoke.mjs create mode 100644 upstream/container/test/standalone-recovery-smoke.mjs create mode 100644 upstream/container/test/system-skill-discovery-smoke.mjs create mode 100644 upstream/container/test/user-skill-watcher-smoke.mjs create mode 100644 upstream/docker-compose.yaml create mode 100644 upstream/docker-sudoers create mode 100644 upstream/docs/cn/guide.md create mode 100644 upstream/docs/en/guide.md create mode 100644 upstream/release/official-dsh-policy.json create mode 100644 upstream/release/supported-target.json create mode 100755 upstream/scripts/bump-environment.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3bed478 --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# ── Обязательное ──────────────────────────────────────────────────────────── +# Ключ DeepSeek API (platform.deepseek.com). Без него dsh не сможет работать с моделью. +DEEPSEEK_API_KEY= + +# ── Образ ─────────────────────────────────────────────────────────────────── +# Свой реестр (обновляется: docker compose pull && docker compose -p dsh up -d) +DSH_IMAGE=gitea.archeon.io/archeon/dsh:latest +# Локальная сборка из этого репо (см. scripts/build-image.sh): +# DSH_IMAGE=dsh-local:0.2.0-rc.2 + +# ── Пути (path-агностичность) ─────────────────────────────────────────────── +# Каталог данных: по умолчанию ./data рядом с compose-файлом. +# Можно вынести куда угодно — абсолютный путь или другой каталог: +# DSH_DATA_DIR=/var/lib/dsh +DSH_DATA_DIR=./data +# Каталог проектов (воркспейс агента): +# DSH_WORKSPACE_DIR=/home/myuser/projects +DSH_WORKSPACE_DIR=./workspace + +# ── Сеть/порты ────────────────────────────────────────────────────────────── +# 127.0.0.1 — доступ только с этого хоста (за реверс-прокси/TLS). +# Для доступа из LAN впишите IP хоста или 0.0.0.0 и настройте DSH_TRUSTED_HOSTS. +DSH_LISTEN_ADDRESS=127.0.0.1 +DSH_PORT=3080 +# Порт code-server редактора (должен совпадать с настройкой плагина в UI!) +DSH_EDITOR_PORT=18500 +# DSH_TRUSTED_HOSTS=192.168.1.50,dsh.example.lan + +# ── dsh ───────────────────────────────────────────────────────────────────── +DSH_DEFAULT_WORKSPACE=/workspace +# true — дать агенту безлимитный root в песочницах (ОСОЗНАННО): +DSH_SUDO_ENABLED=false +DSH_TELEMETRY_DISABLED=true +# Секунды пробации experimental-обновления до автоподтверждения +DSH_EXPERIMENTAL_PROBATION_SECONDS=120 + +# ── Имена контейнеров (менять при конфликтах) ────────────────────────────── +DSH_CONTAINER_NAME=dsh +DSH_DIND_NAME=dsh-dind + +# ── Опционально: память агентов OpenViking ───────────────────────────────── +# OPENVIKING_URL=https://ov.example +# OPENVIKING_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89c71ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# локальные данные стенда и секреты — не коммитятся +.env +data/ +workspace/ +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a2b7a70 --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +MIT License + +Copyright (c) 2026 archeon / Pavel Zhukov (compose, scripts, docs) +Container source in `upstream/`: Copyright (c) 2026 yjrszcq — MIT +(https://github.com/yjrszcq/dsh-docker, см. upstream/LICENSE) + +Данный репозиторий распространяется по лицензии MIT; вендоренная копия +`upstream/` сохраняет своё исходное уведомление MIT. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba4db2e --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# dsh (DeepSeek Harness) — сборка archeon + +Самодостаточный репозиторий: Docker-образ [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) +(`@deepseek-ai/dsh`) + **обновление кода dsh прямо из работающего контейнера** +(как плагины, без пересборки) + **path/user-агностичный docker-compose** как +пример для разработчиков. + +## Что внутри + +| Путь | Что это | +|---|---| +| `upstream/` | вендоренный исходник Docker-образа ([yjrszcq/dsh-docker](https://github.com/yjrszcq/dsh-docker), MIT; см. `upstream/PROVENANCE.md`) | +| `docker-compose.yml` | path/user-агностичный стенд: dsh + вложенный dind + воркспейс | +| `.env.example` | все настройки в одном месте (пути, порты, ключи) | +| `scripts/update-dsh.sh` | **обновление dsh изнутри контейнера** до npm latest (пробация + автокат) | +| `scripts/update-plugins.sh` | установка/обновление плагинов профиля web + фикс code-server | +| `scripts/build-image.sh` | пересборка образа под конкретную версию dsh | +| `docs/UPDATE-MECHANISM.md` | как работает обновление изнутри (анатомия платформы) | + +## Быстрый старт + +```sh +cp .env.example .env # вписать DEEPSEEK_API_KEY +docker compose -p dsh up -d +docker compose -p dsh exec dsh dsh-platform access generate-key # одноразовый ключ админа +xdg-open http://127.0.0.1:3080 +``` + +Все пути по умолчанию относительные: данные — `./data/`, проекты — `./workspace/` +(рядом с compose-файлом). Перенос на другой хост/пользователя: `cp -r` всего +каталога (или переопределите `DSH_DATA_DIR`/`DSH_WORKSPACE_DIR` в `.env`). +Контейнер работает под uid 1000; если ваш uid другой: +`docker compose -p dsh --profile fix-perms run --rm fixperms`. + +## Обновление dsh «как плагины» (главная фича) + +```sh +scripts/update-dsh.sh # до npm latest: download → probation → commit +scripts/update-dsh.sh check # только посмотреть installed / available +``` + +- Ставится в персистентный volume `/data/platform` → переживает `down/up` и + пересоздание контейнера. +- **Важно**: механизм обновления живёт в stage0/management из **образа**, поэтому + работает только у живого контейнера (обновлять «выключенный» контейнер нельзя — + сначала `up -d`). При переходе на существенно новый образ заново проверьте + патчи окружения (`upstream/container/environment/resources/patches/`) против + новой версии dsh: образ собирается с dev-патчами, которые якорятся на + внутренности `bin.js`/`profile-boot` (в upstream пин версий стоит именно + поэтому). Наши патчи толерантны к 0.1.x и 0.2.x — проверены на обеих ветках. +- Experimental-канал платформы качает **официальный tarball с npm** (canonical + URL, sha256, без редиректов) и активирует новую версию с **пробацией** + (`DSH_EXPERIMENTAL_PROBATION_SECONDS`, по умолчанию 120 c) и **автооткатом** + при провале здоровья. +- Тот же механизм в UI: Management Console `/_dsh_platform/console/` + (Update → channel experimental → Update). +- Плагины профиля web живут в `/data/dsh` и обновляются отдельно + (`scripts/update-plugins.sh`). + +### Пересборка образа под конкретную версию + +Community-пин `latestSupportedDsh` (в upstream-исходнике = `0.1.5-rc.2`) в +dev-режиме сборки не проверяется — версию можно задать напрямую: + +```sh +scripts/build-image.sh 0.2.0-rc.2 --push # тегирует :<версия> + :latest и пушит +``` + +## Почему не официальный образ + +Официального образа у DeepSeek Harness нет; community-образ `szcq/deepseek-harness` +застрял на 0.1.5-rc.2 (пин автора). Этот репозиторий собирает свой образ из +того же исходника с актуальной версией и держит механизм обновления изнутри, +не дожидаясь пересборки. + +## Изоляция песочниц агента + +Контейнеры, которые запускает агент dsh, живут во **вложенном dind** +(`DOCKER_HOST=tcp://dind:2375`) — отдельный демон, отдельные образы/тома +(`./data/dind/`). Каталог проектов смонтирован в dind под тем же путём +`/workspace`, поэтому агент может бинд-маунтить свои песочницы на файлы +воркспейса. Порт web UI по умолчанию слушает только `127.0.0.1` — для доступа +из LAN настройте `DSH_LISTEN_ADDRESS` + `DSH_TRUSTED_HOSTS`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ba423e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,105 @@ +# ════════════════════════════════════════════════════════════════════════════ +# DeepSeek Harness (dsh) + вложенный dind для песочниц агента +# ════════════════════════════════════════════════════════════════════════════ +# Path/user-агностично: ВСЕ пути хоста — относительные (рядом с этим файлом) +# и переопределяются через .env. Работает у любого пользователя на любом хосте. +# +# Быстрый старт: +# cp .env.example .env # и вписать DEEPSEEK_API_KEY +# docker compose -p dsh up -d +# docker compose -p dsh exec dsh dsh-platform access generate-key # ключ первого входа +# UI: http://127.0.0.1:${DSH_PORT:-3080} +# +# Обновление dsh изнутри контейнера (как плагины): +# scripts/update-dsh.sh # до npm latest, с пробацией и откатом +# Management Console: /_dsh_platform/console/ +# +# Права на каталогах данных: контейнер работает под uid 1000 (node). +# Если ваш uid ≠ 1000: docker compose -p dsh --profile fix-perms run --rm fixperms +# ════════════════════════════════════════════════════════════════════════════ + +services: + dsh: + image: ${DSH_IMAGE:-gitea.archeon.io/archeon/dsh:latest} + # build: альтернатива — собрать прямо из этого репо: + # docker build --build-arg DSH_VERSION=<версия|latest> -t dsh-local upstream/ + # # затем DSH_IMAGE=dsh-local в .env + container_name: ${DSH_CONTAINER_NAME:-dsh} + restart: unless-stopped + depends_on: + dind: + condition: service_started + ports: + - "${DSH_LISTEN_ADDRESS:-127.0.0.1}:${DSH_PORT:-3080}:3080" # web UI + management console + - "${DSH_LISTEN_ADDRESS:-127.0.0.1}:${DSH_EDITOR_PORT:-18500}:18500" # code-server редактора + environment: + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} + DSH_HOME: /data/dsh + DSH_PLATFORM_DATA: /data/platform + DSH_DEFAULT_WORKSPACE: ${DSH_DEFAULT_WORKSPACE:-/workspace} + DSH_TRUSTED_HOSTS: ${DSH_TRUSTED_HOSTS:-} + DSH_TELEMETRY_DISABLED: ${DSH_TELEMETRY_DISABLED:-true} + DSH_EXPERIMENTAL_PROBATION_SECONDS: ${DSH_EXPERIMENTAL_PROBATION_SECONDS:-120} + DOCKER_HOST: tcp://dind:2375 # песочницы агента — во вложенный dind + # Опциональная память агентов (раскомментировать при необходимости): + # OPENVIKING_URL: ${OPENVIKING_URL} + # OPENVIKING_API_KEY: ${OPENVIKING_API_KEY} + group_add: + - "dsh-sudo-${DSH_SUDO_ENABLED:-false}" + volumes: + # Данные — относительные каталоги рядом с compose-файлом (переопределяются .env): + - ${DSH_DATA_DIR:-./data}/home:/home/node # HOME: .dsh-editor, кэши, user-data + - ${DSH_DATA_DIR:-./data}/dsh:/data/dsh # DSH_HOME: профили, ПЛАГИНЫ, settings.yaml + - ${DSH_DATA_DIR:-./data}/platform:/data/platform # состояние платформы: ОБНОВЛЕНИЯ, откаты + - ${DSH_WORKSPACE_DIR:-./workspace}:/workspace # проекты (= DSH_DEFAULT_WORKSPACE) + # Глобальные правила агента (опционально): раскомментировать и положить AGENTS.md + # рядом с compose-файлом: + # - ./AGENTS.md:/data/dsh/AGENTS.md:ro + networks: [dshnet] + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "--noproxy", "*", "http://127.0.0.1:3080/_dsh_gateway/health"] + interval: 30s + timeout: 5s + start_period: 60s + retries: 3 + + # Вложенный docker-демон: контейнеры, которые запускает агент dsh. + dind: + image: docker:29-dind + container_name: ${DSH_DIND_NAME:-dsh-dind} + restart: unless-stopped + privileged: true + environment: + DOCKER_TLS_CERTDIR: "" # plain tcp://2375 — только внутри сети dshnet + command: + - --host=tcp://0.0.0.0:2375 + - --default-address-pool=base=10.200.0.0/16,size=24 + volumes: + - ${DSH_DATA_DIR:-./data}/dind:/var/lib/docker # образы/тома песочниц — персистентны + - ${DSH_WORKSPACE_DIR:-./workspace}:/workspace # ТЕ ЖЕ проекты под ТЕМ ЖЕ путём: + # агент монтирует /workspace/... в песочницы + networks: [dshnet] + + # Разовый помощник: чинит владельца каталогов данных под uid 1000 (контейнерный node). + # Запуск: docker compose -p dsh --profile fix-perms run --rm fixperms + fixperms: + image: alpine:3.20 + profiles: ["fix-perms"] + user: "0:0" + entrypoint: ["sh", "-c"] + command: + - | + echo "Fixing ownership of data dirs to uid 1000 (container user 'node')..."; + chown -R 1000:1000 /fix/home /fix/dsh /fix/platform /fix/workspace /fix/dind 2>/dev/null || true; + chgrp -R 1000 /fix/home /fix/dsh /fix/platform /fix/workspace /fix/dind 2>/dev/null || true; + echo "Done." + volumes: + - ${DSH_DATA_DIR:-./data}/home:/fix/home + - ${DSH_DATA_DIR:-./data}/dsh:/fix/dsh + - ${DSH_DATA_DIR:-./data}/platform:/fix/platform + - ${DSH_DATA_DIR:-./data}/dind:/fix/dind + - ${DSH_WORKSPACE_DIR:-./workspace}:/fix/workspace + +networks: + dshnet: + driver: bridge diff --git a/docs/UPDATE-MECHANISM.md b/docs/UPDATE-MECHANISM.md new file mode 100644 index 0000000..c52af6f --- /dev/null +++ b/docs/UPDATE-MECHANISM.md @@ -0,0 +1,74 @@ +# Анатомия обновления dsh изнутри контейнера + +Зачем: у `dsh` CLI нет команды update, а community-образ пересобирает автор по +своему пину (это и есть причина «показывает 0.1.5, когда на npm уже 0.2.x»). +Контейнер платформы решает это собственным механизмом обновления — им и пользуемся. + +## Кто есть кто в контейнере + +- **stage0** (PID 1, root) — супервизор платформы: trust-граница, bootstrap, + management-сервис, gateway; живёт в образе (`/opt/dsh-platform/runtime`). +- **dsh runtime** — сам агент; работает под `node` (uid 1000) через + `/run/dsh-platform/views/runtime/bin/dsh` (view поверх управляемого деплоя). +- `/usr/local/bin/dsh` и `/usr/local/bin/dsh-platform` — **шимы** (не бинарники): + root-вызовы дропают привилегии в node и пробрасывают переменные окружения. +- **management console** (`/_dsh_platform/console/`) + CLI `dsh-platform` — + управление: статус, каналы, update, rollback, логи. + +## Где живут данные (и почему обновление персистентно) + +| Путь контейнера | Что там | Volume | +|---|---|---| +| `/data/dsh` | DSH_HOME: профили, **плагины**, settings.yaml, сессии | да | +| `/data/platform` | **деплой платформы: runtime-слоты, pristine-копии dsh, snapshots, журнал обновлений** | да | +| `/home/node` | HOME: `.dsh-editor` (code-server), кэши | да | +| `/opt/dsh-platform` | код платформы из образа | нет (образ) | + +Experimental-обновление активирует новую версию dsh **в `/data/platform`** → +переживает пересоздание контейнера. Откат — тоже (предыдущий runtime-слот). + +## Два канала обновления + +- **stable** — подписанные манифесты автора образа (release-channel на GitHub). + Качает bootstrap+environment+dsh. Работает только когда автор выпустил релиз + (для 0.2.x его ещё нет — и потому stable-канал нам не подходит). +- **experimental** — качает **напрямую с npm** (`OfficialDshDownloader`: + canonical `https://registry.npmjs.org/@deepseek-ai/dsh/-/dsh-.tgz`, + запрет редиректов, контроль sha256/размера, лимиты), поверх текущего + bootstrap/environment. Пин `latestSupportedDsh` здесь не блокирует: он + автовыдвигается (`advanceSupportedDsh`), а gate для experimental — только + «версия больше текущей» (`NpmRegistryClient.latest`). + +## Командный путь обновления (что делает `scripts/update-dsh.sh`) + +1. `dsh-platform channel experimental` — переключить канал (PUT `/channel`). +2. `dsh-platform update --wait` — coordinator: скачать tarball с npm → + «candidate deployment» → пауза dsh-runtime → активация → resume. +3. Пробация `DSH_EXPERIMENTAL_PROBATION_SECONDS` (по умолчанию 120 c): + платформа мониторит здоровье dsh; провал → **автооткат** к предыдущему + деплою; успех → candidate коммитится (`deployments/candidate/commit`). +4. Проверка: `dsh --version` == npm latest. + +Автопроверка обновлений платформой: раз в ~6 ч (21600 c), отключается/настраивается +в Management Console (Automatic checks). + +## Если что-то пошло не так + +```sh +docker exec dsh dsh-platform status +docker exec dsh-platform logs --source updater --limit 100 +docker exec dsh dsh-platform rollback # вернуться к предыдущему деплою +``` + +Coordinator сам восстанавливается после прерванного обновления (journal + +snapshots + `recover`). Кнопка «return stable» в консоли откатывает и +данные-снапшот (требует интерактивного подтверждения — это осознанная потеря +данных новее снапшота). + +## Известные ограничения + +- Experimental ставит **только npm dist-tag `latest`** (политика платформы). + Конкретная версия — путь `scripts/build-image.sh ` + новая `DSH_IMAGE`. +- Плагины и профили (`/data/dsh`) обновлением dsh не трогаются; при мажорном + скачке возможна несовместимость плагинов — лечение: `dsh plugin --profile web update`. +- Stage0/bootstrap/environment остаются от образа; обновляется только слой dsh. diff --git a/scripts/build-image.sh b/scripts/build-image.sh new file mode 100644 index 0000000..da58216 --- /dev/null +++ b/scripts/build-image.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# ════════════════════════════════════════════════════════════════════════════ +# Сборка образа dsh из вендоренного исходника (upstream/) в локальный Docker. +# Пин сообщества (latestSupportedDsh) в dev-режиме сборки не действует — версию +# задаём явно. Результат тегируется в реестр archeon. +# +# Использование: +# scripts/build-image.sh # версия = npm dist-tag latest +# scripts/build-image.sh 0.2.0-rc.2 # конкретная версия +# scripts/build-image.sh 0.2.0-rc.2 --push # + docker push в реестр +# ════════════════════════════════════════════════════════════════════════════ +set -eu +cd "$(dirname "$0")/.." + +VERSION=${1:-} +PUSH=0 +[ "${2:-}" = --push ] && PUSH=1 +if [ -z "$VERSION" ] || [ "$VERSION" = latest ]; then + VERSION=$(curl -fsS https://registry.npmjs.org/-/package/@deepseek-ai%2Fdsh/dist-tags | sed -n 's/.*"latest":"\([^"]*\)".*/\1/p') + [ -n "$VERSION" ] || { echo "не удалось определить npm latest" >&2; exit 1; } +fi + +IMAGE=${DSH_IMAGE:-gitea.archeon.io/archeon/dsh} +REPO=${IMAGE%%:*} +echo "=== Сборка $REPO:$VERSION (DSH_VERSION=$VERSION)" +DOCKER_BUILDKIT=1 docker build \ + --build-arg DSH_VERSION="$VERSION" \ + -t "$REPO:$VERSION" \ + -t "$REPO:latest" \ + upstream/ + +echo "=== Проверка: версия внутри образа" +docker run --rm --entrypoint /bin/sh "$REPO:$VERSION" \ + -c "grep -o '\"version\":\"[^\"]*\"' /opt/dsh-platform/seed/pristine/*/package.json | head -1" + +if [ "$PUSH" = 1 ]; then + echo "=== Push в реестр" + docker push "$REPO:$VERSION" + docker push "$REPO:latest" +fi +echo "Готово: $REPO:$VERSION (+ latest). Обновить стенд: docker compose -p dsh pull && docker compose -p dsh up -d" diff --git a/scripts/update-dsh.sh b/scripts/update-dsh.sh new file mode 100644 index 0000000..388debe --- /dev/null +++ b/scripts/update-dsh.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# ════════════════════════════════════════════════════════════════════════════ +# Обновление dsh внутри работающего контейнера — «как плагины», без пересборки. +# +# Механизм: management console платформы, канал experimental — ставит npm-версию +# @deepseek-ai/dsh в /data/platform ( персистентный volume), с пробацией и +# автоматическим откатом при неудаче. Код dsh переживает пересоздание контейнера. +# +# Использование: +# scripts/update-dsh.sh # до npm dist-tag latest +# scripts/update-dsh.sh check # только показать текущую/доступную версии +# scripts/update-dsh.sh --container X # другое имя контейнера (по умолчанию dsh) +# +# Примечания: +# - experimental ставит ТОЛЬКО npm dist-tag latest (вверх). Конкретная версия — +# scripts/build-image.sh <версия> + смена DSH_IMAGE в .env. +# - Плагины профиля web (/data/dsh) не трогаются и переживают обновление. +# ════════════════════════════════════════════════════════════════════════════ +set -eu + +CONTAINER=dsh +ACTION=update +while [ $# -gt 0 ]; do + case "$1" in + --container) CONTAINER="$2"; shift 2 ;; + check) ACTION=check; shift ;; + *) echo "unknown arg: $1" >&2; exit 64 ;; + esac +done + +say() { printf '\n=== %s\n' "$*"; } + +docker inspect -f '{{.State.Running}}' "$CONTAINER" >/dev/null 2>&1 || { + echo "Контейнер '$CONTAINER' не запущен. Сначала: docker compose -p dsh up -d" >&2; exit 1; } + +say "Текущая версия" +CURRENT=$(docker exec "$CONTAINER" dsh --version) +echo "installed: $CURRENT" + +say "Доступно на npm" +TARGET=$(docker exec "$CONTAINER" node -e "fetch('https://registry.npmjs.org/-/package/@deepseek-ai%2Fdsh/dist-tags').then(r=>r.json()).then(d=>console.log(d.latest)).catch(e=>{console.error(e.message);process.exit(1)})") +echo "npm latest: $TARGET" + +[ "$ACTION" = check ] && exit 0 + +if [ "$CURRENT" = "$TARGET" ]; then + echo "Уже актуально ($CURRENT). Выход."; exit 0 +fi + +say "Переключаю канал на experimental" +docker exec "$CONTAINER" dsh-platform channel experimental + +say "Запускаю обновление (скачивание+установка+рестарт рантайма; это минуты)" +if ! docker exec "$CONTAINER" dsh-platform update --wait; then + echo "ОШИБКА: обновление не прошло. Диагностика:" >&2 + docker exec "$CONTAINER" dsh-platform status || true + docker exec "$CONTAINER" dsh-platform logs --source updater --limit 50 || true + echo "Платформа обычно сама откатилась к $CURRENT. Повтор: scripts/update-dsh.sh" >&2 + exit 1 +fi + +PROBATION=$(docker exec "$CONTAINER" printenv DSH_EXPERIMENTAL_PROBATION_SECONDS || echo 120) +say "Пробация ${PROBATION}s — жду автоподтверждение" +WAIT=$((PROBATION + 180)); I=0 +while [ "$I" -lt "$WAIT" ]; do + NEW=$(docker exec "$CONTAINER" dsh --version 2>/dev/null || echo '?') + if [ "$NEW" = "$TARGET" ]; then + say "Готово: dsh $NEW (было $CURRENT)" + docker exec "$CONTAINER" dsh-platform status || true + exit 0 + fi + sleep 15; I=$((I + 15)) +done + +echo "Таймаут ожидания. Текущая версия: $(docker exec "$CONTAINER" dsh --version 2>/dev/null || echo '?')." >&2 +echo "Проверьте вручную: docker exec $CONTAINER dsh-platform status" >&2 +exit 1 diff --git a/scripts/update-plugins.sh b/scripts/update-plugins.sh new file mode 100644 index 0000000..b986adc --- /dev/null +++ b/scripts/update-plugins.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# ════════════════════════════════════════════════════════════════════════════ +# Обновление плагинов профиля web (и фикс bind-адреса code-server после этого). +# CLI-апдейт dsh НЕ трогает плагины — они обновляются отдельно. +# +# Использование: +# scripts/update-plugins.sh [--add github:/] [--no-restart] +# ════════════════════════════════════════════════════════════════════════════ +set -eu +CONTAINER=${DSH_CONTAINER_NAME:-dsh} +ADD="" +RESTART=1 +while [ $# -gt 0 ]; do + case "$1" in + --add) ADD="$2"; shift 2 ;; + --no-restart) RESTART=0; shift ;; + *) echo "unknown arg: $1" >&2; exit 64 ;; + esac +done + +docker inspect -f '{{.State.Running}}' "$CONTAINER" >/dev/null 2>&1 || { + echo "Контейнер '$CONTAINER' не запущен" >&2; exit 1; } + +# Внутренний редирект: без него docker exec может виснуть на фоновом потомке pnpm. +if [ -n "$ADD" ]; then + echo "=== Установка плагина: $ADD" + docker exec "$CONTAINER" sh -c "dsh plugin --profile web add '$ADD' >/tmp/dsh-plugin.log 2>&1; tail -5 /tmp/dsh-plugin.log" +fi + +echo "=== Обновление всех плагинов профиля web" +docker exec "$CONTAINER" sh -c "dsh plugin --profile web update >/tmp/dsh-plugin.log 2>&1; tail -20 /tmp/dsh-plugin.log" + +echo "=== Фикс bind-адреса code-server (127.0.0.1 -> 0.0.0.0), иначе порт 18500 снаружи refused" +docker exec "$CONTAINER" sh -c \ + "sed -i \"s/'--bind-addr', '127.0.0.1:' + port/'--bind-addr', '0.0.0.0:' + port/\" /data/dsh/profiles/web/node_modules/dsh-vsceditor/lib/host.js 2>/dev/null || echo '(dsh-vsceditor не установлен — пропуск)'" + +if [ "$RESTART" = 1 ]; then + echo "=== Рестарт контейнера (pnpm пересобрал граф node_modules под живой процесс)" + docker restart "$CONTAINER" + echo "ОК. Дождитесь healthy: docker inspect -f '{{.State.Health.Status}}' $CONTAINER" +fi diff --git a/upstream/.dockerignore b/upstream/.dockerignore new file mode 100644 index 0000000..558f798 --- /dev/null +++ b/upstream/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.checkpoint-karpathy +.env +temp +data +**/node_modules +container/**/test +container/**/test/** +container/**/tests +container/**/tests/** +container/test +workspace +README.md +README_CN.md +docker-compose.yaml diff --git a/upstream/.env.example b/upstream/.env.example new file mode 100644 index 0000000..72c512b --- /dev/null +++ b/upstream/.env.example @@ -0,0 +1,33 @@ +# Docker image tag +DSH_IMAGE_TAG=latest + +# Host port binding +DSH_LISTEN_ADDRESS=127.0.0.1 +DSH_PORT=3080 + +# Local directory mounted as /workspace +DSH_WORKSPACE=./workspace + +# Set true only to grant DSH and its Agent unrestricted passwordless root access +DSH_SUDO_ENABLED=false + +# Initial workspace directory shown inside the container +DSH_DEFAULT_WORKSPACE=/workspace + +# true disables telemetry; false enables it +DSH_TELEMETRY_DISABLED=true + +# Inject a crypto.randomUUID compatibility shim when the browser lacks it +DSH_PROXY_POLYFILL=true + +# Required for access through LAN IPs or domains. Use comma-separated host[:port] +# values without schemes or paths. Empty accepts loopback Hosts only; * accepts any Host. +DSH_TRUSTED_HOSTS= + +# Platform update activation +DSH_ACTIVATION_TIMEOUT_SECONDS=60 +DSH_EXPERIMENTAL_PROBATION_SECONDS=120 + +# Unified platform JSONL log retention +DSH_LOG_MAX_BYTES=104857600 +DSH_LOG_RETENTION_DAYS=14 diff --git a/upstream/.github/workflows/docker.yaml b/upstream/.github/workflows/docker.yaml new file mode 100644 index 0000000..3c8a2ce --- /dev/null +++ b/upstream/.github/workflows/docker.yaml @@ -0,0 +1,331 @@ +name: Publish Container Images + +on: + workflow_run: + workflows: + - Publish Supported Platform Target + types: + - completed + workflow_dispatch: + inputs: + source_commit: + description: Source commit to build + required: true + default: main + type: string + channel_commit: + description: release-channel ref to search for the matching signed target + required: false + default: release-channel + type: string + +concurrency: + group: production-container-images + cancel-in-progress: false + +env: + DOCKERHUB_IMAGE: szcq/deepseek-harness + GHCR_IMAGE: ghcr.io/${{ github.repository }} + +jobs: + build: + name: Verify, test, and publish images + if: >- + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main') || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 90 + environment: production-image + permissions: + contents: read + packages: write + steps: + - name: Checkout reviewed source + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || inputs.source_commit }} + path: source + + - name: Checkout signed channel history + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.channel_commit || 'release-channel' }} + fetch-depth: 0 + path: channel + + - name: Resolve immutable signed channel state + id: channel + run: | + source_commit="$(git -C source rev-parse HEAD)" + channel_commit= + while IFS= read -r candidate; do + candidate_source="$(git -C channel show "${candidate}:channel.json" | jq -er .sourceCommit)" + if test "$candidate_source" = "$source_commit"; then + channel_commit="$candidate" + break + fi + done < <(git -C channel log --format=%H -- channel.json) + if test -z "$channel_commit"; then + echo "No signed release-channel target matches source commit $source_commit" >&2 + exit 1 + fi + git -C channel checkout --detach "$channel_commit" + { + printf 'commit=%s\n' "$channel_commit" + printf 'source_commit=%s\n' "$source_commit" + } >> "$GITHUB_OUTPUT" + # shellcheck disable=SC2016 + printf 'Using signed channel commit `%s` for source commit `%s`.\n' \ + "$channel_commit" "$source_commit" >> "$GITHUB_STEP_SUMMARY" + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + source/container/control-plane/services/management/package-lock.json + source/container/environment/resources/plugins/platform-management/package/package-lock.json + + - name: Install Management runtime dependencies + working-directory: source + run: | + npm ci --omit=dev --ignore-scripts --no-audit --no-fund --prefix container/control-plane/services/management + npm ci --omit=dev --ignore-scripts --no-audit --no-fund --legacy-peer-deps --prefix container/environment/resources/plugins/platform-management/package + + - name: Verify gateway and Compose configuration + working-directory: source + run: | + npm test --prefix container/control-plane/services/gateway + npm test --prefix container/platform + node container/test/compose-config.mjs + sh -n container/test/container-smoke.sh + sh -n container/test/devtools-smoke.sh + + - name: Verify signed Supported Target + id: signed-target + working-directory: source + env: + RECOVERY_ROOT_PUBLIC_KEY: ${{ secrets.DSH_RECOVERY_ROOT_PUBLIC_KEY }} + run: | + test -n "$RECOVERY_ROOT_PUBLIC_KEY" + source_commit="$(git rev-parse HEAD)" + test "$(jq -er .sourceCommit ../channel/channel.json)" = "$source_commit" + sequence="$(jq -er .targetSequence ../channel/stable.json)" + test "$(jq -er .targetSequence ../channel/channel.json)" = "$sequence" + release_dir="../channel/targets/$sequence" + test -d "$release_dir" + for name in stable.json stable.sig.json keyring.json keyring.sig.json; do + cmp "../channel/$name" "$release_dir/$name" + done + image_input=container/platform/image-input + mkdir -p "$image_input/release" + cp -a "$release_dir/." "$image_input/release/" + printf '%s\n' "$RECOVERY_ROOT_PUBLIC_KEY" > "$image_input/recovery-root.spki.base64" + cp release/supported-target.json "$image_input/supported-target.json" + cp container/environment/definition.json "$image_input/environment-definition.json" + version="$(jq -er '.latestSupportedDsh' release/supported-target.json)" + npm_input="$RUNNER_TEMP/image-npm-input" + mkdir -p "$npm_input" + npm pack "@deepseek-ai/dsh@${version}" --pack-destination "$npm_input" >/dev/null + tarball="$(find "$npm_input" -maxdepth 1 -type f -name '*.tgz' -print -quit)" + test -n "$tarball" + cp "$tarball" "$image_input/dsh.tgz" + verified="$(node container/platform/tools/verify-image-release.mjs \ + "$image_input/release" "$image_input/recovery-root.spki.base64" "$image_input/dsh.tgz" \ + "$image_input/supported-target.json" "$image_input/environment-definition.json")" + test "$(jq -er .dshVersion <<<"$verified")" = "$version" + test "$(jq -er .targetSequence <<<"$verified")" = "$sequence" + environment_version="$(jq -er .environmentVersion <<<"$verified")" + test "$environment_version" = "$(jq -er .version container/environment/definition.json)" + { + printf 'version=%s\n' "$version" + printf 'environment_version=%s\n' "$environment_version" + printf 'sequence=%s\n' "$sequence" + } >> "$GITHUB_OUTPUT" + cp "$image_input/recovery-root.spki.base64" container/platform/seed/trust/recovery-root.spki.base64 + cp "$release_dir/keyring.json" container/platform/seed/trust/keyring.json + cp "$release_dir/keyring.sig.json" container/platform/seed/trust/keyring.sig.json + rm container/platform/seed/trust/DEVELOPMENT_FIXTURE + test ! -e container/platform/seed/trust/DEVELOPMENT_FIXTURE + printf 'Building signed DSH %s with Environment %s (sequence %s).\n' \ + "$version" "$environment_version" "$sequence" >> "$GITHUB_STEP_SUMMARY" + + - name: Generate registry tags + id: tags + env: + DSH_VERSION: ${{ steps.signed-target.outputs.version }} + ENVIRONMENT_VERSION: ${{ steps.signed-target.outputs.environment_version }} + run: | + [[ "$ENVIRONMENT_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + { + echo 'standard<> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build standard smoke-test image + uses: docker/build-push-action@v7 + with: + context: ./source + file: ./source/Dockerfile + platforms: linux/amd64 + load: true + provenance: false + build-args: | + SIGNED_IMAGE_INPUT=true + DSH_VERSION=${{ steps.signed-target.outputs.version }} + ENVIRONMENT_VERSION=${{ steps.signed-target.outputs.environment_version }} + TARGET_SEQUENCE=${{ steps.signed-target.outputs.sequence }} + PLATFORM_REVISION=${{ steps.channel.outputs.source_commit }} + tags: dsh-gateway-smoke:${{ github.run_id }} + cache-from: type=gha,scope=standard + cache-to: type=gha,mode=max,scope=standard + + - name: Run standard container smoke checks + working-directory: source + run: container/test/container-smoke.sh "dsh-gateway-smoke:${{ github.run_id }}" + + - name: Build devtools smoke-test image + uses: docker/build-push-action@v7 + with: + context: ./source + file: ./source/Dockerfile + platforms: linux/amd64 + load: true + provenance: false + build-args: | + SIGNED_IMAGE_INPUT=true + DSH_VERSION=${{ steps.signed-target.outputs.version }} + ENVIRONMENT_VERSION=${{ steps.signed-target.outputs.environment_version }} + TARGET_SEQUENCE=${{ steps.signed-target.outputs.sequence }} + PLATFORM_REVISION=${{ steps.channel.outputs.source_commit }} + INSTALL_DEVTOOLS=true + tags: dsh-gateway-devtools-smoke:${{ github.run_id }} + cache-from: type=gha,scope=devtools + cache-to: type=gha,mode=max,scope=devtools + + - name: Run devtools smoke checks + working-directory: source + run: container/test/devtools-smoke.sh "dsh-gateway-devtools-smoke:${{ github.run_id }}" + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: szcq + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Push versioned devtools image to Docker Hub + uses: docker/build-push-action@v7 + with: + context: ./source + file: ./source/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + provenance: false + build-args: | + SIGNED_IMAGE_INPUT=true + DSH_VERSION=${{ steps.signed-target.outputs.version }} + ENVIRONMENT_VERSION=${{ steps.signed-target.outputs.environment_version }} + TARGET_SEQUENCE=${{ steps.signed-target.outputs.sequence }} + PLATFORM_REVISION=${{ steps.channel.outputs.source_commit }} + INSTALL_DEVTOOLS=true + tags: ${{ steps.tags.outputs.devtools }} + cache-from: type=gha,scope=devtools + cache-to: type=gha,mode=max,scope=devtools + + - name: Push versioned standard image to Docker Hub and GHCR + uses: docker/build-push-action@v7 + with: + context: ./source + file: ./source/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + provenance: false + build-args: | + SIGNED_IMAGE_INPUT=true + DSH_VERSION=${{ steps.signed-target.outputs.version }} + ENVIRONMENT_VERSION=${{ steps.signed-target.outputs.environment_version }} + TARGET_SEQUENCE=${{ steps.signed-target.outputs.sequence }} + PLATFORM_REVISION=${{ steps.channel.outputs.source_commit }} + tags: ${{ steps.tags.outputs.standard }} + cache-from: type=gha,scope=standard + cache-to: type=gha,mode=max,scope=standard + + - name: Promote Docker Hub rolling tags + env: + DSH_VERSION: ${{ steps.signed-target.outputs.version }} + run: | + docker buildx imagetools create \ + --tag "${DOCKERHUB_IMAGE}:latest-devtools" \ + "${DOCKERHUB_IMAGE}:${DSH_VERSION}-devtools" + docker buildx imagetools create \ + --tag "${DOCKERHUB_IMAGE}:latest" \ + "${DOCKERHUB_IMAGE}:${DSH_VERSION}" + + - name: Verify published registry manifests + env: + DSH_VERSION: ${{ steps.signed-target.outputs.version }} + ENVIRONMENT_VERSION: ${{ steps.signed-target.outputs.environment_version }} + run: | + docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${DSH_VERSION}" + docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${DSH_VERSION}-devtools" + docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:latest" + docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:latest-devtools" + docker buildx imagetools inspect "${GHCR_IMAGE}:${ENVIRONMENT_VERSION}" + docker buildx imagetools inspect "${GHCR_IMAGE}:dsh-${DSH_VERSION}" + + outputs: + dsh_version: ${{ steps.signed-target.outputs.version }} + environment_version: ${{ steps.signed-target.outputs.environment_version }} + sequence: ${{ steps.signed-target.outputs.sequence }} + + notify-result: + name: Notify container image publication result + needs: build + if: >- + always() && + (needs.build.result == 'success' || needs.build.result == 'failure') + uses: yjrszcq/github-workflows/.github/workflows/gotify-notify.yml@c4c7fe9e17854cd4962913ac2792513eab0be988 + with: + title: >- + ${{ + needs.build.result == 'success' && + format('DSH {0} 镜像已发布', needs.build.outputs.dsh_version) || + 'DSH 镜像发布失败' + }} + message: >- + ${{ + needs.build.result == 'success' && + format('DSH {0} 与 Environment {1} 的标准镜像已同步到 Docker Hub 和 GHCR,Devtools 镜像已发布到 Docker Hub;签名目标序列为 {2}。', needs.build.outputs.dsh_version, needs.build.outputs.environment_version, needs.build.outputs.sequence) || + format('Docker Hub 或 GHCR 镜像的构建、测试、推送或验证失败。请查看 GitHub Actions:{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) + }} + priority: ${{ needs.build.result == 'failure' && 8 || 7 }} + secrets: + gotify_url: ${{ secrets.GOTIFY_URL }} + gotify_token: ${{ secrets.GOTIFY_TOKEN }} diff --git a/upstream/.github/workflows/dsh-candidate-validation.yml b/upstream/.github/workflows/dsh-candidate-validation.yml new file mode 100644 index 0000000..f28b091 --- /dev/null +++ b/upstream/.github/workflows/dsh-candidate-validation.yml @@ -0,0 +1,133 @@ +name: DSH Candidate Validation + +on: + workflow_call: + inputs: + ref: + description: Candidate commit to validate + required: true + type: string + workflow_dispatch: + inputs: + ref: + description: Branch, tag, or commit to validate (defaults to the selected workflow ref) + required: false + type: string + pull_request_target: + branches: + - main + - dev + paths: + - release/supported-target.json + - container/** + - Dockerfile + - docker-compose.yaml + - .github/workflows/dsh-candidate-validation.yml + +concurrency: + group: dsh-candidate-${{ inputs.ref || github.event.pull_request.head.sha || github.sha }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + name: Build candidate with current Environment + if: >- + github.event_name != 'pull_request_target' || + github.event.pull_request.head.ref != 'automation/dsh-upstream-candidate' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout candidate + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + container/control-plane/services/management/package-lock.json + container/environment/resources/plugins/platform-management/package/package-lock.json + + - name: Install Management runtime dependencies + run: | + npm ci --omit=dev --ignore-scripts --no-audit --no-fund --prefix container/control-plane/services/management + npm ci --omit=dev --ignore-scripts --no-audit --no-fund --legacy-peer-deps --prefix container/environment/resources/plugins/platform-management/package + + - name: Resolve and verify candidate + id: candidate + run: | + result="$(node container/platform/tools/supported-target.mjs validate \ + release/supported-target.json container/environment/definition.json)" + version="$(jq -er '.target.latestSupportedDsh' <<<"$result")" + environment="$(jq -er '.target.environment' <<<"$result")" + registry_version="$(npm view "@deepseek-ai/dsh@${version}" version --json | jq -er 'if type == "string" then . else error("expected one version") end')" + test "$registry_version" = "$version" + printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" + printf 'environment=%s\n' "$environment" >> "$GITHUB_OUTPUT" + # shellcheck disable=SC2016 + { + printf '### Candidate Runtime\n\n' + printf -- '- DSH: `%s`\n' "$version" + printf -- '- Environment: `%s`\n' "$environment" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Verify upstream npm Artifact integrity + env: + DSH_VERSION: ${{ steps.candidate.outputs.version }} + run: | + mkdir "$RUNNER_TEMP/dsh-package" + expected="$(npm view "@deepseek-ai/dsh@${DSH_VERSION}" dist.integrity --json | jq -er 'if type == "string" and startswith("sha512-") then . else error("missing SHA-512 integrity") end')" + npm pack "@deepseek-ai/dsh@${DSH_VERSION}" --pack-destination "$RUNNER_TEMP/dsh-package" >/dev/null + tarball="$(find "$RUNNER_TEMP/dsh-package" -maxdepth 1 -type f -name '*.tgz' -print -quit)" + test -n "$tarball" + actual="sha512-$(openssl dgst -sha512 -binary "$tarball" | base64 -w 0)" + test "$actual" = "$expected" + + - name: Run static compatibility checks + run: | + npm test --prefix container/control-plane/services/gateway + npm test --prefix container/platform + node container/test/compose-config.mjs + sh -n container/test/container-smoke.sh + sh -n container/test/devtools-smoke.sh + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build candidate smoke image + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + load: true + platforms: linux/amd64 + provenance: false + build-args: | + DSH_VERSION=${{ steps.candidate.outputs.version }} + tags: dsh-candidate:${{ github.run_id }} + + - name: Run candidate container smoke checks + run: container/test/container-smoke.sh "dsh-candidate:${{ github.run_id }}" + + - name: Build candidate devtools image + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + load: true + platforms: linux/amd64 + provenance: false + build-args: | + DSH_VERSION=${{ steps.candidate.outputs.version }} + INSTALL_DEVTOOLS=true + tags: dsh-candidate-devtools:${{ github.run_id }} + + - name: Run candidate devtools smoke checks + run: container/test/devtools-smoke.sh "dsh-candidate-devtools:${{ github.run_id }}" diff --git a/upstream/.github/workflows/dsh-upstream-update.yml b/upstream/.github/workflows/dsh-upstream-update.yml new file mode 100644 index 0000000..9ac6199 --- /dev/null +++ b/upstream/.github/workflows/dsh-upstream-update.yml @@ -0,0 +1,237 @@ +name: DSH Upstream Update + +on: + schedule: + - cron: "0 4 * * *" + workflow_dispatch: + +concurrency: + group: dsh-upstream-update + cancel-in-progress: false + +permissions: + contents: read + +env: + BASE_BRANCH: dev + CANDIDATE_BRANCH: automation/dsh-upstream-candidate + DSH_PACKAGE: "@deepseek-ai/dsh" + SUPPORTED_TARGET: release/supported-target.json + ENVIRONMENT_DEFINITION: container/environment/definition.json + +jobs: + discover: + name: Discover and propose upstream DSH + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + changed: ${{ steps.candidate.outputs.changed }} + current: ${{ steps.candidate.outputs.current }} + upstream: ${{ steps.candidate.outputs.upstream }} + environment: ${{ steps.candidate.outputs.environment }} + pull_request_url: ${{ steps.propose.outputs.pull_request_url }} + pull_request_number: ${{ steps.propose.outputs.pull_request_number }} + head_sha: ${{ steps.propose.outputs.head_sha }} + + steps: + - name: Checkout protected branch + uses: actions/checkout@v6 + with: + ref: ${{ env.BASE_BRANCH }} + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Resolve latest official DSH + id: upstream + run: | + npm view "${DSH_PACKAGE}@latest" version --json > "$RUNNER_TEMP/dsh-version.json" + version="$(jq -er 'if type == "string" then . else error("expected one version") end' "$RUNNER_TEMP/dsh-version.json")" + printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" + + - name: Advance candidate Supported Target + id: candidate + env: + GH_TOKEN: ${{ github.token }} + UPSTREAM_VERSION: ${{ steps.upstream.outputs.version }} + run: | + base_sha="$(git rev-parse HEAD)" + current="$(jq -er '.latestSupportedDsh' "$SUPPORTED_TARGET")" + trusted_platform="$RUNNER_TEMP/trusted-platform" + mkdir -p "$trusted_platform/tools" + cp -R container/platform/lib "$trusted_platform/lib" + cp container/platform/tools/supported-target.mjs "$trusted_platform/tools/supported-target.mjs" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + candidate_remote_sha="$(git ls-remote --heads origin "$CANDIDATE_BRANCH" | cut -f 1)" + git switch --create "$CANDIDATE_BRANCH" "$base_sha" + result="$(node "$trusted_platform/tools/supported-target.mjs" advance \ + "$SUPPORTED_TARGET" "$ENVIRONMENT_DEFINITION" "$UPSTREAM_VERSION")" + changed="$(jq -r 'if (.changed | type) == "boolean" then .changed else error("expected boolean changed") end' <<<"$result")" + environment="$(jq -er '.target.environment' <<<"$result")" + { + printf 'changed=%s\n' "$changed" + printf 'current=%s\n' "$current" + printf 'upstream=%s\n' "$UPSTREAM_VERSION" + printf 'environment=%s\n' "$environment" + printf 'candidate_remote_sha=%s\n' "$candidate_remote_sha" + } >> "$GITHUB_OUTPUT" + # shellcheck disable=SC2016 + { + printf '### DSH upstream discovery\n\n' + printf -- '- Latest Supported: `%s`\n' "$current" + printf -- '- Latest Official: `%s`\n' "$UPSTREAM_VERSION" + printf -- '- Environment: `%s`\n' "$environment" + printf -- '- Candidate required: `%s`\n' "$changed" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Create or update candidate pull request + id: propose + if: steps.candidate.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + CURRENT_VERSION: ${{ steps.candidate.outputs.current }} + UPSTREAM_VERSION: ${{ steps.candidate.outputs.upstream }} + ENVIRONMENT_VERSION: ${{ steps.candidate.outputs.environment }} + run: | + git add -- "$SUPPORTED_TARGET" + git commit -m "chore(dsh): validate ${UPSTREAM_VERSION}" + + candidate_remote_sha="${{ steps.candidate.outputs.candidate_remote_sha }}" + if [ -n "$candidate_remote_sha" ]; then + git push --force-with-lease="refs/heads/$CANDIDATE_BRANCH:$candidate_remote_sha" \ + origin "HEAD:refs/heads/$CANDIDATE_BRANCH" + else + git push origin "HEAD:refs/heads/$CANDIDATE_BRANCH" + fi + + body="$RUNNER_TEMP/dsh-candidate-pr.md" + # shellcheck disable=SC2016 + { + printf '## DSH candidate validation\n\n' + printf '| State | Version |\n|---|---|\n' + printf '| Latest Supported | `%s` |\n' "$CURRENT_VERSION" + printf '| Latest Official candidate | `%s` |\n' "$UPSTREAM_VERSION" + printf '| Current Environment | `%s` |\n\n' "$ENVIRONMENT_VERSION" + printf 'This PR changes only the candidate Supported Target. Compatibility CI validates the new DSH with the current Environment. Merge into dev records the validated candidate; promotion from dev to main remains the production publication gate.\n' + } > "$body" + + pr_number="$(gh pr list --state open --head "$CANDIDATE_BRANCH" --json number --jq '.[0].number // empty')" + if [ -z "$pr_number" ]; then + pr_url="$(gh pr create \ + --base "$BASE_BRANCH" \ + --head "$CANDIDATE_BRANCH" \ + --title "chore(dsh): validate ${UPSTREAM_VERSION}" \ + --body-file "$body")" + pr_number="$(gh pr view "$pr_url" --json number --jq .number)" + else + gh pr edit "$pr_number" \ + --base "$BASE_BRANCH" \ + --title "chore(dsh): validate ${UPSTREAM_VERSION}" \ + --body-file "$body" + pr_url="$(gh pr view "$pr_number" --json url --jq .url)" + fi + { + printf 'pull_request_url=%s\n' "$pr_url" + printf 'pull_request_number=%s\n' "$pr_number" + printf 'head_sha=%s\n' "$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + printf 'Candidate PR: %s\n' "$pr_url" >> "$GITHUB_STEP_SUMMARY" + + validate-candidate: + name: Validate candidate Runtime + needs: discover + if: needs.discover.outputs.changed == 'true' + permissions: + contents: read + uses: ./.github/workflows/dsh-candidate-validation.yml + with: + ref: ${{ needs.discover.outputs.head_sha }} + + report-validation: + name: Report candidate validation + needs: [discover, validate-candidate] + if: always() && needs.discover.outputs.changed == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + statuses: write + steps: + - name: Publish commit status and PR report + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ needs.discover.outputs.head_sha }} + PR_NUMBER: ${{ needs.discover.outputs.pull_request_number }} + VALIDATION_RESULT: ${{ needs.validate-candidate.result }} + UPSTREAM_VERSION: ${{ needs.discover.outputs.upstream }} + ENVIRONMENT_VERSION: ${{ needs.discover.outputs.environment }} + run: | + if [ "$VALIDATION_RESULT" = success ]; then + state=success + label=passed + description="Candidate Runtime validation passed" + else + state=failure + label=failed + description="Candidate Runtime validation failed" + fi + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + gh api --method POST "/repos/${GITHUB_REPOSITORY}/statuses/${HEAD_SHA}" \ + -f state="$state" \ + -f context='DSH candidate Runtime' \ + -f description="$description" \ + -f target_url="$run_url" >/dev/null + gh pr comment "$PR_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --body "DSH \`${UPSTREAM_VERSION}\` with Environment \`${ENVIRONMENT_VERSION}\` **${label}** compatibility validation. [Open workflow run](${run_url})." + printf 'Candidate validation %s: %s\n' "$label" "$run_url" >> "$GITHUB_STEP_SUMMARY" + + notify-discovered: + name: Notify upstream discovery + needs: discover + if: needs.discover.outputs.changed == 'true' + uses: yjrszcq/github-workflows/.github/workflows/gotify-notify.yml@c4c7fe9e17854cd4962913ac2792513eab0be988 + with: + title: 发现 DSH 上游新版本 ${{ needs.discover.outputs.upstream }} + message: >- + 当前支持版本:${{ needs.discover.outputs.current }};上游候选版本:${{ needs.discover.outputs.upstream }}。 + 候选 Environment:${{ needs.discover.outputs.environment }}。 + 候选 PR:${{ needs.discover.outputs.pull_request_url }} + priority: 6 + secrets: + gotify_url: ${{ secrets.GOTIFY_URL }} + gotify_token: ${{ secrets.GOTIFY_TOKEN }} + + notify-result: + name: Notify upstream check result + needs: [discover, validate-candidate] + if: >- + always() && + (needs.discover.result == 'failure' || needs.discover.outputs.changed == 'true') + uses: yjrszcq/github-workflows/.github/workflows/gotify-notify.yml@c4c7fe9e17854cd4962913ac2792513eab0be988 + with: + title: >- + ${{ + needs.discover.result == 'failure' && 'DSH 上游检查失败' || + (needs.validate-candidate.result == 'success' && + format('DSH {0} 候选版本验证通过', needs.discover.outputs.upstream) || + format('DSH {0} 候选版本验证失败', needs.discover.outputs.upstream)) + }} + message: >- + ${{ + needs.discover.result == 'failure' && + format('获取或生成 DSH 上游候选版本失败。工作流:{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) || + (needs.validate-candidate.result == 'success' && + format('DSH {0} 已通过 Environment {1} 兼容性验证。请审核并合并:{2}', needs.discover.outputs.upstream, needs.discover.outputs.environment, needs.discover.outputs.pull_request_url) || + format('DSH {0} 未通过 Environment {1} 兼容性验证,需要适配 Environment。工作流:{2}/{3}/actions/runs/{4}', needs.discover.outputs.upstream, needs.discover.outputs.environment, github.server_url, github.repository, github.run_id)) + }} + priority: ${{ (needs.discover.result == 'failure' || needs.validate-candidate.result != 'success') && 8 || 7 }} + secrets: + gotify_url: ${{ secrets.GOTIFY_URL }} + gotify_token: ${{ secrets.GOTIFY_TOKEN }} diff --git a/upstream/.github/workflows/production-publish.yml b/upstream/.github/workflows/production-publish.yml new file mode 100644 index 0000000..f95d87a --- /dev/null +++ b/upstream/.github/workflows/production-publish.yml @@ -0,0 +1,289 @@ +name: Publish Supported Platform Target + +on: + push: + branches: + - main + paths: + - container/environment/definition.json + - release/supported-target.json + - release/official-dsh-policy.json + workflow_dispatch: + +concurrency: + group: production-supported-target + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + name: Sign and publish Supported Target + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: production-release + permissions: + contents: write + steps: + - name: Checkout protected branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + container/control-plane/services/management/package-lock.json + container/environment/resources/plugins/platform-management/package/package-lock.json + + - name: Install Management runtime dependencies + run: | + npm ci --omit=dev --ignore-scripts --no-audit --no-fund --prefix container/control-plane/services/management + npm ci --omit=dev --ignore-scripts --no-audit --no-fund --legacy-peer-deps --prefix container/environment/resources/plugins/platform-management/package + + - name: Verify merged Supported Target + id: target + run: | + result="$(node container/platform/tools/supported-target.mjs validate \ + release/supported-target.json container/environment/definition.json)" + dsh_version="$(jq -er '.target.latestSupportedDsh' <<<"$result")" + environment_version="$(jq -er '.target.environment' <<<"$result")" + [[ "$environment_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + printf 'dsh_version=%s\n' "$dsh_version" >> "$GITHUB_OUTPUT" + printf 'environment_version=%s\n' "$environment_version" >> "$GITHUB_OUTPUT" + + - name: Run release contract tests + run: | + npm test --prefix container/platform + npm test --prefix container/control-plane/services/gateway + node container/test/compose-config.mjs + + - name: Install protected public trust bundle + env: + RECOVERY_ROOT_PUBLIC_KEY: ${{ secrets.DSH_RECOVERY_ROOT_PUBLIC_KEY }} + KEYRING_JSON_BASE64: ${{ secrets.DSH_KEYRING_JSON_BASE64 }} + KEYRING_SIGNATURE_BASE64: ${{ secrets.DSH_KEYRING_SIGNATURE_BASE64 }} + run: | + test -n "$RECOVERY_ROOT_PUBLIC_KEY" + test -n "$KEYRING_JSON_BASE64" + test -n "$KEYRING_SIGNATURE_BASE64" + mkdir -p "$RUNNER_TEMP/production-trust" + printf '%s\n' "$RECOVERY_ROOT_PUBLIC_KEY" > "$RUNNER_TEMP/production-trust/recovery-root.spki.base64" + printf '%s' "$KEYRING_JSON_BASE64" | base64 --decode > "$RUNNER_TEMP/production-trust/keyring.json" + printf '%s' "$KEYRING_SIGNATURE_BASE64" | base64 --decode > "$RUNNER_TEMP/production-trust/keyring.sig.json" + + - name: Resolve signed channel and target sequence + id: sequence + run: | + channel="$RUNNER_TEMP/release-channel" + keyring_sha256="$(sha256sum "$RUNNER_TEMP/production-trust/keyring.json" | cut -d ' ' -f 1)" + previous=- + reuse=false + if git ls-remote --exit-code origin refs/heads/release-channel >/dev/null 2>&1; then + git fetch origin refs/heads/release-channel + channel_commit="$(git rev-parse FETCH_HEAD)" + git worktree add --detach "$channel" "$channel_commit" + current_sequence="$(jq -er .targetSequence "$channel/stable.json")" + previous="$channel/targets/$current_sequence" + test -d "$previous" + for name in \ + stable.json \ + stable.sig.json \ + keyring.json \ + keyring.sig.json \ + bootstrap.manifest.json \ + bootstrap.manifest.sig.json; do + test -f "$previous/$name" + done + for name in stable.json stable.sig.json keyring.json keyring.sig.json; do + cmp "$channel/$name" "$previous/$name" + done + if test -f "$channel/channel.json" \ + && test "$(jq -er .sourceCommit "$channel/channel.json")" = "$GITHUB_SHA" \ + && test "$(jq -er .keyringSha256 "$channel/channel.json")" = "$keyring_sha256"; then + sequence="$current_sequence" + reuse=true + else + test "$current_sequence" -lt 9007199254740991 + sequence="$((current_sequence + 1))" + fi + else + git worktree add --detach "$channel" "$GITHUB_SHA" + git -C "$channel" checkout --orphan release-channel + git -C "$channel" rm -rf . + sequence=1 + channel_commit= + fi + { + printf 'channel=%s\n' "$channel" + printf 'channel_commit=%s\n' "$channel_commit" + printf 'keyring_sha256=%s\n' "$keyring_sha256" + printf 'previous=%s\n' "$previous" + printf 'reuse=%s\n' "$reuse" + printf 'sequence=%s\n' "$sequence" + } >> "$GITHUB_OUTPUT" + printf 'Target sequence: %s (reuse: %s)\n' "$sequence" "$reuse" >> "$GITHUB_STEP_SUMMARY" + + - name: Download reviewed DSH npm Artifact + if: steps.sequence.outputs.reuse != 'true' + env: + DSH_VERSION: ${{ steps.target.outputs.dsh_version }} + run: | + mkdir -p "$RUNNER_TEMP/platform-release-input" + npm pack "@deepseek-ai/dsh@${DSH_VERSION}" --pack-destination "$RUNNER_TEMP/platform-release-input" >/dev/null + tarball="$(find "$RUNNER_TEMP/platform-release-input" -maxdepth 1 -type f -name '*.tgz' -print -quit)" + test -n "$tarball" + printf 'DSH_TARBALL=%s\n' "$tarball" >> "$GITHUB_ENV" + + - name: Prepare signed immutable target + if: steps.sequence.outputs.reuse != 'true' + env: + RELEASE_PRIVATE_KEY: ${{ secrets.DSH_RELEASE_PRIVATE_KEY }} + TARGET_SEQUENCE: ${{ steps.sequence.outputs.sequence }} + PREVIOUS_STABLE: ${{ steps.sequence.outputs.previous }} + run: | + test -n "$RELEASE_PRIVATE_KEY" + umask 077 + private_key="$RUNNER_TEMP/current-release-private.pem" + trap 'rm -f "$private_key"' EXIT + printf '%s\n' "$RELEASE_PRIVATE_KEY" > "$private_key" + artifact_base_url="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/release-channel/targets/${TARGET_SEQUENCE}/" + SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")" + export SOURCE_DATE_EPOCH + node container/platform/tools/prepare-release.mjs \ + release/supported-target.json container/environment/definition.json release/official-dsh-policy.json \ + "$RUNNER_TEMP/production-trust" "$private_key" "$DSH_TARBALL" "$PREVIOUS_STABLE" \ + "$TARGET_SEQUENCE" "$artifact_base_url" "$RUNNER_TEMP/platform-release" + rm -f "$private_key" + trap - EXIT + + - name: Reuse an already published target after retry + if: steps.sequence.outputs.reuse == 'true' + env: + TARGET_SEQUENCE: ${{ steps.sequence.outputs.sequence }} + CHANNEL: ${{ steps.sequence.outputs.channel }} + DSH_VERSION: ${{ steps.target.outputs.dsh_version }} + ENVIRONMENT_VERSION: ${{ steps.target.outputs.environment_version }} + run: | + cp -a "$CHANNEL/targets/$TARGET_SEQUENCE" "$RUNNER_TEMP/platform-release" + test "$(jq -er .desired.dsh.version "$RUNNER_TEMP/platform-release/stable.json")" = "$DSH_VERSION" + test "$(jq -er .desired.environment.version "$RUNNER_TEMP/platform-release/stable.json")" = "$ENVIRONMENT_VERSION" + + - name: Derive Environment release identity + run: | + node container/platform/tools/environment-release.mjs \ + "$RUNNER_TEMP/platform-release/bootstrap.manifest.json" \ + "$RUNNER_TEMP/platform-release/environment.manifest.json" \ + release/official-dsh-policy.json \ + "$RUNNER_TEMP/environment-release.json" + + - name: Validate Environment version ownership + id: environment-check + env: + GH_TOKEN: ${{ github.token }} + ENVIRONMENT_VERSION: ${{ steps.target.outputs.environment_version }} + run: | + tag="v${ENVIRONMENT_VERSION}" + publish=true + if gh release view "$tag" --json isDraft > "$RUNNER_TEMP/environment-release-state.json" 2>/dev/null; then + if test "$(jq -er .isDraft "$RUNNER_TEMP/environment-release-state.json")" = false; then + mkdir -p "$RUNNER_TEMP/published-environment" + gh release download "$tag" --pattern environment-release.json --dir "$RUNNER_TEMP/published-environment" + cmp "$RUNNER_TEMP/environment-release.json" "$RUNNER_TEMP/published-environment/environment-release.json" + publish=false + else + mkdir -p "$RUNNER_TEMP/draft-environment" + if gh release download "$tag" --pattern environment-release.json --dir "$RUNNER_TEMP/draft-environment" 2>/dev/null; then + cmp "$RUNNER_TEMP/environment-release.json" "$RUNNER_TEMP/draft-environment/environment-release.json" + fi + fi + elif git ls-remote --exit-code origin "refs/tags/$tag" >/dev/null 2>&1; then + echo "Git tag $tag exists without a matching Environment Release" >&2 + exit 1 + fi + printf 'publish=%s\n' "$publish" >> "$GITHUB_OUTPUT" + printf 'tag=%s\n' "$tag" >> "$GITHUB_OUTPUT" + + - name: Publish signed channel target + env: + CHANNEL: ${{ steps.sequence.outputs.channel }} + EXISTING_CHANNEL_COMMIT: ${{ steps.sequence.outputs.channel_commit }} + KEYRING_SHA256: ${{ steps.sequence.outputs.keyring_sha256 }} + REUSE: ${{ steps.sequence.outputs.reuse }} + TARGET_SEQUENCE: ${{ steps.sequence.outputs.sequence }} + DSH_VERSION: ${{ steps.target.outputs.dsh_version }} + ENVIRONMENT_VERSION: ${{ steps.target.outputs.environment_version }} + run: | + if test "$REUSE" = true; then + commit="$EXISTING_CHANNEL_COMMIT" + else + target="$CHANNEL/targets/$TARGET_SEQUENCE" + test ! -e "$target" + mkdir -p "$target" + cp -a "$RUNNER_TEMP/platform-release/." "$target/" + for name in stable.json stable.sig.json keyring.json keyring.sig.json; do cp "$target/$name" "$CHANNEL/$name"; done + # shellcheck disable=SC2016 + jq -n --arg sourceCommit "$GITHUB_SHA" --arg keyringSha256 "$KEYRING_SHA256" \ + --arg dshVersion "$DSH_VERSION" --arg environmentVersion "$ENVIRONMENT_VERSION" \ + --argjson targetSequence "$TARGET_SEQUENCE" \ + '{schema:1,sourceCommit:$sourceCommit,keyringSha256:$keyringSha256,targetSequence:$targetSequence,dshVersion:$dshVersion,environmentVersion:$environmentVersion}' \ + > "$CHANNEL/channel.json" + git -C "$CHANNEL" config user.name github-actions + git -C "$CHANNEL" config user.email github-actions@github.com + git -C "$CHANNEL" add channel.json keyring.json keyring.sig.json stable.json stable.sig.json "targets/$TARGET_SEQUENCE" + git -C "$CHANNEL" commit -m "release: publish target sequence $TARGET_SEQUENCE" + commit="$(git -C "$CHANNEL" rev-parse HEAD)" + git -C "$CHANNEL" push origin HEAD:refs/heads/release-channel + fi + # shellcheck disable=SC2016 + printf 'Published signed channel sequence `%s` at `%s`.\n' "$TARGET_SEQUENCE" "$commit" >> "$GITHUB_STEP_SUMMARY" + + - name: Publish Environment Release + env: + GH_TOKEN: ${{ github.token }} + PUBLISH_ENVIRONMENT: ${{ steps.environment-check.outputs.publish }} + ENVIRONMENT_TAG: ${{ steps.environment-check.outputs.tag }} + ENVIRONMENT_VERSION: ${{ steps.target.outputs.environment_version }} + DSH_VERSION: ${{ steps.target.outputs.dsh_version }} + TARGET_SEQUENCE: ${{ steps.sequence.outputs.sequence }} + run: | + prune_environment_assets() { + gh release view "$ENVIRONMENT_TAG" --json assets --jq '.assets[].name' | while IFS= read -r name; do + if test "$name" != environment-release.json; then + gh release delete-asset "$ENVIRONMENT_TAG" "$name" --yes + fi + done + } + if test "$PUBLISH_ENVIRONMENT" != true; then + prune_environment_assets + test "$(gh release view "$ENVIRONMENT_TAG" --json assets --jq '.assets[].name')" = environment-release.json + exit 0 + fi + assets="$RUNNER_TEMP/environment-release-assets" + mkdir -p "$assets" + cp "$RUNNER_TEMP/environment-release.json" "$assets/" + notes="$RUNNER_TEMP/environment-release-notes.md" + # shellcheck disable=SC2016 + { + printf 'Container Environment: `%s`\n\n' "$ENVIRONMENT_VERSION" + printf 'Supported DSH at publication: `%s`\n\n' "$DSH_VERSION" + printf 'Initial signed target sequence: `%s`\n' "$TARGET_SEQUENCE" + } > "$notes" + if ! gh release view "$ENVIRONMENT_TAG" >/dev/null 2>&1; then + gh release create "$ENVIRONMENT_TAG" --draft --target "$GITHUB_SHA" \ + --title "$ENVIRONMENT_TAG" --notes-file "$notes" + fi + gh release edit "$ENVIRONMENT_TAG" --draft --target "$GITHUB_SHA" \ + --title "$ENVIRONMENT_TAG" --notes-file "$notes" + gh release upload "$ENVIRONMENT_TAG" "$assets"/* --clobber + prune_environment_assets + find "$assets" -maxdepth 1 -type f -printf '%f\n' | sort > "$RUNNER_TEMP/local-environment-assets" + gh release view "$ENVIRONMENT_TAG" --json assets --jq '.assets[].name' | sort > "$RUNNER_TEMP/remote-environment-assets" + diff -u "$RUNNER_TEMP/local-environment-assets" "$RUNNER_TEMP/remote-environment-assets" + gh release edit "$ENVIRONMENT_TAG" --draft=false --latest \ + --title "$ENVIRONMENT_TAG" --notes-file "$notes" diff --git a/upstream/.github/workflows/workflow-notifications.yml b/upstream/.github/workflows/workflow-notifications.yml new file mode 100644 index 0000000..43440e8 --- /dev/null +++ b/upstream/.github/workflows/workflow-notifications.yml @@ -0,0 +1,52 @@ +name: Notify Workflow Results + +on: + workflow_run: + workflows: + - Publish Supported Platform Target + - DSH Candidate Validation + types: + - completed + +permissions: + contents: read + +jobs: + notify-result: + name: Notify completed workflow result + if: >- + (github.event.workflow_run.name == 'Publish Supported Platform Target' && + (github.event.workflow_run.conclusion == 'success' || + github.event.workflow_run.conclusion == 'failure')) || + (github.event.workflow_run.name == 'DSH Candidate Validation' && + (github.event.workflow_run.event == 'pull_request_target' || + github.event.workflow_run.event == 'workflow_dispatch') && + github.event.workflow_run.head_branch != 'automation/dsh-upstream-candidate' && + (github.event.workflow_run.conclusion == 'success' || + github.event.workflow_run.conclusion == 'failure')) + uses: yjrszcq/github-workflows/.github/workflows/gotify-notify.yml@c4c7fe9e17854cd4962913ac2792513eab0be988 + with: + title: >- + ${{ + github.event.workflow_run.name == 'Publish Supported Platform Target' && + (github.event.workflow_run.conclusion == 'success' && 'DSH 正式目标已发布' || 'DSH 正式发布失败') || + (github.event.workflow_run.conclusion == 'success' && 'DSH 候选版本验证通过' || 'DSH 候选版本验证失败') + }} + message: >- + ${{ + github.event.workflow_run.name == 'Publish Supported Platform Target' && + (github.event.workflow_run.conclusion == 'success' && + format('签名目标和必要的 Environment Release 已发布。独立镜像工作流已经启动;如果 production-image 启用了审批,镜像发布会等待批准。镜像工作流:{0}/{1}/actions/workflows/docker.yaml', github.server_url, github.repository) || + format('签名目标或 Environment Release 发布失败。工作流:{0}', github.event.workflow_run.html_url)) || + (github.event.workflow_run.conclusion == 'success' && + (github.event.workflow_run.event == 'workflow_dispatch' && + format('手动发起的 DSH 候选版本兼容性验证通过。候选提交:{0}。工作流:{1}', github.event.workflow_run.head_sha, github.event.workflow_run.html_url) || + format('Pull Request 的 DSH 候选版本兼容性验证通过。候选提交:{0}。工作流:{1}', github.event.workflow_run.head_sha, github.event.workflow_run.html_url)) || + (github.event.workflow_run.event == 'workflow_dispatch' && + format('手动发起的 DSH 候选版本兼容性验证失败。候选提交:{0}。工作流:{1}', github.event.workflow_run.head_sha, github.event.workflow_run.html_url) || + format('Pull Request 的 DSH 候选版本兼容性验证失败。候选提交:{0}。工作流:{1}', github.event.workflow_run.head_sha, github.event.workflow_run.html_url))) + }} + priority: ${{ github.event.workflow_run.conclusion == 'failure' && 8 || 7 }} + secrets: + gotify_url: ${{ secrets.GOTIFY_URL }} + gotify_token: ${{ secrets.GOTIFY_TOKEN }} diff --git a/upstream/.gitignore b/upstream/.gitignore new file mode 100644 index 0000000..0fc70c1 --- /dev/null +++ b/upstream/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +.env +.env.* +!.env.example +temp/ +node_modules/ + +/data/ +/workspace/ +.checkpoint-karpathy/private/ +docker-compose.test.yaml diff --git a/upstream/Dockerfile b/upstream/Dockerfile new file mode 100644 index 0000000..79d8ae9 --- /dev/null +++ b/upstream/Dockerfile @@ -0,0 +1,174 @@ +ARG DSH_VERSION=latest +ARG ENVIRONMENT_VERSION=development +ARG TARGET_SEQUENCE=0 +ARG SIGNED_IMAGE_INPUT=false + +FROM node:24-bookworm-slim AS installer +ARG DSH_VERSION +ARG SIGNED_IMAGE_INPUT +COPY container/platform/image-input /opt/dsh-platform-image-input +COPY release/supported-target.json /opt/dsh-supported-target.json + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + g++ \ + make \ + python3 \ + && case "$SIGNED_IMAGE_INPUT" in \ + true) \ + test -f /opt/dsh-platform-image-input/dsh.tgz \ + && npm install --global /opt/dsh-platform-image-input/dsh.tgz \ + ;; \ + false) \ + requested_version="$DSH_VERSION"; \ + if [ "$requested_version" = latest ]; then \ + requested_version="$(node -p "JSON.parse(require('fs').readFileSync('/opt/dsh-supported-target.json', 'utf8')).latestSupportedDsh")"; \ + fi; \ + npm install --global "@deepseek-ai/dsh@${requested_version}" \ + ;; \ + *) echo "SIGNED_IMAGE_INPUT must be true or false" >&2; exit 64 ;; \ + esac \ + && rm -rf /var/lib/apt/lists/* /root/.npm + +FROM node:24-bookworm-slim AS platform-seed +ARG PLATFORM_REVISION=development +ARG SIGNED_IMAGE_INPUT +COPY --from=installer /usr/local/lib/node_modules/@deepseek-ai/dsh /opt/installed-dsh +COPY container/control-plane/services/management/package.json container/control-plane/services/management/package-lock.json \ + /opt/dsh-platform-source/control-plane/services/management/ +COPY container/environment/resources/plugins/platform-management/package/package.json \ + container/environment/resources/plugins/platform-management/package/package-lock.json \ + /opt/dsh-platform-source/environment/resources/plugins/platform-management/package/ +RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund \ + --prefix /opt/dsh-platform-source/control-plane/services/management \ + && npm ci --omit=dev --ignore-scripts --no-audit --no-fund --legacy-peer-deps \ + --prefix /opt/dsh-platform-source/environment/resources/plugins/platform-management/package +COPY container /opt/dsh-platform-source +RUN case "$SIGNED_IMAGE_INPUT" in \ + true) image_input=/opt/dsh-platform-source/platform/image-input ;; \ + false) image_input=- ;; \ + *) echo "SIGNED_IMAGE_INPUT must be true or false" >&2; exit 64 ;; \ + esac \ + && node /opt/dsh-platform-source/platform/tools/build-seed.mjs \ + /opt/installed-dsh /opt/dsh-platform-seed "$image_input" "$PLATFORM_REVISION" + +FROM node:24-bookworm-slim AS runtime +ARG PNPM_VERSION=11.7.0 +ARG DSH_VERSION +ARG ENVIRONMENT_VERSION +ARG TARGET_SEQUENCE + +LABEL org.opencontainers.image.title="DeepSeek Harness" \ + org.opencontainers.image.description="Unofficial container image for DeepSeek Harness" \ + org.opencontainers.image.source="https://github.com/yjrszcq/dsh-docker" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.version="$DSH_VERSION" \ + io.dsh-docker.environment.version="$ENVIRONMENT_VERSION" \ + io.dsh-docker.target-sequence="$TARGET_SEQUENCE" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + g++ \ + git \ + jq \ + make \ + openssh-client \ + p7zip-full \ + procps \ + python3 \ + python3-venv \ + ripgrep \ + sudo \ + tini \ + unzip \ + util-linux \ + zip \ + && groupadd --system dsh-sudo-true \ + && groupadd --system dsh-sudo-false \ + && groupadd --system --gid 991 dsh-proxy \ + && useradd --system --uid 991 --gid 991 --home-dir /nonexistent --shell /usr/sbin/nologin dsh-proxy \ + && groupadd --system --gid 992 dsh-access \ + && useradd --system --uid 992 --gid 992 --home-dir /nonexistent --shell /usr/sbin/nologin dsh-access \ + && npm install --global "pnpm@${PNPM_VERSION}" \ + && rm -rf /var/lib/apt/lists/* /root/.npm + +COPY docker-sudoers /etc/sudoers.d/dsh-sudo +RUN chmod 440 /etc/sudoers.d/dsh-sudo \ + && visudo -cf /etc/sudoers.d/dsh-sudo + +COPY --from=platform-seed /opt/dsh-platform-seed /opt/dsh-platform/seed +COPY container/platform /opt/dsh-platform/runtime/platform +COPY container/control-plane /opt/dsh-platform/runtime/control-plane +COPY --from=platform-seed /opt/dsh-platform-source/control-plane/services/management/node_modules /opt/dsh-platform/runtime/control-plane/services/management/node_modules +COPY container/platform/tools/dsh-shim.sh /usr/local/bin/dsh +COPY container/platform/tools/dsh-platform-shim.sh /usr/local/bin/dsh-platform +RUN chmod 755 /usr/local/bin/dsh /usr/local/bin/dsh-platform + +ENV DSH_PLATFORM_DATA=/data/platform \ + DSH_PLATFORM_MANAGED=1 \ + DSH_PLATFORM_RUN=/run/dsh-platform \ + DSH_HOME=/data/dsh \ + DSH_DEFAULT_WORKSPACE=/workspace \ + DSH_PROXY_POLYFILL=true \ + DSH_TELEMETRY_DISABLED=true \ + DSH_UPDATE_METADATA_URL=https://raw.githubusercontent.com/yjrszcq/dsh-docker/release-channel/ \ + DSH_LOG_MAX_BYTES=104857600 \ + DSH_LOG_RETENTION_DAYS=14 \ + DSH_ACTIVATION_TIMEOUT_SECONDS=60 \ + DSH_EXPERIMENTAL_PROBATION_SECONDS=120 + +RUN mkdir -p /data/platform /data/dsh /workspace \ + && chown -R node:node /data/dsh /workspace + +ARG INSTALL_DEVTOOLS=false +ARG UV_VERSION=0.11.32 + +RUN case "$INSTALL_DEVTOOLS" in \ + true) apt-get update \ + && apt-get install -y --no-install-recommends \ + bash-completion \ + build-essential \ + dnsutils \ + file \ + htop \ + iproute2 \ + iputils-ping \ + less \ + lsof \ + nano \ + netcat-openbsd \ + openssl \ + pkg-config \ + rsync \ + tmux \ + tree \ + unzip \ + vim \ + wget \ + xz-utils \ + zip \ + && curl --fail --location --silent --show-error \ + "https://astral.sh/uv/${UV_VERSION}/install.sh" \ + --output /tmp/uv-installer.sh \ + && UV_UNMANAGED_INSTALL=/usr/local/bin sh /tmp/uv-installer.sh \ + && mkdir -p /etc/uv \ + && printf '%s\n' 'python-downloads = "manual"' > /etc/uv/uv.toml \ + && rm /tmp/uv-installer.sh \ + && rm -rf /var/lib/apt/lists/* \ + ;; \ + false) ;; \ + *) echo "INSTALL_DEVTOOLS must be true or false" >&2; exit 64 ;; \ + esac + +WORKDIR /workspace + +EXPOSE 3080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD /usr/bin/curl --fail --silent --show-error --noproxy '*' http://127.0.0.1:3080/_dsh_gateway/health >/dev/null || exit 1 + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["/usr/local/bin/node", "/opt/dsh-platform/runtime/platform/stage0/index.mjs"] diff --git a/upstream/LICENSE b/upstream/LICENSE new file mode 100644 index 0000000..0027ee4 --- /dev/null +++ b/upstream/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 yjrszcq + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/upstream/PROVENANCE.md b/upstream/PROVENANCE.md new file mode 100644 index 0000000..a1b67ba --- /dev/null +++ b/upstream/PROVENANCE.md @@ -0,0 +1,11 @@ +# Upstream provenance + +`upstream/` — вендоренная копия (MIT). + +- commit: `2564866f7844f334379dc4913dc466a5a4138306` +- date: 2026-09-19 01:16:39 +0800 +- причина вендоринга: самодостаточность репо (пересборка образа без внешних зависимостей); + лицензия MIT допускает копирование при сохранении уведомления (см. LICENSE). + +Изменения относительно upstream: отсутствуют (чистая копия). Наши артефакты — compose, +скрипты и документация — лежат на верхнем уровне репозитория. diff --git a/upstream/README.md b/upstream/README.md new file mode 100644 index 0000000..ea53893 --- /dev/null +++ b/upstream/README.md @@ -0,0 +1,220 @@ +# DSH-Docker + +English | [中文](README_CN.md) + +An unofficial Docker image for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). It installs the official `@deepseek-ai/dsh` package and adds persistent updates, rollback, gateway access control, and optional development tools. + +> DeepSeek Harness is in Developer Preview and may introduce incompatible changes. This project is not affiliated with DeepSeek AI. + +## Before You Start + +- **Directory permissions:** Bind-mounted `data/dsh`, `data/platform`, and `workspace` must be writable by container UID/GID `1000:1000`. Keep both data directories when replacing or upgrading the container. +- **Port exposure:** The quick-start configuration binds `127.0.0.1:3080` and is reachable only from the Docker host. Changing it to `3080:3080` or `0.0.0.0:3080:3080` exposes the service on every host interface. +- **Remote access:** Before allowing LAN or Internet access, set `DSH_TRUSTED_HOSTS` to the exact IP addresses or domains used by browsers, initialize the local administrator account, and use HTTPS or another trusted network boundary. Do not expose privileged Docker or host resources to the container. +- **Root authority:** DSH/Agent passwordless sudo is disabled by default, including in the quick-start examples and repository Compose. Enable it only when unrestricted root access is intentional: set `DSH_SUDO_ENABLED=true` with repository Compose, add `group_add: [dsh-sudo-true]` to custom Compose, or add `--group-add dsh-sudo-true` to `docker run`. This setting does not restrict the standalone Management Console's root terminal or file manager, which must remain protected by authentication and a trusted network boundary. +- **DSH Management Console:** `/_dsh_platform/console/` remains available when DSH is stopped or fails to start. It provides updates and recovery, outbound proxy settings, live logs, bundled System Plugin and System Skill management, User Plugin recovery, User Skill management, container files, and a root terminal. The first browser visit creates the local administrator account. Management uses a separate session derived from the active DSH browser session; an optional Management password adds a second login layer. + +| Variant | Rolling tag | Versioned tag | Contents | +| --- | --- | --- | --- | +| Standard | `latest` | `` | DSH and normal runtime utilities | +| Devtools | `latest-devtools` | `-devtools` | Standard image plus development tools | + +Docker Hub tags follow the DSH version. `ghcr.io/yjrszcq/dsh-docker` mirrors only the Standard image with Environment version tags and `dsh-` lookup tags; it does not publish Devtools images. + +## Quick Start + +Create local data directories: + +```bash +mkdir -p data/dsh data/platform workspace +``` + +Use this minimal `docker-compose.yaml`: + +```yaml +services: + deepseek-harness: + image: szcq/deepseek-harness:latest + container_name: deepseek-harness + restart: unless-stopped + ports: + - "127.0.0.1:3080:3080" + volumes: + - ./data/dsh:/data/dsh + - ./data/platform:/data/platform + - ./workspace:/workspace +``` + +Start it with: + +```bash +docker compose up -d +``` + +Or use the equivalent `docker run` command: + +```bash +docker run -d \ + --name deepseek-harness \ + --restart unless-stopped \ + -p 127.0.0.1:3080:3080 \ + -v "$(pwd)/data/platform:/data/platform" \ + -v "$(pwd)/data/dsh:/data/dsh" \ + -v "$(pwd)/workspace:/workspace" \ + szcq/deepseek-harness:latest +``` + +Open . + +## Storage + +Keep both persistent locations: + +| Path | Purpose | +| --- | --- | +| `/data/platform` | Platform state, managed rollback assets, snapshots, and logs | +| `/data/dsh` | DSH settings, sessions, credentials, and third-party plugins | +| `/workspace` | Default working directory | + +Container workloads run as UID/GID `1000:1000`. If a bind mount is inaccessible: + +```bash +sudo chown -R 1000:1000 data workspace +``` + +## Remote Access + +For LAN or reverse-proxy access, set the browser-facing host: + +```dotenv +DSH_TRUSTED_HOSTS=192.168.1.100,dsh.example.com +``` + +Use this minimal remote-access `docker-compose.yaml`, replacing the trusted hosts first: + +```yaml +services: + deepseek-harness: + image: szcq/deepseek-harness:latest + container_name: deepseek-harness + restart: unless-stopped + ports: + - "3080:3080" + environment: + DSH_TRUSTED_HOSTS: "192.168.1.100,dsh.example.com" + volumes: + - ./data/dsh:/data/dsh + - ./data/platform:/data/platform + - ./workspace:/workspace +``` + +Or use the equivalent `docker run` command: + +```bash +docker run -d \ + --name deepseek-harness \ + --restart unless-stopped \ + -p 3080:3080 \ + -e 'DSH_TRUSTED_HOSTS=192.168.1.100,dsh.example.com' \ + -v "$(pwd)/data/platform:/data/platform" \ + -v "$(pwd)/data/dsh:/data/dsh" \ + -v "$(pwd)/workspace:/workspace" \ + szcq/deepseek-harness:latest +``` + +A reverse proxy must preserve the original `Host` header. Terminate TLS outside this container. To publish only on the Docker host, use `127.0.0.1:3080:3080` instead of `3080:3080`. + +After startup, open the browser-facing address and create the local administrator username and main password. The initialization page blocks DSH and Management until registration finishes. Use HTTPS or another trusted network boundary because credentials and authenticated sessions grant full DSH access. + +Common settings: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `DSH_TRUSTED_HOSTS` | Loopback only | Allowed browser-facing hosts | +| `DSH_DEFAULT_WORKSPACE` | `/workspace` | Default workspace for directory pickers and standalone file management | +| `DSH_SUDO_ENABLED` | `false` | Compose-only passwordless sudo switch for DSH and its Agent | + +See the [complete configuration reference](docs/en/guide.md#configuration) for all variables and validation rules. + +## Online Updates + +Open **Platform Management** in DSH settings or visit the standalone **DSH Management Console** at . Checks do not download or activate anything until you confirm an update. Both interfaces can configure an existing HTTP or SOCKS5 proxy for selected managed outbound traffic; DSH Docker does not expose or create a public proxy service. + +The standalone page remains available when DSH is down. It provides DSH lifecycle and recovery controls, live logs, bundled System Plugin and System Skill management, User Plugin and User Skill recovery, root file management, and a container terminal. + +### Upgrade and authentication note + +The Access Manager persists the administrator account. DSH and the Management Console use separate sessions, with optional Management-console password and two-factor authentication. The default Compose setting keeps `DSH_SUDO_ENABLED=false`. Preserve both `/data/dsh` and `/data/platform` (especially `/data/platform/state`) when replacing the image. Removing the platform volume resets the administrator account and authentication state and can route an existing deployment into migration or recovery. + +Authentication forms are transport-only: the Gateway accepts the request and the Access Manager performs all authentication and credential decisions. A DSH login creates only a DSH Session; opening the Management Console exchanges that active session once for a separate Management Session. Direct Management access always completes the main DSH login first and then, when configured, asks for the Management console password. Logging out of DSH, or expiry of its session, also invalidates every linked Management Session. During a classified DSH outage, top-level browser navigation shows a generic recovery page before login so the standalone Management entry remains discoverable; API and WebSocket requests still fail closed. Authentication Settings lists signed-in devices with their browser, peer IP, and activity time; signing out one device revokes its DSH Session and linked Management Session together. Management can also move to a separately published public Origin or a local Origin using a user-selected host port (default `3081`). + +Five consecutive password failures from the same browser start a 30-second retry wait; later failures double that wait up to 15 minutes. Each browser source is also limited to 12 failures per hour and 24 per 24 hours. Across all sources, the whole DSH instance has a wider flood limit of 20 failures per minute, 60 per hour, and 120 per 24 hours. The browser only displays the Access Manager decision and remaining time. A successful password check clears that browser's consecutive and rolling-source failures, but not the instance-wide history. + +Lost credentials are recovered only from an interactive Root console. Passwords are read with input echo disabled and are never accepted as command arguments or piped input: + +```bash +docker exec -it --user root deepseek-harness dsh-platform access status +docker exec -it --user root deepseek-harness dsh-platform access reset +docker exec -it --user root deepseek-harness dsh-platform access clear-retry +docker exec -it --user root deepseek-harness dsh-platform access clear-retry --global-only +docker exec -it --user root deepseek-harness dsh-platform access generate-key +``` + +`access clear-retry` asks for `y/[n]` confirmation and clears all browser retry waits, browser rolling windows, and instance-wide failed-attempt windows. Add `--global-only` to clear only the instance-wide windows while preserving every browser's retry wait and rolling limits. Neither form changes credentials or sessions. + +A fresh empty volume opens the normal administrator registration page and needs no key. Only legacy migration or damaged authentication state uses `access generate-key` to issue a single-use authentication reset key valid for ten minutes; the recovery page can then recreate the administrator account. + +Useful commands: + +```bash +docker exec deepseek-harness dsh-platform status +docker exec deepseek-harness dsh-platform check +docker exec deepseek-harness dsh-platform update --wait +docker exec deepseek-harness dsh-platform stop --wait +docker exec deepseek-harness dsh-platform start --wait +docker exec deepseek-harness dsh-platform restart --wait +docker exec deepseek-harness dsh-platform rollback +``` + +Stable follows the supported DSH release. Experimental can install a newer verified upstream DSH and creates a data snapshot before activation. Returning to Stable may discard data written after that snapshot. + +## System Plugins + +The Container Environment includes these DSH-Docker integrations: + +| Plugin | Purpose | +| --- | --- | +| `@dsh-docker/platform-management` | Adds **Platform Management** to DSH settings for updates, maintenance, logs, System Plugins, System Skills, and current-browser sign-out controls; this integration is platform-managed | +| `@dsh-docker/settings-navigation` | Keeps the desktop Settings directory independently scrollable and provides directory/detail navigation on narrow screens | +| `@dsh-docker/settings-document-editor` | Replaces desktop-only configuration-file opening with an optional browser editor for `settings.yaml` | + +Other installed System Plugins can be enabled or disabled from **Platform Management** in DSH; it cannot modify itself. The standalone **DSH Management Console** can install, uninstall, enable, or disable bundled System Plugins, including restoring Platform Management when it is missing. Unsubmitted selections remain page-local; after Apply, the change is retained even when DSH is unavailable or the activation restart fails, and the next successful start uses it. Installation restores verified local Environment assets and does not download from GitHub or npm. Third-party User Plugins remain separate and are not treated as System Plugins. + +Platform Management and the Settings Document Editor use the restricted DSH-side platform API through the authenticated DSH Session. They do not require a separate Management Session. Platform Management can revoke the current browser's DSH Session and its linked Management Sessions, but account names, passwords, and complete authentication settings remain available only in the standalone Management Console or Root CLI. + +## User Plugins + +The standalone Management Console inventories Bundle plugins in the DSH Web Profile and can enable, disable, or uninstall an exact User Plugin. Applying a change snapshots the complete Web Profile and restarts only DSH when it is available. A committed change remains stored if DSH is already down or the restart fails, so another faulty plugin can be corrected before retrying startup. Installation remains in DSH's normal plugin flow or the container terminal; the recovery page does not install arbitrary packages. + +## System and User Skills + +DSH Docker includes the signed `dsh-docker-operations` System Skill. It gives the Agent container-specific instructions for files, development tools, plugins, lifecycle, logs, updates, recovery, networking, and permissions, and can also be invoked explicitly as `/dsh-docker-operations`. + +System Skills are available under **Platform Management** in DSH and **System Skills** in the standalone Management Console. Changes are applied immediately without restarting DSH. The standalone console can install, uninstall, enable, or disable a bundled skill; Platform Management can restore a missing skill and enable or disable it. Project and user skills with the same name retain DSH's native precedence and can override the bundled copy. + +The standalone console also inventories native User Skills from the configured DSH and Agent Skill roots. It can enable, disable, or delete an exact entry without modifying project-level Skills. + +## Security + +An authenticated DSH Session has full DSH authority. A Management Session additionally grants a root container terminal and root file operations, so an admitted administrator can read credentials, execute commands, and modify container data. Use HTTPS and a trusted network boundary for remote access. + +Repository Compose disables DSH/Agent passwordless sudo by default. Set `DSH_SUDO_ENABLED=true` only when DSH or its Agent intentionally needs root access. Do not combine sudo with privileged mode, the Docker socket, or sensitive host mounts without understanding the impact. + +## Documentation + +The [complete guide](docs/en/guide.md) covers: + +- deployment prerequisites, both Compose layouts, and Docker Run; +- all configuration variables, storage, gateway, and remote-access behavior; +- platform architecture, online updates, System and User Plugins, System and User Skills, logs, trust, recovery, and rollback; +- standalone recovery tools, release automation, local builds, tests, and devtools. diff --git a/upstream/README_CN.md b/upstream/README_CN.md new file mode 100644 index 0000000..3f9c549 --- /dev/null +++ b/upstream/README_CN.md @@ -0,0 +1,220 @@ +# DSH-Docker + +[English](README.md) | 中文 + +[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的非官方 Docker 镜像。镜像安装官方 `@deepseek-ai/dsh` 包,并提供持久化更新、回滚、Gateway 访问控制和可选开发工具。 + +> DeepSeek Harness 目前处于 Developer Preview,可能出现不兼容更新。本项目不隶属于 DeepSeek AI。 + +## 使用前须知 + +- **目录权限:** bind mount 的 `data/dsh`、`data/platform` 和 `workspace` 必须允许容器 UID/GID `1000:1000` 写入。替换或升级容器时必须保留两个数据目录。 +- **端口暴露:** 快速开始默认绑定 `127.0.0.1:3080`,只能从 Docker 宿主机访问。改成 `3080:3080` 或 `0.0.0.0:3080:3080` 会向宿主机所有网络接口开放服务。 +- **远程访问:** 允许局域网或互联网访问前,必须将 `DSH_TRUSTED_HOSTS` 设置为浏览器实际使用的 IP 地址或域名,初始化本地管理员账户,并通过 HTTPS 或其他可信网络边界提供服务;不要向容器暴露 Docker Socket、特权模式或敏感宿主机资源。 +- **Root 权限:** DSH/Agent 的免密码 sudo 默认关闭,快速开始示例和仓库 Compose 均不会默认授予该权限。只有明确需要不受限制的 Root 权限时才开启:仓库 Compose 设置 `DSH_SUDO_ENABLED=true`,自定义 Compose 添加 `group_add: [dsh-sudo-true]`,或为 `docker run` 添加 `--group-add dsh-sudo-true`。该设置不会限制独立管理中心的 Root 终端和文件管理,因此管理中心仍须通过认证和可信网络边界保护。 +- **DSH 管理中心:** `/_dsh_platform/console/` 在 DSH 停止或启动失败时仍可使用,提供更新与恢复、出站代理设置、实时日志、内置系统插件和系统技能管理、用户插件恢复、用户技能管理、容器文件和 Root 终端。首次浏览器访问会创建本地管理员账户。管理中心使用由当前 DSH 浏览器会话派生的独立会话;可选的管理中心密码构成第二层登录。 + +| 变体 | 滚动标签 | 固定版本标签 | 内容 | +| --- | --- | --- | --- | +| 标准版 | `latest` | `` | DSH 和正常运行所需工具 | +| 开发工具版 | `latest-devtools` | `-devtools` | 标准版加开发工具 | + +Docker Hub 标签跟随 DSH 版本。`ghcr.io/yjrszcq/dsh-docker` 仅备份标准镜像,使用 Environment 版本标签和 `dsh-` 定位标签,不发布 Devtools 镜像。 + +## 快速开始 + +创建本地数据目录: + +```bash +mkdir -p data/dsh data/platform workspace +``` + +使用以下精简 `docker-compose.yaml`: + +```yaml +services: + deepseek-harness: + image: szcq/deepseek-harness:latest + container_name: deepseek-harness + restart: unless-stopped + ports: + - "127.0.0.1:3080:3080" + volumes: + - ./data/dsh:/data/dsh + - ./data/platform:/data/platform + - ./workspace:/workspace +``` + +启动容器: + +```bash +docker compose up -d +``` + +也可以使用等价的 `docker run` 命令: + +```bash +docker run -d \ + --name deepseek-harness \ + --restart unless-stopped \ + -p 127.0.0.1:3080:3080 \ + -v "$(pwd)/data/platform:/data/platform" \ + -v "$(pwd)/data/dsh:/data/dsh" \ + -v "$(pwd)/workspace:/workspace" \ + szcq/deepseek-harness:latest +``` + +打开 。 + +## 数据存储 + +以下两个持久化位置都必须保留: + +| 路径 | 用途 | +| --- | --- | +| `/data/platform` | 平台状态、受管理的回滚资产、快照和日志 | +| `/data/dsh` | DSH 设置、会话、凭据和第三方插件 | +| `/workspace` | 默认工作目录 | + +容器工作负载使用 UID/GID `1000:1000`。bind mount 无法访问时执行: + +```bash +sudo chown -R 1000:1000 data workspace +``` + +## 远程访问 + +通过局域网或反向代理访问时,必须配置浏览器使用的主机: + +```dotenv +DSH_TRUSTED_HOSTS=192.168.1.100,dsh.example.com +``` + +将可信主机替换为实际值后,可以使用以下支持远程访问的精简 `docker-compose.yaml`: + +```yaml +services: + deepseek-harness: + image: szcq/deepseek-harness:latest + container_name: deepseek-harness + restart: unless-stopped + ports: + - "3080:3080" + environment: + DSH_TRUSTED_HOSTS: "192.168.1.100,dsh.example.com" + volumes: + - ./data/dsh:/data/dsh + - ./data/platform:/data/platform + - ./workspace:/workspace +``` + +也可以使用等价的 `docker run` 命令: + +```bash +docker run -d \ + --name deepseek-harness \ + --restart unless-stopped \ + -p 3080:3080 \ + -e 'DSH_TRUSTED_HOSTS=192.168.1.100,dsh.example.com' \ + -v "$(pwd)/data/platform:/data/platform" \ + -v "$(pwd)/data/dsh:/data/dsh" \ + -v "$(pwd)/workspace:/workspace" \ + szcq/deepseek-harness:latest +``` + +反向代理必须保留原始 `Host` 请求头,TLS 在容器外终止。如需只监听 Docker 宿主机,请使用 `127.0.0.1:3080:3080`,不要使用 `3080:3080`。 + +启动后打开浏览器侧地址,创建本地管理员用户名和主密码。初始化完成前,注册页会阻断 DSH 和管理中心。凭据及认证会话拥有完整 DSH 权限,因此远程访问必须使用 HTTPS 或其他可信网络边界。 + +常用设置: + +| 变量 | 默认值 | 用途 | +| --- | --- | --- | +| `DSH_TRUSTED_HOSTS` | 仅 loopback | 浏览器侧允许的主机 | +| `DSH_DEFAULT_WORKSPACE` | `/workspace` | 目录选择器和独立文件管理的默认工作目录 | +| `DSH_SUDO_ENABLED` | `false` | 仅供 Compose 使用的 DSH/Agent 免密码 sudo 开关 | + +全部变量和校验规则见[完整配置参考](docs/cn/guide.md#配置参考)。 + +## 在线更新 + +在 DSH 设置中打开“平台管理”,或访问独立的 DSH 管理中心。检查不会自动下载或激活,必须由用户确认更新。两个界面都可以为选定的受管出站流量配置现有 HTTP 或 SOCKS5 代理;DSH Docker 不会创建或对外开放代理服务。 + +独立页面在 DSH 无法启动时仍可使用,提供 DSH 生命周期与恢复、实时日志、内置系统插件和系统技能管理、用户插件和用户技能恢复、Root 文件管理及容器终端。 + +### 升级与认证提示 + +管理员账户由 Access Manager 持久化管理,DSH 与管理中心使用独立会话,并支持管理中心密码和两步验证。仓库 Compose 默认保持 `DSH_SUDO_ENABLED=false`。替换镜像时必须保留 `/data/dsh` 与 `/data/platform`(尤其是 `/data/platform/state`)。删除 platform volume 会重置管理员账户和认证状态,已有部署可能因此进入迁移或恢复流程。 + +认证表单只负责传输输入,所有凭据判断由 Access Manager 完成。登录 DSH 只创建 DSH Session;打开管理中心时会从这个有效会话通过一次性交接创建独立的 Management Session。直接访问管理中心也必须先完成 DSH 主密码登录;配置管理中心密码后,再进行第二层验证。DSH 注销或会话过期会同时使其关联的 Management Session 失效。DSH 发生已分类故障时,顶层页面导航会先显示不泄露故障细节的恢复页,使未登录用户仍能找到独立管理中心入口;API 和 WebSocket 继续关闭访问。“认证设置”按登录设备列出浏览器、对端 IP 和活动时间;注销一台设备会同时撤销它的 DSH Session 与关联 Management Session。管理中心也可切换到公开独立 Origin,或使用映射到容器独立入口的本机宿主机端口(默认 `3081`,可自定义)。 + +同一浏览器连续 5 次输入错误密码后,从等待 30 秒开始,后续失败会使等待时间翻倍,最高 15 分钟。每个浏览器来源还限制为每小时 12 次、每 24 小时 24 次失败;整个 DSH 实例跨所有来源使用更宽的防洪泛限制:每分钟 20 次、每小时 60 次、每 24 小时 120 次失败。浏览器只展示 Access Manager 的判定和剩余时间;密码验证成功会清除当前浏览器的连续失败和来源滚动窗口,不清除实例总量记录。 + +遗失凭据时只能从交互式 Root 控制台恢复。密码输入会关闭回显,且不接受命令参数或管道输入: + +```bash +docker exec -it --user root deepseek-harness dsh-platform access status +docker exec -it --user root deepseek-harness dsh-platform access reset +docker exec -it --user root deepseek-harness dsh-platform access clear-retry +docker exec -it --user root deepseek-harness dsh-platform access clear-retry --global-only +docker exec -it --user root deepseek-harness dsh-platform access generate-key +``` + +`access clear-retry` 使用 `y/[n]` 确认,会清除全部浏览器的重试等待、来源滚动窗口和实例总量失败窗口。添加 `--global-only` 时只清除实例总量窗口,保留每个浏览器的重试等待与来源滚动限制。两种形式都不修改凭据或会话。 + +全新空卷直接显示普通管理员注册页,不需要密钥。只有旧部署迁移或认证状态损坏时,才使用 `access generate-key` 生成十分钟有效、单次使用的认证重置密钥;恢复页可据此重新创建管理员账户。 + +常用命令: + +```bash +docker exec deepseek-harness dsh-platform status +docker exec deepseek-harness dsh-platform check +docker exec deepseek-harness dsh-platform update --wait +docker exec deepseek-harness dsh-platform stop --wait +docker exec deepseek-harness dsh-platform start --wait +docker exec deepseek-harness dsh-platform restart --wait +docker exec deepseek-harness dsh-platform rollback +``` + +Stable 跟随受支持的 DSH 版本。Experimental 可以安装经过验证的更新上游 DSH,并在激活前创建数据快照。返回 Stable 可能丢弃快照之后写入的数据。 + +## 系统插件 + +Container Environment 包含以下 DSH-Docker 集成: + +| 插件 | 用途 | +| --- | --- | +| `@dsh-docker/platform-management` | 在 DSH 设置中增加“平台管理”,用于更新、运行维护、日志、系统资源和当前浏览器会话退出;该集成由平台托管 | +| `@dsh-docker/settings-navigation` | 让桌面端设置目录独立滚动,并在窄屏提供目录与详情分级导航 | +| `@dsh-docker/settings-document-editor` | 将只能在桌面打开配置文件的操作替换为可选的浏览器 `settings.yaml` 编辑器 | + +除“平台管理”本身外,已安装的系统插件可在 DSH 的“平台管理”中启用或禁用;“平台管理”不能修改自身。独立的“DSH 管理中心”可以安装、卸载、启用或禁用 Environment 随附的系统插件,并能在“平台管理”缺失时将其恢复。未提交的选择只保留在当前页面;点击应用后,即使 DSH 已不可用或用于激活修改的重启失败,修改仍会保留,并在下次成功启动时生效。安装只使用经过验证的本地 Environment 资产,不会从 GitHub 或 npm 下载。第三方用户插件与系统插件分开管理,不会被视为系统插件。 + +“平台管理”和“设置文档编辑器”通过已认证的 DSH Session 使用 DSH 侧受限平台接口,不要求用户另行取得 Management Session。“平台管理”可以撤销当前浏览器的 DSH Session 及其关联的 Management Session,但用户名、密码和完整认证设置仍只能在独立管理中心或 Root CLI 中修改。 + +## 用户插件 + +独立管理中心会列出 DSH Web Profile 中的 Bundle 用户插件,并可精确启用、禁用或卸载指定插件。应用修改时会为完整 Web Profile 创建快照,并在 DSH 可用时只重启 DSH。若 DSH 已停止或重启失败,已提交的修改仍会保留,用户可继续处理其他故障插件后再尝试启动。插件安装仍使用 DSH 正常插件流程或容器终端;恢复页面不安装任意软件包。 + +## 系统技能与用户技能 + +DSH Docker 内置已签名的 `dsh-docker-operations` System Skill,为 Agent 提供文件、开发工具、插件、生命周期、日志、更新、恢复、网络和权限等容器环境操作指引,也可显式调用 `/dsh-docker-operations`。 + +系统技能可在 DSH 内的“平台管理”和独立管理中心的“系统技能”中管理,变更会立即生效,无需重启 DSH。独立管理中心支持安装、卸载、启用和禁用;“平台管理”可恢复缺失技能并启禁用。项目或用户的同名 Skill 仍按 DSH 原生优先级覆盖内置副本。 + +独立管理中心还会列出已配置 DSH 与 Agent Skill 根中的原生用户技能,可精确启用、禁用或删除指定条目,不会修改项目级 Skill。 + +## 安全提醒 + +已认证的 DSH Session 拥有完整 DSH 权限;Management Session 还可使用 root 容器终端和 root 文件操作,因此管理员可以读取凭据、执行命令并修改容器数据。远程访问必须使用 HTTPS 和可信网络边界。 + +仓库 Compose 默认关闭 DSH/Agent 的免密码 sudo。只有 DSH 或 Agent 明确需要 Root 权限时才设置 `DSH_SUDO_ENABLED=true`。不了解影响时,不要同时使用 sudo、特权模式、Docker Socket 或敏感宿主机挂载。 + +## 详细文档 + +[完整指南](docs/cn/guide.md)包括: + +- 部署前提、两种 Compose 布局和 Docker Run; +- 全部配置变量、数据存储、Gateway 和远程访问行为; +- 平台架构、在线更新、系统与用户插件、系统与用户技能、日志、信任、恢复和回滚; +- 独立恢复工具、发布自动化、本地构建、测试和开发工具。 diff --git a/upstream/container/README.md b/upstream/container/README.md new file mode 100644 index 0000000..8bac562 --- /dev/null +++ b/upstream/container/README.md @@ -0,0 +1,15 @@ +# Container source layout + +The container source tree follows runtime ownership boundaries: + +```text +container/ +├── platform/ Stage-0, Bootstrap, shared contracts, and release tools +├── control-plane/ Bootstrap-owned services and platform managers +├── environment/ Reloadable definition, workloads, and packaged resources +└── test/ Container and Compose integration checks +``` + +`platform/` contains the trust boundary, Bootstrap, and shared protocol code. Persistent services and managers belong under `control-plane/`; the complete Container Environment source belongs under `environment/`. A Component Manifest lives with the implementation it starts. Environment inputs which are not processes live under `environment/resources/`: DSH overlays under `resources/plugins//`, exact source patches under `resources/patches/`, and Agent guidance under `resources/skills//`. Environment-owned components, patches, System Plugins, and System Skills are identified by `id` and SHA-256 rather than independent release versions. + +Bootstrap archives preserve the same `platform/` and `control-plane/` top-level layout. Image and Store References therefore resolve through the same `/run/dsh-platform/views/bootstrap` runtime view, without compatibility links or duplicate component copies. diff --git a/upstream/container/control-plane/definition.json b/upstream/container/control-plane/definition.json new file mode 100644 index 0000000..2fe8b48 --- /dev/null +++ b/upstream/container/control-plane/definition.json @@ -0,0 +1,25 @@ +{ + "schema": 1, + "components": [ + { + "id": "gateway", + "source": "services/gateway/component.json" + }, + { + "id": "access-manager", + "source": "services/access-manager/component.json" + }, + { + "id": "outbound-proxy", + "source": "services/outbound-proxy/component.json" + }, + { + "id": "platform-recovery", + "source": "hooks/recovery/component.json" + }, + { + "id": "platform-management", + "source": "services/management/component.json" + } + ] +} diff --git a/upstream/container/control-plane/hooks/dsh-web-ready.mjs b/upstream/container/control-plane/hooks/dsh-web-ready.mjs new file mode 100644 index 0000000..fdfdf4e --- /dev/null +++ b/upstream/container/control-plane/hooks/dsh-web-ready.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node + +import { request } from 'node:http' +import { realpathSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const RUN_ROOT = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform' + +function fetchResponse(host, port, path, options = {}) { + return new Promise((resolve, reject) => { + const outgoing = request({ hostname: host, port, path, ...options }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.once('end', () => { + resolve({ + body: Buffer.concat(chunks), + headers: response.headers, + status: response.statusCode, + }) + }) + }) + outgoing.setTimeout(2_000, () => outgoing.destroy(new Error('DSH Web request timed out'))) + outgoing.once('error', reject) + outgoing.end(options.body) + }) +} + +async function fetch(host, port, path, options = {}) { + const response = await fetchResponse(host, port, path, options) + if (response.status !== 200) throw new Error(`${path} returned HTTP ${String(response.status)}`) + return response.body +} + +function delay(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)) +} + +function lifecycleReadiness() { + const body = Buffer.from('{}') + return new Promise((resolve, reject) => { + const outgoing = request({ + socketPath: `${RUN_ROOT}/dsh-lifecycle.sock`, + path: '/v1/runtime/readiness', + method: 'POST', + headers: { + 'content-length': body.byteLength, + 'content-type': 'application/json', + }, + }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.once('end', () => { + if (response.statusCode !== 200) { + reject(new Error(`DSH lifecycle readiness returned HTTP ${String(response.statusCode)}`)) + return + } + try { + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) + resolve({ + generation: typeof value?.generation === 'string' ? value.generation : null, + ready: value?.ready === true, + readyUrl: typeof value?.readyUrl === 'string' ? value.readyUrl : null, + }) + } catch (error) { + reject(error) + } + }) + }) + outgoing.setTimeout(2_000, () => outgoing.destroy(new Error('DSH lifecycle readiness timed out'))) + outgoing.once('error', reject) + outgoing.end(body) + }) +} + +async function waitForManagedReady(timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const readiness = await lifecycleReadiness() + if (readiness.ready) return readiness + await delay(100) + } + throw new Error('DSH managed Web boot did not complete') +} + +async function authenticationHeaders(host, port, readyUrl) { + if (readyUrl === null) return {} + const url = new URL(readyUrl) + if (url.protocol !== 'http:' || url.hostname !== host || Number(url.port) !== port + || url.pathname !== '/' || url.username !== '' || url.password !== '' || url.hash !== '' + || url.searchParams.getAll('token').length !== 1) { + throw new Error('DSH Web readiness URL is invalid') + } + const exchange = await fetchResponse(host, port, `${url.pathname}${url.search}`, { + headers: { 'cache-control': 'no-store' }, + }) + const cookie = exchange.headers['set-cookie']?.[0]?.split(';', 1)[0] + // dsh 0.2.x redirects to './' instead of '/' + if (exchange.status !== 303 || !['/', './'].includes(exchange.headers.location) || typeof cookie !== 'string' || cookie === '') { + throw new Error('DSH Web authentication exchange failed') + } + return { cookie } +} + +async function verifyBootManifest(host, port, headers) { + const html = (await fetch(host, port, '/', { headers })).toString('utf8') + const match = /(?:window\.__DSH_BOOT__|globalThis\["__DSH_BOOT__"\])\s*=\s*(\{.*?\})<\/script>/s.exec(html) + if (match === null) throw new Error('DSH boot manifest is unavailable') + const manifest = JSON.parse(match[1]) + if (!Array.isArray(manifest.entries)) throw new Error('DSH boot manifest entries are invalid') + if (manifest.batches !== undefined && !Array.isArray(manifest.batches)) { + throw new Error('DSH boot manifest batches are invalid') + } + const resources = [ + ...manifest.entries.map(entry => entry?.url), + ...(manifest.batches ?? []).map(batch => batch?.url), + ] + await Promise.all([...new Set(resources)].map(async resource => { + // dsh 0.2.x emits plugin URLs relative to the root ('plugins/…'), 0.1.x absolute ('/plugins/…') + const url = typeof resource === 'string' ? `/${resource.replace(/^\/+/, '')}` : resource + if (typeof resource !== 'string' || !url.startsWith('/plugins/')) { + throw new Error('DSH boot manifest contains an invalid Plugin URL') + } + await fetch(host, port, url, { headers }) + })) + return JSON.stringify({ + batches: manifest.batches ?? [], + entries: manifest.entries, + rev: manifest.rev ?? null, + }) +} + +async function verifyPluginInventory(host, port, headers) { + const body = JSON.stringify({ + type: 'client-request', + rpcId: 'dsh-platform-readiness', + method: 'pluginInventory/list', + payload: { args: {} }, + }) + const response = JSON.parse((await fetch(host, port, '/api/pluginInventory/list', { + method: 'POST', + headers: { + ...headers, + 'content-length': Buffer.byteLength(body), + 'content-type': 'application/json', + }, + body, + })).toString('utf8')) + if (response?.rpcId !== 'dsh-platform-readiness' || response?.result?.ok !== true) { + const message = response?.result?.error?.message + throw new Error(`DSH Plugin inventory is unavailable${typeof message === 'string' ? `: ${message}` : ''}`) + } + const entries = response.result.value?.entries + if (!Array.isArray(entries)) throw new Error('DSH Plugin inventory entries are invalid') + const unhealthy = entries.filter(entry => entry?.enabled === true && entry?.fiberPhase !== 'active') + if (unhealthy.length > 0) { + const detail = unhealthy.map(entry => `${String(entry?.moduleName ?? entry?.entryId ?? 'unknown')} (${String(entry?.fiberPhase)})`).join(', ') + throw new Error(`DSH Plugins are not active: ${detail}`) + } + return JSON.stringify(entries.map(entry => ({ + enabled: entry?.enabled === true, + entryId: entry?.entryId ?? null, + fiberPhase: entry?.fiberPhase ?? null, + moduleName: entry?.moduleName ?? null, + })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)))) +} + +function sameLifecycle(left, right) { + return left?.generation === right?.generation && left?.readyUrl === right?.readyUrl +} + +export async function verifyDshWebReady({ + host = '127.0.0.1', + port = 3079, + managedReady = waitForManagedReady, +} = {}) { + const firstReadiness = await managedReady() + const headers = await authenticationHeaders(host, port, firstReadiness?.readyUrl ?? null) + const firstManifest = await verifyBootManifest(host, port, headers) + const firstInventory = await verifyPluginInventory(host, port, headers) + const middleReadiness = await managedReady() + if (!sameLifecycle(firstReadiness, middleReadiness)) { + throw new Error('DSH lifecycle changed during Web readiness verification') + } + const secondManifest = await verifyBootManifest(host, port, headers) + const secondInventory = await verifyPluginInventory(host, port, headers) + const finalReadiness = await managedReady() + if (!sameLifecycle(firstReadiness, finalReadiness)) { + throw new Error('DSH lifecycle changed during Web readiness verification') + } + if (firstManifest !== secondManifest) { + throw new Error('DSH boot manifest changed during Web readiness verification') + } + if (firstInventory !== secondInventory) { + throw new Error('DSH Plugin inventory changed during Web readiness verification') + } +} + +if (process.argv[1] !== undefined + && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { + await verifyDshWebReady() +} diff --git a/upstream/container/control-plane/hooks/recovery/component.json b/upstream/container/control-plane/hooks/recovery/component.json new file mode 100644 index 0000000..a49bc6b --- /dev/null +++ b/upstream/container/control-plane/hooks/recovery/component.json @@ -0,0 +1,21 @@ +{ + "schema": 1, + "id": "platform-recovery", + "type": "oneshot", + "command": { + "executable": "/usr/local/bin/node", + "args": ["/run/dsh-platform/views/bootstrap/control-plane/hooks/recovery/index.mjs"], + "timeoutSeconds": 120 + }, + "environment": {}, + "lifecycle": { + "prepare": null, + "preStart": null, + "postStart": null, + "preStop": null, + "stop": null, + "postStop": null + }, + "health": null, + "logging": { "stdout": true, "stderr": true } +} diff --git a/upstream/container/control-plane/hooks/recovery/index.mjs b/upstream/container/control-plane/hooks/recovery/index.mjs new file mode 100644 index 0000000..245d2f2 --- /dev/null +++ b/upstream/container/control-plane/hooks/recovery/index.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +import { realpathSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { PlatformActivator } from '../../modules/updater/lib/activator.mjs' +import { UpdateJournal } from '../../modules/updater/lib/journal.mjs' +import { recoverInterruptedUpdate } from '../../modules/updater/lib/recovery.mjs' +import { PersistentStateSnapshots } from '../../modules/updater/lib/snapshots.mjs' +import { PlatformPaths } from '../../../platform/lib/paths.mjs' + +export async function recoverPlatformUpdateBeforeDshStart({ + dataRoot = process.env.DSH_PLATFORM_DATA ?? '/data/platform', + runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform', + dshHome = process.env.DSH_HOME ?? '/data/dsh', +} = {}) { + const paths = new PlatformPaths(dataRoot, runRoot) + return recoverInterruptedUpdate({ + journal: new UpdateJournal(join(paths.updaterStateRoot, 'transaction.json')), + snapshots: new PersistentStateSnapshots({ root: paths.snapshotsRoot, sourceRoot: dshHome }), + activator: new PlatformActivator({ dataRoot, runRoot }), + resume: false, + }) +} + +if (process.argv[1] !== undefined + && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { + await recoverPlatformUpdateBeforeDshStart() +} diff --git a/upstream/container/control-plane/modules/file-manager/archives.mjs b/upstream/container/control-plane/modules/file-manager/archives.mjs new file mode 100644 index 0000000..dc84a39 --- /dev/null +++ b/upstream/container/control-plane/modules/file-manager/archives.mjs @@ -0,0 +1,110 @@ +import { spawn } from 'node:child_process' +import { lstat, mkdir, readdir, readlink, rm } from 'node:fs/promises' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { FileManagerError } from './index.mjs' + +export const ARCHIVE_FORMATS = Object.freeze({ + zip: { extension: '.zip', command: 'zip' }, + '7z': { extension: '.7z', command: '7z' }, + 'tar.gz': { extension: '.tar.gz', command: 'tar' }, +}) + +export function normalizeArchiveFormat(value) { + if (!Object.hasOwn(ARCHIVE_FORMATS, value)) throw new FileManagerError('archive format is invalid') + return value +} + +function run(command, args, { cwd, signal, capture = false } = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd, signal, stdio: ['ignore', capture ? 'pipe' : 'ignore', 'pipe'] }) + let errorOutput = '' + let output = '' + if (capture) { + child.stdout.setEncoding('utf8') + child.stdout.on('data', chunk => { if (output.length < 8 * 1024 * 1024) output += chunk }) + } + child.stderr.setEncoding('utf8') + child.stderr.on('data', chunk => { if (errorOutput.length < 16_384) errorOutput += chunk }) + child.once('error', reject) + child.once('exit', (code, signalName) => { + if (code === 0) resolvePromise(output) + else reject(new FileManagerError( + `archive tool failed${errorOutput.trim() === '' ? '' : `: ${errorOutput.trim()}`}`, + 400, + signalName === 'SIGTERM' ? 'FILE_TASK_CANCELLED' : 'ARCHIVE_TOOL_FAILED', + )) + }) + }) +} + +function validateEntryNames(names) { + for (const original of names) { + const name = original.replace(/^(\.\/)+/u, '') + if (name === '') continue + if (name.includes('\0') || name.startsWith('/') || name.split('/').includes('..')) { + throw new FileManagerError('archive contains an unsafe path', 400, 'ARCHIVE_UNSAFE') + } + } +} + +function inside(root, path) { + const value = relative(root, path) + return value === '' || (!value.startsWith(`..${sep}`) && value !== '..') +} + +export async function validateExtractedTree(root) { + const pending = [root] + let entries = 0 + let bytes = 0 + while (pending.length > 0) { + const path = pending.pop() + const details = await lstat(path) + if (details.isDirectory() && !details.isSymbolicLink()) { + for (const name of await readdir(path)) pending.push(join(path, name)) + } else if (details.isSymbolicLink()) { + const target = await readlink(path) + if (target.startsWith('/') || !inside(root, resolve(dirname(path), target))) { + throw new FileManagerError('archive contains a symbolic link outside its destination', 400, 'ARCHIVE_UNSAFE') + } + } else if (!details.isFile()) { + throw new FileManagerError('archive contains an unsupported special file', 415, 'ARCHIVE_UNSAFE') + } + entries += 1 + if (details.isFile()) bytes += details.size + } + return { entries: Math.max(0, entries - 1), bytes } +} + +export async function createArchive({ format, sourceRoot, output, entries = ['.'], signal }) { + normalizeArchiveFormat(format) + if (!Array.isArray(entries) || entries.length === 0 || entries.some(entry => typeof entry !== 'string' || entry === '' || entry.includes('\0') || entry.includes('/'))) { + throw new FileManagerError('archive entry names are invalid') + } + if (format === 'zip') await run('zip', ['-q', '-r', output, '--', ...entries], { cwd: sourceRoot, signal }) + else if (format === '7z') await run('7z', ['a', '-bd', '-y', '-t7z', output, '--', ...entries], { cwd: sourceRoot, signal }) + else await run('tar', ['-czf', output, '-C', sourceRoot, '--', ...entries], { signal }) +} + +export async function extractArchive({ format, archive, output, signal }) { + normalizeArchiveFormat(format) + await mkdir(output, { recursive: true }) + try { + if (format === 'zip') { + validateEntryNames((await run('unzip', ['-Z1', archive], { signal, capture: true })).split('\n')) + await run('unzip', ['-qq', archive, '-d', output], { signal }) + } else if (format === '7z') { + const listed = await run('7z', ['l', '-slt', '--', archive], { signal, capture: true }) + validateEntryNames(listed.split('\n').filter(line => line.startsWith('Path = ')).slice(1).map(line => line.slice(7))) + await run('7z', ['x', '-bd', '-y', `-o${output}`, '--', archive], { signal }) + } else { + validateEntryNames((await run('tar', ['-tzf', archive], { signal, capture: true })).split('\n')) + await run('tar', ['--extract', '--gzip', '--file', archive, '--directory', output, '--no-same-owner', '--no-same-permissions'], { signal }) + } + return await validateExtractedTree(output) + } catch (error) { + await rm(output, { recursive: true, force: true }).catch(() => {}) + throw error + } +} + +export const archiveInternals = Object.freeze({ inside, run, validateEntryNames }) diff --git a/upstream/container/control-plane/modules/file-manager/editor.mjs b/upstream/container/control-plane/modules/file-manager/editor.mjs new file mode 100644 index 0000000..44180ff --- /dev/null +++ b/upstream/container/control-plane/modules/file-manager/editor.mjs @@ -0,0 +1,78 @@ +import { link, open, rename, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { + FileManagerError, FileRevisionConflictError, MAX_TEXT_BYTES, fileError, + fileManagerInternals, isManagedPath, normalizeAbsolutePath, +} from './index.mjs' + +async function syncDirectory(path) { + const handle = await open(path, 'r') + try { await handle.sync() } finally { await handle.close() } +} + +function bytes(value) { + if (typeof value !== 'string') throw new FileManagerError('content must be a string') + const result = Buffer.from(value, 'utf8') + if (result.byteLength > MAX_TEXT_BYTES) throw new FileManagerError('file is too large to edit', 413, 'FILE_TOO_LARGE') + if (result.includes(0)) throw new FileManagerError('content contains NUL', 415, 'FILE_TYPE_UNSUPPORTED') + return result +} + +export class AtomicFileEditor { + constructor({ isManaged = isManagedPath } = {}) { + this.isManaged = isManaged + } + + async write(value, content, revision, { create = false } = {}) { + const path = normalizeAbsolutePath(value) + const body = bytes(content) + const parent = dirname(path) + let original + try { + original = await stat(path, { bigint: true }) + if (!original.isFile()) throw new FileManagerError('path is not a regular file', 415, 'FILE_TYPE_UNSUPPORTED') + if (create) throw new FileManagerError('file already exists', 409, 'FILE_EXISTS') + if (typeof revision !== 'string' || revision !== fileManagerInternals.revisionFor(path, original)) { + throw new FileRevisionConflictError() + } + } catch (error) { + if (error?.code !== 'ENOENT') throw fileError(error) + if (!create) throw fileError(error) + if (revision !== null && revision !== undefined) throw new FileManagerError('new file revision must be null') + } + const temporary = join(parent, `.dsh-edit-${randomUUID()}.tmp`) + let handle + try { + handle = await open(temporary, 'wx', original === undefined ? 0o644 : Number(original.mode & 0o7777n)) + await handle.writeFile(body) + await handle.sync() + if (original !== undefined) { + await handle.chmod(Number(original.mode & 0o7777n)) + const current = await handle.stat({ bigint: true }) + if (current.uid !== original.uid || current.gid !== original.gid) { + try { await handle.chown(Number(original.uid), Number(original.gid)) } catch { + throw new FileManagerError('cannot preserve file ownership; use the terminal', 403, 'OWNERSHIP_PRESERVE_FAILED') + } + } + } + await handle.close() + handle = undefined + if (original === undefined) { + await link(temporary, path) + await rm(temporary) + } else await rename(temporary, path) + await syncDirectory(parent) + const saved = await stat(path, { bigint: true }) + return { + path, revision: fileManagerInternals.revisionFor(path, saved), size: body.byteLength, + mode: Number(saved.mode & 0o7777n), modifiedAt: new Date(Number(saved.mtimeNs / 1_000_000n)).toISOString(), + managed: this.isManaged(path), + } + } catch (error) { + await handle?.close().catch(() => {}) + await rm(temporary, { force: true }).catch(() => {}) + throw fileError(error) + } + } +} diff --git a/upstream/container/control-plane/modules/file-manager/index.mjs b/upstream/container/control-plane/modules/file-manager/index.mjs new file mode 100644 index 0000000..8a89085 --- /dev/null +++ b/upstream/container/control-plane/modules/file-manager/index.mjs @@ -0,0 +1,518 @@ +import { createHash, randomUUID } from 'node:crypto' +import { constants } from 'node:fs' +import { access, lstat, readFile, readdir, readlink, realpath, stat } from 'node:fs/promises' +import { basename, dirname, join, normalize, resolve } from 'node:path' + +export const MAX_PATH_BYTES = 4096 +export const MAX_TEXT_BYTES = 2 * 1024 * 1024 +export const DEFAULT_LIST_LIMIT = 200 +export const MAX_LIST_LIMIT = 1000 +export const SEARCH_MAX_DEPTH = 16 +export const SEARCH_MAX_SCANNED = 10_000 +export const SEARCH_MAX_RESULTS = 1000 + +const SORTS = new Set(['name', 'size', 'type', 'modifiedAt']) +const ORDERS = new Set(['asc', 'desc']) + +export class FileManagerError extends Error { + constructor(message, statusCode = 400, code = 'FILE_INVALID') { + super(message) + this.name = 'FileManagerError' + this.statusCode = statusCode + this.code = code + } +} + +export class FileRevisionConflictError extends FileManagerError { + constructor(message = 'file revision changed') { + super(message, 409, 'REVISION_CONFLICT') + this.name = 'FileRevisionConflictError' + } +} + +function statusCode(error) { + if (['EACCES', 'EPERM', 'EROFS'].includes(error?.code)) return 403 + if (['ENOENT', 'ENOTDIR'].includes(error?.code)) return 404 + if (error?.code === 'EFBIG') return 413 + return 400 +} + +export function fileError(error, fallback = 'file operation failed') { + if (error instanceof FileManagerError) return error + const wrapped = new FileManagerError(error instanceof Error ? error.message : fallback, statusCode(error), error?.code ?? 'FILE_ERROR') + if (error instanceof Error) wrapped.cause = error + return wrapped +} + +export function normalizeAbsolutePath(value) { + if (typeof value !== 'string' || value.length === 0) throw new FileManagerError('path must be a non-empty string') + if (!value.startsWith('/')) throw new FileManagerError('path must be absolute') + if (Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES) throw new FileManagerError('path is too long') + if(/[\u0000-\u001f\u007f]/u.test(value)) throw new FileManagerError('path contains control characters') + const path = normalize(value) + if (!path.startsWith('/')) throw new FileManagerError('path must be absolute') + return path +} + +export function createManagedPathMatcher(platformData = '/data/platform') { + const data = normalizeAbsolutePath(platformData) + const roots = Object.freeze(['/opt/dsh-platform', '/run/dsh-platform', join(data, 'state'), join(data, 'store')]) + return value => { + const path = normalizeAbsolutePath(value) + return roots.some(root => path === root || path.startsWith(`${root}/`)) + } +} + +export const isManagedPath = createManagedPathMatcher() + +export function fileManagerLocations({ + platformData = '/data/platform', dshHome = '/data/dsh', defaultWorkspace = '/workspace', +} = {}) { + const defaultPath = normalizeAbsolutePath(defaultWorkspace) + const shortcuts = [...new Set([defaultPath, normalizeAbsolutePath(dshHome), normalizeAbsolutePath(platformData), '/'])] + return Object.freeze({ defaultPath, shortcuts: Object.freeze(shortcuts) }) +} + +function parseIdentityFile(value, idIndex) { + const result = new Map() + for (const line of value.split('\n')) { + if (line === '' || line.startsWith('#')) continue + const fields = line.split(':') + const id = Number(fields[idIndex]) + if (fields[0] !== '' && Number.isSafeInteger(id) && id >= 0 && !result.has(id)) result.set(id, fields[0]) + } + return result +} + +export class UnixIdentityResolver { + constructor({ passwdPath = '/etc/passwd', groupPath = '/etc/group' } = {}) { + this.passwdPath = passwdPath + this.groupPath = groupPath + this.loading = undefined + } + + async #identities() { + this.loading ??= Promise.all([ + readFile(this.passwdPath, 'utf8').then(value => parseIdentityFile(value, 2), () => new Map()), + readFile(this.groupPath, 'utf8').then(value => parseIdentityFile(value, 2), () => new Map()), + ]) + return this.loading + } + + async resolve(uid, gid) { + const [users, groups] = await this.#identities() + return { user: users.get(uid) ?? String(uid), group: groups.get(gid) ?? String(gid) } + } + + async userId(value) { return this.#identityId(value, 0, 'user') } + + async groupId(value) { return this.#identityId(value, 1, 'group') } + + async #identityId(value, index, label) { + if (typeof value !== 'string' || value === '' || value.length > 256 || /[:\u0000-\u001f\u007f]/u.test(value)) { + throw new FileManagerError(`${label} is invalid`, 400, 'FILE_IDENTITY_INVALID') + } + if (/^[0-9]+$/u.test(value)) { + const id = Number(value) + if (Number.isSafeInteger(id) && id >= 0 && id < 0xffff_ffff) return id + throw new FileManagerError(`${label} id is invalid`, 400, 'FILE_IDENTITY_INVALID') + } + const identities = (await this.#identities())[index] + for (const [id, name] of identities) if (name === value) return id + throw new FileManagerError(`${label} was not found`, 400, 'FILE_IDENTITY_INVALID') + } +} + +function kind(stats) { + if (stats.isFile()) return 'file' + if (stats.isDirectory()) return 'directory' + if (stats.isSymbolicLink()) return 'symlink' + if (stats.isBlockDevice()) return 'block-device' + if (stats.isCharacterDevice()) return 'character-device' + if (stats.isFIFO()) return 'fifo' + if (stats.isSocket()) return 'socket' + return 'unknown' +} + +function metadata(stats) { + return { + type: kind(stats), + size: Number(stats.size), + mode: Number(stats.mode & 0o7777n), + uid: Number(stats.uid), + gid: Number(stats.gid), + modifiedAt: new Date(Number(stats.mtimeNs / 1_000_000n)).toISOString(), + createdAt: new Date(Number(stats.birthtimeNs / 1_000_000n)).toISOString(), + } +} + +function revisionFor(path, stats, extra = '') { + return `sha256:${createHash('sha256').update(JSON.stringify([ + path, stats.dev.toString(), stats.ino.toString(), kind(stats), stats.size.toString(), + stats.mode.toString(), stats.uid.toString(), stats.gid.toString(), stats.mtimeNs.toString(), stats.ctimeNs.toString(), extra, + ])).digest('hex')}` +} + +async function permissions(path) { + const allowed = async mode => access(path, mode).then(() => true, () => false) + const [readable, writable] = await Promise.all([allowed(constants.R_OK), allowed(constants.W_OK)]) + return { readable, writable } +} + +async function symlinkDetails(path, stats) { + if (!stats.isSymbolicLink()) return {} + const linkTarget = await readlink(path) + try { + const target = await stat(path, { bigint: true }) + return { linkTarget, targetExists: true, targetType: kind(target), realPath: await realpath(path) } + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ELOOP') return { linkTarget, targetExists: false, targetType: null, realPath: null } + throw error + } +} + +async function describe(path, stats = undefined, managed = isManagedPath, identity = new UnixIdentityResolver()) { + const value = stats ?? await lstat(path, { bigint: true }) + const link = await symlinkDetails(path, value) + const owner = await identity.resolve(Number(value.uid), Number(value.gid)) + return { + name: basename(path) || '/', + path, + ...metadata(value), + ...owner, + ...await permissions(path), + managed: managed(path), + revision: revisionFor(path, value, link.linkTarget ?? ''), + ...link, + } +} + +function compareEntries(left, right, sort, order) { + if (left.type === 'directory' && right.type !== 'directory') return -1 + if (left.type !== 'directory' && right.type === 'directory') return 1 + let result + if (sort === 'size') result = left.size - right.size + else if (sort === 'modifiedAt') result = left.modifiedAt.localeCompare(right.modifiedAt) + else if (sort === 'type') result = left.type.localeCompare(right.type) + else result = left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' }) + if (result === 0) result = left.name.localeCompare(right.name) + return order === 'desc' ? -result : result +} + +function encodeCursor(revision, offset) { + return Buffer.from(JSON.stringify({ revision, offset })).toString('base64url') +} + +function decodeCursor(value, revision) { + if (value === undefined || value === null || value === '') return 0 + try { + const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) + if (parsed.revision !== revision) throw new FileRevisionConflictError('directory changed while paging') + if (!Number.isSafeInteger(parsed.offset) || parsed.offset < 0) throw new Error('invalid offset') + return parsed.offset + } catch (error) { + if (error instanceof FileRevisionConflictError) throw error + throw new FileManagerError('cursor is invalid') + } +} + +function listRevision(path, stats, entries) { + const digest = createHash('sha256') + for (const entry of entries) digest.update(`${entry.name}\0${entry.revision ?? entry.type}\0`) + return revisionFor(path, stats, digest.digest('hex')) +} + +function directoryEntryType(entry) { + if (entry.isDirectory()) return 'directory' + if (entry.isFile()) return 'file' + if (entry.isSymbolicLink()) return 'symlink' + if (entry.isFIFO()) return 'fifo' + if (entry.isSocket()) return 'socket' + if (entry.isBlockDevice()) return 'block-device' + if (entry.isCharacterDevice()) return 'character-device' + return 'unknown' +} + +function compareNames(left, right, order) { + if (left.type === 'directory' && right.type !== 'directory') return -1 + if (left.type !== 'directory' && right.type === 'directory') return 1 + const result = left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' }) + || left.name.localeCompare(right.name) + return order === 'desc' ? -result : result +} + +function parseLimit(value, fallback = DEFAULT_LIST_LIMIT) { + if (value === undefined || value === null || value === '') return fallback + const limit = Number(value) + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) throw new FileManagerError(`limit must be between 1 and ${MAX_LIST_LIMIT}`) + return limit +} + +function parseOffset(value) { + if (value === undefined || value === null || value === '') return undefined + const offset = Number(value) + if (!Number.isSafeInteger(offset) || offset < 0) throw new FileManagerError('offset must be a non-negative integer') + return offset +} + +export class FileInventory { + constructor({ isManaged = isManagedPath, identity = new UnixIdentityResolver() } = {}) { + this.isManaged = isManaged + this.identity = identity + } + + async stat(value) { + const path = normalizeAbsolutePath(value) + try { + const item = await describe(path, undefined, this.isManaged, this.identity) + return { ...item, realPath: item.realPath ?? await realpath(path).catch(() => path), parent: dirname(path) } + } catch (error) { + throw fileError(error) + } + } + + async list(value, { cursor, offset: requestedOffset, limit, sort = 'name', order = 'asc' } = {}) { + const path = normalizeAbsolutePath(value) + if (!SORTS.has(sort)) throw new FileManagerError('sort is invalid') + if (!ORDERS.has(order)) throw new FileManagerError('order is invalid') + try { + const root = await lstat(path, { bigint: true }) + if (!root.isDirectory()) throw new FileManagerError('path is not a directory', 415, 'FILE_TYPE_UNSUPPORTED') + const pageSize = parseLimit(limit) + const directOffset = parseOffset(requestedOffset) + if (directOffset !== undefined && cursor !== undefined && cursor !== null && cursor !== '') throw new FileManagerError('cursor and offset cannot be combined') + const names = await readdir(path, { withFileTypes: true }) + let entries + let revision + let offset + if (sort === 'name') { + const summary = names.map(entry => ({ name: entry.name, type: directoryEntryType(entry) })) + summary.sort((left, right) => compareNames(left, right, order)) + revision = listRevision(path, root, summary) + offset = directOffset ?? decodeCursor(cursor, revision) + entries = await Promise.all(summary.slice(offset, offset + pageSize).map(entry => ( + describe(resolve(path, entry.name), undefined, this.isManaged, this.identity) + ))) + } else { + entries = await Promise.all(names.map(entry => describe(resolve(path, entry.name), undefined, this.isManaged, this.identity))) + entries.sort((left, right) => compareEntries(left, right, sort, order)) + revision = listRevision(path, root, entries) + offset = directOffset ?? decodeCursor(cursor, revision) + } + const page = sort === 'name' ? entries : entries.slice(offset, offset + pageSize) + const nextOffset = offset + page.length + return { + path, + realPath: await realpath(path), + revision, + entries: page, + nextCursor: nextOffset < names.length ? encodeCursor(revision, nextOffset) : null, + total: names.length, + managed: this.isManaged(path), + } + } catch (error) { + throw fileError(error) + } + } + + async content(value) { + const path = normalizeAbsolutePath(value) + try { + const before = await stat(path, { bigint: true }) + if (!before.isFile()) throw new FileManagerError('path is not a regular file', 415, 'FILE_TYPE_UNSUPPORTED') + if (before.size > BigInt(MAX_TEXT_BYTES)) throw new FileManagerError('file is too large to edit', 413, 'FILE_TOO_LARGE') + const bytes = await readFile(path) + const after = await stat(path, { bigint: true }) + const beforeRevision = revisionFor(path, before) + if (beforeRevision !== revisionFor(path, after)) throw new FileRevisionConflictError('file changed while reading') + if (bytes.includes(0)) throw new FileManagerError('file is not UTF-8 text', 415, 'FILE_TYPE_UNSUPPORTED') + const content = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return { + path, + realPath: await realpath(path), + content, + encoding: 'utf-8', + newline: content.includes('\r\n') ? 'crlf' : 'lf', + revision: beforeRevision, + mode: Number(before.mode & 0o7777n), + size: bytes.byteLength, + modifiedAt: new Date(Number(before.mtimeNs / 1_000_000n)).toISOString(), + managed: this.isManaged(path), + } + } catch (error) { + throw fileError(error) + } + } +} + +export class FileSearchManager { + constructor({ inventory = new FileInventory(), onState = () => {} } = {}) { + this.inventory = inventory + this.onState = onState + this.tasks = new Map() + } + + start({ path: value, revision, query }) { + const path = normalizeAbsolutePath(value) + if (typeof query !== 'string' || query.trim().length === 0 || query.length > 256) throw new FileManagerError('search query is invalid') + const task = { + taskId: randomUUID(), operation: 'search', status: 'running', path, revision, query, + scanned: 0, matches: [], truncated: false, error: null, cancelRequested: false, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } + this.tasks.set(task.taskId, task) + this.#publish(task) + task.completion = this.#run(task).catch(error => { + task.status = task.cancelRequested ? 'cancelled' : 'failed' + task.error = error instanceof Error ? error.message : String(error) + this.#publish(task) + }) + return this.public(task) + } + + get(taskId, { cursor, limit } = {}) { + const task = this.tasks.get(taskId) + if (task === undefined) throw new FileManagerError('file task was not found', 404, 'FILE_TASK_NOT_FOUND') + const offset = cursor === undefined || cursor === '' ? 0 : Number(cursor) + if (!Number.isSafeInteger(offset) || offset < 0) throw new FileManagerError('task cursor is invalid') + const size = parseLimit(limit) + return { ...this.public(task), results: task.matches.slice(offset, offset + size), nextCursor: offset + size < task.matches.length ? String(offset + size) : null } + } + + list() { + return [...this.tasks.values()].map(task => this.public(task)).reverse() + } + + cancel(taskId) { + const task = this.tasks.get(taskId) + if (task === undefined) throw new FileManagerError('file task was not found', 404, 'FILE_TASK_NOT_FOUND') + if (task.status !== 'running') throw new FileManagerError('file task is already complete', 409, 'FILE_TASK_COMPLETE') + task.cancelRequested = true + this.#publish(task) + return this.public(task) + } + + public(task) { + const { completion, matches, cancelRequested, ...value } = task + return { ...value, resultCount: matches.length, cancelRequested } + } + + #publish(task) { + task.updatedAt = new Date().toISOString() + this.onState(this.public(task)) + } + + async #run(task) { + const root = await this.inventory.list(task.path, { limit: 1 }) + if (task.revision !== undefined && task.revision !== null && task.revision !== root.revision) throw new FileRevisionConflictError('search root changed') + task.revision = root.revision + const needle = task.query.toLocaleLowerCase() + const pending = [{ path: task.path, depth: 0 }] + while (pending.length > 0 && task.scanned < SEARCH_MAX_SCANNED && task.matches.length < SEARCH_MAX_RESULTS) { + if (task.cancelRequested) { + task.status = 'cancelled' + this.#publish(task) + return + } + const current = pending.shift() + let names + try { names = await readdir(current.path, { withFileTypes: true }) } catch (error) { + if (['EACCES', 'EPERM', 'ENOENT'].includes(error?.code)) continue + throw error + } + for (const entry of names) { + task.scanned += 1 + const child = resolve(current.path, entry.name) + const relative = child.slice(task.path === '/' ? 1 : task.path.length + 1) + if (relative.toLocaleLowerCase().includes(needle)) task.matches.push(await describe(child, undefined, this.inventory.isManaged, this.inventory.identity)) + if (entry.isDirectory() && !entry.isSymbolicLink() && current.depth < SEARCH_MAX_DEPTH) pending.push({ path: child, depth: current.depth + 1 }) + if (task.scanned >= SEARCH_MAX_SCANNED || task.matches.length >= SEARCH_MAX_RESULTS) break + } + this.#publish(task) + await new Promise(resolve => setImmediate(resolve)) + } + task.truncated = pending.length > 0 || task.scanned >= SEARCH_MAX_SCANNED || task.matches.length >= SEARCH_MAX_RESULTS + task.status = 'success' + this.#publish(task) + } +} + +export class FileSizeManager { + constructor({ inventory = new FileInventory(), onState = () => {} } = {}) { + this.inventory = inventory + this.onState = onState + this.tasks = new Map() + } + + start({ path: value, revision } = {}) { + const path = normalizeAbsolutePath(value) + const task = { + taskId: randomUUID(), operation: 'size', status: 'running', path, revision, + bytes: 0, entries: 0, error: null, cancelRequested: false, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } + this.tasks.set(task.taskId, task) + this.#publish(task) + task.completion = this.#run(task).catch(error => { + task.status = task.cancelRequested ? 'cancelled' : 'failed' + task.error = error instanceof Error ? error.message : String(error) + this.#publish(task) + }) + return this.public(task) + } + + get(taskId) { + const task = this.tasks.get(taskId) + if (task === undefined) throw new FileManagerError('file task was not found', 404, 'FILE_TASK_NOT_FOUND') + return this.public(task) + } + + list() { return [...this.tasks.values()].map(task => this.public(task)).reverse() } + + cancel(taskId) { + const task = this.tasks.get(taskId) + if (task === undefined) throw new FileManagerError('file task was not found', 404, 'FILE_TASK_NOT_FOUND') + if (task.status !== 'running') throw new FileManagerError('file task is already complete', 409, 'FILE_TASK_COMPLETE') + task.cancelRequested = true + this.#publish(task) + return this.public(task) + } + + public(task) { + const { completion, cancelRequested, ...value } = task + return { ...value, cancelRequested } + } + + #publish(task) { + task.updatedAt = new Date().toISOString() + this.onState(this.public(task)) + } + + async #run(task) { + const root = await this.inventory.stat(task.path) + if (root.type !== 'directory') throw new FileManagerError('size target is not a directory', 415, 'FILE_TYPE_UNSUPPORTED') + if (typeof task.revision !== 'string' || task.revision !== root.revision) throw new FileRevisionConflictError('size target changed') + const pending = [task.path] + while (pending.length > 0) { + if (task.cancelRequested) { + task.status = 'cancelled' + this.#publish(task) + return + } + const directory = pending.pop() + for (const name of await readdir(directory)) { + const path = resolve(directory, name) + const details = await lstat(path, { bigint: true }) + task.entries += 1 + if (details.isDirectory() && !details.isSymbolicLink()) pending.push(path) + else if (details.isFile()) task.bytes += Number(details.size) + } + this.#publish(task) + await new Promise(resolveTask => setImmediate(resolveTask)) + } + task.status = 'success' + this.#publish(task) + } +} + +export const fileManagerInternals = Object.freeze({ describe, revisionFor, parseIdentityFile, parseLimit }) diff --git a/upstream/container/control-plane/modules/file-manager/tasks.mjs b/upstream/container/control-plane/modules/file-manager/tasks.mjs new file mode 100644 index 0000000..e6f61e8 --- /dev/null +++ b/upstream/container/control-plane/modules/file-manager/tasks.mjs @@ -0,0 +1,645 @@ +import { randomUUID } from 'node:crypto' +import { chmod, cp, lchown, lstat, mkdir, open, readdir, readFile, readlink, rename, rm } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { durableReplace } from '../../../platform/lib/atomic.mjs' +import { createArchive, extractArchive, normalizeArchiveFormat } from './archives.mjs' +import { + FileInventory, FileManagerError, FileRevisionConflictError, FileSearchManager, FileSizeManager, UnixIdentityResolver, + fileError, isManagedPath, normalizeAbsolutePath, +} from './index.mjs' + +const OPERATIONS = new Set(['mkdir', 'touch', 'rename', 'copy', 'move', 'delete', 'attributes', 'archive', 'extract']) +const DEFAULT_PROTECTED_DELETE_ROOTS = ['/', '/data', '/data/dsh', '/data/platform', '/workspace', '/run/dsh-platform/deployment'] + +function now() { return new Date().toISOString() } + +async function exists(path) { + return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error)) +} + +async function syncDirectory(path) { + const handle = await open(path, 'r') + try { await handle.sync() } finally { await handle.close() } +} + +function publicTask(task) { + const { completion, persisted, executor, resolveResult, rejectResult, cancelRequested, abortController, ...value } = task + return { ...value, cancelRequested: cancelRequested === true } +} + +function cleanName(path) { + const name = basename(path) + if (name === '' || name === '.' || name === '..') throw new FileManagerError('destination name is invalid') + return name +} + +function renamedPath(path, index) { + const dot = basename(path).lastIndexOf('.') + const directory = dirname(path) + const name = basename(path) + const stem = dot > 0 ? name.slice(0, dot) : name + const extension = dot > 0 ? name.slice(dot) : '' + return join(directory, `${stem} (${String(index)})${extension}`) +} + +async function uniqueDestination(path, conflict) { + if (!await exists(path)) return path + if (conflict === 'reject') throw new FileManagerError('destination already exists', 409, 'FILE_EXISTS') + if (conflict === 'overwrite') return path + for (let index = 1; index < 10_000; index += 1) { + const candidate = renamedPath(path, index) + if (!await exists(candidate)) return candidate + } + throw new FileManagerError('could not choose a unique destination', 409, 'FILE_EXISTS') +} + +async function copyEntry(source, staging) { + const sourceStats = await lstat(source) + if (sourceStats.isDirectory()) { + await cp(source, staging, { recursive: true, dereference: false, errorOnExist: true, force: false, preserveTimestamps: true }) + } else if (sourceStats.isSymbolicLink()) { + const target = await readlink(source) + await import('node:fs/promises').then(fs => fs.symlink(target, staging)) + } else if (sourceStats.isFile()) { + await cp(source, staging, { dereference: false, errorOnExist: true, force: false, preserveTimestamps: true }) + } else throw new FileManagerError('special files cannot be copied', 415, 'FILE_TYPE_UNSUPPORTED') +} + +async function treeMetrics(path) { + const value = await lstat(path) + if (!value.isDirectory() || value.isSymbolicLink()) return { entries: 1, bytes: value.isFile() ? value.size : 0 } + let entries = 1 + let bytes = 0 + for (const name of await readdir(path)) { + const child = await treeMetrics(join(path, name)) + entries += child.entries + bytes += child.bytes + } + return { entries, bytes } +} + +function normalizeAttributes(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).some(key => !['user', 'group', 'mode', 'recursive'].includes(key)) + || typeof value.user !== 'string' || typeof value.group !== 'string' + || typeof value.mode !== 'string' || !/^[0-7]{3,4}$/u.test(value.mode) + || typeof value.recursive !== 'boolean') throw new FileManagerError('file attributes are invalid') + return { user: value.user, group: value.group, mode: value.mode, recursive: value.recursive } +} + +function assertAttributesApplied(path, details, { uid, gid, mode }, symbolicLink) { + const ownershipMatches = details.uid === uid && details.gid === gid + const modeMatches = symbolicLink || (details.mode & 0o7777) === mode + if (ownershipMatches && modeMatches) return + throw new FileManagerError( + `filesystem did not apply Unix ownership or mode for ${path}; use a filesystem mounted with Unix metadata support`, + 403, + 'FILE_ATTRIBUTES_UNSUPPORTED', + ) +} + +export class FileTaskManager { + constructor({ + root, inventory = new FileInventory(), isManaged = isManagedPath, + protectedRoots = DEFAULT_PROTECTED_DELETE_ROOTS, + onState = () => {}, platformBusy = () => false, report = async () => {}, identity, + } = {}) { + if (typeof root !== 'string') throw new Error('file task root is required') + this.root = root + this.inventory = inventory + this.isManaged = isManaged + this.protectedRoots = new Set(protectedRoots.map(normalizeAbsolutePath)) + this.onState = onState + this.platformBusy = platformBusy + this.report = report + this.identity = identity ?? inventory.identity ?? new UnixIdentityResolver() + this.tasks = new Map() + this.activeMutation = undefined + this.queue = [] + this.queueTail = Promise.resolve() + this.search = new FileSearchManager({ inventory, onState: state => onState(state) }) + this.sizes = new FileSizeManager({ inventory, onState: state => onState(state) }) + } + + get hasManagedMutation() { return this.queue.some(task => ['queued', 'running'].includes(task.status) && task.managed === true) } + + wouldManage(input) { + if (['search', 'size'].includes(input?.operation)) return false + const sourceManaged = Array.isArray(input?.sources) && input.sources.some(source => typeof source?.path === 'string' && this.isManaged(source.path)) + return sourceManaged || (typeof input?.destination === 'string' && this.isManaged(input.destination)) + } + + async initialize() { + await mkdir(this.root, { recursive: true }) + const files = (await readdir(this.root)).filter(name => name.endsWith('.json')).sort() + for (const name of files) { + try { + const task = JSON.parse(await readFile(join(this.root, name), 'utf8')) + if (task.schema !== 1 || typeof task.taskId !== 'string') continue + this.tasks.set(task.taskId, task) + if (task.status === 'running') await this.#recover(task) + else if (task.status === 'queued' && ['upload', 'download'].includes(task.operation)) { + task.status = 'running' + await this.#recover(task) + } else if (task.status === 'queued') this.#schedule(task) + } catch (error) { + this.onState({ operation: 'recovery', status: 'failed', error: error instanceof Error ? error.message : String(error) }) + } + } + await this.#prune() + } + + start(input) { + if (input?.operation === 'search') return this.search.start(input) + if (input?.operation === 'size') return this.sizes.start(input) + if (input === null || typeof input !== 'object' || !OPERATIONS.has(input.operation)) throw new FileManagerError('file task operation is invalid') + const sources = Array.isArray(input.sources) ? input.sources.map(source => ({ path: normalizeAbsolutePath(source.path), revision: source.revision })) : [] + const destination = input.destination === undefined ? null : normalizeAbsolutePath(input.destination) + const conflict = input.conflict ?? 'reject' + if (!['reject', 'overwrite', 'rename'].includes(conflict)) throw new FileManagerError('file task conflict mode is invalid') + if (['copy', 'move', 'delete', 'rename', 'archive', 'extract'].includes(input.operation) && sources.length === 0) throw new FileManagerError('file task sources are required') + if (input.operation === 'extract' && sources.length !== 1) throw new FileManagerError('archive extraction requires one source') + if (input.operation === 'attributes' && sources.length !== 1) throw new FileManagerError('attribute changes require one source') + if (['mkdir', 'touch', 'copy', 'move', 'rename', 'archive', 'extract'].includes(input.operation) && destination === null) throw new FileManagerError('file task destination is required') + if (input.operation === 'delete' && sources.some(source => this.protectedRoots.has(source.path))) throw new FileManagerError('protected root cannot be deleted', 403, 'PROTECTED_ROOT') + const managed = sources.some(source => this.isManaged(source.path)) || (destination !== null && this.isManaged(destination)) + if (managed && this.platformBusy()) throw new FileManagerError('a platform operation is already running', 409, 'FILE_TASK_CONFLICT') + const task = { + schema: 1, taskId: randomUUID(), operation: input.operation, + status: this.queue.length === 0 ? 'running' : 'queued', phase: this.queue.length === 0 ? 'validating' : 'queued', + sources, destination, destinationRevision: input.destinationRevision ?? null, conflict, managed, + attributes: input.operation === 'attributes' ? normalizeAttributes(input.attributes) : null, + archiveFormat: ['archive', 'extract'].includes(input.operation) ? normalizeArchiveFormat(input.archiveFormat) : null, + processedBytes: 0, totalBytes: 0, processedEntries: 0, totalEntries: 0, currentPath: null, + published: [], staging: [], hidden: [], currentSource: null, currentDestination: null, + error: null, errorCode: null, queuePosition: null, createdAt: now(), updatedAt: now(), cancelRequested: false, + } + this.tasks.set(task.taskId, task) + this.#schedule(task) + return publicTask(task) + } + + startTransfer({ operation, path, totalBytes = null, managed = false }, executor) { + if (!['upload', 'download'].includes(operation) || typeof executor !== 'function') { + throw new FileManagerError('file transfer task is invalid') + } + const normalizedPath = normalizeAbsolutePath(path) + if (managed && this.platformBusy()) throw new FileManagerError('a platform operation is already running', 409, 'FILE_TASK_CONFLICT') + let resolveResult + let rejectResult + const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject }) + const task = { + schema: 1, taskId: randomUUID(), operation, + status: this.queue.length === 0 ? 'running' : 'queued', phase: this.queue.length === 0 ? 'transferring' : 'queued', + path: normalizedPath, sources: [], destination: operation === 'upload' ? normalizedPath : null, + destinationRevision: null, conflict: null, managed, attributes: null, archiveFormat: null, + processedBytes: 0, totalBytes, processedEntries: 0, totalEntries: 1, currentPath: normalizedPath, + published: [], staging: [], hidden: [], currentSource: null, currentDestination: null, + error: null, errorCode: null, queuePosition: null, createdAt: now(), updatedAt: now(), cancelRequested: false, + executor, resolveResult, rejectResult, + } + this.tasks.set(task.taskId, task) + this.#schedule(task) + return { task: publicTask(task), result } + } + + list() { + return [...this.search.list(), ...this.sizes.list(), ...[...this.tasks.values()].map(publicTask)].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 100) + } + + get(taskId, options = {}) { + if (this.tasks.has(taskId)) return publicTask(this.tasks.get(taskId)) + try { return this.search.get(taskId, options) } catch (error) { + if (error?.statusCode !== 404) throw error + return this.sizes.get(taskId) + } + } + + completion(taskId) { + const task = this.tasks.get(taskId) ?? this.search.tasks.get(taskId) ?? this.sizes.tasks.get(taskId) + if (task === undefined) throw new FileManagerError('file task was not found', 404, 'FILE_TASK_NOT_FOUND') + return Promise.resolve(task.completion).then(() => this.get(taskId)) + } + + cancel(taskId) { + const task = this.tasks.get(taskId) + if (task === undefined) { + try { return this.search.cancel(taskId) } catch (error) { + if (error?.statusCode !== 404) throw error + return this.sizes.cancel(taskId) + } + } + if (!['queued', 'running'].includes(task.status)) throw new FileManagerError('file task is already complete', 409, 'FILE_TASK_COMPLETE') + if (task.status === 'queued') { + task.cancelRequested = true + task.status = 'cancelled' + task.phase = 'cancelled' + task.updatedAt = now() + this.queue = this.queue.filter(value => value !== task) + void Promise.resolve(task.persisted).then(() => this.#terminal(task, 'cancelled', 'cancelled')) + task.rejectResult?.(Object.assign(new Error('file transfer cancelled'), { code: 'FILE_TASK_CANCELLED' })) + this.#publishQueue() + return publicTask(task) + } + if (['destination-committed', 'source-hidden', 'cleaning'].includes(task.phase)) throw new FileManagerError('file task passed its cancellation boundary', 409, 'FILE_TASK_COMMITTED') + task.cancelRequested = true + task.abortController?.abort() + this.#publish(task) + return publicTask(task) + } + + #schedule(task) { + this.queue.push(task) + this.#publishQueue() + const previous = this.queueTail + task.persisted = this.#persist(task) + task.completion = Promise.all([previous.catch(() => {}), task.persisted]).then(async () => { + if (task.cancelRequested || task.status === 'cancelled') return + task.status = 'running' + task.phase = 'validating' + task.queuePosition = 0 + this.activeMutation = task + await this.#phase(task, task.executor === undefined ? 'validating' : 'transferring') + if (task.executor === undefined) await this.#run(task).catch(() => {}) + else await this.#runTransfer(task) + }).finally(() => { + if (this.activeMutation === task) this.activeMutation = undefined + this.queue = this.queue.filter(value => value !== task) + this.#publishQueue() + }) + this.queueTail = task.completion + } + + async #runTransfer(task) { + const controller = new AbortController() + task.abortController = controller + const controls = { + signal: controller.signal, + progress: ({ processedBytes, totalBytes, currentPath } = {}) => { + if (Number.isSafeInteger(processedBytes) && processedBytes >= 0) task.processedBytes = processedBytes + if (Number.isSafeInteger(totalBytes) && totalBytes >= 0) task.totalBytes = totalBytes + if (typeof currentPath === 'string') task.currentPath = currentPath + task.updatedAt = now() + this.#publish(task) + }, + } + try { + const result = await task.executor(controls) + task.processedEntries = 1 + if (task.totalBytes !== null) task.processedBytes = task.totalBytes + await this.#terminal(task, 'success', 'completed') + await this.report(`file-task.${task.operation}.completed`, publicTask(task)) + task.resolveResult(result) + } catch (error) { + const status = task.cancelRequested ? 'cancelled' : 'failed' + task.error = error instanceof Error ? error.message : String(error) + task.errorCode = typeof error?.code === 'string' ? error.code : null + await this.#terminal(task, status, status) + await this.report(`file-task.${task.operation}.${status}`, { ...publicTask(task), error }) + task.rejectResult(error) + } finally { + task.abortController = undefined + await this.#prune() + } + } + + #publishQueue() { + let position = 0 + for (const task of this.queue) { + if (task.status === 'queued') position += 1 + task.queuePosition = task.status === 'queued' ? position : 0 + this.#publish(task) + } + } + + async #validate(task) { + for (const source of task.sources) { + const item = await this.inventory.stat(source.path) + if (source.revision !== item.revision) throw new FileRevisionConflictError(`source changed: ${source.path}`) + if (task.operation === 'attributes' && !['file', 'directory'].includes(item.type)) { + throw new FileManagerError('attributes can be changed only for files and directories', 415, 'FILE_TYPE_UNSUPPORTED') + } + const metrics = task.operation === 'attributes' && task.attributes.recursive === false + ? { entries: 1, bytes: item.type === 'file' ? item.size : 0 } + : await treeMetrics(source.path) + task.totalEntries += metrics.entries + task.totalBytes += metrics.bytes + } + if (task.destinationRevision !== null) { + const destinationRoot = task.operation === 'rename' || task.operation === 'mkdir' || task.operation === 'touch' + ? dirname(task.destination) : task.destination + const listed = await this.inventory.list(destinationRoot, { limit: 1 }) + if (listed.revision !== task.destinationRevision) throw new FileRevisionConflictError('destination directory changed') + } + } + + async #run(task) { + try { + await this.#validate(task) + await this.#phase(task, 'mutating') + if (task.operation === 'mkdir') await mkdir(task.destination) + else if (task.operation === 'touch') { + const handle = await open(task.destination, 'wx', 0o644) + await handle.sync(); await handle.close(); await syncDirectory(dirname(task.destination)) + } else if (task.operation === 'rename') await this.#rename(task) + else if (task.operation === 'copy') await this.#copy(task, false) + else if (task.operation === 'move') await this.#copy(task, true) + else if (task.operation === 'delete') await this.#delete(task) + else if (task.operation === 'attributes') await this.#attributes(task) + else if (task.operation === 'archive') await this.#archive(task) + else if (task.operation === 'extract') await this.#extract(task) + await this.#terminal(task, 'success', 'completed') + await this.report(`file-task.${task.operation}.completed`, publicTask(task)) + } catch (error) { + const status = task.cancelRequested ? 'cancelled' : 'failed' + task.error = error instanceof Error ? error.message : String(error) + task.errorCode = typeof error?.code === 'string' ? error.code : null + await this.#cleanupStaging(task) + await this.#terminal(task, status, status) + await this.report(`file-task.${task.operation}.${status}`, { ...publicTask(task), error }) + throw error + } finally { + await this.#prune() + } + } + + async #rename(task) { + if (task.sources.length !== 1) throw new FileManagerError('rename requires one source') + const source = task.sources[0].path + if (dirname(source) !== dirname(task.destination)) throw new FileManagerError('rename destination must use the same directory') + const destination = await uniqueDestination(task.destination, task.conflict) + task.currentSource = source + task.currentDestination = destination + await this.#phase(task, 'move-prepared') + if (task.conflict === 'overwrite' && await exists(destination)) await rm(destination, { recursive: true }) + await rename(source, destination) + task.published.push(destination) + await this.#phase(task, 'destination-committed') + await syncDirectory(dirname(destination)) + task.currentSource = null + task.currentDestination = null + await this.#phase(task, 'mutating') + } + + async #copy(task, move) { + for (const source of task.sources) { + if (task.cancelRequested) throw new Error('file task cancelled') + const destination = await uniqueDestination(join(task.destination, cleanName(source.path)), task.conflict) + if (source.path === destination || destination.startsWith(`${source.path}/`)) throw new FileManagerError('directory cannot be copied into itself') + if (move) { + task.currentSource = source.path + task.currentDestination = destination + await this.#phase(task, 'move-prepared') + try { + if (task.conflict === 'overwrite' && await exists(destination)) await rm(destination, { recursive: true }) + await rename(source.path, destination) + task.published.push(destination) + await this.#phase(task, 'destination-committed') + task.processedEntries += 1 + task.currentSource = null + task.currentDestination = null + await this.#phase(task, 'mutating') + continue + } catch (error) { + if (error?.code !== 'EXDEV') throw error + } + } + const staging = join(dirname(destination), `.dsh-file-task-${task.taskId}-${randomUUID()}.tmp`) + task.staging.push(staging) + task.currentPath = source.path + task.currentSource = source.path + task.currentDestination = destination + await this.#persist(task) + await copyEntry(source.path, staging) + if (task.cancelRequested) throw new Error('file task cancelled') + if (task.conflict === 'overwrite' && await exists(destination)) await rm(destination, { recursive: true }) + await rename(staging, destination) + task.staging = task.staging.filter(path => path !== staging) + task.published.push(destination) + await this.#phase(task, move ? 'destination-committed' : 'mutating') + if (move) await rm(source.path, { recursive: true }) + task.processedEntries += 1 + task.processedBytes = Math.min(task.totalBytes, task.processedBytes + (await treeMetrics(destination)).bytes) + task.currentSource = null + task.currentDestination = null + await this.#phase(task, 'mutating') + } + } + + async #delete(task) { + for (const source of task.sources) { + if (task.cancelRequested) throw new Error('file task cancelled') + const hidden = join(dirname(source.path), `.dsh-delete-${task.taskId}-${randomUUID()}.tmp`) + await rename(source.path, hidden) + task.hidden.push(hidden) + await this.#phase(task, 'source-hidden') + const removed = await treeMetrics(hidden) + await rm(hidden, { recursive: true }) + task.hidden = task.hidden.filter(path => path !== hidden) + task.processedEntries += removed.entries + task.processedBytes = Math.min(task.totalBytes, task.processedBytes + removed.bytes) + await this.#phase(task, 'cleaning') + } + } + + async #attributes(task) { + const [uid, gid] = await Promise.all([ + this.identity.userId(task.attributes.user), + this.identity.groupId(task.attributes.group), + ]) + const mode = Number.parseInt(task.attributes.mode, 8) + const pending = [task.sources[0].path] + task.resolvedAttributes = { uid, gid, mode } + while (pending.length > 0) { + if (task.cancelRequested) throw new Error('file task cancelled') + const path = pending.pop() + const details = await lstat(path) + if (task.attributes.recursive && details.isDirectory() && !details.isSymbolicLink()) { + for (const name of await readdir(path)) pending.push(join(path, name)) + } + await lchown(path, uid, gid) + if (!details.isSymbolicLink()) await chmod(path, mode) + assertAttributesApplied(path, await lstat(path), { uid, gid, mode }, details.isSymbolicLink()) + task.currentPath = path + task.processedEntries += 1 + if (details.isFile()) task.processedBytes += details.size + if (task.processedEntries % 100 === 0) await this.#phase(task, 'mutating') + } + task.currentPath = null + } + + async #archive(task) { + const output = await uniqueDestination(task.destination, task.conflict) + const sourceRoot = join(dirname(output), `.dsh-archive-input-${task.taskId}`) + const staging = join(dirname(output), `.dsh-archive-${task.taskId}.tmp`) + task.staging.push(sourceRoot, staging) + await mkdir(sourceRoot) + const names = new Set() + for (const source of task.sources) { + const name = cleanName(source.path) + if (names.has(name)) throw new FileManagerError('archive source names must be unique', 409, 'FILE_EXISTS') + names.add(name) + task.currentPath = source.path + await copyEntry(source.path, join(sourceRoot, name)) + task.processedEntries += 1 + await this.#phase(task, 'staging') + } + if (task.cancelRequested) throw new Error('file task cancelled') + const controller = new AbortController() + task.abortController = controller + try { + await createArchive({ format: task.archiveFormat, sourceRoot, output: staging, signal: controller.signal }) + } finally { + task.abortController = undefined + } + if (task.cancelRequested) throw new Error('file task cancelled') + if (task.conflict === 'overwrite' && await exists(output)) await rm(output, { recursive: true }) + await rename(staging, output) + task.staging = task.staging.filter(path => path !== staging) + task.published.push(output) + task.processedBytes = task.totalBytes + await rm(sourceRoot, { recursive: true, force: true }) + task.staging = task.staging.filter(path => path !== sourceRoot) + task.currentPath = null + await this.#phase(task, 'destination-committed') + } + + async #extract(task) { + const archive = task.sources[0].path + const staging = join(task.destination, `.dsh-extract-${task.taskId}.tmp`) + task.staging.push(staging) + task.currentPath = archive + await this.#persist(task) + const controller = new AbortController() + task.abortController = controller + let metrics + try { + metrics = await extractArchive({ format: task.archiveFormat, archive, output: staging, signal: controller.signal }) + } finally { + task.abortController = undefined + } + task.totalEntries = metrics.entries + task.totalBytes = metrics.bytes + await this.#publishExtraction(task, staging) + } + + async #publishExtraction(task, staging) { + const plan = [] + for (const name of await readdir(staging)) { + plan.push({ source: join(staging, name), destination: await uniqueDestination(join(task.destination, name), task.conflict) }) + } + for (const { source, destination } of plan) { + if (task.cancelRequested) throw new Error('file task cancelled') + if (task.conflict === 'overwrite' && await exists(destination)) await rm(destination, { recursive: true }) + await rename(source, destination) + task.published.push(destination) + const item = await treeMetrics(destination) + task.processedEntries += item.entries + task.processedBytes += item.bytes + await this.#phase(task, 'publishing') + } + await rm(staging, { recursive: true, force: true }) + task.staging = task.staging.filter(path => path !== staging) + task.currentPath = null + } + + async #cleanupStaging(task) { + for (const path of task.staging) await rm(path, { recursive: true, force: true }).catch(() => {}) + task.staging = [] + } + + async #recover(task) { + if (task.operation === 'attributes') { + try { + task.cancelRequested = false + task.processedEntries = 0 + task.processedBytes = 0 + await this.#phase(task, 'mutating') + await this.#attributes(task) + task.status = 'success' + await this.#phase(task, 'completed') + await this.report('file-task.attributes.recovered', publicTask(task)) + } catch (error) { + task.status = 'failed' + task.error = error instanceof Error ? error.message : String(error) + task.errorCode = typeof error?.code === 'string' ? error.code : null + await this.#phase(task, 'failed') + await this.report('file-task.attributes.recovery-failed', { ...publicTask(task), error }) + } + } else if (task.operation === 'archive' && task.phase === 'destination-committed') { + task.status = 'success' + await this.#phase(task, 'completed') + } else if (task.operation === 'extract' && task.phase === 'publishing' && task.staging?.length === 1 && await exists(task.staging[0])) { + try { + task.cancelRequested = false + await this.#publishExtraction(task, task.staging[0]) + task.status = 'success' + await this.#phase(task, 'completed') + await this.report('file-task.extract.recovered', publicTask(task)) + } catch (error) { + task.status = 'failed' + task.error = error instanceof Error ? error.message : String(error) + task.errorCode = typeof error?.code === 'string' ? error.code : null + await this.#phase(task, 'failed') + await this.report('file-task.extract.recovery-failed', { ...publicTask(task), error }) + } + } else if (task.operation === 'delete' || task.phase === 'source-hidden' || task.phase === 'cleaning') { + for (const path of task.hidden ?? []) await rm(path, { recursive: true, force: true }) + task.hidden = [] + task.status = 'success' + await this.#phase(task, 'completed') + } else if (['move', 'rename'].includes(task.operation) && task.phase === 'destination-committed') { + if (task.currentSource !== null && await exists(task.currentSource)) await rm(task.currentSource, { recursive: true }) + task.status = 'success' + await this.#phase(task, 'completed') + } else if (['move', 'rename'].includes(task.operation) && task.phase === 'move-prepared' + && task.currentSource !== null && task.currentDestination !== null + && !await exists(task.currentSource) && await exists(task.currentDestination)) { + task.published ??= [] + if (!task.published.includes(task.currentDestination)) task.published.push(task.currentDestination) + task.status = 'success' + await this.#phase(task, 'completed') + } else { + await this.#cleanupStaging(task) + task.status = 'interrupted' + task.error = 'file operation was interrupted before a recoverable commit' + await this.#phase(task, 'interrupted') + } + } + + async #phase(task, phase) { + task.phase = phase + task.updatedAt = now() + await this.#persist(task) + this.#publish(task) + } + + async #terminal(task, status, phase) { + task.status = status + task.phase = phase + task.updatedAt = now() + await this.#persist(task) + if (this.activeMutation === task) this.activeMutation = undefined + this.queue = this.queue.filter(value => value !== task) + this.#publishQueue() + this.#publish(task) + } + + async #persist(task) { + await mkdir(this.root, { recursive: true }) + const value = publicTask(task) + await durableReplace(join(this.root, `${task.taskId}.json`), Buffer.from(`${JSON.stringify(value)}\n`)) + } + + #publish(task) { this.onState(publicTask(task)) } + + async #prune() { + const completed = [...this.tasks.values()].filter(task => !['queued', 'running'].includes(task.status)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + for (const task of completed.slice(100)) { + this.tasks.delete(task.taskId) + await rm(join(this.root, `${task.taskId}.json`), { force: true }) + } + } +} + +export const fileTaskInternals = Object.freeze({ protectedDeleteRoots: DEFAULT_PROTECTED_DELETE_ROOTS, uniqueDestination, treeMetrics, normalizeAttributes, assertAttributesApplied }) diff --git a/upstream/container/control-plane/modules/file-manager/transfers.mjs b/upstream/container/control-plane/modules/file-manager/transfers.mjs new file mode 100644 index 0000000..bd444a8 --- /dev/null +++ b/upstream/container/control-plane/modules/file-manager/transfers.mjs @@ -0,0 +1,205 @@ +import { createReadStream } from 'node:fs' +import { link, lstat, mkdir, mkdtemp, open, readlink, rename, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, extname, join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { Readable } from 'node:stream' +import { randomUUID } from 'node:crypto' +import { + FileManagerError, FileRevisionConflictError, fileError, fileManagerInternals, + isManagedPath, normalizeAbsolutePath, +} from './index.mjs' +import { createArchive } from './archives.mjs' + +function parseRange(value, size) { + if (value === undefined) return { start: 0, end: Math.max(0, size - 1), partial: false } + const match = /^bytes=(\d*)-(\d*)$/.exec(value) + if (match === null || (match[1] === '' && match[2] === '')) throw new FileManagerError('range is invalid', 416, 'RANGE_INVALID') + let start + let end + if (match[1] === '') { + const suffix = Number(match[2]) + if (!Number.isSafeInteger(suffix) || suffix < 1) throw new FileManagerError('range is invalid', 416, 'RANGE_INVALID') + start = Math.max(0, size - suffix) + end = size - 1 + } else { + start = Number(match[1]) + end = match[2] === '' ? size - 1 : Number(match[2]) + } + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= size || end < start) { + throw new FileManagerError('range is not satisfiable', 416, 'RANGE_INVALID') + } + return { start, end: Math.min(end, size - 1), partial: true } +} + +function disposition(name) { + const fallback = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_') || 'download' + return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}` +} + +async function syncDirectory(path) { + const handle = await open(path, 'r') + try { await handle.sync() } finally { await handle.close() } +} + +async function existingMetadata(path, { requireFile = false } = {}) { + try { + const value = await stat(path, { bigint: true }) + if (requireFile && !value.isFile()) throw new FileManagerError('upload target is not a regular file', 415, 'FILE_TYPE_UNSUPPORTED') + return value + } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } +} + +function renamedPath(path, index) { + const extension = extname(path) + const stem = extension === '' ? path : path.slice(0, -extension.length) + return `${stem} (${String(index)})${extension}` +} + +export class FileTransferManager { + constructor({ isManaged = isManagedPath, stagingRoot = tmpdir() } = {}) { + this.isManaged = isManaged + this.stagingRoot = stagingRoot + } + + async openDownload(value, { revision, range, signal } = {}) { + const path = normalizeAbsolutePath(value) + let handle + let cleanup + try { + const pathStats = await lstat(path, { bigint: true }) + if (pathStats.isDirectory()) { + if (range !== undefined) throw new FileManagerError('directory downloads do not support ranges', 416, 'RANGE_INVALID') + const actualRevision = fileManagerInternals.revisionFor(path, pathStats) + if (revision !== undefined && revision !== '' && revision !== actualRevision) throw new FileRevisionConflictError('download revision changed') + await mkdir(this.stagingRoot, { recursive: true }) + const staging = await mkdtemp(join(this.stagingRoot, '.dsh-directory-download-')) + cleanup = () => rm(staging, { recursive: true, force: true }) + const name = basename(path) || 'root' + const archive = join(staging, `${name}.zip`) + await createArchive({ format: 'zip', sourceRoot: path === '/' ? path : dirname(path), output: archive, entries: [path === '/' ? '.' : name], signal }) + const download = await this.openDownload(archive, { signal }) + download.path = path + download.revision = actualRevision + download.headers['content-disposition'] = disposition(`${name}.zip`) + download.cleanup = cleanup + cleanup = undefined + return download + } + handle = await open(path, 'r') + const before = await handle.stat({ bigint: true }) + if (!before.isFile()) throw new FileManagerError('path is not a regular file', 415, 'FILE_TYPE_UNSUPPORTED') + const actualRevision = fileManagerInternals.revisionFor(path, before, await readlink(path).catch(() => '')) + if (revision !== undefined && revision !== '' && revision !== actualRevision) throw new FileRevisionConflictError('download revision changed') + const size = Number(before.size) + if (!Number.isSafeInteger(size)) throw new FileManagerError('file is too large', 413, 'FILE_TOO_LARGE') + if (size === 0 && range !== undefined) throw new FileManagerError('range is not satisfiable', 416, 'RANGE_INVALID') + const selected = size === 0 ? { start: 0, end: -1, partial: false } : parseRange(range, size) + const length = selected.end < selected.start ? 0 : selected.end - selected.start + 1 + const stream = length === 0 ? Readable.from([]) : createReadStream(path, { + fd: handle.fd, autoClose: false, start: selected.start, end: selected.end, + }) + return { + path, handle, stream, revision: actualRevision, + status: selected.partial ? 206 : 200, + headers: { + 'accept-ranges': 'bytes', + 'cache-control': 'no-store', + 'content-disposition': disposition(basename(path)), + 'content-length': String(length), + 'content-type': 'application/octet-stream', + etag: `"${actualRevision}"`, + ...(selected.partial ? { 'content-range': `bytes ${String(selected.start)}-${String(selected.end)}/${String(size)}` } : {}), + }, + } + } catch (error) { + await handle?.close().catch(() => {}) + await cleanup?.().catch(() => {}) + throw fileError(error) + } + } + + async sendDownload(response, download, { onProgress = () => {}, signal } = {}) { + response.writeHead(download.status, download.headers) + let sent = 0 + download.stream.on('data', chunk => { + sent += chunk.byteLength + onProgress(sent, Number(download.headers['content-length'])) + }) + try { + await pipeline(download.stream, response, { signal }) + } finally { + await download.handle.close().catch(() => {}) + await download.cleanup?.().catch(() => {}) + } + } + + async upload(input, value, { conflict = 'reject', contentLength, onProgress = () => {}, signal } = {}) { + const requestedPath = normalizeAbsolutePath(value) + if (!['reject', 'overwrite', 'rename'].includes(conflict)) throw new FileManagerError('upload conflict mode is invalid') + const parent = dirname(requestedPath) + const parentStats = await stat(parent).catch(error => { throw fileError(error) }) + if (!parentStats.isDirectory()) throw new FileManagerError('upload parent is not a directory', 415, 'FILE_TYPE_UNSUPPORTED') + const staging = join(parent, `.dsh-upload-${randomUUID()}.tmp`) + let handle + let received = 0 + try { + const original = await existingMetadata(requestedPath, { requireFile: conflict === 'overwrite' }) + if (original !== undefined && conflict === 'reject') throw new FileManagerError('upload target already exists', 409, 'FILE_EXISTS') + handle = await open(staging, 'wx', 0o600) + for await (const chunk of input) { + signal?.throwIfAborted() + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + received += bytes.byteLength + await handle.write(bytes) + onProgress(received, contentLength) + } + if (contentLength !== undefined && received !== contentLength) throw new FileManagerError('upload size does not match Content-Length') + await handle.sync() + if (original === undefined) await handle.chmod(0o644) + else { + await handle.chmod(Number(original.mode & 0o7777n)) + const current = await handle.stat({ bigint: true }) + if (current.uid !== original.uid || current.gid !== original.gid) await handle.chown(Number(original.uid), Number(original.gid)) + } + await handle.close() + handle = undefined + let path = requestedPath + if (conflict === 'overwrite') await rename(staging, path) + else if (conflict === 'reject') { + await link(staging, path) + await rm(staging) + } else { + let index = 0 + for (;;) { + path = index === 0 ? requestedPath : renamedPath(requestedPath, index) + try { + await link(staging, path) + await rm(staging) + break + } catch (error) { + if (error?.code !== 'EEXIST') throw error + index += 1 + } + } + } + await syncDirectory(parent) + const result = await stat(path, { bigint: true }) + return { + path, + size: received, + revision: fileManagerInternals.revisionFor(path, result), + managed: this.isManaged(path), + } + } catch (error) { + await handle?.close().catch(() => {}) + await rm(staging, { force: true }).catch(() => {}) + throw fileError(error) + } + } +} + +export const fileTransferInternals = Object.freeze({ parseRange, disposition, renamedPath }) diff --git a/upstream/container/control-plane/modules/log-manager/index.mjs b/upstream/container/control-plane/modules/log-manager/index.mjs new file mode 100644 index 0000000..5b6375d --- /dev/null +++ b/upstream/container/control-plane/modules/log-manager/index.mjs @@ -0,0 +1,473 @@ +import { EventEmitter } from 'node:events' +import { randomUUID } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { appendFile, chmod, chown, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' + +const SOURCE_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/ +const MAX_ENTRY_BYTES = 64 * 1024 +const CONSOLE_MARKER = 'dsh-platform-log-v1' +const LOG_LEVELS = new Set(['debug', 'info', 'warning', 'error']) +const MAX_DIAGNOSTIC_STRING = 16 * 1024 +const MAX_ERROR_DEPTH = 3 +const MAX_AGGREGATE_ERRORS = 8 +const SUPPRESSION_KEY_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,255}$/ + +function boundedString(value) { + const text = String(value) + return text.length <= MAX_DIAGNOSTIC_STRING ? text : `${text.slice(0, MAX_DIAGNOSTIC_STRING)}...[truncated]` +} + +export function errorDetails(error, depth = 0) { + if (error === undefined || error === null) return {} + if (!(error instanceof Error)) return { error: boundedString(error), errorType: typeof error } + const details = { + error: boundedString(error.message || error.name), + errorName: boundedString(error.name), + } + if (typeof error.code === 'string' || typeof error.code === 'number') details.errorCode = error.code + if (typeof error.stack === 'string') details.errorStack = boundedString(error.stack) + if (depth >= MAX_ERROR_DEPTH) return details + if (error.cause !== undefined) details.errorCause = errorDetails(error.cause, depth + 1) + if (error instanceof AggregateError) { + details.errors = [...error.errors].slice(0, MAX_AGGREGATE_ERRORS).map(value => errorDetails(value, depth + 1)) + if (error.errors.length > MAX_AGGREGATE_ERRORS) details.errorsTruncated = error.errors.length - MAX_AGGREGATE_ERRORS + } + return details +} + +function validateSource(source) { + if (typeof source !== 'string' || !SOURCE_PATTERN.test(source)) throw new Error('log source is invalid') + return source +} + +function validateFilter(value, label) { + if (value === undefined) return undefined + if (typeof value !== 'string' || value.length === 0 || value.length > 128 || /[\0\r\n]/u.test(value)) { + throw new Error(`${label} filter is invalid`) + } + return value +} + +function matches(entry, filters) { + return (filters.taskId === undefined || entry.taskId === filters.taskId) + && (filters.operation === undefined || entry.operation === filters.operation) + && (filters.phase === undefined || entry.phase === filters.phase) +} + +function parseLine(line) { + try { return JSON.parse(line) } catch { return undefined } +} + +function parseForwardedLine(line) { + const entry = parseLine(line) + if ( + entry?.platformLog !== CONSOLE_MARKER + || typeof entry.timestamp !== 'string' + || typeof entry.message !== 'string' + || !['stdout', 'stderr', 'audit', 'platform'].includes(entry.stream) + ) return undefined + try { validateSource(entry.source) } catch { return undefined } + return entry +} + +export function capturedLogLevel(stream, message) { + if (stream !== 'stderr') return 'info' + const text = String(message).trim() + const lines = text.split('\n') + if (lines.some(line => /^(?:\s*at\s+|.*\b(?:error|fatal|failed|failure|exception|panic|unhandled)\b)|错误|失败|异常|致命/iu.test(line))) { + return 'error' + } + if (lines.some(line => /\b(?:warn|warning|deprecated|deprecation)\b|警告|已弃用/iu.test(line))) return 'warning' + return 'info' +} + +const NODE_ERROR_LINE = /^\s*(?:AggregateError|Error|EvalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError):/u +const NODE_LOCATION_LINE = /^(?:file:\/\/|\/).*:\d+(?::\d+)?$/u + +function isNodeErrorContinuation(line) { + return line === '' + || /^\s/u.test(line) + || NODE_ERROR_LINE.test(line) + || /^Node\.js v\d/u.test(line) + || /^\s*(?:\[cause\]|\[errors\]|Caused by:)/u.test(line) + || /^\s*[{}\[\]},]+$/u.test(line) +} + +function capturedMessages(source, stream, lines) { + if (source !== 'dsh-runtime' || stream !== 'stderr') return lines + const messages = [] + let cursor = 0 + while (cursor < lines.length) { + const relativeError = lines.slice(cursor).findIndex(line => NODE_ERROR_LINE.test(line)) + if (relativeError < 0) { + messages.push(...lines.slice(cursor)) + break + } + const errorIndex = cursor + relativeError + let start = errorIndex + for (let index = errorIndex - 1; index >= cursor && index >= errorIndex - 6; index -= 1) { + if (NODE_LOCATION_LINE.test(lines[index])) { + start = index + break + } + } + messages.push(...lines.slice(cursor, start)) + let end = errorIndex + 1 + while (end < lines.length && isNodeErrorContinuation(lines[end])) { + const terminal = /^Node\.js v\d/u.test(lines[end]) + end += 1 + if (terminal) break + } + messages.push(boundedString(lines.slice(start, end).join('\n'))) + cursor = end + } + return messages +} + +export class JsonlLogManager extends EventEmitter { + constructor({ + root, + maxBytes = 100 * 1024 * 1024, + retentionDays = 14, + rotateBytes = 10 * 1024 * 1024, + now = () => new Date(), + output, + fileMode = 0o660, + fileUid, + fileGid, + }) { + super() + if (!Number.isSafeInteger(maxBytes) || maxBytes < MAX_ENTRY_BYTES) throw new Error('log maxBytes is invalid') + if (!Number.isSafeInteger(retentionDays) || retentionDays < 1) throw new Error('log retentionDays is invalid') + if (!Number.isSafeInteger(rotateBytes) || rotateBytes < 1_024) throw new Error('log rotateBytes is invalid') + if (!Number.isSafeInteger(fileMode) || fileMode < 0 || fileMode > 0o777) throw new Error('log fileMode is invalid') + this.root = root + this.maxBytes = maxBytes + this.retentionMs = retentionDays * 86_400_000 + this.rotateBytes = rotateBytes + this.now = now + this.output = output + this.fileMode = fileMode + this.fileUid = fileUid + this.fileGid = fileGid + this.queue = Promise.resolve() + this.suppressedDiagnostics = new Map() + } + + prepare() { + return this.serialized(async () => { + await mkdir(this.root, { recursive: true }) + const names = (await readdir(this.root)).filter(name => name.endsWith('.jsonl')) + for (const name of names) { + const path = join(this.root, name) + if (this.fileGid !== undefined) await chown(path, -1, this.fileGid) + await chmod(path, this.fileMode) + } + }) + } + + writeOutput(stream, line) { + const destination = stream === 'stderr' ? this.output?.stderr : this.output?.stdout + destination?.write(`${line}\n`) + } + + mirror(entry) { + if (this.output === undefined) return + this.writeOutput(entry.stream, JSON.stringify({ ...entry, platformLog: CONSOLE_MARKER })) + } + + serialized(operation) { + const result = this.queue.then(operation, operation) + this.queue = result.then(() => undefined, () => undefined) + return result + } + + currentPath(source) { + return join(this.root, `${validateSource(source)}.jsonl`) + } + + append(source, stream, message, fields = {}) { + return this.serialized(async () => { + validateSource(source) + if (!['stdout', 'stderr', 'audit', 'platform'].includes(stream)) throw new Error('log stream is invalid') + if (typeof message !== 'string') throw new Error('log message must be a string') + const level = fields.level ?? (stream === 'stderr' ? 'error' : 'info') + if (!LOG_LEVELS.has(level)) throw new Error('log level is invalid') + const entry = { ...fields, timestamp: this.now().toISOString(), source, stream, level, message } + const line = Buffer.from(`${JSON.stringify(entry)}\n`) + if (line.byteLength > MAX_ENTRY_BYTES) throw new Error('log entry exceeds 64 KiB') + await mkdir(this.root, { recursive: true }) + const path = this.currentPath(source) + let exists = true + const size = await stat(path).then(value => value.size, error => { + if (error?.code !== 'ENOENT') throw error + exists = false + return 0 + }) + if (size > 0 && size + line.byteLength > this.rotateBytes) { + await rename(path, join(this.root, `${source}.${Date.now()}.${randomUUID()}.jsonl`)) + exists = false + } + await appendFile(path, line, { mode: this.fileMode }) + if (!exists) { + if (this.fileUid !== undefined || this.fileGid !== undefined) await chown(path, this.fileUid ?? -1, this.fileGid ?? -1) + await chmod(path, this.fileMode) + } + this.emit('entry', Object.freeze(entry)) + this.mirror(entry) + await this.pruneUnlocked() + return Object.freeze(entry) + }) + } + + audit(action, fields = {}) { + return this.append('audit', 'audit', action, fields) + } + + async diagnostic(source, message, { error, level = error === undefined || error === null ? 'info' : 'error', stream = 'platform', ...fields } = {}) { + const details = { ...fields, ...errorDetails(error), level } + try { + return await this.append(source, stream, message, details) + } catch (loggingError) { + try { + this.writeOutput('stderr', JSON.stringify({ + timestamp: this.now().toISOString(), + source: 'log-manager', + stream: 'stderr', + level: 'error', + message: 'diagnostic.write.failed', + diagnostic: { source, stream, message, ...details }, + loggingError: errorDetails(loggingError), + platformLog: CONSOLE_MARKER, + })) + } catch {} + return undefined + } + } + + diagnosticRateLimited(key, source, message, fields = {}, { intervalMs = 30_000 } = {}) { + if (typeof key !== 'string' || !SUPPRESSION_KEY_PATTERN.test(key)) { + throw new Error('diagnostic suppression key is invalid') + } + if (!Number.isSafeInteger(intervalMs) || intervalMs < 1_000 || intervalMs > 86_400_000) { + throw new Error('diagnostic suppression interval is invalid') + } + const timestamp = this.now().getTime() + const previous = this.suppressedDiagnostics.get(key) + if (previous !== undefined && timestamp - previous.lastReportedAt < intervalMs) { + previous.suppressedCount += 1 + return Promise.resolve(undefined) + } + const suppressedCount = previous?.suppressedCount ?? 0 + this.suppressedDiagnostics.set(key, { lastReportedAt: timestamp, suppressedCount: 0 }) + return this.diagnostic(source, message, { + ...fields, + ...(suppressedCount === 0 ? {} : { suppressedCount }), + }) + } + + async files() { + try { + const names = (await readdir(this.root)).filter(name => name.endsWith('.jsonl')) + return Promise.all(names.map(async name => ({ name, path: join(this.root, name), details: await stat(join(this.root, name)) }))) + } catch (error) { + if (error?.code === 'ENOENT') return [] + throw error + } + } + + async pruneUnlocked() { + const cutoff = this.now().getTime() - this.retentionMs + let files = await this.files() + for (const file of files) { + if (file.details.mtimeMs < cutoff) await rm(file.path, { force: true }) + } + files = (await this.files()).sort((left, right) => left.details.mtimeMs - right.details.mtimeMs) + let total = files.reduce((sum, file) => sum + file.details.size, 0) + for (const file of files) { + if (total <= this.maxBytes) break + await rm(file.path, { force: true }) + total -= file.details.size + } + } + + prune() { + return this.serialized(() => this.pruneUnlocked()) + } + + async query({ sources, since, taskId, operation, phase, limit = 200 } = {}) { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) throw new Error('log query limit is invalid') + const selected = sources === undefined ? undefined : new Set(sources.map(validateSource)) + const threshold = since === undefined ? undefined : new Date(since).toISOString() + const filters = { + taskId: validateFilter(taskId, 'taskId'), + operation: validateFilter(operation, 'operation'), + phase: validateFilter(phase, 'phase'), + } + const entries = [] + for (const file of await this.files()) { + const lines = (await readFile(file.path, 'utf8')).split('\n') + for (const line of lines) { + if (line === '') continue + const entry = parseLine(line) + if ( + entry !== undefined + && (selected === undefined || selected.has(entry.source)) + && (threshold === undefined || entry.timestamp >= threshold) + && matches(entry, filters) + ) entries.push(entry) + } + } + entries.sort((left, right) => left.timestamp.localeCompare(right.timestamp)) + return Object.freeze(entries.slice(-limit).map(Object.freeze)) + } + + async sources() { + const result = new Set() + for (const file of await this.files()) { + for (const line of (await readFile(file.path, 'utf8')).split('\n')) { + const entry = parseLine(line) + if (entry?.source !== undefined) { + try { result.add(validateSource(entry.source)) } catch {} + } + } + } + return [...result].sort() + } + + async follow({ sources, since, taskId, operation, phase } = {}, listener, { intervalMs = 250, onError = () => {} } = {}) { + if (typeof listener !== 'function') throw new Error('log follow listener is required') + if (!Number.isSafeInteger(intervalMs) || intervalMs < 50) throw new Error('log follow interval is invalid') + const selected = sources === undefined ? undefined : new Set(sources.map(validateSource)) + const threshold = since === undefined ? undefined : new Date(since).toISOString() + const filters = { + taskId: validateFilter(taskId, 'taskId'), + operation: validateFilter(operation, 'operation'), + phase: validateFilter(phase, 'phase'), + } + await mkdir(this.root, { recursive: true }) + let states = new Map() + for (const file of await this.files()) { + states.set(file.name, { identity: `${String(file.details.dev)}:${String(file.details.ino)}`, offset: file.details.size, pending: Buffer.alloc(0) }) + } + let closed = false + let scanning = false + let scanAgain = false + const scan = async () => { + if (closed) return + if (scanning) { + scanAgain = true + return + } + scanning = true + try { + const files = await this.files() + const byIdentity = new Map([...states.values()].map(state => [state.identity, state])) + const nextStates = new Map() + const entries = [] + for (const file of files) { + const identity = `${String(file.details.dev)}:${String(file.details.ino)}` + let state = states.get(file.name) + if (state?.identity !== identity) state = byIdentity.get(identity) + if (state === undefined || file.details.size < state.offset) state = { identity, offset: 0, pending: Buffer.alloc(0) } + if (file.details.size > state.offset) { + const chunks = [] + const end = file.details.size - 1 + for await (const chunk of createReadStream(file.path, { start: state.offset, end })) chunks.push(chunk) + const content = Buffer.concat([state.pending, ...chunks]) + let lineStart = 0 + for (let index = 0; index < content.length; index += 1) { + if (content[index] !== 0x0a) continue + const line = content.subarray(lineStart, index).toString('utf8') + lineStart = index + 1 + if (line === '') continue + const entry = parseLine(line) + if (entry !== undefined + && (selected === undefined || selected.has(entry.source)) + && (threshold === undefined || entry.timestamp >= threshold) + && matches(entry, filters)) entries.push(entry) + } + state = { ...state, identity, offset: file.details.size, pending: content.subarray(lineStart) } + } + nextStates.set(file.name, state) + } + states = nextStates + entries.sort((left, right) => left.timestamp.localeCompare(right.timestamp)) + for (const entry of entries) listener(Object.freeze(entry)) + } catch (error) { + onError(error) + } finally { + scanning = false + if (scanAgain) { + scanAgain = false + void scan() + } + } + } + const timer = setInterval(() => { void scan() }, intervalMs) + timer.unref() + return Object.freeze({ + close: () => { + closed = true + clearInterval(timer) + }, + }) + } + + capture(child, source, declaration = { stdout: true, stderr: true }, { acceptForwarded = false } = {}) { + for (const streamName of ['stdout', 'stderr']) { + if (!declaration[streamName]) continue + const stream = child[streamName] + if (stream === undefined || stream === null) continue + let pending = '' + let completeLines = [] + let flushScheduled = false + const appendLines = lines => { + if (lines.length === 0) return + const messages = capturedMessages(source, streamName, lines) + for (const message of messages) { + void this.append(source, streamName, message, { level: capturedLogLevel(streamName, message) }) + .catch(error => this.emit('error', error)) + } + } + const flush = () => { + flushScheduled = false + const lines = completeLines + completeLines = [] + let unforwarded = [] + const flushUnforwarded = () => { + appendLines(unforwarded) + unforwarded = [] + } + for (const line of lines) { + const forwarded = acceptForwarded && this.output !== undefined ? parseForwardedLine(line) : undefined + if (forwarded === undefined) unforwarded.push(line) + else { + flushUnforwarded() + this.writeOutput(forwarded.stream, line) + } + } + flushUnforwarded() + } + const scheduleFlush = () => { + if (flushScheduled) return + flushScheduled = true + setImmediate(flush) + } + stream.setEncoding?.('utf8') + stream.on('data', chunk => { + pending += String(chunk) + const lines = pending.split('\n') + pending = lines.pop() + completeLines.push(...lines) + scheduleFlush() + }) + stream.once('end', () => { + if (pending !== '') completeLines.push(pending) + pending = '' + flush() + }) + } + } +} diff --git a/upstream/container/control-plane/modules/patch-manager/index.mjs b/upstream/container/control-plane/modules/patch-manager/index.mjs new file mode 100644 index 0000000..c0dbe53 --- /dev/null +++ b/upstream/container/control-plane/modules/patch-manager/index.mjs @@ -0,0 +1,215 @@ +import { createHash, randomUUID } from 'node:crypto' +import { spawn } from 'node:child_process' +import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { TrustError } from '../../../platform/lib/validation.mjs' +import { artifactForReference, parseEnvironmentManifest } from '../../../platform/lib/contracts.mjs' + +async function patchModule(path) { + return import(`${pathToFileURL(resolve(path)).href}?runtime=${randomUUID()}`) +} + +export async function verifyNpmIntegrity(path, integrity) { + if (typeof integrity !== 'string' || !integrity.startsWith('sha512-')) { + throw new TrustError('npm integrity must use SHA-512') + } + const expected = integrity.slice('sha512-'.length) + const bytes = await readFile(path) + const actual = createHash('sha512').update(bytes).digest('base64') + if (actual !== expected) throw new TrustError('npm tarball integrity mismatch', 'TRUST_ARTIFACT_MISMATCH') + return bytes.byteLength +} + +export async function applyPatchSet(dshRoot, patchPaths) { + for (const path of patchPaths) { + const module = await patchModule(path) + if (typeof module.applyPatch !== 'function') { + throw new Error(`Patch ${path} does not export applyPatch(dshRoot)`) + } + await module.applyPatch(dshRoot) + } +} + +export async function verifyPatchSet(dshRoot, patchPaths) { + for (const path of patchPaths) { + const module = await patchModule(path) + if (typeof module.verifyPatch !== 'function') { + throw new Error(`Patch ${path} does not export verifyPatch(dshRoot)`) + } + await module.verifyPatch(resolve(dshRoot)) + } +} + +export async function verifyRuntimePatches({ runtimeRoot, environmentRoot }) { + const patchSet = await loadEnvironmentPatchSet(environmentRoot) + await verifyPatchSet(join(resolve(runtimeRoot), 'package'), patchSet.paths) + return patchSet.ids +} + +export async function loadEnvironmentPatchSet(environmentRoot) { + const environment = resolve(environmentRoot) + const manifest = parseEnvironmentManifest(await readFile(join(environment, 'environment.manifest.json'))) + const paths = [] + for (const reference of manifest.patches) { + const descriptor = artifactForReference(manifest, reference) + const path = join(environment, 'artifacts', descriptor.id) + const bytes = await readFile(path) + if (bytes.byteLength !== descriptor.size + || createHash('sha256').update(bytes).digest('hex') !== descriptor.sha256) { + throw new TrustError(`Patch Artifact ${descriptor.id} differs from the Environment Manifest`) + } + paths.push(path) + } + return Object.freeze({ + ids: Object.freeze(manifest.patches.map(reference => reference.id)), + paths: Object.freeze(paths), + }) +} + +async function measureTree(path) { + const details = await lstat(path) + if (details.isSymbolicLink()) return { bytes: 0, items: 1 } + if (!details.isDirectory()) return { bytes: details.size, items: 1 } + let bytes = 0 + let items = 0 + for (const entry of await readdir(path)) { + const measured = await measureTree(join(path, entry)) + bytes += measured.bytes + items += measured.items + } + return { bytes, items } +} + +async function copyTree(source, destination, onProgress = async () => {}) { + const details = await lstat(source) + if (details.isSymbolicLink()) { + await symlink(await readlink(source), destination) + await onProgress({ processedBytes: 0, processedItems: 1 }) + return + } + if (!details.isDirectory()) { + await cp(source, destination, { preserveTimestamps: true, verbatimSymlinks: true }) + await onProgress({ processedBytes: details.size, processedItems: 1 }) + return + } + await mkdir(destination, { recursive: true }) + for (const entry of await readdir(source)) { + await copyTree(join(source, entry), join(destination, entry), onProgress) + } +} + +export async function buildRuntime({ pristineRoot, versionsRoot, runtimeId, patchPaths, onProgress = async () => {} }) { + if (typeof runtimeId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(runtimeId)) { + throw new Error('Runtime ID is invalid') + } + const versions = resolve(versionsRoot) + const destination = join(versions, runtimeId) + try { + await lstat(destination) + throw new Error(`Runtime ${runtimeId} already exists`) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + await mkdir(versions, { recursive: true }) + const staging = join(versions, `.${runtimeId}.${randomUUID()}.tmp`) + try { + const packageRoot = join(staging, 'package') + await mkdir(staging) + const total = await measureTree(pristineRoot) + let processedBytes = 0 + let processedItems = 0 + await copyTree(pristineRoot, packageRoot, async update => { + processedBytes += update.processedBytes + processedItems += update.processedItems + await onProgress({ processedBytes, totalBytes: total.bytes, processedItems, totalItems: total.items }) + }) + await applyPatchSet(packageRoot, patchPaths) + const binTarget = join(packageRoot, 'lib', 'bin.js') + const details = await lstat(binTarget) + if (!details.isFile()) throw new Error('DSH Runtime has no lib/bin.js entrypoint') + await mkdir(join(staging, 'bin')) + await symlink('../package/lib/bin.js', join(staging, 'bin', 'dsh')) + await rename(staging, destination) + return destination + } catch (error) { + await rm(staging, { recursive: true, force: true }) + throw error + } +} + +async function optionalLink(path) { + try { + return basename(await import('node:fs/promises').then(({ readlink }) => readlink(path))) + } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } +} + +async function replaceLink(root, name, runtimeId) { + const temporary = join(root, `.${name}.${randomUUID()}.tmp`) + await symlink(join('versions', runtimeId), temporary) + await rename(temporary, join(root, name)) +} + +export function cloneTree(source, destination) { + return new Promise((resolveClone, reject) => { + const child = spawn('cp', ['-a', '--reflink=auto', '--', resolve(source), resolve(destination)], { + stdio: ['ignore', 'ignore', 'pipe'], + }) + const stderr = [] + child.stderr.on('data', chunk => stderr.push(chunk)) + child.once('error', reject) + child.once('exit', code => code === 0 + ? resolveClone(destination) + : reject(new Error(`Platform tree copy failed: ${Buffer.concat(stderr).toString('utf8').trim()}`))) + }) +} + +export class RuntimeSlots { + constructor(root) { + this.root = resolve(root) + } + + async state() { + return Object.freeze({ + current: await optionalLink(join(this.root, 'current')), + previous: await optionalLink(join(this.root, 'previous')), + }) + } + + async promote(runtimeId) { + await lstat(join(this.root, 'versions', runtimeId)) + const state = await this.state() + if (state.current !== undefined && state.current !== runtimeId) await replaceLink(this.root, 'previous', state.current) + await replaceLink(this.root, 'current', runtimeId) + return this.state() + } + + async rollback() { + const state = await this.state() + if (state.previous === undefined) throw new Error('no previous Runtime exists') + await replaceLink(this.root, 'current', state.previous) + if (state.current !== undefined) await replaceLink(this.root, 'previous', state.current) + return this.state() + } + + async prune() { + const state = await this.state() + const retained = new Set([state.current, state.previous].filter(Boolean)) + let versions + try { versions = await readdir(join(this.root, 'versions')) } catch (error) { + if (error?.code === 'ENOENT') return Object.freeze([]) + throw error + } + const removed = [] + for (const version of versions) { + if (!version.startsWith('.') && !retained.has(version)) { + await rm(join(this.root, 'versions', version), { recursive: true, force: true }) + removed.push(version) + } + } + return Object.freeze(removed.sort()) + } +} diff --git a/upstream/container/control-plane/modules/plugin-manager/system.mjs b/upstream/container/control-plane/modules/plugin-manager/system.mjs new file mode 100644 index 0000000..c372163 --- /dev/null +++ b/upstream/container/control-plane/modules/plugin-manager/system.mjs @@ -0,0 +1,441 @@ +import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { lstat, mkdir, readdir, readFile, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { canonicalJson } from '../../../platform/lib/canonical-json.mjs' +import { artifactForReference, parseEnvironmentManifest } from '../../../platform/lib/contracts.mjs' +import { hashTree } from '../../../platform/lib/tree-hash.mjs' + +const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/ +const SELECTION_SCHEMA = 1 +export const PROTECTED_SYSTEM_PLUGIN_IDS = Object.freeze(['platform-management']) + +function assertPluginId(pluginId) { + if (typeof pluginId !== 'string' || !PLUGIN_ID_PATTERN.test(pluginId)) { + throw new Error('System Plugin ID is invalid') + } + return pluginId +} + +function run(command, args) { + return new Promise((resolveRun, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + const stdout = [] + const stderr = [] + child.stdout.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.once('error', reject) + child.once('exit', code => { + if (code === 0) resolveRun(Buffer.concat(stdout).toString('utf8')) + else reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString('utf8').trim()}`)) + }) + }) +} + +async function extractPackage(archive, destination) { + const listing = (await run('tar', ['-tzf', archive])).split('\n').filter(Boolean) + if (listing.length === 0) throw new Error('System Plugin archive is empty') + for (const name of listing) { + if (!name.startsWith('package/') || name.startsWith('/') || name.split('/').includes('..')) { + throw new Error(`System Plugin archive path is unsafe: ${name}`) + } + } + await mkdir(destination, { recursive: true }) + await run('tar', ['-xzf', archive, '--strip-components=1', '--no-same-owner', '--no-same-permissions', '-C', destination]) +} + +function validatePackage(value, pluginId) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`System Plugin ${pluginId} package.json must be an object`) + } + const expectedName = `@dsh-docker/${pluginId}` + if (value.name !== expectedName) throw new Error(`System Plugin ${pluginId} package name must be ${expectedName}`) + if (typeof value.main !== 'string' || value.main.length === 0 + || value.main.startsWith('/') || value.main.includes('\\') || value.main.split('/').includes('..')) { + throw new Error(`System Plugin ${pluginId} package main must be a safe relative path`) + } + return expectedName +} + +function validatePatch(value, pluginId, packageName) { + if (!Array.isArray(value)) throw new Error(`System Plugin ${pluginId} cordis.patch.json must be an array`) + return value.map((entry, index) => { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error(`System Plugin ${pluginId} patch ${String(index)} must be an object`) + } + if (Object.keys(entry).length !== 1 || !Array.isArray(entry.insert) || entry.insert.length === 0) { + throw new Error(`System Plugin ${pluginId} patches must contain only a non-empty insert list`) + } + for (const row of entry.insert) { + if (row === null || typeof row !== 'object' || Array.isArray(row)) { + throw new Error(`System Plugin ${pluginId} inserted rows must be objects`) + } + if (typeof row.id !== 'string' || !row.id.startsWith(`dsh-docker.${pluginId}.`)) { + throw new Error(`System Plugin ${pluginId} patch IDs must use its dsh-docker namespace`) + } + if (typeof row.name !== 'string' || row.name.length === 0) { + throw new Error(`System Plugin ${pluginId} inserted rows must name a package`) + } + if (row.name !== packageName) { + throw new Error(`System Plugin ${pluginId} inserted rows must load its own package`) + } + } + return { + insert: entry.insert.map(row => ({ ...row })), + } + }) +} + +async function replaceLink(root, name, version) { + const temporary = join(root, `.${name}.${randomUUID()}.tmp`) + await symlink(join('versions', version), temporary) + await rename(temporary, join(root, name)) +} + +export async function reconcileSystemPlugins({ root, environmentVersion, plugins, artifactPath }) { + const managedRoot = resolve(root) + const versions = join(managedRoot, 'versions') + const destination = join(versions, environmentVersion) + const staging = join(versions, `.${environmentVersion}.${randomUUID()}.tmp`) + await mkdir(staging, { recursive: true }) + const patches = [] + try { + for (const plugin of plugins) { + const packageRoot = join(staging, 'packages', plugin.id) + await extractPackage(artifactPath(plugin), packageRoot) + const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) + const packageName = validatePackage(metadata, plugin.id) + const patch = JSON.parse(await readFile(join(packageRoot, 'cordis.patch.json'), 'utf8')) + patches.push(...validatePatch(patch, plugin.id, packageName)) + } + await writeFile(join(staging, 'cordis.patch.yml'), canonicalJson(patches), { flag: 'wx' }) + await rename(staging, destination) + const current = await import('node:fs/promises').then(({ readlink }) => readlink(join(managedRoot, 'current')).catch(error => error.code === 'ENOENT' ? undefined : Promise.reject(error))) + if (current !== undefined) await replaceLink(managedRoot, 'previous', current.split('/').at(-1)) + await replaceLink(managedRoot, 'current', environmentVersion) + return destination + } catch (error) { + await rm(staging, { recursive: true, force: true }) + throw error + } +} + +async function readOverlay(root) { + const patch = JSON.parse(await readFile(join(root, 'cordis.patch.yml'), 'utf8')) + if (!Array.isArray(patch)) throw new Error('System Plugin overlay patch must be an array') + return patch +} + +function defaultSelection(pluginIds, protectedIds) { + return Object.fromEntries(pluginIds.map(id => [id, { + installed: true, + enabled: true, + protected: protectedIds.has(id), + }])) +} + +function validateSelectionDocument(value, pluginIds, protectedIds) { + if (value === null || typeof value !== 'object' || Array.isArray(value) || value.schema !== SELECTION_SCHEMA + || value.plugins === null || typeof value.plugins !== 'object' || Array.isArray(value.plugins)) { + throw new Error('System Plugin selection state is invalid') + } + const selection = defaultSelection(pluginIds, protectedIds) + for (const id of pluginIds) { + const saved = value.plugins[id] + if (saved === undefined) continue + if (saved === null || typeof saved !== 'object' || Array.isArray(saved) + || typeof saved.installed !== 'boolean' || typeof saved.enabled !== 'boolean') { + throw new Error(`System Plugin ${id} selection state is invalid`) + } + if (saved.enabled && !saved.installed) { + throw new Error(`System Plugin ${id} cannot be enabled while it is not installed`) + } + selection[id] = { installed: saved.installed, enabled: saved.enabled, protected: protectedIds.has(id) } + } + return selection +} + +export class SystemPluginSelectionStore { + constructor(path, { protectedIds = PROTECTED_SYSTEM_PLUGIN_IDS } = {}) { + this.path = resolve(path) + this.protectedIds = new Set(protectedIds) + } + + async read(pluginIds) { + const ids = pluginIds.map(assertPluginId) + let value + try { + value = JSON.parse(await readFile(this.path, 'utf8')) + } catch (error) { + if (error?.code === 'ENOENT') return defaultSelection(ids, this.protectedIds) + throw error + } + return validateSelectionDocument(value, ids, this.protectedIds) + } + + async write(pluginIds, selection) { + const ids = pluginIds.map(assertPluginId) + const normalized = validateSelectionDocument({ schema: SELECTION_SCHEMA, plugins: selection }, ids, this.protectedIds) + const document = { + schema: SELECTION_SCHEMA, + plugins: Object.fromEntries(ids.map(id => [id, { + installed: normalized[id].installed, + enabled: normalized[id].enabled, + }])), + } + await mkdir(dirname(this.path), { recursive: true }) + const temporary = `${this.path}.${randomUUID()}.tmp` + try { + await writeFile(temporary, `${canonicalJson(document)}\n`, { flag: 'wx', mode: 0o600 }) + await rename(temporary, this.path) + } finally { + await rm(temporary, { force: true }) + } + return normalized + } + + async configure(pluginIds, pluginId, action) { + const ids = pluginIds.map(assertPluginId) + assertPluginId(pluginId) + if (!ids.includes(pluginId)) throw new Error(`System Plugin ${pluginId} is not provided by the current Environment`) + if (this.protectedIds.has(pluginId)) throw new Error(`System Plugin ${pluginId} is managed by the platform and cannot be changed`) + return this.configureSelection(ids, pluginId, action) + } + + async recover(pluginIds, pluginId, action) { + const ids = pluginIds.map(assertPluginId) + assertPluginId(pluginId) + if (!ids.includes(pluginId)) throw new Error(`System Plugin ${pluginId} is not provided by the current Environment`) + if (!this.protectedIds.has(pluginId)) throw new Error(`System Plugin ${pluginId} is not a platform recovery target`) + return this.configureSelection(ids, pluginId, action) + } + + async configureSelection(ids, pluginId, action) { + if (!['install', 'uninstall', 'enable', 'disable'].includes(action)) { + throw new Error('System Plugin action is invalid') + } + const selection = await this.read(ids) + const current = selection[pluginId] + if (action === 'install') selection[pluginId] = { ...current, installed: true, enabled: true } + else if (action === 'uninstall') selection[pluginId] = { ...current, installed: false, enabled: false } + else if (action === 'enable') { + if (!current.installed) throw new Error(`System Plugin ${pluginId} must be installed before it can be enabled`) + selection[pluginId] = { ...current, enabled: true } + } else selection[pluginId] = { ...current, enabled: false } + return this.write(ids, selection) + } +} + +async function pluginCatalog(environmentRoot) { + const root = resolve(environmentRoot) + const manifest = parseEnvironmentManifest(await readFile(join(root, 'environment.manifest.json'))) + return { + root, + plugins: manifest.systemPlugins.map(reference => ({ + id: reference.id, + descriptor: artifactForReference(manifest, reference), + })), + } +} + +async function readActiveSelection(root, plugins) { + if (root === undefined) return undefined + const overlay = await readOverlay(resolve(root)) + const enabledPackages = new Set(overlay.flatMap(entry => ( + Array.isArray(entry?.insert) ? entry.insert.map(row => row?.name).filter(Boolean) : [] + ))) + return Object.fromEntries(await Promise.all(plugins.map(async ({ id }) => { + const installed = await lstat(join(resolve(root), 'packages', id)).then( + () => true, + error => error?.code === 'ENOENT' ? false : Promise.reject(error), + ) + return [id, { installed, enabled: installed && enabledPackages.has(`@dsh-docker/${id}`) }] + }))) +} + +export async function listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore, activeRoot }) { + const catalog = await pluginCatalog(environmentRoot) + const selection = await selectionStore.read(catalog.plugins.map(plugin => plugin.id)) + const active = await readActiveSelection(activeRoot, catalog.plugins) + return Object.freeze(await Promise.all(catalog.plugins.map(async ({ id, descriptor }) => { + const metadata = JSON.parse(await readFile(join(resolve(sourceRoot), 'packages', id, 'package.json'), 'utf8')) + validatePackage(metadata, id) + let description = null + const value = metadata.dshDocker?.description + if (value !== undefined) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || typeof value.zh !== 'string' || typeof value.en !== 'string' + || value.zh.length === 0 || value.en.length === 0 || value.zh.length > 240 || value.en.length > 240) { + throw new Error(`System Plugin ${id} description is invalid`) + } + description = Object.freeze({ zh: value.zh, en: value.en }) + } + const activeInstalled = active?.[id].installed ?? selection[id].installed + const activeEnabled = active?.[id].enabled ?? selection[id].enabled + return Object.freeze({ + id, + artifactId: descriptor.id, + sha256: descriptor.sha256, + description, + installed: selection[id].installed, + enabled: selection[id].enabled, + activeInstalled, + activeEnabled, + pendingRestart: activeInstalled !== selection[id].installed || activeEnabled !== selection[id].enabled, + protected: selection[id].protected, + reason: null, + }) + }))) +} + +export async function discardSystemPluginSelection({ environmentRoot, sourceRoot, selectionStore, activeRoot }) { + const catalog = await pluginCatalog(environmentRoot) + const pluginIds = catalog.plugins.map(plugin => plugin.id) + const active = await readActiveSelection(activeRoot, catalog.plugins) + if (active === undefined) throw new Error('active System Plugin view is unavailable') + await selectionStore.write(pluginIds, active) + return listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore, activeRoot }) +} + +export async function materializeSystemPluginSelection({ + environmentRoot, + sourceRoot, + outputRoot, + selectionStore, +}) { + const catalog = await pluginCatalog(environmentRoot) + const pluginIds = catalog.plugins.map(plugin => plugin.id) + const selection = await selectionStore.read(pluginIds) + const root = resolve(outputRoot) + const staging = join(root, `.selection.${randomUUID()}.tmp`) + const destination = join(root, `selection-${randomUUID()}`) + const patches = [] + await mkdir(join(staging, 'packages'), { recursive: true }) + try { + for (const { id } of catalog.plugins) { + if (!selection[id].installed) continue + const packageRoot = join(resolve(sourceRoot), 'packages', id) + const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) + const packageName = validatePackage(metadata, id) + const patch = JSON.parse(await readFile(join(packageRoot, 'cordis.patch.json'), 'utf8')) + const validatedPatch = validatePatch(patch, id, packageName) + await symlink(packageRoot, join(staging, 'packages', id), 'dir') + if (selection[id].enabled) patches.push(...validatedPatch) + } + await writeFile(join(staging, 'cordis.patch.yml'), canonicalJson(patches), { flag: 'wx' }) + await rename(staging, destination) + return Object.freeze({ path: destination, plugins: await listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore }) }) + } catch (error) { + await rm(staging, { recursive: true, force: true }) + throw error + } +} + +export async function pruneSystemPluginSelectionViews({ outputRoot, keepPath }) { + const root = resolve(outputRoot) + const keep = resolve(keepPath) + for (const entry of await readdir(root, { withFileTypes: true })) { + if (!entry.name.startsWith('selection-')) continue + const candidate = join(root, entry.name) + if (candidate !== keep) await rm(candidate, { recursive: true, force: true }) + } +} + +export async function listBundledSystemPlugins({ environmentRoot, viewRoot }) { + const manifest = parseEnvironmentManifest(await readFile(join(resolve(environmentRoot), 'environment.manifest.json'))) + const overlay = await readOverlay(resolve(viewRoot)).catch(() => []) + const moduleNames = new Set(overlay.flatMap(entry => ( + Array.isArray(entry?.insert) ? entry.insert.map(row => row?.name).filter(Boolean) : [] + ))) + const result = [] + for (const reference of manifest.systemPlugins) { + const descriptor = artifactForReference(manifest, reference) + let installed = false + let reason = null + try { + const metadata = JSON.parse(await readFile(join(resolve(viewRoot), 'packages', reference.id, 'package.json'), 'utf8')) + const packageName = validatePackage(metadata, reference.id) + installed = moduleNames.has(packageName) + if (!installed) reason = 'plugin loader entry is absent' + } catch (error) { + reason = error instanceof Error ? error.message : 'plugin package is unavailable' + } + result.push(Object.freeze({ + id: reference.id, + artifactId: descriptor.id, + sha256: descriptor.sha256, + installed, + reason, + })) + } + return Object.freeze(result) +} + +export async function rebuildBundledSystemPluginView({ + environmentRoot, + outputRoot, + expectedSha256, + requestedPluginId, +}) { + const source = resolve(environmentRoot) + const manifest = parseEnvironmentManifest(await readFile(join(source, 'environment.manifest.json'))) + if (!manifest.systemPlugins.some(plugin => plugin.id === requestedPluginId)) { + throw new Error(`System Plugin ${requestedPluginId} is not bundled by the current Environment`) + } + const root = resolve(outputRoot) + const destination = join(root, expectedSha256) + try { + const details = await lstat(destination) + if (!details.isDirectory() || details.isSymbolicLink() || await hashTree(destination) !== expectedSha256) { + await rm(destination, { recursive: true, force: true }) + } else { + return destination + } + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + + const buildRoot = join(root, `.build.${randomUUID()}.tmp`) + await mkdir(root, { recursive: true }) + try { + const built = await reconcileSystemPlugins({ + root: buildRoot, + environmentVersion: expectedSha256, + plugins: manifest.systemPlugins, + artifactPath: reference => join(source, 'artifacts', artifactForReference(manifest, reference).id), + }) + if (await hashTree(built) !== expectedSha256) { + throw new Error('rebuilt System Plugin view differs from the current Deployment Record') + } + await rename(built, destination) + return destination + } finally { + await rm(buildRoot, { recursive: true, force: true }) + } +} + +export async function linkSystemPluginScope({ + dshHome, + viewRoot = '/run/dsh-platform/views/system-plugins', +}) { + const modulesRoot = join(resolve(dshHome), 'profiles', 'node_modules') + const scopeLink = join(modulesRoot, '@dsh-docker') + const target = join(resolve(viewRoot), 'packages') + await mkdir(modulesRoot, { recursive: true }) + const existing = await lstat(scopeLink).catch(error => error?.code === 'ENOENT' ? undefined : Promise.reject(error)) + if (existing !== undefined) { + if (!existing.isSymbolicLink()) { + throw new Error(`System Plugin scope ${scopeLink} exists and is not a symlink`) + } + if (await readlink(scopeLink) === target) return scopeLink + await rm(scopeLink) + } + const temporary = join(modulesRoot, `.@dsh-docker.${randomUUID()}.tmp`) + try { + await symlink(target, temporary, 'dir') + await rename(temporary, scopeLink) + } finally { + await rm(temporary, { force: true }) + } + return scopeLink +} diff --git a/upstream/container/control-plane/modules/plugin-manager/user-inventory.mjs b/upstream/container/control-plane/modules/plugin-manager/user-inventory.mjs new file mode 100644 index 0000000..d6a3013 --- /dev/null +++ b/upstream/container/control-plane/modules/plugin-manager/user-inventory.mjs @@ -0,0 +1,203 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' + +const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]{0,126}\/[a-z0-9][a-z0-9._-]{0,126}|[a-z0-9][a-z0-9._-]{0,213})$/i +const SELECTION_SCHEMA = 1 + +function packagePath(root, packageName) { + if (!PACKAGE_NAME_PATTERN.test(packageName)) return undefined + return join(root, 'node_modules', ...packageName.split('/'), 'package.json') +} + +async function optionalBytes(path) { + return readFile(path).then( + bytes => ({ present: true, bytes }), + error => error?.code === 'ENOENT' ? { present: false, bytes: Buffer.alloc(0) } : Promise.reject(error), + ) +} + +function parseObject(bytes, label) { + let value + try { + value = JSON.parse(bytes.toString('utf8')) + } catch (error) { + throw new Error(`${label} is not valid JSON`, { cause: error }) + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must contain a JSON object`) + } + return value +} + +function profileFields(manifest) { + const dependencies = manifest.dependencies ?? {} + if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) { + throw new Error('Web Profile dependencies must be an object') + } + for (const [name, spec] of Object.entries(dependencies)) { + if (typeof spec !== 'string' || spec.length === 0) { + throw new Error(`Web Profile dependency ${name} must have a non-empty string spec`) + } + } + const bundles = manifest.dsh?.profile?.bundles ?? [] + if (!Array.isArray(bundles) || bundles.some(name => typeof name !== 'string')) { + throw new Error('Web Profile dsh.profile.bundles must be a string array') + } + return { dependencies, bundles } +} + +function disabledPositions(value) { + if (value === undefined) return new Map() + if (value === null || typeof value !== 'object' || Array.isArray(value) + || value.schema !== SELECTION_SCHEMA || !Array.isArray(value.disabled)) { + throw new Error('User Plugin selection state is invalid') + } + const positions = new Map() + for (const entry of value.disabled) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry) + || !PACKAGE_NAME_PATTERN.test(entry.name) || !Number.isSafeInteger(entry.index) || entry.index < 0) { + throw new Error('User Plugin disabled-order entry is invalid') + } + if (positions.has(entry.name)) throw new Error(`User Plugin ${entry.name} has duplicate disabled-order entries`) + positions.set(entry.name, entry.index) + } + return positions +} + +function sourceForSpec(spec) { + if (/^(?:file|link):/i.test(spec) || /^(?:\.{1,2}\/|\/)/.test(spec)) return 'file' + if (/^(?:git(?:\+[^:]+)?|github|gitlab|bitbucket):/i.test(spec) || /\.git(?:#|$)/i.test(spec)) return 'git' + if (/^https?:/i.test(spec)) return 'url' + if (/^npm:/i.test(spec) || /^(?:[~^<>=*]|\d|v\d)/i.test(spec) || spec === 'latest' || spec === 'next') return 'registry' + return 'other' +} + +function updateRevision(hash, label, file) { + hash.update(`${label}:${file.present ? '1' : '0'}:${String(file.bytes.length)}:`) + hash.update(file.bytes) + hash.update('\0') +} + +function metadataFailure(error) { + if (error?.code === 'ENOENT') return 'installed package metadata is missing' + if (error instanceof SyntaxError) return 'installed package metadata is not valid JSON' + return error instanceof Error ? error.message : String(error) +} + +async function inspectDependency(profileRoot, name, spec, enabledBundles, disabled, reservedNames) { + const path = packagePath(profileRoot, name) + let metadata + let metadataError = null + if (path === undefined) { + metadataError = 'dependency name is invalid' + } else { + try { + metadata = JSON.parse(await readFile(path, 'utf8')) + if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) { + throw new Error('installed package metadata must be an object') + } + if (metadata.name !== name) throw new Error(`installed package name is ${String(metadata.name)}`) + } catch (error) { + metadataError = metadataFailure(error) + } + } + if (metadataError === null && metadata.version !== undefined + && (typeof metadata.version !== 'string' || metadata.version.length === 0)) { + metadataError = 'installed package version is invalid' + } + const bundleDeclaration = metadata?.dsh?.bundle + const patch = bundleDeclaration?.patch + const isBundle = typeof patch === 'string' && patch.length > 0 + const reservedNameConflict = reservedNames.has(name) + if (metadataError === null && !isBundle + && (bundleDeclaration !== undefined || enabledBundles.has(name))) { + metadataError = 'installed package Bundle metadata is invalid' + } + if (!isBundle && metadataError === null && !reservedNameConflict) return undefined + return Object.freeze({ + name, + spec, + source: sourceForSpec(spec), + version: typeof metadata?.version === 'string' ? metadata.version : null, + description: typeof metadata?.description === 'string' && metadata.description.trim() !== '' ? metadata.description.trim() : null, + enabled: !reservedNameConflict && enabledBundles.has(name), + previousIndex: disabled.get(name) ?? null, + damaged: metadataError !== null, + metadataError, + reservedNameConflict, + }) +} + +function loadedIdentity(plugin) { + return JSON.stringify([ + plugin.name, plugin.spec, plugin.version, plugin.enabled, plugin.damaged, + plugin.metadataError, plugin.reservedNameConflict, + ]) +} + +export function markUserPluginRestartState(current, loaded) { + if (loaded === undefined) return current + const loadedPlugins = new Map(loaded.plugins.map(plugin => [plugin.name, loadedIdentity(plugin)])) + const plugins = current.plugins.map(plugin => Object.freeze({ + ...plugin, + pendingRestart: loadedPlugins.get(plugin.name) !== loadedIdentity(plugin), + })) + const currentNames = new Set(current.plugins.map(plugin => plugin.name)) + const removedLoadedPlugin = loaded.plugins.some(plugin => !currentNames.has(plugin.name)) + return Object.freeze({ + ...current, + plugins: Object.freeze(plugins), + restartRequired: removedLoadedPlugin || plugins.some(plugin => plugin.pendingRestart), + }) +} + +export class UserPluginInventory { + constructor({ dshHome = '/data/dsh', profile = 'web', selectionPath, systemPluginNames = async () => [] } = {}) { + if (profile !== 'web') throw new Error('Only the web DSH Profile is supported') + this.profile = profile + this.profileRoot = join(resolve(dshHome), 'profiles', profile) + this.selectionPath = selectionPath === undefined + ? join(resolve(dshHome), '..', 'platform', 'state', 'management', 'user-plugins.json') + : resolve(selectionPath) + this.systemPluginNames = systemPluginNames + } + + async read() { + const manifestPath = join(this.profileRoot, 'package.json') + const lockfilePath = join(this.profileRoot, 'pnpm-lock.yaml') + const [manifestBytes, lockfile, selectionFile, names] = await Promise.all([ + readFile(manifestPath), + optionalBytes(lockfilePath), + optionalBytes(this.selectionPath), + this.systemPluginNames(), + ]) + const manifest = parseObject(manifestBytes, 'Web Profile package.json') + const { dependencies, bundles } = profileFields(manifest) + const selection = !selectionFile.present + ? undefined + : parseObject(selectionFile.bytes, 'User Plugin selection state') + const disabled = disabledPositions(selection) + const reservedNames = new Set(names) + if ([...reservedNames].some(name => typeof name !== 'string' || !PACKAGE_NAME_PATTERN.test(name))) { + throw new Error('Verified System Plugin names are invalid') + } + const enabledBundles = new Set(bundles) + const inspected = await Promise.all(Object.entries(dependencies).map(([name, spec]) => ( + inspectDependency(this.profileRoot, name, spec, enabledBundles, disabled, reservedNames) + ))) + const revisionHash = createHash('sha256') + updateRevision(revisionHash, 'manifest', { present: true, bytes: manifestBytes }) + updateRevision(revisionHash, 'lockfile', lockfile) + updateRevision(revisionHash, 'selection', selectionFile) + const revision = revisionHash.digest('hex') + return Object.freeze({ + schema: 1, + profile: this.profile, + revision: `sha256:${revision}`, + plugins: Object.freeze(inspected.filter(Boolean)), + }) + } +} + +export const userPluginInternals = Object.freeze({ PACKAGE_NAME_PATTERN, sourceForSpec, loadedIdentity }) diff --git a/upstream/container/control-plane/modules/plugin-manager/user-journal.mjs b/upstream/container/control-plane/modules/plugin-manager/user-journal.mjs new file mode 100644 index 0000000..7a94651 --- /dev/null +++ b/upstream/container/control-plane/modules/plugin-manager/user-journal.mjs @@ -0,0 +1,116 @@ +import { readFile } from 'node:fs/promises' +import { durableReplace } from '../../../platform/lib/atomic.mjs' +import { userPluginInternals } from './user-inventory.mjs' + +const PHASES = new Set([ + 'validated', 'paused', 'snapshotted', 'mutating', 'committed', 'restarting', + 'restoring', 'completed', 'failed', +]) +const TERMINAL = new Set(['completed', 'failed']) +const TRANSITIONS = new Map([ + ['validated', new Set(['paused', 'snapshotted', 'failed'])], + ['paused', new Set(['snapshotted', 'restoring', 'failed'])], + ['snapshotted', new Set(['mutating', 'restoring'])], + ['mutating', new Set(['committed', 'restoring'])], + ['committed', new Set(['restarting', 'completed', 'failed'])], + ['restarting', new Set(['completed', 'failed'])], + ['restoring', new Set(['failed'])], + ['completed', new Set()], + ['failed', new Set()], +]) + +function parseAction(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).sort().join(',') !== 'action,name' + || typeof value.name !== 'string' || !userPluginInternals.PACKAGE_NAME_PATTERN.test(value.name) + || !['enable', 'disable', 'uninstall'].includes(value.action)) { + throw new Error('User Plugin journal action is invalid') + } + return Object.freeze({ name: value.name, action: value.action }) +} + +function parse(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).sort().join(',') !== 'actions,error,phase,previousDisabled,recoveryResult,revision,schema,selectionPresent,snapshotId,taskId,updatedAt' + || value.schema !== 1 || typeof value.taskId !== 'string' || !/^[A-Za-z0-9-]{1,128}$/.test(value.taskId) + || !PHASES.has(value.phase) || typeof value.revision !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value.revision) + || !Array.isArray(value.actions) || value.actions.length === 0 + || typeof value.selectionPresent !== 'boolean' || !Array.isArray(value.previousDisabled) + || (value.snapshotId !== null && typeof value.snapshotId !== 'string') + || (value.error !== null && typeof value.error !== 'string') + || ![null, 'success', 'failed'].includes(value.recoveryResult) + || typeof value.updatedAt !== 'string' || new Date(value.updatedAt).toISOString() !== value.updatedAt) { + throw new Error('User Plugin transaction journal is invalid') + } + const names = new Set() + const previousDisabled = value.previousDisabled.map(entry => { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry) + || Object.keys(entry).sort().join(',') !== 'index,name' + || typeof entry.name !== 'string' || !userPluginInternals.PACKAGE_NAME_PATTERN.test(entry.name) + || !Number.isSafeInteger(entry.index) || entry.index < 0 + || names.has(entry.name)) throw new Error('User Plugin journal disabled-order state is invalid') + names.add(entry.name) + return Object.freeze({ name: entry.name, index: entry.index }) + }) + return Object.freeze({ + ...value, + actions: Object.freeze(value.actions.map(parseAction)), + previousDisabled: Object.freeze(previousDisabled), + }) +} + +export class UserPluginJournal { + constructor(path, now = () => new Date()) { + this.path = path + this.now = now + this.queue = Promise.resolve() + } + + exclusive(operation) { + const result = this.queue.then(operation, operation) + this.queue = result.then(() => undefined, () => undefined) + return result + } + + async read() { + try { return parse(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } + } + + begin({ taskId, revision, actions, selection }) { + return this.exclusive(async () => { + const current = await this.read() + if (current !== undefined && !TERMINAL.has(current.phase)) { + throw new Error('a User Plugin transaction is already active') + } + const next = parse({ + schema: 1, taskId, revision, actions, + selectionPresent: selection.present, + previousDisabled: selection.state.disabled, + phase: 'validated', snapshotId: null, + error: null, recoveryResult: null, updatedAt: this.now().toISOString(), + }) + await durableReplace(this.path, `${JSON.stringify(next)}\n`) + return next + }) + } + + transition(phase, fields = {}) { + return this.exclusive(async () => { + const current = await this.read() + if (current === undefined || !TRANSITIONS.get(current.phase)?.has(phase)) { + throw new Error(`User Plugin journal cannot transition to ${phase}`) + } + const next = parse({ ...current, ...fields, phase, updatedAt: this.now().toISOString() }) + if (next.taskId !== current.taskId || next.revision !== current.revision) { + throw new Error('User Plugin journal identity cannot change') + } + await durableReplace(this.path, `${JSON.stringify(next)}\n`) + return next + }) + } +} + +export const userPluginJournalInternals = Object.freeze({ parse, TERMINAL }) diff --git a/upstream/container/control-plane/modules/plugin-manager/user-snapshots.mjs b/upstream/container/control-plane/modules/plugin-manager/user-snapshots.mjs new file mode 100644 index 0000000..44d2436 --- /dev/null +++ b/upstream/container/control-plane/modules/plugin-manager/user-snapshots.mjs @@ -0,0 +1,143 @@ +import { spawn } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { lstat, mkdir, open, readFile, readdir, rename, rm } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { canonicalJson } from '../../../platform/lib/canonical-json.mjs' +import { durableReplace } from '../../../platform/lib/atomic.mjs' + +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/ + +function validId(id) { + if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw new Error('User Plugin snapshot ID is invalid') + return id +} + +function run(command, args) { + return new Promise((resolveRun, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + const stdout = [] + const stderr = [] + child.stdout.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.once('error', reject) + child.once('exit', code => code === 0 + ? resolveRun(Buffer.concat(stdout).toString('utf8')) + : reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString('utf8').trim()}`))) + }) +} + +async function measure(path) { + const handle = await open(path, 'r') + const hash = createHash('sha256') + let size = 0 + try { + for await (const chunk of handle.createReadStream({ autoClose: false })) { + hash.update(chunk) + size += chunk.byteLength + } + } finally { + await handle.close() + } + return { sha256: hash.digest('hex'), size } +} + +async function syncDirectory(path) { + const handle = await open(path, 'r') + try { await handle.sync() } finally { await handle.close() } +} + +function parseManifest(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).sort().join(',') !== 'archiveSha256,archiveSize,createdAt,id,schema,source' + || value.schema !== 1 || !ID_PATTERN.test(value.id) + || value.source !== '/data/dsh/profiles/web' + || typeof value.createdAt !== 'string' || new Date(value.createdAt).toISOString() !== value.createdAt + || typeof value.archiveSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.archiveSha256) + || !Number.isSafeInteger(value.archiveSize) || value.archiveSize < 0) { + throw new Error('User Plugin snapshot manifest is invalid') + } + return Object.freeze({ ...value }) +} + +export class UserPluginSnapshots { + constructor({ root, profileRoot = '/data/dsh/profiles/web', now = () => new Date(), runImpl = run }) { + this.root = resolve(root) + this.profileRoot = resolve(profileRoot) + this.now = now + this.run = runImpl + } + + path(id) { return join(this.root, validId(id)) } + + async create(id = randomUUID()) { + validId(id) + const details = await lstat(this.profileRoot) + if (!details.isDirectory() || details.isSymbolicLink()) throw new Error('Web Profile must be a real directory') + await mkdir(this.root, { recursive: true }) + const destination = this.path(id) + await lstat(destination).then( + () => { throw new Error(`User Plugin snapshot ${id} already exists`) }, + error => { if (error?.code !== 'ENOENT') throw error }, + ) + const staging = join(this.root, `.${id}.${randomUUID()}.tmp`) + await mkdir(staging) + const archive = join(staging, 'profile.tar') + try { + await this.run('tar', ['-cpf', archive, '-C', this.profileRoot, '.']) + const measured = await measure(archive) + const manifest = parseManifest({ + schema: 1, + id, + source: '/data/dsh/profiles/web', + createdAt: this.now().toISOString(), + archiveSha256: measured.sha256, + archiveSize: measured.size, + }) + await durableReplace(join(staging, 'manifest.json'), canonicalJson(manifest)) + await rename(staging, destination) + await syncDirectory(this.root) + return Object.freeze({ ...manifest, path: destination }) + } catch (error) { + await rm(staging, { recursive: true, force: true }) + throw error + } + } + + async inspect(id) { + const root = this.path(id) + const manifest = parseManifest(JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8'))) + if (manifest.id !== id) throw new Error('User Plugin snapshot ID does not match its directory') + const archive = await lstat(join(root, 'profile.tar')).then( + () => join(root, 'profile.tar'), + error => error?.code === 'ENOENT' ? join(root, 'profile.tar.gz') : Promise.reject(error), + ) + const measured = await measure(archive) + if (measured.sha256 !== manifest.archiveSha256 || measured.size !== manifest.archiveSize) { + throw new Error('User Plugin snapshot archive does not match its manifest') + } + const entries = (await this.run('tar', [archive.endsWith('.gz') ? '-tzf' : '-tf', archive])).split('\n').filter(Boolean) + if (entries.length === 0 || entries.some(entry => entry.startsWith('/') || entry.split('/').includes('..'))) { + throw new Error('User Plugin snapshot archive contains unsafe paths') + } + return Object.freeze({ ...manifest, archive }) + } + + async restore(id) { + const snapshot = await this.inspect(id) + await mkdir(this.profileRoot, { recursive: true }) + for (const entry of await readdir(this.profileRoot)) { + await rm(join(this.profileRoot, entry), { recursive: true, force: true }) + } + const ownership = process.getuid?.() === 0 ? [] : ['--no-same-owner'] + await this.run('tar', [snapshot.archive.endsWith('.gz') ? '-xzpf' : '-xpf', snapshot.archive, ...ownership, '-C', this.profileRoot]) + await syncDirectory(this.profileRoot) + return snapshot + } + + async remove(id) { + validId(id) + await rm(this.path(id), { recursive: true, force: true }) + } +} + +export const userPluginSnapshotInternals = Object.freeze({ parseManifest }) diff --git a/upstream/container/control-plane/modules/plugin-manager/user-state.mjs b/upstream/container/control-plane/modules/plugin-manager/user-state.mjs new file mode 100644 index 0000000..3584a08 --- /dev/null +++ b/upstream/container/control-plane/modules/plugin-manager/user-state.mjs @@ -0,0 +1,67 @@ +import { open, readFile, rm } from 'node:fs/promises' +import { dirname } from 'node:path' +import { canonicalJson } from '../../../platform/lib/canonical-json.mjs' +import { durableReplace } from '../../../platform/lib/atomic.mjs' +import { userPluginInternals } from './user-inventory.mjs' + +function parse(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).sort().join(',') !== 'disabled,schema' + || value.schema !== 1 || !Array.isArray(value.disabled)) { + throw new Error('User Plugin selection state is invalid') + } + const names = new Set() + const disabled = value.disabled.map(entry => { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry) + || Object.keys(entry).some(key => !['index', 'name'].includes(key)) + || !userPluginInternals.PACKAGE_NAME_PATTERN.test(entry.name) + || !Number.isSafeInteger(entry.index) || entry.index < 0 || names.has(entry.name)) { + throw new Error('User Plugin disabled-order entry is invalid') + } + names.add(entry.name) + return Object.freeze({ name: entry.name, index: entry.index }) + }) + return Object.freeze({ schema: 1, disabled: Object.freeze(disabled) }) +} + +export class UserPluginSelectionStore { + constructor(path) { + this.path = path + } + + async read() { + return (await this.snapshot()).state + } + + async snapshot() { + try { + return Object.freeze({ present: true, state: parse(JSON.parse(await readFile(this.path, 'utf8'))) }) + } catch (error) { + if (error?.code === 'ENOENT') { + return Object.freeze({ present: false, state: parse({ schema: 1, disabled: [] }) }) + } + throw error + } + } + + async write(value) { + const state = parse(value) + await durableReplace(this.path, `${canonicalJson(state).toString('utf8')}\n`) + return state + } + + async restore(snapshot) { + if (snapshot === null || typeof snapshot !== 'object' || typeof snapshot.present !== 'boolean') { + throw new Error('User Plugin selection snapshot is invalid') + } + if (snapshot.present) return this.write(snapshot.state) + await rm(this.path, { force: true }) + const directory = await open(dirname(this.path), 'r').catch(error => error?.code === 'ENOENT' ? undefined : Promise.reject(error)) + if (directory !== undefined) { + try { await directory.sync() } finally { await directory.close() } + } + return parse({ schema: 1, disabled: [] }) + } +} + +export const userPluginStateInternals = Object.freeze({ parse }) diff --git a/upstream/container/control-plane/modules/plugin-manager/user-transaction.mjs b/upstream/container/control-plane/modules/plugin-manager/user-transaction.mjs new file mode 100644 index 0000000..86ac78a --- /dev/null +++ b/upstream/container/control-plane/modules/plugin-manager/user-transaction.mjs @@ -0,0 +1,414 @@ +import { spawn } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { join } from 'node:path' +import { canonicalJson } from '../../../platform/lib/canonical-json.mjs' +import { durableReplace } from '../../../platform/lib/atomic.mjs' +import { userPluginInternals } from './user-inventory.mjs' + +const PACKAGE_PATH_ENVIRONMENT = new Set([ + 'npm_config_cache', + 'npm_config_prefix', + 'npm_config_store_dir', + 'npm_config_userconfig', + 'pnpm_home', +]) + +function pluginEnvironment(dshHome, home = '/home/node') { + const environment = { + ...process.env, + HOME: home, + XDG_CACHE_HOME: join(home, '.cache'), + XDG_CONFIG_HOME: join(home, '.config'), + XDG_DATA_HOME: join(home, '.local', 'share'), + XDG_STATE_HOME: join(home, '.local', 'state'), + DSH_HOME: dshHome, + } + for (const name of Object.keys(environment)) { + if (PACKAGE_PATH_ENVIRONMENT.has(name.toLowerCase())) delete environment[name] + } + return environment +} + +function runDshPlugin({ dshHome, profile, name }, timeoutMs = 300_000) { + return new Promise((resolveRun, reject) => { + const child = spawn('/usr/local/bin/dsh', ['plugin', '--profile', profile, 'remove', name], { + env: pluginEnvironment(dshHome), + stdio: ['ignore', 'pipe', 'pipe'], + }) + const stdout = [] + const stderr = [] + let size = 0 + let settled = false + let timeoutError + let killTimer + const finish = (error) => { + if (settled) return + settled = true + clearTimeout(timer) + clearTimeout(killTimer) + if (error === undefined) resolveRun() + else reject(error) + } + const timer = setTimeout(() => { + timeoutError = new Error(`dsh plugin remove timed out for ${name}`) + child.kill('SIGTERM') + killTimer = setTimeout(() => child.kill('SIGKILL'), 5_000) + killTimer.unref() + }, timeoutMs) + timer.unref() + for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { + size += chunk.byteLength + if (size <= 256 * 1024) (stream === child.stdout ? stdout : stderr).push(chunk) + }) + child.once('error', finish) + child.once('exit', code => timeoutError !== undefined + ? finish(timeoutError) + : code === 0 + ? finish() + : finish(new Error(`dsh plugin remove failed for ${name}: ${[ + ['stdout', Buffer.concat(stdout).toString('utf8').trim()], + ['stderr', Buffer.concat(stderr).toString('utf8').trim()], + ].filter(([, value]) => value !== '').map(([stream, value]) => `${stream}: ${value}`).join('\\n')}`))) + }) +} + +function message(error) { + return error instanceof Error ? error.message : String(error) +} + +function validateActions(inventory, actions) { + if (!Array.isArray(actions) || actions.length === 0 || actions.length > 128) { + throw new Error('User Plugin actions must be a non-empty bounded array') + } + const plugins = new Map(inventory.plugins.map(plugin => [plugin.name, plugin])) + const names = new Set() + return Object.freeze(actions.map(value => { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).sort().join(',') !== 'action,name' + || !userPluginInternals.PACKAGE_NAME_PATTERN.test(value.name) + || !['enable', 'disable', 'uninstall'].includes(value.action) + || names.has(value.name)) { + throw new Error('User Plugin action is invalid') + } + names.add(value.name) + const plugin = plugins.get(value.name) + if (plugin === undefined) throw new Error(`User Plugin ${value.name} is not managed by the Web Profile`) + if (value.action === 'enable' && (plugin.damaged || plugin.reservedNameConflict)) { + throw new Error(`User Plugin ${value.name} cannot be enabled`) + } + return Object.freeze({ name: value.name, action: value.action }) + })) +} + +async function mutateProfile({ inventory, selectionStore, actions, runPlugin }) { + const manifestPath = join(inventory.profileRoot, 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + const bundles = [...(manifest.dsh?.profile?.bundles ?? [])] + const originalBundles = [...bundles] + const selection = await selectionStore.read() + const disabled = new Map(selection.disabled.map(entry => [entry.name, entry.index])) + for (const { name, action } of actions) { + if (action === 'disable') { + const index = bundles.indexOf(name) + if (index !== -1) { + disabled.set(name, originalBundles.indexOf(name)) + for (let found = bundles.indexOf(name); found !== -1; found = bundles.indexOf(name)) bundles.splice(found, 1) + } + } else if (action === 'enable') { + if (!bundles.includes(name)) bundles.splice(Math.min(disabled.get(name) ?? bundles.length, bundles.length), 0, name) + disabled.delete(name) + } else if (action === 'uninstall') { + for (let found = bundles.indexOf(name); found !== -1; found = bundles.indexOf(name)) bundles.splice(found, 1) + } + } + manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } } + await durableReplace(manifestPath, `${canonicalJson(manifest).toString('utf8')}\n`) + for (const { name, action } of actions) { + if (action === 'uninstall') { + await runPlugin({ dshHome: join(inventory.profileRoot, '..', '..'), profile: inventory.profile, name }) + disabled.delete(name) + } + } + if (actions.some(({ action }) => action === 'uninstall')) { + const installedManifest = JSON.parse(await readFile(manifestPath, 'utf8')) + installedManifest.dsh = { + ...installedManifest.dsh, + profile: { ...installedManifest.dsh?.profile, bundles }, + } + await durableReplace(manifestPath, `${canonicalJson(installedManifest).toString('utf8')}\n`) + } + await selectionStore.write({ + schema: 1, + disabled: [...disabled].map(([name, index]) => ({ name, index })), + }) + const resulting = await inventory.read() + const result = new Map(resulting.plugins.map(plugin => [plugin.name, plugin])) + for (const { name, action } of actions) { + if (action === 'uninstall' && result.has(name)) throw new Error(`User Plugin ${name} remains installed`) + if (action === 'enable' && result.get(name)?.enabled !== true) throw new Error(`User Plugin ${name} was not enabled`) + if (action === 'disable' && result.get(name)?.enabled !== false) throw new Error(`User Plugin ${name} was not disabled`) + } + return resulting +} + +export class UserPluginTransactionManager { + constructor({ inventory, selectionStore, snapshots, journal, pauseDsh, restartDsh, runPlugin = runDshPlugin, report = async () => {} }) { + this.inventory = inventory + this.selectionStore = selectionStore + this.snapshots = snapshots + this.journal = journal + this.pauseDsh = pauseDsh + this.restartDsh = restartDsh + this.runPlugin = runPlugin + this.report = report + } + + record(messageValue, fields = {}) { + return Promise.resolve().then(() => this.report(messageValue, fields)).catch(() => {}) + } + + async validate({ revision, actions }) { + const current = await this.inventory.read() + if (revision !== current.revision) { + const error = new Error('User Plugin inventory revision has changed') + error.code = 'REVISION_CONFLICT' + throw error + } + return Object.freeze({ revision, actions: validateActions(current, actions) }) + } + + async apply({ taskId = randomUUID(), revision, actions, strategy = 'restart', onProgress = () => {} }) { + if (!['restart', 'next-start'].includes(strategy)) throw new Error('User Plugin activation strategy is invalid') + const validated = await this.validate({ revision, actions }) + const normalized = validated.actions + const publishProgress = state => { try { onProgress(state) } catch {} } + const selectionBefore = await this.selectionStore.snapshot() + let state = await this.journal.begin({ taskId, revision, actions: normalized, selection: selectionBefore }) + publishProgress(state) + let paused = false + let snapshotCreated = false + await this.record('user-plugin.transaction.started', { taskId, actions: normalized }) + try { + if (strategy === 'restart') { + await this.pauseDsh() + paused = true + state = await this.journal.transition('paused') + publishProgress(state) + } + await this.snapshots.create(taskId) + snapshotCreated = true + state = await this.journal.transition('snapshotted', { snapshotId: taskId }) + publishProgress(state) + state = await this.journal.transition('mutating') + publishProgress(state) + const result = await mutateProfile({ + inventory: this.inventory, + selectionStore: this.selectionStore, + actions: normalized, + runPlugin: this.runPlugin, + }) + state = await this.journal.transition('committed') + publishProgress(state) + let completedStrategy = strategy + let activationError = null + if (strategy === 'restart') { + state = await this.journal.transition('restarting') + publishProgress(state) + try { + await this.restartDsh() + } catch (error) { + completedStrategy = 'next-start' + activationError = message(error) + await this.record('user-plugin.activation.deferred', { taskId, error }) + } + } + state = await this.journal.transition('completed') + publishProgress(state) + await this.snapshots.remove(taskId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', { taskId, error })) + await this.record('user-plugin.transaction.completed', { taskId }) + return Object.freeze({ + taskId, status: 'success', strategy: completedStrategy, activationError, inventory: result, + }) + } catch (error) { + const committed = ['committed', 'restarting'].includes(state.phase) + if (committed) { + await this.journal.transition('failed', { error: message(error) }) + await this.snapshots.remove(taskId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', { taskId, error: cleanupError })) + } else if (snapshotCreated || state.snapshotId !== null) { + const snapshotId = state.snapshotId ?? taskId + try { + if (state.phase !== 'restoring') state = await this.journal.transition('restoring', { + error: message(error), + snapshotId, + }) + await this.snapshots.restore(snapshotId) + await this.selectionStore.restore(selectionBefore) + if (strategy === 'restart') await this.restartDsh() + await this.journal.transition('failed', { recoveryResult: 'success' }) + await this.snapshots.remove(snapshotId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', { + taskId, + error: cleanupError, + })) + } catch (recoveryError) { + const aggregate = new AggregateError([error, recoveryError], 'User Plugin transaction and recovery failed') + aggregate.journal = await this.journal.transition('failed', { + error: `${message(error)}; recovery failed: ${message(recoveryError)}`, + recoveryResult: 'failed', + }).catch(() => {}) + throw aggregate + } + } else if (paused) { + try { + await this.restartDsh() + await this.journal.transition('failed', { error: message(error), recoveryResult: 'success' }) + } catch (recoveryError) { + const aggregate = new AggregateError([error, recoveryError], 'User Plugin transaction and recovery failed') + aggregate.journal = await this.journal.transition('failed', { + error: `${message(error)}; recovery failed: ${message(recoveryError)}`, + recoveryResult: 'failed', + }).catch(() => {}) + throw aggregate + } + } else { + await this.journal.transition('failed', { error: message(error) }).catch(() => {}) + } + await this.record('user-plugin.transaction.failed', { taskId, error, committed }) + if (error !== null && typeof error === 'object') error.journal = await this.journal.read().catch(() => undefined) + throw error + } + } + + async recover() { + const state = await this.journal.read() + if (state === undefined) return undefined + if (['completed', 'failed'].includes(state.phase)) { + if (state.snapshotId !== null) await this.snapshots.remove(state.snapshotId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', { + taskId: state.taskId, + error, + })) + return state + } + if (['committed', 'restarting'].includes(state.phase)) { + try { + if (state.phase === 'committed') await this.journal.transition('restarting') + await this.restartDsh() + const completed = await this.journal.transition('completed') + if (state.snapshotId !== null) await this.snapshots.remove(state.snapshotId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', { + taskId: state.taskId, + error, + })) + return completed + } catch (error) { + const failed = await this.journal.transition('failed', { error: message(error) }) + if (state.snapshotId !== null) { + await this.snapshots.remove(state.snapshotId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', { + taskId: state.taskId, + error: cleanupError, + })) + } + return failed + } + } + if (state.phase === 'validated') return this.journal.transition('failed', { error: 'transaction interrupted before DSH pause' }) + await this.pauseDsh() + if (state.phase === 'paused') { + try { + await this.restartDsh() + return this.journal.transition('failed', { error: 'transaction interrupted before snapshot', recoveryResult: 'success' }) + } catch (error) { + return this.journal.transition('failed', { error: message(error), recoveryResult: 'failed' }) + } + } + try { + const restoring = state.phase === 'restoring' ? state : await this.journal.transition('restoring', { + error: state.error ?? 'transaction interrupted before commit', + }) + await this.snapshots.restore(restoring.snapshotId) + await this.selectionStore.restore({ + present: restoring.selectionPresent, + state: { schema: 1, disabled: restoring.previousDisabled }, + }) + await this.restartDsh() + const failed = await this.journal.transition('failed', { recoveryResult: 'success' }) + await this.snapshots.remove(restoring.snapshotId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', { + taskId: state.taskId, + error, + })) + return failed + } catch (error) { + return this.journal.transition('failed', { error: message(error), recoveryResult: 'failed' }) + } + } + + async recoverBeforeDshStart() { + return recoverUserPluginBeforeDshStart({ + journal: this.journal, + report: (messageValue, fields) => this.record(messageValue, fields), + selectionStore: this.selectionStore, + snapshots: this.snapshots, + }) + } + + async completeDshStartup({ healthy, error = null }) { + const state = await this.journal.read() + if (state === undefined || state.phase !== 'restarting') return state + const next = healthy + ? await this.journal.transition('completed') + : await this.journal.transition('failed', { error: error ?? 'DSH failed to start after the committed User Plugin change' }) + if (state.snapshotId !== null) { + await this.snapshots.remove(state.snapshotId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', { + taskId: state.taskId, + error: cleanupError, + })) + } + return next + } +} + +export async function recoverUserPluginBeforeDshStart({ journal, selectionStore, snapshots, report = async () => {} }) { + const record = (messageValue, fields) => Promise.resolve() + .then(() => report(messageValue, fields)) + .catch(() => {}) + let state = await journal.read() + if (state === undefined) return undefined + if (['completed', 'failed'].includes(state.phase)) { + if (state.snapshotId !== null) await snapshots.remove(state.snapshotId).catch(error => record( + 'user-plugin.snapshot.cleanup.failed', { taskId: state.taskId, error }, + )) + return state + } + if (state.phase === 'validated') { + return journal.transition('failed', { + error: 'transaction interrupted before DSH pause', recoveryResult: 'success', + }) + } + if (state.phase === 'paused') { + return journal.transition('failed', { + error: 'transaction interrupted before snapshot', recoveryResult: 'success', + }) + } + if (['committed', 'restarting'].includes(state.phase)) { + if (state.phase === 'committed') state = await journal.transition('restarting') + return state + } + try { + if (state.phase !== 'restoring') state = await journal.transition('restoring', { + error: state.error ?? 'transaction interrupted before commit', + }) + await snapshots.restore(state.snapshotId) + await selectionStore.restore({ + present: state.selectionPresent, + state: { schema: 1, disabled: state.previousDisabled }, + }) + const failed = await journal.transition('failed', { recoveryResult: 'success' }) + await snapshots.remove(state.snapshotId).catch(error => record( + 'user-plugin.snapshot.cleanup.failed', { taskId: state.taskId, error }, + )) + return failed + } catch (error) { + return journal.transition('failed', { error: message(error), recoveryResult: 'failed' }) + } +} + +export const userPluginTransactionInternals = Object.freeze({ mutateProfile, pluginEnvironment, runDshPlugin, validateActions }) diff --git a/upstream/container/control-plane/modules/skill-manager/index.mjs b/upstream/container/control-plane/modules/skill-manager/index.mjs new file mode 100644 index 0000000..9921801 --- /dev/null +++ b/upstream/container/control-plane/modules/skill-manager/index.mjs @@ -0,0 +1,297 @@ +import { createHash, randomUUID } from 'node:crypto' +import { spawn } from 'node:child_process' +import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { canonicalJson } from '../../../platform/lib/canonical-json.mjs' +import { parseEnvironmentManifest } from '../../../platform/lib/contracts.mjs' +import { hashTree } from '../../../platform/lib/tree-hash.mjs' + +const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const STATE_SCHEMA = 1 +export const SYSTEM_SKILL_CATALOG_ARTIFACT_ID = 'system-skill-catalog' +export const SYSTEM_SKILL_CATALOG_MEDIA_TYPE = 'application/vnd.dsh-platform.system-skill-catalog.v1+tar+gzip' + +function runTar(args, { capture = false } = {}) { + return new Promise((resolveTar, reject) => { + const child = spawn('tar', args, { stdio: ['ignore', capture ? 'pipe' : 'ignore', 'pipe'] }) + const stdout = [] + const stderr = [] + child.stdout?.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.once('error', reject) + child.once('exit', code => code === 0 + ? resolveTar(Buffer.concat(stdout).toString('utf8')) + : reject(new Error(`System Skill archive failed: ${Buffer.concat(stderr).toString('utf8')}`))) + }) +} + +async function exists(path) { + return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error)) +} + +export async function retainLegacySystemSkillCatalog({ sourceRoot, outputRoot }) { + const source = resolve(sourceRoot) + const destination = join(resolve(outputRoot), 'legacy-bootstrap') + if (await exists(join(destination, 'catalog.json'))) { + await readSystemSkillCatalog(destination) + return destination + } + await readSystemSkillCatalog(source) + const staging = `${destination}.${randomUUID()}.tmp` + try { + await cp(source, staging, { recursive: true, verbatimSymlinks: true }) + try { + await rename(staging, destination) + } catch (error) { + if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error + } + } finally { + await rm(staging, { recursive: true, force: true }) + } + await readSystemSkillCatalog(destination) + return destination +} + +export async function materializeSystemSkillCatalog({ environmentRoot, outputRoot }) { + const root = resolve(environmentRoot) + const manifest = parseEnvironmentManifest(await readFile(join(root, 'environment.manifest.json'))) + const artifact = manifest.artifacts.find(entry => entry.id === SYSTEM_SKILL_CATALOG_ARTIFACT_ID) + if (artifact === undefined) return undefined + if (artifact.mediaType !== SYSTEM_SKILL_CATALOG_MEDIA_TYPE) { + throw new Error('System Skill catalog Artifact media type is invalid') + } + const archive = join(root, 'artifacts', artifact.id) + const bytes = await readFile(archive) + if (bytes.byteLength !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) { + throw new Error('System Skill catalog Artifact differs from its Environment manifest') + } + const destination = join(resolve(outputRoot), artifact.sha256) + if (!await exists(destination)) { + const staging = `${destination}.${randomUUID()}.tmp` + await mkdir(staging, { recursive: true }) + try { + const entries = (await runTar(['-tzf', archive], { capture: true })).split('\n').filter(Boolean) + if (entries.length === 0 || entries.some(entry => ( + entry.startsWith('/') || !entry.startsWith('skills/') || entry.split('/').includes('..') + ))) throw new Error('System Skill catalog archive contains an unsafe path') + await runTar(['-xzf', archive, '--no-same-owner', '--no-same-permissions', '--strip-components=1', '-C', staging]) + await readSystemSkillCatalog(staging) + try { + await rename(staging, destination) + } catch (error) { + if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error + } + } finally { + await rm(staging, { recursive: true, force: true }) + } + } + await readSystemSkillCatalog(destination) + return destination +} + +function exactKeys(value, keys, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).sort().join(',') !== [...keys].sort().join(',')) { + throw new Error(`${label} is invalid`) + } + return value +} + +function skillId(value) { + if (typeof value !== 'string' || !ID_PATTERN.test(value) || value.length > 64) { + throw new Error('System Skill ID is invalid') + } + return value +} + +function description(value, label) { + exactKeys(value, ['en', 'zh'], label) + for (const language of ['en', 'zh']) { + if (typeof value[language] !== 'string' || value[language].length === 0 || value[language].length > 240) { + throw new Error(`${label}.${language} is invalid`) + } + } + return Object.freeze({ en: value.en, zh: value.zh }) +} + +export async function readSystemSkillCatalog(root) { + const sourceRoot = resolve(root) + const document = JSON.parse(await readFile(join(sourceRoot, 'catalog.json'), 'utf8')) + exactKeys(document, ['schema', 'skills'], 'System Skill catalog') + if (document.schema !== 1 || !Array.isArray(document.skills)) throw new Error('System Skill catalog is invalid') + const ids = new Set() + const skills = [] + for (const entry of document.skills) { + exactKeys(entry, ['description', 'id', 'source'], 'System Skill catalog entry') + const id = skillId(entry.id) + if (ids.has(id)) throw new Error(`duplicate System Skill ID ${id}`) + ids.add(id) + if (typeof entry.source !== 'string' || entry.source !== id) throw new Error(`System Skill ${id} source is invalid`) + const source = resolve(sourceRoot, entry.source) + if (!source.startsWith(`${sourceRoot}/`)) throw new Error(`System Skill ${id} source escapes its catalog`) + const metadata = await lstat(join(source, 'SKILL.md')) + if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`System Skill ${id} SKILL.md is invalid`) + const bytes = await readFile(join(source, 'SKILL.md'), 'utf8') + if (!bytes.startsWith('---\n') || !bytes.includes(`\nname: ${id}\n`)) { + throw new Error(`System Skill ${id} frontmatter does not match its catalog`) + } + skills.push(Object.freeze({ + id, + source, + sha256: await hashTree(source), + description: description(entry.description, `System Skill ${id} description`), + })) + } + return Object.freeze(skills) +} + +function defaults(catalog) { + return Object.fromEntries(catalog.map(skill => [skill.id, { installed: true, enabled: true }])) +} + +function normalizeState(value, catalog) { + const fallback = defaults(catalog) + if (value === undefined) return fallback + exactKeys(value, ['schema', 'skills'], 'System Skill selection state') + if (value.schema !== STATE_SCHEMA || value.skills === null || typeof value.skills !== 'object' || Array.isArray(value.skills)) { + throw new Error('System Skill selection state is invalid') + } + for (const skill of catalog) { + const saved = value.skills[skill.id] + if (saved === undefined) continue + exactKeys(saved, ['enabled', 'installed'], `System Skill ${skill.id} selection`) + if (typeof saved.installed !== 'boolean' || typeof saved.enabled !== 'boolean' || (saved.enabled && !saved.installed)) { + throw new Error(`System Skill ${skill.id} selection is invalid`) + } + fallback[skill.id] = { installed: saved.installed, enabled: saved.enabled } + } + return fallback +} + +export class SystemSkillSelectionStore { + constructor(path) { + this.path = resolve(path) + } + + async read(catalog) { + let value + try { + value = JSON.parse(await readFile(this.path, 'utf8')) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + return normalizeState(value, catalog) + } + + async write(catalog, state) { + const normalized = normalizeState({ schema: STATE_SCHEMA, skills: state }, catalog) + const document = { + schema: STATE_SCHEMA, + skills: Object.fromEntries(catalog.map(skill => [skill.id, normalized[skill.id]])), + } + await mkdir(dirname(this.path), { recursive: true }) + const temporary = `${this.path}.${randomUUID()}.tmp` + try { + await writeFile(temporary, `${canonicalJson(document)}\n`, { flag: 'wx', mode: 0o600 }) + await rename(temporary, this.path) + } finally { + await rm(temporary, { force: true }) + } + return normalized + } +} + +async function replaceSkillLink(viewRoot, skill) { + const path = join(viewRoot, skill.id) + const existing = await lstat(path).catch(error => error?.code === 'ENOENT' ? undefined : Promise.reject(error)) + if (existing?.isSymbolicLink() && await readlink(path) === skill.source) return + const temporary = join(viewRoot, `.${skill.id}.${randomUUID()}.tmp`) + try { + await symlink(skill.source, temporary, 'dir') + await rename(temporary, path) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +export async function synchronizeSystemSkillView({ catalog, selection, viewRoot }) { + const root = resolve(viewRoot) + await mkdir(root, { recursive: true }) + const expected = new Set(catalog.filter(skill => selection[skill.id].enabled).map(skill => skill.id)) + for (const entry of await readdir(root, { withFileTypes: true })) { + if (!expected.has(entry.name)) await rm(join(root, entry.name), { recursive: true, force: true }) + } + for (const skill of catalog) { + if (selection[skill.id].enabled) await replaceSkillLink(root, skill) + } +} + +export class SystemSkillManager { + constructor({ sourceRoot, viewRoot, statePath }) { + this.sourceRoot = resolve(sourceRoot) + this.viewRoot = resolve(viewRoot) + this.store = new SystemSkillSelectionStore(statePath) + } + + async initialize() { + const catalog = await readSystemSkillCatalog(this.sourceRoot) + const selection = await this.store.read(catalog) + await this.store.write(catalog, selection) + await synchronizeSystemSkillView({ catalog, selection, viewRoot: this.viewRoot }) + return this.listFrom(catalog, selection) + } + + async useSourceRoot(sourceRoot) { + const nextRoot = resolve(sourceRoot) + const catalog = await readSystemSkillCatalog(nextRoot) + const selection = await this.store.read(catalog) + await this.store.write(catalog, selection) + await synchronizeSystemSkillView({ catalog, selection, viewRoot: this.viewRoot }) + this.sourceRoot = nextRoot + return this.listFrom(catalog, selection) + } + + listFrom(catalog, selection) { + return Object.freeze(catalog.map(skill => Object.freeze({ + id: skill.id, + sha256: skill.sha256, + description: skill.description, + installed: selection[skill.id].installed, + enabled: selection[skill.id].enabled, + }))) + } + + async list() { + const catalog = await readSystemSkillCatalog(this.sourceRoot) + return this.listFrom(catalog, await this.store.read(catalog)) + } + + async configure(idValue, action) { + const id = skillId(idValue) + if (!['install', 'uninstall', 'enable', 'disable'].includes(action)) throw new Error('System Skill action is invalid') + const catalog = await readSystemSkillCatalog(this.sourceRoot) + if (!catalog.some(skill => skill.id === id)) throw new Error(`System Skill ${id} is not provided by the current Environment`) + const before = await this.store.read(catalog) + const next = structuredClone(before) + const current = next[id] + if (action === 'install') next[id] = { installed: true, enabled: true } + else if (action === 'uninstall') next[id] = { installed: false, enabled: false } + else if (action === 'enable') { + if (!current.installed) throw new Error(`System Skill ${id} must be installed before it can be enabled`) + next[id] = { ...current, enabled: true } + } else next[id] = { ...current, enabled: false } + try { + await this.store.write(catalog, next) + await synchronizeSystemSkillView({ catalog, selection: next, viewRoot: this.viewRoot }) + return this.listFrom(catalog, next) + } catch (error) { + try { + await this.store.write(catalog, before) + await synchronizeSystemSkillView({ catalog, selection: before, viewRoot: this.viewRoot }) + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], 'System Skill operation and rollback failed') + } + throw error + } + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/activator.mjs b/upstream/container/control-plane/modules/updater/lib/activator.mjs new file mode 100644 index 0000000..469386a --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/activator.mjs @@ -0,0 +1,229 @@ +import { spawn } from 'node:child_process' +import { cp, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { RuntimeSlots } from '../../patch-manager/index.mjs' +import { PlatformPaths } from '../../../../platform/lib/paths.mjs' +import { LocalApiClient } from './client.mjs' +import { ManagedDeploymentBuilder } from './managed-store.mjs' +import { deriveRecordId, parseDeploymentRecord } from '../../../../platform/lib/deployment-contracts.mjs' + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + const stdout = [] + const stderr = [] + child.stdout.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.once('error', reject) + child.once('exit', code => code === 0 + ? resolve(Buffer.concat(stdout)) + : reject(new Error(Buffer.concat(stderr).toString('utf8')))) + }) +} + +async function exists(path) { + return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error)) +} + +function matchesRecoveryJournal(record, deployment) { + if (record?.id === deployment.runtime) return true + if (record === null || record === undefined) return false + if (!Array.isArray(record.receiptTokens) || !Array.isArray(deployment.receiptTokens)) return false + const sameReceipts = [...record.receiptTokens].sort().join('\0') + === [...deployment.receiptTokens].sort().join('\0') + return record.dshVersion === deployment.dsh + && record.environmentVersion === deployment.environment + && record.snapshotId === deployment.dataSnapshot + && sameReceipts +} + +export class PlatformActivator { + constructor({ + dataRoot, + runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform', + bootstrap, + stage0, + builder, + sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), + }) { + this.dataRoot = dataRoot + this.paths = new PlatformPaths(dataRoot, runRoot) + this.bootstrap = bootstrap ?? new LocalApiClient(this.paths.bootstrapSocket) + this.stage0 = stage0 ?? new LocalApiClient(this.paths.trustSocket) + this.builder = builder ?? new ManagedDeploymentBuilder({ paths: this.paths }) + this.sleep = sleep + this.runtimeSlots = new RuntimeSlots(this.paths.runtimesRoot) + this.environmentSlots = new RuntimeSlots(this.paths.environmentsRoot) + this.systemPluginSlots = new RuntimeSlots(this.paths.systemPluginsRoot) + this.experimentalCandidates = new Map() + } + + prepareManaged(prepared, options) { + return this.builder.buildStable(prepared, options) + } + + async pristine(version, receipt) { + const root = join(this.paths.pristineRoot, version) + if (!await exists(root)) { + const staging = `${root}.${randomUUID()}.tmp` + await mkdir(staging, { recursive: true }) + try { + const entries = (await run('tar', ['-tzf', receipt.path])).toString('utf8').split('\n').filter(Boolean) + if (!entries.includes('package/package.json')) throw new Error('official DSH has no package metadata') + if (entries.some(entry => entry.startsWith('/') || !entry.startsWith('package/') || entry.split('/').includes('..'))) { + throw new Error('official DSH archive contains an unsafe path') + } + await run('tar', ['-xzf', receipt.path, '-C', staging]) + await rename(staging, root) + } catch (error) { + await rm(staging, { recursive: true, force: true }) + throw error + } + } + const packageMetadata = JSON.parse(await readFile(join(root, 'package', 'package.json'), 'utf8')) + if (packageMetadata.name !== '@deepseek-ai/dsh' || packageMetadata.version !== version) { + throw new Error('official DSH package metadata differs from its requested version') + } + return join(root, 'package') + } + + async stageEnvironment(prepared) { + const version = prepared.environment.manifest.version + const versions = join(this.paths.environmentsRoot, 'versions') + const destination = join(versions, version) + if (await exists(destination)) return version + const staging = join(versions, `.${version}.${randomUUID()}.tmp`) + await mkdir(join(staging, 'artifacts'), { recursive: true }) + try { + await cp(prepared.paths.get(prepared.stable.desired.environment.manifestArtifactId), join(staging, 'environment.manifest.json')) + for (const descriptor of prepared.environment.manifest.artifacts) { + await cp(prepared.paths.get(descriptor.id), join(staging, 'artifacts', descriptor.id)) + } + await rename(staging, destination) + return version + } catch (error) { + await rm(staging, { recursive: true, force: true }) + throw error + } + } + + async activate(prepared, { onProgress = async () => {}, onSwitching = async () => {} } = {}) { + const desiredBootstrap = prepared.stable.desired.bootstrap.version + const artifacts = prepared.bootstrap.manifest.artifacts + if (artifacts.length !== 1) throw new Error('Bootstrap manifest must contain exactly one package') + const receipt = prepared.receipts.get(artifacts[0].id) + if (receipt === undefined) throw new Error('Bootstrap package receipt is required') + const staged = await this.stage0.stageBootstrap(receipt.token, desiredBootstrap) + // Older Stage-0 responses have only status=switching. Preserve that handoff + // contract while allowing content-identical targets to continue in place. + if (staged.restartRequired === true || staged.status === 'switching') await new Promise(() => {}) + const managed = await this.prepareManaged(prepared, { onProgress }) + await onSwitching() + await this.bootstrap.request('POST', '/v1/deployments/activate', { record: managed.record }) + return managed.record + } + + async rollback(recordId) { + return this.bootstrap.request('POST', '/v1/deployments/rollback', recordId === undefined ? {} : { recordId }) + } + + rollbackDeployments() { + return this.bootstrap.request('GET', '/v1/deployments/rollback-plan') + } + + async currentDeployment({ required = true } = {}) { + let record + for (let attempt = 0; attempt < 100; attempt += 1) { + ({ record } = await this.bootstrap.request('GET', '/v1/deployments/current')) + if (record !== null || !required) break + const status = await this.bootstrap.request('GET', '/v1/status') + if (status.startupComplete === true) break + await this.sleep(100) + } + if (record === null) { + if (required) throw new Error('current Deployment is required') + return null + } + return Object.freeze({ + authority: record.authority, + targetSequence: record.targetSequence, + dsh: record.dshVersion, + runtime: record.id, + environment: record.environmentVersion, + dataSnapshot: record.snapshotId, + receiptTokens: Object.freeze([...record.receiptTokens]), + }) + } + + async prepareExperimental(prepared, { targetSequence }) { + const { record: current } = await this.bootstrap.request('GET', '/v1/deployments/current') + if (current === null) throw new Error('current Deployment is required') + const receiptTokens = await this.experimentalActivationTokens(prepared.receiptTokens) + const built = await this.builder.buildExperimental(prepared, current, receiptTokens, { targetSequence }) + this.experimentalCandidates.set(built.record.id, built.record) + return Object.freeze({ + runtimeId: built.record.id, + environmentVersion: built.record.environmentVersion, + dshVersion: built.record.dshVersion, + }) + } + + suspendDsh() { return this.bootstrap.request('POST', '/v1/components/dsh-runtime/suspend') } + resumeDsh() { return this.bootstrap.request('POST', '/v1/components/dsh-runtime/resume') } + health() { return this.bootstrap.request('GET', '/v1/health') } + + async experimentalActivationTokens(experimentalTokens) { + const active = (await this.stage0.activeReceipts()).receipts + return Object.freeze([ + ...active.filter(receipt => receipt.authorityType === 'stable').map(receipt => receipt.token), + ...experimentalTokens, + ]) + } + + async switchExperimental(runtimeId) { + const record = this.experimentalCandidates.get(runtimeId) + if (record === undefined) throw new Error('Experimental Deployment candidate is not prepared') + return this.bootstrap.request('POST', '/v1/deployments/candidate', { record }) + } + + async bindExperimentalSnapshot(runtimeId, snapshotId) { + const record = this.experimentalCandidates.get(runtimeId) + if (record === undefined) throw new Error('Experimental Deployment candidate is not prepared') + const content = { ...record, snapshotId } + delete content.id + const bound = parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) }) + this.experimentalCandidates.delete(runtimeId) + this.experimentalCandidates.set(bound.id, bound) + return bound.id + } + + async commitExperimental(runtimeId) { + const result = await this.bootstrap.request('POST', '/v1/deployments/candidate/commit', { recordId: runtimeId }) + this.experimentalCandidates.delete(runtimeId) + return result + } + + async restoreDeployment(deployment, { resume = true } = {}) { + await this.bootstrap.request('POST', '/v1/deployments/candidate/cancel') + const { record } = await this.bootstrap.request('GET', '/v1/deployments/current') + if (!matchesRecoveryJournal(record, deployment)) { + const { previous } = await this.rollbackDeployments() + if (previous === null) throw new Error('previous Deployment is unavailable') + if (!matchesRecoveryJournal(previous, deployment)) { + throw new Error('previous Deployment differs from the recovery journal') + } + await this.bootstrap.request('POST', '/v1/deployments/rollback', { recordId: previous.id }) + } + if (resume) await this.resumeDsh() + } + + async cleanup() { + const platform = await this.bootstrap.request('POST', '/v1/platform/gc') + return Object.freeze({ + platform, + bootstrap: await this.stage0.collectBootstrap(), + trustedObjects: (await this.stage0.collectGarbage()).removed, + }) + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/automatic-check.mjs b/upstream/container/control-plane/modules/updater/lib/automatic-check.mjs new file mode 100644 index 0000000..4de22d5 --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/automatic-check.mjs @@ -0,0 +1,170 @@ +import { readFile } from 'node:fs/promises' +import { durableReplace } from '../../../../platform/lib/atomic.mjs' +import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs' + +export const AUTOMATIC_CHECK_INTERVALS = Object.freeze([3_600, 10_800, 21_600, 43_200, 86_400]) + +const DEFAULT_STATE = Object.freeze({ + schema: 1, + automaticCheck: Object.freeze({ + enabled: true, + intervalSeconds: 21_600, + notificationsEnabled: true, + }), + latestAutomatic: Object.freeze({ stable: null, upstream: null }), +}) + +function exactKeys(value, expected, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`) + const keys = Object.keys(value).sort() + const wanted = [...expected].sort() + if (keys.length !== wanted.length || keys.some((key, index) => key !== wanted[index])) { + throw new Error(`${label} fields are invalid`) + } +} + +function parseConfig(value) { + exactKeys(value, ['enabled', 'intervalSeconds', 'notificationsEnabled'], 'automatic check config') + if (typeof value.enabled !== 'boolean') throw new Error('automatic check enabled must be boolean') + if (!AUTOMATIC_CHECK_INTERVALS.includes(value.intervalSeconds)) throw new Error('automatic check interval is invalid') + if (typeof value.notificationsEnabled !== 'boolean') throw new Error('update notifications enabled must be boolean') + return Object.freeze({ ...value }) +} + +function parseStable(value) { + if (value === null) return null + exactKeys(value, ['checkedAt', 'dsh', 'environment', 'targetSequence'], 'Stable notification') + if (!Number.isSafeInteger(value.targetSequence) || value.targetSequence < 1) throw new Error('Stable notification sequence is invalid') + compareDshVersions(value.dsh, value.dsh) + if (typeof value.environment !== 'string' || value.environment.length === 0) throw new Error('Stable notification Environment is invalid') + if (Number.isNaN(Date.parse(value.checkedAt))) throw new Error('Stable notification timestamp is invalid') + return Object.freeze({ ...value }) +} + +function parseUpstream(value) { + if (value === null) return null + exactKeys(value, ['checkedAt', 'version'], 'Upstream notification') + compareDshVersions(value.version, value.version) + if (Number.isNaN(Date.parse(value.checkedAt))) throw new Error('Upstream notification timestamp is invalid') + return Object.freeze({ ...value }) +} + +function parseState(value) { + exactKeys(value, ['automaticCheck', 'latestAutomatic', 'schema'], 'automatic check state') + if (value.schema !== 1) throw new Error('automatic check state schema must be 1') + exactKeys(value.latestAutomatic, ['stable', 'upstream'], 'automatic notification state') + return Object.freeze({ + schema: 1, + automaticCheck: parseConfig(value.automaticCheck), + latestAutomatic: Object.freeze({ + stable: parseStable(value.latestAutomatic.stable), + upstream: parseUpstream(value.latestAutomatic.upstream), + }), + }) +} + +function isHeld(version, environment, holds) { + return holds.some(hold => hold.dshVersion === version + && (hold.type !== 'combination' || hold.environmentVersion === environment)) +} + +function stableSatisfied(candidate, current) { + return current.environment === candidate.environment && compareDshVersions(current.dsh, candidate.dsh) >= 0 +} + +export class AutomaticCheckStateStore { + constructor(path, now = () => new Date()) { + this.path = path + this.now = now + this.pending = Promise.resolve() + } + + async read() { + try { + return parseState(JSON.parse(await readFile(this.path, 'utf8'))) + } catch (error) { + if (error?.code === 'ENOENT') return DEFAULT_STATE + throw error + } + } + + async replace(update) { + const operation = this.pending.then(async () => { + const next = parseState(await update(await this.read())) + await durableReplace(this.path, `${JSON.stringify(next)}\n`) + return next + }) + this.pending = operation.catch(() => {}) + return operation + } + + configure(value) { + const config = parseConfig(value) + return this.replace(previous => ({ + ...previous, + automaticCheck: config, + latestAutomatic: !config.enabled || !config.notificationsEnabled + ? { stable: null, upstream: null } + : previous.latestAutomatic, + })) + } + + record({ channel, current, target, upstream, stableAvailable, holds = [] }) { + return this.replace(previous => { + if (!previous.automaticCheck.enabled || !previous.automaticCheck.notificationsEnabled) return previous + const checkedAt = this.now().toISOString() + let stable = previous.latestAutomatic.stable + let latestUpstream = previous.latestAutomatic.upstream + + if (stable !== null && stableSatisfied(stable, current)) stable = null + if ( + stableAvailable === true + && (stable === null || target.targetSequence > stable.targetSequence) + ) { + stable = { + targetSequence: target.targetSequence, + dsh: target.desired.dsh.version, + environment: target.desired.environment.version, + checkedAt, + } + } + + if (channel !== 'experimental') { + latestUpstream = null + } else { + if (latestUpstream !== null && compareDshVersions(current.dsh, latestUpstream.version) >= 0) latestUpstream = null + if ( + upstream !== null + && compareDshVersions(upstream.version, current.dsh) > 0 + && !isHeld(upstream.version, target.desired.environment.version, holds) + && (latestUpstream === null || compareDshVersions(upstream.version, latestUpstream.version) > 0) + ) latestUpstream = { version: upstream.version, checkedAt } + } + + return { ...previous, latestAutomatic: { stable, upstream: latestUpstream } } + }) + } + + clearSatisfied(current) { + return this.replace(previous => ({ + ...previous, + latestAutomatic: { + stable: previous.latestAutomatic.stable !== null + && stableSatisfied(previous.latestAutomatic.stable, current) + ? null + : previous.latestAutomatic.stable, + upstream: previous.latestAutomatic.upstream !== null + && compareDshVersions(current.dsh, previous.latestAutomatic.upstream.version) >= 0 + ? null + : previous.latestAutomatic.upstream, + }, + })) + } + + clearUpstream() { + return this.replace(previous => ({ + ...previous, + latestAutomatic: { ...previous.latestAutomatic, upstream: null }, + })) + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/channel-state.mjs b/upstream/container/control-plane/modules/updater/lib/channel-state.mjs new file mode 100644 index 0000000..9a1c914 --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/channel-state.mjs @@ -0,0 +1,169 @@ +import { createHash, randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { durableReplace } from '../../../../platform/lib/atomic.mjs' +import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs' +import { isoTimestamp, plainObject, TrustError } from '../../../../platform/lib/validation.mjs' + +const DEFAULT_STATE = Object.freeze({ + schema: 1, + updateChannel: 'stable', + holds: Object.freeze([]), + experimentalBlocked: null, +}) + +function text(value, label) { + if (typeof value !== 'string' || value === '' || value.length > 512 || value.includes('\0')) { + throw new TrustError(`${label} is invalid`) + } + return value +} + +function parseHold(value) { + const hold = plainObject(value, 'update hold') + const keys = Object.keys(hold).sort() + const expected = ['createdAt', 'dshVersion', 'environmentVersion', 'id', 'reason', 'type'] + if (JSON.stringify(keys) !== JSON.stringify(expected)) throw new TrustError('update hold has unexpected fields') + if (!['version', 'combination'].includes(hold.type)) throw new TrustError('update hold type is invalid') + if (hold.type === 'version' && hold.environmentVersion !== null) throw new TrustError('version hold cannot name an Environment') + if (hold.type === 'combination' && typeof hold.environmentVersion !== 'string') { + throw new TrustError('combination hold requires an Environment') + } + isoTimestamp(hold.createdAt, 'update hold creation time') + return Object.freeze({ + id: text(hold.id, 'update hold ID'), + type: hold.type, + dshVersion: text(hold.dshVersion, 'update hold DSH version'), + environmentVersion: hold.environmentVersion === null ? null : text(hold.environmentVersion, 'update hold Environment'), + reason: text(hold.reason, 'update hold reason'), + createdAt: hold.createdAt, + }) +} + +function parseState(value) { + const state = plainObject(value, 'channel state') + const keys = Object.keys(state).sort() + if (JSON.stringify(keys) !== JSON.stringify(['experimentalBlocked', 'holds', 'schema', 'updateChannel'])) { + throw new TrustError('channel state has unexpected fields') + } + if (state.schema !== 1) throw new TrustError('channel state schema must be 1') + if (!['stable', 'experimental'].includes(state.updateChannel)) throw new TrustError('update channel is invalid') + if (!Array.isArray(state.holds)) throw new TrustError('channel holds must be an array') + const holds = state.holds.map(parseHold) + if (new Set(holds.map(hold => hold.id)).size !== holds.length) throw new TrustError('update hold IDs must be unique') + const blocked = state.experimentalBlocked === null ? null : parseHold(state.experimentalBlocked) + if (blocked !== null && blocked.type !== 'combination') throw new TrustError('Experimental block must identify a combination') + return Object.freeze({ schema: 1, updateChannel: state.updateChannel, holds: Object.freeze(holds), experimentalBlocked: blocked }) +} + +export class ChannelStateStore { + constructor(path, now = () => new Date()) { + this.path = path + this.now = now + this.queue = Promise.resolve() + } + + exclusive(operation) { + const result = this.queue.then(operation, operation) + this.queue = result.then(() => undefined, () => undefined) + return result + } + + async read() { + try { return parseState(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) { + if (error?.code === 'ENOENT') return DEFAULT_STATE + throw error + } + } + + write(value) { return durableReplace(this.path, `${JSON.stringify(parseState(value))}\n`) } + + setChannel(updateChannel) { + return this.exclusive(async () => { + const current = await this.read() + const next = parseState({ ...current, updateChannel }) + await this.write(next) + return next + }) + } + + addHold({ type, dshVersion, environmentVersion = null, reason }) { + return this.exclusive(async () => { + const current = await this.read() + const identity = `${type}\0${dshVersion}\0${environmentVersion ?? ''}` + const id = createHash('sha256').update(identity).digest('hex').slice(0, 24) + const hold = parseHold({ + id, type, dshVersion, environmentVersion, reason, + createdAt: this.now().toISOString(), + }) + const holds = [...current.holds.filter(item => item.id !== id), hold] + const next = parseState({ ...current, holds }) + await this.write(next) + return hold + }) + } + + retry(id) { + return this.exclusive(async () => { + const current = await this.read() + const holds = current.holds.filter(hold => hold.id !== id) + const clearsBlock = current.experimentalBlocked?.id === id + if (holds.length === current.holds.length && !clearsBlock) throw new TrustError('update hold does not exist') + const next = parseState({ ...current, holds, experimentalBlocked: clearsBlock ? null : current.experimentalBlocked }) + await this.write(next) + return next + }) + } + + block({ dshVersion, environmentVersion, reason }) { + return this.exclusive(async () => { + const current = await this.read() + const blocked = parseHold({ + id: `blocked-${randomUUID()}`, type: 'combination', dshVersion, environmentVersion, reason, + createdAt: this.now().toISOString(), + }) + const next = parseState({ ...current, experimentalBlocked: blocked }) + await this.write(next) + return next + }) + } +} + +function held(local, dshVersion, environmentVersion) { + return local.holds.find(hold => ( + hold.dshVersion === dshVersion + && (hold.type === 'version' || hold.environmentVersion === environmentVersion) + )) +} + +export function planDesiredState({ local, current, supported, stableTargetSequence, upstream = null }) { + const aheadOfStable = compareDshVersions(current.dsh, supported.dsh) > 0 + const stableIdentityMismatch = Number.isSafeInteger(stableTargetSequence) && ( + current.targetSequence !== stableTargetSequence + || (local.updateChannel === 'stable' + ? current.authority !== 'stable' + : !['stable', 'experimental'].includes(current.authority)) + ) + const base = { + updateChannel: local.updateChannel, + current, + supported, + upstream, + aheadOfStable, + experimentalBlocked: local.experimentalBlocked, + holds: local.holds, + } + if (aheadOfStable) return Object.freeze({ ...base, action: 'frozen', reason: 'Stable has not caught up with the current DSH' }) + if (compareDshVersions(current.dsh, supported.dsh) < 0 || current.environment !== supported.environment || stableIdentityMismatch) { + return Object.freeze({ ...base, action: 'stable', reason: 'converge the signed Stable target before Experimental DSH' }) + } + if (local.updateChannel === 'stable' || upstream === null) { + return Object.freeze({ ...base, action: 'none', reason: null }) + } + const matchingHold = held(local, upstream.version, supported.environment) + if (matchingHold !== undefined) return Object.freeze({ ...base, action: 'held', reason: matchingHold.reason, hold: matchingHold }) + if ( + local.experimentalBlocked?.dshVersion === upstream.version + && local.experimentalBlocked.environmentVersion === supported.environment + ) return Object.freeze({ ...base, action: 'blocked', reason: local.experimentalBlocked.reason }) + return Object.freeze({ ...base, action: 'experimental', reason: null }) +} diff --git a/upstream/container/control-plane/modules/updater/lib/client.mjs b/upstream/container/control-plane/modules/updater/lib/client.mjs new file mode 100644 index 0000000..a6f5f58 --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/client.mjs @@ -0,0 +1,66 @@ +import { request } from 'node:http' + +export class LocalApiClient { + constructor(socketPath) { + this.socketPath = socketPath + } + + request(method, path, body, extraHeaders = {}) { + return new Promise((resolve, reject) => { + const bytes = body === undefined ? undefined : Buffer.from(JSON.stringify(body)) + const req = request({ + socketPath: this.socketPath, + method, + path, + headers: { + ...extraHeaders, + ...(bytes === undefined ? {} : { + 'content-type': 'application/json', + 'content-length': bytes.byteLength, + }), + }, + }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => { + let value + try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { value = {} } + if ((response.statusCode ?? 500) >= 400) { + const error = new Error(value.error ?? `local API returned ${String(response.statusCode)}`) + error.statusCode = response.statusCode + if (typeof value.code === 'string') error.code = value.code + if (Number.isInteger(value.retryAfterSeconds)) error.retryAfterSeconds = value.retryAfterSeconds + error.localApiPath = path + reject(error) + } else resolve(value) + }) + }) + req.once('error', reject) + req.end(bytes) + }) + } + + status() { return this.request('GET', '/v1/status') } + activeReceipts() { return this.request('GET', '/v1/receipts/active') } + acceptKeyring(document, signature) { + return this.request('POST', '/v1/keyring', { document: document.toString('base64'), signature }) + } + acceptTarget(document, signature) { + return this.request('POST', '/v1/target', { document: document.toString('base64'), signature }) + } + importArtifact(artifactId, sourcePath, parentReceipt = null) { + return this.request('POST', '/v1/artifacts/import', { artifactId, sourcePath, parentReceipt }) + } + ensureOfficialDsh(version, metadataPath, tarballPath) { + return this.request('POST', '/v1/dsh/ensure', { version, metadataPath, tarballPath }) + } + acceptManifest(receipt, signatureReceipt) { + return this.request('POST', '/v1/manifests/accept', { receipt, signatureReceipt }) + } + activate(receipts) { return this.request('POST', '/v1/activate', { receipts }) } + collectGarbage() { return this.request('POST', '/v1/objects/collect', {}) } + stageBootstrap(receipt, version) { + return this.request('POST', '/v1/bootstrap/stage', { receipt, version }) + } + collectBootstrap() { return this.request('POST', '/v1/bootstrap/collect', {}) } +} diff --git a/upstream/container/control-plane/modules/updater/lib/coordinator.mjs b/upstream/container/control-plane/modules/updater/lib/coordinator.mjs new file mode 100644 index 0000000..7b8338c --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/coordinator.mjs @@ -0,0 +1,894 @@ +import { randomUUID } from 'node:crypto' +import { EventEmitter } from 'node:events' +import { recoverInterruptedUpdate } from './recovery.mjs' +import { planDesiredState } from './channel-state.mjs' +import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs' + +export class UpdateConflictError extends Error {} + +function healthMetrics(health) { + const components = Array.isArray(health?.components) ? health.components : [] + return { + readyServices: components.filter(component => component?.healthy === true).length, + totalServices: components.length, + } +} + +function journalDeployment(deployment) { + return Object.freeze({ + dsh: deployment.dsh, + environment: deployment.environment, + runtime: deployment.runtime, + dataSnapshot: deployment.dataSnapshot, + receiptTokens: deployment.receiptTokens, + }) +} + +function metricPercentage(processed, total) { + return typeof processed === 'number' && typeof total === 'number' && total > 0 + ? Math.floor(Math.max(0, Math.min(1, processed / total)) * 100) + : null +} + +function holdReason(message) { + const lines = message.split(/\r?\n/u).map(line => line.trim()).filter(Boolean) + const npmNotarget = lines.find(line => /^npm error notarget\b/u.test(line)) + const selected = npmNotarget?.replace(/^npm error notarget\s*/u, '') ?? lines[0] ?? 'Experimental update failed' + const normalized = selected.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim() + return (normalized || 'Experimental update failed').slice(0, 512) +} + +export class UpdateCoordinator extends EventEmitter { + constructor({ + metadata, preparer, activator, state, npm, journal, snapshots, channelState, completeRecovery, automaticChecks, + allowUnavailableMetadata = false, + probationSeconds = 120, + report = async () => {}, + now = () => new Date(), + sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), + }) { + super() + this.metadata = metadata + this.preparer = preparer + this.activator = activator + this.state = state + this.npm = npm + this.journal = journal + this.snapshots = snapshots + this.channelState = channelState + this.completeRecovery = completeRecovery + this.automaticChecks = automaticChecks + this.allowUnavailableMetadata = allowUnavailableMetadata + this.probationSeconds = probationSeconds + this.report = report + this.now = now + this.sleep = sleep + this.task = undefined + this.checkTask = undefined + this.reportTimes = new Map() + } + + async transition(status, fields = {}) { + const previous = await this.state.read() + const phase = fields.phase ?? ( + status === 'idle' + ? null + : fields.rollbackPhase ?? ( + ['success', 'failed'].includes(status) + ? previous.phase ?? previous.rollbackPhase ?? (['idle', 'success', 'failed'].includes(previous.status) ? null : previous.status) + : status + ) + ) + const contextChanged = phase !== (previous.phase ?? null) + || (fields.operation !== undefined && fields.operation !== previous.operation) + const phaseMetrics = contextChanged ? { + processedBytes: null, + totalBytes: null, + processedItems: null, + totalItems: null, + readyServices: null, + totalServices: null, + detail: null, + } : {} + const value = await this.state.write(status, { ...phaseMetrics, ...fields, phase }) + this.emit('state', value) + await this.record('update.phase.changed', { + status, + phase, + operation: value.operation ?? null, + rollbackPhase: value.rollbackPhase ?? null, + taskId: value.taskId ?? null, + progress: value.progress ?? null, + processedBytes: value.processedBytes ?? null, + totalBytes: value.totalBytes ?? null, + processedItems: value.processedItems ?? null, + totalItems: value.totalItems ?? null, + readyServices: value.readyServices ?? null, + totalServices: value.totalServices ?? null, + detail: value.detail ?? null, + targetSequence: value.targetSequence ?? value.available?.targetSequence ?? null, + outcome: value.outcome ?? null, + ...(value.error === null || value.error === undefined ? {} : { error: value.error, level: 'error' }), + }) + return value + } + + async record(message, fields = {}) { + try { + const current = await this.state.read() + const context = { + ...(current.taskId === null || current.taskId === undefined ? {} : { taskId: current.taskId }), + ...(current.operation === null || current.operation === undefined ? {} : { operation: current.operation }), + ...(current.phase === null || current.phase === undefined + ? (current.status === null || current.status === undefined ? {} : { phase: current.status }) + : { phase: current.phase }), + } + return await this.report(message, { ...context, ...fields }) + } catch {} + } + + async bestEffort(message, operation, fallback, fields = {}, cooldownMs = 0) { + try { + return await operation() + } catch (error) { + const timestamp = this.now().valueOf() + const previous = this.reportTimes.get(message) + if (cooldownMs === 0 || previous === undefined || timestamp - previous >= cooldownMs) { + this.reportTimes.set(message, timestamp) + await this.record(message, { ...fields, error, level: 'warning' }) + } + return fallback + } + } + + async reportHealth(status, fields) { + if (this.activator.health === undefined) return undefined + const health = await this.activator.health() + await this.transition(status, { ...fields, ...healthMetrics(health) }) + return health + } + + hasActiveTask() { + return this.task !== undefined + } + + check(source = 'manual') { + if (this.task !== undefined && source === 'page-open') return Promise.resolve({ busy: true }) + if (this.task !== undefined) return Promise.reject(new UpdateConflictError('an update task is already running')) + if (this.checkTask !== undefined) return this.checkTask + this.checkTask = this.runCheck(source).finally(() => { this.checkTask = undefined }) + return this.checkTask + } + + startTask(operation) { + if (this.task !== undefined) throw new UpdateConflictError('an update task is already running') + const taskId = randomUUID() + const precedingCheck = this.checkTask + this.task = (async () => { + if (precedingCheck !== undefined) { + try { await precedingCheck } catch {} + } + return operation(taskId) + })().finally(() => { this.task = undefined }) + return { taskId, completion: this.task } + } + + async runCheck(source) { + await this.transition('checking', { + checkSource: source, taskId: null, progress: 0, operation: 'check', rollbackPhase: null, error: null, + }) + try { + if (this.allowUnavailableMetadata) return await this.recordUnavailableMetadata(source) + if (this.channelState !== undefined) { + const plan = await this.desiredState() + const rollbackPlan = await this.rollbackPlan() + const updateAvailable = ['stable', 'experimental'].includes(plan.action) + await this.transition('idle', { + available: { + targetSequence: plan.target.targetSequence, + bootstrap: plan.target.desired.bootstrap.version, + environment: plan.target.desired.environment.version, + dsh: plan.target.desired.dsh.version, + }, + supported: plan.supported, + ...(plan.upstream === null ? {} : { upstream: { version: plan.upstream.version } }), + current: { dsh: plan.current.dsh, environment: plan.current.environment, runtime: plan.current.runtime }, + aheadOfStable: plan.aheadOfStable, + experimentalBlocked: plan.experimentalBlocked, + holds: plan.holds, + rollbackPlan, + updateAvailable, + checkedAt: this.now().toISOString(), + metadataUnavailable: false, + remoteCheckError: null, + remoteCheckFailedAt: null, + remoteCheckSource: null, + }) + if (source === 'automatic' && this.automaticChecks !== undefined) { + await this.automaticChecks.record({ + channel: plan.updateChannel, + current: plan.current, + target: plan.target, + upstream: plan.upstream, + stableAvailable: plan.action === 'stable', + holds: plan.holds, + }) + this.emit('state', await this.state.read()) + } + return { value: plan.target, updateAvailable } + } + const target = await this.metadata.check() + await this.transition('idle', { + available: { + targetSequence: target.value.targetSequence, + bootstrap: target.value.desired.bootstrap.version, + environment: target.value.desired.environment.version, + dsh: target.value.desired.dsh.version, + }, + checkedAt: new Date().toISOString(), + metadataUnavailable: false, + remoteCheckError: null, + remoteCheckFailedAt: null, + remoteCheckSource: null, + }) + return target + } catch (error) { + await this.record('update.check.failed', { error, checkSource: source }) + await this.transition('idle', { + error: null, + remoteCheckError: error instanceof Error ? error.message : 'update check failed', + remoteCheckFailedAt: this.now().toISOString(), + remoteCheckSource: error?.remoteCheckSource === 'upstream' ? 'upstream' : 'stable', + }) + throw error + } + } + + async recordUnavailableMetadata(source) { + const local = await this.channelState?.read() + const upstream = local?.updateChannel === 'experimental' && this.npm !== undefined + ? await this.bestEffort('update.upstream-discovery.failed', () => this.npm.discover(), null, { checkSource: source }) + : null + await this.transition('idle', { + metadataUnavailable: true, + ...(upstream === null ? {} : { upstream }), + updateAvailable: false, + checkedAt: this.now().toISOString(), + error: null, + }) + return { unavailable: true, upstream } + } + + start() { + return this.startTask(taskId => this.run(taskId)) + } + + startExperimental() { + if (this.npm === undefined || this.journal === undefined || this.snapshots === undefined) { + throw new Error('Experimental updates are not configured') + } + return this.startTask(taskId => this.runExperimental(taskId)) + } + + startReconcile() { + return this.startTask(taskId => this.runReconcile(taskId)) + } + + async setChannel(updateChannel) { + if (this.task !== undefined) throw new UpdateConflictError('an update task is already running') + if (this.channelState === undefined) throw new Error('update channels are not configured') + const result = await this.channelState.setChannel(updateChannel) + if (updateChannel === 'stable') await this.automaticChecks?.clearUpstream() + await this.record('update.channel.changed', { updateChannel }) + return result + } + + async retryHold(id) { + if (this.task !== undefined) throw new UpdateConflictError('an update task is already running') + if (this.channelState === undefined) throw new Error('update channels are not configured') + const result = await this.channelState.retry(id) + await this.record('update.hold.retried', { holdId: id }) + return result + } + + rollbackPlan() { + if (this.completeRecovery === undefined) return Promise.resolve(null) + return this.completeRecovery.plan() + } + + startCompleteRollback(planId, options = {}) { + if (this.completeRecovery === undefined) throw new Error('complete rollback is not configured') + return this.startTask(taskId => this.runCompleteRollback(taskId, planId, options)) + } + + async runCompleteRollback(taskId, planId, options) { + const operation = options.requireConfirmation ? 'return-stable' : 'rollback' + try { + const rollbackPlan = await this.completeRecovery.plan() + let verifiedStable = null + await this.transition('restoring-data', { + taskId, + progress: 5, + operation, + rollbackPhase: 'preparing', + rollbackIncludesSnapshot: rollbackPlan?.snapshot !== null && rollbackPlan?.snapshot !== undefined, + error: null, + }) + if (options.requireConfirmation) { + const [plan, target] = await Promise.all([this.completeRecovery.plan(), this.metadata.check()]) + if ( + plan === null + || plan.planId !== planId + || compareDshVersions(plan.previous.dsh, target.value.desired.dsh.version) > 0 + ) throw new Error('no verified pre-Experimental Stable recovery point is available') + verifiedStable = target.value + } + const result = await this.completeRecovery.restore(planId, { + ...options, + onProgress: (() => { + let previousKey + return (rollbackPhase, progress, metrics = {}) => { + const key = [ + rollbackPhase, + progress, + metricPercentage(metrics.processedBytes, metrics.totalBytes), + metricPercentage(metrics.processedItems, metrics.totalItems), + ].join(':') + if (key === previousKey) return undefined + previousKey = key + return this.transition('restoring-data', { + taskId, + progress, + operation, + rollbackPhase, + processedBytes: metrics.processedBytes ?? null, + totalBytes: metrics.totalBytes ?? null, + processedItems: metrics.processedItems ?? null, + totalItems: metrics.totalItems ?? null, + }) + } + })(), + }) + await this.reportHealth('restoring-data', { + taskId, + progress: 95, + operation, + rollbackPhase: 'verifying', + }) + const current = await this.activator.currentDeployment() + if ( + rollbackPlan === null + || current.dsh !== rollbackPlan.previous.dsh + || current.environment !== rollbackPlan.previous.environment + || current.runtime !== rollbackPlan.previous.runtime + ) throw new Error('restored Deployment differs from the rollback plan') + const [stored, local] = await Promise.all([ + this.state.read(), + this.channelState?.read() ?? Promise.resolve({ updateChannel: 'stable', holds: [], experimentalBlocked: null }), + ]) + const supported = verifiedStable === null + ? stored.supported ?? null + : { + dsh: verifiedStable.desired.dsh.version, + environment: verifiedStable.desired.environment.version, + } + let aheadOfStable = false + let updateAvailable = false + if (supported !== null) { + const upstream = local.updateChannel === 'experimental' + && typeof stored.upstream?.version === 'string' + && compareDshVersions(stored.upstream.version, supported.dsh) > 0 + ? stored.upstream + : null + const plan = planDesiredState({ + local, + current, + supported, + stableTargetSequence: verifiedStable?.targetSequence ?? stored.available?.targetSequence, + upstream, + }) + aheadOfStable = plan.aheadOfStable + updateAvailable = ['stable', 'experimental'].includes(plan.action) + } + await this.bestEffort('update.notifications.cleanup.failed', () => this.clearSatisfiedNotifications(), undefined, { taskId }) + await this.transition('success', { + taskId, + progress: 100, + current: { dsh: current.dsh, environment: current.environment, runtime: current.runtime }, + aheadOfStable, + updateAvailable, + experimentalBlocked: local.experimentalBlocked, + holds: local.holds, + outcome: null, + probationUntil: null, + error: null, + }) + return result + } catch (error) { + await this.record('update.rollback.failed', { error, taskId, operation }) + await this.transition('failed', { taskId, error: error instanceof Error ? error.message : 'rollback failed' }) + throw error + } + } + + async desiredState() { + if (this.channelState === undefined) throw new Error('update channels are not configured') + const stable = (await this.metadata.check()).value + const [current, local] = await Promise.all([this.activator.currentDeployment(), this.channelState.read()]) + const supported = { dsh: stable.desired.dsh.version, environment: stable.desired.environment.version } + let upstream = null + let upstreamCandidate = null + if ( + local.updateChannel === 'experimental' + && compareDshVersions(current.dsh, supported.dsh) <= 0 + && current.environment === supported.environment + ) { + try { + upstream = await this.npm.discover(stable.officialDshPolicy) + } catch (error) { + const scoped = new Error(error instanceof Error ? error.message : 'official DSH discovery failed', { cause: error }) + scoped.name = error instanceof Error ? error.name : 'Error' + scoped.code = error?.code + scoped.remoteCheckSource = 'upstream' + throw scoped + } + upstreamCandidate = compareDshVersions(upstream.version, supported.dsh) > 0 ? upstream : null + } + return Object.freeze({ + ...planDesiredState({ local, current, supported, stableTargetSequence: stable.targetSequence, upstream: upstreamCandidate }), + upstream, + target: stable, + }) + } + + async publicStatus() { + const [update, local, current, rollbackPlan, journal, automatic] = await Promise.all([ + this.state.read(), + this.channelState?.read() ?? Promise.resolve({ updateChannel: 'stable', holds: [], experimentalBlocked: null }), + this.bestEffort( + 'update.status.current.failed', + () => this.activator.currentDeployment({ required: false }), + null, + {}, + 60_000, + ), + this.bestEffort('update.status.rollback-plan.failed', () => this.rollbackPlan(), null, {}, 60_000), + this.journal === undefined + ? Promise.resolve(undefined) + : this.bestEffort('update.status.journal.failed', () => this.journal.read(), undefined, {}, 60_000), + this.automaticChecks?.read() ?? Promise.resolve(null), + ]) + const returnStableAvailable = rollbackPlan !== null && rollbackPlan.snapshot !== null && update.supported?.dsh !== undefined + && compareDshVersions(rollbackPlan.previous.dsh, update.supported.dsh) <= 0 + return Object.freeze({ + update, + updateChannel: local.updateChannel, + current: current === null ? null : { dsh: current.dsh, environment: current.environment, runtime: current.runtime }, + supported: update.supported ?? null, + upstream: update.upstream ?? null, + aheadOfStable: update.aheadOfStable ?? false, + experimentalBlocked: local.experimentalBlocked, + holds: local.holds, + probation: journal?.phase === 'probation' ? { until: journal.probationUntil } : null, + rollbackPlan: rollbackPlan === null ? null : { ...rollbackPlan, returnStableAvailable }, + ...(automatic === null ? {} : { + automaticCheck: automatic.automaticCheck, + latestAutomatic: automatic.latestAutomatic, + }), + }) + } + + async runReconcile(taskId) { + try { + const plan = await this.desiredState() + if (plan.action === 'stable') { + const blockExperimentalCombination = plan.current?.authority === 'experimental' + && plan.current.environment !== plan.supported?.environment + await this.run(taskId, { complete: false }) + const next = await this.desiredState() + if (next.action === 'experimental') { + return this.runExperimental(taskId, { blockCombination: blockExperimentalCombination }) + } + return this.transition('success', { + taskId, + progress: 100, + current: { dsh: next.current.dsh, environment: next.current.environment, runtime: next.current.runtime }, + aheadOfStable: next.aheadOfStable, + experimentalBlocked: next.experimentalBlocked, + holds: next.holds, + updateAvailable: ['stable', 'experimental'].includes(next.action), + error: null, + outcome: next.action, + }) + } + if (plan.action === 'experimental') return this.runExperimental(taskId) + return this.transition('success', { + taskId, progress: 100, error: null, + outcome: plan.action, + }) + } catch (error) { + await this.record('update.reconcile.failed', { error, taskId }) + const current = await this.state.read() + if (current.status !== 'failed') { + await this.transition('failed', { taskId, error: error instanceof Error ? error.message : 'update planning failed' }) + } + throw error + } + } + + async run(taskId, { complete = true } = {}) { + try { + await this.transition('planning', { + taskId, progress: 0, operation: 'update', rollbackPhase: null, rollbackIncludesSnapshot: null, + processedBytes: null, totalBytes: null, processedItems: null, totalItems: null, + readyServices: null, totalServices: null, detail: null, error: null, + }) + const target = await this.metadata.check() + const artifacts = Array.isArray(target.value.artifacts) ? target.value.artifacts : [] + await this.transition('downloading', { + taskId, + progress: 10, + targetSequence: target.value.targetSequence, + processedBytes: 0, + totalBytes: artifacts.reduce((total, artifact) => total + artifact.size, 0), + processedItems: 0, + totalItems: artifacts.length, + detail: 'downloading', + }) + let previousDownloadKey + let previousDownloadProgress = 10 + const prepared = await this.preparer.prepare(target.value, { + onProgress: metrics => { + const byteRatio = Number.isFinite(metrics.totalBytes) && metrics.totalBytes > 0 + ? metrics.processedBytes / metrics.totalBytes + : null + const itemRatio = Number.isFinite(metrics.totalItems) && metrics.totalItems > 0 + ? metrics.processedItems / metrics.totalItems + : null + const ratio = byteRatio ?? itemRatio + const measuredProgress = ratio === null ? 10 : 10 + Math.round(Math.max(0, Math.min(1, ratio)) * 55) + const progress = Math.max(previousDownloadProgress, measuredProgress) + previousDownloadProgress = progress + const key = `${progress}:${metrics.processedItems}:${metrics.totalItems}` + if (key === previousDownloadKey) return undefined + previousDownloadKey = key + return this.transition('downloading', { + taskId, + progress, + processedBytes: metrics.processedBytes, + totalBytes: metrics.totalBytes, + processedItems: metrics.processedItems, + totalItems: metrics.totalItems, + detail: metrics.detail ?? null, + }) + }, + }) + await this.transition('validating', { taskId, progress: 70 }) + await this.transition('building-candidate', { taskId, progress: 75 }) + let previousBuildProgress = 75 + let buildComplete = false + await this.activator.activate(prepared, { + onProgress: progress => { + if (typeof progress === 'number') { + const stageProgress = Math.max(previousBuildProgress, progress) + if (stageProgress === previousBuildProgress) return undefined + previousBuildProgress = stageProgress + return this.transition('building-candidate', { taskId, progress: stageProgress }) + } + const ratio = progress.totalBytes > 0 ? progress.processedBytes / progress.totalBytes : 0 + const stageProgress = Math.max( + previousBuildProgress, + 75 + Math.round(Math.max(0, Math.min(1, ratio)) * 14), + ) + const complete = progress.processedBytes === progress.totalBytes + && progress.processedItems === progress.totalItems + if (stageProgress === previousBuildProgress && (!complete || buildComplete)) return undefined + previousBuildProgress = stageProgress + buildComplete = complete + return this.transition('building-candidate', { + taskId, + progress: stageProgress, + processedBytes: progress.processedBytes, + totalBytes: progress.totalBytes, + processedItems: progress.processedItems, + totalItems: progress.totalItems, + }) + }, + onSwitching: () => this.transition('switching', { taskId, progress: 90 }), + }) + const health = await this.reportHealth('switching', { taskId, progress: 95 }) + const current = this.activator.currentDeployment === undefined + ? null + : await this.activator.currentDeployment() + await this.bestEffort('update.notifications.cleanup.failed', () => this.clearSatisfiedNotifications(), undefined, { taskId }) + return complete ? this.transition('success', { + taskId, + progress: 100, + ...(current === null ? {} : { + current: { dsh: current.dsh, environment: current.environment, runtime: current.runtime }, + updateAvailable: false, + }), + error: null, + }) : health + } catch (error) { + await this.record('update.stable.failed', { error, taskId }) + await this.transition('failed', { taskId, error: error instanceof Error ? error.message : 'update failed' }) + throw error + } + } + + trustActivate(prepared) { + return this.preparer.trust.activate(prepared.receiptTokens) + } + + async runExperimental(taskId, { blockCombination = false } = {}) { + let transaction + let candidate + let failureClass = 'check' + let obsoleteSnapshotId = null + try { + await this.transition('checking-upstream', { + taskId, progress: 0, operation: 'update', rollbackPhase: null, rollbackIncludesSnapshot: null, error: null, + }) + const stable = (await this.metadata.check()).value + candidate = await this.npm.latest(stable) + if (candidate === null) return this.transition('success', { taskId, progress: 100, error: null }) + const from = await this.activator.currentDeployment() + const bootstrap = await this.activator.bootstrap.status() + if ( + from.environment !== stable.desired.environment.version + || bootstrap.bootstrapVersion !== stable.desired.bootstrap.version + ) throw new Error('Stable Environment and Bootstrap must converge before Experimental DSH') + + await this.transition('downloading', { taskId, progress: 10 }) + failureClass = 'candidate' + let previousDownloadProgress = 10 + const prepared = await this.preparer.prepareExperimental(candidate, { + onProgress: metrics => { + const ratio = Number.isFinite(metrics.totalBytes) && metrics.totalBytes > 0 + ? metrics.processedBytes / metrics.totalBytes + : metrics.processedItems / metrics.totalItems + const progress = Math.max(previousDownloadProgress, 10 + Math.round(Math.max(0, Math.min(1, ratio)) * 20)) + previousDownloadProgress = progress + return this.transition('downloading', { + taskId, + progress, + processedBytes: metrics.processedBytes, + totalBytes: metrics.totalBytes, + processedItems: metrics.processedItems, + totalItems: metrics.totalItems, + detail: metrics.detail ?? null, + }) + }, + }) + await this.transition('building-candidate', { taskId, progress: 35 }) + const built = await this.activator.prepareExperimental(prepared, { targetSequence: stable.targetSequence }) + const previousJournal = await this.journal.read() + if (previousJournal !== undefined && ['committed', 'rolled-back', 'failed'].includes(previousJournal.phase)) { + obsoleteSnapshotId = previousJournal.snapshotId + } + transaction = await this.journal.begin({ + transactionId: taskId, + mode: 'experimental', + from: journalDeployment(from), + to: { dsh: built.dshVersion, environment: built.environmentVersion, runtime: built.runtimeId }, + }) + transaction = await this.journal.transition('candidate-ready', { receiptTokens: prepared.receiptTokens }) + + await this.transition('snapshotting-data', { taskId, progress: 55 }) + failureClass = 'snapshot' + await this.activator.suspendDsh() + transaction = await this.journal.transition('suspended') + let previousSnapshotProgress + const snapshot = await this.snapshots.create({ + id: taskId, + runtimeId: from.runtime, + environmentVersion: from.environment, + dshVersion: from.dsh, + onProgress: metrics => { + const metricProgress = metricPercentage(metrics.processedItems, metrics.totalItems) + ?? metricPercentage(metrics.processedBytes, metrics.totalBytes) + if (metricProgress === previousSnapshotProgress) return undefined + previousSnapshotProgress = metricProgress + return this.transition('snapshotting-data', { + taskId, + progress: 55, + processedBytes: metrics.processedBytes ?? null, + totalBytes: metrics.totalBytes ?? null, + processedItems: metrics.processedItems ?? null, + totalItems: metrics.totalItems ?? null, + }) + }, + }) + const candidateRuntimeId = this.activator.bindExperimentalSnapshot === undefined + ? built.runtimeId + : await this.activator.bindExperimentalSnapshot(built.runtimeId, snapshot.id) + transaction = await this.journal.transition('snapshot-created', { + snapshotId: snapshot.id, + to: { ...transaction.to, runtime: candidateRuntimeId }, + }) + + await this.transition('switching', { taskId, progress: 70 }) + await this.activator.switchExperimental(candidateRuntimeId) + failureClass = 'combination' + transaction = await this.journal.transition('switched') + const probationUntil = new Date(this.now().valueOf() + this.probationSeconds * 1000).toISOString() + transaction = await this.journal.transition('probation', { probationUntil }) + await this.transition('probation', { taskId, progress: 85, probationUntil }) + do { + const health = await this.activator.health() + const remainingMs = Math.max(0, new Date(probationUntil).valueOf() - this.now().valueOf()) + const elapsedRatio = this.probationSeconds <= 0 + ? 1 + : Math.max(0, Math.min(1, 1 - remainingMs / (this.probationSeconds * 1000))) + await this.transition('probation', { + taskId, + progress: 85 + Math.round(elapsedRatio * 14), + probationUntil, + detail: `probation:${String(Math.ceil(remainingMs / 1000))}`, + ...healthMetrics(health), + }) + if (!health.healthy) throw new Error('Experimental Runtime failed probation health checks') + if (this.now().valueOf() >= new Date(probationUntil).valueOf()) break + await this.sleep(Math.min(1_000, Math.max(0, new Date(probationUntil).valueOf() - this.now().valueOf()))) + } while (true) + await this.activator.commitExperimental(candidateRuntimeId) + transaction = await this.journal.transition('committed') + await this.bestEffort('update.notifications.cleanup.failed', () => this.clearSatisfiedNotifications(), undefined, { taskId }) + if (this.activator.cleanup !== undefined) { + await this.bestEffort('update.assets.cleanup.failed', () => this.activator.cleanup(), undefined, { taskId }) + } + if (obsoleteSnapshotId !== null && obsoleteSnapshotId !== transaction.snapshotId) { + if (this.snapshots.remove !== undefined) { + await this.bestEffort('update.snapshot.cleanup.failed', () => this.snapshots.remove(obsoleteSnapshotId), undefined, { + snapshotId: obsoleteSnapshotId, + taskId, + }) + } + } + return this.transition('success', { + taskId, + progress: 100, + current: { dsh: built.dshVersion, environment: built.environmentVersion, runtime: candidateRuntimeId }, + aheadOfStable: true, + updateAvailable: false, + outcome: 'frozen', + probationUntil: null, + error: null, + }) + } catch (error) { + let message = error instanceof Error ? error.message : 'Experimental update failed' + const reason = holdReason(message) + const failureState = await this.state.read() + const failurePhase = failureState.phase ?? failureState.status + let recoveryCompleted = false + await this.record('update.experimental.failed', { error, failureClass, taskId }) + if (candidate !== undefined && this.channelState !== undefined) { + if (failureClass === 'candidate') { + await this.bestEffort('update.hold.persist.failed', () => this.channelState.addHold({ + type: 'version', dshVersion: candidate.version, reason, + }), undefined, { dshVersion: candidate.version, holdType: 'version', taskId }) + } else if (failureClass === 'combination') { + const environmentVersion = transaction?.to.environment + if (environmentVersion !== undefined) { + const persist = blockCombination + ? () => this.channelState.block({ dshVersion: candidate.version, environmentVersion, reason }) + : () => this.channelState.addHold({ + type: 'combination', dshVersion: candidate.version, environmentVersion, reason, + }) + await this.bestEffort( + blockCombination ? 'update.experimental-block.persist.failed' : 'update.hold.persist.failed', + persist, + undefined, + { + dshVersion: candidate.version, + environmentVersion, + ...(blockCombination ? { blocked: true } : { holdType: 'combination' }), + taskId, + }, + ) + } + } + } + if (this.journal !== undefined) { + transaction = await this.bestEffort('update.journal.read.failed', () => this.journal.read(), transaction, { taskId }) + } + if (transaction !== undefined && !['committed', 'rolled-back', 'failed'].includes(transaction.phase)) { + try { + if (['snapshot-created', 'switched', 'probation', 'restoring-data'].includes(transaction.phase)) { + if (transaction.phase !== 'restoring-data') transaction = await this.journal.transition('restoring-data', { error: message }) + const recoveryProgress = offset => Math.min(99, Math.max(Number(failureState.progress) || 0, offset)) + await this.transition('restoring-data', { + taskId, + operation: 'update', + progress: recoveryProgress(86), + detail: 'recovery:suspend', + rollbackIncludesSnapshot: transaction.snapshotId !== null, + error: null, + }) + await this.bestEffort('update.rollback.suspend.failed', () => this.activator.suspendDsh(), undefined, { taskId }) + await this.transition('restoring-data', { + taskId, operation: 'update', progress: recoveryProgress(88), detail: 'recovery:deployment', + }) + await this.activator.restoreDeployment(transaction.from, { resume: false }) + if (transaction.snapshotId !== null) { + await this.transition('restoring-data', { + taskId, operation: 'update', progress: recoveryProgress(90), detail: 'recovery:snapshot', + }) + let previousRestoreProgress + await this.snapshots.restore(transaction.snapshotId, { + onProgress: metrics => { + const metricProgress = metricPercentage(metrics.processedItems, metrics.totalItems) + ?? metricPercentage(metrics.processedBytes, metrics.totalBytes) + if (metricProgress === previousRestoreProgress) return undefined + previousRestoreProgress = metricProgress + return this.transition('restoring-data', { + taskId, + operation: 'update', + progress: recoveryProgress(90 + Math.round((metricProgress ?? 0) * 0.05)), + detail: 'recovery:snapshot', + processedBytes: metrics.processedBytes ?? null, + totalBytes: metrics.totalBytes ?? null, + processedItems: metrics.processedItems ?? null, + totalItems: metrics.totalItems ?? null, + }) + }, + }) + } + await this.transition('restoring-data', { + taskId, + operation: 'update', + progress: recoveryProgress(96), + detail: 'recovery:runtime', + processedBytes: null, + totalBytes: null, + processedItems: null, + totalItems: null, + }) + await this.activator.resumeDsh() + const health = await this.reportHealth('restoring-data', { + taskId, operation: 'update', progress: recoveryProgress(99), detail: 'recovery:health', + }) + if (health?.healthy === false) throw new Error('restored Deployment failed health checks') + await this.journal.transition('rolled-back', { error: message }) + recoveryCompleted = true + await this.record('update.experimental.rollback.completed', { taskId }) + } else { + await this.bestEffort('update.rollback.resume.failed', () => this.activator.resumeDsh(), undefined, { taskId }) + await this.journal.transition('failed', { error: message }) + } + } catch (rollbackError) { + await this.record('update.experimental.rollback.failed', { error: rollbackError, taskId }) + const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : 'unknown error' + message = `${message}; rollback failed: ${rollbackMessage}` + } + } + await this.transition('failed', { + taskId, + error: message, + ...(recoveryCompleted ? { + phase: failurePhase, + progress: failureState.progress, + detail: failureState.detail, + processedBytes: failureState.processedBytes, + totalBytes: failureState.totalBytes, + processedItems: failureState.processedItems, + totalItems: failureState.totalItems, + readyServices: failureState.readyServices, + totalServices: failureState.totalServices, + } : {}), + }) + throw error + } + } + + async recover() { + if (this.journal === undefined || this.snapshots === undefined) return undefined + return recoverInterruptedUpdate({ journal: this.journal, snapshots: this.snapshots, activator: this.activator }) + } + + async clearSatisfiedNotifications() { + if (this.automaticChecks === undefined) return + await this.automaticChecks.clearSatisfied(await this.activator.currentDeployment()) + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/journal.mjs b/upstream/container/control-plane/modules/updater/lib/journal.mjs new file mode 100644 index 0000000..7c346a4 --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/journal.mjs @@ -0,0 +1,142 @@ +import { open, readFile, rm } from 'node:fs/promises' +import { dirname } from 'node:path' +import { durableReplace } from '../../../../platform/lib/atomic.mjs' +import { exactKeys, isoTimestamp, plainObject, TrustError } from '../../../../platform/lib/validation.mjs' + +const PHASES = new Set([ + 'planning', 'candidate-ready', 'suspended', 'snapshot-created', 'switched', 'probation', + 'committed', 'restoring-data', 'rolled-back', 'failed', +]) + +const TRANSITIONS = new Map([ + ['planning', new Set(['candidate-ready', 'failed'])], + ['candidate-ready', new Set(['suspended', 'failed'])], + ['suspended', new Set(['snapshot-created', 'failed'])], + ['snapshot-created', new Set(['switched', 'restoring-data', 'failed'])], + ['switched', new Set(['probation', 'restoring-data'])], + ['probation', new Set(['committed', 'restoring-data'])], + ['restoring-data', new Set(['rolled-back', 'failed'])], + ['committed', new Set(['restoring-data'])], + ['rolled-back', new Set()], + ['failed', new Set()], +]) + +function deployment(value, label, snapshot) { + const object = plainObject(value, label) + exactKeys(object, ['dsh', 'environment', 'runtime', ...(snapshot ? ['dataSnapshot', 'receiptTokens'] : [])], label) + for (const name of ['dsh', 'environment', 'runtime']) { + if (typeof object[name] !== 'string' || object[name] === '') throw new TrustError(`${label}.${name} is invalid`) + } + if (snapshot && object.dataSnapshot !== null && typeof object.dataSnapshot !== 'string') { + throw new TrustError(`${label}.dataSnapshot is invalid`) + } + if (snapshot && (!Array.isArray(object.receiptTokens) || object.receiptTokens.some(token => typeof token !== 'string'))) { + throw new TrustError(`${label}.receiptTokens is invalid`) + } + return Object.freeze({ ...object, ...(snapshot ? { receiptTokens: Object.freeze([...object.receiptTokens]) } : {}) }) +} + +function parseJournal(value) { + const object = plainObject(value, 'update journal') + exactKeys(object, [ + 'error', 'from', 'mode', 'phase', 'probationUntil', 'receiptTokens', 'schema', + 'snapshotId', 'to', 'transactionId', 'updatedAt', + ], 'update journal') + if (object.schema !== 1) throw new TrustError('update journal schema must be 1') + if (typeof object.transactionId !== 'string' || !/^[A-Za-z0-9-]{1,128}$/.test(object.transactionId)) { + throw new TrustError('update journal transaction ID is invalid') + } + if (!['stable', 'experimental'].includes(object.mode)) throw new TrustError('update journal mode is invalid') + if (!PHASES.has(object.phase)) throw new TrustError('update journal phase is invalid') + if (!Array.isArray(object.receiptTokens) || object.receiptTokens.some(token => typeof token !== 'string')) { + throw new TrustError('update journal receipt tokens are invalid') + } + for (const name of ['snapshotId', 'probationUntil', 'error']) { + if (object[name] !== null && typeof object[name] !== 'string') throw new TrustError(`update journal ${name} is invalid`) + } + if (object.probationUntil !== null) isoTimestamp(object.probationUntil, 'update journal probation deadline') + isoTimestamp(object.updatedAt, 'update journal update time') + return Object.freeze({ + ...object, + from: deployment(object.from, 'update journal from', true), + to: deployment(object.to, 'update journal to', false), + receiptTokens: Object.freeze([...object.receiptTokens]), + }) +} + +async function syncDirectory(path) { + const handle = await open(path, 'r') + try { await handle.sync() } finally { await handle.close() } +} + +export class UpdateJournal { + constructor(path, now = () => new Date()) { + this.path = path + this.now = now + this.queue = Promise.resolve() + } + + exclusive(operation) { + const result = this.queue.then(operation, operation) + this.queue = result.then(() => undefined, () => undefined) + return result + } + + async read() { + try { return parseJournal(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } + } + + begin(value) { + return this.exclusive(async () => { + const previous = await this.read() + if (previous !== undefined && !['committed', 'rolled-back', 'failed'].includes(previous.phase)) { + throw new TrustError('an update transaction journal already exists') + } + const journal = parseJournal({ + schema: 1, + transactionId: value.transactionId, + mode: value.mode, + phase: 'planning', + from: value.from, + to: value.to, + snapshotId: null, + receiptTokens: [], + probationUntil: null, + error: null, + updatedAt: this.now().toISOString(), + }) + await durableReplace(this.path, `${JSON.stringify(journal)}\n`) + return journal + }) + } + + transition(phase, fields = {}) { + return this.exclusive(async () => { + const current = await this.read() + if (current === undefined) throw new TrustError('update transaction journal does not exist') + if (!TRANSITIONS.get(current.phase)?.has(phase)) { + throw new TrustError(`update journal cannot transition from ${current.phase} to ${phase}`) + } + const next = parseJournal({ ...current, ...fields, phase, updatedAt: this.now().toISOString() }) + if (next.transactionId !== current.transactionId || next.mode !== current.mode) { + throw new TrustError('update journal identity cannot change') + } + await durableReplace(this.path, `${JSON.stringify(next)}\n`) + return next + }) + } + + clear() { + return this.exclusive(async () => { + const current = await this.read() + if (current !== undefined && !['committed', 'rolled-back', 'failed'].includes(current.phase)) { + throw new TrustError('cannot clear a nonterminal update journal') + } + await rm(this.path, { force: true }) + await syncDirectory(dirname(this.path)).catch(error => { if (error?.code !== 'ENOENT') throw error }) + }) + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/managed-store.mjs b/upstream/container/control-plane/modules/updater/lib/managed-store.mjs new file mode 100644 index 0000000..743bb77 --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/managed-store.mjs @@ -0,0 +1,275 @@ +import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { cp, lstat, mkdir, readFile, rename, rm, symlink } from 'node:fs/promises' +import { join } from 'node:path' +import { buildRuntime } from '../../patch-manager/index.mjs' +import { reconcileSystemPlugins } from '../../plugin-manager/system.mjs' +import { artifactForReference, parseEnvironmentManifest } from '../../../../platform/lib/contracts.mjs' +import { deriveRecordId, parseDeploymentRecord } from '../../../../platform/lib/deployment-contracts.mjs' +import { hashTree } from '../../../../platform/lib/tree-hash.mjs' + +async function exists(path) { + return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error)) +} + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + const stdout = [] + const stderr = [] + child.stdout.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.once('error', reject) + child.once('exit', code => code === 0 + ? resolve(Buffer.concat(stdout).toString('utf8')) + : reject(new Error(Buffer.concat(stderr).toString('utf8')))) + }) +} + +const NPM_INSTALL_ATTEMPTS = 7 +const NPM_INSTALL_RETRY_MS = 5_000 + +function retryableNpmInstall(error) { + return /(?:^|\n)npm error (?:code (?:ETARGET|E404|ENOVERSIONS)|notarget\b)/u.test( + error instanceof Error ? error.message : '', + ) +} + +async function verifyDirectory(path, expectedHash) { + const details = await lstat(path) + if (!details.isDirectory() || details.isSymbolicLink()) throw new Error(`Managed Store asset is not an immutable directory: ${path}`) + const sha256 = await hashTree(path) + if (expectedHash !== undefined && sha256 !== expectedHash) throw new Error(`Managed Store asset content conflicts at ${path}`) + return sha256 +} + +async function publish(staging, root, id) { + const sha256 = await hashTree(staging) + const destination = join(root, id) + try { + await rename(staging, destination) + } catch (error) { + if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error + await verifyDirectory(destination, sha256) + } + return Object.freeze({ id, sha256, path: destination }) +} + +function storeReference(kind, asset) { + return Object.freeze({ storage: 'store', kind, id: asset.id, sha256: asset.sha256 }) +} + +export class ManagedDeploymentBuilder { + constructor({ + paths, + npmInstall = (installRoot, archivePath, { preferOnline }) => run('npm', [ + 'install', '--global', '--prefix', installRoot, '--omit=dev', '--ignore-scripts', + '--no-audit', '--no-fund', preferOnline ? '--prefer-online' : '--prefer-offline', + '--cache', paths.npmCacheRoot, archivePath, + ]), + sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), + }) { + this.paths = paths + this.npmInstall = npmInstall + this.sleep = sleep + } + + async pristine(version, receipt) { + const id = `pristine-npm-${receipt.objectSha256}` + const destination = join(this.paths.pristineRoot, id) + if (await exists(destination)) { + const sha256 = await verifyDirectory(destination) + return Object.freeze({ id, sha256, path: destination }) + } + const installRoot = join(this.paths.pristineRoot, `.install.${randomUUID()}.tmp`) + const staging = join(this.paths.pristineRoot, `.${id}.${randomUUID()}.tmp`) + await mkdir(installRoot, { recursive: false }) + try { + const entries = (await run('tar', ['-tzf', receipt.path])).split('\n').filter(Boolean) + if (!entries.includes('package/package.json') || entries.some(entry => ( + entry.startsWith('/') || !entry.startsWith('package/') || entry.split('/').includes('..') + ))) throw new Error('official DSH archive contains an unsafe path') + for (let attempt = 1; attempt <= NPM_INSTALL_ATTEMPTS; attempt += 1) { + const attemptRoot = join(installRoot, `attempt-${String(attempt)}`) + await mkdir(attemptRoot, { recursive: false }) + try { + const archivePath = join(attemptRoot, 'official-dsh.tgz') + await symlink(receipt.path, archivePath, 'file') + await this.npmInstall(attemptRoot, archivePath, { preferOnline: attempt > 1 }) + const installed = join(attemptRoot, 'lib', 'node_modules', '@deepseek-ai', 'dsh') + await rename(installed, staging) + break + } catch (error) { + if (attempt === NPM_INSTALL_ATTEMPTS || !retryableNpmInstall(error)) throw error + await this.sleep(Math.min(30_000, NPM_INSTALL_RETRY_MS * (2 ** (attempt - 1)))) + } finally { + await rm(attemptRoot, { recursive: true, force: true }) + } + } + const metadata = JSON.parse(await readFile(join(staging, 'package.json'), 'utf8')) + if (metadata.name !== '@deepseek-ai/dsh' || metadata.version !== version) { + throw new Error('official DSH package metadata differs from its requested version') + } + return await publish(staging, this.paths.pristineRoot, id) + } finally { + await rm(installRoot, { recursive: true, force: true }) + await rm(staging, { recursive: true, force: true }) + } + } + + async environment(prepared) { + const manifest = prepared.environment.manifest + const identity = { + schema: 1, + version: manifest.version, + targetSequence: prepared.stable.targetSequence, + manifest: prepared.environment.manifestReceipt.objectSha256, + artifacts: manifest.artifacts.map(artifact => ({ id: artifact.id, sha256: artifact.sha256 })), + } + const id = deriveRecordId('environment', identity) + const destination = join(this.paths.environmentsRoot, id) + if (await exists(destination)) { + const sha256 = await verifyDirectory(destination) + return Object.freeze({ id, sha256, path: destination }) + } + const staging = join(this.paths.environmentsRoot, `.${id}.${randomUUID()}.tmp`) + await mkdir(join(staging, 'artifacts'), { recursive: true }) + try { + await cp(prepared.paths.get(prepared.stable.desired.environment.manifestArtifactId), join(staging, 'environment.manifest.json')) + for (const descriptor of manifest.artifacts) { + await cp(prepared.paths.get(descriptor.id), join(staging, 'artifacts', descriptor.id)) + } + return await publish(staging, this.paths.environmentsRoot, id) + } finally { + await rm(staging, { recursive: true, force: true }) + } + } + + async runtime(prepared, pristine, environment, { onProgress = async () => {} } = {}) { + const patches = prepared.environment.manifest.patches.map(reference => { + const artifact = artifactForReference(prepared.environment.manifest, reference) + return { id: reference.id, artifactId: artifact.id, sha256: artifact.sha256 } + }) + const id = deriveRecordId('runtime', { + schema: 1, + pristine: pristine.sha256, + environment: environment.sha256, + patches, + }) + const destination = join(this.paths.runtimesRoot, id) + if (await exists(destination)) { + const sha256 = await verifyDirectory(destination) + return Object.freeze({ id, sha256, path: destination }) + } + await buildRuntime({ + pristineRoot: pristine.path, + versionsRoot: this.paths.runtimesRoot, + runtimeId: id, + patchPaths: patches.map(patch => prepared.paths.get(patch.artifactId)), + onProgress, + }) + return Object.freeze({ id, sha256: await verifyDirectory(destination), path: destination }) + } + + async systemPlugins(prepared, environment) { + const plugins = prepared.environment.manifest.systemPlugins.map(reference => { + const artifact = artifactForReference(prepared.environment.manifest, reference) + return { id: reference.id, artifactId: artifact.id, sha256: artifact.sha256 } + }) + const id = deriveRecordId('system-plugins', { + schema: 1, + environment: environment.sha256, + plugins, + }) + const destination = join(this.paths.systemPluginsRoot, id) + if (await exists(destination)) { + const sha256 = await verifyDirectory(destination) + return Object.freeze({ id, sha256, path: destination }) + } + const buildRoot = join(this.paths.systemPluginsRoot, `.build.${randomUUID()}.tmp`) + try { + const built = await reconcileSystemPlugins({ + root: buildRoot, + environmentVersion: id, + plugins: prepared.environment.manifest.systemPlugins, + artifactPath: reference => prepared.paths.get(artifactForReference(prepared.environment.manifest, reference).id), + }) + return await publish(built, this.paths.systemPluginsRoot, id) + } finally { + await rm(buildRoot, { recursive: true, force: true }) + } + } + + async buildStable(prepared, { onProgress = async () => {} } = {}) { + const pristine = await this.pristine(prepared.dsh.version, prepared.dsh.receipt) + await onProgress(80) + const environment = await this.environment(prepared) + await onProgress(82) + const runtime = await this.runtime(prepared, pristine, environment, { + onProgress: metrics => onProgress({ stage: 'runtime', ...metrics }), + }) + await onProgress(87) + const systemPlugins = await this.systemPlugins(prepared, environment) + await onProgress(89) + const content = { + schema: 1, + authority: 'stable', + targetSequence: prepared.stable.targetSequence, + dshVersion: prepared.dsh.version, + environmentVersion: prepared.environment.manifest.version, + environment: storeReference('environment', environment), + pristine: storeReference('pristine', pristine), + runtime: storeReference('runtime', runtime), + systemPlugins: storeReference('system-plugins', systemPlugins), + receiptTokens: [...prepared.receiptTokens], + snapshotId: null, + } + return Object.freeze({ + record: parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) }), + assets: Object.freeze({ pristine, environment, runtime, systemPlugins }), + }) + } + + async buildExperimental(prepared, currentValue, receiptTokens, { targetSequence }) { + const current = parseDeploymentRecord(currentValue) + const pristine = await this.pristine(prepared.version, prepared.receipt) + const environmentRoot = join(this.paths.viewsRoot, 'environment') + const manifest = parseEnvironmentManifest(await readFile(join(environmentRoot, 'environment.manifest.json'))) + if (manifest.version !== current.environmentVersion) throw new Error('current Environment view differs from its Deployment Record') + const patches = manifest.patches.map(reference => { + const artifact = artifactForReference(manifest, reference) + return { id: reference.id, artifactId: artifact.id, sha256: artifact.sha256 } + }) + const runtimeId = deriveRecordId('runtime', { + schema: 1, + pristine: pristine.sha256, + environment: current.environment.sha256, + patches, + }) + const runtimePath = join(this.paths.runtimesRoot, runtimeId) + if (!await exists(runtimePath)) { + await buildRuntime({ + pristineRoot: pristine.path, + versionsRoot: this.paths.runtimesRoot, + runtimeId, + patchPaths: patches.map(patch => join(environmentRoot, 'artifacts', patch.artifactId)), + }) + } + const runtime = Object.freeze({ id: runtimeId, sha256: await verifyDirectory(runtimePath), path: runtimePath }) + const content = { + schema: 1, + authority: 'experimental', + targetSequence, + dshVersion: prepared.version, + environmentVersion: current.environmentVersion, + environment: current.environment, + pristine: storeReference('pristine', pristine), + runtime: storeReference('runtime', runtime), + systemPlugins: current.systemPlugins, + receiptTokens: [...receiptTokens], + snapshotId: null, + } + const record = parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) }) + return Object.freeze({ record, runtime }) + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/metadata.mjs b/upstream/container/control-plane/modules/updater/lib/metadata.mjs new file mode 100644 index 0000000..7e5b257 --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/metadata.mjs @@ -0,0 +1,180 @@ +import { setTimeout as delay } from 'node:timers/promises' +import { parseStable } from '../../../../platform/lib/contracts.mjs' +import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs' + +export class MetadataUnavailableError extends Error { + constructor() { + super('signed update metadata has not been published') + this.name = 'MetadataUnavailableError' + this.code = 'METADATA_UNAVAILABLE' + } +} + +async function responseBytes(response, label, maxBytes = 10 * 1024 * 1024) { + if (!response.ok) throw new HttpResponseError(label, response.status) + const bytes = Buffer.from(await response.arrayBuffer()) + if (bytes.byteLength > maxBytes) throw new Error(`${label} exceeds the download limit`) + return bytes +} + +class HttpResponseError extends Error { + constructor(label, status) { + super(`${label} returned HTTP ${String(status)}`) + this.name = 'HttpResponseError' + this.status = status + } +} + +function retryableRequest(error) { + return !(error instanceof HttpResponseError) + || error.status === 408 + || error.status === 429 + || error.status >= 500 +} + +function exhaustedRequest(label, error, attempts) { + const timeout = error?.name === 'TimeoutError' + const detail = error instanceof Error && error.message !== '' ? `: ${error.message}` : '' + const wrapped = new Error( + timeout + ? `${label} timed out after ${String(attempts)} attempts` + : `${label} failed after ${String(attempts)} attempts${detail}`, + { cause: error }, + ) + wrapped.name = timeout ? 'TimeoutError' : 'Error' + if (error?.code !== undefined) wrapped.code = error.code + wrapped.requestExhausted = true + return wrapped +} + +function timeoutForAttempt(initial, retry, attempt) { + return attempt === 1 ? initial : retry +} + +export class MetadataClient { + constructor({ + baseUrl, + trust, + fetchImpl = fetch, + attempts = 2, + retryMs = 250, + requestTimeoutMs = 5_000, + retryRequestTimeoutMs = requestTimeoutMs, + }) { + this.baseUrl = new URL(baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`) + if (this.baseUrl.protocol !== 'https:' && this.baseUrl.hostname !== '127.0.0.1' && this.baseUrl.hostname !== 'localhost') { + throw new Error('update metadata URL must use HTTPS') + } + this.trust = trust + this.fetchImpl = fetchImpl + this.attempts = attempts + this.retryMs = retryMs + this.requestTimeoutMs = requestTimeoutMs + this.retryRequestTimeoutMs = retryRequestTimeoutMs + } + + async file(name) { + let lastError + for (let attempt = 1; attempt <= this.attempts; attempt += 1) { + try { + const response = await this.fetchImpl(new URL(name, this.baseUrl), { + signal: AbortSignal.timeout(timeoutForAttempt(this.requestTimeoutMs, this.retryRequestTimeoutMs, attempt)), + }) + if (response.status === 404) throw new MetadataUnavailableError() + return await responseBytes(response, name) + } catch (error) { + if (error instanceof MetadataUnavailableError) throw error + if (!retryableRequest(error)) throw error + lastError = error + if (attempt < this.attempts) await delay(this.retryMs) + } + } + throw exhaustedRequest(name, lastError, this.attempts) + } + + async verifiedPair(payloadName, signatureName, accept) { + let lastError + for (let attempt = 1; attempt <= this.attempts; attempt += 1) { + try { + const [payload, signatureBytes] = await Promise.all([ + this.file(payloadName), this.file(signatureName), + ]) + await accept(payload, JSON.parse(signatureBytes.toString('utf8'))) + return payload + } catch (error) { + if (error instanceof MetadataUnavailableError + || error instanceof HttpResponseError + || error?.requestExhausted === true) throw error + lastError = error + if (attempt < this.attempts) await delay(this.retryMs) + } + } + throw lastError + } + + async check() { + await this.verifiedPair('keyring.json', 'keyring.sig.json', (bytes, signature) => ( + this.trust.acceptKeyring(bytes, signature) + )) + const stable = await this.verifiedPair('stable.json', 'stable.sig.json', (bytes, signature) => ( + this.trust.acceptTarget(bytes, signature) + )) + return Object.freeze({ bytes: stable, value: parseStable(stable) }) + } +} + +export class NpmRegistryClient { + constructor({ + fetchImpl = fetch, + registry = 'https://registry.npmjs.org/', + packageName = '@deepseek-ai/dsh', + attempts = 2, + retryMs = 250, + requestTimeoutMs = 5_000, + retryRequestTimeoutMs = requestTimeoutMs, + }) { + this.fetchImpl = fetchImpl + this.registry = registry + this.packageName = packageName + this.attempts = attempts + this.retryMs = retryMs + this.requestTimeoutMs = requestTimeoutMs + this.retryRequestTimeoutMs = retryRequestTimeoutMs + } + + async discover(policy = {}) { + const registry = policy.registry ?? this.registry + const packageName = policy.packageName ?? this.packageName + const packagePath = encodeURIComponent(packageName) + let bytes + let lastError + for (let attempt = 1; attempt <= this.attempts; attempt += 1) { + try { + const response = await this.fetchImpl(new URL(packagePath, registry), { + headers: { accept: 'application/vnd.npm.install-v1+json' }, + redirect: 'error', + signal: AbortSignal.timeout(timeoutForAttempt(this.requestTimeoutMs, this.retryRequestTimeoutMs, attempt)), + }) + bytes = await responseBytes(response, 'npm packument', 20 * 1024 * 1024) + break + } catch (error) { + if (!retryableRequest(error)) throw error + lastError = error + if (attempt < this.attempts) await delay(this.retryMs) + } + } + if (bytes === undefined) throw exhaustedRequest('npm packument', lastError, this.attempts) + let packument + try { packument = JSON.parse(bytes.toString('utf8')) } catch { throw new Error('npm packument is not valid JSON') } + const latest = packument?.['dist-tags']?.latest + const version = typeof latest === 'string' ? packument?.versions?.[latest]?.version : undefined + if (version !== latest) throw new Error('npm packument has no coherent latest DSH version') + return Object.freeze({ version }) + } + + async latest(stable) { + const found = await this.discover(stable.officialDshPolicy) + const version = found.version + return compareDshVersions(version, stable.desired.dsh.version) > 0 ? Object.freeze({ version }) : null + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/official-dsh-download.mjs b/upstream/container/control-plane/modules/updater/lib/official-dsh-download.mjs new file mode 100644 index 0000000..8dd4741 --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/official-dsh-download.mjs @@ -0,0 +1,192 @@ +import { constants } from 'node:fs' +import { createWriteStream } from 'node:fs' +import { mkdir, open, readFile, rename, rm } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { join } from 'node:path' +import { Readable, Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { setTimeout as delay } from 'node:timers/promises' +import { + OFFICIAL_DSH_METADATA_LIMIT, + OFFICIAL_DSH_PACKAGE, + OFFICIAL_DSH_TARBALL_LIMIT, + officialDshMetadataUrl, + officialDshTarballUrl, +} from '../../../../platform/lib/official-dsh-contracts.mjs' +import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs' + +function exactResponse(response, url, label) { + if (response.redirected || (response.url !== undefined && response.url !== '' && response.url !== url.href)) { + throw new Error(`${label} changed origin or redirected`) + } + if (!response.ok) { + const error = new Error(`${label} returned HTTP ${String(response.status)}`) + error.status = response.status + throw error + } + return response +} + +function responseLength(response) { + const header = response.headers?.get?.('content-length') + if (header === null || header === undefined || header === '') return null + const value = Number(header) + return Number.isSafeInteger(value) && value >= 0 ? value : null +} + +async function boundedBytes(response, label, maxBytes) { + const declared = responseLength(response) + if (declared !== null && declared > maxBytes) throw new Error(`${label} exceeds the download limit`) + const bytes = Buffer.from(await response.arrayBuffer()) + if (bytes.byteLength > maxBytes) throw new Error(`${label} exceeds the download limit`) + return bytes +} + +async function atomicBytes(path, bytes) { + const temporary = `${path}.${randomUUID()}.tmp` + try { + const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) + try { + await handle.writeFile(bytes) + await handle.sync() + } finally { + await handle.close() + } + await rename(temporary, path) + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}) + throw error + } +} + +async function streamToFile(response, path, label, maxBytes, onProgress) { + if (response.body === null || response.body === undefined) throw new Error(`${label} has no response body`) + const declared = responseLength(response) + if (declared !== null && declared > maxBytes) throw new Error(`${label} exceeds the download limit`) + const temporary = `${path}.${randomUUID()}.tmp` + let processedBytes = 0 + try { + const meter = new Transform({ + transform(chunk, _encoding, callback) { + processedBytes += chunk.byteLength + if (processedBytes > maxBytes) { + callback(new Error(`${label} exceeds the download limit`)) + return + } + Promise.resolve(onProgress({ processedBytes, totalBytes: Number.isFinite(declared) ? declared : null })) + .then(() => callback(null, chunk), callback) + }, + }) + await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(temporary, { flags: 'wx', mode: 0o600 })) + const handle = await open(temporary, constants.O_RDWR) + try { await handle.sync() } finally { await handle.close() } + await rename(temporary, path) + return { processedBytes, totalBytes: declared } + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}) + throw error + } +} + +export class OfficialDshDownloader { + constructor({ fetchImpl = fetch, attempts = 3, retryMs = 250 }) { + this.fetchImpl = fetchImpl + this.attempts = attempts + this.retryMs = retryMs + } + + async retry(operation) { + let lastError + for (let attempt = 1; attempt <= this.attempts; attempt += 1) { + try { + return await operation(attempt) + } catch (error) { + lastError = error + const status = error?.status + if (error?.retryable === false + || (Number.isInteger(status) && status < 500 && status !== 408 && status !== 429) + || attempt === this.attempts) { + throw error + } + await delay(this.retryMs) + } + } + throw lastError + } + + async download(version, root, { onProgress = async () => {} } = {}) { + compareDshVersions(version, version) + await mkdir(root, { recursive: true }) + const metadataUrl = officialDshMetadataUrl() + const metadata = await this.retry(async () => { + const metadataResponse = exactResponse(await this.fetchImpl(metadataUrl, { + headers: { accept: 'application/vnd.npm.install-v1+json', 'accept-encoding': 'identity' }, + redirect: 'error', + signal: AbortSignal.timeout(15_000), + }), metadataUrl, 'official DSH metadata') + return boundedBytes(metadataResponse, 'official DSH metadata', OFFICIAL_DSH_METADATA_LIMIT) + }) + let packument + try { packument = JSON.parse(metadata.toString('utf8')) } catch { throw new Error('official DSH metadata is not valid JSON') } + const selected = packument?.versions?.[version] + if (selected?.name !== OFFICIAL_DSH_PACKAGE || selected?.version !== version) { + throw new Error('official DSH metadata has no coherent requested version') + } + const tarballUrl = officialDshTarballUrl(version) + if (selected?.dist?.tarball !== tarballUrl.href) throw new Error('official DSH tarball URL is not canonical') + const metadataPath = join(root, 'official-dsh-packument.json') + const tarballPath = join(root, 'official-dsh.tgz') + await atomicBytes(metadataPath, metadata) + try { + let reportedBytes = metadata.byteLength + const result = await this.retry(async () => { + const tarballResponse = exactResponse(await this.fetchImpl(tarballUrl, { + headers: { accept: 'application/octet-stream', 'accept-encoding': 'identity' }, + redirect: 'error', + }), tarballUrl, 'official DSH tarball') + if (tarballResponse.headers?.get?.('content-encoding')) { + const error = new Error('official DSH tarball must not use content encoding') + error.retryable = false + throw error + } + const declaredTarball = responseLength(tarballResponse) + const knownTotal = declaredTarball === null ? null : metadata.byteLength + declaredTarball + await onProgress({ + processedBytes: reportedBytes, + totalBytes: knownTotal, + processedItems: 1, + totalItems: 2, + detail: 'official-dsh-metadata', + }) + return streamToFile( + tarballResponse, + tarballPath, + 'official DSH tarball', + OFFICIAL_DSH_TARBALL_LIMIT, + metrics => { + reportedBytes = Math.max(reportedBytes, metadata.byteLength + metrics.processedBytes) + return onProgress({ + processedBytes: reportedBytes, + totalBytes: knownTotal, + processedItems: 1, + totalItems: 2, + detail: 'official-dsh-tarball', + }) + }, + ) + }) + await onProgress({ + processedBytes: Math.max(reportedBytes, metadata.byteLength + result.processedBytes), + totalBytes: result.totalBytes === null ? null : metadata.byteLength + result.totalBytes, + processedItems: 2, + totalItems: 2, + detail: 'official-dsh-tarball', + }) + return Object.freeze({ metadataPath, tarballPath }) + } catch (error) { + await rm(metadataPath, { force: true }).catch(() => {}) + await rm(tarballPath, { force: true }).catch(() => {}) + throw error + } + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/preparer.mjs b/upstream/container/control-plane/modules/updater/lib/preparer.mjs new file mode 100644 index 0000000..16dfef3 --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/preparer.mjs @@ -0,0 +1,206 @@ +import { createHash, randomUUID } from 'node:crypto' +import { createWriteStream } from 'node:fs' +import { link, mkdir, open, readFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { Readable, Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { setTimeout as delay } from 'node:timers/promises' +import { parseBootstrapManifest, parseEnvironmentManifest } from '../../../../platform/lib/contracts.mjs' +import { OfficialDshDownloader } from './official-dsh-download.mjs' + +async function verifyExisting(path, descriptor) { + const handle = await open(path, 'r') + const hash = createHash('sha256') + let size = 0 + try { + for await (const chunk of handle.createReadStream({ autoClose: false })) { + size += chunk.byteLength + if (size > descriptor.size) return false + hash.update(chunk) + } + } finally { + await handle.close() + } + return size === descriptor.size && hash.digest('hex') === descriptor.sha256 +} + +async function download(descriptor, destination, fetchImpl, onChunk) { + let lastError + let reportedBytes = 0 + for (let attempt = 1; attempt <= 3; attempt += 1) { + const temporary = `${destination}.${randomUUID()}.tmp` + try { + const response = await fetchImpl(descriptor.url, { + headers: { 'accept-encoding': 'identity' }, + }) + if (!response.ok) { + const error = new Error(`Artifact ${descriptor.id} returned HTTP ${String(response.status)}`) + error.status = response.status + throw error + } + if (response.body === null || response.body === undefined) throw new Error(`Artifact ${descriptor.id} has no response body`) + const contentEncoding = response.headers?.get?.('content-encoding') + if (contentEncoding !== null && contentEncoding !== undefined && contentEncoding !== '') { + const error = new Error(`Artifact ${descriptor.id} must not use content encoding`) + error.retryable = false + throw error + } + const declaredHeader = response.headers?.get?.('content-length') + if (declaredHeader !== null && declaredHeader !== undefined && declaredHeader !== '') { + const declared = Number(declaredHeader) + if (!Number.isSafeInteger(declared) || declared !== descriptor.size) { + throw new Error(`Artifact ${descriptor.id} does not match stable metadata`) + } + } + const hash = createHash('sha256') + let size = 0 + const meter = new Transform({ + transform(chunk, _encoding, callback) { + size += chunk.byteLength + if (size > descriptor.size) { + callback(new Error(`Artifact ${descriptor.id} does not match stable metadata`)) + return + } + hash.update(chunk) + reportedBytes = Math.max(reportedBytes, size) + Promise.resolve(onChunk(reportedBytes)).then(() => callback(null, chunk), callback) + }, + }) + await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(temporary, { flags: 'wx', mode: 0o600 })) + if (size !== descriptor.size || hash.digest('hex') !== descriptor.sha256) { + throw new Error(`Artifact ${descriptor.id} does not match stable metadata`) + } + const handle = await open(temporary, 'r+') + try { await handle.sync() } finally { await handle.close() } + try { + await link(temporary, destination) + await rm(temporary) + } catch (error) { + if (error?.code !== 'EEXIST') throw error + if (!await verifyExisting(destination, descriptor)) { + throw new Error(`existing Artifact ${descriptor.id} differs from the target`) + } + await rm(temporary, { force: true }) + } + return destination + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}) + lastError = error + const status = error?.status + if (error?.retryable === false || (Number.isInteger(status) && status < 500 && status !== 408 && status !== 429) || attempt === 3) throw error + await delay(250) + } + } + throw lastError +} + +export class TargetPreparer { + constructor({ untrustedRoot, trust, fetchImpl = fetch, officialDsh = new OfficialDshDownloader({ fetchImpl }) }) { + this.untrustedRoot = untrustedRoot + this.trust = trust + this.fetchImpl = fetchImpl + this.officialDsh = officialDsh + } + + async prepare(stable, { onProgress = async () => {} } = {}) { + const taskRoot = join(this.untrustedRoot, `target-${String(stable.targetSequence)}`) + await mkdir(taskRoot, { recursive: true }) + const downloads = new Map() + const paths = new Map() + const receipts = new Map() + const authorities = new Map() + let downloadedBytes = 0 + let completedDownloads = 0 + let totalBytes = stable.artifacts.reduce((total, descriptor) => total + descriptor.size, 0) + let totalItems = stable.artifacts.length + const importDescriptor = async (descriptor, parentReceipt = null) => { + const authority = parentReceipt ?? 'stable' + if (authorities.has(descriptor.id) && authorities.get(descriptor.id) !== authority) { + throw new Error(`Artifact ID ${descriptor.id} is reused across authorities`) + } + authorities.set(descriptor.id, authority) + if (!downloads.has(descriptor.id) && !stable.artifacts.some(root => root.id === descriptor.id)) { + totalBytes += descriptor.size + totalItems += 1 + } + if (!downloads.has(descriptor.id)) { + const downloadBase = downloadedBytes + const downloaded = await download( + descriptor, + join(taskRoot, descriptor.id), + this.fetchImpl, + async processed => { + await onProgress({ + processedBytes: downloadBase + processed, + totalBytes, + processedItems: completedDownloads, + totalItems, + }) + }, + ) + downloadedBytes = downloadBase + descriptor.size + downloads.set(descriptor.id, downloaded) + completedDownloads += 1 + await onProgress({ processedBytes: downloadedBytes, totalBytes, processedItems: completedDownloads, totalItems }) + } + if (!receipts.has(descriptor.id)) { + const receipt = await this.trust.importArtifact( + descriptor.id, + downloads.get(descriptor.id), + parentReceipt, + ) + receipts.set(descriptor.id, receipt) + paths.set(descriptor.id, receipt.path) + } + return receipts.get(descriptor.id) + } + for (const descriptor of stable.artifacts) await importDescriptor(descriptor) + + const prepareManifest = async (reference, parser) => { + const manifestReceipt = receipts.get(reference.manifestArtifactId) + const signatureReceipt = receipts.get(reference.signatureArtifactId) + await this.trust.acceptManifest(manifestReceipt.token, signatureReceipt.token) + const manifest = parser(await readFile(paths.get(reference.manifestArtifactId))) + for (const descriptor of manifest.artifacts) await importDescriptor(descriptor, manifestReceipt.token) + return { manifest, manifestReceipt } + } + const bootstrap = await prepareManifest(stable.desired.bootstrap, parseBootstrapManifest) + const environment = await prepareManifest(stable.desired.environment, parseEnvironmentManifest) + const dshBaseBytes = downloadedBytes + const dshBaseItems = completedDownloads + const dshSources = await this.officialDsh.download( + stable.desired.dsh.version, + join(taskRoot, 'official-dsh'), + { + onProgress: metrics => onProgress({ + processedBytes: dshBaseBytes + metrics.processedBytes, + totalBytes: metrics.totalBytes === null ? null : totalBytes + metrics.totalBytes, + processedItems: dshBaseItems + metrics.processedItems, + totalItems: totalItems + metrics.totalItems, + detail: metrics.detail, + }), + }, + ) + const dshReceipt = await this.trust.ensureOfficialDsh( + stable.desired.dsh.version, + dshSources.metadataPath, + dshSources.tarballPath, + ) + return Object.freeze({ + stable, + paths, + receipts, + bootstrap, + environment, + dsh: Object.freeze({ version: stable.desired.dsh.version, receipt: dshReceipt }), + receiptTokens: Object.freeze([...receipts.values(), dshReceipt].map(receipt => receipt.token)), + }) + } + + async prepareExperimental(candidate, { onProgress = async () => {} } = {}) { + const taskRoot = join(this.untrustedRoot, `experimental-${candidate.version}`) + const sources = await this.officialDsh.download(candidate.version, taskRoot, { onProgress }) + const receipt = await this.trust.ensureOfficialDsh(candidate.version, sources.metadataPath, sources.tarballPath) + return Object.freeze({ version: candidate.version, receipt, receiptTokens: Object.freeze([receipt.token]) }) + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/recovery.mjs b/upstream/container/control-plane/modules/updater/lib/recovery.mjs new file mode 100644 index 0000000..d225b8f --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/recovery.mjs @@ -0,0 +1,82 @@ +const TERMINAL_PHASES = new Set(['committed', 'rolled-back', 'failed']) +const PRE_SWITCH_PHASES = new Set(['planning', 'candidate-ready', 'suspended']) + +export async function recoverInterruptedUpdate({ journal, snapshots, activator, resume = true }) { + const transaction = await journal.read() + if (transaction === undefined || TERMINAL_PHASES.has(transaction.phase)) return transaction + + if (PRE_SWITCH_PHASES.has(transaction.phase)) { + if (resume) await activator.resumeDsh().catch(() => {}) + return journal.transition('failed', { error: 'interrupted before Runtime switch' }) + } + + const restoring = transaction.phase === 'restoring-data' + ? transaction + : await journal.transition('restoring-data', { error: 'recovering interrupted update' }) + if (resume) await activator.suspendDsh().catch(() => {}) + await activator.restoreDeployment(restoring.from, { resume: false }) + if (restoring.snapshotId !== null) await snapshots.restore(restoring.snapshotId) + if (resume) await activator.resumeDsh() + return journal.transition('rolled-back', { error: 'recovered interrupted update' }) +} + +export async function reconcileRecoveredState({ journal, state }) { + const [transaction, persisted] = await Promise.all([journal.read(), state.read()]) + const sameTask = transaction !== undefined && persisted.taskId === transaction.transactionId + // A metadata check has no journal and cannot be resumed after Management + // restarts. Leaving this state behind would keep every UI showing a spinner + // and could make startup mistake the check for an interrupted update. + if (persisted.status === 'checking' && !sameTask) { + return { + transaction, + persisted: await state.write('idle', { + taskId: null, + progress: 0, + error: null, + checkSource: null, + }), + } + } + if (sameTask && !['idle', 'success', 'failed'].includes(persisted.status)) { + if (transaction.phase === 'committed') { + return { transaction, persisted: await state.write('success', { progress: 100, error: null }) } + } + if (['rolled-back', 'failed'].includes(transaction.phase)) { + return { + transaction, + persisted: await state.write('failed', { error: transaction.error ?? 'interrupted update was not committed' }), + } + } + } + return { transaction, persisted } +} + +export function resumeInterruptedReconcile({ coordinator, persisted, report, audit }) { + const task = coordinator.startReconcile() + const context = { + taskId: task.taskId, + interruptedTaskId: persisted.taskId ?? null, + } + const started = Promise.resolve() + .then(() => persisted.taskId === null || persisted.taskId === undefined + ? undefined + : audit('update.failed', { + taskId: persisted.taskId, + outcome: 'interrupted', + resumedByTaskId: task.taskId, + level: 'warning', + })) + .then(() => report('update.resume.started', context)) + .catch(() => {}) + void task.completion.then( + async () => { + await started + await report('update.resume.completed', context) + }, + async error => { + await started + await report('update.resume.failed', { ...context, error }) + }, + ).catch(() => {}) + return task +} diff --git a/upstream/container/control-plane/modules/updater/lib/rollback.mjs b/upstream/container/control-plane/modules/updater/lib/rollback.mjs new file mode 100644 index 0000000..93552f7 --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/rollback.mjs @@ -0,0 +1,143 @@ +import { createHash } from 'node:crypto' +import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs' +import { TrustError } from '../../../../platform/lib/validation.mjs' + +function deploymentIdentity(deployment) { + return { + dsh: deployment.dsh, + environment: deployment.environment, + runtime: deployment.runtime, + receiptTokens: [...deployment.receiptTokens].sort(), + } +} + +function recordIdentity(record) { + return deploymentIdentity({ + dsh: record.dshVersion, + environment: record.environmentVersion, + runtime: record.id, + receiptTokens: record.receiptTokens, + }) +} + +function recordMatchesDeployment(record, deployment) { + return record.dshVersion === deployment.dsh + && record.environmentVersion === deployment.environment + && [...record.receiptTokens].sort().join('\0') === [...deployment.receiptTokens].sort().join('\0') +} + +function sameRecordContent(left, right) { + return left.authority === right.authority + && left.targetSequence === right.targetSequence + && left.dshVersion === right.dshVersion + && left.environmentVersion === right.environmentVersion + && left.environment.sha256 === right.environment.sha256 + && left.pristine.sha256 === right.pristine.sha256 + && left.runtime.sha256 === right.runtime.sha256 + && left.systemPlugins.sha256 === right.systemPlugins.sha256 +} + +function bindPlan(value) { + return Object.freeze({ + ...value, + planId: createHash('sha256').update(canonicalJson(value)).digest('hex'), + }) +} + +export class CompleteStateRecovery { + constructor({ journal, snapshots, activator }) { + this.journal = journal + this.snapshots = snapshots + this.activator = activator + } + + async plan() { + const transaction = await this.journal.read() + if (transaction?.phase === 'committed' && transaction.snapshotId !== null) { + const current = await this.activator.currentDeployment() + if ( + current.dsh === transaction.to.dsh + && current.environment === transaction.to.environment + && current.runtime === transaction.to.runtime + ) { + let previous = deploymentIdentity(transaction.from) + if (this.activator.rollbackDeployments !== undefined) { + const slots = await this.activator.rollbackDeployments() + if ( + slots.current?.id !== current.runtime + || slots.previous === null + || !recordMatchesDeployment(slots.previous, transaction.from) + ) return null + previous = recordIdentity(slots.previous) + } + const snapshot = await this.snapshots.inspect(transaction.snapshotId) + if ( + snapshot.runtimeId !== transaction.from.runtime + || snapshot.environmentVersion !== transaction.from.environment + || snapshot.dshVersion !== transaction.from.dsh + ) throw new TrustError('rollback snapshot does not describe the previous deployment') + return bindPlan({ + transactionId: transaction.transactionId, + mode: transaction.mode, + current: deploymentIdentity(current), + previous, + snapshot: { + id: snapshot.id, + createdAt: snapshot.createdAt, + sha256: snapshot.archiveSha256, + size: snapshot.archiveSize, + }, + }) + } + } + if (this.activator.rollbackDeployments === undefined) return null + const { current, previous } = await this.activator.rollbackDeployments() + if (current === null || previous === null) return null + if (sameRecordContent(current, previous)) return null + return bindPlan({ + transactionId: null, + mode: 'stable', + current: recordIdentity(current), + previous: recordIdentity(previous), + snapshot: null, + }) + } + + async restore(planId, { + confirmDataLoss = false, + requireConfirmation = false, + onProgress = async () => {}, + } = {}) { + if (requireConfirmation && confirmDataLoss !== true) { + throw new TrustError('return to Stable requires explicit data-loss confirmation') + } + const plan = await this.plan() + if (plan === null || plan.planId !== planId) throw new TrustError('rollback plan is stale or unavailable') + if (requireConfirmation && plan.snapshot === null) { + throw new TrustError('return to Stable requires a verified data snapshot') + } + if (plan.snapshot === null) { + await onProgress('switching', 35, {}) + await this.activator.rollback(plan.previous.runtime) + await onProgress('verifying', 90, {}) + return Object.freeze({ status: 'rolled-back', transactionId: null }) + } + let transaction = await this.journal.transition('restoring-data', { error: 'manual complete-state rollback' }) + await onProgress('stopping', 15, {}) + await this.activator.suspendDsh() + try { + await onProgress('switching', 35, {}) + await this.activator.restoreDeployment({ ...transaction.from, runtime: plan.previous.runtime }, { resume: false }) + await onProgress('restoring-data', 65, {}) + await this.snapshots.restore(transaction.snapshotId, { + onProgress: metrics => onProgress('restoring-data', 65, metrics), + }) + await onProgress('verifying', 85, {}) + await this.activator.resumeDsh() + transaction = await this.journal.transition('rolled-back', { error: 'manual complete-state rollback completed' }) + return Object.freeze({ status: 'rolled-back', transactionId: transaction.transactionId }) + } catch (error) { + throw error + } + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/scheduler.mjs b/upstream/container/control-plane/modules/updater/lib/scheduler.mjs new file mode 100644 index 0000000..d5a9e24 --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/scheduler.mjs @@ -0,0 +1,55 @@ +export class UpdateScheduler { + constructor({ + check, + intervalSeconds = 21_600, + onError = async () => {}, + random = Math.random, + setTimer = setTimeout, + clearTimer = clearTimeout, + }) { + if (!Number.isSafeInteger(intervalSeconds) || intervalSeconds < 60) throw new Error('update interval must be at least 60 seconds') + this.check = check + this.onError = onError + this.intervalMs = intervalSeconds * 1_000 + this.random = random + this.setTimer = setTimer + this.clearTimer = clearTimer + this.timer = undefined + this.enabled = false + } + + schedule() { + const jitter = 0.9 + this.random() * 0.2 + const timer = this.setTimer(async () => { + if (this.timer !== timer) return + this.timer = undefined + try { + await this.check() + } catch (error) { + await Promise.resolve().then(() => this.onError(error)).catch(() => {}) + } + if (this.enabled && this.timer === undefined) this.schedule() + }, Math.round(this.intervalMs * jitter)) + this.timer = timer + timer.unref?.() + } + + start() { + this.enabled = true + if (this.timer === undefined) this.schedule() + } + + configure({ enabled, intervalSeconds }) { + if (typeof enabled !== 'boolean') throw new Error('automatic check enabled must be boolean') + if (!Number.isSafeInteger(intervalSeconds) || intervalSeconds < 60) throw new Error('update interval must be at least 60 seconds') + this.stop() + this.intervalMs = intervalSeconds * 1_000 + if (enabled) this.start() + } + + stop() { + this.enabled = false + if (this.timer !== undefined) this.clearTimer(this.timer) + this.timer = undefined + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/snapshot-client.mjs b/upstream/container/control-plane/modules/updater/lib/snapshot-client.mjs new file mode 100644 index 0000000..d92ddbd --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/snapshot-client.mjs @@ -0,0 +1,29 @@ +export class SnapshotClient { + constructor(api, scope) { + if (!['dsh', 'user-plugin'].includes(scope)) throw new Error('snapshot scope is invalid') + this.api = api + this.scope = scope + } + + async call(operation, body, onProgress) { + const response = await this.api.request('POST', `/v1/${this.scope}/${operation}`, body) + if (response.progress !== null && onProgress !== undefined) await onProgress(response.progress) + return response.result + } + + create(value = undefined) { + if (this.scope === 'dsh') { + const { onProgress, ...body } = value + return this.call('create', body, onProgress) + } + return this.call('create', { id: value }) + } + + inspect(id) { return this.call('inspect', { id }) } + + restore(id, { onProgress } = {}) { + return this.call('restore', { id }, onProgress) + } + + remove(id) { return this.call('remove', { id }) } +} diff --git a/upstream/container/control-plane/modules/updater/lib/snapshots.mjs b/upstream/container/control-plane/modules/updater/lib/snapshots.mjs new file mode 100644 index 0000000..822f547 --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/snapshots.mjs @@ -0,0 +1,199 @@ +import { spawn } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { lstat, mkdir, open, readFile, readdir, rename, rm } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs' +import { exactKeys, isoTimestamp, parseJsonDocument, plainObject, TrustError } from '../../../../platform/lib/validation.mjs' + +function validId(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) { + throw new TrustError('snapshot ID is invalid') + } + return value +} + +function version(value, label) { + if (typeof value !== 'string' || value === '' || value.length > 128 || /[\0/\\]/.test(value)) { + throw new TrustError(`${label} is invalid`) + } + return value +} + +function parseManifest(bytes) { + const value = parseJsonDocument(bytes, 'snapshot manifest') + exactKeys(value, [ + 'archiveSha256', 'archiveSize', 'createdAt', 'dshVersion', 'environmentVersion', + 'id', 'runtimeId', 'schema', 'source', + ], 'snapshot manifest') + if (value.schema !== 1) throw new TrustError('snapshot manifest schema must be 1') + validId(value.id) + if (value.source !== '/data/dsh') throw new TrustError('snapshot source is invalid') + if (typeof value.archiveSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.archiveSha256)) { + throw new TrustError('snapshot archive SHA-256 is invalid') + } + if (!Number.isSafeInteger(value.archiveSize) || value.archiveSize < 0) { + throw new TrustError('snapshot archive size is invalid') + } + isoTimestamp(value.createdAt, 'snapshot creation time') + version(value.runtimeId, 'snapshot runtime ID') + version(value.environmentVersion, 'snapshot Environment version') + version(value.dshVersion, 'snapshot DSH version') + return Object.freeze(value) +} + +function run(command, args) { + return new Promise((resolveRun, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + const stdout = [] + const stderr = [] + child.stdout.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.once('error', reject) + child.once('exit', code => code === 0 + ? resolveRun(Buffer.concat(stdout).toString('utf8')) + : reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString('utf8').trim()}`))) + }) +} + +async function hashFile(path) { + const handle = await open(path, 'r') + const hash = createHash('sha256') + let size = 0 + try { + for await (const chunk of handle.createReadStream({ autoClose: false })) { + hash.update(chunk) + size += chunk.byteLength + } + } finally { + await handle.close() + } + return { sha256: hash.digest('hex'), size } +} + +async function syncDirectory(path) { + const handle = await open(path, 'r') + try { await handle.sync() } finally { await handle.close() } +} + +async function countTree(path) { + const details = await lstat(path) + if (!details.isDirectory() || details.isSymbolicLink()) return 1 + let count = 0 + for (const entry of await readdir(path)) count += await countTree(join(path, entry)) + return count +} + +export class PersistentStateSnapshots { + constructor({ root, sourceRoot = '/data/dsh', now = () => new Date(), runImpl = run }) { + this.root = resolve(root) + this.sourceRoot = resolve(sourceRoot) + this.now = now + this.run = runImpl + } + + path(id) { + return join(this.root, 'versions', validId(id)) + } + + async create({ id = randomUUID(), runtimeId, environmentVersion, dshVersion, onProgress = async () => {} }) { + validId(id) + const sourceDetails = await lstat(this.sourceRoot) + if (!sourceDetails.isDirectory() || sourceDetails.isSymbolicLink()) { + throw new TrustError('snapshot source must be a real directory') + } + const versions = join(this.root, 'versions') + const destination = this.path(id) + await lstat(destination).then( + () => { throw new TrustError(`snapshot ${id} already exists`) }, + error => { if (error?.code !== 'ENOENT') throw error }, + ) + await mkdir(versions, { recursive: true }) + const staging = join(versions, `.${id}.${randomUUID()}.tmp`) + await mkdir(staging) + const archive = join(staging, 'data.tar.gz') + try { + const totalItems = await countTree(this.sourceRoot) + await onProgress({ processedItems: 0, totalItems }) + await this.run('tar', ['-czpf', archive, '-C', this.sourceRoot, '.']) + await onProgress({ processedItems: totalItems, totalItems }) + const measured = await hashFile(archive) + const manifest = canonicalJson({ + schema: 1, + id, + createdAt: this.now().toISOString(), + source: '/data/dsh', + runtimeId: version(runtimeId, 'snapshot runtime ID'), + environmentVersion: version(environmentVersion, 'snapshot Environment version'), + dshVersion: version(dshVersion, 'snapshot DSH version'), + archiveSha256: measured.sha256, + archiveSize: measured.size, + }) + parseManifest(manifest) + const handle = await open(join(staging, 'manifest.json'), 'wx', 0o600) + try { await handle.writeFile(manifest); await handle.sync() } finally { await handle.close() } + await syncDirectory(staging) + await rename(staging, destination) + await syncDirectory(versions) + return Object.freeze({ ...parseManifest(manifest), path: destination }) + } catch (error) { + await rm(staging, { recursive: true, force: true }) + throw error + } + } + + async inspect(id) { + const root = this.path(id) + const manifest = parseManifest(await readFile(join(root, 'manifest.json'))) + if (manifest.id !== id) throw new TrustError('snapshot manifest ID does not match its directory') + const archive = join(root, 'data.tar.gz') + const measured = await hashFile(archive) + if (measured.sha256 !== manifest.archiveSha256 || measured.size !== manifest.archiveSize) { + throw new TrustError('snapshot archive does not match its manifest', 'TRUST_ARTIFACT_MISMATCH') + } + const entries = (await this.run('tar', ['-tzf', archive])).split('\n').filter(Boolean) + if (entries.length === 0) throw new TrustError('snapshot archive is empty') + for (const entry of entries) { + if (entry.startsWith('/') || entry.split('/').includes('..')) { + throw new TrustError(`snapshot archive path is unsafe: ${entry}`) + } + } + return Object.freeze({ ...manifest, archive }) + } + + async restore(id, { onProgress = async () => {} } = {}) { + const snapshot = await this.inspect(id) + await mkdir(this.sourceRoot, { recursive: true }) + for (const entry of await readdir(this.sourceRoot)) { + await rm(join(this.sourceRoot, entry), { recursive: true, force: true }) + } + const entries = (await this.run('tar', ['-tzf', snapshot.archive])).split('\n').filter(Boolean) + await onProgress({ processedItems: 0, totalItems: entries.length }) + const ownership = process.getuid?.() === 0 ? [] : ['--no-same-owner'] + await this.run('tar', ['-xzpf', snapshot.archive, ...ownership, '-C', this.sourceRoot]) + await onProgress({ processedItems: entries.length, totalItems: entries.length }) + await syncDirectory(this.sourceRoot) + return snapshot + } + + async list() { + let names + try { names = await readdir(join(this.root, 'versions')) } catch (error) { + if (error?.code === 'ENOENT') return [] + throw error + } + const snapshots = [] + for (const name of names.filter(name => !name.startsWith('.')).sort()) snapshots.push(await this.inspect(name)) + return Object.freeze(snapshots) + } + + async remove(id) { + try { + await this.inspect(id) + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } + await rm(this.path(id), { recursive: true, force: true }) + return true + } +} diff --git a/upstream/container/control-plane/modules/updater/lib/state.mjs b/upstream/container/control-plane/modules/updater/lib/state.mjs new file mode 100644 index 0000000..107ed4e --- /dev/null +++ b/upstream/container/control-plane/modules/updater/lib/state.mjs @@ -0,0 +1,32 @@ +import { readFile } from 'node:fs/promises' +import { durableReplace } from '../../../../platform/lib/atomic.mjs' + +export const TERMINAL_UPDATE_STATES = new Set(['idle', 'success', 'failed']) + +export class UpdateStateStore { + constructor(path, now = () => new Date()) { + this.path = path + this.now = now + } + + async read() { + try { + return JSON.parse(await readFile(this.path, 'utf8')) + } catch (error) { + if (error?.code === 'ENOENT') return { status: 'idle', taskId: null, progress: 0, error: null } + throw error + } + } + + async write(status, fields = {}) { + const previous = await this.read() + const next = { + ...previous, + ...fields, + status, + updatedAt: this.now().toISOString(), + } + await durableReplace(this.path, `${JSON.stringify(next)}\n`) + return Object.freeze(next) + } +} diff --git a/upstream/container/control-plane/services/access-manager/component.json b/upstream/container/control-plane/services/access-manager/component.json new file mode 100644 index 0000000..4f149bb --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/component.json @@ -0,0 +1,30 @@ +{ + "schema": 1, + "id": "access-manager", + "type": "service", + "command": { + "executable": "/usr/local/bin/node", + "args": ["/run/dsh-platform/views/bootstrap/control-plane/services/access-manager/supervisor.mjs"], + "timeoutSeconds": 30 + }, + "environment": {}, + "lifecycle": { + "prepare": null, + "preStart": null, + "postStart": null, + "preStop": null, + "stop": null, + "postStop": null + }, + "health": { + "type": "exec", + "command": { + "executable": "/usr/local/bin/node", + "args": ["/run/dsh-platform/views/bootstrap/control-plane/services/access-manager/supervisor.mjs", "health"], + "timeoutSeconds": 5 + }, + "intervalSeconds": 1, + "timeoutSeconds": 30 + }, + "logging": { "stdout": true, "stderr": true } +} diff --git a/upstream/container/control-plane/services/access-manager/index.mjs b/upstream/container/control-plane/services/access-manager/index.mjs new file mode 100644 index 0000000..e4626cd --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/index.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import { chmod, mkdir, rm } from 'node:fs/promises' +import { dirname } from 'node:path' +import { PlatformPaths } from '../../../platform/lib/paths.mjs' +import { AccessStateStore } from './lib/store.mjs' +import { AccessService, createAccessHttpServer } from './lib/server.mjs' + +const paths = new PlatformPaths( + process.env.DSH_PLATFORM_DATA ?? '/data/platform', + process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform', +) + +async function listen(server, path) { + await mkdir(dirname(path), { recursive: true }) + await rm(path, { force: true }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(path, resolve) + }) + await chmod(path, 0o600) +} + +const classificationToken = process.env.DSH_ACCESS_CLASSIFICATION_TOKEN +delete process.env.DSH_ACCESS_CLASSIFICATION_TOKEN +if (typeof classificationToken !== 'string' || classificationToken.length < 32) { + throw new Error('Access classification token is unavailable') +} +const store = new AccessStateStore({ root: paths.accessStateRoot }) +await store.prepare() +const service = new AccessService({ + store, + classificationToken, + report: (message, fields) => { + process.send?.({ type: 'diagnostic', message, fields }) + }, +}) +await service.reconcileRuntimePolicy() +const access = createAccessHttpServer({ service }) +const recovery = createAccessHttpServer({ service, surface: 'recovery' }) +await listen(access, paths.accessSocket) +await listen(recovery, paths.accessRecoverySocket) +process.send?.({ type: 'ready', componentReady: true }) + +let resolveSignal +const signal = new Promise(resolve => { resolveSignal = resolve }) +process.once('SIGINT', () => resolveSignal()) +process.once('SIGTERM', () => resolveSignal()) +await signal +await Promise.all([ + new Promise(resolve => access.close(resolve)), + new Promise(resolve => recovery.close(resolve)), +]) diff --git a/upstream/container/control-plane/services/access-manager/lib/capabilities.mjs b/upstream/container/control-plane/services/access-manager/lib/capabilities.mjs new file mode 100644 index 0000000..39c3148 --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/lib/capabilities.mjs @@ -0,0 +1,64 @@ +import { createHash, randomBytes } from 'node:crypto' + +function digest(value) { return createHash('sha256').update(value).digest('hex') } +function token(random) { return `dshcap_${random(32).toString('base64url')}` } + +export function capabilityTarget(path) { + if (typeof path !== 'string' || path.length === 0 || path.length > 8_192 || !path.startsWith('/')) { + throw new TypeError('capability target is invalid') + } + const value = new URL(path, 'http://capability.internal') + if (value.origin !== 'http://capability.internal' || value.hash !== '') throw new TypeError('capability target is invalid') + return `${value.pathname}${value.search}` +} + +export class CapabilityStore { + constructor({ now = Date.now, random = randomBytes, ttlMs = 5_000 } = {}) { + this.now = now + this.random = random + this.ttlMs = ttlMs + this.capabilities = new Map() + } + + prune() { + const now = this.now() + for (const [key, value] of this.capabilities) if (value.expiresAt <= now) this.capabilities.delete(key) + } + + issue(session, account, { audience, method, target }) { + if (!['management', 'maintenance', 'plugin'].includes(audience)) throw new TypeError('capability audience is invalid') + if (!/^(?:GET|HEAD|POST|PUT|DELETE)$/.test(method ?? '')) throw new TypeError('capability method is invalid') + this.prune() + const value = token(this.random) + this.capabilities.set(digest(value), { + audience, + method, + target: capabilityTarget(target), + sessionId: session.sessionId, + accountId: account.accountId, + mainCredentialVersion: account.mainCredential.version, + managementAdditionalCredentialVersion: account.managementAdditionalCredential.enabled + ? account.managementAdditionalCredential.version : null, + managementAccessVersion: account.managementAccess.version, + expiresAt: this.now() + this.ttlMs, + }) + return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.ttlMs).toISOString() }) + } + + consume(value, account, { audience, method, target }) { + this.prune() + if (typeof value !== 'string' || value.length > 512) return undefined + const key = digest(value) + const capability = this.capabilities.get(key) + this.capabilities.delete(key) + const additionalVersion = account.managementAdditionalCredential.enabled + ? account.managementAdditionalCredential.version : null + if (capability === undefined || capability.accountId !== account.accountId + || capability.mainCredentialVersion !== account.mainCredential.version + || capability.managementAdditionalCredentialVersion !== additionalVersion + || capability.managementAccessVersion !== account.managementAccess.version + || capability.audience !== audience || capability.method !== method + || capability.target !== capabilityTarget(target)) return undefined + return Object.freeze({ sessionId: capability.sessionId, audience: capability.audience }) + } +} diff --git a/upstream/container/control-plane/services/access-manager/lib/credentials.mjs b/upstream/container/control-plane/services/access-manager/lib/credentials.mjs new file mode 100644 index 0000000..c046b78 --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/lib/credentials.mjs @@ -0,0 +1,89 @@ +import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto' +import { promisify } from 'node:util' +import { AccessError } from './errors.mjs' + +const scrypt = promisify(scryptCallback) +const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u +const CONTROL_CHARACTERS = /\p{Cc}/u + +export const SCRYPT_POLICY = Object.freeze({ N: 32768, r: 8, p: 1, keyLength: 32, maxmem: 64 * 1024 * 1024 }) + +function codePoints(value) { return [...value].length } + +export function normalizeUsername(input) { + if (typeof input !== 'string') throw new AccessError('USERNAME_INVALID', 'username is invalid') + const value = input.trim().normalize('NFC') + const bytes = Buffer.byteLength(value, 'utf8') + if (value.length === 0 || codePoints(value) > 64 || bytes > 256 + || CONTROL_CHARACTERS.test(value) || BIDI_CONTROLS.test(value)) { + throw new AccessError('USERNAME_INVALID', 'username is invalid') + } + return value +} + +export function normalizePassword(input) { + if (typeof input !== 'string') throw new AccessError('PASSWORD_POLICY_VIOLATION', 'password does not meet policy') + const value = input.normalize('NFC') + const length = codePoints(value) + if (length < 8 || length > 1024 || Buffer.byteLength(value, 'utf8') > 1024 + || CONTROL_CHARACTERS.test(value) || BIDI_CONTROLS.test(value)) { + throw new AccessError('PASSWORD_POLICY_VIOLATION', 'password does not meet policy') + } + return value +} + +function validParameters(value) { + return value !== null && typeof value === 'object' + && Number.isInteger(value.N) && value.N >= 2 + && Number.isInteger(value.r) && value.r > 0 + && Number.isInteger(value.p) && value.p > 0 + && Number.isInteger(value.keyLength) && value.keyLength >= 16 + && Number.isInteger(value.maxmem) && value.maxmem >= 1024 * 1024 +} + +export async function createCredential(password, { + version = 1, + policy = SCRYPT_POLICY, + now = () => new Date(), + random = randomBytes, + derive = scrypt, +} = {}) { + const normalized = normalizePassword(password) + const salt = random(16) + const hash = await derive(normalized, salt, policy.keyLength, { + N: policy.N, r: policy.r, p: policy.p, maxmem: policy.maxmem, + }) + return Object.freeze({ + version, + algorithm: 'scrypt', + parameters: { ...policy }, + salt: Buffer.from(salt).toString('base64url'), + hash: Buffer.from(hash).toString('base64url'), + changedAt: now().toISOString(), + }) +} + +export function validCredential(value) { + if (value === null || typeof value !== 'object' || value.algorithm !== 'scrypt' + || !Number.isInteger(value.version) || value.version < 1 || !validParameters(value.parameters) + || typeof value.salt !== 'string' || typeof value.hash !== 'string' + || typeof value.changedAt !== 'string') return false + try { + return Buffer.from(value.salt, 'base64url').byteLength >= 16 + && Buffer.from(value.hash, 'base64url').byteLength === value.parameters.keyLength + && Number.isFinite(Date.parse(value.changedAt)) + } catch { return false } +} + +export async function verifyCredential(password, credential, { derive = scrypt } = {}) { + if (!validCredential(credential)) return false + let normalized + try { normalized = normalizePassword(password) } catch { return false } + const expected = Buffer.from(credential.hash, 'base64url') + const salt = Buffer.from(credential.salt, 'base64url') + const parameters = credential.parameters + const actual = Buffer.from(await derive(normalized, salt, parameters.keyLength, { + N: parameters.N, r: parameters.r, p: parameters.p, maxmem: parameters.maxmem, + })) + return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected) +} diff --git a/upstream/container/control-plane/services/access-manager/lib/errors.mjs b/upstream/container/control-plane/services/access-manager/lib/errors.mjs new file mode 100644 index 0000000..334b990 --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/lib/errors.mjs @@ -0,0 +1,19 @@ +export class AccessError extends Error { + constructor(code, message, statusCode = 400, details = {}) { + super(message) + this.name = 'AccessError' + this.code = code + this.statusCode = statusCode + this.details = details + } +} + +export function accessErrorBody(error) { + if (error instanceof AccessError) return { + error: error.message, + code: error.code, + ...(Number.isInteger(error.details.retryAfterSeconds) + ? { retryAfterSeconds: error.details.retryAfterSeconds } : {}), + } + return { error: 'access operation failed', code: 'ACCESS_INTERNAL_ERROR' } +} diff --git a/upstream/container/control-plane/services/access-manager/lib/exchanges.mjs b/upstream/container/control-plane/services/access-manager/lib/exchanges.mjs new file mode 100644 index 0000000..1980570 --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/lib/exchanges.mjs @@ -0,0 +1,97 @@ +import { createHash, randomBytes } from 'node:crypto' + +function digest(value) { return createHash('sha256').update(value).digest('hex') } +function token(prefix, random) { return `${prefix}_${random(32).toString('base64url')}` } + +export class ManagementExchangeStore { + constructor({ now = Date.now, random = randomBytes, handoffTtlMs = 30_000, pendingTtlMs = 5 * 60_000, pendingAttempts = 16 } = {}) { + this.now = now + this.random = random + this.handoffTtlMs = handoffTtlMs + this.pendingTtlMs = pendingTtlMs + this.pendingAttempts = pendingAttempts + this.handoffs = new Map() + this.pending = new Map() + } + + prune() { + const now = this.now() + for (const [key, value] of this.handoffs) if (value.expiresAt <= now) this.handoffs.delete(key) + for (const [key, value] of this.pending) if (value.expiresAt <= now) this.pending.delete(key) + } + + createHandoff(session, account, targetOrigin) { + this.prune() + const value = token('dshh', this.random) + this.handoffs.set(digest(value), { + accountId: account.accountId, + dshSessionId: session.sessionId, + mainCredentialVersion: account.mainCredential.version, + managementAccessVersion: account.managementAccess.version, + sourceDshOrigin: session.origin, + targetOrigin, + expiresAt: this.now() + this.handoffTtlMs, + }) + return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.handoffTtlMs).toISOString() }) + } + + consumeHandoff(value, account, targetOrigin) { + this.prune() + if (typeof value !== 'string' || value.length > 512) return undefined + const key = digest(value) + const handoff = this.handoffs.get(key) + this.handoffs.delete(key) + if (handoff === undefined || handoff.accountId !== account.accountId + || handoff.mainCredentialVersion !== account.mainCredential.version + || handoff.managementAccessVersion !== account.managementAccess.version + || handoff.targetOrigin !== targetOrigin) return undefined + return handoff + } + + createPending(account, { targetOrigin, sourceDshOrigin = null, sourceDshSessionId = null }) { + this.prune() + const value = token('dshmp', this.random) + this.pending.set(digest(value), { + accountId: account.accountId, + mainCredentialVersion: account.mainCredential.version, + managementAdditionalCredentialVersion: account.managementAdditionalCredential.version, + managementAccessVersion: account.managementAccess.version, + targetOrigin, + sourceDshOrigin, + sourceDshSessionId, + attempts: 0, + expiresAt: this.now() + this.pendingTtlMs, + }) + return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.pendingTtlMs).toISOString() }) + } + + peekPendingSource(value, account, targetOrigin) { + this.prune() + if (typeof value !== 'string' || value.length > 512) return undefined + const pending = this.pending.get(digest(value)) + if (pending === undefined || pending.accountId !== account.accountId + || pending.mainCredentialVersion !== account.mainCredential.version + || pending.managementAccessVersion !== account.managementAccess.version + || pending.targetOrigin !== targetOrigin) return undefined + return pending.sourceDshSessionId + } + + inspectPending(value, account, targetOrigin) { + this.prune() + if (typeof value !== 'string' || value.length > 512) return undefined + const key = digest(value) + const pending = this.pending.get(key) + if (pending === undefined || pending.accountId !== account.accountId + || pending.mainCredentialVersion !== account.mainCredential.version + || pending.managementAccessVersion !== account.managementAccess.version + || pending.targetOrigin !== targetOrigin) return undefined + pending.attempts += 1 + if (pending.attempts > this.pendingAttempts) { + this.pending.delete(key) + return undefined + } + return { key, value: pending } + } + + consumePending(key) { return this.pending.delete(key) } +} diff --git a/upstream/container/control-plane/services/access-manager/lib/rate-limiter.mjs b/upstream/container/control-plane/services/access-manager/lib/rate-limiter.mjs new file mode 100644 index 0000000..84874eb --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/lib/rate-limiter.mjs @@ -0,0 +1,241 @@ +import { AccessError } from './errors.mjs' + +export class AuthenticationLimiter { + constructor({ + globalLimit = 20, + windowMs = 60_000, + maxConcurrent = 2, + backoffThreshold = 5, + initialBackoffMs = 30_000, + maxBackoffMs = 15 * 60_000, + consecutiveResetMs = 24 * 60 * 60_000, + sourceWindows, + globalWindows, + clock = () => Date.now(), + } = {}) { + this.globalLimit = globalLimit + this.windowMs = windowMs + this.maxConcurrent = maxConcurrent + this.backoffThreshold = backoffThreshold + this.initialBackoffMs = initialBackoffMs + this.maxBackoffMs = maxBackoffMs + this.consecutiveResetMs = consecutiveResetMs + this.sourceWindows = sourceWindows ?? [ + { limit: 12, windowMs: 60 * 60_000 }, + { limit: 24, windowMs: 24 * 60 * 60_000 }, + ] + this.globalWindows = globalWindows ?? [ + { limit: globalLimit, windowMs }, + { limit: 60, windowMs: 60 * 60_000 }, + { limit: 120, windowMs: 24 * 60 * 60_000 }, + ] + this.maxSourceWindowMs = Math.max(...this.sourceWindows.map(value => value.windowMs)) + this.maxGlobalWindowMs = Math.max(...this.globalWindows.map(value => value.windowMs)) + this.clock = clock + this.global = [] + this.sources = new Map() + this.consecutiveFailures = new Map() + this.active = 0 + this.lastPruneAt = Number.NEGATIVE_INFINITY + } + + prune(values, now, windowMs = this.windowMs) { + while (values.length > 0 && this.timestamp(values[0]) <= now - windowMs) values.shift() + } + + timestamp(value) { return typeof value === 'number' ? value : value.at } + + retryAfterSeconds(values, windows, now) { + let retryAfterMs = 0 + for (const { limit, windowMs } of windows) { + const withinWindow = values.filter(value => this.timestamp(value) > now - windowMs) + if (withinWindow.length < limit) continue + retryAfterMs = Math.max( + retryAfterMs, + this.timestamp(withinWindow[withinWindow.length - limit]) + windowMs - now, + ) + } + return Math.max(0, Math.ceil(retryAfterMs / 1_000)) + } + + retryKey(accountId, sourceId, credential = 'main') { return `${accountId}\u0000${credential}\u0000${sourceId}` } + + retryPrefix(accountId, credential = 'all') { + return credential === 'all' ? `${accountId}\u0000` : `${accountId}\u0000${credential}\u0000` + } + + pruneExpiredSources(now) { + if (now - this.lastPruneAt < this.windowMs) return + this.lastPruneAt = now + for (const [key, values] of this.sources) { + this.prune(values, now, this.maxSourceWindowMs) + if (values.length === 0) this.sources.delete(key) + } + for (const [key, retry] of this.consecutiveFailures) { + if (retry.lastFailureAt <= now - this.consecutiveResetMs) { + this.consecutiveFailures.delete(key) + } + } + } + + currentRetry(accountId = 'unknown', sourceId = 'unknown', now = this.clock(), credential = 'main') { + const retryKey = this.retryKey(accountId, sourceId, credential) + const retry = this.consecutiveFailures.get(retryKey) + if (retry !== undefined && retry.lastFailureAt <= now - this.consecutiveResetMs) { + this.consecutiveFailures.delete(retryKey) + return undefined + } + return retry + } + + checkRetry(accountId = 'unknown', sourceId = 'unknown', credential = 'main') { + const now = this.clock() + const retry = this.currentRetry(accountId, sourceId, now, credential) + if (retry !== undefined && retry.blockedUntil > now) { + const retryAfterSeconds = Math.max(1, Math.ceil((retry.blockedUntil - now) / 1_000)) + throw new AccessError( + 'AUTHENTICATION_RETRY_REQUIRED', + `authentication retry is available in ${retryAfterSeconds} seconds`, + 429, + { retryAfterSeconds }, + ) + } + } + + enter(accountId = 'unknown', sourceId = 'unknown', credential = 'main') { + this.checkRetry(accountId, sourceId, credential) + const now = this.clock() + this.pruneExpiredSources(now) + const retryKey = this.retryKey(accountId, sourceId, credential) + const source = this.sources.get(retryKey) ?? [] + this.prune(source, now, this.maxSourceWindowMs) + this.sources.set(retryKey, source) + this.prune(this.global, now, this.maxGlobalWindowMs) + if (this.active >= this.maxConcurrent) { + throw new AccessError( + 'AUTHENTICATION_RATE_LIMITED', + 'authentication is temporarily unavailable', + 429, + { retryAfterSeconds: 1 }, + ) + } + const globalRetryAfterSeconds = this.retryAfterSeconds(this.global, this.globalWindows, now) + const sourceRetryAfterSeconds = this.retryAfterSeconds(source, this.sourceWindows, now) + if (sourceRetryAfterSeconds > 0) { + throw new AccessError( + 'AUTHENTICATION_RATE_LIMITED', + `browser authentication is available in ${sourceRetryAfterSeconds} seconds`, + 429, + { retryAfterSeconds: sourceRetryAfterSeconds }, + ) + } + if (globalRetryAfterSeconds > 0) { + throw new AccessError( + 'AUTHENTICATION_RATE_LIMITED', + `administrator authentication is available in ${globalRetryAfterSeconds} seconds`, + 429, + { retryAfterSeconds: globalRetryAfterSeconds }, + ) + } + this.active += 1 + let released = false + const release = success => { + if (released) return this.sourceStatus(accountId, sourceId, credential) + released = true + this.active -= 1 + if (success !== true) { + const failedAt = this.clock() + this.global.push({ at: failedAt, credential }) + source.push(failedAt) + const previous = this.consecutiveFailures.get(retryKey) + const failures = (previous !== undefined + && previous.lastFailureAt > failedAt - this.consecutiveResetMs ? previous.failures : 0) + 1 + const exponent = Math.max(0, failures - this.backoffThreshold) + const backoffMs = failures < this.backoffThreshold + ? 0 + : Math.min(this.maxBackoffMs, this.initialBackoffMs * (2 ** exponent)) + this.consecutiveFailures.set(retryKey, { + failures, + blockedUntil: backoffMs === 0 ? 0 : failedAt + backoffMs, + lastFailureAt: failedAt, + }) + } else { + this.consecutiveFailures.delete(this.retryKey(accountId, sourceId, credential)) + this.sources.delete(this.retryKey(accountId, sourceId, credential)) + } + return this.sourceStatus(accountId, sourceId, credential) + } + return { release } + } + + sourceStatus(accountId = 'unknown', sourceId = 'unknown', credential = 'main') { + const retry = this.currentRetry(accountId, sourceId, this.clock(), credential) + if (retry === undefined) return { consecutiveFailures: 0, retryAfterSeconds: 0 } + return { + consecutiveFailures: retry.failures, + retryAfterSeconds: Math.max(0, Math.ceil((retry.blockedUntil - this.clock()) / 1_000)), + } + } + + status(accountId = 'unknown', credential = 'all') { + const now = this.clock() + this.pruneExpiredSources(now) + this.prune(this.global, now, this.maxGlobalWindowMs) + const prefix = this.retryPrefix(accountId, credential) + for (const [key, retry] of this.consecutiveFailures) { + if (key.startsWith(prefix) && retry.lastFailureAt <= now - this.consecutiveResetMs) { + this.consecutiveFailures.delete(key) + } + } + const states = [...this.consecutiveFailures.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([, retry]) => ({ + consecutiveFailures: retry.failures, + retryAfterSeconds: Math.max(0, Math.ceil((retry.blockedUntil - this.clock()) / 1_000)), + })) + const sourcePrefix = prefix + const sourceRetryAfterSeconds = Math.max(0, ...[...this.sources.entries()] + .filter(([key]) => key.startsWith(sourcePrefix)) + .map(([, values]) => this.retryAfterSeconds(values, this.sourceWindows, now))) + const global = credential === 'all' + ? this.global : this.global.filter(value => value.credential === credential) + return { + activeSources: states.length, + consecutiveFailures: Math.max(0, ...states.map(value => value.consecutiveFailures)), + retryAfterSeconds: Math.max(0, ...states.map(value => value.retryAfterSeconds)), + sourceRetryAfterSeconds, + globalFailures: global.length, + globalRetryAfterSeconds: this.retryAfterSeconds(global, this.globalWindows, now), + } + } + + clear(accountId = 'unknown', credential = 'all') { + const previous = this.status(accountId, credential) + const prefix = this.retryPrefix(accountId, credential) + for (const key of this.consecutiveFailures.keys()) { + if (key.startsWith(prefix)) this.consecutiveFailures.delete(key) + } + for (const key of this.sources.keys()) { + if (key.startsWith(prefix)) this.sources.delete(key) + } + this.global = credential === 'all' + ? [] : this.global.filter(value => value.credential !== credential) + return { cleared: previous.activeSources > 0 || previous.globalFailures > 0, ...previous } + } + + clearGlobal(credential = 'all') { + const now = this.clock() + this.prune(this.global, now, this.maxGlobalWindowMs) + const matching = credential === 'all' + ? this.global : this.global.filter(value => value.credential === credential) + const globalFailures = matching.length + const globalRetryAfterSeconds = this.retryAfterSeconds(matching, this.globalWindows, now) + this.global = credential === 'all' + ? [] : this.global.filter(value => value.credential !== credential) + return { + cleared: globalFailures > 0, + globalFailures, + globalRetryAfterSeconds, + } + } +} diff --git a/upstream/container/control-plane/services/access-manager/lib/runtime-capabilities.mjs b/upstream/container/control-plane/services/access-manager/lib/runtime-capabilities.mjs new file mode 100644 index 0000000..0c6f450 --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/lib/runtime-capabilities.mjs @@ -0,0 +1,62 @@ +import { readFile } from 'node:fs/promises' + +function groupId(groupFile, name) { + for (const line of groupFile.split('\n')) { + const fields = line.split(':') + if (fields[0] === name && /^\d+$/.test(fields[2] ?? '')) return Number(fields[2]) + } + return null +} + +function supplementaryGroups(status) { + const line = status.split('\n').find(value => value.startsWith('Groups:')) + if (line === undefined) return [] + return line.slice('Groups:'.length).trim().split(/\s+/).filter(Boolean).map(Number).filter(Number.isInteger) +} + +function mountOption(mounts, mountPoint, prefix) { + for (const line of mounts.split('\n')) { + const fields = line.split(' ') + if (fields[1] !== mountPoint) continue + return fields[3]?.split(',').find(value => value.startsWith(prefix)) ?? null + } + return null +} + +async function optionalRead(path) { + try { return await readFile(path, 'utf8') } catch { return '' } +} + +export async function detectRuntimeCapabilities({ + groupPath = '/etc/group', + initStatusPath = '/proc/1/status', + mountsPath = '/proc/mounts', + ptraceScopePath = '/proc/sys/kernel/yama/ptrace_scope', +} = {}) { + const [groupsFile, initStatus, mounts, ptraceScopeValue] = await Promise.all([ + optionalRead(groupPath), + optionalRead(initStatusPath), + optionalRead(mountsPath), + optionalRead(ptraceScopePath), + ]) + const enabledGroup = groupId(groupsFile, 'dsh-sudo-true') + const disabledGroup = groupId(groupsFile, 'dsh-sudo-false') + const initGroups = supplementaryGroups(initStatus) + const dshRootCapabilityEffective = enabledGroup !== null && initGroups.includes(enabledGroup) + const sudoSelection = dshRootCapabilityEffective + ? 'enabled' + : (disabledGroup !== null && initGroups.includes(disabledGroup) ? 'disabled' : 'unknown') + const ptraceScope = /^\d+$/.test(ptraceScopeValue.trim()) ? Number(ptraceScopeValue.trim()) : null + const hidepid = mountOption(mounts, '/proc', 'hidepid=') + + return { + dshRootCapabilityEffective, + agentIsolationEffective: false, + details: { + sudoSelection, + ptraceScope, + procHidepid: hidepid === null ? null : Number(hidepid.slice('hidepid='.length)), + agentIsolationReason: 'shared-node-process-identity', + }, + } +} diff --git a/upstream/container/control-plane/services/access-manager/lib/server.mjs b/upstream/container/control-plane/services/access-manager/lib/server.mjs new file mode 100644 index 0000000..52c7d95 --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/lib/server.mjs @@ -0,0 +1,1282 @@ +import { createServer } from 'node:http' +import { createHash, randomBytes } from 'node:crypto' +import { timingSafeEqual } from 'node:crypto' +import { AuthenticationLimiter } from './rate-limiter.mjs' +import { AccessError, accessErrorBody } from './errors.mjs' +import { normalizePassword, normalizeUsername, verifyCredential } from './credentials.mjs' +import { BrowserSessionStore } from './sessions.mjs' +import { ManagementExchangeStore } from './exchanges.mjs' +import { CapabilityStore } from './capabilities.mjs' +import { ManagementTransitionStore } from './transitions.mjs' +import { detectRuntimeCapabilities } from './runtime-capabilities.mjs' +import { TotpFlowStore, TotpRetryLimiter, totpUri, verifyTotpCode } from './totp.mjs' + +const MAX_BODY_BYTES = 64 * 1024 +const RECOVERY_OPERATIONS = new Map([ + ['/v1/recovery/set-username', 'set-username'], + ['/v1/recovery/reset-access', 'reset-access'], + ['/v1/recovery/reset-password', 'reset-password'], + ['/v1/recovery/reset-management-password', 'reset-management-password'], + ['/v1/recovery/disable-management-password', 'disable-management-password'], + ['/v1/recovery/generate-key', 'generate-key'], + ['/v1/recovery/clear-retry', 'clear-retry'], +]) +const ACCESS_FAILURE_EVENTS = new Map([ + ['/v1/initialize', 'access.initialization.failed'], + ['/v1/dsh/initialize', 'access.initialization.failed'], + ['/v1/dsh/reset-authentication', 'access.authentication-reset.failed'], +]) +function identifier() { return randomBytes(32).toString('base64url') } +function isLoopbackHostname(hostname) { + if (hostname === 'localhost' || hostname === '[::1]') return true + const parts = hostname.split('.') + return parts.length === 4 && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} +function digest(value) { return createHash('sha256').update(value).digest('base64url') } + +function accountTotp(account) { + return account?.totp ?? { enabled: false, version: 0, secret: null, changedAt: null } +} + +function authenticationSource(value) { + if (typeof value?.authenticationSource === 'string' + && /^[A-Za-z0-9:_-]{1,128}$/.test(value.authenticationSource)) return value.authenticationSource + const ip = value?.client?.ip + return typeof ip === 'string' && ip.length > 0 && ip.length <= 128 + ? `ip:${digest(ip)}` : 'unknown' +} + +function enforceAuthenticationRetry(retry) { + if (retry?.retryAfterSeconds <= 0) return + throw new AccessError( + 'AUTHENTICATION_RETRY_REQUIRED', + `authentication retry is available in ${retry.retryAfterSeconds} seconds`, + 429, + { retryAfterSeconds: retry.retryAfterSeconds }, + ) +} + +function releaseAuthentication(admission, authenticated) { + const retry = admission.release(authenticated) + if (!authenticated) enforceAuthenticationRetry(retry) +} + +function normalizeManagementAccess(mode, entry) { + if (!['compat', 'isolated'].includes(mode)) throw new AccessError('ACCESS_MODE_INVALID', 'management access mode is invalid') + if (mode === 'compat') return null + if (entry === null || typeof entry !== 'object' || Array.isArray(entry) + || !['local-only', 'public'].includes(entry.kind)) { + throw new AccessError('ACCESS_ENTRY_INVALID', 'isolated Management entry is invalid') + } + if (entry.kind === 'local-only') { + try { + const origin = entry.managementLocalOrigin + const parsed = new URL(origin) + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== origin + || !isLoopbackHostname(parsed.hostname)) throw new Error('not loopback') + return { kind: 'local-only', managementLocalOrigin: parsed.origin } + } catch { throw new AccessError('ACCESS_ENTRY_INVALID', 'local Management origin is invalid') } + } + try { + const parsed = new URL(entry.managementPublicOrigin) + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin === 'null' + || parsed.username !== '' || parsed.password !== '' || parsed.pathname !== '/' + || parsed.search !== '' || parsed.hash !== '') throw new Error('invalid origin') + return { kind: 'public', managementPublicOrigin: parsed.origin } + } catch { + throw new AccessError('ACCESS_ENTRY_INVALID', 'isolated Management entry is invalid') + } +} + +function sameToken(left, right) { + const a = Buffer.from(left ?? '') + const b = Buffer.from(right ?? '') + return a.byteLength === b.byteLength && a.byteLength > 0 && timingSafeEqual(a, b) +} + +function send(response, status, value, headers = {}) { + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + ...headers, + }) + response.end(`${JSON.stringify(value)}\n`) +} + +async function body(request) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.byteLength + if (size > MAX_BODY_BYTES) throw new AccessError('REQUEST_TOO_LARGE', 'access request is too large', 413) + chunks.push(chunk) + } + if (size === 0) return {} + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } + catch { throw new AccessError('REQUEST_INVALID', 'access request is invalid') } +} + +function publicAccount(account) { + if (account === undefined) return null + return { + accountId: account.accountId, + username: account.username, + revision: account.revision, + mainCredentialVersion: account.mainCredential.version, + managementAdditionalCredential: { + enabled: account.managementAdditionalCredential.enabled, + version: account.managementAdditionalCredential.version, + }, + totp: { + enabled: accountTotp(account).enabled, + version: accountTotp(account).version, + }, + managementAccess: { ...account.managementAccess }, + } +} + +export class AccessService { + constructor({ + store, + classificationToken, + limiter = new AuthenticationLimiter(), + sessions = null, + exchanges = new ManagementExchangeStore(), + capabilities = new CapabilityStore(), + transitions, + totpFlows, + totpLimiter, + verifyTotp = verifyTotpCode, + verify = verifyCredential, + report = async () => {}, + now = () => Date.now(), + runtimeCapabilities = detectRuntimeCapabilities, + authenticationEpoch = identifier(), + }) { + this.store = store + this.classificationToken = classificationToken + this.limiter = limiter + this.sessions = sessions ?? new BrowserSessionStore() + this.exchanges = exchanges + this.capabilities = capabilities + this.transitions = transitions ?? new ManagementTransitionStore({ now }) + this.totpFlows = totpFlows ?? new TotpFlowStore({ now }) + this.totpLimiter = totpLimiter ?? new TotpRetryLimiter({ now }) + this.verifyTotp = verifyTotp + this.verify = verify + this.report = report + this.sessions.setEventSink?.((message, fields) => { + void Promise.resolve(this.report(message, fields)).catch(() => {}) + }) + this.now = now + this.runtimeCapabilities = runtimeCapabilities + this.authenticationEpoch = authenticationEpoch + this.authenticationReset = null + this.transition = Promise.resolve() + } + + authenticationContext(current) { + const revision = current.account?.revision ?? current.initialization?.instanceId ?? current.state + return `${this.authenticationEpoch}.${revision}` + } + + requireAuthenticationContext(value, current) { + if (!sameToken(value.authenticationContext, this.authenticationContext(current))) { + throw new AccessError('AUTHENTICATION_CONTEXT_STALE', 'authentication context is stale', 409) + } + } + + serialized(operation) { + const pending = this.transition.then(operation, operation) + this.transition = pending.catch(() => {}) + return pending + } + + reconcileRuntimePolicy() { + return this.serialized(async () => { + const [current, runtimeCapabilities] = await Promise.all([ + this.store.state(), + this.runtimeCapabilities(), + ]) + if (current.state !== 'initialized' || current.account === undefined + || runtimeCapabilities.dshRootCapabilityEffective !== true + || current.account.managementAccess.mode === 'compat') { + return { changed: false, account: publicAccount(current.account), ...runtimeCapabilities } + } + const changedAt = new Date(this.now()).toISOString() + const account = { + ...current.account, + revision: identifier(), + updatedAt: changedAt, + managementAccess: { + mode: 'compat', + version: current.account.managementAccess.version + 1, + isolatedEntry: null, + dshPublicOrigin: null, + changedAt, + }, + } + const next = await this.store.replaceAccount(account, current.account.revision) + const revokedSessions = this.sessions.revokeAll() + this.exchanges.clear?.() + await this.report('access.management-origin.reconciled', { + mode: 'compat', reason: 'dsh-root-capability', revokedSessions, + }) + return { changed: true, account: publicAccount(next), ...runtimeCapabilities } + }) + } + + async status() { + const current = await this.store.state() + const capabilities = await this.runtimeCapabilities() + return { + componentReady: true, + state: current.state, + instanceId: current.initialization?.instanceId ?? null, + authenticationContext: this.authenticationContext(current), + account: publicAccount(current.account), + ...capabilities, + } + } + + classify(value) { + if (!sameToken(value.token, this.classificationToken)) { + throw new AccessError('CLASSIFICATION_FORBIDDEN', 'classification is forbidden', 403) + } + return this.serialized(async () => { + const classified = await this.store.classify(value.evidence) + await this.report('access.classification.completed', { state: classified.initialization.state }) + return this.status() + }) + } + + initialize(value) { + return this.serialized(async () => { + const account = await this.store.initialize(value) + await this.report('access.initialization.completed', { accountId: account.accountId }) + return this.status() + }) + } + + initializeDsh(value) { + return this.serialized(async () => { + const account = await this.store.initialize(value) + const session = this.sessions.issue('dsh', account, { + origin: value.origin, + client: value.client, + authenticationSource: authenticationSource(value), + }) + await this.report('access.initialization.completed', { accountId: account.accountId }) + await this.report('access.session.created', { accountId: account.accountId, kind: 'dsh' }) + return { state: 'initialized', account: publicAccount(account), session } + }) + } + + generateAuthenticationResetKey() { + return this.serialized(async () => { + const current = await this.store.state() + if (!['migration-required', 'recovery-required'].includes(current.state)) { + throw new AccessError('AUTHENTICATION_RESET_UNAVAILABLE', 'administrator authentication reset is unavailable', 409) + } + const key = `dshak_${identifier()}` + this.authenticationReset = { digest: digest(key), expiresAt: this.now() + 10 * 60 * 1000 } + await this.report('access.authentication-reset-key.generated', { state: current.state }) + return { key, expiresAt: new Date(this.authenticationReset.expiresAt).toISOString() } + }) + } + + resetDshAuthentication(value) { + return this.serialized(async () => { + const setup = this.authenticationReset + if (setup === null || setup.expiresAt <= this.now() || typeof value.setupKey !== 'string' + || !sameToken(setup.digest, digest(value.setupKey))) { + if (setup !== null && setup.expiresAt <= this.now()) this.authenticationReset = null + throw new AccessError('AUTHENTICATION_RESET_KEY_INVALID', 'authentication reset key is invalid or expired', 401) + } + const current = await this.store.state() + let account + if (current.state === 'migration-required') account = await this.store.migrate(value) + else if (current.state === 'recovery-required') account = await this.store.recover(value) + else throw new AccessError('AUTHENTICATION_RESET_UNAVAILABLE', 'administrator authentication reset is unavailable', 409) + this.authenticationReset = null + const session = this.sessions.issue('dsh', account, { + origin: value.origin, + client: value.client, + authenticationSource: authenticationSource(value), + }) + await this.report('access.authentication-reset.completed', { accountId: account.accountId, previousState: current.state }) + return { state: 'initialized', account: publicAccount(account), session } + }) + } + + async authenticate(value, suppliedCurrent) { + const current = suppliedCurrent ?? await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const admission = this.limiter.enter(current.account.accountId, authenticationSource(value)) + let authenticated = false + try { + let usernameMatches = false + try { usernameMatches = normalizeUsername(value.username) === current.account.username } catch {} + const passwordMatches = await this.verify(value.password, current.account.mainCredential) + authenticated = usernameMatches && passwordMatches + if (!authenticated) { + await this.report('access.authentication.failed', { accountId: current.account.accountId, level: 'warning' }) + throw new AccessError('AUTHENTICATION_FAILED', 'username or password is incorrect', 401) + } + await this.report('access.authentication.succeeded', { accountId: current.account.accountId }) + return { + authenticated: true, + accountId: current.account.accountId, + accountRevision: current.account.revision, + mainCredentialVersion: current.account.mainCredential.version, + managementAccessVersion: current.account.managementAccess.version, + } + } finally { releaseAuthentication(admission, authenticated) } + } + + async verifyFreshAuthentication(account, mainPassword, sourceId = 'unknown') { + const mainMatches = typeof mainPassword === 'string' + && await this.verify(mainPassword, account.mainCredential) + if (!mainMatches) { + await this.report('access.fresh-authentication.failed', { accountId: account.accountId, level: 'warning' }) + throw new AccessError('FRESH_AUTH_FAILED', 'current administrator credentials are incorrect', 401) + } + await this.report('access.fresh-authentication.succeeded', { accountId: account.accountId }) + } + + + async loginDsh(value) { + const current = await this.store.state() + this.requireAuthenticationContext(value, current) + const authenticated = await this.authenticate(value, current) + if (accountTotp(current.account).enabled) { + const challenge = this.totpFlows.createLogin(current.account, { + origin: value.origin, + client: value.client, + authenticationSource: authenticationSource(value), + }) + return { ...authenticated, totpRequired: true, challenge } + } + const session = this.sessions.issue('dsh', current.account, { + origin: value.origin, + client: value.client, + authenticationSource: authenticationSource(value), + }) + await this.report('access.session.created', { accountId: authenticated.accountId, kind: 'dsh' }) + return { ...authenticated, session } + } + + async verifyTotpAuthentication(account, code, source, secret = accountTotp(account).secret) { + const currentRetry = this.totpLimiter.retry(account.accountId, source) + if (currentRetry.retryAfterSeconds > 0) { + throw new AccessError( + currentRetry.kind === 'rate' ? 'TOTP_RATE_LIMITED' : 'TOTP_RETRY_REQUIRED', + currentRetry.kind === 'rate' + ? 'too many two-factor authentication attempts' + : 'two-factor authentication retry is required', + 429, + { retryAfterSeconds: currentRetry.retryAfterSeconds }, + ) + } + const valid = this.verifyTotp(code, secret, { now: this.now() }) + if (!valid) { + const retry = this.totpLimiter.fail(account.accountId, source) + await this.report('access.totp.failed', { accountId: account.accountId, level: 'warning' }) + if (retry.retryAfterSeconds > 0) { + throw new AccessError( + retry.kind === 'rate' ? 'TOTP_RATE_LIMITED' : 'TOTP_RETRY_REQUIRED', + retry.kind === 'rate' + ? 'too many two-factor authentication attempts' + : 'two-factor authentication retry is required', + 429, + { retryAfterSeconds: retry.retryAfterSeconds }, + ) + } + throw new AccessError('TOTP_INVALID', 'two-factor authentication code is invalid', 401) + } + this.totpLimiter.succeed(account.accountId, source) + await this.report('access.totp.succeeded', { accountId: account.accountId }) + } + + async completeDshTotp(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined || !accountTotp(current.account).enabled) { + throw new AccessError('TOTP_LOGIN_INVALID', 'two-factor authentication login is invalid or expired', 401) + } + this.requireAuthenticationContext(value, current) + const pending = this.totpFlows.login(value.challengeToken, current.account) + if (pending === undefined || pending.value.origin !== value.origin + || pending.value.authenticationSource !== authenticationSource(value)) { + throw new AccessError('TOTP_LOGIN_INVALID', 'two-factor authentication login is invalid or expired', 401) + } + await this.verifyTotpAuthentication( + current.account, + value.code, + pending.value.authenticationSource, + ) + this.totpFlows.consumeLogin(pending.key) + const session = this.sessions.issue('dsh', current.account, { + origin: pending.value.origin, + client: pending.value.client, + authenticationSource: pending.value.authenticationSource, + }) + await this.report('access.session.created', { accountId: current.account.accountId, kind: 'dsh' }) + return { authenticated: true, session } + } + + async validateSession(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) return { authenticated: false } + const session = this.sessions.validate(value.token, value.kind, current.account, { + origin: value.origin, + csrfToken: value.csrfToken, + requireCsrf: value.requireCsrf === true, + }) + return session === undefined ? { authenticated: false } : { authenticated: true, session } + } + + async logout(value) { + const revoked = this.sessions.revoke(value.token) + if (revoked) await this.report('access.session.revoked', { kind: value.kind ?? null }) + return { authenticated: false } + } + + async logoutDshBrowser(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + if (!['management', 'all'].includes(value.scope)) { + throw new AccessError('SESSION_ACTION_INVALID', 'session action is invalid') + } + const session = this.sessions.validate(value.dshToken, 'dsh', current.account, { + origin: value.dshOrigin, + touch: false, + }) + if (session === undefined) throw new AccessError('SESSION_INVALID', 'DSH session is invalid', 401) + let managementRevoked = this.sessions.revokeManagementFromDsh(session.sessionId) + if (typeof value.managementToken === 'string' && this.sessions.revoke(value.managementToken)) { + managementRevoked += 1 + } + const dshRevoked = value.scope === 'all' && this.sessions.revoke(value.dshToken) + await this.report('access.browser-sessions.logged-out', { + scope: value.scope, + dshRevoked, + managementRevoked, + }) + return { authenticated: value.scope !== 'all', dshRevoked, managementRevoked } + } + + async managementResult(account, { origin, sourceDshOrigin, sourceDshSessionId }) { + if (account.managementAdditionalCredential.enabled) { + return { pending: this.exchanges.createPending(account, { targetOrigin: origin, sourceDshOrigin, sourceDshSessionId }) } + } + const session = this.sessions.issue('management', account, { origin, sourceDshOrigin, sourceDshSessionId }) + await this.report('access.session.created', { accountId: account.accountId, kind: 'management' }) + return { session } + } + + async createManagementHandoff(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const session = this.sessions.validate(value.dshToken, 'dsh', current.account, { origin: value.dshOrigin }) + if (session === undefined) throw new AccessError('SESSION_INVALID', 'DSH session is invalid', 401) + const handoff = this.exchanges.createHandoff(session, current.account, value.targetOrigin) + await this.report('access.handoff.created', { accountId: current.account.accountId }) + return { handoff } + } + + async consumeManagementHandoff(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const handoff = this.exchanges.consumeHandoff(value.token, current.account, value.origin) + if (handoff === undefined) throw new AccessError('HANDOFF_INVALID', 'management handoff is invalid or expired', 401) + const source = this.sessions.details(handoff.dshSessionId) + if (source?.kind !== 'dsh' || source.origin !== handoff.sourceDshOrigin) { + throw new AccessError('HANDOFF_INVALID', 'management handoff is invalid or expired', 401) + } + await this.report('access.handoff.consumed', { accountId: current.account.accountId }) + return this.managementResult(current.account, { + origin: value.origin, + sourceDshOrigin: handoff.sourceDshOrigin, + sourceDshSessionId: handoff.dshSessionId, + }) + } + + async completeManagementLogin(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('PENDING_LOGIN_INVALID', 'management login is invalid or expired', 401) + } + this.requireAuthenticationContext(value, current) + if (!current.account.managementAdditionalCredential.enabled) { + throw new AccessError('PENDING_LOGIN_INVALID', 'management login is invalid or expired', 401) + } + const source = this.sessions.details( + this.exchanges.peekPendingSource(value.pendingToken, current.account, value.origin) ?? '', + ) + const sourceId = source?.authenticationSource ?? 'unknown' + this.limiter.checkRetry(current.account.accountId, sourceId, 'management') + const pending = this.exchanges.inspectPending(value.pendingToken, current.account, value.origin) + if (pending === undefined) throw new AccessError('PENDING_LOGIN_INVALID', 'management login is invalid or expired', 401) + const validatedSource = this.sessions.details(pending.value.sourceDshSessionId) + if (validatedSource?.kind !== 'dsh' || validatedSource.origin !== pending.value.sourceDshOrigin) { + this.exchanges.consumePending(pending.key) + throw new AccessError('PENDING_LOGIN_INVALID', 'management login is invalid or expired', 401) + } + const admission = this.limiter.enter(current.account.accountId, sourceId, 'management') + let valid = false + try { + valid = await this.verify(value.password, current.account.managementAdditionalCredential.verifier) + if (!valid) { + await this.report('access.authentication.failed', { + accountId: current.account.accountId, kind: 'management', level: 'warning', + }) + throw new AccessError('AUTHENTICATION_FAILED', 'username or password is incorrect', 401) + } + await this.report('access.authentication.succeeded', { + accountId: current.account.accountId, kind: 'management', + }) + } finally { releaseAuthentication(admission, valid) } + this.exchanges.consumePending(pending.key) + const session = this.sessions.issue('management', current.account, { + origin: value.origin, + sourceDshOrigin: pending.value.sourceDshOrigin, + sourceDshSessionId: pending.value.sourceDshSessionId, + }) + await this.report('access.session.created', { accountId: current.account.accountId, kind: 'management' }) + return { session } + } + + async issueCapability(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const session = this.sessions.validate(value.managementToken, 'management', current.account, { + origin: value.origin, + csrfToken: value.csrfToken, + requireCsrf: value.requireCsrf === true, + touch: false, + }) + if (session === undefined) throw new AccessError('SESSION_INVALID', 'Management session is invalid', 401) + return { capability: this.capabilities.issue(session, current.account, value) } + } + + async issuePluginCapability(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const session = this.sessions.validate(value.dshToken, 'dsh', current.account, { + origin: value.origin, + csrfToken: value.csrfToken, + requireCsrf: value.requireCsrf === true, + touch: false, + }) + if (session === undefined) throw new AccessError('SESSION_INVALID', 'DSH session is invalid', 401) + return { + capability: this.capabilities.issue(session, current.account, { + audience: 'plugin', method: value.method, target: value.target, + }), + } + } + + async consumeCapability(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const capability = this.capabilities.consume(value.token, current.account, value) + if (capability === undefined) throw new AccessError('CAPABILITY_INVALID', 'Management capability is invalid or expired', 401) + return { authorized: true, capability } + } + + consumeInternalCapability(value, account) { + const authorization = this.capabilities.consume(value.internalCapability, account, { + audience: 'management', method: value.method, target: value.target, + }) + if (authorization === undefined) throw new AccessError('CAPABILITY_INVALID', 'Management capability is invalid', 401) + return authorization + } + + async authenticationSettings(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const authorization = this.consumeInternalCapability(value, current.account) + const capabilities = await this.runtimeCapabilities() + return { + account: publicAccount(current.account), + sessions: this.sessions.list(current.account, authorization.sessionId), + ...capabilities, + } + } + + async beginTotpEnrollment(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const authorization = this.consumeInternalCapability(value, current.account) + if (accountTotp(current.account).enabled) { + throw new AccessError('TOTP_ALREADY_ENABLED', 'two-factor authentication is already enabled', 409) + } + const username = value.username === undefined ? current.account.username : normalizeUsername(value.username) + const enrollment = this.totpFlows.createEnrollment(current.account, authorization.sessionId) + await this.report('access.totp.enrollment.created', { accountId: current.account.accountId }) + return { + enrollmentToken: enrollment.token, + secret: enrollment.secret, + uri: totpUri({ secret: enrollment.secret, username }), + expiresAt: enrollment.expiresAt, + } + } + + async cancelTotpEnrollment(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const authorization = this.consumeInternalCapability(value, current.account) + const canceled = this.totpFlows.cancelEnrollment(value.enrollmentToken, current.account, authorization.sessionId) + if (canceled) await this.report('access.totp.enrollment.canceled', { accountId: current.account.accountId }) + return { canceled } + } + + async confirmTotpEnrollment(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const authorization = this.consumeInternalCapability(value, current.account) + const enrollment = this.totpFlows.enrollment(value.enrollmentToken, current.account, authorization.sessionId) + if (enrollment === undefined) { + throw new AccessError('TOTP_ENROLLMENT_INVALID', 'two-factor authentication enrollment is invalid or expired', 401) + } + const managementSession = this.sessions.details(authorization.sessionId) + const sourceSession = this.sessions.details(managementSession?.sourceDshSessionId) + await this.verifyTotpAuthentication( + current.account, + value.totpCode, + sourceSession?.authenticationSource ?? 'unknown', + enrollment.value.secret, + ) + if (!this.totpFlows.confirmEnrollment(enrollment.key)) { + throw new AccessError('TOTP_ENROLLMENT_INVALID', 'two-factor authentication enrollment is invalid or expired', 401) + } + await this.report('access.totp.enrollment.confirmed', { accountId: current.account.accountId }) + return { confirmed: true, expiresAt: new Date(enrollment.value.expiresAt).toISOString() } + } + + async beginTotpDisableConfirmation(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const authorization = this.consumeInternalCapability(value, current.account) + if (!accountTotp(current.account).enabled) { + throw new AccessError('TOTP_NOT_ENABLED', 'two-factor authentication is not enabled', 409) + } + const confirmation = this.totpFlows.createDisableConfirmation(current.account, authorization.sessionId) + await this.report('access.totp.disable-confirmation.created', { accountId: current.account.accountId }) + return { confirmationToken: confirmation.token, expiresAt: confirmation.expiresAt } + } + + async cancelTotpDisableConfirmation(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const authorization = this.consumeInternalCapability(value, current.account) + const canceled = this.totpFlows.cancelDisableConfirmation( + value.confirmationToken, + current.account, + authorization.sessionId, + ) + if (canceled) await this.report('access.totp.disable-confirmation.canceled', { accountId: current.account.accountId }) + return { canceled } + } + + async confirmTotpDisable(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const authorization = this.consumeInternalCapability(value, current.account) + const confirmation = this.totpFlows.disableConfirmation( + value.confirmationToken, + current.account, + authorization.sessionId, + ) + if (confirmation === undefined) { + throw new AccessError('TOTP_DISABLE_CONFIRMATION_INVALID', 'two-factor disable confirmation is invalid or expired', 401) + } + const managementSession = this.sessions.details(authorization.sessionId) + const sourceSession = this.sessions.details(managementSession?.sourceDshSessionId) + await this.verifyTotpAuthentication( + current.account, + value.totpCode, + sourceSession?.authenticationSource ?? 'unknown', + ) + if (!this.totpFlows.confirmDisable(confirmation.key)) { + throw new AccessError('TOTP_DISABLE_CONFIRMATION_INVALID', 'two-factor disable confirmation is invalid or expired', 401) + } + await this.report('access.totp.disable-confirmation.confirmed', { accountId: current.account.accountId }) + return { confirmed: true, expiresAt: new Date(confirmation.value.expiresAt).toISOString() } + } + + async revokeBrowserSessions(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const authorization = this.consumeInternalCapability(value, current.account) + if (typeof value.sessionId !== 'string' || value.sessionId.length === 0 || value.sessionId.length > 128) { + throw new AccessError('SESSION_ACTION_INVALID', 'session action is invalid') + } + const currentDshSessionId = this.sessions.sourceDshSessionId(authorization.sessionId) + const revoked = this.sessions.revokeDshSession(value.sessionId, current.account.accountId) + if (!revoked) throw new AccessError('SESSION_NOT_FOUND', 'browser session was not found', 404) + const currentSessionRevoked = value.sessionId === currentDshSessionId + await this.report('access.sessions.revoked', { sessionId: value.sessionId, currentSessionRevoked }) + return { revoked: 1, currentSessionRevoked } + } + + async recoveryStatus() { + const current = await this.store.state() + return { + state: current.state, + authenticationRetry: current.account === undefined + ? null : this.limiter.status(current.account.accountId), + account: publicAccount(current.account), + } + } + + async clearAuthenticationRetry(value = {}) { + const current = await this.store.state() + if (current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator account is unavailable', 409) + } + const scope = value.scope ?? 'all' + const credential = value.credential ?? 'all' + if (!['all', 'global'].includes(scope)) { + throw new AccessError('REQUEST_INVALID', 'authentication retry clear scope is invalid') + } + if (!['all', 'main', 'management', 'totp'].includes(credential)) { + throw new AccessError('REQUEST_INVALID', 'authentication retry credential is invalid') + } + let result + if (credential === 'totp') { + result = this.totpLimiter.clearDailyLimits(current.account.accountId, { globalOnly: scope === 'global' }) + } + else if (credential === 'all') { + const passwordResult = scope === 'global' + ? this.limiter.clearGlobal('all') + : this.limiter.clear(current.account.accountId, 'all') + const totpResult = this.totpLimiter.clearDailyLimits(current.account.accountId, { globalOnly: scope === 'global' }) + result = { ...passwordResult, twoFactorDailyCleared: totpResult.cleared } + } else { + result = scope === 'global' + ? this.limiter.clearGlobal(credential) + : this.limiter.clear(current.account.accountId, credential) + } + await this.report('access.authentication-retry.cleared', { + accountId: current.account.accountId, + scope, + credential, + cleared: result.cleared, + }) + return { status: 'cleared', scope, credential, ...result } + } + + async clearRecoverySessions(value = {}) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator account is unavailable', 409) + } + if (value.managementOnly !== undefined && typeof value.managementOnly !== 'boolean') { + throw new AccessError('REQUEST_INVALID', 'session clear scope is invalid') + } + const managementOnly = value.managementOnly === true + const revoked = managementOnly ? this.sessions.revokeKind('management') : this.sessions.revokeAll() + this.exchanges.clear?.() + await this.report('access.sessions.cleared', { managementOnly, revoked }) + return { status: 'cleared', scope: managementOnly ? 'management' : 'all', revoked } + } + + async authenticationRetryStatus(value = {}) { + const current = await this.store.state() + if (current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator account is unavailable', 409) + } + if (value.credential !== 'totp' || typeof value.source !== 'string' + || value.source.length === 0 || value.source.length > 128) { + throw new AccessError('REQUEST_INVALID', 'authentication retry status request is invalid') + } + return this.totpLimiter.retry(current.account.accountId, value.source) + } + + async replaceRecoveryAccount(value, operation, { revoke = 'none', auditOperation } = {}) { + const current = await this.store.state() + if (current.account === undefined) throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator account is unavailable', 409) + if (value.revision !== current.account.revision) throw new AccessError('REVISION_CONFLICT', 'account revision changed', 409) + const account = { ...current.account, revision: identifier(), updatedAt: new Date().toISOString() } + await operation(account, current.account) + const next = await this.store.replaceAccount(account, current.account.revision) + const managementSessionsBefore = this.sessions.countKind?.('management') ?? 0 + const allSessionsRevoked = revoke === 'all' + const managementSessionsRevoked = revoke === 'management' + ? this.sessions.revokeKind('management') + : allSessionsRevoked ? managementSessionsBefore : 0 + if (allSessionsRevoked) this.sessions.revokeAll() + if (allSessionsRevoked) this.exchanges.clear?.() + const result = { + account: publicAccount(next), + currentManagementSessionRevoked: managementSessionsRevoked > 0, + managementSessionsRevoked, + allSessionsRevoked, + } + await this.report('access.recovery-account.changed', { + operation: auditOperation, + managementSessionsRevoked, + allSessionsRevoked, + }) + return result + } + + async setRecoveryUsername(value) { + const normalized = normalizeUsername(value.username) + return this.replaceRecoveryAccount(value, async account => { account.username = normalized }, { + auditOperation: 'set-username', + }) + } + + async resetRecoveryPassword(value) { + const verifier = await this.store.createVerifier(value.password) + return this.replaceRecoveryAccount(value, async account => { + account.mainCredential = { ...verifier, version: account.mainCredential.version + 1 } + }, { revoke: 'all', auditOperation: 'reset-password' }) + } + + async resetRecoveryAccess(value) { + const action = value.managementPasswordAction + if (!['preserve', 'disable', 'reset'].includes(action)) { + throw new AccessError('REQUEST_INVALID', 'management password action is invalid') + } + const totpAction = value.totpAction ?? 'preserve' + if (!['preserve', 'disable'].includes(totpAction)) { + throw new AccessError('REQUEST_INVALID', 'two-factor action is invalid') + } + const username = value.username === undefined ? null : normalizeUsername(value.username) + const mainPassword = value.password === undefined ? null : normalizePassword(value.password) + const managementPassword = action === 'reset' ? normalizePassword(value.managementPassword) : null + const currentTotpEnabled = accountTotp((await this.store.state()).account).enabled + if (username === null && mainPassword === null && action === 'preserve' + && (totpAction === 'preserve' || !currentTotpEnabled)) { + throw new AccessError('REQUEST_INVALID', 'no access changes were selected') + } + if (managementPassword !== null && mainPassword !== null && managementPassword === mainPassword) { + throw new AccessError('PASSWORDS_MUST_DIFFER', 'main and Management console passwords must differ') + } + const revoke = mainPassword !== null ? 'all' + : action === 'reset' ? 'management' + : action === 'disable' ? 'none' + : 'none' + return this.replaceRecoveryAccount(value, async (account, current) => { + if (managementPassword !== null && mainPassword === null + && await verifyCredential(managementPassword, current.mainCredential)) { + throw new AccessError('PASSWORDS_MUST_DIFFER', 'main and Management console passwords must differ') + } + if (username !== null) account.username = username + if (mainPassword !== null) { + const verifier = await this.store.createVerifier(mainPassword) + account.mainCredential = { ...verifier, version: account.mainCredential.version + 1 } + } + if (action !== 'preserve') { + const version = account.managementAdditionalCredential.version + 1 + account.managementAdditionalCredential = action === 'disable' + ? { enabled: false, version, verifier: null, changedAt: new Date().toISOString() } + : { + enabled: true, + version, + verifier: { ...await this.store.createVerifier(managementPassword), version }, + changedAt: new Date().toISOString(), + } + } + if (totpAction === 'disable' && accountTotp(current).enabled) { + account.totp = { + enabled: false, + version: accountTotp(current).version + 1, + secret: null, + changedAt: new Date().toISOString(), + } + } + }, { revoke, auditOperation: 'reset-access' }) + } + + async resetRecoveryManagementPassword(value) { + const verifier = await this.store.createVerifier(value.password) + return this.replaceRecoveryAccount(value, async account => { + account.managementAdditionalCredential = { + enabled: true, + version: account.managementAdditionalCredential.version + 1, + verifier: { ...verifier, version: account.managementAdditionalCredential.version + 1 }, + changedAt: new Date().toISOString(), + } + }, { revoke: 'management', auditOperation: 'reset-management-password' }) + } + + async disableRecoveryManagementPassword(value) { + return this.replaceRecoveryAccount(value, async account => { + account.managementAdditionalCredential = { + enabled: false, + version: account.managementAdditionalCredential.version + 1, + verifier: null, + changedAt: new Date().toISOString(), + } + }, { revoke: 'none', auditOperation: 'disable-management-password' }) + } + + async createManagementTransition(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const authorization = this.consumeInternalCapability(value, current.account) + const session = this.sessions.details(authorization.sessionId) + if (session?.kind !== 'management') throw new AccessError('SESSION_INVALID', 'Management session is invalid', 401) + const runtimeCapabilities = await this.runtimeCapabilities() + if (runtimeCapabilities.dshRootCapabilityEffective === true + && value.mode !== current.account.managementAccess.mode) { + throw new AccessError('ACCESS_MODE_LOCKED', 'Management access mode cannot change while DSH Root capability is enabled', 409) + } + const isolatedEntry = normalizeManagementAccess(value.mode, value.mode === 'isolated' + && value.isolatedEntry?.kind === 'local-only' + ? { ...value.isolatedEntry, managementLocalOrigin: value.isolatedEntry.managementLocalOrigin ?? value.candidateOrigin } + : value.isolatedEntry) + const candidateOrigin = value.mode === 'isolated' + ? (() => { + if (isolatedEntry.kind === 'public') return isolatedEntry.managementPublicOrigin + try { + const parsed = new URL(value.candidateOrigin) + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== value.candidateOrigin + || !isLoopbackHostname(parsed.hostname)) throw new Error('not loopback') + return parsed.origin + } catch { throw new AccessError('ACCESS_ENTRY_INVALID', 'local Management origin is invalid') } + })() + : null + const transition = this.transitions.create({ + account: current.account, + instanceId: current.initialization.instanceId, + sessionId: authorization.sessionId, + sourceOrigin: session.origin, + sourceDshOrigin: session.sourceDshOrigin, + sourceDshSessionId: session.sourceDshSessionId, + mode: value.mode, + isolatedEntry, + candidateOrigin, + }) + await this.report('access.management-transition.created', { mode: value.mode }) + return { transition, instanceId: current.initialization.instanceId } + } + + async probeManagementTransition(value) { + const current = await this.store.state() + const result = this.transitions.probe({ + ...value, + instanceId: current.initialization?.instanceId, + }) + if (result === undefined) throw new AccessError('TRANSITION_PROBE_INVALID', 'Management origin probe is invalid or expired', 401) + await this.report('access.management-transition.probed') + return result + } + + async commitManagementTransition(value) { + return this.serialized(async () => { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const authorization = this.consumeInternalCapability(value, current.account) + const transitionValue = { + transitionId: value.transitionId, + proof: value.proof, + account: current.account, + sessionId: authorization.sessionId, + } + const transition = this.transitions.consume(transitionValue) + if (transition === undefined) throw new AccessError('TRANSITION_INVALID', 'Management origin transition is invalid or expired', 409) + const sourceDshSession = this.sessions.details(transition.sourceDshSessionId) + if (sourceDshSession?.kind !== 'dsh' || sourceDshSession.origin !== transition.sourceDshOrigin) { + throw new AccessError('SESSION_INVALID', 'source DSH session is invalid', 401) + } + const targetOrigin = transition.mode === 'isolated' + ? transition.candidateOrigin + : transition.sourceDshOrigin + if (typeof targetOrigin !== 'string' || targetOrigin.length === 0) { + throw new AccessError('TRANSITION_TARGET_UNAVAILABLE', 'Management transition target is unavailable', 409) + } + const account = { + ...current.account, + revision: identifier(), + updatedAt: new Date().toISOString(), + managementAccess: { + mode: transition.mode, + version: current.account.managementAccess.version + 1, + isolatedEntry: transition.isolatedEntry, + dshPublicOrigin: transition.mode === 'isolated' ? transition.sourceDshOrigin : null, + changedAt: new Date().toISOString(), + }, + } + const next = await this.store.replaceAccount(account, current.account.revision) + this.sessions.refreshDshSession(transition.sourceDshSessionId, next) + const continuation = this.transitions.createContinuation({ + account: next, + targetOrigin, + sourceDshOrigin: transition.sourceDshOrigin, + sourceDshSessionId: transition.sourceDshSessionId, + }) + if (continuation === null) throw new AccessError('TRANSITION_TARGET_UNAVAILABLE', 'Management transition continuation is unavailable', 409) + this.sessions.revokeKind('management') + this.exchanges.clear?.() + await this.report('access.management-origin.changed', { mode: transition.mode }) + return { + account: publicAccount(next), + continuation, + targetOrigin, + loginOrigin: transition.mode === 'isolated' ? transition.candidateOrigin : transition.sourceDshOrigin, + } + }) + } + + async consumeManagementContinuation(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const continuation = this.transitions.consumeContinuation({ + token: value.token, account: current.account, targetOrigin: value.origin, + }) + if (continuation === undefined) throw new AccessError('CONTINUATION_INVALID', 'Management continuation is invalid or expired', 401) + const source = this.sessions.details(continuation.sourceDshSessionId) + if (source?.kind !== 'dsh' || source.origin !== continuation.sourceDshOrigin) { + throw new AccessError('CONTINUATION_INVALID', 'Management continuation is invalid or expired', 401) + } + const session = this.sessions.issue('management', current.account, { + origin: value.origin, + sourceDshOrigin: continuation.sourceDshOrigin, + sourceDshSessionId: continuation.sourceDshSessionId, + }) + await this.report('access.management-continuation.consumed') + return { session } + } + + async updateAuthenticationSettings(value) { + const current = await this.store.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + const authorization = this.consumeInternalCapability(value, current.account) + const account = { ...current.account, revision: identifier(), updatedAt: new Date().toISOString() } + const usernameChanged = value.username !== undefined && normalizeUsername(value.username) !== current.account.username + const mainPasswordChanged = value.password !== undefined + const additionalChanged = value.additionalPassword !== undefined + || (value.additionalEnabled !== undefined + && value.additionalEnabled !== current.account.managementAdditionalCredential.enabled) + if (value.totpEnabled !== undefined && typeof value.totpEnabled !== 'boolean') { + throw new AccessError('REQUEST_INVALID', 'two-factor authentication setting is invalid') + } + const totpChanged = value.totpEnabled !== undefined + && value.totpEnabled !== accountTotp(current.account).enabled + if (value.mode !== undefined || value.isolatedEntry !== undefined) { + throw new AccessError('TRANSITION_REQUIRED', 'Management access changes require a verified transition', 409) + } + if (!usernameChanged && !mainPasswordChanged && !additionalChanged && !totpChanged) { + return { + account: publicAccount(current.account), + changed: false, + currentManagementSessionRevoked: false, + managementSessionsRevoked: 0, + allSessionsRevoked: false, + } + } + const managementSession = this.sessions.details(authorization.sessionId) + const sourceSession = this.sessions.details(managementSession?.sourceDshSessionId) + const authenticationSourceId = sourceSession?.authenticationSource ?? 'unknown' + if (mainPasswordChanged || additionalChanged || totpChanged) { + await this.verifyFreshAuthentication( + current.account, + value.currentPassword, + authenticationSourceId, + ) + } + let enrollment + let disableConfirmation + if (totpChanged && value.totpEnabled === true) { + enrollment = this.totpFlows.enrollment(value.totpEnrollmentToken, current.account, authorization.sessionId) + if (enrollment === undefined) { + throw new AccessError('TOTP_ENROLLMENT_INVALID', 'two-factor authentication enrollment is invalid or expired', 401) + } + if (enrollment.value.confirmed !== true) { + throw new AccessError('TOTP_ENROLLMENT_UNCONFIRMED', 'two-factor authentication enrollment is not confirmed', 409) + } + } else if (totpChanged) { + disableConfirmation = this.totpFlows.disableConfirmation( + value.totpDisableConfirmationToken, + current.account, + authorization.sessionId, + ) + if (disableConfirmation === undefined) { + throw new AccessError('TOTP_DISABLE_CONFIRMATION_INVALID', 'two-factor disable confirmation is invalid or expired', 401) + } + if (disableConfirmation.value.confirmed !== true) { + throw new AccessError('TOTP_DISABLE_CONFIRMATION_UNCONFIRMED', 'two-factor disable confirmation is not confirmed', 409) + } + } + if (value.username !== undefined) account.username = normalizeUsername(value.username) + if (value.password !== undefined) { + if (current.account.managementAdditionalCredential.enabled + && value.additionalPassword === undefined + && await this.verify(value.password, current.account.managementAdditionalCredential.verifier)) { + throw new AccessError('PASSWORDS_MUST_DIFFER', 'main and Management console passwords must differ') + } + const verifier = await this.store.createVerifier(value.password) + account.mainCredential = { ...verifier, version: current.account.mainCredential.version + 1 } + } + if (value.additionalPassword !== undefined || value.additionalEnabled !== undefined) { + const enabled = value.additionalEnabled === true + if (!enabled) { + account.managementAdditionalCredential = { + enabled: false, + version: current.account.managementAdditionalCredential.version + 1, + verifier: null, + changedAt: new Date().toISOString(), + } + } else if (value.additionalPassword !== undefined) { + if (typeof value.additionalPassword !== 'string') throw new AccessError('PASSWORD_REQUIRED', 'Management console password is required') + const equalsMain = value.password !== undefined + ? normalizePassword(value.additionalPassword) === normalizePassword(value.password) + : await this.verify(value.additionalPassword, current.account.mainCredential) + if (equalsMain) throw new AccessError('PASSWORDS_MUST_DIFFER', 'main and Management console passwords must differ') + const verifier = await this.store.createVerifier(value.additionalPassword) + account.managementAdditionalCredential = { + enabled: true, + version: current.account.managementAdditionalCredential.version + 1, + verifier: { ...verifier, version: current.account.managementAdditionalCredential.version + 1 }, + changedAt: new Date().toISOString(), + } + } else if (!current.account.managementAdditionalCredential.enabled) { + throw new AccessError('PASSWORD_REQUIRED', 'Management console password is required') + } + } + if (totpChanged) { + const version = accountTotp(current.account).version + 1 + account.totp = value.totpEnabled + ? { enabled: true, version, secret: enrollment.value.secret, changedAt: new Date().toISOString() } + : { enabled: false, version, secret: null, changedAt: new Date().toISOString() } + } + const next = await this.store.replaceAccount(account, current.account.revision) + if (enrollment !== undefined) this.totpFlows.consumeEnrollment(enrollment.key) + if (disableConfirmation !== undefined) this.totpFlows.consumeDisableConfirmation(disableConfirmation.key) + const allSessionsRevoked = mainPasswordChanged + const revokedSessionCount = allSessionsRevoked + ? this.sessions.revokeAll() + : additionalChanged ? this.sessions.revokeKind('management') : 0 + this.exchanges.clear?.() + if (allSessionsRevoked) this.totpFlows.clear() + await this.report('access.authentication.settings.changed', { usernameChanged }) + return { + account: publicAccount(next), + changed: true, + currentManagementSessionRevoked: allSessionsRevoked || additionalChanged, + managementSessionsRevoked: allSessionsRevoked || additionalChanged ? revokedSessionCount : 0, + allSessionsRevoked, + } + } +} + +export function createAccessHttpServer({ service, surface = 'access' }) { + return createServer((request, response) => { + void (async () => { + const pathname = new URL(request.url ?? '/', 'http://access.internal').pathname + if (request.method === 'GET' && pathname === '/v1/status') return send(response, 200, await service.status()) + if (surface === 'recovery') { + const value = await body(request) + if (request.method === 'GET' && pathname === '/v1/recovery/status') return send(response, 200, await service.recoveryStatus()) + if (request.method === 'POST' && pathname === '/v1/recovery/set-username') return send(response, 200, await service.setRecoveryUsername(value)) + if (request.method === 'POST' && pathname === '/v1/recovery/reset-access') return send(response, 200, await service.resetRecoveryAccess(value)) + if (request.method === 'POST' && pathname === '/v1/recovery/reset-password') return send(response, 200, await service.resetRecoveryPassword(value)) + if (request.method === 'POST' && pathname === '/v1/recovery/reset-management-password') return send(response, 200, await service.resetRecoveryManagementPassword(value)) + if (request.method === 'POST' && pathname === '/v1/recovery/disable-management-password') return send(response, 200, await service.disableRecoveryManagementPassword(value)) + if (request.method === 'POST' && pathname === '/v1/recovery/generate-key') return send(response, 201, await service.generateAuthenticationResetKey()) + if (request.method === 'POST' && pathname === '/v1/recovery/clear-retry') return send(response, 200, await service.clearAuthenticationRetry(value)) + if (request.method === 'POST' && pathname === '/v1/recovery/clear-sessions') return send(response, 200, await service.clearRecoverySessions(value)) + return send(response, 404, { error: 'not found', code: 'NOT_FOUND' }) + } + const value = await body(request) + if (request.method === 'POST' && pathname === '/v1/classify') return send(response, 200, await service.classify(value)) + if (request.method === 'POST' && pathname === '/v1/initialize') return send(response, 201, await service.initialize(value)) + if (request.method === 'POST' && pathname === '/v1/authenticate') return send(response, 200, await service.authenticate(value)) + if (request.method === 'POST' && pathname === '/v1/dsh/initialize') return send(response, 201, await service.initializeDsh(value)) + if (request.method === 'POST' && pathname === '/v1/dsh/reset-authentication') return send(response, 201, await service.resetDshAuthentication(value)) + if (request.method === 'POST' && pathname === '/v1/dsh/login') return send(response, 200, await service.loginDsh(value)) + if (request.method === 'POST' && pathname === '/v1/dsh/totp/complete') return send(response, 200, await service.completeDshTotp(value)) + if (request.method === 'POST' && pathname === '/v1/authentication-retry/status') return send(response, 200, await service.authenticationRetryStatus(value)) + if (request.method === 'POST' && pathname === '/v1/sessions/validate') return send(response, 200, await service.validateSession(value)) + if (request.method === 'POST' && pathname === '/v1/sessions/logout') return send(response, 200, await service.logout(value)) + if (request.method === 'POST' && pathname === '/v1/dsh/browser-logout') return send(response, 200, await service.logoutDshBrowser(value)) + if (request.method === 'POST' && pathname === '/v1/management/handoffs') return send(response, 201, await service.createManagementHandoff(value)) + if (request.method === 'POST' && pathname === '/v1/management/handoffs/consume') return send(response, 200, await service.consumeManagementHandoff(value)) + if (request.method === 'POST' && pathname === '/v1/management/pending/complete') return send(response, 200, await service.completeManagementLogin(value)) + if (request.method === 'POST' && pathname === '/v1/capabilities') return send(response, 201, await service.issueCapability(value)) + if (request.method === 'POST' && pathname === '/v1/dsh/capabilities') return send(response, 201, await service.issuePluginCapability(value)) + if (request.method === 'POST' && pathname === '/v1/capabilities/consume') return send(response, 200, await service.consumeCapability(value)) + if (request.method === 'POST' && pathname === '/v1/management/settings') return send(response, 200, await service.authenticationSettings(value)) + if (request.method === 'POST' && pathname === '/v1/management/totp/enrollments') return send(response, 201, await service.beginTotpEnrollment(value)) + if (request.method === 'POST' && pathname === '/v1/management/totp/enrollments/confirm') return send(response, 200, await service.confirmTotpEnrollment(value)) + if (request.method === 'POST' && pathname === '/v1/management/totp/enrollments/cancel') return send(response, 200, await service.cancelTotpEnrollment(value)) + if (request.method === 'POST' && pathname === '/v1/management/totp/disable-confirmations') return send(response, 201, await service.beginTotpDisableConfirmation(value)) + if (request.method === 'POST' && pathname === '/v1/management/totp/disable-confirmations/confirm') return send(response, 200, await service.confirmTotpDisable(value)) + if (request.method === 'POST' && pathname === '/v1/management/totp/disable-confirmations/cancel') return send(response, 200, await service.cancelTotpDisableConfirmation(value)) + if (request.method === 'POST' && pathname === '/v1/management/sessions/revoke') return send(response, 200, await service.revokeBrowserSessions(value)) + if (request.method === 'POST' && pathname === '/v1/management/transitions') return send(response, 201, await service.createManagementTransition(value)) + if (request.method === 'POST' && pathname === '/v1/management/transitions/probe') return send(response, 200, await service.probeManagementTransition(value)) + if (request.method === 'POST' && pathname === '/v1/management/transitions/commit') return send(response, 200, await service.commitManagementTransition(value)) + if (request.method === 'POST' && pathname === '/v1/management/continuations/consume') return send(response, 200, await service.consumeManagementContinuation(value)) + if (request.method === 'POST' && pathname === '/v1/management/auth-settings') return send(response, 200, await service.updateAuthenticationSettings(value)) + send(response, 404, { error: 'not found', code: 'NOT_FOUND' }) + })().catch(async error => { + const status = error instanceof AccessError ? error.statusCode : 500 + const failedPathname = new URL(request.url ?? '/', 'http://access.internal').pathname + const recoveryOperation = surface === 'recovery' && request.method === 'POST' + ? RECOVERY_OPERATIONS.get(failedPathname) : undefined + if (recoveryOperation !== undefined) { + await service.report('access.recovery-operation.failed', { + operation: recoveryOperation, + code: error instanceof AccessError ? error.code : 'INTERNAL_ERROR', + }) + } + const accessFailureEvent = surface === 'access' && request.method === 'POST' + ? ACCESS_FAILURE_EVENTS.get(failedPathname) : undefined + if (accessFailureEvent !== undefined) { + await service.report(accessFailureEvent, { + code: error instanceof AccessError ? error.code : 'INTERNAL_ERROR', + }) + } + if (!(error instanceof AccessError)) await service.report('access.request.failed', { + error, method: request.method ?? null, pathname: request.url ?? null, + }) + send(response, status, accessErrorBody(error), Number.isInteger(error?.details?.retryAfterSeconds) + ? { 'retry-after': String(error.details.retryAfterSeconds) } : {}) + }) + }) +} diff --git a/upstream/container/control-plane/services/access-manager/lib/sessions.mjs b/upstream/container/control-plane/services/access-manager/lib/sessions.mjs new file mode 100644 index 0000000..8899153 --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/lib/sessions.mjs @@ -0,0 +1,301 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' + +function digest(value) { + return createHash('sha256').update(value).digest() +} + +function identifier(random = randomBytes, bytes = 32) { + return random(bytes).toString('base64url') +} + +function sameDigest(value, expected) { + const actual = digest(value ?? '') + return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected) +} + +function sessionClient(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return Object.freeze({ ip: null, userAgent: null }) + const bounded = (field, maximum) => typeof field === 'string' && field.length > 0 && field.length <= maximum + ? field : null + return Object.freeze({ + ip: bounded(value.ip, 128), + userAgent: bounded(value.userAgent, 512), + }) +} + +export class BrowserSessionStore { + constructor({ + now = Date.now, + random = randomBytes, + dshAbsoluteMs = 30 * 24 * 60 * 60_000, + dshIdleMs = 7 * 24 * 60 * 60_000, + managementAbsoluteMs = 24 * 60 * 60_000, + managementIdleMs = 2 * 60 * 60_000, + activityReportMs = 5 * 60_000, + onEvent = () => {}, + } = {}) { + this.now = now + this.random = random + this.policy = Object.freeze({ + dsh: Object.freeze({ absoluteMs: dshAbsoluteMs, idleMs: dshIdleMs }), + management: Object.freeze({ absoluteMs: managementAbsoluteMs, idleMs: managementIdleMs }), + }) + this.activityReportMs = activityReportMs + this.onEvent = onEvent + this.sessions = new Map() + } + + setEventSink(onEvent) { + this.onEvent = typeof onEvent === 'function' ? onEvent : () => {} + } + + emit(message, session, fields = {}) { + this.onEvent(message, { + accountId: session.accountId, + sessionId: session.sessionId, + kind: session.kind, + ...fields, + }) + } + + prune() { + const now = this.now() + for (const [key, session] of this.sessions) { + const reason = session.expiresAt <= now + ? 'absolute' + : session.lastSeenAt + session.idleMs <= now ? 'idle' : null + if (reason === null) continue + this.sessions.delete(key) + this.emit('access.session.expired', session, { reason }) + } + for (const [key, session] of this.sessions) { + if (session.kind === 'management' && this.activeDshSession(session.sourceDshSessionId, session.accountId) === undefined) { + this.sessions.delete(key) + this.emit('access.session.invalidated', session, { reason: 'source-dsh-session' }) + } + } + } + + activeDshSession(sessionId, accountId) { + if (typeof sessionId !== 'string') return undefined + for (const session of this.sessions.values()) { + if (session.kind === 'dsh' && session.sessionId === sessionId && session.accountId === accountId) return session + } + return undefined + } + + issue(kind, account, { + origin, sourceDshOrigin = null, sourceDshSessionId = null, client = null, authenticationSource = 'unknown', + } = {}) { + const policy = this.policy[kind] + if (policy === undefined) throw new TypeError('browser session kind is invalid') + if (typeof origin !== 'string' || origin.length === 0 || origin.length > 2_048) { + throw new TypeError('browser session origin is invalid') + } + this.prune() + const sourceDshSession = kind === 'management' + ? this.activeDshSession(sourceDshSessionId, account.accountId) + : undefined + if (kind === 'management' && (sourceDshSession === undefined || sourceDshSession.origin !== sourceDshOrigin)) { + throw new TypeError('Management session requires an active DSH session') + } + const token = `${kind === 'dsh' ? 'dshs' : 'dshms'}_${identifier(this.random)}` + const csrfToken = `dshc_${identifier(this.random)}` + const sessionId = identifier(this.random, 16) + const createdAt = this.now() + const sourceClient = sourceDshSession === undefined ? sessionClient(client) : sourceDshSession.client + const expiresAt = Math.min(createdAt + policy.absoluteMs, sourceDshSession?.expiresAt ?? Number.POSITIVE_INFINITY) + this.sessions.set(digest(token).toString('hex'), { + sessionId, + kind, + accountId: account.accountId, + mainCredentialVersion: account.mainCredential.version, + managementAdditionalCredentialVersion: account.managementAdditionalCredential.enabled + ? account.managementAdditionalCredential.version : null, + managementAccessVersion: account.managementAccess.version, + origin, + sourceDshOrigin, + sourceDshSessionId, + authenticationSource: sourceDshSession?.authenticationSource ?? authenticationSource, + client: sourceClient, + csrfDigest: digest(csrfToken), + createdAt, + lastSeenAt: createdAt, + activityReportedAt: createdAt, + expiresAt, + idleMs: policy.idleMs, + }) + return Object.freeze({ + token, + csrfToken, + sessionId, + expiresAt: new Date(expiresAt).toISOString(), + }) + } + + validate(token, kind, account, { origin, csrfToken, requireCsrf = false, touch = true } = {}) { + this.prune() + if (typeof token !== 'string' || token.length > 512 || this.policy[kind] === undefined) return undefined + const key = digest(token).toString('hex') + const session = this.sessions.get(key) + if (session === undefined || session.kind !== kind || session.accountId !== account.accountId + || session.origin !== origin || (requireCsrf && !sameDigest(csrfToken, session.csrfDigest))) { + return undefined + } + let invalidationReason = null + if (session.mainCredentialVersion !== account.mainCredential.version) invalidationReason = 'main-credential-version' + else if (session.managementAccessVersion !== account.managementAccess.version) invalidationReason = 'management-access-version' + else if (kind === 'management' + && session.managementAdditionalCredentialVersion !== (account.managementAdditionalCredential.enabled + ? account.managementAdditionalCredential.version : null)) invalidationReason = 'management-credential-version' + if (invalidationReason !== null) { + this.sessions.delete(key) + this.emit('access.session.invalidated', session, { reason: invalidationReason }) + return undefined + } + if (kind === 'management') { + const source = this.activeDshSession(session.sourceDshSessionId, account.accountId) + if (source === undefined || source.origin !== session.sourceDshOrigin + || source.mainCredentialVersion !== account.mainCredential.version + || source.managementAccessVersion !== account.managementAccess.version) { + this.sessions.delete(key) + this.emit('access.session.invalidated', session, { reason: 'source-dsh-session' }) + return undefined + } + } + if (touch) { + const now = this.now() + session.lastSeenAt = now + if (now - session.activityReportedAt >= this.activityReportMs) { + session.activityReportedAt = now + this.emit('access.session.activity', session) + } + } + return Object.freeze({ + sessionId: session.sessionId, + kind: session.kind, + accountId: session.accountId, + origin: session.origin, + sourceDshOrigin: session.sourceDshOrigin, + sourceDshSessionId: session.sourceDshSessionId, + createdAt: new Date(session.createdAt).toISOString(), + expiresAt: new Date(session.expiresAt).toISOString(), + }) + } + + revoke(token) { + if (typeof token !== 'string' || token.length > 512) return false + const key = digest(token).toString('hex') + const session = this.sessions.get(key) + if (!this.sessions.delete(key)) return false + if (session.kind === 'dsh') this.revokeManagementFromDsh(session.sessionId, 'source-dsh-session-revoked') + return true + } + + revokeKind(kind) { + const dshSessionIds = [] + let revoked = 0 + for (const [key, session] of this.sessions) { + if (session.kind !== kind) continue + this.sessions.delete(key) + revoked += 1 + if (kind === 'dsh') dshSessionIds.push(session.sessionId) + } + for (const sessionId of dshSessionIds) this.revokeManagementFromDsh(sessionId, 'source-dsh-session-revoked') + return revoked + } + + countKind(kind) { + this.prune() + let count = 0 + for (const session of this.sessions.values()) { + if (session.kind === kind) count += 1 + } + return count + } + + list(account, currentSessionId) { + this.prune() + const currentDshSessionId = this.sourceDshSessionId(currentSessionId) ?? currentSessionId + const managementSources = new Set([...this.sessions.values()] + .filter(session => session.kind === 'management') + .map(session => session.sourceDshSessionId)) + return [...this.sessions.values()] + .filter(session => session.accountId === account.accountId + && session.kind === 'dsh' + && session.mainCredentialVersion === account.mainCredential.version + && session.managementAccessVersion === account.managementAccess.version) + .map(session => Object.freeze({ + sessionId: session.sessionId, + origin: session.origin, + current: session.sessionId === currentDshSessionId, + managementActive: managementSources.has(session.sessionId), + ip: session.client.ip, + userAgent: session.client.userAgent, + createdAt: new Date(session.createdAt).toISOString(), + lastSeenAt: new Date(session.lastSeenAt).toISOString(), + expiresAt: new Date(session.expiresAt).toISOString(), + })) + .sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt)) + } + + revokeDshSession(sessionId, accountId) { + if (typeof sessionId !== 'string' || typeof accountId !== 'string') return false + for (const [key, session] of this.sessions) { + if (session.kind !== 'dsh' || session.sessionId !== sessionId || session.accountId !== accountId) continue + this.sessions.delete(key) + this.revokeManagementFromDsh(session.sessionId, 'source-dsh-session-revoked') + return true + } + return false + } + + revokeManagementFromDsh(sessionId, reason = 'source-dsh-session') { + let revoked = 0 + for (const [key, session] of this.sessions) { + if (session.kind !== 'management' || session.sourceDshSessionId !== sessionId) continue + this.sessions.delete(key) + this.emit('access.session.invalidated', session, { reason }) + revoked += 1 + } + return revoked + } + + sourceDshSessionId(sessionId) { + for (const session of this.sessions.values()) { + if (session.kind === 'management' && session.sessionId === sessionId) return session.sourceDshSessionId + } + return null + } + + refreshDshSession(sessionId, account) { + const session = this.activeDshSession(sessionId, account.accountId) + if (session === undefined) return false + session.mainCredentialVersion = account.mainCredential.version + session.managementAccessVersion = account.managementAccess.version + this.emit('access.session.refreshed', session, { reason: 'management-access-change' }) + return true + } + + details(sessionId) { + this.prune() + for (const session of this.sessions.values()) { + if (session.sessionId !== sessionId) continue + return Object.freeze({ + sessionId: session.sessionId, + kind: session.kind, + origin: session.origin, + sourceDshOrigin: session.sourceDshOrigin, + sourceDshSessionId: session.sourceDshSessionId, + authenticationSource: session.authenticationSource, + }) + } + return undefined + } + + revokeAll() { + const revoked = this.sessions.size + this.sessions.clear() + return revoked + } +} diff --git a/upstream/container/control-plane/services/access-manager/lib/store.mjs b/upstream/container/control-plane/services/access-manager/lib/store.mjs new file mode 100644 index 0000000..b95eff9 --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/lib/store.mjs @@ -0,0 +1,249 @@ +import { randomBytes, randomUUID } from 'node:crypto' +import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { AccessError } from './errors.mjs' +import { createCredential, normalizeUsername, validCredential } from './credentials.mjs' +import { validTotpSecret } from './totp.mjs' + +const STATES = new Set(['never-initialized', 'migration-required', 'initialized', 'recovery-required']) + +function identifier(random = randomBytes) { return random(32).toString('base64url') } +function timestamp(now) { return now().toISOString() } + +async function syncDirectory(root) { + const handle = await open(root, 'r') + try { await handle.sync() } finally { await handle.close() } +} + +async function atomicJson(path, value, mode = 0o600) { + const temporary = join(dirname(path), `.${path.split('/').at(-1)}.${randomUUID()}.tmp`) + const handle = await open(temporary, 'wx', mode) + try { + await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8') + await handle.sync() + } finally { await handle.close() } + await chmod(temporary, mode) + await rename(temporary, path) + await syncDirectory(dirname(path)) +} + +async function createExclusiveJson(path, value, mode = 0o600) { + const handle = await open(path, 'wx', mode) + try { + await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8') + await handle.sync() + } finally { await handle.close() } + await chmod(path, mode) + await syncDirectory(dirname(path)) +} + +async function readJson(path) { + try { return { exists: true, value: JSON.parse(await readFile(path, 'utf8')) } } + catch (error) { + if (error?.code === 'ENOENT') return { exists: false, value: undefined } + return { exists: true, error } + } +} + +export function validInitialization(value) { + return value !== null && typeof value === 'object' && value.schema === 1 + && typeof value.instanceId === 'string' && Buffer.from(value.instanceId, 'base64url').byteLength === 32 + && STATES.has(value.state) && Number.isFinite(Date.parse(value.createdAt)) && Number.isFinite(Date.parse(value.updatedAt)) +} + +export function validAccount(value) { + const additional = value?.managementAdditionalCredential + const totp = value?.totp + const access = value?.managementAccess + const validOrigin = origin => { + try { + const parsed = new URL(origin) + return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin !== 'null' + && parsed.username === '' && parsed.password === '' && parsed.pathname === '/' + && parsed.search === '' && parsed.hash === '' && origin === parsed.origin + } catch { return false } + } + const additionalValid = additional !== null && typeof additional === 'object' + && typeof additional.enabled === 'boolean' + && Number.isInteger(additional.version) && additional.version >= 1 + && Number.isFinite(Date.parse(additional.changedAt)) + && (additional.enabled ? validCredential(additional.verifier) : additional.verifier === null) + const totpValid = totp === undefined || (totp !== null && typeof totp === 'object' + && typeof totp.enabled === 'boolean' + && Number.isInteger(totp.version) && totp.version >= 1 + && Number.isFinite(Date.parse(totp.changedAt)) + && (totp.enabled ? validTotpSecret(totp.secret) : totp.secret === null)) + const isolatedEntryValid = access?.mode === 'compat' + ? access.isolatedEntry === null && access.dshPublicOrigin === null + : access?.mode === 'isolated' && access.isolatedEntry !== null && typeof access.isolatedEntry === 'object' + && validOrigin(access.dshPublicOrigin) + && (access.isolatedEntry.kind === 'local-only' && validOrigin(access.isolatedEntry.managementLocalOrigin) + || (access.isolatedEntry.kind === 'public' && (() => { + try { + const parsed = new URL(access.isolatedEntry.managementPublicOrigin) + return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin !== 'null' + && parsed.username === '' && parsed.password === '' && parsed.pathname === '/' + && parsed.search === '' && parsed.hash === '' + && access.isolatedEntry.managementPublicOrigin === parsed.origin + } catch { return false } + })())) + return value !== null && typeof value === 'object' && value.schema === 1 + && typeof value.revision === 'string' && Buffer.from(value.revision, 'base64url').byteLength === 32 + && typeof value.accountId === 'string' && Buffer.from(value.accountId, 'base64url').byteLength === 32 + && (() => { try { return normalizeUsername(value.username) === value.username } catch { return false } })() + && validCredential(value.mainCredential) + && additionalValid && totpValid && isolatedEntryValid + && Number.isInteger(access?.version) && access.version >= 1 + && Number.isFinite(Date.parse(access.changedAt)) + && Number.isFinite(Date.parse(value.createdAt)) && Number.isFinite(Date.parse(value.updatedAt)) +} + +function validEvidence(evidence) { + if (evidence === null || typeof evidence !== 'object' || Array.isArray(evidence)) return false + const entries = Object.entries(evidence) + return entries.length > 0 && entries.every(([key, value]) => ( + /^[a-z][A-Za-z0-9]{0,63}$/.test(key) && typeof value === 'boolean' + )) +} + +export class AccessStateStore { + constructor({ root, now = () => new Date(), random = randomBytes, createVerifier = createCredential } = {}) { + if (typeof root !== 'string') throw new TypeError('Access state root is required') + this.root = root + this.initializationPath = join(root, 'initialization.json') + this.accountPath = join(root, 'account.json') + this.now = now + this.random = random + this.createVerifier = createVerifier + } + + async prepare() { + await mkdir(this.root, { recursive: true, mode: 0o700 }) + await chmod(this.root, 0o700) + } + + async inspect() { + const [initialization, account] = await Promise.all([ + readJson(this.initializationPath), readJson(this.accountPath), + ]) + return { + initialization: initialization.error === undefined && validInitialization(initialization.value) + ? initialization.value : undefined, + initializationExists: initialization.exists, + initializationDamaged: initialization.error !== undefined || (initialization.exists && !validInitialization(initialization.value)), + account: account.error === undefined && validAccount(account.value) ? account.value : undefined, + accountExists: account.exists, + accountDamaged: account.error !== undefined || (account.exists && !validAccount(account.value)), + } + } + + async transition(initialization, state) { + const next = { ...initialization, state, updatedAt: timestamp(this.now) } + await atomicJson(this.initializationPath, next) + return next + } + + async classify(evidence) { + if (!validEvidence(evidence)) throw new AccessError('CLASSIFICATION_EVIDENCE_INVALID', 'classification evidence is invalid') + await this.prepare() + const inspected = await this.inspect() + if (inspected.initialization !== undefined) { + if (inspected.initialization.state === 'never-initialized' && inspected.account !== undefined) { + const initialization = await this.transition(inspected.initialization, 'initialized') + return { initialization, account: inspected.account } + } + if (inspected.initialization.state === 'initialized' && inspected.account === undefined) { + const initialization = await this.transition(inspected.initialization, 'recovery-required') + return { initialization, account: undefined } + } + if (inspected.accountDamaged) { + const initialization = inspected.initialization.state === 'recovery-required' + ? inspected.initialization : await this.transition(inspected.initialization, 'recovery-required') + return { initialization, account: undefined } + } + return { initialization: inspected.initialization, account: inspected.account } + } + if (inspected.initializationDamaged || inspected.accountExists) { + const initialization = { + schema: 1, instanceId: identifier(this.random), state: 'recovery-required', + createdAt: timestamp(this.now), updatedAt: timestamp(this.now), + } + await atomicJson(this.initializationPath, initialization) + return { initialization, account: undefined } + } + const persistentEvidence = Object.entries(evidence) + .some(([key, value]) => key !== 'legacyAuthenticationConfigured' && value) + const initialization = { + schema: 1, + instanceId: identifier(this.random), + state: persistentEvidence ? 'migration-required' : 'never-initialized', + createdAt: timestamp(this.now), + updatedAt: timestamp(this.now), + } + try { await createExclusiveJson(this.initializationPath, initialization) } + catch (error) { + if (error?.code === 'EEXIST') return this.classify(evidence) + throw error + } + return { initialization, account: undefined } + } + + async state() { + const inspected = await this.inspect() + if (inspected.initialization === undefined) { + return { initialization: undefined, account: undefined, state: 'classification-pending' } + } + if (inspected.initialization.state === 'initialized' && inspected.account === undefined) { + return { initialization: inspected.initialization, account: undefined, state: 'recovery-required' } + } + return { initialization: inspected.initialization, account: inspected.account, state: inspected.initialization.state } + } + + async createAccount({ username, password }, allowedState) { + const current = await this.state() + if (current.state !== allowedState) { + throw new AccessError('ALREADY_INITIALIZED', 'administrator initialization is unavailable', 409) + } + const normalized = normalizeUsername(username) + const now = timestamp(this.now) + const verifier = await this.createVerifier(password, { now: this.now, random: this.random }) + const account = { + schema: 1, + revision: identifier(this.random), + accountId: identifier(this.random), + username: normalized, + mainCredential: verifier, + totp: { enabled: false, version: 1, secret: null, changedAt: now }, + managementAdditionalCredential: { enabled: false, version: 1, verifier: null, changedAt: now }, + managementAccess: { + mode: 'compat', version: 1, isolatedEntry: null, dshPublicOrigin: null, changedAt: now, + }, + createdAt: now, + updatedAt: now, + } + await atomicJson(this.accountPath, account) + await this.transition(current.initialization, 'initialized') + return account + } + + initialize(value) { return this.createAccount(value, 'never-initialized') } + + migrate(value) { return this.createAccount(value, 'migration-required') } + + recover(value) { return this.createAccount(value, 'recovery-required') } + + async replaceAccount(account, revision) { + const current = await this.state() + if (current.state !== 'initialized' || current.account === undefined) { + throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409) + } + if (current.account.revision !== revision) throw new AccessError('REVISION_CONFLICT', 'account revision changed', 409) + if (!validAccount(account)) throw new AccessError('ACCOUNT_INVALID', 'account record is invalid') + await atomicJson(this.accountPath, account) + return account + } + + async clear() { + await rm(this.accountPath, { force: true }) + } +} diff --git a/upstream/container/control-plane/services/access-manager/lib/totp.mjs b/upstream/container/control-plane/services/access-manager/lib/totp.mjs new file mode 100644 index 0000000..fde4c27 --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/lib/totp.mjs @@ -0,0 +1,293 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' +const CODE_PATTERN = /^\d{6}$/ + +function digest(value) { return createHash('sha256').update(value).digest('hex') } + +function token(prefix, random) { return `${prefix}_${random(32).toString('base64url')}` } + +function base32Encode(value) { + let bits = 0 + let buffer = 0 + let output = '' + for (const byte of value) { + buffer = (buffer << 8) | byte + bits += 8 + while (bits >= 5) { + output += BASE32_ALPHABET[(buffer >>> (bits - 5)) & 31] + bits -= 5 + } + } + if (bits > 0) output += BASE32_ALPHABET[(buffer << (5 - bits)) & 31] + return output +} + +function base32Decode(value) { + if (typeof value !== 'string' || value.length === 0 || /[^A-Z2-7]/u.test(value)) return undefined + let bits = 0 + let buffer = 0 + const output = [] + for (const character of value) { + buffer = (buffer << 5) | BASE32_ALPHABET.indexOf(character) + bits += 5 + if (bits >= 8) { + output.push((buffer >>> (bits - 8)) & 255) + bits -= 8 + } + } + return Buffer.from(output) +} + +function counterBuffer(counter) { + const value = Buffer.alloc(8) + value.writeBigUInt64BE(BigInt(counter)) + return value +} + +export function generateTotpSecret(random = randomBytes) { + return base32Encode(random(20)) +} + +export function validTotpSecret(secret) { + return base32Decode(secret)?.byteLength === 20 +} + +export function totpCode(secret, { now = Date.now(), offset = 0 } = {}) { + const key = base32Decode(secret) + if (key?.byteLength !== 20) throw new TypeError('TOTP secret is invalid') + const counter = Math.floor(now / 30_000) + offset + const hmac = createHmac('sha1', key).update(counterBuffer(counter)).digest() + const position = hmac.at(-1) & 15 + const value = (hmac.readUInt32BE(position) & 0x7fffffff) % 1_000_000 + return String(value).padStart(6, '0') +} + +export function verifyTotpCode(code, secret, { now = Date.now(), window = 1 } = {}) { + if (typeof code !== 'string' || !CODE_PATTERN.test(code)) return false + const actual = Buffer.from(code) + for (let offset = -window; offset <= window; offset += 1) { + const expected = Buffer.from(totpCode(secret, { now, offset })) + if (timingSafeEqual(actual, expected)) return true + } + return false +} + +export function totpUri({ secret, username, issuer = 'DSH Docker' }) { + if (!validTotpSecret(secret)) throw new TypeError('TOTP secret is invalid') + const label = `${issuer}:${username}` + const query = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: '30' }) + return `otpauth://totp/${encodeURIComponent(label)}?${query}` +} + +export class TotpFlowStore { + constructor({ now = Date.now, random = randomBytes, enrollmentTtlMs = 5 * 60_000, loginTtlMs = 5 * 60_000 } = {}) { + this.now = now + this.random = random + this.enrollmentTtlMs = enrollmentTtlMs + this.loginTtlMs = loginTtlMs + this.enrollments = new Map() + this.disableConfirmations = new Map() + this.logins = new Map() + } + + prune() { + const current = this.now() + for (const [key, value] of this.enrollments) if (value.expiresAt <= current) this.enrollments.delete(key) + for (const [key, value] of this.disableConfirmations) if (value.expiresAt <= current) this.disableConfirmations.delete(key) + for (const [key, value] of this.logins) if (value.expiresAt <= current) this.logins.delete(key) + } + + createEnrollment(account, sessionId) { + this.prune() + for (const [key, value] of this.enrollments) { + if (value.accountId === account.accountId && value.sessionId === sessionId) this.enrollments.delete(key) + } + const value = token('dshte', this.random) + const secret = generateTotpSecret(this.random) + const expiresAt = this.now() + this.enrollmentTtlMs + this.enrollments.set(digest(value), { + accountId: account.accountId, + accountRevision: account.revision, + sessionId, + secret, + confirmed: false, + expiresAt, + }) + return Object.freeze({ token: value, secret, expiresAt: new Date(expiresAt).toISOString() }) + } + + enrollment(value, account, sessionId) { + this.prune() + if (typeof value !== 'string' || value.length > 512) return undefined + const key = digest(value) + const enrollment = this.enrollments.get(key) + if (enrollment === undefined || enrollment.accountId !== account.accountId + || enrollment.accountRevision !== account.revision || enrollment.sessionId !== sessionId) return undefined + return { key, value: enrollment } + } + + cancelEnrollment(value, account, sessionId) { + const enrollment = this.enrollment(value, account, sessionId) + return enrollment === undefined ? false : this.enrollments.delete(enrollment.key) + } + + confirmEnrollment(key) { + const enrollment = this.enrollments.get(key) + if (enrollment === undefined) return false + enrollment.confirmed = true + return true + } + + consumeEnrollment(key) { return this.enrollments.delete(key) } + + createDisableConfirmation(account, sessionId) { + this.prune() + for (const [key, value] of this.disableConfirmations) { + if (value.accountId === account.accountId && value.sessionId === sessionId) this.disableConfirmations.delete(key) + } + const value = token('dshtd', this.random) + const expiresAt = this.now() + this.enrollmentTtlMs + this.disableConfirmations.set(digest(value), { + accountId: account.accountId, + accountRevision: account.revision, + sessionId, + confirmed: false, + expiresAt, + }) + return Object.freeze({ token: value, expiresAt: new Date(expiresAt).toISOString() }) + } + + disableConfirmation(value, account, sessionId) { + this.prune() + if (typeof value !== 'string' || value.length > 512) return undefined + const key = digest(value) + const confirmation = this.disableConfirmations.get(key) + if (confirmation === undefined || confirmation.accountId !== account.accountId + || confirmation.accountRevision !== account.revision || confirmation.sessionId !== sessionId) return undefined + return { key, value: confirmation } + } + + cancelDisableConfirmation(value, account, sessionId) { + const confirmation = this.disableConfirmation(value, account, sessionId) + return confirmation === undefined ? false : this.disableConfirmations.delete(confirmation.key) + } + + confirmDisable(key) { + const confirmation = this.disableConfirmations.get(key) + if (confirmation === undefined) return false + confirmation.confirmed = true + return true + } + + consumeDisableConfirmation(key) { return this.disableConfirmations.delete(key) } + + createLogin(account, details) { + this.prune() + const value = token('dshtl', this.random) + const expiresAt = this.now() + this.loginTtlMs + this.logins.set(digest(value), { + accountId: account.accountId, + accountRevision: account.revision, + ...details, + expiresAt, + }) + return Object.freeze({ token: value, expiresAt: new Date(expiresAt).toISOString() }) + } + + login(value, account) { + this.prune() + if (typeof value !== 'string' || value.length > 512) return undefined + const key = digest(value) + const login = this.logins.get(key) + if (login === undefined || login.accountId !== account.accountId + || login.accountRevision !== account.revision) return undefined + return { key, value: login } + } + + consumeLogin(key) { return this.logins.delete(key) } + + clear() { + this.enrollments.clear() + this.disableConfirmations.clear() + this.logins.clear() + } +} + +export class TotpRetryLimiter { + constructor({ + now = Date.now, + threshold = 3, + retryMs = 10_000, + dailyWindowMs = 24 * 60 * 60_000, + sourceDailyLimit = 25, + globalDailyLimit = 50, + } = {}) { + this.now = now + this.threshold = threshold + this.retryMs = retryMs + this.dailyWindowMs = dailyWindowMs + this.sourceDailyLimit = sourceDailyLimit + this.globalDailyLimit = globalDailyLimit + this.entries = new Map() + this.globalFailures = new Map() + } + + key(accountId, source) { return `${accountId}\u0000${source}` } + + prune(values) { + const cutoff = this.now() - this.dailyWindowMs + while (values.length > 0 && values[0] <= cutoff) values.shift() + } + + retry(accountId, source) { + const entry = this.entries.get(this.key(accountId, source)) + const global = this.globalFailures.get(accountId) ?? [] + if (entry !== undefined) this.prune(entry.failuresInWindow) + this.prune(global) + const until = values => Math.max(1, Math.ceil((values[0] + this.dailyWindowMs - this.now()) / 1000)) + if (global.length >= this.globalDailyLimit) return { kind: 'rate', retryAfterSeconds: until(global) } + if (entry?.failuresInWindow.length >= this.sourceDailyLimit) { + return { kind: 'rate', retryAfterSeconds: until(entry.failuresInWindow) } + } + if (entry !== undefined && entry.blockedUntil > this.now()) { + return { kind: 'retry', retryAfterSeconds: Math.max(1, Math.ceil((entry.blockedUntil - this.now()) / 1000)) } + } + return { kind: null, retryAfterSeconds: 0 } + } + + fail(accountId, source) { + const key = this.key(accountId, source) + const entry = this.entries.get(key) ?? { consecutiveFailures: 0, blockedUntil: 0, failuresInWindow: [] } + const global = this.globalFailures.get(accountId) ?? [] + this.prune(entry.failuresInWindow) + this.prune(global) + entry.consecutiveFailures += 1 + entry.failuresInWindow.push(this.now()) + global.push(this.now()) + if (entry.consecutiveFailures >= this.threshold) entry.blockedUntil = this.now() + this.retryMs + this.entries.set(key, entry) + this.globalFailures.set(accountId, global) + return this.retry(accountId, source) + } + + succeed(accountId, source) { + const entry = this.entries.get(this.key(accountId, source)) + if (entry === undefined) return + entry.consecutiveFailures = 0 + entry.blockedUntil = 0 + } + + clearDailyLimits(accountId, { globalOnly = false } = {}) { + let cleared = this.globalFailures.delete(accountId) ? 1 : 0 + if (globalOnly) return { cleared } + for (const [key, entry] of this.entries) { + if (!key.startsWith(`${accountId}\u0000`)) continue + if (entry.failuresInWindow.length > 0) { + entry.failuresInWindow = [] + cleared += 1 + } + } + return { cleared } + } +} diff --git a/upstream/container/control-plane/services/access-manager/lib/transitions.mjs b/upstream/container/control-plane/services/access-manager/lib/transitions.mjs new file mode 100644 index 0000000..63d6629 --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/lib/transitions.mjs @@ -0,0 +1,120 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' + +function digest(value) { return createHash('sha256').update(value).digest() } +function token(prefix, random, bytes = 32) { return `${prefix}_${random(bytes).toString('base64url')}` } +function sameDigest(value, expected) { + if (typeof value !== 'string' || expected === null) return false + const actual = digest(value) + return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected) +} + +export class ManagementTransitionStore { + constructor({ now = Date.now, random = randomBytes, ttlMs = 30_000 } = {}) { + this.now = now + this.random = random + this.ttlMs = ttlMs + this.transitions = new Map() + this.continuations = new Map() + } + + prune() { + const now = this.now() + for (const [id, value] of this.transitions) if (value.expiresAt <= now) this.transitions.delete(id) + for (const [key, value] of this.continuations) if (value.expiresAt <= now) this.continuations.delete(key) + } + + create({ account, instanceId, sessionId, sourceOrigin, sourceDshOrigin, sourceDshSessionId, mode, isolatedEntry, candidateOrigin }) { + this.prune() + const transitionId = token('dshmt', this.random, 16) + const nonce = candidateOrigin === null ? null : token('dshmn', this.random) + const expiresAt = this.now() + this.ttlMs + this.transitions.set(transitionId, { + accountId: account.accountId, + accountRevision: account.revision, + managementAccessVersion: account.managementAccess.version, + instanceId, + sessionId, + sourceOrigin, + sourceDshOrigin, + sourceDshSessionId, + mode, + isolatedEntry, + candidateOrigin, + nonceDigest: nonce === null ? null : digest(nonce), + proofDigest: null, + expiresAt, + }) + return Object.freeze({ + transitionId, + nonce, + candidateOrigin, + expiresAt: new Date(expiresAt).toISOString(), + }) + } + + probe({ transitionId, nonce, candidateOrigin, sourceOrigin, instanceId }) { + this.prune() + const transition = this.transitions.get(transitionId) + if (transition === undefined || transition.candidateOrigin !== candidateOrigin + || transition.sourceOrigin !== sourceOrigin || transition.instanceId !== instanceId + || !sameDigest(nonce, transition.nonceDigest)) return undefined + transition.nonceDigest = null + const proof = token('dshmpf', this.random) + transition.proofDigest = digest(proof) + return Object.freeze({ proof, expiresAt: new Date(transition.expiresAt).toISOString() }) + } + + inspect({ transitionId, proof, account, sessionId }) { + this.prune() + const transition = this.transitions.get(transitionId) + if (transition === undefined || transition.accountId !== account.accountId + || transition.accountRevision !== account.revision + || transition.managementAccessVersion !== account.managementAccess.version + || transition.sessionId !== sessionId + || (transition.candidateOrigin !== null && !sameDigest(proof, transition.proofDigest))) return undefined + return transition + } + + consume(value) { + const transition = this.inspect(value) + if (transition === undefined) return undefined + const { transitionId } = value + this.transitions.delete(transitionId) + return transition + } + + createContinuation({ account, targetOrigin, sourceDshOrigin, sourceDshSessionId }) { + if (targetOrigin === null) return null + this.prune() + const continuation = token('dshmc', this.random) + const expiresAt = this.now() + this.ttlMs + this.continuations.set(digest(continuation).toString('hex'), { + accountId: account.accountId, + accountRevision: account.revision, + managementAccessVersion: account.managementAccess.version, + targetOrigin, + sourceDshOrigin, + sourceDshSessionId, + expiresAt, + }) + return Object.freeze({ token: continuation, expiresAt: new Date(expiresAt).toISOString() }) + } + + consumeContinuation({ token: value, account, targetOrigin }) { + this.prune() + if (typeof value !== 'string' || value.length > 512) return undefined + const key = digest(value).toString('hex') + const continuation = this.continuations.get(key) + this.continuations.delete(key) + if (continuation === undefined || continuation.accountId !== account.accountId + || continuation.accountRevision !== account.revision + || continuation.managementAccessVersion !== account.managementAccess.version + || continuation.targetOrigin !== targetOrigin) return undefined + return continuation + } + + clear() { + this.transitions.clear() + this.continuations.clear() + } +} diff --git a/upstream/container/control-plane/services/access-manager/supervisor.mjs b/upstream/container/control-plane/services/access-manager/supervisor.mjs new file mode 100644 index 0000000..92ddb90 --- /dev/null +++ b/upstream/container/control-plane/services/access-manager/supervisor.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +import { LocalApiClient } from '../../modules/updater/lib/client.mjs' +import { PlatformPaths } from '../../../platform/lib/paths.mjs' + +const paths = new PlatformPaths( + process.env.DSH_PLATFORM_DATA ?? '/data/platform', + process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform', +) +const launch = new LocalApiClient(paths.accessLaunchSocket) + +if (process.argv[2] === 'health') { + const status = await launch.request('GET', '/v1/status') + process.exit(status.componentReady === true ? 0 : 1) +} + +const token = process.env.DSH_ACCESS_LAUNCH_TOKEN +delete process.env.DSH_ACCESS_LAUNCH_TOKEN +if (typeof token !== 'string' || token.length < 32) throw new Error('Access Manager launch token is unavailable') +await launch.request('POST', '/v1/start', { token }) + +let stopping = false +let resolveStop +const stopped = new Promise(resolve => { resolveStop = resolve }) +const stop = () => { + if (stopping) return + stopping = true + void launch.request('POST', '/v1/stop', { token }).catch(() => {}).finally(resolveStop) +} +process.once('SIGINT', stop) +process.once('SIGTERM', stop) + +const poll = setInterval(() => { + void launch.request('GET', '/v1/status').then(status => { + if (!stopping && status.running !== true) { + clearInterval(poll) + process.exitCode = 1 + resolveStop() + } + }, () => { + if (!stopping) { + clearInterval(poll) + process.exitCode = 1 + resolveStop() + } + }) +}, 500) +await stopped +clearInterval(poll) diff --git a/upstream/container/control-plane/services/gateway/component.json b/upstream/container/control-plane/services/gateway/component.json new file mode 100644 index 0000000..2ac7353 --- /dev/null +++ b/upstream/container/control-plane/services/gateway/component.json @@ -0,0 +1,31 @@ +{ + "schema": 1, + "id": "gateway", + "type": "service", + "command": { + "executable": "/usr/local/bin/node", + "args": ["/run/dsh-platform/views/bootstrap/control-plane/services/gateway/index.mjs"], + "timeoutSeconds": 30 + }, + "environment": {}, + "lifecycle": { + "prepare": null, + "preStart": null, + "postStart": null, + "preStop": null, + "stop": null, + "postStop": null + }, + "health": { + "type": "http", + "host": "127.0.0.1", + "port": 3080, + "path": "/_dsh_gateway/health", + "intervalSeconds": 1, + "timeoutSeconds": 30 + }, + "logging": { + "stdout": true, + "stderr": true + } +} diff --git a/upstream/container/control-plane/services/gateway/index.mjs b/upstream/container/control-plane/services/gateway/index.mjs new file mode 100644 index 0000000..054fdb1 --- /dev/null +++ b/upstream/container/control-plane/services/gateway/index.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { loadConfig } from './lib/config.mjs' +import { UsageError } from './lib/errors.mjs' +import { runGateway } from './lib/lifecycle.mjs' +import { JsonlLogManager } from '../../modules/log-manager/index.mjs' +import { PlatformPaths } from '../../../platform/lib/paths.mjs' + +const paths = new PlatformPaths( + process.env.DSH_PLATFORM_DATA ?? '/data/platform', + process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform', +) +const logs = new JsonlLogManager({ + root: paths.logsRoot, + maxBytes: Number(process.env.DSH_LOG_MAX_BYTES ?? 104857600), + retentionDays: Number(process.env.DSH_LOG_RETENTION_DAYS ?? 14), + output: { stdout: process.stdout, stderr: process.stderr }, +}) +logs.on('error', error => { void logs.diagnostic('log-manager', 'capture.failed', { error }) }) +const report = (message, fields = {}) => message === 'gateway.access.login-failed' + ? logs.diagnosticRateLimited( + `gateway.access.login-failed:${typeof fields.code === 'string' ? fields.code.toLowerCase() : 'unknown'}`, + 'gateway', message, fields, + ) + : logs.diagnostic('gateway', message, fields) + +try { + const config = await loadConfig() + process.exitCode = await runGateway(config, { report, managementPort: config.managementPort }) +} catch (error) { + if (error instanceof UsageError) { + await logs.diagnostic('gateway', 'gateway.configuration.failed', { error, level: 'warning' }) + process.exitCode = error.exitCode + } else { + await logs.diagnostic('gateway', 'gateway.process.failed', { error }) + process.exitCode = 1 + } +} diff --git a/upstream/container/control-plane/services/gateway/lib/availability.mjs b/upstream/container/control-plane/services/gateway/lib/availability.mjs new file mode 100644 index 0000000..b8e5aba --- /dev/null +++ b/upstream/container/control-plane/services/gateway/lib/availability.mjs @@ -0,0 +1,151 @@ +import { request as httpRequest } from 'node:http' + +// A Bootstrap handoff stops DSH while the updater that initiated it is still +// recorded as building the candidate. This state is consulted only after the +// DSH upstream becomes unavailable, so ordinary candidate builds stay visible. +const SWITCHING_STATES = new Set(['building-candidate', 'snapshotting-data', 'switching', 'probation']) +const RECOVERING_STATES = new Set(['restoring-data']) + +const COPY = Object.freeze({ + en: Object.freeze({ + starting: 'DeepSeek Harness is starting', + stopping: 'DeepSeek Harness is stopping', + stopped: 'DeepSeek Harness is stopped', + restarting: 'DeepSeek Harness is restarting', + switching: 'Switching the DeepSeek Harness runtime', + 'runtime-recovering': 'Restoring the DeepSeek Harness runtime', + recovering: 'DeepSeek Harness stopped unexpectedly and recovery is in progress', + failed: 'DeepSeek Harness could not start', + 'plugin-failed': 'DeepSeek Harness plugins repeatedly failed to load', + unavailable: 'DeepSeek Harness is temporarily unavailable and recovery is in progress', + }), + zh: Object.freeze({ + starting: 'DeepSeek Harness 正在启动', + stopping: 'DeepSeek Harness 正在停止', + stopped: 'DeepSeek Harness 已停止', + restarting: 'DeepSeek Harness 正在重新启动', + switching: '正在切换 DeepSeek Harness 运行版本', + 'runtime-recovering': '正在恢复 DeepSeek Harness 运行版本', + recovering: 'DeepSeek Harness 意外停止,正在尝试恢复', + failed: 'DeepSeek Harness 启动失败', + 'plugin-failed': 'DeepSeek Harness 插件持续加载失败', + unavailable: 'DeepSeek Harness 暂时不可用,正在尝试恢复', + }), +}) + +const MANAGEMENT_LINK = Object.freeze({ + en: 'Open DSH Management Console for diagnostics and recovery', + zh: '打开 DSH 管理中心进行检查和恢复', +}) + +const REFRESH_LINK = Object.freeze({ en: 'Reload page', zh: '刷新页面' }) + +export class DshAvailability { + constructor({ now = () => Date.now(), failures = 3, failureWindowMs = 1_500 } = {}) { + this.now = now + this.failures = failures + this.failureWindowMs = failureWindowMs + this.everReady = false + this.consecutiveFailures = 0 + this.firstFailureAt = null + } + + observe(ready) { + if (ready) { + this.everReady = true + this.consecutiveFailures = 0 + this.firstFailureAt = null + return + } + this.consecutiveFailures += 1 + this.firstFailureAt ??= this.now() + } + + classify(platform = {}) { + if (platform.operation === 'restart-failed') return 'failed' + if (platform.operation === 'restarting') return 'restarting' + if (platform.operation === 'recovering') return 'runtime-recovering' + if (platform.operation === 'switching') return 'switching' + const updateState = platform.update?.status + if (RECOVERING_STATES.has(updateState)) return 'runtime-recovering' + if (SWITCHING_STATES.has(updateState)) return 'switching' + const lifecycle = platform.dshLifecycle ?? {} + if (['starting', 'stopping', 'stopped', 'restarting', 'recovering', 'failed'].includes(lifecycle.state)) { + return lifecycle.state + } + if (platform.recoveryMode !== null && platform.recoveryMode !== undefined) return 'failed' + if (!this.everReady) return 'starting' + if ( + this.consecutiveFailures >= this.failures + && this.firstFailureAt !== null + && this.now() - this.firstFailureAt >= this.failureWindowMs + ) return 'unavailable' + return 'unknown' + } +} + +export function probeDsh({ host, port, timeoutMs = 750 }) { + return new Promise(resolve => { + const request = httpRequest({ hostname: host, port, path: '/', method: 'GET' }, response => { + response.resume() + resolve((response.statusCode ?? 500) < 500) + }) + request.setTimeout(timeoutMs, () => request.destroy()) + request.once('error', () => resolve(false)) + request.end() + }) +} + +function cookieLocale(cookie = '') { + for (const part of cookie.split(';')) { + const [name, value] = part.trim().split('=', 2) + if (name === 'dsh_locale' && (value === 'zh' || value === 'en')) return value + } + return undefined +} + +export function language(headers) { + const saved = cookieLocale(headers.cookie) + if (saved !== undefined) return saved + for (const item of (headers['accept-language'] ?? '').split(',')) { + const primary = item.trim().split(';', 1)[0].split('-', 1)[0].toLowerCase() + if (primary === 'zh' || primary === 'en') return primary + } + return 'en' +} + +export function stateMessage(state, headers = {}, lifecycle = {}) { + const locale = language(headers) + const message = COPY[locale][state] ?? COPY.en.unavailable + if (state !== 'recovering' || !(lifecycle.attempt > 0) || !(lifecycle.maxAttempts > 0)) return message + return locale === 'zh' + ? `${message}(第 ${String(lifecycle.attempt)} / ${String(lifecycle.maxAttempts)} 次)` + : `${message} (attempt ${String(lifecycle.attempt)} of ${String(lifecycle.maxAttempts)})` +} + +export function availabilityPage(state, headers = {}, { lifecycle = {}, returnPath = null, poll = true, allowRefresh = false, managementHref = '/_dsh_platform/console/' } = {}) { + const locale = language(headers) + const message = stateMessage(state, headers, lifecycle) + const managementLink = MANAGEMENT_LINK[locale] + const refreshLink = REFRESH_LINK[locale] + const target = JSON.stringify(returnPath).replaceAll('<', '\\u003c') + return ` + + + + +DeepSeek Harness + + + +
HARNESS
${message}
${managementLink}${allowRefresh ? `${refreshLink}` : ''}
+${poll ? `` : ''} + +` +} diff --git a/upstream/container/control-plane/services/gateway/lib/browser-auth.mjs b/upstream/container/control-plane/services/gateway/lib/browser-auth.mjs new file mode 100644 index 0000000..4dd054e --- /dev/null +++ b/upstream/container/control-plane/services/gateway/lib/browser-auth.mjs @@ -0,0 +1,1088 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' + +export const AUTH_PREFIX = '/_dsh_platform/auth/' +export const ACCESS_PREFIX = '/_dsh_platform/access/' +export const TRANSITION_PREFIX = '/_dsh_platform/transition/' +export const DSH_SESSION_COOKIE = 'dsh_gateway_session' +export const DSH_CSRF_COOKIE = 'dsh_gateway_csrf' +export const AUTH_CSRF_COOKIE = 'dsh_auth_csrf' +export const AUTH_RETRY_COOKIE = 'dsh_auth_retry_source' +export const TOTP_LOGIN_COOKIE = 'dsh_totp_login' +export const MANAGEMENT_SESSION_COOKIE = 'dsh_management_compat_session' +export const MANAGEMENT_CSRF_COOKIE = 'dsh_management_csrf' +export const MANAGEMENT_PENDING_COOKIE = 'dsh_management_pending' + +const DSH_SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 +const MANAGEMENT_SESSION_MAX_AGE_SECONDS = 24 * 60 * 60 + +const RESERVED_COOKIES = new Set([ + DSH_SESSION_COOKIE, + DSH_CSRF_COOKIE, + AUTH_CSRF_COOKIE, + AUTH_RETRY_COOKIE, + TOTP_LOGIN_COOKIE, + MANAGEMENT_SESSION_COOKIE, + MANAGEMENT_CSRF_COOKIE, + MANAGEMENT_PENDING_COOKIE, + 'dsh_management_secure_session', +]) + +export function isReservedCookieName(name) { return RESERVED_COOKIES.has(name) } + +function token(prefix) { return `${prefix}_${randomBytes(32).toString('base64url')}` } +function digest(value) { return createHash('sha256').update(value).digest('base64url') } + +export function cookieValue(header, name) { + if (typeof header !== 'string') return undefined + for (const part of header.split(';')) { + const separator = part.indexOf('=') + if (separator < 0 || part.slice(0, separator).trim() !== name) continue + const value = part.slice(separator + 1).trim() + return /^[A-Za-z0-9_-]{1,512}$/.test(value) ? value : undefined + } + return undefined +} + +export function stripReservedCookies(header) { + if (typeof header !== 'string') return undefined + const kept = header.split(';').map(value => value.trim()).filter(value => { + const separator = value.indexOf('=') + return separator > 0 && !RESERVED_COOKIES.has(value.slice(0, separator).trim()) + }) + return kept.length === 0 ? undefined : kept.join('; ') +} + +function sameSecret(left, right) { + const a = Buffer.from(left ?? '') + const b = Buffer.from(right ?? '') + return a.byteLength === b.byteLength && a.byteLength > 0 && timingSafeEqual(a, b) +} + +function language(request) { + return cookieValue(request.headers.cookie, 'dsh_locale') === 'zh' + || (cookieValue(request.headers.cookie, 'dsh_locale') === undefined + && /^zh\b/i.test(request.headers['accept-language'] ?? '')) ? 'zh' : 'en' +} + +export function requestOrigin(request, { requireHeader = false } = {}) { + const header = request.headers.origin + if (typeof header === 'string') { + try { + const parsed = new URL(header) + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username !== '' || parsed.password !== '' + || parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '' + || parsed.host.toLowerCase() !== String(request.headers.host ?? '').toLowerCase()) return undefined + return parsed.origin + } catch { return undefined } + } + if (requireHeader) return undefined + const protocol = request.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http' + try { return new URL(`${protocol}://${String(request.headers.host ?? '')}`).origin } + catch { return undefined } +} + +function requestTargetOrigin(request) { + const protocol = request.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http' + try { return new URL(`${protocol}://${String(request.headers.host ?? '')}`).origin } + catch { return undefined } +} + +function loginClient(request) { + const remoteAddress = request.socket?.remoteAddress + const ip = typeof remoteAddress === 'string' && remoteAddress.length <= 128 + ? remoteAddress.replace(/^::ffff:/, '') : null + const header = request.headers['user-agent'] + const userAgent = typeof header === 'string' && header.length > 0 + ? header.slice(0, 512) : null + return { ip, userAgent } +} + +function authenticationSource(request) { + const browser = cookieValue(request.headers.cookie, AUTH_RETRY_COOKIE) + if (browser !== undefined) return `browser:${digest(browser)}` + const ip = loginClient(request).ip + return ip === null ? 'unknown' : `ip:${digest(ip)}` +} + +function canonicalOrigin(value) { + try { + const parsed = new URL(value) + return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin === value ? parsed.origin : undefined + } catch { return undefined } +} + +function secureCookie(origin) { return origin.startsWith('https://') ? '; Secure' : '' } + +function sessionCookies(session, origin) { + return [ + `${DSH_SESSION_COOKIE}=${session.token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${DSH_SESSION_MAX_AGE_SECONDS}${secureCookie(origin)}`, + `${DSH_CSRF_COOKIE}=${session.csrfToken}; SameSite=Strict; Path=/; Max-Age=${DSH_SESSION_MAX_AGE_SECONDS}${secureCookie(origin)}`, + `${TOTP_LOGIN_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`, + ] +} + +function clearSessionCookies(origin) { + return [ + `${DSH_SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secureCookie(origin)}`, + `${DSH_CSRF_COOKIE}=; SameSite=Strict; Path=/; Max-Age=0${secureCookie(origin)}`, + `${AUTH_CSRF_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`, + `${TOTP_LOGIN_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`, + ] +} + +function clearInvalidBrowserSessionCookies(origin, managementPath = '/_dsh_platform/') { + return [ + `${DSH_SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secureCookie(origin)}`, + `${DSH_CSRF_COOKIE}=; SameSite=Strict; Path=/; Max-Age=0${secureCookie(origin)}`, + `${TOTP_LOGIN_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`, + ...clearManagementCookies(origin, managementPath), + ] +} + +function managementCookies(session, origin, path = '/_dsh_platform/') { + return [ + // Top-level navigation between the isolated DSH and Management origins + // must carry an existing Management session. Keep mutations protected by + // the Strict CSRF cookie below; Lax only permits safe document navigations. + `${MANAGEMENT_SESSION_COOKIE}=${session.token}; HttpOnly; SameSite=Lax; Path=${path}; Max-Age=${MANAGEMENT_SESSION_MAX_AGE_SECONDS}${secureCookie(origin)}`, + `${MANAGEMENT_CSRF_COOKIE}=${session.csrfToken}; SameSite=Strict; Path=${path}; Max-Age=${MANAGEMENT_SESSION_MAX_AGE_SECONDS}${secureCookie(origin)}`, + `${MANAGEMENT_PENDING_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`, + ] +} + +function clearManagementCookies(origin, path = '/_dsh_platform/') { + return [ + `${MANAGEMENT_SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=${path}; Max-Age=0${secureCookie(origin)}`, + `${MANAGEMENT_CSRF_COOKIE}=; SameSite=Strict; Path=${path}; Max-Age=0${secureCookie(origin)}`, + `${MANAGEMENT_PENDING_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`, + ] +} + +function pendingCookie(pending, origin, authPrefix = AUTH_PREFIX) { + return `${MANAGEMENT_PENDING_COOKIE}=${pending.token}; HttpOnly; SameSite=Strict; Path=${authPrefix}${secureCookie(origin)}` +} + +function totpLoginCookie(challenge, origin, authPrefix = AUTH_PREFIX) { + return `${TOTP_LOGIN_COOKIE}=${challenge.token}; HttpOnly; SameSite=Strict; Path=${authPrefix}; Max-Age=300${secureCookie(origin)}` +} + +function clearTotpLoginCookie(origin, authPrefix = AUTH_PREFIX) { + return `${TOTP_LOGIN_COOKIE}=; HttpOnly; SameSite=Strict; Path=${authPrefix}; Max-Age=0${secureCookie(origin)}` +} + +function authenticationContext(request, origin, authPrefix = AUTH_PREFIX) { + const csrfToken = cookieValue(request.headers.cookie, AUTH_CSRF_COOKIE) ?? token('dsha') + const retrySource = cookieValue(request.headers.cookie, AUTH_RETRY_COOKIE) ?? token('dshr') + return { + csrfToken, + cookies: [ + `${AUTH_CSRF_COOKIE}=${csrfToken}; HttpOnly; SameSite=Strict; Path=${authPrefix}${secureCookie(origin)}`, + `${AUTH_RETRY_COOKIE}=${retrySource}; HttpOnly; SameSite=Strict; Path=${authPrefix}${secureCookie(origin)}`, + ], + } +} + +function managementAuthenticationContext(request, origin, managementPath = '/_dsh_platform/') { + const csrfToken = cookieValue(request.headers.cookie, MANAGEMENT_CSRF_COOKIE) ?? token('dshm') + return { + csrfToken, + cookies: [`${MANAGEMENT_CSRF_COOKIE}=${csrfToken}; SameSite=Strict; Path=${managementPath}${secureCookie(origin)}`], + } +} + +function sendJson(response, status, value, headers = {}) { + const bytes = Buffer.from(`${JSON.stringify(value)}\n`) + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-length': String(bytes.byteLength), + 'content-type': 'application/json; charset=utf-8', + ...headers, + }) + response.end(bytes) +} + +function sendSameOriginNavigation(response, target, cookies) { + const scriptTarget = JSON.stringify(target) + .replaceAll('<', '\\u003c') + .replaceAll('\u2028', '\\u2028') + .replaceAll('\u2029', '\\u2029') + const body = Buffer.from(``) + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': String(body.byteLength), + 'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", + 'content-type': 'text/html; charset=utf-8', + 'referrer-policy': 'no-referrer', + ...(cookies === undefined ? {} : { 'set-cookie': cookies }), + }) + response.end(body) +} + +async function jsonBody(request) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.byteLength + if (size > 64 * 1024) throw Object.assign(new Error('authentication request is too large'), { statusCode: 413 }) + chunks.push(chunk) + } + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } + catch { throw Object.assign(new Error('authentication request is invalid'), { statusCode: 400 }) } +} + +function validBrowserMutationShape(request) { + const origin = requestOrigin(request, { requireHeader: true }) + const site = request.headers['sec-fetch-site'] + const mode = request.headers['sec-fetch-mode'] + return origin !== undefined + && (site === undefined || site === 'same-origin') + && (mode === undefined || mode === 'cors' || mode === 'same-origin') + && typeof request.headers['content-type'] === 'string' + && request.headers['content-type'].toLowerCase().startsWith('application/json') +} + +function validBrowserMutation(request, csrf) { + return validBrowserMutationShape(request) && sameSecret(request.headers['x-dsh-csrf'], csrf) +} + +function validAuthenticationContextRequest(request) { + const site = request.headers['sec-fetch-site'] + const mode = request.headers['sec-fetch-mode'] + return requestOrigin(request) !== undefined + && (site === undefined || site === 'same-origin') + && (mode === undefined || mode === 'cors' || mode === 'same-origin') +} + +function validSessionMutation(request, cookieName) { + const csrf = cookieValue(request.headers.cookie, cookieName) + const origin = requestOrigin(request, { requireHeader: true }) + const site = request.headers['sec-fetch-site'] + const mode = request.headers['sec-fetch-mode'] + return origin !== undefined + && (site === undefined || site === 'same-origin') + && (mode === undefined || mode === 'cors' || mode === 'same-origin') + && sameSecret(request.headers['x-dsh-csrf'], csrf) +} + +function authenticationCountdownScript(submitLabel) { + return `let retryUntil=0,retryTimer;function showRetryCountdown(template,seconds){retryUntil=Date.now()+seconds*1000;clearTimeout(retryTimer);button.disabled=true;const tick=()=>{const remaining=Math.max(0,Math.ceil((retryUntil-Date.now())/1000));if(remaining>0){error.textContent=template.replace('{seconds}',String(remaining));error.hidden=false;retryTimer=setTimeout(tick,Math.min(1000,Math.max(1,retryUntil-Date.now())));return}retryUntil=0;error.textContent='';error.hidden=true;button.disabled=false;button.textContent=${JSON.stringify(submitLabel)}};tick()}function showRetryFailure(payload){const template=payload.code==='AUTHENTICATION_RETRY_REQUIRED'&&Number.isInteger(payload.retryAfterSeconds)?retryMessage:payload.code==='AUTHENTICATION_RATE_LIMITED'&&Number.isInteger(payload.retryAfterSeconds)?rateLimitMessage:null;if(template===null)return false;showRetryCountdown(template,payload.retryAfterSeconds);return true}` +} + +function htmlEscape(value) { + return String(value).replace(/[&<>"']/g, character => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[character]) +} + +const USERNAME_INPUT_PATTERN = String.raw`[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{1,64}` +const PASSWORD_INPUT_PATTERN = String.raw`[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{8,1024}` + +function authenticationPage(request, state, csrf, authenticationContextId, returnPath, { resetForm = false } = {}) { + const zh = language(request) === 'zh' + const copy = zh ? { + brand: 'HARNESS', username: '用户名', password: '密码', confirmPassword: '确认密码', passwordMismatch: '两次输入的新密码不一致。', totp: '动态验证码', register: '注册', login: '登录', + initializing: '正在创建管理员账户…', signingIn: '正在登录…', resetting: '正在重置管理员认证…', + failed: '用户名或密码错误', registrationFailed: '无法创建管理员账户,请检查填写内容后重试。', + concurrent: '管理员账户已创建,请使用现有账户登录。', + invalidUsername: '用户名支持 1 至 64 个字符,可使用中文、字母、数字、空格和常用符号;不能包含控制字符或双向控制字符。', + invalidPassword: '密码支持 8 至 1024 个字符,可使用中文、字母、数字、空格和符号;不能包含控制字符或双向控制字符。', + retryRequired: '当前浏览器已连续多次输入错误,请在 {seconds} 秒后重试。', + rateLimited: '管理员登录尝试过多,请在 {seconds} 秒后重试。', + totpRequired: '请输入身份验证器中的 6 位动态验证码。', totpFailed: '动态验证码不正确,请重试。', + loginUpdated: '登录状态已更新,请重新输入主密码。', + totpRetryRequired: '动态验证码连续多次错误,请在 {seconds} 秒后重试。', + totpRateLimited: '动态验证码尝试次数过多,请在 {seconds} 秒后重试。', + serviceUnavailable: '认证服务暂不可用,请稍后重试。', + invalidResetKey: '认证重置密钥无效、已过期或已使用,请重新生成。', + migrationTitle: '需要迁移管理员认证', migrationDetail: '请在容器 Root 终端运行 dsh-platform access generate-key,然后使用一次性密钥创建新账户。', + resetTitle: '重置管理员认证', resetDetail: '请先在容器 Root 终端运行 dsh-platform access generate-key。重置会替换损坏或缺失的管理员账户。', setupKey: '认证重置密钥', + recoveryTitle: '需要恢复管理员认证', recoveryDetail: '认证状态损坏或缺失。请使用 Root 终端生成认证重置密钥,然后重新创建管理员账户。', recoveryAction: '重置管理员认证', + waitingTitle: '正在准备认证服务', waitingDetail: '平台正在确认本地管理员状态,请稍候。', + } : { + brand: 'HARNESS', username: 'Username', password: 'Password', confirmPassword: 'Confirm password', passwordMismatch: 'The new password confirmation does not match.', totp: 'Authentication code', register: 'Register', login: 'Sign in', + initializing: 'Creating the administrator account…', signingIn: 'Signing in…', resetting: 'Resetting administrator authentication…', + failed: 'The username or password is incorrect.', registrationFailed: 'The administrator account could not be created. Check the entered values and try again.', + concurrent: 'The administrator account already exists. Sign in with it.', + invalidUsername: 'Use 1 to 64 characters. Unicode letters, numbers, spaces, and common symbols are supported; control and bidirectional-control characters are not.', + invalidPassword: 'Use 8 to 1024 characters. Unicode letters, numbers, spaces, and symbols are supported; control and bidirectional-control characters are not.', + retryRequired: 'This browser has made several consecutive failed attempts. Try again in {seconds} seconds.', + rateLimited: 'Too many administrator sign-in attempts. Try again in {seconds} seconds.', + totpRequired: 'Enter the 6-digit code from your authenticator app.', totpFailed: 'The authentication code is incorrect. Try again.', + loginUpdated: 'The sign-in state has changed. Enter the main password again.', + totpRetryRequired: 'Too many incorrect codes. Try again in {seconds} seconds.', + totpRateLimited: 'Too many authentication-code attempts. Try again in {seconds} seconds.', + serviceUnavailable: 'The authentication service is temporarily unavailable. Try again shortly.', + invalidResetKey: 'The authentication reset key is invalid, expired, or already used. Generate a new key.', + migrationTitle: 'Administrator migration required', migrationDetail: 'Run dsh-platform access generate-key from a root container terminal, then create a new account with the one-time key.', + resetTitle: 'Reset administrator authentication', resetDetail: 'First run dsh-platform access generate-key from a root container terminal. Resetting replaces the missing or damaged administrator account.', setupKey: 'Authentication reset key', + recoveryTitle: 'Administrator recovery required', recoveryDetail: 'Authentication state is missing or damaged. Generate an authentication reset key from a root container terminal, then recreate the administrator account.', recoveryAction: 'Reset administrator authentication', + waitingTitle: 'Preparing authentication', waitingDetail: 'The platform is determining local administrator state. Please wait.', + } + let content + if (state === 'never-initialized' || state === 'initialized' || state === 'migration-required' + || (state === 'recovery-required' && resetForm)) { + const initialize = state === 'never-initialized' + const reset = state === 'migration-required' || state === 'recovery-required' + const validateCredentials = initialize || reset + const submit = initialize ? copy.register : reset ? copy.recoveryAction : copy.login + const submitPath = reset + ? `${AUTH_PREFIX}reset` + : `${AUTH_PREFIX}session?return=${encodeURIComponent(returnPath)}` + const registrationErrors = { + ALREADY_INITIALIZED: copy.concurrent, + USERNAME_INVALID: copy.invalidUsername, + PASSWORD_POLICY_VIOLATION: copy.invalidPassword, + AUTHENTICATION_RESET_KEY_INVALID: copy.invalidResetKey, + ACCESS_MANAGER_UNAVAILABLE: copy.serviceUnavailable, + } + const fallbackError = initialize || reset ? copy.registrationFailed : copy.failed + const context = reset + ? `

${state === 'migration-required' ? copy.migrationTitle : copy.resetTitle}

${state === 'migration-required' ? copy.migrationDetail : copy.resetDetail}

` + : '' + const usernameValidation = validateCredentials + ? ` required pattern="${htmlEscape(USERNAME_INPUT_PATTERN)}"` : '' + const passwordValidation = validateCredentials + ? ` required minlength="8" pattern="${htmlEscape(PASSWORD_INPUT_PATTERN)}"` : '' + const usernameError = validateCredentials + ? `` : '' + const passwordError = validateCredentials + ? `` : '' + const passwordConfirmation = validateCredentials + ? `` : '' + const validationScript = validateCredentials + ? `function validateField(name){const input=form.elements[name],message=form.querySelector('[data-field-error="'+name+'"]'),valid=input.validity.valid;message.hidden=valid;return valid}function validatePasswordConfirmation(show=false){const input=form.elements.confirmPassword,message=form.querySelector('[data-field-error="confirmPassword"]'),matches=input.value===form.elements.password.value;message.hidden=matches||(!show&&input.value.length===0);return matches}form.elements.password.addEventListener('input',()=>{validateField('password');validatePasswordConfirmation()});form.elements.confirmPassword.addEventListener('input',()=>validatePasswordConfirmation());` + : '' + // Field hints are presentation-only. The Access Manager remains the sole + // authority for registration, recovery, and credential validation. + const validationGate = validateCredentials + ? `validateField('username');validateField('password');if(!validatePasswordConfirmation(true))return;` : '' + const totpField = state === 'initialized' + ? `` : '' + content = `${context}
${reset ? `` : ''}${passwordConfirmation}${totpField}
` + if (state === 'initialized') { + content += `` + } + } else if (state === 'recovery-required') { + const resetPath = `${AUTH_PREFIX}reset?return=${encodeURIComponent(returnPath)}` + content = `

${copy.recoveryTitle}

${copy.recoveryDetail}

${copy.recoveryAction}` + } else content = `

${copy.waitingTitle}

${copy.waitingDetail}

` + return `${htmlEscape(copy.brand)}
${htmlEscape(copy.brand)}
${content}
` +} + +function managementTabSuffix(searchParams) { + return searchParams?.get('tab') === 'auth-settings' ? '?tab=auth-settings' : '' +} + +function managementConsoleDestination(searchParams, consolePath) { + return searchParams?.get('tab') === 'auth-settings' ? `${consolePath}#auth-settings` : consolePath +} + +function configuredManagementConsoleHref(current) { + const access = current.account?.managementAccess + if (access?.mode !== 'isolated') return '/_dsh_platform/console/' + const entry = access.isolatedEntry + const origin = entry?.kind === 'public' ? entry.managementPublicOrigin : entry?.managementLocalOrigin + return typeof origin === 'string' ? `${origin}/` : '/_dsh_platform/console/' +} + +function managementLoginPage(request, csrf, authenticationContextId, mainCredentialVersion, { + authPrefix = AUTH_PREFIX, + consolePath = '/_dsh_platform/console/', + searchParams = new URLSearchParams(), +} = {}) { + const zh = language(request) === 'zh' + const copy = zh ? { + title: 'DSH 管理中心', detail: '请输入管理中心密码。', + password: '管理中心密码', submit: '登录', failed: '验证失败,请重试。', + retryRequired: '当前浏览器已连续多次输入错误,请在 {seconds} 秒后重试。', + rateLimited: '管理员登录尝试过多,请在 {seconds} 秒后重试。', + serviceUnavailable: '认证服务暂不可用,请稍后重试。', + } : { + title: 'DSH Management Console', detail: 'Enter the Management console password.', + password: 'Management console password', submit: 'Sign in', failed: 'Authentication failed. Try again.', + retryRequired: 'This browser has made several consecutive failed attempts. Try again in {seconds} seconds.', + rateLimited: 'Too many administrator sign-in attempts. Try again in {seconds} seconds.', + serviceUnavailable: 'The authentication service is temporarily unavailable. Try again shortly.', + } + const submitPath = `${authPrefix}management/pending${managementTabSuffix(searchParams)}` + const destination = managementConsoleDestination(searchParams, consolePath) + return `${copy.title}

${copy.title}

${copy.detail}

` +} + +export function createBrowserAuthentication({ + access, + safeReturnPath, + report = async () => {}, + wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), + paths: { + authPrefix = AUTH_PREFIX, + accessPrefix = ACCESS_PREFIX, + transitionPrefix = TRANSITION_PREFIX, + consolePath = '/_dsh_platform/console/', + } = {}, +}) { + const managementCookiePath = consolePath === '/' ? '/' : '/_dsh_platform/' + async function accessRequest(method, path, body) { + try { return await access.request(method, path, body) } + catch (error) { + if (error !== null && typeof error === 'object' + && (!Number.isInteger(error.statusCode) || error.statusCode >= 500)) { + error.browserAuthenticationBackend = true + } + throw error + } + } + + const accessFailureStatus = error => Number.isInteger(error?.statusCode) ? error.statusCode : 503 + const accessFailureCode = (error, fallback) => Number.isInteger(error?.statusCode) + ? error.code ?? fallback + : 'ACCESS_MANAGER_UNAVAILABLE' + const accessFailureBody = (error, fallback) => ({ + error: error.message, + code: accessFailureCode(error, fallback), + ...(Number.isInteger(error?.retryAfterSeconds) + ? { retryAfterSeconds: error.retryAfterSeconds } : {}), + }) + const accessFailureHeaders = error => Number.isInteger(error?.retryAfterSeconds) + ? { 'retry-after': String(error.retryAfterSeconds) } : {} + const isAuthenticationDenial = error => [401, 403].includes(error?.statusCode) + + async function status() { return accessRequest('GET', '/v1/status') } + + async function authenticationStatus() { + for (let attempt = 0; ; attempt++) { + let current + try { current = await status() } + catch (error) { + if (error?.browserAuthenticationBackend !== true || attempt >= 20) throw error + await wait(250) + continue + } + if (current.state !== 'classification-pending' || attempt >= 20) return current + await wait(250) + } + } + + async function validateDsh(request, { requireCsrf = false } = {}) { + const origin = requestOrigin(request) + const value = cookieValue(request.headers.cookie, DSH_SESSION_COOKIE) + if (origin === undefined || value === undefined) return { authenticated: false } + if (requireCsrf && !validSessionMutation(request, DSH_CSRF_COOKIE)) return { authenticated: false } + return accessRequest('POST', '/v1/sessions/validate', { + kind: 'dsh', token: value, origin, + requireCsrf, + csrfToken: requireCsrf ? request.headers['x-dsh-csrf'] : undefined, + }).catch(error => { + if (isAuthenticationDenial(error)) return { authenticated: false } + throw error + }) + } + + async function validateManagement(request, { requireCsrf = false } = {}) { + const origin = requestOrigin(request) + const value = cookieValue(request.headers.cookie, MANAGEMENT_SESSION_COOKIE) + if (origin === undefined || value === undefined) return { authenticated: false } + if (requireCsrf && !validSessionMutation(request, MANAGEMENT_CSRF_COOKIE)) return { authenticated: false } + return accessRequest('POST', '/v1/sessions/validate', { + kind: 'management', token: value, origin, + requireCsrf, + csrfToken: requireCsrf ? request.headers['x-dsh-csrf'] : undefined, + }).catch(error => { + if (isAuthenticationDenial(error)) return { authenticated: false } + throw error + }) + } + + async function authorizeManagement(request, { audience, method, target, requireCsrf = false } = {}) { + const origin = requestOrigin(request) + const managementToken = cookieValue(request.headers.cookie, MANAGEMENT_SESSION_COOKIE) + if (origin === undefined || managementToken === undefined) return { authorized: false } + if (requireCsrf && !validSessionMutation(request, MANAGEMENT_CSRF_COOKIE)) return { authorized: false } + try { + const result = await accessRequest('POST', '/v1/capabilities', { + managementToken, + origin, + csrfToken: requireCsrf ? request.headers['x-dsh-csrf'] : undefined, + requireCsrf, + audience, + method, + target, + }) + return { + authorized: true, + capability: result.capability, + headers: cookieValue(request.headers.cookie, MANAGEMENT_CSRF_COOKIE) === undefined + ? {} + : { + // Refresh legacy Strict cookies while the browser is still on the + // Management origin so the next cross-origin root navigation keeps + // the existing session instead of starting a handoff. + 'set-cookie': managementCookies({ + token: managementToken, + csrfToken: cookieValue(request.headers.cookie, MANAGEMENT_CSRF_COOKIE), + }, origin, managementCookiePath), + }, + } + } catch (error) { + if (isAuthenticationDenial(error)) return { authorized: false } + throw error + } + } + + async function authorizePlugin(request, { method, target, requireCsrf = false } = {}) { + const origin = requestOrigin(request) + const dshToken = cookieValue(request.headers.cookie, DSH_SESSION_COOKIE) + if (origin === undefined || dshToken === undefined) return { authorized: false } + if (requireCsrf && !validSessionMutation(request, DSH_CSRF_COOKIE)) return { authorized: false } + try { + const result = await accessRequest('POST', '/v1/dsh/capabilities', { + dshToken, + origin, + csrfToken: requireCsrf ? request.headers['x-dsh-csrf'] : undefined, + requireCsrf, + method, + target, + }) + return { authorized: true, capability: result.capability } + } catch (error) { + if (isAuthenticationDenial(error)) return { authorized: false } + throw error + } + } + + async function sendManagementLogin(request, response, origin, searchParams) { + const authContext = managementAuthenticationContext(request, origin, managementCookiePath) + const current = await authenticationStatus() + const bytes = Buffer.from(managementLoginPage( + request, authContext.csrfToken, current.authenticationContext, + current.account?.mainCredentialVersion, { authPrefix, consolePath, searchParams }, + )) + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': String(bytes.byteLength), + 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + 'content-type': 'text/html; charset=utf-8', + 'cross-origin-opener-policy': 'same-origin', + 'referrer-policy': 'no-referrer', + 'set-cookie': authContext.cookies, + 'x-content-type-options': 'nosniff', + }) + response.end(bytes) + } + + async function enterManagement(request, response, searchParams) { + const origin = requestOrigin(request) + if (origin === undefined) { sendJson(response, 400, { error: 'request origin is invalid' }); return } + if ((await validateManagement(request)).authenticated) { + sendSameOriginNavigation(response, managementConsoleDestination(searchParams, consolePath)) + return + } + const current = await status() + const valid = await validateDsh(request) + if (!valid.authenticated) { + const dshOrigin = current.account?.managementAccess?.mode === 'isolated' + ? current.account.managementAccess.dshPublicOrigin + : origin + if (typeof dshOrigin !== 'string') { + sendJson(response, 409, { error: 'DSH authentication entry is unavailable' }) + return + } + response.writeHead(303, { + 'cache-control': 'no-store', + location: `${dshOrigin}${AUTH_PREFIX}?return=${encodeURIComponent(`${AUTH_PREFIX}management/start${managementTabSuffix(searchParams)}`)}`, + 'referrer-policy': 'no-referrer', + }) + response.end() + return + } + const entry = current.account?.managementAccess?.isolatedEntry + if (current.account?.managementAccess?.mode === 'isolated' && entry?.kind === 'local-only') { + const targetOrigin = entry.managementLocalOrigin + if (typeof targetOrigin !== 'string') { sendJson(response, 409, { error: 'local Management entry is unavailable' }); return } + const created = await accessRequest('POST', '/v1/management/handoffs', { + dshToken: cookieValue(request.headers.cookie, DSH_SESSION_COOKIE), + dshOrigin: origin, + targetOrigin, + }) + response.writeHead(303, { + 'cache-control': 'no-store', + location: `${targetOrigin}/auth/management/handoff?token=${encodeURIComponent(created.handoff.token)}${managementTabSuffix(searchParams).replace('?', '&')}`, + 'referrer-policy': 'no-referrer', + }) + response.end() + return + } + const targetOrigin = current.account?.managementAccess?.mode === 'isolated' + ? entry?.managementPublicOrigin + : origin + if (typeof targetOrigin !== 'string') { sendJson(response, 409, { error: 'Management entry is unavailable' }); return } + const created = await accessRequest('POST', '/v1/management/handoffs', { + dshToken: cookieValue(request.headers.cookie, DSH_SESSION_COOKIE), + dshOrigin: origin, + targetOrigin, + }) + response.writeHead(303, { + 'cache-control': 'no-store', + location: current.account?.managementAccess?.mode === 'isolated' + ? `${targetOrigin}/auth/management/handoff?token=${encodeURIComponent(created.handoff.token)}${managementTabSuffix(searchParams).replace('?', '&')}` + : `${AUTH_PREFIX}management/handoff?token=${encodeURIComponent(created.handoff.token)}${managementTabSuffix(searchParams).replace('?', '&')}`, + 'referrer-policy': 'no-referrer', + }) + response.end() + } + + async function returnToDsh(request, response) { + const topLevel = request.headers['sec-fetch-mode'] === undefined + || (request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document') + const origin = requestTargetOrigin(request) + if (!topLevel || origin === undefined) { + sendJson(response, 403, { error: 'DSH return navigation is forbidden', code: 'REQUEST_FORBIDDEN' }) + return + } + const valid = await validateManagement(request) + if (!valid.authenticated) { + sendJson(response, 401, { error: 'authentication required', code: 'AUTHENTICATION_REQUIRED' }) + return + } + const current = await authenticationStatus() + const dshOrigin = current.account?.managementAccess?.mode === 'isolated' + ? current.account.managementAccess.dshPublicOrigin + : origin + if (typeof dshOrigin !== 'string' || canonicalOrigin(dshOrigin) === undefined) { + sendJson(response, 409, { error: 'DSH return address is unavailable', code: 'DSH_RETURN_UNAVAILABLE' }) + return + } + response.writeHead(303, { + 'cache-control': 'no-store', + location: `${dshOrigin}/`, + 'referrer-policy': 'no-referrer', + }) + response.end() + } + + async function managementHandoffLocation(current, { dshToken, dshOrigin }) { + const entry = current.account?.managementAccess?.isolatedEntry + const isolated = current.account?.managementAccess?.mode === 'isolated' + const targetOrigin = isolated + ? entry?.kind === 'local-only' ? entry.managementLocalOrigin : entry?.managementPublicOrigin + : dshOrigin + if (typeof targetOrigin !== 'string') throw Object.assign(new Error('Management entry is unavailable'), { + code: 'MANAGEMENT_ENTRY_UNAVAILABLE', statusCode: 409, + }) + const created = await accessRequest('POST', '/v1/management/handoffs', { dshToken, dshOrigin, targetOrigin }) + return isolated + ? `${targetOrigin}/auth/management/handoff?token=${encodeURIComponent(created.handoff.token)}` + : `${authPrefix}management/handoff?token=${encodeURIComponent(created.handoff.token)}` + } + + async function renewManagementLogin(request, origin, value) { + const current = await status() + const dshToken = cookieValue(request.headers.cookie, DSH_SESSION_COOKIE) + const dshOrigin = current.account?.managementAccess?.mode === 'isolated' + ? current.account.managementAccess.dshPublicOrigin + : origin + if (dshToken === undefined || typeof dshOrigin !== 'string') { + throw Object.assign(new Error('authentication context is stale'), { + code: 'AUTHENTICATION_CONTEXT_STALE', statusCode: 409, + }) + } + try { + const created = await accessRequest('POST', '/v1/management/handoffs', { + dshToken, dshOrigin, targetOrigin: origin, + }) + const exchanged = await accessRequest('POST', '/v1/management/handoffs/consume', { + token: created.handoff.token, origin, + }) + if (exchanged.session !== undefined) return exchanged + if (exchanged.pending?.token === undefined) throw new Error('renewed Management login is invalid') + return await accessRequest('POST', '/v1/management/pending/complete', { + ...value, + authenticationContext: (await authenticationStatus()).authenticationContext, + origin, + pendingToken: exchanged.pending.token, + }) + } catch (error) { + if (['SESSION_INVALID', 'HANDOFF_INVALID', 'PENDING_LOGIN_INVALID'].includes(error?.code)) { + throw Object.assign(new Error('authentication context is stale'), { + code: 'AUTHENTICATION_CONTEXT_STALE', statusCode: 409, + }) + } + throw error + } + } + + async function handle(request, response, pathname, searchParams) { + if (pathname === transitionPrefix + 'probe' && request.method === 'GET') { + // Same-origin GET fetches may omit Origin. The target authority is a + // valid source only for that same-origin case; cross-origin probes still + // require the browser-supplied Origin header. + const candidateOrigin = requestTargetOrigin(request) + const requestOrigin = canonicalOrigin(request.headers.origin) + const sourceOrigin = requestOrigin ?? candidateOrigin + if (sourceOrigin === undefined || candidateOrigin === undefined) { + sendJson(response, 400, { error: 'transition probe origin is invalid', code: 'TRANSITION_PROBE_INVALID' }) + return true + } + try { + const result = await accessRequest('POST', '/v1/management/transitions/probe', { + transitionId: searchParams.get('transitionId'), + nonce: searchParams.get('nonce'), + sourceOrigin, + candidateOrigin, + }) + sendJson(response, 200, result, { + 'access-control-allow-origin': sourceOrigin, + 'access-control-expose-headers': 'content-type', + vary: 'Origin', + }) + } catch (error) { + sendJson(response, accessFailureStatus(error), { error: error.message, code: accessFailureCode(error, 'TRANSITION_PROBE_INVALID') }, { + 'access-control-allow-origin': sourceOrigin, + vary: 'Origin', + }) + } + return true + } + if (pathname === transitionPrefix + 'continue' && request.method === 'GET') { + const origin = requestTargetOrigin(request) + const topLevel = request.headers['sec-fetch-mode'] === undefined + || (request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document') + if (!topLevel || origin === undefined) { + sendJson(response, 403, { error: 'Management continuation is forbidden', code: 'REQUEST_FORBIDDEN' }) + return true + } + try { + const result = await accessRequest('POST', '/v1/management/continuations/consume', { + token: searchParams.get('token'), origin, + }) + sendSameOriginNavigation(response, consolePath, managementCookies(result.session, origin, managementCookiePath)) + } catch (error) { + sendJson(response, accessFailureStatus(error), { error: error.message, code: accessFailureCode(error, 'CONTINUATION_INVALID') }) + } + return true + } + if (pathname === accessPrefix + 'status' && request.method === 'GET') { + const current = await status() + sendJson(response, 200, { state: current.state }) + return true + } + if (pathname === authPrefix + 'context' && request.method === 'GET') { + if (!validAuthenticationContextRequest(request)) { + sendJson(response, 403, { error: 'authentication context request rejected', code: 'REQUEST_FORBIDDEN' }) + return true + } + const origin = requestOrigin(request) + const authContext = authenticationContext(request, origin, authPrefix) + const current = await authenticationStatus() + const totpRetry = current.state === 'initialized' && current.account?.totp?.enabled === true + ? await accessRequest('POST', '/v1/authentication-retry/status', { + credential: 'totp', source: authenticationSource(request), + }) + : { kind: null, retryAfterSeconds: 0 } + sendJson(response, 200, { + csrfToken: authContext.csrfToken, + authenticationContext: current.authenticationContext, + managementAdditionalPasswordEnabled: + current.account?.managementAdditionalCredential?.enabled === true, + mainCredentialVersion: current.account?.mainCredentialVersion ?? null, + totpRetry, + }, { 'set-cookie': authContext.cookies }) + return true + } + const authenticationEntry = pathname === authPrefix.slice(0, -1) || pathname === authPrefix + const authenticationReset = pathname === authPrefix + 'reset' + if (authenticationEntry || (authenticationReset && ['GET', 'HEAD'].includes(request.method ?? 'GET'))) { + if (!['GET', 'HEAD'].includes(request.method ?? 'GET')) return false + const current = await status() + const valid = await validateDsh(request) + const returnPath = safeReturnPath(searchParams.get('return')) + if (current.state === 'initialized' && valid.authenticated) { + sendSameOriginNavigation(response, returnPath) + return true + } + const origin = requestOrigin(request) ?? 'http://invalid.local' + const authContext = authenticationContext(request, origin, authPrefix) + const resetForm = authenticationReset && current.state === 'recovery-required' + if (authenticationReset && !resetForm) { + response.writeHead(303, { 'cache-control': 'no-store', location: `${authPrefix}?return=${encodeURIComponent(returnPath)}` }) + response.end() + return true + } + const bytes = Buffer.from(authenticationPage( + request, current.state, authContext.csrfToken, current.authenticationContext, returnPath, { resetForm }, + )) + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': String(bytes.byteLength), + 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + 'content-type': 'text/html; charset=utf-8', + 'referrer-policy': 'no-referrer', + 'set-cookie': [ + ...authContext.cookies, + ...clearInvalidBrowserSessionCookies(origin, managementCookiePath), + ], + 'x-dsh-csrf': authContext.csrfToken, + 'x-content-type-options': 'nosniff', + }) + response.end(request.method === 'HEAD' ? undefined : bytes) + return true + } + if (pathname === authPrefix + 'session' && request.method === 'POST') { + const csrf = cookieValue(request.headers.cookie, AUTH_CSRF_COOKIE) + if (!validBrowserMutationShape(request)) { + sendJson(response, 403, { error: 'authentication request rejected', code: 'REQUEST_FORBIDDEN' }) + return true + } + const origin = requestOrigin(request, { requireHeader: true }) + const value = await jsonBody(request) + const current = await authenticationStatus() + const completingTotp = current.state === 'initialized' && typeof value.totpCode === 'string' + if (completingTotp && (await validateDsh(request)).authenticated) { + sendJson(response, 200, { + authenticated: true, + next: safeReturnPath(searchParams.get('return')), + }, { 'set-cookie': clearTotpLoginCookie(origin, authPrefix) }) + return true + } + if (!validBrowserMutation(request, csrf)) { + sendJson(response, 409, { error: 'authentication context is stale', code: 'AUTHENTICATION_CONTEXT_STALE' }) + return true + } + const route = current.state === 'never-initialized' + ? '/v1/dsh/initialize' : completingTotp ? '/v1/dsh/totp/complete' : '/v1/dsh/login' + if (!['never-initialized', 'initialized'].includes(current.state)) { + sendJson(response, 409, { error: 'administrator access is unavailable', code: 'ACCESS_UNAVAILABLE' }) + return true + } + try { + const result = await accessRequest('POST', route, { + ...value, + origin, + client: loginClient(request), + authenticationSource: authenticationSource(request), + ...(completingTotp + ? { challengeToken: cookieValue(request.headers.cookie, TOTP_LOGIN_COOKIE), code: value.totpCode } + : {}), + }) + if (result.totpRequired === true && result.challenge !== undefined) { + sendJson(response, 202, { authenticated: false, totpRequired: true }, { + 'set-cookie': totpLoginCookie(result.challenge, origin, authPrefix), + }) + return true + } + const returnPath = safeReturnPath(searchParams.get('return')) + const next = current.state === 'initialized' && returnPath === `${authPrefix}management/start` + ? await managementHandoffLocation(current, { dshToken: result.session.token, dshOrigin: origin }) + : returnPath + await report(current.state === 'never-initialized' ? 'gateway.access.initialized' : 'gateway.access.logged-in') + sendJson(response, current.state === 'never-initialized' ? 201 : 200, { authenticated: true, next }, { + 'set-cookie': sessionCookies(result.session, origin), + }) + } catch (error) { + await report('gateway.access.login-failed', { code: error.code ?? null, level: 'warning' }) + sendJson(response, accessFailureStatus(error), accessFailureBody(error, 'AUTHENTICATION_FAILED'), accessFailureHeaders(error)) + } + return true + } + if (pathname === authPrefix + 'reset' && request.method === 'POST') { + const csrf = cookieValue(request.headers.cookie, AUTH_CSRF_COOKIE) + if (!validBrowserMutationShape(request)) { + sendJson(response, 403, { error: 'authentication reset request rejected', code: 'REQUEST_FORBIDDEN' }) + return true + } + if (!validBrowserMutation(request, csrf)) { + sendJson(response, 409, { error: 'authentication context is stale', code: 'AUTHENTICATION_CONTEXT_STALE' }) + return true + } + const origin = requestOrigin(request, { requireHeader: true }) + const current = await authenticationStatus() + if (!['migration-required', 'recovery-required'].includes(current.state)) { + sendJson(response, 409, { error: 'administrator authentication reset is unavailable', code: 'AUTHENTICATION_RESET_UNAVAILABLE' }) + return true + } + try { + const result = await accessRequest('POST', '/v1/dsh/reset-authentication', { + ...await jsonBody(request), + origin, + client: loginClient(request), + authenticationSource: authenticationSource(request), + }) + await report('gateway.access.authentication-reset', { previousState: current.state }) + sendJson(response, 201, { authenticated: true }, { 'set-cookie': sessionCookies(result.session, origin) }) + } catch (error) { + await report('gateway.access.authentication-reset-failed', { code: error.code ?? null, level: 'warning' }) + sendJson(response, accessFailureStatus(error), { error: error.message, code: accessFailureCode(error, 'AUTHENTICATION_RESET_FAILED') }) + } + return true + } + if (pathname === authPrefix + 'session-context' && request.method === 'GET') { + const valid = await validateDsh(request) + if (!valid.authenticated) { + sendJson(response, 401, { error: 'authentication required', code: 'AUTHENTICATION_REQUIRED' }) + return true + } + const current = await status() + sendJson(response, 200, { + managementAdditionalPasswordEnabled: + current.account?.managementAdditionalCredential?.enabled === true, + managementConsoleHref: configuredManagementConsoleHref(current), + }) + return true + } + if (pathname === authPrefix + 'management/session-context' && request.method === 'GET') { + const valid = await validateManagement(request) + if (!valid.authenticated) { + sendJson(response, 401, { error: 'authentication required', code: 'AUTHENTICATION_REQUIRED' }) + return true + } + const origin = requestOrigin(request) + const managementToken = cookieValue(request.headers.cookie, MANAGEMENT_SESSION_COOKIE) + const csrfToken = cookieValue(request.headers.cookie, MANAGEMENT_CSRF_COOKIE) + const headers = origin !== undefined && managementToken !== undefined && csrfToken !== undefined + ? { 'set-cookie': managementCookies({ token: managementToken, csrfToken }, origin, managementCookiePath) } + : {} + sendJson(response, 200, { authenticated: true }, headers) + return true + } + if (pathname === authPrefix + 'browser-logout' && request.method === 'POST') { + const valid = await validateDsh(request, { requireCsrf: true }) + const origin = requestOrigin(request, { requireHeader: true }) + if (!valid.authenticated || origin === undefined) { + sendJson(response, 403, { error: 'logout request rejected', code: 'REQUEST_FORBIDDEN' }) + return true + } + const value = await jsonBody(request) + if (!['management', 'all'].includes(value.scope)) { + sendJson(response, 400, { error: 'logout scope is invalid', code: 'REQUEST_INVALID' }) + return true + } + const result = await accessRequest('POST', '/v1/dsh/browser-logout', { + scope: value.scope, + dshToken: cookieValue(request.headers.cookie, DSH_SESSION_COOKIE), + dshOrigin: origin, + managementToken: cookieValue(request.headers.cookie, MANAGEMENT_SESSION_COOKIE), + }) + const cookies = value.scope === 'all' + ? [...clearSessionCookies(origin), ...clearManagementCookies(origin, managementCookiePath)] + : clearManagementCookies(origin, managementCookiePath) + await report('gateway.access.browser-sessions-logged-out', { scope: value.scope }) + sendJson(response, 200, result, { 'set-cookie': cookies }) + return true + } + if (pathname === authPrefix + 'logout' && request.method === 'POST') { + const valid = await validateDsh(request, { requireCsrf: true }) + const origin = requestOrigin(request, { requireHeader: true }) + if (!validBrowserMutation(request, cookieValue(request.headers.cookie, DSH_CSRF_COOKIE), DSH_CSRF_COOKIE) + || !valid.authenticated) { + sendJson(response, 403, { error: 'logout request rejected', code: 'REQUEST_FORBIDDEN' }) + return true + } + await accessRequest('POST', '/v1/sessions/logout', { + kind: 'dsh', token: cookieValue(request.headers.cookie, DSH_SESSION_COOKIE), + }) + sendJson(response, 200, { authenticated: false }, { + 'set-cookie': [...clearSessionCookies(origin), ...clearManagementCookies(origin, managementCookiePath)], + }) + return true + } + if (pathname === authPrefix + 'management' && ['GET', 'HEAD'].includes(request.method ?? 'GET')) { + await enterManagement(request, response, searchParams) + return true + } + if (pathname === authPrefix + 'management/start' && ['GET', 'HEAD'].includes(request.method ?? 'GET')) { + await enterManagement(request, response, searchParams) + return true + } + if (pathname === authPrefix + 'return-dsh' && ['GET', 'HEAD'].includes(request.method ?? 'GET')) { + await returnToDsh(request, response) + return true + } + if (pathname === authPrefix + 'management/handoff' && request.method === 'GET') { + const topLevel = request.headers['sec-fetch-mode'] === undefined + || (request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document') + const origin = requestOrigin(request) + if (!topLevel || origin === undefined) { + sendJson(response, 403, { error: 'handoff request rejected', code: 'REQUEST_FORBIDDEN' }) + return true + } + try { + const result = await accessRequest('POST', '/v1/management/handoffs/consume', { + token: searchParams.get('token'), origin, + }) + if (result.pending !== undefined) { + sendSameOriginNavigation(response, `${authPrefix}management/pending${managementTabSuffix(searchParams)}`, pendingCookie(result.pending, origin, authPrefix)) + } else { + sendSameOriginNavigation(response, managementConsoleDestination(searchParams, consolePath), managementCookies(result.session, origin, managementCookiePath)) + } + } catch (error) { + sendJson(response, accessFailureStatus(error), { error: error.message, code: accessFailureCode(error, 'HANDOFF_INVALID') }) + } + return true + } + if (pathname === authPrefix + 'management/pending' && ['GET', 'HEAD'].includes(request.method ?? 'GET')) { + const origin = requestOrigin(request) + if (origin === undefined || cookieValue(request.headers.cookie, MANAGEMENT_PENDING_COOKIE) === undefined) { + response.writeHead(303, { 'cache-control': 'no-store', location: `${authPrefix}management${managementTabSuffix(searchParams)}` }) + response.end() + } else await sendManagementLogin(request, response, origin, searchParams) + return true + } + if (pathname === authPrefix + 'management/pending' && request.method === 'POST') { + const csrf = cookieValue(request.headers.cookie, MANAGEMENT_CSRF_COOKIE) + if (!validBrowserMutationShape(request)) { + sendJson(response, 403, { error: 'authentication request rejected', code: 'REQUEST_FORBIDDEN' }) + return true + } + const origin = requestOrigin(request, { requireHeader: true }) + try { + const value = await jsonBody(request) + let result + if (validBrowserMutation(request, csrf)) { + try { + result = await accessRequest('POST', '/v1/management/pending/complete', { + ...value, origin, + pendingToken: cookieValue(request.headers.cookie, MANAGEMENT_PENDING_COOKIE), + }) + } catch (error) { + if (!['AUTHENTICATION_CONTEXT_STALE', 'PENDING_LOGIN_INVALID'].includes(error?.code)) throw error + } + } + result ??= await renewManagementLogin(request, origin, value) + sendJson(response, 200, { authenticated: true }, { 'set-cookie': managementCookies(result.session, origin, managementCookiePath) }) + } catch (error) { + sendJson(response, accessFailureStatus(error), accessFailureBody(error, 'AUTHENTICATION_FAILED'), accessFailureHeaders(error)) + } + return true + } + if (pathname === authPrefix + 'management/logout' && request.method === 'POST') { + const valid = await validateManagement(request, { requireCsrf: true }) + const origin = requestOrigin(request, { requireHeader: true }) + if (!valid.authenticated || origin === undefined) { + sendJson(response, 403, { error: 'logout request rejected', code: 'REQUEST_FORBIDDEN' }) + return true + } + await accessRequest('POST', '/v1/sessions/logout', { + kind: 'management', token: cookieValue(request.headers.cookie, MANAGEMENT_SESSION_COOKIE), + }) + sendJson(response, 200, { authenticated: false }, { 'set-cookie': clearManagementCookies(origin, managementCookiePath) }) + return true + } + return false + } + + return Object.freeze({ status, validateDsh, validateManagement, authorizeManagement, authorizePlugin, enterManagement, handle }) +} + +export function rejectDshAuthentication(request, response, safeReturnPath) { + const url = new URL(request.url ?? '/', 'http://gateway.internal') + const navigation = request.method === 'GET' + && (request.headers['sec-fetch-mode'] === 'navigate' || request.headers.accept?.includes('text/html')) + if (navigation) { + const returnPath = safeReturnPath(`${url.pathname}${url.search}${url.hash}`) + response.writeHead(303, { + 'cache-control': 'no-store', + location: `${AUTH_PREFIX}?return=${encodeURIComponent(returnPath)}`, + }) + response.end() + } else sendJson(response, 401, { error: 'authentication required', code: 'AUTHENTICATION_REQUIRED' }) +} diff --git a/upstream/container/control-plane/services/gateway/lib/config.mjs b/upstream/container/control-plane/services/gateway/lib/config.mjs new file mode 100644 index 0000000..d81e133 --- /dev/null +++ b/upstream/container/control-plane/services/gateway/lib/config.mjs @@ -0,0 +1,75 @@ +import { UsageError } from './errors.mjs' + +export function parseBoolean(name, value, fallback) { + const resolved = value ?? String(fallback) + if (resolved === 'true') return true + if (resolved === 'false') return false + throw new UsageError(`${name} must be true or false`) +} + +function parseUrlAuthority(value) { + try { + return new URL(`http://${value}`) + } catch { + return undefined + } +} + +function canonicalAuthority(value, parsed) { + const port = parsed.port !== '' ? parsed.port : new URL(`https://${value}`).port + return port === '' ? parsed.hostname : `${parsed.hostname}:${port}` +} + +export function parseTrustedAuthority(value) { + const parsed = parseUrlAuthority(value) + if ( + parsed === undefined + || parsed.hostname.includes('*') + || canonicalAuthority(value, parsed) !== value.toLowerCase() + ) { + throw new UsageError( + `DSH_TRUSTED_HOSTS entry ${JSON.stringify(value)} must be a canonical host or host:port authority`, + ) + } + const authority = canonicalAuthority(value, parsed) + return Object.freeze({ + hostname: parsed.hostname, + authority, + anyPort: authority === parsed.hostname, + matchAuthority: parsed.host, + }) +} + +export function parseTrustedHosts(environment = process.env) { + const raw = environment.DSH_TRUSTED_HOSTS?.trim() ?? '' + if (raw === '') return Object.freeze({ wildcard: false, authorities: Object.freeze([]) }) + + const entries = raw.split(',').map(entry => entry.trim()) + if (entries.some(entry => entry === '')) { + throw new UsageError('DSH_TRUSTED_HOSTS cannot contain an empty entry') + } + if (entries.includes('*')) { + if (entries.length !== 1) throw new UsageError('DSH_TRUSTED_HOSTS wildcard must be used alone') + return Object.freeze({ wildcard: true, authorities: Object.freeze([]) }) + } + + const unique = new Map() + for (const entry of entries) { + const parsed = parseTrustedAuthority(entry) + unique.set(`${parsed.authority}|${String(parsed.anyPort)}`, parsed) + } + return Object.freeze({ wildcard: false, authorities: Object.freeze([...unique.values()]) }) +} + +export async function loadConfig(environment = process.env) { + const managementPort = environment.DSH_MANAGEMENT_PORT === undefined || environment.DSH_MANAGEMENT_PORT.trim() === '' + ? 3081 : Number(environment.DSH_MANAGEMENT_PORT) + if (managementPort !== null && (!Number.isInteger(managementPort) || managementPort < 1 || managementPort > 65535)) { + throw new UsageError('DSH_MANAGEMENT_PORT must be an integer between 1 and 65535') + } + return Object.freeze({ + trustedHosts: parseTrustedHosts(environment), + polyfill: parseBoolean('DSH_PROXY_POLYFILL', environment.DSH_PROXY_POLYFILL, true), + managementPort, + }) +} diff --git a/upstream/container/control-plane/services/gateway/lib/dsh-upstream-auth.mjs b/upstream/container/control-plane/services/gateway/lib/dsh-upstream-auth.mjs new file mode 100644 index 0000000..eee40e2 --- /dev/null +++ b/upstream/container/control-plane/services/gateway/lib/dsh-upstream-auth.mjs @@ -0,0 +1,119 @@ +import { request } from 'node:http' + +function lifecycleReadiness(socketPath) { + const body = Buffer.from('{}') + return new Promise((resolve, reject) => { + const outgoing = request({ + socketPath, + path: '/v1/runtime/readiness', + method: 'POST', + headers: { + 'content-length': body.byteLength, + 'content-type': 'application/json', + }, + }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.once('end', () => { + if (response.statusCode !== 200) { + reject(new Error(`DSH lifecycle readiness returned HTTP ${String(response.statusCode)}`)) + return + } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) + } catch (error) { + reject(error) + } + }) + }) + outgoing.setTimeout(750, () => outgoing.destroy(new Error('DSH lifecycle readiness timed out'))) + outgoing.once('error', reject) + outgoing.end(body) + }) +} + +function exchange(host, port, readyUrl) { + const url = new URL(readyUrl) + if (url.protocol !== 'http:' || url.hostname !== host || Number(url.port) !== port + || url.pathname !== '/' || url.username !== '' || url.password !== '' || url.hash !== '' + || url.searchParams.getAll('token').length !== 1) { + throw new Error('DSH Web readiness URL is invalid') + } + return new Promise((resolve, reject) => { + const outgoing = request({ + hostname: host, + port, + path: `${url.pathname}${url.search}`, + method: 'GET', + headers: { 'cache-control': 'no-store' }, + }, response => { + response.resume() + response.once('end', () => { + const cookie = response.headers['set-cookie']?.[0]?.split(';', 1)[0] + if (response.statusCode !== 303 || response.headers.location !== '/' + || typeof cookie !== 'string' || cookie === '') { + reject(new Error('DSH Web authentication exchange failed')) + } else resolve(cookie) + }) + }) + outgoing.setTimeout(750, () => outgoing.destroy(new Error('DSH Web authentication exchange timed out'))) + outgoing.once('error', reject) + outgoing.end() + }) +} + +export class DshUpstreamAuthentication { + constructor({ + socketPath = '/run/dsh-platform/dsh-lifecycle.sock', + host = '127.0.0.1', + port = 3079, + now = () => Date.now(), + legacyProbeIntervalMs = 250, + } = {}) { + this.socketPath = socketPath + this.host = host + this.port = port + this.now = now + this.legacyProbeIntervalMs = legacyProbeIntervalMs + this.cachedCookie = undefined + this.cachedGeneration = null + this.nextProbeAt = 0 + this.pending = null + } + + async publicReady() { + const readiness = await lifecycleReadiness(this.socketPath) + const ready = readiness?.publicReady === true + || (readiness?.publicReady === undefined && readiness?.ready === true) + if (ready) this.nextProbeAt = 0 + return ready + } + + async cookie() { + if (this.pending !== null) return this.pending + if (this.now() < this.nextProbeAt) return null + this.pending = this.refresh().finally(() => { this.pending = null }) + return this.pending + } + + async refresh() { + const readiness = await lifecycleReadiness(this.socketPath) + const publicReady = readiness?.publicReady === true + || (readiness?.publicReady === undefined && readiness?.ready === true) + if (!publicReady || readiness?.ready !== true || typeof readiness.readyUrl !== 'string') { + if (!publicReady) { + this.cachedCookie = undefined + this.cachedGeneration = null + } + this.nextProbeAt = this.now() + this.legacyProbeIntervalMs + return null + } + if (this.cachedCookie !== undefined && this.cachedGeneration === (readiness.generation ?? null)) { + return this.cachedCookie + } + const cookie = await exchange(this.host, this.port, readiness.readyUrl) + this.cachedCookie = cookie + this.cachedGeneration = readiness.generation ?? null + return cookie + } +} diff --git a/upstream/container/control-plane/services/gateway/lib/errors.mjs b/upstream/container/control-plane/services/gateway/lib/errors.mjs new file mode 100644 index 0000000..750253a --- /dev/null +++ b/upstream/container/control-plane/services/gateway/lib/errors.mjs @@ -0,0 +1,7 @@ +export class UsageError extends Error { + constructor(message) { + super(message) + this.name = 'UsageError' + this.exitCode = 64 + } +} diff --git a/upstream/container/control-plane/services/gateway/lib/lifecycle.mjs b/upstream/container/control-plane/services/gateway/lib/lifecycle.mjs new file mode 100644 index 0000000..1ad938f --- /dev/null +++ b/upstream/container/control-plane/services/gateway/lib/lifecycle.mjs @@ -0,0 +1,157 @@ +import { createGatewayServer, closeGatewayServer } from './proxy.mjs' +import { createBrowserAuthentication } from './browser-auth.mjs' +import { LocalApiClient } from '../../../modules/updater/lib/client.mjs' +import { safeReturnPath } from './proxy.mjs' +import { DshUpstreamAuthentication } from './dsh-upstream-auth.mjs' + +export const EXTERNAL_HOST = '0.0.0.0' +export const EXTERNAL_PORT = 3080 + +function listen(server, port, host) { + return new Promise((resolve, reject) => { + const onError = (error) => { + server.off('listening', onListening) + reject(error) + } + const onListening = () => { + server.off('error', onError) + resolve() + } + server.once('error', onError) + server.once('listening', onListening) + server.listen(port, host) + }) +} + +export async function runGateway(config, { + signalSource = process, + gatewayFactory = createGatewayServer, + externalHost = EXTERNAL_HOST, + externalPort = EXTERNAL_PORT, + managementPort = null, + managementHost = externalHost, + managementSocketPath = process.env.DSH_PLATFORM_MANAGEMENT_SOCKET ?? '/run/dsh-platform/management.sock', + managementCliSocketPath = process.env.DSH_PLATFORM_MANAGEMENT_CLI_SOCKET ?? '/run/dsh-platform/management-cli.sock', + maintenanceSocketPath = process.env.DSH_PLATFORM_MAINTENANCE_SOCKET ?? '/run/dsh-platform/maintenance.sock', + lifecycleSocketPath = process.env.DSH_PLATFORM_LIFECYCLE_SOCKET ?? '/run/dsh-platform/dsh-lifecycle.sock', + accessSocketPath = process.env.DSH_PLATFORM_ACCESS_SOCKET ?? '/run/dsh-platform/access/access.sock', + accessClient, + managementClient, + platformStatusPollMs = 250, + platformStatusCacheMs = 10_000, + now = () => Date.now(), + report = async () => {}, +} = {}) { + const record = (message, fields = {}) => Promise.resolve().then(() => report(message, fields)).catch(() => {}) + const management = managementClient ?? new LocalApiClient(managementCliSocketPath) + const access = accessClient ?? new LocalApiClient(accessSocketPath) + const accessRequest = access.request.bind(access) + const resilientAccess = accessClient === undefined + ? { request: async (method, path, body, headers) => { + for (let attempt = 0; ; attempt++) { + try { + return await accessRequest(method, path, body, headers) + } catch (error) { + const retryable = ['ENOENT', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE'].includes(error?.code) + if (!retryable || attempt >= 20) throw error + await new Promise(resolve => setTimeout(resolve, Math.min(250, 25 * (attempt + 1)))) + } + } + } } : access + const browserAuthentication = createBrowserAuthentication({ access: resilientAccess, safeReturnPath, report: record }) + const dshUpstreamAuthentication = new DshUpstreamAuthentication({ socketPath: lifecycleSocketPath }) + const isolatedBrowserAuthentication = createBrowserAuthentication({ + access: resilientAccess, + safeReturnPath, + report: record, + paths: { authPrefix: '/auth/', accessPrefix: '/access/', transitionPrefix: '/transition/', consolePath: '/' }, + }) + let lastPlatformStatus + let lastPlatformStatusAt = 0 + const refreshPlatformStatus = async () => { + const status = await management.request('GET', '/_dsh_platform/api/v1/status') + lastPlatformStatus = status + lastPlatformStatusAt = now() + return status + } + const platformStatus = async () => { + try { + return await refreshPlatformStatus() + } catch (error) { + if (lastPlatformStatus !== undefined && now() - lastPlatformStatusAt <= platformStatusCacheMs) { + return lastPlatformStatus + } + throw error + } + } + const server = gatewayFactory({ + polyfill: config.polyfill, + trustedHosts: config.trustedHosts, + managementSocketPath, + maintenanceSocketPath, + browserAuthentication, + dshUpstreamAuthentication, + platformStatus, + report: record, + surface: 'compat', + }) + const managementServer = managementPort === null || managementPort === undefined + ? null + : gatewayFactory({ + polyfill: false, + trustedHosts: config.trustedHosts, + managementSocketPath, + maintenanceSocketPath, + browserAuthentication: isolatedBrowserAuthentication, + dshUpstreamAuthentication, + platformStatus, + report: record, + surface: 'management', + }) + const platformStatusTimer = setInterval(() => { + void refreshPlatformStatus().catch(() => {}) + }, platformStatusPollMs) + platformStatusTimer.unref?.() + void refreshPlatformStatus().catch(() => {}) + let resolveSignal + const receivedSignal = new Promise(resolve => { resolveSignal = resolve }) + const onSignal = signal => resolveSignal(signal) + const onSigint = () => onSignal('SIGINT') + const onSigterm = () => onSignal('SIGTERM') + signalSource.once('SIGINT', onSigint) + signalSource.once('SIGTERM', onSigterm) + try { + await record('gateway.starting', { + externalHost, externalPort, + ...(managementServer === null ? {} : { managementHost, managementPort }), + }) + await listen(server, externalPort, externalHost) + if (managementServer !== null) await listen(managementServer, managementPort, managementHost) + await record('gateway.ready', { + externalHost, externalPort, + ...(managementServer === null ? {} : { managementHost, managementPort }), + }) + const serverFailed = new Promise((_, reject) => server.once('error', reject)) + const signal = await Promise.race([receivedSignal, serverFailed]) + await record('gateway.stopping', { signal }) + await closeGatewayServer(server) + if (managementServer !== null) await closeGatewayServer(managementServer) + await record('gateway.stopped') + return 0 + } catch (error) { + await record('gateway.fatal', { error }) + if (server.listening || managementServer?.listening) { + try { + if (server.listening) await closeGatewayServer(server) + if (managementServer?.listening) await closeGatewayServer(managementServer) + } catch (closeError) { + await record('gateway.stop.failed', { error: closeError, cause: error instanceof Error ? error.message : String(error) }) + } + } + throw error + } finally { + clearInterval(platformStatusTimer) + signalSource.off('SIGINT', onSigint) + signalSource.off('SIGTERM', onSigterm) + } +} diff --git a/upstream/container/control-plane/services/gateway/lib/polyfill.mjs b/upstream/container/control-plane/services/gateway/lib/polyfill.mjs new file mode 100644 index 0000000..71d9381 --- /dev/null +++ b/upstream/container/control-plane/services/gateway/lib/polyfill.mjs @@ -0,0 +1,60 @@ +export const RANDOM_UUID_POLYFILL = '' + +export const LIFECYCLE_TRANSITION_GUARD = `` + +export const SESSION_VALIDITY_GUARD = `` + +export const PLUGIN_RECOVERY_GUARD = `` + +export function injectRandomUuidPolyfill(html) { + const match = /]*)?>/i.exec(html) + const injected = `${RANDOM_UUID_POLYFILL}${LIFECYCLE_TRANSITION_GUARD}${SESSION_VALIDITY_GUARD}${PLUGIN_RECOVERY_GUARD}` + if (match === null) return `${injected}${html}` + const end = match.index + match[0].length + return `${html.slice(0, end)}${injected}${html.slice(end)}` +} diff --git a/upstream/container/control-plane/services/gateway/lib/proxy.mjs b/upstream/container/control-plane/services/gateway/lib/proxy.mjs new file mode 100644 index 0000000..ca848c5 --- /dev/null +++ b/upstream/container/control-plane/services/gateway/lib/proxy.mjs @@ -0,0 +1,1310 @@ +import { createServer, request as httpRequest } from 'node:http' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { connect as netConnect } from 'node:net' +import { join, resolve } from 'node:path' +import { inspectExternalRequest } from './trust.mjs' +import { injectRandomUuidPolyfill } from './polyfill.mjs' +import { isReservedCookieName, rejectDshAuthentication, stripReservedCookies } from './browser-auth.mjs' +import { availabilityPage, DshAvailability, probeDsh, stateMessage } from './availability.mjs' + +export const INTERNAL_HOST = '127.0.0.1' +export const INTERNAL_PORT = 3079 +export const INTERNAL_AUTHORITY = `${INTERNAL_HOST}:${String(INTERNAL_PORT)}` +export const HEALTH_PATH = '/_dsh_gateway/health' +export const READINESS_PATH = '/_dsh_gateway/readiness' +export const WAIT_PATH = '/_dsh_gateway/wait' +export const PLUGIN_FAILURE_PATH = '/_dsh_gateway/plugin-failure' +export const CLIENT_EVENT_PATH = '/_dsh_gateway/client-event' +export const MANAGEMENT_PREFIX = '/_dsh_platform/api/v1/' +export const MANAGEMENT_PLUGIN_PREFIX = '/_dsh_platform/plugin-api/v1/' +export const MANAGEMENT_UI_PREFIX = '/_dsh_platform/console/' +const EXTERNAL_MANAGEMENT_ROUTES = new Map([ + ['GET', new Set(['status', 'events', 'logs', 'logs/stream', 'rollback-plan', 'auth-settings', 'bundled-plugins', 'system-skills', 'user-skills', 'settings-document', 'user-plugins', 'proxy', 'proxy/provider-inventory', 'files/config', 'files/list', 'files/stat', 'files/content', 'files/download', 'files/tasks'])], + ['POST', new Set(['check', 'update', 'holds/retry', 'rollback', 'return-stable', 'start-dsh', 'stop-dsh', 'restart-dsh', 'runtime/reset', 'auth-sessions/revoke', 'auth-totp/enrollments', 'auth-totp/enrollments/confirm', 'auth-totp/enrollments/cancel', 'auth-totp/disable-confirmations', 'auth-totp/disable-confirmations/confirm', 'auth-totp/disable-confirmations/cancel', 'management-origin/transitions', 'management-origin/transitions/commit', 'management-origin/transitions/failure', 'bundled-plugins/action', 'bundled-plugins/toggle', 'bundled-plugins/recovery-action', 'bundled-plugins/discard', 'system-skills/action', 'user-skills/action', 'user-plugins/apply', 'proxy/test', 'terminal/sessions', 'files/upload', 'files/tasks'])], + ['PUT', new Set(['channel', 'automatic-check', 'auth-settings', 'proxy', 'settings-document', 'files/content'])], +]) +const TERMINAL_SESSION_ROUTE = /^terminal\/sessions\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const TERMINAL_STREAM_ROUTE = /^\/_dsh_platform\/api\/v1\/terminal\/sessions\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\/stream$/ +const FILE_TASK_ROUTE = /^files\/tasks\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const PROXY_TEST_TASK_ROUTE = /^proxy\/test\/tasks\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const PLUGIN_MANAGEMENT_ROUTES = new Map([ + ['GET', new Set(['status', 'events', 'logs', 'logs/stream', 'rollback-plan', 'bundled-plugins', 'system-skills', 'settings-document', 'proxy', 'proxy/provider-inventory'])], + ['POST', new Set(['check', 'update', 'holds/retry', 'rollback', 'return-stable', 'restart-dsh', 'bundled-plugins/action', 'bundled-plugins/toggle', 'bundled-plugins/recovery-action', 'bundled-plugins/discard', 'system-skills/action', 'proxy/test'])], + ['PUT', new Set(['channel', 'automatic-check', 'proxy', 'settings-document'])], +]) + +function isMaintenanceRoute(pathname) { + if (!pathname.startsWith(MANAGEMENT_PREFIX)) return false + const route = pathname.slice(MANAGEMENT_PREFIX.length) + return route.startsWith('files/') || route === 'terminal/sessions' || TERMINAL_SESSION_ROUTE.test(route) +} + +function managementTrustedHosts(access) { + const entry = access.account?.managementAccess?.isolatedEntry + if (access.account?.managementAccess?.mode !== 'isolated' || entry?.kind !== 'public') { + return Object.freeze({ wildcard: false, authorities: Object.freeze([]) }) + } + const parsed = new URL(entry.managementPublicOrigin) + return Object.freeze({ + wildcard: false, + authorities: Object.freeze([Object.freeze({ + anyPort: false, + hostname: parsed.hostname, + matchAuthority: parsed.host, + })]), + }) +} + +const MAX_HTML_BYTES = 5 * 1024 * 1024 +const DEFAULT_PLUGIN_BUNDLE_MAX_BYTES = 16 * 1024 * 1024 +const DEFAULT_PLUGIN_BUNDLE_CACHE_MAX_BYTES = 64 * 1024 * 1024 +const SYSTEM_PLUGIN_BUNDLE = /^\/plugins\/@dsh-docker\/([a-z0-9][a-z0-9._-]{0,127})\/client\.js$/ +const DSH_PLUGIN_BUNDLE = /^\/plugins\/(?:@[^/]+\/)?[^/]+\/client\.js$/ +const MODULE_HOLD_STATES = new Set(['starting', 'restarting', 'switching', 'runtime-recovering', 'recovering']) +const MODULE_FAILED_STATES = new Set(['stopping', 'stopped', 'failed']) +const REGISTERED_OPERATIONS = new Set(['restarting', 'switching', 'recovering', 'restart-failed']) +const REGISTERED_UPDATE_STATES = new Set(['snapshotting-data', 'switching', 'probation', 'restoring-data']) +const REGISTERED_LIFECYCLE_STATES = new Set(['starting', 'stopping', 'stopped', 'restarting', 'recovering', 'failed']) +const CLIENT_EVENTS = new Set([ + 'browser.plugin-load.failed', + 'browser.plugin-load.recovery.started', + 'browser.plugin-load.recovery.completed', + 'browser.plugin-load.recovery.failed', +]) +const upgradedSocketsByServer = new WeakMap() +const probeTimersByServer = new WeakMap() +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'proxy-connection', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]) +const INTERNAL_CAPABILITY_HEADER = 'x-dsh-internal-capability' +const INTERNAL_CAPABILITY_AUDIENCE_HEADER = 'x-dsh-internal-capability-audience' + +function excludedHeaderNames(headers) { + const excluded = new Set(HOP_BY_HOP_HEADERS) + if (typeof headers.connection === 'string') { + for (const name of headers.connection.split(',')) excluded.add(name.trim().toLowerCase()) + } + return excluded +} + +function copyEndToEndHeaders(headers) { + const excluded = excludedHeaderNames(headers) + const copied = {} + for (const [name, value] of Object.entries(headers)) { + if (!excluded.has(name.toLowerCase()) && value !== undefined) copied[name] = value + } + return copied +} + +async function clientEvent(request) { + const chunks = [] + let bytes = 0 + for await (const chunk of request) { + bytes += chunk.byteLength + if (bytes > 8_192) throw new Error('client event exceeds size limit') + chunks.push(chunk) + } + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) + if (value === null || typeof value !== 'object' || Array.isArray(value) || !CLIENT_EVENTS.has(value.event)) { + throw new Error('client event is invalid') + } + const text = (name, maximum, pattern) => { + const field = value[name] + if (field === null || field === undefined) return null + if (typeof field !== 'string' || field.length > maximum || !pattern.test(field)) throw new Error(`client event ${name} is invalid`) + return field + } + const attempt = Number(value.recoveryAttempt) + if (!Number.isSafeInteger(attempt) || attempt < 0 || attempt > 3) throw new Error('client event recoveryAttempt is invalid') + return { + event: value.event, + level: ['info', 'warning', 'error'].includes(value.level) ? value.level : 'warning', + pluginId: text('pluginId', 256, /^[^\u0000-\u001f\u007f]+$/u), + revision: text('revision', 128, /^[A-Za-z0-9._-]+$/), + pathname: text('pathname', 1_024, /^\/[^\u0000-\u001f\u007f]*$/u), + lifecycleState: text('lifecycleState', 64, /^[a-z-]+$/), + lifecycleTaskId: text('lifecycleTaskId', 128, /^[A-Za-z0-9._:-]+$/), + recoveryAttempt: attempt, + reason: text('reason', 128, /^[^\u0000-\u001f\u007f]+$/u), + } +} + +export function upstreamRequestHeaders(headers, { dsh = true } = {}) { + const excluded = excludedHeaderNames(headers) + const rewritten = copyEndToEndHeaders(headers) + if (dsh) { + for (const name of Object.keys(rewritten)) { + const normalized = name.toLowerCase() + if (normalized === 'forwarded' || normalized === 'x-real-ip' || normalized.startsWith('x-forwarded-')) { + delete rewritten[name] + } + } + } + rewritten.host = INTERNAL_AUTHORITY + rewritten['accept-encoding'] = 'identity' + if (!excluded.has('origin') && typeof headers.origin === 'string') { + rewritten.origin = `http://${INTERNAL_AUTHORITY}` + } + delete rewritten.authorization + delete rewritten[INTERNAL_CAPABILITY_HEADER] + delete rewritten[INTERNAL_CAPABILITY_AUDIENCE_HEADER] + const cookies = stripReservedCookies(rewritten.cookie) + if (cookies === undefined) delete rewritten.cookie + else rewritten.cookie = cookies + return rewritten +} + +export function applyDshUpstreamCookie(headers, cookie) { + if (typeof cookie !== 'string' || cookie === '') return headers + const current = typeof headers.cookie === 'string' + ? headers.cookie.split(';').map(value => value.trim()).filter(value => { + const separator = value.indexOf('=') + return separator > 0 && !value.slice(0, separator).startsWith('dsh-auth-') + }) + : [] + headers.cookie = [...current, cookie].join('; ') + return headers +} + +function rejectHttp(response, status, message) { + if (response.headersSent) { + response.destroy() + return + } + response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' }) + response.end(`${message}\n`) +} + +function sendJson(response, status, value) { + const body = Buffer.from(`${JSON.stringify(value)}\n`) + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-length': String(body.byteLength), + 'content-type': 'application/json; charset=utf-8', + }) + response.end(body) +} + +function isPageNavigation(request) { + const pathname = new URL(request.url ?? '/', 'http://gateway.internal').pathname + return request.method === 'GET' + && !pathname.startsWith('/api/') + && typeof request.headers.accept === 'string' + && request.headers.accept.toLowerCase().includes('text/html') +} + +function sendAvailabilityPage(request, response, state, options = {}) { + const body = Buffer.from(availabilityPage(state, request.headers, options)) + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': String(body.byteLength), + 'content-security-policy': "default-src 'none'; connect-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'", + 'content-type': 'text/html; charset=utf-8', + 'referrer-policy': 'no-referrer', + }) + response.end(body) +} + +function managementAvailabilityHref(access) { + const entry = access?.account?.managementAccess?.isolatedEntry + if (access?.account?.managementAccess?.mode !== 'isolated') return '/_dsh_platform/console/' + const origin = entry?.kind === 'public' ? entry.managementPublicOrigin : entry?.managementLocalOrigin + return typeof origin === 'string' ? `${origin}/` : '/_dsh_platform/console/' +} + +async function currentManagementAvailabilityHref(options) { + try { + return managementAvailabilityHref(await options.browserAuthentication.status()) + } catch { + return '/_dsh_platform/console/' + } +} + +export function safeReturnPath(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > 4_096 + || !value.startsWith('/') || value.startsWith('//') || value.includes('\\') + || /[\u0000-\u001f\u007f]/u.test(value)) return '/' + try { + const parsed = new URL(value, 'http://gateway.internal') + return parsed.origin === 'http://gateway.internal' + ? `${parsed.pathname}${parsed.search}${parsed.hash}` + : '/' + } catch { + return '/' + } +} + +async function serveSystemPluginBundle(request, response, root, pathname, searchParams) { + if (!['GET', 'HEAD'].includes(request.method ?? 'GET')) return false + const match = SYSTEM_PLUGIN_BUNDLE.exec(pathname) + if (match === null) return false + const packageRoot = join(resolve(root), match[1]) + try { + const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) + if (metadata.name !== `@dsh-docker/${match[1]}`) return false + const client = metadata.exports?.['./client'] + const relative = typeof client === 'string' ? client : client?.default + if (typeof relative !== 'string' || relative.startsWith('/') || relative.includes('\\') || relative.split('/').includes('..')) return false + const body = await readFile(join(packageRoot, relative)) + const requestedRev = searchParams.get('rev') + const actualRev = createHash('sha1').update(body).digest('hex').slice(0, 12) + if (requestedRev !== null && requestedRev !== actualRev) return false + response.writeHead(200, { + 'cache-control': 'no-cache', + 'content-length': String(body.byteLength), + 'content-type': 'text/javascript; charset=utf-8', + etag: `"${actualRev}"`, + }) + response.end(request.method === 'HEAD' ? undefined : body) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +function rejectUpgrade(socket, status, reason, headers = {}) { + if (!socket.destroyed) { + const lines = [`HTTP/1.1 ${String(status)} ${reason}`, 'Connection: close', 'Content-Length: 0'] + for (const [name, value] of Object.entries(headers)) lines.push(`${name}: ${value}`) + socket.end(`${lines.join('\r\n')}\r\n\r\n`) + } +} + +function proxyResponseHeaders(headers) { + const copied = copyEndToEndHeaders(headers) + const cookies = copied['set-cookie'] + if (cookies !== undefined) { + const values = Array.isArray(cookies) ? cookies : [cookies] + const kept = values.filter(value => { + const separator = value.indexOf('=') + return separator > 0 && !isReservedCookieName(value.slice(0, separator).trim()) + }) + if (kept.length === 0) delete copied['set-cookie'] + else copied['set-cookie'] = kept + } + return copied +} + +function createPluginBundleCache(maxBytes) { + const entries = new Map() + let bytes = 0 + return { + get(key) { + const value = entries.get(key) + if (value === undefined) return undefined + entries.delete(key) + entries.set(key, value) + return value + }, + set(key, value) { + if (value.body.byteLength > maxBytes) return + const previous = entries.get(key) + if (previous !== undefined) bytes -= previous.body.byteLength + entries.delete(key) + entries.set(key, value) + bytes += value.body.byteLength + while (bytes > maxBytes) { + const oldest = entries.entries().next().value + if (oldest === undefined) break + entries.delete(oldest[0]) + bytes -= oldest[1].body.byteLength + } + }, + } +} + +function deploymentRecordId(platform) { + return typeof platform.current?.recordId === 'string' && platform.current.recordId.length > 0 + ? platform.current.recordId + : null +} + +function recentLifecycleTransition(platform, now, windowMs) { + const lifecycle = platform.dshLifecycle ?? {} + const updatedAt = Date.parse(lifecycle.updatedAt ?? '') + return typeof lifecycle.taskId === 'string' && lifecycle.taskId.length > 0 + && Number.isFinite(updatedAt) && now() - updatedAt >= 0 && now() - updatedAt < windowMs +} + +function bufferedPluginHeaders(headers, body) { + const result = proxyResponseHeaders(headers) + result['content-length'] = String(body.byteLength) + delete result['transfer-encoding'] + return result +} + +function sendBufferedPluginBundle(request, response, value) { + response.writeHead(value.statusCode, value.statusMessage, bufferedPluginHeaders(value.headers, value.body)) + response.end(request.method === 'HEAD' ? undefined : value.body) +} + +function pluginCacheValue(value) { + const headers = {} + for (const name of ['cache-control', 'content-language', 'content-type', 'etag', 'last-modified']) { + if (value.headers[name] !== undefined) headers[name] = value.headers[name] + } + return Object.freeze({ statusCode: 200, statusMessage: value.statusMessage, headers, body: value.body }) +} + +function isJavaScriptBundle(value) { + const contentType = value.headers['content-type'] + return value.statusCode === 200 && typeof contentType === 'string' + && /(?:java|ecma)script/i.test(contentType) +} + +async function fetchPluginBundle(request, options) { + const headers = upstreamRequestHeaders(request.headers) + applyDshUpstreamCookie(headers, await options.dshUpstreamAuthentication.cookie()) + return new Promise((resolvePromise, rejectPromise) => { + const upstream = httpRequest({ + hostname: options.upstreamHost, + port: options.upstreamPort, + method: request.method, + path: request.url, + headers, + }) + upstream.once('response', (upstreamResponse) => { + void (async () => { + const chunks = [] + let bytes = 0 + for await (const chunk of upstreamResponse) { + bytes += chunk.byteLength + if (bytes > options.pluginBundleMaxBytes) { + throw new Error('upstream plugin bundle exceeds gateway limit') + } + chunks.push(chunk) + } + resolvePromise({ + statusCode: upstreamResponse.statusCode ?? 502, + statusMessage: upstreamResponse.statusMessage, + headers: upstreamResponse.headers, + body: Buffer.concat(chunks), + }) + })().catch(rejectPromise) + }) + upstream.once('error', rejectPromise) + upstream.end() + }) +} + +function pluginReturnPath(request) { + if (typeof request.headers.referer !== 'string') return '/' + try { + const value = new URL(request.headers.referer) + return safeReturnPath(`${value.pathname}${value.search}${value.hash}`) + } catch { + return '/' + } +} + +function sendPluginHoldingModule(request, response) { + const returnPath = pluginReturnPath(request) + const waitPath = `${WAIT_PATH}?return=${encodeURIComponent(returnPath)}` + const body = Buffer.from(`globalThis.location.replace(${JSON.stringify(waitPath)});\nawait new Promise(function(){});\n`) + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': String(body.byteLength), + 'content-type': 'text/javascript; charset=utf-8', + }) + response.end(request.method === 'HEAD' ? undefined : body) +} + +function isInjectableHtml(request, response) { + if (request.method === 'HEAD') return false + const contentType = response.headers['content-type'] + const encoding = response.headers['content-encoding'] + return typeof contentType === 'string' + && contentType.toLowerCase().includes('text/html') + && encoding === undefined +} + +async function writeInjectedHtml(upstream, response) { + const chunks = [] + let bytes = 0 + for await (const chunk of upstream) { + bytes += chunk.byteLength + if (bytes > MAX_HTML_BYTES) throw new Error('upstream HTML exceeds gateway injection limit') + chunks.push(chunk) + } + const body = Buffer.from(injectRandomUuidPolyfill(Buffer.concat(chunks).toString('utf8'))) + const headers = proxyResponseHeaders(upstream.headers) + delete headers.etag + delete headers['last-modified'] + headers['cache-control'] = 'no-cache' + headers['content-length'] = String(body.byteLength) + response.writeHead(upstream.statusCode ?? 502, upstream.statusMessage, headers) + response.end(body) +} + +function pipeHttpResponse(upstream, response, onError = () => {}) { + response.writeHead( + upstream.statusCode ?? 502, + upstream.statusMessage, + proxyResponseHeaders(upstream.headers), + ) + upstream.on('error', error => { + if (!response.destroyed) onError(error) + response.destroy() + }) + upstream.pipe(response) +} + +function requestContext(request) { + let pathname = 'invalid-url' + try { pathname = new URL(request.url ?? '/', 'http://gateway.internal').pathname } catch {} + return { method: request.method ?? null, pathname } +} + +async function boundedPlatformStatus(platformStatus, options, timeoutMs = 250) { + let timer + try { + return await Promise.race([ + Promise.resolve().then(() => platformStatus()).then(value => { + options.reportRecovered('platform-status', 'gateway.platform-status.recovered', { upstream: 'management' }) + return value + }, error => { + options.reportFailure('platform-status', 'gateway.platform-status.failed', { + error, level: 'warning', upstream: 'management', + }) + return {} + }), + new Promise(resolve => { + timer = setTimeout(() => { + options.reportFailure('platform-status', 'gateway.platform-status.timed-out', { + timeoutMs, level: 'warning', upstream: 'management', + }) + resolve({}) + }, timeoutMs) + timer.unref() + }), + ]) + } finally { + clearTimeout(timer) + } +} + +async function unavailableState(options) { + const platform = await boundedPlatformStatus(options.platformStatus, options) + return options.availability.classify(platform) +} + +async function publicationReady(options) { + try { + const ready = await options.dshUpstreamAuthentication.publicReady() + if (ready) options.reportRecovered('dsh-publication', 'gateway.dsh-publication.recovered', { upstream: 'bootstrap' }) + return ready + } catch (error) { + options.reportFailure('dsh-publication', 'gateway.dsh-publication.failed', { + error, level: 'warning', upstream: 'bootstrap', + }) + return false + } +} + +function registeredAvailabilityState(platform, availability) { + if (!REGISTERED_OPERATIONS.has(platform.operation) + && !REGISTERED_UPDATE_STATES.has(platform.update?.status) + && !REGISTERED_LIFECYCLE_STATES.has(platform.dshLifecycle?.state) + && (platform.recoveryMode === null || platform.recoveryMode === undefined)) return null + return availability.classify(platform) +} + +async function readiness(options, published = undefined) { + const publicReady = published ?? await publicationReady(options) + const upstreamReady = await options.probe() + options.availability.observe(upstreamReady) + const platform = await boundedPlatformStatus(options.platformStatus, options) + const registeredState = registeredAvailabilityState(platform, options.availability) + return { + ready: publicReady && upstreamReady && registeredState === null, + state: registeredState ?? (publicReady ? options.availability.classify(platform) : 'starting'), + platform, + pluginRecoveryEligible: options.pluginRecoveryAvailable?.(platform) === true, + } +} + +async function holdPluginBundleDuringTransition(request, response, options, pathname) { + if (!['GET', 'HEAD'].includes(request.method ?? 'GET') + || !DSH_PLUGIN_BUNDLE.test(pathname)) return false + let platform = await boundedPlatformStatus(options.platformStatus, options) + let state = registeredAvailabilityState(platform, options.availability) + if (!MODULE_HOLD_STATES.has(state) && !MODULE_FAILED_STATES.has(state)) return false + if (MODULE_FAILED_STATES.has(state)) { + rejectHttp(response, 503, stateMessage(state, request.headers, platform.dshLifecycle)) + return true + } + + const deadline = Date.now() + options.pluginBundleHoldTimeoutMs + while (!request.destroyed && !response.destroyed && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, options.pluginBundlePollIntervalMs)) + const ready = await options.probe() + options.availability.observe(ready) + platform = await boundedPlatformStatus(options.platformStatus, options) + state = registeredAvailabilityState(platform, options.availability) + if (ready && !MODULE_HOLD_STATES.has(state) && !MODULE_FAILED_STATES.has(state)) return false + if (MODULE_FAILED_STATES.has(state)) { + rejectHttp(response, 503, stateMessage(state, request.headers, platform.dshLifecycle)) + return true + } + } + if (!request.destroyed && !response.destroyed) rejectHttp(response, 503, 'DeepSeek Harness restart timed out') + return true +} + +async function proxyPluginBundle(request, response, options, pathname) { + if (!['GET', 'HEAD'].includes(request.method ?? 'GET') || !DSH_PLUGIN_BUNDLE.test(pathname)) return false + const deadline = Date.now() + options.pluginBundleHoldTimeoutMs + let attempts = 0 + let lastResponse + let lastError + let recoveryEligible = false + + while (!request.destroyed && !response.destroyed && Date.now() < deadline) { + attempts += 1 + const before = await boundedPlatformStatus(options.platformStatus, options) + const beforeRecord = deploymentRecordId(before) + const cacheKey = beforeRecord === null ? null : `${beforeRecord}\u0000${request.url}` + const cached = cacheKey === null ? undefined : options.pluginBundleCache.get(cacheKey) + try { + const value = await fetchPluginBundle(request, options) + lastResponse = value + lastError = undefined + const ready = await options.probe() + options.availability.observe(ready) + const after = await boundedPlatformStatus(options.platformStatus, options) + const state = registeredAvailabilityState(after, options.availability) + const sameDeployment = beforeRecord === deploymentRecordId(after) + recoveryEligible = recoveryEligible || MODULE_HOLD_STATES.has(state) + || recentLifecycleTransition(after, options.now, options.pluginBundleRecentWindowMs) + || options.pluginRecoveryAvailable?.(after) === true + + if (ready && sameDeployment && !MODULE_HOLD_STATES.has(state) && !MODULE_FAILED_STATES.has(state)) { + if (isJavaScriptBundle(value)) { + if (cacheKey !== null && request.method === 'GET') options.pluginBundleCache.set(cacheKey, pluginCacheValue(value)) + sendBufferedPluginBundle(request, response, value) + return true + } + if (cached !== undefined && value.statusCode >= 400) { + sendBufferedPluginBundle(request, response, cached) + return true + } + if (!recoveryEligible || (value.statusCode < 500 && value.statusCode !== 404)) { + sendBufferedPluginBundle(request, response, value) + return true + } + } + } catch (error) { + lastError = error + if (cached !== undefined) { + sendBufferedPluginBundle(request, response, cached) + return true + } + const after = await boundedPlatformStatus(options.platformStatus, options) + const state = registeredAvailabilityState(after, options.availability) + recoveryEligible = recoveryEligible || MODULE_HOLD_STATES.has(state) + || recentLifecycleTransition(after, options.now, options.pluginBundleRecentWindowMs) + || options.pluginRecoveryAvailable?.(after) === true + options.reportFailure('dsh-plugin-bundle', 'gateway.plugin-bundle.failed', { + ...requestContext(request), error, level: 'warning', attempt: attempts, + }) + } + + if (!recoveryEligible && attempts >= 2) break + await new Promise(resolvePromise => setTimeout(resolvePromise, options.pluginBundlePollIntervalMs)) + } + + if (request.destroyed || response.destroyed) return true + if (recoveryEligible) { + options.consumePluginRecovery?.() + sendPluginHoldingModule(request, response) + } else if (lastResponse !== undefined) { + sendBufferedPluginBundle(request, response, lastResponse) + } else { + rejectHttp(response, 502, lastError?.message ?? 'bad gateway') + } + return true +} + +async function rejectDshFailure(request, response, options) { + options.availability.observe(false) + const platform = await boundedPlatformStatus(options.platformStatus, options) + const state = options.availability.classify(platform) + if (state === 'unknown') { + rejectHttp(response, 502, 'bad gateway') + } else if (isPageNavigation(request)) { + sendAvailabilityPage(request, response, state, { + lifecycle: platform.dshLifecycle, + managementHref: await currentManagementAvailabilityHref(options), + }) + } else { + rejectHttp(response, 503, stateMessage(state, request.headers)) + } + return state +} + +async function proxyHttp(request, response, options) { + const upstreamType = options.socketPath === undefined ? 'dsh' : 'management' + const context = requestContext(request) + const headers = upstreamRequestHeaders(request.headers, { dsh: upstreamType === 'dsh' }) + if (upstreamType === 'dsh') { + applyDshUpstreamCookie(headers, await options.dshUpstreamAuthentication.cookie()) + } + if (typeof options.internalCapability === 'string') delete headers.cookie + if (options.preserveAuthorization === true && typeof request.headers.authorization === 'string') { + headers.authorization = request.headers.authorization + } + if (typeof options.internalCapability === 'string') headers[INTERNAL_CAPABILITY_HEADER] = options.internalCapability + if (typeof options.internalCapabilityAudience === 'string') { + headers[INTERNAL_CAPABILITY_AUDIENCE_HEADER] = options.internalCapabilityAudience + } + const upstream = httpRequest({ + ...(options.socketPath === undefined + ? { hostname: options.upstreamHost, port: options.upstreamPort } + : { socketPath: options.socketPath }), + method: request.method, + path: options.upstreamPath ?? request.url, + headers, + }) + upstream.on('response', (upstreamResponse) => { + options.reportRecovered(`${upstreamType}-http`, 'gateway.upstream.recovered', { upstream: upstreamType }) + if (options.trackDsh === true) options.availability.observe(true) + if (options.polyfill && isInjectableHtml(request, upstreamResponse)) { + void writeInjectedHtml(upstreamResponse, response).catch(error => { + options.reportFailure('dsh-injection', 'gateway.response-injection.failed', { + ...context, + error, + upstream: 'dsh', + }) + upstreamResponse.destroy() + rejectHttp(response, 502, 'bad gateway') + }) + return + } + pipeHttpResponse(upstreamResponse, response, error => options.reportFailure(`${upstreamType}-response`, 'gateway.upstream-response.failed', { + ...context, + error, + level: 'warning', + upstream: upstreamType, + })) + }) + upstream.on('error', error => { + if (request.aborted || response.destroyed) return + const reportUpstreamFailure = () => options.reportFailure(`${upstreamType}-http`, 'gateway.upstream.failed', { + ...context, error, upstream: upstreamType, + }) + if (options.trackDsh === true) { + void rejectDshFailure(request, response, options).then(state => { + if (state === 'unknown') reportUpstreamFailure() + }) + } else { + reportUpstreamFailure() + rejectHttp(response, 502, 'bad gateway') + } + }) + request.on('aborted', () => upstream.destroy()) + response.on('close', () => upstream.destroy()) + request.pipe(upstream) +} + +function isExternalManagementRoute(method, pathname) { + if (!pathname.startsWith(MANAGEMENT_PREFIX)) return false + const route = pathname.slice(MANAGEMENT_PREFIX.length) + if (method === 'GET' && /^bundled-plugins\/task\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(route)) return true + if (method === 'GET' && /^user-plugins\/task\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(route)) return true + if (['GET', 'DELETE'].includes(method ?? 'GET') && PROXY_TEST_TASK_ROUTE.test(route)) return true + if (['GET', 'DELETE'].includes(method ?? 'GET') && TERMINAL_SESSION_ROUTE.test(route)) return true + if (['GET', 'DELETE'].includes(method ?? 'GET') && FILE_TASK_ROUTE.test(route)) return true + return EXTERNAL_MANAGEMENT_ROUTES.get(method ?? 'GET')?.has(route) ?? false +} + +function pluginManagementUpstreamPath(method, url) { + if (!url.pathname.startsWith(MANAGEMENT_PLUGIN_PREFIX)) return undefined + const route = url.pathname.slice(MANAGEMENT_PLUGIN_PREFIX.length) + if (method === 'GET' && /^bundled-plugins\/task\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(route)) { + return `${MANAGEMENT_PREFIX}${route}${url.search}` + } + if (['GET', 'DELETE'].includes(method ?? 'GET') && PROXY_TEST_TASK_ROUTE.test(route)) { + return `${MANAGEMENT_PREFIX}${route}${url.search}` + } + if (!(PLUGIN_MANAGEMENT_ROUTES.get(method ?? 'GET')?.has(route) ?? false)) return null + return `${MANAGEMENT_PREFIX}${route}${url.search}` +} + +function isExternalConsoleRoute(method, pathname) { + return ['GET', 'HEAD'].includes(method ?? 'GET') + && (pathname === MANAGEMENT_UI_PREFIX.slice(0, -1) || pathname.startsWith(MANAGEMENT_UI_PREFIX)) +} + +function managementSurfaceUpstream(url) { + const pathname = url.pathname + if (pathname === '/') return `${MANAGEMENT_UI_PREFIX}${url.search}` + if (pathname === '/app.js' || pathname === '/style.css' || pathname === '/theme-init.js' + || pathname.startsWith('/vendor/')) { + return `${MANAGEMENT_UI_PREFIX}${pathname.slice(1)}${url.search}` + } + if (pathname === '/api/v1' || pathname.startsWith('/api/v1/')) { + return `${MANAGEMENT_PREFIX}${pathname.slice('/api/v1/'.length)}${url.search}` + } + return null +} + +function isCompatibilityManagementPath(pathname) { + return pathname === MANAGEMENT_UI_PREFIX.slice(0, -1) + || pathname.startsWith(MANAGEMENT_UI_PREFIX) + || pathname.startsWith(MANAGEMENT_PREFIX) +} + +function serializeUpgradeRequest(request, headers, target = request.url ?? '/') { + const lines = [`${request.method ?? 'GET'} ${target} HTTP/${request.httpVersion}`] + for (const [name, value] of Object.entries(headers)) { + if (Array.isArray(value)) { + for (const item of value) lines.push(`${name}: ${item}`) + } else if (value !== undefined) { + lines.push(`${name}: ${value}`) + } + } + return `${lines.join('\r\n')}\r\n\r\n` +} + +async function proxyUpgrade(request, clientSocket, head, options) { + const context = requestContext(request) + const upstreamType = options.socketPath === undefined ? 'dsh' : 'management' + const failureKey = `${upstreamType}-websocket` + const headers = upstreamRequestHeaders(request.headers, { dsh: upstreamType === 'dsh' }) + if (upstreamType === 'dsh') { + applyDshUpstreamCookie(headers, await options.dshUpstreamAuthentication.cookie()) + } + if (typeof options.internalCapability === 'string') delete headers.cookie + if (options.preserveAuthorization === true && typeof request.headers.authorization === 'string') { + headers.authorization = request.headers.authorization + } + if (typeof options.internalCapability === 'string') headers[INTERNAL_CAPABILITY_HEADER] = options.internalCapability + headers.connection = 'Upgrade' + headers.upgrade = request.headers.upgrade ?? 'websocket' + const upstreamSocket = options.socketPath === undefined + ? netConnect(options.upstreamPort, options.upstreamHost) + : netConnect(options.socketPath) + let connected = false + + upstreamSocket.once('connect', () => { + connected = true + options.reportRecovered(failureKey, 'gateway.websocket.recovered', { upstream: upstreamType }) + if (upstreamType === 'dsh') options.availability.observe(true) + upstreamSocket.write(serializeUpgradeRequest(request, headers, options.upstreamPath)) + if (head.length > 0) upstreamSocket.write(head) + clientSocket.pipe(upstreamSocket).pipe(clientSocket) + }) + upstreamSocket.once('error', error => { + if (connected) { + options.reportFailure(failureKey, 'gateway.websocket.disconnected', { + ...context, error, level: 'warning', upstream: upstreamType, + }) + clientSocket.destroy() + } else if (upstreamType === 'dsh') { + options.availability.observe(false) + void unavailableState(options).then(state => { + options.reportFailure(failureKey, 'gateway.websocket.failed', { + ...context, error, level: state === 'unknown' ? 'error' : 'warning', upstream: upstreamType, + }) + if (state === 'unknown') rejectUpgrade(clientSocket, 502, 'Bad Gateway') + else rejectUpgrade(clientSocket, 503, 'Service Unavailable') + }) + } else { + options.reportFailure(failureKey, 'gateway.websocket.failed', { + ...context, error, level: 'error', upstream: upstreamType, + }) + rejectUpgrade(clientSocket, 502, 'Bad Gateway') + } + }) + clientSocket.once('error', () => upstreamSocket.destroy()) + clientSocket.once('close', () => upstreamSocket.destroy()) +} + +export function createGatewayServer({ + trustedHosts, + polyfill = true, + upstreamHost = INTERNAL_HOST, + upstreamPort = INTERNAL_PORT, + managementSocketPath = '/run/dsh-platform/management.sock', + maintenanceSocketPath = '/run/dsh-platform/maintenance.sock', + systemPluginRoot = '/run/dsh-platform/deployment/system-plugins/packages', + platformStatus = async () => ({}), + availability = new DshAvailability(), + probe = () => probeDsh({ host: upstreamHost, port: upstreamPort }), + probeIntervalMs = 750, + isReady = () => true, + browserAuthentication = Object.freeze({ + status: async () => ({ state: 'recovery-required' }), + validateDsh: async () => ({ authenticated: false }), + validateManagement: async () => ({ authenticated: false }), + authorizeManagement: async () => ({ authorized: false }), + authorizePlugin: async () => ({ authorized: false }), + enterManagement: async (request, response) => rejectDshAuthentication(request, response, safeReturnPath), + handle: async () => false, + }), + dshUpstreamAuthentication = Object.freeze({ cookie: async () => null, publicReady: async () => true }), + report = async () => {}, + now = () => Date.now(), + failureLogIntervalMs = 30_000, + pluginBundleHoldTimeoutMs = 60_000, + pluginBundlePollIntervalMs = 100, + pluginBundleMaxBytes = DEFAULT_PLUGIN_BUNDLE_MAX_BYTES, + pluginBundleCacheMaxBytes = DEFAULT_PLUGIN_BUNDLE_CACHE_MAX_BYTES, + pluginBundleRecentWindowMs = 30_000, + surface = 'compat', +}) { + const failures = new Map() + // Permit two reloads for each running Deployment. A third structured browser + // failure is terminal and moves the page to the dedicated diagnostics view. + let pluginRecoveryIdentity + let consumedPluginRecoveries = 0 + const currentPluginRecoveryIdentity = platform => { + const lifecycle = platform?.dshLifecycle ?? {} + const identity = deploymentRecordId(platform) ?? lifecycle.updatedAt + if (typeof identity !== 'string' || identity.length === 0) return null + if (pluginRecoveryIdentity !== identity) consumedPluginRecoveries = 0 + pluginRecoveryIdentity = identity + return identity + } + const pluginRecoveryAvailable = platform => { + if ((platform?.dshLifecycle?.state ?? null) !== 'running' || platform?.recoveryMode != null) return false + const identity = currentPluginRecoveryIdentity(platform) + return identity !== null && consumedPluginRecoveries < 2 + } + const consumePluginRecovery = () => { + if (pluginRecoveryIdentity !== undefined && consumedPluginRecoveries < 2) consumedPluginRecoveries += 1 + } + const record = (message, fields) => Promise.resolve().then(() => report(message, fields)).catch(() => {}) + const reportFailure = (key, message, fields) => { + const timestamp = now() + const previous = failures.get(key) + if (previous !== undefined && timestamp - previous.lastReportedAt < failureLogIntervalMs) { + previous.suppressedCount += 1 + return + } + const suppressedCount = previous?.suppressedCount ?? 0 + failures.set(key, { firstFailureAt: previous?.firstFailureAt ?? timestamp, lastReportedAt: timestamp, suppressedCount: 0 }) + void record(message, { ...fields, ...(suppressedCount === 0 ? {} : { suppressedCount }) }) + } + const reportRecovered = (key, message, fields) => { + const failure = failures.get(key) + if (failure === undefined) return + failures.delete(key) + void record(message, { + ...fields, + outageMs: Math.max(0, now() - failure.firstFailureAt), + suppressedCount: failure.suppressedCount, + }) + } + const options = { + trustedHosts, polyfill, upstreamHost, upstreamPort, managementSocketPath, maintenanceSocketPath, systemPluginRoot, platformStatus, + availability, probe, isReady, browserAuthentication, dshUpstreamAuthentication, reportFailure, reportRecovered, + now, pluginBundleHoldTimeoutMs, pluginBundlePollIntervalMs, pluginBundleMaxBytes, + pluginBundleRecentWindowMs, pluginBundleCache: createPluginBundleCache(pluginBundleCacheMaxBytes), + pluginRecoveryAvailable, consumePluginRecovery, + } + const upgradedSockets = new Set() + const server = createServer((request, response) => { + void handleRequest(request, response) + }) + async function handleRequest(request, response) { + try { + const url = new URL(request.url ?? '/', 'http://gateway.internal') + const pathname = url.pathname + const transitionProbe = surface === 'management' + && request.method === 'GET' && pathname === '/transition/probe' + const topLevelNavigation = request.method === 'GET' + && (request.headers['sec-fetch-mode'] === undefined + || (request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document')) + const transitionContinuation = topLevelNavigation + && pathname === (surface === 'management' ? '/transition/continue' : '/_dsh_platform/transition/continue') + const isolatedAuthenticationEntry = surface === 'management' && topLevelNavigation + && ['/auth/management', '/auth/management/handoff'].includes(pathname) + const dshAuthenticationEntry = surface === 'compat' && topLevelNavigation + && ['/_dsh_platform/auth', '/_dsh_platform/auth/'].includes(pathname) + const access = surface === 'management' + ? await options.browserAuthentication.status() + : null + const isolatedRootNavigation = topLevelNavigation && pathname === '/' + && (surface === 'compat' || access?.account?.managementAccess?.mode === 'isolated') + const trust = inspectExternalRequest(request.headers, + surface === 'management' ? managementTrustedHosts(access) : options.trustedHosts, { + allowCrossOrigin: transitionProbe || transitionContinuation || isolatedAuthenticationEntry || dshAuthenticationEntry || isolatedRootNavigation, + allowUntrustedAuthority: transitionProbe, + }) + if (!trust.accepted) { + reportFailure(`gateway-http-trust-${trust.reason}`, 'gateway.request.rejected', { + ...requestContext(request), + reason: trust.reason, + level: 'warning', + }) + rejectHttp(response, 403, 'forbidden') + return + } + if (pathname === HEALTH_PATH) { + if (options.isReady()) { + response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }) + response.end('ok\n') + } else { + rejectHttp(response, 503, 'unavailable') + } + return + } + const currentAccess = access ?? await options.browserAuthentication.status() + const managementMode = currentAccess.account?.managementAccess?.mode ?? 'compat' + if ((surface === 'management' && managementMode !== 'isolated' && !transitionProbe) + || (surface === 'compat' && managementMode === 'isolated' + && isCompatibilityManagementPath(pathname))) { + rejectHttp(response, 404, 'not found') + return + } + if (await options.browserAuthentication.handle(request, response, pathname, url.searchParams)) return + const authPrefix = surface === 'management' ? '/auth/' : '/_dsh_platform/auth/' + const accessPrefix = surface === 'management' ? '/access/' : '/_dsh_platform/access/' + if (pathname.startsWith(authPrefix) || pathname.startsWith(accessPrefix)) { + rejectHttp(response, 404, 'not found') + return + } + if (currentAccess.state !== 'initialized') { + rejectDshAuthentication(request, response, safeReturnPath) + return + } + const requireDsh = async () => { + const published = await publicationReady(options) + if (!published) { + const result = await readiness(options, false) + if (isPageNavigation(request)) { + sendAvailabilityPage(request, response, result.state, { + lifecycle: result.platform.dshLifecycle, + returnPath: safeReturnPath(url.searchParams.get('return')), + managementHref: managementAvailabilityHref(currentAccess), + }) + } else rejectHttp(response, 503, stateMessage(result.state, request.headers, result.platform.dshLifecycle)) + return false + } + const session = await options.browserAuthentication.validateDsh(request) + if (session.authenticated) return true + const result = await readiness(options) + if (!result.ready && result.state !== 'unknown') { + if (isPageNavigation(request)) { + sendAvailabilityPage(request, response, result.state, { + lifecycle: result.platform.dshLifecycle, + returnPath: safeReturnPath(url.searchParams.get('return')), + managementHref: managementAvailabilityHref(currentAccess), + }) + } else rejectHttp(response, 503, stateMessage(result.state, request.headers, result.platform.dshLifecycle)) + return false + } + rejectDshAuthentication(request, response, safeReturnPath) + return false + } + const requireManagement = async (audience, target = request.url ?? pathname) => { + const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(request.method ?? 'GET') + const authorization = typeof options.browserAuthentication.authorizeManagement === 'function' + ? await options.browserAuthentication.authorizeManagement(request, { + audience, + method: request.method ?? 'GET', + target, + requireCsrf: mutation, + }) + : { authorized: (await options.browserAuthentication.validateManagement(request)).authenticated === true } + if (authorization.authorized) { + if (Array.isArray(authorization.headers?.['set-cookie'])) { + response.setHeader('set-cookie', authorization.headers['set-cookie']) + } + return authorization.capability?.token ?? null + } + if (isPageNavigation(request)) await options.browserAuthentication.enterManagement(request, response) + else sendJson(response, 401, { error: 'management authentication required', code: 'MANAGEMENT_AUTHENTICATION_REQUIRED' }) + return undefined + } + if (surface === 'management') { + const upstreamPath = managementSurfaceUpstream(url) + if (upstreamPath === null) { + rejectHttp(response, 404, 'not found') + return + } + const upstreamUrl = new URL(upstreamPath, 'http://gateway.internal') + if (upstreamUrl.pathname.startsWith(MANAGEMENT_UI_PREFIX)) { + if (!isExternalConsoleRoute(request.method, upstreamUrl.pathname)) { + rejectHttp(response, 404, 'not found') + return + } + const capability = await requireManagement('management', upstreamPath) + if (capability === undefined) return + await proxyHttp(request, response, { + ...options, socketPath: options.managementSocketPath, polyfill: false, upstreamPath, + ...(typeof capability === 'string' ? { internalCapability: capability } : {}), + }) + return + } + if (!isExternalManagementRoute(request.method, upstreamUrl.pathname)) { + rejectHttp(response, 404, 'not found') + return + } + const socketPath = isMaintenanceRoute(upstreamUrl.pathname) ? options.maintenanceSocketPath : options.managementSocketPath + const audience = socketPath === options.maintenanceSocketPath ? 'maintenance' : 'management' + const capability = await requireManagement(audience, upstreamPath) + if (capability === undefined) return + await proxyHttp(request, response, { + ...options, socketPath, polyfill: false, upstreamPath, + ...(typeof capability === 'string' ? { internalCapability: capability } : {}), + }) + return + } + if (pathname === MANAGEMENT_UI_PREFIX.slice(0, -1) || pathname.startsWith(MANAGEMENT_UI_PREFIX)) { + if (!isExternalConsoleRoute(request.method, pathname)) { + rejectHttp(response, 404, 'not found') + return + } + const capability = await requireManagement('management') + if (capability === undefined) return + await proxyHttp(request, response, { + ...options, socketPath: options.managementSocketPath, polyfill: false, + ...(typeof capability === 'string' ? { internalCapability: capability } : {}), + }) + return + } + if (pathname.startsWith(MANAGEMENT_PLUGIN_PREFIX)) { + const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(request.method ?? 'GET') + const upstreamPath = pluginManagementUpstreamPath(request.method, url) + if (upstreamPath === null) { + rejectHttp(response, 404, 'not found') + return + } + const authorization = await options.browserAuthentication.authorizePlugin(request, { + method: request.method ?? 'GET', target: upstreamPath, requireCsrf: mutation, + }) + if (!authorization.authorized) { + rejectDshAuthentication(request, response, safeReturnPath) + return + } + await proxyHttp(request, response, { + ...options, socketPath: options.managementSocketPath, polyfill: false, upstreamPath, + internalCapability: authorization.capability.token, + internalCapabilityAudience: 'plugin', + }) + return + } + if (pathname.startsWith(MANAGEMENT_PREFIX)) { + if (!isExternalManagementRoute(request.method, pathname)) { + rejectHttp(response, 404, 'not found') + return + } + const socketPath = isMaintenanceRoute(pathname) ? options.maintenanceSocketPath : options.managementSocketPath + const audience = socketPath === options.maintenanceSocketPath ? 'maintenance' : 'management' + const capability = await requireManagement(audience) + if (capability === undefined) return + await proxyHttp(request, response, { + ...options, socketPath, polyfill: false, + ...(typeof capability === 'string' ? { internalCapability: capability } : {}), + }) + return + } + if (pathname === PLUGIN_FAILURE_PATH) { + if (!['GET', 'HEAD'].includes(request.method ?? 'GET')) { + rejectHttp(response, 405, 'method not allowed') + return + } + sendAvailabilityPage(request, response, 'plugin-failed', { + poll: false, + allowRefresh: true, + managementHref: managementAvailabilityHref(currentAccess), + }) + return + } + if (pathname === READINESS_PATH) { + const result = await readiness(options) + const managementHref = managementAvailabilityHref(currentAccess) + if (result.ready) sendJson(response, 200, { + ready: true, + state: 'ready', + pluginRecoveryEligible: result.pluginRecoveryEligible, + managementHref, + }) + else { + sendJson(response, result.state === 'unknown' ? 502 : 503, { + ready: false, + state: result.state, + message: stateMessage(result.state, request.headers, result.platform.dshLifecycle), + managementHref, + }) + } + return + } + if (pathname === WAIT_PATH) { + if (!['GET', 'HEAD'].includes(request.method ?? 'GET')) { + rejectHttp(response, 405, 'method not allowed') + return + } + const returnPath = safeReturnPath(url.searchParams.get('return')) + const result = await readiness(options) + if (result.ready) { + response.writeHead(302, { 'cache-control': 'no-store', location: returnPath }) + response.end() + return + } + sendAvailabilityPage(request, response, result.state === 'unknown' ? 'unavailable' : result.state, { + lifecycle: result.platform.dshLifecycle, + returnPath, + managementHref: managementAvailabilityHref(currentAccess), + }) + return + } + if (!await requireDsh()) return + if (await serveSystemPluginBundle(request, response, options.systemPluginRoot, pathname, url.searchParams)) return + if (await holdPluginBundleDuringTransition(request, response, options, pathname)) return + if (await proxyPluginBundle(request, response, options, pathname)) return + if (pathname === CLIENT_EVENT_PATH) { + if (request.method !== 'POST') { + rejectHttp(response, 405, 'method not allowed') + return + } + const value = await clientEvent(request) + if (value.event === 'browser.plugin-load.recovery.started') options.consumePluginRecovery?.() + await record(value.event, { + level: value.level, + pluginId: value.pluginId, + revision: value.revision, + pathname: value.pathname, + lifecycleState: value.lifecycleState, + lifecycleTaskId: value.lifecycleTaskId, + recoveryAttempt: value.recoveryAttempt, + reason: value.reason, + }) + response.writeHead(204, { 'cache-control': 'no-store' }) + response.end() + return + } + await proxyHttp(request, response, { ...options, trackDsh: true }) + } catch (error) { + reportFailure('gateway-request', 'gateway.request.failed', { ...requestContext(request), error }) + if (error?.browserAuthenticationBackend === true) rejectHttp(response, 503, 'authentication service unavailable') + else rejectHttp(response, error?.statusCode ?? 400, 'bad request') + } + } + server.on('upgrade', (request, socket, head) => { + try { + const url = new URL(request.url ?? '/', 'http://gateway.internal') + const pathname = url.pathname + void (async () => { + const access = await options.browserAuthentication.status() + const trust = inspectExternalRequest(request.headers, + surface === 'management' ? managementTrustedHosts(access) : options.trustedHosts) + if (!trust.accepted) { + reportFailure(`gateway-upgrade-trust-${trust.reason}`, 'gateway.upgrade-request.rejected', { + ...requestContext(request), + reason: trust.reason, + level: 'warning', + }) + rejectUpgrade(socket, 403, 'Forbidden') + return + } + if (access.state !== 'initialized') { + rejectUpgrade(socket, 401, 'Unauthorized') + return + } + const managementMode = access.account?.managementAccess?.mode ?? 'compat' + if ((surface === 'management' && managementMode !== 'isolated') + || (surface === 'compat' && managementMode === 'isolated' + && isCompatibilityManagementPath(pathname))) { + rejectUpgrade(socket, 404, 'Not Found') + return + } + const upstreamPath = surface === 'management' ? managementSurfaceUpstream(url) : request.url ?? pathname + const upstreamPathname = upstreamPath === null + ? null : new URL(upstreamPath, 'http://gateway.internal').pathname + if (surface === 'management' && upstreamPathname === null) { + rejectUpgrade(socket, 404, 'Not Found') + return + } + if (request.method === 'GET' && TERMINAL_STREAM_ROUTE.test(upstreamPathname ?? pathname)) { + const authorization = typeof options.browserAuthentication.authorizeManagement === 'function' + ? await options.browserAuthentication.authorizeManagement(request, { + audience: 'maintenance', method: request.method ?? 'GET', target: upstreamPath, + }) + : { authorized: (await options.browserAuthentication.validateManagement(request)).authenticated === true } + if (!authorization.authorized) { + rejectUpgrade(socket, 401, 'Unauthorized') + return + } + upgradedSockets.add(socket) + socket.once('close', () => upgradedSockets.delete(socket)) + await proxyUpgrade(request, socket, head, { + ...options, socketPath: options.maintenanceSocketPath, + upstreamPath, + ...(typeof authorization.capability?.token === 'string' + ? { internalCapability: authorization.capability.token } : {}), + }) + return + } + if ((upstreamPathname ?? pathname).startsWith(MANAGEMENT_PREFIX)) { + rejectUpgrade(socket, 400, 'Bad Request') + return + } + const dshSession = await options.browserAuthentication.validateDsh(request) + if (!dshSession.authenticated) { + rejectUpgrade(socket, 401, 'Unauthorized') + return + } + if (!await publicationReady(options)) { + rejectUpgrade(socket, 503, 'Service Unavailable') + return + } + upgradedSockets.add(socket) + socket.once('close', () => upgradedSockets.delete(socket)) + await proxyUpgrade(request, socket, head, options) + })().catch(error => { + reportFailure('gateway-upgrade-authentication', 'gateway.upgrade-authentication.failed', { + ...requestContext(request), error, + }) + rejectUpgrade(socket, 503, 'Service Unavailable') + }) + } catch (error) { + reportFailure('gateway-upgrade', 'gateway.upgrade-request.failed', { ...requestContext(request), error }) + rejectUpgrade(socket, 400, 'Bad Request') + } + }) + upgradedSocketsByServer.set(server, upgradedSockets) + let probing = false + const probeTimer = setInterval(() => { + if (probing) return + probing = true + void options.probe().then(ready => { + const wasReady = options.availability.everReady + options.availability.observe(ready) + if (ready) reportRecovered('dsh-probe', 'gateway.dsh.recovered', { upstream: 'dsh' }) + else if (wasReady && options.availability.consecutiveFailures >= options.availability.failures) { + reportFailure('dsh-probe', 'gateway.dsh.unavailable', { level: 'warning', upstream: 'dsh' }) + } + }, error => { + options.availability.observe(false) + reportFailure('dsh-probe', 'gateway.dsh.probe.failed', { error, level: 'warning', upstream: 'dsh' }) + }) + .finally(() => { probing = false }) + }, probeIntervalMs) + probeTimer.unref() + server.once('close', () => clearInterval(probeTimer)) + probeTimersByServer.set(server, probeTimer) + return server +} + +export async function closeGatewayServer(server) { + clearInterval(probeTimersByServer.get(server)) + const closed = new Promise((resolve, reject) => { + server.close(error => error === undefined ? resolve() : reject(error)) + }) + server.closeAllConnections() + for (const socket of upgradedSocketsByServer.get(server) ?? []) socket.destroy() + await closed +} diff --git a/upstream/container/control-plane/services/gateway/lib/trust.mjs b/upstream/container/control-plane/services/gateway/lib/trust.mjs new file mode 100644 index 0000000..1b26666 --- /dev/null +++ b/upstream/container/control-plane/services/gateway/lib/trust.mjs @@ -0,0 +1,56 @@ +function parseRequestAuthority(value) { + if ( + value === '' + || /[\s/@?#]/.test(value) + || value.endsWith(':') + ) return undefined + try { + const parsed = new URL(`http://${value}`) + return parsed.hostname === '' ? undefined : parsed + } catch { + return undefined + } +} + +export function isLoopbackHostname(hostname) { + if (hostname === 'localhost' || hostname === '[::1]') return true + const parts = hostname.split('.') + return parts.length === 4 + && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} + +function matchesTrustedAuthority(requestUrl, trustedHosts) { + if (trustedHosts.wildcard) return true + return trustedHosts.authorities.some((entry) => ( + entry.anyPort ? entry.hostname === requestUrl.hostname : entry.matchAuthority === requestUrl.host + )) +} + +export function inspectExternalRequest(headers, trustedHosts, { + allowCrossOrigin = false, + allowUntrustedAuthority = false, +} = {}) { + const host = headers.host + if (typeof host !== 'string') return Object.freeze({ accepted: false, reason: 'missing-host' }) + const hostUrl = parseRequestAuthority(host) + if (hostUrl === undefined) return Object.freeze({ accepted: false, reason: 'invalid-host' }) + if (!allowUntrustedAuthority && !isLoopbackHostname(hostUrl.hostname) && !matchesTrustedAuthority(hostUrl, trustedHosts)) { + return Object.freeze({ accepted: false, reason: 'untrusted-host' }) + } + if (!allowCrossOrigin && headers['sec-fetch-site'] === 'cross-site') { + return Object.freeze({ accepted: false, reason: 'cross-site' }) + } + const origin = headers.origin + if (!allowCrossOrigin && origin !== undefined) { + if (typeof origin !== 'string') return Object.freeze({ accepted: false, reason: 'invalid-origin' }) + try { + if (new URL(origin).host !== hostUrl.host) { + return Object.freeze({ accepted: false, reason: 'origin-mismatch' }) + } + } catch { + return Object.freeze({ accepted: false, reason: 'invalid-origin' }) + } + } + return Object.freeze({ accepted: true, externalAuthority: hostUrl.host }) +} diff --git a/upstream/container/control-plane/services/gateway/package.json b/upstream/container/control-plane/services/gateway/package.json new file mode 100644 index 0000000..dd35f4a --- /dev/null +++ b/upstream/container/control-plane/services/gateway/package.json @@ -0,0 +1,11 @@ +{ + "name": "dsh-docker-gateway", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=24" + } +} diff --git a/upstream/container/control-plane/services/gateway/test/auth.test.mjs b/upstream/container/control-plane/services/gateway/test/auth.test.mjs new file mode 100644 index 0000000..6333d18 --- /dev/null +++ b/upstream/container/control-plane/services/gateway/test/auth.test.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict' +import { createServer, request as httpRequest } from 'node:http' +import { connect as netConnect } from 'node:net' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { parseTrustedHosts } from '../lib/config.mjs' +import { closeGatewayServer, createGatewayServer, HEALTH_PATH } from '../lib/proxy.mjs' + +async function listen(server) { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + return server.address().port +} + +async function close(server) { + server.closeAllConnections?.() + await new Promise(resolve => server.close(resolve)) +} + +function request(port, { path = '/', method = 'GET', headers = {}, body = '' } = {}) { + return new Promise((resolve, reject) => { + const outgoing = httpRequest({ hostname: '127.0.0.1', port, path, method, headers }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => resolve({ + status: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + })) + }) + outgoing.once('error', reject) + outgoing.end(body) + }) +} + +const initialized = Object.freeze({ status: async () => ({ state: 'initialized' }), handle: async () => false }) + +test('Gateway fails closed without an authenticated browser session while health remains available', async () => { + let upstreamRequests = 0 + const upstream = createServer((_incoming, response) => { upstreamRequests += 1; response.end('upstream') }) + const upstreamPort = await listen(upstream) + const gateway = createGatewayServer({ trustedHosts: parseTrustedHosts({}), upstreamPort }) + const port = await listen(gateway) + try { + assert.equal((await request(port, { path: HEALTH_PATH, headers: { host: '127.0.0.1' } })).status, 200) + const navigation = await request(port, { headers: { accept: 'text/html', host: '127.0.0.1' } }) + assert.equal(navigation.status, 303) + assert.match(navigation.headers.location, /^\/_dsh_platform\/auth\//) + assert.equal((await request(port, { path: '/api/sessions', headers: { host: '127.0.0.1' } })).status, 401) + assert.equal(upstreamRequests, 0) + } finally { + await Promise.all([closeGatewayServer(gateway), close(upstream)]) + } +}) + +test('Gateway reports an unavailable authentication backend as a service failure', async () => { + const unavailable = new Error('access socket unavailable') + unavailable.browserAuthenticationBackend = true + const browserAuthentication = { + status: async () => { throw unavailable }, + handle: async () => false, + } + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({}), browserAuthentication, upstreamPort: 1, + }) + const port = await listen(gateway) + try { + assert.equal((await request(port, { + path: HEALTH_PATH, headers: { host: '127.0.0.1' }, + })).status, 200) + const unavailable = await request(port, { headers: { host: '127.0.0.1', accept: 'text/html' } }) + assert.equal(unavailable.status, 503) + assert.match(unavailable.body, /authentication service unavailable/) + } finally { await closeGatewayServer(gateway) } +}) + +test('DSH and Management sessions authorize only their own route surfaces', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-gateway-session-domains-')) + const socketPath = join(root, 'management.sock') + const management = createServer((incoming, response) => response.end(`management:${incoming.url}`)) + await new Promise((resolve, reject) => { + management.once('error', reject) + management.listen(socketPath, resolve) + }) + const upstream = createServer((_incoming, response) => response.end('dsh')) + const upstreamPort = await listen(upstream) + const browserAuthentication = { + ...initialized, + validateDsh: async request => ({ authenticated: request.headers.cookie === 'dsh=yes' }), + authorizePlugin: async request => request.headers.cookie === 'dsh=yes' + ? { authorized: true, capability: { token: 'plugin-capability' } } + : { authorized: false }, + validateManagement: async request => ({ authenticated: request.headers.cookie === 'management=yes' }), + enterManagement: async (_request, response) => { response.writeHead(303, { location: '/_dsh_platform/auth/management' }); response.end() }, + } + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({}), browserAuthentication, + managementSocketPath: socketPath, upstreamPort, + }) + const port = await listen(gateway) + try { + assert.equal((await request(port, { headers: { host: '127.0.0.1', cookie: 'dsh=yes' } })).body, 'dsh') + assert.equal((await request(port, { + path: '/_dsh_platform/plugin-api/v1/status', headers: { host: '127.0.0.1', cookie: 'dsh=yes' }, + })).body, 'management:/_dsh_platform/api/v1/status') + assert.equal((await request(port, { + path: '/_dsh_platform/api/v1/status', headers: { host: '127.0.0.1', cookie: 'dsh=yes' }, + })).status, 401) + assert.equal((await request(port, { + path: '/_dsh_platform/api/v1/status', headers: { host: '127.0.0.1', cookie: 'management=yes' }, + })).body, 'management:/_dsh_platform/api/v1/status') + assert.equal((await request(port, { + path: '/', headers: { host: '127.0.0.1', cookie: 'management=yes' }, + })).status, 401) + } finally { + await Promise.all([closeGatewayServer(gateway), close(management), close(upstream)]) + } +}) + +test('terminal WebSocket requires Management rather than DSH authentication', async () => { + const browserAuthentication = { + ...initialized, + validateDsh: async () => ({ authenticated: true }), + validateManagement: async () => ({ authenticated: false }), + enterManagement: async () => {}, + } + const gateway = createGatewayServer({ trustedHosts: parseTrustedHosts({}), browserAuthentication, upstreamPort: 1 }) + const port = await listen(gateway) + const client = netConnect(port, '127.0.0.1') + try { + await new Promise((resolve, reject) => { client.once('connect', resolve); client.once('error', reject) }) + client.write([ + 'GET /_dsh_platform/api/v1/terminal/sessions/123e4567-e89b-42d3-a456-426614174000/stream HTTP/1.1', + 'Host: 127.0.0.1', 'Connection: Upgrade', 'Upgrade: websocket', '', '', + ].join('\r\n')) + const response = await new Promise((resolve, reject) => { + client.once('data', data => resolve(data.toString('utf8'))) + client.once('error', reject) + }) + assert.match(response, /^HTTP\/1\.1 401 Unauthorized/) + } finally { + client.destroy() + await closeGatewayServer(gateway) + } +}) diff --git a/upstream/container/control-plane/services/gateway/test/availability.test.mjs b/upstream/container/control-plane/services/gateway/test/availability.test.mjs new file mode 100644 index 0000000..70d7b02 --- /dev/null +++ b/upstream/container/control-plane/services/gateway/test/availability.test.mjs @@ -0,0 +1,486 @@ +import assert from 'node:assert/strict' +import { createServer, request as httpRequest } from 'node:http' +import { connect as netConnect } from 'node:net' +import test from 'node:test' +import { DshAvailability, availabilityPage, language } from '../lib/availability.mjs' +import { closeGatewayServer, createGatewayServer as createGatewayServerBase, PLUGIN_FAILURE_PATH, READINESS_PATH, WAIT_PATH, safeReturnPath } from '../lib/proxy.mjs' +import { parseTrustedHosts } from '../lib/config.mjs' + +const AUTHENTICATED_BROWSER = Object.freeze({ + status: async () => ({ state: 'initialized' }), + validateDsh: async () => ({ authenticated: true }), + validateManagement: async () => ({ authenticated: true }), + enterManagement: async () => { throw new Error('unexpected Management login') }, + handle: async () => false, +}) + +const UNAUTHENTICATED_BROWSER = Object.freeze({ + status: async () => ({ state: 'initialized' }), + validateDsh: async () => ({ authenticated: false }), + validateManagement: async () => ({ authenticated: false }), + enterManagement: async () => { throw new Error('unexpected Management login') }, + handle: async () => false, +}) + +function createGatewayServer(options) { + return createGatewayServerBase({ + ...options, + browserAuthentication: options.browserAuthentication ?? AUTHENTICATED_BROWSER, + }) +} + +const ISOLATED_UNAUTHENTICATED_BROWSER = Object.freeze({ + ...UNAUTHENTICATED_BROWSER, + status: async () => ({ state: 'initialized', account: { + managementAccess: { + mode: 'isolated', + isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://management.example' }, + }, + } }), +}) + +async function listen(server) { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + return server.address().port +} + +function request(port, path = '/', { method = 'GET', accept = 'text/html', headers = {} } = {}) { + return new Promise((resolve, reject) => { + const outgoing = httpRequest({ + hostname: '127.0.0.1', port, path, method, + headers: { host: 'dsh.example', accept, ...headers }, + }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => resolve({ + status: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + })) + }) + outgoing.once('error', reject) + outgoing.end() + }) +} + +async function unavailableGateway({ platform = {}, availability = new DshAvailability(), probe = async () => false, report, browserAuthentication } = {}) { + const placeholder = createServer() + const unavailablePort = await listen(placeholder) + await new Promise(resolve => placeholder.close(resolve)) + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }), + upstreamPort: unavailablePort, + platformStatus: async () => platform, + availability, + probe, + browserAuthentication, + report, + probeIntervalMs: 60_000, + }) + return { gateway, port: await listen(gateway) } +} + +test('authenticated DSH traffic waits for authoritative Bootstrap publication', async t => { + let upstreamRequests = 0 + const upstream = createServer((_request, response) => { + upstreamRequests += 1 + response.writeHead(200, { 'content-type': 'text/plain' }) + response.end('published') + }) + const upstreamPort = await listen(upstream) + t.after(() => upstream.close()) + let published = false + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }), + upstreamPort, + platformStatus: async () => ({ dshLifecycle: { state: 'running' } }), + probe: async () => true, + dshUpstreamAuthentication: { + cookie: async () => null, + publicReady: async () => published, + }, + probeIntervalMs: 60_000, + }) + const port = await listen(gateway) + try { + const socket = netConnect(port, '127.0.0.1') + await new Promise((resolve, reject) => { + socket.once('connect', resolve) + socket.once('error', reject) + }) + socket.write('GET /events HTTP/1.1\r\nHost: dsh.example\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n') + const upgrade = await new Promise((resolve, reject) => { + socket.once('data', data => resolve(data.toString('utf8'))) + socket.once('error', reject) + }) + assert.match(upgrade, /^HTTP\/1\.1 503 Service Unavailable/) + socket.destroy() + const waiting = await request(port) + assert.equal(waiting.status, 200) + assert.match(waiting.body, /DeepSeek Harness is starting/) + assert.equal(upstreamRequests, 0) + assert.deepEqual(JSON.parse((await request(port, READINESS_PATH, { accept: 'application/json' })).body).ready, false) + const dedicatedWaiting = await request(port, `${WAIT_PATH}?return=%2Fsessions%2Fcurrent`) + assert.equal(dedicatedWaiting.status, 200) + assert.match(dedicatedWaiting.body, /DeepSeek Harness is starting/) + assert.match(dedicatedWaiting.body, /\/sessions\/current/) + published = true + const ready = await request(port) + assert.equal(ready.status, 200) + assert.equal(ready.body, 'published') + assert.equal(upstreamRequests, 1) + assert.equal(JSON.parse((await request(port, READINESS_PATH, { accept: 'application/json' })).body).ready, true) + } finally { + await closeGatewayServer(gateway) + } +}) + +test('an intentional stopped state does not report an upstream proxy failure', async () => { + const reports = [] + const context = await unavailableGateway({ + platform: { dshLifecycle: { state: 'stopped' } }, + report: (message, fields) => { reports.push({ message, fields }) }, + }) + try { + const response = await request(context.port) + assert.equal(response.status, 200) + assert.match(response.body, /DeepSeek Harness is stopped/) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(reports.some(entry => entry.message === 'gateway.upstream.failed'), false) + } finally { + await closeGatewayServer(context.gateway) + } +}) + +test('an unauthenticated top-level navigation sees classified DSH recovery before the login page', async () => { + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }), + upstreamPort: 1, + platformStatus: async () => ({ dshLifecycle: { state: 'failed', error: 'private runtime detail' } }), + probe: async () => false, + browserAuthentication: UNAUTHENTICATED_BROWSER, + probeIntervalMs: 60_000, + }) + const port = await listen(gateway) + try { + const page = await request(port) + assert.equal(page.status, 200) + assert.match(page.body, /DeepSeek Harness could not start/) + assert.match(page.body, /href="\/_dsh_platform\/console\/"/) + assert.doesNotMatch(page.body, /private runtime detail|autocomplete="current-password"/) + assert.equal((await request(port, '/api/sessions', { accept: 'application/json' })).status, 503) + } finally { + await closeGatewayServer(gateway) + } +}) + +test('official-style holding page is self-contained and replaces the spinner with platform status', () => { + const page = availabilityPage('switching', { 'accept-language': 'zh-CN' }) + assert.match(page, /HARNESS/) + assert.match(page, /正在切换 DeepSeek Harness 运行版本/) + assert.match(page, /href="\/_dsh_platform\/console\/"/) + assert.match(page, /打开 DSH 管理中心进行检查和恢复/) + assert.match(page, new RegExp(READINESS_PATH)) + assert.match(page, /prefers-color-scheme:light/) + assert.match(page, /name="viewport"/) + assert.match(page, /html\{height:100%\}body\{min-height:100%;margin:0;display:grid;place-items:center;/) + assert.doesNotMatch(page, /100(?:d|s|l)?vh/) + assert.match(page, /max-width:520px/) + assert.match(page, /management\.href=value\.managementHref/) + assert.doesNotMatch(page, /spinner|Loading plugins/) + assert.doesNotMatch(page, /letter-spacing:-/) +}) + +test('persistent plugin failure page is terminal, localized, and links to Management', async () => { + const page = availabilityPage('plugin-failed', { 'accept-language': 'zh-CN' }, { poll: false, allowRefresh: true }) + assert.match(page, /DeepSeek Harness 插件持续加载失败/) + assert.match(page, /打开 DSH 管理中心进行检查和恢复/) + assert.match(page, /刷新页面/) + assert.doesNotMatch(page, /setTimeout\(check/) + assert.doesNotMatch(availabilityPage('failed', {}, { poll: false }), /Reload page/) + + const context = await unavailableGateway() + try { + const response = await request(context.port, PLUGIN_FAILURE_PATH, { + headers: { 'accept-language': 'zh-CN' }, + }) + assert.equal(response.status, 200) + assert.match(response.body, /DeepSeek Harness 插件持续加载失败/) + assert.equal((await request(context.port, PLUGIN_FAILURE_PATH, { method: 'POST' })).status, 405) + } finally { + await closeGatewayServer(context.gateway) + } +}) + +test('Gateway temporary DSH pages link to the configured isolated Management entry', async () => { + const context = await unavailableGateway({ browserAuthentication: ISOLATED_UNAUTHENTICATED_BROWSER }) + try { + const response = await request(context.port, PLUGIN_FAILURE_PATH) + assert.equal(response.status, 200) + assert.match(response.body, /href="https:\/\/management\.example\/"/) + assert.doesNotMatch(response.body, /href="\/_dsh_platform\/console\/"/) + } finally { + await closeGatewayServer(context.gateway) + } +}) + +test('Gateway readiness publishes the current Management entry for an already-open temporary page', async () => { + let access = { state: 'initialized', account: { managementAccess: { mode: 'compat' } } } + const browserAuthentication = { + ...UNAUTHENTICATED_BROWSER, + status: async () => access, + } + const context = await unavailableGateway({ browserAuthentication }) + try { + const compatible = JSON.parse((await request(context.port, READINESS_PATH)).body) + assert.equal(compatible.managementHref, '/_dsh_platform/console/') + + access = await ISOLATED_UNAUTHENTICATED_BROWSER.status() + const isolated = JSON.parse((await request(context.port, READINESS_PATH)).body) + assert.equal(isolated.managementHref, 'https://management.example/') + } finally { + await closeGatewayServer(context.gateway) + } +}) + +test('holding pages prefer the last DSH locale cookie over browser language', () => { + assert.equal(language({ cookie: 'theme=dark; dsh_locale=en', 'accept-language': 'zh-CN' }), 'en') + assert.equal(language({ cookie: 'dsh_locale=zh', 'accept-language': 'en-US' }), 'zh') + assert.equal(language({ cookie: 'dsh_locale=fr', 'accept-language': 'en-US' }), 'en') + assert.equal(language({ 'accept-language': 'fr-FR, zh-CN;q=0.8, en;q=0.5' }), 'zh') + assert.match(availabilityPage('recovering', { cookie: 'dsh_locale=zh', 'accept-language': 'en-US' }), /意外停止/) + assert.match(availabilityPage('recovering', { cookie: 'dsh_locale=en', 'accept-language': 'zh-CN' }), /Open DSH Management Console for diagnostics and recovery/) +}) + +test('every classified outage page links to the standalone Platform Management console', () => { + for (const state of ['starting', 'stopping', 'stopped', 'restarting', 'switching', 'runtime-recovering', 'recovering', 'failed', 'unavailable']) { + const page = availabilityPage(state, { 'accept-language': 'en-US' }) + assert.match(page, /href="\/_dsh_platform\/console\/"/) + } +}) + +test('intentional DSH restarts have explicit holding and failure states', () => { + const availability = new DshAvailability() + assert.equal(availability.classify({ operation: 'restarting' }), 'restarting') + assert.equal(availability.classify({ operation: 'restart-failed' }), 'failed') + assert.match(availabilityPage('restarting', { 'accept-language': 'zh-CN' }), /正在重新启动/) + assert.equal(availability.classify({ dshLifecycle: { state: 'stopping' } }), 'stopping') + assert.equal(availability.classify({ dshLifecycle: { state: 'stopped' } }), 'stopped') + assert.equal(availability.classify({ dshLifecycle: { state: 'recovering', attempt: 2 } }), 'recovering') + assert.match(availabilityPage('recovering', { 'accept-language': 'zh-CN' }, { + lifecycle: { attempt: 2, maxAttempts: 3 }, + }), /第 2 \/ 3 次/) +}) + +test('holding-page return paths stay same-origin and reject ambiguous inputs', () => { + assert.equal(safeReturnPath('/sessions/current?view=chat#latest'), '/sessions/current?view=chat#latest') + for (const value of [null, '', '//example.com/path', '/\\example.com', 'https://example.com/', '/bad\npath']) { + assert.equal(safeReturnPath(value), '/') + } + const page = availabilityPage('restarting', {}, { returnPath: '/' }) + assert.doesNotMatch(page, /const returnPath="\/<\/script>/) + assert.match(page, /\\u003c\/script>/) +}) + +test('cold start serves a holding page while API and WebSocket-shaped HTTP requests receive 503', async () => { + const { gateway, port } = await unavailableGateway() + try { + const page = await request(port) + assert.equal(page.status, 200) + assert.match(page.body, /DeepSeek Harness is starting/) + assert.equal(page.headers['cache-control'], 'no-store') + assert.equal((await request(port, '/api/sessions', { accept: 'application/json' })).status, 503) + assert.equal((await request(port, '/', { method: 'POST', accept: 'text/html' })).status, 503) + } finally { + await closeGatewayServer(gateway) + } +}) + +test('an unresponsive management service does not block the cold-start holding page', async () => { + const placeholder = createServer() + const unavailablePort = await listen(placeholder) + await new Promise(resolve => placeholder.close(resolve)) + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }), + upstreamPort: unavailablePort, + platformStatus: () => new Promise(() => {}), + probe: async () => false, + probeIntervalMs: 60_000, + }) + const port = await listen(gateway) + try { + const startedAt = Date.now() + const page = await request(port) + assert.equal(page.status, 200) + assert.match(page.body, /DeepSeek Harness is starting/) + assert.ok(Date.now() - startedAt < 1_000) + } finally { + await closeGatewayServer(gateway) + } +}) + +test('cold-start management status failures remain diagnostic warnings', async () => { + const reported = [] + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }), + upstreamPort: 1, + platformStatus: async () => { throw Object.assign(new Error('socket is not ready'), { code: 'ENOENT' }) }, + probe: async () => false, + probeIntervalMs: 60_000, + report: async (message, fields) => reported.push({ message, fields }), + }) + const port = await listen(gateway) + try { + assert.equal((await request(port)).status, 200) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(reported.length, 1) + assert.equal(reported[0].message, 'gateway.platform-status.failed') + assert.equal(reported[0].fields.level, 'warning') + assert.equal(reported[0].fields.error.code, 'ENOENT') + } finally { + await closeGatewayServer(gateway) + } +}) + +test('platform switching, recovery, and startup failure select distinct page messages', async () => { + for (const [platform, expected] of [ + [{ operation: 'switching' }, /Switching the DeepSeek Harness runtime/], + [{ operation: 'recovering' }, /Restoring the DeepSeek Harness runtime/], + [{ recoveryMode: 'candidate failed' }, /DeepSeek Harness could not start/], + ]) { + const { gateway, port } = await unavailableGateway({ platform }) + try { + const page = await request(port) + assert.equal(page.status, 200) + assert.match(page.body, expected) + assert.equal((await request(port, '/api/status', { accept: 'application/json' })).status, 503) + } finally { + await closeGatewayServer(gateway) + } + } +}) + +test('dedicated holding route preserves a safe return path and redirects when ready', async () => { + let ready = false + const platform = { dshLifecycle: { state: 'stopping' } } + const context = await unavailableGateway({ + platform, + probe: async () => ready, + }) + try { + const target = '/sessions/current?view=chat#latest' + const waiting = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent(target)}`) + assert.equal(waiting.status, 200) + assert.match(waiting.body, /DeepSeek Harness is stopping/) + assert.match(waiting.body, /location\.replace\(returnPath\)/) + assert.match(waiting.body, /\/sessions\/current\?view=chat#latest/) + + const invalid = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent('//example.com/')}`) + assert.equal(invalid.status, 200) + assert.match(invalid.body, /const returnPath="\/"/) + + ready = true + const premature = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent(target)}`) + assert.equal(premature.status, 200) + assert.match(premature.body, /DeepSeek Harness is stopping/) + const prematureReadiness = await request(context.port, READINESS_PATH) + assert.equal(prematureReadiness.status, 503) + assert.equal(JSON.parse(prematureReadiness.body).state, 'stopping') + + platform.dshLifecycle.state = 'running' + const redirected = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent(target)}`) + assert.equal(redirected.status, 302) + assert.equal(redirected.headers.location, target) + assert.deepEqual(JSON.parse((await request(context.port, READINESS_PATH)).body), { + ready: true, state: 'ready', pluginRecoveryEligible: false, + managementHref: '/_dsh_platform/console/', + }) + assert.equal((await request(context.port, WAIT_PATH, { method: 'POST' })).status, 405) + } finally { + await closeGatewayServer(context.gateway) + } +}) + +test('WebSocket receives 503 during a classified DSH outage', async () => { + const reports = [] + const { gateway, port } = await unavailableGateway({ + platform: { operation: 'switching' }, + report: (message, fields) => reports.push({ message, fields }), + }) + const socket = netConnect(port, '127.0.0.1') + try { + await new Promise((resolve, reject) => { + socket.once('connect', resolve) + socket.once('error', reject) + }) + socket.write('GET /events HTTP/1.1\r\nHost: dsh.example\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n') + const response = await new Promise((resolve, reject) => { + socket.once('data', data => resolve(data.toString('utf8'))) + socket.once('error', reject) + }) + assert.match(response, /^HTTP\/1\.1 503 Service Unavailable/) + await new Promise(resolve => setImmediate(resolve)) + const failure = reports.find(entry => entry.message === 'gateway.websocket.failed') + assert.equal(failure?.fields.level, 'warning') + assert.equal(failure?.fields.upstream, 'dsh') + } finally { + socket.destroy() + await closeGatewayServer(gateway) + } +}) + +test('one unexpected local failure remains 502 until repeated probes confirm unavailability', async () => { + let now = 0 + const availability = new DshAvailability({ now: () => now, failures: 3, failureWindowMs: 1_500 }) + availability.observe(true) + const context = await unavailableGateway({ availability }) + try { + assert.equal((await request(context.port)).status, 502) + availability.observe(false) + now = 1_600 + availability.observe(false) + const recovered = await request(context.port) + assert.equal(recovered.status, 200) + assert.match(recovered.body, /temporarily unavailable/) + } finally { + await closeGatewayServer(context.gateway) + } +}) + +test('a Bootstrap candidate handoff serves the switching page instead of a bare 502', async () => { + const context = await unavailableGateway({ platform: { update: { status: 'building-candidate' } } }) + try { + const response = await request(context.port) + assert.equal(response.status, 200) + assert.match(response.body, /Switching the DeepSeek Harness runtime/) + } finally { + await closeGatewayServer(context.gateway) + } +}) + +test('readiness immediately reports ready and clears confirmed failure state', async () => { + let ready = false + let now = 0 + const availability = new DshAvailability({ now: () => now, failures: 1, failureWindowMs: 0 }) + availability.observe(true) + availability.observe(false) + const context = await unavailableGateway({ availability, probe: async () => ready }) + try { + const unavailable = await request(context.port, READINESS_PATH, { accept: 'application/json' }) + assert.equal(unavailable.status, 503) + assert.equal(JSON.parse(unavailable.body).state, 'unavailable') + ready = true + const healthy = await request(context.port, READINESS_PATH, { accept: 'application/json' }) + assert.equal(healthy.status, 200) + assert.deepEqual(JSON.parse(healthy.body), { + ready: true, state: 'ready', pluginRecoveryEligible: false, + managementHref: '/_dsh_platform/console/', + }) + assert.equal(availability.classify({}), 'unknown') + } finally { + await closeGatewayServer(context.gateway) + } +}) diff --git a/upstream/container/control-plane/services/gateway/test/browser-auth.test.mjs b/upstream/container/control-plane/services/gateway/test/browser-auth.test.mjs new file mode 100644 index 0000000..f3f3a17 --- /dev/null +++ b/upstream/container/control-plane/services/gateway/test/browser-auth.test.mjs @@ -0,0 +1,1400 @@ +import assert from 'node:assert/strict' +import { createServer, request as httpRequest } from 'node:http' +import test from 'node:test' +import { + createBrowserAuthentication, + AUTH_CSRF_COOKIE, + AUTH_RETRY_COOKIE, + DSH_SESSION_COOKIE, + MANAGEMENT_CSRF_COOKIE, + MANAGEMENT_SESSION_COOKIE, + TOTP_LOGIN_COOKIE, +} from '../lib/browser-auth.mjs' +import { safeReturnPath } from '../lib/proxy.mjs' + +async function listen(server) { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + return server.address().port +} + +async function close(server) { + server.closeAllConnections?.() + await new Promise(resolve => server.close(resolve)) +} + +function request(port, { path = '/', method = 'GET', headers = {}, body } = {}) { + return new Promise((resolve, reject) => { + const outgoing = httpRequest({ hostname: '127.0.0.1', port, path, method, headers }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => resolve({ + status: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + })) + }) + outgoing.once('error', reject) + outgoing.end(body) + }) +} + +function assertInlineScriptsCompile(html) { + const scripts = [...html.matchAll(/'.length), context) + if (eventType === 'error') listeners.get('error')?.({ filename: `http://gateway.local${bundlePath}` }) + else if (eventType === 'unhandledrejection') listeners.get('unhandledrejection')?.({ + reason: { stack: `TypeError: failed to import http://gateway.local${bundlePath}` }, + }) + await new Promise(resolve => setImmediate(resolve)) + return { events, replacement, storage } +} + +test('polyfill is inserted immediately after the head tag', () => { + assert.equal( + injectRandomUuidPolyfill('x'), + `${RANDOM_UUID_POLYFILL}${LIFECYCLE_TRANSITION_GUARD}${SESSION_VALIDITY_GUARD}${PLUGIN_RECOVERY_GUARD}x`, + ) +}) + +test('polyfill falls back to the start when no head exists', () => { + assert.equal(injectRandomUuidPolyfill('
x
'), `${RANDOM_UUID_POLYFILL}${LIFECYCLE_TRANSITION_GUARD}${SESSION_VALIDITY_GUARD}${PLUGIN_RECOVERY_GUARD}
x
`) +}) + +test('gateway lifecycle guard moves an already-open DSH page into the holding flow', async () => { + let inspect + let replacement + const context = { + EventSource: class { + addEventListener(name, callback) { if (name === 'state') inspect = callback } + }, + URL, + fetch: async () => ({ json: async () => ({ dshLifecycle: { state: 'restarting' } }) }), + location: { + pathname: '/sessions/current', search: '?view=chat', hash: '#latest', + replace: value => { replacement = value }, + }, + } + vm.runInNewContext(LIFECYCLE_TRANSITION_GUARD.slice(''.length), context) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(replacement, '/_dsh_gateway/wait?return=%2Fsessions%2Fcurrent%3Fview%3Dchat%23latest') + replacement = undefined + await inspect() + assert.equal(replacement, undefined) +}) + +test('plugin recovery guard runs before DSH modules and bounds lifecycle recovery', () => { + assert.doesNotThrow(() => new Function(PLUGIN_RECOVERY_GUARD.slice(''.length))) + assert.match(PLUGIN_RECOVERY_GUARD, /globalThis\.fetch=function/) + assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/client-event/) + assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/wait/) + assert.match(PLUGIN_RECOVERY_GUARD, /attempt>=3/) + assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/plugin-failure/) + assert.match(PLUGIN_RECOVERY_GUARD, /browser\.plugin-load\.recovery\.completed/) + assert.match(PLUGIN_RECOVERY_GUARD, /addEventListener\("error"/) + assert.match(PLUGIN_RECOVERY_GUARD, /unhandledrejection/) + assert.doesNotMatch(PLUGIN_RECOVERY_GUARD, /Failed to load plugins/) + assert.doesNotMatch(PLUGIN_RECOVERY_GUARD, /localStorage/) +}) + +test('Gateway session guard reloads DSH pages only after Access Manager rejects the session', () => { + assert.doesNotThrow(() => new Function(SESSION_VALIDITY_GUARD.slice(''.length))) + assert.match(SESSION_VALIDITY_GUARD, /auth\/session-context/) + assert.match(SESSION_VALIDITY_GUARD, /response\.status===401/) + assert.doesNotMatch(SESSION_VALIDITY_GUARD, /innerText|textContent/) +}) + +test('plugin recovery guard catches official and third-party dynamic import failures', async () => { + for (const [path, pluginId] of [ + ['/plugins/@deepseek-ai/dsh-client-ui-input-trigger/client.js?rev=abc123', '@deepseek-ai/dsh-client-ui-input-trigger'], + ['/plugins/community-plugin/client.js?rev=def456', 'community-plugin'], + ]) { + const result = await simulatePluginError(path) + assert.match(result.replacement, /^\/_dsh_gateway\/wait\?return=/) + assert.deepEqual(result.events.map(value => [value.event, value.pluginId]), [ + ['browser.plugin-load.failed', pluginId], + ['browser.plugin-load.recovery.started', pluginId], + ]) + } +}) + +test('plugin recovery guard retries twice and opens the Gateway failure page after the third failure', async () => { + const path = '/plugins/@deepseek-ai/dsh-client-ui-input-trigger/client.js?rev=abc123' + const storage = new Map() + const first = await simulatePluginError(path, { storage }) + const second = await simulatePluginError(path, { storage }) + const third = await simulatePluginError(path, { storage }) + + assert.match(first.replacement, /^\/_dsh_gateway\/wait\?return=/) + assert.match(second.replacement, /^\/_dsh_gateway\/wait\?return=/) + assert.equal(third.replacement, '/_dsh_gateway/plugin-failure') + assert.equal(first.events.at(-1).recoveryAttempt, 1) + assert.equal(second.events.at(-1).recoveryAttempt, 2) + assert.deepEqual(third.events.map(value => [value.event, value.recoveryAttempt]), [ + ['browser.plugin-load.recovery.failed', 3], + ]) +}) + +test('plugin recovery guard does not inspect page text', async () => { + const result = await simulatePluginError('/plugins/example/client.js?rev=abc123', { eventType: 'none' }) + assert.equal(result.replacement, undefined) + assert.deepEqual(result.events, []) +}) + +test('polyfill is feature guarded and uses no weak random source', () => { + assert.match(RANDOM_UUID_POLYFILL, /typeof c\.randomUUID!=="function"/) + assert.match(RANDOM_UUID_POLYFILL, /c\.getRandomValues/) + assert.doesNotMatch(RANDOM_UUID_POLYFILL, /Math\.random/) +}) diff --git a/upstream/container/control-plane/services/gateway/test/proxy.test.mjs b/upstream/container/control-plane/services/gateway/test/proxy.test.mjs new file mode 100644 index 0000000..4fe6fc4 --- /dev/null +++ b/upstream/container/control-plane/services/gateway/test/proxy.test.mjs @@ -0,0 +1,1142 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { createServer, request as httpRequest } from 'node:http' +import { connect as netConnect } from 'node:net' +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { parseTrustedHosts } from '../lib/config.mjs' +import { DshAvailability } from '../lib/availability.mjs' +import { + closeGatewayServer, + CLIENT_EVENT_PATH, + createGatewayServer as createGatewayServerBase, + HEALTH_PATH, + INTERNAL_AUTHORITY, + applyDshUpstreamCookie, + upstreamRequestHeaders, +} from '../lib/proxy.mjs' +const AUTHENTICATED_BROWSER = Object.freeze({ + status: async () => ({ state: 'initialized' }), + validateDsh: async () => ({ authenticated: true }), + validateManagement: async () => ({ authenticated: true }), + enterManagement: async () => { throw new Error('unexpected Management login') }, + handle: async () => false, +}) + +function browserForMode(mode) { + return Object.freeze({ + ...AUTHENTICATED_BROWSER, + status: async () => ({ + state: 'initialized', + account: { + managementAccess: { + mode, + version: 1, + isolatedEntry: mode === 'isolated' + ? { kind: 'public', managementPublicOrigin: 'http://127.0.0.1:3081' } + : null, + }, + }, + }), + authorizeManagement: async () => ({ authorized: true, capability: { token: 'test-capability' } }), + }) +} + +function createGatewayServer(options) { + return createGatewayServerBase({ + ...options, + browserAuthentication: options.browserAuthentication ?? AUTHENTICATED_BROWSER, + }) +} + +async function listen(server) { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + return server.address().port +} + +async function close(server) { + server.closeAllConnections?.() + await new Promise(resolve => server.close(resolve)) +} + +function request(port, path, headers = {}, method = 'GET', body) { + return new Promise((resolve, reject) => { + const outgoing = httpRequest({ hostname: '127.0.0.1', port, path, headers, method }, (response) => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => resolve({ + status: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + })) + }) + outgoing.once('error', reject) + outgoing.end(body) + }) +} + +async function withServers(callback, { polyfill = true, report } = {}) { + let upstreamRequests = 0 + const upstream = createServer((incoming, response) => { + upstreamRequests += 1 + if (incoming.url === '/html') { + const body = 'DSH' + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'content-length': String(Buffer.byteLength(body)), + etag: 'stale-after-injection', + 'last-modified': 'Mon, 17 Aug 2026 00:00:00 GMT', + }) + response.end(body) + return + } + if (incoming.url === '/stream') { + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write('data: first\n\n') + setTimeout(() => response.end('data: second\n\n'), 50) + return + } + if (incoming.url === '/delayed') { + setTimeout(() => response.end('delayed'), 50) + return + } + if (incoming.url === '/cookies') { + response.writeHead(200, { + 'set-cookie': [ + 'dsh_gateway_session=forged; Path=/', + 'dsh_management_compat_session=forged; Path=/', + 'dsh_application_cookie=preserved; Path=/', + ], + }) + response.end('cookies') + return + } + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ headers: incoming.headers, url: incoming.url })) + }) + const upstreamPort = await listen(upstream) + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }), + polyfill, + upstreamPort, + report, + }) + const gatewayPort = await listen(gateway) + try { + await callback({ upstream, upstreamPort, gateway, gatewayPort, requests: () => upstreamRequests }) + } finally { + await closeGatewayServer(gateway) + await close(upstream) + } +} + +test('client cancellation of a streamed response is not an upstream failure', async () => { + const reports = [] + await withServers(async ({ gatewayPort }) => { + await new Promise((resolve, reject) => { + const outgoing = httpRequest({ + hostname: '127.0.0.1', port: gatewayPort, path: '/stream', headers: { host: 'dsh.example' }, + }, response => { + response.once('data', () => { + response.destroy() + resolve() + }) + }) + outgoing.once('error', reject) + outgoing.end() + }) + await new Promise(resolve => setTimeout(resolve, 100)) + }, { report: (message, fields) => { reports.push({ message, fields }) } }) + assert.equal(reports.some(entry => entry.message === 'gateway.upstream-response.failed'), false) +}) + +test('client cancellation before response headers is not an upstream outage', async () => { + const reports = [] + await withServers(async ({ gatewayPort }) => { + await new Promise((resolve, reject) => { + const outgoing = httpRequest({ + hostname: '127.0.0.1', port: gatewayPort, path: '/delayed', headers: { host: 'dsh.example' }, + }) + outgoing.once('error', error => { + if (error.code === 'ECONNRESET') resolve() + else reject(error) + }) + outgoing.end() + setTimeout(() => outgoing.destroy(), 10) + }) + await new Promise(resolve => setTimeout(resolve, 100)) + }, { report: (message, fields) => { reports.push({ message, fields }) } }) + assert.equal(reports.some(entry => entry.message === 'gateway.upstream.failed'), false) +}) + +test('trusted HTTP requests reach upstream with loopback headers', async () => { + await withServers(async ({ gatewayPort }) => { + const response = await request(gatewayPort, '/echo?x=1', { + host: 'dsh.example', + origin: 'https://dsh.example', + forwarded: 'for=198.51.100.4;proto=https', + 'x-forwarded-for': '198.51.100.4', + 'x-forwarded-host': 'public.example', + 'x-forwarded-proto': 'https', + 'x-real-ip': '198.51.100.4', + }) + assert.equal(response.status, 200) + const payload = JSON.parse(response.body) + assert.equal(payload.url, '/echo?x=1') + assert.equal(payload.headers.host, INTERNAL_AUTHORITY) + assert.equal(payload.headers.origin, `http://${INTERNAL_AUTHORITY}`) + assert.equal(payload.headers['accept-encoding'], 'identity') + assert.equal(payload.headers.forwarded, undefined) + assert.equal(payload.headers['x-forwarded-for'], undefined) + assert.equal(payload.headers['x-forwarded-host'], undefined) + assert.equal(payload.headers['x-forwarded-proto'], undefined) + assert.equal(payload.headers['x-real-ip'], undefined) + }) +}) + +test('proxies DSH 0.1.5 combo Plugin resources without rewriting the URL', async () => { + const path = '/plugins/??@deepseek-ai/dsh-client-modules/client.js,@deepseek-ai/dsh-client-ui/client.js&rev=0123456789ab' + await withServers(async ({ gatewayPort }) => { + const response = await request(gatewayPort, path, { host: 'dsh.example' }) + assert.equal(response.status, 200) + assert.equal(JSON.parse(response.body).url, path) + }) +}) + +test('upstream headers remove connection tokens and gateway authorization', () => { + const headers = upstreamRequestHeaders({ + authorization: 'Basic c2VjcmV0', + connection: 'keep-alive, x-remove', + cookie: 'dsh_plugin_session=preserved', + host: 'dsh.example', + 'x-remove': 'hop-by-hop', + Forwarded: 'for=198.51.100.4', + 'X-Forwarded-Custom': 'external', + 'X-Real-IP': '198.51.100.4', + }) + assert.equal(headers.connection, undefined) + assert.equal(headers['x-remove'], undefined) + assert.equal(headers.authorization, undefined) + assert.equal(headers.cookie, 'dsh_plugin_session=preserved') + assert.equal(headers.Forwarded, undefined) + assert.equal(headers['X-Forwarded-Custom'], undefined) + assert.equal(headers['X-Real-IP'], undefined) + const management = upstreamRequestHeaders({ + forwarded: 'for=198.51.100.4', + 'x-forwarded-for': '198.51.100.4', + 'x-real-ip': '198.51.100.4', + }, { dsh: false }) + assert.equal(management.forwarded, 'for=198.51.100.4') + assert.equal(management['x-forwarded-for'], '198.51.100.4') + assert.equal(management['x-real-ip'], '198.51.100.4') +}) + +test('Gateway replaces browser-supplied DSH authentication with its internal cookie', () => { + const headers = { cookie: 'theme=dark; dsh-auth-forged=attacker; plugin=value' } + assert.deepEqual(applyDshUpstreamCookie(headers, 'dsh-auth-internal=signed'), { + cookie: 'theme=dark; plugin=value; dsh-auth-internal=signed', + }) +}) + +test('upstream responses cannot replace Gateway authentication cookies', async () => { + await withServers(async ({ gatewayPort }) => { + const response = await request(gatewayPort, '/cookies', { host: 'dsh.example' }) + assert.equal(response.status, 200) + assert.deepEqual(response.headers['set-cookie'], ['dsh_application_cookie=preserved; Path=/']) + }) +}) + +test('untrusted requests are rejected without reaching upstream', async () => { + await withServers(async ({ gatewayPort, requests }) => { + const response = await request(gatewayPort, '/', { host: 'evil.example' }) + assert.equal(response.status, 403) + assert.equal(requests(), 0) + }) +}) + +test('trust rejections report a bounded reason without request secrets', async () => { + const reports = [] + const upstream = createServer((_incoming, response) => response.end('unexpected')) + const upstreamPort = await listen(upstream) + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }), + upstreamPort, + report: (message, fields) => { reports.push({ message, fields }) }, + }) + const gatewayPort = await listen(gateway) + try { + const response = await request(gatewayPort, '/private?token=must-not-appear', { host: 'evil.example' }) + assert.equal(response.status, 403) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(reports[0].message, 'gateway.request.rejected') + assert.equal(reports[0].fields.reason, 'untrusted-host') + assert.equal(reports[0].fields.pathname, '/private') + assert.doesNotMatch(JSON.stringify(reports), /must-not-appear|token/) + } finally { + await closeGatewayServer(gateway) + await close(upstream) + } +}) + +test('health endpoint reports gateway readiness without touching upstream', async () => { + await withServers(async ({ gatewayPort, requests }) => { + const response = await request(gatewayPort, HEALTH_PATH, { host: '127.0.0.1' }) + assert.equal(response.status, 200) + assert.equal(response.body, 'ok\n') + assert.equal(requests(), 0) + }) +}) + +test('classifies, redacts, rate-limits, and closes an upstream outage', async () => { + const reservation = createServer() + const upstreamPort = await listen(reservation) + await close(reservation) + const reports = [] + let now = 1_000 + const availability = new DshAvailability({ now: () => now }) + availability.observe(true) + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({}), + upstreamPort, + probeIntervalMs: 1_000_000, + now: () => now, + availability, + report: (message, fields) => { reports.push({ message, fields }) }, + }) + const gatewayPort = await listen(gateway) + try { + await request(gatewayPort, '/private?token=must-not-appear', { host: '127.0.0.1' }) + await request(gatewayPort, '/second?secret=must-not-appear', { host: '127.0.0.1' }) + for (let attempt = 0; attempt < 20 && !reports.some(entry => entry.message === 'gateway.upstream.failed'); attempt += 1) { + await new Promise(resolve => setTimeout(resolve, 5)) + } + const failures = reports.filter(entry => entry.message === 'gateway.upstream.failed') + assert.equal(failures.length, 1) + assert.equal(failures[0].fields.upstream, 'dsh') + assert.equal(failures[0].fields.pathname, '/private') + assert.equal(failures[0].fields.error.code, 'ECONNREFUSED') + assert.doesNotMatch(JSON.stringify(reports), /must-not-appear|token|secret/) + + const upstream = createServer((_incoming, response) => response.end('ready')) + await new Promise((resolve, reject) => { + upstream.once('error', reject) + upstream.listen(upstreamPort, '127.0.0.1', resolve) + }) + try { + now += 5_000 + assert.equal((await request(gatewayPort, '/', { host: '127.0.0.1' })).status, 200) + await new Promise(resolve => setImmediate(resolve)) + const recovered = reports.find(entry => entry.message === 'gateway.upstream.recovered') + assert.equal(recovered.fields.upstream, 'dsh') + assert.equal(recovered.fields.suppressedCount, 1) + assert.equal(recovered.fields.outageMs, 5_000) + } finally { + await close(upstream) + } + } finally { + await closeGatewayServer(gateway) + } +}) + +test('serves trusted System Plugin bundles across DSH restart and uninstall races', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-gateway-system-plugin-')) + const packageRoot = join(root, 'settings-document-editor') + const bundle = Buffer.from('window.systemPluginLoaded = true\n') + const rev = createHash('sha1').update(bundle).digest('hex').slice(0, 12) + await mkdir(join(packageRoot, 'lib'), { recursive: true }) + await writeFile(join(packageRoot, 'package.json'), JSON.stringify({ + name: '@dsh-docker/settings-document-editor', + exports: { './client': './lib/client.bundle.js' }, + })) + await writeFile(join(packageRoot, 'lib/client.bundle.js'), bundle) + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({}), + upstreamPort: 1, + systemPluginRoot: root, + }) + const port = await listen(gateway) + try { + const response = await request(port, `/plugins/@dsh-docker/settings-document-editor/client.js?rev=${rev}`, { host: '127.0.0.1' }) + assert.equal(response.status, 200) + assert.equal(response.body, bundle.toString('utf8')) + assert.match(response.headers['content-type'], /text\/javascript/) + const stale = await request(port, '/plugins/@dsh-docker/settings-document-editor/client.js?rev=000000000000', { host: '127.0.0.1' }) + assert.notEqual(stale.status, 200) + } finally { + await closeGatewayServer(gateway) + } +}) + +test('holds third-party plugin bundles until a managed DSH restart completes', async () => { + const reservation = createServer() + await listen(reservation) + const upstreamPort = reservation.address().port + await close(reservation) + let lifecycle = 'restarting' + let ready = false + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({}), + upstreamPort, + platformStatus: async () => ({ dshLifecycle: { state: lifecycle } }), + probe: async () => ready, + pluginBundleHoldTimeoutMs: 2_000, + pluginBundlePollIntervalMs: 10, + }) + const gatewayPort = await listen(gateway) + const pending = [ + '/plugins/@example/plugin/client.js?rev=0123456789ab', + '/plugins/@dsh-docker/platform-management/client.js?rev=stale-image-revision', + ].map(path => request(gatewayPort, path, { host: '127.0.0.1' })) + let upstream + try { + await new Promise(resolve => setTimeout(resolve, 30)) + upstream = createServer((_incoming, response) => { + response.writeHead(200, { 'content-type': 'text/javascript' }) + response.end('window.pluginLoaded = true\n') + }) + await new Promise((resolve, reject) => { + upstream.once('error', reject) + upstream.listen(upstreamPort, '127.0.0.1', resolve) + }) + lifecycle = 'running' + ready = true + const responses = await Promise.all(pending) + assert.deepEqual(responses.map(response => response.status), [200, 200]) + assert.deepEqual(responses.map(response => response.body), [ + 'window.pluginLoaded = true\n', + 'window.pluginLoaded = true\n', + ]) + } finally { + await closeGatewayServer(gateway) + if (upstream?.listening) await close(upstream) + } +}) + +test('publishes plugin bundles only after the complete response is stable', async () => { + let requests = 0 + const upstream = createServer((_incoming, response) => { + requests += 1 + response.writeHead(200, { 'content-type': 'text/javascript' }) + if (requests === 1) { + response.write('window.partial = true\n') + response.destroy() + return + } + response.end('window.complete = true\n') + }) + const upstreamPort = await listen(upstream) + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({}), + upstreamPort, + platformStatus: async () => ({ current: { recordId: 'deployment-a' }, dshLifecycle: { state: 'running' } }), + probe: async () => true, + pluginBundleHoldTimeoutMs: 1_000, + pluginBundlePollIntervalMs: 10, + }) + const gatewayPort = await listen(gateway) + try { + const response = await request(gatewayPort, '/plugins/example/client.js?rev=complete', { host: '127.0.0.1' }) + assert.equal(response.status, 200) + assert.equal(response.body, 'window.complete = true\n') + assert.equal(requests, 2) + } finally { + await closeGatewayServer(gateway) + await close(upstream) + } +}) + +test('reuses a complete plugin bundle only within the same Deployment', async () => { + let body = 'window.deployment = "a"\n' + let status = 200 + const upstream = createServer((_incoming, response) => { + response.writeHead(status, { 'content-type': status === 200 ? 'text/javascript' : 'text/plain' }) + response.end(status === 200 ? body : 'temporarily unavailable\n') + }) + const upstreamPort = await listen(upstream) + let recordId = 'deployment-a' + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({}), + upstreamPort, + platformStatus: async () => ({ current: { recordId }, dshLifecycle: { state: 'running' } }), + probe: async () => true, + }) + const gatewayPort = await listen(gateway) + const path = '/plugins/example/client.js?rev=stable' + try { + assert.equal((await request(gatewayPort, path, { host: '127.0.0.1' })).body, body) + status = 503 + assert.equal((await request(gatewayPort, path, { host: '127.0.0.1' })).body, body) + + recordId = 'deployment-b' + body = 'window.deployment = "b"\n' + status = 200 + assert.equal((await request(gatewayPort, path, { host: '127.0.0.1' })).body, body) + } finally { + await closeGatewayServer(gateway) + await close(upstream) + } +}) + +test('returns a holding module instead of an import failure after a recent restart', async () => { + const upstream = createServer((_incoming, response) => { + response.writeHead(503, { 'content-type': 'text/plain' }) + response.end('temporarily unavailable\n') + }) + const upstreamPort = await listen(upstream) + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({}), + upstreamPort, + platformStatus: async () => ({ + current: { recordId: 'deployment-a' }, + dshLifecycle: { state: 'running', taskId: 'restart-1', updatedAt: new Date().toISOString() }, + }), + probe: async () => true, + pluginBundleHoldTimeoutMs: 40, + pluginBundlePollIntervalMs: 5, + }) + const gatewayPort = await listen(gateway) + try { + const response = await request(gatewayPort, '/plugins/example/client.js?rev=missing', { + host: '127.0.0.1', + referer: 'http://127.0.0.1/sessions/current?tab=plugins', + }) + assert.equal(response.status, 200) + assert.match(response.headers['content-type'], /text\/javascript/) + assert.match(response.body, /\/_dsh_gateway\/wait\?return=/) + assert.match(response.body, /new Promise/) + } finally { + await closeGatewayServer(gateway) + await close(upstream) + } +}) + +test('grants two cold-start plugin recovery attempts without a lifecycle task', async () => { + const upstream = createServer((_incoming, response) => { + response.writeHead(503, { 'content-type': 'text/plain' }) + response.end('temporarily unavailable\n') + }) + const upstreamPort = await listen(upstream) + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({}), + upstreamPort, + platformStatus: async () => ({ + current: { recordId: 'fresh-deployment' }, + dshLifecycle: { state: 'running', taskId: null, updatedAt: new Date().toISOString() }, + }), + probe: async () => true, + pluginBundleHoldTimeoutMs: 40, + pluginBundlePollIntervalMs: 5, + }) + const gatewayPort = await listen(gateway) + const path = '/plugins/@deepseek-ai/dsh-client-ui-workflow-run/client.js?rev=fresh' + try { + const first = await request(gatewayPort, path, { host: '127.0.0.1', referer: 'http://127.0.0.1/' }) + assert.equal(first.status, 200) + assert.match(first.body, /\/_dsh_gateway\/wait\?return=/) + const second = await request(gatewayPort, path, { host: '127.0.0.1', referer: 'http://127.0.0.1/' }) + assert.equal(second.status, 200) + assert.match(second.body, /\/_dsh_gateway\/wait\?return=/) + const third = await request(gatewayPort, path, { host: '127.0.0.1', referer: 'http://127.0.0.1/' }) + assert.equal(third.status, 503) + } finally { + await closeGatewayServer(gateway) + await close(upstream) + } +}) + +test('records bounded browser plugin recovery events without request credentials', async () => { + const reports = [] + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({}), + upstreamPort: 1, + report: (message, fields) => { reports.push({ message, fields }) }, + }) + const port = await listen(gateway) + try { + const response = await request(port, CLIENT_EVENT_PATH, { + host: '127.0.0.1', + authorization: 'Basic must-not-appear', + 'content-type': 'application/json', + }, 'POST', JSON.stringify({ + event: 'browser.plugin-load.failed', + level: 'warning', + pluginId: '@deepseek-ai/dsh-typert-registry', + revision: 'f41d56e0b747', + pathname: '/sessions/current', + lifecycleState: 'restarting', + lifecycleTaskId: 'task-1', + recoveryAttempt: 1, + reason: 'HTTP 503', + })) + assert.equal(response.status, 204) + assert.deepEqual(reports, [{ + message: 'browser.plugin-load.failed', + fields: { + level: 'warning', + pluginId: '@deepseek-ai/dsh-typert-registry', + revision: 'f41d56e0b747', + pathname: '/sessions/current', + lifecycleState: 'restarting', + lifecycleTaskId: 'task-1', + recoveryAttempt: 1, + reason: 'HTTP 503', + }, + }]) + assert.doesNotMatch(JSON.stringify(reports), /must-not-appear/) + } finally { + await closeGatewayServer(gateway) + } +}) + +test('bounded management and Console requests use the protected local socket instead of DSH upstream', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-gateway-management-')) + const socketPath = join(root, 'management.sock') + const maintenanceSocketPath = join(root, 'maintenance.sock') + const management = createServer((incoming, response) => { + response.writeHead(202, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + method: incoming.method, + path: incoming.url, + forwarded: incoming.headers.forwarded, + forwardedFor: incoming.headers['x-forwarded-for'], + realIp: incoming.headers['x-real-ip'], + })) + }) + await new Promise((resolve, reject) => { + management.once('error', reject) + management.listen(socketPath, resolve) + }) + const maintenance = createServer((incoming, response) => { + response.writeHead(203, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ method: incoming.method, path: incoming.url, privileged: true })) + }) + await new Promise((resolve, reject) => { + maintenance.once('error', reject) + maintenance.listen(maintenanceSocketPath, resolve) + }) + let upstreamRequests = 0 + const upstream = createServer((_incoming, response) => { upstreamRequests += 1; response.end('dsh') }) + const upstreamPort = await listen(upstream) + const gateway = createGatewayServer({ + trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }), + managementSocketPath: socketPath, + maintenanceSocketPath, + browserAuthentication: AUTHENTICATED_BROWSER, + upstreamPort, + }) + const gatewayPort = await listen(gateway) + try { + const result = await request(gatewayPort, '/_dsh_platform/api/v1/status', { + host: 'dsh.example', + forwarded: 'for=198.51.100.4;proto=https', + 'x-forwarded-for': '198.51.100.4', + 'x-real-ip': '198.51.100.4', + }) + assert.equal(result.status, 202) + assert.deepEqual(JSON.parse(result.body), { + method: 'GET', + path: '/_dsh_platform/api/v1/status', + forwarded: 'for=198.51.100.4;proto=https', + forwardedFor: '198.51.100.4', + realIp: '198.51.100.4', + }) + assert.equal(upstreamRequests, 0) + for (const [method, path] of [ + ['GET', '/_dsh_platform/api/v1/rollback-plan'], + ['GET', '/_dsh_platform/api/v1/bundled-plugins'], + ['GET', '/_dsh_platform/api/v1/bundled-plugins/task/123e4567-e89b-42d3-a456-426614174000'], + ['GET', '/_dsh_platform/api/v1/system-skills'], + ['GET', '/_dsh_platform/api/v1/user-skills'], + ['GET', '/_dsh_platform/api/v1/settings-document'], + ['GET', '/_dsh_platform/api/v1/user-plugins'], + ['GET', '/_dsh_platform/api/v1/proxy'], + ['GET', '/_dsh_platform/api/v1/proxy/provider-inventory'], + ['GET', '/_dsh_platform/api/v1/proxy/test/tasks/123e4567-e89b-42d3-a456-426614174000'], + ['DELETE', '/_dsh_platform/api/v1/proxy/test/tasks/123e4567-e89b-42d3-a456-426614174000'], + ['GET', '/_dsh_platform/api/v1/files/config'], + ['GET', '/_dsh_platform/api/v1/files/list'], + ['GET', '/_dsh_platform/api/v1/files/stat'], + ['GET', '/_dsh_platform/api/v1/files/content'], + ['GET', '/_dsh_platform/api/v1/files/download'], + ['GET', '/_dsh_platform/api/v1/files/tasks'], + ['GET', '/_dsh_platform/api/v1/files/tasks/123e4567-e89b-42d3-a456-426614174000'], + ['DELETE', '/_dsh_platform/api/v1/files/tasks/123e4567-e89b-42d3-a456-426614174000'], + ['GET', '/_dsh_platform/api/v1/user-plugins/task/123e4567-e89b-42d3-a456-426614174000'], + ['GET', '/_dsh_platform/api/v1/terminal/sessions/123e4567-e89b-42d3-a456-426614174000'], + ['DELETE', '/_dsh_platform/api/v1/terminal/sessions/123e4567-e89b-42d3-a456-426614174000'], + ['POST', '/_dsh_platform/api/v1/holds/retry'], + ['POST', '/_dsh_platform/api/v1/rollback'], + ['POST', '/_dsh_platform/api/v1/return-stable'], + ['POST', '/_dsh_platform/api/v1/start-dsh'], + ['POST', '/_dsh_platform/api/v1/stop-dsh'], + ['POST', '/_dsh_platform/api/v1/restart-dsh'], + ['POST', '/_dsh_platform/api/v1/runtime/reset'], + ['POST', '/_dsh_platform/api/v1/auth-totp/disable-confirmations'], + ['POST', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/confirm'], + ['POST', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/cancel'], + ['POST', '/_dsh_platform/api/v1/bundled-plugins/action'], + ['POST', '/_dsh_platform/api/v1/bundled-plugins/toggle'], + ['POST', '/_dsh_platform/api/v1/bundled-plugins/recovery-action'], + ['POST', '/_dsh_platform/api/v1/system-skills/action'], + ['POST', '/_dsh_platform/api/v1/user-skills/action'], + ['POST', '/_dsh_platform/api/v1/user-plugins/apply'], + ['POST', '/_dsh_platform/api/v1/terminal/sessions'], + ['POST', '/_dsh_platform/api/v1/files/upload'], + ['POST', '/_dsh_platform/api/v1/files/tasks'], + ['POST', '/_dsh_platform/api/v1/proxy/test'], + ['PUT', '/_dsh_platform/api/v1/channel'], + ['PUT', '/_dsh_platform/api/v1/automatic-check'], + ['PUT', '/_dsh_platform/api/v1/proxy'], + ['PUT', '/_dsh_platform/api/v1/settings-document'], + ['PUT', '/_dsh_platform/api/v1/files/content'], + ['GET', '/_dsh_platform/console/'], + ['HEAD', '/_dsh_platform/console/style.css'], + ]) { + const proxied = await request(gatewayPort, path, { host: 'dsh.example' }, method) + const maintenanceRoute = path.startsWith('/_dsh_platform/api/v1/files/') + || path.startsWith('/_dsh_platform/api/v1/terminal/sessions') + assert.equal(proxied.status, maintenanceRoute ? 203 : 202, `${method} ${path}`) + if (method !== 'HEAD') assert.equal(JSON.parse(proxied.body).path, path) + } + assert.equal((await request(gatewayPort, '/_dsh_platform/api/v1/trust/reset', { host: 'dsh.example' }, 'POST')).status, 404) + assert.equal((await request(gatewayPort, '/_dsh_platform/api/v1/user-plugins/task/not-a-uuid', { host: 'dsh.example' })).status, 404) + assert.equal((await request(gatewayPort, '/_dsh_platform/api/v1/bundled-plugins/task/not-a-uuid', { host: 'dsh.example' })).status, 404) + assert.equal((await request(gatewayPort, '/_dsh_platform/api/v1/terminal/sessions/not-a-uuid', { host: 'dsh.example' })).status, 404) + assert.equal((await request(gatewayPort, '/_dsh_platform/api/v1/files/tasks/not-a-uuid', { host: 'dsh.example' })).status, 404) + assert.equal((await request(gatewayPort, '/_dsh_platform/api/v1/proxy/test/tasks/not-a-uuid', { host: 'dsh.example' })).status, 404) + assert.equal((await request(gatewayPort, '/_dsh_platform/console/app.js', { host: 'dsh.example' }, 'POST')).status, 404) + assert.equal(upstreamRequests, 0) + } finally { + await Promise.all([closeGatewayServer(gateway), close(upstream), close(management), close(maintenance)]) + } +}) + +test('HTML responses receive a guarded polyfill and updated metadata', async () => { + await withServers(async ({ gatewayPort }) => { + const response = await request(gatewayPort, '/html', { host: 'dsh.example' }) + assert.equal(response.status, 200) + assert.match(response.body, / + DSH Management Console + + + +
+
+
+ +
DSH Management ConsoleStandalone console
+
+
+ + + +
+ Reconnecting +
+
+
+
+ +
+
+ +
+ + + + + + + + + +
+
+
+

DSH lifecycle

Start, stop, or restart DSH while the container and management console remain running.

+
+
+ +
+ +
+
+

Reset runtime

Rebuild the runtime from verified DSH files and current platform patches without deleting user data.

+ +
+ + + +
+ +
+
+

Live logs

View runtime logs from DSH and platform modules.

+
Connecting
+
+
+

+
+
+ +
+

No logs yet

+
+
+ + + + + + + + + + + + +
+ +

Restore Stable state

The following data snapshot will be restored and newer data will be lost: -

+

Restart DSH?

The current DSH connection will be interrupted briefly. This console remains available.

+

Stop DSH?

DSH remains stopped until it is started here or the container restarts. The management console remains available.

+ +
+

Destination already exists

+

Choose how to handle:

+
+ + + +
+ +
+
+
+

Edit file

+

+

+

Proxy scope guide

How managed network sources are classified.

+

+

Outbound proxy component unavailable

The DSH Docker outbound proxy component is unavailable. Settings can still be saved, but managed proxy traffic and connection tests may fail. This does not indicate whether the configured external proxy is reachable.

+

Built-in direct rules

These platform-managed local destinations are always direct and do not need to be entered again.

+

Connection test

Tests the form values without saving or activating them.

    +

    Set up two-factor authentication

    Scan the QR code with an authenticator app, then enter its current code.

    +

    Management isolation comparison

    Both modes require authentication and final-execution authorization. A separate origin additionally isolates the full Management session from DSH client plugins.

    CompatibilitySeparate originSeparate DSH and Management sessionsYesYesProtect from same-origin DSH client pluginsNoYesFinal-execution capabilitiesYesYesProtection after DSH obtains container RootNoNo
    +

    Enable separate Management origin?

    Publish container port 3081 or route a separate address to it. The platform verifies the candidate before changing the current mode.

    + + + + diff --git a/upstream/container/control-plane/services/management/public/style.css b/upstream/container/control-plane/services/management/public/style.css new file mode 100644 index 0000000..75cc3db --- /dev/null +++ b/upstream/container/control-plane/services/management/public/style.css @@ -0,0 +1,1062 @@ +:root { + color-scheme: light dark; + --bg: #f7f7f8; + --surface: #ffffff; + --surface-raised: #f2f2f3; + --text: #202124; + --muted: #6f7177; + --faint: #92949a; + --border: #dedfe2; + --hover: #ececee; + --accent: #3d72d9; + --success: #16a167; + --warning: #b87200; + --danger: #cc3d45; + --button-text: #ffffff; + font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } +html { min-width: 320px; height: 100%; overflow: hidden; background: var(--bg); color: var(--text); } +body { position: fixed; inset: 0; width: 100%; height: 100dvh; margin: 0; overflow: hidden; } +button, input, select { font: inherit; } +button { letter-spacing: 0; } +input:not([type="checkbox"]):not([type="radio"]):not([type="file"]), select, textarea { + min-height: 34px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + background: var(--surface); + font-size: 16px; + line-height: 22px; +} + +.topbar { + position: sticky; + top: 0; + z-index: 30; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 94%, transparent); + backdrop-filter: blur(10px); +} +.topbar-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + width: min(1180px, calc(100% - 48px)); + min-height: 64px; + margin: 0 auto; +} +.brand { display: flex; align-items: center; gap: 11px; min-width: 0; } +.brand-mark { + display: grid; + place-items: center; + width: 34px; + height: 34px; + flex: none; + border-radius: 7px; + background: var(--text); + color: var(--surface); + font-size: 11px; + font-weight: 700; +} +.brand strong, .brand span { display: block; } +.brand strong { font-size: 14px; line-height: 20px; font-weight: 600; } +.brand div > span { color: var(--muted); font-size: 11px; line-height: 16px; } +.topbar-actions { display: flex; align-items: center; gap: 12px; flex: none; } +.topbar-actions > .return-dsh { width: auto; min-width: 28px; height: 28px; min-height: 28px; padding: 3px 9px; border-radius: 6px; font-size: 11px; line-height: 20px; } +.return-dsh svg { display: none; width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.language-switch { height: 28px; padding: 0 26px 0 9px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background-color: transparent; font: inherit; font-size: 11px; cursor: pointer; } +.language-switch:hover { background: var(--hover); } +.language-switch-wrapper .custom-select-trigger { height: 28px; min-height: 28px; padding: 3px 9px; font-size: 11px; line-height: 18px; } +.theme-switch { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; appearance: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background: transparent; cursor: pointer; } +.theme-switch:hover { background: var(--hover); } +.theme-icon { display: none; width: 16px; height: 16px; } +.theme-icon svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; } +.theme-switch[data-theme-preference="system"] [data-theme-icon="system"], +.theme-switch[data-theme-preference="light"] [data-theme-icon="light"], +.theme-switch[data-theme-preference="dark"] [data-theme-icon="dark"] { display: block; } + +.auth-settings-form { display: grid; width: 100%; gap: 16px; } +.auth-settings-form > label, .auth-subsection > label:not(.setting-row), .auth-additional > label { display: grid; gap: 7px; } +.auth-settings-form > label > span, .auth-subsection > label:not(.setting-row) > span, .auth-additional > label > span, .auth-password-grid label > span { color: var(--text); font-size: 13px; font-weight: 400; line-height: 20px; } +.auth-settings-form input, .auth-settings-form select { height: 34px; min-height: 34px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background: var(--surface); font-size: 16px; line-height: 22px; } +.auth-subsection { display: grid; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 8px; } +.auth-subsection h3 { margin: 0; color: var(--text); font-size: 16px; font-weight: 600; line-height: 22px; } +.auth-additional { display: grid; gap: 12px; margin-top: 2px; padding-top: 14px; border-top: 1px solid var(--border); } +.auth-two-factor { margin-top: 2px; padding-top: 14px; border-top: 1px solid var(--border); } +.auth-additional-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.auth-additional-heading > .toggle { width: 34px; height: 20px; margin-left: auto; justify-content: flex-start; } +.auth-totp-control { display: flex; align-items: center; gap: 9px; margin-left: auto; } +.auth-totp-control small { padding: 2px 7px; border-radius: 999px; color: var(--warning); background: color-mix(in srgb, var(--warning) 12%, transparent); font-size: 11px; font-weight: 600; line-height: 16px; } +.auth-totp-control .toggle { width: 34px; height: 20px; justify-content: flex-start; } +.auth-subsection-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.auth-subsection-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; } +.auth-origin-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 10px; } +.auth-origin-field label { display: grid; gap: 7px; } +.auth-security-note { margin: 0; color: var(--muted); font-size: 12px; line-height: 18px; } +.auth-password-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 12px; } +.auth-password-grid label { display: grid; gap: 7px; } +.field-error { color: var(--danger); font-size: 12px; font-weight: 400; } +.auth-session-list { overflow: hidden; border: 1px solid var(--border); border-radius: 7px; } +.auth-session-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px 16px; padding: 12px; } +.auth-session-row + .auth-session-row { border-top: 1px solid var(--border); } +.auth-session-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; min-width: 0; } +.auth-session-device { overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.auth-session-current, .auth-session-management { padding: 2px 7px; border-radius: 999px; background: var(--hover); color: var(--muted); font-size: 11px; line-height: 16px; } +.auth-session-current { color: var(--success); } +.auth-session-meta { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 6px; color: var(--muted); font-size: 12px; line-height: 18px; } +.auth-session-revoke { align-self: center; gap: 7px; color: var(--danger); } +.auth-session-revoke svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.auth-comparison-table { display: grid; grid-template-columns: minmax(0, 1.6fr) repeat(2, minmax(80px, .7fr)); margin-top: 14px; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; font-size: 12px; line-height: 18px; } +.auth-comparison-table > * { padding: 8px 10px; border-bottom: 1px solid var(--border); } +.auth-comparison-table > :nth-child(3n + 2), .auth-comparison-table > :nth-child(3n + 3) { border-left: 1px solid var(--border); text-align: center; } +.auth-comparison-table > :nth-last-child(-n + 3) { border-bottom: 0; } +.auth-totp-dialog { width: min(420px, calc(100% - 32px)); } +.auth-totp-dialog form { display: grid; gap: 14px; } +.auth-totp-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.auth-totp-dialog p { margin: -4px 0 0; } +.auth-totp-dialog img { display: block; width: min(240px, 100%); aspect-ratio: 1; margin: 0 auto; border-radius: 6px; background: #fff; } +.auth-totp-dialog label { display: grid; gap: 7px; } +.auth-totp-dialog input { width: 100%; min-height: 38px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background: var(--surface); } +.auth-totp-dialog .icon-button { flex: 0 0 32px; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 6px; color: var(--muted); background: transparent; font-size: 22px; line-height: 32px; } +.auth-totp-dialog .icon-button:hover { color: var(--text); background: var(--hover); } +.auth-totp-secret { padding: 9px 10px; border: 1px solid var(--border); border-radius: 6px; overflow-wrap: anywhere; text-align: center; user-select: all; } + +.connection, .log-connection { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 12px; + line-height: 18px; + white-space: nowrap; +} +.connection > span, .log-connection > span { + width: 7px; + height: 7px; + flex: none; + border-radius: 50%; + background: var(--warning); +} +.connection[data-state="online"] > span, .log-connection[data-state="live"] > span { background: var(--success); } +.connection[data-state="offline"] > span, .log-connection[data-state="disconnected"] > span { background: var(--danger); } +.connection strong, .log-connection strong { font-weight: 400; } + +main { + display: flex; + flex-direction: column; + width: min(1180px, calc(100% - 48px)); + height: calc(100dvh - 64px); + margin: 0 auto; + overflow: hidden; +} +.management-navigation { z-index: 20; width: 100%; min-width: 0; max-width: 100%; flex: none; background: var(--bg); } + +.tabs { + display: flex; + align-items: flex-end; + gap: 24px; + width: 100%; + min-width: 0; + max-width: 100%; + overflow-x: auto; + border-bottom: 1px solid var(--border); + scrollbar-width: none; + overscroll-behavior-inline: contain; + scroll-snap-type: inline proximity; + touch-action: pan-x; + white-space: nowrap; +} +.tabs::-webkit-scrollbar { display: none; } +.tabs button { + position: relative; + min-height: 38px; + padding: 7px 1px 9px; + flex: none; + scroll-snap-align: start; + border: 0; + background: transparent; + color: var(--muted); + font-size: 13px; + cursor: pointer; +} +.tabs button:hover, .tabs button[aria-selected="true"] { color: var(--text); } +.tabs button[aria-selected="true"]::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + background: var(--text); + content: ""; +} + +@media (hover: hover) and (pointer: fine) { + .tabs { scroll-snap-type: none; } +} + +.tab-panel { + min-width: 0; + min-height: 0; + flex: 1; + padding: 2px 8px 56px 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +.resource-panel { padding-bottom: 0; overflow-x: hidden; overflow-y: auto; overflow-anchor: none; } +.resource-panel > section { height: 100%; min-height: 0; overflow: visible; } +.resource-panel .resource-section-heading, +.resource-panel .recovery-summary, +.resource-panel .plugin-restart-notice, +.resource-panel .empty-state, +.resource-panel .list-pagination, +.resource-panel .operation-result { flex: none; } +section { + display: flex; + flex-direction: column; + gap: 12px; + padding: 24px 0; + border-bottom: 1px solid var(--border); +} +.tab-panel section:last-child { border-bottom: 0; } +.section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} +.section-heading h2 { margin: 0; font-size: 16px; line-height: 24px; font-weight: 500; letter-spacing: 0; } +.section-heading p { margin: 2px 0 0; color: var(--muted); font-size: 13px; line-height: 20px; } +.proxy-title-line { display: flex; align-items: center; gap: 8px; } +.proxy-save-state { padding: 2px 7px; border-radius: 999px; font-size: 11px; line-height: 17px; } +.proxy-save-state[data-state="unsaved"] { color: var(--warning); background: color-mix(in srgb, var(--warning) 12%, transparent); } +.proxy-save-state[data-state="saved"] { color: var(--success); background: color-mix(in srgb, var(--success) 12%, transparent); } +.proxy-component-alert { display: inline-grid; width: 22px; height: 22px; padding: 2px; place-items: center; border: 0; background: transparent; } +.proxy-component-alert:hover { background: color-mix(in srgb, var(--danger) 10%, transparent); } +.proxy-component-alert svg { width: 17px; height: 17px; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.proxy-component-alert circle { fill: var(--danger); stroke: var(--danger); } +.proxy-component-alert path { stroke: #fff; stroke-width: 2.8; } +.lifecycle-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } + +button.primary, button.secondary, button.danger, button.danger-text, button.compact { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 6px 14px; + border: 0; + border-radius: 18px; + color: var(--text); + background: transparent; + font-size: 13px; + line-height: 20px; + cursor: pointer; +} +button.primary { background: var(--text); color: var(--surface); } +button.secondary { border: 1px solid var(--border); } +button.danger { background: var(--danger); color: var(--button-text); } +button.danger-text { color: var(--danger); } +button.compact { height: 34px; min-height: 34px; padding: 3px 9px; border: 1px solid var(--border); border-radius: 6px; font-size: 11px; } +button:hover:not(:disabled) { background: var(--hover); } +button.primary:hover:not(:disabled) { opacity: .86; background: var(--text); } +button.danger:hover:not(:disabled) { opacity: .9; background: var(--danger); } +button:disabled { opacity: .42; cursor: default; } +button:focus-visible, input:focus-visible, select:focus-visible, .log-list:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +input:not([type="checkbox"]):focus-visible, select:focus-visible, textarea:focus-visible { + border-color: var(--text); + outline: none; +} + +.file-section { gap: 14px; } +.file-heading-actions, .file-selection > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +.file-create-panel { display: grid; grid-template-columns: auto minmax(180px, 1fr) auto; align-items: end; gap: 8px 12px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-raised); } +.file-create-panel[hidden] { display: none; } +.file-create-kind { align-self: end; } +.file-create-name { display: grid; gap: 4px; min-width: 0; color: var(--muted); font-size: 11px; line-height: 16px; } +.file-create-name input { width: 100%; min-width: 0; height: 36px; padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background: var(--surface); font: 12px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.file-create-actions { display: flex; gap: 8px; } +.file-create-location { grid-column: 1 / -1; margin: 0; color: var(--faint); font: 11px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } +.file-create-error { grid-column: 1 / -1; margin: -4px 0 0; color: var(--danger); font-size: 12px; line-height: 18px; } +.file-pathbar { display: grid; grid-template-columns: repeat(3, 34px) minmax(0, 1fr) auto; gap: 7px; } +.file-pathbar .file-nav-button { display: grid; place-items: center; width: 34px; min-width: 34px; height: 34px; min-height: 34px; padding: 0; border-radius: 6px; } +.file-nav-icon { width: 8px; height: 14px; background: currentColor; clip-path: polygon(0 0, 25% 0, 100% 50%, 25% 100%, 0 100%, 72% 50%); transform-origin: center; } +.file-nav-icon.back { transform: rotate(180deg); } +.file-nav-icon.forward { transform: none; } +.file-nav-icon.up { transform: rotate(-90deg); } +.file-pathbar input, .file-searchbar input { + min-width: 0; + height: 34px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + background: var(--surface-raised); +} +.file-searchbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 8px; } +.file-searchbar #file-search-recursive { height: 34px; min-height: 34px; } +.file-searchbar label { display: inline-flex; min-height: 34px; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; line-height: 18px; white-space: nowrap; } +.file-searchbar label input { flex: 0 0 auto; margin: 0; } +.file-task-state { display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; color: var(--muted); background: var(--surface-raised); font-size: 12px; } +.file-task-heading { display: flex; justify-content: space-between; gap: 12px; } +.file-task-heading strong { color: var(--text); font-weight: 500; } +.file-task-list { display: grid; gap: 7px; max-height: 180px; overflow-y: auto; overscroll-behavior: contain; padding-right: 4px; scrollbar-gutter: stable; } +.file-task-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 10px; align-items: center; } +.file-task-row p { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-task-row progress { width: 100%; height: 6px; accent-color: var(--accent); } +.file-task-row small { color: var(--faint); } +.file-task-row.failed small { color: var(--danger); } +.file-archive-panel { display: grid; grid-template-columns: minmax(0, 1fr) minmax(120px, 160px) minmax(180px, 1fr) auto; gap: 10px; align-items: end; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-raised); } +.file-archive-panel[hidden] { display: none; } +.file-archive-panel h3, .file-archive-panel p { margin: 0; } +.file-archive-panel h3 { color: var(--text); font-size: 13px; } +.file-archive-panel p { color: var(--faint); font-size: 11px; overflow-wrap: anywhere; } +.file-archive-panel label { display: grid; gap: 4px; color: var(--muted); font-size: 11px; } +.file-archive-panel input, .file-archive-panel select { width: 100%; min-width: 0; } +.file-archive-actions { display: flex; gap: 8px; } +.file-browser { display: grid; grid-template-columns: 132px minmax(0, 1fr); width: 100%; height: auto; max-height: min(760px, 78dvh); aspect-ratio: 16 / 9; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); } +.file-shortcuts { padding: 8px; border-right: 1px solid var(--border); background: var(--surface-raised); } +.file-shortcuts button { display: block; width: 100%; padding: 7px 8px; overflow: hidden; border: 0; border-radius: 5px; color: var(--muted); background: transparent; font: 11px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-align: left; text-overflow: ellipsis; cursor: pointer; } +.file-shortcuts button:hover { color: var(--text); background: var(--hover); } +.file-main { position: relative; display: flex; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: auto; flex-direction: column; } +.file-drop-overlay { position: absolute; inset: 8px; z-index: 3; display: none; place-content: center; gap: 4px; border: 2px dashed var(--accent); border-radius: 7px; color: var(--text); background: color-mix(in srgb, var(--surface) 92%, transparent); text-align: center; pointer-events: none; } +.file-drop-overlay strong { font-size: 14px; font-weight: 600; } +.file-drop-overlay span { color: var(--muted); font-size: 11px; } +.file-main.file-dragging .file-drop-overlay { display: grid; } +.file-table thead { position: sticky; top: 0; z-index: 1; } +.file-table { width: 100%; min-width: 600px; border-collapse: collapse; table-layout: fixed; } +.file-table th, .file-table td { height: 38px; padding: 7px 9px; overflow: hidden; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 11px; line-height: 18px; text-align: left; text-overflow: ellipsis; white-space: nowrap; } +.file-table th { height: 34px; background: var(--surface-raised); font-weight: 400; } +.file-table th button { display: inline-flex; align-items: center; gap: 5px; padding: 0; border: 0; color: inherit; background: transparent; font: inherit; cursor: pointer; } +.file-sort-arrows { position: relative; width: 8px; height: 12px; flex: 0 0 8px; } +.file-sort-arrows::before, .file-sort-arrows::after { position: absolute; left: 1px; width: 0; height: 0; border-right: 3px solid transparent; border-left: 3px solid transparent; content: ''; opacity: .32; } +.file-sort-arrows::before { top: 1px; border-bottom: 4px solid currentColor; } +.file-sort-arrows::after { bottom: 1px; border-top: 4px solid currentColor; } +.file-table th button[data-sort-order='asc'] .file-sort-arrows::before, +.file-table th button[data-sort-order='desc'] .file-sort-arrows::after { color: var(--accent); opacity: 1; } +.file-table th:nth-child(2) { width: 31%; } +.file-table th:nth-child(3) { width: 12%; } +.file-table th:nth-child(4) { width: 18%; } +.file-table th:nth-child(5) { width: 24%; } +.file-table th:nth-child(6) { width: 9%; } +.file-table .file-check { width: 34px; padding-right: 2px; text-align: center; } +.file-table tbody tr { cursor: default; } +.file-table tbody tr:hover, .file-table tbody tr.selected { background: var(--hover); } +.file-table td:nth-child(2) { color: var(--text); } +.file-table td strong { font-weight: 500; } +.file-type-mark { display: inline-block; width: 20px; color: var(--accent); text-align: center; } +.file-size-action { padding: 0; border: 0; color: var(--accent); background: transparent; font: inherit; cursor: pointer; } +.file-size-action:hover { text-decoration: underline; } +.file-size-action:disabled { color: var(--faint); cursor: wait; text-decoration: none; } +.file-mobile-meta { display: none; } +.file-pagination { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: flex-end; gap: 12px; min-height: 46px; margin-top: auto; padding: 7px 10px; border-top: 1px solid var(--border); background: var(--surface); color: var(--muted); font-size: 12px; } +.file-pagination label, .file-pagination > div { display: flex; align-items: center; gap: 7px; } +.file-pagination select { min-width: 70px; min-height: 32px; padding: 4px 24px 4px 8px; } +.file-selection { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 34px; color: var(--muted); font-size: 12px; } +.file-selection .danger-text { border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); } +.file-attributes-panel { display: grid; gap: 14px; padding: 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-raised); } +.file-attributes-panel[hidden] { display: none; } +.file-attributes-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.file-attributes-heading h3 { margin: 0; color: var(--text); font-size: 14px; line-height: 22px; } +.file-attributes-heading p { margin: 2px 0 0; color: var(--faint); font: 11px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } +.file-permission-grid { display: grid; grid-template-columns: minmax(88px, 1fr) repeat(3, minmax(54px, 74px)); align-items: center; gap: 9px 16px; width: min(440px, 100%); } +.file-permission-grid strong { color: var(--muted); font-size: 11px; font-weight: 500; text-align: center; } +.file-permission-grid > span { color: var(--muted); font-size: 12px; } +.file-permission-grid input { justify-self: center; margin: 0; accent-color: var(--accent); } +.file-attribute-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.file-attribute-fields label { display: grid; gap: 5px; color: var(--muted); font-size: 11px; } +.file-attribute-fields input { width: 100%; } +.file-attributes-recursive { display: flex; align-items: center; gap: 7px; color: var(--text); font-size: 12px; } +.file-attributes-recursive input { margin: 0; accent-color: var(--accent); } +.file-attributes-actions { display: flex; justify-content: flex-end; gap: 8px; } +.file-conflict-dialog p strong { overflow-wrap: anywhere; color: var(--text); font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.file-conflict-options { display: grid; gap: 8px; margin: 14px 0; } +.file-conflict-options label { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 10px 11px; border: 1px solid var(--border); border-radius: 7px; cursor: pointer; } +.file-conflict-options label:hover, .file-conflict-options label:has(input:checked) { background: var(--hover); } +.file-conflict-options input { margin: 4px 0 0; accent-color: var(--accent); } +.file-conflict-options strong, .file-conflict-options small { display: block; } +.file-conflict-options strong { font-size: 13px; line-height: 20px; } +.file-conflict-options small { margin-top: 1px; color: var(--muted); font-size: 11px; line-height: 17px; } +.file-conflict-dialog .confirm[hidden] { display: none; } +.file-editor-dialog { width: min(860px, calc(100% - 32px)); height: min(720px, calc(100dvh - 40px)); padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; color: var(--text); background: var(--surface); } +.file-editor-dialog::backdrop { background: rgb(0 0 0 / 48%); } +.file-editor-dialog form { display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto; height: 100%; } +.file-editor-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 14px 16px; border-bottom: 1px solid var(--border); } +.file-editor-dialog h2 { margin: 0; font-size: 16px; line-height: 24px; } +.file-editor-dialog header p, .file-editor-dialog form > p { margin: 2px 0 0; color: var(--muted); font: 11px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } +.file-editor-frame { display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 0; margin: 14px 16px 8px; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); } +.file-editor-frame pre { min-width: 48px; height: 100%; margin: 0; overflow: hidden; padding: 12px 10px; border-right: 1px solid var(--border); color: var(--faint); background: var(--surface-raised); font: 12px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-align: right; user-select: none; } +.file-editor-frame textarea { width: 100%; height: 100%; min-width: 0; min-height: 0; resize: none; padding: 12px 14px; border: 0; outline: none; color: var(--text); background: transparent; font: 13px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: 0; tab-size: 2; } +.file-editor-dialog form > p { padding: 0 16px; } +.file-editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 16px 14px; } +.file-editor-footer > span { color: var(--muted); font-size: 12px; line-height: 18px; } +.file-editor-footer > span.dirty { color: var(--warning); } +.file-editor-footer .dialog-actions { padding: 0; } +.page-input-dialog input { width: 100%; margin-top: 2px; } +.dialog-error { margin: 8px 0 0; color: var(--danger); font-size: 12px; line-height: 18px; } + +.list-pagination .page-jump input:not([type="checkbox"]):not([type="radio"]):not([type="file"]), +.file-pagination .page-jump input:not([type="checkbox"]):not([type="radio"]):not([type="file"]) { + height: 30px; + min-height: 30px; +} + +.segmented { + display: inline-flex; + flex: none; + padding: 2px; + border: 1px solid var(--border); + border-radius: 8px; +} +.segmented button { + height: 28px; + padding: 0 11px; + border: 0; + border-radius: 6px; + color: var(--muted); + background: transparent; + font-size: 12px; + cursor: pointer; +} +.segmented button[aria-pressed="true"] { background: var(--hover); color: var(--text); font-weight: 500; } + +.versions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; +} +.versions.experimental { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.versions > div { min-width: 0; padding: 13px 14px; } +.versions > div + div { border-left: 1px solid var(--border); } +.versions span, .versions strong, .versions small { display: block; overflow-wrap: anywhere; } +.versions span, .versions small { color: var(--muted); font-size: 12px; line-height: 18px; } +.versions strong { margin: 3px 0; font-size: 15px; line-height: 22px; font-weight: 600; } + +.message { margin: 0; padding: 8px 10px; border-left: 3px solid var(--warning); color: var(--warning); background: var(--surface-raised); font-size: 12px; line-height: 18px; } +.message.error { margin-top: 16px; border-left-color: var(--danger); color: var(--danger); } +.action-heading { align-items: center; } +.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +.spinner { + width: 14px; + height: 14px; + margin-right: 7px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin .75s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 1.5s; } } + +.update-state { display: flex; flex-direction: column; gap: 8px; } +.status-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.status-line output, .update-state > p { margin: 0; color: var(--muted); font-size: 12px; line-height: 18px; overflow-wrap: anywhere; } +.status-label { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; line-height: 20px; } +.status-label > span { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--border); } +.status-label.active > span { background: var(--accent); } +.status-label.success > span { background: var(--success); } +.status-label.failed { color: var(--danger); } +.status-label.failed > span { background: var(--danger); } +.status-label strong { font-weight: 400; } +.update-progress { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-raised); } +.progress-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.progress-heading strong { font-size: 13px; line-height: 20px; font-weight: 500; } +.progress-heading output { color: var(--text); font: 600 12px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.progress-heading-actions { display: flex; align-items: center; gap: 8px; } +.progress { height: 8px; overflow: hidden; border-radius: 4px; background: var(--border); } +.progress span { display: block; height: 100%; width: 0; background: var(--success); transition: width .2s ease; } +.progress[data-complete='true'] span { width: 100% !important; transition: none; } +.progress-stage-log { display: grid; gap: 10px; min-width: 0; padding-top: 2px; } +.progress-log-actions { display: flex; align-items: center; gap: 6px; } +.progress-log-actions button { min-height: 28px; padding: 4px 9px; font-size: 11px; } +.progress-auto-scroll { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; cursor: pointer; } +.progress-log-list { display: grid; gap: 0; } +.progress-log-group { position: relative; min-width: 0; padding: 0 0 14px 28px; } +.progress-log-group:not(:last-child)::before { position: absolute; top: 22px; bottom: 0; left: 8px; width: 1px; background: var(--border); content: ''; } +.progress-log-group > summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 12px; cursor: pointer; color: var(--text); list-style: none; } +.progress-log-group > summary::-webkit-details-marker { display: none; } +.progress-stage-marker { --marker-size: 10px; position: absolute; top: 5px; left: 3px; display: grid; width: var(--marker-size); height: var(--marker-size); place-items: center; } +.progress-stage-marker::before { box-sizing: border-box; width: 100%; height: 100%; border: 1.5px solid var(--faint); border-radius: 50%; content: ''; } +.progress-stage-marker.completed::before { border-color: var(--success); background: var(--success); } +.progress-stage-marker.failed::before { border-color: var(--danger); background: var(--danger); } +.progress-stage-marker.active::before { border-color: color-mix(in srgb, var(--accent) 30%, transparent); border-top-color: var(--accent); animation: spin .8s linear infinite; } +.progress-stage-summary { display: grid; min-width: 0; gap: 1px; } +.progress-stage-summary strong { font-size: 13px; line-height: 20px; font-weight: 600; } +.progress-stage-detail, .progress-stage-count { color: var(--muted); font-size: 11px; line-height: 17px; overflow-wrap: anywhere; } +.progress-stage-metric, .progress-stage-percent { color: var(--text); font: 11px/17px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } +.progress-stage-toggle { align-self: start; padding: 0; border: 0; color: var(--accent); background: transparent; font: inherit; font-size: 10px; line-height: 20px; white-space: nowrap; cursor: pointer; } +.progress-stage-items { display: grid; gap: 4px; margin-top: 8px; padding: 8px 10px; border-left: 1px solid var(--border); } +.progress-stage-item { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 5px; color: var(--muted); font-size: 11px; line-height: 17px; } +.progress-stage-item.completed { color: var(--success); } +.progress-stage-item.active { color: var(--accent); } +.progress-stage-item.failed { color: var(--danger); } +.progress-stage-item-marker { --marker-size: 10px; display: inline-grid; width: 14px; height: 17px; place-items: center; } +.progress-stage-item-marker::before { box-sizing: border-box; width: var(--marker-size); height: var(--marker-size); border: 1.5px solid transparent; border-radius: 50%; content: ''; } +.progress-stage-item.completed .progress-stage-item-marker::before { border-color: var(--success); background: var(--success); } +.progress-stage-item.failed .progress-stage-item-marker::before { border-color: var(--danger); background: var(--danger); } +.progress-stage-item.pending .progress-stage-item-marker::before { border: 1.5px solid var(--muted); } +.progress-stage-item.active .progress-stage-item-marker::before { border: 1.5px solid color-mix(in srgb, var(--accent) 30%, transparent); border-top-color: var(--accent); animation: spin .8s linear infinite; } +.progress-stage-item .progress-stage-metric, .progress-stage-item .progress-stage-percent { grid-column: 2; color: var(--text); } +.progress-stage-error { margin: 4px 0 0 21px; color: var(--danger); font-size: 11px; overflow-wrap: anywhere; } +.progress-log-group-list { display: grid; gap: 0; max-height: 250px; overflow: auto; margin-top: 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); } +.progress-log-group-list.populated { height: clamp(160px, 24dvh, 240px); max-height: none; align-content: start; } +.progress-log-entry { min-width: 0; border-bottom: 1px solid var(--border); padding: 7px 9px; } +.progress-log-entry summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: baseline; gap: 2px 8px; cursor: pointer; color: var(--text); font-size: 11px; line-height: 18px; list-style: none; } +.progress-log-entry summary::-webkit-details-marker { display: none; } +.progress-log-entry time, .progress-log-source { min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } +.progress-log-level { color: var(--accent); } +.progress-log-level.error { color: var(--danger); } +.progress-log-level.warning, .progress-log-level.warn { color: var(--warning); } +.progress-log-message { grid-column: 1 / 3; min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.progress-log-chevron { position: relative; top: 2px; grid-column: 3; width: 10px; height: 10px; justify-self: end; } +.progress-log-chevron::before { position: absolute; top: 3px; left: 1px; width: 8px; height: 5px; background: var(--faint); clip-path: polygon(0 0, 50% 72%, 100% 0, 100% 28%, 50% 100%, 0 28%); content: ''; transform-origin: center; } +.progress-log-entry[open] .progress-log-chevron::before { transform: rotate(180deg); } +.progress-log-entry pre { max-height: 180px; overflow: auto; margin: 5px 0 2px; color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 10px/15px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.progress-log-empty { margin: 0; padding: 8px; color: var(--muted); font-size: 11px; line-height: 18px; } +.progress-log-group > .progress-log-actions { justify-content: flex-end; margin-top: 7px; } +.link-button { justify-self: start; padding: 0; border: 0; color: var(--accent); background: transparent; font-size: 11px; cursor: pointer; } +.holds { border-top: 1px solid var(--border); } +.hold { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 0; border-bottom: 1px solid var(--border); } +.hold div { min-width: 0; } +.hold strong, .hold span { display: block; overflow-wrap: anywhere; } +.hold strong { font-size: 13px; line-height: 20px; } +.hold span { color: var(--muted); font-size: 12px; line-height: 18px; } + +.toggle { display: inline-flex; align-items: center; gap: 8px; flex: none; font-size: 12px; cursor: pointer; } +.toggle input { position: absolute; opacity: 0; pointer-events: none; } +.toggle > span { + position: relative; + width: 34px; + height: 20px; + padding: 2px; + border-radius: 10px; + background: var(--border); + transition: background .15s ease; +} +.toggle > span::after { position: absolute; top: 2px; left: 2px; display: block; width: 16px; height: 16px; border-radius: 50%; background: var(--surface); content: ""; transition: left .15s ease; } +.toggle input:checked + span { background: var(--success); } +.auth-totp-control[data-state="unverified"] .toggle input:checked + span { background: var(--warning); } +.toggle input:checked + span::after { left: 16px; } +.toggle input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } +.toggle strong { font-weight: 500; } + +.setting-rows, .plugin-list, .user-plugin-list { overflow: hidden; border: 1px solid var(--border); border-radius: 8px; } +.proxy-form-grid, .proxy-rules-grid, .proxy-scope-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.proxy-direct-rules-field { display: grid; gap: 6px; min-width: 0; color: var(--muted); font-size: 12px; } +.proxy-direct-rules-field textarea { box-sizing: border-box; width: 100%; min-height: 92px; resize: vertical; font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.proxy-direct-rules-field small { color: var(--muted); font-size: 11px; line-height: 17px; } +.proxy-form-grid > label, .proxy-rules-grid > label { display: grid; gap: 6px; min-width: 0; color: var(--muted); font-size: 13px; line-height: 20px; font-weight: 400; } +.proxy-password-field { display: flex; min-width: 0; align-items: end; gap: 8px; } +.proxy-password-field > label { display: grid; min-width: 0; flex: 1; gap: 6px; color: var(--muted); font-size: 13px; line-height: 20px; font-weight: 400; } +.proxy-password-field > label > span { color: var(--text); font-weight: 400; } +.proxy-password-row { display: grid; grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: end; gap: 12px; } +.proxy-password-row > .secondary { justify-self: end; } +.proxy-password-row > .proxy-password-field { min-width: 0; } +.proxy-password-field > button.proxy-clear-password { flex: none; height: 34px; min-height: 34px; padding: 6px 10px; border-radius: 6px; } +.proxy-clear-password[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); } +.proxy-form-grid input, .proxy-form-grid select, .proxy-rules-grid textarea { width: 100%; } +.proxy-form-grid input, .proxy-form-grid select { height: 34px; min-height: 34px; font-size: 16px; line-height: 22px; } +.proxy-rules-grid textarea { resize: vertical; min-height: 92px; font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.proxy-form-grid label > span, .proxy-rules-grid label > span { color: var(--text); font-weight: 400; } +.proxy-rules-grid small { display: block; color: var(--muted); font-size: 11px; line-height: 17px; } +.proxy-setting-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 12px; color: var(--text); } +.proxy-setting-row > span, .proxy-setting-row > div { min-width: 0; } +.proxy-setting-row strong, .proxy-setting-row small { display: block; } +.proxy-setting-row small { margin-top: 2px; color: var(--muted); font-size: 11px; line-height: 17px; } +.proxy-setting-row > .toggle { width: auto; justify-self: end; } +.proxy-master-row { margin-top: 0; } +.proxy-master-row h2 { margin: 0; font-size: 16px; line-height: 24px; font-weight: 500; letter-spacing: 0; } +.proxy-master-row > div > small { font-size: 13px; line-height: 20px; } +.proxy-advanced { display: grid; gap: 12px; margin-top: 14px; padding: 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-raised); } +.proxy-advanced[hidden] { display: none; } +.proxy-advanced h3 { margin: 0; font-size: 14px; line-height: 21px; font-weight: 500; } +.proxy-advanced-setting { margin-top: 0; } +.proxy-advanced-setting > div > small { font-size: 13px; line-height: 20px; } +.proxy-hint { margin: 6px 0 0; color: var(--muted); font-size: 11px; line-height: 17px; } +.proxy-scope-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; min-width: 0; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-raised); } +.proxy-scope-copy { min-width: 0; } +.proxy-scope-card > .toggle { justify-self: end; } +.proxy-scope-grid strong, .proxy-scope-grid small { display: block; } +.proxy-scope-grid strong { font-size: 13px; line-height: 20px; } +.proxy-scope-grid small { color: var(--muted); font-size: 11px; line-height: 17px; } +.proxy-scope-grid .proxy-scope-description { font-size: 11px; line-height: 17px; } +.proxy-scope-description { display: -webkit-box; width: 100%; padding: 0; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 1; border: 0; color: var(--muted); background: transparent; font: inherit; font-size: 11px; line-height: 17px; text-align: left; cursor: default; } +.proxy-scope-description.expandable { cursor: pointer; } +.proxy-scope-description.expanded { display: block; overflow: visible; -webkit-line-clamp: initial; } +.proxy-provider-list { max-height: 320px; overflow-x: hidden; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; scrollbar-gutter: stable; } +.proxy-provider-heading > h2 { margin: 0 0 2px; font-size: 16px; line-height: 24px; font-weight: 500; letter-spacing: 0; } +.proxy-provider-heading > div { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.proxy-provider-heading p { min-width: 0; flex: 1; margin: 0; color: var(--muted); font-size: 13px; line-height: 20px; } +.proxy-provider-heading input { box-sizing: border-box; width: min(320px, 45%); height: 34px; flex: none; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background: var(--surface-raised); font: inherit; font-size: 12px; } +.proxy-provider-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 11px 12px; border-bottom: 1px solid var(--border); } +.proxy-provider-controls { display: flex; align-items: center; justify-self: end; gap: 10px; } +.proxy-provider-route { box-sizing: border-box; min-height: 22px; padding: 3px 9px; border-radius: 999px; color: var(--muted); background: var(--surface-raised); font-size: 10px; line-height: 16px; white-space: nowrap; } +.proxy-provider-item:last-child { border-bottom: 0; } +.proxy-provider-identity { display: flex; min-width: 0; align-items: center; gap: 7px; } +.proxy-provider-identity strong { overflow: hidden; color: var(--text); font-weight: 400; text-overflow: ellipsis; white-space: nowrap; } +.proxy-provider-info { box-sizing: border-box; flex: none; width: 17px; height: 17px; padding: 0; border: 1px solid var(--muted); border-radius: 50%; color: var(--muted); background: transparent; font: 600 11px/15px ui-sans-serif, system-ui, sans-serif; text-align: center; cursor: pointer; } +.proxy-provider-info:hover, .proxy-provider-info:focus-visible { border-color: var(--accent); color: var(--accent); } +.proxy-test-stages { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; } +.proxy-test-stages:empty { display: none; } +.proxy-test-dialog { box-sizing: border-box; width: min(620px, calc(100vw - 40px)); max-height: min(720px, calc(100dvh - 40px)); padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; color: var(--text); background: var(--surface); } +.proxy-test-dialog::backdrop { background: rgb(0 0 0 / 50%); } +.proxy-test-dialog > div { display: grid; max-height: inherit; gap: 14px; padding: 16px; overflow: auto; } +.proxy-test-dialog header, .proxy-test-dialog footer { display: flex; align-items: start; justify-content: space-between; gap: 12px; } +.proxy-test-dialog header h2, .proxy-test-dialog header p { margin: 0; } +.proxy-test-dialog header p { margin-top: 4px; color: var(--muted); } +.proxy-test-dialog footer { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; } +.proxy-test-dialog footer > .operation-result { min-width: 0; margin: 0; } +.proxy-test-dialog footer > button { grid-column: 2; margin-left: 0; } +.proxy-test-stage { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; gap: 8px; align-items: start; padding: 9px 11px; border-bottom: 1px solid var(--border); font-size: 12px; cursor: pointer; } +.proxy-test-stage:hover, .proxy-test-stage:focus-visible { background: var(--surface-soft); outline: none; } +.proxy-test-stage:last-child { border-bottom: 0; } +.proxy-test-stage > span:first-child { width: 9px; height: 9px; margin-top: 4px; border: 1.5px solid var(--muted); border-radius: 50%; } +.proxy-test-stage > span:nth-child(2) { display: grid; min-width: 0; gap: 1px; } +.proxy-test-stage > span:nth-child(2) small { display: block; } +.proxy-test-stage[data-state="running"] > span:first-child { border-color: var(--accent); border-top-color: transparent; animation: spin .7s linear infinite; } +.proxy-test-stage[data-state="success"] > span:first-child { border-color: var(--success); background: var(--success); } +.proxy-test-stage[data-state="failed"] > span:first-child { border-color: var(--danger); background: var(--danger); } +.proxy-test-stage[data-state="cancelled"] > span:first-child { border-color: var(--warning); background: var(--warning); } +.proxy-test-stage[data-state="skipped"] { color: var(--muted); } +.proxy-test-stage small { color: var(--muted); font-size: 10px; } +.proxy-test-stage small[hidden] { display: none; } +.proxy-scope-dialog { width: min(820px, calc(100% - 32px)); height: min(760px, calc(100dvh - 32px)); max-height: min(760px, calc(100dvh - 32px)); padding: 0; overflow: hidden; } +.proxy-scope-dialog form { display: grid; height: 100%; min-height: 0; grid-template-rows: auto minmax(0, 1fr) auto; } +.proxy-provider-info-dialog { box-sizing: border-box; width: min(460px, calc(100% - 32px)); padding: 0; } +.proxy-provider-info-dialog form { display: grid; gap: 16px; } +.proxy-provider-info-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px 0; } +.proxy-provider-info-dialog h2 { margin: 0; font-size: 16px; } +.proxy-provider-info-dialog p { margin: 0; padding: 0 18px 18px; color: var(--muted); line-height: 1.6; } +.proxy-system-rules-body { padding: 0 18px 18px; } +.proxy-system-rules-body p { padding: 0; } +.proxy-system-rules-body pre { margin: 12px 0 0; padding: 10px 12px; overflow: auto; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-soft); font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.proxy-scope-dialog header { display: flex; justify-content: space-between; gap: 12px; align-items: start; padding: 16px 18px; border-bottom: 1px solid var(--border); } +.proxy-scope-dialog header p { margin-bottom: 0; } +.proxy-scope-catalog { min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 8px 18px; } +.proxy-scope-group { display: grid; grid-template-columns: minmax(130px, .8fr) minmax(0, 1.5fr) minmax(0, 1.5fr); gap: 0; column-gap: 16px; padding: 0; } +.proxy-scope-group h3 { grid-column: 1; grid-row: 1 / -1; align-self: center; margin: 0; padding: 7px 0; font-size: 13px; font-weight: 600; } +.proxy-scope-entry { grid-column: 2 / -1; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 2px 16px; padding: 7px 0; border-top: 1px solid var(--border); font-size: 11px; line-height: 17px; } +.proxy-scope-description { display: -webkit-box; width: 100%; padding: 0; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 1; border: 0; color: var(--muted); background: transparent; font: inherit; text-align: left; cursor: default; } +.proxy-scope-description.expandable { cursor: pointer; } +.proxy-scope-description.expanded { display: block; overflow: visible; -webkit-line-clamp: initial; } +.proxy-scope-summaries { padding: 10px 18px 16px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11px; line-height: 17px; } +.proxy-scope-summaries p { margin: 3px 0; font-size: inherit; line-height: inherit; } +.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-height: 48px; padding: 8px 12px; font-size: 13px; } +.setting-row + .setting-row { border-top: 1px solid var(--border); } +.setting-row > span { min-width: 0; } +.setting-row strong, .setting-row small { display: block; } +.setting-row strong { font-weight: 500; } +.setting-row small { margin-top: 2px; color: var(--muted); font-size: 12px; line-height: 18px; } +.setting-row select, .log-filters input, .log-filters select { + min-width: 0; + height: 34px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 7px; + color: var(--text); + background: var(--surface); + font-size: 12px; +} +.setting-row select { min-width: 140px; } + +.operation-result { margin: 0; color: var(--muted); font-size: 13px; line-height: 20px; } +.runtime-reset-progress { display: grid; gap: 7px; } +.runtime-reset-progress .status-line { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.runtime-reset-progress output { flex: none; color: var(--muted); font-size: 12px; line-height: 18px; } +.inline-confirmation { + padding: 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-raised); +} +.inline-confirmation h3 { margin: 0; font-size: 14px; line-height: 22px; font-weight: 600; } +.inline-confirmation p { margin: 4px 0 0; color: var(--muted); font-size: 13px; line-height: 20px; } +.plugin-restart-notice { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--warning) 45%, var(--border)); border-radius: 8px; background: color-mix(in srgb, var(--warning) 8%, var(--surface)); } +.plugin-restart-notice div { min-width: 0; } +.plugin-restart-notice strong { font-size: 13px; line-height: 20px; } +.plugin-restart-notice p { margin: 2px 0 0; color: var(--muted); font-size: 12px; line-height: 18px; } +.plugin-draft-actions { display: flex; flex: none; align-items: center; gap: 8px; } +.plugin-draft-actions button { flex: none; } +.resource-section-heading { display: block; } +.resource-heading-detail { display: flex; align-items: center; gap: 20px; margin-top: 2px; } +.resource-heading-detail p { min-width: 0; flex: 1; margin: 0; } +.resource-search { display: block; width: min(320px, 45%); margin: 0 0 0 auto; flex: none; } +.plugin-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px 16px; min-height: 52px; padding: 7px 12px; } +.plugin-row + .plugin-row { border-top: 1px solid var(--border); } +.list-pagination { display: flex; align-items: center; justify-content: flex-end; gap: 12px; min-height: 42px; color: var(--muted); font-size: 12px; } +.list-pagination label, .list-pagination > div { display: flex; align-items: center; gap: 7px; } +.list-pagination select { min-width: 64px; padding: 3px 22px 3px 7px; } +.list-pagination > span, .file-pagination > span { margin-right: auto; } +.resource-panel .plugin-list, .resource-panel .user-plugin-list { + max-height: min(52dvh, 520px); + flex: 0 1 auto; + overflow-y: auto; + overflow-anchor: none; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +.resource-panel .plugin-list { min-height: 60px; } +.resource-panel .user-plugin-list { min-height: 96px; } +.resource-panel .list-pagination { position: static; z-index: 2; background: var(--bg); } +.page-navigation strong { min-width: 16px; color: var(--text); text-align: center; font-weight: 600; } +button.page-arrow { width: 28px; min-width: 28px; padding: 0 0 6px; font-size: 21px; line-height: 1; } +.page-jump input { width: 50px; height: 30px; padding: 3px 6px; text-align: center; } + +input:not([type="checkbox"]):not([type="radio"]):not([type="file"]), select { height: 34px; min-height: 34px; } +.list-pagination select, .file-pagination select { height: 30px; min-height: 30px; } +.list-pagination select, .file-pagination select, .page-jump input { font-size: 12px; line-height: 18px; } +select:hover { background: var(--hover); } +select:focus-visible { border-color: var(--border); outline: none; } +.list-pagination .page-arrow, .file-pagination .page-arrow { width: 30px; min-width: 30px; height: 30px; min-height: 30px; } + +.custom-select { position: relative; display: inline-flex; min-width: 0; } +.custom-select-native { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } +.custom-select-trigger { display: inline-flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; height: 34px; min-height: 34px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background: var(--surface); font-family: inherit; font-size: 16px; font-weight: 400; line-height: 22px; overflow: hidden; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } +.custom-select-trigger::after { width: 7px; height: 7px; flex: none; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg) translateY(-2px); content: ''; } +.custom-select-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.custom-select-trigger:hover, .custom-select-trigger[aria-expanded="true"] { background: var(--hover); } +.custom-select-trigger:focus-visible { border-color: var(--text); outline: 2px solid var(--accent); outline-offset: 2px; } +.custom-select-menu { position: fixed; z-index: 100; display: none; max-height: 45dvh; overflow-x: hidden; overflow-y: auto; padding: 4px; border: 0; border-radius: 14px; background: var(--surface-raised); box-shadow: 0 10px 28px rgb(0 0 0 / 28%); } +.custom-select-menu[data-open="true"] { display: block; } +.custom-select-option { position: relative; display: block; width: 100%; min-height: 34px; padding: 6px 30px 6px 10px; border: 0; border-radius: 8px; color: var(--text); background: transparent; font-family: inherit; font-size: 16px; font-weight: 400; line-height: 22px; overflow-wrap: anywhere; text-align: left; cursor: pointer; } +.custom-select-option:hover, .custom-select-option[aria-selected="true"] { background: var(--hover); } +.custom-select-option[aria-selected="true"]::after { position: absolute; top: 8px; right: 12px; width: 7px; height: 12px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg); content: ''; } +.list-pagination .custom-select-trigger, .file-pagination .custom-select-trigger { height: 30px; min-height: 30px; padding: 3px 7px; font-size: 12px; line-height: 18px; } +.language-switch-menu .custom-select-option { font-size: 11px; line-height: 18px; } +.pagination-select-menu .custom-select-option { font-size: 12px; line-height: 18px; } +.log-filters > .custom-select { min-width: 132px; } + +.plugin-identity { min-width: 0; } +.plugin-identity strong, .plugin-identity span { overflow-wrap: anywhere; } +.plugin-identity strong { font-size: 13px; line-height: 20px; font-weight: 500; } +.plugin-identity span { margin-top: 2px; color: var(--muted); font-size: 12px; line-height: 18px; } +.resource-heading { display: flex; align-items: flex-start; gap: 8px; min-width: 0; } +.resource-heading > strong { flex: 0 1 auto; max-width: 55%; } +.resource-description { position: relative; flex: 1 1 120px; min-width: 0; padding: 0 15px 0 0; overflow: hidden; border: 0; color: var(--muted); background: transparent; font: inherit; font-size: 12px; line-height: 20px; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: default; } +.resource-description.expandable { cursor: pointer; } +.resource-description.expandable::after { position: absolute; top: 6px; right: 2px; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg); content: ''; } +.resource-description.expanded { overflow: visible; text-overflow: clip; white-space: normal; } +.resource-description.expanded::after { top: 9px; transform: rotate(225deg); } +.plugin-identity > .resource-description { display: block; width: 100%; margin-top: 2px; } +.plugin-identity .user-skill-metadata { display: block; margin-top: 4px; color: var(--faint); font: 11px/17px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } +.plugin-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; } +.plugin-action-state { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: 12px; line-height: 18px; } +.plugin-pending { display: inline-block !important; width: fit-content; margin-top: 5px !important; color: var(--warning) !important; font-weight: 600; } +.empty-state { margin: 0; padding: 26px 12px; color: var(--muted); text-align: center; font-size: 13px; } + +.recovery-summary { + padding: 11px 13px; + border: 1px solid color-mix(in srgb, var(--danger) 42%, var(--border)); + border-radius: 8px; + background: color-mix(in srgb, var(--danger) 7%, var(--surface)); +} +.recovery-summary strong { color: var(--danger); font-size: 13px; line-height: 20px; } +.recovery-summary p { margin: 2px 0 0; color: var(--muted); font-size: 12px; line-height: 18px; overflow-wrap: anywhere; } +.user-plugin-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + min-height: 94px; + padding: 12px 14px; + background: var(--surface); +} +.user-plugin-row + .user-plugin-row { border-top: 1px solid var(--border); } +.user-plugin-row.pending { background: color-mix(in srgb, var(--warning) 5%, var(--surface)); } +.user-plugin-main { min-width: 0; } +.user-plugin-heading { display: flex; align-items: flex-start; gap: 8px; min-width: 0; } +.user-plugin-heading > strong { flex: 0 1 auto; max-width: 55%; overflow-wrap: anywhere; font-size: 13px; line-height: 20px; font-weight: 600; } +.user-plugin-description { position: relative; flex: 1 1 120px; min-width: 0; padding: 0 15px 0 0; overflow: hidden; border: 0; color: var(--muted); background: transparent; font: inherit; font-size: 12px; line-height: 20px; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: default; } +.user-plugin-description.expandable { cursor: pointer; } +.user-plugin-description.expandable::after { position: absolute; top: 6px; right: 2px; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg); content: ''; } +.user-plugin-description.expanded { overflow: visible; text-overflow: clip; white-space: normal; } +.user-plugin-description.expanded::after { top: 9px; transform: rotate(225deg); } +.user-plugin-main > .user-plugin-description { display: block; width: 100%; margin-top: 2px; } +.user-plugin-badge { flex: 0 0 auto; padding: 1px 6px; border-radius: 4px; color: var(--muted); background: var(--surface-raised); font-size: 10px; line-height: 16px; } +.user-plugin-badge.enabled { color: var(--success); } +.user-plugin-badge.warning, .user-plugin-badge.pending { color: var(--warning); } +.user-plugin-badge.danger { color: var(--danger); } +.user-plugin-main dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px 14px; margin: 9px 0 0; } +.user-plugin-main dl > div { min-width: 0; } +.user-plugin-main dt, .user-plugin-main dd { min-width: 0; margin: 0; overflow: hidden; font-size: 11px; line-height: 17px; text-overflow: ellipsis; white-space: nowrap; } +.user-plugin-main dt { color: var(--faint); } +.user-plugin-main dd { color: var(--muted); } +.user-plugin-metadata.expandable { cursor: pointer; } +.user-plugin-metadata.expanded dd { overflow: visible; overflow-wrap: anywhere; text-overflow: clip; white-space: normal; } +.user-plugin-metadata:focus-visible { outline: 1px solid var(--accent); outline-offset: 3px; } +.user-plugin-error { margin: 7px 0 0; color: var(--danger); font-size: 11px; line-height: 17px; overflow-wrap: anywhere; } +.user-plugin-controls { display: flex; align-items: center; justify-content: flex-end; gap: 12px; align-self: center; } +.user-plugin-controls .toggle { width: auto; } + +.terminal-section { gap: 14px; } +.terminal-heading { align-items: center; } +.terminal-actions { display: flex; flex: none; gap: 8px; } +.terminal-status { + display: inline-flex; + align-items: center; + gap: 7px; + width: fit-content; + color: var(--muted); + font-size: 12px; + line-height: 18px; +} +.terminal-status > span { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--faint); } +.terminal-status[data-state="connected"] > span { background: var(--success); } +.terminal-status[data-state="connecting"] > span, +.terminal-status[data-state="reconnecting"] > span, +.terminal-status[data-state="loading"] > span, +.terminal-status[data-state="starting"] > span { background: var(--warning); } +.terminal-status[data-state="failed"] { color: var(--danger); } +.terminal-status[data-state="failed"] > span { background: var(--danger); } +.terminal-status strong { font-weight: 400; } +.terminal-frame { + position: relative; + width: 100%; + height: auto; + max-height: min(760px, 78dvh); + aspect-ratio: 16 / 9; + min-width: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} +#terminal-screen { width: 100%; height: 100%; } +#terminal-screen, #terminal-screen .xterm { cursor: text; } +.terminal-frame:not(.active) #terminal-screen { visibility: hidden; } +#terminal-screen .xterm { height: 100%; padding: 10px; background: var(--surface); } +#terminal-screen .xterm-viewport { position: static; background-color: var(--surface) !important; scrollbar-color: var(--faint) transparent; } +.terminal-frame > p { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + margin: 0; + padding: 24px; + color: var(--muted); + text-align: center; + font-size: 13px; + line-height: 20px; +} +.terminal-loader { + width: 18px; + height: 18px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: terminal-spin 0.8s linear infinite; +} +@keyframes terminal-spin { to { transform: rotate(360deg); } } + +.log-section { gap: 10px; } +.log-tools { display: flex; align-items: center; gap: 8px; } +.log-filters { display: grid; grid-template-columns: minmax(180px, 1fr) repeat(3, minmax(110px, auto)); gap: 8px; } +.log-filters input::placeholder { color: var(--faint); } +.log-filters > .custom-select .custom-select-trigger { font-size: 13px; line-height: 20px; } +.log-filter-menu .custom-select-option { font-size: 13px; line-height: 20px; } +.log-summary-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.log-auto-scroll { display: inline-flex; flex: none; align-items: center; gap: 6px; min-height: 28px; margin-left: auto; color: var(--muted); font-size: 11px; cursor: pointer; } +.log-auto-scroll input { position: absolute; opacity: 0; pointer-events: none; } +.log-auto-scroll > span { box-sizing: border-box; width: 28px; height: 16px; padding: 2px; border-radius: 8px; background: var(--border); } +.log-auto-scroll > span::after { display: block; width: 12px; height: 12px; border-radius: 50%; background: var(--surface); content: ''; transition: transform .15s ease; } +.log-auto-scroll input:checked + span { background: var(--success); } +.log-auto-scroll input:checked + span::after { transform: translateX(12px); } +.log-auto-scroll input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } +.log-auto-scroll strong { font-weight: 400; } +.log-summary { margin: 0; color: var(--muted); font-size: 12px; line-height: 18px; } +.log-resize-frame { position: relative; box-sizing: border-box; height: 480px; min-height: 140px; max-height: min(1080px, 105dvh); } +.log-list { display: flex; flex-direction: column; box-sizing: border-box; height: 100%; overflow: auto; border: 1px solid var(--border); border-radius: 8px; } +.log-resize-handle { position: absolute; right: 0; bottom: 0; left: 0; z-index: 2; height: 16px; cursor: ns-resize; touch-action: none; } +.log-entry { min-width: 0; padding: 10px 12px; background: var(--surface-raised); cursor: pointer; } +.log-entry + .log-entry { border-top: 1px solid var(--border); } +.log-entry:hover { background: var(--hover); } +.log-entry:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.log-meta { display: flex; align-items: center; gap: 8px; min-width: 0; } +.log-message-row { display: flex; align-items: center; gap: 10px; min-width: 0; } +.log-message-row > pre { flex: 1; min-width: 0; } +.log-entry[aria-expanded='false'] .log-message-row > pre { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.log-chevron { position: relative; top: 2px; flex: none; width: 10px; height: 10px; margin-right: 1px; } +.log-chevron::before { position: absolute; top: 3px; left: 1px; width: 8px; height: 5px; background: var(--faint); clip-path: polygon(0 0, 50% 72%, 100% 0, 100% 28%, 50% 100%, 0 28%); content: ""; transform-origin: center; } +.log-entry[aria-expanded='true'] .log-chevron::before { transform: rotate(180deg); } +.log-level { flex: none; font-size: 11px; line-height: 18px; font-weight: 600; } +.log-level.debug { color: var(--faint); } +.log-level.info { color: var(--accent); } +.log-level.warning { color: var(--warning); } +.log-level.error { color: var(--danger); } +.log-source { min-width: 0; overflow: hidden; color: var(--muted); font-size: 12px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; } +.log-meta time { flex: none; margin-left: auto; color: var(--faint); font-size: 11px; line-height: 18px; } +.log-entry pre { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--text); font: 12px/18px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; white-space: pre-wrap; } +.log-entry .log-details { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border); color: var(--muted); } + +dialog { width: min(460px, calc(100% - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 8px; color: var(--text); background: var(--surface); } +dialog::backdrop { background: rgb(0 0 0 / 56%); } +dialog h2 { margin: 0; font-size: 17px; line-height: 26px; } +dialog p { margin: 8px 0 16px; color: var(--muted); font-size: 13px; line-height: 20px; } +.confirm { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; line-height: 20px; } +.confirm input { margin-top: 3px; accent-color: var(--danger); } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } + +@media (max-height: 600px) { + .resource-panel > section { gap: 8px; padding: 12px 0; } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #202022; + --surface: #29292b; + --surface-raised: #323234; + --text: #f0f0f1; + --muted: #acacb2; + --faint: #85858b; + --border: #464649; + --hover: #3b3b3e; + --accent: #6d9df2; + --success: #27c47d; + --warning: #eda52f; + --danger: #f06d74; +} + +:root[data-theme="light"] { color-scheme: light; } + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg: #202022; + --surface: #29292b; + --surface-raised: #323234; + --text: #f0f0f1; + --muted: #acacb2; + --faint: #85858b; + --border: #464649; + --hover: #3b3b3e; + --accent: #6d9df2; + --success: #27c47d; + --warning: #eda52f; + --danger: #f06d74; + } +} + +@media (max-width: 640px) { + .resource-panel .plugin-list { min-height: 122px; } + .resource-panel .user-plugin-list { min-height: 170px; } + .progress-log-group-list.populated { height: 180px; } + .topbar-inner, main { width: calc(100% - 28px); } + .topbar-actions { gap: 7px; } + .topbar-actions > .return-dsh { width: 28px; padding: 3px; } + .return-dsh svg { display: block; } + .return-dsh-label { display: none; } + .language-switch { max-width: 84px; } + .topbar-actions .connection strong { display: none; } + .brand div > span { display: none; } + .section-heading { flex-direction: column; gap: 10px; } + .auth-password-grid { grid-template-columns: 1fr; } + .auth-subsection-heading { align-items: stretch; flex-direction: column; } + .auth-origin-field { grid-template-columns: 1fr; } + .auth-comparison-table { grid-template-columns: minmax(0, 1.2fr) repeat(2, minmax(64px, .7fr)); } + .auth-comparison-table > * { padding: 7px 6px; } + .auth-session-row { grid-template-columns: minmax(0, 1fr); } + .auth-session-revoke { justify-self: start; } + .resource-heading-detail { align-items: stretch; flex-direction: column; gap: 8px; } + .resource-search { width: 100%; margin-left: 0; } + .proxy-form-grid, .proxy-rules-grid, .proxy-scope-grid { grid-template-columns: 1fr; } + .proxy-scope-group { display: block; } + .proxy-scope-group h3 { padding: 7px 0 0; } + .proxy-scope-entry { grid-column: auto; grid-template-columns: minmax(0, 1fr); } + .proxy-provider-item { align-items: center; } + .proxy-scope-dialog { width: 100%; max-width: none; height: 100dvh; max-height: none; border: 0; border-radius: 0; } + .lifecycle-actions { width: 100%; } + .lifecycle-actions button { flex: 1; } + .action-heading { align-items: flex-start; } + .segmented, .actions { width: 100%; } + .segmented button { flex: 1; } + .actions { justify-content: flex-start; } + .actions > button { flex: 1 1 calc(50% - 8px); } + .progress-heading { align-items: flex-start; } + .progress-heading output { text-align: right; } + .progress-log-group { padding-left: 24px; } + .progress-log-group:not(:last-child)::before { left: 7px; } + .progress-stage-marker { left: 2px; } + .progress-log-entry summary { grid-template-columns: auto minmax(0, 1fr) auto; gap: 1px 6px; } + .progress-log-actions { align-items: stretch; flex-direction: column; } + .progress-log-actions button { width: 100%; } + .versions, .versions.experimental { grid-template-columns: 1fr; } + .versions > div + div { border-left: 0; border-top: 1px solid var(--border); } + .toggle { width: 100%; justify-content: space-between; } + .setting-row { align-items: flex-start; } + .plugin-row { grid-template-columns: 1fr; align-items: stretch; } + .plugin-actions { justify-content: space-between; } + .plugin-actions .toggle { width: auto; } + .plugin-restart-notice { align-items: stretch; flex-direction: column; } + .plugin-draft-actions { display: grid; width: 100%; grid-template-columns: 1fr; } + .plugin-draft-actions button { width: 100%; } + .proxy-password-field { align-items: stretch; flex-direction: column; } + .proxy-password-row { align-items: end; grid-template-columns: minmax(0, 1fr) auto; } + .proxy-clear-password { width: 100%; } + .proxy-provider-heading > div { align-items: stretch; flex-direction: column; gap: 8px; } + .proxy-provider-heading input { width: 100%; } + .list-pagination { justify-content: space-between; flex-wrap: wrap; } + .user-plugin-row { grid-template-columns: 1fr; align-items: stretch; } + .user-plugin-controls { justify-content: space-between; align-self: stretch; } + .terminal-heading { align-items: stretch; } + .terminal-actions { width: 100%; } + .terminal-actions button { flex: 1; } + .terminal-frame { height: clamp(300px, 56dvh, 440px); max-height: 440px; aspect-ratio: auto; } + .file-heading-actions { width: 100%; } + .file-heading-actions button { flex: 1; } + .file-create-panel { grid-template-columns: 1fr; align-items: stretch; } + .file-create-kind, .file-create-actions { width: 100%; } + .file-create-kind button, .file-create-actions button { flex: 1; } + .file-create-location, .file-create-error { grid-column: 1; } + .file-archive-panel { grid-template-columns: 1fr; align-items: stretch; } + .file-archive-actions button { flex: 1; } + .file-pathbar { grid-template-columns: repeat(3, 34px) minmax(0, 1fr); } + .file-pathbar #file-refresh { grid-column: 1 / -1; } + .file-searchbar { grid-template-columns: 1fr; } + .file-searchbar button { width: 100%; } + .file-browser { display: block; height: auto; min-height: 300px; max-height: 60dvh; aspect-ratio: auto; border: 0; background: transparent; } + .file-shortcuts { display: flex; gap: 6px; margin-bottom: 8px; padding: 0; overflow-x: auto; border: 0; background: transparent; } + .file-shortcuts button { width: auto; flex: none; border: 1px solid var(--border); } + .file-main { height: auto; min-height: 300px; max-height: 60dvh; overflow: auto; border: 1px solid var(--border); border-radius: 8px; } + .file-pagination { justify-content: space-between; flex-wrap: wrap; } + .file-table { min-width: 0; table-layout: auto; } + .file-table thead th:nth-child(n+3), .file-table tbody td:nth-child(n+3) { display: none; } + .file-table th:nth-child(2) { width: auto; } + .file-table tbody td:nth-child(2) { position: relative; height: 58px; padding-left: 34px; white-space: normal; overflow-wrap: anywhere; } + .file-table tbody td:nth-child(2) > .file-type-mark { position: absolute; top: 12px; left: 8px; } + .file-table tbody td:nth-child(2) > strong { display: block; } + .file-mobile-meta { display: block; margin-top: 2px; color: var(--faint); font-size: 10px; line-height: 15px; } + .file-mobile-meta .file-size-action { font-size: inherit; } + .file-selection { align-items: stretch; flex-direction: column; } + .file-selection > div { display: grid; grid-template-columns: repeat(3, 1fr); } + .file-attribute-fields { grid-template-columns: 1fr; } + .file-attributes-actions button { flex: 1; } + .file-editor-dialog { width: 100%; max-width: none; height: 100dvh; max-height: none; border: 0; border-radius: 0; } + .file-editor-frame { margin: 10px 12px 6px; } + .file-editor-dialog header, .file-editor-footer { padding-right: 12px; padding-left: 12px; } + .log-tools { width: 100%; flex-wrap: wrap; } + .log-connection { margin-right: auto; } + .log-filters { grid-template-columns: 1fr 1fr; } + .log-filters input { grid-column: 1 / -1; } + .log-meta { flex-wrap: wrap; } + .log-meta time { width: auto; margin-left: auto; } + .log-resize-frame { height: min(260px, 36dvh); } +} + +@media (max-width: 440px) { + .actions > button { flex-basis: 100%; } + .setting-row { flex-direction: column; } + .setting-row select { width: 100%; } +} diff --git a/upstream/container/control-plane/services/management/public/theme-init.js b/upstream/container/control-plane/services/management/public/theme-init.js new file mode 100644 index 0000000..f674bf9 --- /dev/null +++ b/upstream/container/control-plane/services/management/public/theme-init.js @@ -0,0 +1,4 @@ +try { + const theme = localStorage.getItem('dsh-platform:console-theme') + if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme +} catch {} diff --git a/upstream/container/control-plane/services/management/restricted-cli.mjs b/upstream/container/control-plane/services/management/restricted-cli.mjs new file mode 100644 index 0000000..ea8481d --- /dev/null +++ b/upstream/container/control-plane/services/management/restricted-cli.mjs @@ -0,0 +1,55 @@ +import { createServer, request as httpRequest } from 'node:http' + +const ROUTES = new Set([ + 'GET /_dsh_platform/api/v1/status', + 'POST /_dsh_platform/api/v1/check', + 'POST /_dsh_platform/api/v1/update', + 'GET /_dsh_platform/api/v1/channel', + 'PUT /_dsh_platform/api/v1/channel', + 'POST /_dsh_platform/api/v1/holds/retry', + 'GET /_dsh_platform/api/v1/rollback-plan', + 'POST /_dsh_platform/api/v1/rollback', + 'POST /_dsh_platform/api/v1/return-stable', + 'POST /_dsh_platform/api/v1/start-dsh', + 'POST /_dsh_platform/api/v1/stop-dsh', + 'POST /_dsh_platform/api/v1/restart-dsh', + 'GET /_dsh_platform/api/v1/logs', +]) + +export function restrictedCliRoute(method, target) { + let pathname + try { pathname = new URL(target, 'http://management-cli.internal').pathname } catch { return false } + return ROUTES.has(`${method} ${pathname}`) +} + +export function createRestrictedCliServer({ managementSocketPath, token }) { + if (typeof token !== 'string' || token.length < 32) throw new Error('restricted CLI token is invalid') + return createServer((request, response) => { + if (!restrictedCliRoute(request.method ?? 'GET', request.url ?? '/')) { + response.writeHead(404, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'CLI route is not available', code: 'CLI_ROUTE_NOT_AVAILABLE' })) + return + } + const headers = { 'x-dsh-restricted-cli': token } + for (const name of ['accept', 'content-length', 'content-type']) { + if (request.headers[name] !== undefined) headers[name] = request.headers[name] + } + const upstream = httpRequest({ + socketPath: managementSocketPath, + method: request.method, + path: request.url, + headers, + }, upstreamResponse => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers) + upstreamResponse.pipe(response) + }) + upstream.once('error', error => { + if (response.headersSent) response.destroy(error) + else { + response.writeHead(503, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'Management service is unavailable', code: 'MANAGEMENT_UNAVAILABLE' })) + } + }) + request.pipe(upstream) + }) +} diff --git a/upstream/container/control-plane/services/management/scoped-fetch.mjs b/upstream/container/control-plane/services/management/scoped-fetch.mjs new file mode 100644 index 0000000..f8cbf4b --- /dev/null +++ b/upstream/container/control-plane/services/management/scoped-fetch.mjs @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs' +import { Agent, fetch as undiciFetch, ProxyAgent } from 'undici' +import { OUTBOUND_PROXY_PORTS, outboundProxyScopeEnabled } from '../../../platform/lib/outbound-proxy.mjs' + +const LOOPBACK = '127.0.0.1' +const DEFAULT_ROUTING_STATE = '/run/dsh-platform/outbound-proxy-routing.json' + +export const PROXY_SCOPE_PORTS = Object.freeze({ + updates: OUTBOUND_PROXY_PORTS.updates, + platform: OUTBOUND_PROXY_PORTS.platform, + dshCore: OUTBOUND_PROXY_PORTS.dshCore, + dshPlugins: OUTBOUND_PROXY_PORTS.dshPlugins, + agent: OUTBOUND_PROXY_PORTS.agentNetwork, + terminal: OUTBOUND_PROXY_PORTS.managementTerminal, + provider: OUTBOUND_PROXY_PORTS.modelApi, + sharedDsh: OUTBOUND_PROXY_PORTS.sharedDsh, +}) + +const CONFIGURATION_SCOPE = Object.freeze({ + updates: 'updates', platform: 'platform', dshCore: 'dshCore', dshPlugins: 'dshPlugins', + agent: 'agentNetwork', terminal: 'managementTerminal', +}) + +function proxyEnabled(path, scope) { + try { + const state = JSON.parse(readFileSync(path, 'utf8')) + if (state?.schema !== 1 || typeof state.enabled !== 'boolean') return true + const configurationScope = CONFIGURATION_SCOPE[scope] + return configurationScope === undefined ? true : outboundProxyScopeEnabled(state, configurationScope) + } catch { + return true + } +} + +export function createScopedFetch(scope, { + fetchImpl = undiciFetch, + routingStatePath = DEFAULT_ROUTING_STATE, + createDirectDispatcher = () => new Agent(), + createProxyDispatcher = options => new ProxyAgent(options), +} = {}) { + const port = PROXY_SCOPE_PORTS[scope] + if (port === undefined) throw new TypeError(`unknown outbound proxy scope ${JSON.stringify(scope)}`) + const directDispatcher = createDirectDispatcher() + const proxyDispatcher = createProxyDispatcher({ uri: `http://${LOOPBACK}:${String(port)}` }) + const scopedFetch = (input, init = {}) => fetchImpl(input, { + ...init, + dispatcher: proxyEnabled(routingStatePath, scope) ? proxyDispatcher : directDispatcher, + }) + scopedFetch.close = async () => { + await Promise.all([directDispatcher.close(), proxyDispatcher.close()]) + } + return scopedFetch +} + +export const scopedFetchInternals = Object.freeze({ proxyEnabled }) diff --git a/upstream/container/control-plane/services/management/server.mjs b/upstream/container/control-plane/services/management/server.mjs new file mode 100644 index 0000000..22c0f27 --- /dev/null +++ b/upstream/container/control-plane/services/management/server.mjs @@ -0,0 +1,1034 @@ +import { createServer } from 'node:http' +import { randomUUID } from 'node:crypto' +import { chmod, mkdir, readFile, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { UpdateConflictError } from '../../modules/updater/lib/coordinator.mjs' +import { MAX_SETTINGS_DOCUMENT_BYTES, SettingsDocumentConflictError } from './settings-document.mjs' + +export const API_PREFIX = '/_dsh_platform/api/v1/' +export const CONSOLE_PREFIX = '/_dsh_platform/console/' +const MAX_BODY_BYTES = 16 * 1024 +const MAX_PROXY_BODY_BYTES = 64 * 1024 +const TERMINAL_SESSION_ROUTE = /^terminal\/sessions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/ +const TERMINAL_STREAM_ROUTE = /^terminal\/sessions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/stream$/ +const FILE_TASK_ROUTE = /^files\/tasks\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/ +const PROXY_TEST_TASK_ROUTE = /^proxy\/test\/tasks\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/ +const CONSOLE_ASSETS = new Map([ + ['', ['public', 'index.html', 'text/html; charset=utf-8']], + ['app.js', ['public', 'app.js', 'text/javascript; charset=utf-8']], + ['theme-init.js', ['public', 'theme-init.js', 'text/javascript; charset=utf-8']], + ['style.css', ['public', 'style.css', 'text/css; charset=utf-8']], + ['vendor/xterm.mjs', ['dependencies', '@xterm/xterm/lib/xterm.mjs', 'text/javascript; charset=utf-8']], + ['vendor/xterm.css', ['dependencies', '@xterm/xterm/css/xterm.css', 'text/css; charset=utf-8']], + ['vendor/addon-fit.mjs', ['dependencies', '@xterm/addon-fit/lib/addon-fit.mjs', 'text/javascript; charset=utf-8']], +]) +const CONSOLE_HEADERS = Object.freeze({ + 'cache-control': 'no-store', + 'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' http: https:; img-src 'self' data:; base-uri 'none'; frame-ancestors 'self'; form-action 'none'", + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', +}) + +async function jsonBody(request, maxBytes = MAX_BODY_BYTES) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.byteLength + if (size > maxBytes) throw new Error('request body is too large') + chunks.push(chunk) + } + if (size === 0) return {} + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function send(response, status, value) { + if (response.headersSent) return response.destroy() + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(`${JSON.stringify(value)}\n`) +} + +async function sendConsoleAsset(request, response, pathname, consoleRoots) { + if (pathname === CONSOLE_PREFIX.slice(0, -1)) { + response.writeHead(308, { location: CONSOLE_PREFIX, ...CONSOLE_HEADERS }) + response.end() + return true + } + if (!pathname.startsWith(CONSOLE_PREFIX)) return false + if (!['GET', 'HEAD'].includes(request.method ?? 'GET')) { + response.writeHead(405, { allow: 'GET, HEAD', ...CONSOLE_HEADERS }) + response.end() + return true + } + const asset = CONSOLE_ASSETS.get(pathname.slice(CONSOLE_PREFIX.length)) + if (asset === undefined) { + response.writeHead(404, CONSOLE_HEADERS) + response.end() + return true + } + const body = await readFile(join(consoleRoots[asset[0]], asset[1])) + response.writeHead(200, { + ...CONSOLE_HEADERS, + 'content-type': asset[2], + 'content-length': String(body.byteLength), + }) + response.end(request.method === 'HEAD' ? undefined : body) + return true +} + +function event(response, type, value) { + response.write(`event: ${type}\ndata: ${JSON.stringify(value)}\n\n`) +} + +function logOptions(url) { + const sources = url.searchParams.getAll('source') + const since = url.searchParams.get('since') ?? undefined + const taskId = url.searchParams.get('taskId') ?? undefined + const operation = url.searchParams.get('operation') ?? undefined + const phase = url.searchParams.get('phase') ?? undefined + const limitValue = url.searchParams.get('limit') + return { + sources: sources.length === 0 ? undefined : sources, + since, + taskId, + operation, + phase, + limit: limitValue === null ? 200 : Number(limitValue), + } +} + +export function createManagementServer({ + coordinator, + logs, + platformStatus = async () => ({}), + startDsh = async () => { throw new Error('DSH start is not configured') }, + stopDsh = async () => { throw new Error('DSH stop is not configured') }, + restartDsh = async () => { throw new Error('DSH restart is not configured') }, + resetRuntime = async () => { throw new Error('Runtime reset is not configured') }, + listBundledPlugins = async () => [], + configureBundledPlugin = async () => { throw new Error('System Plugin management is not configured') }, + recoverBundledPlugin = async () => { throw new Error('System Plugin recovery is not configured') }, + discardBundledPluginChanges = async () => { throw new Error('System Plugin draft management is not configured') }, + listSystemSkills = async () => [], + configureSystemSkill = async () => { throw new Error('System Skill management is not configured') }, + listUserSkills = async () => ({ schema: 1, revision: 'sha256:empty', skills: [] }), + validateUserSkillAction = async value => value, + configureUserSkill = async () => { throw new Error('User Skill management is not configured') }, + listUserPlugins = async () => { throw new Error('User Plugin inventory is not configured') }, + markUserPluginsLoaded = async () => {}, + validateUserPluginActions = async () => { throw new Error('User Plugin recovery is not configured') }, + applyUserPluginActions = async () => { throw new Error('User Plugin recovery is not configured') }, + recoverUserPluginTransaction, + initialUserPluginTransaction, + settingsDocument, + updateAutomaticCheck = async () => { throw new Error('automatic checks are not configured') }, + createManagementOriginTransition = async () => { throw new Error('Management origin transition is not configured') }, + commitManagementOriginTransition = async () => { throw new Error('Management origin transition is not configured') }, + reportManagementOriginTransitionFailure = async () => { throw new Error('Management origin transition reporting is not configured') }, + getAuthenticationSettings = async () => { throw new Error('Authentication settings are not configured') }, + updateAuthenticationSettings = async () => { throw new Error('Authentication settings are not configured') }, + beginTotpEnrollment = async () => { throw new Error('Two-factor enrollment is not configured') }, + confirmTotpEnrollment = async () => { throw new Error('Two-factor enrollment is not configured') }, + cancelTotpEnrollment = async () => { throw new Error('Two-factor enrollment is not configured') }, + beginTotpDisableConfirmation = async () => { throw new Error('Two-factor disable confirmation is not configured') }, + confirmTotpDisable = async () => { throw new Error('Two-factor disable confirmation is not configured') }, + cancelTotpDisableConfirmation = async () => { throw new Error('Two-factor disable confirmation is not configured') }, + revokeAuthenticationSessions = async () => { throw new Error('Authentication session management is not configured') }, + getProxyConfiguration = async () => { throw new Error('outbound proxy configuration is not configured') }, + updateProxyConfiguration = async () => { throw new Error('outbound proxy configuration is not configured') }, + listProxyProviders = async () => ({ schema: 1, source: 'unavailable', providers: [] }), + startProxyTest = async () => { throw new Error('outbound proxy tests are not configured') }, + getProxyTest = async () => { throw new Error('outbound proxy tests are not configured') }, + cancelProxyTest = async () => { throw new Error('outbound proxy tests are not configured') }, + terminalSessions = { + create: () => { throw new Error('terminal sessions are not configured') }, + status: () => { throw new Error('terminal sessions are not configured') }, + close: () => { throw new Error('terminal sessions are not configured') }, + upgrade: (_request, socket) => socket.destroy(), + }, + fileInventory, + fileTransfers, + fileTasks, + fileEditor, + fileLocations = Object.freeze({ defaultPath: '/workspace', shortcuts: Object.freeze(['/workspace', '/data/dsh', '/data/platform', '/']) }), + privilegedMutationActive = () => false, + authorizeInternal = async () => true, + stateHeartbeatMs = 15_000, + restartDelayMs = 2_000, + consoleRoot = join(import.meta.dirname, 'public'), + consoleDependencyRoot = join(import.meta.dirname, 'node_modules'), +}) { + let lifecycleTask + let runtimeResetTask + let pluginTask + let systemSkillTask + let userSkillTask + let userPluginTask + let proxyTestState = Object.freeze({ status: 'idle', taskId: null, currentStage: null, error: null, updatedAt: null }) + let proxyWatchClosed = false + let lifecycleState = Object.freeze({ + state: 'running', action: null, taskId: null, attempt: 0, maxAttempts: 3, + error: null, updatedAt: null, + }) + let runtimeResetState = Object.freeze({ status: 'idle', taskId: null, error: null, updatedAt: null }) + let pluginState = Object.freeze({ status: 'idle', taskId: null, pluginId: null, action: null, error: null, restartRequired: false, updatedAt: null }) + let systemSkillState = Object.freeze({ status: 'idle', taskId: null, skillId: null, action: null, error: null, updatedAt: null }) + let userSkillState = Object.freeze({ status: 'idle', taskId: null, entryId: null, action: null, error: null, updatedAt: null }) + let userPluginState = Object.freeze(initialUserPluginTransaction === undefined + ? { status: 'idle', taskId: null, phase: null, error: null, updatedAt: null } + : { + status: initialUserPluginTransaction.phase === 'completed' ? 'success' + : initialUserPluginTransaction.phase === 'failed' ? 'failed' : 'running', + taskId: initialUserPluginTransaction.taskId, + phase: initialUserPluginTransaction.phase, + error: initialUserPluginTransaction.error, + updatedAt: initialUserPluginTransaction.updatedAt, + }) + const userPluginTasks = new Map() + const pluginTasks = new Map() + if (userPluginState.taskId !== null) userPluginTasks.set(userPluginState.taskId, userPluginState) + let server + coordinator.setMaxListeners?.(0) + const audit = (message, fields = {}) => logs.diagnostic('audit', message, { stream: 'audit', ...fields }) + const recordAudit = (message, fields = {}) => Promise.resolve().then(() => audit(message, fields)).catch(() => {}) + const refreshLoadedUserPlugins = () => Promise.resolve() + .then(() => markUserPluginsLoaded()) + .catch(error => recordAudit('user-plugin.loaded-state.capture.failed', { error })) + + const publishLifecycle = value => { + lifecycleState = Object.freeze({ ...lifecycleState, ...value, updatedAt: new Date().toISOString() }) + server.emit('management-state', lifecycleState) + } + + const currentLifecycle = platformLifecycle => { + if (lifecycleTask !== undefined || platformLifecycle === undefined) return lifecycleState + const localTime = Date.parse(lifecycleState.updatedAt ?? '') + const platformTime = Date.parse(platformLifecycle.updatedAt ?? '') + return Number.isFinite(platformTime) && ( + !Number.isFinite(localTime) + || platformTime > localTime + || (platformTime === localTime && platformLifecycle.state !== lifecycleState.state) + ) + ? platformLifecycle + : lifecycleState + } + + const publishRuntimeReset = value => { + runtimeResetState = Object.freeze({ ...runtimeResetState, ...value, updatedAt: new Date().toISOString() }) + server.emit('management-state', runtimeResetState) + } + + const publishPlugin = value => { + pluginState = Object.freeze({ ...pluginState, ...value, updatedAt: new Date().toISOString() }) + if (pluginState.taskId !== null) { + pluginTasks.set(pluginState.taskId, pluginState) + while (pluginTasks.size > 32) pluginTasks.delete(pluginTasks.keys().next().value) + } + server.emit('management-state', pluginState) + } + + const publishSystemSkill = value => { + systemSkillState = Object.freeze({ ...systemSkillState, ...value, updatedAt: new Date().toISOString() }) + server.emit('management-state', systemSkillState) + } + + const publishUserSkill = value => { + userSkillState = Object.freeze({ ...userSkillState, ...value, updatedAt: new Date().toISOString() }) + server.emit('management-state', userSkillState) + } + + const publishUserPlugin = value => { + userPluginState = Object.freeze({ ...userPluginState, ...value, updatedAt: new Date().toISOString() }) + if (userPluginState.taskId !== null) { + userPluginTasks.set(userPluginState.taskId, userPluginState) + while (userPluginTasks.size > 32) userPluginTasks.delete(userPluginTasks.keys().next().value) + } + server.emit('management-state', userPluginState) + } + + const publishProxyTest = value => { + proxyTestState = Object.freeze({ ...value, updatedAt: value.updatedAt ?? new Date().toISOString() }) + server.emit('management-state', { proxyTestOperation: proxyTestState }) + } + + const watchProxyTest = (taskId, initialState) => { + void (async () => { + let lastIdentity = initialState === undefined ? '' : JSON.stringify(initialState) + const stageStatuses = new Map() + if (initialState !== undefined) { + for (const stage of initialState.stages ?? []) { + if (stage.status === 'pending') continue + stageStatuses.set(stage.stage, stage.status) + await recordAudit('proxy.test.stage.changed', { + taskId, + stage: stage.stage, + status: stage.status, + durationMs: stage.durationMs ?? null, + errorCode: stage.errorCode ?? null, + }) + } + } + while (!proxyWatchClosed) { + let state + try { state = await getProxyTest(taskId) } catch (error) { + publishProxyTest({ + status: 'failed', taskId, currentStage: null, + error: error?.proxyError ?? { + code: error?.code ?? 'PROXY_MANAGER_UNAVAILABLE', + message: error instanceof Error ? error.message : 'proxy test state is unavailable', + stage: error?.stage ?? 'test', retryable: error?.retryable === true, + }, + }) + await recordAudit('proxy.test.failed', { taskId, code: error?.code ?? 'PROXY_MANAGER_UNAVAILABLE' }) + return + } + const identity = JSON.stringify(state) + if (identity !== lastIdentity) { + lastIdentity = identity + publishProxyTest(state) + for (const stage of state.stages ?? []) { + if (stage.status === 'pending' || stageStatuses.get(stage.stage) === stage.status) continue + stageStatuses.set(stage.stage, stage.status) + await recordAudit('proxy.test.stage.changed', { + taskId, + stage: stage.stage, + status: stage.status, + durationMs: stage.durationMs ?? null, + errorCode: stage.errorCode ?? null, + }) + } + } + if (state.status !== 'running') { + await recordAudit(`proxy.test.${state.status}`, { + taskId, mode: state.mode, errorCode: state.error?.errorCode ?? null, + }) + return + } + await new Promise(resolve => setTimeout(resolve, 250)) + } + })().catch(error => recordAudit('proxy.test.watch.failed', { error, taskId })) + } + + const requireRuntimeIdle = () => { + if (lifecycleTask !== undefined) throw new UpdateConflictError('a DSH lifecycle operation is already running') + if (runtimeResetTask !== undefined) throw new UpdateConflictError('the DSH Runtime is already resetting') + if (pluginTask !== undefined) throw new UpdateConflictError('a System Plugin operation is already running') + if (systemSkillTask !== undefined) throw new UpdateConflictError('a System Skill operation is already running') + if (userSkillTask !== undefined) throw new UpdateConflictError('a User Skill operation is already running') + if (userPluginTask !== undefined) throw new UpdateConflictError('a User Plugin operation is already running') + if (fileTasks?.hasManagedMutation === true) throw new UpdateConflictError('a managed file operation is already running') + if (privilegedMutationActive()) throw new UpdateConflictError('a privileged file operation is already running') + } + + const startUserPluginAction = async body => { + if (body === null || typeof body !== 'object' || Array.isArray(body) + || Object.keys(body).sort().join(',') !== 'actions,profile,revision' + || body.profile !== 'web') throw new Error('User Plugin request is invalid') + requireRuntimeIdle() + if (coordinator.hasActiveTask?.() === true) throw new UpdateConflictError('an update task is already running') + const validated = await validateUserPluginActions({ revision: body.revision, actions: body.actions }) + const platform = await platformStatus() + const strategy = platform.recoveryMode !== null && platform.recoveryMode !== undefined + || ['stopped', 'failed'].includes(platform.dshLifecycle?.state) + ? 'next-start' : 'restart' + requireRuntimeIdle() + if (coordinator.hasActiveTask?.() === true) throw new UpdateConflictError('an update task is already running') + const taskId = randomUUID() + publishUserPlugin({ status: 'running', taskId, phase: 'validated', strategy: null, error: null }) + userPluginTask = Promise.resolve() + .then(() => recordAudit('user-plugin.apply.started', { taskId, actions: validated.actions })) + .then(() => applyUserPluginActions({ + taskId, + revision: validated.revision, + actions: validated.actions, + strategy, + onProgress: state => publishUserPlugin({ phase: state.phase }), + })) + .then( + async result => { + await refreshLoadedUserPlugins() + const completedStrategy = result?.strategy ?? strategy + await recordAudit('user-plugin.apply.completed', { taskId, strategy: completedStrategy }) + publishUserPlugin({ + status: 'success', taskId, phase: 'completed', strategy: completedStrategy, + activationError: result?.activationError ?? null, error: null, + }) + publishPlugin({ restartRequired: false }) + }, + async error => { + const journal = error?.journal + await recordAudit('user-plugin.apply.failed', { error, taskId }) + publishUserPlugin({ + status: 'failed', taskId, phase: journal?.phase ?? userPluginState.phase, + error: error instanceof Error ? error.message : 'User Plugin operation failed', + }) + }, + ) + .finally(() => { userPluginTask = undefined }) + userPluginTask.catch(() => {}) + return { taskId } + } + + const startPluginAction = (pluginId, action, { recovery = false, toggleOnly = false } = {}) => { + if (typeof pluginId !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(pluginId)) { + throw new Error('System Plugin ID is invalid') + } + if (!['install', 'uninstall', 'enable', 'disable'].includes(action)) throw new Error('System Plugin action is invalid') + if (toggleOnly && !['enable', 'disable'].includes(action)) throw new Error('Only System Plugin enable and disable actions are allowed') + if (recovery && pluginId !== 'platform-management') throw new Error('System Plugin is not a console recovery target') + requireRuntimeIdle() + if (coordinator.hasActiveTask?.() === true) throw new UpdateConflictError('an update task is already running') + const taskId = randomUUID() + publishPlugin({ + status: 'running', taskId, pluginId, action, + strategy: null, activationError: null, error: null, + }) + pluginTask = Promise.resolve() + .then(() => recordAudit(`system-plugin.${recovery ? 'recovery.' : ''}${action}.started`, { taskId, pluginId })) + .then(() => recovery ? recoverBundledPlugin(pluginId, action) : configureBundledPlugin(pluginId, action)) + .then( + async () => { + const currentPlatformStatus = await platformStatus().catch(() => ({})) + const unavailable = (currentPlatformStatus?.recoveryMode !== null + && currentPlatformStatus?.recoveryMode !== undefined) + || ['stopped', 'failed'].includes(currentPlatformStatus?.dshLifecycle?.state) + const strategy = unavailable ? 'next-start' : 'restart' + await recordAudit(`system-plugin.${recovery ? 'recovery.' : ''}${action}.completed`, { + taskId, pluginId, strategy, + }) + publishPlugin({ + status: 'success', taskId, pluginId, action, strategy, + activationError: null, error: null, restartRequired: !unavailable, + }) + }, + async error => { + const message = error instanceof Error ? error.message : 'System Plugin operation failed' + await recordAudit(`system-plugin.${recovery ? 'recovery.' : ''}${action}.failed`, { error, taskId, pluginId }) + publishPlugin({ status: 'failed', taskId, pluginId, action, error: message }) + }, + ) + .finally(() => { pluginTask = undefined }) + pluginTask.catch(() => {}) + return { taskId } + } + + const startSystemSkillAction = (skillId, action) => { + if (typeof skillId !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skillId) || skillId.length > 64) { + throw new Error('System Skill ID is invalid') + } + if (!['install', 'uninstall', 'enable', 'disable'].includes(action)) throw new Error('System Skill action is invalid') + requireRuntimeIdle() + if (coordinator.hasActiveTask?.() === true) throw new UpdateConflictError('an update task is already running') + const taskId = randomUUID() + publishSystemSkill({ status: 'running', taskId, skillId, action, error: null }) + systemSkillTask = Promise.resolve() + .then(() => recordAudit(`system-skill.${action}.started`, { taskId, skillId })) + .then(() => configureSystemSkill(skillId, action)) + .then( + async () => { + await recordAudit(`system-skill.${action}.completed`, { taskId, skillId }) + publishSystemSkill({ status: 'success', taskId, skillId, action, error: null }) + }, + async error => { + const message = error instanceof Error ? error.message : 'System Skill operation failed' + await recordAudit(`system-skill.${action}.failed`, { error, taskId, skillId }) + publishSystemSkill({ status: 'failed', taskId, skillId, action, error: message }) + }, + ) + .finally(() => { systemSkillTask = undefined }) + systemSkillTask.catch(() => {}) + return { taskId } + } + + const startUserSkillAction = async body => { + if (body === null || typeof body !== 'object' || Array.isArray(body) + || Object.keys(body).sort().join(',') !== 'action,entryId,revision') { + throw new Error('User Skill request is invalid') + } + if (typeof body.entryId !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(body.entryId)) throw new Error('User Skill entry ID is invalid') + if (typeof body.revision !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(body.revision)) throw new Error('User Skill revision is invalid') + if (!['enable', 'disable', 'delete'].includes(body.action)) throw new Error('User Skill action is invalid') + requireRuntimeIdle() + if (coordinator.hasActiveTask?.() === true) throw new UpdateConflictError('an update task is already running') + await validateUserSkillAction(body) + requireRuntimeIdle() + if (coordinator.hasActiveTask?.() === true) throw new UpdateConflictError('an update task is already running') + const taskId = randomUUID() + publishUserSkill({ status: 'running', taskId, entryId: body.entryId, action: body.action, error: null }) + userSkillTask = Promise.resolve() + .then(() => recordAudit(`user-skill.${body.action}.started`, { taskId, entryId: body.entryId })) + .then(() => configureUserSkill(body)) + .then( + async () => { + await recordAudit(`user-skill.${body.action}.completed`, { taskId, entryId: body.entryId }) + publishUserSkill({ status: 'success', taskId, entryId: body.entryId, action: body.action, error: null }) + }, + async error => { + const message = error instanceof Error ? error.message : 'User Skill operation failed' + await recordAudit(`user-skill.${body.action}.failed`, { error, taskId, entryId: body.entryId }) + publishUserSkill({ status: 'failed', taskId, entryId: body.entryId, action: body.action, error: message }) + }, + ) + .finally(() => { userSkillTask = undefined }) + userSkillTask.catch(() => {}) + return { taskId } + } + + const startLifecycle = action => { + if (!['start', 'stop', 'restart'].includes(action)) throw new Error('DSH lifecycle action is invalid') + requireRuntimeIdle() + if (coordinator.hasActiveTask?.() === true) throw new UpdateConflictError('an update task is already running') + const taskId = randomUUID() + const state = action === 'start' ? 'starting' : action === 'stop' ? 'stopping' : 'restarting' + const operation = action === 'start' ? startDsh : action === 'stop' ? stopDsh : restartDsh + publishLifecycle({ state, action, taskId, attempt: 0, error: null }) + lifecycleTask = Promise.resolve() + .then(() => recordAudit(`dsh.${action}.started`, { taskId })) + .then(() => action === 'restart' && restartDelayMs > 0 + ? new Promise(resolve => setTimeout(resolve, restartDelayMs)) + : undefined) + .then(() => operation()) + .then( + async () => { + await recordAudit(`dsh.${action}.completed`, { taskId }) + publishLifecycle({ state: action === 'stop' ? 'stopped' : 'running', action: null, taskId, attempt: 0, error: null }) + if (action !== 'stop') publishPlugin({ restartRequired: false }) + // DSH readiness is the restart completion boundary. Refreshing the + // user-plugin inventory can be slow on mounted or CI filesystems and + // must not keep an already-ready Runtime in the restarting state. + if (action !== 'stop') void refreshLoadedUserPlugins() + }, + async error => { + const message = error instanceof Error ? error.message : `DSH ${action} failed` + await recordAudit(`dsh.${action}.failed`, { error, taskId }) + publishLifecycle({ state: 'failed', action: null, taskId, error: message }) + if (action === 'restart' && pluginState.restartRequired) { + publishPlugin({ strategy: 'next-start', activationError: message, restartRequired: false }) + } + }, + ) + .finally(() => { lifecycleTask = undefined }) + lifecycleTask.catch(() => {}) + return { taskId } + } + + const startRuntimeReset = () => { + requireRuntimeIdle() + if (coordinator.hasActiveTask?.() === true) throw new UpdateConflictError('an update task is already running') + const taskId = randomUUID() + publishRuntimeReset({ status: 'resetting', taskId, error: null }) + runtimeResetTask = Promise.resolve() + .then(() => recordAudit('runtime.reset.started', { taskId })) + .then(() => resetRuntime()) + .then( + async () => { + await refreshLoadedUserPlugins() + await recordAudit('runtime.reset.completed', { taskId }) + publishRuntimeReset({ status: 'success', taskId, error: null }) + publishPlugin({ restartRequired: false }) + }, + async error => { + const message = error instanceof Error ? error.message : 'Runtime reset failed' + await recordAudit('runtime.reset.failed', { error, taskId }) + publishRuntimeReset({ status: 'failed', taskId, error: message }) + }, + ) + .finally(() => { runtimeResetTask = undefined }) + runtimeResetTask.catch(() => {}) + return { taskId } + } + + server = createServer(async (request, response) => { + let pathname = 'invalid-url' + try { + const url = new URL(request.url ?? '/', 'http://management.internal') + pathname = url.pathname + const internalHealth = pathname === '/_dsh_platform/internal/health' + if (internalHealth) { + if (request.method !== 'GET') return send(response, 405, { error: 'method not allowed' }) + return send(response, 200, { ready: true }) + } + if (!await authorizeInternal(request)) return send(response, 401, { + error: 'management capability required', code: 'CAPABILITY_REQUIRED', + }) + if (await sendConsoleAsset(request, response, url.pathname, { + public: consoleRoot, + dependencies: consoleDependencyRoot, + })) return + if (!url.pathname.startsWith(API_PREFIX)) return send(response, 404, { error: 'not found' }) + const route = url.pathname.slice(API_PREFIX.length) + if (request.method === 'GET' && route === 'status') { + const currentPlatformStatus = await platformStatus() + send(response, 200, { + ...(await coordinator.publicStatus()), + ...currentPlatformStatus, + dshLifecycle: currentLifecycle(currentPlatformStatus.dshLifecycle), + runtimeReset: runtimeResetState, + systemPluginOperation: pluginState, + systemSkillOperation: systemSkillState, + userSkillOperation: userSkillState, + userPluginOperation: userPluginState, + proxyTestOperation: proxyTestState, + }) + } else if (request.method === 'GET' && route === 'bundled-plugins') { + send(response, 200, { plugins: await listBundledPlugins() }) + } else if (request.method === 'GET' && /^bundled-plugins\/task\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(route)) { + const taskId = route.slice('bundled-plugins/task/'.length) + const task = pluginTasks.get(taskId) + if (task === undefined) { + const error = new Error('System Plugin task was not found') + error.statusCode = 404 + throw error + } + send(response, 200, task) + } else if (request.method === 'GET' && route === 'system-skills') { + send(response, 200, { skills: await listSystemSkills() }) + } else if (request.method === 'GET' && route === 'user-skills') { + send(response, 200, await listUserSkills()) + } else if (request.method === 'GET' && route === 'user-plugins') { + send(response, 200, await listUserPlugins()) + } else if (request.method === 'GET' && route === 'proxy') { + send(response, 200, await getProxyConfiguration()) + } else if (request.method === 'PUT' && route === 'proxy') { + const body = await jsonBody(request, MAX_PROXY_BODY_BYTES) + await recordAudit('proxy.configuration.update.started', { + baseRevision: typeof body?.baseRevision === 'string' ? body.baseRevision : null, + }) + try { + const configured = await updateProxyConfiguration(body) + await recordAudit('proxy.configuration.update.completed', { revision: configured.revision }) + send(response, 200, configured) + } catch (error) { + await recordAudit('proxy.configuration.update.failed', { + code: error?.code ?? 'PROXY_MANAGER_UNAVAILABLE', + stage: error?.stage ?? 'unknown', + }) + throw error + } + } else if (request.method === 'GET' && route === 'proxy/provider-inventory') { + send(response, 200, await listProxyProviders()) + } else if (request.method === 'POST' && route === 'proxy/test') { + const task = await startProxyTest(await jsonBody(request, MAX_PROXY_BODY_BYTES)) + await recordAudit('proxy.test.started', { taskId: task.taskId }) + publishProxyTest(task) + watchProxyTest(task.taskId, task) + send(response, 202, { taskId: task.taskId }) + } else if (request.method === 'GET' && PROXY_TEST_TASK_ROUTE.test(route)) { + const state = await getProxyTest(PROXY_TEST_TASK_ROUTE.exec(route)[1]) + publishProxyTest(state) + send(response, 200, state) + } else if (request.method === 'DELETE' && PROXY_TEST_TASK_ROUTE.test(route)) { + const state = await cancelProxyTest(PROXY_TEST_TASK_ROUTE.exec(route)[1]) + publishProxyTest(state) + await recordAudit('proxy.test.cancel-requested', { taskId: state.taskId }) + send(response, 202, state) + } else if (request.method === 'POST' && route === 'user-plugins/apply') { + send(response, 202, await startUserPluginAction(await jsonBody(request))) + } else if (request.method === 'GET' && /^user-plugins\/task\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(route)) { + const taskId = route.slice('user-plugins/task/'.length) + const task = userPluginTasks.get(taskId) + if (task === undefined) { + const error = new Error('User Plugin task was not found') + error.statusCode = 404 + throw error + } + send(response, 200, task) + } else if (request.method === 'POST' && route === 'terminal/sessions') { + send(response, 201, terminalSessions.create(await jsonBody(request))) + } else if (request.method === 'GET' && TERMINAL_SESSION_ROUTE.test(route)) { + send(response, 200, terminalSessions.status(TERMINAL_SESSION_ROUTE.exec(route)[1])) + } else if (request.method === 'DELETE' && TERMINAL_SESSION_ROUTE.test(route)) { + send(response, 200, terminalSessions.close(TERMINAL_SESSION_ROUTE.exec(route)[1])) + } else if (request.method === 'GET' && route === 'settings-document') { + if (settingsDocument === undefined) throw new Error('settings document editing is not configured') + send(response, 200, await settingsDocument.read()) + } else if (request.method === 'PUT' && route === 'settings-document') { + if (settingsDocument === undefined) throw new Error('settings document editing is not configured') + const body = await jsonBody(request, MAX_SETTINGS_DOCUMENT_BYTES * 6 + 4096) + if (body === null || typeof body !== 'object' || Array.isArray(body) + || Object.keys(body).some(key => !['content', 'revision'].includes(key))) { + throw new Error('settings document request is invalid') + } + const saved = await settingsDocument.write(body.content, body.revision) + await audit('settings-document.saved', { revision: saved.revision }) + send(response, 200, saved) + } else if (request.method === 'GET' && route === 'files/config') { + send(response, 200, fileLocations) + } else if (request.method === 'GET' && route === 'files/list') { + if (fileInventory === undefined) throw new Error('file management is not configured') + const result = await fileInventory.list(url.searchParams.get('path'), { + cursor: url.searchParams.get('cursor'), limit: url.searchParams.get('limit'), + offset: url.searchParams.get('offset'), + sort: url.searchParams.get('sort') ?? 'name', order: url.searchParams.get('order') ?? 'asc', + }) + await recordAudit('files.list.completed', { path: result.path, entries: result.entries.length }) + send(response, 200, result) + } else if (request.method === 'GET' && route === 'files/stat') { + if (fileInventory === undefined) throw new Error('file management is not configured') + let result + try { + result = await fileInventory.stat(url.searchParams.get('path')) + } catch (error) { + if (url.searchParams.get('optional') !== 'true' || error?.statusCode !== 404) throw error + return send(response, 200, null) + } + await recordAudit('files.stat.completed', { path: result.path, type: result.type }) + send(response, 200, result) + } else if (request.method === 'GET' && route === 'files/content') { + if (fileInventory === undefined) throw new Error('file management is not configured') + const result = await fileInventory.content(url.searchParams.get('path')) + await recordAudit('files.content.completed', { path: result.path, size: result.size }) + send(response, 200, result) + } else if (request.method === 'PUT' && route === 'files/content') { + if (fileEditor === undefined) throw new Error('file editing is not configured') + const body = await jsonBody(request, 2 * 1024 * 1024 + 8192) + if (body === null || typeof body !== 'object' || Array.isArray(body) + || Object.keys(body).some(key => !['path', 'content', 'revision', 'create'].includes(key))) throw new Error('file content request is invalid') + const saved = await fileEditor.write(body.path, body.content, body.revision, { create: body.create === true }) + await recordAudit('files.content.saved', { path: saved.path, size: saved.size, managed: saved.managed }) + send(response, body.create === true ? 201 : 200, saved) + } else if (request.method === 'GET' && route === 'files/download') { + if (fileTransfers === undefined) throw new Error('file transfers are not configured') + const path = url.searchParams.get('path') + const transfer = fileTasks?.startTransfer({ operation: 'download', path }, async controls => { + const download = await fileTransfers.openDownload(path, { + revision: url.searchParams.get('revision') ?? undefined, + range: typeof request.headers.range === 'string' ? request.headers.range : undefined, + signal: controls.signal, + }) + controls.progress({ processedBytes: 0, totalBytes: Number(download.headers['content-length']) }) + await fileTransfers.sendDownload(response, download, { + signal: controls.signal, + onProgress: (processedBytes, totalBytes) => controls.progress({ processedBytes, totalBytes }), + }) + return download + }) + if (transfer === undefined) throw new Error('file tasks are not configured') + await recordAudit('files.download.started', { path, taskId: transfer.task.taskId }) + const download = await transfer.result + await recordAudit('files.download.completed', { path: download.path, revision: download.revision, taskId: transfer.task.taskId }) + } else if (request.method === 'POST' && route === 'files/upload') { + if (fileTransfers === undefined) throw new Error('file transfers are not configured') + const lengthHeader = request.headers['content-length'] + const contentLength = lengthHeader === undefined ? undefined : Number(lengthHeader) + if (contentLength !== undefined && (!Number.isSafeInteger(contentLength) || contentLength < 0)) throw new Error('Content-Length is invalid') + const path = url.searchParams.get('path') + await recordAudit('files.upload.started', { path }) + if (fileTasks === undefined) throw new Error('file tasks are not configured') + const transfer = fileTasks.startTransfer({ operation: 'upload', path, totalBytes: contentLength ?? null }, controls => fileTransfers.upload(request, path, { + conflict: url.searchParams.get('conflict') ?? 'reject', contentLength, signal: controls.signal, + onProgress: (processedBytes, totalBytes) => controls.progress({ processedBytes, totalBytes }), + })) + const result = await transfer.result + await recordAudit('files.upload.completed', { path: result.path, size: result.size }) + send(response, 201, result) + } else if (request.method === 'POST' && route === 'files/tasks') { + if (fileTasks === undefined) throw new Error('file tasks are not configured') + const body = await jsonBody(request) + if (fileTasks.wouldManage?.(body) === true) requireRuntimeIdle() + const task = fileTasks.start(body) + await recordAudit(`files.${task.operation}.started`, { path: task.path, taskId: task.taskId, managed: task.managed }) + send(response, 202, task) + } else if (request.method === 'GET' && route === 'files/tasks') { + if (fileTasks === undefined) throw new Error('file tasks are not configured') + send(response, 200, { tasks: fileTasks.list() }) + } else if (request.method === 'GET' && FILE_TASK_ROUTE.test(route)) { + if (fileTasks === undefined) throw new Error('file tasks are not configured') + const taskId = FILE_TASK_ROUTE.exec(route)[1] + send(response, 200, fileTasks.get(taskId, { cursor: url.searchParams.get('cursor'), limit: url.searchParams.get('limit') })) + } else if (request.method === 'DELETE' && FILE_TASK_ROUTE.test(route)) { + if (fileTasks === undefined) throw new Error('file tasks are not configured') + const task = fileTasks.cancel(FILE_TASK_ROUTE.exec(route)[1]) + await recordAudit('files.task.cancel-requested', { operation: task.operation, taskId: task.taskId }) + send(response, 202, task) + } else if (request.method === 'POST' && route === 'check') { + const body = await jsonBody(request) + const source = body.source ?? 'manual' + if (!['manual', 'page-open', 'channel-change'].includes(source)) throw new Error('update check source is invalid') + const target = await coordinator.check(source) + if (target.busy === true) { + send(response, 202, { busy: true }) + return + } + send(response, 200, target.unavailable === true + ? { available: false, upstream: target.upstream } + : { + available: target.updateAvailable ?? true, + targetSequence: target.value.targetSequence, + desired: target.value.desired, + }) + } else if (request.method === 'POST' && route === 'update') { + requireRuntimeIdle() + const task = coordinator.startReconcile() + void task.completion + .then( + async () => { + await refreshLoadedUserPlugins() + await audit('update.completed', { taskId: task.taskId }) + }, + error => audit('update.failed', { error, taskId: task.taskId }), + ) + .catch(() => {}) + await audit('update.started', { taskId: task.taskId }) + send(response, 202, { taskId: task.taskId }) + } else if (request.method === 'POST' && ['start-dsh', 'stop-dsh', 'restart-dsh'].includes(route)) { + const task = startLifecycle(route.slice(0, -4)) + send(response, 202, task) + } else if (request.method === 'POST' && route === 'runtime/reset') { + const task = startRuntimeReset() + send(response, 202, task) + } else if (request.method === 'POST' && route === 'bundled-plugins/action') { + const body = await jsonBody(request) + send(response, 202, startPluginAction(body.id, body.action)) + } else if (request.method === 'POST' && route === 'bundled-plugins/toggle') { + const body = await jsonBody(request) + send(response, 202, startPluginAction(body.id, body.action, { toggleOnly: true })) + } else if (request.method === 'POST' && route === 'bundled-plugins/recovery-action') { + const body = await jsonBody(request) + send(response, 202, startPluginAction(body.id, body.action, { recovery: true })) + } else if (request.method === 'POST' && route === 'bundled-plugins/discard') { + requireRuntimeIdle() + if (coordinator.hasActiveTask?.() === true) throw new UpdateConflictError('an update task is already running') + const result = await discardBundledPluginChanges() + publishPlugin({ restartRequired: false }) + await audit('system-plugin.changes.discarded') + send(response, 200, result) + } else if (request.method === 'POST' && route === 'system-skills/action') { + const body = await jsonBody(request) + if (body === null || typeof body !== 'object' || Array.isArray(body) + || Object.keys(body).sort().join(',') !== 'action,skillId') { + throw new Error('System Skill request is invalid') + } + send(response, 202, startSystemSkillAction(body.skillId, body.action)) + } else if (request.method === 'POST' && route === 'user-skills/action') { + send(response, 202, await startUserSkillAction(await jsonBody(request))) + } else if (request.method === 'PUT' && route === 'channel') { + const body = await jsonBody(request) + const value = await coordinator.setChannel(body.channel) + await audit('update.channel.changed', { updateChannel: value.updateChannel }) + server.emit('management-state', value) + send(response, 200, value) + } else if (request.method === 'PUT' && route === 'automatic-check') { + const value = await updateAutomaticCheck(await jsonBody(request)) + await audit('update.automatic-check.configured', { + enabled: value.enabled, + intervalSeconds: value.intervalSeconds, + notificationsEnabled: value.notificationsEnabled, + }) + server.emit('management-state', value) + send(response, 200, value) + } else if (request.method === 'GET' && route === 'auth-settings') { + send(response, 200, await getAuthenticationSettings(request)) + } else if (request.method === 'PUT' && route === 'auth-settings') { + const body = await jsonBody(request) + const value = await updateAuthenticationSettings(body, request) + await audit('access.authentication.settings.changed', {}) + send(response, 200, value) + } else if (request.method === 'POST' && route === 'auth-totp/enrollments') { + send(response, 201, await beginTotpEnrollment(await jsonBody(request), request)) + } else if (request.method === 'POST' && route === 'auth-totp/enrollments/confirm') { + send(response, 200, await confirmTotpEnrollment(await jsonBody(request), request)) + } else if (request.method === 'POST' && route === 'auth-totp/enrollments/cancel') { + send(response, 200, await cancelTotpEnrollment(await jsonBody(request), request)) + } else if (request.method === 'POST' && route === 'auth-totp/disable-confirmations') { + send(response, 201, await beginTotpDisableConfirmation(await jsonBody(request), request)) + } else if (request.method === 'POST' && route === 'auth-totp/disable-confirmations/confirm') { + send(response, 200, await confirmTotpDisable(await jsonBody(request), request)) + } else if (request.method === 'POST' && route === 'auth-totp/disable-confirmations/cancel') { + send(response, 200, await cancelTotpDisableConfirmation(await jsonBody(request), request)) + } else if (request.method === 'POST' && route === 'auth-sessions/revoke') { + const body = await jsonBody(request) + const value = await revokeAuthenticationSessions(body, request) + await audit('access.sessions.revoked', { + sessionId: body.sessionId ?? null, + currentSessionRevoked: value.currentSessionRevoked === true, + }) + send(response, 200, value) + } else if (request.method === 'POST' && route === 'management-origin/transitions') { + const body = await jsonBody(request) + const value = await createManagementOriginTransition(body, request) + await audit('access.management-transition.created', { mode: body.mode ?? null }) + send(response, 201, value) + } else if (request.method === 'POST' && route === 'management-origin/transitions/commit') { + const value = await commitManagementOriginTransition(await jsonBody(request), request) + await audit('access.management-origin.changed', { mode: value.account?.managementAccess?.mode ?? null }) + send(response, 200, value) + } else if (request.method === 'POST' && route === 'management-origin/transitions/failure') { + send(response, 200, await reportManagementOriginTransitionFailure(await jsonBody(request))) + } else if (request.method === 'POST' && route === 'holds/retry') { + const body = await jsonBody(request) + const result = await coordinator.retryHold(body.id) + await audit('update.hold.retried', { holdId: body.id }) + send(response, 200, result) + } else if (request.method === 'GET' && route === 'rollback-plan') { + send(response, 200, { plan: await coordinator.rollbackPlan() }) + } else if (request.method === 'POST' && ['rollback', 'return-stable'].includes(route)) { + requireRuntimeIdle() + const body = await jsonBody(request) + const task = coordinator.startCompleteRollback(body.planId, { + requireConfirmation: route === 'return-stable', + confirmDataLoss: body.confirmDataLoss, + }) + void task.completion + .then(async () => { + await refreshLoadedUserPlugins() + await audit(`${route}.completed`, { taskId: task.taskId }) + }) + .catch(error => audit(`${route}.failed`, { error, taskId: task.taskId })) + .catch(() => {}) + await audit(`${route}.started`, { taskId: task.taskId }) + send(response, 202, { taskId: task.taskId }) + } else if (request.method === 'GET' && route === 'events') { + let heartbeat + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + 'x-accel-buffering': 'no', + }) + response.flushHeaders() + event(response, 'state', await coordinator.state.read()) + const listener = value => event(response, 'state', value) + coordinator.on('state', listener) + server.on('management-state', listener) + heartbeat = setInterval(() => event(response, 'heartbeat', { timestamp: new Date().toISOString() }), stateHeartbeatMs) + heartbeat.unref() + response.once('close', () => { + clearInterval(heartbeat) + coordinator.off('state', listener) + server.off('management-state', listener) + }) + } else if (request.method === 'GET' && route === 'logs') { + send(response, 200, { entries: await logs.query(logOptions(url)) }) + } else if (request.method === 'GET' && route === 'logs/stream') { + const options = logOptions(url) + const selectedSources = options.sources === undefined ? undefined : new Set(options.sources) + const sent = new Set() + const sentOrder = [] + const sendLog = value => { + const identity = JSON.stringify(value) + if (sent.has(identity)) return + sent.add(identity) + sentOrder.push(identity) + if (sentOrder.length > 10_000) sent.delete(sentOrder.shift()) + event(response, 'log', value) + } + const listener = value => { + if (selectedSources === undefined || selectedSources.has(value.source)) sendLog(value) + } + let follower + let heartbeat + let closed = false + const cleanup = () => { + closed = true + clearInterval(heartbeat) + follower?.close() + logs.off('entry', listener) + } + response.once('close', cleanup) + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + 'x-accel-buffering': 'no', + }) + response.flushHeaders() + event(response, 'heartbeat', { timestamp: new Date().toISOString() }) + event(response, 'sources', await logs.sources()) + for (const entry of await logs.query(options)) sendLog(entry) + if (closed) return + logs.on('entry', listener) + let followFailureReported = false + follower = await logs.follow(options, sendLog, { + onError: error => { + if (followFailureReported) return + followFailureReported = true + void logs.diagnostic('log-manager', 'log.follow.failed', { error }) + }, + }) + if (closed) { + follower.close() + return + } + for (const entry of await logs.query(options)) sendLog(entry) + heartbeat = setInterval(() => event(response, 'heartbeat', { timestamp: new Date().toISOString() }), 15_000) + heartbeat.unref() + if (closed) clearInterval(heartbeat) + } else send(response, 404, { error: 'not found' }) + } catch (error) { + const conflict = error instanceof UpdateConflictError || error instanceof SettingsDocumentConflictError + || error?.code === 'REVISION_CONFLICT' || error?.statusCode === 409 + await logs.diagnostic('platform-management', 'management.request.failed', { + error, + level: conflict ? 'warning' : 'error', + method: request.method ?? null, + pathname, + }) + if (error?.proxyError !== undefined) { + send(response, error.statusCode ?? 400, { error: error.proxyError }) + } else { + send(response, conflict ? 409 : (error?.statusCode ?? 400), { + error: error instanceof Error ? error.message : 'management request failed', + code: error?.code ?? 'MANAGEMENT_ERROR', + }) + } + } + }) + server.setMaxListeners(0) + server.on('upgrade', (request, socket, head) => { + let pathname = 'invalid-url' + void (async () => { + pathname = new URL(request.url ?? '/', 'http://management.internal').pathname + if (!await authorizeInternal(request)) { + socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') + return + } + if (!pathname.startsWith(API_PREFIX)) throw new Error('not found') + const match = TERMINAL_STREAM_ROUTE.exec(pathname.slice(API_PREFIX.length)) + if (match === null) throw new Error('not found') + terminalSessions.upgrade(request, socket, head, match[1]) + })().catch(error => { + void logs.diagnostic('platform-management', 'management.terminal-upgrade.failed', { + error, + level: error?.statusCode === 404 ? 'warning' : 'error', + pathname, + }) + const status = error?.statusCode === 503 ? '503 Service Unavailable' : '404 Not Found' + if (!socket.destroyed) socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`) + }) + }) + server.once('close', () => { proxyWatchClosed = true }) + if (recoverUserPluginTransaction !== undefined) server.once('listening', () => { + userPluginTask = Promise.resolve() + .then(() => recoverUserPluginTransaction()) + .then(async state => { + if (state === undefined) return + publishUserPlugin({ + status: state.phase === 'completed' ? 'success' : 'failed', + taskId: state.taskId, + phase: state.phase, + error: state.error, + }) + await audit('user-plugin.recovery.completed', { + taskId: state.taskId, + phase: state.phase, + recoveryResult: state.recoveryResult, + }) + }, async error => { + publishUserPlugin({ status: 'failed', phase: 'recovery', error: error instanceof Error ? error.message : String(error) }) + await audit('user-plugin.recovery.failed', { error }) + }) + .finally(() => { userPluginTask = undefined }) + userPluginTask.catch(() => {}) + }) + return server +} + +export async function listenManagement(server, socketPath) { + await mkdir(dirname(socketPath), { recursive: true }) + await rm(socketPath, { force: true }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + await chmod(socketPath, 0o600) +} diff --git a/upstream/container/control-plane/services/management/settings-document.mjs b/upstream/container/control-plane/services/management/settings-document.mjs new file mode 100644 index 0000000..c46475b --- /dev/null +++ b/upstream/container/control-plane/services/management/settings-document.mjs @@ -0,0 +1,82 @@ +import { createHash, randomUUID } from 'node:crypto' +import { chown, lstat, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' + +export const MAX_SETTINGS_DOCUMENT_BYTES = 1024 * 1024 +const UTF8 = new TextDecoder('utf-8', { fatal: true }) + +function revision(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function assertRegularFile(details) { + if (details.isSymbolicLink() || !details.isFile()) { + throw new Error('settings document must be a regular file') + } +} + +export class SettingsDocumentConflictError extends Error {} + +export class SettingsDocumentStore { + constructor(dshHome, { maxBytes = MAX_SETTINGS_DOCUMENT_BYTES } = {}) { + this.dshHome = resolve(dshHome) + this.path = join(this.dshHome, 'settings.yaml') + this.maxBytes = maxBytes + } + + async read() { + let details + try { + details = await lstat(this.path) + } catch (error) { + if (error?.code === 'ENOENT') { + const bytes = Buffer.alloc(0) + return Object.freeze({ content: '', revision: revision(bytes), exists: false }) + } + throw error + } + assertRegularFile(details) + if (details.size > this.maxBytes) throw new Error('settings document is too large') + const bytes = await readFile(this.path) + if (bytes.byteLength > this.maxBytes) throw new Error('settings document is too large') + let content + try { + content = UTF8.decode(bytes) + } catch { + throw new Error('settings document is not valid UTF-8') + } + return Object.freeze({ content, revision: revision(bytes), exists: true }) + } + + async write(content, expectedRevision) { + if (typeof content !== 'string') throw new Error('settings document content must be a string') + if (typeof expectedRevision !== 'string' || !/^[a-f0-9]{64}$/.test(expectedRevision)) { + throw new Error('settings document revision is invalid') + } + const bytes = Buffer.from(content) + if (bytes.byteLength > this.maxBytes) throw new Error('settings document is too large') + const current = await this.read() + if (current.revision !== expectedRevision) { + throw new SettingsDocumentConflictError('settings document changed since it was loaded') + } + + await mkdir(this.dshHome, { recursive: true, mode: 0o700 }) + const owner = current.exists ? await lstat(this.path) : await stat(this.dshHome) + if (current.exists) assertRegularFile(owner) + const temporary = join(this.dshHome, `.settings.yaml.${randomUUID()}.tmp`) + try { + await writeFile(temporary, bytes, { + flag: 'wx', + mode: current.exists ? owner.mode & 0o777 : 0o600, + }) + if (process.getuid?.() === 0) await chown(temporary, owner.uid, owner.gid) + if ((await this.read()).revision !== expectedRevision) { + throw new SettingsDocumentConflictError('settings document changed while it was being saved') + } + await rename(temporary, this.path) + } finally { + await rm(temporary, { force: true }) + } + return Object.freeze({ content, revision: revision(bytes), exists: true }) + } +} diff --git a/upstream/container/control-plane/services/management/terminal/pty-helper.py b/upstream/container/control-plane/services/management/terminal/pty-helper.py new file mode 100644 index 0000000..2610c51 --- /dev/null +++ b/upstream/container/control-plane/services/management/terminal/pty-helper.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 + +import base64 +import fcntl +import json +import os +import pty +import select +import signal +import struct +import sys +import termios + + +def emit(value): + sys.stdout.write(json.dumps(value, separators=(",", ":")) + "\n") + sys.stdout.flush() + + +def resize(master, cols, rows): + fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + + +def write_all(target, data): + remaining = memoryview(data) + while remaining: + written = os.write(target, remaining) + remaining = remaining[written:] + + +def terminate(pid, requested_signal): + try: + os.killpg(pid, requested_signal) + except ProcessLookupError: + return + except PermissionError: + os.kill(pid, requested_signal) + + +def main(): + if len(sys.argv) != 4: + raise RuntimeError("usage: pty-helper.py ") + cwd = sys.argv[1] + cols = int(sys.argv[2]) + rows = int(sys.argv[3]) + pid, master = pty.fork() + if pid == 0: + os.chdir(cwd) + os.umask(0o002) + os.execvpe("/bin/bash", ["/bin/bash", "-i"], os.environ) + def stop_child(received_signal, _frame): + terminate(pid, signal.SIGTERM if received_signal in (signal.SIGINT, signal.SIGTERM) else received_signal) + signal.signal(signal.SIGINT, stop_child) + signal.signal(signal.SIGTERM, stop_child) + resize(master, cols, rows) + stdin_fd = sys.stdin.fileno() + stdin_buffer = b"" + stdin_open = True + while True: + readable, _, _ = select.select([master] + ([stdin_fd] if stdin_open else []), [], [], 0.25) + if master in readable: + try: + data = os.read(master, 16384) + except OSError: + data = b"" + if not data: + break + emit({"type": "output", "data": base64.b64encode(data).decode("ascii")}) + if stdin_open and stdin_fd in readable: + data = os.read(stdin_fd, 65536) + if not data: + stdin_open = False + terminate(pid, signal.SIGHUP) + else: + stdin_buffer += data + while b"\n" in stdin_buffer: + line, stdin_buffer = stdin_buffer.split(b"\n", 1) + command = json.loads(line.decode("utf-8")) + command_type = command.get("type") + if command_type == "input": + write_all(master, base64.b64decode(command["data"], validate=True)) + elif command_type == "resize": + resize(master, int(command["cols"]), int(command["rows"])) + elif command_type == "signal": + terminate(pid, int(command["signal"])) + else: + raise RuntimeError("unknown PTY command") + finished, status = os.waitpid(pid, os.WNOHANG) + if finished == pid: + if os.WIFEXITED(status): + emit({"type": "exit", "code": os.WEXITSTATUS(status), "signal": None}) + else: + emit({"type": "exit", "code": None, "signal": os.WTERMSIG(status)}) + os.close(master) + return + _, status = os.waitpid(pid, 0) + if os.WIFEXITED(status): + emit({"type": "exit", "code": os.WEXITSTATUS(status), "signal": None}) + else: + emit({"type": "exit", "code": None, "signal": os.WTERMSIG(status)}) + os.close(master) + + +if __name__ == "__main__": + try: + main() + except Exception as error: + emit({"type": "error", "message": str(error)}) + sys.exit(1) diff --git a/upstream/container/control-plane/services/management/terminal/sessions.mjs b/upstream/container/control-plane/services/management/terminal/sessions.mjs new file mode 100644 index 0000000..b9e0b27 --- /dev/null +++ b/upstream/container/control-plane/services/management/terminal/sessions.mjs @@ -0,0 +1,288 @@ +import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { EventEmitter } from 'node:events' +import { userInfo } from 'node:os' +import { resolve } from 'node:path' +import { StringDecoder } from 'node:string_decoder' +import { WebSocketServer } from 'ws' +import { OUTBOUND_PROXY_ENVIRONMENT_KEYS } from '../../../../platform/lib/outbound-proxy.mjs' + +const SESSION_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const MAX_INPUT_BYTES = 64 * 1024 +const MAX_OUTPUT_BYTES = 256 * 1024 +const MAX_SOCKET_BUFFER_BYTES = 1024 * 1024 +const MAX_HELPER_INPUT_BUFFER_BYTES = 256 * 1024 +const MAX_SESSIONS = 32 +const MIN_COLS = 2 +const MAX_COLS = 500 +const MIN_ROWS = 1 +const MAX_ROWS = 200 + +function dimensions(value = {}) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).some(key => !['cols', 'rows'].includes(key))) throw new Error('Terminal dimensions are invalid') + const cols = value.cols ?? 80 + const rows = value.rows ?? 24 + if (!Number.isSafeInteger(cols) || cols < MIN_COLS || cols > MAX_COLS + || !Number.isSafeInteger(rows) || rows < MIN_ROWS || rows > MAX_ROWS) { + throw new Error('Terminal dimensions are outside the supported range') + } + return { cols, rows } +} + +function exactMessage(value, keys) { + return value !== null && typeof value === 'object' && !Array.isArray(value) + && Object.keys(value).sort().join(',') === [...keys].sort().join(',') +} + +class TerminalSession extends EventEmitter { + constructor({ id, helperPath, cwd, env, cols, rows, reconnectMs, report, python }) { + super() + this.id = id + this.createdAt = new Date().toISOString() + this.updatedAt = this.createdAt + this.status = 'running' + this.exit = null + this.output = Buffer.alloc(0) + this.connections = new Set() + this.everConnected = false + this.reconnectMs = reconnectMs + this.report = report + this.decoder = new StringDecoder('utf8') + this.outputDecoder = new StringDecoder('utf8') + this.lineBuffer = '' + this.closed = false + this.terminationTimers = [] + this.child = spawn(python, [helperPath, cwd, String(cols), String(rows)], { + cwd, + env: { ...env, TERM: 'xterm-256color' }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + this.childClosed = new Promise(resolveClosed => this.child.once('close', resolveClosed)) + this.child.stdout.on('data', chunk => this.consume(chunk)) + this.child.stderr.on('data', chunk => this.report('terminal.helper.stderr', { + sessionId: this.id, + bytes: chunk.byteLength, + level: 'warning', + })) + this.child.once('error', error => this.fail(error)) + this.child.once('exit', (code, signal) => { + if (this.status === 'running') this.finish({ code, signal }) + }) + this.child.once('close', () => { + for (const timer of this.terminationTimers) clearTimeout(timer) + }) + this.scheduleExpiry() + } + + publicStatus() { + return Object.freeze({ + sessionId: this.id, + status: this.status, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + connected: this.connections.size > 0, + exit: this.exit, + }) + } + + scheduleExpiry() { + clearTimeout(this.expiry) + this.expiry = setTimeout(() => { + if (this.connections.size === 0) this.terminate('reconnect-expired') + }, this.reconnectMs) + this.expiry.unref() + } + + sendCommand(value) { + if (this.status !== 'running' || !this.child.stdin.writable) return false + this.child.stdin.write(`${JSON.stringify(value)}\n`) + return true + } + + consume(chunk) { + this.lineBuffer += this.decoder.write(chunk) + for (let newline = this.lineBuffer.indexOf('\n'); newline !== -1; newline = this.lineBuffer.indexOf('\n')) { + const line = this.lineBuffer.slice(0, newline) + this.lineBuffer = this.lineBuffer.slice(newline + 1) + try { + const value = JSON.parse(line) + if (value.type === 'output' && typeof value.data === 'string') this.broadcastOutput(Buffer.from(value.data, 'base64')) + else if (value.type === 'exit') this.finish({ code: value.code ?? null, signal: value.signal ?? null }) + else if (value.type === 'error') this.fail(new Error(String(value.message))) + else this.fail(new Error('PTY helper emitted an invalid message')) + } catch (error) { + this.fail(error) + } + } + } + + broadcastOutput(bytes) { + this.output = Buffer.concat([this.output, bytes]) + if (this.output.byteLength > MAX_OUTPUT_BYTES) this.output = this.output.subarray(this.output.byteLength - MAX_OUTPUT_BYTES) + const data = this.outputDecoder.write(bytes) + if (data === '') return + const message = JSON.stringify({ type: 'output', data }) + for (const socket of this.connections) if (socket.readyState === 1) { + if (socket.bufferedAmount > MAX_SOCKET_BUFFER_BYTES) socket.close(1013, 'terminal client is too slow') + else socket.send(message) + } + } + + attach(socket) { + clearTimeout(this.expiry) + const reconnect = this.everConnected + this.everConnected = true + this.connections.add(socket) + void this.report(reconnect ? 'terminal.session.reconnected' : 'terminal.session.connected', { sessionId: this.id }) + if (this.output.byteLength > 0) socket.send(JSON.stringify({ type: 'output', data: this.output.toString('utf8') })) + if (this.exit !== null) socket.send(JSON.stringify({ type: 'exit', ...this.exit })) + socket.on('message', data => this.handleMessage(socket, data)) + socket.once('close', () => { + this.connections.delete(socket) + void this.report('terminal.session.disconnected', { sessionId: this.id }) + if (!this.closed && this.connections.size === 0) this.scheduleExpiry() + }) + socket.once('error', error => this.report('terminal.session.connection.failed', { error, sessionId: this.id })) + } + + handleMessage(socket, data) { + try { + if (Buffer.byteLength(data) > MAX_INPUT_BYTES + 1024) throw new Error('Terminal message is too large') + const value = JSON.parse(data.toString()) + if (value.type === 'input') { + if (!exactMessage(value, ['type', 'data']) || typeof value.data !== 'string' + || Buffer.byteLength(value.data) > MAX_INPUT_BYTES) throw new Error('Terminal input is invalid') + if (this.child.stdin.writableLength > MAX_HELPER_INPUT_BUFFER_BYTES) { + socket.close(1013, 'terminal input is too fast') + return + } + this.sendCommand({ type: 'input', data: Buffer.from(value.data).toString('base64') }) + } else if (value.type === 'resize') { + if (!exactMessage(value, ['type', 'cols', 'rows'])) throw new Error('Terminal resize is invalid') + this.sendCommand({ type: 'resize', ...dimensions({ cols: value.cols, rows: value.rows }) }) + } else throw new Error('Terminal message type is invalid') + } catch { + socket.close(1008, 'invalid terminal message') + } + } + + finish(exit) { + if (this.status !== 'running') return + this.status = 'exited' + this.exit = Object.freeze({ code: exit.code ?? null, signal: exit.signal ?? null }) + this.updatedAt = new Date().toISOString() + for (const socket of this.connections) if (socket.readyState === 1) { + socket.send(JSON.stringify({ type: 'exit', ...this.exit })) + } + void this.report('terminal.session.exited', { sessionId: this.id, ...this.exit }) + if (this.connections.size === 0) this.scheduleExpiry() + this.emit('terminal-exit') + } + + fail(error) { + if (this.status !== 'running') return + void this.report('terminal.session.failed', { error, sessionId: this.id }) + this.terminate('failed') + } + + terminate(reason = 'closed') { + if (this.closed) return + this.closed = true + clearTimeout(this.expiry) + if (this.status === 'running') { + this.sendCommand({ type: 'signal', signal: 15 }) + for (const [delay, signal] of [[2_000, 'SIGTERM'], [4_000, 'SIGKILL']]) { + const timer = setTimeout(() => this.child.kill(signal), delay) + timer.unref() + this.terminationTimers.push(timer) + } + } + this.child.stdin.end() + for (const socket of this.connections) socket.close(1001, 'terminal session closed') + this.connections.clear() + void this.report('terminal.session.closed', { reason, sessionId: this.id }) + this.emit('terminal-close') + } + + async stop(reason) { + this.terminate(reason) + let timeout + await Promise.race([ + this.childClosed, + new Promise(resolveTimeout => { timeout = setTimeout(resolveTimeout, 5_000) }), + ]) + clearTimeout(timeout) + } +} + +export class TerminalSessionManager { + constructor({ + helperPath = new URL('./pty-helper.py', import.meta.url).pathname, + cwd = '/root', + dshHome = '/data/dsh', + env = process.env, + python = '/usr/bin/python3', + reconnectMs = 30_000, + report = async () => {}, + } = {}) { + this.helperPath = resolve(helperPath) + this.cwd = resolve(cwd) + this.env = { ...env, HOME: userInfo().homedir, DSH_HOME: dshHome } + for (const key of OUTBOUND_PROXY_ENVIRONMENT_KEYS) delete this.env[key] + this.python = python + this.reconnectMs = reconnectMs + this.report = (message, fields) => Promise.resolve().then(() => report(message, fields)).catch(() => {}) + this.sessions = new Map() + this.webSockets = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_INPUT_BYTES + 1024 }) + } + + create(value = {}, environment = {}) { + if (this.sessions.size >= MAX_SESSIONS) { + const error = new Error('Terminal session limit reached') + error.statusCode = 409 + throw error + } + const { cols, rows } = dimensions(value) + const sessionId = randomUUID() + const session = new TerminalSession({ + id: sessionId, helperPath: this.helperPath, cwd: this.cwd, env: { ...this.env, ...environment }, + cols, rows, reconnectMs: this.reconnectMs, report: this.report, python: this.python, + }) + this.sessions.set(sessionId, session) + session.once('terminal-close', () => this.sessions.delete(sessionId)) + void this.report('terminal.session.created', { sessionId }) + return session.publicStatus() + } + + require(id) { + if (!SESSION_PATTERN.test(id) || !this.sessions.has(id)) { + const error = new Error('Terminal session was not found') + error.statusCode = 404 + throw error + } + return this.sessions.get(id) + } + + status(id) { + return this.require(id).publicStatus() + } + + close(id) { + const session = this.require(id) + session.terminate('deleted') + return { sessionId: id, closed: true } + } + + upgrade(request, socket, head, id) { + const session = this.require(id) + this.webSockets.handleUpgrade(request, socket, head, webSocket => session.attach(webSocket)) + } + + async shutdown() { + await Promise.all([...this.sessions.values()].map(session => session.stop('management-stopping'))) + this.webSockets.close() + } +} + +export const terminalSessionInternals = Object.freeze({ dimensions, SESSION_PATTERN }) diff --git a/upstream/container/control-plane/services/management/user-skills.mjs b/upstream/container/control-plane/services/management/user-skills.mjs new file mode 100644 index 0000000..1daf279 --- /dev/null +++ b/upstream/container/control-plane/services/management/user-skills.mjs @@ -0,0 +1,226 @@ +import { createHash, randomUUID } from 'node:crypto' +import { chmod, lchown, lstat, mkdir, readFile, readdir, rename, rm, stat } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import { parseDocument } from 'yaml' + +const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const DISABLED_DIRECTORY = '.disabled' +const MAX_SKILL_BYTES = 2 * 1024 * 1024 + +async function adoptSkillTree(path, uid, gid) { + const metadata = await lstat(path) + await lchown(path, uid ?? -1, gid ?? -1) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) return + for (const entry of await readdir(path)) await adoptSkillTree(join(path, entry), uid, gid) +} + +async function adoptSkillRoot(root, uid, gid, { skipSystem = false } = {}) { + await lchown(root, uid ?? -1, gid ?? -1) + for (const entry of await readdir(root)) { + if (skipSystem && entry === '.system') continue + const path = join(root, entry) + const metadata = await lstat(path) + if ((uid === undefined || metadata.uid === uid) && (gid === undefined || metadata.gid === gid)) continue + await adoptSkillTree(path, uid, gid) + } +} + +export async function prepareUserSkillRoots({ dshHome = '/data/dsh', agentsHome = '/home/node/.agents', uid, gid } = {}) { + const roots = [join(resolve(dshHome), 'skills'), join(resolve(agentsHome), 'skills')] + for (const [index, root] of roots.entries()) { + await mkdir(root, { recursive: true }) + const metadata = await lstat(root) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error(`User Skill root ${root} must be a directory`) + if (uid !== undefined || gid !== undefined) await adoptSkillRoot(root, uid, gid, { skipSystem: index === 0 }) + await chmod(root, 0o2775) + } + return Object.freeze(roots) +} + +export class UserSkillConflictError extends Error { + constructor(message) { + super(message) + this.statusCode = 409 + } +} + +function entryId(source, location, entryName) { + return `sha256:${createHash('sha256').update(`${source}\0${location}\0${entryName}`).digest('hex')}` +} + +function revisionFor(entries) { + const hash = createHash('sha256') + for (const entry of entries) { + hash.update(`${entry.entryId}\0${entry.fingerprint}\0`) + } + return `sha256:${hash.digest('hex')}` +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error) +} + +async function entryType(path, name) { + const link = await lstat(path) + let target = link + if (link.isSymbolicLink()) target = await stat(path) + if (target.isDirectory()) return { kind: 'directory', documentPath: join(path, 'SKILL.md'), link } + if (target.isFile() && name.endsWith('.md')) return { kind: 'file', documentPath: path, link } + return undefined +} + +async function inspectEntry(root, source, location, entryName) { + const path = location === 'active' ? join(root, entryName) : join(root, DISABLED_DIRECTORY, entryName) + let type + let bytes + let damaged = false + let metadataError = null + let name = null + let description = null + try { + type = await entryType(path, entryName) + if (type === undefined) return undefined + const metadata = await stat(type.documentPath) + if (!metadata.isFile() || metadata.size > MAX_SKILL_BYTES) { + throw new Error(metadata.size > MAX_SKILL_BYTES ? 'SKILL.md is too large' : 'SKILL.md is not a regular file') + } + bytes = await readFile(type.documentPath) + const text = bytes.toString('utf8') + if (text.includes('\uFFFD') || !text.startsWith('---\n')) throw new Error('SKILL.md frontmatter is missing') + const close = text.indexOf('\n---', 4) + if (close < 0) throw new Error('SKILL.md frontmatter is not closed') + const document = parseDocument(text.slice(4, close), { prettyErrors: false, strict: true }) + if (document.errors.length > 0) throw new Error(document.errors[0].message) + const value = document.toJS() + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('SKILL.md frontmatter is invalid') + if (typeof value.name !== 'string' || !SKILL_NAME_PATTERN.test(value.name)) throw new Error('Skill name is invalid') + if (typeof value.description !== 'string' || value.description.trim() === '') throw new Error('Skill description is invalid') + name = value.name + description = value.description.trim() + } catch (error) { + if (error?.code === 'ENOENT' && type === undefined) return undefined + damaged = true + metadataError = errorMessage(error) + } + const link = type?.link ?? await lstat(path) + const fingerprint = createHash('sha256') + .update(`${link.dev}:${link.ino}:${link.mode}:${link.size}:${link.mtimeNs ?? BigInt(Math.trunc(link.mtimeMs * 1e6))}:`) + .update(bytes ?? Buffer.alloc(0)) + .digest('hex') + return Object.freeze({ + entryId: entryId(source, location, entryName), + entryName, + source, + kind: type?.kind ?? (entryName.endsWith('.md') ? 'file' : 'directory'), + name, + description, + enabled: location === 'active', + damaged, + metadataError, + symbolicLink: link.isSymbolicLink(), + path, + root, + fingerprint, + }) +} + +async function rootEntries(root, source) { + const result = [] + const locations = [ + { name: 'active', path: root }, + { name: 'disabled', path: join(root, DISABLED_DIRECTORY) }, + ] + for (const location of locations) { + let entries + try { + entries = await readdir(location.path, { withFileTypes: true }) + } catch (error) { + if (error?.code === 'ENOENT') continue + throw error + } + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (location.name === 'active' && (entry.name === DISABLED_DIRECTORY || (source === 'user-dsh' && entry.name === '.system'))) continue + const inspected = await inspectEntry(root, source, location.name, entry.name) + if (inspected !== undefined) result.push(inspected) + } + } + return result +} + +async function removeEntry(path) { + const metadata = await lstat(path) + if (metadata.isDirectory() && !metadata.isSymbolicLink()) await rm(path, { recursive: true }) + else await rm(path) +} + +export class UserSkillManager { + constructor({ dshHome = '/data/dsh', agentsHome = '/home/node/.agents' } = {}) { + this.roots = Object.freeze([ + Object.freeze({ source: 'user-dsh', path: join(resolve(dshHome), 'skills') }), + Object.freeze({ source: 'user-agents', path: join(resolve(agentsHome), 'skills') }), + ]) + } + + async inspect() { + const entries = (await Promise.all(this.roots.map(root => rootEntries(root.path, root.source)))) + .flat() + .sort((left, right) => left.source.localeCompare(right.source) || left.entryName.localeCompare(right.entryName) || Number(right.enabled) - Number(left.enabled)) + return Object.freeze({ + schema: 1, + revision: revisionFor(entries), + skills: Object.freeze(entries.map(({ path: _path, root: _root, fingerprint: _fingerprint, ...entry }) => Object.freeze(entry))), + internal: entries, + }) + } + + async list() { + const { internal: _internal, ...inventory } = await this.inspect() + return inventory + } + + async validate({ entryId: requestedId, revision, action }) { + if (typeof requestedId !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(requestedId)) throw new Error('User Skill entry ID is invalid') + if (typeof revision !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(revision)) throw new Error('User Skill revision is invalid') + if (!['enable', 'disable', 'delete'].includes(action)) throw new Error('User Skill action is invalid') + const inventory = await this.inspect() + if (inventory.revision !== revision) throw new UserSkillConflictError('User Skill state changed; reload and try again') + const entry = inventory.internal.find(value => value.entryId === requestedId) + if (entry === undefined) throw new UserSkillConflictError('User Skill entry no longer exists') + if (action === 'enable' && entry.enabled) throw new UserSkillConflictError('User Skill is already enabled') + if (action === 'disable' && !entry.enabled) throw new UserSkillConflictError('User Skill is already disabled') + return Object.freeze({ action, entry, revision }) + } + + async configure(request) { + const { action, entry } = await this.validate(request) + if (action === 'delete') { + const trash = join(entry.root, `.user-skill-delete-${randomUUID()}`) + await rename(entry.path, trash) + try { + await removeEntry(trash) + } catch (error) { + try { + await rename(trash, entry.path) + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], 'User Skill deletion and rollback failed') + } + throw error + } + } else { + const destination = entry.enabled + ? join(entry.root, DISABLED_DIRECTORY, entry.entryName) + : join(entry.root, entry.entryName) + await mkdir(join(entry.root, DISABLED_DIRECTORY), { recursive: true }) + try { + await lstat(destination) + throw new UserSkillConflictError(`User Skill destination ${basename(destination)} already exists`) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + await rename(entry.path, destination) + } + return this.list() + } +} + +export const userSkillInternals = Object.freeze({ DISABLED_DIRECTORY, SKILL_NAME_PATTERN, entryId }) diff --git a/upstream/container/control-plane/services/outbound-proxy/component.json b/upstream/container/control-plane/services/outbound-proxy/component.json new file mode 100644 index 0000000..b157807 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/component.json @@ -0,0 +1,30 @@ +{ + "schema": 1, + "id": "outbound-proxy", + "type": "service", + "command": { + "executable": "/usr/local/bin/node", + "args": ["/run/dsh-platform/views/bootstrap/control-plane/services/outbound-proxy/supervisor.mjs"], + "timeoutSeconds": 30 + }, + "environment": {}, + "lifecycle": { + "prepare": null, + "preStart": null, + "postStart": null, + "preStop": null, + "stop": null, + "postStop": null + }, + "health": { + "type": "exec", + "command": { + "executable": "/usr/local/bin/node", + "args": ["/run/dsh-platform/views/bootstrap/control-plane/services/outbound-proxy/supervisor.mjs", "health"], + "timeoutSeconds": 5 + }, + "intervalSeconds": 1, + "timeoutSeconds": 30 + }, + "logging": { "stdout": true, "stderr": true } +} diff --git a/upstream/container/control-plane/services/outbound-proxy/index.mjs b/upstream/container/control-plane/services/outbound-proxy/index.mjs new file mode 100644 index 0000000..73c17d9 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/index.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { PlatformPaths } from '../../../platform/lib/paths.mjs' +import { PROXY_PORTS } from './lib/contracts.mjs' +import { createOutboundProxyControl } from './lib/control.mjs' +import { createScopedProxyServer, ProxyAgentPool } from './lib/data-plane.mjs' +import { ProxyDnsCache } from './lib/dns-cache.mjs' +import { probeProxyEntry } from './lib/readiness.mjs' +import { ProxyRouteHealth } from './lib/route-health.mjs' +import { ProxyConfigurationStore } from './lib/store.mjs' +import { ProviderHandleStore } from './lib/provider-handles.mjs' +import { ProxyTestManager } from './lib/test-manager.mjs' + +const paths = new PlatformPaths( + process.env.DSH_PLATFORM_DATA ?? '/data/platform', + process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform', +) +const store = new ProxyConfigurationStore(paths.proxyStateRoot) +let snapshot = await store.load() +const listeners = [] +const dnsCache = new ProxyDnsCache() +const agentPool = new ProxyAgentPool() +const routeHealth = new ProxyRouteHealth() +const providerHandles = new ProviderHandleStore() +const proxyTests = new ProxyTestManager({ + statePath: paths.proxyTestStatePath, +}) +await proxyTests.initialize(async path => JSON.parse(await readFile(path, 'utf8'))) + +const ENTRY_BIND_ATTEMPTS = 21 +const ENTRY_BIND_RETRY_MS = 50 + +async function listenProxyEntry(server, port) { + for (let attempt = 1; attempt <= ENTRY_BIND_ATTEMPTS; attempt += 1) { + try { + await new Promise((resolve, reject) => { + const cleanup = () => { + server.off('error', onError) + server.off('listening', onListening) + } + const onError = error => { + cleanup() + reject(error) + } + const onListening = () => { + cleanup() + resolve() + } + server.once('error', onError) + server.once('listening', onListening) + server.listen(port, '127.0.0.1') + }) + return + } catch (error) { + if (error?.code !== 'EADDRINUSE' || attempt === ENTRY_BIND_ATTEMPTS) throw error + await new Promise(resolve => setTimeout(resolve, ENTRY_BIND_RETRY_MS)) + } + } +} + +async function publishRoutingState(current) { + const staging = `${paths.proxyRoutingStatePath}.${String(process.pid)}.tmp` + const value = { + schema: 1, + revision: current.revision, + enabled: current.configuration.enabled, + scopes: current.configuration.scopes, + modelApi: current.configuration.modelApi, + environment: current.configuration.environment, + noProxy: current.configuration.noProxy, + bypass: current.configuration.bypass, + } + await writeFile(staging, `${JSON.stringify(value)}\n`, { mode: 0o644 }) + await chmod(staging, 0o644) + await rename(staging, paths.proxyRoutingStatePath) +} + +await publishRoutingState(snapshot) + +for (const [scope, port] of Object.entries(PROXY_PORTS)) { + const server = createScopedProxyServer({ + scope, getSnapshot: () => snapshot, dnsCache, agentPool, routeHealth, providerHandles, + }) + await listenProxyEntry(server, port) + listeners.push(server) +} +await Promise.all(Object.values(PROXY_PORTS).map(port => probeProxyEntry(port))) + +const control = createOutboundProxyControl({ + getSnapshot: () => snapshot, + routeHealth, + providerHandles, + proxyTests, + getTestState: () => proxyTests.getState(), + commitConfiguration: async request => { + const activated = await store.commit(request) + snapshot = Object.freeze({ ...activated, recovery: 'none' }) + await publishRoutingState(snapshot) + return snapshot + }, +}) +await mkdir(dirname(paths.proxyControlSocket), { recursive: true }) +await unlink(paths.proxyControlSocket).catch(error => { + if (error?.code !== 'ENOENT') throw error +}) +await new Promise((resolve, reject) => { + control.once('error', reject) + control.listen(paths.proxyControlSocket, resolve) +}) +await chmod(paths.proxyControlSocket, 0o600) + +process.send?.({ + type: 'ready', + componentReady: true, + revision: snapshot.revision, + ports: Object.values(PROXY_PORTS), +}) + +let resolveSignal +const signal = new Promise(resolve => { resolveSignal = resolve }) +process.once('SIGINT', () => resolveSignal()) +process.once('SIGTERM', () => resolveSignal()) +await signal +agentPool.close() +await Promise.all(listeners.map(server => new Promise(resolve => server.close(resolve)))) +await new Promise(resolve => control.close(resolve)) +await unlink(paths.proxyControlSocket).catch(error => { + if (error?.code !== 'ENOENT') throw error +}) diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/contracts.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/contracts.mjs new file mode 100644 index 0000000..4e330d9 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/contracts.mjs @@ -0,0 +1,185 @@ +import { isIP } from 'node:net' +import { domainToASCII } from 'node:url' +import { ProxyConfigurationError } from './errors.mjs' +import { normalizeProxyRules, PLATFORM_NO_PROXY } from './rules.mjs' +import { OUTBOUND_PROXY_PORTS } from '../../../../platform/lib/outbound-proxy.mjs' + +export const PROXY_SCHEMA = 1 +export const PROXY_SCOPES = Object.freeze([ + 'updates', 'platform', 'dshCore', 'dshPlugins', 'agentNetwork', 'managementTerminal', +]) +export const PROXY_PORTS = OUTBOUND_PROXY_PORTS + +function deepFreeze(value) { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child) + Object.freeze(value) + } + return value +} + +function object(value, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new ProxyConfigurationError(`${label} must be an object`) + } + return value +} + +function allowedKeys(value, allowed, label) { + const extras = Object.keys(value).filter(key => !allowed.includes(key)) + if (extras.length > 0) throw new ProxyConfigurationError(`${label} contains unsupported fields: ${extras.sort().join(', ')}`) +} + +function requiredKeys(value, required, label) { + const missing = required.filter(key => !(key in value)) + if (missing.length > 0) throw new ProxyConfigurationError(`${label} is missing fields: ${missing.join(', ')}`) +} + +function normalizeProxyHost(value) { + if (typeof value !== 'string' || value !== value.trim() || /[\u0000-\u0020\u007f\/@?#]/.test(value)) { + throw new ProxyConfigurationError('proxy host is invalid') + } + if (value === '') return '' + if (isIP(value) !== 0) return value.toLowerCase() + const ascii = domainToASCII(value.toLowerCase().replace(/\.$/, '')) + if (ascii === '' || ascii.length > 253 || ascii.split('.').some(part => part === '' || part.length > 63)) { + throw new ProxyConfigurationError('proxy host is invalid') + } + return ascii +} + +function normalizeScopes(value) { + object(value, 'proxy scopes') + requiredKeys(value, PROXY_SCOPES, 'proxy scopes') + allowedKeys(value, PROXY_SCOPES, 'proxy scopes') + return Object.fromEntries(PROXY_SCOPES.map(scope => { + if (typeof value[scope] !== 'boolean') throw new ProxyConfigurationError(`proxy scope ${scope} must be boolean`) + return [scope, value[scope]] + })) +} + +function normalizeProviderPolicy(value, label) { + object(value, label) + requiredKeys(value, ['proxyEnabled'], label) + allowedKeys(value, ['proxyEnabled'], label) + if (typeof value.proxyEnabled !== 'boolean') throw new ProxyConfigurationError(`${label} proxyEnabled must be boolean`) + return { proxyEnabled: value.proxyEnabled } +} + +export function validProviderId(value) { + return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,127}$/.test(value) +} + +function normalizeModelApi(value) { + object(value, 'model API proxy policy') + requiredKeys(value, ['default', 'providers'], 'model API proxy policy') + allowedKeys(value, ['default', 'providers'], 'model API proxy policy') + object(value.providers, 'model API provider policies') + const providers = {} + for (const [id, policy] of Object.entries(value.providers)) { + if (!validProviderId(id)) throw new ProxyConfigurationError('model API provider ID is invalid') + providers[id] = normalizeProviderPolicy(policy, `model API provider ${id}`) + } + return { default: normalizeProviderPolicy(value.default, 'model API default'), providers } +} + +export function defaultProxyConfiguration() { + return Object.freeze({ + schema: PROXY_SCHEMA, + enabled: false, + proxy: Object.freeze({ + protocol: 'http', host: '', port: null, username: '', passwordConfigured: false, remoteDns: true, + }), + scopes: Object.freeze(Object.fromEntries(PROXY_SCOPES.map(scope => [scope, false]))), + environment: Object.freeze({ allProxy: null }), + modelApi: Object.freeze({ + default: Object.freeze({ proxyEnabled: false }), + providers: Object.freeze({}), + }), + noProxy: Object.freeze({ system: PLATFORM_NO_PROXY, user: Object.freeze([]) }), + bypass: Object.freeze({ additional: Object.freeze([]) }), + }) +} + +export function validateProxyConfiguration(value, { existingPassword = null } = {}) { + object(value, 'proxy configuration') + requiredKeys(value, ['schema', 'enabled', 'proxy', 'scopes', 'environment', 'modelApi', 'noProxy', 'bypass'], 'proxy configuration') + allowedKeys(value, ['schema', 'enabled', 'proxy', 'scopes', 'environment', 'modelApi', 'noProxy', 'bypass'], 'proxy configuration') + if (value.schema !== PROXY_SCHEMA) throw new ProxyConfigurationError('proxy configuration schema must be 1') + if (typeof value.enabled !== 'boolean') throw new ProxyConfigurationError('proxy enabled must be boolean') + + const proxy = object(value.proxy, 'proxy endpoint') + requiredKeys(proxy, ['protocol', 'host', 'port', 'username', 'remoteDns'], 'proxy endpoint') + allowedKeys(proxy, ['protocol', 'host', 'port', 'username', 'password', 'clearPassword', 'passwordConfigured', 'remoteDns'], 'proxy endpoint') + if (!['http', 'socks5'].includes(proxy.protocol)) throw new ProxyConfigurationError('proxy protocol must be http or socks5') + const host = normalizeProxyHost(proxy.host) + const port = proxy.port === null ? null : Number(proxy.port) + if (port !== null && (!Number.isSafeInteger(port) || port < 1 || port > 65535)) throw new ProxyConfigurationError('proxy port is invalid') + if (value.enabled && (host === '' || port === null)) throw new ProxyConfigurationError('enabled proxy requires a host and port') + if ((host === '') !== (port === null)) throw new ProxyConfigurationError('proxy host and port must be configured together') + if (typeof proxy.username !== 'string' || Buffer.byteLength(proxy.username, 'utf8') > 255 || /[\u0000-\u001f\u007f]/.test(proxy.username)) { + throw new ProxyConfigurationError('proxy username is invalid') + } + if (typeof proxy.remoteDns !== 'boolean') throw new ProxyConfigurationError('proxy remoteDns must be boolean') + if (proxy.clearPassword === true && proxy.password !== undefined && proxy.password !== null) { + throw new ProxyConfigurationError('proxy password cannot be set and cleared together') + } + let password = existingPassword + if (proxy.clearPassword === true) password = null + else if (proxy.password !== undefined && proxy.password !== null) { + if (typeof proxy.password !== 'string' || proxy.password === '' || Buffer.byteLength(proxy.password, 'utf8') > 255 || /[\u0000-\u001f\u007f]/.test(proxy.password)) { + throw new ProxyConfigurationError('proxy password is invalid') + } + password = proxy.password + } + + const environment = object(value.environment, 'proxy environment') + requiredKeys(environment, ['allProxy'], 'proxy environment') + allowedKeys(environment, ['allProxy'], 'proxy environment') + if (environment.allProxy !== null && environment.allProxy !== 'scope-proxy') { + throw new ProxyConfigurationError('proxy environment allProxy must be null or scope-proxy') + } + const noProxy = object(value.noProxy, 'NO_PROXY') + requiredKeys(noProxy, ['user'], 'NO_PROXY') + allowedKeys(noProxy, ['user', 'system'], 'NO_PROXY') + const bypass = object(value.bypass, 'proxy bypass') + requiredKeys(bypass, ['additional'], 'proxy bypass') + allowedKeys(bypass, ['additional'], 'proxy bypass') + + const normalized = { + schema: PROXY_SCHEMA, + enabled: value.enabled, + proxy: { + protocol: proxy.protocol, + host, + port, + username: proxy.username, + passwordConfigured: password !== null, + remoteDns: proxy.remoteDns, + }, + scopes: normalizeScopes(value.scopes), + environment: { allProxy: environment.allProxy }, + modelApi: normalizeModelApi(value.modelApi), + noProxy: { + system: PLATFORM_NO_PROXY, + user: normalizeProxyRules(noProxy.user, { allowWildcard: true, label: 'NO_PROXY' }).map(rule => rule.value), + }, + bypass: { + additional: normalizeProxyRules(bypass.additional, { allowCidr: true, label: 'proxy bypass' }).map(rule => rule.value), + }, + } + return Object.freeze({ + configuration: deepFreeze(normalized), + credentials: deepFreeze({ username: proxy.username, password }), + }) +} + +export function sanitizedProxyConfiguration(configuration, revision, state = {}) { + return Object.freeze({ + ...configuration, + revision, + componentReady: state.componentReady === true, + routeHealth: state.routeHealth ?? Object.fromEntries([...PROXY_SCOPES, 'modelApi', 'sharedDsh'].map(scope => [scope, 'unknown'])), + lastTest: state.lastTest ?? null, + }) +} diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/control.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/control.mjs new file mode 100644 index 0000000..ead1341 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/control.mjs @@ -0,0 +1,130 @@ +import { createServer } from 'node:http' +import { outboundProxyEnvironment, outboundProxyScopeEnabled } from '../../../../platform/lib/outbound-proxy.mjs' +import { sanitizedProxyConfiguration } from './contracts.mjs' +import { ProxyConfigurationError, proxyErrorBody } from './errors.mjs' + +const MAX_CONTROL_BODY_BYTES = 64 * 1024 +const MAX_PROVIDER_HANDLE_BODY_BYTES = 4096 +const TEST_TASK_ROUTE = /^\/v1\/test\/tasks\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/ + +async function jsonBody(request, maximum = MAX_CONTROL_BODY_BYTES) { + const chunks = [] + let total = 0 + for await (const chunk of request) { + total += chunk.byteLength + if (total > maximum) throw new ProxyConfigurationError('control request body is too large') + chunks.push(chunk) + } + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { + throw new ProxyConfigurationError('control request body is invalid') + } +} + +function send(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(`${JSON.stringify(value)}\n`) +} + +function configurationView(snapshot, routeHealth, state = {}) { + return sanitizedProxyConfiguration(snapshot.configuration, snapshot.revision, { + componentReady: true, + routeHealth: routeHealth.status(snapshot), + lastTest: state.lastTest ?? null, + }) +} + +export function createOutboundProxyControl({ + getSnapshot, + routeHealth, + providerHandles, + proxyTests, + commitConfiguration, + getTestState = () => ({ lastTest: null }), +}) { + return createServer(async (request, response) => { + const url = new URL(request.url ?? '/', 'http://outbound-proxy.internal') + const snapshot = getSnapshot() + if (request.method === 'GET' && url.pathname === '/v1/status') { + send(response, 200, { + componentReady: true, + revision: snapshot.revision, + recovery: snapshot.recovery, + routeHealth: routeHealth.status(snapshot), + }) + return + } + if (request.method === 'GET' && url.pathname === '/v1/configuration') { + send(response, 200, configurationView(snapshot, routeHealth, getTestState())) + return + } + if (request.method === 'PUT' && url.pathname === '/v1/configuration') { + try { + if (commitConfiguration === undefined) throw new Error('proxy configuration updates are unavailable') + const body = await jsonBody(request) + if (body === null || typeof body !== 'object' || Array.isArray(body) + || Object.keys(body).sort().join(',') !== 'baseRevision,value') { + throw new ProxyConfigurationError('proxy configuration update is invalid') + } + const activated = await commitConfiguration(body) + providerHandles?.clear() + send(response, 200, configurationView(activated, routeHealth, getTestState())) + } catch (error) { + send(response, error?.statusCode ?? 400, proxyErrorBody(error)) + } + return + } + if (request.method === 'POST' && url.pathname === '/v1/test') { + try { + if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable') + send(response, 202, await proxyTests.start(await jsonBody(request), snapshot)) + } catch (error) { + send(response, error?.statusCode ?? 400, proxyErrorBody(error)) + } + return + } + if (request.method === 'GET' && TEST_TASK_ROUTE.test(url.pathname)) { + try { + if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable') + send(response, 200, proxyTests.get(TEST_TASK_ROUTE.exec(url.pathname)[1])) + } catch (error) { + send(response, error?.statusCode ?? 400, proxyErrorBody(error)) + } + return + } + if (request.method === 'DELETE' && TEST_TASK_ROUTE.test(url.pathname)) { + try { + if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable') + send(response, 202, await proxyTests.cancel(TEST_TASK_ROUTE.exec(url.pathname)[1])) + } catch (error) { + send(response, error?.statusCode ?? 400, proxyErrorBody(error)) + } + return + } + if (request.method === 'GET' && url.pathname === '/v1/environment') { + try { + const scope = url.searchParams.get('scope') + const environment = outboundProxyEnvironment(scope, { + noProxy: [...snapshot.configuration.noProxy.system, ...snapshot.configuration.noProxy.user], + allProxy: snapshot.configuration.environment.allProxy === 'scope-proxy', + enabled: outboundProxyScopeEnabled(snapshot.configuration, scope), + }) + send(response, 200, { scope, revision: snapshot.revision, environment }) + } catch (error) { + send(response, 400, { error: error instanceof Error ? error.message : 'invalid scope' }) + } + return + } + if (request.method === 'POST' && url.pathname === '/v1/provider-handles') { + try { + if (providerHandles === undefined) throw new Error('provider handles are unavailable') + send(response, 201, providerHandles.issue(await jsonBody(request, MAX_PROVIDER_HANDLE_BODY_BYTES), snapshot)) + } catch (error) { + send(response, error?.statusCode ?? 400, proxyErrorBody(error)) + } + return + } + send(response, 404, { error: 'not found' }) + }) +} + +export const outboundProxyControlInternals = Object.freeze({ configurationView }) diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/data-plane.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/data-plane.mjs new file mode 100644 index 0000000..ea0ba8a --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/data-plane.mjs @@ -0,0 +1,459 @@ +import { Agent, createServer, request as httpRequest } from 'node:http' +import { ProxyDnsCache } from './dns-cache.mjs' +import { connectTcp, basicProxyAuthorization, connectThroughHttpProxy, connectThroughSocks5, PROXY_TIMEOUTS, ProxyTransportError } from './transport.mjs' +import { selectProxyRoute } from './policy.mjs' +import { ProxyRouteHealth } from './route-health.mjs' + +const MAX_HEADER_LINE_BYTES = 64 * 1024 +const MAX_CONNECT_AUTHORITY_BYTES = 1024 +const HOP_BY_HOP = new Set([ + 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade', +]) + +function proxyError(response, statusCode, code, message) { + if (response.destroyed) return + if (response.headersSent) return response.destroy() + const body = `${message}\n` + response.writeHead(statusCode, { + 'content-type': 'text/plain; charset=utf-8', + 'content-length': Buffer.byteLength(body), + 'x-dsh-proxy-error': code, + connection: 'close', + ...statusCode === 407 ? { 'proxy-authenticate': 'DSH-Provider' } : {}, + }) + response.end(body) +} + +function socketError(socket, statusCode, code, message) { + if (socket.destroyed) return + const body = `${message}\n` + socket.end([ + `HTTP/1.1 ${statusCode} ${statusCode === 504 ? 'Gateway Timeout' : statusCode === 431 ? 'Request Header Fields Too Large' : statusCode === 407 ? 'Proxy Authentication Required' : statusCode === 400 ? 'Bad Request' : 'Bad Gateway'}`, + 'Content-Type: text/plain; charset=utf-8', + `Content-Length: ${Buffer.byteLength(body)}`, + `X-DSH-Proxy-Error: ${code}`, + ...statusCode === 407 ? ['Proxy-Authenticate: DSH-Provider'] : [], + 'Connection: close', + '', + body, + ].join('\r\n')) +} + +function errorFields(error) { + if (error instanceof ProxyTransportError) { + return { statusCode: error.statusCode, code: error.code, message: error.message } + } + return { statusCode: 502, code: 'UPSTREAM_CONNECT_FAILED', message: 'upstream connection failed' } +} + +function observeRouteHealth(context, snapshot, outcome) { + if (context.getSnapshot().revision === snapshot.revision) { + context.routeHealth.observe(snapshot, context.scope, outcome) + } +} + +function authorizeProvider(context, headers, snapshot) { + if (context.scope !== 'modelApi') return undefined + try { + if (context.providerHandles === undefined) throw new Error('provider handles are unavailable') + return context.providerHandles.resolve(headers['proxy-authorization'], snapshot) + } catch { + throw new ProxyTransportError('provider policy handle is invalid', { + code: 'PROVIDER_HANDLE_INVALID', statusCode: 407, + }) + } +} + +function checkHeaderLimits(request) { + if (request.rawHeaders.length / 2 > 256) return false + for (let index = 0; index < request.rawHeaders.length; index += 2) { + if (Buffer.byteLength(`${request.rawHeaders[index]}: ${request.rawHeaders[index + 1]}`, 'latin1') > MAX_HEADER_LINE_BYTES) return false + } + return true +} + +function connectionTokens(headers) { + return new Set(String(headers.connection ?? '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean)) +} + +function forwardHeaders(headers, { upgrade = false, authorization = null, host }) { + const tokens = connectionTokens(headers) + const result = {} + for (const [name, value] of Object.entries(headers)) { + const lower = name.toLowerCase() + if (HOP_BY_HOP.has(lower) || tokens.has(lower)) continue + if (value !== undefined) result[lower] = value + } + result.host = host + if (upgrade) { + result.connection = 'Upgrade' + result.upgrade = headers.upgrade + } + if (authorization !== null) result['proxy-authorization'] = authorization + return result +} + +function responseHeaders(headers) { + const tokens = connectionTokens(headers) + const result = {} + for (const [name, value] of Object.entries(headers)) { + const lower = name.toLowerCase() + if (HOP_BY_HOP.has(lower) || tokens.has(lower)) continue + if (value !== undefined) result[lower] = value + } + return result +} + +function parseAbsoluteTarget(raw) { + let target + try { target = new URL(raw) } catch { return null } + if (target.username !== '' || target.password !== '' || target.hostname === '' || target.hash !== '') return null + if (!['http:', 'ws:'].includes(target.protocol)) return Object.freeze({ unsupported: true, url: target }) + const port = target.port === '' ? 80 : Number(target.port) + if (!Number.isInteger(port) || port < 1 || port > 65535) return null + const host = target.hostname.startsWith('[') && target.hostname.endsWith(']') + ? target.hostname.slice(1, -1) + : target.hostname + return Object.freeze({ url: target, host, port, authority: target.host }) +} + +function parseAuthority(raw) { + if (Buffer.byteLength(raw) > MAX_CONNECT_AUTHORITY_BYTES || raw.includes('/') || raw.includes('@')) return null + try { + const value = new URL(`tcp://${raw}`) + if (value.hostname === '' || value.port === '') return null + const port = Number(value.port) + if (!Number.isInteger(port) || port < 1 || port > 65535) return null + const host = value.hostname.startsWith('[') && value.hostname.endsWith(']') + ? value.hostname.slice(1, -1) + : value.hostname + return Object.freeze({ host, port }) + } catch { return null } +} + +function requestPath(target) { + return `${target.url.pathname || '/'}${target.url.search}` +} + +function idle(socket, timeoutMs) { + socket.setTimeout(timeoutMs, () => socket.destroy(new ProxyTransportError('upstream stream timed out', { + code: 'UPSTREAM_TIMEOUT', statusCode: 504, + }))) +} + +function bridge(left, right, { leftHead = Buffer.alloc(0), rightHead = Buffer.alloc(0), timeoutMs }) { + const close = () => { + if (!left.destroyed) left.destroy() + if (!right.destroyed) right.destroy() + } + left.on('error', close) + right.on('error', close) + idle(left, timeoutMs) + idle(right, timeoutMs) + if (leftHead.byteLength > 0) right.write(leftHead) + if (rightHead.byteLength > 0) left.write(rightHead) + left.pipe(right) + right.pipe(left) +} + +async function tryTargets(targets, connectTarget, { retry } = {}) { + let failure + for (const target of targets) { + try { return await connectTarget(target) } catch (error) { + failure = error + if (error?.code === 'REQUEST_CANCELLED' || retry?.(error) === false) throw error + } + } + throw failure ?? new ProxyTransportError('no usable target addresses', { code: 'TARGET_DNS_FAILED' }) +} + +async function routeSocket(route, target, signal, timeouts) { + if (route.mode === 'direct') return tryTargets(route.targets ?? [{ address: target.host }], async candidate => Object.freeze({ + socket: await connectTcp({ host: candidate.address, port: target.port, timeoutMs: timeouts.connectMs, signal }), + remainder: Buffer.alloc(0), + })) + if (route.mode === 'http') return connectThroughHttpProxy({ + endpoint: route.endpoint, + targetHost: target.host, + targetPort: target.port, + connectTimeoutMs: timeouts.connectMs, + handshakeTimeoutMs: timeouts.handshakeMs, + signal, + }) + if (route.mode === 'socks5') return tryTargets(route.targets ?? [{ address: target.host }], candidate => connectThroughSocks5({ + endpoint: route.endpoint, + targetHost: candidate.address, + targetPort: target.port, + connectTimeoutMs: timeouts.connectMs, + handshakeTimeoutMs: timeouts.handshakeMs, + signal, + }), { retry: error => error?.code === 'UPSTREAM_PROXY_REJECTED' }) + throw new ProxyTransportError('configured proxy protocol is not available', { + code: 'PROXY_PROTOCOL_UNAVAILABLE', + }) +} + +async function httpConnection(route, target, signal, timeouts) { + if (route.mode === 'http') return Object.freeze({ + socket: await connectTcp({ + host: route.endpoint.host, + port: route.endpoint.port, + timeoutMs: timeouts.connectMs, + signal, + }), + remainder: Buffer.alloc(0), + }) + return routeSocket(route, target, signal, timeouts) +} + +function agentKey(route) { + const endpoint = route.endpoint === undefined + ? '' + : `${route.endpoint.host}:${route.endpoint.port}:${route.endpoint.username}` + return `${route.revision}\u0000${route.mode}\u0000${endpoint}` +} + +export class ProxyAgentPool { + constructor() { + this.entries = new Map() + this.currentRevision = null + } + + retireOtherRevisions(revision) { + if (this.currentRevision === revision) return + this.currentRevision = revision + for (const [key, entry] of this.entries) { + if (entry.revision === revision) continue + entry.retired = true + for (const sockets of Object.values(entry.agent.freeSockets)) { + for (const socket of sockets) socket.destroy() + } + this.entries.delete(key) + } + } + + agentFor(route, timeouts = PROXY_TIMEOUTS) { + this.retireOtherRevisions(route.revision) + const key = agentKey(route) + const existing = this.entries.get(key) + if (existing !== undefined) return existing.agent + const entry = { revision: route.revision, retired: false, agent: null } + const agent = new Agent({ keepAlive: true, maxSockets: 32, maxFreeSockets: 8, timeout: timeouts.streamIdleMs }) + entry.agent = agent + agent.createConnection = (options, callback) => { + const target = { + host: options.dshProxyTargetHost, + port: Number(options.dshProxyTargetPort), + } + void httpConnection(route, target, options.dshProxySignal, timeouts).then(connection => { + if (connection.remainder.byteLength > 0) connection.socket.unshift(connection.remainder) + connection.socket.resume() + callback(null, connection.socket) + }, callback) + } + agent.on('free', socket => { + if (entry.retired) socket.destroy() + }) + this.entries.set(key, entry) + return agent + } + + close() { + for (const entry of this.entries.values()) entry.agent.destroy() + this.entries.clear() + this.currentRevision = null + } +} + +async function handleHttp(request, response, context) { + if (!checkHeaderLimits(request)) return proxyError(response, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large') + const target = parseAbsoluteTarget(request.url ?? '') + if (target === null || target.unsupported === true || target.url.protocol !== 'http:') { + const invalid = target === null + return proxyError(response, invalid ? 400 : 501, invalid ? 'INVALID_PROXY_REQUEST' : 'UNSUPPORTED_TARGET_SCHEME', invalid ? 'absolute-form HTTP target required' : 'target scheme is not supported') + } + let upstream + let route + let snapshot + const cancellation = new AbortController() + request.once('aborted', () => cancellation.abort()) + response.once('close', () => { + if (!response.writableEnded) cancellation.abort() + }) + try { + request.pause() + snapshot = context.getSnapshot() + const providerId = authorizeProvider(context, request.headers, snapshot) + route = await selectProxyRoute({ + snapshot, scope: context.scope, providerId, host: target.host, port: target.port, + dnsCache: context.dnsCache, signal: cancellation.signal, + }) + const proxied = route.mode === 'http' + const agent = context.agentPool.agentFor(route, context.timeouts) + const headers = forwardHeaders(request.headers, { + host: target.authority, + authorization: proxied ? basicProxyAuthorization(route.endpoint.username, route.endpoint.password) : null, + }) + upstream = httpRequest({ + host: proxied ? route.endpoint.host : target.host, + port: proxied ? route.endpoint.port : target.port, + method: request.method, + path: proxied ? target.url.href : requestPath(target), + headers, + agent, + signal: cancellation.signal, + maxHeaderSize: 256 * 1024, + dshProxyTargetHost: target.host, + dshProxyTargetPort: target.port, + dshProxySignal: cancellation.signal, + }) + upstream.setTimeout(context.timeouts.responseHeaderMs, () => upstream.destroy(new ProxyTransportError('upstream response timed out', { + code: 'UPSTREAM_TIMEOUT', statusCode: 504, + }))) + request.once('aborted', () => upstream.destroy()) + upstream.once('response', incoming => { + if (proxied && incoming.statusCode === 407) { + observeRouteHealth(context, snapshot, 'degraded') + incoming.socket.destroy() + return proxyError(response, 502, 'UPSTREAM_PROXY_AUTH_FAILED', 'upstream proxy authentication failed') + } + if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready') + incoming.socket.setTimeout(context.timeouts.streamIdleMs, () => incoming.destroy(new ProxyTransportError('upstream response timed out', { + code: 'UPSTREAM_TIMEOUT', statusCode: 504, + }))) + response.writeHead(incoming.statusCode ?? 502, incoming.statusMessage, responseHeaders(incoming.headers)) + incoming.pipe(response, { end: false }) + incoming.once('end', () => { + if (Object.keys(incoming.trailers).length > 0) response.addTrailers(incoming.trailers) + response.end() + }) + incoming.once('error', error => response.destroy(error)) + }) + upstream.once('error', error => { + if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded') + const fields = errorFields(error) + proxyError(response, fields.statusCode, fields.code, fields.message) + }) + request.pipe(upstream, { end: false }) + request.once('end', () => { + if (Object.keys(request.trailers).length > 0) upstream.addTrailers(request.trailers) + upstream.end() + }) + request.resume() + } catch (error) { + upstream?.destroy() + if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded') + const fields = errorFields(error) + proxyError(response, fields.statusCode, fields.code, fields.message) + } +} + +function rawRequest(request, target, { absolute, authorization }) { + const headers = forwardHeaders(request.headers, { + upgrade: true, + host: target.authority, + authorization, + }) + const start = `${request.method ?? 'GET'} ${absolute ? target.url.href : requestPath(target)} HTTP/${request.httpVersion}` + return Buffer.from(`${[start, ...Object.entries(headers).map(([name, value]) => `${name}: ${Array.isArray(value) ? value.join(', ') : value}`), '', ''].join('\r\n')}`, 'latin1') +} + +async function handleUpgrade(request, socket, head, context) { + socket.on('error', () => {}) + if (!checkHeaderLimits(request)) return socketError(socket, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large') + const target = parseAbsoluteTarget(request.url ?? '') + if (target === null || target.unsupported === true || target.url.protocol !== 'ws:' || String(request.headers.upgrade ?? '').toLowerCase() !== 'websocket') { + const invalid = target === null + return socketError(socket, invalid ? 400 : 501, invalid ? 'INVALID_PROXY_REQUEST' : 'UNSUPPORTED_TARGET_SCHEME', invalid ? 'absolute-form WS target required' : 'target scheme is not supported') + } + const cancellation = new AbortController() + let route + let snapshot + socket.once('close', () => cancellation.abort()) + try { + snapshot = context.getSnapshot() + const providerId = authorizeProvider(context, request.headers, snapshot) + route = await selectProxyRoute({ + snapshot, scope: context.scope, providerId, host: target.host, port: target.port, + dnsCache: context.dnsCache, signal: cancellation.signal, + }) + if (!['direct', 'http', 'socks5'].includes(route.mode)) throw new ProxyTransportError('configured proxy protocol is not available', { code: 'PROXY_PROTOCOL_UNAVAILABLE' }) + const connected = await httpConnection(route, target, cancellation.signal, context.timeouts) + if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready') + const authorization = route.mode === 'http' ? basicProxyAuthorization(route.endpoint.username, route.endpoint.password) : null + const preface = rawRequest(request, target, { absolute: route.mode === 'http', authorization }) + bridge(socket, connected.socket, { + leftHead: Buffer.concat([preface, head]), + rightHead: connected.remainder, + timeoutMs: context.timeouts.streamIdleMs, + }) + } catch (error) { + if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded') + const fields = errorFields(error) + socketError(socket, fields.statusCode, fields.code, fields.message) + } +} + +async function handleConnect(request, socket, head, context) { + socket.on('error', () => {}) + if (!checkHeaderLimits(request)) return socketError(socket, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large') + const target = parseAuthority(request.url ?? '') + if (target === null) return socketError(socket, 400, 'INVALID_CONNECT_AUTHORITY', 'CONNECT authority is invalid') + const cancellation = new AbortController() + let route + let snapshot + socket.once('close', () => cancellation.abort()) + try { + snapshot = context.getSnapshot() + const providerId = authorizeProvider(context, request.headers, snapshot) + route = await selectProxyRoute({ + snapshot, scope: context.scope, providerId, host: target.host, port: target.port, + dnsCache: context.dnsCache, signal: cancellation.signal, + }) + const connected = await routeSocket(route, target, cancellation.signal, context.timeouts) + if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready') + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + bridge(socket, connected.socket, { + leftHead: head, + rightHead: connected.remainder, + timeoutMs: context.timeouts.tunnelIdleMs, + }) + } catch (error) { + if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded') + const fields = errorFields(error) + socketError(socket, fields.statusCode, fields.code, fields.message) + } +} + +export function createScopedProxyServer({ + scope, + getSnapshot, + resolver, + dnsCache = new ProxyDnsCache({ resolver }), + agentPool = new ProxyAgentPool(), + routeHealth = new ProxyRouteHealth(), + providerHandles, + timeouts = PROXY_TIMEOUTS, +}) { + const context = Object.freeze({ + scope, + getSnapshot, + dnsCache, + agentPool, + routeHealth, + providerHandles, + timeouts: Object.freeze({ ...PROXY_TIMEOUTS, ...timeouts }), + }) + const server = createServer({ maxHeaderSize: 256 * 1024, allowHalfOpen: true }, (request, response) => { + void handleHttp(request, response, context) + }) + server.maxHeadersCount = 256 + server.on('connect', (request, socket, head) => { void handleConnect(request, socket, head, context) }) + server.on('upgrade', (request, socket, head) => { void handleUpgrade(request, socket, head, context) }) + server.on('clientError', (error, socket) => { + const overflow = error?.code === 'HPE_HEADER_OVERFLOW' + socketError(socket, overflow ? 431 : 400, overflow ? 'REQUEST_HEADERS_TOO_LARGE' : 'INVALID_PROXY_REQUEST', overflow ? 'request headers are too large' : 'proxy request is invalid') + }) + return server +} diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/dns-cache.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/dns-cache.mjs new file mode 100644 index 0000000..5d50801 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/dns-cache.mjs @@ -0,0 +1,80 @@ +import { lookup } from 'node:dns/promises' +import { isIP } from 'node:net' +import { domainToASCII } from 'node:url' +import { ProxyTransportError } from './transport.mjs' + +const POSITIVE_TTL_MS = 60_000 +const NEGATIVE_TTL_MS = 10_000 + +function normalizeHost(value) { + if (isIP(value) !== 0) return value.toLowerCase() + const ascii = domainToASCII(String(value).toLowerCase().replace(/\.+$/, '')) + if (ascii === '') throw new ProxyTransportError('target DNS name is invalid', { code: 'TARGET_DNS_FAILED' }) + return ascii +} + +function normalizeRecords(value) { + if (!Array.isArray(value)) value = [value] + const records = new Map() + for (const record of value) { + const address = typeof record === 'string' ? record : record?.address + const family = typeof record === 'object' ? Number(record?.family) : isIP(address) + if (typeof address !== 'string' || ![4, 6].includes(family) || isIP(address) !== family) continue + const ttl = typeof record?.ttl === 'number' && Number.isFinite(record.ttl) + ? Math.max(0, Math.min(60, record.ttl)) + : 60 + records.set(`${family}:${address}`, Object.freeze({ address, family, ttl })) + } + return Object.freeze([...records.values()]) +} + +export class ProxyDnsCache { + constructor({ resolver = lookup, now = Date.now, positiveTtlMs = POSITIVE_TTL_MS, negativeTtlMs = NEGATIVE_TTL_MS } = {}) { + this.resolver = resolver + this.now = now + this.positiveTtlMs = positiveTtlMs + this.negativeTtlMs = negativeTtlMs + this.entries = new Map() + } + + async resolve(host, revision, { signal } = {}) { + const normalized = normalizeHost(host) + const type = isIP(normalized) + if (type !== 0) return Object.freeze([{ address: normalized, family: type, ttl: 60 }]) + const key = `${revision}\u0000${normalized}` + const cached = this.entries.get(key) + if (cached !== undefined && cached.expiresAt > this.now()) { + if (cached.error !== null) throw cached.error + return cached.records + } + if (signal?.aborted) throw new ProxyTransportError('target DNS resolution was cancelled', { code: 'REQUEST_CANCELLED', statusCode: 499 }) + let records + try { + if (signal === undefined) records = await this.resolver(normalized, { all: true, verbatim: true }) + else records = await new Promise((resolve, reject) => { + const cancelled = () => reject(new ProxyTransportError('target DNS resolution was cancelled', { + code: 'REQUEST_CANCELLED', statusCode: 499, + })) + signal.addEventListener('abort', cancelled, { once: true }) + this.resolver(normalized, { all: true, verbatim: true }).then(resolve, reject).finally(() => { + signal.removeEventListener('abort', cancelled) + }) + }) + records = normalizeRecords(records) + if (records.length === 0) throw new Error('resolver returned no usable addresses') + } catch (cause) { + if (cause instanceof ProxyTransportError) throw cause + const error = new ProxyTransportError('target DNS resolution failed', { code: 'TARGET_DNS_FAILED', cause }) + this.entries.set(key, Object.freeze({ records: null, error, expiresAt: this.now() + this.negativeTtlMs })) + throw error + } + const recordTtlMs = Math.min(...records.map(record => record.ttl * 1000), this.positiveTtlMs) + this.entries.set(key, Object.freeze({ records, error: null, expiresAt: this.now() + recordTtlMs })) + if (this.entries.size > 512) { + const now = this.now() + for (const [entryKey, entry] of this.entries) if (entry.expiresAt <= now) this.entries.delete(entryKey) + while (this.entries.size > 512) this.entries.delete(this.entries.keys().next().value) + } + return records + } +} diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/errors.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/errors.mjs new file mode 100644 index 0000000..ff457c7 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/errors.mjs @@ -0,0 +1,26 @@ +export class ProxyConfigurationError extends Error { + constructor(message, { + code = 'INVALID_CONFIG', + statusCode = 400, + stage = 'validate', + retryable = false, + } = {}) { + super(message) + this.name = 'ProxyConfigurationError' + this.code = code + this.statusCode = statusCode + this.stage = stage + this.retryable = retryable + } +} + +export function proxyErrorBody(error) { + return Object.freeze({ + error: Object.freeze({ + code: error?.code ?? 'PROXY_MANAGER_UNAVAILABLE', + message: error?.message ?? 'proxy manager is unavailable', + stage: error?.stage ?? 'unknown', + retryable: error?.retryable === true, + }), + }) +} diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/policy.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/policy.mjs new file mode 100644 index 0000000..ba968a6 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/policy.mjs @@ -0,0 +1,47 @@ +import { isIP } from 'node:net' +import { matchesProxyRules, normalizeProxyRules } from './rules.mjs' +import { outboundProxyScopeEnabled } from '../../../../platform/lib/outbound-proxy.mjs' + +const PLATFORM_RULES = normalizeProxyRules(['localhost', '127.0.0.1', '::1'], { label: 'platform NO_PROXY' }) + +function route(mode, reason, snapshot, fields = {}) { + return Object.freeze({ mode, reason, revision: snapshot.revision, ...fields }) +} + +export async function selectProxyRoute({ snapshot, scope, providerId, host, port, dnsCache, signal }) { + const configuration = snapshot.configuration + const userNoProxy = normalizeProxyRules(configuration.noProxy.user, { + allowWildcard: true, + label: 'NO_PROXY', + }) + const bypass = normalizeProxyRules(configuration.bypass.additional, { + allowCidr: true, + label: 'proxy bypass', + }) + const bypassHosts = bypass.filter(rule => rule.type !== 'cidr') + const bypassCidrs = bypass.filter(rule => rule.type === 'cidr') + if (matchesProxyRules(PLATFORM_RULES, host, port)) return route('direct', 'platform-forced-direct', snapshot) + if (matchesProxyRules(userNoProxy, host, port)) return route('direct', 'no-proxy', snapshot) + if (matchesProxyRules(bypassHosts, host, port)) return route('direct', 'bypass', snapshot) + if (isIP(host) !== 0 && matchesProxyRules(bypassCidrs, host, port)) return route('direct', 'bypass', snapshot) + if (!outboundProxyScopeEnabled(configuration, scope, providerId)) { + return route('direct', scope === 'modelApi' ? 'provider-direct' : 'scope-direct', snapshot) + } + const endpoint = Object.freeze({ + host: configuration.proxy.host, + port: configuration.proxy.port, + remoteDns: configuration.proxy.remoteDns, + username: snapshot.credentials.username, + password: snapshot.credentials.password, + }) + if (configuration.proxy.protocol !== 'socks5' || configuration.proxy.remoteDns || isIP(host) !== 0) { + const reason = scope === 'modelApi' ? 'provider-proxy' : 'scope-proxy' + return route(configuration.proxy.protocol, reason, snapshot, { endpoint }) + } + if (dnsCache === undefined) throw new TypeError('SOCKS5 local DNS requires a ProxyDnsCache') + const targets = await dnsCache.resolve(host, snapshot.revision, { signal }) + const directTargets = targets.filter(target => matchesProxyRules(bypassCidrs, target.address, port)) + if (directTargets.length > 0) return route('direct', 'bypass-cidr', snapshot, { targets: Object.freeze(directTargets) }) + const reason = scope === 'modelApi' ? 'provider-proxy' : 'scope-proxy' + return route('socks5', reason, snapshot, { endpoint, targets }) +} diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/provider-handles.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/provider-handles.mjs new file mode 100644 index 0000000..dec0508 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/provider-handles.mjs @@ -0,0 +1,62 @@ +import { randomBytes } from 'node:crypto' +import { ProxyConfigurationError } from './errors.mjs' +import { validProviderId } from './contracts.mjs' + +const HANDLE_BYTES = 32 +const HANDLE_PATTERN = /^[A-Za-z0-9_-]{43}$/ +export const PROVIDER_HANDLE_TTL_MS = 15 * 60 * 1000 + +function invalidHandle() { + return new ProxyConfigurationError('provider policy handle is invalid', { + code: 'PROVIDER_HANDLE_INVALID', statusCode: 407, stage: 'authorize', retryable: true, + }) +} + +export class ProviderHandleStore { + constructor({ ttlMs = PROVIDER_HANDLE_TTL_MS, now = () => Date.now(), random = randomBytes } = {}) { + if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) throw new TypeError('provider handle TTL is invalid') + this.ttlMs = ttlMs + this.now = now + this.random = random + this.handles = new Map() + } + + issue({ providerId, policyRevision }, snapshot) { + if (!validProviderId(providerId)) throw new ProxyConfigurationError('model API provider ID is invalid') + if (typeof policyRevision !== 'string' || policyRevision !== snapshot.revision) { + throw new ProxyConfigurationError('provider policy revision changed', { + code: 'REVISION_CONFLICT', statusCode: 409, stage: 'authorize', retryable: true, + }) + } + this.prune() + const handle = this.random(HANDLE_BYTES).toString('base64url') + if (!HANDLE_PATTERN.test(handle) || this.handles.has(handle)) throw new Error('provider handle generation failed') + const expiresAtMs = this.now() + this.ttlMs + this.handles.set(handle, Object.freeze({ providerId, policyRevision, expiresAtMs })) + return Object.freeze({ handle, policyRevision, expiresAt: new Date(expiresAtMs).toISOString() }) + } + + resolve(authorization, snapshot) { + const match = typeof authorization === 'string' ? /^DSH-Provider ([A-Za-z0-9_-]{43})$/.exec(authorization) : null + if (match === null) throw invalidHandle() + const entry = this.handles.get(match[1]) + if (entry === undefined || entry.expiresAtMs <= this.now() || entry.policyRevision !== snapshot.revision) { + if (entry !== undefined) this.handles.delete(match[1]) + throw invalidHandle() + } + return entry.providerId + } + + prune() { + const now = this.now() + for (const [handle, entry] of this.handles) { + if (entry.expiresAtMs <= now) this.handles.delete(handle) + } + } + + clear() { + this.handles.clear() + } +} + +export const providerHandleInternals = Object.freeze({ HANDLE_BYTES, HANDLE_PATTERN }) diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/readiness.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/readiness.mjs new file mode 100644 index 0000000..2894274 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/readiness.mjs @@ -0,0 +1,29 @@ +import { request } from 'node:http' +import { ProxyTransportError } from './transport.mjs' + +export async function probeProxyEntry(port, { timeoutMs = 2_000 } = {}) { + await new Promise((resolve, reject) => { + const probe = request({ + host: '127.0.0.1', + port, + method: 'GET', + path: '/__dsh_outbound_proxy_readiness__', + headers: { connection: 'close' }, + agent: false, + }, response => { + response.resume() + response.once('end', () => { + if (response.statusCode === 400 && response.headers['x-dsh-proxy-error'] === 'INVALID_PROXY_REQUEST') resolve() + else reject(new ProxyTransportError('outbound proxy readiness response is invalid', { + code: 'PROXY_READINESS_FAILED', + })) + }) + }) + const timer = setTimeout(() => probe.destroy(new ProxyTransportError('outbound proxy readiness timed out', { + code: 'PROXY_READINESS_FAILED', + })), timeoutMs) + probe.once('close', () => clearTimeout(timer)) + probe.once('error', reject) + probe.end() + }) +} diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/route-health.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/route-health.mjs new file mode 100644 index 0000000..667af1b --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/route-health.mjs @@ -0,0 +1,38 @@ +import { PROXY_PORTS } from './contracts.mjs' + +function enabled(configuration, scope) { + if (!configuration.enabled) return false + if (scope === 'sharedDsh') return configuration.scopes.dshCore || configuration.scopes.dshPlugins + if (scope === 'modelApi') { + return configuration.modelApi.default.proxyEnabled + || Object.values(configuration.modelApi.providers).some(policy => policy.proxyEnabled) + } + return configuration.scopes[scope] === true +} + +export class ProxyRouteHealth { + constructor() { + this.revision = null + this.routes = new Map() + } + + observe(snapshot, scope, outcome) { + if (snapshot.revision !== this.revision) { + this.revision = snapshot.revision + this.routes.clear() + } + if (!['ready', 'degraded'].includes(outcome)) throw new TypeError('proxy route health outcome is invalid') + this.routes.set(scope, outcome) + } + + status(snapshot) { + if (snapshot.revision !== this.revision) { + this.revision = snapshot.revision + this.routes.clear() + } + return Object.freeze(Object.fromEntries(Object.keys(PROXY_PORTS).map(scope => [ + scope, + enabled(snapshot.configuration, scope) ? (this.routes.get(scope) ?? 'unknown') : 'direct', + ]))) + } +} diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/rules.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/rules.mjs new file mode 100644 index 0000000..7d67b7b --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/rules.mjs @@ -0,0 +1,121 @@ +import { BlockList, isIP } from 'node:net' +import { domainToASCII } from 'node:url' +import { ProxyConfigurationError } from './errors.mjs' +import { PLATFORM_NO_PROXY } from '../../../../platform/lib/outbound-proxy.mjs' + +export { PLATFORM_NO_PROXY } + +function invalid(label) { + throw new ProxyConfigurationError(`${label} rule is invalid`) +} + +function normalizePort(value, label) { + if (!/^[0-9]+$/.test(value)) invalid(label) + const port = Number(value) + if (!Number.isSafeInteger(port) || port < 1 || port > 65535) invalid(label) + return port +} + +function splitHostPort(input, label) { + if (input.startsWith('[')) { + const close = input.indexOf(']') + if (close < 0) invalid(label) + const host = input.slice(1, close) + const suffix = input.slice(close + 1) + if (suffix === '') return { host, port: null } + if (!suffix.startsWith(':')) invalid(label) + return { host, port: normalizePort(suffix.slice(1), label) } + } + if (isIP(input) === 6) return { host: input, port: null } + const colon = input.lastIndexOf(':') + if (colon >= 0) { + if (input.indexOf(':') !== colon) invalid(label) + return { host: input.slice(0, colon), port: normalizePort(input.slice(colon + 1), label) } + } + return { host: input, port: null } +} + +function normalizeHost(input, label) { + let value = input.toLowerCase() + while (value.endsWith('.')) value = value.slice(0, -1) + if (value === '') invalid(label) + const ipType = isIP(value) + if (ipType !== 0) return { host: value, ipType } + const suffix = value.startsWith('.') + const name = suffix ? value.slice(1) : value + if (name === '' || name.includes('*')) invalid(label) + const ascii = domainToASCII(name) + if (ascii === '' || ascii.length > 253 || ascii.split('.').some(part => part === '' || part.length > 63)) invalid(label) + return { host: suffix ? `.${ascii}` : ascii, ipType: 0 } +} + +export function normalizeProxyRule(input, { allowWildcard = false, allowCidr = false, label = 'proxy' } = {}) { + if ( + typeof input !== 'string' + || input === '' + || input !== input.trim() + || /[\u0000-\u0020\u007f]/.test(input) + || /[@?#]/.test(input) + ) invalid(label) + if (input === '*') { + if (!allowWildcard) invalid(label) + return Object.freeze({ type: 'wildcard', value: '*' }) + } + if (input.includes('/')) { + if (!allowCidr) invalid(label) + if (input.indexOf('/') !== input.lastIndexOf('/')) invalid(label) + const slash = input.lastIndexOf('/') + const address = input.slice(0, slash) + const type = isIP(address) + const prefix = Number(input.slice(slash + 1)) + if (type === 0 || !Number.isInteger(prefix) || prefix < 0 || prefix > (type === 4 ? 32 : 128)) invalid(label) + return Object.freeze({ type: 'cidr', value: `${address}/${prefix}`, address, prefix, ipType: type }) + } + const { host: rawHost, port } = splitHostPort(input, label) + const { host, ipType } = normalizeHost(rawHost, label) + const bracketed = ipType === 6 && port !== null + const value = port === null ? host : `${bracketed ? `[${host}]` : host}:${String(port)}` + return Object.freeze({ type: host.startsWith('.') ? 'suffix' : 'host', value, host, port, ipType }) +} + +export function normalizeProxyRules(values, options = {}) { + if (!Array.isArray(values)) throw new ProxyConfigurationError(`${options.label ?? 'proxy'} rules must be an array`) + const result = new Map() + for (const value of values) { + const rule = normalizeProxyRule(value, options) + result.set(rule.value, rule) + } + return Object.freeze([...result.values()].sort((left, right) => left.value.localeCompare(right.value))) +} + +function hostMatches(rule, host, port) { + if (rule.port !== null && rule.port !== port) return false + if (rule.type === 'suffix') { + const root = rule.host.slice(1) + return host === root || host.endsWith(`.${root}`) + } + return host === rule.host +} + +export function matchesProxyRules(rules, targetHost, targetPort = null) { + const normalized = normalizeHost(String(targetHost), 'target').host + const ipType = isIP(normalized) + for (const rule of rules) { + if (rule.type === 'wildcard') return true + if (rule.type === 'cidr') { + if (ipType === 0) continue + const block = new BlockList() + block.addSubnet(rule.address, rule.prefix, rule.ipType === 4 ? 'ipv4' : 'ipv6') + if (block.check(normalized, ipType === 4 ? 'ipv4' : 'ipv6')) return true + continue + } + if (hostMatches(rule, normalized, targetPort)) return true + } + return false +} + +export function noProxyEnvironment(userRules) { + const system = normalizeProxyRules(PLATFORM_NO_PROXY, { label: 'platform NO_PROXY' }) + const user = normalizeProxyRules(userRules, { allowWildcard: true, label: 'NO_PROXY' }) + return [...new Set([...system, ...user].map(rule => rule.value))].join(',') +} diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/scope-catalog.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/scope-catalog.mjs new file mode 100644 index 0000000..bee88a9 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/scope-catalog.mjs @@ -0,0 +1,52 @@ +const groups = Object.freeze({ + updates: { zh: '更新管理', en: 'Updates' }, + platform: { zh: '平台组件', en: 'Platform components' }, + dsh: { zh: 'DSH 核心与插件', en: 'DSH core and plugins' }, + agent: { zh: 'Agent 与终端', en: 'Agent and terminals' }, + models: { zh: '模型 API', en: 'Model APIs' }, + direct: { zh: '始终直连或不受平台管理', en: 'Always direct or unmanaged' }, +}) + +function entry(id, group, category, sourceZh, sourceEn, detailZh, detailEn) { + return Object.freeze({ + id, group, category, + source: Object.freeze({ zh: sourceZh, en: sourceEn }), + detail: Object.freeze({ zh: detailZh, en: detailEn }), + }) +} + +export const PROXY_SCOPE_CATALOG = Object.freeze({ + schema: 1, + groups, + entries: Object.freeze([ + entry('stable-metadata', 'updates', 'updates', 'Stable、keyring、Release metadata 检查', 'Stable, keyring, and Release metadata checks', '仅远程获取走代理。', 'Only remote retrieval uses the proxy.'), + entry('update-artifacts', 'updates', 'updates', '更新 Artifact 下载', 'Update Artifact downloads', '包括 DSH、Environment 和 Bootstrap 等下载。', 'Includes DSH, Environment, Bootstrap, and related downloads.'), + entry('experimental-metadata', 'updates', 'updates', 'Experimental 上游版本检查', 'Experimental upstream checks', '包括 npm 和相关 metadata 请求。', 'Includes npm and related metadata requests.'), + entry('update-remote', 'updates', 'updates', '更新、回滚中的远程请求', 'Remote requests during update and rollback', '本地切换、恢复和校验保持直连。', 'Local switching, recovery, and verification remain direct.'), + entry('stage0-verification', 'direct', 'direct', 'Stage-0 签名与 Hash 校验', 'Stage-0 signature and Hash verification', '完全本地执行,不接收代理凭据。', 'Runs locally and never receives proxy credentials.'), + entry('management-external', 'platform', 'platform', 'Management 对外请求', 'Management external requests', '不包括本地 Unix socket 通信。', 'Excludes local Unix socket communication.'), + entry('gateway-external', 'platform', 'platform', 'Gateway 自身对外请求', 'Gateway external requests', '浏览器到 DSH 的本地反向代理不使用外部代理。', 'The local browser-to-DSH reverse proxy does not use the external proxy.'), + entry('updater-platform', 'platform', 'platform', 'Updater 的非更新类外部请求', 'Updater non-update external requests', '更新请求优先归入更新管理。', 'Update requests are classified as Updates first.'), + entry('system-plugins', 'platform', 'platform', 'DSH Docker 系统插件', 'DSH Docker System Plugins', '身份来自签名 Environment Manifest。', 'Identity comes from the signed Environment Manifest.'), + entry('dsh-core', 'dsh', 'dshCore', 'DSH 核心对外请求', 'DSH core external requests', '不包括模型 Provider API。', 'Excludes model Provider APIs.'), + entry('official-plugins', 'dsh', 'dshPlugins', 'DSH 官方插件', 'Official DSH plugins', '当前与第三方插件共用一个总开关。', 'Currently shares one switch with third-party plugins.'), + entry('user-plugins', 'dsh', 'dshPlugins', '用户安装的第三方插件', 'User-installed third-party plugins', '包括插件产生的可识别子进程。', 'Includes identifiable child processes created by plugins.'), + entry('plugin-terminal', 'dsh', 'dshPlugins', '第三方插件提供的终端', 'Third-party plugin terminals', '通过可识别子进程接口创建时归入插件范围;直接创建的 PTY 跟随共享 DSH 策略。', 'Uses the plugin scope when created through an identifiable subprocess interface; directly created PTYs follow the shared DSH policy.'), + entry('agent-tools', 'agent', 'agentNetwork', 'Agent 的 web_search、web_fetch 等联网工具', 'Agent network tools such as web_search and web_fetch', '按 Agent 工具执行上下文注入代理。', 'Proxy settings are injected using the Agent execution context.'), + entry('agent-commands', 'agent', 'agentNetwork', 'Agent 执行的 curl、git、npm 等命令', 'Commands such as curl, git, and npm run by an Agent', '包括 Agent 创建的子进程。', 'Includes child processes created by the Agent.'), + entry('agent-session', 'agent', 'agentNetwork', 'Agent 持久终端会话', 'Persistent Agent terminal sessions', '会话创建时确定代理范围。', 'The proxy scope is selected when the session is created.'), + entry('management-terminal', 'agent', 'managementTerminal', '管理中心容器终端', 'Management container terminal', '独立于 Agent 和插件终端。', 'Independent from Agent and plugin terminals.'), + entry('docker-exec', 'direct', 'unmanaged', '宿主机执行的 docker exec Shell', 'Host-created docker exec Shell', '不受平台管理,用户自行设置代理环境。', 'Not managed by the platform; users configure its environment.'), + entry('provider-routed', 'models', 'modelApi', '可识别的模型 Provider', 'Identifiable model Providers', '可分别选择直连或独立代理。', 'Can independently select direct access or a dedicated proxy route.'), + entry('provider-shared', 'models', 'sharedDsh', '无法独立路由的模型请求', 'Model requests without independent routing', '只能选择直连或跟随 DSH 共享流量策略;DSH 核心或 DSH 插件任一范围启用时共享路由使用代理。', 'Can only connect directly or follow the shared DSH policy; the shared route uses the proxy when either DSH Core or DSH Plugins is enabled.'), + entry('provider-local', 'direct', 'forcedDirect', '本地模型 Provider', 'Local model Providers', '本地地址强制直连。', 'Local addresses are always direct.'), + entry('gateway-dsh', 'direct', 'direct', 'Gateway 到 DSH 的本地转发', 'Local Gateway-to-DSH forwarding', '容器内部 loopback 通信。', 'Container-internal loopback communication.'), + entry('platform-sockets', 'direct', 'direct', 'Management、Bootstrap、Stage-0 的 Unix socket', 'Management, Bootstrap, and Stage-0 Unix sockets', '本地控制协议不使用代理。', 'Local control protocols do not use the proxy.'), + entry('local-files', 'direct', 'direct', '日志、文件、快照、Runtime 构建', 'Logs, files, snapshots, and Runtime builds', '本地文件和进程操作不使用代理。', 'Local file and process operations do not use the proxy.'), + ]), + summaries: Object.freeze([ + Object.freeze({ zh: '本地平台通信始终直连。', en: 'Local platform communication is always direct.' }), + Object.freeze({ zh: '可识别的模型 Provider 可分别选择直连或独立代理;无法独立路由的请求只能选择直连或跟随 DSH。', en: 'Identifiable model Providers independently select direct access or a dedicated proxy; requests without independent routing can only connect directly or follow DSH.' }), + Object.freeze({ zh: 'docker exec 创建的 Shell 不受平台代理设置管理。', en: 'Shells created with docker exec are not managed by platform proxy settings.' }), + ]), +}) diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/store.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/store.mjs new file mode 100644 index 0000000..8f8bb8c --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/store.mjs @@ -0,0 +1,195 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { chmod, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs' +import { durableReplace } from '../../../../platform/lib/atomic.mjs' +import { defaultProxyConfiguration, validateProxyConfiguration } from './contracts.mjs' +import { ProxyConfigurationError } from './errors.mjs' + +function hash(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +async function syncDirectory(path) { + const handle = await open(path, 'r') + try { await handle.sync() } finally { await handle.close() } +} + +async function writeSynced(path, bytes, mode) { + const handle = await open(path, 'wx', mode) + try { + await handle.writeFile(bytes) + await handle.sync() + } finally { + await handle.close() + } +} + +function revision() { + return randomBytes(32).toString('base64url') +} + +function parse(bytes, label) { + try { return JSON.parse(bytes.toString('utf8')) } catch { throw new ProxyConfigurationError(`${label} is corrupt`, { code: 'PROXY_STATE_CORRUPT', stage: 'load' }) } +} + +export class ProxyConfigurationStore { + constructor(root, { now = () => new Date() } = {}) { + this.root = root + this.revisionsRoot = join(root, 'revisions') + this.currentPath = join(root, 'current.json') + this.now = now + this.queue = Promise.resolve() + } + + exclusive(operation) { + const result = this.queue.then(operation, operation) + this.queue = result.then(() => undefined, () => undefined) + return result + } + + async prepare() { + await mkdir(this.revisionsRoot, { recursive: true, mode: 0o700 }) + await chmod(this.root, 0o700) + await chmod(this.revisionsRoot, 0o700) + } + + async readPointer() { + try { + const pointer = parse(await readFile(this.currentPath), 'proxy current pointer') + if (pointer?.schema !== 1 || typeof pointer.revision !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(pointer.revision)) { + throw new ProxyConfigurationError('proxy current pointer is corrupt', { code: 'PROXY_STATE_CORRUPT', stage: 'load' }) + } + return pointer.revision + } catch (error) { + if (error?.code === 'ENOENT') return null + throw error + } + } + + async readRevision(id) { + const root = join(this.revisionsRoot, id) + const [configBytes, credentialsBytes, manifestBytes] = await Promise.all([ + readFile(join(root, 'config.json')), + readFile(join(root, 'credentials.json')), + readFile(join(root, 'manifest.json')), + ]) + const manifest = parse(manifestBytes, 'proxy revision manifest') + if ( + manifest?.schema !== 1 || manifest.revision !== id + || manifest.files?.config !== hash(configBytes) + || manifest.files?.credentials !== hash(credentialsBytes) + ) throw new ProxyConfigurationError('proxy revision manifest does not match its files', { code: 'PROXY_STATE_CORRUPT', stage: 'load' }) + const configuration = parse(configBytes, 'proxy configuration') + const credentials = parse(credentialsBytes, 'proxy credentials') + if (credentials?.schema !== 1 || credentials.username !== configuration?.proxy?.username || !('password' in credentials)) { + throw new ProxyConfigurationError('proxy credentials do not match configuration', { code: 'PROXY_STATE_CORRUPT', stage: 'load' }) + } + const validated = validateProxyConfiguration({ + ...configuration, + proxy: { ...configuration.proxy, password: credentials.password }, + noProxy: { user: configuration.noProxy.user }, + }) + if (validated.configuration.proxy.passwordConfigured !== configuration.proxy.passwordConfigured) { + throw new ProxyConfigurationError('proxy password state is inconsistent', { code: 'PROXY_STATE_CORRUPT', stage: 'load' }) + } + return Object.freeze({ revision: id, configuration: validated.configuration, credentials: validated.credentials, createdAt: manifest.createdAt }) + } + + async recoveryCandidates() { + const entries = await readdir(this.revisionsRoot, { withFileTypes: true }) + const candidates = [] + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue + try { + const loaded = await this.readRevision(entry.name) + candidates.push(loaded) + } catch {} + } + return candidates.sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + } + + async load() { + await this.prepare() + let current = null + let corruptPointer = false + try { + current = await this.readPointer() + } catch (error) { + if (error?.code !== 'PROXY_STATE_CORRUPT') throw error + corruptPointer = true + } + if (current !== null) { + try { return Object.freeze({ ...(await this.readRevision(current)), recovery: 'none' }) } catch (error) { + if (error?.code !== 'PROXY_STATE_CORRUPT' && error?.code !== 'ENOENT') throw error + } + } + const recovered = (await this.recoveryCandidates())[0] + if (recovered !== undefined) { + await durableReplace(this.currentPath, canonicalJson({ schema: 1, revision: recovered.revision }), 0o640) + return Object.freeze({ ...recovered, recovery: 'previous' }) + } + const entries = await readdir(this.revisionsRoot, { withFileTypes: true }) + const hadPublishedState = corruptPointer || entries.some(entry => entry.isDirectory() && !entry.name.startsWith('.')) + if (corruptPointer) { + await rename(this.currentPath, join(this.root, `current.corrupt.${randomUUID()}.json`)) + await syncDirectory(this.root) + } + const initialized = await this.commit({ baseRevision: null, value: defaultProxyConfiguration() }) + return Object.freeze({ ...initialized, recovery: hadPublishedState ? 'reset-disabled' : 'none' }) + } + + commit(request) { + return this.exclusive(() => this.commitUnlocked(request)) + } + + async commitUnlocked({ baseRevision, value }) { + await this.prepare() + let current = null + try { current = await this.loadCurrentWithoutRecovery() } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + if ((current?.revision ?? null) !== baseRevision) { + throw new ProxyConfigurationError('proxy configuration changed', { + code: 'REVISION_CONFLICT', statusCode: 409, stage: 'activate', + }) + } + const validated = validateProxyConfiguration(value, { existingPassword: current?.credentials.password ?? null }) + const id = revision() + const staging = join(this.revisionsRoot, `.${id}.${randomUUID()}.tmp`) + const published = join(this.revisionsRoot, id) + await mkdir(staging, { mode: 0o700 }) + try { + const configBytes = canonicalJson(validated.configuration) + const credentialsBytes = canonicalJson({ schema: 1, ...validated.credentials }) + const manifestBytes = canonicalJson({ + schema: 1, + revision: id, + createdAt: this.now().toISOString(), + files: { config: hash(configBytes), credentials: hash(credentialsBytes) }, + }) + await writeSynced(join(staging, 'config.json'), configBytes, 0o640) + await writeSynced(join(staging, 'credentials.json'), credentialsBytes, 0o600) + await writeSynced(join(staging, 'manifest.json'), manifestBytes, 0o640) + await syncDirectory(staging) + await rename(staging, published) + await syncDirectory(this.revisionsRoot) + const candidate = await this.readRevision(id) + await durableReplace(this.currentPath, canonicalJson({ schema: 1, revision: id }), 0o640) + return candidate + } catch (error) { + await rm(staging, { recursive: true, force: true }) + throw error + } + } + + async loadCurrentWithoutRecovery() { + const current = await this.readPointer() + if (current === null) { + const error = new Error('proxy configuration is not initialized') + error.code = 'ENOENT' + throw error + } + return this.readRevision(current) + } +} diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/test-manager.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/test-manager.mjs new file mode 100644 index 0000000..851e053 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/test-manager.mjs @@ -0,0 +1,455 @@ +import { createHash, randomUUID } from 'node:crypto' +import { lookup } from 'node:dns/promises' +import { connect as tlsConnect } from 'node:tls' +import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs' +import { durableReplace } from '../../../../platform/lib/atomic.mjs' +import { validateProxyConfiguration } from './contracts.mjs' +import { ProxyDnsCache } from './dns-cache.mjs' +import { ProxyConfigurationError } from './errors.mjs' +import { selectProxyRoute } from './policy.mjs' +import { + connectTcp, + connectThroughHttpProxy, + connectThroughSocks5, + readHttpHead, + ProxyTransportError, +} from './transport.mjs' + +const TASK_SCHEMA = 1 +const PHASE_TIMEOUT_MS = 15_000 +const TOTAL_TIMEOUT_MS = 60_000 +const RETENTION_MS = 10 * 60_000 +const STAGES = Object.freeze([ + 'proxy-address', 'proxy-connect', 'proxy-handshake', 'target-dns', 'target-tls', 'target-http', +]) +const TARGETS = Object.freeze([ + Object.freeze({ id: 'github-metadata', host: 'api.github.com', port: 443, path: '/repos/yjrszcq/dsh-docker/releases/latest' }), + Object.freeze({ id: 'npm-registry', host: 'registry.npmjs.org', port: 443, path: '/@deepseek-ai%2fdsh/latest' }), +]) + +function safeError(error) { + return Object.freeze({ + errorCode: typeof error?.code === 'string' ? error.code : 'PROXY_TEST_FAILED', + detail: error instanceof Error ? error.message : 'proxy test failed', + }) +} + +function pendingStage(stage) { + return { + stage, + status: 'pending', + durationMs: null, + errorCode: null, + detail: null, + detailCode: null, + detailData: null, + } +} + +function stageDetail(detail, detailCode, detailData = {}) { + return Object.freeze({ detail, detailCode, detailData: Object.freeze(detailData) }) +} + +function stagesFor(configuration) { + const localTargetDns = configuration.proxy.protocol === 'socks5' + && configuration.proxy.remoteDns === false + return localTargetDns ? STAGES : STAGES.filter(stage => stage !== 'target-dns') +} + +function taskHash(configuration, baseRevision) { + return `sha256:${createHash('sha256').update(canonicalJson({ baseRevision, configuration })).digest('hex')}` +} + +function publicTask(task) { + return Object.freeze({ + schema: TASK_SCHEMA, + taskId: task.taskId, + status: task.status, + baseRevision: task.baseRevision, + candidateHash: task.candidateHash, + mode: task.mode, + currentStage: task.currentStage, + stages: Object.freeze(task.stages.map(stage => Object.freeze({ ...stage }))), + error: task.error === null ? null : Object.freeze({ ...task.error }), + createdAt: task.createdAt, + updatedAt: task.updatedAt, + }) +} + +function modeFor(routes) { + const modes = new Set(routes.map(route => route.mode === 'direct' ? 'direct' : 'proxy')) + return modes.size === 1 ? modes.values().next().value : 'mixed' +} + +async function firstConnection(route, target, signal) { + if (route.mode === 'direct') { + const candidates = route.targets ?? [{ address: target.host }] + let failure + for (const candidate of candidates) { + try { + return Object.freeze({ + socket: await connectTcp({ host: candidate.address, port: target.port, signal }), + remainder: Buffer.alloc(0), + }) + } catch (error) { failure = error } + } + throw failure ?? new ProxyTransportError('target connection failed') + } + if (route.mode === 'http') return connectThroughHttpProxy({ + endpoint: route.endpoint, targetHost: target.host, targetPort: target.port, signal, + }) + if (route.mode === 'socks5') { + const candidates = route.targets ?? [{ address: target.host }] + let failure + for (const candidate of candidates) { + try { + return await connectThroughSocks5({ + endpoint: route.endpoint, targetHost: candidate.address, targetPort: target.port, signal, + }) + } catch (error) { failure = error } + } + throw failure ?? new ProxyTransportError('SOCKS5 target connection failed') + } + throw new ProxyTransportError('proxy route is invalid', { code: 'PROXY_PROTOCOL_UNAVAILABLE' }) +} + +async function secureConnection(route, target, signal) { + const connection = await firstConnection(route, target, signal) + if (connection.remainder.byteLength > 0) connection.socket.unshift(connection.remainder) + const socket = tlsConnect({ socket: connection.socket, servername: target.host, rejectUnauthorized: true }) + await new Promise((resolve, reject) => { + const cleanup = () => { + socket.off('secureConnect', connected) + socket.off('error', failed) + signal.removeEventListener('abort', cancelled) + } + const connected = () => { cleanup(); resolve() } + const failed = error => { cleanup(); reject(error) } + const cancelled = () => { + cleanup() + socket.destroy() + reject(new ProxyTransportError('TLS connection was cancelled', { code: 'REQUEST_CANCELLED', statusCode: 499 })) + } + socket.once('secureConnect', connected) + socket.once('error', failed) + signal.addEventListener('abort', cancelled, { once: true }) + }) + return socket +} + +async function targetHttp(route, target, signal) { + const socket = await secureConnection(route, target, signal) + try { + socket.write([ + `GET ${target.path} HTTP/1.1`, + `Host: ${target.host}`, + 'User-Agent: DSH-Docker-Proxy-Test/1', + 'Accept: application/json', + 'Connection: close', + '', + '', + ].join('\r\n')) + const { head } = await readHttpHead(socket, { signal }) + const status = Number(/^HTTP\/1\.[01] ([0-9]{3})/.exec(head.toString('latin1'))?.[1]) + if (!Number.isInteger(status) || status >= 500) { + throw new ProxyTransportError(`target returned HTTP ${Number.isInteger(status) ? String(status) : 'invalid'}`, { + code: 'TARGET_HTTP_FAILED', + }) + } + return status + } finally { + socket.destroy() + } +} + +export class ProxyTestManager { + constructor({ + statePath, + now = () => new Date(), + resolve = lookup, + phaseTimeoutMs = PHASE_TIMEOUT_MS, + totalTimeoutMs = TOTAL_TIMEOUT_MS, + retentionMs = RETENTION_MS, + targets = TARGETS, + }) { + this.statePath = statePath + this.now = now + this.resolve = resolve + this.phaseTimeoutMs = phaseTimeoutMs + this.totalTimeoutMs = totalTimeoutMs + this.retentionMs = retentionMs + this.targets = targets + this.tasks = new Map() + this.active = null + this.lastTest = null + this.writeQueue = Promise.resolve() + } + + async initialize(readState) { + let value + try { value = await readState(this.statePath) } catch (error) { + if (error?.code === 'ENOENT') return + throw error + } + if (value?.schema !== TASK_SCHEMA || !Array.isArray(value.tasks)) return + for (const stored of value.tasks) { + if (stored?.schema !== TASK_SCHEMA || typeof stored.taskId !== 'string') continue + const task = { ...stored, stages: stored.stages.map(stage => ({ ...stage })), controller: undefined } + if (task.status === 'running') { + task.status = 'failed' + task.error = { errorCode: 'PROXY_TEST_INTERRUPTED', detail: 'Proxy Manager restarted during the test' } + task.updatedAt = this.now().toISOString() + for (const stage of task.stages) if (['pending', 'running'].includes(stage.status)) stage.status = 'skipped' + } + this.tasks.set(task.taskId, task) + } + this.prune() + this.lastTest = [...this.tasks.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0] ?? null + await this.persist() + } + + getState() { + return { lastTest: this.lastTest === null ? null : publicTask(this.lastTest) } + } + + prune() { + const cutoff = this.now().getTime() - this.retentionMs + for (const [id, task] of this.tasks) { + if (task.status !== 'running' && Date.parse(task.updatedAt) < cutoff) this.tasks.delete(id) + } + } + + async persist() { + this.prune() + const tasks = [...this.tasks.values()].map(task => publicTask(task)) + const bytes = canonicalJson({ schema: TASK_SCHEMA, tasks }) + const write = this.writeQueue.then(() => durableReplace(this.statePath, bytes, 0o640)) + this.writeQueue = write.catch(() => {}) + await write + } + + async lookup(host, signal) { + if (signal?.aborted) throw signal.reason + return new Promise((resolve, reject) => { + const cancelled = () => reject(signal.reason ?? new ProxyTransportError('DNS resolution was cancelled', { + code: 'REQUEST_CANCELLED', statusCode: 499, + })) + signal?.addEventListener('abort', cancelled, { once: true }) + Promise.resolve(this.resolve(host, { all: true, verbatim: true })).then(resolve, reject).finally(() => { + signal?.removeEventListener('abort', cancelled) + }) + }) + } + + async start(request, current) { + if (request === null || typeof request !== 'object' || Array.isArray(request) + || Object.keys(request).sort().join(',') !== 'baseRevision,value') { + throw new ProxyConfigurationError('proxy test request is invalid', { stage: 'test' }) + } + const { baseRevision, value } = request + if (this.active !== null) { + throw new ProxyConfigurationError('another proxy test is already running', { + code: 'PROXY_TEST_BUSY', statusCode: 409, stage: 'test', retryable: true, + }) + } + if (baseRevision !== current.revision) { + throw new ProxyConfigurationError('proxy configuration changed', { + code: 'REVISION_CONFLICT', statusCode: 409, stage: 'test', retryable: true, + }) + } + const testedValue = { + ...value, + enabled: true, + scopes: { ...value.scopes, updates: true }, + noProxy: { ...value.noProxy, user: [] }, + bypass: { ...value.bypass, additional: [] }, + } + const validated = validateProxyConfiguration(testedValue, { existingPassword: current.credentials.password }) + const snapshot = Object.freeze({ + revision: `test-${randomUUID()}`, + configuration: validated.configuration, + credentials: validated.credentials, + }) + const createdAt = this.now().toISOString() + const task = { + schema: TASK_SCHEMA, + taskId: randomUUID(), + status: 'running', + baseRevision, + candidateHash: taskHash(validated.configuration, baseRevision), + mode: null, + currentStage: stagesFor(validated.configuration)[0], + stages: stagesFor(validated.configuration).map(pendingStage), + error: null, + createdAt, + updatedAt: createdAt, + controller: new AbortController(), + } + this.tasks.set(task.taskId, task) + this.active = task + await this.persist() + void this.run(task, snapshot).catch(() => {}) + return publicTask(task) + } + + async update(task, value) { + Object.assign(task, value, { updatedAt: this.now().toISOString() }) + await this.persist() + } + + async stage(task, name, operation) { + const entry = task.stages.find(value => value.stage === name) + entry.status = 'running' + task.currentStage = name + await this.update(task, {}) + const started = Date.now() + const signal = AbortSignal.any([task.controller.signal, AbortSignal.timeout(this.phaseTimeoutMs)]) + try { + const result = await operation(signal) + Object.assign(entry, { + status: 'success', durationMs: Date.now() - started, errorCode: null, + detail: typeof result === 'string' ? result : result?.detail ?? null, + detailCode: typeof result === 'object' ? result?.detailCode ?? null : null, + detailData: typeof result === 'object' ? result?.detailData ?? null : null, + }) + await this.update(task, {}) + } catch (error) { + const cancelled = task.controller.signal.aborted + && task.controller.signal.reason?.code !== 'PROXY_TEST_TIMEOUT' + const failure = cancelled + ? { errorCode: 'REQUEST_CANCELLED', detail: 'proxy test was cancelled' } + : signal.reason?.name === 'TimeoutError' + ? { errorCode: 'PROXY_TEST_STAGE_TIMEOUT', detail: `${name} timed out` } + : safeError(error) + Object.assign(entry, { + status: cancelled ? 'cancelled' : 'failed', + durationMs: Date.now() - started, + ...failure, + }) + throw error + } + } + + async run(task, snapshot) { + const totalTimer = setTimeout(() => task.controller.abort(new ProxyTransportError('proxy test timed out', { + code: 'PROXY_TEST_TIMEOUT', statusCode: 504, + })), this.totalTimeoutMs) + totalTimer.unref?.() + const dnsCache = new ProxyDnsCache({ resolver: this.resolve }) + let routes + try { + routes = await Promise.all(this.targets.map(target => selectProxyRoute({ + snapshot, scope: 'updates', host: target.host, port: target.port, dnsCache, + signal: task.controller.signal, + }))) + task.mode = modeFor(routes) + const proxied = routes.find(route => route.mode !== 'direct') + if (proxied === undefined) { + for (const name of ['proxy-address', 'proxy-connect', 'proxy-handshake']) { + const stage = task.stages.find(value => value.stage === name) + Object.assign(stage, { + status: 'skipped', durationMs: 0, detail: 'candidate route is direct', + detailCode: 'route-direct', detailData: {}, + }) + } + await this.update(task, {}) + } else { + await this.stage(task, 'proxy-address', async () => { + const records = await this.lookup(proxied.endpoint.host, task.controller.signal) + if (!Array.isArray(records) || records.length === 0) throw new ProxyTransportError('proxy address resolution returned no records', { code: 'PROXY_DNS_FAILED' }) + return stageDetail( + `resolved ${String(records.length)} proxy address record(s)`, + 'proxy-addresses-resolved', + { count: records.length }, + ) + }) + await this.stage(task, 'proxy-connect', async signal => { + const socket = await connectTcp({ host: proxied.endpoint.host, port: proxied.endpoint.port, signal }) + socket.destroy() + return stageDetail('proxy TCP connection succeeded', 'proxy-connected') + }) + await this.stage(task, 'proxy-handshake', async signal => { + const target = this.targets[routes.indexOf(proxied)] + const connection = await firstConnection(proxied, target, signal) + connection.socket.destroy() + const protocol = snapshot.configuration.proxy.protocol.toUpperCase() + return stageDetail(`${protocol} proxy authentication succeeded`, 'proxy-handshake-completed', { protocol }) + }) + } + if (task.stages.some(stage => stage.stage === 'target-dns')) { + await this.stage(task, 'target-dns', async () => { + for (const target of this.targets) { + const records = await this.lookup(target.host, task.controller.signal) + if (!Array.isArray(records) || records.length === 0) throw new ProxyTransportError('target DNS returned no records', { code: 'TARGET_DNS_FAILED' }) + } + return stageDetail( + `resolved ${String(this.targets.length)} target(s)`, + 'target-addresses-resolved', + { count: this.targets.length }, + ) + }) + } + await this.stage(task, 'target-tls', async signal => { + for (let index = 0; index < this.targets.length; index += 1) { + const socket = await secureConnection(routes[index], this.targets[index], signal) + socket.destroy() + } + return stageDetail( + `TLS verified for ${String(this.targets.length)} target(s)`, + 'target-tls-verified', + { count: this.targets.length }, + ) + }) + await this.stage(task, 'target-http', async signal => { + const results = [] + for (let index = 0; index < this.targets.length; index += 1) { + const status = await targetHttp(routes[index], this.targets[index], signal) + results.push({ id: this.targets[index].id, status }) + } + return stageDetail( + results.map(result => `${result.id}=HTTP ${String(result.status)}`).join(', '), + 'target-http-completed', + { results }, + ) + }) + await this.update(task, { status: 'success', currentStage: null, error: null }) + } catch (error) { + const timeout = task.controller.signal.reason?.code === 'PROXY_TEST_TIMEOUT' + const cancelled = task.controller.signal.aborted && !timeout + for (const stage of task.stages) { + if (stage.status === 'pending') Object.assign(stage, { status: 'skipped', durationMs: 0 }) + } + await this.update(task, { + status: cancelled ? 'cancelled' : 'failed', + currentStage: null, + error: cancelled + ? { errorCode: 'REQUEST_CANCELLED', detail: 'proxy test was cancelled' } + : timeout ? safeError(task.controller.signal.reason) : safeError(error), + }) + } finally { + clearTimeout(totalTimer) + if (this.active === task) this.active = null + task.controller = undefined + this.lastTest = task + await this.persist() + } + } + + get(taskId) { + this.prune() + const task = this.tasks.get(taskId) + if (task === undefined) throw new ProxyConfigurationError('proxy test task was not found', { + code: 'PROXY_TEST_NOT_FOUND', statusCode: 404, stage: 'test', + }) + return publicTask(task) + } + + async cancel(taskId) { + const task = this.tasks.get(taskId) + if (task === undefined) return this.get(taskId) + if (task.status === 'running') task.controller.abort(new Error('proxy test cancelled')) + return publicTask(task) + } +} + +export const proxyTestInternals = Object.freeze({ STAGES, TARGETS, modeFor, publicTask, stageDetail, stagesFor }) diff --git a/upstream/container/control-plane/services/outbound-proxy/lib/transport.mjs b/upstream/container/control-plane/services/outbound-proxy/lib/transport.mjs new file mode 100644 index 0000000..619d42b --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/lib/transport.mjs @@ -0,0 +1,423 @@ +import { lookup } from 'node:dns/promises' +import { connect, isIP } from 'node:net' +import { domainToASCII } from 'node:url' + +export const PROXY_TIMEOUTS = Object.freeze({ + connectMs: 10_000, + handshakeMs: 15_000, + responseHeaderMs: 60_000, + streamIdleMs: 120_000, + tunnelIdleMs: 30 * 60_000, +}) + +export class ProxyTransportError extends Error { + constructor(message, { code = 'UPSTREAM_CONNECT_FAILED', statusCode = 502, cause } = {}) { + super(message, { cause }) + this.name = 'ProxyTransportError' + this.code = code + this.statusCode = statusCode + } +} + +function timeoutError(stage) { + return new ProxyTransportError(`${stage} timed out`, { + code: 'PROXY_CONNECT_TIMEOUT', + statusCode: 504, + }) +} + +export async function connectTcp({ host, port, timeoutMs = PROXY_TIMEOUTS.connectMs, signal }) { + if (signal?.aborted) { + throw new ProxyTransportError('upstream connection was cancelled', { + code: 'REQUEST_CANCELLED', statusCode: 499, + }) + } + const socket = connect({ host, port, allowHalfOpen: true }) + // The socket passes through async handshake and Agent/tunnel owners. Keep a + // baseline listener so a peer reset during an ownership handoff cannot exit + // the Proxy Manager; each active owner still installs its own error handler. + socket.on('error', () => {}) + try { + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer) + socket.off('connect', connected) + socket.off('error', failed) + signal?.removeEventListener('abort', cancelled) + } + const connected = () => { cleanup(); resolve() } + const failed = error => { cleanup(); reject(error) } + const cancelled = () => { + const error = new ProxyTransportError('upstream connection was cancelled', { + code: 'REQUEST_CANCELLED', statusCode: 499, + }) + cleanup() + socket.destroy() + reject(error) + } + const timer = setTimeout(() => { cleanup(); reject(timeoutError('TCP connection')) }, timeoutMs) + socket.once('connect', connected) + socket.once('error', failed) + signal?.addEventListener('abort', cancelled, { once: true }) + }) + socket.setTimeout(0) + return socket + } catch (error) { + socket.destroy() + if (error instanceof ProxyTransportError) throw error + throw new ProxyTransportError('upstream connection failed', { cause: error }) + } +} + +export function basicProxyAuthorization(username, password) { + if (username === '' && password === null) return null + return `Basic ${Buffer.from(`${username}:${password ?? ''}`, 'utf8').toString('base64')}` +} + +function validateHttpHeadLimits(head) { + const lines = head.toString('latin1').split('\r\n') + if (lines.length - 2 > 256 || lines.some(line => Buffer.byteLength(line, 'latin1') > 64 * 1024)) { + throw new ProxyTransportError('upstream response headers are too large', { + code: 'UPSTREAM_HEADERS_TOO_LARGE', + }) + } +} + +export async function readHttpHead(socket, { + maxBytes = 256 * 1024, + timeoutMs = PROXY_TIMEOUTS.handshakeMs, + signal, +} = {}) { + const reader = new SocketByteReader(socket) + const cancel = () => socket.destroy(new ProxyTransportError('proxy handshake was cancelled', { + code: 'REQUEST_CANCELLED', statusCode: 499, + })) + signal?.addEventListener('abort', cancel, { once: true }) + try { + const head = await reader.readUntil(Buffer.from('\r\n\r\n'), maxBytes, Date.now() + timeoutMs) + return Object.freeze({ head, remainder: reader.release() }) + } catch (error) { + if (reader.pending === null) reader.release() + if (error instanceof ProxyTransportError) throw error + throw new ProxyTransportError('upstream proxy handshake failed', { cause: error }) + } finally { + signal?.removeEventListener('abort', cancel) + } +} + +export async function connectThroughHttpProxy({ + endpoint, + targetHost, + targetPort, + connectTimeoutMs = PROXY_TIMEOUTS.connectMs, + handshakeTimeoutMs = PROXY_TIMEOUTS.handshakeMs, + signal, +}) { + const socket = await connectTcp({ ...endpoint, timeoutMs: connectTimeoutMs, signal }) + const authorization = basicProxyAuthorization(endpoint.username, endpoint.password) + const authority = targetHost.includes(':') ? `[${targetHost}]:${targetPort}` : `${targetHost}:${targetPort}` + socket.write([ + `CONNECT ${authority} HTTP/1.1`, + `Host: ${authority}`, + ...(authorization === null ? [] : [`Proxy-Authorization: ${authorization}`]), + 'Proxy-Connection: keep-alive', + '', + '', + ].join('\r\n')) + try { + const result = await readHttpHead(socket, { timeoutMs: handshakeTimeoutMs, signal }) + validateHttpHeadLimits(result.head) + const first = result.head.subarray(0, result.head.indexOf('\r\n')).toString('latin1') + const match = /^HTTP\/1\.[01] ([0-9]{3})(?: |$)/.exec(first) + if (match === null) throw new ProxyTransportError('upstream proxy returned an invalid response') + const status = Number(match[1]) + if (status < 200 || status >= 300) { + throw new ProxyTransportError( + status === 407 ? 'upstream proxy authentication failed' : 'upstream proxy rejected the tunnel', + { code: status === 407 ? 'UPSTREAM_PROXY_AUTH_FAILED' : 'UPSTREAM_PROXY_REJECTED' }, + ) + } + return Object.freeze({ socket, remainder: result.remainder }) + } catch (error) { + socket.destroy() + throw error + } +} + +function ipv4Bytes(address) { + const values = address.split('.').map(Number) + if (values.length !== 4 || values.some(value => !Number.isInteger(value) || value < 0 || value > 255)) { + throw new ProxyTransportError('SOCKS5 IPv4 target is invalid', { code: 'SOCKS5_TARGET_INVALID' }) + } + return Buffer.from(values) +} + +function ipv6Groups(input) { + const expand = values => { + const result = [] + for (const value of values) { + if (value.includes('.')) { + const bytes = ipv4Bytes(value) + result.push(bytes.readUInt16BE(0).toString(16), bytes.readUInt16BE(2).toString(16)) + } else result.push(value) + } + return result + } + const halves = input.toLowerCase().split('::') + if (halves.length > 2) throw new ProxyTransportError('SOCKS5 IPv6 target is invalid', { code: 'SOCKS5_TARGET_INVALID' }) + const left = expand(halves[0] === '' ? [] : halves[0].split(':')) + const right = expand(halves.length === 1 || halves[1] === '' ? [] : halves[1].split(':')) + const missing = 8 - left.length - right.length + if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) { + throw new ProxyTransportError('SOCKS5 IPv6 target is invalid', { code: 'SOCKS5_TARGET_INVALID' }) + } + const groups = halves.length === 1 ? left : [...left, ...Array(missing).fill('0'), ...right] + if (groups.length !== 8 || groups.some(group => !/^[0-9a-f]{1,4}$/.test(group))) { + throw new ProxyTransportError('SOCKS5 IPv6 target is invalid', { code: 'SOCKS5_TARGET_INVALID' }) + } + return groups.map(group => Number.parseInt(group, 16)) +} + +function ipv6Bytes(address) { + const output = Buffer.alloc(16) + ipv6Groups(address).forEach((value, index) => output.writeUInt16BE(value, index * 2)) + return output +} + +async function socksTarget(host, remoteDns, resolver) { + let value = host + let type = isIP(value) + if (type === 0 && !remoteDns) { + let record + try { record = await resolver(value, { verbatim: true }) } catch (error) { + throw new ProxyTransportError('SOCKS5 target DNS resolution failed', { + code: 'TARGET_DNS_FAILED', + cause: error, + }) + } + value = record.address + type = record.family + } + if (type === 4) return Buffer.concat([Buffer.from([0x01]), ipv4Bytes(value)]) + if (type === 6) return Buffer.concat([Buffer.from([0x04]), ipv6Bytes(value)]) + const ascii = domainToASCII(value) + const bytes = Buffer.from(ascii, 'ascii') + if (ascii === '' || bytes.byteLength > 255) { + throw new ProxyTransportError('SOCKS5 domain target is invalid', { code: 'SOCKS5_TARGET_INVALID' }) + } + return Buffer.concat([Buffer.from([0x03, bytes.byteLength]), bytes]) +} + +class SocketByteReader { + constructor(socket) { + this.socket = socket + this.buffer = Buffer.alloc(0) + this.pending = null + this.ended = false + this.failure = null + this.onData = chunk => { + this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk]) + this.flush() + } + this.onEnd = () => { + this.ended = true + this.flush() + } + this.onError = error => { + this.failure = error + this.flush() + } + socket.on('data', this.onData) + socket.on('end', this.onEnd) + socket.on('error', this.onError) + } + + flush() { + if (this.pending === null) return + let size = null + if (this.pending.kind === 'exact' && this.buffer.byteLength >= this.pending.size) size = this.pending.size + if (this.pending.kind === 'until') { + const boundary = this.buffer.indexOf(this.pending.delimiter) + if (boundary >= 0) { + size = boundary + this.pending.delimiter.byteLength + if (size > this.pending.maxBytes) { + const pending = this.pending + this.pending = null + clearTimeout(pending.timer) + pending.reject(new ProxyTransportError('upstream response headers are too large', { + code: 'UPSTREAM_HEADERS_TOO_LARGE', + })) + return + } + } + else if (this.buffer.byteLength > this.pending.maxBytes) { + const pending = this.pending + this.pending = null + clearTimeout(pending.timer) + pending.reject(new ProxyTransportError('upstream response headers are too large', { + code: 'UPSTREAM_HEADERS_TOO_LARGE', + })) + return + } + } + if (size !== null) { + const pending = this.pending + this.pending = null + clearTimeout(pending.timer) + const value = this.buffer.subarray(0, size) + this.buffer = this.buffer.subarray(size) + pending.resolve(value) + return + } + if (this.failure !== null || this.ended) { + const pending = this.pending + this.pending = null + clearTimeout(pending.timer) + pending.reject(this.failure ?? new ProxyTransportError('upstream closed during SOCKS5 handshake')) + } + } + + read(size, deadline) { + if (!Number.isSafeInteger(size) || size < 0) { + return Promise.reject(new ProxyTransportError('SOCKS5 response length is invalid')) + } + if (this.pending !== null) { + return Promise.reject(new ProxyTransportError('concurrent SOCKS5 reads are not supported')) + } + if (this.buffer.byteLength >= size) { + const value = this.buffer.subarray(0, size) + this.buffer = this.buffer.subarray(size) + return Promise.resolve(value) + } + if (this.failure !== null) return Promise.reject(this.failure) + if (this.ended) return Promise.reject(new ProxyTransportError('upstream closed during SOCKS5 handshake')) + const remaining = deadline - Date.now() + if (remaining <= 0) return Promise.reject(timeoutError('SOCKS5 handshake')) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.pending?.timer !== timer) return + this.pending = null + reject(timeoutError('SOCKS5 handshake')) + }, remaining) + this.pending = { kind: 'exact', size, resolve, reject, timer } + this.flush() + }) + } + + readUntil(delimiter, maxBytes, deadline) { + if (!Buffer.isBuffer(delimiter) || delimiter.byteLength === 0 || !Number.isSafeInteger(maxBytes) || maxBytes < delimiter.byteLength) { + return Promise.reject(new ProxyTransportError('proxy handshake boundary is invalid')) + } + if (this.pending !== null) return Promise.reject(new ProxyTransportError('concurrent proxy handshake reads are not supported')) + const boundary = this.buffer.indexOf(delimiter) + if (boundary >= 0) { + const size = boundary + delimiter.byteLength + if (size > maxBytes) return Promise.reject(new ProxyTransportError('upstream response headers are too large', { code: 'UPSTREAM_HEADERS_TOO_LARGE' })) + const value = this.buffer.subarray(0, size) + this.buffer = this.buffer.subarray(size) + return Promise.resolve(value) + } + if (this.buffer.byteLength > maxBytes) return Promise.reject(new ProxyTransportError('upstream response headers are too large', { code: 'UPSTREAM_HEADERS_TOO_LARGE' })) + if (this.failure !== null) return Promise.reject(this.failure) + if (this.ended) return Promise.reject(new ProxyTransportError('upstream closed during proxy handshake')) + const remaining = deadline - Date.now() + if (remaining <= 0) return Promise.reject(timeoutError('proxy handshake')) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.pending?.timer !== timer) return + this.pending = null + reject(timeoutError('proxy handshake')) + }, remaining) + this.pending = { kind: 'until', delimiter, maxBytes, resolve, reject, timer } + this.flush() + }) + } + + release() { + if (this.pending !== null) throw new ProxyTransportError('proxy handshake read is still pending') + this.socket.pause() + this.socket.off('data', this.onData) + this.socket.off('end', this.onEnd) + this.socket.off('error', this.onError) + const remainder = this.buffer + this.buffer = Buffer.alloc(0) + return remainder + } +} + +function socksReplyError(code) { + const names = new Map([ + [0x01, 'general failure'], [0x02, 'connection not allowed'], [0x03, 'network unreachable'], + [0x04, 'host unreachable'], [0x05, 'connection refused'], [0x06, 'TTL expired'], + [0x07, 'command not supported'], [0x08, 'address type not supported'], + ]) + return new ProxyTransportError(`SOCKS5 proxy rejected the connection (${names.get(code) ?? 'unknown error'})`, { + code: 'UPSTREAM_PROXY_REJECTED', + }) +} + +export async function connectThroughSocks5({ + endpoint, + targetHost, + targetPort, + resolver = lookup, + connectTimeoutMs = PROXY_TIMEOUTS.connectMs, + handshakeTimeoutMs = PROXY_TIMEOUTS.handshakeMs, + timeoutMs, + signal, +}) { + const socket = await connectTcp({ ...endpoint, timeoutMs: connectTimeoutMs, signal }) + const cancel = () => socket.destroy(new ProxyTransportError('SOCKS5 handshake was cancelled', { + code: 'REQUEST_CANCELLED', statusCode: 499, + })) + signal?.addEventListener('abort', cancel, { once: true }) + const reader = new SocketByteReader(socket) + const deadline = Date.now() + (timeoutMs ?? handshakeTimeoutMs) + try { + const username = Buffer.from(endpoint.username ?? '', 'utf8') + const password = Buffer.from(endpoint.password ?? '', 'utf8') + const authenticated = username.byteLength > 0 || endpoint.password !== null + if (username.byteLength > 255 || password.byteLength > 255) { + throw new ProxyTransportError('SOCKS5 credentials are too long', { code: 'SOCKS5_CREDENTIALS_INVALID' }) + } + socket.write(authenticated ? Buffer.from([0x05, 0x02, 0x00, 0x02]) : Buffer.from([0x05, 0x01, 0x00])) + const greeting = await reader.read(2, deadline) + if (greeting[0] !== 0x05) throw new ProxyTransportError('SOCKS5 proxy returned an invalid greeting') + if (greeting[1] === 0xff) throw new ProxyTransportError('SOCKS5 proxy has no acceptable authentication method', { code: 'UPSTREAM_PROXY_AUTH_FAILED' }) + if (greeting[1] === 0x02) { + if (!authenticated) throw new ProxyTransportError('SOCKS5 proxy requires authentication', { code: 'UPSTREAM_PROXY_AUTH_FAILED' }) + socket.write(Buffer.concat([ + Buffer.from([0x01, username.byteLength]), username, + Buffer.from([password.byteLength]), password, + ])) + const authentication = await reader.read(2, deadline) + if (authentication[0] !== 0x01 || authentication[1] !== 0x00) { + throw new ProxyTransportError('SOCKS5 proxy authentication failed', { code: 'UPSTREAM_PROXY_AUTH_FAILED' }) + } + } else if (greeting[1] !== 0x00) { + throw new ProxyTransportError('SOCKS5 proxy selected an unsupported authentication method', { code: 'UPSTREAM_PROXY_AUTH_FAILED' }) + } + const address = await socksTarget(targetHost, endpoint.remoteDns, resolver) + const port = Buffer.alloc(2) + port.writeUInt16BE(targetPort) + socket.write(Buffer.concat([Buffer.from([0x05, 0x01, 0x00]), address, port])) + const response = await reader.read(4, deadline) + if (response[0] !== 0x05 || response[2] !== 0x00) throw new ProxyTransportError('SOCKS5 proxy returned an invalid CONNECT response') + if (response[1] !== 0x00) throw socksReplyError(response[1]) + if (response[3] === 0x01) await reader.read(6, deadline) + else if (response[3] === 0x04) await reader.read(18, deadline) + else if (response[3] === 0x03) { + const length = (await reader.read(1, deadline))[0] + await reader.read(length + 2, deadline) + } else throw new ProxyTransportError('SOCKS5 proxy returned an invalid address type') + const remainder = reader.release() + return Object.freeze({ socket, remainder }) + } catch (error) { + if (reader.pending === null) reader.release() + socket.destroy() + if (error instanceof ProxyTransportError) throw error + throw new ProxyTransportError('SOCKS5 proxy handshake failed', { cause: error }) + } finally { + signal?.removeEventListener('abort', cancel) + } +} diff --git a/upstream/container/control-plane/services/outbound-proxy/supervisor.mjs b/upstream/container/control-plane/services/outbound-proxy/supervisor.mjs new file mode 100644 index 0000000..039a8f1 --- /dev/null +++ b/upstream/container/control-plane/services/outbound-proxy/supervisor.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +import { LocalApiClient } from '../../modules/updater/lib/client.mjs' +import { PlatformPaths } from '../../../platform/lib/paths.mjs' + +const paths = new PlatformPaths( + process.env.DSH_PLATFORM_DATA ?? '/data/platform', + process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform', +) +const launch = new LocalApiClient(paths.proxyLaunchSocket) + +if (process.argv[2] === 'health') { + const status = await launch.request('GET', '/v1/status') + process.exit(status.componentReady === true ? 0 : 1) +} + +const token = process.env.DSH_PROXY_LAUNCH_TOKEN +delete process.env.DSH_PROXY_LAUNCH_TOKEN +if (typeof token !== 'string' || token.length < 32) throw new Error('Proxy launch token is unavailable') +await launch.request('POST', '/v1/start', { token }) + +let stopping = false +let resolveStop +const stopped = new Promise(resolve => { resolveStop = resolve }) +const stop = () => { + if (stopping) return + stopping = true + void launch.request('POST', '/v1/stop', { token }).catch(() => {}).finally(resolveStop) +} +process.once('SIGINT', stop) +process.once('SIGTERM', stop) + +const poll = setInterval(() => { + void launch.request('GET', '/v1/status').then(status => { + if (!stopping && status.running !== true) { + clearInterval(poll) + process.exitCode = 1 + resolveStop() + } + }, () => { + if (!stopping) { + clearInterval(poll) + process.exitCode = 1 + resolveStop() + } + }) +}, 500) +await stopped +clearInterval(poll) diff --git a/upstream/container/environment/definition.json b/upstream/container/environment/definition.json new file mode 100644 index 0000000..646ed72 --- /dev/null +++ b/upstream/container/environment/definition.json @@ -0,0 +1,63 @@ +{ + "version": "1.0.11", + "bootstrapApi": 1, + "systemSkillCatalog": { + "artifactId": "system-skill-catalog", + "source": "resources/skills", + "mediaType": "application/vnd.dsh-platform.system-skill-catalog.v1+tar+gzip" + }, + "components": [ + { + "id": "dsh-runtime", + "artifactId": "component-dsh-runtime", + "source": "dsh-runtime/component.json", + "mediaType": "application/vnd.dsh-platform.component.v1+json" + } + ], + "patches": [ + { + "id": "directory-picker", + "artifactId": "patch-directory-picker", + "source": "resources/patches/directory-picker.mjs", + "mediaType": "text/javascript" + }, + { + "id": "browser-loopback", + "artifactId": "patch-browser-loopback", + "source": "resources/patches/browser-loopback.mjs", + "mediaType": "text/javascript" + }, + { + "id": "managed-lifecycle", + "artifactId": "patch-managed-lifecycle", + "source": "resources/patches/managed-lifecycle.mjs", + "mediaType": "text/javascript" + }, + { + "id": "native-corners", + "artifactId": "patch-native-corners", + "source": "resources/patches/native-corners.mjs", + "mediaType": "text/javascript" + } + ], + "systemPlugins": [ + { + "id": "platform-management", + "artifactId": "system-plugin-platform-management", + "source": "resources/plugins/platform-management/package", + "mediaType": "application/vnd.dsh-platform.system-plugin.v1+tar+gzip" + }, + { + "id": "settings-navigation", + "artifactId": "system-plugin-settings-navigation", + "source": "resources/plugins/settings-navigation/package", + "mediaType": "application/vnd.dsh-platform.system-plugin.v1+tar+gzip" + }, + { + "id": "settings-document-editor", + "artifactId": "system-plugin-settings-document-editor", + "source": "resources/plugins/settings-document-editor/package", + "mediaType": "application/vnd.dsh-platform.system-plugin.v1+tar+gzip" + } + ] +} diff --git a/upstream/container/environment/dsh-runtime/component.json b/upstream/container/environment/dsh-runtime/component.json new file mode 100644 index 0000000..5eb41bd --- /dev/null +++ b/upstream/container/environment/dsh-runtime/component.json @@ -0,0 +1,43 @@ +{ + "schema": 1, + "id": "dsh-runtime", + "type": "service", + "command": { + "executable": "/run/dsh-platform/views/runtime/bin/dsh", + "args": ["web", "--patch", "/run/dsh-platform/views/system-plugins/cordis.patch.yml", "--host", "127.0.0.1", "--port", "3079"], + "timeoutSeconds": 30 + }, + "environment": { + "HOME": "/home/node", + "LOGNAME": "node", + "USER": "node", + "DSH_BUNDLED_SKILL_DIR": "/run/dsh-platform/views/skills", + "XDG_CACHE_HOME": "/home/node/.cache", + "XDG_CONFIG_HOME": "/home/node/.config", + "XDG_DATA_HOME": "/home/node/.local/share" + }, + "lifecycle": { + "prepare": null, + "preStart": null, + "postStart": { + "executable": "/usr/local/bin/node", + "args": ["/run/dsh-platform/views/bootstrap/control-plane/hooks/dsh-web-ready.mjs"], + "timeoutSeconds": 30 + }, + "preStop": null, + "stop": null, + "postStop": null + }, + "health": { + "type": "http", + "host": "127.0.0.1", + "port": 3079, + "path": "/", + "intervalSeconds": 1, + "timeoutSeconds": 30 + }, + "logging": { + "stdout": true, + "stderr": true + } +} diff --git a/upstream/container/environment/resources/patches/browser-loopback.mjs b/upstream/container/environment/resources/patches/browser-loopback.mjs new file mode 100644 index 0000000..8bc6944 --- /dev/null +++ b/upstream/container/environment/resources/patches/browser-loopback.mjs @@ -0,0 +1,41 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' + +export const BROWSER_CONNECTION_TARGET = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-client-connection/lib/client.js' +export const BROWSER_CONNECTION_RELATIVE_TARGET = 'node_modules/@deepseek-ai/dsh-client-connection/lib/client.js' + +const LOOPBACK_CLASSIFICATIONS = Object.freeze([ + 'isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),', + 'isLoopback: transport?.ownsHost === true || pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),', +]) + +export function patchBrowserLoopback(target = BROWSER_CONNECTION_TARGET) { + const after = 'isLoopback: true,' + const source = readFileSync(target, 'utf8') + const matches = LOOPBACK_CLASSIFICATIONS.flatMap(before => source.split(before).slice(1).map(() => before)) + + if (matches.length !== 1) { + throw new Error( + `Expected exactly one browser loopback classification in ${target}, found ${matches.length}. ` + + 'The upstream package may have changed; review this patch before building.', + ) + } + + writeFileSync(target, source.replace(matches[0], after)) +} + +export function applyPatch(dshRoot) { + patchBrowserLoopback(resolve(dshRoot, BROWSER_CONNECTION_RELATIVE_TARGET)) +} + +export function verifyPatch(dshRoot) { + const target = resolve(dshRoot, BROWSER_CONNECTION_RELATIVE_TARGET) + const source = readFileSync(target, 'utf8') + const matches = source.split('isLoopback: true,').length - 1 + if (matches !== 1 || LOOPBACK_CLASSIFICATIONS.some(classification => source.includes(classification))) { + throw new Error(`Browser loopback Patch verification failed for ${target}`) + } +} + +if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) patchBrowserLoopback() diff --git a/upstream/container/environment/resources/patches/directory-picker.mjs b/upstream/container/environment/resources/patches/directory-picker.mjs new file mode 100644 index 0000000..776d1be --- /dev/null +++ b/upstream/container/environment/resources/patches/directory-picker.mjs @@ -0,0 +1,38 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' + +export const DIRECTORY_PICKER_TARGET = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker-browse/lib/index.js' +export const DIRECTORY_PICKER_RELATIVE_TARGET = 'node_modules/@deepseek-ai/dsh-host-directory-picker-browse/lib/index.js' + +export function patchDirectoryPicker(target = DIRECTORY_PICKER_TARGET) { + const before = 'const target = resolve(path ?? home);' + const after = 'const target = resolve(path ?? process.env.DSH_DEFAULT_WORKSPACE ?? home);' + const source = readFileSync(target, 'utf8') + const matches = source.split(before).length - 1 + + if (matches !== 1) { + throw new Error( + `Expected exactly one directory picker default path in ${target}, found ${matches}. ` + + 'The upstream package may have changed; review this patch before building.', + ) + } + + writeFileSync(target, source.replace(before, after)) +} + +export function applyPatch(dshRoot) { + patchDirectoryPicker(resolve(dshRoot, DIRECTORY_PICKER_RELATIVE_TARGET)) +} + +export function verifyPatch(dshRoot) { + const target = resolve(dshRoot, DIRECTORY_PICKER_RELATIVE_TARGET) + const expected = 'const target = resolve(path ?? process.env.DSH_DEFAULT_WORKSPACE ?? home);' + const source = readFileSync(target, 'utf8') + const matches = source.split(expected).length - 1 + if (matches !== 1 || source.includes('const target = resolve(path ?? home);')) { + throw new Error(`Directory picker Patch verification failed for ${target}`) + } +} + +if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) patchDirectoryPicker() diff --git a/upstream/container/environment/resources/patches/managed-lifecycle.mjs b/upstream/container/environment/resources/patches/managed-lifecycle.mjs new file mode 100644 index 0000000..596f037 --- /dev/null +++ b/upstream/container/environment/resources/patches/managed-lifecycle.mjs @@ -0,0 +1,550 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { basename, join, resolve } from 'node:path' + +export const MANAGED_LIFECYCLE_MODULE = 'lib/dsh-docker-managed-lifecycle.js' +export const PROFILE_PACKAGE_STORAGE_MODULE = 'lib/dsh-docker-profile-package-storage.js' +const BIN_TARGET = 'lib/bin.js' +const BIN_IMPORT = 'import { prepareManagedInvocation } from "./dsh-docker-managed-lifecycle.js";' +const PROFILE_IMPORT = 'import { managedSigtermHandler, markManagedReady, provideManagedLifecycle } from "./dsh-docker-managed-lifecycle.js";' +// parseDshArgs signature changed in dsh 0.2.0 (version and manageDesktopProfile come from runCli scope). +const BIN_DISPATCHES = [ + 'const invocation = parseDshArgs(process.argv.slice(2), readVersion());', + 'const invocation = parseDshArgs(process.argv.slice(2), version, manageDesktopProfile);', +] +const binDispatchPatched = (dispatch) => `${dispatch}\nconst managedExitCode = await prepareManagedInvocation(invocation);\nif (managedExitCode !== null) process.exit(managedExitCode);` +// dsh 0.1.x indents the handler with 2 tabs, 0.2.x with 3 — match the shape, not the indent. +const SIGTERM_SOURCE = /process\.on\("SIGTERM", \(\) => \{\n\t+interrupt\(0\);\n\t+\}\);/g +const SIGTERM_PATCHED = 'process.on("SIGTERM", managedSigtermHandler(interrupt));' +const HOST_PROVIDER = 'hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment);' +const HOST_PROVIDER_PATCHED = `${HOST_PROVIDER}\n\t\tprovideManagedLifecycle(hostCtx);` +// Same: settled-return indentation differs between dsh 0.1.x (1 tab) and 0.2.x (2 tabs). +const PROFILE_RETURN_SOURCE = /(\t+)return \{\n\t+ctx,\n\t+shutdown\n\t+\};/g + +const PROFILE_PACKAGE_STORAGE_SOURCE = String.raw`import { + closeSync, cpSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, + statSync, unlinkSync, writeFileSync +} from "node:fs"; +import { spawnSync } from "node:child_process"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, normalize } from "node:path"; + +const MODULES_BACKUP = ".dsh-platform-node_modules.previous"; +const WORKSPACE_BACKUP = ".dsh-platform-pnpm-workspace.previous"; +const MIGRATION_COMMIT = ".dsh-platform-profile-migration.committed"; +const PNPM_TIMEOUT_MS = 3e5; + +function profileDirectory(profile) { + if (typeof profile !== "string" || profile === "" || profile === "." || profile === ".." + || profile === "node_modules" || profile.includes("/") || profile.includes("\\")) { + throw new Error("invalid managed DSH Profile name"); + } + const dshHome = process.env.DSH_HOME || join(homedir(), ".dsh"); + return { dshHome, profileDir: join(dshHome, "profiles", profile) }; +} + +function installedStore(profileDir) { + const modulesState = join(profileDir, "node_modules", ".modules.yaml"); + if (!existsSync(modulesState)) return null; + let value; + try { + value = JSON.parse(readFileSync(modulesState, "utf8")); + } catch { + throw new Error("Web Profile package metadata is not valid pnpm state"); + } + if (typeof value?.storeDir !== "string" || !isAbsolute(value.storeDir)) { + throw new Error("Web Profile package metadata has no absolute pnpm store"); + } + return normalize(value.storeDir); +} + +function withStoreDirectory(source, storeRoot) { + const line = "storeDir: " + JSON.stringify(storeRoot) + "\n"; + const matches = source.match(/^storeDir:[^\r\n]*(?:\r?\n|$)/gm) ?? []; + if (matches.length > 1) throw new Error("Web Profile pnpm workspace has multiple storeDir entries"); + if (matches.length === 1) return source.replace(/^storeDir:[^\r\n]*(?:\r?\n|$)/m, line); + return source.replace(/(?:\r?\n)?$/, "\n\n" + line); +} + +function replaceFile(path, content) { + const temporary = path + ".dsh-platform-" + String(process.pid) + "-" + String(Date.now()) + ".tmp"; + try { + writeFileSync(temporary, content, { flag: "wx", mode: statSync(path).mode & 0o777 }); + renameSync(temporary, path); + } finally { + try { unlinkSync(temporary); } catch {} + } +} + +function durableBackup(path, content, mode) { + const handle = openSync(path, "wx", mode); + try { + writeFileSync(handle, content); + fsyncSync(handle); + } finally { + closeSync(handle); + } +} + +function restoreWorkspace(workspacePath, backupPath) { + if (!existsSync(backupPath)) return; + if (existsSync(workspacePath)) unlinkSync(workspacePath); + renameSync(backupPath, workspacePath); +} + +function recoverInterruptedMigration(profileDir, workspacePath) { + const modulesPath = join(profileDir, "node_modules"); + const modulesBackup = join(profileDir, MODULES_BACKUP); + const workspaceBackup = join(profileDir, WORKSPACE_BACKUP); + const commitMarker = join(profileDir, MIGRATION_COMMIT); + if (!existsSync(modulesBackup) && !existsSync(workspaceBackup) && !existsSync(commitMarker)) return; + if (existsSync(commitMarker)) { + rmSync(workspaceBackup, { force: true }); + rmSync(modulesBackup, { recursive: true, force: true }); + rmSync(commitMarker, { force: true }); + return; + } + if (!existsSync(modulesBackup)) { + restoreWorkspace(workspacePath, workspaceBackup); + return; + } + if (existsSync(modulesPath)) rmSync(modulesPath, { recursive: true, force: true }); + if (existsSync(modulesBackup)) renameSync(modulesBackup, modulesPath); + restoreWorkspace(workspacePath, workspaceBackup); +} + +function pnpmInstall(profileDir, offline) { + const arguments_ = ["install"]; + if (offline) arguments_.push("--offline"); + arguments_.push("--frozen-lockfile", "--reporter=append-only"); + return spawnSync("pnpm", arguments_, { + cwd: profileDir, + encoding: "utf8", + env: process.env, + timeout: PNPM_TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024 + }); +} + +function failureDetail(result) { + if (result.error !== void 0) return result.error.message; + const output = [result.stdout, result.stderr].filter((value) => typeof value === "string" && value.trim() !== "").join("\n"); + return output.trim().slice(-4096) || "pnpm exited with status " + String(result.status); +} + +function migrateProfile(profileDir, workspacePath, originalWorkspace, currentStore, storeRoot) { + const modulesPath = join(profileDir, "node_modules"); + const modulesBackup = join(profileDir, MODULES_BACKUP); + const workspaceBackup = join(profileDir, WORKSPACE_BACKUP); + const commitMarker = join(profileDir, MIGRATION_COMMIT); + const targetStore = join(storeRoot, basename(currentStore)); + let copiedStore = false; + let migratedStore; + try { + if (existsSync(currentStore) && statSync(currentStore).isDirectory()) { + try { + mkdirSync(targetStore, { recursive: true }); + cpSync(currentStore, targetStore, { recursive: true, force: false, errorOnExist: false }); + copiedStore = true; + } catch {} + } + durableBackup(workspaceBackup, originalWorkspace, statSync(workspacePath).mode & 0o777); + replaceFile(workspacePath, withStoreDirectory(originalWorkspace, storeRoot)); + renameSync(modulesPath, modulesBackup); + let result = pnpmInstall(profileDir, copiedStore); + if (result.status !== 0 && copiedStore) { + rmSync(modulesPath, { recursive: true, force: true }); + result = pnpmInstall(profileDir, false); + } + if (result.status !== 0) throw new Error(failureDetail(result)); + migratedStore = installedStore(profileDir); + if (migratedStore === null || dirname(migratedStore) !== normalize(storeRoot)) { + throw new Error("pnpm rebuilt the Profile with an unexpected store"); + } + durableBackup(commitMarker, "committed\n", 384); + } catch (error) { + if (existsSync(modulesPath)) rmSync(modulesPath, { recursive: true, force: true }); + if (existsSync(modulesBackup)) renameSync(modulesBackup, modulesPath); + restoreWorkspace(workspacePath, workspaceBackup); + rmSync(commitMarker, { force: true }); + throw new Error("failed to migrate Profile package storage: " + String(error?.message ?? error)); + } + // The new layout is committed after verification. Cleanup is recoverable: + // removing the workspace backup first ensures any surviving modules backup + // is recognized as a committed migration on the next launch. + rmSync(workspaceBackup, { force: true }); + rmSync(modulesBackup, { recursive: true, force: true }); + rmSync(commitMarker, { force: true }); + return migratedStore; +} + +export function prepareProfilePackageStorage(profile) { + const { dshHome, profileDir } = profileDirectory(profile); + const manifestPath = join(profileDir, "package.json"); + const workspacePath = join(profileDir, "pnpm-workspace.yaml"); + if (!existsSync(manifestPath) || !existsSync(workspacePath)) return Object.freeze({ status: "absent" }); + const storeRoot = join(dshHome, ".pnpm-store"); + recoverInterruptedMigration(profileDir, workspacePath); + const currentStore = installedStore(profileDir); + if (currentStore !== null && dirname(currentStore) !== normalize(storeRoot)) { + const migratedStore = migrateProfile( + profileDir, workspacePath, readFileSync(workspacePath, "utf8"), currentStore, storeRoot + ); + console.log("dsh: migrated Profile package storage from " + currentStore + " to " + migratedStore); + return Object.freeze({ status: "migrated", currentStore: migratedStore, storeRoot }); + } + const original = readFileSync(workspacePath, "utf8"); + const updated = withStoreDirectory(original, storeRoot); + if (updated !== original) replaceFile(workspacePath, updated); + return Object.freeze({ status: "ready", currentStore, storeRoot }); +} +`; + +const ADAPTER_SOURCE = String.raw`import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { request } from "node:http"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { prepareProfilePackageStorage } from "./dsh-docker-profile-package-storage.js"; + +const API_PREFIX = "/_dsh_platform/api/v1/"; +const REQUEST_TIMEOUT_MS = 3e3; +const RESTART_FALLBACK_MS = 10e3; +let managedSessionId = null; + +function packageManifest(root, packageName) { + if (packageName.includes("\\")) return null; + const parts = packageName.split("/"); + const validShape = packageName.startsWith("@") ? parts.length === 2 : parts.length === 1; + if (!validShape || parts.some((part) => part === "" || part === "." || part === "..")) { + return null; + } + return join(root, "node_modules", ...parts, "package.json"); +} + +function packageExists(root, packageName) { + const manifest = packageManifest(root, packageName); + return manifest !== null && existsSync(manifest); +} + +function repairOrphanedProfileBundles() { + const profileDir = join(process.env.DSH_HOME || join(homedir(), ".dsh"), "profiles", "web"); + const manifestPath = join(profileDir, "package.json"); + if (!existsSync(manifestPath)) return; + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const bundles = manifest?.dsh?.profile?.bundles; + if (!Array.isArray(bundles)) return; + const dependencies = new Set(Object.keys(manifest.dependencies ?? {})); + const dshRoot = dirname(dirname(fileURLToPath(import.meta.url))); + const kept = []; + const removed = []; + for (const packageName of bundles) { + if (typeof packageName !== "string" + || dependencies.has(packageName) + || packageExists(profileDir, packageName) + || packageExists(dshRoot, packageName)) { + kept.push(packageName); + } else { + removed.push(packageName); + } + } + if (removed.length === 0) return; + manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles: kept } }; + const temporary = manifestPath + ".dsh-platform-" + String(process.pid) + "-" + String(Date.now()) + ".tmp"; + try { + writeFileSync(temporary, JSON.stringify(manifest, null, 2) + "\n", { + flag: "wx", + mode: statSync(manifestPath).mode & 0o777 + }); + renameSync(temporary, manifestPath); + } finally { + try { unlinkSync(temporary); } catch {} + } + console.log("dsh: removed orphaned profile bundle(s): " + removed.join(", ")); +} + +function socketPath(name) { + return process.env.DSH_PLATFORM_RUN + "/" + name; +} + +function requestJson(socket, method, path, body) { + return new Promise((resolveRequest, reject) => { + const bytes = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body)); + const req = request({ + socketPath: socket, + method, + path, + headers: bytes === void 0 ? {} : { + "content-type": "application/json", + "content-length": bytes.byteLength + } + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + let value = {}; + try { + value = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch {} + if ((response.statusCode ?? 500) >= 400) { + const error = new Error(value.error ?? "local API request failed"); + error.statusCode = response.statusCode; + reject(error); + } else resolveRequest(value); + }); + }); + req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error("local API request timed out"))); + req.once("error", reject); + req.end(bytes); + }); +} + +function managedWeb(invocation) { + return process.env.DSH_PLATFORM_MANAGED === "1" + && invocation?.mode === "profile" + && invocation.profile === "web"; +} + +async function management(method, path) { + return requestJson(socketPath("management-cli.sock"), method, API_PREFIX + path); +} + +export async function prepareManagedInvocation(invocation) { + if (process.env.DSH_PLATFORM_MANAGED === "1" && invocation?.mode === "plugin") { + try { + prepareProfilePackageStorage(invocation.profile); + } catch (error) { + console.error("dsh: failed to prepare Profile package storage: " + String(error?.message ?? error)); + return 1; + } + return null; + } + if (!managedWeb(invocation)) return null; + if (typeof process.env.DSH_PLATFORM_RUN !== "string" || process.env.DSH_PLATFORM_RUN === "") { + console.error("dsh: managed lifecycle runtime directory is unavailable"); + return 1; + } + let claimed; + try { + claimed = await requestJson(socketPath("dsh-lifecycle.sock"), "POST", "/v1/runtime/claim", { + launchToken: process.env.DSH_PLATFORM_LAUNCH_TOKEN ?? "" + }); + } catch (error) { + if (error?.statusCode !== 409) { + console.error("dsh: managed lifecycle broker is unavailable; refusing an unsupervised Web instance"); + return 1; + } + } + if (claimed !== void 0) { + if (typeof claimed.sessionId !== "string" || claimed.sessionId === "") { + console.error("dsh: managed lifecycle broker returned an invalid launch session"); + return 1; + } + managedSessionId = claimed.sessionId; + delete process.env.DSH_PLATFORM_LAUNCH_TOKEN; + try { + prepareProfilePackageStorage("web"); + } catch (error) { + console.error("dsh: Web Profile package storage repair failed; plugin changes may be unavailable: " + String(error?.message ?? error)); + } + try { + repairOrphanedProfileBundles(); + } catch (error) { + console.error("dsh: failed to repair orphaned Web Profile bundles: " + String(error?.message ?? error)); + return 1; + } + return null; + } + + let status; + try { + status = await management("GET", "status"); + } catch { + console.error("dsh: DSH is managed by the container platform, but its Control Plane is unavailable"); + console.error("dsh: open /_dsh_platform/console/ after the Control Plane recovers"); + return 1; + } + const state = status?.dshLifecycle?.state; + if (state === "stopped") { + try { + const task = await management("POST", "start-dsh"); + console.log("dsh: requested managed DSH start (task " + String(task.taskId) + ")"); + return 0; + } catch (error) { + console.error("dsh: managed DSH start was rejected: " + String(error?.message ?? error)); + return 1; + } + } + if (state === "failed" || status?.recoveryMode != null) { + console.error("dsh: managed DSH is in recovery mode; open /_dsh_platform/console/"); + return 1; + } + if (["running", "starting", "stopping", "restarting", "recovering"].includes(state)) { + console.log("dsh: managed DSH is " + state + "; no second Web instance was started"); + return 0; + } + console.error("dsh: managed DSH state is unavailable; open /_dsh_platform/console/"); + return 1; +} + +export function managedSigtermHandler(interrupt) { + if (managedSessionId === null) return () => interrupt(0); + let count = 0; + let fallback; + let abandoned = false; + return () => { + count += 1; + if (count > 1) { + abandoned = true; + if (fallback !== void 0) clearTimeout(fallback); + interrupt(0); + return; + } + void requestJson(socketPath("dsh-lifecycle.sock"), "POST", "/v1/runtime/signal", { + sessionId: managedSessionId, + signal: "SIGTERM" + }).then(async ({ disposition }) => { + if (abandoned) return; + if (disposition === "terminate") { + interrupt(0); + return; + } + if (disposition !== "request-restart") throw new Error("invalid lifecycle signal response"); + const task = await management("POST", "restart-dsh"); + console.log("dsh: requested managed DSH restart (task " + String(task.taskId) + ")"); + fallback = setTimeout(() => interrupt(0), RESTART_FALLBACK_MS); + fallback.unref?.(); + }).catch(() => interrupt(0)); + }; +} + +export async function markManagedReady(ctx) { + if (managedSessionId === null) return; + const connection = ctx?.get?.("connection"); + const webServer = ctx?.get?.("webServer"); + const readyUrl = typeof connection?.authenticatedUrl === "function" + && Number.isInteger(webServer?.port) + ? connection.authenticatedUrl("http://127.0.0.1:" + String(webServer.port)) + : null; + await requestJson(socketPath("dsh-lifecycle.sock"), "POST", "/v1/runtime/ready", { + sessionId: managedSessionId, + readyUrl + }); +} + +export function provideManagedLifecycle(hostCtx) { + if (managedSessionId === null) return; + hostCtx.provide("dshPlatformLifecycle", Object.freeze({ + restart: () => management("POST", "restart-dsh") + })); +} +` + +function exactlyOnce(source, pattern, name, path) { + const matches = [...source.matchAll(pattern)] + if (matches.length !== 1) { + throw new Error(`Expected exactly one ${name} in ${path}, found ${String(matches.length)}. The upstream package may have changed; review this patch before building.`) + } + return matches[0] +} + +function sigtermSource(source) { + const matches = [...source.matchAll(SIGTERM_SOURCE)] + return matches.length === 1 ? matches[0] : null +} + +function replaceOnce(source, before, after, name, path) { + const count = source.split(before).length - 1 + if (count !== 1) { + throw new Error(`Expected exactly one ${name} in ${path}, found ${String(count)}. The upstream package may have changed; review this patch before building.`) + } + return source.replace(before, after) +} + +function profileImplementation(dshRoot, binSource) { + // dsh 0.1.x: hashed chunk import; 0.2.x: bare facade ./profile-boot.js (any quote style). + const binMatch = exactlyOnce( + binSource, + /const \{ runProfile \} = await import\(['"](\.\/profile-boot(?:-[A-Za-z0-9_-]+)?\.js)['"]\);/g, + 'dynamic Web Profile import', + join(dshRoot, BIN_TARGET), + ) + const wrapperPath = join(dshRoot, 'lib', basename(binMatch[1])) + const wrapper = readFileSync(wrapperPath, 'utf8') + if (sigtermSource(wrapper) !== null && wrapper.includes(HOST_PROVIDER)) return wrapperPath + const implementation = exactlyOnce( + wrapper, + /import \{[^\n;]*\brunProfile\b[^\n;]*\} from ['"](\.\/profile-boot(?:-[A-Za-z0-9_-]+)?\.js)['"];?/g, + 'Profile implementation import', + wrapperPath, + ) + return join(dshRoot, 'lib', basename(implementation[1])) +} + +export function applyPatch(dshRoot) { + const root = resolve(dshRoot) + const binPath = join(root, BIN_TARGET) + const bin = readFileSync(binPath, 'utf8') + const profilePath = profileImplementation(root, bin) + let profile = readFileSync(profilePath, 'utf8') + if (readFileSync(binPath, 'utf8').includes(BIN_IMPORT)) throw new Error('Managed lifecycle Patch is already applied') + if (existsSync(join(root, MANAGED_LIFECYCLE_MODULE))) throw new Error('Managed lifecycle adapter path already exists') + if (existsSync(join(root, PROFILE_PACKAGE_STORAGE_MODULE))) throw new Error('Profile package storage adapter path already exists') + exactlyOnce(bin, /^#!\/usr\/bin\/env node$/gm, 'DSH bin shebang', binPath) + let patchedBin = bin.replace(/^#!\/usr\/bin\/env node$/m, `#!/usr/bin/env node\n${BIN_IMPORT}`) + const dispatch = BIN_DISPATCHES.find(candidate => patchedBin.includes(candidate)) + if (dispatch === undefined) { + throw new Error(`Expected one known invocation dispatch in ${binPath}; the upstream package may have changed. Known: ${JSON.stringify(BIN_DISPATCHES)}`) + } + patchedBin = replaceOnce(patchedBin, dispatch, binDispatchPatched(dispatch), 'DSH invocation dispatch', binPath) + profile = `${PROFILE_IMPORT}\n${profile}` + const sigterm = sigtermSource(profile) + if (sigterm === null) throw new Error(`Expected exactly one Web Profile SIGTERM handler in ${profilePath}, found 0. The upstream package may have changed; review this patch before building.`) + profile = profile.replace(SIGTERM_SOURCE, SIGTERM_PATCHED) + profile = replaceOnce(profile, HOST_PROVIDER, HOST_PROVIDER_PATCHED, 'Web Profile host provider', profilePath) + // Settled return: preserve the source indentation, insert the ready-marker before it. + const settledMatches = [...profile.matchAll(PROFILE_RETURN_SOURCE)] + if (settledMatches.length !== 1) { + throw new Error(`Expected exactly one Web Profile settled return in ${profilePath}, found ${String(settledMatches.length)}. The upstream package may have changed; review this patch before building.`) + } + const settled = settledMatches[0] + profile = profile.replace( + PROFILE_RETURN_SOURCE, + (match, indent) => `${indent}if (ctx.get("loader") !== void 0) await markManagedReady(ctx);\n${match}`, + ) + writeFileSync(join(root, MANAGED_LIFECYCLE_MODULE), ADAPTER_SOURCE) + writeFileSync(join(root, PROFILE_PACKAGE_STORAGE_MODULE), PROFILE_PACKAGE_STORAGE_SOURCE) + writeFileSync(profilePath, profile) + writeFileSync(binPath, patchedBin) +} + +export function verifyPatch(dshRoot) { + const root = resolve(dshRoot) + const binPath = join(root, BIN_TARGET) + const bin = readFileSync(binPath, 'utf8') + const profilePath = profileImplementation(root, bin) + const profile = readFileSync(profilePath, 'utf8') + const adapter = readFileSync(join(root, MANAGED_LIFECYCLE_MODULE), 'utf8') + const packageStorage = readFileSync(join(root, PROFILE_PACKAGE_STORAGE_MODULE), 'utf8') + const dispatched = BIN_DISPATCHES.filter(candidate => bin.split(binDispatchPatched(candidate)).length - 1 === 1) + if (bin.split(BIN_IMPORT).length - 1 !== 1 || dispatched.length !== 1) { + throw new Error(`Managed lifecycle bin Patch verification failed for ${binPath}`) + } + if (profile.split(PROFILE_IMPORT).length - 1 !== 1 + || profile.split(SIGTERM_PATCHED).length - 1 !== 1 + || profile.split(HOST_PROVIDER_PATCHED).length - 1 !== 1 + || [...profile.matchAll(/\t+if \(ctx\.get\("loader"\) !== void 0\) await markManagedReady\(ctx\);\n\t+return \{\n\t+ctx,\n\t+shutdown\n\t+\};/g)].length !== 1 + || sigtermSource(profile) !== null) { + throw new Error(`Managed lifecycle Profile Patch verification failed for ${profilePath}`) + } + for (const marker of ['prepareManagedInvocation', 'managedSigtermHandler', 'markManagedReady', 'provideManagedLifecycle']) { + if (!adapter.includes(`export function ${marker}`) && !adapter.includes(`export async function ${marker}`)) { + throw new Error(`Managed lifecycle adapter is missing ${marker}`) + } + } + if (!adapter.includes('function repairOrphanedProfileBundles()')) { + throw new Error('Managed lifecycle adapter is missing repairOrphanedProfileBundles') + } + for (const marker of ['prepareProfilePackageStorage', 'migrateProfile', '.pnpm-store']) { + if (!packageStorage.includes(marker)) throw new Error(`Managed lifecycle Profile package storage is missing ${marker}`) + } +} diff --git a/upstream/container/environment/resources/patches/native-corners.mjs b/upstream/container/environment/resources/patches/native-corners.mjs new file mode 100644 index 0000000..17ea76b --- /dev/null +++ b/upstream/container/environment/resources/patches/native-corners.mjs @@ -0,0 +1,29 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' + +export const NATIVE_CORNERS_RELATIVE_TARGET = 'node_modules/@deepseek-ai/dsh-client-ui-theme/lib/client.js' + +const BEFORE = 'var corner_shape_css_default = "@supports (corner-shape:superellipse(1.5)){:root{--dsw-corner-shape:superellipse(1.5)}*,:before,:after{corner-shape:var(--dsw-corner-shape)}}";' +const AFTER = 'var corner_shape_css_default = "";' + +export function patchNativeCorners(target) { + if (!existsSync(target)) return + const source = readFileSync(target, 'utf8') + const matches = source.split(BEFORE).length - 1 + if (matches !== 1) throw new Error(`Expected exactly one DSH global corner theme in ${target}, found ${matches}. The upstream package may have changed; review this patch before building.`) + writeFileSync(target, source.replace(BEFORE, AFTER)) +} + +export function applyPatch(dshRoot) { + patchNativeCorners(resolve(dshRoot, NATIVE_CORNERS_RELATIVE_TARGET)) +} + +export function verifyPatch(dshRoot) { + const target = resolve(dshRoot, NATIVE_CORNERS_RELATIVE_TARGET) + if (!existsSync(target)) return + const source = readFileSync(target, 'utf8') + if (source.split(AFTER).length - 1 !== 1 || source.includes(BEFORE)) throw new Error(`Native corners Patch verification failed for ${target}`) +} + +if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) patchNativeCorners(process.argv[2]) diff --git a/upstream/container/environment/resources/plugins/platform-management/package/cordis.patch.json b/upstream/container/environment/resources/plugins/platform-management/package/cordis.patch.json new file mode 100644 index 0000000..b4757e1 --- /dev/null +++ b/upstream/container/environment/resources/plugins/platform-management/package/cordis.patch.json @@ -0,0 +1,10 @@ +[ + { + "insert": [ + { + "id": "dsh-docker.platform-management.plugin", + "name": "@dsh-docker/platform-management" + } + ] + } +] diff --git a/upstream/container/environment/resources/plugins/platform-management/package/lib/client.bundle.js b/upstream/container/environment/resources/plugins/platform-management/package/lib/client.bundle.js new file mode 100644 index 0000000..4261fdc --- /dev/null +++ b/upstream/container/environment/resources/plugins/platform-management/package/lib/client.bundle.js @@ -0,0 +1,2818 @@ +window.__ModuleLoader__.load({ id: "@dsh-docker/platform-management", factory: (require) => { +var module = { exports: {} }; var exports = module.exports; +const css = Object.freeze({"actionHeading":"dshPlatform_actionHeading","actions":"dshPlatform_actions","authenticationButton":"dshPlatform_authenticationButton","autoScrollButton":"dshPlatform_autoScrollButton","caption":"dshPlatform_caption","checkSpinner":"dshPlatform_checkSpinner","clearLogsButton":"dshPlatform_clearLogsButton","commandHint":"dshPlatform_commandHint","confirmActions":"dshPlatform_confirmActions","confirmation":"dshPlatform_confirmation","connection":"dshPlatform_connection","dangerButton":"dshPlatform_dangerButton","dangerFilledButton":"dshPlatform_dangerFilledButton","detail":"dshPlatform_detail","disconnected":"dshPlatform_disconnected","emptyLogs":"dshPlatform_emptyLogs","emptyPlugins":"dshPlatform_emptyPlugins","error":"dshPlatform_error","expandable":"dshPlatform_expandable","expanded":"dshPlatform_expanded","experimentalVersions":"dshPlatform_experimentalVersions","heading":"dshPlatform_heading","hold":"dshPlatform_hold","holds":"dshPlatform_holds","intro":"dshPlatform_intro","listPagination":"dshPlatform_listPagination","live":"dshPlatform_live","logAutoScroll":"dshPlatform_logAutoScroll","logChevron":"dshPlatform_logChevron","logConnection":"dshPlatform_logConnection","logDebug":"dshPlatform_logDebug","logDetails":"dshPlatform_logDetails","logEntry":"dshPlatform_logEntry","logError":"dshPlatform_logError","logFilters":"dshPlatform_logFilters","logInfo":"dshPlatform_logInfo","logLevel":"dshPlatform_logLevel","logList":"dshPlatform_logList","logMessageRow":"dshPlatform_logMessageRow","logMeta":"dshPlatform_logMeta","logResizeFrame":"dshPlatform_logResizeFrame","logResizeHandle":"dshPlatform_logResizeHandle","logSection":"dshPlatform_logSection","logSelectTrigger":"dshPlatform_logSelectTrigger","logSource":"dshPlatform_logSource","logSummary":"dshPlatform_logSummary","logSummaryRow":"dshPlatform_logSummaryRow","logTools":"dshPlatform_logTools","logWarning":"dshPlatform_logWarning","maintenanceButton":"dshPlatform_maintenanceButton","maintenanceStatus":"dshPlatform_maintenanceStatus","managedBadge":"dshPlatform_managedBadge","notice":"dshPlatform_notice","offline":"dshPlatform_offline","online":"dshPlatform_online","pageArrow":"dshPlatform_pageArrow","pageJump":"dshPlatform_pageJump","pageNavigation":"dshPlatform_pageNavigation","paginationSelectTrigger":"dshPlatform_paginationSelectTrigger","pendingBadge":"dshPlatform_pendingBadge","platformHeader":"dshPlatform_platformHeader","pluginActions":"dshPlatform_pluginActions","pluginDraftActions":"dshPlatform_pluginDraftActions","pluginIdentity":"dshPlatform_pluginIdentity","pluginList":"dshPlatform_pluginList","pluginOperation":"dshPlatform_pluginOperation","pluginRestartNotice":"dshPlatform_pluginRestartNotice","pluginRow":"dshPlatform_pluginRow","pluginSection":"dshPlatform_pluginSection","primaryButton":"dshPlatform_primaryButton","progress":"dshPlatform_progress","progressAutoScroll":"dshPlatform_progressAutoScroll","progressFullLog":"dshPlatform_progressFullLog","progressHeading":"dshPlatform_progressHeading","progressHeadingActions":"dshPlatform_progressHeadingActions","progressLogActions":"dshPlatform_progressLogActions","progressLogChevron":"dshPlatform_progressLogChevron","progressLogEmpty":"dshPlatform_progressLogEmpty","progressLogEntry":"dshPlatform_progressLogEntry","progressLogGroup":"dshPlatform_progressLogGroup","progressLogGroupList":"dshPlatform_progressLogGroupList","progressLogGroupListPopulated":"dshPlatform_progressLogGroupListPopulated","progressLogLevel":"dshPlatform_progressLogLevel","progressLogList":"dshPlatform_progressLogList","progressLogMessage":"dshPlatform_progressLogMessage","progressLogSource":"dshPlatform_progressLogSource","progressStageCount":"dshPlatform_progressStageCount","progressStageDetail":"dshPlatform_progressStageDetail","progressStageError":"dshPlatform_progressStageError","progressStageHeader":"dshPlatform_progressStageHeader","progressStageItem":"dshPlatform_progressStageItem","progressStageItemActive":"dshPlatform_progressStageItemActive","progressStageItemCompleted":"dshPlatform_progressStageItemCompleted","progressStageItemFailed":"dshPlatform_progressStageItemFailed","progressStageItemMarker":"dshPlatform_progressStageItemMarker","progressStageItemPending":"dshPlatform_progressStageItemPending","progressStageItems":"dshPlatform_progressStageItems","progressStageLog":"dshPlatform_progressStageLog","progressStageMarker":"dshPlatform_progressStageMarker","progressStageMarkerActive":"dshPlatform_progressStageMarkerActive","progressStageMarkerCompleted":"dshPlatform_progressStageMarkerCompleted","progressStageMarkerFailed":"dshPlatform_progressStageMarkerFailed","progressStageMetric":"dshPlatform_progressStageMetric","progressStagePercent":"dshPlatform_progressStagePercent","progressStageSummary":"dshPlatform_progressStageSummary","progressStageToggle":"dshPlatform_progressStageToggle","proxyAdvanced":"dshPlatform_proxyAdvanced","proxyAdvancedSetting":"dshPlatform_proxyAdvancedSetting","proxyClearPassword":"dshPlatform_proxyClearPassword","proxyComponentAlert":"dshPlatform_proxyComponentAlert","proxyDirectRulesField":"dshPlatform_proxyDirectRulesField","proxyFormGrid":"dshPlatform_proxyFormGrid","proxyHint":"dshPlatform_proxyHint","proxyMasterRow":"dshPlatform_proxyMasterRow","proxyPasswordField":"dshPlatform_proxyPasswordField","proxyPasswordRow":"dshPlatform_proxyPasswordRow","proxyProvider":"dshPlatform_proxyProvider","proxyProviderControls":"dshPlatform_proxyProviderControls","proxyProviderHeading":"dshPlatform_proxyProviderHeading","proxyProviderIdentity":"dshPlatform_proxyProviderIdentity","proxyProviderInfo":"dshPlatform_proxyProviderInfo","proxyProviderInfoDialog":"dshPlatform_proxyProviderInfoDialog","proxyProviderList":"dshPlatform_proxyProviderList","proxyProviderRoute":"dshPlatform_proxyProviderRoute","proxyProviderSearch":"dshPlatform_proxyProviderSearch","proxyProviderSection":"dshPlatform_proxyProviderSection","proxyRulesGrid":"dshPlatform_proxyRulesGrid","proxySaveState":"dshPlatform_proxySaveState","proxyScopeCard":"dshPlatform_proxyScopeCard","proxyScopeCatalog":"dshPlatform_proxyScopeCatalog","proxyScopeCopy":"dshPlatform_proxyScopeCopy","proxyScopeDescription":"dshPlatform_proxyScopeDescription","proxyScopeDialog":"dshPlatform_proxyScopeDialog","proxyScopeGrid":"dshPlatform_proxyScopeGrid","proxyScopeSummaries":"dshPlatform_proxyScopeSummaries","proxySelectField":"dshPlatform_proxySelectField","proxySelectTrigger":"dshPlatform_proxySelectTrigger","proxySettingRow":"dshPlatform_proxySettingRow","proxySystemRulesBody":"dshPlatform_proxySystemRulesBody","proxyTestDialog":"dshPlatform_proxyTestDialog","proxyTestStages":"dshPlatform_proxyTestStages","proxyTitleLine":"dshPlatform_proxyTitleLine","reminderActions":"dshPlatform_reminderActions","resourceDescription":"dshPlatform_resourceDescription","resourceHeading":"dshPlatform_resourceHeading","resourceHeadingDetail":"dshPlatform_resourceHeadingDetail","resourceSearch":"dshPlatform_resourceSearch","resourceSectionHeading":"dshPlatform_resourceSectionHeading","restartConfirmation":"dshPlatform_restartConfirmation","root":"dshPlatform_root","secondaryButton":"dshPlatform_secondaryButton","section":"dshPlatform_section","sectionHeading":"dshPlatform_sectionHeading","segmented":"dshPlatform_segmented","selectMenu":"dshPlatform_selectMenu","selectTrigger":"dshPlatform_selectTrigger","settingRow":"dshPlatform_settingRow","settingRows":"dshPlatform_settingRows","settingSelectTrigger":"dshPlatform_settingSelectTrigger","smallButton":"dshPlatform_smallButton","statusActive":"dshPlatform_statusActive","statusBadge":"dshPlatform_statusBadge","statusDot":"dshPlatform_statusDot","statusEnabled":"dshPlatform_statusEnabled","statusFailed":"dshPlatform_statusFailed","statusLabel":"dshPlatform_statusLabel","statusLine":"dshPlatform_statusLine","statusPending":"dshPlatform_statusPending","statusSuccess":"dshPlatform_statusSuccess","tabPanel":"dshPlatform_tabPanel","tabs":"dshPlatform_tabs","title":"dshPlatform_title","titleRow":"dshPlatform_titleRow","toggle":"dshPlatform_toggle","updateProgress":"dshPlatform_updateProgress","updateReminder":"dshPlatform_updateReminder","updateReminderClose":"dshPlatform_updateReminderClose","updateReminderVersions":"dshPlatform_updateReminderVersions","updateState":"dshPlatform_updateState","version":"dshPlatform_version","versionCell":"dshPlatform_versionCell","versions":"dshPlatform_versions","warn":"dshPlatform_warn","warning":"dshPlatform_warning"}); +const styleId = "@dsh-docker/platform-management/style.module.css"; +if (typeof document !== 'undefined' && ![...document.querySelectorAll('style[data-plugin-css]')].some(tag => tag.dataset.pluginCss === styleId)) { + const tag = document.createElement('style'); + tag.dataset.plugin = "@dsh-docker/platform-management"; + tag.dataset.pluginCss = styleId; + tag.textContent = ".dshPlatform_root {\n display: flex;\n flex-direction: column;\n gap: 12px;\n width: 100%;\n height: min(720px, calc(100dvh - 152px));\n min-height: 0;\n max-width: 760px;\n overflow: hidden;\n color: var(--dsw-alias-label-primary);\n}\n\n.dshPlatform_platformHeader {\n z-index: 10;\n display: flex;\n width: 100%;\n min-width: 0;\n max-width: 100%;\n flex: none;\n flex-direction: column;\n gap: 12px;\n background: var(--dsw-alias-bg-layer-2);\n}\n\n.dshPlatform_heading {\n display: flex;\n flex-direction: column;\n gap: 12px;\n}\n\n.dshPlatform_sectionHeading {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n}\n\n.dshPlatform_titleRow {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.dshPlatform_title {\n margin: 0;\n font-size: 18px;\n line-height: 26px;\n font-weight: 600;\n}\n\n.dshPlatform_intro {\n margin: 0;\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.dshPlatform_sectionHeading p {\n margin: 2px 0 0;\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.dshPlatform_connection {\n flex: none;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 3px 9px;\n border-radius: 12px;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-secondary);\n background: var(--dsw-alias-bg-module-platform);\n}\n\n.dshPlatform_connection > span {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--dsw-alias-state-warn-label);\n}\n\n.dshPlatform_connection.dshPlatform_online > span { background: var(--dsw-alias-state-success-primary); }\n.dshPlatform_connection.dshPlatform_offline > span { background: var(--dsw-alias-state-error-primary); }\n\n.dshPlatform_tabs {\n display: flex;\n align-items: flex-end;\n gap: 22px;\n box-sizing: border-box;\n width: 100%;\n min-width: 0;\n max-width: 100%;\n overflow-x: auto;\n overscroll-behavior-inline: contain;\n scroll-snap-type: inline proximity;\n touch-action: pan-x;\n white-space: nowrap;\n border-bottom: 1px solid var(--dsw-alias-border-l2);\n margin-top: 2px;\n scrollbar-width: none;\n -webkit-overflow-scrolling: touch;\n}\n\n.dshPlatform_tabs::-webkit-scrollbar { display: none; }\n\n.dshPlatform_tabs button {\n position: relative;\n flex: none;\n padding: 7px 1px 9px;\n border: 0;\n color: var(--dsw-alias-label-tertiary);\n background: transparent;\n font: inherit;\n font-size: 13px;\n line-height: 20px;\n scroll-snap-align: start;\n cursor: pointer;\n}\n\n.dshPlatform_tabs button:hover,\n.dshPlatform_tabs button[aria-selected='true'] {\n color: var(--dsw-alias-label-primary);\n}\n\n.dshPlatform_tabs button[aria-selected='true']::after,\n.dshPlatform_tabs button:focus-visible::after {\n position: absolute;\n right: 0;\n bottom: -1px;\n left: 0;\n height: 2px;\n border-radius: 2px 2px 0 0;\n background: var(--dsw-alias-label-primary);\n content: '';\n}\n\n.dshPlatform_tabs button:focus-visible {\n border-radius: 2px;\n outline: 2px solid var(--dsw-alias-state-business-primary);\n outline-offset: 2px;\n color: var(--dsw-alias-label-primary);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .dshPlatform_tabs { scroll-snap-type: none; }\n}\n\n.dshPlatform_tabPanel {\n display: flex;\n flex-direction: column;\n gap: 20px;\n min-width: 0;\n min-height: 0;\n flex: 1;\n padding: 2px 8px 0 0;\n overflow-y: auto;\n overscroll-behavior: contain;\n scrollbar-gutter: stable;\n}\n\n.dshPlatform_tabPanel[hidden] { display: none; }\n.dshPlatform_tabPanel > .dshPlatform_section:last-child { padding-bottom: 0; border-bottom: 0; }\n\n.dshPlatform_section {\n display: flex;\n flex-direction: column;\n gap: 12px;\n padding-bottom: 20px;\n border-bottom: 1px solid var(--dsw-alias-border-l2);\n}\n\n.dshPlatform_sectionHeading h3 {\n margin: 0;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n}\n\n.dshPlatform_segmented {\n flex: none;\n display: inline-flex;\n padding: 2px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n}\n\n.dshPlatform_segmented button {\n box-sizing: border-box;\n height: 28px;\n padding: 0 10px;\n border: none;\n border-radius: 6px;\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n font: inherit;\n font-size: 12px;\n cursor: pointer;\n}\n\n.dshPlatform_segmented button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }\n.dshPlatform_segmented button[aria-pressed='true'] {\n background: var(--dsw-specific-sidebar-nav-item-active);\n color: var(--dsw-alias-label-primary);\n font-weight: 500;\n}\n\n.dshPlatform_versions {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n overflow: hidden;\n}\n\n.dshPlatform_experimentalVersions { grid-template-columns: repeat(3, minmax(0, 1fr)); }\n\n.dshPlatform_versionCell {\n min-width: 0;\n padding: 12px 14px;\n}\n\n.dshPlatform_versionCell + .dshPlatform_versionCell { border-left: 1px solid var(--dsw-alias-border-l2); }\n.dshPlatform_caption, .dshPlatform_version, .dshPlatform_detail { display: block; overflow-wrap: anywhere; }\n.dshPlatform_caption, .dshPlatform_detail { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }\n.dshPlatform_version { margin: 3px 0; font-size: 15px; line-height: 22px; font-weight: 600; }\n\n.dshPlatform_notice,\n.dshPlatform_error {\n margin: 0;\n padding: 8px 10px;\n border-left: 3px solid var(--dsw-alias-state-warn-label);\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-state-warn-label);\n background: var(--dsw-alias-bg-module-platform);\n}\n\n.dshPlatform_commandHint {\n display: flex;\n align-items: baseline;\n flex-wrap: wrap;\n gap: 6px;\n margin: 12px 0 0;\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n line-height: 18px;\n}\n\n.dshPlatform_commandHint code {\n padding: 2px 6px;\n border-radius: 4px;\n color: var(--dsw-alias-label-primary);\n background: var(--dsw-alias-bg-module-platform);\n font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n}\n\n.dshPlatform_error {\n border-left-color: var(--dsw-alias-state-error-primary);\n color: var(--dsw-alias-state-error-primary);\n}\n\n.dshPlatform_actions {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 8px;\n}\n\n.dshPlatform_primaryButton,\n.dshPlatform_secondaryButton,\n.dshPlatform_dangerButton,\n.dshPlatform_dangerFilledButton,\n.dshPlatform_smallButton {\n box-sizing: border-box;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n height: 36px;\n padding: 0 14px;\n border: none;\n border-radius: 18px;\n font: inherit;\n font-size: 14px;\n line-height: 22px;\n cursor: pointer;\n}\n\n.dshPlatform_listPagination {\n position: sticky;\n bottom: 0;\n z-index: 2;\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto auto auto;\n align-items: center;\n gap: 12px;\n min-height: 42px;\n padding: 6px 0;\n color: var(--dsw-alias-label-secondary);\n background: var(--dsw-alias-bg-layer-2);\n font-size: 12px;\n}\n.dshPlatform_listPagination label,\n.dshPlatform_listPagination > div { display: flex; align-items: center; gap: 7px; white-space: nowrap; }\n.dshPlatform_paginationSelectTrigger {\n min-width: 64px;\n min-height: 30px;\n padding: 3px 7px;\n}\n.dshPlatform_pageNavigation strong { min-width: 16px; color: var(--dsw-alias-label-primary); text-align: center; font-weight: 600; }\n.dshPlatform_pageArrow { width: 30px; min-width: 30px; height: 30px; padding: 0; border-radius: 6px; font-size: 0; line-height: 1; }\n.dshPlatform_pageArrow::before {\n width: 7px;\n height: 7px;\n border-top: 1.5px solid currentColor;\n border-right: 1.5px solid currentColor;\n content: '';\n}\n.dshPlatform_pageNavigation .dshPlatform_pageArrow:first-child::before { transform: translateX(1px) rotate(-135deg); }\n.dshPlatform_pageNavigation .dshPlatform_pageArrow:last-child::before { transform: translateX(-1px) rotate(45deg); }\n.dshPlatform_pageJump input {\n box-sizing: border-box;\n width: 50px;\n min-height: 30px;\n padding: 3px 6px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n color: var(--dsw-alias-label-primary);\n background: var(--dsw-alias-bg-module-platform);\n font: inherit;\n text-align: center;\n}\n\n.dshPlatform_primaryButton {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dshPlatform_primaryButton:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }\n\n.dshPlatform_secondaryButton,\n.dshPlatform_smallButton {\n border: 1px solid var(--dsw-alias-border-l2);\n background: transparent;\n color: var(--dsw-alias-label-primary);\n}\n.dshPlatform_secondaryButton:hover:not(:disabled),\n.dshPlatform_smallButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }\n\na.dshPlatform_secondaryButton { text-decoration: none; }\n\n.dshPlatform_checkSpinner {\n box-sizing: border-box;\n width: 14px;\n height: 14px;\n margin-right: 7px;\n border: 2px solid currentColor;\n border-right-color: transparent;\n border-radius: 50%;\n animation: checkSpin .75s linear infinite;\n}\n\n@keyframes checkSpin { to { transform: rotate(360deg); } }\n\n@media (prefers-reduced-motion: reduce) {\n .dshPlatform_checkSpinner { animation-duration: 1.5s; }\n}\n\n.dshPlatform_dangerButton {\n background: transparent;\n color: var(--dsw-alias-state-error-primary);\n}\n.dshPlatform_dangerButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger); }\n.dshPlatform_dangerFilledButton { background: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-label-primary-foreground); }\n\n.dshPlatform_authenticationButton {\n flex: none;\n border: 1px solid var(--dsw-alias-border-l2);\n white-space: nowrap;\n}\n\n.dshPlatform_dangerButton.dshPlatform_authenticationButton { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); }\n\n.dshPlatform_smallButton {\n height: 28px;\n padding: 0 10px;\n border-radius: 14px;\n font-size: 12px;\n line-height: 18px;\n}\n\n.dshPlatform_primaryButton:disabled,\n.dshPlatform_secondaryButton:disabled,\n.dshPlatform_dangerButton:disabled,\n.dshPlatform_dangerFilledButton:disabled,\n.dshPlatform_smallButton:disabled,\n.dshPlatform_segmented button:disabled { opacity: .4; cursor: default; }\n\n.dshPlatform_primaryButton:focus-visible,\n.dshPlatform_secondaryButton:focus-visible,\n.dshPlatform_dangerButton:focus-visible,\n.dshPlatform_dangerFilledButton:focus-visible,\n.dshPlatform_smallButton:focus-visible,\n.dshPlatform_segmented button:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 2px;\n}\n\n.dshPlatform_actionHeading { align-items: center; }\n.dshPlatform_updateState { display: flex; flex-direction: column; gap: 8px; }\n.dshPlatform_statusLine { display: flex; align-items: center; justify-content: space-between; gap: 12px; }\n.dshPlatform_statusLine output { font-size: 12px; color: var(--dsw-alias-label-secondary); }\n.dshPlatform_statusLabel { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); }\n.dshPlatform_statusDot { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--dsw-alias-border-l2); }\n.dshPlatform_statusActive .dshPlatform_statusDot { background: var(--dsw-alias-brand-primary); }\n.dshPlatform_statusSuccess .dshPlatform_statusDot { background: var(--dsw-alias-state-success-primary); }\n.dshPlatform_statusFailed { color: var(--dsw-alias-state-error-primary); }\n.dshPlatform_statusFailed .dshPlatform_statusDot { background: var(--dsw-alias-state-error-primary); }\n.dshPlatform_updateState > p { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }\n.dshPlatform_updateProgress { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-module-platform); }\n.dshPlatform_progressHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }\n.dshPlatform_progressHeading strong { font-size: 13px; line-height: 20px; font-weight: 500; }\n.dshPlatform_progressHeading output { color: var(--dsw-alias-label-primary); font: 600 12px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }\n.dshPlatform_progressHeadingActions { display: flex; align-items: center; gap: 8px; }\n.dshPlatform_progressStageLog { display: grid; gap: 8px; min-width: 0; }\n.dshPlatform_progressLogActions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin-top: 7px; }\n.dshPlatform_progressAutoScroll { display: inline-flex; align-items: center; gap: 5px; color: var(--dsw-alias-label-secondary); font-size: 10px; }\n.dshPlatform_progressLogList { display: grid; gap: 0; }\n.dshPlatform_progressLogGroup { position: relative; min-width: 0; padding: 0 0 14px 28px; }\n.dshPlatform_progressLogGroup:not(:last-child)::before { position: absolute; top: 22px; bottom: 0; left: 8px; width: 1px; background: var(--dsw-alias-border-l2); content: ''; }\n.dshPlatform_progressStageMarker { --marker-size: 10px; position: absolute; top: 5px; left: 3px; display: grid; width: var(--marker-size); height: var(--marker-size); place-items: center; }\n.dshPlatform_progressStageMarker::before { box-sizing: border-box; width: 100%; height: 100%; border: 1.5px solid var(--dsw-alias-label-tertiary); border-radius: 50%; content: ''; }\n.dshPlatform_progressStageMarkerCompleted::before { border-color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-primary); }\n.dshPlatform_progressStageMarkerFailed::before { border-color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-state-error-primary); }\n.dshPlatform_progressStageMarkerActive::before { border-color: color-mix(in srgb, var(--dsw-alias-state-business-primary) 30%, transparent); border-top-color: var(--dsw-alias-state-business-primary); animation: checkSpin .8s linear infinite; }\n.dshPlatform_progressStageHeader { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; }\n.dshPlatform_progressStageSummary { display: grid; min-width: 0; gap: 1px; }\n.dshPlatform_progressStageSummary strong { font-size: 13px; line-height: 20px; font-weight: 600; }\n.dshPlatform_progressStageDetail, .dshPlatform_progressStageCount { color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 17px; overflow-wrap: anywhere; }\n.dshPlatform_progressStageMetric, .dshPlatform_progressStagePercent { color: var(--dsw-alias-label-primary); font: 11px/17px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }\n.dshPlatform_progressStageToggle { align-self: start; padding: 0; border: 0; color: var(--dsw-alias-state-business-primary); background: transparent; font-size: 10px; line-height: 20px; white-space: nowrap; cursor: pointer; }\n.dshPlatform_progressStageItems { display: grid; gap: 4px; margin-top: 8px; padding: 8px 10px; border-left: 1px solid var(--dsw-alias-border-l2); }\n.dshPlatform_progressStageItem { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 5px; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 17px; }\n.dshPlatform_progressStageItemCompleted { color: var(--dsw-alias-state-success-primary); }\n.dshPlatform_progressStageItemActive { color: var(--dsw-alias-state-business-primary); }\n.dshPlatform_progressStageItemFailed { color: var(--dsw-alias-state-error-primary); }\n.dshPlatform_progressStageItemMarker { --marker-size: 10px; display: inline-grid; width: 14px; height: 17px; place-items: center; }\n.dshPlatform_progressStageItemMarker::before { box-sizing: border-box; width: var(--marker-size); height: var(--marker-size); border: 1.5px solid transparent; border-radius: 50%; content: ''; }\n.dshPlatform_progressStageItemCompleted .dshPlatform_progressStageItemMarker::before { border-color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-primary); }\n.dshPlatform_progressStageItemFailed .dshPlatform_progressStageItemMarker::before { border-color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-state-error-primary); }\n.dshPlatform_progressStageItemPending .dshPlatform_progressStageItemMarker::before { border: 1.5px solid var(--dsw-alias-label-secondary); }\n.dshPlatform_progressStageItemActive .dshPlatform_progressStageItemMarker::before { border: 1.5px solid color-mix(in srgb, var(--dsw-alias-state-business-primary) 30%, transparent); border-top-color: var(--dsw-alias-state-business-primary); animation: checkSpin .8s linear infinite; }\n.dshPlatform_progressStageItem .dshPlatform_progressStageMetric, .dshPlatform_progressStageItem .dshPlatform_progressStagePercent { grid-column: 2; }\n.dshPlatform_progressStageError { margin: 4px 0 0 21px !important; color: var(--dsw-alias-state-error-primary) !important; font-size: 11px !important; overflow-wrap: anywhere; }\n.dshPlatform_progressLogGroupList { display: grid; gap: 0; max-height: 250px; overflow: auto; margin-top: 9px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: var(--dsw-alias-bg-layer-2); }\n.dshPlatform_progressLogGroupListPopulated { height: clamp(160px, 24dvh, 240px); max-height: none; align-content: start; }\n.dshPlatform_progressLogEntry { border-bottom: 1px solid var(--dsw-alias-border-l2); padding: 7px 9px; background: var(--dsw-alias-bg-layer-2); }\n.dshPlatform_progressLogEntry summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: baseline; gap: 2px 8px; cursor: pointer; font-size: 11px; line-height: 18px; list-style: none; }\n.dshPlatform_progressLogEntry summary::-webkit-details-marker { display: none; }\n.dshPlatform_progressLogEntry summary span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.dshPlatform_progressLogEntry summary time, .dshPlatform_progressLogSource { min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); text-overflow: ellipsis; white-space: nowrap; }\n.dshPlatform_progressLogLevel { color: var(--dsw-alias-state-business-primary); }\n.dshPlatform_progressLogLevel.dshPlatform_error { color: var(--dsw-alias-state-error-primary); }\n.dshPlatform_progressLogLevel.dshPlatform_warning, .dshPlatform_progressLogLevel.dshPlatform_warn { color: var(--dsw-alias-state-warning-primary); }\n.dshPlatform_progressLogMessage { grid-column: 1 / 3; color: var(--dsw-alias-label-primary); font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }\n.dshPlatform_progressLogChevron { position: relative; top: 2px; grid-column: 3; width: 10px; height: 10px; justify-self: end; }\n.dshPlatform_progressLogChevron::before { position: absolute; top: 3px; left: 1px; width: 8px; height: 5px; background: var(--dsw-alias-label-tertiary); clip-path: polygon(0 0, 50% 72%, 100% 0, 100% 28%, 50% 100%, 0 28%); content: ''; transform-origin: center; }\n.dshPlatform_progressLogEntry[open] .dshPlatform_progressLogChevron::before { transform: rotate(180deg); }\n.dshPlatform_progressLogEntry pre { max-height: 160px; overflow: auto; margin: 5px 0 2px; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; font: 10px/15px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }\n.dshPlatform_progressLogEmpty { margin: 0 !important; padding: 8px; color: var(--dsw-alias-label-secondary) !important; font-size: 11px; }\n.dshPlatform_progressFullLog { justify-self: start; padding: 0; border: 0; color: var(--dsw-alias-state-business-primary); background: transparent; cursor: pointer; font-size: 11px; }\n.dshPlatform_progress { height: 8px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-border-l2); }\n.dshPlatform_progress span { display: block; height: 100%; background: var(--dsw-alias-state-success-primary); transition: width .2s ease; }\n.dshPlatform_progress[data-complete='true'] span { width: 100% !important; transition: none; }\n\n.dshPlatform_holds { display: flex; flex-direction: column; border-top: 1px solid var(--dsw-alias-border-l2); }\n.dshPlatform_hold { display: flex; align-items: center; gap: 12px; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--dsw-alias-border-l2); }\n.dshPlatform_hold div { min-width: 0; }\n.dshPlatform_hold strong, .dshPlatform_hold span { display: block; overflow-wrap: anywhere; }\n.dshPlatform_hold strong { font-size: 13px; line-height: 20px; }\n.dshPlatform_hold span { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }\n\n.dshPlatform_confirmation {\n padding: 14px;\n border: 1px solid var(--dsw-alias-state-error-primary);\n border-radius: 8px;\n background: var(--dsw-alias-bg-module-platform);\n}\n.dshPlatform_confirmation h4 { margin: 0; font-size: 14px; line-height: 22px; }\n.dshPlatform_confirmation p { margin: 4px 0 12px; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); }\n.dshPlatform_confirmation label { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; line-height: 20px; }\n.dshPlatform_confirmation input { margin: 3px 0 0; accent-color: var(--dsw-alias-brand-primary); }\n.dshPlatform_confirmActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }\n\n.dshPlatform_maintenanceButton { flex: none; }\n.dshPlatform_maintenanceStatus {\n margin: 0;\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-secondary);\n}\n.dshPlatform_restartConfirmation { border-color: var(--dsw-alias-border-l2); }\n\n.dshPlatform_toggle {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n font-size: 12px;\n cursor: pointer;\n}\n.dshPlatform_toggle input { position: absolute; opacity: 0; pointer-events: none; }\n.dshPlatform_toggle > span {\n box-sizing: border-box;\n width: 34px;\n height: 20px;\n padding: 2px;\n border-radius: 10px;\n background: var(--dsw-alias-border-l2);\n transition: background .15s ease;\n}\n.dshPlatform_toggle > span::after {\n display: block;\n width: 16px;\n height: 16px;\n border-radius: 50%;\n background: var(--dsw-alias-label-primary-foreground);\n content: '';\n transition: transform .15s ease;\n}\n.dshPlatform_toggle input:checked + span { background: var(--dsw-alias-state-success-primary); }\n.dshPlatform_toggle input:checked + span::after { transform: translateX(14px); }\n.dshPlatform_toggle input:focus-visible + span { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }\n.dshPlatform_toggle input:disabled ~ * { opacity: .5; cursor: default; }\n.dshPlatform_toggle b { font-weight: 500; }\n\n.dshPlatform_settingRows { border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; overflow: hidden; }\n.dshPlatform_proxyFormGrid, .dshPlatform_proxyRulesGrid, .dshPlatform_proxyScopeGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }\n.dshPlatform_proxyDirectRulesField { display: grid; gap: 5px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; }\n.dshPlatform_proxyDirectRulesField textarea { box-sizing: border-box; width: 100%; min-height: 88px; padding: 6px 9px; resize: vertical; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }\n.dshPlatform_proxyDirectRulesField small { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; }\n.dshPlatform_proxyFormGrid > label, .dshPlatform_proxyRulesGrid > label, .dshPlatform_proxySelectField { display: grid; gap: 5px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; }\n.dshPlatform_proxyTitleLine { display: flex; align-items: center; gap: 7px; }\n.dshPlatform_proxyTitleLine h3 { margin: 0; }\n.dshPlatform_proxySaveState { padding: 1px 6px; border-radius: 999px; font-size: 10px; line-height: 16px; }\n.dshPlatform_proxySaveState[data-state='unsaved'] { color: var(--dsw-alias-state-warn-label); background: color-mix(in srgb, var(--dsw-alias-state-warn-label) 10%, transparent); }\n.dshPlatform_proxySaveState[data-state='saved'] { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); }\n.dshPlatform_proxyComponentAlert { display: inline-grid; width: 21px; height: 21px; padding: 2px; place-items: center; border: 0; border-radius: 5px; background: transparent; }\n.dshPlatform_proxyComponentAlert:hover { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); }\n.dshPlatform_proxyComponentAlert svg { width: 16px; height: 16px; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }\n.dshPlatform_proxyComponentAlert circle { fill: var(--dsw-alias-state-error-primary); stroke: var(--dsw-alias-state-error-primary); }\n.dshPlatform_proxyComponentAlert path { stroke: var(--dsw-alias-label-primary-foreground); stroke-width: 2.8; }\n.dshPlatform_proxyPasswordRow { display: grid; grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: end; gap: 10px; }\n.dshPlatform_proxyPasswordRow > .dshPlatform_secondaryButton { justify-self: end; }\n.dshPlatform_proxyPasswordField { display: flex; min-width: 0; align-items: end; gap: 8px; }\n.dshPlatform_proxyPasswordRow > .dshPlatform_proxyPasswordField { min-width: 0; }\n.dshPlatform_proxyPasswordField > label { display: grid; min-width: 0; flex: 1; gap: 5px; color: var(--dsw-alias-label-secondary); font-size: 12px; }\n.dshPlatform_proxyPasswordField > label > span { color: var(--dsw-alias-label-primary); font-weight: 500; }\n.dshPlatform_proxyClearPassword { min-height: 34px; flex: none; padding: 5px 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; color: var(--dsw-alias-label-secondary); background: transparent; font: inherit; font-size: 12px; cursor: pointer; }\n.dshPlatform_proxyClearPassword[aria-pressed='true'] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent); }\n.dshPlatform_proxyFormGrid input, .dshPlatform_proxyRulesGrid textarea { box-sizing: border-box; width: 100%; min-height: 34px; padding: 6px 9px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); font: inherit; }\n.dshPlatform_proxySelectTrigger { width: 100%; min-height: 34px; }\n.dshPlatform_proxyRulesGrid textarea { min-height: 88px; resize: vertical; font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }\n.dshPlatform_proxyFormGrid label > span, .dshPlatform_proxyRulesGrid label > span { color: var(--dsw-alias-label-primary); font-weight: 500; }\n.dshPlatform_proxyRulesGrid small { display: block; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; }\n.dshPlatform_proxySettingRow { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 10px; color: var(--dsw-alias-label-primary); font-size: 12px; }\n.dshPlatform_proxySettingRow > span, .dshPlatform_proxySettingRow > div { min-width: 0; }\n.dshPlatform_proxySettingRow b, .dshPlatform_proxySettingRow small { display: block; }\n.dshPlatform_proxySettingRow small { margin-top: 2px; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; }\n.dshPlatform_proxySettingRow > .dshPlatform_toggle { width: auto; justify-self: end; }\n.dshPlatform_proxyMasterRow { margin-top: 0; }\n.dshPlatform_proxyMasterRow h3 { margin: 0; font-size: 16px; line-height: 24px; font-weight: 500; }\n.dshPlatform_proxyMasterRow > div > small { font-size: 13px; line-height: 20px; }\n.dshPlatform_proxyAdvanced { display: grid; gap: 11px; margin-top: 12px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-module-platform); }\n.dshPlatform_proxyAdvanced h4 { margin: 0; font-size: 13px; line-height: 20px; font-weight: 500; }\n.dshPlatform_proxyAdvancedSetting { margin-top: 0; }\n.dshPlatform_proxyAdvancedSetting > div > small { font-size: 13px; line-height: 20px; }\n.dshPlatform_proxyHint { margin: 6px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; }\n.dshPlatform_proxyScopeCard { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 11px; min-width: 0; padding: 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 7px; background: var(--dsw-alias-bg-module-platform); }\n.dshPlatform_proxyScopeCopy { min-width: 0; }\n.dshPlatform_proxyScopeCard > .dshPlatform_toggle { justify-self: end; }\n.dshPlatform_proxyScopeGrid b, .dshPlatform_proxyScopeGrid small { display: block; }\n.dshPlatform_proxyScopeGrid b { font-size: 13px; line-height: 20px; }\n.dshPlatform_proxyScopeGrid small { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; }\n.dshPlatform_proxyScopeDescription { display: -webkit-box; width: 100%; padding: 0; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 1; border: 0; color: var(--dsw-alias-label-tertiary); background: transparent; font: inherit; font-size: 11px; line-height: 17px; text-align: left; cursor: default; }\n.dshPlatform_proxyScopeDescription.dshPlatform_expandable { cursor: pointer; }\n.dshPlatform_proxyScopeDescription.dshPlatform_expanded { display: block; overflow: visible; -webkit-line-clamp: initial; }\n.dshPlatform_proxyProviderList { max-height: 320px; overflow-x: hidden; overflow-y: auto; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; scrollbar-gutter: stable; }\n.dshPlatform_proxyProviderSection { padding-bottom: 0; border-bottom: 0; }\n.dshPlatform_proxyProviderHeading > h3 { margin: 0 0 2px; font-size: 16px; line-height: 24px; font-weight: 500; }\n.dshPlatform_proxyProviderHeading > div { display: flex; align-items: center; justify-content: space-between; gap: 16px; }\n.dshPlatform_proxyProviderHeading p { min-width: 0; flex: 1; margin: 0; color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 20px; }\n.dshPlatform_proxyProvider { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 9px 11px; border-bottom: 1px solid var(--dsw-alias-border-l2); }\n.dshPlatform_proxyProviderControls { display: flex; align-items: center; justify-self: end; gap: 10px; }\n.dshPlatform_proxyProviderRoute { box-sizing: border-box; min-height: 22px; padding: 3px 9px; border-radius: 999px; color: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-bg-module-platform); font-size: 10px; line-height: 16px; white-space: nowrap; }\n.dshPlatform_proxyProvider:last-child { border-bottom: 0; }\n.dshPlatform_proxyProviderIdentity { display: flex; min-width: 0; align-items: center; gap: 7px; }\n.dshPlatform_proxyProviderIdentity b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.dshPlatform_proxyProviderInfo { box-sizing: border-box; flex: none; width: 17px; height: 17px; padding: 0; border: 1px solid var(--dsw-alias-label-tertiary); border-radius: 50%; color: var(--dsw-alias-label-tertiary); background: transparent; font: 600 11px/15px ui-sans-serif, system-ui, sans-serif; text-align: center; cursor: pointer; }\n.dshPlatform_proxyProviderInfo:hover, .dshPlatform_proxyProviderInfo:focus-visible { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }\n.dshPlatform_proxyTestStages { display: grid; gap: 0; margin: 0; padding: 0; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; list-style: none; }\n.dshPlatform_proxyTestStages > li { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; gap: 7px; align-items: start; padding: 8px 10px; border-bottom: 1px solid var(--dsw-alias-border-l2); font-size: 12px; cursor: pointer; }\n.dshPlatform_proxyTestStages > li:hover, .dshPlatform_proxyTestStages > li:focus-visible { background: var(--dsw-alias-bg-module-platform); outline: none; }\n.dshPlatform_proxyTestStages > li:last-child { border-bottom: 0; }\n.dshPlatform_proxyTestStages > li > span:first-child { width: 9px; height: 9px; margin-top: 4px; border: 1.5px solid var(--dsw-alias-label-tertiary); border-radius: 50%; }\n.dshPlatform_proxyTestStages > li > span:nth-child(2) { display: grid; min-width: 0; gap: 1px; }\n.dshPlatform_proxyTestStages > li > span:nth-child(2) small { display: block; }\n.dshPlatform_proxyTestStages > li[data-state='running'] > span:first-child { border-color: var(--dsw-alias-brand-primary); border-top-color: transparent; animation: checkSpin .7s linear infinite; }\n.dshPlatform_proxyTestStages > li[data-state='success'] > span:first-child { border-color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-primary); }\n.dshPlatform_proxyTestStages > li[data-state='failed'] > span:first-child { border-color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-state-error-primary); }\n.dshPlatform_proxyTestStages > li[data-state='cancelled'] > span:first-child { border-color: var(--dsw-alias-state-warn-label); background: var(--dsw-alias-state-warn-label); }\n.dshPlatform_proxyTestStages > li small { display: block; color: var(--dsw-alias-label-tertiary); font-size: 10px; line-height: 16px; }\n.dshPlatform_proxyTestDialog { box-sizing: border-box; width: min(620px, calc(100vw - 40px)); max-height: min(720px, calc(100dvh - 40px)); padding: 0; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }\n.dshPlatform_proxyTestDialog::backdrop { background: rgb(0 0 0 / 50%); }\n.dshPlatform_proxyTestDialog > div { display: grid; max-height: inherit; gap: 14px; padding: 16px; overflow: auto; }\n.dshPlatform_proxyTestDialog header, .dshPlatform_proxyTestDialog footer { display: flex; align-items: start; justify-content: space-between; gap: 12px; }\n.dshPlatform_proxyTestDialog header h3, .dshPlatform_proxyTestDialog header p { margin: 0; }\n.dshPlatform_proxyTestDialog header p { margin-top: 4px; color: var(--dsw-alias-label-tertiary); }\n.dshPlatform_proxyTestDialog footer { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; }\n.dshPlatform_proxyTestDialog footer > .dshPlatform_proxyHint, .dshPlatform_proxyTestDialog footer > .dshPlatform_error { min-width: 0; margin: 0; }\n.dshPlatform_proxyTestDialog footer > button { grid-column: 2; margin-left: 0; }\n.dshPlatform_proxyScopeDialog { box-sizing: border-box; width: min(760px, calc(100vw - 40px)); height: min(720px, calc(100dvh - 40px)); max-height: min(720px, calc(100dvh - 40px)); padding: 0; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }\n.dshPlatform_proxyScopeDialog::backdrop { background: rgb(0 0 0 / 50%); }\n.dshPlatform_proxyScopeDialog form { display: grid; height: 100%; min-height: 0; grid-template-rows: auto minmax(0, 1fr) auto; }\n.dshPlatform_proxyScopeDialog header { display: flex; align-items: start; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--dsw-alias-border-l2); }\n.dshPlatform_proxyScopeDialog h3, .dshPlatform_proxyScopeDialog h4 { margin: 0; }\n.dshPlatform_proxyScopeDialog header p { margin: 2px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; }\n.dshPlatform_proxyProviderInfoDialog { box-sizing: border-box; width: min(460px, calc(100vw - 40px)); padding: 0; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }\n.dshPlatform_proxyProviderInfoDialog::backdrop { background: rgb(0 0 0 / 50%); }\n.dshPlatform_proxyProviderInfoDialog form { display: grid; gap: 14px; }\n.dshPlatform_proxyProviderInfoDialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px 0; }\n.dshPlatform_proxyProviderInfoDialog h3 { margin: 0; }\n.dshPlatform_proxyProviderInfoDialog p { margin: 0; padding: 0 16px 16px; color: var(--dsw-alias-label-tertiary); line-height: 1.6; }\n.dshPlatform_proxySystemRulesBody { padding: 0 16px 16px; }\n.dshPlatform_proxySystemRulesBody p { padding: 0; }\n.dshPlatform_proxySystemRulesBody pre { margin: 12px 0 0; padding: 9px 11px; overflow: auto; border: 1px solid var(--dsw-alias-border-l2); border-radius: 7px; background: var(--dsw-alias-bg-module-platform); font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }\n.dshPlatform_proxyScopeCatalog { min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 8px 16px; }\n.dshPlatform_proxyScopeCatalog section { display: grid; grid-template-columns: minmax(130px, .8fr) minmax(0, 1.5fr) minmax(0, 1.5fr); gap: 0; column-gap: 16px; margin: 0; padding: 0; }\n.dshPlatform_proxyScopeCatalog section + section { border-top: 1px solid var(--dsw-alias-border-l2); }\n.dshPlatform_proxyScopeCatalog h4 { grid-column: 1; grid-row: 1 / -1; align-self: center; margin: 0; padding: 6px 0; font-size: 13px; font-weight: 600; }\n.dshPlatform_proxyScopeCatalog section > div { grid-column: 2 / -1; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 2px 16px; padding: 6px 0; border-top: 1px solid var(--dsw-alias-border-l2); font-size: 11px; line-height: 17px; }\n.dshPlatform_proxyScopeSummaries { padding: 9px 16px 14px; border-top: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; }\n.dshPlatform_proxyScopeSummaries p { margin: 3px 0; }\n.dshPlatform_settingRow { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 44px; padding: 8px 12px; font-size: 13px; }\n.dshPlatform_settingRow + .dshPlatform_settingRow { border-top: 1px solid var(--dsw-alias-border-l2); }\n.dshPlatform_settingRow > span { min-width: 0; }\n.dshPlatform_settingRow b, .dshPlatform_settingRow small { display: block; }\n.dshPlatform_settingRow b { font-weight: 500; }\n.dshPlatform_settingRow small { margin-top: 2px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }\n.dshPlatform_settingSelectTrigger {\n min-width: 120px;\n height: 32px;\n}\n.dshPlatform_settingRow input[type='checkbox'] { width: 16px; height: 16px; flex: none; accent-color: var(--dsw-alias-brand-primary); }\n\n.dshPlatform_pluginSection { height: 100%; min-height: 0; overflow: visible; border-bottom: 0; }\n.dshPlatform_pluginSection > .dshPlatform_resourceSectionHeading,\n.dshPlatform_pluginSection > .dshPlatform_pluginRestartNotice,\n.dshPlatform_pluginSection > .dshPlatform_listPagination,\n.dshPlatform_pluginSection > .dshPlatform_error { flex: none; }\n.dshPlatform_pluginSection .dshPlatform_listPagination { position: static; }\n.dshPlatform_resourceSectionHeading { display: block; }\n.dshPlatform_resourceHeadingDetail { display: flex; align-items: center; gap: 16px; margin-top: 2px; }\n.dshPlatform_resourceHeadingDetail p { min-width: 0; flex: 1; margin: 0; }\n.dshPlatform_resourceSearch {\n display: block;\n box-sizing: border-box;\n width: min(320px, 45%);\n margin: 0 0 0 auto;\n flex: none;\n min-width: 0;\n height: 34px;\n padding: 0 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 7px;\n color: var(--dsw-alias-label-primary);\n background: var(--dsw-alias-bg-module-platform);\n font: inherit;\n font-size: 12px;\n}\n.dshPlatform_resourceSearch:focus-visible { border-color: var(--dsw-alias-label-primary); outline: none; }\n.dshPlatform_resourceSearch::placeholder { color: var(--dsw-alias-label-tertiary); }\n.dshPlatform_proxyProviderSearch { border-radius: 7px; }\n.dshPlatform_proxyProviderSearch:focus-visible { border-color: var(--dsw-alias-label-primary); outline: none; }\n.dshPlatform_proxyProviderSearch::placeholder { color: var(--dsw-alias-label-tertiary); }\n.dshPlatform_pluginList {\n min-height: 68px;\n max-height: min(46dvh, 420px);\n flex: 0 1 auto;\n overflow-x: hidden;\n overflow-y: auto;\n overscroll-behavior: contain;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n}\n.dshPlatform_pluginRow {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n align-items: center;\n gap: 8px 16px;\n min-height: 52px;\n padding: 7px 12px;\n}\n.dshPlatform_pluginRow + .dshPlatform_pluginRow { border-top: 1px solid var(--dsw-alias-border-l2); }\n.dshPlatform_pluginIdentity { min-width: 0; }\n.dshPlatform_pluginIdentity strong,\n.dshPlatform_pluginIdentity span { overflow-wrap: anywhere; }\n.dshPlatform_pluginIdentity strong { font-size: 13px; line-height: 20px; font-weight: 500; }\n.dshPlatform_pluginIdentity span { margin-top: 2px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }\n.dshPlatform_resourceHeading { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }\n.dshPlatform_resourceHeading > strong { flex: 0 1 auto; max-width: 55%; }\n.dshPlatform_statusBadge { box-sizing: border-box; display: inline-flex; min-height: 18px; flex: 0 0 auto; align-items: center; margin-top: 1px; padding: 1px 6px; overflow: hidden; border-radius: 999px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-module-platform); font-size: 10px; line-height: 16px; white-space: nowrap; }\n.dshPlatform_statusEnabled {\n color: var(--dsw-alias-state-success-primary);\n background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent);\n}\n.dshPlatform_statusPending {\n color: var(--dsw-alias-state-warn-label);\n background: color-mix(in srgb, var(--dsw-alias-state-warn-label) 12%, transparent);\n}\n.dshPlatform_resourceDescription { position: relative; flex: 1 1 120px; min-width: 0; padding: 0 15px 0 0; overflow: hidden; border: 0; color: var(--dsw-alias-label-tertiary); background: transparent; font: inherit; font-size: 12px; line-height: 20px; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: default; }\n.dshPlatform_resourceDescription.dshPlatform_expandable { cursor: pointer; }\n.dshPlatform_resourceDescription.dshPlatform_expandable::after { position: absolute; top: 6px; right: 2px; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg); content: ''; }\n.dshPlatform_resourceDescription.dshPlatform_expanded { overflow: visible; text-overflow: clip; white-space: normal; }\n.dshPlatform_resourceDescription.dshPlatform_expanded::after { top: 9px; transform: rotate(225deg); }\n.dshPlatform_pluginIdentity > .dshPlatform_resourceDescription { display: block; width: 100%; margin-top: 2px; }\n.dshPlatform_pluginIdentity .dshPlatform_pendingBadge {\n display: inline-block;\n width: fit-content;\n margin-top: 5px;\n color: var(--dsw-alias-state-warn-label);\n font-weight: 500;\n}\n.dshPlatform_managedBadge {\n padding: 3px 9px;\n border-radius: 12px;\n color: var(--dsw-alias-label-secondary);\n background: var(--dsw-alias-bg-module-platform);\n font-size: 12px;\n line-height: 18px;\n white-space: nowrap;\n}\n.dshPlatform_pluginActions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }\n.dshPlatform_pluginActions .dshPlatform_toggle { width: auto; }\n.dshPlatform_pluginOperation {\n grid-column: 1 / -1;\n margin: 0;\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n line-height: 18px;\n}\n\n.dshPlatform_pluginRestartNotice {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 12px 14px;\n border: 1px solid var(--dsw-alias-state-warn-border);\n border-radius: 8px;\n background: var(--dsw-alias-state-warn-bg);\n}\n\n.dshPlatform_pluginRestartNotice div { min-width: 0; }\n.dshPlatform_pluginRestartNotice strong { font-size: 13px; line-height: 20px; }\n.dshPlatform_pluginRestartNotice p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }\n.dshPlatform_pluginDraftActions { display: flex; flex: none; align-items: center; gap: 8px; }\n.dshPlatform_pluginDraftActions button { flex: none; }\n.dshPlatform_emptyPlugins { margin: 0; padding: 24px 12px; color: var(--dsw-alias-label-tertiary); text-align: center; font-size: 13px; }\n\n.dshPlatform_logSection { gap: 10px; }\n\n.dshPlatform_logTools {\n flex: none;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.dshPlatform_logConnection {\n flex: none;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-height: 24px;\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n}\n\n.dshPlatform_logConnection > span {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--dsw-alias-state-warn-label);\n}\n\n.dshPlatform_logConnection.dshPlatform_live > span { background: var(--dsw-alias-state-success-primary); }\n.dshPlatform_logConnection.dshPlatform_disconnected > span { background: var(--dsw-alias-state-error-primary); }\n\n.dshPlatform_autoScrollButton,\n.dshPlatform_clearLogsButton {\n min-height: 26px;\n padding: 2px 8px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 999px;\n color: var(--dsw-alias-label-secondary);\n background: transparent;\n font: inherit;\n font-size: 11px;\n cursor: pointer;\n}\n\n.dshPlatform_autoScrollButton:hover,\n.dshPlatform_clearLogsButton:hover:not(:disabled) { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }\n.dshPlatform_autoScrollButton[aria-pressed='true'] { color: var(--dsw-alias-label-primary); }\n.dshPlatform_clearLogsButton:disabled { opacity: 0.45; cursor: default; }\n\n.dshPlatform_logSummaryRow { display: flex; align-items: center; justify-content: space-between; gap: 12px; }\n.dshPlatform_logAutoScroll { display: inline-flex; flex: none; align-items: center; gap: 6px; min-height: 26px; margin-left: auto; color: var(--dsw-alias-label-secondary); font-size: 11px; cursor: pointer; }\n.dshPlatform_logAutoScroll input { position: absolute; opacity: 0; pointer-events: none; }\n.dshPlatform_logAutoScroll > span { box-sizing: border-box; width: 28px; height: 16px; padding: 2px; border-radius: 8px; background: var(--dsw-alias-border-l2); }\n.dshPlatform_logAutoScroll > span::after { display: block; width: 12px; height: 12px; border-radius: 50%; background: var(--dsw-alias-label-primary-foreground); content: ''; transition: transform .15s ease; }\n.dshPlatform_logAutoScroll input:checked + span { background: var(--dsw-alias-state-success-primary); }\n.dshPlatform_logAutoScroll input:checked + span::after { transform: translateX(12px); }\n.dshPlatform_logAutoScroll input:focus-visible + span { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }\n.dshPlatform_logAutoScroll b { font-weight: 400; }\n\n.dshPlatform_logFilters {\n display: grid;\n grid-template-columns: minmax(180px, 1fr) repeat(3, minmax(110px, auto));\n gap: 8px;\n}\n\n.dshPlatform_selectMenu {\n min-width: 0;\n}\nbody[data-dsh-platform-select-menu='open'] > [role='menu'] {\n max-height: min(320px, 60dvh);\n overflow: hidden;\n overscroll-behavior: contain;\n}\nbody[data-dsh-platform-select-menu='open'] > [role='menu'] > [role='presentation'] {\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;\n}\n.dshPlatform_selectTrigger {\n box-sizing: border-box;\n display: inline-flex;\n min-width: 0;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 0 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 7px;\n color: var(--dsw-alias-label-primary);\n background: var(--dsw-alias-bg-module-platform);\n font: inherit;\n cursor: pointer;\n}\n.dshPlatform_selectTrigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }\n.dshPlatform_selectTrigger:disabled { cursor: not-allowed; opacity: .45; }\n.dshPlatform_selectTrigger svg { flex: none; }\n.dshPlatform_logFilters > .dshPlatform_selectMenu { width: 100%; }\n.dshPlatform_logSelectTrigger { width: 100%; height: 34px; font-size: 12px; }\n.dshPlatform_proxySelectField > .dshPlatform_selectMenu { width: 100%; }\n.dshPlatform_proxySelectTrigger, .dshPlatform_settingSelectTrigger, .dshPlatform_paginationSelectTrigger { border-radius: 6px; }\n\n.dshPlatform_logFilters input {\n box-sizing: border-box;\n min-width: 0;\n height: 34px;\n padding: 0 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 7px;\n color: var(--dsw-alias-label-primary);\n background: var(--dsw-alias-bg-module-platform);\n font: inherit;\n font-size: 12px;\n}\n\n.dshPlatform_logFilters input::placeholder { color: var(--dsw-alias-label-tertiary); }\n.dshPlatform_proxyFormGrid input:focus-visible,\n.dshPlatform_proxyRulesGrid textarea:focus-visible,\n.dshPlatform_pageJump input:focus-visible,\n.dshPlatform_logFilters input:focus-visible {\n border-color: var(--dsw-alias-label-primary);\n outline: none;\n}\n.dshPlatform_selectTrigger:focus-visible { outline: none; }\n\n.dshPlatform_logSummary {\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n}\n\n.dshPlatform_logResizeFrame {\n position: relative;\n box-sizing: border-box;\n height: 280px;\n min-height: 140px;\n max-height: min(560px, 70dvh);\n}\n\n.dshPlatform_logList {\n display: flex;\n flex-direction: column;\n box-sizing: border-box;\n height: 100%;\n overflow: auto;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n}\n\n.dshPlatform_logResizeHandle {\n position: absolute;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 2;\n height: 16px;\n cursor: ns-resize;\n touch-action: none;\n}\n\n.dshPlatform_logEntry {\n min-width: 0;\n padding: 10px 12px;\n background: var(--dsw-alias-bg-module-platform);\n cursor: pointer;\n}\n\n.dshPlatform_logEntry + .dshPlatform_logEntry { border-top: 1px solid var(--dsw-alias-border-l2); }\n.dshPlatform_logEntry:hover { background: var(--dsw-alias-interactive-bg-hover); }\n.dshPlatform_logEntry:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }\n.dshPlatform_logMeta { display: flex; align-items: center; gap: 8px; min-width: 0; }\n.dshPlatform_logMessageRow { display: flex; align-items: center; gap: 10px; min-width: 0; }\n.dshPlatform_logMessageRow > pre { flex: 1; min-width: 0; }\n.dshPlatform_logEntry[aria-expanded='false'] .dshPlatform_logMessageRow > pre { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.dshPlatform_logChevron {\n position: relative;\n top: 2px;\n flex: none;\n width: 10px;\n height: 10px;\n margin-right: 1px;\n}\n.dshPlatform_logChevron::before {\n position: absolute;\n top: 3px;\n left: 1px;\n width: 8px;\n height: 5px;\n background: var(--dsw-alias-label-tertiary);\n clip-path: polygon(0 0, 50% 72%, 100% 0, 100% 28%, 50% 100%, 0 28%);\n content: \"\";\n transform-origin: center;\n}\n.dshPlatform_logEntry[aria-expanded='true'] .dshPlatform_logChevron::before { transform: rotate(180deg); }\n.dshPlatform_logLevel { flex: none; font-size: 11px; line-height: 18px; font-weight: 600; }\n.dshPlatform_logDebug { color: var(--dsw-alias-label-tertiary); }\n.dshPlatform_logInfo { color: var(--dsw-alias-state-business-primary); }\n.dshPlatform_logWarning { color: var(--dsw-alias-state-warn-label); }\n.dshPlatform_logError { color: var(--dsw-alias-state-error-primary); }\n.dshPlatform_logSource {\n min-width: 0;\n overflow: hidden;\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n line-height: 18px;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.dshPlatform_logMeta time {\n flex: none;\n margin-left: auto;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11px;\n line-height: 18px;\n}\n\n.dshPlatform_logEntry pre {\n margin: 5px 0 0;\n overflow-wrap: anywhere;\n color: var(--dsw-alias-label-primary);\n font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n white-space: pre-wrap;\n}\n\n.dshPlatform_logDetails {\n margin-top: 9px !important;\n padding-top: 9px;\n border-top: 1px solid var(--dsw-alias-border-l2);\n color: var(--dsw-alias-label-secondary) !important;\n}\n\n.dshPlatform_emptyLogs {\n margin: 0;\n padding: 28px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n color: var(--dsw-alias-label-tertiary);\n text-align: center;\n font-size: 13px;\n}\n\n.dshPlatform_updateReminder {\n position: fixed;\n right: 20px;\n bottom: 20px;\n z-index: 1000;\n box-sizing: border-box;\n width: min(360px, calc(100vw - 32px));\n padding: 14px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n color: var(--dsw-alias-label-primary);\n background: var(--dsw-alias-bg-module-platform);\n box-shadow: 0 8px 24px rgb(0 0 0 / 24%);\n pointer-events: auto;\n}\n.dshPlatform_updateReminderClose {\n position: absolute;\n top: 8px;\n right: 8px;\n width: 28px;\n height: 28px;\n padding: 0;\n border: 0;\n border-radius: 50%;\n color: var(--dsw-alias-label-secondary);\n background: transparent;\n font: 20px/28px sans-serif;\n cursor: pointer;\n}\n.dshPlatform_updateReminderClose:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }\n.dshPlatform_updateReminderClose:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }\n.dshPlatform_updateReminder strong { display: block; font-size: 14px; line-height: 22px; }\n.dshPlatform_updateReminder p { margin: 4px 0 12px; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; }\n.dshPlatform_updateReminderVersions span { display: block; }\n.dshPlatform_reminderActions { display: flex; justify-content: flex-end; gap: 8px; }\n.dshPlatform_reminderActions button {\n min-height: 30px;\n padding: 4px 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 15px;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n font: inherit;\n font-size: 12px;\n cursor: pointer;\n}\n.dshPlatform_reminderActions button:hover { background: var(--dsw-alias-interactive-bg-hover); }\n\n@media (max-width: 640px) {\n .dshPlatform_pluginList { min-height: 122px; }\n .dshPlatform_progressLogGroupListPopulated { height: 180px; }\n .dshPlatform_sectionHeading { flex-direction: column; gap: 10px; }\n .dshPlatform_resourceHeadingDetail { align-items: stretch; flex-direction: column; gap: 8px; }\n .dshPlatform_resourceSearch { width: 100%; margin-left: 0; }\n .dshPlatform_proxyFormGrid, .dshPlatform_proxyRulesGrid, .dshPlatform_proxyScopeGrid { grid-template-columns: 1fr; }\n .dshPlatform_proxyScopeCatalog section { display: block; }\n .dshPlatform_proxyScopeCatalog h4 { padding: 6px 0 0; }\n .dshPlatform_proxyScopeCatalog section > div { grid-column: auto; grid-template-columns: minmax(0, 1fr); }\n .dshPlatform_proxyProvider { align-items: center; }\n .dshPlatform_proxyProviderList { max-height: 280px; }\n .dshPlatform_proxyScopeDialog { width: 100%; max-width: none; height: 100dvh; max-height: none; border: 0; border-radius: 0; }\n .dshPlatform_actionHeading { align-items: flex-start; }\n .dshPlatform_versions { grid-template-columns: 1fr; }\n .dshPlatform_versionCell + .dshPlatform_versionCell { border-left: 0; border-top: 1px solid var(--dsw-alias-border-l2); }\n .dshPlatform_segmented { width: 100%; }\n .dshPlatform_segmented button { flex: 1; }\n .dshPlatform_actions > button { flex: 1 1 calc(50% - 8px); }\n .dshPlatform_actions { width: 100%; justify-content: flex-start; }\n .dshPlatform_progressHeading { align-items: flex-start; }\n .dshPlatform_progressHeading output { text-align: right; }\n .dshPlatform_progressLogGroup { padding-left: 24px; }\n .dshPlatform_progressLogGroup:not(:last-child)::before { left: 7px; }\n .dshPlatform_progressStageMarker { left: 2px; }\n .dshPlatform_progressLogEntry summary { grid-template-columns: auto minmax(0, 1fr) auto; gap: 1px 6px; }\n .dshPlatform_progressLogActions { align-items: stretch; flex-direction: column; }\n .dshPlatform_progressLogActions button { width: 100%; }\n .dshPlatform_maintenanceButton { width: 100%; }\n .dshPlatform_toggle { width: 100%; justify-content: space-between; }\n .dshPlatform_settingRow { align-items: flex-start; }\n .dshPlatform_pluginRow { grid-template-columns: 1fr; align-items: stretch; }\n .dshPlatform_pluginActions { justify-content: space-between; }\n .dshPlatform_pluginActions .dshPlatform_toggle { width: auto; }\n .dshPlatform_pluginRestartNotice { align-items: stretch; flex-direction: column; }\n .dshPlatform_pluginDraftActions { display: grid; width: 100%; grid-template-columns: 1fr; }\n .dshPlatform_pluginDraftActions button { width: 100%; }\n .dshPlatform_proxyPasswordField { align-items: stretch; flex-direction: column; }\n .dshPlatform_proxyPasswordRow { align-items: end; grid-template-columns: minmax(0, 1fr) auto; }\n .dshPlatform_proxyClearPassword { width: 100%; }\n .dshPlatform_proxyProviderHeading > div { align-items: stretch; flex-direction: column; gap: 8px; }\n .dshPlatform_proxyProviderHeading .dshPlatform_resourceSearch { width: 100%; margin-left: 0; }\n .dshPlatform_managedBadge { justify-self: start; }\n .dshPlatform_listPagination { grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; }\n .dshPlatform_listPagination > span { grid-column: 1; grid-row: 1; }\n .dshPlatform_listPagination > label:not(.dshPlatform_pageJump) { grid-column: 2; grid-row: 1; justify-self: end; }\n .dshPlatform_pageNavigation { grid-column: 1; grid-row: 2; justify-self: start; }\n .dshPlatform_pageJump { grid-column: 2; grid-row: 2; justify-self: end; }\n .dshPlatform_logFilters { grid-template-columns: 1fr 1fr; }\n .dshPlatform_logTools { width: 100%; flex-wrap: wrap; }\n .dshPlatform_logConnection { margin-right: auto; }\n .dshPlatform_logFilters input { grid-column: 1 / -1; }\n .dshPlatform_logMeta { flex-wrap: wrap; }\n .dshPlatform_logMeta time { width: auto; margin-left: auto; }\n .dshPlatform_logResizeFrame {\n height: min(260px, 36dvh);\n max-height: min(520px, 72dvh);\n }\n .dshPlatform_updateReminder { right: 16px; bottom: 16px; }\n}\n\n@media (max-width: 480px) {\n .dshPlatform_actions > button { flex-basis: 100%; }\n}\n"; + document.head.appendChild(tag); +} +const React = require('react') +const { useCallback, useEffect, useRef, useState } = React +const { IconChevronDownOutline14, Menu } = require('@deepseek-ai/dsh-client-ui-primitives') + +const API = '/_dsh_platform/plugin-api/v1' +const AUTH_API = '/_dsh_platform/auth' +const TERMINAL = new Set(['idle', 'success', 'failed']) +const UPDATE_PROGRESS_STEPS = Object.freeze(['progressPrepare', 'progressAcquire', 'progressBuild', 'progressActivate']) +const UPDATE_PROGRESS_STAGE = Object.freeze({ + checking: 0, + planning: 0, + 'checking-upstream': 0, + downloading: 1, + validating: 1, + 'building-candidate': 2, + 'snapshotting-data': 3, + switching: 3, + probation: 3, + 'restoring-data': 3, +}) +const ROLLBACK_PROGRESS_STEPS = Object.freeze(['rollbackPrepare', 'rollbackSwitch', 'rollbackData', 'rollbackVerify']) +const ROLLBACK_PROGRESS_STAGE = Object.freeze({ preparing: 0, stopping: 0, switching: 1, 'restoring-data': 2, verifying: 3 }) + +function isRecoveryOperation(operation) { + return operation === 'rollback' || operation === 'return-stable' +} +const h = React.createElement +const LOCALE_COOKIE = 'dsh_locale' +const NOTICE_OWNER_KEY = 'dsh-platform:update-notice-owner' +const NOTICE_SNOOZE_KEY = 'dsh-platform:update-notice-snooze' +const NOTICE_OWNER_TTL = 20_000 +const PLUGIN_DRAFT_KEY = 'dsh-platform:system-plugin-draft' +const LOG_CLEAR_CUTOFF_KEY = 'dsh-platform:log-clear-cutoff' +const LOG_DISPLAY_LIMIT_KEY = 'dsh-platform:log-display-limit' +const LOG_DISPLAY_LIMITS = Object.freeze([100, 250, 500, 1_000]) +const DEFAULT_LOG_DISPLAY_LIMIT = 500 +const LOG_STREAM_LIMIT = 5_000 +const LIST_PAGE_SIZES = Object.freeze([5, 10, 20, 50]) +const LIST_PAGE_SIZE_KEY_PREFIX = 'dsh-platform:plugin-page-size:' + +const inject = ['slots', 'locale', 'connection'] + +function SelectMenu({ value, items, onSelect, ariaLabel, disabled = false, className = '' }) { + const [open, setOpen] = useState(false) + const selectedId = String(value) + const selected = items.find(item => item.id === selectedId) ?? items[0] + useEffect(() => { + if (!open || disabled) return undefined + document.body.dataset.dshPlatformSelectMenu = 'open' + return () => { + delete document.body.dataset.dshPlatformSelectMenu + } + }, [open, disabled]) + return h(Menu, { + open: open && !disabled, + portal: true, + align: 'end', + items, + selectedId, + onClose: () => setOpen(false), + onSelect: id => { + setOpen(false) + onSelect(id) + }, + className: css.selectMenu, + anchor: h('button', { + type: 'button', + className: `${css.selectTrigger}${className ? ` ${className}` : ''}`, + disabled, + 'aria-label': ariaLabel, + 'aria-haspopup': 'menu', + 'aria-expanded': open && !disabled, + onClick: () => setOpen(current => !current), + }, h('span', null, selected?.label ?? selectedId), h(IconChevronDownOutline14, { 'aria-hidden': 'true' })), + }) +} + +const LIFECYCLE_WAIT_PATH = '/_dsh_gateway/wait' +const LIFECYCLE_READINESS_PATH = '/_dsh_gateway/readiness' +const CONNECTION_LOSS_GRACE_MS = 1_000 +const READINESS_RETRY_MS = 500 + +let platformStateEventSource +const platformStateEventSubscribers = new Set() + +function openPlatformStateEvents() { + if (platformStateEventSource !== undefined || platformStateEventSubscribers.size === 0) return + const stream = new EventSource(`${API}/events`) + platformStateEventSource = stream + stream.addEventListener('state', event => { + for (const current of platformStateEventSubscribers) current.state?.(event) + }) + stream.onopen = event => { + for (const current of platformStateEventSubscribers) current.open?.(event) + } + stream.onerror = event => { + for (const current of platformStateEventSubscribers) current.error?.(event) + } +} + +function closePlatformStateEvents() { + platformStateEventSource?.close() + platformStateEventSource = undefined +} + +function subscribePlatformStateEvents({ state, open, error }) { + const subscriber = { state, open, error } + platformStateEventSubscribers.add(subscriber) + openPlatformStateEvents() + return () => { + platformStateEventSubscribers.delete(subscriber) + if (platformStateEventSubscribers.size === 0) closePlatformStateEvents() + } +} + +function requiresLifecycleHoldingPage(status) { + if (['restarting', 'switching', 'recovering', 'restart-failed'].includes(status?.operation)) return true + if (['snapshotting-data', 'switching', 'probation', 'restoring-data'].includes(status?.update?.status)) return true + return ['starting', 'stopping', 'stopped', 'restarting', 'recovering', 'failed'] + .includes(status?.dshLifecycle?.state) +} + +function lifecycleReturnPath(locationValue = window.location) { + return `${locationValue.pathname}${locationValue.search}${locationValue.hash}` +} + +function lifecycleWaitUrl(locationValue = window.location) { + return `${LIFECYCLE_WAIT_PATH}?return=${encodeURIComponent(lifecycleReturnPath(locationValue))}` +} + +function display(value) { + return value === undefined || value === null || value === '' ? '-' : String(value) +} + +function displayEnvironment(value) { + return value === undefined || value === null || value === '' ? '-' : `env-${String(value)}` +} + +function formatBytes(value) { + const size = Number(value) + if (!Number.isFinite(size)) return '-' + if (size < 1024) return `${size} B` + if (size < 1024 ** 2) return `${(size / 1024).toFixed(1)} KiB` + if (size < 1024 ** 3) return `${(size / 1024 ** 2).toFixed(1)} MiB` + return `${(size / 1024 ** 3).toFixed(1)} GiB` +} + +function stageMetricLines(update, stage, state, t) { + const values = [] + const bytes = Number(update?.processedBytes) + const totalBytes = Number(update?.totalBytes) + const items = Number(update?.processedItems) + const totalItems = Number(update?.totalItems) + const ready = Number(update?.readyServices) + const totalServices = Number(update?.totalServices) + const completeEnough = state !== 'completed' || stageMetricProgress(update) === 100 + if (stage.index > 0 && completeEnough && Number.isFinite(bytes) && Number.isFinite(totalBytes) && totalBytes > 0) { + const key = stage.key.startsWith('rollback') && stage.labelKey === 'rollbackData' + ? 'metricBytesRestored' + : stage.index === 1 ? 'metricBytesRead' : stage.index === 2 ? 'metricBytesCopied' : 'metricBytesProcessed' + values.push(t(key).replace('{processed}', formatBytes(bytes)).replace('{total}', formatBytes(totalBytes))) + } + if (stage.index > 0 && completeEnough && Number.isFinite(items) && Number.isFinite(totalItems) && totalItems > 0) { + const key = stage.index === 1 ? 'metricArtifacts' : stage.index >= 2 ? 'metricFiles' : 'metricItems' + values.push(t(key).replace('{processed}', String(items)).replace('{total}', String(totalItems))) + } + if (Number.isFinite(ready) && Number.isFinite(totalServices) && totalServices > 0) values.push(t('metricServices').replace('{ready}', String(ready)).replace('{total}', String(totalServices))) + if (stage.labelKey === 'progressActivate' && (update?.phase ?? update?.status) === 'probation') { + const seconds = probationRemainingSeconds(update) + if (seconds !== undefined) values.push(t('metricProbationRemaining').replace('{seconds}', String(seconds))) + } + return values +} + +function probationRemainingSeconds(update) { + const detail = String(update?.detail ?? '').match(/^probation:(\d+)$/u) + if (detail !== null) return Number(detail[1]) + const deadline = Date.parse(update?.probationUntil ?? '') + return Number.isFinite(deadline) ? Math.max(0, Math.ceil((deadline - Date.now()) / 1_000)) : undefined +} + +function stageMetricProgress(update) { + for (const [processedKey, totalKey] of [['processedBytes', 'totalBytes'], ['processedItems', 'totalItems'], ['readyServices', 'totalServices']]) { + const processed = Number(update?.[processedKey]) + const total = Number(update?.[totalKey]) + if (Number.isFinite(processed) && Number.isFinite(total) && total > 0) { + return Math.max(0, Math.min(100, Math.round(processed / total * 100))) + } + } + return undefined +} + +const UPDATE_STAGE_ITEMS = Object.freeze({ + progressPrepare: ['itemVerifyMetadata', 'itemVerifyKeyring', 'itemVerifyTarget'], + progressAcquire: ['itemDownloadArtifacts', 'itemVerifyArtifacts', 'itemImportObjects'], + progressBuild: ['itemMaterializePristine', 'itemPrepareEnvironment', 'itemBuildRuntime', 'itemPreparePlugins'], + progressActivate: ['itemSwitchDeployment', 'itemCheckHealth', 'itemObserveProbation'], +}) + +const UPDATE_RECOVERY_STAGE_ITEMS = Object.freeze([ + 'itemSwitchPrevious', 'itemRestoreSnapshot', 'itemStartRuntime', 'itemCheckHealth', +]) + +const ROLLBACK_STAGE_ITEMS = Object.freeze({ + rollbackPrepare: ['itemValidateRollback', 'itemPauseRuntime'], + rollbackSwitch: ['itemSwitchPrevious'], + rollbackData: ['itemVerifySnapshot', 'itemRestoreSnapshot'], + rollbackVerify: ['itemStartRuntime', 'itemCheckHealth'], +}) + +function activeStageItemIndex(stage, update) { + const phase = update?.phase ?? update?.status + if (stage.labelKey === 'progressPrepare') return phase === 'planning' ? 2 : 0 + if (stage.labelKey === 'progressAcquire') return phase === 'validating' ? 2 : 0 + if (stage.labelKey === 'progressBuild') { + const progress = Number(update?.progress) || 0 + if (Number(update?.totalBytes) > 0 || Number(update?.totalItems) > 0) return progress >= 87 ? 3 : 2 + if (progress < 80) return 0 + if (progress < 82) return 1 + if (progress < 87) return 2 + return 3 + } + if (stage.labelKey === 'progressActivate') { + if (phase === 'restoring-data') { + return { + 'recovery:suspend': 0, + 'recovery:deployment': 0, + 'recovery:snapshot': 1, + 'recovery:runtime': 2, + 'recovery:health': 3, + }[update?.detail] ?? 0 + } + if (phase === 'probation') return 2 + if (Number(update?.totalServices) > 0) return 1 + } + if (stage.labelKey === 'rollbackPrepare') return phase === 'stopping' ? 1 : 0 + if (stage.labelKey === 'rollbackSwitch') return 0 + if (stage.labelKey === 'rollbackData') return Number(update?.totalItems) > 0 || Number(update?.totalBytes) > 0 ? 1 : 0 + if (stage.labelKey === 'rollbackVerify') return Number(update?.totalServices) > 0 ? 1 : 0 + return 0 +} + +function stageItems(stage, update, state, t) { + const keys = stage.labelKey === 'progressActivate' && (update?.phase ?? update?.status) === 'restoring-data' + ? UPDATE_RECOVERY_STAGE_ITEMS + : (stage.key.startsWith('rollback') ? ROLLBACK_STAGE_ITEMS : UPDATE_STAGE_ITEMS)[stage.labelKey] ?? ['stageCompleted'] + const active = activeStageItemIndex(stage, update) + return keys.map((key, index) => ({ + key, + label: t(key), + state: state === 'completed' ? 'completed' + : state === 'pending' ? 'pending' + : index < active ? 'completed' + : index === active ? (state === 'failed' ? 'failed' : 'active') : 'pending', + })) +} + +function makeHorizontalTabStripScrollable(tablist) { + let pointerId + let pointerStartX = 0 + let scrollStart = 0 + let wheelTarget = tablist.scrollLeft + let wheelFrame + let wheelFrameTime + let dragged = false + let dragTarget + let suppressClickTarget + + const stopWheelAnimation = () => { + if (wheelFrame !== undefined) window.cancelAnimationFrame(wheelFrame) + wheelFrame = undefined + wheelFrameTime = undefined + wheelTarget = tablist.scrollLeft + } + const animateWheel = timestamp => { + const elapsed = wheelFrameTime === undefined ? 16 : Math.min(timestamp - wheelFrameTime, 32) + const remaining = wheelTarget - tablist.scrollLeft + if (Math.abs(remaining) < 0.5) { + tablist.scrollLeft = wheelTarget + wheelFrame = undefined + wheelFrameTime = undefined + return + } + tablist.scrollLeft += remaining * (1 - Math.exp(-elapsed / 45)) + wheelFrameTime = timestamp + wheelFrame = window.requestAnimationFrame(animateWheel) + } + + const finishDrag = event => { + if (pointerId === undefined || (event.pointerId !== undefined && event.pointerId !== pointerId)) return + if (tablist.hasPointerCapture?.(pointerId)) tablist.releasePointerCapture(pointerId) + const displacedClickTarget = !dragged && event.target !== dragTarget ? dragTarget : undefined + suppressClickTarget = dragged ? dragTarget : undefined + if (suppressClickTarget !== undefined) window.setTimeout(() => { suppressClickTarget = undefined }, 0) + pointerId = undefined + dragged = false + dragTarget = undefined + displacedClickTarget?.click() + } + const wheel = event => { + if (tablist.scrollWidth <= tablist.clientWidth) return + if (event.deltaX !== 0) return + let delta = event.deltaY + if (delta === 0) return + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16 + else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= tablist.clientWidth + if (wheelFrame === undefined) wheelTarget = tablist.scrollLeft + const nextTarget = Math.max(0, Math.min(tablist.scrollWidth - tablist.clientWidth, wheelTarget + delta)) + if (nextTarget === wheelTarget) return + wheelTarget = nextTarget + if (wheelFrame === undefined) wheelFrame = window.requestAnimationFrame(animateWheel) + event.preventDefault() + } + const pointerDown = event => { + if (event.button !== 0 || event.pointerType === 'touch' || tablist.scrollWidth <= tablist.clientWidth) return + stopWheelAnimation() + pointerId = event.pointerId + pointerStartX = event.clientX + scrollStart = tablist.scrollLeft + dragged = false + dragTarget = event.target + } + const pointerMove = event => { + if (event.pointerId !== pointerId) return + const distance = event.clientX - pointerStartX + if (Math.abs(distance) >= 4 && !dragged) { + dragged = true + tablist.setPointerCapture?.(pointerId) + } + if (!dragged) return + tablist.scrollLeft = scrollStart - distance + event.preventDefault() + } + const click = event => { + if (event.target !== suppressClickTarget) return + suppressClickTarget = undefined + event.preventDefault() + event.stopPropagation() + } + + tablist.addEventListener('wheel', wheel, { passive: false }) + tablist.addEventListener('pointerdown', pointerDown) + tablist.addEventListener('pointermove', pointerMove) + tablist.addEventListener('pointerup', finishDrag) + tablist.addEventListener('pointercancel', finishDrag) + tablist.addEventListener('click', click, true) + return () => { + stopWheelAnimation() + tablist.removeEventListener('wheel', wheel) + tablist.removeEventListener('pointerdown', pointerDown) + tablist.removeEventListener('pointermove', pointerMove) + tablist.removeEventListener('pointerup', finishDrag) + tablist.removeEventListener('pointercancel', finishDrag) + tablist.removeEventListener('click', click, true) + } +} + +function localTime(value, locale) { + if (!value) return '-' + const date = new Date(value) + return Number.isNaN(date.valueOf()) + ? String(value) + : date.toLocaleString(locale === 'en' ? 'en-US' : 'zh-CN') +} + +function updateProgressModel(update, t) { + const rollback = isRecoveryOperation(update.operation) + const phase = update.phase ?? (rollback ? update.rollbackPhase : update.status) + const labels = rollback + ? ROLLBACK_PROGRESS_STEPS.filter(key => update.rollbackIncludesSnapshot !== false || key !== 'rollbackData') + : UPDATE_PROGRESS_STEPS + const rawStage = rollback + ? ROLLBACK_PROGRESS_STAGE[phase] ?? 0 + : UPDATE_PROGRESS_STAGE[phase] ?? 0 + const stage = rollback && update.rollbackIncludesSnapshot === false && rawStage > 2 ? rawStage - 1 : rawStage + return { + title: t(update.operation === 'return-stable' ? 'returnStableProgress' : rollback ? 'rollbackProgress' : 'updateProgress'), + labels, + stage, + } +} + +function transactionLogStage(phase, update, t) { + const rollback = isRecoveryOperation(update?.operation) + const stageMap = rollback ? ROLLBACK_PROGRESS_STAGE : UPDATE_PROGRESS_STAGE + const labels = rollback ? ROLLBACK_PROGRESS_STEPS : UPDATE_PROGRESS_STEPS + let index = stageMap[phase] + if (index === undefined) return { key: `phase:${String(phase ?? 'unknown')}`, label: String(phase ?? t('stageLogs')), index: labels.length } + if (rollback && update?.rollbackIncludesSnapshot === false && index > 2) index -= 1 + const visibleLabels = rollback && update?.rollbackIncludesSnapshot === false + ? labels.filter(key => key !== 'rollbackData') + : labels + return { key: `${rollback ? 'rollback' : 'update'}:${String(index)}`, label: t(visibleLabels[index] ?? 'stageLogs'), index } +} + +function updateOutcome(value, t) { + const key = { + none: 'outcomeNone', frozen: 'outcomeFrozen', held: 'outcomeHeld', blocked: 'outcomeBlocked', + stable: 'outcomeStable', experimental: 'outcomeExperimental', + }[value] + return key === undefined ? display(value) : t(key) +} + +function localizedError(value, t) { + const message = value instanceof Error ? value.message : String(value) + if (t('localeCode') === 'en') return message + const httpStatus = message.match(/HTTP\s+(\d{3})/i)?.[1] + return httpStatus === undefined ? t('operationError') : `${t('requestError')}(HTTP ${httpStatus})` +} + +function localizedHoldReason(hold, t) { + if (t('localeCode') === 'en') return display(hold.reason) + return t(hold.type === 'combination' ? 'holdCombination' : 'holdVersion') +} + +function usePaginatedItems(key, items) { + const [page, setPage] = useState(0) + const [pageSize, setPageSize] = useState(() => { + const value = Number(storageValue(`${LIST_PAGE_SIZE_KEY_PREFIX}${key}`)) + return LIST_PAGE_SIZES.includes(value) ? value : 10 + }) + const lastPage = Math.max(0, Math.ceil(items.length / pageSize) - 1) + const currentPage = Math.min(page, lastPage) + useEffect(() => { if (page !== currentPage) setPage(currentPage) }, [currentPage, page]) + const start = currentPage * pageSize + const changePageSize = value => { + if (!LIST_PAGE_SIZES.includes(value)) return + setPageSize(value) + setPage(0) + writeStorage(`${LIST_PAGE_SIZE_KEY_PREFIX}${key}`, String(value)) + } + const goToPage = value => setPage(Math.min(lastPage, Math.max(0, value))) + return { + items: items.slice(start, start + pageSize), page: currentPage, pageSize, lastPage, start, + setPage: goToPage, setPageSize: changePageSize, + } +} + +function ListPagination({ pagination, total, t }) { + const [jumpValue, setJumpValue] = useState(String(pagination.page + 1)) + useEffect(() => { setJumpValue(String(pagination.page + 1)) }, [pagination.page]) + const commitJump = () => { + const value = Number(jumpValue) + const page = Number.isSafeInteger(value) + ? Math.min(pagination.lastPage, Math.max(0, value - 1)) + : pagination.page + pagination.setPage(page) + setJumpValue(String(page + 1)) + } + return h('div', { className: css.listPagination }, + h('span', null, t('totalItems').replace('{total}', String(total))), + h('div', null, + h(SelectMenu, { + ariaLabel: t('itemsPerPage'), + value: pagination.pageSize, + items: LIST_PAGE_SIZES.map(value => ({ id: String(value), label: String(value) })), + onSelect: value => pagination.setPageSize(Number(value)), + className: css.paginationSelectTrigger, + }), + h('span', null, t('itemsPerPageSuffix'))), + h('div', { className: css.pageNavigation }, + h('button', { type: 'button', className: `${css.smallButton} ${css.pageArrow}`, 'aria-label': t('previousPage'), disabled: pagination.page === 0, onClick: () => pagination.setPage(pagination.page - 1) }), + h('strong', null, String(pagination.page + 1)), + h('button', { type: 'button', className: `${css.smallButton} ${css.pageArrow}`, 'aria-label': t('nextPage'), disabled: pagination.page === pagination.lastPage, onClick: () => pagination.setPage(pagination.page + 1) })), + h('label', { className: css.pageJump }, + h('span', null, t('goToPage')), + h('input', { + type: 'number', min: 1, max: pagination.lastPage + 1, value: jumpValue, + onChange: event => setJumpValue(event.target.value), onBlur: commitJump, + onKeyDown: event => { if (event.key === 'Enter') { event.preventDefault(); commitJump() } }, + }), + h('span', null, t('pageUnit')))) +} + +function ExpandableDescription({ text, identity }) { + const element = useRef(null) + const [expanded, setExpanded] = useState(false) + const [expandable, setExpandable] = useState(false) + useEffect(() => { + const node = element.current + if (node !== null && !expanded) setExpandable(node.scrollWidth > node.clientWidth) + }, [expanded, identity, text]) + return h('button', { + ref: element, + type: 'button', + className: `${css.resourceDescription}${expandable ? ` ${css.expandable}` : ''}${expanded ? ` ${css.expanded}` : ''}`, + title: text, + 'aria-expanded': expanded, + onClick: event => { + if (!expandable) return + preserveScrollableAncestors(event.currentTarget, () => setExpanded(value => !value)) + }, + }, text) +} + +function ExpandableProxyDescription({ text, identity, className = css.proxyScopeDescription }) { + const element = useRef(null) + const [expanded, setExpanded] = useState(false) + const [expandable, setExpandable] = useState(false) + useEffect(() => { + const node = element.current + if (node === null) return undefined + const measure = () => { + if (expanded) return + const clone = node.cloneNode(true) + clone.classList.remove(css.expandable, css.expanded) + Object.assign(clone.style, { + position: 'fixed', visibility: 'hidden', width: `${node.clientWidth}px`, + height: 'auto', maxHeight: 'none', overflow: 'visible', display: 'block', + webkitLineClamp: 'unset', pointerEvents: 'none', + }) + document.body.append(clone) + setExpandable(clone.scrollHeight > node.clientHeight + 1) + clone.remove() + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => observer.disconnect() + }, [expanded, identity, text]) + return h('button', { + ref: element, + type: 'button', + className: `${className}${expandable ? ` ${css.expandable}` : ''}${expanded ? ` ${css.expanded}` : ''}`, + 'aria-expanded': expanded, + onClick: event => { + if (!expandable) return + preserveScrollableAncestors(event.currentTarget, () => setExpanded(value => !value)) + }, + }, text) +} + +function scrollableAncestorPositions(element) { + const positions = [] + for (let current = element.parentElement; current !== null; current = current.parentElement) { + if (current.scrollHeight > current.clientHeight || current.scrollWidth > current.clientWidth) { + positions.push([current, current.scrollLeft, current.scrollTop]) + } + } + return positions +} + +function restoreScrollableAncestors(positions) { + const restore = () => { + for (const [current, left, top] of positions) { + current.scrollLeft = left + current.scrollTop = top + } + } + restore() + window.requestAnimationFrame(() => window.requestAnimationFrame(restore)) +} + +function preserveScrollableAncestors(element, update) { + const positions = scrollableAncestorPositions(element) + update() + restoreScrollableAncestors(positions) +} + +function preserveResourceRowPosition(element, update) { + const row = element.closest('article') + const panel = element.closest('[role="tabpanel"]') + const identity = row?.querySelector('strong')?.textContent + const top = row?.getBoundingClientRect().top + if (panel === null) { + update() + return + } + const positions = [] + for (let current = element.parentElement; current !== null; current = current.parentElement) { + positions.push([current, current.scrollLeft, current.scrollTop]) + if (current === panel) break + } + update() + restoreScrollableAncestors(positions) + window.requestAnimationFrame(() => window.requestAnimationFrame(() => { + for (const [current, left, scrollTop] of positions) { + current.scrollLeft = left + current.scrollTop = scrollTop + } + const nextRow = identity === undefined ? undefined : [...(panel?.querySelectorAll('article') ?? [])] + .find(candidate => candidate.querySelector('strong')?.textContent === identity) + if (nextRow === undefined || top === undefined) return + for (const [current, left] of positions) { + const offset = nextRow.getBoundingClientRect().top - top + if (Math.abs(offset) <= 1) break + current.scrollLeft = left + if (current.scrollHeight > current.clientHeight) current.scrollTop += offset + } + })) +} + +function matchesResourceSearch(query, values) { + const normalized = query.trim().toLocaleLowerCase() + return normalized === '' || values.some(value => String(value ?? '').toLocaleLowerCase().includes(normalized)) +} + +function ResourceStatusBadge({ label, enabled = false, pending = false }) { + return h('span', { + className: `${css.statusBadge}${enabled ? ` ${css.statusEnabled}` : ''}${pending ? ` ${css.statusPending}` : ''}`, + 'aria-live': pending ? 'polite' : undefined, + }, label) +} + +function persistLocale(locale) { + if (typeof document === 'undefined') return + document.cookie = `${LOCALE_COOKIE}=${locale}; Path=/; Max-Age=31536000; SameSite=Lax` +} + +function browserCookie(name) { + for (const part of document.cookie.split(';')) { + const separator = part.indexOf('=') + if (separator >= 0 && part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim() + } + return '' +} + +async function request(path, { method = 'GET', body } = {}) { + const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method) + const response = await fetch(`${API}/${path}`, { + method, + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...(mutation ? { 'x-dsh-csrf': browserCookie('dsh_gateway_csrf') } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const value = await response.json() + if (!response.ok) { + const detail = value?.error + const error = new Error(typeof detail === 'object' && detail !== null + ? detail.message ?? `HTTP ${String(response.status)}` + : detail ?? `HTTP ${String(response.status)}`) + error.statusCode = response.status + error.code = detail?.code ?? value.code + error.stage = detail?.stage + throw error + } + return value +} + +async function authenticationRequest(path, { method = 'GET', body } = {}) { + const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method) + const response = await fetch(`${AUTH_API}/${path}`, { + method, + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...(mutation ? { 'x-dsh-csrf': browserCookie('dsh_gateway_csrf') } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const value = await response.json() + if (!response.ok) { + const error = new Error(value?.error ?? `HTTP ${String(response.status)}`) + error.statusCode = response.status + error.code = value?.code + throw error + } + return value +} + +function VersionCell({ label, version, detail }) { + return h('div', { className: css.versionCell }, + h('span', { className: css.caption }, label), + h('strong', { className: css.version }, display(version)), + h('span', { className: css.detail }, display(detail))) +} + +function logLevel(entry) { + if (entry?.stream === 'stderr') { + const message = String(entry?.message ?? '').trim() + const lines = message.split('\n') + if (lines.some(line => /^(?:\s*at\s+|.*\b(?:error|fatal|failed|failure|exception|panic|unhandled)\b)|错误|失败|异常|致命/iu.test(line))) return 'error' + if (lines.some(line => /\b(?:warn|warning|deprecated|deprecation)\b|警告|已弃用/iu.test(line))) return 'warning' + return 'info' + } + if (['debug', 'info', 'warning', 'error'].includes(entry?.level)) return entry.level + return /^\s*(warn(?:ing)?)[\s:]/i.test(entry?.message ?? '') ? 'warning' : 'info' +} + +function logMessageSummary(message) { + const lines = String(message ?? '').split('\n').map(line => line.trim()).filter(Boolean) + return lines.find(line => /^(?:AggregateError|Error|EvalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError):/u.test(line)) + ?? lines[0] + ?? '' +} + +function isJsonFragment(message) { + const value = message.trim() + return /^(?:[{}\[\]],?|"(?:[^"\\]|\\.)+"\s*:\s*.*)$/.test(value) +} + +function compactLogEntries(entries) { + const compacted = [] + for (let index = 0; index < entries.length; index += 1) { + const first = entries[index] + const opening = first.value.message?.trim() + if (opening !== '{' && opening !== '[') { + if (!isJsonFragment(first.value.message ?? '')) compacted.push(first) + continue + } + const lines = [first.value.message] + const startedAt = Date.parse(first.value.timestamp) + let merged = false + for (let end = index + 1; end < entries.length; end += 1) { + const next = entries[end] + if ( + next.value.source !== first.value.source + || next.value.stream !== first.value.stream + || logLevel(next.value) !== logLevel(first.value) + || Date.parse(next.value.timestamp) - startedAt > 2_000 + ) break + lines.push(next.value.message) + try { + const value = JSON.parse(lines.join('\n')) + compacted.push({ + identity: entries.slice(index, end + 1).map(item => item.identity).join('|'), + value: { ...first.value, message: JSON.stringify(value) }, + }) + index = end + merged = true + break + } catch {} + } + if (!merged && !isJsonFragment(first.value.message)) compacted.push(first) + } + return compacted +} + +function readLogDisplayLimit() { + try { + const value = Number(window.localStorage.getItem(LOG_DISPLAY_LIMIT_KEY)) + return LOG_DISPLAY_LIMITS.includes(value) ? value : DEFAULT_LOG_DISPLAY_LIMIT + } catch { return DEFAULT_LOG_DISPLAY_LIMIT } +} + +function limitProcessedLogEntries(entries, limit) { + return compactLogEntries(entries).slice(-limit) +} + +function readLogClearCutoff() { + try { + const value = window.sessionStorage.getItem(LOG_CLEAR_CUTOFF_KEY) + return Number.isFinite(Date.parse(value)) ? value : null + } catch { return null } +} + +function latestLogCutoff(entries, now = Date.now()) { + const latest = entries.reduce((value, entry) => { + const timestamp = Date.parse(entry.value.timestamp) + return Number.isFinite(timestamp) ? Math.max(value, timestamp) : value + }, now) + return new Date(latest).toISOString() +} + +function isClearedLog(entry, cutoff) { + const timestamp = Date.parse(entry.timestamp) + return cutoff !== null && Number.isFinite(timestamp) && timestamp <= Date.parse(cutoff) +} + +function downloadLogJsonl(entries) { + if (entries.length === 0) return + const content = `${entries.map(entry => JSON.stringify(entry)).join('\n')}\n` + const url = URL.createObjectURL(new Blob([content], { type: 'application/x-ndjson;charset=utf-8' })) + const link = document.createElement('a') + link.href = url + link.download = `dsh-platform-logs-${new Date().toISOString().replace(/[:.]/gu, '-')}.jsonl` + link.click() + window.setTimeout(() => URL.revokeObjectURL(url), 0) +} + +function LogViewer({ active, focusTaskId, t }) { + const [entries, setEntries] = useState([]) + const [sourceCatalog, setSourceCatalog] = useState([]) + const [query, setQuery] = useState('') + const [source, setSource] = useState('all') + const [level, setLevel] = useState('all') + const [streamState, setStreamState] = useState('connecting') + const [streamRevision, setStreamRevision] = useState(0) + const [autoScroll, setAutoScroll] = useState(true) + const [displayLimit, setDisplayLimit] = useState(readLogDisplayLimit) + const [expanded, setExpanded] = useState(() => new Set()) + const listRef = useRef(null) + const resizeFrameRef = useRef(null) + const clearCutoff = useRef(readLogClearCutoff()) + const logIdentities = useRef(new Set()) + const pendingEntries = useRef([]) + const renderFrame = useRef() + + useEffect(() => { + if (focusTaskId) setQuery(String(focusTaskId)) + }, [focusTaskId]) + + useEffect(() => { + if (!active) return undefined + setStreamState('connecting') + const stream = new EventSource(`${API}/logs/stream?limit=${String(LOG_STREAM_LIMIT)}`) + let lastActivity = Date.now() + const commitPendingEntries = () => { + renderFrame.current = undefined + const pending = pendingEntries.current.splice(0) + if (pending.length === 0) return + setEntries(previous => { + const combined = [...previous, ...pending] + const removed = combined.slice(0, Math.max(0, combined.length - LOG_STREAM_LIMIT)) + for (const entry of removed) logIdentities.current.delete(entry.identity) + return combined.slice(-LOG_STREAM_LIMIT) + }) + } + stream.addEventListener('log', event => { + lastActivity = Date.now() + try { + const entry = JSON.parse(event.data) + const identity = JSON.stringify(entry) + if (isClearedLog(entry, clearCutoff.current) || logIdentities.current.has(identity)) return + logIdentities.current.add(identity) + pendingEntries.current.push({ identity, value: entry }) + if (renderFrame.current === undefined) { + renderFrame.current = window.requestAnimationFrame(commitPendingEntries) + } + } catch {} + }) + stream.addEventListener('sources', event => { + try { + const sources = JSON.parse(event.data) + if (Array.isArray(sources)) setSourceCatalog(sources.filter(value => typeof value === 'string')) + } catch {} + }) + stream.addEventListener('heartbeat', () => { lastActivity = Date.now() }) + stream.onopen = () => { + lastActivity = Date.now() + setStreamState('live') + } + stream.onerror = () => setStreamState('disconnected') + const watchdog = window.setInterval(() => { + if (Date.now() - lastActivity > 35_000) setStreamRevision(value => value + 1) + }, 5_000) + return () => { + window.clearInterval(watchdog) + stream.close() + if (renderFrame.current !== undefined) window.cancelAnimationFrame(renderFrame.current) + for (const entry of pendingEntries.current.splice(0)) logIdentities.current.delete(entry.identity) + renderFrame.current = undefined + } + }, [active, streamRevision]) + + const visibleEntries = limitProcessedLogEntries(entries, displayLimit) + const sources = [...new Set([ + ...sourceCatalog, + ...visibleEntries.map(item => item.value.source).filter(Boolean), + ])].sort() + const normalizedQuery = query.trim().toLocaleLowerCase(t('localeCode') === 'en' ? 'en-US' : 'zh-CN') + const filtered = visibleEntries.filter(item => { + const entry = item.value + return (source === 'all' || entry.source === source) + && (level === 'all' || logLevel(entry) === level) + && (normalizedQuery === '' || JSON.stringify(entry).toLocaleLowerCase().includes(normalizedQuery)) + }) + const hasFilteredEntries = filtered.length > 0 + const exportEntries = entries.slice(-displayLimit).map(item => item.value).filter(entry => (source === 'all' || entry.source === source) + && (level === 'all' || logLevel(entry) === level) + && (normalizedQuery === '' || JSON.stringify(entry).toLocaleLowerCase().includes(normalizedQuery))) + + useEffect(() => { + const frame = resizeFrameRef.current + if (!active || !hasFilteredEntries || frame === null) return undefined + const handle = frame.querySelector(`.${css.logResizeHandle}`) + const scrollContainer = frame.closest(`.${css.tabPanel}`) + if (handle === null) return undefined + let pointerId + let startY = 0 + let startHeight = 0 + let startScrollTop = 0 + let lastClientY = 0 + let minimumHeight = 0 + let maximumHeight = 0 + let scrollFrame + let previousCursor = '' + let previousUserSelect = '' + const keepBottomVisible = () => { + if (scrollFrame !== undefined) window.cancelAnimationFrame(scrollFrame) + scrollFrame = window.requestAnimationFrame(() => { + scrollFrame = undefined + if (scrollContainer === null) return + const overflow = frame.getBoundingClientRect().bottom - scrollContainer.getBoundingClientRect().bottom + 8 + if (overflow <= 0) return + const previousScrollTop = scrollContainer.scrollTop + scrollContainer.scrollTop += Math.min(overflow, 24) + const height = Math.min(maximumHeight, Math.max(minimumHeight, + startHeight + lastClientY - startY + scrollContainer.scrollTop - startScrollTop)) + frame.style.height = `${String(height)}px` + if (scrollContainer.scrollTop > previousScrollTop + && (pointerId === undefined || lastClientY >= scrollContainer.getBoundingClientRect().bottom - 24)) keepBottomVisible() + }) + } + const startResize = event => { + event.preventDefault() + pointerId = event.pointerId + startY = event.clientY + lastClientY = event.clientY + startHeight = frame.getBoundingClientRect().height + startScrollTop = scrollContainer?.scrollTop ?? 0 + const style = window.getComputedStyle(frame) + minimumHeight = Number.parseFloat(style.minHeight) + maximumHeight = Number.parseFloat(style.maxHeight) + previousCursor = document.body.style.cursor + previousUserSelect = document.body.style.userSelect + document.body.style.cursor = 'ns-resize' + document.body.style.userSelect = 'none' + handle.setPointerCapture?.(pointerId) + } + const resize = event => { + if (pointerId === undefined || event.pointerId !== pointerId) return + lastClientY = event.clientY + const height = Math.min(maximumHeight, Math.max(minimumHeight, + startHeight + lastClientY - startY + (scrollContainer?.scrollTop ?? 0) - startScrollTop)) + frame.style.height = `${String(height)}px` + keepBottomVisible() + } + const finishResize = event => { + if (pointerId === undefined || event.pointerId !== pointerId) return + if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) + pointerId = undefined + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + keepBottomVisible() + } + handle.addEventListener('pointerdown', startResize) + window.addEventListener('pointermove', resize) + window.addEventListener('pointerup', finishResize) + window.addEventListener('pointercancel', finishResize) + return () => { + handle.removeEventListener('pointerdown', startResize) + window.removeEventListener('pointermove', resize) + window.removeEventListener('pointerup', finishResize) + window.removeEventListener('pointercancel', finishResize) + if (scrollFrame !== undefined) window.cancelAnimationFrame(scrollFrame) + if (pointerId !== undefined) { + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + } + } + }, [active, hasFilteredEntries]) + + useEffect(() => { + if (!active || !autoScroll || listRef.current === null) return undefined + let layoutFrame + const visibilityFrame = window.requestAnimationFrame(() => { + layoutFrame = window.requestAnimationFrame(() => { + if (listRef.current !== null) listRef.current.scrollTop = listRef.current.scrollHeight + }) + }) + return () => { + window.cancelAnimationFrame(visibilityFrame) + if (layoutFrame !== undefined) window.cancelAnimationFrame(layoutFrame) + } + }, [active, autoScroll, displayLimit, entries, level, query, source]) + + const clearLogView = () => { + clearCutoff.current = latestLogCutoff([...entries, ...pendingEntries.current]) + try { window.sessionStorage.setItem(LOG_CLEAR_CUTOFF_KEY, clearCutoff.current) } catch {} + if (renderFrame.current !== undefined) window.cancelAnimationFrame(renderFrame.current) + renderFrame.current = undefined + pendingEntries.current.length = 0 + logIdentities.current.clear() + setExpanded(new Set()) + setEntries([]) + } + + const changeDisplayLimit = selected => { + const value = Number(selected) + if (!LOG_DISPLAY_LIMITS.includes(value)) return + setDisplayLimit(value) + try { window.localStorage.setItem(LOG_DISPLAY_LIMIT_KEY, String(value)) } catch {} + } + + return h('section', { className: `${css.section} ${css.logSection}`, 'aria-labelledby': 'platform-logs-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-logs-title' }, t('logs')), + h('p', null, t('logsDetail'))), + h('div', { className: css.logTools }, + h('span', { className: `${css.logConnection} ${css[streamState]}`, role: 'status' }, + h('span', { 'aria-hidden': 'true' }), + t(`logs${streamState[0].toUpperCase()}${streamState.slice(1)}`)), + h('button', { + type: 'button', + className: css.autoScrollButton, + onClick: () => setStreamRevision(value => value + 1), + }, t('refreshLogs')), + h('button', { + type: 'button', + className: css.clearLogsButton, + disabled: exportEntries.length === 0, + onClick: () => downloadLogJsonl(exportEntries), + }, t('exportLogs')), + h('button', { + type: 'button', + className: css.clearLogsButton, + disabled: entries.length === 0, + onClick: clearLogView, + }, t('clearLogView')))), + h('div', { className: css.logFilters }, + h('input', { + type: 'search', + value: query, + placeholder: t('searchLogs'), + 'aria-label': t('searchLogs'), + onChange: event => setQuery(event.target.value), + }), + h(SelectMenu, { + value: source, + ariaLabel: t('logSource'), + items: [{ id: 'all', label: t('allSources') }, ...sources.map(value => ({ id: value, label: value }))], + onSelect: setSource, + className: css.logSelectTrigger, + }), + h(SelectMenu, { + value: level, + ariaLabel: t('logLevel'), + items: ['all', 'debug', 'info', 'warning', 'error'].map(value => ({ id: value, label: t(`level${value[0].toUpperCase()}${value.slice(1)}`) })), + onSelect: setLevel, + className: css.logSelectTrigger, + }), + h(SelectMenu, { + value: displayLimit, + ariaLabel: t('logDisplayLimit'), + items: LOG_DISPLAY_LIMITS.map(value => ({ id: String(value), label: t('logDisplayLimitValue').replace('{count}', String(value)) })), + onSelect: changeDisplayLimit, + className: css.logSelectTrigger, + }), + ), + h('div', { className: css.logSummaryRow }, + h('div', { className: css.logSummary }, t('logCount').replace('{shown}', String(filtered.length)).replace('{total}', String(displayLimit))), + h('label', { className: css.logAutoScroll }, + h('input', { + type: 'checkbox', + checked: autoScroll, + 'aria-label': t('autoScroll'), + onChange: event => setAutoScroll(event.target.checked), + }), + h('span', { 'aria-hidden': 'true' }), + h('b', null, t('autoScroll')))), + filtered.length === 0 + ? h('p', { className: css.emptyLogs }, visibleEntries.length === 0 ? t('noLogs') : t('noMatchingLogs')) + : h('div', { className: css.logResizeFrame, ref: resizeFrameRef }, + h('div', { className: css.logList, ref: listRef }, filtered.map(item => { + const entry = item.value + const entryLevel = logLevel(entry) + const isExpanded = expanded.has(item.identity) + const toggle = () => setExpanded(value => { + const next = new Set(value) + if (next.has(item.identity)) next.delete(item.identity); else next.add(item.identity) + return next + }) + return h('article', { + className: css.logEntry, + key: item.identity, + role: 'button', + tabIndex: 0, + 'aria-expanded': isExpanded, + onClick: toggle, + onKeyDown: event => { + if (!['Enter', ' '].includes(event.key)) return + event.preventDefault() + toggle() + }, + }, + h('div', { className: css.logMeta }, + h('strong', { className: `${css.logLevel} ${css[`log${entryLevel[0].toUpperCase()}${entryLevel.slice(1)}`]}` }, t(`level${entryLevel[0].toUpperCase()}${entryLevel.slice(1)}`)), + h('span', { className: css.logSource }, display(entry.source)), + h('time', { dateTime: entry.timestamp }, localTime(entry.timestamp, t('localeCode')))), + h('div', { className: css.logMessageRow }, + h('pre', null, display(logMessageSummary(entry.message))), + h('span', { className: css.logChevron, 'aria-hidden': true })), + isExpanded ? h('pre', { className: css.logDetails }, JSON.stringify(entry, null, 2)) : null) + })), + h('span', { className: css.logResizeHandle, 'aria-hidden': true }))) +} + +function storageValue(key) { + try { return window.localStorage.getItem(key) } catch { return null } +} + +function writeStorage(key, value) { + try { + if (value === null) window.localStorage.removeItem(key) + else window.localStorage.setItem(key, value) + } catch {} +} + +function parsedStorage(key) { + try { return JSON.parse(storageValue(key) ?? 'null') } catch { return null } +} + +function candidateIdentity(candidate) { + return candidate.kind === 'stable' + ? `stable:${String(candidate.targetSequence)}` + : `upstream:${candidate.version}` +} + +function eligibleCandidates(status) { + if (status?.automaticCheck?.notificationsEnabled !== true) return [] + const candidates = [] + if (status?.updateChannel === 'stable' && status?.latestAutomatic?.stable !== null && status?.latestAutomatic?.stable !== undefined) { + candidates.push({ kind: 'stable', ...status.latestAutomatic.stable }) + } + const upstream = status?.latestAutomatic?.upstream + const held = upstream !== null && upstream !== undefined && (status?.holds ?? []).some(hold => hold.dshVersion === upstream.version) + if (status?.updateChannel === 'experimental' && upstream !== null && upstream !== undefined && !held) { + candidates.push({ kind: 'upstream', ...upstream }) + } + return candidates.filter(candidate => storageValue(`dsh-platform:update-notice-dismissed:${candidate.kind}`) !== candidateIdentity(candidate)) +} + +function clearSatisfiedDismissals(status) { + if (status?.update?.status !== 'success') return + const completion = status.update.taskId ?? status.update.updatedAt + if (completion === undefined || completion === null || storageValue('dsh-platform:update-notice-cleared-completion') === completion) return + writeStorage('dsh-platform:update-notice-dismissed:stable', null) + writeStorage('dsh-platform:update-notice-dismissed:upstream', null) + writeStorage('dsh-platform:update-notice-cleared-completion', completion) +} + +function updateNoticeModel(candidate, status) { + const dsh = candidate.kind === 'stable' ? candidate.dsh : candidate.version + const dshChanged = dsh !== status?.current?.dsh + if (candidate.kind === 'upstream') return { kind: 'upstream', scope: 'dsh', dsh, dshChanged } + const environment = candidate.environment ?? status?.supported?.environment ?? status?.current?.environment + const environmentChanged = environment !== status?.current?.environment + const scope = dshChanged && environmentChanged ? 'both' : environmentChanged ? 'environment' : 'dsh' + return { kind: candidate.kind, scope, dsh, environment, dshChanged, environmentChanged } +} + +function UpdateReminder({ t }) { + const [status, setStatus] = useState(null) + const [tick, setTick] = useState(0) + const [ownsNotice, setOwnsNotice] = useState(false) + const ownerId = useRef(`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`) + const previousFocus = useRef(null) + + const refresh = useCallback(async () => { + try { + const value = await request('status') + clearSatisfiedDismissals(value) + setStatus(value) + return value + } catch {} + return undefined + }, []) + + useEffect(() => { + const unsubscribeEvents = subscribePlatformStateEvents({ state: () => { void refresh() } }) + const refreshAndConnect = async () => { + await refresh() + } + void refreshAndConnect() + const timer = window.setInterval(() => { void refreshAndConnect() }, 30_000) + const changed = () => setTick(value => value + 1) + const visibilityChanged = () => { + if (document.visibilityState !== 'visible' && parsedStorage(NOTICE_OWNER_KEY)?.id === ownerId.current) { + writeStorage(NOTICE_OWNER_KEY, null) + } + changed() + } + const leaseTimer = window.setInterval(changed, NOTICE_OWNER_TTL / 2) + window.addEventListener('storage', changed) + document.addEventListener('visibilitychange', visibilityChanged) + return () => { + unsubscribeEvents() + window.clearInterval(timer) + window.clearInterval(leaseTimer) + window.removeEventListener('storage', changed) + document.removeEventListener('visibilitychange', visibilityChanged) + const owner = parsedStorage(NOTICE_OWNER_KEY) + if (owner?.id === ownerId.current) writeStorage(NOTICE_OWNER_KEY, null) + } + }, [refresh]) + + const candidate = eligibleCandidates(status)[0] + const identity = candidate === undefined ? null : candidateIdentity(candidate) + + useEffect(() => { + if (identity === null || document.visibilityState !== 'visible') { + setOwnsNotice(false) + return + } + const now = Date.now() + const snooze = parsedStorage(NOTICE_SNOOZE_KEY) + const owner = parsedStorage(NOTICE_OWNER_KEY) + const available = !(snooze?.identity === identity && snooze.until > now) + && (owner?.id === ownerId.current || owner?.expiresAt <= now || owner === null) + if (available) writeStorage(NOTICE_OWNER_KEY, JSON.stringify({ id: ownerId.current, expiresAt: now + NOTICE_OWNER_TTL })) + setOwnsNotice(available) + }, [identity, tick]) + + useEffect(() => { + if (!ownsNotice) return undefined + if (previousFocus.current === null && document.activeElement instanceof HTMLElement && document.activeElement !== document.body) { + previousFocus.current = document.activeElement + } + const timer = window.setInterval(() => { + writeStorage(NOTICE_OWNER_KEY, JSON.stringify({ id: ownerId.current, expiresAt: Date.now() + NOTICE_OWNER_TTL })) + }, NOTICE_OWNER_TTL / 2) + return () => window.clearInterval(timer) + }, [identity, ownsNotice]) + + const dismiss = useCallback(permanent => { + if (permanent) writeStorage(`dsh-platform:update-notice-dismissed:${candidate?.kind}`, identity) + else writeStorage(NOTICE_SNOOZE_KEY, JSON.stringify({ identity, until: Date.now() + 3_600_000 })) + writeStorage(NOTICE_OWNER_KEY, null) + setTick(value => value + 1) + window.requestAnimationFrame(() => { + if (previousFocus.current?.isConnected) previousFocus.current.focus() + previousFocus.current = null + }) + }, [candidate?.kind, identity]) + + useEffect(() => { + const handleClick = event => { + const button = event.target.closest('button[data-notice-action]') + if (button === null || button.closest('aside[role="status"]') === null) return + dismiss(button.dataset.noticeAction === 'dismiss') + } + document.addEventListener('click', handleClick) + return () => document.removeEventListener('click', handleClick) + }, [dismiss]) + + if (!ownsNotice || candidate === undefined) return null + const notice = updateNoticeModel(candidate, status) + const scopeName = `${notice.scope[0].toUpperCase()}${notice.scope.slice(1)}` + const title = t(`${notice.kind}Notice${scopeName}Title`) + const dshLine = t('updateNoticeDshLine') + .replace('{version}', display(notice.dsh)) + .replace('{state}', notice.dshChanged ? t('noticeWillUpdate') : '') + const environmentLine = notice.kind === 'stable' + ? t('updateNoticeEnvironmentLine') + .replace('{version}', displayEnvironment(notice.environment)) + .replace('{state}', notice.environmentChanged ? t('noticeWillUpdate') : '') + : null + return h('aside', { className: css.updateReminder, role: 'status', 'aria-live': 'polite' }, + h('strong', null, title), + h('p', { className: css.updateReminderVersions }, + h('span', null, dshLine), + environmentLine === null ? null : h('span', null, environmentLine)), + h('div', { className: css.reminderActions }, + h('button', { type: 'button', 'data-notice-action': 'snooze' }, t('later')), + h('button', { type: 'button', 'data-notice-action': 'dismiss' }, t('dismissVersion'))), + h('button', { type: 'button', className: css.updateReminderClose, 'data-notice-action': 'snooze', 'aria-label': t('closeReminder'), title: t('closeReminder') }, '×')) +} + +function LifecycleGuard({ connection }) { + useEffect(() => { + const generation = connection.generation ?? connection.hostDescription + let stopped = false + let navigating = false + let hadConnection = generation.getSnapshot() !== undefined + let graceTimer + let retryTimer + + const clearTimers = () => { + window.clearTimeout(graceTimer) + window.clearTimeout(retryTimer) + graceTimer = undefined + retryTimer = undefined + } + const navigate = () => { + if (stopped || navigating || window.location.pathname === LIFECYCLE_WAIT_PATH) return + navigating = true + window.location.replace(lifecycleWaitUrl()) + } + const scheduleReadiness = () => { + if (stopped || retryTimer !== undefined) return + retryTimer = window.setTimeout(() => { + retryTimer = undefined + void verifyReadiness() + }, READINESS_RETRY_MS) + } + const verifyReadiness = async () => { + if (stopped || generation.getSnapshot() !== undefined) return + try { + const response = await fetch(LIFECYCLE_READINESS_PATH, { + headers: { accept: 'application/json' }, + cache: 'no-store', + }) + if (response.status === 503) { + const value = await response.json().catch(() => ({})) + if (value.state !== undefined && value.state !== 'unknown') navigate() + else scheduleReadiness() + return + } + } catch {} + scheduleReadiness() + } + const inspectPlatformState = async () => { + try { + if (requiresLifecycleHoldingPage(await request('status'))) navigate() + } catch {} + } + const connectionChanged = () => { + if (generation.getSnapshot() !== undefined) { + hadConnection = true + clearTimers() + return + } + if (!hadConnection || graceTimer !== undefined) return + graceTimer = window.setTimeout(() => { + graceTimer = undefined + void verifyReadiness() + }, CONNECTION_LOSS_GRACE_MS) + } + + const unsubscribe = generation.subscribe(connectionChanged) + const unsubscribeEvents = subscribePlatformStateEvents({ state: () => { void inspectPlatformState() } }) + void inspectPlatformState() + connectionChanged() + return () => { + stopped = true + clearTimers() + unsubscribe() + unsubscribeEvents() + } + }, [connection]) + return null +} + +function systemPluginSummary(progress, draft, t) { + if (progress?.phase === 'restarting') return t('systemPluginRestarting') + if (progress?.phase === 'applying') { + const actionKey = { + install: 'pluginActionInstall', uninstall: 'pluginActionUninstall', enable: 'pluginActionEnable', disable: 'pluginActionDisable', + }[progress.action] + return t('systemPluginApplyingItem', { + action: t(actionKey ?? 'pluginActionWorking'), id: progress.id, current: progress.current, total: progress.total, + }) + } + return draft.size > 0 ? t('pendingSystemPluginChanges', { count: draft.size }) : t('pluginChangesPendingDetail') +} + +function SystemPluginManager({ plugins, draft, applyingDraft, progress, operation, busy, error, onAction, onCancel, onApply, t }) { + const operationBusy = operation?.status === 'running' + const [query, setQuery] = useState('') + const filteredPlugins = plugins.filter(plugin => matchesResourceSearch(query, [plugin.id])) + const pagination = usePaginatedItems('system-plugins', filteredPlugins) + const visiblePlugins = pagination.items + const restartRequired = draft.size > 0 || plugins.some(plugin => plugin.pendingRestart) || progress !== null + return h('section', { className: `${css.section} ${css.pluginSection}`, 'aria-labelledby': 'system-plugins-title' }, + h('div', { className: `${css.sectionHeading} ${css.resourceSectionHeading}` }, + h('h3', { id: 'system-plugins-title' }, t('systemPlugins')), + h('div', { className: css.resourceHeadingDetail }, + h('p', null, t('systemPluginsDetail')), + h('input', { + type: 'search', className: css.resourceSearch, value: query, placeholder: t('searchSystemPlugins'), 'aria-label': t('searchSystemPlugins'), + onChange: event => { setQuery(event.target.value); pagination.setPage(0) }, + }))), + restartRequired ? h('div', { className: css.pluginRestartNotice, role: 'status' }, + h('div', null, + h('strong', null, t('pluginChangesPending')), + h('p', null, systemPluginSummary(progress, draft, t))), + h('div', { className: css.pluginDraftActions }, + h('button', { type: 'button', className: css.secondaryButton, disabled: busy, onClick: onCancel }, t('cancelChanges')), + h('button', { type: 'button', className: css.primaryButton, disabled: busy, onClick: onApply }, t('applyPluginChanges')))) : null, + h('div', { className: css.pluginList }, + visiblePlugins.length === 0 + ? h('p', { className: css.emptyPlugins }, plugins.length === 0 ? t('noSystemPlugins') : t('noMatchingResources')) + : visiblePlugins.map(plugin => { + const action = draft.get(plugin.id) + const projected = action === 'install' ? { ...plugin, installed: true, enabled: true } + : action === 'enable' ? { ...plugin, enabled: true } + : action === 'disable' ? { ...plugin, enabled: false } : plugin + const isActive = operationBusy && operation.pluginId === plugin.id + const applyingAction = restartRequired + ? applyingDraft.get(plugin.id) ?? (isActive ? operation.action : undefined) + : isActive ? operation.action : undefined + const description = plugin.description?.[t('localeCode')] ?? plugin.id + const stateKey = applyingAction !== undefined + ? { install: 'statusInstalling', uninstall: 'statusUninstalling', enable: 'statusEnabling', disable: 'statusDisabling' }[applyingAction] + : action !== undefined + ? { install: 'pendingInstall', uninstall: 'pendingUninstall', enable: 'pendingEnable', disable: 'pendingDisable' }[action] + : plugin.installed ? (plugin.enabled ? 'resourceEnabled' : 'resourceDisabled') : 'notInstalled' + return h('article', { className: css.pluginRow, key: plugin.id }, + h('div', { className: css.pluginIdentity }, + h('div', { className: css.resourceHeading }, + h(ResourceStatusBadge, { label: t(stateKey ?? 'pluginActionWorking'), enabled: applyingAction === undefined && action === undefined && plugin.enabled, pending: applyingAction !== undefined || action !== undefined }), + h('strong', null, `@dsh-docker/${plugin.id}`)), + h(ExpandableDescription, { text: description, identity: plugin.id })), + !projected.installed + ? h('div', { className: css.pluginActions }, + h('button', { + type: 'button', + className: css.primaryButton, + disabled: busy, + onClick: event => preserveResourceRowPosition(event.currentTarget, () => onAction(plugin, 'install')), + }, t('installPlugin'))) + : plugin.protected + ? h('span', { className: css.managedBadge }, t('managed')) + : h('div', { className: css.pluginActions }, + h('label', { className: css.toggle }, + h('input', { + type: 'checkbox', + checked: projected.enabled, + disabled: busy || action === 'install', + onChange: event => preserveResourceRowPosition(event.currentTarget, () => onAction(plugin, event.target.checked ? 'enable' : 'disable')), + }), + h('span', { 'aria-hidden': 'true' })))) + })), + h(ListPagination, { pagination, total: filteredPlugins.length, t }), + operation?.status === 'failed' + ? h('p', { className: css.error, role: 'alert' }, localizedError(operation.error, t)) + : error ? h('p', { className: css.error, role: 'alert' }, localizedError(error, t)) + : null) +} + +function SystemSkillManager({ skills, operation, busy, error, onAction, t }) { + const operationBusy = operation?.status === 'running' + const [query, setQuery] = useState('') + const filteredSkills = skills.filter(skill => matchesResourceSearch(query, [skill.id])) + const pagination = usePaginatedItems('system-skills', filteredSkills) + return h('section', { className: `${css.section} ${css.pluginSection}`, 'aria-labelledby': 'system-skills-title' }, + h('div', { className: `${css.sectionHeading} ${css.resourceSectionHeading}` }, + h('h3', { id: 'system-skills-title' }, t('systemSkills')), + h('div', { className: css.resourceHeadingDetail }, + h('p', null, t('systemSkillsDetail')), + h('input', { + type: 'search', className: css.resourceSearch, value: query, placeholder: t('searchSystemSkills'), 'aria-label': t('searchSystemSkills'), + onChange: event => { setQuery(event.target.value); pagination.setPage(0) }, + }))), + h('div', { className: css.pluginList }, + filteredSkills.length === 0 + ? h('p', { className: css.emptyPlugins }, skills.length === 0 ? t('noSystemSkills') : t('noMatchingResources')) + : pagination.items.map(skill => { + const isActive = operationBusy && operation.skillId === skill.id + const stateKey = isActive + ? { install: 'statusInstalling', enable: 'statusEnabling', disable: 'statusDisabling' }[operation.action] + : skill.installed ? (skill.enabled ? 'resourceEnabled' : 'resourceDisabled') : 'notInstalled' + return h('article', { className: css.pluginRow, key: skill.id }, + h('div', { className: css.pluginIdentity }, + h('div', { className: css.resourceHeading }, + h(ResourceStatusBadge, { label: t(stateKey ?? 'skillActionWorking'), enabled: !isActive && skill.enabled, pending: isActive }), + h('strong', null, skill.id)), + h(ExpandableDescription, { text: skill.description?.[t('localeCode')] ?? skill.id, identity: skill.id })), + !skill.installed + ? h('div', { className: css.pluginActions }, + h('button', { + type: 'button', className: css.primaryButton, disabled: busy, + onClick: event => preserveResourceRowPosition(event.currentTarget, () => onAction(skill, 'install')), + }, t('installPlugin'))) + : h('div', { className: css.pluginActions }, + h('label', { className: css.toggle }, + h('input', { + type: 'checkbox', checked: skill.enabled, disabled: busy, + onChange: event => preserveResourceRowPosition(event.currentTarget, () => onAction(skill, event.target.checked ? 'enable' : 'disable')), + }), + h('span', { 'aria-hidden': 'true' })))) + })), + h(ListPagination, { pagination, total: filteredSkills.length, t }), + operation?.status === 'failed' + ? h('p', { className: css.error, role: 'alert' }, localizedError(operation.error, t)) + : error ? h('p', { className: css.error, role: 'alert' }, localizedError(error, t)) : null) +} + +function transactionStageState(index, currentIndex, status) { + if (index < currentIndex || (index === currentIndex && status === 'success')) return 'completed' + if (index === currentIndex) return status === 'failed' ? 'failed' : 'active' + return 'pending' +} + +function TransactionStageLogs({ update, visible, t, onViewFullLog }) { + const [entries, setEntries] = useState([]) + const [expanded, setExpanded] = useState({}) + const [autoScroll, setAutoScroll] = useState(true) + const [copied, setCopied] = useState(false) + const touched = useRef(new Set()) + const identities = useRef(new Set()) + const previousStage = useRef() + const activeList = useRef(null) + const phase = update?.phase ?? (isRecoveryOperation(update?.operation) ? update.rollbackPhase : update?.status) + const model = updateProgressModel(update ?? {}, t) + const currentStage = transactionLogStage(phase, update, t) + useEffect(() => { + if (!visible || !update?.taskId || !phase) { + setEntries([]) + return undefined + } + setEntries([]) + setExpanded({ [currentStage.key]: update.status !== 'success' }) + touched.current.clear() + identities.current.clear() + previousStage.current = currentStage.key + const params = new URLSearchParams({ taskId: String(update.taskId), operation: String(update.operation ?? 'update'), limit: '1000' }) + const stream = new EventSource(`${API}/logs/stream?${params.toString()}`) + stream.addEventListener('log', event => { + try { + const entry = JSON.parse(event.data) + const identity = JSON.stringify(entry) + if (identities.current.has(identity)) return + identities.current.add(identity) + setEntries(previous => [...previous, entry].slice(-1000)) + } catch {} + }) + return () => stream.close() + }, [update?.taskId, visible]) + useEffect(() => { + const prior = previousStage.current + setExpanded(value => { + const next = { ...value } + if (prior !== undefined && prior !== currentStage.key && !touched.current.has(prior)) next[prior] = false + if (update.status === 'success') { + touched.current.delete(currentStage.key) + next[currentStage.key] = false + } else if (!touched.current.has(currentStage.key)) { + next[currentStage.key] = true + } + return next + }) + previousStage.current = currentStage.key + }, [currentStage.key, update.status]) + useEffect(() => { + if (!autoScroll || !expanded[currentStage.key]) return + activeList.current?.scrollTo({ top: activeList.current.scrollHeight }) + }, [autoScroll, currentStage.key, entries, expanded]) + if (!visible || !update?.taskId || !phase) return null + const groupPrefix = isRecoveryOperation(update?.operation) ? 'rollback' : 'update' + const groups = new Map(model.labels.map((label, index) => [`${groupPrefix}:${String(index)}`, { + key: `${groupPrefix}:${String(index)}`, + label: t(label), + labelKey: label, + index, + entries: [], + }])) + for (const entry of entries) { + const stage = transactionLogStage(entry.phase, update, t) + const group = groups.get(stage.key) ?? { ...stage, entries: [] } + group.entries.push(entry) + groups.set(stage.key, group) + } + const orderedGroups = [...groups.values()].sort((left, right) => left.index - right.index) + const currentEntries = groups.get(currentStage.key)?.entries ?? [] + const toggleStage = key => { + touched.current.add(key) + setExpanded(value => ({ ...value, [key]: !(value[key] ?? key === currentStage.key) })) + } + const copyCurrent = async () => { + if (currentEntries.length === 0) return + try { + await navigator.clipboard.writeText(currentEntries.map(entry => JSON.stringify(entry)).join('\n')) + setCopied(true) + window.setTimeout(() => setCopied(false), 1_500) + } catch {} + } + return h('div', { className: css.progressStageLog }, + h('div', { className: css.progressLogList, role: 'log' }, orderedGroups.map(group => { + const state = transactionStageState(group.index, model.stage, update.status) + const isExpanded = expanded[group.key] ?? (state === 'active' || state === 'failed') + const latest = group.entries.at(-1) + const metricSource = state === 'active' || state === 'failed' ? { ...latest, ...update } : latest + const metricLines = stageMetricLines(metricSource, group, state, t) + const metricProgress = state === 'active' ? stageMetricProgress(metricSource) : undefined + const items = stageItems(group, update, state, t) + const completedItems = items.filter(item => item.state === 'completed').length + return h('section', { className: `${css.progressLogGroup} ${css[`progressStage${state[0].toUpperCase()}${state.slice(1)}`]}`, key: group.key }, + h('span', { className: `${css.progressStageMarker} ${css[`progressStageMarker${state[0].toUpperCase()}${state.slice(1)}`]}`, 'aria-hidden': 'true' }), + h('div', { className: css.progressStageHeader }, + h('div', { className: css.progressStageSummary }, + h('strong', null, group.label), + h('span', { className: css.progressStageCount }, t('stageItemsCompleted').replace('{completed}', String(completedItems)).replace('{total}', String(items.length)))), + h('button', { type: 'button', className: css.progressStageToggle, 'aria-expanded': isExpanded, onClick: () => toggleStage(group.key) }, + t(isExpanded ? 'collapseStage' : 'expandStage').replace('{count}', String(group.entries.length)))), + isExpanded ? h(React.Fragment, null, + h('div', { className: css.progressStageItems }, items.map(item => h('div', { className: `${css.progressStageItem} ${css[`progressStageItem${item.state[0].toUpperCase()}${item.state.slice(1)}`]}`, key: item.key }, + h('span', { className: css.progressStageItemMarker, 'aria-hidden': 'true' }), + h('span', null, t({ completed: 'stageItemCompleted', active: 'stageItemActive', failed: 'stageItemFailed', pending: 'stageItemPending' }[item.state]).replace('{item}', item.label)), + item.state === 'active' || item.state === 'failed' ? metricLines.map(line => h('span', { className: css.progressStageMetric, key: line }, line)) : null, + item.state === 'active' && metricProgress !== undefined ? h('span', { className: css.progressStagePercent }, t('stageProgress').replace('{progress}', String(metricProgress))) : null))), + state === 'failed' ? h('p', { className: css.progressStageError }, localizedError(update.error ?? latest?.error ?? t('statusFailed'), t)) : null, + h('div', { className: `${css.progressLogGroupList}${group.entries.length > 0 ? ` ${css.progressLogGroupListPopulated}` : ''}`, ref: group.key === currentStage.key ? activeList : undefined }, group.entries.length === 0 + ? h('p', { className: css.progressLogEmpty }, t('noStageLogs')) + : group.entries.slice(-200).map((entry, index) => h('details', { className: css.progressLogEntry, key: `${entry.timestamp ?? ''}-${index}` }, + h('summary', null, + h('span', { className: `${css.progressLogLevel} ${css[String(entry.level ?? 'info').toLowerCase()] ?? ''}` }, (() => { const level = String(entry.level ?? 'info').toLowerCase(); return t(`level${level[0].toUpperCase()}${level.slice(1)}`) })()), + h('span', { className: css.progressLogSource }, String(entry.source ?? '-')), + h('time', null, localTime(entry.timestamp, t('localeCode'))), + h('span', { className: css.progressLogMessage }, String(entry.message ?? '-').replace(/\s+/gu, ' ')), + h('span', { className: css.progressLogChevron, 'aria-hidden': 'true' })), + h('pre', null, JSON.stringify(entry, null, 2))))), + group.key === currentStage.key ? h('div', { className: css.progressLogActions }, + h('label', { className: css.progressAutoScroll }, + h('input', { type: 'checkbox', checked: autoScroll, onChange: event => setAutoScroll(event.target.checked) }), + h('span', null, t('autoScroll'))), + h('button', { type: 'button', className: css.smallButton, disabled: currentEntries.length === 0, onClick: copyCurrent }, t(copied ? 'logsCopied' : 'copyStageLogs'))) : null) : null) + })), + h('button', { type: 'button', className: css.progressFullLog, onClick: onViewFullLog }, t('viewFullTransactionLog'))) +} + +const PROXY_SCOPES = Object.freeze([ + ['updates', 'proxyScopeUpdates', 'proxyScopeUpdatesDetail'], + ['platform', 'proxyScopePlatform', 'proxyScopePlatformDetail'], + ['dshCore', 'proxyScopeDshCore', 'proxyScopeDshCoreDetail'], + ['dshPlugins', 'proxyScopeDshPlugins', 'proxyScopeDshPluginsDetail'], + ['agentNetwork', 'proxyScopeAgent', 'proxyScopeAgentDetail'], + ['managementTerminal', 'proxyScopeTerminal', 'proxyScopeTerminalDetail'], +]) +const PROXY_TEST_LABELS = Object.freeze({ + 'proxy-address': 'proxyStageAddress', 'proxy-connect': 'proxyStageConnect', + 'proxy-handshake': 'proxyStageHandshake', 'target-dns': 'proxyStageDns', + 'target-tls': 'proxyStageTls', 'target-http': 'proxyStageHttp', +}) +const PROXY_TEST_DETAIL_LABELS = Object.freeze({ + 'route-direct': 'proxyStageDetailDirect', + 'proxy-addresses-resolved': 'proxyStageDetailAddress', + 'proxy-connected': 'proxyStageDetailConnect', + 'proxy-handshake-completed': 'proxyStageDetailHandshake', + 'target-addresses-resolved': 'proxyStageDetailDns', + 'target-tls-verified': 'proxyStageDetailTls', + 'target-http-completed': 'proxyStageDetailHttp', +}) + +function localizedProxyTestDetail(stage, t, fallback) { + const key = PROXY_TEST_DETAIL_LABELS[stage.detailCode] + if (key === undefined) return stage.detail ?? stage.errorCode ?? fallback + const values = { ...(stage.detailData ?? {}) } + if (stage.detailCode === 'target-http-completed') { + const labels = { 'github-metadata': 'proxyTargetGithub', 'npm-registry': 'proxyTargetNpm' } + values.results = (Array.isArray(values.results) ? values.results : []).map(result => ( + `${t(labels[result?.id] ?? result?.id ?? 'unknown')} HTTP ${String(result?.status ?? '-')}` + )).join(t('localeCode') === 'zh' ? ';' : ', ') + } + return t(key, values) +} + +function pendingProxyTestStages(proxy = {}) { + return Object.keys(PROXY_TEST_LABELS) + .filter(stage => stage !== 'target-dns' || (proxy.protocol === 'socks5' && proxy.remoteDns === false)) + .map(stage => ({ stage, status: 'pending' })) +} + +function proxyLines(value) { + return String(value ?? '').split(/\r?\n/u).map(entry => entry.trim()).filter(Boolean) +} + +function directRuleText(configuration) { + return [...new Set([ + ...(configuration.noProxy?.user ?? []), + ...(configuration.bypass?.additional ?? []), + ])].join('\n') +} + +function splitDirectRules(value) { + const rules = proxyLines(value) + return { + noProxy: rules.filter(rule => !/\/\d+$/u.test(rule)), + bypass: rules.filter(rule => /\/\d+$/u.test(rule)), + } +} + +function proxyConnectionDraft(configuration) { + return { + protocol: configuration.proxy.protocol, + host: configuration.proxy.host, + port: configuration.proxy.port, + username: configuration.proxy.username, + remoteDns: configuration.proxy.remoteDns === true, + allProxy: configuration.environment.allProxy === 'scope-proxy', + } +} + +function ProxySettings({ active, t }) { + const [configuration, setConfiguration] = useState(null) + const [connection, setConnection] = useState(null) + const [providers, setProviders] = useState([]) + const [providerQuery, setProviderQuery] = useState('') + const [directRulesText, setDirectRulesText] = useState('') + const [password, setPassword] = useState('') + const [clearPassword, setClearPassword] = useState(false) + const [advanced, setAdvanced] = useState(false) + const [saveState, setSaveState] = useState('') + const [task, setTask] = useState(null) + const [expandedTestStages, setExpandedTestStages] = useState(() => new Set()) + const [testError, setTestError] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const scopeDialog = useRef(null) + const systemRulesDialog = useRef(null) + const providerInfoDialog = useRef(null) + const componentDialog = useRef(null) + const testDialog = useRef(null) + const saveStateTimer = useRef(null) + const toggleScrollPositions = useRef(null) + const proxyLoad = useRef(null) + const providerLoad = useRef(null) + const [providerInfo, setProviderInfo] = useState(null) + + const refreshProviders = useCallback(async () => { + if (providerLoad.current !== null) return providerLoad.current + providerLoad.current = (async () => { + const [inventory, platformStatus] = await Promise.all([ + request('proxy/provider-inventory'), request('status'), + ]) + setProviders(inventory.providers ?? []) + const activeTask = platformStatus?.proxyTestOperation + if (activeTask?.status === 'running' && activeTask.taskId) { + setTask(await request(`proxy/test/tasks/${activeTask.taskId}`)) + } + })().catch(nextError => { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + }).finally(() => { providerLoad.current = null }) + return providerLoad.current + }, []) + + const load = useCallback(async () => { + if (proxyLoad.current !== null) return proxyLoad.current + proxyLoad.current = (async () => { + const next = await request('proxy') + setConfiguration(next) + setConnection(proxyConnectionDraft(next)) + setDirectRulesText(directRuleText(next)) + setError('') + void refreshProviders() + })().catch(nextError => { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + }).finally(() => { proxyLoad.current = null }) + return proxyLoad.current + }, [refreshProviders]) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (active) void refreshProviders() + else { + setPassword('') + setClearPassword(false) + } + }, [active, refreshProviders]) + + useEffect(() => { + if (task?.status !== 'running') return undefined + const timer = window.setTimeout(async () => { + try { setTask(await request(`proxy/test/tasks/${task.taskId}`)) } catch (nextError) { + setTestError(nextError instanceof Error ? nextError.message : String(nextError)) + } + }, 400) + return () => window.clearTimeout(timer) + }, [task]) + + useEffect(() => () => window.clearTimeout(saveStateTimer.current), []) + + useEffect(() => { + if (task && task.status !== 'running') { + setPassword('') + setClearPassword(false) + } + }, [task]) + + const patchConnection = useCallback((path, value) => { + setConnection(current => { + const next = structuredClone(current) + let target = next + for (const part of path.slice(0, -1)) target = target[part] + target[path.at(-1)] = value + return next + }) + }, []) + + const rememberToggleScroll = useCallback(event => { + if (event.type === 'keydown' && event.key !== ' ') return + if (event.type === 'mousedown') event.preventDefault() + toggleScrollPositions.current = scrollableAncestorPositions(event.currentTarget) + }, []) + + const preserveToggleScroll = useCallback((element, update) => { + const positions = toggleScrollPositions.current ?? scrollableAncestorPositions(element) + toggleScrollPositions.current = null + update() + restoreScrollableAncestors(positions) + }, []) + + const toggleScrollHandlers = { + onPointerDown: rememberToggleScroll, + onMouseDown: rememberToggleScroll, + onKeyDown: rememberToggleScroll, + } + + const toggleProxy = event => { + if (event.target.checked && (connection.host.trim() === '' || connection.port === '' || connection.port == null)) { + setError(t('proxyEndpointRequired')) + return + } + preserveToggleScroll(event.currentTarget, () => { void saveImmediate(next => { next.enabled = event.target.checked }) }) + } + + if (configuration === null || connection === null) return h('section', { className: css.section }, + h('h3', null, t('proxyTitle')), + h('p', { className: error ? css.error : undefined }, error || t('proxyLoading'))) + + const componentUnavailable = configuration.componentReady === false + + const candidate = ({ source = configuration, connectionSource = connection, rules = 'form', includeSecret = true } = {}) => { + const directRules = rules === 'form' ? splitDirectRules(directRulesText) : { + noProxy: source.noProxy?.user ?? [], bypass: source.bypass?.additional ?? [], + } + const proxy = { + protocol: connectionSource.protocol, + host: connectionSource.host.trim(), + port: connectionSource.port === '' || connectionSource.port == null ? null : Number(connectionSource.port), + username: connectionSource.username, + passwordConfigured: source.proxy.passwordConfigured === true, + remoteDns: connectionSource.remoteDns === true, + } + if (includeSecret) { + if (clearPassword) proxy.clearPassword = true + else if (password !== '') proxy.password = password + } + return { + schema: 1, enabled: source.enabled === true, proxy, + scopes: Object.fromEntries(PROXY_SCOPES.map(([id]) => [id, source.scopes[id] === true])), + environment: { allProxy: rules === 'form' ? (connectionSource.allProxy ? 'scope-proxy' : null) : source.environment.allProxy }, + modelApi: source.modelApi, + noProxy: { user: directRules.noProxy }, + bypass: { additional: directRules.bypass }, + } + } + + const saveConnection = async () => { + setBusy(true); setError('') + try { + const next = await request('proxy', { method: 'PUT', body: { baseRevision: configuration.revision, value: candidate() } }) + setConfiguration(next) + setConnection(proxyConnectionDraft(next)) + setDirectRulesText(directRuleText(next)) + setPassword('') + setClearPassword(false) + setSaveState('saved') + window.clearTimeout(saveStateTimer.current) + saveStateTimer.current = window.setTimeout(() => setSaveState(''), 3000) + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)); setSaveState('') + if (nextError?.statusCode === 409) await load() + } finally { setBusy(false) } + } + + const saveImmediate = async mutate => { + if (busy) return + const nextSource = structuredClone(configuration) + mutate(nextSource) + setConfiguration(nextSource) + setBusy(true); setError('') + try { + const saved = await request('proxy', { + method: 'PUT', body: { + baseRevision: configuration.revision, + value: candidate({ source: nextSource, connectionSource: proxyConnectionDraft(nextSource), rules: 'saved', includeSecret: false }), + }, + }) + setConfiguration(saved) + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + await load() + } finally { setBusy(false) } + } + + const startTest = async () => { + setBusy(true); setTestError('') + setExpandedTestStages(new Set()) + setTask({ status: 'starting', stages: pendingProxyTestStages(connection) }) + requestAnimationFrame(() => testDialog.current?.showModal()) + try { + const started = await request('proxy/test', { method: 'POST', body: { baseRevision: configuration.revision, value: candidate() } }) + setTask(await request(`proxy/test/tasks/${started.taskId}`)) + } catch (nextError) { + const detail = nextError instanceof Error ? nextError.message : String(nextError) + setTask(current => ({ + status: 'failed', + stages: current?.stages?.length > 0 ? current.stages : pendingProxyTestStages(), + error: { detail }, + })) + setTestError(detail) + if (nextError?.statusCode === 409) await load() + } finally { setBusy(false) } + } + + const cancelTest = async () => { + if (!task?.taskId) return + try { setTask(await request(`proxy/test/tasks/${task.taskId}`, { method: 'DELETE' })) } catch (nextError) { + setTestError(nextError instanceof Error ? nextError.message : String(nextError)) + } + } + + const taskRunning = task?.status === 'starting' || task?.status === 'running' + const formDirty = connection.protocol !== configuration.proxy.protocol + || connection.host.trim() !== configuration.proxy.host + || (connection.port === '' || connection.port == null ? null : Number(connection.port)) !== configuration.proxy.port + || connection.username !== (configuration.proxy.username ?? '') + || connection.remoteDns !== (configuration.proxy.remoteDns === true) + || connection.allProxy !== (configuration.environment.allProxy === 'scope-proxy') + || directRulesText !== directRuleText(configuration) + || password !== '' || clearPassword + const visibleSaveState = formDirty ? 'unsaved' : saveState + const visibleProviders = providers.filter(provider => { + const displayName = typeof provider.displayName === 'string' && provider.displayName.trim() !== '' ? provider.displayName : provider.id + return matchesResourceSearch(providerQuery, [displayName]) + }) + const localize = value => value?.[t('localeCode')] ?? value?.en ?? '' + const catalog = configuration.scopeCatalog + const catalogGroups = Object.entries(catalog?.groups ?? {}) + const catalogNodes = catalogGroups.map(([group, groupValue]) => { + const entries = (catalog?.entries ?? []).filter(entry => entry.group === group) + return h('section', { key: group }, + h('h4', { style: { gridRow: `1 / span ${entries.length}` } }, localize(groupValue)), + ...entries.map(entry => h('div', { key: entry.id }, + h('span', null, localize(entry.source)), + h(ExpandableProxyDescription, { text: localize(entry.detail), identity: entry.id })))) + }) + const summaryNodes = (catalog?.summaries ?? []).map((summary, index) => h('p', { key: index }, localize(summary))) + return h(React.Fragment, null, + h('section', { className: css.section, 'aria-labelledby': 'platform-proxy-master-title' }, + h('div', { className: `${css.proxySettingRow} ${css.proxyMasterRow}` }, + h('div', null, + h('div', { className: css.proxyTitleLine }, + h('h3', { id: 'platform-proxy-master-title' }, t('proxyMaster')), + configuration.componentReady === false ? h('button', { + type: 'button', className: css.proxyComponentAlert, + 'aria-label': t('proxyComponentIssueLabel'), title: t('proxyComponentIssueLabel'), + onClick: () => componentDialog.current?.showModal(), + }, h('svg', { viewBox: '0 0 24 24', 'aria-hidden': true }, + h('circle', { cx: '12', cy: '12', r: '10' }), + h('path', { d: 'M12 6.5v7' }), h('path', { d: 'M12 17.5h.01' }))) : null), + h('small', null, t('proxyMasterDetail'))), + h('label', { className: css.toggle, 'aria-label': t('proxyMaster'), ...toggleScrollHandlers }, + h('input', { type: 'checkbox', checked: configuration.enabled === true, disabled: busy || componentUnavailable, onChange: toggleProxy }), + h('span', { 'aria-hidden': 'true' }))), + error === t('proxyEndpointRequired') ? h('p', { className: css.error, role: 'alert' }, error) : null), + + h('section', { className: css.section, 'aria-labelledby': 'platform-proxy-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('div', { className: css.proxyTitleLine }, + h('h3', { id: 'platform-proxy-title' }, t('proxyTitle')), + visibleSaveState ? h('span', { className: css.proxySaveState, 'data-state': visibleSaveState, role: 'status' }, t(visibleSaveState === 'unsaved' ? 'proxyUnsaved' : 'proxySavedShort')) : null), + h('p', null, t('proxyDetail'))), + h('div', { className: css.actions }, + h('button', { type: 'button', className: css.secondaryButton, disabled: busy || taskRunning || componentUnavailable, onClick: () => { void startTest() } }, t('proxyTestStart')), + h('button', { type: 'button', className: css.primaryButton, disabled: busy || taskRunning || componentUnavailable, onClick: () => { void saveConnection() } }, t('proxySave')))), + h('div', { className: css.proxyFormGrid }, + h('div', { className: css.proxySelectField }, h('span', null, t('proxyProtocol')), h(SelectMenu, { + value: connection.protocol, + disabled: componentUnavailable, + ariaLabel: t('proxyProtocol'), + items: [{ id: 'http', label: 'HTTP' }, { id: 'socks5', label: 'SOCKS5' }], + onSelect: value => patchConnection(['protocol'], value), + className: css.proxySelectTrigger, + })), + h('label', null, h('span', null, t('proxyHost')), h('input', { value: connection.host, disabled: componentUnavailable, maxLength: 253, spellCheck: false, placeholder: '172.17.0.1', onChange: event => patchConnection(['host'], event.target.value) })), + h('label', null, h('span', null, t('proxyPort')), h('input', { type: 'number', min: 1, max: 65535, value: connection.port ?? '', disabled: componentUnavailable, placeholder: '7890', onChange: event => patchConnection(['port'], event.target.value) })), + h('label', null, h('span', null, t('proxyUsername')), h('input', { value: connection.username, disabled: componentUnavailable, maxLength: 255, autoComplete: 'off', onChange: event => patchConnection(['username'], event.target.value) })), + h('div', { className: css.proxyPasswordRow }, + h('div', { className: css.proxyPasswordField }, + h('label', null, h('span', null, t('proxyPassword')), h('input', { type: 'password', value: password, disabled: clearPassword || componentUnavailable, maxLength: 255, autoComplete: 'new-password', placeholder: t('proxyPasswordPlaceholder'), onChange: event => { setPassword(event.target.value); if (event.target.value !== '') setClearPassword(false) } })), + configuration.proxy.passwordConfigured ? h('button', { type: 'button', className: css.proxyClearPassword, disabled: componentUnavailable, 'aria-pressed': clearPassword, onClick: () => { setClearPassword(value => !value); setPassword('') } }, t('proxyClearPassword')) : null), + h('button', { type: 'button', className: css.secondaryButton, disabled: componentUnavailable, 'aria-expanded': advanced, onClick: () => setAdvanced(value => !value) }, t('proxyAdvanced'))), + ), + advanced ? h('div', { className: css.proxyAdvanced }, + h('div', { className: css.sectionHeading }, + h('div', null, h('h4', null, t('proxyRules')), h('p', null, t('proxyRulesDetail'))), + h('button', { type: 'button', className: css.secondaryButton, disabled: componentUnavailable, onClick: () => systemRulesDialog.current?.showModal() }, t('proxySystemRules'))), + h('label', { className: css.proxyDirectRulesField }, h('textarea', { rows: 5, value: directRulesText, disabled: componentUnavailable, spellCheck: false, 'aria-label': t('proxyDirectRules'), placeholder: t('proxyDirectRulesPlaceholder'), onChange: event => setDirectRulesText(event.target.value) }), h('small', null, t('proxyDirectRulesDetail'))), + h('div', { className: `${css.proxySettingRow} ${css.proxyAdvancedSetting}` }, + h('div', null, h('h4', null, t('proxyAllProxy')), h('small', null, t('proxyAllProxyDetail'))), + h('label', { className: css.toggle, 'aria-label': t('proxyAllProxy'), ...toggleScrollHandlers }, h('input', { type: 'checkbox', checked: connection.allProxy, disabled: componentUnavailable, onChange: event => preserveToggleScroll(event.currentTarget, () => patchConnection(['allProxy'], event.target.checked)) }), h('span', { 'aria-hidden': true }))), + connection.protocol === 'socks5' ? h('div', { className: `${css.proxySettingRow} ${css.proxyAdvancedSetting}` }, h('div', null, h('h4', null, t('proxyRemoteDns')), h('small', null, t('proxyRemoteDnsDetail'))), h('label', { className: css.toggle, 'aria-label': t('proxyRemoteDns'), ...toggleScrollHandlers }, h('input', { type: 'checkbox', checked: connection.remoteDns, disabled: componentUnavailable, onChange: event => preserveToggleScroll(event.currentTarget, () => patchConnection(['remoteDns'], event.target.checked)) }), h('span', { 'aria-hidden': true }))) : null) : null, + location.protocol !== 'https:' && (password !== '' || connection.username !== '') ? h('p', { className: css.notice }, t('proxyTransportWarning')) : null, + error && error !== t('proxyEndpointRequired') ? h('p', { className: css.error, role: 'alert' }, error) : null), + + h('section', { className: css.section, 'aria-labelledby': 'platform-proxy-scopes-title' }, + h('div', { className: css.sectionHeading }, h('div', null, h('h3', { id: 'platform-proxy-scopes-title' }, t('proxyScopes')), h('p', null, t('proxyScopesDetail'))), h('button', { type: 'button', className: css.secondaryButton, disabled: componentUnavailable, onClick: () => scopeDialog.current?.showModal() }, t('proxyScopeHelp'))), + h('div', { className: css.proxyScopeGrid }, PROXY_SCOPES.map(([id, title, detail]) => + h('div', { className: css.proxyScopeCard, key: id }, + h('span', { className: css.proxyScopeCopy }, + h('b', null, t(title)), + h(ExpandableProxyDescription, { text: t(detail), identity: `scope:${id}` })), + h('label', { className: css.toggle, 'aria-label': t(title), ...toggleScrollHandlers }, + h('input', { type: 'checkbox', checked: configuration.scopes[id], disabled: busy || componentUnavailable, onChange: event => preserveToggleScroll(event.currentTarget, () => { void saveImmediate(next => { next.scopes[id] = event.target.checked }) }) }), + h('span', { 'aria-hidden': true })))))), + + h('section', { className: `${css.section} ${css.proxyProviderSection}`, 'aria-labelledby': 'platform-proxy-providers-title' }, + h('div', { className: css.proxyProviderHeading }, + h('h3', { id: 'platform-proxy-providers-title' }, t('proxyProviders')), + h('div', null, + h('p', null, t('proxyProvidersDetail')), + h('input', { type: 'search', className: `${css.resourceSearch} ${css.proxyProviderSearch}`, value: providerQuery, disabled: componentUnavailable, placeholder: t('proxyProviderSearch'), 'aria-label': t('proxyProviderSearch'), onChange: event => setProviderQuery(event.target.value) }))), + visibleProviders.length === 0 ? h('p', { className: css.emptyPlugins }, t('proxyNoProviders')) : h('div', { className: css.proxyProviderList }, visibleProviders.map(provider => { + const displayName = typeof provider.displayName === 'string' && provider.displayName.trim() !== '' ? provider.displayName : provider.id + const information = provider.routingCapability === 'forced-direct' ? t('proxyProviderReasonLocal') : provider.routingCapability === 'shared-dsh' ? t('proxyProviderReasonShared') : null + const policy = configuration.modelApi.providers[provider.id] + ?? provider.requestedPolicy + ?? { proxyEnabled: false } + const setPolicy = (element, value) => preserveToggleScroll(element, () => { void saveImmediate(next => { next.modelApi.providers[provider.id] = value }) }) + return h('div', { className: css.proxyProvider, key: provider.id }, + h('div', { className: css.proxyProviderIdentity }, + h('b', null, displayName), + information === null ? null : h('button', { + type: 'button', + className: css.proxyProviderInfo, + 'aria-label': t('proxyProviderInfo', { name: displayName }), + onClick: () => { + setProviderInfo({ title: displayName, detail: information }) + requestAnimationFrame(() => providerInfoDialog.current?.showModal()) + }, + }, 'i')), + h('div', { className: css.proxyProviderControls }, + provider.routingCapability === 'provider' ? null : h('span', { className: css.proxyProviderRoute }, + t(provider.routingCapability === 'shared-dsh' ? 'proxyProviderShared' : 'proxyProviderDirect')), + h('label', { className: css.toggle, 'aria-label': displayName, ...toggleScrollHandlers }, + h('input', { + type: 'checkbox', + checked: policy.proxyEnabled === true, + disabled: provider.routingCapability === 'forced-direct' || componentUnavailable, + onChange: event => setPolicy(event.currentTarget, { proxyEnabled: event.target.checked }), + }), + h('span', { 'aria-hidden': true })))) + }))), + + h('dialog', { ref: testDialog, className: css.proxyTestDialog }, + h('div', null, + h('header', null, h('div', null, h('h3', null, t('proxyTest')), h('p', null, t('proxyTestDetail')))), + task ? h('ol', { className: css.proxyTestStages }, task.stages.map(stage => { + const status = t(`proxyStage${stage.status[0].toUpperCase()}${stage.status.slice(1)}`) + const expanded = expandedTestStages.has(stage.stage) + const toggleDetails = element => preserveScrollableAncestors(element, () => setExpandedTestStages(current => { + const next = new Set(current) + if (next.has(stage.stage)) next.delete(stage.stage) + else next.add(stage.stage) + return next + })) + return h('li', { + key: stage.stage, + 'data-state': stage.status, + role: 'button', + tabIndex: 0, + 'aria-expanded': expanded, + onClick: event => toggleDetails(event.currentTarget), + onKeyDown: event => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + toggleDetails(event.currentTarget) + }, + }, h('span', { 'aria-hidden': 'true' }), h('span', null, + h('b', null, t(PROXY_TEST_LABELS[stage.stage])), + expanded ? h('small', null, localizedProxyTestDetail(stage, t, status)) : null), + h('small', null, status)) + })) : null, + task === null && testError ? h('p', { className: css.error, role: 'alert' }, testError) : null, + task !== null || testError ? h('footer', null, + task && !taskRunning ? h('p', { className: task.status === 'failed' ? css.error : css.proxyHint, role: 'status' }, task.status === 'success' ? t('proxyTestSuccess') : task.status === 'cancelled' ? t('proxyTestCancelled') : `${t('proxyTestFailed')}: ${task.error?.detail ?? task.error?.errorCode ?? ''}`) : null, + taskRunning + ? h('button', { type: 'button', className: css.secondaryButton, disabled: !task?.taskId, onClick: () => { void cancelTest() } }, t('cancel')) + : h('button', { type: 'button', className: css.secondaryButton, onClick: () => testDialog.current?.close() }, t('close'))) : null)), + + h('dialog', { ref: scopeDialog, className: css.proxyScopeDialog }, + h('form', { method: 'dialog' }, + h('header', null, + h('div', null, h('h3', null, t('proxyScopeGuideTitle')), h('p', null, t('proxyScopeGuideDetail'))), + h('button', { value: 'cancel', className: css.secondaryButton }, t('close'))), + h('div', { className: css.proxyScopeCatalog }, catalogNodes), + h('div', { className: css.proxyScopeSummaries }, summaryNodes))), + h('dialog', { ref: providerInfoDialog, className: css.proxyProviderInfoDialog, onClick: event => { if (event.target === event.currentTarget) event.currentTarget.close() } }, + h('form', { method: 'dialog' }, + h('header', null, + h('h3', null, providerInfo?.title ?? ''), + h('button', { value: 'cancel', className: css.secondaryButton }, t('close'))), + h('p', null, providerInfo?.detail ?? ''))), + h('dialog', { ref: componentDialog, className: css.proxyProviderInfoDialog, onClick: event => { if (event.target === event.currentTarget) event.currentTarget.close() } }, + h('form', { method: 'dialog' }, + h('header', null, + h('h3', null, t('proxyComponentIssueTitle')), + h('button', { value: 'cancel', className: css.secondaryButton }, t('close'))), + h('p', null, t('proxyComponentIssueDetail')))), + h('dialog', { ref: systemRulesDialog, className: css.proxyProviderInfoDialog }, + h('form', { method: 'dialog' }, + h('header', null, + h('h3', null, t('proxySystemRulesTitle')), + h('button', { value: 'cancel', className: css.secondaryButton }, t('close'))), + h('div', { className: css.proxySystemRulesBody }, + h('p', null, t('proxySystemRulesDetail')), + h('pre', null, (configuration.noProxy?.system ?? []).join('\n')))))) +} + +function AuthenticationSettings({ active, managementConsoleHref, t }) { + const [context, setContext] = useState(null) + const [loading, setLoading] = useState(false) + const [working, setWorking] = useState(null) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + + useEffect(() => { + if (!message) return undefined + const timer = window.setTimeout(() => setMessage(''), 3_000) + return () => window.clearTimeout(timer) + }, [message]) + + const load = useCallback(async () => { + setLoading(true) + try { + setContext(await authenticationRequest('session-context')) + setError('') + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (!active) return + void load() + }, [active, load]) + + const logout = async scope => { + setWorking(scope) + setMessage('') + setError('') + const suspendedStateEvents = scope === 'all' + if (suspendedStateEvents) closePlatformStateEvents() + try { + await authenticationRequest('browser-logout', { method: 'POST', body: { scope } }) + if (scope === 'all') { + window.location.replace(`/_dsh_platform/auth/?fresh=${Date.now()}`) + return + } + setMessage(t('managementLogoutComplete')) + await load() + } catch (nextError) { + if (suspendedStateEvents) openPlatformStateEvents() + setError(nextError instanceof Error ? nextError.message : String(nextError)) + } finally { + setWorking(null) + } + } + + return h(React.Fragment, null, + h('section', { className: css.section, 'aria-labelledby': 'platform-account-access-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-account-access-title' }, t('accountAccess')), + h('p', null, t('accountAccessDetail'))), + h('a', { + className: `${css.secondaryButton} ${css.authenticationButton}`, + href: managementConsoleHref === null ? undefined : `${managementConsoleHref}#auth-settings`, + 'aria-disabled': managementConsoleHref === null, + onClick: event => { if (managementConsoleHref === null) event.preventDefault() }, + target: '_blank', + rel: 'noopener noreferrer', + }, t('openPlatformManagement'))), + h('p', { className: css.commandHint }, + h('span', null, t('rootResetDetail')), + h('code', null, 'dsh-platform access reset'))), + h('section', { className: css.section, 'aria-labelledby': 'platform-session-access-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-session-access-title' }, t('sessionAccess')), + h('p', null, t('sessionAccessDetail'))), + h('div', { className: css.actions }, + context?.managementAdditionalPasswordEnabled ? h('button', { + type: 'button', + className: `${css.secondaryButton} ${css.authenticationButton}`, + disabled: loading || working !== null, + onClick: () => { void logout('management') }, + }, working === 'management' ? t('loggingOut') : t('logoutManagementOnly')) : null, + h('button', { + type: 'button', + className: `${css.dangerButton} ${css.authenticationButton}`, + disabled: loading || working !== null, + onClick: () => { void logout('all') }, + }, working === 'all' ? t('loggingOut') : t('logoutAll')))), + loading && context === null ? h('p', { className: css.maintenanceStatus }, t('loadingAuthentication')) : null, + message ? h('p', { className: css.maintenanceStatus, role: 'status' }, message) : null, + error ? h('p', { className: css.error, role: 'alert' }, error) : null)) +} + +function PlatformManagement({ t }) { + const [activeTab, setActiveTab] = useState('maintenance') + const [status, setStatus] = useState(null) + const [plugins, setPlugins] = useState([]) + const [systemPluginDraft, setSystemPluginDraft] = useState(() => new Map()) + const [systemPluginApplyingDraft, setSystemPluginApplyingDraft] = useState(() => new Map()) + const [systemPluginProgress, setSystemPluginProgress] = useState(null) + const [skills, setSkills] = useState([]) + const [error, setError] = useState('') + const [connection, setConnection] = useState('connecting') + const [acting, setActing] = useState(false) + const [checking, setChecking] = useState(false) + const [showSuccessfulProgress, setShowSuccessfulProgress] = useState(false) + const [dismissedProgressTaskId, setDismissedProgressTaskId] = useState(null) + const [confirmRestart, setConfirmRestart] = useState(false) + const [focusedLogTaskId, setFocusedLogTaskId] = useState(null) + const [managementConsoleHref, setManagementConsoleHref] = useState(null) + const statusLoad = useRef() + const statusLoadRevision = useRef(0) + const inventoryLoads = useRef({ plugins: undefined, skills: undefined }) + const inventoryLoadRevisions = useRef({ plugins: 0, skills: 0 }) + const activeTabRef = useRef(activeTab) + const tabsRef = useRef(null) + activeTabRef.current = activeTab + + useEffect(() => makeHorizontalTabStripScrollable(tabsRef.current), []) + + useEffect(() => { + const refreshManagementConsoleHref = () => { + void authenticationRequest('session-context') + .then(value => setManagementConsoleHref(value.managementConsoleHref ?? '/_dsh_platform/console/')) + .catch(() => setManagementConsoleHref(null)) + } + refreshManagementConsoleHref() + window.addEventListener('focus', refreshManagementConsoleHref) + return () => window.removeEventListener('focus', refreshManagementConsoleHref) + }, []) + + const refresh = useCallback(() => { + statusLoadRevision.current += 1 + if (statusLoad.current !== undefined) return statusLoad.current + statusLoad.current = (async () => { + let value + let loadedRevision + do { + loadedRevision = statusLoadRevision.current + try { + const nextStatus = await request('status') + value = nextStatus + setStatus(nextStatus) + setError('') + setConnection('online') + } catch (nextError) { + setStatus(null) + setError(nextError instanceof Error ? nextError.message : String(nextError)) + setConnection('offline') + value = undefined + } + } while (loadedRevision !== statusLoadRevision.current) + return value + })().finally(() => { statusLoad.current = undefined }) + return statusLoad.current + }, []) + + const refreshInventory = useCallback(key => { + inventoryLoadRevisions.current[key] += 1 + if (inventoryLoads.current[key] !== undefined) return inventoryLoads.current[key] + const loader = key === 'plugins' + ? { path: 'bundled-plugins', apply: value => setPlugins(value.plugins ?? []) } + : { path: 'system-skills', apply: value => setSkills(value.skills ?? []) } + inventoryLoads.current[key] = (async () => { + let loadedRevision + do { + loadedRevision = inventoryLoadRevisions.current[key] + try { + loader.apply(await request(loader.path)) + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + } + } while (loadedRevision !== inventoryLoadRevisions.current[key]) + })().finally(() => { inventoryLoads.current[key] = undefined }) + return inventoryLoads.current[key] + }, []) + + const act = useCallback(async (path, options) => { + setActing(true) + setError('') + try { + await request(path, options) + await refresh() + return true + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + return false + } finally { + setActing(false) + } + }, [refresh]) + + const checkUpdates = useCallback(async (source = 'manual') => { + setChecking(true) + setError('') + try { + await request('check', { method: 'POST', body: { source } }) + await refresh() + return true + } catch (nextError) { + const nextStatus = await refresh() + if (nextStatus === undefined) setError(nextError instanceof Error ? nextError.message : String(nextError)) + return false + } finally { + setChecking(false) + } + }, [refresh]) + + const changeChannel = useCallback(async channel => { + if (await act('channel', { method: 'PUT', body: { channel } })) void checkUpdates('channel-change') + }, [act, checkUpdates]) + + const restartDsh = useCallback(async () => { + setActing(true) + setError('') + try { + await request('restart-dsh', { method: 'POST' }) + window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) + setConfirmRestart(false) + await refresh() + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + } finally { + setActing(false) + } + }, [refresh]) + + const manageSystemPlugin = useCallback((plugin, action) => { + setSystemPluginProgress(null) + setSystemPluginApplyingDraft(new Map()) + setSystemPluginDraft(current => { + const next = new Map(current) + if ((action === 'install' && plugin.installed) + || (action === 'enable' && plugin.enabled) + || (action === 'disable' && !plugin.enabled)) next.delete(plugin.id) + else next.set(plugin.id, action) + return next + }) + }, []) + + const waitForSystemPluginTask = useCallback(async taskId => { + for (let attempt = 0; attempt < 2_400; attempt += 1) { + const operation = await request(`bundled-plugins/task/${taskId}`) + if (operation?.taskId === taskId && operation.status !== 'running') return operation + await new Promise(resolve => window.setTimeout(resolve, 250)) + } + throw new Error('System Plugin task timed out') + }, []) + + const cancelSystemPluginChanges = useCallback(async () => { + setSystemPluginProgress(null) + setSystemPluginApplyingDraft(new Map()) + setSystemPluginDraft(new Map()) + if (plugins.some(plugin => plugin.pendingRestart)) { + await act('bundled-plugins/discard', { method: 'POST' }) + await refreshInventory('plugins') + } + window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) + }, [act, plugins, refreshInventory]) + + const applySystemPluginChanges = useCallback(async () => { + if (systemPluginDraft.size === 0) { + if (plugins.some(plugin => plugin.pendingRestart)) await restartDsh() + return + } + setActing(true) + setSystemPluginApplyingDraft(new Map(systemPluginDraft)) + setError('') + let changed = false + try { + const changes = [...systemPluginDraft] + for (const [index, [id, action]] of changes.entries()) { + setSystemPluginProgress({ phase: 'applying', id, action, current: index + 1, total: changes.length }) + const plugin = plugins.find(item => item.id === id) + if (plugin === undefined) throw new Error(`System Plugin ${id} is no longer available`) + const path = plugin.protected ? 'bundled-plugins/recovery-action' : 'bundled-plugins/action' + const task = await request(path, { method: 'POST', body: { id, action } }) + changed = true + window.sessionStorage.setItem(PLUGIN_DRAFT_KEY, '1') + const operation = await waitForSystemPluginTask(task.taskId) + if (operation.status !== 'success') throw new Error(operation.error ?? 'System Plugin operation failed') + } + setSystemPluginDraft(new Map()) + const restartTask = await request('restart-dsh', { method: 'POST' }) + setSystemPluginProgress({ phase: 'restarting', total: changes.length, taskId: restartTask.taskId }) + window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) + await refresh() + } catch (nextError) { + setSystemPluginProgress(null) + if (changed) await request('bundled-plugins/discard', { method: 'POST' }).catch(() => {}) + window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) + setError(nextError instanceof Error ? nextError.message : String(nextError)) + } finally { + await refreshInventory('plugins') + setActing(false) + } + }, [plugins, refresh, refreshInventory, restartDsh, systemPluginDraft, waitForSystemPluginTask]) + + useEffect(() => { + if (systemPluginProgress?.phase !== 'restarting' || systemPluginProgress.taskId === undefined) return + const lifecycle = status?.dshLifecycle + if (lifecycle?.taskId !== systemPluginProgress.taskId) return + if (['running', 'failed', 'stopped'].includes(lifecycle.state)) setSystemPluginProgress(null) + }, [status?.dshLifecycle, systemPluginProgress]) + + const manageSystemSkill = useCallback(async (skill, action) => { + if (await act('system-skills/action', { method: 'POST', body: { skillId: skill.id, action } })) { + await refreshInventory('skills') + } + }, [act, refreshInventory]) + + useEffect(() => { + if (activeTab === 'plugins' || activeTab === 'skills') void refreshInventory(activeTab) + }, [activeTab, refreshInventory]) + + useEffect(() => { + let checkedOnOpen = false + const unsubscribeEvents = subscribePlatformStateEvents({ + state: () => { + void refresh() + const inventoryKey = activeTabRef.current + if (inventoryKey === 'plugins' || inventoryKey === 'skills') void refreshInventory(inventoryKey) + }, + open: () => setConnection('online'), + error: () => { + setConnection('connecting') + void refresh() + }, + }) + const refreshAndConnect = async () => { + const value = await refresh() + if (value !== undefined) { + if (!checkedOnOpen && TERMINAL.has(value.update?.status ?? 'idle')) { + checkedOnOpen = true + void checkUpdates('page-open') + } + } + return value + } + + void (async () => { + if (window.sessionStorage.getItem(PLUGIN_DRAFT_KEY) === '1') { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + await request('bundled-plugins/discard', { method: 'POST' }) + window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) + break + } catch { + await new Promise(resolve => window.setTimeout(resolve, 100)) + } + } + } + await refreshAndConnect() + })() + + const timer = window.setInterval(() => { void refreshAndConnect() }, 15_000) + const focus = () => { void refreshAndConnect() } + window.addEventListener('focus', focus) + return () => { + window.clearInterval(timer) + window.removeEventListener('focus', focus) + unsubscribeEvents() + } + }, [checkUpdates, refresh, refreshInventory]) + + const update = status?.update ?? {} + const restart = status?.dshLifecycle ?? {} + const pluginOperation = status?.systemPluginOperation ?? {} + const skillOperation = status?.systemSkillOperation ?? {} + const checkingUpdates = checking || update.status === 'checking' + const restartBusy = restart.state === 'restarting' + const busy = (acting && !checking) || restartBusy || pluginOperation.status === 'running' || skillOperation.status === 'running' + || (!TERMINAL.has(update.status ?? 'idle') && update.status !== 'checking') + const updateActive = !TERMINAL.has(update.status ?? 'idle') + useEffect(() => { + if (update.status !== 'success' || !update.taskId) { + setShowSuccessfulProgress(false) + return undefined + } + setShowSuccessfulProgress(true) + const timer = window.setTimeout(() => setShowSuccessfulProgress(false), 3_000) + return () => window.clearTimeout(timer) + }, [update.status, update.taskId, update.updatedAt]) + const failedDismissed = update.status === 'failed' && String(update.taskId ?? '') === dismissedProgressTaskId + const progressVisible = updateActive || (update.status === 'failed' && Boolean(update.taskId) && !failedDismissed) || showSuccessfulProgress + const hasSupportedTarget = status?.supported !== null && status?.supported !== undefined + const progress = Math.max(0, Math.min(100, Number(update.progress) || 0)) + const progressModel = updateProgressModel(update, t) + const recoveryProgress = isRecoveryOperation(update.operation) + const checkingProgress = update.operation === 'check' + const progressTarget = status?.updateChannel === 'experimental' ? status?.upstream?.version : status?.supported?.dsh + const holds = [...new Map([ + ...(status?.holds ?? []), + ...(status?.experimentalBlocked ? [status.experimentalBlocked] : []), + ].map(hold => [hold.id, hold])).values()] + const notices = [] + if (status?.aheadOfStable) notices.push(t('aheadOfStable')) + if (status?.experimentalBlocked) notices.push(t('experimentalBlocked')) + const automaticCheck = status?.automaticCheck ?? { enabled: true, intervalSeconds: 21_600, notificationsEnabled: true } + + const saveAutomaticCheck = async change => { + await act('automatic-check', { + method: 'PUT', + body: { ...automaticCheck, ...change }, + }) + } + + return h('div', { className: css.root }, + h('div', { className: css.platformHeader }, + h('div', { className: css.heading }, + h('div', { className: css.titleRow }, + h('h2', { className: css.title }, t('title')), + h('span', { className: `${css.connection} ${css[connection]}`, role: 'status' }, + h('span', { 'aria-hidden': 'true' }), + t(connection))), + h('p', { className: css.intro }, t('intro'))), + + h('div', { ref: tabsRef, className: css.tabs, role: 'tablist', 'aria-label': t('managementSections') }, + ['maintenance', 'plugins', 'skills', 'proxy', 'updates', 'authentication'].map(tab => h('button', { + key: tab, + id: `platform-tab-${tab}-button`, + type: 'button', + role: 'tab', + 'aria-selected': activeTab === tab, + 'aria-controls': `platform-tab-${tab}`, + tabIndex: activeTab === tab ? 0 : -1, + onClick: () => setActiveTab(tab), + }, t(`${tab}Tab`))))), + + h('div', { + id: 'platform-tab-authentication', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-authentication-button', + hidden: activeTab !== 'authentication', + }, h(AuthenticationSettings, { active: activeTab === 'authentication', managementConsoleHref, t })), + + h('div', { + id: 'platform-tab-proxy', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-proxy-button', + hidden: activeTab !== 'proxy', + }, h(ProxySettings, { active: activeTab === 'proxy', t })), + + h('div', { + id: 'platform-tab-updates', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-updates-button', + hidden: activeTab !== 'updates', + }, + h('section', { className: css.section, 'aria-labelledby': 'platform-channel-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-channel-title' }, t('channel')), + h('p', null, t('channelDetail'))), + h('div', { className: css.segmented, role: 'group', 'aria-label': t('channel') }, + ['stable', 'experimental'].map(channel => h('button', { + key: channel, + type: 'button', + 'aria-pressed': status?.updateChannel === channel, + disabled: busy, + onClick: () => { void changeChannel(channel) }, + }, t(channel))))), + h('div', { className: `${css.versions} ${status?.updateChannel === 'experimental' ? css.experimentalVersions : ''}` }, + h(VersionCell, { label: t('current'), version: status?.current?.dsh, detail: displayEnvironment(status?.current?.environment) }), + h(VersionCell, { label: t('supported'), version: status?.supported?.dsh, detail: displayEnvironment(status?.supported?.environment) }), + status?.updateChannel === 'experimental' + ? h(VersionCell, { label: t('upstream'), version: status?.upstream?.version, detail: t('officialNpm') }) + : null), + notices.length > 0 ? h('p', { className: css.notice }, notices.join(' ')) : null, + error ? h('p', { className: css.error, role: 'alert' }, localizedError(error, t)) : null), + + h('section', { className: css.section, 'aria-labelledby': 'platform-actions-title' }, + h('div', { className: `${css.sectionHeading} ${css.actionHeading}` }, + h('div', null, + h('h3', { id: 'platform-actions-title' }, t('actions')), + h('p', null, update.checkedAt ? `${t('lastChecked')} ${localTime(update.checkedAt, t('localeCode'))}` : t('notChecked'))), + h('div', { className: css.actions }, + h('button', { type: 'button', className: css.secondaryButton, disabled: busy, onClick: () => { void checkUpdates('manual') } }, + checkingUpdates ? h('span', { className: css.checkSpinner, 'aria-hidden': 'true' }) : null, + checkingUpdates ? t('checking') : t('check')), + h('button', { type: 'button', className: css.primaryButton, disabled: busy || update.metadataUnavailable || !hasSupportedTarget || update.updateAvailable !== true, onClick: () => { void act('update', { method: 'POST' }) } }, status?.updateChannel === 'experimental' ? t('updateUpstream') : t('updateSupported')), + )), + h('div', { className: css.updateState, 'aria-live': 'polite' }, + !progressVisible && !failedDismissed && (update.error || update.outcome) ? h('p', null, update.error ? localizedError(update.error, t) : updateOutcome(update.outcome, t)) : null, + progressVisible ? h('div', { className: css.updateProgress }, + h('div', { className: css.progressHeading }, + h('strong', null, checkingProgress + ? t('statusChecking') + : recoveryProgress ? progressModel.title : progressTarget ? t('updateToTarget').replace('{target}', String(progressTarget)) : progressModel.title), + h('span', { className: css.progressHeadingActions }, + h('output', null, `${String(progress)}%`), + update.status === 'failed' ? h('button', { type: 'button', className: css.smallButton, onClick: () => setDismissedProgressTaskId(String(update.taskId ?? '')) }, t('dismissProgress')) : null)), + h('div', { className: css.progress, 'data-complete': progress === 100, role: 'progressbar', 'aria-label': t('progress'), 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': progress }, + h('span', { style: { width: `${String(progress)}%` } })), + h(TransactionStageLogs, { + update, + visible: progressVisible, + t, + onViewFullLog: () => { + setFocusedLogTaskId(update.taskId ?? null) + setActiveTab('maintenance') + }, + })) : null), + update.metadataUnavailable ? h('p', { className: css.notice }, t('metadataUnavailable')) : null, + update.remoteCheckError ? h('p', { className: css.notice, role: 'status' }, t(update.remoteCheckSource === 'upstream' + ? (update.upstream?.version ? 'upstreamCheckFailed' : 'upstreamCheckFailedNoResult') + : (update.checkedAt ? 'remoteCheckFailed' : 'remoteCheckFailedNoResult'))) : null, + holds.length > 0 ? h('div', { className: css.holds }, + holds.map(hold => h('div', { className: css.hold, key: hold.id }, + h('div', null, + h('strong', null, `${display(hold.dshVersion)}${hold.environmentVersion ? ` + ${displayEnvironment(hold.environmentVersion)}` : ''}`), + h('span', null, localizedHoldReason(hold, t))), + h('button', { type: 'button', className: css.smallButton, disabled: busy, onClick: () => { void act('holds/retry', { method: 'POST', body: { id: hold.id } }) } }, t('retry'))))) : null, + null), + + h('section', { className: css.section, 'aria-labelledby': 'automatic-check-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'automatic-check-title' }, t('automaticChecks')), + h('p', null, t('automaticChecksDetail'))), + h('label', { className: css.toggle }, + h('input', { type: 'checkbox', checked: automaticCheck.enabled, disabled: acting, onChange: event => { void saveAutomaticCheck({ enabled: event.target.checked }) } }), + h('span', { 'aria-hidden': 'true' }), + h('b', null, automaticCheck.enabled ? t('enabled') : t('disabled')))), + h('div', { className: css.settingRows }, + h('div', { className: css.settingRow }, + h('span', null, t('checkInterval')), + h(SelectMenu, { + value: automaticCheck.intervalSeconds, + disabled: acting || !automaticCheck.enabled, + ariaLabel: t('checkInterval'), + items: [3_600, 10_800, 21_600, 43_200, 86_400].map(seconds => ({ id: String(seconds), label: t(`interval${String(seconds)}`) })), + onSelect: value => { void saveAutomaticCheck({ intervalSeconds: Number(value) }) }, + className: css.settingSelectTrigger, + })), + h('div', { className: css.settingRow }, + h('span', null, + h('b', null, t('updateNotifications')), + h('small', null, t('updateNotificationsDetail'))), + h('label', { className: css.toggle, 'aria-label': t('updateNotifications') }, + h('input', { type: 'checkbox', checked: automaticCheck.notificationsEnabled, disabled: acting, onChange: event => { void saveAutomaticCheck({ notificationsEnabled: event.target.checked }) } }), + h('span', { 'aria-hidden': 'true' })))))), + + h('div', { + id: 'platform-tab-maintenance', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-maintenance-button', + hidden: activeTab !== 'maintenance', + }, + h('section', { className: css.section, 'aria-labelledby': 'platform-maintenance-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-maintenance-title' }, t('standaloneManagement')), + h('p', null, t('standaloneManagementDetail'))), + h('a', { + className: `${css.secondaryButton} ${css.maintenanceButton}`, + href: managementConsoleHref ?? undefined, + 'aria-disabled': managementConsoleHref === null, + onClick: event => { if (managementConsoleHref === null) event.preventDefault() }, + target: '_blank', + rel: 'noopener noreferrer', + }, t('openPlatformManagement')))), + h('section', { className: css.section, 'aria-labelledby': 'platform-restart-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-restart-title' }, t('restartDshSection')), + h('p', null, t('restartDshDetail'))), + h('button', { + type: 'button', + className: `${css.secondaryButton} ${css.maintenanceButton}`, + disabled: busy, + 'aria-controls': 'restart-dsh-confirmation', + 'aria-expanded': confirmRestart, + onClick: () => setConfirmRestart(value => !value), + }, restartBusy ? t('restarting') : t(confirmRestart ? 'cancelRestartDsh' : 'restartDsh'))), + restart.state === 'failed' + ? h('p', { className: css.error, role: 'alert' }, `${t('restartFailed')}: ${localizedError(restart.error, t)}`) + : restartBusy + ? h('p', { className: css.maintenanceStatus, 'aria-live': 'polite' }, t('restarting')) + : null, + confirmRestart ? h('div', { id: 'restart-dsh-confirmation', className: `${css.confirmation} ${css.restartConfirmation}`, role: 'alertdialog', 'aria-labelledby': 'restart-dsh-title' }, + h('h4', { id: 'restart-dsh-title' }, t('restartTitle')), + h('p', null, t('restartWarning')), + h('div', { className: css.confirmActions }, + h('button', { type: 'button', className: css.secondaryButton, onClick: () => setConfirmRestart(false) }, t('cancel')), + h('button', { type: 'button', className: css.primaryButton, disabled: busy, onClick: () => { void restartDsh() } }, t('confirmRestart')))) : null), + h(LogViewer, { active: activeTab === 'maintenance', focusTaskId: focusedLogTaskId, t })), + + h('div', { + id: 'platform-tab-plugins', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-plugins-button', + hidden: activeTab !== 'plugins', + }, h(SystemPluginManager, { + plugins, + draft: systemPluginDraft, + applyingDraft: systemPluginApplyingDraft, + progress: systemPluginProgress, + operation: pluginOperation, + busy, + error, + onAction: (plugin, action) => { void manageSystemPlugin(plugin, action) }, + onCancel: () => { void cancelSystemPluginChanges() }, + onApply: () => { void applySystemPluginChanges() }, + t, + })), + + h('div', { + id: 'platform-tab-skills', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-skills-button', + hidden: activeTab !== 'skills', + }, h(SystemSkillManager, { + skills, + operation: skillOperation, + busy, + error, + onAction: (skill, action) => { void manageSystemSkill(skill, action) }, + t, + }))) +} + +function apply(ctx) { + const syncLocaleCookie = snapshot => { persistLocale(snapshot.active) } + syncLocaleCookie(ctx.locale.getLocale()) + ctx.on('locale/change', syncLocaleCookie) + ctx.effect(() => ctx.locale.register('settings.dshPlatformManagement', { + zh: { + localeCode: 'zh', + nav: '平台管理', title: '平台管理', intro: 'DSH Docker 运行、更新与恢复', + managementSections: '平台管理功能', updatesTab: '更新管理', maintenanceTab: '运行维护', pluginsTab: '系统插件', skillsTab: '系统技能', authenticationTab: '认证设置', proxyTab: '代理设置', + accountAccess: '账户与密码', accountAccessDetail: '修改用户名或密码需要前往 DSH 管理中心,也可以在容器 Root 终端中完成。', rootResetDetail: 'Root 终端命令:', sessionAccess: '退出登录', sessionAccessDetail: '退出当前浏览器中的 DSH 和管理中心登录会话。', logoutAll: '退出登录', logoutManagementOnly: '仅退出管理中心', loggingOut: '正在退出', loadingAuthentication: '正在读取认证状态…', managementLogoutComplete: '管理中心登录已退出。', + proxyMaster: '使用代理', proxyMasterDetail: '关闭时,所有受管流量范围均直连。', proxyEndpointRequired: '请先填写代理主机和端口。', + proxyTitle: '代理设置', proxyDetail: '配置由 DSH Docker 使用的外部 HTTP 或 SOCKS5 代理。', proxyLoading: '正在加载代理配置…', proxyAdvanced: '高级', proxyUnsaved: '未保存', proxySavedShort: '已保存', proxyProtocol: '代理协议', proxyHost: '主机', proxyPort: '端口', proxyUsername: '用户名', proxyPassword: '密码', proxyPasswordPlaceholder: '留空则保持当前密码', proxyRemoteDns: '通过代理解析域名', proxyRemoteDnsDetail: '仅适用于 SOCKS5;关闭时由容器本地解析。', proxyClearPassword: '清除密码', proxyTransportWarning: '当前页面未使用 HTTPS。代理凭据会受到传输链路保护能力的限制。', proxyComponentUnavailable: '出站代理组件当前不可用。', proxyComponentIssueLabel: '查看出站代理组件异常', proxyComponentIssueTitle: '出站代理组件不可用', proxyComponentIssueDetail: 'DSH Docker 的出站代理组件当前不可用。当前配置仅供查看,保存、连接测试和受管代理流量将在组件恢复后可用。这不代表已配置的外部代理是否可连接。', + proxyScopes: '代理范围', proxyScopesDetail: '仅勾选需要通过外部代理访问网络的来源。', proxyScopeHelp: '范围说明', proxyScopeGuideTitle: '代理范围说明', proxyScopeGuideDetail: '此表由管理后端提供,两套管理界面使用相同分类。', proxyScopeUpdates: '更新管理', proxyScopeUpdatesDetail: '更新检查、npm metadata 与 Artifact 下载。', proxyScopePlatform: '平台组件', proxyScopePlatformDetail: 'Management、Updater 和 DSH Docker 系统插件等的非本地外部请求。', proxyScopeDshCore: 'DSH 核心', proxyScopeDshCoreDetail: 'DSH 核心联网,不包括模型 Provider API。', proxyScopeDshPlugins: 'DSH 插件', proxyScopeDshPluginsDetail: 'DSH 官方插件与用户安装的第三方插件联网。', proxyScopeAgent: 'Agent 联网操作', proxyScopeAgentDetail: 'Agent 工具、命令与其子进程的联网。', proxyScopeTerminal: '容器终端', proxyScopeTerminalDetail: 'DSH 管理中心提供的容器终端。', + proxyRules: '直连规则', proxyRulesDetail: '列出的目标不会使用外部代理。', proxyDirectRules: '附加直连规则', proxyDirectRulesDetail: '每行一个主机、域后缀、IP 地址或 CIDR;使用 .google.com,不使用 *.google.com。', proxyDirectRulesPlaceholder: '.example.com\n10.0.0.0/8', proxySystemRules: '内置规则', proxySystemRulesTitle: '内置直连规则', proxySystemRulesDetail: '以下平台托管的本地目标始终直连,无需重复填写。', proxyAllProxy: '为兼容客户端注入 ALL_PROXY', proxyAllProxyDetail: '仅对明确支持 ALL_PROXY 的客户端有效;默认关闭。', proxyProviders: '模型 Provider', proxyProvidersDetail: '可识别的 DSH llm/stream Provider 可分别选择直连或独立代理。', proxyProviderSearch: '搜索已配置的 Provider', proxyNoProviders: '没有符合条件的已配置 Provider。', proxyProviderDirect: '强制直连', proxyProviderIndependent: '独立代理', proxyProviderShared: '跟随 DSH', proxyProviderInfo: '查看 {name} 的路由说明', proxyProviderReasonLocal: '本地 Provider 强制直连。', proxyProviderReasonShared: '当前客户端无法稳定携带 Provider 身份。开关开启时跟随 DSH 共享流量策略,关闭时直连。', + proxyTest: '代理连接测试', proxyTestDetail: '测试当前表单,不会覆盖已保存配置。', proxyTestStart: '测试连接', proxySave: '保存并应用', proxySaving: '正在保存代理设置', proxySaved: '代理设置已保存', proxyTestSuccess: '代理连接测试通过。', proxyTestFailed: '代理连接测试失败', proxyTestCancelled: '代理连接测试已取消。', proxyStageAddress: '解析代理地址', proxyStageConnect: '连接代理', proxyStageHandshake: '代理握手', proxyStageDns: '解析目标域名', proxyStageTls: '建立目标 TLS', proxyStageHttp: '请求目标服务', proxyStagePending: '待测试', proxyStageRunning: '测试中', proxyStageSuccess: '已通过', proxyStageFailed: '失败', proxyStageCancelled: '已取消', proxyStageSkipped: '已跳过', proxyStageDetailDirect: '当前候选配置使用直连。', proxyStageDetailAddress: '已解析 {count} 个代理地址记录。', proxyStageDetailConnect: '已成功连接代理服务器。', proxyStageDetailHandshake: '已验证 {protocol} 代理协议与认证。', proxyStageDetailDns: '已解析 {count} 个目标地址。', proxyStageDetailTls: '已验证 {count} 个目标的 TLS 连接。', proxyStageDetailHttp: '{results}', proxyTargetGithub: 'GitHub metadata', proxyTargetNpm: 'npm registry', + channel: '更新通道', channelDetail: '实验通道仅更新 DSH,平台环境仍使用正式支持版本。', returnStableProgress: '返回稳定通道', + stable: '稳定', experimental: '实验', current: '当前版本', supported: '正式支持版本', upstream: '上游版本', officialNpm: 'npm 官方源', + actions: '更新操作', lastChecked: '上次检查', notChecked: '尚未检查', check: '检查更新', checking: '检查中', updateSupported: '更新到最新支持版本', updateUpstream: '更新到最新上游版本', rollback: '回滚到上一版本', returnStable: '立即返回稳定通道', retry: '重试', progress: '更新进度', + updateProgress: '更新进度', rollbackProgress: '回滚进度', updateToTarget: '更新到 {target}', progressPrepare: '准备更新', progressAcquire: '下载与验证', progressBuild: '构建 Runtime', progressActivate: '切换与健康检查', stageLogs: '阶段日志', hideStageLogs: '收起日志 · {count} 条', showStageLogs: '查看日志 · {count} 条', noStageLogs: '当前阶段暂无日志', copyStageLogs: '复制当前日志', logsCopied: '已复制', viewFullTransactionLog: '查看完整事务日志', stageCompleted: '阶段已完成。', stageWaiting: '等待前一阶段完成。', stageProgress: '阶段进度 {progress}%', stageItemsCompleted: '已完成 {completed}/{total} 项', expandStage: '展开 · 日志 {count} 条', collapseStage: '收起 · 日志 {count} 条', stageItemCompleted: '已完成:{item}', stageItemActive: '正在执行:{item}', stageItemPending: '待执行:{item}', stageItemFailed: '执行失败:{item}', itemVerifyMetadata: '验证 metadata', itemVerifyKeyring: '验证 keyring', itemVerifyTarget: '验证目标清单', itemDownloadArtifacts: '下载 Artifact', itemVerifyArtifacts: '验证 Artifact 签名、引用、大小和 Hash', itemImportObjects: '导入可信对象库', itemMaterializePristine: '物化 Pristine DSH', itemPrepareEnvironment: '准备 Environment', itemBuildRuntime: '构建 Runtime 并应用完整 Patch Set', itemPreparePlugins: '准备 System Plugin Set', itemSwitchDeployment: '原子切换 Deployment', itemCheckHealth: '检查服务健康状态', itemObserveProbation: '观察候选 Runtime', metadataVerified: 'metadata 已验证。', keyringVerified: 'keyring 已验证。', targetManifestVerified: '目标清单已验证。', artifactDownloadCompleted: 'Artifact 下载已完成。', artifactVerificationCompleted: 'Artifact 签名、引用和 Hash 已验证。', runtimeMaterialized: 'Pristine DSH 已物化。', patchSetApplied: '完整 Patch Set 已应用。', systemPluginsPrepared: 'System Plugin Set 已准备。', deploymentSwitched: 'Deployment 已原子切换。', healthChecksPassed: '服务健康检查已通过。', metricBytesRead: '已读取 {processed} / {total}', metricBytesCopied: '已复制 {processed} / {total}', metricBytesProcessed: '已处理 {processed} / {total}', metricArtifacts: '已验证 {processed} / {total} 个 Artifact', metricFiles: '已完成 {processed} / {total} 个文件', metricItems: '已完成 {processed} / {total} 项', metricServices: '已就绪 {ready} / {total} 个服务', rollbackPrepare: '准备回滚', rollbackSwitch: '切换上一版本', rollbackData: '恢复数据', rollbackVerify: '启动与检查', + dismissProgress: '关闭', itemValidateRollback: '验证回滚计划和上一完整 Deployment', itemPauseRuntime: '暂停当前 DSH Runtime', itemSwitchPrevious: '切换上一完整 Deployment', itemVerifySnapshot: '验证数据快照', itemRestoreSnapshot: '恢复数据快照', itemStartRuntime: '启动上一 DSH Runtime', metricBytesRestored: '已恢复 {processed} / {total}', metricProbationRemaining: '剩余观察 {seconds} 秒', + progressDetailChecking: '正在获取并验证最新的签名更新信息。', progressDetailPlanning: '正在计算需要收敛的完整目标状态。', progressDetailUpstream: '正在查询 npm 官方源中的最新 DSH。', progressDetailDownloading: '正在下载 Artifact,并通过 Stage-0 导入可信对象库。', progressDetailValidating: '正在验证签名、Artifact 引用、大小和内容 Hash。', progressDetailBuilding: '正在从 Pristine DSH、补丁和系统插件构建不可变 Runtime。', progressDetailSnapshot: 'DSH 已暂停,正在为实验更新创建完整数据快照。', progressDetailSwitching: '正在原子切换完整 Deployment,并检查 DSH 是否就绪。', progressDetailProbation: '候选 Runtime 正在持续接受健康检查,观察至 {until}。', + rollbackDetailPreparing: '正在验证回滚计划与上一完整 Deployment。', rollbackDetailStopping: '正在暂停 DSH,准备恢复上一完整状态。', rollbackDetailSwitching: '正在切换上一 Runtime、Environment 和系统插件集合。', rollbackDetailData: '正在校验并恢复更新前的数据快照。', rollbackDetailVerifying: '正在启动 DSH 并执行健康检查。', + statusIdle: '等待操作', statusChecking: '正在检查更新', statusPlanning: '正在准备更新', statusCheckingUpstream: '正在检查上游版本', statusDownloading: '正在下载', statusValidating: '正在验证', statusBuildingCandidate: '正在构建候选版本', statusSnapshottingData: '正在备份数据', statusSwitching: '正在切换版本', statusProbation: '正在观察运行状态', statusRestoringData: '正在恢复数据', statusRollingBack: '正在回滚', statusSuccess: '操作完成', statusFailed: '操作失败', statusUnknown: '正在处理', + outcomeNone: '当前已是最新版本', outcomeFrozen: '等待正式支持版本追上当前版本', outcomeHeld: '此版本已暂停更新', outcomeBlocked: '当前版本组合不可用', outcomeStable: '已切换到稳定版本', outcomeExperimental: '已切换到实验版本', + requestError: '请求失败', operationError: '操作失败,请查看容器日志。', holdVersion: '此版本更新失败,已暂停自动重试。', holdCombination: '此版本与正式环境组合不可用,已暂停自动重试。', + metadataUnavailable: '正式更新信息暂未发布,请稍后再试。', remoteCheckFailed: '远程检查失败,继续显示上次已验证结果。', remoteCheckFailedNoResult: '远程检查失败,暂无已验证结果。', upstreamCheckFailed: 'DSH 官方版本检查失败,继续显示上次已验证结果。', upstreamCheckFailedNoResult: 'DSH 官方版本检查失败,暂无已验证结果。', + aheadOfStable: '当前版本领先正式支持版本,已暂停完整运行组合更新。', experimentalBlocked: '实验 DSH 与正式环境组合不可用。', + returnStableTitle: '恢复稳定状态', returnStableWarning: '将恢复以下时间的数据快照,此后产生的数据会丢失:', confirmDataLoss: '我了解并确认丢弃更新后的数据', cancel: '取消', confirm: '确认恢复', + standaloneManagement: 'DSH 管理中心', standaloneManagementDetail: 'DSH 不可用时仍可进行更新、插件恢复、日志查看和终端操作。', openPlatformManagement: '打开 DSH 管理中心', restartDshSection: '重启 DSH', restartDshDetail: '仅重新启动 DSH,容器和管理中心服务保持运行。', restartDsh: '重新启动 DSH', cancelRestartDsh: '取消重启 DSH', restarting: '正在重新启动 DSH', restartFailed: 'DSH 重启失败', restartTitle: '确认重新启动 DSH', restartWarning: '当前 DSH 连接会暂时中断,重启完成后页面将自动刷新。', confirmRestart: '确认重启', + automaticChecks: '自动检查', automaticChecksDetail: '仅检查可用版本,不会自动下载或更新。', enabled: '已开启', disabled: '已关闭', checkInterval: '检查频率', updateNotifications: '更新提醒', updateNotificationsDetail: '自动检查发现新版本时,弹窗提醒更新。', + systemPlugins: '系统插件', systemPluginsDetail: '管理 DSH Docker 提供的系统插件。', noSystemPlugins: '当前环境没有提供系统插件。', platformManaged: '平台核心组件,始终保持安装和启用。', managed: '平台托管', notInstalled: '未安装', resourceEnabled: '已启用', resourceDisabled: '已禁用', pendingInstall: '待安装', pendingUninstall: '待卸载', pendingEnable: '待启用', pendingDisable: '待禁用', statusInstalling: '安装中', statusUninstalling: '卸载中', statusEnabling: '启用中', statusDisabling: '禁用中', pluginEnabled: '已安装并启用', pluginDisabled: '已安装但已禁用', installPlugin: '安装', uninstallPlugin: '卸载', pluginActionWorking: '正在应用插件设置', pluginActionInstall: '正在安装', pluginActionUninstall: '正在卸载', pluginActionEnable: '正在启用', pluginActionDisable: '正在禁用', pluginChangesPending: '有待应用的修改', pluginChangesPendingDetail: '插件修改尚未应用。应用后将重新启动 DSH 并生效。', pendingSystemPluginChanges: '有 {count} 项修改待应用', systemPluginApplyingItem: '{action} @dsh-docker/{id}({current}/{total})', systemPluginRestarting: '插件修改已应用,正在重新启动 DSH', cancelChanges: '取消修改', applyPluginChanges: '应用并重新启动 DSH', searchSystemPlugins: '搜索系统插件', + systemSkills: '系统技能', systemSkillsDetail: '管理 DSH Docker 提供的系统技能。', noSystemSkills: '当前 Bootstrap 没有提供系统技能。', skillActionWorking: '正在应用技能设置', searchSystemSkills: '搜索系统技能', noMatchingResources: '没有符合搜索条件的项目。', itemsPerPage: '每页数量', itemsPerPageSuffix: '条/页', previousPage: '上一页', nextPage: '下一页', totalItems: '共 {total} 条', goToPage: '前往', pageUnit: '页', + logs: '实时日志', logsDetail: '查看 DSH 与平台各模块的运行日志。', searchLogs: '搜索日志', logSource: '日志模块', logLevel: '日志级别', logDisplayLimit: '显示条数', logDisplayLimitValue: '最近 {count} 条', allSources: '全部模块', levelAll: '全部级别', levelDebug: '调试', levelInfo: '信息', levelWarning: '警告', levelError: '错误', logsLive: '实时', logsConnecting: '连接中', logsDisconnected: '已断开', refreshLogs: '刷新日志', exportLogs: '导出日志', autoScroll: '自动滚动', clearLogView: '清空显示', logCount: '显示 {shown} / {total} 条', noLogs: '暂无日志', noMatchingLogs: '没有符合筛选条件的日志', + interval3600: '每 1 小时', interval10800: '每 3 小时', interval21600: '每 6 小时', interval43200: '每 12 小时', interval86400: '每 24 小时', + stableNoticeDshTitle: '正式更新:DSH', stableNoticeEnvironmentTitle: '正式更新:Environment', stableNoticeBothTitle: '正式更新:DSH 与 Environment', upstreamNoticeDshTitle: '上游更新:DSH', updateNoticeDshLine: 'DSH:{version}{state}', updateNoticeEnvironmentLine: 'ENV:{version}{state}', noticeWillUpdate: '(将更新)', later: '稍后提醒', dismissVersion: '不再提醒此版本', closeReminder: '关闭更新提醒', + online: '已连接', connecting: '正在重连', offline: '连接中断', + }, + en: { + localeCode: 'en', + nav: 'Platform Management', title: 'Platform Management', intro: 'DSH Docker runtime, updates, and recovery', + managementSections: 'Platform management sections', updatesTab: 'Updates', maintenanceTab: 'Maintenance', pluginsTab: 'System plugins', skillsTab: 'System skills', authenticationTab: 'Authentication', proxyTab: 'Proxy', + accountAccess: 'Account and password', accountAccessDetail: 'Use the DSH Management Console or the container Root terminal to change the username or password.', rootResetDetail: 'Root terminal command:', sessionAccess: 'Sign out', sessionAccessDetail: 'Sign out the DSH and Management sessions in this browser.', logoutAll: 'Sign out', logoutManagementOnly: 'Sign out of Management only', loggingOut: 'Signing out', loadingAuthentication: 'Loading authentication status…', managementLogoutComplete: 'The Management session has been signed out.', + proxyMaster: 'Use proxy', proxyMasterDetail: 'When off, all managed traffic scopes connect directly.', proxyEndpointRequired: 'Enter the proxy host and port first.', + proxyTitle: 'Proxy settings', proxyDetail: 'Configure an external HTTP or SOCKS5 proxy used by DSH Docker.', proxyLoading: 'Loading proxy configuration…', proxyAdvanced: 'Advanced', proxyUnsaved: 'Unsaved', proxySavedShort: 'Saved', proxyProtocol: 'Proxy protocol', proxyHost: 'Host', proxyPort: 'Port', proxyUsername: 'Username', proxyPassword: 'Password', proxyPasswordPlaceholder: 'Leave blank to keep the current password', proxyRemoteDns: 'Resolve names through the proxy', proxyRemoteDnsDetail: 'SOCKS5 only. When off, names are resolved locally in the container.', proxyClearPassword: 'Clear password', proxyTransportWarning: 'This page is not using HTTPS. Proxy credentials are limited by the protection of the transport path.', proxyComponentUnavailable: 'The outbound proxy component is unavailable.', proxyComponentIssueLabel: 'View outbound proxy component error', proxyComponentIssueTitle: 'Outbound proxy component unavailable', proxyComponentIssueDetail: 'The DSH Docker outbound proxy component is unavailable. The current configuration is read-only until the component recovers; saving, connection tests, and managed proxy traffic are unavailable. This does not indicate whether the configured external proxy is reachable.', + proxyScopes: 'Proxy scopes', proxyScopesDetail: 'Enable the external proxy only for sources that need it.', proxyScopeHelp: 'Scope guide', proxyScopeGuideTitle: 'Proxy scope guide', proxyScopeGuideDetail: 'The Management backend supplies this table to both management interfaces.', proxyScopeUpdates: 'Update management', proxyScopeUpdatesDetail: 'Update checks, npm metadata, and Artifact downloads.', proxyScopePlatform: 'Platform components', proxyScopePlatformDetail: 'Non-local external requests from Management, Updater, DSH Docker System Plugins, and other platform components.', proxyScopeDshCore: 'DSH core', proxyScopeDshCoreDetail: 'DSH core traffic, excluding model Provider APIs.', proxyScopeDshPlugins: 'DSH plugins', proxyScopeDshPluginsDetail: 'Official DSH plugins and user-installed third-party plugins.', proxyScopeAgent: 'Agent network operations', proxyScopeAgentDetail: 'Agent tools, commands, and their child processes.', proxyScopeTerminal: 'Container terminal', proxyScopeTerminalDetail: 'The container terminal provided by DSH Management Console.', + proxyRules: 'Direct rules', proxyRulesDetail: 'Listed destinations bypass the external proxy.', proxyDirectRules: 'Additional direct rules', proxyDirectRulesDetail: 'One host, domain suffix, IP address, or CIDR per line. Use .google.com, not *.google.com.', proxyDirectRulesPlaceholder: '.example.com\n10.0.0.0/8', proxySystemRules: 'Built-in rules', proxySystemRulesTitle: 'Built-in direct rules', proxySystemRulesDetail: 'These platform-managed local destinations are always direct and do not need to be entered again.', proxyAllProxy: 'Inject ALL_PROXY for compatible clients', proxyAllProxyDetail: 'Only affects clients known to support ALL_PROXY. Off by default.', proxyProviders: 'Model Providers', proxyProvidersDetail: 'Identifiable DSH llm/stream Providers can independently select direct access or a dedicated proxy route.', proxyProviderSearch: 'Search configured Providers', proxyNoProviders: 'No configured Providers match the current search.', proxyProviderDirect: 'Forced direct', proxyProviderIndependent: 'Independent proxy', proxyProviderShared: 'Follow DSH', proxyProviderInfo: 'View routing information for {name}', proxyProviderReasonLocal: 'Local Provider; forced direct.', proxyProviderReasonShared: 'The client cannot carry a stable Provider identity. When enabled it follows shared DSH traffic; when disabled it connects directly.', + proxyTest: 'Proxy connection test', proxyTestDetail: 'Tests the current form without replacing saved settings.', proxyTestStart: 'Test connection', proxySave: 'Save and apply', proxySaving: 'Saving proxy settings', proxySaved: 'Proxy settings saved', proxyTestSuccess: 'Proxy connection test passed.', proxyTestFailed: 'Proxy connection test failed', proxyTestCancelled: 'Proxy connection test cancelled.', proxyStageAddress: 'Resolve proxy address', proxyStageConnect: 'Connect to proxy', proxyStageHandshake: 'Proxy handshake', proxyStageDns: 'Resolve target name', proxyStageTls: 'Establish target TLS', proxyStageHttp: 'Request target service', proxyStagePending: 'Pending test', proxyStageRunning: 'Testing', proxyStageSuccess: 'Passed', proxyStageFailed: 'Failed', proxyStageCancelled: 'Cancelled', proxyStageSkipped: 'Skipped', proxyStageDetailDirect: 'The candidate configuration connects directly.', proxyStageDetailAddress: 'Resolved {count} proxy address record(s).', proxyStageDetailConnect: 'Connected to the proxy server.', proxyStageDetailHandshake: 'Verified the {protocol} proxy protocol and authentication.', proxyStageDetailDns: 'Resolved {count} target address(es).', proxyStageDetailTls: 'Verified TLS for {count} target(s).', proxyStageDetailHttp: '{results}', proxyTargetGithub: 'GitHub metadata', proxyTargetNpm: 'npm registry', + channel: 'Update channel', channelDetail: 'Experimental updates DSH only; the platform Environment remains on the supported release.', returnStableProgress: 'Return to Stable', + stable: 'Stable', experimental: 'Experimental', current: 'Current', supported: 'Supported', upstream: 'Upstream', officialNpm: 'Official npm', + actions: 'Update actions', lastChecked: 'Last checked', notChecked: 'Not checked yet', check: 'Check for updates', checking: 'Checking', updateSupported: 'Update to latest supported', updateUpstream: 'Update to latest upstream', rollback: 'Roll back previous', returnStable: 'Return to Stable now', retry: 'Retry', progress: 'Update progress', + updateProgress: 'Update progress', rollbackProgress: 'Rollback progress', updateToTarget: 'Update to {target}', progressPrepare: 'Prepare update', progressAcquire: 'Download and verify', progressBuild: 'Build Runtime', progressActivate: 'Switch and health check', stageLogs: 'Stage logs', hideStageLogs: 'Hide logs · {count}', showStageLogs: 'View logs · {count}', noStageLogs: 'No logs for this phase yet', copyStageLogs: 'Copy current logs', logsCopied: 'Copied', viewFullTransactionLog: 'View full transaction log', stageCompleted: 'Stage completed.', stageWaiting: 'Waiting for the previous stage.', stageProgress: 'Stage progress {progress}%', stageItemsCompleted: '{completed}/{total} items completed', expandStage: 'Expand · {count} log entries', collapseStage: 'Collapse · {count} log entries', stageItemCompleted: 'Completed: {item}', stageItemActive: 'In progress: {item}', stageItemPending: 'Pending: {item}', stageItemFailed: 'Failed: {item}', itemVerifyMetadata: 'Verify metadata', itemVerifyKeyring: 'Verify keyring', itemVerifyTarget: 'Verify target manifest', itemDownloadArtifacts: 'Download Artifacts', itemVerifyArtifacts: 'Verify Artifact signatures, references, sizes, and hashes', itemImportObjects: 'Import trusted objects', itemMaterializePristine: 'Materialize Pristine DSH', itemPrepareEnvironment: 'Prepare Environment', itemBuildRuntime: 'Build Runtime and apply the complete Patch Set', itemPreparePlugins: 'Prepare System Plugin Set', itemSwitchDeployment: 'Switch Deployment atomically', itemCheckHealth: 'Check service health', itemObserveProbation: 'Observe candidate Runtime', metadataVerified: 'Metadata verified.', keyringVerified: 'Keyring verified.', targetManifestVerified: 'Target manifest verified.', artifactDownloadCompleted: 'Artifact download completed.', artifactVerificationCompleted: 'Artifact signatures, references, and hashes verified.', runtimeMaterialized: 'Pristine DSH materialized.', patchSetApplied: 'Complete Patch Set applied.', systemPluginsPrepared: 'System Plugin Set prepared.', deploymentSwitched: 'Deployment switched atomically.', healthChecksPassed: 'Service health checks passed.', metricBytesRead: 'Read {processed} / {total}', metricBytesCopied: 'Copied {processed} / {total}', metricBytesProcessed: 'Processed {processed} / {total}', metricArtifacts: 'Verified {processed} / {total} Artifacts', metricFiles: 'Completed {processed} / {total} files', metricItems: 'Completed {processed} / {total} items', metricServices: '{ready} / {total} services ready', rollbackPrepare: 'Prepare rollback', rollbackSwitch: 'Switch previous version', rollbackData: 'Restore data', rollbackVerify: 'Start and check', + dismissProgress: 'Close', itemValidateRollback: 'Validate rollback plan and previous complete Deployment', itemPauseRuntime: 'Pause current DSH Runtime', itemSwitchPrevious: 'Switch previous complete Deployment', itemVerifySnapshot: 'Verify data snapshot', itemRestoreSnapshot: 'Restore data snapshot', itemStartRuntime: 'Start previous DSH Runtime', metricBytesRestored: 'Restored {processed} / {total}', metricProbationRemaining: '{seconds} seconds of observation remaining', + progressDetailChecking: 'Fetching and verifying the latest signed update metadata.', progressDetailPlanning: 'Calculating the complete target state to reconcile.', progressDetailUpstream: 'Checking the official npm registry for the latest DSH.', progressDetailDownloading: 'Downloading Artifacts and importing them through Stage-0 into the trusted object store.', progressDetailValidating: 'Verifying signatures, Artifact references, sizes, and content hashes.', progressDetailBuilding: 'Building an immutable Runtime from Pristine DSH, patches, and System Plugins.', progressDetailSnapshot: 'DSH is paused while a complete data snapshot is created for the Experimental update.', progressDetailSwitching: 'Atomically switching the complete Deployment and checking DSH readiness.', progressDetailProbation: 'The candidate Runtime remains under health observation until {until}.', + rollbackDetailPreparing: 'Validating the rollback plan and previous complete Deployment.', rollbackDetailStopping: 'Pausing DSH before restoring the previous complete state.', rollbackDetailSwitching: 'Switching the previous Runtime, Environment, and System Plugin set.', rollbackDetailData: 'Verifying and restoring the pre-update data snapshot.', rollbackDetailVerifying: 'Starting DSH and running health checks.', + statusIdle: 'Ready', statusChecking: 'Checking for updates', statusPlanning: 'Preparing update', statusCheckingUpstream: 'Checking upstream', statusDownloading: 'Downloading', statusValidating: 'Verifying', statusBuildingCandidate: 'Building candidate', statusSnapshottingData: 'Backing up data', statusSwitching: 'Switching version', statusProbation: 'Observing runtime health', statusRestoringData: 'Restoring data', statusRollingBack: 'Rolling back', statusSuccess: 'Completed', statusFailed: 'Failed', statusUnknown: 'Working', + outcomeNone: 'Already up to date', outcomeFrozen: 'Waiting for the supported release to catch up', outcomeHeld: 'This version is on hold', outcomeBlocked: 'This version combination is unavailable', outcomeStable: 'Switched to the Stable release', outcomeExperimental: 'Switched to the Experimental release', + requestError: 'Request failed', operationError: 'The operation failed. Check the container logs.', holdVersion: 'This version failed and automatic retries are on hold.', holdCombination: 'This version is incompatible with the production Environment and automatic retries are on hold.', + metadataUnavailable: 'Signed update metadata has not been published yet. Try again later.', remoteCheckFailed: 'Remote check failed. Showing the last verified result.', remoteCheckFailedNoResult: 'Remote check failed. No verified result is available yet.', upstreamCheckFailed: 'The official DSH version check failed. Showing the last verified result.', upstreamCheckFailedNoResult: 'The official DSH version check failed. No verified result is available yet.', + aheadOfStable: 'The current version is ahead of Latest Supported; the complete deployment is frozen.', experimentalBlocked: 'The Experimental DSH and production Environment combination is unavailable.', + returnStableTitle: 'Restore Stable state', returnStableWarning: 'The following data snapshot will be restored and newer data will be lost:', confirmDataLoss: 'I understand and confirm the loss of newer data', cancel: 'Cancel', confirm: 'Restore', + standaloneManagement: 'DSH Management Console', standaloneManagementDetail: 'Updates, plugin recovery, logs, and terminal tools remain available when DSH is unavailable.', openPlatformManagement: 'Open DSH Management Console', restartDshSection: 'Restart DSH', restartDshDetail: 'Restart DSH only. The container and management console services remain running.', restartDsh: 'Restart DSH', cancelRestartDsh: 'Cancel DSH restart', restarting: 'Restarting DSH', restartFailed: 'DSH restart failed', restartTitle: 'Restart DSH?', restartWarning: 'The current DSH connection will be interrupted briefly. This page reloads when DSH is ready.', confirmRestart: 'Restart', + automaticChecks: 'Automatic checks', automaticChecksDetail: 'Checks for available versions without downloading or updating.', enabled: 'On', disabled: 'Off', checkInterval: 'Check frequency', updateNotifications: 'Update notifications', updateNotificationsDetail: 'Show an update notification popup when an automatic check finds a new version.', + systemPlugins: 'System plugins', systemPluginsDetail: 'Manage the System Plugins provided by DSH Docker.', noSystemPlugins: 'No System Plugins are provided by the current Environment.', platformManaged: 'Core platform component. It is always installed and enabled.', managed: 'Platform managed', notInstalled: 'Not installed', resourceEnabled: 'Enabled', resourceDisabled: 'Disabled', pendingInstall: 'Pending install', pendingUninstall: 'Pending uninstall', pendingEnable: 'Pending enable', pendingDisable: 'Pending disable', statusInstalling: 'Installing', statusUninstalling: 'Uninstalling', statusEnabling: 'Enabling', statusDisabling: 'Disabling', pluginEnabled: 'Installed and enabled', pluginDisabled: 'Installed but disabled', installPlugin: 'Install', uninstallPlugin: 'Uninstall', pluginActionWorking: 'Applying plugin settings', pluginActionInstall: 'Installing', pluginActionUninstall: 'Uninstalling', pluginActionEnable: 'Enabling', pluginActionDisable: 'Disabling', pluginChangesPending: 'Changes pending', pluginChangesPendingDetail: 'Plugin changes have not been applied. Apply them to restart DSH and make them effective.', pendingSystemPluginChanges: '{count} pending changes', systemPluginApplyingItem: '{action} @dsh-docker/{id} ({current}/{total})', systemPluginRestarting: 'Plugin changes applied; restarting DSH', cancelChanges: 'Cancel changes', applyPluginChanges: 'Apply and restart DSH', searchSystemPlugins: 'Search System Plugins', + systemSkills: 'System skills', systemSkillsDetail: 'Manage System Skills provided by DSH Docker.', noSystemSkills: 'The current Bootstrap provides no System Skills.', skillActionWorking: 'Applying skill settings', searchSystemSkills: 'Search System Skills', noMatchingResources: 'No items match this search.', itemsPerPage: 'Items per page', itemsPerPageSuffix: '/ page', previousPage: 'Previous', nextPage: 'Next', totalItems: '{total} total', goToPage: 'Go to', pageUnit: 'page', + logs: 'Live logs', logsDetail: 'View runtime logs from DSH and platform modules.', searchLogs: 'Search logs', logSource: 'Log module', logLevel: 'Log level', logDisplayLimit: 'Entries shown', logDisplayLimitValue: 'Latest {count}', allSources: 'All modules', levelAll: 'All levels', levelDebug: 'Debug', levelInfo: 'Info', levelWarning: 'Warning', levelError: 'Error', logsLive: 'Live', logsConnecting: 'Connecting', logsDisconnected: 'Disconnected', refreshLogs: 'Refresh logs', exportLogs: 'Export logs', autoScroll: 'Auto-scroll', clearLogView: 'Clear view', logCount: 'Showing {shown} / {total}', noLogs: 'No logs yet', noMatchingLogs: 'No logs match these filters', + interval3600: 'Every hour', interval10800: 'Every 3 hours', interval21600: 'Every 6 hours', interval43200: 'Every 12 hours', interval86400: 'Every 24 hours', + stableNoticeDshTitle: 'Supported update: DSH', stableNoticeEnvironmentTitle: 'Supported update: Environment', stableNoticeBothTitle: 'Supported update: DSH and Environment', upstreamNoticeDshTitle: 'Upstream update: DSH', updateNoticeDshLine: 'DSH: {version}{state}', updateNoticeEnvironmentLine: 'ENV: {version}{state}', noticeWillUpdate: ' (will update)', later: 'Remind me later', dismissVersion: 'Do not remind for this version', closeReminder: 'Close update reminder', + online: 'Connected', connecting: 'Reconnecting', offline: 'Disconnected', + }, + }), 'dsh-platform-management: locale') + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'dsh-platform-management', + order: 90, + label: () => ctx.locale.bind('settings.dshPlatformManagement')('nav'), + locale: 'settings.dshPlatformManagement', + }, PlatformManagement)) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: 'dsh-platform-management-reminder', + order: 90, + locale: 'settings.dshPlatformManagement', + }, UpdateReminder)) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: 'dsh-platform-lifecycle-guard', + order: -100, + }, () => h(LifecycleGuard, { connection: ctx.connection }))) +} +exports.inject = inject; +exports.apply = apply; +return module.exports; } }); diff --git a/upstream/container/environment/resources/plugins/platform-management/package/lib/client.js b/upstream/container/environment/resources/plugins/platform-management/package/lib/client.js new file mode 100644 index 0000000..988708a --- /dev/null +++ b/upstream/container/environment/resources/plugins/platform-management/package/lib/client.js @@ -0,0 +1,2804 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import css from './style.module.css' +const { IconChevronDownOutline14, Menu } = require('@deepseek-ai/dsh-client-ui-primitives') + +const API = '/_dsh_platform/plugin-api/v1' +const AUTH_API = '/_dsh_platform/auth' +const TERMINAL = new Set(['idle', 'success', 'failed']) +const UPDATE_PROGRESS_STEPS = Object.freeze(['progressPrepare', 'progressAcquire', 'progressBuild', 'progressActivate']) +const UPDATE_PROGRESS_STAGE = Object.freeze({ + checking: 0, + planning: 0, + 'checking-upstream': 0, + downloading: 1, + validating: 1, + 'building-candidate': 2, + 'snapshotting-data': 3, + switching: 3, + probation: 3, + 'restoring-data': 3, +}) +const ROLLBACK_PROGRESS_STEPS = Object.freeze(['rollbackPrepare', 'rollbackSwitch', 'rollbackData', 'rollbackVerify']) +const ROLLBACK_PROGRESS_STAGE = Object.freeze({ preparing: 0, stopping: 0, switching: 1, 'restoring-data': 2, verifying: 3 }) + +function isRecoveryOperation(operation) { + return operation === 'rollback' || operation === 'return-stable' +} +const h = React.createElement +const LOCALE_COOKIE = 'dsh_locale' +const NOTICE_OWNER_KEY = 'dsh-platform:update-notice-owner' +const NOTICE_SNOOZE_KEY = 'dsh-platform:update-notice-snooze' +const NOTICE_OWNER_TTL = 20_000 +const PLUGIN_DRAFT_KEY = 'dsh-platform:system-plugin-draft' +const LOG_CLEAR_CUTOFF_KEY = 'dsh-platform:log-clear-cutoff' +const LOG_DISPLAY_LIMIT_KEY = 'dsh-platform:log-display-limit' +const LOG_DISPLAY_LIMITS = Object.freeze([100, 250, 500, 1_000]) +const DEFAULT_LOG_DISPLAY_LIMIT = 500 +const LOG_STREAM_LIMIT = 5_000 +const LIST_PAGE_SIZES = Object.freeze([5, 10, 20, 50]) +const LIST_PAGE_SIZE_KEY_PREFIX = 'dsh-platform:plugin-page-size:' + +export const inject = ['slots', 'locale', 'connection'] + +function SelectMenu({ value, items, onSelect, ariaLabel, disabled = false, className = '' }) { + const [open, setOpen] = useState(false) + const selectedId = String(value) + const selected = items.find(item => item.id === selectedId) ?? items[0] + useEffect(() => { + if (!open || disabled) return undefined + document.body.dataset.dshPlatformSelectMenu = 'open' + return () => { + delete document.body.dataset.dshPlatformSelectMenu + } + }, [open, disabled]) + return h(Menu, { + open: open && !disabled, + portal: true, + align: 'end', + items, + selectedId, + onClose: () => setOpen(false), + onSelect: id => { + setOpen(false) + onSelect(id) + }, + className: css.selectMenu, + anchor: h('button', { + type: 'button', + className: `${css.selectTrigger}${className ? ` ${className}` : ''}`, + disabled, + 'aria-label': ariaLabel, + 'aria-haspopup': 'menu', + 'aria-expanded': open && !disabled, + onClick: () => setOpen(current => !current), + }, h('span', null, selected?.label ?? selectedId), h(IconChevronDownOutline14, { 'aria-hidden': 'true' })), + }) +} + +const LIFECYCLE_WAIT_PATH = '/_dsh_gateway/wait' +const LIFECYCLE_READINESS_PATH = '/_dsh_gateway/readiness' +const CONNECTION_LOSS_GRACE_MS = 1_000 +const READINESS_RETRY_MS = 500 + +let platformStateEventSource +const platformStateEventSubscribers = new Set() + +function openPlatformStateEvents() { + if (platformStateEventSource !== undefined || platformStateEventSubscribers.size === 0) return + const stream = new EventSource(`${API}/events`) + platformStateEventSource = stream + stream.addEventListener('state', event => { + for (const current of platformStateEventSubscribers) current.state?.(event) + }) + stream.onopen = event => { + for (const current of platformStateEventSubscribers) current.open?.(event) + } + stream.onerror = event => { + for (const current of platformStateEventSubscribers) current.error?.(event) + } +} + +function closePlatformStateEvents() { + platformStateEventSource?.close() + platformStateEventSource = undefined +} + +function subscribePlatformStateEvents({ state, open, error }) { + const subscriber = { state, open, error } + platformStateEventSubscribers.add(subscriber) + openPlatformStateEvents() + return () => { + platformStateEventSubscribers.delete(subscriber) + if (platformStateEventSubscribers.size === 0) closePlatformStateEvents() + } +} + +function requiresLifecycleHoldingPage(status) { + if (['restarting', 'switching', 'recovering', 'restart-failed'].includes(status?.operation)) return true + if (['snapshotting-data', 'switching', 'probation', 'restoring-data'].includes(status?.update?.status)) return true + return ['starting', 'stopping', 'stopped', 'restarting', 'recovering', 'failed'] + .includes(status?.dshLifecycle?.state) +} + +function lifecycleReturnPath(locationValue = window.location) { + return `${locationValue.pathname}${locationValue.search}${locationValue.hash}` +} + +function lifecycleWaitUrl(locationValue = window.location) { + return `${LIFECYCLE_WAIT_PATH}?return=${encodeURIComponent(lifecycleReturnPath(locationValue))}` +} + +function display(value) { + return value === undefined || value === null || value === '' ? '-' : String(value) +} + +function displayEnvironment(value) { + return value === undefined || value === null || value === '' ? '-' : `env-${String(value)}` +} + +function formatBytes(value) { + const size = Number(value) + if (!Number.isFinite(size)) return '-' + if (size < 1024) return `${size} B` + if (size < 1024 ** 2) return `${(size / 1024).toFixed(1)} KiB` + if (size < 1024 ** 3) return `${(size / 1024 ** 2).toFixed(1)} MiB` + return `${(size / 1024 ** 3).toFixed(1)} GiB` +} + +function stageMetricLines(update, stage, state, t) { + const values = [] + const bytes = Number(update?.processedBytes) + const totalBytes = Number(update?.totalBytes) + const items = Number(update?.processedItems) + const totalItems = Number(update?.totalItems) + const ready = Number(update?.readyServices) + const totalServices = Number(update?.totalServices) + const completeEnough = state !== 'completed' || stageMetricProgress(update) === 100 + if (stage.index > 0 && completeEnough && Number.isFinite(bytes) && Number.isFinite(totalBytes) && totalBytes > 0) { + const key = stage.key.startsWith('rollback') && stage.labelKey === 'rollbackData' + ? 'metricBytesRestored' + : stage.index === 1 ? 'metricBytesRead' : stage.index === 2 ? 'metricBytesCopied' : 'metricBytesProcessed' + values.push(t(key).replace('{processed}', formatBytes(bytes)).replace('{total}', formatBytes(totalBytes))) + } + if (stage.index > 0 && completeEnough && Number.isFinite(items) && Number.isFinite(totalItems) && totalItems > 0) { + const key = stage.index === 1 ? 'metricArtifacts' : stage.index >= 2 ? 'metricFiles' : 'metricItems' + values.push(t(key).replace('{processed}', String(items)).replace('{total}', String(totalItems))) + } + if (Number.isFinite(ready) && Number.isFinite(totalServices) && totalServices > 0) values.push(t('metricServices').replace('{ready}', String(ready)).replace('{total}', String(totalServices))) + if (stage.labelKey === 'progressActivate' && (update?.phase ?? update?.status) === 'probation') { + const seconds = probationRemainingSeconds(update) + if (seconds !== undefined) values.push(t('metricProbationRemaining').replace('{seconds}', String(seconds))) + } + return values +} + +function probationRemainingSeconds(update) { + const detail = String(update?.detail ?? '').match(/^probation:(\d+)$/u) + if (detail !== null) return Number(detail[1]) + const deadline = Date.parse(update?.probationUntil ?? '') + return Number.isFinite(deadline) ? Math.max(0, Math.ceil((deadline - Date.now()) / 1_000)) : undefined +} + +function stageMetricProgress(update) { + for (const [processedKey, totalKey] of [['processedBytes', 'totalBytes'], ['processedItems', 'totalItems'], ['readyServices', 'totalServices']]) { + const processed = Number(update?.[processedKey]) + const total = Number(update?.[totalKey]) + if (Number.isFinite(processed) && Number.isFinite(total) && total > 0) { + return Math.max(0, Math.min(100, Math.round(processed / total * 100))) + } + } + return undefined +} + +const UPDATE_STAGE_ITEMS = Object.freeze({ + progressPrepare: ['itemVerifyMetadata', 'itemVerifyKeyring', 'itemVerifyTarget'], + progressAcquire: ['itemDownloadArtifacts', 'itemVerifyArtifacts', 'itemImportObjects'], + progressBuild: ['itemMaterializePristine', 'itemPrepareEnvironment', 'itemBuildRuntime', 'itemPreparePlugins'], + progressActivate: ['itemSwitchDeployment', 'itemCheckHealth', 'itemObserveProbation'], +}) + +const UPDATE_RECOVERY_STAGE_ITEMS = Object.freeze([ + 'itemSwitchPrevious', 'itemRestoreSnapshot', 'itemStartRuntime', 'itemCheckHealth', +]) + +const ROLLBACK_STAGE_ITEMS = Object.freeze({ + rollbackPrepare: ['itemValidateRollback', 'itemPauseRuntime'], + rollbackSwitch: ['itemSwitchPrevious'], + rollbackData: ['itemVerifySnapshot', 'itemRestoreSnapshot'], + rollbackVerify: ['itemStartRuntime', 'itemCheckHealth'], +}) + +function activeStageItemIndex(stage, update) { + const phase = update?.phase ?? update?.status + if (stage.labelKey === 'progressPrepare') return phase === 'planning' ? 2 : 0 + if (stage.labelKey === 'progressAcquire') return phase === 'validating' ? 2 : 0 + if (stage.labelKey === 'progressBuild') { + const progress = Number(update?.progress) || 0 + if (Number(update?.totalBytes) > 0 || Number(update?.totalItems) > 0) return progress >= 87 ? 3 : 2 + if (progress < 80) return 0 + if (progress < 82) return 1 + if (progress < 87) return 2 + return 3 + } + if (stage.labelKey === 'progressActivate') { + if (phase === 'restoring-data') { + return { + 'recovery:suspend': 0, + 'recovery:deployment': 0, + 'recovery:snapshot': 1, + 'recovery:runtime': 2, + 'recovery:health': 3, + }[update?.detail] ?? 0 + } + if (phase === 'probation') return 2 + if (Number(update?.totalServices) > 0) return 1 + } + if (stage.labelKey === 'rollbackPrepare') return phase === 'stopping' ? 1 : 0 + if (stage.labelKey === 'rollbackSwitch') return 0 + if (stage.labelKey === 'rollbackData') return Number(update?.totalItems) > 0 || Number(update?.totalBytes) > 0 ? 1 : 0 + if (stage.labelKey === 'rollbackVerify') return Number(update?.totalServices) > 0 ? 1 : 0 + return 0 +} + +function stageItems(stage, update, state, t) { + const keys = stage.labelKey === 'progressActivate' && (update?.phase ?? update?.status) === 'restoring-data' + ? UPDATE_RECOVERY_STAGE_ITEMS + : (stage.key.startsWith('rollback') ? ROLLBACK_STAGE_ITEMS : UPDATE_STAGE_ITEMS)[stage.labelKey] ?? ['stageCompleted'] + const active = activeStageItemIndex(stage, update) + return keys.map((key, index) => ({ + key, + label: t(key), + state: state === 'completed' ? 'completed' + : state === 'pending' ? 'pending' + : index < active ? 'completed' + : index === active ? (state === 'failed' ? 'failed' : 'active') : 'pending', + })) +} + +function makeHorizontalTabStripScrollable(tablist) { + let pointerId + let pointerStartX = 0 + let scrollStart = 0 + let wheelTarget = tablist.scrollLeft + let wheelFrame + let wheelFrameTime + let dragged = false + let dragTarget + let suppressClickTarget + + const stopWheelAnimation = () => { + if (wheelFrame !== undefined) window.cancelAnimationFrame(wheelFrame) + wheelFrame = undefined + wheelFrameTime = undefined + wheelTarget = tablist.scrollLeft + } + const animateWheel = timestamp => { + const elapsed = wheelFrameTime === undefined ? 16 : Math.min(timestamp - wheelFrameTime, 32) + const remaining = wheelTarget - tablist.scrollLeft + if (Math.abs(remaining) < 0.5) { + tablist.scrollLeft = wheelTarget + wheelFrame = undefined + wheelFrameTime = undefined + return + } + tablist.scrollLeft += remaining * (1 - Math.exp(-elapsed / 45)) + wheelFrameTime = timestamp + wheelFrame = window.requestAnimationFrame(animateWheel) + } + + const finishDrag = event => { + if (pointerId === undefined || (event.pointerId !== undefined && event.pointerId !== pointerId)) return + if (tablist.hasPointerCapture?.(pointerId)) tablist.releasePointerCapture(pointerId) + const displacedClickTarget = !dragged && event.target !== dragTarget ? dragTarget : undefined + suppressClickTarget = dragged ? dragTarget : undefined + if (suppressClickTarget !== undefined) window.setTimeout(() => { suppressClickTarget = undefined }, 0) + pointerId = undefined + dragged = false + dragTarget = undefined + displacedClickTarget?.click() + } + const wheel = event => { + if (tablist.scrollWidth <= tablist.clientWidth) return + if (event.deltaX !== 0) return + let delta = event.deltaY + if (delta === 0) return + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16 + else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= tablist.clientWidth + if (wheelFrame === undefined) wheelTarget = tablist.scrollLeft + const nextTarget = Math.max(0, Math.min(tablist.scrollWidth - tablist.clientWidth, wheelTarget + delta)) + if (nextTarget === wheelTarget) return + wheelTarget = nextTarget + if (wheelFrame === undefined) wheelFrame = window.requestAnimationFrame(animateWheel) + event.preventDefault() + } + const pointerDown = event => { + if (event.button !== 0 || event.pointerType === 'touch' || tablist.scrollWidth <= tablist.clientWidth) return + stopWheelAnimation() + pointerId = event.pointerId + pointerStartX = event.clientX + scrollStart = tablist.scrollLeft + dragged = false + dragTarget = event.target + } + const pointerMove = event => { + if (event.pointerId !== pointerId) return + const distance = event.clientX - pointerStartX + if (Math.abs(distance) >= 4 && !dragged) { + dragged = true + tablist.setPointerCapture?.(pointerId) + } + if (!dragged) return + tablist.scrollLeft = scrollStart - distance + event.preventDefault() + } + const click = event => { + if (event.target !== suppressClickTarget) return + suppressClickTarget = undefined + event.preventDefault() + event.stopPropagation() + } + + tablist.addEventListener('wheel', wheel, { passive: false }) + tablist.addEventListener('pointerdown', pointerDown) + tablist.addEventListener('pointermove', pointerMove) + tablist.addEventListener('pointerup', finishDrag) + tablist.addEventListener('pointercancel', finishDrag) + tablist.addEventListener('click', click, true) + return () => { + stopWheelAnimation() + tablist.removeEventListener('wheel', wheel) + tablist.removeEventListener('pointerdown', pointerDown) + tablist.removeEventListener('pointermove', pointerMove) + tablist.removeEventListener('pointerup', finishDrag) + tablist.removeEventListener('pointercancel', finishDrag) + tablist.removeEventListener('click', click, true) + } +} + +function localTime(value, locale) { + if (!value) return '-' + const date = new Date(value) + return Number.isNaN(date.valueOf()) + ? String(value) + : date.toLocaleString(locale === 'en' ? 'en-US' : 'zh-CN') +} + +function updateProgressModel(update, t) { + const rollback = isRecoveryOperation(update.operation) + const phase = update.phase ?? (rollback ? update.rollbackPhase : update.status) + const labels = rollback + ? ROLLBACK_PROGRESS_STEPS.filter(key => update.rollbackIncludesSnapshot !== false || key !== 'rollbackData') + : UPDATE_PROGRESS_STEPS + const rawStage = rollback + ? ROLLBACK_PROGRESS_STAGE[phase] ?? 0 + : UPDATE_PROGRESS_STAGE[phase] ?? 0 + const stage = rollback && update.rollbackIncludesSnapshot === false && rawStage > 2 ? rawStage - 1 : rawStage + return { + title: t(update.operation === 'return-stable' ? 'returnStableProgress' : rollback ? 'rollbackProgress' : 'updateProgress'), + labels, + stage, + } +} + +function transactionLogStage(phase, update, t) { + const rollback = isRecoveryOperation(update?.operation) + const stageMap = rollback ? ROLLBACK_PROGRESS_STAGE : UPDATE_PROGRESS_STAGE + const labels = rollback ? ROLLBACK_PROGRESS_STEPS : UPDATE_PROGRESS_STEPS + let index = stageMap[phase] + if (index === undefined) return { key: `phase:${String(phase ?? 'unknown')}`, label: String(phase ?? t('stageLogs')), index: labels.length } + if (rollback && update?.rollbackIncludesSnapshot === false && index > 2) index -= 1 + const visibleLabels = rollback && update?.rollbackIncludesSnapshot === false + ? labels.filter(key => key !== 'rollbackData') + : labels + return { key: `${rollback ? 'rollback' : 'update'}:${String(index)}`, label: t(visibleLabels[index] ?? 'stageLogs'), index } +} + +function updateOutcome(value, t) { + const key = { + none: 'outcomeNone', frozen: 'outcomeFrozen', held: 'outcomeHeld', blocked: 'outcomeBlocked', + stable: 'outcomeStable', experimental: 'outcomeExperimental', + }[value] + return key === undefined ? display(value) : t(key) +} + +function localizedError(value, t) { + const message = value instanceof Error ? value.message : String(value) + if (t('localeCode') === 'en') return message + const httpStatus = message.match(/HTTP\s+(\d{3})/i)?.[1] + return httpStatus === undefined ? t('operationError') : `${t('requestError')}(HTTP ${httpStatus})` +} + +function localizedHoldReason(hold, t) { + if (t('localeCode') === 'en') return display(hold.reason) + return t(hold.type === 'combination' ? 'holdCombination' : 'holdVersion') +} + +function usePaginatedItems(key, items) { + const [page, setPage] = useState(0) + const [pageSize, setPageSize] = useState(() => { + const value = Number(storageValue(`${LIST_PAGE_SIZE_KEY_PREFIX}${key}`)) + return LIST_PAGE_SIZES.includes(value) ? value : 10 + }) + const lastPage = Math.max(0, Math.ceil(items.length / pageSize) - 1) + const currentPage = Math.min(page, lastPage) + useEffect(() => { if (page !== currentPage) setPage(currentPage) }, [currentPage, page]) + const start = currentPage * pageSize + const changePageSize = value => { + if (!LIST_PAGE_SIZES.includes(value)) return + setPageSize(value) + setPage(0) + writeStorage(`${LIST_PAGE_SIZE_KEY_PREFIX}${key}`, String(value)) + } + const goToPage = value => setPage(Math.min(lastPage, Math.max(0, value))) + return { + items: items.slice(start, start + pageSize), page: currentPage, pageSize, lastPage, start, + setPage: goToPage, setPageSize: changePageSize, + } +} + +function ListPagination({ pagination, total, t }) { + const [jumpValue, setJumpValue] = useState(String(pagination.page + 1)) + useEffect(() => { setJumpValue(String(pagination.page + 1)) }, [pagination.page]) + const commitJump = () => { + const value = Number(jumpValue) + const page = Number.isSafeInteger(value) + ? Math.min(pagination.lastPage, Math.max(0, value - 1)) + : pagination.page + pagination.setPage(page) + setJumpValue(String(page + 1)) + } + return h('div', { className: css.listPagination }, + h('span', null, t('totalItems').replace('{total}', String(total))), + h('div', null, + h(SelectMenu, { + ariaLabel: t('itemsPerPage'), + value: pagination.pageSize, + items: LIST_PAGE_SIZES.map(value => ({ id: String(value), label: String(value) })), + onSelect: value => pagination.setPageSize(Number(value)), + className: css.paginationSelectTrigger, + }), + h('span', null, t('itemsPerPageSuffix'))), + h('div', { className: css.pageNavigation }, + h('button', { type: 'button', className: `${css.smallButton} ${css.pageArrow}`, 'aria-label': t('previousPage'), disabled: pagination.page === 0, onClick: () => pagination.setPage(pagination.page - 1) }), + h('strong', null, String(pagination.page + 1)), + h('button', { type: 'button', className: `${css.smallButton} ${css.pageArrow}`, 'aria-label': t('nextPage'), disabled: pagination.page === pagination.lastPage, onClick: () => pagination.setPage(pagination.page + 1) })), + h('label', { className: css.pageJump }, + h('span', null, t('goToPage')), + h('input', { + type: 'number', min: 1, max: pagination.lastPage + 1, value: jumpValue, + onChange: event => setJumpValue(event.target.value), onBlur: commitJump, + onKeyDown: event => { if (event.key === 'Enter') { event.preventDefault(); commitJump() } }, + }), + h('span', null, t('pageUnit')))) +} + +function ExpandableDescription({ text, identity }) { + const element = useRef(null) + const [expanded, setExpanded] = useState(false) + const [expandable, setExpandable] = useState(false) + useEffect(() => { + const node = element.current + if (node !== null && !expanded) setExpandable(node.scrollWidth > node.clientWidth) + }, [expanded, identity, text]) + return h('button', { + ref: element, + type: 'button', + className: `${css.resourceDescription}${expandable ? ` ${css.expandable}` : ''}${expanded ? ` ${css.expanded}` : ''}`, + title: text, + 'aria-expanded': expanded, + onClick: event => { + if (!expandable) return + preserveScrollableAncestors(event.currentTarget, () => setExpanded(value => !value)) + }, + }, text) +} + +function ExpandableProxyDescription({ text, identity, className = css.proxyScopeDescription }) { + const element = useRef(null) + const [expanded, setExpanded] = useState(false) + const [expandable, setExpandable] = useState(false) + useEffect(() => { + const node = element.current + if (node === null) return undefined + const measure = () => { + if (expanded) return + const clone = node.cloneNode(true) + clone.classList.remove(css.expandable, css.expanded) + Object.assign(clone.style, { + position: 'fixed', visibility: 'hidden', width: `${node.clientWidth}px`, + height: 'auto', maxHeight: 'none', overflow: 'visible', display: 'block', + webkitLineClamp: 'unset', pointerEvents: 'none', + }) + document.body.append(clone) + setExpandable(clone.scrollHeight > node.clientHeight + 1) + clone.remove() + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => observer.disconnect() + }, [expanded, identity, text]) + return h('button', { + ref: element, + type: 'button', + className: `${className}${expandable ? ` ${css.expandable}` : ''}${expanded ? ` ${css.expanded}` : ''}`, + 'aria-expanded': expanded, + onClick: event => { + if (!expandable) return + preserveScrollableAncestors(event.currentTarget, () => setExpanded(value => !value)) + }, + }, text) +} + +function scrollableAncestorPositions(element) { + const positions = [] + for (let current = element.parentElement; current !== null; current = current.parentElement) { + if (current.scrollHeight > current.clientHeight || current.scrollWidth > current.clientWidth) { + positions.push([current, current.scrollLeft, current.scrollTop]) + } + } + return positions +} + +function restoreScrollableAncestors(positions) { + const restore = () => { + for (const [current, left, top] of positions) { + current.scrollLeft = left + current.scrollTop = top + } + } + restore() + window.requestAnimationFrame(() => window.requestAnimationFrame(restore)) +} + +function preserveScrollableAncestors(element, update) { + const positions = scrollableAncestorPositions(element) + update() + restoreScrollableAncestors(positions) +} + +function preserveResourceRowPosition(element, update) { + const row = element.closest('article') + const panel = element.closest('[role="tabpanel"]') + const identity = row?.querySelector('strong')?.textContent + const top = row?.getBoundingClientRect().top + if (panel === null) { + update() + return + } + const positions = [] + for (let current = element.parentElement; current !== null; current = current.parentElement) { + positions.push([current, current.scrollLeft, current.scrollTop]) + if (current === panel) break + } + update() + restoreScrollableAncestors(positions) + window.requestAnimationFrame(() => window.requestAnimationFrame(() => { + for (const [current, left, scrollTop] of positions) { + current.scrollLeft = left + current.scrollTop = scrollTop + } + const nextRow = identity === undefined ? undefined : [...(panel?.querySelectorAll('article') ?? [])] + .find(candidate => candidate.querySelector('strong')?.textContent === identity) + if (nextRow === undefined || top === undefined) return + for (const [current, left] of positions) { + const offset = nextRow.getBoundingClientRect().top - top + if (Math.abs(offset) <= 1) break + current.scrollLeft = left + if (current.scrollHeight > current.clientHeight) current.scrollTop += offset + } + })) +} + +function matchesResourceSearch(query, values) { + const normalized = query.trim().toLocaleLowerCase() + return normalized === '' || values.some(value => String(value ?? '').toLocaleLowerCase().includes(normalized)) +} + +function ResourceStatusBadge({ label, enabled = false, pending = false }) { + return h('span', { + className: `${css.statusBadge}${enabled ? ` ${css.statusEnabled}` : ''}${pending ? ` ${css.statusPending}` : ''}`, + 'aria-live': pending ? 'polite' : undefined, + }, label) +} + +function persistLocale(locale) { + if (typeof document === 'undefined') return + document.cookie = `${LOCALE_COOKIE}=${locale}; Path=/; Max-Age=31536000; SameSite=Lax` +} + +function browserCookie(name) { + for (const part of document.cookie.split(';')) { + const separator = part.indexOf('=') + if (separator >= 0 && part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim() + } + return '' +} + +async function request(path, { method = 'GET', body } = {}) { + const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method) + const response = await fetch(`${API}/${path}`, { + method, + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...(mutation ? { 'x-dsh-csrf': browserCookie('dsh_gateway_csrf') } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const value = await response.json() + if (!response.ok) { + const detail = value?.error + const error = new Error(typeof detail === 'object' && detail !== null + ? detail.message ?? `HTTP ${String(response.status)}` + : detail ?? `HTTP ${String(response.status)}`) + error.statusCode = response.status + error.code = detail?.code ?? value.code + error.stage = detail?.stage + throw error + } + return value +} + +async function authenticationRequest(path, { method = 'GET', body } = {}) { + const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method) + const response = await fetch(`${AUTH_API}/${path}`, { + method, + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...(mutation ? { 'x-dsh-csrf': browserCookie('dsh_gateway_csrf') } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const value = await response.json() + if (!response.ok) { + const error = new Error(value?.error ?? `HTTP ${String(response.status)}`) + error.statusCode = response.status + error.code = value?.code + throw error + } + return value +} + +function VersionCell({ label, version, detail }) { + return h('div', { className: css.versionCell }, + h('span', { className: css.caption }, label), + h('strong', { className: css.version }, display(version)), + h('span', { className: css.detail }, display(detail))) +} + +function logLevel(entry) { + if (entry?.stream === 'stderr') { + const message = String(entry?.message ?? '').trim() + const lines = message.split('\n') + if (lines.some(line => /^(?:\s*at\s+|.*\b(?:error|fatal|failed|failure|exception|panic|unhandled)\b)|错误|失败|异常|致命/iu.test(line))) return 'error' + if (lines.some(line => /\b(?:warn|warning|deprecated|deprecation)\b|警告|已弃用/iu.test(line))) return 'warning' + return 'info' + } + if (['debug', 'info', 'warning', 'error'].includes(entry?.level)) return entry.level + return /^\s*(warn(?:ing)?)[\s:]/i.test(entry?.message ?? '') ? 'warning' : 'info' +} + +function logMessageSummary(message) { + const lines = String(message ?? '').split('\n').map(line => line.trim()).filter(Boolean) + return lines.find(line => /^(?:AggregateError|Error|EvalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError):/u.test(line)) + ?? lines[0] + ?? '' +} + +function isJsonFragment(message) { + const value = message.trim() + return /^(?:[{}\[\]],?|"(?:[^"\\]|\\.)+"\s*:\s*.*)$/.test(value) +} + +function compactLogEntries(entries) { + const compacted = [] + for (let index = 0; index < entries.length; index += 1) { + const first = entries[index] + const opening = first.value.message?.trim() + if (opening !== '{' && opening !== '[') { + if (!isJsonFragment(first.value.message ?? '')) compacted.push(first) + continue + } + const lines = [first.value.message] + const startedAt = Date.parse(first.value.timestamp) + let merged = false + for (let end = index + 1; end < entries.length; end += 1) { + const next = entries[end] + if ( + next.value.source !== first.value.source + || next.value.stream !== first.value.stream + || logLevel(next.value) !== logLevel(first.value) + || Date.parse(next.value.timestamp) - startedAt > 2_000 + ) break + lines.push(next.value.message) + try { + const value = JSON.parse(lines.join('\n')) + compacted.push({ + identity: entries.slice(index, end + 1).map(item => item.identity).join('|'), + value: { ...first.value, message: JSON.stringify(value) }, + }) + index = end + merged = true + break + } catch {} + } + if (!merged && !isJsonFragment(first.value.message)) compacted.push(first) + } + return compacted +} + +function readLogDisplayLimit() { + try { + const value = Number(window.localStorage.getItem(LOG_DISPLAY_LIMIT_KEY)) + return LOG_DISPLAY_LIMITS.includes(value) ? value : DEFAULT_LOG_DISPLAY_LIMIT + } catch { return DEFAULT_LOG_DISPLAY_LIMIT } +} + +function limitProcessedLogEntries(entries, limit) { + return compactLogEntries(entries).slice(-limit) +} + +function readLogClearCutoff() { + try { + const value = window.sessionStorage.getItem(LOG_CLEAR_CUTOFF_KEY) + return Number.isFinite(Date.parse(value)) ? value : null + } catch { return null } +} + +function latestLogCutoff(entries, now = Date.now()) { + const latest = entries.reduce((value, entry) => { + const timestamp = Date.parse(entry.value.timestamp) + return Number.isFinite(timestamp) ? Math.max(value, timestamp) : value + }, now) + return new Date(latest).toISOString() +} + +function isClearedLog(entry, cutoff) { + const timestamp = Date.parse(entry.timestamp) + return cutoff !== null && Number.isFinite(timestamp) && timestamp <= Date.parse(cutoff) +} + +function downloadLogJsonl(entries) { + if (entries.length === 0) return + const content = `${entries.map(entry => JSON.stringify(entry)).join('\n')}\n` + const url = URL.createObjectURL(new Blob([content], { type: 'application/x-ndjson;charset=utf-8' })) + const link = document.createElement('a') + link.href = url + link.download = `dsh-platform-logs-${new Date().toISOString().replace(/[:.]/gu, '-')}.jsonl` + link.click() + window.setTimeout(() => URL.revokeObjectURL(url), 0) +} + +function LogViewer({ active, focusTaskId, t }) { + const [entries, setEntries] = useState([]) + const [sourceCatalog, setSourceCatalog] = useState([]) + const [query, setQuery] = useState('') + const [source, setSource] = useState('all') + const [level, setLevel] = useState('all') + const [streamState, setStreamState] = useState('connecting') + const [streamRevision, setStreamRevision] = useState(0) + const [autoScroll, setAutoScroll] = useState(true) + const [displayLimit, setDisplayLimit] = useState(readLogDisplayLimit) + const [expanded, setExpanded] = useState(() => new Set()) + const listRef = useRef(null) + const resizeFrameRef = useRef(null) + const clearCutoff = useRef(readLogClearCutoff()) + const logIdentities = useRef(new Set()) + const pendingEntries = useRef([]) + const renderFrame = useRef() + + useEffect(() => { + if (focusTaskId) setQuery(String(focusTaskId)) + }, [focusTaskId]) + + useEffect(() => { + if (!active) return undefined + setStreamState('connecting') + const stream = new EventSource(`${API}/logs/stream?limit=${String(LOG_STREAM_LIMIT)}`) + let lastActivity = Date.now() + const commitPendingEntries = () => { + renderFrame.current = undefined + const pending = pendingEntries.current.splice(0) + if (pending.length === 0) return + setEntries(previous => { + const combined = [...previous, ...pending] + const removed = combined.slice(0, Math.max(0, combined.length - LOG_STREAM_LIMIT)) + for (const entry of removed) logIdentities.current.delete(entry.identity) + return combined.slice(-LOG_STREAM_LIMIT) + }) + } + stream.addEventListener('log', event => { + lastActivity = Date.now() + try { + const entry = JSON.parse(event.data) + const identity = JSON.stringify(entry) + if (isClearedLog(entry, clearCutoff.current) || logIdentities.current.has(identity)) return + logIdentities.current.add(identity) + pendingEntries.current.push({ identity, value: entry }) + if (renderFrame.current === undefined) { + renderFrame.current = window.requestAnimationFrame(commitPendingEntries) + } + } catch {} + }) + stream.addEventListener('sources', event => { + try { + const sources = JSON.parse(event.data) + if (Array.isArray(sources)) setSourceCatalog(sources.filter(value => typeof value === 'string')) + } catch {} + }) + stream.addEventListener('heartbeat', () => { lastActivity = Date.now() }) + stream.onopen = () => { + lastActivity = Date.now() + setStreamState('live') + } + stream.onerror = () => setStreamState('disconnected') + const watchdog = window.setInterval(() => { + if (Date.now() - lastActivity > 35_000) setStreamRevision(value => value + 1) + }, 5_000) + return () => { + window.clearInterval(watchdog) + stream.close() + if (renderFrame.current !== undefined) window.cancelAnimationFrame(renderFrame.current) + for (const entry of pendingEntries.current.splice(0)) logIdentities.current.delete(entry.identity) + renderFrame.current = undefined + } + }, [active, streamRevision]) + + const visibleEntries = limitProcessedLogEntries(entries, displayLimit) + const sources = [...new Set([ + ...sourceCatalog, + ...visibleEntries.map(item => item.value.source).filter(Boolean), + ])].sort() + const normalizedQuery = query.trim().toLocaleLowerCase(t('localeCode') === 'en' ? 'en-US' : 'zh-CN') + const filtered = visibleEntries.filter(item => { + const entry = item.value + return (source === 'all' || entry.source === source) + && (level === 'all' || logLevel(entry) === level) + && (normalizedQuery === '' || JSON.stringify(entry).toLocaleLowerCase().includes(normalizedQuery)) + }) + const hasFilteredEntries = filtered.length > 0 + const exportEntries = entries.slice(-displayLimit).map(item => item.value).filter(entry => (source === 'all' || entry.source === source) + && (level === 'all' || logLevel(entry) === level) + && (normalizedQuery === '' || JSON.stringify(entry).toLocaleLowerCase().includes(normalizedQuery))) + + useEffect(() => { + const frame = resizeFrameRef.current + if (!active || !hasFilteredEntries || frame === null) return undefined + const handle = frame.querySelector(`.${css.logResizeHandle}`) + const scrollContainer = frame.closest(`.${css.tabPanel}`) + if (handle === null) return undefined + let pointerId + let startY = 0 + let startHeight = 0 + let startScrollTop = 0 + let lastClientY = 0 + let minimumHeight = 0 + let maximumHeight = 0 + let scrollFrame + let previousCursor = '' + let previousUserSelect = '' + const keepBottomVisible = () => { + if (scrollFrame !== undefined) window.cancelAnimationFrame(scrollFrame) + scrollFrame = window.requestAnimationFrame(() => { + scrollFrame = undefined + if (scrollContainer === null) return + const overflow = frame.getBoundingClientRect().bottom - scrollContainer.getBoundingClientRect().bottom + 8 + if (overflow <= 0) return + const previousScrollTop = scrollContainer.scrollTop + scrollContainer.scrollTop += Math.min(overflow, 24) + const height = Math.min(maximumHeight, Math.max(minimumHeight, + startHeight + lastClientY - startY + scrollContainer.scrollTop - startScrollTop)) + frame.style.height = `${String(height)}px` + if (scrollContainer.scrollTop > previousScrollTop + && (pointerId === undefined || lastClientY >= scrollContainer.getBoundingClientRect().bottom - 24)) keepBottomVisible() + }) + } + const startResize = event => { + event.preventDefault() + pointerId = event.pointerId + startY = event.clientY + lastClientY = event.clientY + startHeight = frame.getBoundingClientRect().height + startScrollTop = scrollContainer?.scrollTop ?? 0 + const style = window.getComputedStyle(frame) + minimumHeight = Number.parseFloat(style.minHeight) + maximumHeight = Number.parseFloat(style.maxHeight) + previousCursor = document.body.style.cursor + previousUserSelect = document.body.style.userSelect + document.body.style.cursor = 'ns-resize' + document.body.style.userSelect = 'none' + handle.setPointerCapture?.(pointerId) + } + const resize = event => { + if (pointerId === undefined || event.pointerId !== pointerId) return + lastClientY = event.clientY + const height = Math.min(maximumHeight, Math.max(minimumHeight, + startHeight + lastClientY - startY + (scrollContainer?.scrollTop ?? 0) - startScrollTop)) + frame.style.height = `${String(height)}px` + keepBottomVisible() + } + const finishResize = event => { + if (pointerId === undefined || event.pointerId !== pointerId) return + if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) + pointerId = undefined + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + keepBottomVisible() + } + handle.addEventListener('pointerdown', startResize) + window.addEventListener('pointermove', resize) + window.addEventListener('pointerup', finishResize) + window.addEventListener('pointercancel', finishResize) + return () => { + handle.removeEventListener('pointerdown', startResize) + window.removeEventListener('pointermove', resize) + window.removeEventListener('pointerup', finishResize) + window.removeEventListener('pointercancel', finishResize) + if (scrollFrame !== undefined) window.cancelAnimationFrame(scrollFrame) + if (pointerId !== undefined) { + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + } + } + }, [active, hasFilteredEntries]) + + useEffect(() => { + if (!active || !autoScroll || listRef.current === null) return undefined + let layoutFrame + const visibilityFrame = window.requestAnimationFrame(() => { + layoutFrame = window.requestAnimationFrame(() => { + if (listRef.current !== null) listRef.current.scrollTop = listRef.current.scrollHeight + }) + }) + return () => { + window.cancelAnimationFrame(visibilityFrame) + if (layoutFrame !== undefined) window.cancelAnimationFrame(layoutFrame) + } + }, [active, autoScroll, displayLimit, entries, level, query, source]) + + const clearLogView = () => { + clearCutoff.current = latestLogCutoff([...entries, ...pendingEntries.current]) + try { window.sessionStorage.setItem(LOG_CLEAR_CUTOFF_KEY, clearCutoff.current) } catch {} + if (renderFrame.current !== undefined) window.cancelAnimationFrame(renderFrame.current) + renderFrame.current = undefined + pendingEntries.current.length = 0 + logIdentities.current.clear() + setExpanded(new Set()) + setEntries([]) + } + + const changeDisplayLimit = selected => { + const value = Number(selected) + if (!LOG_DISPLAY_LIMITS.includes(value)) return + setDisplayLimit(value) + try { window.localStorage.setItem(LOG_DISPLAY_LIMIT_KEY, String(value)) } catch {} + } + + return h('section', { className: `${css.section} ${css.logSection}`, 'aria-labelledby': 'platform-logs-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-logs-title' }, t('logs')), + h('p', null, t('logsDetail'))), + h('div', { className: css.logTools }, + h('span', { className: `${css.logConnection} ${css[streamState]}`, role: 'status' }, + h('span', { 'aria-hidden': 'true' }), + t(`logs${streamState[0].toUpperCase()}${streamState.slice(1)}`)), + h('button', { + type: 'button', + className: css.autoScrollButton, + onClick: () => setStreamRevision(value => value + 1), + }, t('refreshLogs')), + h('button', { + type: 'button', + className: css.clearLogsButton, + disabled: exportEntries.length === 0, + onClick: () => downloadLogJsonl(exportEntries), + }, t('exportLogs')), + h('button', { + type: 'button', + className: css.clearLogsButton, + disabled: entries.length === 0, + onClick: clearLogView, + }, t('clearLogView')))), + h('div', { className: css.logFilters }, + h('input', { + type: 'search', + value: query, + placeholder: t('searchLogs'), + 'aria-label': t('searchLogs'), + onChange: event => setQuery(event.target.value), + }), + h(SelectMenu, { + value: source, + ariaLabel: t('logSource'), + items: [{ id: 'all', label: t('allSources') }, ...sources.map(value => ({ id: value, label: value }))], + onSelect: setSource, + className: css.logSelectTrigger, + }), + h(SelectMenu, { + value: level, + ariaLabel: t('logLevel'), + items: ['all', 'debug', 'info', 'warning', 'error'].map(value => ({ id: value, label: t(`level${value[0].toUpperCase()}${value.slice(1)}`) })), + onSelect: setLevel, + className: css.logSelectTrigger, + }), + h(SelectMenu, { + value: displayLimit, + ariaLabel: t('logDisplayLimit'), + items: LOG_DISPLAY_LIMITS.map(value => ({ id: String(value), label: t('logDisplayLimitValue').replace('{count}', String(value)) })), + onSelect: changeDisplayLimit, + className: css.logSelectTrigger, + }), + ), + h('div', { className: css.logSummaryRow }, + h('div', { className: css.logSummary }, t('logCount').replace('{shown}', String(filtered.length)).replace('{total}', String(displayLimit))), + h('label', { className: css.logAutoScroll }, + h('input', { + type: 'checkbox', + checked: autoScroll, + 'aria-label': t('autoScroll'), + onChange: event => setAutoScroll(event.target.checked), + }), + h('span', { 'aria-hidden': 'true' }), + h('b', null, t('autoScroll')))), + filtered.length === 0 + ? h('p', { className: css.emptyLogs }, visibleEntries.length === 0 ? t('noLogs') : t('noMatchingLogs')) + : h('div', { className: css.logResizeFrame, ref: resizeFrameRef }, + h('div', { className: css.logList, ref: listRef }, filtered.map(item => { + const entry = item.value + const entryLevel = logLevel(entry) + const isExpanded = expanded.has(item.identity) + const toggle = () => setExpanded(value => { + const next = new Set(value) + if (next.has(item.identity)) next.delete(item.identity); else next.add(item.identity) + return next + }) + return h('article', { + className: css.logEntry, + key: item.identity, + role: 'button', + tabIndex: 0, + 'aria-expanded': isExpanded, + onClick: toggle, + onKeyDown: event => { + if (!['Enter', ' '].includes(event.key)) return + event.preventDefault() + toggle() + }, + }, + h('div', { className: css.logMeta }, + h('strong', { className: `${css.logLevel} ${css[`log${entryLevel[0].toUpperCase()}${entryLevel.slice(1)}`]}` }, t(`level${entryLevel[0].toUpperCase()}${entryLevel.slice(1)}`)), + h('span', { className: css.logSource }, display(entry.source)), + h('time', { dateTime: entry.timestamp }, localTime(entry.timestamp, t('localeCode')))), + h('div', { className: css.logMessageRow }, + h('pre', null, display(logMessageSummary(entry.message))), + h('span', { className: css.logChevron, 'aria-hidden': true })), + isExpanded ? h('pre', { className: css.logDetails }, JSON.stringify(entry, null, 2)) : null) + })), + h('span', { className: css.logResizeHandle, 'aria-hidden': true }))) +} + +function storageValue(key) { + try { return window.localStorage.getItem(key) } catch { return null } +} + +function writeStorage(key, value) { + try { + if (value === null) window.localStorage.removeItem(key) + else window.localStorage.setItem(key, value) + } catch {} +} + +function parsedStorage(key) { + try { return JSON.parse(storageValue(key) ?? 'null') } catch { return null } +} + +function candidateIdentity(candidate) { + return candidate.kind === 'stable' + ? `stable:${String(candidate.targetSequence)}` + : `upstream:${candidate.version}` +} + +function eligibleCandidates(status) { + if (status?.automaticCheck?.notificationsEnabled !== true) return [] + const candidates = [] + if (status?.updateChannel === 'stable' && status?.latestAutomatic?.stable !== null && status?.latestAutomatic?.stable !== undefined) { + candidates.push({ kind: 'stable', ...status.latestAutomatic.stable }) + } + const upstream = status?.latestAutomatic?.upstream + const held = upstream !== null && upstream !== undefined && (status?.holds ?? []).some(hold => hold.dshVersion === upstream.version) + if (status?.updateChannel === 'experimental' && upstream !== null && upstream !== undefined && !held) { + candidates.push({ kind: 'upstream', ...upstream }) + } + return candidates.filter(candidate => storageValue(`dsh-platform:update-notice-dismissed:${candidate.kind}`) !== candidateIdentity(candidate)) +} + +function clearSatisfiedDismissals(status) { + if (status?.update?.status !== 'success') return + const completion = status.update.taskId ?? status.update.updatedAt + if (completion === undefined || completion === null || storageValue('dsh-platform:update-notice-cleared-completion') === completion) return + writeStorage('dsh-platform:update-notice-dismissed:stable', null) + writeStorage('dsh-platform:update-notice-dismissed:upstream', null) + writeStorage('dsh-platform:update-notice-cleared-completion', completion) +} + +function updateNoticeModel(candidate, status) { + const dsh = candidate.kind === 'stable' ? candidate.dsh : candidate.version + const dshChanged = dsh !== status?.current?.dsh + if (candidate.kind === 'upstream') return { kind: 'upstream', scope: 'dsh', dsh, dshChanged } + const environment = candidate.environment ?? status?.supported?.environment ?? status?.current?.environment + const environmentChanged = environment !== status?.current?.environment + const scope = dshChanged && environmentChanged ? 'both' : environmentChanged ? 'environment' : 'dsh' + return { kind: candidate.kind, scope, dsh, environment, dshChanged, environmentChanged } +} + +function UpdateReminder({ t }) { + const [status, setStatus] = useState(null) + const [tick, setTick] = useState(0) + const [ownsNotice, setOwnsNotice] = useState(false) + const ownerId = useRef(`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`) + const previousFocus = useRef(null) + + const refresh = useCallback(async () => { + try { + const value = await request('status') + clearSatisfiedDismissals(value) + setStatus(value) + return value + } catch {} + return undefined + }, []) + + useEffect(() => { + const unsubscribeEvents = subscribePlatformStateEvents({ state: () => { void refresh() } }) + const refreshAndConnect = async () => { + await refresh() + } + void refreshAndConnect() + const timer = window.setInterval(() => { void refreshAndConnect() }, 30_000) + const changed = () => setTick(value => value + 1) + const visibilityChanged = () => { + if (document.visibilityState !== 'visible' && parsedStorage(NOTICE_OWNER_KEY)?.id === ownerId.current) { + writeStorage(NOTICE_OWNER_KEY, null) + } + changed() + } + const leaseTimer = window.setInterval(changed, NOTICE_OWNER_TTL / 2) + window.addEventListener('storage', changed) + document.addEventListener('visibilitychange', visibilityChanged) + return () => { + unsubscribeEvents() + window.clearInterval(timer) + window.clearInterval(leaseTimer) + window.removeEventListener('storage', changed) + document.removeEventListener('visibilitychange', visibilityChanged) + const owner = parsedStorage(NOTICE_OWNER_KEY) + if (owner?.id === ownerId.current) writeStorage(NOTICE_OWNER_KEY, null) + } + }, [refresh]) + + const candidate = eligibleCandidates(status)[0] + const identity = candidate === undefined ? null : candidateIdentity(candidate) + + useEffect(() => { + if (identity === null || document.visibilityState !== 'visible') { + setOwnsNotice(false) + return + } + const now = Date.now() + const snooze = parsedStorage(NOTICE_SNOOZE_KEY) + const owner = parsedStorage(NOTICE_OWNER_KEY) + const available = !(snooze?.identity === identity && snooze.until > now) + && (owner?.id === ownerId.current || owner?.expiresAt <= now || owner === null) + if (available) writeStorage(NOTICE_OWNER_KEY, JSON.stringify({ id: ownerId.current, expiresAt: now + NOTICE_OWNER_TTL })) + setOwnsNotice(available) + }, [identity, tick]) + + useEffect(() => { + if (!ownsNotice) return undefined + if (previousFocus.current === null && document.activeElement instanceof HTMLElement && document.activeElement !== document.body) { + previousFocus.current = document.activeElement + } + const timer = window.setInterval(() => { + writeStorage(NOTICE_OWNER_KEY, JSON.stringify({ id: ownerId.current, expiresAt: Date.now() + NOTICE_OWNER_TTL })) + }, NOTICE_OWNER_TTL / 2) + return () => window.clearInterval(timer) + }, [identity, ownsNotice]) + + const dismiss = useCallback(permanent => { + if (permanent) writeStorage(`dsh-platform:update-notice-dismissed:${candidate?.kind}`, identity) + else writeStorage(NOTICE_SNOOZE_KEY, JSON.stringify({ identity, until: Date.now() + 3_600_000 })) + writeStorage(NOTICE_OWNER_KEY, null) + setTick(value => value + 1) + window.requestAnimationFrame(() => { + if (previousFocus.current?.isConnected) previousFocus.current.focus() + previousFocus.current = null + }) + }, [candidate?.kind, identity]) + + useEffect(() => { + const handleClick = event => { + const button = event.target.closest('button[data-notice-action]') + if (button === null || button.closest('aside[role="status"]') === null) return + dismiss(button.dataset.noticeAction === 'dismiss') + } + document.addEventListener('click', handleClick) + return () => document.removeEventListener('click', handleClick) + }, [dismiss]) + + if (!ownsNotice || candidate === undefined) return null + const notice = updateNoticeModel(candidate, status) + const scopeName = `${notice.scope[0].toUpperCase()}${notice.scope.slice(1)}` + const title = t(`${notice.kind}Notice${scopeName}Title`) + const dshLine = t('updateNoticeDshLine') + .replace('{version}', display(notice.dsh)) + .replace('{state}', notice.dshChanged ? t('noticeWillUpdate') : '') + const environmentLine = notice.kind === 'stable' + ? t('updateNoticeEnvironmentLine') + .replace('{version}', displayEnvironment(notice.environment)) + .replace('{state}', notice.environmentChanged ? t('noticeWillUpdate') : '') + : null + return h('aside', { className: css.updateReminder, role: 'status', 'aria-live': 'polite' }, + h('strong', null, title), + h('p', { className: css.updateReminderVersions }, + h('span', null, dshLine), + environmentLine === null ? null : h('span', null, environmentLine)), + h('div', { className: css.reminderActions }, + h('button', { type: 'button', 'data-notice-action': 'snooze' }, t('later')), + h('button', { type: 'button', 'data-notice-action': 'dismiss' }, t('dismissVersion'))), + h('button', { type: 'button', className: css.updateReminderClose, 'data-notice-action': 'snooze', 'aria-label': t('closeReminder'), title: t('closeReminder') }, '×')) +} + +function LifecycleGuard({ connection }) { + useEffect(() => { + const generation = connection.generation ?? connection.hostDescription + let stopped = false + let navigating = false + let hadConnection = generation.getSnapshot() !== undefined + let graceTimer + let retryTimer + + const clearTimers = () => { + window.clearTimeout(graceTimer) + window.clearTimeout(retryTimer) + graceTimer = undefined + retryTimer = undefined + } + const navigate = () => { + if (stopped || navigating || window.location.pathname === LIFECYCLE_WAIT_PATH) return + navigating = true + window.location.replace(lifecycleWaitUrl()) + } + const scheduleReadiness = () => { + if (stopped || retryTimer !== undefined) return + retryTimer = window.setTimeout(() => { + retryTimer = undefined + void verifyReadiness() + }, READINESS_RETRY_MS) + } + const verifyReadiness = async () => { + if (stopped || generation.getSnapshot() !== undefined) return + try { + const response = await fetch(LIFECYCLE_READINESS_PATH, { + headers: { accept: 'application/json' }, + cache: 'no-store', + }) + if (response.status === 503) { + const value = await response.json().catch(() => ({})) + if (value.state !== undefined && value.state !== 'unknown') navigate() + else scheduleReadiness() + return + } + } catch {} + scheduleReadiness() + } + const inspectPlatformState = async () => { + try { + if (requiresLifecycleHoldingPage(await request('status'))) navigate() + } catch {} + } + const connectionChanged = () => { + if (generation.getSnapshot() !== undefined) { + hadConnection = true + clearTimers() + return + } + if (!hadConnection || graceTimer !== undefined) return + graceTimer = window.setTimeout(() => { + graceTimer = undefined + void verifyReadiness() + }, CONNECTION_LOSS_GRACE_MS) + } + + const unsubscribe = generation.subscribe(connectionChanged) + const unsubscribeEvents = subscribePlatformStateEvents({ state: () => { void inspectPlatformState() } }) + void inspectPlatformState() + connectionChanged() + return () => { + stopped = true + clearTimers() + unsubscribe() + unsubscribeEvents() + } + }, [connection]) + return null +} + +function systemPluginSummary(progress, draft, t) { + if (progress?.phase === 'restarting') return t('systemPluginRestarting') + if (progress?.phase === 'applying') { + const actionKey = { + install: 'pluginActionInstall', uninstall: 'pluginActionUninstall', enable: 'pluginActionEnable', disable: 'pluginActionDisable', + }[progress.action] + return t('systemPluginApplyingItem', { + action: t(actionKey ?? 'pluginActionWorking'), id: progress.id, current: progress.current, total: progress.total, + }) + } + return draft.size > 0 ? t('pendingSystemPluginChanges', { count: draft.size }) : t('pluginChangesPendingDetail') +} + +function SystemPluginManager({ plugins, draft, applyingDraft, progress, operation, busy, error, onAction, onCancel, onApply, t }) { + const operationBusy = operation?.status === 'running' + const [query, setQuery] = useState('') + const filteredPlugins = plugins.filter(plugin => matchesResourceSearch(query, [plugin.id])) + const pagination = usePaginatedItems('system-plugins', filteredPlugins) + const visiblePlugins = pagination.items + const restartRequired = draft.size > 0 || plugins.some(plugin => plugin.pendingRestart) || progress !== null + return h('section', { className: `${css.section} ${css.pluginSection}`, 'aria-labelledby': 'system-plugins-title' }, + h('div', { className: `${css.sectionHeading} ${css.resourceSectionHeading}` }, + h('h3', { id: 'system-plugins-title' }, t('systemPlugins')), + h('div', { className: css.resourceHeadingDetail }, + h('p', null, t('systemPluginsDetail')), + h('input', { + type: 'search', className: css.resourceSearch, value: query, placeholder: t('searchSystemPlugins'), 'aria-label': t('searchSystemPlugins'), + onChange: event => { setQuery(event.target.value); pagination.setPage(0) }, + }))), + restartRequired ? h('div', { className: css.pluginRestartNotice, role: 'status' }, + h('div', null, + h('strong', null, t('pluginChangesPending')), + h('p', null, systemPluginSummary(progress, draft, t))), + h('div', { className: css.pluginDraftActions }, + h('button', { type: 'button', className: css.secondaryButton, disabled: busy, onClick: onCancel }, t('cancelChanges')), + h('button', { type: 'button', className: css.primaryButton, disabled: busy, onClick: onApply }, t('applyPluginChanges')))) : null, + h('div', { className: css.pluginList }, + visiblePlugins.length === 0 + ? h('p', { className: css.emptyPlugins }, plugins.length === 0 ? t('noSystemPlugins') : t('noMatchingResources')) + : visiblePlugins.map(plugin => { + const action = draft.get(plugin.id) + const projected = action === 'install' ? { ...plugin, installed: true, enabled: true } + : action === 'enable' ? { ...plugin, enabled: true } + : action === 'disable' ? { ...plugin, enabled: false } : plugin + const isActive = operationBusy && operation.pluginId === plugin.id + const applyingAction = restartRequired + ? applyingDraft.get(plugin.id) ?? (isActive ? operation.action : undefined) + : isActive ? operation.action : undefined + const description = plugin.description?.[t('localeCode')] ?? plugin.id + const stateKey = applyingAction !== undefined + ? { install: 'statusInstalling', uninstall: 'statusUninstalling', enable: 'statusEnabling', disable: 'statusDisabling' }[applyingAction] + : action !== undefined + ? { install: 'pendingInstall', uninstall: 'pendingUninstall', enable: 'pendingEnable', disable: 'pendingDisable' }[action] + : plugin.installed ? (plugin.enabled ? 'resourceEnabled' : 'resourceDisabled') : 'notInstalled' + return h('article', { className: css.pluginRow, key: plugin.id }, + h('div', { className: css.pluginIdentity }, + h('div', { className: css.resourceHeading }, + h(ResourceStatusBadge, { label: t(stateKey ?? 'pluginActionWorking'), enabled: applyingAction === undefined && action === undefined && plugin.enabled, pending: applyingAction !== undefined || action !== undefined }), + h('strong', null, `@dsh-docker/${plugin.id}`)), + h(ExpandableDescription, { text: description, identity: plugin.id })), + !projected.installed + ? h('div', { className: css.pluginActions }, + h('button', { + type: 'button', + className: css.primaryButton, + disabled: busy, + onClick: event => preserveResourceRowPosition(event.currentTarget, () => onAction(plugin, 'install')), + }, t('installPlugin'))) + : plugin.protected + ? h('span', { className: css.managedBadge }, t('managed')) + : h('div', { className: css.pluginActions }, + h('label', { className: css.toggle }, + h('input', { + type: 'checkbox', + checked: projected.enabled, + disabled: busy || action === 'install', + onChange: event => preserveResourceRowPosition(event.currentTarget, () => onAction(plugin, event.target.checked ? 'enable' : 'disable')), + }), + h('span', { 'aria-hidden': 'true' })))) + })), + h(ListPagination, { pagination, total: filteredPlugins.length, t }), + operation?.status === 'failed' + ? h('p', { className: css.error, role: 'alert' }, localizedError(operation.error, t)) + : error ? h('p', { className: css.error, role: 'alert' }, localizedError(error, t)) + : null) +} + +function SystemSkillManager({ skills, operation, busy, error, onAction, t }) { + const operationBusy = operation?.status === 'running' + const [query, setQuery] = useState('') + const filteredSkills = skills.filter(skill => matchesResourceSearch(query, [skill.id])) + const pagination = usePaginatedItems('system-skills', filteredSkills) + return h('section', { className: `${css.section} ${css.pluginSection}`, 'aria-labelledby': 'system-skills-title' }, + h('div', { className: `${css.sectionHeading} ${css.resourceSectionHeading}` }, + h('h3', { id: 'system-skills-title' }, t('systemSkills')), + h('div', { className: css.resourceHeadingDetail }, + h('p', null, t('systemSkillsDetail')), + h('input', { + type: 'search', className: css.resourceSearch, value: query, placeholder: t('searchSystemSkills'), 'aria-label': t('searchSystemSkills'), + onChange: event => { setQuery(event.target.value); pagination.setPage(0) }, + }))), + h('div', { className: css.pluginList }, + filteredSkills.length === 0 + ? h('p', { className: css.emptyPlugins }, skills.length === 0 ? t('noSystemSkills') : t('noMatchingResources')) + : pagination.items.map(skill => { + const isActive = operationBusy && operation.skillId === skill.id + const stateKey = isActive + ? { install: 'statusInstalling', enable: 'statusEnabling', disable: 'statusDisabling' }[operation.action] + : skill.installed ? (skill.enabled ? 'resourceEnabled' : 'resourceDisabled') : 'notInstalled' + return h('article', { className: css.pluginRow, key: skill.id }, + h('div', { className: css.pluginIdentity }, + h('div', { className: css.resourceHeading }, + h(ResourceStatusBadge, { label: t(stateKey ?? 'skillActionWorking'), enabled: !isActive && skill.enabled, pending: isActive }), + h('strong', null, skill.id)), + h(ExpandableDescription, { text: skill.description?.[t('localeCode')] ?? skill.id, identity: skill.id })), + !skill.installed + ? h('div', { className: css.pluginActions }, + h('button', { + type: 'button', className: css.primaryButton, disabled: busy, + onClick: event => preserveResourceRowPosition(event.currentTarget, () => onAction(skill, 'install')), + }, t('installPlugin'))) + : h('div', { className: css.pluginActions }, + h('label', { className: css.toggle }, + h('input', { + type: 'checkbox', checked: skill.enabled, disabled: busy, + onChange: event => preserveResourceRowPosition(event.currentTarget, () => onAction(skill, event.target.checked ? 'enable' : 'disable')), + }), + h('span', { 'aria-hidden': 'true' })))) + })), + h(ListPagination, { pagination, total: filteredSkills.length, t }), + operation?.status === 'failed' + ? h('p', { className: css.error, role: 'alert' }, localizedError(operation.error, t)) + : error ? h('p', { className: css.error, role: 'alert' }, localizedError(error, t)) : null) +} + +function transactionStageState(index, currentIndex, status) { + if (index < currentIndex || (index === currentIndex && status === 'success')) return 'completed' + if (index === currentIndex) return status === 'failed' ? 'failed' : 'active' + return 'pending' +} + +function TransactionStageLogs({ update, visible, t, onViewFullLog }) { + const [entries, setEntries] = useState([]) + const [expanded, setExpanded] = useState({}) + const [autoScroll, setAutoScroll] = useState(true) + const [copied, setCopied] = useState(false) + const touched = useRef(new Set()) + const identities = useRef(new Set()) + const previousStage = useRef() + const activeList = useRef(null) + const phase = update?.phase ?? (isRecoveryOperation(update?.operation) ? update.rollbackPhase : update?.status) + const model = updateProgressModel(update ?? {}, t) + const currentStage = transactionLogStage(phase, update, t) + useEffect(() => { + if (!visible || !update?.taskId || !phase) { + setEntries([]) + return undefined + } + setEntries([]) + setExpanded({ [currentStage.key]: update.status !== 'success' }) + touched.current.clear() + identities.current.clear() + previousStage.current = currentStage.key + const params = new URLSearchParams({ taskId: String(update.taskId), operation: String(update.operation ?? 'update'), limit: '1000' }) + const stream = new EventSource(`${API}/logs/stream?${params.toString()}`) + stream.addEventListener('log', event => { + try { + const entry = JSON.parse(event.data) + const identity = JSON.stringify(entry) + if (identities.current.has(identity)) return + identities.current.add(identity) + setEntries(previous => [...previous, entry].slice(-1000)) + } catch {} + }) + return () => stream.close() + }, [update?.taskId, visible]) + useEffect(() => { + const prior = previousStage.current + setExpanded(value => { + const next = { ...value } + if (prior !== undefined && prior !== currentStage.key && !touched.current.has(prior)) next[prior] = false + if (update.status === 'success') { + touched.current.delete(currentStage.key) + next[currentStage.key] = false + } else if (!touched.current.has(currentStage.key)) { + next[currentStage.key] = true + } + return next + }) + previousStage.current = currentStage.key + }, [currentStage.key, update.status]) + useEffect(() => { + if (!autoScroll || !expanded[currentStage.key]) return + activeList.current?.scrollTo({ top: activeList.current.scrollHeight }) + }, [autoScroll, currentStage.key, entries, expanded]) + if (!visible || !update?.taskId || !phase) return null + const groupPrefix = isRecoveryOperation(update?.operation) ? 'rollback' : 'update' + const groups = new Map(model.labels.map((label, index) => [`${groupPrefix}:${String(index)}`, { + key: `${groupPrefix}:${String(index)}`, + label: t(label), + labelKey: label, + index, + entries: [], + }])) + for (const entry of entries) { + const stage = transactionLogStage(entry.phase, update, t) + const group = groups.get(stage.key) ?? { ...stage, entries: [] } + group.entries.push(entry) + groups.set(stage.key, group) + } + const orderedGroups = [...groups.values()].sort((left, right) => left.index - right.index) + const currentEntries = groups.get(currentStage.key)?.entries ?? [] + const toggleStage = key => { + touched.current.add(key) + setExpanded(value => ({ ...value, [key]: !(value[key] ?? key === currentStage.key) })) + } + const copyCurrent = async () => { + if (currentEntries.length === 0) return + try { + await navigator.clipboard.writeText(currentEntries.map(entry => JSON.stringify(entry)).join('\n')) + setCopied(true) + window.setTimeout(() => setCopied(false), 1_500) + } catch {} + } + return h('div', { className: css.progressStageLog }, + h('div', { className: css.progressLogList, role: 'log' }, orderedGroups.map(group => { + const state = transactionStageState(group.index, model.stage, update.status) + const isExpanded = expanded[group.key] ?? (state === 'active' || state === 'failed') + const latest = group.entries.at(-1) + const metricSource = state === 'active' || state === 'failed' ? { ...latest, ...update } : latest + const metricLines = stageMetricLines(metricSource, group, state, t) + const metricProgress = state === 'active' ? stageMetricProgress(metricSource) : undefined + const items = stageItems(group, update, state, t) + const completedItems = items.filter(item => item.state === 'completed').length + return h('section', { className: `${css.progressLogGroup} ${css[`progressStage${state[0].toUpperCase()}${state.slice(1)}`]}`, key: group.key }, + h('span', { className: `${css.progressStageMarker} ${css[`progressStageMarker${state[0].toUpperCase()}${state.slice(1)}`]}`, 'aria-hidden': 'true' }), + h('div', { className: css.progressStageHeader }, + h('div', { className: css.progressStageSummary }, + h('strong', null, group.label), + h('span', { className: css.progressStageCount }, t('stageItemsCompleted').replace('{completed}', String(completedItems)).replace('{total}', String(items.length)))), + h('button', { type: 'button', className: css.progressStageToggle, 'aria-expanded': isExpanded, onClick: () => toggleStage(group.key) }, + t(isExpanded ? 'collapseStage' : 'expandStage').replace('{count}', String(group.entries.length)))), + isExpanded ? h(React.Fragment, null, + h('div', { className: css.progressStageItems }, items.map(item => h('div', { className: `${css.progressStageItem} ${css[`progressStageItem${item.state[0].toUpperCase()}${item.state.slice(1)}`]}`, key: item.key }, + h('span', { className: css.progressStageItemMarker, 'aria-hidden': 'true' }), + h('span', null, t({ completed: 'stageItemCompleted', active: 'stageItemActive', failed: 'stageItemFailed', pending: 'stageItemPending' }[item.state]).replace('{item}', item.label)), + item.state === 'active' || item.state === 'failed' ? metricLines.map(line => h('span', { className: css.progressStageMetric, key: line }, line)) : null, + item.state === 'active' && metricProgress !== undefined ? h('span', { className: css.progressStagePercent }, t('stageProgress').replace('{progress}', String(metricProgress))) : null))), + state === 'failed' ? h('p', { className: css.progressStageError }, localizedError(update.error ?? latest?.error ?? t('statusFailed'), t)) : null, + h('div', { className: `${css.progressLogGroupList}${group.entries.length > 0 ? ` ${css.progressLogGroupListPopulated}` : ''}`, ref: group.key === currentStage.key ? activeList : undefined }, group.entries.length === 0 + ? h('p', { className: css.progressLogEmpty }, t('noStageLogs')) + : group.entries.slice(-200).map((entry, index) => h('details', { className: css.progressLogEntry, key: `${entry.timestamp ?? ''}-${index}` }, + h('summary', null, + h('span', { className: `${css.progressLogLevel} ${css[String(entry.level ?? 'info').toLowerCase()] ?? ''}` }, (() => { const level = String(entry.level ?? 'info').toLowerCase(); return t(`level${level[0].toUpperCase()}${level.slice(1)}`) })()), + h('span', { className: css.progressLogSource }, String(entry.source ?? '-')), + h('time', null, localTime(entry.timestamp, t('localeCode'))), + h('span', { className: css.progressLogMessage }, String(entry.message ?? '-').replace(/\s+/gu, ' ')), + h('span', { className: css.progressLogChevron, 'aria-hidden': 'true' })), + h('pre', null, JSON.stringify(entry, null, 2))))), + group.key === currentStage.key ? h('div', { className: css.progressLogActions }, + h('label', { className: css.progressAutoScroll }, + h('input', { type: 'checkbox', checked: autoScroll, onChange: event => setAutoScroll(event.target.checked) }), + h('span', null, t('autoScroll'))), + h('button', { type: 'button', className: css.smallButton, disabled: currentEntries.length === 0, onClick: copyCurrent }, t(copied ? 'logsCopied' : 'copyStageLogs'))) : null) : null) + })), + h('button', { type: 'button', className: css.progressFullLog, onClick: onViewFullLog }, t('viewFullTransactionLog'))) +} + +const PROXY_SCOPES = Object.freeze([ + ['updates', 'proxyScopeUpdates', 'proxyScopeUpdatesDetail'], + ['platform', 'proxyScopePlatform', 'proxyScopePlatformDetail'], + ['dshCore', 'proxyScopeDshCore', 'proxyScopeDshCoreDetail'], + ['dshPlugins', 'proxyScopeDshPlugins', 'proxyScopeDshPluginsDetail'], + ['agentNetwork', 'proxyScopeAgent', 'proxyScopeAgentDetail'], + ['managementTerminal', 'proxyScopeTerminal', 'proxyScopeTerminalDetail'], +]) +const PROXY_TEST_LABELS = Object.freeze({ + 'proxy-address': 'proxyStageAddress', 'proxy-connect': 'proxyStageConnect', + 'proxy-handshake': 'proxyStageHandshake', 'target-dns': 'proxyStageDns', + 'target-tls': 'proxyStageTls', 'target-http': 'proxyStageHttp', +}) +const PROXY_TEST_DETAIL_LABELS = Object.freeze({ + 'route-direct': 'proxyStageDetailDirect', + 'proxy-addresses-resolved': 'proxyStageDetailAddress', + 'proxy-connected': 'proxyStageDetailConnect', + 'proxy-handshake-completed': 'proxyStageDetailHandshake', + 'target-addresses-resolved': 'proxyStageDetailDns', + 'target-tls-verified': 'proxyStageDetailTls', + 'target-http-completed': 'proxyStageDetailHttp', +}) + +function localizedProxyTestDetail(stage, t, fallback) { + const key = PROXY_TEST_DETAIL_LABELS[stage.detailCode] + if (key === undefined) return stage.detail ?? stage.errorCode ?? fallback + const values = { ...(stage.detailData ?? {}) } + if (stage.detailCode === 'target-http-completed') { + const labels = { 'github-metadata': 'proxyTargetGithub', 'npm-registry': 'proxyTargetNpm' } + values.results = (Array.isArray(values.results) ? values.results : []).map(result => ( + `${t(labels[result?.id] ?? result?.id ?? 'unknown')} HTTP ${String(result?.status ?? '-')}` + )).join(t('localeCode') === 'zh' ? ';' : ', ') + } + return t(key, values) +} + +function pendingProxyTestStages(proxy = {}) { + return Object.keys(PROXY_TEST_LABELS) + .filter(stage => stage !== 'target-dns' || (proxy.protocol === 'socks5' && proxy.remoteDns === false)) + .map(stage => ({ stage, status: 'pending' })) +} + +function proxyLines(value) { + return String(value ?? '').split(/\r?\n/u).map(entry => entry.trim()).filter(Boolean) +} + +function directRuleText(configuration) { + return [...new Set([ + ...(configuration.noProxy?.user ?? []), + ...(configuration.bypass?.additional ?? []), + ])].join('\n') +} + +function splitDirectRules(value) { + const rules = proxyLines(value) + return { + noProxy: rules.filter(rule => !/\/\d+$/u.test(rule)), + bypass: rules.filter(rule => /\/\d+$/u.test(rule)), + } +} + +function proxyConnectionDraft(configuration) { + return { + protocol: configuration.proxy.protocol, + host: configuration.proxy.host, + port: configuration.proxy.port, + username: configuration.proxy.username, + remoteDns: configuration.proxy.remoteDns === true, + allProxy: configuration.environment.allProxy === 'scope-proxy', + } +} + +function ProxySettings({ active, t }) { + const [configuration, setConfiguration] = useState(null) + const [connection, setConnection] = useState(null) + const [providers, setProviders] = useState([]) + const [providerQuery, setProviderQuery] = useState('') + const [directRulesText, setDirectRulesText] = useState('') + const [password, setPassword] = useState('') + const [clearPassword, setClearPassword] = useState(false) + const [advanced, setAdvanced] = useState(false) + const [saveState, setSaveState] = useState('') + const [task, setTask] = useState(null) + const [expandedTestStages, setExpandedTestStages] = useState(() => new Set()) + const [testError, setTestError] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const scopeDialog = useRef(null) + const systemRulesDialog = useRef(null) + const providerInfoDialog = useRef(null) + const componentDialog = useRef(null) + const testDialog = useRef(null) + const saveStateTimer = useRef(null) + const toggleScrollPositions = useRef(null) + const proxyLoad = useRef(null) + const providerLoad = useRef(null) + const [providerInfo, setProviderInfo] = useState(null) + + const refreshProviders = useCallback(async () => { + if (providerLoad.current !== null) return providerLoad.current + providerLoad.current = (async () => { + const [inventory, platformStatus] = await Promise.all([ + request('proxy/provider-inventory'), request('status'), + ]) + setProviders(inventory.providers ?? []) + const activeTask = platformStatus?.proxyTestOperation + if (activeTask?.status === 'running' && activeTask.taskId) { + setTask(await request(`proxy/test/tasks/${activeTask.taskId}`)) + } + })().catch(nextError => { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + }).finally(() => { providerLoad.current = null }) + return providerLoad.current + }, []) + + const load = useCallback(async () => { + if (proxyLoad.current !== null) return proxyLoad.current + proxyLoad.current = (async () => { + const next = await request('proxy') + setConfiguration(next) + setConnection(proxyConnectionDraft(next)) + setDirectRulesText(directRuleText(next)) + setError('') + void refreshProviders() + })().catch(nextError => { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + }).finally(() => { proxyLoad.current = null }) + return proxyLoad.current + }, [refreshProviders]) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (active) void refreshProviders() + else { + setPassword('') + setClearPassword(false) + } + }, [active, refreshProviders]) + + useEffect(() => { + if (task?.status !== 'running') return undefined + const timer = window.setTimeout(async () => { + try { setTask(await request(`proxy/test/tasks/${task.taskId}`)) } catch (nextError) { + setTestError(nextError instanceof Error ? nextError.message : String(nextError)) + } + }, 400) + return () => window.clearTimeout(timer) + }, [task]) + + useEffect(() => () => window.clearTimeout(saveStateTimer.current), []) + + useEffect(() => { + if (task && task.status !== 'running') { + setPassword('') + setClearPassword(false) + } + }, [task]) + + const patchConnection = useCallback((path, value) => { + setConnection(current => { + const next = structuredClone(current) + let target = next + for (const part of path.slice(0, -1)) target = target[part] + target[path.at(-1)] = value + return next + }) + }, []) + + const rememberToggleScroll = useCallback(event => { + if (event.type === 'keydown' && event.key !== ' ') return + if (event.type === 'mousedown') event.preventDefault() + toggleScrollPositions.current = scrollableAncestorPositions(event.currentTarget) + }, []) + + const preserveToggleScroll = useCallback((element, update) => { + const positions = toggleScrollPositions.current ?? scrollableAncestorPositions(element) + toggleScrollPositions.current = null + update() + restoreScrollableAncestors(positions) + }, []) + + const toggleScrollHandlers = { + onPointerDown: rememberToggleScroll, + onMouseDown: rememberToggleScroll, + onKeyDown: rememberToggleScroll, + } + + const toggleProxy = event => { + if (event.target.checked && (connection.host.trim() === '' || connection.port === '' || connection.port == null)) { + setError(t('proxyEndpointRequired')) + return + } + preserveToggleScroll(event.currentTarget, () => { void saveImmediate(next => { next.enabled = event.target.checked }) }) + } + + if (configuration === null || connection === null) return h('section', { className: css.section }, + h('h3', null, t('proxyTitle')), + h('p', { className: error ? css.error : undefined }, error || t('proxyLoading'))) + + const componentUnavailable = configuration.componentReady === false + + const candidate = ({ source = configuration, connectionSource = connection, rules = 'form', includeSecret = true } = {}) => { + const directRules = rules === 'form' ? splitDirectRules(directRulesText) : { + noProxy: source.noProxy?.user ?? [], bypass: source.bypass?.additional ?? [], + } + const proxy = { + protocol: connectionSource.protocol, + host: connectionSource.host.trim(), + port: connectionSource.port === '' || connectionSource.port == null ? null : Number(connectionSource.port), + username: connectionSource.username, + passwordConfigured: source.proxy.passwordConfigured === true, + remoteDns: connectionSource.remoteDns === true, + } + if (includeSecret) { + if (clearPassword) proxy.clearPassword = true + else if (password !== '') proxy.password = password + } + return { + schema: 1, enabled: source.enabled === true, proxy, + scopes: Object.fromEntries(PROXY_SCOPES.map(([id]) => [id, source.scopes[id] === true])), + environment: { allProxy: rules === 'form' ? (connectionSource.allProxy ? 'scope-proxy' : null) : source.environment.allProxy }, + modelApi: source.modelApi, + noProxy: { user: directRules.noProxy }, + bypass: { additional: directRules.bypass }, + } + } + + const saveConnection = async () => { + setBusy(true); setError('') + try { + const next = await request('proxy', { method: 'PUT', body: { baseRevision: configuration.revision, value: candidate() } }) + setConfiguration(next) + setConnection(proxyConnectionDraft(next)) + setDirectRulesText(directRuleText(next)) + setPassword('') + setClearPassword(false) + setSaveState('saved') + window.clearTimeout(saveStateTimer.current) + saveStateTimer.current = window.setTimeout(() => setSaveState(''), 3000) + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)); setSaveState('') + if (nextError?.statusCode === 409) await load() + } finally { setBusy(false) } + } + + const saveImmediate = async mutate => { + if (busy) return + const nextSource = structuredClone(configuration) + mutate(nextSource) + setConfiguration(nextSource) + setBusy(true); setError('') + try { + const saved = await request('proxy', { + method: 'PUT', body: { + baseRevision: configuration.revision, + value: candidate({ source: nextSource, connectionSource: proxyConnectionDraft(nextSource), rules: 'saved', includeSecret: false }), + }, + }) + setConfiguration(saved) + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + await load() + } finally { setBusy(false) } + } + + const startTest = async () => { + setBusy(true); setTestError('') + setExpandedTestStages(new Set()) + setTask({ status: 'starting', stages: pendingProxyTestStages(connection) }) + requestAnimationFrame(() => testDialog.current?.showModal()) + try { + const started = await request('proxy/test', { method: 'POST', body: { baseRevision: configuration.revision, value: candidate() } }) + setTask(await request(`proxy/test/tasks/${started.taskId}`)) + } catch (nextError) { + const detail = nextError instanceof Error ? nextError.message : String(nextError) + setTask(current => ({ + status: 'failed', + stages: current?.stages?.length > 0 ? current.stages : pendingProxyTestStages(), + error: { detail }, + })) + setTestError(detail) + if (nextError?.statusCode === 409) await load() + } finally { setBusy(false) } + } + + const cancelTest = async () => { + if (!task?.taskId) return + try { setTask(await request(`proxy/test/tasks/${task.taskId}`, { method: 'DELETE' })) } catch (nextError) { + setTestError(nextError instanceof Error ? nextError.message : String(nextError)) + } + } + + const taskRunning = task?.status === 'starting' || task?.status === 'running' + const formDirty = connection.protocol !== configuration.proxy.protocol + || connection.host.trim() !== configuration.proxy.host + || (connection.port === '' || connection.port == null ? null : Number(connection.port)) !== configuration.proxy.port + || connection.username !== (configuration.proxy.username ?? '') + || connection.remoteDns !== (configuration.proxy.remoteDns === true) + || connection.allProxy !== (configuration.environment.allProxy === 'scope-proxy') + || directRulesText !== directRuleText(configuration) + || password !== '' || clearPassword + const visibleSaveState = formDirty ? 'unsaved' : saveState + const visibleProviders = providers.filter(provider => { + const displayName = typeof provider.displayName === 'string' && provider.displayName.trim() !== '' ? provider.displayName : provider.id + return matchesResourceSearch(providerQuery, [displayName]) + }) + const localize = value => value?.[t('localeCode')] ?? value?.en ?? '' + const catalog = configuration.scopeCatalog + const catalogGroups = Object.entries(catalog?.groups ?? {}) + const catalogNodes = catalogGroups.map(([group, groupValue]) => { + const entries = (catalog?.entries ?? []).filter(entry => entry.group === group) + return h('section', { key: group }, + h('h4', { style: { gridRow: `1 / span ${entries.length}` } }, localize(groupValue)), + ...entries.map(entry => h('div', { key: entry.id }, + h('span', null, localize(entry.source)), + h(ExpandableProxyDescription, { text: localize(entry.detail), identity: entry.id })))) + }) + const summaryNodes = (catalog?.summaries ?? []).map((summary, index) => h('p', { key: index }, localize(summary))) + return h(React.Fragment, null, + h('section', { className: css.section, 'aria-labelledby': 'platform-proxy-master-title' }, + h('div', { className: `${css.proxySettingRow} ${css.proxyMasterRow}` }, + h('div', null, + h('div', { className: css.proxyTitleLine }, + h('h3', { id: 'platform-proxy-master-title' }, t('proxyMaster')), + configuration.componentReady === false ? h('button', { + type: 'button', className: css.proxyComponentAlert, + 'aria-label': t('proxyComponentIssueLabel'), title: t('proxyComponentIssueLabel'), + onClick: () => componentDialog.current?.showModal(), + }, h('svg', { viewBox: '0 0 24 24', 'aria-hidden': true }, + h('circle', { cx: '12', cy: '12', r: '10' }), + h('path', { d: 'M12 6.5v7' }), h('path', { d: 'M12 17.5h.01' }))) : null), + h('small', null, t('proxyMasterDetail'))), + h('label', { className: css.toggle, 'aria-label': t('proxyMaster'), ...toggleScrollHandlers }, + h('input', { type: 'checkbox', checked: configuration.enabled === true, disabled: busy || componentUnavailable, onChange: toggleProxy }), + h('span', { 'aria-hidden': 'true' }))), + error === t('proxyEndpointRequired') ? h('p', { className: css.error, role: 'alert' }, error) : null), + + h('section', { className: css.section, 'aria-labelledby': 'platform-proxy-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('div', { className: css.proxyTitleLine }, + h('h3', { id: 'platform-proxy-title' }, t('proxyTitle')), + visibleSaveState ? h('span', { className: css.proxySaveState, 'data-state': visibleSaveState, role: 'status' }, t(visibleSaveState === 'unsaved' ? 'proxyUnsaved' : 'proxySavedShort')) : null), + h('p', null, t('proxyDetail'))), + h('div', { className: css.actions }, + h('button', { type: 'button', className: css.secondaryButton, disabled: busy || taskRunning || componentUnavailable, onClick: () => { void startTest() } }, t('proxyTestStart')), + h('button', { type: 'button', className: css.primaryButton, disabled: busy || taskRunning || componentUnavailable, onClick: () => { void saveConnection() } }, t('proxySave')))), + h('div', { className: css.proxyFormGrid }, + h('div', { className: css.proxySelectField }, h('span', null, t('proxyProtocol')), h(SelectMenu, { + value: connection.protocol, + disabled: componentUnavailable, + ariaLabel: t('proxyProtocol'), + items: [{ id: 'http', label: 'HTTP' }, { id: 'socks5', label: 'SOCKS5' }], + onSelect: value => patchConnection(['protocol'], value), + className: css.proxySelectTrigger, + })), + h('label', null, h('span', null, t('proxyHost')), h('input', { value: connection.host, disabled: componentUnavailable, maxLength: 253, spellCheck: false, placeholder: '172.17.0.1', onChange: event => patchConnection(['host'], event.target.value) })), + h('label', null, h('span', null, t('proxyPort')), h('input', { type: 'number', min: 1, max: 65535, value: connection.port ?? '', disabled: componentUnavailable, placeholder: '7890', onChange: event => patchConnection(['port'], event.target.value) })), + h('label', null, h('span', null, t('proxyUsername')), h('input', { value: connection.username, disabled: componentUnavailable, maxLength: 255, autoComplete: 'off', onChange: event => patchConnection(['username'], event.target.value) })), + h('div', { className: css.proxyPasswordRow }, + h('div', { className: css.proxyPasswordField }, + h('label', null, h('span', null, t('proxyPassword')), h('input', { type: 'password', value: password, disabled: clearPassword || componentUnavailable, maxLength: 255, autoComplete: 'new-password', placeholder: t('proxyPasswordPlaceholder'), onChange: event => { setPassword(event.target.value); if (event.target.value !== '') setClearPassword(false) } })), + configuration.proxy.passwordConfigured ? h('button', { type: 'button', className: css.proxyClearPassword, disabled: componentUnavailable, 'aria-pressed': clearPassword, onClick: () => { setClearPassword(value => !value); setPassword('') } }, t('proxyClearPassword')) : null), + h('button', { type: 'button', className: css.secondaryButton, disabled: componentUnavailable, 'aria-expanded': advanced, onClick: () => setAdvanced(value => !value) }, t('proxyAdvanced'))), + ), + advanced ? h('div', { className: css.proxyAdvanced }, + h('div', { className: css.sectionHeading }, + h('div', null, h('h4', null, t('proxyRules')), h('p', null, t('proxyRulesDetail'))), + h('button', { type: 'button', className: css.secondaryButton, disabled: componentUnavailable, onClick: () => systemRulesDialog.current?.showModal() }, t('proxySystemRules'))), + h('label', { className: css.proxyDirectRulesField }, h('textarea', { rows: 5, value: directRulesText, disabled: componentUnavailable, spellCheck: false, 'aria-label': t('proxyDirectRules'), placeholder: t('proxyDirectRulesPlaceholder'), onChange: event => setDirectRulesText(event.target.value) }), h('small', null, t('proxyDirectRulesDetail'))), + h('div', { className: `${css.proxySettingRow} ${css.proxyAdvancedSetting}` }, + h('div', null, h('h4', null, t('proxyAllProxy')), h('small', null, t('proxyAllProxyDetail'))), + h('label', { className: css.toggle, 'aria-label': t('proxyAllProxy'), ...toggleScrollHandlers }, h('input', { type: 'checkbox', checked: connection.allProxy, disabled: componentUnavailable, onChange: event => preserveToggleScroll(event.currentTarget, () => patchConnection(['allProxy'], event.target.checked)) }), h('span', { 'aria-hidden': true }))), + connection.protocol === 'socks5' ? h('div', { className: `${css.proxySettingRow} ${css.proxyAdvancedSetting}` }, h('div', null, h('h4', null, t('proxyRemoteDns')), h('small', null, t('proxyRemoteDnsDetail'))), h('label', { className: css.toggle, 'aria-label': t('proxyRemoteDns'), ...toggleScrollHandlers }, h('input', { type: 'checkbox', checked: connection.remoteDns, disabled: componentUnavailable, onChange: event => preserveToggleScroll(event.currentTarget, () => patchConnection(['remoteDns'], event.target.checked)) }), h('span', { 'aria-hidden': true }))) : null) : null, + location.protocol !== 'https:' && (password !== '' || connection.username !== '') ? h('p', { className: css.notice }, t('proxyTransportWarning')) : null, + error && error !== t('proxyEndpointRequired') ? h('p', { className: css.error, role: 'alert' }, error) : null), + + h('section', { className: css.section, 'aria-labelledby': 'platform-proxy-scopes-title' }, + h('div', { className: css.sectionHeading }, h('div', null, h('h3', { id: 'platform-proxy-scopes-title' }, t('proxyScopes')), h('p', null, t('proxyScopesDetail'))), h('button', { type: 'button', className: css.secondaryButton, disabled: componentUnavailable, onClick: () => scopeDialog.current?.showModal() }, t('proxyScopeHelp'))), + h('div', { className: css.proxyScopeGrid }, PROXY_SCOPES.map(([id, title, detail]) => + h('div', { className: css.proxyScopeCard, key: id }, + h('span', { className: css.proxyScopeCopy }, + h('b', null, t(title)), + h(ExpandableProxyDescription, { text: t(detail), identity: `scope:${id}` })), + h('label', { className: css.toggle, 'aria-label': t(title), ...toggleScrollHandlers }, + h('input', { type: 'checkbox', checked: configuration.scopes[id], disabled: busy || componentUnavailable, onChange: event => preserveToggleScroll(event.currentTarget, () => { void saveImmediate(next => { next.scopes[id] = event.target.checked }) }) }), + h('span', { 'aria-hidden': true })))))), + + h('section', { className: `${css.section} ${css.proxyProviderSection}`, 'aria-labelledby': 'platform-proxy-providers-title' }, + h('div', { className: css.proxyProviderHeading }, + h('h3', { id: 'platform-proxy-providers-title' }, t('proxyProviders')), + h('div', null, + h('p', null, t('proxyProvidersDetail')), + h('input', { type: 'search', className: `${css.resourceSearch} ${css.proxyProviderSearch}`, value: providerQuery, disabled: componentUnavailable, placeholder: t('proxyProviderSearch'), 'aria-label': t('proxyProviderSearch'), onChange: event => setProviderQuery(event.target.value) }))), + visibleProviders.length === 0 ? h('p', { className: css.emptyPlugins }, t('proxyNoProviders')) : h('div', { className: css.proxyProviderList }, visibleProviders.map(provider => { + const displayName = typeof provider.displayName === 'string' && provider.displayName.trim() !== '' ? provider.displayName : provider.id + const information = provider.routingCapability === 'forced-direct' ? t('proxyProviderReasonLocal') : provider.routingCapability === 'shared-dsh' ? t('proxyProviderReasonShared') : null + const policy = configuration.modelApi.providers[provider.id] + ?? provider.requestedPolicy + ?? { proxyEnabled: false } + const setPolicy = (element, value) => preserveToggleScroll(element, () => { void saveImmediate(next => { next.modelApi.providers[provider.id] = value }) }) + return h('div', { className: css.proxyProvider, key: provider.id }, + h('div', { className: css.proxyProviderIdentity }, + h('b', null, displayName), + information === null ? null : h('button', { + type: 'button', + className: css.proxyProviderInfo, + 'aria-label': t('proxyProviderInfo', { name: displayName }), + onClick: () => { + setProviderInfo({ title: displayName, detail: information }) + requestAnimationFrame(() => providerInfoDialog.current?.showModal()) + }, + }, 'i')), + h('div', { className: css.proxyProviderControls }, + provider.routingCapability === 'provider' ? null : h('span', { className: css.proxyProviderRoute }, + t(provider.routingCapability === 'shared-dsh' ? 'proxyProviderShared' : 'proxyProviderDirect')), + h('label', { className: css.toggle, 'aria-label': displayName, ...toggleScrollHandlers }, + h('input', { + type: 'checkbox', + checked: policy.proxyEnabled === true, + disabled: provider.routingCapability === 'forced-direct' || componentUnavailable, + onChange: event => setPolicy(event.currentTarget, { proxyEnabled: event.target.checked }), + }), + h('span', { 'aria-hidden': true })))) + }))), + + h('dialog', { ref: testDialog, className: css.proxyTestDialog }, + h('div', null, + h('header', null, h('div', null, h('h3', null, t('proxyTest')), h('p', null, t('proxyTestDetail')))), + task ? h('ol', { className: css.proxyTestStages }, task.stages.map(stage => { + const status = t(`proxyStage${stage.status[0].toUpperCase()}${stage.status.slice(1)}`) + const expanded = expandedTestStages.has(stage.stage) + const toggleDetails = element => preserveScrollableAncestors(element, () => setExpandedTestStages(current => { + const next = new Set(current) + if (next.has(stage.stage)) next.delete(stage.stage) + else next.add(stage.stage) + return next + })) + return h('li', { + key: stage.stage, + 'data-state': stage.status, + role: 'button', + tabIndex: 0, + 'aria-expanded': expanded, + onClick: event => toggleDetails(event.currentTarget), + onKeyDown: event => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + toggleDetails(event.currentTarget) + }, + }, h('span', { 'aria-hidden': 'true' }), h('span', null, + h('b', null, t(PROXY_TEST_LABELS[stage.stage])), + expanded ? h('small', null, localizedProxyTestDetail(stage, t, status)) : null), + h('small', null, status)) + })) : null, + task === null && testError ? h('p', { className: css.error, role: 'alert' }, testError) : null, + task !== null || testError ? h('footer', null, + task && !taskRunning ? h('p', { className: task.status === 'failed' ? css.error : css.proxyHint, role: 'status' }, task.status === 'success' ? t('proxyTestSuccess') : task.status === 'cancelled' ? t('proxyTestCancelled') : `${t('proxyTestFailed')}: ${task.error?.detail ?? task.error?.errorCode ?? ''}`) : null, + taskRunning + ? h('button', { type: 'button', className: css.secondaryButton, disabled: !task?.taskId, onClick: () => { void cancelTest() } }, t('cancel')) + : h('button', { type: 'button', className: css.secondaryButton, onClick: () => testDialog.current?.close() }, t('close'))) : null)), + + h('dialog', { ref: scopeDialog, className: css.proxyScopeDialog }, + h('form', { method: 'dialog' }, + h('header', null, + h('div', null, h('h3', null, t('proxyScopeGuideTitle')), h('p', null, t('proxyScopeGuideDetail'))), + h('button', { value: 'cancel', className: css.secondaryButton }, t('close'))), + h('div', { className: css.proxyScopeCatalog }, catalogNodes), + h('div', { className: css.proxyScopeSummaries }, summaryNodes))), + h('dialog', { ref: providerInfoDialog, className: css.proxyProviderInfoDialog, onClick: event => { if (event.target === event.currentTarget) event.currentTarget.close() } }, + h('form', { method: 'dialog' }, + h('header', null, + h('h3', null, providerInfo?.title ?? ''), + h('button', { value: 'cancel', className: css.secondaryButton }, t('close'))), + h('p', null, providerInfo?.detail ?? ''))), + h('dialog', { ref: componentDialog, className: css.proxyProviderInfoDialog, onClick: event => { if (event.target === event.currentTarget) event.currentTarget.close() } }, + h('form', { method: 'dialog' }, + h('header', null, + h('h3', null, t('proxyComponentIssueTitle')), + h('button', { value: 'cancel', className: css.secondaryButton }, t('close'))), + h('p', null, t('proxyComponentIssueDetail')))), + h('dialog', { ref: systemRulesDialog, className: css.proxyProviderInfoDialog }, + h('form', { method: 'dialog' }, + h('header', null, + h('h3', null, t('proxySystemRulesTitle')), + h('button', { value: 'cancel', className: css.secondaryButton }, t('close'))), + h('div', { className: css.proxySystemRulesBody }, + h('p', null, t('proxySystemRulesDetail')), + h('pre', null, (configuration.noProxy?.system ?? []).join('\n')))))) +} + +function AuthenticationSettings({ active, managementConsoleHref, t }) { + const [context, setContext] = useState(null) + const [loading, setLoading] = useState(false) + const [working, setWorking] = useState(null) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + + useEffect(() => { + if (!message) return undefined + const timer = window.setTimeout(() => setMessage(''), 3_000) + return () => window.clearTimeout(timer) + }, [message]) + + const load = useCallback(async () => { + setLoading(true) + try { + setContext(await authenticationRequest('session-context')) + setError('') + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (!active) return + void load() + }, [active, load]) + + const logout = async scope => { + setWorking(scope) + setMessage('') + setError('') + const suspendedStateEvents = scope === 'all' + if (suspendedStateEvents) closePlatformStateEvents() + try { + await authenticationRequest('browser-logout', { method: 'POST', body: { scope } }) + if (scope === 'all') { + window.location.replace(`/_dsh_platform/auth/?fresh=${Date.now()}`) + return + } + setMessage(t('managementLogoutComplete')) + await load() + } catch (nextError) { + if (suspendedStateEvents) openPlatformStateEvents() + setError(nextError instanceof Error ? nextError.message : String(nextError)) + } finally { + setWorking(null) + } + } + + return h(React.Fragment, null, + h('section', { className: css.section, 'aria-labelledby': 'platform-account-access-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-account-access-title' }, t('accountAccess')), + h('p', null, t('accountAccessDetail'))), + h('a', { + className: `${css.secondaryButton} ${css.authenticationButton}`, + href: managementConsoleHref === null ? undefined : `${managementConsoleHref}#auth-settings`, + 'aria-disabled': managementConsoleHref === null, + onClick: event => { if (managementConsoleHref === null) event.preventDefault() }, + target: '_blank', + rel: 'noopener noreferrer', + }, t('openPlatformManagement'))), + h('p', { className: css.commandHint }, + h('span', null, t('rootResetDetail')), + h('code', null, 'dsh-platform access reset'))), + h('section', { className: css.section, 'aria-labelledby': 'platform-session-access-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-session-access-title' }, t('sessionAccess')), + h('p', null, t('sessionAccessDetail'))), + h('div', { className: css.actions }, + context?.managementAdditionalPasswordEnabled ? h('button', { + type: 'button', + className: `${css.secondaryButton} ${css.authenticationButton}`, + disabled: loading || working !== null, + onClick: () => { void logout('management') }, + }, working === 'management' ? t('loggingOut') : t('logoutManagementOnly')) : null, + h('button', { + type: 'button', + className: `${css.dangerButton} ${css.authenticationButton}`, + disabled: loading || working !== null, + onClick: () => { void logout('all') }, + }, working === 'all' ? t('loggingOut') : t('logoutAll')))), + loading && context === null ? h('p', { className: css.maintenanceStatus }, t('loadingAuthentication')) : null, + message ? h('p', { className: css.maintenanceStatus, role: 'status' }, message) : null, + error ? h('p', { className: css.error, role: 'alert' }, error) : null)) +} + +function PlatformManagement({ t }) { + const [activeTab, setActiveTab] = useState('maintenance') + const [status, setStatus] = useState(null) + const [plugins, setPlugins] = useState([]) + const [systemPluginDraft, setSystemPluginDraft] = useState(() => new Map()) + const [systemPluginApplyingDraft, setSystemPluginApplyingDraft] = useState(() => new Map()) + const [systemPluginProgress, setSystemPluginProgress] = useState(null) + const [skills, setSkills] = useState([]) + const [error, setError] = useState('') + const [connection, setConnection] = useState('connecting') + const [acting, setActing] = useState(false) + const [checking, setChecking] = useState(false) + const [showSuccessfulProgress, setShowSuccessfulProgress] = useState(false) + const [dismissedProgressTaskId, setDismissedProgressTaskId] = useState(null) + const [confirmRestart, setConfirmRestart] = useState(false) + const [focusedLogTaskId, setFocusedLogTaskId] = useState(null) + const [managementConsoleHref, setManagementConsoleHref] = useState(null) + const statusLoad = useRef() + const statusLoadRevision = useRef(0) + const inventoryLoads = useRef({ plugins: undefined, skills: undefined }) + const inventoryLoadRevisions = useRef({ plugins: 0, skills: 0 }) + const activeTabRef = useRef(activeTab) + const tabsRef = useRef(null) + activeTabRef.current = activeTab + + useEffect(() => makeHorizontalTabStripScrollable(tabsRef.current), []) + + useEffect(() => { + const refreshManagementConsoleHref = () => { + void authenticationRequest('session-context') + .then(value => setManagementConsoleHref(value.managementConsoleHref ?? '/_dsh_platform/console/')) + .catch(() => setManagementConsoleHref(null)) + } + refreshManagementConsoleHref() + window.addEventListener('focus', refreshManagementConsoleHref) + return () => window.removeEventListener('focus', refreshManagementConsoleHref) + }, []) + + const refresh = useCallback(() => { + statusLoadRevision.current += 1 + if (statusLoad.current !== undefined) return statusLoad.current + statusLoad.current = (async () => { + let value + let loadedRevision + do { + loadedRevision = statusLoadRevision.current + try { + const nextStatus = await request('status') + value = nextStatus + setStatus(nextStatus) + setError('') + setConnection('online') + } catch (nextError) { + setStatus(null) + setError(nextError instanceof Error ? nextError.message : String(nextError)) + setConnection('offline') + value = undefined + } + } while (loadedRevision !== statusLoadRevision.current) + return value + })().finally(() => { statusLoad.current = undefined }) + return statusLoad.current + }, []) + + const refreshInventory = useCallback(key => { + inventoryLoadRevisions.current[key] += 1 + if (inventoryLoads.current[key] !== undefined) return inventoryLoads.current[key] + const loader = key === 'plugins' + ? { path: 'bundled-plugins', apply: value => setPlugins(value.plugins ?? []) } + : { path: 'system-skills', apply: value => setSkills(value.skills ?? []) } + inventoryLoads.current[key] = (async () => { + let loadedRevision + do { + loadedRevision = inventoryLoadRevisions.current[key] + try { + loader.apply(await request(loader.path)) + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + } + } while (loadedRevision !== inventoryLoadRevisions.current[key]) + })().finally(() => { inventoryLoads.current[key] = undefined }) + return inventoryLoads.current[key] + }, []) + + const act = useCallback(async (path, options) => { + setActing(true) + setError('') + try { + await request(path, options) + await refresh() + return true + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + return false + } finally { + setActing(false) + } + }, [refresh]) + + const checkUpdates = useCallback(async (source = 'manual') => { + setChecking(true) + setError('') + try { + await request('check', { method: 'POST', body: { source } }) + await refresh() + return true + } catch (nextError) { + const nextStatus = await refresh() + if (nextStatus === undefined) setError(nextError instanceof Error ? nextError.message : String(nextError)) + return false + } finally { + setChecking(false) + } + }, [refresh]) + + const changeChannel = useCallback(async channel => { + if (await act('channel', { method: 'PUT', body: { channel } })) void checkUpdates('channel-change') + }, [act, checkUpdates]) + + const restartDsh = useCallback(async () => { + setActing(true) + setError('') + try { + await request('restart-dsh', { method: 'POST' }) + window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) + setConfirmRestart(false) + await refresh() + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)) + } finally { + setActing(false) + } + }, [refresh]) + + const manageSystemPlugin = useCallback((plugin, action) => { + setSystemPluginProgress(null) + setSystemPluginApplyingDraft(new Map()) + setSystemPluginDraft(current => { + const next = new Map(current) + if ((action === 'install' && plugin.installed) + || (action === 'enable' && plugin.enabled) + || (action === 'disable' && !plugin.enabled)) next.delete(plugin.id) + else next.set(plugin.id, action) + return next + }) + }, []) + + const waitForSystemPluginTask = useCallback(async taskId => { + for (let attempt = 0; attempt < 2_400; attempt += 1) { + const operation = await request(`bundled-plugins/task/${taskId}`) + if (operation?.taskId === taskId && operation.status !== 'running') return operation + await new Promise(resolve => window.setTimeout(resolve, 250)) + } + throw new Error('System Plugin task timed out') + }, []) + + const cancelSystemPluginChanges = useCallback(async () => { + setSystemPluginProgress(null) + setSystemPluginApplyingDraft(new Map()) + setSystemPluginDraft(new Map()) + if (plugins.some(plugin => plugin.pendingRestart)) { + await act('bundled-plugins/discard', { method: 'POST' }) + await refreshInventory('plugins') + } + window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) + }, [act, plugins, refreshInventory]) + + const applySystemPluginChanges = useCallback(async () => { + if (systemPluginDraft.size === 0) { + if (plugins.some(plugin => plugin.pendingRestart)) await restartDsh() + return + } + setActing(true) + setSystemPluginApplyingDraft(new Map(systemPluginDraft)) + setError('') + let changed = false + try { + const changes = [...systemPluginDraft] + for (const [index, [id, action]] of changes.entries()) { + setSystemPluginProgress({ phase: 'applying', id, action, current: index + 1, total: changes.length }) + const plugin = plugins.find(item => item.id === id) + if (plugin === undefined) throw new Error(`System Plugin ${id} is no longer available`) + const path = plugin.protected ? 'bundled-plugins/recovery-action' : 'bundled-plugins/action' + const task = await request(path, { method: 'POST', body: { id, action } }) + changed = true + window.sessionStorage.setItem(PLUGIN_DRAFT_KEY, '1') + const operation = await waitForSystemPluginTask(task.taskId) + if (operation.status !== 'success') throw new Error(operation.error ?? 'System Plugin operation failed') + } + setSystemPluginDraft(new Map()) + const restartTask = await request('restart-dsh', { method: 'POST' }) + setSystemPluginProgress({ phase: 'restarting', total: changes.length, taskId: restartTask.taskId }) + window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) + await refresh() + } catch (nextError) { + setSystemPluginProgress(null) + if (changed) await request('bundled-plugins/discard', { method: 'POST' }).catch(() => {}) + window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) + setError(nextError instanceof Error ? nextError.message : String(nextError)) + } finally { + await refreshInventory('plugins') + setActing(false) + } + }, [plugins, refresh, refreshInventory, restartDsh, systemPluginDraft, waitForSystemPluginTask]) + + useEffect(() => { + if (systemPluginProgress?.phase !== 'restarting' || systemPluginProgress.taskId === undefined) return + const lifecycle = status?.dshLifecycle + if (lifecycle?.taskId !== systemPluginProgress.taskId) return + if (['running', 'failed', 'stopped'].includes(lifecycle.state)) setSystemPluginProgress(null) + }, [status?.dshLifecycle, systemPluginProgress]) + + const manageSystemSkill = useCallback(async (skill, action) => { + if (await act('system-skills/action', { method: 'POST', body: { skillId: skill.id, action } })) { + await refreshInventory('skills') + } + }, [act, refreshInventory]) + + useEffect(() => { + if (activeTab === 'plugins' || activeTab === 'skills') void refreshInventory(activeTab) + }, [activeTab, refreshInventory]) + + useEffect(() => { + let checkedOnOpen = false + const unsubscribeEvents = subscribePlatformStateEvents({ + state: () => { + void refresh() + const inventoryKey = activeTabRef.current + if (inventoryKey === 'plugins' || inventoryKey === 'skills') void refreshInventory(inventoryKey) + }, + open: () => setConnection('online'), + error: () => { + setConnection('connecting') + void refresh() + }, + }) + const refreshAndConnect = async () => { + const value = await refresh() + if (value !== undefined) { + if (!checkedOnOpen && TERMINAL.has(value.update?.status ?? 'idle')) { + checkedOnOpen = true + void checkUpdates('page-open') + } + } + return value + } + + void (async () => { + if (window.sessionStorage.getItem(PLUGIN_DRAFT_KEY) === '1') { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + await request('bundled-plugins/discard', { method: 'POST' }) + window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) + break + } catch { + await new Promise(resolve => window.setTimeout(resolve, 100)) + } + } + } + await refreshAndConnect() + })() + + const timer = window.setInterval(() => { void refreshAndConnect() }, 15_000) + const focus = () => { void refreshAndConnect() } + window.addEventListener('focus', focus) + return () => { + window.clearInterval(timer) + window.removeEventListener('focus', focus) + unsubscribeEvents() + } + }, [checkUpdates, refresh, refreshInventory]) + + const update = status?.update ?? {} + const restart = status?.dshLifecycle ?? {} + const pluginOperation = status?.systemPluginOperation ?? {} + const skillOperation = status?.systemSkillOperation ?? {} + const checkingUpdates = checking || update.status === 'checking' + const restartBusy = restart.state === 'restarting' + const busy = (acting && !checking) || restartBusy || pluginOperation.status === 'running' || skillOperation.status === 'running' + || (!TERMINAL.has(update.status ?? 'idle') && update.status !== 'checking') + const updateActive = !TERMINAL.has(update.status ?? 'idle') + useEffect(() => { + if (update.status !== 'success' || !update.taskId) { + setShowSuccessfulProgress(false) + return undefined + } + setShowSuccessfulProgress(true) + const timer = window.setTimeout(() => setShowSuccessfulProgress(false), 3_000) + return () => window.clearTimeout(timer) + }, [update.status, update.taskId, update.updatedAt]) + const failedDismissed = update.status === 'failed' && String(update.taskId ?? '') === dismissedProgressTaskId + const progressVisible = updateActive || (update.status === 'failed' && Boolean(update.taskId) && !failedDismissed) || showSuccessfulProgress + const hasSupportedTarget = status?.supported !== null && status?.supported !== undefined + const progress = Math.max(0, Math.min(100, Number(update.progress) || 0)) + const progressModel = updateProgressModel(update, t) + const recoveryProgress = isRecoveryOperation(update.operation) + const checkingProgress = update.operation === 'check' + const progressTarget = status?.updateChannel === 'experimental' ? status?.upstream?.version : status?.supported?.dsh + const holds = [...new Map([ + ...(status?.holds ?? []), + ...(status?.experimentalBlocked ? [status.experimentalBlocked] : []), + ].map(hold => [hold.id, hold])).values()] + const notices = [] + if (status?.aheadOfStable) notices.push(t('aheadOfStable')) + if (status?.experimentalBlocked) notices.push(t('experimentalBlocked')) + const automaticCheck = status?.automaticCheck ?? { enabled: true, intervalSeconds: 21_600, notificationsEnabled: true } + + const saveAutomaticCheck = async change => { + await act('automatic-check', { + method: 'PUT', + body: { ...automaticCheck, ...change }, + }) + } + + return h('div', { className: css.root }, + h('div', { className: css.platformHeader }, + h('div', { className: css.heading }, + h('div', { className: css.titleRow }, + h('h2', { className: css.title }, t('title')), + h('span', { className: `${css.connection} ${css[connection]}`, role: 'status' }, + h('span', { 'aria-hidden': 'true' }), + t(connection))), + h('p', { className: css.intro }, t('intro'))), + + h('div', { ref: tabsRef, className: css.tabs, role: 'tablist', 'aria-label': t('managementSections') }, + ['maintenance', 'plugins', 'skills', 'proxy', 'updates', 'authentication'].map(tab => h('button', { + key: tab, + id: `platform-tab-${tab}-button`, + type: 'button', + role: 'tab', + 'aria-selected': activeTab === tab, + 'aria-controls': `platform-tab-${tab}`, + tabIndex: activeTab === tab ? 0 : -1, + onClick: () => setActiveTab(tab), + }, t(`${tab}Tab`))))), + + h('div', { + id: 'platform-tab-authentication', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-authentication-button', + hidden: activeTab !== 'authentication', + }, h(AuthenticationSettings, { active: activeTab === 'authentication', managementConsoleHref, t })), + + h('div', { + id: 'platform-tab-proxy', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-proxy-button', + hidden: activeTab !== 'proxy', + }, h(ProxySettings, { active: activeTab === 'proxy', t })), + + h('div', { + id: 'platform-tab-updates', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-updates-button', + hidden: activeTab !== 'updates', + }, + h('section', { className: css.section, 'aria-labelledby': 'platform-channel-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-channel-title' }, t('channel')), + h('p', null, t('channelDetail'))), + h('div', { className: css.segmented, role: 'group', 'aria-label': t('channel') }, + ['stable', 'experimental'].map(channel => h('button', { + key: channel, + type: 'button', + 'aria-pressed': status?.updateChannel === channel, + disabled: busy, + onClick: () => { void changeChannel(channel) }, + }, t(channel))))), + h('div', { className: `${css.versions} ${status?.updateChannel === 'experimental' ? css.experimentalVersions : ''}` }, + h(VersionCell, { label: t('current'), version: status?.current?.dsh, detail: displayEnvironment(status?.current?.environment) }), + h(VersionCell, { label: t('supported'), version: status?.supported?.dsh, detail: displayEnvironment(status?.supported?.environment) }), + status?.updateChannel === 'experimental' + ? h(VersionCell, { label: t('upstream'), version: status?.upstream?.version, detail: t('officialNpm') }) + : null), + notices.length > 0 ? h('p', { className: css.notice }, notices.join(' ')) : null, + error ? h('p', { className: css.error, role: 'alert' }, localizedError(error, t)) : null), + + h('section', { className: css.section, 'aria-labelledby': 'platform-actions-title' }, + h('div', { className: `${css.sectionHeading} ${css.actionHeading}` }, + h('div', null, + h('h3', { id: 'platform-actions-title' }, t('actions')), + h('p', null, update.checkedAt ? `${t('lastChecked')} ${localTime(update.checkedAt, t('localeCode'))}` : t('notChecked'))), + h('div', { className: css.actions }, + h('button', { type: 'button', className: css.secondaryButton, disabled: busy, onClick: () => { void checkUpdates('manual') } }, + checkingUpdates ? h('span', { className: css.checkSpinner, 'aria-hidden': 'true' }) : null, + checkingUpdates ? t('checking') : t('check')), + h('button', { type: 'button', className: css.primaryButton, disabled: busy || update.metadataUnavailable || !hasSupportedTarget || update.updateAvailable !== true, onClick: () => { void act('update', { method: 'POST' }) } }, status?.updateChannel === 'experimental' ? t('updateUpstream') : t('updateSupported')), + )), + h('div', { className: css.updateState, 'aria-live': 'polite' }, + !progressVisible && !failedDismissed && (update.error || update.outcome) ? h('p', null, update.error ? localizedError(update.error, t) : updateOutcome(update.outcome, t)) : null, + progressVisible ? h('div', { className: css.updateProgress }, + h('div', { className: css.progressHeading }, + h('strong', null, checkingProgress + ? t('statusChecking') + : recoveryProgress ? progressModel.title : progressTarget ? t('updateToTarget').replace('{target}', String(progressTarget)) : progressModel.title), + h('span', { className: css.progressHeadingActions }, + h('output', null, `${String(progress)}%`), + update.status === 'failed' ? h('button', { type: 'button', className: css.smallButton, onClick: () => setDismissedProgressTaskId(String(update.taskId ?? '')) }, t('dismissProgress')) : null)), + h('div', { className: css.progress, 'data-complete': progress === 100, role: 'progressbar', 'aria-label': t('progress'), 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': progress }, + h('span', { style: { width: `${String(progress)}%` } })), + h(TransactionStageLogs, { + update, + visible: progressVisible, + t, + onViewFullLog: () => { + setFocusedLogTaskId(update.taskId ?? null) + setActiveTab('maintenance') + }, + })) : null), + update.metadataUnavailable ? h('p', { className: css.notice }, t('metadataUnavailable')) : null, + update.remoteCheckError ? h('p', { className: css.notice, role: 'status' }, t(update.remoteCheckSource === 'upstream' + ? (update.upstream?.version ? 'upstreamCheckFailed' : 'upstreamCheckFailedNoResult') + : (update.checkedAt ? 'remoteCheckFailed' : 'remoteCheckFailedNoResult'))) : null, + holds.length > 0 ? h('div', { className: css.holds }, + holds.map(hold => h('div', { className: css.hold, key: hold.id }, + h('div', null, + h('strong', null, `${display(hold.dshVersion)}${hold.environmentVersion ? ` + ${displayEnvironment(hold.environmentVersion)}` : ''}`), + h('span', null, localizedHoldReason(hold, t))), + h('button', { type: 'button', className: css.smallButton, disabled: busy, onClick: () => { void act('holds/retry', { method: 'POST', body: { id: hold.id } }) } }, t('retry'))))) : null, + null), + + h('section', { className: css.section, 'aria-labelledby': 'automatic-check-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'automatic-check-title' }, t('automaticChecks')), + h('p', null, t('automaticChecksDetail'))), + h('label', { className: css.toggle }, + h('input', { type: 'checkbox', checked: automaticCheck.enabled, disabled: acting, onChange: event => { void saveAutomaticCheck({ enabled: event.target.checked }) } }), + h('span', { 'aria-hidden': 'true' }), + h('b', null, automaticCheck.enabled ? t('enabled') : t('disabled')))), + h('div', { className: css.settingRows }, + h('div', { className: css.settingRow }, + h('span', null, t('checkInterval')), + h(SelectMenu, { + value: automaticCheck.intervalSeconds, + disabled: acting || !automaticCheck.enabled, + ariaLabel: t('checkInterval'), + items: [3_600, 10_800, 21_600, 43_200, 86_400].map(seconds => ({ id: String(seconds), label: t(`interval${String(seconds)}`) })), + onSelect: value => { void saveAutomaticCheck({ intervalSeconds: Number(value) }) }, + className: css.settingSelectTrigger, + })), + h('div', { className: css.settingRow }, + h('span', null, + h('b', null, t('updateNotifications')), + h('small', null, t('updateNotificationsDetail'))), + h('label', { className: css.toggle, 'aria-label': t('updateNotifications') }, + h('input', { type: 'checkbox', checked: automaticCheck.notificationsEnabled, disabled: acting, onChange: event => { void saveAutomaticCheck({ notificationsEnabled: event.target.checked }) } }), + h('span', { 'aria-hidden': 'true' })))))), + + h('div', { + id: 'platform-tab-maintenance', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-maintenance-button', + hidden: activeTab !== 'maintenance', + }, + h('section', { className: css.section, 'aria-labelledby': 'platform-maintenance-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-maintenance-title' }, t('standaloneManagement')), + h('p', null, t('standaloneManagementDetail'))), + h('a', { + className: `${css.secondaryButton} ${css.maintenanceButton}`, + href: managementConsoleHref ?? undefined, + 'aria-disabled': managementConsoleHref === null, + onClick: event => { if (managementConsoleHref === null) event.preventDefault() }, + target: '_blank', + rel: 'noopener noreferrer', + }, t('openPlatformManagement')))), + h('section', { className: css.section, 'aria-labelledby': 'platform-restart-title' }, + h('div', { className: css.sectionHeading }, + h('div', null, + h('h3', { id: 'platform-restart-title' }, t('restartDshSection')), + h('p', null, t('restartDshDetail'))), + h('button', { + type: 'button', + className: `${css.secondaryButton} ${css.maintenanceButton}`, + disabled: busy, + 'aria-controls': 'restart-dsh-confirmation', + 'aria-expanded': confirmRestart, + onClick: () => setConfirmRestart(value => !value), + }, restartBusy ? t('restarting') : t(confirmRestart ? 'cancelRestartDsh' : 'restartDsh'))), + restart.state === 'failed' + ? h('p', { className: css.error, role: 'alert' }, `${t('restartFailed')}: ${localizedError(restart.error, t)}`) + : restartBusy + ? h('p', { className: css.maintenanceStatus, 'aria-live': 'polite' }, t('restarting')) + : null, + confirmRestart ? h('div', { id: 'restart-dsh-confirmation', className: `${css.confirmation} ${css.restartConfirmation}`, role: 'alertdialog', 'aria-labelledby': 'restart-dsh-title' }, + h('h4', { id: 'restart-dsh-title' }, t('restartTitle')), + h('p', null, t('restartWarning')), + h('div', { className: css.confirmActions }, + h('button', { type: 'button', className: css.secondaryButton, onClick: () => setConfirmRestart(false) }, t('cancel')), + h('button', { type: 'button', className: css.primaryButton, disabled: busy, onClick: () => { void restartDsh() } }, t('confirmRestart')))) : null), + h(LogViewer, { active: activeTab === 'maintenance', focusTaskId: focusedLogTaskId, t })), + + h('div', { + id: 'platform-tab-plugins', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-plugins-button', + hidden: activeTab !== 'plugins', + }, h(SystemPluginManager, { + plugins, + draft: systemPluginDraft, + applyingDraft: systemPluginApplyingDraft, + progress: systemPluginProgress, + operation: pluginOperation, + busy, + error, + onAction: (plugin, action) => { void manageSystemPlugin(plugin, action) }, + onCancel: () => { void cancelSystemPluginChanges() }, + onApply: () => { void applySystemPluginChanges() }, + t, + })), + + h('div', { + id: 'platform-tab-skills', + className: css.tabPanel, + role: 'tabpanel', + 'aria-labelledby': 'platform-tab-skills-button', + hidden: activeTab !== 'skills', + }, h(SystemSkillManager, { + skills, + operation: skillOperation, + busy, + error, + onAction: (skill, action) => { void manageSystemSkill(skill, action) }, + t, + }))) +} + +export function apply(ctx) { + const syncLocaleCookie = snapshot => { persistLocale(snapshot.active) } + syncLocaleCookie(ctx.locale.getLocale()) + ctx.on('locale/change', syncLocaleCookie) + ctx.effect(() => ctx.locale.register('settings.dshPlatformManagement', { + zh: { + localeCode: 'zh', + nav: '平台管理', title: '平台管理', intro: 'DSH Docker 运行、更新与恢复', + managementSections: '平台管理功能', updatesTab: '更新管理', maintenanceTab: '运行维护', pluginsTab: '系统插件', skillsTab: '系统技能', authenticationTab: '认证设置', proxyTab: '代理设置', + accountAccess: '账户与密码', accountAccessDetail: '修改用户名或密码需要前往 DSH 管理中心,也可以在容器 Root 终端中完成。', rootResetDetail: 'Root 终端命令:', sessionAccess: '退出登录', sessionAccessDetail: '退出当前浏览器中的 DSH 和管理中心登录会话。', logoutAll: '退出登录', logoutManagementOnly: '仅退出管理中心', loggingOut: '正在退出', loadingAuthentication: '正在读取认证状态…', managementLogoutComplete: '管理中心登录已退出。', + proxyMaster: '使用代理', proxyMasterDetail: '关闭时,所有受管流量范围均直连。', proxyEndpointRequired: '请先填写代理主机和端口。', + proxyTitle: '代理设置', proxyDetail: '配置由 DSH Docker 使用的外部 HTTP 或 SOCKS5 代理。', proxyLoading: '正在加载代理配置…', proxyAdvanced: '高级', proxyUnsaved: '未保存', proxySavedShort: '已保存', proxyProtocol: '代理协议', proxyHost: '主机', proxyPort: '端口', proxyUsername: '用户名', proxyPassword: '密码', proxyPasswordPlaceholder: '留空则保持当前密码', proxyRemoteDns: '通过代理解析域名', proxyRemoteDnsDetail: '仅适用于 SOCKS5;关闭时由容器本地解析。', proxyClearPassword: '清除密码', proxyTransportWarning: '当前页面未使用 HTTPS。代理凭据会受到传输链路保护能力的限制。', proxyComponentUnavailable: '出站代理组件当前不可用。', proxyComponentIssueLabel: '查看出站代理组件异常', proxyComponentIssueTitle: '出站代理组件不可用', proxyComponentIssueDetail: 'DSH Docker 的出站代理组件当前不可用。当前配置仅供查看,保存、连接测试和受管代理流量将在组件恢复后可用。这不代表已配置的外部代理是否可连接。', + proxyScopes: '代理范围', proxyScopesDetail: '仅勾选需要通过外部代理访问网络的来源。', proxyScopeHelp: '范围说明', proxyScopeGuideTitle: '代理范围说明', proxyScopeGuideDetail: '此表由管理后端提供,两套管理界面使用相同分类。', proxyScopeUpdates: '更新管理', proxyScopeUpdatesDetail: '更新检查、npm metadata 与 Artifact 下载。', proxyScopePlatform: '平台组件', proxyScopePlatformDetail: 'Management、Updater 和 DSH Docker 系统插件等的非本地外部请求。', proxyScopeDshCore: 'DSH 核心', proxyScopeDshCoreDetail: 'DSH 核心联网,不包括模型 Provider API。', proxyScopeDshPlugins: 'DSH 插件', proxyScopeDshPluginsDetail: 'DSH 官方插件与用户安装的第三方插件联网。', proxyScopeAgent: 'Agent 联网操作', proxyScopeAgentDetail: 'Agent 工具、命令与其子进程的联网。', proxyScopeTerminal: '容器终端', proxyScopeTerminalDetail: 'DSH 管理中心提供的容器终端。', + proxyRules: '直连规则', proxyRulesDetail: '列出的目标不会使用外部代理。', proxyDirectRules: '附加直连规则', proxyDirectRulesDetail: '每行一个主机、域后缀、IP 地址或 CIDR;使用 .google.com,不使用 *.google.com。', proxyDirectRulesPlaceholder: '.example.com\n10.0.0.0/8', proxySystemRules: '内置规则', proxySystemRulesTitle: '内置直连规则', proxySystemRulesDetail: '以下平台托管的本地目标始终直连,无需重复填写。', proxyAllProxy: '为兼容客户端注入 ALL_PROXY', proxyAllProxyDetail: '仅对明确支持 ALL_PROXY 的客户端有效;默认关闭。', proxyProviders: '模型 Provider', proxyProvidersDetail: '可识别的 DSH llm/stream Provider 可分别选择直连或独立代理。', proxyProviderSearch: '搜索已配置的 Provider', proxyNoProviders: '没有符合条件的已配置 Provider。', proxyProviderDirect: '强制直连', proxyProviderIndependent: '独立代理', proxyProviderShared: '跟随 DSH', proxyProviderInfo: '查看 {name} 的路由说明', proxyProviderReasonLocal: '本地 Provider 强制直连。', proxyProviderReasonShared: '当前客户端无法稳定携带 Provider 身份。开关开启时跟随 DSH 共享流量策略,关闭时直连。', + proxyTest: '代理连接测试', proxyTestDetail: '测试当前表单,不会覆盖已保存配置。', proxyTestStart: '测试连接', proxySave: '保存并应用', proxySaving: '正在保存代理设置', proxySaved: '代理设置已保存', proxyTestSuccess: '代理连接测试通过。', proxyTestFailed: '代理连接测试失败', proxyTestCancelled: '代理连接测试已取消。', proxyStageAddress: '解析代理地址', proxyStageConnect: '连接代理', proxyStageHandshake: '代理握手', proxyStageDns: '解析目标域名', proxyStageTls: '建立目标 TLS', proxyStageHttp: '请求目标服务', proxyStagePending: '待测试', proxyStageRunning: '测试中', proxyStageSuccess: '已通过', proxyStageFailed: '失败', proxyStageCancelled: '已取消', proxyStageSkipped: '已跳过', proxyStageDetailDirect: '当前候选配置使用直连。', proxyStageDetailAddress: '已解析 {count} 个代理地址记录。', proxyStageDetailConnect: '已成功连接代理服务器。', proxyStageDetailHandshake: '已验证 {protocol} 代理协议与认证。', proxyStageDetailDns: '已解析 {count} 个目标地址。', proxyStageDetailTls: '已验证 {count} 个目标的 TLS 连接。', proxyStageDetailHttp: '{results}', proxyTargetGithub: 'GitHub metadata', proxyTargetNpm: 'npm registry', + channel: '更新通道', channelDetail: '实验通道仅更新 DSH,平台环境仍使用正式支持版本。', returnStableProgress: '返回稳定通道', + stable: '稳定', experimental: '实验', current: '当前版本', supported: '正式支持版本', upstream: '上游版本', officialNpm: 'npm 官方源', + actions: '更新操作', lastChecked: '上次检查', notChecked: '尚未检查', check: '检查更新', checking: '检查中', updateSupported: '更新到最新支持版本', updateUpstream: '更新到最新上游版本', rollback: '回滚到上一版本', returnStable: '立即返回稳定通道', retry: '重试', progress: '更新进度', + updateProgress: '更新进度', rollbackProgress: '回滚进度', updateToTarget: '更新到 {target}', progressPrepare: '准备更新', progressAcquire: '下载与验证', progressBuild: '构建 Runtime', progressActivate: '切换与健康检查', stageLogs: '阶段日志', hideStageLogs: '收起日志 · {count} 条', showStageLogs: '查看日志 · {count} 条', noStageLogs: '当前阶段暂无日志', copyStageLogs: '复制当前日志', logsCopied: '已复制', viewFullTransactionLog: '查看完整事务日志', stageCompleted: '阶段已完成。', stageWaiting: '等待前一阶段完成。', stageProgress: '阶段进度 {progress}%', stageItemsCompleted: '已完成 {completed}/{total} 项', expandStage: '展开 · 日志 {count} 条', collapseStage: '收起 · 日志 {count} 条', stageItemCompleted: '已完成:{item}', stageItemActive: '正在执行:{item}', stageItemPending: '待执行:{item}', stageItemFailed: '执行失败:{item}', itemVerifyMetadata: '验证 metadata', itemVerifyKeyring: '验证 keyring', itemVerifyTarget: '验证目标清单', itemDownloadArtifacts: '下载 Artifact', itemVerifyArtifacts: '验证 Artifact 签名、引用、大小和 Hash', itemImportObjects: '导入可信对象库', itemMaterializePristine: '物化 Pristine DSH', itemPrepareEnvironment: '准备 Environment', itemBuildRuntime: '构建 Runtime 并应用完整 Patch Set', itemPreparePlugins: '准备 System Plugin Set', itemSwitchDeployment: '原子切换 Deployment', itemCheckHealth: '检查服务健康状态', itemObserveProbation: '观察候选 Runtime', metadataVerified: 'metadata 已验证。', keyringVerified: 'keyring 已验证。', targetManifestVerified: '目标清单已验证。', artifactDownloadCompleted: 'Artifact 下载已完成。', artifactVerificationCompleted: 'Artifact 签名、引用和 Hash 已验证。', runtimeMaterialized: 'Pristine DSH 已物化。', patchSetApplied: '完整 Patch Set 已应用。', systemPluginsPrepared: 'System Plugin Set 已准备。', deploymentSwitched: 'Deployment 已原子切换。', healthChecksPassed: '服务健康检查已通过。', metricBytesRead: '已读取 {processed} / {total}', metricBytesCopied: '已复制 {processed} / {total}', metricBytesProcessed: '已处理 {processed} / {total}', metricArtifacts: '已验证 {processed} / {total} 个 Artifact', metricFiles: '已完成 {processed} / {total} 个文件', metricItems: '已完成 {processed} / {total} 项', metricServices: '已就绪 {ready} / {total} 个服务', rollbackPrepare: '准备回滚', rollbackSwitch: '切换上一版本', rollbackData: '恢复数据', rollbackVerify: '启动与检查', + dismissProgress: '关闭', itemValidateRollback: '验证回滚计划和上一完整 Deployment', itemPauseRuntime: '暂停当前 DSH Runtime', itemSwitchPrevious: '切换上一完整 Deployment', itemVerifySnapshot: '验证数据快照', itemRestoreSnapshot: '恢复数据快照', itemStartRuntime: '启动上一 DSH Runtime', metricBytesRestored: '已恢复 {processed} / {total}', metricProbationRemaining: '剩余观察 {seconds} 秒', + progressDetailChecking: '正在获取并验证最新的签名更新信息。', progressDetailPlanning: '正在计算需要收敛的完整目标状态。', progressDetailUpstream: '正在查询 npm 官方源中的最新 DSH。', progressDetailDownloading: '正在下载 Artifact,并通过 Stage-0 导入可信对象库。', progressDetailValidating: '正在验证签名、Artifact 引用、大小和内容 Hash。', progressDetailBuilding: '正在从 Pristine DSH、补丁和系统插件构建不可变 Runtime。', progressDetailSnapshot: 'DSH 已暂停,正在为实验更新创建完整数据快照。', progressDetailSwitching: '正在原子切换完整 Deployment,并检查 DSH 是否就绪。', progressDetailProbation: '候选 Runtime 正在持续接受健康检查,观察至 {until}。', + rollbackDetailPreparing: '正在验证回滚计划与上一完整 Deployment。', rollbackDetailStopping: '正在暂停 DSH,准备恢复上一完整状态。', rollbackDetailSwitching: '正在切换上一 Runtime、Environment 和系统插件集合。', rollbackDetailData: '正在校验并恢复更新前的数据快照。', rollbackDetailVerifying: '正在启动 DSH 并执行健康检查。', + statusIdle: '等待操作', statusChecking: '正在检查更新', statusPlanning: '正在准备更新', statusCheckingUpstream: '正在检查上游版本', statusDownloading: '正在下载', statusValidating: '正在验证', statusBuildingCandidate: '正在构建候选版本', statusSnapshottingData: '正在备份数据', statusSwitching: '正在切换版本', statusProbation: '正在观察运行状态', statusRestoringData: '正在恢复数据', statusRollingBack: '正在回滚', statusSuccess: '操作完成', statusFailed: '操作失败', statusUnknown: '正在处理', + outcomeNone: '当前已是最新版本', outcomeFrozen: '等待正式支持版本追上当前版本', outcomeHeld: '此版本已暂停更新', outcomeBlocked: '当前版本组合不可用', outcomeStable: '已切换到稳定版本', outcomeExperimental: '已切换到实验版本', + requestError: '请求失败', operationError: '操作失败,请查看容器日志。', holdVersion: '此版本更新失败,已暂停自动重试。', holdCombination: '此版本与正式环境组合不可用,已暂停自动重试。', + metadataUnavailable: '正式更新信息暂未发布,请稍后再试。', remoteCheckFailed: '远程检查失败,继续显示上次已验证结果。', remoteCheckFailedNoResult: '远程检查失败,暂无已验证结果。', upstreamCheckFailed: 'DSH 官方版本检查失败,继续显示上次已验证结果。', upstreamCheckFailedNoResult: 'DSH 官方版本检查失败,暂无已验证结果。', + aheadOfStable: '当前版本领先正式支持版本,已暂停完整运行组合更新。', experimentalBlocked: '实验 DSH 与正式环境组合不可用。', + returnStableTitle: '恢复稳定状态', returnStableWarning: '将恢复以下时间的数据快照,此后产生的数据会丢失:', confirmDataLoss: '我了解并确认丢弃更新后的数据', cancel: '取消', confirm: '确认恢复', + standaloneManagement: 'DSH 管理中心', standaloneManagementDetail: 'DSH 不可用时仍可进行更新、插件恢复、日志查看和终端操作。', openPlatformManagement: '打开 DSH 管理中心', restartDshSection: '重启 DSH', restartDshDetail: '仅重新启动 DSH,容器和管理中心服务保持运行。', restartDsh: '重新启动 DSH', cancelRestartDsh: '取消重启 DSH', restarting: '正在重新启动 DSH', restartFailed: 'DSH 重启失败', restartTitle: '确认重新启动 DSH', restartWarning: '当前 DSH 连接会暂时中断,重启完成后页面将自动刷新。', confirmRestart: '确认重启', + automaticChecks: '自动检查', automaticChecksDetail: '仅检查可用版本,不会自动下载或更新。', enabled: '已开启', disabled: '已关闭', checkInterval: '检查频率', updateNotifications: '更新提醒', updateNotificationsDetail: '自动检查发现新版本时,弹窗提醒更新。', + systemPlugins: '系统插件', systemPluginsDetail: '管理 DSH Docker 提供的系统插件。', noSystemPlugins: '当前环境没有提供系统插件。', platformManaged: '平台核心组件,始终保持安装和启用。', managed: '平台托管', notInstalled: '未安装', resourceEnabled: '已启用', resourceDisabled: '已禁用', pendingInstall: '待安装', pendingUninstall: '待卸载', pendingEnable: '待启用', pendingDisable: '待禁用', statusInstalling: '安装中', statusUninstalling: '卸载中', statusEnabling: '启用中', statusDisabling: '禁用中', pluginEnabled: '已安装并启用', pluginDisabled: '已安装但已禁用', installPlugin: '安装', uninstallPlugin: '卸载', pluginActionWorking: '正在应用插件设置', pluginActionInstall: '正在安装', pluginActionUninstall: '正在卸载', pluginActionEnable: '正在启用', pluginActionDisable: '正在禁用', pluginChangesPending: '有待应用的修改', pluginChangesPendingDetail: '插件修改尚未应用。应用后将重新启动 DSH 并生效。', pendingSystemPluginChanges: '有 {count} 项修改待应用', systemPluginApplyingItem: '{action} @dsh-docker/{id}({current}/{total})', systemPluginRestarting: '插件修改已应用,正在重新启动 DSH', cancelChanges: '取消修改', applyPluginChanges: '应用并重新启动 DSH', searchSystemPlugins: '搜索系统插件', + systemSkills: '系统技能', systemSkillsDetail: '管理 DSH Docker 提供的系统技能。', noSystemSkills: '当前 Bootstrap 没有提供系统技能。', skillActionWorking: '正在应用技能设置', searchSystemSkills: '搜索系统技能', noMatchingResources: '没有符合搜索条件的项目。', itemsPerPage: '每页数量', itemsPerPageSuffix: '条/页', previousPage: '上一页', nextPage: '下一页', totalItems: '共 {total} 条', goToPage: '前往', pageUnit: '页', + logs: '实时日志', logsDetail: '查看 DSH 与平台各模块的运行日志。', searchLogs: '搜索日志', logSource: '日志模块', logLevel: '日志级别', logDisplayLimit: '显示条数', logDisplayLimitValue: '最近 {count} 条', allSources: '全部模块', levelAll: '全部级别', levelDebug: '调试', levelInfo: '信息', levelWarning: '警告', levelError: '错误', logsLive: '实时', logsConnecting: '连接中', logsDisconnected: '已断开', refreshLogs: '刷新日志', exportLogs: '导出日志', autoScroll: '自动滚动', clearLogView: '清空显示', logCount: '显示 {shown} / {total} 条', noLogs: '暂无日志', noMatchingLogs: '没有符合筛选条件的日志', + interval3600: '每 1 小时', interval10800: '每 3 小时', interval21600: '每 6 小时', interval43200: '每 12 小时', interval86400: '每 24 小时', + stableNoticeDshTitle: '正式更新:DSH', stableNoticeEnvironmentTitle: '正式更新:Environment', stableNoticeBothTitle: '正式更新:DSH 与 Environment', upstreamNoticeDshTitle: '上游更新:DSH', updateNoticeDshLine: 'DSH:{version}{state}', updateNoticeEnvironmentLine: 'ENV:{version}{state}', noticeWillUpdate: '(将更新)', later: '稍后提醒', dismissVersion: '不再提醒此版本', closeReminder: '关闭更新提醒', + online: '已连接', connecting: '正在重连', offline: '连接中断', + }, + en: { + localeCode: 'en', + nav: 'Platform Management', title: 'Platform Management', intro: 'DSH Docker runtime, updates, and recovery', + managementSections: 'Platform management sections', updatesTab: 'Updates', maintenanceTab: 'Maintenance', pluginsTab: 'System plugins', skillsTab: 'System skills', authenticationTab: 'Authentication', proxyTab: 'Proxy', + accountAccess: 'Account and password', accountAccessDetail: 'Use the DSH Management Console or the container Root terminal to change the username or password.', rootResetDetail: 'Root terminal command:', sessionAccess: 'Sign out', sessionAccessDetail: 'Sign out the DSH and Management sessions in this browser.', logoutAll: 'Sign out', logoutManagementOnly: 'Sign out of Management only', loggingOut: 'Signing out', loadingAuthentication: 'Loading authentication status…', managementLogoutComplete: 'The Management session has been signed out.', + proxyMaster: 'Use proxy', proxyMasterDetail: 'When off, all managed traffic scopes connect directly.', proxyEndpointRequired: 'Enter the proxy host and port first.', + proxyTitle: 'Proxy settings', proxyDetail: 'Configure an external HTTP or SOCKS5 proxy used by DSH Docker.', proxyLoading: 'Loading proxy configuration…', proxyAdvanced: 'Advanced', proxyUnsaved: 'Unsaved', proxySavedShort: 'Saved', proxyProtocol: 'Proxy protocol', proxyHost: 'Host', proxyPort: 'Port', proxyUsername: 'Username', proxyPassword: 'Password', proxyPasswordPlaceholder: 'Leave blank to keep the current password', proxyRemoteDns: 'Resolve names through the proxy', proxyRemoteDnsDetail: 'SOCKS5 only. When off, names are resolved locally in the container.', proxyClearPassword: 'Clear password', proxyTransportWarning: 'This page is not using HTTPS. Proxy credentials are limited by the protection of the transport path.', proxyComponentUnavailable: 'The outbound proxy component is unavailable.', proxyComponentIssueLabel: 'View outbound proxy component error', proxyComponentIssueTitle: 'Outbound proxy component unavailable', proxyComponentIssueDetail: 'The DSH Docker outbound proxy component is unavailable. The current configuration is read-only until the component recovers; saving, connection tests, and managed proxy traffic are unavailable. This does not indicate whether the configured external proxy is reachable.', + proxyScopes: 'Proxy scopes', proxyScopesDetail: 'Enable the external proxy only for sources that need it.', proxyScopeHelp: 'Scope guide', proxyScopeGuideTitle: 'Proxy scope guide', proxyScopeGuideDetail: 'The Management backend supplies this table to both management interfaces.', proxyScopeUpdates: 'Update management', proxyScopeUpdatesDetail: 'Update checks, npm metadata, and Artifact downloads.', proxyScopePlatform: 'Platform components', proxyScopePlatformDetail: 'Non-local external requests from Management, Updater, DSH Docker System Plugins, and other platform components.', proxyScopeDshCore: 'DSH core', proxyScopeDshCoreDetail: 'DSH core traffic, excluding model Provider APIs.', proxyScopeDshPlugins: 'DSH plugins', proxyScopeDshPluginsDetail: 'Official DSH plugins and user-installed third-party plugins.', proxyScopeAgent: 'Agent network operations', proxyScopeAgentDetail: 'Agent tools, commands, and their child processes.', proxyScopeTerminal: 'Container terminal', proxyScopeTerminalDetail: 'The container terminal provided by DSH Management Console.', + proxyRules: 'Direct rules', proxyRulesDetail: 'Listed destinations bypass the external proxy.', proxyDirectRules: 'Additional direct rules', proxyDirectRulesDetail: 'One host, domain suffix, IP address, or CIDR per line. Use .google.com, not *.google.com.', proxyDirectRulesPlaceholder: '.example.com\n10.0.0.0/8', proxySystemRules: 'Built-in rules', proxySystemRulesTitle: 'Built-in direct rules', proxySystemRulesDetail: 'These platform-managed local destinations are always direct and do not need to be entered again.', proxyAllProxy: 'Inject ALL_PROXY for compatible clients', proxyAllProxyDetail: 'Only affects clients known to support ALL_PROXY. Off by default.', proxyProviders: 'Model Providers', proxyProvidersDetail: 'Identifiable DSH llm/stream Providers can independently select direct access or a dedicated proxy route.', proxyProviderSearch: 'Search configured Providers', proxyNoProviders: 'No configured Providers match the current search.', proxyProviderDirect: 'Forced direct', proxyProviderIndependent: 'Independent proxy', proxyProviderShared: 'Follow DSH', proxyProviderInfo: 'View routing information for {name}', proxyProviderReasonLocal: 'Local Provider; forced direct.', proxyProviderReasonShared: 'The client cannot carry a stable Provider identity. When enabled it follows shared DSH traffic; when disabled it connects directly.', + proxyTest: 'Proxy connection test', proxyTestDetail: 'Tests the current form without replacing saved settings.', proxyTestStart: 'Test connection', proxySave: 'Save and apply', proxySaving: 'Saving proxy settings', proxySaved: 'Proxy settings saved', proxyTestSuccess: 'Proxy connection test passed.', proxyTestFailed: 'Proxy connection test failed', proxyTestCancelled: 'Proxy connection test cancelled.', proxyStageAddress: 'Resolve proxy address', proxyStageConnect: 'Connect to proxy', proxyStageHandshake: 'Proxy handshake', proxyStageDns: 'Resolve target name', proxyStageTls: 'Establish target TLS', proxyStageHttp: 'Request target service', proxyStagePending: 'Pending test', proxyStageRunning: 'Testing', proxyStageSuccess: 'Passed', proxyStageFailed: 'Failed', proxyStageCancelled: 'Cancelled', proxyStageSkipped: 'Skipped', proxyStageDetailDirect: 'The candidate configuration connects directly.', proxyStageDetailAddress: 'Resolved {count} proxy address record(s).', proxyStageDetailConnect: 'Connected to the proxy server.', proxyStageDetailHandshake: 'Verified the {protocol} proxy protocol and authentication.', proxyStageDetailDns: 'Resolved {count} target address(es).', proxyStageDetailTls: 'Verified TLS for {count} target(s).', proxyStageDetailHttp: '{results}', proxyTargetGithub: 'GitHub metadata', proxyTargetNpm: 'npm registry', + channel: 'Update channel', channelDetail: 'Experimental updates DSH only; the platform Environment remains on the supported release.', returnStableProgress: 'Return to Stable', + stable: 'Stable', experimental: 'Experimental', current: 'Current', supported: 'Supported', upstream: 'Upstream', officialNpm: 'Official npm', + actions: 'Update actions', lastChecked: 'Last checked', notChecked: 'Not checked yet', check: 'Check for updates', checking: 'Checking', updateSupported: 'Update to latest supported', updateUpstream: 'Update to latest upstream', rollback: 'Roll back previous', returnStable: 'Return to Stable now', retry: 'Retry', progress: 'Update progress', + updateProgress: 'Update progress', rollbackProgress: 'Rollback progress', updateToTarget: 'Update to {target}', progressPrepare: 'Prepare update', progressAcquire: 'Download and verify', progressBuild: 'Build Runtime', progressActivate: 'Switch and health check', stageLogs: 'Stage logs', hideStageLogs: 'Hide logs · {count}', showStageLogs: 'View logs · {count}', noStageLogs: 'No logs for this phase yet', copyStageLogs: 'Copy current logs', logsCopied: 'Copied', viewFullTransactionLog: 'View full transaction log', stageCompleted: 'Stage completed.', stageWaiting: 'Waiting for the previous stage.', stageProgress: 'Stage progress {progress}%', stageItemsCompleted: '{completed}/{total} items completed', expandStage: 'Expand · {count} log entries', collapseStage: 'Collapse · {count} log entries', stageItemCompleted: 'Completed: {item}', stageItemActive: 'In progress: {item}', stageItemPending: 'Pending: {item}', stageItemFailed: 'Failed: {item}', itemVerifyMetadata: 'Verify metadata', itemVerifyKeyring: 'Verify keyring', itemVerifyTarget: 'Verify target manifest', itemDownloadArtifacts: 'Download Artifacts', itemVerifyArtifacts: 'Verify Artifact signatures, references, sizes, and hashes', itemImportObjects: 'Import trusted objects', itemMaterializePristine: 'Materialize Pristine DSH', itemPrepareEnvironment: 'Prepare Environment', itemBuildRuntime: 'Build Runtime and apply the complete Patch Set', itemPreparePlugins: 'Prepare System Plugin Set', itemSwitchDeployment: 'Switch Deployment atomically', itemCheckHealth: 'Check service health', itemObserveProbation: 'Observe candidate Runtime', metadataVerified: 'Metadata verified.', keyringVerified: 'Keyring verified.', targetManifestVerified: 'Target manifest verified.', artifactDownloadCompleted: 'Artifact download completed.', artifactVerificationCompleted: 'Artifact signatures, references, and hashes verified.', runtimeMaterialized: 'Pristine DSH materialized.', patchSetApplied: 'Complete Patch Set applied.', systemPluginsPrepared: 'System Plugin Set prepared.', deploymentSwitched: 'Deployment switched atomically.', healthChecksPassed: 'Service health checks passed.', metricBytesRead: 'Read {processed} / {total}', metricBytesCopied: 'Copied {processed} / {total}', metricBytesProcessed: 'Processed {processed} / {total}', metricArtifacts: 'Verified {processed} / {total} Artifacts', metricFiles: 'Completed {processed} / {total} files', metricItems: 'Completed {processed} / {total} items', metricServices: '{ready} / {total} services ready', rollbackPrepare: 'Prepare rollback', rollbackSwitch: 'Switch previous version', rollbackData: 'Restore data', rollbackVerify: 'Start and check', + dismissProgress: 'Close', itemValidateRollback: 'Validate rollback plan and previous complete Deployment', itemPauseRuntime: 'Pause current DSH Runtime', itemSwitchPrevious: 'Switch previous complete Deployment', itemVerifySnapshot: 'Verify data snapshot', itemRestoreSnapshot: 'Restore data snapshot', itemStartRuntime: 'Start previous DSH Runtime', metricBytesRestored: 'Restored {processed} / {total}', metricProbationRemaining: '{seconds} seconds of observation remaining', + progressDetailChecking: 'Fetching and verifying the latest signed update metadata.', progressDetailPlanning: 'Calculating the complete target state to reconcile.', progressDetailUpstream: 'Checking the official npm registry for the latest DSH.', progressDetailDownloading: 'Downloading Artifacts and importing them through Stage-0 into the trusted object store.', progressDetailValidating: 'Verifying signatures, Artifact references, sizes, and content hashes.', progressDetailBuilding: 'Building an immutable Runtime from Pristine DSH, patches, and System Plugins.', progressDetailSnapshot: 'DSH is paused while a complete data snapshot is created for the Experimental update.', progressDetailSwitching: 'Atomically switching the complete Deployment and checking DSH readiness.', progressDetailProbation: 'The candidate Runtime remains under health observation until {until}.', + rollbackDetailPreparing: 'Validating the rollback plan and previous complete Deployment.', rollbackDetailStopping: 'Pausing DSH before restoring the previous complete state.', rollbackDetailSwitching: 'Switching the previous Runtime, Environment, and System Plugin set.', rollbackDetailData: 'Verifying and restoring the pre-update data snapshot.', rollbackDetailVerifying: 'Starting DSH and running health checks.', + statusIdle: 'Ready', statusChecking: 'Checking for updates', statusPlanning: 'Preparing update', statusCheckingUpstream: 'Checking upstream', statusDownloading: 'Downloading', statusValidating: 'Verifying', statusBuildingCandidate: 'Building candidate', statusSnapshottingData: 'Backing up data', statusSwitching: 'Switching version', statusProbation: 'Observing runtime health', statusRestoringData: 'Restoring data', statusRollingBack: 'Rolling back', statusSuccess: 'Completed', statusFailed: 'Failed', statusUnknown: 'Working', + outcomeNone: 'Already up to date', outcomeFrozen: 'Waiting for the supported release to catch up', outcomeHeld: 'This version is on hold', outcomeBlocked: 'This version combination is unavailable', outcomeStable: 'Switched to the Stable release', outcomeExperimental: 'Switched to the Experimental release', + requestError: 'Request failed', operationError: 'The operation failed. Check the container logs.', holdVersion: 'This version failed and automatic retries are on hold.', holdCombination: 'This version is incompatible with the production Environment and automatic retries are on hold.', + metadataUnavailable: 'Signed update metadata has not been published yet. Try again later.', remoteCheckFailed: 'Remote check failed. Showing the last verified result.', remoteCheckFailedNoResult: 'Remote check failed. No verified result is available yet.', upstreamCheckFailed: 'The official DSH version check failed. Showing the last verified result.', upstreamCheckFailedNoResult: 'The official DSH version check failed. No verified result is available yet.', + aheadOfStable: 'The current version is ahead of Latest Supported; the complete deployment is frozen.', experimentalBlocked: 'The Experimental DSH and production Environment combination is unavailable.', + returnStableTitle: 'Restore Stable state', returnStableWarning: 'The following data snapshot will be restored and newer data will be lost:', confirmDataLoss: 'I understand and confirm the loss of newer data', cancel: 'Cancel', confirm: 'Restore', + standaloneManagement: 'DSH Management Console', standaloneManagementDetail: 'Updates, plugin recovery, logs, and terminal tools remain available when DSH is unavailable.', openPlatformManagement: 'Open DSH Management Console', restartDshSection: 'Restart DSH', restartDshDetail: 'Restart DSH only. The container and management console services remain running.', restartDsh: 'Restart DSH', cancelRestartDsh: 'Cancel DSH restart', restarting: 'Restarting DSH', restartFailed: 'DSH restart failed', restartTitle: 'Restart DSH?', restartWarning: 'The current DSH connection will be interrupted briefly. This page reloads when DSH is ready.', confirmRestart: 'Restart', + automaticChecks: 'Automatic checks', automaticChecksDetail: 'Checks for available versions without downloading or updating.', enabled: 'On', disabled: 'Off', checkInterval: 'Check frequency', updateNotifications: 'Update notifications', updateNotificationsDetail: 'Show an update notification popup when an automatic check finds a new version.', + systemPlugins: 'System plugins', systemPluginsDetail: 'Manage the System Plugins provided by DSH Docker.', noSystemPlugins: 'No System Plugins are provided by the current Environment.', platformManaged: 'Core platform component. It is always installed and enabled.', managed: 'Platform managed', notInstalled: 'Not installed', resourceEnabled: 'Enabled', resourceDisabled: 'Disabled', pendingInstall: 'Pending install', pendingUninstall: 'Pending uninstall', pendingEnable: 'Pending enable', pendingDisable: 'Pending disable', statusInstalling: 'Installing', statusUninstalling: 'Uninstalling', statusEnabling: 'Enabling', statusDisabling: 'Disabling', pluginEnabled: 'Installed and enabled', pluginDisabled: 'Installed but disabled', installPlugin: 'Install', uninstallPlugin: 'Uninstall', pluginActionWorking: 'Applying plugin settings', pluginActionInstall: 'Installing', pluginActionUninstall: 'Uninstalling', pluginActionEnable: 'Enabling', pluginActionDisable: 'Disabling', pluginChangesPending: 'Changes pending', pluginChangesPendingDetail: 'Plugin changes have not been applied. Apply them to restart DSH and make them effective.', pendingSystemPluginChanges: '{count} pending changes', systemPluginApplyingItem: '{action} @dsh-docker/{id} ({current}/{total})', systemPluginRestarting: 'Plugin changes applied; restarting DSH', cancelChanges: 'Cancel changes', applyPluginChanges: 'Apply and restart DSH', searchSystemPlugins: 'Search System Plugins', + systemSkills: 'System skills', systemSkillsDetail: 'Manage System Skills provided by DSH Docker.', noSystemSkills: 'The current Bootstrap provides no System Skills.', skillActionWorking: 'Applying skill settings', searchSystemSkills: 'Search System Skills', noMatchingResources: 'No items match this search.', itemsPerPage: 'Items per page', itemsPerPageSuffix: '/ page', previousPage: 'Previous', nextPage: 'Next', totalItems: '{total} total', goToPage: 'Go to', pageUnit: 'page', + logs: 'Live logs', logsDetail: 'View runtime logs from DSH and platform modules.', searchLogs: 'Search logs', logSource: 'Log module', logLevel: 'Log level', logDisplayLimit: 'Entries shown', logDisplayLimitValue: 'Latest {count}', allSources: 'All modules', levelAll: 'All levels', levelDebug: 'Debug', levelInfo: 'Info', levelWarning: 'Warning', levelError: 'Error', logsLive: 'Live', logsConnecting: 'Connecting', logsDisconnected: 'Disconnected', refreshLogs: 'Refresh logs', exportLogs: 'Export logs', autoScroll: 'Auto-scroll', clearLogView: 'Clear view', logCount: 'Showing {shown} / {total}', noLogs: 'No logs yet', noMatchingLogs: 'No logs match these filters', + interval3600: 'Every hour', interval10800: 'Every 3 hours', interval21600: 'Every 6 hours', interval43200: 'Every 12 hours', interval86400: 'Every 24 hours', + stableNoticeDshTitle: 'Supported update: DSH', stableNoticeEnvironmentTitle: 'Supported update: Environment', stableNoticeBothTitle: 'Supported update: DSH and Environment', upstreamNoticeDshTitle: 'Upstream update: DSH', updateNoticeDshLine: 'DSH: {version}{state}', updateNoticeEnvironmentLine: 'ENV: {version}{state}', noticeWillUpdate: ' (will update)', later: 'Remind me later', dismissVersion: 'Do not remind for this version', closeReminder: 'Close update reminder', + online: 'Connected', connecting: 'Reconnecting', offline: 'Disconnected', + }, + }), 'dsh-platform-management: locale') + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'dsh-platform-management', + order: 90, + label: () => ctx.locale.bind('settings.dshPlatformManagement')('nav'), + locale: 'settings.dshPlatformManagement', + }, PlatformManagement)) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: 'dsh-platform-management-reminder', + order: 90, + locale: 'settings.dshPlatformManagement', + }, UpdateReminder)) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: 'dsh-platform-lifecycle-guard', + order: -100, + }, () => h(LifecycleGuard, { connection: ctx.connection }))) +} diff --git a/upstream/container/environment/resources/plugins/platform-management/package/lib/index.js b/upstream/container/environment/resources/plugins/platform-management/package/lib/index.js new file mode 100644 index 0000000..b08955a --- /dev/null +++ b/upstream/container/environment/resources/plugins/platform-management/package/lib/index.js @@ -0,0 +1,128 @@ +import { readFileSync, watch } from 'node:fs' +import { basename, dirname } from 'node:path' +import { installProviderRouting } from './provider-routing.js' + +export const name = '@dsh-docker/platform-management' +export const inject = ['subprocess'] + +const PROXY_KEYS = Object.freeze([ + 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', + 'NO_PROXY', 'no_proxy', 'ALL_PROXY', 'all_proxy', +]) +const CONNECTION_PROXY_KEYS = Object.freeze([ + 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy', +]) +const SHARED_DSH_PROXY_URL = 'http://127.0.0.1:17898' +const ROUTING_STATE_PATH = `${process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform'}/outbound-proxy-routing.json` + +function routingState(path = ROUTING_STATE_PATH) { + try { + const value = JSON.parse(readFileSync(path, 'utf8')) + if (value?.schema !== 1 || typeof value.enabled !== 'boolean' || typeof value.scopes?.agentNetwork !== 'boolean') return null + return value + } catch { + return null + } +} + +function agentNetworkEnvironment(environment = process.env) { + const proxy = environment.DSH_PLATFORM_AGENT_PROXY_URL + if (typeof proxy !== 'string' || proxy === '') return null + const noProxy = environment.NO_PROXY ?? environment.no_proxy ?? '' + const allProxy = environment.ALL_PROXY !== undefined || environment.all_proxy !== undefined + return { + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + http_proxy: proxy, + https_proxy: proxy, + NO_PROXY: noProxy, + no_proxy: noProxy, + ...(allProxy ? { ALL_PROXY: proxy, all_proxy: proxy } : {}), + } +} + +function sharedDshProxyEnabled(state) { + return state !== null && state.enabled + && (state.scopes?.dshCore === true || state.scopes?.dshPlugins === true) +} + +function synchronizeSharedDshEnvironment(environment, state) { + if (state === null) return + for (const key of CONNECTION_PROXY_KEYS) delete environment[key] + const noProxy = [...new Set([ + ...(state.noProxy?.system ?? []), + ...(state.noProxy?.user ?? []), + ])].join(',') + environment.NO_PROXY = noProxy + environment.no_proxy = noProxy + if (!sharedDshProxyEnabled(state)) return + for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']) environment[key] = SHARED_DSH_PROXY_URL + if (state.environment?.allProxy === 'scope-proxy') { + environment.ALL_PROXY = SHARED_DSH_PROXY_URL + environment.all_proxy = SHARED_DSH_PROXY_URL + } +} + +function watchSharedDshEnvironment(path = ROUTING_STATE_PATH, environment = process.env) { + const original = Object.fromEntries(PROXY_KEYS.map(key => [key, environment[key]])) + const synchronize = () => synchronizeSharedDshEnvironment(environment, routingState(path)) + synchronize() + let watcher + try { + watcher = watch(dirname(path), (_event, filename) => { + if (filename === null || filename.toString() === basename(path)) synchronize() + }) + watcher.on('error', () => {}) + } catch {} + return () => { + watcher?.close() + for (const key of PROXY_KEYS) { + if (original[key] === undefined) delete environment[key] + else environment[key] = original[key] + } + } +} + +function managedSubprocessSpec(spec, environment = process.env, routingPath = ROUTING_STATE_PATH) { + if (spec?.env?.DSH_SHELL !== '1') return spec + const env = { ...spec.env } + for (const key of PROXY_KEYS) delete env[key] + const state = routingState(routingPath) + if (state !== null && (!state.enabled || !state.scopes.agentNetwork)) { + for (const key of PROXY_KEYS) env[key] = undefined + return { ...spec, env } + } + const proxy = agentNetworkEnvironment(environment) + if (proxy === null) return spec + return { ...spec, env: { ...env, ...proxy } } +} + +export function apply(ctx) { + const removeSharedDshEnvironment = watchSharedDshEnvironment() + const removeProviderRouting = installProviderRouting(ctx) + const service = ctx.subprocess + const spawn = service.spawn + const spawnTerminal = service.spawnTerminal + const managedSpawn = function (spec) { + return spawn.call(this, managedSubprocessSpec(spec)) + } + const managedSpawnTerminal = function (spec) { + return spawnTerminal.call(this, managedSubprocessSpec(spec)) + } + service.spawn = managedSpawn + service.spawnTerminal = managedSpawnTerminal + ctx.effect(() => () => { + removeSharedDshEnvironment() + removeProviderRouting() + if (service.spawn === managedSpawn) service.spawn = spawn + if (service.spawnTerminal === managedSpawnTerminal) service.spawnTerminal = spawnTerminal + }, 'dsh-docker managed network scopes') +} + +export const managedNetworkInternals = Object.freeze({ + agentNetworkEnvironment, + managedSubprocessSpec, + routingState, + sharedDshProxyEnabled, + synchronizeSharedDshEnvironment, +}) diff --git a/upstream/container/environment/resources/plugins/platform-management/package/lib/provider-routing.js b/upstream/container/environment/resources/plugins/platform-management/package/lib/provider-routing.js new file mode 100644 index 0000000..cc28732 --- /dev/null +++ b/upstream/container/environment/resources/plugins/platform-management/package/lib/provider-routing.js @@ -0,0 +1,248 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { readFileSync } from 'node:fs' +import { request } from 'node:http' +import { Agent, fetch as proxyFetch, ProxyAgent } from 'undici' + +const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/ +const FETCH_ROUTER = Symbol.for('dsh-docker.platform-management.provider-fetch-router') +const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024 +const DEFAULT_SOCKET = '/run/dsh-platform/outbound-proxy.sock' +const DEFAULT_PROXY_URL = 'http://127.0.0.1:17897' +const DEFAULT_SHARED_PROXY_URL = 'http://127.0.0.1:17898' +const DEFAULT_AGENT_PROXY_URL = 'http://127.0.0.1:17895' +const DEFAULT_ROUTING_STATE = '/run/dsh-platform/outbound-proxy-routing.json' + +function routingState(path) { + try { + const value = JSON.parse(readFileSync(path, 'utf8')) + if (value?.schema !== 1 || typeof value.enabled !== 'boolean' || value.modelApi === null || typeof value.modelApi !== 'object') return null + return value + } catch { + return null + } +} + +function providerProxyEnabled(state, providerId) { + if (state === null || !state.enabled) return false + const policy = state.modelApi.providers?.[providerId] ?? state.modelApi.default + return policy?.proxyEnabled === true +} + +function providerRoutingCapability(options) { + return options?.routingCapability === 'shared-dsh' || options?.supportsIndependentRouting === false + ? 'shared-dsh' + : 'provider' +} + +function sharedDshProxyEnabled(state) { + return state !== null && state.enabled + && (state.scopes?.dshCore === true || state.scopes?.dshPlugins === true) +} + +function agentNetworkProxyEnabled(state) { + return state !== null && state.enabled && state.scopes?.agentNetwork === true +} + +function controlRequest(socketPath, method, path, body) { + return new Promise((resolve, reject) => { + const bytes = body === undefined ? undefined : Buffer.from(JSON.stringify(body)) + const outgoing = request({ + socketPath, + method, + path, + headers: bytes === undefined ? {} : { + 'content-type': 'application/json', + 'content-length': bytes.byteLength, + }, + signal: AbortSignal.timeout(5_000), + }, response => { + const chunks = [] + let size = 0 + response.on('data', chunk => { + size += chunk.byteLength + if (size > MAX_CONTROL_RESPONSE_BYTES) outgoing.destroy(new Error('Outbound Proxy response is too large')) + else chunks.push(chunk) + }) + response.on('end', () => { + let value + try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { + reject(new Error('Outbound Proxy response is invalid')) + return + } + if ((response.statusCode ?? 500) >= 400) { + const error = new Error(value?.error?.message ?? `Outbound Proxy returned HTTP ${String(response.statusCode)}`) + error.statusCode = response.statusCode + error.code = value?.error?.code + reject(error) + } else resolve(value) + }) + }) + outgoing.once('error', reject) + outgoing.end(bytes) + }) +} + +async function issueProviderHandle(providerId, socketPath) { + for (let attempt = 0; attempt < 2; attempt += 1) { + const configuration = await controlRequest(socketPath, 'GET', '/v1/configuration') + try { + return await controlRequest(socketPath, 'POST', '/v1/provider-handles', { + providerId, + policyRevision: configuration.revision, + }) + } catch (error) { + if (error?.code !== 'REVISION_CONFLICT' || attempt > 0) throw error + } + } + throw new Error('Provider policy changed repeatedly') +} + +function installFetchRouter(store, routedFetch = proxyFetch, resolveDefaultRoute = () => undefined) { + const owner = Object.freeze({}) + let registry = globalThis[FETCH_ROUTER] + if (registry === undefined) { + const original = globalThis.fetch + registry = { original, owner, store, routedFetch, resolveDefaultRoute } + const wrapper = function (input, init) { + const route = registry.store.getStore() ?? registry.resolveDefaultRoute() + if (route === undefined || (init !== undefined && Object.hasOwn(init, 'dispatcher'))) { + return registry.original.call(this, input, init) + } + return registry.routedFetch(input, { ...init, dispatcher: route.dispatcher }) + } + registry.wrapper = wrapper + globalThis[FETCH_ROUTER] = registry + globalThis.fetch = wrapper + } else { + registry.owner = owner + registry.store = store + registry.routedFetch = routedFetch + registry.resolveDefaultRoute = resolveDefaultRoute + } + return () => { + if (registry.owner !== owner) return + if (globalThis.fetch === registry.wrapper) globalThis.fetch = registry.original + delete globalThis[FETCH_ROUTER] + } +} + +function routedIterator(next, providerId, store, { resolveRoute }) { + let iterator + let routePromise + let closed = false + + async function route() { + if (routePromise === undefined) { + routePromise = resolveRoute(providerId) + } + return routePromise + } + + async function invoke(method, value) { + const context = await route() + if (iterator === undefined) iterator = store.run(context, () => next()[Symbol.asyncIterator]()) + if (typeof iterator[method] !== 'function') { + await close(context) + if (method === 'throw') throw value + return { value, done: true } + } + try { + const result = await store.run(context, () => iterator[method](value)) + if (result.done) await close(context) + return result + } catch (error) { + await close(context) + throw error + } + } + + async function close(context) { + if (closed) return + closed = true + if (context.owned) await context.dispatcher.close() + } + + return { + next: value => invoke('next', value), + return: value => invoke('return', value), + throw: value => invoke('throw', value), + [Symbol.asyncIterator]() { return this }, + } +} + +export function installProviderRouting(ctx, { + socketPath = process.env.DSH_PLATFORM_RUN === undefined + ? DEFAULT_SOCKET + : `${process.env.DSH_PLATFORM_RUN}/outbound-proxy.sock`, + proxyUrl = DEFAULT_PROXY_URL, + routingStatePath = process.env.DSH_PLATFORM_RUN === undefined + ? DEFAULT_ROUTING_STATE + : `${process.env.DSH_PLATFORM_RUN}/outbound-proxy-routing.json`, + createDispatcher = options => new ProxyAgent(options), + createSharedDispatcher = options => new ProxyAgent(options), + createAgentDispatcher = options => new ProxyAgent(options), + createDirectDispatcher = () => new Agent(), + routedFetch = proxyFetch, +} = {}) { + const store = new AsyncLocalStorage() + const directDispatcher = createDirectDispatcher() + const sharedDispatcher = createSharedDispatcher({ uri: DEFAULT_SHARED_PROXY_URL }) + const agentDispatcher = createAgentDispatcher({ uri: DEFAULT_AGENT_PROXY_URL }) + const resolveDefaultRoute = () => { + const state = routingState(routingStatePath) + if (state === null) return undefined + return { dispatcher: sharedDshProxyEnabled(state) ? sharedDispatcher : directDispatcher } + } + const removeFetchRouter = installFetchRouter(store, routedFetch, resolveDefaultRoute) + const disposeTool = ctx.on('tools/execute', (_execution, next) => { + const state = routingState(routingStatePath) + return store.run({ + dispatcher: agentNetworkProxyEnabled(state) ? agentDispatcher : directDispatcher, + owned: false, + }, next) + }, { global: true, prepend: true }) + const disposeStream = ctx.on('llm/stream', (options, next) => { + if (!PROVIDER_ID_PATTERN.test(options.provider)) return next() + const capability = providerRoutingCapability(options) + return routedIterator(next, options.provider, store, { + resolveRoute: async providerId => { + const state = routingState(routingStatePath) + if (state !== null && !providerProxyEnabled(state, providerId)) { + return { dispatcher: directDispatcher, owned: false } + } + if (capability === 'shared-dsh') { + return { + dispatcher: sharedDshProxyEnabled(state) ? sharedDispatcher : directDispatcher, + owned: false, + } + } + const { handle } = await issueProviderHandle(providerId, socketPath) + return { + dispatcher: createDispatcher({ uri: proxyUrl, token: `DSH-Provider ${handle}` }), + owned: true, + } + }, + }) + }, { global: true, prepend: true }) + return () => { + disposeTool?.() + disposeStream?.() + removeFetchRouter() + void directDispatcher.close() + void sharedDispatcher.close() + void agentDispatcher.close() + } +} + +export const providerRoutingInternals = Object.freeze({ + FETCH_ROUTER, + controlRequest, + installFetchRouter, + issueProviderHandle, + agentNetworkProxyEnabled, + providerProxyEnabled, + providerRoutingCapability, + sharedDshProxyEnabled, + routingState, + routedIterator, +}) diff --git a/upstream/container/environment/resources/plugins/platform-management/package/lib/style.module.css b/upstream/container/environment/resources/plugins/platform-management/package/lib/style.module.css new file mode 100644 index 0000000..b5c2bc6 --- /dev/null +++ b/upstream/container/environment/resources/plugins/platform-management/package/lib/style.module.css @@ -0,0 +1,1122 @@ +.root { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + height: min(720px, calc(100dvh - 152px)); + min-height: 0; + max-width: 760px; + overflow: hidden; + color: var(--dsw-alias-label-primary); +} + +.platformHeader { + z-index: 10; + display: flex; + width: 100%; + min-width: 0; + max-width: 100%; + flex: none; + flex-direction: column; + gap: 12px; + background: var(--dsw-alias-bg-layer-2); +} + +.heading { + display: flex; + flex-direction: column; + gap: 12px; +} + +.sectionHeading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.titleRow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.title { + margin: 0; + font-size: 18px; + line-height: 26px; + font-weight: 600; +} + +.intro { + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.sectionHeading p { + margin: 2px 0 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.connection { + flex: none; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: 12px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-module-platform); +} + +.connection > span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--dsw-alias-state-warn-label); +} + +.connection.online > span { background: var(--dsw-alias-state-success-primary); } +.connection.offline > span { background: var(--dsw-alias-state-error-primary); } + +.tabs { + display: flex; + align-items: flex-end; + gap: 22px; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow-x: auto; + overscroll-behavior-inline: contain; + scroll-snap-type: inline proximity; + touch-action: pan-x; + white-space: nowrap; + border-bottom: 1px solid var(--dsw-alias-border-l2); + margin-top: 2px; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} + +.tabs::-webkit-scrollbar { display: none; } + +.tabs button { + position: relative; + flex: none; + padding: 7px 1px 9px; + border: 0; + color: var(--dsw-alias-label-tertiary); + background: transparent; + font: inherit; + font-size: 13px; + line-height: 20px; + scroll-snap-align: start; + cursor: pointer; +} + +.tabs button:hover, +.tabs button[aria-selected='true'] { + color: var(--dsw-alias-label-primary); +} + +.tabs button[aria-selected='true']::after, +.tabs button:focus-visible::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + border-radius: 2px 2px 0 0; + background: var(--dsw-alias-label-primary); + content: ''; +} + +.tabs button:focus-visible { + border-radius: 2px; + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; + color: var(--dsw-alias-label-primary); +} + +@media (hover: hover) and (pointer: fine) { + .tabs { scroll-snap-type: none; } +} + +.tabPanel { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; + min-height: 0; + flex: 1; + padding: 2px 8px 0 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.tabPanel[hidden] { display: none; } +.tabPanel > .section:last-child { padding-bottom: 0; border-bottom: 0; } + +.section { + display: flex; + flex-direction: column; + gap: 12px; + padding-bottom: 20px; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.sectionHeading h3 { + margin: 0; + font-size: 16px; + line-height: 24px; + font-weight: 500; +} + +.segmented { + flex: none; + display: inline-flex; + padding: 2px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; +} + +.segmented button { + box-sizing: border-box; + height: 28px; + padding: 0 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.segmented button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } +.segmented button[aria-pressed='true'] { + background: var(--dsw-specific-sidebar-nav-item-active); + color: var(--dsw-alias-label-primary); + font-weight: 500; +} + +.versions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + overflow: hidden; +} + +.experimentalVersions { grid-template-columns: repeat(3, minmax(0, 1fr)); } + +.versionCell { + min-width: 0; + padding: 12px 14px; +} + +.versionCell + .versionCell { border-left: 1px solid var(--dsw-alias-border-l2); } +.caption, .version, .detail { display: block; overflow-wrap: anywhere; } +.caption, .detail { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); } +.version { margin: 3px 0; font-size: 15px; line-height: 22px; font-weight: 600; } + +.notice, +.error { + margin: 0; + padding: 8px 10px; + border-left: 3px solid var(--dsw-alias-state-warn-label); + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-warn-label); + background: var(--dsw-alias-bg-module-platform); +} + +.commandHint { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px; + margin: 12px 0 0; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.commandHint code { + padding: 2px 6px; + border-radius: 4px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-module-platform); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.error { + border-left-color: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-state-error-primary); +} + +.actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.primaryButton, +.secondaryButton, +.dangerButton, +.dangerFilledButton, +.smallButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + font: inherit; + font-size: 14px; + line-height: 22px; + cursor: pointer; +} + +.listPagination { + position: sticky; + bottom: 0; + z-index: 2; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto; + align-items: center; + gap: 12px; + min-height: 42px; + padding: 6px 0; + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-layer-2); + font-size: 12px; +} +.listPagination label, +.listPagination > div { display: flex; align-items: center; gap: 7px; white-space: nowrap; } +.paginationSelectTrigger { + min-width: 64px; + min-height: 30px; + padding: 3px 7px; +} +.pageNavigation strong { min-width: 16px; color: var(--dsw-alias-label-primary); text-align: center; font-weight: 600; } +.pageArrow { width: 30px; min-width: 30px; height: 30px; padding: 0; border-radius: 6px; font-size: 0; line-height: 1; } +.pageArrow::before { + width: 7px; + height: 7px; + border-top: 1.5px solid currentColor; + border-right: 1.5px solid currentColor; + content: ''; +} +.pageNavigation .pageArrow:first-child::before { transform: translateX(1px) rotate(-135deg); } +.pageNavigation .pageArrow:last-child::before { transform: translateX(-1px) rotate(45deg); } +.pageJump input { + box-sizing: border-box; + width: 50px; + min-height: 30px; + padding: 3px 6px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-module-platform); + font: inherit; + text-align: center; +} + +.primaryButton { + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-label-primary-foreground); +} +.primaryButton:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); } + +.secondaryButton, +.smallButton { + border: 1px solid var(--dsw-alias-border-l2); + background: transparent; + color: var(--dsw-alias-label-primary); +} +.secondaryButton:hover:not(:disabled), +.smallButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } + +a.secondaryButton { text-decoration: none; } + +.checkSpinner { + box-sizing: border-box; + width: 14px; + height: 14px; + margin-right: 7px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: checkSpin .75s linear infinite; +} + +@keyframes checkSpin { to { transform: rotate(360deg); } } + +@media (prefers-reduced-motion: reduce) { + .checkSpinner { animation-duration: 1.5s; } +} + +.dangerButton { + background: transparent; + color: var(--dsw-alias-state-error-primary); +} +.dangerButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger); } +.dangerFilledButton { background: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-label-primary-foreground); } + +.authenticationButton { + flex: none; + border: 1px solid var(--dsw-alias-border-l2); + white-space: nowrap; +} + +.dangerButton.authenticationButton { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); } + +.smallButton { + height: 28px; + padding: 0 10px; + border-radius: 14px; + font-size: 12px; + line-height: 18px; +} + +.primaryButton:disabled, +.secondaryButton:disabled, +.dangerButton:disabled, +.dangerFilledButton:disabled, +.smallButton:disabled, +.segmented button:disabled { opacity: .4; cursor: default; } + +.primaryButton:focus-visible, +.secondaryButton:focus-visible, +.dangerButton:focus-visible, +.dangerFilledButton:focus-visible, +.smallButton:focus-visible, +.segmented button:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 2px; +} + +.actionHeading { align-items: center; } +.updateState { display: flex; flex-direction: column; gap: 8px; } +.statusLine { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.statusLine output { font-size: 12px; color: var(--dsw-alias-label-secondary); } +.statusLabel { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); } +.statusDot { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--dsw-alias-border-l2); } +.statusActive .statusDot { background: var(--dsw-alias-brand-primary); } +.statusSuccess .statusDot { background: var(--dsw-alias-state-success-primary); } +.statusFailed { color: var(--dsw-alias-state-error-primary); } +.statusFailed .statusDot { background: var(--dsw-alias-state-error-primary); } +.updateState > p { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; } +.updateProgress { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-module-platform); } +.progressHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.progressHeading strong { font-size: 13px; line-height: 20px; font-weight: 500; } +.progressHeading output { color: var(--dsw-alias-label-primary); font: 600 12px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.progressHeadingActions { display: flex; align-items: center; gap: 8px; } +.progressStageLog { display: grid; gap: 8px; min-width: 0; } +.progressLogActions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin-top: 7px; } +.progressAutoScroll { display: inline-flex; align-items: center; gap: 5px; color: var(--dsw-alias-label-secondary); font-size: 10px; } +.progressLogList { display: grid; gap: 0; } +.progressLogGroup { position: relative; min-width: 0; padding: 0 0 14px 28px; } +.progressLogGroup:not(:last-child)::before { position: absolute; top: 22px; bottom: 0; left: 8px; width: 1px; background: var(--dsw-alias-border-l2); content: ''; } +.progressStageMarker { --marker-size: 10px; position: absolute; top: 5px; left: 3px; display: grid; width: var(--marker-size); height: var(--marker-size); place-items: center; } +.progressStageMarker::before { box-sizing: border-box; width: 100%; height: 100%; border: 1.5px solid var(--dsw-alias-label-tertiary); border-radius: 50%; content: ''; } +.progressStageMarkerCompleted::before { border-color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-primary); } +.progressStageMarkerFailed::before { border-color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-state-error-primary); } +.progressStageMarkerActive::before { border-color: color-mix(in srgb, var(--dsw-alias-state-business-primary) 30%, transparent); border-top-color: var(--dsw-alias-state-business-primary); animation: checkSpin .8s linear infinite; } +.progressStageHeader { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; } +.progressStageSummary { display: grid; min-width: 0; gap: 1px; } +.progressStageSummary strong { font-size: 13px; line-height: 20px; font-weight: 600; } +.progressStageDetail, .progressStageCount { color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 17px; overflow-wrap: anywhere; } +.progressStageMetric, .progressStagePercent { color: var(--dsw-alias-label-primary); font: 11px/17px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } +.progressStageToggle { align-self: start; padding: 0; border: 0; color: var(--dsw-alias-state-business-primary); background: transparent; font-size: 10px; line-height: 20px; white-space: nowrap; cursor: pointer; } +.progressStageItems { display: grid; gap: 4px; margin-top: 8px; padding: 8px 10px; border-left: 1px solid var(--dsw-alias-border-l2); } +.progressStageItem { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 5px; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 17px; } +.progressStageItemCompleted { color: var(--dsw-alias-state-success-primary); } +.progressStageItemActive { color: var(--dsw-alias-state-business-primary); } +.progressStageItemFailed { color: var(--dsw-alias-state-error-primary); } +.progressStageItemMarker { --marker-size: 10px; display: inline-grid; width: 14px; height: 17px; place-items: center; } +.progressStageItemMarker::before { box-sizing: border-box; width: var(--marker-size); height: var(--marker-size); border: 1.5px solid transparent; border-radius: 50%; content: ''; } +.progressStageItemCompleted .progressStageItemMarker::before { border-color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-primary); } +.progressStageItemFailed .progressStageItemMarker::before { border-color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-state-error-primary); } +.progressStageItemPending .progressStageItemMarker::before { border: 1.5px solid var(--dsw-alias-label-secondary); } +.progressStageItemActive .progressStageItemMarker::before { border: 1.5px solid color-mix(in srgb, var(--dsw-alias-state-business-primary) 30%, transparent); border-top-color: var(--dsw-alias-state-business-primary); animation: checkSpin .8s linear infinite; } +.progressStageItem .progressStageMetric, .progressStageItem .progressStagePercent { grid-column: 2; } +.progressStageError { margin: 4px 0 0 21px !important; color: var(--dsw-alias-state-error-primary) !important; font-size: 11px !important; overflow-wrap: anywhere; } +.progressLogGroupList { display: grid; gap: 0; max-height: 250px; overflow: auto; margin-top: 9px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: var(--dsw-alias-bg-layer-2); } +.progressLogGroupListPopulated { height: clamp(160px, 24dvh, 240px); max-height: none; align-content: start; } +.progressLogEntry { border-bottom: 1px solid var(--dsw-alias-border-l2); padding: 7px 9px; background: var(--dsw-alias-bg-layer-2); } +.progressLogEntry summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: baseline; gap: 2px 8px; cursor: pointer; font-size: 11px; line-height: 18px; list-style: none; } +.progressLogEntry summary::-webkit-details-marker { display: none; } +.progressLogEntry summary span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.progressLogEntry summary time, .progressLogSource { min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); text-overflow: ellipsis; white-space: nowrap; } +.progressLogLevel { color: var(--dsw-alias-state-business-primary); } +.progressLogLevel.error { color: var(--dsw-alias-state-error-primary); } +.progressLogLevel.warning, .progressLogLevel.warn { color: var(--dsw-alias-state-warning-primary); } +.progressLogMessage { grid-column: 1 / 3; color: var(--dsw-alias-label-primary); font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.progressLogChevron { position: relative; top: 2px; grid-column: 3; width: 10px; height: 10px; justify-self: end; } +.progressLogChevron::before { position: absolute; top: 3px; left: 1px; width: 8px; height: 5px; background: var(--dsw-alias-label-tertiary); clip-path: polygon(0 0, 50% 72%, 100% 0, 100% 28%, 50% 100%, 0 28%); content: ''; transform-origin: center; } +.progressLogEntry[open] .progressLogChevron::before { transform: rotate(180deg); } +.progressLogEntry pre { max-height: 160px; overflow: auto; margin: 5px 0 2px; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; font: 10px/15px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.progressLogEmpty { margin: 0 !important; padding: 8px; color: var(--dsw-alias-label-secondary) !important; font-size: 11px; } +.progressFullLog { justify-self: start; padding: 0; border: 0; color: var(--dsw-alias-state-business-primary); background: transparent; cursor: pointer; font-size: 11px; } +.progress { height: 8px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-border-l2); } +.progress span { display: block; height: 100%; background: var(--dsw-alias-state-success-primary); transition: width .2s ease; } +.progress[data-complete='true'] span { width: 100% !important; transition: none; } + +.holds { display: flex; flex-direction: column; border-top: 1px solid var(--dsw-alias-border-l2); } +.hold { display: flex; align-items: center; gap: 12px; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--dsw-alias-border-l2); } +.hold div { min-width: 0; } +.hold strong, .hold span { display: block; overflow-wrap: anywhere; } +.hold strong { font-size: 13px; line-height: 20px; } +.hold span { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); } + +.confirmation { + padding: 14px; + border: 1px solid var(--dsw-alias-state-error-primary); + border-radius: 8px; + background: var(--dsw-alias-bg-module-platform); +} +.confirmation h4 { margin: 0; font-size: 14px; line-height: 22px; } +.confirmation p { margin: 4px 0 12px; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); } +.confirmation label { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; line-height: 20px; } +.confirmation input { margin: 3px 0 0; accent-color: var(--dsw-alias-brand-primary); } +.confirmActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; } + +.maintenanceButton { flex: none; } +.maintenanceStatus { + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} +.restartConfirmation { border-color: var(--dsw-alias-border-l2); } + +.toggle { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + cursor: pointer; +} +.toggle input { position: absolute; opacity: 0; pointer-events: none; } +.toggle > span { + box-sizing: border-box; + width: 34px; + height: 20px; + padding: 2px; + border-radius: 10px; + background: var(--dsw-alias-border-l2); + transition: background .15s ease; +} +.toggle > span::after { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--dsw-alias-label-primary-foreground); + content: ''; + transition: transform .15s ease; +} +.toggle input:checked + span { background: var(--dsw-alias-state-success-primary); } +.toggle input:checked + span::after { transform: translateX(14px); } +.toggle input:focus-visible + span { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } +.toggle input:disabled ~ * { opacity: .5; cursor: default; } +.toggle b { font-weight: 500; } + +.settingRows { border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; overflow: hidden; } +.proxyFormGrid, .proxyRulesGrid, .proxyScopeGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.proxyDirectRulesField { display: grid; gap: 5px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; } +.proxyDirectRulesField textarea { box-sizing: border-box; width: 100%; min-height: 88px; padding: 6px 9px; resize: vertical; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.proxyDirectRulesField small { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; } +.proxyFormGrid > label, .proxyRulesGrid > label, .proxySelectField { display: grid; gap: 5px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; } +.proxyTitleLine { display: flex; align-items: center; gap: 7px; } +.proxyTitleLine h3 { margin: 0; } +.proxySaveState { padding: 1px 6px; border-radius: 999px; font-size: 10px; line-height: 16px; } +.proxySaveState[data-state='unsaved'] { color: var(--dsw-alias-state-warn-label); background: color-mix(in srgb, var(--dsw-alias-state-warn-label) 10%, transparent); } +.proxySaveState[data-state='saved'] { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); } +.proxyComponentAlert { display: inline-grid; width: 21px; height: 21px; padding: 2px; place-items: center; border: 0; border-radius: 5px; background: transparent; } +.proxyComponentAlert:hover { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); } +.proxyComponentAlert svg { width: 16px; height: 16px; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.proxyComponentAlert circle { fill: var(--dsw-alias-state-error-primary); stroke: var(--dsw-alias-state-error-primary); } +.proxyComponentAlert path { stroke: var(--dsw-alias-label-primary-foreground); stroke-width: 2.8; } +.proxyPasswordRow { display: grid; grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: end; gap: 10px; } +.proxyPasswordRow > .secondaryButton { justify-self: end; } +.proxyPasswordField { display: flex; min-width: 0; align-items: end; gap: 8px; } +.proxyPasswordRow > .proxyPasswordField { min-width: 0; } +.proxyPasswordField > label { display: grid; min-width: 0; flex: 1; gap: 5px; color: var(--dsw-alias-label-secondary); font-size: 12px; } +.proxyPasswordField > label > span { color: var(--dsw-alias-label-primary); font-weight: 500; } +.proxyClearPassword { min-height: 34px; flex: none; padding: 5px 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; color: var(--dsw-alias-label-secondary); background: transparent; font: inherit; font-size: 12px; cursor: pointer; } +.proxyClearPassword[aria-pressed='true'] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent); } +.proxyFormGrid input, .proxyRulesGrid textarea { box-sizing: border-box; width: 100%; min-height: 34px; padding: 6px 9px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); font: inherit; } +.proxySelectTrigger { width: 100%; min-height: 34px; } +.proxyRulesGrid textarea { min-height: 88px; resize: vertical; font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.proxyFormGrid label > span, .proxyRulesGrid label > span { color: var(--dsw-alias-label-primary); font-weight: 500; } +.proxyRulesGrid small { display: block; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; } +.proxySettingRow { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 10px; color: var(--dsw-alias-label-primary); font-size: 12px; } +.proxySettingRow > span, .proxySettingRow > div { min-width: 0; } +.proxySettingRow b, .proxySettingRow small { display: block; } +.proxySettingRow small { margin-top: 2px; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; } +.proxySettingRow > .toggle { width: auto; justify-self: end; } +.proxyMasterRow { margin-top: 0; } +.proxyMasterRow h3 { margin: 0; font-size: 16px; line-height: 24px; font-weight: 500; } +.proxyMasterRow > div > small { font-size: 13px; line-height: 20px; } +.proxyAdvanced { display: grid; gap: 11px; margin-top: 12px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-module-platform); } +.proxyAdvanced h4 { margin: 0; font-size: 13px; line-height: 20px; font-weight: 500; } +.proxyAdvancedSetting { margin-top: 0; } +.proxyAdvancedSetting > div > small { font-size: 13px; line-height: 20px; } +.proxyHint { margin: 6px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; } +.proxyScopeCard { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 11px; min-width: 0; padding: 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 7px; background: var(--dsw-alias-bg-module-platform); } +.proxyScopeCopy { min-width: 0; } +.proxyScopeCard > .toggle { justify-self: end; } +.proxyScopeGrid b, .proxyScopeGrid small { display: block; } +.proxyScopeGrid b { font-size: 13px; line-height: 20px; } +.proxyScopeGrid small { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; } +.proxyScopeDescription { display: -webkit-box; width: 100%; padding: 0; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 1; border: 0; color: var(--dsw-alias-label-tertiary); background: transparent; font: inherit; font-size: 11px; line-height: 17px; text-align: left; cursor: default; } +.proxyScopeDescription.expandable { cursor: pointer; } +.proxyScopeDescription.expanded { display: block; overflow: visible; -webkit-line-clamp: initial; } +.proxyProviderList { max-height: 320px; overflow-x: hidden; overflow-y: auto; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; scrollbar-gutter: stable; } +.proxyProviderSection { padding-bottom: 0; border-bottom: 0; } +.proxyProviderHeading > h3 { margin: 0 0 2px; font-size: 16px; line-height: 24px; font-weight: 500; } +.proxyProviderHeading > div { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.proxyProviderHeading p { min-width: 0; flex: 1; margin: 0; color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 20px; } +.proxyProvider { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 9px 11px; border-bottom: 1px solid var(--dsw-alias-border-l2); } +.proxyProviderControls { display: flex; align-items: center; justify-self: end; gap: 10px; } +.proxyProviderRoute { box-sizing: border-box; min-height: 22px; padding: 3px 9px; border-radius: 999px; color: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-bg-module-platform); font-size: 10px; line-height: 16px; white-space: nowrap; } +.proxyProvider:last-child { border-bottom: 0; } +.proxyProviderIdentity { display: flex; min-width: 0; align-items: center; gap: 7px; } +.proxyProviderIdentity b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.proxyProviderInfo { box-sizing: border-box; flex: none; width: 17px; height: 17px; padding: 0; border: 1px solid var(--dsw-alias-label-tertiary); border-radius: 50%; color: var(--dsw-alias-label-tertiary); background: transparent; font: 600 11px/15px ui-sans-serif, system-ui, sans-serif; text-align: center; cursor: pointer; } +.proxyProviderInfo:hover, .proxyProviderInfo:focus-visible { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); } +.proxyTestStages { display: grid; gap: 0; margin: 0; padding: 0; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; list-style: none; } +.proxyTestStages > li { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; gap: 7px; align-items: start; padding: 8px 10px; border-bottom: 1px solid var(--dsw-alias-border-l2); font-size: 12px; cursor: pointer; } +.proxyTestStages > li:hover, .proxyTestStages > li:focus-visible { background: var(--dsw-alias-bg-module-platform); outline: none; } +.proxyTestStages > li:last-child { border-bottom: 0; } +.proxyTestStages > li > span:first-child { width: 9px; height: 9px; margin-top: 4px; border: 1.5px solid var(--dsw-alias-label-tertiary); border-radius: 50%; } +.proxyTestStages > li > span:nth-child(2) { display: grid; min-width: 0; gap: 1px; } +.proxyTestStages > li > span:nth-child(2) small { display: block; } +.proxyTestStages > li[data-state='running'] > span:first-child { border-color: var(--dsw-alias-brand-primary); border-top-color: transparent; animation: checkSpin .7s linear infinite; } +.proxyTestStages > li[data-state='success'] > span:first-child { border-color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-primary); } +.proxyTestStages > li[data-state='failed'] > span:first-child { border-color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-state-error-primary); } +.proxyTestStages > li[data-state='cancelled'] > span:first-child { border-color: var(--dsw-alias-state-warn-label); background: var(--dsw-alias-state-warn-label); } +.proxyTestStages > li small { display: block; color: var(--dsw-alias-label-tertiary); font-size: 10px; line-height: 16px; } +.proxyTestDialog { box-sizing: border-box; width: min(620px, calc(100vw - 40px)); max-height: min(720px, calc(100dvh - 40px)); padding: 0; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); } +.proxyTestDialog::backdrop { background: rgb(0 0 0 / 50%); } +.proxyTestDialog > div { display: grid; max-height: inherit; gap: 14px; padding: 16px; overflow: auto; } +.proxyTestDialog header, .proxyTestDialog footer { display: flex; align-items: start; justify-content: space-between; gap: 12px; } +.proxyTestDialog header h3, .proxyTestDialog header p { margin: 0; } +.proxyTestDialog header p { margin-top: 4px; color: var(--dsw-alias-label-tertiary); } +.proxyTestDialog footer { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; } +.proxyTestDialog footer > .proxyHint, .proxyTestDialog footer > .error { min-width: 0; margin: 0; } +.proxyTestDialog footer > button { grid-column: 2; margin-left: 0; } +.proxyScopeDialog { box-sizing: border-box; width: min(760px, calc(100vw - 40px)); height: min(720px, calc(100dvh - 40px)); max-height: min(720px, calc(100dvh - 40px)); padding: 0; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); } +.proxyScopeDialog::backdrop { background: rgb(0 0 0 / 50%); } +.proxyScopeDialog form { display: grid; height: 100%; min-height: 0; grid-template-rows: auto minmax(0, 1fr) auto; } +.proxyScopeDialog header { display: flex; align-items: start; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--dsw-alias-border-l2); } +.proxyScopeDialog h3, .proxyScopeDialog h4 { margin: 0; } +.proxyScopeDialog header p { margin: 2px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; } +.proxyProviderInfoDialog { box-sizing: border-box; width: min(460px, calc(100vw - 40px)); padding: 0; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); } +.proxyProviderInfoDialog::backdrop { background: rgb(0 0 0 / 50%); } +.proxyProviderInfoDialog form { display: grid; gap: 14px; } +.proxyProviderInfoDialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px 0; } +.proxyProviderInfoDialog h3 { margin: 0; } +.proxyProviderInfoDialog p { margin: 0; padding: 0 16px 16px; color: var(--dsw-alias-label-tertiary); line-height: 1.6; } +.proxySystemRulesBody { padding: 0 16px 16px; } +.proxySystemRulesBody p { padding: 0; } +.proxySystemRulesBody pre { margin: 12px 0 0; padding: 9px 11px; overflow: auto; border: 1px solid var(--dsw-alias-border-l2); border-radius: 7px; background: var(--dsw-alias-bg-module-platform); font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.proxyScopeCatalog { min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 8px 16px; } +.proxyScopeCatalog section { display: grid; grid-template-columns: minmax(130px, .8fr) minmax(0, 1.5fr) minmax(0, 1.5fr); gap: 0; column-gap: 16px; margin: 0; padding: 0; } +.proxyScopeCatalog section + section { border-top: 1px solid var(--dsw-alias-border-l2); } +.proxyScopeCatalog h4 { grid-column: 1; grid-row: 1 / -1; align-self: center; margin: 0; padding: 6px 0; font-size: 13px; font-weight: 600; } +.proxyScopeCatalog section > div { grid-column: 2 / -1; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 2px 16px; padding: 6px 0; border-top: 1px solid var(--dsw-alias-border-l2); font-size: 11px; line-height: 17px; } +.proxyScopeSummaries { padding: 9px 16px 14px; border-top: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 17px; } +.proxyScopeSummaries p { margin: 3px 0; } +.settingRow { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 44px; padding: 8px 12px; font-size: 13px; } +.settingRow + .settingRow { border-top: 1px solid var(--dsw-alias-border-l2); } +.settingRow > span { min-width: 0; } +.settingRow b, .settingRow small { display: block; } +.settingRow b { font-weight: 500; } +.settingRow small { margin-top: 2px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; } +.settingSelectTrigger { + min-width: 120px; + height: 32px; +} +.settingRow input[type='checkbox'] { width: 16px; height: 16px; flex: none; accent-color: var(--dsw-alias-brand-primary); } + +.pluginSection { height: 100%; min-height: 0; overflow: visible; border-bottom: 0; } +.pluginSection > .resourceSectionHeading, +.pluginSection > .pluginRestartNotice, +.pluginSection > .listPagination, +.pluginSection > .error { flex: none; } +.pluginSection .listPagination { position: static; } +.resourceSectionHeading { display: block; } +.resourceHeadingDetail { display: flex; align-items: center; gap: 16px; margin-top: 2px; } +.resourceHeadingDetail p { min-width: 0; flex: 1; margin: 0; } +.resourceSearch { + display: block; + box-sizing: border-box; + width: min(320px, 45%); + margin: 0 0 0 auto; + flex: none; + min-width: 0; + height: 34px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 7px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 12px; +} +.resourceSearch:focus-visible { border-color: var(--dsw-alias-label-primary); outline: none; } +.resourceSearch::placeholder { color: var(--dsw-alias-label-tertiary); } +.proxyProviderSearch { border-radius: 7px; } +.proxyProviderSearch:focus-visible { border-color: var(--dsw-alias-label-primary); outline: none; } +.proxyProviderSearch::placeholder { color: var(--dsw-alias-label-tertiary); } +.pluginList { + min-height: 68px; + max-height: min(46dvh, 420px); + flex: 0 1 auto; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; +} +.pluginRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px 16px; + min-height: 52px; + padding: 7px 12px; +} +.pluginRow + .pluginRow { border-top: 1px solid var(--dsw-alias-border-l2); } +.pluginIdentity { min-width: 0; } +.pluginIdentity strong, +.pluginIdentity span { overflow-wrap: anywhere; } +.pluginIdentity strong { font-size: 13px; line-height: 20px; font-weight: 500; } +.pluginIdentity span { margin-top: 2px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; } +.resourceHeading { display: flex; align-items: flex-start; gap: 8px; min-width: 0; } +.resourceHeading > strong { flex: 0 1 auto; max-width: 55%; } +.statusBadge { box-sizing: border-box; display: inline-flex; min-height: 18px; flex: 0 0 auto; align-items: center; margin-top: 1px; padding: 1px 6px; overflow: hidden; border-radius: 999px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-module-platform); font-size: 10px; line-height: 16px; white-space: nowrap; } +.statusEnabled { + color: var(--dsw-alias-state-success-primary); + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); +} +.statusPending { + color: var(--dsw-alias-state-warn-label); + background: color-mix(in srgb, var(--dsw-alias-state-warn-label) 12%, transparent); +} +.resourceDescription { position: relative; flex: 1 1 120px; min-width: 0; padding: 0 15px 0 0; overflow: hidden; border: 0; color: var(--dsw-alias-label-tertiary); background: transparent; font: inherit; font-size: 12px; line-height: 20px; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: default; } +.resourceDescription.expandable { cursor: pointer; } +.resourceDescription.expandable::after { position: absolute; top: 6px; right: 2px; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg); content: ''; } +.resourceDescription.expanded { overflow: visible; text-overflow: clip; white-space: normal; } +.resourceDescription.expanded::after { top: 9px; transform: rotate(225deg); } +.pluginIdentity > .resourceDescription { display: block; width: 100%; margin-top: 2px; } +.pluginIdentity .pendingBadge { + display: inline-block; + width: fit-content; + margin-top: 5px; + color: var(--dsw-alias-state-warn-label); + font-weight: 500; +} +.managedBadge { + padding: 3px 9px; + border-radius: 12px; + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-module-platform); + font-size: 12px; + line-height: 18px; + white-space: nowrap; +} +.pluginActions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; } +.pluginActions .toggle { width: auto; } +.pluginOperation { + grid-column: 1 / -1; + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.pluginRestartNotice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border: 1px solid var(--dsw-alias-state-warn-border); + border-radius: 8px; + background: var(--dsw-alias-state-warn-bg); +} + +.pluginRestartNotice div { min-width: 0; } +.pluginRestartNotice strong { font-size: 13px; line-height: 20px; } +.pluginRestartNotice p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; } +.pluginDraftActions { display: flex; flex: none; align-items: center; gap: 8px; } +.pluginDraftActions button { flex: none; } +.emptyPlugins { margin: 0; padding: 24px 12px; color: var(--dsw-alias-label-tertiary); text-align: center; font-size: 13px; } + +.logSection { gap: 10px; } + +.logTools { + flex: none; + display: flex; + align-items: center; + gap: 8px; +} + +.logConnection { + flex: none; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 24px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; +} + +.logConnection > span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--dsw-alias-state-warn-label); +} + +.logConnection.live > span { background: var(--dsw-alias-state-success-primary); } +.logConnection.disconnected > span { background: var(--dsw-alias-state-error-primary); } + +.autoScrollButton, +.clearLogsButton { + min-height: 26px; + padding: 2px 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + color: var(--dsw-alias-label-secondary); + background: transparent; + font: inherit; + font-size: 11px; + cursor: pointer; +} + +.autoScrollButton:hover, +.clearLogsButton:hover:not(:disabled) { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } +.autoScrollButton[aria-pressed='true'] { color: var(--dsw-alias-label-primary); } +.clearLogsButton:disabled { opacity: 0.45; cursor: default; } + +.logSummaryRow { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.logAutoScroll { display: inline-flex; flex: none; align-items: center; gap: 6px; min-height: 26px; margin-left: auto; color: var(--dsw-alias-label-secondary); font-size: 11px; cursor: pointer; } +.logAutoScroll input { position: absolute; opacity: 0; pointer-events: none; } +.logAutoScroll > span { box-sizing: border-box; width: 28px; height: 16px; padding: 2px; border-radius: 8px; background: var(--dsw-alias-border-l2); } +.logAutoScroll > span::after { display: block; width: 12px; height: 12px; border-radius: 50%; background: var(--dsw-alias-label-primary-foreground); content: ''; transition: transform .15s ease; } +.logAutoScroll input:checked + span { background: var(--dsw-alias-state-success-primary); } +.logAutoScroll input:checked + span::after { transform: translateX(12px); } +.logAutoScroll input:focus-visible + span { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } +.logAutoScroll b { font-weight: 400; } + +.logFilters { + display: grid; + grid-template-columns: minmax(180px, 1fr) repeat(3, minmax(110px, auto)); + gap: 8px; +} + +.selectMenu { + min-width: 0; +} +body[data-dsh-platform-select-menu='open'] > [role='menu'] { + max-height: min(320px, 60dvh); + overflow: hidden; + overscroll-behavior: contain; +} +body[data-dsh-platform-select-menu='open'] > [role='menu'] > [role='presentation'] { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} +.selectTrigger { + box-sizing: border-box; + display: inline-flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 7px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-module-platform); + font: inherit; + cursor: pointer; +} +.selectTrigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } +.selectTrigger:disabled { cursor: not-allowed; opacity: .45; } +.selectTrigger svg { flex: none; } +.logFilters > .selectMenu { width: 100%; } +.logSelectTrigger { width: 100%; height: 34px; font-size: 12px; } +.proxySelectField > .selectMenu { width: 100%; } +.proxySelectTrigger, .settingSelectTrigger, .paginationSelectTrigger { border-radius: 6px; } + +.logFilters input { + box-sizing: border-box; + min-width: 0; + height: 34px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 7px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 12px; +} + +.logFilters input::placeholder { color: var(--dsw-alias-label-tertiary); } +.proxyFormGrid input:focus-visible, +.proxyRulesGrid textarea:focus-visible, +.pageJump input:focus-visible, +.logFilters input:focus-visible { + border-color: var(--dsw-alias-label-primary); + outline: none; +} +.selectTrigger:focus-visible { outline: none; } + +.logSummary { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.logResizeFrame { + position: relative; + box-sizing: border-box; + height: 280px; + min-height: 140px; + max-height: min(560px, 70dvh); +} + +.logList { + display: flex; + flex-direction: column; + box-sizing: border-box; + height: 100%; + overflow: auto; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; +} + +.logResizeHandle { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + height: 16px; + cursor: ns-resize; + touch-action: none; +} + +.logEntry { + min-width: 0; + padding: 10px 12px; + background: var(--dsw-alias-bg-module-platform); + cursor: pointer; +} + +.logEntry + .logEntry { border-top: 1px solid var(--dsw-alias-border-l2); } +.logEntry:hover { background: var(--dsw-alias-interactive-bg-hover); } +.logEntry:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } +.logMeta { display: flex; align-items: center; gap: 8px; min-width: 0; } +.logMessageRow { display: flex; align-items: center; gap: 10px; min-width: 0; } +.logMessageRow > pre { flex: 1; min-width: 0; } +.logEntry[aria-expanded='false'] .logMessageRow > pre { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.logChevron { + position: relative; + top: 2px; + flex: none; + width: 10px; + height: 10px; + margin-right: 1px; +} +.logChevron::before { + position: absolute; + top: 3px; + left: 1px; + width: 8px; + height: 5px; + background: var(--dsw-alias-label-tertiary); + clip-path: polygon(0 0, 50% 72%, 100% 0, 100% 28%, 50% 100%, 0 28%); + content: ""; + transform-origin: center; +} +.logEntry[aria-expanded='true'] .logChevron::before { transform: rotate(180deg); } +.logLevel { flex: none; font-size: 11px; line-height: 18px; font-weight: 600; } +.logDebug { color: var(--dsw-alias-label-tertiary); } +.logInfo { color: var(--dsw-alias-state-business-primary); } +.logWarning { color: var(--dsw-alias-state-warn-label); } +.logError { color: var(--dsw-alias-state-error-primary); } +.logSource { + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.logMeta time { + flex: none; + margin-left: auto; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 18px; +} + +.logEntry pre { + margin: 5px 0 0; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-primary); + font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: pre-wrap; +} + +.logDetails { + margin-top: 9px !important; + padding-top: 9px; + border-top: 1px solid var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-secondary) !important; +} + +.emptyLogs { + margin: 0; + padding: 28px 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + color: var(--dsw-alias-label-tertiary); + text-align: center; + font-size: 13px; +} + +.updateReminder { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 1000; + box-sizing: border-box; + width: min(360px, calc(100vw - 32px)); + padding: 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-module-platform); + box-shadow: 0 8px 24px rgb(0 0 0 / 24%); + pointer-events: auto; +} +.updateReminderClose { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--dsw-alias-label-secondary); + background: transparent; + font: 20px/28px sans-serif; + cursor: pointer; +} +.updateReminderClose:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } +.updateReminderClose:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; } +.updateReminder strong { display: block; font-size: 14px; line-height: 22px; } +.updateReminder p { margin: 4px 0 12px; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; } +.updateReminderVersions span { display: block; } +.reminderActions { display: flex; justify-content: flex-end; gap: 8px; } +.reminderActions button { + min-height: 30px; + padding: 4px 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 15px; + color: var(--dsw-alias-label-primary); + background: transparent; + font: inherit; + font-size: 12px; + cursor: pointer; +} +.reminderActions button:hover { background: var(--dsw-alias-interactive-bg-hover); } + +@media (max-width: 640px) { + .pluginList { min-height: 122px; } + .progressLogGroupListPopulated { height: 180px; } + .sectionHeading { flex-direction: column; gap: 10px; } + .resourceHeadingDetail { align-items: stretch; flex-direction: column; gap: 8px; } + .resourceSearch { width: 100%; margin-left: 0; } + .proxyFormGrid, .proxyRulesGrid, .proxyScopeGrid { grid-template-columns: 1fr; } + .proxyScopeCatalog section { display: block; } + .proxyScopeCatalog h4 { padding: 6px 0 0; } + .proxyScopeCatalog section > div { grid-column: auto; grid-template-columns: minmax(0, 1fr); } + .proxyProvider { align-items: center; } + .proxyProviderList { max-height: 280px; } + .proxyScopeDialog { width: 100%; max-width: none; height: 100dvh; max-height: none; border: 0; border-radius: 0; } + .actionHeading { align-items: flex-start; } + .versions { grid-template-columns: 1fr; } + .versionCell + .versionCell { border-left: 0; border-top: 1px solid var(--dsw-alias-border-l2); } + .segmented { width: 100%; } + .segmented button { flex: 1; } + .actions > button { flex: 1 1 calc(50% - 8px); } + .actions { width: 100%; justify-content: flex-start; } + .progressHeading { align-items: flex-start; } + .progressHeading output { text-align: right; } + .progressLogGroup { padding-left: 24px; } + .progressLogGroup:not(:last-child)::before { left: 7px; } + .progressStageMarker { left: 2px; } + .progressLogEntry summary { grid-template-columns: auto minmax(0, 1fr) auto; gap: 1px 6px; } + .progressLogActions { align-items: stretch; flex-direction: column; } + .progressLogActions button { width: 100%; } + .maintenanceButton { width: 100%; } + .toggle { width: 100%; justify-content: space-between; } + .settingRow { align-items: flex-start; } + .pluginRow { grid-template-columns: 1fr; align-items: stretch; } + .pluginActions { justify-content: space-between; } + .pluginActions .toggle { width: auto; } + .pluginRestartNotice { align-items: stretch; flex-direction: column; } + .pluginDraftActions { display: grid; width: 100%; grid-template-columns: 1fr; } + .pluginDraftActions button { width: 100%; } + .proxyPasswordField { align-items: stretch; flex-direction: column; } + .proxyPasswordRow { align-items: end; grid-template-columns: minmax(0, 1fr) auto; } + .proxyClearPassword { width: 100%; } + .proxyProviderHeading > div { align-items: stretch; flex-direction: column; gap: 8px; } + .proxyProviderHeading .resourceSearch { width: 100%; margin-left: 0; } + .managedBadge { justify-self: start; } + .listPagination { grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; } + .listPagination > span { grid-column: 1; grid-row: 1; } + .listPagination > label:not(.pageJump) { grid-column: 2; grid-row: 1; justify-self: end; } + .pageNavigation { grid-column: 1; grid-row: 2; justify-self: start; } + .pageJump { grid-column: 2; grid-row: 2; justify-self: end; } + .logFilters { grid-template-columns: 1fr 1fr; } + .logTools { width: 100%; flex-wrap: wrap; } + .logConnection { margin-right: auto; } + .logFilters input { grid-column: 1 / -1; } + .logMeta { flex-wrap: wrap; } + .logMeta time { width: auto; margin-left: auto; } + .logResizeFrame { + height: min(260px, 36dvh); + max-height: min(520px, 72dvh); + } + .updateReminder { right: 16px; bottom: 16px; } +} + +@media (max-width: 480px) { + .actions > button { flex-basis: 100%; } +} diff --git a/upstream/container/environment/resources/plugins/platform-management/package/package-lock.json b/upstream/container/environment/resources/plugins/platform-management/package/package-lock.json new file mode 100644 index 0000000..1963181 --- /dev/null +++ b/upstream/container/environment/resources/plugins/platform-management/package/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "@dsh-docker/platform-management", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@dsh-docker/platform-management", + "version": "1.0.0", + "dependencies": { + "undici": "8.10.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-client-connection": "*", + "@deepseek-ai/dsh-client-locale": "*", + "@deepseek-ai/dsh-client-ui-primitives": "*", + "@deepseek-ai/dsh-client-ui-settings": "*", + "react": "^18.2.0" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + } + } +} diff --git a/upstream/container/environment/resources/plugins/platform-management/package/package.json b/upstream/container/environment/resources/plugins/platform-management/package/package.json new file mode 100644 index 0000000..0220055 --- /dev/null +++ b/upstream/container/environment/resources/plugins/platform-management/package/package.json @@ -0,0 +1,39 @@ +{ + "name": "@dsh-docker/platform-management", + "version": "1.0.0", + "type": "module", + "main": "lib/index.js", + "exports": { + ".": "./lib/index.js", + "./client": "./lib/client.bundle.js", + "./package.json": "./package.json" + }, + "dependencies": { + "undici": "8.10.0" + }, + "dshDocker": { + "description": { + "zh": "管理 DSH Docker 更新、运行维护、系统资源与登录会话。", + "en": "Manage DSH Docker updates, runtime maintenance, system resources, and login sessions." + } + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-client-ui-settings" + ], + "platform": "web" + } + }, + "peerDependencies": { + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-client-connection": "*", + "@deepseek-ai/dsh-client-locale": "*", + "@deepseek-ai/dsh-client-ui-primitives": "*", + "@deepseek-ai/dsh-client-ui-settings": "*", + "react": "^18.2.0" + } +} diff --git a/upstream/container/environment/resources/plugins/settings-document-editor/package/cordis.patch.json b/upstream/container/environment/resources/plugins/settings-document-editor/package/cordis.patch.json new file mode 100644 index 0000000..159cd7c --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-document-editor/package/cordis.patch.json @@ -0,0 +1,10 @@ +[ + { + "insert": [ + { + "id": "dsh-docker.settings-document-editor.plugin", + "name": "@dsh-docker/settings-document-editor" + } + ] + } +] diff --git a/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/client.bundle.js b/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/client.bundle.js new file mode 100644 index 0000000..e75645a --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/client.bundle.js @@ -0,0 +1,270 @@ +window.__ModuleLoader__.load({ id: "@dsh-docker/settings-document-editor", factory: (require) => { +var module = { exports: {} }; var exports = module.exports; +const css = Object.freeze({"actions":"dshSettingsDocumentEditor_actions","body":"dshSettingsDocumentEditor_body","close":"dshSettingsDocumentEditor_close","confirmActions":"dshSettingsDocumentEditor_confirmActions","confirmation":"dshSettingsDocumentEditor_confirmation","danger":"dshSettingsDocumentEditor_danger","dialog":"dshSettingsDocumentEditor_dialog","dirty":"dshSettingsDocumentEditor_dirty","editor":"dshSettingsDocumentEditor_editor","editorFrame":"dshSettingsDocumentEditor_editorFrame","error":"dshSettingsDocumentEditor_error","footer":"dshSettingsDocumentEditor_footer","header":"dshSettingsDocumentEditor_header","lineNumbers":"dshSettingsDocumentEditor_lineNumbers","loading":"dshSettingsDocumentEditor_loading","mask":"dshSettingsDocumentEditor_mask","overlay":"dshSettingsDocumentEditor_overlay","primary":"dshSettingsDocumentEditor_primary","saved":"dshSettingsDocumentEditor_saved"}); +const styleId = "@dsh-docker/settings-document-editor/style.module.css"; +if (typeof document !== 'undefined' && ![...document.querySelectorAll('style[data-plugin-css]')].some(tag => tag.dataset.pluginCss === styleId)) { + const tag = document.createElement('style'); + tag.dataset.plugin = "@dsh-docker/settings-document-editor"; + tag.dataset.pluginCss = styleId; + tag.textContent = ".dshSettingsDocumentEditor_overlay {\n position: fixed;\n inset: 0;\n z-index: 1200;\n display: grid;\n place-items: center;\n padding: 24px;\n color: var(--dsw-alias-label-primary);\n}\n\n.dshSettingsDocumentEditor_overlay,\n.dshSettingsDocumentEditor_overlay *,\n.dshSettingsDocumentEditor_overlay *::before,\n.dshSettingsDocumentEditor_overlay *::after {\n corner-shape: round;\n}\n\n.dshSettingsDocumentEditor_mask {\n position: absolute;\n inset: 0;\n background: rgb(0 0 0 / 48%);\n}\n\n.dshSettingsDocumentEditor_dialog {\n position: relative;\n display: grid;\n grid-template-rows: auto minmax(0, 1fr) auto;\n box-sizing: border-box;\n width: min(820px, 100%);\n height: min(680px, calc(100dvh - 48px));\n min-height: 420px;\n overflow: hidden;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n background: var(--dsw-alias-bg-module-platform);\n box-shadow: 0 18px 50px rgb(0 0 0 / 32%);\n}\n\n.dshSettingsDocumentEditor_header,\n.dshSettingsDocumentEditor_footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 14px 18px;\n}\n\n.dshSettingsDocumentEditor_header { border-bottom: 1px solid var(--dsw-alias-border-l2); }\n.dshSettingsDocumentEditor_footer { border-top: 1px solid var(--dsw-alias-border-l2); }\n.dshSettingsDocumentEditor_header h2 { margin: 0; font-size: 17px; line-height: 24px; font-weight: 600; }\n.dshSettingsDocumentEditor_header p { margin: 2px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }\n\n.dshSettingsDocumentEditor_close {\n flex: none;\n width: 32px;\n height: 32px;\n padding: 0;\n border: 0;\n border-radius: 6px;\n color: var(--dsw-alias-label-secondary);\n background: transparent;\n font: 18px/32px ui-sans-serif, system-ui, sans-serif;\n cursor: pointer;\n}\n.dshSettingsDocumentEditor_close:hover { background: var(--dsw-alias-interactive-bg-hover); }\n\n.dshSettingsDocumentEditor_body { position: relative; min-height: 0; padding: 14px 18px; }\n.dshSettingsDocumentEditor_editorFrame {\n display: grid;\n grid-template-columns: auto minmax(0, 1fr);\n box-sizing: border-box;\n width: 100%;\n height: 100%;\n min-height: 300px;\n overflow: hidden;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n background: var(--dsw-alias-bg-base);\n}\n.dshSettingsDocumentEditor_editorFrame:focus-within { border-color: var(--dsw-alias-border-l2); }\n\n.dshSettingsDocumentEditor_lineNumbers {\n box-sizing: border-box;\n min-width: 44px;\n height: 100%;\n margin: 0;\n overflow: hidden;\n padding: 12px 10px;\n border-right: 1px solid var(--dsw-alias-border-l2);\n color: var(--dsw-alias-label-tertiary);\n background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, var(--dsw-alias-bg-module-platform));\n font: 12px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n text-align: right;\n user-select: none;\n}\n\n.dshSettingsDocumentEditor_editor {\n box-sizing: border-box;\n width: 100%;\n height: 100%;\n min-width: 0;\n min-height: 0;\n resize: none;\n padding: 12px 14px;\n border: 0;\n outline: none;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n font: 13px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n letter-spacing: 0;\n tab-size: 2;\n}\n\n.dshSettingsDocumentEditor_loading {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 9px;\n height: 100%;\n color: var(--dsw-alias-label-secondary);\n font-size: 13px;\n}\n.dshSettingsDocumentEditor_loading span {\n width: 14px;\n height: 14px;\n border: 2px solid var(--dsw-alias-border-l2);\n border-top-color: var(--dsw-alias-label-primary);\n border-radius: 50%;\n animation: document-editor-spin .8s linear infinite;\n}\n@keyframes document-editor-spin { to { transform: rotate(360deg); } }\n\n.dshSettingsDocumentEditor_error {\n position: absolute;\n right: 28px;\n bottom: 24px;\n left: 28px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 9px 11px;\n border-left: 3px solid var(--dsw-alias-state-error-primary);\n color: var(--dsw-alias-state-error-primary);\n background: var(--dsw-alias-bg-module-platform);\n box-shadow: 0 4px 16px rgb(0 0 0 / 18%);\n font-size: 12px;\n line-height: 18px;\n}\n\n.dshSettingsDocumentEditor_actions,\n.dshSettingsDocumentEditor_confirmActions { display: flex; justify-content: flex-end; gap: 8px; }\n.dshSettingsDocumentEditor_footer button,\n.dshSettingsDocumentEditor_error button,\n.dshSettingsDocumentEditor_confirmation button {\n min-height: 32px;\n padding: 4px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 16px;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n font: inherit;\n font-size: 12px;\n cursor: pointer;\n}\n.dshSettingsDocumentEditor_footer button:hover:not(:disabled),\n.dshSettingsDocumentEditor_error button:hover,\n.dshSettingsDocumentEditor_confirmation button:hover { background: var(--dsw-alias-interactive-bg-hover); }\n.dshSettingsDocumentEditor_footer button:disabled { cursor: not-allowed; opacity: .45; }\n.dshSettingsDocumentEditor_footer .dshSettingsDocumentEditor_primary {\n border-color: transparent;\n color: var(--dsw-alias-label-primary-foreground);\n background: var(--dsw-alias-button-primary-fill);\n}\n.dshSettingsDocumentEditor_footer .dshSettingsDocumentEditor_primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }\n.dshSettingsDocumentEditor_saved, .dshSettingsDocumentEditor_dirty { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }\n.dshSettingsDocumentEditor_dirty { color: var(--dsw-alias-state-warn-label); }\n\n.dshSettingsDocumentEditor_confirmation {\n position: absolute;\n inset: 0;\n z-index: 2;\n display: grid;\n place-items: center;\n padding: 24px;\n background: rgb(0 0 0 / 48%);\n}\n.dshSettingsDocumentEditor_confirmation > div {\n box-sizing: border-box;\n width: min(420px, 100%);\n padding: 18px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n background: var(--dsw-alias-bg-module-platform);\n box-shadow: 0 12px 30px rgb(0 0 0 / 28%);\n}\n.dshSettingsDocumentEditor_confirmation strong { font-size: 15px; line-height: 22px; }\n.dshSettingsDocumentEditor_confirmation p { margin: 6px 0 18px; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; }\n.dshSettingsDocumentEditor_confirmation .dshSettingsDocumentEditor_danger { color: var(--dsw-alias-state-error-primary); }\n\n@media (max-width: 640px) {\n .dshSettingsDocumentEditor_overlay { padding: 0; }\n .dshSettingsDocumentEditor_dialog { width: 100%; height: 100dvh; min-height: 0; border: 0; border-radius: 0; }\n .dshSettingsDocumentEditor_header, .dshSettingsDocumentEditor_footer { padding: 12px 14px; }\n .dshSettingsDocumentEditor_body { padding: 12px 14px; }\n .dshSettingsDocumentEditor_editorFrame { min-height: 0; }\n .dshSettingsDocumentEditor_footer { align-items: flex-start; flex-direction: column; }\n .dshSettingsDocumentEditor_actions { width: 100%; }\n .dshSettingsDocumentEditor_actions button { flex: 1; }\n .dshSettingsDocumentEditor_error { right: 20px; bottom: 20px; left: 20px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .dshSettingsDocumentEditor_loading span { animation: none; }\n}\n"; + document.head.appendChild(tag); +} +const React = require('react') +const { useCallback, useEffect, useRef, useState } = React +const { createPortal } = require('react-dom') + +const API = '/_dsh_platform/plugin-api/v1/settings-document' +const h = React.createElement + +const inject = ['slots', 'locale', 'connection', 'remote'] + +function browserCookie(name) { + for (const part of document.cookie.split(';')) { + const separator = part.indexOf('=') + if (separator >= 0 && part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim() + } + return '' +} + +async function request(method, body) { + const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method) + const response = await fetch(API, { + method, + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...(mutation ? { 'x-dsh-csrf': browserCookie('dsh_gateway_csrf') } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const value = await response.json() + if (!response.ok) { + const error = new Error(value.error ?? `HTTP ${String(response.status)}`) + error.status = response.status + throw error + } + return value +} + +class DocumentController { + constructor() { + this.listeners = new Set() + this.state = { + open: false, + loading: false, + saving: false, + content: '', + savedContent: '', + revision: null, + error: null, + conflict: false, + confirmClose: false, + } + } + + subscribe(listener) { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publish(patch) { + this.state = { ...this.state, ...patch } + for (const listener of this.listeners) listener(this.state) + } + + async open() { + if (this.state.open) return + this.publish({ + open: true, + loading: true, + content: '', + savedContent: '', + revision: null, + error: null, + conflict: false, + confirmClose: false, + }) + await this.reload() + } + + async reload() { + this.publish({ loading: true, error: null, conflict: false, confirmClose: false }) + try { + const value = await request('GET') + this.publish({ + loading: false, + content: value.content, + savedContent: value.content, + revision: value.revision, + }) + } catch (error) { + this.publish({ loading: false, error: error.message }) + } + } + + edit(content) { + this.publish({ content, error: null, conflict: false }) + } + + requestClose() { + if (this.state.saving) return + if (this.state.content !== this.state.savedContent) this.publish({ confirmClose: true }) + else this.close() + } + + close() { + this.publish({ open: false, confirmClose: false, error: null, conflict: false }) + } + + async save() { + if (this.state.saving || this.state.loading || this.state.revision === null + || this.state.content === this.state.savedContent) return + this.publish({ saving: true, error: null, conflict: false }) + try { + const value = await request('PUT', { content: this.state.content, revision: this.state.revision }) + this.publish({ + saving: false, + content: value.content, + savedContent: value.content, + revision: value.revision, + }) + } catch (error) { + this.publish({ saving: false, error: error.message, conflict: error.status === 409 }) + } + } +} + +function Editor({ controller, t }) { + const [state, setState] = useState(controller.state) + const textarea = useRef(null) + const lineNumbers = useRef(null) + + useEffect(() => controller.subscribe(setState), [controller]) + useEffect(() => { + if (state.open && !state.loading && !state.confirmClose) textarea.current?.focus() + }, [state.open, state.loading, state.confirmClose]) + + const keyDown = useCallback(event => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { + event.preventDefault() + void controller.save() + } else if (event.key === 'Escape') { + event.preventDefault() + controller.requestClose() + } + }, [controller]) + + useEffect(() => { + if (!state.open) return undefined + document.addEventListener('keydown', keyDown) + return () => document.removeEventListener('keydown', keyDown) + }, [keyDown, state.open]) + + if (!state.open) return null + const dirty = state.content !== state.savedContent + return createPortal(h('div', { className: css.overlay, role: 'presentation' }, + h('div', { className: css.mask, 'aria-hidden': 'true', onClick: () => controller.requestClose() }), + h('section', { className: css.dialog, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-title' }, + h('header', { className: css.header }, + h('div', null, + h('h2', { id: 'dsh-settings-document-title' }, t('title')), + h('p', null, t('filename'))), + h('button', { className: css.close, type: 'button', 'aria-label': t('close'), onClick: () => controller.requestClose() }, '×')), + h('div', { className: css.body }, + state.loading + ? h('div', { className: css.loading, role: 'status' }, h('span', null), t('loading')) + : h('div', { className: css.editorFrame }, + h('pre', { ref: lineNumbers, className: css.lineNumbers, 'aria-hidden': 'true' }, + Array.from({ length: state.content.split('\n').length }, (_, index) => String(index + 1)).join('\n')), + h('textarea', { + ref: textarea, + className: css.editor, + value: state.content, + wrap: 'off', + spellCheck: false, + 'aria-label': t('editorLabel'), + onChange: event => controller.edit(event.target.value), + onScroll: event => { lineNumbers.current.scrollTop = event.currentTarget.scrollTop }, + })), + state.error === null ? null : h('div', { className: css.error, role: 'alert' }, + h('span', null, state.conflict ? t('conflict') : t('loadError')), + state.conflict ? h('button', { type: 'button', onClick: () => { void controller.reload() } }, t('reload')) : null)), + h('footer', { className: css.footer }, + h('span', { className: dirty ? css.dirty : css.saved, role: 'status' }, dirty ? t('unsaved') : t('saved')), + h('div', { className: css.actions }, + h('button', { type: 'button', disabled: dirty || state.loading || state.saving, onClick: () => { void controller.reload() } }, t('reload')), + h('button', { type: 'button', onClick: () => controller.requestClose() }, t('cancel')), + h('button', { className: css.primary, type: 'button', disabled: !dirty || state.loading || state.saving, onClick: () => { void controller.save() } }, state.saving ? t('saving') : t('save')))), + state.confirmClose ? h('div', { className: css.confirmation, role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-discard-title' }, + h('div', null, + h('strong', { id: 'dsh-settings-document-discard-title' }, t('discardTitle')), + h('p', null, t('discardBody')), + h('div', { className: css.confirmActions }, + h('button', { type: 'button', onClick: () => controller.publish({ confirmClose: false }) }, t('continueEditing')), + h('button', { className: css.danger, type: 'button', onClick: () => controller.close() }, t('discard'))))) : null)), document.body) +} + +function interceptDocumentOpen(target, method, controller, result) { + const descriptor = Object.getOwnPropertyDescriptor(target, method) + const intercepted = async () => { + void controller.open() + return result + } + Object.defineProperty(target, method, { + configurable: true, + enumerable: descriptor?.enumerable ?? true, + value: intercepted, + writable: true, + }) + return () => { + if (target[method] !== intercepted) return + if (descriptor === undefined) Reflect.deleteProperty(target, method) + else Object.defineProperty(target, method, descriptor) + } +} + +function apply(ctx) { + const controller = new DocumentController() + ctx.effect(() => ctx.locale.register('settings.dshDocumentEditor', { + zh: { + title: '编辑配置文件', filename: 'settings.yaml', close: '关闭', loading: '正在读取配置文件', editorLabel: '配置文件内容', + loadError: '无法读取或保存配置文件。', conflict: '配置文件已在其他位置发生变化,请重新加载后再编辑。', reload: '重新加载', + unsaved: '有未保存的修改', saved: '所有修改均已保存', cancel: '关闭', save: '保存', saving: '正在保存', + discardTitle: '放弃未保存的修改?', discardBody: '关闭后,本次未保存的修改将会丢失。', continueEditing: '继续编辑', discard: '放弃修改', + }, + en: { + title: 'Edit configuration file', filename: 'settings.yaml', close: 'Close', loading: 'Loading configuration file', editorLabel: 'Configuration file content', + loadError: 'The configuration file could not be loaded or saved.', conflict: 'The configuration file changed elsewhere. Reload it before editing again.', reload: 'Reload', + unsaved: 'Unsaved changes', saved: 'All changes saved', cancel: 'Close', save: 'Save', saving: 'Saving', + discardTitle: 'Discard unsaved changes?', discardBody: 'Your unsaved changes will be lost when this editor closes.', continueEditing: 'Continue editing', discard: 'Discard changes', + }, + }), 'dsh-settings-document-editor: locale') + + const connection = ctx.get('connection') + if (typeof connection.api?.settings?.openDocument === 'function') { + ctx.effect(() => interceptDocumentOpen( + connection.api.settings, + 'openDocument', + controller, + { result: { ok: true, value: { opened: true } } }, + ), 'dsh-settings-document-editor: legacy open action') + } else { + ctx.inject(['remote.settings'], child => { + child.effect(() => interceptDocumentOpen( + child.remote.settings, + 'openSettingsDocument', + controller, + { ok: true, value: { opened: true } }, + ), 'dsh-settings-document-editor: open action') + }) + } + + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: 'dsh-settings-document-editor', + order: 80, + locale: 'settings.dshDocumentEditor', + }, props => h(Editor, { ...props, controller }))) +} +exports.inject = inject; +exports.apply = apply; +return module.exports; } }); diff --git a/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/client.js b/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/client.js new file mode 100644 index 0000000..68ebb43 --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/client.js @@ -0,0 +1,256 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import css from './style.module.css' +const { createPortal } = require('react-dom') + +const API = '/_dsh_platform/plugin-api/v1/settings-document' +const h = React.createElement + +export const inject = ['slots', 'locale', 'connection', 'remote'] + +function browserCookie(name) { + for (const part of document.cookie.split(';')) { + const separator = part.indexOf('=') + if (separator >= 0 && part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim() + } + return '' +} + +async function request(method, body) { + const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method) + const response = await fetch(API, { + method, + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...(mutation ? { 'x-dsh-csrf': browserCookie('dsh_gateway_csrf') } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const value = await response.json() + if (!response.ok) { + const error = new Error(value.error ?? `HTTP ${String(response.status)}`) + error.status = response.status + throw error + } + return value +} + +class DocumentController { + constructor() { + this.listeners = new Set() + this.state = { + open: false, + loading: false, + saving: false, + content: '', + savedContent: '', + revision: null, + error: null, + conflict: false, + confirmClose: false, + } + } + + subscribe(listener) { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publish(patch) { + this.state = { ...this.state, ...patch } + for (const listener of this.listeners) listener(this.state) + } + + async open() { + if (this.state.open) return + this.publish({ + open: true, + loading: true, + content: '', + savedContent: '', + revision: null, + error: null, + conflict: false, + confirmClose: false, + }) + await this.reload() + } + + async reload() { + this.publish({ loading: true, error: null, conflict: false, confirmClose: false }) + try { + const value = await request('GET') + this.publish({ + loading: false, + content: value.content, + savedContent: value.content, + revision: value.revision, + }) + } catch (error) { + this.publish({ loading: false, error: error.message }) + } + } + + edit(content) { + this.publish({ content, error: null, conflict: false }) + } + + requestClose() { + if (this.state.saving) return + if (this.state.content !== this.state.savedContent) this.publish({ confirmClose: true }) + else this.close() + } + + close() { + this.publish({ open: false, confirmClose: false, error: null, conflict: false }) + } + + async save() { + if (this.state.saving || this.state.loading || this.state.revision === null + || this.state.content === this.state.savedContent) return + this.publish({ saving: true, error: null, conflict: false }) + try { + const value = await request('PUT', { content: this.state.content, revision: this.state.revision }) + this.publish({ + saving: false, + content: value.content, + savedContent: value.content, + revision: value.revision, + }) + } catch (error) { + this.publish({ saving: false, error: error.message, conflict: error.status === 409 }) + } + } +} + +function Editor({ controller, t }) { + const [state, setState] = useState(controller.state) + const textarea = useRef(null) + const lineNumbers = useRef(null) + + useEffect(() => controller.subscribe(setState), [controller]) + useEffect(() => { + if (state.open && !state.loading && !state.confirmClose) textarea.current?.focus() + }, [state.open, state.loading, state.confirmClose]) + + const keyDown = useCallback(event => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { + event.preventDefault() + void controller.save() + } else if (event.key === 'Escape') { + event.preventDefault() + controller.requestClose() + } + }, [controller]) + + useEffect(() => { + if (!state.open) return undefined + document.addEventListener('keydown', keyDown) + return () => document.removeEventListener('keydown', keyDown) + }, [keyDown, state.open]) + + if (!state.open) return null + const dirty = state.content !== state.savedContent + return createPortal(h('div', { className: css.overlay, role: 'presentation' }, + h('div', { className: css.mask, 'aria-hidden': 'true', onClick: () => controller.requestClose() }), + h('section', { className: css.dialog, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-title' }, + h('header', { className: css.header }, + h('div', null, + h('h2', { id: 'dsh-settings-document-title' }, t('title')), + h('p', null, t('filename'))), + h('button', { className: css.close, type: 'button', 'aria-label': t('close'), onClick: () => controller.requestClose() }, '×')), + h('div', { className: css.body }, + state.loading + ? h('div', { className: css.loading, role: 'status' }, h('span', null), t('loading')) + : h('div', { className: css.editorFrame }, + h('pre', { ref: lineNumbers, className: css.lineNumbers, 'aria-hidden': 'true' }, + Array.from({ length: state.content.split('\n').length }, (_, index) => String(index + 1)).join('\n')), + h('textarea', { + ref: textarea, + className: css.editor, + value: state.content, + wrap: 'off', + spellCheck: false, + 'aria-label': t('editorLabel'), + onChange: event => controller.edit(event.target.value), + onScroll: event => { lineNumbers.current.scrollTop = event.currentTarget.scrollTop }, + })), + state.error === null ? null : h('div', { className: css.error, role: 'alert' }, + h('span', null, state.conflict ? t('conflict') : t('loadError')), + state.conflict ? h('button', { type: 'button', onClick: () => { void controller.reload() } }, t('reload')) : null)), + h('footer', { className: css.footer }, + h('span', { className: dirty ? css.dirty : css.saved, role: 'status' }, dirty ? t('unsaved') : t('saved')), + h('div', { className: css.actions }, + h('button', { type: 'button', disabled: dirty || state.loading || state.saving, onClick: () => { void controller.reload() } }, t('reload')), + h('button', { type: 'button', onClick: () => controller.requestClose() }, t('cancel')), + h('button', { className: css.primary, type: 'button', disabled: !dirty || state.loading || state.saving, onClick: () => { void controller.save() } }, state.saving ? t('saving') : t('save')))), + state.confirmClose ? h('div', { className: css.confirmation, role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-discard-title' }, + h('div', null, + h('strong', { id: 'dsh-settings-document-discard-title' }, t('discardTitle')), + h('p', null, t('discardBody')), + h('div', { className: css.confirmActions }, + h('button', { type: 'button', onClick: () => controller.publish({ confirmClose: false }) }, t('continueEditing')), + h('button', { className: css.danger, type: 'button', onClick: () => controller.close() }, t('discard'))))) : null)), document.body) +} + +function interceptDocumentOpen(target, method, controller, result) { + const descriptor = Object.getOwnPropertyDescriptor(target, method) + const intercepted = async () => { + void controller.open() + return result + } + Object.defineProperty(target, method, { + configurable: true, + enumerable: descriptor?.enumerable ?? true, + value: intercepted, + writable: true, + }) + return () => { + if (target[method] !== intercepted) return + if (descriptor === undefined) Reflect.deleteProperty(target, method) + else Object.defineProperty(target, method, descriptor) + } +} + +export function apply(ctx) { + const controller = new DocumentController() + ctx.effect(() => ctx.locale.register('settings.dshDocumentEditor', { + zh: { + title: '编辑配置文件', filename: 'settings.yaml', close: '关闭', loading: '正在读取配置文件', editorLabel: '配置文件内容', + loadError: '无法读取或保存配置文件。', conflict: '配置文件已在其他位置发生变化,请重新加载后再编辑。', reload: '重新加载', + unsaved: '有未保存的修改', saved: '所有修改均已保存', cancel: '关闭', save: '保存', saving: '正在保存', + discardTitle: '放弃未保存的修改?', discardBody: '关闭后,本次未保存的修改将会丢失。', continueEditing: '继续编辑', discard: '放弃修改', + }, + en: { + title: 'Edit configuration file', filename: 'settings.yaml', close: 'Close', loading: 'Loading configuration file', editorLabel: 'Configuration file content', + loadError: 'The configuration file could not be loaded or saved.', conflict: 'The configuration file changed elsewhere. Reload it before editing again.', reload: 'Reload', + unsaved: 'Unsaved changes', saved: 'All changes saved', cancel: 'Close', save: 'Save', saving: 'Saving', + discardTitle: 'Discard unsaved changes?', discardBody: 'Your unsaved changes will be lost when this editor closes.', continueEditing: 'Continue editing', discard: 'Discard changes', + }, + }), 'dsh-settings-document-editor: locale') + + const connection = ctx.get('connection') + if (typeof connection.api?.settings?.openDocument === 'function') { + ctx.effect(() => interceptDocumentOpen( + connection.api.settings, + 'openDocument', + controller, + { result: { ok: true, value: { opened: true } } }, + ), 'dsh-settings-document-editor: legacy open action') + } else { + ctx.inject(['remote.settings'], child => { + child.effect(() => interceptDocumentOpen( + child.remote.settings, + 'openSettingsDocument', + controller, + { ok: true, value: { opened: true } }, + ), 'dsh-settings-document-editor: open action') + }) + } + + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: 'dsh-settings-document-editor', + order: 80, + locale: 'settings.dshDocumentEditor', + }, props => h(Editor, { ...props, controller }))) +} diff --git a/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/index.js b/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/index.js new file mode 100644 index 0000000..720daad --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/index.js @@ -0,0 +1,3 @@ +export const name = '@dsh-docker/settings-document-editor' + +export function apply() {} diff --git a/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/style.module.css b/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/style.module.css new file mode 100644 index 0000000..3da97dd --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-document-editor/package/lib/style.module.css @@ -0,0 +1,215 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: grid; + place-items: center; + padding: 24px; + color: var(--dsw-alias-label-primary); +} + +.overlay, +.overlay *, +.overlay *::before, +.overlay *::after { + corner-shape: round; +} + +.mask { + position: absolute; + inset: 0; + background: rgb(0 0 0 / 48%); +} + +.dialog { + position: relative; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + box-sizing: border-box; + width: min(820px, 100%); + height: min(680px, calc(100dvh - 48px)); + min-height: 420px; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + background: var(--dsw-alias-bg-module-platform); + box-shadow: 0 18px 50px rgb(0 0 0 / 32%); +} + +.header, +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; +} + +.header { border-bottom: 1px solid var(--dsw-alias-border-l2); } +.footer { border-top: 1px solid var(--dsw-alias-border-l2); } +.header h2 { margin: 0; font-size: 17px; line-height: 24px; font-weight: 600; } +.header p { margin: 2px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; } + +.close { + flex: none; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: 6px; + color: var(--dsw-alias-label-secondary); + background: transparent; + font: 18px/32px ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.close:hover { background: var(--dsw-alias-interactive-bg-hover); } + +.body { position: relative; min-height: 0; padding: 14px 18px; } +.editorFrame { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + box-sizing: border-box; + width: 100%; + height: 100%; + min-height: 300px; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + background: var(--dsw-alias-bg-base); +} +.editorFrame:focus-within { border-color: var(--dsw-alias-border-l2); } + +.lineNumbers { + box-sizing: border-box; + min-width: 44px; + height: 100%; + margin: 0; + overflow: hidden; + padding: 12px 10px; + border-right: 1px solid var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-tertiary); + background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, var(--dsw-alias-bg-module-platform)); + font: 12px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + text-align: right; + user-select: none; +} + +.editor { + box-sizing: border-box; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + resize: none; + padding: 12px 14px; + border: 0; + outline: none; + color: var(--dsw-alias-label-primary); + background: transparent; + font: 13px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0; + tab-size: 2; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + height: 100%; + color: var(--dsw-alias-label-secondary); + font-size: 13px; +} +.loading span { + width: 14px; + height: 14px; + border: 2px solid var(--dsw-alias-border-l2); + border-top-color: var(--dsw-alias-label-primary); + border-radius: 50%; + animation: document-editor-spin .8s linear infinite; +} +@keyframes document-editor-spin { to { transform: rotate(360deg); } } + +.error { + position: absolute; + right: 28px; + bottom: 24px; + left: 28px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 11px; + border-left: 3px solid var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-state-error-primary); + background: var(--dsw-alias-bg-module-platform); + box-shadow: 0 4px 16px rgb(0 0 0 / 18%); + font-size: 12px; + line-height: 18px; +} + +.actions, +.confirmActions { display: flex; justify-content: flex-end; gap: 8px; } +.footer button, +.error button, +.confirmation button { + min-height: 32px; + padding: 4px 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + color: var(--dsw-alias-label-primary); + background: transparent; + font: inherit; + font-size: 12px; + cursor: pointer; +} +.footer button:hover:not(:disabled), +.error button:hover, +.confirmation button:hover { background: var(--dsw-alias-interactive-bg-hover); } +.footer button:disabled { cursor: not-allowed; opacity: .45; } +.footer .primary { + border-color: transparent; + color: var(--dsw-alias-label-primary-foreground); + background: var(--dsw-alias-button-primary-fill); +} +.footer .primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); } +.saved, .dirty { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); } +.dirty { color: var(--dsw-alias-state-warn-label); } + +.confirmation { + position: absolute; + inset: 0; + z-index: 2; + display: grid; + place-items: center; + padding: 24px; + background: rgb(0 0 0 / 48%); +} +.confirmation > div { + box-sizing: border-box; + width: min(420px, 100%); + padding: 18px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + background: var(--dsw-alias-bg-module-platform); + box-shadow: 0 12px 30px rgb(0 0 0 / 28%); +} +.confirmation strong { font-size: 15px; line-height: 22px; } +.confirmation p { margin: 6px 0 18px; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; } +.confirmation .danger { color: var(--dsw-alias-state-error-primary); } + +@media (max-width: 640px) { + .overlay { padding: 0; } + .dialog { width: 100%; height: 100dvh; min-height: 0; border: 0; border-radius: 0; } + .header, .footer { padding: 12px 14px; } + .body { padding: 12px 14px; } + .editorFrame { min-height: 0; } + .footer { align-items: flex-start; flex-direction: column; } + .actions { width: 100%; } + .actions button { flex: 1; } + .error { right: 20px; bottom: 20px; left: 20px; } +} + +@media (prefers-reduced-motion: reduce) { + .loading span { animation: none; } +} diff --git a/upstream/container/environment/resources/plugins/settings-document-editor/package/package.json b/upstream/container/environment/resources/plugins/settings-document-editor/package/package.json new file mode 100644 index 0000000..6dcee35 --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-document-editor/package/package.json @@ -0,0 +1,37 @@ +{ + "name": "@dsh-docker/settings-document-editor", + "version": "1.0.0", + "type": "module", + "main": "lib/index.js", + "exports": { + ".": "./lib/index.js", + "./client": "./lib/client.bundle.js", + "./package.json": "./package.json" + }, + "dshDocker": { + "description": { + "zh": "在浏览器中查看和编辑 DSH 配置文件。", + "en": "View and edit the DSH settings document in the browser." + } + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-ui-settings" + ], + "platform": "web" + } + }, + "peerDependencies": { + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-client-connection": "*", + "@deepseek-ai/dsh-client-locale": "*", + "@deepseek-ai/dsh-api-remotes": "*", + "@deepseek-ai/dsh-client-ui-settings": "*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/upstream/container/environment/resources/plugins/settings-navigation/package/cordis.patch.json b/upstream/container/environment/resources/plugins/settings-navigation/package/cordis.patch.json new file mode 100644 index 0000000..6a00da3 --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-navigation/package/cordis.patch.json @@ -0,0 +1,10 @@ +[ + { + "insert": [ + { + "id": "dsh-docker.settings-navigation.plugin", + "name": "@dsh-docker/settings-navigation" + } + ] + } +] diff --git a/upstream/container/environment/resources/plugins/settings-navigation/package/lib/client.bundle.js b/upstream/container/environment/resources/plugins/settings-navigation/package/lib/client.bundle.js new file mode 100644 index 0000000..fe2dd21 --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-navigation/package/lib/client.bundle.js @@ -0,0 +1,267 @@ +window.__ModuleLoader__.load({ id: "@dsh-docker/settings-navigation", factory: (require) => { +var module = { exports: {} }; var exports = module.exports; +const css = Object.freeze({"backButton":"dshSettingsNavigation_backButton","backMount":"dshSettingsNavigation_backMount","content":"dshSettingsNavigation_content","header":"dshSettingsNavigation_header","nav":"dshSettingsNavigation_nav","navList":"dshSettingsNavigation_navList","navTitle":"dshSettingsNavigation_navTitle","options":"dshSettingsNavigation_options","panel":"dshSettingsNavigation_panel"}); +const styleId = "@dsh-docker/settings-navigation/style.module.css"; +if (typeof document !== 'undefined' && ![...document.querySelectorAll('style[data-plugin-css]')].some(tag => tag.dataset.pluginCss === styleId)) { + const tag = document.createElement('style'); + tag.dataset.plugin = "@dsh-docker/settings-navigation"; + tag.dataset.pluginCss = styleId; + tag.textContent = ".dshSettingsNavigation_panel {\n --dsh-settings-navigation-header-height: 54px;\n}\n\n.dshSettingsNavigation_nav,\n.dshSettingsNavigation_navList,\n.dshSettingsNavigation_content,\n.dshSettingsNavigation_options {\n min-height: 0;\n}\n\n.dshSettingsNavigation_navList {\n flex: 1;\n overflow-x: hidden;\n overflow-y: auto;\n overscroll-behavior: contain;\n scrollbar-gutter: stable;\n padding-bottom: 18px;\n}\n\n.dshSettingsNavigation_backMount {\n display: none;\n flex: none;\n}\n\n.dshSettingsNavigation_backButton {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 0;\n border: 0;\n border-radius: 50%;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n cursor: pointer;\n}\n\n.dshSettingsNavigation_backButton:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dshSettingsNavigation_backButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 2px;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] {\n display: grid;\n grid-template: var(--dsh-settings-navigation-header-height) minmax(0, 1fr) / minmax(0, 1fr);\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_nav,\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_content {\n grid-area: 1 / 1 / 3 / 2;\n width: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_nav {\n padding-right: 16px;\n padding-left: 16px;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_navTitle {\n padding-left: 0;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_navList {\n padding-right: 0;\n padding-bottom: 22px;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_content {\n pointer-events: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_header {\n position: relative;\n z-index: 1;\n pointer-events: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_header > * {\n pointer-events: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='directory'] .dshSettingsNavigation_options {\n display: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_nav {\n display: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_content {\n pointer-events: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_backMount {\n display: inline-flex;\n align-items: center;\n margin-right: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_options {\n display: block;\n}\n"; + document.head.appendChild(tag); +} +const React = require('react') +const { useCallback, useEffect, useRef, useState } = React +const { createPortal } = require('react-dom') +const { IconChevronLeftOutline14 } = require('@deepseek-ai/dsh-client-ui-primitives') + +const h = React.createElement +const COMPACT_WIDTH = 640 +const NS = 'settings.dshSettingsNavigation' + +const inject = ['slots', 'locale'] + +function directChildren(element) { + return Array.from(element?.children ?? []) +} + +function matchSettingsDialog(dialog) { + if (dialog?.getAttribute?.('role') !== 'dialog' + || dialog.getAttribute('aria-modal') !== 'true') return null + const labelledBy = dialog.getAttribute('aria-labelledby') + const title = labelledBy ? dialog.ownerDocument?.getElementById(labelledBy) : null + if (!title || !dialog.contains(title)) return null + + const [nav, content, ...extra] = directChildren(dialog) + if (extra.length !== 0 || nav?.tagName !== 'NAV' || content === undefined + || !nav.contains(title)) return null + const [navTitle, navList, ...navExtra] = directChildren(nav) + const [header, options, ...contentExtra] = directChildren(content) + if (navExtra.length !== 0 || contentExtra.length !== 0 || navTitle !== title + || navList === undefined || header === undefined || options === undefined) return null + const buttons = Array.from(navList.querySelectorAll?.('button') ?? []) + const current = buttons.find(button => button.getAttribute('aria-current') === 'true') + if (buttons.length === 0 || current === undefined || header.querySelector?.('button') === null) return null + return { dialog, nav, navTitle, navList, content, header, options, buttons, current } +} + +function findSettingsDialog(root) { + for (const dialog of root.querySelectorAll?.('[role="dialog"][aria-modal="true"][aria-labelledby]') ?? []) { + const match = matchSettingsDialog(dialog) + if (match !== null) return match + } + return null +} + +class SettingsNavigationController { + constructor({ root, ResizeObserverImpl, MutationObserverImpl, requestFrame, classes, onChange }) { + this.root = root + this.ResizeObserverImpl = ResizeObserverImpl + this.MutationObserverImpl = MutationObserverImpl + this.requestFrame = requestFrame + this.classes = classes + this.onChange = onChange + this.match = null + this.compact = false + this.modeInitialized = false + this.view = 'directory' + this.directoryScrollTop = 0 + this.backMount = null + this.resizeObserver = null + this.mutationObserver = null + this.scanQueued = false + } + + start() { + this.scan() + this.mutationObserver = new this.MutationObserverImpl(() => this.queueScan()) + this.mutationObserver.observe(this.root.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['aria-current', 'aria-labelledby'], + }) + } + + stop() { + this.mutationObserver?.disconnect() + this.mutationObserver = null + this.detach() + } + + queueScan() { + if (this.scanQueued) return + this.scanQueued = true + queueMicrotask(() => { + this.scanQueued = false + this.scan() + }) + } + + scan() { + const next = findSettingsDialog(this.root) + const sameStructure = next !== null && this.match !== null + && ['dialog', 'nav', 'navTitle', 'navList', 'content', 'header', 'options'] + .every(key => next[key] === this.match[key]) + if (sameStructure) { + this.match.current = next.current + this.match.buttons = next.buttons + return + } + this.detach() + if (next !== null) this.attach(next) + } + + attach(match) { + this.match = match + const roles = [ + ['dialog', match.dialog, this.classes.panel], + ['nav', match.nav, this.classes.nav], + ['nav-title', match.navTitle, this.classes.navTitle], + ['nav-list', match.navList, this.classes.navList], + ['content', match.content, this.classes.content], + ['header', match.header, this.classes.header], + ['options', match.options, this.classes.options], + ] + for (const [role, node, className] of roles) { + node.dataset.dshSettingsNavigationRole = role + node.classList.add(className) + } + this.backMount = this.root.createElement('span') + this.backMount.className = this.classes.backMount + this.backMount.dataset.dshSettingsNavigationRole = 'back' + match.header.prepend(this.backMount) + match.navList.addEventListener('click', this.onNavClick) + + this.view = match.buttons.indexOf(match.current) > 0 ? 'detail' : 'directory' + this.resizeObserver = new this.ResizeObserverImpl(entries => { + const width = entries.at(-1)?.contentRect?.width ?? match.dialog.getBoundingClientRect().width + this.setCompact(width < COMPACT_WIDTH) + }) + this.resizeObserver.observe(match.dialog) + this.setCompact(match.dialog.getBoundingClientRect().width < COMPACT_WIDTH) + } + + detach() { + if (this.match === null) return + this.resizeObserver?.disconnect() + this.resizeObserver = null + this.match.navList.removeEventListener('click', this.onNavClick) + const roles = [ + [this.match.dialog, this.classes.panel], + [this.match.nav, this.classes.nav], + [this.match.navTitle, this.classes.navTitle], + [this.match.navList, this.classes.navList], + [this.match.content, this.classes.content], + [this.match.header, this.classes.header], + [this.match.options, this.classes.options], + ] + for (const [node, className] of roles) { + delete node.dataset.dshSettingsNavigationRole + node.classList.remove(className) + } + delete this.match.dialog.dataset.dshSettingsNavigationMode + delete this.match.dialog.dataset.dshSettingsNavigationView + this.backMount?.remove() + this.backMount = null + this.match = null + this.compact = false + this.modeInitialized = false + this.view = 'directory' + this.directoryScrollTop = 0 + this.onChange() + } + + setCompact(compact) { + if (this.match === null) return + if (this.modeInitialized && compact && !this.compact) this.view = 'detail' + this.compact = compact + this.modeInitialized = true + this.match.dialog.dataset.dshSettingsNavigationMode = compact ? 'compact' : 'wide' + this.match.dialog.dataset.dshSettingsNavigationView = this.view + this.onChange() + } + + onNavClick = event => { + if (!this.compact || this.match === null) return + const button = event.target.closest?.('button') + if (!button || !this.match.navList.contains(button)) return + this.match.current = button + this.directoryScrollTop = this.match.navList.scrollTop + this.view = 'detail' + this.match.dialog.dataset.dshSettingsNavigationView = this.view + this.onChange() + this.requestFrame(() => this.backMount?.querySelector('button')?.focus()) + } + + back() { + if (!this.compact || this.match === null) return + this.view = 'directory' + this.match.dialog.dataset.dshSettingsNavigationView = this.view + this.onChange() + const current = this.match.current + const navList = this.match.navList + const scrollTop = this.directoryScrollTop + this.requestFrame(() => { + current?.focus({ preventScroll: true }) + this.requestFrame(() => { navList.scrollTop = scrollTop }) + }) + } + + snapshot() { + return { + active: this.match !== null, + compact: this.compact, + view: this.view, + backMount: this.backMount, + } + } +} + +function SettingsNavigationOverlay({ t }) { + const [, render] = useState(0) + const forceRender = useCallback(() => render(value => value + 1), []) + const controllerRef = useRef(null) + + useEffect(() => { + const controller = new SettingsNavigationController({ + root: document, + ResizeObserverImpl: ResizeObserver, + MutationObserverImpl: MutationObserver, + requestFrame: callback => requestAnimationFrame(callback), + classes: css, + onChange: forceRender, + }) + controllerRef.current = controller + controller.start() + return () => { + controller.stop() + controllerRef.current = null + } + }, [forceRender]) + + const state = controllerRef.current?.snapshot() + if (!state?.active || !state.compact || state.view !== 'detail' || state.backMount === null) return null + return createPortal(h('button', { + type: 'button', + className: css.backButton, + 'aria-label': t('back'), + onClick: () => controllerRef.current?.back(), + }, h(IconChevronLeftOutline14, { size: 18 })), state.backMount) +} + +function apply(ctx) { + ctx.effect(() => ctx.locale.register(NS, { + zh: { back: '返回设置目录' }, + en: { back: 'Back to Settings directory' }, + }), 'dsh-settings-navigation: locale') + + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: 'dsh-settings-navigation', + order: 70, + locale: NS, + }, SettingsNavigationOverlay)) +} +exports.inject = inject; +exports.apply = apply; +return module.exports; } }); diff --git a/upstream/container/environment/resources/plugins/settings-navigation/package/lib/client.js b/upstream/container/environment/resources/plugins/settings-navigation/package/lib/client.js new file mode 100644 index 0000000..dd89d58 --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-navigation/package/lib/client.js @@ -0,0 +1,253 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import css from './style.module.css' +const { createPortal } = require('react-dom') +const { IconChevronLeftOutline14 } = require('@deepseek-ai/dsh-client-ui-primitives') + +const h = React.createElement +const COMPACT_WIDTH = 640 +const NS = 'settings.dshSettingsNavigation' + +export const inject = ['slots', 'locale'] + +function directChildren(element) { + return Array.from(element?.children ?? []) +} + +function matchSettingsDialog(dialog) { + if (dialog?.getAttribute?.('role') !== 'dialog' + || dialog.getAttribute('aria-modal') !== 'true') return null + const labelledBy = dialog.getAttribute('aria-labelledby') + const title = labelledBy ? dialog.ownerDocument?.getElementById(labelledBy) : null + if (!title || !dialog.contains(title)) return null + + const [nav, content, ...extra] = directChildren(dialog) + if (extra.length !== 0 || nav?.tagName !== 'NAV' || content === undefined + || !nav.contains(title)) return null + const [navTitle, navList, ...navExtra] = directChildren(nav) + const [header, options, ...contentExtra] = directChildren(content) + if (navExtra.length !== 0 || contentExtra.length !== 0 || navTitle !== title + || navList === undefined || header === undefined || options === undefined) return null + const buttons = Array.from(navList.querySelectorAll?.('button') ?? []) + const current = buttons.find(button => button.getAttribute('aria-current') === 'true') + if (buttons.length === 0 || current === undefined || header.querySelector?.('button') === null) return null + return { dialog, nav, navTitle, navList, content, header, options, buttons, current } +} + +function findSettingsDialog(root) { + for (const dialog of root.querySelectorAll?.('[role="dialog"][aria-modal="true"][aria-labelledby]') ?? []) { + const match = matchSettingsDialog(dialog) + if (match !== null) return match + } + return null +} + +class SettingsNavigationController { + constructor({ root, ResizeObserverImpl, MutationObserverImpl, requestFrame, classes, onChange }) { + this.root = root + this.ResizeObserverImpl = ResizeObserverImpl + this.MutationObserverImpl = MutationObserverImpl + this.requestFrame = requestFrame + this.classes = classes + this.onChange = onChange + this.match = null + this.compact = false + this.modeInitialized = false + this.view = 'directory' + this.directoryScrollTop = 0 + this.backMount = null + this.resizeObserver = null + this.mutationObserver = null + this.scanQueued = false + } + + start() { + this.scan() + this.mutationObserver = new this.MutationObserverImpl(() => this.queueScan()) + this.mutationObserver.observe(this.root.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['aria-current', 'aria-labelledby'], + }) + } + + stop() { + this.mutationObserver?.disconnect() + this.mutationObserver = null + this.detach() + } + + queueScan() { + if (this.scanQueued) return + this.scanQueued = true + queueMicrotask(() => { + this.scanQueued = false + this.scan() + }) + } + + scan() { + const next = findSettingsDialog(this.root) + const sameStructure = next !== null && this.match !== null + && ['dialog', 'nav', 'navTitle', 'navList', 'content', 'header', 'options'] + .every(key => next[key] === this.match[key]) + if (sameStructure) { + this.match.current = next.current + this.match.buttons = next.buttons + return + } + this.detach() + if (next !== null) this.attach(next) + } + + attach(match) { + this.match = match + const roles = [ + ['dialog', match.dialog, this.classes.panel], + ['nav', match.nav, this.classes.nav], + ['nav-title', match.navTitle, this.classes.navTitle], + ['nav-list', match.navList, this.classes.navList], + ['content', match.content, this.classes.content], + ['header', match.header, this.classes.header], + ['options', match.options, this.classes.options], + ] + for (const [role, node, className] of roles) { + node.dataset.dshSettingsNavigationRole = role + node.classList.add(className) + } + this.backMount = this.root.createElement('span') + this.backMount.className = this.classes.backMount + this.backMount.dataset.dshSettingsNavigationRole = 'back' + match.header.prepend(this.backMount) + match.navList.addEventListener('click', this.onNavClick) + + this.view = match.buttons.indexOf(match.current) > 0 ? 'detail' : 'directory' + this.resizeObserver = new this.ResizeObserverImpl(entries => { + const width = entries.at(-1)?.contentRect?.width ?? match.dialog.getBoundingClientRect().width + this.setCompact(width < COMPACT_WIDTH) + }) + this.resizeObserver.observe(match.dialog) + this.setCompact(match.dialog.getBoundingClientRect().width < COMPACT_WIDTH) + } + + detach() { + if (this.match === null) return + this.resizeObserver?.disconnect() + this.resizeObserver = null + this.match.navList.removeEventListener('click', this.onNavClick) + const roles = [ + [this.match.dialog, this.classes.panel], + [this.match.nav, this.classes.nav], + [this.match.navTitle, this.classes.navTitle], + [this.match.navList, this.classes.navList], + [this.match.content, this.classes.content], + [this.match.header, this.classes.header], + [this.match.options, this.classes.options], + ] + for (const [node, className] of roles) { + delete node.dataset.dshSettingsNavigationRole + node.classList.remove(className) + } + delete this.match.dialog.dataset.dshSettingsNavigationMode + delete this.match.dialog.dataset.dshSettingsNavigationView + this.backMount?.remove() + this.backMount = null + this.match = null + this.compact = false + this.modeInitialized = false + this.view = 'directory' + this.directoryScrollTop = 0 + this.onChange() + } + + setCompact(compact) { + if (this.match === null) return + if (this.modeInitialized && compact && !this.compact) this.view = 'detail' + this.compact = compact + this.modeInitialized = true + this.match.dialog.dataset.dshSettingsNavigationMode = compact ? 'compact' : 'wide' + this.match.dialog.dataset.dshSettingsNavigationView = this.view + this.onChange() + } + + onNavClick = event => { + if (!this.compact || this.match === null) return + const button = event.target.closest?.('button') + if (!button || !this.match.navList.contains(button)) return + this.match.current = button + this.directoryScrollTop = this.match.navList.scrollTop + this.view = 'detail' + this.match.dialog.dataset.dshSettingsNavigationView = this.view + this.onChange() + this.requestFrame(() => this.backMount?.querySelector('button')?.focus()) + } + + back() { + if (!this.compact || this.match === null) return + this.view = 'directory' + this.match.dialog.dataset.dshSettingsNavigationView = this.view + this.onChange() + const current = this.match.current + const navList = this.match.navList + const scrollTop = this.directoryScrollTop + this.requestFrame(() => { + current?.focus({ preventScroll: true }) + this.requestFrame(() => { navList.scrollTop = scrollTop }) + }) + } + + snapshot() { + return { + active: this.match !== null, + compact: this.compact, + view: this.view, + backMount: this.backMount, + } + } +} + +function SettingsNavigationOverlay({ t }) { + const [, render] = useState(0) + const forceRender = useCallback(() => render(value => value + 1), []) + const controllerRef = useRef(null) + + useEffect(() => { + const controller = new SettingsNavigationController({ + root: document, + ResizeObserverImpl: ResizeObserver, + MutationObserverImpl: MutationObserver, + requestFrame: callback => requestAnimationFrame(callback), + classes: css, + onChange: forceRender, + }) + controllerRef.current = controller + controller.start() + return () => { + controller.stop() + controllerRef.current = null + } + }, [forceRender]) + + const state = controllerRef.current?.snapshot() + if (!state?.active || !state.compact || state.view !== 'detail' || state.backMount === null) return null + return createPortal(h('button', { + type: 'button', + className: css.backButton, + 'aria-label': t('back'), + onClick: () => controllerRef.current?.back(), + }, h(IconChevronLeftOutline14, { size: 18 })), state.backMount) +} + +export function apply(ctx) { + ctx.effect(() => ctx.locale.register(NS, { + zh: { back: '返回设置目录' }, + en: { back: 'Back to Settings directory' }, + }), 'dsh-settings-navigation: locale') + + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: 'dsh-settings-navigation', + order: 70, + locale: NS, + }, SettingsNavigationOverlay)) +} diff --git a/upstream/container/environment/resources/plugins/settings-navigation/package/lib/index.js b/upstream/container/environment/resources/plugins/settings-navigation/package/lib/index.js new file mode 100644 index 0000000..4c9409d --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-navigation/package/lib/index.js @@ -0,0 +1,3 @@ +export const name = '@dsh-docker/settings-navigation' + +export function apply() {} diff --git a/upstream/container/environment/resources/plugins/settings-navigation/package/lib/style.module.css b/upstream/container/environment/resources/plugins/settings-navigation/package/lib/style.module.css new file mode 100644 index 0000000..723b8bc --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-navigation/package/lib/style.module.css @@ -0,0 +1,108 @@ +.panel { + --dsh-settings-navigation-header-height: 54px; +} + +.nav, +.navList, +.content, +.options { + min-height: 0; +} + +.navList { + flex: 1; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + padding-bottom: 18px; +} + +.backMount { + display: none; + flex: none; +} + +.backButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--dsw-alias-label-primary); + background: transparent; + cursor: pointer; +} + +.backButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.backButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 2px; +} + +.panel[data-dsh-settings-navigation-mode='compact'] { + display: grid; + grid-template: var(--dsh-settings-navigation-header-height) minmax(0, 1fr) / minmax(0, 1fr); +} + +.panel[data-dsh-settings-navigation-mode='compact'] .nav, +.panel[data-dsh-settings-navigation-mode='compact'] .content { + grid-area: 1 / 1 / 3 / 2; + width: auto; +} + +.panel[data-dsh-settings-navigation-mode='compact'] .nav { + padding-right: 16px; + padding-left: 16px; +} + +.panel[data-dsh-settings-navigation-mode='compact'] .navTitle { + padding-left: 0; +} + +.panel[data-dsh-settings-navigation-mode='compact'] .navList { + padding-right: 0; + padding-bottom: 22px; +} + +.panel[data-dsh-settings-navigation-mode='compact'] .content { + pointer-events: none; +} + +.panel[data-dsh-settings-navigation-mode='compact'] .header { + position: relative; + z-index: 1; + pointer-events: none; +} + +.panel[data-dsh-settings-navigation-mode='compact'] .header > * { + pointer-events: auto; +} + +.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='directory'] .options { + display: none; +} + +.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .nav { + display: none; +} + +.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .content { + pointer-events: auto; +} + +.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .backMount { + display: inline-flex; + align-items: center; + margin-right: auto; +} + +.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .options { + display: block; +} diff --git a/upstream/container/environment/resources/plugins/settings-navigation/package/package.json b/upstream/container/environment/resources/plugins/settings-navigation/package/package.json new file mode 100644 index 0000000..3313a54 --- /dev/null +++ b/upstream/container/environment/resources/plugins/settings-navigation/package/package.json @@ -0,0 +1,32 @@ +{ + "name": "@dsh-docker/settings-navigation", + "version": "1.0.0", + "type": "module", + "main": "lib/index.js", + "exports": { + ".": "./lib/index.js", + "./client": "./lib/client.bundle.js", + "./package.json": "./package.json" + }, + "dshDocker": { + "description": { + "zh": "优化 DSH 设置目录滚动和窄屏分级导航。", + "en": "Improve DSH Settings directory scrolling and compact navigation." + } + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } + }, + "peerDependencies": { + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-client-locale": "*", + "@deepseek-ai/dsh-client-ui-primitives": "*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/upstream/container/environment/resources/skills/catalog.json b/upstream/container/environment/resources/skills/catalog.json new file mode 100644 index 0000000..fcd7a19 --- /dev/null +++ b/upstream/container/environment/resources/skills/catalog.json @@ -0,0 +1,13 @@ +{ + "schema": 1, + "skills": [ + { + "id": "dsh-docker-operations", + "source": "dsh-docker-operations", + "description": { + "zh": "在 DSH Docker 环境中使用受支持的命令完成开发、插件、维护、更新、恢复与诊断。", + "en": "Use supported commands for development, plugins, maintenance, updates, recovery, and diagnostics in the DSH Docker Environment." + } + } + ] +} diff --git a/upstream/container/environment/resources/skills/dsh-docker-operations/SKILL.md b/upstream/container/environment/resources/skills/dsh-docker-operations/SKILL.md new file mode 100644 index 0000000..02b44ae --- /dev/null +++ b/upstream/container/environment/resources/skills/dsh-docker-operations/SKILL.md @@ -0,0 +1,31 @@ +--- +name: dsh-docker-operations +description: Use for operating inside the official DSH Docker Environment, including files, development tools, plugins, skills, lifecycle, logs, updates, recovery, networking, permissions, and container-specific diagnostics. +user-invocable: true +disable-model-invocation: false +--- + +# DSH Docker Operations + +Use the platform's supported interfaces before inspecting implementation details. Match the user's language in every response; these instructions being English does not change the response language. + +## Operating rules + +1. Preserve the user's requested scope and authorization. Read-only diagnosis does not authorize updates, restarts, plugin mutations, or destructive file operations. +2. Start with `dsh`, `dsh-platform`, their help output, and the DSH Management Console. Do not rediscover public behavior by reading Stage-0, Bootstrap, sockets, process environments, or runtime source. +3. In routine work, never override `HOME`, `USER`, `LOGNAME`, XDG variables, npm configuration, pnpm configuration, or package-manager cache paths. The container already provides the correct `node` environment. +4. Do not read credentials from process environments, files, command histories, or service internals. Never print secrets. +5. Do not mutate platform state, Store assets, runtime views, keyrings, receipts, or Unix sockets. Use only the documented recovery commands for exceptional operations. +6. Inspect `/opt/dsh-platform` or `/run/dsh-platform` internals only when the user explicitly asks to develop or debug the platform implementation itself. Ordinary DSH, plugin, update, and container troubleshooting does not meet that condition. + +## Load the relevant guide + +- Environment identity, paths, persistence, Root, and sudo: [references/environment-and-permissions.md](references/environment-and-permissions.md) +- Workspaces, files, development tools, and package managers: [references/files-and-development.md](references/files-and-development.md) +- DSH CLI, profiles, user plugins, System Plugins, and skills: [references/dsh-and-extensions.md](references/dsh-and-extensions.md) +- Status, health, logs, restart, and Management Console: [references/lifecycle-and-logs.md](references/lifecycle-and-logs.md) +- Stable/experimental updates, rollback, reset, and recovery: [references/updates-and-recovery.md](references/updates-and-recovery.md) +- Gateway, ports, reverse proxies, authentication, and remote access: [references/networking-and-auth.md](references/networking-and-auth.md) +- Ordered troubleshooting and protected implementation boundaries: [references/diagnostics-and-boundaries.md](references/diagnostics-and-boundaries.md) + +Read only the references needed for the current task. When a referenced command differs from the installed version, prefer that command's current `--help` output and report the difference. diff --git a/upstream/container/environment/resources/skills/dsh-docker-operations/references/diagnostics-and-boundaries.md b/upstream/container/environment/resources/skills/dsh-docker-operations/references/diagnostics-and-boundaries.md new file mode 100644 index 0000000..8271bd7 --- /dev/null +++ b/upstream/container/environment/resources/skills/dsh-docker-operations/references/diagnostics-and-boundaries.md @@ -0,0 +1,44 @@ +# Diagnostics and protected boundaries + +## Ordered diagnosis + +Use the least invasive source of truth that can answer the question: + +1. Run the relevant public command's `--help` when syntax is uncertain. +2. Inspect `dsh-platform status` and the current operation/task state. +3. Read focused structured logs through `dsh-platform logs` or a management UI. +4. Check DSH/Profile state with the public `dsh` CLI. +5. Use the standalone Management Console for recovery actions, Root file inspection, or a terminal explicitly requested by the user. +6. Inspect platform implementation files only when the user explicitly asks to debug or develop dsh-docker itself. + +For a restart failure immediately after plugin removal, first look for `cannot resolve profile bundle` in the structured DSH Runtime logs. A current managed startup repairs legacy orphaned Bundle references automatically; verify the subsequent lifecycle terminal state before escalating. Do not bypass that repair by editing the Web Profile manifest or lockfile directly. + +For a browser plugin-load failure around a restart, correlate the current `dshLifecycle` task with `browser.plugin-load.failed` and the matching recovery event. Apply this diagnosis equally to official DSH plugins, bundled System Plugins, and User Plugins. `browser.plugin-load.recovery.completed` identifies a recovered transition race. Recovery attempt three opens Gateway's terminal plugin-failure page and records `browser.plugin-load.recovery.failed`; a failure without recovery eligibility is also persistent. Diagnose either case from the named plugin and DSH Runtime logs. Do not create a reload loop, match visible page text, or assume every plugin-load failure is transient. + +For an update check failure, distinguish the remote request from the cached target: a later failed check must retain the last verified Stable or upstream result and report `remote check failed`; only a first failed check has no verified result to show. Check the public update status and the structured `update.check.failed` event before changing proxy settings. When an external proxy is configured, use the Proxy tab's asynchronous test and its task result; never read `/data/platform/state/proxy`, inspect the Outbound Proxy environment, or call its control socket directly. A proxy test failure must not replace the active configuration. + +Stop once the cause is established. Do not enumerate Seed trees, runtime packages, sockets, process environments, or supervisor source as a substitute for trying the public operation. + +## Protected trust boundary + +Stage-0 is the only writer and verifier for Recovery-signed keyrings, Release signatures, monotonic ledgers, trusted objects, and receipts. Bootstrap, Updater, DSH, plugins, and agents consume its verified results. + +Never: + +- edit or replace the Recovery Root public key; +- add a Release key or modify keyring state; +- call the Stage-0 Trust socket directly; +- import an object by supplying an arbitrary expected hash; +- forge, copy, reactivate, or rewrite a receipt; +- lower keyring generation or target sequence; +- mutate current/previous deployment or Bootstrap slots by hand. + +Use `dsh-platform trust status` for read-only trust diagnostics. Trust reset is a last-resort Root TTY operation, not a Web/API repair method. + +## Failure reporting + +Distinguish among DSH startup, update/restart, automatic rollback, startup failure, prolonged unavailability, Gateway authorization, and an unclassified proxy error. Capture the public status plus complete relevant structured error. Do not label a warning or ordinary stderr line as a failure without its structured level/result, and do not report an operation as successful until its terminal state and postcondition are verified. + +Repeated authentication, 2FA, Management-origin probe, and upstream failures are rate-limited by failure class. Treat `suppressedCount` on the next retained event as part of the incident total; do not infer the attempt count from the number of visible warning rows alone. + +Browser-session activity is reported at most once per session every five minutes. Use `access.session.expired` and `access.session.invalidated` reason codes to distinguish absolute or idle expiry, credential or Management-entry changes, and source DSH-session loss. Root authentication recovery is duplicated to the audit stream with only the operation, result, and session-revocation counts; credential values, reset keys, one-time codes, Cookies, and CSRF tokens must never appear. diff --git a/upstream/container/environment/resources/skills/dsh-docker-operations/references/dsh-and-extensions.md b/upstream/container/environment/resources/skills/dsh-docker-operations/references/dsh-and-extensions.md new file mode 100644 index 0000000..d2267b0 --- /dev/null +++ b/upstream/container/environment/resources/skills/dsh-docker-operations/references/dsh-and-extensions.md @@ -0,0 +1,47 @@ +# DSH, profiles, plugins, and skills + +## DSH CLI + +Use the `dsh` shim. It dynamically executes the current verified Runtime and remains correct across updates and rollbacks. Consult `dsh --help` and the relevant subcommand help instead of calling files below `/run/dsh-platform/views/runtime`. + +The Web Profile is stored below `$DSH_HOME/profiles/web`. Treat its package manifest, lockfile, and bundle declarations as a coordinated profile owned by the DSH CLI; do not hand-edit them for ordinary plugin operations. + +## User plugins + +Install a published user plugin with: + +```sh +dsh plugin --profile web add +``` + +Remove it with: + +```sh +dsh plugin --profile web remove +``` + +Run these commands with the inherited `node` environment. The public container `dsh` shim also drops a Root caller to the managed `node` identity, but that protection is not a reason to invoke profile-internal pnpm commands as Root. Do not redirect pnpm configuration or caches. Prefer the package declared by the plugin project; clone/build source only when the user explicitly requests source installation or no published package exists. + +The platform pins the Web Profile store below `$DSH_HOME/.pnpm-store` and transactionally migrates a legacy Profile whose pnpm metadata points at an older workspace or home. If a package operation reports `ERR_PNPM_UNEXPECTED_STORE`, restart DSH once through the public lifecycle command and inspect the DSH Runtime log for the migration result. Do not delete `$DSH_HOME`, edit `node_modules/.modules.yaml`, or create/chmod the old store path to suppress the check. + +Some plugin changes require DSH to restart. Use: + +```sh +dsh-platform restart +``` + +When this command is run by an Agent inside a DSH session, submit the asynchronous task and report its task ID. Do not add `--wait`: stopping the current DSH process interrupts the tool transport before a durable result can be returned. Use `--wait` only from an external operator terminal, the standalone Management Console terminal, or external automation. + +The standalone Management Console can disable or uninstall a faulty Web Profile Bundle while DSH is down. It deliberately manages only DSH bundle plugins, not arbitrary dependencies or hand-written Cordis patch entries. After Apply reports that the Profile change was saved, treat that mutation as committed even if DSH remains unavailable or its restart fails; inspect lifecycle logs and correct another faulty plugin or retry startup instead of repeating the saved action. + +If a plugin tool reports a successful removal but DSH then fails with `cannot resolve profile bundle`, inspect public DSH Runtime logs and restart once through `dsh-platform restart`. An authorized Web startup atomically removes only an orphaned Bundle which is no longer a declared dependency and cannot be resolved from the Profile or DSH installation. Do not hand-edit `package.json`, the lockfile, or `dsh.profile.bundles`; if the managed repair does not restore readiness, use User Plugin recovery in the standalone Management Console. + +## System Plugins + +System Plugins are signed resources supplied by the current DSH Docker Environment. Manage them through **System Plugins** in Platform Management or the standalone Management Console. The standalone console remains usable while DSH is down. Its selections are drafts until Apply; once Apply saves a selection, the change remains committed for the next DSH start even if DSH is already unavailable or the activation restart fails. Diagnose that lifecycle failure separately instead of treating the saved plugin action as unapplied. Do not install, delete, or impersonate `@dsh-docker/*` packages through the normal user-plugin CLI. + +## Skills + +User and project skills belong to the official DSH roots such as `$DSH_HOME/skills`, `.dsh/skills`, or `.agents/skills`. System Skills are signed platform resources and are managed through **System Skills** in the two management interfaces. + +Installing, uninstalling, enabling, or disabling a System Skill hot-refreshes the DSH skill catalog. It does not require a DSH restart, and the standalone Management Console can still apply the selection while DSH is unavailable. User Skill changes in that console are likewise independent of DSH lifecycle state. A project or user skill with the same name may override the bundled System Skill according to DSH's native precedence; platform management does not alter or remove that override. diff --git a/upstream/container/environment/resources/skills/dsh-docker-operations/references/environment-and-permissions.md b/upstream/container/environment/resources/skills/dsh-docker-operations/references/environment-and-permissions.md new file mode 100644 index 0000000..d0d4cd6 --- /dev/null +++ b/upstream/container/environment/resources/skills/dsh-docker-operations/references/environment-and-permissions.md @@ -0,0 +1,35 @@ +# Environment and permissions + +## Process identity + +DSH and its Agent normally run as `node` with: + +- `HOME=/home/node` +- `USER=node` +- `LOGNAME=node` +- XDG configuration, data, and cache below `/home/node` +- `DSH_HOME`, normally `/data/dsh` +- the configured default workspace, normally `/workspace` + +Do not replace these values to work around a package-manager error. First inspect ownership of the target profile or workspace and use the existing environment. A reference to `/root/.config`, `/root/.cache`, or `/root/.local` from a normal DSH/plugin operation is a defect or inherited override, not a directory to create or chmod around. + +The standalone Management Console terminal and file manager run through the platform's Root maintenance broker. Its terminal intentionally uses UID 0 and `HOME=/root`. Do not generalize that identity to DSH or Agent commands. + +## Persistent and ephemeral paths + +- `$DSH_HOME` (`/data/dsh` by default): user configuration, profiles, sessions, user plugins, and DSH data. +- `$DSH_PLATFORM_DATA` (`/data/platform` by default): platform state, trusted/managed assets, cache, snapshots, and logs. +- `$DSH_DEFAULT_WORKSPACE` (`/workspace` by default): initial user workspace; it is not a sandbox. +- `/run/dsh-platform`: ephemeral runtime views and local sockets rebuilt at container start. +- `/opt/dsh-platform`: immutable image implementation and Seed. +- `/home/node`: normal user home and package-manager/XDG state. + +Use the configured environment variables instead of hard-coding their default paths when writing scripts intended to survive custom deployments. + +## Root and sudo + +When `DSH_SUDO_ENABLED=true`, the DSH/Agent process can use passwordless `sudo` for commands that genuinely need operating-system authority. Use it narrowly for system package installation or protected host-mounted files. Keep ordinary workspace, DSH profile, npm, and pnpm operations as `node` so new files remain usable by DSH. + +When sudo is disabled, do not assume a failing command may be rerun as Root. Explain the required permission or use the authenticated Management Console when the user explicitly asks for container maintenance. + +Never combine broad Root operations with guessed paths, unresolved variables, recursive deletion, the Docker socket, or unrelated host mounts. diff --git a/upstream/container/environment/resources/skills/dsh-docker-operations/references/files-and-development.md b/upstream/container/environment/resources/skills/dsh-docker-operations/references/files-and-development.md new file mode 100644 index 0000000..4f06bf7 --- /dev/null +++ b/upstream/container/environment/resources/skills/dsh-docker-operations/references/files-and-development.md @@ -0,0 +1,31 @@ +# Files and development tools + +## Work in the selected workspace + +Start from `$DSH_DEFAULT_WORKSPACE` when the user has not named a directory. Confirm the current directory before installing dependencies, initializing a repository, or running destructive commands. Preserve existing files and unrelated changes. + +Use ordinary shell and editor tools for accessible workspace and DSH user data. Use the standalone Management Console file manager for browser-based upload/download, archive operations, ownership/mode changes, or Root-only repair. It is available independently of the DSH Runtime. + +Do not edit these managed paths during routine work: + +- `$DSH_PLATFORM_DATA/state` +- `$DSH_PLATFORM_DATA/store` +- `/run/dsh-platform/views` +- `/opt/dsh-platform` + +Cache directories may be cleared only when the user requests it and the exact cache target is known. Never delete `$DSH_HOME` to solve a platform or image upgrade problem. + +## Package managers and toolchains + +Run language package managers from the intended project or DSH profile directory under the inherited environment. Do not set XDG/npm/pnpm variables to the workspace, `/root`, or a temporary directory merely to make one command pass. + +For Node development: + +- use the installed Node/npm/pnpm commands and their project lockfile; +- do not use global installs when a project-local dependency or `npx`/`pnpm exec` is appropriate; +- keep user caches and configuration under `/home/node` through the inherited environment; +- use `sudo` only for explicit operating-system or global-tool installation requested by the user. + +For other development environments, use the same rule: normal user tooling belongs to `/home/node` and the workspace, while system packages may require narrowly scoped sudo. Do not make a tool inherit Root's home just because its installer was invoked through sudo. + +Before downloading or executing a third-party installer, verify the requested source and explain any material external side effect. Prefer the project's documented package manager over cloning and building source unless the user explicitly wants a source installation. diff --git a/upstream/container/environment/resources/skills/dsh-docker-operations/references/lifecycle-and-logs.md b/upstream/container/environment/resources/skills/dsh-docker-operations/references/lifecycle-and-logs.md new file mode 100644 index 0000000..5478c8e --- /dev/null +++ b/upstream/container/environment/resources/skills/dsh-docker-operations/references/lifecycle-and-logs.md @@ -0,0 +1,59 @@ +# Lifecycle, health, and logs + +## Public status and lifecycle + +Start diagnosis with: + +```sh +dsh-platform status +``` + +Restart only DSH, while keeping Gateway and Management available, with: + +```sh +dsh-platform restart +``` + +For an Agent running inside the current DSH session, `restart` must remain asynchronous: report the returned task ID and let the browser enter the lifecycle holding page. Never run `restart --wait` or `stop --wait` from that session because DSH shutdown interrupts the tool transport. `--wait` is reserved for an external operator terminal, the standalone Management Console terminal, and external automation. + +The standalone Management Console and CLI also expose explicit lifecycle operations: + +```sh +dsh-platform start +dsh-platform stop +dsh-platform restart +``` + +An explicit stop lasts only for the current container lifetime. An unexpected DSH exit is recovered at most three times; check `dshLifecycle` and `recoveryMode` rather than repeatedly sending restart commands. + +The container admits only the Bootstrap-supervised Web Profile. Do not launch a replacement with `dsh web`: when DSH is stopped that command delegates to the public start operation, and otherwise it reports the managed state without creating a second instance. An unregistered first `SIGTERM` is converted into a public asynchronous restart, but this compatibility behavior is not a reason to signal the process directly. + +Do not send signals to DSH, kill its PID, invoke Bootstrap sockets, or restart the whole container unless the user specifically requested that broader action. During a registered lifecycle operation, browser navigation enters the localized holding page and returns to the original same-origin path only after the DSH HTTP upstream responds and the platform lifecycle has finished starting, restarting, recovering, or switching. This prevents a listening but not yet plugin-ready DSH instance from being shown early. API and WebSocket requests receive `503` until that combined readiness succeeds. + +Do not diagnose these classified holding responses as Gateway outages. An explicit start, stop, restart, switch, or recovery does not emit `gateway.upstream.failed` or `gateway.upstream.recovered`; those records are reserved for an unclassified upstream outage and its recovery. + +Plugin bundle requests are held while a registered lifecycle transition makes DSH temporarily unavailable. The same guard covers official DSH plugins, bundled System Plugins, and User Plugins. A failed bundle request or structured dynamic-import error may enter the holding page twice for the same running Deployment. A third structured failure opens Gateway's terminal plugin-failure page and links to the standalone Management Console; it never starts an unbounded reload loop. Detection uses the plugin request or module error URL, not visible page text. Do not advise repeated manual refreshes before checking lifecycle state, recovery-attempt fields, and browser recovery events in platform logs. + +Docker health probes Gateway control-plane health at `127.0.0.1:3080/_dsh_gateway/health`. It is independent of DSH readiness, so an intentional DSH stop does not make Docker report the container as unhealthy while the standalone Management Console remains available. + +## Logs + +Inside the container, query structured platform logs with: + +```sh +dsh-platform logs --limit 500 +dsh-platform logs --source dsh-runtime --limit 500 +dsh-platform logs --since --limit 500 +``` + +The standalone Management Console and Platform Management plugin provide filtering, expansion of complete structured entries, refresh, live following, and JSONL export. From the Docker host, `docker logs ` includes platform and DSH output. + +When reporting a failure, include the operation/task ID, source, timestamp, complete error, and the smallest relevant surrounding entries. Do not include credentials, terminal contents, or unrelated user data. + +For a browser plugin-load failure, correlate `browser.plugin-load.failed` with `browser.plugin-load.recovery.started`, `browser.plugin-load.recovery.completed`, or `browser.plugin-load.recovery.failed`. A completed recovery confirms a transient lifecycle race. Attempt three and the terminal Gateway page identify a persistent failure; a failure without recovery eligibility also requires investigation of the named plugin bundle and DSH Runtime logs. + +Logs rotate automatically according to the platform size and retention settings. Clearing the UI display is a local display cutoff and does not delete the persisted platform log files. + +## Management surfaces + +The standalone DSH Management Console is served at `/_dsh_platform/console/` and stays available when DSH is starting, restarting, recovering, or down. Platform Management inside DSH exposes the routine subset appropriate while DSH is available. Prefer the standalone console for recovery, Root terminal/file access, user-plugin recovery, and System Skill reinstallation. diff --git a/upstream/container/environment/resources/skills/dsh-docker-operations/references/networking-and-auth.md b/upstream/container/environment/resources/skills/dsh-docker-operations/references/networking-and-auth.md new file mode 100644 index 0000000..cfb67d1 --- /dev/null +++ b/upstream/container/environment/resources/skills/dsh-docker-operations/references/networking-and-auth.md @@ -0,0 +1,63 @@ +# Networking and authentication + +## Ports and proxying + +- Port `3080` is the Gateway and the only normal published service. +- Port `3079` is the loopback-only DSH upstream inside the container; do not publish or access it remotely. +- The standalone Management Console is below `/_dsh_platform/console/` on the Gateway in compatibility mode. Separate-origin mode uses the container's dedicated Management entry without a DSH upstream. + +For remote access, configure `DSH_TRUSTED_HOSTS` for accepted external Host values, initialize the local administrator in the browser, and terminate HTTPS at a trusted reverse proxy or equivalent network boundary. Never expose port `3080` or the standalone Management Console before authentication: its terminal and file manager operate with Root authority. The reverse proxy must preserve the original `Host` and normal WebSocket upgrade headers. Optional strong isolation publishes the dedicated Management entry without a DSH upstream; for a public separate origin configure its exact public URL, and for a local separate origin configure the host port mapped to the container entry (default `3081`, but the host port is user-selectable). Do not assume either separate entry is exposed unless the operator configured it. + +Do not weaken Host/Origin checks to work around a reverse-proxy error. Diagnose the forwarded Host, scheme, Origin, WebSocket upgrade, and authentication headers first. + +## Managed outbound proxy + +Configure an existing HTTP or SOCKS5 proxy through the **Proxy** tab in the DSH Management Console or Platform Management. Do not edit `/data/platform/state/proxy`, read the Outbound Proxy process environment, or call `/run/dsh-platform/outbound-proxy.sock` directly. Proxy credentials are write-only and must never be recovered from files or logs. + +The UI can route updates, platform components, DSH core/plugins, Agent network operations, the Management container terminal, and model Providers independently. A remote Provider identified through DSH `llm/stream` uses one switch: off selects direct access and on selects its dedicated proxy route. A future Provider that cannot carry stable identity is marked with a read-only **Follow DSH** label; its switch selects direct access when off and the shared DSH policy when on. Local Providers remain direct. Commands launched outside the container are not classified by the platform. + +For Agent-issued `curl`, Git, npm, pnpm, and similar commands, preserve the injected `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, and optional `ALL_PROXY` environment. Do not replace these with the external proxy URL or expose its credentials. Tool support for `ALL_PROXY` varies; inspect the tool's current documentation or behavior before relying on it. + +Use `.example.com`, not `*.example.com`, for a domain suffix. Platform-required loopback rules cannot be removed. If the proxy runs on the Docker host, obtain the current bridge gateway with `ip route`; container `127.0.0.1` is not the host. The host proxy must listen on the bridge address, with appropriate firewall restrictions. + +Use the built-in asynchronous proxy test before saving uncertain settings. A failed test does not replace the active configuration. When diagnosing update checks, distinguish a later remote failure that retains the last verified result from a first check that has never succeeded. + +## Authentication + +Access Manager owns the single local administrator account. A fresh installation stays in an initialization page until the browser sets a username and main password. DSH login creates a DSH Session; the standalone Management Console consumes a one-time handoff and creates a separate Management Session that remains valid only while its source DSH Session is active. Direct Management access always completes the main DSH login first, including on an isolated Management Origin. An optional Management console password is a second layer. DSH logout or session expiry also invalidates linked Management sessions. During a classified DSH outage, top-level navigation may show a generic Gateway recovery page before login so the Management entry remains reachable; it does not grant a session or expose failure details. Do not look for Gateway Basic Auth, `DSH_PROXY_PASSWORD`, `DSH_PLATFORM_PASSWORD`, or a temporary login key: those legacy login paths are removed. + +Browser authentication forms only transport values; the Access Manager performs authentication and credential decisions. Authentication Settings changes the username without a current password, while main-password and Management-console-password changes require the current main password. Resetting or disabling the Management console password does not require the old Management console password and revokes existing Management Sessions. + +After five consecutive failures from one browser source, Access Manager imposes a 30-second retry wait and doubles later waits up to 15 minutes. Each source has fixed limits of 12 failures per hour and 24 per 24 hours; the whole instance has wider flood limits of 20 per minute, 60 per hour, and 120 per 24 hours. Treat `AUTHENTICATION_RETRY_REQUIRED` and `AUTHENTICATION_RATE_LIMITED` as backend decisions; do not retry around them or implement credential admission in the browser. + +These `access` recovery, credential, session, and retry-clear commands require the user's explicit authorization in addition to Root access. Run them only from an interactive container TTY: + +```sh +dsh-platform access status +dsh-platform access reset +dsh-platform access set-username +dsh-platform access reset-password +dsh-platform access reset-management-password +dsh-platform access disable-management-password +dsh-platform access clear-retry +dsh-platform access clear-retry --global-only +dsh-platform access clear-retry --main-password +dsh-platform access clear-retry --management-password +dsh-platform access clear-retry --two-factor +dsh-platform access disable-two-factor +dsh-platform access clear-sessions +dsh-platform access clear-sessions --management-only +dsh-platform access generate-key +``` + +`access status` is read-only and reports the current account, initialization state, and retry state. The combined `access reset` command atomically applies selected username and main-password changes. When a Management console password is enabled, `y` resets it, the default `n` preserves it, and `d` disables it. When two-factor authentication is enabled, a separate `y/[n]` prompt can disable it. It submits nothing until all prompts complete. Yes/no recovery prompts accept only `y` or `n`, with the default shown in brackets. Other interactive recovery commands include `access set-username`, `access reset-password`, `access reset-management-password`, and `access disable-management-password`. `access clear-retry` uses the same Root/TTY and `y/[n]` boundary; it clears every browser-source wait, every source rolling window, and the instance-wide failure windows without changing credentials or sessions. Add `--global-only` to clear only the instance-wide windows while leaving browser waits and source windows active. The optional `--main-password`, `--management-password`, or `--two-factor` selector limits clearing to one credential class; `--two-factor` clears only its daily limit and leaves the fixed 10-second code backoff active. Fresh empty volumes register without a key. Persisted pre-account deployments and damaged authentication state use `access generate-key`, which issues a single-use authentication reset key valid for ten minutes from the same Root TTY. Never pass passwords as arguments or pipe them to the CLI. + +`--global-only` limits `access clear-retry` to the instance-wide flood windows; without it, both source/browser waits and instance windows are cleared. `access clear-sessions` clears all sessions, or only Management sessions with `--management-only`. + +`access set-username` changes only the administrator name; `access reset-password` changes the main password and revokes all sessions; `access reset-management-password` sets or changes the Management password and revokes Management sessions; `access disable-management-password` and `access disable-two-factor` disable those optional layers without logging out existing sessions. `access generate-key` issues the one-time key used by migration or damaged-account recovery. + +Do not read configured passwords from environment files, `/proc`, process listings, or service memory. Ask the user to authenticate through the normal browser flow. + +The Platform Management and Settings Document Editor System Plugins use the restricted DSH-side platform API and must not require a separate Management Console login. Access is inherited from the authenticated DSH Session and limited by an exact Gateway route allowlist; do not search for a Management session token or invent a separate plugin credential. The restricted API cannot access files, the Root terminal, User Plugin recovery, or authentication settings. + +When the Gateway is behind another proxy, use the public Gateway URL for checks. A direct request with an untrusted Host can correctly return `403`; that is not evidence that DSH itself failed. diff --git a/upstream/container/environment/resources/skills/dsh-docker-operations/references/updates-and-recovery.md b/upstream/container/environment/resources/skills/dsh-docker-operations/references/updates-and-recovery.md new file mode 100644 index 0000000..31cf0a9 --- /dev/null +++ b/upstream/container/environment/resources/skills/dsh-docker-operations/references/updates-and-recovery.md @@ -0,0 +1,44 @@ +# Updates, rollback, and recovery + +## Read-only check and activation + +Checking never downloads or activates an update: + +```sh +dsh-platform check +``` + +Inspect `dsh-platform status` after the check. Activate the latest supported target only after the user confirms: + +```sh +dsh-platform update +``` + +For an Agent running inside the current DSH session, activation must remain asynchronous: report the returned task ID and do not use `update --wait`, because switching DSH can interrupt the tool transport. `--wait` is reserved for an external operator terminal, the standalone Management Console terminal, and external automation. + +After submitting an update, use the public `dsh-platform status` result and task-correlated platform logs to report its current phase or terminal outcome. The Management interfaces reconstruct their stage history from this persistent state and JSONL log stream, so a browser refresh does not imply that the operation restarted. Prefer the phase, measurable byte/item/file/service counters, and structured failure details over an estimated elapsed time. Do not inspect updater journals, Deployment slots, or internal sockets to infer progress. + +Use `dsh-platform channel` to inspect the current channel and `dsh-platform channel stable|experimental` only when the user requests a channel change. + +Stable targets update the signed supported DSH and Environment. Before activating a newer upstream DSH, Experimental mode first converges the complete Deployment to the signed Stable target sequence, even if the displayed DSH and Environment versions already match. Do not download npm/GitHub artifacts yourself, submit an expected hash, or call the Trust API; Stage-0 alone authorizes and imports release objects. + +If npm exposes a selected DSH version before every dependency version is available, Runtime materialization automatically retries transient `E404`, `ETARGET`, and `ENOVERSIONS` failures in fresh install roots with bounded backoff. Do not repeatedly restart the update while these retries are active. If the bounded attempts still end in a version Hold, report its sanitized reason, wait until the Registry dependency graph is complete, and use `dsh-platform retry` only with the user's authorization. + +## Rollback and return to stable + +Use `dsh-platform rollback` only when a complete previous state is available and the user explicitly confirms the target and service interruption. Explain any reported snapshot or data boundary before proceeding, then verify status and health after rollback. Experimental return-to-stable can restore an older data snapshot and discard newer DSH data; use the interactive `dsh-platform return-stable` flow and present the data-loss boundary before proceeding. + +The in-DSH Platform Management interface intentionally does not expose rollback or return-to-stable. Direct users who need these recovery actions to the standalone DSH Management Console, where return-to-stable is shown only for an active Experimental Deployment with a verified Stable recovery point. + +Runtime reset rebuilds DSH from verified Pristine content and the current complete patch/plugin set. Use the standalone Management Console for that operation; it does not erase `$DSH_HOME`, sessions, user plugins, or the workspace. + +## Exceptional recovery + +```sh +dsh-platform recover +dsh-platform trust reset +``` + +Both commands are Root-only interactive console operations. `recover` restores the image-baseline Deployment after current and previous Deployments are unusable; `trust reset` clears the accepted trust state. + +Never manually delete `$DSH_PLATFORM_DATA/state`, rewrite slots, copy objects into Store, edit keyrings, forge receipts, or reinterpret an image reference. If a legacy or corrupt platform volume prevents startup, preserve `$DSH_HOME` and follow the exact platform error/recovery guidance. diff --git a/upstream/container/platform/bootstrap/VERSION b/upstream/container/platform/bootstrap/VERSION new file mode 100644 index 0000000..f8f3c08 --- /dev/null +++ b/upstream/container/platform/bootstrap/VERSION @@ -0,0 +1 @@ +1.0.18 diff --git a/upstream/container/platform/bootstrap/index.mjs b/upstream/container/platform/bootstrap/index.mjs new file mode 100644 index 0000000..4c040fc --- /dev/null +++ b/upstream/container/platform/bootstrap/index.mjs @@ -0,0 +1,454 @@ +#!/usr/bin/env node + +import { mkdir, readFile, realpath } from 'node:fs/promises' +import { join } from 'node:path' +import { EnvironmentRunner, loadControlPlane } from './lib/lifecycle.mjs' +import { BootstrapRuntime } from './lib/runtime.mjs' +import { createBootstrapControl, listenBootstrapControl } from './lib/control.mjs' +import { createDshLifecycleServer, DshLifecycleBroker, listenDshLifecycle } from './lib/dsh-lifecycle.mjs' +import { JsonlLogManager } from '../../control-plane/modules/log-manager/index.mjs' +import { PlatformPaths } from '../lib/paths.mjs' +import { parseImageInventory, recordsFromImageInventory } from '../lib/deployment-contracts.mjs' +import { DeploymentManager, DeploymentResolutionError } from './lib/deployments.mjs' +import { LocalApiClient } from '../../control-plane/modules/updater/lib/client.mjs' +import { + discardSystemPluginSelection, + linkSystemPluginScope, + listManagedSystemPlugins, + materializeSystemPluginSelection, + pruneSystemPluginSelectionViews, + SystemPluginSelectionStore, +} from '../../control-plane/modules/plugin-manager/system.mjs' +import { replaceSystemPluginView } from '../lib/paths.mjs' +import { verifyRuntimePatches } from '../../control-plane/modules/patch-manager/index.mjs' +import { + materializeSystemSkillCatalog, + retainLegacySystemSkillCatalog, + SystemSkillManager, +} from '../../control-plane/modules/skill-manager/index.mjs' +import { SnapshotClient } from '../../control-plane/modules/updater/lib/snapshot-client.mjs' +import { recoverPlatformUpdateBeforeDshStart } from '../../control-plane/hooks/recovery/index.mjs' +import { recoverUserPluginBeforeDshStart } from '../../control-plane/modules/plugin-manager/user-transaction.mjs' +import { UserPluginJournal } from '../../control-plane/modules/plugin-manager/user-journal.mjs' +import { UserPluginSelectionStore } from '../../control-plane/modules/plugin-manager/user-state.mjs' +import { + outboundProxyEnvironment, + outboundProxyScopeEnabled, + outboundProxyUrl, + clearOutboundProxyEnvironment, + parseOutboundProxyEnvironment, +} from '../lib/outbound-proxy.mjs' +import { parseAccessEvidence } from '../lib/access-evidence.mjs' + +const dataRoot = process.env.DSH_PLATFORM_DATA ?? '/data/platform' +const runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform' +const paths = new PlatformPaths(dataRoot, runRoot) +const logs = new JsonlLogManager({ + root: paths.logsRoot, + maxBytes: Number(process.env.DSH_LOG_MAX_BYTES ?? 104857600), + retentionDays: Number(process.env.DSH_LOG_RETENTION_DAYS ?? 14), + output: { stdout: process.stdout, stderr: process.stderr }, +}) +logs.on('error', error => { void logs.diagnostic('log-manager', 'capture.failed', { error }) }) +const seedRoot = process.env.DSH_PLATFORM_SEED ?? '/opt/dsh-platform/seed' +const inventory = parseImageInventory(await readFile(join(seedRoot, 'inventory.json'))) +const imageRecords = recordsFromImageInventory(inventory) +const dshHome = process.env.DSH_HOME ?? '/data/dsh' +const accessEvidence = parseAccessEvidence(process.env.DSH_LEGACY_INSTALLATION_EVIDENCE) +await logs.diagnostic('bootstrap', 'bootstrap.starting', { + bootstrapVersion: process.env.DSH_BOOTSTRAP_VERSION ?? null, + imageBuildId: inventory.imageBuildId, + targetSequence: inventory.targetSequence, +}) +const deployments = new DeploymentManager({ paths, seedRoot, inventory }) +const trust = new LocalApiClient(paths.trustSocket) +const outboundProxy = new LocalApiClient(paths.proxyControlSocket) +const snapshots = new SnapshotClient(new LocalApiClient(paths.snapshotSocket), 'dsh') +const access = new LocalApiClient(paths.accessSocket) +try { + await deployments.recoverActivation(trust) +} catch (error) { + await logs.diagnostic('bootstrap', 'deployment.activation-recovery.failed', { error }) + throw error +} +let imagePlan +let previousPlan +let planningError = null +try { + imagePlan = await deployments.prepareImage(imageRecords.deployment) + await deployments.publishStatus({ plan: imagePlan, currentId: imagePlan.target }) +} catch (error) { + planningError = error + await logs.diagnostic('bootstrap', 'deployment.image-plan.failed', { error }) + if (error instanceof DeploymentResolutionError) { + try { + previousPlan = await deployments.preparePreviousRecovery() + await deployments.publishStatus({ currentId: previousPlan.target, operation: 'recovering' }) + } catch (recoveryError) { + planningError = new AggregateError([error, recoveryError], 'Deployment resolution and previous recovery failed') + await logs.diagnostic('bootstrap', 'deployment.previous-plan.failed', { error: planningError }) + } + } + if (previousPlan === undefined) await deployments.publishStatus({ + recoveryMode: { + reason: error instanceof Error ? error.message : 'Deployment resolution failed', + failedRecordId: null, + }, + }) +} +await linkSystemPluginScope({ + dshHome: process.env.DSH_HOME ?? '/data/dsh', + viewRoot: join(paths.viewsRoot, 'system-plugins'), +}) +const systemPluginSelections = new SystemPluginSelectionStore(join(paths.deploymentStateRoot, 'system-plugins.json')) +await mkdir(paths.systemSkillCatalogsRoot, { recursive: true }) +const legacySystemSkillRoot = () => retainLegacySystemSkillCatalog({ + sourceRoot: join(seedRoot, 'bootstrap', inventory.bootstrap.id, 'control-plane', 'skills'), + outputRoot: paths.systemSkillCatalogsRoot, +}) +const selectedSystemSkillRoot = async () => { + const selected = await deployments.selected() + if (selected === null) throw new Error('no Deployment is selected') + return await materializeSystemSkillCatalog({ + environmentRoot: selected.paths.environment, + outputRoot: paths.systemSkillCatalogsRoot, + }) ?? await legacySystemSkillRoot() +} +const systemSkillManager = new SystemSkillManager({ + sourceRoot: await selectedSystemSkillRoot(), + viewRoot: paths.systemSkillsView, + statePath: paths.systemSkillStatePath, +}) +await systemSkillManager.initialize() +const systemSkills = { + list: () => systemSkillManager.list(), + configure: (skillId, action) => deployments.exclusive(() => systemSkillManager.configure(skillId, action)), +} +const applySystemPluginSelection = async () => { + const selected = await deployments.selected() + if (selected === null) return [] + const materialized = await materializeSystemPluginSelection({ + environmentRoot: selected.paths.environment, + sourceRoot: selected.paths['system-plugins'], + outputRoot: paths.systemPluginViewsRoot, + selectionStore: systemPluginSelections, + }) + await replaceSystemPluginView(paths, materialized.path) + await pruneSystemPluginSelectionViews({ outputRoot: paths.systemPluginViewsRoot, keepPath: materialized.path }) + await logs.diagnostic('bootstrap', 'system-plugin.view.applied', { + pluginCount: materialized.plugins.length, + }) + return materialized.plugins +} +const applyDeploymentResources = async () => { + await applySystemPluginSelection() + const skills = await systemSkillManager.useSourceRoot(await selectedSystemSkillRoot()) + await logs.diagnostic('bootstrap', 'system-skill.view.applied', { skillCount: skills.length }) +} +const prepareSystemPluginSelection = async () => { + const previous = await realpath(join(paths.systemPluginViewsRoot, 'current')) + const selected = await deployments.selected() + if (selected === null) throw new Error('no Deployment is selected') + const materialized = await materializeSystemPluginSelection({ + environmentRoot: selected.paths.environment, + sourceRoot: selected.paths['system-plugins'], + outputRoot: paths.systemPluginViewsRoot, + selectionStore: systemPluginSelections, + }) + await logs.diagnostic('bootstrap', 'system-plugin.view.prepared', { + pluginCount: materialized.plugins.length, + }) + return Object.freeze({ + activate: async () => { + await replaceSystemPluginView(paths, materialized.path) + await logs.diagnostic('bootstrap', 'system-plugin.view.activated') + }, + commit: async () => { + await pruneSystemPluginSelectionViews({ + outputRoot: paths.systemPluginViewsRoot, + keepPath: materialized.path, + }) + await logs.diagnostic('bootstrap', 'system-plugin.view.committed') + }, + rollback: async () => { + await replaceSystemPluginView(paths, previous) + await pruneSystemPluginSelectionViews({ outputRoot: paths.systemPluginViewsRoot, keepPath: previous }) + await logs.diagnostic('bootstrap', 'system-plugin.view.rolled-back', { level: 'warning' }) + }, + }) +} +const capture = (child, source, declaration) => logs.capture( + child, + source, + declaration, + { acceptForwarded: ['gateway', 'platform-management'].includes(source) }, +) +const proxyLaunchToken = process.env.DSH_PROXY_LAUNCH_TOKEN +delete process.env.DSH_PROXY_LAUNCH_TOKEN +const accessLaunchToken = process.env.DSH_ACCESS_LAUNCH_TOKEN +delete process.env.DSH_ACCESS_LAUNCH_TOKEN +const reportLifecycle = (message, fields) => logs.diagnostic(fields.componentId ?? 'bootstrap', message, fields) +const managedNetworkEnvironment = async scope => { + try { + const result = await outboundProxy.request('GET', `/v1/environment?scope=${encodeURIComponent(scope)}`) + return clearOutboundProxyEnvironment(parseOutboundProxyEnvironment(result.environment, scope)) + } catch (error) { + await logs.diagnostic('bootstrap', 'outbound-proxy.environment.fallback', { + scope, + error, + level: 'warning', + }) + try { + const state = JSON.parse(await readFile(paths.proxyRoutingStatePath, 'utf8')) + return outboundProxyEnvironment(scope, { enabled: outboundProxyScopeEnabled(state, scope) }) + } catch { + return outboundProxyEnvironment(scope) + } + } +} +let runtime +const dshLifecycleBroker = new DshLifecycleBroker({ + report: reportLifecycle, + shouldTerminate: async () => { + const lifecycle = runtime?.status().dshLifecycle + return ['stopping', 'restarting'].includes(lifecycle?.state) + || environment?.stopping === true + || await deployments.activation() !== undefined + }, +}) +const controlPlane = new EnvironmentRunner({ + environmentRoot: join(import.meta.dirname, '..', '..', 'control-plane'), + loader: loadControlPlane, + capture, + prepareService: async component => { + if (component.id === 'access-manager') return { + environment: { DSH_ACCESS_LAUNCH_TOKEN: accessLaunchToken }, + release: () => {}, + } + if (component.id === 'outbound-proxy') return { + environment: { DSH_PROXY_LAUNCH_TOKEN: proxyLaunchToken }, + release: () => {}, + } + const environment = component.id === 'gateway' + ? outboundProxyEnvironment('platform', { enabled: false }) + : await managedNetworkEnvironment('platform') + return { environment: { ...environment, NODE_USE_ENV_PROXY: '1' }, release: () => {} } + }, + recoverableComponents: ['access-manager', 'outbound-proxy'], + report: reportLifecycle, + startupGroups: [ + ['gateway', 'access-manager', 'outbound-proxy'], + ['platform-recovery'], + ['platform-management'], + ], +}) +const environment = new EnvironmentRunner({ + environmentRoot: join(paths.viewsRoot, 'environment'), + capture, + prepareService: async component => { + const launch = dshLifecycleBroker.prepareLaunch(component.id) + if (component.id !== 'dsh-runtime') return launch + const environment = await managedNetworkEnvironment('sharedDsh') + return { + ...launch, + environment: { + ...launch.environment, + ...environment, + NODE_USE_ENV_PROXY: '1', + DSH_PLATFORM_AGENT_PROXY_URL: outboundProxyUrl('agentNetwork'), + }, + } + }, + report: reportLifecycle, +}) +runtime = new BootstrapRuntime({ + controlPlane, + environment, + validateDeployment: async () => { + await verifyRuntimePatches({ + runtimeRoot: join(paths.viewsRoot, 'runtime'), + environmentRoot: join(paths.viewsRoot, 'environment'), + }) + }, + prepareDeployment: applyDeploymentResources, + beforeReady: async () => { + await access.request('POST', '/v1/classify', { token: accessLaunchToken, evidence: accessEvidence }) + await logs.diagnostic('bootstrap', 'access.classification.submitted') + }, + onEnvironmentVerified: async () => dshLifecycleBroker.verify(), + onDshAvailable: async () => dshLifecycleBroker.publish(), + ownsDshLifecycle: async () => await deployments.activation() !== undefined, + onDshRecovered: async () => { + await deployments.publishStatus({ recoveryMode: null }) + await logs.diagnostic('bootstrap', 'dsh.recovery-mode.cleared') + }, + report: reportLifecycle, + onEnvironmentFatal: async error => { + const state = await deployments.state().catch(() => ({ current: null })) + const reason = error instanceof Error ? error.message : String(error) + await deployments.publishStatus({ + recoveryMode: { reason, failedRecordId: state.current }, + }) + await logs.diagnostic('bootstrap', 'environment.recovery-mode.entered', { error, failedRecordId: state.current }) + }, +}) +const systemPlugins = { + list: async () => { + const deploymentRoot = await realpath(paths.deploymentView).catch(error => ( + error?.code === 'ENOENT' ? undefined : Promise.reject(error) + )) + if (deploymentRoot === undefined) return [] + const activeRoot = await realpath(join(paths.systemPluginViewsRoot, 'current')).catch(error => ( + error?.code === 'ENOENT' ? undefined : Promise.reject(error) + )) + return listManagedSystemPlugins({ + environmentRoot: join(deploymentRoot, 'environment'), + sourceRoot: join(deploymentRoot, 'system-plugins'), + selectionStore: systemPluginSelections, + activeRoot, + }) + }, + mutate: (pluginId, action, recovery = false) => deployments.exclusive(async () => { + const resolved = await deployments.selected() + if (resolved === null) throw new Error('no Deployment is selected') + const before = await systemPlugins.list() + const pluginIds = before.map(plugin => plugin.id) + const previousSelection = await systemPluginSelections.read(pluginIds) + try { + await systemPluginSelections[recovery ? 'recover' : 'configure'](pluginIds, pluginId, action) + return systemPlugins.list() + } catch (error) { + try { + await systemPluginSelections.write(pluginIds, previousSelection) + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], 'System Plugin operation and rollback failed') + } + throw error + } + }), + discard: () => deployments.exclusive(async () => { + const resolved = await deployments.selected() + if (resolved === null) throw new Error('no Deployment is selected') + const activeRoot = await realpath(join(paths.systemPluginViewsRoot, 'current')) + return discardSystemPluginSelection({ + environmentRoot: resolved.paths.environment, + sourceRoot: resolved.paths['system-plugins'], + selectionStore: systemPluginSelections, + activeRoot, + }) + }), + prepare: prepareSystemPluginSelection, +} +systemPlugins.configure = (pluginId, action) => systemPlugins.mutate(pluginId, action) +systemPlugins.recover = (pluginId, action) => systemPlugins.mutate(pluginId, action, true) +const server = createBootstrapControl(runtime, { deployments, trust, systemPlugins, systemSkills, snapshots }) +const dshLifecycleServer = createDshLifecycleServer(dshLifecycleBroker) +await listenBootstrapControl(server, paths.bootstrapSocket) +await listenDshLifecycle(dshLifecycleServer, paths.dshLifecycleSocket) +let imageCandidateHealthy = true +try { + const recoveryStartedAt = Date.now() + const updateRecovery = await recoverPlatformUpdateBeforeDshStart({ dataRoot, runRoot, dshHome }) + const userPluginRecovery = await recoverUserPluginBeforeDshStart({ + journal: new UserPluginJournal(paths.userPluginJournalPath), + report: (message, fields) => logs.diagnostic('user-plugin-manager', message, fields), + selectionStore: new UserPluginSelectionStore(paths.userPluginStatePath), + snapshots: new SnapshotClient(new LocalApiClient(paths.snapshotSocket), 'user-plugin'), + }) + await logs.diagnostic('bootstrap', 'startup-recovery.completed', { + elapsedMs: Date.now() - recoveryStartedAt, + updatePhase: updateRecovery?.phase ?? null, + userPluginPhase: userPluginRecovery?.phase ?? null, + }) + await runtime.start({ + allowRecovery: true, + onEnvironmentFailure: async () => { + imageCandidateHealthy = false + if (previousPlan !== undefined) return false + if (planningError !== null) return false + if (imagePlan?.fallback !== null && imagePlan?.fallback !== undefined) { + return deployments.rejectImage(imagePlan) + } + try { + previousPlan = await deployments.preparePreviousRecovery(imagePlan?.target) + return true + } catch (error) { + await logs.diagnostic('bootstrap', 'deployment.previous-plan.failed', { error }) + return false + } + }, + }) +} catch (error) { + await logs.diagnostic('bootstrap', 'bootstrap.start.failed', { error }) + server.close() + dshLifecycleServer.close() + throw error +} +if (runtime.recoveryMode === null) { + if (imageCandidateHealthy && imagePlan !== undefined) await deployments.acceptImage(imagePlan) + else if (previousPlan !== undefined) { + try { + await deployments.acceptPreviousRecovery(previousPlan, tokens => trust.activate(tokens)) + planningError = null + } catch (error) { + await runtime.suspend('dsh-runtime').catch(suspendError => logs.diagnostic('bootstrap', 'dsh-runtime.suspend.failed', { + error: suspendError, + cause: error instanceof Error ? error.message : String(error), + })) + await logs.diagnostic('bootstrap', 'deployment.previous-activation.failed', { error }) + runtime.enterRecovery(error) + } + } +} +const recoveryReason = runtime.recoveryMode ?? (previousPlan === undefined && planningError instanceof Error + ? planningError.message + : null) +const recoveryMode = recoveryReason === null ? null : { + reason: recoveryReason, + failedRecordId: imagePlan?.target ?? null, +} +await deployments.publishStatus({ plan: imagePlan, recoveryMode }) +runtime.markStartupComplete() +if (recoveryMode === null) await dshLifecycleBroker.publish() +await logs.diagnostic('bootstrap', 'platform.ready', { recoveryMode: recoveryMode !== null }) +process.send?.({ type: 'ready', bootstrapApi: 1 }) +const onRecoveryMessage = message => { + if (message?.type !== 'recover-image-baseline' || typeof message.requestId !== 'string') return + void deployments.recoverImageBaseline(imageRecords.deployment, { + healthCheck: () => runtime.reload(), + activateReceipts: tokens => trust.activate(tokens), + }).then( + async slots => { + await logs.diagnostic('bootstrap', 'image-baseline.recovery.completed', { requestId: message.requestId }) + process.send?.({ type: 'recovery-result', requestId: message.requestId, slots }) + }, + async error => { + await logs.diagnostic('bootstrap', 'image-baseline.recovery.failed', { error, requestId: message.requestId }) + process.send?.({ + type: 'recovery-result', requestId: message.requestId, + error: error instanceof Error ? error.message : 'image baseline recovery failed', + }) + }, + ) +} +process.on('message', onRecoveryMessage) + +let resolveSignal +const signal = new Promise(resolve => { resolveSignal = resolve }) +const onSignal = () => resolveSignal({ type: 'signal' }) +process.once('SIGINT', onSignal) +process.once('SIGTERM', onSignal) +const outcome = await Promise.race([ + signal, + runtime.fatal.then(error => ({ type: 'fatal', error })), +]) +if (outcome.type === 'fatal') await logs.diagnostic('bootstrap', 'bootstrap.fatal', { error: outcome.error }) +else await logs.diagnostic('bootstrap', 'bootstrap.stopping') +dshLifecycleBroker.beginShutdown() +server.close() +dshLifecycleServer.close() +await runtime.stop().catch(error => logs.diagnostic('bootstrap', 'bootstrap.stop.failed', { error })) +await logs.diagnostic('bootstrap', 'bootstrap.stopped') +process.off('message', onRecoveryMessage) +if (process.connected) process.disconnect() +if (outcome.type === 'fatal') throw outcome.error diff --git a/upstream/container/platform/bootstrap/lib/control.mjs b/upstream/container/platform/bootstrap/lib/control.mjs new file mode 100644 index 0000000..640c1c1 --- /dev/null +++ b/upstream/container/platform/bootstrap/lib/control.mjs @@ -0,0 +1,170 @@ +import { createServer } from 'node:http' +import { chmod, mkdir, rm } from 'node:fs/promises' +import { dirname } from 'node:path' +import { PlatformGarbageCollector } from './gc.mjs' + +async function jsonBody(request) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.byteLength + if (size > 256 * 1024) throw new Error('Bootstrap request body is too large') + chunks.push(chunk) + } + return size === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function send(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(`${JSON.stringify(value)}\n`) +} + +export function createBootstrapControl(runner, { deployments, trust, systemPlugins, systemSkills, snapshots } = {}) { + return createServer(async (request, response) => { + let pathname = 'invalid-url' + try { + pathname = new URL(request.url ?? '/', 'http://bootstrap.internal').pathname + if (request.method === 'GET' && pathname === '/v1/status') send(response, 200, { + ...runner.status(), + bootstrapVersion: process.env.DSH_BOOTSTRAP_VERSION ?? '1.0.0', + }) + else if (request.method === 'POST' && pathname === '/v1/reload') send(response, 200, await runner.reload()) + else if (request.method === 'GET' && pathname === '/v1/health') send(response, 200, await runner.health()) + else if (request.method === 'GET' && pathname === '/v1/system-plugins' && systemPlugins !== undefined) { + send(response, 200, { plugins: await systemPlugins.list() }) + } else if (request.method === 'POST' && pathname === '/v1/system-plugins/action' && systemPlugins !== undefined) { + const body = await jsonBody(request) + send(response, 200, { plugins: await systemPlugins.configure(body.id, body.action) }) + } else if (request.method === 'POST' && pathname === '/v1/system-plugins/recovery-action' && systemPlugins !== undefined) { + const body = await jsonBody(request) + send(response, 200, { plugins: await systemPlugins.recover(body.id, body.action) }) + } else if (request.method === 'POST' && pathname === '/v1/system-plugins/discard' && systemPlugins !== undefined) { + send(response, 200, { plugins: await systemPlugins.discard() }) + } else if (request.method === 'GET' && pathname === '/v1/system-skills' && systemSkills !== undefined) { + send(response, 200, { skills: await systemSkills.list() }) + } else if (request.method === 'POST' && pathname === '/v1/system-skills/action' && systemSkills !== undefined) { + const body = await jsonBody(request) + send(response, 200, { skills: await systemSkills.configure(body.skillId, body.action) }) + } + else if (request.method === 'GET' && pathname === '/v1/deployments/current' && deployments !== undefined) { + const state = await deployments.state() + send(response, 200, { record: state.current === null ? null : await deployments.record(state.current) }) + } else if (request.method === 'POST' && pathname === '/v1/deployments/activate' && deployments !== undefined && trust !== undefined) { + const body = await jsonBody(request) + const slots = await deployments.activateManaged(body.record, { + healthCheck: () => runner.reload(), + activateReceipts: tokens => trust.activate(tokens), + }) + send(response, 200, { slots }) + } else if (request.method === 'POST' && pathname === '/v1/deployments/rollback' && deployments !== undefined && trust !== undefined) { + const body = await jsonBody(request) + const state = await deployments.state() + if (state.previous === null) throw new Error('no previous Deployment exists') + if (body.recordId !== undefined && body.recordId !== state.previous) throw new Error('requested Deployment is not previous') + const slots = await deployments.rollback({ + healthCheck: () => runner.reload(), + activateReceipts: tokens => trust.activate(tokens), + }) + send(response, 200, { slots }) + } else if (request.method === 'GET' && pathname === '/v1/deployments/rollback-plan' && deployments !== undefined) { + send(response, 200, await deployments.rollbackPlan()) + } else if (request.method === 'POST' && pathname === '/v1/deployments/candidate' && deployments !== undefined) { + const body = await jsonBody(request) + const record = await deployments.stageCandidate(body.record, () => runner.reload()) + send(response, 200, { recordId: record.id }) + } else if (request.method === 'POST' && pathname === '/v1/deployments/candidate/commit' && deployments !== undefined && trust !== undefined) { + const body = await jsonBody(request) + const slots = await deployments.commitCandidate(body.recordId, tokens => trust.activate(tokens)) + send(response, 200, { slots }) + } else if (request.method === 'POST' && pathname === '/v1/deployments/candidate/cancel' && deployments !== undefined) { + send(response, 200, await deployments.cancelCandidate()) + } else if (request.method === 'POST' && pathname === '/v1/platform/gc' && deployments !== undefined) { + send(response, 200, await new PlatformGarbageCollector({ paths: deployments.paths, deployments, snapshots }).collect()) + } else if (request.method === 'POST' && pathname === '/v1/deployments/runtime/reset' && deployments !== undefined) { + send(response, 200, await deployments.resetCurrentRuntime({ + pauseDsh: () => runner.pause('dsh-runtime'), + restartDsh: () => runner.restart('dsh-runtime'), + })) + } + else { + const operation = /^\/v1\/components\/([a-z0-9][a-z0-9._-]{0,127})\/(pause|suspend|resume|restart)$/.exec(pathname) + if (request.method === 'POST' && operation !== null) { + const [componentId, action] = operation.slice(1) + if (componentId === 'dsh-runtime' && action === 'resume' && deployments !== undefined) { + const status = await deployments.exclusive(async () => { + await deployments.setOperation('starting') + try { + const value = await runner.resume(componentId) + await deployments.publishStatus() + return value + } catch (error) { + await deployments.publishStatus({ + recoveryMode: runner.status().recoveryMode + ?? (error instanceof Error ? error.message : String(error)), + }) + throw error + } + }) + send(response, 200, status) + } else if (componentId === 'dsh-runtime' && action === 'restart' && deployments !== undefined) { + const status = await deployments.exclusive(async () => { + await deployments.setOperation('restarting') + let transaction + try { + transaction = await systemPlugins?.prepare?.() + const value = await runner.restart(componentId, { + skipPreparation: true, + recoverStart: true, + beforeStart: () => transaction?.activate?.(), + onStartFailure: () => transaction?.rollback?.(), + }) + if (transaction?.commit !== undefined) { + try { + await transaction.commit() + } catch (error) { + await runner.record?.('system-plugin.view.commit.failed', { error, level: 'warning' }) + } + } + await deployments.publishStatus() + return value + } catch (error) { + try { + await deployments.publishStatus({ + operation: 'restart-failed', + recoveryMode: runner.status().recoveryMode + ?? (error instanceof Error ? error.message : String(error)), + }) + } catch (statusError) { + await runner.record?.('deployment.status.failed', { + error: statusError, + level: 'warning', + originalError: error instanceof Error ? error.message : String(error), + }) + } + throw error + } + }) + send(response, 200, status) + } else send(response, 200, await runner[action](componentId)) + } else send(response, 404, { error: 'not found' }) + } + } catch (error) { + await runner.record?.('bootstrap.request.failed', { + error, + method: request.method ?? null, + pathname, + }) + send(response, 500, { error: error instanceof Error ? error.message : 'Bootstrap operation failed' }) + } + }) +} + +export async function listenBootstrapControl(server, socketPath) { + await mkdir(dirname(socketPath), { recursive: true }) + await rm(socketPath, { force: true }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + await chmod(socketPath, 0o600) +} diff --git a/upstream/container/platform/bootstrap/lib/deployments.mjs b/upstream/container/platform/bootstrap/lib/deployments.mjs new file mode 100644 index 0000000..46b85a2 --- /dev/null +++ b/upstream/container/platform/bootstrap/lib/deployments.mjs @@ -0,0 +1,808 @@ +import { lstat, mkdir, readFile, rename, rm, symlink } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' +import { canonicalJson } from '../../lib/canonical-json.mjs' +import { + deriveRecordId, + parseDeploymentRecord, + parseSlots, + recordsFromImageInventory, +} from '../../lib/deployment-contracts.mjs' +import { durableCreate, durableReplace } from '../../lib/atomic.mjs' +import { readDeploymentStatus, writeDeploymentStatus } from '../../lib/deployment-status.mjs' +import { replaceDeploymentView, replaceSystemPluginView } from '../../lib/paths.mjs' +import { hashTree, hashTreeContent } from '../../lib/tree-hash.mjs' +import { compareDshVersions } from '../../lib/supported-target.mjs' +import { TrustError } from '../../lib/validation.mjs' +import { buildRuntime, cloneTree, loadEnvironmentPatchSet } from '../../../control-plane/modules/patch-manager/index.mjs' + +const KINDS = Object.freeze(['environment', 'pristine', 'runtime', 'system-plugins']) + +export class DeploymentResolutionError extends Error { + constructor(message, cause) { + super(message, { cause }) + this.name = 'DeploymentResolutionError' + } +} + +function sameDeployment(left, right) { + return left.dshVersion === right.dshVersion + && left.environmentVersion === right.environmentVersion + && KINDS.every(kind => { + const field = kind === 'system-plugins' ? 'systemPlugins' : kind + return left[field].sha256 === right[field].sha256 + }) +} + +function validateCandidate(from, candidate, experimental) { + if (experimental) { + if (candidate.authority !== 'experimental') throw new TrustError('probation candidate must use Experimental authority') + if (candidate.targetSequence !== from.targetSequence) throw new TrustError('Experimental candidate must retain the Stable targetSequence') + if (compareDshVersions(candidate.dshVersion, from.dshVersion) <= 0) { + throw new TrustError('Experimental candidate DSH must advance the current version') + } + if ( + candidate.environment.sha256 !== from.environment.sha256 + || candidate.systemPlugins.sha256 !== from.systemPlugins.sha256 + ) throw new TrustError('Experimental candidate must retain the current Environment and System Plugins') + return + } + if (candidate.authority !== 'stable') throw new TrustError('managed release activation requires Stable authority') + if (candidate.targetSequence < from.targetSequence) throw new TrustError('Stable candidate targetSequence would roll back') + if (from.authority === 'stable' && candidate.targetSequence === from.targetSequence && !sameDeployment(from, candidate)) { + throw new TrustError('Stable candidate conflicts with current content at the same targetSequence') + } + if (from.authority === 'experimental' && compareDshVersions(candidate.dshVersion, from.dshVersion) < 0) { + throw new TrustError('Stable candidate has not caught up with Experimental DSH') + } +} + +async function readOptional(path) { + try { return await readFile(path) } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } +} + +async function exists(path) { + return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error)) +} + +async function replaceRuntimeDirectory(staging, destination) { + const backup = `${destination}.${randomUUID()}.reset-backup` + const hadDestination = await exists(destination) + if (hadDestination) await rename(destination, backup) + try { + await rename(staging, destination) + } catch (error) { + if (hadDestination) await rename(backup, destination).catch(() => {}) + throw error + } + return Object.freeze({ + commit: () => hadDestination ? rm(backup, { recursive: true, force: true }) : Promise.resolve(), + rollback: async () => { + await rm(destination, { recursive: true, force: true }) + if (hadDestination) await rename(backup, destination) + }, + }) +} + +export class DeploymentManager { + constructor({ paths, seedRoot, inventory }) { + this.paths = paths + this.seedRoot = resolve(seedRoot) + this.inventory = inventory + this.imageRecord = recordsFromImageInventory(inventory).deployment + this.stateRoot = paths.deploymentStateRoot + this.recordsRoot = join(this.stateRoot, 'records') + this.slotsPath = join(this.stateRoot, 'slots.json') + this.activationPath = join(this.stateRoot, 'activation.json') + this.queue = Promise.resolve() + } + + exclusive(operation) { + const result = this.queue.then(operation, operation) + this.queue = result.then(() => undefined, () => undefined) + return result + } + + recordPath(id) { + return join(this.recordsRoot, `${id}.json`) + } + + async writeRecord(value) { + const record = parseDeploymentRecord(value) + const bytes = canonicalJson(record) + const path = this.recordPath(record.id) + const existing = await readOptional(path) + if (existing === undefined) await durableCreate(path, bytes) + else if (!existing.equals(bytes)) throw new TrustError('Deployment Record ID identifies conflicting content') + return record + } + + async repairImageRecord(value) { + const record = parseDeploymentRecord(value) + const bytes = canonicalJson(record) + if (!bytes.equals(canonicalJson(this.imageRecord))) { + throw new TrustError('image recovery Record differs from the current image inventory') + } + await durableReplace(this.recordPath(record.id), bytes) + return record + } + + async record(id) { + const bytes = await readOptional(this.recordPath(id)) + if (bytes === undefined) throw new TrustError(`Deployment Record ${id} does not exist`) + return parseDeploymentRecord(bytes) + } + + async state() { + const bytes = await readOptional(this.slotsPath) + if (bytes === undefined) return Object.freeze({ generation: 0, current: null, previous: null }) + return parseSlots(bytes, 'deployment-record', 'Deployment slots') + } + + availableInCurrentImage(record) { + return KINDS.every(kind => { + const reference = record[kind === 'system-plugins' ? 'systemPlugins' : kind] + return reference.storage !== 'image' || reference.imageBuildId === this.inventory.imageBuildId + }) + } + + imageRecordFromAnotherImage(record) { + return KINDS.every(kind => { + const reference = record[kind === 'system-plugins' ? 'systemPlugins' : kind] + return reference.storage === 'image' && reference.imageBuildId !== this.inventory.imageBuildId + }) + } + + async rollbackPlan() { + const state = await this.state() + const current = state.current === null ? null : await this.record(state.current) + let previous = state.previous === null ? null : await this.record(state.previous) + if (previous !== null && !this.availableInCurrentImage(previous)) previous = null + return Object.freeze({ current, previous }) + } + + async commit(current, previous) { + const state = await this.state() + if (current === previous) previous = null + const slots = parseSlots({ + schema: 1, + generation: state.generation + 1, + current, + previous, + }, 'deployment-record', 'Deployment slots') + await durableReplace(this.slotsPath, canonicalJson(slots)) + return slots + } + + imageAsset(reference) { + if (reference.imageBuildId !== this.inventory.imageBuildId) { + throw new TrustError('Deployment Image Reference belongs to a different image') + } + const expected = reference.kind === 'system-plugins' + ? this.inventory.deployment.systemPlugins + : this.inventory.deployment[reference.kind] + if (expected === undefined || reference.id !== expected.id || reference.sha256 !== expected.sha256) { + throw new TrustError(`Deployment Image Reference for ${reference.kind} is absent from inventory`) + } + const roots = { + environment: 'environments', + pristine: 'pristine', + runtime: 'runtimes', + 'system-plugins': 'system-plugins', + } + return join(this.seedRoot, roots[reference.kind], reference.id) + } + + storeAsset(reference) { + const roots = { + environment: this.paths.environmentsRoot, + pristine: this.paths.pristineRoot, + runtime: this.paths.runtimesRoot, + 'system-plugins': this.paths.systemPluginsRoot, + } + return join(roots[reference.kind], reference.id) + } + + async resolveReference(reference) { + const path = reference.storage === 'image' ? this.imageAsset(reference) : this.storeAsset(reference) + const details = await lstat(path) + if (!details.isDirectory() || details.isSymbolicLink()) { + throw new TrustError(`resolved ${reference.kind} must be an immutable directory`) + } + // Image bytes are bound to the signed inventory during image assembly. Rehashing a large + // Runtime on every boot would defeat zero-copy startup; managed objects are checked here. + if (reference.storage === 'store' && await hashTree(path) !== reference.sha256) { + throw new TrustError(`resolved ${reference.kind} content hash differs from its Record`) + } + return path + } + + async resolveRecord(recordId) { + const record = await this.record(recordId) + const entries = await Promise.all(KINDS.map(async kind => [kind, await this.resolveReference(record[kind === 'system-plugins' ? 'systemPlugins' : kind])])) + return Object.freeze({ record, paths: Object.freeze(Object.fromEntries(entries)) }) + } + + async sameImageDeployment(current, image) { + if (sameDeployment(current, image)) return true + if ( + current.dshVersion !== image.dshVersion + || current.environmentVersion !== image.environmentVersion + || current.pristine.sha256 !== image.pristine.sha256 + || current.runtime.sha256 !== image.runtime.sha256 + || current.systemPlugins.sha256 !== image.systemPlugins.sha256 + ) return false + const [currentEnvironment, imageEnvironment] = await Promise.all([ + this.resolveReference(current.environment), + this.resolveReference(image.environment), + ]) + const [currentContent, imageContent] = await Promise.all([ + hashTreeContent(currentEnvironment), + hashTreeContent(imageEnvironment), + ]) + return currentContent === imageContent + } + + async selected() { + const recordId = this.selectedRecordId ?? (await this.state()).current + return recordId === null ? null : this.resolveRecord(recordId) + } + + async prepareView(recordId) { + const resolved = await this.resolveRecord(recordId) + const destination = join(this.paths.deploymentViewsRoot, recordId) + const temporary = join(this.paths.deploymentViewsRoot, `.${recordId}.${randomUUID()}.tmp`) + await mkdir(temporary, { recursive: false }) + try { + for (const kind of KINDS) await symlink(resolved.paths[kind], join(temporary, kind), 'dir') + try { + await rename(temporary, destination) + } catch (error) { + if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error + } + } finally { + await rm(temporary, { recursive: true, force: true }) + } + return Object.freeze({ ...resolved, view: destination }) + } + + async select(recordId) { + const candidate = await this.prepareView(recordId) + await replaceDeploymentView(this.paths, candidate.view) + await replaceSystemPluginView(this.paths) + this.selectedRecordId = recordId + return candidate + } + + async initialize(record) { + const plan = await this.prepareImage(record) + return this.acceptImage(plan) + } + + async prepareImage(record) { + return this.exclusive(async () => { + const image = await this.writeRecord(record) + const state = await this.state() + if (state.current === null) { + await this.select(image.id) + return Object.freeze({ + baseGeneration: 0, + target: image.id, + fallback: null, + action: 'initialize', + imageBehindCurrent: false, + requiresExperimentalRebuild: false, + }) + } + + const current = await this.record(state.current) + let target = current.id + let action = 'retain' + let imageBehindCurrent = false + let requiresExperimentalRebuild = false + + if ( + image.authority === 'development' + && image.targetSequence === 0 + && current.id !== image.id + && compareDshVersions(image.dshVersion, current.dshVersion) >= 0 + ) { + target = image.id + action = 'development-refresh' + } else if ( + image.targetSequence < current.targetSequence + && current.receiptTokens.length === 0 + && this.imageRecordFromAnotherImage(current) + ) { + target = image.id + action = 'image-rollback' + } else if (current.authority === 'experimental') { + const dshCaughtUp = compareDshVersions(image.dshVersion, current.dshVersion) >= 0 + if (image.targetSequence >= current.targetSequence && dshCaughtUp) { + target = image.id + action = 'stable-caught-up' + } else { + imageBehindCurrent = image.targetSequence < current.targetSequence + requiresExperimentalRebuild = image.targetSequence > current.targetSequence && !dshCaughtUp + } + } else if (image.targetSequence > current.targetSequence) { + target = image.id + action = 'image-forward' + } else if (image.targetSequence < current.targetSequence) { + imageBehindCurrent = true + } else if (!await this.sameImageDeployment(current, image)) { + throw new TrustError('Deployment image conflicts with current content at the same targetSequence') + } else if (current.id !== image.id) { + target = image.id + action = 'prefer-image' + } + + let candidateError = null + try { + await this.select(target) + } catch (error) { + if (target === current.id) { + throw new DeploymentResolutionError('current Deployment assets could not be resolved', error) + } + try { + await this.select(current.id) + } catch (fallbackError) { + throw new DeploymentResolutionError( + 'image candidate and current Deployment assets could not be resolved', + new AggregateError([error, fallbackError]), + ) + } + target = current.id + action = 'image-rejected' + candidateError = error instanceof Error ? error.message : 'image candidate could not be resolved' + } + return Object.freeze({ + baseGeneration: state.generation, + target, + fallback: target === current.id || action === 'development-refresh' ? null : current.id, + action, + imageBehindCurrent, + requiresExperimentalRebuild, + candidateError, + }) + }) + } + + async acceptImage(plan) { + return this.exclusive(async () => { + const state = await this.state() + if (state.generation !== plan.baseGeneration) throw new TrustError('Deployment slots changed during image startup') + if (state.current === plan.target) return state + return this.commit(plan.target, plan.action === 'development-refresh' ? null : state.current) + }) + } + + async rejectImage(plan) { + return this.exclusive(async () => { + const state = await this.state() + if (state.generation !== plan.baseGeneration) throw new TrustError('Deployment slots changed during image startup') + if (plan.fallback === null) return false + await this.select(plan.fallback) + return true + }) + } + + async preparePreviousRecovery(failedRecordId) { + return this.exclusive(async () => { + const state = await this.state() + const failed = failedRecordId ?? state.current + if (failed === null || state.current !== failed) { + throw new TrustError('failed Deployment is no longer current') + } + if (state.previous === null || state.previous === failed) { + throw new TrustError('no previous Deployment exists') + } + await this.record(failed) + await this.select(state.previous) + return Object.freeze({ + baseGeneration: state.generation, + failed, + target: state.previous, + }) + }) + } + + async acceptPreviousRecovery(plan, activateReceipts) { + return this.exclusive(async () => { + const state = await this.state() + if (state.generation !== plan.baseGeneration || state.current !== plan.failed || state.previous !== plan.target) { + throw new TrustError('Deployment slots changed during automatic recovery') + } + const previous = await this.record(plan.target) + await activateReceipts(previous.receiptTokens) + await this.writeActivation({ phase: 'receipts-active', from: plan.failed, to: plan.target }) + const committed = await this.commit(plan.target, plan.failed) + await this.clearActivation() + return committed + }) + } + + async activate(recordId, healthCheck) { + return this.exclusive(async () => { + await this.record(recordId) + const state = await this.state() + if (state.current === recordId) { + await this.select(recordId) + return state + } + await this.select(recordId) + try { + await healthCheck() + } catch (error) { + if (state.current !== null) { + await this.select(state.current) + await healthCheck().catch(() => {}) + } + throw error + } + return this.commit(recordId, state.current) + }) + } + + async rollback({ healthCheck, activateReceipts }) { + return this.exclusive(async () => { + const state = await this.state() + if (state.previous === null) throw new TrustError('no previous Deployment exists') + const from = await this.record(state.current) + const previous = await this.record(state.previous) + await this.writeActivation({ phase: 'prepared', from: from.id, to: previous.id }) + await this.publishStatus({ operation: 'recovering' }) + let receiptsActive = false + try { + await this.select(previous.id) + await healthCheck() + await this.writeActivation({ phase: 'healthy', from: from.id, to: previous.id }) + await activateReceipts(previous.receiptTokens) + receiptsActive = true + await this.writeActivation({ phase: 'receipts-active', from: from.id, to: previous.id }) + const committed = await this.commit(previous.id, from.id) + await this.clearActivation() + await this.publishStatus() + return committed + } catch (error) { + if (receiptsActive) await activateReceipts(from.receiptTokens).catch(() => {}) + await this.select(from.id).catch(() => {}) + await healthCheck().catch(() => {}) + await this.clearActivation().catch(() => {}) + await this.publishStatus().catch(() => {}) + throw error + } + }) + } + + async materializeRecord(recordId) { + const record = await this.record(recordId) + const fields = { + environment: 'environment', + pristine: 'pristine', + runtime: 'runtime', + systemPlugins: 'system-plugins', + } + const references = {} + for (const [field, kind] of Object.entries(fields)) { + const reference = record[field] + if (reference.storage === 'store') { + references[field] = reference + continue + } + const source = await this.resolveReference(reference) + const id = `image-${kind}-${reference.sha256}` + const destination = this.storeAsset({ kind, id }) + if (!await exists(destination)) { + const temporary = `${destination}.${randomUUID()}.tmp` + try { + await cloneTree(source, temporary) + if (await hashTree(temporary) !== reference.sha256) throw new TrustError(`materialized ${kind} differs from Image Reference`) + await rename(temporary, destination) + } finally { + await rm(temporary, { recursive: true, force: true }) + } + } else if (await hashTree(destination) !== reference.sha256) { + throw new TrustError(`materialized ${kind} conflicts with Managed Store`) + } + references[field] = { storage: 'store', kind, id, sha256: reference.sha256 } + } + const content = { + schema: 1, + authority: record.authority, + targetSequence: record.targetSequence, + dshVersion: record.dshVersion, + environmentVersion: record.environmentVersion, + ...references, + receiptTokens: [...record.receiptTokens], + snapshotId: record.snapshotId, + } + return this.writeRecord({ ...content, id: deriveRecordId('deployment-record', content) }) + } + + async materializeCurrent() { + return this.exclusive(async () => { + const state = await this.state() + if (state.current === null) throw new TrustError('no current Deployment exists') + const current = await this.record(state.current) + const references = [current.environment, current.pristine, current.runtime, current.systemPlugins] + if (references.every(reference => reference.storage === 'store')) return current + const materialized = await this.materializeRecord(current.id) + await this.select(materialized.id) + await this.commit(materialized.id, state.previous) + return materialized + }) + } + + async resetCurrentRuntime({ pauseDsh, restartDsh }) { + return this.exclusive(async () => { + const state = await this.state() + if (state.current === null) throw new TrustError('no current Deployment exists') + const current = await this.record(state.current) + const [pristineRoot, environmentRoot] = await Promise.all([ + this.resolveReference(current.pristine), + this.resolveReference(current.environment), + ]) + const patchSet = await loadEnvironmentPatchSet(environmentRoot) + const stagingId = `runtime-reset-${randomUUID()}` + const versionsRoot = this.paths.runtimesRoot + const staging = join(versionsRoot, stagingId) + const destinationId = current.runtime.storage === 'store' + ? current.runtime.id + : `reset-${current.runtime.sha256}` + const destination = join(this.paths.runtimesRoot, destinationId) + let replacement + let paused = false + await this.publishStatus({ operation: 'runtime-reset-building' }) + try { + await buildRuntime({ + pristineRoot, + versionsRoot, + runtimeId: stagingId, + patchPaths: patchSet.paths, + }) + await this.publishStatus({ operation: 'runtime-reset-verifying' }) + const sha256 = await hashTree(staging) + if (sha256 !== current.runtime.sha256) { + throw new TrustError('rebuilt Runtime differs from the current Deployment Record') + } + + await this.publishStatus({ operation: 'runtime-reset-switching' }) + await pauseDsh() + paused = true + replacement = await replaceRuntimeDirectory(staging, destination) + const runtime = Object.freeze({ + storage: 'store', kind: 'runtime', id: destinationId, sha256, + }) + const content = { + schema: 1, + authority: current.authority, + targetSequence: current.targetSequence, + dshVersion: current.dshVersion, + environmentVersion: current.environmentVersion, + environment: current.environment, + pristine: current.pristine, + runtime, + systemPlugins: current.systemPlugins, + receiptTokens: [...current.receiptTokens], + snapshotId: current.snapshotId, + } + const repaired = current.runtime.storage === 'store' + ? current + : await this.writeRecord({ ...content, id: deriveRecordId('deployment-record', content) }) + await this.select(repaired.id) + await this.publishStatus({ operation: 'runtime-reset-starting' }) + await restartDsh() + const slots = repaired.id === current.id + ? state + : await this.commit(repaired.id, state.previous) + await replacement.commit() + await this.publishStatus({ recoveryMode: null }) + return Object.freeze({ recordId: repaired.id, slots }) + } catch (error) { + if (replacement !== undefined) await replacement.rollback().catch(() => {}) + if (paused) { + await this.publishStatus({ operation: 'runtime-reset-recovering' }).catch(() => {}) + await this.select(current.id).catch(() => {}) + await restartDsh().catch(() => {}) + } + await this.publishStatus({ operation: 'runtime-reset-failed' }).catch(() => {}) + throw error + } finally { + await rm(staging, { recursive: true, force: true }) + } + }) + } + + async activation() { + const bytes = await readOptional(this.activationPath) + if (bytes === undefined) return undefined + const value = JSON.parse(bytes.toString('utf8')) + if ( + value?.schema !== 1 + || !['prepared', 'healthy', 'probation', 'receipts-active'].includes(value.phase) + || typeof value.from !== 'string' + || typeof value.to !== 'string' + ) throw new TrustError('Deployment activation journal is invalid') + return Object.freeze(value) + } + + async writeActivation(value) { + const journal = Object.freeze({ schema: 1, ...value, updatedAt: new Date().toISOString() }) + await durableReplace(this.activationPath, canonicalJson(journal)) + return journal + } + + async clearActivation() { + await rm(this.activationPath, { force: true }) + } + + async recoverActivation(trust) { + const journal = await this.activation() + if (journal === undefined) return null + const [from, to, active] = await Promise.all([ + this.record(journal.from), + this.record(journal.to), + trust.activeReceipts(), + ]) + const activeTokens = new Set(active.receipts.map(receipt => receipt.token)) + const candidateReceiptsActive = to.receiptTokens.every(token => activeTokens.has(token)) + if (journal.phase === 'receipts-active' || (journal.phase === 'healthy' && candidateReceiptsActive)) { + await this.select(to.id) + const state = await this.state() + if (state.current !== to.id) await this.commit(to.id, from.id) + await this.clearActivation() + return Object.freeze({ action: 'committed', recordId: to.id }) + } + await trust.activate(from.receiptTokens) + await this.select(from.id) + const state = await this.state() + if (state.current !== from.id) await this.commit(from.id, state.current) + await this.clearActivation() + return Object.freeze({ action: 'rolled-back', recordId: from.id }) + } + + async activateManaged(value, { healthCheck, activateReceipts }) { + const candidate = await this.writeRecord(value) + await this.materializeCurrent() + const state = await this.state() + const from = await this.record(state.current) + if (candidate.id === from.id) return state + validateCandidate(from, candidate, false) + await this.writeActivation({ phase: 'prepared', from: from.id, to: candidate.id }) + await this.publishStatus({ operation: 'switching' }) + let receiptsActive = false + try { + await this.select(candidate.id) + await healthCheck() + await this.writeActivation({ phase: 'healthy', from: from.id, to: candidate.id }) + await activateReceipts(candidate.receiptTokens) + receiptsActive = true + await this.writeActivation({ phase: 'receipts-active', from: from.id, to: candidate.id }) + const committed = await this.commit(candidate.id, from.id) + await this.clearActivation() + await this.publishStatus() + return committed + } catch (error) { + await this.publishStatus({ operation: 'recovering' }).catch(() => {}) + if (receiptsActive) await activateReceipts(from.receiptTokens).catch(() => {}) + await this.select(from.id).catch(() => {}) + await healthCheck().catch(() => {}) + await this.clearActivation().catch(() => {}) + await this.publishStatus().catch(() => {}) + throw error + } + } + + async stageCandidate(value, healthCheck) { + const candidate = await this.writeRecord(value) + await this.materializeCurrent() + const state = await this.state() + const from = await this.record(state.current) + validateCandidate(from, candidate, true) + await this.writeActivation({ phase: 'prepared', from: from.id, to: candidate.id }) + try { + await this.select(candidate.id) + await healthCheck() + await this.writeActivation({ phase: 'probation', from: from.id, to: candidate.id }) + return candidate + } catch (error) { + await this.select(from.id).catch(() => {}) + await this.clearActivation().catch(() => {}) + throw error + } + } + + async commitCandidate(recordId, activateReceipts) { + const journal = await this.activation() + if (journal?.phase !== 'probation' || journal.to !== recordId) { + throw new TrustError('Deployment candidate is not in probation') + } + const candidate = await this.record(recordId) + await activateReceipts(candidate.receiptTokens) + await this.writeActivation({ phase: 'receipts-active', from: journal.from, to: journal.to }) + const committed = await this.commit(candidate.id, journal.from) + await this.clearActivation() + await this.publishStatus() + return committed + } + + async cancelCandidate() { + const journal = await this.activation() + if (journal === undefined) return Object.freeze({ cancelled: false, slots: await this.state() }) + const from = await this.record(journal.from) + await this.select(from.id) + await this.clearActivation() + await this.publishStatus() + return Object.freeze({ cancelled: true, slots: await this.state() }) + } + + async recoverImageBaseline(value, { healthCheck, activateReceipts }) { + const previousStatus = await readDeploymentStatus(this.paths.deploymentStatusPath) + await this.publishStatus({ operation: 'recovering', recoveryMode: previousStatus.recoveryMode }) + const image = await this.repairImageRecord(value) + await this.prepareView(image.id) + const state = await this.state() + await this.select(image.id) + try { + await healthCheck() + await activateReceipts(image.receiptTokens) + const committed = state.current === image.id ? state : await this.commit(image.id, state.current) + await this.clearActivation() + await this.publishStatus({ recoveryMode: null }) + return committed + } catch (error) { + if (state.current !== null) await this.select(state.current).catch(() => {}) + await this.publishStatus({ + recoveryMode: error instanceof Error ? error.message : 'image baseline recovery failed', + }).catch(() => {}) + throw error + } + } + + describe(record) { + const references = [record.environment, record.pristine, record.runtime, record.systemPlugins] + return Object.freeze({ + recordId: record.id, + source: references.every(reference => reference.storage === 'image') ? 'image' : 'managed', + authority: record.authority, + targetSequence: record.targetSequence, + dsh: record.dshVersion, + environment: record.environmentVersion, + }) + } + + async publishStatus({ plan = null, recoveryMode = null, currentId, operation = null } = {}) { + const state = await this.state().catch(() => ({ current: null })) + const selectedId = currentId === undefined ? state.current : currentId + const current = selectedId === null + ? null + : await this.record(selectedId).then(record => this.describe(record), () => null) + const value = Object.freeze({ + platformLayout: 1, + imageBaseline: Object.freeze({ + imageBuildId: this.inventory.imageBuildId, + targetSequence: this.inventory.targetSequence, + dsh: this.inventory.deployment.dshVersion, + environment: this.inventory.deployment.environmentVersion, + }), + current, + imageBehindCurrent: plan?.imageBehindCurrent + ?? (current !== null && current.targetSequence > this.inventory.targetSequence), + recoveryMode, + operation, + }) + return writeDeploymentStatus(this.paths.deploymentStatusPath, value) + } + + async setOperation(operation) { + const status = await readDeploymentStatus(this.paths.deploymentStatusPath) + return writeDeploymentStatus(this.paths.deploymentStatusPath, Object.freeze({ + ...status, + operation, + })) + } +} diff --git a/upstream/container/platform/bootstrap/lib/dsh-lifecycle.mjs b/upstream/container/platform/bootstrap/lib/dsh-lifecycle.mjs new file mode 100644 index 0000000..2ec0630 --- /dev/null +++ b/upstream/container/platform/bootstrap/lib/dsh-lifecycle.mjs @@ -0,0 +1,206 @@ +import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' +import { chmod, mkdir, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { dirname } from 'node:path' + +const MAX_BODY_BYTES = 4 * 1024 + +function exactObject(value, keys, name) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${name} must be an object`) + } + const actual = Object.keys(value).sort() + const expected = [...keys].sort() + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new Error(`${name} fields are invalid`) + } + return value +} + +async function jsonBody(request) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.byteLength + if (size > MAX_BODY_BYTES) throw new Error('lifecycle request body is too large') + chunks.push(chunk) + } + if (size === 0) throw new Error('lifecycle request body is required') + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function send(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(`${JSON.stringify(value)}\n`) +} + +function matchesSecret(actual, expected) { + if (typeof actual !== 'string' || typeof expected !== 'string') return false + const left = Buffer.from(actual) + const right = Buffer.from(expected) + return left.byteLength === right.byteLength && timingSafeEqual(left, right) +} + +export class DshLifecycleBroker { + constructor({ shouldTerminate = async () => false, report = async () => {} } = {}) { + this.shouldTerminate = shouldTerminate + this.report = report + this.launch = null + this.session = null + this.published = false + this.shuttingDown = false + } + + record(message, fields = {}) { + return Promise.resolve().then(() => this.report(message, fields)).catch(() => {}) + } + + prepareLaunch(componentId) { + if (componentId !== 'dsh-runtime') return Object.freeze({ environment: Object.freeze({}), release: () => {} }) + const launch = Object.freeze({ id: randomUUID(), token: randomBytes(32).toString('base64url') }) + this.launch = launch + this.session = null + this.published = false + void this.record('dsh.launch.authorized') + return Object.freeze({ + environment: Object.freeze({ DSH_PLATFORM_LAUNCH_TOKEN: launch.token }), + release: () => this.release(launch.id), + }) + } + + release(launchId) { + if (this.launch?.id !== launchId) return + this.launch = null + this.session = null + void this.record('dsh.launch.released') + } + + claim(launchToken) { + if (this.launch === null || this.session !== null || !matchesSecret(launchToken, this.launch.token)) { + throw Object.assign(new Error('DSH launch token is invalid or already consumed'), { statusCode: 409 }) + } + const sessionId = randomUUID() + this.session = Object.freeze({ id: sessionId, launchId: this.launch.id, ready: false, verified: false }) + this.launch = Object.freeze({ ...this.launch, token: null }) + void this.record('dsh.launch.claimed') + return Object.freeze({ sessionId }) + } + + ready(sessionId, readyUrl = null) { + if (this.session === null || !matchesSecret(sessionId, this.session.id)) { + throw Object.assign(new Error('DSH lifecycle session is invalid'), { statusCode: 409 }) + } + if (readyUrl !== null) { + let parsed + try { + parsed = new URL(readyUrl) + } catch { + throw Object.assign(new Error('DSH Web readiness URL is invalid'), { statusCode: 400 }) + } + if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' + || parsed.pathname !== '/' || parsed.username !== '' || parsed.password !== '' + || parsed.hash !== '' || parsed.searchParams.getAll('token').length !== 1) { + throw Object.assign(new Error('DSH Web readiness URL is invalid'), { statusCode: 400 }) + } + } + if (!this.session.ready) { + this.session = Object.freeze({ ...this.session, ready: true, readyUrl }) + void this.record('dsh.launch.ready') + } + return Object.freeze({ ready: true }) + } + + verify() { + if (this.session?.ready !== true) throw new Error('DSH lifecycle is not ready for verification') + if (!this.session.verified) { + this.session = Object.freeze({ ...this.session, verified: true }) + return this.record('dsh.launch.verified') + } + } + + publish() { + if (this.session?.verified !== true) throw new Error('DSH lifecycle is not verified for publication') + if (!this.published) { + this.published = true + return this.record('platform.public-ready') + } + } + + readiness() { + const ready = this.session?.ready === true + const verified = this.session?.verified === true + return Object.freeze({ + generation: this.session?.id ?? null, + publicReady: ready && verified && this.published, + ready, + readyUrl: ready ? this.session.readyUrl ?? null : null, + verified, + }) + } + + async signal(sessionId, signal) { + if (signal !== 'SIGTERM') throw Object.assign(new Error('lifecycle signal is invalid'), { statusCode: 400 }) + if (this.session === null || !matchesSecret(sessionId, this.session.id)) { + throw Object.assign(new Error('DSH lifecycle session is invalid'), { statusCode: 409 }) + } + const disposition = this.session.ready !== true || this.shuttingDown || await this.shouldTerminate() + ? 'terminate' + : 'request-restart' + await this.record('dsh.signal.disposition', { disposition, signal }) + return Object.freeze({ disposition }) + } + + beginShutdown() { + this.published = false + this.shuttingDown = true + } +} + +export function createDshLifecycleServer(broker) { + return createServer(async (request, response) => { + let pathname = 'invalid-url' + try { + pathname = new URL(request.url ?? '/', 'http://dsh-lifecycle.internal').pathname + if (request.method !== 'POST') { + send(response, 404, { error: 'not found' }) + return + } + if (pathname === '/v1/runtime/claim') { + const body = exactObject(await jsonBody(request), ['launchToken'], 'claim request') + send(response, 200, broker.claim(body.launchToken)) + } else if (pathname === '/v1/runtime/ready') { + const value = await jsonBody(request) + const body = Object.hasOwn(value, 'readyUrl') + ? exactObject(value, ['readyUrl', 'sessionId'], 'ready request') + : exactObject(value, ['sessionId'], 'ready request') + send(response, 200, broker.ready(body.sessionId, body.readyUrl ?? null)) + } else if (pathname === '/v1/runtime/readiness') { + exactObject(await jsonBody(request), [], 'readiness request') + send(response, 200, broker.readiness()) + } else if (pathname === '/v1/runtime/signal') { + const body = exactObject(await jsonBody(request), ['sessionId', 'signal'], 'signal request') + send(response, 200, await broker.signal(body.sessionId, body.signal)) + } else send(response, 404, { error: 'not found' }) + } catch (error) { + await broker.record('dsh.lifecycle-request.failed', { + error, + method: request.method ?? null, + pathname, + level: error?.statusCode === 409 ? 'warning' : 'error', + }) + send(response, Number.isInteger(error?.statusCode) ? error.statusCode : 400, { + error: error instanceof Error ? error.message : 'invalid lifecycle request', + }) + } + }) +} + +export async function listenDshLifecycle(server, socketPath) { + await mkdir(dirname(socketPath), { recursive: true }) + await rm(socketPath, { force: true }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + await chmod(socketPath, 0o600) +} diff --git a/upstream/container/platform/bootstrap/lib/gc.mjs b/upstream/container/platform/bootstrap/lib/gc.mjs new file mode 100644 index 0000000..f7b4768 --- /dev/null +++ b/upstream/container/platform/bootstrap/lib/gc.mjs @@ -0,0 +1,148 @@ +import { lstat, readFile, readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { parseDeploymentRecord } from '../../lib/deployment-contracts.mjs' + +async function jsonOptional(path) { + try { return JSON.parse(await readFile(path, 'utf8')) } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } +} + +async function files(root, suffix = '') { + try { return (await readdir(root)).filter(name => !name.startsWith('.') && name.endsWith(suffix)).sort() } catch (error) { + if (error?.code === 'ENOENT') return [] + throw error + } +} + +async function removeUnmarked(root, retained, reserved = new Set()) { + const removed = [] + for (const name of await files(root)) { + if (reserved.has(name) || retained.has(name)) continue + const path = join(root, name) + const details = await lstat(path) + if (!details.isDirectory() || details.isSymbolicLink()) continue + await rm(path, { recursive: true, force: true }) + removed.push(name) + } + return removed +} + +async function deploymentRecords(root) { + const records = new Map() + for (const name of await files(root, '.json')) { + const record = parseDeploymentRecord(await readFile(join(root, name))) + records.set(record.id, record) + } + return records +} + +function recordIds(value, output) { + if (typeof value === 'string' && value.startsWith('deployment-record-')) output.add(value) + else if (Array.isArray(value)) for (const entry of value) recordIds(entry, output) + else if (value !== null && typeof value === 'object') for (const entry of Object.values(value)) recordIds(entry, output) +} + +function heldRecords(channel, records, roots) { + const held = [...(channel?.holds ?? []), ...(channel?.experimentalBlocked === null || channel?.experimentalBlocked === undefined + ? [] + : [channel.experimentalBlocked])] + for (const hold of held) { + for (const record of records.values()) { + if ( + record.dshVersion === hold.dshVersion + && (hold.environmentVersion === null || hold.environmentVersion === undefined || record.environmentVersion === hold.environmentVersion) + ) roots.add(record.id) + } + } +} + +export class PlatformGarbageCollector { + constructor({ paths, deployments, snapshots }) { + this.paths = paths + this.deployments = deployments + this.snapshots = snapshots + } + + async removeSnapshots(retained) { + if (this.snapshots?.remove === undefined) throw new Error('privileged snapshot cleanup is unavailable') + const removed = [] + for (const name of await files(join(this.paths.snapshotsRoot, 'versions'))) { + if (retained.has(name)) continue + const details = await lstat(join(this.paths.snapshotsRoot, 'versions', name)) + if (!details.isDirectory() || details.isSymbolicLink()) continue + if (await this.snapshots.remove(name)) removed.push(name) + } + return removed + } + + async roots(records) { + const roots = new Set() + const slots = await this.deployments.state() + if (slots.current !== null) roots.add(slots.current) + if (slots.previous !== null) roots.add(slots.previous) + recordIds(await jsonOptional(this.deployments.activationPath), roots) + const transaction = await jsonOptional(join(this.paths.updaterStateRoot, 'transaction.json')) + recordIds(transaction, roots) + heldRecords(await jsonOptional(join(this.paths.updaterStateRoot, 'channel.json')), records, roots) + return { roots, transaction } + } + + async collectDeployments() { + const records = await deploymentRecords(this.deployments.recordsRoot) + const { roots, transaction } = await this.roots(records) + const retained = { + environments: new Set(), + pristine: new Set(), + runtimes: new Set(), + systemPlugins: new Set(), + snapshots: new Set(), + } + for (const id of roots) { + const record = records.get(id) + if (record === undefined) continue + for (const [field, output] of [ + ['environment', retained.environments], + ['pristine', retained.pristine], + ['runtime', retained.runtimes], + ['systemPlugins', retained.systemPlugins], + ]) if (record[field].storage === 'store') output.add(record[field].id) + if (record.snapshotId !== null) retained.snapshots.add(record.snapshotId) + } + if (typeof transaction?.snapshotId === 'string') retained.snapshots.add(transaction.snapshotId) + + const reserved = new Set(['current', 'previous', 'versions']) + const removed = { + environments: await removeUnmarked(this.paths.environmentsRoot, retained.environments, reserved), + pristine: await removeUnmarked(this.paths.pristineRoot, retained.pristine, reserved), + runtimes: await removeUnmarked(this.paths.runtimesRoot, retained.runtimes, reserved), + systemPlugins: await removeUnmarked(this.paths.systemPluginsRoot, retained.systemPlugins, reserved), + snapshots: await this.removeSnapshots(retained.snapshots), + records: [], + } + for (const [id] of records) { + if (!roots.has(id)) { + await rm(join(this.deployments.recordsRoot, `${id}.json`), { force: true }) + removed.records.push(id) + } + } + return removed + } + + async cleanCache() { + const removed = [] + for (const name of await files(this.paths.downloadsRoot)) { + await rm(join(this.paths.downloadsRoot, name), { recursive: true, force: true }) + removed.push(name) + } + return removed + } + + async collect() { + return Object.freeze({ + deployments: await this.collectDeployments(), + cache: await this.cleanCache(), + }) + } +} diff --git a/upstream/container/platform/bootstrap/lib/lifecycle.mjs b/upstream/container/platform/bootstrap/lib/lifecycle.mjs new file mode 100644 index 0000000..80da8ab --- /dev/null +++ b/upstream/container/platform/bootstrap/lib/lifecycle.mjs @@ -0,0 +1,572 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { request } from 'node:http' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { artifactForReference, parseComponentManifest, parseEnvironmentManifest } from '../../lib/contracts.mjs' +import { exactKeys, parseJsonDocument, plainObject, TrustError } from '../../lib/validation.mjs' + +const STARTUP_HEALTH_RETRY_MS = 100 + +function delay(milliseconds) { + return new Promise(resolveDelay => { + const timer = setTimeout(resolveDelay, milliseconds) + timer.unref?.() + }) +} + +function terminate(child, graceMs = 5_000) { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() + return new Promise(resolveTerminate => { + const timer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') + }, graceMs) + child.once('exit', () => { + clearTimeout(timer) + resolveTerminate() + }) + child.kill('SIGTERM') + }) +} + +function runCommand(spec, options, captureOutput = true) { + return new Promise((resolveCommand, reject) => { + const child = options.spawnImpl(spec.executable, spec.args, { + env: options.environment, + stdio: ['ignore', 'pipe', 'pipe'], + }) + if (captureOutput) options.capture(child, options.componentId, options.logging) + else { + child.stdout?.resume() + child.stderr?.resume() + } + const timer = setTimeout(() => child.kill('SIGKILL'), spec.timeoutSeconds * 1000) + child.once('error', reject) + child.once('exit', (code, signal) => { + clearTimeout(timer) + if (code === 0) resolveCommand() + else reject(new Error(`${options.componentId} command failed (code=${String(code)}, signal=${String(signal)})`)) + }) + }) +} + +function probeHttp(health) { + return new Promise(resolveProbe => { + const req = request({ hostname: health.host, port: health.port, path: health.path }, response => { + response.resume() + resolveProbe((response.statusCode ?? 500) < 500) + }) + req.setTimeout(Math.min(health.intervalSeconds * 1000, 1_000), () => req.destroy()) + req.once('error', () => resolveProbe(false)) + req.end() + }) +} + +async function waitForHealth(component, running, options) { + if (component.health === null) return + const deadline = Date.now() + component.health.timeoutSeconds * 1000 + while (Date.now() < deadline) { + if (running?.child.exitCode !== null) throw new Error(`${component.id} exited before health check passed`) + const healthy = component.health.type === 'http' + ? await probeHttp(component.health) + : await runCommand(component.health.command, options, false).then(() => true, () => false) + if (healthy) return + await delay(Math.min(component.health.intervalSeconds * 1000, STARTUP_HEALTH_RETRY_MS)) + } + if (running?.child !== undefined && running.child.exitCode !== null) { + throw new Error(`${component.id} exited before health check passed`) + } + throw new Error(`${component.id} health check timed out`) +} + +function defaultCapture(child, componentId) { + for (const [stream, source] of [[child.stdout, 'stdout'], [child.stderr, 'stderr']]) { + stream?.on('data', chunk => process.stdout.write(`[${componentId}:${source}] ${chunk.toString()}`)) + } +} + +export class ComponentExitError extends Error { + constructor(componentId, code, signal) { + super(`${componentId} exited unexpectedly (code=${String(code)}, signal=${String(signal)})`) + this.name = 'ComponentExitError' + this.componentId = componentId + this.exitCode = code + this.signal = signal + } +} + +export async function loadEnvironment(root) { + const environmentRoot = resolve(root) + const manifest = parseEnvironmentManifest(await readFile(join(environmentRoot, 'environment.manifest.json'))) + const components = [] + for (const reference of manifest.components) { + const artifact = artifactForReference(manifest, reference) + const component = parseComponentManifest(await readFile(join(environmentRoot, 'artifacts', artifact.id))) + if (component.id !== reference.id) { + throw new Error(`component ${reference.id} differs from the Environment manifest`) + } + components.push(component) + } + return Object.freeze({ root: environmentRoot, manifest, components: Object.freeze(components) }) +} + +export async function loadControlPlane(root) { + const controlPlaneRoot = resolve(root) + const definition = parseJsonDocument(await readFile(join(controlPlaneRoot, 'definition.json')), 'control plane') + exactKeys(definition, ['components', 'schema'], 'control plane') + if (definition.schema !== 1) throw new TrustError('control plane schema must be 1') + if (!Array.isArray(definition.components) || definition.components.length === 0) { + throw new TrustError('control plane components must be a non-empty array') + } + const components = [] + const ids = new Set() + for (const [index, value] of definition.components.entries()) { + const reference = plainObject(value, `control plane components[${String(index)}]`) + exactKeys(reference, ['id', 'source'], `control plane components[${String(index)}]`) + if (typeof reference.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(reference.id)) { + throw new TrustError('control plane component ID is invalid') + } + if (ids.has(reference.id)) throw new TrustError('control plane component IDs must be unique') + if (typeof reference.source !== 'string' || reference.source.startsWith('/')) { + throw new TrustError('control plane component source is invalid') + } + const source = resolve(controlPlaneRoot, reference.source) + if (source === controlPlaneRoot || !source.startsWith(`${controlPlaneRoot}/`)) { + throw new TrustError('control plane component source escapes its root') + } + const component = parseComponentManifest(await readFile(source)) + if (component.id !== reference.id) throw new TrustError(`control plane component ${reference.id} differs from its manifest`) + ids.add(reference.id) + components.push(component) + } + return Object.freeze({ + root: controlPlaneRoot, + manifest: Object.freeze({ version: null }), + components: Object.freeze(components), + }) +} + +export class EnvironmentRunner { + constructor({ + environmentRoot, + spawnImpl = spawn, + capture = defaultCapture, + loader = loadEnvironment, + prepareService = async () => ({ environment: {}, release: () => {} }), + recoverableComponents = [], + recoveryDelaysMs = [0, 2_000, 5_000], + report = () => {}, + startupGroups = null, + }) { + if (!Array.isArray(recoverableComponents) + || recoverableComponents.some(value => typeof value !== 'string' || value.length === 0)) { + throw new Error('Recoverable component IDs must be non-empty strings') + } + if (!Array.isArray(recoveryDelaysMs) || recoveryDelaysMs.length === 0 + || recoveryDelaysMs.some(value => !Number.isFinite(value) || value < 0)) { + throw new Error('Component recovery delays must be non-negative durations') + } + if (startupGroups !== null && (!Array.isArray(startupGroups) || startupGroups.length === 0 + || startupGroups.some(group => !Array.isArray(group) || group.length === 0 + || group.some(value => typeof value !== 'string' || value.length === 0)))) { + throw new Error('Startup groups must contain non-empty component ID lists') + } + this.environmentRoot = environmentRoot + this.spawnImpl = spawnImpl + this.capture = capture + this.loader = loader + this.prepareService = prepareService + this.recoverableComponents = new Set(recoverableComponents) + this.recoveryDelaysMs = Object.freeze([...recoveryDelaysMs]) + this.report = report + this.startupGroups = startupGroups === null ? null : Object.freeze( + startupGroups.map(group => Object.freeze([...group])), + ) + this.running = [] + this.environment = undefined + this.operation = Promise.resolve() + this.stopping = false + this.componentRecoveries = new Map() + this.fatalListeners = new Set() + this.fatal = new Promise(resolveFatal => { this.resolveFatal = resolveFatal }) + } + + onFatal(listener) { + this.fatalListeners.add(listener) + return () => this.fatalListeners.delete(listener) + } + + emitFatal(error) { + this.resolveFatal(error) + for (const listener of this.fatalListeners) { + try { listener(error) } catch {} + } + } + + emitLifecycle(message, fields = {}) { + void Promise.resolve().then(() => this.report(message, fields)).catch(() => {}) + } + + serialized(operation) { + const result = this.operation.then(operation, operation) + this.operation = result.then(() => undefined, () => undefined) + return result + } + + recoverable(component) { + return this.recoverableComponents.has(component.id) && component.type === 'service' + } + + scheduleRecovery(component, originalError) { + if (this.stopping || !this.recoverable(component) || this.componentRecoveries.has(component.id)) return + const recover = () => this.recoverComponentUnlocked(component, originalError) + const pending = this.serialized(recover).finally(() => { + if (this.componentRecoveries.get(component.id) === pending) this.componentRecoveries.delete(component.id) + }) + this.componentRecoveries.set(component.id, pending) + void pending.catch(() => {}) + } + + async recoverComponentUnlocked(component, originalError) { + const stale = this.running.find(value => value.component.id === component.id) + if (stale !== undefined) { + try { await this.stopComponentUnlocked(stale) } catch {} + } + for (let index = 0; index < this.recoveryDelaysMs.length; index += 1) { + if (this.stopping) return + const delayMs = this.recoveryDelaysMs[index] + if (delayMs > 0) await delay(delayMs) + if (this.stopping) return + const attempt = index + 1 + this.emitLifecycle('component.recovery.started', { + componentId: component.id, + attempt, + maxAttempts: this.recoveryDelaysMs.length, + }) + try { + await this.startComponentUnlocked(component, true) + this.emitLifecycle('component.recovery.completed', { + componentId: component.id, + attempt, + maxAttempts: this.recoveryDelaysMs.length, + }) + return + } catch (error) { + this.emitLifecycle('component.recovery.failed', { + componentId: component.id, + attempt, + maxAttempts: this.recoveryDelaysMs.length, + error: error instanceof Error ? error.message : String(error), + level: 'warning', + }) + } + } + this.emitLifecycle('component.isolated', { + componentId: component.id, + error: originalError instanceof Error ? originalError.message : String(originalError), + level: 'error', + }) + } + + commandOptions(component) { + return { + spawnImpl: this.spawnImpl, + capture: this.capture, + componentId: component.id, + logging: component.logging, + environment: { ...process.env, ...component.environment }, + } + } + + async phase(component, name) { + const spec = component.lifecycle[name] + if (spec !== null) await runCommand(spec, this.commandOptions(component)) + } + + startupSequence() { + if (this.startupGroups === null) return this.environment.components.map(component => [component]) + const components = new Map(this.environment.components.map(component => [component.id, component])) + const seen = new Set() + const groups = this.startupGroups.map(group => group.map(componentId => { + const component = components.get(componentId) + if (component === undefined) throw new Error(`Startup group component ${componentId} does not exist`) + if (seen.has(componentId)) throw new Error(`Startup group component ${componentId} is duplicated`) + seen.add(componentId) + return component + })) + const missing = this.environment.components.filter(component => !seen.has(component.id)) + if (missing.length > 0) { + throw new Error(`Startup groups omit components: ${missing.map(component => component.id).join(', ')}`) + } + return groups + } + + start() { + return this.serialized(() => this.startUnlocked()) + } + + async startUnlocked() { + if (this.running.length > 0) throw new Error('Environment is already running') + this.environment = await this.loader(this.environmentRoot) + try { + const startupGroups = this.startupSequence() + const recoveries = [] + for (const component of this.environment.components) await this.phase(component, 'prepare') + for (const group of startupGroups) { + const results = await Promise.allSettled(group.map(component => this.startComponentUnlocked(component))) + const failures = [] + for (const [index, result] of results.entries()) { + if (result.status === 'fulfilled') continue + const component = group[index] + if (!this.recoverable(component)) { + failures.push(result.reason) + continue + } + this.emitLifecycle('component.isolated', { + componentId: component.id, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + level: 'error', + }) + recoveries.push({ component, error: result.reason }) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'Startup group failed') + } + for (const recovery of recoveries) this.scheduleRecovery(recovery.component, recovery.error) + return this.status() + } catch (error) { + try { + await this.stopUnlocked() + } catch (cleanupError) { + this.emitLifecycle('environment.cleanup.failed', { + error: cleanupError, + level: 'warning', + originalError: error instanceof Error ? error.message : String(error), + }) + } + throw error + } + } + + async startComponentUnlocked(component, prepare = false) { + if (this.running.some(running => running.component.id === component.id)) { + throw new Error(`component ${component.id} is already running`) + } + const startedAt = Date.now() + this.emitLifecycle('component.starting', { componentId: component.id, componentType: component.type }) + let running + let releaseService = () => {} + try { + if (prepare) await this.phase(component, 'prepare') + await this.phase(component, 'preStart') + if (component.type === 'service') { + const options = this.commandOptions(component) + const launch = await this.prepareService(component) + let released = false + const release = () => { + if (released) return + released = true + launch.release?.() + } + releaseService = release + const child = this.spawnImpl(component.command.executable, component.command.args, { + env: { ...options.environment, ...launch.environment }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + this.capture(child, component.id, component.logging) + await Promise.race([ + once(child, 'spawn'), + once(child, 'error').then(([error]) => { throw error }), + ]) + running = { component, child, ready: false, release } + this.emitLifecycle('component.spawned', { componentId: component.id, pid: child.pid ?? null }) + child.once('exit', (code, signal) => { + release() + if (running.ready && !this.stopping && this.running.includes(running)) { + const error = new ComponentExitError(component.id, code, signal) + this.emitLifecycle('component.exited', { + componentId: component.id, + code, + signal, + error: error.message, + level: 'error', + }) + if (this.recoverable(component)) this.scheduleRecovery(component, error) + else this.emitFatal(error) + } + }) + } else { + await runCommand(component.command, this.commandOptions(component)) + running = { component, child: undefined } + } + this.running.push(running) + this.running.sort((left, right) => ( + this.environment.components.indexOf(left.component) - this.environment.components.indexOf(right.component) + )) + await waitForHealth(component, running, this.commandOptions(component)) + await this.phase(component, 'postStart') + if (running.child !== undefined && running.child.exitCode !== null) { + throw new Error(`${component.id} exited before startup completed`) + } + running.ready = true + this.emitLifecycle('component.ready', { componentId: component.id, elapsedMs: Date.now() - startedAt }) + return running + } catch (error) { + this.emitLifecycle('component.start.failed', { + componentId: component.id, + elapsedMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : String(error), + level: 'error', + }) + if (running !== undefined) { + try { + await this.stopComponentUnlocked(running) + } catch (cleanupError) { + this.emitLifecycle('component.cleanup.failed', { + componentId: component.id, + error: cleanupError, + level: 'warning', + originalError: error instanceof Error ? error.message : String(error), + }) + } + } else if (component.type === 'service') { + releaseService() + } + throw error + } + } + + async stopComponentUnlocked(running) { + const { component, child } = running + const failures = [] + this.emitLifecycle('component.stopping', { componentId: component.id, pid: child?.pid ?? null }) + this.running = this.running.filter(value => value !== running) + for (const phase of ['preStop', 'stop']) { + try { await this.phase(component, phase) } catch (error) { failures.push(error) } + } + if (child !== undefined) { + try { await terminate(child) } catch (error) { failures.push(error) } + } + try { await this.phase(component, 'postStop') } catch (error) { failures.push(error) } + if (failures.length > 0) { + this.emitLifecycle('component.stop.failed', { + componentId: component.id, + errors: failures.map(error => error instanceof Error ? error.message : String(error)), + level: 'error', + }) + throw new AggregateError(failures, `component ${component.id} shutdown failed`) + } + this.emitLifecycle('component.stopped', { componentId: component.id }) + } + + stop() { + this.stopping = true + return this.serialized(() => this.stopUnlocked()) + } + + async stopUnlocked() { + this.stopping = true + const failures = [] + for (const running of [...this.running].reverse()) { + try { await this.stopComponentUnlocked(running) } catch (error) { failures.push(error) } + } + this.running = [] + this.stopping = false + if (failures.length > 0) throw new AggregateError(failures, 'Environment shutdown failed') + } + + reload() { + return this.serialized(async () => { + await this.stopUnlocked() + return this.startUnlocked() + }) + } + + suspend(componentId) { + return this.serialized(async () => { + const running = this.running.find(value => value.component.id === componentId) + if (running === undefined) throw new Error(`component ${componentId} is not running`) + if (running.component.type !== 'service') throw new Error(`component ${componentId} is not a service`) + await this.stopComponentUnlocked(running) + return this.status() + }) + } + + pause(componentId, { allowMissing = false } = {}) { + return this.serialized(async () => { + const running = this.running.find(value => value.component.id === componentId) + const component = running?.component ?? this.environment?.components.find(value => value.id === componentId) + if (component === undefined) { + if (allowMissing) return this.status() + throw new Error(`component ${componentId} does not exist`) + } + if (component.type !== 'service') throw new Error(`component ${componentId} is not a service`) + if (running === undefined) return this.status() + await this.stopComponentUnlocked(running) + return this.status() + }) + } + + resume(componentId) { + return this.serialized(async () => { + if (this.environment === undefined) throw new Error('Environment is not loaded') + const component = this.environment.components.find(value => value.id === componentId) + if (component === undefined) throw new Error(`component ${componentId} does not exist`) + if (this.running.some(value => value.component.id === componentId)) return this.status() + await this.startComponentUnlocked(component, true) + return this.status() + }) + } + + restart(componentId, { beforeStart, recoverStart = false, onStartFailure } = {}) { + return this.serialized(async () => { + if (this.environment === undefined) this.environment = await this.loader(this.environmentRoot) + const running = this.running.find(value => value.component.id === componentId) + const component = running?.component + ?? this.environment.components.find(value => value.id === componentId) + if (component === undefined) throw new Error(`component ${componentId} does not exist`) + if (component.type !== 'service') throw new Error(`component ${componentId} is not a service`) + if (running !== undefined) await this.stopComponentUnlocked(running) + try { + await beforeStart?.() + await this.startComponentUnlocked(component, true) + return this.status() + } catch (error) { + const failures = [error] + try { await onStartFailure?.(error) } catch (recoveryError) { failures.push(recoveryError) } + if (recoverStart && failures.length === 1) { + try { await this.startComponentUnlocked(component, true) } catch (recoveryError) { failures.push(recoveryError) } + } + if (failures.length > 1) throw new AggregateError(failures, `component ${componentId} restart recovery failed`) + throw error + } + }) + } + + health() { + return this.serialized(async () => { + const components = await Promise.all((this.environment?.components ?? []).map(async component => { + const running = this.running.find(value => value.component.id === component.id) + if (running === undefined) return { id: component.id, running: false, healthy: false } + const healthy = component.health === null + ? running.child === undefined || running.child.exitCode === null + : component.health.type === 'http' + ? await probeHttp(component.health) + : await runCommand(component.health.command, this.commandOptions(component), false).then(() => true, () => false) + return { id: component.id, running: true, healthy } + })) + return { healthy: components.length > 0 && components.every(component => component.healthy), components } + }) + } + + status() { + return Object.freeze({ + environmentVersion: this.environment?.manifest.version ?? null, + components: this.running.map(({ component, child }) => ({ + id: component.id, + type: component.type, + pid: child?.pid ?? null, + })), + }) + } +} diff --git a/upstream/container/platform/bootstrap/lib/runtime.mjs b/upstream/container/platform/bootstrap/lib/runtime.mjs new file mode 100644 index 0000000..646a206 --- /dev/null +++ b/upstream/container/platform/bootstrap/lib/runtime.mjs @@ -0,0 +1,347 @@ +export class BootstrapRuntime { + constructor({ + controlPlane, + environment, + validateDeployment = async () => {}, + prepareDeployment = async () => {}, + beforeReady = async () => {}, + onEnvironmentVerified = async () => {}, + onDshAvailable = async () => {}, + onEnvironmentFatal = async () => {}, + onDshRecovered = async () => {}, + ownsDshLifecycle = async () => false, + recoveryDelaysMs = [0, 2_000, 5_000], + sleep = (milliseconds, signal) => new Promise(resolve => { + if (milliseconds === 0 || signal?.aborted) return resolve() + const timer = setTimeout(resolve, milliseconds) + timer.unref?.() + signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true }) + }), + report = async () => {}, + }) { + if (!Array.isArray(recoveryDelaysMs) || recoveryDelaysMs.length !== 3 + || recoveryDelaysMs.some(value => !Number.isFinite(value) || value < 0)) { + throw new Error('DSH recovery delays must contain three non-negative durations') + } + this.controlPlane = controlPlane + this.environment = environment + this.fatal = controlPlane.fatal + this.recoveryMode = null + this.startupComplete = false + this.dshLifecycle = Object.freeze({ + state: 'starting', action: 'start', taskId: null, attempt: 0, maxAttempts: 3, + error: null, updatedAt: new Date().toISOString(), + }) + this.recovery = Promise.resolve() + this.recoveryAbort = new AbortController() + this.validateDeployment = validateDeployment + this.prepareDeployment = prepareDeployment + this.beforeReady = beforeReady + this.onEnvironmentVerified = onEnvironmentVerified + this.onDshAvailable = onDshAvailable + this.onEnvironmentFatal = onEnvironmentFatal + this.onDshRecovered = onDshRecovered + this.ownsDshLifecycle = ownsDshLifecycle + this.recoveryDelaysMs = Object.freeze([...recoveryDelaysMs]) + this.sleep = sleep + this.report = report + const handleEnvironmentFatal = error => { + const recover = () => this.handleEnvironmentFatal(error) + this.recovery = this.recovery.then(recover, recover).catch(recoveryError => this.record( + 'environment.recovery.failed', { error: recoveryError, originalError: String(error) }, + )) + } + if (typeof environment.onFatal === 'function') { + this.offEnvironmentFatal = environment.onFatal(handleEnvironmentFatal) + } else { + void environment.fatal.then(handleEnvironmentFatal) + this.offEnvironmentFatal = undefined + } + } + + async lifecycleOwned() { + try { + return await this.ownsDshLifecycle() + } catch (error) { + await this.record('dsh.recovery-ownership.failed', { error, level: 'warning' }) + return true + } + } + + async handleEnvironmentFatal(error) { + if (error?.componentId !== 'dsh-runtime') return this.isolateEnvironmentFailure(error) + const message = error instanceof Error ? error.message : String(error) + if (await this.lifecycleOwned()) { + this.publishDshLifecycle({ state: 'recovering', action: 'auto-recover', attempt: 0, error: message }) + await this.record('dsh.recovery.deferred', { error, reason: 'deployment-transaction-active', level: 'warning' }) + return + } + for (let index = 0; index < this.recoveryDelaysMs.length; index += 1) { + const attempt = index + 1 + await this.sleep(this.recoveryDelaysMs[index], this.recoveryAbort.signal) + if (this.recoveryAbort.signal.aborted) return + if (await this.lifecycleOwned()) { + this.publishDshLifecycle({ state: 'recovering', action: 'auto-recover', attempt: attempt - 1, error: message }) + await this.record('dsh.recovery.deferred', { error, reason: 'deployment-transaction-active', level: 'warning' }) + return + } + this.publishDshLifecycle({ state: 'recovering', action: 'auto-recover', attempt, error: message }) + await this.record('dsh.recovery.attempt.started', { attempt, maxAttempts: this.recoveryDelaysMs.length }) + try { + await this.validateDeployment() + await this.prepareDeployment() + await this.environment.restart('dsh-runtime') + await this.onEnvironmentVerified() + this.recoveryMode = null + this.publishDshLifecycle({ state: 'running', action: null, attempt: 0, error: null }) + let recoveryStatusPublished = true + try { + await this.onDshRecovered() + } catch (statusError) { + recoveryStatusPublished = false + await this.record('dsh.recovery-status.failed', { error: statusError, level: 'warning' }) + } + if (recoveryStatusPublished) await this.onDshAvailable() + await this.record('dsh.recovery.completed', { attempt, maxAttempts: this.recoveryDelaysMs.length }) + return + } catch (recoveryError) { + await this.record('dsh.recovery.attempt.failed', { + attempt, + error: recoveryError, + maxAttempts: this.recoveryDelaysMs.length, + level: 'warning', + }) + } + } + await this.isolateEnvironmentFailure(error) + } + + async isolateEnvironmentFailure(error) { + this.recoveryMode = error instanceof Error ? error.message : String(error) + this.publishDshLifecycle({ + state: 'failed', action: null, + attempt: error?.componentId === 'dsh-runtime' ? this.recoveryDelaysMs.length : 0, + error: this.recoveryMode, + }) + try { + await this.onEnvironmentFatal(error) + } catch (reportError) { + await this.record('environment.recovery-report.failed', { error: reportError, originalError: String(error) }) + } + try { + await this.environment.stop() + } catch (stopError) { + await this.record('environment.recovery-stop.failed', { error: stopError, originalError: String(error) }) + } + } + + record(message, fields = {}) { + return Promise.resolve().then(() => this.report(message, fields)).catch(() => {}) + } + + publishDshLifecycle(value) { + this.dshLifecycle = Object.freeze({ + ...this.dshLifecycle, + ...value, + updatedAt: new Date().toISOString(), + }) + return this.dshLifecycle + } + + async stopControlPlane(phase, cause) { + try { + await this.controlPlane.stop() + } catch (error) { + await this.record('control-plane.cleanup.failed', { + error, + phase, + originalError: cause instanceof Error ? cause.message : String(cause), + }) + } + } + + async stopEnvironment(phase, cause) { + try { + await this.environment.stop() + } catch (error) { + await this.record('environment.cleanup.failed', { + error, + phase, + originalError: cause instanceof Error ? cause.message : String(cause), + }) + } + } + + async startEnvironment({ onEnvironmentFailure, allowRecovery }) { + try { + await this.validateDeployment() + await this.prepareDeployment() + await this.environment.start() + await this.onEnvironmentVerified() + } catch (error) { + let retry + try { + retry = await onEnvironmentFailure?.(error) + } catch (recoveryError) { + const failure = new AggregateError([error, recoveryError], 'Deployment failed and its fallback could not be resolved') + if (allowRecovery) { + this.recoveryMode = failure.message + return + } + throw failure + } + if (retry === true) { + try { + await this.validateDeployment() + await this.prepareDeployment() + await this.environment.start() + await this.onEnvironmentVerified() + } catch (fallbackError) { + const failure = new AggregateError([error, fallbackError], 'Deployment candidate and fallback both failed') + if (allowRecovery) this.recoveryMode = failure.message + else throw failure + } + } else if (allowRecovery) { + this.recoveryMode = error instanceof Error ? error.message : 'Deployment failed to start' + } else throw error + } + } + + async start({ onEnvironmentFailure, allowRecovery = false } = {}) { + const readiness = Promise.resolve().then(async () => { + await this.controlPlane.start() + await this.beforeReady() + }) + const environment = Promise.resolve().then(() => this.startEnvironment({ onEnvironmentFailure, allowRecovery })) + const results = await Promise.allSettled([readiness, environment]) + const failures = results.filter(result => result.status === 'rejected').map(result => result.reason) + if (failures.length > 0) { + const failure = failures.length === 1 ? failures[0] : new AggregateError(failures, 'Platform startup failed') + await Promise.all([ + this.stopEnvironment('concurrent-start', failure), + this.stopControlPlane('concurrent-start', failure), + ]) + throw failure + } + return this.status() + } + + async stop() { + this.offEnvironmentFatal?.() + this.recoveryAbort.abort() + await this.recovery + const failures = [] + try { await this.environment.stop() } catch (error) { failures.push(error) } + try { await this.controlPlane.stop() } catch (error) { failures.push(error) } + if (failures.length > 0) throw new AggregateError(failures, 'Bootstrap shutdown failed') + } + + async reload() { + await this.validateDeployment() + await this.prepareDeployment() + const status = await this.environment.reload() + await this.onEnvironmentVerified() + this.recoveryMode = null + if (this.environment.status().components.some(value => value.id === 'dsh-runtime')) { + this.publishDshLifecycle({ state: 'running', action: null, attempt: 0, error: null }) + await this.onDshAvailable() + } + return status + } + async suspend(componentId) { + if (componentId !== 'dsh-runtime') return this.environment.suspend(componentId) + this.publishDshLifecycle({ state: 'stopping', action: 'stop', attempt: 0, error: null }) + try { + const status = await this.environment.suspend(componentId) + this.publishDshLifecycle({ state: 'stopped', action: null, attempt: 0, error: null }) + return status + } catch (error) { + this.publishDshLifecycle({ + state: 'failed', action: null, + error: error instanceof Error ? error.message : String(error), + }) + throw error + } + } + async pause(componentId) { + if (componentId !== 'dsh-runtime') return this.environment.pause(componentId) + this.publishDshLifecycle({ state: 'stopping', action: 'stop', attempt: 0, error: null }) + try { + const status = await this.environment.pause(componentId, { allowMissing: true }) + this.publishDshLifecycle({ state: 'stopped', action: null, attempt: 0, error: null }) + return status + } catch (error) { + this.publishDshLifecycle({ + state: 'failed', action: null, + error: error instanceof Error ? error.message : String(error), + }) + throw error + } + } + async resume(componentId, options) { + if (componentId === 'dsh-runtime') this.publishDshLifecycle({ state: 'starting', action: 'start', attempt: 0, error: null }) + try { + if (componentId === 'dsh-runtime') { + if (options?.skipValidation !== true) await this.validateDeployment() + if (options?.skipPreparation !== true) await this.prepareDeployment() + } + const status = await this.environment.resume(componentId) + if (componentId === 'dsh-runtime') { + await this.onEnvironmentVerified() + this.recoveryMode = null + this.publishDshLifecycle({ state: 'running', action: null, attempt: 0, error: null }) + await this.onDshAvailable() + } + return status + } catch (error) { + if (componentId === 'dsh-runtime') this.enterRecovery(error) + throw error + } + } + async restart(componentId, options) { + if (componentId === 'dsh-runtime') this.publishDshLifecycle({ state: 'restarting', action: 'restart', attempt: 0, error: null }) + try { + if (componentId === 'dsh-runtime') { + if (options?.skipValidation !== true) await this.validateDeployment() + if (options?.skipPreparation !== true) await this.prepareDeployment() + } + const status = await this.environment.restart(componentId, options) + if (componentId === 'dsh-runtime') { + await this.onEnvironmentVerified() + this.recoveryMode = null + this.publishDshLifecycle({ state: 'running', action: null, attempt: 0, error: null }) + await this.onDshAvailable() + } + return status + } catch (error) { + if (componentId === 'dsh-runtime') this.enterRecovery(error) + throw error + } + } + health() { return this.environment.health() } + + markStartupComplete() { + this.startupComplete = true + if (this.recoveryMode === null && this.environment.status().components.some(value => value.id === 'dsh-runtime')) { + this.publishDshLifecycle({ state: 'running', action: null, attempt: 0, error: null }) + } else if (this.recoveryMode !== null) { + this.publishDshLifecycle({ state: 'failed', action: null, error: this.recoveryMode }) + } + } + + enterRecovery(error) { + this.recoveryMode = error instanceof Error ? error.message : String(error) + this.publishDshLifecycle({ state: 'failed', action: null, attempt: 0, error: this.recoveryMode }) + } + + status() { + const environment = this.environment.status() + return Object.freeze({ + ...environment, + controlPlane: this.controlPlane.status().components, + recoveryMode: this.recoveryMode, + dshLifecycle: this.dshLifecycle, + startupComplete: this.startupComplete, + }) + } +} diff --git a/upstream/container/platform/image-input/.gitkeep b/upstream/container/platform/image-input/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/upstream/container/platform/lib/access-capability.mjs b/upstream/container/platform/lib/access-capability.mjs new file mode 100644 index 0000000..ea4fc5f --- /dev/null +++ b/upstream/container/platform/lib/access-capability.mjs @@ -0,0 +1,12 @@ +export async function consumeInternalCapability(access, { token, audience, method, target }) { + try { + const result = await access.request('POST', '/v1/capabilities/consume', { + token, audience, method, target, + }) + return result.authorized === true + } catch (error) { + if (error?.statusCode === 401) return false + if (!Number.isInteger(error?.statusCode) || error.statusCode >= 500) error.statusCode = 503 + throw error + } +} diff --git a/upstream/container/platform/lib/access-evidence.mjs b/upstream/container/platform/lib/access-evidence.mjs new file mode 100644 index 0000000..3877b6f --- /dev/null +++ b/upstream/container/platform/lib/access-evidence.mjs @@ -0,0 +1,63 @@ +import { lstat, readdir } from 'node:fs/promises' +import { join } from 'node:path' + +const EVIDENCE_KEYS = Object.freeze([ + 'dshSettings', + 'webProfile', + 'dshSessions', + 'userSkills', + 'deploymentState', + 'managementState', + 'updaterState', + 'legacyAuthenticationConfigured', +]) + +async function exists(path) { + return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error)) +} + +async function nonEmptyDirectory(path) { + try { return (await readdir(path)).length > 0 } + catch (error) { return error?.code === 'ENOENT' ? false : Promise.reject(error) } +} + +export function parseAccessEvidence(value) { + let parsed + try { parsed = JSON.parse(value) } catch { throw new Error('legacy installation evidence is invalid') } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) + || Object.keys(parsed).length !== EVIDENCE_KEYS.length + || EVIDENCE_KEYS.some(key => typeof parsed[key] !== 'boolean')) { + throw new Error('legacy installation evidence is invalid') + } + return Object.freeze(Object.fromEntries(EVIDENCE_KEYS.map(key => [key, parsed[key]]))) +} + +export async function collectAccessEvidence({ dshHome, paths, legacyAuthenticationConfigured = false }) { + const [ + dshSettings, + webProfile, + dshSessions, + userSkills, + deploymentState, + managementState, + updaterState, + ] = await Promise.all([ + exists(join(dshHome, 'settings.yaml')), + exists(join(dshHome, 'profiles', 'web', 'package.json')), + nonEmptyDirectory(join(dshHome, 'sessions')), + nonEmptyDirectory(join(dshHome, 'skills')), + exists(join(paths.deploymentStateRoot, 'slots.json')), + nonEmptyDirectory(paths.managementStateRoot), + nonEmptyDirectory(paths.updaterStateRoot), + ]) + return Object.freeze({ + dshSettings, + webProfile, + dshSessions, + userSkills, + deploymentState, + managementState, + updaterState, + legacyAuthenticationConfigured: legacyAuthenticationConfigured === true, + }) +} diff --git a/upstream/container/platform/lib/artifact-contracts.mjs b/upstream/container/platform/lib/artifact-contracts.mjs new file mode 100644 index 0000000..bf1c295 --- /dev/null +++ b/upstream/container/platform/lib/artifact-contracts.mjs @@ -0,0 +1,76 @@ +import { exactKeys, isoTimestamp, parseJsonDocument, plainObject, positiveSafeInteger, TrustError } from './validation.mjs' + +export const MANIFEST_MEDIA_TYPE = 'application/vnd.dsh-platform.manifest.v1+json' +export const SIGNATURE_MEDIA_TYPE = 'application/vnd.dsh-platform.signature.v1+json' + +function nonNegativeSafeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TrustError(`${label} must be a non-negative safe integer`) + } + return value +} + +export function parseArtifactDescriptor(value, label = 'artifact') { + const object = plainObject(value, label) + exactKeys(object, ['id', 'mediaType', 'sha256', 'size', 'url'], label) + if (typeof object.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(object.id)) { + throw new TrustError(`${label}.id is invalid`) + } + if (typeof object.mediaType !== 'string' || !/^[\x21-\x7e]{1,127}$/.test(object.mediaType)) { + throw new TrustError(`${label}.mediaType is invalid`) + } + if (typeof object.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(object.sha256)) { + throw new TrustError(`${label}.sha256 must be a SHA-256 hex digest`) + } + const size = nonNegativeSafeInteger(object.size, `${label}.size`) + let url + try { + url = new URL(object.url) + } catch { + throw new TrustError(`${label}.url must be an HTTPS URL`) + } + if (url.protocol !== 'https:' || url.username !== '' || url.password !== '') { + throw new TrustError(`${label}.url must be an HTTPS URL without credentials`) + } + return Object.freeze({ + id: object.id, + mediaType: object.mediaType, + sha256: object.sha256, + size, + url: url.href, + }) +} + +export function parseArtifactList(value, label) { + if (!Array.isArray(value)) throw new TrustError(`${label} must be an array`) + const artifacts = value.map((entry, index) => parseArtifactDescriptor(entry, `${label}[${String(index)}]`)) + if (new Set(artifacts.map(artifact => artifact.id)).size !== artifacts.length) { + throw new TrustError(`${label} must not contain duplicate IDs`) + } + return Object.freeze(artifacts) +} + +export function parseReleaseManifest(bytes) { + const object = parseJsonDocument(bytes, 'release manifest') + const common = ['artifacts', 'issuedAt', 'keyringGeneration', 'manifestType', 'schema', 'targetSequence', 'version'] + const specific = object.manifestType === 'bootstrap' + ? ['bootstrapApi', 'entrypoint'] + : object.manifestType === 'environment' + ? ['bootstrapApi', 'components', 'patches', 'systemPlugins'] + : [] + exactKeys(object, [...common, ...specific], 'release manifest') + if (object.schema !== 1) throw new TrustError('release manifest schema must be 1') + if (typeof object.manifestType !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(object.manifestType)) { + throw new TrustError('release manifest manifestType is invalid') + } + if (typeof object.version !== 'string' || object.version === '') { + throw new TrustError('release manifest version is invalid') + } + return Object.freeze({ + document: object, + keyringGeneration: positiveSafeInteger(object.keyringGeneration, 'release manifest keyringGeneration'), + targetSequence: positiveSafeInteger(object.targetSequence, 'release manifest targetSequence'), + issuedAt: isoTimestamp(object.issuedAt, 'release manifest issuedAt'), + artifacts: parseArtifactList(object.artifacts, 'release manifest artifacts'), + }) +} diff --git a/upstream/container/platform/lib/atomic.mjs b/upstream/container/platform/lib/atomic.mjs new file mode 100644 index 0000000..725e1d2 --- /dev/null +++ b/upstream/container/platform/lib/atomic.mjs @@ -0,0 +1,46 @@ +import { link, mkdir, open, rename, rm } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { dirname } from 'node:path' + +async function syncDirectory(path) { + const handle = await open(path, 'r') + try { + await handle.sync() + } finally { + await handle.close() + } +} + +async function temporaryFile(path, bytes, mode) { + await mkdir(dirname(path), { recursive: true }) + const temporary = `${path}.${randomUUID()}.tmp` + const handle = await open(temporary, 'wx', mode) + try { + await handle.writeFile(bytes) + await handle.sync() + } finally { + await handle.close() + } + return temporary +} + +export async function durableReplace(path, bytes, mode = 0o600) { + const temporary = await temporaryFile(path, bytes, mode) + try { + await rename(temporary, path) + await syncDirectory(dirname(path)) + } catch (error) { + await rm(temporary, { force: true }) + throw error + } +} + +export async function durableCreate(path, bytes, mode = 0o600) { + const temporary = await temporaryFile(path, bytes, mode) + try { + await link(temporary, path) + await syncDirectory(dirname(path)) + } finally { + await rm(temporary, { force: true }) + } +} diff --git a/upstream/container/platform/lib/bootstrap-version.mjs b/upstream/container/platform/lib/bootstrap-version.mjs new file mode 100644 index 0000000..81efc67 --- /dev/null +++ b/upstream/container/platform/lib/bootstrap-version.mjs @@ -0,0 +1,31 @@ +export function parseBootstrapVersion(value, label = 'Bootstrap VERSION') { + if (typeof value !== 'string') throw new Error(`${label} is invalid`) + const normalized = value.endsWith('\r\n') + ? value.slice(0, -2) + : value.endsWith('\n') ? value.slice(0, -1) : value + if (!/^\d+\.\d+\.\d+$/.test(normalized)) { + throw new Error(`${label} is invalid`) + } + if (normalized.split('.').some(part => !Number.isSafeInteger(Number(part)))) { + throw new Error(`${label} is invalid`) + } + return normalized +} + +function compareBootstrapVersions(left, right) { + const leftParts = left.split('.').map(Number) + const rightParts = right.split('.').map(Number) + for (let index = 0; index < leftParts.length; index += 1) { + if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index] + } + return 0 +} + +export function validateBootstrapContentTransition({ previousVersion, previousSha256, nextVersion, nextSha256 }) { + parseBootstrapVersion(previousVersion, 'previous Bootstrap version') + parseBootstrapVersion(nextVersion, 'next Bootstrap version') + if (previousSha256 === nextSha256) return + if (compareBootstrapVersions(nextVersion, previousVersion) <= 0) { + throw new Error('Bootstrap content changed without increasing Bootstrap VERSION') + } +} diff --git a/upstream/container/platform/lib/canonical-json.mjs b/upstream/container/platform/lib/canonical-json.mjs new file mode 100644 index 0000000..513ca38 --- /dev/null +++ b/upstream/container/platform/lib/canonical-json.mjs @@ -0,0 +1,23 @@ +import { TrustError } from './validation.mjs' + +function normalize(value) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value + if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) throw new TrustError('canonical JSON numbers must be safe integers') + return value + } + if (Array.isArray(value)) return value.map(normalize) + if (typeof value === 'object') { + const result = {} + for (const key of Object.keys(value).sort()) { + if (value[key] === undefined) throw new TrustError('canonical JSON cannot contain undefined') + result[key] = normalize(value[key]) + } + return result + } + throw new TrustError(`canonical JSON cannot contain ${typeof value}`) +} + +export function canonicalJson(value) { + return Buffer.from(`${JSON.stringify(normalize(value))}\n`) +} diff --git a/upstream/container/platform/lib/contracts.mjs b/upstream/container/platform/lib/contracts.mjs new file mode 100644 index 0000000..cf1861c --- /dev/null +++ b/upstream/container/platform/lib/contracts.mjs @@ -0,0 +1,265 @@ +import { exactKeys, isoTimestamp, parseJsonDocument, plainObject, positiveSafeInteger, TrustError } from './validation.mjs' +import { parseArtifactList } from './artifact-contracts.mjs' + +function identifier(value, label) { + if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(value)) { + throw new TrustError(`${label} is invalid`) + } + return value +} + +function version(value, label) { + if (typeof value !== 'string' || value === '' || value.length > 128 || /[\0/\\]/.test(value)) { + throw new TrustError(`${label} is invalid`) + } + return value +} + +function exactReference(value, label) { + const object = plainObject(value, label) + exactKeys(object, ['manifestArtifactId', 'signatureArtifactId', 'version'], label) + const manifestArtifactId = identifier(object.manifestArtifactId, `${label}.manifestArtifactId`) + const signatureArtifactId = identifier(object.signatureArtifactId, `${label}.signatureArtifactId`) + if (manifestArtifactId === signatureArtifactId) throw new TrustError(`${label} manifest and signature Artifacts must differ`) + return Object.freeze({ + version: version(object.version, `${label}.version`), + manifestArtifactId, + signatureArtifactId, + }) +} + +function dshReference(value) { + const object = plainObject(value, 'stable.desired.dsh') + exactKeys(object, ['integrity', 'version'], 'stable.desired.dsh') + if (typeof object.integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(object.integrity)) { + throw new TrustError('stable.desired.dsh.integrity must be npm SHA-512 integrity') + } + return Object.freeze({ + version: version(object.version, 'stable.desired.dsh.version'), + integrity: object.integrity, + }) +} + +function registryKey(value, label) { + const object = plainObject(value, label) + exactKeys(object, ['algorithm', 'expires', 'keyId', 'publicKey'], label) + if (object.algorithm !== 'ECDSA-P256-SHA256') throw new TrustError(`${label}.algorithm is unsupported`) + if (typeof object.keyId !== 'string' || !/^SHA256:[A-Za-z0-9+/]{43}$/.test(object.keyId)) { + throw new TrustError(`${label}.keyId is invalid`) + } + if (typeof object.publicKey !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(object.publicKey)) { + throw new TrustError(`${label}.publicKey is invalid`) + } + if (object.expires !== null) isoTimestamp(object.expires, `${label}.expires`) + return Object.freeze({ ...object }) +} + +export function parseOfficialDshPolicy(value, label = 'official DSH policy') { + const object = plainObject(value, label) + exactKeys(object, ['keys', 'packageName', 'registry'], label) + if (object.registry !== 'https://registry.npmjs.org/') { + throw new TrustError(`${label}.registry must be the official npm registry`) + } + if (object.packageName !== '@deepseek-ai/dsh') { + throw new TrustError(`${label}.packageName must be @deepseek-ai/dsh`) + } + if (!Array.isArray(object.keys) || object.keys.length === 0) throw new TrustError(`${label}.keys must not be empty`) + const keys = object.keys.map((entry, index) => registryKey(entry, `${label}.keys[${String(index)}]`)) + if (new Set(keys.map(key => key.keyId)).size !== keys.length) throw new TrustError(`${label}.keys must be unique`) + return Object.freeze({ registry: object.registry, packageName: object.packageName, keys: Object.freeze(keys) }) +} + +export function parseStable(bytes) { + const object = parseJsonDocument(bytes, 'stable') + exactKeys( + object, + ['artifacts', 'desired', 'issuedAt', 'keyringGeneration', 'officialDshPolicy', 'schema', 'targetSequence', 'updateApi'], + 'stable', + ) + if (object.schema !== 1) throw new TrustError('stable.schema must be 1') + if (object.updateApi !== 1) throw new TrustError('stable.updateApi must be 1') + const desired = plainObject(object.desired, 'stable.desired') + exactKeys(desired, ['bootstrap', 'dsh', 'environment'], 'stable.desired') + const artifacts = parseArtifactList(object.artifacts, 'stable.artifacts') + const parsedDesired = Object.freeze({ + bootstrap: exactReference(desired.bootstrap, 'stable.desired.bootstrap'), + environment: exactReference(desired.environment, 'stable.desired.environment'), + dsh: dshReference(desired.dsh), + }) + const artifactIds = new Set(artifacts.map(artifact => artifact.id)) + for (const artifactId of [ + parsedDesired.bootstrap.manifestArtifactId, + parsedDesired.bootstrap.signatureArtifactId, + parsedDesired.environment.manifestArtifactId, + parsedDesired.environment.signatureArtifactId, + ]) { + if (!artifactIds.has(artifactId)) throw new TrustError(`stable desired Artifact ${JSON.stringify(artifactId)} is missing`) + } + return Object.freeze({ + document: object, + keyringGeneration: positiveSafeInteger(object.keyringGeneration, 'stable.keyringGeneration'), + targetSequence: positiveSafeInteger(object.targetSequence, 'stable.targetSequence'), + issuedAt: isoTimestamp(object.issuedAt, 'stable.issuedAt'), + artifacts, + desired: parsedDesired, + officialDshPolicy: parseOfficialDshPolicy(object.officialDshPolicy), + }) +} + +function stringArray(value, label) { + if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string' || entry.includes('\0'))) { + throw new TrustError(`${label} must be a string array`) + } + return Object.freeze([...value]) +} + +function stringMap(value, label) { + const object = plainObject(value, label) + const result = {} + for (const [key, entry] of Object.entries(object)) { + if (!/^[A-Z_][A-Z0-9_]*$/.test(key) || typeof entry !== 'string' || entry.includes('\0')) { + throw new TrustError(`${label} contains an invalid environment entry`) + } + result[key] = entry + } + return Object.freeze(result) +} + +function command(value, label) { + const object = plainObject(value, label) + exactKeys(object, ['args', 'executable', 'timeoutSeconds'], label) + if (typeof object.executable !== 'string' || !object.executable.startsWith('/')) { + throw new TrustError(`${label}.executable must be an absolute path`) + } + return Object.freeze({ + executable: object.executable, + args: stringArray(object.args, `${label}.args`), + timeoutSeconds: positiveSafeInteger(object.timeoutSeconds, `${label}.timeoutSeconds`), + }) +} + +function optionalCommand(value, label) { + return value === null ? null : command(value, label) +} + +function parseHealth(value) { + if (value === null) return null + const object = plainObject(value, 'component.health') + if (object.type === 'http') { + exactKeys(object, ['host', 'intervalSeconds', 'path', 'port', 'timeoutSeconds', 'type'], 'component.health') + if (object.host !== '127.0.0.1') throw new TrustError('HTTP health host must be 127.0.0.1') + if (!Number.isInteger(object.port) || object.port < 1 || object.port > 65535) throw new TrustError('HTTP health port is invalid') + if (typeof object.path !== 'string' || !object.path.startsWith('/')) throw new TrustError('HTTP health path is invalid') + } else if (object.type === 'exec') { + exactKeys(object, ['command', 'intervalSeconds', 'timeoutSeconds', 'type'], 'component.health') + command(object.command, 'component.health.command') + } else throw new TrustError('component.health.type must be http or exec') + positiveSafeInteger(object.intervalSeconds, 'component.health.intervalSeconds') + positiveSafeInteger(object.timeoutSeconds, 'component.health.timeoutSeconds') + return Object.freeze(object) +} + +export function parseComponentManifest(bytes) { + const object = parseJsonDocument(bytes, 'component') + exactKeys( + object, + ['command', 'environment', 'health', 'id', 'lifecycle', 'logging', 'schema', 'type'], + 'component', + ) + if (object.schema !== 1) throw new TrustError('component.schema must be 1') + if (!['service', 'oneshot', 'hook'].includes(object.type)) throw new TrustError('component.type is invalid') + const lifecycle = plainObject(object.lifecycle, 'component.lifecycle') + const phases = ['prepare', 'postStart', 'postStop', 'preStart', 'preStop', 'stop'] + exactKeys(lifecycle, phases, 'component.lifecycle') + const logging = plainObject(object.logging, 'component.logging') + exactKeys(logging, ['stderr', 'stdout'], 'component.logging') + if (typeof logging.stdout !== 'boolean' || typeof logging.stderr !== 'boolean') { + throw new TrustError('component logging flags must be booleans') + } + return Object.freeze({ + schema: object.schema, + id: identifier(object.id, 'component.id'), + type: object.type, + command: command(object.command, 'component.command'), + environment: stringMap(object.environment, 'component.environment'), + lifecycle: Object.freeze(Object.fromEntries(phases.map(phase => [ + phase, + optionalCommand(lifecycle[phase], `component.lifecycle.${phase}`), + ]))), + health: parseHealth(object.health), + logging: Object.freeze({ stdout: logging.stdout, stderr: logging.stderr }), + }) +} + +function orderedReferences(value, artifacts, label) { + if (!Array.isArray(value)) throw new TrustError(`${label} must be an array`) + const result = value.map((entry, index) => { + const object = plainObject(entry, `${label}[${String(index)}]`) + exactKeys(object, ['id', 'sha256'], `${label}[${String(index)}]`) + if (typeof object.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(object.sha256)) { + throw new TrustError(`${label}[${String(index)}].sha256 must be a SHA-256 hex digest`) + } + if (!artifacts.some(artifact => artifact.sha256 === object.sha256)) { + throw new TrustError(`${label}[${String(index)}] references a missing Artifact hash`) + } + return Object.freeze({ + id: identifier(object.id, `${label}[${String(index)}].id`), + sha256: object.sha256, + }) + }) + if (new Set(result.map(item => item.id)).size !== result.length) throw new TrustError(`${label} IDs must be unique`) + return Object.freeze(result) +} + +export function parseBootstrapManifest(bytes) { + const manifest = parseCommonManifest(bytes, 'bootstrap') + exactKeys(manifest.object, [...manifest.commonKeys, 'bootstrapApi', 'entrypoint'], 'bootstrap manifest') + if (manifest.object.bootstrapApi !== 1) throw new TrustError('bootstrapApi must be 1') + if (typeof manifest.object.entrypoint !== 'string' || !manifest.object.entrypoint.startsWith('/')) { + throw new TrustError('bootstrap entrypoint must be absolute') + } + return Object.freeze({ ...manifest.common, bootstrapApi: 1, entrypoint: manifest.object.entrypoint }) +} + +export function parseEnvironmentManifest(bytes) { + const manifest = parseCommonManifest(bytes, 'environment') + exactKeys( + manifest.object, + [...manifest.commonKeys, 'bootstrapApi', 'components', 'patches', 'systemPlugins'], + 'environment manifest', + ) + if (manifest.object.bootstrapApi !== 1) throw new TrustError('environment bootstrapApi must be 1') + return Object.freeze({ + ...manifest.common, + bootstrapApi: 1, + components: orderedReferences(manifest.object.components, manifest.common.artifacts, 'environment components'), + patches: orderedReferences(manifest.object.patches, manifest.common.artifacts, 'environment patches'), + systemPlugins: orderedReferences(manifest.object.systemPlugins, manifest.common.artifacts, 'environment systemPlugins'), + }) +} + +export function artifactForReference(manifest, reference) { + const artifact = manifest.artifacts.find(candidate => candidate.sha256 === reference.sha256) + if (artifact === undefined) throw new TrustError(`resource ${JSON.stringify(reference.id)} references a missing Artifact hash`) + return artifact +} + +function parseCommonManifest(bytes, expectedType) { + const object = parseJsonDocument(bytes, `${expectedType} manifest`) + const commonKeys = ['artifacts', 'issuedAt', 'keyringGeneration', 'manifestType', 'schema', 'targetSequence', 'version'] + if (object.schema !== 1) throw new TrustError(`${expectedType} manifest schema must be 1`) + if (object.manifestType !== expectedType) throw new TrustError(`manifestType must be ${expectedType}`) + return { + object, + commonKeys, + common: { + schema: 1, + manifestType: expectedType, + version: version(object.version, `${expectedType} manifest version`), + keyringGeneration: positiveSafeInteger(object.keyringGeneration, `${expectedType} keyringGeneration`), + targetSequence: positiveSafeInteger(object.targetSequence, `${expectedType} targetSequence`), + issuedAt: isoTimestamp(object.issuedAt, `${expectedType} issuedAt`), + artifacts: parseArtifactList(object.artifacts, `${expectedType} artifacts`), + }, + } +} diff --git a/upstream/container/platform/lib/deployment-contracts.mjs b/upstream/container/platform/lib/deployment-contracts.mjs new file mode 100644 index 0000000..2c90505 --- /dev/null +++ b/upstream/container/platform/lib/deployment-contracts.mjs @@ -0,0 +1,286 @@ +import { createHash } from 'node:crypto' +import { canonicalJson } from './canonical-json.mjs' +import { exactKeys, parseJsonDocument, plainObject, TrustError } from './validation.mjs' + +export const PLATFORM_LAYOUT = 1 +export const BOOTSTRAP_API = 1 +export const UPDATE_API = 1 + +const REFERENCE_KINDS = new Set([ + 'bootstrap', + 'environment', + 'pristine', + 'runtime', + 'system-plugins', +]) +const AUTHORITIES = new Set(['stable', 'experimental', 'development']) +const SHA256 = /^[a-f0-9]{64}$/ +const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/ +const IMAGE_BUILD_ID = /^sha256:[a-f0-9]{64}$/ + +function identifier(value, label) { + if (typeof value !== 'string' || !IDENTIFIER.test(value)) throw new TrustError(`${label} is invalid`) + return value +} + +function sha256(value, label) { + if (typeof value !== 'string' || !SHA256.test(value)) throw new TrustError(`${label} must be a SHA-256 hex digest`) + return value +} + +function imageBuildId(value, label = 'imageBuildId') { + if (typeof value !== 'string' || !IMAGE_BUILD_ID.test(value)) throw new TrustError(`${label} is invalid`) + return value +} + +function version(value, label) { + if (typeof value !== 'string' || value === '' || value.length > 128 || /[\0/\\]/.test(value)) { + throw new TrustError(`${label} is invalid`) + } + return value +} + +function nonNegativeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 0) throw new TrustError(`${label} must be a non-negative safe integer`) + return value +} + +function positiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 1) throw new TrustError(`${label} must be a positive safe integer`) + return value +} + +function authority(value, sequence, label = 'authority') { + if (!AUTHORITIES.has(value)) throw new TrustError(`${label} is invalid`) + if (value === 'development' && sequence !== 0) throw new TrustError('development authority requires targetSequence 0') + if (value !== 'development' && sequence === 0) throw new TrustError(`${value} authority requires a positive targetSequence`) + return value +} + +function parseBytesOrObject(value, label) { + return Buffer.isBuffer(value) ? parseJsonDocument(value, label) : plainObject(value, label) +} + +function contentHash(value) { + return createHash('sha256').update(canonicalJson(value)).digest('hex') +} + +export function deriveRecordId(prefix, recordWithoutId) { + identifier(prefix, 'record prefix') + return `${prefix}-${contentHash(recordWithoutId)}` +} + +export function deriveImageBuildId(inventoryWithoutId) { + return `sha256:${contentHash(inventoryWithoutId)}` +} + +function parseInventoryAsset(value, kind, label) { + const object = plainObject(value, label) + exactKeys(object, ['id', 'sha256'], label) + return Object.freeze({ + id: identifier(object.id, `${label}.id`), + sha256: sha256(object.sha256, `${label}.sha256`), + kind, + }) +} + +export function parseArtifactReference(value, label = 'artifact reference') { + const object = plainObject(value, label) + if (object.storage === 'image') { + exactKeys(object, ['id', 'imageBuildId', 'kind', 'sha256', 'storage'], label) + imageBuildId(object.imageBuildId, `${label}.imageBuildId`) + } else if (object.storage === 'store') { + exactKeys(object, ['id', 'kind', 'sha256', 'storage'], label) + } else { + throw new TrustError(`${label}.storage must be image or store`) + } + if (!REFERENCE_KINDS.has(object.kind)) throw new TrustError(`${label}.kind is invalid`) + return Object.freeze({ + storage: object.storage, + ...(object.storage === 'image' ? { imageBuildId: object.imageBuildId } : {}), + kind: object.kind, + id: identifier(object.id, `${label}.id`), + sha256: sha256(object.sha256, `${label}.sha256`), + }) +} + +export function parseImageInventory(value) { + const object = parseBytesOrObject(value, 'image inventory') + exactKeys(object, [ + 'authority', 'bootstrap', 'bootstrapApi', 'deployment', 'imageBuildId', 'platformRevision', + 'schema', 'targetSequence', 'updateApi', + ], 'image inventory') + if (object.schema !== 1) throw new TrustError('image inventory schema must be 1') + if (object.bootstrapApi !== BOOTSTRAP_API) throw new TrustError('image inventory bootstrapApi must be 1') + if (object.updateApi !== UPDATE_API) throw new TrustError('image inventory updateApi must be 1') + const targetSequence = nonNegativeInteger(object.targetSequence, 'image inventory targetSequence') + const parsedAuthority = authority(object.authority, targetSequence, 'image inventory authority') + identifier(object.platformRevision, 'image inventory platformRevision') + + const bootstrap = plainObject(object.bootstrap, 'image inventory bootstrap') + exactKeys(bootstrap, ['id', 'sha256', 'version'], 'image inventory bootstrap') + const parsedBootstrap = Object.freeze({ + version: version(bootstrap.version, 'image inventory bootstrap.version'), + ...parseInventoryAsset({ id: bootstrap.id, sha256: bootstrap.sha256 }, 'bootstrap', 'image inventory bootstrap artifact'), + }) + + const deployment = plainObject(object.deployment, 'image inventory deployment') + exactKeys(deployment, [ + 'dshVersion', 'environment', 'environmentVersion', 'id', 'pristine', 'runtime', 'systemPlugins', + ], 'image inventory deployment') + const parsedDeployment = Object.freeze({ + id: identifier(deployment.id, 'image inventory deployment.id'), + dshVersion: version(deployment.dshVersion, 'image inventory deployment.dshVersion'), + environmentVersion: version(deployment.environmentVersion, 'image inventory deployment.environmentVersion'), + environment: parseInventoryAsset(deployment.environment, 'environment', 'image inventory deployment.environment'), + pristine: parseInventoryAsset(deployment.pristine, 'pristine', 'image inventory deployment.pristine'), + runtime: parseInventoryAsset(deployment.runtime, 'runtime', 'image inventory deployment.runtime'), + systemPlugins: parseInventoryAsset(deployment.systemPlugins, 'system-plugins', 'image inventory deployment.systemPlugins'), + }) + + const withoutId = { ...object } + delete withoutId.imageBuildId + const expected = deriveImageBuildId(withoutId) + imageBuildId(object.imageBuildId) + if (object.imageBuildId !== expected) throw new TrustError('image inventory imageBuildId does not match its canonical content') + + return Object.freeze({ + schema: 1, + imageBuildId: object.imageBuildId, + authority: parsedAuthority, + platformRevision: object.platformRevision, + targetSequence, + bootstrapApi: BOOTSTRAP_API, + updateApi: UPDATE_API, + bootstrap: parsedBootstrap, + deployment: parsedDeployment, + document: Object.freeze(object), + }) +} + +function parseRecord(value, label, fields) { + const object = parseBytesOrObject(value, label) + exactKeys(object, ['id', 'schema', ...fields], label) + if (object.schema !== 1) throw new TrustError(`${label} schema must be 1`) + return object +} + +function verifyRecordId(object, prefix, label) { + const withoutId = { ...object } + delete withoutId.id + const expected = deriveRecordId(prefix, withoutId) + if (object.id !== expected) throw new TrustError(`${label} id does not match its canonical content`) + return object.id +} + +export function parseBootstrapRecord(value) { + const object = parseRecord(value, 'Bootstrap Record', [ + 'artifact', 'bootstrapApi', 'targetSequence', 'version', + ]) + if (object.bootstrapApi !== BOOTSTRAP_API) throw new TrustError('Bootstrap Record bootstrapApi must be 1') + const parsed = { + schema: 1, + id: object.id, + version: version(object.version, 'Bootstrap Record version'), + bootstrapApi: BOOTSTRAP_API, + targetSequence: nonNegativeInteger(object.targetSequence, 'Bootstrap Record targetSequence'), + artifact: parseArtifactReference(object.artifact, 'Bootstrap Record artifact'), + } + verifyRecordId(object, 'bootstrap-record', 'Bootstrap Record') + return Object.freeze(parsed) +} + +function stringList(value, label) { + if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string' || entry === '' || entry.includes('\0'))) { + throw new TrustError(`${label} must be a string array`) + } + if (new Set(value).size !== value.length) throw new TrustError(`${label} must not contain duplicates`) + return Object.freeze([...value]) +} + +export function parseDeploymentRecord(value) { + const object = parseRecord(value, 'Deployment Record', [ + 'authority', 'dshVersion', 'environment', 'environmentVersion', 'pristine', 'receiptTokens', + 'runtime', 'snapshotId', 'systemPlugins', 'targetSequence', + ]) + const targetSequence = nonNegativeInteger(object.targetSequence, 'Deployment Record targetSequence') + const parsed = { + schema: 1, + id: object.id, + authority: authority(object.authority, targetSequence, 'Deployment Record authority'), + targetSequence, + dshVersion: version(object.dshVersion, 'Deployment Record dshVersion'), + environmentVersion: version(object.environmentVersion, 'Deployment Record environmentVersion'), + environment: parseArtifactReference(object.environment, 'Deployment Record environment'), + pristine: parseArtifactReference(object.pristine, 'Deployment Record pristine'), + runtime: parseArtifactReference(object.runtime, 'Deployment Record runtime'), + systemPlugins: parseArtifactReference(object.systemPlugins, 'Deployment Record systemPlugins'), + receiptTokens: stringList(object.receiptTokens, 'Deployment Record receiptTokens'), + snapshotId: object.snapshotId === null ? null : identifier(object.snapshotId, 'Deployment Record snapshotId'), + } + if (parsed.environment.kind !== 'environment' || parsed.pristine.kind !== 'pristine' + || parsed.runtime.kind !== 'runtime' || parsed.systemPlugins.kind !== 'system-plugins') { + throw new TrustError('Deployment Record references use the wrong kind') + } + const imageIds = [parsed.environment, parsed.pristine, parsed.runtime, parsed.systemPlugins] + .filter(reference => reference.storage === 'image') + .map(reference => reference.imageBuildId) + if (new Set(imageIds).size > 1) throw new TrustError('Deployment Record image references must use one imageBuildId') + verifyRecordId(object, 'deployment-record', 'Deployment Record') + return Object.freeze(parsed) +} + +export function parseSlots(value, recordPrefix, label = 'slots') { + const object = parseBytesOrObject(value, label) + exactKeys(object, ['current', 'generation', 'previous', 'schema'], label) + if (object.schema !== 1) throw new TrustError(`${label} schema must be 1`) + const parseId = (entry, field) => entry === null ? null : identifier(entry, `${label}.${field}`) + const current = parseId(object.current, 'current') + const previous = parseId(object.previous, 'previous') + if (current === null) throw new TrustError(`${label}.current must not be null`) + if (!current.startsWith(`${recordPrefix}-`) || (previous !== null && !previous.startsWith(`${recordPrefix}-`))) { + throw new TrustError(`${label} contains an unexpected Record ID`) + } + if (current === previous) throw new TrustError(`${label} current and previous must differ`) + return Object.freeze({ schema: 1, generation: positiveInteger(object.generation, `${label}.generation`), current, previous }) +} + +function withRecordId(prefix, content) { + const document = { ...content } + return Object.freeze({ ...document, id: deriveRecordId(prefix, document) }) +} + +export function recordsFromImageInventory(value) { + const inventory = value?.document === undefined ? parseImageInventory(value) : value + const imageReference = asset => ({ + storage: 'image', + imageBuildId: inventory.imageBuildId, + kind: asset.kind, + id: asset.id, + sha256: asset.sha256, + }) + const bootstrap = withRecordId('bootstrap-record', { + schema: 1, + version: inventory.bootstrap.version, + bootstrapApi: BOOTSTRAP_API, + targetSequence: inventory.targetSequence, + artifact: imageReference(inventory.bootstrap), + }) + const deployment = withRecordId('deployment-record', { + schema: 1, + authority: inventory.authority, + targetSequence: inventory.targetSequence, + dshVersion: inventory.deployment.dshVersion, + environmentVersion: inventory.deployment.environmentVersion, + environment: imageReference(inventory.deployment.environment), + pristine: imageReference(inventory.deployment.pristine), + runtime: imageReference(inventory.deployment.runtime), + systemPlugins: imageReference(inventory.deployment.systemPlugins), + receiptTokens: [], + snapshotId: null, + }) + return Object.freeze({ + bootstrap: parseBootstrapRecord(bootstrap), + deployment: parseDeploymentRecord(deployment), + }) +} diff --git a/upstream/container/platform/lib/deployment-status.mjs b/upstream/container/platform/lib/deployment-status.mjs new file mode 100644 index 0000000..c087643 --- /dev/null +++ b/upstream/container/platform/lib/deployment-status.mjs @@ -0,0 +1,22 @@ +import { readFile } from 'node:fs/promises' +import { durableReplace } from './atomic.mjs' +import { canonicalJson } from './canonical-json.mjs' + +export async function readDeploymentStatus(path) { + try { + const value = JSON.parse(await readFile(path, 'utf8')) + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value + : { platformLayout: 1, imageBaseline: null, current: null, imageBehindCurrent: false, recoveryMode: 'status-invalid' } + } catch (error) { + if (error?.code === 'ENOENT') { + return { platformLayout: 1, imageBaseline: null, current: null, imageBehindCurrent: false, recoveryMode: 'status-unavailable' } + } + throw error + } +} + +export async function writeDeploymentStatus(path, value) { + await durableReplace(path, canonicalJson(value)) + return value +} diff --git a/upstream/container/platform/lib/dsh-compatibility.mjs b/upstream/container/platform/lib/dsh-compatibility.mjs new file mode 100644 index 0000000..b0e8b39 --- /dev/null +++ b/upstream/container/platform/lib/dsh-compatibility.mjs @@ -0,0 +1,143 @@ +import { execFile } from 'node:child_process' +import { readFile, readdir } from 'node:fs/promises' +import { promisify } from 'node:util' +import { join } from 'node:path' + +const execFileAsync = promisify(execFile) + +export const DSH_COMPATIBILITY_PACKAGES = Object.freeze({ + '@deepseek-ai/dsh-api-remotes': Object.freeze(['.', './client']), + '@deepseek-ai/dsh-app-boot': Object.freeze(['.']), + '@deepseek-ai/dsh-client-connection': Object.freeze(['.', './client']), + '@deepseek-ai/dsh-client-locale': Object.freeze(['.', './client']), + '@deepseek-ai/dsh-client-ui-settings': Object.freeze(['.', './client']), + '@deepseek-ai/dsh-client-ui-theme': Object.freeze(['.', './client']), + '@deepseek-ai/dsh-host-directory-picker-browse': Object.freeze(['.']), + '@deepseek-ai/dsh-llm': Object.freeze(['.', './remote']), + '@deepseek-ai/dsh-settings': Object.freeze(['.']), + '@deepseek-ai/dsh-web-app': Object.freeze(['.', './startup', './cordis.patch.yml']), +}) + +const DSH_DIRECT_COMPATIBILITY_PACKAGES = Object.freeze([ + '@deepseek-ai/dsh-app-boot', + '@deepseek-ai/dsh-web-app', +]) + +export const DSH_WEB_ROWS = Object.freeze({ + connection: '@deepseek-ai/dsh-client-connection', + 'plugin-inventory': '@deepseek-ai/dsh-host-plugin-inventory', + 'settings-controller': '@deepseek-ai/dsh-api-settings-controller', + webserver: '@deepseek-ai/dsh-host-webserver', + 'web-runtime': '@deepseek-ai/dsh-web-app', +}) + +export const DSH_REMOTE_METHODS = Object.freeze([ + '@deepseek-ai/dsh-api-settings-controller#settings/describe', + '@deepseek-ai/dsh-llm#llm/listConfigurableProviders', + '@deepseek-ai/dsh-llm#llm/listProviders', +]) + +export const DSH_STATIC_CLIENT_MODULES = Object.freeze([ + '@deepseek-ai/dsh-client-ui-primitives', + 'react', + 'react-dom', +]) + +function packagePath(root, packageName, file = 'package.json') { + return join(root, 'node_modules', ...packageName.split('/'), file) +} + +async function json(path) { + return JSON.parse(await readFile(path, 'utf8')) +} + +function requireObject(value, description) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${description} is invalid`) + } + return value +} + +function requireText(source, expected, description) { + if (!source.includes(expected)) throw new Error(`${description} is missing ${JSON.stringify(expected)}`) +} + +function requireStaticModule(source, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = name === 'react' + ? /(?:^|[{,])["']?react["']?:/ + : new RegExp(`["']${escaped}["']:`) + if (!pattern.test(source)) throw new Error(`DSH Web static module table is missing ${JSON.stringify(name)}`) +} + +function exported(manifest, subpath) { + const exports = requireObject(manifest.exports, `${manifest.name} exports`) + return Object.hasOwn(exports, subpath) +} + +function webRowPattern(id, packageName) { + const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const escapedPackage = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`(?:^|\\n)- id: ${escapedId}\\n(?: {2}[^\\n]*\\n)*? {2}name: ['\"]?${escapedPackage}['\"]?(?:\\n|$)`) +} + +export async function verifyDshCompatibility({ packageRoot, expectedVersion, home }) { + if (typeof packageRoot !== 'string' || packageRoot === '') throw new TypeError('DSH package root is required') + if (typeof expectedVersion !== 'string' || expectedVersion === '') throw new TypeError('Expected DSH version is required') + if (typeof home !== 'string' || home === '') throw new TypeError('Temporary DSH home is required') + + const manifest = await json(join(packageRoot, 'package.json')) + if (manifest.name !== '@deepseek-ai/dsh') throw new Error('DSH package name is invalid') + if (manifest.version !== expectedVersion) { + throw new Error(`DSH package version ${JSON.stringify(manifest.version)} does not match ${JSON.stringify(expectedVersion)}`) + } + if (manifest.bin?.dsh !== 'lib/bin.js') throw new Error('DSH command entry is not lib/bin.js') + const dependencies = requireObject(manifest.dependencies, 'DSH dependencies') + for (const packageName of DSH_DIRECT_COMPATIBILITY_PACKAGES) { + if (typeof dependencies[packageName] !== 'string') throw new Error(`DSH does not declare ${packageName}`) + } + + for (const [packageName, requiredExports] of Object.entries(DSH_COMPATIBILITY_PACKAGES)) { + const dependency = await json(packagePath(packageRoot, packageName)) + if (dependency.name !== packageName || dependency.version !== expectedVersion) { + throw new Error(`${packageName} is not installed at DSH version ${expectedVersion}`) + } + for (const subpath of requiredExports) { + if (!exported(dependency, subpath)) throw new Error(`${packageName} does not export ${subpath}`) + } + } + + const remotes = await readFile(packagePath(packageRoot, '@deepseek-ai/dsh-api-remotes', 'lib/client.js'), 'utf8') + for (const method of DSH_REMOTE_METHODS) requireText(remotes, method, 'DSH Remote registry') + const frontendRoot = packagePath(packageRoot, '@deepseek-ai/dsh-web-frontend', 'dist/assets') + const frontend = (await Promise.all((await readdir(frontendRoot)) + .filter(name => name.endsWith('.js')) + .map(name => readFile(join(frontendRoot, name), 'utf8')))).join('\n') + for (const moduleName of DSH_STATIC_CLIENT_MODULES) { + requireStaticModule(frontend, moduleName) + } + + const { stdout, stderr } = await execFileAsync( + process.execPath, + [join(packageRoot, 'lib/bin.js'), '--profile', 'web', '--dump-default-config'], + { + env: { ...process.env, DSH_HOME: home }, + maxBuffer: 8 * 1024 * 1024, + timeout: 30_000, + }, + ) + if (stderr.trim() !== '') throw new Error(`DSH config dump wrote to stderr: ${stderr.trim()}`) + for (const [id, packageName] of Object.entries(DSH_WEB_ROWS)) { + if (!webRowPattern(id, packageName).test(stdout)) { + throw new Error(`DSH Web profile does not contain ${id} (${packageName})`) + } + } + + return Object.freeze({ + name: manifest.name, + version: manifest.version, + packages: Object.keys(DSH_COMPATIBILITY_PACKAGES).length, + remoteMethods: DSH_REMOTE_METHODS.length, + webRows: Object.keys(DSH_WEB_ROWS).length, + }) +} diff --git a/upstream/container/platform/lib/official-dsh-contracts.mjs b/upstream/container/platform/lib/official-dsh-contracts.mjs new file mode 100644 index 0000000..2c93865 --- /dev/null +++ b/upstream/container/platform/lib/official-dsh-contracts.mjs @@ -0,0 +1,15 @@ +import { compareDshVersions } from './supported-target.mjs' + +export const OFFICIAL_DSH_PACKAGE = '@deepseek-ai/dsh' +export const OFFICIAL_NPM_REGISTRY = 'https://registry.npmjs.org/' +export const OFFICIAL_DSH_METADATA_LIMIT = 20 * 1024 * 1024 +export const OFFICIAL_DSH_TARBALL_LIMIT = 512 * 1024 * 1024 + +export function officialDshMetadataUrl() { + return new URL(encodeURIComponent(OFFICIAL_DSH_PACKAGE), OFFICIAL_NPM_REGISTRY) +} + +export function officialDshTarballUrl(version) { + compareDshVersions(version, version) + return new URL(`${OFFICIAL_DSH_PACKAGE}/-/dsh-${version}.tgz`, OFFICIAL_NPM_REGISTRY) +} diff --git a/upstream/container/platform/lib/outbound-proxy.mjs b/upstream/container/platform/lib/outbound-proxy.mjs new file mode 100644 index 0000000..99173ce --- /dev/null +++ b/upstream/container/platform/lib/outbound-proxy.mjs @@ -0,0 +1,98 @@ +const LOOPBACK = '127.0.0.1' + +export const OUTBOUND_PROXY_PORTS = Object.freeze({ + updates: 17891, + platform: 17892, + dshCore: 17893, + dshPlugins: 17894, + agentNetwork: 17895, + managementTerminal: 17896, + modelApi: 17897, + sharedDsh: 17898, +}) + +export const OUTBOUND_PROXY_SCOPES = Object.freeze(Object.keys(OUTBOUND_PROXY_PORTS)) +export const PLATFORM_NO_PROXY = Object.freeze(['localhost', '127.0.0.1', '::1']) +export const OUTBOUND_PROXY_ENVIRONMENT_KEYS = Object.freeze([ + 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', + 'ALL_PROXY', 'all_proxy', +]) + +export function outboundProxyUrl(scope) { + const port = OUTBOUND_PROXY_PORTS[scope] + if (port === undefined) throw new Error(`outbound proxy scope ${String(scope)} is invalid`) + return `http://${LOOPBACK}:${String(port)}` +} + +export function outboundProxyScopeEnabled(configuration, scope, providerId) { + if (configuration?.enabled !== true) return false + if (scope === 'sharedDsh') return configuration.scopes?.dshCore === true || configuration.scopes?.dshPlugins === true + if (scope === 'modelApi') { + if (providerId === undefined) return false + const policy = configuration.modelApi?.providers?.[providerId] ?? configuration.modelApi?.default + return policy?.proxyEnabled === true + } + return configuration.scopes?.[scope] === true +} + +export function outboundProxyEnvironment(scope, { + noProxy = PLATFORM_NO_PROXY, + allProxy = false, + enabled = true, +} = {}) { + if (!Array.isArray(noProxy) || noProxy.some(value => typeof value !== 'string' || value.length === 0)) { + throw new Error('outbound proxy NO_PROXY entries are invalid') + } + if (typeof allProxy !== 'boolean') throw new Error('outbound proxy ALL_PROXY setting is invalid') + const proxy = outboundProxyUrl(scope) + const bypass = [...new Set(noProxy)].join(',') + if (typeof enabled !== 'boolean') throw new Error('outbound proxy enabled setting is invalid') + return Object.freeze({ + ...Object.fromEntries(OUTBOUND_PROXY_ENVIRONMENT_KEYS.map(key => [key, undefined])), + NO_PROXY: bypass, + no_proxy: bypass, + ...(enabled ? { + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + http_proxy: proxy, + https_proxy: proxy, + ...(allProxy ? { ALL_PROXY: proxy, all_proxy: proxy } : {}), + } : {}), + }) +} + +export function parseOutboundProxyEnvironment(value, scope) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('outbound proxy environment response is invalid') + } + const expectedUrl = outboundProxyUrl(scope) + const allowed = new Set([ + 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', + 'NO_PROXY', 'no_proxy', 'ALL_PROXY', 'all_proxy', + ]) + if (Object.keys(value).some(key => !allowed.has(key))) { + throw new Error('outbound proxy environment response contains unsupported fields') + } + const proxyKeys = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'] + const proxied = proxyKeys.some(key => value[key] !== undefined) + for (const key of proxyKeys) { + if (proxied ? value[key] !== expectedUrl : value[key] !== undefined) { + throw new Error(`outbound proxy environment ${key} is invalid`) + } + } + if (typeof value.NO_PROXY !== 'string' || value.no_proxy !== value.NO_PROXY) { + throw new Error('outbound proxy environment NO_PROXY is invalid') + } + const hasAllProxy = value.ALL_PROXY !== undefined || value.all_proxy !== undefined + if (hasAllProxy && (value.ALL_PROXY !== expectedUrl || value.all_proxy !== expectedUrl)) { + throw new Error('outbound proxy environment ALL_PROXY is invalid') + } + return Object.freeze(Object.fromEntries(Object.entries(value))) +} + +export function clearOutboundProxyEnvironment(value = {}) { + return Object.freeze({ + ...Object.fromEntries(OUTBOUND_PROXY_ENVIRONMENT_KEYS.map(key => [key, undefined])), + ...value, + }) +} diff --git a/upstream/container/platform/lib/paths.mjs b/upstream/container/platform/lib/paths.mjs new file mode 100644 index 0000000..270586d --- /dev/null +++ b/upstream/container/platform/lib/paths.mjs @@ -0,0 +1,155 @@ +import { randomUUID } from 'node:crypto' +import { chmod, chown, lstat, mkdir, rename, rm, symlink } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' + +const LEGACY_NAMES = Object.freeze([ + 'bootstrap', 'downloads', 'dsh', 'environments', 'run', 'runtime', 'snapshots', 'system-plugins', 'trust', +]) + +async function exists(path) { + return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error)) +} + +export class PlatformPaths { + constructor(dataRoot = '/data/platform', runRoot = '/run/dsh-platform') { + this.dataRoot = resolve(dataRoot) + this.runRoot = resolve(runRoot) + this.stateRoot = join(this.dataRoot, 'state') + this.storeRoot = join(this.dataRoot, 'store') + this.cacheRoot = join(this.dataRoot, 'cache') + this.logsRoot = join(this.dataRoot, 'logs') + this.trustStateRoot = join(this.stateRoot, 'trust') + this.developmentTrustStateRoot = join(this.trustStateRoot, 'development') + this.bootstrapStateRoot = join(this.stateRoot, 'bootstrap') + this.deploymentStateRoot = join(this.stateRoot, 'deployments') + this.deploymentStatusPath = join(this.deploymentStateRoot, 'status.json') + this.systemSkillStatePath = join(this.deploymentStateRoot, 'skills.json') + this.updaterStateRoot = join(this.stateRoot, 'updater') + this.proxyStateRoot = join(this.stateRoot, 'proxy') + this.accessStateRoot = join(this.stateRoot, 'access') + this.proxyTestStatePath = join(this.proxyStateRoot, 'test-tasks.json') + this.managementStateRoot = join(this.stateRoot, 'management') + this.userPluginStatePath = join(this.managementStateRoot, 'user-plugins.json') + this.userPluginJournalPath = join(this.managementStateRoot, 'user-plugin-transaction.json') + this.proxyProviderInventoryPath = join(this.managementStateRoot, 'proxy-provider-inventory.json') + this.fileTasksRoot = join(this.managementStateRoot, 'file-tasks') + this.maintenanceLeasePath = join(this.managementStateRoot, 'maintenance-mutation.lock') + this.objectsRoot = join(this.storeRoot, 'objects') + this.bootstrapStoreRoot = join(this.storeRoot, 'bootstrap') + this.environmentsRoot = join(this.storeRoot, 'environments') + this.systemSkillCatalogsRoot = join(this.environmentsRoot, 'versions', '.system-skill-catalogs') + this.pristineRoot = join(this.storeRoot, 'pristine') + this.runtimesRoot = join(this.storeRoot, 'runtimes') + this.systemPluginsRoot = join(this.storeRoot, 'system-plugins') + this.snapshotsRoot = join(this.storeRoot, 'snapshots') + this.userPluginSnapshotsRoot = join(this.snapshotsRoot, 'user-plugins') + this.downloadsRoot = join(this.cacheRoot, 'downloads') + this.npmCacheRoot = join(this.cacheRoot, 'npm') + this.viewsRoot = join(this.runRoot, 'views') + this.deploymentViewsRoot = join(this.runRoot, 'deployments') + this.systemPluginViewsRoot = join(this.runRoot, 'system-plugin-views') + this.systemSkillsView = join(this.viewsRoot, 'skills') + this.deploymentView = join(this.runRoot, 'deployment') + this.trustSocket = join(this.runRoot, 'stage0-trust.sock') + this.bootstrapSocket = join(this.runRoot, 'bootstrap.sock') + this.dshLifecycleSocket = join(this.runRoot, 'dsh-lifecycle.sock') + this.managementSocket = join(this.runRoot, 'management.sock') + this.managementCliSocket = join(this.runRoot, 'management-cli.sock') + this.maintenanceSocket = join(this.runRoot, 'maintenance.sock') + this.userSkillSocket = join(this.runRoot, 'user-skills.sock') + this.snapshotSocket = join(this.runRoot, 'snapshot.sock') + this.gatewayAccessSocket = join(this.runRoot, 'gateway-access.sock') + this.accessRunRoot = join(this.runRoot, 'access') + this.accessSocket = join(this.accessRunRoot, 'access.sock') + this.accessRecoverySocket = join(this.accessRunRoot, 'recovery.sock') + this.accessLaunchSocket = join(this.runRoot, 'access-launch.sock') + this.proxyControlSocket = join(this.runRoot, 'outbound-proxy.sock') + this.proxyRoutingStatePath = join(this.runRoot, 'outbound-proxy-routing.json') + this.proxyLaunchSocket = join(this.runRoot, 'proxy-launch.sock') + this.recoverySocket = join(this.runRoot, 'recovery.sock') + } +} + +export function trustStateRootForAuthority(paths, authority) { + return authority === 'development' ? paths.developmentTrustStateRoot : paths.trustStateRoot +} + +export async function rejectLegacyLayout(paths) { + const found = [] + for (const name of LEGACY_NAMES) { + if (await exists(join(paths.dataRoot, name))) found.push(name) + } + if (found.length > 0) { + throw new Error( + `legacy DSH platform volume layout detected (${found.join(', ')}); stop the container and clear only ${paths.dataRoot}, then restart. Do not delete /data/dsh.`, + ) + } +} + +export async function preparePersistentLayout(paths) { + await mkdir(paths.dataRoot, { recursive: true }) + await rejectLegacyLayout(paths) + const directories = [ + paths.trustStateRoot, paths.bootstrapStateRoot, paths.deploymentStateRoot, paths.updaterStateRoot, + paths.managementStateRoot, paths.fileTasksRoot, + paths.objectsRoot, paths.bootstrapStoreRoot, paths.environmentsRoot, paths.pristineRoot, + paths.runtimesRoot, paths.systemPluginsRoot, paths.snapshotsRoot, paths.userPluginSnapshotsRoot, + paths.downloadsRoot, paths.npmCacheRoot, paths.logsRoot, + ] + await Promise.all(directories.map(path => mkdir(path, { recursive: true }))) + return Object.freeze([...directories]) +} + +export async function resetRuntimeLayout(paths) { + if (paths.runRoot === '/' || paths.runRoot === paths.dataRoot) throw new Error('platform run root is unsafe') + await rm(paths.runRoot, { recursive: true, force: true }) + await mkdir(paths.viewsRoot, { recursive: true }) + await mkdir(paths.deploymentViewsRoot, { recursive: true }) + await mkdir(paths.systemPluginViewsRoot, { recursive: true }) + await mkdir(paths.systemSkillsView, { recursive: true }) + for (const name of ['environment', 'runtime']) { + await symlink(join('..', 'deployment', name), join(paths.viewsRoot, name), 'dir') + } + await symlink(join('..', 'system-plugin-views', 'current'), join(paths.viewsRoot, 'system-plugins'), 'dir') + await symlink(join('..', 'deployment', 'system-plugins'), join(paths.systemPluginViewsRoot, 'current'), 'dir') + if (process.getuid?.() === 0) { + await chown(paths.runRoot, 0, 1000) + // Isolated service identities must traverse to their dedicated subtrees and + // immutable Bootstrap entrypoints without gaining the node group's socket access. + await chmod(paths.runRoot, 0o1771) + await chown(paths.viewsRoot, 0, 0) + await chmod(paths.viewsRoot, 0o755) + await chown(paths.deploymentViewsRoot, 1000, 1000) + await chmod(paths.deploymentViewsRoot, 0o755) + await chown(paths.systemPluginViewsRoot, 1000, 1000) + await chmod(paths.systemPluginViewsRoot, 0o755) + await chown(paths.systemSkillsView, 1000, 1000) + await chmod(paths.systemSkillsView, 0o755) + } +} + +export async function replaceRuntimeView(paths, name, target) { + if (name !== 'bootstrap') { + throw new Error(`runtime view ${name} is invalid`) + } + const path = join(paths.viewsRoot, name) + const temporary = join(dirname(path), `.${name}.${randomUUID()}.tmp`) + await symlink(resolve(target), temporary, 'dir') + await rename(temporary, path) + return path +} + +export async function replaceDeploymentView(paths, target) { + const temporary = join(paths.runRoot, `.deployment.${randomUUID()}.tmp`) + await symlink(resolve(target), temporary, 'dir') + await rename(temporary, paths.deploymentView) + return paths.deploymentView +} + +export async function replaceSystemPluginView(paths, target = join(paths.deploymentView, 'system-plugins')) { + const path = join(paths.systemPluginViewsRoot, 'current') + const temporary = join(paths.systemPluginViewsRoot, `.current.${randomUUID()}.tmp`) + await symlink(resolve(target), temporary, 'dir') + await rename(temporary, path) + return path +} diff --git a/upstream/container/platform/lib/supported-target.mjs b/upstream/container/platform/lib/supported-target.mjs new file mode 100644 index 0000000..fe503c2 --- /dev/null +++ b/upstream/container/platform/lib/supported-target.mjs @@ -0,0 +1,92 @@ +import { exactKeys, parseJsonDocument, TrustError } from './validation.mjs' + +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ + +function parseSemver(value, label) { + if (typeof value !== 'string') throw new TrustError(`${label} must be a semantic version`) + const match = SEMVER.exec(value) + if (match === null) throw new TrustError(`${label} must be a semantic version`) + const prerelease = match[4] === undefined ? [] : match[4].split('.') + for (const identifier of prerelease) { + if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0')) { + throw new TrustError(`${label} has an invalid numeric prerelease identifier`) + } + } + return { + core: match.slice(1, 4), + prerelease, + } +} + +function compareNumeric(left, right) { + if (left.length !== right.length) return left.length < right.length ? -1 : 1 + if (left === right) return 0 + return left < right ? -1 : 1 +} + +function compareIdentifier(left, right) { + const leftNumeric = /^\d+$/.test(left) + const rightNumeric = /^\d+$/.test(right) + if (leftNumeric && rightNumeric) return compareNumeric(left, right) + if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1 + if (left === right) return 0 + return left < right ? -1 : 1 +} + +export function compareSemanticVersions(leftValue, rightValue, leftLabel = 'left version', rightLabel = 'right version') { + const left = parseSemver(leftValue, leftLabel) + const right = parseSemver(rightValue, rightLabel) + for (let index = 0; index < left.core.length; index += 1) { + const compared = compareNumeric(left.core[index], right.core[index]) + if (compared !== 0) return compared + } + if (left.prerelease.length === 0 || right.prerelease.length === 0) { + return left.prerelease.length === right.prerelease.length ? 0 : left.prerelease.length === 0 ? 1 : -1 + } + const length = Math.max(left.prerelease.length, right.prerelease.length) + for (let index = 0; index < length; index += 1) { + if (left.prerelease[index] === undefined) return -1 + if (right.prerelease[index] === undefined) return 1 + const compared = compareIdentifier(left.prerelease[index], right.prerelease[index]) + if (compared !== 0) return Math.sign(compared) + } + return 0 +} + +export function compareDshVersions(leftValue, rightValue) { + return compareSemanticVersions(leftValue, rightValue, 'left DSH version', 'right DSH version') +} + +export function parseSupportedTarget(bytes) { + const object = parseJsonDocument(bytes, 'supported target') + exactKeys(object, ['environment', 'latestSupportedDsh', 'schema'], 'supported target') + if (object.schema !== 1) throw new TrustError('supported target schema must be 1') + parseSemver(object.latestSupportedDsh, 'latestSupportedDsh') + if (typeof object.environment !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(object.environment)) { + throw new TrustError('supported target environment is invalid') + } + return Object.freeze({ + schema: 1, + latestSupportedDsh: object.latestSupportedDsh, + environment: object.environment, + }) +} + +export function validateSupportedTarget(targetBytes, environmentDefinitionBytes) { + const target = parseSupportedTarget(targetBytes) + const definition = parseJsonDocument(environmentDefinitionBytes, 'environment definition') + if (definition.version !== target.environment) { + throw new TrustError(`supported Environment ${JSON.stringify(target.environment)} does not match definition ${JSON.stringify(definition.version)}`) + } + return target +} + +export function advanceSupportedDsh(target, upstreamVersion) { + const comparison = compareDshVersions(upstreamVersion, target.latestSupportedDsh) + if (comparison < 0) throw new TrustError('upstream DSH version would roll back Latest Supported') + if (comparison === 0) return Object.freeze({ changed: false, target }) + return Object.freeze({ + changed: true, + target: Object.freeze({ ...target, latestSupportedDsh: upstreamVersion }), + }) +} diff --git a/upstream/container/platform/lib/tree-hash.mjs b/upstream/container/platform/lib/tree-hash.mjs new file mode 100644 index 0000000..a34c65c --- /dev/null +++ b/upstream/container/platform/lib/tree-hash.mjs @@ -0,0 +1,45 @@ +import { createHash } from 'node:crypto' +import { lstat, readFile, readdir, readlink } from 'node:fs/promises' +import { join } from 'node:path' +import { canonicalJson } from './canonical-json.mjs' +import { TrustError } from './validation.mjs' + +async function entries(root, relative = '', includeMode = true) { + const names = await readdir(join(root, relative)) + const result = [] + for (const name of names.sort()) { + const path = relative === '' ? name : `${relative}/${name}` + const absolute = join(root, path) + const details = await lstat(absolute) + const mode = details.mode & 0o777 + if (details.isDirectory()) { + result.push({ path, type: 'directory', ...(includeMode ? { mode } : {}) }) + result.push(...await entries(root, path, includeMode)) + } else if (details.isFile()) { + result.push({ + path, + type: 'file', + ...(includeMode ? { mode } : {}), + sha256: createHash('sha256').update(await readFile(absolute)).digest('hex'), + size: details.size, + }) + } else if (details.isSymbolicLink()) { + const target = await readlink(absolute) + if (target.includes('\0')) throw new TrustError(`tree symlink ${path} is invalid`) + result.push({ path, type: 'symlink', ...(includeMode ? { mode } : {}), target }) + } else { + throw new TrustError(`tree entry ${path} has an unsupported type`) + } + } + return result +} + +export async function hashTree(root) { + const manifest = { schema: 1, entries: await entries(root) } + return createHash('sha256').update(canonicalJson(manifest)).digest('hex') +} + +export async function hashTreeContent(root) { + const manifest = { schema: 1, entries: await entries(root, '', false) } + return createHash('sha256').update(canonicalJson(manifest)).digest('hex') +} diff --git a/upstream/container/platform/lib/validation.mjs b/upstream/container/platform/lib/validation.mjs new file mode 100644 index 0000000..597a0d1 --- /dev/null +++ b/upstream/container/platform/lib/validation.mjs @@ -0,0 +1,49 @@ +export class TrustError extends Error { + constructor(message, code = 'TRUST_INVALID') { + super(message) + this.name = 'TrustError' + this.code = code + } +} + +export function plainObject(value, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TrustError(`${label} must be an object`) + } + return value +} + +export function exactKeys(value, expected, label) { + const actual = Object.keys(value).sort() + const wanted = [...expected].sort() + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new TrustError(`${label} fields must be exactly: ${wanted.join(', ')}`) + } +} + +export function positiveSafeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new TrustError(`${label} must be a positive safe integer`) + } + return value +} + +export function isoTimestamp(value, label) { + if (typeof value !== 'string') throw new TrustError(`${label} must be an ISO timestamp`) + const parsed = new Date(value) + if (!Number.isFinite(parsed.valueOf()) || parsed.toISOString() !== value) { + throw new TrustError(`${label} must be a canonical ISO timestamp`) + } + return value +} + +export function parseJsonDocument(bytes, label) { + if (!Buffer.isBuffer(bytes)) throw new TrustError(`${label} must be bytes`) + let value + try { + value = JSON.parse(bytes.toString('utf8')) + } catch { + throw new TrustError(`${label} must contain valid JSON`) + } + return plainObject(value, label) +} diff --git a/upstream/container/platform/package.json b/upstream/container/platform/package.json new file mode 100644 index 0000000..0072a0f --- /dev/null +++ b/upstream/container/platform/package.json @@ -0,0 +1,11 @@ +{ + "name": "dsh-docker-platform", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=24" + } +} diff --git a/upstream/container/platform/seed/trust/DEVELOPMENT_FIXTURE b/upstream/container/platform/seed/trust/DEVELOPMENT_FIXTURE new file mode 100644 index 0000000..b7b35db --- /dev/null +++ b/upstream/container/platform/seed/trust/DEVELOPMENT_FIXTURE @@ -0,0 +1,2 @@ +This public trust fixture is for local builds and tests only. +Release workflows must replace it with an offline Recovery-signed seed and reject this marker. diff --git a/upstream/container/platform/seed/trust/keyring.json b/upstream/container/platform/seed/trust/keyring.json new file mode 100644 index 0000000..7422f0b --- /dev/null +++ b/upstream/container/platform/seed/trust/keyring.json @@ -0,0 +1 @@ +{"current":{"algorithm":"Ed25519","keyId":"67d0eaa2e221472b07bb7e090f13fbc33662f00b1b95b77650aa98fcbfc3917c","publicKey":"MCowBQYDK2VwAyEAloaqTWysQwhw98hQc+bhcd7zjvngnhLw9fjaC8X28Ho="},"generation":1,"issuedAt":"2026-08-19T00:00:00.000Z","next":{"algorithm":"Ed25519","keyId":"1fe144dde06c2525dcf73b50363d051d821d9ddc80f6c2954a0a9a9a23d07cf5","publicKey":"MCowBQYDK2VwAyEAz4DKA6dm2sx/zmQA96MJjJ3xWEIsYZRVLw+ebFc/kAY="},"revokedKeyIds":[],"schema":1} diff --git a/upstream/container/platform/seed/trust/keyring.sig.json b/upstream/container/platform/seed/trust/keyring.sig.json new file mode 100644 index 0000000..30694fa --- /dev/null +++ b/upstream/container/platform/seed/trust/keyring.sig.json @@ -0,0 +1 @@ +{"algorithm":"Ed25519","keyId":"e40cdc209514de354c2c1976bf3476ef21753058c73ba9bf373a20f201860fd6","schema":1,"signature":"WXErXfkHnhprgnywUB4ukhizyqqBBBAoxQE9WDN4Q452lz1nm867eBrUsKpMfrh7DE0OU7Z+FN4g1leCEawiBg=="} diff --git a/upstream/container/platform/seed/trust/recovery-root.spki.base64 b/upstream/container/platform/seed/trust/recovery-root.spki.base64 new file mode 100644 index 0000000..990e868 --- /dev/null +++ b/upstream/container/platform/seed/trust/recovery-root.spki.base64 @@ -0,0 +1 @@ +MCowBQYDK2VwAyEA2mTS4kmUv3mIxqkJkQc949uZ7mOLtPT2g7iWU7mstRM= diff --git a/upstream/container/platform/stage0/index.mjs b/upstream/container/platform/stage0/index.mjs new file mode 100644 index 0000000..79deaff --- /dev/null +++ b/upstream/container/platform/stage0/index.mjs @@ -0,0 +1,372 @@ +#!/usr/bin/env node + +import { chmod, chown, mkdir, readFile } from 'node:fs/promises' +import { randomBytes } from 'node:crypto' +import { join } from 'node:path' +import { TrustLedger } from './lib/ledger.mjs' +import { VerifiedObjectStore } from './lib/artifacts.mjs' +import { BootstrapManager } from './lib/slots.mjs' +import { BootstrapSupervisor } from './lib/supervisor.mjs' +import { createTrustServer, listenUnix } from './lib/trust-server.mjs' +import { provisionPlatformSeed } from './lib/seed.mjs' +import { + PlatformPaths, + preparePersistentLayout, + replaceRuntimeView, + resetRuntimeLayout, + trustStateRootForAuthority, +} from '../lib/paths.mjs' +import { parseImageInventory, recordsFromImageInventory } from '../lib/deployment-contracts.mjs' +import { readDeploymentStatus } from '../lib/deployment-status.mjs' +import { createRecoveryServer, listenRecovery } from './lib/recovery-server.mjs' +import { JsonlLogManager } from '../../control-plane/modules/log-manager/index.mjs' +import { LocalApiClient } from '../../control-plane/modules/updater/lib/client.mjs' +import { consumeInternalCapability } from '../lib/access-capability.mjs' +import { collectAccessEvidence } from '../lib/access-evidence.mjs' +import { createMaintenanceServer, listenMaintenance } from './lib/maintenance-server.mjs' +import { prepareUserSkillRoots, UserSkillManager } from '../../control-plane/services/management/user-skills.mjs' +import { PersistentStateSnapshots } from '../../control-plane/modules/updater/lib/snapshots.mjs' +import { UserPluginSnapshots } from '../../control-plane/modules/plugin-manager/user-snapshots.mjs' +import { createSnapshotServer, listenSnapshots } from './lib/snapshot-server.mjs' +import { createUserSkillServer, listenUserSkills } from './lib/user-skill-server.mjs' +import { prepareUserWritableRoots } from './lib/user-roots.mjs' +import { + ProxyLaunchBroker, + createProxyLaunchServer, + listenProxyLaunch, +} from './lib/proxy-launch-server.mjs' +import { + AccessLaunchBroker, + createAccessLaunchServer, + listenAccessLaunch, +} from './lib/access-launch-server.mjs' +import { + clearOutboundProxyEnvironment, + outboundProxyEnvironment, + outboundProxyScopeEnabled, + parseOutboundProxyEnvironment, +} from '../lib/outbound-proxy.mjs' + +const dataRoot = process.env.DSH_PLATFORM_DATA ?? '/data/platform' +const runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform' +const paths = new PlatformPaths(dataRoot, runRoot) +const seedRoot = process.env.DSH_PLATFORM_SEED ?? '/opt/dsh-platform/seed' +const dshHome = process.env.DSH_HOME ?? '/data/dsh' +const defaultWorkspace = process.env.DSH_DEFAULT_WORKSPACE ?? '/workspace' +const agentsHome = process.env.DSH_AGENTS_HOME ?? '/home/node/.agents' +const inventory = parseImageInventory(await readFile(join(seedRoot, 'inventory.json'))) +const proxyUid = 991 +const proxyGid = 991 +const accessUid = 992 +const accessGid = 992 +const platformGid = 1000 +const imageRecords = recordsFromImageInventory(inventory) +const recoveryPublicKey = (await readFile(join(seedRoot, 'trust', 'recovery-root.spki.base64'), 'utf8')).trim() +const trustStateRoot = trustStateRootForAuthority(paths, inventory.authority) +const accessEvidence = await collectAccessEvidence({ + dshHome, + paths, + legacyAuthenticationConfigured: Boolean( + process.env.DSH_PROXY_USERNAME || process.env.DSH_PROXY_PASSWORD || process.env.DSH_PLATFORM_PASSWORD, + ), +}) +await preparePersistentLayout(paths) +const logs = new JsonlLogManager({ + root: paths.logsRoot, + maxBytes: Number(process.env.DSH_LOG_MAX_BYTES ?? 104857600), + retentionDays: Number(process.env.DSH_LOG_RETENTION_DAYS ?? 14), + output: { stdout: process.stdout, stderr: process.stderr }, + fileMode: 0o660, + fileGid: process.getgid?.() === 0 ? 1000 : undefined, +}) +const rateLimitedAccessMessages = new Set([ + 'access.authentication.failed', + 'access.fresh-authentication.failed', + 'access.totp.failed', +]) +const auditedAccessMessages = new Set([ + 'access.authentication-reset-key.generated', + 'access.authentication-reset.completed', + 'access.authentication-reset.failed', + 'access.authentication-retry.cleared', + 'access.recovery-account.changed', + 'access.recovery-operation.failed', +]) +const reportAccess = async (message, fields = {}) => { + if (rateLimitedAccessMessages.has(message)) { + await logs.diagnosticRateLimited( + `${message}:${fields.kind === 'management' ? 'management' : 'main'}`, + 'access-manager', message, fields, + ) + } else { + await logs.diagnostic('access-manager', message, fields) + } + if (auditedAccessMessages.has(message)) await logs.audit(message, fields) +} +await logs.prepare() +logs.on('error', error => { void logs.diagnostic('log-manager', 'capture.failed', { error }) }) +const startup = async (phase, operation) => { + try { + return await operation() + } catch (error) { + await logs.diagnostic('stage0', 'stage0.startup.failed', { error, phase }) + throw error + } +} +await logs.diagnostic('stage0', 'stage0.starting', { + imageBuildId: inventory.imageBuildId, + targetSequence: inventory.targetSequence, +}) +await startup('runtime-layout', () => resetRuntimeLayout(paths)) +await startup('user-writable-roots', () => prepareUserWritableRoots({ + dshHome, + defaultWorkspace, + uid: process.getuid?.() === 0 ? 1000 : undefined, + gid: process.getgid?.() === 0 ? 1000 : undefined, +})) +await startup('user-skill-roots', () => prepareUserSkillRoots({ + dshHome, + agentsHome, + uid: process.getuid?.() === 0 ? 1000 : undefined, + gid: process.getgid?.() === 0 ? 1000 : undefined, +})) +const ledger = new TrustLedger(trustStateRoot, recoveryPublicKey) +const objects = new VerifiedObjectStore({ + objectRoot: paths.objectsRoot, + receiptRoot: join(trustStateRoot, 'receipts'), + untrustedRoot: paths.downloadsRoot, + ledger, +}) +await startup('seed-provision', () => provisionPlatformSeed(seedRoot, paths)) +const slots = new BootstrapManager({ + stateRoot: paths.bootstrapStateRoot, + storeRoot: paths.bootstrapStoreRoot, + seedRoot, + inventory, +}) +await startup('bootstrap-reconcile', () => slots.reconcileImage(imageRecords.bootstrap)) +const currentBootstrap = await startup('bootstrap-resolve', () => slots.current()) +await startup('bootstrap-view', () => replaceRuntimeView(paths, 'bootstrap', currentBootstrap.path)) +await startup('proxy-state-root', async () => { + await mkdir(paths.proxyStateRoot, { recursive: true }) + if (process.getuid?.() === 0) await chown(paths.proxyStateRoot, proxyUid, proxyGid) + await chmod(paths.proxyStateRoot, process.getuid?.() === 0 ? 0o700 : 0o700) +}) +await startup('access-state-root', async () => { + await mkdir(paths.accessStateRoot, { recursive: true }) + if (process.getuid?.() === 0) await chown(paths.accessStateRoot, accessUid, accessGid) + await chmod(paths.accessStateRoot, 0o700) +}) +await startup('access-run-root', async () => { + await mkdir(paths.accessRunRoot, { recursive: true }) + if (process.getuid?.() === 0) await chown(paths.accessRunRoot, accessUid, platformGid) + await chmod(paths.accessRunRoot, process.getuid?.() === 0 ? 0o710 : 0o700) +}) +const accessLaunchToken = randomBytes(32).toString('base64url') +const accessBroker = new AccessLaunchBroker({ + token: accessLaunchToken, + dataRoot, + runRoot, + script: join(paths.viewsRoot, 'bootstrap', 'control-plane', 'services', 'access-manager', 'index.mjs'), + accessSocket: paths.accessSocket, + recoverySocket: paths.accessRecoverySocket, + uid: accessUid, + gid: accessGid, + platformGid, + capture: (child, source, declaration) => logs.capture(child, source, declaration), + report: reportAccess, +}) +const accessLaunchServer = createAccessLaunchServer({ + broker: accessBroker, + token: accessLaunchToken, + report: (message, fields) => logs.diagnostic('stage0', message, fields), +}) +await startup('access-launch-api', () => listenAccessLaunch(accessLaunchServer, paths.accessLaunchSocket, platformGid)) +await logs.diagnostic('stage0', 'access-launch-api.ready') +const proxyLaunchToken = randomBytes(32).toString('base64url') +const proxyBroker = new ProxyLaunchBroker({ + token: proxyLaunchToken, + dataRoot, + runRoot, + script: join(paths.viewsRoot, 'bootstrap', 'control-plane', 'services', 'outbound-proxy', 'index.mjs'), + controlSocket: paths.proxyControlSocket, + uid: proxyUid, + gid: proxyGid, + platformGid, + capture: (child, source, declaration) => logs.capture(child, source, declaration), + report: (message, fields) => logs.diagnostic('outbound-proxy', message, fields), +}) +const proxyLaunchServer = createProxyLaunchServer({ + broker: proxyBroker, + token: proxyLaunchToken, + report: (message, fields) => logs.diagnostic('stage0', message, fields), +}) +await startup('proxy-launch-api', () => listenProxyLaunch(proxyLaunchServer, paths.proxyLaunchSocket, platformGid)) +await logs.diagnostic('stage0', 'proxy-launch-api.ready') +const seedKeyring = await readFile(join(seedRoot, 'trust', 'keyring.json')) +const seedSignature = JSON.parse(await readFile(join(seedRoot, 'trust', 'keyring.sig.json'), 'utf8')) +await startup('keyring', () => ledger.acceptKeyring(seedKeyring, seedSignature)) +const supervisor = new BootstrapSupervisor({ + slots, + dataRoot, + runRoot, + paths, + uid: process.getuid?.() === 0 ? 1000 : undefined, + gid: process.getgid?.() === 0 ? 1000 : undefined, + user: process.getuid?.() === 0 ? 'node' : undefined, + home: process.getuid?.() === 0 ? '/home/node' : undefined, + proxyLaunchToken, + accessLaunchToken, + accessEvidence, + entrypoint: 'platform/bootstrap/index.mjs', + seedRoot, + report: (message, fields) => logs.diagnostic('stage0', message, fields), +}) +const trustServer = createTrustServer({ + ledger, + objects, + stageBootstrap: async (receipt, version) => { + const packageObject = await objects.bootstrapPackage(receipt, version) + const staged = await slots.stageArchive(packageObject.path, { + version, + targetSequence: packageObject.receipt.targetSequence, + }) + await logs.diagnostic('stage0', 'bootstrap.staged', { + bootstrapVersion: version, + recordId: staged.record.id, + targetSequence: staged.record.targetSequence, + recordChanged: staged.recordChanged, + restartRequired: staged.restartRequired, + }) + if (staged.restartRequired) setImmediate(() => { + void supervisor.restart().catch(error => logs.diagnostic('stage0', 'bootstrap.activation.failed', { + error, + bootstrapVersion: version, + })) + }) + return Object.freeze({ + recordId: staged.record.id, + targetSequence: staged.record.targetSequence, + recordChanged: staged.recordChanged, + restartRequired: staged.restartRequired, + }) + }, + collectBootstrap: () => slots.collectGarbage(), + report: (message, fields) => logs.diagnostic('stage0-trust', message, fields), +}) +await startup('trust-api', () => listenUnix(trustServer, paths.trustSocket, { + mode: process.getuid?.() === 0 ? 0o660 : 0o600, + uid: process.getuid?.() === 0 ? 0 : undefined, + gid: process.getgid?.() === 0 ? 1000 : undefined, +})) +await logs.diagnostic('stage0', 'trust-api.ready') +const management = new LocalApiClient(paths.managementSocket) +const outboundProxy = new LocalApiClient(paths.proxyControlSocket) +const access = new LocalApiClient(paths.accessSocket) +const maintenance = await createMaintenanceServer({ + paths, + dshHome, + defaultWorkspace, + terminalEnvironment: async () => { + try { + const result = await outboundProxy.request('GET', '/v1/environment?scope=managementTerminal') + return clearOutboundProxyEnvironment(parseOutboundProxyEnvironment(result.environment, 'managementTerminal')) + } catch (error) { + await logs.diagnostic('stage0', 'outbound-proxy.terminal-environment.fallback', { + error, + level: 'warning', + }) + try { + const state = JSON.parse(await readFile(paths.proxyRoutingStatePath, 'utf8')) + return outboundProxyEnvironment('managementTerminal', { + enabled: outboundProxyScopeEnabled(state, 'managementTerminal'), + }) + } catch { + return outboundProxyEnvironment('managementTerminal') + } + } + }, + authorize: async request => { + const token = request.headers['x-dsh-internal-capability'] + if (typeof token !== 'string') return false + return consumeInternalCapability(access, { + token, audience: 'maintenance', method: request.method ?? 'GET', target: request.url ?? '/', + }) + }, + platformBusy: async () => { + try { + const status = await management.request('GET', '/_dsh_platform/api/v1/status') + return !['idle', 'success', 'failed'].includes(status.update?.status) + || ['starting', 'stopping', 'restarting', 'recovering'].includes(status.dshLifecycle?.state) + || status.runtimeReset?.status === 'resetting' + || status.systemPluginOperation?.status === 'running' + || status.userPluginOperation?.status === 'running' + } catch { return true } + }, + report: (message, fields) => logs.diagnostic('maintenance', message, fields), + fileReport: (message, fields) => logs.diagnostic('file-manager', message, fields), + terminalReport: (message, fields) => logs.diagnostic('terminal', message, fields), + audit: (message, fields) => logs.diagnostic('audit', message, { stream: 'audit', ...fields }), +}) +await startup('maintenance-api', () => listenMaintenance(maintenance.server, paths.maintenanceSocket)) +await logs.diagnostic('stage0', 'maintenance-api.ready', { privileged: true }) +const snapshotServer = createSnapshotServer({ + dshSnapshots: new PersistentStateSnapshots({ root: paths.snapshotsRoot, sourceRoot: dshHome }), + userPluginSnapshots: new UserPluginSnapshots({ + root: paths.userPluginSnapshotsRoot, + profileRoot: join(dshHome, 'profiles', 'web'), + }), + report: (message, fields) => logs.diagnostic('snapshot-manager', message, fields), +}) +await startup('snapshot-api', () => listenSnapshots(snapshotServer, paths.snapshotSocket)) +await logs.diagnostic('stage0', 'snapshot-api.ready', { privileged: true }) +const userSkillServer = createUserSkillServer({ + manager: new UserSkillManager({ dshHome, agentsHome }), + report: (message, fields) => logs.diagnostic('user-skill-manager', message, fields), +}) +await startup('user-skill-api', () => listenUserSkills(userSkillServer, paths.userSkillSocket)) +await logs.diagnostic('stage0', 'user-skill-api.ready', { privileged: true }) +await startup('bootstrap', () => supervisor.startWithRollback()) +const recoveryServer = createRecoveryServer({ + inventory, + deployments: () => readDeploymentStatus(paths.deploymentStatusPath), + supervisor, + report: (message, fields) => logs.diagnostic('stage0-recovery', message, fields), +}) +await startup('recovery-api', () => listenRecovery(recoveryServer, paths.recoverySocket)) +await logs.diagnostic('stage0', 'stage0.ready', { + bootstrapRecordId: (await slots.state()).current, + recoveryApi: true, + trustApi: true, +}) +let resolveSignal +const signal = new Promise(resolve => { resolveSignal = resolve }) +const onSignal = value => resolveSignal({ type: 'signal', signal: value }) +process.once('SIGINT', () => onSignal('SIGINT')) +process.once('SIGTERM', () => onSignal('SIGTERM')) +const outcome = await Promise.race([ + signal, + supervisor.fatal.then(error => ({ type: 'exit', error })), +]) +if (outcome.type === 'exit') await logs.diagnostic('stage0', 'stage0.fatal', { error: outcome.error }) +else await logs.diagnostic('stage0', 'stage0.stopping', { signal: outcome.signal }) +trustServer.close() +recoveryServer.close() +await maintenance.terminal.shutdown() +await new Promise(resolve => maintenance.server.close(resolve)) +await new Promise(resolve => snapshotServer.close(resolve)) +await new Promise(resolve => userSkillServer.close(resolve)) +try { + await supervisor.stop() +} catch (error) { + await logs.diagnostic('stage0', 'stage0.stop.failed', { error }) + throw error +} +await proxyBroker.stop() +await new Promise(resolve => proxyLaunchServer.close(resolve)) +await accessBroker.stop() +await new Promise(resolve => accessLaunchServer.close(resolve)) +await logs.diagnostic('stage0', 'stage0.stopped') +if (outcome.type === 'exit') { + throw outcome.error +} diff --git a/upstream/container/platform/stage0/lib/access-launch-server.mjs b/upstream/container/platform/stage0/lib/access-launch-server.mjs new file mode 100644 index 0000000..9da44fb --- /dev/null +++ b/upstream/container/platform/stage0/lib/access-launch-server.mjs @@ -0,0 +1,223 @@ +import { spawn } from 'node:child_process' +import { timingSafeEqual } from 'node:crypto' +import { once } from 'node:events' +import { createServer } from 'node:http' +import { chmod, chown, mkdir, rm } from 'node:fs/promises' +import { dirname } from 'node:path' + +const MAX_BODY_BYTES = 1024 + +function sameToken(left, right) { + const a = Buffer.from(left ?? '') + const b = Buffer.from(right ?? '') + return a.byteLength === b.byteLength && a.byteLength > 0 && timingSafeEqual(a, b) +} + +function send(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(`${JSON.stringify(value)}\n`) +} + +async function body(request) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.byteLength + if (size > MAX_BODY_BYTES) throw new Error('access launch request body is too large') + chunks.push(chunk) + } + return size === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +export function accessLaunchCommand({ node, script, uid, gid }) { + if (!Number.isInteger(uid) || uid < 1 || !Number.isInteger(gid) || gid < 1) { + throw new Error('Access Manager UID and GID must be positive integers') + } + return { + executable: '/usr/bin/setpriv', + args: [ + `--reuid=${String(uid)}`, + `--regid=${String(gid)}`, + '--clear-groups', + '--', node, script, + ], + } +} + +export function accessLaunchEnvironment({ environment = process.env, dataRoot, runRoot, classificationToken }) { + return { + HOME: '/nonexistent', + USER: 'dsh-access', + LOGNAME: 'dsh-access', + PATH: environment.PATH ?? '/usr/local/bin:/usr/bin:/bin', + DSH_PLATFORM_DATA: dataRoot, + DSH_PLATFORM_RUN: runRoot, + DSH_ACCESS_CLASSIFICATION_TOKEN: classificationToken, + } +} + +export class AccessLaunchBroker { + constructor({ + token, + dataRoot, + runRoot, + script, + accessSocket, + recoverySocket, + uid, + gid, + platformGid, + node = process.execPath, + spawnImpl = spawn, + capture = () => {}, + report = async () => {}, + readyTimeoutMs = 10_000, + }) { + this.token = token + this.dataRoot = dataRoot + this.runRoot = runRoot + this.script = script + this.accessSocket = accessSocket + this.recoverySocket = recoverySocket + this.uid = uid + this.gid = gid + this.platformGid = platformGid + this.node = node + this.spawnImpl = spawnImpl + this.capture = capture + this.report = report + this.readyTimeoutMs = readyTimeoutMs + this.child = undefined + this.ready = false + } + + status() { + const running = this.child !== undefined + && this.child.exitCode === null + && this.child.signalCode === null + return { + running, + componentReady: running && this.ready, + pid: running ? this.child.pid ?? null : null, + } + } + + async start() { + if (this.status().running) return this.status() + const command = accessLaunchCommand({ node: this.node, script: this.script, uid: this.uid, gid: this.gid }) + const child = this.spawnImpl(command.executable, command.args, { + env: accessLaunchEnvironment({ + dataRoot: this.dataRoot, runRoot: this.runRoot, classificationToken: this.token, + }), + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }) + this.child = child + this.ready = false + this.capture(child, 'access-manager', { stdout: true, stderr: true }) + const ready = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => reject(new Error( + `Access Manager exited before readiness (code=${String(code)}, signal=${String(signal)})`, + ))) + child.on('message', message => { + if (message?.type === 'ready' && message.componentReady === true) resolve() + if (message?.type === 'diagnostic' && typeof message.message === 'string') { + void this.report(message.message, message.fields ?? {}) + } + }) + }) + let timer + try { + await Promise.race([ + ready, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Access Manager readiness timed out')), this.readyTimeoutMs) + timer.unref?.() + }), + ]) + if (process.getuid?.() === 0) { + await chown(this.accessSocket, this.uid, this.platformGid) + await chmod(this.accessSocket, 0o660) + await chown(this.recoverySocket, this.uid, this.gid) + await chmod(this.recoverySocket, 0o600) + } + this.ready = true + await this.report('access-manager.launch.completed', { pid: child.pid ?? null }) + child.once('exit', (code, signal) => { + if (this.child !== child) return + this.child = undefined + this.ready = false + void this.report('access-manager.exited', { + level: code === 0 || signal === 'SIGTERM' ? 'info' : 'error', code, signal, + }) + }) + return this.status() + } catch (error) { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit') + child.kill('SIGTERM') + await exited.catch(() => {}) + } + if (this.child === child) this.child = undefined + this.ready = false + await this.report('access-manager.launch.failed', { error }) + throw error + } finally { + clearTimeout(timer) + } + } + + async stop() { + const child = this.child + if (child === undefined || child.exitCode !== null || child.signalCode !== null) { + this.child = undefined + this.ready = false + return this.status() + } + const exited = once(child, 'exit') + child.kill('SIGTERM') + const graceful = await Promise.race([ + exited.then(() => true), + new Promise(resolve => setTimeout(() => resolve(false), 5_000)), + ]) + if (!graceful && child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + await once(child, 'exit') + } + if (this.child === child) this.child = undefined + this.ready = false + await this.report('access-manager.stop.completed') + return this.status() + } +} + +export function createAccessLaunchServer({ broker, token, report = async () => {} }) { + return createServer((request, response) => { + void (async () => { + const pathname = new URL(request.url ?? '/', 'http://access-launch.internal').pathname + if (request.method === 'GET' && pathname === '/v1/status') return send(response, 200, broker.status()) + if (request.method !== 'POST' || !['/v1/start', '/v1/stop'].includes(pathname)) { + return send(response, 404, { error: 'not found' }) + } + const value = await body(request) + if (!sameToken(value.token, token)) return send(response, 403, { error: 'access launch authorization failed' }) + send(response, 200, pathname === '/v1/start' ? await broker.start() : await broker.stop()) + })().catch(async error => { + await report('access-launch.request.failed', { + error, method: request.method ?? null, pathname: request.url ?? null, + }) + send(response, 500, { error: error instanceof Error ? error.message : 'access launch failed' }) + }) + }) +} + +export async function listenAccessLaunch(server, socketPath, platformGid) { + await mkdir(dirname(socketPath), { recursive: true }) + await rm(socketPath, { force: true }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + if (process.getuid?.() === 0) await chown(socketPath, 0, platformGid) + await chmod(socketPath, process.getuid?.() === 0 ? 0o660 : 0o600) +} diff --git a/upstream/container/platform/stage0/lib/artifacts.mjs b/upstream/container/platform/stage0/lib/artifacts.mjs new file mode 100644 index 0000000..44435d4 --- /dev/null +++ b/upstream/container/platform/stage0/lib/artifacts.mjs @@ -0,0 +1,625 @@ +import { constants } from 'node:fs' +import { link, mkdir, open, readdir, readFile, rm } from 'node:fs/promises' +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { join, resolve } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { Transform } from 'node:stream' +import { durableReplace } from '../../lib/atomic.mjs' +import { + MANIFEST_MEDIA_TYPE, + parseArtifactList, + parseReleaseManifest, + SIGNATURE_MEDIA_TYPE, +} from '../../lib/artifact-contracts.mjs' +import { exactKeys, isoTimestamp, plainObject, positiveSafeInteger, TrustError } from '../../lib/validation.mjs' +import { compareDshVersions } from '../../lib/supported-target.mjs' +import { OFFICIAL_DSH_METADATA_LIMIT, OFFICIAL_DSH_TARBALL_LIMIT } from '../../lib/official-dsh-contracts.mjs' +import { candidateFromPackument, verifyOfficialDshCandidate } from './official-dsh.mjs' +import { verifyDetached } from './signature.mjs' + +export { + MANIFEST_MEDIA_TYPE, + parseArtifactDescriptor, + parseArtifactList, + parseReleaseManifest, + SIGNATURE_MEDIA_TYPE, +} from '../../lib/artifact-contracts.mjs' + +function nonNegativeSafeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TrustError(`${label} must be a non-negative safe integer`) + } + return value +} + +function descriptorsFromTarget(target) { + return parseArtifactList(target.document.artifacts, 'release target artifacts') +} + +function descriptorById(artifacts, artifactId) { + const descriptor = artifacts.find(artifact => artifact.id === artifactId) + if (descriptor === undefined) throw new TrustError(`artifact ${JSON.stringify(artifactId)} is not authorized`) + return descriptor +} + +async function atomicJson(path, value) { + await durableReplace(path, `${JSON.stringify(value)}\n`) +} + +async function hashFile(path) { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) + const hash = createHash('sha256') + let size = 0 + try { + if (!(await handle.stat()).isFile()) throw new TrustError('trusted object must be a regular file') + for await (const chunk of handle.createReadStream({ autoClose: false })) { + hash.update(chunk) + size += chunk.byteLength + } + } finally { + await handle.close() + } + return { sha256: hash.digest('hex'), size } +} + +function receiptToken() { + return randomBytes(32).toString('base64url') +} + +function validateReceipt(value) { + const receipt = plainObject(value, 'receipt') + const required = [ + 'artifactId', 'authoritySignature', 'importedAt', 'keyringGeneration', 'mediaType', + 'objectSha256', 'parentReceipt', 'parentSha256', 'signerKeyId', 'size', 'status', + 'targetSequence', 'token', + ] + const authorityType = receipt.authorityType ?? 'stable' + const receiptFields = receipt.authorityType === undefined + ? required + : [...required, 'authorityType', ...(authorityType === 'official-dsh' ? ['authorityVersion'] : [])] + exactKeys(receipt, receiptFields, 'receipt') + if (!['stable', 'official-dsh'].includes(authorityType)) throw new TrustError('receipt authority type is invalid') + if (authorityType === 'official-dsh') { + compareDshVersions(receipt.authorityVersion, receipt.authorityVersion) + } + if (typeof receipt.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(receipt.token)) { + throw new TrustError('receipt token is invalid') + } + if (!['staged', 'active', 'previous', 'retired', 'revoked'].includes(receipt.status)) { + throw new TrustError('receipt status is invalid') + } + if (typeof receipt.artifactId !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(receipt.artifactId)) { + throw new TrustError('receipt artifact ID is invalid') + } + if (typeof receipt.mediaType !== 'string' || !/^[\x21-\x7e]{1,127}$/.test(receipt.mediaType)) { + throw new TrustError('receipt media type is invalid') + } + for (const [name, digest] of [ + ['object SHA-256', receipt.objectSha256], + ['parent SHA-256', receipt.parentSha256], + ['signer key ID', receipt.signerKeyId], + ]) { + const valid = name === 'signer key ID' && authorityType === 'official-dsh' + ? typeof digest === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(digest) + : typeof digest === 'string' && /^[a-f0-9]{64}$/.test(digest) + if (!valid) { + throw new TrustError(`receipt ${name} is invalid`) + } + } + nonNegativeSafeInteger(receipt.size, 'receipt size') + positiveSafeInteger(receipt.keyringGeneration, 'receipt keyring generation') + positiveSafeInteger(receipt.targetSequence, 'receipt target sequence') + isoTimestamp(receipt.importedAt, 'receipt import time') + if ( + receipt.parentReceipt !== null + && (typeof receipt.parentReceipt !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(receipt.parentReceipt)) + ) throw new TrustError('receipt parent token is invalid') + if ( + receipt.authoritySignature !== null + && (typeof receipt.authoritySignature !== 'object' || Array.isArray(receipt.authoritySignature)) + ) throw new TrustError('receipt authority signature is invalid') + return Object.assign(receipt, { authorityType }) +} + +export class VerifiedObjectStore { + constructor({ + root, objectRoot, receiptRoot, untrustedRoot, ledger, now = () => new Date(), + }) { + if (root === undefined && (objectRoot === undefined || receiptRoot === undefined)) { + throw new TypeError('VerifiedObjectStore requires root or explicit objectRoot and receiptRoot') + } + this.objectRoot = resolve(objectRoot ?? join(root, 'objects')) + this.receiptRoot = resolve(receiptRoot ?? join(root, 'receipts')) + this.untrustedRoot = resolve(untrustedRoot) + this.ledger = ledger + this.now = now + this.queue = Promise.resolve() + } + + exclusive(operation) { + const result = this.queue.then(operation, operation) + this.queue = result.then(() => undefined, () => undefined) + return result + } + + objectPath(sha256) { + return join(this.objectRoot, sha256) + } + + receiptPath(token) { + return join(this.receiptRoot, `${token}.json`) + } + + resolveUntrustedSource(sourcePath) { + if (typeof sourcePath !== 'string' || sourcePath === '') { + throw new TrustError('artifact source path is invalid') + } + const source = resolve(sourcePath) + if (source !== this.untrustedRoot && !source.startsWith(`${this.untrustedRoot}/`)) { + throw new TrustError('artifact source must be inside the untrusted download directory') + } + return source + } + + async readReceipt(token) { + if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) { + throw new TrustError('receipt token is invalid') + } + let value + try { + value = JSON.parse(await readFile(this.receiptPath(token), 'utf8')) + } catch (error) { + if (error?.code === 'ENOENT') throw new TrustError('receipt does not exist') + throw error + } + return validateReceipt(value) + } + + async authorityFromTarget(artifactId) { + const target = await this.ledger.currentTarget() + if (target === undefined) throw new TrustError('no accepted release target exists') + return { + descriptor: descriptorById(descriptorsFromTarget(target.value), artifactId), + keyringGeneration: target.value.keyringGeneration, + parentReceipt: null, + parentSha256: createHash('sha256').update(target.bytes).digest('hex'), + signerKeyId: target.value.keyId, + targetSequence: target.value.targetSequence, + authorityType: 'stable', + } + } + + async authorityFromManifest(parentToken, artifactId) { + const parent = await this.readReceipt(parentToken) + if (!['staged', 'active', 'previous'].includes(parent.status)) { + throw new TrustError('parent manifest receipt is not usable') + } + if (parent.authoritySignature === null) throw new TrustError('parent receipt is not a verified manifest') + const bytes = await readFile(this.objectPath(parent.objectSha256)) + const manifest = parseReleaseManifest(bytes) + const keyring = (await this.ledger.keyringGeneration(manifest.keyringGeneration)).value + const signerKeyId = verifyDetached(bytes, parent.authoritySignature, keyring.current.publicKey, 'release manifest') + if ( + manifest.targetSequence !== parent.targetSequence + || manifest.keyringGeneration !== parent.keyringGeneration + || signerKeyId !== parent.signerKeyId + ) throw new TrustError('parent manifest receipt is inconsistent') + return { + descriptor: descriptorById(manifest.artifacts, artifactId), + keyringGeneration: manifest.keyringGeneration, + parentReceipt: parentToken, + parentSha256: parent.objectSha256, + signerKeyId, + targetSequence: manifest.targetSequence, + authorityType: 'stable', + } + } + + importFromTarget(artifactId, sourcePath) { + return this.exclusive(async () => this.importAuthorized( + await this.authorityFromTarget(artifactId), + sourcePath, + )) + } + + importFromManifest(parentToken, artifactId, sourcePath) { + return this.exclusive(async () => this.importAuthorized( + await this.authorityFromManifest(parentToken, artifactId), + sourcePath, + )) + } + + async importAuthorized(authority, sourcePath) { + const currentKeyring = (await this.ledger.currentKeyring())?.value + const currentTarget = (await this.ledger.currentTarget())?.value + if ( + currentKeyring === undefined + || currentTarget === undefined + || authority.keyringGeneration !== currentKeyring.generation + || authority.signerKeyId !== currentKeyring.current.keyId + || authority.targetSequence !== currentTarget.targetSequence + ) throw new TrustError('artifact authority is no longer current', 'TRUST_REVOKED') + const source = this.resolveUntrustedSource(sourcePath) + await mkdir(this.objectRoot, { recursive: true }) + await mkdir(this.receiptRoot, { recursive: true }) + const destination = this.objectPath(authority.descriptor.sha256) + const temporary = `${destination}.${randomUUID()}.tmp` + const sourceHandle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW) + let destinationHandle + try { + if (!(await sourceHandle.stat()).isFile()) throw new TrustError('artifact source must be a regular file') + destinationHandle = await open(temporary, 'wx', 0o444) + const hash = createHash('sha256') + let size = 0 + const meter = new Transform({ + transform(chunk, _encoding, callback) { + hash.update(chunk) + size += chunk.byteLength + callback(null, chunk) + }, + }) + await pipeline(sourceHandle.createReadStream({ autoClose: false }), meter, destinationHandle.createWriteStream()) + destinationHandle = undefined + const sha256 = hash.digest('hex') + if (size !== authority.descriptor.size || sha256 !== authority.descriptor.sha256) { + throw new TrustError('artifact content does not match its signed descriptor', 'TRUST_ARTIFACT_MISMATCH') + } + try { + await link(temporary, destination) + await rm(temporary) + } catch (error) { + if (error?.code !== 'EEXIST') throw error + const existing = await hashFile(destination) + if (existing.size !== size || existing.sha256 !== sha256) { + throw new TrustError('trusted object store contains conflicting content') + } + await rm(temporary, { force: true }) + } + const receipt = { + token: receiptToken(), + artifactId: authority.descriptor.id, + mediaType: authority.descriptor.mediaType, + objectSha256: sha256, + size, + parentReceipt: authority.parentReceipt, + parentSha256: authority.parentSha256, + signerKeyId: authority.signerKeyId, + keyringGeneration: authority.keyringGeneration, + targetSequence: authority.targetSequence, + authorityType: authority.authorityType, + status: 'staged', + authoritySignature: null, + importedAt: this.now().toISOString(), + } + await atomicJson(this.receiptPath(receipt.token), receipt) + return Object.freeze({ ...receipt, path: destination }) + } finally { + await sourceHandle.close().catch(() => {}) + await destinationHandle?.close().catch(() => {}) + await rm(temporary, { force: true }).catch(() => {}) + } + } + + ensureOfficialDsh(requestedVersion, metadataPath, tarballPath) { + return this.exclusive(async () => { + compareDshVersions(requestedVersion, requestedVersion) + const target = (await this.ledger.currentTarget())?.value + if (target === undefined) { + throw new TrustError('official DSH import requires an accepted registry policy') + } + if (compareDshVersions(requestedVersion, target.desired.dsh.version) < 0) { + throw new TrustError('official DSH version is older than the current supported target', 'TRUST_ROLLBACK') + } + const metadataSource = this.resolveUntrustedSource(metadataPath) + const tarballSource = this.resolveUntrustedSource(tarballPath) + const metadataHandle = await open(metadataSource, constants.O_RDONLY | constants.O_NOFOLLOW) + let metadata + try { + const stat = await metadataHandle.stat() + if (!stat.isFile()) throw new TrustError('official DSH metadata source must be a regular file') + if (stat.size > OFFICIAL_DSH_METADATA_LIMIT) throw new TrustError('official DSH metadata exceeds the download limit') + metadata = await metadataHandle.readFile() + if (metadata.byteLength > OFFICIAL_DSH_METADATA_LIMIT) { + throw new TrustError('official DSH metadata exceeds the download limit') + } + } finally { + await metadataHandle.close() + } + let packument + try { packument = JSON.parse(metadata.toString('utf8')) } catch { throw new TrustError('official DSH metadata is not valid JSON') } + const candidate = verifyOfficialDshCandidate( + candidateFromPackument(packument, requestedVersion), + requestedVersion, + target.officialDshPolicy, + this.now(), + ) + if (requestedVersion === target.desired.dsh.version && candidate.dist.integrity !== target.desired.dsh.integrity) { + throw new TrustError('official DSH metadata does not match the supported target integrity', 'TRUST_ARTIFACT_MISMATCH') + } + await mkdir(this.objectRoot, { recursive: true }) + await mkdir(this.receiptRoot, { recursive: true }) + const temporary = join(this.objectRoot, `.${randomUUID()}.tmp`) + const sourceHandle = await open(tarballSource, constants.O_RDONLY | constants.O_NOFOLLOW) + let destinationHandle + try { + if (!(await sourceHandle.stat()).isFile()) throw new TrustError('official DSH tarball source must be a regular file') + destinationHandle = await open(temporary, 'wx', 0o444) + const sha256 = createHash('sha256') + const sha512 = createHash('sha512') + let size = 0 + const meter = new Transform({ + transform(chunk, _encoding, callback) { + size += chunk.byteLength + if (size > OFFICIAL_DSH_TARBALL_LIMIT) { + callback(new TrustError('official DSH tarball exceeds the download limit')) + return + } + sha256.update(chunk) + sha512.update(chunk) + callback(null, chunk) + }, + }) + await pipeline(sourceHandle.createReadStream({ autoClose: false }), meter, destinationHandle.createWriteStream()) + destinationHandle = undefined + const objectSha256 = sha256.digest('hex') + const integrity = `sha512-${sha512.digest('base64')}` + if (integrity !== candidate.dist.integrity) { + throw new TrustError('official DSH tarball does not match signed npm integrity', 'TRUST_ARTIFACT_MISMATCH') + } + const destination = this.objectPath(objectSha256) + try { + await link(temporary, destination) + await rm(temporary) + } catch (error) { + if (error?.code !== 'EEXIST') throw error + const existing = await hashFile(destination) + if (existing.size !== size || existing.sha256 !== objectSha256) { + throw new TrustError('trusted object store contains conflicting content') + } + await rm(temporary, { force: true }) + } + const accepted = await this.ledger.acceptOfficialDsh(candidate, objectSha256, size) + const verifiedCandidate = accepted.value + const authority = `${candidate.name}@${candidate.version}:${candidate.dist.integrity}` + const receipt = { + token: receiptToken(), + artifactId: 'official-dsh-tarball', + mediaType: 'application/vnd.npm.package+gzip', + objectSha256, + size, + parentReceipt: null, + parentSha256: createHash('sha256').update(authority).digest('hex'), + signerKeyId: verifiedCandidate.signerKeyId, + keyringGeneration: accepted.keyringGeneration, + targetSequence: accepted.targetSequence, + authorityType: 'official-dsh', + authorityVersion: candidate.version, + status: 'staged', + authoritySignature: null, + importedAt: this.now().toISOString(), + } + await atomicJson(this.receiptPath(receipt.token), receipt) + return Object.freeze({ ...receipt, path: destination }) + } finally { + await sourceHandle.close().catch(() => {}) + await destinationHandle?.close().catch(() => {}) + await rm(temporary, { force: true }).catch(() => {}) + } + }) + } + + acceptManifest(token, signatureToken) { + return this.exclusive(async () => { + const receipt = await this.readReceipt(token) + const signatureReceipt = await this.readReceipt(signatureToken) + if (receipt.mediaType !== MANIFEST_MEDIA_TYPE) throw new TrustError('receipt is not a manifest artifact') + if (receipt.status !== 'staged') throw new TrustError('only a staged manifest can become an authority') + if (signatureReceipt.mediaType !== SIGNATURE_MEDIA_TYPE) throw new TrustError('signature receipt has the wrong media type') + if (signatureReceipt.status !== 'staged' || signatureReceipt.parentReceipt !== null) { + throw new TrustError('manifest signature must be a staged target Artifact') + } + if ( + signatureReceipt.keyringGeneration !== receipt.keyringGeneration + || signatureReceipt.targetSequence !== receipt.targetSequence + || signatureReceipt.signerKeyId !== receipt.signerKeyId + ) throw new TrustError('manifest and signature receipts have different authorities') + const bytes = await readFile(this.objectPath(receipt.objectSha256)) + const signatureBytes = await readFile(this.objectPath(signatureReceipt.objectSha256)) + let signature + try { + signature = JSON.parse(signatureBytes.toString('utf8')) + } catch { + throw new TrustError('manifest signature Artifact must contain valid JSON') + } + const manifest = parseReleaseManifest(bytes) + const currentTarget = await this.ledger.currentTarget() + if ( + currentTarget === undefined + || manifest.keyringGeneration !== currentTarget.value.keyringGeneration + || manifest.targetSequence !== currentTarget.value.targetSequence + ) throw new TrustError('manifest does not belong to the current release target') + const keyring = (await this.ledger.currentKeyring()).value + const signerKeyId = verifyDetached(bytes, signature, keyring.current.publicKey, 'release manifest') + if (signerKeyId !== receipt.signerKeyId) throw new TrustError('manifest signer differs from its parent target') + const updated = { ...receipt, authoritySignature: signature } + await atomicJson(this.receiptPath(token), updated) + return Object.freeze(updated) + }) + } + + async receiptFiles() { + try { + return (await readdir(this.receiptRoot)).filter(name => name.endsWith('.json')) + } catch (error) { + if (error?.code === 'ENOENT') return [] + throw error + } + } + + async allReceipts() { + return Promise.all((await this.receiptFiles()).map(async name => ( + validateReceipt(JSON.parse(await readFile(join(this.receiptRoot, name), 'utf8'))) + ))) + } + + async activeReceipts() { + return Object.freeze((await this.allReceipts()) + .filter(receipt => receipt.status === 'active') + .map(receipt => Object.freeze({ token: receipt.token, authorityType: receipt.authorityType }))) + } + + reconcileRevocations(keyring) { + return this.exclusive(async () => { + const receipts = await this.allReceipts() + const revoked = new Set(keyring.revokedKeyIds) + const currentTarget = (await this.ledger.currentTarget())?.value + const currentOfficialDsh = await this.ledger.currentOfficialDsh().catch(() => undefined) + const unusable = new Set(receipts.filter(receipt => ( + receipt.status === 'revoked' || receipt.status === 'retired' + )).map(receipt => receipt.token)) + let changed = true + while (changed) { + changed = false + for (const receipt of receipts) { + if ( + receipt.status === 'staged' + && ( + revoked.has(receipt.signerKeyId) + || (receipt.authorityType === 'stable' && ( + currentTarget === undefined + || receipt.keyringGeneration !== currentTarget.keyringGeneration + || receipt.targetSequence !== currentTarget.targetSequence + )) + || (receipt.authorityType === 'official-dsh' && ( + currentOfficialDsh === undefined + || receipt.authorityVersion !== currentOfficialDsh.value.version + || receipt.objectSha256 !== currentOfficialDsh.objectSha256 + || receipt.signerKeyId !== currentOfficialDsh.value.signerKeyId + )) + || (receipt.parentReceipt !== null && unusable.has(receipt.parentReceipt)) + ) + ) { + receipt.status = 'revoked' + unusable.add(receipt.token) + changed = true + } + } + } + for (const receipt of receipts) await atomicJson(this.receiptPath(receipt.token), receipt) + return receipts.filter(receipt => receipt.status === 'revoked').map(receipt => receipt.token) + }) + } + + activate(tokens) { + return this.exclusive(async () => { + if (!Array.isArray(tokens)) throw new TrustError('activation receipts must be an array') + const receipts = await this.allReceipts() + const byToken = new Map(receipts.map(receipt => [receipt.token, receipt])) + const selected = new Set() + let currentKeyring + let currentTarget + let currentOfficialDsh + if (tokens.length > 0) { + currentKeyring = (await this.ledger.currentKeyring())?.value + if (currentKeyring === undefined) throw new TrustError('activation requires a current keyring') + currentTarget = (await this.ledger.currentTarget())?.value + if (currentTarget === undefined) throw new TrustError('activation requires a current release target') + currentOfficialDsh = await this.ledger.currentOfficialDsh().catch(() => undefined) + } + const add = (token) => { + const receipt = byToken.get(token) + if (receipt === undefined) throw new TrustError('activation references an unknown receipt') + if (!['staged', 'active', 'previous'].includes(receipt.status)) { + throw new TrustError('activation references an unusable receipt') + } + const officialAuthorityCurrent = currentOfficialDsh !== undefined + && receipt.authorityVersion === currentOfficialDsh.value.version + && receipt.objectSha256 === currentOfficialDsh.objectSha256 + && receipt.signerKeyId === currentOfficialDsh.value.signerKeyId + const stableAuthorityCurrent = receipt.keyringGeneration === currentKeyring.generation + && receipt.signerKeyId === currentKeyring.current.keyId + && receipt.targetSequence === currentTarget.targetSequence + const authorityCurrent = receipt.authorityType === 'official-dsh' + ? officialAuthorityCurrent + : stableAuthorityCurrent + if ( + receipt.status === 'staged' + && !authorityCurrent + ) throw new TrustError('activation references a revoked receipt', 'TRUST_REVOKED') + if (selected.has(token)) return + selected.add(token) + if (receipt.parentReceipt !== null) add(receipt.parentReceipt) + } + for (const token of tokens) add(token) + for (const receipt of receipts) { + if (selected.has(receipt.token)) receipt.status = 'active' + else if (receipt.status === 'active') receipt.status = 'previous' + else if (receipt.status === 'previous') receipt.status = 'retired' + await atomicJson(this.receiptPath(receipt.token), receipt) + } + return Object.freeze([...selected]) + }) + } + + rollback() { + return this.exclusive(async () => { + const receipts = await this.allReceipts() + if (!receipts.some(receipt => receipt.status === 'previous')) { + throw new TrustError('no previous activation exists') + } + for (const receipt of receipts) { + if (receipt.status === 'active') receipt.status = 'previous' + else if (receipt.status === 'previous') receipt.status = 'active' + await atomicJson(this.receiptPath(receipt.token), receipt) + } + }) + } + + bootstrapPackage(token, version) { + return this.exclusive(async () => { + const receipt = await this.readReceipt(token) + if (receipt.status !== 'staged' || receipt.parentReceipt === null) { + throw new TrustError('Bootstrap package must be a staged manifest child') + } + const parent = await this.readReceipt(receipt.parentReceipt) + if (parent.authoritySignature === null) throw new TrustError('Bootstrap package parent is not verified') + const manifest = parseReleaseManifest(await readFile(this.objectPath(parent.objectSha256))) + if (manifest.document.manifestType !== 'bootstrap' || manifest.document.version !== version) { + throw new TrustError('Bootstrap package does not belong to the requested version') + } + if (manifest.artifacts.length !== 1 || manifest.artifacts[0].id !== receipt.artifactId) { + throw new TrustError('Bootstrap manifest must authorize exactly one package') + } + const target = await this.ledger.currentTarget() + if (target?.value.document.desired?.bootstrap?.manifestArtifactId !== parent.artifactId) { + throw new TrustError('Bootstrap manifest is not the current desired Bootstrap') + } + return Object.freeze({ path: this.objectPath(receipt.objectSha256), receipt, manifest }) + }) + } + + collectGarbage() { + return this.exclusive(async () => { + const receipts = await this.allReceipts() + const retained = receipts.filter(receipt => !['retired', 'revoked'].includes(receipt.status)) + const retainedObjects = new Set(retained.map(receipt => receipt.objectSha256)) + for (const receipt of receipts) { + if (!retained.includes(receipt)) await rm(this.receiptPath(receipt.token), { force: true }) + } + let objects = [] + try { + objects = await readdir(this.objectRoot) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + const removed = [] + for (const sha256 of objects) { + if (!retainedObjects.has(sha256)) { + await rm(this.objectPath(sha256), { force: true }) + removed.push(sha256) + } + } + return Object.freeze(removed) + }) + } +} diff --git a/upstream/container/platform/stage0/lib/keyring.mjs b/upstream/container/platform/stage0/lib/keyring.mjs new file mode 100644 index 0000000..7abe60c --- /dev/null +++ b/upstream/container/platform/stage0/lib/keyring.mjs @@ -0,0 +1,76 @@ +import { exactKeys, isoTimestamp, parseJsonDocument, plainObject, positiveSafeInteger, TrustError } from '../../lib/validation.mjs' +import { releaseKeyId, verifyDetached } from './signature.mjs' + +export const KEYRING_SCHEMA = 1 + +function parseReleaseKey(value, label) { + const object = plainObject(value, label) + exactKeys(object, ['algorithm', 'keyId', 'publicKey'], label) + if (object.algorithm !== 'Ed25519') throw new TrustError(`${label}.algorithm must be Ed25519`) + if (typeof object.publicKey !== 'string') throw new TrustError(`${label}.publicKey must be base64`) + const keyId = releaseKeyId(object.publicKey) + if (object.keyId !== keyId) throw new TrustError(`${label}.keyId does not match its public key`) + return Object.freeze({ algorithm: object.algorithm, keyId, publicKey: object.publicKey }) +} + +export function parseKeyring(bytes) { + const object = parseJsonDocument(bytes, 'keyring') + exactKeys(object, ['current', 'generation', 'issuedAt', 'next', 'revokedKeyIds', 'schema'], 'keyring') + if (object.schema !== KEYRING_SCHEMA) throw new TrustError('keyring.schema must be 1') + const generation = positiveSafeInteger(object.generation, 'keyring.generation') + const issuedAt = isoTimestamp(object.issuedAt, 'keyring.issuedAt') + const current = parseReleaseKey(object.current, 'keyring.current') + const next = parseReleaseKey(object.next, 'keyring.next') + if (current.keyId === next.keyId) throw new TrustError('keyring current and next keys must differ') + if (!Array.isArray(object.revokedKeyIds)) throw new TrustError('keyring.revokedKeyIds must be an array') + const revokedKeyIds = object.revokedKeyIds.map((keyId, index) => { + if (typeof keyId !== 'string' || !/^[a-f0-9]{64}$/.test(keyId)) { + throw new TrustError(`keyring.revokedKeyIds[${String(index)}] must be a SHA-256 hex digest`) + } + return keyId + }) + if (new Set(revokedKeyIds).size !== revokedKeyIds.length) { + throw new TrustError('keyring.revokedKeyIds must not contain duplicates') + } + if ([...revokedKeyIds].sort().some((keyId, index) => keyId !== revokedKeyIds[index])) { + throw new TrustError('keyring.revokedKeyIds must be sorted') + } + if (revokedKeyIds.includes(current.keyId) || revokedKeyIds.includes(next.keyId)) { + throw new TrustError('keyring active keys must not be revoked') + } + return Object.freeze({ + schema: object.schema, + generation, + issuedAt, + current, + next, + revokedKeyIds: Object.freeze(revokedKeyIds), + }) +} + +export function verifyRecoveryKeyring(bytes, signature, recoveryPublicKey) { + verifyDetached(bytes, signature, recoveryPublicKey, 'keyring') + return parseKeyring(bytes) +} + +export function validateKeyringTransition(previous, next) { + if (next.generation <= previous.generation) { + throw new TrustError('keyring generation must increase', 'TRUST_ROLLBACK') + } + const nextRevoked = new Set(next.revokedKeyIds) + for (const keyId of previous.revokedKeyIds) { + if (!nextRevoked.has(keyId)) throw new TrustError('keyring cannot remove a revoked key') + } + for (const keyId of [previous.current.keyId, previous.next.keyId]) { + const remainsActive = keyId === next.current.keyId || keyId === next.next.keyId + if (!remainsActive && !nextRevoked.has(keyId)) { + throw new TrustError('keyring must revoke every removed release key') + } + } + for (const keyId of [next.current.keyId, next.next.keyId]) { + if (previous.revokedKeyIds.includes(keyId)) { + throw new TrustError('keyring cannot reactivate a revoked key') + } + } + return next +} diff --git a/upstream/container/platform/stage0/lib/ledger.mjs b/upstream/container/platform/stage0/lib/ledger.mjs new file mode 100644 index 0000000..1eea9ee --- /dev/null +++ b/upstream/container/platform/stage0/lib/ledger.mjs @@ -0,0 +1,244 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { durableCreate, durableReplace } from '../../lib/atomic.mjs' +import { compareDshVersions } from '../../lib/supported-target.mjs' +import { TrustError } from '../../lib/validation.mjs' +import { validateKeyringTransition, verifyRecoveryKeyring } from './keyring.mjs' +import { parseReleaseTarget, validateTargetTransition, verifyReleaseTarget } from './target.mjs' +import { parseOfficialDshCandidate, verifyOfficialDshCandidate } from './official-dsh.mjs' + +async function readOptional(path) { + try { + return await readFile(path) + } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } +} + +function signedRecord(document, signature) { + return Buffer.from(`${JSON.stringify({ document: document.toString('base64'), signature })}\n`) +} + +function parseSignedRecord(bytes, label) { + let value + try { + value = JSON.parse(bytes.toString('utf8')) + } catch { + throw new TrustError(`${label} record must contain valid JSON`) + } + if ( + value === null + || typeof value !== 'object' + || Array.isArray(value) + || typeof value.document !== 'string' + || value.signature === null + || typeof value.signature !== 'object' + || Array.isArray(value.signature) + || Object.keys(value).sort().join(',') !== 'document,signature' + ) { + throw new TrustError(`${label} record is invalid`) + } + const document = Buffer.from(value.document, 'base64') + if (document.toString('base64') !== value.document) { + throw new TrustError(`${label} record document must be canonical base64`) + } + return { document, signature: value.signature } +} + +function parseOfficialDshRecord(bytes) { + let record + try { record = JSON.parse(bytes.toString('utf8')) } catch { throw new TrustError('official DSH record must contain valid JSON') } + if ( + record === null || typeof record !== 'object' || Array.isArray(record) + || Object.keys(record).sort().join(',') !== 'candidate,objectSha256,size' + || typeof record.objectSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(record.objectSha256) + || !Number.isSafeInteger(record.size) || record.size < 0 + ) throw new TrustError('official DSH record is invalid') + return { candidate: parseOfficialDshCandidate(record.candidate), objectSha256: record.objectSha256, size: record.size } +} + +export class TrustLedger { + constructor(root, recoveryPublicKey) { + this.root = root + this.recoveryPublicKey = recoveryPublicKey + this.queue = Promise.resolve() + } + + exclusive(operation) { + const result = this.queue.then(operation, operation) + this.queue = result.then(() => undefined, () => undefined) + return result + } + + keyringPath(name) { + return join(this.root, 'keyring', name) + } + + targetPath(name) { + return join(this.root, 'target', name) + } + + officialDshPath(name) { + return join(this.root, 'official-dsh', name) + } + + async currentKeyring() { + const recordBytes = await readOptional(this.keyringPath('current.record.json')) + if (recordBytes === undefined) return undefined + return this.parseKeyringRecord(recordBytes) + } + + parseKeyringRecord(recordBytes) { + const { document, signature } = parseSignedRecord(recordBytes, 'keyring') + return { + bytes: document, + signature, + value: verifyRecoveryKeyring(document, signature, this.recoveryPublicKey), + } + } + + async keyringGeneration(generation) { + const recordBytes = await readOptional(this.keyringPath(join('generations', `${String(generation)}.record.json`))) + if (recordBytes === undefined) throw new TrustError(`trusted keyring generation ${String(generation)} is missing`) + const record = this.parseKeyringRecord(recordBytes) + if (record.value.generation !== generation) throw new TrustError('keyring generation record is inconsistent') + return record + } + + acceptKeyring(bytes, signature) { + return this.exclusive(() => this.acceptKeyringUnlocked(bytes, signature)) + } + + async acceptKeyringUnlocked(bytes, signature) { + const next = verifyRecoveryKeyring(bytes, signature, this.recoveryPublicKey) + const current = await this.currentKeyring() + if (current !== undefined) { + if (next.generation === current.value.generation) { + if (!current.bytes.equals(bytes)) { + throw new TrustError('keyring generation cannot identify different content') + } + return current.value + } + validateKeyringTransition(current.value, next) + } + const record = signedRecord(bytes, signature) + const generationPath = this.keyringPath(join('generations', `${String(next.generation)}.record.json`)) + const existingGeneration = await readOptional(generationPath) + if (existingGeneration === undefined) { + await durableCreate(generationPath, record) + } else if (!this.parseKeyringRecord(existingGeneration).bytes.equals(bytes)) { + throw new TrustError('keyring generation history conflicts with the proposed content') + } + await durableReplace(this.keyringPath('current.record.json'), record) + return next + } + + async currentTarget(keyring = undefined) { + const recordBytes = await readOptional(this.targetPath('current.record.json')) + if (recordBytes === undefined) return undefined + const { document, signature } = parseSignedRecord(recordBytes, 'release target') + const parsed = parseReleaseTarget(document) + const trustedKeyring = keyring?.generation === parsed.keyringGeneration + ? keyring + : (await this.keyringGeneration(parsed.keyringGeneration)).value + return { bytes: document, signature, value: verifyReleaseTarget(document, signature, trustedKeyring) } + } + + acceptTarget(bytes, signature) { + return this.exclusive(() => this.acceptTargetUnlocked(bytes, signature)) + } + + async acceptTargetUnlocked(bytes, signature) { + const keyring = (await this.currentKeyring())?.value + if (keyring === undefined) throw new TrustError('a keyring must be accepted before a release target') + const next = verifyReleaseTarget(bytes, signature, keyring) + const current = await this.currentTarget(keyring) + if (current !== undefined) validateTargetTransition(current.bytes, current.value, bytes, next) + if (current?.bytes.equals(bytes)) return current.value + await durableReplace(this.targetPath('current.record.json'), signedRecord(bytes, signature)) + return next + } + + async currentOfficialDsh(target = undefined) { + const recordBytes = await readOptional(this.officialDshPath('current.record.json')) + if (recordBytes === undefined) return undefined + const record = parseOfficialDshRecord(recordBytes) + const currentTarget = target ?? (await this.currentTarget())?.value + if (currentTarget === undefined) { + throw new TrustError('official DSH requires an accepted registry policy') + } + const keyring = (await this.currentKeyring())?.value + if ( + keyring === undefined + || currentTarget.keyringGeneration !== keyring.generation + || currentTarget.keyId !== keyring.current.keyId + ) throw new TrustError('official DSH policy is no longer authorized', 'TRUST_REVOKED') + return Object.freeze({ + ...record, + value: verifyOfficialDshCandidate( + record.candidate, + record.candidate.version, + currentTarget.officialDshPolicy, + ), + }) + } + + acceptOfficialDsh(candidate, objectSha256, size) { + return this.exclusive(() => this.acceptOfficialDshUnlocked(candidate, objectSha256, size)) + } + + async acceptOfficialDshUnlocked(candidate, objectSha256, size) { + if (typeof objectSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(objectSha256)) { + throw new TrustError('official DSH object SHA-256 is invalid') + } + if (!Number.isSafeInteger(size) || size < 0) throw new TrustError('official DSH object size is invalid') + const target = (await this.currentTarget())?.value + if (target === undefined) { + throw new TrustError('official DSH requires an accepted registry policy') + } + const keyring = (await this.currentKeyring())?.value + if ( + keyring === undefined + || target.keyringGeneration !== keyring.generation + || target.keyId !== keyring.current.keyId + ) throw new TrustError('official DSH policy is no longer authorized', 'TRUST_REVOKED') + const candidateDocument = parseOfficialDshCandidate({ + schema: candidate.schema, + name: candidate.name, + version: candidate.version, + dist: candidate.dist, + }) + const next = verifyOfficialDshCandidate(candidateDocument, candidateDocument.version, target.officialDshPolicy) + if (compareDshVersions(next.version, target.desired.dsh.version) < 0) { + throw new TrustError('official DSH version is older than the current supported target', 'TRUST_ROLLBACK') + } + if (next.version === target.desired.dsh.version && next.dist.integrity !== target.desired.dsh.integrity) { + throw new TrustError('official DSH does not match the supported target integrity', 'TRUST_ARTIFACT_MISMATCH') + } + const previousBytes = await readOptional(this.officialDshPath('current.record.json')) + if (previousBytes !== undefined) { + const previous = parseOfficialDshRecord(previousBytes) + const compared = compareDshVersions(next.version, previous.candidate.version) + if (compared < 0) throw new TrustError('official DSH version cannot decrease', 'TRUST_ROLLBACK') + if ( + compared === 0 + && ( + next.dist.integrity !== previous.candidate.dist.integrity + || objectSha256 !== previous.objectSha256 + || size !== previous.size + ) + ) throw new TrustError('official DSH version cannot identify different content') + } + const record = { candidate: candidateDocument, objectSha256, size } + await durableReplace(this.officialDshPath('current.record.json'), `${JSON.stringify(record)}\n`) + return Object.freeze({ + candidate: next, + objectSha256, + size, + value: next, + keyringGeneration: target.keyringGeneration, + targetSequence: target.targetSequence, + }) + } +} diff --git a/upstream/container/platform/stage0/lib/maintenance-server.mjs b/upstream/container/platform/stage0/lib/maintenance-server.mjs new file mode 100644 index 0000000..1792a6a --- /dev/null +++ b/upstream/container/platform/stage0/lib/maintenance-server.mjs @@ -0,0 +1,270 @@ +import { createServer } from 'node:http' +import { chmod, chown, mkdir, open, rm } from 'node:fs/promises' +import { dirname } from 'node:path' +import { AtomicFileEditor } from '../../../control-plane/modules/file-manager/editor.mjs' +import { createManagedPathMatcher, fileError, FileInventory, fileManagerLocations } from '../../../control-plane/modules/file-manager/index.mjs' +import { FileTaskManager } from '../../../control-plane/modules/file-manager/tasks.mjs' +import { FileTransferManager } from '../../../control-plane/modules/file-manager/transfers.mjs' +import { TerminalSessionManager } from '../../../control-plane/services/management/terminal/sessions.mjs' + +const API_PREFIX = '/_dsh_platform/api/v1/' +const MAX_BODY_BYTES = 16 * 1024 +const MAX_TEXT_BODY_BYTES = 2 * 1024 * 1024 + 8192 +const TERMINAL_SESSION_ROUTE = /^terminal\/sessions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/ +const TERMINAL_STREAM_ROUTE = /^terminal\/sessions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/stream$/ +const FILE_TASK_ROUTE = /^files\/tasks\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/ + +async function jsonBody(request, maxBytes = MAX_BODY_BYTES) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.byteLength + if (size > maxBytes) { + const error = new Error('request body is too large') + error.statusCode = 413 + throw error + } + chunks.push(chunk) + } + return size === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function send(response, status, value) { + if (response.headersSent) return response.destroy() + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(`${JSON.stringify(value)}\n`) +} + +async function acquireLease(path) { + try { + const handle = await open(path, 'wx', 0o600) + await handle.writeFile(`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`) + await handle.sync() + await handle.close() + } catch (error) { + if (error?.code === 'EEXIST') { + const conflict = new Error('a privileged file operation is already running') + conflict.statusCode = 409 + throw conflict + } + throw error + } +} + +export async function createMaintenanceServer({ + paths, + dshHome = '/data/dsh', + defaultWorkspace = '/workspace', + platformBusy = async () => false, + authorize = async () => false, + report = async () => {}, + fileReport = report, + terminalReport = report, + terminalEnvironment = async () => ({}), + audit = async () => {}, +} = {}) { + if (process.getuid?.() !== 0) throw new Error('Maintenance Broker must run as root') + const isManaged = createManagedPathMatcher(paths.dataRoot) + const inventory = new FileInventory({ isManaged }) + const transfers = new FileTransferManager({ isManaged }) + const editor = new AtomicFileEditor({ isManaged }) + const locations = Object.freeze({ + ...fileManagerLocations({ platformData: paths.dataRoot, dshHome, defaultWorkspace }), + privileged: true, + }) + const terminal = new TerminalSessionManager({ + cwd: '/root', + dshHome, + report: (message, fields) => terminalReport(message, fields), + }) + let server + const tasks = new FileTaskManager({ + root: paths.fileTasksRoot, + inventory, + isManaged, + protectedRoots: ['/', '/data', '/data/dsh', '/data/platform', '/workspace', paths.dataRoot, dshHome, defaultWorkspace, paths.deploymentView], + platformBusy: () => false, + onState: state => server?.emit('maintenance-state', state), + report: (message, fields) => fileReport(message, fields), + }) + await rm(paths.maintenanceLeasePath, { force: true }) + await tasks.initialize() + + const startTask = async body => { + const managed = tasks.wouldManage(body) + if (managed && await platformBusy()) { + const error = new Error('a platform operation is already running') + error.statusCode = 409 + throw error + } + if (managed) await acquireLease(paths.maintenanceLeasePath) + try { + const task = tasks.start(body) + if (managed) { + const internal = tasks.tasks.get(task.taskId) + void internal.completion.finally(() => rm(paths.maintenanceLeasePath, { force: true })) + } + void tasks.completion(task.taskId).then(async result => { + const outcome = result.status === 'success' ? 'completed' : result.status + await audit(`files.${result.operation}.${outcome}`, { + taskId: result.taskId, + path: result.path ?? result.destination ?? result.sources?.[0]?.path ?? null, + processedEntries: result.processedEntries ?? result.entries ?? null, + processedBytes: result.processedBytes ?? result.bytes ?? null, + ...(result.error === null || result.error === undefined ? {} : { error: result.error }), + managed: result.managed ?? false, + privileged: true, + }) + }).catch(error => report('maintenance.file-task.audit.failed', { error, taskId: task.taskId })) + return task + } catch (error) { + if (managed) await rm(paths.maintenanceLeasePath, { force: true }) + throw error + } + } + + const managedWrite = async (path, operation) => { + const managed = isManaged(path) + if (!managed) return operation() + if (await platformBusy()) { + const error = new Error('a platform operation is already running') + error.statusCode = 409 + throw error + } + await acquireLease(paths.maintenanceLeasePath) + try { return await operation() } finally { await rm(paths.maintenanceLeasePath, { force: true }) } + } + + server = createServer((request, response) => { + void (async () => { + if (!await authorize(request)) return send(response, 401, { error: 'maintenance authentication required' }) + const url = new URL(request.url ?? '/', 'http://maintenance.internal') + if (!url.pathname.startsWith(API_PREFIX)) return send(response, 404, { error: 'not found' }) + const route = url.pathname.slice(API_PREFIX.length) + if (request.method === 'POST' && route === 'terminal/sessions') { + const [body, environment] = await Promise.all([jsonBody(request), terminalEnvironment()]) + const result = terminal.create(body, environment) + await audit('terminal.root-session.created', { sessionId: result.sessionId }) + send(response, 201, { ...result, privileged: true }) + } else if (request.method === 'GET' && TERMINAL_SESSION_ROUTE.test(route)) { + send(response, 200, { ...terminal.status(TERMINAL_SESSION_ROUTE.exec(route)[1]), privileged: true }) + } else if (request.method === 'DELETE' && TERMINAL_SESSION_ROUTE.test(route)) { + send(response, 200, terminal.close(TERMINAL_SESSION_ROUTE.exec(route)[1])) + } else if (request.method === 'GET' && route === 'files/config') { + send(response, 200, locations) + } else if (request.method === 'GET' && route === 'files/list') { + const result = await inventory.list(url.searchParams.get('path'), { + cursor: url.searchParams.get('cursor'), limit: url.searchParams.get('limit'), + sort: url.searchParams.get('sort') ?? 'name', order: url.searchParams.get('order') ?? 'asc', + }) + send(response, 200, result) + } else if (request.method === 'GET' && route === 'files/stat') { + try { + send(response, 200, await inventory.stat(url.searchParams.get('path'))) + } catch (error) { + if (url.searchParams.get('optional') !== 'true' || error?.statusCode !== 404) throw error + send(response, 200, null) + } + } else if (request.method === 'GET' && route === 'files/content') { + send(response, 200, await inventory.content(url.searchParams.get('path'))) + } else if (request.method === 'PUT' && route === 'files/content') { + const body = await jsonBody(request, MAX_TEXT_BODY_BYTES) + const result = await managedWrite(body.path, () => editor.write(body.path, body.content, body.revision, { create: body.create === true })) + await audit('files.content.saved', { path: result.path, size: result.size, managed: result.managed, privileged: true }) + send(response, body.create === true ? 201 : 200, result) + } else if (request.method === 'GET' && route === 'files/download') { + const path = url.searchParams.get('path') + const transfer = tasks.startTransfer({ operation: 'download', path }, async controls => { + const download = await transfers.openDownload(path, { + revision: url.searchParams.get('revision') ?? undefined, + range: typeof request.headers.range === 'string' ? request.headers.range : undefined, + signal: controls.signal, + }) + controls.progress({ processedBytes: 0, totalBytes: Number(download.headers['content-length']) }) + await transfers.sendDownload(response, download, { + signal: controls.signal, + onProgress: (processedBytes, totalBytes) => controls.progress({ processedBytes, totalBytes }), + }) + return download + }) + await audit('files.download.started', { path, taskId: transfer.task.taskId, privileged: true }) + await transfer.result + } else if (request.method === 'POST' && route === 'files/upload') { + const length = request.headers['content-length'] + const contentLength = length === undefined ? undefined : Number(length) + const path = url.searchParams.get('path') + const managed = isManaged(path) + if (managed && await platformBusy()) { + const error = new Error('a platform operation is already running') + error.statusCode = 409 + throw error + } + if (managed) await acquireLease(paths.maintenanceLeasePath) + const transfer = tasks.startTransfer({ operation: 'upload', path, totalBytes: contentLength ?? null, managed }, controls => transfers.upload(request, path, { + conflict: url.searchParams.get('conflict') ?? 'reject', contentLength, signal: controls.signal, + onProgress: (processedBytes, totalBytes) => controls.progress({ processedBytes, totalBytes }), + })) + if (managed) void tasks.completion(transfer.task.taskId).finally(() => rm(paths.maintenanceLeasePath, { force: true })) + await audit('files.upload.started', { path, taskId: transfer.task.taskId, managed, privileged: true }) + const result = await transfer.result + await audit('files.upload.completed', { path: result.path, size: result.size, privileged: true }) + send(response, 201, result) + } else if (request.method === 'POST' && route === 'files/tasks') { + const body = await jsonBody(request) + try { + const task = await startTask(body) + await audit(`files.${task.operation}.started`, { taskId: task.taskId, path: task.destination, managed: task.managed, privileged: true }) + send(response, 202, task) + } catch (error) { + const operation = ['mkdir', 'touch'].includes(body?.operation) ? body.operation : 'task' + await audit(`files.${operation}.rejected`, { error, path: body?.destination ?? null, privileged: true }) + throw error + } + } else if (request.method === 'GET' && route === 'files/tasks') { + send(response, 200, { tasks: tasks.list() }) + } else if (request.method === 'GET' && FILE_TASK_ROUTE.test(route)) { + send(response, 200, tasks.get(FILE_TASK_ROUTE.exec(route)[1], { + cursor: url.searchParams.get('cursor'), limit: url.searchParams.get('limit'), + })) + } else if (request.method === 'DELETE' && FILE_TASK_ROUTE.test(route)) { + send(response, 202, tasks.cancel(FILE_TASK_ROUTE.exec(route)[1])) + } else send(response, 404, { error: 'not found' }) + })().catch(async error => { + const failure = Number.isInteger(error?.statusCode) + ? error + : fileError(error, 'maintenance request failed') + await report('maintenance.request.failed', { + error: failure, method: request.method ?? null, pathname: request.url ?? null, + level: failure.statusCode === 409 ? 'warning' : 'error', + }) + send(response, failure.statusCode, { error: failure.message, code: failure.code ?? 'FILE_ERROR' }) + }) + }) + server.on('upgrade', (request, socket, head) => { + void (async () => { + if (!await authorize(request)) { + socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') + return + } + const pathname = new URL(request.url ?? '/', 'http://maintenance.internal').pathname + const match = pathname.startsWith(API_PREFIX) ? TERMINAL_STREAM_ROUTE.exec(pathname.slice(API_PREFIX.length)) : null + if (match === null) throw new Error('not found') + terminal.upgrade(request, socket, head, match[1]) + })().catch(error => { + void report('maintenance.terminal-upgrade.failed', { error }) + if (!socket.destroyed) socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') + }) + }) + return Object.freeze({ server, terminal, tasks, locations }) +} + +export async function listenMaintenance(server, socketPath) { + await mkdir(dirname(socketPath), { recursive: true }) + await rm(socketPath, { force: true }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + await chown(socketPath, 0, 1000) + await chmod(socketPath, 0o660) +} diff --git a/upstream/container/platform/stage0/lib/official-dsh.mjs b/upstream/container/platform/stage0/lib/official-dsh.mjs new file mode 100644 index 0000000..1de7d72 --- /dev/null +++ b/upstream/container/platform/stage0/lib/official-dsh.mjs @@ -0,0 +1,89 @@ +import { createPublicKey, verify } from 'node:crypto' +import { compareDshVersions } from '../../lib/supported-target.mjs' +import { exactKeys, plainObject, TrustError } from '../../lib/validation.mjs' +import { + OFFICIAL_DSH_PACKAGE, + OFFICIAL_NPM_REGISTRY, + officialDshTarballUrl, +} from '../../lib/official-dsh-contracts.mjs' + +function registrySignature(value, label) { + const object = plainObject(value, label) + exactKeys(object, ['keyid', 'sig'], label) + if (typeof object.keyid !== 'string' || typeof object.sig !== 'string') { + throw new TrustError(`${label} is invalid`) + } + return Object.freeze({ keyid: object.keyid, sig: object.sig }) +} + +export function parseOfficialDshCandidate(value) { + const object = plainObject(value, 'official DSH candidate') + exactKeys(object, ['dist', 'name', 'schema', 'version'], 'official DSH candidate') + if (object.schema !== 1) throw new TrustError('official DSH candidate schema must be 1') + if (object.name !== OFFICIAL_DSH_PACKAGE) throw new TrustError('official DSH candidate package name is invalid') + compareDshVersions(object.version, object.version) + const dist = plainObject(object.dist, 'official DSH candidate dist') + exactKeys(dist, ['integrity', 'signatures', 'tarball'], 'official DSH candidate dist') + if (typeof dist.integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(dist.integrity)) { + throw new TrustError('official DSH candidate integrity must use SHA-512') + } + if (!Array.isArray(dist.signatures) || dist.signatures.length === 0) { + throw new TrustError('official DSH candidate must contain registry signatures') + } + return Object.freeze({ + schema: 1, + name: object.name, + version: object.version, + dist: Object.freeze({ + integrity: dist.integrity, + tarball: dist.tarball, + signatures: Object.freeze(dist.signatures.map((entry, index) => ( + registrySignature(entry, `official DSH signatures[${String(index)}]`) + ))), + }), + }) +} + +export function candidateFromPackument(value, requestedVersion) { + compareDshVersions(requestedVersion, requestedVersion) + const packument = plainObject(value, 'official DSH packument') + const versions = plainObject(packument.versions, 'official DSH packument versions') + const selected = plainObject(versions[requestedVersion], `official DSH ${requestedVersion}`) + return parseOfficialDshCandidate({ + schema: 1, + name: selected.name, + version: selected.version, + dist: { + integrity: selected.dist?.integrity, + tarball: selected.dist?.tarball, + signatures: selected.dist?.signatures, + }, + }) +} + +export function verifyOfficialDshCandidate(value, requestedVersion, policy, now = new Date()) { + const candidate = parseOfficialDshCandidate(value) + if (policy.registry !== OFFICIAL_NPM_REGISTRY) throw new TrustError('official DSH policy registry is invalid') + if (candidate.version !== requestedVersion) throw new TrustError('official DSH metadata returned a different version') + if (candidate.name !== policy.packageName) throw new TrustError('official DSH candidate is outside the trusted policy') + const expectedTarball = officialDshTarballUrl(candidate.version).href + if (candidate.dist.tarball !== expectedTarball) throw new TrustError('official DSH tarball URL is not canonical') + const message = `${candidate.name}@${candidate.version}:${candidate.dist.integrity}` + for (const candidateSignature of candidate.dist.signatures) { + const key = policy.keys.find(entry => entry.keyId === candidateSignature.keyid) + if (key === undefined || (key.expires !== null && new Date(key.expires) <= now)) continue + let publicKey + try { + publicKey = createPublicKey({ key: Buffer.from(key.publicKey, 'base64'), format: 'der', type: 'spki' }) + } catch { + throw new TrustError('official DSH policy contains an invalid registry public key') + } + if (publicKey.asymmetricKeyType !== 'ec' || publicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') { + throw new TrustError('official DSH registry key must use P-256') + } + if (verify('sha256', Buffer.from(message), publicKey, Buffer.from(candidateSignature.sig, 'base64'))) { + return Object.freeze({ ...candidate, signerKeyId: key.keyId }) + } + } + throw new TrustError('official DSH candidate has no valid delegated registry signature', 'TRUST_UNKNOWN_KEY') +} diff --git a/upstream/container/platform/stage0/lib/proxy-launch-server.mjs b/upstream/container/platform/stage0/lib/proxy-launch-server.mjs new file mode 100644 index 0000000..758b2e0 --- /dev/null +++ b/upstream/container/platform/stage0/lib/proxy-launch-server.mjs @@ -0,0 +1,232 @@ +import { spawn } from 'node:child_process' +import { timingSafeEqual } from 'node:crypto' +import { once } from 'node:events' +import { createServer } from 'node:http' +import { chmod, chown, mkdir, rm } from 'node:fs/promises' +import { dirname } from 'node:path' + +const MAX_BODY_BYTES = 1024 + +function sameToken(left, right) { + const a = Buffer.from(left ?? '') + const b = Buffer.from(right ?? '') + return a.byteLength === b.byteLength && a.byteLength > 0 && timingSafeEqual(a, b) +} + +function send(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(`${JSON.stringify(value)}\n`) +} + +async function body(request) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.byteLength + if (size > MAX_BODY_BYTES) throw new Error('proxy launch request body is too large') + chunks.push(chunk) + } + return size === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +export function proxyLaunchCommand({ node, script, uid, gid, platformGid }) { + if (!Number.isInteger(uid) || uid < 1 || !Number.isInteger(gid) || gid < 1) { + throw new Error('Proxy Manager UID and GID must be positive integers') + } + if (!Number.isInteger(platformGid) || platformGid < 1) { + throw new Error('Proxy Manager platform GID must be a positive integer') + } + return { + executable: '/usr/bin/setpriv', + args: [ + `--reuid=${String(uid)}`, + `--regid=${String(gid)}`, + `--groups=${String(platformGid)}`, + '--', node, script, + ], + } +} + +export function proxyLaunchEnvironment({ environment = process.env, dataRoot, runRoot }) { + return { + HOME: '/nonexistent', + USER: 'dsh-proxy', + LOGNAME: 'dsh-proxy', + PATH: environment.PATH ?? '/usr/local/bin:/usr/bin:/bin', + DSH_PLATFORM_DATA: dataRoot, + DSH_PLATFORM_RUN: runRoot, + ...(environment.DSH_PROXY_MASTER_KEY === undefined + ? {} + : { DSH_PROXY_MASTER_KEY: environment.DSH_PROXY_MASTER_KEY }), + } +} + +export class ProxyLaunchBroker { + constructor({ + token, + dataRoot, + runRoot, + script, + controlSocket, + uid, + gid, + platformGid, + node = process.execPath, + spawnImpl = spawn, + capture = () => {}, + report = async () => {}, + readyTimeoutMs = 10_000, + }) { + this.token = token + this.dataRoot = dataRoot + this.runRoot = runRoot + this.script = script + this.controlSocket = controlSocket + this.uid = uid + this.gid = gid + this.platformGid = platformGid + this.node = node + this.spawnImpl = spawnImpl + this.capture = capture + this.report = report + this.readyTimeoutMs = readyTimeoutMs + this.child = undefined + this.ready = undefined + } + + status() { + const running = this.child !== undefined + && this.child.exitCode === null + && this.child.signalCode === null + return { + running, + componentReady: running && this.ready?.componentReady === true, + pid: running ? this.child.pid ?? null : null, + revision: running ? this.ready?.revision ?? null : null, + ports: running ? this.ready?.ports ?? [] : [], + } + } + + async start() { + if (this.status().running) return this.status() + const command = proxyLaunchCommand({ + node: this.node, + script: this.script, + uid: this.uid, + gid: this.gid, + platformGid: this.platformGid, + }) + const child = this.spawnImpl(command.executable, command.args, { + env: proxyLaunchEnvironment({ dataRoot: this.dataRoot, runRoot: this.runRoot }), + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }) + this.child = child + this.ready = undefined + this.capture(child, 'outbound-proxy', { stdout: true, stderr: true }) + const ready = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => reject(new Error( + `Proxy Manager exited before readiness (code=${String(code)}, signal=${String(signal)})`, + ))) + child.on('message', message => { + if (message?.type === 'ready' && message.componentReady === true) resolve(message) + }) + }) + let timer + try { + const message = await Promise.race([ + ready, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Proxy Manager readiness timed out')), this.readyTimeoutMs) + timer.unref?.() + }), + ]) + this.ready = Object.freeze({ + componentReady: true, + revision: message.revision ?? null, + ports: Array.isArray(message.ports) ? [...message.ports] : [], + }) + if (process.getuid?.() === 0 && this.platformGid !== undefined) { + await chown(this.controlSocket, this.uid, this.platformGid) + await chmod(this.controlSocket, 0o660) + } + await this.report('outbound-proxy.launch.completed', { pid: child.pid ?? null }) + child.once('exit', (code, signal) => { + if (this.child !== child) return + this.child = undefined + this.ready = undefined + void this.report('outbound-proxy.exited', { + level: code === 0 || signal === 'SIGTERM' ? 'info' : 'error', code, signal, + }) + }) + return this.status() + } catch (error) { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit') + child.kill('SIGTERM') + await exited.catch(() => {}) + } + if (this.child === child) this.child = undefined + this.ready = undefined + await this.report('outbound-proxy.launch.failed', { error }) + throw error + } finally { + clearTimeout(timer) + } + } + + async stop() { + const child = this.child + if (child === undefined || child.exitCode !== null || child.signalCode !== null) { + this.child = undefined + this.ready = undefined + return this.status() + } + const exited = once(child, 'exit') + child.kill('SIGTERM') + const graceful = await Promise.race([ + exited.then(() => true), + new Promise(resolve => setTimeout(() => resolve(false), 5_000)), + ]) + if (!graceful && child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + await once(child, 'exit') + } + if (this.child === child) this.child = undefined + this.ready = undefined + await this.report('outbound-proxy.stop.completed') + return this.status() + } +} + +export function createProxyLaunchServer({ broker, token, report = async () => {} }) { + return createServer((request, response) => { + void (async () => { + const pathname = new URL(request.url ?? '/', 'http://proxy-launch.internal').pathname + if (request.method === 'GET' && pathname === '/v1/status') return send(response, 200, broker.status()) + if (request.method !== 'POST' || !['/v1/start', '/v1/stop'].includes(pathname)) { + return send(response, 404, { error: 'not found' }) + } + const value = await body(request) + if (!sameToken(value.token, token)) return send(response, 403, { error: 'proxy launch authorization failed' }) + const result = pathname === '/v1/start' ? await broker.start() : await broker.stop() + send(response, 200, result) + })().catch(async error => { + await report('proxy-launch.request.failed', { + error, method: request.method ?? null, pathname: request.url ?? null, + }) + send(response, 500, { error: error instanceof Error ? error.message : 'proxy launch failed' }) + }) + }) +} + +export async function listenProxyLaunch(server, socketPath, platformGid) { + await mkdir(dirname(socketPath), { recursive: true }) + await rm(socketPath, { force: true }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + if (process.getuid?.() === 0) await chown(socketPath, 0, platformGid) + await chmod(socketPath, process.getuid?.() === 0 ? 0o660 : 0o600) +} diff --git a/upstream/container/platform/stage0/lib/recovery-server.mjs b/upstream/container/platform/stage0/lib/recovery-server.mjs new file mode 100644 index 0000000..11bf162 --- /dev/null +++ b/upstream/container/platform/stage0/lib/recovery-server.mjs @@ -0,0 +1,58 @@ +import { createServer } from 'node:http' +import { chmod, mkdir, rm } from 'node:fs/promises' +import { dirname } from 'node:path' + +async function body(request) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.byteLength + if (size > 16 * 1024) throw new Error('recovery request body is too large') + chunks.push(chunk) + } + return size === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function send(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(`${JSON.stringify(value)}\n`) +} + +export function createRecoveryServer({ inventory, deployments, supervisor, report = async () => {} }) { + const record = (message, fields) => Promise.resolve().then(() => report(message, fields)).catch(() => {}) + return createServer(async (request, response) => { + try { + if (request.method === 'GET' && request.url === '/v1/status') { + send(response, 200, { + imageBaseline: { + imageBuildId: inventory.imageBuildId, + targetSequence: inventory.targetSequence, + dsh: inventory.deployment.dshVersion, + environment: inventory.deployment.environmentVersion, + }, + deployment: await deployments().catch(() => null), + }) + } else if (request.method === 'POST' && request.url === '/v1/recover-image-baseline') { + const value = await body(request) + if (value.confirm !== inventory.imageBuildId) throw new Error('image baseline recovery confirmation is invalid') + await record('image-baseline.requested', { imageBuildId: inventory.imageBuildId }) + const slots = await supervisor.recoverImageBaseline() + await record('image-baseline.recovered', { imageBuildId: inventory.imageBuildId }) + send(response, 200, { status: 'recovered', slots }) + } else send(response, 404, { error: 'not found' }) + } catch (error) { + await record('recovery.request.failed', { error, method: request.method ?? null, pathname: request.url ?? null }) + send(response, 400, { error: error instanceof Error ? error.message : 'recovery failed' }) + } + }) +} + +export async function listenRecovery(server, socketPath) { + await mkdir(dirname(socketPath), { recursive: true }) + await rm(socketPath, { force: true }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + await chmod(socketPath, 0o600) +} diff --git a/upstream/container/platform/stage0/lib/seed.mjs b/upstream/container/platform/stage0/lib/seed.mjs new file mode 100644 index 0000000..9278e70 --- /dev/null +++ b/upstream/container/platform/stage0/lib/seed.mjs @@ -0,0 +1,86 @@ +import { lchown, lstat, mkdir, readFile, readlink, rename, stat, symlink } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' +import { PlatformPaths, preparePersistentLayout } from '../../lib/paths.mjs' + +async function exists(path) { + return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error)) +} + +function validSeedId(value, label) { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) { + throw new Error(`${label} seed ID is invalid`) + } + return value +} + +async function seedLink(source, destination) { + const target = resolve(source) + const details = await lstat(target) + if (!details.isDirectory() || details.isSymbolicLink()) throw new Error(`seed source is not a directory: ${target}`) + await mkdir(dirname(destination), { recursive: true }) + if (await exists(destination)) { + const destinationDetails = await lstat(destination) + if (!destinationDetails.isSymbolicLink() || await resolves(destination)) return undefined + } + await replaceLink(destination, target) + return destination +} + +async function seedSlot(source, root, version) { + const seeded = await seedLink(source, join(root, 'versions', version)) + await mkdir(root, { recursive: true }) + const current = join(root, 'current') + if (!await exists(current) || !await resolves(current)) await replaceLink(current, join('versions', version)) + return seeded +} + +async function resolves(path) { + return stat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error)) +} + +async function replaceLink(path, target) { + const temporary = `${path}.${randomUUID()}.tmp` + await symlink(target, temporary, 'dir') + await rename(temporary, path) +} + +export async function provisionPlatformSeed(seedRoot, dataRoot) { + const paths = dataRoot instanceof PlatformPaths ? dataRoot : new PlatformPaths(dataRoot) + await preparePersistentLayout(paths) + const environmentSeedRoot = await exists(join(seedRoot, 'environments')) + ? join(seedRoot, 'environments') + : join(seedRoot, 'environment') + const runtimeSeedRoot = await exists(join(seedRoot, 'runtimes')) + ? join(seedRoot, 'runtimes') + : join(seedRoot, 'runtime') + const environmentVersion = validSeedId( + (await readFile(join(environmentSeedRoot, 'VERSION'), 'utf8')).trim(), + 'Environment', + ) + const runtimeVersion = validSeedId( + (await readFile(join(runtimeSeedRoot, 'VERSION'), 'utf8')).trim(), + 'Runtime', + ) + const seededLinks = (await Promise.all([ + seedSlot(join(environmentSeedRoot, environmentVersion), paths.environmentsRoot, environmentVersion), + seedSlot(join(runtimeSeedRoot, runtimeVersion), paths.runtimesRoot, runtimeVersion), + seedLink(join(seedRoot, 'pristine', runtimeVersion), join(paths.pristineRoot, runtimeVersion)), + seedSlot(join(seedRoot, 'system-plugins', environmentVersion), paths.systemPluginsRoot, environmentVersion), + ])).filter(Boolean) + if (process.getuid?.() === 0) { + const writableRoots = [ + paths.deploymentStateRoot, paths.updaterStateRoot, paths.managementStateRoot, paths.fileTasksRoot, + paths.environmentsRoot, join(paths.environmentsRoot, 'versions'), + paths.pristineRoot, paths.runtimesRoot, join(paths.runtimesRoot, 'versions'), paths.systemPluginsRoot, + join(paths.systemPluginsRoot, 'versions'), paths.snapshotsRoot, paths.userPluginSnapshotsRoot, + paths.cacheRoot, paths.downloadsRoot, paths.npmCacheRoot, paths.logsRoot, + ] + await Promise.all(writableRoots.map(path => lchown(path, 1000, 1000))) + } + return Object.freeze({ environmentVersion, runtimeVersion, seededLinks: Object.freeze(seededLinks) }) +} + +export async function currentSlot(root) { + return basename(await readlink(join(root, 'current'))) +} diff --git a/upstream/container/platform/stage0/lib/signature.mjs b/upstream/container/platform/stage0/lib/signature.mjs new file mode 100644 index 0000000..f173a23 --- /dev/null +++ b/upstream/container/platform/stage0/lib/signature.mjs @@ -0,0 +1,65 @@ +import { createHash, createPublicKey, verify } from 'node:crypto' +import { exactKeys, plainObject, TrustError } from '../../lib/validation.mjs' + +export const SIGNATURE_SCHEMA = 1 +export const SIGNATURE_ALGORITHM = 'Ed25519' + +function canonicalBase64(value, label) { + if (typeof value !== 'string' || value === '') throw new TrustError(`${label} must be base64`) + const bytes = Buffer.from(value, 'base64') + if (bytes.length === 0 || bytes.toString('base64') !== value) { + throw new TrustError(`${label} must be canonical base64`) + } + return bytes +} + +export function publicKeyFromBase64(value, label = 'public key') { + const der = canonicalBase64(value, label) + let key + try { + key = createPublicKey({ key: der, format: 'der', type: 'spki' }) + } catch { + throw new TrustError(`${label} must be an Ed25519 SPKI public key`) + } + if (key.asymmetricKeyType !== 'ed25519') { + throw new TrustError(`${label} must be an Ed25519 SPKI public key`) + } + return { der, key } +} + +export function releaseKeyId(publicKey) { + const { der } = publicKeyFromBase64(publicKey) + return createHash('sha256').update(der).digest('hex') +} + +export function parseSignature(value, label = 'signature') { + const object = plainObject(value, label) + exactKeys(object, ['algorithm', 'keyId', 'schema', 'signature'], label) + if (object.schema !== SIGNATURE_SCHEMA) throw new TrustError(`${label}.schema must be 1`) + if (object.algorithm !== SIGNATURE_ALGORITHM) { + throw new TrustError(`${label}.algorithm must be Ed25519`) + } + if (typeof object.keyId !== 'string' || !/^[a-f0-9]{64}$/.test(object.keyId)) { + throw new TrustError(`${label}.keyId must be a SHA-256 hex digest`) + } + return Object.freeze({ + schema: object.schema, + algorithm: object.algorithm, + keyId: object.keyId, + signature: canonicalBase64(object.signature, `${label}.signature`), + }) +} + +export function verifyDetached(document, signatureValue, publicKey, label = 'document') { + if (!Buffer.isBuffer(document)) throw new TrustError(`${label} must be bytes`) + const signature = parseSignature(signatureValue) + const parsedKey = publicKeyFromBase64(publicKey, `${label} signing key`) + const expectedKeyId = createHash('sha256').update(parsedKey.der).digest('hex') + if (signature.keyId !== expectedKeyId) { + throw new TrustError(`${label} signature key is not trusted`, 'TRUST_UNKNOWN_KEY') + } + if (!verify(null, document, parsedKey.key, signature.signature)) { + throw new TrustError(`${label} signature is invalid`, 'TRUST_BAD_SIGNATURE') + } + return signature.keyId +} diff --git a/upstream/container/platform/stage0/lib/slots.mjs b/upstream/container/platform/stage0/lib/slots.mjs new file mode 100644 index 0000000..1583d61 --- /dev/null +++ b/upstream/container/platform/stage0/lib/slots.mjs @@ -0,0 +1,242 @@ +import { lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' +import { spawn } from 'node:child_process' +import { canonicalJson } from '../../lib/canonical-json.mjs' +import { deriveRecordId, parseBootstrapRecord, parseSlots } from '../../lib/deployment-contracts.mjs' +import { durableCreate, durableReplace } from '../../lib/atomic.mjs' +import { hashTree } from '../../lib/tree-hash.mjs' +import { TrustError } from '../../lib/validation.mjs' + +async function readOptional(path) { + try { return await readFile(path) } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } +} + +function tar(args, capture = false) { + return new Promise((resolveTar, reject) => { + const child = spawn('tar', args, { stdio: ['ignore', capture ? 'pipe' : 'ignore', 'pipe'] }) + const stdout = [] + const stderr = [] + child.stdout?.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.once('error', reject) + child.once('exit', code => code === 0 + ? resolveTar(Buffer.concat(stdout).toString('utf8')) + : reject(new Error(`Bootstrap archive failed: ${Buffer.concat(stderr).toString('utf8')}`))) + }) +} + +function recordDocument(content) { + return parseBootstrapRecord({ ...content, id: deriveRecordId('bootstrap-record', content) }) +} + +export class BootstrapManager { + constructor({ stateRoot, storeRoot, seedRoot, inventory }) { + this.stateRoot = resolve(stateRoot) + this.storeRoot = resolve(storeRoot) + this.seedRoot = resolve(seedRoot) + this.inventory = inventory + this.recordsRoot = join(this.stateRoot, 'records') + this.slotsPath = join(this.stateRoot, 'slots.json') + this.queue = Promise.resolve() + } + + exclusive(operation) { + const result = this.queue.then(operation, operation) + this.queue = result.then(() => undefined, () => undefined) + return result + } + + recordPath(id) { + return join(this.recordsRoot, `${id}.json`) + } + + async writeRecord(value) { + const record = parseBootstrapRecord(value) + const bytes = canonicalJson(record) + const path = this.recordPath(record.id) + const existing = await readOptional(path) + if (existing === undefined) await durableCreate(path, bytes) + else if (!existing.equals(bytes)) throw new TrustError('Bootstrap Record ID identifies conflicting content') + return record + } + + async record(id) { + const bytes = await readOptional(this.recordPath(id)) + if (bytes === undefined) throw new TrustError(`Bootstrap Record ${id} does not exist`) + return parseBootstrapRecord(bytes) + } + + async state() { + const bytes = await readOptional(this.slotsPath) + if (bytes === undefined) return Object.freeze({ generation: 0, current: null, previous: null }) + return parseSlots(bytes, 'bootstrap-record', 'Bootstrap slots') + } + + async commit(current, previous) { + const state = await this.state() + if (current === previous) previous = null + const slots = parseSlots({ + schema: 1, + generation: state.generation + 1, + current, + previous, + }, 'bootstrap-record', 'Bootstrap slots') + await durableReplace(this.slotsPath, canonicalJson(slots)) + return slots + } + + async reconcileImage(record) { + return this.exclusive(async () => { + const image = await this.writeRecord(record) + const state = await this.state() + if (state.current === null) return this.commit(image.id, null) + const current = await this.record(state.current) + if ( + this.inventory.authority === 'development' + && image.targetSequence === 0 + && image.artifact.storage === 'image' + && current.id !== image.id + ) return this.commit(image.id, null) + if (current.targetSequence > image.targetSequence) { + if ( + current.artifact.storage === 'image' + && current.artifact.imageBuildId !== this.inventory.imageBuildId + ) return this.commit(image.id, state.current) + return state + } + if (current.targetSequence < image.targetSequence) return this.commit(image.id, state.current) + if (current.version !== image.version || current.artifact.sha256 !== image.artifact.sha256) { + throw new TrustError('Bootstrap image conflicts with current content at the same targetSequence') + } + if (current.id === image.id) return state + return this.commit(image.id, state.current) + }) + } + + async promote(recordId) { + return this.exclusive(async () => { + await this.record(recordId) + const state = await this.state() + if (state.current === recordId) return state + return this.commit(recordId, state.current) + }) + } + + async rollback() { + return this.exclusive(async () => { + const state = await this.state() + if (state.previous === null) throw new TrustError('no previous Bootstrap exists') + await this.record(state.previous) + return this.commit(state.previous, state.current) + }) + } + + async resolveRecord(recordId) { + const record = await this.record(recordId) + const reference = record.artifact + let path + if (reference.storage === 'image') { + if (reference.imageBuildId !== this.inventory.imageBuildId) { + throw new TrustError('Bootstrap Image Reference belongs to a different image') + } + if (reference.id !== this.inventory.bootstrap.id || reference.sha256 !== this.inventory.bootstrap.sha256) { + throw new TrustError('Bootstrap Image Reference is absent from inventory') + } + path = join(this.seedRoot, 'bootstrap', reference.id) + } else { + path = join(this.storeRoot, reference.id) + } + const details = await lstat(path) + if (!details.isDirectory() || details.isSymbolicLink()) throw new TrustError('resolved Bootstrap must be an immutable directory') + if (await hashTree(path) !== reference.sha256) throw new TrustError('resolved Bootstrap content hash differs from its Record') + return Object.freeze({ record, path }) + } + + async current() { + const state = await this.state() + if (state.current === null) throw new TrustError('no current Bootstrap exists') + return this.resolveRecord(state.current) + } + + async installArchive(archive, { version, targetSequence }) { + const temporary = join(this.storeRoot, `.${randomUUID()}.tmp`) + await mkdir(temporary, { recursive: true }) + try { + const allowed = new Set(['platform', 'control-plane']) + const entries = (await tar(['-tzf', archive], true)).split('\n').filter(Boolean) + if (entries.length === 0 || entries.some(name => ( + name.startsWith('/') || name.split('/').includes('..') || !allowed.has(name.split('/')[0]) + ))) throw new TrustError('Bootstrap archive contains an unsafe path') + await tar(['-xzf', archive, '--no-same-owner', '--no-same-permissions', '-C', temporary]) + await lstat(join(temporary, 'platform', 'bootstrap', 'index.mjs')) + const sha256 = await hashTree(temporary) + const artifactId = `bootstrap-${sha256}` + const destination = join(this.storeRoot, artifactId) + try { + await rename(temporary, destination) + } catch (error) { + if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error + if (await hashTree(destination) !== sha256) throw new TrustError('Bootstrap Store contains conflicting content') + } + return this.writeRecord(recordDocument({ + schema: 1, + version, + bootstrapApi: 1, + targetSequence, + artifact: { storage: 'store', kind: 'bootstrap', id: artifactId, sha256 }, + })) + } finally { + await rm(temporary, { recursive: true, force: true }) + } + } + + async stageArchive(archive, { version, targetSequence }) { + const record = await this.installArchive(archive, { version, targetSequence }) + return this.exclusive(async () => { + const state = await this.state() + if (state.current === null) throw new TrustError('no current Bootstrap exists') + const current = await this.record(state.current) + const recordChanged = current.id !== record.id + const restartRequired = current.version !== record.version + || current.artifact.sha256 !== record.artifact.sha256 + if (recordChanged) await this.commit(record.id, state.current) + return Object.freeze({ record, recordChanged, restartRequired }) + }) + } + + async collectGarbage() { + const state = await this.state() + const retainedRecords = new Set([state.current, state.previous].filter(Boolean)) + const retainedAssets = new Set() + let names + try { names = await readdir(this.recordsRoot) } catch (error) { + if (error?.code === 'ENOENT') return Object.freeze({ assets: [], records: [] }) + throw error + } + const records = [] + for (const name of names.filter(name => name.endsWith('.json')).sort()) { + const record = parseBootstrapRecord(await readFile(join(this.recordsRoot, name))) + records.push(record) + if (retainedRecords.has(record.id) && record.artifact.storage === 'store') retainedAssets.add(record.artifact.id) + } + const removedAssets = [] + for (const name of await readdir(this.storeRoot).catch(error => error?.code === 'ENOENT' ? [] : Promise.reject(error))) { + if (name.startsWith('.') || retainedAssets.has(name)) continue + const path = join(this.storeRoot, name) + const details = await lstat(path) + if (!details.isDirectory() || details.isSymbolicLink()) continue + await rm(path, { recursive: true, force: true }) + removedAssets.push(name) + } + const removedRecords = [] + for (const record of records) if (!retainedRecords.has(record.id)) { + await rm(this.recordPath(record.id), { force: true }) + removedRecords.push(record.id) + } + return Object.freeze({ assets: removedAssets.sort(), records: removedRecords.sort() }) + } +} diff --git a/upstream/container/platform/stage0/lib/snapshot-server.mjs b/upstream/container/platform/stage0/lib/snapshot-server.mjs new file mode 100644 index 0000000..6fbf891 --- /dev/null +++ b/upstream/container/platform/stage0/lib/snapshot-server.mjs @@ -0,0 +1,73 @@ +import { createServer } from 'node:http' +import { chmod, chown, mkdir, rm } from 'node:fs/promises' +import { dirname } from 'node:path' + +const ROUTE = /^\/v1\/(dsh|user-plugin)\/(create|inspect|restore|remove)$/ +const MAX_BODY_BYTES = 4096 + +async function jsonBody(request) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.byteLength + if (size > MAX_BODY_BYTES) throw new Error('snapshot request body is too large') + chunks.push(chunk) + } + return size === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function publicSnapshot(value) { + if (value === undefined || value === null || typeof value !== 'object') return value + const { archive: _archive, path: _path, ...snapshot } = value + return snapshot +} + +function send(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(`${JSON.stringify(value)}\n`) +} + +export function createSnapshotServer({ dshSnapshots, userPluginSnapshots, report = async () => {} }) { + return createServer((request, response) => { + void (async () => { + if (request.method !== 'POST') return send(response, 404, { error: 'not found' }) + const match = ROUTE.exec(new URL(request.url ?? '/', 'http://snapshot.internal').pathname) + if (match === null) return send(response, 404, { error: 'not found' }) + const [, scope, operation] = match + const body = await jsonBody(request) + let progress = null + const onProgress = value => { progress = value } + let result + if (scope === 'dsh') { + if (operation === 'create') result = await dshSnapshots.create({ ...body, onProgress }) + else if (operation === 'inspect') result = await dshSnapshots.inspect(body.id) + else if (operation === 'restore') result = await dshSnapshots.restore(body.id, { onProgress }) + else result = await dshSnapshots.remove(body.id) + } else { + if (operation === 'create') result = await userPluginSnapshots.create(body.id) + else if (operation === 'inspect') result = await userPluginSnapshots.inspect(body.id) + else if (operation === 'restore') result = await userPluginSnapshots.restore(body.id) + else result = await userPluginSnapshots.remove(body.id) + } + send(response, 200, { result: publicSnapshot(result), progress }) + })().catch(async error => { + await report('snapshot.request.failed', { + error, + method: request.method ?? null, + pathname: request.url ?? null, + }) + send(response, 500, { error: error.message }) + }) + }) +} + +export async function listenSnapshots(server, socketPath) { + await mkdir(dirname(socketPath), { recursive: true }) + await rm(socketPath, { force: true }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + if (process.getuid?.() === 0) await chown(socketPath, 0, 1000) + await chmod(socketPath, process.getuid?.() === 0 ? 0o660 : 0o600) +} diff --git a/upstream/container/platform/stage0/lib/supervisor.mjs b/upstream/container/platform/stage0/lib/supervisor.mjs new file mode 100644 index 0000000..99923e7 --- /dev/null +++ b/upstream/container/platform/stage0/lib/supervisor.mjs @@ -0,0 +1,339 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { replaceRuntimeView } from '../../lib/paths.mjs' + +function timeout(milliseconds, message) { + return new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error(message)), milliseconds) + timer.unref?.() + }) +} + +export function bootstrapLaunchCommand({ node, script, uid, gid }) { + if (uid === undefined && gid === undefined) return { executable: node, args: [script] } + if (!Number.isInteger(uid) || uid < 0 || !Number.isInteger(gid) || gid < 0) { + throw new Error('Bootstrap UID and GID must be non-negative integers') + } + return { + executable: '/usr/bin/setpriv', + args: [ + `--reuid=${String(uid)}`, + `--regid=${String(gid)}`, + '--keep-groups', + '--', + node, + script, + ], + } +} + +export function bootstrapLaunchEnvironment({ + environment = process.env, + dataRoot, + runRoot, + seedRoot, + bootstrapVersion, + user, + home, + proxyLaunchToken, + accessLaunchToken, + accessEvidence, +}) { + const inherited = { ...environment } + delete inherited.DSH_PROXY_MASTER_KEY + delete inherited.DSH_PROXY_LAUNCH_TOKEN + delete inherited.DSH_ACCESS_LAUNCH_TOKEN + delete inherited.DSH_PROXY_USERNAME + delete inherited.DSH_PROXY_PASSWORD + delete inherited.DSH_PLATFORM_PASSWORD + return { + ...inherited, + ...(home === undefined ? {} : { + HOME: home, + XDG_CACHE_HOME: join(home, '.cache'), + XDG_CONFIG_HOME: join(home, '.config'), + XDG_DATA_HOME: join(home, '.local', 'share'), + }), + ...(user === undefined ? {} : { USER: user, LOGNAME: user }), + DSH_PLATFORM_DATA: dataRoot, + DSH_PLATFORM_RUN: runRoot, + ...(seedRoot === undefined ? {} : { DSH_PLATFORM_SEED: seedRoot }), + DSH_BOOTSTRAP_VERSION: bootstrapVersion, + ...(proxyLaunchToken === undefined ? {} : { DSH_PROXY_LAUNCH_TOKEN: proxyLaunchToken }), + ...(accessLaunchToken === undefined ? {} : { DSH_ACCESS_LAUNCH_TOKEN: accessLaunchToken }), + DSH_LEGACY_INSTALLATION_EVIDENCE: JSON.stringify(accessEvidence), + } +} + +export async function terminateChild(child, graceMs = 5_000) { + if (child.exitCode !== null || child.signalCode !== null) return + const exited = once(child, 'exit') + child.kill('SIGTERM') + const graceful = await Promise.race([ + exited.then(() => true), + new Promise(resolve => setTimeout(() => resolve(false), graceMs)), + ]) + if (!graceful && child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + await once(child, 'exit') + } +} + +export class BootstrapSupervisor { + constructor({ + slots, + dataRoot, + runRoot = '/run/dsh-platform', + paths, + node = process.execPath, + entrypoint = 'index.mjs', + seedRoot, + readyTimeoutMs = 30_000, + spawnImpl = spawn, + report = async () => {}, + uid, + gid, + user, + home, + proxyLaunchToken, + accessLaunchToken, + accessEvidence, + }) { + this.slots = slots + this.dataRoot = dataRoot + this.runRoot = runRoot + this.paths = paths + this.node = node + this.entrypoint = entrypoint + this.seedRoot = seedRoot + this.readyTimeoutMs = readyTimeoutMs + this.spawnImpl = spawnImpl + this.report = report + this.uid = uid + this.gid = gid + this.user = user + this.home = home + this.proxyLaunchToken = proxyLaunchToken + this.accessLaunchToken = accessLaunchToken + this.accessEvidence = accessEvidence + this.child = undefined + this.requests = new Map() + this.transition = Promise.resolve() + this.fatal = new Promise(resolveFatal => { this.resolveFatal = resolveFatal }) + } + + emit(message, fields = {}) { + void Promise.resolve().then(() => this.report(message, fields)).catch(() => {}) + } + + rejectRequests(error) { + for (const pending of this.requests.values()) pending.reject(error) + this.requests.clear() + } + + enqueueTransition(operation) { + const pending = this.transition.then(operation, operation) + this.transition = pending.catch(() => {}) + return pending + } + + async launch(recordId) { + const startedAt = Date.now() + const resolved = await this.slots.resolveRecord(recordId) + this.emit('bootstrap.launch.started', { + recordId, + bootstrapVersion: resolved.record.version, + targetSequence: resolved.record.targetSequence, + }) + if (this.paths !== undefined) await replaceRuntimeView(this.paths, 'bootstrap', resolved.path) + const command = bootstrapLaunchCommand({ + node: this.node, + script: join(resolved.path, this.entrypoint), + uid: this.uid, + gid: this.gid, + }) + const child = this.spawnImpl(command.executable, command.args, { + env: bootstrapLaunchEnvironment({ + dataRoot: this.dataRoot, + runRoot: this.runRoot, + seedRoot: this.seedRoot, + bootstrapVersion: resolved.record.version, + user: this.user, + home: this.home, + proxyLaunchToken: this.proxyLaunchToken, + accessLaunchToken: this.accessLaunchToken, + accessEvidence: this.accessEvidence, + }), + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + }) + this.child = child + const ready = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => reject(new Error( + `Bootstrap exited before readiness (code=${String(code)}, signal=${String(signal)})`, + ))) + child.on('message', message => { + if (message?.type === 'ready' && message.bootstrapApi === 1) resolve() + if (message?.type === 'recovery-result' && typeof message.requestId === 'string') { + const pending = this.requests.get(message.requestId) + if (pending !== undefined) { + this.requests.delete(message.requestId) + if (message.error === undefined) pending.resolve(message.slots) + else pending.reject(new Error(message.error)) + } + } + }) + }) + try { + await Promise.race([ready, timeout(this.readyTimeoutMs, 'Bootstrap readiness timed out')]) + this.emit('bootstrap.ready', { + recordId, + bootstrapVersion: resolved.record.version, + pid: child.pid ?? null, + elapsedMs: Date.now() - startedAt, + }) + child.once('exit', (code, signal) => { + if (this.child === child) { + const error = new Error(`Bootstrap exited unexpectedly (code=${String(code)}, signal=${String(signal)})`) + this.emit('bootstrap.exited', { + error, + recordId, + pid: child.pid ?? null, + code, + signal, + elapsedMs: Date.now() - startedAt, + }) + this.rejectRequests(error) + this.resolveFatal(error) + } + }) + return child + } catch (error) { + await terminateChild(child) + if (this.child === child) this.child = undefined + this.emit('bootstrap.launch.failed', { + error, + recordId, + bootstrapVersion: resolved.record.version, + pid: child.pid ?? null, + elapsedMs: Date.now() - startedAt, + }) + throw error + } + } + + startWithRollback() { + return this.enqueueTransition(() => this.startWithRollbackNow()) + } + + async startWithRollbackNow() { + const state = await this.slots.state() + if (state.current === null) throw new Error('no current Bootstrap is installed') + try { + return await this.launch(state.current) + } catch (candidateError) { + if (state.previous === null) { + this.emit('bootstrap.start.failed', { error: candidateError, failedRecordId: state.current }) + throw candidateError + } + this.emit('bootstrap.rollback.started', { + level: 'warning', + error: candidateError, + failedRecordId: state.current, + targetRecordId: state.previous, + }) + await this.slots.rollback() + try { + const child = await this.launch(state.previous) + this.emit('bootstrap.rollback.completed', { + level: 'warning', + failedRecordId: state.current, + recordId: state.previous, + pid: child.pid ?? null, + }) + return child + } catch (rollbackError) { + const error = new AggregateError([candidateError, rollbackError], 'Bootstrap candidate and rollback both failed') + this.emit('bootstrap.rollback.failed', { + error, + failedRecordId: state.current, + targetRecordId: state.previous, + }) + throw error + } + } + } + + restart() { + return this.enqueueTransition(() => this.restartNow()) + } + + async restartNow() { + this.emit('bootstrap.restart.started', { pid: this.child?.pid ?? null }) + const child = this.child + this.child = undefined + this.rejectRequests(new Error('Bootstrap restarted during recovery')) + if (child !== undefined) await terminateChild(child) + try { + const restarted = await this.startWithRollbackNow() + this.emit('bootstrap.restart.completed', { pid: restarted.pid ?? null }) + return restarted + } catch (error) { + this.emit('bootstrap.restart.failed', { error }) + throw error + } + } + + stop() { + return this.enqueueTransition(() => this.stopNow()) + } + + async stopNow() { + const child = this.child + this.emit('bootstrap.stop.started', { pid: child?.pid ?? null }) + this.child = undefined + this.rejectRequests(new Error('Bootstrap stopped during recovery')) + if (child !== undefined) await terminateChild(child) + this.emit('bootstrap.stop.completed', { + pid: child?.pid ?? null, + code: child?.exitCode ?? null, + signal: child?.signalCode ?? null, + }) + } + + recoverImageBaseline(timeoutMs = 60_000) { + if (this.child === undefined || this.child.connected !== true) throw new Error('Bootstrap is unavailable for recovery') + const requestId = randomUUID() + this.emit('bootstrap.recovery.started', { requestId }) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.requests.delete(requestId) + const error = new Error('image baseline recovery timed out') + this.emit('bootstrap.recovery.failed', { error, requestId }) + reject(error) + }, timeoutMs) + this.requests.set(requestId, { + resolve: value => { + clearTimeout(timer) + this.emit('bootstrap.recovery.completed', { requestId }) + resolve(value) + }, + reject: error => { + clearTimeout(timer) + this.emit('bootstrap.recovery.failed', { error, requestId }) + reject(error) + }, + }) + this.child.send({ type: 'recover-image-baseline', requestId }, error => { + if (error !== null && error !== undefined) { + clearTimeout(timer) + this.requests.delete(requestId) + this.emit('bootstrap.recovery.failed', { error, requestId }) + reject(error) + } + }) + }) + } +} diff --git a/upstream/container/platform/stage0/lib/target.mjs b/upstream/container/platform/stage0/lib/target.mjs new file mode 100644 index 0000000..95ad55f --- /dev/null +++ b/upstream/container/platform/stage0/lib/target.mjs @@ -0,0 +1,26 @@ +import { TrustError } from '../../lib/validation.mjs' +import { parseStable } from '../../lib/contracts.mjs' +import { verifyDetached } from './signature.mjs' + +export function parseReleaseTarget(bytes) { + return parseStable(bytes) +} + +export function verifyReleaseTarget(bytes, signature, keyring) { + const parsed = parseReleaseTarget(bytes) + if (parsed.keyringGeneration !== keyring.generation) { + throw new TrustError('release target requires a different keyring generation') + } + const keyId = verifyDetached(bytes, signature, keyring.current.publicKey, 'release target') + return Object.freeze({ ...parsed, keyId }) +} + +export function validateTargetTransition(previousBytes, previous, nextBytes, next) { + if (next.targetSequence < previous.targetSequence) { + throw new TrustError('release target sequence cannot decrease', 'TRUST_ROLLBACK') + } + if (next.targetSequence === previous.targetSequence && !previousBytes.equals(nextBytes)) { + throw new TrustError('release target sequence cannot identify different content') + } + return next +} diff --git a/upstream/container/platform/stage0/lib/trust-server.mjs b/upstream/container/platform/stage0/lib/trust-server.mjs new file mode 100644 index 0000000..d38ab3d --- /dev/null +++ b/upstream/container/platform/stage0/lib/trust-server.mjs @@ -0,0 +1,121 @@ +import { createServer } from 'node:http' +import { chmod, chown, mkdir, rm } from 'node:fs/promises' +import { dirname } from 'node:path' + +const MAX_BODY = 64 * 1024 + +async function jsonBody(request) { + const chunks = [] + let bytes = 0 + for await (const chunk of request) { + bytes += chunk.byteLength + if (bytes > MAX_BODY) throw new Error('request body is too large') + chunks.push(chunk) + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function send(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(`${JSON.stringify(value)}\n`) +} + +export function createTrustServer({ ledger, objects, stageBootstrap, collectBootstrap, report = async () => {} }) { + const record = (message, fields) => Promise.resolve().then(() => report(message, fields)).catch(() => {}) + return createServer(async (request, response) => { + let pathname = 'invalid-url' + try { + const url = new URL(request.url ?? '/', 'http://stage0.internal') + pathname = url.pathname + if (request.method === 'GET' && url.pathname === '/v1/status') { + const keyring = await ledger.currentKeyring() + const target = await ledger.currentTarget() + send(response, 200, { + keyringGeneration: keyring?.value.generation ?? null, + targetSequence: target?.value.targetSequence ?? null, + officialDshVersion: (await ledger.currentOfficialDsh().catch(() => undefined))?.value.version ?? null, + }) + return + } + if (request.method === 'GET' && url.pathname === '/v1/receipts/active') { + send(response, 200, { receipts: await objects.activeReceipts() }) + return + } + if (request.method !== 'POST') { + send(response, 404, { error: 'not found' }) + return + } + const body = await jsonBody(request) + if (url.pathname === '/v1/keyring') { + const value = await ledger.acceptKeyring(Buffer.from(body.document, 'base64'), body.signature) + await objects.reconcileRevocations(value) + await record('keyring.accepted', { generation: value.generation }) + send(response, 200, { generation: value.generation }) + } else if (url.pathname === '/v1/target') { + const value = await ledger.acceptTarget(Buffer.from(body.document, 'base64'), body.signature) + await objects.reconcileRevocations((await ledger.currentKeyring()).value) + await record('target.accepted', { targetSequence: value.targetSequence }) + send(response, 200, { targetSequence: value.targetSequence }) + } else if (url.pathname === '/v1/artifacts/import') { + const receipt = body.parentReceipt === null || body.parentReceipt === undefined + ? await objects.importFromTarget(body.artifactId, body.sourcePath) + : await objects.importFromManifest(body.parentReceipt, body.artifactId, body.sourcePath) + await record('artifact.imported', { artifactId: body.artifactId, objectSha256: receipt.objectSha256 }) + send(response, 200, receipt) + } else if (url.pathname === '/v1/dsh/ensure') { + if ( + body === null || typeof body !== 'object' || Array.isArray(body) + || Object.keys(body).length !== 3 + || !Object.hasOwn(body, 'version') + || !Object.hasOwn(body, 'metadataPath') + || !Object.hasOwn(body, 'tarballPath') + ) { + throw new Error('official DSH import requires fixed untrusted sources') + } + const receipt = await objects.ensureOfficialDsh(body.version, body.metadataPath, body.tarballPath) + await record('official-dsh.imported', { dshVersion: body.version, objectSha256: receipt.objectSha256 }) + send(response, 200, receipt) + } else if (url.pathname === '/v1/manifests/accept') { + const receipt = await objects.acceptManifest(body.receipt, body.signatureReceipt) + await record('manifest.accepted', { objectSha256: receipt.objectSha256 }) + send(response, 200, receipt) + } else if (url.pathname === '/v1/activate') { + const receipts = await objects.activate(body.receipts) + await record('receipts.activated', { receiptCount: receipts.length }) + send(response, 200, { receipts }) + } else if (url.pathname === '/v1/objects/collect') { + const removed = await objects.collectGarbage() + await record('objects.collected', { removedCount: removed.length }) + send(response, 200, { removed }) + } else if (url.pathname === '/v1/bootstrap/stage' && stageBootstrap !== undefined) { + const result = await stageBootstrap(body.receipt, body.version) + send(response, 202, { + status: result?.restartRequired === false ? 'staged' : 'switching', + version: body.version, + ...(result ?? {}), + }) + } else if (url.pathname === '/v1/bootstrap/collect' && collectBootstrap !== undefined) { + send(response, 200, await collectBootstrap()) + } else { + send(response, 404, { error: 'not found' }) + } + } catch (error) { + await record('trust.request.failed', { error, method: request.method ?? null, pathname }) + send(response, 400, { + error: error instanceof Error ? error.message : 'invalid request', + ...(typeof error?.code === 'string' ? { code: error.code } : {}), + }) + } + }) +} + +export async function listenUnix(server, socketPath, { mode = 0o600, uid, gid } = {}) { + await mkdir(dirname(socketPath), { recursive: true }) + await rm(socketPath, { force: true }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + if (uid !== undefined || gid !== undefined) await chown(socketPath, uid ?? -1, gid ?? -1) + await chmod(socketPath, mode) +} diff --git a/upstream/container/platform/stage0/lib/user-roots.mjs b/upstream/container/platform/stage0/lib/user-roots.mjs new file mode 100644 index 0000000..9fc5194 --- /dev/null +++ b/upstream/container/platform/stage0/lib/user-roots.mjs @@ -0,0 +1,23 @@ +import { lchown, lstat, mkdir } from 'node:fs/promises' +import { isAbsolute, resolve } from 'node:path' + +export async function prepareUserWritableRoot(path, { uid, gid, label = 'User-writable root' } = {}) { + if (typeof path !== 'string' || !isAbsolute(path)) throw new Error(`${label} must be an absolute path`) + const root = resolve(path) + if (root === '/') throw new Error(`${label} cannot be the filesystem root`) + await mkdir(root, { recursive: true }) + const metadata = await lstat(root) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error(`${label} must be a directory`) + if (uid !== undefined || gid !== undefined) await lchown(root, uid ?? -1, gid ?? -1) + return root +} + +export async function prepareUserWritableRoots({ dshHome, defaultWorkspace, uid, gid } = {}) { + const roots = [] + for (const [label, path] of [['DSH_HOME', dshHome], ['DSH_DEFAULT_WORKSPACE', defaultWorkspace]]) { + const root = resolve(path) + if (roots.includes(root)) continue + roots.push(await prepareUserWritableRoot(path, { uid, gid, label })) + } + return Object.freeze(roots) +} diff --git a/upstream/container/platform/stage0/lib/user-skill-server.mjs b/upstream/container/platform/stage0/lib/user-skill-server.mjs new file mode 100644 index 0000000..0b0496a --- /dev/null +++ b/upstream/container/platform/stage0/lib/user-skill-server.mjs @@ -0,0 +1,67 @@ +import { createServer } from 'node:http' +import { chmod, chown, mkdir, rm } from 'node:fs/promises' +import { dirname } from 'node:path' + +const ACTION_ROUTE = '/v1/action' +const MAX_BODY_BYTES = 4096 + +async function jsonBody(request) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.byteLength + if (size > MAX_BODY_BYTES) { + const error = new Error('User Skill request body is too large') + error.statusCode = 413 + throw error + } + chunks.push(chunk) + } + return size === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function send(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(`${JSON.stringify(value)}\n`) +} + +function actionBody(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).sort().join(',') !== 'action,entryId,revision') { + const error = new Error('User Skill request is invalid') + error.statusCode = 400 + throw error + } + return value +} + +export function createUserSkillServer({ manager, report = async () => {} }) { + if (manager === undefined || typeof manager.configure !== 'function') throw new Error('User Skill manager is required') + return createServer((request, response) => { + void (async () => { + const pathname = new URL(request.url ?? '/', 'http://user-skills.internal').pathname + if (request.method !== 'POST' || pathname !== ACTION_ROUTE) return send(response, 404, { error: 'not found' }) + send(response, 200, await manager.configure(actionBody(await jsonBody(request)))) + })().catch(async error => { + await report('user-skill.privileged-action.failed', { + error, + method: request.method ?? null, + pathname: request.url ?? null, + }) + send(response, Number.isInteger(error?.statusCode) ? error.statusCode : 500, { + error: error instanceof Error ? error.message : String(error), + }) + }) + }) +} + +export async function listenUserSkills(server, socketPath) { + await mkdir(dirname(socketPath), { recursive: true }) + await rm(socketPath, { force: true }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + if (process.getuid?.() === 0) await chown(socketPath, 0, 1000) + await chmod(socketPath, process.getuid?.() === 0 ? 0o660 : 0o600) +} diff --git a/upstream/container/platform/test/access-manager.test.mjs b/upstream/container/platform/test/access-manager.test.mjs new file mode 100644 index 0000000..8f284e9 --- /dev/null +++ b/upstream/container/platform/test/access-manager.test.mjs @@ -0,0 +1,2121 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { AccessStateStore } from '../../control-plane/services/access-manager/lib/store.mjs' +import { + createCredential, + normalizePassword, + normalizeUsername, + verifyCredential, +} from '../../control-plane/services/access-manager/lib/credentials.mjs' +import { AuthenticationLimiter } from '../../control-plane/services/access-manager/lib/rate-limiter.mjs' +import { AccessService, createAccessHttpServer } from '../../control-plane/services/access-manager/lib/server.mjs' +import { consumeInternalCapability } from '../lib/access-capability.mjs' +import { BrowserSessionStore } from '../../control-plane/services/access-manager/lib/sessions.mjs' +import { LocalApiClient } from '../../control-plane/modules/updater/lib/client.mjs' +import { collectAccessEvidence, parseAccessEvidence } from '../lib/access-evidence.mjs' +import { PlatformPaths } from '../lib/paths.mjs' +import { CapabilityStore } from '../../control-plane/services/access-manager/lib/capabilities.mjs' +import { ManagementTransitionStore } from '../../control-plane/services/access-manager/lib/transitions.mjs' +import { detectRuntimeCapabilities } from '../../control-plane/services/access-manager/lib/runtime-capabilities.mjs' + +const fastVerifier = (password, options = {}) => createCredential(password, { + ...options, + policy: { N: 16, r: 1, p: 1, keyLength: 32, maxmem: 2 * 1024 * 1024 }, +}) + +test('derives Root capability from actual init groups and reports shared-process isolation honestly', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-access-capabilities-')) + try { + const groupPath = join(root, 'group') + const statusPath = join(root, 'status') + const mountsPath = join(root, 'mounts') + const ptracePath = join(root, 'ptrace_scope') + await writeFile(groupPath, 'dsh-sudo-true:x:993:\ndsh-sudo-false:x:994:\n') + await writeFile(statusPath, 'Name:\ttini\nGroups:\t1000 993\n') + await writeFile(mountsPath, 'proc /proc proc rw,nosuid,nodev,noexec,relatime,hidepid=2 0 0\n') + await writeFile(ptracePath, '1\n') + assert.deepEqual(await detectRuntimeCapabilities({ + groupPath, initStatusPath: statusPath, mountsPath, ptraceScopePath: ptracePath, + }), { + dshRootCapabilityEffective: true, + agentIsolationEffective: false, + details: { + sudoSelection: 'enabled', ptraceScope: 1, procHidepid: 2, + agentIsolationReason: 'shared-node-process-identity', + }, + }) + await writeFile(statusPath, 'Name:\ttini\nGroups:\t1000 994\n') + assert.equal((await detectRuntimeCapabilities({ + groupPath, initStatusPath: statusPath, mountsPath, ptraceScopePath: ptracePath, + })).dshRootCapabilityEffective, false) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +test('privileged capabilities are single-use and bound to execution details', () => { + let now = 1_000 + const store = new CapabilityStore({ now: () => now, random: size => Buffer.alloc(size, 7), ttlMs: 100 }) + const account = { + accountId: 'account', + mainCredential: { version: 2 }, + managementAdditionalCredential: { enabled: false, version: 1 }, + managementAccess: { version: 3 }, + } + const issued = store.issue({ sessionId: 'session' }, account, { + audience: 'management', method: 'POST', target: '/_dsh_platform/api/v1/restart-dsh', + }) + assert.equal(store.consume(issued.token, account, { + audience: 'management', method: 'POST', target: '/_dsh_platform/api/v1/restart-dsh', + })?.sessionId, 'session') + assert.equal(store.consume(issued.token, account, { + audience: 'management', method: 'POST', target: '/_dsh_platform/api/v1/restart-dsh', + }), undefined) + const second = store.issue({ sessionId: 'session' }, account, { + audience: 'maintenance', method: 'GET', target: '/_dsh_platform/api/v1/files/list?path=%2Fworkspace', + }) + assert.equal(store.consume(second.token, account, { + audience: 'maintenance', method: 'GET', target: '/_dsh_platform/api/v1/files/list?path=%2Fdata', + }), undefined) + const third = store.issue({ sessionId: 'session' }, account, { + audience: 'management', method: 'GET', target: '/_dsh_platform/api/v1/status', + }) + now += 101 + assert.equal(store.consume(third.token, account, { + audience: 'management', method: 'GET', target: '/_dsh_platform/api/v1/status', + }), undefined) +}) + +test('DSH sessions issue only single-use Plugin API capabilities', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + const issued = await service.issuePluginCapability({ + dshToken: initialized.session.token, + origin: 'https://dsh.example', + csrfToken: initialized.session.csrfToken, + requireCsrf: true, + method: 'POST', + target: '/_dsh_platform/api/v1/restart-dsh', + }) + assert.equal((await service.consumeCapability({ + token: issued.capability.token, + audience: 'plugin', + method: 'POST', + target: '/_dsh_platform/api/v1/restart-dsh', + })).authorized, true) + await assert.rejects(service.consumeCapability({ + token: issued.capability.token, + audience: 'plugin', + method: 'POST', + target: '/_dsh_platform/api/v1/restart-dsh', + }), error => error.code === 'CAPABILITY_INVALID') + await assert.rejects(service.issuePluginCapability({ + dshToken: initialized.session.token, + origin: 'https://dsh.example', + csrfToken: initialized.session.csrfToken, + requireCsrf: true, + method: 'POST', + target: '/_dsh_platform/api/v1/restart-dsh', + }).then(async value => service.consumeCapability({ + token: value.capability.token, + audience: 'management', + method: 'POST', + target: '/_dsh_platform/api/v1/restart-dsh', + })), error => error.code === 'CAPABILITY_INVALID') +}) + +test('final execution capability checks distinguish denial from Access Manager failure', async () => { + const calls = [] + const access = { + async request(method, path, body) { + calls.push({ method, path, body }) + return { authorized: true } + }, + } + assert.equal(await consumeInternalCapability(access, { + token: 'token', audience: 'management', method: 'POST', target: '/target', + }), true) + assert.deepEqual(calls, [{ + method: 'POST', path: '/v1/capabilities/consume', + body: { token: 'token', audience: 'management', method: 'POST', target: '/target' }, + }]) + + for (const statusCode of [401, 409, 500]) { + const failingAccess = { + async request() { throw Object.assign(new Error(`access ${statusCode}`), { statusCode }) }, + } + if (statusCode === 401) { + assert.equal(await consumeInternalCapability(failingAccess, { + token: 'token', audience: 'maintenance', method: 'GET', target: '/target', + }), false) + } else { + await assert.rejects(consumeInternalCapability(failingAccess, { + token: 'token', audience: 'maintenance', method: 'GET', target: '/target', + }), error => error.statusCode === (statusCode === 500 ? 503 : statusCode)) + } + } + await assert.rejects(consumeInternalCapability({ + async request() { throw Object.assign(new Error('socket unavailable'), { code: 'ECONNREFUSED' }) }, + }, { + token: 'token', audience: 'management', method: 'GET', target: '/target', + }), error => error.statusCode === 503 && error.code === 'ECONNREFUSED') +}) + +test('Management origin transitions and continuations expire without changing authority', () => { + let now = 1_000 + const transitions = new ManagementTransitionStore({ now: () => now, ttlMs: 100 }) + const account = { + accountId: 'account', revision: 'revision', + managementAccess: { version: 3 }, + } + const created = transitions.create({ + account, instanceId: 'instance', sessionId: 'session', + sourceOrigin: 'https://dsh.example', sourceDshOrigin: 'https://dsh.example', + sourceDshSessionId: 'dsh-session', + mode: 'isolated', + isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://manage.example' }, + candidateOrigin: 'https://manage.example', + }) + now += 101 + assert.equal(transitions.probe({ + transitionId: created.transitionId, nonce: created.nonce, + sourceOrigin: 'https://dsh.example', candidateOrigin: 'https://manage.example', + instanceId: 'instance', + }), undefined) + + const continuation = transitions.createContinuation({ + account, targetOrigin: 'https://manage.example', sourceDshOrigin: 'https://dsh.example', + sourceDshSessionId: 'dsh-session', + }) + now += 101 + assert.equal(transitions.consumeContinuation({ + token: continuation.token, account, targetOrigin: 'https://manage.example', + }), undefined) +}) + +async function fixture(options = {}) { + const root = await mkdtemp(join(tmpdir(), 'dsh-access-state-')) + let now = new Date('2026-08-28T00:00:00.000Z') + const store = new AccessStateStore({ + root, + now: () => now, + createVerifier: fastVerifier, + }) + const service = new AccessService({ + store, + classificationToken: 'classification-token', + limiter: new AuthenticationLimiter({ globalLimit: 10, maxConcurrent: 2 }), + sessions: new BrowserSessionStore({ now: () => now.getTime() }), + now: () => now.getTime(), + ...options, + }) + return { root, store, service, setNow: value => { now = new Date(value) } } +} + +async function persistIsolatedManagementAccess(store) { + const current = await store.state() + assert.equal(current.state, 'initialized') + const account = { + ...current.account, + managementAccess: { + mode: 'isolated', + version: current.account.managementAccess.version + 1, + isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://manage.example' }, + dshPublicOrigin: 'https://dsh.example', + changedAt: '2026-08-28T00:00:00.000Z', + }, + } + await store.replaceAccount(account, current.account.revision) + return account +} + +async function loginDsh(service, value) { + const { authenticationContext } = await service.status() + return service.loginDsh({ ...value, authenticationContext }) +} + +async function completeManagementLogin(service, value) { + const { authenticationContext } = await service.status() + return service.completeManagementLogin({ ...value, authenticationContext }) +} + +async function loginManagement(service, { + dshOrigin = 'http://dsh.example:3080', + targetOrigin = dshOrigin, + managementPassword, +} = {}) { + const dsh = await loginDsh(service, { + username: 'admin', password: 'correct horse battery staple', origin: dshOrigin, + }) + const handoff = await service.createManagementHandoff({ + dshToken: dsh.session.token, dshOrigin, targetOrigin, + }) + let management = await service.consumeManagementHandoff({ + token: handoff.handoff.token, origin: targetOrigin, + }) + if (management.pending !== undefined && managementPassword !== undefined) { + management = await completeManagementLogin(service, { + pendingToken: management.pending.token, + origin: targetOrigin, + password: managementPassword, + }) + } + return { dsh, management } +} + +test('normalizes administrator usernames and preserves password whitespace', () => { + assert.equal(normalizeUsername(' a\u0301dmin '), 'ádmin') + assert.equal(normalizePassword(' password '), ' password ') + assert.throws(() => normalizeUsername('admin\nroot'), error => error.code === 'USERNAME_INVALID') + assert.throws(() => normalizeUsername(`admin\u202eroot`), error => error.code === 'USERNAME_INVALID') + assert.throws(() => normalizePassword('short'), error => error.code === 'PASSWORD_POLICY_VIOLATION') + assert.throws(() => normalizePassword('password\nvalue'), error => error.code === 'PASSWORD_POLICY_VIOLATION') + assert.throws(() => normalizePassword(`password\u202evalue`), error => error.code === 'PASSWORD_POLICY_VIOLATION') +}) + +test('creates salted scrypt credentials and verifies them in constant-length form', async () => { + const left = await fastVerifier('correct horse battery staple') + const right = await fastVerifier('correct horse battery staple') + assert.equal(left.algorithm, 'scrypt') + assert.notEqual(left.salt, right.salt) + assert.notEqual(left.hash, right.hash) + assert.equal(await verifyCredential('correct horse battery staple', left), true) + assert.equal(await verifyCredential('incorrect password', left), false) +}) + +test('classifies fresh and legacy installations exactly once', async () => { + const fresh = await fixture() + assert.equal((await fresh.store.state()).state, 'classification-pending') + assert.equal((await fresh.store.classify({ dshProfile: false, legacyAuthenticationConfigured: false })).initialization.state, 'never-initialized') + assert.equal((await fresh.store.classify({ dshProfile: true, legacyAuthenticationConfigured: true })).initialization.state, 'never-initialized') + + const emptyWithLegacyEnvironment = await fixture() + assert.equal((await emptyWithLegacyEnvironment.store.classify({ + dshProfile: false, + legacyAuthenticationConfigured: true, + })).initialization.state, 'never-initialized') + + const legacy = await fixture() + const classified = await legacy.store.classify({ dshProfile: true, legacyAuthenticationConfigured: false }) + assert.equal(classified.initialization.state, 'migration-required') + assert.equal(Buffer.from(classified.initialization.instanceId, 'base64url').byteLength, 32) +}) + +test('publishes an account before converging initialization and rejects a second registration', async () => { + const { root, service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initialize({ username: ' admin ', password: 'correct horse battery staple' }) + assert.equal(initialized.state, 'initialized') + assert.equal(initialized.account.username, 'admin') + assert.equal(JSON.parse(await readFile(join(root, 'initialization.json'), 'utf8')).state, 'initialized') + assert.equal(JSON.parse(await readFile(join(root, 'account.json'), 'utf8')).mainCredential.algorithm, 'scrypt') + assert.equal((await stat(root)).mode & 0o777, 0o700) + assert.equal((await stat(join(root, 'initialization.json'))).mode & 0o777, 0o600) + assert.equal((await stat(join(root, 'account.json'))).mode & 0o777, 0o600) + await assert.rejects( + service.initialize({ username: 'other', password: 'another strong password' }), + error => error.code === 'ALREADY_INITIALIZED', + ) +}) + +test('migrates a legacy deployment with one expiring single-use authentication reset key', async () => { + const current = await fixture() + await current.service.classify({ token: 'classification-token', evidence: { dshProfile: true } }) + const first = await current.service.generateAuthenticationResetKey() + const second = await current.service.generateAuthenticationResetKey() + assert.match(second.key, /^dshak_/) + assert.notEqual(first.key, second.key) + await assert.rejects(current.service.resetDshAuthentication({ + setupKey: first.key, username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }), error => error.code === 'AUTHENTICATION_RESET_KEY_INVALID' && error.statusCode === 401) + const migrated = await current.service.resetDshAuthentication({ + setupKey: second.key, username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + assert.equal(migrated.state, 'initialized') + assert.equal(migrated.account.username, 'admin') + assert.match(migrated.session.token, /^dshs_/) + await assert.rejects(current.service.generateAuthenticationResetKey(), error => error.code === 'AUTHENTICATION_RESET_UNAVAILABLE') +}) + +test('keeps a valid authentication reset key usable while new account fields are corrected', async () => { + const current = await fixture() + await current.service.classify({ token: 'classification-token', evidence: { dshProfile: true } }) + const setup = await current.service.generateAuthenticationResetKey() + await assert.rejects(current.service.resetDshAuthentication({ + setupKey: setup.key, username: 'operator', password: 'four', origin: 'https://dsh.example', + }), error => error.code === 'PASSWORD_POLICY_VIOLATION') + const migrated = await current.service.resetDshAuthentication({ + setupKey: setup.key, username: 'operator', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + assert.equal(migrated.account.username, 'operator') + await assert.rejects(current.service.resetDshAuthentication({ + setupKey: setup.key, username: 'operator', password: 'correct horse battery staple', origin: 'https://dsh.example', + }), error => error.code === 'AUTHENTICATION_RESET_KEY_INVALID') +}) + +test('rejects expired and malformed authentication reset keys without exposing an internal error', async () => { + const current = await fixture() + await current.service.classify({ token: 'classification-token', evidence: { dshProfile: true } }) + const expired = await current.service.generateAuthenticationResetKey() + current.setNow('2026-08-28T00:10:00.001Z') + await assert.rejects(current.service.resetDshAuthentication({ + setupKey: expired.key, username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }), error => error.code === 'AUTHENTICATION_RESET_KEY_INVALID' && error.statusCode === 401) + await current.service.generateAuthenticationResetKey() + await assert.rejects(current.service.resetDshAuthentication({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }), error => error.code === 'AUTHENTICATION_RESET_KEY_INVALID' && error.statusCode === 401) +}) + +test('recreates a damaged administrator account with a root-issued authentication reset key', async () => { + const current = await fixture() + await current.store.prepare() + await writeFile(join(current.root, 'initialization.json'), JSON.stringify({ + schema: 1, + instanceId: Buffer.alloc(32, 7).toString('base64url'), + state: 'recovery-required', + createdAt: '2026-08-28T00:00:00.000Z', + updatedAt: '2026-08-28T00:00:00.000Z', + })) + const setup = await current.service.generateAuthenticationResetKey() + const recovered = await current.service.resetDshAuthentication({ + setupKey: setup.key, + username: 'recovered-admin', + password: 'correct horse battery staple', + origin: 'https://dsh.example', + }) + assert.equal(recovered.state, 'initialized') + assert.equal(recovered.account.username, 'recovered-admin') + assert.equal((await current.store.state()).state, 'initialized') +}) + +test('normal and recovery sockets expose distinct bounded protocols without verifier material', async () => { + const reports = [] + const { root, service } = await fixture({ + limiter: new AuthenticationLimiter({ + globalLimit: 100, backoffThreshold: 1, initialBackoffMs: 1_000, + }), + report: async (message, fields) => reports.push({ message, fields }), + }) + const accessPath = join(root, 'access.sock') + const recoveryPath = join(root, 'recovery.sock') + const accessServer = createAccessHttpServer({ service }) + const recoveryServer = createAccessHttpServer({ service, surface: 'recovery' }) + await Promise.all([ + new Promise((resolve, reject) => { accessServer.once('error', reject); accessServer.listen(accessPath, resolve) }), + new Promise((resolve, reject) => { recoveryServer.once('error', reject); recoveryServer.listen(recoveryPath, resolve) }), + ]) + const access = new LocalApiClient(accessPath) + const recovery = new LocalApiClient(recoveryPath) + try { + await assert.rejects( + access.request('POST', '/v1/classify', { token: 'wrong', evidence: { dshProfile: false } }), + error => error.statusCode === 403, + ) + await access.request('POST', '/v1/classify', { + token: 'classification-token', evidence: { dshProfile: false }, + }) + const initialized = await access.request('POST', '/v1/initialize', { + username: 'admin', password: 'correct horse battery staple', + }) + assert.doesNotMatch(JSON.stringify(initialized), /salt|hash|password|verifier/i) + await assert.rejects(access.request('POST', '/v1/initialize', { + username: 'other', password: 'registration secret must not enter logs', + }), error => error.statusCode === 409 && error.code === 'ALREADY_INITIALIZED') + await assert.rejects(access.request('POST', '/v1/dsh/reset-authentication', { + setupKey: 'reset key must not enter logs', + username: 'other', + password: 'reset secret must not enter logs', + origin: 'https://dsh.example', + }), error => error.statusCode === 401 && error.code === 'AUTHENTICATION_RESET_KEY_INVALID') + assert.deepEqual(reports.find(report => report.message === 'access.initialization.failed'), { + message: 'access.initialization.failed', + fields: { code: 'ALREADY_INITIALIZED' }, + }) + assert.deepEqual(reports.find(report => report.message === 'access.authentication-reset.failed'), { + message: 'access.authentication-reset.failed', + fields: { code: 'AUTHENTICATION_RESET_KEY_INVALID' }, + }) + assert.doesNotMatch(JSON.stringify(reports), /registration secret|reset key|reset secret/) + const initialize = service.store.initialize.bind(service.store) + service.store.initialize = async () => { throw Object.assign(new Error('simulated account write failure'), { code: 'EIO' }) } + await assert.rejects(access.request('POST', '/v1/dsh/initialize', { + username: 'storage-failure-user', + password: 'storage failure secret must not enter logs', + origin: 'https://dsh.example', + }), error => error.statusCode === 500 && error.code === 'ACCESS_INTERNAL_ERROR') + service.store.initialize = initialize + assert.ok(reports.some(report => report.message === 'access.initialization.failed' + && report.fields.code === 'INTERNAL_ERROR')) + assert.doesNotMatch(JSON.stringify(reports), /storage-failure-user|storage failure secret/) + const authenticated = await access.request('POST', '/v1/authenticate', { + username: 'admin', password: 'correct horse battery staple', + }) + assert.equal(authenticated.authenticated, true) + assert.doesNotMatch(JSON.stringify(authenticated), /salt|hash|password|verifier/i) + await assert.rejects(access.request('POST', '/v1/authenticate', { + username: 'admin', password: 'incorrect password', authenticationSource: 'browser:test', + }), error => error.statusCode === 429 + && error.code === 'AUTHENTICATION_RETRY_REQUIRED' + && error.retryAfterSeconds === 1) + await assert.rejects(access.request('POST', '/v1/authenticate', { + username: 'admin', password: 'correct horse battery staple', authenticationSource: 'browser:test', + }), error => error.statusCode === 429 + && error.code === 'AUTHENTICATION_RETRY_REQUIRED' + && error.retryAfterSeconds === 1) + const recoveryStatus = await recovery.request('GET', '/v1/recovery/status') + assert.deepEqual(recoveryStatus.authenticationRetry, { + activeSources: 1, consecutiveFailures: 1, retryAfterSeconds: 1, + sourceRetryAfterSeconds: 0, globalFailures: 1, globalRetryAfterSeconds: 0, + }) + const cleared = await recovery.request('POST', '/v1/recovery/clear-retry') + assert.deepEqual(cleared, { + status: 'cleared', scope: 'all', credential: 'all', cleared: true, activeSources: 1, + consecutiveFailures: 1, retryAfterSeconds: 1, + sourceRetryAfterSeconds: 0, globalFailures: 1, globalRetryAfterSeconds: 0, + twoFactorDailyCleared: 0, + }) + assert.equal((await access.request('POST', '/v1/authenticate', { + username: 'admin', password: 'correct horse battery staple', authenticationSource: 'browser:test', + })).authenticated, true) + await assert.rejects(access.request('POST', '/v1/authenticate', { + username: 'admin', password: 'incorrect password', authenticationSource: 'browser:global-only', + }), error => error.statusCode === 429 + && error.code === 'AUTHENTICATION_RETRY_REQUIRED' + && error.retryAfterSeconds === 1) + const globalCleared = await recovery.request('POST', '/v1/recovery/clear-retry', { scope: 'global' }) + assert.deepEqual(globalCleared, { + status: 'cleared', scope: 'global', credential: 'all', cleared: true, + globalFailures: 1, globalRetryAfterSeconds: 0, twoFactorDailyCleared: 0, + }) + assert.equal((await recovery.request('GET', '/v1/recovery/status')) + .authenticationRetry.consecutiveFailures, 1) + await assert.rejects( + recovery.request('POST', '/v1/recovery/clear-retry', { scope: 'browser' }), + error => error.statusCode === 400 && error.code === 'REQUEST_INVALID', + ) + const reset = await recovery.request('POST', '/v1/recovery/reset-access', { + revision: recoveryStatus.account.revision, + password: 'replacement administrator password', + managementPasswordAction: 'preserve', + }) + assert.equal(reset.account.mainCredentialVersion, 2) + assert.doesNotMatch(JSON.stringify(reset), /salt|hash|password|verifier/i) + await assert.rejects( + recovery.request('POST', '/v1/recovery/reset-password', { + revision: recoveryStatus.account.revision, + password: 'credential material must not enter audit logs', + }), + error => error.statusCode === 409 && error.code === 'REVISION_CONFLICT', + ) + assert.deepEqual(reports.find(report => report.message === 'access.recovery-operation.failed' + && report.fields.operation === 'reset-password'), { + message: 'access.recovery-operation.failed', + fields: { operation: 'reset-password', code: 'REVISION_CONFLICT' }, + }) + assert.doesNotMatch(JSON.stringify(reports), /credential material must not enter audit logs/) + await assert.rejects( + recovery.request('POST', '/v1/initialize', { username: 'other', password: 'another password' }), + error => error.statusCode === 404, + ) + await assert.rejects( + access.request('POST', '/v1/recovery/clear-retry'), + error => error.statusCode === 404, + ) + } finally { + await Promise.all([ + new Promise(resolve => accessServer.close(resolve)), + new Promise(resolve => recoveryServer.close(resolve)), + ]) + } +}) + +test('atomically resets administrator access with an explicit additional-password action', async () => { + const { service, store } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initializeDsh({ + username: 'admin', password: 'original administrator password', origin: 'https://dsh.example', + }) + const additional = await service.resetRecoveryManagementPassword({ + revision: initialized.account.revision, + password: 'original management password', + }) + + const preserved = await service.resetRecoveryAccess({ + revision: additional.account.revision, + username: 'operator', + password: 'replacement administrator password', + managementPasswordAction: 'preserve', + }) + let state = await store.state() + assert.equal(preserved.account.username, 'operator') + assert.equal(preserved.account.mainCredentialVersion, 2) + assert.equal(preserved.account.managementAdditionalCredential.version, 2) + assert.equal(await verifyCredential('replacement administrator password', state.account.mainCredential), true) + assert.equal(await verifyCredential('original management password', state.account.managementAdditionalCredential.verifier), true) + assert.equal((await service.validateSession({ + kind: 'dsh', token: initialized.session.token, origin: 'https://dsh.example', + })).authenticated, false) + + const disabled = await service.resetRecoveryAccess({ + revision: preserved.account.revision, + managementPasswordAction: 'disable', + }) + assert.deepEqual(disabled.account.managementAdditionalCredential, { enabled: false, version: 3 }) + + const reset = await service.resetRecoveryAccess({ + revision: disabled.account.revision, + managementPasswordAction: 'reset', + managementPassword: 'replacement management password', + }) + state = await store.state() + assert.deepEqual(reset.account.managementAdditionalCredential, { enabled: true, version: 4 }) + assert.equal(await verifyCredential('replacement management password', state.account.managementAdditionalCredential.verifier), true) + + const revision = reset.account.revision + await assert.rejects(service.resetRecoveryAccess({ + revision, + managementPasswordAction: 'reset', + managementPassword: 'replacement administrator password', + }), error => error.code === 'PASSWORDS_MUST_DIFFER') + assert.equal((await service.recoveryStatus()).account.revision, revision) + await assert.rejects(service.resetRecoveryAccess({ + revision, + password: 'another administrator password', + managementPasswordAction: 'unknown', + }), error => error.code === 'REQUEST_INVALID') + assert.equal((await service.recoveryStatus()).account.revision, revision) + await assert.rejects(service.resetRecoveryAccess({ + revision, + managementPasswordAction: 'preserve', + }), error => error.code === 'REQUEST_INVALID') + assert.equal((await service.recoveryStatus()).account.revision, revision) +}) + +test('recovery access reset can disable two-factor authentication without revoking sessions', async () => { + const { service, store } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initializeDsh({ + username: 'admin', password: 'original administrator password', origin: 'https://dsh.example', + }) + const state = await store.state() + state.account.totp = { enabled: true, version: 1, secret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP', changedAt: new Date().toISOString() } + await store.replaceAccount(state.account, state.account.revision) + const recoveryAccount = (await service.recoveryStatus()).account + assert.deepEqual(recoveryAccount.totp, { enabled: true, version: 1 }) + assert.doesNotMatch(JSON.stringify(recoveryAccount), /JBSWY3DPEHPK3PXP/) + const reset = await service.resetRecoveryAccess({ + revision: state.account.revision, + managementPasswordAction: 'preserve', + totpAction: 'disable', + }) + assert.equal(reset.account.totp.enabled, false) + assert.equal(reset.allSessionsRevoked, false) +}) + +test('Root recovery can clear all sessions or only Management sessions', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initializeDsh({ + username: 'admin', password: 'original administrator password', origin: 'https://dsh.example', + }) + const dsh = initialized.session.token + const handoff = await service.createManagementHandoff({ + dshToken: dsh, dshOrigin: 'https://dsh.example', targetOrigin: 'https://dsh.example', + }) + await service.consumeManagementHandoff({ token: handoff.handoff.token, origin: 'https://dsh.example' }) + const management = await service.clearRecoverySessions({ managementOnly: true }) + assert.equal(management.scope, 'management') + assert.equal((await service.validateSession({ kind: 'dsh', token: dsh, origin: 'https://dsh.example' })).authenticated, true) + const all = await service.clearRecoverySessions() + assert.equal(all.scope, 'all') + assert.equal((await service.validateSession({ kind: 'dsh', token: dsh, origin: 'https://dsh.example' })).authenticated, false) +}) + +test('audits successful Root account recovery changes without credential material', async () => { + const reports = [] + const { service } = await fixture({ + report: async (message, fields) => reports.push({ message, fields }), + }) + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + let current = await service.initializeDsh({ + username: 'admin', password: 'original administrator password', origin: 'https://dsh.example', + }) + current = await service.setRecoveryUsername({ revision: current.account.revision, username: 'operator' }) + current = await service.resetRecoveryManagementPassword({ + revision: current.account.revision, password: 'original management password', + }) + current = await service.disableRecoveryManagementPassword({ revision: current.account.revision }) + current = await service.resetRecoveryPassword({ + revision: current.account.revision, password: 'replacement administrator password', + }) + await service.resetRecoveryAccess({ + revision: current.account.revision, + managementPasswordAction: 'reset', + managementPassword: 'replacement management password', + }) + + const audits = reports.filter(report => report.message === 'access.recovery-account.changed') + assert.deepEqual(audits.map(report => report.fields.operation), [ + 'set-username', + 'reset-management-password', + 'disable-management-password', + 'reset-password', + 'reset-access', + ]) + assert.equal(audits.at(-3).fields.allSessionsRevoked, false) + assert.equal(audits.at(-2).fields.allSessionsRevoked, true) + assert.equal(audits.at(-1).fields.managementSessionsRevoked, 0) + const serialized = JSON.stringify(audits) + assert.doesNotMatch(serialized, /original administrator password/) + assert.doesNotMatch(serialized, /original management password/) + assert.doesNotMatch(serialized, /replacement administrator password/) + assert.doesNotMatch(serialized, /replacement management password/) +}) + +test('authenticates normalized usernames without exposing which credential failed', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + await service.initialize({ username: 'a\u0301dmin', password: 'correct horse battery staple' }) + assert.equal((await service.authenticate({ username: ' ádmin ', password: 'correct horse battery staple' })).authenticated, true) + await assert.rejects( + service.authenticate({ username: 'missing', password: 'correct horse battery staple' }), + error => error.code === 'AUTHENTICATION_FAILED' && error.statusCode === 401, + ) + await assert.rejects( + service.authenticate({ username: 'ádmin', password: 'incorrect password' }), + error => error.code === 'AUTHENTICATION_FAILED' && error.statusCode === 401, + ) +}) + +test('rejects a login context minted before the Access Manager restarted', async () => { + const current = await fixture({ authenticationEpoch: 'epoch-before-restart' }) + await current.service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + await current.service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + const staleContext = (await current.service.status()).authenticationContext + const restarted = new AccessService({ + store: current.store, + classificationToken: 'classification-token', + authenticationEpoch: 'epoch-after-restart', + }) + await assert.rejects(restarted.loginDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + authenticationContext: staleContext, + }), error => error.code === 'AUTHENTICATION_CONTEXT_STALE' && error.statusCode === 409) + const freshContext = (await restarted.status()).authenticationContext + const loggedIn = await restarted.loginDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + authenticationContext: freshContext, + }) + assert.equal(loggedIn.authenticated, true) +}) + +test('limits failed authentication before admitting more KDF work', () => { + let now = 1_000 + const limiter = new AuthenticationLimiter({ globalLimit: 2, maxConcurrent: 1, clock: () => now }) + const first = limiter.enter('account') + assert.throws( + () => limiter.enter('account'), + error => error.code === 'AUTHENTICATION_RATE_LIMITED' + && error.details.retryAfterSeconds === 1, + ) + first.release(false) + const second = limiter.enter('account') + second.release(false) + assert.throws(() => limiter.enter('account'), error => error.code === 'AUTHENTICATION_RATE_LIMITED') + now += 60_001 + limiter.enter('account').release(true) +}) + +test('applies fixed tiered instance windows without global exponential growth', () => { + const defaults = new AuthenticationLimiter() + assert.equal(defaults.initialBackoffMs, 30_000) + assert.equal(defaults.maxBackoffMs, 15 * 60_000) + assert.equal(defaults.consecutiveResetMs, 24 * 60 * 60_000) + assert.deepEqual(defaults.globalWindows, [ + { limit: 20, windowMs: 60_000 }, + { limit: 60, windowMs: 60 * 60_000 }, + { limit: 120, windowMs: 24 * 60 * 60_000 }, + ]) + assert.deepEqual(defaults.sourceWindows, [ + { limit: 12, windowMs: 60 * 60_000 }, + { limit: 24, windowMs: 24 * 60 * 60_000 }, + ]) + + for (let attempt = 0; attempt < 5; attempt += 1) { + defaults.enter('account', 'default-browser').release(false) + } + assert.deepEqual(defaults.sourceStatus('account', 'default-browser'), { + consecutiveFailures: 5, retryAfterSeconds: 30, + }) + assert.doesNotThrow(() => defaults.enter('account', 'other-browser').release(true)) + + let now = 0 + const limiter = new AuthenticationLimiter({ + globalLimit: 100, + globalWindows: [ + { limit: 2, windowMs: 1_000 }, + { limit: 3, windowMs: 10_000 }, + ], + clock: () => now, + }) + limiter.enter('account', 'browser-a').release(false) + limiter.enter('account', 'browser-b').release(false) + assert.throws( + () => limiter.enter('account', 'browser-c'), + error => error.code === 'AUTHENTICATION_RATE_LIMITED' + && error.details.retryAfterSeconds === 1, + ) + now += 1_001 + limiter.enter('account', 'browser-c').release(false) + assert.throws( + () => limiter.enter('account', 'browser-d'), + error => error.code === 'AUTHENTICATION_RATE_LIMITED' + && error.details.retryAfterSeconds === 9, + ) +}) + +test('applies tiered source windows and clears only that source after success', () => { + let now = 0 + const limiter = new AuthenticationLimiter({ + backoffThreshold: 100, + sourceWindows: [ + { limit: 2, windowMs: 1_000 }, + { limit: 3, windowMs: 10_000 }, + ], + globalWindows: [{ limit: 100, windowMs: 10_000 }], + clock: () => now, + }) + limiter.enter('account', 'browser-a').release(false) + limiter.enter('account', 'browser-a').release(false) + assert.throws( + () => limiter.enter('account', 'browser-a'), + error => error.code === 'AUTHENTICATION_RATE_LIMITED' + && error.details.retryAfterSeconds === 1, + ) + assert.doesNotThrow(() => limiter.enter('account', 'browser-b').release(true)) + now += 1_001 + limiter.enter('account', 'browser-a').release(false) + assert.throws( + () => limiter.enter('account', 'browser-a'), + error => error.code === 'AUTHENTICATION_RATE_LIMITED' + && error.details.retryAfterSeconds === 9, + ) + now += 9_000 + limiter.enter('account', 'browser-a').release(true) + assert.equal(limiter.status('account').globalFailures, 1) + assert.equal(limiter.status('account').sourceRetryAfterSeconds, 0) +}) + +test('backs off exponentially after five consecutive failures and can clear one account', () => { + let now = 1_000 + const limiter = new AuthenticationLimiter({ + globalLimit: 100, + backoffThreshold: 5, + initialBackoffMs: 1_000, + maxBackoffMs: 4_000, + sourceWindows: [{ limit: 100, windowMs: 60_000 }], + clock: () => now, + }) + for (let attempt = 1; attempt <= 4; attempt += 1) { + limiter.enter('account').release(false) + assert.deepEqual(limiter.sourceStatus('account'), { consecutiveFailures: attempt, retryAfterSeconds: 0 }) + } + limiter.enter('account').release(false) + assert.deepEqual(limiter.sourceStatus('account'), { consecutiveFailures: 5, retryAfterSeconds: 1 }) + assert.throws( + () => limiter.enter('account'), + error => error.code === 'AUTHENTICATION_RETRY_REQUIRED' + && error.statusCode === 429 + && error.details.retryAfterSeconds === 1, + ) + assert.doesNotThrow(() => limiter.enter('account', 'other-browser').release(true)) + assert.equal(limiter.sourceStatus('account').retryAfterSeconds, 1) + assert.doesNotThrow(() => limiter.enter('other').release(true)) + + now += 1_000 + limiter.enter('account').release(false) + assert.equal(limiter.sourceStatus('account').retryAfterSeconds, 2) + now += 2_000 + limiter.enter('account').release(false) + assert.equal(limiter.sourceStatus('account').retryAfterSeconds, 4) + now += 4_000 + limiter.enter('account').release(false) + assert.equal(limiter.sourceStatus('account').retryAfterSeconds, 4) + + assert.deepEqual(limiter.clear('account'), { + cleared: true, activeSources: 1, consecutiveFailures: 8, retryAfterSeconds: 4, + sourceRetryAfterSeconds: 0, globalFailures: 8, globalRetryAfterSeconds: 0, + }) + assert.deepEqual(limiter.status('account'), { + activeSources: 0, consecutiveFailures: 0, retryAfterSeconds: 0, + sourceRetryAfterSeconds: 0, globalFailures: 0, globalRetryAfterSeconds: 0, + }) + assert.doesNotThrow(() => limiter.enter('account').release(true)) +}) + +test('can clear instance flood windows without clearing browser retry state', () => { + const limiter = new AuthenticationLimiter({ globalLimit: 100 }) + for (let attempt = 0; attempt < 5; attempt += 1) { + limiter.enter('account', 'browser').release(false) + } + assert.deepEqual(limiter.clearGlobal(), { + cleared: true, + globalFailures: 5, + globalRetryAfterSeconds: 0, + }) + assert.throws( + () => limiter.enter('account', 'browser'), + error => error.code === 'AUTHENTICATION_RETRY_REQUIRED' + && error.details.retryAfterSeconds === 30, + ) + assert.equal(limiter.status('account').globalFailures, 0) +}) + +test('successful authentication clears consecutive failures', () => { + const limiter = new AuthenticationLimiter({ globalLimit: 100 }) + for (let attempt = 0; attempt < 4; attempt += 1) limiter.enter('account').release(false) + limiter.enter('account').release(true) + assert.deepEqual(limiter.status('account'), { + activeSources: 0, consecutiveFailures: 0, retryAfterSeconds: 0, + sourceRetryAfterSeconds: 0, globalFailures: 4, globalRetryAfterSeconds: 0, + }) +}) + +test('consecutive source failures expire after one quiet day', () => { + let now = 0 + const limiter = new AuthenticationLimiter({ + globalWindows: [{ limit: 100, windowMs: 48 * 60 * 60_000 }], + sourceWindows: [{ limit: 100, windowMs: 48 * 60 * 60_000 }], + clock: () => now, + }) + for (let attempt = 0; attempt < 4; attempt += 1) limiter.enter('account', 'browser').release(false) + now += 24 * 60 * 60_000 + 1 + limiter.enter('account', 'browser').release(false) + assert.deepEqual(limiter.sourceStatus('account', 'browser'), { + consecutiveFailures: 1, retryAfterSeconds: 0, + }) +}) + +test('prunes expired browser source histories during later authentication', () => { + let now = 0 + const limiter = new AuthenticationLimiter({ clock: () => now }) + limiter.enter('account', 'expired-browser').release(false) + assert.equal(limiter.sources.size, 1) + assert.equal(limiter.consecutiveFailures.size, 1) + + now += 24 * 60 * 60_000 + 1 + limiter.enter('account', 'current-browser').release(true) + assert.equal(limiter.sources.has(limiter.retryKey('account', 'expired-browser')), false) + assert.equal(limiter.consecutiveFailures.has(limiter.retryKey('account', 'expired-browser')), false) +}) + +test('fresh authentication does not consume the login retry budget', async () => { + let now = 1_000 + const limiter = new AuthenticationLimiter({ + globalLimit: 100, clock: () => now, + }) + const { service } = await fixture({ limiter }) + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initialize({ + username: 'admin', password: 'correct horse battery staple', + }) + for (let attempt = 0; attempt < 4; attempt += 1) { + await assert.rejects( + service.authenticate({ username: 'admin', password: 'incorrect password' }), + error => error.code === 'AUTHENTICATION_FAILED', + ) + } + const account = (await service.store.state()).account + await assert.rejects( + service.verifyFreshAuthentication(account, 'incorrect password'), + error => error.code === 'FRESH_AUTH_FAILED', + ) + assert.equal((await service.authenticate({ + username: 'admin', password: 'correct horse battery staple', + })).authenticated, true) + assert.equal((await service.recoveryStatus()).authenticationRetry.consecutiveFailures, 0) + await assert.rejects( + service.authenticate({ username: 'admin', password: 'incorrect password' }), + error => error.code === 'AUTHENTICATION_FAILED', + ) + assert.equal((await service.clearAuthenticationRetry()).cleared, true) + assert.equal((await service.authenticate({ + username: initialized.account.username, + password: 'correct horse battery staple', + })).authenticated, true) +}) + +test('management password failures use the shared backend retry state', async () => { + const limiter = new AuthenticationLimiter({ + globalLimit: 100, initialBackoffMs: 1_000, + }) + const { service } = await fixture({ limiter }) + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', + origin: 'https://dsh.example', + }) + await service.resetRecoveryManagementPassword({ + revision: initialized.account.revision, + password: 'separate management password', + }) + const account = (await service.store.state()).account + const pending = (await service.managementResult(account, { + origin: 'https://manage.example', + sourceDshOrigin: 'https://dsh.example', + sourceDshSessionId: initialized.session.sessionId, + })).pending + for (let attempt = 0; attempt < 4; attempt += 1) { + await assert.rejects(completeManagementLogin(service, { + pendingToken: pending.token, + origin: 'https://manage.example', + password: 'incorrect management password', + }), error => error.code === 'AUTHENTICATION_FAILED') + } + await assert.rejects(completeManagementLogin(service, { + pendingToken: pending.token, + origin: 'https://manage.example', + password: 'incorrect management password', + }), error => error.code === 'AUTHENTICATION_RETRY_REQUIRED' + && error.details.retryAfterSeconds === 1) + await assert.rejects(completeManagementLogin(service, { + pendingToken: pending.token, + origin: 'https://manage.example', + password: 'separate management password', + }), error => error.code === 'AUTHENTICATION_RETRY_REQUIRED') + assert.equal((await service.authenticate({ + username: 'admin', password: 'correct horse battery staple', authenticationSource: 'unknown', + })).authenticated, true) + assert.equal((await service.clearAuthenticationRetry({ credential: 'main' })).cleared, false) + await assert.rejects(completeManagementLogin(service, { + pendingToken: pending.token, + origin: 'https://manage.example', + password: 'separate management password', + }), error => error.code === 'AUTHENTICATION_RETRY_REQUIRED') + assert.equal((await service.clearAuthenticationRetry({ credential: 'management' })).cleared, true) + assert.match((await completeManagementLogin(service, { + pendingToken: pending.token, + origin: 'https://manage.example', + password: 'separate management password', + })).session.token, /^dshms_/) +}) + +test('stores only digests for origin-bound DSH sessions with absolute and idle expiry', async () => { + assert.deepEqual(new BrowserSessionStore().policy, { + dsh: { absoluteMs: 30 * 24 * 60 * 60_000, idleMs: 7 * 24 * 60 * 60_000 }, + management: { absoluteMs: 24 * 60 * 60_000, idleMs: 2 * 60 * 60_000 }, + }) + let now = 1_000 + const sessions = new BrowserSessionStore({ + now: () => now, + dshAbsoluteMs: 1_000, + dshIdleMs: 100, + }) + const account = { + accountId: 'account', + mainCredential: { version: 2 }, + managementAdditionalCredential: { enabled: false, version: 1 }, + managementAccess: { version: 3 }, + } + const issued = sessions.issue('dsh', account, { origin: 'https://dsh.example' }) + assert.match(issued.token, /^dshs_/) + assert.match(issued.csrfToken, /^dshc_/) + assert.doesNotMatch(JSON.stringify([...sessions.sessions.values()]), new RegExp(issued.token)) + assert.equal(sessions.validate(issued.token, 'dsh', account, { + origin: 'https://dsh.example', csrfToken: issued.csrfToken, requireCsrf: true, + })?.kind, 'dsh') + assert.equal(sessions.validate(issued.token, 'dsh', account, { origin: 'https://other.example' }), undefined) + assert.equal(sessions.validate(issued.token, 'management', account, { origin: 'https://dsh.example' }), undefined) + assert.equal(sessions.validate(issued.token, 'dsh', account, { + origin: 'https://dsh.example', csrfToken: 'wrong', requireCsrf: true, + }), undefined) + account.mainCredential.version += 1 + assert.equal(sessions.validate(issued.token, 'dsh', account, { origin: 'https://dsh.example' }), undefined) + account.mainCredential.version -= 1 + account.managementAccess.version += 1 + assert.equal(sessions.validate(issued.token, 'dsh', account, { origin: 'https://dsh.example' }), undefined) + account.managementAccess.version -= 1 + now += 101 + assert.equal(sessions.validate(issued.token, 'dsh', account, { origin: 'https://dsh.example' }), undefined) + + now = 2_000 + const absoluteSessions = new BrowserSessionStore({ + now: () => now, dshAbsoluteMs: 100, dshIdleMs: 1_000, + }) + const absolute = absoluteSessions.issue('dsh', account, { origin: 'https://dsh.example' }) + now += 101 + assert.equal(absoluteSessions.validate(absolute.token, 'dsh', account, { origin: 'https://dsh.example' }), undefined) +}) + +test('reports bounded session activity, expiry, version invalidation, and source revocation without tokens', () => { + let now = 1_000 + const events = [] + const sessions = new BrowserSessionStore({ + now: () => now, + dshAbsoluteMs: 1_000, + dshIdleMs: 500, + managementAbsoluteMs: 1_000, + managementIdleMs: 1_000, + activityReportMs: 100, + onEvent: (message, fields) => events.push({ message, fields }), + }) + const account = { + accountId: 'account', + mainCredential: { version: 2 }, + managementAdditionalCredential: { enabled: false, version: 1 }, + managementAccess: { version: 3 }, + } + const dsh = sessions.issue('dsh', account, { origin: 'https://dsh.example' }) + const management = sessions.issue('management', account, { + origin: 'https://manage.example', + sourceDshOrigin: 'https://dsh.example', + sourceDshSessionId: dsh.sessionId, + }) + + now += 49 + sessions.validate(dsh.token, 'dsh', account, { origin: 'https://dsh.example' }) + now += 51 + sessions.validate(dsh.token, 'dsh', account, { origin: 'https://dsh.example' }) + sessions.validate(dsh.token, 'dsh', account, { origin: 'https://dsh.example' }) + assert.equal(events.filter(event => event.message === 'access.session.activity').length, 1) + + sessions.revoke(dsh.token) + assert.deepEqual(events.find(event => event.message === 'access.session.invalidated'), { + message: 'access.session.invalidated', + fields: { + accountId: 'account', sessionId: management.sessionId, kind: 'management', + reason: 'source-dsh-session-revoked', + }, + }) + + const versioned = sessions.issue('dsh', account, { origin: 'https://dsh.example' }) + account.mainCredential.version += 1 + assert.equal(sessions.validate(versioned.token, 'dsh', account, { origin: 'https://dsh.example' }), undefined) + assert.equal(events.at(-1).fields.reason, 'main-credential-version') + account.mainCredential.version -= 1 + + const expiring = sessions.issue('dsh', account, { origin: 'https://dsh.example' }) + now += 501 + assert.equal(sessions.validate(expiring.token, 'dsh', account, { origin: 'https://dsh.example' }), undefined) + assert.equal(events.at(-1).message, 'access.session.expired') + assert.equal(events.at(-1).fields.reason, 'idle') + const serialized = JSON.stringify(events) + assert.doesNotMatch(serialized, new RegExp(dsh.token)) + assert.doesNotMatch(serialized, new RegExp(dsh.csrfToken)) + assert.doesNotMatch(serialized, new RegExp(management.token)) + assert.doesNotMatch(serialized, new RegExp(management.csrfToken)) + + now = 2_000 + const linkedEvents = [] + const linked = new BrowserSessionStore({ + now: () => now, + dshAbsoluteMs: 1_000, + dshIdleMs: 50, + managementAbsoluteMs: 1_000, + managementIdleMs: 1_000, + onEvent: (message, fields) => linkedEvents.push({ message, fields }), + }) + const source = linked.issue('dsh', account, { origin: 'https://dsh.example' }) + const linkedManagement = linked.issue('management', account, { + origin: 'https://manage.example', + sourceDshOrigin: 'https://dsh.example', + sourceDshSessionId: source.sessionId, + }) + now += 51 + assert.equal(linked.validate(linkedManagement.token, 'management', account, { + origin: 'https://manage.example', + }), undefined) + assert.deepEqual(linkedEvents.map(event => [event.message, event.fields.kind, event.fields.reason]), [ + ['access.session.expired', 'dsh', 'idle'], + ['access.session.invalidated', 'management', 'source-dsh-session'], + ]) +}) + +test('Management sessions inherit source DSH expiry and bind every account version', async () => { + let now = 1_000 + const sessions = new BrowserSessionStore({ + now: () => now, + dshAbsoluteMs: 100, + dshIdleMs: 1_000, + managementAbsoluteMs: 500, + managementIdleMs: 500, + }) + const account = { + accountId: 'account', + mainCredential: { version: 2 }, + managementAdditionalCredential: { enabled: true, version: 4 }, + managementAccess: { version: 3 }, + } + const dsh = sessions.issue('dsh', account, { origin: 'https://dsh.example' }) + now += 10 + const management = sessions.issue('management', account, { + origin: 'https://manage.example', + sourceDshOrigin: 'https://dsh.example', + sourceDshSessionId: dsh.sessionId, + }) + assert.equal(management.expiresAt, dsh.expiresAt) + assert.doesNotMatch(JSON.stringify([...sessions.sessions.values()]), new RegExp(management.token)) + assert.equal(sessions.validate(management.token, 'management', account, { + origin: 'https://manage.example', + })?.sourceDshSessionId, dsh.sessionId) + assert.equal(sessions.validate(management.token, 'management', account, { + origin: 'https://other.example', + }), undefined) + + account.managementAdditionalCredential.version += 1 + assert.equal(sessions.validate(management.token, 'management', account, { + origin: 'https://manage.example', + }), undefined) + account.managementAdditionalCredential.version -= 1 + account.mainCredential.version += 1 + assert.equal(sessions.validate(management.token, 'management', account, { + origin: 'https://manage.example', + }), undefined) + account.mainCredential.version -= 1 + account.managementAccess.version += 1 + assert.equal(sessions.validate(management.token, 'management', account, { + origin: 'https://manage.example', + }), undefined) + account.managementAccess.version -= 1 + now = 1_101 + assert.equal(sessions.validate(management.token, 'management', account, { + origin: 'https://manage.example', + }), undefined) + + for (const [expiry, options] of [ + ['idle', { managementAbsoluteMs: 1_000, managementIdleMs: 50 }], + ['absolute', { managementAbsoluteMs: 50, managementIdleMs: 1_000 }], + ]) { + now = 2_000 + const expiring = new BrowserSessionStore({ + now: () => now, dshAbsoluteMs: 1_000, dshIdleMs: 1_000, ...options, + }) + const source = expiring.issue('dsh', account, { origin: 'https://dsh.example' }) + const target = expiring.issue('management', account, { + origin: 'https://manage.example', + sourceDshOrigin: 'https://dsh.example', + sourceDshSessionId: source.sessionId, + }) + now += 51 + assert.equal(expiring.validate(target.token, 'management', account, { + origin: 'https://manage.example', + }), undefined, `Management ${expiry} expiry must invalidate the session`) + assert.equal(expiring.validate(source.token, 'dsh', account, { + origin: 'https://dsh.example', touch: false, + })?.kind, 'dsh') + } +}) + +test('probes the current instance before atomically changing Management origin', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initialize({ username: 'admin', password: 'correct horse battery staple' }) + await service.resetRecoveryManagementPassword({ + revision: initialized.account.revision, + password: 'separate management password', + }) + const login = await loginManagement(service, { managementPassword: 'separate management password' }) + const issued = await service.issueCapability({ + managementToken: login.management.session.token, + origin: 'http://dsh.example:3080', + csrfToken: login.management.session.csrfToken, + requireCsrf: true, + audience: 'management', + method: 'POST', + target: '/_dsh_platform/api/v1/management-origin/transitions', + }) + const created = await service.createManagementTransition({ + internalCapability: issued.capability.token, + method: 'POST', + target: '/_dsh_platform/api/v1/management-origin/transitions', + mode: 'isolated', + isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://manage.example' }, + }) + const proof = await service.probeManagementTransition({ + transitionId: created.transition.transitionId, + nonce: created.transition.nonce, + sourceOrigin: 'http://dsh.example:3080', + candidateOrigin: 'https://manage.example', + }) + const commitCapability = await service.issueCapability({ + managementToken: login.management.session.token, + origin: 'http://dsh.example:3080', + csrfToken: login.management.session.csrfToken, + requireCsrf: true, + audience: 'management', + method: 'POST', + target: '/_dsh_platform/api/v1/management-origin/transitions/commit', + }) + const changed = await service.commitManagementTransition({ + internalCapability: commitCapability.capability.token, + method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions/commit', + transitionId: created.transition.transitionId, proof: proof.proof, + }) + assert.equal(changed.account.managementAccess.mode, 'isolated') + assert.equal(changed.account.managementAccess.version, 2) + assert.equal(changed.targetOrigin, 'https://manage.example') + assert.equal(changed.loginOrigin, 'https://manage.example') + assert.equal((await service.validateSession({ + token: login.management.session.token, kind: 'management', origin: 'http://dsh.example:3080', + })).authenticated, false) + const continued = await service.consumeManagementContinuation({ + token: changed.continuation.token, origin: 'https://manage.example', + }) + assert.match(continued.session.token, /^dshms_/) + await assert.rejects(service.consumeManagementContinuation({ + token: changed.continuation.token, origin: 'https://manage.example', + }), error => error.code === 'CONTINUATION_INVALID') + + const nextCapability = async target => (await service.issueCapability({ + managementToken: continued.session.token, + origin: 'https://manage.example', + csrfToken: continued.session.csrfToken, + requireCsrf: true, + audience: 'management', + method: 'POST', + target, + })).capability.token + const moved = await service.createManagementTransition({ + internalCapability: await nextCapability('/_dsh_platform/api/v1/management-origin/transitions'), + method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions', + mode: 'isolated', + isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://manage-two.example' }, + }) + const movedProof = await service.probeManagementTransition({ + transitionId: moved.transition.transitionId, + nonce: moved.transition.nonce, + sourceOrigin: 'https://manage.example', + candidateOrigin: 'https://manage-two.example', + }) + const relocated = await service.commitManagementTransition({ + internalCapability: await nextCapability('/_dsh_platform/api/v1/management-origin/transitions/commit'), + method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions/commit', + transitionId: moved.transition.transitionId, + proof: movedProof.proof, + }) + assert.equal(relocated.account.managementAccess.version, 3) + assert.equal(relocated.targetOrigin, 'https://manage-two.example') +}) + +test('verifies a loopback candidate without persisting it for local-only Management', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + await service.initialize({ username: 'admin', password: 'correct horse battery staple' }) + const login = await loginManagement(service) + const capability = async target => (await service.issueCapability({ + managementToken: login.management.session.token, origin: 'http://dsh.example:3080', + csrfToken: login.management.session.csrfToken, requireCsrf: true, + audience: 'management', method: 'POST', target, + })).capability.token + const created = await service.createManagementTransition({ + internalCapability: await capability('/_dsh_platform/api/v1/management-origin/transitions'), + method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions', + mode: 'isolated', isolatedEntry: { kind: 'local-only' }, candidateOrigin: 'http://127.20.30.40:45678', + }) + const proof = await service.probeManagementTransition({ + transitionId: created.transition.transitionId, nonce: created.transition.nonce, + sourceOrigin: 'http://dsh.example:3080', candidateOrigin: 'http://127.20.30.40:45678', + }) + const changed = await service.commitManagementTransition({ + internalCapability: await capability('/_dsh_platform/api/v1/management-origin/transitions/commit'), + method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions/commit', + transitionId: created.transition.transitionId, proof: proof.proof, + }) + assert.deepEqual(changed.account.managementAccess.isolatedEntry, { + kind: 'local-only', managementLocalOrigin: 'http://127.20.30.40:45678', + }) + assert.equal(changed.targetOrigin, 'http://127.20.30.40:45678') + assert.equal(typeof changed.continuation?.token, 'string') + assert.equal(changed.loginOrigin, 'http://127.20.30.40:45678') + const continued = await service.consumeManagementContinuation({ + token: changed.continuation.token, origin: 'http://127.20.30.40:45678', + }) + assert.match(continued.session.token, /^dshms_/) +}) + +test('commits a Management transition without fresh authentication and rejects replay', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + await service.initialize({ username: 'admin', password: 'correct horse battery staple' }) + const login = await loginManagement(service) + const capability = async target => (await service.issueCapability({ + managementToken: login.management.session.token, origin: 'http://dsh.example:3080', + csrfToken: login.management.session.csrfToken, requireCsrf: true, + audience: 'management', method: 'POST', target, + })).capability.token + const created = await service.createManagementTransition({ + internalCapability: await capability('/_dsh_platform/api/v1/management-origin/transitions'), + method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions', + mode: 'isolated', isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://manage.example' }, + }) + const proof = await service.probeManagementTransition({ + transitionId: created.transition.transitionId, nonce: created.transition.nonce, + sourceOrigin: 'http://dsh.example:3080', candidateOrigin: 'https://manage.example', + }) + const firstCapability = await capability('/_dsh_platform/api/v1/management-origin/transitions/commit') + const attempt = internalCapability => service.commitManagementTransition({ + internalCapability, + method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions/commit', + transitionId: created.transition.transitionId, proof: proof.proof, + }) + const changed = await attempt(firstCapability) + assert.equal(changed.account.managementAccess.mode, 'isolated') + const continued = await service.consumeManagementContinuation({ + token: changed.continuation.token, origin: 'https://manage.example', + }) + const replayCapability = (await service.issueCapability({ + managementToken: continued.session.token, origin: 'https://manage.example', + csrfToken: continued.session.csrfToken, requireCsrf: true, + audience: 'management', method: 'POST', + target: '/_dsh_platform/api/v1/management-origin/transitions/commit', + })).capability.token + await assert.rejects(attempt(replayCapability), error => error.code === 'TRANSITION_INVALID') +}) + +test('all six Management entry directions switch without a password', async t => { + const directions = [ + ['compat', 'public'], ['compat', 'local'], + ['public', 'compat'], ['public', 'local'], + ['local', 'compat'], ['local', 'public'], + ] + for (const [sourceKind, targetKind] of directions) await t.test(`${sourceKind} -> ${targetKind}`, async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const dshOrigin = 'http://dsh.example:3080' + const dsh = await service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: dshOrigin, + }) + const initialHandoff = await service.createManagementHandoff({ + dshToken: dsh.session.token, dshOrigin, targetOrigin: dshOrigin, + }) + let managementOrigin = dshOrigin + let management = await service.consumeManagementHandoff({ + token: initialHandoff.handoff.token, origin: managementOrigin, + }) + const commits = [] + + const capability = async target => (await service.issueCapability({ + managementToken: management.session.token, origin: managementOrigin, + csrfToken: management.session.csrfToken, requireCsrf: true, + audience: 'management', method: 'POST', target, + })).capability.token + const switchTo = async kind => { + const candidateOrigin = kind === 'public' + ? 'https://manage.example' + : kind === 'local' ? 'http://127.0.0.1:33081' : null + const request = kind === 'compat' + ? { mode: 'compat', isolatedEntry: null, candidateOrigin: null } + : kind === 'public' + ? { mode: 'isolated', isolatedEntry: { kind: 'public', managementPublicOrigin: candidateOrigin }, candidateOrigin } + : { mode: 'isolated', isolatedEntry: { kind: 'local-only' }, candidateOrigin } + const created = await service.createManagementTransition({ + internalCapability: await capability('/_dsh_platform/api/v1/management-origin/transitions'), + method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions', ...request, + }) + const proof = candidateOrigin === null ? null : (await service.probeManagementTransition({ + transitionId: created.transition.transitionId, nonce: created.transition.nonce, + sourceOrigin: managementOrigin, candidateOrigin, + })).proof + const commit = { + internalCapability: await capability('/_dsh_platform/api/v1/management-origin/transitions/commit'), + method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions/commit', + transitionId: created.transition.transitionId, proof, + } + assert.equal(Object.hasOwn(commit, 'currentPassword'), false) + const changed = await service.commitManagementTransition(commit) + commits.push(changed) + managementOrigin = candidateOrigin ?? dshOrigin + if (changed.continuation !== null) { + management = await service.consumeManagementContinuation({ + token: changed.continuation.token, origin: managementOrigin, + }) + } else { + const handoff = await service.createManagementHandoff({ + dshToken: dsh.session.token, dshOrigin, targetOrigin: managementOrigin, + }) + management = await service.consumeManagementHandoff({ token: handoff.handoff.token, origin: managementOrigin }) + } + return changed + } + + if (sourceKind !== 'compat') await switchTo(sourceKind) + const changed = await switchTo(targetKind) + assert.equal(changed.account.managementAccess.mode, targetKind === 'compat' ? 'compat' : 'isolated') + assert.equal(commits.length, sourceKind === 'compat' ? 1 : 2) + }) +}) + +test('requires Management origin changes to use the verified transition protocol', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + await service.initialize({ username: 'admin', password: 'correct horse battery staple' }) + const login = await loginManagement(service) + const issued = await service.issueCapability({ + managementToken: login.management.session.token, origin: 'http://dsh.example:3080', + csrfToken: login.management.session.csrfToken, requireCsrf: true, + audience: 'management', method: 'PUT', target: '/_dsh_platform/api/v1/auth-settings', + }) + await assert.rejects(service.updateAuthenticationSettings({ + internalCapability: issued.capability.token, + method: 'PUT', target: '/_dsh_platform/api/v1/auth-settings', + currentPassword: 'correct horse battery staple', + username: 'operator', password: 'a different secure password', + mode: 'isolated', isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://manage.example' }, + }), error => error.code === 'TRANSITION_REQUIRED') +}) + +test('requires fresh authentication and applies credential-specific session revocation', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + const handoff = await service.createManagementHandoff({ + dshToken: initialized.session.token, + dshOrigin: 'https://dsh.example', + targetOrigin: 'https://dsh.example', + }) + const management = await service.consumeManagementHandoff({ + token: handoff.handoff.token, origin: 'https://dsh.example', + }) + const capability = async target => (await service.issueCapability({ + managementToken: management.session.token, + origin: 'https://dsh.example', + csrfToken: management.session.csrfToken, + requireCsrf: true, + audience: 'management', method: 'PUT', target, + })).capability.token + + const unchangedRevision = (await service.status()).account.revision + const unchanged = await service.updateAuthenticationSettings({ + internalCapability: await capability('/_dsh_platform/api/v1/auth-settings'), + method: 'PUT', target: '/_dsh_platform/api/v1/auth-settings', username: 'admin', + }) + assert.equal(unchanged.changed, false) + assert.equal(unchanged.account.revision, unchangedRevision) + assert.equal(unchanged.currentManagementSessionRevoked, false) + + const renamed = await service.updateAuthenticationSettings({ + internalCapability: await capability('/_dsh_platform/api/v1/auth-settings'), + method: 'PUT', target: '/_dsh_platform/api/v1/auth-settings', username: 'operator', + }) + assert.equal(renamed.currentManagementSessionRevoked, false) + assert.equal((await service.validateSession({ + kind: 'management', token: management.session.token, origin: 'https://dsh.example', + })).authenticated, true) + assert.equal((await service.validateSession({ + kind: 'dsh', token: initialized.session.token, origin: 'https://dsh.example', + })).authenticated, true) + + const changed = await service.updateAuthenticationSettings({ + internalCapability: await capability('/_dsh_platform/api/v1/auth-settings'), + method: 'PUT', target: '/_dsh_platform/api/v1/auth-settings', + password: 'a different secure password', currentPassword: 'correct horse battery staple', + }) + assert.equal(changed.currentManagementSessionRevoked, true) + assert.equal(changed.changed, true) + assert.equal(changed.allSessionsRevoked, true) + assert.equal(changed.managementSessionsRevoked, 2) + assert.equal((await service.validateSession({ + kind: 'management', token: management.session.token, origin: 'https://dsh.example', + })).authenticated, false) + assert.equal((await service.validateSession({ + kind: 'dsh', token: initialized.session.token, origin: 'https://dsh.example', + })).authenticated, false) +}) + +test('enables account TOTP through one-time enrollment and completes the same login challenge after recovery clear', async () => { + let now = Date.parse('2026-08-28T00:00:00.000Z') + const { service, store } = await fixture({ + now: () => now, + sessions: new BrowserSessionStore({ now: () => now }), + verifyTotp: code => code === '123456', + }) + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + authenticationSource: 'browser:a', + }) + const handoff = await service.createManagementHandoff({ + dshToken: initialized.session.token, + dshOrigin: 'https://dsh.example', + targetOrigin: 'https://dsh.example', + }) + const management = await service.consumeManagementHandoff({ + token: handoff.handoff.token, origin: 'https://dsh.example', + }) + const capability = async (method, target) => (await service.issueCapability({ + managementToken: management.session.token, + origin: 'https://dsh.example', + csrfToken: management.session.csrfToken, + requireCsrf: true, + audience: 'management', method, target, + })).capability.token + const enrollmentTarget = '/_dsh_platform/api/v1/auth-totp/enrollments' + const confirmationTarget = '/_dsh_platform/api/v1/auth-totp/enrollments/confirm' + const settingsTarget = '/_dsh_platform/api/v1/auth-settings' + const enrollment = await service.beginTotpEnrollment({ + internalCapability: await capability('POST', enrollmentTarget), + method: 'POST', target: enrollmentTarget, + currentPassword: 'correct horse battery staple', + }) + assert.match(enrollment.enrollmentToken, /^dshte_/) + assert.match(enrollment.uri, /^otpauth:\/\/totp\//) + await assert.rejects(service.updateAuthenticationSettings({ + internalCapability: await capability('PUT', settingsTarget), + method: 'PUT', target: settingsTarget, + currentPassword: 'correct horse battery staple', + totpEnabled: true, + totpEnrollmentToken: enrollment.enrollmentToken, + }), error => error.code === 'TOTP_ENROLLMENT_UNCONFIRMED') + const confirmed = await service.confirmTotpEnrollment({ + internalCapability: await capability('POST', confirmationTarget), + method: 'POST', target: confirmationTarget, + enrollmentToken: enrollment.enrollmentToken, + totpCode: '123456', + }) + assert.equal(confirmed.confirmed, true) + assert.equal((await store.state()).account.totp.enabled, false) + assert.equal((await service.validateSession({ + kind: 'management', token: management.session.token, origin: 'https://dsh.example', + })).authenticated, true) + const enabled = await service.updateAuthenticationSettings({ + internalCapability: await capability('PUT', settingsTarget), + method: 'PUT', target: settingsTarget, + currentPassword: 'correct horse battery staple', + totpEnabled: true, + totpEnrollmentToken: enrollment.enrollmentToken, + }) + assert.equal(enabled.account.totp.enabled, true) + assert.equal(enabled.allSessionsRevoked, false) + assert.equal(enabled.currentManagementSessionRevoked, false) + assert.equal(JSON.stringify(enabled).includes(enrollment.secret), false) + assert.equal((await store.state()).account.totp.secret, enrollment.secret) + assert.equal((await service.validateSession({ + kind: 'management', token: management.session.token, origin: 'https://dsh.example', + })).authenticated, true) + assert.equal((await service.validateSession({ + kind: 'dsh', token: initialized.session.token, origin: 'https://dsh.example', + })).authenticated, true) + + const pending = await loginDsh(service, { + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + authenticationSource: 'browser:a', + }) + assert.equal(pending.totpRequired, true) + assert.equal(pending.session, undefined) + for (let attempt = 0; attempt < 2; attempt += 1) { + await assert.rejects(service.completeDshTotp({ + challengeToken: pending.challenge.token, + code: '000000', + origin: 'https://dsh.example', + authenticationSource: 'browser:a', + authenticationContext: (await service.status()).authenticationContext, + }), error => error.code === 'TOTP_INVALID') + } + await assert.rejects(service.completeDshTotp({ + challengeToken: pending.challenge.token, + code: '000000', + origin: 'https://dsh.example', + authenticationSource: 'browser:a', + authenticationContext: (await service.status()).authenticationContext, + }), error => error.code === 'TOTP_RETRY_REQUIRED' && error.details.retryAfterSeconds === 10) + assert.equal((await service.clearAuthenticationRetry({ credential: 'totp' })).credential, 'totp') + assert.deepEqual(await service.authenticationRetryStatus({ + credential: 'totp', source: 'browser:a', + }), { kind: 'retry', retryAfterSeconds: 10 }) + await assert.rejects(service.authenticationRetryStatus({ + credential: 'totp', source: '', + }), error => error.code === 'REQUEST_INVALID') + await assert.rejects(service.completeDshTotp({ + challengeToken: pending.challenge.token, + code: '123456', + origin: 'https://dsh.example', + authenticationSource: 'browser:a', + authenticationContext: (await service.status()).authenticationContext, + }), error => error.code === 'TOTP_RETRY_REQUIRED') + now += 10_000 + const loggedIn = await service.completeDshTotp({ + challengeToken: pending.challenge.token, + code: '123456', + origin: 'https://dsh.example', + authenticationSource: 'browser:a', + authenticationContext: (await service.status()).authenticationContext, + }) + assert.equal(loggedIn.authenticated, true) + assert.match(loggedIn.session.token, /^dshs_/) + + const disableTarget = '/_dsh_platform/api/v1/auth-totp/disable-confirmations' + const disableConfirmTarget = `${disableTarget}/confirm` + const disabling = await service.beginTotpDisableConfirmation({ + internalCapability: await capability('POST', disableTarget), + method: 'POST', target: disableTarget, + }) + assert.match(disabling.confirmationToken, /^dshtd_/) + await assert.rejects(service.updateAuthenticationSettings({ + internalCapability: await capability('PUT', settingsTarget), + method: 'PUT', target: settingsTarget, + currentPassword: 'correct horse battery staple', + totpEnabled: false, + totpDisableConfirmationToken: disabling.confirmationToken, + }), error => error.code === 'TOTP_DISABLE_CONFIRMATION_UNCONFIRMED') + await service.confirmTotpDisable({ + internalCapability: await capability('POST', disableConfirmTarget), + method: 'POST', target: disableConfirmTarget, + confirmationToken: disabling.confirmationToken, + totpCode: '123456', + }) + assert.equal((await store.state()).account.totp.enabled, true) + const disabled = await service.updateAuthenticationSettings({ + internalCapability: await capability('PUT', settingsTarget), + method: 'PUT', target: settingsTarget, + currentPassword: 'correct horse battery staple', + totpEnabled: false, + totpDisableConfirmationToken: disabling.confirmationToken, + }) + assert.equal(disabled.account.totp.enabled, false) + assert.equal(disabled.currentManagementSessionRevoked, false) + assert.equal((await service.validateSession({ + kind: 'management', token: management.session.token, origin: 'https://dsh.example', + })).authenticated, true) +}) + +test('changes the Management console password using only the current main password', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + await service.initialize({ username: 'admin', password: 'correct horse battery staple' }) + const target = '/_dsh_platform/api/v1/auth-settings' + const update = async (managementPassword, body) => { + const login = await loginManagement(service, { managementPassword }) + const capability = await service.issueCapability({ + managementToken: login.management.session.token, + origin: 'http://dsh.example:3080', + csrfToken: login.management.session.csrfToken, + requireCsrf: true, + audience: 'management', method: 'PUT', target, + }) + return service.updateAuthenticationSettings({ + internalCapability: capability.capability.token, + method: 'PUT', target, + currentPassword: 'correct horse battery staple', + ...body, + }) + } + + const enabled = await update(undefined, { + additionalEnabled: true, + additionalPassword: 'first management console password', + }) + assert.equal(enabled.account.managementAdditionalCredential.enabled, true) + assert.equal(enabled.currentManagementSessionRevoked, true) + assert.equal(enabled.allSessionsRevoked, false) + assert.equal(enabled.managementSessionsRevoked, 1) + + const reset = await update('first management console password', { + additionalEnabled: true, + additionalPassword: 'replacement management password', + }) + assert.equal(reset.account.managementAdditionalCredential.enabled, true) + assert.equal(reset.currentManagementSessionRevoked, true) + + const disabled = await update('replacement management password', { additionalEnabled: false }) + assert.equal(disabled.account.managementAdditionalCredential.enabled, false) + assert.equal(disabled.currentManagementSessionRevoked, true) +}) + +test('Root recovery password changes revoke the matching browser sessions', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + const enabled = await service.resetRecoveryManagementPassword({ + revision: initialized.account.revision, + password: 'original management password', + }) + const loggedIn = await loginManagement(service, { managementPassword: 'original management password' }) + const handoff = await service.createManagementHandoff({ + dshToken: loggedIn.dsh.session.token, + dshOrigin: 'http://dsh.example:3080', + targetOrigin: 'http://dsh.example:3080', + }) + const pending = await service.consumeManagementHandoff({ + token: handoff.handoff.token, origin: 'http://dsh.example:3080', + }) + const staleContext = (await service.status()).authenticationContext + const changed = await service.resetRecoveryManagementPassword({ + revision: enabled.account.revision, + password: 'replacement management password', + }) + assert.equal(changed.allSessionsRevoked, false) + assert.equal(changed.managementSessionsRevoked, 1) + assert.equal((await service.validateSession({ + kind: 'management', token: loggedIn.management.session.token, origin: 'http://dsh.example:3080', + })).authenticated, false) + assert.equal((await service.validateSession({ + kind: 'dsh', token: loggedIn.dsh.session.token, origin: 'http://dsh.example:3080', + })).authenticated, true) + await assert.rejects(service.completeManagementLogin({ + pendingToken: pending.pending.token, + origin: 'http://dsh.example:3080', + password: 'replacement management password', + authenticationContext: staleContext, + }), error => error.code === 'AUTHENTICATION_CONTEXT_STALE') + assert.match((await completeManagementLogin(service, { + pendingToken: pending.pending.token, + origin: 'http://dsh.example:3080', + password: 'replacement management password', + })).session.token, /^dshms_/) +}) + +test('lists login devices and revokes each DSH session with its linked Management session', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const firstDsh = await service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + client: { ip: '192.0.2.10', userAgent: 'Browser One' }, + }) + const secondDsh = await loginDsh(service, { + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + client: { ip: '192.0.2.11', userAgent: 'Browser Two' }, + }) + const handoff = await service.createManagementHandoff({ + dshToken: firstDsh.session.token, dshOrigin: 'https://dsh.example', targetOrigin: 'https://dsh.example', + }) + const management = await service.consumeManagementHandoff({ token: handoff.handoff.token, origin: 'https://dsh.example' }) + const issue = async (method, target) => (await service.issueCapability({ + managementToken: management.session.token, origin: 'https://dsh.example', + csrfToken: management.session.csrfToken, requireCsrf: true, + audience: 'management', method, target, + })).capability.token + const settings = await service.authenticationSettings({ + internalCapability: await issue('GET', '/_dsh_platform/api/v1/auth-settings'), + method: 'GET', target: '/_dsh_platform/api/v1/auth-settings', + }) + assert.equal(typeof settings.dshRootCapabilityEffective, 'boolean') + assert.equal(settings.agentIsolationEffective, false) + assert.equal(settings.details.agentIsolationReason, 'shared-node-process-identity') + assert.equal(settings.sessions.length, 2) + assert.deepEqual(settings.sessions.find(value => value.current), { + sessionId: firstDsh.session.sessionId, + origin: 'https://dsh.example', + current: true, + managementActive: true, + ip: '192.0.2.10', + userAgent: 'Browser One', + createdAt: '2026-08-28T00:00:00.000Z', + lastSeenAt: '2026-08-28T00:00:00.000Z', + expiresAt: '2026-09-27T00:00:00.000Z', + }) + assert.equal(settings.sessions.find(value => value.sessionId === secondDsh.session.sessionId)?.managementActive, false) + + const result = await service.revokeBrowserSessions({ + internalCapability: await issue('POST', '/_dsh_platform/api/v1/auth-sessions/revoke'), + method: 'POST', target: '/_dsh_platform/api/v1/auth-sessions/revoke', + sessionId: secondDsh.session.sessionId, + }) + assert.equal(result.revoked, 1) + assert.equal(result.currentSessionRevoked, false) + assert.equal((await service.validateSession({ + kind: 'dsh', token: firstDsh.session.token, origin: 'https://dsh.example', + })).authenticated, true) + assert.equal((await service.validateSession({ + kind: 'dsh', token: secondDsh.session.token, origin: 'https://dsh.example', + })).authenticated, false) + + const current = await service.revokeBrowserSessions({ + internalCapability: await issue('POST', '/_dsh_platform/api/v1/auth-sessions/revoke'), + method: 'POST', target: '/_dsh_platform/api/v1/auth-sessions/revoke', + sessionId: firstDsh.session.sessionId, + }) + assert.equal(current.currentSessionRevoked, true) + assert.equal((await service.validateSession({ + kind: 'management', token: management.session.token, origin: 'https://dsh.example', + })).authenticated, false) +}) + +test('rejects equal administrator passwords and non-origin Management entries', async () => { + const { service } = await fixture() + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + await service.initialize({ username: 'admin', password: 'correct horse battery staple' }) + const capability = async (method = 'PUT', target = '/_dsh_platform/api/v1/auth-settings') => { + const login = await loginManagement(service) + return (await service.issueCapability({ + managementToken: login.management.session.token, origin: 'http://dsh.example:3080', + csrfToken: login.management.session.csrfToken, requireCsrf: true, + audience: 'management', method, target, + })).capability.token + } + await assert.rejects(service.updateAuthenticationSettings({ + internalCapability: await capability(), method: 'PUT', target: '/_dsh_platform/api/v1/auth-settings', + currentPassword: 'correct horse battery staple', + additionalEnabled: true, additionalPassword: 'correct horse battery staple', + }), error => error.code === 'PASSWORDS_MUST_DIFFER') + await assert.rejects(service.createManagementTransition({ + internalCapability: await capability('POST', '/_dsh_platform/api/v1/management-origin/transitions'), + method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions', + mode: 'isolated', isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://manage.example/path' }, + }), error => error.code === 'ACCESS_ENTRY_INVALID') +}) + +test('locks Management mode changes at the final execution point when DSH can become Root', async () => { + const { service } = await fixture({ + runtimeCapabilities: async () => ({ + dshRootCapabilityEffective: true, + agentIsolationEffective: false, + details: { sudoSelection: 'enabled', agentIsolationReason: 'shared-node-process-identity' }, + }), + }) + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + const handoff = await service.createManagementHandoff({ + dshToken: initialized.session.token, dshOrigin: 'https://dsh.example', targetOrigin: 'https://dsh.example', + }) + const management = await service.consumeManagementHandoff({ token: handoff.handoff.token, origin: 'https://dsh.example' }) + const capability = (await service.issueCapability({ + managementToken: management.session.token, origin: 'https://dsh.example', + csrfToken: management.session.csrfToken, requireCsrf: true, + audience: 'management', method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions', + })).capability.token + await assert.rejects(service.createManagementTransition({ + internalCapability: capability, + method: 'POST', target: '/_dsh_platform/api/v1/management-origin/transitions', + mode: 'isolated', isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://manage.example' }, + }), error => error.code === 'ACCESS_MODE_LOCKED') +}) + +test('reconciles a persisted isolated Management entry when DSH Root capability becomes effective', async () => { + const reports = [] + const { service, store } = await fixture({ + runtimeCapabilities: async () => ({ + dshRootCapabilityEffective: true, + agentIsolationEffective: false, + details: { sudoSelection: 'enabled', agentIsolationReason: 'shared-node-process-identity' }, + }), + report: async (message, fields) => reports.push({ message, fields }), + }) + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + await service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + const isolated = await persistIsolatedManagementAccess(store) + const session = await loginDsh(service, { + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + + const result = await service.reconcileRuntimePolicy() + const current = await store.state() + assert.equal(result.changed, true) + assert.notEqual(current.account.revision, isolated.revision) + assert.deepEqual(current.account.managementAccess, { + mode: 'compat', + version: isolated.managementAccess.version + 1, + isolatedEntry: null, + dshPublicOrigin: null, + changedAt: '2026-08-28T00:00:00.000Z', + }) + assert.equal((await service.validateSession({ + kind: 'dsh', token: session.session.token, origin: 'https://dsh.example', + })).authenticated, false) + assert.deepEqual(reports.at(-1), { + message: 'access.management-origin.reconciled', + fields: { mode: 'compat', reason: 'dsh-root-capability', revokedSessions: 2 }, + }) +}) + +test('preserves a persisted isolated Management entry without DSH Root capability', async () => { + const { service, store } = await fixture({ + runtimeCapabilities: async () => ({ + dshRootCapabilityEffective: false, + agentIsolationEffective: false, + details: { sudoSelection: 'disabled', agentIsolationReason: 'shared-node-process-identity' }, + }), + }) + await service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + await service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + const isolated = await persistIsolatedManagementAccess(store) + + const result = await service.reconcileRuntimePolicy() + const current = await store.state() + assert.equal(result.changed, false) + assert.equal(current.account.revision, isolated.revision) + assert.deepEqual(current.account.managementAccess, isolated.managementAccess) +}) + +test('initializes and logs into DSH with separately revocable browser sessions', async () => { + const current = await fixture() + await current.service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await current.service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + assert.equal(initialized.state, 'initialized') + assert.match(initialized.session.token, /^dshs_/) + assert.equal((await current.service.validateSession({ + kind: 'dsh', token: initialized.session.token, origin: 'https://dsh.example', + })).authenticated, true) + await current.service.logout({ kind: 'dsh', token: initialized.session.token }) + assert.equal((await current.service.validateSession({ + kind: 'dsh', token: initialized.session.token, origin: 'https://dsh.example', + })).authenticated, false) + + const loggedIn = await loginDsh(current.service, { + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + assert.match(loggedIn.session.token, /^dshs_/) + assert.doesNotMatch(JSON.stringify(loggedIn), /salt|hash|verifier/i) +}) + +test('exchanges one DSH session for one origin-bound Management session', async () => { + const current = await fixture() + await current.service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await current.service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + const created = await current.service.createManagementHandoff({ + dshToken: initialized.session.token, + dshOrigin: 'https://dsh.example', + targetOrigin: 'https://dsh.example', + }) + assert.match(created.handoff.token, /^dshh_/) + const consumed = await current.service.consumeManagementHandoff({ + token: created.handoff.token, origin: 'https://dsh.example', + }) + assert.match(consumed.session.token, /^dshms_/) + assert.notEqual(consumed.session.token, initialized.session.token) + assert.equal((await current.service.validateSession({ + kind: 'management', token: consumed.session.token, origin: 'https://dsh.example', + })).authenticated, true) + assert.equal((await current.service.validateSession({ + kind: 'dsh', token: consumed.session.token, origin: 'https://dsh.example', + })).authenticated, false) + await assert.rejects( + current.service.consumeManagementHandoff({ token: created.handoff.token, origin: 'https://dsh.example' }), + error => error.code === 'HANDOFF_INVALID', + ) +}) + +test('rejects an additional Management password after its source DSH session ends', async () => { + const current = await fixture() + await current.service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const initialized = await current.service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + await current.service.resetRecoveryManagementPassword({ + revision: initialized.account.revision, + password: 'separate management password', + }) + const dsh = await loginDsh(current.service, { + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + const handoff = await current.service.createManagementHandoff({ + dshToken: dsh.session.token, + dshOrigin: 'https://dsh.example', + targetOrigin: 'https://manage.example', + }) + const pending = await current.service.consumeManagementHandoff({ + token: handoff.handoff.token, origin: 'https://manage.example', + }) + assert.match(pending.pending.token, /^dshmp_/) + await current.service.logout({ kind: 'dsh', token: dsh.session.token }) + await assert.rejects(completeManagementLogin(current.service, { + pendingToken: pending.pending.token, + origin: 'https://manage.example', + password: 'separate management password', + }), error => error.code === 'PENDING_LOGIN_INVALID') +}) + +test('revokes Management sessions linked to the current DSH browser without widening access', async () => { + const current = await fixture() + await current.service.classify({ token: 'classification-token', evidence: { dshProfile: false } }) + const firstDsh = await current.service.initializeDsh({ + username: 'admin', password: 'correct horse battery staple', origin: 'https://dsh.example', + }) + const secondDsh = await loginDsh(current.service, { + username: 'admin', password: 'correct horse battery staple', origin: 'https://other.example', + }) + const exchange = async (dsh, dshOrigin, targetOrigin) => { + const created = await current.service.createManagementHandoff({ + dshToken: dsh.session.token, dshOrigin, targetOrigin, + }) + return current.service.consumeManagementHandoff({ token: created.handoff.token, origin: targetOrigin }) + } + const firstManagement = await exchange(firstDsh, 'https://dsh.example', 'https://manage.example') + const otherManagement = await exchange(secondDsh, 'https://other.example', 'https://other-manage.example') + + const managementOnly = await current.service.logoutDshBrowser({ + scope: 'management', dshToken: firstDsh.session.token, dshOrigin: 'https://dsh.example', + }) + assert.deepEqual(managementOnly, { authenticated: true, dshRevoked: false, managementRevoked: 1 }) + assert.equal((await current.service.validateSession({ + kind: 'dsh', token: firstDsh.session.token, origin: 'https://dsh.example', + })).authenticated, true) + assert.equal((await current.service.validateSession({ + kind: 'management', token: firstManagement.session.token, origin: 'https://manage.example', + })).authenticated, false) + assert.equal((await current.service.validateSession({ + kind: 'management', token: otherManagement.session.token, origin: 'https://other-manage.example', + })).authenticated, true) + + const replacement = await exchange(firstDsh, 'https://dsh.example', 'https://manage.example') + const all = await current.service.logoutDshBrowser({ + scope: 'all', dshToken: firstDsh.session.token, dshOrigin: 'https://dsh.example', + }) + assert.deepEqual(all, { authenticated: false, dshRevoked: true, managementRevoked: 1 }) + assert.equal((await current.service.validateSession({ + kind: 'dsh', token: firstDsh.session.token, origin: 'https://dsh.example', + })).authenticated, false) + assert.equal((await current.service.validateSession({ + kind: 'management', token: replacement.session.token, origin: 'https://manage.example', + })).authenticated, false) +}) + +test('moves initialized installations with missing or damaged accounts to recovery-required', async () => { + const { root, store } = await fixture() + await store.classify({ dshProfile: false }) + await store.initialize({ username: 'admin', password: 'correct horse battery staple' }) + await rm(join(root, 'account.json')) + const recovered = await store.classify({ dshProfile: false }) + assert.equal(recovered.initialization.state, 'recovery-required') + + const damaged = await fixture() + await damaged.store.prepare() + await writeFile(join(damaged.root, 'initialization.json'), '{broken') + assert.equal((await damaged.store.classify({ dshProfile: true })).initialization.state, 'recovery-required') +}) + +test('collects only fixed boolean legacy evidence before DSH starts', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-access-evidence-')) + const dshHome = join(root, 'dsh') + const paths = new PlatformPaths(join(root, 'platform'), join(root, 'run')) + await mkdir(join(dshHome, 'profiles', 'web'), { recursive: true }) + await mkdir(paths.managementStateRoot, { recursive: true }) + await writeFile(join(dshHome, 'profiles', 'web', 'package.json'), '{}') + const evidence = await collectAccessEvidence({ dshHome, paths, legacyAuthenticationConfigured: true }) + assert.equal(evidence.webProfile, true) + assert.equal(evidence.legacyAuthenticationConfigured, true) + assert.equal(Object.values(evidence).every(value => typeof value === 'boolean'), true) + assert.deepEqual(parseAccessEvidence(JSON.stringify(evidence)), evidence) + assert.throws(() => parseAccessEvidence('{"webProfile":false}'), /evidence is invalid/) +}) + +test('captures fresh-install evidence before Stage-0 creates platform records', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-access-fresh-evidence-')) + const dshHome = join(root, 'dsh') + const paths = new PlatformPaths(join(root, 'platform'), join(root, 'run')) + const evidence = await collectAccessEvidence({ dshHome, paths }) + await mkdir(paths.deploymentStateRoot, { recursive: true }) + await writeFile(join(paths.deploymentStateRoot, 'slots.json'), '{}') + assert.equal(evidence.deploymentState, false) + assert.equal(Object.values(evidence).some(Boolean), false) +}) diff --git a/upstream/container/platform/test/artifacts.test.mjs b/upstream/container/platform/test/artifacts.test.mjs new file mode 100644 index 0000000..d0cfdcb --- /dev/null +++ b/upstream/container/platform/test/artifacts.test.mjs @@ -0,0 +1,353 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { MANIFEST_MEDIA_TYPE, SIGNATURE_MEDIA_TYPE, VerifiedObjectStore } from '../stage0/lib/artifacts.mjs' +import { TrustLedger } from '../stage0/lib/ledger.mjs' +import { document, officialDshPolicy, keyPair, keyring, registryCandidate, registryKeyPair, signature } from './helpers.mjs' + +function descriptor(id, content, mediaType = 'application/octet-stream') { + return { + id, + mediaType, + sha256: createHash('sha256').update(content).digest('hex'), + size: content.byteLength, + url: `https://github.com/example/releases/download/v1/${id}`, + } +} + +function releaseTarget(generation, sequence, artifacts, policy = officialDshPolicy()) { + const selected = artifacts[0] + const signatureArtifact = descriptor('stable-signature', Buffer.from('signature'), 'application/vnd.dsh-platform.signature.v1+json') + const targetArtifacts = artifacts.some(artifact => artifact.id === signatureArtifact.id) + ? artifacts + : [...artifacts, signatureArtifact] + return { + schema: 1, + updateApi: 1, + keyringGeneration: generation, + targetSequence: sequence, + issuedAt: '2026-08-19T00:00:00.000Z', + artifacts: targetArtifacts, + desired: { + bootstrap: { version: '1.0.0', manifestArtifactId: selected.id, signatureArtifactId: signatureArtifact.id }, + environment: { version: '1.0.0', manifestArtifactId: selected.id, signatureArtifactId: signatureArtifact.id }, + dsh: { + version: '0.1.0-rc.7', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + }, + }, + officialDshPolicy: policy, + } +} + +function manifest(generation, sequence, artifacts) { + return { + schema: 1, + manifestType: 'environment', + version: '1.0.0', + keyringGeneration: generation, + targetSequence: sequence, + issuedAt: '2026-08-19T00:00:00.000Z', + artifacts, + bootstrapApi: 1, + components: [], + patches: [], + systemPlugins: [], + } +} + +async function fixture(targetArtifacts, policy = officialDshPolicy()) { + const directory = await mkdtemp(join(tmpdir(), 'dsh-object-store-')) + const recovery = keyPair() + const current = keyPair() + const next = keyPair() + const ledger = new TrustLedger(join(directory, 'trust'), recovery.publicKey) + const ringBytes = document(keyring(1, current, next)) + await ledger.acceptKeyring(ringBytes, signature(ringBytes, recovery)) + const artifacts = typeof targetArtifacts === 'function' ? targetArtifacts(current) : targetArtifacts + const targetBytes = document(releaseTarget(1, 1, artifacts, policy)) + await ledger.acceptTarget(targetBytes, signature(targetBytes, current)) + const untrustedRoot = join(directory, 'downloads', 'untrusted') + await mkdir(untrustedRoot, { recursive: true }) + return { + current, + directory, + ledger, + next, + recovery, + store: new VerifiedObjectStore({ root: join(directory, 'trust'), untrustedRoot, ledger }), + untrustedRoot, + } +} + +test('empty activation succeeds before release trust is initialized', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-empty-activation-')) + const recovery = keyPair() + const ledger = new TrustLedger(join(directory, 'trust'), recovery.publicKey) + const untrustedRoot = join(directory, 'downloads', 'untrusted') + await mkdir(untrustedRoot, { recursive: true }) + const store = new VerifiedObjectStore({ root: join(directory, 'trust'), untrustedRoot, ledger }) + + assert.deepEqual(await store.activate([]), []) +}) + +async function importSignature(store, untrustedRoot, signatureBytes) { + const path = join(untrustedRoot, 'manifest.sig.json') + await writeFile(path, signatureBytes) + return store.importFromTarget('stable-signature', path) +} + +async function officialSources(untrustedRoot, candidate, content, suffix = '') { + const metadataPath = join(untrustedRoot, `official-dsh${suffix}.json`) + const tarballPath = join(untrustedRoot, `official-dsh${suffix}.tgz`) + await writeFile(metadataPath, JSON.stringify({ versions: { [candidate.version]: candidate } })) + await writeFile(tarballPath, content) + return { metadataPath, tarballPath } +} + +test('imports only target-authorized bytes from the untrusted directory', async () => { + const content = Buffer.from('verified artifact') + const expected = descriptor('gateway', content) + const { store, untrustedRoot, directory } = await fixture([expected]) + const source = join(untrustedRoot, 'gateway.bin') + await writeFile(source, content) + const receipt = await store.importFromTarget('gateway', source) + assert.equal(receipt.objectSha256, expected.sha256) + assert.deepEqual(await readFile(receipt.path), content) + assert.equal((await stat(receipt.path)).mode & 0o777, 0o444) + await assert.rejects(store.importFromTarget('missing', source), /not authorized/) + await assert.rejects(store.importFromTarget('gateway', join(directory, 'outside.bin')), /untrusted/) +}) + +test('treats receipts written before authority typing as Stable receipts', async () => { + const content = Buffer.from('legacy stable artifact') + const { store, untrustedRoot } = await fixture([descriptor('legacy', content)]) + const source = join(untrustedRoot, 'legacy') + await writeFile(source, content) + const receipt = await store.importFromTarget('legacy', source) + const persisted = JSON.parse(await readFile(store.receiptPath(receipt.token), 'utf8')) + delete persisted.authorityType + await writeFile(store.receiptPath(receipt.token), `${JSON.stringify(persisted)}\n`) + assert.equal((await store.readReceipt(receipt.token)).authorityType, 'stable') +}) + +test('verifies updater-downloaded official DSH metadata and tarball without networking', async () => { + const content = Buffer.from('official tarball') + const registry = registryKeyPair() + const candidate = registryCandidate(registry, '0.1.0-rc.8', content) + const policy = officialDshPolicy(registry) + const { current, ledger, untrustedRoot, directory } = await fixture( + [descriptor('stable-only', Buffer.alloc(0))], + policy, + ) + const store = new VerifiedObjectStore({ root: join(directory, 'trust'), untrustedRoot, ledger }) + const advanced = document(releaseTarget(1, 2, [descriptor('stable-only', Buffer.alloc(0))], policy)) + await ledger.acceptTarget(advanced, signature(advanced, current)) + const sources = await officialSources(untrustedRoot, candidate, content) + const receipt = await store.ensureOfficialDsh(candidate.version, sources.metadataPath, sources.tarballPath) + assert.equal(receipt.authorityType, 'official-dsh') + assert.equal(receipt.authorityVersion, candidate.version) + assert.equal(receipt.targetSequence, 2) + assert.deepEqual(await readFile(receipt.path), content) + await store.activate([receipt.token]) + assert.equal((await store.readReceipt(receipt.token)).status, 'active') +}) + +test('official DSH import accepts only regular files inside the untrusted directory', async () => { + const content = Buffer.from('official tarball') + const registry = registryKeyPair() + const candidate = registryCandidate(registry, '0.1.0-rc.8', content) + const { store, untrustedRoot, directory } = await fixture( + [descriptor('stable-only', Buffer.alloc(0))], + officialDshPolicy(registry), + ) + const outside = await officialSources(directory, candidate, content) + await assert.rejects(store.ensureOfficialDsh(candidate.version, outside.metadataPath, outside.tarballPath), /untrusted/) + const sources = await officialSources(untrustedRoot, candidate, content) + const metadataLink = join(untrustedRoot, 'metadata-link.json') + await symlink(sources.metadataPath, metadataLink) + await assert.rejects(store.ensureOfficialDsh(candidate.version, metadataLink, sources.tarballPath), /ELOOP|symbolic/i) +}) + +test('official DSH import rejects malformed metadata and mismatched tarball bytes', async () => { + const content = Buffer.from('official tarball') + const registry = registryKeyPair() + const candidate = registryCandidate(registry, '0.1.0-rc.8', content) + const { store, untrustedRoot } = await fixture( + [descriptor('stable-only', Buffer.alloc(0))], + officialDshPolicy(registry), + ) + const malformed = join(untrustedRoot, 'malformed.json') + const tarball = join(untrustedRoot, 'official.tgz') + await writeFile(malformed, '{') + await writeFile(tarball, content) + await assert.rejects(store.ensureOfficialDsh(candidate.version, malformed, tarball), /valid JSON/) + const sources = await officialSources(untrustedRoot, candidate, Buffer.from('different bytes'), '-mismatch') + await assert.rejects( + store.ensureOfficialDsh(candidate.version, sources.metadataPath, sources.tarballPath), + { code: 'TRUST_ARTIFACT_MISMATCH' }, + ) +}) + +test('revokes staged official DSH receipts after Registry delegation changes but retains active objects', async () => { + const content = Buffer.from('official tarball') + const registry = registryKeyPair() + const replacement = registryKeyPair() + const candidate = registryCandidate(registry, '0.1.0-rc.8', content) + const { current, directory, ledger, untrustedRoot } = await fixture( + [descriptor('stable-only', Buffer.alloc(0))], + officialDshPolicy(registry), + ) + const store = new VerifiedObjectStore({ root: join(directory, 'trust'), untrustedRoot, ledger }) + const first = await officialSources(untrustedRoot, candidate, content, '-active') + const active = await store.ensureOfficialDsh(candidate.version, first.metadataPath, first.tarballPath) + await store.activate([active.token]) + const second = await officialSources(untrustedRoot, candidate, content, '-staged') + const staged = await store.ensureOfficialDsh(candidate.version, second.metadataPath, second.tarballPath) + + const advanced = document(releaseTarget( + 1, + 2, + [descriptor('stable-only', Buffer.alloc(0))], + officialDshPolicy(replacement), + )) + await ledger.acceptTarget(advanced, signature(advanced, current)) + await store.reconcileRevocations((await ledger.currentKeyring()).value) + assert.equal((await store.readReceipt(active.token)).status, 'active') + assert.equal((await store.readReceipt(staged.token)).status, 'revoked') +}) + +test('rejects mismatched content and symbolic-link sources', async () => { + const expected = descriptor('gateway', Buffer.from('expected')) + const { store, untrustedRoot } = await fixture([expected]) + const bad = join(untrustedRoot, 'bad.bin') + await writeFile(bad, 'different') + await assert.rejects(store.importFromTarget('gateway', bad), { code: 'TRUST_ARTIFACT_MISMATCH' }) + const real = join(untrustedRoot, 'real.bin') + const link = join(untrustedRoot, 'link.bin') + await writeFile(real, 'expected') + await symlink(real, link) + await assert.rejects(store.importFromTarget('gateway', link), /ELOOP|symbolic/i) +}) + +test('verifies a signed manifest before authorizing its child artifacts', async () => { + const component = Buffer.from('component') + const manifestBytes = document(manifest(1, 1, [descriptor('component', component)])) + const manifestDescriptor = descriptor('environment-manifest', manifestBytes, MANIFEST_MEDIA_TYPE) + let signatureBytes + const { store, untrustedRoot } = await fixture(current => { + signatureBytes = document(signature(manifestBytes, current)) + return [manifestDescriptor, descriptor('stable-signature', signatureBytes, SIGNATURE_MEDIA_TYPE)] + }) + const manifestPath = join(untrustedRoot, 'manifest.json') + await writeFile(manifestPath, manifestBytes) + const imported = await store.importFromTarget('environment-manifest', manifestPath) + await assert.rejects(store.importFromManifest(imported.token, 'component', manifestPath), /verified manifest/) + const signatureReceipt = await importSignature(store, untrustedRoot, signatureBytes) + await store.acceptManifest(imported.token, signatureReceipt.token) + const componentPath = join(untrustedRoot, 'component.bin') + await writeFile(componentPath, component) + const child = await store.importFromManifest(imported.token, 'component', componentPath) + assert.equal(child.parentReceipt, imported.token) +}) + +test('revokes unactivated receipts after key rotation but retains active and previous objects', async () => { + const one = Buffer.from('one') + const two = Buffer.from('two') + const third = Buffer.from('three') + const descriptors = [descriptor('one', one), descriptor('two', two), descriptor('three', third)] + const { current, next, recovery, ledger, store, untrustedRoot } = await fixture(descriptors) + for (const [name, content] of [['one', one], ['two', two], ['three', third]]) { + await writeFile(join(untrustedRoot, name), content) + } + const first = await store.importFromTarget('one', join(untrustedRoot, 'one')) + const second = await store.importFromTarget('two', join(untrustedRoot, 'two')) + const unactivated = await store.importFromTarget('three', join(untrustedRoot, 'three')) + await store.activate([first.token]) + await store.activate([second.token]) + + const future = keyPair() + const rotated = document(keyring(2, next, future, [current.keyId])) + const rotatedValue = await ledger.acceptKeyring(rotated, signature(rotated, recovery)) + await assert.rejects(store.importFromTarget('three', join(untrustedRoot, 'three')), { code: 'TRUST_REVOKED' }) + await assert.rejects(store.activate([unactivated.token]), { code: 'TRUST_REVOKED' }) + const revoked = await store.reconcileRevocations(rotatedValue) + assert.deepEqual(revoked, [unactivated.token]) + assert.equal((await store.readReceipt(first.token)).status, 'previous') + assert.equal((await store.readReceipt(second.token)).status, 'active') + + await store.collectGarbage() + await assert.rejects(store.readReceipt(unactivated.token), /does not exist/) + assert.deepEqual(await readFile(store.objectPath(descriptors[0].sha256)), one) + assert.deepEqual(await readFile(store.objectPath(descriptors[1].sha256)), two) +}) + +test('activation includes manifest ancestors and rollback swaps current and previous', async () => { + const component = Buffer.from('component') + const other = Buffer.from('other') + const manifestBytes = document(manifest(1, 1, [descriptor('component', component)])) + const manifestDescriptor = descriptor('environment-manifest', manifestBytes, MANIFEST_MEDIA_TYPE) + let signatureBytes + const { store, untrustedRoot } = await fixture(current => { + signatureBytes = document(signature(manifestBytes, current)) + return [ + manifestDescriptor, + descriptor('stable-signature', signatureBytes, SIGNATURE_MEDIA_TYPE), + descriptor('other', other), + ] + }) + await writeFile(join(untrustedRoot, 'manifest'), manifestBytes) + await writeFile(join(untrustedRoot, 'component'), component) + await writeFile(join(untrustedRoot, 'other'), other) + const parent = await store.importFromTarget('environment-manifest', join(untrustedRoot, 'manifest')) + const signatureReceipt = await importSignature(store, untrustedRoot, signatureBytes) + await store.acceptManifest(parent.token, signatureReceipt.token) + const child = await store.importFromManifest(parent.token, 'component', join(untrustedRoot, 'component')) + const selected = await store.activate([child.token]) + assert.deepEqual(new Set(selected), new Set([parent.token, child.token])) + assert.equal((await store.readReceipt(parent.token)).status, 'active') + const nextReceipt = await store.importFromTarget('other', join(untrustedRoot, 'other')) + await store.activate([nextReceipt.token]) + assert.equal((await store.readReceipt(parent.token)).status, 'previous') + await store.rollback() + assert.equal((await store.readReceipt(parent.token)).status, 'active') + assert.equal((await store.readReceipt(nextReceipt.token)).status, 'previous') +}) + +test('rejects staged receipts after the current Release Key advances targetSequence', async () => { + const content = Buffer.from('old target artifact') + const expected = descriptor('artifact', content) + const { current, ledger, store, untrustedRoot } = await fixture([expected]) + const source = join(untrustedRoot, 'artifact') + await writeFile(source, content) + const receipt = await store.importFromTarget('artifact', source) + const advanced = document(releaseTarget(1, 2, [expected])) + await ledger.acceptTarget(advanced, signature(advanced, current)) + await assert.rejects(store.activate([receipt.token]), { code: 'TRUST_REVOKED' }) +}) + +test('rejects child imports through a manifest from an older targetSequence', async () => { + const component = Buffer.from('component') + const componentDescriptor = descriptor('component', component) + const manifestBytes = document(manifest(1, 1, [componentDescriptor])) + const manifestDescriptor = descriptor('environment-manifest', manifestBytes, MANIFEST_MEDIA_TYPE) + let signatureBytes + const { current, ledger, store, untrustedRoot } = await fixture(release => { + signatureBytes = document(signature(manifestBytes, release)) + return [manifestDescriptor, descriptor('stable-signature', signatureBytes, SIGNATURE_MEDIA_TYPE)] + }) + await writeFile(join(untrustedRoot, 'manifest'), manifestBytes) + await writeFile(join(untrustedRoot, 'component'), component) + const parent = await store.importFromTarget('environment-manifest', join(untrustedRoot, 'manifest')) + const signatureReceipt = await importSignature(store, untrustedRoot, signatureBytes) + await store.acceptManifest(parent.token, signatureReceipt.token) + const advanced = document(releaseTarget(1, 2, [manifestDescriptor])) + await ledger.acceptTarget(advanced, signature(advanced, current)) + await assert.rejects( + store.importFromManifest(parent.token, 'component', join(untrustedRoot, 'component')), + { code: 'TRUST_REVOKED' }, + ) +}) diff --git a/upstream/container/platform/test/automatic-check.test.mjs b/upstream/container/platform/test/automatic-check.test.mjs new file mode 100644 index 0000000..7064cf2 --- /dev/null +++ b/upstream/container/platform/test/automatic-check.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { + AUTOMATIC_CHECK_INTERVALS, + AutomaticCheckStateStore, +} from '../../control-plane/modules/updater/lib/automatic-check.mjs' + +function target(targetSequence, dsh) { + return { + targetSequence, + desired: { + dsh: { version: dsh }, + environment: { version: '1.0.0' }, + }, + } +} + +test('automatic checks use persistent defaults and fixed user-facing intervals', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-automatic-check-')) + const store = new AutomaticCheckStateStore(join(root, 'automatic-check.json')) + assert.deepEqual(AUTOMATIC_CHECK_INTERVALS, [3_600, 10_800, 21_600, 43_200, 86_400]) + assert.deepEqual(await store.read(), { + schema: 1, + automaticCheck: { enabled: true, intervalSeconds: 21_600, notificationsEnabled: true }, + latestAutomatic: { stable: null, upstream: null }, + }) + assert.throws(() => store.configure({ enabled: true, intervalSeconds: 60, notificationsEnabled: true }), /interval/) +}) + +test('automatic checks retain only the newest Stable and Upstream candidates', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-automatic-candidates-')) + const store = new AutomaticCheckStateStore(join(root, 'automatic-check.json'), () => new Date('2026-08-20T08:00:00Z')) + const current = { targetSequence: 1, dsh: '0.1.0-rc.7', environment: '1.0.0' } + + await store.record({ channel: 'experimental', current, target: target(2, '0.1.0-rc.8'), upstream: { version: '0.1.0-rc.10' }, stableAvailable: true }) + await store.record({ channel: 'experimental', current, target: target(3, '0.1.0-rc.9'), upstream: { version: '0.1.0-rc.11' }, stableAvailable: true }) + await store.record({ channel: 'experimental', current, target: target(2, '0.1.0-rc.8'), upstream: { version: '0.1.0-rc.10' }, stableAvailable: true }) + const state = await store.read() + assert.equal(state.latestAutomatic.stable.targetSequence, 3) + assert.equal(state.latestAutomatic.upstream.version, '0.1.0-rc.11') + + await store.clearSatisfied({ targetSequence: 3, dsh: '0.1.0-rc.11', environment: '1.0.0' }) + assert.deepEqual((await store.read()).latestAutomatic, { stable: null, upstream: null }) +}) + +test('Stable channel, Holds, and disabled settings suppress notification candidates', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-automatic-suppression-')) + const store = new AutomaticCheckStateStore(join(root, 'automatic-check.json')) + const current = { targetSequence: 1, dsh: '0.1.0-rc.7', environment: '1.0.0' } + const stable = target(2, '0.1.0-rc.8') + + await store.record({ channel: 'experimental', current, target: stable, upstream: { version: '0.1.0-rc.10' }, stableAvailable: true }) + await store.record({ channel: 'stable', current, target: stable, upstream: { version: '0.1.0-rc.11' }, stableAvailable: true }) + assert.equal((await store.read()).latestAutomatic.upstream, null) + + await store.record({ + channel: 'experimental', current, target: stable, upstream: { version: '0.1.0-rc.11' }, + stableAvailable: true, holds: [{ type: 'version', dshVersion: '0.1.0-rc.11' }], + }) + assert.equal((await store.read()).latestAutomatic.upstream, null) + + await store.configure({ enabled: false, intervalSeconds: 3_600, notificationsEnabled: true }) + assert.deepEqual((await store.read()).latestAutomatic, { stable: null, upstream: null }) +}) + +test('a sequence-only metadata advance does not advertise a Stable update', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-automatic-ahead-')) + const store = new AutomaticCheckStateStore(join(root, 'automatic-check.json')) + await store.record({ + channel: 'experimental', + current: { targetSequence: 2, dsh: '0.1.0-rc.10', environment: '1.0.0' }, + target: target(3, '0.1.0-rc.10'), + upstream: null, + stableAvailable: false, + }) + assert.equal((await store.read()).latestAutomatic.stable, null) +}) diff --git a/upstream/container/platform/test/bootstrap.test.mjs b/upstream/container/platform/test/bootstrap.test.mjs new file mode 100644 index 0000000..99b3bd9 --- /dev/null +++ b/upstream/container/platform/test/bootstrap.test.mjs @@ -0,0 +1,1318 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import { canonicalJson } from '../lib/canonical-json.mjs' +import { BootstrapRuntime } from '../bootstrap/lib/runtime.mjs' +import { EnvironmentRunner, loadControlPlane } from '../bootstrap/lib/lifecycle.mjs' +import { createBootstrapControl, listenBootstrapControl } from '../bootstrap/lib/control.mjs' +import { LocalApiClient } from '../../control-plane/modules/updater/lib/client.mjs' + +function command(script, args = []) { + return { executable: process.execPath, args: [script, ...args], timeoutSeconds: 5 } +} + +const emptyLifecycle = { + prepare: null, + preStart: null, + postStart: null, + preStop: null, + stop: null, + postStop: null, +} + +async function environment(components) { + const root = await mkdtemp(join(tmpdir(), 'dsh-environment-runner-')) + await mkdir(join(root, 'artifacts')) + const references = [] + const artifacts = [] + for (const component of components) { + const artifactId = `component-${component.id}` + const bytes = canonicalJson(component) + const sha256 = createHash('sha256').update(bytes).digest('hex') + await writeFile(join(root, 'artifacts', artifactId), bytes) + references.push({ id: component.id, sha256 }) + artifacts.push({ + id: artifactId, + mediaType: 'application/vnd.dsh-platform.component.v1+json', + sha256, + size: bytes.byteLength, + url: `https://release.example/${artifactId}`, + }) + } + await writeFile(join(root, 'environment.manifest.json'), canonicalJson({ + schema: 1, + manifestType: 'environment', + version: 'test', + keyringGeneration: 1, + targetSequence: 1, + issuedAt: '2026-08-19T00:00:00.000Z', + artifacts, + bootstrapApi: 1, + components: references, + patches: [], + systemPlugins: [], + })) + return root +} + +function component(id, script, type = 'oneshot', lifecycle = emptyLifecycle) { + return { + schema: 1, + id, + type, + command: command(script), + environment: {}, + lifecycle, + health: null, + logging: { stdout: true, stderr: true }, + } +} + +test('runs components in manifest order and stop phases in reverse order', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-order-')) + const log = join(temp, 'order.log') + const append = join(temp, 'append.mjs') + const service = join(temp, 'service.mjs') + const check = join(temp, 'check.mjs') + await writeFile(append, 'import { appendFileSync } from "node:fs"; appendFileSync(process.argv[2], process.argv[3] + "\\n")') + await writeFile(service, 'import { appendFileSync } from "node:fs"; appendFileSync(process.argv[2], process.argv[3] + "\\n"); setInterval(() => {}, 1000)') + await writeFile(check, 'import { readFileSync } from "node:fs"; process.exit(readFileSync(process.argv[2], "utf8").includes(process.argv[3]) ? 0 : 1)') + const lifecycle = label => ({ + ...emptyLifecycle, + prepare: command(append, [log, `${label}:prepare`]), + preStart: command(append, [log, `${label}:preStart`]), + postStart: command(append, [log, `${label}:postStart`]), + preStop: command(append, [log, `${label}:preStop`]), + postStop: command(append, [log, `${label}:postStop`]), + }) + const first = component('first', service, 'service', lifecycle('first')) + first.command = command(service, [log, 'first:start']) + first.health = { + type: 'exec', + command: command(check, [log, 'first:start']), + intervalSeconds: 1, + timeoutSeconds: 5, + } + const second = component('second', append, 'oneshot', lifecycle('second')) + second.command = command(append, [log, 'second:start']) + const reports = [] + const runner = new EnvironmentRunner({ + environmentRoot: await environment([first, second]), + capture: () => {}, + report: (message, fields) => { reports.push({ message, fields }) }, + }) + await runner.start() + await runner.stop() + const lines = (await readFile(log, 'utf8')).trim().split('\n') + assert.deepEqual(lines.slice(0, 8), [ + 'first:prepare', 'second:prepare', 'first:preStart', 'first:start', + 'first:postStart', 'second:preStart', 'second:start', 'second:postStart', + ]) + assert.deepEqual(lines.slice(8), ['second:preStop', 'second:postStop', 'first:preStop', 'first:postStop']) + assert.deepEqual(reports.map(report => [report.message, report.fields.componentId]), [ + ['component.starting', 'first'], + ['component.spawned', 'first'], + ['component.ready', 'first'], + ['component.starting', 'second'], + ['component.ready', 'second'], + ['component.stopping', 'second'], + ['component.stopped', 'second'], + ['component.stopping', 'first'], + ['component.stopped', 'first'], + ]) + assert.equal(reports.find(report => report.message === 'component.ready').fields.elapsedMs >= 0, true) +}) + +test('starts explicit component groups concurrently and waits before the next group', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-groups-')) + const barrier = join(temp, 'barrier.mjs') + const after = join(temp, 'after.mjs') + const firstStarted = join(temp, 'first.started') + const secondStarted = join(temp, 'second.started') + const firstDone = join(temp, 'first.done') + const secondDone = join(temp, 'second.done') + await writeFile(barrier, ` + import { existsSync, writeFileSync } from 'node:fs' + writeFileSync(process.argv[2], '') + while (!existsSync(process.argv[3])) await new Promise(resolve => setTimeout(resolve, 10)) + writeFileSync(process.argv[4], '') + `) + await writeFile(after, ` + import { existsSync } from 'node:fs' + process.exit(existsSync(process.argv[2]) && existsSync(process.argv[3]) ? 0 : 9) + `) + const first = component('first', barrier) + first.command = command(barrier, [firstStarted, secondStarted, firstDone]) + const second = component('second', barrier) + second.command = command(barrier, [secondStarted, firstStarted, secondDone]) + const final = component('final', after) + final.command = command(after, [firstDone, secondDone]) + const runner = new EnvironmentRunner({ + environmentRoot: await environment([first, second, final]), + capture: () => {}, + startupGroups: [['first', 'second'], ['final']], + }) + await runner.start() + assert.deepEqual(runner.status().components.map(value => value.id), ['first', 'second', 'final']) + await runner.stop() +}) + +test('waits for a failed startup group before rolling back every started service', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-group-failure-')) + const service = join(temp, 'service.mjs') + const failure = join(temp, 'failure.mjs') + await writeFile(service, 'setInterval(() => {}, 1000)') + await writeFile(failure, 'process.exit(9)') + const reports = [] + const runner = new EnvironmentRunner({ + environmentRoot: await environment([ + component('service', service, 'service'), + component('failure', failure), + ]), + capture: () => {}, + report: (message, fields) => { reports.push({ message, fields }) }, + startupGroups: [['service', 'failure']], + }) + await assert.rejects(runner.start(), /failure command failed/) + assert.deepEqual(runner.status().components, []) + assert.equal(reports.some(report => report.message === 'component.stopped' + && report.fields.componentId === 'service'), true) +}) + +test('does not recover an isolated startup failure after its group fatally fails', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-group-recovery-')) + const service = join(temp, 'service.mjs') + await writeFile(service, 'setInterval(() => {}, 1000)') + const reports = [] + const runner = new EnvironmentRunner({ + environmentRoot: await environment([ + component('gateway', service, 'service'), + component('outbound-proxy', service, 'service'), + ]), + capture: () => {}, + recoverableComponents: ['outbound-proxy'], + recoveryDelaysMs: [0], + report: (message, fields) => { reports.push({ message, fields }) }, + startupGroups: [['gateway', 'outbound-proxy']], + }) + runner.startComponentUnlocked = candidate => Promise.reject(new Error(`${candidate.id} unavailable`)) + await assert.rejects(runner.start(), /gateway unavailable/) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(reports.some(report => report.message === 'component.recovery.started'), false) +}) + +test('rejects incomplete or duplicated startup groups before component preparation', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-invalid-groups-')) + const marker = join(temp, 'prepared') + const append = join(temp, 'append.mjs') + await writeFile(append, `import { writeFileSync } from 'node:fs'; writeFileSync(process.argv[2], '')`) + const first = component('first', append) + first.lifecycle = { ...emptyLifecycle, prepare: command(append, [marker]) } + const root = await environment([first, component('second', append)]) + const omitted = new EnvironmentRunner({ + environmentRoot: root, + capture: () => {}, + startupGroups: [['first']], + }) + await assert.rejects(omitted.start(), /omit components: second/) + await assert.rejects(readFile(marker), error => error?.code === 'ENOENT') + const duplicated = new EnvironmentRunner({ + environmentRoot: root, + capture: () => {}, + startupGroups: [['first'], ['first', 'second']], + }) + await assert.rejects(duplicated.start(), /component first is duplicated/) +}) + +test('exec health probes use only their exit status and do not emit component logs', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-health-output-')) + const service = join(temp, 'service.mjs') + const health = join(temp, 'health.mjs') + await writeFile(service, 'setInterval(() => {}, 1000)') + await writeFile(health, 'console.log(JSON.stringify({ noisy: true }, null, 2))') + const candidate = component('service', service, 'service') + candidate.health = { + type: 'exec', + command: command(health), + intervalSeconds: 1, + timeoutSeconds: 5, + } + let captures = 0 + const runner = new EnvironmentRunner({ + environmentRoot: await environment([candidate]), + capture: () => { captures += 1 }, + }) + await runner.start() + assert.equal((await runner.health()).healthy, true) + assert.equal(captures, 1) + await runner.stop() +}) + +test('startup health retries do not inherit the steady one-second interval', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-health-retry-')) + const service = join(temp, 'service.mjs') + const health = join(temp, 'health.mjs') + const attempts = join(temp, 'attempts') + await writeFile(service, 'setInterval(() => {}, 1000)') + await writeFile(health, `import { existsSync, writeFileSync } from 'node:fs'; if (!existsSync(process.argv[2])) { writeFileSync(process.argv[2], 'first'); process.exit(1) }`) + const candidate = component('service', service, 'service') + candidate.health = { + type: 'exec', + command: command(health, [attempts]), + intervalSeconds: 2, + timeoutSeconds: 5, + } + const runner = new EnvironmentRunner({ + environmentRoot: await environment([candidate]), + capture: () => {}, + }) + const started = Date.now() + await runner.start() + const elapsedMs = Date.now() - started + assert.ok(elapsedMs < 1_000, `startup health retry took ${String(elapsedMs)} ms`) + await runner.stop() +}) + +test('component environment replaces an inherited root user environment', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-component-environment-')) + const output = join(temp, 'environment.json') + const inspect = join(temp, 'inspect.mjs') + await writeFile(inspect, `import { writeFileSync } from 'node:fs'; writeFileSync(process.argv[2], JSON.stringify({ HOME: process.env.HOME, USER: process.env.USER, LOGNAME: process.env.LOGNAME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, XDG_CACHE_HOME: process.env.XDG_CACHE_HOME, XDG_DATA_HOME: process.env.XDG_DATA_HOME }))`) + const candidate = component('dsh-runtime', inspect) + candidate.command = command(inspect, [output]) + candidate.environment = { + HOME: '/home/node', + USER: 'node', + LOGNAME: 'node', + XDG_CONFIG_HOME: '/home/node/.config', + XDG_CACHE_HOME: '/home/node/.cache', + XDG_DATA_HOME: '/home/node/.local/share', + } + const original = { + HOME: process.env.HOME, + USER: process.env.USER, + LOGNAME: process.env.LOGNAME, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + XDG_CACHE_HOME: process.env.XDG_CACHE_HOME, + XDG_DATA_HOME: process.env.XDG_DATA_HOME, + } + process.env.HOME = '/root' + process.env.USER = 'root' + process.env.LOGNAME = 'root' + process.env.XDG_CONFIG_HOME = '/root/.config' + process.env.XDG_CACHE_HOME = '/root/.cache' + process.env.XDG_DATA_HOME = '/root/.local/share' + try { + const runner = new EnvironmentRunner({ + environmentRoot: await environment([candidate]), + capture: () => {}, + }) + await runner.start() + assert.deepEqual(JSON.parse(await readFile(output, 'utf8')), candidate.environment) + } finally { + for (const [name, value] of Object.entries(original)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + } +}) + +test('binds prepared service environment and cleanup to one process launch', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-prepared-service-')) + const output = join(temp, 'launch-token') + const service = join(temp, 'service.mjs') + const health = join(temp, 'health.mjs') + await writeFile(service, `import { writeFileSync } from 'node:fs'; writeFileSync(process.argv[2], process.env.DSH_PLATFORM_LAUNCH_TOKEN); setInterval(() => {}, 1000)`) + await writeFile(health, `import { existsSync } from 'node:fs'; process.exit(existsSync(process.argv[2]) ? 0 : 1)`) + let releases = 0 + const candidate = component('dsh-runtime', service, 'service') + candidate.command = command(service, [output]) + candidate.health = { + type: 'exec', command: command(health, [output]), intervalSeconds: 1, timeoutSeconds: 5, + } + const runner = new EnvironmentRunner({ + environmentRoot: await environment([candidate]), + capture: () => {}, + prepareService: async value => ({ + environment: { DSH_PLATFORM_LAUNCH_TOKEN: `token-for-${value.id}` }, + release: () => { releases += 1 }, + }), + }) + try { + await runner.start() + assert.equal(await readFile(output, 'utf8'), 'token-for-dsh-runtime') + assert.equal(releases, 0) + } finally { + await runner.stop() + } + assert.equal(releases, 1) +}) + +test('releases prepared service ownership when process spawn fails', async () => { + let releases = 0 + const candidate = component('dsh-runtime', '/path/that/does/not/exist', 'service') + candidate.command.executable = '/path/that/does/not/exist' + candidate.command.args = [] + const runner = new EnvironmentRunner({ + environmentRoot: await environment([candidate]), + capture: () => {}, + prepareService: async () => ({ + environment: { DSH_PLATFORM_LAUNCH_TOKEN: 'unused-token' }, + release: () => { releases += 1 }, + }), + }) + await assert.rejects(runner.start(), /ENOENT/) + assert.equal(releases, 1) +}) + +test('stops already-started services when a later component fails', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-failure-')) + const service = join(temp, 'service.mjs') + const failure = join(temp, 'failure.mjs') + await writeFile(service, 'setInterval(() => {}, 1000)') + await writeFile(failure, 'process.exit(9)') + const reports = [] + const runner = new EnvironmentRunner({ + environmentRoot: await environment([ + component('service', service, 'service'), + component('failure', failure), + ]), + capture: () => {}, + report: (message, fields) => { reports.push({ message, fields }) }, + }) + await assert.rejects(runner.start(), /failure command failed/) + assert.deepEqual(runner.status().components, []) + const failureReport = reports.find(report => report.message === 'component.start.failed').fields + assert.equal(failureReport.componentId, 'failure') + assert.equal(failureReport.elapsedMs >= 0, true) + assert.match(failureReport.error, /failure command failed.*code=9/) + assert.equal(failureReport.level, 'error') +}) + +test('reports a service exit after readiness as a fatal Bootstrap condition', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-runtime-exit-')) + const exits = join(temp, 'exits.mjs') + await writeFile(exits, 'setTimeout(() => process.exit(7), 20)') + const reports = [] + const observed = [] + const runner = new EnvironmentRunner({ + environmentRoot: await environment([component('service', exits, 'service')]), + capture: () => {}, + report: (message, fields) => { reports.push({ message, fields }) }, + }) + runner.onFatal(error => observed.push(error)) + await runner.start() + const error = await runner.fatal + assert.match(error.message, /service exited unexpectedly.*code=7/) + assert.equal(error.componentId, 'service') + assert.equal(error.exitCode, 7) + assert.equal(observed[0], error) + assert.equal(reports.find(report => report.message === 'component.exited').fields.level, 'error') + await runner.stop() +}) + +test('recovers an opted-in service exit without stopping sibling services', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-isolated-recovery-')) + const counter = join(temp, 'counter') + const recoverable = join(temp, 'recoverable.mjs') + const sibling = join(temp, 'sibling.mjs') + await writeFile(recoverable, `import { existsSync, writeFileSync } from 'node:fs'; if (!existsSync(process.argv[2])) { writeFileSync(process.argv[2], '1'); setTimeout(() => process.exit(7), 20) } else setInterval(() => {}, 1000)`) + await writeFile(sibling, 'setInterval(() => {}, 1000)') + const candidate = component('outbound-proxy', recoverable, 'service') + candidate.command = command(recoverable, [counter]) + const reports = [] + const runner = new EnvironmentRunner({ + environmentRoot: await environment([candidate, component('management', sibling, 'service')]), + capture: () => {}, + recoverableComponents: ['outbound-proxy'], + recoveryDelaysMs: [0, 0, 0], + report: (message, fields) => { reports.push({ message, fields }) }, + }) + await runner.start() + const deadline = Date.now() + 2_000 + while (!reports.some(report => report.message === 'component.recovery.completed')) { + if (Date.now() >= deadline) throw new Error('recoverable component did not restart') + await new Promise(resolve => setTimeout(resolve, 10)) + } + assert.deepEqual(runner.status().components.map(value => value.id), ['outbound-proxy', 'management']) + assert.equal(reports.some(report => report.message === 'component.exited'), true) + assert.equal(await Promise.race([ + runner.fatal.then(() => 'fatal'), + new Promise(resolve => setTimeout(() => resolve('isolated'), 20)), + ]), 'isolated') + await runner.stop() +}) + +test('isolates a persistently failing opted-in component and starts later services', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-isolated-startup-')) + const service = join(temp, 'service.mjs') + await writeFile(service, 'setInterval(() => {}, 1000)') + const reports = [] + const runner = new EnvironmentRunner({ + environmentRoot: await environment([ + component('outbound-proxy', service, 'service'), + component('management', service, 'service'), + ]), + capture: () => {}, + recoverableComponents: ['outbound-proxy'], + recoveryDelaysMs: [0, 0, 0], + report: (message, fields) => { reports.push({ message, fields }) }, + }) + const start = runner.startComponentUnlocked.bind(runner) + runner.startComponentUnlocked = (candidate, prepare) => candidate.id === 'outbound-proxy' + ? Promise.reject(new Error('proxy port conflict')) + : start(candidate, prepare) + await runner.start() + const deadline = Date.now() + 2_000 + while (reports.filter(report => report.message === 'component.isolated').length < 2) { + if (Date.now() >= deadline) throw new Error('proxy failure was not isolated') + await new Promise(resolve => setTimeout(resolve, 10)) + } + assert.deepEqual(runner.status().components.map(value => value.id), ['management']) + assert.equal(reports.filter(report => report.message === 'component.recovery.failed').length, 3) + await runner.stop() +}) + +test('contains a service exit during readiness inside the startup operation', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-startup-exit-')) + const service = join(temp, 'service.mjs') + await writeFile(service, 'process.exit(1)') + const candidate = component('candidate', service, 'service') + candidate.health = { + type: 'http', host: '127.0.0.1', port: 1, path: '/', intervalSeconds: 1, timeoutSeconds: 1, + } + const runner = new EnvironmentRunner({ + environmentRoot: await environment([candidate]), + capture: () => {}, + }) + await assert.rejects(runner.start(), /exited before health check passed/) + const outcome = await Promise.race([ + runner.fatal.then(() => 'fatal'), + new Promise(resolve => setTimeout(() => resolve('contained'), 20)), + ]) + assert.equal(outcome, 'contained') +}) + +test('suspends, resumes, and restarts one service while keeping other Environment components running', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-suspend-')) + const service = join(temp, 'service.mjs') + await writeFile(service, 'setInterval(() => {}, 1000)') + const runner = new EnvironmentRunner({ + environmentRoot: await environment([ + component('dsh-runtime', service, 'service'), + component('platform-management', service, 'service'), + ]), + capture: () => {}, + }) + await runner.start() + await runner.suspend('dsh-runtime') + await runner.pause('dsh-runtime') + await runner.pause('dsh-runtime') + await assert.rejects(runner.pause('unknown-service'), /does not exist/) + assert.deepEqual(runner.status().components.map(value => value.id), ['platform-management']) + assert.equal((await runner.health()).healthy, false) + await runner.resume('dsh-runtime') + assert.deepEqual(runner.status().components.map(value => value.id), ['dsh-runtime', 'platform-management']) + const managementPid = runner.status().components.find(value => value.id === 'platform-management').pid + const firstDshPid = runner.status().components.find(value => value.id === 'dsh-runtime').pid + await runner.restart('dsh-runtime') + assert.notEqual(runner.status().components.find(value => value.id === 'dsh-runtime').pid, firstDshPid) + assert.equal(runner.status().components.find(value => value.id === 'platform-management').pid, managementPid) + assert.equal((await runner.health()).healthy, true) + assert.equal(await Promise.race([ + runner.fatal.then(() => 'fatal'), + new Promise(resolve => setTimeout(() => resolve('intentional'), 20)), + ]), 'intentional') + await runner.stop() +}) + +test('retries a service restart after its first start attempt fails', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-restart-retry-')) + const service = join(temp, 'service.mjs') + await writeFile(service, 'setInterval(() => {}, 1000)') + const runner = new EnvironmentRunner({ + environmentRoot: await environment([component('dsh-runtime', service, 'service')]), + capture: () => {}, + }) + await runner.start() + const startComponent = runner.startComponentUnlocked.bind(runner) + let failStart = true + runner.startComponentUnlocked = async (...args) => { + if (failStart) { + failStart = false + throw new Error('restart failed') + } + return startComponent(...args) + } + await assert.rejects(runner.restart('dsh-runtime'), /restart failed/) + assert.deepEqual(runner.status().components, []) + await runner.restart('dsh-runtime') + assert.deepEqual(runner.status().components.map(value => value.id), ['dsh-runtime']) + await runner.stop() +}) + +test('restores a service after a transactional restart candidate fails', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-restart-recovery-')) + const service = join(temp, 'service.mjs') + await writeFile(service, 'setInterval(() => {}, 1000)') + const runner = new EnvironmentRunner({ + environmentRoot: await environment([component('dsh-runtime', service, 'service')]), + capture: () => {}, + }) + await runner.start() + const startComponent = runner.startComponentUnlocked.bind(runner) + let candidate = true + runner.startComponentUnlocked = async (...args) => { + if (candidate) { + candidate = false + throw new Error('candidate failed') + } + return startComponent(...args) + } + const changes = [] + await assert.rejects(runner.restart('dsh-runtime', { + recoverStart: true, + beforeStart: async () => { changes.push('activate') }, + onStartFailure: async () => { changes.push('rollback') }, + }), /candidate failed/) + assert.deepEqual(changes, ['activate', 'rollback']) + assert.deepEqual(runner.status().components.map(value => value.id), ['dsh-runtime']) + await runner.stop() +}) + +test('Bootstrap control socket exposes component suspension, resumption, restart, and health', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-bootstrap-control-')) + const calls = [] + const runner = { + status: () => ({ components: [] }), + reload: async () => ({}), + health: async () => ({ healthy: true, components: [] }), + suspend: async id => { calls.push(['suspend', id]); return {} }, + pause: async id => { calls.push(['pause', id]); return {} }, + resume: async id => { calls.push(['resume', id]); return {} }, + restart: async (id, options = {}) => { + calls.push(['restart', id]) + try { + await options.beforeStart?.() + return {} + } catch (error) { + await options.onStartFailure?.(error) + if (options.recoverStart) calls.push(['restart-recovered', id]) + throw error + } + }, + record: async (message, fields) => { calls.push(['report', message, fields.pathname ?? null]) }, + } + const deployments = { + exclusive: operation => operation(), + setOperation: async operation => { calls.push(['operation', operation]) }, + publishStatus: async options => { calls.push(['status', 'published', options ?? null]) }, + resetCurrentRuntime: async ({ pauseDsh, restartDsh }) => { + calls.push(['runtime-reset']) + await pauseDsh() + await restartDsh() + return { recordId: 'repaired' } + }, + } + const systemPlugins = { + prepare: async () => { + calls.push(['prepare-system-plugins']) + return { + activate: async () => { calls.push(['activate-system-plugins']) }, + commit: async () => { calls.push(['commit-system-plugins']) }, + rollback: async () => { calls.push(['rollback-system-plugins']) }, + } + }, + list: async () => [{ id: 'platform-management', installed: true }], + configure: async (id, action) => { + calls.push(['configure', id, action]) + return [{ id, installed: true }] + }, + recover: async (id, action) => { + calls.push(['recover', id, action]) + return [{ id, installed: false }] + }, + discard: async () => { + calls.push(['discard-system-plugins']) + return [{ id: 'platform-management', installed: true }] + }, + } + const server = createBootstrapControl(runner, { deployments, systemPlugins }) + const socket = join(root, 'run', 'bootstrap.sock') + await listenBootstrapControl(server, socket) + const client = new LocalApiClient(socket) + try { + assert.equal((await client.request('GET', '/v1/health')).healthy, true) + assert.deepEqual((await client.request('GET', '/v1/system-plugins')).plugins, [{ + id: 'platform-management', installed: true, + }]) + assert.deepEqual((await client.request('POST', '/v1/system-plugins/action', { + id: 'diagnostics', action: 'disable', + })).plugins, [{ id: 'diagnostics', installed: true }]) + assert.deepEqual((await client.request('POST', '/v1/system-plugins/recovery-action', { + id: 'platform-management', action: 'uninstall', + })).plugins, [{ id: 'platform-management', installed: false }]) + assert.deepEqual((await client.request('POST', '/v1/system-plugins/discard')).plugins, [{ + id: 'platform-management', installed: true, + }]) + await client.request('POST', '/v1/components/dsh-runtime/suspend') + await client.request('POST', '/v1/components/dsh-runtime/pause') + await client.request('POST', '/v1/components/dsh-runtime/resume') + await client.request('POST', '/v1/components/dsh-runtime/restart') + assert.deepEqual(calls, [ + ['configure', 'diagnostics', 'disable'], + ['recover', 'platform-management', 'uninstall'], + ['discard-system-plugins'], + ['suspend', 'dsh-runtime'], + ['pause', 'dsh-runtime'], + ['operation', 'starting'], + ['resume', 'dsh-runtime'], + ['status', 'published', null], + ['operation', 'restarting'], + ['prepare-system-plugins'], + ['restart', 'dsh-runtime'], + ['activate-system-plugins'], + ['commit-system-plugins'], + ['status', 'published', null], + ]) + assert.equal((await client.request('POST', '/v1/deployments/runtime/reset')).recordId, 'repaired') + assert.deepEqual(calls.slice(-3), [ + ['runtime-reset'], + ['pause', 'dsh-runtime'], + ['restart', 'dsh-runtime'], + ]) + systemPlugins.prepare = async () => { + calls.push(['prepare-system-plugins-failed']) + return { + activate: async () => { + calls.push(['activate-system-plugins-failed']) + throw new Error('overlay failed') + }, + rollback: async () => { calls.push(['rollback-system-plugins']) }, + } + } + await assert.rejects(client.request('POST', '/v1/components/dsh-runtime/restart'), /overlay failed/) + assert.deepEqual(calls.slice(-8), [ + ['operation', 'restarting'], + ['prepare-system-plugins-failed'], + ['restart', 'dsh-runtime'], + ['activate-system-plugins-failed'], + ['rollback-system-plugins'], + ['restart-recovered', 'dsh-runtime'], + ['status', 'published', { operation: 'restart-failed', recoveryMode: 'overlay failed' }], + ['report', 'bootstrap.request.failed', '/v1/components/dsh-runtime/restart'], + ]) + + runner.resume = async id => { + calls.push(['resume-failed', id]) + throw new Error('resume remained unavailable') + } + runner.status = () => ({ components: [], recoveryMode: 'resume remained unavailable' }) + await assert.rejects( + client.request('POST', '/v1/components/dsh-runtime/resume'), + /resume remained unavailable/, + ) + assert.deepEqual(calls.slice(-4), [ + ['operation', 'starting'], + ['resume-failed', 'dsh-runtime'], + ['status', 'published', { recoveryMode: 'resume remained unavailable' }], + ['report', 'bootstrap.request.failed', '/v1/components/dsh-runtime/resume'], + ]) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('loads the checked-in Control Plane independently from an Environment manifest', async () => { + const controlPlane = await loadControlPlane(join( + dirname(dirname(fileURLToPath(import.meta.url))), + '..', + 'control-plane', + )) + assert.equal(controlPlane.manifest.version, null) + assert.deepEqual(controlPlane.components.map(component => component.id), [ + 'gateway', 'access-manager', 'outbound-proxy', 'platform-recovery', 'platform-management', + ]) +}) + +test('keeps the Control Plane running while Environment operations replace DSH', async () => { + const calls = [] + const runner = (name, status) => ({ + fatal: new Promise(() => {}), + start: async () => { calls.push(`${name}:start`) }, + stop: async () => { calls.push(`${name}:stop`) }, + reload: async () => { calls.push(`${name}:reload`); return status }, + suspend: async id => { calls.push(`${name}:suspend:${id}`); return status }, + pause: async id => { calls.push(`${name}:pause:${id}`); return status }, + resume: async id => { calls.push(`${name}:resume:${id}`); return status }, + restart: async id => { calls.push(`${name}:restart:${id}`); return status }, + health: async () => ({ healthy: true, components: status.components }), + status: () => status, + }) + const controlPlane = runner('control', { + environmentVersion: null, + components: [{ id: 'gateway' }], + }) + const environmentRunner = runner('environment', { + environmentVersion: '1.0.0', + components: [{ id: 'dsh-runtime' }], + }) + const runtime = new BootstrapRuntime({ + controlPlane, + environment: environmentRunner, + onEnvironmentVerified: async () => { calls.push('environment:verified') }, + onDshAvailable: async () => { calls.push('dsh:available') }, + }) + + await runtime.start() + await runtime.suspend('dsh-runtime') + assert.equal(runtime.status().dshLifecycle.state, 'stopped') + await runtime.reload() + assert.equal(runtime.status().dshLifecycle.state, 'running') + await runtime.pause('dsh-runtime') + await runtime.resume('dsh-runtime') + assert.equal(runtime.status().dshLifecycle.state, 'running') + await runtime.restart('dsh-runtime') + await runtime.reload() + assert.equal((await runtime.health()).healthy, true) + assert.deepEqual(runtime.status().controlPlane, [{ id: 'gateway' }]) + await runtime.stop() + + assert.deepEqual(calls, [ + 'control:start', + 'environment:start', + 'environment:verified', + 'environment:suspend:dsh-runtime', + 'environment:reload', + 'environment:verified', + 'dsh:available', + 'environment:pause:dsh-runtime', + 'environment:resume:dsh-runtime', + 'environment:verified', + 'dsh:available', + 'environment:restart:dsh-runtime', + 'environment:verified', + 'dsh:available', + 'environment:reload', + 'environment:verified', + 'dsh:available', + 'environment:stop', + 'control:stop', + ]) +}) + +test('starts DSH alongside the Control Plane but gates readiness on access classification', async () => { + const calls = [] + let resolveEnvironmentStarted + const environmentStarted = new Promise(resolve => { resolveEnvironmentStarted = resolve }) + const runtime = new BootstrapRuntime({ + controlPlane: { + fatal: new Promise(() => {}), + start: async () => { calls.push('control:start'); await environmentStarted }, + stop: async () => { calls.push('control:stop') }, + status: () => ({ components: [{ id: 'access-manager' }] }), + }, + environment: { + fatal: new Promise(() => {}), + start: async () => { calls.push('environment:start'); resolveEnvironmentStarted() }, + stop: async () => { calls.push('environment:stop') }, + status: () => ({ environmentVersion: '1.0.0', components: [{ id: 'dsh-runtime' }] }), + }, + beforeReady: async () => { calls.push('access:classify') }, + }) + + await runtime.start() + assert.deepEqual(calls, ['control:start', 'environment:start', 'access:classify']) + await runtime.stop() + + const blocked = [] + let resolveBlockedEnvironmentStarted + const blockedEnvironmentStarted = new Promise(resolve => { resolveBlockedEnvironmentStarted = resolve }) + const failure = new Error('access classification failed') + const blockedRuntime = new BootstrapRuntime({ + controlPlane: { + fatal: new Promise(() => {}), + start: async () => { blocked.push('control:start'); await blockedEnvironmentStarted }, + stop: async () => { blocked.push('control:stop') }, + status: () => ({ components: [{ id: 'access-manager' }] }), + }, + environment: { + fatal: new Promise(() => {}), + start: async () => { blocked.push('environment:start'); resolveBlockedEnvironmentStarted() }, + stop: async () => { blocked.push('environment:stop') }, + status: () => ({ environmentVersion: '1.0.0', components: [] }), + }, + beforeReady: async () => { blocked.push('access:classify'); throw failure }, + }) + + await assert.rejects(blockedRuntime.start(), error => error === failure) + assert.deepEqual(blocked, [ + 'control:start', 'environment:start', 'access:classify', 'environment:stop', 'control:stop', + ]) +}) + +test('isolates a non-DSH Environment exit and keeps the Control Plane available', async () => { + const calls = [] + let emitEnvironmentFatal + const controlPlane = { + fatal: new Promise(() => {}), + start: async () => { calls.push('control:start') }, + stop: async () => { calls.push('control:stop') }, + status: () => ({ components: [{ id: 'gateway' }, { id: 'platform-management' }] }), + } + const environmentRunner = { + fatal: new Promise(() => {}), + onFatal: listener => { + emitEnvironmentFatal = listener + return () => { emitEnvironmentFatal = undefined } + }, + start: async () => { calls.push('environment:start') }, + stop: async () => { calls.push('environment:stop') }, + restart: async id => { calls.push(`environment:restart:${id}`); return { components: [{ id }] } }, + status: () => ({ environmentVersion: '1.0.0', components: [] }), + } + const runtime = new BootstrapRuntime({ + controlPlane, + environment: environmentRunner, + onEnvironmentFatal: async error => { calls.push(`recovery:${error.message}`) }, + }) + await runtime.start() + const failure = Object.assign(new Error('worker exited unexpectedly'), { componentId: 'worker' }) + emitEnvironmentFatal(failure) + await runtime.recovery + + assert.equal(runtime.status().recoveryMode, 'worker exited unexpectedly') + assert.deepEqual(runtime.status().controlPlane.map(component => component.id), ['gateway', 'platform-management']) + assert.deepEqual(calls, [ + 'control:start', + 'environment:start', + 'recovery:worker exited unexpectedly', + 'environment:stop', + ]) + assert.equal(await Promise.race([ + runtime.fatal.then(() => 'fatal'), + new Promise(resolve => setTimeout(() => resolve('available'), 20)), + ]), 'available') + + await runtime.restart('dsh-runtime') + assert.equal(runtime.status().recoveryMode, null) + await runtime.stop() +}) + +test('recovers an unexpected DSH exit without stopping the Control Plane', async () => { + const calls = [] + let emitEnvironmentFatal + const environmentRunner = { + fatal: new Promise(() => {}), + onFatal: listener => { emitEnvironmentFatal = listener; return () => {} }, + start: async () => { calls.push('environment:start') }, + stop: async () => { calls.push('environment:stop') }, + restart: async id => { calls.push(`environment:restart:${id}`); return { components: [{ id }] } }, + status: () => ({ environmentVersion: '1.0.3', components: [{ id: 'dsh-runtime' }] }), + } + const runtime = new BootstrapRuntime({ + controlPlane: { + fatal: new Promise(() => {}), + start: async () => { calls.push('control:start') }, + stop: async () => { calls.push('control:stop') }, + status: () => ({ components: [{ id: 'gateway' }] }), + }, + environment: environmentRunner, + validateDeployment: async () => { calls.push('validate') }, + prepareDeployment: async () => { calls.push('prepare') }, + onEnvironmentVerified: async () => { calls.push('environment:verified') }, + onDshRecovered: async () => { calls.push('recovery:published') }, + onDshAvailable: async () => { calls.push('dsh:available') }, + recoveryDelaysMs: [0, 2_000, 5_000], + sleep: async milliseconds => { calls.push(`delay:${String(milliseconds)}`) }, + }) + await runtime.start() + calls.length = 0 + emitEnvironmentFatal(Object.assign(new Error('dsh-runtime exited unexpectedly'), { componentId: 'dsh-runtime' })) + await runtime.recovery + + assert.deepEqual(calls, [ + 'delay:0', 'validate', 'prepare', 'environment:restart:dsh-runtime', + 'environment:verified', 'recovery:published', 'dsh:available', + ]) + assert.deepEqual(runtime.status().dshLifecycle, { + state: 'running', action: null, taskId: null, attempt: 0, maxAttempts: 3, + error: null, updatedAt: runtime.status().dshLifecycle.updatedAt, + }) + assert.equal(runtime.status().recoveryMode, null) + await runtime.stop() +}) + +test('requires exactly three bounded DSH recovery delays', () => { + const runner = { + fatal: new Promise(() => {}), onFatal: () => () => {}, + start: async () => {}, stop: async () => {}, status: () => ({ components: [] }), + } + assert.throws(() => new BootstrapRuntime({ + controlPlane: runner, environment: runner, recoveryDelaysMs: [0, 2_000], + }), /three non-negative/) + assert.throws(() => new BootstrapRuntime({ + controlPlane: runner, environment: runner, recoveryDelaysMs: [0, -1, 5_000], + }), /three non-negative/) +}) + +test('replaces a SIGKILLed DSH process while preserving the Control Plane', async () => { + const temp = await mkdtemp(join(tmpdir(), 'dsh-runtime-sigkill-recovery-')) + const service = join(temp, 'service.mjs') + await writeFile(service, 'setInterval(() => {}, 1000)') + const environmentRunner = new EnvironmentRunner({ + environmentRoot: await environment([component('dsh-runtime', service, 'service')]), + capture: () => {}, + }) + let controlStarts = 0 + const runtime = new BootstrapRuntime({ + controlPlane: { + fatal: new Promise(() => {}), + start: async () => { controlStarts += 1 }, + stop: async () => {}, + status: () => ({ components: [{ id: 'gateway', pid: 100 }] }), + }, + environment: environmentRunner, + recoveryDelaysMs: [0, 0, 0], + }) + await runtime.start() + runtime.markStartupComplete() + const firstPid = runtime.status().components.find(value => value.id === 'dsh-runtime').pid + process.kill(firstPid, 'SIGKILL') + + let secondPid = firstPid + for (let attempt = 0; attempt < 100; attempt += 1) { + secondPid = runtime.status().components.find(value => value.id === 'dsh-runtime')?.pid + if (runtime.status().dshLifecycle.state === 'running' && secondPid !== undefined && secondPid !== firstPid) break + await new Promise(resolve => setTimeout(resolve, 10)) + } + assert.notEqual(secondPid, firstPid) + assert.equal(runtime.status().dshLifecycle.state, 'running') + assert.equal(runtime.status().recoveryMode, null) + assert.equal(controlStarts, 1) + await runtime.stop() +}) + +test('does not restart DSH again when recovered-status publication fails', async () => { + let emitEnvironmentFatal + let restarts = 0 + let publications = 0 + const reports = [] + const runtime = new BootstrapRuntime({ + controlPlane: { + fatal: new Promise(() => {}), start: async () => {}, stop: async () => {}, + status: () => ({ components: [{ id: 'gateway' }] }), + }, + environment: { + fatal: new Promise(() => {}), + onFatal: listener => { emitEnvironmentFatal = listener; return () => {} }, + start: async () => {}, stop: async () => {}, + restart: async () => { restarts += 1 }, + status: () => ({ environmentVersion: '1.0.3', components: [{ id: 'dsh-runtime' }] }), + }, + onDshRecovered: async () => { throw new Error('status unavailable') }, + onDshAvailable: async () => { publications += 1 }, + recoveryDelaysMs: [0, 0, 0], + report: (message, fields) => { reports.push({ message, fields }) }, + }) + await runtime.start() + emitEnvironmentFatal(Object.assign(new Error('dsh-runtime exited'), { componentId: 'dsh-runtime' })) + await runtime.recovery + assert.equal(restarts, 1) + assert.equal(publications, 0) + assert.equal(runtime.status().dshLifecycle.state, 'running') + assert.equal(reports.some(entry => entry.message === 'dsh.recovery-status.failed'), true) + await runtime.stop() +}) + +test('limits unexpected DSH recovery to three attempts before recovery mode', async () => { + const delays = [] + const reports = [] + let emitEnvironmentFatal + let restarts = 0 + let stopped = 0 + let isolated = 0 + const runtime = new BootstrapRuntime({ + controlPlane: { + fatal: new Promise(() => {}), start: async () => {}, stop: async () => {}, + status: () => ({ components: [{ id: 'gateway' }] }), + }, + environment: { + fatal: new Promise(() => {}), + onFatal: listener => { emitEnvironmentFatal = listener; return () => {} }, + start: async () => {}, + stop: async () => { stopped += 1 }, + restart: async () => { restarts += 1; throw new Error(`restart ${String(restarts)} failed`) }, + status: () => ({ environmentVersion: '1.0.3', components: [] }), + }, + recoveryDelaysMs: [0, 2_000, 5_000], + sleep: async milliseconds => { delays.push(milliseconds) }, + onEnvironmentFatal: async () => { isolated += 1 }, + report: (message, fields) => { reports.push({ message, fields }) }, + }) + await runtime.start() + emitEnvironmentFatal(Object.assign(new Error('DSH repeatedly crashed'), { componentId: 'dsh-runtime' })) + await runtime.recovery + + assert.deepEqual(delays, [0, 2_000, 5_000]) + assert.equal(restarts, 3) + assert.equal(isolated, 1) + assert.equal(stopped, 1) + assert.equal(runtime.status().dshLifecycle.state, 'failed') + assert.equal(runtime.status().dshLifecycle.attempt, 3) + assert.equal(runtime.status().recoveryMode, 'DSH repeatedly crashed') + assert.deepEqual(reports.filter(entry => entry.message === 'dsh.recovery.attempt.failed') + .map(entry => entry.fields.attempt), [1, 2, 3]) + await runtime.stop() +}) + +test('defers unexpected DSH recovery while a Deployment transaction owns lifecycle', async () => { + let emitEnvironmentFatal + let restarts = 0 + let stops = 0 + const reports = [] + const runtime = new BootstrapRuntime({ + controlPlane: { + fatal: new Promise(() => {}), start: async () => {}, stop: async () => {}, + status: () => ({ components: [{ id: 'gateway' }] }), + }, + environment: { + fatal: new Promise(() => {}), + onFatal: listener => { emitEnvironmentFatal = listener; return () => {} }, + start: async () => {}, + stop: async () => { stops += 1 }, + restart: async () => { restarts += 1 }, + status: () => ({ environmentVersion: '1.0.3', components: [] }), + }, + ownsDshLifecycle: async () => true, + report: (message, fields) => { reports.push({ message, fields }) }, + }) + await runtime.start() + emitEnvironmentFatal(Object.assign(new Error('candidate exited'), { componentId: 'dsh-runtime' })) + await runtime.recovery + + assert.equal(restarts, 0) + assert.equal(stops, 0) + assert.equal(runtime.status().dshLifecycle.state, 'recovering') + assert.equal(runtime.status().dshLifecycle.attempt, 0) + assert.equal(reports.some(entry => entry.message === 'dsh.recovery.deferred'), true) + await runtime.stop() +}) + +test('enters recovery mode when an explicit DSH resume or restart fails', async () => { + const environment = { + fatal: new Promise(() => {}), + onFatal: () => () => {}, + start: async () => {}, + stop: async () => {}, + resume: async () => { throw new Error('DSH resume failed') }, + restart: async () => { throw new Error('DSH restart failed') }, + status: () => ({ environmentVersion: '1.0.0', components: [] }), + } + const runtime = new BootstrapRuntime({ + controlPlane: { + fatal: new Promise(() => {}), + start: async () => {}, + stop: async () => {}, + status: () => ({ components: [{ id: 'platform-management' }] }), + }, + environment, + }) + await runtime.start() + await assert.rejects(runtime.resume('dsh-runtime'), /DSH resume failed/) + assert.equal(runtime.status().recoveryMode, 'DSH resume failed') + await assert.rejects(runtime.restart('dsh-runtime'), /DSH restart failed/) + assert.equal(runtime.status().recoveryMode, 'DSH restart failed') + await runtime.stop() +}) + +test('reports secondary failures while isolating an unexpected Environment exit', async () => { + let emitEnvironmentFatal + const reports = [] + const runtime = new BootstrapRuntime({ + controlPlane: { + fatal: new Promise(() => {}), + start: async () => {}, + stop: async () => {}, + status: () => ({ components: [] }), + }, + environment: { + fatal: new Promise(() => {}), + onFatal: listener => { emitEnvironmentFatal = listener; return () => {} }, + start: async () => {}, + stop: async () => { throw new Error('environment cleanup failed') }, + status: () => ({ environmentVersion: 'env-1', components: [] }), + }, + onEnvironmentFatal: async () => { throw new Error('status publication failed') }, + report: (message, fields) => { reports.push({ message, fields }) }, + }) + await runtime.start() + emitEnvironmentFatal(new Error('dsh-runtime crashed')) + await runtime.recovery + assert.equal(reports.some(entry => entry.message === 'environment.recovery-report.failed' + && entry.fields.error.message === 'status publication failed'), true) + assert.equal(reports.some(entry => entry.message === 'environment.recovery-stop.failed' + && entry.fields.error.message === 'environment cleanup failed'), true) +}) + +test('validates mandatory Patches before every operation that starts DSH', async () => { + const calls = [] + const service = name => ({ + fatal: new Promise(() => {}), + start: async () => { calls.push(`${name}:start`) }, + stop: async () => { calls.push(`${name}:stop`) }, + reload: async () => { calls.push(`${name}:reload`) }, + suspend: async id => { calls.push(`${name}:suspend:${id}`) }, + resume: async id => { calls.push(`${name}:resume:${id}`) }, + restart: async id => { calls.push(`${name}:restart:${id}`) }, + health: async () => ({ healthy: true, components: [] }), + status: () => ({ components: [] }), + }) + let validations = 0 + const runtime = new BootstrapRuntime({ + controlPlane: service('control'), + environment: service('environment'), + validateDeployment: async () => { + validations += 1 + calls.push('validate') + if (validations === 1) throw new Error('Patch verification failed') + }, + }) + await runtime.start({ onEnvironmentFailure: async () => true }) + await runtime.reload() + await runtime.resume('dsh-runtime') + await runtime.restart('dsh-runtime') + await runtime.resume('gateway') + assert.deepEqual(calls, [ + 'control:start', + 'validate', + 'validate', + 'environment:start', + 'validate', + 'environment:reload', + 'validate', + 'environment:resume:dsh-runtime', + 'validate', + 'environment:restart:dsh-runtime', + 'environment:resume:gateway', + ]) +}) + +test('retries a previous Deployment without restarting the Control Plane', async () => { + const calls = [] + let environmentStarts = 0 + const controlPlane = { + fatal: new Promise(() => {}), + start: async () => { calls.push('control:start') }, + stop: async () => { calls.push('control:stop') }, + status: () => ({ components: [] }), + } + const environmentRunner = { + fatal: new Promise(() => {}), + start: async () => { + environmentStarts += 1 + calls.push(`environment:start:${String(environmentStarts)}`) + if (environmentStarts === 1) throw new Error('candidate failed') + }, + stop: async () => { calls.push('environment:stop') }, + status: () => ({ environmentVersion: 'test', components: [] }), + } + const runtime = new BootstrapRuntime({ controlPlane, environment: environmentRunner }) + await runtime.start({ + onEnvironmentFailure: async error => { + assert.match(error.message, /candidate failed/) + calls.push('deployment:restore') + return true + }, + }) + assert.deepEqual(calls, [ + 'control:start', + 'environment:start:1', + 'deployment:restore', + 'environment:start:2', + ]) +}) + +test('keeps DSH stopped when mandatory Patch verification fails for current and previous', async () => { + let validations = 0 + let environmentStarts = 0 + const controlPlane = { + fatal: new Promise(() => {}), + start: async () => {}, + stop: async () => {}, + status: () => ({ components: [{ id: 'gateway' }] }), + } + const environmentRunner = { + fatal: new Promise(() => {}), + start: async () => { environmentStarts += 1 }, + stop: async () => {}, + status: () => ({ environmentVersion: null, components: [] }), + } + const runtime = new BootstrapRuntime({ + controlPlane, + environment: environmentRunner, + validateDeployment: async () => { + validations += 1 + throw new Error(`Patch verification failed for ${validations === 1 ? 'current' : 'previous'}`) + }, + }) + await runtime.start({ + allowRecovery: true, + onEnvironmentFailure: async error => { + assert.match(error.message, /current/) + return true + }, + }) + assert.equal(validations, 2) + assert.equal(environmentStarts, 0) + assert.equal(runtime.status().recoveryMode, 'Deployment candidate and fallback both failed') + assert.deepEqual(runtime.status().controlPlane, [{ id: 'gateway' }]) +}) + +test('keeps the Control Plane alive in recovery mode when no Deployment starts', async () => { + const calls = [] + const controlPlane = { + fatal: new Promise(() => {}), + start: async () => { calls.push('control:start') }, + stop: async () => { calls.push('control:stop') }, + status: () => ({ components: [{ id: 'gateway' }] }), + } + const environmentRunner = { + fatal: new Promise(() => {}), + start: async () => { throw new Error('Runtime missing') }, + stop: async () => {}, + status: () => ({ environmentVersion: null, components: [] }), + } + const runtime = new BootstrapRuntime({ controlPlane, environment: environmentRunner }) + await runtime.start({ allowRecovery: true, onEnvironmentFailure: async () => false }) + assert.equal(runtime.status().recoveryMode, 'Runtime missing') + assert.deepEqual(runtime.status().controlPlane, [{ id: 'gateway' }]) + assert.deepEqual(calls, ['control:start']) +}) + +test('enters recovery mode when a failed candidate fallback cannot resolve', async () => { + const calls = [] + const controlPlane = { + fatal: new Promise(() => {}), + start: async () => { calls.push('control:start') }, + stop: async () => { calls.push('control:stop') }, + status: () => ({ components: [{ id: 'gateway' }] }), + } + const environmentRunner = { + fatal: new Promise(() => {}), + start: async () => { throw new Error('candidate unhealthy') }, + stop: async () => {}, + status: () => ({ environmentVersion: null, components: [] }), + } + const runtime = new BootstrapRuntime({ controlPlane, environment: environmentRunner }) + await runtime.start({ + allowRecovery: true, + onEnvironmentFailure: async () => { throw new Error('previous image is unavailable') }, + }) + assert.equal(runtime.status().recoveryMode, 'Deployment failed and its fallback could not be resolved') + assert.deepEqual(calls, ['control:start']) +}) diff --git a/upstream/container/platform/test/bump-environment.test.mjs b/upstream/container/platform/test/bump-environment.test.mjs new file mode 100644 index 0000000..336c55c --- /dev/null +++ b/upstream/container/platform/test/bump-environment.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { chmod, mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import test from 'node:test' + +async function fixture(environment = '2.3.4') { + const root = await mkdtemp(join(tmpdir(), 'dsh-bump-environment-')) + const definitionPath = join(root, 'container/environment/definition.json') + const targetPath = join(root, 'release/supported-target.json') + const scriptPath = join(root, 'scripts/bump-environment.sh') + await mkdir(join(root, 'container/environment'), { recursive: true }) + await mkdir(join(root, 'container/platform'), { recursive: true }) + await mkdir(join(root, 'release'), { recursive: true }) + await mkdir(join(root, 'scripts'), { recursive: true }) + await symlink(new URL('../lib', import.meta.url).pathname, join(root, 'container/platform/lib')) + await symlink(new URL('../tools', import.meta.url).pathname, join(root, 'container/platform/tools')) + await writeFile(scriptPath, await readFile(new URL('../../../scripts/bump-environment.sh', import.meta.url))) + await chmod(scriptPath, 0o755) + await writeFile(definitionPath, `${JSON.stringify({ version: environment, bootstrapApi: 1 }, null, 2)}\n`) + await writeFile(targetPath, `${JSON.stringify({ + schema: 1, + latestSupportedDsh: '9.8.7-rc.6', + environment, + }, null, 2)}\n`) + return { definitionPath, root, scriptPath, targetPath } +} + +function run(paths, version) { + return spawnSync(paths.scriptPath, [version], { cwd: paths.root, encoding: 'utf8' }) +} + +test('bumps both authoritative Environment versions and is idempotent', async () => { + const paths = await fixture() + let result = run(paths, '2.4.0') + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(JSON.parse(result.stdout), { changed: true, previousVersion: '2.3.4', version: '2.4.0' }) + assert.equal(JSON.parse(await readFile(paths.definitionPath, 'utf8')).version, '2.4.0') + const target = JSON.parse(await readFile(paths.targetPath, 'utf8')) + assert.equal(target.environment, '2.4.0') + assert.equal(target.latestSupportedDsh, '9.8.7-rc.6') + result = run(paths, '2.4.0') + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(JSON.parse(result.stdout), { changed: false, version: '2.4.0' }) +}) + +test('rejects invalid, rollback, and mismatched Environment versions without writing', async () => { + const paths = await fixture() + let result = run(paths, 'not-a-version') + assert.notEqual(result.status, 0) + assert.match(result.stderr, /semantic version/) + result = run(paths, '2.3.3') + assert.notEqual(result.status, 0) + assert.match(result.stderr, /roll back/) + assert.equal(JSON.parse(await readFile(paths.definitionPath, 'utf8')).version, '2.3.4') + assert.equal(JSON.parse(await readFile(paths.targetPath, 'utf8')).environment, '2.3.4') + + await writeFile(paths.targetPath, `${JSON.stringify({ + schema: 1, + latestSupportedDsh: '9.8.7-rc.6', + environment: '2.3.3', + }, null, 2)}\n`) + result = run(paths, '2.4.0') + assert.notEqual(result.status, 0) + assert.match(result.stderr, /does not match/) +}) diff --git a/upstream/container/platform/test/channel-state.test.mjs b/upstream/container/platform/test/channel-state.test.mjs new file mode 100644 index 0000000..8d130ec --- /dev/null +++ b/upstream/container/platform/test/channel-state.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { ChannelStateStore, planDesiredState } from '../../control-plane/modules/updater/lib/channel-state.mjs' + +const current = { dsh: '0.1.0-rc.7', environment: 'env-1', runtime: 'runtime-a' } +const supported = { dsh: '0.1.0-rc.7', environment: 'env-1' } + +test('persists channel selection and retryable version or combination Holds', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-channel-state-')) + const store = new ChannelStateStore(join(root, 'channel.json'), () => new Date('2026-08-19T00:00:00.000Z')) + assert.equal((await store.read()).updateChannel, 'stable') + await store.setChannel('experimental') + const version = await store.addHold({ type: 'version', dshVersion: '0.1.0-rc.8', reason: 'build failed' }) + const combination = await store.addHold({ + type: 'combination', dshVersion: '0.1.0-rc.9', environmentVersion: 'env-1', reason: 'health failed', + }) + assert.deepEqual((await store.read()).holds.map(hold => hold.type), ['version', 'combination']) + await store.retry(version.id) + assert.deepEqual((await store.read()).holds.map(hold => hold.id), [combination.id]) +}) + +test('plans Stable convergence before Experimental and freezes Ahead-of-Stable combinations', () => { + const local = { updateChannel: 'experimental', holds: [], experimentalBlocked: null } + assert.equal(planDesiredState({ + local, current: { ...current, environment: 'env-old' }, supported, + upstream: { version: '0.1.0-rc.8' }, + }).action, 'stable') + assert.equal(planDesiredState({ + local, current: { ...current, dsh: '0.1.0-rc.8' }, supported, + upstream: { version: '0.1.0-rc.9' }, + }).action, 'frozen') + assert.equal(planDesiredState({ local, current, supported, upstream: { version: '0.1.0-rc.8' } }).action, 'experimental') + assert.equal(planDesiredState({ + local, + current: { ...current, authority: 'development', targetSequence: 0 }, + supported, stableTargetSequence: 11, + upstream: { version: '0.1.0-rc.8' }, + }).action, 'stable') + assert.equal(planDesiredState({ + local, + current: { ...current, authority: 'stable', targetSequence: 11 }, + supported, stableTargetSequence: 11, + upstream: { version: '0.1.0-rc.8' }, + }).action, 'experimental') +}) + +test('suppresses only the held Experimental candidate and Environment combination', () => { + const local = { + updateChannel: 'experimental', + experimentalBlocked: null, + holds: [{ + id: 'hold', type: 'combination', dshVersion: '0.1.0-rc.8', environmentVersion: 'env-1', + reason: 'incompatible', createdAt: '2026-08-19T00:00:00.000Z', + }], + } + const plan = planDesiredState({ local, current, supported, upstream: { version: '0.1.0-rc.8' } }) + assert.equal(plan.action, 'held') + assert.equal(plan.hold.id, 'hold') +}) + +test('retries an Experimental Block independently of ordinary Holds', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-channel-block-')) + const store = new ChannelStateStore(join(root, 'channel.json'), () => new Date('2026-08-19T00:00:00.000Z')) + const blocked = await store.block({ + dshVersion: '0.1.0-rc.8', environmentVersion: 'env-2', reason: 'combination failed', + }) + assert.equal((await store.read()).experimentalBlocked.id, blocked.experimentalBlocked.id) + await store.retry(blocked.experimentalBlocked.id) + assert.equal((await store.read()).experimentalBlocked, null) +}) diff --git a/upstream/container/platform/test/container-integration.test.mjs b/upstream/container/platform/test/container-integration.test.mjs new file mode 100644 index 0000000..e16119b --- /dev/null +++ b/upstream/container/platform/test/container-integration.test.mjs @@ -0,0 +1,233 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('container smoke targets ephemeral views and the separated persistent layout', async () => { + const script = await readFile(new URL('../../test/container-smoke.sh', import.meta.url), 'utf8') + assert.match(script, /\/run\/dsh-platform\/views\/runtime/) + assert.match(script, /\/run\/dsh-platform\/bootstrap\.sock/) + assert.match(script, /\/data\/platform\/state\/updater\/transaction\.json/) + assert.match(script, /\/data\/platform\/store\/snapshots/) + assert.match(script, /recovery\.sock/) + assert.match(script, /dsh-platform restart/) + assert.match(script, /dsh-platform stop/) + assert.match(script, /dsh web --no-open/) + assert.match(script, /requested managed DSH start/) + assert.match(script, /bundled-plugins\/action/) + assert.match(script, /"action":"uninstall"/) + assert.match(script, /managed by the platform/) + assert.match(script, /\.dshLifecycle\.taskId == \$task and \.dshLifecycle\.state == "running"/) + assert.match(script, /stage0_pid/) + assert.match(script, /\.source == "stage0" and \.message == "stage0\.ready"/) + assert.match(script, /\.source == "bootstrap" and \.message == "platform\.ready"/) + assert.match(script, /\.source == "gateway" and \.message == "gateway\.ready"/) + assert.match(script, /dsh_pid/) + assert.match(script, /kill -9 "\$dsh_pid"/) + assert.match(script, /managed-lifecycle-restart-smoke\.mjs/) + assert.match(script, /\.helperExitCode == 0 and \.sawRestarting == true and \.dshCount == 1/) + assert.match(script, /\.dshLifecycle\.state == "running" and \.recoveryMode == null/) + assert.match(script, /DSH recovered after an explicit stop/) + assert.match(script, /\.RestartCount/) + assert.match(script, /gateway\.upstream\.failed/) + assert.match(script, /gateway\.upstream\.recovered/) + assert.match(script, /standalone-recovery-smoke\.mjs/) + assert.match(script, /standalone-file-management-smoke\.mjs/) + assert.match(script, /profile-package-compatibility-smoke\.sh/) + assert.match(script, /--group-add dsh-sudo-false/) + assert.match(script, /sudo -n true/) + assert.match(script, /--unix-socket \/run\/dsh-platform\/maintenance\.sock/) + assert.match(script, /file-task\.copy\.completed/) + assert.match(script, /files\.content\.saved/) + assert.match(script, /runtime\.reset\.completed/) + assert.match(script, /runtime-reset-sentinel/) + assert.match(script, /terminal\.session\.created/) + assert.match(script, /user-plugin\.apply\.completed/) + assert.match(script, /readiness_timeout_ms=30000/) + assert.match(script, /platform readiness exceeded 30 seconds/) + assert.match(script, /DSH orphaned-bundle restart did not complete within the readiness timeout/) + assert.match(script, /DSH started before interrupted update recovery completed/) + assert.doesNotMatch(script, /echo "DSH restart did not complete"/) + assert.match(script, /clear only \/data\/platform/) + assert.match(script, /Do not delete \/data\/dsh/) + assert.doesNotMatch(script, /\/data\/platform\/(?:runtime|environments|system-plugins|bootstrap|run)\//) +}) + +test('Bootstrap resolves update and User Plugin recovery before concurrent startup', async () => { + const bootstrap = await readFile(new URL('../bootstrap/index.mjs', import.meta.url), 'utf8') + const updateRecovery = bootstrap.indexOf('await recoverPlatformUpdateBeforeDshStart(') + const userPluginRecovery = bootstrap.indexOf('await recoverUserPluginBeforeDshStart(') + const runtimeStart = bootstrap.indexOf('await runtime.start({') + assert.ok(updateRecovery >= 0) + assert.ok(userPluginRecovery > updateRecovery) + assert.ok(runtimeStart > userPluginRecovery) +}) + +test('Profile compatibility smoke migrates old stores and preserves node ownership', async () => { + const script = await readFile(new URL('../../test/profile-package-compatibility-smoke.sh', import.meta.url), 'utf8') + assert.match(script, /dsh-platform stop --wait/) + assert.match(script, /pnpm install --offline --frozen-lockfile/) + assert.match(script, /dsh-platform start --wait/) + assert.match(script, /npm_config_userconfig=\/root\/\.config\/npm\/npmrc/) + assert.match(script, /dsh plugin --profile web add/) + assert.match(script, /stat -c %U/) + assert.match(script, /dsh plugin --profile web remove/) +}) + +test('the public DSH shim keeps Profile operations under the node identity', async () => { + const script = await readFile(new URL('../tools/dsh-shim.sh', import.meta.url), 'utf8') + assert.match(script, /if \[ "\$\(id -u\)" -eq 0 \]/) + assert.match(script, /--reuid=1000/) + assert.match(script, /--regid=1000/) + assert.match(script, /--keep-groups/) + assert.match(script, /-u npm_config_userconfig/) + assert.match(script, /-u NPM_CONFIG_USERCONFIG/) + assert.match(script, /-u PNPM_HOME/) + assert.match(script, /HOME=\/home\/node/) + assert.match(script, /XDG_CONFIG_HOME=\/home\/node\/\.config/) + assert.match(script, /XDG_STATE_HOME=\/home\/node\/\.local\/state/) + assert.match(script, /DSH_HOME="\$\{DSH_HOME:-\/data\/dsh\}"/) + assert.doesNotMatch(script, /HOME=\/root|XDG_CONFIG_HOME=\/root/) +}) + +test('managed lifecycle smoke rejects replacement instances and converts SIGTERM into restart', async () => { + const script = await readFile(new URL('../../test/managed-lifecycle-restart-smoke.mjs', import.meta.url), 'utf8') + assert.match(script, /spawn\(DSH_EXECUTABLE, \['web', '--no-open'\]/) + assert.match(script, /detached: true/) + assert.match(script, /DSH_PLATFORM_LAUNCH_TOKEN: ''/) + assert.match(script, /process\.kill\(oldPid, 'SIGTERM'\)/) + assert.match(script, /unregistered SIGTERM did not create a managed restart task/) + assert.match(script, /newPids\.length === 1/) + assert.doesNotMatch(script, /dsh-market|better-sidebar|net-proxy/) +}) + +test('standalone file smoke covers recovery-mode transfer, edit, search, symlink, and mutations', async () => { + const script = await readFile(new URL('../../test/standalone-file-management-smoke.mjs', import.meta.url), 'utf8') + assert.match(script, /files\/upload/) + assert.match(script, /range: 'bytes=3-6'/) + assert.match(script, /edited in recovery mode/) + assert.match(script, /operation: 'copy'/) + assert.match(script, /operation: 'move'/) + assert.match(script, /operation: 'delete'/) + assert.match(script, /operation: 'search'/) + assert.match(script, /operation: 'size'/) + assert.match(script, /operation: 'attributes'/) + assert.match(script, /recursive: true/) + assert.match(script, /type, 'symlink'/) + assert.match(script, /\/data\/dsh\/file-management-smoke/) + assert.match(script, /\/root\/dsh-maintenance-smoke/) + assert.match(script, /config\.privileged, true/) + assert.match(script, /\['queued', 'running'\]\.includes\(state\.status\)/) +}) + +test('standalone recovery smoke covers live terminal and consecutive faulty plugins', async () => { + const script = await readFile(new URL('../../test/standalone-recovery-smoke.mjs', import.meta.url), 'utf8') + assert.match(script, /terminal-before-restart/) + assert.match(script, /terminal-after-restart-中文/) + assert.match(script, /operation\?\.status \?\? operation\?\.state/) + assert.match(script, /uid=0/) + assert.match(script, /cwd=\\\/root/) + assert.match(script, /smoke-fault-one/) + assert.match(script, /smoke-fault-two/) + assert.match(script, /firstOutcome/) + assert.match(script, /userPluginOperation/) + assert.match(script, /recoveryMode === null/) + assert.match(script, /assert\.doesNotMatch\(JSON\.stringify\(logEntries\.filter/) +}) + +test('Stage-0 owns privileged User Skill mutations behind a constrained local API', async () => { + const [stage0, management, paths] = await Promise.all([ + readFile(new URL('../stage0/index.mjs', import.meta.url), 'utf8'), + readFile(new URL('../../control-plane/services/management/index.mjs', import.meta.url), 'utf8'), + readFile(new URL('../lib/paths.mjs', import.meta.url), 'utf8'), + ]) + assert.match(stage0, /prepareUserSkillRoots, UserSkillManager/) + assert.match(stage0, /new UserSkillManager\(\{ dshHome, agentsHome \}\)/) + assert.match(stage0, /listenUserSkills\(userSkillServer, paths\.userSkillSocket\)/) + assert.match(management, /new LocalApiClient\(paths\.userSkillSocket\)/) + assert.match(management, /configureUserSkill: value => userSkillApi\.request\('POST', '\/v1\/action', value\)/) + assert.match(paths, /this\.userSkillSocket = join\(this\.runRoot, 'user-skills\.sock'\)/) +}) + +test('isolated service identities can traverse the runtime root without inheriting platform socket access', async () => { + const paths = await readFile(new URL('../lib/paths.mjs', import.meta.url), 'utf8') + const accessLaunch = await readFile(new URL('../stage0/lib/access-launch-server.mjs', import.meta.url), 'utf8') + assert.match(paths, /chmod\(paths\.runRoot, 0o1771\)/) + assert.match(accessLaunch, /'--clear-groups'/) + assert.doesNotMatch(accessLaunch, /--groups=/) +}) + +test('Docker health reports Gateway control-plane liveness independently of DSH', async () => { + const dockerfile = await readFile(new URL('../../../Dockerfile', import.meta.url), 'utf8') + const healthcheck = dockerfile.split('HEALTHCHECK')[1]?.split('\n\n')[0] ?? '' + assert.match(healthcheck, /--start-period=60s/) + assert.match(healthcheck, /127\.0\.0\.1:3080\/_dsh_gateway\/health/) + assert.doesNotMatch(healthcheck, /127\.0\.0\.1:3079\/|stage0-trust\.sock/) +}) + +test('Management readiness does not depend on an authenticated management API', async () => { + const component = JSON.parse(await readFile(new URL('../../control-plane/services/management/component.json', import.meta.url))) + assert.equal(component.health.type, 'exec') + assert.equal(component.health.command.executable, '/usr/local/bin/node') + assert.deepEqual(component.health.command.args, [ + '/run/dsh-platform/views/bootstrap/control-plane/services/management/healthcheck.mjs', + ]) + const probe = await readFile(new URL('../../control-plane/services/management/healthcheck.mjs', import.meta.url), 'utf8') + assert.match(probe, /\/_dsh_platform\/internal\/health/) + assert.doesNotMatch(probe, /\/_dsh_platform\/api\/v1/) +}) + +test('public platform CLI and Gateway status probes use a separate restricted Management socket', async () => { + const [paths, cli, management, gateway] = await Promise.all([ + readFile(new URL('../lib/paths.mjs', import.meta.url), 'utf8'), + readFile(new URL('../../control-plane/services/management/cli.mjs', import.meta.url), 'utf8'), + readFile(new URL('../../control-plane/services/management/index.mjs', import.meta.url), 'utf8'), + readFile(new URL('../../control-plane/services/gateway/lib/lifecycle.mjs', import.meta.url), 'utf8'), + ]) + assert.match(paths, /this\.managementCliSocket = join\(this\.runRoot, 'management-cli\.sock'\)/) + assert.match(cli, /DSH_PLATFORM_MANAGEMENT_CLI_SOCKET.*management-cli\.sock/) + assert.match(management, /restrictedCliRoute/) + assert.match(gateway, /DSH_PLATFORM_MANAGEMENT_CLI_SOCKET.*management-cli\.sock/) +}) + +test('Docker installs locked Management dependencies inside the Seed stage only', async () => { + const dockerfile = await readFile(new URL('../../../Dockerfile', import.meta.url), 'utf8') + const ignore = await readFile(new URL('../../../.dockerignore', import.meta.url), 'utf8') + const seed = await readFile(new URL('../stage0/lib/seed.mjs', import.meta.url), 'utf8') + const buildSeed = await readFile(new URL('../tools/build-seed.mjs', import.meta.url), 'utf8') + assert.match(dockerfile, /npm ci --omit=dev --ignore-scripts --no-audit --no-fund[\s\S]*--prefix \/opt\/dsh-platform-source\/control-plane\/services\/management/) + assert.match(dockerfile, /npm ci --omit=dev --ignore-scripts --no-audit --no-fund --legacy-peer-deps[\s\S]*--prefix \/opt\/dsh-platform-source\/environment\/resources\/plugins\/platform-management\/package/) + assert.match(dockerfile, /util-linux/) + assert.match(dockerfile, /\n\s+g\+\+ \\\n/) + assert.match(dockerfile, /\n\s+make \\\n/) + assert.equal(ignore.split('\n').includes('**/node_modules'), true) + assert.match(seed, /paths\.managementStateRoot/) + assert.match(seed, /paths\.managementStateRoot, paths\.fileTasksRoot/) + assert.match(seed, /paths\.userPluginSnapshotsRoot/) + assert.match(buildSeed, /file-manager/) + const managementManifest = dockerfile.indexOf('COPY container/control-plane/services/management/package.json') + const pluginManifest = dockerfile.indexOf('COPY container/environment/resources/plugins/platform-management/package/package.json') + const dependencyInstall = dockerfile.indexOf('RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund') + const sourceCopy = dockerfile.indexOf('COPY container /opt/dsh-platform-source') + assert.ok(managementManifest >= 0 && pluginManifest > managementManifest) + assert.ok(dependencyInstall > pluginManifest && sourceCopy > dependencyInstall) +}) + +test('local Docker builds resolve the default DSH from the supported target without a sticky latest cache', async () => { + const dockerfile = await readFile(new URL('../../../Dockerfile', import.meta.url), 'utf8') + assert.match(dockerfile, /COPY release\/supported-target\.json \/opt\/dsh-supported-target\.json/) + assert.match(dockerfile, /ARG SIGNED_IMAGE_INPUT=false/) + assert.match(dockerfile, /case "\$SIGNED_IMAGE_INPUT" in[\s\S]*true\)[\s\S]*npm install --global \/opt\/dsh-platform-image-input\/dsh\.tgz[\s\S]*false\)[\s\S]*latestSupportedDsh/) + assert.match(dockerfile, /if \[ "\$requested_version" = latest \]; then[\s\S]*latestSupportedDsh/) + assert.match(dockerfile, /npm install --global "@deepseek-ai\/dsh@\$\{requested_version\}"/) + assert.match(dockerfile, /false\) image_input=-/) + assert.doesNotMatch(dockerfile, /if \[ -f \/opt\/dsh-platform-(?:image-input|source\/platform\/image-input)/) +}) + +test('formal images identify DSH, Environment, target sequence, and the signed update channel', async () => { + const dockerfile = await readFile(new URL('../../../Dockerfile', import.meta.url), 'utf8') + assert.match(dockerfile, /org\.opencontainers\.image\.version="\$DSH_VERSION"/) + assert.match(dockerfile, /io\.dsh-docker\.environment\.version="\$ENVIRONMENT_VERSION"/) + assert.match(dockerfile, /io\.dsh-docker\.target-sequence="\$TARGET_SEQUENCE"/) + assert.match(dockerfile, /DSH_UPDATE_METADATA_URL=https:\/\/raw\.githubusercontent\.com\/yjrszcq\/dsh-docker\/release-channel\//) + assert.doesNotMatch(dockerfile, /releases\/latest\/download/) +}) diff --git a/upstream/container/platform/test/contracts.test.mjs b/upstream/container/platform/test/contracts.test.mjs new file mode 100644 index 0000000..adc29be --- /dev/null +++ b/upstream/container/platform/test/contracts.test.mjs @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict' +import { generateKeyPairSync } from 'node:crypto' +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import test from 'node:test' +import { canonicalJson } from '../lib/canonical-json.mjs' +import { parseBootstrapManifest, parseComponentManifest, parseEnvironmentManifest, parseOfficialDshPolicy, parseStable } from '../lib/contracts.mjs' +import { + deriveImageBuildId, + deriveRecordId, + parseArtifactReference, + parseBootstrapRecord, + parseDeploymentRecord, + parseImageInventory, + parseSlots, + recordsFromImageInventory, +} from '../lib/deployment-contracts.mjs' +import { verifyDetached } from '../stage0/lib/signature.mjs' +import { document, officialDshPolicy, registryKeyPair, target } from './helpers.mjs' + +const common = manifestType => ({ + schema: 1, + manifestType, + version: manifestType === 'bootstrap' ? '1.0.0' : '1.0.0', + keyringGeneration: 1, + targetSequence: 1, + issuedAt: '2026-08-19T00:00:00.000Z', + artifacts: [], +}) + +const lifecycle = { + prepare: null, + preStart: null, + postStart: null, + preStop: null, + stop: null, + postStop: null, +} + +test('canonical JSON recursively orders keys and rejects unsafe numbers', () => { + assert.equal(canonicalJson({ z: 1, a: { y: 2, x: 3 } }).toString(), '{"a":{"x":3,"y":2},"z":1}\n') + assert.throws(() => canonicalJson({ value: 1.5 })) +}) + +function imageInventory(overrides = {}) { + const content = { + schema: 1, + authority: 'stable', + platformRevision: 'fixture-revision', + targetSequence: 10, + bootstrapApi: 1, + updateApi: 1, + bootstrap: { version: '1.0.0', id: 'bootstrap-fixture', sha256: '1'.repeat(64) }, + deployment: { + id: 'deployment-fixture', + dshVersion: '0.1.0-rc.10', + environmentVersion: '1.0.0', + environment: { id: 'environment-fixture', sha256: '2'.repeat(64) }, + pristine: { id: 'pristine-fixture', sha256: '3'.repeat(64) }, + runtime: { id: 'runtime-fixture', sha256: '4'.repeat(64) }, + systemPlugins: { id: 'system-plugins-fixture', sha256: '5'.repeat(64) }, + }, + ...overrides, + } + return { ...content, imageBuildId: deriveImageBuildId(content) } +} + +test('parses content-bound image inventory and derives immutable image Records', () => { + const inventory = parseImageInventory(document(imageInventory())) + const records = recordsFromImageInventory(inventory) + assert.match(inventory.imageBuildId, /^sha256:[a-f0-9]{64}$/) + assert.equal(records.bootstrap.version, '1.0.0') + assert.equal(records.deployment.runtime.imageBuildId, inventory.imageBuildId) + assert.equal(records.deployment.targetSequence, 10) + assert.doesNotThrow(() => parseBootstrapRecord(records.bootstrap)) + assert.doesNotThrow(() => parseDeploymentRecord(records.deployment)) + + const changed = imageInventory({ platformRevision: 'different-revision' }) + assert.notEqual(changed.imageBuildId, inventory.imageBuildId) + assert.throws(() => parseImageInventory(document({ ...imageInventory(), targetSequence: 11 })), /imageBuildId/) +}) + +test('restricts inventory authority and Artifact References without accepting paths', () => { + const development = imageInventory({ authority: 'development', targetSequence: 0 }) + development.imageBuildId = deriveImageBuildId(Object.fromEntries(Object.entries(development).filter(([key]) => key !== 'imageBuildId'))) + assert.equal(parseImageInventory(document(development)).targetSequence, 0) + assert.throws(() => parseImageInventory(document(imageInventory({ authority: 'development' }))), /targetSequence 0/) + assert.throws(() => parseArtifactReference({ + storage: 'store', kind: 'runtime', id: '../escape', sha256: 'a'.repeat(64), + }), /invalid/) + assert.throws(() => parseArtifactReference({ + storage: 'store', kind: 'runtime', id: 'runtime-one', sha256: 'a'.repeat(64), path: '/tmp/runtime', + }), /fields/) + assert.throws(() => parseArtifactReference({ + storage: 'image', imageBuildId: `sha256:${'b'.repeat(64)}`, kind: 'unknown', id: 'one', sha256: 'a'.repeat(64), + }), /kind/) +}) + +test('rejects mutated Records and invalid atomic slot generations', () => { + const { bootstrap, deployment } = recordsFromImageInventory(parseImageInventory(document(imageInventory()))) + assert.throws(() => parseDeploymentRecord({ ...deployment, dshVersion: 'changed' }), /canonical content/) + assert.throws(() => parseBootstrapRecord({ ...bootstrap, artifact: { ...bootstrap.artifact, id: 'changed' } }), /canonical content/) + assert.deepEqual(parseSlots({ + schema: 1, generation: 2, current: deployment.id, previous: null, + }, 'deployment-record'), { + schema: 1, generation: 2, current: deployment.id, previous: null, + }) + assert.throws(() => parseSlots({ + schema: 1, generation: 0, current: deployment.id, previous: null, + }, 'deployment-record'), /positive/) + assert.throws(() => parseSlots({ + schema: 1, generation: 1, current: deployment.id, previous: deployment.id, + }, 'deployment-record'), /must differ/) + const content = { schema: 1, authority: 'development' } + assert.equal(deriveRecordId('fixture', content), deriveRecordId('fixture', { authority: 'development', schema: 1 })) +}) + +test('parses the exact stable desired state and rejects missing referenced Artifacts', () => { + const parsed = parseStable(document(target(1, 1))) + assert.equal(parsed.desired.dsh.version, '0.1.0-rc.7') + const invalid = target(1, 1) + invalid.desired.bootstrap.manifestArtifactId = 'missing' + assert.throws(() => parseStable(document(invalid)), /missing/) + assert.throws(() => parseStable(document({ ...target(1, 1), unknown: true })), /fields/) +}) + +test('parses the Release-delegated official DSH policy with Stable schema 1', () => { + const policy = officialDshPolicy(registryKeyPair()) + assert.equal(parseOfficialDshPolicy(policy).packageName, '@deepseek-ai/dsh') + assert.equal(parseStable(document(target(1, 1, policy))).officialDshPolicy.keys[0].keyId, policy.keys[0].keyId) + const wrongRegistry = { ...policy, registry: 'https://registry.example/' } + assert.throws(() => parseOfficialDshPolicy(wrongRegistry), /official npm registry/) +}) + +test('parses bootstrap and environment manifests with ordered references', () => { + const bootstrap = { ...common('bootstrap'), bootstrapApi: 1, entrypoint: '/opt/bootstrap/index.mjs' } + assert.equal(parseBootstrapManifest(document(bootstrap)).entrypoint, bootstrap.entrypoint) + const hashes = { + component: '1'.repeat(64), + patch: '2'.repeat(64), + plugin: '3'.repeat(64), + } + const environment = { + ...common('environment'), + artifacts: Object.entries(hashes).map(([id, sha256]) => ({ + id, + mediaType: 'application/octet-stream', + sha256, + size: 1, + url: `https://release.example/${id}`, + })), + bootstrapApi: 1, + components: [{ id: 'gateway', sha256: hashes.component }], + patches: [{ id: 'directory-picker', sha256: hashes.patch }], + systemPlugins: [{ id: 'update-ui', sha256: hashes.plugin }], + } + assert.equal(parseEnvironmentManifest(document(environment)).components[0].id, 'gateway') + assert.throws(() => parseEnvironmentManifest(document({ ...environment, components: [...environment.components, environment.components[0]] })), /unique/) +}) + +test('parses service, oneshot, and hook component lifecycle declarations', () => { + const value = { + schema: 1, + id: 'gateway', + type: 'service', + command: { executable: '/usr/local/bin/node', args: ['/opt/gateway/index.mjs'], timeoutSeconds: 30 }, + environment: { PLATFORM_SOCKET: '/data/platform/run/platform.sock' }, + lifecycle, + health: { type: 'http', host: '127.0.0.1', port: 3080, path: '/health', intervalSeconds: 1, timeoutSeconds: 5 }, + logging: { stdout: true, stderr: true }, + } + assert.equal(parseComponentManifest(document(value)).type, 'service') + assert.throws(() => parseComponentManifest(document({ ...value, version: '1.0.0' })), /fields/) + assert.throws(() => parseComponentManifest(document({ ...value, type: 'unknown' })), /type/) + assert.throws(() => parseComponentManifest(document({ ...value, command: { ...value.command, executable: 'node' } })), /absolute/) +}) + +test('offline signing tool emits a signature accepted by Stage-0', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-sign-tool-')) + const pair = generateKeyPairSync('ed25519') + const privatePath = join(root, 'private.pem') + const documentPath = join(root, 'document.json') + const signaturePath = join(root, 'document.sig.json') + await writeFile(privatePath, pair.privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 }) + const bytes = canonicalJson({ stable: true }) + await writeFile(documentPath, bytes) + const result = spawnSync(process.execPath, [ + new URL('../tools/sign.mjs', import.meta.url).pathname, + 'sign', privatePath, documentPath, signaturePath, + ], { encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + const publicKey = pair.publicKey.export({ format: 'der', type: 'spki' }).toString('base64') + const signature = JSON.parse(await readFile(signaturePath, 'utf8')) + assert.doesNotThrow(() => verifyDetached(bytes, signature, publicKey)) +}) diff --git a/upstream/container/platform/test/deployments.test.mjs b/upstream/container/platform/test/deployments.test.mjs new file mode 100644 index 0000000..b1597e5 --- /dev/null +++ b/upstream/container/platform/test/deployments.test.mjs @@ -0,0 +1,953 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { chmod, lstat, mkdtemp, mkdir, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { DeploymentManager, DeploymentResolutionError } from '../bootstrap/lib/deployments.mjs' +import { PlatformGarbageCollector } from '../bootstrap/lib/gc.mjs' +import { canonicalJson } from '../lib/canonical-json.mjs' +import { + deriveImageBuildId, + deriveRecordId, + parseDeploymentRecord, + parseImageInventory, + recordsFromImageInventory, +} from '../lib/deployment-contracts.mjs' +import { PlatformPaths, preparePersistentLayout, resetRuntimeLayout } from '../lib/paths.mjs' +import { hashTree } from '../lib/tree-hash.mjs' +import { buildRuntime } from '../../control-plane/modules/patch-manager/index.mjs' + +const kinds = ['environment', 'pristine', 'runtime', 'system-plugins'] + +async function fixture({ + root, + paths, + authority = 'stable', + targetSequence = 1, + marker = 'image', +} = {}) { + root ??= await mkdtemp(join(tmpdir(), 'dsh-deployments-')) + paths ??= new PlatformPaths(join(root, 'data'), join(root, 'run')) + const seedRoot = join(root, `seed-${marker}`) + const assets = {} + for (const kind of kinds) { + const plural = kind === 'environment' ? 'environments' : kind === 'runtime' ? 'runtimes' : kind + const id = `image-${kind}-${marker}` + const path = join(seedRoot, plural, id) + await mkdir(path, { recursive: true }) + await writeFile(join(path, 'sentinel'), `${kind}:${marker}`) + assets[kind] = { id, sha256: await hashTree(path) } + } + const content = { + schema: 1, + authority, + platformRevision: `deployment-fixture-${marker}`, + targetSequence, + bootstrapApi: 1, + updateApi: 1, + bootstrap: { version: '1.0.0', id: 'bootstrap', sha256: 'a'.repeat(64) }, + deployment: { + id: `image-deployment-${marker}`, + dshVersion: '0.1.0-rc.1', + environmentVersion: '1.0.0', + environment: assets.environment, + pristine: assets.pristine, + runtime: assets.runtime, + systemPlugins: assets['system-plugins'], + }, + } + const inventory = parseImageInventory({ ...content, imageBuildId: deriveImageBuildId(content) }) + await preparePersistentLayout(paths) + await resetRuntimeLayout(paths) + const manager = new DeploymentManager({ paths, seedRoot, inventory }) + return { root, seedRoot, paths, inventory, manager, image: recordsFromImageInventory(inventory).deployment } +} + +async function repairableFixture() { + const root = await mkdtemp(join(tmpdir(), 'dsh-runtime-reset-')) + const paths = new PlatformPaths(join(root, 'data'), join(root, 'run')) + const seedRoot = join(root, 'seed') + const pristineRoot = join(seedRoot, 'pristine', 'dsh') + const environmentRoot = join(seedRoot, 'environments', 'environment') + const runtimeRoot = join(seedRoot, 'runtimes', 'dsh') + const systemPluginsRoot = join(seedRoot, 'system-plugins', 'environment') + await mkdir(join(pristineRoot, 'lib'), { recursive: true }) + await mkdir(join(environmentRoot, 'artifacts'), { recursive: true }) + await mkdir(systemPluginsRoot, { recursive: true }) + await writeFile(join(pristineRoot, 'lib', 'bin.js'), '#!/usr/bin/env node\n') + const patch = [ + "import { readFile, writeFile } from 'node:fs/promises'", + "import { join } from 'node:path'", + "export async function applyPatch(root) { await writeFile(join(root, 'patched.txt'), 'patched\\n') }", + "export async function verifyPatch(root) { if (await readFile(join(root, 'patched.txt'), 'utf8') !== 'patched\\n') throw new Error('patch missing') }", + '', + ].join('\n') + const patchBytes = Buffer.from(patch) + const patchSha256 = createHash('sha256').update(patchBytes).digest('hex') + await writeFile(join(environmentRoot, 'artifacts', 'runtime-patch'), patchBytes) + await writeFile(join(environmentRoot, 'environment.manifest.json'), JSON.stringify({ + schema: 1, + manifestType: 'environment', + version: '1.0.0', + keyringGeneration: 1, + targetSequence: 1, + issuedAt: '2026-08-20T00:00:00.000Z', + artifacts: [{ + id: 'runtime-patch', + mediaType: 'text/javascript', + sha256: patchSha256, + size: patchBytes.byteLength, + url: 'https://example.invalid/runtime-patch', + }], + bootstrapApi: 1, + components: [], + patches: [{ id: 'runtime-patch', sha256: patchSha256 }], + systemPlugins: [], + })) + await buildRuntime({ + pristineRoot, + versionsRoot: join(seedRoot, 'runtimes'), + runtimeId: 'dsh', + patchPaths: [join(environmentRoot, 'artifacts', 'runtime-patch')], + }) + const assets = { + environment: { id: 'environment', sha256: await hashTree(environmentRoot) }, + pristine: { id: 'dsh', sha256: await hashTree(pristineRoot) }, + runtime: { id: 'dsh', sha256: await hashTree(runtimeRoot) }, + systemPlugins: { id: 'environment', sha256: await hashTree(systemPluginsRoot) }, + } + const content = { + schema: 1, + authority: 'development', + platformRevision: 'runtime-reset-fixture', + targetSequence: 0, + bootstrapApi: 1, + updateApi: 1, + bootstrap: { version: '1.0.0', id: 'bootstrap', sha256: 'a'.repeat(64) }, + deployment: { + id: 'image-deployment-runtime-reset', + dshVersion: '0.1.0-rc.1', + environmentVersion: '1.0.0', + ...assets, + }, + } + const inventory = parseImageInventory({ ...content, imageBuildId: deriveImageBuildId(content) }) + await preparePersistentLayout(paths) + await resetRuntimeLayout(paths) + const manager = new DeploymentManager({ paths, seedRoot, inventory }) + const image = recordsFromImageInventory(inventory).deployment + await manager.initialize(image) + return { root, paths, seedRoot, manager, image, environmentRoot } +} + +async function managedRecord( + context, + suffix, + sequence = 2, + authority = 'stable', + dshVersion = `0.1.0-rc.${String(sequence)}`, + receiptTokens = [], +) { + const references = {} + for (const kind of kinds) { + const root = kind === 'environment' + ? context.paths.environmentsRoot + : kind === 'pristine' + ? context.paths.pristineRoot + : kind === 'runtime' + ? context.paths.runtimesRoot + : context.paths.systemPluginsRoot + const id = `managed-${kind}-${suffix}` + const path = join(root, id) + await mkdir(path, { recursive: true }) + await writeFile(join(path, 'sentinel'), `${kind}:${suffix}`) + references[kind] = { storage: 'store', kind, id, sha256: await hashTree(path) } + } + const content = { + schema: 1, + authority, + targetSequence: sequence, + dshVersion, + environmentVersion: '1.0.0', + environment: references.environment, + pristine: references.pristine, + runtime: references.runtime, + systemPlugins: references['system-plugins'], + receiptTokens, + snapshotId: null, + } + return parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) }) +} + +async function experimentalRecord(context, from, suffix, dshVersion, receiptTokens = []) { + const built = await managedRecord(context, suffix, from.targetSequence, 'experimental', dshVersion, receiptTokens) + const content = { + ...built, + environmentVersion: from.environmentVersion, + environment: from.environment, + systemPlugins: from.systemPlugins, + } + delete content.id + return parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) }) +} + +test('initializes an Image Deployment through one atomic runtime view', async () => { + const context = await fixture() + const state = await context.manager.initialize(context.image) + assert.equal(state.current, context.image.id) + assert.equal(state.previous, null) + assert.equal(await readFile(join(context.paths.viewsRoot, 'runtime', 'sentinel'), 'utf8'), 'runtime:image') + assert.match(await readlink(context.paths.deploymentView), new RegExp(`${context.image.id}$`)) + assert.equal( + await readlink(join(context.paths.viewsRoot, 'system-plugins')), + join('..', 'system-plugin-views', 'current'), + ) + assert.equal( + await readlink(join(context.paths.systemPluginViewsRoot, 'current')), + join(context.paths.deploymentView, 'system-plugins'), + ) + assert.equal((await context.manager.selected()).record.id, context.image.id) + const status = await context.manager.publishStatus() + assert.equal(status.platformLayout, 1) + assert.equal(status.imageBaseline.imageBuildId, context.inventory.imageBuildId) + assert.equal(status.current.recordId, context.image.id) + assert.equal(status.current.source, 'image') + assert.equal(status.recoveryMode, null) +}) + +test('rebuilds image and managed Runtime bytes without changing versions or previous Deployment', async () => { + const context = await repairableFixture() + let pauses = 0 + let restarts = 0 + const reset = () => context.manager.resetCurrentRuntime({ + pauseDsh: async () => { pauses += 1 }, + restartDsh: async () => { + restarts += 1 + assert.equal(await readFile(join(context.paths.viewsRoot, 'runtime', 'package', 'patched.txt'), 'utf8'), 'patched\n') + }, + }) + const first = await reset() + assert.notEqual(first.recordId, context.image.id) + assert.equal(first.slots.previous, null) + const repaired = await context.manager.record(first.recordId) + assert.equal(repaired.runtime.storage, 'store') + assert.equal(repaired.dshVersion, context.image.dshVersion) + assert.equal(repaired.environmentVersion, context.image.environmentVersion) + assert.deepEqual(await readdir(context.paths.runtimesRoot), [repaired.runtime.id]) + + const materialized = await context.manager.materializeCurrent() + assert.equal(materialized.runtime.storage, 'store') + assert.notEqual(materialized.id, repaired.id) + assert.equal(materialized.runtime.id, repaired.runtime.id) + assert.equal((await context.manager.state()).previous, null) + const managedRuntime = join(context.paths.runtimesRoot, materialized.runtime.id) + await writeFile(join(managedRuntime, 'package', 'patched.txt'), 'corrupt-again\n') + const second = await reset() + assert.equal(second.recordId, materialized.id) + assert.equal(second.slots.previous, null) + assert.equal(await hashTree(managedRuntime), materialized.runtime.sha256) + assert.equal(pauses, 2) + assert.equal(restarts, 2) + + await writeFile(join(managedRuntime, 'package', 'patched.txt'), 'runtime-before-failed-reset\n') + let failedResetRestarts = 0 + await assert.rejects(context.manager.resetCurrentRuntime({ + pauseDsh: async () => { pauses += 1 }, + restartDsh: async () => { + failedResetRestarts += 1 + const content = await readFile(join(context.paths.viewsRoot, 'runtime', 'package', 'patched.txt'), 'utf8') + if (failedResetRestarts === 1) { + assert.equal(content, 'patched\n') + throw new Error('repaired Runtime failed to start') + } + assert.equal(content, 'runtime-before-failed-reset\n') + }, + }), /repaired Runtime failed to start/) + assert.equal(failedResetRestarts, 2) + assert.equal(await readFile(join(managedRuntime, 'package', 'patched.txt'), 'utf8'), 'runtime-before-failed-reset\n') + assert.deepEqual(await context.manager.state(), second.slots) + + await writeFile(join(context.paths.environmentsRoot, materialized.environment.id, 'artifacts', 'runtime-patch'), 'corrupt artifact\n') + await assert.rejects(reset(), /differs from (?:its Record|the Environment Manifest)/) + assert.equal(pauses, 3) + assert.equal(restarts, 2) +}) + +test('updates a runtime operation without replacing deployment identity fields', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const before = await context.manager.publishStatus({ recoveryMode: 'diagnostic-state' }) + const restarting = await context.manager.setOperation('restarting') + assert.deepEqual(restarting, { ...before, operation: 'restarting' }) + const ready = await context.manager.setOperation(null) + assert.deepEqual(ready, before) +}) + +test('commits a complete managed Deployment only after candidate health succeeds', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const candidate = await context.manager.writeRecord(await managedRecord(context, 'next')) + let observed + const state = await context.manager.activate(candidate.id, async () => { + observed = await Promise.all(kinds.map(kind => readFile(join(context.paths.deploymentView, kind, 'sentinel'), 'utf8'))) + }) + assert.deepEqual(observed, kinds.map(kind => `${kind}:next`)) + assert.equal(state.current, candidate.id) + assert.equal(state.previous, context.image.id) +}) + +test('restores the prior complete view and leaves slots unchanged when health fails', async () => { + const context = await fixture() + const before = await context.manager.initialize(context.image) + const candidate = await context.manager.writeRecord(await managedRecord(context, 'broken')) + await assert.rejects(context.manager.activate(candidate.id, async () => { + const value = await readFile(join(context.paths.deploymentView, 'runtime', 'sentinel'), 'utf8') + if (value === 'runtime:broken') throw new Error('candidate unhealthy') + }), /candidate unhealthy/) + assert.deepEqual(await context.manager.state(), before) + assert.equal(await readFile(join(context.paths.viewsRoot, 'runtime', 'sentinel'), 'utf8'), 'runtime:image') +}) + +test('rejects modified managed content and stale Image References', async () => { + const context = await fixture() + const candidate = await context.manager.writeRecord(await managedRecord(context, 'tampered')) + await writeFile(join(context.paths.runtimesRoot, candidate.runtime.id, 'sentinel'), 'changed') + await assert.rejects(context.manager.prepareView(candidate.id), /content hash differs/) + + await context.manager.writeRecord(context.image) + const changedContent = { ...context.inventory.document, platformRevision: 'new-image' } + delete changedContent.imageBuildId + const changedInventory = parseImageInventory({ ...changedContent, imageBuildId: deriveImageBuildId(changedContent) }) + const changedManager = new DeploymentManager({ paths: context.paths, seedRoot: context.seedRoot, inventory: changedInventory }) + await assert.rejects(changedManager.prepareView(context.image.id), /different image/) +}) + +test('writes immutable Deployment Record bytes', async () => { + const context = await fixture() + await context.manager.writeRecord(context.image) + assert.deepEqual( + await readFile(context.manager.recordPath(context.image.id)), + canonicalJson(context.image), + ) +}) + +test('materializes an Image Deployment as a complete Managed previous state', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const materialized = await context.manager.materializeCurrent() + assert.notEqual(materialized.id, context.image.id) + assert.equal(materialized.dshVersion, context.image.dshVersion) + for (const field of ['environment', 'pristine', 'runtime', 'systemPlugins']) { + assert.equal(materialized[field].storage, 'store') + const resolved = await context.manager.resolveReference(materialized[field]) + assert.equal((await lstat(resolved)).isDirectory(), true) + } + assert.equal((await context.manager.state()).current, materialized.id) +}) + +test('advances to a newer image only after startup acceptance', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const nextContent = { + ...context.inventory.document, + platformRevision: 'deployment-fixture-next', + targetSequence: 2, + } + delete nextContent.imageBuildId + const nextInventory = parseImageInventory({ ...nextContent, imageBuildId: deriveImageBuildId(nextContent) }) + const nextRecord = recordsFromImageInventory(nextInventory).deployment + const restarted = new DeploymentManager({ paths: context.paths, seedRoot: context.seedRoot, inventory: nextInventory }) + const plan = await restarted.prepareImage(nextRecord) + assert.equal(plan.action, 'image-forward') + assert.equal((await restarted.state()).current, context.image.id) + const state = await restarted.acceptImage(plan) + assert.equal(state.current, nextRecord.id) + assert.equal(state.previous, context.image.id) +}) + +test('does not advertise a rollback to assets owned by another Docker image', async () => { + const first = await fixture({ targetSequence: 1, marker: 'rollback-first' }) + await first.manager.initialize(first.image) + const second = await fixture({ + root: first.root, + paths: first.paths, + targetSequence: 2, + marker: 'rollback-second', + }) + const plan = await second.manager.prepareImage(second.image) + await second.manager.acceptImage(plan) + + const rollback = await second.manager.rollbackPlan() + assert.equal(rollback.current.id, second.image.id) + assert.equal(rollback.previous, null) +}) + +test('selects the prior Deployment directly after an explicit Docker image rollback', async () => { + const first = await fixture({ targetSequence: 1, marker: 'docker-rollback-first' }) + await first.manager.initialize(first.image) + const second = await fixture({ + root: first.root, + paths: first.paths, + targetSequence: 2, + marker: 'docker-rollback-second', + }) + await second.manager.acceptImage(await second.manager.prepareImage(second.image)) + + const rolledBack = new DeploymentManager({ + paths: first.paths, + seedRoot: first.seedRoot, + inventory: first.inventory, + }) + const rollback = await rolledBack.prepareImage(first.image) + assert.equal(rollback.action, 'image-rollback') + assert.equal(rollback.target, first.image.id) + await rolledBack.acceptImage(rollback) + assert.equal((await rolledBack.state()).current, first.image.id) +}) + +test('rejects an unresolvable newer image candidate and keeps the valid current Deployment', async () => { + const current = await fixture({ targetSequence: 1, marker: 'current' }) + await current.manager.initialize(current.image) + const managed = await current.manager.materializeCurrent() + const candidateContent = { + ...current.inventory.document, + platformRevision: 'deployment-fixture-candidate-missing', + targetSequence: 2, + } + delete candidateContent.imageBuildId + const inventory = parseImageInventory({ + ...candidateContent, + imageBuildId: deriveImageBuildId(candidateContent), + }) + const candidate = recordsFromImageInventory(inventory).deployment + await rm(join(current.seedRoot, 'runtimes', inventory.deployment.runtime.id), { recursive: true }) + const manager = new DeploymentManager({ paths: current.paths, seedRoot: current.seedRoot, inventory }) + const plan = await manager.prepareImage(candidate) + assert.equal(plan.action, 'image-rejected') + assert.equal(plan.target, managed.id) + assert.match(plan.candidateError, /ENOENT/) + assert.equal((await manager.selected()).record.id, managed.id) + assert.equal((await manager.state()).current, managed.id) +}) + +test('commits previous only after an invalid current resolves and previous receipts activate', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const previous = await managedRecord(context, 'previous-valid', 2, 'stable', '0.1.0-rc.2', ['receipt-previous']) + const current = await managedRecord(context, 'current-invalid', 3, 'stable', '0.1.0-rc.3', ['receipt-current']) + await context.manager.activateManaged(previous, { healthCheck: async () => {}, activateReceipts: async () => {} }) + await context.manager.activateManaged(current, { healthCheck: async () => {}, activateReceipts: async () => {} }) + await rm(join(context.paths.runtimesRoot, current.runtime.id), { recursive: true }) + + const manager = new DeploymentManager({ paths: context.paths, seedRoot: context.seedRoot, inventory: context.inventory }) + await assert.rejects(manager.prepareImage(context.image), DeploymentResolutionError) + const plan = await manager.preparePreviousRecovery() + assert.equal((await manager.selected()).record.id, previous.id) + assert.deepEqual(await manager.state(), { schema: 1, generation: 4, current: current.id, previous: previous.id }) + const activations = [] + await manager.acceptPreviousRecovery(plan, async tokens => { activations.push(tokens) }) + assert.deepEqual(activations, [['receipt-previous']]) + assert.deepEqual(await manager.state(), { schema: 1, generation: 5, current: previous.id, previous: current.id }) +}) + +test('finishes automatic previous recovery after interruption between receipts and slots', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const previous = await managedRecord(context, 'recovery-interrupted-previous', 2, 'stable', '0.1.0-rc.2', ['receipt-previous']) + const current = await managedRecord(context, 'recovery-interrupted-current', 3, 'stable', '0.1.0-rc.3', ['receipt-current']) + await context.manager.activateManaged(previous, { healthCheck: async () => {}, activateReceipts: async () => {} }) + await context.manager.activateManaged(current, { healthCheck: async () => {}, activateReceipts: async () => {} }) + + const manager = new DeploymentManager({ paths: context.paths, seedRoot: context.seedRoot, inventory: context.inventory }) + const plan = await manager.preparePreviousRecovery() + manager.commit = async () => { throw new Error('simulated interruption before slots commit') } + await assert.rejects( + manager.acceptPreviousRecovery(plan, async tokens => assert.deepEqual(tokens, ['receipt-previous'])), + /simulated interruption/, + ) + assert.equal((await manager.activation()).phase, 'receipts-active') + assert.equal((await manager.state()).current, current.id) + + const restarted = new DeploymentManager({ paths: context.paths, seedRoot: context.seedRoot, inventory: context.inventory }) + const recovered = await restarted.recoverActivation({ + activeReceipts: async () => ({ receipts: [{ token: 'receipt-previous' }] }), + activate: async () => { throw new Error('active previous must be completed') }, + }) + assert.deepEqual(recovered, { action: 'committed', recordId: previous.id }) + assert.deepEqual(await restarted.state(), { schema: 1, generation: 5, current: previous.id, previous: current.id }) + assert.equal(await restarted.activation(), undefined) +}) + +test('does not automatically downgrade an unparseable current Deployment Record', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const previous = await managedRecord(context, 'record-valid-previous', 2) + const current = await managedRecord(context, 'record-corrupt-current', 3) + await context.manager.activateManaged(previous, { healthCheck: async () => {}, activateReceipts: async () => {} }) + await context.manager.activateManaged(current, { healthCheck: async () => {}, activateReceipts: async () => {} }) + await writeFile(context.manager.recordPath(current.id), '{invalid') + + const manager = new DeploymentManager({ paths: context.paths, seedRoot: context.seedRoot, inventory: context.inventory }) + await assert.rejects(manager.prepareImage(context.image), error => { + assert.equal(error instanceof DeploymentResolutionError, false) + assert.match(error.message, /valid JSON/) + return true + }) + await assert.rejects(manager.preparePreviousRecovery(), /valid JSON/) + assert.deepEqual(await manager.state(), { schema: 1, generation: 4, current: current.id, previous: previous.id }) +}) + +test('keeps a newer managed Deployment when the image is behind', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const managed = await context.manager.writeRecord(await managedRecord(context, 'ahead', 3)) + await context.manager.activate(managed.id, async () => {}) + const plan = await context.manager.prepareImage(context.image) + assert.equal(plan.action, 'retain') + assert.equal(plan.imageBehindCurrent, true) + assert.equal(plan.target, managed.id) +}) + +test('rejects same-sequence Stable content conflicts', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const conflicting = await context.manager.writeRecord(await managedRecord(context, 'conflict', 1)) + await context.manager.activate(conflicting.id, async () => {}) + await assert.rejects(context.manager.prepareImage(context.image), /same targetSequence/) +}) + +test('prefers a same-sequence image when the managed Environment differs only by file mode', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const managed = await context.manager.materializeCurrent() + const environmentPath = await context.manager.resolveReference(managed.environment) + await chmod(join(environmentPath, 'sentinel'), 0o444) + const content = { + ...managed, + environment: { ...managed.environment, sha256: await hashTree(environmentPath) }, + } + delete content.id + const current = await context.manager.writeRecord({ + ...content, + id: deriveRecordId('deployment-record', content), + }) + await context.manager.commit(current.id, null) + + const plan = await context.manager.prepareImage(context.image) + assert.equal(plan.action, 'prefer-image') + assert.equal(plan.target, context.image.id) +}) + +test('rejects a same-sequence image when the managed Environment content differs', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const managed = await context.manager.materializeCurrent() + const environmentPath = await context.manager.resolveReference(managed.environment) + await writeFile(join(environmentPath, 'sentinel'), 'different environment content') + const content = { + ...managed, + environment: { ...managed.environment, sha256: await hashTree(environmentPath) }, + } + delete content.id + const current = await context.manager.writeRecord({ + ...content, + id: deriveRecordId('deployment-record', content), + }) + await context.manager.commit(current.id, null) + + await assert.rejects(context.manager.prepareImage(context.image), /same targetSequence/) +}) + +test('replaces a rebuilt development image without retaining stale image slots', async () => { + const first = await fixture({ authority: 'development', targetSequence: 0, marker: 'development-one' }) + await first.manager.initialize(first.image) + const rebuilt = await fixture({ + root: first.root, + paths: first.paths, + authority: 'development', + targetSequence: 0, + marker: 'development-two', + }) + const plan = await rebuilt.manager.prepareImage(rebuilt.image) + assert.equal(plan.action, 'development-refresh') + assert.equal(plan.fallback, null) + assert.equal(await readFile(join(rebuilt.paths.viewsRoot, 'runtime', 'sentinel'), 'utf8'), 'runtime:development-two') + const state = await rebuilt.manager.acceptImage(plan) + assert.equal(state.current, rebuilt.image.id) + assert.equal(state.previous, null) +}) + +test('replaces stale formal Image Deployment slots when starting a development image', async () => { + const formal = await fixture({ authority: 'stable', targetSequence: 11, marker: 'formal-eleven' }) + await formal.manager.initialize(formal.image) + const development = await fixture({ + root: formal.root, + paths: formal.paths, + authority: 'development', + targetSequence: 0, + marker: 'local-rebuild', + }) + const plan = await development.manager.prepareImage(development.image) + assert.equal(plan.action, 'development-refresh') + assert.equal(plan.fallback, null) + const state = await development.manager.acceptImage(plan) + assert.equal(state.current, development.image.id) + assert.equal(state.previous, null) + assert.equal(await readFile(join(development.paths.viewsRoot, 'runtime', 'sentinel'), 'utf8'), 'runtime:local-rebuild') +}) + +test('replaces stale Managed Deployment assets when starting a compatible development image', async () => { + const formal = await fixture({ authority: 'stable', targetSequence: 11, marker: 'formal-eleven' }) + await formal.manager.initialize(formal.image) + const managed = await formal.manager.writeRecord(await managedRecord( + formal, + 'managed-eleven', + 11, + 'stable', + '0.1.0-rc.1', + )) + await formal.manager.activate(managed.id, async () => {}) + const development = await fixture({ + root: formal.root, + paths: formal.paths, + authority: 'development', + targetSequence: 0, + marker: 'local-managed-refresh', + }) + const plan = await development.manager.prepareImage(development.image) + assert.equal(plan.action, 'development-refresh') + assert.equal(plan.fallback, null) + const state = await development.manager.acceptImage(plan) + assert.equal(state.current, development.image.id) + assert.equal(state.previous, null) + assert.equal( + await readFile(join(development.paths.viewsRoot, 'system-plugins', 'sentinel'), 'utf8'), + 'system-plugins:local-managed-refresh', + ) +}) + +test('preserves Experimental DSH until a Stable image catches up', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const experimental = await context.manager.writeRecord(await managedRecord( + context, + 'experimental', + 1, + 'experimental', + '0.1.0-rc.3', + )) + await context.manager.activate(experimental.id, async () => {}) + + const behindDshContent = { + ...context.inventory.document, + platformRevision: 'stable-environment-forward', + targetSequence: 2, + } + delete behindDshContent.imageBuildId + const behindDshInventory = parseImageInventory({ + ...behindDshContent, + imageBuildId: deriveImageBuildId(behindDshContent), + }) + const environmentForward = new DeploymentManager({ + paths: context.paths, + seedRoot: context.seedRoot, + inventory: behindDshInventory, + }) + const retained = await environmentForward.prepareImage(recordsFromImageInventory(behindDshInventory).deployment) + assert.equal(retained.target, experimental.id) + assert.equal(retained.requiresExperimentalRebuild, true) + + const caughtUpContent = structuredClone(behindDshContent) + caughtUpContent.platformRevision = 'stable-caught-up' + caughtUpContent.deployment.dshVersion = '0.1.0-rc.3' + const caughtUpInventory = parseImageInventory({ + ...caughtUpContent, + imageBuildId: deriveImageBuildId(caughtUpContent), + }) + const caughtUpManager = new DeploymentManager({ + paths: context.paths, + seedRoot: context.seedRoot, + inventory: caughtUpInventory, + }) + const caughtUp = await caughtUpManager.prepareImage(recordsFromImageInventory(caughtUpInventory).deployment) + assert.equal(caughtUp.action, 'stable-caught-up') + assert.notEqual(caughtUp.target, experimental.id) +}) + +test('activates receipts and commits one complete Managed Deployment transaction', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const candidate = await managedRecord(context, 'transaction', 2, 'stable', '0.1.0-rc.2', ['candidate-receipt']) + let active = [] + const trust = { + activate: async tokens => { active = [...tokens] }, + activeReceipts: async () => ({ receipts: active.map(token => ({ token })) }), + } + const slots = await context.manager.activateManaged(candidate, { + healthCheck: async () => { + assert.equal(await readFile(join(context.paths.viewsRoot, 'runtime', 'sentinel'), 'utf8'), 'runtime:transaction') + }, + activateReceipts: tokens => trust.activate(tokens), + }) + assert.equal(slots.current, candidate.id) + assert.deepEqual(active, ['candidate-receipt']) + const previous = await context.manager.record(slots.previous) + assert.equal(previous.runtime.storage, 'store') + assert.equal(await context.manager.activation(), undefined) +}) + +test('rolls back only the exact previous Deployment with receipts and health checks', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const candidate = await managedRecord(context, 'rollback-target', 2, 'stable', '0.1.0-rc.2', ['candidate-receipt']) + const active = [] + await context.manager.activateManaged(candidate, { + healthCheck: async () => {}, + activateReceipts: async tokens => active.push([...tokens]), + }) + const before = await context.manager.state() + const previous = await context.manager.record(before.previous) + let healthChecks = 0 + const rolledBack = await context.manager.rollback({ + healthCheck: async () => { + healthChecks += 1 + assert.equal(JSON.parse(await readFile(context.paths.deploymentStatusPath, 'utf8')).operation, 'recovering') + }, + activateReceipts: async tokens => active.push([...tokens]), + }) + assert.equal(rolledBack.current, previous.id) + assert.equal(rolledBack.previous, candidate.id) + assert.deepEqual(active.at(-1), previous.receiptTokens) + assert.equal(healthChecks, 1) + assert.equal(await context.manager.activation(), undefined) + const status = await context.manager.publishStatus() + assert.equal(status.current.recordId, previous.id) + assert.equal(status.operation, null) +}) + +test('reports an image behind a newly activated Managed Deployment immediately', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const candidate = await managedRecord(context, 'status-ahead', 2, 'stable', '0.1.0-rc.2') + await context.manager.activateManaged(candidate, { + healthCheck: async () => {}, + activateReceipts: async () => {}, + }) + const status = await context.manager.publishStatus() + assert.equal(status.current.recordId, candidate.id) + assert.equal(status.imageBehindCurrent, true) +}) + +test('restores the materialized previous Deployment when receipt activation fails', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const candidate = await managedRecord(context, 'receipt-failure', 2, 'stable', '0.1.0-rc.2', ['candidate-receipt']) + await assert.rejects(context.manager.activateManaged(candidate, { + healthCheck: async () => {}, + activateReceipts: async () => { throw new Error('receipt activation failed') }, + }), /receipt activation failed/) + const state = await context.manager.state() + assert.notEqual(state.current, context.image.id) + assert.equal((await context.manager.record(state.current)).runtime.storage, 'store') + assert.equal(await readFile(join(context.paths.viewsRoot, 'runtime', 'sentinel'), 'utf8'), 'runtime:image') + assert.equal(await context.manager.activation(), undefined) +}) + +test('publishes switching and recovering states around a failed atomic activation', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + await context.manager.publishStatus() + const candidate = await managedRecord(context, 'operation-status', 2, 'stable', '0.1.0-rc.2') + const observed = [] + await assert.rejects(context.manager.activateManaged(candidate, { + healthCheck: async () => { + observed.push(JSON.parse(await readFile(context.paths.deploymentStatusPath, 'utf8')).operation) + if (observed.length === 1) throw new Error('candidate failed') + }, + activateReceipts: async () => {}, + }), /candidate failed/) + assert.deepEqual(observed, ['switching', 'recovering']) + assert.equal(JSON.parse(await readFile(context.paths.deploymentStatusPath, 'utf8')).operation, null) +}) + +test('turns a failed image recovery operation into an explicit recovery failure', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + await context.manager.publishStatus({ recoveryMode: 'current deployment failed' }) + let operation + await assert.rejects(context.manager.recoverImageBaseline(context.image, { + healthCheck: async () => { + const status = JSON.parse(await readFile(context.paths.deploymentStatusPath, 'utf8')) + operation = status.operation + assert.equal(status.recoveryMode, 'current deployment failed') + throw new Error('image recovery health failed') + }, + activateReceipts: async () => {}, + }), /image recovery health failed/) + assert.equal(operation, 'recovering') + const failed = JSON.parse(await readFile(context.paths.deploymentStatusPath, 'utf8')) + assert.equal(failed.operation, null) + assert.equal(failed.recoveryMode, 'image recovery health failed') +}) + +test('finishes a journaled activation after receipts became active before restart', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const from = await context.manager.materializeCurrent() + const candidate = await context.manager.writeRecord(await managedRecord( + context, + 'resume', + 2, + 'stable', + '0.1.0-rc.2', + ['candidate-receipt'], + )) + await context.manager.writeActivation({ phase: 'healthy', from: from.id, to: candidate.id }) + const recovered = await context.manager.recoverActivation({ + activeReceipts: async () => ({ receipts: [{ token: 'candidate-receipt' }] }), + activate: async () => { throw new Error('active candidate must be completed') }, + }) + assert.deepEqual(recovered, { action: 'committed', recordId: candidate.id }) + assert.equal((await context.manager.state()).current, candidate.id) + assert.equal(await context.manager.activation(), undefined) +}) + +test('keeps Experimental candidate slots uncommitted through probation and supports cancellation', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const candidate = await experimentalRecord( + context, + context.image, + 'probation', + '0.1.0-rc.2', + ['experimental-receipt'], + ) + await context.manager.stageCandidate(candidate, async () => { + assert.equal(await readFile(join(context.paths.viewsRoot, 'runtime', 'sentinel'), 'utf8'), 'runtime:probation') + }) + const during = await context.manager.state() + assert.notEqual(during.current, candidate.id) + assert.equal((await context.manager.activation()).phase, 'probation') + const cancelled = await context.manager.cancelCandidate() + assert.equal(cancelled.cancelled, true) + assert.equal(await readFile(join(context.paths.viewsRoot, 'runtime', 'sentinel'), 'utf8'), 'runtime:image') + assert.equal(await context.manager.activation(), undefined) +}) + +test('rejects rollback and cross-authority candidates at the Bootstrap boundary', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const advanced = await managedRecord(context, 'advanced', 3) + await context.manager.activateManaged(advanced, { healthCheck: async () => {}, activateReceipts: async () => {} }) + const rollback = await managedRecord(context, 'rollback', 2) + await assert.rejects(context.manager.activateManaged(rollback, { + healthCheck: async () => {}, activateReceipts: async () => {}, + }), /roll back/) + const wrongExperimental = await managedRecord(context, 'wrong-environment', 3, 'experimental', '0.1.0-rc.4') + await assert.rejects(context.manager.stageCandidate(wrongExperimental, async () => {}), /retain the current Environment/) +}) + +test('does not restart previous DSH before snapshot recovery after candidate health failure', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const candidate = await experimentalRecord(context, context.image, 'health-failure', '0.1.0-rc.2') + let healthCalls = 0 + await assert.rejects(context.manager.stageCandidate(candidate, async () => { + healthCalls += 1 + throw new Error('candidate failed health') + }), /candidate failed health/) + assert.equal(healthCalls, 1) + assert.equal(await readFile(join(context.paths.viewsRoot, 'runtime', 'sentinel'), 'utf8'), 'runtime:image') +}) + +test('collects only Store assets and records outside slots, transactions, Holds, and snapshots', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const current = await context.manager.writeRecord(await managedRecord(context, 'current', 2)) + await context.manager.activateManaged(current, { + healthCheck: async () => {}, + activateReceipts: async () => {}, + }) + const orphan = await context.manager.writeRecord(await managedRecord(context, 'orphan', 3)) + const held = await context.manager.writeRecord(await managedRecord( + context, + 'held', + 4, + 'experimental', + '0.1.0-rc.held', + )) + await writeFile(join(context.paths.updaterStateRoot, 'channel.json'), JSON.stringify({ + schema: 1, + updateChannel: 'experimental', + holds: [{ + id: 'hold', type: 'version', dshVersion: held.dshVersion, environmentVersion: null, + reason: 'fixture', createdAt: '2026-08-19T00:00:00.000Z', + }], + experimentalBlocked: null, + })) + await mkdir(join(context.paths.snapshotsRoot, 'versions', 'snapshot-kept'), { recursive: true }) + await mkdir(join(context.paths.snapshotsRoot, 'versions', 'snapshot-orphan'), { recursive: true }) + await writeFile(join(context.paths.updaterStateRoot, 'transaction.json'), JSON.stringify({ + from: { runtime: current.id }, to: { runtime: current.id }, snapshotId: 'snapshot-kept', + })) + await writeFile(join(context.paths.downloadsRoot, 'disposable'), 'cache') + await mkdir(context.paths.systemSkillCatalogsRoot, { recursive: true }) + await writeFile(join(context.paths.systemSkillCatalogsRoot, 'catalog-marker'), 'retained') + + const removedSnapshots = [] + const result = await new PlatformGarbageCollector({ + paths: context.paths, + deployments: context.manager, + snapshots: { + remove: async id => { + removedSnapshots.push(id) + await rm(join(context.paths.snapshotsRoot, 'versions', id), { recursive: true, force: true }) + return true + }, + }, + }).collect() + assert.equal(result.deployments.records.includes(orphan.id), true) + assert.equal(result.deployments.records.includes(held.id), false) + await assert.rejects(lstat(context.manager.recordPath(orphan.id)), { code: 'ENOENT' }) + assert.equal((await lstat(context.manager.recordPath(held.id))).isFile(), true) + await assert.rejects(lstat(join(context.paths.runtimesRoot, orphan.runtime.id)), { code: 'ENOENT' }) + assert.equal((await lstat(join(context.paths.runtimesRoot, held.runtime.id))).isDirectory(), true) + assert.equal((await lstat(join(context.paths.snapshotsRoot, 'versions', 'snapshot-kept'))).isDirectory(), true) + assert.deepEqual(removedSnapshots, ['snapshot-orphan']) + await assert.rejects(lstat(join(context.paths.snapshotsRoot, 'versions', 'snapshot-orphan')), { code: 'ENOENT' }) + await assert.rejects(lstat(join(context.paths.downloadsRoot, 'disposable')), { code: 'ENOENT' }) + assert.equal(await readFile(join(context.paths.systemSkillCatalogsRoot, 'catalog-marker'), 'utf8'), 'retained') +}) + +test('explicit recovery replaces an invalid Managed current with the exact Image Baseline', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const managed = await managedRecord(context, 'invalid-managed', 3) + await context.manager.activateManaged(managed, { healthCheck: async () => {}, activateReceipts: async () => {} }) + let activated + const state = await context.manager.recoverImageBaseline(context.image, { + healthCheck: async () => { + assert.equal(await readFile(join(context.paths.viewsRoot, 'runtime', 'sentinel'), 'utf8'), 'runtime:image') + }, + activateReceipts: async tokens => { activated = tokens }, + }) + assert.equal(state.current, context.image.id) + assert.equal(state.previous, managed.id) + assert.deepEqual(activated, []) +}) + +test('explicit recovery repairs only the exact image Record after its persisted bytes are damaged', async () => { + const context = await fixture() + await context.manager.initialize(context.image) + const managed = await managedRecord(context, 'managed-before-repair', 3) + await context.manager.activateManaged(managed, { healthCheck: async () => {}, activateReceipts: async () => {} }) + await writeFile(context.manager.recordPath(context.image.id), '{"damaged":true}\n') + const state = await context.manager.recoverImageBaseline(context.image, { + healthCheck: async () => {}, + activateReceipts: async () => {}, + }) + assert.equal(state.current, context.image.id) + assert.deepEqual(await context.manager.record(context.image.id), context.image) + await assert.rejects(context.manager.repairImageRecord(managed), /differs from the current image inventory/) +}) diff --git a/upstream/container/platform/test/dsh-compatibility.test.mjs b/upstream/container/platform/test/dsh-compatibility.test.mjs new file mode 100644 index 0000000..4266a3f --- /dev/null +++ b/upstream/container/platform/test/dsh-compatibility.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { + DSH_COMPATIBILITY_PACKAGES, + DSH_REMOTE_METHODS, + DSH_STATIC_CLIENT_MODULES, + DSH_WEB_ROWS, + verifyDshCompatibility, +} from '../lib/dsh-compatibility.mjs' + +const VERSION = '0.1.5-rc.1' + +async function writeJson(path, value) { + await writeFile(path, `${JSON.stringify(value)}\n`) +} + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'dsh-compatibility-fixture-')) + await mkdir(join(root, 'lib'), { recursive: true }) + await writeJson(join(root, 'package.json'), { + name: '@deepseek-ai/dsh', + version: VERSION, + type: 'module', + bin: { dsh: 'lib/bin.js' }, + dependencies: Object.fromEntries(Object.keys(DSH_COMPATIBILITY_PACKAGES).map(name => [name, `^${VERSION}`])), + }) + const config = Object.entries(DSH_WEB_ROWS) + .map(([id, name]) => `- id: ${id}\n name: '${name}'`) + .join('\n') + await writeFile(join(root, 'lib/bin.js'), `console.log(${JSON.stringify(config)})\n`) + for (const [name, exports] of Object.entries(DSH_COMPATIBILITY_PACKAGES)) { + const directory = join(root, 'node_modules', ...name.split('/')) + await mkdir(join(directory, 'lib'), { recursive: true }) + await writeJson(join(directory, 'package.json'), { + name, + version: VERSION, + exports: Object.fromEntries(exports.map(subpath => [subpath, './lib/index.js'])), + }) + await writeFile(join(directory, 'lib/index.js'), '') + } + await writeFile( + packageFile(root, '@deepseek-ai/dsh-api-remotes', 'lib/client.js'), + DSH_REMOTE_METHODS.join('\n'), + ) + const frontendAssets = packageFile(root, '@deepseek-ai/dsh-web-frontend', 'dist/assets') + await mkdir(frontendAssets, { recursive: true }) + await writeFile(join(frontendAssets, 'index.js'), `{${DSH_STATIC_CLIENT_MODULES + .map(name => `${JSON.stringify(name)}:fixture`) + .join(',')}}\n`) + return root +} + +function packageFile(root, packageName, file) { + return join(root, 'node_modules', ...packageName.split('/'), file) +} + +function removeAfter(t, ...paths) { + t.after(() => Promise.all(paths.map(path => rm(path, { recursive: true, force: true })))) +} + +test('accepts the complete DSH package, Web profile, and Remote contract', async t => { + const packageRoot = await fixture() + const home = await mkdtemp(join(tmpdir(), 'dsh-compatibility-home-')) + removeAfter(t, packageRoot, home) + assert.deepEqual(await verifyDshCompatibility({ packageRoot, expectedVersion: VERSION, home }), { + name: '@deepseek-ai/dsh', + version: VERSION, + packages: 10, + remoteMethods: 3, + webRows: 5, + }) +}) + +test('rejects a stale installed package in the DSH dependency closure', async t => { + const packageRoot = await fixture() + const home = await mkdtemp(join(tmpdir(), 'dsh-compatibility-home-')) + removeAfter(t, packageRoot, home) + const manifestPath = packageFile(packageRoot, '@deepseek-ai/dsh-client-connection', 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + await writeJson(manifestPath, { ...manifest, version: '0.1.2-rc.1' }) + await assert.rejects( + verifyDshCompatibility({ packageRoot, expectedVersion: VERSION, home }), + /dsh-client-connection is not installed at DSH version 0\.1\.5-rc\.1/, + ) +}) + +test('rejects missing Web rows and management Remote methods', async t => { + const packageRoot = await fixture() + const home = await mkdtemp(join(tmpdir(), 'dsh-compatibility-home-')) + removeAfter(t, packageRoot, home) + await writeFile(join(packageRoot, 'lib/bin.js'), 'console.log("- id: connection\\n name: missing")\n') + await assert.rejects( + verifyDshCompatibility({ packageRoot, expectedVersion: VERSION, home }), + /DSH Web profile does not contain/, + ) + + await writeFile( + packageFile(packageRoot, '@deepseek-ai/dsh-api-remotes', 'lib/client.js'), + DSH_REMOTE_METHODS.slice(1).join('\n'), + ) + await assert.rejects( + verifyDshCompatibility({ packageRoot, expectedVersion: VERSION, home }), + /Remote registry is missing/, + ) +}) + +test('rejects each missing static module required by a bundled System Plugin', async t => { + for (const missing of DSH_STATIC_CLIENT_MODULES) { + await t.test(missing, async t => { + const packageRoot = await fixture() + const home = await mkdtemp(join(tmpdir(), 'dsh-compatibility-home-')) + removeAfter(t, packageRoot, home) + const present = DSH_STATIC_CLIENT_MODULES.filter(name => name !== missing) + await writeFile( + packageFile(packageRoot, '@deepseek-ai/dsh-web-frontend', 'dist/assets/index.js'), + `{${present.map(name => `${JSON.stringify(name)}:fixture`).join(',')}}\n`, + ) + await assert.rejects( + verifyDshCompatibility({ packageRoot, expectedVersion: VERSION, home }), + new RegExp(`Web static module table is missing ${JSON.stringify(missing).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + ) + }) + } +}) + +test('requires every static browser module consumed directly by bundled System Plugins', () => { + assert.deepEqual(DSH_STATIC_CLIENT_MODULES, [ + '@deepseek-ai/dsh-client-ui-primitives', + 'react', + 'react-dom', + ]) +}) diff --git a/upstream/container/platform/test/dsh-lifecycle.test.mjs b/upstream/container/platform/test/dsh-lifecycle.test.mjs new file mode 100644 index 0000000..045ad9b --- /dev/null +++ b/upstream/container/platform/test/dsh-lifecycle.test.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict' +import { request } from 'node:http' +import { mkdtemp, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { + createDshLifecycleServer, + DshLifecycleBroker, + listenDshLifecycle, +} from '../bootstrap/lib/dsh-lifecycle.mjs' + +function call(socketPath, path, body) { + return new Promise((resolve, reject) => { + const req = request({ socketPath, path, method: 'POST', headers: { 'content-type': 'application/json' } }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => resolve({ status: response.statusCode, body: JSON.parse(Buffer.concat(chunks)) })) + }) + req.once('error', reject) + req.end(JSON.stringify(body)) + }) +} + +test('authorizes exactly one claim for each supervised DSH launch', async () => { + const reports = [] + const broker = new DshLifecycleBroker({ report: (message, fields) => reports.push({ message, fields }) }) + const unrelated = broker.prepareLaunch('gateway') + assert.deepEqual(unrelated.environment, {}) + + const launch = broker.prepareLaunch('dsh-runtime') + const first = broker.claim(launch.environment.DSH_PLATFORM_LAUNCH_TOKEN) + assert.match(first.sessionId, /^[0-9a-f-]{36}$/) + assert.deepEqual(broker.readiness(), { + generation: first.sessionId, publicReady: false, ready: false, readyUrl: null, verified: false, + }) + assert.deepEqual(broker.ready(first.sessionId), { ready: true }) + assert.deepEqual(broker.readiness(), { + generation: first.sessionId, publicReady: false, ready: true, readyUrl: null, verified: false, + }) + broker.verify() + assert.equal(broker.readiness().publicReady, false) + broker.publish() + assert.equal(broker.readiness().publicReady, true) + assert.throws(() => broker.claim(launch.environment.DSH_PLATFORM_LAUNCH_TOKEN), /already consumed/) + launch.release() + await assert.rejects(broker.signal(first.sessionId, 'SIGTERM'), /session is invalid/) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(reports.map(value => value.message), [ + 'dsh.launch.authorized', 'dsh.launch.claimed', 'dsh.launch.ready', 'dsh.launch.verified', + 'platform.public-ready', 'dsh.launch.released', + ]) + assert.doesNotMatch(JSON.stringify(reports), new RegExp(launch.environment.DSH_PLATFORM_LAUNCH_TOKEN)) + assert.doesNotMatch(JSON.stringify(reports), new RegExp(first.sessionId)) +}) + +test('invalidates an old launch without allowing its cleanup to release the replacement', () => { + const broker = new DshLifecycleBroker() + const oldLaunch = broker.prepareLaunch('dsh-runtime') + const oldSession = broker.claim(oldLaunch.environment.DSH_PLATFORM_LAUNCH_TOKEN) + broker.ready(oldSession.sessionId) + broker.verify() + broker.publish() + assert.equal(broker.readiness().publicReady, true) + const nextLaunch = broker.prepareLaunch('dsh-runtime') + assert.equal(broker.readiness().publicReady, false) + oldLaunch.release() + const session = broker.claim(nextLaunch.environment.DSH_PLATFORM_LAUNCH_TOKEN) + assert.match(session.sessionId, /^[0-9a-f-]{36}$/) +}) + +test('returns restart only for an active session outside an owned shutdown', async () => { + let owned = false + const broker = new DshLifecycleBroker({ shouldTerminate: async () => owned }) + const launch = broker.prepareLaunch('dsh-runtime') + const { sessionId } = broker.claim(launch.environment.DSH_PLATFORM_LAUNCH_TOKEN) + assert.deepEqual(await broker.signal(sessionId, 'SIGTERM'), { disposition: 'terminate' }) + broker.ready(sessionId) + assert.deepEqual(await broker.signal(sessionId, 'SIGTERM'), { disposition: 'request-restart' }) + owned = true + assert.deepEqual(await broker.signal(sessionId, 'SIGTERM'), { disposition: 'terminate' }) + owned = false + broker.beginShutdown() + assert.deepEqual(await broker.signal(sessionId, 'SIGTERM'), { disposition: 'terminate' }) + await assert.rejects(broker.signal(sessionId, 'SIGINT'), /signal is invalid/) +}) + +test('serves launch ownership and readiness over a private Unix socket', async t => { + const root = await mkdtemp(join(tmpdir(), 'dsh-lifecycle-broker-')) + const socketPath = join(root, 'dsh-lifecycle.sock') + const reports = [] + const broker = new DshLifecycleBroker({ report: (message, fields) => reports.push({ message, fields }) }) + const server = createDshLifecycleServer(broker) + t.after(() => server.close()) + await listenDshLifecycle(server, socketPath) + assert.equal((await stat(socketPath)).mode & 0o777, 0o600) + + const launch = broker.prepareLaunch('dsh-runtime') + const claim = await call(socketPath, '/v1/runtime/claim', { + launchToken: launch.environment.DSH_PLATFORM_LAUNCH_TOKEN, + }) + assert.equal(claim.status, 200) + assert.deepEqual(await call(socketPath, '/v1/runtime/readiness', {}), { + status: 200, + body: { + generation: claim.body.sessionId, publicReady: false, ready: false, readyUrl: null, verified: false, + }, + }) + const readyUrl = 'http://127.0.0.1:3079/?token=fixture-token' + assert.deepEqual(await call(socketPath, '/v1/runtime/ready', { readyUrl, sessionId: claim.body.sessionId }), { + status: 200, + body: { ready: true }, + }) + assert.deepEqual(await call(socketPath, '/v1/runtime/ready', { sessionId: claim.body.sessionId }), { + status: 200, + body: { ready: true }, + }) + assert.deepEqual(await call(socketPath, '/v1/runtime/readiness', {}), { + status: 200, + body: { + generation: claim.body.sessionId, publicReady: false, ready: true, readyUrl, verified: false, + }, + }) + broker.verify() + broker.publish() + assert.equal((await call(socketPath, '/v1/runtime/readiness', {})).body.publicReady, true) + const signal = await call(socketPath, '/v1/runtime/signal', { + sessionId: claim.body.sessionId, + signal: 'SIGTERM', + }) + assert.deepEqual(signal, { status: 200, body: { disposition: 'request-restart' } }) + assert.equal((await call(socketPath, '/v1/runtime/claim', { + launchToken: launch.environment.DSH_PLATFORM_LAUNCH_TOKEN, + })).status, 409) + assert.equal((await call(socketPath, '/v1/runtime/signal', { + sessionId: claim.body.sessionId, + signal: 'SIGTERM', + unexpected: true, + })).status, 400) + assert.equal((await call(socketPath, '/v1/unknown', {})).status, 404) + assert.equal(reports.at(-2).message, 'dsh.lifecycle-request.failed') + assert.equal(reports.at(-2).fields.level, 'warning') + assert.equal(reports.at(-1).message, 'dsh.lifecycle-request.failed') + assert.equal(reports.at(-1).fields.level, 'error') +}) diff --git a/upstream/container/platform/test/dsh-web-ready.test.mjs b/upstream/container/platform/test/dsh-web-ready.test.mjs new file mode 100644 index 0000000..28ce184 --- /dev/null +++ b/upstream/container/platform/test/dsh-web-ready.test.mjs @@ -0,0 +1,303 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { mkdtemp, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { verifyDshWebReady } from '../../control-plane/hooks/dsh-web-ready.mjs' + +async function fixture(routes) { + const requests = new Map() + const server = createServer((request, response) => { + requests.set(request.url, (requests.get(request.url) ?? 0) + 1) + const configured = routes.get(`${request.method} ${request.url}`) ?? routes.get(request.url) + const route = typeof configured === 'function' ? configured(requests.get(request.url)) : configured + response.writeHead(route?.status ?? 404, { + 'content-type': route?.type ?? 'text/plain', + ...route?.headers, + }) + response.end(route?.body ?? '') + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + return { server, port: server.address().port, requests } +} + +test('DSH web readiness requires every boot manifest Plugin bundle', async () => { + const manifest = JSON.stringify({ rev: 'one', entries: [ + { id: 'one', url: '/plugins/one/client.js?rev=one', rev: 'one' }, + { id: 'two', url: '/plugins/two/client.js?rev=two', rev: 'two' }, + ] }) + const routes = new Map([ + ['/', { status: 200, type: 'text/html', body: `` }], + ['/plugins/one/client.js?rev=one', { status: 200, type: 'text/javascript', body: 'one' }], + ['/plugins/two/client.js?rev=two', { status: 404 }], + ['POST /api/pluginInventory/list', { status: 200, type: 'application/json', body: JSON.stringify({ + type: 'server-response', + rpcId: 'dsh-platform-readiness', + result: { ok: true, value: { entries: [{ moduleName: 'ready', enabled: true, fiberPhase: 'active' }] } }, + }) }], + ]) + const context = await fixture(routes) + try { + await assert.rejects(verifyDshWebReady({ port: context.port, managedReady: async () => {} }), /returned HTTP 404/) + routes.set('/plugins/two/client.js?rev=two', { status: 200, type: 'text/javascript', body: 'two' }) + await verifyDshWebReady({ port: context.port, managedReady: async () => {} }) + assert.equal(context.requests.get('/'), 3) + assert.equal(context.requests.get('/plugins/one/client.js?rev=one'), 3) + assert.equal(context.requests.get('/plugins/two/client.js?rev=two'), 3) + assert.equal(context.requests.get('/api/pluginInventory/list'), 2) + } finally { + await new Promise(resolve => context.server.close(resolve)) + } +}) + +test('DSH web readiness verifies 0.1.5 initial combo batches', async () => { + const entryUrl = '/plugins/??one/client.js&rev=entry-one' + const batchUrl = '/plugins/??one/client.js,two/client.js&rev=batch-one' + const manifest = JSON.stringify({ + rev: 'graph-one', + entries: [{ id: 'one', url: entryUrl, rev: 'entry-one' }], + batches: [{ phase: 'application', url: batchUrl, rev: 'batch-one', entries: ['one', 'two'] }], + }) + const routes = new Map([ + ['/', { status: 200, type: 'text/html', body: `` }], + [entryUrl, { status: 200, type: 'text/javascript', body: 'one' }], + [batchUrl, { status: 404 }], + ['POST /api/pluginInventory/list', { status: 200, type: 'application/json', body: JSON.stringify({ + type: 'server-response', + rpcId: 'dsh-platform-readiness', + result: { ok: true, value: { entries: [{ moduleName: 'ready', enabled: true, fiberPhase: 'active' }] } }, + }) }], + ]) + const context = await fixture(routes) + try { + await assert.rejects( + verifyDshWebReady({ port: context.port, managedReady: async () => ({}) }), + /returned HTTP 404/, + ) + routes.set(batchUrl, { status: 200, type: 'text/javascript', body: 'one and two' }) + await verifyDshWebReady({ port: context.port, managedReady: async () => ({}) }) + assert.equal(context.requests.get(entryUrl), 3) + assert.equal(context.requests.get(batchUrl), 3) + } finally { + await new Promise(resolve => context.server.close(resolve)) + } +}) + +test('DSH web readiness rejects a malformed combo batch contract', async () => { + const manifest = JSON.stringify({ + rev: 'graph-one', + entries: [{ id: 'one', url: '/plugins/one/client.js?rev=one', rev: 'one' }], + batches: { phase: 'application', url: '/plugins/??one/client.js&rev=one' }, + }) + const context = await fixture(new Map([ + ['/', { status: 200, type: 'text/html', body: `` }], + ])) + try { + await assert.rejects( + verifyDshWebReady({ port: context.port, managedReady: async () => ({}) }), + /boot manifest batches are invalid/, + ) + } finally { + await new Promise(resolve => context.server.close(resolve)) + } +}) + +test('DSH web readiness exchanges the private launch token for an authenticated cookie', async () => { + const manifest = JSON.stringify({ rev: 'one', entries: [ + { id: 'one', url: '/plugins/one/client.js?rev=one', rev: 'one' }, + ] }) + let expectedCookie = '' + const routes = new Map([ + ['/?token=fixture-token', { + status: 303, + headers: { location: '/', 'set-cookie': 'dsh-auth-fixture=session; Path=/; HttpOnly; SameSite=Strict' }, + }], + ]) + const context = await fixture(routes) + context.server.removeAllListeners('request') + context.server.on('request', (request, response) => { + context.requests.set(request.url, (context.requests.get(request.url) ?? 0) + 1) + if (request.url === '/?token=fixture-token') { + response.writeHead(303, { + location: '/', + 'set-cookie': 'dsh-auth-fixture=session; Path=/; HttpOnly; SameSite=Strict', + }) + response.end() + return + } + expectedCookie = request.headers.cookie ?? '' + if (expectedCookie !== 'dsh-auth-fixture=session') { + response.writeHead(401) + response.end('unauthorized') + return + } + if (request.url === '/') { + response.writeHead(200, { 'content-type': 'text/html' }) + response.end(``) + } else if (request.url === '/plugins/one/client.js?rev=one') { + response.writeHead(200, { 'content-type': 'text/javascript' }) + response.end('one') + } else if (request.url === '/api/pluginInventory/list') { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + type: 'server-response', + rpcId: 'dsh-platform-readiness', + result: { ok: true, value: { entries: [{ moduleName: 'ready', enabled: true, fiberPhase: 'active' }] } }, + })) + } else { + response.writeHead(404) + response.end() + } + }) + try { + await verifyDshWebReady({ + port: context.port, + managedReady: async () => ({ ready: true, readyUrl: `http://127.0.0.1:${String(context.port)}/?token=fixture-token` }), + }) + assert.equal(expectedCookie, 'dsh-auth-fixture=session') + assert.equal(context.requests.get('/?token=fixture-token'), 1) + } finally { + await new Promise(resolve => context.server.close(resolve)) + } +}) + +test('DSH web readiness rejects enabled Plugins that are not active', async () => { + const manifest = JSON.stringify({ rev: 'one', entries: [] }) + const routes = new Map([ + ['/', { status: 200, type: 'text/html', body: `` }], + ['POST /api/pluginInventory/list', { status: 200, type: 'application/json', body: JSON.stringify({ + type: 'server-response', + rpcId: 'dsh-platform-readiness', + result: { ok: true, value: { entries: [ + { moduleName: 'active', enabled: true, fiberPhase: 'active' }, + { moduleName: 'disabled', enabled: false, fiberPhase: null }, + { moduleName: 'broken', enabled: true, fiberPhase: 'failed' }, + ] } }, + }) }], + ]) + const context = await fixture(routes) + try { + await assert.rejects( + verifyDshWebReady({ port: context.port, managedReady: async () => {} }), + /DSH Plugins are not active: broken \(failed\)/, + ) + } finally { + await new Promise(resolve => context.server.close(resolve)) + } +}) + +test('DSH web readiness rejects Plugin inventory RPC failures', async () => { + const manifest = JSON.stringify({ rev: 'one', entries: [] }) + const routes = new Map([ + ['/', { status: 200, type: 'text/html', body: `` }], + ['POST /api/pluginInventory/list', { status: 200, type: 'application/json', body: JSON.stringify({ + type: 'server-response', + rpcId: 'dsh-platform-readiness', + result: { ok: false, error: { code: 'internal', message: 'loader unavailable', details: {} } }, + }) }], + ]) + const context = await fixture(routes) + try { + await assert.rejects( + verifyDshWebReady({ port: context.port, managedReady: async () => {} }), + /DSH Plugin inventory is unavailable: loader unavailable/, + ) + } finally { + await new Promise(resolve => context.server.close(resolve)) + } +}) + +test('DSH web readiness rejects a lifecycle generation change during verification', async () => { + const manifest = JSON.stringify({ rev: 'one', entries: [] }) + const routes = new Map([ + ['/', { status: 200, type: 'text/html', body: `` }], + ['POST /api/pluginInventory/list', { status: 200, type: 'application/json', body: JSON.stringify({ + type: 'server-response', + rpcId: 'dsh-platform-readiness', + result: { ok: true, value: { entries: [] } }, + }) }], + ]) + const context = await fixture(routes) + let observation = 0 + try { + await assert.rejects(verifyDshWebReady({ + port: context.port, + managedReady: async () => ({ generation: ++observation === 1 ? 'one' : 'two', ready: true, readyUrl: null }), + }), /lifecycle changed during Web readiness verification/) + assert.equal(context.requests.get('/'), 1) + assert.equal(context.requests.get('/api/pluginInventory/list'), 1) + } finally { + await new Promise(resolve => context.server.close(resolve)) + } +}) + +test('DSH web readiness rejects a boot manifest mutation between observations', async () => { + const manifest = rev => JSON.stringify({ rev, entries: [] }) + const routes = new Map([ + ['/', observation => ({ + status: 200, + type: 'text/html', + body: ``, + })], + ['POST /api/pluginInventory/list', { status: 200, type: 'application/json', body: JSON.stringify({ + type: 'server-response', + rpcId: 'dsh-platform-readiness', + result: { ok: true, value: { entries: [] } }, + }) }], + ]) + const context = await fixture(routes) + try { + await assert.rejects( + verifyDshWebReady({ port: context.port, managedReady: async () => ({ generation: 'one' }) }), + /boot manifest changed during Web readiness verification/, + ) + } finally { + await new Promise(resolve => context.server.close(resolve)) + } +}) + +test('DSH web readiness rejects a Plugin inventory mutation between observations', async () => { + const manifest = JSON.stringify({ rev: 'one', entries: [] }) + const inventory = moduleName => JSON.stringify({ + type: 'server-response', + rpcId: 'dsh-platform-readiness', + result: { ok: true, value: { entries: [{ moduleName, enabled: true, fiberPhase: 'active' }] } }, + }) + const routes = new Map([ + ['/', { status: 200, type: 'text/html', body: `` }], + ['POST /api/pluginInventory/list', observation => ({ + status: 200, + type: 'application/json', + body: inventory(observation === 1 ? 'one' : 'two'), + })], + ]) + const context = await fixture(routes) + try { + await assert.rejects( + verifyDshWebReady({ port: context.port, managedReady: async () => ({ generation: 'one' }) }), + /Plugin inventory changed during Web readiness verification/, + ) + } finally { + await new Promise(resolve => context.server.close(resolve)) + } +}) + +test('executes readiness checks when launched through the Bootstrap view symlink', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-web-ready-view-')) + const executable = join(root, 'dsh-web-ready.mjs') + await symlink(new URL('../../control-plane/hooks/dsh-web-ready.mjs', import.meta.url), executable) + const result = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [executable], { + env: { ...process.env, DSH_PLATFORM_RUN: join(root, 'missing-run') }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const stderr = [] + child.stderr.on('data', chunk => stderr.push(chunk)) + child.once('error', reject) + child.once('exit', code => resolve({ code, stderr: Buffer.concat(stderr).toString('utf8') })) + }) + assert.notEqual(result.code, 0) + assert.match(result.stderr, /dsh-lifecycle\.sock/) +}) diff --git a/upstream/container/platform/test/file-editor.test.mjs b/upstream/container/platform/test/file-editor.test.mjs new file mode 100644 index 0000000..52cc1c9 --- /dev/null +++ b/upstream/container/platform/test/file-editor.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { chmod, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { AtomicFileEditor } from '../../control-plane/modules/file-manager/editor.mjs' +import { FileInventory } from '../../control-plane/modules/file-manager/index.mjs' + +test('atomically replaces text with optimistic revision and preserves mode', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-editor-')) + const path = join(root, 'file.txt') + await writeFile(path, 'before') + await chmod(path, 0o640) + const inventory = new FileInventory() + const before = await inventory.content(path) + const saved = await new AtomicFileEditor().write(path, 'after\n', before.revision) + assert.equal(await readFile(path, 'utf8'), 'after\n') + assert.equal((await stat(path)).mode & 0o777, 0o640) + assert.notEqual(saved.revision, before.revision) + await assert.rejects(new AtomicFileEditor().write(path, 'stale', before.revision), error => error.statusCode === 409) + assert.equal(await readFile(path, 'utf8'), 'after\n') + assert.equal((await readdir(root)).some(name => name.startsWith('.dsh-edit-')), false) +}) + +test('creates a new text file without overwriting a concurrent target', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-create-')) + const path = join(root, 'new.txt') + const editor = new AtomicFileEditor() + const created = await editor.write(path, 'new', null, { create: true }) + assert.equal(created.size, 3) + await assert.rejects(editor.write(path, 'replace', null, { create: true }), error => error.statusCode === 409) + assert.equal(await readFile(path, 'utf8'), 'new') +}) + +test('rejects binary, oversized, missing, and non-file edits', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-editor-invalid-')) + const editor = new AtomicFileEditor() + await assert.rejects(editor.write(join(root, 'nul'), 'a\0b', null, { create: true }), error => error.statusCode === 415) + await assert.rejects(editor.write(join(root, 'large'), 'x'.repeat(2 * 1024 * 1024 + 1), null, { create: true }), error => error.statusCode === 413) + await assert.rejects(editor.write(join(root, 'missing'), 'x', 'sha256:missing'), error => error.statusCode === 404) + await assert.rejects(editor.write(root, 'x', 'sha256:missing'), error => error.statusCode === 415) +}) diff --git a/upstream/container/platform/test/file-manager.test.mjs b/upstream/container/platform/test/file-manager.test.mjs new file mode 100644 index 0000000..74c32ab --- /dev/null +++ b/upstream/container/platform/test/file-manager.test.mjs @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { + createManagedPathMatcher, fileManagerLocations, FileInventory, FileManagerError, + FileRevisionConflictError, FileSearchManager, FileSizeManager, isManagedPath, normalizeAbsolutePath, +} from '../../control-plane/modules/file-manager/index.mjs' + +test('normalizes absolute paths and rejects unsafe path representations', () => { + assert.equal(normalizeAbsolutePath('/workspace/a/../b'), '/workspace/b') + assert.throws(() => normalizeAbsolutePath('workspace'), /absolute/) + assert.throws(() => normalizeAbsolutePath('/workspace/a\nb'), /control/) + assert.throws(() => normalizeAbsolutePath(`/${'a'.repeat(4097)}`), /too long/) +}) + +test('identifies only exact platform-managed roots and descendants', () => { + assert.equal(isManagedPath('/data/platform/state'), true) + assert.equal(isManagedPath('/data/platform/state/updater/status.json'), true) + assert.equal(isManagedPath('/data/platform/stateful'), false) + assert.equal(isManagedPath('/data/dsh'), false) + const custom = createManagedPathMatcher('/srv/platform') + assert.equal(custom('/srv/platform/store/objects'), true) + assert.equal(custom('/data/platform/store/objects'), false) +}) + +test('derives the default directory and unique shortcuts from existing environment paths', () => { + assert.deepEqual(fileManagerLocations({ + defaultWorkspace: '/work/current', dshHome: '/srv/dsh', platformData: '/srv/platform', + }), { + defaultPath: '/work/current', shortcuts: ['/work/current', '/srv/dsh', '/srv/platform', '/'], + }) + assert.deepEqual(fileManagerLocations({ defaultWorkspace: '/data/dsh' }).shortcuts, ['/data/dsh', '/data/platform', '/']) + assert.throws(() => fileManagerLocations({ defaultWorkspace: 'relative' }), /absolute/) +}) + +test('lists directories with stable metadata, directory-first sorting, and revision cursors', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-list-')) + await mkdir(join(root, 'folder')) + await writeFile(join(root, 'z.txt'), 'z') + await writeFile(join(root, '.hidden'), 'hidden') + await symlink('missing', join(root, 'dangling')) + const inventory = new FileInventory() + const first = await inventory.list(root, { limit: 2 }) + assert.equal(first.entries[0].name, 'folder') + assert.equal(first.entries.some(entry => entry.name === '.hidden'), true) + assert.equal(first.total, 4) + assert.equal(typeof first.nextCursor, 'string') + const second = await inventory.list(root, { limit: 2, cursor: first.nextCursor }) + assert.equal(second.entries.length, 2) + const dangling = [...first.entries, ...second.entries].find(entry => entry.name === 'dangling') + assert.deepEqual([dangling.type, dangling.targetExists], ['symlink', false]) + await writeFile(join(root, 'new.txt'), 'new') + await assert.rejects(inventory.list(root, { cursor: first.nextCursor }), FileRevisionConflictError) +}) + +test('name pagination resolves detailed metadata only for the requested page', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-page-')) + for (let index = 0; index < 20; index += 1) await writeFile(join(root, `${String(index).padStart(2, '0')}.txt`), 'x') + let resolutions = 0 + const inventory = new FileInventory({ + identity: { resolve: async () => { resolutions += 1; return { user: 'node', group: 'node' } } }, + }) + const page = await inventory.list(root, { limit: 5 }) + assert.equal(page.entries.length, 5) + assert.equal(page.total, 20) + assert.equal(resolutions, 5) + const thirdPage = await inventory.list(root, { limit: 5, offset: 10 }) + assert.deepEqual(thirdPage.entries.map(entry => entry.name), ['10.txt', '11.txt', '12.txt', '13.txt', '14.txt']) + assert.equal(resolutions, 10) +}) + +test('reads only bounded UTF-8 regular files and detects binary content', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-content-')) + const text = join(root, '中文.txt') + await writeFile(text, 'hello\r\n世界\r\n') + const inventory = new FileInventory() + const result = await inventory.content(text) + assert.equal(result.content, 'hello\r\n世界\r\n') + assert.equal(result.newline, 'crlf') + assert.match(result.revision, /^sha256:[a-f0-9]{64}$/) + await writeFile(join(root, 'binary'), Buffer.from([1, 0, 2])) + await assert.rejects(inventory.content(join(root, 'binary')), error => error.statusCode === 415) + await assert.rejects(inventory.content(root), error => error.statusCode === 415) +}) + +test('reports container user and group and calculates directory size without following links', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-size-')) + await mkdir(join(root, 'nested')) + await writeFile(join(root, 'one.txt'), '1234') + await writeFile(join(root, 'nested', 'two.txt'), '567890') + await symlink('one.txt', join(root, 'link')) + const identity = { resolve: async () => ({ user: 'node', group: 'node' }) } + const inventory = new FileInventory({ identity }) + const listing = await inventory.list(root) + assert.equal(listing.entries.every(entry => entry.user === 'node' && entry.group === 'node'), true) + const size = new FileSizeManager({ inventory }) + const directory = await inventory.stat(root) + const task = size.start({ path: root, revision: directory.revision }) + await size.tasks.get(task.taskId).completion + const result = size.get(task.taskId) + assert.equal(result.status, 'success') + assert.equal(result.bytes, 10) + assert.equal(result.entries, 4) + const stale = size.start({ path: root, revision: listing.revision }) + await size.tasks.get(stale.taskId).completion + assert.equal(size.get(stale.taskId).status, 'failed') +}) + +test('bounded search does not follow symlinked directories and can be cancelled', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-search-')) + await mkdir(join(root, 'nested')) + await writeFile(join(root, 'nested', 'Needle.txt'), 'found') + await symlink(root, join(root, 'nested', 'loop')) + const states = [] + const manager = new FileSearchManager({ onState: state => states.push(state) }) + const task = manager.start({ path: root, query: 'needle' }) + await manager.tasks.get(task.taskId).completion + const result = manager.get(task.taskId) + assert.equal(result.status, 'success') + assert.deepEqual(result.results.map(entry => entry.name), ['Needle.txt']) + assert.equal(result.scanned, 3) + assert.equal(states.at(-1).status, 'success') + + const cancelled = manager.start({ path: root, query: 'x' }) + manager.cancel(cancelled.taskId) + await manager.tasks.get(cancelled.taskId).completion + assert.equal(manager.get(cancelled.taskId).status, 'cancelled') +}) + +test('rejects invalid list, search, and task parameters', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-invalid-')) + const inventory = new FileInventory() + await assert.rejects(inventory.list(root, { limit: 1001 }), FileManagerError) + await assert.rejects(inventory.list(root, { sort: 'owner' }), /sort/) + await assert.rejects(inventory.list(root, { offset: -1 }), /offset/) + await assert.rejects(inventory.list(root, { offset: 1.5 }), /offset/) + await writeFile(join(root, 'one'), '1') + await writeFile(join(root, 'two'), '2') + const first = await inventory.list(root, { limit: 1 }) + await assert.rejects(inventory.list(root, { cursor: first.nextCursor, offset: 0 }), /cannot be combined/) + const manager = new FileSearchManager() + assert.throws(() => manager.start({ path: root, query: '' }), /query/) + assert.throws(() => manager.get('missing'), error => error.statusCode === 404) +}) diff --git a/upstream/container/platform/test/file-tasks.test.mjs b/upstream/container/platform/test/file-tasks.test.mjs new file mode 100644 index 0000000..b8bb490 --- /dev/null +++ b/upstream/container/platform/test/file-tasks.test.mjs @@ -0,0 +1,450 @@ +import assert from 'node:assert/strict' +import { chmod, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { FileInventory } from '../../control-plane/modules/file-manager/index.mjs' +import { FileTaskManager, fileTaskInternals } from '../../control-plane/modules/file-manager/tasks.mjs' + +async function setup(prefix = 'dsh-file-tasks-') { + const root = await mkdtemp(join(tmpdir(), prefix)) + const files = join(root, 'files') + const tasks = join(root, 'tasks') + await mkdir(files) + const manager = new FileTaskManager({ root: tasks }) + await manager.initialize() + return { root, files, tasks, manager, inventory: new FileInventory() } +} + +async function finish(manager, task) { + await manager.tasks.get(task.taskId).completion + return manager.get(task.taskId) +} + +test('runs mkdir, touch, rename, copy, move, and permanent delete tasks', async () => { + const { files, manager, inventory } = await setup() + assert.equal((await finish(manager, manager.start({ operation: 'mkdir', destination: join(files, 'dir') }))).status, 'success') + assert.equal((await stat(join(files, 'dir'))).isDirectory(), true) + assert.equal((await finish(manager, manager.start({ operation: 'touch', destination: join(files, 'empty') }))).status, 'success') + const empty = await inventory.stat(join(files, 'empty')) + const renamed = await finish(manager, manager.start({ + operation: 'rename', sources: [{ path: empty.path, revision: empty.revision }], destination: join(files, 'renamed'), + })) + assert.equal(renamed.status, 'success') + await writeFile(join(files, 'dir', 'nested.txt'), 'nested') + await symlink('nested.txt', join(files, 'dir', 'link')) + await mkdir(join(files, 'copies')) + const source = await inventory.stat(join(files, 'dir')) + const copied = await finish(manager, manager.start({ + operation: 'copy', sources: [{ path: source.path, revision: source.revision }], destination: join(files, 'copies'), conflict: 'reject', + })) + assert.equal(copied.status, 'success') + assert.equal(await readFile(join(files, 'copies', 'dir', 'nested.txt'), 'utf8'), 'nested') + const link = await inventory.stat(join(files, 'copies', 'dir', 'link')) + assert.equal(link.type, 'symlink') + const movedSource = await inventory.stat(join(files, 'renamed')) + const moved = await finish(manager, manager.start({ + operation: 'move', sources: [{ path: movedSource.path, revision: movedSource.revision }], destination: join(files, 'copies'), + })) + assert.equal(moved.status, 'success') + const deleteSource = await inventory.stat(join(files, 'copies', 'dir')) + const removed = await finish(manager, manager.start({ operation: 'delete', sources: [{ path: deleteSource.path, revision: deleteSource.revision }] })) + assert.equal(removed.status, 'success') + assert.equal(removed.processedBytes, removed.totalBytes) + assert.equal(removed.processedEntries, removed.totalEntries) + assert.equal(await stat(deleteSource.path).then(() => true, error => error.code !== 'ENOENT'), false) + assert.equal((await readdir(join(files, 'copies'))).some(name => name.startsWith('.dsh-')), false) +}) + +test('releases the mutation lease before publishing a terminal task state', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-terminal-lease-')) + const files = join(root, 'files') + const tasks = join(root, 'tasks') + const destination = join(files, 'destination') + await mkdir(destination, { recursive: true }) + await writeFile(join(files, 'item.txt'), 'source') + await writeFile(join(destination, 'item.txt'), 'existing') + let releaseReport + const blockedReport = new Promise(resolve => { releaseReport = resolve }) + let observeFailure + const failurePublished = new Promise(resolve => { observeFailure = resolve }) + const inventory = new FileInventory() + const manager = new FileTaskManager({ + root: tasks, + onState: task => { if (task.status === 'failed') observeFailure() }, + report: message => message === 'file-task.copy.failed' ? blockedReport : undefined, + }) + await manager.initialize() + const source = await inventory.stat(join(files, 'item.txt')) + const rejected = manager.start({ + operation: 'copy', sources: [{ path: source.path, revision: source.revision }], destination, conflict: 'reject', + }) + await failurePublished + assert.equal(manager.activeMutation, undefined) + assert.equal(manager.queue.some(task => task.taskId === rejected.taskId), false) + manager.activeMutation = manager.tasks.get(rejected.taskId) + const renamed = manager.start({ + operation: 'copy', sources: [{ path: source.path, revision: source.revision }], destination, conflict: 'rename', + }) + releaseReport() + assert.equal((await finish(manager, renamed)).status, 'success') + assert.equal((await finish(manager, rejected)).status, 'failed') + assert.equal((await readdir(destination)).length, 2) +}) + +test('changes file ownership and permissions recursively without following symbolic links', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-attributes-')) + const files = join(root, 'files') + const tasks = join(root, 'tasks') + const tree = join(files, 'tree') + const nested = join(tree, 'nested') + const target = join(root, 'outside.txt') + await mkdir(nested, { recursive: true }) + await writeFile(join(nested, 'inside.txt'), 'inside') + await writeFile(target, 'outside') + await chmod(target, 0o640) + await symlink(target, join(tree, 'outside-link')) + const identity = { + resolve: async () => ({ user: 'current', group: 'current' }), + userId: async value => value === 'current' ? process.getuid() : Promise.reject(new Error('unknown user')), + groupId: async value => value === 'current' ? process.getgid() : Promise.reject(new Error('unknown group')), + } + const inventory = new FileInventory({ identity }) + const reports = [] + const manager = new FileTaskManager({ root: tasks, inventory, identity, report: (message, fields) => reports.push({ message, fields }) }) + await manager.initialize() + const source = await inventory.stat(tree) + const started = manager.start({ + operation: 'attributes', + sources: [{ path: tree, revision: source.revision }], + attributes: { user: 'current', group: 'current', mode: '0750', recursive: true }, + }) + const result = await manager.completion(started.taskId) + assert.equal(result.status, 'success') + assert.equal(result.errorCode, null) + assert.equal(result.processedEntries, 4) + assert.equal((await stat(tree)).mode & 0o7777, 0o750) + assert.equal((await stat(nested)).mode & 0o7777, 0o750) + assert.equal((await stat(join(nested, 'inside.txt'))).mode & 0o7777, 0o750) + assert.equal((await stat(target)).mode & 0o7777, 0o640) + assert.equal(reports.at(-1).message, 'file-task.attributes.completed') +}) + +test('rejects filesystems that silently ignore Unix ownership or mode changes', () => { + const attributes = { uid: 0, gid: 0, mode: 0o600 } + assert.doesNotThrow(() => fileTaskInternals.assertAttributesApplied('/supported', { uid: 0, gid: 0, mode: 0o100600 }, attributes, false)) + assert.throws( + () => fileTaskInternals.assertAttributesApplied('/unsupported', { uid: 1000, gid: 1000, mode: 0o100755 }, attributes, false), + error => error.code === 'FILE_ATTRIBUTES_UNSUPPORTED' && error.statusCode === 403, + ) +}) + +test('reports attribute failures instead of a false completion', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-attributes-failed-log-')) + const path = join(root, 'item') + await writeFile(path, 'value') + const identity = { + resolve: async () => ({ user: 'current', group: 'current' }), + userId: async () => { throw Object.assign(new Error('ownership is unsupported'), { code: 'FILE_ATTRIBUTES_UNSUPPORTED' }) }, + groupId: async () => process.getgid(), + } + const inventory = new FileInventory({ identity }) + const reports = [] + const manager = new FileTaskManager({ root: join(root, 'tasks'), inventory, identity, report: message => reports.push(message) }) + await manager.initialize() + const source = await inventory.stat(path) + const started = manager.start({ + operation: 'attributes', sources: [{ path, revision: source.revision }], + attributes: { user: 'current', group: 'current', mode: '0600', recursive: false }, + }) + const result = await manager.completion(started.taskId) + assert.equal(result.status, 'failed') + assert.equal(result.errorCode, 'FILE_ATTRIBUTES_UNSUPPORTED') + assert.deepEqual(reports, ['file-task.attributes.failed']) +}) + +test('validates attribute requests and resumes an interrupted idempotent change', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-attributes-recovery-')) + const files = join(root, 'files') + const tasks = join(root, 'tasks') + await mkdir(files) + await mkdir(tasks) + const path = join(files, 'item') + await writeFile(path, 'value') + const identity = { + resolve: async () => ({ user: 'current', group: 'current' }), + userId: async () => process.getuid(), + groupId: async () => process.getgid(), + } + const inventory = new FileInventory({ identity }) + const source = await inventory.stat(path) + const manager = new FileTaskManager({ root: tasks, inventory, identity }) + assert.throws(() => manager.start({ + operation: 'attributes', sources: [{ path, revision: source.revision }], + attributes: { user: 'current', group: 'current', mode: '888', recursive: false }, + }), /attributes/) + const task = { + schema: 1, taskId: '55555555-5555-4555-8555-555555555555', operation: 'attributes', status: 'running', phase: 'mutating', + sources: [{ path, revision: source.revision }], destination: null, destinationRevision: null, conflict: 'reject', managed: false, + attributes: { user: 'current', group: 'current', mode: '0600', recursive: false }, + processedBytes: 0, totalBytes: 5, processedEntries: 0, totalEntries: 1, + published: [], staging: [], hidden: [], currentPath: path, currentSource: null, currentDestination: null, + error: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), cancelRequested: false, + } + await writeFile(join(tasks, `${task.taskId}.json`), JSON.stringify(task)) + const recovered = new FileTaskManager({ root: tasks, inventory, identity }) + await recovered.initialize() + assert.equal(recovered.get(task.taskId).status, 'success') + assert.equal((await stat(path)).mode & 0o7777, 0o600) +}) + +test('rejects stale revisions, protected roots, and recursive copies while queueing concurrent mutations', async () => { + const { files, manager, inventory } = await setup() + const sourcePath = join(files, 'source') + await writeFile(sourcePath, 'one') + const source = await inventory.stat(sourcePath) + await writeFile(sourcePath, 'two') + const stale = manager.start({ operation: 'delete', sources: [{ path: sourcePath, revision: source.revision }] }) + assert.equal((await finish(manager, stale)).status, 'failed') + assert.throws(() => manager.start({ operation: 'delete', sources: [{ path: '/', revision: 'x' }] }), error => error.statusCode === 403) + await mkdir(join(files, 'tree')) + const tree = await inventory.stat(join(files, 'tree')) + const recursive = manager.start({ operation: 'copy', sources: [{ path: tree.path, revision: tree.revision }], destination: tree.path }) + assert.equal((await finish(manager, recursive)).status, 'failed') + const first = manager.start({ operation: 'touch', destination: join(files, 'first') }) + const second = manager.start({ operation: 'touch', destination: join(files, 'second') }) + assert.equal(second.status, 'queued') + assert.equal(second.queuePosition, 1) + await finish(manager, first) + assert.equal((await finish(manager, second)).status, 'success') +}) + +test('runs queued mutations in FIFO order and cancels queued work without touching files', async () => { + const { files, manager } = await setup('dsh-file-queue-') + const first = manager.start({ operation: 'touch', destination: join(files, 'first') }) + const second = manager.start({ operation: 'touch', destination: join(files, 'second') }) + const third = manager.start({ operation: 'touch', destination: join(files, 'third') }) + assert.deepEqual([second.queuePosition, third.queuePosition], [1, 2]) + assert.equal(manager.cancel(second.taskId).status, 'cancelled') + assert.equal(manager.get(third.taskId).queuePosition, 1) + assert.equal((await finish(manager, first)).status, 'success') + assert.equal((await finish(manager, second)).status, 'cancelled') + assert.equal((await finish(manager, third)).status, 'success') + assert.equal(await stat(join(files, 'second')).then(() => true, error => error.code !== 'ENOENT'), false) +}) + +test('persists queued work before the active task releases its lease', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-queue-persisted-')) + const files = join(root, 'files') + const tasks = join(root, 'tasks') + await mkdir(files) + let releaseReport + const blocked = new Promise(resolve => { releaseReport = resolve }) + const manager = new FileTaskManager({ root: tasks, report: message => message === 'file-task.touch.completed' ? blocked : undefined }) + await manager.initialize() + const first = manager.start({ operation: 'touch', destination: join(files, 'first') }) + const second = manager.start({ operation: 'touch', destination: join(files, 'second') }) + await manager.tasks.get(second.taskId).persisted + const journal = JSON.parse(await readFile(join(tasks, `${second.taskId}.json`), 'utf8')) + assert.equal(journal.status, 'queued') + assert.equal(journal.queuePosition, 1) + releaseReport() + assert.equal((await finish(manager, first)).status, 'success') + assert.equal((await finish(manager, second)).status, 'success') +}) + +test('reports real transfer progress and cancels a queued transfer', async () => { + const { files, manager } = await setup('dsh-file-transfer-progress-') + const transfer = manager.startTransfer({ operation: 'upload', path: join(files, 'upload.bin'), totalBytes: 10 }, async ({ progress }) => { + progress({ processedBytes: 4, totalBytes: 10 }) + progress({ processedBytes: 10, totalBytes: 10 }) + return { size: 10 } + }) + assert.deepEqual(await transfer.result, { size: 10 }) + await manager.completion(transfer.task.taskId) + assert.deepEqual( + [manager.get(transfer.task.taskId).status, manager.get(transfer.task.taskId).processedBytes], + ['success', 10], + ) + + let release + const blocked = new Promise(resolve => { release = resolve }) + const active = manager.startTransfer({ operation: 'download', path: join(files, 'active') }, () => blocked) + const queued = manager.startTransfer({ operation: 'download', path: join(files, 'queued') }, async () => undefined) + const rejection = assert.rejects(queued.result, error => error.code === 'FILE_TASK_CANCELLED') + assert.equal(manager.cancel(queued.task.taskId).status, 'cancelled') + await rejection + release() + await active.result +}) + +test('marks persisted transfers interrupted because HTTP streams cannot resume', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-transfer-recovery-')) + const tasks = join(root, 'tasks') + await mkdir(tasks) + for (const [index, status] of ['running', 'queued'].entries()) { + const taskId = `${String(index + 8).repeat(8)}-${String(index + 8).repeat(4)}-4${String(index + 8).repeat(3)}-8${String(index + 8).repeat(3)}-${String(index + 8).repeat(12)}` + await writeFile(join(tasks, `${taskId}.json`), JSON.stringify({ + schema: 1, taskId, operation: index === 0 ? 'upload' : 'download', status, + phase: status === 'queued' ? 'queued' : 'transferring', path: join(root, 'value'), + managed: false, staging: [], hidden: [], published: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + })) + } + const recovered = new FileTaskManager({ root: tasks }) + await recovered.initialize() + assert.equal(recovered.list().filter(task => ['upload', 'download'].includes(task.operation)).every(task => task.status === 'interrupted'), true) +}) + +test('recovers committed deletes and marks uncommitted tasks interrupted', async () => { + const { files, tasks } = await setup() + const hidden = join(files, '.dsh-delete-task.tmp') + await writeFile(hidden, 'remove') + const runningDelete = { + schema: 1, taskId: '11111111-1111-4111-8111-111111111111', operation: 'delete', status: 'running', phase: 'source-hidden', + sources: [], destination: null, managed: false, hidden: [hidden], staging: [], published: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } + const staging = join(files, '.dsh-file-task.tmp') + await writeFile(staging, 'partial') + const runningCopy = { + ...runningDelete, taskId: '22222222-2222-4222-8222-222222222222', operation: 'copy', phase: 'mutating', hidden: [], staging: [staging], + } + await writeFile(join(tasks, `${runningDelete.taskId}.json`), JSON.stringify(runningDelete)) + await writeFile(join(tasks, `${runningCopy.taskId}.json`), JSON.stringify(runningCopy)) + const recovered = new FileTaskManager({ root: tasks }) + await recovered.initialize() + assert.equal(recovered.get(runningDelete.taskId).status, 'success') + assert.equal(recovered.get(runningCopy.taskId).status, 'interrupted') + assert.equal(await stat(hidden).then(() => true, error => error.code !== 'ENOENT'), false) + assert.equal(await stat(staging).then(() => true, error => error.code !== 'ENOENT'), false) +}) + +test('move recovery cleans only the source bound to the committed destination', async () => { + const { files, tasks } = await setup('dsh-file-move-recovery-') + const committedSource = join(files, 'committed-source') + const untouchedSource = join(files, 'untouched-source') + const destination = join(files, 'destination') + await writeFile(committedSource, 'old duplicate') + await writeFile(untouchedSource, 'must remain') + await writeFile(destination, 'committed') + const task = { + schema: 1, taskId: '33333333-3333-4333-8333-333333333333', operation: 'move', status: 'running', phase: 'destination-committed', + sources: [{ path: committedSource, revision: 'old' }, { path: untouchedSource, revision: 'untouched' }], + destination: files, currentSource: committedSource, currentDestination: destination, + managed: false, hidden: [], staging: [], published: [destination], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } + await writeFile(join(tasks, `${task.taskId}.json`), JSON.stringify(task)) + const recovered = new FileTaskManager({ root: tasks }) + await recovered.initialize() + assert.equal(recovered.get(task.taskId).status, 'success') + assert.equal(await stat(committedSource).then(() => true, error => error.code !== 'ENOENT'), false) + assert.equal(await readFile(untouchedSource, 'utf8'), 'must remain') +}) + +test('move recovery recognizes an atomic rename completed before its journal commit', async () => { + const { files, tasks } = await setup('dsh-file-rename-recovery-') + const source = join(files, 'source') + const destination = join(files, 'destination') + await writeFile(destination, 'moved') + const task = { + schema: 1, taskId: '44444444-4444-4444-8444-444444444444', operation: 'move', status: 'running', phase: 'move-prepared', + sources: [{ path: source, revision: 'old' }], destination: files, + currentSource: source, currentDestination: destination, managed: false, hidden: [], staging: [], published: [], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } + await writeFile(join(tasks, `${task.taskId}.json`), JSON.stringify(task)) + const recovered = new FileTaskManager({ root: tasks }) + await recovered.initialize() + assert.equal(recovered.get(task.taskId).status, 'success') + assert.deepEqual(recovered.get(task.taskId).published, [destination]) +}) + +test('managed mutations expose a lease and reject active platform work', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-managed-tasks-')) + const manager = new FileTaskManager({ root: join(root, 'tasks'), platformBusy: () => true }) + await manager.initialize() + assert.equal(manager.wouldManage({ operation: 'touch', destination: '/data/platform/state/test' }), true) + assert.throws(() => manager.start({ operation: 'touch', destination: '/data/platform/state/test' }), error => error.statusCode === 409) + + const custom = new FileTaskManager({ root: join(root, 'custom-tasks'), protectedRoots: ['/custom/workspace'] }) + assert.throws(() => custom.start({ + operation: 'delete', sources: [{ path: '/custom/workspace', revision: 'sha256:unused' }], + }), error => error.statusCode === 403) +}) + +test('creates and extracts zip, 7z, and tar.gz archives through file tasks', async () => { + for (const [format, extension] of [['zip', 'zip'], ['7z', '7z'], ['tar.gz', 'tar.gz']]) { + const { files, manager, inventory } = await setup(`dsh-file-archive-${format.replace('.', '-')}-`) + const sourcePath = join(files, 'source') + await mkdir(sourcePath) + await writeFile(join(sourcePath, 'nested.txt'), `content-${format}`) + const source = await inventory.stat(sourcePath) + const archivePath = join(files, `bundle.${extension}`) + const archived = await finish(manager, manager.start({ + operation: 'archive', archiveFormat: format, + sources: [{ path: source.path, revision: source.revision }], destination: archivePath, + })) + assert.equal(archived.status, 'success') + assert.equal((await stat(archivePath)).isFile(), true) + const extractRoot = join(files, 'extracted') + await mkdir(extractRoot) + const archive = await inventory.stat(archivePath) + const extracted = await finish(manager, manager.start({ + operation: 'extract', archiveFormat: format, + sources: [{ path: archive.path, revision: archive.revision }], destination: extractRoot, + })) + assert.equal(extracted.status, 'success') + assert.equal(await readFile(join(extractRoot, 'source', 'nested.txt'), 'utf8'), `content-${format}`) + } +}) + +test('rejects invalid archive formats and escaping extracted links', async () => { + const { files, manager, inventory } = await setup('dsh-file-archive-unsafe-') + const sourcePath = join(files, 'source') + await mkdir(sourcePath) + await symlink('../../outside', join(sourcePath, 'escape')) + const source = await inventory.stat(sourcePath) + assert.throws(() => manager.start({ + operation: 'archive', archiveFormat: 'rar', sources: [{ path: source.path, revision: source.revision }], + destination: join(files, 'bad.rar'), + }), /format/) + const archivePath = join(files, 'unsafe.tar.gz') + assert.equal((await finish(manager, manager.start({ + operation: 'archive', archiveFormat: 'tar.gz', sources: [{ path: source.path, revision: source.revision }], destination: archivePath, + }))).status, 'success') + const archive = await inventory.stat(archivePath) + const destination = join(files, 'destination') + await mkdir(destination) + const extracted = await finish(manager, manager.start({ + operation: 'extract', archiveFormat: 'tar.gz', sources: [{ path: archive.path, revision: archive.revision }], destination, + })) + assert.equal(extracted.status, 'failed') + assert.equal(extracted.errorCode, 'ARCHIVE_UNSAFE') +}) + +test('recovers committed archives and resumes staged extraction publication', async () => { + const { files, tasks } = await setup('dsh-file-archive-recovery-') + const archivePath = join(files, 'published.zip') + await writeFile(archivePath, 'published') + const archiveTask = { + schema: 1, taskId: '66666666-6666-4666-8666-666666666666', operation: 'archive', status: 'running', phase: 'destination-committed', + sources: [], destination: archivePath, managed: false, hidden: [], staging: [], published: [archivePath], + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } + const destination = join(files, 'destination') + const staging = join(destination, '.dsh-extract-recovery.tmp') + await mkdir(staging, { recursive: true }) + await writeFile(join(staging, 'remaining.txt'), 'remaining') + const extractTask = { + schema: 1, taskId: '77777777-7777-4777-8777-777777777777', operation: 'extract', status: 'running', phase: 'publishing', + sources: [], destination, conflict: 'reject', managed: false, hidden: [], staging: [staging], published: [], + processedBytes: 0, totalBytes: 9, processedEntries: 0, totalEntries: 1, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), cancelRequested: false, + } + await writeFile(join(tasks, `${archiveTask.taskId}.json`), JSON.stringify(archiveTask)) + await writeFile(join(tasks, `${extractTask.taskId}.json`), JSON.stringify(extractTask)) + const recovered = new FileTaskManager({ root: tasks }) + await recovered.initialize() + assert.equal(recovered.get(archiveTask.taskId).status, 'success') + assert.equal(recovered.get(extractTask.taskId).status, 'success') + assert.equal(await readFile(join(destination, 'remaining.txt'), 'utf8'), 'remaining') +}) diff --git a/upstream/container/platform/test/file-transfers.test.mjs b/upstream/container/platform/test/file-transfers.test.mjs new file mode 100644 index 0000000..ca5cf91 --- /dev/null +++ b/upstream/container/platform/test/file-transfers.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Readable } from 'node:stream' +import { spawnSync } from 'node:child_process' +import test from 'node:test' +import { FileTransferManager, fileTransferInternals } from '../../control-plane/modules/file-manager/transfers.mjs' + +async function collect(stream) { + const chunks = [] + for await (const chunk of stream) chunks.push(chunk) + return Buffer.concat(chunks) +} + +test('streams complete, ranged, suffix, and empty downloads', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-download-')) + const path = join(root, '中文 name.txt') + await writeFile(path, '0123456789') + const transfers = new FileTransferManager() + const complete = await transfers.openDownload(path) + assert.equal((await collect(complete.stream)).toString(), '0123456789') + await complete.handle.close() + assert.equal(complete.headers['content-length'], '10') + assert.match(complete.headers['content-disposition'], /filename\*=UTF-8''/) + const ranged = await transfers.openDownload(path, { range: 'bytes=2-5', revision: complete.revision }) + assert.equal(ranged.status, 206) + assert.equal((await collect(ranged.stream)).toString(), '2345') + await ranged.handle.close() + const suffix = await transfers.openDownload(path, { range: 'bytes=-3' }) + assert.equal((await collect(suffix.stream)).toString(), '789') + await suffix.handle.close() + const emptyPath = join(root, 'empty') + await writeFile(emptyPath, '') + const empty = await transfers.openDownload(emptyPath) + assert.equal((await collect(empty.stream)).byteLength, 0) + await empty.handle.close() + await assert.rejects(transfers.openDownload(path, { range: 'bytes=20-30' }), error => error.statusCode === 416) +}) + +test('streams directories as temporary zip downloads', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-directory-download-')) + const directory = join(root, 'folder') + await mkdir(directory) + await writeFile(join(directory, 'entry.txt'), 'directory-content') + const transfers = new FileTransferManager({ stagingRoot: join(root, 'cache') }) + const download = await transfers.openDownload(directory) + const archive = join(root, 'folder.zip') + await writeFile(archive, await collect(download.stream)) + await download.handle.close() + await download.cleanup() + assert.match(download.headers['content-disposition'], /folder\.zip/) + const extracted = spawnSync('unzip', ['-p', archive, 'folder/entry.txt'], { encoding: 'utf8' }) + assert.equal(extracted.status, 0, extracted.stderr) + assert.equal(extracted.stdout, 'directory-content') +}) + +test('uploads through sibling staging with reject, overwrite, and rename policies', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-upload-')) + const path = join(root, 'file.txt') + const transfers = new FileTransferManager() + const first = await transfers.upload(Readable.from(['first']), path, { contentLength: 5 }) + assert.equal(first.path, path) + assert.equal(await readFile(path, 'utf8'), 'first') + await assert.rejects(transfers.upload(Readable.from(['no']), path), error => error.statusCode === 409) + const renamed = await transfers.upload(Readable.from(['second']), path, { conflict: 'rename' }) + assert.equal(renamed.path, join(root, 'file (1).txt')) + assert.equal(await readFile(renamed.path, 'utf8'), 'second') + await mkdir(join(root, 'directory')) + const directoryConflict = await transfers.upload(Readable.from(['safe']), join(root, 'directory'), { conflict: 'rename' }) + assert.equal(directoryConflict.path, join(root, 'directory (1)')) + const oldMode = (await stat(path)).mode & 0o777 + await transfers.upload(Readable.from(['replacement']), path, { conflict: 'overwrite' }) + assert.equal(await readFile(path, 'utf8'), 'replacement') + assert.equal((await stat(path)).mode & 0o777, oldMode) +}) + +test('cleans staging files after interrupted or mismatched uploads', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-upload-failure-')) + const path = join(root, 'file') + const transfers = new FileTransferManager() + await assert.rejects(transfers.upload(Readable.from(['short']), path, { contentLength: 100 }), /Content-Length/) + assert.equal(await readFile(path).then(() => true, error => error.code !== 'ENOENT'), false) + const entries = await import('node:fs/promises').then(fs => fs.readdir(root)) + assert.deepEqual(entries, []) +}) + +test('sanitizes download names and parses only a single byte range', () => { + assert.match(fileTransferInternals.disposition('a"b.txt'), /filename="a_b.txt"/) + assert.deepEqual(fileTransferInternals.parseRange('bytes=0-0', 5), { start: 0, end: 0, partial: true }) + assert.throws(() => fileTransferInternals.parseRange('bytes=0-1,3-4', 5), /invalid/) +}) diff --git a/upstream/container/platform/test/fixtures/dsh-package.mjs b/upstream/container/platform/test/fixtures/dsh-package.mjs new file mode 100644 index 0000000..a52bc97 --- /dev/null +++ b/upstream/container/platform/test/fixtures/dsh-package.mjs @@ -0,0 +1,30 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +export async function writeDshEntrypointFixture(packageRoot) { + await mkdir(join(packageRoot, 'lib'), { recursive: true }) + await writeFile(join(packageRoot, 'lib/bin.js'), `#!/usr/bin/env node +const invocation = parseDshArgs(process.argv.slice(2), readVersion()); +switch (invocation.mode) { + case "profile": { + const { runProfile } = await import("./profile-boot-fixture.js"); + } +} +`) + await writeFile(join(packageRoot, 'lib/profile-boot-fixture.js'), 'import { r as runProfile } from "./profile-boot-implementation.js";\nexport { runProfile };\n') + await writeFile(join(packageRoot, 'lib/profile-boot-implementation.js'), `async function runProfile(options) { +\tprocess.on("SIGTERM", () => { +\t\tinterrupt(0); +\t}); +\tprocess.on("SIGINT", () => { interrupt(130); }); +\tconst ctx = await boot(NAME, rootConfig, patches, (hostCtx) => { +\t\thostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment); +\t}); +\treturn { +\t\tctx, +\t\tshutdown +\t}; +} +export { runProfile as r }; +`) +} diff --git a/upstream/container/platform/test/helpers.mjs b/upstream/container/platform/test/helpers.mjs new file mode 100644 index 0000000..66b269b --- /dev/null +++ b/upstream/container/platform/test/helpers.mjs @@ -0,0 +1,102 @@ +import { createHash, generateKeyPairSync, sign } from 'node:crypto' + +export function keyPair() { + const pair = generateKeyPairSync('ed25519') + const der = pair.publicKey.export({ format: 'der', type: 'spki' }) + return { + privateKey: pair.privateKey, + publicKey: der.toString('base64'), + keyId: createHash('sha256').update(der).digest('hex'), + } +} + +export function registryKeyPair() { + const pair = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) + const der = pair.publicKey.export({ format: 'der', type: 'spki' }) + return { + privateKey: pair.privateKey, + publicKey: der.toString('base64'), + keyId: `SHA256:${createHash('sha256').update(der).digest('base64').replace(/=$/, '')}`, + } +} + +const defaultRegistry = registryKeyPair() + +export function officialDshPolicy(pair = defaultRegistry, expires = null) { + return { + registry: 'https://registry.npmjs.org/', + packageName: '@deepseek-ai/dsh', + keys: [{ algorithm: 'ECDSA-P256-SHA256', keyId: pair.keyId, publicKey: pair.publicKey, expires }], + } +} + +export function registryCandidate(pair, version = '0.1.0-rc.8', content = Buffer.alloc(0)) { + const integrity = `sha512-${createHash('sha512').update(content).digest('base64')}` + const message = `@deepseek-ai/dsh@${version}:${integrity}` + return { + schema: 1, + name: '@deepseek-ai/dsh', + version, + dist: { + integrity, + tarball: `https://registry.npmjs.org/@deepseek-ai/dsh/-/dsh-${version}.tgz`, + signatures: [{ keyid: pair.keyId, sig: sign('sha256', Buffer.from(message), pair.privateKey).toString('base64') }], + }, + } +} + +export function releaseKey(pair) { + return { algorithm: 'Ed25519', keyId: pair.keyId, publicKey: pair.publicKey } +} + +export function document(value) { + return Buffer.from(`${JSON.stringify(value)}\n`) +} + +export function signature(bytes, pair) { + return { + schema: 1, + algorithm: 'Ed25519', + keyId: pair.keyId, + signature: sign(null, bytes, pair.privateKey).toString('base64'), + } +} + +export function keyring(generation, current, next, revokedKeyIds = []) { + return { + schema: 1, + generation, + issuedAt: new Date(Date.UTC(2026, 7, 19, 0, 0, generation)).toISOString(), + current: releaseKey(current), + next: releaseKey(next), + revokedKeyIds: [...revokedKeyIds].sort(), + } +} + +export function target(generation, sequence, policy = officialDshPolicy()) { + const emptyHash = '0'.repeat(64) + const artifacts = ['bootstrap-manifest', 'bootstrap-signature', 'environment-manifest', 'environment-signature'].map(id => ({ + id, + mediaType: 'application/octet-stream', + sha256: emptyHash, + size: 0, + url: `https://example.com/${id}`, + })) + return { + schema: 1, + updateApi: 1, + keyringGeneration: generation, + targetSequence: sequence, + issuedAt: new Date(Date.UTC(2026, 7, 19, 1, 0, sequence)).toISOString(), + artifacts, + desired: { + bootstrap: { version: '1.0.0', manifestArtifactId: 'bootstrap-manifest', signatureArtifactId: 'bootstrap-signature' }, + environment: { version: '1.0.0', manifestArtifactId: 'environment-manifest', signatureArtifactId: 'environment-signature' }, + dsh: { + version: '0.1.0-rc.7', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + }, + }, + officialDshPolicy: policy, + } +} diff --git a/upstream/container/platform/test/journal.test.mjs b/upstream/container/platform/test/journal.test.mjs new file mode 100644 index 0000000..40c919d --- /dev/null +++ b/upstream/container/platform/test/journal.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { UpdateJournal } from '../../control-plane/modules/updater/lib/journal.mjs' + +const transaction = { + transactionId: 'transaction-one', + mode: 'experimental', + from: { + dsh: '0.1.0-rc.7', environment: 'env-1', runtime: 'runtime-a', dataSnapshot: null, receiptTokens: [], + }, + to: { dsh: '0.1.0-rc.8', environment: 'env-1', runtime: 'runtime-b' }, +} + +test('persists a complete update transaction across process restarts', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-journal-')) + const path = join(root, 'state', 'update-transaction.json') + let tick = 0 + const now = () => new Date(Date.UTC(2026, 7, 19, 0, 0, tick++)) + const journal = new UpdateJournal(path, now) + await journal.begin(transaction) + await journal.transition('candidate-ready', { receiptTokens: ['receipt-one'] }) + await journal.transition('suspended') + await journal.transition('snapshot-created', { snapshotId: 'snapshot-one' }) + + const resumed = await new UpdateJournal(path, now).read() + assert.equal(resumed.phase, 'snapshot-created') + assert.equal(resumed.from.runtime, 'runtime-a') + assert.equal(resumed.to.runtime, 'runtime-b') + assert.equal(resumed.snapshotId, 'snapshot-one') + assert.deepEqual(resumed.receiptTokens, ['receipt-one']) +}) + +test('rejects skipped phases and clears only terminal journals', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-journal-transition-')) + const journal = new UpdateJournal(join(root, 'state', 'update-transaction.json')) + await journal.begin(transaction) + await assert.rejects(journal.transition('switched'), /cannot transition/) + await assert.rejects(journal.clear(), /nonterminal/) + await journal.transition('failed', { error: 'candidate build failed' }) + await journal.clear() + assert.equal(await journal.read(), undefined) +}) + +test('records an idempotent data-restoration phase before rollback completion', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-journal-restore-')) + const path = join(root, 'state', 'update-transaction.json') + const journal = new UpdateJournal(path) + await journal.begin(transaction) + await journal.transition('candidate-ready') + await journal.transition('suspended') + await journal.transition('snapshot-created', { snapshotId: 'snapshot-one' }) + await journal.transition('switched') + await journal.transition('restoring-data') + assert.equal((await new UpdateJournal(path).read()).phase, 'restoring-data') + await journal.transition('rolled-back') +}) diff --git a/upstream/container/platform/test/keyring.test.mjs b/upstream/container/platform/test/keyring.test.mjs new file mode 100644 index 0000000..c676692 --- /dev/null +++ b/upstream/container/platform/test/keyring.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { parseKeyring, validateKeyringTransition, verifyRecoveryKeyring } from '../stage0/lib/keyring.mjs' +import { document, keyPair, keyring, signature } from './helpers.mjs' + +test('accepts a Recovery-signed keyring with derived current and next IDs', () => { + const recovery = keyPair() + const value = keyring(1, keyPair(), keyPair()) + const bytes = document(value) + assert.deepEqual(verifyRecoveryKeyring(bytes, signature(bytes, recovery), recovery.publicKey), value) +}) + +test('rejects a keyring signed by a non-Recovery key or modified after signing', () => { + const recovery = keyPair() + const other = keyPair() + const bytes = document(keyring(1, keyPair(), keyPair())) + assert.throws(() => verifyRecoveryKeyring(bytes, signature(bytes, other), recovery.publicKey), { + code: 'TRUST_UNKNOWN_KEY', + }) + const signed = signature(bytes, recovery) + assert.throws(() => verifyRecoveryKeyring(Buffer.concat([bytes, Buffer.from(' ')]), signed, recovery.publicKey), { + code: 'TRUST_BAD_SIGNATURE', + }) +}) + +test('rejects mismatched IDs, duplicate active keys, revoked active keys, and unknown algorithms', () => { + const current = keyPair() + const next = keyPair() + const cases = [ + { ...keyring(1, current, next), current: { ...keyring(1, current, next).current, keyId: next.keyId } }, + { ...keyring(1, current, next), next: { ...keyring(1, current, next).current } }, + { ...keyring(1, current, next), revokedKeyIds: [current.keyId] }, + { ...keyring(1, current, next), current: { ...keyring(1, current, next).current, algorithm: 'RSA' } }, + ] + for (const value of cases) assert.throws(() => parseKeyring(document(value))) +}) + +test('requires transitions to preserve revocations and revoke removed keys', () => { + const first = keyPair() + const second = keyPair() + const third = keyPair() + const fourth = keyPair() + const initial = parseKeyring(document(keyring(1, first, second))) + const rotated = parseKeyring(document(keyring(2, second, third, [first.keyId]))) + assert.equal(validateKeyringTransition(initial, rotated), rotated) + assert.throws(() => validateKeyringTransition(initial, parseKeyring(document(keyring(2, second, third))))) + const next = parseKeyring(document(keyring(3, third, fourth, [first.keyId, second.keyId]))) + assert.equal(validateKeyringTransition(rotated, next), next) + assert.throws(() => validateKeyringTransition(next, parseKeyring(document(keyring(4, first, fourth, [second.keyId, third.keyId]))))) +}) diff --git a/upstream/container/platform/test/ledger.test.mjs b/upstream/container/platform/test/ledger.test.mjs new file mode 100644 index 0000000..2e72195 --- /dev/null +++ b/upstream/container/platform/test/ledger.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { TrustLedger } from '../stage0/lib/ledger.mjs' +import { document, officialDshPolicy, keyPair, keyring, registryCandidate, registryKeyPair, signature, target } from './helpers.mjs' + +async function fixture() { + const recovery = keyPair() + return { + recovery, + ledger: new TrustLedger(await mkdtemp(join(tmpdir(), 'dsh-trust-ledger-')), recovery.publicKey), + } +} + +test('persists only increasing keyring generations and identical repeats', async () => { + const { ledger, recovery } = await fixture() + const current = keyPair() + const next = keyPair() + const first = document(keyring(1, current, next)) + await ledger.acceptKeyring(first, signature(first, recovery)) + await ledger.acceptKeyring(first, signature(first, recovery)) + const conflicting = document({ ...keyring(1, current, next), issuedAt: '2026-08-19T02:00:00.000Z' }) + await assert.rejects(ledger.acceptKeyring(conflicting, signature(conflicting, recovery))) + const older = document(keyring(1, current, next)) + const third = keyPair() + const rotated = document(keyring(2, next, third, [current.keyId])) + await ledger.acceptKeyring(rotated, signature(rotated, recovery)) + await assert.rejects(ledger.acceptKeyring(older, signature(older, recovery)), { code: 'TRUST_ROLLBACK' }) +}) + +test('accepts targets only from current release key and prevents sequence rollback', async () => { + const { ledger, recovery } = await fixture() + const current = keyPair() + const next = keyPair() + const ring = document(keyring(1, current, next)) + await ledger.acceptKeyring(ring, signature(ring, recovery)) + + const first = document(target(1, 1)) + await assert.rejects(ledger.acceptTarget(first, signature(first, next)), { code: 'TRUST_UNKNOWN_KEY' }) + await ledger.acceptTarget(first, signature(first, current)) + await ledger.acceptTarget(first, signature(first, current)) + + const conflicting = document({ ...target(1, 1), target: { changed: true } }) + await assert.rejects(ledger.acceptTarget(conflicting, signature(conflicting, current))) + const second = document(target(1, 2)) + await ledger.acceptTarget(second, signature(second, current)) + await assert.rejects(ledger.acceptTarget(first, signature(first, current)), { code: 'TRUST_ROLLBACK' }) +}) + +test('requires an accepted keyring before accepting a target', async () => { + const { ledger } = await fixture() + const release = keyPair() + const bytes = document(target(1, 1)) + await assert.rejects(ledger.acceptTarget(bytes, signature(bytes, release)), /keyring/) +}) + +test('records official DSH identity monotonically without choosing an update channel', async () => { + const { ledger, recovery } = await fixture() + const current = keyPair() + const next = keyPair() + const registry = registryKeyPair() + const ring = document(keyring(1, current, next)) + await ledger.acceptKeyring(ring, signature(ring, recovery)) + const stable = document(target(1, 4, officialDshPolicy(registry))) + await ledger.acceptTarget(stable, signature(stable, current)) + + const content = Buffer.from('rc8') + const first = registryCandidate(registry, '0.1.0-rc.8', content) + const digest = createHash('sha256').update(content).digest('hex') + await ledger.acceptOfficialDsh(first, digest, content.byteLength) + await ledger.acceptOfficialDsh(first, digest, content.byteLength) + assert.equal((await ledger.currentOfficialDsh()).value.version, first.version) + + const older = registryCandidate(registry, '0.1.0-rc.6', content) + await assert.rejects(ledger.acceptOfficialDsh(older, digest, content.byteLength), { code: 'TRUST_ROLLBACK' }) + const conflicting = registryCandidate(registry, first.version, Buffer.from('changed')) + await assert.rejects( + ledger.acceptOfficialDsh(conflicting, createHash('sha256').update('changed').digest('hex'), 7), + /different content/, + ) +}) + +test('rejects new official DSH imports after the target Release Key is revoked', async () => { + const { ledger, recovery } = await fixture() + const current = keyPair() + const next = keyPair() + const registry = registryKeyPair() + const ring = document(keyring(1, current, next)) + await ledger.acceptKeyring(ring, signature(ring, recovery)) + const stable = document(target(1, 4, officialDshPolicy(registry))) + await ledger.acceptTarget(stable, signature(stable, current)) + + const content = Buffer.from('rc8') + const candidate = registryCandidate(registry, '0.1.0-rc.8', content) + const digest = createHash('sha256').update(content).digest('hex') + await ledger.acceptOfficialDsh(candidate, digest, content.byteLength) + + const future = keyPair() + const rotated = document(keyring(2, next, future, [current.keyId])) + await ledger.acceptKeyring(rotated, signature(rotated, recovery)) + await assert.rejects(ledger.acceptOfficialDsh(candidate, digest, content.byteLength), { code: 'TRUST_REVOKED' }) + await assert.rejects(ledger.currentOfficialDsh(), { code: 'TRUST_REVOKED' }) +}) + +test('retains historical keyrings so an accepted target remains verifiable after rotation', async () => { + const { ledger, recovery } = await fixture() + const first = keyPair() + const second = keyPair() + const third = keyPair() + const initialRing = document(keyring(1, first, second)) + await ledger.acceptKeyring(initialRing, signature(initialRing, recovery)) + const initialTarget = document(target(1, 7)) + await ledger.acceptTarget(initialTarget, signature(initialTarget, first)) + + const rotatedRing = document(keyring(2, second, third, [first.keyId])) + await ledger.acceptKeyring(rotatedRing, signature(rotatedRing, recovery)) + assert.equal((await ledger.currentTarget()).value.targetSequence, 7) + + const rotatedTarget = document(target(2, 8)) + await ledger.acceptTarget(rotatedTarget, signature(rotatedTarget, second)) + assert.equal((await ledger.currentTarget()).value.keyId, second.keyId) +}) + +test('serializes concurrent target acceptance in call order', async () => { + const { ledger, recovery } = await fixture() + const current = keyPair() + const next = keyPair() + const ring = document(keyring(1, current, next)) + await ledger.acceptKeyring(ring, signature(ring, recovery)) + const first = document(target(1, 1)) + const second = document(target(1, 2)) + await Promise.all([ + ledger.acceptTarget(first, signature(first, current)), + ledger.acceptTarget(second, signature(second, current)), + ]) + assert.equal((await ledger.currentTarget()).value.targetSequence, 2) +}) + +test('does not overwrite a conflicting historical generation after interrupted promotion', async () => { + const { ledger, recovery } = await fixture() + const first = keyPair() + const second = keyPair() + const third = keyPair() + const initial = document(keyring(1, first, second)) + await ledger.acceptKeyring(initial, signature(initial, recovery)) + const rotated = document(keyring(2, second, third, [first.keyId])) + await ledger.acceptKeyring(rotated, signature(rotated, recovery)) + const rotatedRecord = await readFile(ledger.keyringPath('current.record.json')) + const initialRecord = await readFile(ledger.keyringPath('generations/1.record.json')) + await writeFile(ledger.keyringPath('current.record.json'), initialRecord) + + const replacement = keyPair() + const conflicting = document(keyring(2, second, replacement, [first.keyId, third.keyId])) + await assert.rejects( + ledger.acceptKeyring(conflicting, signature(conflicting, recovery)), + /history conflicts/, + ) + assert.deepEqual(await readFile(ledger.keyringPath('generations/2.record.json')), rotatedRecord) +}) diff --git a/upstream/container/platform/test/logging.test.mjs b/upstream/container/platform/test/logging.test.mjs new file mode 100644 index 0000000..7bc33f9 --- /dev/null +++ b/upstream/container/platform/test/logging.test.mjs @@ -0,0 +1,321 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { appendFile, chmod, mkdtemp, readFile, readdir, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import test from 'node:test' +import { errorDetails, JsonlLogManager } from '../../control-plane/modules/log-manager/index.mjs' + +test('writes source-separated JSONL and queries bounded chronological entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-logs-')) + const logs = new JsonlLogManager({ root }) + await logs.append('gateway', 'stdout', 'ready') + await logs.append('updater', 'platform', 'checked', { taskId: 'one' }) + const entries = await logs.query({ sources: ['updater'], limit: 10 }) + assert.equal(entries.length, 1) + assert.equal(entries[0].taskId, 'one') + assert.equal(entries[0].level, 'info') + assert.deepEqual((await readdir(root)).sort(), ['gateway.jsonl', 'updater.jsonl']) + assert.equal((await logs.query({ limit: 5_000 })).length, 2) + await assert.rejects(logs.query({ limit: 5_001 }), /log query limit is invalid/) +}) + +test('filters transaction logs by task, operation, and phase', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-transaction-')) + const logs = new JsonlLogManager({ root }) + await logs.append('updater', 'platform', 'download', { taskId: 'task-a', operation: 'update', phase: 'downloading' }) + await logs.append('updater', 'platform', 'health', { taskId: 'task-a', operation: 'update', phase: 'switching' }) + await logs.append('updater', 'platform', 'other', { taskId: 'task-b', operation: 'rollback', phase: 'switching' }) + const entries = await logs.query({ taskId: 'task-a', operation: 'update', phase: 'downloading' }) + assert.deepEqual(entries.map(entry => entry.message), ['download']) + const received = [] + const follower = await logs.follow({ taskId: 'task-a', operation: 'update', phase: 'switching' }, entry => received.push(entry), { intervalMs: 50 }) + try { + await logs.append('updater', 'platform', 'switching-now', { taskId: 'task-a', operation: 'update', phase: 'switching' }) + await new Promise(resolve => setTimeout(resolve, 100)) + assert.deepEqual(received.map(entry => entry.message), ['switching-now']) + } finally { + follower.close() + } +}) + +test('follows entries appended by another process without replaying existing logs', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-follow-')) + const logs = new JsonlLogManager({ root }) + await logs.append('gateway', 'stdout', 'existing') + const received = [] + const follower = await logs.follow({}, entry => received.push(entry), { intervalMs: 50 }) + try { + const external = { + timestamp: '2026-08-21T00:00:00.000Z', source: 'gateway', stream: 'stdout', level: 'info', message: '外部日志', + } + const encoded = Buffer.from(`${JSON.stringify(external)}\n`) + const split = encoded.indexOf(Buffer.from('外')) + 1 + await appendFile(logs.currentPath('gateway'), encoded.subarray(0, split)) + await new Promise(resolve => setTimeout(resolve, 80)) + await appendFile(logs.currentPath('gateway'), encoded.subarray(split)) + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + clearInterval(poll) + reject(new Error('timed out waiting for followed log')) + }, 1_000) + const poll = setInterval(() => { + if (received.length === 0) return + clearTimeout(timeout) + clearInterval(poll) + resolve() + }, 10) + }) + assert.deepEqual(received.map(entry => entry.message), ['外部日志']) + } finally { + follower.close() + } +}) + +test('assigns default levels and accepts only explicit supported levels', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-levels-')) + const logs = new JsonlLogManager({ root }) + await logs.append('component', 'stdout', 'ordinary') + await logs.append('component', 'stderr', 'failed') + await logs.append('component', 'platform', 'slow response', { level: 'warning' }) + await logs.diagnostic('component', 'completed', { error: null }) + assert.deepEqual((await logs.query()).map(entry => entry.level), ['info', 'error', 'warning', 'info']) + await assert.rejects(logs.append('component', 'stdout', 'invalid', { level: 'fatal' }), /log level is invalid/) +}) + +test('rate-limits repeated diagnostics and reports the suppressed count', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-suppression-')) + let now = Date.parse('2026-08-19T00:00:00.000Z') + const logs = new JsonlLogManager({ root, now: () => new Date(now) }) + await logs.diagnosticRateLimited('authentication.failed', 'access-manager', 'access.authentication.failed', { + level: 'warning', kind: 'main', + }) + now += 1_000 + await logs.diagnosticRateLimited('authentication.failed', 'access-manager', 'access.authentication.failed', { + level: 'warning', kind: 'main', + }) + now += 30_000 + await logs.diagnosticRateLimited('authentication.failed', 'access-manager', 'access.authentication.failed', { + level: 'warning', kind: 'main', + }) + + const entries = await logs.query({ sources: ['access-manager'] }) + assert.equal(entries.length, 2) + assert.equal(entries[0].suppressedCount, undefined) + assert.equal(entries[1].suppressedCount, 1) + await assert.rejects( + Promise.resolve().then(() => logs.diagnosticRateLimited('invalid key', 'gateway', 'failed')), + /suppression key is invalid/, + ) +}) + +test('applies an explicit shared-reader mode to newly created and rotated log files', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-mode-')) + const logs = new JsonlLogManager({ root, fileMode: 0o640, rotateBytes: 1_024 }) + await logs.append('stage0', 'platform', 'first') + assert.equal((await stat(join(root, 'stage0.jsonl'))).mode & 0o777, 0o640) + await logs.append('stage0', 'platform', 'x'.repeat(1_000)) + await logs.append('stage0', 'platform', 'rotated') + assert.equal((await stat(join(root, 'stage0.jsonl'))).mode & 0o777, 0o640) +}) + +test('repairs existing log files for shared Control Plane writers', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-shared-writers-')) + const path = join(root, 'audit.jsonl') + await writeFile(path, '{}\n') + await chmod(path, 0o640) + const logs = new JsonlLogManager({ root, fileMode: 0o660 }) + await logs.prepare() + assert.equal((await stat(path)).mode & 0o777, 0o660) + await logs.append('audit', 'audit', 'shared-writer.ready') + assert.equal((await readFile(path, 'utf8')).includes('shared-writer.ready'), true) +}) + +test('rotates files and enforces the aggregate byte budget', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-rotation-')) + const logs = new JsonlLogManager({ root, maxBytes: 70_000, rotateBytes: 10_000 }) + for (let index = 0; index < 150; index += 1) await logs.append('gateway', 'stdout', 'x'.repeat(800)) + const files = await logs.files() + assert.ok(files.length > 1) + assert.ok(files.reduce((sum, file) => sum + file.details.size, 0) <= 70_000) +}) + +test('prunes expired logs and captures complete child output lines', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-retention-')) + const logs = new JsonlLogManager({ root, retentionDays: 1 }) + await logs.append('old', 'stdout', 'expired') + const oldPath = join(root, 'old.jsonl') + const old = new Date(Date.now() - 2 * 86_400_000) + await utimes(oldPath, old, old) + await logs.prune() + await assert.rejects(stat(oldPath), { code: 'ENOENT' }) + + const child = new EventEmitter() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + logs.capture(child, 'component', { stdout: true, stderr: false }) + child.stdout.end('one\ntwo') + await new Promise(resolve => setImmediate(resolve)) + await logs.queue + assert.deepEqual((await logs.query({ sources: ['component'] })).map(entry => entry.message), ['one', 'two']) +}) + +test('captures one multiline Node error as one log entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-multiline-error-')) + const logs = new JsonlLogManager({ root }) + const child = new EventEmitter() + child.stderr = new PassThrough() + logs.capture(child, 'dsh-runtime', { stdout: false, stderr: true }) + + child.stderr.write('ordinary before\nfile:///app/index.js:2\n throw new Error("startup failed")\n') + child.stderr.end(' ^\n\nError: startup failed\n at file:///app/index.js:2:9\n\nNode.js v24.20.0\nordinary after\n') + await new Promise(resolve => setImmediate(resolve)) + await logs.queue + + assert.deepEqual((await logs.query({ sources: ['dsh-runtime'] })).map(entry => ({ + level: entry.level, + message: entry.message, + })), [ + { level: 'info', message: 'ordinary before' }, + { + level: 'error', + message: 'file:///app/index.js:2\n throw new Error("startup failed")\n ^\n\nError: startup failed\n at file:///app/index.js:2:9\n\nNode.js v24.20.0', + }, + { level: 'info', message: 'ordinary after' }, + ]) +}) + +test('does not group multiline stderr from other components', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-other-stderr-')) + const logs = new JsonlLogManager({ root }) + const child = new EventEmitter() + child.stderr = new PassThrough() + logs.capture(child, 'gateway', { stdout: false, stderr: true }) + child.stderr.end('Error: first line\n at second line\nordinary line\n') + await new Promise(resolve => setImmediate(resolve)) + await logs.queue + assert.deepEqual((await logs.query({ sources: ['gateway'] })).map(entry => entry.message), [ + 'Error: first line', ' at second line', 'ordinary line', + ]) +}) + +test('mirrors new entries to the matching container output without replaying history', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-output-')) + const stdout = [] + const stderr = [] + const logs = new JsonlLogManager({ + root, + now: () => new Date('2026-08-19T00:00:00.000Z'), + output: { + stdout: { write: value => stdout.push(value) }, + stderr: { write: value => stderr.push(value) }, + }, + }) + await logs.append('gateway', 'stdout', 'ready') + await logs.append('dsh-runtime', 'stderr', 'failed') + assert.deepEqual(stdout.map(line => JSON.parse(line)), [{ + timestamp: '2026-08-19T00:00:00.000Z', source: 'gateway', stream: 'stdout', message: 'ready', + level: 'info', platformLog: 'dsh-platform-log-v1', + }]) + assert.deepEqual(stderr.map(line => JSON.parse(line)), [{ + timestamp: '2026-08-19T00:00:00.000Z', source: 'dsh-runtime', stream: 'stderr', message: 'failed', + level: 'error', platformLog: 'dsh-platform-log-v1', + }]) + + const secondOutput = [] + const restarted = new JsonlLogManager({ + root, + output: { stdout: { write: value => secondOutput.push(value) }, stderr: { write: value => secondOutput.push(value) } }, + }) + assert.equal((await restarted.query()).length, 2) + assert.deepEqual(secondOutput, []) +}) + +test('passes a platform-management envelope through without persisting it twice', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-forwarded-')) + const stdout = [] + const stderr = [] + const logs = new JsonlLogManager({ + root, + output: { + stdout: { write: value => stdout.push(value) }, + stderr: { write: value => stderr.push(value) }, + }, + }) + const child = new EventEmitter() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + logs.capture(child, 'platform-management', { stdout: true, stderr: true }, { acceptForwarded: true }) + const audit = JSON.stringify({ + timestamp: '2026-08-19T00:00:00.000Z', source: 'audit', stream: 'audit', message: 'update.started', + platformLog: 'dsh-platform-log-v1', taskId: 'task-one', + }) + child.stdout.end(`${audit}\nplain output\n`) + child.stderr.end('[net-proxy] 已启用代理 http://172.17.0.1:7890\nwarning: slow proxy\nplain error\n') + await new Promise(resolve => setImmediate(resolve)) + await logs.queue + + assert.equal(stdout[0], `${audit}\n`) + assert.equal(JSON.parse(stdout[1]).source, 'platform-management') + assert.equal(JSON.parse(stderr[0]).stream, 'stderr') + assert.deepEqual( + (await logs.query({ sources: ['platform-management'] })).map(entry => [entry.stream, entry.level, entry.message]), + [ + ['stdout', 'info', 'plain output'], + ['stderr', 'info', '[net-proxy] 已启用代理 http://172.17.0.1:7890'], + ['stderr', 'warning', 'warning: slow proxy'], + ['stderr', 'error', 'plain error'], + ], + ) + assert.deepEqual(await logs.query({ sources: ['audit'] }), []) + const bootstrap = await readFile(new URL('../bootstrap/index.mjs', import.meta.url), 'utf8') + assert.match(bootstrap, /acceptForwarded: \['gateway', 'platform-management'\]\.includes\(source\)/) +}) + +test('records bounded structured error diagnostics in JSONL and container output', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-log-diagnostic-')) + const stdout = [] + const logs = new JsonlLogManager({ + root, + now: () => new Date('2026-08-20T00:00:00.000Z'), + output: { stdout: { write: value => stdout.push(value) } }, + }) + const cause = Object.assign(new Error('socket unavailable'), { code: 'ECONNREFUSED' }) + const failure = new AggregateError([cause, 'secondary failure'], 'startup failed', { cause }) + await logs.diagnostic('stage0', 'bootstrap.launch.failed', { error: failure, recordId: 'bootstrap-one' }) + + const [entry] = await logs.query({ sources: ['stage0'] }) + assert.equal(entry.message, 'bootstrap.launch.failed') + assert.equal(entry.recordId, 'bootstrap-one') + assert.equal(entry.error, 'startup failed') + assert.equal(entry.errorCause.errorCode, 'ECONNREFUSED') + assert.equal(entry.errors[1].error, 'secondary failure') + assert.match(entry.errorStack, /AggregateError: startup failed/) + assert.equal(JSON.parse(stdout[0]).platformLog, 'dsh-platform-log-v1') +}) + +test('falls back to container stderr when a diagnostic cannot be persisted', async () => { + const stderr = [] + const logs = new JsonlLogManager({ + root: '/dev/null/not-a-directory', + now: () => new Date('2026-08-20T00:00:00.000Z'), + output: { stderr: { write: value => stderr.push(value) } }, + }) + await logs.diagnostic('updater', 'update.failed', { error: new Error('network failed'), taskId: 'one' }) + const fallback = JSON.parse(stderr[0]) + assert.equal(fallback.message, 'diagnostic.write.failed') + assert.equal(fallback.diagnostic.message, 'update.failed') + assert.equal(fallback.diagnostic.error, 'network failed') + assert.equal(fallback.loggingError.errorCode, 'ENOTDIR') +}) + +test('bounds recursive and oversized error details', () => { + let failure = new Error('root') + for (let index = 0; index < 6; index += 1) failure = new Error(`level-${String(index)}`, { cause: failure }) + const details = errorDetails(new AggregateError(Array.from({ length: 10 }, (_, index) => new Error(String(index))), 'x'.repeat(20_000), { cause: failure })) + assert.equal(details.error.endsWith('...[truncated]'), true) + assert.equal(details.errors.length, 8) + assert.equal(details.errorsTruncated, 2) + assert.equal(details.errorCause.errorCause.errorCause.errorCause, undefined) +}) diff --git a/upstream/container/platform/test/managed-lifecycle-patch.test.mjs b/upstream/container/platform/test/managed-lifecycle-patch.test.mjs new file mode 100644 index 0000000..e6ba4b0 --- /dev/null +++ b/upstream/container/platform/test/managed-lifecycle-patch.test.mjs @@ -0,0 +1,372 @@ +import assert from 'node:assert/strict' +import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import test from 'node:test' +import { + applyPatch, + MANAGED_LIFECYCLE_MODULE, + PROFILE_PACKAGE_STORAGE_MODULE, + verifyPatch, +} from '../../environment/resources/patches/managed-lifecycle.mjs' + +async function fixture({ duplicateImport = false, sigterm = true, wrapper = true } = {}) { + const root = await mkdtemp(join(tmpdir(), 'dsh-managed-lifecycle-patch-')) + await mkdir(join(root, 'lib')) + await writeFile(join(root, 'package.json'), '{"type":"module"}\n') + const dynamicImport = 'const { runProfile } = await import("./profile-boot-wrapper_42.js");' + await writeFile(join(root, 'lib/bin.js'), `#!/usr/bin/env node +const invocation = parseDshArgs(process.argv.slice(2), readVersion()); +switch (invocation.mode) { + case "profile": { + ${dynamicImport} + ${duplicateImport ? dynamicImport : ''} + } +} +`) + await writeFile(join(root, 'lib/profile-boot-wrapper_42.js'), wrapper + ? 'import { x as runProfile } from "./profile-boot-anyHash.js";\nexport { runProfile };\n' + : 'export const unrelated = true;\n') + await writeFile(join(root, 'lib/profile-boot-anyHash.js'), `async function runProfile(options) { +${sigterm ? '\tprocess.on("SIGTERM", () => {\n\t\tinterrupt(0);\n\t});' : '\tprocess.on("SIGHUP", () => interrupt(0));'} +\tprocess.on("SIGINT", () => { interrupt(130); }); +\tconst ctx = await boot(NAME, rootConfig, patches, (hostCtx) => { +\t\thostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment); +\t}); +\treturn { +\t\tctx, +\t\tshutdown +\t}; +} +export { runProfile as x }; +`) + return root +} + +async function listen(socketPath, handler) { + const server = createServer(handler) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + await chmod(socketPath, 0o600) + return server +} + +function json(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json' }) + response.end(JSON.stringify(value)) +} + +test('patches a hash-independent Profile import chain exactly once', async () => { + const root = await fixture() + applyPatch(root) + verifyPatch(root) + assert.match(await readFile(join(root, 'lib/bin.js'), 'utf8'), /prepareManagedInvocation/) + assert.match(await readFile(join(root, 'lib/profile-boot-anyHash.js'), 'utf8'), /managedSigtermHandler/) + assert.match(await readFile(join(root, MANAGED_LIFECYCLE_MODULE), 'utf8'), /dshPlatformLifecycle/) + assert.match(await readFile(join(root, PROFILE_PACKAGE_STORAGE_MODULE), 'utf8'), /prepareProfilePackageStorage/) + assert.throws(() => applyPatch(root), /already applied/) +}) + +test('fails closed when upstream Profile anchors are missing or ambiguous', async () => { + await assert.rejects(Promise.resolve().then(async () => applyPatch(await fixture({ duplicateImport: true }))), /found 2/) + await assert.rejects(Promise.resolve().then(async () => applyPatch(await fixture({ wrapper: false }))), /found 0/) + await assert.rejects(Promise.resolve().then(async () => applyPatch(await fixture({ sigterm: false }))), /SIGTERM handler.*found 0/) +}) + +async function fakePnpm(root) { + const bin = join(root, 'bin') + await mkdir(bin, { recursive: true }) + const executable = join(bin, 'pnpm') + await writeFile(executable, `#!/usr/bin/env node +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +if (process.env.DSH_TEST_PNPM_FAIL === '1') { + process.stderr.write('fixture pnpm failed\\n') + process.exit(1) +} +const workspace = await import('node:fs').then(({ readFileSync }) => readFileSync('pnpm-workspace.yaml', 'utf8')) +const match = /^storeDir:\\s*(.+)$/m.exec(workspace) +if (match === null) process.exit(2) +const storeRoot = JSON.parse(match[1]) +mkdirSync('node_modules', { recursive: true }) +writeFileSync(join('node_modules', '.modules.yaml'), JSON.stringify({ storeDir: join(storeRoot, 'v11') })) +writeFileSync(join('node_modules', 'migration-marker'), 'rebuilt\\n') +`) + await chmod(executable, 0o755) + return bin +} + +test('pins compatible Profiles to their custom DSH_HOME store', async () => { + const root = await fixture() + applyPatch(root) + const storage = await import(`${pathToFileURL(join(root, PROFILE_PACKAGE_STORAGE_MODULE)).href}?storage`) + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-profile-storage-home-')) + const profileDir = join(dshHome, 'profiles', 'web') + const workspacePath = join(profileDir, 'pnpm-workspace.yaml') + const modulesRoot = join(profileDir, 'node_modules') + await mkdir(modulesRoot, { recursive: true }) + await writeFile(join(profileDir, 'package.json'), '{"name":"dsh-profile-web"}\n') + await writeFile(workspacePath, 'packages:\n - .\n\nnodeLinker: hoisted\n') + const previousHome = process.env.DSH_HOME + process.env.DSH_HOME = dshHome + try { + assert.deepEqual(storage.prepareProfilePackageStorage('web'), { + status: 'ready', + currentStore: null, + storeRoot: join(dshHome, '.pnpm-store'), + }) + assert.match(await readFile(workspacePath, 'utf8'), new RegExp( + `storeDir: ${JSON.stringify(join(dshHome, '.pnpm-store')).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + )) + + await writeFile(join(modulesRoot, '.modules.yaml'), JSON.stringify({ + storeDir: join(dshHome, '.pnpm-store', 'v11'), + })) + assert.equal(storage.prepareProfilePackageStorage('web').status, 'ready') + assert.equal((await readFile(workspacePath, 'utf8')).match(/^storeDir:/gm)?.length, 1) + + const ambiguous = `${await readFile(workspacePath, 'utf8')}storeDir: "/another/store"\n` + await writeFile(workspacePath, ambiguous) + assert.throws(() => storage.prepareProfilePackageStorage('web'), /multiple storeDir entries/) + assert.equal(await readFile(workspacePath, 'utf8'), ambiguous) + } finally { + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome + } +}) + +test('migrates a legacy Profile store and recovers a failed or interrupted rebuild', async () => { + const root = await fixture() + applyPatch(root) + const storage = await import(`${pathToFileURL(join(root, PROFILE_PACKAGE_STORAGE_MODULE)).href}?migration`) + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-profile-migration-home-')) + const profileDir = join(dshHome, 'profiles', 'web') + const modulesRoot = join(profileDir, 'node_modules') + const workspacePath = join(profileDir, 'pnpm-workspace.yaml') + const legacyStore = join(root, 'workspace', '.pnpm-store', 'v11') + const originalWorkspace = `packages:\n - .\n\nstoreDir: ${JSON.stringify(dirname(legacyStore))}\n` + await mkdir(modulesRoot, { recursive: true }) + await mkdir(legacyStore, { recursive: true }) + await writeFile(join(legacyStore, 'store-marker'), 'cached\n') + await writeFile(join(modulesRoot, '.modules.yaml'), JSON.stringify({ storeDir: legacyStore })) + await writeFile(join(modulesRoot, 'original-marker'), 'original\n') + await writeFile(join(profileDir, 'package.json'), '{"name":"dsh-profile-web","private":true}\n') + await writeFile(join(profileDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + await writeFile(workspacePath, originalWorkspace) + const bin = await fakePnpm(root) + const previous = { + DSH_HOME: process.env.DSH_HOME, + PATH: process.env.PATH, + DSH_TEST_PNPM_FAIL: process.env.DSH_TEST_PNPM_FAIL, + } + process.env.DSH_HOME = dshHome + process.env.PATH = `${bin}:${process.env.PATH}` + try { + assert.deepEqual(storage.prepareProfilePackageStorage('web'), { + status: 'migrated', + currentStore: join(dshHome, '.pnpm-store', 'v11'), + storeRoot: join(dshHome, '.pnpm-store'), + }) + assert.equal(await readFile(join(dshHome, '.pnpm-store', 'v11', 'store-marker'), 'utf8'), 'cached\n') + assert.equal(await readFile(join(modulesRoot, 'migration-marker'), 'utf8'), 'rebuilt\n') + await assert.rejects(readFile(join(profileDir, '.dsh-platform-node_modules.previous')), { code: 'ENOENT' }) + await assert.rejects(readFile(join(profileDir, '.dsh-platform-pnpm-workspace.previous')), { code: 'ENOENT' }) + + await rm(modulesRoot, { recursive: true }) + await mkdir(modulesRoot) + await writeFile(join(modulesRoot, '.modules.yaml'), JSON.stringify({ storeDir: legacyStore })) + await writeFile(join(modulesRoot, 'original-marker'), 'restored\n') + await writeFile(workspacePath, originalWorkspace) + process.env.DSH_TEST_PNPM_FAIL = '1' + assert.throws(() => storage.prepareProfilePackageStorage('web'), /fixture pnpm failed/) + assert.equal(await readFile(join(modulesRoot, 'original-marker'), 'utf8'), 'restored\n') + assert.equal(await readFile(workspacePath, 'utf8'), originalWorkspace) + + delete process.env.DSH_TEST_PNPM_FAIL + await writeFile(join(profileDir, '.dsh-platform-pnpm-workspace.previous'), originalWorkspace) + await writeFile(workspacePath, `packages:\n - .\n\nstoreDir: ${JSON.stringify(join(dshHome, '.pnpm-store'))}\n`) + assert.equal(storage.prepareProfilePackageStorage('web').status, 'migrated') + assert.equal(await readFile(join(modulesRoot, 'migration-marker'), 'utf8'), 'rebuilt\n') + + await rm(modulesRoot, { recursive: true }) + await mkdir(modulesRoot) + await writeFile(join(modulesRoot, '.modules.yaml'), JSON.stringify({ storeDir: legacyStore })) + await writeFile(join(modulesRoot, 'original-marker'), 'interrupted\n') + await writeFile(workspacePath, originalWorkspace) + await writeFile(join(profileDir, '.dsh-platform-pnpm-workspace.previous'), originalWorkspace) + await rename(modulesRoot, join(profileDir, '.dsh-platform-node_modules.previous')) + await writeFile(workspacePath, `packages:\n - .\n\nstoreDir: ${JSON.stringify(join(dshHome, '.pnpm-store'))}\n`) + assert.equal(storage.prepareProfilePackageStorage('web').status, 'migrated') + assert.equal(await readFile(join(modulesRoot, 'migration-marker'), 'utf8'), 'rebuilt\n') + + await rm(modulesRoot, { recursive: true }) + await mkdir(modulesRoot) + await writeFile(join(modulesRoot, '.modules.yaml'), JSON.stringify({ + storeDir: join(dshHome, '.pnpm-store', 'v11'), + })) + await writeFile(join(modulesRoot, 'partial-marker'), 'partial\n') + await writeFile(workspacePath, `packages:\n - .\n\nstoreDir: ${JSON.stringify(join(dshHome, '.pnpm-store'))}\n`) + await mkdir(join(profileDir, '.dsh-platform-node_modules.previous')) + await writeFile(join(profileDir, '.dsh-platform-node_modules.previous', '.modules.yaml'), JSON.stringify({ + storeDir: legacyStore, + })) + await writeFile(join(profileDir, '.dsh-platform-node_modules.previous', 'original-marker'), 'original\n') + await writeFile(join(profileDir, '.dsh-platform-pnpm-workspace.previous'), originalWorkspace) + assert.equal(storage.prepareProfilePackageStorage('web').status, 'migrated') + assert.equal(await readFile(join(modulesRoot, 'migration-marker'), 'utf8'), 'rebuilt\n') + await assert.rejects(readFile(join(modulesRoot, 'partial-marker')), { code: 'ENOENT' }) + } finally { + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + } +}) + +test('gates only managed Web launches and routes restart through Management', async t => { + const root = await fixture() + applyPatch(root) + const runRoot = await mkdtemp(join(tmpdir(), 'dsh-managed-lifecycle-run-')) + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-managed-lifecycle-home-')) + const profileDir = join(dshHome, 'profiles', 'web') + await mkdir(profileDir, { recursive: true }) + await mkdir(join(root, 'node_modules', 'built-in-bundle'), { recursive: true }) + await writeFile(join(root, 'node_modules', 'built-in-bundle', 'package.json'), '{"name":"built-in-bundle"}\n') + const profileManifest = join(profileDir, 'package.json') + await writeFile(join(profileDir, 'pnpm-workspace.yaml'), 'packages:\n - .\n\nnodeLinker: hoisted\n') + await writeFile(profileManifest, JSON.stringify({ + dependencies: { 'missing-dependency': '1.0.0' }, + dsh: { profile: { bundles: ['built-in-bundle', 'removed-plugin', '../../outside', 'missing-dependency'] } }, + }, null, 2)) + const calls = [] + let claimStatus = 200 + let disposition = 'request-restart' + let lifecycleState = 'running' + let signalDelayMs = 0 + const broker = await listen(join(runRoot, 'dsh-lifecycle.sock'), async (request, response) => { + const chunks = [] + for await (const chunk of request) chunks.push(chunk) + calls.push({ socket: 'broker', path: request.url, body: JSON.parse(Buffer.concat(chunks)) }) + if (claimStatus !== 200) json(response, claimStatus, { error: 'launch already claimed' }) + else if (request.url === '/v1/runtime/claim') json(response, 200, { sessionId: 'session-1' }) + else if (request.url === '/v1/runtime/ready') json(response, 200, { ready: true }) + else { + if (signalDelayMs > 0) await new Promise(resolve => setTimeout(resolve, signalDelayMs)) + json(response, 200, { disposition }) + } + }) + const management = await listen(join(runRoot, 'management-cli.sock'), (request, response) => { + calls.push({ socket: 'management', path: request.url }) + if (request.url?.endsWith('/status')) json(response, 200, { dshLifecycle: { state: lifecycleState }, recoveryMode: null }) + else json(response, 202, { taskId: request.url?.endsWith('/start-dsh') ? 'start-task' : 'restart-task' }) + }) + t.after(() => { broker.close(); management.close() }) + const previous = Object.fromEntries(['DSH_HOME', 'DSH_PLATFORM_MANAGED', 'DSH_PLATFORM_RUN', 'DSH_PLATFORM_LAUNCH_TOKEN'] + .map(name => [name, process.env[name]])) + process.env.DSH_HOME = dshHome + process.env.DSH_PLATFORM_MANAGED = '1' + process.env.DSH_PLATFORM_RUN = runRoot + process.env.DSH_PLATFORM_LAUNCH_TOKEN = 'launch-token' + try { + const adapter = await import(`${pathToFileURL(join(root, MANAGED_LIFECYCLE_MODULE)).href}?authorized`) + assert.equal(await adapter.prepareManagedInvocation({ mode: 'plugin', profile: 'web' }), null) + assert.equal(calls.length, 0) + assert.match(await readFile(join(profileDir, 'pnpm-workspace.yaml'), 'utf8'), new RegExp( + `storeDir: ${JSON.stringify(join(dshHome, '.pnpm-store')).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + )) + assert.equal(await adapter.prepareManagedInvocation({ mode: 'profile', profile: 'web' }), null) + assert.equal(process.env.DSH_PLATFORM_LAUNCH_TOKEN, undefined) + assert.deepEqual( + JSON.parse(await readFile(profileManifest, 'utf8')).dsh.profile.bundles, + ['built-in-bundle', 'missing-dependency'], + ) + await adapter.markManagedReady({ + get: name => name === 'connection' + ? { authenticatedUrl: base => `${base}/?token=fixture-token` } + : name === 'webServer' ? { port: 3079 } : undefined, + }) + assert.equal(calls.some(value => value.socket === 'broker' && value.path === '/v1/runtime/ready'), true) + assert.deepEqual(calls.find(value => value.path === '/v1/runtime/ready')?.body, { + sessionId: 'session-1', + readyUrl: 'http://127.0.0.1:3079/?token=fixture-token', + }) + let provided + adapter.provideManagedLifecycle({ provide: (name, value) => { provided = { name, value } } }) + assert.equal(provided.name, 'dshPlatformLifecycle') + assert.deepEqual(await provided.value.restart(), { taskId: 'restart-task' }) + const restartCallsBeforeSigterm = calls.filter(value => value.path?.endsWith('/restart-dsh')).length + let interrupts = 0 + const onSigterm = adapter.managedSigtermHandler(() => { interrupts += 1 }) + onSigterm() + for (let attempt = 0; attempt < 50 && calls.filter(value => value.path?.endsWith('/restart-dsh')).length === restartCallsBeforeSigterm; attempt += 1) { + await new Promise(resolve => setTimeout(resolve, 5)) + } + assert.equal(calls.some(value => value.socket === 'broker' && value.path === '/v1/runtime/signal'), true) + assert.equal(calls.filter(value => value.path?.endsWith('/restart-dsh')).length, restartCallsBeforeSigterm + 1) + assert.equal(interrupts, 0) + onSigterm() + assert.equal(interrupts, 1) + + const restartCallsBeforeCancellation = calls.filter(value => value.path?.endsWith('/restart-dsh')).length + signalDelayMs = 30 + let cancellationInterrupts = 0 + const cancelSigterm = adapter.managedSigtermHandler(() => { cancellationInterrupts += 1 }) + cancelSigterm() + cancelSigterm() + await new Promise(resolve => setTimeout(resolve, 50)) + assert.equal(cancellationInterrupts, 1) + assert.equal(calls.filter(value => value.path?.endsWith('/restart-dsh')).length, restartCallsBeforeCancellation) + signalDelayMs = 0 + + disposition = 'terminate' + let terminationInterrupts = 0 + const managementCalls = calls.filter(value => value.path?.endsWith('/restart-dsh')).length + adapter.managedSigtermHandler(() => { terminationInterrupts += 1 })() + for (let attempt = 0; attempt < 50 && terminationInterrupts === 0; attempt += 1) { + await new Promise(resolve => setTimeout(resolve, 5)) + } + assert.equal(terminationInterrupts, 1) + assert.equal(calls.filter(value => value.path?.endsWith('/restart-dsh')).length, managementCalls) + + claimStatus = 409 + disposition = 'request-restart' + const duplicate = await import(`${pathToFileURL(join(root, MANAGED_LIFECYCLE_MODULE)).href}?duplicate`) + assert.equal(await duplicate.prepareManagedInvocation({ mode: 'profile', profile: 'web' }), 0) + lifecycleState = 'stopped' + const stopped = await import(`${pathToFileURL(join(root, MANAGED_LIFECYCLE_MODULE)).href}?stopped`) + assert.equal(await stopped.prepareManagedInvocation({ mode: 'profile', profile: 'web' }), 0) + assert.equal(calls.some(value => value.path?.endsWith('/start-dsh')), true) + lifecycleState = 'failed' + const failed = await import(`${pathToFileURL(join(root, MANAGED_LIFECYCLE_MODULE)).href}?failed`) + assert.equal(await failed.prepareManagedInvocation({ mode: 'profile', profile: 'web' }), 1) + } finally { + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + } +}) + +test('keeps native behavior without the image marker and fails closed without the Broker', async () => { + const root = await fixture() + applyPatch(root) + const adapter = await import(`${pathToFileURL(join(root, MANAGED_LIFECYCLE_MODULE)).href}?fail-closed`) + const previous = Object.fromEntries(['DSH_PLATFORM_MANAGED', 'DSH_PLATFORM_RUN'] + .map(name => [name, process.env[name]])) + try { + delete process.env.DSH_PLATFORM_MANAGED + assert.equal(await adapter.prepareManagedInvocation({ mode: 'profile', profile: 'web' }), null) + process.env.DSH_PLATFORM_MANAGED = '1' + process.env.DSH_PLATFORM_RUN = await mkdtemp(join(tmpdir(), 'dsh-no-broker-')) + assert.equal(await adapter.prepareManagedInvocation({ mode: 'profile', profile: 'web' }), 1) + } finally { + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + } +}) diff --git a/upstream/container/platform/test/managed-store.test.mjs b/upstream/container/platform/test/managed-store.test.mjs new file mode 100644 index 0000000..9f39ce0 --- /dev/null +++ b/upstream/container/platform/test/managed-store.test.mjs @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { lstat, mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { ManagedDeploymentBuilder } from '../../control-plane/modules/updater/lib/managed-store.mjs' +import { canonicalJson } from '../lib/canonical-json.mjs' +import { deriveRecordId } from '../lib/deployment-contracts.mjs' +import { PlatformPaths, preparePersistentLayout } from '../lib/paths.mjs' + +test('builds a complete content-addressed Managed Deployment from verified inputs', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-managed-store-')) + const paths = new PlatformPaths(join(root, 'data'), join(root, 'run')) + await preparePersistentLayout(paths) + + const dependencyRoot = join(root, 'dependency', 'package') + await mkdir(join(dependencyRoot, 'lib'), { recursive: true }) + await writeFile(join(dependencyRoot, 'package.json'), JSON.stringify({ name: 'fixture-dependency', version: '1.0.0' })) + await writeFile(join(dependencyRoot, 'lib', 'index.js'), 'export const installed = true\n') + const dependencyArchive = join(root, 'fixture-dependency.tgz') + const packedDependency = spawnSync('tar', ['-czf', dependencyArchive, '-C', join(root, 'dependency'), 'package'], { encoding: 'utf8' }) + assert.equal(packedDependency.status, 0, packedDependency.stderr) + + const packageRoot = join(root, 'source', 'package') + await mkdir(join(packageRoot, 'lib'), { recursive: true }) + await writeFile(join(packageRoot, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh', + version: '0.1.0-rc.8', + dependencies: { 'fixture-dependency': `file:${dependencyArchive}` }, + })) + await writeFile(join(packageRoot, 'lib', 'bin.js'), '#!/usr/bin/env node\n') + const archive = join(root, 'dsh.tgz') + const packed = spawnSync('tar', ['-czf', archive, '-C', join(root, 'source'), 'package'], { encoding: 'utf8' }) + assert.equal(packed.status, 0, packed.stderr) + const archiveBytes = await readFile(archive) + const objectSha256 = createHash('sha256').update(archiveBytes).digest('hex') + const trustedObjectPath = join(paths.objectsRoot, objectSha256) + await writeFile(trustedObjectPath, archiveBytes) + + const manifestPath = join(root, 'environment.manifest.json') + const manifest = { + schema: 1, + manifestType: 'environment', + version: '1.0.0', + keyringGeneration: 1, + targetSequence: 2, + issuedAt: '2026-08-19T00:00:00.000Z', + artifacts: [], + bootstrapApi: 1, + components: [], + patches: [], + systemPlugins: [], + } + await writeFile(manifestPath, canonicalJson(manifest)) + const prepared = { + stable: { + targetSequence: 2, + desired: { environment: { manifestArtifactId: 'environment-manifest' } }, + }, + environment: { + manifest, + manifestReceipt: { objectSha256: createHash('sha256').update(await readFile(manifestPath)).digest('hex') }, + }, + paths: new Map([['environment-manifest', manifestPath]]), + dsh: { version: '0.1.0-rc.8', receipt: { path: trustedObjectPath, objectSha256 } }, + receiptTokens: ['stable-receipt', 'dsh-receipt'], + } + + const builder = new ManagedDeploymentBuilder({ paths }) + const progress = [] + const first = await builder.buildStable(prepared, { onProgress: async value => progress.push(value) }) + assert.deepEqual(progress.filter(value => typeof value === 'number'), [80, 82, 87, 89]) + const runtimeProgress = progress.filter(value => typeof value === 'object') + assert.ok(runtimeProgress.length > 0) + assert.equal(runtimeProgress.at(-1).processedBytes, runtimeProgress.at(-1).totalBytes) + assert.equal(runtimeProgress.at(-1).processedItems, runtimeProgress.at(-1).totalItems) + assert.equal(first.assets.pristine.id, `pristine-npm-${objectSha256}`) + assert.equal(first.record.authority, 'stable') + assert.equal(first.record.targetSequence, 2) + assert.equal(first.record.dshVersion, '0.1.0-rc.8') + assert.deepEqual(first.record.receiptTokens, ['stable-receipt', 'dsh-receipt']) + for (const reference of [ + first.record.environment, + first.record.pristine, + first.record.runtime, + first.record.systemPlugins, + ]) { + assert.equal(reference.storage, 'store') + const storeRoot = reference.kind === 'environment' + ? paths.environmentsRoot + : reference.kind === 'pristine' + ? paths.pristineRoot + : reference.kind === 'runtime' + ? paths.runtimesRoot + : paths.systemPluginsRoot + assert.equal((await lstat(join(storeRoot, reference.id))).isDirectory(), true) + } + assert.equal(await readFile(join(first.assets.runtime.path, 'package', 'package.json'), 'utf8'), await readFile(join(packageRoot, 'package.json'), 'utf8')) + assert.equal( + await readFile(join(first.assets.pristine.path, 'node_modules', 'fixture-dependency', 'lib', 'index.js'), 'utf8'), + 'export const installed = true\n', + ) + await assert.rejects(lstat(join(first.assets.pristine.path, 'npm-cache')), error => error?.code === 'ENOENT') + assert.deepEqual(await readFile(trustedObjectPath), archiveBytes) + assert.equal((await lstat(trustedObjectPath)).isSymbolicLink(), false) + assert.equal((await readFile(join(first.assets.systemPlugins.path, 'cordis.patch.yml'), 'utf8')).trim(), '[]') + + const repeated = await builder.buildStable(prepared) + assert.equal(repeated.record.id, first.record.id) + assert.deepEqual(repeated.record, first.record) + + const experimentalPackage = join(root, 'experimental-source', 'package') + await mkdir(join(experimentalPackage, 'lib'), { recursive: true }) + await writeFile(join(experimentalPackage, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.1.0-rc.9' })) + await writeFile(join(experimentalPackage, 'lib', 'bin.js'), '#!/usr/bin/env node\n') + const experimentalArchive = join(root, 'experimental.tgz') + const experimentalPacked = spawnSync('tar', [ + '-czf', experimentalArchive, '-C', join(root, 'experimental-source'), 'package', + ], { encoding: 'utf8' }) + assert.equal(experimentalPacked.status, 0, experimentalPacked.stderr) + await mkdir(paths.viewsRoot, { recursive: true }) + await symlink(first.assets.environment.path, join(paths.viewsRoot, 'environment'), 'dir') + const { id: _stableId, ...developmentContent } = first.record + developmentContent.authority = 'development' + developmentContent.targetSequence = 0 + const developmentRecord = { ...developmentContent, id: deriveRecordId('deployment-record', developmentContent) } + const experimental = await builder.buildExperimental({ + version: '0.1.0-rc.9', + receipt: { + path: experimentalArchive, + objectSha256: createHash('sha256').update(await readFile(experimentalArchive)).digest('hex'), + }, + }, developmentRecord, ['stable-receipt', 'experimental-receipt'], { targetSequence: 11 }) + assert.equal(experimental.record.authority, 'experimental') + assert.equal(experimental.record.targetSequence, 11) + assert.equal(experimental.record.dshVersion, '0.1.0-rc.9') + assert.deepEqual(experimental.record.environment, first.record.environment) + assert.deepEqual(experimental.record.systemPlugins, first.record.systemPlugins) + assert.notEqual(experimental.record.pristine.id, first.record.pristine.id) + assert.notEqual(experimental.record.runtime.id, first.record.runtime.id) + assert.deepEqual(experimental.record.receiptTokens, ['stable-receipt', 'experimental-receipt']) +}) + +test('retries a partially published npm dependency graph in fresh install roots', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-managed-store-retry-')) + const paths = new PlatformPaths(join(root, 'data'), join(root, 'run')) + await preparePersistentLayout(paths) + + const packageRoot = join(root, 'source', 'package') + await mkdir(packageRoot, { recursive: true }) + await writeFile(join(packageRoot, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh', version: '0.1.5-rc.1', + })) + const archive = join(root, 'dsh.tgz') + const packed = spawnSync('tar', ['-czf', archive, '-C', join(root, 'source'), 'package'], { encoding: 'utf8' }) + assert.equal(packed.status, 0, packed.stderr) + + const attempts = [] + const delays = [] + const builder = new ManagedDeploymentBuilder({ + paths, + sleep: async milliseconds => delays.push(milliseconds), + npmInstall: async (installRoot, _archivePath, options) => { + attempts.push({ installRoot, options }) + if (attempts.length === 1) { + throw new Error('npm error code E404\nnpm error 404 A dependency package is not published yet.') + } + if (attempts.length === 2) { + throw new Error('npm error code ETARGET\nnpm error notarget No matching version found for a dependency.') + } + if (attempts.length === 3) { + throw new Error('npm error code ENOVERSIONS\nnpm error No versions available for a dependency.') + } + const installed = join(installRoot, 'lib', 'node_modules', '@deepseek-ai', 'dsh') + await mkdir(installed, { recursive: true }) + await writeFile(join(installed, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh', version: '0.1.5-rc.1', + })) + }, + }) + const pristine = await builder.pristine('0.1.5-rc.1', { + path: archive, + objectSha256: createHash('sha256').update(await readFile(archive)).digest('hex'), + }) + + assert.equal(attempts.length, 4) + assert.equal(new Set(attempts.map(attempt => attempt.installRoot)).size, 4) + assert.deepEqual(attempts.map(attempt => attempt.options.preferOnline), [false, true, true, true]) + assert.deepEqual(delays, [5_000, 10_000, 20_000]) + assert.equal(JSON.parse(await readFile(join(pristine.path, 'package.json'))).version, '0.1.5-rc.1') + + let permanentAttempts = 0 + const permanent = new ManagedDeploymentBuilder({ + paths, + sleep: async () => assert.fail('a permanent npm failure must not wait for a retry'), + npmInstall: async () => { + permanentAttempts += 1 + throw new Error('npm error code EACCES\nnpm error permission denied') + }, + }) + await assert.rejects(permanent.pristine('0.1.5-rc.1', { + path: archive, + objectSha256: 'permanent-error-fixture', + }), /EACCES/) + assert.equal(permanentAttempts, 1) +}) diff --git a/upstream/container/platform/test/management.test.mjs b/upstream/container/platform/test/management.test.mjs new file mode 100644 index 0000000..e390ec2 --- /dev/null +++ b/upstream/container/platform/test/management.test.mjs @@ -0,0 +1,3259 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { request as httpRequest } from 'node:http' +import { createConnection } from 'node:net' +import { appendFile, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import test from 'node:test' +import { JsonlLogManager } from '../../control-plane/modules/log-manager/index.mjs' +import { parseCli, readSecret, recoverImageBaseline, resetTrust, runCli } from '../../control-plane/services/management/cli.mjs' +import { API_PREFIX, createManagementServer, listenManagement } from '../../control-plane/services/management/server.mjs' +import { LocalApiClient } from '../../control-plane/modules/updater/lib/client.mjs' +import { UpdateConflictError } from '../../control-plane/modules/updater/lib/coordinator.mjs' +import { UpdateScheduler } from '../../control-plane/modules/updater/lib/scheduler.mjs' +import { SettingsDocumentStore } from '../../control-plane/services/management/settings-document.mjs' +import { UserPluginInventory } from '../../control-plane/modules/plugin-manager/user-inventory.mjs' +import { UserPluginJournal } from '../../control-plane/modules/plugin-manager/user-journal.mjs' +import { UserPluginSnapshots } from '../../control-plane/modules/plugin-manager/user-snapshots.mjs' +import { UserPluginSelectionStore } from '../../control-plane/modules/plugin-manager/user-state.mjs' +import { UserPluginTransactionManager } from '../../control-plane/modules/plugin-manager/user-transaction.mjs' +import { FileInventory, FileSearchManager } from '../../control-plane/modules/file-manager/index.mjs' +import { FileTransferManager } from '../../control-plane/modules/file-manager/transfers.mjs' +import { FileTaskManager } from '../../control-plane/modules/file-manager/tasks.mjs' +import { AtomicFileEditor } from '../../control-plane/modules/file-manager/editor.mjs' + +const ASYNC_POLL_ATTEMPTS = 2_000 +const ASYNC_POLL_INTERVAL_MS = 5 + +class Coordinator extends EventEmitter { + constructor() { + super() + this.value = { status: 'idle', progress: 0 } + this.state = { read: async () => this.value } + } + + async publicStatus() { + return { update: this.value, updateChannel: 'experimental', holds: [], experimentalBlocked: null, rollbackPlan: { planId: 'plan-a' } } + } + + hasActiveTask() { return this.running === true } + + async check(source) { + this.checkSource = source + return { value: { targetSequence: 2, desired: { dsh: { version: 'rc.8' } } } } + } + + start() { + if (this.running) throw new UpdateConflictError('busy') + this.running = true + return { taskId: 'task-one', completion: new Promise(() => {}) } + } + + startReconcile() { return this.start() } + rollbackPlan() { return Promise.resolve({ planId: 'plan-a' }) } + setChannel(channel) { return Promise.resolve({ updateChannel: channel, holds: [], experimentalBlocked: null }) } + retryHold(id) { return Promise.resolve({ retried: id }) } + startCompleteRollback(planId) { + assert.equal(planId, 'plan-a') + return { taskId: 'rollback-task', completion: Promise.resolve() } + } +} + +function rawRequest(socketPath, path, method = 'GET') { + return new Promise((resolve, reject) => { + const request = httpRequest({ socketPath, path, method }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => resolve({ + status: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + })) + }) + request.once('error', reject) + request.end() + }) +} + +function rawUpgrade(socketPath, path) { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath) + const chunks = [] + socket.once('connect', () => socket.write([ + `GET ${path} HTTP/1.1`, + 'Host: management.internal', + 'Connection: Upgrade', + 'Upgrade: websocket', + 'Sec-WebSocket-Key: dGVzdC1rZXk=', + 'Sec-WebSocket-Version: 13', + '', '', + ].join('\r\n'))) + socket.on('data', chunk => chunks.push(chunk)) + socket.once('error', reject) + socket.once('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + }) +} + +test('management preserves capability backend failures for HTTP and WebSocket requests', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-capability-')) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + const authorizeInternal = async () => { + throw Object.assign(new Error('Access Manager unavailable'), { statusCode: 503 }) + } + const server = createManagementServer({ + coordinator: new Coordinator(), logs, authorizeInternal, + terminalSessions: { upgrade() { assert.fail('unauthorized terminal upgrade reached execution point') } }, + }) + const socketPath = join(root, 'management.sock') + await listenManagement(server, socketPath) + try { + const response = await rawRequest(socketPath, `${API_PREFIX}status`) + assert.equal(response.status, 503) + assert.equal(JSON.parse(response.body).error, 'Access Manager unavailable') + assert.match(await rawUpgrade(socketPath, `${API_PREFIX}terminal/sessions/00000000-0000-4000-8000-000000000000/stream`), /^HTTP\/1\.1 503 Service Unavailable/m) + } finally { + await new Promise(resolve => server.close(resolve)) + await rm(root, { recursive: true, force: true }) + } +}) + +test('management socket exposes status, check, update, logs, and local rollback', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-')) + const coordinator = new Coordinator() + const originTransitions = [] + const totpEnrollments = [] + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + await logs.append('gateway', 'stdout', 'ready') + const server = createManagementServer({ + coordinator, + logs, + platformStatus: async () => ({ environment: 'one' }), + updateAutomaticCheck: async value => value, + getAuthenticationSettings: async () => ({ account: { username: 'admin' } }), + updateAuthenticationSettings: async value => ({ account: { username: value.username } }), + beginTotpEnrollment: async value => { + totpEnrollments.push({ action: 'begin', value }) + return { enrollmentToken: 'dshte_test', qrCode: 'data:image/svg+xml,test', secret: 'SECRET' } + }, + confirmTotpEnrollment: async value => { + totpEnrollments.push({ action: 'confirm', value }) + return { confirmed: true } + }, + cancelTotpEnrollment: async value => { + totpEnrollments.push({ action: 'cancel', value }) + return { canceled: true } + }, + beginTotpDisableConfirmation: async value => { + totpEnrollments.push({ action: 'disable-begin', value }) + return { confirmationToken: 'dshtd_test' } + }, + confirmTotpDisable: async value => { + totpEnrollments.push({ action: 'disable-confirm', value }) + return { confirmed: true } + }, + cancelTotpDisableConfirmation: async value => { + totpEnrollments.push({ action: 'disable-cancel', value }) + return { canceled: true } + }, + revokeAuthenticationSessions: async value => ({ revoked: value.scope === 'all' ? 2 : 1 }), + createManagementOriginTransition: async value => { + originTransitions.push({ stage: 'create', value }) + return { transition: { transitionId: 'transition-one', nonce: 'nonce-one' } } + }, + commitManagementOriginTransition: async value => { + originTransitions.push({ stage: 'commit', value }) + return { account: { managementAccess: { mode: 'isolated' } } } + }, + reportManagementOriginTransitionFailure: async value => { + originTransitions.push({ stage: 'failure', value }) + return { reported: true } + }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + const status = await client.request('GET', '/_dsh_platform/api/v1/status') + assert.equal(status.environment, 'one') + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/check', { source: 'page-open' })).targetSequence, 2) + assert.equal(coordinator.checkSource, 'page-open') + assert.deepEqual(await client.request('POST', '/_dsh_platform/api/v1/update'), { taskId: 'task-one' }) + await assert.rejects(client.request('POST', '/_dsh_platform/api/v1/update'), error => error.statusCode === 409) + const rejected = await logs.query({ sources: ['platform-management'] }) + assert.equal(rejected.some(entry => entry.message === 'management.request.failed' + && entry.pathname === '/_dsh_platform/api/v1/update' + && entry.level === 'warning'), true) + assert.equal((await client.request('GET', '/_dsh_platform/api/v1/logs?source=gateway')).entries[0].message, 'ready') + assert.equal((await client.request('PUT', '/_dsh_platform/api/v1/channel', { channel: 'experimental' })).updateChannel, 'experimental') + assert.deepEqual(await client.request('PUT', '/_dsh_platform/api/v1/automatic-check', { + enabled: true, intervalSeconds: 21_600, notificationsEnabled: false, + }), { enabled: true, intervalSeconds: 21_600, notificationsEnabled: false }) + assert.equal((await client.request('GET', '/_dsh_platform/api/v1/auth-settings')).account.username, 'admin') + assert.equal((await client.request('PUT', '/_dsh_platform/api/v1/auth-settings', { username: 'operator' })).account.username, 'operator') + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/auth-totp/enrollments', { + currentPassword: 'correct password', + })).enrollmentToken, 'dshte_test') + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/auth-totp/enrollments/confirm', { + enrollmentToken: 'dshte_test', totpCode: '123456', + })).confirmed, true) + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/auth-totp/enrollments/cancel', { + enrollmentToken: 'dshte_test', + })).canceled, true) + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/auth-totp/disable-confirmations')).confirmationToken, 'dshtd_test') + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/confirm', { + confirmationToken: 'dshtd_test', totpCode: '123456', + })).confirmed, true) + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/cancel', { + confirmationToken: 'dshtd_test', + })).canceled, true) + assert.deepEqual(totpEnrollments, [ + { action: 'begin', value: { currentPassword: 'correct password' } }, + { action: 'confirm', value: { enrollmentToken: 'dshte_test', totpCode: '123456' } }, + { action: 'cancel', value: { enrollmentToken: 'dshte_test' } }, + { action: 'disable-begin', value: {} }, + { action: 'disable-confirm', value: { confirmationToken: 'dshtd_test', totpCode: '123456' } }, + { action: 'disable-cancel', value: { confirmationToken: 'dshtd_test' } }, + ]) + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/auth-sessions/revoke', { + sessionId: 'session-two', + })).revoked, 1) + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/management-origin/transitions', { + mode: 'isolated', isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://manage.example' }, + })).transition.transitionId, 'transition-one') + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/management-origin/transitions/commit', { + transitionId: 'transition-one', proof: 'proof-one', + })).account.managementAccess.mode, 'isolated') + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/management-origin/transitions/failure', { + stage: 'browser-probe', mode: 'isolated', + })).reported, true) + assert.deepEqual(originTransitions.map(value => value.stage), ['create', 'commit', 'failure']) + assert.equal((await client.request('POST', '/_dsh_platform/api/v1/holds/retry', { id: 'hold-a' })).retried, 'hold-a') + assert.equal((await client.request('GET', '/_dsh_platform/api/v1/rollback-plan')).plan.planId, 'plan-a') + coordinator.running = false + assert.deepEqual(await client.request('POST', '/_dsh_platform/api/v1/rollback', { planId: 'plan-a' }), { taskId: 'rollback-task' }) + const audit = await logs.query({ sources: ['audit'] }) + assert.equal(audit.some(entry => entry.message === 'update.channel.changed' && entry.updateChannel === 'experimental'), true) + assert.equal(audit.some(entry => entry.message === 'update.automatic-check.configured' && entry.intervalSeconds === 21_600), true) + assert.equal(audit.some(entry => entry.message === 'update.hold.retried' && entry.holdId === 'hold-a'), true) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management proxies sanitized outbound configuration, Provider inventory, and structured conflicts', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-outbound-proxy-')) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + const view = { + schema: 1, enabled: false, revision: 'revision-one', componentReady: true, + proxy: { protocol: 'http', host: '', port: null, username: '', passwordConfigured: false, remoteDns: true }, + scopes: {}, environment: { allProxy: null }, + modelApi: { default: { proxyEnabled: false }, providers: {} }, + noProxy: { system: ['localhost'], user: [] }, bypass: { additional: [] }, + routeHealth: {}, lastTest: null, scopeCatalog: { schema: 1, entries: [] }, + } + const providers = { schema: 1, source: 'live', providers: [{ id: 'deepseek', routingCapability: 'shared-dsh' }] } + const updates = [] + const server = createManagementServer({ + coordinator: new Coordinator(), logs, + getProxyConfiguration: async () => view, + listProxyProviders: async () => providers, + updateProxyConfiguration: async value => { + updates.push(value) + if (value.baseRevision !== view.revision) { + const error = new Error('proxy configuration changed') + error.statusCode = 409 + error.code = 'REVISION_CONFLICT' + error.stage = 'activate' + error.retryable = true + error.proxyError = { + code: error.code, message: error.message, stage: error.stage, retryable: error.retryable, + } + throw error + } + return { ...view, revision: 'revision-two' } + }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + assert.deepEqual(await client.request('GET', `${API_PREFIX}proxy`), view) + assert.deepEqual(await client.request('GET', `${API_PREFIX}proxy/provider-inventory`), providers) + const configured = await client.request('PUT', `${API_PREFIX}proxy`, { + baseRevision: view.revision, + value: { password: 'must-not-log' }, + }) + assert.equal(configured.revision, 'revision-two') + assert.deepEqual(updates, [{ baseRevision: view.revision, value: { password: 'must-not-log' } }]) + + const bytes = Buffer.from(JSON.stringify({ baseRevision: 'stale', value: {} })) + const conflict = await new Promise((resolve, reject) => { + const outgoing = httpRequest({ + socketPath, method: 'PUT', path: `${API_PREFIX}proxy`, + headers: { 'content-type': 'application/json', 'content-length': bytes.byteLength }, + }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => resolve({ status: response.statusCode, body: JSON.parse(Buffer.concat(chunks).toString()) })) + }) + outgoing.once('error', reject) + outgoing.end(bytes) + }) + assert.equal(conflict.status, 409) + assert.deepEqual(conflict.body, { error: { + code: 'REVISION_CONFLICT', message: 'proxy configuration changed', stage: 'activate', retryable: true, + } }) + const audit = await logs.query({ sources: ['audit'] }) + assert.equal(audit.some(entry => entry.message === 'proxy.configuration.update.completed' && entry.revision === 'revision-two'), true) + assert.equal(audit.some(entry => entry.message === 'proxy.configuration.update.failed' && entry.code === 'REVISION_CONFLICT'), true) + assert.equal(JSON.stringify(audit).includes('must-not-log'), false) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management exposes one reconnectable and cancellable outbound proxy test task', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-proxy-test-')) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + const taskId = '123e4567-e89b-42d3-a456-426614174000' + let status = 'running' + let cancelled = false + const state = () => ({ + schema: 1, taskId, status, baseRevision: 'revision-one', candidateHash: `sha256:${'a'.repeat(64)}`, + mode: 'proxy', currentStage: status === 'running' ? 'proxy-connect' : null, + stages: [{ stage: 'proxy-connect', status: status === 'running' ? 'running' : 'success', durationMs: 5, errorCode: null, detail: null }], + error: null, createdAt: '2026-08-25T00:00:00.000Z', updatedAt: new Date().toISOString(), + }) + const server = createManagementServer({ + coordinator: new Coordinator(), logs, + startProxyTest: async () => state(), + getProxyTest: async requested => { + assert.equal(requested, taskId) + return state() + }, + cancelProxyTest: async requested => { + assert.equal(requested, taskId) + cancelled = true + status = 'cancelled' + return state() + }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + assert.deepEqual(await client.request('POST', `${API_PREFIX}proxy/test`, { + baseRevision: 'revision-one', value: {}, + }), { taskId }) + assert.equal((await client.request('GET', `${API_PREFIX}status`)).proxyTestOperation.taskId, taskId) + assert.equal((await client.request('GET', `${API_PREFIX}proxy/test/tasks/${taskId}`)).status, 'running') + assert.equal((await client.request('DELETE', `${API_PREFIX}proxy/test/tasks/${taskId}`)).status, 'cancelled') + assert.equal(cancelled, true) + let audit + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + audit = await logs.query({ sources: ['audit'] }) + if (audit.some(entry => entry.message === 'proxy.test.cancelled' && entry.taskId === taskId)) break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(audit.some(entry => entry.message === 'proxy.test.started' && entry.taskId === taskId), true) + assert.equal(audit.some(entry => entry.message === 'proxy.test.stage.changed' + && entry.taskId === taskId && entry.stage === 'proxy-connect' && entry.status === 'running'), true) + assert.equal(audit.some(entry => entry.message === 'proxy.test.stage.changed' + && entry.taskId === taskId && entry.stage === 'proxy-connect' && entry.status === 'success'), true) + assert.equal(audit.some(entry => entry.message === 'proxy.test.cancel-requested' && entry.taskId === taskId), true) + assert.equal(audit.some(entry => entry.message === 'proxy.test.cancelled' && entry.taskId === taskId), true) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management exposes audited System Skill tasks through the shared runtime mutex', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-system-skills-')) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + let complete + const pending = new Promise(resolve => { complete = resolve }) + const calls = [] + const skills = [{ + id: 'dsh-docker-operations', sha256: 'a'.repeat(64), + description: { zh: '容器操作手册', en: 'Container operations guide' }, installed: true, enabled: true, + }] + const server = createManagementServer({ + coordinator: new Coordinator(), + logs, + listSystemSkills: async () => skills, + configureSystemSkill: async (skillId, action) => { calls.push({ skillId, action }); await pending }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + assert.deepEqual((await client.request('GET', `${API_PREFIX}system-skills`)).skills, skills) + const task = await client.request('POST', `${API_PREFIX}system-skills/action`, { + skillId: 'dsh-docker-operations', action: 'disable', + }) + assert.match(task.taskId, /^[0-9a-f-]{36}$/) + await assert.rejects(client.request('POST', `${API_PREFIX}restart-dsh`), error => error.statusCode === 409) + await assert.rejects(client.request('POST', `${API_PREFIX}system-skills/action`, { + skillId: 'dsh-docker-operations', action: 'disable', unexpected: true, + }), error => error.statusCode === 400) + complete() + let status + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', `${API_PREFIX}status`) + if (status.systemSkillOperation.status === 'success') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(status.systemSkillOperation.status, 'success') + assert.deepEqual(calls, [{ skillId: 'dsh-docker-operations', action: 'disable' }]) + const audit = await logs.query({ sources: ['audit'] }) + assert.equal(audit.some(entry => entry.message === 'system-skill.disable.started'), true) + assert.equal(audit.some(entry => entry.message === 'system-skill.disable.completed'), true) + } finally { + complete() + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management exposes validated User Skill tasks only on the standalone API', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-user-skills-')) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + let complete + const pending = new Promise(resolve => { complete = resolve }) + const revision = `sha256:${'a'.repeat(64)}` + const entryId = `sha256:${'b'.repeat(64)}` + const inventory = { + schema: 1, revision, + skills: [{ entryId, entryName: 'local-guide', source: 'user-dsh', kind: 'directory', name: 'local-guide', description: 'Local guide', enabled: true, damaged: false, metadataError: null, symbolicLink: false }], + } + const calls = [] + const server = createManagementServer({ + coordinator: new Coordinator(), + logs, + listUserSkills: async () => inventory, + validateUserSkillAction: async value => { + if (value.revision !== revision) { + const error = new Error('User Skill state changed') + error.statusCode = 409 + throw error + } + }, + configureUserSkill: async value => { + calls.push(value) + if (value.action === 'delete') throw new Error('permission denied') + await pending + }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + assert.deepEqual(await client.request('GET', `${API_PREFIX}user-skills`), inventory) + await assert.rejects(client.request('POST', `${API_PREFIX}user-skills/action`, { + entryId, revision: `sha256:${'c'.repeat(64)}`, action: 'disable', + }), error => error.statusCode === 409) + await assert.rejects(client.request('POST', `${API_PREFIX}user-skills/action`, { + entryId, revision, action: 'disable', unexpected: true, + }), error => error.statusCode === 400) + const task = await client.request('POST', `${API_PREFIX}user-skills/action`, { entryId, revision, action: 'disable' }) + assert.match(task.taskId, /^[0-9a-f-]{36}$/) + await assert.rejects(client.request('POST', `${API_PREFIX}restart-dsh`), error => error.statusCode === 409) + await assert.rejects(client.request('POST', `${API_PREFIX}user-skills/action`, { + entryId, revision, action: 'disable', + }), error => error.statusCode === 409) + complete() + let status + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', `${API_PREFIX}status`) + if (status.userSkillOperation.status === 'success') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(status.userSkillOperation.status, 'success') + const failedTask = await client.request('POST', `${API_PREFIX}user-skills/action`, { entryId, revision, action: 'delete' }) + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', `${API_PREFIX}status`) + if (status.userSkillOperation.taskId === failedTask.taskId && status.userSkillOperation.status === 'failed') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(status.userSkillOperation.status, 'failed') + assert.equal(status.userSkillOperation.error, 'permission denied') + assert.deepEqual(calls, [{ entryId, revision, action: 'disable' }, { entryId, revision, action: 'delete' }]) + const audit = await logs.query({ sources: ['audit'] }) + assert.equal(audit.some(entry => entry.message === 'user-skill.disable.started'), true) + assert.equal(audit.some(entry => entry.message === 'user-skill.disable.completed'), true) + assert.equal(audit.some(entry => entry.message === 'user-skill.delete.started'), true) + assert.equal(audit.some(entry => entry.message === 'user-skill.delete.failed' && entry.error === 'permission denied'), true) + } finally { + complete() + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management live logs preserve the requested source filter', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-log-stream-')) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + await logs.append('gateway', 'stdout', 'initial') + const server = createManagementServer({ coordinator: new Coordinator(), logs }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + let request + let response + try { + ({ request, response } = await new Promise((resolve, reject) => { + const nextRequest = httpRequest({ socketPath, path: `${API_PREFIX}logs/stream?source=gateway&limit=10` }, nextResponse => { + resolve({ request: nextRequest, response: nextResponse }) + }) + nextRequest.once('error', reject) + nextRequest.end() + })) + let body = '' + const live = new Promise(resolve => { + response.on('data', chunk => { + body += chunk.toString('utf8') + if (body.includes('must-stream')) resolve() + }) + }) + await logs.append('updater', 'stdout', 'must-not-stream') + await new Promise(resolve => setTimeout(resolve, 300)) + await appendFile(logs.currentPath('gateway'), `${JSON.stringify({ + timestamp: new Date().toISOString(), source: 'gateway', stream: 'stdout', level: 'info', message: 'must-stream', + })}\n`) + await Promise.race([ + live, + new Promise((_, reject) => setTimeout(() => reject(new Error('timed out waiting for live log')), 1_000)), + ]) + assert.match(body, /must-stream/) + assert.doesNotMatch(body, /must-not-stream/) + assert.equal(response.headers['x-accel-buffering'], 'no') + assert.equal(response.headers['cache-control'], 'no-cache, no-transform') + } finally { + request?.destroy() + response?.destroy() + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management state events keep idle proxy connections alive', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-state-stream-')) + const server = createManagementServer({ + coordinator: new Coordinator(), + logs: new JsonlLogManager({ root: join(root, 'logs') }), + stateHeartbeatMs: 10, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + let request + let response + try { + ({ request, response } = await new Promise((resolve, reject) => { + const nextRequest = httpRequest({ socketPath, path: `${API_PREFIX}events` }, nextResponse => { + resolve({ request: nextRequest, response: nextResponse }) + }) + nextRequest.once('error', reject) + nextRequest.end() + })) + let body = '' + await Promise.race([ + new Promise(resolve => { + response.on('data', chunk => { + body += chunk.toString('utf8') + if (body.includes('event: heartbeat')) resolve() + }) + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('timed out waiting for state heartbeat')), 1_000)), + ]) + assert.match(body, /event: state/) + assert.match(body, /event: heartbeat/) + assert.equal(response.headers['x-accel-buffering'], 'no') + assert.equal(response.headers['cache-control'], 'no-cache, no-transform') + } finally { + request?.destroy() + response?.destroy() + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management state fan-out supports many tabs and releases every listener', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-state-fanout-')) + const coordinator = new Coordinator() + const server = createManagementServer({ + coordinator, + logs: new JsonlLogManager({ root: join(root, 'logs') }), + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const connections = [] + try { + for (let index = 0; index < 12; index += 1) { + connections.push(await new Promise((resolve, reject) => { + const request = httpRequest({ socketPath, path: `${API_PREFIX}events` }, response => { + response.once('data', () => resolve({ request, response })) + }) + request.once('error', reject) + request.end() + })) + } + assert.equal(coordinator.getMaxListeners(), 0) + assert.equal(server.getMaxListeners(), 0) + assert.equal(coordinator.listenerCount('state'), 12) + assert.equal(server.listenerCount('management-state'), 12) + for (const connection of connections) { + connection.request.destroy() + connection.response.destroy() + } + for (let attempt = 0; attempt < 100 && coordinator.listenerCount('state') !== 0; attempt += 1) { + await new Promise(resolve => setTimeout(resolve, 5)) + } + assert.equal(coordinator.listenerCount('state'), 0) + assert.equal(server.listenerCount('management-state'), 0) + } finally { + for (const connection of connections) { + connection.request.destroy() + connection.response.destroy() + } + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management reports unpublished development metadata without an HTTP error', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-unpublished-')) + const coordinator = new Coordinator() + coordinator.check = async () => ({ unavailable: true, upstream: { version: 'rc.10' } }) + const server = createManagementServer({ + coordinator, + logs: new JsonlLogManager({ root: join(root, 'logs') }), + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + try { + assert.deepEqual(await new LocalApiClient(socketPath).request('POST', '/_dsh_platform/api/v1/check'), { + available: false, + upstream: { version: 'rc.10' }, + }) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management treats a page-open check during an update as an accepted no-op', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-busy-page-open-')) + const coordinator = new Coordinator() + coordinator.check = async () => ({ busy: true }) + const server = createManagementServer({ + coordinator, + logs: new JsonlLogManager({ root: join(root, 'logs') }), + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + try { + const response = await rawRequest(socketPath, `${API_PREFIX}check`, 'POST') + assert.equal(response.status, 202) + assert.deepEqual(JSON.parse(response.body), { busy: true }) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management edits only the fixed settings document with optimistic revisions', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-settings-document-')) + const dshHome = join(root, 'dsh') + const settingsDocument = new SettingsDocumentStore(dshHome) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + const server = createManagementServer({ coordinator: new Coordinator(), logs, settingsDocument }) + const socketPath = join(root, 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + const empty = await client.request('GET', `${API_PREFIX}settings-document`) + assert.equal(empty.content, '') + assert.equal(empty.exists, false) + assert.match(empty.revision, /^[a-f0-9]{64}$/) + const saved = await client.request('PUT', `${API_PREFIX}settings-document`, { + content: 'language: zh\n', revision: empty.revision, + }) + assert.equal(saved.exists, true) + assert.equal(await readFile(join(dshHome, 'settings.yaml'), 'utf8'), 'language: zh\n') + assert.equal((await lstat(join(dshHome, 'settings.yaml'))).isFile(), true) + await assert.rejects(client.request('PUT', `${API_PREFIX}settings-document`, { + content: 'language: en\n', revision: empty.revision, path: '/etc/passwd', + }), error => error.statusCode === 400) + await assert.rejects(client.request('PUT', `${API_PREFIX}settings-document`, { + content: 'language: en\n', revision: empty.revision, + }), error => error.statusCode === 409) + assert.equal(await readFile(join(dshHome, 'settings.yaml'), 'utf8'), 'language: zh\n') + assert.deepEqual((await logs.query({ sources: ['audit'] })).map(entry => entry.message), ['settings-document.saved']) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('settings document editing rejects symbolic links, invalid UTF-8, and oversized content', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-settings-unsafe-')) + const dshHome = join(root, 'dsh') + await import('node:fs/promises').then(({ mkdir }) => mkdir(dshHome)) + const target = join(root, 'outside.yaml') + await writeFile(target, 'outside: true\n') + await symlink(target, join(dshHome, 'settings.yaml')) + const store = new SettingsDocumentStore(dshHome, { maxBytes: 8 }) + await assert.rejects(store.read(), /regular file/) + await import('node:fs/promises').then(({ rm }) => rm(join(dshHome, 'settings.yaml'))) + await writeFile(join(dshHome, 'settings.yaml'), Buffer.from([0xc3, 0x28])) + await assert.rejects(store.read(), /not valid UTF-8/) + await assert.rejects(store.write('123456789', '0'.repeat(64)), /too large/) + assert.equal(await readFile(target, 'utf8'), 'outside: true\n') +}) + +test('management restarts only DSH as an audited task and excludes update activation', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-restart-')) + const coordinator = new Coordinator() + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + let finishRestart + const restartCompletion = new Promise(resolve => { finishRestart = resolve }) + let finishLoadedStateCapture + const loadedStateCaptureCompletion = new Promise(resolve => { finishLoadedStateCapture = resolve }) + let restarts = 0 + let loadedStateCaptures = 0 + const server = createManagementServer({ + coordinator, + logs, + restartDelayMs: 0, + restartDsh: async () => { + restarts += 1 + await restartCompletion + }, + markUserPluginsLoaded: async () => { + loadedStateCaptures += 1 + await loadedStateCaptureCompletion + }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + const task = await client.request('POST', '/_dsh_platform/api/v1/restart-dsh') + assert.equal(typeof task.taskId, 'string') + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS && restarts === 0; attempt += 1) { + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(restarts, 1) + assert.equal((await client.request('GET', '/_dsh_platform/api/v1/status')).dshLifecycle.state, 'restarting') + await assert.rejects( + client.request('POST', '/_dsh_platform/api/v1/restart-dsh'), + error => error.statusCode === 409, + ) + await assert.rejects( + client.request('POST', '/_dsh_platform/api/v1/update'), + error => error.statusCode === 409, + ) + finishRestart() + await new Promise(resolve => setImmediate(resolve)) + await logs.queue + const status = await client.request('GET', '/_dsh_platform/api/v1/status') + assert.equal(status.dshLifecycle.state, 'running') + assert.equal(status.dshLifecycle.taskId, task.taskId) + assert.equal(loadedStateCaptures, 1) + finishLoadedStateCapture() + assert.deepEqual( + (await logs.query({ sources: ['audit'] })).map(entry => entry.message), + ['dsh.restart.started', 'dsh.restart.completed'], + ) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management rejects DSH restart while an update task is active', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-restart-conflict-')) + const coordinator = new Coordinator() + coordinator.running = true + const server = createManagementServer({ + coordinator, + logs: new JsonlLogManager({ root: join(root, 'logs') }), + restartDelayMs: 0, + restartDsh: async () => {}, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + try { + await assert.rejects( + new LocalApiClient(socketPath).request('POST', '/_dsh_platform/api/v1/restart-dsh'), + error => error.statusCode === 409, + ) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management returns the restart task before the configured restart delay elapses', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-restart-delay-')) + let restarted = false + const server = createManagementServer({ + coordinator: new Coordinator(), + logs: new JsonlLogManager({ root: join(root, 'logs') }), + restartDelayMs: 40, + restartDsh: async () => { restarted = true }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + const task = await client.request('POST', `${API_PREFIX}restart-dsh`) + assert.equal(typeof task.taskId, 'string') + assert.equal(restarted, false) + await new Promise(resolve => setTimeout(resolve, 60)) + assert.equal(restarted, true) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management starts and stops DSH through the unified lifecycle state', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-lifecycle-')) + const calls = [] + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + const server = createManagementServer({ + coordinator: new Coordinator(), + logs, + startDsh: async () => { calls.push('start') }, + stopDsh: async () => { calls.push('stop') }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + const stopped = await client.request('POST', `${API_PREFIX}stop-dsh`) + let status + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', `${API_PREFIX}status`) + if (status.dshLifecycle.taskId === stopped.taskId && status.dshLifecycle.state === 'stopped') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(status.dshLifecycle.state, 'stopped') + assert.equal(status.dshLifecycle.action, null) + + const started = await client.request('POST', `${API_PREFIX}start-dsh`) + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', `${API_PREFIX}status`) + if (status.dshLifecycle.taskId === started.taskId && status.dshLifecycle.state === 'running') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(status.dshLifecycle.state, 'running') + assert.deepEqual(calls, ['stop', 'start']) + assert.deepEqual((await logs.query({ sources: ['audit'] })).map(entry => entry.message), [ + 'dsh.stop.started', 'dsh.stop.completed', 'dsh.start.started', 'dsh.start.completed', + ]) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management preserves a completed lifecycle task until Bootstrap publishes a newer state', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-lifecycle-merge-')) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + let bootstrapLifecycle = { + state: 'running', action: null, taskId: null, attempt: 0, maxAttempts: 3, + error: null, updatedAt: '2026-08-22T00:00:00.000Z', + } + const server = createManagementServer({ + coordinator: new Coordinator(), + logs, + platformStatus: async () => ({ dshLifecycle: bootstrapLifecycle }), + startDsh: async () => {}, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + const started = await client.request('POST', `${API_PREFIX}start-dsh`) + let status + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', `${API_PREFIX}status`) + if (status.dshLifecycle.taskId === started.taskId && status.dshLifecycle.state === 'running') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(status.dshLifecycle.taskId, started.taskId) + + bootstrapLifecycle = { + ...bootstrapLifecycle, + state: 'recovering', action: 'auto-recover', attempt: 1, + updatedAt: '2099-08-22T00:00:00.000Z', + } + status = await client.request('GET', `${API_PREFIX}status`) + assert.equal(status.dshLifecycle.state, 'recovering') + assert.equal(status.dshLifecycle.attempt, 1) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management resets the current Runtime as an audited exclusive task', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-runtime-reset-')) + const coordinator = new Coordinator() + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + let finishReset + const completion = new Promise(resolve => { finishReset = resolve }) + let resets = 0 + const server = createManagementServer({ + coordinator, + logs, + resetRuntime: async () => { + resets += 1 + await completion + }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + const task = await client.request('POST', '/_dsh_platform/api/v1/runtime/reset') + assert.equal(typeof task.taskId, 'string') + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS && resets === 0; attempt += 1) { + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(resets, 1) + assert.equal((await client.request('GET', '/_dsh_platform/api/v1/status')).runtimeReset.status, 'resetting') + for (const path of ['runtime/reset', 'restart-dsh', 'update']) { + await assert.rejects( + client.request('POST', `/_dsh_platform/api/v1/${path}`), + error => error.statusCode === 409, + ) + } + finishReset() + await new Promise(resolve => setImmediate(resolve)) + await logs.queue + const status = await client.request('GET', '/_dsh_platform/api/v1/status') + assert.equal(status.runtimeReset.status, 'success') + assert.equal(status.runtimeReset.taskId, task.taskId) + assert.deepEqual( + (await logs.query({ sources: ['audit'] })).map(entry => entry.message), + ['runtime.reset.started', 'runtime.reset.completed'], + ) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management exposes recoverable User Plugin tasks under the shared runtime mutex', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-user-plugins-')) + const coordinator = new Coordinator() + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + let finish + const completion = new Promise(resolve => { finish = resolve }) + const calls = [] + const inventory = { + schema: 1, + profile: 'web', + revision: `sha256:${'a'.repeat(64)}`, + plugins: [{ name: 'faulty-plugin', enabled: true, damaged: false }], + } + const server = createManagementServer({ + coordinator, + logs, + listUserPlugins: async () => inventory, + validateUserPluginActions: async value => { + calls.push(['validate', value]) + if (value.revision !== inventory.revision) { + const error = new Error('revision changed') + error.code = 'REVISION_CONFLICT' + throw error + } + return value + }, + applyUserPluginActions: async value => { + calls.push(['apply', value.taskId, value.revision, value.actions]) + value.onProgress({ phase: 'paused' }) + await completion + value.onProgress({ phase: 'completed' }) + }, + }) + const socketPath = join(root, 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + assert.deepEqual(await client.request('GET', `${API_PREFIX}user-plugins`), inventory) + const request = { + profile: 'web', revision: inventory.revision, + actions: [{ name: 'faulty-plugin', action: 'disable' }], + } + const started = await client.request('POST', `${API_PREFIX}user-plugins/apply`, request) + assert.match(started.taskId, /^[0-9a-f-]{36}$/) + let status + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', `${API_PREFIX}status`) + if (status.userPluginOperation.phase === 'paused') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(status.userPluginOperation.status, 'running') + assert.equal(status.userPluginOperation.phase, 'paused') + assert.equal((await client.request('GET', `${API_PREFIX}user-plugins/task/${started.taskId}`)).status, 'running') + for (const [path, body] of [ + ['restart-dsh', undefined], + ['update', undefined], + ['bundled-plugins/action', { id: 'diagnostics', action: 'disable' }], + ['user-plugins/apply', request], + ]) { + await assert.rejects(client.request('POST', `${API_PREFIX}${path}`, body), error => error.statusCode === 409) + } + finish() + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', `${API_PREFIX}status`) + if (status.userPluginOperation.status === 'success') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(status.userPluginOperation.phase, 'completed') + assert.equal((await client.request('GET', `${API_PREFIX}user-plugins/task/${started.taskId}`)).status, 'success') + assert.deepEqual(calls[0], ['validate', { revision: inventory.revision, actions: request.actions }]) + assert.deepEqual(calls[1].slice(0, 3), ['apply', started.taskId, inventory.revision]) + await assert.rejects(client.request('POST', `${API_PREFIX}user-plugins/apply`, { + ...request, revision: `sha256:${'b'.repeat(64)}`, + }), error => error.statusCode === 409) + await assert.rejects( + client.request('GET', `${API_PREFIX}user-plugins/task/123e4567-e89b-42d3-a456-426614174000`), + error => error.statusCode === 404, + ) + await logs.queue + assert.deepEqual((await logs.query({ sources: ['audit'] })).map(entry => entry.message), [ + 'user-plugin.apply.started', 'user-plugin.apply.completed', + ]) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management resumes a persisted User Plugin transaction after its socket is available', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-user-plugin-resume-')) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + let recover + const recovery = new Promise(resolve => { recover = resolve }) + const server = createManagementServer({ + coordinator: new Coordinator(), + logs, + recoverUserPluginTransaction: async () => recovery, + }) + const socketPath = join(root, 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + await assert.rejects(client.request('POST', `${API_PREFIX}restart-dsh`), error => error.statusCode === 409) + recover({ + taskId: 'resume-task', phase: 'failed', error: 'interrupted change restored', recoveryResult: 'success', + }) + let status + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', `${API_PREFIX}status`) + if (status.userPluginOperation.taskId === 'resume-task') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(status.userPluginOperation.status, 'failed') + assert.equal(status.userPluginOperation.error, 'interrupted change restored') + await logs.queue + assert.equal((await logs.query({ sources: ['audit'] })).at(-1).message, 'user-plugin.recovery.completed') + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('standalone Management disables a startup-faulting Bundle while DSH is already down', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-fault-plugin-')) + const dshHome = join(root, 'dsh') + const profileRoot = join(dshHome, 'profiles/web') + const pluginRoot = join(profileRoot, 'node_modules/startup-fault') + const selectionPath = join(root, 'platform/state/management/user-plugins.json') + await mkdir(pluginRoot, { recursive: true }) + await writeFile(join(profileRoot, 'package.json'), JSON.stringify({ + name: 'dsh-profile-web', + dependencies: { 'startup-fault': '1.0.0' }, + dsh: { profile: { bundles: ['startup-fault'] } }, + })) + await writeFile(join(profileRoot, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + await writeFile(join(pluginRoot, 'package.json'), JSON.stringify({ + name: 'startup-fault', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + await writeFile(join(pluginRoot, 'cordis.patch.yml'), '- insert: [{ id: startup-fault, name: startup-fault }]\n') + const inventory = new UserPluginInventory({ dshHome, selectionPath }) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + let paused = 0 + let restarted = 0 + const transactions = new UserPluginTransactionManager({ + inventory, + selectionStore: new UserPluginSelectionStore(selectionPath), + snapshots: new UserPluginSnapshots({ root: join(root, 'platform/store/snapshots/user-plugins'), profileRoot }), + journal: new UserPluginJournal(join(root, 'platform/state/management/user-plugin-transaction.json')), + pauseDsh: async () => { paused += 1 }, + restartDelayMs: 0, + restartDsh: async () => { + restarted += 1 + const manifest = JSON.parse(await readFile(join(profileRoot, 'package.json'), 'utf8')) + if (manifest.dsh.profile.bundles.includes('startup-fault')) throw new Error('startup-fault crashed DSH') + }, + report: (message, fields) => logs.diagnostic('user-plugin-manager', message, fields), + }) + const server = createManagementServer({ + coordinator: new Coordinator(), + logs, + platformStatus: async () => ({ recoveryMode: 'startup-fault crashed DSH' }), + listUserPlugins: () => inventory.read(), + validateUserPluginActions: value => transactions.validate(value), + applyUserPluginActions: value => transactions.apply(value), + }) + const socketPath = join(root, 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + const listed = await client.request('GET', `${API_PREFIX}user-plugins`) + assert.deepEqual(listed.plugins.map(plugin => [plugin.name, plugin.enabled]), [['startup-fault', true]]) + assert.equal((await client.request('GET', `${API_PREFIX}status`)).recoveryMode, 'startup-fault crashed DSH') + const task = await client.request('POST', `${API_PREFIX}user-plugins/apply`, { + profile: 'web', revision: listed.revision, + actions: [{ name: 'startup-fault', action: 'disable' }], + }) + let state + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + state = await client.request('GET', `${API_PREFIX}user-plugins/task/${task.taskId}`) + if (state.status !== 'running') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(state.status, 'success') + assert.equal(state.strategy, 'next-start') + assert.equal(paused, 0) + assert.equal(restarted, 0) + const manifest = JSON.parse(await readFile(join(profileRoot, 'package.json'), 'utf8')) + assert.deepEqual(manifest.dsh.profile.bundles, []) + await logs.queue + assert.deepEqual((await logs.query({ sources: ['user-plugin-manager'] })).map(entry => entry.message), [ + 'user-plugin.transaction.started', 'user-plugin.transaction.completed', + ]) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management changes a bundled plugin as an audited task and excludes runtime changes', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-plugin-action-')) + const coordinator = new Coordinator() + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + let finish + const completion = new Promise(resolve => { finish = resolve }) + const calls = [] + const server = createManagementServer({ + coordinator, + logs, + listBundledPlugins: async () => [{ id: 'diagnostics', installed: true, enabled: true, protected: false }], + configureBundledPlugin: async (id, action) => { + calls.push([id, action]) + await completion + }, + restartDelayMs: 0, + restartDsh: async () => {}, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + assert.deepEqual((await client.request('GET', '/_dsh_platform/api/v1/bundled-plugins')).plugins, [{ + id: 'diagnostics', installed: true, enabled: true, protected: false, + }]) + const task = await client.request('POST', '/_dsh_platform/api/v1/bundled-plugins/action', { + id: 'diagnostics', action: 'disable', + }) + assert.equal(typeof task.taskId, 'string') + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS && calls.length === 0; attempt += 1) { + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.deepEqual(calls, [['diagnostics', 'disable']]) + assert.equal((await client.request('GET', '/_dsh_platform/api/v1/status')).systemPluginOperation.status, 'running') + assert.equal((await client.request('GET', `/_dsh_platform/api/v1/bundled-plugins/task/${task.taskId}`)).status, 'running') + await assert.rejects( + client.request('GET', '/_dsh_platform/api/v1/bundled-plugins/task/123e4567-e89b-42d3-a456-426614174000'), + error => error.statusCode === 404, + ) + for (const [method, path, body] of [ + ['POST', '/_dsh_platform/api/v1/bundled-plugins/action', { id: 'diagnostics', action: 'uninstall' }], + ['POST', '/_dsh_platform/api/v1/restart-dsh'], + ['POST', '/_dsh_platform/api/v1/update'], + ]) { + await assert.rejects(client.request(method, path, body), error => error.statusCode === 409) + } + finish() + await new Promise(resolve => setImmediate(resolve)) + await logs.queue + const changed = await client.request('GET', '/_dsh_platform/api/v1/status') + assert.equal(changed.systemPluginOperation.status, 'success') + assert.equal(changed.systemPluginOperation.restartRequired, true) + assert.equal((await client.request('GET', `/_dsh_platform/api/v1/bundled-plugins/task/${task.taskId}`)).status, 'success') + assert.deepEqual( + (await logs.query({ sources: ['audit'] })).map(entry => entry.message), + ['system-plugin.disable.started', 'system-plugin.disable.completed'], + ) + await client.request('POST', '/_dsh_platform/api/v1/restart-dsh') + let restarted + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + restarted = await client.request('GET', '/_dsh_platform/api/v1/status') + if (restarted.dshLifecycle.state === 'running') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(restarted.dshLifecycle.state, 'running') + assert.equal(restarted.systemPluginOperation.restartRequired, false) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management completes System Plugin changes for the next start while DSH is unavailable', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-system-plugin-offline-')) + let configured = false + const server = createManagementServer({ + coordinator: new Coordinator(), + logs: new JsonlLogManager({ root: join(root, 'logs') }), + platformStatus: async () => ({ + recoveryMode: 'startup failed', + dshLifecycle: { state: 'failed' }, + }), + configureBundledPlugin: async () => { configured = true }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + await client.request('POST', `${API_PREFIX}bundled-plugins/action`, { + id: 'diagnostics', action: 'disable', + }) + let status + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', `${API_PREFIX}status`) + if (status.systemPluginOperation.status === 'success') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(configured, true) + assert.equal(status.systemPluginOperation.strategy, 'next-start') + assert.equal(status.systemPluginOperation.restartRequired, false) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management toggle endpoint accepts only enable and disable actions', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-plugin-toggle-')) + const calls = [] + const server = createManagementServer({ + coordinator: new Coordinator(), + logs: new JsonlLogManager({ root: join(root, 'logs') }), + configureBundledPlugin: async (id, action) => { calls.push([id, action]) }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + await assert.rejects(client.request('POST', `${API_PREFIX}bundled-plugins/toggle`, { + id: 'diagnostics', action: 'install', + }), error => error.statusCode === 400) + const task = await client.request('POST', `${API_PREFIX}bundled-plugins/toggle`, { + id: 'diagnostics', action: 'disable', + }) + assert.equal(typeof task.taskId, 'string') + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS && calls.length === 0; attempt += 1) { + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.deepEqual(calls, [['diagnostics', 'disable']]) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management discards unapplied System Plugin changes and clears restart state', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-plugin-discard-')) + let discarded = 0 + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + const server = createManagementServer({ + coordinator: new Coordinator(), + logs, + configureBundledPlugin: async () => {}, + discardBundledPluginChanges: async () => { + discarded += 1 + return { plugins: [{ id: 'diagnostics', pendingRestart: false }] } + }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + await client.request('POST', `${API_PREFIX}bundled-plugins/action`, { + id: 'diagnostics', action: 'disable', + }) + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + if ((await client.request('GET', `${API_PREFIX}status`)).systemPluginOperation.restartRequired) break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + let result + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + try { + result = await client.request('POST', `${API_PREFIX}bundled-plugins/discard`) + break + } catch (error) { + if (error.statusCode !== 409) throw error + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + } + assert.notEqual(result, undefined) + assert.equal(discarded, 1) + assert.deepEqual(result, { plugins: [{ id: 'diagnostics', pendingRestart: false }] }) + assert.equal((await client.request('GET', `${API_PREFIX}status`)).systemPluginOperation.restartRequired, false) + assert.deepEqual((await logs.query({ sources: ['audit'] })).map(entry => entry.message), [ + 'system-plugin.disable.started', + 'system-plugin.disable.completed', + 'system-plugin.changes.discarded', + ]) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management completes the System Plugin change when DSH restart fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-plugin-restart-failure-')) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + const server = createManagementServer({ + coordinator: new Coordinator(), + logs, + configureBundledPlugin: async () => {}, + restartDelayMs: 0, + restartDsh: async () => { throw new Error('restart failed') }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + await client.request('POST', '/_dsh_platform/api/v1/bundled-plugins/action', { + id: 'diagnostics', action: 'disable', + }) + let status + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', '/_dsh_platform/api/v1/status') + if (status.systemPluginOperation.status === 'success') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(status.systemPluginOperation.restartRequired, true) + await client.request('POST', '/_dsh_platform/api/v1/restart-dsh') + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', '/_dsh_platform/api/v1/status') + if (status.dshLifecycle.state === 'failed') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(status.dshLifecycle.state, 'failed') + assert.equal(status.systemPluginOperation.strategy, 'next-start') + assert.equal(status.systemPluginOperation.activationError, 'restart failed') + assert.equal(status.systemPluginOperation.restartRequired, false) + await logs.queue + assert.equal((await logs.query({ sources: ['audit'] })).find(entry => entry.message === 'dsh.restart.failed').level, 'error') + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management audit failures do not block a completed System Plugin operation', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-audit-failure-')) + let configured = false + const logs = { + diagnostic: () => { throw new Error('audit storage unavailable') }, + query: async () => [], + on: () => {}, + off: () => {}, + } + const server = createManagementServer({ + coordinator: new Coordinator(), + logs, + configureBundledPlugin: async () => { configured = true }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + await client.request('POST', `${API_PREFIX}bundled-plugins/action`, { + id: 'diagnostics', action: 'disable', + }) + let status + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS; attempt += 1) { + status = await client.request('GET', `${API_PREFIX}status`) + if (status.systemPluginOperation.status === 'success') break + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.equal(configured, true) + assert.equal(status.systemPluginOperation.status, 'success') + assert.equal(status.systemPluginOperation.restartRequired, true) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management exposes a dedicated recovery action only for Platform Management', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-plugin-recovery-')) + const calls = [] + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + const server = createManagementServer({ + coordinator: new Coordinator(), + logs, + recoverBundledPlugin: async (id, action) => { calls.push([id, action]) }, + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + const client = new LocalApiClient(socketPath) + try { + await assert.rejects(client.request('POST', `${API_PREFIX}bundled-plugins/recovery-action`, { + id: 'diagnostics', action: 'uninstall', + }), error => error.statusCode === 400) + const task = await client.request('POST', `${API_PREFIX}bundled-plugins/recovery-action`, { + id: 'platform-management', action: 'disable', + }) + assert.equal(typeof task.taskId, 'string') + for (let attempt = 0; attempt < ASYNC_POLL_ATTEMPTS && calls.length === 0; attempt += 1) { + await new Promise(resolve => setTimeout(resolve, ASYNC_POLL_INTERVAL_MS)) + } + assert.deepEqual(calls, [['platform-management', 'disable']]) + await new Promise(resolve => setImmediate(resolve)) + await logs.queue + assert.deepEqual( + (await logs.query({ sources: ['audit'] })).map(entry => entry.message), + ['system-plugin.recovery.disable.started', 'system-plugin.recovery.disable.completed'], + ) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management separates a known Stable target from update availability', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-current-')) + const coordinator = new Coordinator() + coordinator.check = async () => ({ + value: { targetSequence: 2, desired: { dsh: { version: 'rc.7' } } }, + updateAvailable: false, + }) + const server = createManagementServer({ + coordinator, + logs: new JsonlLogManager({ root: join(root, 'logs') }), + }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + try { + assert.deepEqual(await new LocalApiClient(socketPath).request('POST', '/_dsh_platform/api/v1/check'), { + available: false, + targetSequence: 2, + desired: { dsh: { version: 'rc.7' } }, + }) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management records a completion audit for a successful update task', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-audit-')) + const coordinator = new Coordinator() + coordinator.startReconcile = () => ({ taskId: 'task-success', completion: Promise.resolve() }) + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + const server = createManagementServer({ coordinator, logs }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + try { + const client = new LocalApiClient(socketPath) + await client.request('POST', '/_dsh_platform/api/v1/update') + await new Promise(resolve => setImmediate(resolve)) + await logs.queue + assert.deepEqual( + (await logs.query({ sources: ['audit'] })).map(entry => [entry.message, entry.taskId]), + [['update.started', 'task-success'], ['update.completed', 'task-success']], + ) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management records correlated rollback and return-stable audit lifecycles', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-rollback-audit-')) + const coordinator = new Coordinator() + coordinator.startCompleteRollback = (planId, options) => { + assert.equal(planId, 'plan-a') + if (options.requireConfirmation) { + return { taskId: 'return-stable-failure', completion: Promise.reject(new Error('restore failed')) } + } + return { taskId: 'rollback-success', completion: Promise.resolve() } + } + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + const server = createManagementServer({ coordinator, logs }) + const socketPath = join(root, 'run', 'management.sock') + await listenManagement(server, socketPath) + try { + const client = new LocalApiClient(socketPath) + await client.request('POST', `${API_PREFIX}rollback`, { planId: 'plan-a' }) + await client.request('POST', `${API_PREFIX}return-stable`, { + planId: 'plan-a', confirmDataLoss: true, + }) + await new Promise(resolve => setImmediate(resolve)) + await logs.queue + assert.deepEqual( + (await logs.query({ sources: ['audit'] })).map(entry => [entry.message, entry.taskId]), + [ + ['rollback.started', 'rollback-success'], + ['rollback.completed', 'rollback-success'], + ['return-stable.started', 'return-stable-failure'], + ['return-stable.failed', 'return-stable-failure'], + ], + ) + } finally { + await new Promise(resolve => server.close(resolve)) + } +}) + +test('management serves only the fixed persistent Platform Management assets', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-management-console-')) + const coordinator = new Coordinator() + const logs = new JsonlLogManager({ root: join(root, 'logs') }) + const server = createManagementServer({ coordinator, logs }) + const socketPath = join(root, 'management.sock') + await listenManagement(server, socketPath) + try { + const redirect = await rawRequest(socketPath, '/_dsh_platform/console') + assert.equal(redirect.status, 308) + assert.equal(redirect.headers.location, '/_dsh_platform/console/') + const page = await rawRequest(socketPath, '/_dsh_platform/console/') + assert.equal(page.status, 200) + assert.match(page.headers['content-type'], /^text\/html/) + assert.match(page.body.toString('utf8'), / match[1])) + const references = [...script.matchAll(/elements(?:\.([A-Za-z][A-Za-z0-9]*)|\[['"]([a-z0-9-]+)['"]\])/g)] + .map(match => match[1] ?? match[2]) + assert.equal(references.length > 0, true) + for (const id of references) assert.equal(ids.has(id), true, `Console element ${id} is not declared`) + assert.doesNotMatch(script, /innerHTML|outerHTML|insertAdjacentHTML/) + assert.match(script, /selectTab\(window\.location\.hash === '#auth-settings' \? 'auth-settings' : 'maintenance'\)/) +}) + +test('standalone file task queue stays below file actions and scrolls within a fixed height', async () => { + const publicRoot = new URL('../../control-plane/services/management/public/', import.meta.url) + const html = await readFile(new URL('index.html', publicRoot), 'utf8') + const script = await readFile(new URL('app.js', publicRoot), 'utf8') + const style = await readFile(new URL('style.css', publicRoot), 'utf8') + const browserOffset = html.indexOf('class="file-browser"') + const selectionOffset = html.indexOf('class="file-selection"') + const taskOffset = html.indexOf('id="file-task-state"') + const archiveOffset = html.indexOf('id="file-archive-panel"') + assert.equal(browserOffset < selectionOffset && selectionOffset < taskOffset && taskOffset < archiveOffset, true) + assert.match(style, /\.file-task-list\s*\{[^}]*max-height:\s*180px;[^}]*overflow-y:\s*auto;/) + assert.match(style, /\.file-main\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;/) + assert.match(style, /\.file-pagination\s*\{[^}]*margin-top:\s*auto;/) + assert.match(script, /task\.status === 'failed' && Date\.parse\(task\.updatedAt\) >= recentCutoff/) + assert.doesNotMatch(script, /\['success', 'failed', 'cancelled'\]/) + assert.match(script, /fileUploadQueue = files\.map/) + assert.match(script, /request\.upload\.onprogress/) + assert.match(script, /\.\.\.fileUploadQueue, \.\.\.backend/) + assert.match(script, /task\.local === true\) task\.cancel\(\)/) + assert.match(script, /item\.webkitGetAsEntry\?\.\(\)/) + assert.match(script, /collectDroppedEntry\(child, relativePath\)/) + assert.match(script, /fileDropTarget\.addEventListener\('drop'/) + assert.match(html, /class="file-drop-overlay"/) + assert.match(style, /\.file-main\.file-dragging \.file-drop-overlay\s*\{[^}]*display:\s*grid;/) + assert.match(style, /\.proxy-provider-list \{[^}]*max-height: 320px;[^}]*overflow-y: auto;/) + assert.match(html, /id="proxy-advanced"[\s\S]*id="proxy-remote-dns-row"[^>]*hidden[\s\S]*id="proxy-remote-dns"/) + assert.doesNotMatch(style, /\.proxy-check-row/) + assert.match(style, /\.proxy-scope-description \{[^}]*-webkit-line-clamp: 1;/) + assert.match(script, /function refreshProxyDescriptions\(\)[\s\S]*preserveScrollableAncestors/) + assert.match(script, /function renderProxyProviders\(\)[\s\S]*displayName[\s\S]*info\.className = 'proxy-provider-info'/) + assert.match(script, /proxyConfiguration = await api\('proxy'\)[\s\S]*renderProxyConfiguration\(\)[\s\S]*void refreshProxyProviders\(\)/) + assert.match(script, /if \(tab === 'proxy'\) \{[\s\S]*if \(proxyLoaded\) void refreshProxyProviders\(\)[\s\S]*else void loadProxy\(\)/) + assert.match(script, /function refreshVisibleProxyProviders\(\)[\s\S]*proxyLoaded[\s\S]*panel-proxy[\s\S]*refreshProxyProviders\(\)/) + assert.match(script, /window\.addEventListener\('focus', refreshVisibleProxyProviders\)/) + assert.doesNotMatch(script, /Promise\.all\(\[api\('proxy'\), api\('proxy\/provider-inventory'\)\]\)/) + assert.match(script, /proxy-provider-search[\s\S]*addEventListener\('input', renderProxyProviders\)/) + assert.match(script, /proxy-clear-password[\s\S]*getAttribute\('aria-pressed'\) === 'true'/) + assert.match(script, /route\.className = 'proxy-provider-route'[\s\S]*input\.dataset\.providerPolicy = provider\.id[\s\S]*input\.checked = policy\.proxyEnabled === true/) + assert.match(html, /class="proxy-scope-card"[\s\S]*class="toggle"[\s\S]*data-proxy-scope="updates"/) + assert.match(html, /id="proxy-clear-password"[^>]*aria-pressed="false"/) + assert.match(html, /class="proxy-setting-row[^"]*"[\s\S]*class="toggle"[\s\S]*id="proxy-all-proxy"/) + assert.match(html, /id="proxy-provider-search"[^>]*type="search"/) + assert.match(html, /id="proxy-test-dialog"[\s\S]*id="proxy-test-stages"/) + assert.doesNotMatch(html, /id="proxy-test-close"/) + assert.match(html, /