diff --git a/addons/godot_ai/migration_bridge.gd b/addons/godot_ai/migration_bridge.gd new file mode 100644 index 0000000..50874db --- /dev/null +++ b/addons/godot_ai/migration_bridge.gd @@ -0,0 +1,212 @@ +@tool +extends Node + +## Verifies and stages the embedded canonical v4 release while the migration +## capsule is still the enabled plugin. Every step runs in GDScript on the +## editor's main thread through the same installer scripts the v4 tree ships +## (`utils/release_verifier.gd`, `utils/update_installer.gd`); nothing here +## spawns a process, and nothing here writes a live add-on file. + +const PAYLOAD_ROOT := "res://addons/godot_ai/migration_payload" +const ARCHIVE_NAME := "godot-ai-v4-plugin.zip" +const MANIFEST_NAME := "godot-ai-v4-plugin.manifest.json" +const SIGNATURE_NAME := "godot-ai-v4-plugin.manifest.sig" +const VERIFIER_SCRIPT := "res://addons/godot_ai/utils/release_verifier.gd" +const INSTALLER_SCRIPT := "res://addons/godot_ai/utils/update_installer.gd" +const REPOSITORY := "hi-godot/godot-ai" +const CHANNEL := "stable" +const MAX_MANIFEST_BYTES := 1024 * 1024 +const SIGNATURE_BYTES := 512 +const PENDING_V3_UPDATE_SETTING := "godot_ai/pending_self_update_event" +const INCOMPLETE_CAPSULE := ( + "The migration capsule is incomplete (%s). Download the release again, then click Retry migration." +) + +## Kept byte-identical to `utils/update_manager.gd::RELEASE_SIGNING_PUBLIC_KEY_PEM` +## in the v4 tree; `tests/unit/test_release_support.py` pins the two copies +## together. The capsule cannot preload the v4 updater to read it there. +const RELEASE_SIGNING_PUBLIC_KEY_PEM := """-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr4OmbONFTONGFcXSUQ2p +e54YaUhWDA75wxeDWhOc476vsdo53YnXEFT7EPr2hUKqeNxv++LqKOkFuAsxSNZy +wBe6P1tmQA4Og6Ezv4CGnZdEj1uhlDJFK9ShQ29oWfC6bf/84625SvvBxZos2Br9 +yPKl7h5wzqDoeUSpv+f0ynTiC0i/HAUo/NQBlkgGwkomK2Fr3pP1VDxxq2xvgHSk +lU6Qcomr9WjJxI+HkDN5tRPPn0pDrg6YFx2J18OfD8KIa/kMGxuXOcHlPyRYpjyu +qTtg2oL0NyUIG+1TmJ3DcN4GlKC55eOrkfJ04vudS5pxdnUIFRmkGBXZLdaetoPc +ixtlD4w6gi8KIH1CTG+/TtHP1KVdOogCWDcjRCAmMJPFZe6eEKXmGQUZDb9wfnbx +h++XiVe5tq83BTLWmaFTy+fZbNo12uhNCNS1LJ42/yj+S1xvo0yMbkkNr1hIYk0P +584XnBQeBSVJDf3667NZXaxnWv94K9zbb+1OvOvPwhbOdgi2Ymcw5QEOQIavtg86 +XLLcWzG+SJsycz1imikjv6sStWh8WHneKSTMq6A7V6PBj7oJyEJp10696BDw287k +YlH+9VGqowPEMXpWX57wOBKiWb4K1kw1LfxjT8W1e/pcX9pJqiv0DkjTXUxo9CDG +1X1+ZXBBR3MkGuFAOCjy0x8CAwEAAQ== +-----END PUBLIC KEY----- +""" + +signal state_changed(message: String, failed: bool) +signal prepared(package: Dictionary) + +enum Step { IDLE, VERIFY, STAGE, DONE } + +var _step := Step.IDLE +var _manifest: Dictionary = {} +var _manifest_sha256 := "" +var _from_version := "" + + +func start() -> void: + if _step != Step.IDLE: + return + _from_version = _previous_version() + _step = Step.VERIFY + state_changed.emit("Verifying the signed Godot AI v4 release…", false) + set_process(true) + + +func _process(_delta: float) -> void: + ## One step per frame so the dock renders each status line before the + ## next synchronous hash/extract pass runs. + match _step: + Step.VERIFY: + _verify() + Step.STAGE: + _stage() + _: + set_process(false) + + +func _verify() -> void: + var verifier := _load_script_with(VERIFIER_SCRIPT, "verify_manifest") + if verifier == null or not _script_declares(verifier, "verify_archive"): + _fail(INCOMPLETE_CAPSULE % "utils/release_verifier.gd is missing or invalid") + return + var manifest_bytes := _read_bounded(PAYLOAD_ROOT.path_join(MANIFEST_NAME), MAX_MANIFEST_BYTES) + var signature := _read_bounded(PAYLOAD_ROOT.path_join(SIGNATURE_NAME), SIGNATURE_BYTES) + if manifest_bytes.is_empty() or signature.size() != SIGNATURE_BYTES: + _fail(INCOMPLETE_CAPSULE % "the embedded v4 manifest or signature is unreadable") + return + var expected := {"repository": REPOSITORY, "channel": CHANNEL} + if not _from_version.is_empty(): + ## Only the final v3 runners publish the pre-capsule plugin.cfg + ## version; older ones cannot, and the verifier must not be told a + ## guess. + expected["current_version"] = _from_version + var verified: Variant = verifier.call( + "verify_manifest", manifest_bytes, signature, expected, RELEASE_SIGNING_PUBLIC_KEY_PEM + ) + if not verified is Dictionary or not bool(verified.get("ok", false)): + _fail(_error_of(verified, "The embedded v4 release manifest could not be verified.")) + return + var manifest: Variant = (verified as Dictionary).get("manifest", {}) + if not manifest is Dictionary or str(manifest.get("version", "")).is_empty(): + _fail("The verified v4 manifest is missing its release version.") + return + _manifest = (manifest as Dictionary).duplicate(true) + _manifest_sha256 = _sha256_hex(manifest_bytes) + var archive_checked: Variant = verifier.call( + "verify_archive", PAYLOAD_ROOT.path_join(ARCHIVE_NAME), _manifest + ) + if not archive_checked is Dictionary or not bool(archive_checked.get("ok", false)): + _fail(_error_of(archive_checked, "The embedded v4 archive does not match its signed manifest.")) + return + _step = Step.STAGE + state_changed.emit("Staging the verified Godot AI v4 tree…", false) + + +func _stage() -> void: + var installer := _load_script_with(INSTALLER_SCRIPT, "stage") + if installer == null: + _fail(INCOMPLETE_CAPSULE % "utils/update_installer.gd is missing or invalid") + return + var staged: Variant = installer.call("stage", PAYLOAD_ROOT.path_join(ARCHIVE_NAME), _manifest) + if not staged is Dictionary or not bool(staged.get("ok", false)): + _fail(_error_of(staged, "The verified v4 tree could not be staged.")) + return + var stage_root := str((staged as Dictionary).get("stage_root", "")) + var tree_sha256 := str((staged as Dictionary).get("tree_sha256", "")) + if stage_root.is_empty() or tree_sha256.is_empty(): + _fail("The installer staged the v4 tree without reporting where or what it wrote.") + return + _step = Step.DONE + set_process(false) + state_changed.emit("Activating the verified Godot AI v4 tree…", false) + prepared.emit({ + "stage_root": stage_root, + "expected_tree_sha256": tree_sha256, + "manifest_sha256": _manifest_sha256, + "from_version": _from_version, + "to_version": str(_manifest.get("version", "")), + }) + + +func _fail(message: String) -> void: + _step = Step.DONE + set_process(false) + state_changed.emit(message, true) + + +## The pre-capsule plugin.cfg version, as recorded by the final v3 self-update +## runner before it extracted the capsule over the tree. Empty when unknown. +static func _previous_version() -> String: + var settings := EditorInterface.get_editor_settings() + if settings == null or not settings.has_setting(PENDING_V3_UPDATE_SETTING): + return "" + var parsed: Variant = JSON.parse_string(str(settings.get_setting(PENDING_V3_UPDATE_SETTING))) + if not parsed is Dictionary: + return "" + var value := str(parsed.get("from_version", "")) + return value if _is_pre_v4_version(value) else "" + + +static func _is_pre_v4_version(value: String) -> bool: + var expression := RegEx.new() + return expression.compile("^[0-3]\\.\\d+\\.\\d+$") == OK and expression.search(value) != null + + +## Load one of the v4 installer scripts by path. The capsule cannot name them +## by `class_name`: those globals only exist once the v4 tree is live, and the +## bridge must still parse when a truncated capsule lacks them entirely. +static func _load_script_with(path: String, method: String) -> Script: + if not ResourceLoader.exists(path): + return null + ## Final v3 ships older files at some of these paths (utils/port_resolver.gd), + ## already compiled into the resource cache before the capsule was extracted + ## over the tree. Ignore the cache, deeply, so the capsule's copies and + ## their preloads are the ones that run. + var loaded: Variant = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) + if not loaded is Script or not _script_declares(loaded, method): + return null + return loaded + + +static func _script_declares(script: Script, method: String) -> bool: + for entry in script.get_script_method_list(): + if str(entry.get("name", "")) == method: + return true + return false + + +static func _read_bounded(path: String, maximum: int) -> PackedByteArray: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return PackedByteArray() + var length := file.get_length() + if length <= 0 or length > maximum: + file.close() + return PackedByteArray() + var data := file.get_buffer(length) + file.close() + return data if data.size() == length else PackedByteArray() + + +static func _sha256_hex(data: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(data) + return context.finish().hex_encode() + + +static func _error_of(result: Variant, fallback: String) -> String: + if result is Dictionary: + var error := str((result as Dictionary).get("error", "")).strip_edges() + if not error.is_empty(): + return error + return fallback diff --git a/addons/godot_ai/migration_bridge.gd.uid b/addons/godot_ai/migration_bridge.gd.uid new file mode 100644 index 0000000..d53f987 --- /dev/null +++ b/addons/godot_ai/migration_bridge.gd.uid @@ -0,0 +1 @@ +uid://e3r4fem8egus diff --git a/addons/godot_ai/migration_coordinator.gd b/addons/godot_ai/migration_coordinator.gd new file mode 100644 index 0000000..ab2ab35 --- /dev/null +++ b/addons/godot_ai/migration_coordinator.gd @@ -0,0 +1,213 @@ +@tool +extends Node + +## Prepare the capsule's value-only handoff to the shared activation runner. +## This coordinator finishes before the runner disables the capsule or swaps +## its source. The runner alone owns the scan and in-editor activation. + +const INSTALLER_SCRIPT := "res://addons/godot_ai/utils/update_installer.gd" +const ACTIVATION_SCRIPT := "res://addons/godot_ai/utils/update_activation_runner.gd" +const PORT_RESOLVER_SCRIPT := "res://addons/godot_ai/utils/port_resolver.gd" +const STATUS_SETTING := "godot_ai/v4_migration_bridge_status" +## v3's `plugin.gd::_ensure_game_helper_autoload` wrote +## `ProjectSettings.set_setting("autoload/_mcp_game_helper", "*res://addons/godot_ai/runtime/game_helper.gd")` +## and saved project.godot. The capsule never re-registers it, so the entry +## dangles for the whole migration window and points at a file the v4 swap +## moves away (#946). Remove it before the swap, matching the exact target so +## a user-owned autoload of the same name is left alone. +const GAME_HELPER_AUTOLOAD_SETTING := "autoload/_mcp_game_helper" +const GAME_HELPER_AUTOLOAD_TARGET := "res://addons/godot_ai/runtime/game_helper.gd" +const REQUIRED_INSTALLER_METHODS := [ + "acquire_lock", "release_lock", "discard_stage", +] +const PRE_DISABLE_DRAIN_FRAMES := 2 +## Unknown pre-v4 versions still describe a major crossing. Manual-major +## client repinning replaces any owned pre-v4 entry, so this fallback is +## identity metadata rather than a client-selection authority. +const UNKNOWN_V3_VERSION := "3.0.0" + +signal state_changed(message: String, failed: bool) + +enum Phase { IDLE, DRAIN, DONE } + +var _package: Dictionary = {} +var _phase := Phase.IDLE +var _frames := 0 +var _installer: Script +var _lock_held := false + + +func start(package: Dictionary) -> void: + if _phase != Phase.IDLE: + return + if not _valid_package(package): + _fail_before_swap("The prepared v4 migration handoff is invalid.") + return + _package = package.duplicate(true) + _frames = PRE_DISABLE_DRAIN_FRAMES + _phase = Phase.DRAIN + set_process(true) + + +func _process(_delta: float) -> void: + match _phase: + Phase.DRAIN: + _frames -= 1 + if _frames <= 0: + _prepare_swap() + _: + set_process(false) + + +## Everything that can still refuse without touching the live tree. +func _prepare_swap() -> void: + _installer = _load_script_with(INSTALLER_SCRIPT, "swap") + for method in REQUIRED_INSTALLER_METHODS: + if _installer == null or not _script_declares(_installer, method): + _installer = null + _fail_before_swap( + "The migration capsule is incomplete (utils/update_installer.gd is missing or invalid)." + ) + return + var activation := GDScript.new() + activation.source_code = FileAccess.get_file_as_string(ACTIVATION_SCRIPT) + if activation.source_code.is_empty() or activation.reload() != OK or not _script_declares(activation, "start"): + _fail_before_swap("The migration capsule's activation runner is missing or invalid.") + return + var cleared := _remove_v3_game_helper_autoload() + if cleared != OK: + _fail_before_swap( + "Could not remove the v3 game-helper autoload from project.godot (%s)." + % error_string(cleared) + ) + return + var fingerprint := _editor_fingerprint() + if fingerprint.is_empty(): + _fail_before_swap("Could not fingerprint this editor process for the update lock.") + return + var locked: Variant = _installer.call("acquire_lock", OS.get_process_id(), fingerprint) + if not locked is Dictionary or not bool(locked.get("ok", false)): + _fail_before_swap(_error_of(locked, "Another editor holds the Godot AI update lock.")) + return + _lock_held = true + var from_version := str(_package.get("from_version", "")) + var record := { + "from_version": from_version if not from_version.is_empty() else UNKNOWN_V3_VERSION, + "to_version": str(_package.get("to_version", "")), + "manifest_sha256": str(_package.get("manifest_sha256", "")), + "expected_tree_sha256": str(_package.get("expected_tree_sha256", "")), + "editor_nonce": Crypto.new().generate_random_bytes(16).hex_encode(), + ## The v4 plugin reads this after activation: a v3-to-v4 crossing + ## may replace owned client-config entries broadly, which an + ## ordinary v4-to-v4 update must not do. + "replace_owned_mismatches": true, + } + var runner: Node = activation.new() + get_tree().root.add_child(runner) + var accepted: Variant = runner.call("start", {"stage_root": str(_package.stage_root), "record": record}) + if not accepted is bool or not accepted: + var reason := str(runner.get("refusal_reason")) + runner.queue_free() + _fail_before_swap(reason if not reason.is_empty() else "The migration activation runner refused the prepared handoff.") + return + ## No capsule Script or suspended callback crosses into the scan. The + ## runner holds only its independent source and copied package values. + _lock_held = false + _installer = null + _phase = Phase.DONE + set_process(false) + print("MCP | v3 bridge handing verified tree to in-editor activation") + queue_free() + + +func _fail_before_swap(message: String) -> void: + _release_lock() + _discard_stage() + record_status(message) + push_error("Godot AI v4 migration: %s" % message) + _phase = Phase.DONE + set_process(false) + state_changed.emit(message, true) + queue_free() + + +func _release_lock() -> void: + if not _lock_held: + return + _lock_held = false + if _installer != null: + _installer.call("release_lock") + + +## A refused attempt leaves no staged tree behind; `stage()` would clear it +## on Retry anyway, so this is tidiness, not a contract the bridge relies on. +func _discard_stage() -> void: + if _installer != null and _script_declares(_installer, "discard_stage"): + _installer.call("discard_stage") + + +static func _remove_v3_game_helper_autoload() -> Error: + if not ProjectSettings.has_setting(GAME_HELPER_AUTOLOAD_SETTING): + return OK + var target := str(ProjectSettings.get_setting(GAME_HELPER_AUTOLOAD_SETTING, "")).trim_prefix("*") + if target != GAME_HELPER_AUTOLOAD_TARGET: + return OK + ProjectSettings.clear(GAME_HELPER_AUTOLOAD_SETTING) + return ProjectSettings.save() + + +## `McpPortResolver.process_fingerprint` from the v4 `utils/port_resolver.gd` +## the capsule carries; final v3's copy of that file predates the method, so +## the lookup is by path and method, never by class_name. +static func _editor_fingerprint() -> String: + var resolver := _load_script_with(PORT_RESOLVER_SCRIPT, "process_fingerprint") + if resolver == null: + return "" + var value: Variant = resolver.call("process_fingerprint", OS.get_process_id()) + return str(value) if value is String else "" + + +static func _load_script_with(path: String, method: String) -> Script: + if not ResourceLoader.exists(path): + return null + ## Final v3 ships older files at some of these paths (utils/port_resolver.gd), + ## already compiled into the resource cache before the capsule was extracted + ## over the tree. Ignore the cache, deeply, so the capsule's copies and + ## their preloads are the ones that run. + var loaded: Variant = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) + if not loaded is Script or not _script_declares(loaded, method): + return null + return loaded + + +static func _script_declares(script: Script, method: String) -> bool: + for entry in script.get_script_method_list(): + if str(entry.get("name", "")) == method: + return true + return false + + +static func _error_of(result: Variant, fallback: String) -> String: + if result is Dictionary: + var error := str((result as Dictionary).get("error", "")).strip_edges() + if not error.is_empty(): + return error + return fallback + + +static func record_status(error: String) -> void: + var settings := EditorInterface.get_editor_settings() + if settings != null: + settings.set_setting(status_setting(), JSON.stringify({"error": error})) + + +static func status_setting() -> String: + ## A failed migration in one project must not block a different project. + return STATUS_SETTING + "_" + ProjectSettings.globalize_path("res://").sha256_text() + + +static func _valid_package(package: Dictionary) -> bool: + for name in ["stage_root", "expected_tree_sha256", "manifest_sha256", "to_version"]: + if str(package.get(name, "")).is_empty(): + return false + return true diff --git a/addons/godot_ai/migration_coordinator.gd.uid b/addons/godot_ai/migration_coordinator.gd.uid new file mode 100644 index 0000000..7b3ce25 --- /dev/null +++ b/addons/godot_ai/migration_coordinator.gd.uid @@ -0,0 +1 @@ +uid://qqsn55jmog6v diff --git a/addons/godot_ai/migration_fallback.gd b/addons/godot_ai/migration_fallback.gd new file mode 100644 index 0000000..5030a4d --- /dev/null +++ b/addons/godot_ai/migration_fallback.gd @@ -0,0 +1,217 @@ +@tool +extends Node + +## Puts the final v3 add-on back when this Godot is below v4's floor. The +## user's own updater already overlaid the capsule over their v3 tree and +## discarded its per-file backups, so without this the plugin stays dead +## until they upgrade Godot. Parented outside the EditorPlugin so it survives +## the capsule being disabled. Leaves with the capsule once the fleet is on +## v4 (docs/self-update.md). + +const PLUGIN_CFG := "res://addons/godot_ai/plugin.cfg" +const LIVE_ROOT := "res://addons/godot_ai" +const FALLBACK_ZIP := "res://addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip" +const LIVE_PREFIX := "addons/godot_ai/" +## Every file is written and read back here before the live tree is touched. +## The update directory is hidden from Godot's scanner and from git exactly +## as utils/update_installer.gd hides it. +const UPDATE_ROOT := "res://addons/.godot_ai_update" +const STAGE_ROOT := UPDATE_ROOT + "/v3-restore" +const UPDATE_IGNORE_FILES := [[".gdignore", ""], [".gitignore", "*\n"]] +const MAX_FILES := 4096 +const MAX_TOTAL_BYTES := 64 * 1024 * 1024 +## Capsule-only files; the restored v3 tree must not carry them. +const CAPSULE_ONLY := [ + "migration_bridge.gd", "migration_bridge.gd.uid", + "migration_coordinator.gd", "migration_coordinator.gd.uid", + "migration_fallback.gd", "migration_fallback.gd.uid", + "utils/release_verifier.gd", "utils/release_verifier.gd.uid", + "utils/update_installer.gd", "utils/update_installer.gd.uid", + "migration_payload", +] + + +static func available() -> bool: + return FileAccess.file_exists(FALLBACK_ZIP) + + +func start() -> void: + _restore.call_deferred() + + +func _restore() -> void: + var entries: Array = [] + var error := _read_payload(entries) + if error.is_empty(): + error = _prepare_update_root() + if error.is_empty(): + error = _write_all(STAGE_ROOT, entries) + if error.is_empty(): + error = _write_all(LIVE_ROOT, entries) + if not error.is_empty(): + ## The capsule payload and any stage stay for a retry; nothing is + ## removed until the restored plugin has been enabled. + push_error("Godot AI v4 migration: could not restore the previous version: %s" % error) + queue_free() + return + print( + "MCP | v3 bridge restored Godot AI v%s; update again on Godot 4.7 or newer" + % _restored_version() + ) + ## Disabling the capsule removed its dock; re-enabling after a scan loads + ## the restored plugin.gd, exactly as final v3's own updater re-enables. + EditorInterface.set_plugin_enabled(PLUGIN_CFG, false) + var filesystem := EditorInterface.get_resource_filesystem() + if filesystem == null: + _enable_after_scan.call_deferred() + return + filesystem.filesystem_changed.connect(_enable_after_scan, CONNECT_ONE_SHOT) + filesystem.scan() + + +func _enable_after_scan() -> void: + EditorInterface.set_plugin_enabled(PLUGIN_CFG, true) + if not EditorInterface.is_plugin_enabled(PLUGIN_CFG): + push_error( + "Godot AI v4 migration: the restored plugin could not be enabled;" + + " the capsule payload is kept for another attempt" + ) + queue_free() + return + ## Only a running restored plugin makes the capsule material disposable. + _remove_capsule_only() + _discard_stage() + queue_free() + + +## Every entry must be a regular file under the live add-on path: no absolute +## paths, no `..`, nothing outside `addons/godot_ai/`. Read everything before +## writing anything, so a bad archive changes nothing. +func _read_payload(entries: Array) -> String: + var reader := ZIPReader.new() + if reader.open(FALLBACK_ZIP) != OK: + return "cannot open %s" % FALLBACK_ZIP + var files := reader.get_files() + if files.size() > MAX_FILES: + reader.close() + return "too many entries" + var total := 0 + for name in files: + if name.ends_with("/"): + continue + var segments := name.split("/") + if ( + not name.begins_with(LIVE_PREFIX) + or name.contains("\\") + or segments.has("..") + or segments.has(".") + or segments.has("") + ): + reader.close() + return "unsafe entry %s" % name + var data := reader.read_file(name) + total += data.size() + if total > MAX_TOTAL_BYTES: + reader.close() + return "payload too large" + entries.append([name, data]) + reader.close() + if entries.is_empty(): + return "empty archive" + return "" + + +static func _prepare_update_root() -> String: + var root := ProjectSettings.globalize_path(UPDATE_ROOT) + if DirAccess.make_dir_recursive_absolute(root) != OK: + return "cannot create %s" % root + for marker in UPDATE_IGNORE_FILES: + var path := root.path_join(str(marker[0])) + if FileAccess.file_exists(path): + continue + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return "cannot write %s" % path + file.store_string(str(marker[1])) + file.close() + _remove_dir(ProjectSettings.globalize_path(STAGE_ROOT)) + return "" + + +## Drop the stage; when nothing but the ignore markers is left, drop the +## update directory too, so a refused or restored crossing leaves no trace. +static func _discard_stage() -> void: + _remove_dir(ProjectSettings.globalize_path(STAGE_ROOT)) + var root := ProjectSettings.globalize_path(UPDATE_ROOT) + var dir := DirAccess.open(root) + if dir == null: + return + dir.include_hidden = true + dir.include_navigational = false + if not dir.get_directories().is_empty(): + return + var markers := PackedStringArray() + for marker in UPDATE_IGNORE_FILES: + markers.append(str(marker[0])) + for name in dir.get_files(): + if not markers.has(name): + return + _remove_dir(root) + + +## Write every entry under `root`, checking each write and reading it back, +## so a full disk or a refused file stops before anything else is touched. +static func _write_all(root: String, entries: Array) -> String: + for entry in entries: + var relative: String = str(entry[0]).trim_prefix(LIVE_PREFIX) + var error := _write_checked(root + "/" + relative, entry[1]) + if not error.is_empty(): + return error + return "" + + +static func _write_checked(target: String, data: PackedByteArray) -> String: + var parent := ProjectSettings.globalize_path(target.get_base_dir()) + if DirAccess.make_dir_recursive_absolute(parent) != OK: + return "cannot create %s" % parent + var file := FileAccess.open(target, FileAccess.WRITE) + if file == null: + return "cannot write %s (%s)" % [target, error_string(FileAccess.get_open_error())] + file.store_buffer(data) + var error := file.get_error() + file.close() + if error != OK: + return "write failed for %s (%s)" % [target, error_string(error)] + var written := FileAccess.get_file_as_bytes(target) + if written.size() != data.size(): + return "short write for %s (%d of %d bytes)" % [target, written.size(), data.size()] + return "" + + +func _remove_capsule_only() -> void: + for relative in CAPSULE_ONLY: + var path := ProjectSettings.globalize_path(LIVE_ROOT + "/" + str(relative)) + if DirAccess.dir_exists_absolute(path): + _remove_dir(path) + elif FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + + +static func _remove_dir(path: String) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + dir.include_hidden = true + dir.include_navigational = false + for name in dir.get_directories(): + _remove_dir(path.path_join(name)) + for name in dir.get_files(): + DirAccess.remove_absolute(path.path_join(name)) + DirAccess.remove_absolute(path) + + +static func _restored_version() -> String: + var config := ConfigFile.new() + if config.load(PLUGIN_CFG) != OK: + return "?" + return str(config.get_value("plugin", "version", "?")) diff --git a/addons/godot_ai/migration_fallback.gd.uid b/addons/godot_ai/migration_fallback.gd.uid new file mode 100644 index 0000000..0851f5c --- /dev/null +++ b/addons/godot_ai/migration_fallback.gd.uid @@ -0,0 +1 @@ +uid://bym2nenea1nvd diff --git a/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip b/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip new file mode 100644 index 0000000..be3f088 Binary files /dev/null and b/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip differ diff --git a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json new file mode 100644 index 0000000..0f5e238 --- /dev/null +++ b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json @@ -0,0 +1 @@ +{"asset":{"name":"godot-ai-v4-plugin.zip","sha256":"535d8a7541871af8d991b07fe5031550dd6121a31b844400476b334a70612831","size":1971643},"channel":"stable","inventory":[{"path":"addons/godot_ai/LICENSE","sha256":"f2afe4af4ec80c453ca34704bc61d5d24a9bd948d365ca22699cabb8b4a18308","size":1078},{"path":"addons/godot_ai/README.md","sha256":"73ade921edaa0d7fe41278d07d40caaa8ba1d6358368c1954784dd1b119391a2","size":1742},{"path":"addons/godot_ai/client_configurator.gd","sha256":"fd283d5c66d097fd970374c249d75e680540b7704901b2c9d7305abb8efe416e","size":93044},{"path":"addons/godot_ai/client_configurator.gd.uid","sha256":"b11391096156780923df61e627d9241a691efd13330aaa0d69c44de3e91f9ccd","size":19},{"path":"addons/godot_ai/clients/_atomic_write.gd","sha256":"23042ee1ef57f72c102cadbfefdaf6cc2adf1d875a9f7e2f3c499dcbf74f91a8","size":12381},{"path":"addons/godot_ai/clients/_atomic_write.gd.uid","sha256":"69d170b100e1477dc969d937664bf32e9f1a41448fbddae997616e4d549f857b","size":19},{"path":"addons/godot_ai/clients/_base.gd","sha256":"1fbe00b374efebe4cd2b7c02418608102e2dc4b2bd25406471fc681a4a80e367","size":29069},{"path":"addons/godot_ai/clients/_base.gd.uid","sha256":"6ad5fd0be314b164e5a501aaf4afff8c17d3c406e4931b3d490fa55ed9399d75","size":20},{"path":"addons/godot_ai/clients/_cli_exec.gd","sha256":"1f49fcf5c2ed63e575858a13066745f5489ad096f7691261dd768da49e4ca6a5","size":8426},{"path":"addons/godot_ai/clients/_cli_exec.gd.uid","sha256":"0a011c6ccf204f647bb410e812452397b8d9d3f82e98e9c8c43255e8a8db43be","size":20},{"path":"addons/godot_ai/clients/_cli_finder.gd","sha256":"66ec387838fe5c574188ff6d57655ca30c32e992e445e7e1ab2fc957d1b3190c","size":6820},{"path":"addons/godot_ai/clients/_cli_finder.gd.uid","sha256":"844ed9a1cb168c6fa4a2ad7369a0b9e2b317a8845780fa551f0ddeb31035d4c7","size":20},{"path":"addons/godot_ai/clients/_cli_strategy.gd","sha256":"be3772eaa3c9bfbdd269310d58cf286a45008bf60e4d76493c651635466fc73c","size":20313},{"path":"addons/godot_ai/clients/_cli_strategy.gd.uid","sha256":"7ea94aa0a18f18001abe6c039d3f9101a20211dafe616bde1171de42e8878188","size":20},{"path":"addons/godot_ai/clients/_dsh_strategy.gd","sha256":"883adc4181073e1e7d7adacda749f55de0ef8b7f53de518bdecb23a8fa25b067","size":31296},{"path":"addons/godot_ai/clients/_dsh_strategy.gd.uid","sha256":"b603679cc9d3071b0029da2200a85ecbe367bc9f03fb1e352eceb544ebe1f4a1","size":20},{"path":"addons/godot_ai/clients/_json_strategy.gd","sha256":"6781f728f3b8f8498abbdfe694c6a49c50424ba9e7e278d7739919c60e298e30","size":48846},{"path":"addons/godot_ai/clients/_json_strategy.gd.uid","sha256":"0d7613d1a25a2637491759c219cdad8610156f840258238df5728eb18523a49d","size":19},{"path":"addons/godot_ai/clients/_manual_command.gd","sha256":"d77adc6b6f66f700ea1ae26adb5ede8b0f3e70620e071d8a4b15e79b24e64358","size":15256},{"path":"addons/godot_ai/clients/_manual_command.gd.uid","sha256":"323e682575c1bf3b1841ec517b59888bf304b3ada97dfd861ec317b8f714635a","size":20},{"path":"addons/godot_ai/clients/_path_template.gd","sha256":"bc3f01879b1469dc8a72e3025f698441e60f52207282c9fa978156c94fcf98bc","size":9696},{"path":"addons/godot_ai/clients/_path_template.gd.uid","sha256":"b110326fea223f45902def5a53b35522f269ad55848718b7ddd143cd63293b86","size":19},{"path":"addons/godot_ai/clients/_registry.gd","sha256":"0b7f45367f8b208bbee2c521b4513aee3d716c02cad2544f45743d5f815e5442","size":5956},{"path":"addons/godot_ai/clients/_registry.gd.uid","sha256":"0fb18ab129e0fba2eb85e7e5b784ce566c586b1711f76f8ab429610172a203ab","size":19},{"path":"addons/godot_ai/clients/_toml_strategy.gd","sha256":"f1f8481dcd568ef408b9de29a23792921a51a81ab70865463fbb77fcf1efe912","size":25296},{"path":"addons/godot_ai/clients/_toml_strategy.gd.uid","sha256":"3f581fce945ceabf62ce7397f0da3c9a64888f7d071fe59edeec9126735c9837","size":20},{"path":"addons/godot_ai/clients/_yaml_strategy.gd","sha256":"9ca3a596c973603192cb05ebc107d06e54552eeaef61ea3c252bd98c8ec05963","size":22431},{"path":"addons/godot_ai/clients/_yaml_strategy.gd.uid","sha256":"bee0844c9cd2c399c1105d4f741e804ac433a0dd89c338a63da9d00b1b983885","size":20},{"path":"addons/godot_ai/clients/antigravity.gd","sha256":"af2cb95aa3b1e132ac90dbd3eac2d5b993a0c8642368c245644b59b3afb63fbb","size":1815},{"path":"addons/godot_ai/clients/antigravity.gd.uid","sha256":"d247b7f9da8ed483178c3f12e207494060181af364ef829951a6de2dec8fb482","size":20},{"path":"addons/godot_ai/clients/claude_code.gd","sha256":"2660ee8d70f11d4d3d55b12a29551fcdbb749e3002c9eb4bee1894d4853bb362","size":5009},{"path":"addons/godot_ai/clients/claude_code.gd.uid","sha256":"18d1ebc89fe7cd71054545da54c04477465e69b8291d082f8c860abc77dd2c41","size":20},{"path":"addons/godot_ai/clients/claude_desktop.gd","sha256":"25cd87161a8985273b5ad3954b397a95af64ab205ecd8359c63da09a0efbc020","size":1634},{"path":"addons/godot_ai/clients/claude_desktop.gd.uid","sha256":"e430bb213b5005f3182aca544fdade6c8a7e89128705907c11228f699eb27fc0","size":20},{"path":"addons/godot_ai/clients/cline.gd","sha256":"f48504c220090cdb44914241e9ac760246e7e931678c9f4c9f119b651299f85c","size":2159},{"path":"addons/godot_ai/clients/cline.gd.uid","sha256":"c8d58f6b86221ef68289a090b836d31f4006d395c3f42ed3000fc027a020c06d","size":20},{"path":"addons/godot_ai/clients/codebuddy.gd","sha256":"7eca77746e458647de6c02befb3cfabc171e988af0dbe3cdfe12e6c055fa9a68","size":907},{"path":"addons/godot_ai/clients/codebuddy.gd.uid","sha256":"e3b12124e5b591f480aff9bc890bcc1169cd6f83907c1d7dbabd95e9f5964eec","size":20},{"path":"addons/godot_ai/clients/codex.gd","sha256":"1840261a6e6eafdde63a26e3a8d85334fde916f21079192e9b3957231232a103","size":1493},{"path":"addons/godot_ai/clients/codex.gd.uid","sha256":"4bdcaae2f5f0c2e850430a7cc275d64bb84b880df8076a1cd9710dfb9fdc17e2","size":19},{"path":"addons/godot_ai/clients/cursor.gd","sha256":"197be68bfa76b0ca142fdde802f14d706b26a85d78aa96f0476dafc1d44a5353","size":821},{"path":"addons/godot_ai/clients/cursor.gd.uid","sha256":"3625853ad9bb909b2668f94f22a592231612d216c384a9a19e59f295eece2314","size":20},{"path":"addons/godot_ai/clients/deepseek_harness.gd","sha256":"b45feb086a36a19f925bd8de8a619377b72df49ac911609cfd3cb8e914112f3e","size":2072},{"path":"addons/godot_ai/clients/deepseek_harness.gd.uid","sha256":"e9f7b2f0544a8c03f4e153af30e21a8c4e4a138814ea16e005416fdc127bc466","size":20},{"path":"addons/godot_ai/clients/gemini_cli.gd","sha256":"647e4627e3977de234aed98f762e7d9a624ed518ebfe189ffd6b4e14bb313d47","size":874},{"path":"addons/godot_ai/clients/gemini_cli.gd.uid","sha256":"7af07715d59b73f3e69173d39364eb4103f5d84eeee312e13ed04fae4c5279d6","size":20},{"path":"addons/godot_ai/clients/grok.gd","sha256":"b2ae578424587437e14a56c801ef2551e7652ffb01808e6b8285e4b2557d6502","size":1612},{"path":"addons/godot_ai/clients/grok.gd.uid","sha256":"e911f4916e044eea03744e3a3e9920d53d18db876a515e0f9415be6f76d3b89b","size":20},{"path":"addons/godot_ai/clients/hermes.gd","sha256":"28759488fa59ead7c094c59e31f8cf2080f644e17b806929d94a1f8645870782","size":1956},{"path":"addons/godot_ai/clients/hermes.gd.uid","sha256":"331b1b1d2699ddc02ed293d674dae6b2d5b69ed5e29a94ce7b6662de6ec24a1e","size":19},{"path":"addons/godot_ai/clients/kilo_code.gd","sha256":"28cca26f36ae48c0382efde216f7946fcc731cb5ef5a87242580df75b346d0a3","size":1937},{"path":"addons/godot_ai/clients/kilo_code.gd.uid","sha256":"73705b20213a8cdabde5c3207cbfd750acba3ecfdb12e5fc0ad2d32c861fdeee","size":20},{"path":"addons/godot_ai/clients/kimi_code.gd","sha256":"233c5719b1532a7d3d0ad533ca9584dc4eb0d21d3308e9d183ee64a789373e9a","size":1557},{"path":"addons/godot_ai/clients/kimi_code.gd.uid","sha256":"caeefab43e4177a581a0a527194db6580c308f640ff3755526b95602c7d284e1","size":20},{"path":"addons/godot_ai/clients/kiro.gd","sha256":"73a1e60d961f81508f6ab5263770978af3a9a0cb29adf5cf30267674c86ccbb3","size":829},{"path":"addons/godot_ai/clients/kiro.gd.uid","sha256":"e36281df5685847acc757533d5ab4beb44391db897e123ebb0b377ce554e0ce5","size":20},{"path":"addons/godot_ai/clients/opencode.gd","sha256":"fe0cdab3b2541f330367b97c718e3331be323a5d31d056e6502f399ce8711a5e","size":2475},{"path":"addons/godot_ai/clients/opencode.gd.uid","sha256":"cebbf0729ea920db3266906f32279189520176bd10225ebbeec69882d97b9c09","size":19},{"path":"addons/godot_ai/clients/pi.gd","sha256":"0afa45f304d7da41a624aafcb6d684c76fe6a16f3f54c6d5b573e93963be4471","size":2731},{"path":"addons/godot_ai/clients/pi.gd.uid","sha256":"6ca88777d2a2a46d7ae8cb27c96650c24821c2db31efdaf14ec1c672770b13a7","size":19},{"path":"addons/godot_ai/clients/qwen_code.gd","sha256":"af8c74797bf3b0617d0639052a86167cabff123fc110487a2f9a53033be5cef1","size":884},{"path":"addons/godot_ai/clients/qwen_code.gd.uid","sha256":"124a3efc1063037a79d1ef713b066808dff87f84b062779108e3df0dfe78c70a","size":19},{"path":"addons/godot_ai/clients/roo_code.gd","sha256":"7dbdbfa610abfea313d53497b35b3d9f482a6a8d67e37486d2d8e01c9fa48935","size":2149},{"path":"addons/godot_ai/clients/roo_code.gd.uid","sha256":"60128a673c0122f6cdc2562d2ad517805c4120b98ae24ebce02c5471a5a2b16b","size":20},{"path":"addons/godot_ai/clients/trae.gd","sha256":"3fabf591a0e7507b509116ec0539f7175cbed74346c82c3f56a2fab81d122746","size":873},{"path":"addons/godot_ai/clients/trae.gd.uid","sha256":"69409fe3a502dd271d2abddbd97ac31187034b3f8aa409226116e2f1bc7199b8","size":20},{"path":"addons/godot_ai/clients/vscode.gd","sha256":"e44f4c08cf67326ae312c83fa3bb1179840e5bb178352e50e6a432590e0d969a","size":1219},{"path":"addons/godot_ai/clients/vscode.gd.uid","sha256":"738e01d95a248402eeca40d764030a4a4063a246904475b49145c6c159d55ddf","size":20},{"path":"addons/godot_ai/clients/vscode_insiders.gd","sha256":"1b9da0fce3d453dd108b28b1e0b645feb12be4e56401f9d5b58aed95f3396a92","size":851},{"path":"addons/godot_ai/clients/vscode_insiders.gd.uid","sha256":"4fd8b6a9a5e330f7428dac1adb0fd0e5406343febd8bafad0a7fe3e5d214cf88","size":20},{"path":"addons/godot_ai/clients/windsurf.gd","sha256":"9a1a3ba1be1d27dd24684923a483fe8706a65343bf08e47d7625ff6ef2c0f453","size":1242},{"path":"addons/godot_ai/clients/windsurf.gd.uid","sha256":"6b348d4a775be6af159c77bbc7ff5df86a921928f76a1920df97060c5f3c5901","size":20},{"path":"addons/godot_ai/clients/zed.gd","sha256":"e087ab94dea5a41dd1046f83d120583b9258935668e81091ffa12161c4d7cb1b","size":1646},{"path":"addons/godot_ai/clients/zed.gd.uid","sha256":"737f5044ffdf88b5d9f00a52e69108378b4bf4dd404fe240b8865a1e13bd45cf","size":20},{"path":"addons/godot_ai/clients/zoo_code.gd","sha256":"260b57e7e7c7e32854f7e3690adb1e01164f855f8a828948b8f78fd3f9144637","size":1742},{"path":"addons/godot_ai/clients/zoo_code.gd.uid","sha256":"9000f73477a13428779c8e1db0631291275fde02913148ec0094ced66bc0080b","size":20},{"path":"addons/godot_ai/connection.gd","sha256":"802071f7d38251101d448c421ade7b09fb28c2f3438344455f2965dede857910","size":48377},{"path":"addons/godot_ai/connection.gd.uid","sha256":"5bd784acd459f5d89ce2290026030f9bac4586f2ab9fa2ce6f680acbba6cd7ec","size":20},{"path":"addons/godot_ai/custom_tools/custom_tool_wrapper.gd","sha256":"5a103ae1c5c26f01028b35e85b1dfdb264a0d1cf04ad9588210cd2dc11ebbe30","size":4070},{"path":"addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid","sha256":"ce5f3ecfd8794eac6ae799df6b73ca6f9ee74637820e99e31eec831ff00bdf70","size":20},{"path":"addons/godot_ai/custom_tools/mcp_call_context.gd","sha256":"229fa14a9257878dd3dd7ad2628195c09ac9068680498105551540864e55cbd6","size":1498},{"path":"addons/godot_ai/custom_tools/mcp_call_context.gd.uid","sha256":"197960618a07c1962b7ae9e2d6cffdfc7e38f66c3980be5f3922e5dbf8be55a2","size":20},{"path":"addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd","sha256":"c9edada47c56e4aea1735b668d9212dae0420a75ffc66ca4e974962bc8351df2","size":4400},{"path":"addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid","sha256":"7472166e686079f1054ca3365154fa4d05db5a7fed8f137ce05d2f268b48f3b3","size":19},{"path":"addons/godot_ai/custom_tools/mcp_service_locator.gd","sha256":"f329eac25097a201c3d00f342ccdb5c9e4cd5bd758bc5504726abe2a6c5b3069","size":377},{"path":"addons/godot_ai/custom_tools/mcp_service_locator.gd.uid","sha256":"4b878baf63f39983325ac5435cb6fc456090a24610f5db7dfefe1d8aa344a9bf","size":20},{"path":"addons/godot_ai/custom_tools/mcp_tool_registry.gd","sha256":"d9b69c3464dc09e4e1fa224f4cb8b4c928a9190d6d44bd2216c48e3026ea70dc","size":10436},{"path":"addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid","sha256":"1c6f5521d3757f347c4cd4171541328008245c9423500f379c4c9bd9ed0cf0fd","size":20},{"path":"addons/godot_ai/debugger/mcp_debugger_plugin.gd","sha256":"13766ef2d32d90bdb86df03010e507dff6251959b115d0840ead5695e9294b7b","size":86484},{"path":"addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid","sha256":"be3050234c07a27e62be35eb521fb48d37037de4beefe88cbea9e2232051d75c","size":20},{"path":"addons/godot_ai/dispatcher.gd","sha256":"6a4a802a0d7b44837d9a25b9b95199150881e1acf7e7bd5484f7e51425db921f","size":22386},{"path":"addons/godot_ai/dispatcher.gd.uid","sha256":"66d99fb65718903951361405e5f161cab7aa44636aa080c0bb4c7eef774a10c6","size":20},{"path":"addons/godot_ai/dock_panels/log_viewer.gd","sha256":"3975f54e49a4259f6cba23d031426ab4cbd90efda6ade75f0d3263d91b202a73","size":3036},{"path":"addons/godot_ai/dock_panels/log_viewer.gd.uid","sha256":"0c49d5ca8c63cc2d29614dbc7cbc1dcdc046f4dd85ddfd46cda93c3b3246426c","size":20},{"path":"addons/godot_ai/dock_panels/port_picker_panel.gd","sha256":"718b8e592abbe2e5b1118a04c452735fdb7e247904ac971003692ddcec3bc225","size":4252},{"path":"addons/godot_ai/dock_panels/port_picker_panel.gd.uid","sha256":"c4c8bfff382983add25baf1000e1214a6c6d227cb9ae4dd9c94e078513b74cd1","size":19},{"path":"addons/godot_ai/export/mcp_export_plugin.gd","sha256":"e32fd58f473c4d9c974d866ada447058e1f71b933aadcf4b1ef6c6c4ad2a470a","size":3039},{"path":"addons/godot_ai/export/mcp_export_plugin.gd.uid","sha256":"35c878e5453ee13abf3955993de021f7cb71b3c50c30a2b3dc84236959eb7e6e","size":19},{"path":"addons/godot_ai/handlers/_node_validator.gd","sha256":"f24931cfddfec7e0b311e9f53778a5ea7e75026c5a776a6ee75ef1d82050bb7d","size":2864},{"path":"addons/godot_ai/handlers/_node_validator.gd.uid","sha256":"3d293beeb180ea1e5ed05a17655a9ba61498d49ddd3ade61608cd56613cc27bb","size":20},{"path":"addons/godot_ai/handlers/_param_validators.gd","sha256":"d9104c864b375304cf54d7d8825376bae3f606c7ab0956c302b8714968036a33","size":1397},{"path":"addons/godot_ai/handlers/_param_validators.gd.uid","sha256":"f77bead393cdf61b23131c5151bd75b08f6671a2457cff7ea3c7cfe38278eb66","size":20},{"path":"addons/godot_ai/handlers/_property_errors.gd","sha256":"92ddc1dfd433a4aae7d4088a53a0cff36b0b196aaca9d0488e43b7f2614bbd97","size":2837},{"path":"addons/godot_ai/handlers/_property_errors.gd.uid","sha256":"2b49d3d85542c5e0af947ab114fbb7f71885d0310f35a026411323df007c0a2c","size":20},{"path":"addons/godot_ai/handlers/animation_handler.gd","sha256":"7914575e6f8e8ee216a17b43eecde176ab42f61343a218fe88ca29a4feff4789","size":33812},{"path":"addons/godot_ai/handlers/animation_handler.gd.uid","sha256":"47b49982ea7e09cddf957ab596d401b28b8838190a434e07c2afd02ad5381567","size":20},{"path":"addons/godot_ai/handlers/animation_presets.gd","sha256":"39cdcfb12975d194b6cd58c75d5aa4eb08d31aaa36558cb68c9e45198625f47b","size":20151},{"path":"addons/godot_ai/handlers/animation_presets.gd.uid","sha256":"8678c72d4d8d9883c7f0282a80058f0d19c41aaf82d32e17215cf3378171c1bd","size":20},{"path":"addons/godot_ai/handlers/animation_values.gd","sha256":"d6d2d066eee01c312421775e8188aecc6fb235b11329c8b4f5bed36fbac59130","size":16811},{"path":"addons/godot_ai/handlers/animation_values.gd.uid","sha256":"d251c65c53fa71891d10e420ed8ca06ac1ca308f853b6a758badad17acc90aa9","size":20},{"path":"addons/godot_ai/handlers/api_handler.gd","sha256":"9d32daff53e0b78759ff20bca3661ecccd75bef91b9e8bb7feb7928774807fb5","size":3427},{"path":"addons/godot_ai/handlers/api_handler.gd.uid","sha256":"b22ddf4e5b697aab7099ce15d47c2086624997f8e86d2e93c92a585fe23eb5b8","size":19},{"path":"addons/godot_ai/handlers/audio_handler.gd","sha256":"4a1f4832b6cf1b90de41a126d1ccf762c95b18d9f01c6e86cb1b475c4d28c3b1","size":11600},{"path":"addons/godot_ai/handlers/audio_handler.gd.uid","sha256":"64b5a9af05623f153e788733749516412509423eb57b945146b0778cd867b146","size":20},{"path":"addons/godot_ai/handlers/autoload_handler.gd","sha256":"b35b70096de141afa97035127702d6e6dad7f1432218a22dee217a2bc9cfd8b7","size":3112},{"path":"addons/godot_ai/handlers/autoload_handler.gd.uid","sha256":"aa05d871c5800e244428010f2a19dc357f0f29b8a025b8c64614357c4db31956","size":20},{"path":"addons/godot_ai/handlers/batch_handler.gd","sha256":"d951813ac00f536e2f8d14d97d4949ced152ddb94e8a305852193e3bb94f794c","size":10760},{"path":"addons/godot_ai/handlers/batch_handler.gd.uid","sha256":"e506e0940373f6e8dbf2c775e1bb690cb7c75b1d877f6e1a0d2493b2d364ce05","size":20},{"path":"addons/godot_ai/handlers/camera_handler.gd","sha256":"e17a7e037ac887f5b7b78e462ffdc78c51d6f0a11cd846cf5b274507383d99ef","size":40171},{"path":"addons/godot_ai/handlers/camera_handler.gd.uid","sha256":"bd89c9cdb3c8834e74b6a8e2fcbf233b516f1c471309f28c21f0b020b9c31792","size":20},{"path":"addons/godot_ai/handlers/camera_presets.gd","sha256":"b9a3f6814f183ea15cfba625742496fdc6fc26f0952294cf89f526fab653085d","size":2208},{"path":"addons/godot_ai/handlers/camera_presets.gd.uid","sha256":"68ef1f3fe020a144155eabe99b2c2c17feb113c095a41b3af57442dc5d36c471","size":20},{"path":"addons/godot_ai/handlers/camera_values.gd","sha256":"8a0483e799d7e27bfb513670ece5fd0c8638b288fed8ec2c92971cd06a371cbd","size":4251},{"path":"addons/godot_ai/handlers/camera_values.gd.uid","sha256":"fa6f2ea8c1bb70167d06426cb01dd29ecd47d8b3cdfe9efa034cfdc16d95427a","size":20},{"path":"addons/godot_ai/handlers/client_handler.gd","sha256":"7cd2d0873d7b1cdff84abc310ca6b9c0de69167015a437bb93c219f19bcc5a34","size":1922},{"path":"addons/godot_ai/handlers/client_handler.gd.uid","sha256":"2599d215b73e6a60b767aba1083a68da6203674c13bb131d29179e98eef029cf","size":20},{"path":"addons/godot_ai/handlers/command_handler.gd","sha256":"2cd67d1e5482c1f1cb1768aaca18b80ea4902ece50ce2b2b67e23c721ca1ac02","size":555},{"path":"addons/godot_ai/handlers/command_handler.gd.uid","sha256":"47fb104a35055fa877816068db671b7dc8afde68734e64ba62f591f54eeda078","size":19},{"path":"addons/godot_ai/handlers/control_draw_recipe_handler.gd","sha256":"e0d50f72bdc61ee3c6ce0dade726cc7d9668c747af33290e17d74bb92861d0ea","size":9717},{"path":"addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid","sha256":"ccb3400c75d59519bdcc4da1931831bae093ce327a9c5fd39938cb3710e37cf3","size":20},{"path":"addons/godot_ai/handlers/csg_handler.gd","sha256":"45216f2820921f2e94a1ae7c849368c9a738c51d7dda2525ec888b1bf6d5e3d8","size":4479},{"path":"addons/godot_ai/handlers/csg_handler.gd.uid","sha256":"a593088c8622b9ac81d8a4790eccb1630e79ce57568a87b2c2f87edb6ba25aba","size":20},{"path":"addons/godot_ai/handlers/curve_handler.gd","sha256":"b7791298b4362de8191d0b87605dd05d4bb5e408b803d837482cb49d15680050","size":9363},{"path":"addons/godot_ai/handlers/curve_handler.gd.uid","sha256":"7fec875de89cf7a634b5fa787d139c601face5e123df6134dc13eecd9e3f3285","size":20},{"path":"addons/godot_ai/handlers/editor_handler.gd","sha256":"6d88b551b5ec92d210c9ffc0956217229ab91f4959976d0ac81185ed8f11e6fc","size":45777},{"path":"addons/godot_ai/handlers/editor_handler.gd.uid","sha256":"d36224d3a7b803805c0e80ce017ea7dcdd395c360ccce81a102edf9b264cc499","size":20},{"path":"addons/godot_ai/handlers/environment_handler.gd","sha256":"51fa05f0f0c2f4c8ce98569380955815796f6ec33ab198f57be326bb1372d61c","size":6731},{"path":"addons/godot_ai/handlers/environment_handler.gd.uid","sha256":"88f573289f80073f1b7556c25c0c235762e9454c38973a71d57a3319e34da63e","size":20},{"path":"addons/godot_ai/handlers/filesystem_handler.gd","sha256":"9dddf357cafb9ff774f5c1167a71558c41c8956d071b65bf573bfaec22c66988","size":13807},{"path":"addons/godot_ai/handlers/filesystem_handler.gd.uid","sha256":"3156268e89d4c2df4d58027620252304ade3622dfffa63621c9cdfa9ea4d8676","size":20},{"path":"addons/godot_ai/handlers/gridmap_handler.gd","sha256":"308eca951d561a84c13430f5c71b2bb92c0a8bd86f175ea126d3cd47726bc3e1","size":7927},{"path":"addons/godot_ai/handlers/gridmap_handler.gd.uid","sha256":"2d7826639c1a23d5b6b2949b4349200b11d4ebd633e1f3fb22b1207cccfab4c0","size":20},{"path":"addons/godot_ai/handlers/input_handler.gd","sha256":"5c24996f5979ebacdf0321f0383917ae9a31db3aedae2d93087a6c6279f85659","size":16046},{"path":"addons/godot_ai/handlers/input_handler.gd.uid","sha256":"33ec35e4bfce00cf0af9bb186a89b6d489690418209d6b3fccdd2d3e5e5a1fce","size":20},{"path":"addons/godot_ai/handlers/material_handler.gd","sha256":"4d6ad02aaf8a2d4e405e62d5c89f0071d6de28dd46a2754821dd8c7a9c57fec4","size":28527},{"path":"addons/godot_ai/handlers/material_handler.gd.uid","sha256":"2ce5a5847745f8992c75e6634fa8292ad145bac78e97a75df6bc3ec5ff4f3c01","size":20},{"path":"addons/godot_ai/handlers/material_presets.gd","sha256":"cbcff9a52cd25d36e7f462f9b81e85df04d7570afc340f5f741d6a04d80c06b8","size":2217},{"path":"addons/godot_ai/handlers/material_presets.gd.uid","sha256":"25ef884fb537b484f3509bd84558598661ed2f48cd1121893b18203aea55f114","size":20},{"path":"addons/godot_ai/handlers/material_values.gd","sha256":"899d5f9fd614f21f5b98c57ce062d792ef1d6bca204d860e2476680d364e7971","size":6776},{"path":"addons/godot_ai/handlers/material_values.gd.uid","sha256":"c610639c2977ceea714b5c0f9dd97251fd2a4b5b3a500d8ca34cff0b65f502ec","size":20},{"path":"addons/godot_ai/handlers/node_handler.gd","sha256":"d706c30a6286ea1a568f94c40531cfdb2a4cab48058118a841417268d4185819","size":53928},{"path":"addons/godot_ai/handlers/node_handler.gd.uid","sha256":"8a6a204c9cc207814aac0a20f3c2837c174dd8b7d0ba452f2a8694bceb8370bc","size":19},{"path":"addons/godot_ai/handlers/particle_handler.gd","sha256":"f665324b19c2e2e12e57da229b51e0401d83294af12cd0e5934a861b220124ac","size":30982},{"path":"addons/godot_ai/handlers/particle_handler.gd.uid","sha256":"e01cc4640e7f68f31ce6a709f5da2b00e1e14fb93d970101bd4df72be351890e","size":20},{"path":"addons/godot_ai/handlers/particle_presets.gd","sha256":"6a9d93719af847462f057a8ab58dfef61965dbc360f60f045093f4c30187267c","size":8261},{"path":"addons/godot_ai/handlers/particle_presets.gd.uid","sha256":"67e6d4b02f715fdbc8abc9df4667176b7638658f46049d4c040d7ea5b6118b00","size":20},{"path":"addons/godot_ai/handlers/particle_values.gd","sha256":"dc5d00ec054b1aaa4c7c6db2de82f5f18047e2b13bbe5f93ba27c83427ca722e","size":8356},{"path":"addons/godot_ai/handlers/particle_values.gd.uid","sha256":"7843a953959bd04557fa586070f376ff1f9fb6ad6452d53fa6602dad80ff2059","size":20},{"path":"addons/godot_ai/handlers/physics_shape_handler.gd","sha256":"a40de61c3d574a8a4b7c01d9e59c69efc9f6a44982aff43771a956abf09ebee3","size":29747},{"path":"addons/godot_ai/handlers/physics_shape_handler.gd.uid","sha256":"c338b89967011e02e6f143c3115da1c25793dcd2394a2e286f5a9f787f8c0d19","size":20},{"path":"addons/godot_ai/handlers/project_handler.gd","sha256":"ca5d7b9085104824cc9a62802f1bab2e0f34067bf16413b1c857e8f57c4cb892","size":30927},{"path":"addons/godot_ai/handlers/project_handler.gd.uid","sha256":"d2de31aa9f55873f21af809e8933555f5975d208e4555f3e1b788e5d9aad53ca","size":20},{"path":"addons/godot_ai/handlers/resource_handler.gd","sha256":"7b43ae86b51630a9a605963c56ee10c5faf6dda0954fc8924d60eda5a354cf4f","size":24437},{"path":"addons/godot_ai/handlers/resource_handler.gd.uid","sha256":"e57815d98359fcc777f4b5c05c0561299da4aff4c1ccf14add572e068dc53daf","size":19},{"path":"addons/godot_ai/handlers/scene_handler.gd","sha256":"38ab2f20f669ed3641d1606db685993aef8f922a3ab25946417127a22bc04317","size":15888},{"path":"addons/godot_ai/handlers/scene_handler.gd.uid","sha256":"7905d18048c92df68d4ad37c176ec366ffaff0f108d8aca733c48142a8f8aae1","size":19},{"path":"addons/godot_ai/handlers/script_handler.gd","sha256":"2d492cc34949dda110791a87819faa233e3983818e016a977a75465dfcb4d09f","size":21903},{"path":"addons/godot_ai/handlers/script_handler.gd.uid","sha256":"4105d57bc4d5524fa01d0a38a227f03ce5ddf0944206c21f72169b030c33a56d","size":20},{"path":"addons/godot_ai/handlers/signal_handler.gd","sha256":"87183b18355079dbb8350bc81948028a7ac82090654939fea0fb843887733d1c","size":11116},{"path":"addons/godot_ai/handlers/signal_handler.gd.uid","sha256":"81475e35a53bccfb598d9679652169729ea2fa69f365f78005454bd1dd762210","size":20},{"path":"addons/godot_ai/handlers/test_handler.gd","sha256":"91fb4553f92233738d32e30ae1b0e1c6f98b49127ae6fd51fa05eb2aa8988ffe","size":14556},{"path":"addons/godot_ai/handlers/test_handler.gd.uid","sha256":"e92068b687252be24be2b95b3b93eabf906282214b583059c2ec6a45f69fa29c","size":20},{"path":"addons/godot_ai/handlers/texture_handler.gd","sha256":"8c36a856d9fe062075af5427655fab9d9ad9323d70acbd54147f873ac9f8f895","size":6466},{"path":"addons/godot_ai/handlers/texture_handler.gd.uid","sha256":"3bdeafe73069a8f04b9486d8b6697e63c4971bb01f7bab619eaedded39722bf7","size":20},{"path":"addons/godot_ai/handlers/theme_handler.gd","sha256":"cf717acb4c440f6697cb080131c61f35a0cb0a1deeb7da40d167736564a74159","size":18738},{"path":"addons/godot_ai/handlers/theme_handler.gd.uid","sha256":"e1c87aa079ad87e7624994e2539414bddf2caadd180458fd49dafd0109982004","size":19},{"path":"addons/godot_ai/handlers/tilemap_handler.gd","sha256":"13c334e9259965b0990bd7b64fcc249befb98e7cdb520f7ab26cc45558e2ceac","size":6052},{"path":"addons/godot_ai/handlers/tilemap_handler.gd.uid","sha256":"3c688fd5b5f2929b092a9d59c7a55c0878834287a78e18b333654c1f4c2421f2","size":20},{"path":"addons/godot_ai/handlers/tileset_handler.gd","sha256":"d6b11b0e75444aa3e8f9473c7b2ff2207b772f370a77978667541907cd7d56b0","size":5968},{"path":"addons/godot_ai/handlers/tileset_handler.gd.uid","sha256":"44eedb2ff0845c76c196b498a020e9aeb667cdb1b45e4da5813ea87a0b9b9ec7","size":20},{"path":"addons/godot_ai/handlers/ui_handler.gd","sha256":"c3aa8fea776e6bf441f940b90950e890d230b3088acff19381680f61aaddaed4","size":19080},{"path":"addons/godot_ai/handlers/ui_handler.gd.uid","sha256":"a0e5f401b82ea94d81b4ec3b7a2a8905d47258335f728932c01bab75476b84ea","size":20},{"path":"addons/godot_ai/mcp_dock.gd","sha256":"d9766235bd329ae3f63e0e16b9d906fd5b053a636dc613051cad4d4b200b8b03","size":118796},{"path":"addons/godot_ai/mcp_dock.gd.uid","sha256":"62248eebd93ecd7e65fae56ddb14ca23ec0b85989079a6e999bfd60c314712db","size":20},{"path":"addons/godot_ai/migration_bridge.gd","sha256":"851afbc299883d6dc3d091070d03a791804ab0f84a46cfacecb2a4b7f49c2e06","size":286},{"path":"addons/godot_ai/migration_bridge.gd.uid","sha256":"81cfb331c7aca2fde909e025312caf3dca2b52499e6aa7e95510c6bc01d24eec","size":19},{"path":"addons/godot_ai/migration_coordinator.gd","sha256":"3dc10fb657cb3485cb80115348281f2e666dcd8c6ee0caec3794824a15ccf100","size":626},{"path":"addons/godot_ai/migration_coordinator.gd.uid","sha256":"098e70726396f27526b26ec4f4ed115d7f81ae1a63a9eae736bdffd5c63988a8","size":19},{"path":"addons/godot_ai/migration_fallback.gd","sha256":"50a5f046be8f44169135d92fa2f945c3454c13b42b99398eaae67d0a54418e08","size":247},{"path":"addons/godot_ai/migration_fallback.gd.uid","sha256":"6c8781f55e5d61deae64832a79c30273ea73b95857d2b847d354bd72c46bf42e","size":20},{"path":"addons/godot_ai/plugin.cfg","sha256":"6447a6e9b4cf265bca7947e8cbca6b12192d2786ba0cc85ef4c0b5ce61535f0f","size":127},{"path":"addons/godot_ai/plugin.gd","sha256":"8bc48528c68bc16dae211816a93b7b8d2fca2983931e2c1b116ca785d71dc687","size":80914},{"path":"addons/godot_ai/plugin.gd.uid","sha256":"35a798ab044b50da51e1624b5aa951551b83aa45573ae2fa0ae378146cbb58c7","size":20},{"path":"addons/godot_ai/runtime/draw_recipe.gd","sha256":"51ca515c420abb816991c5a730e9ee166b5296086cfad98c89c7abce68a0edff","size":2131},{"path":"addons/godot_ai/runtime/draw_recipe.gd.uid","sha256":"7ae9a6fc4fe77482b2189b7421f024a35cb544e8b1368107e7f0f26598a778c8","size":20},{"path":"addons/godot_ai/runtime/editor_logger.gd","sha256":"d4093bea61f6a8275ff752e85b2265e6f0834f420bbf08c3a59b62b57e7a06af","size":5510},{"path":"addons/godot_ai/runtime/editor_logger.gd.uid","sha256":"587fd5ccabfe31b6a3c78742cb37fcfa696b6e6b9e27746f455a5e3547508542","size":20},{"path":"addons/godot_ai/runtime/game_helper.gd","sha256":"99a0391661d957ea5c0210ae51265ce1f14e68855edd6e73d8d048339897328a","size":55041},{"path":"addons/godot_ai/runtime/game_helper.gd.uid","sha256":"2bb2be95830555d56ff38ad2bcfd69ef29b4a55348f879a9cb61b97b727558ff","size":19},{"path":"addons/godot_ai/runtime/game_logger.gd","sha256":"54f4657c2b9ca26b0c8152f21c5d48a2a71a7cb82c062973218172d507e989ac","size":5606},{"path":"addons/godot_ai/runtime/game_logger.gd.uid","sha256":"e012598e90decb26fe0acee8a5c722e83ab52760470e74f13a91989c3a0f11b6","size":20},{"path":"addons/godot_ai/runtime/validation_logger.gd","sha256":"cc1254c56343d69bd531610b25f99b57f4437fb5b10b1c64312247005bcc2c1d","size":1065},{"path":"addons/godot_ai/runtime/validation_logger.gd.uid","sha256":"e1ec736f74292480f977c54421dcafb68c5def09ec2bf4b78569521b37bfd816","size":20},{"path":"addons/godot_ai/telemetry.gd","sha256":"92755278b554dc0a07b29430edc81abd416e21dc69fbf2c1a569868147b95299","size":8767},{"path":"addons/godot_ai/telemetry.gd.uid","sha256":"641f5ee47d5f74c0591652a500324a81b03b8bd152d874caabceab1489d78920","size":20},{"path":"addons/godot_ai/testing/script_error_capture.gd","sha256":"47363f95b07cff8d2dde46956a7dc46d1721ae5c4b5679633c0038ca0b0ea5cb","size":1102},{"path":"addons/godot_ai/testing/script_error_capture.gd.uid","sha256":"99acfa0494027479551559fb293ad0770cf463042bf4c6974703b873916990f5","size":20},{"path":"addons/godot_ai/testing/test_runner.gd","sha256":"73f1a3efd4024bf6214128c5229c4307f6ee04532a0980defedcf5b3b46301fc","size":14841},{"path":"addons/godot_ai/testing/test_runner.gd.uid","sha256":"af6daba585151379f5275a4dca4418521df77249d71f82050ba44135fd04650e","size":19},{"path":"addons/godot_ai/testing/test_suite.gd","sha256":"7cb7e68509660880d68f98d01e775354b767faff58e31a9f90d33e3857bb5f24","size":9027},{"path":"addons/godot_ai/testing/test_suite.gd.uid","sha256":"20d26aae67a6df5b2c40d0f3bd513d1257d4adc8320d9f559f8a87c85cd19744","size":20},{"path":"addons/godot_ai/tool_catalog.gd","sha256":"8629fb510f27b8b4f8c8d45d440eb19cedfafa754fa136ca572f9282d050ef87","size":5299},{"path":"addons/godot_ai/tool_catalog.gd.uid","sha256":"a5067a52ef97b89d8c9d587679d661d0c5ca7d550e0eef3c3b8a18bb56f31f68","size":20},{"path":"addons/godot_ai/utils/allow_hosts.gd","sha256":"2decc72bb5e3a880a84b4ee4536050bf458f4f4e4987f3672319fa037e64ada5","size":6343},{"path":"addons/godot_ai/utils/allow_hosts.gd.uid","sha256":"6c02022e576afca8d8487f87481b8618543df37030d9e0f19bfdf5d9d9c7b373","size":20},{"path":"addons/godot_ai/utils/class_introspection.gd","sha256":"f0708952ed838349630efe705f70dcc878c86073ddb1d8f37aacd7b5c5b5eca7","size":9452},{"path":"addons/godot_ai/utils/class_introspection.gd.uid","sha256":"f4cf97e1f9676bb5bfe0c3af123ef61678a7881b176cf4cd68114c5642ffe3a9","size":20},{"path":"addons/godot_ai/utils/client_job_owner.gd","sha256":"df1096c71a18842f2fac07227c7af05026eb010ed972bb001fce672d0dd7d86f","size":34489},{"path":"addons/godot_ai/utils/client_job_owner.gd.uid","sha256":"753fdba75d6a597d1e7fbbbe5657c35e80a96d009e36f4746157d3c07d05b082","size":19},{"path":"addons/godot_ai/utils/client_mutation_lock.gd","sha256":"add3b8894d56b66a43ece0406b28601c4d4572ba151e841025499a3ca240d8dc","size":4903},{"path":"addons/godot_ai/utils/client_mutation_lock.gd.uid","sha256":"0e0a9933268cb5eb04f14db2e70db024f069a911ffdf06aee572b238614a4719","size":21},{"path":"addons/godot_ai/utils/diagnostics_capture.gd","sha256":"0eaa9aa3e00274405091cb6d74dc1042341efe88048b6bf168955c5c1b873530","size":2386},{"path":"addons/godot_ai/utils/diagnostics_capture.gd.uid","sha256":"811bc51627303add74eec6dcc72f85dc61aef633c0dc3231442d8eb4b7130fe9","size":20},{"path":"addons/godot_ai/utils/editor_log_buffer.gd","sha256":"f21b0a767b05bd1763f03d1b81e63ad36212071529c1e415b88d874deda0ad43","size":3395},{"path":"addons/godot_ai/utils/editor_log_buffer.gd.uid","sha256":"acfbe64271596c496835b86f4b4c2642cd8675e85d456d4ea5e6a8422da5cd29","size":20},{"path":"addons/godot_ai/utils/error_codes.gd","sha256":"8895a773d74e3b8e7602a4109c8aee8077abd63d3223392f9a87884d27787437","size":9149},{"path":"addons/godot_ai/utils/error_codes.gd.uid","sha256":"df7b436601e68a12ecc38acbad7543bfe0fe6cb152c716b8f9dc7f70a417fe80","size":20},{"path":"addons/godot_ai/utils/fuzzy_suggestions.gd","sha256":"bc2306f4b27d5ae3d8b9a76a00a773382a943ec3df4ce412f92d10bdac336308","size":1072},{"path":"addons/godot_ai/utils/fuzzy_suggestions.gd.uid","sha256":"07f5f545e8c14bd19b4fad4bc5d683fcf4268bb6adfc1eda5d30a1603bc36459","size":20},{"path":"addons/godot_ai/utils/game_log_buffer.gd","sha256":"c3f9e9247ec8b70032bca8ce309c90c938238e859b82f11a7679ce736da55a52","size":3080},{"path":"addons/godot_ai/utils/game_log_buffer.gd.uid","sha256":"2d288c2000132251bc1b969c654ca3b9b7593ffb8e32aabe5faa81c0a7d8303b","size":20},{"path":"addons/godot_ai/utils/json_values.gd","sha256":"cf8b8ca0b9becc9fc7adeb7200972337ef2ee351f77b7cd11cebac90bf7321f5","size":3642},{"path":"addons/godot_ai/utils/json_values.gd.uid","sha256":"c52c80ff8b4fc6120879a598831139a5971fc98551aaac1aaf542f74ec8fed55","size":19},{"path":"addons/godot_ai/utils/log_backtrace.gd","sha256":"d7df786a4437991f403715e6bbdd20bbc396009dc5baf48f7dfeacf75ab2b27b","size":3887},{"path":"addons/godot_ai/utils/log_backtrace.gd.uid","sha256":"450dc80371aac8d4d974f042ecdedc15e0766a2442e8765fa298cb3dd18b6a20","size":20},{"path":"addons/godot_ai/utils/log_buffer.gd","sha256":"81d8a9e24c73b745b3dc90b6b7990f4babada9687189bcffa4379d84f94fb919","size":2569},{"path":"addons/godot_ai/utils/log_buffer.gd.uid","sha256":"cdbbcffb78756e9d9b68d19412110fd6e8fb7a8b2dcfe02d9490b2dd1151d1e1","size":20},{"path":"addons/godot_ai/utils/mcp_adoption_label.gd","sha256":"1c6461816767dbc1d3b0ffefffe4147ea06fe5d8137a7760964b131aa73d0112","size":267},{"path":"addons/godot_ai/utils/mcp_adoption_label.gd.uid","sha256":"6720efd543a8f55ed448f5e2a8216a9950adca1942714958932b1149920509cd","size":19},{"path":"addons/godot_ai/utils/mcp_client_refresh_state.gd","sha256":"589cd2fe35e19a1484ddc10afee2b0070b01b90bda877548aa3f4ff34342f46f","size":4010},{"path":"addons/godot_ai/utils/mcp_client_refresh_state.gd.uid","sha256":"509aec8279aced0e854c8e766e9719cd8a436d945e96ee2e1d223d9f16ea4c3d","size":20},{"path":"addons/godot_ai/utils/mcp_server_state.gd","sha256":"9d44e6d320396375c639bd13f52efd34481cee97edad9b3f244de62f2c7b493f","size":1825},{"path":"addons/godot_ai/utils/mcp_server_state.gd.uid","sha256":"0454bc89b123487b8a90927318fa4ec91315971ff0e798395191bc5d225e6f8a","size":20},{"path":"addons/godot_ai/utils/mcp_startup_path.gd","sha256":"ec7ba9d388b6a565598d433eb43ea40eeb9a8006df1cf05fbcc823742f7fec84","size":455},{"path":"addons/godot_ai/utils/mcp_startup_path.gd.uid","sha256":"570bec0886f6787fd3a74de10f76df90840b284a02f2edf4f4dfff956d7b2933","size":20},{"path":"addons/godot_ai/utils/path_validator.gd","sha256":"9399ba742fc8af571b6ceaa12504efef1a4fafbe6388f74c4e48a20e06ce43fe","size":11101},{"path":"addons/godot_ai/utils/path_validator.gd.uid","sha256":"dbbce00244442b32cc776f39d815a4c09c5ce840f3da001d5aa5db37dd1fa0ef","size":20},{"path":"addons/godot_ai/utils/plugin_reload.gd","sha256":"6fac4ed4e5b3ed6ba54f75d7f9ef6708cfacee8bd532dbad3370a519fedca87c","size":5092},{"path":"addons/godot_ai/utils/plugin_reload.gd.uid","sha256":"56054cb7d397405f0a1137b14cc46bc412207594fc28de393856d13bc9e29131","size":20},{"path":"addons/godot_ai/utils/port_resolver.gd","sha256":"c9d6c9b97501277d86b49ebe84c669b2b612c2e6a4a8674ae505d3bd4840d5a8","size":30181},{"path":"addons/godot_ai/utils/port_resolver.gd.uid","sha256":"4f386419a069705d93a56b64395add29e23e9f74745934a515813c119eb96c70","size":19},{"path":"addons/godot_ai/utils/release_verifier.gd","sha256":"23ed32925d8d6d73874b78b2fa5dbda1f20e57ee3cc433b4bcf25aab96cd1528","size":32268},{"path":"addons/godot_ai/utils/release_verifier.gd.uid","sha256":"dc0b8903879c85733a651a06cd702bd3d3c10922c8e3ee7436d49cccafd8964a","size":20},{"path":"addons/godot_ai/utils/resource_io.gd","sha256":"9afe123cb81f2113f28e7fdbf88daf6a464be6cd327082b813a811b3a7e712c1","size":13172},{"path":"addons/godot_ai/utils/resource_io.gd.uid","sha256":"1759b54639494e1fd14b97a87cae668500555e0141148a9e2aea04d3d1d2b48e","size":20},{"path":"addons/godot_ai/utils/scene_path.gd","sha256":"ab457ce39833897d7448a01e4fe397e44cab022eb407c0b3384bf5bc2bbec7fd","size":7545},{"path":"addons/godot_ai/utils/scene_path.gd.uid","sha256":"1d74b073f8cbc0778b3facbba2a7039553a657710f93899c2514104b221a6560","size":20},{"path":"addons/godot_ai/utils/screenshot_encode.gd","sha256":"6655c7a62d8458656303dc97744f0122aa32b46506d5b6c8b921e599241b085b","size":1487},{"path":"addons/godot_ai/utils/screenshot_encode.gd.uid","sha256":"010f223143d099028ccafba428f9cdb40c05d59b0a949dae49eab339bfb8b006","size":20},{"path":"addons/godot_ai/utils/script_work.gd","sha256":"8a0bef00aafda06debe57cd3562e299919b116548ae07acdbfa86328a989bde7","size":836},{"path":"addons/godot_ai/utils/script_work.gd.uid","sha256":"5caa9666d97006f759ecfa3ac3d9f74688563daaab595347fd0a962d40f0db47","size":19},{"path":"addons/godot_ai/utils/server_authority.gd","sha256":"bbca05a304a369a6ab77a2af34608c0f738e801b13715344b22ccd4f735e9e13","size":3745},{"path":"addons/godot_ai/utils/server_authority.gd.uid","sha256":"093b575d10ad2700e48f454a18e3d00c7df64ae9414e6f77a89e14c0c5eed845","size":19},{"path":"addons/godot_ai/utils/server_lifecycle.gd","sha256":"7570c099314e67d777b67606ccb708f490c7cb35a93ec815911bc8d1ba81b0ad","size":67067},{"path":"addons/godot_ai/utils/server_lifecycle.gd.uid","sha256":"546899b3a5d4024aac3b001ca634e8e74d74343faba27e491ac03af2c3fd6bb1","size":20},{"path":"addons/godot_ai/utils/server_version_check.gd","sha256":"534ca6fd105415d2e9562308a23f62e8e0af6fb0563caf05b9108d1007bfa8d3","size":2597},{"path":"addons/godot_ai/utils/server_version_check.gd.uid","sha256":"b71827a9383a60c02e26d59c19530cfc65a2df0d523220780e5283a9a8573f82","size":20},{"path":"addons/godot_ai/utils/settings.gd","sha256":"8e7409077186eeb15a4a88eeaceb79bacb01d9cfb72bbee4bae745e5c8bcce2f","size":6074},{"path":"addons/godot_ai/utils/settings.gd.uid","sha256":"99e66f1dfe502b81097d3d83819bc211e42ef3b3a97bff4ffd3d672bf21b26dc","size":19},{"path":"addons/godot_ai/utils/structured_log_ring.gd","sha256":"7bb5b56f3b5ec3ba4427b44ac5867005c01ac8a3d84735afd38479993e1f4d7f","size":5282},{"path":"addons/godot_ai/utils/structured_log_ring.gd.uid","sha256":"5ff3dab0db39995b662e5aea73ae0223ba92f6a219a283141d29ef9bce6667a1","size":20},{"path":"addons/godot_ai/utils/surfaced_error_tracker.gd","sha256":"a7e6fc7f4525aa64ceec15a7ab3eab6188f32f744fdd714ad1f4a819e9ede236","size":23645},{"path":"addons/godot_ai/utils/surfaced_error_tracker.gd.uid","sha256":"09b485e56fdd3a69a42d9b74400816bd36943fe15d0781da3aefe4b51be0dc6b","size":19},{"path":"addons/godot_ai/utils/transport_capability.gd","sha256":"9b4d7e41620b1b73aa3e14ba46f57851aa1e38c8cbbeaf365c231ecbc07bebad","size":10590},{"path":"addons/godot_ai/utils/transport_capability.gd.uid","sha256":"b3fdc46837d80983269f7c5cd1b3de88577f9a4daa3cf0312bf72cdb87703fff","size":20},{"path":"addons/godot_ai/utils/update_activation_runner.gd","sha256":"432aa506051e1ffcba51211bb91e9a2a7d98e3c84c8937c7382e70eee95c4601","size":16706},{"path":"addons/godot_ai/utils/update_activation_runner.gd.uid","sha256":"6cbd1c1da3fe5addb3144e517eeee7d8ba775f2806bf822f60d87dacfca03cd4","size":19},{"path":"addons/godot_ai/utils/update_installer.gd","sha256":"d0abd539eda13b8d3b6a00f3b4db8534cf242872a0772853115d5e16f6786d0a","size":22372},{"path":"addons/godot_ai/utils/update_installer.gd.uid","sha256":"100a3707894816165ab33e7cadbb07ec9d7346dcde22ff89eebbced7a82f018e","size":20},{"path":"addons/godot_ai/utils/update_manager.gd","sha256":"615bcde9ea6aa30219b319c0aadc4e17a5bf4abf9482262bebe650f0aa54de12","size":17826},{"path":"addons/godot_ai/utils/update_manager.gd.uid","sha256":"2a2e8422a89307dc7c57711daa1aab0629b5a54ec7f108097a36c2273a0735d0","size":20},{"path":"addons/godot_ai/utils/update_mixed_state.gd","sha256":"97195f0fb8cc005d6a91707dcfd1f14c91c6111f5d675d325de110763b87cd8d","size":6295},{"path":"addons/godot_ai/utils/update_mixed_state.gd.uid","sha256":"ed8d5d6740f1bcc183897ec4407487bea769160573353887d7811a8decf8ca3f","size":20},{"path":"addons/godot_ai/utils/uv_cache_cleanup.gd","sha256":"2f75771088514910b48ff26269e64064a37984f50ba91284185cf3955985269b","size":6444},{"path":"addons/godot_ai/utils/uv_cache_cleanup.gd.uid","sha256":"d209b2b5de32eb6cfeb7b18c8e785f26011d44326b43794478967da7705eace4","size":20},{"path":"addons/godot_ai/utils/uv_resolution_policy.gd","sha256":"9c3da07655e2a0378de430a4612accd1df5709af8555fa9a9df90d0b5abe264f","size":3652},{"path":"addons/godot_ai/utils/uv_resolution_policy.gd.uid","sha256":"cde159a474d570d804fb1efba4b289629b4a830b93f89992a8dd1224c115fe60","size":20},{"path":"addons/godot_ai/utils/variant_serializer.gd","sha256":"25d6e56e62372ea4f1dd7b5be7a1538931177d1dc93ba36687cdb1f2949a4598","size":3150},{"path":"addons/godot_ai/utils/variant_serializer.gd.uid","sha256":"3954570c851e2a1c908b24dfe1e542c622feb5efb1ee5137bdf1a201cadafbbc","size":20},{"path":"addons/godot_ai/utils/windows_port_reservation.gd","sha256":"fbf0060529259bb97dbeb8a0a0f4dce8bbf0ab6a140b9e62170b67908763d14d","size":5248},{"path":"addons/godot_ai/utils/windows_port_reservation.gd.uid","sha256":"f369812cbcfa0201ed23ca05081ad8981a0200f53a8ef7e4ba690d722bf13abb","size":20},{"path":"addons/godot_ai/vision_routing.gd","sha256":"9eab92fda631edf36297712937c8cef1eff5cfbe804e3634e8422b053f622c1a","size":45078},{"path":"addons/godot_ai/vision_routing.gd.uid","sha256":"47ddab7fc353d2a49c3dcf4bbaa5e0e9f05116f7c7fc6bc5ece12246ad0a1494","size":20}],"repository":"hi-godot/godot-ai","schema_version":1,"source_commit":"7968ada03044473372a8ab4beadd975c60b9d94a","tag":"v4.1.0","version":"4.1.0"} diff --git a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.sig b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.sig new file mode 100644 index 0000000..d7a34b8 Binary files /dev/null and b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.sig differ diff --git a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.zip b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.zip new file mode 100644 index 0000000..bf2ee8f Binary files /dev/null and b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.zip differ diff --git a/addons/godot_ai/plugin.cfg b/addons/godot_ai/plugin.cfg new file mode 100644 index 0000000..75c2188 --- /dev/null +++ b/addons/godot_ai/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Godot AI v4 Migration" +description="One-click signed migration from Godot AI v3 to v4" +author="Godot AI" +version="4.1.0" +script="plugin.gd" diff --git a/addons/godot_ai/plugin.gd b/addons/godot_ai/plugin.gd new file mode 100644 index 0000000..dfe0fe4 --- /dev/null +++ b/addons/godot_ai/plugin.gd @@ -0,0 +1,202 @@ +@tool +extends EditorPlugin + +## Temporary v3-compatible entry point delivered through the signed legacy +## updater asset. It owns presentation only. Verification and staging run in +## the detached bridge node; the value-only coordinator survives this plugin +## handing off to the shared in-editor activation runner. + +const MigrationBridge := preload("res://addons/godot_ai/migration_bridge.gd") +const MigrationCoordinator := preload("res://addons/godot_ai/migration_coordinator.gd") +const MigrationFallback := preload("res://addons/godot_ai/migration_fallback.gd") +const FLOOR_REFUSAL := "Godot AI v4 migration requires Godot 4.7 or newer; bridge remains inactive." +const FLOOR_MESSAGE := "Godot AI v4 requires Godot 4.7 or newer. Upgrade Godot, reopen this project, then click Retry." +const FLOOR_RESTORING := ( + "Godot AI v4 requires Godot 4.7 or newer. Restoring the previous Godot AI version; " + + "upgrade Godot to update." +) +const EDITOR_REQUIRED := ( + "Godot AI v4 migration needs the interactive Godot editor. " + + "Reopen the project in the editor (not headless or exported), then click Retry migration." +) + +var _dock: VBoxContainer +var _label: Label +var _retry: Button +var _bridge +var _coordinator + + +func _enter_tree() -> void: + _build_dock() + var prior := _bridge_status() + if not prior.is_empty(): + _present_error(str(prior.get("error", "The previous migration attempt did not finish."))) + return + if not _editor_available(): + ## Headless imports and exports must never mutate the tree, not even + ## to restore final v3; the next interactive start handles it. + _present_state(EDITOR_REQUIRED, true, false) + return + if not _meets_floor(): + print(FLOOR_REFUSAL) + ## The user's updater already overlaid the capsule over their v3 tree. + ## Put the final v3 back so nothing is lost; without the fallback (an + ## older capsule) the bridge can only wait for a newer Godot. + if MigrationFallback.available(): + _present_state(FLOOR_RESTORING, false, false) + var fallback: Node = MigrationFallback.new() + _parent_node().add_child(fallback) + fallback.start() + return + _present_state(FLOOR_MESSAGE, true, false) + return + _start_migration.call_deferred() + + +func _exit_tree() -> void: + _free_bridge() + if _coordinator != null and is_instance_valid(_coordinator): + ## The coordinator outlives this plugin on purpose; it disables the + ## capsule and keeps running. Only stop listening to it. + if _coordinator.state_changed.is_connected(_present_state): + _coordinator.state_changed.disconnect(_present_state) + _coordinator = null + if _dock != null and is_instance_valid(_dock): + remove_control_from_docks(_dock) + _dock.queue_free() + _dock = null + + +func _build_dock() -> void: + _dock = VBoxContainer.new() + _dock.name = "Godot AI" + _label = Label.new() + _label.text = "Preparing the signed Godot AI v4 migration…" + _label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _label.add_theme_font_size_override("font_size", 15) + _dock.add_child(_label) + _retry = Button.new() + _retry.text = "Retry migration" + _retry.visible = false + _retry.pressed.connect(_on_retry) + _dock.add_child(_retry) + add_control_to_dock(DOCK_SLOT_RIGHT_UL, _dock) + + +func _start_migration() -> void: + if _bridge != null or _coordinator != null: + return + _clear_bridge_status() + _retry.visible = false + _label.text = "Preparing the signed Godot AI v4 migration…" + print("MCP | v3 bridge verifying and staging the signed v4 tree") + _bridge = MigrationBridge.new() + _bridge.state_changed.connect(_present_state) + _bridge.prepared.connect(_activate_prepared) + _parent_node().add_child(_bridge) + _bridge.start() + + +func _present_state(message: String, failed: bool = false, record: bool = true) -> void: + if failed: + print("MCP | v3 bridge: %s" % message) + if record: + MigrationCoordinator.record_status(message) + _free_bridge() + _coordinator = null + if _label != null: + _label.text = message + if _retry != null: + _retry.visible = failed + + +func _present_error(message: String) -> void: + _present_state(message, true) + + +func _activate_prepared(package: Dictionary) -> void: + _free_bridge() + print("MCP | v3 bridge activating canonical v4 tree") + _coordinator = MigrationCoordinator.new() + _coordinator.state_changed.connect(_present_state) + _parent_node().add_child(_coordinator) + _coordinator.start(package) + + +func _on_retry() -> void: + if not _meets_floor(): + _present_state(FLOOR_MESSAGE, true, false) + return + if not _editor_available(): + _present_state(EDITOR_REQUIRED, true, false) + return + _start_migration() + + +func _free_bridge() -> void: + if _bridge != null and is_instance_valid(_bridge): + _bridge.queue_free() + _bridge = null + + +func _parent_node() -> Node: + var parent: Node = EditorInterface.get_base_control() + return parent if parent != null else get_tree().root + + +static func _meets_floor() -> bool: + ## Test-only: lets a scenario exercise the refusal path on a current Godot. + if OS.get_environment("GODOT_AI_TEST_GODOT_FLOOR") == "unmet": + return false + var version := Engine.get_version_info() + return int(version.get("major", 0)) == 4 and int(version.get("minor", 0)) >= 7 + + +## Updates are interactive-only: a headless import or an export run loads +## enabled plugins too, and must never swap the add-on tree or restart. +static func _editor_available() -> bool: + if not Engine.is_editor_hint(): + return false + if _truthy(OS.get_environment("GODOT_AI_ALLOW_HEADLESS")): + ## The override the v4 plugin honours too, so CI can drive the + ## crossing in a headless editor. Never set it for a real install. + return EditorInterface.get_base_control() != null + if DisplayServer.get_name().to_lower() == "headless": + return false + if _args_request_headless(OS.get_cmdline_args()): + return false + return EditorInterface.get_base_control() != null + + +static func _truthy(value: String) -> bool: + var lowered := value.strip_edges().to_lower() + return lowered == "1" or lowered == "true" or lowered == "yes" or lowered == "on" + + +static func _args_request_headless(args: PackedStringArray) -> bool: + for i in range(args.size()): + var arg := args[i] + if arg == "--headless": + return true + if arg == "--display-driver" and i + 1 < args.size() and args[i + 1] == "headless": + return true + if arg.begins_with("--display-driver=") and arg.get_slice("=", 1) == "headless": + return true + return false + + +static func _bridge_status() -> Dictionary: + var settings := EditorInterface.get_editor_settings() + var key := MigrationCoordinator.status_setting() + if settings == null or not settings.has_setting(key): + return {} + var parsed: Variant = JSON.parse_string(str(settings.get_setting(key))) + return parsed if parsed is Dictionary else {} + + +static func _clear_bridge_status() -> void: + var settings := EditorInterface.get_editor_settings() + var key := MigrationCoordinator.status_setting() + if settings != null and settings.has_setting(key): + settings.erase(key) diff --git a/addons/godot_ai/plugin.gd.uid b/addons/godot_ai/plugin.gd.uid new file mode 100644 index 0000000..33cf809 --- /dev/null +++ b/addons/godot_ai/plugin.gd.uid @@ -0,0 +1 @@ +uid://b5w7nw8pfnjww diff --git a/addons/godot_ai/utils/port_resolver.gd b/addons/godot_ai/utils/port_resolver.gd new file mode 100644 index 0000000..9ef32e9 --- /dev/null +++ b/addons/godot_ai/utils/port_resolver.gd @@ -0,0 +1,768 @@ +@tool +class_name McpPortResolver +extends RefCounted + +## Pure-static port discovery / OS-specific scrapers. No instance state, +## no editor dependencies. plugin.gd has thin instance shims that wrap +## these and increment the cold-start trace counters. + +## Canonical pid-file path. plugin.gd::SERVER_PID_FILE re-exports this so +## external readers and tests can use either name. +const SERVER_PID_FILE := "user://godot_ai_server.pid" +## Where a plugin-spawned server reports a failure that happens before it +## publishes its capability record (`--startup-report`). Removed before every +## spawn, so a report that exists afterwards belongs to that launch. +const SERVER_STARTUP_REPORT := "user://godot_ai_server_startup.json" +const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") +static var _process_spawn_mutex := Mutex.new() + + +## Serialize plugin-owned subprocess creation across the brief process-global +## environment override used for server capabilities. Callers must keep this +## window to environment mutation + one spawn and release it immediately. +static func lock_process_spawn() -> void: + _process_spawn_mutex.lock() + + +static func unlock_process_spawn() -> void: + _process_spawn_mutex.unlock() + + +## A managed Linux server needs a listener PID tool to prove ownership. +## Query each launch so installing the missing tool makes Retry work. +static func listener_tools_problem() -> String: + if OS.get_name() != "Linux": + return "" + var output: Array = [] + var available := OS.execute("/bin/sh", ["-c", + "command -v lsof >/dev/null 2>&1 || command -v ss >/dev/null 2>&1" + ], output, true) + if available == 0: + return "" + return "Cannot verify Linux listener ownership: neither lsof nor ss is available on the editor's PATH. Install lsof or iproute2 (which provides ss), then retry starting the server." + + +static func can_bind_local_port(port: int) -> bool: + var server := TCPServer.new() + var err := server.listen(port, "127.0.0.1") + if err == OK: + server.stop() + return true + return false + + +## True when `port` is bound on 127.0.0.1. Probes via TCPServer first, +## falls back to OS scraping. Callers that want per-scraper trace +## counters should call `is_port_in_use_via_scrape` with a trace hook +## after their own `can_bind_local_port` probe. +static func is_port_in_use(port: int) -> bool: + if can_bind_local_port(port): + ## On POSIX, an IPv6 wildcard listener can coexist with a + ## successful 127.0.0.1 bind probe. Confirm with lsof so startup + ## sees the same listener set that shutdown/recovery would see. + if OS.get_name() != "Windows": + return is_port_in_use_via_scrape(port) + return false + return is_port_in_use_via_scrape(port) + + +## `trace` mirrors `find_all_pids_on_port`'s hook: one call per OS +## invocation with the counter name of the scraper that actually ran, so +## a wrapping caller's startup trace sees a genuine PowerShell fallback +## as `powershell`, not as a silent extra second under `netstat`. +static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -> bool: + var output: Array = [] + if OS.get_name() == "Windows": + _trace(trace, "netstat") + var exit_code := OS.execute("netstat", ["-ano"], output, true) + if exit_code == 0 and output.size() > 0: + var stdout := str(output[0]) + if parse_windows_netstat_listening(stdout, port): + return true + ## A healthy dump with no listener row IS the answer — don't + ## pay the ~1.2s powershell.exe spawn to confirm "not in use" + ## (see find_all_pids_on_port for the cost rationale). + if windows_netstat_dump_parseable(stdout): + return false + ## Fallback: netstat can be absent or unparseable on + ## stripped/locale-odd Windows installs. + _trace(trace, "powershell") + return not find_listener_pids_windows(port).is_empty() + _trace(trace, "lsof") + var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) + if exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty(): + return true + if OS.get_name() != "Linux": + return false + _trace(trace, "ss") + output.clear() + return ( + OS.execute("ss", ["-H", "-ltnp"], output, true) == 0 + and not parse_linux_ss_pids(str(output[0]) if not output.is_empty() else "", port).is_empty() + ) + + +## Return the PID currently listening on the given TCP port, or 0 if +## the port is free. Thin convenience wrapper around `find_all_pids_on_port` +## — the per-OS scraping logic lives in one place. +static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int: + var pids := find_all_pids_on_port(port, trace) + return pids[0] if not pids.is_empty() else 0 + + +## Returns every PID bound LISTEN on `port`. Used by the kill paths so +## both the uvicorn reloader parent AND its worker child are caught when +## both bind the same port. +## +## `trace` is an optional Callable that fires once per OS invocation with +## a counter name (`"netstat"` / `"powershell"` / `"lsof"` / `"ss"`) so the plugin +## can keep its cold-start trace accurate. The Windows path may fall +## through netstat → PowerShell, and a wrapping caller can't see which +## scraper actually ran without the hook. +static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Array[int]: + if OS.get_name() == "Windows": + var output: Array = [] + _trace(trace, "netstat") + var exit_code := OS.execute("netstat", ["-ano"], output, true) + if exit_code == 0 and not output.is_empty(): + var stdout := str(output[0]) + var netstat_pids := parse_windows_netstat_pids(stdout, port) + if not netstat_pids.is_empty(): + return netstat_pids + ## An empty per-port parse from a healthy dump IS the answer + ## ("no listener"). Confirming it through the PowerShell probe + ## costs a powershell.exe spawn (~1.2s measured) against ~30ms + ## for the netstat scrape — two such confirmations dominated a + ## ~7s Windows startup walk. Only fall through when the dump + ## itself is unusable (netstat absent, or so format-odd that + ## zero TCP rows parse). + if windows_netstat_dump_parseable(stdout): + var no_listeners: Array[int] = [] + return no_listeners + _trace(trace, "powershell") + return find_listener_pids_windows(port) + var output: Array = [] + _trace(trace, "lsof") + var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) + if exit_code == 0 and not output.is_empty(): + var lsof_pids := parse_lsof_pids(str(output[0])) + if not lsof_pids.is_empty() or OS.get_name() != "Linux": + return lsof_pids + elif OS.get_name() != "Linux": + var empty: Array[int] = [] + return empty + _trace(trace, "ss") + output.clear() + if OS.execute("ss", ["-H", "-ltnp"], output, true) != 0 or output.is_empty(): + var empty: Array[int] = [] + return empty + return parse_linux_ss_pids(str(output[0]), port) + + +static func _trace(trace: Callable, counter: String) -> void: + if trace.is_valid(): + trace.call(counter) + + +static func find_listener_pids_windows(port: int) -> Array[int]: + var script := ( + "Get-NetTCPConnection -LocalPort %d -State Listen " + + "-ErrorAction SilentlyContinue | " + + "Select-Object -ExpandProperty OwningProcess" + ) % port + var output: Array = [] + var exit_code := execute_windows_powershell(script, output) + return windows_listener_pids_from_execute_result(exit_code, output) + + +static func execute_windows_powershell(script: String, output: Array) -> int: + var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script] + for exe in windows_powershell_candidates(): + output.clear() + var exit_code := OS.execute(exe, args, output, true) + if exit_code == 0: + return exit_code + return -1 + + +static func windows_powershell_candidates() -> Array[String]: + var candidates: Array[String] = [] + ## Prefer an already-installed PowerShell 7 at its fixed machine path. + ## Missing or failing installations retain every existing fallback below. + for variable in ["ProgramW6432", "ProgramFiles"]: + var program_files := OS.get_environment(variable).replace("\\", "/").trim_suffix("/") + if not program_files.is_empty(): + var pwsh := program_files.path_join("PowerShell/7/pwsh.exe") + if FileAccess.file_exists(pwsh) and not candidates.has(pwsh): + candidates.append(pwsh) + var system_root := OS.get_environment("SystemRoot") + if system_root.is_empty(): + system_root = "C:/Windows" + system_root = system_root.replace("\\", "/").trim_suffix("/") + candidates.append(system_root + "/System32/WindowsPowerShell/v1.0/powershell.exe") + candidates.append("powershell.exe") + candidates.append("pwsh.exe") + return candidates + + +static func windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]: + var empty: Array[int] = [] + if exit_code == 0 and not output.is_empty(): + return parse_pid_lines(str(output[0])) + return empty + + +static func windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool: + return not windows_listener_pids_from_execute_result(exit_code, output).is_empty() + + +## Pure parser for `lsof -ti` output — newline-separated decimal PIDs. +## Empty lines, non-positive values, non-numeric tokens, and duplicate +## IPv4/IPv6 rows for the same listener are dropped. +static func parse_lsof_pids(raw: String) -> Array[int]: + return parse_pid_lines(raw) + + +## Parse Linux `ss -H -ltnp` without trusting process names or localized +## state text. The local endpoint is the fourth whitespace-delimited field; +## every `pid=` value on an exact-port row is retained for later brand, +## lineage, and fingerprint checks. +static func parse_linux_ss_pids(raw: String, port: int) -> Array[int]: + var result: Array[int] = [] + if port < 1 or port > 65535: + return result + var expression := RegEx.new() + if expression.compile("(?:^|,)pid=([0-9]+)(?:,|\\))") != OK: + return result + for line in raw.split("\n", false): + var fields := line.split(" ", false) + if fields.size() < 4 or not str(fields[3]).ends_with(":%d" % port): + continue + for matched in expression.search_all(line): + var pid := int(matched.get_string(1)) + if pid > 0 and not result.has(pid): + result.append(pid) + return result + + +static func parse_pid_lines(raw: String) -> Array[int]: + var pids: Array[int] = [] + for line in raw.strip_edges().split("\n", false): + var stripped := line.strip_edges() + if stripped.is_valid_int(): + var pid := int(stripped) + if pid > 0 and not pids.has(pid): + pids.append(pid) + return pids + + +## Parse a Windows `netstat -ano` dump and return PIDs of rows whose +## local address ends with `:port` AND state is `LISTENING`. Substring +## matching the whole dump is wrong: a remote address containing +## `:port` would false-positive against an unrelated ESTABLISHED row. +static func parse_windows_netstat_pid(stdout: String, port: int) -> int: + var pids := parse_windows_netstat_pids(stdout, port) + return pids[0] if not pids.is_empty() else 0 + + +static func parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]: + var pids: Array[int] = [] + var port_suffix := ":%d" % port + for line in stdout.split("\n"): + var s := line.strip_edges() + if s.is_empty(): + continue + var fields := split_on_whitespace(s) + if fields.size() < 5: # proto, local, remote, state, pid + continue + ## Locale-independent listener signal (mirrors script/_dev_env.py): + ## the state column is localized ("LISTENING"/"ABHÖREN"/"ÉCOUTE"...), + ## but a listener's FOREIGN address is always the wildcard ":0". + if not fields[2].ends_with(":0"): + continue + if not fields[1].ends_with(port_suffix): + continue + var pid_str := fields[fields.size() - 1] + if pid_str.is_valid_int(): + var pid := int(pid_str) + if pid > 0 and not pids.has(pid): + pids.append(pid) + return pids + + +static func parse_windows_netstat_listening(stdout: String, port: int) -> bool: + return parse_windows_netstat_pid(stdout, port) > 0 + + +## True when `stdout` looks like a healthy `netstat -ano` dump: at least +## one row parses as a TCP connection (proto column literally "TCP", an +## address containing ":", an integer PID in the last column). Locale- +## independent — protocol names are never localized, unlike the state +## column. Gates whether an empty per-port parse can be trusted as "no +## listener": a live Windows host always carries TCP rows (svchost/RPC +## listen on 135 at minimum), so a dump with zero parseable rows means +## netstat itself is absent/broken and the PowerShell fallback must run. +static func windows_netstat_dump_parseable(stdout: String) -> bool: + for line in stdout.split("\n"): + var fields := split_on_whitespace(line.strip_edges()) + if fields.size() < 5: + continue + if fields[0].to_upper() != "TCP": + continue + if fields[1].find(":") < 0: + continue + if fields[fields.size() - 1].is_valid_int(): + return true + return false + + +## `String.split(" ", false)` only splits on single spaces; netstat +## columns are separated by runs of spaces / tabs. Collapse manually. +static func split_on_whitespace(s: String) -> PackedStringArray: + var out: PackedStringArray = [] + var cur := "" + for i in s.length(): + var c := s.substr(i, 1) + if c == " " or c == "\t": + if not cur.is_empty(): + out.append(cur) + cur = "" + else: + cur += c + if not cur.is_empty(): + out.append(cur) + return out + + +static func read_pid_file(path := SERVER_PID_FILE) -> int: + if not FileAccess.file_exists(path): + return 0 + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + return 0 + var content := f.get_as_text().strip_edges() + f.close() + if content.is_empty() or not content.is_valid_int(): + return 0 + var pid := int(content) + return pid if pid > 0 else 0 + + +static func clear_pid_file() -> void: + if FileAccess.file_exists(SERVER_PID_FILE): + DirAccess.remove_absolute(ProjectSettings.globalize_path(SERVER_PID_FILE)) + + +## `kill -0` returns 0 for both running and zombie processes; Godot +## never `waitpid`s on `OS.create_process` children, so a fast-failing +## uvx launcher lingers as a zombie forever and `kill -0` would block +## the spawn-failure branch in check_server_health from firing. Use +## `ps -o stat=` instead. State codes: R/S/D/I/T (live), Z (zombie). #172. +static func pid_alive(pid: int, snapshot: Variant = null) -> bool: + if pid <= 0: + return false + if snapshot != null: + return not _process_snapshot_row(snapshot, pid).is_empty() + if OS.get_name() == "Windows": + var output: Array = [] + var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true) + if exit_code != 0 or output.is_empty(): + return false + for line in output: + if str(line).find("\"%d\"" % pid) >= 0: + return true + return false + var output: Array = [] + var exit_code := OS.execute("ps", ["-p", str(pid), "-o", "stat="], output, true) + if exit_code != 0 or output.is_empty(): + return false + var stat := str(output[0]).strip_edges() + return not stat.is_empty() and not stat.begins_with("Z") + + +## Read-only process identity helpers. Lifecycle authority is granted only +## after capturing a fingerprint and every later stop re-reads the fingerprint +## before acting, so PID reuse fails closed. +## One Windows query captures the target and at most fifteen ancestors. The +## dictionary belongs to one proof boundary, never a cache: callers must take +## a fresh snapshot when closing the capture window or authorizing a kill. +## Non-Windows callers receive null and keep the existing live-query path. +## The process enumeration stays inside PowerShell; only the bounded target +## ancestry crosses back into Godot, without logging command-line metadata. +static func capture_process_snapshot(pid: int) -> Variant: + if OS.get_name() != "Windows": + return null + if pid <= 1: + return {"capture_error": true} + var script := _windows_process_snapshot_script(pid) + var output: Array = [] + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return {"capture_error": true} + return parse_process_snapshot(str(output[0]), pid) + + +static func _windows_process_snapshot_script(pid: int) -> String: + return ( + "$rows = @(); $current = %d; $seen = @{}; $capture_failed = $false; " + + "$processes = @{}; try { Get-CimInstance Win32_Process -ErrorAction Stop | " + + "ForEach-Object { $processes[[int]$_.ProcessId] = $_ } } catch {}; " + + "for ($depth = 0; $depth -lt 16 -and $current -gt 1; $depth++) { " + + "if ($seen.ContainsKey($current)) { break }; $seen[$current] = $true; " + + "$p = $processes[$current]; " + + "if ($null -eq $p) { " + + "try { $identity = [string](Get-Process -Id $current -ErrorAction Stop).StartTime.ToFileTimeUtc(); " + + "$rows += @{pid=$current;parent_pid=0;identity=$identity;commandline=''} } catch { if ($_.CategoryInfo.Category -ne 'ObjectNotFound') { $capture_failed = $true } }; break }; " + + "$rows += @{pid=[int]$p.ProcessId;parent_pid=[int]$p.ParentProcessId; " + + "identity=([string]$p.CreationDate + '|' + [string]$p.CommandLine);commandline=[string]$p.CommandLine}; " + + "$current = [int]$p.ParentProcessId }; " + + "if ($capture_failed) { 'null' } else { ConvertTo-Json -InputObject @($rows) -Compress -Depth 3 }" + ) % pid + + +## Independent CIM maps in separate scriptblock scopes are +## captured within one shell invocation. No snapshot crosses a grant call. +static func _capture_windows_process_snapshot_pair(pid: int) -> Array: + var query := _windows_process_snapshot_script(pid) + var script := ( + "$first = & { %s }; $final = & { %s }; " + + "ConvertTo-Json -InputObject @([string]$first,[string]$final) -Compress" + ) % [query, query] + var output: Array = [] + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return [{"capture_error": true}, {"capture_error": true}] + return _parse_process_snapshot_pair(str(output[0]), pid) + + +static func _parse_process_snapshot_pair(raw: String, pid: int) -> Array: + if raw.length() > 4 * 1024 * 1024 + 16: + return [{"capture_error": true}, {"capture_error": true}] + var json := JSON.new() + if json.parse(raw) != OK: + return [{"capture_error": true}, {"capture_error": true}] + var pair: Variant = json.data + if not (pair is Array) or pair.size() != 2 or not (pair[0] is String and pair[1] is String): + return [{"capture_error": true}, {"capture_error": true}] + return [parse_process_snapshot(pair[0], pid), parse_process_snapshot(pair[1], pid)] + + +static func parse_process_snapshot(raw: String, expected_pid: int) -> Dictionary: + if raw.length() > 1024 * 1024: + return {"capture_error": true} + var json := JSON.new() + if json.parse(raw) != OK: + return {"capture_error": true} + var rows: Variant = json.data + if not (rows is Array) or rows.size() > 16: + return {"capture_error": true} + var snapshot := {} + var next_pid := expected_pid + for row in rows: + if not (row is Dictionary): + return {"capture_error": true} + var value: Variant = row.get("pid") + if ( + not (value is int or value is float) or not is_finite(float(value)) + or float(value) <= 1.0 or float(value) > 4294967295.0 + or float(value) != float(int(value)) + ): + return {"capture_error": true} + var pid := int(value) + if pid <= 1 or pid != next_pid or snapshot.has(pid): + return {"capture_error": true} + snapshot[pid] = row + if _process_snapshot_row(snapshot, pid).is_empty(): + return {"capture_error": true} + next_pid = int(row.parent_pid) + if snapshot.has(next_pid): + return {"capture_error": true} + return snapshot + + +## An unavailable capture is not evidence that its PID has exited. +static func capture_failed(snapshot: Variant) -> bool: + return snapshot is Dictionary and bool(snapshot.get("capture_error", false)) + + +static func _process_snapshot_row(snapshot: Variant, pid: int) -> Dictionary: + if not (snapshot is Dictionary) or capture_failed(snapshot) or pid <= 1: + return {} + var row: Variant = snapshot.get(pid) + if not (row is Dictionary) or row.size() != 4: + return {} + var parent: Variant = row.get("parent_pid") + if ( + row.get("pid") != pid + or not (parent is int or parent is float) + or not is_finite(float(parent)) or float(parent) < 0.0 or float(parent) > 4294967295.0 + or float(parent) != float(int(parent)) + or not (row.get("identity") is String) or str(row.identity).strip_edges().is_empty() + or not (row.get("commandline") is String) + ): + return {} + var identity := str(row.identity) + var commandline := str(row.commandline) + var separator := identity.find("|") + if separator >= 0: + if separator == 0 or identity.substr(separator + 1) != commandline: + return {} + elif not identity.is_valid_int() or not commandline.is_empty() or int(parent) != 0: + return {} + return row + + +static func process_commandline(pid: int, snapshot: Variant = null) -> String: + if pid <= 1: + return "" + if snapshot != null: + return str(_process_snapshot_row(snapshot, pid).get("commandline", "")) + if OS.get_name() == "Windows": + var output: Array = [] + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "Select-Object -ExpandProperty CommandLine" + ) % pid + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return "" + return str(output[0]).strip_edges() + var proc_path := "/proc/%d/cmdline" % pid + if FileAccess.file_exists(proc_path): + var file := FileAccess.open(proc_path, FileAccess.READ) + if file != null: + var bytes := PackedByteArray() + while bytes.size() < (1 << 20): + var chunk := file.get_buffer(4096) + if chunk.is_empty(): + break + bytes.append_array(chunk) + if file.eof_reached(): + break + file.close() + for index in range(bytes.size()): + if bytes[index] == 0: + bytes[index] = 0x20 + return bytes.get_string_from_utf8().strip_edges() + var output: Array = [] + if OS.execute("ps", ["-ww", "-p", str(pid), "-o", "args="], output, true) != 0: + return "" + return str(output[0]).strip_edges() if not output.is_empty() else "" + + +static func process_parent(pid: int, snapshot: Variant = null) -> int: + if pid <= 1: + return 0 + if snapshot != null: + return int(_process_snapshot_row(snapshot, pid).get("parent_pid", 0)) + var output: Array = [] + if OS.get_name() == "Windows": + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "Select-Object -ExpandProperty ParentProcessId" + ) % pid + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return 0 + else: + if OS.execute("ps", ["-o", "ppid=", "-p", str(pid)], output, true) != 0 or output.is_empty(): + return 0 + var raw := str(output[0]).strip_edges() + return int(raw) if raw.is_valid_int() else 0 + + +static func process_descends_from(pid: int, ancestor_pid: int, snapshot: Variant = null) -> bool: + if pid <= 1 or ancestor_pid <= 1: + return false + if snapshot != null and _process_snapshot_row(snapshot, pid).is_empty(): + return false + var current := pid + for _depth in range(16): + if snapshot != null and _process_snapshot_row(snapshot, current).is_empty(): + return false + if current == ancestor_pid: + return true + var parent := process_parent(current, snapshot) + if parent <= 1 or parent == current: + return false + current = parent + return false + + +static func commandline_is_godot_ai_server(commandline: String) -> bool: + if commandline.is_empty(): + return false + var lower := commandline.to_lower() + var expression := RegEx.new() + var brand_search := lower + ## Paths we choose ourselves must not satisfy the brand: strip the pid-file + ## and startup-report values (they live under user:// and carry our name) + ## before searching for it. + if expression.compile("--(pid-file|startup-report)(?:=|\\s+)\\S+") == OK: + brand_search = expression.sub(lower, "--$1 ", true) + var branded := brand_search.contains("godot-ai") or brand_search.contains("godot_ai") + return branded and (lower.contains("--pid-file") or lower.contains("--transport")) + + +static func pid_cmdline_is_godot_ai(pid: int, snapshot: Variant = null) -> bool: + var current := pid + for _depth in range(5): + if current <= 1: + return false + if commandline_is_godot_ai_server(process_commandline(current, snapshot)): + return true + current = process_parent(current, snapshot) + return false + + +static func process_fingerprint(pid: int, snapshot: Variant = null) -> String: + if not pid_alive(pid, snapshot): + return "" + var output: Array = [] + var identity := "" + if snapshot != null: + identity = str(_process_snapshot_row(snapshot, pid).get("identity", "")).strip_edges() + elif OS.get_name() == "Windows": + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "ForEach-Object { [string]$_.CreationDate + '|' + [string]$_.CommandLine }" + ) % pid + if execute_windows_powershell(script, output) == 0 and not output.is_empty(): + identity = str(output[0]).strip_edges() + if identity.is_empty(): + ## Some restricted Windows hosts expose process start time through + ## Get-Process while CIM returns no Win32_Process projection. PID + + ## creation time is the reuse-resistant identity; command-line brand + ## is checked independently before destructive authority is minted. + output.clear() + script = ( + "(Get-Process -Id %d -ErrorAction Stop).StartTime.ToFileTimeUtc()" + ) % pid + if execute_windows_powershell(script, output) == 0 and not output.is_empty(): + identity = str(output[0]).strip_edges() + else: + if OS.execute( + "ps", ["-ww", "-p", str(pid), "-o", "lstart=", "-o", "args="], output, true + ) == 0 and not output.is_empty(): + identity = str(output[0]).strip_edges() + if identity.is_empty(): + return "" + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update((str(pid) + "|" + identity).to_utf8_buffer()) + return context.finish().hex_encode() + + +## Capture the process identity that a later destructive call must present. +## A PID or command-line brand alone is never kill authority: either can refer +## to a different process by the time a worker reaches the effect boundary. +## `diagnostics`, when given, receives one word naming the check that +## refused the grant. It costs no extra probe and never changes the result. +static func capture_process_kill_grant( + pid: int, require_brand := false, diagnostics: Array = [] +) -> Dictionary: + if pid <= 1 or pid == OS.get_process_id(): + diagnostics.append("invalid_pid") + return {} + var pair: Array = _capture_windows_process_snapshot_pair(pid) if OS.get_name() == "Windows" else [] + var first: Variant = pair[0] if not pair.is_empty() else capture_process_snapshot(pid) + if capture_failed(first): + diagnostics.append("identity_unavailable") + return {} + if not pid_alive(pid, first): + diagnostics.append("not_alive") + return {} + if require_brand and not pid_cmdline_is_godot_ai(pid, first): + diagnostics.append("unbranded") + return {} + var fingerprint := process_fingerprint(pid, first) + if fingerprint.is_empty(): + diagnostics.append("fingerprint_unavailable") + return {} + ## Close the capture window: both identity and optional lineage/brand must + ## still describe the same process in the independently collected final snapshot. + var final: Variant = pair[1] if not pair.is_empty() else capture_process_snapshot(pid) + if capture_failed(final): + diagnostics.append("identity_unavailable") + return {} + if process_fingerprint(pid, final) != fingerprint: + diagnostics.append("fingerprint_changed") + return {} + if require_brand and not pid_cmdline_is_godot_ai(pid, final): + diagnostics.append("brand_changed") + return {} + return {"pid": pid, "fingerprint": fingerprint} + + +## Kill only exact process grants and re-read identity immediately before the +## OS call. On Windows, taskkill /T already handles real descendants; do not +## append command-line lookalikes such as spoofed multiprocessing children. +static func kill_exact_processes( + grants: Array[Dictionary], require_brand := false, require_tree_proof := false +) -> Array[int]: + var killed: Array[int] = [] + var seen: Array[int] = [] + for grant in grants: + if grant.keys().size() != 2 or not grant.has("pid") or not grant.has("fingerprint"): + continue + var pid := int(grant.get("pid", 0)) + var fingerprint := str(grant.get("fingerprint", "")) + if pid <= 1 or pid == OS.get_process_id() or seen.has(pid) or fingerprint.is_empty(): + continue + var snapshot: Variant = capture_process_snapshot(pid) + if ( + process_fingerprint(pid, snapshot) != fingerprint + or (require_brand and not pid_cmdline_is_godot_ai(pid, snapshot)) + ): + continue + seen.append(pid) + if OS.get_name() == "Windows": + var output: Array = [] + var code := OS.execute("taskkill", ["/PID", str(pid), "/T", "/F"], output, true) + ## A vanished direct PID is enough for ordinary exact-process callers. + ## Timeout safety needs stronger evidence: only taskkill /T success + ## proves that Windows accepted termination of the whole child tree. + if code == 0 or (not require_tree_proof and not pid_alive(pid)): + killed.append(pid) + elif OS.kill(pid) == OK or not pid_alive(pid): + killed.append(pid) + return killed + + +## Poll until the given port is no longer bound, or the timeout elapses. +## Used after `OS.kill` so we don't race the port-in-use check on rebind. +static func wait_for_port_free(port: int, timeout_s: float) -> void: + var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0) + while is_port_in_use(port): + if Time.get_ticks_msec() >= deadline: + push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s]) + return + OS.delay_msec(100) + + +## Choose a non-Windows-reserved WS port. Returns `configured` when free; +## otherwise the first non-excluded port within `span` of it. Optional +## `log_buffer` is a duck-typed sink (`log(String)`) that gets the +## remap notice so users see why the port shifted. +static func resolve_ws_port(configured: int, max_port: int, log_buffer = null) -> int: + var resolved := WindowsPortReservation.suggest_non_excluded_port( + configured, + 2048, + max_port + ) + if resolved != configured: + var message := "WebSocket port %d is reserved by Windows; using %d" % [configured, resolved] + print("MCP | %s" % message) + if log_buffer != null: + log_buffer.log(message) + return resolved + + +static func resolve_ws_port_from_output( + configured_port: int, + netsh_output: String, + max_port: int, + span: int = 2048 +) -> int: + return WindowsPortReservation.suggest_non_excluded_port_from_output( + netsh_output, + configured_port, + span, + max_port + ) diff --git a/addons/godot_ai/utils/port_resolver.gd.uid b/addons/godot_ai/utils/port_resolver.gd.uid new file mode 100644 index 0000000..54a3d73 --- /dev/null +++ b/addons/godot_ai/utils/port_resolver.gd.uid @@ -0,0 +1 @@ +uid://pk0212qfh61x diff --git a/addons/godot_ai/utils/release_verifier.gd b/addons/godot_ai/utils/release_verifier.gd new file mode 100644 index 0000000..77ce158 --- /dev/null +++ b/addons/godot_ai/utils/release_verifier.gd @@ -0,0 +1,876 @@ +@tool +class_name McpReleaseVerifier +extends RefCounted + +## Pure, static verification of a signed v4 release: the canonical manifest, +## its RSA signature, the archive, and the tree hash the installer compares +## after extraction and again after restart. +## +## Every rule here mirrors `src/godot_ai/release_verify.py`. The two must stay +## byte-for-byte compatible: the release pipeline produces the manifest with +## Python's `_canonical()` and the installed-tree hash with Python's +## `hash_tree()`, and this file re-derives both from raw bytes. Every function +## fails closed with a specific error string and never raises. + +const REPOSITORY := "hi-godot/godot-ai" +const CHANNEL := "stable" +const ASSET_NAME := "godot-ai-v4-plugin.zip" +const PLUGIN_PREFIX := "addons/godot_ai/" +const PLUGIN_CONFIG := "addons/godot_ai/plugin.cfg" +const SCHEMA_VERSION := 1 +const MAX_FILES := 4096 +const MAX_FILE_SIZE := 64 * 1024 * 1024 +const MAX_ARCHIVE_SIZE := 64 * 1024 * 1024 +const MAX_TREE_SIZE := MAX_ARCHIVE_SIZE +const MAX_MANIFEST_SIZE := 1024 * 1024 +## Signature length for the production RSA-4096 key. The check itself is +## derived from the supplied key's modulus so a smaller injected test key +## still verifies its own (shorter) signatures. +const SIGNATURE_SIZE := 512 +const MIN_RSA_MODULUS_BYTES := 256 +## Largest integer JSON can round-trip through Godot's double-based parser. +const MAX_SAFE_INTEGER := 9007199254740992 +const READ_CHUNK := 1024 * 1024 + +const ROOT_KEYS := [ + "asset", "channel", "inventory", "repository", "schema_version", "source_commit", "tag", "version", +] +const ASSET_KEYS := ["name", "sha256", "size"] +const ROW_KEYS := ["path", "sha256", "size"] +const RESERVED_NAMES := [ + "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "COM¹", "COM²", "COM³", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + "LPT¹", "LPT²", "LPT³", +] +const _UNSAFE_PATH_CHARS := "<>:\"|?*" +## DER-encoded AlgorithmIdentifier for rsaEncryption with NULL parameters. +const _RSA_ALGORITHM := [0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00] + + +# ----- public API ------------------------------------------------------- + + +## Authenticate raw manifest bytes against a detached signature and the +## caller's explicit expectations. `expected` carries `repository` and +## `channel`, optionally `version` (must equal the manifest's), and optionally +## `current_version` (the manifest must be strictly newer). Returns +## `{"ok": bool, "error": String, "manifest": Dictionary}`; the returned +## manifest has integer sizes normalised to `int`. +static func verify_manifest( + manifest_bytes: PackedByteArray, + signature: PackedByteArray, + expected: Dictionary, + public_key_pem: String, +) -> Dictionary: + var result := {"ok": false, "error": "", "manifest": {}} + if manifest_bytes.is_empty() or manifest_bytes.size() > MAX_MANIFEST_SIZE: + result.error = "manifest: expected 1..%d bytes, got %d" % [MAX_MANIFEST_SIZE, manifest_bytes.size()] + return result + if not is_valid_utf8(manifest_bytes): + result.error = "manifest: not valid UTF-8" + return result + var json := JSON.new() + var parse_error := json.parse(manifest_bytes.get_string_from_utf8()) + if parse_error != OK: + result.error = "manifest: invalid JSON: %s (line %d)" % [json.get_error_message(), json.get_error_line()] + return result + var canonical := canonical_json(json.data) + if not bool(canonical.ok): + result.error = "manifest: encoding is not canonical JSON: %s" % str(canonical.error) + return result + if canonical.bytes != manifest_bytes: + result.error = "manifest: encoding is not canonical JSON" + return result + if not json.data is Dictionary: + result.error = "manifest: expected a JSON object" + return result + var validated := validate_manifest_dict(json.data) + if not bool(validated.ok): + result.error = str(validated.error) + return result + var manifest: Dictionary = validated.manifest + var identity_error := _check_identity(manifest, expected) + if not identity_error.is_empty(): + result.error = identity_error + return result + var signature_error := verify_signature(manifest_bytes, signature, public_key_pem) + if not signature_error.is_empty(): + result.error = signature_error + return result + result.ok = true + result.manifest = manifest + return result + + +## Check a zip on disk against an already-verified manifest: whole-file size +## and SHA-256 against `manifest.asset`, then the entry list against the +## inventory (exact names, exact order, no directory entries), then every +## entry's size and SHA-256 against its row. Returns `{"ok", "error"}`. +static func verify_archive(zip_path: String, manifest: Dictionary) -> Dictionary: + var result := {"ok": false, "error": ""} + var validated := validate_manifest_dict(manifest) + if not bool(validated.ok): + result.error = str(validated.error) + return result + var normalised: Dictionary = validated.manifest + var asset: Dictionary = normalised["asset"] + var hashed := sha256_file(zip_path) + if not bool(hashed.ok): + result.error = "archive: %s" % str(hashed.error) + return result + if int(hashed["size"]) != int(asset["size"]): + result.error = "archive: size %d differs from signed manifest size %d" % [int(hashed["size"]), int(asset["size"])] + return result + if str(hashed.sha256) != str(asset["sha256"]): + result.error = "archive: SHA-256 differs from signed manifest" + return result + var reader := ZIPReader.new() + var open_error := reader.open(_absolute(zip_path)) + if open_error != OK: + result.error = "archive: cannot open zip: %s" % error_string(open_error) + return result + var entries := reader.get_files() + var rows: Array = normalised["inventory"] + var expected_paths: Array[String] = [] + var expected_rows := {} + for row in rows: + expected_paths.append(str(row["path"])) + expected_rows[str(row["path"])] = row + var listing_error := _check_entry_names(entries, expected_paths, expected_rows) + if not listing_error.is_empty(): + reader.close() + result.error = listing_error + return result + var total := 0 + var config := PackedByteArray() + for entry in entries: + var row: Dictionary = expected_rows[entry] + var data := reader.read_file(entry) + if data.size() != int(row["size"]): + reader.close() + result.error = "archive entry %s: size %d differs from inventory size %d" % [entry, data.size(), int(row["size"])] + return result + if sha256_bytes(data) != str(row["sha256"]): + reader.close() + result.error = "archive entry %s: content differs from inventory" % entry + return result + total += data.size() + if total > MAX_TREE_SIZE: + reader.close() + result.error = "archive: expanded tree exceeds %d-byte bound" % MAX_TREE_SIZE + return result + if entry == PLUGIN_CONFIG: + config = data + reader.close() + var config_version := plugin_cfg_version(config) + if config_version != str(normalised["version"]): + result.error = "archive: plugin.cfg version %s differs from signed manifest version %s" % [ + config_version if not config_version.is_empty() else "(unreadable)", str(normalised["version"]), + ] + return result + result.ok = true + return result + + +## Hash every regular file under `root` recursively. Returns +## `{"ok", "error", "files": {relative_path: {"size", "sha256"}}, "tree_sha256"}`. +## +## Tree-hash algorithm (shared verbatim with Python `release_verify.hash_tree`): +## relative POSIX paths sorted by UTF-8 byte order; for each file one line +## `sha256 + " " + str(size) + " " + path + "\n"`; `tree_sha256` is the hex +## SHA-256 of those lines concatenated. Symlinks anywhere in the tree fail. +static func hash_tree(root: String) -> Dictionary: + var result := {"ok": false, "error": "", "files": {}, "tree_sha256": ""} + var root_abs := _absolute(root) + if not DirAccess.dir_exists_absolute(root_abs): + result.error = "tree: directory does not exist: %s" % root_abs + return result + var files := {} + var pending: Array[String] = [""] + var total := 0 + while not pending.is_empty(): + var relative: String = pending.pop_back() + var directory_abs := root_abs if relative.is_empty() else root_abs.path_join(relative) + var directory := DirAccess.open(directory_abs) + if directory == null: + result.error = "tree: cannot open %s: %s" % [directory_abs, error_string(DirAccess.get_open_error())] + return result + directory.include_hidden = true + directory.include_navigational = false + for name in directory.get_directories(): + var child_abs := directory_abs.path_join(name) + if directory.is_link(child_abs): + result.error = "tree: link is not allowed: %s" % child_abs + return result + pending.append(name if relative.is_empty() else relative + "/" + name) + for name in directory.get_files(): + var child_abs := directory_abs.path_join(name) + if directory.is_link(child_abs): + result.error = "tree: link is not allowed: %s" % child_abs + return result + var hashed := sha256_file(child_abs) + if not bool(hashed.ok): + result.error = "tree: %s" % str(hashed.error) + return result + total += int(hashed["size"]) + if files.size() >= MAX_FILES or total > MAX_TREE_SIZE: + result.error = "tree: exceeds %d files or %d bytes" % [MAX_FILES, MAX_TREE_SIZE] + return result + var relative_path := name if relative.is_empty() else relative + "/" + name + files[relative_path] = {"size": int(hashed["size"]), "sha256": str(hashed.sha256)} + var sorted_paths: Array[String] = [] + for path in files: + sorted_paths.append(str(path)) + sorted_paths.sort() + var ordered := {} + for path in sorted_paths: + ordered[path] = files[path] + result.files = ordered + result.tree_sha256 = tree_hash_from_files(ordered) + result.ok = true + return result + + +## The tree hash a correct extraction of `manifest.inventory` must produce: +## the same line algorithm as `hash_tree` over the inventory rows with the +## `addons/godot_ai/` prefix stripped. Only `inventory` is read, and row +## order does not matter. Returns "" for a malformed inventory or a row +## outside `addons/godot_ai/`, which can never equal a real hash. +static func inventory_tree_hash(manifest: Dictionary) -> String: + if not manifest.has("inventory") or not manifest["inventory"] is Array: + return "" + var files := {} + for value in manifest["inventory"]: + if not value is Dictionary: + return "" + var row: Dictionary = value + if not _check_keys(row, ROW_KEYS, "row").is_empty(): + return "" + var size := _integer(row["size"], "row.size", MAX_FILE_SIZE) + if not row["path"] is String or not bool(size.ok) or not row["sha256"] is String: + return "" + var path := str(row["path"]) + var digest := str(row["sha256"]) + if not path.begins_with(PLUGIN_PREFIX) or path.length() == PLUGIN_PREFIX.length() or not _is_hex(digest, 64): + return "" + var relative := path.substr(PLUGIN_PREFIX.length()) + if files.has(relative): + return "" + files[relative] = {"size": int(size.value), "sha256": digest} + if files.is_empty(): + return "" + return tree_hash_from_files(files) + + +## Apply the tree-hash line algorithm to a `{relative_path: {size, sha256}}` +## dictionary (the `files` shape `hash_tree` returns). +static func tree_hash_from_files(files: Dictionary) -> String: + var paths: Array[String] = [] + for path in files: + paths.append(str(path)) + paths.sort() + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + for path in paths: + var entry: Dictionary = files[path] + var line := "%s %d %s\n" % [str(entry["sha256"]), int(entry["size"]), path] + context.update(line.to_utf8_buffer()) + return context.finish().hex_encode() + + +## Hex SHA-256 of a byte buffer. +static func sha256_bytes(bytes: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + if not bytes.is_empty(): + context.update(bytes) + return context.finish().hex_encode() + + +## Chunked SHA-256 of a file. Returns `{"ok", "error", "size", "sha256"}`; +## files beyond MAX_FILE_SIZE fail without being read to the end. +static func sha256_file(path: String) -> Dictionary: + var result := {"ok": false, "error": "", "size": 0, "sha256": ""} + var absolute := _absolute(path) + var file := FileAccess.open(absolute, FileAccess.READ) + if file == null: + result.error = "cannot read %s: %s" % [absolute, error_string(FileAccess.get_open_error())] + return result + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + var size := 0 + while true: + var chunk := file.get_buffer(READ_CHUNK) + if chunk.is_empty(): + break + size += chunk.size() + if size > MAX_FILE_SIZE: + file.close() + result.error = "%s exceeds %d-byte bound" % [absolute, MAX_FILE_SIZE] + return result + context.update(chunk) + var read_error := file.get_error() + file.close() + if read_error != OK and read_error != ERR_FILE_EOF: + result.error = "cannot read %s: %s" % [absolute, error_string(read_error)] + return result + result.size = size + result.sha256 = context.finish().hex_encode() + result.ok = true + return result + + +## Serialise `value` exactly as Python's +## `json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"` +## encodes it: sorted keys, no whitespace, integers (including integral +## floats, which is what Godot's JSON parser yields for them) as integers, +## non-ASCII unescaped. Returns `{"ok", "error", "bytes"}`. Non-integral +## numbers fail: the manifest schema has none and Godot's float formatting +## is not guaranteed to match Python's. +static func canonical_json(value: Variant) -> Dictionary: + var result := {"ok": false, "error": "", "bytes": PackedByteArray()} + var text := _canonical_value(value, result) + if not str(result.error).is_empty(): + return result + result.bytes = (text + "\n").to_utf8_buffer() + result.ok = true + return result + + +## Strict UTF-8 validation: rejects overlong forms, surrogates, and code +## points beyond U+10FFFF, the same input Python's `bytes.decode` rejects. +static func is_valid_utf8(bytes: PackedByteArray) -> bool: + var index := 0 + var size := bytes.size() + while index < size: + var lead := bytes[index] + if lead < 0x80: + index += 1 + continue + var need := 0 + var code := 0 + if lead >= 0xC2 and lead <= 0xDF: + need = 1 + code = lead & 0x1F + elif lead >= 0xE0 and lead <= 0xEF: + need = 2 + code = lead & 0x0F + elif lead >= 0xF0 and lead <= 0xF4: + need = 3 + code = lead & 0x07 + else: + return false + if index + need >= size: + return false + for offset in range(1, need + 1): + var continuation := bytes[index + offset] + if (continuation & 0xC0) != 0x80: + return false + code = (code << 6) | (continuation & 0x3F) + if need == 2 and code < 0x800: + return false + if need == 3 and (code < 0x10000 or code > 0x10FFFF): + return false + if code >= 0xD800 and code <= 0xDFFF: + return false + index += need + 1 + return true + + +## Validate a parsed manifest's schema, bounds, and inventory path safety +## without checking identity or the signature. Returns +## `{"ok", "error", "manifest"}` where `manifest` is a normalised copy with +## integer fields as `int`. +static func validate_manifest_dict(root: Dictionary) -> Dictionary: + var result := {"ok": false, "error": "", "manifest": {}} + var root_error := _check_keys(root, ROOT_KEYS, "manifest") + if not root_error.is_empty(): + result.error = root_error + return result + var schema := _integer(root["schema_version"], "manifest.schema_version", SCHEMA_VERSION) + if not bool(schema.ok): + result.error = str(schema.error) + return result + if int(schema.value) != SCHEMA_VERSION: + result.error = "manifest.schema_version: expected %d" % SCHEMA_VERSION + return result + var manifest := {"schema_version": SCHEMA_VERSION} + for key in ["repository", "channel", "tag", "version", "source_commit"]: + var text := _string(root[key], "manifest.%s" % key) + if not bool(text.ok): + result.error = str(text.error) + return result + manifest[key] = str(text.value) + if not root["asset"] is Dictionary: + result.error = "manifest.asset: expected object" + return result + var asset: Dictionary = root["asset"] + var asset_error := _check_keys(asset, ASSET_KEYS, "manifest.asset") + if not asset_error.is_empty(): + result.error = asset_error + return result + if not asset["name"] is String or str(asset["name"]) != ASSET_NAME: + result.error = "manifest.asset.name: expected %s" % ASSET_NAME + return result + var asset_size := _integer(asset["size"], "manifest.asset.size", MAX_ARCHIVE_SIZE) + if not bool(asset_size.ok): + result.error = str(asset_size.error) + return result + if not asset["sha256"] is String or not _is_hex(str(asset["sha256"]), 64): + result.error = "manifest.asset.sha256: expected 64 lowercase hex characters" + return result + manifest["asset"] = {"name": ASSET_NAME, "size": int(asset_size.value), "sha256": str(asset["sha256"])} + if not root["inventory"] is Array: + result.error = "manifest.inventory: expected array" + return result + var rows: Array = root["inventory"] + var inventory: Array = [] + var paths: Array[String] = [] + var total := 0 + for index in rows.size(): + var context := "manifest.inventory[%d]" % index + if not rows[index] is Dictionary: + result.error = "%s: expected object" % context + return result + var row: Dictionary = rows[index] + var row_error := _check_keys(row, ROW_KEYS, context) + if not row_error.is_empty(): + result.error = row_error + return result + var path := _string(row["path"], context + ".path") + if not bool(path.ok): + result.error = str(path.error) + return result + var size := _integer(row["size"], context + ".size", MAX_FILE_SIZE) + if not bool(size.ok): + result.error = str(size.error) + return result + if not row["sha256"] is String or not _is_hex(str(row["sha256"]), 64): + result.error = "%s.sha256: expected 64 lowercase hex characters" % context + return result + total += int(size.value) + paths.append(str(path.value)) + inventory.append({"path": str(path.value), "size": int(size.value), "sha256": str(row["sha256"])}) + var paths_error := _validate_paths(paths, "manifest.inventory") + if not paths_error.is_empty(): + result.error = paths_error + return result + if total > MAX_TREE_SIZE: + result.error = "manifest.inventory: expanded tree exceeds %d-byte bound" % MAX_TREE_SIZE + return result + if not paths.has(PLUGIN_CONFIG): + result.error = "manifest.inventory: missing %s" % PLUGIN_CONFIG + return result + manifest["inventory"] = inventory + result.manifest = manifest + result.ok = true + return result + + +## Verify a PKCS#1 v1.5 SHA-256 signature over `message` with a PEM +## SubjectPublicKeyInfo. Returns "" on success, otherwise the reason. +static func verify_signature(message: PackedByteArray, signature: PackedByteArray, public_key_pem: String) -> String: + var modulus_size := rsa_modulus_size(public_key_pem) + if modulus_size < MIN_RSA_MODULUS_BYTES: + return "public key: expected a PEM RSA SubjectPublicKeyInfo of at least %d bits" % (MIN_RSA_MODULUS_BYTES * 8) + if signature.size() != modulus_size: + return "manifest signature: expected exactly %d bytes, got %d" % [modulus_size, signature.size()] + var key := CryptoKey.new() + if key.load_from_string(public_key_pem, true) != OK: + return "public key: could not be loaded" + var crypto := Crypto.new() + var digest := PackedByteArray() + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(message) + digest = context.finish() + if not crypto.verify(HashingContext.HASH_SHA256, digest, signature, key): + return "manifest signature: RSA PKCS#1 v1.5 SHA-256 verification failed" + return "" + + +## Byte length of the RSA modulus inside a PEM SubjectPublicKeyInfo, or 0 +## when the PEM is not a well-formed rsaEncryption public key. +static func rsa_modulus_size(public_key_pem: String) -> int: + var lines := public_key_pem.replace("\r\n", "\n").strip_edges().split("\n") + if ( + lines.size() < 3 + or lines[0].strip_edges() != "-----BEGIN PUBLIC KEY-----" + or lines[lines.size() - 1].strip_edges() != "-----END PUBLIC KEY-----" + ): + return 0 + var encoded := "" + for index in range(1, lines.size() - 1): + encoded += lines[index].strip_edges() + if encoded.is_empty() or not _is_base64(encoded): + return 0 + var der := Marshalls.base64_to_raw(encoded) + if der.is_empty(): + return 0 + var outer := _der_item(der, 0, 0x30) + if not bool(outer.ok) or int(outer.end) != der.size(): + return 0 + var body: PackedByteArray = outer.value + var algorithm := _der_item(body, 0, 0x30) + if not bool(algorithm.ok) or algorithm.value != PackedByteArray(_RSA_ALGORITHM): + return 0 + var bits := _der_item(body, int(algorithm.end), 0x03) + if not bool(bits.ok) or int(bits.end) != body.size(): + return 0 + var bit_string: PackedByteArray = bits.value + if bit_string.is_empty() or bit_string[0] != 0: + return 0 + var key := _der_item(bit_string, 1, 0x30) + if not bool(key.ok) or int(key.end) != bit_string.size(): + return 0 + var key_body: PackedByteArray = key.value + var modulus := _der_item(key_body, 0, 0x02) + if not bool(modulus.ok): + return 0 + var raw: PackedByteArray = modulus.value + if raw.is_empty() or raw[0] & 0x80: + return 0 + if raw.size() > 1 and raw[0] == 0: + if not (raw[1] & 0x80): + return 0 + raw = raw.slice(1) + var exponent := _der_item(key_body, int(modulus.end), 0x02) + if not bool(exponent.ok) or int(exponent.end) != key_body.size() or exponent.value.is_empty(): + return 0 + return raw.size() + + +## The single quoted `version="..."` line of a plugin.cfg, or "" when the +## file does not carry exactly one. +static func plugin_cfg_version(data: PackedByteArray) -> String: + if not is_valid_utf8(data): + return "" + var expression := RegEx.new() + if expression.compile("(?m)^\\s*version\\s*=\\s*\"([^\"]+)\"\\s*$") != OK: + return "" + var matches := expression.search_all(data.get_string_from_utf8()) + if matches.size() != 1: + return "" + return matches[0].get_string(1) + + +## Parse a dotted `major.minor.patch` version into three ints, or [] when +## it is not exactly three unsigned decimal components. +static func version_parts(version: String) -> Array[int]: + var parts := version.split(".") + if parts.size() != 3: + return [] + var numbers: Array[int] = [] + for part in parts: + if not _all_digits(part): + return [] + numbers.append(int(part)) + return numbers + + +## True when `candidate` is strictly newer than `current` by tuple order. +static func is_newer_version(candidate: String, current: String) -> bool: + var left := version_parts(candidate) + var right := version_parts(current) + if left.is_empty() or right.is_empty(): + return false + for index in 3: + if left[index] != right[index]: + return left[index] > right[index] + return false + + +# ----- identity --------------------------------------------------------- + + +static func _check_identity(manifest: Dictionary, expected: Dictionary) -> String: + var repository := str(manifest["repository"]) + if repository != REPOSITORY or repository != str(expected.get("repository", REPOSITORY)): + return "manifest.repository: expected %s" % REPOSITORY + var channel := str(manifest["channel"]) + if channel != CHANNEL or channel != str(expected.get("channel", CHANNEL)): + return "manifest.channel: v4 releases are stable-only" + var version := str(manifest["version"]) + var parts := version_parts(version) + if parts.is_empty() or parts[0] != 4: + return "manifest.version: expected a 4.x.y version" + if str(manifest["tag"]) != "v" + version: + return "manifest.tag: expected v%s" % version + if not _is_hex(str(manifest["source_commit"]), 40): + return "manifest.source_commit: expected 40 lowercase hex characters" + var expected_version := str(expected.get("version", "")) + if not expected_version.is_empty() and expected_version != version: + return "manifest.version: %s does not match the expected release %s" % [version, expected_version] + var expected_tag := str(expected.get("tag", "")) + if not expected_tag.is_empty() and expected_tag != str(manifest["tag"]): + return "manifest.tag: %s does not match the expected tag %s" % [str(manifest["tag"]), expected_tag] + var expected_commit := str(expected.get("source_commit", "")) + if not expected_commit.is_empty() and expected_commit != str(manifest["source_commit"]): + return "manifest.source_commit: does not match the expected commit" + var current_version := str(expected.get("current_version", "")) + if not current_version.is_empty(): + if version_parts(current_version).is_empty(): + return "current_version: %s is not a dotted major.minor.patch version" % current_version + if not is_newer_version(version, current_version): + return "manifest.version: %s is not newer than the installed %s" % [version, current_version] + return "" + + +# ----- archive listing -------------------------------------------------- + + +static func _check_entry_names( + entries: PackedStringArray, expected_paths: Array[String], expected_rows: Dictionary +) -> String: + var seen := {} + for entry in entries: + if entry.ends_with("/"): + return "archive: directory entry is not allowed: %s" % entry + if not expected_rows.has(entry): + return "archive: entry is not in the signed inventory: %s" % entry + if seen.has(entry): + return "archive: duplicate entry: %s" % entry + seen[entry] = true + for path in expected_paths: + if not seen.has(path): + return "archive: inventory file is missing from the zip: %s" % path + if Array(entries) != Array(expected_paths): + return "archive: entries are not in the sorted inventory order" + return "" + + +# ----- path safety ------------------------------------------------------ + + +static func _validate_paths(paths: Array[String], context: String) -> String: + if paths.is_empty() or paths.size() > MAX_FILES: + return "%s: expected 1..%d paths" % [context, MAX_FILES] + var sorted := paths.duplicate() + sorted.sort() + var exact := {} + var identities := {} + for index in paths.size(): + var path := paths[index] + var path_error := _check_path(path, "%s[%d]" % [context, index]) + if not path_error.is_empty(): + return path_error + if exact.has(path): + return "%s: duplicate path %s" % [context, path] + exact[path] = true + var identity := path.to_lower() + if identities.has(identity): + return "%s: case-colliding paths %s and %s" % [context, str(identities[identity]), path] + identities[identity] = path + if paths != sorted: + return "%s: paths are not in bytewise order" % context + for path in paths: + var parent := path.get_base_dir() + while parent.contains("/"): + if exact.has(parent): + return "%s: file/ancestor collision at %s" % [context, parent] + parent = parent.get_base_dir() + return "" + + +static func _check_path(path: String, context: String) -> String: + if not path.begins_with(PLUGIN_PREFIX) or path.ends_with("/") or path.contains("\\"): + return "%s: path must be a file beneath %s: %s" % [context, PLUGIN_PREFIX, path] + for part in path.split("/"): + if part.is_empty() or part == "." or part == ".." or part.ends_with(" ") or part.ends_with("."): + return "%s: unsafe path component in %s" % [context, path] + for index in part.length(): + var code := part.unicode_at(index) + if code < 32 or _UNSAFE_PATH_CHARS.contains(char(code)): + return "%s: unsafe path character in %s" % [context, path] + var stem := part.split(".", true, 1)[0] + if RESERVED_NAMES.has(stem.to_upper()): + return "%s: reserved path component in %s" % [context, path] + return "" + + +# ----- schema helpers --------------------------------------------------- + + +static func _check_keys(value: Dictionary, expected_keys: Array, context: String) -> String: + var keys := value.keys() + for key in keys: + if not key is String: + return "%s: expected object with exactly keys %s" % [context, str(expected_keys)] + keys.sort() + if keys != expected_keys: + return "%s: expected object with exactly keys %s" % [context, str(expected_keys)] + return "" + + +static func _string(value: Variant, context: String) -> Dictionary: + if not value is String or str(value).is_empty(): + return {"ok": false, "error": "%s: expected non-empty string" % context, "value": ""} + return {"ok": true, "error": "", "value": str(value)} + + +## Accept `int`, or the integral `float` Godot's JSON parser yields, within +## `[0, maximum]`. Booleans are rejected. +static func _integer(value: Variant, context: String, maximum: int) -> Dictionary: + var failure := {"ok": false, "error": "%s: expected integer in [0, %d]" % [context, maximum], "value": 0} + var number := 0 + if value is int: + number = value + elif value is float: + var real: float = value + if real != floor(real) or absf(real) > MAX_SAFE_INTEGER: + return failure + number = int(real) + else: + return failure + if number < 0 or number > maximum: + return failure + return {"ok": true, "error": "", "value": number} + + +static func _is_hex(text: String, length: int) -> bool: + if text.length() != length: + return false + for index in text.length(): + var code := text.unicode_at(index) + if not ((code >= 48 and code <= 57) or (code >= 97 and code <= 102)): + return false + return true + + +static func _all_digits(text: String) -> bool: + if text.is_empty(): + return false + for index in text.length(): + var code := text.unicode_at(index) + if code < 48 or code > 57: + return false + return true + + +static func _is_base64(text: String) -> bool: + for index in text.length(): + var code := text.unicode_at(index) + var ok := ( + (code >= 65 and code <= 90) + or (code >= 97 and code <= 122) + or (code >= 48 and code <= 57) + or code == 43 or code == 47 or code == 61 + ) + if not ok: + return false + return true + + +# ----- canonical JSON --------------------------------------------------- + + +static func _canonical_value(value: Variant, result: Dictionary) -> String: + match typeof(value): + TYPE_NIL: + return "null" + TYPE_BOOL: + return "true" if value else "false" + TYPE_INT: + return String.num_int64(value) + TYPE_FLOAT: + var real: float = value + if real != floor(real) or absf(real) > MAX_SAFE_INTEGER: + result.error = "non-integer number %s" % str(real) + return "" + return String.num_int64(int(real)) + TYPE_STRING, TYPE_STRING_NAME: + return _escape_json_string(str(value)) + TYPE_ARRAY: + var items: Array[String] = [] + for item in value: + items.append(_canonical_value(item, result)) + if not str(result.error).is_empty(): + return "" + return "[" + ",".join(items) + "]" + TYPE_DICTIONARY: + var dictionary: Dictionary = value + var keys: Array = dictionary.keys() + for key in keys: + if not key is String: + result.error = "non-string object key" + return "" + keys.sort() + var members: Array[String] = [] + for key in keys: + var member := _canonical_value(dictionary[key], result) + if not str(result.error).is_empty(): + return "" + members.append(_escape_json_string(str(key)) + ":" + member) + return "{" + ",".join(members) + "}" + _: + result.error = "unsupported JSON value type %s" % type_string(typeof(value)) + return "" + + +static func _escape_json_string(text: String) -> String: + var out := "\"" + for index in text.length(): + var code := text.unicode_at(index) + match code: + 0x22: + out += "\\\"" + 0x5C: + out += "\\\\" + 0x0A: + out += "\\n" + 0x0D: + out += "\\r" + 0x09: + out += "\\t" + 0x08: + out += "\\b" + 0x0C: + out += "\\f" + _: + if code < 0x20: + out += "\\u%04x" % code + else: + out += char(code) + return out + "\"" + + +# ----- DER -------------------------------------------------------------- + + +static func _der_item(data: PackedByteArray, offset: int, tag: int) -> Dictionary: + var failure := {"ok": false, "value": PackedByteArray(), "end": 0} + var size := data.size() + if offset >= size or data[offset] != tag: + return failure + offset += 1 + if offset >= size: + return failure + var first := data[offset] + offset += 1 + var length := 0 + if first < 0x80: + length = first + else: + var count := first & 0x7F + if count < 1 or count > 4 or offset + count > size or data[offset] == 0: + return failure + for index in count: + length = (length << 8) | data[offset + index] + offset += count + if length < 0x80: + return failure + var end := offset + length + if end > size: + return failure + return {"ok": true, "value": data.slice(offset, end), "end": end} + + +# ----- paths ------------------------------------------------------------ + + +static func _absolute(path: String) -> String: + if path.begins_with("res://") or path.begins_with("user://"): + return ProjectSettings.globalize_path(path) + return path diff --git a/addons/godot_ai/utils/release_verifier.gd.uid b/addons/godot_ai/utils/release_verifier.gd.uid new file mode 100644 index 0000000..34abbfe --- /dev/null +++ b/addons/godot_ai/utils/release_verifier.gd.uid @@ -0,0 +1 @@ +uid://cvjv1388502ne diff --git a/addons/godot_ai/utils/update_activation_runner.gd b/addons/godot_ai/utils/update_activation_runner.gd new file mode 100644 index 0000000..2f302a2 --- /dev/null +++ b/addons/godot_ai/utils/update_activation_runner.gd @@ -0,0 +1,391 @@ +@tool +extends Node + +## Compile this source into a GDScript with no resource_path before creating +## the node. Its named callbacks must survive replacement of the file itself. +## The caller verifies/stages/quiesces and owns the update lock. No plugin, +## dock, handler, or suspended caller frame crosses the deferred handoff. +const LIVE_ROOT := "res://addons/godot_ai" +const PLUGIN_CFG := LIVE_ROOT + "/plugin.cfg" +const STAGE_ROOT := "res://addons/.godot_ai_update/stage/addons/godot_ai" +const INSTALLER_PATH := LIVE_ROOT + "/utils/update_installer.gd" +const VERIFIER_PATH := LIVE_ROOT + "/utils/release_verifier.gd" +const RECEIPT_PATH := "res://addons/.godot_ai_update/activation.json" +const SCAN_TIMEOUT_MS := 60000 + +enum Phase { IDLE, DRAIN, REQUEST_OLD_SCAN, WAIT_OLD_SCAN, SWAPPING, REQUEST_NEW_SCAN, WAIT_NEW_SCAN, ENABLING, DONE } + +var _phase := Phase.IDLE +var _package: Dictionary = {} +var _installer: Script +var _verifier: Script +var _frames := 0 +var _deadline := 0 +var _filesystem: EditorFileSystem +var _failure := "" +var _outcome := "" +var _backup_root := "" +var _retired_scripts: Array[Script] = [] +var _inspected_objects := {} +var _inspection_budget := 100000 +## Copied by a caller when start() refuses, before the runner is freed. +var refusal_reason := "The independent activation handoff was refused." + + +func start(package: Dictionary) -> bool: + if _phase != Phase.IDLE: + refusal_reason = "The activation runner has already started." + return false + if not is_inside_tree(): + refusal_reason = "The activation runner must belong to the editor scene tree." + return false + if not str(get_script().resource_path).is_empty(): + refusal_reason = "The activation runner must be source-free before replacing its files." + return false # A file-backed runner can be replaced by the scan it starts. + if package.get("stage_root") != STAGE_ROOT: + refusal_reason = "The update stage root does not match the verified activation location." + return false + if not package.get("record") is Dictionary: + refusal_reason = "The update handoff requires an installer record Dictionary." + return false + var record: Dictionary = package.record + for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce"]: + if not record.get(key) is String or str(record[key]).is_empty(): + refusal_reason = "The update record requires a nonempty String field: %s." % key + return false + if not record.get("replace_owned_mismatches") is bool: + refusal_reason = "The update record requires a boolean replace_owned_mismatches field." + return false + # Copy only values that belong to the existing installer's swap record. + _package = {"stage_root": STAGE_ROOT, "record": {}} + for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce", "replace_owned_mismatches"]: + _package.record[key] = record[key] + _installer = ResourceLoader.load(INSTALLER_PATH, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as Script + _verifier = ResourceLoader.load(VERIFIER_PATH, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as Script + if _installer == null or _verifier == null: + refusal_reason = "The update installer or release verifier script could not be loaded." + return false + for method in ["swap", "verify_after_restart", "release_lock", "read_lock", "discard_stage"]: + if not _declares(_installer, method): + refusal_reason = "The update installer is missing its required %s method." % method + return false + if not _declares(_verifier, "hash_tree"): + refusal_reason = "The release verifier is missing its required hash_tree method." + return false + var lock: Dictionary = _installer.call("read_lock") + if int(lock.get("pid", 0)) != OS.get_process_id() or str(lock.get("fingerprint", "")).is_empty(): + refusal_reason = "The update lock does not identify this editor with a process fingerprint." + return false + _inspected_objects.clear() + _inspection_budget = 100000 + if not _user_resources_can_keep_paths(): + return false + refusal_reason = "" + process_mode = Node.PROCESS_MODE_ALWAYS + _phase = Phase.DRAIN + set_process(false) + _begin.call_deferred() + return true + + +func _declares(script: Script, method: String) -> bool: + for entry in script.get_script_method_list(): + if str(entry.get("name", "")) == method: + return true + return false + + +func _begin() -> void: + _filesystem = EditorInterface.get_resource_filesystem() + if _filesystem == null: + _stop("The editor filesystem is unavailable; no update swap was attempted.") + return + _filesystem.sources_changed.connect(_scan_completed) + EditorInterface.set_plugin_enabled(PLUGIN_CFG, false) + if EditorInterface.is_plugin_enabled(PLUGIN_CFG): + _stop("Godot could not disable the plugin; the verified stage was retained.") + return + _frames = 2 + set_process(true) + + +func _process(_delta: float) -> void: + if _phase == Phase.DRAIN: + _frames -= 1 + if _frames <= 0: + _phase = Phase.REQUEST_OLD_SCAN + _deadline = Time.get_ticks_msec() + SCAN_TIMEOUT_MS + elif _phase in [Phase.REQUEST_OLD_SCAN, Phase.WAIT_OLD_SCAN, Phase.REQUEST_NEW_SCAN, Phase.WAIT_NEW_SCAN]: + if Time.get_ticks_msec() >= _deadline: + _stop("The filesystem scan did not complete. The plugin remains disabled; the staged update and any backup were retained.") + elif _phase in [Phase.REQUEST_OLD_SCAN, Phase.REQUEST_NEW_SCAN] and not _filesystem.is_scanning(): + _phase = Phase.WAIT_OLD_SCAN if _phase == Phase.REQUEST_OLD_SCAN else Phase.WAIT_NEW_SCAN + # scan() may decline while a finished worker still awaits GUI + # completion. Its sources_changed signal remains a valid barrier. + _filesystem.scan() + + +func _swap() -> void: + set_process(false) + # A cache lookup may resolve dependencies even when the requested script + # is cached. Retain all old references while their complete tree exists; + # never load a canonical path after the swap to recover an old script. + if not _collect_cached_scripts(LIVE_ROOT): + _stop("The previous script cache could not be captured safely; no update swap was attempted.") + return + var before: Dictionary = _verifier.call("hash_tree", LIVE_ROOT) + if not bool(before.get("ok", false)): + _stop("Could not hash the previous tree before activation; no swap was attempted.") + return + var swapped: Dictionary = _installer.call("swap", STAGE_ROOT, LIVE_ROOT, _package.record) + if not bool(swapped.get("ok", false)): + _failure = "Update swap failed: %s" % str(swapped.get("error", "unknown error")) + var restored: Dictionary = _verifier.call("hash_tree", LIVE_ROOT) + if not bool(restored.get("ok", false)) or restored.get("tree_sha256") != before.get("tree_sha256"): + _stop(_failure + "; the original tree could not be proven restored. Explicit repair is required.") + return + _installer.call("discard_stage") + _outcome = "previous_tree_retained" + else: + print("MCP | update to %s swapped in; reloading the plugin in this editor" % str(_package.record.to_version)) + var verified: Dictionary = _installer.call("verify_after_restart", LIVE_ROOT) + _outcome = str(verified.get("status", "")) + if _outcome != "success" and _outcome != "rolled_back": + _stop("The installed tree could not be verified: %s" % str(verified.get("error", "unknown outcome"))) + return + if _outcome == "rolled_back": + _failure = "Update verification failed; the previous tree was restored: %s" % str(verified.get("error", "")) + else: + _backup_root = str(verified.get("backup_root", "")) + var expected_backup := "res://addons/.godot_ai_update/backup/" + str(_package.record.from_version) + if _backup_root != expected_backup: + _stop("The update marker names an unexpected backup. The plugin remains disabled.") + return + var backup: Dictionary = _verifier.call("hash_tree", _backup_root) + if not bool(backup.get("ok", false)) or backup.get("tree_sha256") != before.get("tree_sha256"): + _stop("The retained backup could not be proven to match the previous tree. The plugin remains disabled.") + return + if _outcome == "success" and not _retire_cached_scripts(_backup_root): + _stop("The previous script cache could not be retained safely. The plugin remains disabled.") + return + _release_scripts() + _phase = Phase.REQUEST_NEW_SCAN + _deadline = Time.get_ticks_msec() + SCAN_TIMEOUT_MS + set_process(true) + + +func _scan_completed(_sources_changed: bool) -> void: + if _phase not in [Phase.WAIT_OLD_SCAN, Phase.WAIT_NEW_SCAN]: + return + if _filesystem.is_scanning(): + return # Another completion listener already started a newer scan. + if Time.get_ticks_msec() >= _deadline: + _stop("The filesystem scan completion arrived after the activation deadline. The plugin remains disabled.") + return + set_process(false) + # sources_changed follows scan actions, documentation, and GUI updates. + # filesystem_changed can occur earlier; a deferred completion can become + # stale during a newer GUI scan. Consume this boundary synchronously. + if _phase == Phase.WAIT_OLD_SCAN: + _phase = Phase.SWAPPING + _swap() + else: + _phase = Phase.ENABLING + _filesystem.sources_changed.disconnect(_scan_completed) + _enable() + + +func _enable() -> void: + if not _failure.is_empty() and str(_package.record.from_version).get_slice(".", 0).to_int() < 4: + # A restored capsule must show Retry instead of automatically repeating + # the failed update. This is its existing project-scoped status key. + var settings := EditorInterface.get_editor_settings() + if settings != null: + var key := "godot_ai/v4_migration_bridge_status_" + ProjectSettings.globalize_path("res://").sha256_text() + settings.set_setting(key, JSON.stringify({"error": _failure})) + EditorInterface.set_plugin_enabled(PLUGIN_CFG, true) + if not EditorInterface.is_plugin_enabled(PLUGIN_CFG): + _stop("The scanned plugin could not be enabled. The installed tree and backup were retained.") + return + if _outcome == "success": + var plugin := _find_plugin(get_tree().root) + if plugin == null or str(plugin.get("_loaded_plugin_version")) != str(_package.record.to_version): + EditorInterface.set_plugin_enabled(PLUGIN_CFG, false) + _stop("The enabled plugin did not report the installed version. The update evidence was retained.") + return + var saved := ProjectSettings.save() + if saved != OK: + _stop("The plugin is enabled, but saving its enabled state failed: %s" % error_string(saved)) + return + _phase = Phase.DONE + _record(_outcome, _failure) + if not _failure.is_empty(): + _show_failure(_failure) + return + print("MCP | update activation completed in editor PID %d" % OS.get_process_id()) + queue_free() + + +func _user_resources_can_keep_paths() -> bool: + # Retiring a Script changes resource_path for all its existing users. + # Refuse serializable scene/resource references instead of silently making + # a user's next save point into the updater's retained backup directory. + var roots: Array = [] + if EditorInterface.has_method("get_open_scene_roots"): + roots = EditorInterface.call("get_open_scene_roots") + else: + if EditorInterface.get_open_scenes().size() > 1: + refusal_reason = "This Godot version cannot inspect every open scene safely. Close the other scene tabs before retrying the update." + return false + var current := EditorInterface.get_edited_scene_root() + if current != null: + roots.append(current) + for root in roots: + if not _inspect_user_node(root): + return false + var inspected := EditorInterface.get_inspector().get_edited_object() + if inspected is Resource and not _inspect_user_value(inspected, 0): + return false + return true + + +func _inspect_user_node(node: Node) -> bool: + if not _inspect_user_value(node, 0): + return false + for child in node.get_children(): + if not _inspect_user_node(child): + return false + return true + + +func _inspect_user_value(value: Variant, depth: int) -> bool: + _inspection_budget -= 1 + if _inspection_budget < 0 or depth > 64: + refusal_reason = "Open scene or resource data exceeded the safe inspection limit. Close the affected scenes or resources before retrying the update." + return false + if value is Script: + if value.resource_path.begins_with(LIVE_ROOT + "/"): + refusal_reason = "An open scene or resource references addon script %s. In-editor activation would change its saved script path. Close the scene or resource before retrying; the update was not installed." % value.resource_path + return false + return true + if value is Resource or value is Node: + var identity: int = value.get_instance_id() + if _inspected_objects.has(identity): + return true + _inspected_objects[identity] = true + for property in value.get_property_list(): + if int(property.usage) & PROPERTY_USAGE_STORAGE: + if not _inspect_user_value(value.get(property.name), depth + 1): + return false + elif value is Array: + for entry in value: + if not _inspect_user_value(entry, depth + 1): + return false + elif value is Dictionary: + for key in value: + if not _inspect_user_value(key, depth + 1) or not _inspect_user_value(value[key], depth + 1): + return false + return true + + +func _retire_cached_scripts(backup_root: String) -> bool: + # All references were captured before replacement. Godot moves + # both its resource and GDScript cache entries through take_over_path(). + # Existing UndoRedo callbacks keep the old compiled code and typed state; + # canonical loads create a fresh graph, including fresh static variables. + for script in _retired_scripts: + var previous_path := script.resource_path + var retained_path := backup_root + previous_path.trim_prefix(LIVE_ROOT) + if not previous_path.begins_with(LIVE_ROOT + "/") or not FileAccess.file_exists(retained_path): + return false + if ResourceLoader.has_cached(retained_path): + return false # Never displace a different retained generation. + # A later update in this same editor must not prune source files still + # referenced by an earlier generation's undo callbacks. + get_tree().root.set_meta("godot_ai_retained_update_scripts", true) + for script in _retired_scripts: + var retained_path := backup_root + script.resource_path.trim_prefix(LIVE_ROOT) + script.take_over_path(retained_path) + if script.resource_path != retained_path: + return false + return true + + +func _collect_cached_scripts(folder: String) -> bool: + var directory := DirAccess.open(folder) + if directory == null: + return false + for filename in directory.get_files(): + if not filename.ends_with(".gd"): + continue + var canonical := folder.path_join(filename) + if ResourceLoader.has_cached(canonical): + var script := ResourceLoader.load(canonical) as Script + if script == null or script.resource_path != canonical: + return false + _retired_scripts.append(script) + for child in directory.get_directories(): + if not _collect_cached_scripts(folder.path_join(child)): + return false + return true + + +func _find_plugin(node: Node) -> Node: + var script := node.get_script() as Script + if script != null and script.resource_path == LIVE_ROOT + "/plugin.gd": + return node + for child in node.get_children(): + var found := _find_plugin(child) + if found != null: + return found + return null + + +func _release_scripts() -> void: + if _installer != null: + _installer.call("release_lock") + _installer = null + _verifier = null + + +func _stop(message: String) -> void: + _phase = Phase.DONE + set_process(false) + if _filesystem != null and _filesystem.sources_changed.is_connected(_scan_completed): + _filesystem.sources_changed.disconnect(_scan_completed) + _release_scripts() + _record("activation_failed", message) + _show_failure(message) + + +func _record(status: String, message: String) -> void: + var file := FileAccess.open(RECEIPT_PATH, FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify({"status": status, "error": message, + "editor_pid": OS.get_process_id(), "from_version": _package.record.from_version, + "to_version": _package.record.to_version, "unix": Time.get_unix_time_from_system()})) + else: + push_error("MCP | could not write update activation evidence: %s" % error_string(FileAccess.get_open_error())) + + +func _show_failure(message: String) -> void: + push_error("MCP | %s" % message) + if DisplayServer.get_name() == "headless": + queue_free() + return + var dialog := AcceptDialog.new() + dialog.title = "Godot AI update needs attention" + dialog.dialog_text = message + "\nUpdate evidence: res://addons/.godot_ai_update/" + add_child(dialog) + dialog.confirmed.connect(_dismiss_failure) + dialog.canceled.connect(_dismiss_failure) + dialog.popup_centered(Vector2i(620, 220)) + + +func _dismiss_failure() -> void: + ## Godot may have left its shared progress dialog under the failure window. + ## Return it before freeing our subtree and leaving the editor's pointer dangling. + if is_inside_tree(): + for dialog in find_children("*", "ProgressDialog", true, false): + dialog.reparent(get_tree().root) + queue_free() diff --git a/addons/godot_ai/utils/update_activation_runner.gd.uid b/addons/godot_ai/utils/update_activation_runner.gd.uid new file mode 100644 index 0000000..fd3597f --- /dev/null +++ b/addons/godot_ai/utils/update_activation_runner.gd.uid @@ -0,0 +1 @@ +uid://mj7jpaq6wt27 diff --git a/addons/godot_ai/utils/update_installer.gd b/addons/godot_ai/utils/update_installer.gd new file mode 100644 index 0000000..a74c893 --- /dev/null +++ b/addons/godot_ai/utils/update_installer.gd @@ -0,0 +1,548 @@ +@tool +class_name McpUpdateInstaller +extends RefCounted + +## The in-editor half of the lean self-updater: stage a verified archive +## beside the live add-on, take the project-wide update lock, swap the two +## trees with two directory renames, and after the restart verify the live +## tree against the signed inventory, rolling back to the retained backup on +## any mismatch. See docs/self-update.md. +## +## Everything lives under `res://addons/.godot_ai_update/` (dot-prefixed so +## Godot's scanner ignores it; a `.gdignore` and a `.gitignore` are written +## on first use) plus the live root the caller passes in. No function writes +## anywhere else, no failure path deletes a backup, and every function fails +## closed with a specific error string instead of raising. + +const UPDATE_ROOT := "res://addons/.godot_ai_update" +const STAGE_DIR := UPDATE_ROOT + "/stage" +const STAGE_PLUGIN_ROOT := STAGE_DIR + "/addons/godot_ai" +const BACKUP_DIR := UPDATE_ROOT + "/backup" +const QUARANTINE_DIR := UPDATE_ROOT + "/quarantine" +const PENDING_FILE := UPDATE_ROOT + "/pending.json" +const LOCK_FILE := UPDATE_ROOT + "/lock.json" +const DEFAULT_LIVE_ROOT := "res://addons/godot_ai" +const ENABLED_PLUGINS_SETTING := "editor_plugins/enabled" + +const STATUS_SWAPPED := "swapped" +const STATUS_SUCCESS := "success" +const STATUS_ROLLED_BACK := "rolled_back" +const STATUS_REPAIR_REQUIRED := "repair_required" + +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") +const ReleaseVerifier := preload("res://addons/godot_ai/utils/release_verifier.gd") + + +# ----- staging ---------------------------------------------------------- + + +## Extract `zip_path` into a fresh `stage/addons/godot_ai/` and prove the +## extracted tree hashes to exactly the signed inventory. Returns +## `{"ok", "error", "stage_root", "tree_sha256"}`; `stage_root` is the +## `res://` path of the staged plugin directory. +static func stage(zip_path: String, manifest: Dictionary) -> Dictionary: + var result := {"ok": false, "error": "", "stage_root": "", "tree_sha256": ""} + var validated := ReleaseVerifier.validate_manifest_dict(manifest) + if not bool(validated.ok): + result.error = "stage: %s" % str(validated.error) + return result + var normalised: Dictionary = validated.manifest + var rooted := ensure_root() + if not bool(rooted.ok): + result.error = "stage: %s" % str(rooted.error) + return result + var stage_abs := _absolute(STAGE_DIR) + if _exists(stage_abs): + var removed := remove_under_update_root(STAGE_DIR) + if not bool(removed.ok): + result.error = "stage: cannot clear previous stage: %s" % str(removed.error) + return result + var reader := ZIPReader.new() + var open_error := reader.open(_absolute(zip_path)) + if open_error != OK: + result.error = "stage: cannot open archive %s: %s" % [zip_path, error_string(open_error)] + return result + var rows: Array = normalised["inventory"] + var expected: Array = [] + for row in rows: + expected.append(str(row["path"])) + if Array(reader.get_files()) != expected: + reader.close() + result.error = "stage: archive entries do not match the signed inventory (run verify_archive for detail)" + return result + for row in rows: + var path := str(row["path"]) + var target := stage_abs.path_join(path) + var make_error := DirAccess.make_dir_recursive_absolute(target.get_base_dir()) + if make_error != OK: + reader.close() + result.error = "stage: cannot create %s: %s" % [target.get_base_dir(), error_string(make_error)] + return result + var write_error := _write_bytes(target, reader.read_file(path)) + if write_error != OK: + reader.close() + result.error = "stage: cannot write %s: %s" % [target, error_string(write_error)] + return result + reader.close() + var expected_hash := ReleaseVerifier.inventory_tree_hash(normalised) + var hashed := ReleaseVerifier.hash_tree(STAGE_PLUGIN_ROOT) + if not bool(hashed.ok): + remove_under_update_root(STAGE_DIR) + result.error = "stage: %s" % str(hashed.error) + return result + if expected_hash.is_empty() or str(hashed.tree_sha256) != expected_hash: + remove_under_update_root(STAGE_DIR) + result.error = "stage: staged tree hash %s differs from the signed inventory hash %s" % [ + str(hashed.tree_sha256), expected_hash, + ] + return result + result.stage_root = STAGE_PLUGIN_ROOT + result.tree_sha256 = str(hashed.tree_sha256) + result.ok = true + return result + + +## Remove `stage/` if present (for example when quiescence is refused after +## staging). Never touches backups, quarantine, or the live tree. +static func discard_stage() -> void: + if _exists(_absolute(STAGE_DIR)): + remove_under_update_root(STAGE_DIR) + + +# ----- lock ------------------------------------------------------------- + + +## Take `lock.json` for this editor. A lock whose recorded process is alive +## and carries a different fingerprint refuses; a dead or own lock is +## replaced. Returns `{"ok", "error"}`. +static func acquire_lock(pid: int, fingerprint: String) -> Dictionary: + var result := {"ok": false, "error": ""} + if pid <= 0 or fingerprint.is_empty(): + result.error = "lock: a positive pid and a non-empty fingerprint are required" + return result + var rooted := ensure_root() + if not bool(rooted.ok): + result.error = "lock: %s" % str(rooted.error) + return result + var existing := read_lock() + if not existing.is_empty(): + var other_pid := int(existing.get("pid", 0)) + var other_fingerprint := str(existing.get("fingerprint", "")) + if other_pid > 0 and other_fingerprint != fingerprint and PortResolver.pid_alive(other_pid): + result.error = "lock: update lock is held by live process %d (%s) since %d" % [ + other_pid, other_fingerprint, int(existing.get("started_unix", 0)), + ] + return result + var write_error := _write_json(LOCK_FILE, { + "pid": pid, + "fingerprint": fingerprint, + "started_unix": int(Time.get_unix_time_from_system()), + }) + if write_error != OK: + result.error = "lock: cannot write %s: %s" % [LOCK_FILE, error_string(write_error)] + return result + result.ok = true + return result + + +## Drop `lock.json` when it belongs to this process or to a dead one. A +## lock held by another live process is left in place. +static func release_lock() -> void: + var existing := read_lock() + if existing.is_empty(): + if FileAccess.file_exists(_absolute(LOCK_FILE)): + DirAccess.remove_absolute(_absolute(LOCK_FILE)) + return + var pid := int(existing.get("pid", 0)) + if pid == OS.get_process_id() or not PortResolver.pid_alive(pid): + DirAccess.remove_absolute(_absolute(LOCK_FILE)) + + +## The current lock record, or `{}` when there is none or it is unreadable. +static func read_lock() -> Dictionary: + return _read_json(LOCK_FILE) + + +# ----- swap ------------------------------------------------------------- + + +## Replace the live tree with the staged one using two directory renames: +## live -> `backup//`, then stage -> live. If the second rename +## fails the first is undone. On success `pending.json` holds every key of +## `record` verbatim plus `status: "swapped"`, `backup_root`, `live_root`, +## `expected_tree_sha256` and `swapped_unix`. `record` must carry +## `from_version`, `to_version`, `manifest_sha256`, `expected_tree_sha256` +## and `editor_nonce`. Refuses when `backup//` already exists: +## backups are pruned only after a verified success, so a leftover one means +## an unresolved earlier update. +static func swap(stage_root: String, live_root: String, record: Dictionary) -> Dictionary: + var result := {"ok": false, "error": ""} + for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce"]: + if not record.has(key) or not record[key] is String or str(record[key]).is_empty(): + result.error = "swap: record is missing %s" % key + return result + var from_version := str(record["from_version"]) + var to_version := str(record["to_version"]) + if not _is_safe_segment(from_version) or not _is_safe_segment(to_version): + result.error = "swap: versions must be plain path segments, got %s -> %s" % [from_version, to_version] + return result + if not _is_hex(str(record["expected_tree_sha256"]), 64): + result.error = "swap: expected_tree_sha256 must be 64 lowercase hex characters" + return result + var stage_abs := _absolute(stage_root) + var live_abs := _absolute(live_root) + if not DirAccess.dir_exists_absolute(stage_abs): + result.error = "swap: stage root does not exist: %s" % stage_abs + return result + if not DirAccess.dir_exists_absolute(live_abs): + result.error = "swap: live root does not exist: %s" % live_abs + return result + if stage_abs.simplify_path() == live_abs.simplify_path(): + result.error = "swap: stage and live roots are the same directory" + return result + var pending := read_pending() + if not pending.is_empty() and str(pending.get("status", "")) == STATUS_SWAPPED: + result.error = "swap: an earlier swap is still pending verification (%s)" % PENDING_FILE + return result + var rooted := ensure_root() + if not bool(rooted.ok): + result.error = "swap: %s" % str(rooted.error) + return result + var backup_root := BACKUP_DIR + "/" + from_version + var backup_abs := _absolute(backup_root) + if _exists(backup_abs): + result.error = "swap: backup already exists at %s; an earlier update was never resolved" % backup_root + return result + var make_error := DirAccess.make_dir_recursive_absolute(_absolute(BACKUP_DIR)) + if make_error != OK: + result.error = "swap: cannot create %s: %s" % [BACKUP_DIR, error_string(make_error)] + return result + var first := DirAccess.rename_absolute(live_abs, backup_abs) + if first != OK: + result.error = "swap: cannot move live tree %s to %s: %s" % [live_abs, backup_abs, error_string(first)] + return result + var second := DirAccess.rename_absolute(stage_abs, live_abs) + if second != OK: + var restore := DirAccess.rename_absolute(backup_abs, live_abs) + result.error = "swap: cannot move stage %s into %s: %s" % [stage_abs, live_abs, error_string(second)] + if restore != OK: + result.error += "; restoring the backup failed too (%s): live tree is at %s" % [ + error_string(restore), backup_abs, + ] + return result + var marker := record.duplicate(true) + marker["status"] = STATUS_SWAPPED + marker["backup_root"] = backup_root + marker["live_root"] = live_root + marker["expected_tree_sha256"] = str(record["expected_tree_sha256"]) + marker["swapped_unix"] = int(Time.get_unix_time_from_system()) + ## The editor that verifies the swap should be this same Godot. On macOS + ## the restart goes through LaunchServices, which has been seen to launch + ## another installed copy of Godot; recording the version lets the next + ## start explain that instead of silently refusing or verifying elsewhere. + marker["godot_version"] = str(Engine.get_version_info().get("string", "")) + var write_error := _write_json(PENDING_FILE, marker) + if write_error != OK: + var undo_stage := DirAccess.rename_absolute(live_abs, stage_abs) + var undo_backup := DirAccess.rename_absolute(backup_abs, live_abs) + result.error = "swap: cannot write %s: %s" % [PENDING_FILE, error_string(write_error)] + if undo_stage != OK or undo_backup != OK: + result.error += "; undoing the swap failed: new tree at %s, old tree at %s" % [ + live_abs if undo_stage != OK else stage_abs, backup_abs if undo_backup != OK else live_abs, + ] + return result + ## The staged tree now lives; only its empty scaffold is left behind. + discard_stage() + result.ok = true + return result + + +# ----- pending marker --------------------------------------------------- + + +## The `pending.json` record, `{}` when there is none. An unreadable marker +## is reported as `status: "repair_required"` rather than ignored. +static func read_pending() -> Dictionary: + var absolute := _absolute(PENDING_FILE) + if not FileAccess.file_exists(absolute): + return {} + var marker := _read_json(PENDING_FILE) + if marker.is_empty(): + return { + "status": STATUS_REPAIR_REQUIRED, + "error": "pending marker %s exists but is not a JSON object; inspect it by hand" % PENDING_FILE, + } + return marker + + +## Record in the success marker that the plugin repinned client configuration, +## so later starts do not repeat the migration. No-op unless the marker +## records success. +static func record_clients_migrated() -> Error: + var marker := read_pending() + if str(marker.get("status", "")) != STATUS_SUCCESS: + return ERR_UNAVAILABLE + marker["clients_migrated"] = true + return _write_json(PENDING_FILE, marker) + + +## Remove `pending.json`. +static func clear_pending() -> void: + var absolute := _absolute(PENDING_FILE) + if FileAccess.file_exists(absolute): + DirAccess.remove_absolute(absolute) + + +## After the restart: settle a `swapped` marker. Hash the live tree and +## compare it to `expected_tree_sha256`. Equal: the marker becomes +## `success`. Different: the live tree is renamed to +## `quarantine//`, the backup is renamed back into place, and the +## marker becomes `rolled_back` with the reason. No backup: the marker becomes +## `repair_required` naming the exact paths and the live tree is left alone. +## Nothing is ever deleted. A marker in any other status is returned +## unchanged, and `{}` means there is nothing pending. Every marker key is +## preserved verbatim. +static func verify_after_restart(live_root: String) -> Dictionary: + var marker := read_pending() + if marker.is_empty(): + return {} + if str(marker.get("status", "")) != STATUS_SWAPPED: + ## A success marker whose client migration already ran is the durable + ## record of the last update, not work for this start. + if str(marker.get("status", "")) == STATUS_SUCCESS and bool(marker.get("clients_migrated", false)): + return {} + return marker + var live_abs := _absolute(live_root) + var expected := str(marker.get("expected_tree_sha256", "")) + var hashed := ReleaseVerifier.hash_tree(live_abs) + if bool(hashed.ok) and _is_hex(expected, 64) and str(hashed.tree_sha256) == expected: + marker["status"] = STATUS_SUCCESS + marker["error"] = "" + marker["verified_unix"] = int(Time.get_unix_time_from_system()) + _write_json(PENDING_FILE, marker) + return marker + var reason := "" + if not bool(hashed.ok): + reason = "live tree could not be hashed: %s" % str(hashed.error) + elif not _is_hex(expected, 64): + reason = "pending marker carries no usable expected_tree_sha256" + else: + reason = "live tree hash %s does not match the expected %s" % [str(hashed.tree_sha256), expected] + var backup_root := str(marker.get("backup_root", "")) + var backup_abs := _absolute(backup_root) if not backup_root.is_empty() else "" + if backup_abs.is_empty() or not DirAccess.dir_exists_absolute(backup_abs): + return _settle_repair(marker, "%s; no backup exists at %s to restore, live tree left at %s" % [ + reason, backup_abs if not backup_abs.is_empty() else "(unrecorded)", live_abs, + ]) + var to_version := str(marker.get("to_version", "")) + var quarantine_root := QUARANTINE_DIR + "/" + (to_version if _is_safe_segment(to_version) else "unknown") + var quarantine_abs := _absolute(quarantine_root) + if _exists(quarantine_abs): + return _settle_repair(marker, "%s; quarantine already exists at %s, live tree left at %s, backup at %s" % [ + reason, quarantine_abs, live_abs, backup_abs, + ]) + if DirAccess.dir_exists_absolute(live_abs): + var make_error := DirAccess.make_dir_recursive_absolute(_absolute(QUARANTINE_DIR)) + if make_error != OK: + return _settle_repair(marker, "%s; cannot create %s (%s), live tree left at %s, backup at %s" % [ + reason, QUARANTINE_DIR, error_string(make_error), live_abs, backup_abs, + ]) + var quarantine_error := DirAccess.rename_absolute(live_abs, quarantine_abs) + if quarantine_error != OK: + return _settle_repair(marker, "%s; cannot move live tree %s to %s (%s), backup at %s" % [ + reason, live_abs, quarantine_abs, error_string(quarantine_error), backup_abs, + ]) + var restore_error := DirAccess.rename_absolute(backup_abs, live_abs) + if restore_error != OK: + var undo := DirAccess.rename_absolute(quarantine_abs, live_abs) + return _settle_repair(marker, "%s; cannot restore backup %s to %s (%s); new tree is at %s" % [ + reason, backup_abs, live_abs, error_string(restore_error), + live_abs if undo == OK else quarantine_abs, + ]) + marker["status"] = STATUS_ROLLED_BACK + marker["error"] = reason + marker["quarantine_root"] = quarantine_root + marker["verified_unix"] = int(Time.get_unix_time_from_system()) + _write_json(PENDING_FILE, marker) + return marker + + +# ----- retention -------------------------------------------------------- + + +## Remove every `backup//` except `keep_version`. Call only after a +## verified success. +static func prune_backups(keep_version: String) -> void: + var backups := DirAccess.open(_absolute(BACKUP_DIR)) + if backups == null: + return + backups.include_hidden = true + backups.include_navigational = false + for name in backups.get_directories(): + if name != keep_version: + remove_under_update_root(BACKUP_DIR + "/" + name) + + +# ----- restart ---------------------------------------------------------- + + +## Add `plugin_cfg` to `editor_plugins/enabled` and save project.godot so the +## next editor start loads the add-on, without enabling it in this process. +static func persist_next_start_enabled(plugin_cfg: String) -> Error: + if plugin_cfg.is_empty(): + return ERR_INVALID_PARAMETER + var enabled := PackedStringArray() + if ProjectSettings.has_setting(ENABLED_PLUGINS_SETTING): + var current: Variant = ProjectSettings.get_setting(ENABLED_PLUGINS_SETTING) + if current is PackedStringArray: + enabled = current + elif current is Array: + for item in current: + enabled.append(str(item)) + if not enabled.has(plugin_cfg): + enabled.append(plugin_cfg) + ProjectSettings.set_setting(ENABLED_PLUGINS_SETTING, enabled) + return ProjectSettings.save() + + +## Restart the editor, saving open scenes first. +static func request_restart() -> void: + if Engine.is_editor_hint(): + EditorInterface.restart_editor(true) + + +# ----- update root ------------------------------------------------------ + + +## Create `res://addons/.godot_ai_update/` with its `.gdignore` and +## `.gitignore` (`*`). Returns `{"ok", "error"}`. +static func ensure_root() -> Dictionary: + var result := {"ok": false, "error": ""} + var root_abs := _absolute(UPDATE_ROOT) + var make_error := DirAccess.make_dir_recursive_absolute(root_abs) + if make_error != OK: + result.error = "cannot create %s: %s" % [UPDATE_ROOT, error_string(make_error)] + return result + for marker in [[".gdignore", ""], [".gitignore", "*\n"]]: + var path := root_abs.path_join(str(marker[0])) + if FileAccess.file_exists(path): + continue + var write_error := _write_bytes(path, str(marker[1]).to_utf8_buffer()) + if write_error != OK: + result.error = "cannot write %s: %s" % [path, error_string(write_error)] + return result + result.ok = true + return result + + +## Recursively remove a file or directory, refusing anything outside the +## update root. Returns `{"ok", "error"}`. +static func remove_under_update_root(path: String) -> Dictionary: + var result := {"ok": false, "error": ""} + var absolute := _absolute(path).simplify_path() + var root_abs := _absolute(UPDATE_ROOT).simplify_path() + if absolute == root_abs or not absolute.begins_with(root_abs + "/"): + result.error = "refusing to remove %s: outside %s" % [absolute, UPDATE_ROOT] + return result + var remove_error := _remove_tree(absolute) + if remove_error != OK: + result.error = "cannot remove %s: %s" % [absolute, error_string(remove_error)] + return result + result.ok = true + return result + + +# ----- helpers ---------------------------------------------------------- + + +static func _settle_repair(marker: Dictionary, error: String) -> Dictionary: + marker["status"] = STATUS_REPAIR_REQUIRED + marker["error"] = error + marker["verified_unix"] = int(Time.get_unix_time_from_system()) + _write_json(PENDING_FILE, marker) + return marker + + +static func _remove_tree(absolute: String) -> Error: + var parent := DirAccess.open(absolute.get_base_dir()) + if parent == null: + return DirAccess.get_open_error() + if not DirAccess.dir_exists_absolute(absolute) or parent.is_link(absolute): + return DirAccess.remove_absolute(absolute) + var directory := DirAccess.open(absolute) + if directory == null: + return DirAccess.get_open_error() + directory.include_hidden = true + directory.include_navigational = false + for name in directory.get_files(): + var file_error := DirAccess.remove_absolute(absolute.path_join(name)) + if file_error != OK: + return file_error + for name in directory.get_directories(): + var child_error := _remove_tree(absolute.path_join(name)) + if child_error != OK: + return child_error + return DirAccess.remove_absolute(absolute) + + +static func _write_bytes(absolute: String, data: PackedByteArray) -> Error: + var file := FileAccess.open(absolute, FileAccess.WRITE) + if file == null: + return FileAccess.get_open_error() + file.store_buffer(data) + var error := file.get_error() + file.close() + return error + + +static func _write_json(path: String, data: Dictionary) -> Error: + return _write_bytes(_absolute(path), (JSON.stringify(data, "\t") + "\n").to_utf8_buffer()) + + +static func _read_json(path: String) -> Dictionary: + var absolute := _absolute(path) + if not FileAccess.file_exists(absolute): + return {} + var bytes := FileAccess.get_file_as_bytes(absolute) + if bytes.is_empty() or not ReleaseVerifier.is_valid_utf8(bytes): + return {} + var parsed: Variant = JSON.parse_string(bytes.get_string_from_utf8()) + if not parsed is Dictionary: + return {} + return parsed + + +static func _exists(absolute: String) -> bool: + return DirAccess.dir_exists_absolute(absolute) or FileAccess.file_exists(absolute) + + +static func _is_safe_segment(name: String) -> bool: + if name.is_empty() or name.length() > 64 or name == "." or name == ".." or name.begins_with("."): + return false + for index in name.length(): + var code := name.unicode_at(index) + var ok := ( + (code >= 48 and code <= 57) + or (code >= 65 and code <= 90) + or (code >= 97 and code <= 122) + or code == 45 or code == 46 or code == 95 + ) + if not ok: + return false + return true + + +static func _is_hex(text: String, length: int) -> bool: + if text.length() != length: + return false + for index in text.length(): + var code := text.unicode_at(index) + if not ((code >= 48 and code <= 57) or (code >= 97 and code <= 102)): + return false + return true + + +static func _absolute(path: String) -> String: + if path.begins_with("res://") or path.begins_with("user://"): + return ProjectSettings.globalize_path(path) + return path diff --git a/addons/godot_ai/utils/update_installer.gd.uid b/addons/godot_ai/utils/update_installer.gd.uid new file mode 100644 index 0000000..337a3af --- /dev/null +++ b/addons/godot_ai/utils/update_installer.gd.uid @@ -0,0 +1 @@ +uid://c30k72xyjmfrp diff --git a/addons/godot_ai/utils/windows_port_reservation.gd b/addons/godot_ai/utils/windows_port_reservation.gd new file mode 100644 index 0000000..23b4f43 --- /dev/null +++ b/addons/godot_ai/utils/windows_port_reservation.gd @@ -0,0 +1,146 @@ +@tool +class_name McpWindowsPortReservation +extends RefCounted + +## Detects whether Windows has reserved a TCP port range that covers the +## plugin's server port. Hyper-V, WSL2, Docker Desktop, and Windows +## Sandbox all grab port ranges at boot via the winnat service. When a +## user's chosen port sits inside a reserved range, bind(2) fails with +## WinError 10013 ("forbidden by its access permissions") rather than +## 10048 ("address in use") — `netstat` shows nothing because no process +## owns the port, making the failure invisible. See issue #146. + +const NETSH_ARGS := ["interface", "ipv4", "show", "excludedportrange", "protocol=tcp"] + +## Session-lifetime cache. winnat establishes its excluded-port ranges at +## boot, so the table is effectively static for an editor session — while +## a `netsh` spawn costs ~250ms (measured), which the old 2s TTL re-paid +## on every startup walk (and could even re-pay *within* one walk when +## server-command discovery ran long between the two netsh consumers). +## Staleness risk is bounded: a mid-session winnat change (Docker/WSL2 +## start) at worst yields the same failure mode as the pre-#146 code for +## the remainder of the session, and only if a spawn happens after it. +static var _netsh_cache_text := "" +static var _netsh_cache_valid := false +static var _netsh_query_count := 0 + + +## Returns true if `port` falls inside a currently-reserved range on this +## Windows host. No-op on non-Windows (returns false). +static func is_port_excluded(port: int) -> bool: + if OS.get_name() != "Windows": + return false + var cached := _get_cached_excluded_output() + if bool(cached.get("hit", false)): + return parse_excluded(str(cached.get("text", "")), port) + var output: Array = [] + var exit_code := _execute_netsh_excluded_ranges(output) + if exit_code != 0 or output.is_empty(): + return false + var text := str(output[0]) + _store_excluded_output(text) + return parse_excluded(text, port) + + +static func _store_excluded_output(text: String) -> void: + _netsh_cache_text = text + _netsh_cache_valid = true + + +static func _get_cached_excluded_output() -> Dictionary: + if not _netsh_cache_valid: + return {"hit": false, "text": ""} + return {"hit": true, "text": _netsh_cache_text} + + +static func _clear_cache_for_tests() -> void: + _netsh_cache_text = "" + _netsh_cache_valid = false + + +static func netsh_query_count() -> int: + return _netsh_query_count + + +static func _execute_netsh_excluded_ranges(output: Array) -> int: + _netsh_query_count += 1 + return OS.execute("netsh", NETSH_ARGS, output, true) + + +## Parse the `netsh` excluded-port-range output and return true if `port` +## sits inside any reserved range. Exposed for testing; the live check +## uses `is_port_excluded`. Expected input format: +## +## Protocol tcp Port Exclusion Ranges +## +## Start Port End Port +## ---------- -------- +## 80 80 +## 5040 5040 +## 8000 8099 +## +## * - Administered port exclusions. +static func parse_excluded(text: String, port: int) -> bool: + return _ranges_contain(parse_excluded_ranges(text), port) + + +## Parse the `netsh` excluded-port-range output once into inclusive ranges. +static func parse_excluded_ranges(text: String) -> Array[Vector2i]: + var ranges: Array[Vector2i] = [] + for line in text.split("\n"): + var trimmed := line.strip_edges() + if trimmed.is_empty() or trimmed.begins_with("-") or trimmed.begins_with("*"): + continue + var parts: PackedStringArray = trimmed.split(" ", false) + if parts.size() < 2: + continue + if not parts[0].is_valid_int() or not parts[1].is_valid_int(): + continue + var start_p := int(parts[0]) + var end_p := int(parts[1]) + ranges.append(Vector2i(start_p, end_p)) + return ranges + + +static func _ranges_contain(ranges: Array[Vector2i], port: int) -> bool: + for r in ranges: + if port >= r.x and port <= r.y: + return true + return false + + +## Return the first port in `start`..`start+span-1` that is not excluded by +## Windows' port reservation table. Runs `netsh` once, unlike probing every +## candidate with `is_port_excluded`, which keeps fallback port selection cheap +## when Hyper-V / WSL2 / Docker reserve many adjacent ranges. +static func suggest_non_excluded_port(start: int, span: int = 2048, max_port: int = 65535) -> int: + if OS.get_name() != "Windows": + return start + var cached := _get_cached_excluded_output() + if bool(cached.get("hit", false)): + return suggest_non_excluded_port_from_output(str(cached.get("text", "")), start, span, max_port) + var output: Array = [] + var exit_code := _execute_netsh_excluded_ranges(output) + if exit_code != 0 or output.is_empty(): + return start + var text := str(output[0]) + _store_excluded_output(text) + return suggest_non_excluded_port_from_output(text, start, span, max_port) + + +## Pure parser-backed helper for tests and for `suggest_non_excluded_port`. +static func suggest_non_excluded_port_from_output(text: String, start: int, span: int = 2048, max_port: int = 65535) -> int: + var ranges := parse_excluded_ranges(text) + var limit := mini(start + span - 1, max_port) + var p := start + while p <= limit: + var advanced := false + for r in ranges: + if p >= r.x and p <= r.y: + p = r.y + 1 + advanced = true + break + if not advanced: + return p + return start + diff --git a/addons/godot_ai/utils/windows_port_reservation.gd.uid b/addons/godot_ai/utils/windows_port_reservation.gd.uid new file mode 100644 index 0000000..ffc5746 --- /dev/null +++ b/addons/godot_ai/utils/windows_port_reservation.gd.uid @@ -0,0 +1 @@ +uid://bt7mxpjcdrobq