godot ai插件
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://e3r4fem8egus
|
||||
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b5w7nw8pfnjww
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user