From 2f662c6c6fd3e05bdc0c7020b3962cefef339eb5 Mon Sep 17 00:00:00 2001 From: yvmou_ Date: Thu, 24 Sep 2026 15:33:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0godot=20ai=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=88=B0=E6=9C=80=E6=96=B0=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/godot_ai/plugin.cfg | 2 +- addons/godot_ai/utils/linux_proc.gd | 160 ++++++++++++++++++ addons/godot_ai/utils/linux_proc.gd.uid | 1 + addons/godot_ai/utils/port_resolver.gd | 32 +++- addons/godot_ai/utils/transport_capability.gd | 32 +++- project.godot | 2 +- 6 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 addons/godot_ai/utils/linux_proc.gd create mode 100644 addons/godot_ai/utils/linux_proc.gd.uid diff --git a/addons/godot_ai/plugin.cfg b/addons/godot_ai/plugin.cfg index a7ba07d..03d9b25 100644 --- a/addons/godot_ai/plugin.cfg +++ b/addons/godot_ai/plugin.cfg @@ -3,5 +3,5 @@ name="Godot AI" description="MCP server and AI tools for Godot" author="Godot AI" -version="4.2.1" +version="4.2.2" script="plugin.gd" diff --git a/addons/godot_ai/utils/linux_proc.gd b/addons/godot_ai/utils/linux_proc.gd new file mode 100644 index 0000000..1d87f8b --- /dev/null +++ b/addons/godot_ai/utils/linux_proc.gd @@ -0,0 +1,160 @@ +@tool +extends RefCounted + +## Linux process and listener evidence from the editor's own PID/network +## namespace. Never run host commands across a Flatpak/Steam sandbox boundary. +## proc files report length zero: read bounded chunks, not get_as_text(). + +static func read_text(path: String, limit := 1024 * 1024) -> Dictionary: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return {"known": false, "text": ""} + var bytes := PackedByteArray() + while bytes.size() <= limit: + var chunk := file.get_buffer(mini(4096, limit + 1 - bytes.size())) + if chunk.is_empty(): + break + bytes.append_array(chunk) + if file.eof_reached(): + break + var error := file.get_error() + file.close() + if bytes.size() > limit or error not in [OK, ERR_FILE_EOF]: + return {"known": false, "text": ""} + for index in range(bytes.size()): + if bytes[index] == 0: + bytes[index] = 32 + return {"known": true, "text": bytes.get_string_from_utf8()} + + +static func parse_stat(raw: String, expected_pid: int) -> Dictionary: + ## comm may contain spaces, parentheses and newlines. The final ')' is + ## the delimiter; field 22 (starttime) is index 19 after comm. + var opening := raw.find(" (") + var closing := raw.rfind(") ") + if opening < 1 or closing <= opening: + return {} + var pid_text := raw.substr(0, opening) + var fields := raw.substr(closing + 2).strip_edges().split(" ", false) + if not pid_text.is_valid_int() or int(pid_text) != expected_pid or fields.size() < 20: + return {} + if fields[0] not in ["R", "S", "D", "T", "t", "Z", "X", "x", "K", "W", "P", "I"]: + return {} + if not fields[1].is_valid_int() or int(fields[1]) < 0 or not fields[19].is_valid_int() or int(fields[19]) < 0: + return {} + return {"state": fields[0], "parent_pid": int(fields[1]), "start": fields[19]} + + +static func process_stat(pid: int, root := "/proc") -> Dictionary: + var read := read_text(root.path_join("%d/stat" % pid), 8192) + return parse_stat(read.text, pid) if read.known else {} + + +static func is_alive(row: Dictionary) -> bool: + return not row.is_empty() and row.state not in ["Z", "X", "x"] + + +static func commandline(pid: int, root := "/proc") -> String: + var read := read_text(root.path_join("%d/cmdline" % pid)) + return str(read.text).strip_edges() if read.known else "" + + +static func process_snapshot(pid: int, root := "/proc") -> Dictionary: + if DirAccess.open(root) == null: + return {"capture_error": true} + var snapshot := {} + var current := pid + for _depth in range(16): + if current <= 1: + break + if snapshot.has(current): + return {"capture_error": true} + var first := process_stat(current, root) + if first.is_empty(): + ## A vanished process differs from an unreadable proc mount. + return snapshot if not DirAccess.dir_exists_absolute(root.path_join(str(current))) else {"capture_error": true} + if not is_alive(first): + return snapshot + var read := read_text(root.path_join("%d/cmdline" % current)) + var final := process_stat(current, root) + if not read.known or not is_alive(final) or first.start != final.start or first.parent_pid != final.parent_pid: + return {"capture_error": true} + var command := str(read.text).strip_edges() + snapshot[current] = {"pid": current, "parent_pid": first.parent_pid, + "identity": "linux:" + str(first.start) + "|" + command, "commandline": command} + current = int(first.parent_pid) + return snapshot + + +static func parse_tcp(raw: String) -> Dictionary: + var listeners := {} + var lines := raw.strip_edges().split("\n", false) + if lines.is_empty() or not lines[0].contains("local_address") or not lines[0].contains("inode"): + return {"known": false, "listeners": {}} + for line in lines.slice(1): + var fields := line.replace("\t", " ").strip_edges().split(" ", false) + if fields.size() < 10 or not fields[1].contains(":"): + return {"known": false, "listeners": {}} + if fields[3] != "0A": + continue + var port_hex := fields[1].get_slice(":", 1) + if port_hex.length() != 4 or not port_hex.is_valid_hex_number() or not fields[9].is_valid_int() or int(fields[9]) <= 0: + return {"known": false, "listeners": {}} + var port := port_hex.hex_to_int() + if not listeners.has(port): + listeners[port] = [] + listeners[port].append(fields[9]) + return {"known": true, "listeners": listeners} + + +static func listener_snapshot(root := "/proc") -> Dictionary: + var listeners := {} + for name in ["tcp", "tcp6"]: + var path := root.path_join("net/" + name) + ## IPv6 may be disabled at kernel build time; IPv4 must be readable. + if name == "tcp6" and not FileAccess.file_exists(path): + continue + var read := read_text(path, 8 * 1024 * 1024) + var parsed := parse_tcp(read.text) if read.known else {"known": false} + if not parsed.known: + return {"known": false, "listeners": {}} + for port in parsed.listeners: + if not listeners.has(port): + listeners[port] = [] + listeners[port].append_array(parsed.listeners[port]) + return {"known": true, "listeners": listeners} + + +static func listener_pids(port: int, snapshot: Dictionary, root := "/proc") -> Array[int]: + var result: Array[int] = [] + if not snapshot.get("known", false) or not snapshot.listeners.has(port): + return result + var wanted: Array = snapshot.listeners[port] + var proc := DirAccess.open(root) + if proc == null: + return result + for name in proc.get_directories(): + if not name.is_valid_int() or int(name) <= 1: + continue + var descriptors := DirAccess.open(root.path_join(name).path_join("fd")) + if descriptors == null: + continue + ## fd entries are symlinks; their socket targets need not exist as files. + descriptors.list_dir_begin() + var descriptor := descriptors.get_next() + while not descriptor.is_empty(): + var target := descriptors.read_link(descriptor) + if target.begins_with("socket:[") and target.ends_with("]") and wanted.has(target.substr(8, target.length() - 9)): + result.append(int(name)) + break + descriptor = descriptors.get_next() + descriptors.list_dir_end() + return result + + +static func available() -> bool: + return ( + is_alive(process_stat(OS.get_process_id())) + and DirAccess.open("/proc/self/fd") != null + and listener_snapshot().known + ) diff --git a/addons/godot_ai/utils/linux_proc.gd.uid b/addons/godot_ai/utils/linux_proc.gd.uid new file mode 100644 index 0000000..054ae19 --- /dev/null +++ b/addons/godot_ai/utils/linux_proc.gd.uid @@ -0,0 +1 @@ +uid://b6ojjbvx02x1g diff --git a/addons/godot_ai/utils/port_resolver.gd b/addons/godot_ai/utils/port_resolver.gd index cfba5a4..74dae41 100644 --- a/addons/godot_ai/utils/port_resolver.gd +++ b/addons/godot_ai/utils/port_resolver.gd @@ -14,6 +14,7 @@ const SERVER_PID_FILE := "user://godot_ai_server.pid" ## 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") +const LinuxProc := preload("res://addons/godot_ai/utils/linux_proc.gd") static var _process_spawn_mutex := Mutex.new() enum PortOccupancy { UNKNOWN, FREE, OCCUPIED } @@ -30,10 +31,10 @@ static func unlock_process_spawn() -> void: _process_spawn_mutex.unlock() -## A managed Linux server needs a listener PID tool to prove ownership. +## Linux can prove ownership through procfs inside minimal desktop sandboxes. ## Query each launch so installing the missing tool makes Retry work. static func listener_tools_problem() -> String: - if OS.get_name() != "Linux": + if OS.get_name() != "Linux" or LinuxProc.available(): return "" var output: Array = [] var available := OS.execute("/bin/sh", ["-c", @@ -41,7 +42,7 @@ static func listener_tools_problem() -> String: ], 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." + return "Cannot verify Linux listener ownership: /proc is unavailable and neither lsof nor ss is available on the editor's PATH. Make /proc accessible inside the editor environment, or install lsof or iproute2 there, then retry starting the server." static func can_bind_local_port(port: int) -> bool: @@ -66,6 +67,13 @@ static func is_port_in_use(port: int) -> bool: ## a wrapping caller's startup trace sees a genuine PowerShell fallback ## as `powershell`, not as a silent extra second under `netstat`. static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -> bool: + if OS.get_name() == "Linux": + var linux := LinuxProc.listener_snapshot() + if linux.known: + return linux.listeners.has(port) + ## A failed observation cannot prove that a port is free. + if not can_bind_local_port(port): + return true var output: Array = [] if OS.get_name() == "Windows": return windows_port_occupancy(port, windows_listener_snapshot(trace)) != PortOccupancy.FREE @@ -189,6 +197,12 @@ static func find_all_pids_on_port(port: int, trace: Callable = Callable(), snaps if snapshot.known: pids.assign(snapshot.listeners.get(port, [])) return pids + if OS.get_name() == "Linux": + var linux := LinuxProc.listener_snapshot() + if linux.known: + var linux_pids := LinuxProc.listener_pids(port, linux) + if not linux_pids.is_empty() or not linux.listeners.has(port): + return linux_pids var output: Array = [] _trace(trace, "lsof") var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) @@ -350,6 +364,8 @@ static func pid_alive(pid: int, snapshot: Variant = null) -> bool: return false if snapshot != null: return not _process_snapshot_row(snapshot, pid).is_empty() + if OS.get_name() == "Linux": + return LinuxProc.is_alive(LinuxProc.process_stat(pid)) if OS.get_name() == "Windows": var output: Array = [] var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true) @@ -373,10 +389,12 @@ static func pid_alive(pid: int, snapshot: Variant = null) -> bool: ## 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. +## Linux captures the same bounded ancestry using procfs; macOS keeps ps. ## 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() == "Linux": + return LinuxProc.process_snapshot(pid) if OS.get_name() != "Windows": return null if pid <= 1: @@ -511,6 +529,8 @@ static func process_commandline(pid: int, snapshot: Variant = null) -> String: if execute_windows_powershell(script, output) != 0 or output.is_empty(): return "" return str(output[0]).strip_edges() + if OS.get_name() == "Linux": + return LinuxProc.commandline(pid) var proc_path := "/proc/%d/cmdline" % pid if FileAccess.file_exists(proc_path): var file := FileAccess.open(proc_path, FileAccess.READ) @@ -539,6 +559,8 @@ static func process_parent(pid: int, snapshot: Variant = null) -> int: return 0 if snapshot != null: return int(_process_snapshot_row(snapshot, pid).get("parent_pid", 0)) + if OS.get_name() == "Linux": + return int(LinuxProc.process_stat(pid).get("parent_pid", 0)) var output: Array = [] if OS.get_name() == "Windows": var script := ( @@ -599,6 +621,8 @@ static func pid_cmdline_is_godot_ai(pid: int, snapshot: Variant = null) -> bool: static func process_fingerprint(pid: int, snapshot: Variant = null) -> String: + if snapshot == null and OS.get_name() == "Linux": + snapshot = capture_process_snapshot(pid) if not pid_alive(pid, snapshot): return "" var output: Array = [] diff --git a/addons/godot_ai/utils/transport_capability.gd b/addons/godot_ai/utils/transport_capability.gd index 2f9f7b6..69fcdc7 100644 --- a/addons/godot_ai/utils/transport_capability.gd +++ b/addons/godot_ai/utils/transport_capability.gd @@ -113,7 +113,8 @@ static func _private_path(path: String, directory: bool) -> bool: ## writes, so only that directory's owner or root could have placed it; ostree ## distributions need this for `/home -> /var/home` (#993). Godot exposes no ## owning UID, so this is the strongest proof available here; Python, which -## publishes the record, additionally requires such a link to be root-owned. +## publishes the record, additionally verifies root/current-user ownership of +## the link and its parent (including Steam's user-owned namespace root). ## The record file itself is never followed, and a chain longer than ## `_MAX_LINK_HOPS` fails closed. Windows keeps rejecting every detectable ## link or reparse point. @@ -244,14 +245,39 @@ static func is_lower_hex(value: String, length: int) -> bool: ## spawn turns a silent "proof timed out at capability_record" into a message ## that names the directory and the fix. static func directory_write_problem(http_port: int) -> String: - if OS.get_name() != "Windows": - return "" var record := path_for_http_port(http_port) if record.is_empty(): return "" + if OS.get_name() != "Windows": + return posix_directory_problem(record.get_base_dir()) return directory_write_problem_for(record.get_base_dir()) +## Diagnose all existing writable ancestors in one pass. Never chmod the +## user's home/config directories; Python remains the owning-UID authority. +static func posix_directory_problem(directory: String) -> String: + var problems: Array[String] = [] + var current := directory.simplify_path() + while not current.is_empty(): + if DirAccess.dir_exists_absolute(current): + var permissions := FileAccess.get_unix_permissions(current) + if not _safe_posix_ancestor_mode(current, permissions): + problems.push_front("%s (mode %03o)" % [current, permissions & _POSIX_PERMISSION_MASK]) + var parent := current.get_base_dir() + if parent.is_empty() or parent == current: + break + current = parent + if problems.is_empty(): + return "" + return ( + "Godot AI cannot securely store connection credentials. Check these directories: %s. " + + "They must be accessible and not writable by group or other users. " + + "If you own them and shared write access is not intentional, remove group/other write " + + "permission on each named directory (chmod go-w), then retry. " + + "Do not apply chmod recursively. No permissions have been changed." + ) % "; ".join(problems) + + static func directory_write_problem_for(directory: String) -> String: if DirAccess.open(directory) == null: var created := DirAccess.make_dir_recursive_absolute(directory) diff --git a/project.godot b/project.godot index e250d8a..c142958 100644 --- a/project.godot +++ b/project.godot @@ -18,8 +18,8 @@ config/icon="res://icon.svg" [autoload] Steamworks="*uid://w087kpi6dipk" -_mcp_game_helper="*res://addons/godot_ai/runtime/game_helper.gd" Net="*res://autoload/net.tscn" +_mcp_game_helper="*res://addons/godot_ai/runtime/game_helper.gd" [debug]