@paperclipai/server 2026.824.0 → 2026.825.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/builtin-adapter-types.d.ts.map +1 -1
- package/dist/adapters/builtin-adapter-types.js +1 -0
- package/dist/adapters/builtin-adapter-types.js.map +1 -1
- package/dist/adapters/plugin-loader.d.ts +1 -0
- package/dist/adapters/plugin-loader.d.ts.map +1 -1
- package/dist/adapters/plugin-loader.js +12 -1
- package/dist/adapters/plugin-loader.js.map +1 -1
- package/dist/adapters/plugin-loader.test.d.ts +2 -0
- package/dist/adapters/plugin-loader.test.d.ts.map +1 -0
- package/dist/adapters/plugin-loader.test.js +37 -0
- package/dist/adapters/plugin-loader.test.js.map +1 -0
- package/dist/adapters/registry.d.ts.map +1 -1
- package/dist/adapters/registry.js +68 -5
- package/dist/adapters/registry.js.map +1 -1
- package/dist/adapters/registry.test.d.ts +2 -0
- package/dist/adapters/registry.test.d.ts.map +1 -0
- package/dist/adapters/registry.test.js +35 -0
- package/dist/adapters/registry.test.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +5 -7
- package/dist/app.js.map +1 -1
- package/dist/auth/better-auth.d.ts +10 -0
- package/dist/auth/better-auth.d.ts.map +1 -1
- package/dist/auth/better-auth.js +47 -0
- package/dist/auth/better-auth.js.map +1 -1
- package/dist/auth/workspace-login-handoff-exchange.d.ts +75 -0
- package/dist/auth/workspace-login-handoff-exchange.d.ts.map +1 -0
- package/dist/auth/workspace-login-handoff-exchange.js +73 -0
- package/dist/auth/workspace-login-handoff-exchange.js.map +1 -0
- package/dist/auth/workspace-login-handoff-plugin.d.ts +40 -0
- package/dist/auth/workspace-login-handoff-plugin.d.ts.map +1 -0
- package/dist/auth/workspace-login-handoff-plugin.js +140 -0
- package/dist/auth/workspace-login-handoff-plugin.js.map +1 -0
- package/dist/auth/workspace-login-handoff.d.ts +189 -0
- package/dist/auth/workspace-login-handoff.d.ts.map +1 -0
- package/dist/auth/workspace-login-handoff.js +319 -0
- package/dist/auth/workspace-login-handoff.js.map +1 -0
- package/dist/build-info.json +3 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +14 -0
- package/dist/config.js.map +1 -1
- package/dist/dev-runner-worktree.d.ts.map +1 -1
- package/dist/dev-runner-worktree.js +5 -0
- package/dist/dev-runner-worktree.js.map +1 -1
- package/dist/duplex-aggregate-ceiling-env.d.ts +21 -0
- package/dist/duplex-aggregate-ceiling-env.d.ts.map +1 -0
- package/dist/duplex-aggregate-ceiling-env.js +23 -0
- package/dist/duplex-aggregate-ceiling-env.js.map +1 -0
- package/dist/embedded-postgres-supervisor.d.ts +27 -0
- package/dist/embedded-postgres-supervisor.d.ts.map +1 -0
- package/dist/embedded-postgres-supervisor.js +72 -0
- package/dist/embedded-postgres-supervisor.js.map +1 -0
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +6 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +115 -7
- package/dist/index.js.map +1 -1
- package/dist/instrumentation.d.ts +33 -0
- package/dist/instrumentation.d.ts.map +1 -1
- package/dist/instrumentation.js +68 -1
- package/dist/instrumentation.js.map +1 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +35 -10
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/error-handler.d.ts.map +1 -1
- package/dist/middleware/error-handler.js +8 -1
- package/dist/middleware/error-handler.js.map +1 -1
- package/dist/middleware/logger.d.ts.map +1 -1
- package/dist/middleware/logger.js +5 -2
- package/dist/middleware/logger.js.map +1 -1
- package/dist/middleware/redact-sensitive.d.ts.map +1 -1
- package/dist/middleware/redact-sensitive.js +4 -0
- package/dist/middleware/redact-sensitive.js.map +1 -1
- package/dist/middleware/validate.d.ts +3 -1
- package/dist/middleware/validate.d.ts.map +1 -1
- package/dist/middleware/validate.js +37 -0
- package/dist/middleware/validate.js.map +1 -1
- package/dist/routes/access.d.ts.map +1 -1
- package/dist/routes/access.js +20 -0
- package/dist/routes/access.js.map +1 -1
- package/dist/routes/activity.js +4 -4
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/adapter-login-route-spine.d.ts +81 -0
- package/dist/routes/adapter-login-route-spine.d.ts.map +1 -0
- package/dist/routes/adapter-login-route-spine.js +40 -0
- package/dist/routes/adapter-login-route-spine.js.map +1 -0
- package/dist/routes/adapters.d.ts +33 -0
- package/dist/routes/adapters.d.ts.map +1 -1
- package/dist/routes/adapters.js +35 -1
- package/dist/routes/adapters.js.map +1 -1
- package/dist/routes/adapters.test.d.ts +2 -0
- package/dist/routes/adapters.test.d.ts.map +1 -0
- package/dist/routes/adapters.test.js +58 -0
- package/dist/routes/adapters.test.js.map +1 -0
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +281 -91
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/cases.d.ts +11 -22
- package/dist/routes/cases.d.ts.map +1 -1
- package/dist/routes/cases.js +13 -13
- package/dist/routes/companies.js +3 -3
- package/dist/routes/decision-training.js +6 -6
- package/dist/routes/decisions.js +4 -4
- package/dist/routes/decisions.js.map +1 -1
- package/dist/routes/environments.d.ts.map +1 -1
- package/dist/routes/environments.js +79 -6
- package/dist/routes/environments.js.map +1 -1
- package/dist/routes/execution-workspaces.d.ts.map +1 -1
- package/dist/routes/execution-workspaces.js +384 -16
- package/dist/routes/execution-workspaces.js.map +1 -1
- package/dist/routes/file-resources.js +2 -2
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +58 -4
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/instance-settings.d.ts.map +1 -1
- package/dist/routes/instance-settings.js +40 -0
- package/dist/routes/instance-settings.js.map +1 -1
- package/dist/routes/issues.d.ts +10 -0
- package/dist/routes/issues.d.ts.map +1 -1
- package/dist/routes/issues.js +495 -65
- package/dist/routes/issues.js.map +1 -1
- package/dist/routes/openapi.d.ts.map +1 -1
- package/dist/routes/openapi.js +241 -122
- package/dist/routes/openapi.js.map +1 -1
- package/dist/routes/pipelines.js +19 -19
- package/dist/routes/plugins.d.ts.map +1 -1
- package/dist/routes/plugins.js +22 -0
- package/dist/routes/plugins.js.map +1 -1
- package/dist/routes/projects.d.ts.map +1 -1
- package/dist/routes/projects.js +2 -0
- package/dist/routes/projects.js.map +1 -1
- package/dist/routes/routines.js +2 -2
- package/dist/routes/routines.js.map +1 -1
- package/dist/routes/secrets.d.ts +1 -1
- package/dist/routes/secrets.d.ts.map +1 -1
- package/dist/routes/secrets.js +39 -50
- package/dist/routes/secrets.js.map +1 -1
- package/dist/routes/setup-token-route.test.js +50 -3
- package/dist/routes/setup-token-route.test.js.map +1 -1
- package/dist/services/adapter-login-lease.d.ts +50 -0
- package/dist/services/adapter-login-lease.d.ts.map +1 -0
- package/dist/services/adapter-login-lease.js +26 -0
- package/dist/services/adapter-login-lease.js.map +1 -0
- package/dist/services/adapter-login-lease.test.d.ts +2 -0
- package/dist/services/adapter-login-lease.test.d.ts.map +1 -0
- package/dist/services/adapter-login-lease.test.js +68 -0
- package/dist/services/adapter-login-lease.test.js.map +1 -0
- package/dist/services/agent-action-audit.js +1 -1
- package/dist/services/agent-secret-bindings.d.ts +15 -0
- package/dist/services/agent-secret-bindings.d.ts.map +1 -1
- package/dist/services/agent-secret-bindings.js +2 -2
- package/dist/services/agent-secret-bindings.js.map +1 -1
- package/dist/services/agents.d.ts +13 -13
- package/dist/services/agents.d.ts.map +1 -1
- package/dist/services/agents.js +46 -5
- package/dist/services/agents.js.map +1 -1
- package/dist/services/artifact-review-documents.d.ts +34 -0
- package/dist/services/artifact-review-documents.d.ts.map +1 -0
- package/dist/services/artifact-review-documents.js +196 -0
- package/dist/services/artifact-review-documents.js.map +1 -0
- package/dist/services/assets.d.ts +1 -1
- package/dist/services/codex-device-login-credential-read.d.ts +58 -0
- package/dist/services/codex-device-login-credential-read.d.ts.map +1 -0
- package/dist/services/codex-device-login-credential-read.js +118 -0
- package/dist/services/codex-device-login-credential-read.js.map +1 -0
- package/dist/services/codex-device-login-reaper.d.ts.map +1 -1
- package/dist/services/codex-device-login-reaper.js +8 -5
- package/dist/services/codex-device-login-reaper.js.map +1 -1
- package/dist/services/codex-device-login-service.d.ts +127 -36
- package/dist/services/codex-device-login-service.d.ts.map +1 -1
- package/dist/services/codex-device-login-service.js +204 -84
- package/dist/services/codex-device-login-service.js.map +1 -1
- package/dist/services/company-portability.d.ts +3 -1
- package/dist/services/company-portability.d.ts.map +1 -1
- package/dist/services/company-portability.js +76 -20
- package/dist/services/company-portability.js.map +1 -1
- package/dist/services/company-skill-policy.d.ts +1 -1
- package/dist/services/costs.d.ts +1 -1
- package/dist/services/decisions.d.ts +6 -6
- package/dist/services/duplex-observability-recorder.d.ts +62 -0
- package/dist/services/duplex-observability-recorder.d.ts.map +1 -0
- package/dist/services/duplex-observability-recorder.js +54 -0
- package/dist/services/duplex-observability-recorder.js.map +1 -0
- package/dist/services/environment-config.js +5 -5
- package/dist/services/environment-config.js.map +1 -1
- package/dist/services/environment-custom-image-runtime.d.ts +116 -0
- package/dist/services/environment-custom-image-runtime.d.ts.map +1 -1
- package/dist/services/environment-custom-image-runtime.js +255 -1
- package/dist/services/environment-custom-image-runtime.js.map +1 -1
- package/dist/services/environment-custom-images.d.ts +39 -0
- package/dist/services/environment-custom-images.d.ts.map +1 -1
- package/dist/services/environment-custom-images.js +219 -17
- package/dist/services/environment-custom-images.js.map +1 -1
- package/dist/services/environment-driver-traits.d.ts +109 -0
- package/dist/services/environment-driver-traits.d.ts.map +1 -0
- package/dist/services/environment-driver-traits.js +102 -0
- package/dist/services/environment-driver-traits.js.map +1 -0
- package/dist/services/environment-execution-target.d.ts +4 -0
- package/dist/services/environment-execution-target.d.ts.map +1 -1
- package/dist/services/environment-execution-target.js +63 -7
- package/dist/services/environment-execution-target.js.map +1 -1
- package/dist/services/environment-run-orchestrator.d.ts +27 -1
- package/dist/services/environment-run-orchestrator.d.ts.map +1 -1
- package/dist/services/environment-run-orchestrator.js +24 -4
- package/dist/services/environment-run-orchestrator.js.map +1 -1
- package/dist/services/environment-runtime.d.ts +118 -20
- package/dist/services/environment-runtime.d.ts.map +1 -1
- package/dist/services/environment-runtime.js +436 -91
- package/dist/services/environment-runtime.js.map +1 -1
- package/dist/services/environments.d.ts.map +1 -1
- package/dist/services/environments.js +20 -2
- package/dist/services/environments.js.map +1 -1
- package/dist/services/execution-workspace-branch-ownership.d.ts +4 -0
- package/dist/services/execution-workspace-branch-ownership.d.ts.map +1 -0
- package/dist/services/execution-workspace-branch-ownership.js +9 -0
- package/dist/services/execution-workspace-branch-ownership.js.map +1 -0
- package/dist/services/execution-workspace-policy.d.ts.map +1 -1
- package/dist/services/execution-workspace-policy.js +3 -0
- package/dist/services/execution-workspace-policy.js.map +1 -1
- package/dist/services/execution-workspaces.d.ts +2 -0
- package/dist/services/execution-workspaces.d.ts.map +1 -1
- package/dist/services/execution-workspaces.js +90 -2
- package/dist/services/execution-workspaces.js.map +1 -1
- package/dist/services/external-objects.d.ts +19 -19
- package/dist/services/finance.d.ts +1 -1
- package/dist/services/goals.d.ts +2 -2
- package/dist/services/heartbeat-policy.d.ts +3 -0
- package/dist/services/heartbeat-policy.d.ts.map +1 -0
- package/dist/services/heartbeat-policy.js +10 -0
- package/dist/services/heartbeat-policy.js.map +1 -0
- package/dist/services/heartbeat-run-runtime-status.test.js +1 -1
- package/dist/services/heartbeat-run-runtime-status.test.js.map +1 -1
- package/dist/services/heartbeat.d.ts +23 -1
- package/dist/services/heartbeat.d.ts.map +1 -1
- package/dist/services/heartbeat.js +192 -11
- package/dist/services/heartbeat.js.map +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/instance-settings.d.ts.map +1 -1
- package/dist/services/instance-settings.js +2 -0
- package/dist/services/instance-settings.js.map +1 -1
- package/dist/services/issue-dependency-wakeups.d.ts +57 -9
- package/dist/services/issue-dependency-wakeups.d.ts.map +1 -1
- package/dist/services/issue-dependency-wakeups.js +146 -14
- package/dist/services/issue-dependency-wakeups.js.map +1 -1
- package/dist/services/issue-dependency-wakeups.test.d.ts +2 -0
- package/dist/services/issue-dependency-wakeups.test.d.ts.map +1 -0
- package/dist/services/issue-dependency-wakeups.test.js +183 -0
- package/dist/services/issue-dependency-wakeups.test.js.map +1 -0
- package/dist/services/issue-recovery-actions.d.ts +6 -1
- package/dist/services/issue-recovery-actions.d.ts.map +1 -1
- package/dist/services/issue-recovery-actions.js +123 -44
- package/dist/services/issue-recovery-actions.js.map +1 -1
- package/dist/services/issue-thread-interactions.d.ts +8 -0
- package/dist/services/issue-thread-interactions.d.ts.map +1 -1
- package/dist/services/issue-thread-interactions.js +220 -10
- package/dist/services/issue-thread-interactions.js.map +1 -1
- package/dist/services/issues.d.ts +8 -7
- package/dist/services/issues.d.ts.map +1 -1
- package/dist/services/issues.js +84 -12
- package/dist/services/issues.js.map +1 -1
- package/dist/services/local-service-supervisor.d.ts +61 -1
- package/dist/services/local-service-supervisor.d.ts.map +1 -1
- package/dist/services/local-service-supervisor.js +221 -25
- package/dist/services/local-service-supervisor.js.map +1 -1
- package/dist/services/login-command.d.ts +46 -0
- package/dist/services/login-command.d.ts.map +1 -0
- package/dist/services/login-command.js +87 -0
- package/dist/services/login-command.js.map +1 -0
- package/dist/services/login-command.test.d.ts +2 -0
- package/dist/services/login-command.test.d.ts.map +1 -0
- package/dist/services/login-command.test.js +71 -0
- package/dist/services/login-command.test.js.map +1 -0
- package/dist/services/managed-workspace-identity.d.ts +140 -0
- package/dist/services/managed-workspace-identity.d.ts.map +1 -0
- package/dist/services/managed-workspace-identity.js +277 -0
- package/dist/services/managed-workspace-identity.js.map +1 -0
- package/dist/services/plugin-environment-driver.d.ts +15 -1
- package/dist/services/plugin-environment-driver.d.ts.map +1 -1
- package/dist/services/plugin-environment-driver.js +23 -1
- package/dist/services/plugin-environment-driver.js.map +1 -1
- package/dist/services/plugin-host-services.d.ts +3 -2
- package/dist/services/plugin-host-services.d.ts.map +1 -1
- package/dist/services/plugin-host-services.js +11 -2
- package/dist/services/plugin-host-services.js.map +1 -1
- package/dist/services/plugin-loader.d.ts.map +1 -1
- package/dist/services/plugin-loader.js +39 -8
- package/dist/services/plugin-loader.js.map +1 -1
- package/dist/services/plugin-managed-agents.d.ts.map +1 -1
- package/dist/services/plugin-managed-agents.js +50 -5
- package/dist/services/plugin-managed-agents.js.map +1 -1
- package/dist/services/plugin-manifest-validator.d.ts.map +1 -1
- package/dist/services/plugin-manifest-validator.js +5 -2
- package/dist/services/plugin-manifest-validator.js.map +1 -1
- package/dist/services/plugin-registry.d.ts +5 -5
- package/dist/services/plugin-worker-manager.d.ts +158 -11
- package/dist/services/plugin-worker-manager.d.ts.map +1 -1
- package/dist/services/plugin-worker-manager.js +1139 -79
- package/dist/services/plugin-worker-manager.js.map +1 -1
- package/dist/services/projects.d.ts +1 -1
- package/dist/services/recovery/disposition-repair.d.ts +24 -0
- package/dist/services/recovery/disposition-repair.d.ts.map +1 -0
- package/dist/services/recovery/disposition-repair.js +125 -0
- package/dist/services/recovery/disposition-repair.js.map +1 -0
- package/dist/services/recovery/service.d.ts +6 -6
- package/dist/services/recovery/service.d.ts.map +1 -1
- package/dist/services/recovery/service.js +856 -384
- package/dist/services/recovery/service.js.map +1 -1
- package/dist/services/recovery/stranded-notice.js +4 -4
- package/dist/services/recovery/stranded-notice.js.map +1 -1
- package/dist/services/recovery/stranded-notice.test.js +1 -1
- package/dist/services/recovery/stranded-notice.test.js.map +1 -1
- package/dist/services/recovery/successful-run-handoff.d.ts +1 -1
- package/dist/services/recovery/successful-run-handoff.d.ts.map +1 -1
- package/dist/services/recovery/successful-run-handoff.js +6 -4
- package/dist/services/recovery/successful-run-handoff.js.map +1 -1
- package/dist/services/recovery/successful-run-handoff.test.js +1 -1
- package/dist/services/recovery/successful-run-handoff.test.js.map +1 -1
- package/dist/services/recovery-observability.d.ts +1 -1
- package/dist/services/recovery-observability.d.ts.map +1 -1
- package/dist/services/recovery-observability.js +4 -1
- package/dist/services/recovery-observability.js.map +1 -1
- package/dist/services/routines.d.ts.map +1 -1
- package/dist/services/routines.js +86 -1
- package/dist/services/routines.js.map +1 -1
- package/dist/services/runtime-exposure/loopback-listener.js +1 -1
- package/dist/services/runtime-exposure/loopback-listener.js.map +1 -1
- package/dist/services/runtime-exposure/loopback-listener.test.js +1 -1
- package/dist/services/runtime-exposure/loopback-listener.test.js.map +1 -1
- package/dist/services/scripts/codex-auth-read.cjs +93 -0
- package/dist/services/secret-proposal-authorization.d.ts +15 -0
- package/dist/services/secret-proposal-authorization.d.ts.map +1 -0
- package/dist/services/secret-proposal-authorization.js +29 -0
- package/dist/services/secret-proposal-authorization.js.map +1 -0
- package/dist/services/secret-proposal-notifications.d.ts +18 -0
- package/dist/services/secret-proposal-notifications.d.ts.map +1 -0
- package/dist/services/secret-proposal-notifications.js +37 -0
- package/dist/services/secret-proposal-notifications.js.map +1 -0
- package/dist/services/secret-proposals.d.ts +20 -10
- package/dist/services/secret-proposals.d.ts.map +1 -1
- package/dist/services/secret-proposals.js +205 -14
- package/dist/services/secret-proposals.js.map +1 -1
- package/dist/services/secrets.d.ts +3 -3
- package/dist/services/settings-visibility.d.ts +17 -0
- package/dist/services/settings-visibility.d.ts.map +1 -0
- package/dist/services/settings-visibility.js +29 -0
- package/dist/services/settings-visibility.js.map +1 -0
- package/dist/services/setup-token-reaper.d.ts +36 -0
- package/dist/services/setup-token-reaper.d.ts.map +1 -0
- package/dist/services/setup-token-reaper.js +38 -0
- package/dist/services/setup-token-reaper.js.map +1 -0
- package/dist/services/setup-token-reaper.test.d.ts +2 -0
- package/dist/services/setup-token-reaper.test.d.ts.map +1 -0
- package/dist/services/setup-token-reaper.test.js +123 -0
- package/dist/services/setup-token-reaper.test.js.map +1 -0
- package/dist/services/setup-token-session.d.ts +57 -45
- package/dist/services/setup-token-session.d.ts.map +1 -1
- package/dist/services/setup-token-session.js +118 -110
- package/dist/services/setup-token-session.js.map +1 -1
- package/dist/services/setup-token-session.test.js +88 -47
- package/dist/services/setup-token-session.test.js.map +1 -1
- package/dist/services/setup-token-transport-binding.d.ts +20 -12
- package/dist/services/setup-token-transport-binding.d.ts.map +1 -1
- package/dist/services/setup-token-transport-binding.js +59 -32
- package/dist/services/setup-token-transport-binding.js.map +1 -1
- package/dist/services/setup-token-transport-binding.test.js +50 -17
- package/dist/services/setup-token-transport-binding.test.js.map +1 -1
- package/dist/services/smoke-lab.d.ts +1 -1
- package/dist/services/stalled-review-decisions.d.ts +1 -1
- package/dist/services/status-cards.d.ts +168 -168
- package/dist/services/tool-access-policy.d.ts +2 -2
- package/dist/services/tool-gateway.d.ts +1 -1
- package/dist/services/work-products.d.ts +19 -0
- package/dist/services/work-products.d.ts.map +1 -1
- package/dist/services/work-products.js +50 -3
- package/dist/services/work-products.js.map +1 -1
- package/dist/services/workspace-git-operation-scheduler.test.js +10 -3
- package/dist/services/workspace-git-operation-scheduler.test.js.map +1 -1
- package/dist/services/workspace-instance-cleanup.d.ts +9 -0
- package/dist/services/workspace-instance-cleanup.d.ts.map +1 -1
- package/dist/services/workspace-instance-cleanup.js +29 -3
- package/dist/services/workspace-instance-cleanup.js.map +1 -1
- package/dist/services/workspace-login-handoff-issuer.d.ts +95 -0
- package/dist/services/workspace-login-handoff-issuer.d.ts.map +1 -0
- package/dist/services/workspace-login-handoff-issuer.js +147 -0
- package/dist/services/workspace-login-handoff-issuer.js.map +1 -0
- package/dist/services/workspace-operations.d.ts +4 -1
- package/dist/services/workspace-operations.d.ts.map +1 -1
- package/dist/services/workspace-operations.js +29 -15
- package/dist/services/workspace-operations.js.map +1 -1
- package/dist/services/workspace-readiness.d.ts +56 -0
- package/dist/services/workspace-readiness.d.ts.map +1 -0
- package/dist/services/workspace-readiness.js +250 -0
- package/dist/services/workspace-readiness.js.map +1 -0
- package/dist/services/workspace-realization.d.ts.map +1 -1
- package/dist/services/workspace-realization.js +5 -33
- package/dist/services/workspace-realization.js.map +1 -1
- package/dist/services/workspace-runtime-exposure.test.js +321 -12
- package/dist/services/workspace-runtime-exposure.test.js.map +1 -1
- package/dist/services/workspace-runtime-leases.d.ts.map +1 -1
- package/dist/services/workspace-runtime-leases.js +1 -1
- package/dist/services/workspace-runtime-leases.js.map +1 -1
- package/dist/services/workspace-runtime-read-model.d.ts +8 -8
- package/dist/services/workspace-runtime-read-model.test.js +20 -0
- package/dist/services/workspace-runtime-read-model.test.js.map +1 -1
- package/dist/services/workspace-runtime-ready-comment.test.js +1 -0
- package/dist/services/workspace-runtime-ready-comment.test.js.map +1 -1
- package/dist/services/workspace-runtime.d.ts +77 -4
- package/dist/services/workspace-runtime.d.ts.map +1 -1
- package/dist/services/workspace-runtime.js +1020 -126
- package/dist/services/workspace-runtime.js.map +1 -1
- package/dist/worktree-config.d.ts.map +1 -1
- package/dist/worktree-config.js +3 -76
- package/dist/worktree-config.js.map +1 -1
- package/dist/worktree-seed-manifest.d.ts +8 -0
- package/dist/worktree-seed-manifest.d.ts.map +1 -0
- package/dist/worktree-seed-manifest.js +47 -0
- package/dist/worktree-seed-manifest.js.map +1 -0
- package/package.json +34 -30
- package/skills/paperclip/SKILL.md +2 -0
- package/skills/paperclip/references/api-reference.md +44 -2
- package/ui-dist/assets/CompanyExport-DnEsO-St.js +4 -0
- package/ui-dist/assets/{abnfDiagram-N423BO3Z-BvgoV-u5.js → abnfDiagram-N423BO3Z-2ff8TsQ0.js} +1 -1
- package/ui-dist/assets/{arc-BInnUBUU.js → arc-B2AXsH34.js} +1 -1
- package/ui-dist/assets/architectureDiagram-T3A2C74G-BDxtKMxk.js +36 -0
- package/ui-dist/assets/{blockDiagram-VBNYF7ZC-D9kzAkLN.js → blockDiagram-VBNYF7ZC-BB8T5Mj5.js} +1 -1
- package/ui-dist/assets/{c4Diagram-5PPSVZJV-BwzsgV_0.js → c4Diagram-5PPSVZJV-lMo8qk-x.js} +1 -1
- package/ui-dist/assets/channel-DCTVZpyq.js +1 -0
- package/ui-dist/assets/{chunk-2GRJ4B5K-D2EQ9vrl.js → chunk-2GRJ4B5K-CsS7EB3G.js} +1 -1
- package/ui-dist/assets/{chunk-2Q5K7J3B-DOA_8fSd.js → chunk-2Q5K7J3B-DFyQPjoR.js} +1 -1
- package/ui-dist/assets/{chunk-5RXB4S5H-CT5ukRiT.js → chunk-5RXB4S5H-No2SHOQR.js} +1 -1
- package/ui-dist/assets/{chunk-5VM5RSS4-D2gOlkyB.js → chunk-5VM5RSS4-DdxQO7V9.js} +1 -1
- package/ui-dist/assets/{chunk-6Q2QTUOP-DUb6aG8c.js → chunk-6Q2QTUOP-gbHjh-aK.js} +1 -1
- package/ui-dist/assets/{chunk-GF5L2VYU-BPabG3Cx.js → chunk-GF5L2VYU-h-BBl6NY.js} +1 -1
- package/ui-dist/assets/{chunk-JWPE2WC7-scNfhlZz.js → chunk-JWPE2WC7-Q3pJdTz5.js} +1 -1
- package/ui-dist/assets/{chunk-KBJHAD2P-BcRAzRpq.js → chunk-KBJHAD2P-Ba8LGLSx.js} +1 -1
- package/ui-dist/assets/{chunk-RYQCIY6F-BiZ0-EGh.js → chunk-RYQCIY6F-9VcvykBo.js} +1 -1
- package/ui-dist/assets/{chunk-XXDRQBXY-lJULxFIE.js → chunk-XXDRQBXY-NVbjDH4W.js} +1 -1
- package/ui-dist/assets/classDiagram-JCYQIIEL-CzJ88u6h.js +1 -0
- package/ui-dist/assets/classDiagram-v2-OCEON4UE-CzJ88u6h.js +1 -0
- package/ui-dist/assets/cose-bilkent-JH36ORCC-Cdz4wtUh.js +1 -0
- package/ui-dist/assets/{cynefin-VYW2F7L2-B34jJY1x.js → cynefin-VYW2F7L2-MpzJkW5s.js} +1 -1
- package/ui-dist/assets/{cynefinDiagram-MW4NZA55-DQ8QK8JK.js → cynefinDiagram-MW4NZA55-_Nx8av7i.js} +1 -1
- package/ui-dist/assets/{dagre-VZM6K2ZE-B7lSKgxH.js → dagre-VZM6K2ZE-DcZTQ4Oh.js} +1 -1
- package/ui-dist/assets/{diagram-7IWD3JNH-B3hLAFGE.js → diagram-7IWD3JNH-UyLi2mee.js} +1 -1
- package/ui-dist/assets/{diagram-B4RE2ZJO-CH9Ro9e0.js → diagram-B4RE2ZJO-B6p0Ivqg.js} +1 -1
- package/ui-dist/assets/{diagram-LBJQPF4R-DdPVMii7.js → diagram-LBJQPF4R-C8u6pa97.js} +1 -1
- package/ui-dist/assets/{diagram-Q27KOJAE-f5VXswjc.js → diagram-Q27KOJAE-DbwyagW0.js} +1 -1
- package/ui-dist/assets/{diagram-UB23O5K3-DGqxixsR.js → diagram-UB23O5K3-Cn5BMEH7.js} +1 -1
- package/ui-dist/assets/{ebnfDiagram-BXEA7PRR-C-1klg_w.js → ebnfDiagram-BXEA7PRR-vE0IZma9.js} +1 -1
- package/ui-dist/assets/{erDiagram-JOGREHBK-Dt4qK4gv.js → erDiagram-JOGREHBK-3OZVIlVG.js} +1 -1
- package/ui-dist/assets/{flowDiagram-UKHOOZJN-9ZlrTuET.js → flowDiagram-UKHOOZJN-C94FiPyr.js} +1 -1
- package/ui-dist/assets/ganttDiagram-PKOTCBZU-DMVaPmF-.js +292 -0
- package/ui-dist/assets/{gitGraphDiagram-DS77QQ5N-HH2fSgad.js → gitGraphDiagram-DS77QQ5N-yeFtJ689.js} +1 -1
- package/ui-dist/assets/{index-Cm6iEYPt.js → index-B4ZF3vY8.js} +288 -224
- package/ui-dist/assets/{index-CHXG4GPL.js → index-Bi_vqkcQ.js} +1 -1
- package/ui-dist/assets/{index-Bc9g6q63.js → index-BjDGQg9-.js} +1 -1
- package/ui-dist/assets/{index-FHzrJ7iQ.js → index-BnpyUMTb.js} +1 -1
- package/ui-dist/assets/{index-C1mpWCcn.js → index-BoqQTVMA.js} +1 -1
- package/ui-dist/assets/index-Bq-Mr-4x.js +1 -0
- package/ui-dist/assets/{index-DceuCrr8.js → index-Brb2U7N4.js} +1 -1
- package/ui-dist/assets/{index-DQIrrn2L.js → index-BsBybVwH.js} +1 -1
- package/ui-dist/assets/{index-CQhaM6qd.js → index-C2iv4Wgu.js} +1 -1
- package/ui-dist/assets/{index-DV2FOgyD.js → index-CKxIu7uO.js} +1 -1
- package/ui-dist/assets/{index-DU4U1fuv.js → index-Cb4VGIwp.js} +1 -1
- package/ui-dist/assets/{index-B61ORkIb.js → index-ChpszwcO.js} +1 -1
- package/ui-dist/assets/{index-DlVBBF8o.js → index-CoQFdT8N.js} +1 -1
- package/ui-dist/assets/index-D6P8_f0W.css +1 -0
- package/ui-dist/assets/index-DKdmghUG.js +1 -0
- package/ui-dist/assets/{index-CVTL3aAL.js → index-DaBci7Bx.js} +1 -1
- package/ui-dist/assets/{index-diFs0CNC.js → index-DjYaNiXZ.js} +1 -1
- package/ui-dist/assets/{index-Bf7tyGxE.js → index-DxTBJl4h.js} +1 -1
- package/ui-dist/assets/{index-DsdVneqe.js → index-Dz3E8nn0.js} +1 -1
- package/ui-dist/assets/{index-DvFUXI-Q.js → index-J7dz_hap.js} +1 -1
- package/ui-dist/assets/{index-CLu3Tfpv.js → index-JMp2Gpnz.js} +1 -1
- package/ui-dist/assets/{index-CcRTJ93J.js → index-Kp6wavQt.js} +1 -1
- package/ui-dist/assets/{index-BvFx-4KW.js → index-LBhYZ0L4.js} +1 -1
- package/ui-dist/assets/{index-BbAhzCq8.js → index-c3C0P6l8.js} +4 -4
- package/ui-dist/assets/{index-BY5ITKmG.js → index-pBEPheTW.js} +1 -1
- package/ui-dist/assets/{infoDiagram-6WML65LV-2jai2fw-.js → infoDiagram-6WML65LV-BiL7jKLA.js} +1 -1
- package/ui-dist/assets/{ishikawaDiagram-WSZJBQD7-CUmGhmEb.js → ishikawaDiagram-WSZJBQD7-DYcQy41D.js} +1 -1
- package/ui-dist/assets/{journeyDiagram-NVQOT4AX-CGQuPDO1.js → journeyDiagram-NVQOT4AX-Bxva6JgA.js} +1 -1
- package/ui-dist/assets/{kanban-definition-27J2QSJJ-zgEPcbHl.js → kanban-definition-27J2QSJJ-B2HpN34Q.js} +1 -1
- package/ui-dist/assets/{linear-BVf5VtLS.js → linear-Dq8qSESw.js} +1 -1
- package/ui-dist/assets/{mermaid.core-D2TFkGfI.js → mermaid.core-Ikay8Cpl.js} +35 -35
- package/ui-dist/assets/{mindmap-definition-FAOFIHXS-xoAQ0Lb_.js → mindmap-definition-FAOFIHXS-C-SIdYSY.js} +1 -1
- package/ui-dist/assets/{pegDiagram-VL7TDLO6-CBR1PWLr.js → pegDiagram-VL7TDLO6-C7rIJ_13.js} +1 -1
- package/ui-dist/assets/{pieDiagram-7S7Q4E2Y-BMPnzK_e.js → pieDiagram-7S7Q4E2Y-3gx0a0ve.js} +1 -1
- package/ui-dist/assets/{quadrantDiagram-CIZ2JOQS-CPtVUPkR.js → quadrantDiagram-CIZ2JOQS-BiwM9QGv.js} +1 -1
- package/ui-dist/assets/{railroadDiagram-AXF67PYL-DC0lsZV1.js → railroadDiagram-AXF67PYL-rFS8XcFA.js} +1 -1
- package/ui-dist/assets/{requirementDiagram-LRYGKXZP-BF6k05SB.js → requirementDiagram-LRYGKXZP-CEAUDUTQ.js} +1 -1
- package/ui-dist/assets/{sankeyDiagram-W5VNT64P-CRN0ibiP.js → sankeyDiagram-W5VNT64P-B7anpjlM.js} +1 -1
- package/ui-dist/assets/{sequenceDiagram-SI44F4Z6-CQtTiPWm.js → sequenceDiagram-SI44F4Z6-CJEdJvm_.js} +1 -1
- package/ui-dist/assets/{sizeCapture-X5ZJPWSS-BdjuGZd1.js → sizeCapture-X5ZJPWSS-CU1Vm6c1.js} +1 -1
- package/ui-dist/assets/{stateDiagram-OKZ733FA-B99onxyh.js → stateDiagram-OKZ733FA-wxVV9WIi.js} +1 -1
- package/ui-dist/assets/stateDiagram-v2-UEYNNEHI-BIFLqlqR.js +1 -0
- package/ui-dist/assets/{swimlanes-SLNWSIFB-BIByPbrL.js → swimlanes-SLNWSIFB-Cd-C1Z_k.js} +2 -2
- package/ui-dist/assets/swimlanesDiagram-ULZ7WXOC-CoBROkkd.js +8 -0
- package/ui-dist/assets/{timeline-definition-Z64GVDOM-I1NgOcn9.js → timeline-definition-Z64GVDOM-SWngk4aR.js} +1 -1
- package/ui-dist/assets/{vennDiagram-T6HMQDX7-D6t2Njcl.js → vennDiagram-T6HMQDX7-DFdPUr36.js} +1 -1
- package/ui-dist/assets/{wardleyDiagram-T6FBY63Y-BiYKm9gh.js → wardleyDiagram-T6FBY63Y-C6IlrZ7t.js} +1 -1
- package/ui-dist/assets/{xychartDiagram-ELKLHX3M-BhwXKWIa.js → xychartDiagram-ELKLHX3M-BNm6hHGl.js} +1 -1
- package/ui-dist/index.html +2 -2
- package/ui-dist/assets/architectureDiagram-T3A2C74G-Dg4MziOm.js +0 -36
- package/ui-dist/assets/channel-qoSYbCn0.js +0 -1
- package/ui-dist/assets/classDiagram-JCYQIIEL-B24u1REs.js +0 -1
- package/ui-dist/assets/classDiagram-v2-OCEON4UE-B24u1REs.js +0 -1
- package/ui-dist/assets/cose-bilkent-JH36ORCC-Do8pe8JB.js +0 -1
- package/ui-dist/assets/ganttDiagram-PKOTCBZU-CE3lyvFq.js +0 -292
- package/ui-dist/assets/index-BHiUiToL.css +0 -1
- package/ui-dist/assets/index-hkanj4t6.js +0 -1
- package/ui-dist/assets/index-yRJnh4vx.js +0 -1
- package/ui-dist/assets/stateDiagram-v2-UEYNNEHI-bzFlYBZ1.js +0 -1
- package/ui-dist/assets/swimlanesDiagram-ULZ7WXOC-DgdkBnuK.js +0 -8
|
@@ -21,11 +21,26 @@ import { fork } from "node:child_process";
|
|
|
21
21
|
import { randomUUID } from "node:crypto";
|
|
22
22
|
import { EventEmitter } from "node:events";
|
|
23
23
|
import { createInterface } from "node:readline";
|
|
24
|
-
import { JSONRPC_VERSION, JSONRPC_ERROR_CODES, PLUGIN_RPC_ERROR_CODES, createRequest, createErrorResponse, parseMessage, serializeMessage, isJsonRpcResponse, isJsonRpcRequest, isJsonRpcNotification, isJsonRpcSuccessResponse, JsonRpcParseError, JsonRpcCallError,
|
|
24
|
+
import { JSONRPC_VERSION, JSONRPC_ERROR_CODES, PLUGIN_RPC_ERROR_CODES, createRequest, createErrorResponse, parseMessage, serializeMessage, isJsonRpcResponse, isJsonRpcRequest, isJsonRpcNotification, isJsonRpcSuccessResponse, JsonRpcParseError, JsonRpcCallError, LOGIN_PTY_OUTPUT_NOTIFICATION, LOGIN_PTY_EXIT_NOTIFICATION, DUPLEX_CHANNEL_DATA_NOTIFICATION, DUPLEX_CHANNEL_EXIT_NOTIFICATION, encodeChannelBytes, decodeChannelBytes, } from "@paperclipai/plugin-sdk";
|
|
25
25
|
import { getActiveStepContext } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
|
|
26
|
-
import {
|
|
26
|
+
import { DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED, } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
|
|
27
|
+
import { isLoginCommandKey, validateLoginSessionHome, } from "./login-command.js";
|
|
27
28
|
import { logger } from "../middleware/logger.js";
|
|
28
29
|
import { traceparentFromContextToken } from "../instrumentation.js";
|
|
30
|
+
/**
|
|
31
|
+
* The host raises this error when the child-stdin transport reservation for a
|
|
32
|
+
* duplex write fails against the aggregate byte ledger. The host does not write
|
|
33
|
+
* the frame. The write path throws it, `callInternal` rejects the RPC with it
|
|
34
|
+
* unwrapped, and the duplex write caller ends the route fail-closed with the
|
|
35
|
+
* {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED} marker. Only a duplex write
|
|
36
|
+
* meters the transport, so this error never reaches a non-duplex control message.
|
|
37
|
+
*/
|
|
38
|
+
class DuplexAggregateBytesExceededError extends Error {
|
|
39
|
+
constructor() {
|
|
40
|
+
super(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
|
|
41
|
+
this.name = "DuplexAggregateBytesExceededError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
29
44
|
// ---------------------------------------------------------------------------
|
|
30
45
|
// Constants
|
|
31
46
|
// ---------------------------------------------------------------------------
|
|
@@ -88,24 +103,119 @@ const MAX_WORKER_MESSAGE_CHARS = 128 * 1024 * 1024;
|
|
|
88
103
|
*/
|
|
89
104
|
const MAX_EXECUTE_LOG_TOTAL_CHARS = 128 * 1024 * 1024;
|
|
90
105
|
/** Maximum characters for one live login pseudo-terminal output notification. */
|
|
91
|
-
const
|
|
106
|
+
const MAX_LOGIN_PTY_CHUNK_CHARS = 1_000_000;
|
|
92
107
|
/** Maximum cumulative output characters for one login pseudo-terminal route. */
|
|
93
|
-
const
|
|
108
|
+
const MAX_LOGIN_PTY_TOTAL_CHARS = 8 * 1024 * 1024;
|
|
94
109
|
/** The default open timeout for one login pseudo-terminal route, in milliseconds. */
|
|
95
|
-
const
|
|
110
|
+
const LOGIN_PTY_OPEN_TIMEOUT_MS = 30_000;
|
|
96
111
|
/** The default close timeout for one login pseudo-terminal route, in milliseconds. */
|
|
97
|
-
const
|
|
112
|
+
const LOGIN_PTY_CLOSE_TIMEOUT_MS = 10_000;
|
|
98
113
|
/**
|
|
99
|
-
* The fixed non-secret error a disallowed login command returns. The manager
|
|
100
|
-
* forwards only
|
|
101
|
-
*
|
|
102
|
-
*
|
|
114
|
+
* The fixed non-secret error a disallowed login command key returns. The manager
|
|
115
|
+
* forwards only a closed login command key to the worker pseudo-terminal. It
|
|
116
|
+
* rejects a key outside the closed set before the worker call, so a caller cannot
|
|
117
|
+
* select an arbitrary command in the sandbox.
|
|
103
118
|
*/
|
|
104
|
-
const
|
|
119
|
+
const LOGIN_PTY_COMMAND_NOT_ALLOWED = "LOGIN_PTY_COMMAND_NOT_ALLOWED";
|
|
105
120
|
/** The fixed non-secret error a rejected second credential open returns. */
|
|
106
|
-
const
|
|
121
|
+
const LOGIN_PTY_ROUTE_BUSY = "LOGIN_PTY_ROUTE_BUSY";
|
|
107
122
|
/** The fixed non-secret error a failed open returns. */
|
|
108
|
-
const
|
|
123
|
+
const LOGIN_PTY_OPEN_FAILED = "LOGIN_PTY_OPEN_FAILED";
|
|
124
|
+
// Bounds and timeouts for the generic duplex channel route. The route mirrors the
|
|
125
|
+
// login pseudo-terminal route, but it carries no command allowlist and adds seven
|
|
126
|
+
// explicit bounds the pseudo-terminal route lacks. Each bound ends the route when
|
|
127
|
+
// it passes the limit, so a faulty or hostile worker cannot flood the host.
|
|
128
|
+
/** The default maximum characters for one duplex channel data notification. */
|
|
129
|
+
const MAX_DUPLEX_CHANNEL_CHUNK_CHARS = 1_000_000;
|
|
130
|
+
/**
|
|
131
|
+
* The default maximum cumulative characters the host buffers for one duplex
|
|
132
|
+
* channel route before a data listener attaches. A worker that streams data
|
|
133
|
+
* before the consumer binds cannot grow the host buffer without limit.
|
|
134
|
+
*/
|
|
135
|
+
const MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS = 8 * 1024 * 1024;
|
|
136
|
+
/**
|
|
137
|
+
* The default maximum number of data frames the host buffers for one duplex
|
|
138
|
+
* channel route before a data listener attaches.
|
|
139
|
+
*/
|
|
140
|
+
const MAX_DUPLEX_CHANNEL_PRE_BIND_FRAMES = 10_000;
|
|
141
|
+
/**
|
|
142
|
+
* The margin the pre-bind hold ceiling keeps above the pre-bind buffered-frame
|
|
143
|
+
* bound (`maxDuplexChannelPreBindFrames`). A worker can batch data and exit
|
|
144
|
+
* frames with the open reply, so the host reads them before the route binds and
|
|
145
|
+
* before it can apply the per-frame bounds. The host holds these frames and
|
|
146
|
+
* replays them after the bind. The hold ceiling bounds that hold, so a worker
|
|
147
|
+
* that floods frames before the open reply cannot make the host hold an
|
|
148
|
+
* unbounded number of frames.
|
|
149
|
+
*
|
|
150
|
+
* The hold ceiling derives from the buffered bound, not a fixed constant. A
|
|
151
|
+
* fixed ceiling at or below a caller-configured buffered bound would drop the
|
|
152
|
+
* frame that must instead trip the buffered bound during replay, so the route
|
|
153
|
+
* would never end. One frame of margin is enough. It lets the frame that
|
|
154
|
+
* exceeds the buffered bound reach the hold, so the replay's buffered-bound
|
|
155
|
+
* check, not the hold, ends the route.
|
|
156
|
+
*/
|
|
157
|
+
const DUPLEX_CHANNEL_PRE_BIND_HOLD_MARGIN_FRAMES = 1;
|
|
158
|
+
/**
|
|
159
|
+
* The default maximum number of in-flight host→worker requests for one duplex
|
|
160
|
+
* channel route. A worker that never replies cannot make the host hold an
|
|
161
|
+
* unbounded number of pending requests.
|
|
162
|
+
*/
|
|
163
|
+
const MAX_DUPLEX_CHANNEL_PENDING_REQUESTS = 256;
|
|
164
|
+
/** The default maximum characters for one host→worker duplex channel write. */
|
|
165
|
+
const MAX_DUPLEX_CHANNEL_WRITE_CHARS = 1_000_000;
|
|
166
|
+
/**
|
|
167
|
+
* The default maximum number of protocol errors for one duplex channel route.
|
|
168
|
+
* A protocol error is one malformed or mismatched data frame. The route ends
|
|
169
|
+
* when the count passes this budget, so a flood of bad frames bounds the route.
|
|
170
|
+
*/
|
|
171
|
+
const MAX_DUPLEX_CHANNEL_PROTOCOL_ERRORS = 100;
|
|
172
|
+
/**
|
|
173
|
+
* The default maximum cumulative bytes the host forwards for one duplex channel
|
|
174
|
+
* route over its whole life. The host counts the bytes of every inbound chunk,
|
|
175
|
+
* before and after a data listener attaches. The route ends when the count
|
|
176
|
+
* passes this cap, so an active route with a bound listener cannot stream an
|
|
177
|
+
* unbounded number of bytes.
|
|
178
|
+
*/
|
|
179
|
+
const MAX_DUPLEX_CHANNEL_TOTAL_DATA_BYTES = 256 * 1024 * 1024;
|
|
180
|
+
/**
|
|
181
|
+
* The default maximum lifetime for one duplex channel route, in milliseconds.
|
|
182
|
+
* The host starts a timer when the route opens and ends the route when the
|
|
183
|
+
* timer expires, so a route cannot live without limit.
|
|
184
|
+
*/
|
|
185
|
+
const MAX_DUPLEX_CHANNEL_DURATION_MS = 60 * 60 * 1000;
|
|
186
|
+
/** The default open timeout for one duplex channel route, in milliseconds. */
|
|
187
|
+
const DUPLEX_CHANNEL_OPEN_TIMEOUT_MS = 30_000;
|
|
188
|
+
/** The default close timeout for one duplex channel route, in milliseconds. */
|
|
189
|
+
const DUPLEX_CHANNEL_CLOSE_TIMEOUT_MS = 10_000;
|
|
190
|
+
/** The fixed non-secret error a rejected second duplex channel open returns. */
|
|
191
|
+
const DUPLEX_CHANNEL_ROUTE_BUSY = "DUPLEX_CHANNEL_ROUTE_BUSY";
|
|
192
|
+
/** The fixed non-secret error a failed duplex channel open returns. */
|
|
193
|
+
const DUPLEX_CHANNEL_OPEN_FAILED = "DUPLEX_CHANNEL_OPEN_FAILED";
|
|
194
|
+
// The process-scoped monotonic route-generation source. The host mints one
|
|
195
|
+
// strictly increasing, non-reusable `hostRouteId` on each duplex channel open,
|
|
196
|
+
// across every worker in the process. A retired generation never returns, so a
|
|
197
|
+
// late frame for a closed pair never collides with a new open. The host owns the
|
|
198
|
+
// value; no worker field sets it.
|
|
199
|
+
let duplexHostRouteIdSequence = 0;
|
|
200
|
+
/** Mint the next monotonic, non-reusable host route identifier for a duplex channel open. */
|
|
201
|
+
function nextDuplexHostRouteId() {
|
|
202
|
+
duplexHostRouteIdSequence += 1;
|
|
203
|
+
return `duplex-route-${duplexHostRouteIdSequence}`;
|
|
204
|
+
}
|
|
205
|
+
// The separator between the two identifiers of a duplex route pair key. It is a
|
|
206
|
+
// null byte, which appears in neither a host route id nor a worker session id, so
|
|
207
|
+
// two distinct pairs never collapse to one key.
|
|
208
|
+
const DUPLEX_PAIR_KEY_SEPARATOR = "\u0000";
|
|
209
|
+
/** Build the exact-pair routing key from the host route id and the worker session id. */
|
|
210
|
+
function duplexPairKey(hostRouteId, workerSessionId) {
|
|
211
|
+
return `${hostRouteId}${DUPLEX_PAIR_KEY_SEPARATOR}${workerSessionId}`;
|
|
212
|
+
}
|
|
213
|
+
// The maximum number of tombstoned duplex pairs one worker retains. The worker
|
|
214
|
+
// keeps every tombstone until it retires, so a closed pair never returns. Before
|
|
215
|
+
// the set would exceed this bound, the host retires the worker, which drops every
|
|
216
|
+
// route and every tombstone at once. A tombstone overflow fails closed; the host
|
|
217
|
+
// never evicts a tombstone and lets a pair return.
|
|
218
|
+
const MAX_DUPLEX_ROUTE_TOMBSTONES = 4096;
|
|
109
219
|
/** Minimum time between two dropped-`execute.log` debug records. The router
|
|
110
220
|
* rate-limits the record so a flood of dropped chunks writes at most one line
|
|
111
221
|
* per window with a running count. */
|
|
@@ -190,10 +300,34 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
190
300
|
const maxExecuteLogTotalChars = options.executeLogLimits?.maxTotalCharsPerExecute ?? MAX_EXECUTE_LOG_TOTAL_CHARS;
|
|
191
301
|
// Bounds and timeouts for the login pseudo-terminal route. A caller
|
|
192
302
|
// (a test) can lower them to exercise the terminalize paths.
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
const
|
|
303
|
+
const maxLoginPtyChunkChars = options.loginPtyLimits?.maxChunkChars ?? MAX_LOGIN_PTY_CHUNK_CHARS;
|
|
304
|
+
const maxLoginPtyTotalChars = options.loginPtyLimits?.maxTotalChars ?? MAX_LOGIN_PTY_TOTAL_CHARS;
|
|
305
|
+
const loginPtyOpenTimeoutMs = options.loginPtyLimits?.openTimeoutMs ?? LOGIN_PTY_OPEN_TIMEOUT_MS;
|
|
306
|
+
const loginPtyCloseTimeoutMs = options.loginPtyLimits?.closeTimeoutMs ?? LOGIN_PTY_CLOSE_TIMEOUT_MS;
|
|
307
|
+
// Bounds and timeouts for the generic duplex channel route. A caller (a test)
|
|
308
|
+
// can lower them to exercise each bound and the terminalize paths. Each
|
|
309
|
+
// "Chars" name is a historical holdover: the channel now carries `Uint8Array`
|
|
310
|
+
// chunks, so every one of these bounds counts raw bytes (`.length` on a
|
|
311
|
+
// `Uint8Array` is its byte count), not UTF-16 code units.
|
|
312
|
+
const maxDuplexChannelChunkChars = options.duplexChannelLimits?.maxChunkChars ?? MAX_DUPLEX_CHANNEL_CHUNK_CHARS;
|
|
313
|
+
const maxDuplexChannelPreBindChars = options.duplexChannelLimits?.maxPreBindBufferedChars ??
|
|
314
|
+
MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS;
|
|
315
|
+
const maxDuplexChannelPreBindFrames = options.duplexChannelLimits?.maxPreBindBufferedFrames ??
|
|
316
|
+
MAX_DUPLEX_CHANNEL_PRE_BIND_FRAMES;
|
|
317
|
+
// The pre-bind hold ceiling stays one frame above the buffered bound, so the
|
|
318
|
+
// replay, not the hold, ends the route on the buffered bound. See
|
|
319
|
+
// DUPLEX_CHANNEL_PRE_BIND_HOLD_MARGIN_FRAMES for why the margin must hold.
|
|
320
|
+
const maxDuplexChannelPreBindHoldFrames = maxDuplexChannelPreBindFrames + DUPLEX_CHANNEL_PRE_BIND_HOLD_MARGIN_FRAMES;
|
|
321
|
+
const maxDuplexChannelPendingRequests = options.duplexChannelLimits?.maxPendingRequests ??
|
|
322
|
+
MAX_DUPLEX_CHANNEL_PENDING_REQUESTS;
|
|
323
|
+
const maxDuplexChannelWriteChars = options.duplexChannelLimits?.maxWriteChars ?? MAX_DUPLEX_CHANNEL_WRITE_CHARS;
|
|
324
|
+
const maxDuplexChannelProtocolErrors = options.duplexChannelLimits?.maxProtocolErrors ??
|
|
325
|
+
MAX_DUPLEX_CHANNEL_PROTOCOL_ERRORS;
|
|
326
|
+
const maxDuplexChannelTotalDataBytes = options.duplexChannelLimits?.maxTotalDataBytes ??
|
|
327
|
+
MAX_DUPLEX_CHANNEL_TOTAL_DATA_BYTES;
|
|
328
|
+
const maxDuplexChannelDurationMs = options.duplexChannelLimits?.maxDurationMs ?? MAX_DUPLEX_CHANNEL_DURATION_MS;
|
|
329
|
+
const duplexChannelOpenTimeoutMs = options.duplexChannelLimits?.openTimeoutMs ?? DUPLEX_CHANNEL_OPEN_TIMEOUT_MS;
|
|
330
|
+
const duplexChannelCloseTimeoutMs = options.duplexChannelLimits?.closeTimeoutMs ?? DUPLEX_CHANNEL_CLOSE_TIMEOUT_MS;
|
|
197
331
|
// ------------------------------------------------------------------
|
|
198
332
|
// Proactive company scopes (LOOA-629)
|
|
199
333
|
// ------------------------------------------------------------------
|
|
@@ -250,11 +384,42 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
250
384
|
// -----------------------------------------------------------------------
|
|
251
385
|
// JSON-RPC message sending
|
|
252
386
|
// -----------------------------------------------------------------------
|
|
253
|
-
function sendMessage(message) {
|
|
387
|
+
function sendMessage(message, meterDuplexWrite = false) {
|
|
254
388
|
if (!childProcess?.stdin?.writable) {
|
|
255
389
|
throw new Error(`Worker process for plugin "${pluginId}" is not writable`);
|
|
256
390
|
}
|
|
257
391
|
const serialized = serializeMessage(message);
|
|
392
|
+
const ledger = duplexAggregateByteLedger;
|
|
393
|
+
if (meterDuplexWrite && ledger) {
|
|
394
|
+
// Charge the child-stdin transport buffer for a duplex write. The host writes
|
|
395
|
+
// the serialized frame to the child stdin without backpressure. When the child
|
|
396
|
+
// stops reading its stdin, the frame stays in the host stdin write buffer.
|
|
397
|
+
// Reserve the exact serialized-frame byte count. That count includes the JSON
|
|
398
|
+
// escaping and the newline framing, so the ledger covers the retained transport
|
|
399
|
+
// bytes, not only the raw payload. The RPC separately holds the raw payload
|
|
400
|
+
// under a `pending_write` token, so the two tokens cover the peak of both
|
|
401
|
+
// retentions at the same time.
|
|
402
|
+
const bytes = Buffer.byteLength(serialized);
|
|
403
|
+
const token = ledger.reserve("stdin_write", bytes);
|
|
404
|
+
if (!token) {
|
|
405
|
+
// The reservation would pass the aggregate ceiling. Fail closed before the
|
|
406
|
+
// enqueue: do not write the frame. The duplex write caller ends the route.
|
|
407
|
+
throw new DuplexAggregateBytesExceededError();
|
|
408
|
+
}
|
|
409
|
+
// Hold the token until the stream flushes the chunk. The write callback fires
|
|
410
|
+
// when the stream hands the chunk to the operating system, so the bytes then
|
|
411
|
+
// leave the host stream buffer. The RPC settle and the RPC timeout never
|
|
412
|
+
// release this token. Only the flush, the stream error, the stream close, or
|
|
413
|
+
// the worker exit releases it. Release through the outstanding-token set, so a
|
|
414
|
+
// later stream-error or worker-exit sweep never double-releases the same token.
|
|
415
|
+
pendingStdinWriteTokens.add(token);
|
|
416
|
+
childProcess.stdin.write(serialized, () => {
|
|
417
|
+
if (pendingStdinWriteTokens.delete(token)) {
|
|
418
|
+
ledger.release(token);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
258
423
|
childProcess.stdin.write(serialized);
|
|
259
424
|
}
|
|
260
425
|
function errorCodeForWorkerHostError(err) {
|
|
@@ -509,22 +674,37 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
509
674
|
log.error({ err: err instanceof Error ? err.message : String(err) }, "execute.log delivery threw");
|
|
510
675
|
}
|
|
511
676
|
}
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
677
|
+
// Shared route-binding helpers. The login pseudo-terminal route and the duplex
|
|
678
|
+
// channel route both use them, so the two routes bind and settle one way.
|
|
679
|
+
// Settle the route wait exactly once. Replace the settler with a no-op, so a
|
|
680
|
+
// later exit or terminalize never settles the wait a second time.
|
|
681
|
+
function settleRouteWait(route, value) {
|
|
516
682
|
const settle = route.settleWait;
|
|
517
683
|
route.settleWait = () => { };
|
|
518
684
|
settle(value);
|
|
519
685
|
}
|
|
686
|
+
// Read the worker session identifier from an open reply, but only when the
|
|
687
|
+
// route can still bind. Return null for a malformed reply, or for a route that
|
|
688
|
+
// already left `opening` or terminalized. A late or a duplicate reply never
|
|
689
|
+
// binds, revives, or reopens a route.
|
|
690
|
+
function readBindableWorkerSessionId(route, openResult) {
|
|
691
|
+
const workerSessionId = readNonEmptyString(isRecord(openResult) ? openResult.workerSessionId : null);
|
|
692
|
+
if (!workerSessionId || route.state !== "opening" || route.terminalized) {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
return workerSessionId;
|
|
696
|
+
}
|
|
697
|
+
// At most one active credential pseudo-terminal per worker. A non-null route
|
|
698
|
+
// blocks a second open until the manager confirms the first route's close.
|
|
699
|
+
let loginPtyRoute = null;
|
|
520
700
|
// Close the worker terminal by the host route identifier and verify the bound
|
|
521
701
|
// acknowledgement. Return true only when the worker returns an acknowledgement
|
|
522
702
|
// that carries the exact host route identifier. An absent, malformed,
|
|
523
703
|
// mismatched, or timed-out acknowledgement returns false, so the caller fails
|
|
524
704
|
// closed.
|
|
525
|
-
async function
|
|
705
|
+
async function closeLoginPtyTerminal(hostRouteId) {
|
|
526
706
|
try {
|
|
527
|
-
const ack = await callInternal("
|
|
707
|
+
const ack = await callInternal("loginPtyClose", { hostRouteId }, loginPtyCloseTimeoutMs);
|
|
528
708
|
return isRecord(ack) && readNonEmptyString(ack.hostRouteId) === hostRouteId;
|
|
529
709
|
}
|
|
530
710
|
catch {
|
|
@@ -534,7 +714,7 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
534
714
|
// Terminalize the route exactly once. Resolve the login wait, close the worker
|
|
535
715
|
// terminal by the host route identifier, and free the per-worker slot only
|
|
536
716
|
// after the close resolves. Retire the worker when the close is unconfirmed.
|
|
537
|
-
async function
|
|
717
|
+
async function terminalizeLoginPtyRoute(route) {
|
|
538
718
|
if (route.terminalized)
|
|
539
719
|
return;
|
|
540
720
|
route.terminalized = true;
|
|
@@ -543,14 +723,14 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
543
723
|
route.buffered = [];
|
|
544
724
|
// A terminalized route reports a null exit code, which the runner treats as a
|
|
545
725
|
// failure.
|
|
546
|
-
|
|
547
|
-
const confirmed = await
|
|
548
|
-
if (
|
|
549
|
-
|
|
726
|
+
settleRouteWait(route, { exitCode: null });
|
|
727
|
+
const confirmed = await closeLoginPtyTerminal(route.hostRouteId);
|
|
728
|
+
if (loginPtyRoute === route)
|
|
729
|
+
loginPtyRoute = null;
|
|
550
730
|
if (!confirmed) {
|
|
551
731
|
// The worker did not acknowledge the close, so the host cannot prove the
|
|
552
732
|
// terminal is gone. Fail closed: retire the worker before any reuse.
|
|
553
|
-
log.error({ pluginId }, "
|
|
733
|
+
log.error({ pluginId }, "login pseudo-terminal close not acknowledged; retiring worker");
|
|
554
734
|
void killProcess();
|
|
555
735
|
}
|
|
556
736
|
}
|
|
@@ -558,8 +738,8 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
558
738
|
// listener. Deliver only while the route is `open` and the notification carries
|
|
559
739
|
// the exact bound worker session identifier and valid bounded bytes. Drop an
|
|
560
740
|
// unknown, late, malformed, or mismatched notification. Never log the raw bytes.
|
|
561
|
-
function
|
|
562
|
-
const route =
|
|
741
|
+
function routeLoginPtyOutput(notification) {
|
|
742
|
+
const route = loginPtyRoute;
|
|
563
743
|
if (!route || route.state !== "open")
|
|
564
744
|
return;
|
|
565
745
|
const params = isRecord(notification.params) ? notification.params : {};
|
|
@@ -569,12 +749,12 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
569
749
|
const chunk = params.chunk;
|
|
570
750
|
if (typeof chunk !== "string" ||
|
|
571
751
|
chunk.length === 0 ||
|
|
572
|
-
chunk.length >
|
|
752
|
+
chunk.length > maxLoginPtyChunkChars) {
|
|
573
753
|
return;
|
|
574
754
|
}
|
|
575
|
-
if (route.deliveredChars + chunk.length >
|
|
755
|
+
if (route.deliveredChars + chunk.length > maxLoginPtyTotalChars) {
|
|
576
756
|
// The cumulative output passed the per-route bound. Terminalize the route.
|
|
577
|
-
void
|
|
757
|
+
void terminalizeLoginPtyRoute(route);
|
|
578
758
|
return;
|
|
579
759
|
}
|
|
580
760
|
route.deliveredChars += chunk.length;
|
|
@@ -586,8 +766,8 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
586
766
|
// Route one login pseudo-terminal exit notification to the login wait. Resolve
|
|
587
767
|
// only while the route is `open` and the notification carries the exact bound
|
|
588
768
|
// worker session identifier.
|
|
589
|
-
function
|
|
590
|
-
const route =
|
|
769
|
+
function routeLoginPtyExit(notification) {
|
|
770
|
+
const route = loginPtyRoute;
|
|
591
771
|
if (!route || route.state !== "open")
|
|
592
772
|
return;
|
|
593
773
|
const params = isRecord(notification.params) ? notification.params : {};
|
|
@@ -595,38 +775,42 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
595
775
|
if (!workerSessionId || workerSessionId !== route.workerSessionId)
|
|
596
776
|
return;
|
|
597
777
|
const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
|
|
598
|
-
|
|
778
|
+
settleRouteWait(route, { exitCode });
|
|
599
779
|
}
|
|
600
780
|
// Close the one route on a worker exit. The worker is gone, so the manager
|
|
601
781
|
// resolves the login wait with the fixed non-secret exit and clears the route
|
|
602
782
|
// one time. The pending pseudo-terminal calls reject through `rejectAllPending`.
|
|
603
|
-
function
|
|
604
|
-
const route =
|
|
783
|
+
function closeLoginPtyRouteOnWorkerExit() {
|
|
784
|
+
const route = loginPtyRoute;
|
|
605
785
|
if (!route)
|
|
606
786
|
return;
|
|
607
|
-
|
|
787
|
+
loginPtyRoute = null;
|
|
608
788
|
route.terminalized = true;
|
|
609
789
|
route.state = "closed";
|
|
610
790
|
route.listener = null;
|
|
611
791
|
route.buffered = [];
|
|
612
|
-
|
|
792
|
+
settleRouteWait(route, { exitCode: null });
|
|
613
793
|
}
|
|
614
794
|
// Open one live login pseudo-terminal route. Reserve the route
|
|
615
795
|
// before the open call, bind the worker session identifier one time on the
|
|
616
796
|
// first successful open reply, and return a session the login transport drives.
|
|
617
797
|
// Terminalize the route on every open failure path.
|
|
618
|
-
async function
|
|
619
|
-
if (input.
|
|
620
|
-
// Allowlist the login command. Only the
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
throw new Error(
|
|
625
|
-
}
|
|
626
|
-
|
|
798
|
+
async function openLoginPtySession(input) {
|
|
799
|
+
if (!isLoginCommandKey(input.loginCommandKey)) {
|
|
800
|
+
// Allowlist the login command key. Only a key in the closed set may open a
|
|
801
|
+
// sandbox pseudo-terminal. Reject a key outside the set with one fixed
|
|
802
|
+
// non-secret error before the worker call, so a caller cannot select an
|
|
803
|
+
// arbitrary command in the sandbox pseudo-terminal.
|
|
804
|
+
throw new Error(LOGIN_PTY_COMMAND_NOT_ALLOWED);
|
|
805
|
+
}
|
|
806
|
+
// Revalidate the server-controlled session home shape before the worker RPC.
|
|
807
|
+
// The binding validated it at the service boundary; this is the last host gate
|
|
808
|
+
// before the worker call, so a malformed home fails closed here too.
|
|
809
|
+
validateLoginSessionHome(input.sessionHome);
|
|
810
|
+
if (loginPtyRoute) {
|
|
627
811
|
// A route for this worker is not yet closed and confirmed. Reject the
|
|
628
812
|
// second open with one fixed non-secret error before it reaches the worker.
|
|
629
|
-
throw new Error(
|
|
813
|
+
throw new Error(LOGIN_PTY_ROUTE_BUSY);
|
|
630
814
|
}
|
|
631
815
|
const hostRouteId = randomUUID();
|
|
632
816
|
let settleWait = () => { };
|
|
@@ -643,31 +827,32 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
643
827
|
terminalized: false,
|
|
644
828
|
settleWait,
|
|
645
829
|
};
|
|
646
|
-
|
|
830
|
+
loginPtyRoute = route;
|
|
647
831
|
route.state = "opening";
|
|
648
832
|
let openResult;
|
|
649
833
|
try {
|
|
650
|
-
openResult = await callInternal("
|
|
834
|
+
openResult = await callInternal("loginPtyOpen", {
|
|
651
835
|
hostRouteId,
|
|
652
836
|
driverKey: input.driverKey,
|
|
653
837
|
companyId: input.companyId,
|
|
654
838
|
environmentId: input.environmentId,
|
|
655
839
|
providerLeaseId: input.providerLeaseId,
|
|
656
|
-
|
|
657
|
-
|
|
840
|
+
loginCommandKey: input.loginCommandKey,
|
|
841
|
+
sessionHome: input.sessionHome,
|
|
842
|
+
}, loginPtyOpenTimeoutMs);
|
|
658
843
|
}
|
|
659
844
|
catch (err) {
|
|
660
845
|
// A send failure, an RPC rejection, or an open timeout. Terminalize the
|
|
661
846
|
// route exactly once and fail closed.
|
|
662
|
-
await
|
|
663
|
-
throw err instanceof Error ? err : new Error(
|
|
847
|
+
await terminalizeLoginPtyRoute(route);
|
|
848
|
+
throw err instanceof Error ? err : new Error(LOGIN_PTY_OPEN_FAILED);
|
|
664
849
|
}
|
|
665
|
-
const workerSessionId =
|
|
666
|
-
if (!workerSessionId
|
|
850
|
+
const workerSessionId = readBindableWorkerSessionId(route, openResult);
|
|
851
|
+
if (!workerSessionId) {
|
|
667
852
|
// A malformed reply, or a route that already left `opening`. A late or a
|
|
668
853
|
// duplicate reply never binds, revives, or reopens a route.
|
|
669
|
-
await
|
|
670
|
-
throw new Error(
|
|
854
|
+
await terminalizeLoginPtyRoute(route);
|
|
855
|
+
throw new Error(LOGIN_PTY_OPEN_FAILED);
|
|
671
856
|
}
|
|
672
857
|
// Bind the worker session identifier one time and move the route to `open`.
|
|
673
858
|
route.workerSessionId = workerSessionId;
|
|
@@ -686,7 +871,7 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
686
871
|
const sid = route.workerSessionId;
|
|
687
872
|
if (route.state !== "open" || !sid)
|
|
688
873
|
return;
|
|
689
|
-
void callInternal("
|
|
874
|
+
void callInternal("loginPtyInput", { workerSessionId: sid, data }, loginPtyOpenTimeoutMs).catch(() => { });
|
|
690
875
|
},
|
|
691
876
|
wait() {
|
|
692
877
|
return waitPromise;
|
|
@@ -695,10 +880,796 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
695
880
|
const sid = route.workerSessionId;
|
|
696
881
|
if (!sid)
|
|
697
882
|
return;
|
|
698
|
-
void callInternal("
|
|
883
|
+
void callInternal("loginPtyStop", { workerSessionId: sid }, loginPtyOpenTimeoutMs).catch(() => { });
|
|
699
884
|
},
|
|
700
885
|
async close() {
|
|
701
|
-
await
|
|
886
|
+
await terminalizeLoginPtyRoute(route);
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
// The live duplex routes on this worker, keyed by the exact
|
|
891
|
+
// `{ hostRouteId, workerSessionId }` pair. The host binds one pair once, at
|
|
892
|
+
// open, and routes each data, exit, and close frame only to the route that owns
|
|
893
|
+
// its exact pair. The host never routes by the worker session id alone.
|
|
894
|
+
const liveDuplexRoutes = new Map();
|
|
895
|
+
// The reserved-or-opening routes, keyed by the host route id. A route lives here
|
|
896
|
+
// from the open call until the worker session id binds. The host then moves it
|
|
897
|
+
// to `liveDuplexRoutes` under the exact pair key.
|
|
898
|
+
const openingDuplexRoutes = new Map();
|
|
899
|
+
// The tombstoned pairs on this worker, keyed by the exact pair key. The host
|
|
900
|
+
// installs a tombstone atomically when it removes a live binding, and retains it
|
|
901
|
+
// until the worker retires. A late frame for a tombstoned pair reaches no
|
|
902
|
+
// listener. A closed pair never returns.
|
|
903
|
+
const duplexPairTombstones = new Set();
|
|
904
|
+
// The process-scoped aggregate route-slot controller. The manager injects it, so
|
|
905
|
+
// the ceiling counts every duplex route across every worker in the process, not
|
|
906
|
+
// one agent's setting. When it is absent, the worker admits every open, so the
|
|
907
|
+
// handle runs unbounded in isolation (a unit test constructs it this way).
|
|
908
|
+
const duplexRouteSlots = options.duplexRouteSlots ?? null;
|
|
909
|
+
// The routes that currently hold one aggregate slot. The host releases a slot one
|
|
910
|
+
// time per route, so a double terminalize never releases two slots.
|
|
911
|
+
const duplexRouteSlotHolders = new Set();
|
|
912
|
+
// The process-owned aggregate byte ledger. The manager injects it, so one gauge
|
|
913
|
+
// bounds the aggregate retained bytes across every route in the process. When it
|
|
914
|
+
// is absent, the worker retains duplex bytes unbounded (a unit test constructs it
|
|
915
|
+
// this way).
|
|
916
|
+
const duplexAggregateByteLedger = options.duplexAggregateByteLedger ?? null;
|
|
917
|
+
// The outstanding child-stdin transport tokens for duplex writes. Each token
|
|
918
|
+
// covers one serialized frame the host stdin write buffer still retains. The
|
|
919
|
+
// write callback releases a token on the flush; a stream error, a stream close,
|
|
920
|
+
// or a worker exit releases every remaining token, because each of those
|
|
921
|
+
// discards the stdin write buffer. The set is the release guard, so a token
|
|
922
|
+
// releases one time across the two paths.
|
|
923
|
+
const pendingStdinWriteTokens = new Set();
|
|
924
|
+
// Release every outstanding child-stdin transport token. A stream error, a
|
|
925
|
+
// stream close, or a worker exit calls this, because each discards the stdin
|
|
926
|
+
// write buffer. The `delete` guard drops each token one time, so a later flush
|
|
927
|
+
// callback or a second sweep releases nothing again.
|
|
928
|
+
function releaseAllPendingStdinWriteTokens() {
|
|
929
|
+
if (!duplexAggregateByteLedger)
|
|
930
|
+
return;
|
|
931
|
+
for (const token of pendingStdinWriteTokens) {
|
|
932
|
+
duplexAggregateByteLedger.release(token);
|
|
933
|
+
}
|
|
934
|
+
pendingStdinWriteTokens.clear();
|
|
935
|
+
}
|
|
936
|
+
// The terminalized routes that still hold buffered bytes for a late listener
|
|
937
|
+
// drain. A terminalized route leaves the opening and live maps, so this registry
|
|
938
|
+
// keeps the worker-exit sweep able to release its still-charged buffered tokens.
|
|
939
|
+
const terminalDuplexRoutes = new Set();
|
|
940
|
+
// Reserve `bytes` for one route retention against the aggregate ledger. Return
|
|
941
|
+
// the held token, or `null` when the reservation would pass the ceiling. When no
|
|
942
|
+
// ledger is present, return `"no-ledger"`, so the caller admits the retention
|
|
943
|
+
// with no token. The helper adds a real token to `route.retainedTokens`.
|
|
944
|
+
function reserveRouteBytes(route, owner, bytes) {
|
|
945
|
+
if (!duplexAggregateByteLedger)
|
|
946
|
+
return "no-ledger";
|
|
947
|
+
const token = duplexAggregateByteLedger.reserve(owner, bytes);
|
|
948
|
+
if (!token)
|
|
949
|
+
return null;
|
|
950
|
+
route.retainedTokens.add(token);
|
|
951
|
+
return token;
|
|
952
|
+
}
|
|
953
|
+
// Transfer a held route token to a new owner label. The token identity and the
|
|
954
|
+
// reserved bytes stay the same, so no admission gap opens between two
|
|
955
|
+
// representations of the same retained bytes.
|
|
956
|
+
function transferRouteToken(token, owner) {
|
|
957
|
+
if (!token)
|
|
958
|
+
return;
|
|
959
|
+
duplexAggregateByteLedger?.transfer(token, owner);
|
|
960
|
+
}
|
|
961
|
+
// Release one held route token and drop it from the route registry, in one
|
|
962
|
+
// synchronous step. A `null` token (no ledger) releases nothing.
|
|
963
|
+
function releaseRouteToken(route, token) {
|
|
964
|
+
if (!token)
|
|
965
|
+
return;
|
|
966
|
+
route.retainedTokens.delete(token);
|
|
967
|
+
duplexAggregateByteLedger?.release(token);
|
|
968
|
+
}
|
|
969
|
+
// Release every token a route still holds and clear its retained representations,
|
|
970
|
+
// exactly once. A later call finds an empty registry and releases nothing, so the
|
|
971
|
+
// helper is idempotent. Every terminal-discard, open-failure, bind-replay
|
|
972
|
+
// failure, close-acknowledgement failure, worker-exit, and shutdown path calls
|
|
973
|
+
// it. Terminal map deletion is never treated as proof that retained bytes are
|
|
974
|
+
// gone; this registry is authoritative for byte cleanup.
|
|
975
|
+
function discardRouteRetained(route) {
|
|
976
|
+
if (duplexAggregateByteLedger) {
|
|
977
|
+
for (const token of route.retainedTokens) {
|
|
978
|
+
duplexAggregateByteLedger.release(token);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
route.retainedTokens.clear();
|
|
982
|
+
route.preBind = [];
|
|
983
|
+
route.preBindExit = null;
|
|
984
|
+
route.buffered = [];
|
|
985
|
+
route.bufferedChars = 0;
|
|
986
|
+
terminalDuplexRoutes.delete(route);
|
|
987
|
+
}
|
|
988
|
+
// Try to reserve one aggregate route slot for a route. Return true when the
|
|
989
|
+
// route holds a slot after the call. When no controller is present, the route
|
|
990
|
+
// always holds a slot.
|
|
991
|
+
function acquireDuplexRouteSlot(route) {
|
|
992
|
+
if (!duplexRouteSlots)
|
|
993
|
+
return true;
|
|
994
|
+
if (!duplexRouteSlots.tryAcquire())
|
|
995
|
+
return false;
|
|
996
|
+
duplexRouteSlotHolders.add(route);
|
|
997
|
+
return true;
|
|
998
|
+
}
|
|
999
|
+
// Release the aggregate route slot a route holds, one time. A route that never
|
|
1000
|
+
// held a slot releases nothing.
|
|
1001
|
+
function releaseDuplexRouteSlot(route) {
|
|
1002
|
+
if (!duplexRouteSlotHolders.delete(route))
|
|
1003
|
+
return;
|
|
1004
|
+
duplexRouteSlots?.release();
|
|
1005
|
+
}
|
|
1006
|
+
// Retire the worker at once. The host kills the process, so every live route,
|
|
1007
|
+
// every reserved route, and every tombstone drops together. The host uses this
|
|
1008
|
+
// as the fail-closed response to an ownership violation and to a tombstone-set
|
|
1009
|
+
// overflow. The host never logs the raw frame content on a violation.
|
|
1010
|
+
function retireDuplexWorkerOnViolation(reason) {
|
|
1011
|
+
log.error({ pluginId }, `duplex channel ownership violation (${reason}); retiring worker`);
|
|
1012
|
+
void killProcess();
|
|
1013
|
+
}
|
|
1014
|
+
// Install a tombstone for a bound pair atomically with the removal of its live
|
|
1015
|
+
// binding. This runs in one synchronous step, before any slot release or reuse,
|
|
1016
|
+
// so a late frame for the pair reaches no listener. Before the set would exceed
|
|
1017
|
+
// its hard bound, retire the worker, which drops every route and every tombstone
|
|
1018
|
+
// at once. A tombstone overflow fails closed; the host never evicts a tombstone
|
|
1019
|
+
// and lets a pair return.
|
|
1020
|
+
function tombstoneBoundDuplexPair(route) {
|
|
1021
|
+
if (route.workerSessionId === null)
|
|
1022
|
+
return;
|
|
1023
|
+
const pairKey = duplexPairKey(route.hostRouteId, route.workerSessionId);
|
|
1024
|
+
liveDuplexRoutes.delete(pairKey);
|
|
1025
|
+
if (!duplexPairTombstones.has(pairKey)) {
|
|
1026
|
+
if (duplexPairTombstones.size >= MAX_DUPLEX_ROUTE_TOMBSTONES) {
|
|
1027
|
+
// The set is full. Do not evict a tombstone. Retire the worker, which
|
|
1028
|
+
// drops every route and every tombstone together, so no closed pair ever
|
|
1029
|
+
// returns.
|
|
1030
|
+
retireDuplexWorkerOnViolation("tombstone set overflow");
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
duplexPairTombstones.add(pairKey);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// Close the worker channel by the host route identifier and verify the bound
|
|
1037
|
+
// acknowledgement. Return true only when the worker returns an acknowledgement
|
|
1038
|
+
// for the exact pair. Before a session binds, the acknowledgement carries the
|
|
1039
|
+
// host route identifier only, so a lost open reply still permits a route-only
|
|
1040
|
+
// close. After a session binds, the acknowledgement must echo both the host
|
|
1041
|
+
// route identifier and the bound worker session identifier. An absent,
|
|
1042
|
+
// malformed, mismatched, or timed-out acknowledgement returns false, so the
|
|
1043
|
+
// caller fails closed.
|
|
1044
|
+
async function closeDuplexChannelTerminal(hostRouteId, boundWorkerSessionId) {
|
|
1045
|
+
try {
|
|
1046
|
+
const ack = await callInternal("duplexChannelClose", { hostRouteId }, duplexChannelCloseTimeoutMs);
|
|
1047
|
+
if (!isRecord(ack) || readNonEmptyString(ack.hostRouteId) !== hostRouteId) {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
if (boundWorkerSessionId !== null) {
|
|
1051
|
+
// A bound close: the acknowledgement must echo the exact worker session
|
|
1052
|
+
// identifier. A missing or a mismatched identifier is invalid.
|
|
1053
|
+
return readNonEmptyString(ack.workerSessionId) === boundWorkerSessionId;
|
|
1054
|
+
}
|
|
1055
|
+
return true;
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
// Terminalize the route exactly once. Resolve the wait, close the worker
|
|
1062
|
+
// channel by the host route identifier, and free the per-worker slot only
|
|
1063
|
+
// after the close resolves. Retire the worker when the close is unconfirmed.
|
|
1064
|
+
// Clear the route lifetime timer one time. Every terminal path and the
|
|
1065
|
+
// worker-exit path calls this, so a timer never fires after the route ends.
|
|
1066
|
+
function clearDuplexChannelLifetimeTimer(route) {
|
|
1067
|
+
if (route.lifetimeTimer) {
|
|
1068
|
+
clearTimeout(route.lifetimeTimer);
|
|
1069
|
+
route.lifetimeTimer = null;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
async function terminalizeDuplexChannelRoute(route) {
|
|
1073
|
+
if (route.terminalized)
|
|
1074
|
+
return;
|
|
1075
|
+
route.terminalized = true;
|
|
1076
|
+
route.state = "closed";
|
|
1077
|
+
route.listener = null;
|
|
1078
|
+
// Stop admission first. Then move the byte cleanup. Keep the buffered chunks the
|
|
1079
|
+
// host accepted before the route ended, so a listener that attaches after the
|
|
1080
|
+
// end still drains them. A frame can end the route during the pre-bind replay,
|
|
1081
|
+
// before a listener attaches, and the chunks the host accepted before that frame
|
|
1082
|
+
// are valid data the listener must still receive. The buffered bytes stay
|
|
1083
|
+
// bounded by the pre-bind buffered bound, and `onData` clears and releases them
|
|
1084
|
+
// once it drains them.
|
|
1085
|
+
//
|
|
1086
|
+
// The buffered records keep their tokens; move each token to the terminal owner
|
|
1087
|
+
// label and register the route in the terminal registry, so the worker-exit
|
|
1088
|
+
// sweep can release the still-charged bytes later. Do not release a buffered
|
|
1089
|
+
// token at map deletion; terminal map deletion is never proof the bytes are
|
|
1090
|
+
// gone. Release every other token the route still holds (the pre-bind events,
|
|
1091
|
+
// the held exit, and any stranded token), because they never reach a listener.
|
|
1092
|
+
const bufferedTokens = new Set();
|
|
1093
|
+
for (const record of route.buffered) {
|
|
1094
|
+
if (record.token) {
|
|
1095
|
+
bufferedTokens.add(record.token);
|
|
1096
|
+
transferRouteToken(record.token, "terminal_buffered");
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
for (const token of [...route.retainedTokens]) {
|
|
1100
|
+
if (!bufferedTokens.has(token)) {
|
|
1101
|
+
route.retainedTokens.delete(token);
|
|
1102
|
+
duplexAggregateByteLedger?.release(token);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
route.preBind = [];
|
|
1106
|
+
route.preBindExit = null;
|
|
1107
|
+
if (route.buffered.length > 0) {
|
|
1108
|
+
terminalDuplexRoutes.add(route);
|
|
1109
|
+
}
|
|
1110
|
+
clearDuplexChannelLifetimeTimer(route);
|
|
1111
|
+
// Remove the live binding and install the tombstone in one synchronous step,
|
|
1112
|
+
// before the worker close and before any reuse. A reserved route that never
|
|
1113
|
+
// bound leaves the opening map only; it has no pair to tombstone, and the
|
|
1114
|
+
// monotonic host route id never returns.
|
|
1115
|
+
openingDuplexRoutes.delete(route.hostRouteId);
|
|
1116
|
+
tombstoneBoundDuplexPair(route);
|
|
1117
|
+
releaseDuplexRouteSlot(route);
|
|
1118
|
+
// A terminalized route reports a null exit code, which the caller treats as a
|
|
1119
|
+
// failure.
|
|
1120
|
+
settleRouteWait(route, { exitCode: null });
|
|
1121
|
+
const confirmed = await closeDuplexChannelTerminal(route.hostRouteId, route.workerSessionId);
|
|
1122
|
+
if (!confirmed) {
|
|
1123
|
+
// The worker did not acknowledge the close, so the host cannot prove the
|
|
1124
|
+
// channel is gone. Fail closed: retire the worker before any reuse.
|
|
1125
|
+
log.error({ pluginId }, "duplex channel close not acknowledged; retiring worker");
|
|
1126
|
+
void killProcess();
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
// Count one protocol error for the route. End the route when the count passes
|
|
1130
|
+
// the per-route budget, so a flood of malformed or mismatched frames bounds the
|
|
1131
|
+
// route.
|
|
1132
|
+
function recordDuplexChannelProtocolError(route) {
|
|
1133
|
+
route.protocolErrors += 1;
|
|
1134
|
+
if (route.protocolErrors > maxDuplexChannelProtocolErrors) {
|
|
1135
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// Deliver one duplex channel chunk to the bound listener in isolation. A
|
|
1139
|
+
// listener that throws must not escape the worker stdout notification handler
|
|
1140
|
+
// or the buffered replay, so a throw here breaks neither the notification
|
|
1141
|
+
// dispatch loop nor the pre-bind drain. The manager catches the error and logs
|
|
1142
|
+
// it without the raw bytes. This mirrors the `execute.log` delivery isolation.
|
|
1143
|
+
function deliverDuplexChannelChunk(listener, chunk) {
|
|
1144
|
+
try {
|
|
1145
|
+
listener(chunk);
|
|
1146
|
+
}
|
|
1147
|
+
catch (err) {
|
|
1148
|
+
log.error({ err: err instanceof Error ? err.message : String(err) }, "duplex channel data delivery threw");
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
// Route one duplex channel data notification to the per-session listener.
|
|
1152
|
+
// Deliver only while the route is `open` and the notification carries the exact
|
|
1153
|
+
// bound worker session identifier and a valid chunk. Count a mismatched or
|
|
1154
|
+
// malformed frame as a protocol error. End the route at once when one chunk is
|
|
1155
|
+
// larger than the per-chunk limit or when the cumulative bytes pass the total
|
|
1156
|
+
// cap. Buffer a valid frame under the pre-bind bounds when no listener has
|
|
1157
|
+
// attached yet. Never log the raw bytes.
|
|
1158
|
+
// Resolve one inbound duplex frame to its owning route by the exact pair. Return
|
|
1159
|
+
// the live route on an exact live-pair match. Return `"tombstoned"` for a late
|
|
1160
|
+
// frame whose exact pair the host already closed; that frame reaches no listener
|
|
1161
|
+
// and changes no state. Return `"violation"` for any unknown, foreign,
|
|
1162
|
+
// duplicate, ambiguous, or malformed pair; the caller fails closed and retires
|
|
1163
|
+
// the worker. The lookup never routes by the worker session id alone.
|
|
1164
|
+
function resolveDuplexRouteByPair(hostRouteId, workerSessionId) {
|
|
1165
|
+
if (!hostRouteId || !workerSessionId)
|
|
1166
|
+
return "violation";
|
|
1167
|
+
const pairKey = duplexPairKey(hostRouteId, workerSessionId);
|
|
1168
|
+
const live = liveDuplexRoutes.get(pairKey);
|
|
1169
|
+
if (live && live.state === "open")
|
|
1170
|
+
return live;
|
|
1171
|
+
if (duplexPairTombstones.has(pairKey))
|
|
1172
|
+
return "tombstoned";
|
|
1173
|
+
// A frame whose host route id names a route that is still opening arrived
|
|
1174
|
+
// before the bind. It is the host's own reserved route, not a foreign frame.
|
|
1175
|
+
// The caller defers it, so the bind replays it through the exact-pair routing.
|
|
1176
|
+
if (openingDuplexRoutes.has(hostRouteId))
|
|
1177
|
+
return "opening";
|
|
1178
|
+
return "violation";
|
|
1179
|
+
}
|
|
1180
|
+
// Route one duplex channel data frame. On a live route with a listener, deliver
|
|
1181
|
+
// the chunk transiently. On a live route with no listener, buffer the chunk and
|
|
1182
|
+
// charge its raw bytes against the aggregate ledger. The `carried` argument marks
|
|
1183
|
+
// a bind-replay frame: it carries the pre-bind token the buffered record must
|
|
1184
|
+
// reuse, so the replay transfers the token to the buffered representation with no
|
|
1185
|
+
// decrement and no re-reserve. A live frame passes no `carried`, so it reserves a
|
|
1186
|
+
// fresh buffered token. A `carried.token` of `null` means no ledger is present.
|
|
1187
|
+
function routeDuplexChannelData(notification, carried) {
|
|
1188
|
+
const params = isRecord(notification.params) ? notification.params : {};
|
|
1189
|
+
const hostRouteId = readNonEmptyString(params.hostRouteId);
|
|
1190
|
+
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
|
1191
|
+
const resolved = resolveDuplexRouteByPair(hostRouteId, workerSessionId);
|
|
1192
|
+
if (resolved === "tombstoned") {
|
|
1193
|
+
// A late frame for a closed pair. It reaches no listener and changes no
|
|
1194
|
+
// state. A bind replay never lands here, because the replayed route is live;
|
|
1195
|
+
// a carried token stays on its route and the terminal cleanup releases it.
|
|
1196
|
+
// Never log the raw frame content.
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (resolved === "violation") {
|
|
1200
|
+
// An unknown, foreign, duplicate, ambiguous, or malformed pair. Fail closed:
|
|
1201
|
+
// retire the worker. Never log the raw frame content.
|
|
1202
|
+
retireDuplexWorkerOnViolation("data frame pair");
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
if (resolved === "opening") {
|
|
1206
|
+
// The frame arrived before the bind. A replay never lands here, because the
|
|
1207
|
+
// route is live during replay. Hold the live frame; the bind replays it.
|
|
1208
|
+
bufferPreBindDuplexFrame(hostRouteId, notification);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
const route = resolved;
|
|
1212
|
+
// The wire carries the chunk as a base64 string (JSON has no binary type; see
|
|
1213
|
+
// `ChannelBytesWireValue` in protocol.ts). Decode it back to raw bytes before
|
|
1214
|
+
// any bound check, so every bound below counts real bytes, not base64 text.
|
|
1215
|
+
// A bind replay (`replayPreBindDuplexFrames`) reuses this function with a
|
|
1216
|
+
// held event whose chunk the host already decoded once; it carries the
|
|
1217
|
+
// already-decoded `Uint8Array` straight through with no second decode.
|
|
1218
|
+
const rawChunk = params.chunk;
|
|
1219
|
+
const chunk = rawChunk instanceof Uint8Array ? rawChunk : decodeChannelBytes(rawChunk);
|
|
1220
|
+
if (chunk === null || chunk.byteLength === 0) {
|
|
1221
|
+
// The exact pair matches, but the chunk is malformed or empty. Count one
|
|
1222
|
+
// per-route protocol error. Release a carried replay token first.
|
|
1223
|
+
releaseRouteToken(route, carried?.token ?? null);
|
|
1224
|
+
recordDuplexChannelProtocolError(route);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
if (chunk.byteLength > maxDuplexChannelChunkChars) {
|
|
1228
|
+
// One inbound chunk is larger than the per-chunk limit. End the route at
|
|
1229
|
+
// once. Do not count the chunk as a protocol error.
|
|
1230
|
+
releaseRouteToken(route, carried?.token ?? null);
|
|
1231
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
// Enforce the cumulative total-byte cap before and after a listener attaches.
|
|
1235
|
+
// End the route when the cap is exceeded, so a bound listener cannot receive
|
|
1236
|
+
// data past the cap.
|
|
1237
|
+
const chunkBytes = chunk.byteLength;
|
|
1238
|
+
if (route.totalDataBytes + chunkBytes > maxDuplexChannelTotalDataBytes) {
|
|
1239
|
+
releaseRouteToken(route, carried?.token ?? null);
|
|
1240
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
route.totalDataBytes += chunkBytes;
|
|
1244
|
+
if (route.listener) {
|
|
1245
|
+
// A listener is attached. Deliver the chunk transiently and release a carried
|
|
1246
|
+
// replay token after the synchronous delivery boundary.
|
|
1247
|
+
deliverDuplexChannelChunk(route.listener, chunk);
|
|
1248
|
+
releaseRouteToken(route, carried?.token ?? null);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
// No listener attached yet. Buffer the frame under the pre-bind bounds. End
|
|
1252
|
+
// the route when the cumulative bytes or the frame count passes the bound.
|
|
1253
|
+
if (route.buffered.length + 1 > maxDuplexChannelPreBindFrames ||
|
|
1254
|
+
route.bufferedChars + chunk.length > maxDuplexChannelPreBindChars) {
|
|
1255
|
+
releaseRouteToken(route, carried?.token ?? null);
|
|
1256
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
let token;
|
|
1260
|
+
if (carried) {
|
|
1261
|
+
// The bind replay transfers the pre-bind token to the buffered
|
|
1262
|
+
// representation. No decrement and no re-reserve, so no admission gap opens.
|
|
1263
|
+
token = carried.token;
|
|
1264
|
+
transferRouteToken(token, "buffered_chunk");
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
// A live frame reserves a fresh buffered token for its exact raw bytes.
|
|
1268
|
+
const reserved = reserveRouteBytes(route, "buffered_chunk", chunkBytes);
|
|
1269
|
+
if (reserved === null) {
|
|
1270
|
+
// The aggregate ceiling rejected the reservation. Retain nothing and fail
|
|
1271
|
+
// closed with the fixed marker.
|
|
1272
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
token = reserved === "no-ledger" ? null : reserved;
|
|
1276
|
+
}
|
|
1277
|
+
route.buffered.push({ chunk, token });
|
|
1278
|
+
route.bufferedChars += chunk.length;
|
|
1279
|
+
}
|
|
1280
|
+
// Route one duplex channel exit notification to the wait. Resolve only while
|
|
1281
|
+
// the route is `open` and the notification carries the exact bound worker
|
|
1282
|
+
// session identifier.
|
|
1283
|
+
function routeDuplexChannelExit(notification) {
|
|
1284
|
+
const params = isRecord(notification.params) ? notification.params : {};
|
|
1285
|
+
const hostRouteId = readNonEmptyString(params.hostRouteId);
|
|
1286
|
+
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
|
1287
|
+
const resolved = resolveDuplexRouteByPair(hostRouteId, workerSessionId);
|
|
1288
|
+
if (resolved === "tombstoned") {
|
|
1289
|
+
// A late exit for a closed pair. It reaches no wait and changes no state.
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (resolved === "violation") {
|
|
1293
|
+
// An unknown, foreign, duplicate, ambiguous, or malformed pair. Fail closed:
|
|
1294
|
+
// retire the worker.
|
|
1295
|
+
retireDuplexWorkerOnViolation("exit frame pair");
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (resolved === "opening") {
|
|
1299
|
+
// The exit arrived before the bind. Hold it; the bind replays it through the
|
|
1300
|
+
// exact-pair routing.
|
|
1301
|
+
bufferPreBindDuplexFrame(hostRouteId, notification);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
|
|
1305
|
+
// A reason-less transport close carries `transportClosed`; a real process exit
|
|
1306
|
+
// does not. Carry the discriminator to the wait only when it is a transport
|
|
1307
|
+
// close, so a real exit keeps the plain `{ exitCode }` result and the broker
|
|
1308
|
+
// still keeps the two apart in the loss taxonomy.
|
|
1309
|
+
if (params.transportClosed === true) {
|
|
1310
|
+
settleRouteWait(resolved, { exitCode, transportClosed: true });
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
settleRouteWait(resolved, { exitCode });
|
|
1314
|
+
}
|
|
1315
|
+
// Hold one frame that arrived before its route bound, in order. The bind replays
|
|
1316
|
+
// the held frames through the exact-pair routing. An exit frame never consumes a
|
|
1317
|
+
// data hold slot; the host holds it in the single `preBindExit` slot instead, so
|
|
1318
|
+
// a worker that batches an exit among enough data frames to fill the hold cannot
|
|
1319
|
+
// crowd out a data frame. The data hold ceiling stays one frame above the
|
|
1320
|
+
// buffered bound, so the replay's buffered-bound check, not the hold, ends the
|
|
1321
|
+
// route. The host counts one protocol error for each data frame past the ceiling,
|
|
1322
|
+
// so an early flood bounds the hold instead of growing without limit.
|
|
1323
|
+
function bufferPreBindDuplexFrame(hostRouteId, notification) {
|
|
1324
|
+
if (!hostRouteId)
|
|
1325
|
+
return;
|
|
1326
|
+
const route = openingDuplexRoutes.get(hostRouteId);
|
|
1327
|
+
if (!route)
|
|
1328
|
+
return;
|
|
1329
|
+
const params = isRecord(notification.params) ? notification.params : {};
|
|
1330
|
+
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
|
1331
|
+
if (!workerSessionId) {
|
|
1332
|
+
// A malformed pair. Count one per-route protocol error and hold nothing.
|
|
1333
|
+
recordDuplexChannelProtocolError(route);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
if (notification.method === DUPLEX_CHANNEL_EXIT_NOTIFICATION) {
|
|
1337
|
+
// Normalize the exit to the narrow duplex-event schema. An exit retains only
|
|
1338
|
+
// a bounded scalar, so reserve a zero-byte token that carries the record
|
|
1339
|
+
// through the one cleanup path. Release any earlier held exit token first, so
|
|
1340
|
+
// a replaced exit never leaks its reservation.
|
|
1341
|
+
const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
|
|
1342
|
+
if (route.preBindExit)
|
|
1343
|
+
releaseRouteToken(route, route.preBindExit.token);
|
|
1344
|
+
const reserved = reserveRouteBytes(route, "pre_bind_event", 0);
|
|
1345
|
+
if (reserved === null) {
|
|
1346
|
+
// The aggregate ceiling rejected the reservation. Fail closed.
|
|
1347
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
route.preBindExit = {
|
|
1351
|
+
workerSessionId,
|
|
1352
|
+
exitCode,
|
|
1353
|
+
token: reserved === "no-ledger" ? null : reserved,
|
|
1354
|
+
};
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
// A data event. Validate and normalize it to the narrow duplex-event schema
|
|
1358
|
+
// before any retention. The wire carries the chunk as base64; decode it back
|
|
1359
|
+
// to raw bytes before any bound check or retention.
|
|
1360
|
+
const chunk = decodeChannelBytes(params.chunk);
|
|
1361
|
+
if (chunk === null || chunk.byteLength === 0) {
|
|
1362
|
+
recordDuplexChannelProtocolError(route);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (route.preBind.length >= maxDuplexChannelPreBindHoldFrames) {
|
|
1366
|
+
recordDuplexChannelProtocolError(route);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
// Reserve the exact retained raw byte count before the host holds the event.
|
|
1370
|
+
const reserved = reserveRouteBytes(route, "pre_bind_event", chunk.byteLength);
|
|
1371
|
+
if (reserved === null) {
|
|
1372
|
+
// The aggregate ceiling rejected the reservation. The caller retains nothing
|
|
1373
|
+
// and the route fails closed with the fixed marker.
|
|
1374
|
+
log.warn({ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED }, "duplex pre-bind hold rejected");
|
|
1375
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
route.preBind.push({
|
|
1379
|
+
workerSessionId,
|
|
1380
|
+
chunk,
|
|
1381
|
+
token: reserved === "no-ledger" ? null : reserved,
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
// Replay the frames a route held before it bound. The route is live now, so the
|
|
1385
|
+
// exact-pair routing delivers each held frame or fails closed on a mismatch.
|
|
1386
|
+
// Replay the held data frames first, in order, then the held exit last, so the
|
|
1387
|
+
// data delivers before the exit resolves the wait, which matches a real worker's
|
|
1388
|
+
// order. A frame that ends the route terminalizes it, and every later frame in
|
|
1389
|
+
// the replay is a no-op, because the routing functions drop a frame for a route
|
|
1390
|
+
// that is not `open`.
|
|
1391
|
+
function replayPreBindDuplexFrames(route) {
|
|
1392
|
+
const held = route.preBind;
|
|
1393
|
+
route.preBind = [];
|
|
1394
|
+
for (const event of held) {
|
|
1395
|
+
if (route.terminalized) {
|
|
1396
|
+
// The route ended mid-replay. Release the remaining held tokens, so the
|
|
1397
|
+
// unreplayed events retain nothing.
|
|
1398
|
+
releaseRouteToken(route, event.token);
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
// Reconstruct a transient data notification for the exact-pair routing. The
|
|
1402
|
+
// host holds only the bounded event, so it builds this notification for the
|
|
1403
|
+
// routing step and discards it at once. The carried token moves to the
|
|
1404
|
+
// buffered representation without a decrement or a re-reserve.
|
|
1405
|
+
routeDuplexChannelData({
|
|
1406
|
+
jsonrpc: "2.0",
|
|
1407
|
+
method: DUPLEX_CHANNEL_DATA_NOTIFICATION,
|
|
1408
|
+
params: {
|
|
1409
|
+
hostRouteId: route.hostRouteId,
|
|
1410
|
+
workerSessionId: event.workerSessionId,
|
|
1411
|
+
chunk: event.chunk,
|
|
1412
|
+
},
|
|
1413
|
+
}, { token: event.token });
|
|
1414
|
+
}
|
|
1415
|
+
const heldExit = route.preBindExit;
|
|
1416
|
+
route.preBindExit = null;
|
|
1417
|
+
if (heldExit) {
|
|
1418
|
+
// The exit retains only a bounded scalar. Release its placeholder token, then
|
|
1419
|
+
// resolve the wait through the exact-pair routing when the route still lives.
|
|
1420
|
+
releaseRouteToken(route, heldExit.token);
|
|
1421
|
+
if (!route.terminalized) {
|
|
1422
|
+
routeDuplexChannelExit({
|
|
1423
|
+
jsonrpc: "2.0",
|
|
1424
|
+
method: DUPLEX_CHANNEL_EXIT_NOTIFICATION,
|
|
1425
|
+
params: {
|
|
1426
|
+
hostRouteId: route.hostRouteId,
|
|
1427
|
+
workerSessionId: heldExit.workerSessionId,
|
|
1428
|
+
exitCode: heldExit.exitCode,
|
|
1429
|
+
},
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
// Close every route on a worker exit. The worker is gone, so the manager
|
|
1435
|
+
// resolves each wait with the fixed non-secret exit, releases each aggregate
|
|
1436
|
+
// slot, and clears the live, opening, and tombstone state. A worker exit is a
|
|
1437
|
+
// worker retirement, so the host drops every tombstone; the monotonic host route
|
|
1438
|
+
// id never returns, so no closed pair can revive on a restart. The pending
|
|
1439
|
+
// channel calls reject through `rejectAllPending`.
|
|
1440
|
+
function closeDuplexChannelRouteOnWorkerExit() {
|
|
1441
|
+
// Enumerate the opening index, the live index, and the terminal registry. A
|
|
1442
|
+
// terminalized route left the opening and live maps but may still hold buffered
|
|
1443
|
+
// bytes for a late listener; the worker is gone, so the host releases them now.
|
|
1444
|
+
const routes = [
|
|
1445
|
+
...openingDuplexRoutes.values(),
|
|
1446
|
+
...liveDuplexRoutes.values(),
|
|
1447
|
+
...terminalDuplexRoutes,
|
|
1448
|
+
];
|
|
1449
|
+
openingDuplexRoutes.clear();
|
|
1450
|
+
liveDuplexRoutes.clear();
|
|
1451
|
+
duplexPairTombstones.clear();
|
|
1452
|
+
for (const route of routes) {
|
|
1453
|
+
if (!route.terminalized) {
|
|
1454
|
+
route.terminalized = true;
|
|
1455
|
+
route.state = "closed";
|
|
1456
|
+
route.listener = null;
|
|
1457
|
+
route.bufferedChars = 0;
|
|
1458
|
+
clearDuplexChannelLifetimeTimer(route);
|
|
1459
|
+
releaseDuplexRouteSlot(route);
|
|
1460
|
+
settleRouteWait(route, { exitCode: null });
|
|
1461
|
+
}
|
|
1462
|
+
// Release every token the route still holds and clear its retained
|
|
1463
|
+
// representations, exactly once. A route already drained by a late listener
|
|
1464
|
+
// holds no token, so this is harmless and leaves the ledger at zero.
|
|
1465
|
+
discardRouteRetained(route);
|
|
1466
|
+
}
|
|
1467
|
+
// Clear the terminal registry. Every route in it was just discarded above.
|
|
1468
|
+
terminalDuplexRoutes.clear();
|
|
1469
|
+
}
|
|
1470
|
+
// Open one live generic duplex channel route. Reserve the route before the open
|
|
1471
|
+
// call, bind the worker session identifier one time on the first successful
|
|
1472
|
+
// open reply, and return a session a caller drives. Terminalize the route on
|
|
1473
|
+
// every open failure path.
|
|
1474
|
+
async function openDuplexChannel(input) {
|
|
1475
|
+
const hostRouteId = nextDuplexHostRouteId();
|
|
1476
|
+
let settleWait = () => { };
|
|
1477
|
+
const waitPromise = new Promise((resolve) => {
|
|
1478
|
+
settleWait = resolve;
|
|
1479
|
+
});
|
|
1480
|
+
const route = {
|
|
1481
|
+
hostRouteId,
|
|
1482
|
+
state: "reserved",
|
|
1483
|
+
workerSessionId: null,
|
|
1484
|
+
listener: null,
|
|
1485
|
+
buffered: [],
|
|
1486
|
+
bufferedChars: 0,
|
|
1487
|
+
pendingRequests: 0,
|
|
1488
|
+
protocolErrors: 0,
|
|
1489
|
+
totalDataBytes: 0,
|
|
1490
|
+
lifetimeTimer: null,
|
|
1491
|
+
terminalized: false,
|
|
1492
|
+
settleWait,
|
|
1493
|
+
preBind: [],
|
|
1494
|
+
preBindExit: null,
|
|
1495
|
+
retainedTokens: new Set(),
|
|
1496
|
+
};
|
|
1497
|
+
// Reserve one aggregate route slot before any work. When the process-scoped
|
|
1498
|
+
// ceiling is full, reject with the fixed route-busy error and open nothing, so
|
|
1499
|
+
// an active channel never downgrades and the ceiling never overcommits.
|
|
1500
|
+
if (!acquireDuplexRouteSlot(route)) {
|
|
1501
|
+
throw new Error(DUPLEX_CHANNEL_ROUTE_BUSY);
|
|
1502
|
+
}
|
|
1503
|
+
openingDuplexRoutes.set(hostRouteId, route);
|
|
1504
|
+
route.state = "opening";
|
|
1505
|
+
let openResult;
|
|
1506
|
+
try {
|
|
1507
|
+
openResult = await callInternal("duplexChannelOpen", {
|
|
1508
|
+
hostRouteId,
|
|
1509
|
+
driverKey: input.driverKey,
|
|
1510
|
+
companyId: input.companyId,
|
|
1511
|
+
environmentId: input.environmentId,
|
|
1512
|
+
providerLeaseId: input.providerLeaseId,
|
|
1513
|
+
command: input.command,
|
|
1514
|
+
}, duplexChannelOpenTimeoutMs);
|
|
1515
|
+
}
|
|
1516
|
+
catch (err) {
|
|
1517
|
+
// A send failure, an RPC rejection, or an open timeout. Terminalize the
|
|
1518
|
+
// route exactly once and fail closed.
|
|
1519
|
+
await terminalizeDuplexChannelRoute(route);
|
|
1520
|
+
throw err instanceof Error ? err : new Error(DUPLEX_CHANNEL_OPEN_FAILED);
|
|
1521
|
+
}
|
|
1522
|
+
const workerSessionId = readBindableWorkerSessionId(route, openResult);
|
|
1523
|
+
// Verify the worker echoed the exact host route id the open request carried.
|
|
1524
|
+
// A reply with a missing or a mismatched host route id never binds, so the
|
|
1525
|
+
// host binds only a reply that proves the worker holds the exact pair.
|
|
1526
|
+
const echoedHostRouteId = isRecord(openResult)
|
|
1527
|
+
? readNonEmptyString(openResult.hostRouteId)
|
|
1528
|
+
: null;
|
|
1529
|
+
if (!workerSessionId || echoedHostRouteId !== hostRouteId) {
|
|
1530
|
+
// A malformed reply, a mismatched host route id, or a route that already
|
|
1531
|
+
// left `opening`. A late or a duplicate reply never binds, revives, or
|
|
1532
|
+
// reopens a route.
|
|
1533
|
+
await terminalizeDuplexChannelRoute(route);
|
|
1534
|
+
throw new Error(DUPLEX_CHANNEL_OPEN_FAILED);
|
|
1535
|
+
}
|
|
1536
|
+
const pairKey = duplexPairKey(hostRouteId, workerSessionId);
|
|
1537
|
+
if (liveDuplexRoutes.has(pairKey) || duplexPairTombstones.has(pairKey)) {
|
|
1538
|
+
// The worker returned a pair that is already live or already tombstoned. Fail
|
|
1539
|
+
// closed: terminalize this route and retire the worker before any reuse.
|
|
1540
|
+
await terminalizeDuplexChannelRoute(route);
|
|
1541
|
+
retireDuplexWorkerOnViolation("duplicate bound pair");
|
|
1542
|
+
throw new Error(DUPLEX_CHANNEL_OPEN_FAILED);
|
|
1543
|
+
}
|
|
1544
|
+
// Bind the worker session identifier one time and move the route from the
|
|
1545
|
+
// opening map to the live map under the exact pair key.
|
|
1546
|
+
route.workerSessionId = workerSessionId;
|
|
1547
|
+
route.state = "open";
|
|
1548
|
+
openingDuplexRoutes.delete(hostRouteId);
|
|
1549
|
+
liveDuplexRoutes.set(pairKey, route);
|
|
1550
|
+
// Start the route lifetime timer now the route is open. The route ends when
|
|
1551
|
+
// the timer expires. Every terminal path and the worker-exit path clears the
|
|
1552
|
+
// timer. Unreference the timer so it never blocks the host process shutdown.
|
|
1553
|
+
route.lifetimeTimer = setTimeout(() => {
|
|
1554
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1555
|
+
}, maxDuplexChannelDurationMs);
|
|
1556
|
+
route.lifetimeTimer.unref?.();
|
|
1557
|
+
// Replay any frame that arrived before the bind. The route is live now, so the
|
|
1558
|
+
// exact-pair routing delivers each held frame or fails closed on a mismatch.
|
|
1559
|
+
replayPreBindDuplexFrames(route);
|
|
1560
|
+
// Send one host→worker request under the pending-request bound. End the route
|
|
1561
|
+
// when too many requests are in-flight, so a worker that never replies cannot
|
|
1562
|
+
// make the host hold an unbounded number of pending requests.
|
|
1563
|
+
const sendBoundedRequest = (method, params,
|
|
1564
|
+
// The exact raw byte count of a `duplexChannelWrite` payload, before base64
|
|
1565
|
+
// encoding. The caller supplies it: `params.data` on the wire is the base64
|
|
1566
|
+
// form (`ChannelBytesWireValue`), so its string length no longer equals the
|
|
1567
|
+
// real payload bytes. A stop request carries no payload and omits this.
|
|
1568
|
+
writeByteCount) => {
|
|
1569
|
+
if (route.state !== "open")
|
|
1570
|
+
return;
|
|
1571
|
+
if (route.pendingRequests >= maxDuplexChannelPendingRequests) {
|
|
1572
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
// Reserve the exact raw byte count of a host→worker write against the
|
|
1576
|
+
// aggregate ledger before `callInternal` retains the payload. A pending write
|
|
1577
|
+
// RPC holds `params.data` until it settles, so this reservation bounds the
|
|
1578
|
+
// aggregate host→worker pending-write bytes across every route. A stop
|
|
1579
|
+
// request carries no payload, so it reserves nothing. When no ledger is
|
|
1580
|
+
// present, admit the write with no token (a unit test constructs the handle
|
|
1581
|
+
// this way).
|
|
1582
|
+
let pendingWriteToken = null;
|
|
1583
|
+
if (method === "duplexChannelWrite" && duplexAggregateByteLedger) {
|
|
1584
|
+
pendingWriteToken = duplexAggregateByteLedger.reserve("pending_write", writeByteCount ?? 0);
|
|
1585
|
+
if (!pendingWriteToken) {
|
|
1586
|
+
// The reservation would pass the aggregate ceiling. Retain nothing, do
|
|
1587
|
+
// not enqueue the RPC, and end the route fail-closed with the aggregate
|
|
1588
|
+
// marker, not the route-busy marker.
|
|
1589
|
+
log.warn({ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED }, "duplex pending write reservation rejected");
|
|
1590
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
route.pendingRequests += 1;
|
|
1595
|
+
// Meter the child-stdin transport buffer only for a duplex write. A stop
|
|
1596
|
+
// request carries a tiny fixed frame that the host never lets grow, so it
|
|
1597
|
+
// does not meter or reject. The write path reserves the serialized frame in
|
|
1598
|
+
// `sendMessage` right before the stdin write.
|
|
1599
|
+
const meterDuplexWrite = method === "duplexChannelWrite";
|
|
1600
|
+
void callInternal(method, params, duplexChannelOpenTimeoutMs, undefined, meterDuplexWrite)
|
|
1601
|
+
.catch((err) => {
|
|
1602
|
+
if (err instanceof DuplexAggregateBytesExceededError) {
|
|
1603
|
+
// The transport reservation failed. The host did not write the frame.
|
|
1604
|
+
// End the route fail-closed with the aggregate marker, not the
|
|
1605
|
+
// route-busy marker.
|
|
1606
|
+
log.warn({ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED }, "duplex stdin write reservation rejected");
|
|
1607
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1608
|
+
}
|
|
1609
|
+
})
|
|
1610
|
+
.finally(() => {
|
|
1611
|
+
route.pendingRequests -= 1;
|
|
1612
|
+
// Release the pending-write token one time, after the RPC settles on any
|
|
1613
|
+
// path: success, error, timeout, worker exit, or shutdown. The token is
|
|
1614
|
+
// not in `route.retainedTokens`, so route terminalization never releases
|
|
1615
|
+
// it; only this settlement releases it. The separate `stdin_write` token
|
|
1616
|
+
// covers the serialized frame and releases on the stream flush, the
|
|
1617
|
+
// stream error, the stream close, or the worker exit, never here.
|
|
1618
|
+
if (pendingWriteToken) {
|
|
1619
|
+
duplexAggregateByteLedger?.release(pendingWriteToken);
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
};
|
|
1623
|
+
return {
|
|
1624
|
+
onData(listener) {
|
|
1625
|
+
route.listener = listener;
|
|
1626
|
+
if (route.buffered.length > 0) {
|
|
1627
|
+
const pending = route.buffered;
|
|
1628
|
+
route.buffered = [];
|
|
1629
|
+
route.bufferedChars = 0;
|
|
1630
|
+
for (const record of pending) {
|
|
1631
|
+
// Deliver each buffered record, drop its retained buffer, and release
|
|
1632
|
+
// its exact token before the drain proceeds to the next record. A
|
|
1633
|
+
// terminal route drains through this same code.
|
|
1634
|
+
deliverDuplexChannelChunk(listener, record.chunk);
|
|
1635
|
+
releaseRouteToken(route, record.token);
|
|
1636
|
+
}
|
|
1637
|
+
// The buffered records are gone, so the route no longer holds terminal
|
|
1638
|
+
// bytes for a late listener.
|
|
1639
|
+
terminalDuplexRoutes.delete(route);
|
|
1640
|
+
}
|
|
1641
|
+
},
|
|
1642
|
+
write(data) {
|
|
1643
|
+
const sid = route.workerSessionId;
|
|
1644
|
+
if (route.state !== "open" || !sid)
|
|
1645
|
+
return;
|
|
1646
|
+
if (data.byteLength > maxDuplexChannelWriteChars) {
|
|
1647
|
+
// The write is larger than the size bound. End the route before the
|
|
1648
|
+
// write reaches the worker.
|
|
1649
|
+
void terminalizeDuplexChannelRoute(route);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
// Encode the raw bytes to the wire-safe base64 form (JSON carries no
|
|
1653
|
+
// binary type) and pass the exact raw byte count separately, so the
|
|
1654
|
+
// pending-write ledger reservation charges the real payload bytes, not
|
|
1655
|
+
// the inflated base64 string length.
|
|
1656
|
+
sendBoundedRequest("duplexChannelWrite", {
|
|
1657
|
+
hostRouteId: route.hostRouteId,
|
|
1658
|
+
workerSessionId: sid,
|
|
1659
|
+
data: encodeChannelBytes(data),
|
|
1660
|
+
}, data.byteLength);
|
|
1661
|
+
},
|
|
1662
|
+
wait() {
|
|
1663
|
+
return waitPromise;
|
|
1664
|
+
},
|
|
1665
|
+
kill() {
|
|
1666
|
+
const sid = route.workerSessionId;
|
|
1667
|
+
if (!sid)
|
|
1668
|
+
return;
|
|
1669
|
+
sendBoundedRequest("duplexChannelStop", { hostRouteId: route.hostRouteId, workerSessionId: sid });
|
|
1670
|
+
},
|
|
1671
|
+
async close() {
|
|
1672
|
+
await terminalizeDuplexChannelRoute(route);
|
|
702
1673
|
},
|
|
703
1674
|
};
|
|
704
1675
|
}
|
|
@@ -840,15 +1811,26 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
840
1811
|
routeExecuteLogNotification(notification);
|
|
841
1812
|
return;
|
|
842
1813
|
}
|
|
843
|
-
//
|
|
1814
|
+
// Login pseudo-terminal notifications: deliver output
|
|
844
1815
|
// and the exit to the one host-owned login route, bound by the worker session
|
|
845
1816
|
// identifier while the route is open.
|
|
846
|
-
if (notification.method ===
|
|
847
|
-
|
|
1817
|
+
if (notification.method === LOGIN_PTY_OUTPUT_NOTIFICATION) {
|
|
1818
|
+
routeLoginPtyOutput(notification);
|
|
848
1819
|
return;
|
|
849
1820
|
}
|
|
850
|
-
if (notification.method ===
|
|
851
|
-
|
|
1821
|
+
if (notification.method === LOGIN_PTY_EXIT_NOTIFICATION) {
|
|
1822
|
+
routeLoginPtyExit(notification);
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
// Duplex channel notifications: deliver data and the exit to the one
|
|
1826
|
+
// host-owned duplex route, bound by the worker session identifier while the
|
|
1827
|
+
// route is open.
|
|
1828
|
+
if (notification.method === DUPLEX_CHANNEL_DATA_NOTIFICATION) {
|
|
1829
|
+
routeDuplexChannelData(notification);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
if (notification.method === DUPLEX_CHANNEL_EXIT_NOTIFICATION) {
|
|
1833
|
+
routeDuplexChannelExit(notification);
|
|
852
1834
|
return;
|
|
853
1835
|
}
|
|
854
1836
|
// Stream notifications: forward to the stream bus via callback
|
|
@@ -921,6 +1903,17 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
921
1903
|
readline = createInterface({ input: child.stdout });
|
|
922
1904
|
readline.on("line", handleLine);
|
|
923
1905
|
}
|
|
1906
|
+
// Release the outstanding child-stdin transport tokens when the stdin stream
|
|
1907
|
+
// errors or closes, because each discards the stdin write buffer. The `error`
|
|
1908
|
+
// listener also stops an unhandled EPIPE from a child that closed its stdin.
|
|
1909
|
+
if (child.stdin) {
|
|
1910
|
+
child.stdin.on("error", () => {
|
|
1911
|
+
releaseAllPendingStdinWriteTokens();
|
|
1912
|
+
});
|
|
1913
|
+
child.stdin.on("close", () => {
|
|
1914
|
+
releaseAllPendingStdinWriteTokens();
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
924
1917
|
// Capture stderr for logging
|
|
925
1918
|
if (child.stderr) {
|
|
926
1919
|
stderrReadline = createInterface({ input: child.stderr });
|
|
@@ -958,12 +1951,20 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
958
1951
|
}
|
|
959
1952
|
childProcess = null;
|
|
960
1953
|
startedAt = null;
|
|
1954
|
+
// The worker exit discards the child-stdin write buffer, so release every
|
|
1955
|
+
// outstanding transport token. The RPC rejections below never release these
|
|
1956
|
+
// tokens; only this sweep, a stream flush, a stream error, or a stream close
|
|
1957
|
+
// releases them.
|
|
1958
|
+
releaseAllPendingStdinWriteTokens();
|
|
961
1959
|
// Reject all pending requests
|
|
962
1960
|
rejectAllPending(new Error(formatWorkerFailureMessage(`Worker process exited (code=${code}, signal=${signal})`, stderrExcerpt)));
|
|
963
1961
|
// Close the one login pseudo-terminal route with a fixed non-secret exit and
|
|
964
1962
|
// clear the route one time. The pending pseudo-terminal calls
|
|
965
1963
|
// already rejected through `rejectAllPending`.
|
|
966
|
-
|
|
1964
|
+
closeLoginPtyRouteOnWorkerExit();
|
|
1965
|
+
// Close the one duplex channel route the same way. The pending channel calls
|
|
1966
|
+
// already rejected through `rejectAllPending`.
|
|
1967
|
+
closeDuplexChannelRouteOnWorkerExit();
|
|
967
1968
|
// Emit synthetic close for any orphaned stream channels so SSE clients
|
|
968
1969
|
// are notified instead of hanging indefinitely.
|
|
969
1970
|
if (openStreamChannels.size > 0 && options.onStreamNotification) {
|
|
@@ -1216,7 +2217,7 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
1216
2217
|
// -----------------------------------------------------------------------
|
|
1217
2218
|
// RPC call implementation
|
|
1218
2219
|
// -----------------------------------------------------------------------
|
|
1219
|
-
function callInternal(method, params, timeoutMs, executeLogSink) {
|
|
2220
|
+
function callInternal(method, params, timeoutMs, executeLogSink, meterDuplexWrite = false) {
|
|
1220
2221
|
const rpcPromise = new Promise((resolve, reject) => {
|
|
1221
2222
|
if (!childProcess?.stdin?.writable) {
|
|
1222
2223
|
reject(new Error(`Cannot call "${method}" — worker for "${pluginId}" is not running`));
|
|
@@ -1278,14 +2279,22 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
1278
2279
|
...createRequest(method, params, id),
|
|
1279
2280
|
...(invocation ? { paperclipInvocation: invocation } : {}),
|
|
1280
2281
|
};
|
|
1281
|
-
sendMessage(request);
|
|
2282
|
+
sendMessage(request, meterDuplexWrite);
|
|
1282
2283
|
}
|
|
1283
2284
|
catch (err) {
|
|
1284
2285
|
clearTimeout(timer);
|
|
1285
2286
|
pendingRequests.delete(id);
|
|
1286
2287
|
clearInvocation(invocation);
|
|
1287
2288
|
clearExecuteRoute(invocation?.id);
|
|
1288
|
-
|
|
2289
|
+
if (err instanceof DuplexAggregateBytesExceededError) {
|
|
2290
|
+
// The transport reservation failed before the write. Reject with the
|
|
2291
|
+
// typed error unwrapped, so the duplex write caller ends the route
|
|
2292
|
+
// fail-closed with the aggregate marker.
|
|
2293
|
+
reject(err);
|
|
2294
|
+
}
|
|
2295
|
+
else {
|
|
2296
|
+
reject(new Error(`Failed to send "${method}" to worker: ${err instanceof Error ? err.message : String(err)}`));
|
|
2297
|
+
}
|
|
1289
2298
|
}
|
|
1290
2299
|
});
|
|
1291
2300
|
// Some call sites hand these promises across async boundaries before
|
|
@@ -1324,11 +2333,17 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
1324
2333
|
}
|
|
1325
2334
|
return callInternal(method, params, timeoutMs, executeLogSink);
|
|
1326
2335
|
},
|
|
1327
|
-
|
|
2336
|
+
openLoginPtySession(input) {
|
|
1328
2337
|
if (status !== "running" && status !== "starting") {
|
|
1329
2338
|
return Promise.reject(new Error(`Cannot open a login pseudo-terminal — worker for "${pluginId}" is ${status}`));
|
|
1330
2339
|
}
|
|
1331
|
-
return
|
|
2340
|
+
return openLoginPtySession(input);
|
|
2341
|
+
},
|
|
2342
|
+
openDuplexChannel(input) {
|
|
2343
|
+
if (status !== "running" && status !== "starting") {
|
|
2344
|
+
return Promise.reject(new Error(`Cannot open a duplex channel — worker for "${pluginId}" is ${status}`));
|
|
2345
|
+
}
|
|
2346
|
+
return openDuplexChannel(input);
|
|
1332
2347
|
},
|
|
1333
2348
|
notify(method, params) {
|
|
1334
2349
|
if (status !== "running")
|
|
@@ -1383,6 +2398,35 @@ export function createPluginWorkerHandle(pluginId, options) {
|
|
|
1383
2398
|
};
|
|
1384
2399
|
return handle;
|
|
1385
2400
|
}
|
|
2401
|
+
/**
|
|
2402
|
+
* The default process-scoped aggregate ceiling for concurrent duplex channel
|
|
2403
|
+
* routes. It caps the manager-wide resource, not one agent's run budget. The host
|
|
2404
|
+
* reports an explicit route-busy outcome when the ceiling is full.
|
|
2405
|
+
*/
|
|
2406
|
+
export const DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES = 128;
|
|
2407
|
+
/**
|
|
2408
|
+
* Build one process-scoped aggregate route-slot controller. The controller holds a
|
|
2409
|
+
* strictly positive integer ceiling and a live count. `tryAcquire` reserves one
|
|
2410
|
+
* slot only when a slot is free, so the count never passes the ceiling.
|
|
2411
|
+
*/
|
|
2412
|
+
export function createDuplexRouteSlotController(maxRoutes) {
|
|
2413
|
+
const ceiling = typeof maxRoutes === "number" && Number.isInteger(maxRoutes) && maxRoutes > 0
|
|
2414
|
+
? maxRoutes
|
|
2415
|
+
: DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES;
|
|
2416
|
+
let active = 0;
|
|
2417
|
+
return {
|
|
2418
|
+
tryAcquire() {
|
|
2419
|
+
if (active >= ceiling)
|
|
2420
|
+
return false;
|
|
2421
|
+
active += 1;
|
|
2422
|
+
return true;
|
|
2423
|
+
},
|
|
2424
|
+
release() {
|
|
2425
|
+
if (active > 0)
|
|
2426
|
+
active -= 1;
|
|
2427
|
+
},
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
1386
2430
|
/**
|
|
1387
2431
|
* Create a new PluginWorkerManager.
|
|
1388
2432
|
*
|
|
@@ -1414,6 +2458,15 @@ export function createPluginWorkerManager(managerOptions) {
|
|
|
1414
2458
|
const workers = new Map();
|
|
1415
2459
|
/** Per-plugin startup locks to prevent concurrent spawn races. */
|
|
1416
2460
|
const startupLocks = new Map();
|
|
2461
|
+
// The one shared, process-scoped aggregate route-slot controller. The manager
|
|
2462
|
+
// injects it into every worker handle, so the duplex route ceiling counts every
|
|
2463
|
+
// concurrent route across the process, not one agent's setting.
|
|
2464
|
+
const duplexRouteSlots = createDuplexRouteSlotController(managerOptions?.maxConcurrentDuplexRoutes);
|
|
2465
|
+
// The one shared, process-owned aggregate byte ledger. The manager injects the
|
|
2466
|
+
// same object into every worker handle, so one gauge bounds the aggregate
|
|
2467
|
+
// retained bytes across every route in the process. It is `null` when the
|
|
2468
|
+
// process root injected no ledger.
|
|
2469
|
+
const duplexAggregateByteLedger = managerOptions?.duplexAggregateByteLedger ?? null;
|
|
1417
2470
|
return {
|
|
1418
2471
|
async startWorker(pluginId, options) {
|
|
1419
2472
|
// Mutex: if a start is already in-flight for this plugin, wait for it
|
|
@@ -1426,7 +2479,14 @@ export function createPluginWorkerManager(managerOptions) {
|
|
|
1426
2479
|
if (existing && existing.status !== "stopped") {
|
|
1427
2480
|
throw new Error(`Worker already registered for plugin "${pluginId}" (status: ${existing.status})`);
|
|
1428
2481
|
}
|
|
1429
|
-
const handle = createPluginWorkerHandle(pluginId,
|
|
2482
|
+
const handle = createPluginWorkerHandle(pluginId, {
|
|
2483
|
+
// Inject the shared process-scoped route-slot controller and the shared
|
|
2484
|
+
// process-owned aggregate byte ledger, unless the caller already supplied
|
|
2485
|
+
// its own (a test may inject its own).
|
|
2486
|
+
duplexRouteSlots,
|
|
2487
|
+
duplexAggregateByteLedger,
|
|
2488
|
+
...options,
|
|
2489
|
+
});
|
|
1430
2490
|
workers.set(pluginId, handle);
|
|
1431
2491
|
// Subscribe to crash/ready events for live event forwarding
|
|
1432
2492
|
if (managerOptions?.onWorkerEvent) {
|
|
@@ -1505,12 +2565,12 @@ export function createPluginWorkerManager(managerOptions) {
|
|
|
1505
2565
|
}
|
|
1506
2566
|
return handle.call(method, params, timeoutMs, executeLogSink);
|
|
1507
2567
|
},
|
|
1508
|
-
|
|
2568
|
+
openLoginPtySession(pluginId, input) {
|
|
1509
2569
|
const handle = workers.get(pluginId);
|
|
1510
2570
|
if (!handle) {
|
|
1511
2571
|
return Promise.reject(new Error(`No worker registered for plugin "${pluginId}"`));
|
|
1512
2572
|
}
|
|
1513
|
-
return handle.
|
|
2573
|
+
return handle.openLoginPtySession(input);
|
|
1514
2574
|
},
|
|
1515
2575
|
};
|
|
1516
2576
|
}
|