更新godot ai插件到最新版本
This commit is contained in:
@@ -3,5 +3,5 @@
|
|||||||
name="Godot AI"
|
name="Godot AI"
|
||||||
description="MCP server and AI tools for Godot"
|
description="MCP server and AI tools for Godot"
|
||||||
author="Godot AI"
|
author="Godot AI"
|
||||||
version="4.2.1"
|
version="4.2.2"
|
||||||
script="plugin.gd"
|
script="plugin.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
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b6ojjbvx02x1g
|
||||||
@@ -14,6 +14,7 @@ const SERVER_PID_FILE := "user://godot_ai_server.pid"
|
|||||||
## spawn, so a report that exists afterwards belongs to that launch.
|
## spawn, so a report that exists afterwards belongs to that launch.
|
||||||
const SERVER_STARTUP_REPORT := "user://godot_ai_server_startup.json"
|
const SERVER_STARTUP_REPORT := "user://godot_ai_server_startup.json"
|
||||||
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
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()
|
static var _process_spawn_mutex := Mutex.new()
|
||||||
|
|
||||||
enum PortOccupancy { UNKNOWN, FREE, OCCUPIED }
|
enum PortOccupancy { UNKNOWN, FREE, OCCUPIED }
|
||||||
@@ -30,10 +31,10 @@ static func unlock_process_spawn() -> void:
|
|||||||
_process_spawn_mutex.unlock()
|
_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.
|
## Query each launch so installing the missing tool makes Retry work.
|
||||||
static func listener_tools_problem() -> String:
|
static func listener_tools_problem() -> String:
|
||||||
if OS.get_name() != "Linux":
|
if OS.get_name() != "Linux" or LinuxProc.available():
|
||||||
return ""
|
return ""
|
||||||
var output: Array = []
|
var output: Array = []
|
||||||
var available := OS.execute("/bin/sh", ["-c",
|
var available := OS.execute("/bin/sh", ["-c",
|
||||||
@@ -41,7 +42,7 @@ static func listener_tools_problem() -> String:
|
|||||||
], output, true)
|
], output, true)
|
||||||
if available == 0:
|
if available == 0:
|
||||||
return ""
|
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:
|
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
|
## a wrapping caller's startup trace sees a genuine PowerShell fallback
|
||||||
## as `powershell`, not as a silent extra second under `netstat`.
|
## as `powershell`, not as a silent extra second under `netstat`.
|
||||||
static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -> bool:
|
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 = []
|
var output: Array = []
|
||||||
if OS.get_name() == "Windows":
|
if OS.get_name() == "Windows":
|
||||||
return windows_port_occupancy(port, windows_listener_snapshot(trace)) != PortOccupancy.FREE
|
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:
|
if snapshot.known:
|
||||||
pids.assign(snapshot.listeners.get(port, []))
|
pids.assign(snapshot.listeners.get(port, []))
|
||||||
return pids
|
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 = []
|
var output: Array = []
|
||||||
_trace(trace, "lsof")
|
_trace(trace, "lsof")
|
||||||
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
|
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
|
return false
|
||||||
if snapshot != null:
|
if snapshot != null:
|
||||||
return not _process_snapshot_row(snapshot, pid).is_empty()
|
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":
|
if OS.get_name() == "Windows":
|
||||||
var output: Array = []
|
var output: Array = []
|
||||||
var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true)
|
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
|
## One Windows query captures the target and at most fifteen ancestors. The
|
||||||
## dictionary belongs to one proof boundary, never a cache: callers must take
|
## dictionary belongs to one proof boundary, never a cache: callers must take
|
||||||
## a fresh snapshot when closing the capture window or authorizing a kill.
|
## 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
|
## The process enumeration stays inside PowerShell; only the bounded target
|
||||||
## ancestry crosses back into Godot, without logging command-line metadata.
|
## ancestry crosses back into Godot, without logging command-line metadata.
|
||||||
static func capture_process_snapshot(pid: int) -> Variant:
|
static func capture_process_snapshot(pid: int) -> Variant:
|
||||||
|
if OS.get_name() == "Linux":
|
||||||
|
return LinuxProc.process_snapshot(pid)
|
||||||
if OS.get_name() != "Windows":
|
if OS.get_name() != "Windows":
|
||||||
return null
|
return null
|
||||||
if pid <= 1:
|
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():
|
if execute_windows_powershell(script, output) != 0 or output.is_empty():
|
||||||
return ""
|
return ""
|
||||||
return str(output[0]).strip_edges()
|
return str(output[0]).strip_edges()
|
||||||
|
if OS.get_name() == "Linux":
|
||||||
|
return LinuxProc.commandline(pid)
|
||||||
var proc_path := "/proc/%d/cmdline" % pid
|
var proc_path := "/proc/%d/cmdline" % pid
|
||||||
if FileAccess.file_exists(proc_path):
|
if FileAccess.file_exists(proc_path):
|
||||||
var file := FileAccess.open(proc_path, FileAccess.READ)
|
var file := FileAccess.open(proc_path, FileAccess.READ)
|
||||||
@@ -539,6 +559,8 @@ static func process_parent(pid: int, snapshot: Variant = null) -> int:
|
|||||||
return 0
|
return 0
|
||||||
if snapshot != null:
|
if snapshot != null:
|
||||||
return int(_process_snapshot_row(snapshot, pid).get("parent_pid", 0))
|
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 = []
|
var output: Array = []
|
||||||
if OS.get_name() == "Windows":
|
if OS.get_name() == "Windows":
|
||||||
var script := (
|
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:
|
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):
|
if not pid_alive(pid, snapshot):
|
||||||
return ""
|
return ""
|
||||||
var output: Array = []
|
var output: Array = []
|
||||||
|
|||||||
@@ -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
|
## 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
|
## distributions need this for `/home -> /var/home` (#993). Godot exposes no
|
||||||
## owning UID, so this is the strongest proof available here; Python, which
|
## 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
|
## The record file itself is never followed, and a chain longer than
|
||||||
## `_MAX_LINK_HOPS` fails closed. Windows keeps rejecting every detectable
|
## `_MAX_LINK_HOPS` fails closed. Windows keeps rejecting every detectable
|
||||||
## link or reparse point.
|
## 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
|
## spawn turns a silent "proof timed out at capability_record" into a message
|
||||||
## that names the directory and the fix.
|
## that names the directory and the fix.
|
||||||
static func directory_write_problem(http_port: int) -> String:
|
static func directory_write_problem(http_port: int) -> String:
|
||||||
if OS.get_name() != "Windows":
|
|
||||||
return ""
|
|
||||||
var record := path_for_http_port(http_port)
|
var record := path_for_http_port(http_port)
|
||||||
if record.is_empty():
|
if record.is_empty():
|
||||||
return ""
|
return ""
|
||||||
|
if OS.get_name() != "Windows":
|
||||||
|
return posix_directory_problem(record.get_base_dir())
|
||||||
return directory_write_problem_for(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:
|
static func directory_write_problem_for(directory: String) -> String:
|
||||||
if DirAccess.open(directory) == null:
|
if DirAccess.open(directory) == null:
|
||||||
var created := DirAccess.make_dir_recursive_absolute(directory)
|
var created := DirAccess.make_dir_recursive_absolute(directory)
|
||||||
|
|||||||
+1
-1
@@ -18,8 +18,8 @@ config/icon="res://icon.svg"
|
|||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
Steamworks="*uid://w087kpi6dipk"
|
Steamworks="*uid://w087kpi6dipk"
|
||||||
_mcp_game_helper="*res://addons/godot_ai/runtime/game_helper.gd"
|
|
||||||
Net="*res://autoload/net.tscn"
|
Net="*res://autoload/net.tscn"
|
||||||
|
_mcp_game_helper="*res://addons/godot_ai/runtime/game_helper.gd"
|
||||||
|
|
||||||
[debug]
|
[debug]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user