更新godot ai到最新版本
This commit is contained in:
@@ -1,170 +0,0 @@
|
||||
@tool
|
||||
class_name McpAllowHosts
|
||||
extends RefCounted
|
||||
|
||||
## Client-side helpers for the `--allow-host` LAN opt-in (#507, server core
|
||||
## in #421). Pure static functions only — no EditorSettings, no sockets —
|
||||
## so the settings-value → launch-args plumbing and the manual-command LAN
|
||||
## URL builder are deterministically testable without a live editor.
|
||||
##
|
||||
## Accepted syntax mirrors the server's `parse_allow_hosts`
|
||||
## (src/godot_ai/transport/origin_guard.py): each token is a bare IP
|
||||
## (IPv4 or IPv6) or a CIDR, comma-separated. Host bits set on a CIDR are
|
||||
## tolerated server-side (`strict=False`), so we only validate the IP part
|
||||
## and the prefix length here — anything else fails loudly at server
|
||||
## startup, which the dock-side validation exists to pre-empt.
|
||||
|
||||
|
||||
## Canonicalize a comma-separated allow-host value: whitespace-stripped,
|
||||
## deduplicated, sorted. Returns "" for a value with no usable tokens so
|
||||
## callers can skip appending `--allow-host` entirely (keeps spawns
|
||||
## compatible with pre-#421 servers — same contract as
|
||||
## `ClientConfigurator.excluded_domains()`).
|
||||
static func normalize(raw: String) -> String:
|
||||
var parts := PackedStringArray()
|
||||
for p in raw.split(","):
|
||||
var t := p.strip_edges()
|
||||
if not t.is_empty() and parts.find(t) == -1:
|
||||
parts.append(t)
|
||||
parts.sort()
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
## Whether a single token is a bare IP or a CIDR the server will accept.
|
||||
static func token_is_valid(token: String) -> bool:
|
||||
var t := token.strip_edges()
|
||||
if t.is_empty():
|
||||
return false
|
||||
var ip := t
|
||||
var prefix := 0
|
||||
## Track slash presence separately from the prefix value: reusing -1 as
|
||||
## the "no slash" sentinel let an explicit negative prefix like
|
||||
## "10.0.0.0/-1" validate (is_valid_int accepts "-1"). CodeRabbit review.
|
||||
var has_prefix := false
|
||||
var slash := t.find("/")
|
||||
if slash != -1:
|
||||
ip = t.substr(0, slash)
|
||||
var prefix_text := t.substr(slash + 1)
|
||||
## Explicit signs are rejected by the server's parse_allow_hosts
|
||||
## (ipaddress refuses "10.0.0.0/+8"), but is_valid_int accepts
|
||||
## them — keep the mirror honest.
|
||||
if prefix_text.is_empty() or prefix_text.begins_with("+") or prefix_text.begins_with("-"):
|
||||
return false
|
||||
if not prefix_text.is_valid_int():
|
||||
return false
|
||||
prefix = int(prefix_text)
|
||||
has_prefix = true
|
||||
if not ip.is_valid_ip_address():
|
||||
return false
|
||||
var max_prefix := 128 if ip.contains(":") else 32
|
||||
return not has_prefix or (prefix >= 0 and prefix <= max_prefix)
|
||||
|
||||
|
||||
## Every token in `raw` that fails `token_is_valid` — the dock surfaces
|
||||
## these inline so a typo is caught before it aborts the server spawn.
|
||||
static func invalid_tokens(raw: String) -> PackedStringArray:
|
||||
var bad := PackedStringArray()
|
||||
for p in raw.split(","):
|
||||
var t := p.strip_edges()
|
||||
if t.is_empty():
|
||||
continue
|
||||
if not token_is_valid(t) and bad.find(t) == -1:
|
||||
bad.append(t)
|
||||
return bad
|
||||
|
||||
|
||||
## True when the allowlist names at least one non-loopback range — i.e.
|
||||
## the server is actually reachable off this machine and the manual
|
||||
## command should surface a LAN URL.
|
||||
static func is_lan_allowlist_active(value: String) -> bool:
|
||||
for p in value.split(","):
|
||||
var t := p.strip_edges()
|
||||
if t.is_empty():
|
||||
continue
|
||||
var ip := t.substr(0, t.find("/")) if t.contains("/") else t
|
||||
if not _is_loopback(ip):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Choose the LAN address to show in the manual command from the
|
||||
## machine's local addresses (caller passes `IP.get_local_addresses()`
|
||||
## so this stays pure). Loopback and link-local addresses are dropped;
|
||||
## the first private-range IPv4 wins, then any remaining IPv4, then
|
||||
## anything left (IPv6). Returns `{"address": String, "ambiguous": bool}`
|
||||
## — `ambiguous` flags multiple viable candidates so the note can tell
|
||||
## the user to pick the interface on their trusted network.
|
||||
static func pick_lan_address(addresses: PackedStringArray) -> Dictionary:
|
||||
var candidates := PackedStringArray()
|
||||
for a in addresses:
|
||||
var addr := String(a).strip_edges()
|
||||
if addr.is_empty() or _is_loopback(addr) or _is_link_local(addr):
|
||||
continue
|
||||
candidates.append(addr)
|
||||
if candidates.is_empty():
|
||||
return {"address": "", "ambiguous": false}
|
||||
var chosen := ""
|
||||
for addr in candidates:
|
||||
if _is_private_ipv4(addr):
|
||||
chosen = addr
|
||||
break
|
||||
if chosen.is_empty():
|
||||
for addr in candidates:
|
||||
if addr.contains("."):
|
||||
chosen = addr
|
||||
break
|
||||
if chosen.is_empty():
|
||||
chosen = candidates[0]
|
||||
return {"address": chosen, "ambiguous": candidates.size() > 1}
|
||||
|
||||
|
||||
## Informational LAN-URL note appended to the manual command when the
|
||||
## allowlist is active (#507). Never changes what gets WRITTEN to client
|
||||
## configs — loopback stays the write target; this is copy-paste help for
|
||||
## pointing a remote agent at the right address.
|
||||
static func lan_url_note(allow_hosts_value: String, addresses: PackedStringArray, http_port: int) -> String:
|
||||
if not is_lan_allowlist_active(allow_hosts_value):
|
||||
return ""
|
||||
var pick := pick_lan_address(addresses)
|
||||
var addr := String(pick.get("address", ""))
|
||||
if addr.is_empty():
|
||||
return (
|
||||
"LAN access is enabled (--allow-host %s), but no LAN address was detected on this machine."
|
||||
% allow_hosts_value
|
||||
)
|
||||
var host := "[%s]" % addr if addr.contains(":") else addr
|
||||
var note := (
|
||||
"LAN access is enabled (--allow-host %s). Remote agents on the allowed network can use: http://%s:%d/mcp"
|
||||
% [allow_hosts_value, host, http_port]
|
||||
)
|
||||
if bool(pick.get("ambiguous", false)):
|
||||
note += "\n(multiple network interfaces detected — pick the address on the network you allowed)"
|
||||
return note
|
||||
|
||||
|
||||
static func _is_loopback(addr: String) -> bool:
|
||||
var a := addr.to_lower()
|
||||
return a.begins_with("127.") or a == "::1" or a == "localhost"
|
||||
|
||||
|
||||
static func _is_link_local(addr: String) -> bool:
|
||||
var a := addr.to_lower()
|
||||
if a.begins_with("169.254."):
|
||||
return true
|
||||
## IPv6 link-local is fe80::/10 — the whole fe80-febf first hextet, not
|
||||
## just literal "fe80" (Copilot review on #507's PR: fea0::... etc. must
|
||||
## also be excluded from LAN-URL candidates).
|
||||
if a.length() >= 4 and a.begins_with("fe") and a[2] in "89ab":
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
static func _is_private_ipv4(addr: String) -> bool:
|
||||
if not addr.contains("."):
|
||||
return false
|
||||
if addr.begins_with("10.") or addr.begins_with("192.168."):
|
||||
return true
|
||||
if addr.begins_with("172."):
|
||||
var second := int(addr.get_slice(".", 1))
|
||||
return second >= 16 and second <= 31
|
||||
return false
|
||||
@@ -1 +0,0 @@
|
||||
uid://b7qk3vw2nxr4d
|
||||
@@ -1,259 +0,0 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Builds stable, JSON-safe metadata for any class registered in ClassDB.
|
||||
|
||||
const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
|
||||
|
||||
## Sections returned when the caller does not name any. Deliberately narrow:
|
||||
## a bare `get_class` is almost always "what properties does X have", and the
|
||||
## full five-section dump for a large class (e.g. Node, Control) costs an agent
|
||||
## thousands of tokens it rarely wanted. Callers opt into the rest by name, or
|
||||
## request the lot with the "all" keyword (see `_sections`).
|
||||
const DEFAULT_SECTIONS: Array[String] = ["properties"]
|
||||
## The full documentation-shaped section set (excludes the heavier, separately
|
||||
## gated "inheritors"). Expanded from the "all" keyword.
|
||||
const ALL_SECTIONS: Array[String] = ["properties", "methods", "signals", "enums", "constants"]
|
||||
const KNOWN_SECTIONS: Array[String] = ["properties", "methods", "signals", "enums", "constants", "inheritors"]
|
||||
## Tokens a caller may legitimately pass in `sections` — the known sections plus
|
||||
## the "all" meta-keyword. Used for error suggestions so a typo like "al" can
|
||||
## resolve to "all"; "all" is NOT a section (it expands in `_sections`), so it
|
||||
## stays out of KNOWN_SECTIONS which gates validity.
|
||||
const SUGGESTABLE_SECTION_TOKENS: Array[String] = [
|
||||
"properties", "methods", "signals", "enums", "constants", "inheritors", "all"
|
||||
]
|
||||
const MAX_DEFAULT_ITEMS := 100
|
||||
|
||||
|
||||
static func build(type_name: String, options: Dictionary = {}) -> Dictionary:
|
||||
var sections := _sections(options.get("sections", DEFAULT_SECTIONS))
|
||||
var include_inherited := bool(options.get("include_inherited", false))
|
||||
var include_inheritors := bool(options.get("include_inheritors", false))
|
||||
var offset := max(0, int(options.get("offset", 0)))
|
||||
var limit := int(options.get("limit", MAX_DEFAULT_ITEMS))
|
||||
if limit < 0:
|
||||
limit = MAX_DEFAULT_ITEMS
|
||||
var can_instantiate := ClassDB.can_instantiate(type_name)
|
||||
|
||||
var data := {
|
||||
"class_name": type_name,
|
||||
"engine_version": Engine.get_version_info().get("string", ""),
|
||||
"parent_class": str(ClassDB.get_parent_class(type_name)),
|
||||
"inheritance_chain": _inheritance_chain(type_name),
|
||||
"can_instantiate": can_instantiate,
|
||||
"is_singleton": Engine.has_singleton(type_name),
|
||||
"include_inherited": include_inherited,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
if include_inheritors or sections.has("inheritors"):
|
||||
_add_paged(data, "inheritor", "inheritors", _inheritors(type_name, false), offset, limit)
|
||||
_add_paged(
|
||||
data,
|
||||
"concrete_inheritor",
|
||||
"concrete_inheritors",
|
||||
_inheritors(type_name, true),
|
||||
offset,
|
||||
limit
|
||||
)
|
||||
if sections.has("properties"):
|
||||
_add_paged(data, "property", "properties", _properties(type_name, include_inherited), offset, limit)
|
||||
if sections.has("methods"):
|
||||
_add_paged(data, "method", "methods", _methods(type_name, include_inherited), offset, limit)
|
||||
if sections.has("signals"):
|
||||
_add_paged(data, "signal", "signals", _signals(type_name, include_inherited), offset, limit)
|
||||
if sections.has("enums"):
|
||||
_add_paged(data, "enum", "enums", _enums(type_name, include_inherited), offset, limit)
|
||||
if sections.has("constants"):
|
||||
_add_paged(
|
||||
data,
|
||||
"constant",
|
||||
"constants",
|
||||
_unscoped_constants(type_name, include_inherited),
|
||||
offset,
|
||||
limit
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
static func validate_sections(raw_sections: Variant) -> Dictionary:
|
||||
var sections := _sections(raw_sections)
|
||||
var invalid: Array[String] = []
|
||||
for section in sections:
|
||||
if not KNOWN_SECTIONS.has(section):
|
||||
invalid.append(section)
|
||||
return {"sections": sections, "invalid": invalid}
|
||||
|
||||
|
||||
static func _inheritance_chain(type_name: String) -> Array[String]:
|
||||
var chain: Array[String] = []
|
||||
var current := type_name
|
||||
while not current.is_empty():
|
||||
chain.append(current)
|
||||
current = str(ClassDB.get_parent_class(current))
|
||||
return chain
|
||||
|
||||
|
||||
static func _sections(raw_sections: Variant) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
var values: Array = []
|
||||
if raw_sections is String:
|
||||
values = raw_sections.split(",", false)
|
||||
elif raw_sections is Array:
|
||||
values = raw_sections
|
||||
else:
|
||||
values = DEFAULT_SECTIONS
|
||||
for raw_section in values:
|
||||
var section := str(raw_section).strip_edges().to_lower()
|
||||
if section == "all":
|
||||
for expanded in ALL_SECTIONS:
|
||||
if not result.has(expanded):
|
||||
result.append(expanded)
|
||||
continue
|
||||
if not section.is_empty() and not result.has(section):
|
||||
result.append(section)
|
||||
if result.is_empty():
|
||||
result.assign(DEFAULT_SECTIONS)
|
||||
return result
|
||||
|
||||
|
||||
static func _add_paged(
|
||||
data: Dictionary,
|
||||
singular: String,
|
||||
key: String,
|
||||
items: Array,
|
||||
offset: int,
|
||||
limit: int
|
||||
) -> void:
|
||||
var end := items.size() if limit == 0 else min(items.size(), offset + limit)
|
||||
var page: Array = []
|
||||
if offset < items.size():
|
||||
page = items.slice(offset, end)
|
||||
data[key] = page
|
||||
data["%s_count" % singular] = items.size()
|
||||
data["%s_returned_count" % singular] = page.size()
|
||||
|
||||
|
||||
static func _inheritors(type_name: String, concrete_only: bool) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for inheritor in ClassDB.get_inheriters_from_class(type_name):
|
||||
var inheritor_name := str(inheritor)
|
||||
if concrete_only and not ClassDB.can_instantiate(inheritor_name):
|
||||
continue
|
||||
result.append(inheritor_name)
|
||||
result.sort()
|
||||
return result
|
||||
|
||||
|
||||
static func _properties(type_name: String, include_inherited: bool) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for raw_prop in ClassDB.class_get_property_list(type_name, not include_inherited):
|
||||
var prop: Dictionary = raw_prop
|
||||
var usage := int(prop.get("usage", 0))
|
||||
if not (usage & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
var prop_name := str(prop.get("name", ""))
|
||||
result.append({
|
||||
"name": prop_name,
|
||||
"type": type_string(int(prop.get("type", TYPE_NIL))),
|
||||
"class_name": str(prop.get("class_name", "")),
|
||||
"hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
|
||||
"hint_string": str(prop.get("hint_string", "")),
|
||||
"usage": usage,
|
||||
"default": VariantSerializer.serialize(
|
||||
ClassDB.class_get_property_default_value(type_name, prop_name)
|
||||
),
|
||||
})
|
||||
result.sort_custom(func(a, b): return a.name < b.name)
|
||||
return result
|
||||
|
||||
|
||||
static func _methods(type_name: String, include_inherited: bool) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for raw_method in ClassDB.class_get_method_list(type_name, not include_inherited):
|
||||
var method: Dictionary = raw_method
|
||||
var args: Array[Dictionary] = []
|
||||
for raw_arg in method.get("args", []):
|
||||
args.append(_argument_info(raw_arg))
|
||||
var defaults: Array = []
|
||||
for value in method.get("default_args", []):
|
||||
defaults.append(VariantSerializer.serialize(value))
|
||||
result.append({
|
||||
"name": str(method.get("name", "")),
|
||||
"arguments": args,
|
||||
"default_arguments": defaults,
|
||||
"return": _argument_info(method.get("return", {})),
|
||||
"flags": int(method.get("flags", 0)),
|
||||
})
|
||||
result.sort_custom(func(a, b): return a.name < b.name)
|
||||
return result
|
||||
|
||||
|
||||
static func _signals(type_name: String, include_inherited: bool) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for raw_signal in ClassDB.class_get_signal_list(type_name, not include_inherited):
|
||||
var signal_info: Dictionary = raw_signal
|
||||
var args: Array[Dictionary] = []
|
||||
for raw_arg in signal_info.get("args", []):
|
||||
args.append(_argument_info(raw_arg))
|
||||
var defaults: Array = []
|
||||
for value in signal_info.get("default_args", []):
|
||||
defaults.append(VariantSerializer.serialize(value))
|
||||
result.append({
|
||||
"name": str(signal_info.get("name", "")),
|
||||
"arguments": args,
|
||||
"default_arguments": defaults,
|
||||
"flags": int(signal_info.get("flags", 0)),
|
||||
})
|
||||
result.sort_custom(func(a, b): return a.name < b.name)
|
||||
return result
|
||||
|
||||
|
||||
static func _argument_info(raw_info: Variant) -> Dictionary:
|
||||
var info: Dictionary = raw_info if raw_info is Dictionary else {}
|
||||
return {
|
||||
"name": str(info.get("name", "")),
|
||||
"type": type_string(int(info.get("type", TYPE_NIL))),
|
||||
"class_name": str(info.get("class_name", "")),
|
||||
"hint": int(info.get("hint", PROPERTY_HINT_NONE)),
|
||||
"hint_string": str(info.get("hint_string", "")),
|
||||
"usage": int(info.get("usage", 0)),
|
||||
}
|
||||
|
||||
|
||||
static func _enums(type_name: String, include_inherited: bool) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var enum_names: Array[String] = []
|
||||
for enum_name in ClassDB.class_get_enum_list(type_name, not include_inherited):
|
||||
enum_names.append(str(enum_name))
|
||||
enum_names.sort()
|
||||
for enum_name in enum_names:
|
||||
var values: Array[Dictionary] = []
|
||||
for constant_name in ClassDB.class_get_enum_constants(type_name, enum_name, not include_inherited):
|
||||
values.append({
|
||||
"name": str(constant_name),
|
||||
"value": ClassDB.class_get_integer_constant(type_name, constant_name),
|
||||
})
|
||||
values.sort_custom(func(a, b): return a.name < b.name)
|
||||
result.append({
|
||||
"name": enum_name,
|
||||
"is_bitfield": ClassDB.is_class_enum_bitfield(type_name, enum_name, not include_inherited),
|
||||
"values": values,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
static func _unscoped_constants(type_name: String, include_inherited: bool) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for constant_name in ClassDB.class_get_integer_constant_list(type_name, not include_inherited):
|
||||
var enum_name := str(
|
||||
ClassDB.class_get_integer_constant_enum(type_name, constant_name, not include_inherited)
|
||||
)
|
||||
if not enum_name.is_empty():
|
||||
continue
|
||||
result.append({
|
||||
"name": str(constant_name),
|
||||
"value": ClassDB.class_get_integer_constant(type_name, constant_name),
|
||||
})
|
||||
result.sort_custom(func(a, b): return a.name < b.name)
|
||||
return result
|
||||
@@ -1 +0,0 @@
|
||||
uid://caedbsmsl6fk4
|
||||
@@ -1,66 +0,0 @@
|
||||
@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 {}
|
||||
@@ -1 +0,0 @@
|
||||
uid://b3npxxpuobbc2
|
||||
@@ -1,127 +0,0 @@
|
||||
@tool
|
||||
class_name McpEditorLogBuffer
|
||||
extends McpStructuredLogRing
|
||||
|
||||
## Ring buffer for editor-process script errors and warnings (parse errors,
|
||||
## @tool runtime errors, EditorPlugin errors, push_error/push_warning) captured
|
||||
## by editor_logger.gd's Logger subclass.
|
||||
##
|
||||
## Smaller cap than McpGameLogBuffer (500 vs 2000) — the editor only emits errors,
|
||||
## not the full println firehose a game can produce. No run_id rotation: editor
|
||||
## errors persist across project_run cycles (they're about *editing* state, not
|
||||
## about the playing game).
|
||||
##
|
||||
## Mutex-protected because Logger virtuals can fire from any thread (e.g.
|
||||
## async script-loader threads emitting parse errors), and the buffer is
|
||||
## read on the main thread by EditorHandler.get_logs. Each public method
|
||||
## wraps the base ring's lockless helpers in `_mutex.lock()/unlock()` —
|
||||
## the base stays lockless so McpGameLogBuffer's hot path doesn't pay an
|
||||
## unused mutex cost.
|
||||
##
|
||||
## Entry shape: {source: "editor", level: "info"|"warn"|"error",
|
||||
## text, path, line, function} — `path/line/function` may be empty/zero
|
||||
## when the source location wasn't recoverable (e.g. printerr from a
|
||||
## thread without a script context).
|
||||
|
||||
const MAX_LINES := 500
|
||||
|
||||
var _mutex := Mutex.new()
|
||||
var _error_appended_total := 0
|
||||
var _warn_appended_total := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
super._init(MAX_LINES)
|
||||
|
||||
|
||||
func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void:
|
||||
var coerced_level := _coerce_level(level)
|
||||
var entry := {
|
||||
"source": "editor",
|
||||
"level": coerced_level,
|
||||
"text": text,
|
||||
"path": path,
|
||||
"line": line,
|
||||
"function": function,
|
||||
}
|
||||
if not details.is_empty():
|
||||
entry["details"] = details.duplicate(true)
|
||||
_mutex.lock()
|
||||
_append_entry(entry)
|
||||
if coerced_level == "error":
|
||||
_error_appended_total += 1
|
||||
elif coerced_level == "warn":
|
||||
_warn_appended_total += 1
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
func get_range(offset: int, count: int) -> Array[Dictionary]:
|
||||
_mutex.lock()
|
||||
var out := _get_range_unlocked(offset, count)
|
||||
_mutex.unlock()
|
||||
return out
|
||||
|
||||
|
||||
func get_recent(count: int) -> Array[Dictionary]:
|
||||
## Single-lock so the size we compute `start` from can't race against
|
||||
## a concurrent append between the size read and the slice copy.
|
||||
_mutex.lock()
|
||||
var size := _total_count_unlocked()
|
||||
var start := maxi(0, size - count)
|
||||
var out := _get_range_unlocked(start, size - start)
|
||||
_mutex.unlock()
|
||||
return out
|
||||
|
||||
|
||||
func get_since(since_seq: int, limit: int = -1) -> Dictionary:
|
||||
## Single-lock so the cursor snapshot and slice copy can't race against a
|
||||
## Logger-thread append.
|
||||
_mutex.lock()
|
||||
var out := _get_since_unlocked(since_seq, limit)
|
||||
_mutex.unlock()
|
||||
return out
|
||||
|
||||
|
||||
func total_count() -> int:
|
||||
_mutex.lock()
|
||||
var n := _total_count_unlocked()
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
|
||||
func dropped_count() -> int:
|
||||
_mutex.lock()
|
||||
var n := _dropped_count_unlocked()
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
|
||||
func appended_total() -> int:
|
||||
_mutex.lock()
|
||||
var n := _appended_total_unlocked()
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
|
||||
func error_appended_total() -> int:
|
||||
_mutex.lock()
|
||||
var n := _error_appended_total
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
|
||||
func warn_appended_total() -> int:
|
||||
_mutex.lock()
|
||||
var n := _warn_appended_total
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
|
||||
func clear() -> int:
|
||||
_mutex.lock()
|
||||
var n := _total_count_unlocked()
|
||||
_clear_storage()
|
||||
_error_appended_total = 0
|
||||
_warn_appended_total = 0
|
||||
_mutex.unlock()
|
||||
return n
|
||||
@@ -1 +0,0 @@
|
||||
uid://b6ynms0856hhq
|
||||
@@ -1,163 +0,0 @@
|
||||
@tool
|
||||
class_name McpErrorCodes
|
||||
extends RefCounted
|
||||
|
||||
## Error code constants shared across handlers. Mirrors protocol/errors.py.
|
||||
##
|
||||
## This `class_name` shipped in v2.3.2 and earlier and must stay reachable
|
||||
## through self-update. v2.4.1 dropped it and triggered a "Could not resolve
|
||||
## script" cascade for every user upgrading from any earlier version; v2.4.2
|
||||
## restored it as a hot-fix. The cascade fires because Godot keeps stale
|
||||
## registry entries during the disable -> extract -> enable window when a
|
||||
## previously-registered class_name disappears, and that failure mode is
|
||||
## independent of the runner's install ordering. See CLAUDE.md's
|
||||
## never-delete-published-class_name policy for the shape-aware shim path
|
||||
## that retirement (if ever needed) must follow.
|
||||
##
|
||||
## All consumers use the preload-alias pattern
|
||||
## (`const ErrorCodes := preload(...)`) introduced in #412. The alias is
|
||||
## stylistic; both `McpErrorCodes.X` and `ErrorCodes.X` resolve through the
|
||||
## same Script object cache, so the alias is not a parse-safety boundary
|
||||
## under the single-phase runner.
|
||||
|
||||
const INVALID_PARAMS := "INVALID_PARAMS"
|
||||
const EDITED_SCENE_MISMATCH := "EDITED_SCENE_MISMATCH"
|
||||
const EDITOR_NOT_READY := "EDITOR_NOT_READY"
|
||||
const UNKNOWN_COMMAND := "UNKNOWN_COMMAND"
|
||||
const INTERNAL_ERROR := "INTERNAL_ERROR"
|
||||
const DEFERRED_TIMEOUT := "DEFERRED_TIMEOUT"
|
||||
## Python-originated attach bridge codes. GDScript has no emit path, but the
|
||||
## public registry intentionally mirrors protocol/errors.py.
|
||||
const TRANSPORT_OUTCOME_UNKNOWN := "TRANSPORT_OUTCOME_UNKNOWN"
|
||||
const NEW_CLIENT_SESSION_REQUIRED := "NEW_CLIENT_SESSION_REQUIRED"
|
||||
const ATTACH_LOCK_TIMEOUT := "ATTACH_LOCK_TIMEOUT"
|
||||
const ATTACH_LOCK_ERROR := "ATTACH_LOCK_ERROR"
|
||||
const ATTACH_RUNTIME_DIR_ERROR := "ATTACH_RUNTIME_DIR_ERROR"
|
||||
const PORT_OCCUPIED := "PORT_OCCUPIED"
|
||||
const BACKEND_START_FAILED := "BACKEND_START_FAILED"
|
||||
const BACKEND_START_TIMEOUT := "BACKEND_START_TIMEOUT"
|
||||
# game_eval failure codes (#490) — keep in sync with protocol/errors.py
|
||||
const EVAL_COMPILE_ERROR := "EVAL_COMPILE_ERROR"
|
||||
const EVAL_RUNTIME_ERROR := "EVAL_RUNTIME_ERROR"
|
||||
## #518/#859: the play session is up (EditorInterface.is_playing_scene() is true,
|
||||
## so editor_handler's EDITOR_NOT_READY "game is not running" gate already
|
||||
## passed) but the game cannot service evals: the helper did not register within
|
||||
## EVAL_READY_WAIT_SEC, its main-loop beacon is stale, or its debugger session
|
||||
## closed mid-eval. Carved out of INTERNAL_ERROR so these fast,
|
||||
## caller-actionable failures do not burn the opaque 10s eval backstop.
|
||||
const EVAL_GAME_NOT_READY := "EVAL_GAME_NOT_READY"
|
||||
## #518: the eval genuinely never finished inside the timeout ladder — the
|
||||
## game-side 8s deadline aborted a hung await, or the editor-side 10s backstop
|
||||
## fired because the game never replied at all (CPU-bound loop, frozen /
|
||||
## backgrounded idle loop). Carved out of INTERNAL_ERROR — the last big
|
||||
## still-unlabeled bucket from #487/#488 — so "your eval code never finished"
|
||||
## stops reading as an internal fault in telemetry and agent-facing errors.
|
||||
const EVAL_HUNG := "EVAL_HUNG"
|
||||
## #518: the eval completed but its serialized result is too large for the
|
||||
## debugger + WebSocket pipeline. Without this the reply is dropped silently
|
||||
## (the debugger TCP peer discards messages over ~8 MiB) and the request rides
|
||||
## to the 10s backstop as a phantom "hang". Failing fast game-side with the
|
||||
## real byte count makes the failure actionable (return a smaller slice).
|
||||
const EVAL_RESULT_TOO_LARGE := "EVAL_RESULT_TOO_LARGE"
|
||||
## #777: a game-side request (currently editor_screenshot source="game")
|
||||
## reached a live, registered game helper but no reply came back before the
|
||||
## editor-side timer fired. Every editor gate already passed
|
||||
## (is_playing_scene, helper hello) so this is a TOP-LEVEL code, not an
|
||||
## EDITOR_NOT_READY sub-code: the game process itself failed to respond —
|
||||
## backgrounded with a frozen main loop and nothing rendered to fall back
|
||||
## on, main thread blocked, or the helper died mid-run. Carved out of
|
||||
## INTERNAL_ERROR (the largest opaque timeout bucket fleet-wide) so the
|
||||
## residual timeout is attributable and actionable.
|
||||
const GAME_HELPER_TIMEOUT := "GAME_HELPER_TIMEOUT"
|
||||
## audit-v2 #21 (issue #365): finer-grained codes carved out of the 471
|
||||
## INVALID_PARAMS sites so agents can distinguish recoverable input
|
||||
## errors from structural ones. INVALID_PARAMS stays for genuinely
|
||||
## catch-all input errors that don't fit any of the buckets below.
|
||||
##
|
||||
## - NODE_NOT_FOUND: scene-tree/autoload node lookup failed (path didn't
|
||||
## resolve to a Node).
|
||||
## - RESOURCE_NOT_FOUND: a `res://` path lookup failed (file/.tres/
|
||||
## .gdshader/.tscn etc. doesn't exist or couldn't load). Distinct from
|
||||
## NODE_NOT_FOUND because the recovery path differs — agents need to
|
||||
## know whether to fix a node path vs. create/import a resource.
|
||||
## - PROPERTY_NOT_ON_CLASS: property/signal/method/uniform/slot lookup
|
||||
## failed on a known instance (path resolved, but the requested
|
||||
## member doesn't exist on that class).
|
||||
## - VALUE_OUT_OF_RANGE: numeric/index bound violation OR enum value
|
||||
## not in the allowed set.
|
||||
## - WRONG_TYPE: input was a value (or a loaded resource) of the wrong
|
||||
## type — the param was provided, but `typeof` or `is X` failed.
|
||||
## - MISSING_REQUIRED_PARAM: required input field was absent or empty.
|
||||
const NODE_NOT_FOUND := "NODE_NOT_FOUND"
|
||||
const RESOURCE_NOT_FOUND := "RESOURCE_NOT_FOUND"
|
||||
const PROPERTY_NOT_ON_CLASS := "PROPERTY_NOT_ON_CLASS"
|
||||
const VALUE_OUT_OF_RANGE := "VALUE_OUT_OF_RANGE"
|
||||
const WRONG_TYPE := "WRONG_TYPE"
|
||||
const MISSING_REQUIRED_PARAM := "MISSING_REQUIRED_PARAM"
|
||||
const CUSTOM_TOOL_NOT_UNDOABLE := "CUSTOM_TOOL_NOT_UNDOABLE"
|
||||
const CUSTOM_TOOL_DISABLED := "CUSTOM_TOOL_DISABLED"
|
||||
|
||||
## #651 stage 1: EDITOR_NOT_READY sub-codes. These travel in
|
||||
## `error.data.sub_code`, NEVER as the top-level `error.code` — existing
|
||||
## callers and dashboards key on EDITOR_NOT_READY, so the top-level code is
|
||||
## frozen. Each sub-code names the concrete editor state at rejection time,
|
||||
## limited to states EditorInterface/EditorFileSystem can report
|
||||
## deterministically. States we cannot observe (script compilation,
|
||||
## resource reload, modal dialogs) intentionally get NO sub-code: a bare
|
||||
## EDITOR_NOT_READY stays the honest fallback rather than a guessed label.
|
||||
## Keep in sync with protocol/errors.py::EditorNotReadySubCode — enforced
|
||||
## by tests/unit/test_editor_not_ready_hint_contract.py.
|
||||
const SUB_EDITOR_IMPORTING := "EDITOR_IMPORTING"
|
||||
const SUB_EDITOR_PLAYING := "EDITOR_PLAYING"
|
||||
const SUB_EDITOR_NO_SCENE := "EDITOR_NO_SCENE"
|
||||
const SUB_EDITOR_GAME_NOT_RUNNING := "EDITOR_GAME_NOT_RUNNING"
|
||||
const SUB_EDITOR_VIEWPORT_UNAVAILABLE := "EDITOR_VIEWPORT_UNAVAILABLE"
|
||||
const SUB_EDITOR_VIEWPORT_NOT_3D := "EDITOR_VIEWPORT_NOT_3D"
|
||||
const SUB_EDITOR_VIEWPORT_EMPTY := "EDITOR_VIEWPORT_EMPTY"
|
||||
const SUB_EDITOR_UNAVAILABLE := "EDITOR_UNAVAILABLE"
|
||||
## Emitted only by the exclusive-run transport servicing path: a command
|
||||
## arrived while a synchronous test run holds the main thread, and was
|
||||
## rejected (not buffered) so it can't replay stale after its server-side
|
||||
## future expires. See connection.gd::service_transport_during_exclusive_run.
|
||||
const SUB_EDITOR_TEST_RUNNING := "EDITOR_TEST_RUNNING"
|
||||
|
||||
## Terminal code for a test run that hit its between-test abort ceiling
|
||||
## before finishing. error.data carries the partial summary; full partial
|
||||
## results stay retrievable via get_test_results.
|
||||
const TEST_RUN_TIMEOUT := "TEST_RUN_TIMEOUT"
|
||||
|
||||
|
||||
## Build a standard error response dictionary.
|
||||
static func make(code: String, message: String) -> Dictionary:
|
||||
return {"status": "error", "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
## Build an EDITOR_NOT_READY error carrying the #651 stage-1 attribution
|
||||
## payload: `data.sub_code` + `retryable` + `hint`. Mirrors the shape
|
||||
## scene_path.gd::require_edited_scene established (editor_state/retryable/
|
||||
## hint). `hint` may be empty when `message` already IS the recovery hint —
|
||||
## the server's GodotCommandError string-appends every data key, so
|
||||
## duplicating the message into data would double the agent-visible text.
|
||||
static func make_not_ready(
|
||||
sub_code: String, message: String, retryable: bool, hint: String = ""
|
||||
) -> Dictionary:
|
||||
var err := make(EDITOR_NOT_READY, message)
|
||||
var data := {"sub_code": sub_code, "retryable": retryable}
|
||||
if not hint.is_empty():
|
||||
data["hint"] = hint
|
||||
err["error"]["data"] = data
|
||||
return err
|
||||
|
||||
|
||||
## Return a NEW error dict with the original code and a prefixed message.
|
||||
## Prefer this over mutating `err["error"]["message"]` in place — callers
|
||||
## that want to add context ("Property '%s': …") shouldn't need to know
|
||||
## the internal shape of the dict returned by `make`. Empty `prefix`
|
||||
## returns `err` unchanged so callers don't need their own guard.
|
||||
static func prefix_message(err: Dictionary, prefix: String) -> Dictionary:
|
||||
if prefix.is_empty():
|
||||
return err
|
||||
var inner: Dictionary = err.get("error", {})
|
||||
var code: String = inner.get("code", INTERNAL_ERROR)
|
||||
var message: String = inner.get("message", "")
|
||||
return make(code, "%s: %s" % [prefix, message])
|
||||
@@ -1 +0,0 @@
|
||||
uid://d2klnglf5p861
|
||||
@@ -1,39 +0,0 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Shared fuzzy ranking for typo suggestions.
|
||||
|
||||
|
||||
static func rank(
|
||||
needle: String,
|
||||
candidates: Array,
|
||||
limit: int = 5,
|
||||
threshold: float = 0.4,
|
||||
substring_bonus: float = 0.5,
|
||||
prefix_bonus: float = 1.0
|
||||
) -> Array[String]:
|
||||
if needle.is_empty() or candidates.is_empty():
|
||||
return []
|
||||
var needle_lower := needle.to_lower()
|
||||
var scored: Array = []
|
||||
for raw_candidate in candidates:
|
||||
var candidate := str(raw_candidate)
|
||||
var candidate_lower := candidate.to_lower()
|
||||
var score := needle.similarity(candidate)
|
||||
if prefix_bonus != 0.0 and candidate_lower.begins_with(needle_lower):
|
||||
score += prefix_bonus
|
||||
elif substring_bonus != 0.0 and (
|
||||
candidate_lower.contains(needle_lower) or needle_lower.contains(candidate_lower)
|
||||
):
|
||||
score += substring_bonus
|
||||
if score >= threshold:
|
||||
scored.append([score, candidate])
|
||||
scored.sort_custom(func(a, b):
|
||||
if a[0] == b[0]:
|
||||
return a[1] < b[1]
|
||||
return a[0] > b[0]
|
||||
)
|
||||
var result: Array[String] = []
|
||||
for index in range(min(limit, scored.size())):
|
||||
result.append(scored[index][1])
|
||||
return result
|
||||
@@ -1 +0,0 @@
|
||||
uid://bxwaws6w0xw60
|
||||
@@ -1,105 +0,0 @@
|
||||
@tool
|
||||
class_name McpGameLogBuffer
|
||||
extends McpStructuredLogRing
|
||||
|
||||
## Ring buffer for game-process log lines (print, push_warning, push_error)
|
||||
## ferried back from the playing game over the EngineDebugger channel.
|
||||
##
|
||||
## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id`
|
||||
## rotates at play-start, giving agents a stable cursor for "lines from
|
||||
## this run" even when the game never reaches the mcp:hello boot beacon.
|
||||
##
|
||||
## Single-threaded — game_helper.gd drains its logger from `_process` and
|
||||
## calls `append` from the main thread, so this subclass can use the base
|
||||
## ring's lockless reads/writes directly.
|
||||
|
||||
const MAX_LINES := 2000
|
||||
|
||||
var _run_id := ""
|
||||
var _run_seq := 0
|
||||
var _error_warn_total := 0
|
||||
var _error_total := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
super._init(MAX_LINES)
|
||||
|
||||
|
||||
func append(level: String, text: String, details: Dictionary = {}) -> void:
|
||||
var coerced_level := _coerce_level(level)
|
||||
var entry := {
|
||||
"source": "game",
|
||||
"level": coerced_level,
|
||||
"text": text,
|
||||
"run_id": _run_id,
|
||||
}
|
||||
if not details.is_empty():
|
||||
entry["details"] = details.duplicate(true)
|
||||
_append_entry(entry)
|
||||
if coerced_level in ["warn", "error"]:
|
||||
_error_warn_total += 1
|
||||
if coerced_level == "error":
|
||||
_error_total += 1
|
||||
|
||||
|
||||
## Rotate the run identifier without dropping buffered entries. Called at
|
||||
## play-start so even no-hello parse failures get a fresh current-run identity.
|
||||
## Historical lines stay tagged with their original run_id and can still be
|
||||
## queried explicitly.
|
||||
func clear_for_new_run() -> String:
|
||||
_run_id = _generate_run_id()
|
||||
_error_warn_total = 0
|
||||
_error_total = 0
|
||||
return _run_id
|
||||
|
||||
|
||||
func run_id() -> String:
|
||||
return _run_id
|
||||
|
||||
|
||||
func error_warn_total() -> int:
|
||||
return _error_warn_total
|
||||
|
||||
|
||||
func error_total() -> int:
|
||||
return _error_total
|
||||
|
||||
|
||||
## Warn-level lines for the current run: the combined error+warn tally minus
|
||||
## the error-only tally. Feeds the `game_warn` watermark component so a run
|
||||
## that only emitted push_warning is no longer reported as clean.
|
||||
func warn_total() -> int:
|
||||
return _error_warn_total - _error_total
|
||||
|
||||
|
||||
func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]:
|
||||
return get_run_page(run_id, offset, count).entries
|
||||
|
||||
|
||||
func get_run_page(run_id: String, offset: int, count: int) -> Dictionary:
|
||||
var entries := _entries_for_run(run_id)
|
||||
var start := mini(maxi(0, offset), entries.size())
|
||||
var stop := mini(entries.size(), start + maxi(0, count))
|
||||
var out: Array[Dictionary] = []
|
||||
for i in range(start, stop):
|
||||
out.append(entries[i])
|
||||
return {
|
||||
"entries": out,
|
||||
"total_count": entries.size(),
|
||||
}
|
||||
|
||||
|
||||
func _entries_for_run(run_id: String) -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
for entry in get_range(0, total_count()):
|
||||
if str(entry.get("run_id", "")) == run_id:
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
func _generate_run_id() -> String:
|
||||
## Opaque to agents — they only check equality. Time-based is plenty
|
||||
## unique within a single editor session; the local sequence protects
|
||||
## fast back-to-back test runs within the same millisecond.
|
||||
_run_seq += 1
|
||||
return "r%d-%d" % [Time.get_ticks_msec(), _run_seq]
|
||||
@@ -1 +0,0 @@
|
||||
uid://biojw0xl64haw
|
||||
@@ -1,92 +0,0 @@
|
||||
@tool
|
||||
class_name McpJsonValues
|
||||
extends RefCounted
|
||||
|
||||
## Canonical JSON→Variant parsers for the wire shapes agents send.
|
||||
##
|
||||
## One parser family instead of five drifted per-handler copies (#714) —
|
||||
## the canonical color set is the maintainer decision recorded on that
|
||||
## issue. parse_color accepts: Color passthrough; "#rrggbb"/"#rrggbbaa"
|
||||
## hex or named-color strings (two-sentinel Color.from_string
|
||||
## validation); {r,g,b[,a]} dicts; [r,g,b[,a]] arrays. parse_vector2/3
|
||||
## accept the Vector passthrough, {x,y[,z]} dicts, and [x,y[,z]] arrays.
|
||||
##
|
||||
## Strict WITHIN each shape (the #123/#126 contract): wrong dict keys,
|
||||
## wrong array lengths, or non-numeric components return null instead of
|
||||
## guessing zeros — callers turn null into their own typed error.
|
||||
|
||||
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
|
||||
const VECTOR2_KEYS: Array[String] = ["x", "y"]
|
||||
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
|
||||
|
||||
|
||||
static func parse_color(value: Variant) -> Variant:
|
||||
if value is Color:
|
||||
return value
|
||||
if value is String:
|
||||
## Color.from_string returns the fallback on parse failure — call
|
||||
## twice with distinct sentinels; agreement means a real parse.
|
||||
var a := Color.from_string(value, Color(0, 0, 0, 0))
|
||||
var b := Color.from_string(value, Color(1, 1, 1, 1))
|
||||
if a != b:
|
||||
return null
|
||||
return a
|
||||
if value is Dictionary:
|
||||
var d: Dictionary = value
|
||||
if not d.has_all(COLOR_KEYS):
|
||||
return null
|
||||
var alpha: Variant = d.get("a", 1.0)
|
||||
if not (_is_number(d.r) and _is_number(d.g) and _is_number(d.b) and _is_number(alpha)):
|
||||
return null
|
||||
return Color(float(d.r), float(d.g), float(d.b), float(alpha))
|
||||
if value is Array:
|
||||
var arr: Array = value
|
||||
if arr.size() != 3 and arr.size() != 4:
|
||||
return null
|
||||
for item in arr:
|
||||
if not _is_number(item):
|
||||
return null
|
||||
var a4 := float(arr[3]) if arr.size() == 4 else 1.0
|
||||
return Color(float(arr[0]), float(arr[1]), float(arr[2]), a4)
|
||||
return null
|
||||
|
||||
|
||||
static func parse_vector2(value: Variant) -> Variant:
|
||||
if value is Vector2:
|
||||
return value
|
||||
if value is Dictionary:
|
||||
var d: Dictionary = value
|
||||
if not d.has_all(VECTOR2_KEYS) or not (_is_number(d.x) and _is_number(d.y)):
|
||||
return null
|
||||
return Vector2(float(d.x), float(d.y))
|
||||
if value is Array:
|
||||
var arr: Array = value
|
||||
if arr.size() != 2 or not (_is_number(arr[0]) and _is_number(arr[1])):
|
||||
return null
|
||||
return Vector2(float(arr[0]), float(arr[1]))
|
||||
return null
|
||||
|
||||
|
||||
static func parse_vector3(value: Variant) -> Variant:
|
||||
if value is Vector3:
|
||||
return value
|
||||
if value is Dictionary:
|
||||
var d: Dictionary = value
|
||||
if not d.has_all(VECTOR3_KEYS):
|
||||
return null
|
||||
if not (_is_number(d.x) and _is_number(d.y) and _is_number(d.z)):
|
||||
return null
|
||||
return Vector3(float(d.x), float(d.y), float(d.z))
|
||||
if value is Array:
|
||||
var arr: Array = value
|
||||
if arr.size() != 3:
|
||||
return null
|
||||
for item in arr:
|
||||
if not _is_number(item):
|
||||
return null
|
||||
return Vector3(float(arr[0]), float(arr[1]), float(arr[2]))
|
||||
return null
|
||||
|
||||
|
||||
static func _is_number(v: Variant) -> bool:
|
||||
return v is int or v is float
|
||||
@@ -1 +0,0 @@
|
||||
uid://4wbf83hwckms
|
||||
@@ -1,113 +0,0 @@
|
||||
@tool
|
||||
class_name McpLogBacktrace
|
||||
extends RefCounted
|
||||
|
||||
## Helpers for interpreting Godot's `_log_error` virtual arguments.
|
||||
## (Named `McpLogBacktrace`, not `ScriptBacktrace`: Godot ships a built-in
|
||||
## `ScriptBacktrace` class — the type of `script_backtraces[i]` entries
|
||||
## — so class_name'ing ours the same would collide. Verified against
|
||||
## the engine's `--doctool` output in 4.6.)
|
||||
##
|
||||
## Both `editor_logger.gd` and `game_logger.gd` need to:
|
||||
## - Map `error_type` (0=ERROR, 1=WARNING, 2=SCRIPT, 3=SHADER) to a
|
||||
## two-bucket "error" / "warn" string so callers can filter without
|
||||
## consulting the enum.
|
||||
## - Fall back to `code` when `rationale` is empty — single-arg
|
||||
## `push_error("msg")` leaves rationale empty and stuffs the user's
|
||||
## string into `code`; without the fallback the user message is
|
||||
## silently lost. The two-arg form `push_error(code, rationale)`
|
||||
## populates both and rationale wins.
|
||||
## - Remap the source location to the first frame of `script_backtraces[0]`
|
||||
## when present. `push_error` / `push_warning` always report
|
||||
## `file=core/variant/variant_utility.cpp`; the actual user GDScript
|
||||
## caller is in the backtrace.
|
||||
##
|
||||
## Centralising the rules keeps the next push_error semantics shift
|
||||
## (already happened once between 4.5 and 4.6, see PR #78) a one-place
|
||||
## fix instead of a two-place hunt.
|
||||
|
||||
|
||||
## Coalesce the per-virtual-arg shape Godot hands `_log_error` into a
|
||||
## flat record. Always walks `script_backtraces` for the first non-empty
|
||||
## frame; loggers that need to filter by source path call this first and
|
||||
## then check the resolved `path` field.
|
||||
##
|
||||
## Returns: `{level, message, path, line, function, details}`
|
||||
## - `level`: "error" or "warn" (warn iff `error_type == 1`).
|
||||
## - `message`: `rationale` when non-empty, else `code`.
|
||||
## - `path` / `line` / `function`: first backtrace frame when one is
|
||||
## available; otherwise the original `file` / `line` / `function`.
|
||||
## - `details`: original `_log_error` fields plus the first non-empty
|
||||
## backtrace as frames, mirroring the debugger Errors tab context.
|
||||
const ERROR_TYPE_NAMES := {
|
||||
0: "error",
|
||||
1: "warning",
|
||||
2: "script",
|
||||
3: "shader",
|
||||
}
|
||||
|
||||
|
||||
static func resolve_error(
|
||||
function: String,
|
||||
file: String,
|
||||
line: int,
|
||||
code: String,
|
||||
rationale: String,
|
||||
error_type: int,
|
||||
script_backtraces: Array,
|
||||
) -> Dictionary:
|
||||
var src_file := file
|
||||
var src_line := line
|
||||
var src_function := function
|
||||
var frames: Array[Dictionary] = []
|
||||
## First non-empty frame wins, not just `script_backtraces[0]` —
|
||||
## chained errors can leave the leading entry empty with the actual
|
||||
## user frame in `script_backtraces[1]`.
|
||||
for bt in script_backtraces:
|
||||
if bt != null and bt.get_frame_count() > 0:
|
||||
frames = _frames_from_backtrace(bt)
|
||||
src_file = str(frames[0].get("path", ""))
|
||||
src_line = int(frames[0].get("line", 0))
|
||||
src_function = str(frames[0].get("function", ""))
|
||||
break
|
||||
var message := rationale if not rationale.is_empty() else code
|
||||
return {
|
||||
"level": "warn" if error_type == 1 else "error",
|
||||
"message": message,
|
||||
"path": src_file,
|
||||
"line": src_line,
|
||||
"function": src_function,
|
||||
"details": {
|
||||
"message": message,
|
||||
"code": code,
|
||||
"rationale": rationale,
|
||||
"error_type": error_type,
|
||||
"error_type_name": _error_type_name(error_type),
|
||||
"source": {
|
||||
"path": file,
|
||||
"line": line,
|
||||
"function": function,
|
||||
},
|
||||
"resolved": {
|
||||
"path": src_file,
|
||||
"line": src_line,
|
||||
"function": src_function,
|
||||
},
|
||||
"frames": frames,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
static func _frames_from_backtrace(bt) -> Array[Dictionary]:
|
||||
var frames: Array[Dictionary] = []
|
||||
for i in bt.get_frame_count():
|
||||
frames.append({
|
||||
"path": bt.get_frame_file(i),
|
||||
"line": bt.get_frame_line(i),
|
||||
"function": bt.get_frame_function(i),
|
||||
})
|
||||
return frames
|
||||
|
||||
|
||||
static func _error_type_name(error_type: int) -> String:
|
||||
return str(ERROR_TYPE_NAMES.get(error_type, "unknown"))
|
||||
@@ -1 +0,0 @@
|
||||
uid://b8t9kznr2pqxa
|
||||
@@ -1,67 +0,0 @@
|
||||
@tool
|
||||
class_name McpLogBuffer
|
||||
extends RefCounted
|
||||
|
||||
## Ring buffer for MCP log lines. Also prints to Godot console.
|
||||
|
||||
const MAX_LINES := 500
|
||||
|
||||
## When false, `log()` still records into the ring buffer but does not echo the
|
||||
## line to the Godot console. The test runner flips this off for the duration
|
||||
## of a run so negative-path suites (which intentionally drive a 500-line ring
|
||||
## fill and malformed-result error logging) don't bury an all-green run in
|
||||
## console noise. Ring *contents* — what tests assert on via `get_recent()` /
|
||||
## `total_logged()` — are unaffected. Engine-level C++ errors raised by
|
||||
## negative-path tests are not routed through here and still surface.
|
||||
static var console_echo := true
|
||||
|
||||
var _lines: Array[String] = []
|
||||
## Monotonic count of every line ever passed to `log()` since the last
|
||||
## `clear()`. Distinct from `_lines.size()`, which is bounded at MAX_LINES.
|
||||
## Consumers that need to detect "new lines arrived" (e.g. `LogViewer.tick`)
|
||||
## must track this rather than the bounded size — once the ring fills, the
|
||||
## size stays at MAX_LINES on every subsequent append, so a size-based
|
||||
## cursor would freeze and the consumer would stop seeing new entries.
|
||||
var _total_logged: int = 0
|
||||
var enabled := true
|
||||
|
||||
|
||||
## `echo=false` records the line into the ring (so the dock's log panel shows
|
||||
## it) without printing to the Godot console. Used for high-frequency
|
||||
## machine-driven lines like readiness flips, which spammed the console of
|
||||
## every install (#626) — filesystem scans toggle readiness on each import.
|
||||
func log(msg: String, echo: bool = true) -> void:
|
||||
var line := "MCP | %s" % msg
|
||||
if enabled and console_echo and echo:
|
||||
print(line)
|
||||
_lines.append(line)
|
||||
if _lines.size() > MAX_LINES:
|
||||
_lines = _lines.slice(-MAX_LINES)
|
||||
_total_logged += 1
|
||||
|
||||
|
||||
func get_recent(count: int = 50) -> Array[String]:
|
||||
var start := maxi(0, _lines.size() - count)
|
||||
var result: Array[String] = []
|
||||
result.assign(_lines.slice(start))
|
||||
return result
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
_lines.clear()
|
||||
## Reset the monotonic counter so a viewer's `seq < _last_seq` shrink
|
||||
## detection still recognizes the clear. Callers that want a cumulative
|
||||
## ever-produced count across clears can wrap their own counter.
|
||||
_total_logged = 0
|
||||
|
||||
|
||||
func total_count() -> int:
|
||||
return _lines.size()
|
||||
|
||||
|
||||
## Monotonic sequence — number of lines ever appended via `log()` since
|
||||
## the last `clear()`. Strictly increases per append, even once the ring
|
||||
## has filled and `total_count()` is pinned at MAX_LINES. See `_total_logged`
|
||||
## for rationale.
|
||||
func total_logged() -> int:
|
||||
return _total_logged
|
||||
@@ -1 +0,0 @@
|
||||
uid://ddkslse7511e6
|
||||
@@ -1,23 +0,0 @@
|
||||
@tool
|
||||
class_name McpAdoptionLabel
|
||||
extends RefCounted
|
||||
|
||||
## Outcome flag for `McpServerLifecycleManager.adopt_compatible_server`.
|
||||
## Distinguishes a same-version managed adoption (we own the PID, can
|
||||
## restart it) from an external compatible adoption (some other plugin
|
||||
## instance / dev server owns the process; we just rendezvoused with it).
|
||||
##
|
||||
## Was a free-form string in PR 5; promoted to constants here because
|
||||
## the seam now spans `server_lifecycle.gd`, `plugin.gd`'s log helper,
|
||||
## the dock's restart-button gating, and the test suite. Stable strings
|
||||
## keep log scrapes and characterization fixtures unaffected.
|
||||
|
||||
## We have a PID we spawned (or re-acquired by reading the managed
|
||||
## record + verifying liveness). `force_restart_server` and
|
||||
## `prepare_for_update_reload` may target this PID.
|
||||
const MANAGED := "managed"
|
||||
|
||||
## A compatible godot-ai server is on the port but we don't own its
|
||||
## PID — likely another plugin instance's spawn, or a developer-run
|
||||
## `godot-ai --reload` server. We reuse it but won't kill it on stop.
|
||||
const EXTERNAL := "external"
|
||||
@@ -1 +0,0 @@
|
||||
uid://klhsu1cuhcue
|
||||
@@ -1,107 +0,0 @@
|
||||
@tool
|
||||
class_name McpClientRefreshState
|
||||
extends RefCounted
|
||||
|
||||
## State machine for the dock's client-status refresh sweep. Single
|
||||
## source of truth — supersedes the seven booleans + deadline previously
|
||||
## scattered across `mcp_dock.gd` (`_client_status_refresh_in_flight`,
|
||||
## `_client_status_refresh_pending`, `_client_status_refresh_pending_force`,
|
||||
## `_client_status_refresh_timed_out`, `_client_status_refresh_started_msec`,
|
||||
## `_client_status_refresh_deferred_until_filesystem_ready`,
|
||||
## `_client_status_refresh_deferred_force`,
|
||||
## `_client_status_refresh_deferred_initial`,
|
||||
## `_client_status_refresh_shutdown_requested`).
|
||||
##
|
||||
## The ints are stable for tests; reordering is a breaking change.
|
||||
|
||||
## No worker running, no pending request. Default state.
|
||||
const IDLE := 0
|
||||
## A refresh request landed but the editor filesystem is busy
|
||||
## (`EditorInterface.get_resource_filesystem().is_scanning()` is true);
|
||||
## the dock parks the request and retries on the next `_process` after
|
||||
## the scan settles. Held alongside two flags (force / initial) for
|
||||
## what kind of refresh to retry; those live next to the state, not
|
||||
## inside it, because they're requests not state.
|
||||
const DEFERRED_FOR_FILESYSTEM := 1
|
||||
## Worker thread is alive and probing client status off-main. The
|
||||
## dock paints "(checking...)" in the clients summary and accepts
|
||||
## additional requests as `pending`.
|
||||
const RUNNING := 2
|
||||
## Worker has been alive past CLIENT_STATUS_REFRESH_TIMEOUT_MSEC. The
|
||||
## dock paints "(client probe still running)" and a forced refresh is
|
||||
## allowed to abandon the worker into the orphan list and start a new
|
||||
## sweep. The state stays RUNNING after a forced abandon-and-restart.
|
||||
const RUNNING_TIMED_OUT := 3
|
||||
## `_exit_tree` / `_install_update` is draining workers. New refresh
|
||||
## requests are rejected outright. Set once and not cleared (the dock
|
||||
## instance is being torn down).
|
||||
const SHUTTING_DOWN := 4
|
||||
|
||||
const _NAMES := {
|
||||
IDLE: "idle",
|
||||
DEFERRED_FOR_FILESYSTEM: "deferred_for_filesystem",
|
||||
RUNNING: "running",
|
||||
RUNNING_TIMED_OUT: "running_timed_out",
|
||||
SHUTTING_DOWN: "shutting_down",
|
||||
}
|
||||
|
||||
|
||||
static func name_of(state: int) -> String:
|
||||
return _NAMES.get(state, "unknown(%d)" % state)
|
||||
|
||||
|
||||
## True when a worker thread should be alive in this state. Combined
|
||||
## state — RUNNING or RUNNING_TIMED_OUT both have a worker running, but
|
||||
## the timed-out flavor allows a force-refresh to abandon it.
|
||||
static func has_worker_alive(state: int) -> bool:
|
||||
return state == RUNNING or state == RUNNING_TIMED_OUT
|
||||
|
||||
|
||||
## True while the status worker is still within its healthy budget. Once a
|
||||
## refresh has timed out, the dock keeps the warning badge but must let users
|
||||
## retry Configure / Configure all instead of stranding the controls behind an
|
||||
## orphaned, uninterruptible worker.
|
||||
static func should_disable_client_actions(state: int) -> bool:
|
||||
return state == RUNNING
|
||||
|
||||
|
||||
## True when the dock should reject new refresh spawns. Used by the
|
||||
## dock's two refresh-spawn guards (the deferred refresh entrypoint and
|
||||
## the status-refresh scheduler).
|
||||
static func is_blocked_for_spawn(state: int) -> bool:
|
||||
return state == SHUTTING_DOWN
|
||||
|
||||
|
||||
## True when the summary label should show the in-flight badge.
|
||||
static func should_show_checking_badge(state: int) -> bool:
|
||||
return state == RUNNING or state == RUNNING_TIMED_OUT
|
||||
|
||||
|
||||
## Transition table. Same shape as McpServerState — illegal transitions
|
||||
## return false; callers `push_warning` and no-op.
|
||||
static func can_transition(from: int, to: int) -> bool:
|
||||
if from == to:
|
||||
return true
|
||||
## Shutdown is sticky.
|
||||
if from == SHUTTING_DOWN:
|
||||
return false
|
||||
## Anything → SHUTTING_DOWN is legal (drain on _exit_tree / install).
|
||||
if to == SHUTTING_DOWN:
|
||||
return true
|
||||
match from:
|
||||
IDLE:
|
||||
return to == RUNNING or to == DEFERRED_FOR_FILESYSTEM
|
||||
DEFERRED_FOR_FILESYSTEM:
|
||||
## When the filesystem scan settles we either spawn a worker
|
||||
## (RUNNING) or roll back to IDLE if no rows need probing.
|
||||
return to == RUNNING or to == IDLE
|
||||
RUNNING:
|
||||
## Worker finishes -> IDLE. Worker outlives budget ->
|
||||
## RUNNING_TIMED_OUT. Forced respawn after orphan abandon
|
||||
## stays in RUNNING (covered by from == to above).
|
||||
return to == IDLE or to == RUNNING_TIMED_OUT
|
||||
RUNNING_TIMED_OUT:
|
||||
## Late-arriving worker result drops back to IDLE; forced
|
||||
## abandon-and-respawn drops back to RUNNING.
|
||||
return to == IDLE or to == RUNNING
|
||||
return false
|
||||
@@ -1 +0,0 @@
|
||||
uid://dv4tukg6eioww
|
||||
@@ -1,182 +0,0 @@
|
||||
@tool
|
||||
class_name McpServerState
|
||||
extends RefCounted
|
||||
|
||||
## State machine for the plugin's server-spawn / adopt / version-verify
|
||||
## lifecycle. Single source of truth — supersedes the boolean-flag thicket
|
||||
## (`_server_started_this_session`, `_awaiting_server_version`,
|
||||
## `_server_version_deadline_ms`, `_connection_blocked`,
|
||||
## `_can_recover_incompatible`, `_refresh_retried`,
|
||||
## `_adoption_watch_deadline_ms`) and the older terminal-only
|
||||
## McpSpawnState string union.
|
||||
##
|
||||
## The integer values matter — they're what `get_server_status()`
|
||||
## surfaces, what the dock pattern-matches on, and what the test suites
|
||||
## assert against. Reordering the enum is a breaking change.
|
||||
##
|
||||
## The transitions are documented in `can_transition()`. The lifecycle
|
||||
## manager calls `set_state()` which:
|
||||
## 1. Validates the transition (logs a warning + no-ops on illegal).
|
||||
## 2. Preserves first-writer-wins among terminal diagnoses so a late
|
||||
## CRASHED from the watch loop can't clobber an earlier
|
||||
## PORT_EXCLUDED from the proactive Windows reservation check.
|
||||
|
||||
## Fresh plugin instance, `_start_server` has not run yet. Default state.
|
||||
const UNINITIALIZED := 0
|
||||
## Process spawned via OS.create_process; watch loop is observing the
|
||||
## SPAWN_GRACE_MS window. Transitions directly to READY (handshake_ack
|
||||
## verifies a compatible version), CRASHED (process died early), or
|
||||
## INCOMPATIBLE (handshake reported a mismatch).
|
||||
const SPAWNING := 1
|
||||
## (slot 2 reserved — keep wire-compat for clients pattern-matching
|
||||
## numeric `editor_state.state` values; do not reuse.)
|
||||
## Server is healthy and version-verified. Happy path. Includes both
|
||||
## "spawned fresh" and "adopted compatible existing server" flavors —
|
||||
## adoption flavor is recorded separately via `McpAdoptionLabel`.
|
||||
const READY := 3
|
||||
## Live server on the HTTP port returned a version that doesn't match
|
||||
## what this plugin expects, OR returned no `handshake_ack` inside the
|
||||
## timeout. Connection is blocked; recovery requires a kill+respawn
|
||||
## click via `recover_incompatible_server`.
|
||||
const INCOMPATIBLE := 4
|
||||
## Spawned process exited inside the SPAWN_GRACE_MS window. Python
|
||||
## traceback went to Godot's output log. Terminal — reload the plugin
|
||||
## or restart the editor to retry.
|
||||
const CRASHED := 5
|
||||
## No server command resolved: no `.venv` Python, no `uvx` on PATH, no
|
||||
## system `godot-ai`. Terminal — install guidance shown in dock.
|
||||
const NO_COMMAND := 6
|
||||
## Windows reserved the HTTP port via Hyper-V / WSL2 / Docker exclusion
|
||||
## range. Caught proactively before bind. Terminal — port picker shown.
|
||||
const PORT_EXCLUDED := 7
|
||||
## HTTP port held by a process we didn't spawn (no matching managed
|
||||
## record). Plugin armed an adoption-confirmation watcher; if the foreign
|
||||
## occupant turns out to be a compatible godot-ai server,
|
||||
## `handle_server_version_verified` transitions to READY. If the
|
||||
## adoption deadline expires without a connection, the watcher self-
|
||||
## disarms but the state stays at FOREIGN_PORT — the dock keeps showing
|
||||
## "port held by another process" until the user reloads. The version-
|
||||
## check seam (separate from the adoption deadline) is what fires
|
||||
## INCOMPATIBLE on a positive-but-mismatched handshake.
|
||||
const FOREIGN_PORT := 8
|
||||
## Static re-entrancy guard fired (`_server_started_this_session` was
|
||||
## already true). The plugin is being re-enabled within the same editor
|
||||
## session; the previous instance still owns the spawn. Terminal — does
|
||||
## NOT block READY paths, just records that this enable cycle no-op'd.
|
||||
const GUARDED := 9
|
||||
## stop_server / prepare_for_update_reload in progress. Transitional —
|
||||
## next state is STOPPED.
|
||||
const STOPPING := 10
|
||||
## stop_server completed; `_server_pid` reset to -1, port may or may
|
||||
## not be free. From here a fresh `start_server` call moves back through
|
||||
## SPAWNING / READY.
|
||||
const STOPPED := 11
|
||||
|
||||
const _NAMES := {
|
||||
UNINITIALIZED: "uninitialized",
|
||||
SPAWNING: "spawning",
|
||||
READY: "ready",
|
||||
INCOMPATIBLE: "incompatible",
|
||||
CRASHED: "crashed",
|
||||
NO_COMMAND: "no_command",
|
||||
PORT_EXCLUDED: "port_excluded",
|
||||
FOREIGN_PORT: "foreign_port",
|
||||
GUARDED: "guarded",
|
||||
STOPPING: "stopping",
|
||||
STOPPED: "stopped",
|
||||
}
|
||||
|
||||
|
||||
## Human-readable label. Used in startup-trace logs and transition
|
||||
## warnings. Falls back to `unknown(<int>)` for unrecognised values so
|
||||
## a future enum addition won't crash the formatter.
|
||||
static func name_of(state: int) -> String:
|
||||
return _NAMES.get(state, "unknown(%d)" % state)
|
||||
|
||||
|
||||
## True for any state the dock should render as a non-OK diagnostic
|
||||
## panel. Used as the "should we hide the spawn-failure panel?" gate.
|
||||
static func is_terminal_diagnosis(state: int) -> bool:
|
||||
return (
|
||||
state == CRASHED
|
||||
or state == NO_COMMAND
|
||||
or state == PORT_EXCLUDED
|
||||
or state == INCOMPATIBLE
|
||||
or state == FOREIGN_PORT
|
||||
)
|
||||
|
||||
|
||||
## True when the dock should consider the server unsuitable for client
|
||||
## health checks (incompatible tool surface). Currently just INCOMPATIBLE
|
||||
## — FOREIGN_PORT is transitional and may resolve to READY if the
|
||||
## foreign occupant turns out to speak our handshake.
|
||||
static func blocks_client_health(state: int) -> bool:
|
||||
return state == INCOMPATIBLE
|
||||
|
||||
|
||||
## Transition validation table. Returns true when `from -> to` is a
|
||||
## legal transition the lifecycle manager should accept. Illegal
|
||||
## transitions are silently no-op'd at the call site (with a
|
||||
## `push_warning` log) — this preserves the first-writer-wins contract
|
||||
## that prevents a late CRASHED from the watch loop overwriting an
|
||||
## earlier PORT_EXCLUDED diagnosis.
|
||||
static func can_transition(from: int, to: int) -> bool:
|
||||
if from == to:
|
||||
return true
|
||||
## Stop is always legal — teardown / install reload short-circuits
|
||||
## any in-flight state.
|
||||
if to == STOPPING:
|
||||
return true
|
||||
if to == STOPPED and from == STOPPING:
|
||||
return true
|
||||
## STOPPED can also be reached directly when `_server_pid <= 0` and
|
||||
## stop_server early-returns; treat it as legal from any state to
|
||||
## keep the teardown path forgiving.
|
||||
if to == STOPPED:
|
||||
return true
|
||||
## STOPPED -> any (re-arm via restart paths).
|
||||
if from == STOPPED:
|
||||
return true
|
||||
## GUARDED is sticky for the rest of this enable cycle; only stop is
|
||||
## legal out of it. Already covered by the stop checks above.
|
||||
if from == GUARDED:
|
||||
return false
|
||||
## Terminal diagnoses freeze further forward transitions. Recovery
|
||||
## goes through STOPPING (covered above), so any other target is
|
||||
## rejected — this is the first-writer-wins contract.
|
||||
if (
|
||||
from == CRASHED
|
||||
or from == NO_COMMAND
|
||||
or from == PORT_EXCLUDED
|
||||
or from == INCOMPATIBLE
|
||||
):
|
||||
return false
|
||||
## UNINITIALIZED is the boot state — any target except STOPPING is
|
||||
## reachable directly (start_server's early branches set
|
||||
## terminal states without going through SPAWNING).
|
||||
if from == UNINITIALIZED:
|
||||
return true
|
||||
## In-flight forward transitions.
|
||||
match from:
|
||||
SPAWNING:
|
||||
return (
|
||||
to == READY
|
||||
or to == CRASHED
|
||||
or to == FOREIGN_PORT
|
||||
or to == INCOMPATIBLE
|
||||
)
|
||||
FOREIGN_PORT:
|
||||
return to == READY or to == INCOMPATIBLE
|
||||
READY:
|
||||
## Late incompatibility detection (e.g. version verifier
|
||||
## re-arms after a foreign-port reconnect that turns out
|
||||
## to be incompatible after all).
|
||||
return to == INCOMPATIBLE or to == CRASHED
|
||||
STOPPING:
|
||||
## Recovery rollback: kill-then-respawn paths that fail to
|
||||
## free the port re-latch INCOMPATIBLE (so the dock keeps
|
||||
## the diagnostic UI) or fall back to UNINITIALIZED (clean
|
||||
## baseline for a follow-up `_set_incompatible_server`).
|
||||
## STOPPING -> STOPPED is handled by the early checks above.
|
||||
return to == INCOMPATIBLE or to == UNINITIALIZED
|
||||
return false
|
||||
@@ -1 +0,0 @@
|
||||
uid://d3ial4erjonlq
|
||||
@@ -1,34 +0,0 @@
|
||||
@tool
|
||||
class_name McpStartupPath
|
||||
extends RefCounted
|
||||
|
||||
## Branch-tag enum for `McpServerLifecycleManager.start_server`. Records
|
||||
## which arm of the spawn / adopt / drift / recover decision tree the
|
||||
## current `_enter_tree` walked. Surfaced via the startup trace log so
|
||||
## a Windows port-reservation issue or a stale-record kill can be
|
||||
## reconstructed from the editor output.
|
||||
##
|
||||
## Single-file constants, not an int enum, because the values land in
|
||||
## startup-trace text and the strings are stable across releases (the
|
||||
## CLAUDE.md "tool surface" entry references them by name).
|
||||
|
||||
const UNSET := ""
|
||||
## Re-entrancy guard fired; this enable cycle did not spawn or adopt.
|
||||
const GUARDED := "guarded"
|
||||
## Adopted a compatible existing server (managed or external).
|
||||
const ADOPTED := "adopted"
|
||||
## Spawned a fresh server process.
|
||||
const SPAWNED := "spawned"
|
||||
## OS.create_process returned -1 or proactive Windows reservation
|
||||
## detected. Either way the spawn never produced a live process.
|
||||
const CRASHED := "crashed"
|
||||
## Windows port-exclusion check fired — port is blocked at the OS layer.
|
||||
const RESERVED := "reserved"
|
||||
## Server-command discovery returned an empty list — no .venv, no uvx,
|
||||
## no system godot-ai.
|
||||
const NO_COMMAND := "no_command"
|
||||
## Drift-recovery kill fell through; we set INCOMPATIBLE and stayed.
|
||||
const INCOMPATIBLE := "incompatible"
|
||||
## Port was free at start; this is the prelude to SPAWNED but kept as
|
||||
## a distinct path so adopt-vs-spawn is unambiguous in the trace.
|
||||
const FREE := "free"
|
||||
@@ -1 +0,0 @@
|
||||
uid://cikdvq2x4vs4x
|
||||
@@ -1,178 +0,0 @@
|
||||
@tool
|
||||
class_name McpPathValidator
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Validates `res://`-rooted paths against directory-traversal escape.
|
||||
##
|
||||
## Issue #347 (audit-v2 #3): handlers were accepting `res://../etc/passwd.gd`
|
||||
## because the only check was `path.begins_with("res://")`. LLM-driven path
|
||||
## generation (prompt injection, agent typos, untrusted issue/PR text in
|
||||
## context) can produce traversal payloads for the write tools that produce
|
||||
## arbitrary disk content (`script_create`, `filesystem_write_text`,
|
||||
## `patch_script`) and for the matching reads (info disclosure surface).
|
||||
##
|
||||
## Two entry points:
|
||||
## * `validate_resource_path` — for paths that name a `res://` disk file the
|
||||
## plugin will read or (with `for_write`) write. This is the strict one.
|
||||
## * `validate_loadable_path` — for paths handed to `ResourceLoader`, which
|
||||
## also accepts `uid://` (an opaque resource-DB id that cannot express
|
||||
## traversal) and `user://` (the per-project user data sandbox). Load
|
||||
## handlers must use this so `uid://` references copied out of `.tscn`
|
||||
## ExtResource / `.uid` sidecars and `user://` runtime assets keep loading.
|
||||
##
|
||||
## Error wrapping: callers should use `path_error` / `loadable_error`, which
|
||||
## return a ready `ErrorCodes.make(VALUE_OUT_OF_RANGE, …)` dict (or null). A
|
||||
## bad path is a value-domain error, and funneling every site through one
|
||||
## wrapper keeps the error code consistent across all handlers.
|
||||
##
|
||||
## Known limitation: containment is lexical (`globalize_path` + `simplify_path`
|
||||
## prefix match). It does NOT resolve symlinks — GDScript exposes no realpath.
|
||||
## A symlink *inside* the project that points outside it can therefore defeat
|
||||
## the under-root check. This matches the engine's own `res://` resolution and
|
||||
## is accepted; the loopback trust boundary is the primary control.
|
||||
|
||||
|
||||
# Cached project / user roots. `globalize_path` is stable across the editor's
|
||||
# lifetime — caching avoids redundant resolution on every call. Matters most
|
||||
# for `reimport`, which loops the validator over each path in a batch.
|
||||
# Lazy-init on first call so static-load timing can't see a half-initialised
|
||||
# ProjectSettings.
|
||||
static var _cached_res_root: String = ""
|
||||
static var _cached_user_root: String = ""
|
||||
|
||||
|
||||
static func _res_root() -> String:
|
||||
if _cached_res_root.is_empty():
|
||||
_cached_res_root = ProjectSettings.globalize_path("res://").simplify_path()
|
||||
return _cached_res_root
|
||||
|
||||
|
||||
static func _user_root() -> String:
|
||||
if _cached_user_root.is_empty():
|
||||
_cached_user_root = ProjectSettings.globalize_path("user://").simplify_path()
|
||||
return _cached_user_root
|
||||
|
||||
|
||||
## Returns "" when the path is a safe `res://`-rooted reference inside the
|
||||
## project root. Returns a human-readable error message otherwise.
|
||||
## Prefer `path_error` over calling this directly — it wraps the message in the
|
||||
## canonical error code.
|
||||
##
|
||||
## Pass `for_write = true` for any handler that creates/overwrites the file
|
||||
## (write_file, create_script, patch_script, ResourceSaver-backed saves,
|
||||
## scene saves). Write callers additionally refuse the project manifest and
|
||||
## startup override, plus the `.godot/` metadata dir. Reads default to
|
||||
## `for_write = false`, which permits inspecting those files.
|
||||
static func validate_resource_path(path: String, for_write: bool = false) -> String:
|
||||
if path.is_empty():
|
||||
return "Missing required param: path"
|
||||
## Guard the sentinel: on builds where String.chr(0) yields "" (some engines
|
||||
## normalize embedded nulls away, e.g. 4.3), contains("") would be true and
|
||||
## reject every path. A String that can't hold a null can't smuggle one.
|
||||
var nul := String.chr(0)
|
||||
if not nul.is_empty() and path.contains(nul):
|
||||
return "Path must not contain null bytes"
|
||||
if not path.begins_with("res://"):
|
||||
return "Path must start with res://"
|
||||
var confine_err := _confine_under(path, _res_root(), "res://")
|
||||
if not confine_err.is_empty():
|
||||
return confine_err
|
||||
if for_write:
|
||||
return _reject_sensitive_write(path)
|
||||
return ""
|
||||
|
||||
|
||||
## Returns "" when `path` is safe to hand to `ResourceLoader.load` / `.exists`.
|
||||
## Accepts, in addition to confined `res://` paths:
|
||||
## * `uid://<id>` — an opaque 64-bit resource id; it cannot express a path
|
||||
## and the engine only ever resolves it to a resource already in the
|
||||
## project, so there is nothing to confine.
|
||||
## * `user://…` — the per-project user data dir, confined under its root the
|
||||
## same way `res://` is (so `user://../…` can't escape the sandbox).
|
||||
static func validate_loadable_path(path: String) -> String:
|
||||
if path.is_empty():
|
||||
return "Missing required param: path"
|
||||
## Guard the sentinel: on builds where String.chr(0) yields "" (some engines
|
||||
## normalize embedded nulls away, e.g. 4.3), contains("") would be true and
|
||||
## reject every path. A String that can't hold a null can't smuggle one.
|
||||
var nul := String.chr(0)
|
||||
if not nul.is_empty() and path.contains(nul):
|
||||
return "Path must not contain null bytes"
|
||||
if path.begins_with("uid://"):
|
||||
return ""
|
||||
if path.begins_with("user://"):
|
||||
return _confine_under(path, _user_root(), "user://")
|
||||
if path.begins_with("res://"):
|
||||
return _confine_under(path, _res_root(), "res://")
|
||||
return "Path must start with res://, uid://, or user://"
|
||||
|
||||
|
||||
## Shared traversal + under-root containment. `root` must already be simplified.
|
||||
static func _confine_under(path: String, root: String, label: String) -> String:
|
||||
if ".." in path:
|
||||
return "Path must not contain '..' (path traversal not allowed)"
|
||||
var globalized := ProjectSettings.globalize_path(path).simplify_path()
|
||||
# Append a separator so `/proj_evil/...` can't pretend to be inside `/proj`
|
||||
# via prefix match. `globalized == root` covers the bare `res://` / `user://`.
|
||||
if globalized != root and not globalized.begins_with(root + "/"):
|
||||
return "Path must resolve under %s root" % label
|
||||
return ""
|
||||
|
||||
|
||||
## Refuse writes that would clobber project-critical files. The path is already
|
||||
## confirmed `res://`-rooted and traversal-free by the caller.
|
||||
##
|
||||
## Comparisons are case-folded: macOS (APFS) and Windows (NTFS) are
|
||||
## case-insensitive by default, so `res://Project.godot` resolves to the real
|
||||
## `project.godot` and must be refused too.
|
||||
##
|
||||
## `.import` sidecars are deliberately NOT blocked — editing an asset's import
|
||||
## options then re-importing is a legitimate, recoverable workflow (the file is
|
||||
## source-controlled). The blocked set is the startup-execution surface only:
|
||||
## the manifest, its `override.cfg` shadow, and the `.godot/` cache dir.
|
||||
static func _reject_sensitive_write(path: String) -> String:
|
||||
var file_lower := path.get_file().to_lower()
|
||||
if file_lower == "project.godot":
|
||||
return "Refusing to write res://project.godot (project manifest)"
|
||||
if file_lower == "override.cfg":
|
||||
return "Refusing to write res://override.cfg (startup config override)"
|
||||
# Reject the `.godot/` editor-metadata dir at any depth. Split drops empty
|
||||
# segments so a trailing slash can't hide a segment from the check.
|
||||
var segments := path.trim_prefix("res://").split("/", false)
|
||||
for segment in segments:
|
||||
if segment.to_lower() == ".godot":
|
||||
return "Refusing to write under res://.godot/ (editor metadata)"
|
||||
# Reject the currently-loaded plugin's own script tree. Overwriting a
|
||||
# loaded .gd here (plus the immediate reimport triggered by update_file())
|
||||
# can SIGABRT the editor mid-call, or silently corrupt the installed
|
||||
# plugin so the next enable fails to resolve scripts.
|
||||
if segments.size() >= 2 and segments[0].to_lower() == "addons" and segments[1].to_lower() == "godot_ai":
|
||||
return "Refusing to write under res://addons/godot_ai/ (overwriting a loaded plugin script can crash the editor)"
|
||||
return ""
|
||||
|
||||
|
||||
## Validate a write/read `res://` path and return a ready error dict, or null
|
||||
## when the path is fine. The single wrapper every handler should use so the
|
||||
## error code (VALUE_OUT_OF_RANGE — a bad path is a value-domain error) stays
|
||||
## consistent. `param_name` is prefixed onto the message for context.
|
||||
static func path_error(path: String, param_name: String = "path", for_write: bool = false) -> Variant:
|
||||
if path.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
|
||||
var err := validate_resource_path(path, for_write)
|
||||
if err.is_empty():
|
||||
return null
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err])
|
||||
|
||||
|
||||
## Same as `path_error` but for paths handed to `ResourceLoader` (allows
|
||||
## `uid://` / `user://`). Returns a ready error dict or null. An empty path is
|
||||
## reported as MISSING_REQUIRED_PARAM rather than a value error.
|
||||
static func loadable_error(path: String, param_name: String = "path") -> Variant:
|
||||
if path.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
|
||||
var err := validate_loadable_path(path)
|
||||
if err.is_empty():
|
||||
return null
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err])
|
||||
@@ -1 +0,0 @@
|
||||
uid://blxntmd65ljyu
|
||||
@@ -9,7 +9,37 @@ extends RefCounted
|
||||
## 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:
|
||||
@@ -60,7 +90,16 @@ static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -
|
||||
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)
|
||||
return exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty()
|
||||
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
|
||||
@@ -76,7 +115,7 @@ static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int:
|
||||
## both bind the same port.
|
||||
##
|
||||
## `trace` is an optional Callable that fires once per OS invocation with
|
||||
## a counter name (`"netstat"` / `"powershell"` / `"lsof"`) so the plugin
|
||||
## 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.
|
||||
@@ -105,10 +144,19 @@ static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Ar
|
||||
var output: Array = []
|
||||
_trace(trace, "lsof")
|
||||
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
|
||||
if exit_code != 0 or output.is_empty():
|
||||
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
|
||||
return parse_lsof_pids(str(output[0]))
|
||||
_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:
|
||||
@@ -139,6 +187,14 @@ static func execute_windows_powershell(script: String, output: Array) -> int:
|
||||
|
||||
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"
|
||||
@@ -161,16 +217,32 @@ static func windows_listener_execute_result_in_use(exit_code: int, output: Array
|
||||
|
||||
|
||||
## Pure parser for `lsof -ti` output — newline-separated decimal PIDs.
|
||||
## Empty lines and non-numeric tokens are dropped. Duplicates pass
|
||||
## through (uvicorn reloader + worker can produce the same PID twice
|
||||
## across runs but typically two distinct 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]:
|
||||
var pids: Array[int] = []
|
||||
for line in raw.strip_edges().split("\n", false):
|
||||
var stripped := line.strip_edges()
|
||||
if stripped.is_valid_int():
|
||||
pids.append(int(stripped))
|
||||
return pids
|
||||
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]:
|
||||
@@ -262,10 +334,10 @@ static func split_on_whitespace(s: String) -> PackedStringArray:
|
||||
return out
|
||||
|
||||
|
||||
static func read_pid_file() -> int:
|
||||
if not FileAccess.file_exists(SERVER_PID_FILE):
|
||||
static func read_pid_file(path := SERVER_PID_FILE) -> int:
|
||||
if not FileAccess.file_exists(path):
|
||||
return 0
|
||||
var f := FileAccess.open(SERVER_PID_FILE, FileAccess.READ)
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return 0
|
||||
var content := f.get_as_text().strip_edges()
|
||||
@@ -286,9 +358,11 @@ static func clear_pid_file() -> void:
|
||||
## 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) -> bool:
|
||||
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)
|
||||
@@ -306,11 +380,353 @@ static func pid_alive(pid: int) -> bool:
|
||||
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.
|
||||
## NOTE: plugin.gd::_wait_for_port_free (and _is_port_in_use) is a
|
||||
## deliberate line-for-line fork of this pair kept for _ProofPlugin
|
||||
## isolation — keep the two in sync when editing either.
|
||||
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):
|
||||
@@ -338,24 +754,6 @@ static func resolve_ws_port(configured: int, max_port: int, log_buffer = null) -
|
||||
return resolved
|
||||
|
||||
|
||||
## Trust the cached ws_port from the managed record only when the record
|
||||
## is current ownership proof — i.e. record version matches the installed
|
||||
## plugin. Otherwise a stale record from an older install (e.g. a 9500
|
||||
## value pre-Windows-reservation collision) would mislead the
|
||||
## compatibility check into killing an unrelated external process. #259.
|
||||
static func resolved_ws_port_for_existing_server(
|
||||
record_ws_port: int,
|
||||
record_version: String,
|
||||
current_version: String,
|
||||
fresh_resolved: int
|
||||
) -> int:
|
||||
if record_ws_port <= 0:
|
||||
return fresh_resolved
|
||||
if current_version.is_empty() or record_version != current_version:
|
||||
return fresh_resolved
|
||||
return record_ws_port
|
||||
|
||||
|
||||
static func resolve_ws_port_from_output(
|
||||
configured_port: int,
|
||||
netsh_output: String,
|
||||
|
||||
@@ -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
|
||||
@@ -1,277 +0,0 @@
|
||||
@tool
|
||||
class_name McpResourceIO
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Shared helpers for "save a Resource to .tres" and the mutually-exclusive
|
||||
## path-vs-resource_path param validation that every resource-authoring
|
||||
## handler needs. Extracted to remove 4-way duplication across
|
||||
## resource_handler, environment_handler, texture_handler, and curve_handler.
|
||||
## Also home to the shared write-a-text-file path + deferred import-settle
|
||||
## completion used by both script_handler.create_script and
|
||||
## filesystem_handler.write_file (#714).
|
||||
|
||||
# Bounded settle window for `ResourceLoader.exists(path)` after a fresh text
|
||||
# write registers with the filesystem, so an agent calling
|
||||
# create_script/write_file -> attach_script back-to-back doesn't race the
|
||||
# editor's import pipeline (#261, extended to write_file by #714). Polled once
|
||||
# per frame, with an elapsed-time cap below the dispatcher's deferred timeouts
|
||||
# for both commands. If import is still not visible at the cap, we still
|
||||
# return committed data instead of letting the already-written file surface
|
||||
# as DEFERRED_TIMEOUT.
|
||||
const IMPORT_SETTLE_MAX_FRAMES := 300
|
||||
const IMPORT_SETTLE_MAX_MSEC := 3500
|
||||
|
||||
|
||||
## Validate that exactly one of {path, resource_path} is provided.
|
||||
##
|
||||
## When `require_property` is true (default), also requires a non-empty
|
||||
## `property` param when `path` is given — this matches the semantics of
|
||||
## "assign a resource to node.property" (resource_create, texture tools,
|
||||
## curve_set_points). Pass false for tools where the path itself IS the
|
||||
## target (environment_create assigning to WorldEnvironment.environment).
|
||||
##
|
||||
## Returns null on success or an error dict on failure.
|
||||
static func validate_home(params: Dictionary, require_property: bool = true) -> Variant:
|
||||
var node_path: String = params.get("path", "")
|
||||
var property: String = params.get("property", "")
|
||||
var resource_path: String = params.get("resource_path", "")
|
||||
var has_node_target := not node_path.is_empty()
|
||||
var has_file_target := not resource_path.is_empty()
|
||||
|
||||
if has_node_target and has_file_target:
|
||||
var both_msg := "Provide either path+property or resource_path, not both" if require_property else "Provide either path or resource_path, not both"
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, both_msg)
|
||||
if not has_node_target and not has_file_target:
|
||||
var none_msg := "Must provide either path+property (assign inline) or resource_path (save .tres)" if require_property else "Must provide either path or resource_path"
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, none_msg)
|
||||
if require_property and has_node_target and property.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Missing required param: property (required when path is given)")
|
||||
return null
|
||||
|
||||
|
||||
## Save `res` to `resource_path` as a .tres/.res file.
|
||||
##
|
||||
## Handles: res:// prefix validation, overwrite check, parent-directory
|
||||
## creation, ResourceSaver.save error reporting, and the post-save
|
||||
## EditorFileSystem.update_file() so the dock picks up the change.
|
||||
##
|
||||
## `label` is the human-readable resource-kind for error messages (e.g.
|
||||
## "Environment", "Gradient texture", "Curve"). `extra_fields` is merged
|
||||
## into the success response alongside the standard fields
|
||||
## (`resource_path`, `overwritten`, `undoable: false`, `reason`). Passing
|
||||
## a `reason` key in `extra_fields` overrides the default — useful for
|
||||
## tools that edit existing files rather than creating fresh ones.
|
||||
##
|
||||
## `pause_target` should be the handler's `McpConnection`. When supplied,
|
||||
## `pause_processing` is flipped on around `ResourceSaver.save()` so the
|
||||
## dispatcher's WebSocket pump can't re-enter while Godot pumps
|
||||
## `Main::iteration()` for the resource-save's progress UI / script-class
|
||||
## update task. Without this guard a queued command landing during the
|
||||
## save can trigger another `save_to_disk` that tries to add the same
|
||||
## `update_scripts_classes` editor task — "Task already exists" → null
|
||||
## deref → SIGSEGV. Same family of bug as godotengine/godot#118545 and
|
||||
## the same mitigation as `SceneHandler`'s `save_scene*` wraps. See
|
||||
## issue #288.
|
||||
##
|
||||
## Returns either an error dict or a {"data": {...}} success dict — ready
|
||||
## for the handler to return directly.
|
||||
static func save_to_disk(
|
||||
res: Resource,
|
||||
resource_path: String,
|
||||
overwrite: bool,
|
||||
label: String,
|
||||
extra_fields: Dictionary = {},
|
||||
pause_target: McpConnection = null,
|
||||
) -> Dictionary:
|
||||
var path_err = McpPathValidator.path_error(resource_path, "resource_path", true)
|
||||
if path_err != null:
|
||||
return path_err
|
||||
|
||||
var existed_before := FileAccess.file_exists(resource_path)
|
||||
if existed_before and not overwrite:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"%s already exists at %s (pass overwrite=true to replace)" % [label, resource_path]
|
||||
)
|
||||
# Captured BEFORE the overwrite below so a resave of an already-uid'd file
|
||||
# (overwrite=true) can restore its own uid instead of losing it — see
|
||||
# ensure_uid's doc comment.
|
||||
var prior_uid := ResourceLoader.get_resource_uid(resource_path) if existed_before else ResourceUID.INVALID_ID
|
||||
|
||||
var dir_path := resource_path.get_base_dir()
|
||||
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to create directory %s: %s" % [dir_path, error_string(mkdir_err)]
|
||||
)
|
||||
|
||||
if pause_target != null:
|
||||
pause_target.pause_processing = true
|
||||
var save_err := ResourceSaver.save(res, resource_path)
|
||||
if pause_target != null:
|
||||
pause_target.pause_processing = false
|
||||
if save_err != OK:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to save %s to %s: %s" % [label, resource_path, error_string(save_err)]
|
||||
)
|
||||
var uid_err := ensure_uid(resource_path, prior_uid)
|
||||
if uid_err != OK:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"%s saved to %s but failed to write its uid: %s" % [label, resource_path, error_string(uid_err)]
|
||||
)
|
||||
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs != null:
|
||||
efs.update_file(resource_path)
|
||||
|
||||
var data := {
|
||||
"resource_path": resource_path,
|
||||
"overwritten": existed_before,
|
||||
"undoable": false,
|
||||
"reason": "File creation is persistent; delete the file manually to revert",
|
||||
}
|
||||
attach_cleanup_hint(data, existed_before, [resource_path])
|
||||
# merge with overwrite=true so callers (e.g. curve_set_points editing an
|
||||
# existing .tres) can supply a domain-specific `reason`.
|
||||
data.merge(extra_fields, true)
|
||||
return {"data": data}
|
||||
|
||||
|
||||
## Save `res` to `resource_path` with the same `pause_processing` re-entrancy
|
||||
## guard as `save_to_disk` (see its doc for the #288 SIGSEGV background), for
|
||||
## call sites that need to pick their own error handling / overwrite policy
|
||||
## instead of `save_to_disk`'s full validate+mkdir+overwrite-guard bundle
|
||||
## (undo/redo callables reloading-mutating-resaving an existing resource,
|
||||
## `apply_to_node`'s inline-then-save branch). Returns the raw
|
||||
## `ResourceSaver.save` error code.
|
||||
static func guarded_save(res: Resource, resource_path: String, pause_target: McpConnection) -> int:
|
||||
var prior_uid := ResourceLoader.get_resource_uid(resource_path) if FileAccess.file_exists(resource_path) else ResourceUID.INVALID_ID
|
||||
if pause_target != null:
|
||||
pause_target.pause_processing = true
|
||||
var save_err := ResourceSaver.save(res, resource_path)
|
||||
if pause_target != null:
|
||||
pause_target.pause_processing = false
|
||||
if save_err != OK:
|
||||
return save_err
|
||||
return ensure_uid(resource_path, prior_uid)
|
||||
|
||||
|
||||
## Make `resource_path` carry a stable uid after a successful
|
||||
## `ResourceSaver.save()`, matching what Godot's own "New Scene"/"New
|
||||
## Resource" editor flows always embed. A bare `ResourceSaver.save()` call
|
||||
## does neither on its own: a brand-new file gets no `uid=` at all, and
|
||||
## resaving a file that already had one silently drops it (#737). Call this
|
||||
## immediately after every successful save.
|
||||
##
|
||||
## `prior_uid` is whatever `ResourceLoader.get_resource_uid(resource_path)`
|
||||
## returned BEFORE this save overwrote the file (pass `ResourceUID.INVALID_ID`
|
||||
## for a brand-new path). Reusing the prior id — instead of always minting a
|
||||
## fresh one — keeps any `uid://...` references elsewhere in the project
|
||||
## resolving to the same file.
|
||||
##
|
||||
## Returns the `Error` from `ResourceSaver.set_uid()` so callers can surface a
|
||||
## uid-write failure instead of silently reporting success on a file that
|
||||
## didn't end up with the uid it was supposed to get.
|
||||
static func ensure_uid(resource_path: String, prior_uid: int) -> Error:
|
||||
var id := prior_uid
|
||||
if id == ResourceUID.INVALID_ID:
|
||||
id = ResourceUID.create_id()
|
||||
return ResourceSaver.set_uid(resource_path, id)
|
||||
|
||||
|
||||
## Attach a `cleanup.rm` hint listing `paths` to `data` — only when the call
|
||||
## just created a new file (`existed_before == false`). On overwrite the field
|
||||
## is omitted because the caller already had the file on disk, and handing
|
||||
## them a cleanup list would invite dropping user content instead of just
|
||||
## scratch artifacts. Used by write-and-return handlers (create_script,
|
||||
## filesystem_write_text, resource_create/save_to_disk) so callers running
|
||||
## transient smoke tests can rm artifacts without tracking paths. See #82.
|
||||
static func attach_cleanup_hint(data: Dictionary, existed_before: bool, paths: Array) -> void:
|
||||
if existed_before:
|
||||
return
|
||||
data["cleanup"] = {"rm": paths}
|
||||
|
||||
|
||||
## Shared write-a-text-file path (#714): parent-directory mkdir, write +
|
||||
## flush with an explicit error check so a truncated write (disk full,
|
||||
## permission flip mid-write) surfaces as an error instead of plain success.
|
||||
## Deliberately does NOT call `EditorFileSystem.update_file()` — callers
|
||||
## register the file themselves after assembling their response fields, so
|
||||
## the registration comment (the dsarno/godot#6 scan-stacking rationale)
|
||||
## stays next to the call. Returns null on success or an error dict ready
|
||||
## to return from the handler.
|
||||
static func write_text_to_disk(path: String, content: String) -> Variant:
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var err := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if err != OK:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path)
|
||||
|
||||
file.store_string(content)
|
||||
file.flush()
|
||||
var write_err := file.get_error()
|
||||
file.close()
|
||||
if write_err != OK:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Write failed for %s (%s); file may be truncated" % [path, error_string(write_err)]
|
||||
)
|
||||
return null
|
||||
|
||||
|
||||
# `static` is load-bearing: the deferred completion captures no `self`, so the
|
||||
# coroutine survives even if the calling handler RefCounted is freed mid-await.
|
||||
# Under concurrent create storms with editor_reload_plugin fired during the
|
||||
# burst, an instance-method coroutine is otherwise GC'd between `await` and
|
||||
# resume, producing "Resumed function ... after await, but class instance is
|
||||
# gone" errors and dropping the response. Keep this function static and
|
||||
# parameterise everything it needs explicitly — do not reference instance
|
||||
# state. Shared by create_script and write_file's fresh-`.gd` path (#714).
|
||||
static func finish_text_write_deferred(
|
||||
connection: McpConnection,
|
||||
request_id: String,
|
||||
path: String,
|
||||
data: Dictionary,
|
||||
) -> void:
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
var tree := connection.get_tree()
|
||||
if tree == null:
|
||||
return
|
||||
var deadline_ms := Time.get_ticks_msec() + IMPORT_SETTLE_MAX_MSEC
|
||||
# Let _dispatch() return DEFERRED_RESPONSE and register the request before
|
||||
# this coroutine can send a committed result. ResourceLoader.exists(path)
|
||||
# may already be true on fast imports; without this handoff the connection
|
||||
# treats the response as late/unregistered and drops it, then the dispatcher
|
||||
# times out a file that was already written (#324). The deadline starts
|
||||
# before this await so a slow handoff frame is counted against the bounded
|
||||
# settle window.
|
||||
await tree.process_frame
|
||||
var frames := 0
|
||||
while (
|
||||
frames < IMPORT_SETTLE_MAX_FRAMES
|
||||
and Time.get_ticks_msec() < deadline_ms
|
||||
and not ResourceLoader.exists(path)
|
||||
):
|
||||
await tree.process_frame
|
||||
frames += 1
|
||||
# If the plugin tears down (_exit_tree frees the connection) during the
|
||||
# await, is_instance_valid() goes false and we drop the response silently —
|
||||
# the server's request timeout will surface the failure to the caller.
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
var payload := data.duplicate()
|
||||
var settled := ResourceLoader.exists(path)
|
||||
payload["import_settled"] = settled
|
||||
payload["import_settle"] = "settled" if settled else "timeout"
|
||||
payload["import_pending"] = not settled
|
||||
connection.send_deferred_response(request_id, {"data": payload})
|
||||
@@ -1 +0,0 @@
|
||||
uid://de2rwdoa4wabf
|
||||
@@ -1,155 +0,0 @@
|
||||
@tool
|
||||
class_name McpScenePath
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Utility for converting between Godot internal node paths and clean
|
||||
## scene-relative paths like /Main/Camera3D.
|
||||
|
||||
|
||||
## Return a clean path relative to the scene root (e.g. /Main/Camera3D).
|
||||
## Returns "" when `node` is not the scene root or a descendant of it —
|
||||
## without the ancestry guard, get_path_to() returns an empty NodePath that
|
||||
## concatenates into a plausible-looking but invalid "/Main/".
|
||||
static func from_node(node: Node, scene_root: Node) -> String:
|
||||
if scene_root == null or node == null:
|
||||
return ""
|
||||
if node == scene_root:
|
||||
return "/" + scene_root.name
|
||||
if not scene_root.is_ancestor_of(node):
|
||||
return ""
|
||||
var relative := scene_root.get_path_to(node)
|
||||
return "/" + scene_root.name + "/" + str(relative)
|
||||
|
||||
|
||||
## Resolve a clean scene path like "/Main/Camera3D" to the actual node.
|
||||
##
|
||||
## Accepts forms relative to the edited scene root:
|
||||
## "/Main" — explicit root prefix (canonical)
|
||||
## "/Main/Camera3D" — descendant path
|
||||
## "Camera3D" — bare relative to scene_root
|
||||
## "World/Ground" — nested bare relative to scene_root
|
||||
##
|
||||
## Also accepts SceneTree-style "/root/<scene_root_name>[/...]" as an alias for
|
||||
## the edited scene root. Agents reach for /root/Foo right after creating a
|
||||
## scene because that's where scenes live at runtime; we honor it so the call
|
||||
## doesn't fail with a confusing "not found" error. The alias only kicks in
|
||||
## when the segment after /root matches the scene root's name — paths like
|
||||
## "/root/@EditorNode@.../Main/..." (returned by Node.get_path() in the editor)
|
||||
## fall through to the absolute-path fallback unchanged.
|
||||
static func resolve(scene_path: String, scene_root: Node) -> Node:
|
||||
if scene_root == null:
|
||||
return null
|
||||
|
||||
## Bare "/" alias: the most natural first guess for "the scene root".
|
||||
## There is exactly one edited-scene root, so the alias is unambiguous;
|
||||
## without it, "/" falls through to get_node_or_null("/") → null and
|
||||
## every call costs the agent a NODE_NOT_FOUND round trip (issue #624).
|
||||
if scene_path == "/":
|
||||
return scene_root
|
||||
|
||||
## /root/<scene_root_name>[/...] alias: strip the /root prefix and recurse.
|
||||
## Match the scene root by name explicitly so we don't capture editor-
|
||||
## internal paths that legitimately live under /root.
|
||||
var alias_prefix := "/root/" + scene_root.name
|
||||
if scene_path == alias_prefix or scene_path.begins_with(alias_prefix + "/"):
|
||||
return resolve(scene_path.substr(5), scene_root) # keep leading slash
|
||||
|
||||
var root_prefix := "/" + scene_root.name
|
||||
if scene_path == root_prefix:
|
||||
return scene_root
|
||||
if scene_path.begins_with(root_prefix + "/"):
|
||||
var relative := scene_path.substr(root_prefix.length() + 1)
|
||||
return scene_root.get_node_or_null(relative)
|
||||
|
||||
# Try as-is (relative path, or absolute SceneTree path).
|
||||
return scene_root.get_node_or_null(scene_path)
|
||||
|
||||
|
||||
## Return the edited scene root, or an error dict if the editor has no open
|
||||
## scene or the open scene doesn't match `expected_scene_file`.
|
||||
##
|
||||
## `expected_scene_file` is the caller's `scene_file` parameter — an empty
|
||||
## string means "target whatever is currently edited" (current behaviour,
|
||||
## no guard). A non-empty value must match `scene_file_path` on the current
|
||||
## edited scene root exactly, or we return EDITED_SCENE_MISMATCH so the
|
||||
## caller can re-open the right scene.
|
||||
##
|
||||
## Shape on success: {"node": <scene_root>}. Shape on error matches
|
||||
## `ErrorCodes.make()` so callers can propagate the result directly.
|
||||
static func require_edited_scene(expected_scene_file: String) -> Dictionary:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
# Mirrors the structured payload that the Python-side require_writable
|
||||
# gate attaches for `playing` / `importing`. Together these cover the
|
||||
# three recoverable editor *states* (playing / importing / no_scene)
|
||||
# — the EDITOR_NOT_READY paths an AI caller can act on. Other
|
||||
# EDITOR_NOT_READY callsites describing internal-state failures
|
||||
# ("EditorFileSystem not available" etc.) carry sub_code + retryable
|
||||
# via ErrorCodes.make_not_ready (#651 stage 1) but intentionally
|
||||
# omit the hint — there's no useful caller action to name.
|
||||
var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open")
|
||||
err["error"]["data"] = {
|
||||
"sub_code": ErrorCodes.SUB_EDITOR_NO_SCENE,
|
||||
"editor_state": "no_scene",
|
||||
"retryable": false,
|
||||
"hint": (
|
||||
"No scene is open. Call scene_open with a scene path "
|
||||
+ "(e.g. \"res://main.tscn\") before issuing scene-mutating tools."
|
||||
),
|
||||
}
|
||||
return err
|
||||
if not expected_scene_file.is_empty() and root.scene_file_path != expected_scene_file:
|
||||
var actual := root.scene_file_path if not root.scene_file_path.is_empty() else "<unsaved>"
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.EDITED_SCENE_MISMATCH,
|
||||
(
|
||||
"Expected edited scene \"%s\" but \"%s\" is active. "
|
||||
+ "Call scene_open(\"%s\") first, or omit scene_file to target the active scene."
|
||||
) % [expected_scene_file, actual, expected_scene_file],
|
||||
)
|
||||
return {"node": root}
|
||||
|
||||
|
||||
## Format a "parent not found" error that names the path convention.
|
||||
## Agents routinely try /root/Foo or absolute SceneTree paths; the bare
|
||||
## "Parent not found: X" gave them no hint that paths are scene-relative.
|
||||
## Wording is generic ("Paths are relative...") so the helper works for any
|
||||
## param name (parent_path, new_parent, …).
|
||||
static func format_parent_error(path: String, scene_root: Node) -> String:
|
||||
if scene_root == null:
|
||||
return "Parent not found: %s. No edited scene is open." % path
|
||||
var root_name := str(scene_root.name)
|
||||
return "Parent not found: %s. Paths are relative to the edited scene root (e.g. \"/%s\" or \"\"), not the SceneTree. Scene root is \"/%s\"." % [path, root_name, root_name]
|
||||
|
||||
|
||||
## Format a "node not found" error that names the path convention and, when
|
||||
## possible, suggests a corrected path. Agents routinely pass /root/Foo
|
||||
## (runtime SceneTree) or unprefixed names; the bare "Node not found: X"
|
||||
## gives no hint that paths are edited-scene-relative.
|
||||
##
|
||||
## Suggestion logic (highest-confidence first):
|
||||
## 1. /root/<X>[/...] where <X> is not the scene root → suggest /<sceneRoot>/<X>[/...]
|
||||
## 2. path doesn't start with "/" → suggest "/<sceneRoot>/<path>"
|
||||
## 3. otherwise no concrete "did you mean", just the convention reminder.
|
||||
static func format_node_error(path: String, scene_root: Node) -> String:
|
||||
if scene_root == null:
|
||||
return "Node not found: %s. No edited scene is open." % path
|
||||
var root_name := str(scene_root.name)
|
||||
var suggestion := ""
|
||||
|
||||
if path.begins_with("/root/"):
|
||||
var after_root := path.substr(6) # "/root/" is 6 chars
|
||||
# Only suggest if the segment after /root/ isn't already the scene root
|
||||
# (resolve() handles /root/<sceneRoot>/... as an alias, so a failure
|
||||
# with that prefix means a deeper segment is wrong — no clean rewrite).
|
||||
var first_seg := after_root.split("/")[0]
|
||||
if first_seg != root_name and not first_seg.is_empty():
|
||||
suggestion = "/" + root_name + "/" + after_root
|
||||
elif not path.begins_with("/") and not path.is_empty():
|
||||
suggestion = "/" + root_name + "/" + path
|
||||
|
||||
if suggestion.is_empty():
|
||||
return "Node not found: %s. Paths are relative to the edited scene root (e.g. \"/%s/Child\"), not runtime /root/... paths. Scene root is \"/%s\"." % [path, root_name, root_name]
|
||||
return "Node not found: %s. Did you mean \"%s\"? Paths are relative to the edited scene root, not runtime /root/... paths. Scene root is \"/%s\"." % [path, suggestion, root_name]
|
||||
@@ -1 +0,0 @@
|
||||
uid://c1irdrss0amex
|
||||
@@ -1,38 +0,0 @@
|
||||
@tool
|
||||
class_name McpScreenshotEncode
|
||||
extends RefCounted
|
||||
|
||||
## Shared downscale + PNG + base64 block for the screenshot paths (#716).
|
||||
##
|
||||
## Two call sites straddle the editor/game process boundary — the editor's
|
||||
## take_screenshot (editor_handler) and the game-process autoload
|
||||
## (runtime/game_helper) — and were maintained as manually synchronized
|
||||
## copies. Pure static, no editor APIs, so it loads safely in the game
|
||||
## process too.
|
||||
|
||||
|
||||
## Downscale `image` in place so its longest edge is at most
|
||||
## `max_resolution` (0 = no cap), then PNG-encode. Returns
|
||||
## {base64, width, height, original_width, original_height}.
|
||||
static func downscale_and_encode(image: Image, max_resolution: int) -> Dictionary:
|
||||
var original_width := image.get_width()
|
||||
var original_height := image.get_height()
|
||||
|
||||
if max_resolution > 0:
|
||||
var longest := maxi(original_width, original_height)
|
||||
if longest > max_resolution:
|
||||
var scale := float(max_resolution) / float(longest)
|
||||
## Clamp to 1px min: extreme aspect ratios at very small
|
||||
## max_resolution could otherwise compute a zero dimension and
|
||||
## crash image.resize().
|
||||
var new_w := maxi(1, int(original_width * scale))
|
||||
var new_h := maxi(1, int(original_height * scale))
|
||||
image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
|
||||
|
||||
return {
|
||||
"base64": Marshalls.raw_to_base64(image.save_png_to_buffer()),
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
"original_width": original_width,
|
||||
"original_height": original_height,
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://cl0xhoxwsbmow
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
uid://bwfx8b0w2mgf6
|
||||
@@ -1,126 +0,0 @@
|
||||
@tool
|
||||
class_name McpServerVersionCheck
|
||||
extends RefCounted
|
||||
|
||||
## Standalone polling seam for the post-connection server-version
|
||||
## handshake gate. Extracted from `plugin.gd` so the lifecycle manager
|
||||
## stays focused on spawn/adopt/stop and the version-verify dance has
|
||||
## its own home.
|
||||
##
|
||||
## The seam itself does NOT transition `McpServerState` on arm/disarm —
|
||||
## the version check runs concurrently with whatever spawn-state the
|
||||
## caller had latched (typically FOREIGN_PORT during adoption
|
||||
## confirmation, or no-op directly to READY for a fresh spawn). Result
|
||||
## transitions land on the manager via `handle_server_version_verified`
|
||||
## (READY / INCOMPATIBLE) or `handle_server_version_unverified`
|
||||
## (INCOMPATIBLE on deadline expiry); arm() leaves the state alone so a
|
||||
## FOREIGN_PORT diagnosis isn't accidentally cleared before the
|
||||
## handshake actually arrives.
|
||||
##
|
||||
## Owns the deadline timer (`_deadline_ms`) and requires the manager to
|
||||
## feed it `tick(now_msec)` from the plugin's `_process` while
|
||||
## `is_active()` is true.
|
||||
##
|
||||
## Decoupled from the connection's signal surface: `tick()` polls
|
||||
## `_connection.is_connected` and `_connection.server_version` directly.
|
||||
## A same-release signal addition plus a new consumer is shape-coupled work
|
||||
## for old two-phase runners; they can parse the consumer while the
|
||||
## McpConnection Script object still reflects v(N). We still null-check
|
||||
## `_connection` because `disarm()` releases it.
|
||||
|
||||
## How long to wait after the WebSocket opens before declaring the
|
||||
## handshake_ack overdue. This is the sole owner of the 5s budget
|
||||
## — kept at this layer so the version-check seam is self-contained.
|
||||
const TIMEOUT_MS := 5 * 1000
|
||||
|
||||
## Untyped on purpose for the same self-update field-storage reason
|
||||
## plugin.gd's fields are untyped. `_connection` is the live
|
||||
## `McpConnection`; `_manager` is `McpServerLifecycleManager`.
|
||||
## `_connection` is null between disarm() and the next arm() — the
|
||||
## seam can spend most of the plugin's life dormant and we don't want
|
||||
## to pin a Node that may be queue_freed in `_exit_tree`. `_manager` is
|
||||
## set once at construction and held for the seam's lifetime (the
|
||||
## manager owns this instance, so the cycle is short).
|
||||
var _connection
|
||||
var _manager
|
||||
var _active: bool = false
|
||||
var _deadline_ms: int = 0
|
||||
var _expected_version: String = ""
|
||||
|
||||
|
||||
func _init(manager) -> void:
|
||||
_manager = manager
|
||||
|
||||
|
||||
## Arm the version-check. Marks the seam active, (re)attaches the
|
||||
## connection it should poll, and starts watching for
|
||||
## `_connection.server_version`. Does NOT transition manager state —
|
||||
## the version check runs concurrently with whatever spawn-state was
|
||||
## latched (e.g. FOREIGN_PORT during adoption confirmation, READY for
|
||||
## a fresh spawn). Result transitions land on the manager via
|
||||
## `handle_server_version_verified` / `_unverified` once the handshake
|
||||
## (or its deadline) lands.
|
||||
##
|
||||
## The deadline starts the moment the connection actually opens, not at
|
||||
## arm-time, because uvx cold-starts can take ~30s to bind the
|
||||
## WebSocket and we don't want to count that against the handshake.
|
||||
func arm(connection, expected_version: String) -> void:
|
||||
_active = true
|
||||
_deadline_ms = 0
|
||||
_expected_version = expected_version
|
||||
_connection = connection
|
||||
|
||||
|
||||
## Disarm without firing a verdict. Used when the manager moves on
|
||||
## (e.g. recovery click → STOPPING). Releases the connection /
|
||||
## manager references so the seam doesn't pin them past the active
|
||||
## window — the plugin can spend most of its life with the version
|
||||
## check disarmed, and `_connection` is a Node that may be queue_free'd
|
||||
## by `_exit_tree`. Caller has already transitioned state, so we don't
|
||||
## touch the manager.
|
||||
func disarm() -> void:
|
||||
_active = false
|
||||
_deadline_ms = 0
|
||||
_connection = null
|
||||
|
||||
|
||||
## True while the version-check needs `_process` ticks. Plugin uses
|
||||
## this to gate `set_process(true)`.
|
||||
func is_active() -> bool:
|
||||
return _active
|
||||
|
||||
|
||||
## Per-frame tick from the plugin's `_process`. No-op when disarmed.
|
||||
## Returns true when the check finished this tick (verified or
|
||||
## unverified) so the plugin can re-evaluate `set_process` enable.
|
||||
func tick(now_msec: int) -> bool:
|
||||
if not _active:
|
||||
return false
|
||||
if _connection == null:
|
||||
return false
|
||||
if not bool(_connection.is_connected):
|
||||
return false
|
||||
if _deadline_ms == 0:
|
||||
_deadline_ms = now_msec + TIMEOUT_MS
|
||||
var server_version := str(_connection.server_version)
|
||||
if not server_version.is_empty():
|
||||
_complete_with_version(server_version)
|
||||
return true
|
||||
if now_msec >= _deadline_ms:
|
||||
_complete_unverified()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _complete_with_version(version: String) -> void:
|
||||
_active = false
|
||||
_deadline_ms = 0
|
||||
if _manager != null:
|
||||
_manager.handle_server_version_verified(_expected_version, version)
|
||||
|
||||
|
||||
func _complete_unverified() -> void:
|
||||
_active = false
|
||||
_deadline_ms = 0
|
||||
if _manager != null:
|
||||
_manager.handle_server_version_unverified(_expected_version)
|
||||
@@ -1 +0,0 @@
|
||||
uid://ciqldbuaq8i8u
|
||||
@@ -1,135 +0,0 @@
|
||||
@tool
|
||||
class_name McpSettings
|
||||
extends RefCounted
|
||||
|
||||
## Shared EditorSettings key constants for the godot_ai/* namespace.
|
||||
##
|
||||
## Centralised here so lightweight files (e.g. telemetry.gd) can reference
|
||||
## settings keys without pulling in the full client_configurator.gd dep tree.
|
||||
## All keys must keep their raw string values stable across releases because
|
||||
## they are persisted in the user's editor_settings-4.tres.
|
||||
|
||||
const SETTING_HTTP_PORT := "godot_ai/http_port"
|
||||
## Comma-separated list of tool domains excluded from the server at spawn time.
|
||||
const SETTING_EXCLUDED_DOMAINS := "godot_ai/excluded_domains"
|
||||
const SETTING_TELEMETRY_ENABLED := "godot_ai/telemetry_enabled"
|
||||
## Comma-separated CIDRs / bare IPs passed to the server as `--allow-host`
|
||||
## at spawn time (#507, server core #421). Empty means loopback-only.
|
||||
const SETTING_ALLOW_HOSTS := "godot_ai/allow_remote_hosts"
|
||||
## Whether MCP log lines echo to the Godot console (dock "Log" toggle).
|
||||
## The dock's ring-buffer log panel keeps recording regardless.
|
||||
const SETTING_MCP_LOGGING := "godot_ai/mcp_logging"
|
||||
## Which scope CLI-configured clients register the server in. Claude Code's
|
||||
## `--scope user` writes the global block of ~/.claude.json, which that client
|
||||
## loads in EVERY workspace — so the server gets spawned in unrelated,
|
||||
## non-Godot projects. `project` writes <project>/.mcp.json instead, so the
|
||||
## entry travels with the project. Defaults to `user` to preserve the
|
||||
## historical behaviour for existing installs.
|
||||
const SETTING_CLIENT_SCOPE := "godot_ai/mcp_client_scope"
|
||||
|
||||
## Scopes accepted for SETTING_CLIENT_SCOPE. These are the values the Claude
|
||||
## Code CLI's `--scope` flag takes; an unrecognised setting falls back to
|
||||
## DEFAULT_CLIENT_SCOPE rather than passing junk to the CLI.
|
||||
const CLIENT_SCOPES := ["user", "project", "local"]
|
||||
const DEFAULT_CLIENT_SCOPE := "user"
|
||||
|
||||
|
||||
## Returns true if the string value is truthy
|
||||
## ("1", "true", "yes", "on", case-insensitive, whitespace-trimmed).
|
||||
static func truthy(value: String) -> bool:
|
||||
return value.strip_edges().to_lower() in ["1", "true", "yes", "on"]
|
||||
|
||||
|
||||
## Returns true if the named environment variable is set to a truthy value.
|
||||
static func env_truthy(var_name: String) -> bool:
|
||||
return truthy(OS.get_environment(var_name))
|
||||
|
||||
|
||||
## #691: EditorInterface / EditorSettings are not thread-safe, and the dock
|
||||
## runs Configure / Remove on a worker Thread (mcp_dock.gd
|
||||
## `_run_client_action_worker` -> McpClientConfigurator -> McpCliStrategy
|
||||
## `_format_args` -> client_scope()). Same contract as
|
||||
## McpClientConfigurator._editor_setting_lookup: the main thread does a live
|
||||
## read and refreshes the snapshot; worker threads read the snapshot only, so
|
||||
## a never-warmed value reads as null (unset) rather than touching
|
||||
## EditorInterface off-thread. Warmed on the main thread by
|
||||
## McpClientConfigurator.warm_env_snapshot() before each worker dispatch.
|
||||
##
|
||||
## Kept here rather than reusing the configurator's `_editor_setting_lookup`
|
||||
## so utils/settings.gd stays free of the client_configurator.gd dep tree —
|
||||
## see this file's header.
|
||||
static var _client_scope_snapshot: Variant = null
|
||||
static var _client_scope_mutex := Mutex.new()
|
||||
|
||||
|
||||
static func _client_scope_setting() -> Variant:
|
||||
if OS.get_thread_caller_id() == OS.get_main_thread_id():
|
||||
var live: Variant = null
|
||||
if Engine.is_editor_hint():
|
||||
var es := EditorInterface.get_editor_settings()
|
||||
if es != null and es.has_setting(SETTING_CLIENT_SCOPE):
|
||||
live = es.get_setting(SETTING_CLIENT_SCOPE)
|
||||
_client_scope_mutex.lock()
|
||||
_client_scope_snapshot = live
|
||||
_client_scope_mutex.unlock()
|
||||
return live
|
||||
_client_scope_mutex.lock()
|
||||
var cached: Variant = _client_scope_snapshot
|
||||
_client_scope_mutex.unlock()
|
||||
return cached
|
||||
|
||||
|
||||
## Main-thread pre-warm of the scope snapshot so the dock's Configure/Remove
|
||||
## worker resolves `{scope}` from a value captured while EditorSettings access
|
||||
## was safe. Idempotent; called from warm_env_snapshot().
|
||||
static func warm_client_scope() -> void:
|
||||
_client_scope_setting()
|
||||
|
||||
|
||||
## Returns the scope CLI clients should register the MCP server in, as
|
||||
## substituted into `{scope}` in a descriptor's cli_register_template /
|
||||
## cli_unregister_template. Safe to call from either thread (see
|
||||
## _client_scope_setting). Unrecognised values fall back to
|
||||
## DEFAULT_CLIENT_SCOPE so a hand-edited editor_settings-4.tres can never make
|
||||
## the plugin shell out with a bad flag.
|
||||
static func client_scope() -> String:
|
||||
var value: Variant = _client_scope_setting()
|
||||
## String(null) is a hard error (#850) — an unset/never-warmed key must
|
||||
## short-circuit before the cast.
|
||||
if value == null:
|
||||
return DEFAULT_CLIENT_SCOPE
|
||||
var raw := String(value).strip_edges().to_lower()
|
||||
if raw in CLIENT_SCOPES:
|
||||
return raw
|
||||
return DEFAULT_CLIENT_SCOPE
|
||||
|
||||
|
||||
## Returns true if telemetry should be active, checking in priority order:
|
||||
## 1. GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY env vars
|
||||
## 2. The godot_ai/telemetry_enabled EditorSetting written by the dock UI
|
||||
## Defaults to true when neither source has set a preference.
|
||||
static func telemetry_enabled() -> bool:
|
||||
if env_truthy("GODOT_AI_DISABLE_TELEMETRY") or env_truthy("DISABLE_TELEMETRY"):
|
||||
return false
|
||||
var es := EditorInterface.get_editor_settings()
|
||||
if es != null and es.has_setting(SETTING_TELEMETRY_ENABLED):
|
||||
return bool(es.get_setting(SETTING_TELEMETRY_ENABLED))
|
||||
return true
|
||||
|
||||
|
||||
## Returns whether MCP log lines should echo to the Godot console. Read at
|
||||
## plugin startup (to apply the persisted choice to the log buffer and
|
||||
## dispatcher) and by the dock's LogViewer toggle for its initial state.
|
||||
## Defaults to true when the user has never touched the toggle.
|
||||
static func mcp_logging_enabled() -> bool:
|
||||
var es := EditorInterface.get_editor_settings()
|
||||
if es != null and es.has_setting(SETTING_MCP_LOGGING):
|
||||
return bool(es.get_setting(SETTING_MCP_LOGGING))
|
||||
return true
|
||||
|
||||
|
||||
## Persist the dock "Log" toggle so the choice survives editor restarts (#626).
|
||||
static func set_mcp_logging_enabled(enabled: bool) -> void:
|
||||
var es := EditorInterface.get_editor_settings()
|
||||
if es != null:
|
||||
es.set_setting(SETTING_MCP_LOGGING, enabled)
|
||||
@@ -1 +0,0 @@
|
||||
uid://pefrtofs7ijw
|
||||
@@ -1,156 +0,0 @@
|
||||
@tool
|
||||
class_name McpStructuredLogRing
|
||||
extends RefCounted
|
||||
|
||||
## Head-indexed circular buffer of structured log entries shared by
|
||||
## game_log_buffer and editor_log_buffer.
|
||||
##
|
||||
## Once `_max_lines` (set in subclass `_init`) is reached, new appends
|
||||
## overwrite the oldest slot at `_head`, keeping append O(1) on overflow
|
||||
## — the previous slice() approach reallocated the full retained array
|
||||
## on every drop, which a chatty game would pay for thousands of times
|
||||
## per second.
|
||||
##
|
||||
## Lockless. Subclasses needing thread-safety (editor_log_buffer is
|
||||
## written from any thread a Godot Logger virtual can fire on) wrap each
|
||||
## public method with their own Mutex around the `_*_unlocked` helpers.
|
||||
## Keeping the base lockless means the hot game-side path (single thread,
|
||||
## called from _process) doesn't pay an unused mutex cost.
|
||||
##
|
||||
## Entry shape is owned by subclasses — `_append_entry` takes a
|
||||
## ready-built Dictionary so each buffer can carry the fields it needs
|
||||
## (game: `source/level/text`; editor: adds `path/line/function`).
|
||||
|
||||
const VALID_LEVELS := ["info", "warn", "error"]
|
||||
|
||||
var _max_lines: int
|
||||
var _storage: Array[Dictionary] = []
|
||||
## Next write position within `_storage`. While filling (before first
|
||||
## wrap) equals `_storage.size()`; once full, points at the oldest entry
|
||||
## (the one about to be overwritten).
|
||||
var _head := 0
|
||||
var _dropped_count := 0
|
||||
## Monotonic number of entries appended since this ring was created. Unlike
|
||||
## `_storage.size()` and `_dropped_count`, this intentionally survives clear()
|
||||
## so callers can use it as a stable "next entry to read" cursor.
|
||||
var _appended_total := 0
|
||||
|
||||
|
||||
func _init(max_lines: int) -> void:
|
||||
_max_lines = max_lines
|
||||
|
||||
|
||||
## Append `entry` to the ring, evicting the oldest slot when full.
|
||||
## Subclasses build the dict with their per-source shape and pass it in.
|
||||
func _append_entry(entry: Dictionary) -> void:
|
||||
if _storage.size() < _max_lines:
|
||||
_storage.append(entry)
|
||||
_head = _storage.size() % _max_lines
|
||||
else:
|
||||
## Full — overwrite oldest in place, advance head, count the drop.
|
||||
_storage[_head] = entry
|
||||
_head = (_head + 1) % _max_lines
|
||||
_dropped_count += 1
|
||||
_appended_total += 1
|
||||
|
||||
|
||||
## Lockless slice. Subclasses with a mutex wrap their `get_range` /
|
||||
## `get_recent` overrides around this; the lockless base implementations
|
||||
## of those public methods just delegate here.
|
||||
func _get_range_unlocked(offset: int, count: int) -> Array[Dictionary]:
|
||||
var size := _storage.size()
|
||||
var start := maxi(0, offset)
|
||||
var stop := mini(size, start + count)
|
||||
var out: Array[Dictionary] = []
|
||||
for i in range(start, stop):
|
||||
out.append(_storage[_logical_to_physical(i)])
|
||||
return out
|
||||
|
||||
|
||||
func get_range(offset: int, count: int) -> Array[Dictionary]:
|
||||
return _get_range_unlocked(offset, count)
|
||||
|
||||
|
||||
func get_recent(count: int) -> Array[Dictionary]:
|
||||
var size := _storage.size()
|
||||
var start := maxi(0, size - count)
|
||||
return _get_range_unlocked(start, size - start)
|
||||
|
||||
|
||||
## Lockless cursor read. The cursor is the next sequence to read: calling
|
||||
## get_since(appended_total()) after a snapshot returns only later appends.
|
||||
func _get_since_unlocked(since_seq: int, limit: int = -1) -> Dictionary:
|
||||
var size := _storage.size()
|
||||
var oldest_seq := _appended_total - size
|
||||
var start_seq := mini(maxi(since_seq, oldest_seq), _appended_total)
|
||||
var start := start_seq - oldest_seq
|
||||
var available := maxi(0, size - start)
|
||||
var count := available
|
||||
if limit >= 0:
|
||||
count = mini(available, limit)
|
||||
var entries := _get_range_unlocked(start, count)
|
||||
var next_cursor := start_seq + entries.size()
|
||||
return {
|
||||
"cursor": since_seq,
|
||||
"oldest_cursor": oldest_seq,
|
||||
"next_cursor": next_cursor,
|
||||
"appended_total": _appended_total,
|
||||
"truncated": since_seq < oldest_seq,
|
||||
"has_more": next_cursor < _appended_total,
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
func get_since(since_seq: int, limit: int = -1) -> Dictionary:
|
||||
return _get_since_unlocked(since_seq, limit)
|
||||
|
||||
|
||||
## Lockless accessors. Subclasses with a mutex use these under their lock
|
||||
## so the field reads stay encapsulated in the base instead of leaking
|
||||
## `_storage` / `_dropped_count` reach-through into the subclass.
|
||||
func _total_count_unlocked() -> int:
|
||||
return _storage.size()
|
||||
|
||||
|
||||
func _dropped_count_unlocked() -> int:
|
||||
return _dropped_count
|
||||
|
||||
|
||||
func _appended_total_unlocked() -> int:
|
||||
return _appended_total
|
||||
|
||||
|
||||
func total_count() -> int:
|
||||
return _total_count_unlocked()
|
||||
|
||||
|
||||
func dropped_count() -> int:
|
||||
return _dropped_count_unlocked()
|
||||
|
||||
|
||||
func appended_total() -> int:
|
||||
return _appended_total_unlocked()
|
||||
|
||||
|
||||
## Translate a logical index (0 = oldest retained) to a physical
|
||||
## `_storage` slot. Before the first wrap, storage-order is logical-
|
||||
## order. After wrapping, the oldest entry lives at `_head`.
|
||||
func _logical_to_physical(logical: int) -> int:
|
||||
if _storage.size() < _max_lines:
|
||||
return logical
|
||||
return (_head + logical) % _max_lines
|
||||
|
||||
|
||||
## Reset the ring to empty. Subclasses with a mutex wrap this with their
|
||||
## lock; subclasses that surface `clear` to callers (McpEditorLogBuffer)
|
||||
## return the prior size from their wrapper.
|
||||
func _clear_storage() -> void:
|
||||
_storage.clear()
|
||||
_head = 0
|
||||
_dropped_count = 0
|
||||
|
||||
|
||||
## Coerce unknown levels to "info" so a misbehaving sender can't poison
|
||||
## downstream filters with arbitrary strings.
|
||||
static func _coerce_level(level: String) -> String:
|
||||
return level if level in VALID_LEVELS else "info"
|
||||
@@ -1 +0,0 @@
|
||||
uid://c4yh3jqfn6dwe
|
||||
@@ -1,642 +0,0 @@
|
||||
@tool
|
||||
class_name McpSurfacedErrorTracker
|
||||
extends RefCounted
|
||||
|
||||
## Central source for "errors the agent should know exist".
|
||||
##
|
||||
## Editor log cursors only cover McpEditorLogBuffer. Runtime errors from the
|
||||
## game subprocess can land solely in the Debugger Errors tab, so this tracker
|
||||
## promotes visible Debugger-tab rows into a monotonic sequence before the
|
||||
## dispatcher stamps a watermark on each response envelope.
|
||||
|
||||
const MAX_PROMOTED_DEBUGGER_ENTRIES := 500
|
||||
const MAX_PROMOTED_DEBUGGER_KEYS := 5000
|
||||
const DEBUGGER_REFRESH_MIN_INTERVAL_MS := 250
|
||||
const DEBUGGER_SCAN_AFTER_STOP_MS := 5000
|
||||
## #641: delays for the self-scheduled forced scans armed on run stop (and on
|
||||
## game-helper hello, via McpDebuggerPlugin). Two ticks: an early one for rows
|
||||
## the remote debugger delivers right around the event, and a late one past
|
||||
## Godot's per-frame Errors-tab insertion throttle for error floods.
|
||||
const DEFERRED_SCAN_DELAYS_SEC: Array[float] = [1.0, 5.0]
|
||||
## #635: cap on accounted per-key row-time signatures. The live Errors tab is
|
||||
## itself bounded, so this only guards a pathological flood of same-keyed rows
|
||||
## with distinct time texts; past the cap the set resets to the current scan.
|
||||
const MAX_ACCOUNTED_ROW_TIMES_PER_KEY := 512
|
||||
|
||||
var _editor_log_buffer
|
||||
var _game_log_buffer
|
||||
var _debugger_errors_root: Node
|
||||
var _debugger_search_root_cache: Node
|
||||
var _promoted_debugger_keys: Dictionary = {}
|
||||
## #635: per-key set of Errors-tab row time texts already promoted, so a row
|
||||
## observed after a clear+repopulate that no scan saw as empty still counts as
|
||||
## new (see the re-promotion comment in refresh_debugger_errors).
|
||||
var _promoted_debugger_row_times: Dictionary = {}
|
||||
var _promoted_debugger_key_order: Array[String] = []
|
||||
var _promoted_debugger_entries: Array[Dictionary] = []
|
||||
var _debugger_promoted_total := 0
|
||||
var _run_seq := 0
|
||||
var _oldest_retained_debugger_sequence := 1
|
||||
var _last_debugger_refresh_msec := -DEBUGGER_REFRESH_MIN_INTERVAL_MS
|
||||
var _debugger_scan_active := false
|
||||
var _debugger_scan_until_msec := 0
|
||||
var _deferred_scans_scheduled_total := 0
|
||||
|
||||
|
||||
func _init(editor_log_buffer = null, game_log_buffer = null, debugger_errors_root: Node = null) -> void:
|
||||
_editor_log_buffer = editor_log_buffer
|
||||
_game_log_buffer = game_log_buffer
|
||||
_debugger_errors_root = debugger_errors_root
|
||||
|
||||
|
||||
func note_game_run_started(sticky_scan: bool = true) -> void:
|
||||
_run_seq += 1
|
||||
_debugger_scan_active = sticky_scan
|
||||
_debugger_scan_until_msec = 0
|
||||
if not sticky_scan:
|
||||
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
|
||||
refresh_debugger_errors(true)
|
||||
|
||||
|
||||
func note_game_run_stopped() -> void:
|
||||
_debugger_scan_active = false
|
||||
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
|
||||
schedule_deferred_scans()
|
||||
|
||||
|
||||
## #641: promotion into the watermark used to depend on a tool call arriving
|
||||
## while the scan gate was open (run active, or within DEBUGGER_SCAN_AFTER_STOP_MS
|
||||
## of stop). Boot parse errors that landed in the Errors tab with no tool call
|
||||
## in that window were never promoted, so the agent never got the
|
||||
## new_errors_since_last_call hint. These editor-side timers force a scan
|
||||
## regardless of tool-call cadence; the next stamped response then carries the
|
||||
## already-promoted count even after the gate closes. Scans are content-keyed
|
||||
## and idempotent, so a timer firing after an unrelated new run is harmless.
|
||||
func schedule_deferred_scans(delays: Array = DEFERRED_SCAN_DELAYS_SEC) -> void:
|
||||
var tree := Engine.get_main_loop() as SceneTree
|
||||
if tree == null:
|
||||
return
|
||||
for delay in delays:
|
||||
var timer := tree.create_timer(maxf(0.05, float(delay)))
|
||||
timer.timeout.connect(_on_deferred_scan_timeout)
|
||||
_deferred_scans_scheduled_total += 1
|
||||
|
||||
|
||||
func deferred_scans_scheduled_total() -> int:
|
||||
return _deferred_scans_scheduled_total
|
||||
|
||||
|
||||
func _on_deferred_scan_timeout() -> void:
|
||||
refresh_debugger_errors(true)
|
||||
|
||||
|
||||
func refresh_debugger_errors(force: bool = true) -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
if not force and not _should_scan_debugger_for_cached_watermark(now):
|
||||
return
|
||||
_last_debugger_refresh_msec = now
|
||||
var current_by_key: Dictionary = {}
|
||||
for entry in _raw_debugger_error_entries():
|
||||
if str(entry.get("level", "")) != "error":
|
||||
continue
|
||||
var key := _log_entry_key(entry)
|
||||
var info: Dictionary = current_by_key.get(key, {"count": 0, "entry": entry, "times": {}})
|
||||
info["count"] = int(info.get("count", 0)) + 1
|
||||
var time_text := _row_time_text(entry)
|
||||
if not time_text.is_empty():
|
||||
(info["times"] as Dictionary)[time_text] = true
|
||||
current_by_key[key] = info
|
||||
for key in _promoted_debugger_keys.keys():
|
||||
if not current_by_key.has(key):
|
||||
_promoted_debugger_keys[key] = 0
|
||||
for key in current_by_key.keys():
|
||||
var info: Dictionary = current_by_key[key]
|
||||
var current := int(info.get("count", 0))
|
||||
var stored := int(_promoted_debugger_keys.get(key, 0))
|
||||
## #635: a count increase alone misses rows observed after a run
|
||||
## boundary. Godot clears the Errors tab at run start; when the new run
|
||||
## re-fires an error identical to one promoted before the clear, and no
|
||||
## scan happened to observe the tab empty in between, the per-key count
|
||||
## never dips — so the row kept its pre-run sequence and run-scoping
|
||||
## (editor_entries_since against the run-start cursor) misclassified an
|
||||
## in-run error as retained_recent. Each Errors-tab row carries its own
|
||||
## time text; an unaccounted (key, time) signature is a row we have not
|
||||
## promoted yet, so it earns a fresh sequence even at an equal or lower
|
||||
## count. Boundary condition: rows with an empty time text, or a
|
||||
## repopulated row whose time text is byte-identical to a pre-clear row,
|
||||
## fall back to count-only dedup and can still be missed.
|
||||
var unseen_times := _unaccounted_row_times(key, info.get("times", {}))
|
||||
var delta := current - stored
|
||||
if delta <= 0 and not unseen_times.is_empty():
|
||||
delta = mini(unseen_times.size(), current)
|
||||
if delta <= 0:
|
||||
if current != stored:
|
||||
_promoted_debugger_keys[key] = current
|
||||
continue
|
||||
if not _promoted_debugger_keys.has(key):
|
||||
_promoted_debugger_key_order.append(key)
|
||||
_promoted_debugger_keys[key] = current
|
||||
_account_row_times(key, info.get("times", {}))
|
||||
_debugger_promoted_total += delta
|
||||
var source_entry: Dictionary = info.get("entry", {})
|
||||
var promoted := source_entry.duplicate(true)
|
||||
promoted["_debugger_key"] = key
|
||||
promoted["_debugger_occurrences"] = current
|
||||
promoted["_debugger_sequence"] = _debugger_promoted_total
|
||||
_remove_promoted_debugger_entry(key)
|
||||
_promoted_debugger_entries.append(promoted)
|
||||
_trim_promoted_debugger_entries()
|
||||
_trim_promoted_debugger_key_counts()
|
||||
|
||||
|
||||
## #645: promote an error record that has no Errors-tab row to scrape — e.g. a
|
||||
## boot-time parse error that parked the game in a remote-debugger break before
|
||||
## any surface got a record. The entry joins the same promoted sequence as
|
||||
## scraped Debugger rows, so run-scoping (editor_entries_since), the retained
|
||||
## fallback, and the response watermark all see it with no extra plumbing.
|
||||
## Re-recording the same key later (the same script still broken on the next
|
||||
## run) re-promotes it with a fresh sequence, mirroring how re-appearing
|
||||
## Errors-tab rows behave; scan reconciliation zeroes the key's count once the
|
||||
## break ends since the row never exists in the live tab.
|
||||
func record_synthetic_error(entry: Dictionary) -> void:
|
||||
var key := _log_entry_key(entry)
|
||||
var occurrences := int(_promoted_debugger_keys.get(key, 0)) + 1
|
||||
if not _promoted_debugger_keys.has(key):
|
||||
_promoted_debugger_key_order.append(key)
|
||||
_promoted_debugger_keys[key] = occurrences
|
||||
_debugger_promoted_total += 1
|
||||
var promoted := entry.duplicate(true)
|
||||
promoted["_debugger_key"] = key
|
||||
promoted["_debugger_occurrences"] = occurrences
|
||||
promoted["_debugger_sequence"] = _debugger_promoted_total
|
||||
promoted["_debugger_synthetic"] = true
|
||||
_remove_promoted_debugger_entry(key)
|
||||
_promoted_debugger_entries.append(promoted)
|
||||
_trim_promoted_debugger_entries()
|
||||
_trim_promoted_debugger_key_counts()
|
||||
|
||||
|
||||
## Monotonicity contract (#767): run_seq and the session-scoped components
|
||||
## (editor_ring, debugger_promoted, editor_ring_warn) must NEVER decrease
|
||||
## within an editor session, and the per-run components (game_error_warn,
|
||||
## game_warn) must never decrease within a run — they may reset only when
|
||||
## run_seq increments in the same stamp (the run boundary that rotates the
|
||||
## game buffer's counters). Released servers diff consecutive stamps
|
||||
## (websocket.py::_sync_error_watermark_for_session) and treat any other
|
||||
## decrease as a counter reset, counting the FULL current value as new — one
|
||||
## dip makes every old server out there over-report errors. Any future
|
||||
## hold/classification feature must therefore DEFER an increment until its
|
||||
## entry is released, never subtract an already-stamped one: a stamp like
|
||||
## `raw_total - currently_held_entries` is exactly the regression this
|
||||
## guards against. Producer-side coverage:
|
||||
## test_editor.gd::test_surfaced_error_tracker_watermark_components_never_decrease.
|
||||
func watermark(force_debugger_scan: bool = false) -> Dictionary:
|
||||
refresh_debugger_errors(force_debugger_scan)
|
||||
return {
|
||||
"run_seq": _run_seq,
|
||||
"editor_ring": _error_appended_total(),
|
||||
"debugger_promoted": _debugger_promoted_total,
|
||||
## Historically misnamed: carries game-process ERROR counts only.
|
||||
"game_error_warn": _game_error_total(),
|
||||
## Warn-level components, parallel to the error counts above. The server
|
||||
## diffs these into `new_warnings_since_last_call` so a warning-only run
|
||||
## surfaces instead of reading as clean. Debugger Errors-tab warning rows
|
||||
## are not promoted here yet (buffers cover push_warning from the game and
|
||||
## editor parse/@tool warnings) — tracked as a follow-up.
|
||||
"editor_ring_warn": _warn_appended_total(),
|
||||
"game_warn": _game_warn_total(),
|
||||
}
|
||||
|
||||
|
||||
static func stamp_watermark(response: Dictionary, tracker) -> void:
|
||||
if tracker == null:
|
||||
return
|
||||
if not tracker.has_method("watermark"):
|
||||
return
|
||||
response["error_watermark"] = tracker.watermark()
|
||||
|
||||
|
||||
func debugger_promoted_total(force_debugger_scan: bool = true) -> int:
|
||||
refresh_debugger_errors(force_debugger_scan)
|
||||
return _debugger_promoted_total
|
||||
|
||||
|
||||
func collect_editor_log_entries() -> Array[Dictionary]:
|
||||
refresh_debugger_errors(true)
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
if _editor_log_buffer != null:
|
||||
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
|
||||
seen_keys[_log_entry_key(entry)] = true
|
||||
entries.append(entry)
|
||||
for entry in read_debugger_error_entries():
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
## #645: synthesized break records have no live Errors-tab row to scrape —
|
||||
## merge them from the promoted list so logs_read(source="editor") shows
|
||||
## the record that run/game responses point at.
|
||||
for entry in _promoted_debugger_entries:
|
||||
if not bool(entry.get("_debugger_synthetic", false)):
|
||||
continue
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(_strip_promotion_bookkeeping(entry))
|
||||
return entries
|
||||
|
||||
|
||||
static func _strip_promotion_bookkeeping(entry: Dictionary) -> Dictionary:
|
||||
var clean := entry.duplicate(true)
|
||||
for key in ["_debugger_key", "_debugger_occurrences", "_debugger_sequence", "_debugger_synthetic"]:
|
||||
clean.erase(key)
|
||||
return clean
|
||||
|
||||
|
||||
func editor_entries_since(editor_cursor: int, debugger_cursor: int, force_debugger_scan: bool = true) -> Dictionary:
|
||||
refresh_debugger_errors(force_debugger_scan)
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
var truncated := false
|
||||
if _editor_log_buffer != null:
|
||||
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, editor_cursor), -1)
|
||||
truncated = bool(captured.get("truncated", false))
|
||||
for entry in captured.get("entries", []):
|
||||
seen_keys[_log_entry_key(entry)] = true
|
||||
entries.append(entry)
|
||||
if debugger_cursor < _oldest_retained_debugger_sequence - 1:
|
||||
truncated = true
|
||||
for entry in _promoted_debugger_entries:
|
||||
if int(entry.get("_debugger_sequence", 0)) <= debugger_cursor:
|
||||
continue
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
return {
|
||||
"entries": entries,
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
|
||||
func retained_recent_editor_entries() -> Array[Dictionary]:
|
||||
## There is no shared timestamp across the editor logger ring and Godot's
|
||||
## Debugger Errors tree. Preserve the pre-PR fallback contract: newest
|
||||
## buffered editor entries first, then debugger-only rows that were not in
|
||||
## the ring, so stale Debugger rows cannot outrank newer ring entries.
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
if _editor_log_buffer != null:
|
||||
entries = _editor_log_buffer.get_recent(_editor_log_buffer.total_count())
|
||||
entries.reverse()
|
||||
for entry in entries:
|
||||
seen_keys[_log_entry_key(entry)] = true
|
||||
for entry in collect_editor_log_entries():
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
func read_debugger_error_entries() -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
for entry in _raw_debugger_error_entries():
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
func locate_debugger_error_trees() -> Array[Tree]:
|
||||
var trees: Array[Tree] = []
|
||||
var root: Node = _debugger_errors_root
|
||||
## #641: a deferred-scan timer can outlive an injected root (tests,
|
||||
## teardown). A freed root must not fall through to the live editor UI —
|
||||
## that would promote unrelated real errors into a tracker scoped to the
|
||||
## dead root — so treat it as "nothing to scan".
|
||||
if root != null and not is_instance_valid(root):
|
||||
return trees
|
||||
if root == null:
|
||||
root = _debugger_search_root()
|
||||
if root == null:
|
||||
return trees
|
||||
_collect_debugger_error_trees(root, trees)
|
||||
return trees
|
||||
|
||||
|
||||
func clear_debugger_error_trees() -> int:
|
||||
var cleared := 0
|
||||
for tree in locate_debugger_error_trees():
|
||||
cleared += entries_from_debugger_error_tree(tree).size()
|
||||
if not _press_debugger_clear_button(tree):
|
||||
## Synthetic roots in tests do not have Godot's Clear button.
|
||||
tree.clear()
|
||||
return cleared
|
||||
|
||||
|
||||
func _debugger_search_root() -> Node:
|
||||
if is_instance_valid(_debugger_search_root_cache):
|
||||
return _debugger_search_root_cache
|
||||
_debugger_search_root_cache = null
|
||||
var base := EditorInterface.get_base_control()
|
||||
if base == null:
|
||||
return null
|
||||
_debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
|
||||
if _debugger_search_root_cache == null:
|
||||
return base
|
||||
return _debugger_search_root_cache
|
||||
|
||||
|
||||
static func _find_first_of_class(node: Node, klass: String) -> Node:
|
||||
if node.get_class() == klass:
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var found := _find_first_of_class(child, klass)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
|
||||
if node is Tree and _tree_has_debugger_errors(node as Tree):
|
||||
out.append(node as Tree)
|
||||
for child in node.get_children():
|
||||
if child is Node:
|
||||
_collect_debugger_error_trees(child as Node, out)
|
||||
|
||||
|
||||
static func _tree_has_debugger_errors(tree: Tree) -> bool:
|
||||
var root := tree.get_root()
|
||||
if root == null:
|
||||
return false
|
||||
var item := root.get_first_child()
|
||||
while item != null:
|
||||
if _is_debugger_error_item(item):
|
||||
return true
|
||||
item = item.get_next()
|
||||
return false
|
||||
|
||||
|
||||
static func _press_debugger_clear_button(tree: Tree) -> bool:
|
||||
var parent := tree.get_parent()
|
||||
if parent == null:
|
||||
return false
|
||||
var stack: Array[Node] = [parent]
|
||||
while not stack.is_empty():
|
||||
var node: Node = stack.pop_back()
|
||||
if node is BaseButton:
|
||||
for conn in node.get_signal_connection_list("pressed"):
|
||||
if str(conn.get("callable", "")).contains("_clear_errors_list"):
|
||||
node.emit_signal("pressed")
|
||||
return true
|
||||
for child in node.get_children():
|
||||
stack.push_back(child)
|
||||
return false
|
||||
|
||||
|
||||
static func entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
var root := tree.get_root()
|
||||
if root == null:
|
||||
return entries
|
||||
var item := root.get_first_child()
|
||||
while item != null:
|
||||
if _is_debugger_error_item(item):
|
||||
entries.append(_entry_from_debugger_error_item(item))
|
||||
item = item.get_next()
|
||||
return entries
|
||||
|
||||
|
||||
static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
|
||||
var title := item.get_text(1)
|
||||
var loc := _location_from_metadata(item.get_metadata(0))
|
||||
var function := _function_from_title(title)
|
||||
return {
|
||||
"source": "editor",
|
||||
"level": "warn" if item.has_meta("_is_warning") else "error",
|
||||
"text": title,
|
||||
"path": str(loc.get("path", "")),
|
||||
"line": int(loc.get("line", 0)),
|
||||
"function": function,
|
||||
"details": _details_from_debugger_error_item(item, loc, function),
|
||||
}
|
||||
|
||||
|
||||
static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
|
||||
var children: Array[Dictionary] = []
|
||||
var child := item.get_first_child()
|
||||
while child != null:
|
||||
var child_loc := _location_from_metadata(child.get_metadata(0))
|
||||
children.append({
|
||||
"label": child.get_text(0),
|
||||
"text": child.get_text(1),
|
||||
"path": str(child_loc.get("path", "")),
|
||||
"line": int(child_loc.get("line", 0)),
|
||||
})
|
||||
child = child.get_next()
|
||||
return {
|
||||
"debugger_tab": "Errors",
|
||||
"time": item.get_text(0),
|
||||
"message": item.get_text(1),
|
||||
"error_type_name": "warning" if item.has_meta("_is_warning") else "error",
|
||||
"source": {
|
||||
"path": str(loc.get("path", "")),
|
||||
"line": int(loc.get("line", 0)),
|
||||
"function": function,
|
||||
},
|
||||
"resolved": {
|
||||
"path": str(loc.get("path", "")),
|
||||
"line": int(loc.get("line", 0)),
|
||||
"function": function,
|
||||
},
|
||||
"children": children,
|
||||
"frames": _frames_from_error_children(children),
|
||||
}
|
||||
|
||||
|
||||
static func _is_debugger_error_item(item: TreeItem) -> bool:
|
||||
return item.has_meta("_is_warning") or item.has_meta("_is_error")
|
||||
|
||||
|
||||
static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var start := -1
|
||||
for i in children.size():
|
||||
if str(children[i].label).contains("Stack Trace"):
|
||||
start = i
|
||||
break
|
||||
if start < 0:
|
||||
for i in children.size():
|
||||
if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
|
||||
start = maxi(i - 1, 0)
|
||||
break
|
||||
if start < 0:
|
||||
return []
|
||||
var frames: Array[Dictionary] = []
|
||||
for i in range(start, children.size()):
|
||||
if str(children[i].path).is_empty():
|
||||
continue
|
||||
frames.append({
|
||||
"path": children[i].path,
|
||||
"line": children[i].line,
|
||||
"function": _function_from_frame_text(children[i].text),
|
||||
})
|
||||
return frames
|
||||
|
||||
|
||||
static func _location_from_metadata(meta: Variant) -> Dictionary:
|
||||
if meta is Array and meta.size() >= 2:
|
||||
return {"path": str(meta[0]), "line": int(meta[1])}
|
||||
return {"path": "", "line": 0}
|
||||
|
||||
|
||||
static func _function_from_title(title: String) -> String:
|
||||
var colon := title.find(": ")
|
||||
if colon <= 0:
|
||||
return ""
|
||||
return title.substr(0, colon)
|
||||
|
||||
|
||||
static func _function_from_frame_text(text: String) -> String:
|
||||
var marker := text.find(" @ ")
|
||||
if marker < 0:
|
||||
return ""
|
||||
var fn := text.substr(marker + 3).strip_edges()
|
||||
if fn.ends_with("()"):
|
||||
fn = fn.substr(0, fn.length() - 2)
|
||||
return fn
|
||||
|
||||
|
||||
## Shared one-line rendering of a compact editor-error entry for messages and
|
||||
## hints ("text (path:line)"). Single home so the debugger plugin, project
|
||||
## handler, and editor handler can't drift apart.
|
||||
static func format_editor_error_summary(entry: Dictionary) -> String:
|
||||
var text := str(entry.get("text", "editor error"))
|
||||
var path := str(entry.get("path", ""))
|
||||
var line := int(entry.get("line", 0))
|
||||
if not path.is_empty() and line > 0:
|
||||
return "%s (%s:%d)" % [text, path, line]
|
||||
if not path.is_empty():
|
||||
return "%s (%s)" % [text, path]
|
||||
return text
|
||||
|
||||
|
||||
static func _log_entry_key(entry: Dictionary) -> String:
|
||||
return "%s|%s|%s|%s" % [
|
||||
str(entry.get("level", "")),
|
||||
str(entry.get("text", "")),
|
||||
str(entry.get("path", "")),
|
||||
str(entry.get("line", 0)),
|
||||
]
|
||||
|
||||
|
||||
func _error_appended_total() -> int:
|
||||
if _editor_log_buffer == null:
|
||||
return 0
|
||||
if _editor_log_buffer.has_method("error_appended_total"):
|
||||
return int(_editor_log_buffer.call("error_appended_total"))
|
||||
return 0
|
||||
|
||||
|
||||
func _game_error_total() -> int:
|
||||
if _game_log_buffer == null:
|
||||
return 0
|
||||
if _game_log_buffer.has_method("error_total"):
|
||||
return int(_game_log_buffer.call("error_total"))
|
||||
return 0
|
||||
|
||||
|
||||
func _warn_appended_total() -> int:
|
||||
if _editor_log_buffer == null:
|
||||
return 0
|
||||
if _editor_log_buffer.has_method("warn_appended_total"):
|
||||
return int(_editor_log_buffer.call("warn_appended_total"))
|
||||
return 0
|
||||
|
||||
|
||||
func _game_warn_total() -> int:
|
||||
if _game_log_buffer == null:
|
||||
return 0
|
||||
if _game_log_buffer.has_method("warn_total"):
|
||||
return int(_game_log_buffer.call("warn_total"))
|
||||
return 0
|
||||
|
||||
|
||||
func _should_scan_debugger_for_cached_watermark(now_msec: int) -> bool:
|
||||
if not _debugger_scan_active and now_msec > _debugger_scan_until_msec:
|
||||
return false
|
||||
return now_msec - _last_debugger_refresh_msec >= DEBUGGER_REFRESH_MIN_INTERVAL_MS
|
||||
|
||||
|
||||
func _trim_promoted_debugger_entries() -> void:
|
||||
while _promoted_debugger_entries.size() > MAX_PROMOTED_DEBUGGER_ENTRIES:
|
||||
_promoted_debugger_entries.pop_front()
|
||||
if _promoted_debugger_entries.is_empty():
|
||||
_oldest_retained_debugger_sequence = _debugger_promoted_total + 1
|
||||
else:
|
||||
_oldest_retained_debugger_sequence = int(_promoted_debugger_entries[0].get("_debugger_sequence", 1))
|
||||
|
||||
|
||||
func _trim_promoted_debugger_key_counts() -> void:
|
||||
while _promoted_debugger_key_order.size() > MAX_PROMOTED_DEBUGGER_KEYS:
|
||||
var key := _promoted_debugger_key_order.pop_front()
|
||||
_promoted_debugger_keys.erase(key)
|
||||
_promoted_debugger_row_times.erase(key)
|
||||
|
||||
|
||||
## #635: per-row time text from a scraped Errors-tab entry (column 0 of the
|
||||
## row, carried in details.time). Empty when the entry has no details — e.g.
|
||||
## synthetic records — which keeps those on count-only dedup.
|
||||
static func _row_time_text(entry: Dictionary) -> String:
|
||||
var details: Variant = entry.get("details", {})
|
||||
if details is Dictionary:
|
||||
return str((details as Dictionary).get("time", ""))
|
||||
return ""
|
||||
|
||||
|
||||
func _unaccounted_row_times(key: String, times: Dictionary) -> Array:
|
||||
var accounted: Dictionary = _promoted_debugger_row_times.get(key, {})
|
||||
var unseen := []
|
||||
for time_text in times.keys():
|
||||
if not accounted.has(time_text):
|
||||
unseen.append(time_text)
|
||||
return unseen
|
||||
|
||||
|
||||
func _account_row_times(key: String, times: Dictionary) -> void:
|
||||
if times.is_empty():
|
||||
return
|
||||
var accounted: Dictionary = _promoted_debugger_row_times.get(key, {})
|
||||
for time_text in times.keys():
|
||||
accounted[time_text] = true
|
||||
## Enforce the bound AFTER merging: a pre-merge `>` check let the set
|
||||
## reach the cap and keep growing (and a batch of new times could jump
|
||||
## past it). Past the cap, reset to just this scan's times — the live
|
||||
## Errors tab is itself bounded, so this only fires under a pathological
|
||||
## same-key flood, where "recent scan only" is an acceptable memory of
|
||||
## what was promoted (worst case: a re-observed ancient row re-promotes).
|
||||
if accounted.size() > MAX_ACCOUNTED_ROW_TIMES_PER_KEY:
|
||||
accounted = times.duplicate()
|
||||
_promoted_debugger_row_times[key] = accounted
|
||||
|
||||
|
||||
func _remove_promoted_debugger_entry(key: String) -> void:
|
||||
for i in range(_promoted_debugger_entries.size() - 1, -1, -1):
|
||||
if str(_promoted_debugger_entries[i].get("_debugger_key", "")) == key:
|
||||
_promoted_debugger_entries.remove_at(i)
|
||||
return
|
||||
|
||||
|
||||
func _raw_debugger_error_entries() -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
for tree in locate_debugger_error_trees():
|
||||
entries.append_array(entries_from_debugger_error_tree(tree))
|
||||
return entries
|
||||
@@ -1 +0,0 @@
|
||||
uid://o0ulahkt83re
|
||||
@@ -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
|
||||
@@ -1,775 +0,0 @@
|
||||
@tool
|
||||
class_name McpUpdateManager
|
||||
extends Node
|
||||
|
||||
## Self-update manager for pre-runner work. Owns release checks, HTTP ZIP
|
||||
## download, the install-in-flight gate, and install state signals back to
|
||||
## the dock. Once `_install_zip()` calls
|
||||
## `plugin.gd::install_downloaded_update(...)`, ownership transfers to
|
||||
## `update_reload_runner.gd`, which owns extract, scan, plugin re-enable,
|
||||
## and detached-dock cleanup.
|
||||
##
|
||||
## The dock owns banner rendering and forwards button clicks. The split
|
||||
## exists because the dock script is one of the files overwritten on disk
|
||||
## during install — keeping pipeline state on a separate Node lets the dock
|
||||
## tear down cleanly without losing the in-flight gate that other dock spawn
|
||||
## paths consult.
|
||||
##
|
||||
## `class_name McpUpdateManager` is retained because it shipped in a
|
||||
## published release. If this class is ever retired, follow CLAUDE.md's
|
||||
## never-delete-published-class_name shim policy instead of deleting the
|
||||
## declaration.
|
||||
##
|
||||
## `_plugin` and `_dock` are deliberately untyped: the same self-update
|
||||
## window that overwrites this script also overwrites the dock and plugin
|
||||
## scripts, and a static-typed reference into a script being hot-reloaded
|
||||
## crashes inside `GDScriptFunction::call`. `server_lifecycle.gd` follows
|
||||
## the same convention.
|
||||
|
||||
const RELEASES_URL := (
|
||||
"https://api.github.com/repos/hi-godot/godot-ai/releases/latest"
|
||||
)
|
||||
const RELEASES_PAGE := "https://github.com/hi-godot/godot-ai/releases/latest"
|
||||
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")
|
||||
|
||||
## RSA-4096 public key for release-signature verification (#687). The paired
|
||||
## private key exists only in the GitHub Actions secret RELEASE_SIGNING_KEY_PEM
|
||||
## (plus the maintainer's offline backup) — deliberately outside the repo
|
||||
## token's scope, because the threat model is release-asset substitution by a
|
||||
## leaked token or compromised workflow, and a token that can rewrite assets
|
||||
## still cannot read secrets. Rotation requires shipping a new plugin release
|
||||
## embedding the new key (and bumping SIGNING_REQUIRED_FROM_VERSION past the
|
||||
## last release signed with the old one).
|
||||
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-----
|
||||
"""
|
||||
|
||||
## Every release at or above this version ships a signed sidecar
|
||||
## (release.yml hard-fails without the signing secret). At or above it, a
|
||||
## missing `.sha256.sig` asset is treated as tampering — an attacker who can
|
||||
## rewrite release assets could otherwise just strip the signature to skip
|
||||
## verification. Below it (releases published before signing existed), the
|
||||
## legacy checksum-only path still installs.
|
||||
const SIGNING_REQUIRED_FROM_VERSION := "2.9.3"
|
||||
|
||||
## Host -> required path prefix for self-update downloads (ZIP and checksum
|
||||
## sidecar). The URLs are taken verbatim from the GitHub Releases API's
|
||||
## `browser_download_url`, so before fetching we pin them to https on a
|
||||
## GitHub-owned host AND to this repo's release-asset path (#599) — a
|
||||
## tampered or unexpected API response can't point the in-editor updater at
|
||||
## an arbitrary origin, nor at a release asset of a *different* repo on a
|
||||
## trusted host.
|
||||
##
|
||||
## In practice `browser_download_url` is always the
|
||||
## `https://github.com/hi-godot/godot-ai/releases/download/<tag>/<asset>`
|
||||
## shape; HTTPRequest then follows the github.com -> *.githubusercontent.com
|
||||
## redirect internally (this guard validates the entry point, not each hop).
|
||||
## The CDN hosts are kept as defense-in-depth should the API ever hand back
|
||||
## a direct CDN URL — their object keys carry the repo *id*, not the repo
|
||||
## name, so the tightest checkable prefix there is the release-asset key
|
||||
## namespace.
|
||||
const _TRUSTED_DOWNLOAD_PATH_PREFIXES := {
|
||||
"github.com": "/hi-godot/godot-ai/releases/download/",
|
||||
"www.github.com": "/hi-godot/godot-ai/releases/download/",
|
||||
"api.github.com": "/repos/hi-godot/godot-ai/releases/assets/",
|
||||
"objects.githubusercontent.com": "/github-production-release-asset-",
|
||||
"release-assets.githubusercontent.com": "/github-production-release-asset-",
|
||||
}
|
||||
|
||||
## 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}
|
||||
signal update_check_completed(result: Dictionary)
|
||||
|
||||
## Emitted at every UI-relevant step of the install pipeline. Payload
|
||||
## keys are all optional and apply on top of the current banner state:
|
||||
## label_text: String ## banner label override
|
||||
## button_text: String ## update button text override
|
||||
## button_disabled: bool ## update button disabled state
|
||||
## banner_visible: bool ## banner visibility override
|
||||
## outcome: String ## "success" -> dock paints green
|
||||
signal install_state_changed(state: Dictionary)
|
||||
|
||||
var _plugin
|
||||
var _dock
|
||||
|
||||
var _http_request: HTTPRequest
|
||||
var _download_request: HTTPRequest
|
||||
var _verify_request: HTTPRequest
|
||||
var _signature_request: HTTPRequest
|
||||
var _latest_download_url: String = ""
|
||||
## URL of the `godot-ai-plugin.zip.sha256` sidecar asset. Used to verify the
|
||||
## downloaded archive's integrity before extract (#523). Verification is
|
||||
## mandatory (#599): when a release ships no sidecar this stays empty and
|
||||
## `_verify_then_install` refuses the install.
|
||||
var _latest_checksum_url: String = ""
|
||||
## URL of the `godot-ai-plugin.zip.sha256.sig` signature asset (#687). Empty
|
||||
## on releases published before signing existed; `_verify_then_install`
|
||||
## refuses an empty URL once the remote version is inside the signing era
|
||||
## (see SIGNING_REQUIRED_FROM_VERSION).
|
||||
var _latest_signature_url: String = ""
|
||||
## Remote version from the last update check — drives the
|
||||
## signature-required compat gate in `_verify_then_install`.
|
||||
var _latest_remote_version: String = ""
|
||||
## Sidecar bytes + parsed digest held between the checksum download and the
|
||||
## signature verdict, so the signature is checked against exactly the bytes
|
||||
## the digest was parsed from.
|
||||
var _pending_sidecar_body := PackedByteArray()
|
||||
var _pending_expected_digest: String = ""
|
||||
|
||||
## Set for the duration of `_install_zip` — extract-overwrite of plugin
|
||||
## scripts on disk would crash any worker mid-`GDScriptFunction::call`
|
||||
## (confirmed via SIGABRT in the dock's refresh worker). Dock spawn paths
|
||||
## consult this via `is_install_in_flight()`; in-flight workers are
|
||||
## drained before any disk write.
|
||||
var _install_in_flight: bool = false
|
||||
|
||||
|
||||
# ---- Setup -------------------------------------------------------------
|
||||
|
||||
func setup(plugin, dock) -> void:
|
||||
_plugin = plugin
|
||||
_dock = dock
|
||||
|
||||
|
||||
# ---- Public API ---------------------------------------------------------
|
||||
|
||||
## Kick off the GitHub Releases API check. No-ops in dev checkouts —
|
||||
## `addons/godot_ai/` is a symlink into canonical `plugin/` source there,
|
||||
## and an extract would clobber tracked files (#116). `is_dev_checkout()`
|
||||
## honours the mode override (EditorSetting `godot_ai/mode_override` >
|
||||
## `GODOT_AI_MODE` env), so
|
||||
## testers can force `user` to exercise the AssetLib flow from a dev tree;
|
||||
## `_install_zip` still gates on the physical symlink check so a forced-
|
||||
## user mode can never clobber source.
|
||||
func check_for_updates() -> void:
|
||||
if ClientConfigurator.is_dev_checkout():
|
||||
return
|
||||
if _http_request == null:
|
||||
_http_request = HTTPRequest.new()
|
||||
_http_request.request_completed.connect(_on_update_check_completed)
|
||||
add_child(_http_request)
|
||||
_http_request.request(RELEASES_URL, ["Accept: application/vnd.github+json"])
|
||||
|
||||
|
||||
## Cancel any in-flight check so a follow-up check_for_updates() can't
|
||||
## hit ERR_BUSY on the shared HTTPRequest. No current dock caller — the
|
||||
## mode-override dropdown that used it was removed in #408; kept as
|
||||
## published API of the update flow.
|
||||
func cancel_check() -> void:
|
||||
if _http_request != null:
|
||||
_http_request.cancel_request()
|
||||
|
||||
|
||||
## Reset the cached download/checksum URLs so a fresh check paints over
|
||||
## a clean banner. No current production caller — the mode-override
|
||||
## dropdown that used it was removed in #408; kept for tests and any
|
||||
## future re-check path.
|
||||
func clear_pending_download() -> void:
|
||||
_latest_download_url = ""
|
||||
_latest_checksum_url = ""
|
||||
_latest_signature_url = ""
|
||||
_latest_remote_version = ""
|
||||
_pending_sidecar_body = PackedByteArray()
|
||||
_pending_expected_digest = ""
|
||||
|
||||
|
||||
## True when the running Godot is within the supported self-update floor.
|
||||
## Godot < 4.5 must not be offered a one-click update to a release whose
|
||||
## always-loaded scripts depend on 4.5 APIs/classes.
|
||||
## Guards `major` too so a future Godot 5.x (minor 0) isn't misclassified.
|
||||
func _can_self_update() -> bool:
|
||||
var v := Engine.get_version_info()
|
||||
return _version_can_self_update(int(v.get("major", 0)), int(v.get("minor", 0)))
|
||||
|
||||
|
||||
## Pure version predicate, split out so it's testable without faking the
|
||||
## running engine. In-editor self-update needs Godot >= 4.5.
|
||||
static func _version_can_self_update(major: int, minor: int) -> bool:
|
||||
return major > 4 or (major == 4 and minor >= 5)
|
||||
|
||||
|
||||
## Banner guidance for engines below the support floor. Shown up-front at
|
||||
## check time so those users do not install an incompatible latest release.
|
||||
static func _manual_update_label(version: String) -> String:
|
||||
var release_noun := "release"
|
||||
var suffix := ""
|
||||
if not version.is_empty():
|
||||
release_noun = "version"
|
||||
suffix = " (latest: v%s)" % version
|
||||
return (
|
||||
"This is the last Godot AI %s for this Godot%s. " % [release_noun, suffix]
|
||||
+ "Upgrade to Godot 4.5+ to keep receiving updates."
|
||||
)
|
||||
|
||||
## Driven by the dock's Update button. On Godot < 4.5 (see _can_self_update)
|
||||
## the in-editor install is disabled so users cannot install an incompatible
|
||||
## latest release. With no resolved download URL, falls back to opening the
|
||||
## release page. Otherwise kicks off the download -> extract -> reload pipeline.
|
||||
func start_install() -> void:
|
||||
if not _can_self_update():
|
||||
install_state_changed.emit({
|
||||
"button_text": "Upgrade Godot",
|
||||
"button_disabled": true,
|
||||
"label_text": _manual_update_label(""),
|
||||
"banner_visible": true,
|
||||
})
|
||||
return
|
||||
|
||||
if _latest_download_url.is_empty():
|
||||
OS.shell_open(RELEASES_PAGE)
|
||||
return
|
||||
|
||||
## Pin the resolved asset URL to https on a GitHub host AND to this
|
||||
## repo's release-asset path before fetching (#523, #599). Fall back to
|
||||
## the release page (a user-driven browser download) rather than pulling
|
||||
## an executable plugin payload from an unexpected origin.
|
||||
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,
|
||||
})
|
||||
|
||||
## Warm the uv cache for the NEW server version while the plugin zip
|
||||
## downloads (see prewarm_server_package): the post-update walk must win
|
||||
## a port bind race against attach bridges respawning the cached OLD
|
||||
## version, and a cold uvx resolve loses that race every time. uvx is
|
||||
## almost always cached in the CliFinder by now (the dock's setup probe);
|
||||
## a cold lookup is the same bounded main-thread cost as
|
||||
## `_reprobe_uv_if_negative`, on the same rare click-driven path.
|
||||
ClientConfigurator.prewarm_server_package(_latest_remote_version)
|
||||
|
||||
if _download_request != null:
|
||||
_download_request.queue_free()
|
||||
_download_request = HTTPRequest.new()
|
||||
var global_zip := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)
|
||||
var global_dir := ProjectSettings.globalize_path(UPDATE_TEMP_DIR)
|
||||
DirAccess.make_dir_recursive_absolute(global_dir)
|
||||
_download_request.download_file = global_zip
|
||||
_download_request.max_redirects = 10
|
||||
_download_request.request_completed.connect(_on_download_completed)
|
||||
add_child(_download_request)
|
||||
var err := _download_request.request(_latest_download_url)
|
||||
if err != OK:
|
||||
## `request_completed` never fires when `request()` itself errors,
|
||||
## so cleanup (queue_free + null + drop the staged zip) has to land
|
||||
## inline — otherwise the HTTPRequest stays parented under the
|
||||
## manager until the next click.
|
||||
_download_request.queue_free()
|
||||
_download_request = null
|
||||
DirAccess.remove_absolute(global_zip)
|
||||
install_state_changed.emit({
|
||||
"button_text": "Request failed",
|
||||
"button_disabled": false,
|
||||
})
|
||||
|
||||
## Consulted by the dock's spawn paths (focus-in refresh, manual button,
|
||||
## deferred initial refresh) — true while plugin scripts are being
|
||||
## overwritten. A worker mid-`GDScriptFunction::call` into a half-
|
||||
## overwritten script SIGABRTs the editor.
|
||||
func is_install_in_flight() -> bool:
|
||||
return _install_in_flight
|
||||
|
||||
|
||||
# ---- Releases-API parse (pure, testable) -------------------------------
|
||||
|
||||
## Parses the GitHub Releases API JSON response. Returns:
|
||||
## has_update: bool ## true if remote tag > local version
|
||||
## version: String ## remote tag minus leading "v"
|
||||
## 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)
|
||||
## signature_url: String ## `godot-ai-plugin.zip.sha256.sig` asset URL ("" if absent)
|
||||
##
|
||||
## Static so tests drive it without instancing the manager.
|
||||
static func parse_releases_response(
|
||||
result: int, response_code: int, body: PackedByteArray
|
||||
) -> Dictionary:
|
||||
var out := {
|
||||
"has_update": false,
|
||||
"version": "",
|
||||
"forced": false,
|
||||
"label_text": "",
|
||||
"download_url": "",
|
||||
"checksum_url": "",
|
||||
"signature_url": "",
|
||||
}
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
|
||||
return out
|
||||
var parsed = JSON.parse_string(body.get_string_from_utf8())
|
||||
if parsed == null or not (parsed is Dictionary):
|
||||
return out
|
||||
var json: Dictionary = parsed
|
||||
var tag: String = String(json.get("tag_name", ""))
|
||||
if tag.is_empty():
|
||||
return out
|
||||
var remote_version := tag.trim_prefix("v")
|
||||
var local_version := ClientConfigurator.get_plugin_version()
|
||||
if not _is_newer(remote_version, local_version):
|
||||
return out
|
||||
|
||||
var url := ""
|
||||
var checksum_url := ""
|
||||
var signature_url := ""
|
||||
var assets: Array = json.get("assets", [])
|
||||
for asset in assets:
|
||||
var asset_dict: Dictionary = asset
|
||||
var asset_name := String(asset_dict.get("name", ""))
|
||||
if asset_name == "godot-ai-plugin.zip":
|
||||
url = String(asset_dict.get("browser_download_url", ""))
|
||||
elif asset_name == "godot-ai-plugin.zip.sha256":
|
||||
checksum_url = String(asset_dict.get("browser_download_url", ""))
|
||||
elif asset_name == "godot-ai-plugin.zip.sha256.sig":
|
||||
signature_url = String(asset_dict.get("browser_download_url", ""))
|
||||
|
||||
var forced := ClientConfigurator.mode_override() == "user"
|
||||
var label_text := "Update available: v%s" % remote_version
|
||||
if forced:
|
||||
## Forced-user mode (EditorSetting or env) is the only way the banner
|
||||
## lights up in a dev tree; suffix so the operator notices.
|
||||
label_text += " (forced)"
|
||||
|
||||
out["has_update"] = true
|
||||
out["version"] = remote_version
|
||||
out["forced"] = forced
|
||||
out["label_text"] = label_text
|
||||
out["download_url"] = url
|
||||
out["checksum_url"] = checksum_url
|
||||
out["signature_url"] = signature_url
|
||||
return out
|
||||
|
||||
|
||||
## True only for an `https://` URL whose host is a key of
|
||||
## `_TRUSTED_DOWNLOAD_PATH_PREFIXES` AND whose path starts with that host's
|
||||
## required prefix — trusted host alone is not enough; the URL must be a
|
||||
## hi-godot/godot-ai release asset (#599). 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`. The path is compared case-sensitively (GitHub
|
||||
## release paths are case-sensitive). 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
|
||||
if url.find("\\") >= 0:
|
||||
return false
|
||||
var rest := url.substr(SCHEME.length())
|
||||
var authority := rest
|
||||
var path := ""
|
||||
var slash := rest.find("/")
|
||||
if slash >= 0:
|
||||
authority = rest.substr(0, slash)
|
||||
path = rest.substr(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)
|
||||
var host := authority.to_lower()
|
||||
if not _TRUSTED_DOWNLOAD_PATH_PREFIXES.has(host):
|
||||
return false
|
||||
## Scope the checks below to the path proper (#713): direct CDN asset
|
||||
## URLs carry signed query params (X-Amz-Credential=...%2F...) whose
|
||||
## legitimate %2F tokens made every CDN prefix unreachable when the
|
||||
## needle scan covered the query string. Routing is decided by the
|
||||
## path, so the query is safe to ignore.
|
||||
var qmark := path.find("?")
|
||||
if qmark >= 0:
|
||||
path = path.substr(0, qmark)
|
||||
## Reject dot-segments (and their percent-encoded forms) anywhere in the
|
||||
## path: "/hi-godot/godot-ai/releases/download/../../evil/..." passes a
|
||||
## raw string-prefix test but normalizes server-side to a different repo,
|
||||
## defeating the scoping (#599 review). Also reject percent-encoded
|
||||
## slashes, which some servers decode before routing.
|
||||
var lower_path := path.to_lower()
|
||||
for needle in ["/../", "/..", "%2e", "%2f", "%5c"]:
|
||||
if lower_path.contains(needle):
|
||||
return false
|
||||
return path.begins_with(String(_TRUSTED_DOWNLOAD_PATH_PREFIXES[host]))
|
||||
|
||||
|
||||
static func _is_newer(remote: String, local: String) -> bool:
|
||||
var r := remote.split(".")
|
||||
var l := local.split(".")
|
||||
for i in range(max(r.size(), l.size())):
|
||||
var rv := int(r[i]) if i < r.size() else 0
|
||||
var lv := int(l[i]) if i < l.size() else 0
|
||||
if rv > lv:
|
||||
return true
|
||||
if rv < lv:
|
||||
return false
|
||||
return false
|
||||
|
||||
|
||||
# ---- HTTPRequest callbacks (instance-side) -----------------------------
|
||||
|
||||
func _on_update_check_completed(
|
||||
result: int,
|
||||
response_code: int,
|
||||
_headers: PackedStringArray,
|
||||
body: PackedByteArray
|
||||
) -> void:
|
||||
var parsed := parse_releases_response(result, response_code, body)
|
||||
if not bool(parsed.get("has_update", false)):
|
||||
return
|
||||
if not _can_self_update():
|
||||
install_state_changed.emit({
|
||||
"button_text": "Upgrade Godot",
|
||||
"button_disabled": true,
|
||||
"label_text": _manual_update_label(String(parsed.get("version", ""))),
|
||||
"banner_visible": true,
|
||||
})
|
||||
return
|
||||
_latest_download_url = String(parsed.get("download_url", ""))
|
||||
_latest_checksum_url = String(parsed.get("checksum_url", ""))
|
||||
_latest_signature_url = String(parsed.get("signature_url", ""))
|
||||
_latest_remote_version = String(parsed.get("version", ""))
|
||||
update_check_completed.emit(parsed)
|
||||
|
||||
|
||||
func _on_download_completed(
|
||||
result: int,
|
||||
response_code: int,
|
||||
_headers: PackedStringArray,
|
||||
_body: PackedByteArray
|
||||
) -> void:
|
||||
if _download_request != null:
|
||||
_download_request.queue_free()
|
||||
_download_request = null
|
||||
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
|
||||
print("MCP | update download failed: result=%d code=%d" % [result, response_code])
|
||||
## Failure parity with _fail_verification (#713): HTTPRequest's
|
||||
## download_file mode leaves whatever partial/error bytes it wrote
|
||||
## staged at UPDATE_TEMP_ZIP — drop them so no later step can ever
|
||||
## pick up a half-downloaded archive.
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP))
|
||||
install_state_changed.emit({
|
||||
"button_text": "Download failed (%d)" % response_code,
|
||||
"button_disabled": false,
|
||||
})
|
||||
return
|
||||
|
||||
# Deferred so the HTTPRequest callback returns before the next step starts.
|
||||
_verify_then_install.call_deferred()
|
||||
|
||||
|
||||
# ---- Integrity verification (#523, #599, #687) --------------------------
|
||||
|
||||
## Gate the extract on (1) an RSA signature over the checksum sidecar and
|
||||
## (2) a SHA-256 match of the archive against that sidecar. TLS + host
|
||||
## pinning constrain where the bytes came from; the digest verifies the
|
||||
## bytes themselves (in-transit corruption, single-object substitution);
|
||||
## the signature verifies the digest's *provenance*. Both `download_url`
|
||||
## and `checksum_url` come from the same GitHub Releases API response over
|
||||
## the same channel, so anyone able to modify the release's assets (leaked
|
||||
## repo token, compromised release workflow) can regenerate the sidecar to
|
||||
## match a tampered zip — but cannot forge the `.sha256.sig` signature,
|
||||
## whose private key lives only in an Actions secret outside the repo
|
||||
## token's scope (#687).
|
||||
##
|
||||
## Verification is MANDATORY (#599): no `.sha256` sidecar — mistake or
|
||||
## tamper — refuses to install. The signature is mandatory for every
|
||||
## release at or above SIGNING_REQUIRED_FROM_VERSION: a missing signature
|
||||
## there is a strip-attack signal, not a compat case, and hard-fails. Only
|
||||
## releases predating signing take the legacy checksum-only path.
|
||||
func _verify_then_install() -> void:
|
||||
_pending_sidecar_body = PackedByteArray()
|
||||
_pending_expected_digest = ""
|
||||
|
||||
if _latest_checksum_url.is_empty():
|
||||
_fail_verification(
|
||||
"release published no godot-ai-plugin.zip.sha256 sidecar; "
|
||||
+ "refusing unverified install (#599)"
|
||||
)
|
||||
return
|
||||
|
||||
## A present-but-untrusted checksum URL is a tamper signal, not a
|
||||
## backward-compat case — refuse rather than silently skip. Trusted
|
||||
## means a GitHub host AND this repo's release-asset path (#599).
|
||||
if not _is_trusted_download_url(_latest_checksum_url):
|
||||
_fail_verification("checksum URL is not a trusted hi-godot/godot-ai release asset")
|
||||
return
|
||||
|
||||
if _latest_signature_url.is_empty():
|
||||
if _signature_required(_latest_remote_version):
|
||||
_fail_verification(
|
||||
"release v%s ships no godot-ai-plugin.zip.sha256.sig signature. "
|
||||
% _latest_remote_version
|
||||
+ "Every release from v%s on is signed" % SIGNING_REQUIRED_FROM_VERSION
|
||||
+ " — a missing signature means a stripped or tampered release (#687)"
|
||||
)
|
||||
return
|
||||
print(
|
||||
"MCP | self-update: release v%s predates signing; " % _latest_remote_version
|
||||
+ "using legacy checksum-only verification (#687)"
|
||||
)
|
||||
elif not _is_trusted_download_url(_latest_signature_url):
|
||||
_fail_verification("signature URL is not a trusted hi-godot/godot-ai release asset")
|
||||
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
|
||||
|
||||
## Signature verification (when armed) runs over the exact sidecar bytes
|
||||
## the digest was parsed from — hold both until the signature verdict.
|
||||
if not _latest_signature_url.is_empty():
|
||||
_pending_sidecar_body = body
|
||||
_pending_expected_digest = expected
|
||||
_fetch_signature()
|
||||
return
|
||||
|
||||
## Legacy pre-signing release: `_verify_then_install` already gated this
|
||||
## on the remote version predating SIGNING_REQUIRED_FROM_VERSION.
|
||||
_finish_digest_check_and_install(expected)
|
||||
|
||||
|
||||
## Download the `.sha256.sig` release asset; `_on_signature_completed`
|
||||
## verifies it over the held sidecar bytes before the digest is trusted.
|
||||
func _fetch_signature() -> void:
|
||||
if _signature_request != null:
|
||||
_signature_request.queue_free()
|
||||
_signature_request = HTTPRequest.new()
|
||||
_signature_request.max_redirects = 10
|
||||
_signature_request.request_completed.connect(_on_signature_completed)
|
||||
add_child(_signature_request)
|
||||
var err := _signature_request.request(_latest_signature_url)
|
||||
if err != OK:
|
||||
_signature_request.queue_free()
|
||||
_signature_request = null
|
||||
_fail_verification("could not request signature (error %d)" % err)
|
||||
|
||||
|
||||
func _on_signature_completed(
|
||||
result: int,
|
||||
response_code: int,
|
||||
_headers: PackedStringArray,
|
||||
body: PackedByteArray
|
||||
) -> void:
|
||||
if _signature_request != null:
|
||||
_signature_request.queue_free()
|
||||
_signature_request = null
|
||||
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
|
||||
_fail_verification(
|
||||
"signature download failed (result=%d code=%d)" % [result, response_code]
|
||||
)
|
||||
return
|
||||
|
||||
if not _verify_sidecar_signature(RELEASE_SIGNING_PUBLIC_KEY_PEM, _pending_sidecar_body, body):
|
||||
_fail_verification(
|
||||
"release signature does not verify against the embedded public key — "
|
||||
+ "the checksum sidecar was not produced by the release pipeline (#687)"
|
||||
)
|
||||
return
|
||||
|
||||
print("MCP | self-update release signature verified (rsa-4096/sha256)")
|
||||
_finish_digest_check_and_install(_pending_expected_digest)
|
||||
|
||||
|
||||
## Final gate shared by the signed and legacy paths: the staged archive's
|
||||
## SHA-256 must match the (now-trusted) sidecar digest before extract.
|
||||
func _finish_digest_check_and_install(expected: String) -> void:
|
||||
_pending_sidecar_body = PackedByteArray()
|
||||
_pending_expected_digest = ""
|
||||
|
||||
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..."})
|
||||
_install_zip.call_deferred()
|
||||
|
||||
|
||||
## True when `remote_version` falls inside the signing era — every release
|
||||
## at or above SIGNING_REQUIRED_FROM_VERSION ships a signed sidecar, so a
|
||||
## missing signature there must hard-fail rather than fall back to the
|
||||
## legacy checksum-only path. An empty/unknown version fails closed. Static
|
||||
## so it's unit-testable.
|
||||
static func _signature_required(remote_version: String) -> bool:
|
||||
if remote_version.strip_edges().is_empty():
|
||||
return true
|
||||
return not _is_newer(SIGNING_REQUIRED_FROM_VERSION, remote_version)
|
||||
|
||||
|
||||
## PKCS#1 v1.5 RSA verification of `signature` over SHA-256(`sidecar`) —
|
||||
## the exact output of release.yml's `openssl dgst -sha256 -sign`. Takes
|
||||
## the PEM as a parameter (rather than reading the const) so tests can
|
||||
## exercise both verdicts with a generated throwaway keypair. Static so
|
||||
## it's unit-testable without instancing the manager.
|
||||
static func _verify_sidecar_signature(
|
||||
public_key_pem: String, sidecar: PackedByteArray, signature: PackedByteArray
|
||||
) -> bool:
|
||||
if sidecar.is_empty() or signature.is_empty():
|
||||
return false
|
||||
var key := CryptoKey.new()
|
||||
if key.load_from_string(public_key_pem, true) != OK:
|
||||
return false
|
||||
var ctx := HashingContext.new()
|
||||
if ctx.start(HashingContext.HASH_SHA256) != OK:
|
||||
return false
|
||||
ctx.update(sidecar)
|
||||
var digest := ctx.finish()
|
||||
var crypto := Crypto.new()
|
||||
return crypto.verify(HashingContext.HASH_SHA256, digest, signature, key)
|
||||
|
||||
|
||||
## 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:
|
||||
_pending_sidecar_body = PackedByteArray()
|
||||
_pending_expected_digest = ""
|
||||
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, but some tools use tabs.
|
||||
var normalized := trimmed.replace("\t", " ").replace("\n", " ").replace("\r", " ")
|
||||
var tokens := normalized.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 ---------------------------------------------
|
||||
|
||||
func _install_zip() -> void:
|
||||
## Symlinked addons dir means an extract would clobber canonical
|
||||
## `plugin/` source through the link. Symlink detection is independent
|
||||
## of the mode override: even forced-user aborts here. See #116.
|
||||
if ClientConfigurator.addons_dir_is_symlink():
|
||||
install_state_changed.emit({
|
||||
"button_text": "Dev checkout — update via git",
|
||||
"button_disabled": true,
|
||||
"banner_visible": false,
|
||||
})
|
||||
return
|
||||
|
||||
## Drain in-flight workers + block new ones BEFORE any disk write.
|
||||
## Without this, focus-in landing in the extract -> reload window spawns
|
||||
## a worker that walks into a partially-overwritten script and
|
||||
## SIGABRTs in `GDScriptFunction::call`.
|
||||
_install_in_flight = true
|
||||
_drain_dock_workers()
|
||||
|
||||
var has_runner: bool = (
|
||||
_plugin != null
|
||||
and _plugin.has_method("install_downloaded_update")
|
||||
)
|
||||
if has_runner:
|
||||
install_state_changed.emit({"button_text": "Reloading..."})
|
||||
## Runner takes over: plugin tears down, runner extracts + scans +
|
||||
## re-enables. `install_downloaded_update` calls
|
||||
## `prepare_for_update_reload()` internally (kills the server,
|
||||
## resets the spawn guard) - see plugin.gd::install_downloaded_update.
|
||||
_plugin.install_downloaded_update(UPDATE_TEMP_ZIP, UPDATE_TEMP_DIR, _dock)
|
||||
return
|
||||
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP))
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_DIR))
|
||||
_install_in_flight = false
|
||||
install_state_changed.emit({
|
||||
"button_text": "Reload runner missing",
|
||||
"button_disabled": false,
|
||||
})
|
||||
|
||||
|
||||
func _reload_after_update() -> void:
|
||||
EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false)
|
||||
EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true)
|
||||
|
||||
|
||||
func _drain_dock_workers() -> void:
|
||||
if _dock != null and _dock.has_method("prepare_for_self_update_drain"):
|
||||
_dock.prepare_for_self_update_drain()
|
||||
@@ -1 +0,0 @@
|
||||
uid://cegiyw3fjcwev
|
||||
@@ -1,140 +0,0 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Scanner that detects whether `addons/godot_ai/` is in a half-installed
|
||||
## state left behind by a self-update whose rollback couldn't restore the
|
||||
## previous addon contents (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`).
|
||||
##
|
||||
## Without this surface the user sees "plugin won't start" with no actionable
|
||||
## context, re-runs the update, and compounds the mismatch (issue #354 /
|
||||
## audit-v2 #10). The dock paints a banner from `diagnose()` and
|
||||
## `editor_handler.gd::get_editor_state` includes the same Dictionary so an
|
||||
## MCP agent can see and report the state.
|
||||
|
||||
const ADDON_DIR := "res://addons/godot_ai/"
|
||||
## Producer is `update_reload_runner.gd::INSTALL_BACKUP_SUFFIX`. Inlined as a
|
||||
## literal because old two-phase runners can parse this diagnostic script
|
||||
## against stale runner Script-object content during their mixed-snapshot
|
||||
## scan. `test_update_backup_suffix_stays_in_sync` guards against drift.
|
||||
const BACKUP_SUFFIX := ".update_backup"
|
||||
## Cap so a runaway addons tree (someone parented the wrong dir, an old
|
||||
## crashed install left thousands of artifacts) can't blow the
|
||||
## `editor_state` payload size or freeze the editor on first paint.
|
||||
const MAX_BACKUP_RESULTS := 200
|
||||
## TTL for the `diagnose()` cache. `editor_state` is one of the highest-
|
||||
## traffic MCP tools (agents poll it constantly) and a recursive
|
||||
## `DirAccess` walk on every call would put I/O on the 4ms `_process()`
|
||||
## budget. Mixed-state is rare and persistent across editor restarts, so
|
||||
## a few seconds of staleness is acceptable; the dock's Re-scan button
|
||||
## bypasses the cache via `force=true` for immediate feedback.
|
||||
const CACHE_TTL_MSEC := 5000
|
||||
|
||||
static var _cache_value: Dictionary = {}
|
||||
static var _cache_timestamp_msec: int = -1
|
||||
|
||||
|
||||
## Walk `dir` recursively and return every `res://`-relative path that ends
|
||||
## in `.update_backup`, sorted ascending. Truncates at `MAX_BACKUP_RESULTS`
|
||||
## — the truncation flag is exposed via `diagnose()`.
|
||||
##
|
||||
## Walk order is deterministic: entries within each directory are sorted
|
||||
## alphabetically, subdirs pushed reverse-sorted so DFS pops them in
|
||||
## ascending order. Without this two scans of the same mixed tree could
|
||||
## return different 200-file slices when truncation kicks in (Godot's
|
||||
## `list_dir` order isn't guaranteed stable across filesystems).
|
||||
static func find_backups(dir: String = ADDON_DIR) -> Array:
|
||||
var results: Array = []
|
||||
var stack: Array = [dir]
|
||||
while not stack.is_empty():
|
||||
if results.size() >= MAX_BACKUP_RESULTS:
|
||||
break
|
||||
var current: String = stack.pop_back()
|
||||
var d := DirAccess.open(current)
|
||||
## Missing dir, permission error, or unreadable junction — skip
|
||||
## silently. A missing addons dir is the bare-clone case; mid-walk
|
||||
## errors stay quiet so a single permission glitch can't block the
|
||||
## diagnostic the rest of the scan would have produced.
|
||||
if d == null:
|
||||
continue
|
||||
var entries: Array = []
|
||||
d.list_dir_begin()
|
||||
while true:
|
||||
var entry := d.get_next()
|
||||
if entry.is_empty():
|
||||
break
|
||||
if entry == "." or entry == "..":
|
||||
continue
|
||||
entries.append({"name": entry, "is_dir": d.current_is_dir()})
|
||||
d.list_dir_end()
|
||||
entries.sort_custom(func(a, b): return a["name"] < b["name"])
|
||||
## Push subdirs reverse-sorted so the next outer iteration pops
|
||||
## them in ascending order — see method docstring for why this
|
||||
## determinism matters for the truncated case.
|
||||
for i in range(entries.size() - 1, -1, -1):
|
||||
var entry: Dictionary = entries[i]
|
||||
if entry["is_dir"]:
|
||||
stack.append(current.path_join(entry["name"]))
|
||||
for entry in entries:
|
||||
if entry["is_dir"]:
|
||||
continue
|
||||
if not String(entry["name"]).ends_with(BACKUP_SUFFIX):
|
||||
continue
|
||||
results.append(current.path_join(entry["name"]))
|
||||
if results.size() >= MAX_BACKUP_RESULTS:
|
||||
break
|
||||
results.sort()
|
||||
return results
|
||||
|
||||
|
||||
## Build the structured diagnostic Dictionary surfaced via `editor_state`
|
||||
## and the dock banner. Empty when the addons tree is clean — callers
|
||||
## gate banner visibility / response field on `is_empty()`.
|
||||
##
|
||||
## Cached for `CACHE_TTL_MSEC` when scanning the default `ADDON_DIR` so
|
||||
## per-`editor_state` polls don't re-walk the addons tree every frame.
|
||||
## Tests passing a custom `dir` always see a fresh scan (cache only
|
||||
## tracks the production path). `force=true` bypasses the cache — used
|
||||
## by the dock's Re-scan button so a manual fix is reflected immediately.
|
||||
static func diagnose(dir: String = ADDON_DIR, force: bool = false) -> Dictionary:
|
||||
var use_cache := dir == ADDON_DIR and not force
|
||||
if use_cache and _cache_timestamp_msec >= 0:
|
||||
if Time.get_ticks_msec() - _cache_timestamp_msec < CACHE_TTL_MSEC:
|
||||
return _cache_value.duplicate(true)
|
||||
|
||||
var backups := find_backups(dir)
|
||||
var result: Dictionary = {}
|
||||
if not backups.is_empty():
|
||||
## Most commonly produced by `_rollback_paths_written` returning
|
||||
## FAILED_MIXED, but `_finalize_install_success` removes backups on
|
||||
## a best-effort basis so a successful install can also leave them
|
||||
## behind if the cleanup `remove_absolute` hit a permission error.
|
||||
## The recovery action — delete the *.update_backup files — is the
|
||||
## same in both cases, so the message acknowledges both
|
||||
## possibilities rather than asserting the alarming one.
|
||||
result = {
|
||||
"addon_dir": dir,
|
||||
"backup_files": backups,
|
||||
"backup_count": backups.size(),
|
||||
"truncated": backups.size() >= MAX_BACKUP_RESULTS,
|
||||
"message": (
|
||||
"Found .update_backup files in addons/godot_ai/. This usually"
|
||||
+ " means a self-update rollback couldn't restore the previous"
|
||||
+ " addon contents (FAILED_MIXED) — the plugin may load a mix"
|
||||
+ " of old and new files. Restore the addon from your VCS or a"
|
||||
+ " fresh release ZIP, then delete the listed *.update_backup"
|
||||
+ " files. If the plugin runs without issues these are likely"
|
||||
+ " stale from a successful install and safe to delete."
|
||||
),
|
||||
}
|
||||
if use_cache:
|
||||
_cache_value = result.duplicate(true)
|
||||
_cache_timestamp_msec = Time.get_ticks_msec()
|
||||
return result
|
||||
|
||||
|
||||
## Reset the `diagnose()` cache. Tests that flip the addons-tree state
|
||||
## between calls use this to avoid TTL-bound flakiness; the dock's
|
||||
## Re-scan button uses `force=true` instead.
|
||||
static func clear_cache() -> void:
|
||||
_cache_value = {}
|
||||
_cache_timestamp_msec = -1
|
||||
@@ -1 +0,0 @@
|
||||
uid://dd5rti52vgs71
|
||||
@@ -1,161 +0,0 @@
|
||||
@tool
|
||||
class_name McpUvCacheCleanup
|
||||
extends RefCounted
|
||||
|
||||
## Sweeps stale `.tmp*` build venvs out of `%LOCALAPPDATA%\uv\cache\builds-v0`.
|
||||
##
|
||||
## Background
|
||||
## ----------
|
||||
## When an MCP client's attach launcher invokes
|
||||
## `uvx --from godot-ai==VERSION godot-ai attach ...`, uv builds an ephemeral venv under
|
||||
## `builds-v0\.tmpXXXXXX\`. To save disk it hard-links shared C extensions
|
||||
## (notably `pydantic_core/_pydantic_core.cp313-win_amd64.pyd`) from
|
||||
## `archive-v0\<hash>\Lib\site-packages\...` into the build venv.
|
||||
##
|
||||
## If the godot-ai server's own Python child has that same `.pyd` mapped via
|
||||
## `LoadLibrary` (it does — godot-ai imports pydantic), the file is locked
|
||||
## under BOTH paths because hard links share the inode and Windows tracks
|
||||
## handles per-file, not per-path. uv's post-install cleanup of the build
|
||||
## venv then dies with:
|
||||
##
|
||||
## Failed to install: pywin32-311-cp313-cp313-win_amd64.whl (pywin32==311)
|
||||
## Caused by: failed to remove directory `...\.tmpXXXXXX\Lib\site-packages\pywin32-311.data`
|
||||
## 다른 프로세스가 파일을 사용 중이기 때문에 ... (os error 32)
|
||||
##
|
||||
## (the `pywin32` mention is incidental — the actual lock is on the earlier
|
||||
## hard-linked `_pydantic_core.pyd`; pywin32 is just the last install step
|
||||
## in the wheel-resolution order that triggers the cleanup pass).
|
||||
##
|
||||
## What this does
|
||||
## --------------
|
||||
## After the plugin stops/restarts the managed server — i.e. the moment when
|
||||
## the archive-v0 `.pyd` mappings drop and the hard-linked builds-v0 copy
|
||||
## becomes deletable — sweep `builds-v0\` for `.tmp*` orphans:
|
||||
##
|
||||
## 1. Rename each `.tmpXXX` to `_dead_.tmpXXX`. Rename succeeds even when
|
||||
## AV scanners hold the file open without `FILE_SHARE_DELETE` (Defender
|
||||
## and Softcamp SDS both do this), so this step always advances.
|
||||
## 2. Recursively remove the renamed dir, swallowing per-file
|
||||
## access-denied. Anything still genuinely locked is left for the next
|
||||
## sweep — uv won't reuse the renamed name, so no future build collides.
|
||||
##
|
||||
## No-op on non-Windows (uv's hard-link strategy only causes this lock
|
||||
## pattern on NTFS) and when the cache directory doesn't exist.
|
||||
|
||||
const DEAD_PREFIX := "_dead_"
|
||||
const TMP_PREFIX := ".tmp"
|
||||
|
||||
|
||||
## Live entrypoint. Resolves `%LOCALAPPDATA%\uv\cache\builds-v0` and runs
|
||||
## the sweep. Returns the same counts the testable `purge_directory` returns,
|
||||
## or all zeros on non-Windows / missing cache.
|
||||
static func purge_stale_builds() -> Dictionary:
|
||||
if OS.get_name() != "Windows":
|
||||
return _empty_result()
|
||||
var local_appdata := OS.get_environment("LOCALAPPDATA")
|
||||
if local_appdata.is_empty():
|
||||
return _empty_result()
|
||||
var builds_root := local_appdata.replace("\\", "/").path_join("uv/cache/builds-v0")
|
||||
return purge_directory(builds_root)
|
||||
|
||||
|
||||
## Pure-ish entrypoint that takes a directory path. Returns
|
||||
## `{ "scanned": int, "renamed": int, "deleted": int, "remaining": int }`.
|
||||
## - `scanned`: how many `.tmp*` subdirs we saw on entry.
|
||||
## - `renamed`: how many we successfully renamed to `_dead_*`.
|
||||
## - `deleted`: how many we then fully removed.
|
||||
## - `remaining`: how many `_dead_*` dirs are still on disk after the sweep
|
||||
## (left for the next call to retry).
|
||||
##
|
||||
## Errors are swallowed — the caller is on a server-stop hot path and
|
||||
## must not raise.
|
||||
static func purge_directory(builds_root: String) -> Dictionary:
|
||||
var result := _empty_result()
|
||||
if not DirAccess.dir_exists_absolute(builds_root):
|
||||
return result
|
||||
var dir := DirAccess.open(builds_root)
|
||||
if dir == null:
|
||||
return result
|
||||
dir.include_hidden = true
|
||||
|
||||
## Pass 1: collect names. Iterating + renaming in the same walk would
|
||||
## confuse DirAccess's internal cursor on NTFS.
|
||||
var tmp_names: Array[String] = []
|
||||
var dead_names: Array[String] = []
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if dir.current_is_dir() and not (entry == "." or entry == ".."):
|
||||
if entry.begins_with(TMP_PREFIX):
|
||||
tmp_names.append(entry)
|
||||
elif entry.begins_with(DEAD_PREFIX):
|
||||
dead_names.append(entry)
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
result.scanned = tmp_names.size()
|
||||
|
||||
## Pass 2: rename `.tmp*` → `_dead_.tmp*`. Rename works even on
|
||||
## AV-locked files (Defender opens without FILE_SHARE_DELETE, but rename
|
||||
## doesn't need delete share). Any rename failure is non-fatal.
|
||||
for name in tmp_names:
|
||||
var src := builds_root.path_join(name)
|
||||
var dst := builds_root.path_join(DEAD_PREFIX + name)
|
||||
if dir.rename(src, dst) == OK:
|
||||
result.renamed += 1
|
||||
dead_names.append(DEAD_PREFIX + name)
|
||||
|
||||
## Pass 3: best-effort recursive delete of every `_dead_*`, including
|
||||
## ones left over from earlier sweeps that couldn't be cleaned then.
|
||||
for name in dead_names:
|
||||
var path := builds_root.path_join(name)
|
||||
if _remove_recursive(path):
|
||||
result.deleted += 1
|
||||
|
||||
## Final pass: count `_dead_*` survivors so the caller (and tests) can
|
||||
## see how many genuinely-locked dirs we couldn't reach.
|
||||
var dir2 := DirAccess.open(builds_root)
|
||||
if dir2 != null:
|
||||
dir2.include_hidden = true
|
||||
dir2.list_dir_begin()
|
||||
var e := dir2.get_next()
|
||||
while e != "":
|
||||
if dir2.current_is_dir() and e.begins_with(DEAD_PREFIX):
|
||||
result.remaining += 1
|
||||
e = dir2.get_next()
|
||||
dir2.list_dir_end()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Recursive `rm -rf` that swallows access-denied per-file. Returns true
|
||||
## only when the target directory itself was removed.
|
||||
static func _remove_recursive(path: String) -> bool:
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
## Already gone, or unreadable — try a direct remove just in case
|
||||
## (an empty dir handle-leak path) and report based on existence.
|
||||
DirAccess.remove_absolute(path)
|
||||
return not DirAccess.dir_exists_absolute(path)
|
||||
dir.include_hidden = true
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if entry == "." or entry == "..":
|
||||
entry = dir.get_next()
|
||||
continue
|
||||
var child := path.path_join(entry)
|
||||
if dir.current_is_dir():
|
||||
_remove_recursive(child)
|
||||
else:
|
||||
DirAccess.remove_absolute(child)
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
## Remove the (hopefully now empty) dir itself. If a hard-linked .pyd is
|
||||
## still mapped by a surviving process, this fails silently and the
|
||||
## caller sees `remaining > 0` so it can retry on the next sweep.
|
||||
DirAccess.remove_absolute(path)
|
||||
return not DirAccess.dir_exists_absolute(path)
|
||||
|
||||
|
||||
static func _empty_result() -> Dictionary:
|
||||
return { "scanned": 0, "renamed": 0, "deleted": 0, "remaining": 0 }
|
||||
@@ -1 +0,0 @@
|
||||
uid://d33ukg65qf7q0
|
||||
@@ -1,102 +0,0 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Converts Godot Variants into values that can be encoded as JSON.
|
||||
|
||||
|
||||
## Non-finite floats (NaN/INF) have no JSON representation: JSON.stringify
|
||||
## emits them as the bare tokens `inf`/`nan`, which are invalid JSON — the
|
||||
## server drops the whole frame and the pending request times out (#688).
|
||||
## Serialize them as null instead (the same choice web JSON.stringify makes),
|
||||
## applied uniformly across the supported 4.5+ floor — no version gate, so
|
||||
## wire output is identical on every supported engine.
|
||||
static func _safe_float(f: float) -> Variant:
|
||||
return f if is_finite(f) else null
|
||||
|
||||
|
||||
static func serialize(value: Variant) -> Variant:
|
||||
if value == null:
|
||||
return null
|
||||
match typeof(value):
|
||||
TYPE_BOOL, TYPE_INT, TYPE_STRING:
|
||||
return value
|
||||
TYPE_FLOAT:
|
||||
return _safe_float(value)
|
||||
TYPE_STRING_NAME:
|
||||
return str(value)
|
||||
# Integer vector types are listed separately from their float twins so
|
||||
# int components stay ints on the wire (no float coercion via
|
||||
# _safe_float's typed parameter).
|
||||
TYPE_VECTOR2I:
|
||||
return {"x": value.x, "y": value.y}
|
||||
TYPE_VECTOR2:
|
||||
return {"x": _safe_float(value.x), "y": _safe_float(value.y)}
|
||||
TYPE_VECTOR3I:
|
||||
return {"x": value.x, "y": value.y, "z": value.z}
|
||||
TYPE_VECTOR3:
|
||||
return {"x": _safe_float(value.x), "y": _safe_float(value.y), "z": _safe_float(value.z)}
|
||||
TYPE_VECTOR4I:
|
||||
return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
|
||||
TYPE_VECTOR4, TYPE_QUATERNION:
|
||||
return {
|
||||
"x": _safe_float(value.x),
|
||||
"y": _safe_float(value.y),
|
||||
"z": _safe_float(value.z),
|
||||
"w": _safe_float(value.w),
|
||||
}
|
||||
TYPE_COLOR:
|
||||
return {
|
||||
"r": _safe_float(value.r),
|
||||
"g": _safe_float(value.g),
|
||||
"b": _safe_float(value.b),
|
||||
"a": _safe_float(value.a),
|
||||
}
|
||||
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
|
||||
return {
|
||||
"position": serialize(value.position),
|
||||
"size": serialize(value.size),
|
||||
}
|
||||
TYPE_PLANE:
|
||||
return {"normal": serialize(value.normal), "d": _safe_float(value.d)}
|
||||
TYPE_BASIS:
|
||||
return {
|
||||
"x": serialize(value.x),
|
||||
"y": serialize(value.y),
|
||||
"z": serialize(value.z),
|
||||
}
|
||||
TYPE_TRANSFORM2D:
|
||||
return {
|
||||
"x": serialize(value.x),
|
||||
"y": serialize(value.y),
|
||||
"origin": serialize(value.origin),
|
||||
}
|
||||
TYPE_TRANSFORM3D:
|
||||
return {
|
||||
"basis": serialize(value.basis),
|
||||
"origin": serialize(value.origin),
|
||||
}
|
||||
TYPE_PROJECTION:
|
||||
return {
|
||||
"x": serialize(value.x),
|
||||
"y": serialize(value.y),
|
||||
"z": serialize(value.z),
|
||||
"w": serialize(value.w),
|
||||
}
|
||||
TYPE_NODE_PATH:
|
||||
return str(value)
|
||||
TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_VECTOR4_ARRAY, TYPE_PACKED_COLOR_ARRAY:
|
||||
var arr: Array = []
|
||||
for item in value:
|
||||
arr.append(serialize(item))
|
||||
return arr
|
||||
TYPE_DICTIONARY:
|
||||
var out := {}
|
||||
for key in value:
|
||||
out[str(key)] = serialize(value[key])
|
||||
return out
|
||||
TYPE_OBJECT:
|
||||
if value is Resource and value.resource_path:
|
||||
return value.resource_path
|
||||
return str(value)
|
||||
_:
|
||||
return str(value)
|
||||
@@ -1 +0,0 @@
|
||||
uid://cte37mtbd61n3
|
||||
Reference in New Issue
Block a user