refactor: enhance test framework with automated resource tracking and scripted error capture capabilities
This commit is contained in:
@@ -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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user