refactor: enhance test framework with automated resource tracking and scripted error capture capabilities

This commit is contained in:
2026-06-26 09:40:17 +08:00
parent 948a99cf90
commit 00f9d98f4b
58 changed files with 3594 additions and 1289 deletions
+46 -2
View File
@@ -70,6 +70,8 @@ func get_logs(params: Dictionary) -> Dictionary:
var offset: int = maxi(0, int(params.get("offset", 0)))
var source: String = str(params.get("source", "plugin"))
var include_details: bool = bool(params.get("include_details", false))
var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
if not source in VALID_LOG_SOURCES:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
@@ -82,7 +84,7 @@ func get_logs(params: Dictionary) -> Dictionary:
"game":
return _get_game_logs(count, offset, include_details)
"editor":
return _get_editor_logs(count, offset, include_details)
return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
"all":
return _get_all_logs(count, offset, include_details)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
@@ -134,15 +136,18 @@ func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionar
}
func _get_editor_logs(count: int, offset: int, include_details: bool) -> Dictionary:
func _get_editor_logs(count: int, offset: int, include_details: bool, has_since_cursor: bool = false, since_cursor: int = 0) -> Dictionary:
## Editor-process script errors (parse errors, @tool runtime errors,
## EditorPlugin errors, push_error/push_warning). Captured by
## editor_logger.gd via OS.add_logger and gated on Godot 4.5+; on older
## engines the buffer can be null. Godot also sends GDScript reload
## warnings/errors straight to the Debugger dock's Errors tab; those do
## not flow through OS.add_logger, so merge the visible tree rows here.
if has_since_cursor:
return _get_editor_logs_since(count, since_cursor, include_details)
var all_entries := _collect_editor_log_entries()
var page := _entries_for_response(_slice_entries(all_entries, offset, count), include_details)
var appended_total := _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
return {
"data": {
"source": "editor",
@@ -151,6 +156,45 @@ func _get_editor_logs(count: int, offset: int, include_details: bool) -> Diction
"returned_count": page.size(),
"offset": offset,
"dropped_count": _editor_log_buffer.dropped_count() if _editor_log_buffer != null else 0,
"next_cursor": appended_total,
"appended_total": appended_total,
}
}
func _get_editor_logs_since(count: int, since_cursor: int, include_details: bool) -> Dictionary:
## Cursor reads are defined over the monotonic editor logger ring only.
## Visible Debugger Errors-tab rows are live UI state, not ring entries,
## so regular offset reads still merge them while since_cursor polling
## reports only Logger-backed entries.
var captured := {
"cursor": since_cursor,
"oldest_cursor": 0,
"next_cursor": 0,
"appended_total": 0,
"truncated": false,
"has_more": false,
"entries": [],
}
var dropped := 0
if _editor_log_buffer != null:
captured = _editor_log_buffer.get_since(since_cursor, count)
dropped = _editor_log_buffer.dropped_count()
var page := _entries_for_response(captured.get("entries", []), include_details)
return {
"data": {
"source": "editor",
"lines": page,
"total_count": int(captured.get("appended_total", 0)),
"returned_count": page.size(),
"offset": 0,
"dropped_count": dropped,
"cursor": int(captured.get("cursor", since_cursor)),
"oldest_cursor": int(captured.get("oldest_cursor", 0)),
"next_cursor": int(captured.get("next_cursor", 0)),
"appended_total": int(captured.get("appended_total", 0)),
"truncated": bool(captured.get("truncated", false)),
"has_more": bool(captured.get("has_more", false)),
}
}
@@ -175,6 +175,7 @@ func _create_event(event_type: String, params: Dictionary):
ev.alt_pressed = params.get("alt", false)
ev.shift_pressed = params.get("shift", false)
ev.meta_pressed = params.get("meta", false)
ev.device = -1
return ev
"mouse_button":
if not params.has("button"):
@@ -186,6 +187,7 @@ func _create_event(event_type: String, params: Dictionary):
"mouse_button button must be > 0 (got %d). Use 1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down." % button)
var ev := InputEventMouseButton.new()
ev.button_index = button
ev.device = -1
return ev
"joy_button":
if not params.has("button"):
+117 -19
View File
@@ -2,6 +2,8 @@
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd")
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
## Handles script creation, reading, attaching, detaching, and symbol inspection.
@@ -50,6 +52,17 @@ func create_script(params: Dictionary) -> Dictionary:
file.store_string(content)
file.close()
var data := {
"path": path,
"size": content.length(),
"committed": true,
"import_settled": existed_before,
"import_settle": "already_known" if existed_before else "not_waited",
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}
_attach_gdscript_diagnostics(data, path, content)
# Register just this file with the editor instead of a full recursive
# scan(). A scan() per write stacks `update_scripts_classes` /
# `update_script_paths_documentation` WorkerThreadPool tasks under concurrent
@@ -61,15 +74,6 @@ func create_script(params: Dictionary) -> Dictionary:
if efs != null:
efs.update_file(path)
var data := {
"path": path,
"size": content.length(),
"committed": true,
"import_settled": existed_before,
"import_settle": "already_known" if existed_before else "not_waited",
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}
# `.gd.uid` is the sidecar Godot generates on scan; list both so the caller
# can rm the full set in one go.
McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"])
@@ -165,6 +169,99 @@ func read_script(params: Dictionary) -> Dictionary:
}
func _attach_gdscript_diagnostics(data: Dictionary, path: String, content: String) -> void:
var validation := _validate_gdscript_source(content)
var diagnostics: Array = []
var diagnostics_detail := "none"
var diagnostics_status := "checked"
if not validation.get("ok", true):
var capture := _capture_gdscript_load_diagnostics(path)
diagnostics = capture.get("diagnostics", [])
diagnostics_detail = capture.get("diagnostics_detail", "none")
diagnostics_status = capture.get("diagnostics_status", "checked")
if not validation.get("ok", true) and diagnostics.is_empty():
diagnostics.append(_fallback_gdscript_diagnostic(path, validation.get("error_code", FAILED), content))
diagnostics_detail = "fallback"
data["diagnostics"] = diagnostics
data["diagnostics_detail"] = diagnostics_detail
data["diagnostics_scope"] = "this_file"
data["diagnostics_status"] = diagnostics_status
static func _validate_gdscript_source(content: String) -> Dictionary:
var script := GDScript.new()
script.source_code = content
## Keep validation off the live cached resource: assigning resource_path to
## this ephemeral Script can collide with loaded instances. reload() still
## performs normal GDScript analysis, including static initializer work, so
## this check is intentionally scoped to `.gd` writes where the editor would
## compile the file on scan anyway.
var err := script.reload()
return {
"ok": err == OK,
"error_code": err,
}
static func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger") and OS.has_method("remove_logger")):
return _empty_diagnostics_capture()
var logger_script := LoggerLoader.build(LoggerLoader.VALIDATION_LOGGER_PATH)
if logger_script == null:
return _empty_diagnostics_capture()
var buffer := McpEditorLogBuffer.new()
var logger = logger_script.new(buffer)
var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary:
OS.call("add_logger", logger)
# ResourceLoader.load() reports parse failure instead of throwing, and
# a failed GDScript parse does not execute user code; remove immediately
# after the synchronous load to keep the private capture window tiny.
ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
OS.call("remove_logger", logger)
return {}
)
return capture
static func _empty_diagnostics_capture() -> Dictionary:
return {
"diagnostics": [],
"diagnostics_detail": "none",
"diagnostics_scope": "this_file",
"diagnostics_status": "checked",
}
static func _fallback_gdscript_diagnostic(path: String, error_code: int, content: String) -> Dictionary:
var line := _fallback_gdscript_error_line(content)
return {
"source": "editor",
"level": "error",
"text": "GDScript reload failed with error code %d." % error_code,
"path": path,
"line": line,
"function": "GDScript::reload",
"details": {
"code": "gdscript_reload_failed",
"error_code": error_code,
"fallback_line": true,
"source": {
"path": path,
"line": line,
},
},
}
static func _fallback_gdscript_error_line(content: String) -> int:
var lines := content.split("\n")
for i in range(lines.size() - 1, -1, -1):
if not str(lines[i]).strip_edges().is_empty():
return i + 1
return 1
func patch_script(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var old_text: String = params.get("old_text", "")
@@ -214,21 +311,22 @@ func patch_script(params: Dictionary) -> Dictionary:
write.store_string(new_content)
write.close()
var data := {
"path": path,
"replacements": replacements,
"size": new_content.length(),
"old_size": content.length(),
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}
_attach_gdscript_diagnostics(data, path, new_content)
# Single-file register, not a full scan() — see create_script (dsarno/godot#6).
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
return {
"data": {
"path": path,
"replacements": replacements,
"size": new_content.length(),
"old_size": content.length(),
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}
}
return {"data": data}
func attach_script(params: Dictionary) -> Dictionary:
+1 -1
View File
@@ -3,5 +3,5 @@
name="Godot AI"
description="MCP server and AI tools for Godot"
author="Godot AI"
version="2.7.3"
version="2.7.6"
script="plugin.gd"
+11 -1
View File
@@ -1092,7 +1092,17 @@ func _evaluate_strong_port_occupant_proof(port: int, live: Dictionary = {}) -> D
var record_version := str(record.get("version", ""))
if record_pid > 1 and record_pid != OS.get_process_id():
if listener_pids.has(record_pid) and _pid_alive_for_proof(record_pid):
## Brand-verify the recorded PID before trusting it as a kill target.
## A recorded PID can outlive the server it named and be recycled by
## the kernel for an unrelated process that happens to bind the same
## port — without the cmdline brand gate (the same one the
## `pidfile_listener` branch enforces) that process could be killed.
## See #525.
if (
listener_pids.has(record_pid)
and _pid_alive_for_proof(record_pid)
and _pid_cmdline_is_godot_ai_for_proof(record_pid)
):
return {"proof": "managed_record", "pids": [record_pid]}
var legacy_targets := _legacy_pidfile_kill_targets(port, listener_pids)
+9 -1
View File
@@ -430,7 +430,15 @@ func _current_scene_root() -> Node:
return null
var scene_root := tree.current_scene
if scene_root == null and Engine.is_editor_hint():
scene_root = EditorInterface.get_edited_scene_root()
# Look the editor singleton up by name rather than referencing the bare
# `EditorInterface` identifier: that identifier is compiled out of export
# templates, so the GDScript parser rejects it ("Identifier
# "EditorInterface" not declared in the current scope") in an exported
# build even though `Engine.is_editor_hint()` would never run it there.
# That parse failure stops this autoload from loading in every export.
var editor := Engine.get_singleton(&"EditorInterface")
if editor:
scene_root = editor.get_edited_scene_root()
return scene_root
+1
View File
@@ -25,6 +25,7 @@ extends RefCounted
const EDITOR_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/editor_logger.gd"
const GAME_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/game_logger.gd"
const VALIDATION_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/validation_logger.gd"
## Compile a `.gdignore`'d logger script from its on-disk source. Returns the
@@ -0,0 +1,43 @@
@tool
extends Logger
## Short-lived Logger used only for per-write validation loads.
##
## Unlike editor_logger.gd this deliberately has no addon feedback-loop filter:
## the caller attaches it around one ResourceLoader.load() call, reads its
## private buffer, and immediately removes it. The shared editor logger should
## still drop these validation-load errors so logs_read(source="editor") stays
## clean.
const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
var _buffer
func _init(buffer = null) -> void:
_buffer = buffer
func _log_error(
function: String,
file: String,
line: int,
code: String,
rationale: String,
_editor_notify: bool,
error_type: int,
script_backtraces: Array,
) -> void:
if _buffer == null:
return
var resolved := _LogBacktrace.resolve_error(
function,
file,
line,
code,
rationale,
error_type,
script_backtraces,
)
var details: Dictionary = resolved.get("details", {})
_buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details)
@@ -0,0 +1 @@
@@ -0,0 +1,51 @@
@tool
extends Logger
## Captures GDScript runtime errors emitted while a test is running.
##
## Deliberately no class_name: this script is compiled dynamically by
## script_error_capture_loader.gd only when Logger exists. It lives in a
## `.gdignore`d folder so older Godot editor scans never parse `extends Logger`.
##
## Only ERROR_TYPE_SCRIPT is captured. push_error(), push_warning(), and
## engine-internal ERR_FAIL_* checks are often valid negative-path assertions and
## should not abort the test.
var _mutex := Mutex.new()
var _capturing := false
var _errors := PackedStringArray()
func begin_capture() -> void:
_mutex.lock()
_capturing = true
_errors.clear()
_mutex.unlock()
func end_capture() -> PackedStringArray:
_mutex.lock()
var captured := _errors.duplicate()
_capturing = false
_errors.clear()
_mutex.unlock()
return captured
func _log_error(
function: String,
file: String,
line: int,
code: String,
rationale: String,
_editor_notify: bool,
error_type: int,
_script_backtraces: Array,
) -> void:
if error_type != ERROR_TYPE_SCRIPT:
return
_mutex.lock()
if _capturing:
var text := rationale if not rationale.is_empty() else code
_errors.append("%s (%s:%d in %s)" % [text, file, line, function])
_mutex.unlock()
@@ -0,0 +1,26 @@
@tool
extends RefCounted
## Builds the Logger-based test script-error capture lazily.
##
## Logger exists only on newer Godot versions. The capture implementation lives
## under a `.gdignore`d folder and is compiled from source only after the runner
## verifies the Logger API is present, so older editor scans do not parse an
## `extends Logger` file and emit red startup errors.
const SCRIPT_ERROR_CAPTURE_PATH := "res://addons/godot_ai/testing/loggers/script_error_capture.gd"
static func build() -> Object:
if not ClassDB.class_exists("Logger") or not OS.has_method("add_logger"):
return null
if not FileAccess.file_exists(SCRIPT_ERROR_CAPTURE_PATH):
return null
var source := FileAccess.get_file_as_string(SCRIPT_ERROR_CAPTURE_PATH)
if source.is_empty():
return null
var script := GDScript.new()
script.source_code = source
if script.reload() != OK:
return null
return script.new()
@@ -0,0 +1 @@
uid://pmtc07go8ty4
+80 -2
View File
@@ -5,11 +5,25 @@ extends RefCounted
## Lightweight test runner for MCP plugin tests. Discovers test_* methods
## on McpTestSuite instances, runs them, and collects structured results.
const ScriptErrorCaptureLoader := preload("res://addons/godot_ai/testing/script_error_capture_loader.gd")
var _results: Array[Dictionary] = []
var _last_run_ms: int = 0
var _script_error_capture: Object = null
var _capture_registered := false
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE and _capture_registered and _script_error_capture != null:
OS.call("remove_logger", _script_error_capture)
_capture_registered = false
func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filter: String = "") -> void:
var owns_capture := not _capture_registered
if owns_capture:
_register_capture()
var name := suite.suite_name()
var methods := _get_test_methods(suite)
var exclusions := _parse_exclusions(exclude_test_filter)
@@ -29,9 +43,12 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte
continue
suite._reset()
_begin_script_error_capture()
suite.setup()
suite.call(method_name)
suite.teardown()
var script_errors := suite._unexpected_script_errors(_end_script_error_capture())
suite._free_tracked()
## Issue #19 defence: free any `_McpTest*` nodes the test created, even
## nested ones. If the scene gets auto-saved mid-test while one of these
@@ -39,10 +56,23 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte
## with a "missing dependency" error. Runs after every test, not just at
## suite boundaries, so a test that fails mid-flow can't leave a trap
## for the next test or for scene autosave.
var scene_root_for_cleanup := EditorInterface.get_edited_scene_root()
var scene_root_for_cleanup := _edited_scene_root()
if scene_root_for_cleanup != null and scene_root_for_cleanup.is_inside_tree():
_free_mcp_test_nodes_recursive(scene_root_for_cleanup)
if not script_errors.is_empty():
var abort_message := "Aborted by SCRIPT ERROR: %s" % "; ".join(script_errors)
if suite._failed:
abort_message += " (after assertion failure: %s)" % suite._message
_results.append({
"suite": name,
"test": method_name,
"passed": false,
"message": abort_message,
"assertion_count": suite._assertion_count,
})
continue
if suite._skipped:
_results.append({
"suite": name,
@@ -70,6 +100,9 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte
"assertion_count": suite._assertion_count,
})
if owns_capture:
_unregister_capture()
func run_suites(suites: Array, suite_filter: String = "", test_filter: String = "", ctx: Dictionary = {}, verbose: bool = false, exclude_test_filter: String = "") -> Dictionary:
_results.clear()
@@ -83,12 +116,17 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String =
var _prev_console_echo := McpLogBuffer.console_echo
McpLogBuffer.console_echo = false
## If a prior run was interrupted after registering the logger but before
## normal teardown, remove that stale registration before starting fresh.
_unregister_capture()
_register_capture()
for suite: McpTestSuite in suites:
if not suite_filter.is_empty() and suite.suite_name() != suite_filter:
continue
## Snapshot scene children before the suite so we can clean up leaks.
var scene_root := EditorInterface.get_edited_scene_root()
var scene_root := _edited_scene_root()
var before_children: Array[Node] = []
if scene_root != null:
before_children = _get_children_snapshot(scene_root)
@@ -119,6 +157,7 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String =
else:
run_suite(suite, test_filter, exclude_test_filter)
suite.suite_teardown()
suite._free_tracked()
## Remove any nodes the suite left behind (failed undo, missing cleanup).
if scene_root != null and scene_root.is_inside_tree():
@@ -126,9 +165,48 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String =
_last_run_ms = Time.get_ticks_msec() - start
McpLogBuffer.console_echo = _prev_console_echo
_unregister_capture()
return get_results(verbose)
func _register_capture() -> void:
if _capture_registered:
return
if _script_error_capture == null:
_script_error_capture = ScriptErrorCaptureLoader.build()
if _script_error_capture == null:
return
OS.call("add_logger", _script_error_capture)
_capture_registered = true
func _unregister_capture() -> void:
if not _capture_registered:
return
if _script_error_capture == null:
_capture_registered = false
return
OS.call("remove_logger", _script_error_capture)
_capture_registered = false
func _begin_script_error_capture() -> void:
if _script_error_capture != null and _capture_registered:
_script_error_capture.call("begin_capture")
func _end_script_error_capture() -> PackedStringArray:
if _script_error_capture == null or not _capture_registered:
return PackedStringArray()
return _script_error_capture.call("end_capture") as PackedStringArray
static func _edited_scene_root() -> Node:
if not Engine.is_editor_hint():
return null
return EditorInterface.get_edited_scene_root()
func get_results(verbose: bool = false) -> Dictionary:
var passed := 0
var failed := 0
+53
View File
@@ -31,6 +31,34 @@ func suite_teardown() -> void:
pass
# ----- tracked allocations (freed by the runner after each test) -----
var _tracked_objects: Array[Object] = []
## Register a manually-managed Object (plain Object or out-of-tree Node) so
## the runner frees it after the current test. RefCounted instances are
## accepted but ignored because they manage their own lifetime.
func track(obj: Object) -> Object:
if obj != null and not obj is RefCounted:
_tracked_objects.append(obj)
return obj
## Free everything registered via track(). Called by the runner after each
## test's teardown() and again after suite_teardown().
func _free_tracked() -> void:
for obj in _tracked_objects:
if not is_instance_valid(obj) or (obj is Node and obj.is_queued_for_deletion()):
continue
if obj is Node:
var parent := (obj as Node).get_parent()
if parent != null:
parent.remove_child(obj)
obj.free()
_tracked_objects.clear()
# ----- assertion state (managed by McpTestRunner) -----
var _failed: bool = false
@@ -38,6 +66,7 @@ var _message: String = ""
var _assertion_count: int = 0
var _skipped: bool = false
var _skip_reason: String = ""
var _expected_script_error_substrings: Array[String] = []
# ----- suite-level state (managed by McpTestRunner) -----
@@ -53,6 +82,7 @@ func _reset() -> void:
_assertion_count = 0
_skipped = false
_skip_reason = ""
_expected_script_error_substrings.clear()
func _reset_suite_state() -> void:
@@ -121,6 +151,29 @@ func skip_on_godot_lt(min_version: String, reason: String = "") -> bool:
return false
## Allow one captured SCRIPT ERROR whose text contains `substring`.
## Use only for negative-path tests that intentionally compile or execute
## invalid GDScript and assert on the resulting diagnostics.
func expect_script_error_containing(substring: String) -> void:
_expected_script_error_substrings.append(substring)
func _unexpected_script_errors(captured: PackedStringArray) -> PackedStringArray:
var unexpected := PackedStringArray()
var remaining := _expected_script_error_substrings.duplicate()
for error in captured:
var matched_index := -1
for i in range(remaining.size()):
if error.find(remaining[i]) != -1:
matched_index = i
break
if matched_index == -1:
unexpected.append(error)
else:
remaining.remove_at(matched_index)
return unexpected
## Trigger an undo against whichever history (scene or global) holds the most
## recent action. `EditorUndoRedoManager` in Godot 4.x doesn't expose `.undo()`
## directly — you resolve the history's underlying UndoRedo and call it there.
@@ -0,0 +1,66 @@
@tool
class_name McpDiagnosticsCapture
extends RefCounted
## Small helper for scoped validation-log capture windows. Callers snapshot a
## private log cursor, perform a deliberate validation action, then only report
## new diagnostics whose original source location is the target file.
static func capture_this_file(log_buffer: McpEditorLogBuffer, target_path: String, action: Callable) -> Dictionary:
var cursor := 0
if log_buffer != null:
cursor = log_buffer.appended_total()
var action_result = action.call()
var diagnostics: Array[Dictionary] = []
var truncated := false
if log_buffer != null:
var captured: Dictionary = log_buffer.get_since(cursor)
truncated = captured.get("truncated", false)
diagnostics = _diagnostics_for_target(captured.get("entries", []), target_path)
return {
"action": action_result if action_result is Dictionary else {},
"diagnostics": diagnostics,
"diagnostics_detail": "log_capture" if not diagnostics.is_empty() else "none",
"diagnostics_scope": "this_file",
"diagnostics_status": "partial" if truncated else "checked",
}
static func _diagnostics_for_target(entries: Array, target_path: String) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for raw_entry in entries:
if not raw_entry is Dictionary:
continue
var entry: Dictionary = raw_entry
if not _entry_matches_target(entry, target_path):
continue
out.append(_normalize_entry(entry, target_path))
return out
static func _entry_matches_target(entry: Dictionary, target_path: String) -> bool:
var source := _source_location(entry)
return str(source.get("path", "")) == target_path
static func _normalize_entry(entry: Dictionary, target_path: String) -> Dictionary:
var normalized := entry.duplicate(true)
var source := _source_location(entry)
normalized["path"] = str(source.get("path", target_path))
normalized["line"] = int(source.get("line", normalized.get("line", 0)))
normalized["function"] = str(source.get("function", normalized.get("function", "")))
if normalized.has("details") and normalized.details is Dictionary:
normalized["details"] = normalized.details.duplicate(true)
return normalized
static func _source_location(entry: Dictionary) -> Dictionary:
if entry.get("details") is Dictionary:
var details: Dictionary = entry.details
if details.get("source") is Dictionary:
return details.source
return {}
@@ -0,0 +1 @@
uid://b3npxxpuobbc2
+258 -13
View File
@@ -34,6 +34,21 @@ const UPDATE_TEMP_DIR := "user://godot_ai_update/"
const UPDATE_TEMP_ZIP := "user://godot_ai_update/update.zip"
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
## Hosts the self-update download is allowed to come from. The download URL
## is taken verbatim from the GitHub Releases API's `browser_download_url`,
## so before fetching we pin it to https on a GitHub-owned host — a tampered
## or unexpected API response can't then point the in-editor updater at an
## arbitrary origin. (HTTPRequest follows the github.com -> githubusercontent
## redirect internally; this validates the entry point. Release-side checksum
## / provenance verification of the downloaded bytes remains tracked in #523.)
const _TRUSTED_DOWNLOAD_HOSTS := [
"github.com",
"www.github.com",
"api.github.com",
"objects.githubusercontent.com",
"release-assets.githubusercontent.com",
]
## Emitted after `check_for_updates()` resolves a newer remote version.
## Payload mirrors the Dictionary returned by `parse_releases_response`:
## {has_update, version, forced, label_text, download_url}
@@ -53,7 +68,12 @@ var _dock
var _http_request: HTTPRequest
var _download_request: HTTPRequest
var _verify_request: HTTPRequest
var _latest_download_url: String = ""
## URL of the `godot-ai-plugin.zip.sha256` sidecar asset, when the release
## ships one. Used to verify the downloaded archive's integrity before extract
## (#523). Empty for older releases published without a checksum sidecar.
var _latest_checksum_url: String = ""
## Set for the duration of `_install_zip` — extract-overwrite of plugin
## scripts on disk would crash any worker mid-`GDScriptFunction::call`
@@ -101,6 +121,7 @@ func cancel_check() -> void:
## flips so a fresh check paints over a clean banner.
func clear_pending_download() -> void:
_latest_download_url = ""
_latest_checksum_url = ""
## True when the running Godot can self-update in place. Godot < 4.4 takes
@@ -166,6 +187,22 @@ func start_install() -> void:
OS.shell_open(RELEASES_PAGE)
return
## Pin the resolved asset URL to https on a GitHub host before fetching.
## Fall back to the release page (a user-driven browser download) rather
## than pulling an executable plugin payload from an unexpected origin.
## See #523.
if not _is_trusted_download_url(_latest_download_url):
push_error(
"MCP | refusing self-update download from untrusted URL: %s"
% _latest_download_url
)
OS.shell_open(RELEASES_PAGE)
install_state_changed.emit({
"button_text": "Update via download page",
"button_disabled": false,
})
return
install_state_changed.emit({
"button_text": "Downloading...",
"button_disabled": true,
@@ -212,6 +249,7 @@ func is_install_in_flight() -> bool:
## forced: bool ## mode_override() == "user" (banner-only hint)
## label_text: String ## "Update available: vX.Y.Z" + " (forced)"
## download_url: String ## matching `godot-ai-plugin.zip` asset URL
## checksum_url: String ## `godot-ai-plugin.zip.sha256` asset URL ("" if absent)
##
## Static so tests drive it without instancing the manager.
static func parse_releases_response(
@@ -223,6 +261,7 @@ static func parse_releases_response(
"forced": false,
"label_text": "",
"download_url": "",
"checksum_url": "",
}
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
return out
@@ -239,12 +278,15 @@ static func parse_releases_response(
return out
var url := ""
var checksum_url := ""
var assets: Array = json.get("assets", [])
for asset in assets:
var asset_dict: Dictionary = asset
if String(asset_dict.get("name", "")) == "godot-ai-plugin.zip":
var asset_name := String(asset_dict.get("name", ""))
if asset_name == "godot-ai-plugin.zip":
url = String(asset_dict.get("browser_download_url", ""))
break
elif asset_name == "godot-ai-plugin.zip.sha256":
checksum_url = String(asset_dict.get("browser_download_url", ""))
var forced := ClientConfigurator.mode_override() == "user"
var label_text := "Update available: v%s" % remote_version
@@ -258,9 +300,35 @@ static func parse_releases_response(
out["forced"] = forced
out["label_text"] = label_text
out["download_url"] = url
out["checksum_url"] = checksum_url
return out
## True only for an `https://` URL whose host is one of
## `_TRUSTED_DOWNLOAD_HOSTS`. Parses the authority by hand (GDScript has no
## URL parser): strips userinfo via the LAST `@` so a spoof like
## `https://github.com@evil.com/...` resolves to `evil.com` (rejected), and
## strips any `:port`. Static so the guard is unit-testable without
## instancing the manager.
static func _is_trusted_download_url(url: String) -> bool:
const SCHEME := "https://"
if not url.begins_with(SCHEME):
return false
var rest := url.substr(SCHEME.length())
var authority := rest
var slash := rest.find("/")
if slash >= 0:
authority = rest.substr(0, slash)
## Host is everything after the LAST '@' (userinfo precedes it).
var at := authority.rfind("@")
if at >= 0:
authority = authority.substr(at + 1)
var colon := authority.find(":")
if colon >= 0:
authority = authority.substr(0, colon)
return authority.to_lower() in _TRUSTED_DOWNLOAD_HOSTS
static func _is_newer(remote: String, local: String) -> bool:
var r := remote.split(".")
var l := local.split(".")
@@ -286,6 +354,7 @@ func _on_update_check_completed(
if not bool(parsed.get("has_update", false)):
return
_latest_download_url = String(parsed.get("download_url", ""))
_latest_checksum_url = String(parsed.get("checksum_url", ""))
update_check_completed.emit(parsed)
## On engines that can't self-update (Godot < 4.4, #475), surface the
## full manual-update guidance AND relabel the button up-front — before
@@ -315,9 +384,117 @@ func _on_download_completed(
})
return
# Deferred so the HTTPRequest callback returns before the next step starts.
_verify_then_install.call_deferred()
# ---- Integrity verification (#523) -------------------------------------
## Gate the extract on a SHA-256 match against the release's checksum sidecar.
## TLS + host pinning already constrain where the bytes came from; this
## verifies the bytes themselves so a tampered asset (or a compromised CDN
## object) can't be installed over live plugin code. Releases published
## without a `.sha256` sidecar (older versions) install without this check —
## verify-if-present rather than hard-fail, so existing releases stay
## updatable; the host pin still applies to the download itself.
func _verify_then_install() -> void:
if _latest_checksum_url.is_empty():
print("MCP | no checksum published for this release; skipping integrity verification")
install_state_changed.emit({"button_text": "Installing..."})
_install_zip()
return
## A present-but-untrusted checksum URL is a tamper signal, not a
## backward-compat case — refuse rather than silently skip.
if not _is_trusted_download_url(_latest_checksum_url):
_fail_verification("checksum URL is not a trusted GitHub host")
return
install_state_changed.emit({"button_text": "Verifying..."})
if _verify_request != null:
_verify_request.queue_free()
_verify_request = HTTPRequest.new()
_verify_request.max_redirects = 10
_verify_request.request_completed.connect(_on_checksum_completed)
add_child(_verify_request)
var err := _verify_request.request(_latest_checksum_url)
if err != OK:
_verify_request.queue_free()
_verify_request = null
_fail_verification("could not request checksum (error %d)" % err)
func _on_checksum_completed(
result: int,
response_code: int,
_headers: PackedStringArray,
body: PackedByteArray
) -> void:
if _verify_request != null:
_verify_request.queue_free()
_verify_request = null
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
_fail_verification("checksum download failed (result=%d code=%d)" % [result, response_code])
return
var expected := _parse_sha256_digest(body.get_string_from_utf8())
if expected.is_empty():
_fail_verification("malformed checksum file")
return
var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)
var actual := FileAccess.get_sha256(zip_path).to_lower()
if actual.is_empty():
_fail_verification("could not hash the downloaded archive")
return
if actual != expected:
_fail_verification(
"checksum mismatch (expected %s…, got %s…)"
% [expected.substr(0, 12), actual.substr(0, 12)]
)
return
print("MCP | self-update checksum verified (sha256 %s)" % actual)
install_state_changed.emit({"button_text": "Installing..."})
# Deferred so the HTTPRequest callback returns before the extract starts.
_install_zip.call_deferred()
_install_zip()
## Surface an integrity-check failure and drop the staged zip so the bad
## bytes can never reach the extract path. Keeps the button enabled for retry.
func _fail_verification(reason: String) -> void:
push_error(
"MCP | self-update integrity check failed: %s. The download was not installed."
% reason
)
print("MCP | self-update aborted (integrity): %s" % reason)
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP))
install_state_changed.emit({
"button_text": "Verification failed — retry",
"button_disabled": false,
})
## Extract the hex digest from a `sha256sum`-style file ("<hex> <name>") or a
## bare digest line. Returns lowercase 64-char hex, or "" if the content isn't
## a valid SHA-256 digest. Static so it's unit-testable. See #523.
static func _parse_sha256_digest(text: String) -> String:
var trimmed := text.strip_edges()
if trimmed.is_empty():
return ""
## First whitespace-delimited token; `sha256sum` separates digest and
## filename with two spaces, so allow_empty=false collapses the run.
var tokens := trimmed.split(" ", false)
if tokens.is_empty():
return ""
var digest := String(tokens[0]).strip_edges().to_lower()
if digest.length() != 64:
return ""
for i in digest.length():
var c := digest[i]
if not ((c >= "0" and c <= "9") or (c >= "a" and c <= "f")):
return ""
return digest
# ---- Install orchestration ---------------------------------------------
@@ -382,16 +559,43 @@ func _install_zip_inline(version: Dictionary) -> void:
for file_path in files:
if not file_path.begins_with("addons/godot_ai/"):
continue
## Skip zip dir entries; parent dirs are created from each validated
## file's base dir below — the same shape the runner uses. Creating a
## dir from an unvalidated entry would itself be a traversal hole.
if file_path.ends_with("/"):
DirAccess.make_dir_recursive_absolute(install_base.path_join(file_path))
else:
var dir := file_path.get_base_dir()
DirAccess.make_dir_recursive_absolute(install_base.path_join(dir))
var content := reader.read_file(file_path)
var f := FileAccess.open(install_base.path_join(file_path), FileAccess.WRITE)
if f != null:
f.store_buffer(content)
f.close()
continue
## Reject path-traversal / absolute / backslash entries BEFORE any
## path_join + write. The modern runner enforces this via
## `update_reload_runner.gd::_is_safe_zip_addon_file`; the pre-4.4
## inline path used to gate only on the `addons/godot_ai/` prefix, so
## `addons/godot_ai/../../evil.gd` escaped the addon dir. This guard
## closes that gap so the weaker path runs the same checks. See #522.
if not _is_safe_zip_addon_file(file_path):
_abort_inline_install(reader, "unsafe zip path: %s" % file_path)
return
var dir := file_path.get_base_dir()
DirAccess.make_dir_recursive_absolute(install_base.path_join(dir))
var content := reader.read_file(file_path)
var target := install_base.path_join(file_path)
var f := FileAccess.open(target, FileAccess.WRITE)
## Unlike the runner (tmp+rename+per-file backup+rollback), this pre-4.4
## path writes directly over live files and can't roll back. It used to
## skip a null open and ignore store_buffer errors silently, leaving a
## partially-overwritten addons tree while still telling the user to
## restart onto it. Check both error surfaces and abort loudly instead.
## See #524.
if f == null:
_abort_inline_install(
reader,
"could not open %s for write (error %d)" % [target, FileAccess.get_open_error()],
)
return
f.store_buffer(content)
var write_error := f.get_error()
f.close()
if write_error != OK:
_abort_inline_install(reader, "write error %d for %s" % [write_error, target])
return
reader.close()
@@ -428,6 +632,47 @@ func _install_zip_inline(version: Dictionary) -> void:
})
## Abort the inline (pre-4.4) extract on a path-safety or write failure.
## Closes the ZIP reader, drops the in-flight gate so dock spawn paths
## un-block, and surfaces the failure loudly: this path has no rollback, so
## the addons tree may be partially overwritten and the user must reinstall
## from the download page rather than relaunch onto a half-written plugin.
## See #522 / #524.
func _abort_inline_install(reader: ZIPReader, reason: String) -> void:
reader.close()
_install_in_flight = false
push_error(
"MCP | self-update extract failed: %s. addons/godot_ai/ may be"
% reason
+ " partially updated — reinstall the plugin from the download page"
+ " before relaunching."
)
print("MCP | self-update extract aborted: %s" % reason)
install_state_changed.emit({
"button_text": "Extract failed — reinstall",
"button_disabled": false,
})
## Mirror of `update_reload_runner.gd::_is_safe_zip_addon_file`. Rejects any
## entry that could escape `addons/godot_ai/` — absolute paths, backslashes,
## and `.`/`..`/empty path segments — before it reaches a `path_join` + write
## on the inline (pre-4.4) extract path, which has no rollback. Static so the
## guard is unit-testable without instancing the manager. See #522.
static func _is_safe_zip_addon_file(file_path: String) -> bool:
if file_path.is_absolute_path() or file_path.contains("\\"):
return false
if not file_path.begins_with("addons/godot_ai/"):
return false
var rel_path := file_path.trim_prefix("addons/godot_ai/")
if rel_path.is_empty() or rel_path.ends_with("/"):
return false
for segment in rel_path.split("/", true):
if segment.is_empty() or segment == "." or segment == "..":
return false
return true
func _on_filesystem_scanned_for_update() -> void:
install_state_changed.emit({"button_text": "Reloading..."})
_reload_after_update.call_deferred()