godot ai插件

This commit is contained in:
2026-09-21 16:16:04 +08:00
parent 542c25fcdb
commit c9c7d59d1e
23 changed files with 3590 additions and 0 deletions
+212
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://e3r4fem8egus
+213
View File
@@ -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
@@ -0,0 +1 @@
uid://qqsn55jmog6v
+217
View File
@@ -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", "?"))
@@ -0,0 +1 @@
uid://bym2nenea1nvd
File diff suppressed because one or more lines are too long
+7
View File
@@ -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"
+202
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://b5w7nw8pfnjww
+768
View File
@@ -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
)
@@ -0,0 +1 @@
uid://pk0212qfh61x
+876
View File
@@ -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
@@ -0,0 +1 @@
uid://cvjv1388502ne
@@ -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()
@@ -0,0 +1 @@
uid://mj7jpaq6wt27
+548
View File
@@ -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/<from_version>/`, 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/<from_version>/` 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/<to_version>/`, 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/<version>/` 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
@@ -0,0 +1 @@
uid://c30k72xyjmfrp
@@ -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
@@ -0,0 +1 @@
uid://bt7mxpjcdrobq