@gajae-code/coding-agent 0.13.3 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +184 -2
- package/README.md +2 -0
- package/bin/gajaessi.js +4 -0
- package/dist/types/async/job-manager.d.ts +55 -2
- package/dist/types/async/support.d.ts +14 -0
- package/dist/types/capability/mcp.d.ts +6 -0
- package/dist/types/capability/types.d.ts +6 -0
- package/dist/types/cli/accounts-cli.d.ts +19 -0
- package/dist/types/cli/args.d.ts +3 -0
- package/dist/types/cli/config-cli.d.ts +6 -0
- package/dist/types/cli/crash-cli.d.ts +10 -0
- package/dist/types/cli/customize-doctor.d.ts +145 -0
- package/dist/types/cli/daemon-cli.d.ts +3 -1
- package/dist/types/cli/mcp-cli.d.ts +11 -0
- package/dist/types/cli/notify-cli.d.ts +1 -1
- package/dist/types/cli/root-flags.d.ts +12 -0
- package/dist/types/cli/setup-cli.d.ts +3 -1
- package/dist/types/cli/skills-cli.d.ts +2 -1
- package/dist/types/cli/update-cli.d.ts +19 -1
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/commands/accounts.d.ts +40 -0
- package/dist/types/commands/crash.d.ts +24 -0
- package/dist/types/commands/customize.d.ts +22 -0
- package/dist/types/commands/daemon.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +6 -0
- package/dist/types/commands/setup.d.ts +6 -0
- package/dist/types/commands/skills.d.ts +6 -1
- package/dist/types/commands/update.d.ts +7 -0
- package/dist/types/config/atomic-yaml-patch-worker.d.ts +25 -0
- package/dist/types/config/atomic-yaml-patch.d.ts +62 -2
- package/dist/types/config/config-schema-version.d.ts +10 -0
- package/dist/types/config/file-lock.d.ts +18 -0
- package/dist/types/config/model-profile-activation.d.ts +3 -1
- package/dist/types/config/model-registry.d.ts +9 -1
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +70 -6
- package/dist/types/config/settings.d.ts +63 -0
- package/dist/types/config/skill-settings-defaults.d.ts +26 -0
- package/dist/types/config/task-delegation.d.ts +39 -0
- package/dist/types/config/update-channel.d.ts +10 -0
- package/dist/types/coordinator-mcp/server.d.ts +5 -1
- package/dist/types/crash/index-store.d.ts +87 -0
- package/dist/types/crash/nudge.d.ts +50 -0
- package/dist/types/crash/record-loader.d.ts +17 -0
- package/dist/types/crash/report.d.ts +134 -0
- package/dist/types/crash/sanitize.d.ts +25 -0
- package/dist/types/cursor.d.ts +13 -2
- package/dist/types/customization/import.d.ts +37 -0
- package/dist/types/customization/index.d.ts +4 -0
- package/dist/types/customization/inventory.d.ts +25 -0
- package/dist/types/customization/mutations.d.ts +32 -0
- package/dist/types/customization/types.d.ts +115 -0
- package/dist/types/daemon/control-types.d.ts +8 -1
- package/dist/types/daemon/operator-contract.d.ts +4 -4
- package/dist/types/discovery/claude.d.ts +39 -1
- package/dist/types/discovery/codex.d.ts +39 -1
- package/dist/types/discovery/index.d.ts +2 -0
- package/dist/types/discovery/mcp-compat.d.ts +30 -0
- package/dist/types/edit/renderer.d.ts +23 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +21 -4
- package/dist/types/extensibility/gjc-plugins/observability.d.ts +6 -1
- package/dist/types/extensibility/gjc-plugins/registry.d.ts +8 -1
- package/dist/types/extensibility/hooks/loader.d.ts +19 -5
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +22 -1
- package/dist/types/extensibility/skill-management.d.ts +107 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +3 -1
- package/dist/types/gjc-runtime/launch-worktree.d.ts +8 -0
- package/dist/types/gjc-runtime/linux-proc.d.ts +3 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +10 -6
- package/dist/types/gjc-runtime/session-state-lock.d.ts +104 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +120 -2
- package/dist/types/gjc-runtime/state-writer.d.ts +10 -1
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -2
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -6
- package/dist/types/gjc-runtime/workflow-settings.d.ts +109 -0
- package/dist/types/hooks/events.d.ts +60 -0
- package/dist/types/hooks/normalize.d.ts +83 -0
- package/dist/types/hooks/skill-state.d.ts +9 -2
- package/dist/types/main.d.ts +1 -1
- package/dist/types/modes/acp/abort-scope.d.ts +13 -0
- package/dist/types/modes/acp/acp-agent.d.ts +7 -4
- package/dist/types/modes/components/customization/customization-dashboard.d.ts +44 -0
- package/dist/types/modes/components/customization/import-wizard.d.ts +21 -0
- package/dist/types/modes/components/customization/index.d.ts +1 -0
- package/dist/types/modes/components/gajae-pet-widget.d.ts +8 -30
- package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
- package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
- package/dist/types/modes/components/oauth-selector.d.ts +10 -4
- package/dist/types/modes/components/pet-capability.d.ts +11 -25
- package/dist/types/modes/components/status-line/gh.d.ts +6 -2
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
- package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
- package/dist/types/runtime-mcp/config.d.ts +9 -0
- package/dist/types/runtime-mcp/manager.d.ts +26 -1
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
- package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
- package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
- package/dist/types/runtime-mcp/protocol.d.ts +278 -0
- package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
- package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
- package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
- package/dist/types/runtime-mcp/types.d.ts +102 -10
- package/dist/types/sdk/acp/adapter.d.ts +26 -5
- package/dist/types/sdk/broker/broker.d.ts +3 -55
- package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
- package/dist/types/sdk/broker/session-index.d.ts +14 -5
- package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
- package/dist/types/sdk/bus/config.d.ts +11 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
- package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
- package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
- package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
- package/dist/types/sdk/bus/index.d.ts +85 -99
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
- package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
- package/dist/types/sdk/bus/notification-service.d.ts +52 -0
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
- package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
- package/dist/types/sdk/bus/session-control.d.ts +25 -1
- package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
- package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
- package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
- package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
- package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
- package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
- package/dist/types/sdk/cli/rows.d.ts +2 -11
- package/dist/types/sdk/cli/session-cli.d.ts +15 -25
- package/dist/types/sdk/client/client.d.ts +97 -1
- package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +18 -5
- package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
- package/dist/types/sdk/host/host.d.ts +15 -1
- package/dist/types/sdk/host/query/handlers.d.ts +5 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/host/session-runtime.d.ts +127 -7
- package/dist/types/sdk/host/types.d.ts +1 -1
- package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
- package/dist/types/sdk/index.d.ts +2 -1
- package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
- package/dist/types/sdk/lifecycle/client.d.ts +59 -0
- package/dist/types/sdk/lifecycle/index.d.ts +3 -0
- package/dist/types/sdk/lifecycle/service.d.ts +232 -0
- package/dist/types/sdk/mcp/server.d.ts +9 -6
- package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
- package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
- package/dist/types/sdk/prompt-status.d.ts +10 -3
- package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
- package/dist/types/sdk/receipt-state.d.ts +17 -0
- package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
- package/dist/types/sdk/router/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +152 -0
- package/dist/types/sdk/session-list.d.ts +22 -0
- package/dist/types/sdk/session-reconnect.d.ts +43 -1
- package/dist/types/sdk/session.d.ts +34 -1
- package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
- package/dist/types/session/account-inventory.d.ts +86 -0
- package/dist/types/session/agent-session.d.ts +145 -6
- package/dist/types/session/agent-storage.d.ts +10 -0
- package/dist/types/session/coordinator-tool-label.d.ts +28 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
- package/dist/types/session/messages.d.ts +1 -1
- package/dist/types/session/secure-token-file.d.ts +3 -0
- package/dist/types/session/session-manager.d.ts +40 -1
- package/dist/types/session/startup-auth-config.d.ts +44 -0
- package/dist/types/session/terminal-abort.d.ts +378 -0
- package/dist/types/session/yield-queue.d.ts +7 -0
- package/dist/types/session-import/claude.d.ts +26 -0
- package/dist/types/session-import/codex.d.ts +88 -0
- package/dist/types/session-import/command.d.ts +35 -0
- package/dist/types/session-import/detect.d.ts +22 -0
- package/dist/types/session-import/index.d.ts +7 -0
- package/dist/types/session-import/provider-codex.d.ts +22 -0
- package/dist/types/session-import/provider-service.d.ts +38 -0
- package/dist/types/session-import/redact.d.ts +23 -0
- package/dist/types/session-import/service.d.ts +47 -0
- package/dist/types/session-import/types.d.ts +120 -0
- package/dist/types/setup/paseo/check.d.ts +16 -0
- package/dist/types/setup/paseo/install-saga.d.ts +115 -0
- package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
- package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
- package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
- package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
- package/dist/types/setup/paseo/provider-config.d.ts +35 -0
- package/dist/types/setup/paseo/remove.d.ts +14 -0
- package/dist/types/setup/paseo/result-types.d.ts +59 -0
- package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
- package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
- package/dist/types/skill-state/active-state.d.ts +5 -1
- package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
- package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
- package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
- package/dist/types/slash-commands/types.d.ts +7 -1
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/tools/ask-answer-registry.d.ts +6 -3
- package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
- package/dist/types/tools/bash.d.ts +2 -6
- package/dist/types/tools/browser/attach.d.ts +11 -0
- package/dist/types/tools/browser/launch.d.ts +20 -0
- package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
- package/dist/types/tools/browser/render.d.ts +1 -0
- package/dist/types/tools/browser.d.ts +3 -1
- package/dist/types/tools/image-gen.d.ts +9 -2
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/tools/monitor.d.ts +1 -1
- package/dist/types/tools/skill-discovery.d.ts +8 -1
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
- package/dist/types/utils/herdr-pane.d.ts +76 -0
- package/package.json +19 -9
- package/scripts/build-binary.ts +2 -1
- package/scripts/build-sdk-package-smoke.ts +2 -13
- package/scripts/capture-sticky-viewport-showcase.ts +0 -1
- package/scripts/compile-args.ts +2 -0
- package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
- package/scripts/generate-sdk-operation-inventory.ts +28 -0
- package/scripts/generate-telegram-baseline-manifest.ts +1 -0
- package/scripts/generate-tool-catalog.ts +6 -4
- package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
- package/src/async/job-manager.ts +352 -17
- package/src/async/support.ts +25 -0
- package/src/autoresearch/dashboard.ts +28 -13
- package/src/capability/index.ts +3 -1
- package/src/capability/mcp.ts +6 -0
- package/src/capability/types.ts +6 -0
- package/src/cli/accounts-cli.ts +395 -0
- package/src/cli/args.ts +23 -0
- package/src/cli/auth-broker-cli.ts +136 -66
- package/src/cli/auth-gateway-cli.ts +94 -70
- package/src/cli/config-cli.ts +27 -2
- package/src/cli/crash-cli.ts +78 -0
- package/src/cli/customize-doctor.ts +1605 -0
- package/src/cli/daemon-cli.ts +12 -3
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/mcp-cli.ts +128 -60
- package/src/cli/notify-cli.ts +8 -14
- package/src/cli/root-flags.ts +8 -0
- package/src/cli/setup-cli.ts +145 -0
- package/src/cli/skills-cli.ts +38 -2
- package/src/cli/update-cli.ts +167 -12
- package/src/cli.ts +4 -0
- package/src/commands/accounts.ts +71 -0
- package/src/commands/crash.ts +35 -0
- package/src/commands/customize.ts +84 -0
- package/src/commands/daemon.ts +2 -2
- package/src/commands/deep-interview.ts +2 -0
- package/src/commands/launch.ts +1 -0
- package/src/commands/mcp.ts +12 -7
- package/src/commands/ralplan.ts +2 -0
- package/src/commands/sdk.ts +65 -32
- package/src/commands/setup.ts +5 -0
- package/src/commands/skills.ts +14 -4
- package/src/commands/ultragoal.ts +10 -1
- package/src/commands/update.ts +26 -15
- package/src/config/atomic-yaml-patch-worker.ts +57 -0
- package/src/config/atomic-yaml-patch.ts +769 -29
- package/src/config/config-schema-version.ts +10 -0
- package/src/config/file-lock.ts +70 -1
- package/src/config/mcp-schema.json +52 -14
- package/src/config/model-profile-activation.ts +20 -1
- package/src/config/model-profiles.ts +29 -19
- package/src/config/model-registry.ts +132 -21
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +76 -6
- package/src/config/settings.ts +4038 -199
- package/src/config/skill-settings-defaults.ts +41 -3
- package/src/config/task-delegation.ts +87 -0
- package/src/config/update-channel.ts +23 -0
- package/src/coordinator-mcp/server.ts +377 -308
- package/src/crash/index-store.ts +723 -0
- package/src/crash/nudge.ts +89 -0
- package/src/crash/record-loader.ts +149 -0
- package/src/crash/report.ts +551 -0
- package/src/crash/sanitize.ts +129 -0
- package/src/cursor.ts +36 -10
- package/src/customization/import.ts +946 -0
- package/src/customization/index.ts +4 -0
- package/src/customization/inventory.ts +335 -0
- package/src/customization/mutations.ts +141 -0
- package/src/customization/types.ts +223 -0
- package/src/daemon/control-types.ts +8 -1
- package/src/daemon/operator-contract.ts +6 -6
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
- package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
- package/src/discovery/claude-hooks.ts +38 -0
- package/src/discovery/claude.ts +142 -209
- package/src/discovery/codex-hooks.ts +38 -0
- package/src/discovery/codex.ts +132 -198
- package/src/discovery/helpers.ts +8 -1
- package/src/discovery/index.ts +2 -0
- package/src/discovery/mcp-compat.ts +234 -0
- package/src/discovery/mcp-json.ts +6 -0
- package/src/edit/renderer.ts +40 -0
- package/src/edit/streaming.ts +4 -2
- package/src/exec/bash-executor.ts +24 -20
- package/src/export/html/index.ts +51 -2
- package/src/extensibility/extensions/loader.ts +9 -1
- package/src/extensibility/extensions/runner.ts +17 -4
- package/src/extensibility/extensions/types.ts +31 -6
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
- package/src/extensibility/gjc-plugins/installer.ts +9 -5
- package/src/extensibility/gjc-plugins/observability.ts +8 -2
- package/src/extensibility/gjc-plugins/registry.ts +13 -2
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
- package/src/extensibility/gjc-plugins/schema.ts +222 -39
- package/src/extensibility/hooks/loader.ts +154 -10
- package/src/extensibility/runtime-skill-discovery.ts +257 -60
- package/src/extensibility/skill-management.ts +368 -0
- package/src/extensibility/skills.ts +52 -16
- package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
- package/src/gjc-runtime/launch-worktree.ts +56 -2
- package/src/gjc-runtime/linux-proc.ts +7 -1
- package/src/gjc-runtime/ralplan-runtime.ts +100 -164
- package/src/gjc-runtime/session-state-lock.ts +992 -0
- package/src/gjc-runtime/session-state-sidecar.ts +644 -142
- package/src/gjc-runtime/state-writer.ts +39 -14
- package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
- package/src/gjc-runtime/ultragoal-guard.ts +11 -4
- package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
- package/src/gjc-runtime/workflow-settings.ts +596 -0
- package/src/goals/tools/goal-tool.ts +6 -1
- package/src/hooks/events.ts +291 -0
- package/src/hooks/native-skill-hook.ts +4 -0
- package/src/hooks/normalize.ts +369 -0
- package/src/hooks/skill-state.ts +165 -46
- package/src/internal-urls/docs-index.generated.ts +40 -32
- package/src/main.ts +32 -5
- package/src/migrate/mcp-mapper.ts +25 -2
- package/src/modes/acp/abort-scope.ts +29 -0
- package/src/modes/acp/acp-agent.ts +423 -98
- package/src/modes/components/assistant-message.ts +6 -1
- package/src/modes/components/customization/customization-dashboard.ts +443 -0
- package/src/modes/components/customization/import-wizard.ts +372 -0
- package/src/modes/components/customization/index.ts +1 -0
- package/src/modes/components/gajae-pet-widget.ts +539 -213
- package/src/modes/components/irc-sidebar.ts +11 -0
- package/src/modes/components/iterm-pet-transport.ts +585 -0
- package/src/modes/components/oauth-selector.ts +277 -86
- package/src/modes/components/pet-capability.ts +39 -40
- package/src/modes/components/settings-selector.ts +9 -0
- package/src/modes/components/status-line/gh.ts +45 -1
- package/src/modes/components/status-line/segments.ts +16 -1
- package/src/modes/components/tool-status-header.ts +13 -3
- package/src/modes/controllers/command-controller.ts +20 -8
- package/src/modes/controllers/extension-ui-controller.ts +56 -257
- package/src/modes/controllers/input-controller.ts +10 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
- package/src/modes/controllers/selector-controller.ts +177 -72
- package/src/modes/controllers/stt-controller.ts +3 -3
- package/src/modes/interactive-mode.ts +88 -16
- package/src/modes/print-mode.ts +35 -6
- package/src/modes/runtime-init.ts +21 -68
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/injected-user-submission.ts +25 -0
- package/src/modes/utils/terminal-bell.ts +15 -1
- package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/critic.md +1 -1
- package/src/prompts/system/rlm-research.md +1 -1
- package/src/prompts/system/system-prompt.md +0 -2
- package/src/prompts/tools/bash.md +7 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/cron.md +6 -6
- package/src/prompts/tools/read.md +0 -2
- package/src/prompts/tools/skill-discovery.md +4 -2
- package/src/runtime-mcp/client.ts +427 -23
- package/src/runtime-mcp/config.ts +37 -5
- package/src/runtime-mcp/manager-pool.test.ts +56 -0
- package/src/runtime-mcp/manager.ts +52 -2
- package/src/runtime-mcp/oauth-discovery.ts +13 -0
- package/src/runtime-mcp/oauth-flow.ts +46 -1
- package/src/runtime-mcp/pool-key.ts +5 -1
- package/src/runtime-mcp/pool.test.ts +9 -0
- package/src/runtime-mcp/protocol.test.ts +353 -0
- package/src/runtime-mcp/protocol.ts +624 -0
- package/src/runtime-mcp/scope-config.ts +78 -0
- package/src/runtime-mcp/tool-bridge.ts +44 -10
- package/src/runtime-mcp/transports/http.ts +84 -13
- package/src/runtime-mcp/types.ts +123 -12
- package/src/sdk/acp/adapter.ts +328 -97
- package/src/sdk/broker/broker.ts +160 -741
- package/src/sdk/broker/ensure.ts +9 -1
- package/src/sdk/broker/lifecycle.ts +510 -116
- package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
- package/src/sdk/broker/process-incarnation.ts +19 -0
- package/src/sdk/broker/session-index.ts +516 -117
- package/src/sdk/broker/startup-failure.ts +28 -5
- package/src/sdk/broker/transport.ts +1 -13
- package/src/sdk/bus/chat-daemon-cli.ts +4 -2
- package/src/sdk/bus/chat-daemon-control.ts +67 -6
- package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
- package/src/sdk/bus/config.ts +13 -0
- package/src/sdk/bus/conversation-store.ts +59 -171
- package/src/sdk/bus/daemon-paths.ts +20 -0
- package/src/sdk/bus/discord-conversation.ts +2 -0
- package/src/sdk/bus/discord-daemon.ts +524 -126
- package/src/sdk/bus/discord-live-provider.ts +206 -79
- package/src/sdk/bus/existing-thread-readiness.ts +4 -97
- package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
- package/src/sdk/bus/index.ts +2480 -456
- package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
- package/src/sdk/bus/lifecycle-commands.ts +2 -2
- package/src/sdk/bus/managed-daemon.ts +2 -145
- package/src/sdk/bus/notification-service.ts +196 -0
- package/src/sdk/bus/operator-runtime.ts +1 -1
- package/src/sdk/bus/prompt-reconciliation.ts +24 -15
- package/src/sdk/bus/reconciliation-store.ts +553 -24
- package/src/sdk/bus/session-control.ts +49 -3
- package/src/sdk/bus/slack-conversation.ts +4 -0
- package/src/sdk/bus/slack-daemon.ts +733 -134
- package/src/sdk/bus/slack-live-provider.ts +65 -15
- package/src/sdk/bus/slack-provider.ts +4 -0
- package/src/sdk/bus/slack-thread-binding.ts +9 -104
- package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
- package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
- package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
- package/src/sdk/bus/telegram-daemon-control.ts +3 -13
- package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
- package/src/sdk/bus/telegram-daemon.ts +2552 -2280
- package/src/sdk/bus/telegram-reference.ts +1 -226
- package/src/sdk/bus/telegram-setup.ts +2 -2
- package/src/sdk/bus/threaded-render.ts +1 -0
- package/src/sdk/bus/topic-registry.ts +279 -146
- package/src/sdk/cli/rows.ts +2 -35
- package/src/sdk/cli/session-cli.ts +776 -700
- package/src/sdk/client/client.ts +892 -1
- package/src/sdk/host/control/dispatch.ts +93 -58
- package/src/sdk/host/control/index.ts +1 -1
- package/src/sdk/host/control/operations.ts +18 -10
- package/src/sdk/host/control/runtime-gate.ts +40 -0
- package/src/sdk/host/host.ts +63 -12
- package/src/sdk/host/query/handlers.ts +66 -7
- package/src/sdk/host/reverse-leases.ts +2 -2
- package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
- package/src/sdk/host/session-runtime.test.ts +2787 -34
- package/src/sdk/host/session-runtime.ts +1489 -101
- package/src/sdk/host/surface-policy.ts +12 -8
- package/src/sdk/host/types.ts +1 -1
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
- package/src/sdk/host/websocket-transport.ts +85 -50
- package/src/sdk/index.ts +2 -1
- package/src/sdk/lifecycle/broker-client.ts +53 -0
- package/src/sdk/lifecycle/client.ts +234 -0
- package/src/sdk/lifecycle/index.ts +3 -0
- package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
- package/src/sdk/lifecycle/service.ts +720 -0
- package/src/sdk/mcp/server.ts +226 -188
- package/src/sdk/prompt-deadline-lease.ts +55 -0
- package/src/sdk/prompt-deadline-manager.ts +148 -0
- package/src/sdk/prompt-status.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +272 -129
- package/src/sdk/protocol/operation-registry.ts +71 -8
- package/src/sdk/receipt-state.ts +43 -0
- package/src/sdk/reconciliation-extensions.ts +14 -0
- package/src/sdk/router/index.ts +1 -0
- package/src/sdk/router/session-router.ts +1658 -0
- package/src/sdk/session-list.ts +93 -0
- package/src/sdk/session-reconnect.ts +45 -1
- package/src/sdk/session.ts +716 -120
- package/src/sdk/transport/serve-cli.ts +18 -34
- package/src/session/account-inventory.ts +603 -0
- package/src/session/agent-session.ts +2857 -582
- package/src/session/agent-storage.ts +35 -0
- package/src/session/blob-store.ts +32 -1
- package/src/session/coordinator-tool-label.ts +40 -0
- package/src/session/internal/managed-session-scope.ts +4 -0
- package/src/session/internal/managed-session-storage.ts +173 -12
- package/src/session/messages.ts +1 -1
- package/src/session/secure-token-file.ts +80 -0
- package/src/session/session-manager.ts +730 -58
- package/src/session/session-storage.ts +25 -16
- package/src/session/startup-auth-config.ts +297 -0
- package/src/session/terminal-abort.ts +1253 -0
- package/src/session/yield-queue.ts +57 -15
- package/src/session-import/claude.ts +334 -0
- package/src/session-import/codex.ts +1033 -0
- package/src/session-import/command.ts +77 -0
- package/src/session-import/detect.ts +133 -0
- package/src/session-import/index.ts +7 -0
- package/src/session-import/provider-codex.ts +327 -0
- package/src/session-import/provider-service.ts +457 -0
- package/src/session-import/redact.ts +128 -0
- package/src/session-import/service.ts +1264 -0
- package/src/session-import/types.ts +148 -0
- package/src/setup/paseo/check.ts +213 -0
- package/src/setup/paseo/install-saga.ts +293 -0
- package/src/setup/paseo/json-publisher.ts +263 -0
- package/src/setup/paseo/orchestration-preferences.ts +86 -0
- package/src/setup/paseo/paseo-ownership.ts +224 -0
- package/src/setup/paseo/paseo-setup.ts +253 -0
- package/src/setup/paseo/provider-config.ts +139 -0
- package/src/setup/paseo/remove.ts +183 -0
- package/src/setup/paseo/result-types.ts +79 -0
- package/src/setup/paseo/setup-deps.ts +175 -0
- package/src/setup/paseo/skills-bridge.ts +260 -0
- package/src/skill-state/active-state.ts +21 -11
- package/src/slash-commands/acp-builtins.ts +9 -1
- package/src/slash-commands/builtin-registry.ts +98 -16
- package/src/slash-commands/helpers/credential-switch.ts +143 -0
- package/src/slash-commands/helpers/mcp.ts +9 -3
- package/src/slash-commands/helpers/usage-report.ts +61 -56
- package/src/slash-commands/types.ts +7 -2
- package/src/task/executor.ts +30 -11
- package/src/task/index.ts +66 -7
- package/src/task/render.ts +23 -9
- package/src/tools/ask-answer-registry.ts +23 -7
- package/src/tools/ask.ts +8 -4
- package/src/tools/bash-sleep-advisory.ts +115 -0
- package/src/tools/bash.ts +98 -18
- package/src/tools/browser/attach.ts +71 -3
- package/src/tools/browser/launch.ts +86 -13
- package/src/tools/browser/profile-discovery.ts +57 -7
- package/src/tools/browser/render.ts +8 -1
- package/src/tools/browser.ts +130 -21
- package/src/tools/computer.enforcement.test.ts +1 -1
- package/src/tools/computer.ts +3 -2
- package/src/tools/cron.ts +12 -1
- package/src/tools/image-gen.ts +27 -16
- package/src/tools/index.ts +8 -0
- package/src/tools/job.ts +33 -6
- package/src/tools/monitor.ts +41 -2
- package/src/tools/skill-discovery.ts +21 -29
- package/src/tools/subagent.ts +142 -24
- package/src/tools/tool-catalog.generated.ts +6 -6
- package/src/tools/ultragoal-ask-guard.ts +10 -3
- package/src/utils/herdr-pane.ts +345 -0
- package/src/utils/title-generator.ts +2 -0
- package/src/web/scrapers/index.ts +6 -4
- package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
- package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
- package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
- package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
- package/dist/types/runtime/lazy-service.test.d.ts +0 -1
- package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
- package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
- package/dist/types/runtime-mcp/config.test.d.ts +0 -1
- package/dist/types/runtime-mcp/http.test.d.ts +0 -1
- package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
- package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
- package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
- package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
- package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
- package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
- package/dist/types/sdk/elevation/audit.d.ts +0 -93
- package/dist/types/sdk/elevation/capability.d.ts +0 -3
- package/dist/types/sdk/elevation/digest.d.ts +0 -72
- package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
- package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
- package/dist/types/sdk/elevation/owner.d.ts +0 -48
- package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
- package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
- package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
- package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
- package/dist/types/session/auth-broker-config.d.ts +0 -13
- package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
- package/dist/types/skills/skills.test.d.ts +0 -1
- package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
- package/dist/types/task/token-log.test.d.ts +0 -1
- package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
- package/dist/types/tools/descriptors.test.d.ts +0 -1
- package/scripts/g005-daemon-smoke.ts +0 -180
- package/scripts/g011-daemon-path-smoke.ts +0 -153
- package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
- package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
- package/src/sdk/bus/telegram-cli.ts +0 -212
- package/src/sdk/elevation/allowlist.ts +0 -54
- package/src/sdk/elevation/audit.ts +0 -208
- package/src/sdk/elevation/capability.ts +0 -41
- package/src/sdk/elevation/digest.ts +0 -129
- package/src/sdk/elevation/dispatch-receipt.ts +0 -184
- package/src/sdk/elevation/grant-ledger.ts +0 -1142
- package/src/sdk/elevation/owner.ts +0 -109
- package/src/session/auth-broker-config.ts +0 -109
- /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
- /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
- /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import * as fsSync from "node:fs";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import type {
|
|
6
|
+
NativeDirectoryTreeResult,
|
|
7
|
+
NativeDirectoryTreeSnapshot,
|
|
8
|
+
NativeExactUnlinkResult,
|
|
9
|
+
} from "@gajae-code/natives";
|
|
10
|
+
import { genericFileLockDirIsStale, processStartTime as portableProcessStartTime } from "../config/file-lock";
|
|
11
|
+
import { readLinuxProcStartTimeSync } from "./linux-proc";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The one lock implementation for a coordinator-shared session state file.
|
|
15
|
+
*
|
|
16
|
+
* The Coordinator MCP server and the runtime sidecar both read-modify-write the same
|
|
17
|
+
* `session-states/<id>.json`, so they must contend on one lock with one on-disk owner
|
|
18
|
+
* format. That format is the regular-file `<file>.lock` owner JSON the base Coordinator
|
|
19
|
+
* wrote. The base RUNTIME did not use it: it guarded this path with the generic
|
|
20
|
+
* directory-style lock, so a `<file>.lock/` DIRECTORY left by an older runtime is a real
|
|
21
|
+
* on-disk shape this code still has to survive.
|
|
22
|
+
*
|
|
23
|
+
* Both shapes are therefore handled, and neither is guessed at: `lstat` decides which one
|
|
24
|
+
* is present, a directory is evaluated by the generic protocol's own implementation, and
|
|
25
|
+
* anything that is neither a regular file nor a directory fails closed — a symlink, FIFO,
|
|
26
|
+
* socket, or device at this path is not a lock this code wrote, and reading or removing
|
|
27
|
+
* it would follow an attacker-chosen target.
|
|
28
|
+
*/
|
|
29
|
+
const LOCK_ACQUIRE_ATTEMPTS = 12_000;
|
|
30
|
+
const LOCK_ACQUIRE_RETRY_MS = 5;
|
|
31
|
+
const LOCK_STALE_MS = 30_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The claim that serializes PATHNAME TRANSITIONS of `<file>.lock` among current writers.
|
|
35
|
+
*
|
|
36
|
+
* `<file>.lock` cannot serialize its own creation and removal on its own: a base writer
|
|
37
|
+
* that only ever `open(..., "wx")`s the path is a real contender this protocol cannot
|
|
38
|
+
* exclude, and interleaving its create with a reclaimer's delete is what corrupts
|
|
39
|
+
* ownership. So every current transition of that pathname — acquire, stale reclaim,
|
|
40
|
+
* write-failure cleanup, release — is made under this separate claim. Only pathname
|
|
41
|
+
* bookkeeping happens inside it; the caller's state-file operation never does.
|
|
42
|
+
*
|
|
43
|
+
* The claim is itself a regular owner record at `<file>.lock.transition`, in the same
|
|
44
|
+
* format and with the same liveness rules as the record it guards: pid and start time are
|
|
45
|
+
* stamped into it, so a holder that is merely paused — descheduled, stalled on the
|
|
46
|
+
* filesystem, stopped in a debugger — is never broken by elapsed time. An ownerless claim
|
|
47
|
+
* broken by age reintroduces exactly the concurrent create/unlink it exists to prevent.
|
|
48
|
+
*
|
|
49
|
+
* It needs no claim of its own because its every removal is identity-bound: `exactUnlink`
|
|
50
|
+
* refuses unless the record is still byte-for-byte and inode-for-inode the one that was
|
|
51
|
+
* judged, which is the atomic compare-and-delete `fs` does not offer. That is also why the
|
|
52
|
+
* path is `<file>.lock.transition` and not the generic protocol's `.lock` directory — it
|
|
53
|
+
* stays distinct from `<file>.lock` (whose regular-file owner format base writers read is
|
|
54
|
+
* unchanged) and from the outer `locks/mutation.lock` (whose generic directory semantics
|
|
55
|
+
* are likewise untouched).
|
|
56
|
+
*/
|
|
57
|
+
const LOCK_TRANSITION_RESOURCE_SUFFIX = ".transition";
|
|
58
|
+
|
|
59
|
+
interface SessionStateLockOwner {
|
|
60
|
+
pid: number;
|
|
61
|
+
start_time: string;
|
|
62
|
+
token: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How a `<file>.lock` owner record can be opened safely on this platform.
|
|
67
|
+
*
|
|
68
|
+
* `posix-nofollow` proves the object's TYPE from the descriptor the bytes come off, so the
|
|
69
|
+
* pathname can change freely without ever being followed. Windows has neither `O_NOFOLLOW`
|
|
70
|
+
* nor `O_NONBLOCK`, so no such descriptor exists there and `windows-validated` brackets the
|
|
71
|
+
* open with `lstat`/`fstat` identity proofs instead. `unsupported` is neither, and the
|
|
72
|
+
* owner protocol fails closed rather than reading a pathname it cannot vouch for.
|
|
73
|
+
*/
|
|
74
|
+
export type SessionStateLockOwnerAccessStrategy = "posix-nofollow" | "windows-validated" | "unsupported";
|
|
75
|
+
|
|
76
|
+
const POSIX_NOFOLLOW_AVAILABLE =
|
|
77
|
+
typeof fsSync.constants.O_NOFOLLOW === "number" && typeof fsSync.constants.O_NONBLOCK === "number";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Select the safe owner-record access protocol for one platform/runtime pair.
|
|
81
|
+
*
|
|
82
|
+
* Platform identity comes first. A win32 runtime may expose numeric compatibility constants
|
|
83
|
+
* without providing POSIX no-follow semantics, so their mere presence must never select the
|
|
84
|
+
* POSIX path there.
|
|
85
|
+
*
|
|
86
|
+
* @internal Pure seam for cross-platform detection tests.
|
|
87
|
+
*/
|
|
88
|
+
export function detectedSessionStateLockOwnerAccessStrategy(
|
|
89
|
+
platform: NodeJS.Platform,
|
|
90
|
+
posixNoFollowAvailable: boolean,
|
|
91
|
+
): SessionStateLockOwnerAccessStrategy {
|
|
92
|
+
if (platform === "win32") return "windows-validated";
|
|
93
|
+
return posixNoFollowAvailable ? "posix-nofollow" : "unsupported";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const DETECTED_OWNER_ACCESS_STRATEGY = detectedSessionStateLockOwnerAccessStrategy(
|
|
97
|
+
process.platform,
|
|
98
|
+
POSIX_NOFOLLOW_AVAILABLE,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Test seams for the windows a reclaim passes through: the moment the path's TYPE has just
|
|
103
|
+
* been decided and its owner has not been read yet, the stale verdict, and the FINAL
|
|
104
|
+
* identity validation immediately before the unlink. Production code never sets them.
|
|
105
|
+
*/
|
|
106
|
+
export const SessionStateLockTestHooks: {
|
|
107
|
+
afterLockTypeDecision?: (lockFile: string) => void | Promise<void>;
|
|
108
|
+
afterStaleInspection?: (lockFile: string) => void | Promise<void>;
|
|
109
|
+
beforeStaleRemoval?: (lockFile: string) => void | Promise<void>;
|
|
110
|
+
afterTransitionStaleInspection?: (transitionFile: string) => void | Promise<void>;
|
|
111
|
+
beforeTransitionStaleRemoval?: (transitionFile: string) => void | Promise<void>;
|
|
112
|
+
afterLegacyDirectoryStaleVerdict?: (lockDir: string) => void | Promise<void>;
|
|
113
|
+
/**
|
|
114
|
+
* @internal Which owner-access strategy to exercise, so the Windows path can be proved
|
|
115
|
+
* on the test filesystem this suite actually runs on. Production code never sets it,
|
|
116
|
+
* and it is never serialized or exposed as runtime configuration.
|
|
117
|
+
*/
|
|
118
|
+
ownerAccessStrategy?: SessionStateLockOwnerAccessStrategy;
|
|
119
|
+
/**
|
|
120
|
+
* @internal How an owner pid is probed for liveness. Whether a signal is permitted is a
|
|
121
|
+
* property of the OS and this process's privileges, so it cannot be arranged on disk.
|
|
122
|
+
* Production code never sets it, and it is never serialized or exposed as runtime
|
|
123
|
+
* configuration.
|
|
124
|
+
*/
|
|
125
|
+
probeProcessSignal?: (pid: number) => void;
|
|
126
|
+
/**
|
|
127
|
+
* @internal Runs inside a NEW owner record's write, after the exclusive create has
|
|
128
|
+
* taken the pathname and before the record's bytes land. Throwing from it fails that
|
|
129
|
+
* write exactly as an I/O fault does, which is the only deterministic way to reach the
|
|
130
|
+
* write-failure cleanup and prove what that cleanup is authorized to delete.
|
|
131
|
+
* Production code never sets it, and it is never serialized or exposed as runtime
|
|
132
|
+
* configuration.
|
|
133
|
+
*/
|
|
134
|
+
ownerRecordWriteFault?: (file: string) => void | Promise<void>;
|
|
135
|
+
beforeLegacyDirectoryRemoval?: (lockDir: string) => void | Promise<void>;
|
|
136
|
+
} = {};
|
|
137
|
+
|
|
138
|
+
/** Raised when the lock could not be acquired; callers map it to their own refusal. */
|
|
139
|
+
export class SessionStateLockUnavailableError extends Error {
|
|
140
|
+
constructor(cause?: unknown) {
|
|
141
|
+
super("Coordinator session state lock is unavailable.");
|
|
142
|
+
this.name = "SessionStateLockUnavailableError";
|
|
143
|
+
if (cause !== undefined) this.cause = cause;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The identity-bound deletion primitives this lock is built on.
|
|
149
|
+
*
|
|
150
|
+
* There is no portable atomic compare-and-delete in `fs`: validating a record and then
|
|
151
|
+
* unlinking its PATHNAME is always two syscalls, and a successor that claims the path in
|
|
152
|
+
* between loses its brand-new lock to the first reclaimer. These natives close that hole
|
|
153
|
+
* for real — every removal is descriptor-relative, no-follow, and refuses unless the
|
|
154
|
+
* object still carries the exact `dev`/`ino`/`nlink`/`size`/`mtimeNs`/SHA-256 identity the
|
|
155
|
+
* caller proved. A replacement is therefore never deleted, by construction rather than by
|
|
156
|
+
* a narrower window.
|
|
157
|
+
*/
|
|
158
|
+
export type SessionStateLockNativeBindings = Pick<
|
|
159
|
+
typeof import("@gajae-code/natives"),
|
|
160
|
+
"exactRemoveDirectoryTree" | "exactUnlink" | "snapshotDirectoryTree"
|
|
161
|
+
>;
|
|
162
|
+
|
|
163
|
+
/** How the deletion primitives are obtained. Throwing means they are unavailable. */
|
|
164
|
+
type SessionStateLockNativeLoader = () => SessionStateLockNativeBindings;
|
|
165
|
+
|
|
166
|
+
let injectedNativeLoader: SessionStateLockNativeLoader | undefined;
|
|
167
|
+
let loadedNativeBindings: SessionStateLockNativeBindings | undefined;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @internal The seam focused TS tests bind a faithful in-process implementation to.
|
|
171
|
+
*
|
|
172
|
+
* It exists because the deletion contract — "refuse unless the object on disk still has
|
|
173
|
+
* exactly this identity" — is the thing under test, and a test double that merely reports
|
|
174
|
+
* success would assert nothing.
|
|
175
|
+
*
|
|
176
|
+
* A LOADER rather than a value, so the unavailable case needs no special production
|
|
177
|
+
* branch: a loader that throws is indistinguishable from an addon that will not load,
|
|
178
|
+
* which is the only way to observe that path where the addon is present. Passing
|
|
179
|
+
* `undefined` restores the real addon.
|
|
180
|
+
*/
|
|
181
|
+
export function setSessionStateLockNativeBindings(load: SessionStateLockNativeLoader | undefined): void {
|
|
182
|
+
injectedNativeLoader = load;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolve the deletion primitives, loading the addon on first real use.
|
|
187
|
+
*
|
|
188
|
+
* @throws {SessionStateLockUnavailableError} when they cannot be loaded. There is no
|
|
189
|
+
* `fs.rm` fallback: an unavailable identity-bound delete means this process cannot prove
|
|
190
|
+
* what it would be deleting, and deleting anyway is the exact defect these primitives fix.
|
|
191
|
+
*/
|
|
192
|
+
function nativeSessionStateLock(): SessionStateLockNativeBindings {
|
|
193
|
+
try {
|
|
194
|
+
if (injectedNativeLoader) return injectedNativeLoader();
|
|
195
|
+
if (!loadedNativeBindings)
|
|
196
|
+
loadedNativeBindings = require("@gajae-code/natives") as SessionStateLockNativeBindings;
|
|
197
|
+
return loadedNativeBindings;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
throw error instanceof SessionStateLockUnavailableError ? error : new SessionStateLockUnavailableError(error);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Start time stamped into a NEW owner record.
|
|
205
|
+
*
|
|
206
|
+
* Linux `/proc` is preferred because it is the value base owners on that platform were
|
|
207
|
+
* written from, so a base reader still recognizes this incarnation. Everywhere else the
|
|
208
|
+
* portable `ps` value is used instead of giving up and writing `unknown`, which would
|
|
209
|
+
* make PID reuse undetectable.
|
|
210
|
+
*/
|
|
211
|
+
function ownerStartTime(pid: number): string {
|
|
212
|
+
return readLinuxProcStartTimeSync(pid) ?? portableProcessStartTime(pid) ?? "unknown";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function validLockOwner(value: unknown): value is SessionStateLockOwner {
|
|
216
|
+
if (!value || typeof value !== "object") return false;
|
|
217
|
+
const owner = value as Partial<SessionStateLockOwner>;
|
|
218
|
+
return (
|
|
219
|
+
typeof owner.pid === "number" &&
|
|
220
|
+
Number.isSafeInteger(owner.pid) &&
|
|
221
|
+
owner.pid > 0 &&
|
|
222
|
+
typeof owner.start_time === "string" &&
|
|
223
|
+
typeof owner.token === "string" &&
|
|
224
|
+
owner.token.length > 0
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Whether the process holding `owner` is still the incarnation that took the lock.
|
|
230
|
+
*
|
|
231
|
+
* Owner records exist in three vintages: base `unknown`, base Linux `/proc` ticks, and
|
|
232
|
+
* the portable `ps` timestamp written today. A recorded value matching EITHER reader is
|
|
233
|
+
* the same incarnation. Only when a reader actually produced a value and NO reader agreed
|
|
234
|
+
* is the mismatch proved — an unreadable or unknown start time is indeterminate, and
|
|
235
|
+
* indeterminate never means "safe to steal".
|
|
236
|
+
*/
|
|
237
|
+
function sameOwnerIncarnation(owner: SessionStateLockOwner): boolean {
|
|
238
|
+
if (owner.start_time === "unknown" || owner.start_time.length === 0) return true;
|
|
239
|
+
const procStartTime = readLinuxProcStartTimeSync(owner.pid);
|
|
240
|
+
if (procStartTime !== null && procStartTime === owner.start_time) return true;
|
|
241
|
+
const psStartTime = portableProcessStartTime(owner.pid);
|
|
242
|
+
if (psStartTime !== null && psStartTime === owner.start_time) return true;
|
|
243
|
+
return procStartTime === null && psStartTime === null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* What one liveness probe of an owner pid can prove.
|
|
248
|
+
*
|
|
249
|
+
* `process.kill(pid, 0)` answers three different questions at once and only ONE of its
|
|
250
|
+
* answers means the owner is gone. `ESRCH` is that proof. `EPERM` is its opposite — the
|
|
251
|
+
* process exists, this one just may not signal it, which is the ordinary answer for an
|
|
252
|
+
* owner running as another user, under a different container UID, or behind a sandbox
|
|
253
|
+
* policy. Anything else is a question the OS declined to answer.
|
|
254
|
+
*
|
|
255
|
+
* The generic `mutation.lock` protocol classifies the same three answers, but its helper is
|
|
256
|
+
* private to that protocol and takes no probe seam; duplicating the classification here
|
|
257
|
+
* keeps this lock's semantics provable without widening the generic lock's surface.
|
|
258
|
+
*/
|
|
259
|
+
type OwnerProcessLiveness = "alive" | "dead" | "unknown";
|
|
260
|
+
|
|
261
|
+
function probeOwnerProcess(pid: number): OwnerProcessLiveness {
|
|
262
|
+
try {
|
|
263
|
+
const probe = SessionStateLockTestHooks.probeProcessSignal;
|
|
264
|
+
if (probe) probe(pid);
|
|
265
|
+
else process.kill(pid, 0);
|
|
266
|
+
return "alive";
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
269
|
+
if (code === "ESRCH") return "dead";
|
|
270
|
+
return code === "EPERM" ? "alive" : "unknown";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Whether the lock at hand still has an owner, for reclaim purposes.
|
|
276
|
+
*
|
|
277
|
+
* Only `dead` — a probe that PROVED the pid is gone, or a live pid whose recorded
|
|
278
|
+
* incarnation is provably not the one running — ever authorizes deleting the record.
|
|
279
|
+
* `unknown` liveness is reported as an owner, because a probe the OS refused to answer is
|
|
280
|
+
* not evidence of death, and the exact-identity unlink cannot undo a false verdict: the
|
|
281
|
+
* record still is the record that was judged, so the compare-and-delete matches and a live
|
|
282
|
+
* holder loses its lock.
|
|
283
|
+
*/
|
|
284
|
+
function lockOwnerIsAlive(value: unknown): boolean {
|
|
285
|
+
if (!validLockOwner(value)) return false;
|
|
286
|
+
const owner = value;
|
|
287
|
+
const liveness = probeOwnerProcess(owner.pid);
|
|
288
|
+
if (liveness === "dead") return false;
|
|
289
|
+
if (liveness === "unknown") return true;
|
|
290
|
+
return sameOwnerIncarnation(owner);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The immutable identity of the EXACT owner bytes one decision was made from.
|
|
295
|
+
*
|
|
296
|
+
* Every field is read from the descriptor the bytes came off, never re-derived from the
|
|
297
|
+
* pathname afterwards, so it names one inode incarnation and one payload. `sha256` is over
|
|
298
|
+
* the bytes that were actually read, which is what makes "the record I judged" and "the
|
|
299
|
+
* record I am about to delete" the same provable object rather than the same string.
|
|
300
|
+
*/
|
|
301
|
+
interface LockOwnerSnapshot {
|
|
302
|
+
dev: bigint;
|
|
303
|
+
ino: bigint;
|
|
304
|
+
nlink: bigint;
|
|
305
|
+
size: bigint;
|
|
306
|
+
mtimeNs: bigint;
|
|
307
|
+
sha256: string;
|
|
308
|
+
bytes: string;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* No-follow, non-blocking read flags.
|
|
313
|
+
*
|
|
314
|
+
* `lstat` deciding the path is a regular file and a later `readFile(path)` are two
|
|
315
|
+
* syscalls on a MUTABLE pathname: in between, the path can become a symlink whose target
|
|
316
|
+
* this process would then read and reclaim, or a FIFO whose open never returns. Opening
|
|
317
|
+
* `O_RDONLY | O_NONBLOCK | O_NOFOLLOW` once and proving the TYPE from that descriptor
|
|
318
|
+
* removes the window entirely — a link refuses to open, a writerless FIFO opens without
|
|
319
|
+
* blocking and is then rejected by `fstat`.
|
|
320
|
+
*/
|
|
321
|
+
const POSIX_OWNER_READ_FLAGS = POSIX_NOFOLLOW_AVAILABLE
|
|
322
|
+
? fsSync.constants.O_RDONLY | fsSync.constants.O_NONBLOCK | fsSync.constants.O_NOFOLLOW
|
|
323
|
+
: undefined;
|
|
324
|
+
|
|
325
|
+
/** Which strategy the owner protocol runs under right now. */
|
|
326
|
+
function ownerAccessStrategy(): SessionStateLockOwnerAccessStrategy {
|
|
327
|
+
return SessionStateLockTestHooks.ownerAccessStrategy ?? DETECTED_OWNER_ACCESS_STRATEGY;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Whether two stat results name the same regular-file incarnation. */
|
|
331
|
+
function sameRegularFileIdentity(left: fsSync.BigIntStats, right: fsSync.BigIntStats): boolean {
|
|
332
|
+
return (
|
|
333
|
+
left.isFile() &&
|
|
334
|
+
right.isFile() &&
|
|
335
|
+
left.dev === right.dev &&
|
|
336
|
+
left.ino === right.ino &&
|
|
337
|
+
left.nlink === right.nlink &&
|
|
338
|
+
left.size === right.size &&
|
|
339
|
+
left.mtimeNs === right.mtimeNs
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function ownerSnapshotFrom(stat: fsSync.BigIntStats, bytes: Buffer): LockOwnerSnapshot {
|
|
344
|
+
return {
|
|
345
|
+
dev: stat.dev,
|
|
346
|
+
ino: stat.ino,
|
|
347
|
+
nlink: stat.nlink,
|
|
348
|
+
size: stat.size,
|
|
349
|
+
mtimeNs: stat.mtimeNs,
|
|
350
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
351
|
+
bytes: bytes.toString("utf8"),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Capture the owner record through a descriptor whose no-follow type proof is its own. */
|
|
356
|
+
async function capturePosixLockOwner(lockFile: string, flags: number): Promise<LockOwnerSnapshot | null> {
|
|
357
|
+
let handle: fs.FileHandle;
|
|
358
|
+
try {
|
|
359
|
+
handle = await fs.open(lockFile, flags);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
362
|
+
// `ELOOP` is the no-follow refusal itself; every other failure is equally unproven.
|
|
363
|
+
throw new SessionStateLockUnavailableError(error);
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const opened = await handle.stat({ bigint: true });
|
|
367
|
+
// Proved from the open descriptor, so this is the type of the object being read —
|
|
368
|
+
// not the type some earlier `lstat` saw at a pathname that has since moved on.
|
|
369
|
+
if (!opened.isFile())
|
|
370
|
+
throw new SessionStateLockUnavailableError(new Error("Lock path is not a regular owner file."));
|
|
371
|
+
const bytes = await handle.readFile();
|
|
372
|
+
const settled = await handle.stat({ bigint: true });
|
|
373
|
+
// The identity must bracket the read: a record rewritten while it was being read
|
|
374
|
+
// has no single payload, so it is reported as nothing rather than mis-identified.
|
|
375
|
+
if (!sameRegularFileIdentity(opened, settled) || settled.size !== BigInt(bytes.byteLength)) return null;
|
|
376
|
+
return ownerSnapshotFrom(settled, bytes);
|
|
377
|
+
} finally {
|
|
378
|
+
await handle.close().catch(() => undefined);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Capture the owner record where no no-follow descriptor exists.
|
|
384
|
+
*
|
|
385
|
+
* Windows offers neither `O_NOFOLLOW` nor `O_NONBLOCK`, so the type cannot be proved by
|
|
386
|
+
* the open itself. It is proved around it instead: an `lstat` BEFORE the open must already
|
|
387
|
+
* show a regular file, the opened descriptor's own `fstat` and a second `lstat` must both
|
|
388
|
+
* still be that same regular-file incarnation, and only then are any bytes read — from the
|
|
389
|
+
* handle, never from the pathname. A reparse point, a type swap, or an inode substitution
|
|
390
|
+
* anywhere in that bracket refuses or reports nothing, so no foreign object is ever read
|
|
391
|
+
* through and none is ever attributed an identity that could authorize its removal.
|
|
392
|
+
*
|
|
393
|
+
* This is selected only under the win32 strategy. It makes no claim about Unix FIFOs: a
|
|
394
|
+
* blocking open is precisely what `O_NONBLOCK` exists for, and that flag is what POSIX
|
|
395
|
+
* keeps.
|
|
396
|
+
*/
|
|
397
|
+
async function captureWindowsLockOwner(lockFile: string, flags: number): Promise<LockOwnerSnapshot | null> {
|
|
398
|
+
let before: fsSync.BigIntStats;
|
|
399
|
+
try {
|
|
400
|
+
before = await fs.lstat(lockFile, { bigint: true });
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
403
|
+
throw new SessionStateLockUnavailableError(error);
|
|
404
|
+
}
|
|
405
|
+
if (before.isSymbolicLink())
|
|
406
|
+
throw new SessionStateLockUnavailableError(new Error("Lock path is a reparse point, not an owner file."));
|
|
407
|
+
if (!before.isFile())
|
|
408
|
+
throw new SessionStateLockUnavailableError(new Error("Lock path is not a regular owner file."));
|
|
409
|
+
let handle: fs.FileHandle;
|
|
410
|
+
try {
|
|
411
|
+
handle = await fs.open(lockFile, flags);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
414
|
+
throw new SessionStateLockUnavailableError(error);
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const opened = await handle.stat({ bigint: true });
|
|
418
|
+
const relinked = await fs.lstat(lockFile, { bigint: true }).catch(() => null);
|
|
419
|
+
if (relinked?.isSymbolicLink() === true)
|
|
420
|
+
throw new SessionStateLockUnavailableError(new Error("Lock path became a reparse point under the read."));
|
|
421
|
+
// The open landed on the object the pre-`lstat` judged, and the pathname still
|
|
422
|
+
// names it. Either disagreement means the bytes have no attributable identity.
|
|
423
|
+
if (!sameRegularFileIdentity(before, opened)) return null;
|
|
424
|
+
if (!relinked || !sameRegularFileIdentity(before, relinked)) return null;
|
|
425
|
+
const bytes = await handle.readFile();
|
|
426
|
+
const settled = await handle.stat({ bigint: true });
|
|
427
|
+
if (!sameRegularFileIdentity(opened, settled) || settled.size !== BigInt(bytes.byteLength)) return null;
|
|
428
|
+
return ownerSnapshotFrom(settled, bytes);
|
|
429
|
+
} finally {
|
|
430
|
+
await handle.close().catch(() => undefined);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Capture the regular `<file>.lock` owner record, or prove nothing is there to capture.
|
|
436
|
+
*
|
|
437
|
+
* @returns `null` when the path holds no record this call can speak for: it is absent, or
|
|
438
|
+
* it changed underneath the read, so no identity can be attributed to the bytes obtained.
|
|
439
|
+
* @throws {SessionStateLockUnavailableError} when the path is occupied by something the
|
|
440
|
+
* owner protocol never writes, or when no strategy can read one safely here. Nothing is
|
|
441
|
+
* read through it and nothing is removed.
|
|
442
|
+
*/
|
|
443
|
+
async function captureRegularLockOwner(lockFile: string): Promise<LockOwnerSnapshot | null> {
|
|
444
|
+
const strategy = ownerAccessStrategy();
|
|
445
|
+
if (strategy === "windows-validated") return await captureWindowsLockOwner(lockFile, fsSync.constants.O_RDONLY);
|
|
446
|
+
if (strategy === "posix-nofollow" && POSIX_OWNER_READ_FLAGS !== undefined)
|
|
447
|
+
return await capturePosixLockOwner(lockFile, POSIX_OWNER_READ_FLAGS);
|
|
448
|
+
throw new SessionStateLockUnavailableError(new Error("No-follow owner reads are unsupported on this platform."));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Whether two captures name the same inode incarnation holding the same bytes. */
|
|
452
|
+
function sameLockOwnerSnapshot(left: LockOwnerSnapshot, right: LockOwnerSnapshot): boolean {
|
|
453
|
+
return (
|
|
454
|
+
left.dev === right.dev &&
|
|
455
|
+
left.ino === right.ino &&
|
|
456
|
+
left.nlink === right.nlink &&
|
|
457
|
+
left.size === right.size &&
|
|
458
|
+
left.mtimeNs === right.mtimeNs &&
|
|
459
|
+
left.sha256 === right.sha256
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* What an identity-bound removal is allowed to conclude.
|
|
465
|
+
*
|
|
466
|
+
* `refused` is the safety verdict and is never treated as progress: the canonical pathname
|
|
467
|
+
* was not provably detached, so it may still hold an object this process does not own.
|
|
468
|
+
*/
|
|
469
|
+
type ExactRemovalOutcome = "removed" | "absent" | "identity_mismatch" | "refused";
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* A single-component quarantine destination, required by the native primitive so that
|
|
473
|
+
* authority over a detached record survives a crash between detach and unlink.
|
|
474
|
+
*/
|
|
475
|
+
function lockQuarantineName(): string {
|
|
476
|
+
return `.gjc-delete-session-state-lock-${randomUUID()}.json`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Delete the regular owner record at `file`, but only while it is still EXACTLY `identity`.
|
|
481
|
+
*
|
|
482
|
+
* @throws {SessionStateLockUnavailableError} when the primitive itself cannot run. It is
|
|
483
|
+
* never downgraded to `fs.rm`: not knowing what is at the pathname is precisely the state
|
|
484
|
+
* in which deleting it destroys a successor's lock.
|
|
485
|
+
*/
|
|
486
|
+
function exactUnlinkOwnerRecord(file: string, identity: LockOwnerSnapshot): ExactRemovalOutcome {
|
|
487
|
+
let result: NativeExactUnlinkResult;
|
|
488
|
+
try {
|
|
489
|
+
result = nativeSessionStateLock().exactUnlink(file, {
|
|
490
|
+
dev: identity.dev,
|
|
491
|
+
ino: identity.ino,
|
|
492
|
+
nlink: identity.nlink,
|
|
493
|
+
size: identity.size,
|
|
494
|
+
mtimeNs: identity.mtimeNs,
|
|
495
|
+
sha256: identity.sha256,
|
|
496
|
+
quarantineName: lockQuarantineName(),
|
|
497
|
+
});
|
|
498
|
+
} catch (error) {
|
|
499
|
+
throw new SessionStateLockUnavailableError(error);
|
|
500
|
+
}
|
|
501
|
+
if (result.ok) return "removed";
|
|
502
|
+
if (result.code === "not_found") return "absent";
|
|
503
|
+
if (result.code === "identity_mismatch") return "identity_mismatch";
|
|
504
|
+
// `cleanup_pending` means the canonical name WAS detached and only the quarantined
|
|
505
|
+
// copy of our own record is still around, so the pathname is free — but only when the
|
|
506
|
+
// retained artifact is durable and no successor or unidentified object was retained.
|
|
507
|
+
// Any retained successor makes this a refusal: the pathname is not ours to reuse.
|
|
508
|
+
if (
|
|
509
|
+
result.code === "cleanup_pending" &&
|
|
510
|
+
result.payloadDurable === true &&
|
|
511
|
+
result.detachedPath !== undefined &&
|
|
512
|
+
result.retainedSuccessorPath === undefined &&
|
|
513
|
+
result.retainedUnknownPath === undefined
|
|
514
|
+
)
|
|
515
|
+
return "removed";
|
|
516
|
+
return "refused";
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Flags for taking an owner record: create it or fail, and never through a link.
|
|
521
|
+
*
|
|
522
|
+
* `O_EXCL` is what makes the claim exclusive, and `O_NOFOLLOW` is what keeps a pre-planted
|
|
523
|
+
* symlink at the path from turning that create into a write somewhere else.
|
|
524
|
+
*
|
|
525
|
+
* Windows has no `O_NOFOLLOW`, but it does not need one here: create-new semantics refuse
|
|
526
|
+
* ANY pre-existing final component, symlink and reparse point included, so the create can
|
|
527
|
+
* only ever land on an object this call brought into existence. The identity is then taken
|
|
528
|
+
* from that writing handle, so the record authorized for removal is the one just written
|
|
529
|
+
* rather than whatever the pathname resolves to afterwards.
|
|
530
|
+
*/
|
|
531
|
+
const POSIX_OWNER_CREATE_FLAGS =
|
|
532
|
+
typeof fsSync.constants.O_NOFOLLOW === "number"
|
|
533
|
+
? fsSync.constants.O_CREAT | fsSync.constants.O_EXCL | fsSync.constants.O_WRONLY | fsSync.constants.O_NOFOLLOW
|
|
534
|
+
: undefined;
|
|
535
|
+
|
|
536
|
+
const CREATE_NEW_OWNER_FLAGS = fsSync.constants.O_CREAT | fsSync.constants.O_EXCL | fsSync.constants.O_WRONLY;
|
|
537
|
+
|
|
538
|
+
function ownerCreateFlags(): number | undefined {
|
|
539
|
+
const strategy = ownerAccessStrategy();
|
|
540
|
+
if (strategy === "windows-validated") return CREATE_NEW_OWNER_FLAGS;
|
|
541
|
+
return strategy === "posix-nofollow" ? POSIX_OWNER_CREATE_FLAGS : undefined;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function newLockOwner(): SessionStateLockOwner {
|
|
545
|
+
return { pid: process.pid, start_time: ownerStartTime(process.pid), token: randomUUID() };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The object a descriptor is currently open on.
|
|
550
|
+
*
|
|
551
|
+
* `dev`/`ino` and nothing else: this names the OBJECT rather than a payload or a moment,
|
|
552
|
+
* which is exactly what an open descriptor pins. While the descriptor is open the inode
|
|
553
|
+
* cannot be recycled, so an equal pair at a pathname can only be that same object — never
|
|
554
|
+
* a successor that happened to land on a reused inode number.
|
|
555
|
+
*/
|
|
556
|
+
interface OpenOwnerIdentity {
|
|
557
|
+
dev: bigint;
|
|
558
|
+
ino: bigint;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Identity of the regular file `handle` is open on, or `null` when it cannot be proved. */
|
|
562
|
+
async function openOwnerIdentity(handle: fs.FileHandle): Promise<OpenOwnerIdentity | null> {
|
|
563
|
+
try {
|
|
564
|
+
const stat = await handle.stat({ bigint: true });
|
|
565
|
+
return stat.isFile() ? { dev: stat.dev, ino: stat.ino } : null;
|
|
566
|
+
} catch {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Retract the record a failed write left behind — and only that record.
|
|
573
|
+
*
|
|
574
|
+
* The authority to delete it belongs to the writer's OWN open file, not to whatever the
|
|
575
|
+
* pathname names by the time this runs. Those are different objects far more often than
|
|
576
|
+
* the window suggests: the partial record can be stale-reclaimed and the freed pathname
|
|
577
|
+
* taken by a successor between the fault and this cleanup. Capturing "whatever is there
|
|
578
|
+
* now" and handing it to the identity-bound unlink deletes that successor's live lock, and
|
|
579
|
+
* the compare-and-delete cannot object — the capture and the authorization would be the
|
|
580
|
+
* same foreign object.
|
|
581
|
+
*
|
|
582
|
+
* So the created descriptor is still open here, and it stays open: it is what makes the
|
|
583
|
+
* comparison sound instead of merely likely, because the created inode cannot be recycled
|
|
584
|
+
* underneath a successor while this process holds it. Only when a fresh no-follow capture
|
|
585
|
+
* of the pathname proves to be that same still-open object is the existing exact unlink
|
|
586
|
+
* allowed to run — and it then closes the remaining capture-to-unlink race on the bytes.
|
|
587
|
+
*
|
|
588
|
+
* Anything short of that proof — a descriptor that no longer answers, a capture that
|
|
589
|
+
* refuses or reports a type swap, a different object at the path — leaves the pathname
|
|
590
|
+
* exactly as it is for the normal stale protocol.
|
|
591
|
+
*/
|
|
592
|
+
async function retractFailedOwnerRecord(
|
|
593
|
+
file: string,
|
|
594
|
+
handle: fs.FileHandle,
|
|
595
|
+
created: OpenOwnerIdentity | null,
|
|
596
|
+
): Promise<void> {
|
|
597
|
+
if (!created) return;
|
|
598
|
+
const stillOpen = await openOwnerIdentity(handle);
|
|
599
|
+
if (!stillOpen || stillOpen.dev !== created.dev || stillOpen.ino !== created.ino) return;
|
|
600
|
+
const current = await captureRegularLockOwner(file).catch(() => null);
|
|
601
|
+
if (!current || current.dev !== created.dev || current.ino !== created.ino) return;
|
|
602
|
+
exactUnlinkOwnerRecord(file, current);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Take `file` as a regular owner record and capture the exact identity of the bytes just
|
|
607
|
+
* written, from the SAME descriptor that wrote them.
|
|
608
|
+
*
|
|
609
|
+
* That identity is the only thing that will ever authorize deleting this record again, so
|
|
610
|
+
* it has to describe the object on disk rather than the string that was handed over. The
|
|
611
|
+
* same descriptor is what authorizes retracting the record when the write FAILS: it is
|
|
612
|
+
* held open across the whole cleanup, so the object this call created cannot be confused
|
|
613
|
+
* with a successor that took the pathname after a stale reclaim freed it.
|
|
614
|
+
*
|
|
615
|
+
* The deletion primitive is resolved BEFORE the record is created. A record this process
|
|
616
|
+
* cannot ever remove is worse than no record at all: it is indistinguishable from a live
|
|
617
|
+
* holder, so it would strand the pathname for every later contender instead of failing the
|
|
618
|
+
* one call that could not proceed.
|
|
619
|
+
*
|
|
620
|
+
* @throws `EEXIST` (or `EISDIR`/`EPERM`) unchanged, so callers can tell contention apart
|
|
621
|
+
* from failure.
|
|
622
|
+
*/
|
|
623
|
+
async function createOwnerLock(file: string, owner: SessionStateLockOwner): Promise<LockOwnerSnapshot> {
|
|
624
|
+
const flags = ownerCreateFlags();
|
|
625
|
+
if (flags === undefined)
|
|
626
|
+
throw new SessionStateLockUnavailableError(
|
|
627
|
+
new Error("Safe owner record creation is unsupported on this platform."),
|
|
628
|
+
);
|
|
629
|
+
nativeSessionStateLock();
|
|
630
|
+
const handle = await fs.open(file, flags);
|
|
631
|
+
const bytes = Buffer.from(JSON.stringify(owner), "utf8");
|
|
632
|
+
// Taken from the SAME descriptor the exclusive create produced, before anything is
|
|
633
|
+
// written through it. `O_EXCL` proves the object did not exist a moment ago, so this
|
|
634
|
+
// pair names the record this call brought into existence and nothing else.
|
|
635
|
+
let created: OpenOwnerIdentity | null = null;
|
|
636
|
+
try {
|
|
637
|
+
created = await openOwnerIdentity(handle);
|
|
638
|
+
await SessionStateLockTestHooks.ownerRecordWriteFault?.(file);
|
|
639
|
+
await handle.writeFile(bytes);
|
|
640
|
+
const stat = await handle.stat({ bigint: true });
|
|
641
|
+
if (!stat.isFile() || stat.size !== BigInt(bytes.byteLength))
|
|
642
|
+
throw new SessionStateLockUnavailableError(new Error("Owner record did not land as a regular file."));
|
|
643
|
+
return ownerSnapshotFrom(stat, bytes);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
// Deliberately BEFORE any close: releasing the descriptor first would free the
|
|
646
|
+
// created inode for reuse and turn a successor into a match.
|
|
647
|
+
try {
|
|
648
|
+
await retractFailedOwnerRecord(file, handle, created);
|
|
649
|
+
} catch (cleanupError) {
|
|
650
|
+
throw new AggregateError(
|
|
651
|
+
[error, cleanupError],
|
|
652
|
+
"Owner record write failed and its identity-bound cleanup also failed.",
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
throw error;
|
|
656
|
+
} finally {
|
|
657
|
+
await handle.close().catch(() => undefined);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Give up an owner record this process holds.
|
|
663
|
+
*
|
|
664
|
+
* A mismatch means something replaced our record while we held it. That successor is left
|
|
665
|
+
* strictly alone and the caller is told the lock is no longer trustworthy, because the
|
|
666
|
+
* alternative — deleting whatever is there now — is the exact bug this protocol exists to
|
|
667
|
+
* remove.
|
|
668
|
+
*/
|
|
669
|
+
function releaseOwnerLock(file: string, held: LockOwnerSnapshot): void {
|
|
670
|
+
const outcome = exactUnlinkOwnerRecord(file, held);
|
|
671
|
+
if (outcome === "removed" || outcome === "absent") return;
|
|
672
|
+
throw new SessionStateLockUnavailableError(new Error(`Owner record could not be released: ${outcome}.`));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Reclaim a regular owner record whose owner is dead, or whose malformed bytes have
|
|
677
|
+
* outlived the stale window.
|
|
678
|
+
*
|
|
679
|
+
* The identity is re-captured immediately before the delete, and the delete itself refuses
|
|
680
|
+
* unless the object still carries it. That second half is what makes this safe against a
|
|
681
|
+
* base or legacy writer, which takes no claim and can create the pathname at any instant:
|
|
682
|
+
* a successor is reported as `identity_mismatch` and survives untouched, and the caller
|
|
683
|
+
* simply retries.
|
|
684
|
+
*/
|
|
685
|
+
async function reclaimStaleOwnerRecord(
|
|
686
|
+
file: string,
|
|
687
|
+
hooks: {
|
|
688
|
+
afterInspection?: (file: string) => void | Promise<void>;
|
|
689
|
+
beforeRemoval?: (file: string) => void | Promise<void>;
|
|
690
|
+
},
|
|
691
|
+
): Promise<void> {
|
|
692
|
+
const snapshot = await captureRegularLockOwner(file);
|
|
693
|
+
if (!snapshot) return;
|
|
694
|
+
let owner: unknown;
|
|
695
|
+
try {
|
|
696
|
+
owner = JSON.parse(snapshot.bytes);
|
|
697
|
+
} catch {
|
|
698
|
+
owner = null;
|
|
699
|
+
}
|
|
700
|
+
if (!validLockOwner(owner)) {
|
|
701
|
+
// The mtime of the very inode the bytes were read from, not a fresh path `stat`.
|
|
702
|
+
if (Date.now() - Number(snapshot.mtimeNs / 1_000_000n) < LOCK_STALE_MS) return;
|
|
703
|
+
} else if (lockOwnerIsAlive(owner)) return;
|
|
704
|
+
await hooks.afterInspection?.(file);
|
|
705
|
+
const current = await captureRegularLockOwner(file);
|
|
706
|
+
if (!current || !sameLockOwnerSnapshot(current, snapshot)) return;
|
|
707
|
+
await hooks.beforeRemoval?.(file);
|
|
708
|
+
const outcome = exactUnlinkOwnerRecord(file, current);
|
|
709
|
+
// A successor that took the path in the final window keeps it; this call just loses.
|
|
710
|
+
if (outcome === "refused")
|
|
711
|
+
throw new SessionStateLockUnavailableError(new Error("Stale owner record could not be reclaimed."));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Run one pathname transition of `<file>.lock` with no other CURRENT contender able to
|
|
716
|
+
* create or remove that path.
|
|
717
|
+
*
|
|
718
|
+
* The claim is always released, including when the transition throws, so a fault leaves
|
|
719
|
+
* behind at most the lock file itself — which the normal stale protocol then handles.
|
|
720
|
+
*/
|
|
721
|
+
async function withLockPathTransition<T>(lockFile: string, transition: () => Promise<T>): Promise<T> {
|
|
722
|
+
const transitionFile = `${lockFile}${LOCK_TRANSITION_RESOURCE_SUFFIX}`;
|
|
723
|
+
const owner = newLockOwner();
|
|
724
|
+
for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) {
|
|
725
|
+
let held: LockOwnerSnapshot;
|
|
726
|
+
try {
|
|
727
|
+
held = await createOwnerLock(transitionFile, owner);
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (error instanceof SessionStateLockUnavailableError) throw error;
|
|
730
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
731
|
+
if (code !== "EEXIST" && code !== "EISDIR" && code !== "EPERM")
|
|
732
|
+
throw new SessionStateLockUnavailableError(error);
|
|
733
|
+
await reclaimStaleOwnerRecord(transitionFile, {
|
|
734
|
+
afterInspection: SessionStateLockTestHooks.afterTransitionStaleInspection,
|
|
735
|
+
beforeRemoval: SessionStateLockTestHooks.beforeTransitionStaleRemoval,
|
|
736
|
+
});
|
|
737
|
+
await Bun.sleep(LOCK_ACQUIRE_RETRY_MS);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
const outcome = await transition().then(
|
|
741
|
+
value => ({ ok: true as const, value }),
|
|
742
|
+
error => ({ ok: false as const, error }),
|
|
743
|
+
);
|
|
744
|
+
try {
|
|
745
|
+
releaseOwnerLock(transitionFile, held);
|
|
746
|
+
} catch (releaseError) {
|
|
747
|
+
// A claim that cannot be given up leaves the lock untrustworthy either way, so
|
|
748
|
+
// the typed refusal is what callers see; the transition's own fault rides along
|
|
749
|
+
// as the cause rather than replacing it.
|
|
750
|
+
if (!outcome.ok)
|
|
751
|
+
throw new SessionStateLockUnavailableError(
|
|
752
|
+
new AggregateError([outcome.error, releaseError], "Lock path transition and release both failed."),
|
|
753
|
+
);
|
|
754
|
+
throw releaseError;
|
|
755
|
+
}
|
|
756
|
+
if (!outcome.ok) throw outcome.error;
|
|
757
|
+
return outcome.value;
|
|
758
|
+
}
|
|
759
|
+
throw new SessionStateLockUnavailableError();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Reclaim the base regular-file `<file>.lock` when its owner is dead, or when a malformed
|
|
764
|
+
* record has outlived the stale window.
|
|
765
|
+
*
|
|
766
|
+
* Two independent guarantees, because one contender class each defeats the other one:
|
|
767
|
+
* the transition claim keeps CURRENT writers of this protocol out of the create/delete
|
|
768
|
+
* window, and the identity-bound delete keeps a BASE writer — which takes no claim and
|
|
769
|
+
* just creates the pathname — from having its brand-new lock unlinked.
|
|
770
|
+
*/
|
|
771
|
+
async function reclaimStaleRegularLock(lockFile: string): Promise<void> {
|
|
772
|
+
await withLockPathTransition(
|
|
773
|
+
lockFile,
|
|
774
|
+
async () =>
|
|
775
|
+
await reclaimStaleOwnerRecord(lockFile, {
|
|
776
|
+
afterInspection: SessionStateLockTestHooks.afterStaleInspection,
|
|
777
|
+
beforeRemoval: SessionStateLockTestHooks.beforeStaleRemoval,
|
|
778
|
+
}),
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Whether two tree captures describe the same directory down to every entry.
|
|
784
|
+
*
|
|
785
|
+
* Structural equality over the evidence the native primitive itself consumes: the root's
|
|
786
|
+
* own `dev`/`ino`, and each entry's position, kind, inode identity, and content hash. A
|
|
787
|
+
* successor that recreated the path is a different root inode; a successor that reused it
|
|
788
|
+
* differs in some entry. Neither can look like survival.
|
|
789
|
+
*/
|
|
790
|
+
function sameDirectoryTreeSnapshot(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
|
|
791
|
+
if (left.rootDev !== right.rootDev || left.rootIno !== right.rootIno) return false;
|
|
792
|
+
if (left.entries.length !== right.entries.length) return false;
|
|
793
|
+
return left.entries.every((entry, index) => {
|
|
794
|
+
const other = right.entries[index];
|
|
795
|
+
return (
|
|
796
|
+
other !== undefined &&
|
|
797
|
+
entry.relativePath === other.relativePath &&
|
|
798
|
+
entry.kind === other.kind &&
|
|
799
|
+
entry.dev === other.dev &&
|
|
800
|
+
entry.ino === other.ino &&
|
|
801
|
+
entry.nlink === other.nlink &&
|
|
802
|
+
entry.size === other.size &&
|
|
803
|
+
entry.mtimeNs === other.mtimeNs &&
|
|
804
|
+
entry.ctimeNs === other.ctimeNs &&
|
|
805
|
+
entry.sha256 === other.sha256
|
|
806
|
+
);
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Capture the exact tree at `lockDir`, or prove there is nothing there to capture.
|
|
812
|
+
*
|
|
813
|
+
* @returns `null` when the path is gone. A symlink, a special file, or anything else that
|
|
814
|
+
* cannot be described entry-by-entry is not a legacy lock directory.
|
|
815
|
+
* @throws {SessionStateLockUnavailableError} when the tree exists but cannot be described
|
|
816
|
+
* exactly. A tree that cannot be described exactly cannot be removed exactly, so its bytes
|
|
817
|
+
* stay where they are and the caller is told the lock is unusable.
|
|
818
|
+
*/
|
|
819
|
+
function captureLegacyDirectoryTree(
|
|
820
|
+
native: SessionStateLockNativeBindings,
|
|
821
|
+
lockDir: string,
|
|
822
|
+
): NativeDirectoryTreeSnapshot | null {
|
|
823
|
+
let captured: NativeDirectoryTreeResult;
|
|
824
|
+
try {
|
|
825
|
+
captured = native.snapshotDirectoryTree(lockDir);
|
|
826
|
+
} catch (error) {
|
|
827
|
+
throw new SessionStateLockUnavailableError(error);
|
|
828
|
+
}
|
|
829
|
+
if (captured.ok && captured.snapshot) return captured.snapshot;
|
|
830
|
+
if (captured.code === "not_found") return null;
|
|
831
|
+
throw new SessionStateLockUnavailableError(
|
|
832
|
+
new Error(`Legacy lock directory could not be captured: ${captured.code ?? "unknown"}.`),
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Evaluate a `<file>.lock/` DIRECTORY left behind by a base runtime that guarded this same
|
|
838
|
+
* state file with the generic directory-style lock.
|
|
839
|
+
*
|
|
840
|
+
* A directory at this path makes an exclusive create fail `EISDIR` forever, which is
|
|
841
|
+
* exactly the stranding this shared lock exists to prevent — in the other direction.
|
|
842
|
+
*
|
|
843
|
+
* The VERDICT stays with the generic implementation, which owns that format: duplicating
|
|
844
|
+
* its parser and liveness rules is how a live owner gets reaped by a timestamp it never
|
|
845
|
+
* wrote. Only the REMOVAL is taken over, because that protocol can offer nothing better
|
|
846
|
+
* than re-reading a token and then unlinking a pathname — and a successor can change the
|
|
847
|
+
* tree underneath a token that still reads the same.
|
|
848
|
+
*
|
|
849
|
+
* But the verdict is rendered against a PATHNAME, and the object it judged is not the
|
|
850
|
+
* object that would be deleted. A legacy writer takes no transition claim, so it can
|
|
851
|
+
* remove the judged directory and create a brand-new LIVE one at the same path; capturing
|
|
852
|
+
* "whatever is there now" hands the successor's own tree to the exact removal, which then
|
|
853
|
+
* matches and deletes a live lock. The compare-and-delete protected the object it was
|
|
854
|
+
* given — the authorization simply belonged to a different one.
|
|
855
|
+
*
|
|
856
|
+
* So the identity BRACKETS the verdict: the tree is captured before it, captured again
|
|
857
|
+
* after it, and removed only when both captures are the same tree. Any replacement in that
|
|
858
|
+
* window makes the two disagree and the reclaim declines, leaving the successor untouched.
|
|
859
|
+
*
|
|
860
|
+
* Nothing creates such a directory at this path anymore.
|
|
861
|
+
*/
|
|
862
|
+
async function reclaimStaleDirectoryLock(lockFile: string): Promise<void> {
|
|
863
|
+
await withLockPathTransition(lockFile, async () => {
|
|
864
|
+
const native = nativeSessionStateLock();
|
|
865
|
+
const before = captureLegacyDirectoryTree(native, lockFile);
|
|
866
|
+
if (!before) return;
|
|
867
|
+
if (!(await genericFileLockDirIsStale(lockFile, LOCK_STALE_MS))) return;
|
|
868
|
+
await SessionStateLockTestHooks.afterLegacyDirectoryStaleVerdict?.(lockFile);
|
|
869
|
+
const authorized = captureLegacyDirectoryTree(native, lockFile);
|
|
870
|
+
// The verdict spoke for `before`; only an unchanged tree carries that authority.
|
|
871
|
+
if (!authorized || !sameDirectoryTreeSnapshot(before, authorized)) return;
|
|
872
|
+
await SessionStateLockTestHooks.beforeLegacyDirectoryRemoval?.(lockFile);
|
|
873
|
+
let removed: NativeExactUnlinkResult;
|
|
874
|
+
try {
|
|
875
|
+
// The SAME verified capture the verdict was bound to, so a replacement that
|
|
876
|
+
// lands after this point is still refused by the primitive itself.
|
|
877
|
+
removed = native.exactRemoveDirectoryTree(lockFile, authorized);
|
|
878
|
+
} catch (error) {
|
|
879
|
+
throw new SessionStateLockUnavailableError(error);
|
|
880
|
+
}
|
|
881
|
+
if (removed.ok || removed.code === "not_found") return;
|
|
882
|
+
// Current natives may finish the security-critical phase by durably scrubbing the
|
|
883
|
+
// authorized tree and detaching it to the one replayable `.removing` name. That
|
|
884
|
+
// retained cleanup authority is not a live lock: acquisition may continue only when
|
|
885
|
+
// the typed receipt names exactly that sibling and the original namespace is still
|
|
886
|
+
// absent. A successor already at the lock path remains authoritative and fails closed.
|
|
887
|
+
if (
|
|
888
|
+
removed.code === "cleanup_pending" &&
|
|
889
|
+
removed.payloadDurable === true &&
|
|
890
|
+
removed.detachedPath === `${lockFile}.removing`
|
|
891
|
+
) {
|
|
892
|
+
try {
|
|
893
|
+
await fs.lstat(lockFile);
|
|
894
|
+
} catch (error) {
|
|
895
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
896
|
+
throw new SessionStateLockUnavailableError(error);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
// The tree changed after it was captured, so it belongs to a successor now.
|
|
900
|
+
if (removed.code === "identity_mismatch") return;
|
|
901
|
+
throw new SessionStateLockUnavailableError(
|
|
902
|
+
new Error(`Legacy lock directory could not be removed: ${removed.code ?? "unknown"}.`),
|
|
903
|
+
);
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Decide what actually occupies the lock path, without following it.
|
|
909
|
+
*
|
|
910
|
+
* @internal exported as the seam these decisions are tested through: a live legacy
|
|
911
|
+
* directory owner must be provably left alone without waiting out a real stale window.
|
|
912
|
+
*/
|
|
913
|
+
export async function reclaimStaleSessionStateLock(lockFile: string): Promise<void> {
|
|
914
|
+
let stat: fsSync.Stats;
|
|
915
|
+
try {
|
|
916
|
+
stat = await fs.lstat(lockFile);
|
|
917
|
+
} catch (error) {
|
|
918
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
919
|
+
throw error;
|
|
920
|
+
}
|
|
921
|
+
if (stat.isDirectory()) {
|
|
922
|
+
await reclaimStaleDirectoryLock(lockFile);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
// A symlink, FIFO, socket, or device is not a shape either lock protocol writes.
|
|
926
|
+
// Opening one follows an attacker-chosen target and a FIFO read blocks forever, so the
|
|
927
|
+
// path is refused outright rather than inspected or removed.
|
|
928
|
+
if (!stat.isFile()) throw new SessionStateLockUnavailableError();
|
|
929
|
+
await SessionStateLockTestHooks.afterLockTypeDecision?.(lockFile);
|
|
930
|
+
await reclaimStaleRegularLock(lockFile);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Serialize one read-modify-write of `stateFile` against every other holder of
|
|
935
|
+
* `<stateFile>.lock`.
|
|
936
|
+
*
|
|
937
|
+
* Taking the path, cleaning up a failed write, and releasing are all pathname transitions,
|
|
938
|
+
* so each runs under the transition claim — never the caller's operation, which holds the
|
|
939
|
+
* lock file itself and may run for as long as it needs.
|
|
940
|
+
*/
|
|
941
|
+
export async function withSessionStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
|
|
942
|
+
const lockFile = `${stateFile}.lock`;
|
|
943
|
+
const owner = newLockOwner();
|
|
944
|
+
await fs.mkdir(path.dirname(stateFile), { recursive: true });
|
|
945
|
+
for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) {
|
|
946
|
+
let held: LockOwnerSnapshot | undefined;
|
|
947
|
+
try {
|
|
948
|
+
// Contention (`EEXIST`, or `EISDIR`/`EPERM` for a legacy directory owner)
|
|
949
|
+
// propagates out of the claim to the evaluation below; the claim is released
|
|
950
|
+
// first, so the reclaim that follows can take it.
|
|
951
|
+
held = await withLockPathTransition(lockFile, () => createOwnerLock(lockFile, owner));
|
|
952
|
+
let outcome: { ok: true; value: T } | { ok: false; error: unknown };
|
|
953
|
+
try {
|
|
954
|
+
outcome = { ok: true, value: await operation() };
|
|
955
|
+
} catch (error) {
|
|
956
|
+
outcome = { ok: false, error };
|
|
957
|
+
}
|
|
958
|
+
const record = held;
|
|
959
|
+
// Released against the identity captured when it was written, so a record that
|
|
960
|
+
// is no longer ours is left for its owner rather than unlinked by name.
|
|
961
|
+
let releaseFailure: { error: unknown } | undefined;
|
|
962
|
+
try {
|
|
963
|
+
await withLockPathTransition(lockFile, async () => releaseOwnerLock(lockFile, record));
|
|
964
|
+
} catch (error) {
|
|
965
|
+
releaseFailure = { error };
|
|
966
|
+
}
|
|
967
|
+
if (!outcome.ok) {
|
|
968
|
+
if (releaseFailure)
|
|
969
|
+
throw new AggregateError(
|
|
970
|
+
[outcome.error, releaseFailure.error],
|
|
971
|
+
"Session state operation failed and its owner record could not be released.",
|
|
972
|
+
);
|
|
973
|
+
throw outcome.error;
|
|
974
|
+
}
|
|
975
|
+
if (releaseFailure) throw releaseFailure.error;
|
|
976
|
+
return outcome.value;
|
|
977
|
+
} catch (error) {
|
|
978
|
+
// A fault after the lock was taken belongs to the operation, not to acquisition.
|
|
979
|
+
if (held) throw error;
|
|
980
|
+
// A legacy `<file>.lock/` directory reports EISDIR (EPERM on some platforms);
|
|
981
|
+
// both are contention to be evaluated, not a hard failure.
|
|
982
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
983
|
+
if (code !== "EEXIST" && code !== "EISDIR" && code !== "EPERM")
|
|
984
|
+
throw error instanceof SessionStateLockUnavailableError
|
|
985
|
+
? error
|
|
986
|
+
: new SessionStateLockUnavailableError(error);
|
|
987
|
+
await reclaimStaleSessionStateLock(lockFile);
|
|
988
|
+
await Bun.sleep(LOCK_ACQUIRE_RETRY_MS);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
throw new SessionStateLockUnavailableError();
|
|
992
|
+
}
|