@zergai/ztc 0.4.0-beta.1 → 0.5.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/README.md +398 -45
- package/RUST_THIRD_PARTY_LICENSES.txt +1080 -822
- package/THIRD_PARTY_NOTICES.md +273 -12
- package/bin/zmux-darwin-arm64 +0 -0
- package/bin/ztc-audio-darwin-arm64 +0 -0
- package/bin/ztc-capture-darwin-arm64 +0 -0
- package/bin/ztc-computer-darwin-arm64 +0 -0
- package/bin/ztc-llm-darwin-arm64 +0 -0
- package/bin/ztc-tts-darwin-arm64 +0 -0
- package/bin/ztc-tui-darwin-arm64 +0 -0
- package/bin/ztc-zlicky-overlay-darwin-arm64 +0 -0
- package/bin/zterm-darwin-arm64 +0 -0
- package/dist/App.js +1206 -209
- package/dist/agent/agent.d.ts +74 -8
- package/dist/agent/agent.js +1073 -184
- package/dist/agent/backends/account_rotation.d.ts +4 -0
- package/dist/agent/backends/account_rotation.js +8 -2
- package/dist/agent/backends/anthropic.d.ts +13 -0
- package/dist/agent/backends/anthropic.js +169 -28
- package/dist/agent/backends/claude_cli.js +18 -8
- package/dist/agent/backends/codex_app_server.d.ts +1 -0
- package/dist/agent/backends/codex_app_server.js +55 -3
- package/dist/agent/backends/codex_auth.d.ts +1 -0
- package/dist/agent/backends/codex_auth.js +67 -13
- package/dist/agent/backends/codex_buffered_recovery.d.ts +27 -0
- package/dist/agent/backends/codex_buffered_recovery.js +293 -0
- package/dist/agent/backends/gemini.js +3 -1
- package/dist/agent/backends/http_error.d.ts +26 -0
- package/dist/agent/backends/http_error.js +221 -0
- package/dist/agent/backends/index.d.ts +9 -2
- package/dist/agent/backends/index.js +22 -2
- package/dist/agent/backends/openai.d.ts +0 -6
- package/dist/agent/backends/openai.js +5 -2
- package/dist/agent/backends/openai_compatible.d.ts +5 -4
- package/dist/agent/backends/openai_compatible.js +26 -19
- package/dist/agent/backends/openai_responses.d.ts +8 -3
- package/dist/agent/backends/openai_responses.js +77 -33
- package/dist/agent/backends/provider_ids.d.ts +1 -0
- package/dist/agent/backends/provider_ids.js +4 -0
- package/dist/agent/backends/request_metrics.js +12 -2
- package/dist/agent/backends/types.d.ts +109 -6
- package/dist/agent/backends/types.js +5 -1
- package/dist/agent/backends/zrouter_stream.d.ts +6 -1
- package/dist/agent/backends/zrouter_stream.js +160 -42
- package/dist/agent/background_registry.d.ts +2 -0
- package/dist/agent/background_registry.js +1 -0
- package/dist/agent/claim_observation.d.ts +6 -0
- package/dist/agent/claim_observation.js +54 -0
- package/dist/agent/commands/accounts.js +46 -177
- package/dist/agent/commands/attention.d.ts +2 -0
- package/dist/agent/commands/attention.js +67 -0
- package/dist/agent/commands/auth.js +18 -2
- package/dist/agent/commands/board.d.ts +2 -0
- package/dist/agent/commands/board.js +169 -0
- package/dist/agent/commands/clipboard.js +50 -1
- package/dist/agent/commands/compact.js +21 -13
- package/dist/agent/commands/config.js +4 -1
- package/dist/agent/commands/context.js +6 -1
- package/dist/agent/commands/desktop.d.ts +2 -0
- package/dist/agent/commands/desktop.js +29 -0
- package/dist/agent/commands/effort.js +14 -6
- package/dist/agent/commands/environment.d.ts +3 -0
- package/dist/agent/commands/environment.js +109 -0
- package/dist/agent/commands/evidence.d.ts +2 -0
- package/dist/agent/commands/evidence.js +9 -0
- package/dist/agent/commands/fork.js +4 -3
- package/dist/agent/commands/goal.d.ts +33 -1
- package/dist/agent/commands/goal.js +174 -14
- package/dist/agent/commands/goal_exit.d.ts +36 -0
- package/dist/agent/commands/goal_exit.js +120 -0
- package/dist/agent/commands/help.js +3 -1
- package/dist/agent/commands/history.js +3 -1
- package/dist/agent/commands/inbox.d.ts +2 -0
- package/dist/agent/commands/inbox.js +148 -0
- package/dist/agent/commands/index.js +16 -1
- package/dist/agent/commands/input_mode.js +8 -6
- package/dist/agent/commands/job.d.ts +2 -0
- package/dist/agent/commands/job.js +93 -0
- package/dist/agent/commands/keybindings.js +4 -1
- package/dist/agent/commands/label.d.ts +2 -0
- package/dist/agent/commands/label.js +77 -0
- package/dist/agent/commands/layout.d.ts +2 -0
- package/dist/agent/commands/layout.js +20 -0
- package/dist/agent/commands/mirror.js +16 -1
- package/dist/agent/commands/model.js +12 -2
- package/dist/agent/commands/permissions.d.ts +3 -1
- package/dist/agent/commands/permissions.js +79 -89
- package/dist/agent/commands/plan.js +5 -15
- package/dist/agent/commands/profile.js +21 -2
- package/dist/agent/commands/provider_selection.js +20 -1
- package/dist/agent/commands/rename.js +1 -1
- package/dist/agent/commands/resources.d.ts +2 -0
- package/dist/agent/commands/resources.js +67 -0
- package/dist/agent/commands/rewind.d.ts +3 -0
- package/dist/agent/commands/rewind.js +16 -0
- package/dist/agent/commands/runbooks.d.ts +2 -0
- package/dist/agent/commands/runbooks.js +73 -0
- package/dist/agent/commands/runbooks_lazy.d.ts +2 -0
- package/dist/agent/commands/runbooks_lazy.js +8 -0
- package/dist/agent/commands/sessions.d.ts +1 -1
- package/dist/agent/commands/sessions.js +33 -5
- package/dist/agent/commands/settings.js +185 -39
- package/dist/agent/commands/setup.d.ts +2 -0
- package/dist/agent/commands/setup.js +78 -0
- package/dist/agent/commands/share.d.ts +2 -0
- package/dist/agent/commands/share.js +64 -0
- package/dist/agent/commands/status.js +14 -2
- package/dist/agent/commands/types.d.ts +99 -6
- package/dist/agent/commands/update.js +8 -0
- package/dist/agent/commands/usage.d.ts +2 -0
- package/dist/agent/commands/usage.js +8 -0
- package/dist/agent/factory.d.ts +11 -0
- package/dist/agent/factory.js +113 -88
- package/dist/agent/loop_policy.d.ts +6 -3
- package/dist/agent/loop_policy.js +12 -3
- package/dist/agent/measurement_guidance.d.ts +2 -0
- package/dist/agent/measurement_guidance.js +5 -0
- package/dist/agent/request_encoder.d.ts +8 -1
- package/dist/agent/request_encoder.js +95 -23
- package/dist/agent/runtime/benchmark_comparison.d.ts +30 -0
- package/dist/agent/runtime/benchmark_comparison.js +94 -0
- package/dist/agent/runtime/benchmark_contract.d.ts +48 -0
- package/dist/agent/runtime/benchmark_contract.js +90 -0
- package/dist/agent/runtime/checked_commands.d.ts +32 -0
- package/dist/agent/runtime/checked_commands.js +119 -0
- package/dist/agent/runtime/command_execution_policy.d.ts +26 -0
- package/dist/agent/runtime/command_execution_policy.js +91 -0
- package/dist/agent/runtime/command_jobs.d.ts +36 -18
- package/dist/agent/runtime/command_jobs.js +151 -13
- package/dist/agent/runtime/command_panes.d.ts +59 -0
- package/dist/agent/runtime/command_panes.js +398 -0
- package/dist/agent/runtime/command_wait.d.ts +19 -0
- package/dist/agent/runtime/command_wait.js +167 -0
- package/dist/agent/runtime/cpu_benchmark.d.ts +30 -0
- package/dist/agent/runtime/cpu_benchmark.js +126 -0
- package/dist/agent/runtime/delegated_evidence.d.ts +26 -0
- package/dist/agent/runtime/delegated_evidence.js +80 -0
- package/dist/agent/runtime/evidence.d.ts +127 -0
- package/dist/agent/runtime/evidence.js +468 -0
- package/dist/agent/runtime/execution_context.d.ts +7 -0
- package/dist/agent/runtime/execution_context.js +39 -3
- package/dist/agent/runtime/json_verifier_recipe.d.ts +8 -0
- package/dist/agent/runtime/json_verifier_recipe.js +66 -0
- package/dist/agent/runtime/measurements.d.ts +110 -0
- package/dist/agent/runtime/measurements.js +317 -0
- package/dist/agent/runtime/policy.d.ts +4 -0
- package/dist/agent/runtime/policy.js +31 -20
- package/dist/agent/runtime/provider_native_evidence.d.ts +20 -0
- package/dist/agent/runtime/provider_native_evidence.js +86 -0
- package/dist/agent/runtime/recovery.d.ts +2 -0
- package/dist/agent/runtime/recovery.js +45 -2
- package/dist/agent/runtime/remote_broker_transport.d.ts +10 -0
- package/dist/agent/runtime/remote_broker_transport.js +84 -0
- package/dist/agent/runtime/remote_deletion_receipts.d.ts +3 -0
- package/dist/agent/runtime/remote_deletion_receipts.js +43 -0
- package/dist/agent/runtime/remote_journal_replay.d.ts +13 -0
- package/dist/agent/runtime/remote_journal_replay.js +233 -0
- package/dist/agent/runtime/remote_mutation_journal.d.ts +25 -0
- package/dist/agent/runtime/remote_mutation_journal.js +208 -0
- package/dist/agent/runtime/remote_resource_endpoint.d.ts +7 -0
- package/dist/agent/runtime/remote_resource_endpoint.js +6 -0
- package/dist/agent/runtime/remote_resource_errors.d.ts +5 -0
- package/dist/agent/runtime/remote_resource_errors.js +9 -0
- package/dist/agent/runtime/remote_resource_script.d.ts +2 -0
- package/dist/agent/runtime/remote_resource_script.js +1009 -0
- package/dist/agent/runtime/remote_resources.d.ts +29 -0
- package/dist/agent/runtime/remote_resources.js +160 -0
- package/dist/agent/runtime/source_verification.d.ts +12 -0
- package/dist/agent/runtime/source_verification.js +210 -0
- package/dist/agent/runtime/source_verification_contract.d.ts +75 -0
- package/dist/agent/runtime/source_verification_contract.js +203 -0
- package/dist/agent/runtime/tool_checkpoints.d.ts +11 -0
- package/dist/agent/runtime/tool_checkpoints.js +26 -0
- package/dist/agent/runtime/tool_effects.d.ts +5 -0
- package/dist/agent/runtime/tool_progress.d.ts +12 -0
- package/dist/agent/runtime/tool_progress.js +76 -0
- package/dist/agent/runtime/tool_runtime.d.ts +16 -1
- package/dist/agent/runtime/tool_runtime.js +204 -10
- package/dist/agent/runtime/tracing.d.ts +1 -1
- package/dist/agent/runtime/verifier_capture.d.ts +9 -0
- package/dist/agent/runtime/verifier_capture.js +100 -0
- package/dist/agent/runtime/verifier_contract.d.ts +24 -0
- package/dist/agent/runtime/verifier_contract.js +41 -0
- package/dist/agent/subagent.d.ts +20 -10
- package/dist/agent/subagent.js +266 -29
- package/dist/agent/tool_response_directory.d.ts +24 -0
- package/dist/agent/tool_response_directory.js +61 -0
- package/dist/agent/tool_response_source.d.ts +8 -0
- package/dist/agent/tool_response_source.js +49 -0
- package/dist/agent/tool_response_store.d.ts +75 -4
- package/dist/agent/tool_response_store.js +583 -64
- package/dist/agent/tools/ask_user.d.ts +3 -1
- package/dist/agent/tools/ask_user.js +30 -11
- package/dist/agent/tools/background.js +1 -0
- package/dist/agent/tools/benchmark_cpu.d.ts +2 -0
- package/dist/agent/tools/benchmark_cpu.js +102 -0
- package/dist/agent/tools/compare_benchmarks.d.ts +2 -0
- package/dist/agent/tools/compare_benchmarks.js +53 -0
- package/dist/agent/tools/deferred.js +1 -0
- package/dist/agent/tools/delegate.js +18 -2
- package/dist/agent/tools/helper_requirements.d.ts +7 -0
- package/dist/agent/tools/helper_requirements.js +19 -0
- package/dist/agent/tools/index.d.ts +1 -0
- package/dist/agent/tools/index.js +3 -0
- package/dist/agent/tools/measurement_report.d.ts +22 -0
- package/dist/agent/tools/measurement_report.js +190 -0
- package/dist/agent/tools/pane.d.ts +0 -7
- package/dist/agent/tools/pane.js +5 -4
- package/dist/agent/tools/project_context.d.ts +3 -0
- package/dist/agent/tools/project_context.js +45 -0
- package/dist/agent/tools/recall_tool_response.d.ts +3 -5
- package/dist/agent/tools/recall_tool_response.js +19 -29
- package/dist/agent/tools/registry.d.ts +1 -0
- package/dist/agent/tools/registry.js +23 -0
- package/dist/agent/tools/related_sessions.d.ts +2 -0
- package/dist/agent/tools/related_sessions.js +156 -0
- package/dist/agent/tools/routine.js +13 -3
- package/dist/agent/tools/session_evidence.d.ts +3 -0
- package/dist/agent/tools/session_evidence.js +72 -0
- package/dist/agent/tools/shell.d.ts +4 -3
- package/dist/agent/tools/shell.js +174 -47
- package/dist/agent/tools/tool_search.js +3 -2
- package/dist/agent/tools/types.d.ts +28 -0
- package/dist/agent/tools/verify_artifact.d.ts +2 -0
- package/dist/agent/tools/verify_artifact.js +60 -0
- package/dist/agent/tools/workflow.js +1 -0
- package/dist/app/activity.d.ts +6 -1
- package/dist/app/activity.js +81 -12
- package/dist/app/agent_runner.d.ts +6 -1
- package/dist/app/agent_runner.js +34 -19
- package/dist/app/command_context.js +106 -10
- package/dist/app/controller.d.ts +15 -4
- package/dist/app/controller.js +29 -3
- package/dist/app/escape_stop.d.ts +11 -0
- package/dist/app/escape_stop.js +15 -0
- package/dist/app/liveness.d.ts +6 -4
- package/dist/app/liveness.js +13 -3
- package/dist/app/model_catalog_cache.d.ts +5 -0
- package/dist/app/model_catalog_cache.js +138 -0
- package/dist/app/ordered_transcript.js +43 -4
- package/dist/app/prompt_turn.d.ts +14 -0
- package/dist/app/prompt_turn.js +53 -0
- package/dist/app/recap.d.ts +3 -0
- package/dist/app/recap.js +9 -3
- package/dist/app/restart.d.ts +3 -2
- package/dist/app/restart.js +22 -7
- package/dist/app/rewind.d.ts +3 -3
- package/dist/app/rewind.js +3 -3
- package/dist/app/selection_completion.d.ts +17 -2
- package/dist/app/selection_completion.js +84 -9
- package/dist/app/session_manager.d.ts +38 -6
- package/dist/app/session_manager.js +263 -60
- package/dist/app/session_messages.d.ts +3 -0
- package/dist/app/session_messages.js +60 -0
- package/dist/app/task_scope_router.d.ts +20 -0
- package/dist/app/task_scope_router.js +173 -0
- package/dist/app/transcript_window.d.ts +2 -0
- package/dist/app/transcript_window.js +4 -0
- package/dist/app/workspace_admission_details.d.ts +3 -0
- package/dist/app/workspace_admission_details.js +26 -0
- package/dist/app/workspace_layout_controller.d.ts +11 -0
- package/dist/app/workspace_layout_controller.js +88 -0
- package/dist/app/workspace_monitor.d.ts +1 -0
- package/dist/app/workspace_monitor.js +216 -0
- package/dist/app/workspace_presets.d.ts +29 -0
- package/dist/app/workspace_presets.js +192 -0
- package/dist/attention/queue.d.ts +37 -0
- package/dist/attention/queue.js +183 -0
- package/dist/bench/rf_shared.d.ts +2 -0
- package/dist/board/binding.d.ts +42 -0
- package/dist/board/binding.js +38 -0
- package/dist/board/client.d.ts +42 -0
- package/dist/board/client.js +60 -0
- package/dist/cli.js +1 -1
- package/dist/cli_args.js +19 -17
- package/dist/cli_main.js +110 -13
- package/dist/cli_ratatui.d.ts +183 -16
- package/dist/cli_ratatui.js +3989 -1255
- package/dist/components/ActivityLine.d.ts +3 -2
- package/dist/components/ActivityLine.js +2 -2
- package/dist/components/InputArea.d.ts +19 -1
- package/dist/components/InputArea.js +264 -190
- package/dist/components/MessageList.d.ts +2 -0
- package/dist/components/MessageList.js +3 -1
- package/dist/components/SingleMessage.d.ts +2 -0
- package/dist/components/SingleMessage.js +4 -2
- package/dist/components/StatusBar.d.ts +4 -2
- package/dist/components/StatusBar.js +3 -1
- package/dist/conductor_branch.js +3 -0
- package/dist/config/api_retry.d.ts +6 -1
- package/dist/config/api_retry.js +19 -1
- package/dist/config/defaults.d.ts +2 -1
- package/dist/config/defaults.js +9 -2
- package/dist/config/message_display.d.ts +1 -0
- package/dist/config/message_display.js +17 -2
- package/dist/config/permissions.d.ts +21 -0
- package/dist/config/permissions.js +89 -0
- package/dist/config/resolved.d.ts +2 -2
- package/dist/config/types.d.ts +32 -5
- package/dist/config.d.ts +38 -4
- package/dist/config.js +290 -42
- package/dist/context/project_intelligence.d.ts +44 -0
- package/dist/context/project_intelligence.js +246 -0
- package/dist/desktop/cli_launch.d.ts +8 -0
- package/dist/desktop/cli_launch.js +6 -0
- package/dist/desktop/cli_shim.d.ts +8 -0
- package/dist/desktop/cli_shim.js +120 -0
- package/dist/desktop/runtime.d.ts +16 -0
- package/dist/desktop/runtime.js +161 -0
- package/dist/desktop/worker_launch.d.ts +7 -0
- package/dist/desktop/worker_launch.js +7 -0
- package/dist/diagnostics/cargo_mutants_selection.d.ts +27 -0
- package/dist/diagnostics/cargo_mutants_selection.js +48 -0
- package/dist/diagnostics/native_terminal_benchmark.d.ts +58 -0
- package/dist/diagnostics/native_terminal_benchmark.js +1247 -0
- package/dist/diagnostics/session_evidence_package.d.ts +40 -0
- package/dist/diagnostics/session_evidence_package.js +234 -0
- package/dist/diagnostics/session_export.d.ts +93 -0
- package/dist/diagnostics/session_export.js +634 -0
- package/dist/emulation/tool_adapters.js +48 -18
- package/dist/emulation/ztc_addendum.d.ts +1 -0
- package/dist/emulation/ztc_addendum.js +13 -12
- package/dist/environment/service_runner.d.ts +1 -0
- package/dist/environment/service_runner.js +68 -0
- package/dist/environment/store.d.ts +120 -0
- package/dist/environment/store.js +387 -0
- package/dist/events.d.ts +40 -1
- package/dist/events.js +10 -17
- package/dist/goal_contract.d.ts +25 -0
- package/dist/goal_contract.js +44 -0
- package/dist/headless.d.ts +31 -0
- package/dist/headless.js +578 -70
- package/dist/headless_cli.js +8 -0
- package/dist/headless_cli_args.d.ts +1 -0
- package/dist/headless_cli_args.js +1 -0
- package/dist/intake/cli.d.ts +1 -0
- package/dist/intake/cli.js +33 -0
- package/dist/intake/format.d.ts +2 -0
- package/dist/intake/format.js +14 -0
- package/dist/intake/notifications.d.ts +16 -0
- package/dist/intake/notifications.js +132 -0
- package/dist/intake/store.d.ts +56 -0
- package/dist/intake/store.js +238 -0
- package/dist/library.d.ts +8 -0
- package/dist/library.js +19 -10
- package/dist/mcp/client.d.ts +1 -0
- package/dist/mcp/client.js +20 -0
- package/dist/modes/builtin/plan.yaml +2 -2
- package/dist/modes/handlers.js +1 -1
- package/dist/onboarding/first_run.d.ts +3 -0
- package/dist/onboarding/first_run.js +33 -25
- package/dist/onboarding/setup.d.ts +32 -0
- package/dist/onboarding/setup.js +133 -0
- package/dist/onboarding/setup_agent_health.d.ts +17 -0
- package/dist/onboarding/setup_agent_health.js +79 -0
- package/dist/onboarding/setup_environment.d.ts +66 -0
- package/dist/onboarding/setup_environment.js +219 -0
- package/dist/onboarding/setup_workspace.d.ts +17 -0
- package/dist/onboarding/setup_workspace.js +129 -0
- package/dist/optimize/adapters/index.d.ts +4 -1
- package/dist/optimize/adapters/index.js +7 -0
- package/dist/optimize/adapters/kernelbench.js +14 -5
- package/dist/optimize/adapters/popcorn.js +3 -2
- package/dist/optimize/adapters/profile_benchmark.js +13 -7
- package/dist/optimize/adapters/rfbench.js +25 -14
- package/dist/optimize/adapters/types.d.ts +3 -0
- package/dist/optimize/adapters/zergpod.js +5 -2
- package/dist/optimize/baseline.js +10 -21
- package/dist/optimize/branch.js +9 -12
- package/dist/optimize/combine.js +2 -3
- package/dist/optimize/executor.js +34 -48
- package/dist/optimize/measurements.d.ts +55 -0
- package/dist/optimize/measurements.js +173 -0
- package/dist/optimize/results.d.ts +1 -0
- package/dist/optimize/results.js +11 -33
- package/dist/optimize/tge.js +2 -1
- package/dist/panes/initial_input.d.ts +19 -0
- package/dist/panes/initial_input.js +206 -0
- package/dist/panes/labels.d.ts +16 -0
- package/dist/panes/labels.js +42 -0
- package/dist/panes/model_catalog.d.ts +3 -3
- package/dist/panes/model_catalog.js +28 -4
- package/dist/panes/pane_registry.js +6 -2
- package/dist/panes/types.d.ts +3 -0
- package/dist/panes/ztc_launch.js +19 -0
- package/dist/plugin/optimization.d.ts +2 -0
- package/dist/plugin/optimization.js +1 -0
- package/dist/plugin/runtime.d.ts +2 -0
- package/dist/plugin/simulation.d.ts +6 -1
- package/dist/plugin/simulation_contract.d.ts +836 -1
- package/dist/plugin/simulation_documents.d.ts +22 -0
- package/dist/plugin/simulation_documents.js +1 -0
- package/dist/plugin/simulation_interventions.d.ts +103 -0
- package/dist/plugin/simulation_interventions.js +1 -0
- package/dist/plugin/simulation_living.d.ts +1 -0
- package/dist/plugin/simulation_living.js +3 -0
- package/dist/plugin/simulation_meetings.d.ts +121 -0
- package/dist/plugin/simulation_meetings.js +1 -0
- package/dist/plugin/simulation_story_review.d.ts +111 -0
- package/dist/plugin/simulation_story_review.js +1 -0
- package/dist/plugin/simulation_storyteller.d.ts +91 -0
- package/dist/plugin/simulation_storyteller.js +1 -0
- package/dist/plugin/simulation_structured.d.ts +2 -0
- package/dist/plugin/simulation_structured.js +2 -0
- package/dist/plugin/simulation_toolkit.d.ts +21 -0
- package/dist/plugin/simulation_toolkit.js +24 -0
- package/dist/prompts/tool_delegate_task.j2 +1 -1
- package/dist/remote-broker/Dockerfile +10 -0
- package/dist/remote-broker/archive.py +1227 -0
- package/dist/remote-broker/cutover.py +761 -0
- package/dist/remote-broker/requirements.txt +5 -0
- package/dist/remote-broker/resources.json +6 -0
- package/dist/remote-broker/resources.write-staging.example.json +18 -0
- package/dist/remote-broker/retained.py +686 -0
- package/dist/remote-broker/retained_cleanup.py +550 -0
- package/dist/remote-broker/server.py +648 -0
- package/dist/remote-broker/worker.py +1007 -0
- package/dist/router_auth.d.ts +31 -0
- package/dist/router_auth.js +155 -9
- package/dist/runbooks/catalog.d.ts +3 -0
- package/dist/runbooks/catalog.js +26 -0
- package/dist/runbooks/library.d.ts +11 -0
- package/dist/runbooks/library.js +95 -0
- package/dist/runbooks/package.d.ts +43 -0
- package/dist/runbooks/package.js +102 -0
- package/dist/session.d.ts +65 -13
- package/dist/session.js +548 -43
- package/dist/session_handoff.d.ts +25 -0
- package/dist/session_handoff.js +322 -0
- package/dist/session_handoff_contract.d.ts +89 -0
- package/dist/session_handoff_contract.js +179 -0
- package/dist/session_media.d.ts +41 -0
- package/dist/session_media.js +357 -0
- package/dist/session_ownership.d.ts +16 -0
- package/dist/session_ownership.js +221 -0
- package/dist/session_public.d.ts +10 -2
- package/dist/session_public.js +6 -1
- package/dist/simulation/dynamic_history.d.ts +42 -0
- package/dist/simulation/dynamic_history.js +173 -0
- package/dist/simulation/engines/swarm/pane_manager.js +7 -6
- package/dist/simulation/inference.d.ts +3 -1
- package/dist/simulation/inference.js +19 -1
- package/dist/simulation/intervention_generation.d.ts +27 -0
- package/dist/simulation/intervention_generation.js +246 -0
- package/dist/simulation/intervention_material.d.ts +4 -0
- package/dist/simulation/intervention_material.js +81 -0
- package/dist/simulation/living/context.d.ts +3130 -0
- package/dist/simulation/living/context.js +944 -0
- package/dist/simulation/living/definer.d.ts +12 -0
- package/dist/simulation/living/definer.js +223 -0
- package/dist/simulation/living/definition.d.ts +257 -0
- package/dist/simulation/living/definition.js +341 -0
- package/dist/simulation/living/frame.d.ts +259 -0
- package/dist/simulation/living/frame.js +223 -0
- package/dist/simulation/living/identifiers.d.ts +2 -0
- package/dist/simulation/living/identifiers.js +12 -0
- package/dist/simulation/living/index.d.ts +15 -0
- package/dist/simulation/living/index.js +15 -0
- package/dist/simulation/living/inference.d.ts +219 -0
- package/dist/simulation/living/inference.js +576 -0
- package/dist/simulation/living/mechanics.d.ts +54 -0
- package/dist/simulation/living/mechanics.js +588 -0
- package/dist/simulation/living/model-input.d.ts +25 -0
- package/dist/simulation/living/model-input.js +215 -0
- package/dist/simulation/living/open-world.d.ts +257 -0
- package/dist/simulation/living/open-world.js +102 -0
- package/dist/simulation/living/projection.d.ts +7 -0
- package/dist/simulation/living/projection.js +31 -0
- package/dist/simulation/living/recall.d.ts +17 -0
- package/dist/simulation/living/recall.js +25 -0
- package/dist/simulation/living/reduce.d.ts +15 -0
- package/dist/simulation/living/reduce.js +1853 -0
- package/dist/simulation/living/replay.d.ts +6 -0
- package/dist/simulation/living/replay.js +22 -0
- package/dist/simulation/living/runtime.d.ts +140 -0
- package/dist/simulation/living/runtime.js +3020 -0
- package/dist/simulation/living/shared-kinds.d.ts +24 -0
- package/dist/simulation/living/shared-kinds.js +96 -0
- package/dist/simulation/living/types.d.ts +2785 -0
- package/dist/simulation/living/types.js +669 -0
- package/dist/simulation/llm.d.ts +3 -1
- package/dist/simulation/llm.js +90 -30
- package/dist/simulation/memory/embedding.d.ts +1 -1
- package/dist/simulation/memory/embedding.js +9 -3
- package/dist/simulation/memory/filesystem.d.ts +2 -0
- package/dist/simulation/memory/filesystem.js +5 -1
- package/dist/simulation/memory/filesystem_file_tree.d.ts +4 -1
- package/dist/simulation/memory/filesystem_file_tree.js +41 -10
- package/dist/simulation/memory/filesystem_invariants.js +1 -2
- package/dist/simulation/memory/filesystem_types.d.ts +1 -0
- package/dist/simulation/memory/memory.d.ts +2 -0
- package/dist/simulation/memory/memory.js +5 -1
- package/dist/simulation/orchestration/intent.js +0 -2
- package/dist/simulation/orchestration/lifecycle.js +0 -2
- package/dist/simulation/plugin_host.d.ts +25 -1
- package/dist/simulation/plugin_host.js +334 -17
- package/dist/simulation/session_documents.d.ts +20 -0
- package/dist/simulation/session_documents.js +62 -0
- package/dist/simulation/session_input_validation.d.ts +43 -0
- package/dist/simulation/session_input_validation.js +364 -0
- package/dist/simulation/session_inputs.d.ts +32 -0
- package/dist/simulation/session_inputs.js +154 -0
- package/dist/simulation/telemetry.d.ts +11 -14
- package/dist/simulation/telemetry.js +177 -54
- package/dist/simulation/toolkit/bounded_turns.d.ts +13 -0
- package/dist/simulation/toolkit/bounded_turns.js +80 -0
- package/dist/simulation/toolkit/dynamic_agents.d.ts +25 -0
- package/dist/simulation/toolkit/dynamic_agents.js +218 -0
- package/dist/simulation/toolkit/dynamic_digest.d.ts +1 -0
- package/dist/simulation/toolkit/dynamic_digest.js +8 -0
- package/dist/simulation/toolkit/dynamic_documents.d.ts +7 -0
- package/dist/simulation/toolkit/dynamic_documents.js +71 -0
- package/dist/simulation/toolkit/dynamic_driver.d.ts +8 -0
- package/dist/simulation/toolkit/dynamic_driver.js +269 -0
- package/dist/simulation/toolkit/dynamic_game.d.ts +53 -0
- package/dist/simulation/toolkit/dynamic_game.js +805 -0
- package/dist/simulation/toolkit/dynamic_knowledge.d.ts +15 -0
- package/dist/simulation/toolkit/dynamic_knowledge.js +91 -0
- package/dist/simulation/toolkit/dynamic_mechanics.d.ts +22 -0
- package/dist/simulation/toolkit/dynamic_mechanics.js +910 -0
- package/dist/simulation/toolkit/dynamic_meetings.d.ts +239 -0
- package/dist/simulation/toolkit/dynamic_meetings.js +350 -0
- package/dist/simulation/toolkit/dynamic_progress.d.ts +131 -0
- package/dist/simulation/toolkit/dynamic_progress.js +214 -0
- package/dist/simulation/toolkit/dynamic_projection.d.ts +9 -0
- package/dist/simulation/toolkit/dynamic_projection.js +219 -0
- package/dist/simulation/toolkit/dynamic_runtime.d.ts +412 -0
- package/dist/simulation/toolkit/dynamic_runtime.js +6536 -0
- package/dist/simulation/toolkit/dynamic_state.d.ts +111 -0
- package/dist/simulation/toolkit/dynamic_state.js +1967 -0
- package/dist/simulation/toolkit/dynamic_story_review.d.ts +508 -0
- package/dist/simulation/toolkit/dynamic_story_review.js +333 -0
- package/dist/simulation/toolkit/dynamic_storyteller.d.ts +77 -0
- package/dist/simulation/toolkit/dynamic_storyteller.js +114 -0
- package/dist/simulation/toolkit/dynamic_storyteller_runtime.d.ts +26 -0
- package/dist/simulation/toolkit/dynamic_storyteller_runtime.js +126 -0
- package/dist/simulation/toolkit/dynamic_world.d.ts +22 -0
- package/dist/simulation/toolkit/dynamic_world.js +377 -0
- package/dist/simulation/toolkit/structured_output.d.ts +5 -0
- package/dist/simulation/toolkit/structured_output.js +321 -0
- package/dist/simulation/types/world.d.ts +2 -0
- package/dist/simulation/world/artifact_store.js +1 -1
- package/dist/simulation/world/memory_manager.d.ts +5 -0
- package/dist/simulation/world/memory_manager.js +17 -2
- package/dist/simulation/world/state.d.ts +3 -2
- package/dist/simulation/world/state.js +15 -5
- package/dist/skills/bundled/measurement-validation/SKILL.md +144 -0
- package/dist/terminal_attach.d.ts +1 -0
- package/dist/terminal_attach.js +113 -0
- package/dist/tool_progress.d.ts +3 -0
- package/dist/tool_progress.js +27 -0
- package/dist/types.d.ts +145 -10
- package/dist/types.js +3 -0
- package/dist/ui/core/input.d.ts +3 -0
- package/dist/ui/core/input_segments.d.ts +18 -1
- package/dist/ui/core/input_segments.js +131 -47
- package/dist/ui/core/input_state.d.ts +12 -0
- package/dist/ui/core/layout_yoga.d.ts +1 -1
- package/dist/ui/core/turn_metadata.d.ts +5 -0
- package/dist/ui/core/turn_metadata.js +22 -0
- package/dist/ui/ink/render.d.ts +2 -1
- package/dist/ui/ink/render.js +6 -5
- package/dist/ui/ratatui/headless_pane_manager.d.ts +14 -1
- package/dist/ui/ratatui/headless_pane_manager.js +293 -104
- package/dist/ui/ratatui/index.d.ts +1 -0
- package/dist/ui/ratatui/input.d.ts +4 -0
- package/dist/ui/ratatui/input.js +15 -0
- package/dist/ui/ratatui/ipc_path.d.ts +2 -0
- package/dist/ui/ratatui/ipc_path.js +29 -1
- package/dist/ui/ratatui/mux_persistence.d.ts +3 -1
- package/dist/ui/ratatui/mux_persistence.js +139 -1
- package/dist/ui/ratatui/pane_relay.d.ts +1 -1
- package/dist/ui/ratatui/pane_relay.js +55 -27
- package/dist/ui/ratatui/process.d.ts +17 -1
- package/dist/ui/ratatui/process.js +80 -7
- package/dist/ui/ratatui/protocol.d.ts +236 -1
- package/dist/ui/ratatui/protocol.js +90 -0
- package/dist/ui/scrollback.d.ts +2 -1
- package/dist/ui/scrollback.js +9 -4
- package/dist/ui/stream_typewriter.d.ts +8 -3
- package/dist/ui/stream_typewriter.js +30 -1
- package/dist/ui/streaming_scrollback.d.ts +1 -0
- package/dist/ui/streaming_scrollback.js +1 -1
- package/dist/ui/transcript_render_cache.js +10 -0
- package/dist/ui/views/activity_line.d.ts +5 -3
- package/dist/ui/views/activity_line.js +65 -7
- package/dist/ui/views/app.d.ts +6 -3
- package/dist/ui/views/app.js +17 -6
- package/dist/ui/views/completion_menu.js +10 -5
- package/dist/ui/views/input_area.d.ts +7 -3
- package/dist/ui/views/input_area.js +15 -9
- package/dist/ui/views/message_list.d.ts +4 -1
- package/dist/ui/views/message_list.js +89 -19
- package/dist/ui/views/queue_panel.d.ts +3 -1
- package/dist/ui/views/queue_panel.js +9 -2
- package/dist/ui/views/status_bar.d.ts +4 -2
- package/dist/ui/views/status_bar.js +28 -13
- package/dist/ui/views/theme.d.ts +2 -0
- package/dist/ui/views/theme.js +2 -0
- package/dist/ui/widgets/color_panel.js +18 -0
- package/dist/ui/widgets/form.js +8 -0
- package/dist/ui/widgets/log_view.js +7 -0
- package/dist/ui/widgets/navigator_panel.js +8 -0
- package/dist/ui/widgets/prompt_viewer.js +2 -0
- package/dist/ui/widgets/questionnaire.d.ts +2 -0
- package/dist/ui/widgets/questionnaire.js +79 -16
- package/dist/ui/widgets/resume_panel.d.ts +2 -0
- package/dist/ui/widgets/resume_panel.js +26 -15
- package/dist/ui/widgets/rewind_panel.d.ts +2 -2
- package/dist/ui/widgets/rewind_panel.js +4 -2
- package/dist/ui/widgets/select.js +16 -27
- package/dist/ui/widgets/settings_panel.js +16 -0
- package/dist/ui/widgets/status_line_panel.d.ts +1 -0
- package/dist/ui/widgets/status_line_panel.js +30 -4
- package/dist/ui/widgets/text_input.d.ts +2 -0
- package/dist/ui/widgets/text_input.js +15 -1
- package/dist/ui/widgets/types.d.ts +2 -0
- package/dist/usage_projection.d.ts +24 -0
- package/dist/usage_projection.js +130 -0
- package/dist/usage_snapshot.d.ts +36 -0
- package/dist/usage_snapshot.js +131 -0
- package/dist/utils/assistant_message.d.ts +2 -0
- package/dist/utils/assistant_message.js +5 -0
- package/dist/utils/atomic_write.d.ts +6 -2
- package/dist/utils/atomic_write.js +29 -4
- package/dist/utils/checked_shell.d.ts +25 -0
- package/dist/utils/checked_shell.js +107 -0
- package/dist/utils/compaction.d.ts +4 -1
- package/dist/utils/compaction.js +31 -7
- package/dist/utils/context.d.ts +4 -4
- package/dist/utils/context.js +14 -12
- package/dist/utils/deepseek.d.ts +2 -2
- package/dist/utils/deepseek.js +13 -5
- package/dist/utils/ephemeral.js +4 -0
- package/dist/utils/ephemeral_tools.d.ts +18 -0
- package/dist/utils/ephemeral_tools.js +97 -0
- package/dist/utils/jev_client.d.ts +4 -0
- package/dist/utils/jev_client.js +15 -0
- package/dist/utils/model_cache.d.ts +15 -0
- package/dist/utils/model_cache.js +143 -0
- package/dist/utils/models.d.ts +4 -2
- package/dist/utils/models.js +12 -16
- package/dist/utils/reasoning.d.ts +8 -1
- package/dist/utils/reasoning.js +31 -11
- package/dist/utils/router_catalog.d.ts +49 -0
- package/dist/utils/router_catalog.js +271 -0
- package/dist/utils/sandbox.d.ts +9 -0
- package/dist/utils/sandbox.js +118 -15
- package/dist/utils/shell.d.ts +7 -0
- package/dist/utils/shell.js +98 -34
- package/dist/utils/subprocess_env.d.ts +3 -0
- package/dist/utils/subprocess_env.js +27 -1
- package/dist/web/accounts_mirror.d.ts +3 -1
- package/dist/web/accounts_mirror.js +13 -9
- package/dist/web/child_publication.d.ts +17 -0
- package/dist/web/child_publication.js +45 -0
- package/dist/web/mirror_authenticator.d.ts +31 -11
- package/dist/web/mirror_authenticator.js +67 -17
- package/dist/web/mirror_client.d.ts +31 -4
- package/dist/web/mirror_client.js +189 -23
- package/dist/web/mirror_stream.d.ts +84 -22
- package/dist/web/mirror_stream.js +676 -95
- package/dist/web/observer_query_redaction.d.ts +6 -0
- package/dist/web/observer_query_redaction.js +58 -0
- package/dist/web/pane_registry.d.ts +2 -0
- package/dist/web/pane_registry.js +86 -22
- package/dist/web/tool_image.d.ts +6 -0
- package/dist/web/tool_image.js +17 -0
- package/dist/web/view_plugin.d.ts +14 -0
- package/dist/web/view_plugin.js +23 -1
- package/dist/worker/executor.js +31 -12
- package/dist/worker/index.d.ts +1 -0
- package/dist/worker/toolsets.js +3 -1
- package/dist/worker/types.d.ts +12 -1
- package/dist/workflows/registry.js +1 -0
- package/dist/workflows/registry_handle.d.ts +2 -0
- package/dist/workflows/runtime.d.ts +2 -0
- package/dist/workflows/runtime.js +1 -0
- package/dist/workspace_agent.d.ts +7 -0
- package/dist/workspace_agent.js +36 -0
- package/dist/workspace_collision.d.ts +76 -0
- package/dist/workspace_collision.js +688 -0
- package/dist/workspace_layout.d.ts +18 -0
- package/dist/workspace_layout.js +121 -0
- package/dist/workspace_runtime.d.ts +44 -0
- package/dist/workspace_runtime.js +82 -0
- package/dist/workspace_session_context.d.ts +62 -0
- package/dist/workspace_session_context.js +160 -0
- package/dist/zlm-embed.mjs +32518 -9615
- package/dist/zlm.js +1 -0
- package/node_modules/@zerg/model-contracts/LICENSE.md +375 -0
- package/node_modules/@zerg/model-contracts/README.md +90 -0
- package/node_modules/@zerg/model-contracts/index.d.ts +20 -0
- package/node_modules/@zerg/model-contracts/index.js +223 -0
- package/node_modules/@zerg/model-contracts/package.json +8 -0
- package/node_modules/glob/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/glob/node_modules/brace-expansion/package.json +1 -1
- package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
- package/package.json +45 -24
- package/packages/model-contracts/LICENSE.md +375 -0
- package/packages/model-contracts/README.md +90 -0
- package/packages/model-contracts/index.d.ts +20 -0
- package/packages/model-contracts/index.js +223 -0
- package/packages/model-contracts/package.json +8 -0
- package/remote-broker/Dockerfile +10 -0
- package/remote-broker/README.md +308 -0
- package/remote-broker/archive.py +1227 -0
- package/remote-broker/cutover.py +761 -0
- package/remote-broker/requirements.txt +5 -0
- package/remote-broker/resources.example.json +6 -0
- package/remote-broker/resources.write-staging.example.json +18 -0
- package/remote-broker/retained.py +686 -0
- package/remote-broker/retained_cleanup.py +550 -0
- package/remote-broker/server.py +648 -0
- package/scripts/build-remote-broker.mjs +13 -0
- package/scripts/ensure-local-packages.mjs +6 -0
- package/scripts/node-pty-helpers.mjs +103 -0
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
|
|
2
|
+
import contextlib, ctypes, errno, fcntl, hashlib, json, math, os, re, stat, sys, tempfile, time, uuid
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
admitted = False
|
|
6
|
+
|
|
7
|
+
def sync_directory(path: str) -> None:
|
|
8
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
9
|
+
try: os.fsync(descriptor)
|
|
10
|
+
finally: os.close(descriptor)
|
|
11
|
+
|
|
12
|
+
def rename_without_replacement(source_directory: int, source: str, target_directory: int, target: str) -> None:
|
|
13
|
+
# The kernel must refuse an occupied destination. A stat/rename fallback
|
|
14
|
+
# would introduce another replacement race, including for directories.
|
|
15
|
+
library = ctypes.CDLL(None, use_errno=True)
|
|
16
|
+
if sys.platform == 'linux':
|
|
17
|
+
function, flag = library.renameat2, 1
|
|
18
|
+
elif sys.platform == 'darwin':
|
|
19
|
+
function, flag = library.renameatx_np, 4
|
|
20
|
+
else:
|
|
21
|
+
raise OSError(errno.ENOTSUP, 'Deletion requires atomic no-replace rename support')
|
|
22
|
+
function.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
|
23
|
+
function.restype = ctypes.c_int
|
|
24
|
+
if function(source_directory, os.fsencode(source), target_directory, os.fsencode(target), flag) != 0:
|
|
25
|
+
error = ctypes.get_errno()
|
|
26
|
+
raise OSError(error, os.strerror(error))
|
|
27
|
+
|
|
28
|
+
def decode_owner_presence(data: bytes) -> Dict[str, float]:
|
|
29
|
+
if len(data) > 16 * 1024 * 1024:
|
|
30
|
+
raise ValueError('Owner presence registry exceeds its bounded read size')
|
|
31
|
+
document = json.loads(data)
|
|
32
|
+
if not isinstance(document, dict) or len(document) > 4096:
|
|
33
|
+
raise ValueError('Invalid resource owner presence registry')
|
|
34
|
+
result: Dict[str, float] = {}
|
|
35
|
+
for name, seen in document.items():
|
|
36
|
+
if not isinstance(name, str) or not name or len(name) > 200 or type(seen) not in [int, float]:
|
|
37
|
+
raise ValueError('Invalid resource owner presence entry')
|
|
38
|
+
try: timestamp = float(seen)
|
|
39
|
+
except (ValueError, OverflowError) as error:
|
|
40
|
+
raise ValueError('Invalid resource owner presence timestamp') from error
|
|
41
|
+
if not math.isfinite(timestamp):
|
|
42
|
+
raise ValueError('Invalid resource owner presence timestamp')
|
|
43
|
+
result[name] = timestamp
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
def register_owner_presence(control: str, owner: str, now: float) -> Dict[str, Any]:
|
|
47
|
+
# Recent presence is an advisory transfer prerequisite, never lease authority.
|
|
48
|
+
# A malformed cache is retained once and rebuilt solely from this request's
|
|
49
|
+
# immutable ID. Every other pane must register itself again before transfer.
|
|
50
|
+
owners_path = os.path.join(control, 'owners.json')
|
|
51
|
+
damaged_path = os.path.join(control, 'owners.json.damaged')
|
|
52
|
+
owners: Dict[str, float] = {}
|
|
53
|
+
recovered = False
|
|
54
|
+
try:
|
|
55
|
+
descriptor = os.open(owners_path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
56
|
+
except FileNotFoundError: descriptor = None
|
|
57
|
+
if descriptor is not None:
|
|
58
|
+
with os.fdopen(descriptor, 'rb') as stream:
|
|
59
|
+
opened = os.fstat(stream.fileno())
|
|
60
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
61
|
+
raise ValueError('Owner presence registry must be a regular file; operator recovery required')
|
|
62
|
+
data = stream.read(16 * 1024 * 1024 + 1)
|
|
63
|
+
try: owners = decode_owner_presence(data)
|
|
64
|
+
except (ValueError, UnicodeDecodeError):
|
|
65
|
+
current = os.stat(owners_path, follow_symlinks=False)
|
|
66
|
+
if (opened.st_dev, opened.st_ino, opened.st_mtime_ns, opened.st_size) != (current.st_dev, current.st_ino, current.st_mtime_ns, current.st_size):
|
|
67
|
+
raise ValueError('Owner presence registry changed; inspect and retry registration')
|
|
68
|
+
os.fsync(stream.fileno())
|
|
69
|
+
directory = os.open(control, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
70
|
+
try:
|
|
71
|
+
try: rename_without_replacement(directory, 'owners.json', directory, 'owners.json.damaged')
|
|
72
|
+
except FileExistsError as error:
|
|
73
|
+
raise ValueError('A retained damaged owner registry already exists; operator recovery required') from error
|
|
74
|
+
os.fsync(directory)
|
|
75
|
+
finally: os.close(directory)
|
|
76
|
+
recovered = True
|
|
77
|
+
owners = {name: seen for name, seen in owners.items() if seen > now - 3600}
|
|
78
|
+
if owner not in owners and len(owners) >= 4096:
|
|
79
|
+
raise ValueError('Resource owner registry is full')
|
|
80
|
+
owners[owner] = now
|
|
81
|
+
fd, temporary = tempfile.mkstemp(dir=control)
|
|
82
|
+
published = False
|
|
83
|
+
try:
|
|
84
|
+
with os.fdopen(fd, 'w') as stream:
|
|
85
|
+
json.dump(owners, stream); stream.flush(); os.fsync(stream.fileno())
|
|
86
|
+
os.replace(temporary, owners_path)
|
|
87
|
+
published = True
|
|
88
|
+
sync_directory(control)
|
|
89
|
+
except OSError as error:
|
|
90
|
+
if published:
|
|
91
|
+
raise RuntimeError('Owner presence publication outcome is unresolved; inspect status after storage recovers before retrying: ' + str(error)) from error
|
|
92
|
+
raise
|
|
93
|
+
finally:
|
|
94
|
+
with contextlib.suppress(FileNotFoundError): os.unlink(temporary)
|
|
95
|
+
return {'registered_owner_id': owner, 'recovered_in_this_request': recovered,
|
|
96
|
+
'retained_copy_entry_present': os.path.lexists(damaged_path), 'retained_copy_name': 'owners.json.damaged',
|
|
97
|
+
'recent_owners': owners}
|
|
98
|
+
|
|
99
|
+
def read_archive_bytes(path: str, limit: int) -> bytes:
|
|
100
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
101
|
+
with os.fdopen(descriptor, 'rb') as stream:
|
|
102
|
+
info = os.fstat(stream.fileno())
|
|
103
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
|
|
104
|
+
raise ValueError('Replay index or archived evidence exceeds its regular-file bound')
|
|
105
|
+
data = stream.read(limit + 1)
|
|
106
|
+
if len(data) > limit: raise ValueError('Replay index or archived evidence exceeds its byte bound')
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
def replay_manifest(directory: str, required: bool = False) -> Optional[Dict[str, Any]]:
|
|
110
|
+
if not required and not os.path.lexists(directory): return None
|
|
111
|
+
try:
|
|
112
|
+
if not stat.S_ISDIR(os.lstat(directory).st_mode): raise ValueError('Replay index must be a real directory')
|
|
113
|
+
value = json.loads(read_archive_bytes(os.path.join(directory, 'manifest.json'), 1048576))
|
|
114
|
+
if (not isinstance(value, dict) or set(value) != {'version', 'total', 'buckets'}
|
|
115
|
+
or type(value['version']) is not int or value['version'] != 1
|
|
116
|
+
or type(value['total']) is not int or not 0 <= value['total'] <= 1000000
|
|
117
|
+
or not isinstance(value['buckets'], dict) or len(value['buckets']) > 4096):
|
|
118
|
+
raise ValueError('Invalid bounded replay index manifest')
|
|
119
|
+
count = 0
|
|
120
|
+
for prefix, bucket in value['buckets'].items():
|
|
121
|
+
if (not isinstance(prefix, str) or re.fullmatch(r'[a-f0-9]{3}', prefix) is None
|
|
122
|
+
or not isinstance(bucket, dict) or set(bucket) != {'sha256', 'count'}
|
|
123
|
+
or not isinstance(bucket['sha256'], str) or re.fullmatch(r'[a-f0-9]{64}', bucket['sha256']) is None
|
|
124
|
+
or type(bucket['count']) is not int or not 1 <= bucket['count'] <= 4096):
|
|
125
|
+
raise ValueError('Invalid replay index manifest bucket')
|
|
126
|
+
count += bucket['count']
|
|
127
|
+
if count != value['total']: raise ValueError('Replay index manifest count changed')
|
|
128
|
+
return dict(value)
|
|
129
|
+
except (ValueError, OSError, UnicodeDecodeError) as error:
|
|
130
|
+
raise ValueError('Required replay index manifest is unavailable or invalid') from error
|
|
131
|
+
|
|
132
|
+
def replay_key(parts: List[Any]) -> str:
|
|
133
|
+
return hashlib.sha256(json.dumps(parts, ensure_ascii=False, separators=(',', ':')).encode('utf-8', errors='backslashreplace')).hexdigest()
|
|
134
|
+
|
|
135
|
+
def replay_bucket(directory: str, prefix: str, bucket: Dict[str, Any]) -> Dict[str, Any]:
|
|
136
|
+
try:
|
|
137
|
+
objects = os.path.join(directory, 'objects')
|
|
138
|
+
if not stat.S_ISDIR(os.lstat(objects).st_mode): raise ValueError('Replay objects must be a real directory')
|
|
139
|
+
data = read_archive_bytes(os.path.join(objects, bucket['sha256'] + '.json'), 2097152)
|
|
140
|
+
if hashlib.sha256(data).hexdigest() != bucket['sha256']: raise ValueError('Replay bucket checksum changed')
|
|
141
|
+
value = json.loads(data)
|
|
142
|
+
if (not isinstance(value, dict) or set(value) != {'version', 'entries'} or type(value['version']) is not int
|
|
143
|
+
or value['version'] != 1 or not isinstance(value['entries'], dict) or len(value['entries']) != bucket['count']):
|
|
144
|
+
raise ValueError('Invalid replay index bucket')
|
|
145
|
+
for key, pointer in value['entries'].items():
|
|
146
|
+
if (not isinstance(key, str) or re.fullmatch(r'[a-f0-9]{64}', key) is None or not key.startswith(prefix)
|
|
147
|
+
or not isinstance(pointer, dict) or set(pointer) != {'id', 'archive', 'sha256', 'kind'}
|
|
148
|
+
or not isinstance(pointer['id'], str) or str(uuid.UUID(pointer['id'])) != pointer['id']
|
|
149
|
+
or not isinstance(pointer['archive'], str) or re.fullmatch(r'[a-f0-9]{64}', pointer['archive']) is None
|
|
150
|
+
or not isinstance(pointer['sha256'], str) or re.fullmatch(r'[a-f0-9]{64}', pointer['sha256']) is None
|
|
151
|
+
or pointer['kind'] not in ['client', 'receipt']):
|
|
152
|
+
raise ValueError('Invalid replay index pointer')
|
|
153
|
+
return dict(value['entries'])
|
|
154
|
+
except (ValueError, OSError, UnicodeDecodeError) as error:
|
|
155
|
+
raise ValueError('Required replay index bucket is unavailable or invalid') from error
|
|
156
|
+
|
|
157
|
+
def replay_lookup(directory: str, parts: List[Any], required: bool = False) -> Optional[Dict[str, Any]]:
|
|
158
|
+
header = replay_manifest(directory, required)
|
|
159
|
+
if header is None: return None
|
|
160
|
+
key = replay_key(parts)
|
|
161
|
+
bucket = header['buckets'].get(key[:3])
|
|
162
|
+
if bucket is None: return None
|
|
163
|
+
pointer = replay_bucket(directory, key[:3], bucket).get(key)
|
|
164
|
+
return dict(pointer) if pointer is not None else None
|
|
165
|
+
|
|
166
|
+
def archived_receipt(control: str, pointer: Dict[str, Any]) -> Dict[str, Any]:
|
|
167
|
+
if pointer['kind'] != 'receipt': raise ValueError('Operation archived without a remote receipt; inspect its original client export')
|
|
168
|
+
directory = os.path.join(control, 'receipt-archives')
|
|
169
|
+
for path in [directory, os.path.join(directory, pointer['archive']), os.path.join(directory, pointer['archive'], 'receipts')]:
|
|
170
|
+
if not stat.S_ISDIR(os.lstat(path).st_mode): raise ValueError('Archived receipt directory changed')
|
|
171
|
+
data = read_archive_bytes(os.path.join(directory, pointer['archive'], 'receipts', pointer['id'] + '.json'), 1048576)
|
|
172
|
+
if hashlib.sha256(data).hexdigest() != pointer['sha256']: raise ValueError('Archived receipt checksum changed')
|
|
173
|
+
value = json.loads(data)
|
|
174
|
+
if not isinstance(value, dict) or value.get('id') != pointer['id'] or value.get('version') != 1:
|
|
175
|
+
raise ValueError('Invalid archived receipt identity')
|
|
176
|
+
return dict(value)
|
|
177
|
+
|
|
178
|
+
def runtime_private_directory(path: str) -> None:
|
|
179
|
+
os.makedirs(path, mode=0o700, exist_ok=True)
|
|
180
|
+
info = os.lstat(path)
|
|
181
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
|
182
|
+
raise ValueError('Runtime archive requires a private real directory')
|
|
183
|
+
sync_directory(os.path.dirname(path))
|
|
184
|
+
|
|
185
|
+
def publish_runtime_file(path: str, data: bytes, replace: bool = False) -> None:
|
|
186
|
+
if not replace and os.path.lexists(path):
|
|
187
|
+
if read_archive_bytes(path, 2097152) != data: raise ValueError('Immutable runtime archive changed')
|
|
188
|
+
return
|
|
189
|
+
fd, temporary = tempfile.mkstemp(dir=os.path.dirname(path))
|
|
190
|
+
try:
|
|
191
|
+
with os.fdopen(fd, 'wb') as stream:
|
|
192
|
+
stream.write(data); stream.flush(); os.fsync(stream.fileno())
|
|
193
|
+
if replace: os.replace(temporary, path)
|
|
194
|
+
else: os.link(temporary, path)
|
|
195
|
+
sync_directory(os.path.dirname(path))
|
|
196
|
+
finally:
|
|
197
|
+
with contextlib.suppress(FileNotFoundError): os.unlink(temporary)
|
|
198
|
+
|
|
199
|
+
def retain_terminal_receipts(control: str, selected: List[Dict[str, Any]]) -> None:
|
|
200
|
+
# The caller holds the lease lock and selects only acknowledged terminal
|
|
201
|
+
# records without retained staging. Never discard full historical evidence.
|
|
202
|
+
if not selected: return
|
|
203
|
+
receipts = os.path.join(control, 'receipts')
|
|
204
|
+
originals = []
|
|
205
|
+
byte_count = 0
|
|
206
|
+
for receipt in selected:
|
|
207
|
+
data = read_archive_bytes(os.path.join(receipts, receipt['id'] + '.json'), 1048576)
|
|
208
|
+
byte_count += len(data)
|
|
209
|
+
if byte_count > 128 * 1048576: raise ValueError('Runtime archive exceeds its bounded batch byte budget')
|
|
210
|
+
originals.append((receipt, data, hashlib.sha256(data).hexdigest()))
|
|
211
|
+
encoded = lambda value: json.dumps(value, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
|
|
212
|
+
batch = hashlib.sha256(encoded([[receipt['id'], checksum] for receipt, data, checksum in originals])).hexdigest()
|
|
213
|
+
directory = os.path.join(control, 'replay-index')
|
|
214
|
+
header = replay_manifest(directory) or {'version': 1, 'total': 0, 'buckets': {}}
|
|
215
|
+
runtime_private_directory(directory)
|
|
216
|
+
runtime_private_directory(os.path.join(directory, 'objects'))
|
|
217
|
+
manifest_path = os.path.join(directory, 'manifest.json')
|
|
218
|
+
if not os.path.lexists(manifest_path): publish_runtime_file(manifest_path, encoded(header))
|
|
219
|
+
archive_root = os.path.join(control, 'receipt-archives')
|
|
220
|
+
archive = os.path.join(archive_root, batch, 'receipts')
|
|
221
|
+
for path in [archive_root, os.path.dirname(archive), archive]: runtime_private_directory(path)
|
|
222
|
+
additions: Dict[str, Dict[str, Any]] = {}
|
|
223
|
+
for receipt, data, checksum in originals:
|
|
224
|
+
publish_runtime_file(os.path.join(archive, receipt['id'] + '.json'), data)
|
|
225
|
+
pointer = {'id': receipt['id'], 'archive': batch, 'sha256': checksum, 'kind': 'receipt'}
|
|
226
|
+
keys: List[List[Any]] = [['operation', receipt['id']]]
|
|
227
|
+
if receipt.get('tool_call_id') is not None:
|
|
228
|
+
endpoint = receipt['endpoint']
|
|
229
|
+
keys.append(['call', endpoint['host'], endpoint['user'], endpoint['port'], receipt['owner_id'], receipt['tool_call_id']])
|
|
230
|
+
for parts in keys:
|
|
231
|
+
key = replay_key(parts)
|
|
232
|
+
prefix = key[:3]
|
|
233
|
+
if prefix not in additions:
|
|
234
|
+
additions[prefix] = replay_bucket(directory, prefix, header['buckets'][prefix]) if prefix in header['buckets'] else {}
|
|
235
|
+
previous = additions[prefix].get(key)
|
|
236
|
+
if previous is not None and (previous['id'] != pointer['id'] or previous['sha256'] != checksum or previous['kind'] != 'receipt'):
|
|
237
|
+
raise ValueError('Conflicting original runtime archive linkage; preserve active receipts')
|
|
238
|
+
if previous is None: additions[prefix][key] = pointer
|
|
239
|
+
for prefix, entries in additions.items():
|
|
240
|
+
data = encoded({'version': 1, 'entries': entries})
|
|
241
|
+
if len(entries) > 4096 or len(data) > 2097152:
|
|
242
|
+
raise ValueError('Replay bucket is full; existing protection cannot be discarded')
|
|
243
|
+
header['total'] += len(entries) - header['buckets'].get(prefix, {}).get('count', 0)
|
|
244
|
+
checksum = hashlib.sha256(data).hexdigest()
|
|
245
|
+
publish_runtime_file(os.path.join(directory, 'objects', checksum + '.json'), data)
|
|
246
|
+
header['buckets'][prefix] = {'sha256': checksum, 'count': len(entries)}
|
|
247
|
+
data = encoded(header)
|
|
248
|
+
if header['total'] > 1000000 or len(data) > 1048576:
|
|
249
|
+
raise ValueError('Runtime replay index exceeds its bounded key budget')
|
|
250
|
+
publish_runtime_file(manifest_path, data, replace=True)
|
|
251
|
+
for receipt, data, checksum in originals:
|
|
252
|
+
path = os.path.join(receipts, receipt['id'] + '.json')
|
|
253
|
+
if read_archive_bytes(path, 1048576) != data: raise ValueError('Active runtime receipt changed before retirement')
|
|
254
|
+
os.unlink(path)
|
|
255
|
+
sync_directory(receipts)
|
|
256
|
+
|
|
257
|
+
def main(q: Dict[str, Any]) -> Dict[str, Any]:
|
|
258
|
+
global admitted
|
|
259
|
+
root = os.path.realpath(q['root'])
|
|
260
|
+
if not os.path.isabs(q['root']) or not os.path.isdir(root):
|
|
261
|
+
raise ValueError('Resource root must be an existing absolute directory')
|
|
262
|
+
owner = q['owner_id']
|
|
263
|
+
if not isinstance(owner, str) or not owner or len(owner) > 200:
|
|
264
|
+
raise ValueError('Invalid immutable owner ID')
|
|
265
|
+
control = os.path.realpath(os.path.expanduser('~/.ztc/resource-leases'))
|
|
266
|
+
os.makedirs(control, mode=0o700, exist_ok=True)
|
|
267
|
+
state_path = os.path.join(control, 'leases.json')
|
|
268
|
+
client_journal = q.get('client_journal')
|
|
269
|
+
if client_journal is not None and (not isinstance(client_journal, str) or not os.path.isabs(client_journal)):
|
|
270
|
+
raise ValueError('Invalid host-bound client journal')
|
|
271
|
+
staging = q.get('host_delete_staging', os.path.join(control, 'deletion-staging'))
|
|
272
|
+
if not isinstance(staging, str) or not os.path.isabs(staging) or os.path.normpath(staging) != staging:
|
|
273
|
+
raise ValueError('Invalid host-bound deletion staging directory')
|
|
274
|
+
write_staging = q.get('host_write_staging', staging)
|
|
275
|
+
if not isinstance(write_staging, str) or not os.path.isabs(write_staging) or os.path.normpath(write_staging) != write_staging:
|
|
276
|
+
raise ValueError('Invalid host-bound write staging directory')
|
|
277
|
+
staging_bindings: Dict[str, Any] = {staging: q.get('host_delete_staging_identity')}
|
|
278
|
+
host_staging_bindings = q.get('host_write_staging_directories')
|
|
279
|
+
if host_staging_bindings is not None:
|
|
280
|
+
if not isinstance(host_staging_bindings, dict) or len(host_staging_bindings) > 201:
|
|
281
|
+
raise ValueError('Invalid host-bound write staging inventory')
|
|
282
|
+
for stage_path, stage_identity in host_staging_bindings.items():
|
|
283
|
+
if (not isinstance(stage_path, str) or not os.path.isabs(stage_path) or os.path.normpath(stage_path) != stage_path
|
|
284
|
+
or not isinstance(stage_identity, list) or len(stage_identity) != 2
|
|
285
|
+
or any(type(item) is not int or item < 0 for item in stage_identity)):
|
|
286
|
+
raise ValueError('Invalid host-bound write staging identity')
|
|
287
|
+
if (staging not in host_staging_bindings or write_staging not in host_staging_bindings
|
|
288
|
+
or host_staging_bindings[staging] != q.get('host_delete_staging_identity')
|
|
289
|
+
or host_staging_bindings[write_staging] != q.get('host_write_staging_identity')):
|
|
290
|
+
raise ValueError('Host write staging selection lacks its pinned identity')
|
|
291
|
+
staging_bindings.update(host_staging_bindings)
|
|
292
|
+
elif write_staging != staging:
|
|
293
|
+
raise ValueError('Configured write staging requires a pinned host inventory')
|
|
294
|
+
protected = [control, *staging_bindings] + ([os.path.realpath(client_journal + suffix)
|
|
295
|
+
for suffix in ['', '.archives', '.replay', '.replay-required.json', '.admission-lock', '.maintenance.json']] if client_journal else [])
|
|
296
|
+
def protected_target(target: str) -> bool:
|
|
297
|
+
return any(os.path.commonpath([path, target]) in [path, target] for path in protected)
|
|
298
|
+
key = hashlib.sha256(root.encode()).hexdigest()
|
|
299
|
+
def identity(path: str) -> List[int]:
|
|
300
|
+
s = os.stat(path)
|
|
301
|
+
return [s.st_dev, s.st_ino]
|
|
302
|
+
def ancestors(path: str) -> List[List[int]]:
|
|
303
|
+
result = []
|
|
304
|
+
while True:
|
|
305
|
+
result.append(identity(path))
|
|
306
|
+
parent = os.path.dirname(path)
|
|
307
|
+
if parent == path: return result
|
|
308
|
+
path = parent
|
|
309
|
+
root_id, root_ancestors = identity(root), ancestors(root)
|
|
310
|
+
if q.get('host_root_identity') is not None and q['host_root_identity'] != root_id:
|
|
311
|
+
raise ValueError('Host broker resource identity changed')
|
|
312
|
+
host_region: Optional[Dict[str, Any]] = q.get('host_filesystem_region')
|
|
313
|
+
mountinfo_sha256 = q.get('host_mountinfo_sha256')
|
|
314
|
+
def valid_region(value: Any) -> bool:
|
|
315
|
+
return (isinstance(value, dict) and set(value) == {'version', 'device', 'path'}
|
|
316
|
+
and type(value['version']) is int and value['version'] == 1
|
|
317
|
+
and isinstance(value['device'], str) and re.fullmatch(r'[0-9]+:[0-9]+', value['device']) is not None
|
|
318
|
+
and isinstance(value['path'], str) and len(value['path']) <= 4096 and '\x00' not in value['path']
|
|
319
|
+
and os.path.isabs(value['path']) and os.path.normpath(value['path']) == value['path'])
|
|
320
|
+
def proven_region(record: Dict[str, Any]) -> bool:
|
|
321
|
+
return (record.get('mountinfo_sha256') == mountinfo_sha256
|
|
322
|
+
and valid_region(record.get('filesystem_region')))
|
|
323
|
+
def overlaps(record: Dict[str, Any]) -> bool:
|
|
324
|
+
if host_region is not None:
|
|
325
|
+
# Missing or stale evidence cannot establish that two roots are disjoint.
|
|
326
|
+
if not proven_region(record): return True
|
|
327
|
+
other = record['filesystem_region']
|
|
328
|
+
if (other['device'] == host_region['device']
|
|
329
|
+
and os.path.commonpath([other['path'], host_region['path']]) in [other['path'], host_region['path']]):
|
|
330
|
+
return True
|
|
331
|
+
return record['identity'] in root_ancestors or root_id in record['ancestors']
|
|
332
|
+
with open(os.path.join(control, 'lock'), 'a') as lock:
|
|
333
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
334
|
+
# Operator cutover leaves the old authority frozen across restarts.
|
|
335
|
+
# Any entry (including damaged evidence) requires explicit recovery;
|
|
336
|
+
# a request cannot supply an override or silently clear this fence.
|
|
337
|
+
if os.path.lexists(os.path.join(control, 'cutover-fence.json')):
|
|
338
|
+
raise ValueError('Resource cutover authority is frozen for operator review')
|
|
339
|
+
if os.path.lexists(os.path.join(control, 'journal-maintenance.json')):
|
|
340
|
+
raise ValueError('Resource journal maintenance is frozen; inspect and resume the confirmed archive')
|
|
341
|
+
replay_directory = os.path.join(control, 'replay-index')
|
|
342
|
+
replay_required = q.get('host_receipt_archive_index') is True or os.path.lexists(os.path.join(control, 'receipt-archives'))
|
|
343
|
+
if q['operation'] in ['claim', 'transfer', 'stop', 'write', 'delete', 'ack', 'resolve']:
|
|
344
|
+
replay_manifest(replay_directory, replay_required)
|
|
345
|
+
if q['operation'] in ['write', 'delete']:
|
|
346
|
+
retired_id = q.get('operation_id')
|
|
347
|
+
pointer = replay_lookup(replay_directory, ['operation', retired_id], replay_required)
|
|
348
|
+
call = q.get('tool_call_id')
|
|
349
|
+
if pointer is None and q['operation'] in ['write', 'delete'] and call is not None:
|
|
350
|
+
endpoint = q['endpoint']
|
|
351
|
+
pointer = replay_lookup(replay_directory, ['call', endpoint['host'], endpoint['user'], endpoint['port'], owner, call], replay_required)
|
|
352
|
+
if pointer is not None:
|
|
353
|
+
raise ValueError('Operation archived as ' + pointer['id'] + '; inspect original evidence instead of replaying')
|
|
354
|
+
# Settle an earlier unacknowledged rename before using its lease. Sync
|
|
355
|
+
# every actual ancestor so newly created control paths (including a
|
|
356
|
+
# previous interrupted initialization) survive directory-entry loss.
|
|
357
|
+
control_ancestor = control
|
|
358
|
+
while True:
|
|
359
|
+
sync_directory(control_ancestor)
|
|
360
|
+
parent_directory = os.path.dirname(control_ancestor)
|
|
361
|
+
if parent_directory == control_ancestor: break
|
|
362
|
+
control_ancestor = parent_directory
|
|
363
|
+
if host_region is not None or mountinfo_sha256 is not None:
|
|
364
|
+
if (not valid_region(host_region) or not isinstance(mountinfo_sha256, str)
|
|
365
|
+
or re.fullmatch(r'[a-f0-9]{64}', mountinfo_sha256) is None):
|
|
366
|
+
raise ValueError('Invalid host mount evidence')
|
|
367
|
+
host_roots = q.get('host_resource_roots')
|
|
368
|
+
if not isinstance(host_roots, dict) or q['root'] not in host_roots:
|
|
369
|
+
raise ValueError('Invalid host resource root evidence')
|
|
370
|
+
if root != q['root'] or root_id != host_roots[q['root']]:
|
|
371
|
+
raise ValueError('Host broker resource canonical path or identity changed before locking')
|
|
372
|
+
# Check every configured root: moving another owner's root can
|
|
373
|
+
# change overlap even when the requested root itself is unchanged.
|
|
374
|
+
for configured_root, configured_identity in host_roots.items():
|
|
375
|
+
if (not isinstance(configured_root, str) or not os.path.isabs(configured_root)
|
|
376
|
+
or os.path.realpath(configured_root) != configured_root
|
|
377
|
+
or not os.path.isdir(configured_root) or identity(configured_root) != configured_identity):
|
|
378
|
+
raise ValueError('Host broker resource canonical path or identity changed')
|
|
379
|
+
with open('/proc/self/mountinfo', 'rb') as mount_stream:
|
|
380
|
+
mount_snapshot = mount_stream.read(8 * 1024 * 1024 + 1)
|
|
381
|
+
if (len(mount_snapshot) > 8 * 1024 * 1024
|
|
382
|
+
or hashlib.sha256(mount_snapshot).hexdigest() != mountinfo_sha256):
|
|
383
|
+
raise ValueError('Host mount topology changed before lease admission')
|
|
384
|
+
records: Dict[str, Dict[str, Any]]
|
|
385
|
+
record: Optional[Dict[str, Any]]
|
|
386
|
+
try:
|
|
387
|
+
with open(state_path) as lease_stream: records = json.load(lease_stream)
|
|
388
|
+
except FileNotFoundError: records = {}
|
|
389
|
+
if not isinstance(records, dict): raise ValueError('Invalid resource lease registry')
|
|
390
|
+
for record in records.values():
|
|
391
|
+
if (not isinstance(record, dict) or not isinstance(record.get('generation'), int)
|
|
392
|
+
or not isinstance(record.get('expires_at'), (int, float))
|
|
393
|
+
or not math.isfinite(record['expires_at']) or not isinstance(record.get('root'), str)
|
|
394
|
+
or not isinstance(record.get('identity'), list) or not isinstance(record.get('ancestors'), list)
|
|
395
|
+
or record.get('owner_id') is not None and not isinstance(record['owner_id'], str)):
|
|
396
|
+
raise ValueError('Invalid resource lease registry')
|
|
397
|
+
now = time.time()
|
|
398
|
+
presence = register_owner_presence(control, owner, now)
|
|
399
|
+
owners = presence.pop('recent_owners')
|
|
400
|
+
record = records.get(key)
|
|
401
|
+
if record is not None and record['identity'] != root_id:
|
|
402
|
+
raise ValueError('Resource identity changed; configure and confirm the replacement resource')
|
|
403
|
+
op = q['operation']
|
|
404
|
+
def active(record: Dict[str, Any]) -> bool:
|
|
405
|
+
return bool(record['owner_id'] is not None and record['expires_at'] > now)
|
|
406
|
+
conflicts = [r for r in records.values() if overlaps(r) and active(r) and r['owner_id'] != owner]
|
|
407
|
+
status = {'root': root, 'owner_id': record['owner_id'] if record and active(record) else None,
|
|
408
|
+
'generation': record['generation'] if record else 0,
|
|
409
|
+
'expires_at': record['expires_at'] if record else None,
|
|
410
|
+
'authorized': bool(record and active(record) and record['owner_id'] == owner and not conflicts),
|
|
411
|
+
'conflicts': [{'root': r['root'], 'owner_id': r['owner_id']} for r in conflicts],
|
|
412
|
+
'owner_presence': presence}
|
|
413
|
+
receipts = os.path.join(control, 'receipts')
|
|
414
|
+
def persist_receipt(receipt: Dict[str, Any]) -> None:
|
|
415
|
+
os.makedirs(receipts, mode=0o700, exist_ok=True)
|
|
416
|
+
directory = os.open(control, os.O_RDONLY | os.O_DIRECTORY)
|
|
417
|
+
try: os.fsync(directory)
|
|
418
|
+
finally: os.close(directory)
|
|
419
|
+
fd, temporary = tempfile.mkstemp(dir=receipts)
|
|
420
|
+
try:
|
|
421
|
+
with os.fdopen(fd, 'w') as f:
|
|
422
|
+
json.dump(receipt, f); f.flush(); os.fsync(f.fileno())
|
|
423
|
+
os.replace(temporary, os.path.join(receipts, receipt['id'] + '.json'))
|
|
424
|
+
directory = os.open(receipts, os.O_RDONLY | os.O_DIRECTORY)
|
|
425
|
+
try: os.fsync(directory)
|
|
426
|
+
finally: os.close(directory)
|
|
427
|
+
finally:
|
|
428
|
+
with contextlib.suppress(FileNotFoundError): os.unlink(temporary)
|
|
429
|
+
def journal() -> List[Dict[str, Any]]:
|
|
430
|
+
try: names = os.listdir(receipts)
|
|
431
|
+
except FileNotFoundError: return []
|
|
432
|
+
if len(names) > 10000: raise ValueError('Receipt journal is full; confirmed maintenance is required')
|
|
433
|
+
result = []
|
|
434
|
+
for name in names:
|
|
435
|
+
if not name.endswith('.json') or str(uuid.UUID(name[:-5])) != name[:-5]:
|
|
436
|
+
raise ValueError('Invalid receipt journal entry; recovery is required')
|
|
437
|
+
with open(os.path.join(receipts, name)) as f: receipt = json.load(f)
|
|
438
|
+
if (receipt.get('version') != 1 or receipt.get('id') + '.json' != name
|
|
439
|
+
or receipt.get('operation') not in ['delete', 'write']
|
|
440
|
+
or not isinstance(receipt.get('target'), dict) or not isinstance(receipt['target'].get('path'), str)
|
|
441
|
+
or not os.path.isabs(receipt['target']['path'])
|
|
442
|
+
or receipt.get('status') not in ['pending', 'verified', 'failed', 'unverified', 'dry_run', 'cancelled']
|
|
443
|
+
or 'admission_resolved' in receipt and not isinstance(receipt['admission_resolved'], bool)):
|
|
444
|
+
raise ValueError('Invalid receipt journal record; recovery is required')
|
|
445
|
+
result.append(receipt)
|
|
446
|
+
return result
|
|
447
|
+
def inspect_staging(receipt: Dict[str, Any], sample: bool = False,
|
|
448
|
+
operation: str = 'deletion', leaf: str = 'target') -> Optional[Dict[str, Any]]:
|
|
449
|
+
stage = receipt.get(operation + '_staging')
|
|
450
|
+
if stage is None: return None
|
|
451
|
+
receipt_id = receipt['id']
|
|
452
|
+
path_key = 'payload_path' if leaf == 'payload' else 'path'
|
|
453
|
+
if not isinstance(stage, dict) or not isinstance(stage.get('directory_identity'), list):
|
|
454
|
+
raise ValueError('Invalid ' + operation + ' staging receipt; operator recovery is required')
|
|
455
|
+
stage_choices = [path for path in (staging_bindings if operation == 'write' else [staging])
|
|
456
|
+
if stage.get(path_key) == os.path.join(path, receipt_id, leaf)]
|
|
457
|
+
if len(stage_choices) != 1:
|
|
458
|
+
raise ValueError('Invalid ' + operation + ' staging receipt; operator recovery is required')
|
|
459
|
+
receipt_staging = stage_choices[0]
|
|
460
|
+
expected_staging_identity = staging_bindings[receipt_staging]
|
|
461
|
+
retained: Dict[str, Any] = {'path': stage[path_key], 'recorded_state': stage['state'], 'exists': False}
|
|
462
|
+
with contextlib.ExitStack() as stage_handles:
|
|
463
|
+
stage_root = os.open(receipt_staging, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
464
|
+
stage_handles.callback(os.close, stage_root)
|
|
465
|
+
private = os.fstat(stage_root)
|
|
466
|
+
if (private.st_uid != os.geteuid() or private.st_mode & 0o077
|
|
467
|
+
or expected_staging_identity is not None
|
|
468
|
+
and expected_staging_identity != [private.st_dev, private.st_ino]):
|
|
469
|
+
raise ValueError('Host ' + operation + ' staging changed during reconciliation')
|
|
470
|
+
try:
|
|
471
|
+
stage_directory = os.open(receipt_id, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=stage_root)
|
|
472
|
+
stage_handles.callback(os.close, stage_directory)
|
|
473
|
+
private_operation = os.fstat(stage_directory)
|
|
474
|
+
if ([private_operation.st_dev, private_operation.st_ino] != stage['directory_identity']
|
|
475
|
+
or private_operation.st_uid != os.geteuid() or private_operation.st_mode & 0o077):
|
|
476
|
+
raise ValueError(operation.capitalize() + ' staging operation identity changed')
|
|
477
|
+
info = os.stat(leaf, dir_fd=stage_directory, follow_symlinks=False)
|
|
478
|
+
retained.update(exists=True, device=info.st_dev, inode=info.st_ino,
|
|
479
|
+
mode=info.st_mode, size=info.st_size, mtime_ns=info.st_mtime_ns, ctime_ns=info.st_ctime_ns)
|
|
480
|
+
if sample and stat.S_ISREG(info.st_mode):
|
|
481
|
+
fd = os.open(leaf, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=stage_directory)
|
|
482
|
+
with os.fdopen(fd, 'rb') as retained_stream:
|
|
483
|
+
opened = os.fstat(retained_stream.fileno())
|
|
484
|
+
if [opened.st_dev, opened.st_ino] != [info.st_dev, info.st_ino]:
|
|
485
|
+
raise ValueError('Retained ' + operation + ' target changed during reconciliation')
|
|
486
|
+
retained['sample_sha256'] = hashlib.sha256(retained_stream.read(1048576)).hexdigest()
|
|
487
|
+
elif stat.S_ISLNK(info.st_mode):
|
|
488
|
+
retained['link_target'] = os.readlink(leaf, dir_fd=stage_directory)
|
|
489
|
+
except FileNotFoundError: pass
|
|
490
|
+
return retained
|
|
491
|
+
def retain_receipts(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
492
|
+
if len(entries) < 512: return entries
|
|
493
|
+
selected = []
|
|
494
|
+
for receipt in sorted(entries, key=lambda value: (value.get('completed_at') or 0, value['id'])):
|
|
495
|
+
if len(selected) >= 64: break
|
|
496
|
+
if receipt.get('admission_resolved') is not True or receipt['status'] not in ['verified', 'failed', 'dry_run', 'cancelled']:
|
|
497
|
+
continue
|
|
498
|
+
verification = receipt.get('verification')
|
|
499
|
+
if receipt['status'] == 'verified' and (not isinstance(verification, dict)
|
|
500
|
+
or verification.get('target_matches') is not True and verification.get('target_absent') is not True):
|
|
501
|
+
continue
|
|
502
|
+
resolution = receipt.get('resolution')
|
|
503
|
+
if receipt['status'] == 'cancelled' and (not isinstance(resolution, dict) or resolution.get('decision') != 'accept_current_state'):
|
|
504
|
+
continue
|
|
505
|
+
if any(retained is not None and retained['exists'] for retained in [
|
|
506
|
+
inspect_staging(receipt), inspect_staging(receipt, operation='write'),
|
|
507
|
+
inspect_staging(receipt, operation='write', leaf='payload')]):
|
|
508
|
+
continue
|
|
509
|
+
selected.append(receipt)
|
|
510
|
+
retain_terminal_receipts(control, selected)
|
|
511
|
+
selected_ids = {receipt['id'] for receipt in selected}
|
|
512
|
+
return [receipt for receipt in entries if receipt['id'] not in selected_ids]
|
|
513
|
+
if op == 'status':
|
|
514
|
+
try:
|
|
515
|
+
pending = [r for r in journal() if not r.get('admission_resolved', r['status'] in ['verified', 'failed', 'dry_run'])
|
|
516
|
+
and (r['target']['path'] == root or r['target']['path'].startswith(root + os.sep) or root.startswith(r['target']['path'] + os.sep))]
|
|
517
|
+
status['unresolved_operations'] = [{'id': r['id'], 'operation': r['operation'], 'status': r['status'],
|
|
518
|
+
'target': r['target']['path'], 'owner_id': r['owner_id']} for r in pending[:100]]
|
|
519
|
+
status['unresolved_operation_count'] = len(pending)
|
|
520
|
+
except (ValueError, OSError, TypeError, KeyError) as error:
|
|
521
|
+
status['mutation_journal_error'] = str(error)[:2000]
|
|
522
|
+
return status
|
|
523
|
+
def save() -> None:
|
|
524
|
+
global admitted
|
|
525
|
+
fd, path = tempfile.mkstemp(dir=control)
|
|
526
|
+
try:
|
|
527
|
+
with os.fdopen(fd, 'w') as f:
|
|
528
|
+
json.dump(records, f); f.flush(); os.fsync(f.fileno())
|
|
529
|
+
# Replacement can take effect even when its response is lost.
|
|
530
|
+
# Never describe a post-dispatch publication error as refusal.
|
|
531
|
+
admitted = True
|
|
532
|
+
os.replace(path, state_path)
|
|
533
|
+
sync_directory(control)
|
|
534
|
+
except OSError as error:
|
|
535
|
+
if admitted:
|
|
536
|
+
raise RuntimeError('Lease publication outcome is unresolved; inspect current owner and generation before retrying: ' + str(error)) from error
|
|
537
|
+
raise
|
|
538
|
+
finally:
|
|
539
|
+
with contextlib.suppress(FileNotFoundError): os.unlink(path)
|
|
540
|
+
if op in ['claim', 'transfer', 'stop']:
|
|
541
|
+
expected = q['expected_generation']
|
|
542
|
+
if not isinstance(expected, int) or isinstance(expected, bool) or expected != status['generation']:
|
|
543
|
+
raise ValueError('Lease changed; inspect current owner and retry the confirmed action')
|
|
544
|
+
own_active_record = bool(record and active(record) and record['owner_id'] == owner)
|
|
545
|
+
if (op == 'stop' and not own_active_record) or (op == 'transfer' and not status['authorized']):
|
|
546
|
+
raise ValueError('Only the recorded current owner may stop or transfer this lease')
|
|
547
|
+
if op == 'claim' and conflicts:
|
|
548
|
+
if host_region is not None and any(not proven_region(other) for other in conflicts):
|
|
549
|
+
raise ValueError('Resource overlap requires recovery: confirmed owners must stop or renew unproven leases')
|
|
550
|
+
raise ValueError('Resource is owned by another agent: ' + json.dumps(status['conflicts']))
|
|
551
|
+
ttl = q.get('ttl_seconds', 900)
|
|
552
|
+
if not isinstance(ttl, int) or isinstance(ttl, bool) or ttl < 1 or ttl > 3600:
|
|
553
|
+
raise ValueError('Lease duration must be 1 to 3600 seconds')
|
|
554
|
+
target = q.get('target_owner_id') if op == 'transfer' else owner
|
|
555
|
+
if op == 'transfer' and (not isinstance(target, str) or not target or len(target) > 200 or target == owner):
|
|
556
|
+
raise ValueError('Transfer requires a different host-assigned owner label')
|
|
557
|
+
if op == 'transfer' and target not in owners:
|
|
558
|
+
raise ValueError('Target owner must be registered by its own recent host broker request; inspect status in the target pane')
|
|
559
|
+
if op == 'transfer' and any(other is not record and active(other) and overlaps(other) and other['owner_id'] != target for other in records.values()):
|
|
560
|
+
raise ValueError('Stop overlapping leases before transferring this resource')
|
|
561
|
+
records[key] = {'root': root, 'identity': root_id, 'ancestors': root_ancestors,
|
|
562
|
+
'owner_id': None if op == 'stop' else target, 'expires_at': 0 if op == 'stop' else now + ttl,
|
|
563
|
+
'generation': status['generation'] + 1,
|
|
564
|
+
'filesystem_region': host_region, 'mountinfo_sha256': mountinfo_sha256}
|
|
565
|
+
save()
|
|
566
|
+
return records[key]
|
|
567
|
+
if record is None or not status['authorized']:
|
|
568
|
+
raise ValueError('Resource lease required; current owner: ' + str(status['owner_id']) + '; conflicts: ' + json.dumps(status['conflicts']))
|
|
569
|
+
if op in ['receipt', 'ack', 'reconcile', 'resolve']:
|
|
570
|
+
receipt_id = q.get('receipt_id')
|
|
571
|
+
if not isinstance(receipt_id, str): raise ValueError('Receipt ID must be a canonical operation UUID')
|
|
572
|
+
try: parsed_id = str(uuid.UUID(receipt_id))
|
|
573
|
+
except ValueError: raise ValueError('Receipt ID must be a canonical operation UUID')
|
|
574
|
+
if parsed_id != receipt_id: raise ValueError('Receipt ID must be a canonical operation UUID')
|
|
575
|
+
receipt_archive: Optional[Dict[str, Any]] = None
|
|
576
|
+
try:
|
|
577
|
+
with open(os.path.join(receipts, receipt_id + '.json')) as receipt_stream: receipt = json.load(receipt_stream)
|
|
578
|
+
except FileNotFoundError:
|
|
579
|
+
pointer = replay_lookup(replay_directory, ['operation', receipt_id], replay_required)
|
|
580
|
+
if pointer is not None:
|
|
581
|
+
receipt = archived_receipt(control, pointer)
|
|
582
|
+
if receipt.get('admission_resolved') is not True:
|
|
583
|
+
raise ValueError('Archived operation lacks its resolved admission; operator recovery required')
|
|
584
|
+
receipt_archive = {'historical': True, 'digest': pointer['archive'], 'record_sha256': pointer['sha256']}
|
|
585
|
+
else:
|
|
586
|
+
if op not in ['reconcile', 'resolve']: raise
|
|
587
|
+
receipt = None
|
|
588
|
+
if receipt is not None and receipt['root_identity'] != root_id:
|
|
589
|
+
raise ValueError('Receipt does not belong to this physical resource')
|
|
590
|
+
if op in ['receipt', 'ack']:
|
|
591
|
+
if receipt['owner_id'] != owner or receipt['resource_id'] != q['resource_id']:
|
|
592
|
+
raise ValueError('Receipt does not belong to this owner and resource')
|
|
593
|
+
if op == 'ack':
|
|
594
|
+
if receipt['status'] not in ['verified', 'failed', 'dry_run']:
|
|
595
|
+
raise ValueError('Unresolved outcome requires confirmed reconciliation')
|
|
596
|
+
if receipt_archive is None:
|
|
597
|
+
receipt['admission_resolved'] = True
|
|
598
|
+
persist_receipt(receipt)
|
|
599
|
+
return {'receipt': receipt, **({'archive': receipt_archive} if receipt_archive is not None else {})}
|
|
600
|
+
path = receipt['requested_path'] if receipt else q.get('path')
|
|
601
|
+
if not isinstance(path, str) or not path or os.path.isabs(path) or '\x00' in path:
|
|
602
|
+
raise ValueError('Reconciliation requires the original relative target path')
|
|
603
|
+
target = os.path.normpath(os.path.join(root, path))
|
|
604
|
+
if target == root or os.path.commonpath([root, target]) != root or protected_target(target):
|
|
605
|
+
raise ValueError('Reconciliation target escapes the resource or its protected boundary')
|
|
606
|
+
# Inspect held, no-follow descriptors. Never probe through a substituted parent.
|
|
607
|
+
with contextlib.ExitStack() as handles:
|
|
608
|
+
parent = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
609
|
+
handles.callback(os.close, parent)
|
|
610
|
+
parts = os.path.relpath(target, root).split(os.sep)
|
|
611
|
+
if [os.fstat(parent).st_dev, os.fstat(parent).st_ino] != root_id:
|
|
612
|
+
raise ValueError('Resource identity changed during reconciliation')
|
|
613
|
+
try:
|
|
614
|
+
for part in parts[:-1]:
|
|
615
|
+
parent = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
|
|
616
|
+
handles.callback(os.close, parent)
|
|
617
|
+
info = os.stat(parts[-1], dir_fd=parent, follow_symlinks=False)
|
|
618
|
+
current: Dict[str, Any] = {'exists': True, 'device': info.st_dev, 'inode': info.st_ino,
|
|
619
|
+
'mode': info.st_mode, 'size': info.st_size, 'mtime_ns': info.st_mtime_ns, 'ctime_ns': info.st_ctime_ns}
|
|
620
|
+
if stat.S_ISREG(info.st_mode):
|
|
621
|
+
fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
|
622
|
+
with os.fdopen(fd, 'rb') as reconciliation_stream:
|
|
623
|
+
observed = os.fstat(reconciliation_stream.fileno())
|
|
624
|
+
if [observed.st_dev, observed.st_ino] != [info.st_dev, info.st_ino]:
|
|
625
|
+
raise ValueError('Target changed during reconciliation')
|
|
626
|
+
current['sample_sha256'] = hashlib.sha256(reconciliation_stream.read(1048576)).hexdigest()
|
|
627
|
+
except FileNotFoundError: current = {'exists': False}
|
|
628
|
+
observation = {'receipt_id': receipt_id, 'receipt_status': receipt['status'] if receipt else 'not_recorded',
|
|
629
|
+
'admission_resolved': receipt.get('admission_resolved', False) if receipt else False,
|
|
630
|
+
'root_identity': root_id, 'target': target, 'current_target': current, 'generation': record['generation']}
|
|
631
|
+
retained = inspect_staging(receipt, sample=True) if receipt is not None else None
|
|
632
|
+
if retained is not None: observation['current_staging'] = retained
|
|
633
|
+
if receipt is not None and 'write_staging' in receipt:
|
|
634
|
+
observation['current_write_staging'] = {
|
|
635
|
+
name: inspect_staging(receipt, sample=True, operation='write', leaf=name)
|
|
636
|
+
for name in ['target', 'payload']}
|
|
637
|
+
token = hashlib.sha256(json.dumps(observation, sort_keys=True).encode()).hexdigest()
|
|
638
|
+
if op == 'resolve':
|
|
639
|
+
if q.get('observation_token') != token:
|
|
640
|
+
raise ValueError('Reconciliation changed; inspect the current receipt and target before confirming')
|
|
641
|
+
if receipt_archive is not None:
|
|
642
|
+
return {'receipt': receipt, 'archive': receipt_archive, 'observation': observation,
|
|
643
|
+
'observation_token': token, 'decision_required': False, 'decision': 'accept_current_state'}
|
|
644
|
+
if receipt is None:
|
|
645
|
+
if len(journal()) >= 10000: raise ValueError('Receipt journal is full; confirmed maintenance is required')
|
|
646
|
+
original_operation = q.get('original_operation')
|
|
647
|
+
if original_operation not in ['delete', 'write']: raise ValueError('Original mutation is required for cancellation')
|
|
648
|
+
original_owner = q.get('original_owner_id', owner)
|
|
649
|
+
original_call = q.get('original_tool_call_id')
|
|
650
|
+
if not isinstance(original_owner, str) or not original_owner or len(original_owner) > 200:
|
|
651
|
+
raise ValueError('Invalid original mutation owner')
|
|
652
|
+
if original_call is not None and (not isinstance(original_call, str) or not original_call or len(original_call) > 200):
|
|
653
|
+
raise ValueError('Invalid original mutation call')
|
|
654
|
+
# A durable tombstone denies a delayed request with this ID, even if it never arrived before inspection.
|
|
655
|
+
receipt = {'version': 1, 'id': receipt_id, 'status': 'cancelled', 'operation': original_operation,
|
|
656
|
+
'owner_id': original_owner, 'tool_call_id': original_call, 'resource_id': q['resource_id'], 'root_identity': root_id,
|
|
657
|
+
'configured_root': q['root'], 'root': root, 'requested_path': path,
|
|
658
|
+
'target': {'path': target}, 'endpoint': q['endpoint']}
|
|
659
|
+
receipt['admission_resolved'] = True
|
|
660
|
+
receipt['resolution'] = {'decision': 'accept_current_state', 'confirmed_by': owner,
|
|
661
|
+
'confirmed_at': time.time(), 'observation': observation, 'observation_token': token}
|
|
662
|
+
persist_receipt(receipt)
|
|
663
|
+
return {'receipt': receipt, 'observation': observation, 'observation_token': token,
|
|
664
|
+
'decision_required': op == 'reconcile', 'decision': 'accept_current_state' if op == 'resolve' else None}
|
|
665
|
+
if op not in ['read', 'list', 'stat', 'write', 'delete']:
|
|
666
|
+
raise ValueError('Unsupported managed resource operation')
|
|
667
|
+
path = q.get('path', '.')
|
|
668
|
+
if not isinstance(path, str) or not path or os.path.isabs(path) or '\x00' in path:
|
|
669
|
+
raise ValueError('Resource path must be relative to the configured root')
|
|
670
|
+
target = os.path.realpath(os.path.join(root, path))
|
|
671
|
+
if os.path.commonpath([root, target]) != root:
|
|
672
|
+
raise ValueError('Resource path escapes the configured root')
|
|
673
|
+
if protected_target(target):
|
|
674
|
+
raise ValueError('Resource lease control files are protected')
|
|
675
|
+
if op in ['write', 'delete'] and target == root:
|
|
676
|
+
raise ValueError('Cannot mutate the resource root itself')
|
|
677
|
+
dry = q.get('dry_run', False)
|
|
678
|
+
if not isinstance(dry, bool): raise ValueError('dry_run must be a boolean')
|
|
679
|
+
if dry and op != 'delete': return dict(status, path=target, operation=op, dry_run=True)
|
|
680
|
+
# The lease lock spans verification and the complete filesystem action.
|
|
681
|
+
# Transfers and overlapping operations cannot cross this boundary.
|
|
682
|
+
# Walk held directory descriptors without following links. A path swap
|
|
683
|
+
# after validation cannot redirect the filesystem operation outside its
|
|
684
|
+
# verified root, even if another process renames a parent directory.
|
|
685
|
+
with contextlib.ExitStack() as handles:
|
|
686
|
+
def held(fd: int) -> int:
|
|
687
|
+
handles.callback(os.close, fd)
|
|
688
|
+
return fd
|
|
689
|
+
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
690
|
+
parent = held(os.open(root, flags))
|
|
691
|
+
s = os.fstat(parent)
|
|
692
|
+
if [s.st_dev, s.st_ino] != root_id: raise ValueError('Resource identity changed during execution')
|
|
693
|
+
parts = os.path.relpath(target, root).split(os.sep)
|
|
694
|
+
namespaces = []
|
|
695
|
+
directory_ids = [root_id]
|
|
696
|
+
for part in parts[:-1]:
|
|
697
|
+
info = os.fstat(parent)
|
|
698
|
+
namespaces.append({'parent': [info.st_dev, info.st_ino], 'leaf': part})
|
|
699
|
+
parent = held(os.open(part, flags, dir_fd=parent))
|
|
700
|
+
info = os.fstat(parent)
|
|
701
|
+
directory_ids.append([info.st_dev, info.st_ino])
|
|
702
|
+
leaf = parts[-1]
|
|
703
|
+
info = os.fstat(parent)
|
|
704
|
+
namespace = {'parent': [info.st_dev, info.st_ino], 'leaf': leaf}
|
|
705
|
+
namespaces.append(namespace)
|
|
706
|
+
if op == 'list':
|
|
707
|
+
directory = held(os.open(leaf, flags, dir_fd=parent))
|
|
708
|
+
return {'path': target, 'entries': sorted(os.listdir(directory))[:1000]}
|
|
709
|
+
if op == 'stat':
|
|
710
|
+
s = os.stat(leaf, dir_fd=parent, follow_symlinks=False)
|
|
711
|
+
if stat.S_ISLNK(s.st_mode): raise ValueError('Resource target changed to a symbolic link')
|
|
712
|
+
return {'path': target, 'size': s.st_size, 'directory': stat.S_ISDIR(s.st_mode)}
|
|
713
|
+
if op == 'read':
|
|
714
|
+
with os.fdopen(os.open(leaf, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent), 'rb') as read_stream:
|
|
715
|
+
if not stat.S_ISREG(os.fstat(read_stream.fileno()).st_mode): raise ValueError('Read requires a regular file')
|
|
716
|
+
data = read_stream.read(1048577)
|
|
717
|
+
if len(data) > 1048576: raise ValueError('Read exceeds 1 MiB; narrow the resource file')
|
|
718
|
+
return {'path': target, 'content': data.decode('utf-8')}
|
|
719
|
+
# The shared lease lock admits a durable operation before any effect.
|
|
720
|
+
entries = retain_receipts(journal())
|
|
721
|
+
unresolved_physical: Dict[tuple[int, int], str] = {}
|
|
722
|
+
if not dry:
|
|
723
|
+
for previous in entries:
|
|
724
|
+
resolved = previous.get('admission_resolved', previous['status'] in ['verified', 'failed', 'dry_run'])
|
|
725
|
+
affected = previous['target']['path']
|
|
726
|
+
physical = previous['target']
|
|
727
|
+
try:
|
|
728
|
+
info = os.stat(leaf, dir_fd=parent, follow_symlinks=False)
|
|
729
|
+
current_identity = [info.st_dev, info.st_ino]
|
|
730
|
+
except FileNotFoundError: current_identity = None
|
|
731
|
+
same_physical = current_identity == [physical.get('device'), physical.get('inode')]
|
|
732
|
+
same_captured = False
|
|
733
|
+
captured_directory = False
|
|
734
|
+
if not resolved:
|
|
735
|
+
physical_ids = [[physical.get('device'), physical.get('inode')]]
|
|
736
|
+
for operation in ['deletion', 'write']:
|
|
737
|
+
captured_record = previous.get(operation + '_staging', {})
|
|
738
|
+
physical_ids.extend([captured_record.get('intended_identity'), captured_record.get('captured_identity')])
|
|
739
|
+
captured_directory = captured_directory or (captured_record.get('captured_identity') in directory_ids
|
|
740
|
+
and stat.S_ISDIR(captured_record.get('captured_mode', 0)))
|
|
741
|
+
for name in (['target', 'payload'] if operation == 'write' else ['target']):
|
|
742
|
+
retained = inspect_staging(previous, operation=operation, leaf=name)
|
|
743
|
+
if retained is not None and retained['exists']:
|
|
744
|
+
retained_identity = [retained['device'], retained['inode']]
|
|
745
|
+
physical_ids.append(retained_identity)
|
|
746
|
+
captured_directory = captured_directory or (retained_identity in directory_ids and stat.S_ISDIR(retained['mode']))
|
|
747
|
+
same_captured = current_identity is not None and current_identity in physical_ids
|
|
748
|
+
for physical_id in physical_ids:
|
|
749
|
+
if (isinstance(physical_id, list) and len(physical_id) == 2
|
|
750
|
+
and all(isinstance(value, int) and not isinstance(value, bool) for value in physical_id)):
|
|
751
|
+
unresolved_physical[(physical_id[0], physical_id[1])] = previous['id']
|
|
752
|
+
same_namespace = physical.get('namespace') in namespaces
|
|
753
|
+
within_directory = physical.get('kind') == 'directory' and [physical.get('device'), physical.get('inode')] in directory_ids
|
|
754
|
+
# Older receipts still identify a relative pathname under the same physical root.
|
|
755
|
+
relative_alias = False
|
|
756
|
+
if previous['root_identity'] == root_id:
|
|
757
|
+
prior_relative = os.path.relpath(affected, previous['root'])
|
|
758
|
+
current_relative = os.path.relpath(target, root)
|
|
759
|
+
relative_alias = (current_relative == prior_relative or current_relative.startswith(prior_relative + os.sep)
|
|
760
|
+
or prior_relative.startswith(current_relative + os.sep))
|
|
761
|
+
if not resolved and (same_physical or same_captured or captured_directory or same_namespace or within_directory or relative_alias
|
|
762
|
+
or target == affected or target.startswith(affected + os.sep) or affected.startswith(target + os.sep)):
|
|
763
|
+
raise ValueError('Remote mutation fenced by unresolved operation ' + previous['id'] + '; reconcile through /resources')
|
|
764
|
+
content = q.get('content')
|
|
765
|
+
if op == 'write':
|
|
766
|
+
if not isinstance(content, str) or len(content.encode('utf-8')) > 1048576:
|
|
767
|
+
raise ValueError('Write requires UTF-8 content of at most 1 MiB')
|
|
768
|
+
write_content = content
|
|
769
|
+
operation_id = q.get('operation_id')
|
|
770
|
+
if not isinstance(operation_id, str) or str(uuid.UUID(operation_id)) != operation_id:
|
|
771
|
+
raise ValueError('Mutation requires a host-generated operation UUID')
|
|
772
|
+
call_id = q.get('tool_call_id')
|
|
773
|
+
if call_id is not None and (not isinstance(call_id, str) or not call_id or len(call_id) > 200):
|
|
774
|
+
raise ValueError('Invalid originating tool call ID')
|
|
775
|
+
retired = replay_lookup(replay_directory, ['operation', operation_id], replay_required)
|
|
776
|
+
if retired is None and call_id is not None:
|
|
777
|
+
endpoint = q['endpoint']
|
|
778
|
+
retired = replay_lookup(replay_directory, ['call', endpoint['host'], endpoint['user'], endpoint['port'], owner, call_id], replay_required)
|
|
779
|
+
if retired is not None:
|
|
780
|
+
raise ValueError('Operation archived as ' + retired['id'] + '; inspect original evidence instead of replaying')
|
|
781
|
+
if any(previous['id'] == operation_id for previous in entries):
|
|
782
|
+
raise ValueError('Operation already recorded; inspect its receipt instead of replaying')
|
|
783
|
+
if call_id is not None and any(previous['owner_id'] == owner and previous.get('tool_call_id') == call_id for previous in entries):
|
|
784
|
+
raise ValueError('Tool call already recorded; inspect its receipt instead of replaying')
|
|
785
|
+
if len(entries) >= 10000: raise ValueError('Receipt journal is full; archive verified receipts before admitting new mutations')
|
|
786
|
+
intended_identity = None
|
|
787
|
+
if op == 'write':
|
|
788
|
+
try: existing = os.stat(leaf, dir_fd=parent, follow_symlinks=False)
|
|
789
|
+
except FileNotFoundError: existing = None
|
|
790
|
+
if existing is not None:
|
|
791
|
+
if not stat.S_ISREG(existing.st_mode): raise ValueError('Write requires an absent target or a regular file')
|
|
792
|
+
intended = held(os.open(leaf, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent))
|
|
793
|
+
pinned = os.fstat(intended)
|
|
794
|
+
intended_identity = [pinned.st_dev, pinned.st_ino]
|
|
795
|
+
if intended_identity != [existing.st_dev, existing.st_ino]: raise ValueError('Write target changed during inspection')
|
|
796
|
+
pending_id = unresolved_physical.get((pinned.st_dev, pinned.st_ino))
|
|
797
|
+
if pending_id is not None:
|
|
798
|
+
raise ValueError('Remote mutation fenced by unresolved operation ' + pending_id + '; reconcile through /resources')
|
|
799
|
+
receipt = {'version': 1, 'id': operation_id, 'status': 'pending', 'operation': op,
|
|
800
|
+
'admission_resolved': dry, 'tool_call_id': call_id, 'resource_id': q['resource_id'], 'owner_id': owner,
|
|
801
|
+
'generation': record['generation'], 'root': root, 'root_identity': root_id,
|
|
802
|
+
'configured_root': q['root'], 'requested_path': path, 'endpoint': q['endpoint'],
|
|
803
|
+
'target': {'path': target, 'device': None, 'inode': None, 'kind': None, 'namespace': namespace},
|
|
804
|
+
'removed_bytes': 0, 'started_at': time.time(), 'completed_at': None,
|
|
805
|
+
'verification': {'target_absent': None, 'checked_at': None},
|
|
806
|
+
'space': {'reclaimed_bytes': None, 'attribution': 'unknown', 'source': 'not measured', 'interval': None}}
|
|
807
|
+
persist_receipt(receipt)
|
|
808
|
+
admitted = True
|
|
809
|
+
if op == 'write':
|
|
810
|
+
write_parent: Optional[int] = None
|
|
811
|
+
write_directory: Optional[int] = None
|
|
812
|
+
stage_name = operation_id
|
|
813
|
+
try:
|
|
814
|
+
os.makedirs(write_staging, mode=0o700, exist_ok=True)
|
|
815
|
+
write_parent = held(os.open(write_staging, flags))
|
|
816
|
+
private = os.fstat(write_parent)
|
|
817
|
+
if (private.st_uid != os.geteuid() or private.st_mode & 0o077
|
|
818
|
+
or private.st_dev != os.fstat(parent).st_dev
|
|
819
|
+
or staging_bindings[write_staging] is not None
|
|
820
|
+
and staging_bindings[write_staging] != [private.st_dev, private.st_ino]):
|
|
821
|
+
raise ValueError('Write requires a private same-filesystem host staging directory')
|
|
822
|
+
os.mkdir(stage_name, mode=0o700, dir_fd=write_parent)
|
|
823
|
+
os.fsync(write_parent)
|
|
824
|
+
write_directory = held(os.open(stage_name, flags, dir_fd=write_parent))
|
|
825
|
+
private_operation = os.fstat(write_directory)
|
|
826
|
+
receipt['write_staging'] = {'path': os.path.join(write_staging, stage_name, 'target'),
|
|
827
|
+
'payload_path': os.path.join(write_staging, stage_name, 'payload'),
|
|
828
|
+
'directory_identity': [private_operation.st_dev, private_operation.st_ino],
|
|
829
|
+
'intended_identity': intended_identity, 'state': 'prepared'}
|
|
830
|
+
# Persist both private names before creating or moving data.
|
|
831
|
+
# A reconnect can inspect them even if a syscall never returns.
|
|
832
|
+
persist_receipt(receipt)
|
|
833
|
+
fd = held(os.open('payload', os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=write_directory))
|
|
834
|
+
with os.fdopen(os.dup(fd), 'w') as write_stream:
|
|
835
|
+
write_stream.write(write_content); write_stream.flush(); os.fsync(write_stream.fileno())
|
|
836
|
+
info = os.fstat(fd)
|
|
837
|
+
os.fsync(write_directory)
|
|
838
|
+
receipt['target'].update(device=info.st_dev, inode=info.st_ino, kind='file')
|
|
839
|
+
receipt['write_staging']['state'] = 'payload_ready'
|
|
840
|
+
persist_receipt(receipt)
|
|
841
|
+
if intended_identity is not None:
|
|
842
|
+
rename_without_replacement(parent, leaf, write_directory, 'target')
|
|
843
|
+
os.fsync(parent); os.fsync(write_directory)
|
|
844
|
+
moved = os.stat('target', dir_fd=write_directory, follow_symlinks=False)
|
|
845
|
+
receipt['write_staging'].update(state='captured', captured_identity=[moved.st_dev, moved.st_ino], captured_mode=moved.st_mode)
|
|
846
|
+
persist_receipt(receipt)
|
|
847
|
+
if [moved.st_dev, moved.st_ino] != intended_identity or not stat.S_ISREG(moved.st_mode):
|
|
848
|
+
raise ValueError('Write target replaced before capture; replacement data preserved')
|
|
849
|
+
# Both names are protected: a private source cannot be replaced
|
|
850
|
+
# by managed writes, and the kernel refuses any public newcomer.
|
|
851
|
+
try: rename_without_replacement(write_directory, 'payload', parent, leaf)
|
|
852
|
+
except FileExistsError as error:
|
|
853
|
+
raise ValueError('Write target changed before publication; replacement data preserved') from error
|
|
854
|
+
os.fsync(parent); os.fsync(write_directory)
|
|
855
|
+
receipt['write_staging']['state'] = 'published'
|
|
856
|
+
persist_receipt(receipt)
|
|
857
|
+
visible = os.stat(leaf, dir_fd=parent, follow_symlinks=False)
|
|
858
|
+
same_parent = identity(os.path.dirname(target)) == [os.fstat(parent).st_dev, os.fstat(parent).st_ino]
|
|
859
|
+
verified = (identity(root) == root_id and same_parent and stat.S_ISREG(visible.st_mode)
|
|
860
|
+
and [visible.st_dev, visible.st_ino] == [info.st_dev, info.st_ino])
|
|
861
|
+
if verified:
|
|
862
|
+
if intended_identity is not None: os.unlink('target', dir_fd=write_directory)
|
|
863
|
+
os.fsync(write_directory)
|
|
864
|
+
receipt['write_staging']['state'] = 'completed'
|
|
865
|
+
receipt['status'] = 'verified' if verified else 'unverified'
|
|
866
|
+
receipt['written_bytes'] = len(write_content.encode('utf-8'))
|
|
867
|
+
receipt['content_sha256'] = hashlib.sha256(write_content.encode('utf-8')).hexdigest()
|
|
868
|
+
receipt['verification'] = {'target_matches': verified, 'checked_at': time.time()}
|
|
869
|
+
receipt['completed_at'] = time.time()
|
|
870
|
+
persist_receipt(receipt)
|
|
871
|
+
except Exception as error:
|
|
872
|
+
if write_directory is not None and 'write_staging' in receipt:
|
|
873
|
+
# Inspect the private name, not a post-syscall flag: the
|
|
874
|
+
# capture or publication might have applied before raising.
|
|
875
|
+
try:
|
|
876
|
+
moved = os.stat('target', dir_fd=write_directory, follow_symlinks=False)
|
|
877
|
+
except FileNotFoundError: pass
|
|
878
|
+
else:
|
|
879
|
+
receipt['write_staging'].update(captured_identity=[moved.st_dev, moved.st_ino], captured_mode=moved.st_mode)
|
|
880
|
+
try:
|
|
881
|
+
rename_without_replacement(write_directory, 'target', parent, leaf)
|
|
882
|
+
os.fsync(parent); os.fsync(write_directory)
|
|
883
|
+
receipt['write_staging']['state'] = 'restored'
|
|
884
|
+
except Exception as restoration:
|
|
885
|
+
receipt['write_staging']['restoration_error'] = str(restoration)[:2000]
|
|
886
|
+
receipt['status'] = 'unverified' if 'write_staging' in receipt else 'failed'
|
|
887
|
+
receipt['error'] = str(error)[:2000]
|
|
888
|
+
receipt['completed_at'] = time.time()
|
|
889
|
+
persist_receipt(receipt)
|
|
890
|
+
raise
|
|
891
|
+
finally:
|
|
892
|
+
if write_parent is not None and write_directory is not None:
|
|
893
|
+
# Never purge retained data, including an unpublished payload.
|
|
894
|
+
with contextlib.suppress(OSError):
|
|
895
|
+
os.rmdir(stage_name, dir_fd=write_parent); os.fsync(write_parent)
|
|
896
|
+
return {'path': target, 'operation': 'write', 'written_bytes': receipt['written_bytes'],
|
|
897
|
+
'generation': record['generation'], 'receipt': receipt}
|
|
898
|
+
removed = False
|
|
899
|
+
captured = False
|
|
900
|
+
deletion_parent: Optional[int] = None
|
|
901
|
+
deletion_directory: Optional[int] = None
|
|
902
|
+
stage_name = operation_id
|
|
903
|
+
def restore_captured() -> None:
|
|
904
|
+
# Even a replacement captured during the race is data: never
|
|
905
|
+
# discard it or overwrite a newer entry in the public directory.
|
|
906
|
+
if deletion_directory is None: raise RuntimeError('Missing private deletion capture')
|
|
907
|
+
rename_without_replacement(deletion_directory, 'target', parent, leaf)
|
|
908
|
+
os.fsync(parent); os.fsync(deletion_directory)
|
|
909
|
+
receipt['deletion_staging']['state'] = 'restored'
|
|
910
|
+
try:
|
|
911
|
+
s = os.stat(leaf, dir_fd=parent, follow_symlinks=False)
|
|
912
|
+
if stat.S_ISLNK(s.st_mode): raise ValueError('Deletion target changed to a symbolic link')
|
|
913
|
+
pending_id = unresolved_physical.get((s.st_dev, s.st_ino))
|
|
914
|
+
if not dry and pending_id is not None:
|
|
915
|
+
raise ValueError('Remote mutation fenced by unresolved operation ' + pending_id + '; reconcile through /resources')
|
|
916
|
+
if not stat.S_ISDIR(s.st_mode) and not stat.S_ISREG(s.st_mode):
|
|
917
|
+
raise ValueError('Deletion requires a regular file or empty directory')
|
|
918
|
+
# Keep the intended inode alive so its number cannot be reused
|
|
919
|
+
# while the public name is being captured.
|
|
920
|
+
intended = held(os.open(leaf, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent))
|
|
921
|
+
observed = os.fstat(intended)
|
|
922
|
+
if [observed.st_dev, observed.st_ino] != [s.st_dev, s.st_ino]:
|
|
923
|
+
raise ValueError('Deletion target changed during inspection')
|
|
924
|
+
kind = 'directory' if stat.S_ISDIR(s.st_mode) else 'file'
|
|
925
|
+
receipt['target'].update(device=s.st_dev, inode=s.st_ino, kind=kind)
|
|
926
|
+
persist_receipt(receipt)
|
|
927
|
+
if not dry and kind == 'directory' and os.listdir(intended):
|
|
928
|
+
raise OSError(errno.ENOTEMPTY, 'Deletion requires an empty directory')
|
|
929
|
+
if dry:
|
|
930
|
+
receipt['status'] = 'dry_run'
|
|
931
|
+
else:
|
|
932
|
+
os.makedirs(staging, mode=0o700, exist_ok=True)
|
|
933
|
+
deletion_parent = held(os.open(staging, flags))
|
|
934
|
+
private = os.fstat(deletion_parent)
|
|
935
|
+
if (private.st_uid != os.geteuid() or private.st_mode & 0o077
|
|
936
|
+
or private.st_dev != s.st_dev
|
|
937
|
+
or q.get('host_delete_staging_identity') is not None
|
|
938
|
+
and q['host_delete_staging_identity'] != [private.st_dev, private.st_ino]):
|
|
939
|
+
raise ValueError('Deletion requires a private same-filesystem host staging directory')
|
|
940
|
+
os.mkdir(stage_name, mode=0o700, dir_fd=deletion_parent)
|
|
941
|
+
os.fsync(deletion_parent)
|
|
942
|
+
deletion_directory = held(os.open(stage_name, flags, dir_fd=deletion_parent))
|
|
943
|
+
private_operation = os.fstat(deletion_directory)
|
|
944
|
+
receipt['deletion_staging'] = {'path': os.path.join(staging, stage_name, 'target'),
|
|
945
|
+
'directory_identity': [private_operation.st_dev, private_operation.st_ino], 'state': 'prepared'}
|
|
946
|
+
persist_receipt(receipt)
|
|
947
|
+
rename_without_replacement(parent, leaf, deletion_directory, 'target')
|
|
948
|
+
captured = True
|
|
949
|
+
receipt['deletion_staging']['state'] = 'captured'
|
|
950
|
+
os.fsync(parent); os.fsync(deletion_directory)
|
|
951
|
+
moved = os.stat('target', dir_fd=deletion_directory, follow_symlinks=False)
|
|
952
|
+
receipt['deletion_staging']['captured_identity'] = [moved.st_dev, moved.st_ino]
|
|
953
|
+
receipt['deletion_staging']['captured_mode'] = moved.st_mode
|
|
954
|
+
persist_receipt(receipt)
|
|
955
|
+
if [moved.st_dev, moved.st_ino] != [s.st_dev, s.st_ino] or stat.S_IFMT(moved.st_mode) != stat.S_IFMT(s.st_mode):
|
|
956
|
+
raise ValueError('Deletion target replaced before capture; replacement data preserved')
|
|
957
|
+
if stat.S_ISDIR(moved.st_mode): os.rmdir('target', dir_fd=deletion_directory)
|
|
958
|
+
else: os.unlink('target', dir_fd=deletion_directory)
|
|
959
|
+
removed = True
|
|
960
|
+
receipt['deletion_staging']['state'] = 'removed'
|
|
961
|
+
receipt['removed_bytes'] = 0 if kind == 'directory' else moved.st_size
|
|
962
|
+
os.fsync(deletion_directory)
|
|
963
|
+
try:
|
|
964
|
+
os.stat(leaf, dir_fd=parent, follow_symlinks=False)
|
|
965
|
+
absent = False
|
|
966
|
+
except FileNotFoundError: absent = True
|
|
967
|
+
# A held descriptor prevents escaping the root. Completion
|
|
968
|
+
# also requires that the named path still maps to that same
|
|
969
|
+
# parent/root, and that no other process recreated the target.
|
|
970
|
+
same_parent = identity(os.path.dirname(target)) == [os.fstat(parent).st_dev, os.fstat(parent).st_ino]
|
|
971
|
+
same_root = identity(root) == root_id
|
|
972
|
+
visible_absent = not os.path.lexists(target)
|
|
973
|
+
verified = absent and same_parent and same_root and visible_absent
|
|
974
|
+
receipt['verification'] = {'target_absent': verified, 'checked_at': time.time()}
|
|
975
|
+
receipt['status'] = 'verified' if verified else 'unverified'
|
|
976
|
+
receipt['completed_at'] = time.time()
|
|
977
|
+
persist_receipt(receipt)
|
|
978
|
+
except Exception as error:
|
|
979
|
+
if captured and not removed:
|
|
980
|
+
try: restore_captured()
|
|
981
|
+
except Exception as restoration:
|
|
982
|
+
receipt['deletion_staging']['restoration_error'] = str(restoration)[:2000]
|
|
983
|
+
receipt['status'] = 'unverified' if captured else 'failed'
|
|
984
|
+
receipt['error'] = str(error)[:2000]
|
|
985
|
+
receipt['completed_at'] = time.time()
|
|
986
|
+
# Failure to save after a mutation leaves the durable pending
|
|
987
|
+
# record. It never acknowledges deletion as verified.
|
|
988
|
+
persist_receipt(receipt)
|
|
989
|
+
raise
|
|
990
|
+
finally:
|
|
991
|
+
if deletion_parent is not None and deletion_directory is not None:
|
|
992
|
+
# Only an empty operation directory is removable. Retained
|
|
993
|
+
# data survives restart and is never automatically purged.
|
|
994
|
+
with contextlib.suppress(OSError):
|
|
995
|
+
os.rmdir(stage_name, dir_fd=deletion_parent); os.fsync(deletion_parent)
|
|
996
|
+
return {'path': target, 'operation': 'delete', 'removed_bytes': receipt['removed_bytes'],
|
|
997
|
+
'generation': record['generation'], 'dry_run': dry, 'receipt': receipt}
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
if __name__ == '__main__':
|
|
1001
|
+
try:
|
|
1002
|
+
request = json.load(sys.stdin)
|
|
1003
|
+
result = main(request)
|
|
1004
|
+
print(json.dumps({'ok': True, 'result': result}))
|
|
1005
|
+
except Exception as e:
|
|
1006
|
+
print(json.dumps({'ok': False, 'error': str(e), 'admitted': admitted, 'operation_id': request.get('operation_id') if 'request' in globals() else None}))
|
|
1007
|
+
sys.exit(1)
|