Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalProcessProbe.swift
T
Kaede Akino e457e9af98 fix(multiplexer): preserve session ownership and safe shell startup
Detach managed sessions when native mobile windows are discarded, retain
live reopen references, and fence ordinary startup commands by input and
session generation. Handle noexec helpers, isolate tmux discovery locale,
and count only verified live contexts against the journal limit.

Move actual shell execution coverage into the hostless framework suite.
Validation: 76 focused macOS tests passed; iOS multiplexer regressions
passed with the inherited hosted-test artwork failure still present.
2026-10-17 09:50:37 +08:00

263 lines
12 KiB
Swift

import Foundation
import TermindApplication
import TermindDomain
/// Read-only OS evidence. An argv session name is never proof of a client's current endpoint.
nonisolated struct RemoteTerminalProcessProbe {
struct Client: Equatable {
let backend: SessionPersistenceBackendID
let process: String
let started: String
let endpoint: String?
var identity: String { process + ":" + started }
}
let execute: RemoteSessionCommandExecutor
func isOrdinaryShell(terminal: String) async throws -> Bool {
let result = try await execute(Self.ordinaryShellCommand(terminal: terminal))
return result.exitCode == 0 && !result.truncated && result.stdout == "TERMIND_ORDINARY_SHELL\n"
}
static func ordinaryShellCommand(terminal: String) throws -> String {
guard RemoteTmuxClientIdentity.validTerminal(terminal) else { throw TerminalRuntimeError.invalidResponse }
return """
export LC_ALL=C
tty=\(try RemoteMultiplexerCommands.quote(String(terminal.dropFirst("/dev/".count))))
rows=$(ps -t "$tty" -o pid= -o pgid= -o tpgid= -o tty= -o comm= | head -c 65537) || exit 79
[ "${#rows}" -le 65536 ] || exit 79
printf '%s\\n' "$rows" | awk -v tty="$tty" '
NF != 5 || $1 !~ /^[0-9]+$/ || $2 !~ /^[0-9]+$/ || $3 !~ /^[0-9]+$/ || $4 != tty {
invalid=1; next
}
$2 == $3 && $3 > 0 {
count++
name=$5; sub(/^.*\\//,"",name); sub(/^-/,"",name)
if (name ~ /^(sh|dash|ksh|ksh93|bash|zsh|fish|csh|tcsh)$/) shells++
}
END {
if (!invalid && count == 1 && shells == 1) print "TERMIND_ORDINARY_SHELL"
else exit 79
}'
"""
}
func inspect(terminal: String) async throws -> Client? {
let result = try await execute(Self.command(terminal: terminal))
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
return try Self.parse(result.stdout)
}
static func command(terminal: String) throws -> String {
guard RemoteTmuxClientIdentity.validTerminal(terminal) else { throw TerminalRuntimeError.invalidResponse }
let names = SessionPersistenceBackendID.supported.map(\.rawValue).joined(separator: "|")
return """
export LC_ALL=C
\(RemoteMultiplexerCommands.utilities)
tty=\(try RemoteMultiplexerCommands.quote(String(terminal.dropFirst("/dev/".count))))
rows=$(ps -t "$tty" -o pid= -o pgid= -o tpgid= -o comm=) || exit 79
clients=$(printf '%s\\n' "$rows" | awk '
$2 == $3 && $3 > 0 {
name=$4; sub(/^.*\\//,"",name); sub(/:$/,"",name)
if (name ~ /^(\(names))$/) print name "|" $1
}')
if [ -z "$clients" ]; then printf 'NONE\\n'; exit 0; fi
[ "$(printf '%s\\n' "$clients" | wc -l | tr -d ' ')" = 1 ] || { printf 'AMBIGUOUS\\n'; exit 0; }
kind=${clients%|*}; client=${clients#*|}
started=$(process_start "$client")
[ -n "$started" ] || exit 79
printf 'M|%s|%s|%s\\n' "$kind" "$client" "$(hex "$started")"
if command -v ss >/dev/null 2>&1; then
sockets=$(ss -xnpH 2>/dev/null | head -c 262145)
printf 'S|%s\\n' "$(hex "$sockets")"
elif [ "$(uname -s)" = Linux ] && command -v python3 >/dev/null 2>&1; then
endpoint=$(python3 - "$client" 2>/dev/null <<'TERMIND_SOCKET_DIAG'
\(linuxSocketDiagnostic)
TERMIND_SOCKET_DIAG
) || endpoint=
printf 'P|%s\\n' "$endpoint"
elif [ "$(uname -s)" = Darwin ] && command -v lsof >/dev/null 2>&1; then
pids=$(ps -u "$(id -u)" -o pid= -o comm= | awk -v kind="$kind" '
{name=$2; sub(/^.*\\//,"",name); if(name==kind) {if(p!="") p=p ","; p=p $1}} END {print p}')
sockets=$(lsof -nP -a -p "$pids" -U -F pdfn 2>/dev/null | head -c 262145)
printf 'L|%s\\n' "$(hex "$sockets")"
else
printf 'S|\\n'
fi
[ "$(process_start "$client")" = "$started" ] || exit 79
printf 'DONE\\n'
"""
}
static func parse(_ text: String) throws -> Client? {
if text == "NONE\n" { return nil }
guard text != "AMBIGUOUS\n" else { throw TerminalRuntimeError.invalidResponse }
let lines = text.components(separatedBy: "\n")
guard lines.count == 4, lines[2] == "DONE", lines[3].isEmpty else {
throw TerminalRuntimeError.invalidResponse
}
let meta = lines[0].components(separatedBy: "|")
guard meta.count == 4, meta[0] == "M",
SessionPersistenceBackendID.supported.contains(.init(rawValue: meta[1])),
UInt64(meta[2]).map({ $0 > 1 }) == true,
lines[1].hasPrefix("S|") || lines[1].hasPrefix("L|") || lines[1].hasPrefix("P|")
else { throw TerminalRuntimeError.invalidResponse }
let started = try RemoteMultiplexerCommands.unhex(meta[3])
guard !started.isEmpty, started.utf8.allSatisfy({ (32...126).contains($0) }) else {
throw TerminalRuntimeError.invalidResponse
}
let evidence = try RemoteMultiplexerCommands.unhex(String(lines[1].dropFirst(2)), limit: 262144)
let endpoint: String?
if lines[1].hasPrefix("P|") {
endpoint = RemoteMultiplexerCommands.validPath(evidence) ? evidence : nil
} else {
endpoint =
lines[1].hasPrefix("S|")
? linuxEndpoint(evidence, process: meta[2]) : darwinEndpoint(evidence, process: meta[2])
}
return Client(backend: .init(rawValue: meta[1]), process: meta[2], started: started, endpoint: endpoint)
}
/// Linux exposes UNIX peers through SOCK_DIAG, not /proc/net/unix. Use the
/// standard library when iproute2 is absent, without installing remote helpers.
static let linuxSocketDiagnostic = #"""
import os, socket, struct, sys
owned = set()
for entry in os.scandir("/proc/" + sys.argv[1] + "/fd"):
try:
link = os.readlink(entry.path)
if link.startswith("socket:[") and link.endswith("]"):
owned.add(int(link[8:-1]))
except (OSError, ValueError):
pass
if not owned:
sys.exit(0)
request = struct.pack("=BBHIIIII", socket.AF_UNIX, 0, 0, 0xffffffff, 0, 5, 0xffffffff, 0xffffffff)
header = struct.pack("=IHHII", 16 + len(request), 20, 0x301, 1, 0)
peers = {}
with socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, 4) as channel:
channel.settimeout(0.5)
channel.bind((0, 0))
channel.sendto(header + request, (0, 0))
done = False
total = 0
while not done:
data, _, flags, sender = channel.recvmsg(65536)
total += len(data)
if flags & socket.MSG_TRUNC or total > 1048576 or sender[0] != 0:
sys.exit(1)
offset = 0
while offset < len(data):
length, kind, flags, seq, _ = struct.unpack_from("=IHHII", data, offset)
if length < 16 or offset + length > len(data) or seq != 1 or flags & 0x10:
sys.exit(1)
end = offset + length
if kind == 3:
done = True
elif kind == 2:
sys.exit(1)
elif kind == 20:
family, _, _, _, inode, _, _ = struct.unpack_from("=BBBBIII", data, offset + 16)
if family != socket.AF_UNIX:
sys.exit(1)
peer, path = None, b""
attribute = offset + 32
while attribute < end:
size, tag = struct.unpack_from("=HH", data, attribute)
if size < 4 or attribute + size > end:
sys.exit(1)
value = data[attribute + 4:attribute + size]
if tag == 0:
path = value.rstrip(b"\0")
elif tag == 2:
peer, = struct.unpack("=I", value)
attribute += (size + 3) & ~3
peers[inode] = (peer, path)
offset += (length + 3) & ~3
paths = set()
for inode in owned:
peer, _ = peers.get(inode, (None, b""))
reciprocal, path = peers.get(peer, (None, b""))
if reciprocal == inode and path.startswith(b"/"):
paths.add(path)
if len(paths) == 1:
print(paths.pop().hex())
"""#
static func linuxEndpoint(_ text: String, process: String) -> String? {
struct Socket {
let local: String
let peer: String
let path: String
let owner: Bool
}
let sockets: [Socket] = text.split(separator: "\n").compactMap { line in
let fields = line.split(whereSeparator: \.isWhitespace).map(String.init)
guard fields.count >= 8, fields[0] == "u_str", fields[1] == "ESTAB",
UInt64(fields[5]) != nil, UInt64(fields[7]) != nil
else { return nil }
return Socket(
local: fields[5], peer: fields[7], path: fields[4],
owner: fields.dropFirst(8).joined().contains("pid=\(process),"))
}
var paths: Set<String> = []
for client in sockets where client.owner {
for peer in sockets where peer.local == client.peer && peer.peer == client.local {
if RemoteMultiplexerCommands.validPath(peer.path) { paths.insert(peer.path) }
}
}
return paths.count == 1 ? paths.first : nil
}
static func darwinEndpoint(_ text: String, process: String) -> String? {
struct Socket {
let process: String
let device: String
let name: String
}
var sockets: [Socket] = []
var pid = ""
var device = ""
var name = ""
func append() {
if !device.isEmpty, !name.isEmpty { sockets.append(.init(process: pid, device: device, name: name)) }
device = ""
name = ""
}
for line in text.split(separator: "\n") {
switch line.first {
case "p":
append()
pid = String(line.dropFirst())
case "f": append()
case "d": device = String(line.dropFirst())
case "n": name = String(line.dropFirst())
default: break
}
}
append()
var paths: Set<String> = []
for client in sockets where client.process == process {
guard let arrow = client.name.range(of: "->") else { continue }
let peerDevice = String(client.name[arrow.upperBound...])
for peer in sockets where peer.device == peerDevice {
let path = peer.name.components(separatedBy: "->")[0]
if RemoteMultiplexerCommands.validPath(path) { paths.insert(path) }
}
}
return paths.count == 1 ? paths.first : nil
}
static func guardClient(_ client: Client, terminal: String) throws -> String {
"""
[ "$(process_start '\(client.process)')" = \(try RemoteMultiplexerCommands.quote(client.started)) ] || exit 79
[ "/dev/$(ps -p '\(client.process)' -o tty= | tr -d ' ')" = \(try RemoteMultiplexerCommands.quote(terminal)) ] || exit 79
groups=$(ps -p '\(client.process)' -o pgid= -o tpgid=) || exit 79
set -- $groups
[ "$#" = 2 ] && [ "$1" = "$2" ] && [ "$1" -gt 0 ] || exit 79
"""
}
}