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.
This commit is contained in:
1 parent
01530326eb
commit
e457e9af98
21 files changed
+931
-266
No files matched your search
@@ -0,0 +1,457 @@
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@testable import TermindRemoteInfrastructure
|
||||
|
||||
@Suite
|
||||
nonisolated struct RemoteMultiplexerShellTests {
|
||||
private static let shells = ["/bin/bash", "/bin/zsh", "/opt/homebrew/bin/fish"]
|
||||
.filter { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
|
||||
@Test(arguments: [
|
||||
("100 100 100 pts/4 /bin/sh\n", true),
|
||||
("100 100 100 pts/4 -ksh\n", true),
|
||||
("100 100 100 pts/4 tmux\n", false),
|
||||
("100 100 200 pts/4 bash\n200 200 200 pts/4 tmux\n", false),
|
||||
("100 100 100 pts/4 bash\n200 100 100 pts/4 vim\n", false),
|
||||
("100 100 100 pts/4 bash\n200 100 100 pts/4 zsh\n", false),
|
||||
("100 100 100 pts/5 sh\n", false),
|
||||
("100 100 100 pts/4 unknown\n", false),
|
||||
("malformed\n", false),
|
||||
("", false),
|
||||
])
|
||||
func fallbackRequiresOneVerifiedForegroundShell(value: (String, Bool)) async throws {
|
||||
let fixture = try TmuxFixture()
|
||||
defer { fixture.cleanUp() }
|
||||
let probe = RemoteTerminalProcessProbe { command in
|
||||
try Self.run(
|
||||
"ps() { printf '%s' \(try RemoteMultiplexerCommands.quote(value.0)); }\n" + command,
|
||||
home: fixture.home.path)
|
||||
}
|
||||
#expect(try await probe.isOrdinaryShell(terminal: "/dev/pts/4") == value.1)
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func tmuxCreationPreservesTheInteractiveLocale(explicitLocale: Bool) throws {
|
||||
let fixture = try TmuxFixture()
|
||||
defer { fixture.cleanUp() }
|
||||
let result = try fixture.run(
|
||||
"""
|
||||
export STUB_EMPTY=1 LANG=en_US.UTF-8
|
||||
\(explicitLocale ? "export LC_ALL=C" : "unset LC_ALL")
|
||||
\(try RemoteTmuxCommands.create(token: fixture.owner, startupCommand: nil, shellIntegration: true))
|
||||
code=$?
|
||||
rm -rf "$helper"
|
||||
exit "$code"
|
||||
""")
|
||||
#expect(result.exitCode == 0, "\(result.stdout)")
|
||||
let locale = try String(contentsOf: fixture.home.appendingPathComponent("locale"), encoding: .utf8)
|
||||
#expect(locale == (explicitLocale ? "x|C|en_US.UTF-8" : "||en_US.UTF-8"))
|
||||
#expect(FileManager.default.fileExists(atPath: fixture.home.appendingPathComponent("created").path))
|
||||
}
|
||||
|
||||
@Test func ordinaryStartupRunsReadableNonExecutableHelpersWithAnUnsupportedShell() throws {
|
||||
let fixture = try TmuxFixture()
|
||||
defer { fixture.cleanUp() }
|
||||
let install = try RemoteShellStartup.install(
|
||||
token: UUID(), startupCommand: nil, shellIntegration: true, ordinary: true
|
||||
)
|
||||
.replacingOccurrences(of: "/tmp/termind-shell.XXXXXXXX", with: fixture.home.path + "/helper.XXXXXXXX")
|
||||
let installation = try Self.run(install + "\nprintf '%s' \"$helper\"", home: fixture.home.path)
|
||||
#expect(installation.exitCode == 0)
|
||||
let helper = installation.stdout
|
||||
for name in ["shell", "first"] {
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: helper + "/" + name)
|
||||
}
|
||||
let input = "printf usable > \"$HOME/usable\"; exit\n"
|
||||
for launch in [
|
||||
try RemoteShellStartup.ordinaryCommand(helper: helper),
|
||||
"export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec /bin/sh \"$TERMIND_TMUX_HELPERS/first\"",
|
||||
] {
|
||||
let result = try Self.run(
|
||||
"""
|
||||
export TERMIND_TMUX_SHELL=/bin/sh
|
||||
printf '%s' \(try RemoteMultiplexerCommands.quote(input)) | /bin/sh -c \(try RemoteMultiplexerCommands.quote(launch))
|
||||
""", home: fixture.home.path)
|
||||
#expect(result.exitCode == 0, "\(result.stdout)")
|
||||
#expect(
|
||||
try String(contentsOf: fixture.home.appendingPathComponent("usable"), encoding: .utf8) == "usable")
|
||||
try FileManager.default.removeItem(at: fixture.home.appendingPathComponent("usable"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func managedHelperExecutionFailurePrecedesNativeCreation() throws {
|
||||
let fixture = try TmuxFixture()
|
||||
defer { fixture.cleanUp() }
|
||||
let create = try RemoteTmuxCommands.create(
|
||||
token: fixture.owner, startupCommand: nil, shellIntegration: true
|
||||
)
|
||||
.replacingOccurrences(
|
||||
of: "chmod 700 \"$helper/shell\" \"$helper/first\"",
|
||||
with: "chmod 600 \"$helper/shell\" \"$helper/first\"")
|
||||
let result = try fixture.run("export STUB_EMPTY=1\n" + create)
|
||||
#expect(result.exitCode == 78)
|
||||
#expect(!FileManager.default.fileExists(atPath: fixture.home.appendingPathComponent("created").path))
|
||||
}
|
||||
|
||||
@Test func everyTmuxOperationResolvesTheExecutableOutsidePATH() throws {
|
||||
let fixture = try TmuxFixture()
|
||||
defer { fixture.cleanUp() }
|
||||
try fixture.journals(stale: 0, live: 1, backend: "tmux")
|
||||
let probe = try fixture.run(RemoteMultiplexerCommands.discovery(.tmux) + "\n\"$exe\" -V")
|
||||
#expect(probe.stdout == "tmux 3.4\n")
|
||||
let listing = try fixture.run(RemoteTmuxCommands.list)
|
||||
let session = try #require(try RemoteTmuxCommands.parseSessions(listing.stdout).first)
|
||||
let reference = RemoteSessionReference(
|
||||
backend: .tmux, session: session, hostID: UUID(), endpoint: "test:22", username: "user",
|
||||
ownershipToken: fixture.owner)
|
||||
let create = try fixture.run(
|
||||
RemoteTmuxCommands.create(token: fixture.owner, startupCommand: nil, shellIntegration: true))
|
||||
#expect(try RemoteTmuxCommands.parseSessions(create.stdout).first?.id == session.id)
|
||||
#expect(try fixture.run(RemoteTmuxCommands.attach(reference)).stdout == "TERMIND_ATTACHED\n")
|
||||
#expect(try fixture.run(RemoteTmuxCommands.terminate(reference)).stdout == "TERMIND_ENDED\n")
|
||||
|
||||
let runtime = try fixture.run(RemoteTmuxRuntimeDialect.command(terminal: "/dev/pts/4"))
|
||||
let update = try RemoteTmuxRuntimeDialect.parse(runtime.stdout, terminal: "/dev/pts/4")
|
||||
let context = try #require(update.contexts.first)
|
||||
#expect(context.capabilities.contains(.replaceDraft))
|
||||
let client = try #require(RemoteTmuxClientIdentity(update: update, terminal: "/dev/pts/4"))
|
||||
let resize = try fixture.run(
|
||||
"kill() { [ \"$1:$2\" = '-WINCH:300' ]; }\n" + RemoteTmuxRuntimeDialect.resizeCommand(client: client))
|
||||
#expect(resize.stdout == "TERMIND_RESIZED\n")
|
||||
let request = UUID()
|
||||
let draft = try fixture.run(
|
||||
RemoteTerminalDraft.command(
|
||||
context: context, terminal: "/dev/pts/4", helper: nil, clientIdentity: update.clientIdentity,
|
||||
request: request, replacement: "echo draft"))
|
||||
#expect(draft.exitCode == 0)
|
||||
let requested = try String(
|
||||
contentsOf: fixture.helper.appendingPathComponent("ctx.live0/request"), encoding: .utf8)
|
||||
#expect(requested.hasPrefix(request.uuidString + "|1|replace|"))
|
||||
let journal = fixture.helper.appendingPathComponent("ctx.live0/journal")
|
||||
let updatedJournal = try String(contentsOf: journal, encoding: .utf8)
|
||||
.replacingOccurrences(of: "R1|1|1|", with: "R1|2|1|")
|
||||
try (updatedJournal + "E1|2|draft|%64%72%61%66%74|\(request)\n")
|
||||
.write(to: journal, atomically: true, encoding: .utf8)
|
||||
let result = try fixture.run(
|
||||
RemoteTerminalDraft.resultCommand(context: context, helper: nil, request: request))
|
||||
let record = try RemoteShellContextRecord(result.stdout)
|
||||
#expect(record.entries.last?.1 == .draft(request: request.uuidString, value: "draft"))
|
||||
}
|
||||
|
||||
@Test(arguments: [1, 129])
|
||||
func tmuxJournalLimitCountsOnlyLivePaneProcesses(live: Int) throws {
|
||||
let fixture = try TmuxFixture()
|
||||
defer { fixture.cleanUp() }
|
||||
try fixture.journals(stale: 129, live: live, backend: "tmux")
|
||||
let result = try fixture.run(
|
||||
"export STUB_PANES=\(live)\n" + RemoteTmuxRuntimeDialect.command(terminal: "/dev/pts/4"))
|
||||
if live > 128 {
|
||||
#expect(result.exitCode == 78)
|
||||
} else {
|
||||
#expect(result.exitCode == 0, "\(result.stdout)")
|
||||
let update = try RemoteTmuxRuntimeDialect.parse(result.stdout, terminal: "/dev/pts/4")
|
||||
#expect(update.contexts.count == 1)
|
||||
#expect(update.contexts.first?.capabilities.contains(.replaceDraft) == true)
|
||||
#expect(update.active?.shellInstance == "ctx.live0")
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [1, 129])
|
||||
func socketJournalLimitCountsOnlyValidatedProcesses(live: Int) throws {
|
||||
let fixture = try TmuxFixture()
|
||||
defer { fixture.cleanUp() }
|
||||
try fixture.journals(stale: 129, live: live, backend: "zmx")
|
||||
let result = try fixture.run(
|
||||
"""
|
||||
helper="$STUB_HELPER"
|
||||
shell_check() { [ "$1" -ge 500 ]; }
|
||||
foreground_shell() { shell_check "$@"; }
|
||||
\(RemoteSocketMultiplexerRuntime.journalCommand(backend: .zmx))
|
||||
""")
|
||||
if live > 128 {
|
||||
#expect(result.exitCode == 78)
|
||||
} else {
|
||||
#expect(result.exitCode == 0)
|
||||
let lines = result.stdout.components(separatedBy: "\n")
|
||||
#expect(lines.first == "BEGIN|1")
|
||||
#expect(lines.filter { $0 == "BEGIN|1" }.count == 1)
|
||||
let record = try RemoteShellContextRecord(lines[1...3].joined(separator: "\n"))
|
||||
#expect(record.instance == "ctx.live0")
|
||||
#expect(record.ready)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"], shells)
|
||||
func shellHooksKeepCwdInTheJournalDuringCommands(backend: String, shell: String) throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cwd-test-\(UUID())")
|
||||
try FileManager.default.createDirectory(
|
||||
at: root.appendingPathComponent("next"), withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let install = try RemoteShellStartup.install(
|
||||
token: UUID(), startupCommand: "cd \"$HOME\"", shellIntegration: true,
|
||||
backend: ["shell", "tmux"].contains(backend) ? nil : backend
|
||||
).replacingOccurrences(
|
||||
of: "/tmp/termind-shell.XXXXXXXX",
|
||||
with: root.appendingPathComponent("termind-shell.XXXXXXXX").path
|
||||
)
|
||||
let input = """
|
||||
cat "$__termind_context_dir/journal" > "$HOME/during"
|
||||
cd "$HOME/next"
|
||||
cat "$__termind_context_dir/journal" > "$HOME/after"
|
||||
exit
|
||||
"""
|
||||
let result = try Self.run(
|
||||
"""
|
||||
\(install)
|
||||
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
|
||||
unset TMUX_PANE ZELLIJ_PANE_ID HERDR_PANE_ID
|
||||
\(backend == "tmux" ? "export TMUX_PANE=%1" : "")
|
||||
export ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1
|
||||
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(input)) | /bin/sh "$helper/first"
|
||||
""", home: root.path)
|
||||
#expect(result.exitCode == 0, "\(result.stdout)")
|
||||
let during = try RemoteShellContextRecord(
|
||||
String(contentsOf: root.appendingPathComponent("during"), encoding: .utf8))
|
||||
let after = try RemoteShellContextRecord(
|
||||
String(contentsOf: root.appendingPathComponent("after"), encoding: .utf8))
|
||||
#expect(during.backend == backend)
|
||||
#expect(!during.ready)
|
||||
#expect(!after.ready)
|
||||
#expect(during.directory == root.resolvingSymlinksInPath().path)
|
||||
#expect(after.directory == root.appendingPathComponent("next").resolvingSymlinksInPath().path)
|
||||
#expect(after.sequence > during.sequence)
|
||||
}
|
||||
|
||||
@Test(arguments: SessionPersistenceBackendID.supported, shells)
|
||||
func startupSnippetRunsOnlyInFirstShellAndPreservesUserConfiguration(
|
||||
backend: SessionPersistenceBackendID, shell: String
|
||||
) throws {
|
||||
for integration in [false, true] {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-shell-test-\(UUID())")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let name = URL(fileURLWithPath: shell).lastPathComponent
|
||||
let userConfig = name == "zsh" ? ".zshrc" : ".bash_profile"
|
||||
try "export USER_CONFIGURATION=preserved\n".write(
|
||||
to: root.appendingPathComponent(userConfig), atomically: true, encoding: .utf8)
|
||||
if name == "fish" {
|
||||
let config = root.appendingPathComponent(".config/fish")
|
||||
try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true)
|
||||
try "set -gx USER_CONFIGURATION preserved\n".write(
|
||||
to: config.appendingPathComponent("config.fish"), atomically: true, encoding: .utf8)
|
||||
}
|
||||
let startup =
|
||||
name == "fish"
|
||||
? "cd \"$HOME\"; set -gx FROM_SNIPPET yes; printf once >> \"$HOME/once\""
|
||||
: "cd \"$HOME\"; export FROM_SNIPPET=yes; printf once >> \"$HOME/once\""
|
||||
let install = try RemoteShellStartup.install(
|
||||
token: UUID(), startupCommand: startup, shellIntegration: integration,
|
||||
backend: backend == .tmux ? nil : backend.rawValue,
|
||||
environment: [.init(variable: "SESSION_ONLY", value: "host-value")]
|
||||
)
|
||||
.replacingOccurrences(
|
||||
of: "/tmp/termind-shell.XXXXXXXX",
|
||||
with: root.appendingPathComponent("termind-shell.XXXXXXXX").path
|
||||
)
|
||||
|
||||
let firstInput = """
|
||||
printf '%s|%s|%s|%s\\n' "$PWD" "$FROM_SNIPPET" "$USER_CONFIGURATION" "$SESSION_ONLY" > "$HOME/first-result"
|
||||
exit
|
||||
"""
|
||||
let secondInput = """
|
||||
printf '%s\\n' "$USER_CONFIGURATION" > "$HOME/second-result"
|
||||
exit
|
||||
"""
|
||||
let script = """
|
||||
\(install)
|
||||
trap 'rm -rf "$helper"' EXIT
|
||||
[ -z "${SESSION_ONLY:-}" ] || exit 83
|
||||
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
|
||||
export TMUX_PANE=%1 ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1
|
||||
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(firstInput)) | /bin/sh "$helper/first"
|
||||
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(secondInput)) | /bin/sh "$helper/shell"
|
||||
"""
|
||||
let result = try Self.run(script, home: root.path)
|
||||
#expect(result.exitCode == 0, "\(result.stderr)\n\(result.stdout)")
|
||||
#expect(try String(contentsOf: root.appendingPathComponent("once"), encoding: .utf8) == "once")
|
||||
let first = try String(contentsOf: root.appendingPathComponent("first-result"), encoding: .utf8)
|
||||
#expect(first.hasSuffix("|yes|preserved|host-value\n"), "\(first)")
|
||||
#expect(
|
||||
try String(contentsOf: root.appendingPathComponent("second-result"), encoding: .utf8)
|
||||
== "preserved\n")
|
||||
#expect(
|
||||
try String(contentsOf: root.appendingPathComponent(userConfig), encoding: .utf8)
|
||||
== "export USER_CONFIGURATION=preserved\n")
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: shells)
|
||||
func cancelledStartupCannotRunLaterWhenUserConfigurationFinishes(shell: String) throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cancel-test-\(UUID())")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let install = try RemoteShellStartup.install(
|
||||
token: UUID(), startupCommand: "printf late > \"$HOME/late\"", shellIntegration: true, backend: "zmx"
|
||||
).replacingOccurrences(
|
||||
of: "/tmp/termind-shell.XXXXXXXX", with: root.appendingPathComponent("termind-shell.XXXXXXXX").path)
|
||||
let result = try Self.run(
|
||||
"""
|
||||
\(install)
|
||||
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
|
||||
mkdir "$helper/startup-decision"
|
||||
printf 'printf ready > "$HOME/ready"\\nexit\\n' | /bin/sh "$helper/shell" || exit $?
|
||||
[ ! -e "$helper/started" ]
|
||||
""", home: root.path)
|
||||
#expect(result.exitCode == 0, "\(result.stdout)")
|
||||
#expect(try String(contentsOf: root.appendingPathComponent("ready"), encoding: .utf8) == "ready")
|
||||
#expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("late").path))
|
||||
}
|
||||
|
||||
@Test func expiredAndMismatchedDraftRequestsAreConsumedWithoutReturningPayload() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-draft-test-\(UUID())")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let state = root.appendingPathComponent("state")
|
||||
let request = root.appendingPathComponent("request")
|
||||
let reader = root.appendingPathComponent("reader")
|
||||
try "R1|10|1|%2Ftmp\n".write(to: state, atomically: true, encoding: .utf8)
|
||||
try RemoteShellContextJournal.requestReader.write(to: reader, atomically: true, encoding: .utf8)
|
||||
for (sequence, deadline) in [("10", "1"), ("9", String(Int(Date().timeIntervalSince1970) + 60))] {
|
||||
try "\(UUID())|\(sequence)|replace|%65%63%68%6F|\(deadline)|-\n".write(
|
||||
to: request, atomically: true, encoding: .utf8)
|
||||
let result = try Self.run(
|
||||
"sh \(RemoteMultiplexerCommands.quote(reader.path)) \(RemoteMultiplexerCommands.quote(root.path)) replace",
|
||||
home: root.path)
|
||||
#expect(result.exitCode != 0)
|
||||
#expect(result.stdout.isEmpty)
|
||||
#expect(!FileManager.default.fileExists(atPath: request.path))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TmuxFixture {
|
||||
let home = FileManager.default.temporaryDirectory.appendingPathComponent("termind-tmux-test-\(UUID())")
|
||||
let helper = URL(
|
||||
fileURLWithPath: "/tmp/termind-shell." + UUID().uuidString.replacingOccurrences(of: "-", with: ""))
|
||||
let owner = UUID()
|
||||
let token = UUID()
|
||||
|
||||
init() throws {
|
||||
let bin = home.appendingPathComponent(".local/bin")
|
||||
try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: helper, withIntermediateDirectories: true)
|
||||
try token.uuidString.write(
|
||||
to: helper.appendingPathComponent("event-token"), atomically: true, encoding: .utf8)
|
||||
let executable = bin.appendingPathComponent("tmux")
|
||||
try Self.stub.write(to: executable, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path)
|
||||
}
|
||||
|
||||
func cleanUp() {
|
||||
try? FileManager.default.removeItem(at: home)
|
||||
try? FileManager.default.removeItem(at: helper)
|
||||
}
|
||||
|
||||
func run(_ command: String) throws -> RemoteSessionCommandResult {
|
||||
// Do not allow fixture execution to discover a real multiplexer installed on the test host.
|
||||
let command = command.replacingOccurrences(
|
||||
of: "/opt/homebrew/bin /usr/local/bin /usr/bin /snap/bin \"$HOME/.local/bin\" \"$HOME/.cargo/bin\"",
|
||||
with: "\"$HOME/.local/bin\"")
|
||||
return try RemoteMultiplexerShellTests.run(
|
||||
"""
|
||||
export PATH=/usr/bin:/bin
|
||||
export STUB_HELPER=\(try RemoteMultiplexerCommands.quote(helper.path)) STUB_OWNER='\(owner)'
|
||||
\(command)
|
||||
""", home: home.path)
|
||||
}
|
||||
|
||||
func journals(stale: Int, live: Int, backend: String) throws {
|
||||
for (prefix, count, firstPID) in [("stale", stale, 10), ("live", live, 500)] {
|
||||
for index in 0..<count {
|
||||
let name = "ctx.\(prefix)\(index)"
|
||||
let directory = helper.appendingPathComponent(name)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let pane = backend == "tmux" ? "%\(index + 1)" : "shell"
|
||||
let metadata =
|
||||
backend == "tmux"
|
||||
? "M1|\(token)|\(pane)|\(name)|bash|%2Fhome|\(firstPID + index)|"
|
||||
: "M2|\(token)|\(pane)|\(name)|bash|%2Fhome|\(firstPID + index)||\(backend)"
|
||||
try "\(metadata)\nR1|1|1|%2Fwork\nE1|1|prompt||\n".write(
|
||||
to: directory.appendingPathComponent("journal"), atomically: true, encoding: .utf8)
|
||||
try "R1|1|1|%2Fwork\n".write(
|
||||
to: directory.appendingPathComponent("state"), atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let stub = #"""
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$HOME/calls"
|
||||
for argument do format=$argument; done
|
||||
case "$1" in
|
||||
-V) printf 'tmux 3.4\n' ;;
|
||||
list-sessions) [ "${STUB_EMPTY:-}" = 1 ] || printf '$1\n' ;;
|
||||
new-session)
|
||||
: > "$HOME/created"
|
||||
printf '%s|%s|%s' "${LC_ALL+x}" "${LC_ALL-}" "${LANG-}" > "$HOME/locale"
|
||||
printf '$1\n' ;;
|
||||
set-option) ;;
|
||||
show-environment)
|
||||
case "$4" in
|
||||
TERMIND_TMUX_OWNER) printf 'TERMIND_TMUX_OWNER=%s\n' "$STUB_OWNER" ;;
|
||||
TERMIND_TMUX_HELPERS) printf 'TERMIND_TMUX_HELPERS=%s\n' "$STUB_HELPER" ;;
|
||||
TERMIND_TMUX_SHELL) printf 'TERMIND_TMUX_SHELL=/bin/bash\n' ;;
|
||||
*) exit 90 ;;
|
||||
esac ;;
|
||||
display-message)
|
||||
case "$format" in
|
||||
'#{session_name}') printf 'build\n' ;;
|
||||
'#{pid}:#{session_created}') printf '200:100\n' ;;
|
||||
'#{pane_current_path}') printf '/work\n' ;;
|
||||
'#{pane_current_command}') printf 'bash\n' ;;
|
||||
*session_id*) printf '$1 100 200 1 1\n' ;;
|
||||
*) printf '1\n' ;;
|
||||
esac ;;
|
||||
list-clients)
|
||||
case "$format" in
|
||||
C*) printf 'C|/dev/pts/4|300|400|200|$1|100|%%1||root\n' ;;
|
||||
*client_key_table*) printf '/dev/pts/4|300|400|%%1||root|$1|100\n' ;;
|
||||
*) printf '/dev/pts/4|300|400|200|$1|100|\n' ;;
|
||||
esac ;;
|
||||
list-panes)
|
||||
i=0
|
||||
while [ "$i" -lt "${STUB_PANES:-1}" ]; do
|
||||
printf 'P|%%%s|%s|0|0|bash\n' "$((i + 1))" "$((i + 500))"
|
||||
i=$((i + 1))
|
||||
done ;;
|
||||
if-shell)
|
||||
case "$*" in
|
||||
*kill-session*) printf 'TERMIND_ENDED\n' ;;
|
||||
*attach-session*) printf 'TERMIND_ATTACHED\n' ;;
|
||||
esac ;;
|
||||
*) exit 91 ;;
|
||||
esac
|
||||
"""#
|
||||
}
|
||||
|
||||
static func run(_ command: String, home: String) throws -> RemoteSessionCommandResult {
|
||||
let process = Process()
|
||||
let output = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
process.arguments = ["-c", command]
|
||||
process.environment = ProcessInfo.processInfo.environment.merging([
|
||||
"HOME": home, "XDG_CONFIG_HOME": home + "/.config", "XDG_DATA_HOME": home + "/.local/share",
|
||||
"ZDOTDIR": home, "LC_ALL": "C",
|
||||
]) { _, new in new }
|
||||
process.standardOutput = output
|
||||
process.standardError = output
|
||||
try process.run()
|
||||
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return .init(stdout: String(decoding: data, as: UTF8.self), stderr: "", exitCode: process.terminationStatus)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -7,6 +7,29 @@ import Testing
|
||||
|
||||
@Suite
|
||||
nonisolated struct RemoteMultiplexerTests {
|
||||
@Test func ordinaryStartupFallbackIsBoundedOneShotAndGenerationScoped() {
|
||||
let generation = UUID()
|
||||
var unsupportedShell = RemoteShellStartupReadiness(generation: generation, now: 100)
|
||||
unsupportedShell.receive(.init(status: .unknown))
|
||||
let beforeDeadline = unsupportedShell.poll(generation: generation, now: 3_000_000_099)
|
||||
let atDeadline = unsupportedShell.poll(generation: generation, now: 3_000_000_100)
|
||||
let afterCompletion = unsupportedShell.poll(generation: generation, now: 4_000_000_000)
|
||||
#expect(!beforeDeadline)
|
||||
#expect(atDeadline)
|
||||
#expect(!afterCompletion)
|
||||
var replaced = RemoteShellStartupReadiness(generation: generation, now: 100)
|
||||
let replacement = replaced.poll(generation: UUID(), now: 4_000_000_000)
|
||||
let afterReplacement = replaced.poll(generation: generation, now: 4_000_000_000)
|
||||
#expect(!replacement)
|
||||
#expect(!afterReplacement)
|
||||
for status in [TerminalRuntimeUpdate.Status.ordinary, .multiplexer] {
|
||||
var integrated = RemoteShellStartupReadiness(generation: generation, now: 100)
|
||||
integrated.receive(.init(status: status))
|
||||
let afterIntegration = integrated.poll(generation: generation, now: 4_000_000_000)
|
||||
#expect(!afterIntegration)
|
||||
}
|
||||
}
|
||||
|
||||
private static let token = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!
|
||||
private static let kinds = SessionPersistenceBackendID.supported
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import Foundation
|
||||
|
||||
public nonisolated enum SSHShellIntegrationEvent: Sendable {
|
||||
case runtime(TerminalRuntimeUpdate)
|
||||
/// Ordinary PTY startup completed without hooks; this grants no runtime context capabilities.
|
||||
case ordinaryStartupUnavailable(ticket: SSHOrdinaryStartupTicket?)
|
||||
case available(shell: String, systemInfo: RemoteSystemInfo?, homeDirectory: String?)
|
||||
case systemInfo(RemoteSystemInfo)
|
||||
case homeResolutionUnavailable
|
||||
@@ -10,3 +12,14 @@ public nonisolated enum SSHShellIntegrationEvent: Sendable {
|
||||
case commandFinished(Int32, duration: TimeInterval?)
|
||||
case draft(String)
|
||||
}
|
||||
|
||||
/// A single connection/input epoch, revalidated by the transport before fallback submission.
|
||||
public nonisolated struct SSHOrdinaryStartupTicket: Sendable, Equatable {
|
||||
public let generation: UUID
|
||||
public let inputRevision: UInt64
|
||||
|
||||
public init(generation: UUID, inputRevision: UInt64) {
|
||||
self.generation = generation
|
||||
self.inputRevision = inputRevision
|
||||
}
|
||||
}
|
||||
@@ -210,23 +210,7 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime {
|
||||
native=$(\(nativeCommand)) || exit 79
|
||||
focus=$(\(focusCommand)) || exit 79
|
||||
printf 'C|%s\\nN|%s\\nA|%s\\n' "$client" "$(hex "$native")" "$(hex "$focus")"
|
||||
count=0
|
||||
for file in "$helper"/ctx.*/journal; do
|
||||
[ -f "$file" ] && [ ! -L "$file" ] || continue
|
||||
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
|
||||
IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || exit 78
|
||||
[ "$tag" = M2 ] && [ "$backend" = '\(try identity.kind.rawValue)' ] || continue
|
||||
[ "$token" = "$(cat "$helper/event-token")" ] || continue
|
||||
dir=${file%/journal}
|
||||
[ "${dir##*/}" = "$instance" ] || continue
|
||||
shell_check "$pid" "$shell" "$dir" || continue
|
||||
foreground=0
|
||||
if foreground_shell "$pid" "$shell" "$dir"; then foreground=1; fi
|
||||
printf 'BEGIN|%s\\n' "$foreground"
|
||||
cat "$file" || exit 78
|
||||
printf 'END\\n'
|
||||
done
|
||||
printf 'DONE\\n'
|
||||
\(Self.journalCommand(backend: try identity.kind))
|
||||
"""
|
||||
let result = try await execute(command)
|
||||
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
|
||||
@@ -234,6 +218,28 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime {
|
||||
result.stdout, reference: reference, charset: charset, clientBound: boundClient != nil)
|
||||
}
|
||||
|
||||
static func journalCommand(backend: SessionPersistenceBackendID) -> String {
|
||||
"""
|
||||
count=0
|
||||
for file in "$helper"/ctx.*/journal; do
|
||||
[ -f "$file" ] && [ ! -L "$file" ] || continue
|
||||
IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || exit 78
|
||||
[ "$tag" = M2 ] && [ "$backend" = '\(backend.rawValue)' ] || continue
|
||||
[ "$token" = "$(cat "$helper/event-token")" ] || continue
|
||||
dir=${file%/journal}
|
||||
[ "${dir##*/}" = "$instance" ] || continue
|
||||
shell_check "$pid" "$shell" "$dir" || continue
|
||||
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
|
||||
foreground=0
|
||||
if foreground_shell "$pid" "$shell" "$dir"; then foreground=1; fi
|
||||
printf 'BEGIN|%s\\n' "$foreground"
|
||||
cat "$file" || exit 78
|
||||
printf 'END\\n'
|
||||
done
|
||||
printf 'DONE\\n'
|
||||
"""
|
||||
}
|
||||
|
||||
static func parse(
|
||||
_ text: String, reference: RemoteSessionReference, charset: RemoteCharset = .utf8, clientBound: Bool = false
|
||||
) throws -> TerminalRuntimeUpdate {
|
||||
|
||||
@@ -3,6 +3,10 @@ import TermindDomain
|
||||
|
||||
/// Private, per-session startup files. Nothing is ever typed into an attached terminal.
|
||||
nonisolated enum RemoteShellStartup {
|
||||
static func ordinaryCommand(helper: String) throws -> String {
|
||||
"export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\""
|
||||
}
|
||||
|
||||
static func install(
|
||||
token: UUID, startupCommand: String?, shellIntegration: Bool, ordinary: Bool = false,
|
||||
backend: String? = nil, environment: [HostEnvironmentVariable] = []
|
||||
@@ -54,6 +58,7 @@ nonisolated enum RemoteShellStartup {
|
||||
+ "\n" + fishStartup
|
||||
let shell = """
|
||||
#!/bin/sh
|
||||
if [ "${1:-}" = --termind-check-executable ]; then exit 0; fi
|
||||
\(backend == nil ? "" : """
|
||||
if [ "${TERMIND_SHELL_LOCALE_SET:-}" = x ]; then
|
||||
export LC_ALL="$TERMIND_SHELL_LOCALE"
|
||||
@@ -97,7 +102,7 @@ nonisolated enum RemoteShellStartup {
|
||||
"""
|
||||
// Run the first command inside the initialized shell, preserving cd/export state.
|
||||
// Subsequent ordinary panes use `shell` directly and cannot replay this command.
|
||||
let first = "#!/bin/sh\nexport TERMIND_TMUX_FIRST=1\nexec \"$TERMIND_TMUX_HELPERS/shell\"\n"
|
||||
let first = "#!/bin/sh\nexport TERMIND_TMUX_FIRST=1\nexec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\"\n"
|
||||
var files = [
|
||||
"shell": shell, "first": first, "bashrc": bashrc, "fish": fish, "event": RemoteShellContextJournal.writer,
|
||||
"request-reader": RemoteShellContextJournal.requestReader, "event-token": eventToken.uuidString,
|
||||
@@ -119,6 +124,36 @@ nonisolated enum RemoteShellStartup {
|
||||
helper=$(mktemp -d "/tmp/termind-shell.XXXXXXXX") || exit 78
|
||||
\(writes)
|
||||
chmod 700 "$helper/shell" "$helper/first" || exit 78
|
||||
\(ordinary ? "" : """
|
||||
"$helper/shell" --termind-check-executable >/dev/null 2>&1 || { rm -rf "$helper"; exit 78; }
|
||||
""")
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
/// Only ordinary startup uses the plain-shell fallback, never multiplexer inspection.
|
||||
nonisolated struct RemoteShellStartupReadiness {
|
||||
private let generation: UUID
|
||||
private var deadline: UInt64?
|
||||
|
||||
init(generation: UUID, now: UInt64 = DispatchTime.now().uptimeNanoseconds) {
|
||||
self.generation = generation
|
||||
deadline = now + 3_000_000_000
|
||||
}
|
||||
|
||||
mutating func receive(_ update: TerminalRuntimeUpdate) {
|
||||
if update.status == .ordinary || update.status == .multiplexer || update.backend != nil {
|
||||
deadline = nil
|
||||
}
|
||||
}
|
||||
|
||||
mutating func poll(generation: UUID, now: UInt64 = DispatchTime.now().uptimeNanoseconds) -> Bool {
|
||||
guard self.generation == generation else {
|
||||
deadline = nil
|
||||
return false
|
||||
}
|
||||
guard let deadline, now >= deadline else { return false }
|
||||
self.deadline = nil
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ nonisolated enum RemoteTerminalDraft {
|
||||
throw TerminalRuntimeError.staleContext
|
||||
}
|
||||
root = """
|
||||
\(RemoteTmuxCommands.executionPreamble)
|
||||
helper=$(tmux show-environment -t \(try RemoteMultiplexerCommands.quote(String(session))) TERMIND_TMUX_HELPERS) || exit 79
|
||||
helper=${helper#TERMIND_TMUX_HELPERS=}
|
||||
"""
|
||||
|
||||
@@ -15,6 +15,34 @@ nonisolated struct RemoteTerminalProcessProbe {
|
||||
|
||||
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 }
|
||||
|
||||
@@ -16,6 +16,18 @@ nonisolated struct TmuxSessionIdentity: Codable {
|
||||
}
|
||||
|
||||
public nonisolated enum RemoteTmuxCommands {
|
||||
static var executionPreamble: String {
|
||||
"""
|
||||
termind_resolve_tmux() {
|
||||
\(RemoteMultiplexerCommands.discovery(.tmux))
|
||||
printf '%s' "$exe"
|
||||
}
|
||||
exe=$(termind_resolve_tmux) || exit $?
|
||||
unset -f termind_resolve_tmux
|
||||
export PATH="${exe%/*}:$PATH"
|
||||
"""
|
||||
}
|
||||
|
||||
public static func quote(_ value: String) throws -> String {
|
||||
try RemoteMultiplexerCommands.quote(value)
|
||||
}
|
||||
@@ -42,7 +54,7 @@ public nonisolated enum RemoteTmuxCommands {
|
||||
|
||||
public static var list: String {
|
||||
"""
|
||||
export LC_ALL=C
|
||||
\(executionPreamble)
|
||||
\(describe)
|
||||
ids=$(tmux list-sessions -F '#{session_id}' 2>&1)
|
||||
code=$?
|
||||
@@ -132,6 +144,7 @@ public nonisolated enum RemoteTmuxCommands {
|
||||
case \(shell) in */bash|*/zsh|*/fish) ;; *) exit 80 ;; esac
|
||||
"""
|
||||
return """
|
||||
\(executionPreamble)
|
||||
\(describe)
|
||||
session_shell=\(shell)
|
||||
id=$(tmux list-sessions -f '#{==:#{session_name},\(name)}' -F '#{session_id}' 2>/dev/null)
|
||||
@@ -173,13 +186,15 @@ public nonisolated enum RemoteTmuxCommands {
|
||||
// The guard and attach share tmux's command queue; never use -d, -A, or a name prefix.
|
||||
let command = "attach-session -t \(target)"
|
||||
return
|
||||
"exec tmux if-shell -t \(target) -F \(try quote(identityCondition(attachment))) \(try quote(command)) 'display-message -p TERMIND_IDENTITY_MISMATCH'"
|
||||
executionPreamble
|
||||
+ "\nexec tmux if-shell -t \(target) -F \(try quote(identityCondition(attachment))) \(try quote(command)) 'display-message -p TERMIND_IDENTITY_MISMATCH'"
|
||||
}
|
||||
|
||||
public static func terminate(_ attachment: RemoteSessionReference) throws -> String {
|
||||
guard attachment.ownershipToken != nil else { throw RemoteSessionFailure.ownershipMismatch }
|
||||
let target = try quote(attachment.session.id)
|
||||
return """
|
||||
\(executionPreamble)
|
||||
[ "$(tmux display-message -p -t \(target) \(try quote(identityCondition(attachment))))" = 1 ] || exit 79
|
||||
tmux if-shell -t \(target) -F \(try quote(identityCondition(attachment))) \
|
||||
\(try quote("kill-session -t \(target) ; display-message -p TERMIND_ENDED"))
|
||||
|
||||
@@ -41,12 +41,12 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime {
|
||||
// Revalidate on the server immediately before signalling. A detached client's TTY
|
||||
// may already contain a shell, so this operation must never write terminal input.
|
||||
return """
|
||||
export LC_ALL=C
|
||||
\(RemoteTmuxCommands.executionPreamble)
|
||||
client=$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_id}|#{session_created}|#{client_flags}' 2>/dev/null | \
|
||||
while IFS='|' read -r tty cp cc sp sid sc flags; do
|
||||
if [ "$tty" = \(try RemoteTmuxCommands.quote(client.terminal)) ]; then
|
||||
case ",$flags," in
|
||||
*,read-only,*|*,ignore-size,*|*,active-pane,*|*,control-mode,*) continue ;;
|
||||
(*,read-only,*|*,ignore-size,*|*,active-pane,*|*,control-mode,*) continue ;;
|
||||
esac
|
||||
printf '%s|%s|%s|%s|%s|%s\\n' "$tty" "$cp" "$cc" "$sp" "$sid" "$sc"
|
||||
fi
|
||||
@@ -60,8 +60,7 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime {
|
||||
static func command(terminal: String) throws -> String {
|
||||
guard RemoteTmuxClientIdentity.validTerminal(terminal) else { throw TerminalRuntimeError.invalidResponse }
|
||||
return """
|
||||
export LC_ALL=C
|
||||
command -v tmux >/dev/null 2>&1 || exit 77
|
||||
\(RemoteTmuxCommands.executionPreamble)
|
||||
tmux -V || exit 77
|
||||
client=$(tmux list-clients -F 'C|#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_id}|#{session_created}|#{pane_id}|#{client_flags}|#{client_key_table}' 2>/dev/null | \
|
||||
while IFS='|' read -r tag tty cp cc sp sid sc pane flags table; do
|
||||
@@ -72,7 +71,8 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime {
|
||||
if [ -z "$client" ]; then printf 'NONE\\n'; exit 0; fi
|
||||
printf '%s\\n' "$client"
|
||||
sid=$(printf '%s' "$client" | cut -d '|' -f6)
|
||||
tmux list-panes -s -t "$sid" -F 'P|#{pane_id}|#{pane_pid}|#{pane_in_mode}|#{synchronize-panes}|#{?#{==:#{pane_current_command},bash},bash,#{?#{==:#{pane_current_command},zsh},zsh,#{?#{==:#{pane_current_command},fish},fish,unknown}}}' || exit 78
|
||||
panes=$(tmux list-panes -s -t "$sid" -F 'P|#{pane_id}|#{pane_pid}|#{pane_in_mode}|#{synchronize-panes}|#{?#{==:#{pane_current_command},bash},bash,#{?#{==:#{pane_current_command},zsh},zsh,#{?#{==:#{pane_current_command},fish},fish,unknown}}}') || exit 78
|
||||
printf '%s\\n' "$panes"
|
||||
pane=$(printf '%s' "$client" | cut -d '|' -f8)
|
||||
cwd=$(tmux display-message -p -t "$pane" '#{pane_current_path}') || exit 78
|
||||
foreground=$(tmux display-message -p -t "$pane" '#{pane_current_command}') || exit 78
|
||||
@@ -87,6 +87,12 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime {
|
||||
count=0
|
||||
for file in "$helper"/ctx.*/journal; do
|
||||
[ -f "$file" ] && [ ! -L "$file" ] || continue
|
||||
IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || continue
|
||||
case "$tag:$backend" in M1:|M2:tmux) ;; *) continue ;; esac
|
||||
[ "$token" = "$(cat "$helper/event-token")" ] || continue
|
||||
dir=${file%/journal}
|
||||
[ ! -L "$dir" ] && [ "${dir##*/}" = "$instance" ] || continue
|
||||
[ "$(printf '%s\\n' "$panes" | awk -F '|' -v pane="$pane" '$2 == pane {print $3}')" = "$pid" ] || continue
|
||||
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
|
||||
printf 'BEGIN\\n'
|
||||
cat "$file" || exit 78
|
||||
@@ -216,6 +222,7 @@ extension RemoteTmuxRuntimeDialect {
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
let target = try RemoteTmuxCommands.quote(session[0])
|
||||
let root = """
|
||||
\(RemoteTmuxCommands.executionPreamble)
|
||||
[ "$(tmux display-message -p -t \(target) '#{pid}:#{session_created}')" = '\(identity.server):\(session[1])' ] || exit 79
|
||||
[ "$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pane_id}|#{client_flags}|#{client_key_table}|#{session_id}|#{session_created}' | \
|
||||
awk -F '|' -v tty=\(try RemoteTmuxCommands.quote(terminal)) '$1 == tty {print $2 ":" $3 ":" $4 ":" $6 ":" $7 ":" $8; if ($5 ~ /active-pane|read-only/) print "UNSUPPORTED"}')" \
|
||||
|
||||
@@ -892,6 +892,11 @@ public actor SSHPTYChannel {
|
||||
worker.write(data)
|
||||
}
|
||||
|
||||
public func submitInitialCommand(_ data: Data, ticket: SSHOrdinaryStartupTicket) async {
|
||||
guard resumable == nil else { return }
|
||||
await worker.submitInitialCommand(data, ticket: ticket)
|
||||
}
|
||||
|
||||
/// Opens an SFTP subsystem Channel on the PTY Channel's authenticated connection.
|
||||
/// This is a fallback only: callers must first attempt their ordinary
|
||||
/// independent SFTP connection.
|
||||
@@ -969,6 +974,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
private var shellIntegrationLifecycleLogger: RemoteShellIntegrationLifecycleLogger
|
||||
private var commandEventFilter: RemoteCommandEventFilter?
|
||||
private var contextFilter: RemoteTerminalContextFilter?
|
||||
private var ordinaryStartup: RemoteShellStartupReadiness?
|
||||
private var ordinaryStartupInputRevision: UInt64?
|
||||
private var ordinaryStartupTicket: SSHOrdinaryStartupTicket?
|
||||
private var runtimeTask: Task<Void, Never>?
|
||||
private var runtimeTerminal: String?
|
||||
private var runtimeRevision: UInt64 = 0
|
||||
@@ -1344,13 +1352,6 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
hostID: request.configuration.hostID,
|
||||
enabled: shellIntegrationEnabled
|
||||
)
|
||||
if shellIntegrationEnabled {
|
||||
let bootstrap = RemoteShellIntegrationBootstrap(charset: request.configuration.charset)
|
||||
pendingWrites.append(bootstrap.startCommand)
|
||||
commandEventFilter = RemoteCommandEventFilter(
|
||||
token: bootstrap.token, charset: request.configuration.charset)
|
||||
shellIntegration = bootstrap
|
||||
}
|
||||
startPump()
|
||||
progress?(.connected(request.configuration.hostID))
|
||||
}
|
||||
@@ -1384,6 +1385,38 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func submitInitialCommand(_ data: Data, ticket: SSHOrdinaryStartupTicket) async {
|
||||
do {
|
||||
let (terminal, revision) = try await queue.perform { [self] in
|
||||
guard ordinaryStartupTicket == ticket,
|
||||
ptyResize.generation == ticket.generation, runtimeInputRevision == ticket.inputRevision,
|
||||
runtimeAttachment == nil, ptyChannel != nil, let runtimeTerminal
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
ordinaryStartupTicket = nil
|
||||
return (runtimeTerminal, runtimeRevision)
|
||||
}
|
||||
let probe = RemoteTerminalProcessProbe(
|
||||
execute: try await sessionCommandExecutor(runtime: true, expectedInput: ticket.inputRevision))
|
||||
guard try await probe.isOrdinaryShell(terminal: terminal) else { throw TerminalRuntimeError.unsupported }
|
||||
try await queue.perform { [self] in
|
||||
guard ptyResize.generation == ticket.generation, runtimeInputRevision == ticket.inputRevision,
|
||||
runtimeRevision == revision, runtimeTerminal == terminal, runtimeAttachment == nil,
|
||||
ptyChannel != nil, latestRuntimeUpdate?.status != .multiplexer,
|
||||
latestRuntimeUpdate?.backend == nil
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
runtimeInputRevision &+= 1
|
||||
runtimeRevision &+= 1
|
||||
let converted = (try? inputConverter?.convert(data).data) ?? data
|
||||
if !converted.isEmpty { pendingWrites.append(converted) }
|
||||
pump()
|
||||
}
|
||||
} catch {
|
||||
logger.notice(
|
||||
"ssh.initial_command.skipped",
|
||||
metadata: ["reason": "ordinary_shell_not_verified"])
|
||||
}
|
||||
}
|
||||
|
||||
public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) {
|
||||
guard columns > 0, rows > 0 else { return }
|
||||
queue.async { [self] in
|
||||
@@ -1534,16 +1567,19 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0)
|
||||
})
|
||||
else { throw TerminalRuntimeError.invalidResponse }
|
||||
startupCommand =
|
||||
"export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec \"$TERMIND_TMUX_HELPERS/shell\""
|
||||
await queue.perform { self.runtimeHelper = helper }
|
||||
startupCommand = try RemoteShellStartup.ordinaryCommand(helper: helper)
|
||||
try await queue.perform {
|
||||
guard self.ptyResize.generation == generation else { throw CancellationError() }
|
||||
self.runtimeHelper = helper
|
||||
}
|
||||
} catch {
|
||||
try Task.checkCancellation()
|
||||
// Startup-only integration is optional; never fall back to typing a bootstrap.
|
||||
usesRuntime = false
|
||||
startupCommand = "exec \"${SHELL:-/bin/sh}\" -l"
|
||||
}
|
||||
}
|
||||
if let original = startupCommand, usesRuntime {
|
||||
if let original = startupCommand, shellIntegrationEnabled {
|
||||
startupCommand =
|
||||
"printf '\\033]777;TermindTerminal=\(runtimeToken.uuidString);%s\\007' \"$(tty)\";\n" + original
|
||||
}
|
||||
@@ -1556,8 +1592,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
runtimeEnabled = runtimeIsEnabled
|
||||
allowsDefaultHistorySource = launch == nil && !runtimeIsEnabled
|
||||
runtimeWantsObservation = runtimeIsEnabled && launch != nil
|
||||
if runtimeIsEnabled {
|
||||
if shellIntegrationEnabled {
|
||||
contextFilter = .init(token: runtimeToken.uuidString, charset: charset)
|
||||
}
|
||||
if runtimeIsEnabled {
|
||||
publishRuntime(.init(status: .unknown))
|
||||
} else if shellIntegrationEnabled {
|
||||
publishRuntime(.init(status: .unavailable))
|
||||
@@ -1620,6 +1658,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
outputConverter = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
|
||||
workingDirectoryFilter = RemoteOSC7CharsetFilter(charset: charset)
|
||||
didReportExit = false
|
||||
if launch == nil, shellIntegrationEnabled {
|
||||
ordinaryStartup = .init(generation: generation)
|
||||
ordinaryStartupInputRevision = runtimeInputRevision
|
||||
}
|
||||
progress?(.connected(request.configuration.hostID))
|
||||
pump()
|
||||
}
|
||||
@@ -2030,6 +2072,18 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
shellIntegration = update.isComplete ? nil : integration
|
||||
applyShellIntegrationUpdate(update)
|
||||
}
|
||||
if let generation = ptyResize.generation, ordinaryStartup?.poll(generation: generation) == true {
|
||||
ordinaryStartupTicket =
|
||||
runtimeTerminal == nil || runtimeInputRevision != ordinaryStartupInputRevision
|
||||
? nil : .init(generation: generation, inputRevision: runtimeInputRevision)
|
||||
shellIntegrationHandler(
|
||||
.ordinaryStartupUnavailable(ticket: ordinaryStartupTicket))
|
||||
if ordinaryStartupTicket == nil {
|
||||
logger.notice(
|
||||
"ssh.initial_command.skipped",
|
||||
metadata: ["reason": "terminal_missing_or_input_changed"])
|
||||
}
|
||||
}
|
||||
|
||||
while !pendingWrites.isEmpty && DispatchTime.now().uptimeNanoseconds < deadline {
|
||||
let written = pendingWrites.withFirstBytes { bytes -> Int in
|
||||
@@ -2267,6 +2321,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func publishRuntime(_ proposed: TerminalRuntimeUpdate) {
|
||||
ordinaryStartup?.receive(proposed)
|
||||
var update = proposed
|
||||
if runtimeControlUnavailable || channelBudget?.permitsConcurrentControl == false {
|
||||
if update.status == .multiplexer {
|
||||
@@ -2509,6 +2564,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func stopRuntimeObservation() {
|
||||
ordinaryStartup = nil
|
||||
ordinaryStartupInputRevision = nil
|
||||
ordinaryStartupTicket = nil
|
||||
activationTask?.task.cancel()
|
||||
activationTask = nil
|
||||
terminalActivation.reset()
|
||||
|
||||
@@ -765,21 +765,10 @@ final class MobileAppModel {
|
||||
return id
|
||||
}
|
||||
|
||||
func discardWorkspaceSessions(_ ids: Set<String>, authorized: Bool = false) {
|
||||
func discardWorkspaceSessions(_ ids: Set<String>) {
|
||||
guard !ids.isEmpty else { return }
|
||||
let recovered = ids.reduce(into: Set<UUID>()) { $0.formUnion(recoveredWorkspaceIDs[$1] ?? []) }
|
||||
let targets = workspaceSessions.flatMap { session in
|
||||
session.workspaces.filter { ids.contains(session.id) || recovered.contains($0.id) }
|
||||
}
|
||||
if !authorized,
|
||||
deferPersistentClose(
|
||||
targets,
|
||||
action: { [weak self] in
|
||||
self?.discardWorkspaceSessions(ids, authorized: true)
|
||||
})
|
||||
{
|
||||
return
|
||||
}
|
||||
// Native dismissal detaches clients; only explicit workspace closure ends owned remote work.
|
||||
persistentClose.cancel()
|
||||
discardedSessionIDs.formUnion(ids)
|
||||
orphanedSessionIDs.subtract(ids)
|
||||
for id in Array(sceneSessionIDs.keys) where ids.contains(sceneSessionIDs[id] ?? "") { removeScene(id) }
|
||||
@@ -798,7 +787,7 @@ final class MobileAppModel {
|
||||
for workspaceID in recoveredIDs { session.close(workspaceID, rememberForReopen: false, authorized: true) }
|
||||
}
|
||||
for id in ids { recoveredWorkspaceIDs[id] = nil }
|
||||
for session in workspaceSessions where ids.contains(session.id) { session.discard(authorized: true) }
|
||||
for session in workspaceSessions where ids.contains(session.id) { session.discard() }
|
||||
workspaceSessions.removeAll { ids.contains($0.id) }
|
||||
|
||||
}
|
||||
|
||||
@@ -386,34 +386,18 @@ final class MobileSceneModel {
|
||||
requestWindowClose(sessionID: workspaceSession.id)
|
||||
}
|
||||
|
||||
func closeAllWindows(authorized: Bool = false) {
|
||||
func closeAllWindows() {
|
||||
guard !workspaceSession.isDiscarded else { return }
|
||||
let sessionIDs = app.openWorkspaceSessionIDs()
|
||||
if !authorized,
|
||||
app.deferPersistentClose(
|
||||
sessionIDs.flatMap { app.workspaceSession(for: $0).workspaces },
|
||||
action: { [weak self] in self?.closeAllWindows(authorized: true) })
|
||||
{
|
||||
return
|
||||
}
|
||||
for id in sessionIDs.sorted() where id != workspaceSession.id {
|
||||
requestWindowClose(sessionID: id, authorized: true)
|
||||
requestWindowClose(sessionID: id)
|
||||
}
|
||||
if sessionIDs.contains(workspaceSession.id) {
|
||||
requestWindowClose(sessionID: workspaceSession.id, authorized: true)
|
||||
requestWindowClose(sessionID: workspaceSession.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestWindowClose(sessionID: String, authorized: Bool = false) {
|
||||
if !authorized,
|
||||
app.deferPersistentClose(
|
||||
app.workspaceSession(for: sessionID).workspaces,
|
||||
action: { [weak self] in
|
||||
self?.requestWindowClose(sessionID: sessionID, authorized: true)
|
||||
})
|
||||
{
|
||||
return
|
||||
}
|
||||
private func requestWindowClose(sessionID: String) {
|
||||
// UIKit's discard callback owns cleanup; a rejected request must preserve this session.
|
||||
let logger = app.logging.makeLogger(label: "termind.window")
|
||||
app.closeWorkspaceSession(sessionID) { error in
|
||||
|
||||
@@ -98,12 +98,9 @@ final class MobileWorkspaceSessionModel {
|
||||
}
|
||||
}
|
||||
|
||||
func discard(authorized: Bool = false) {
|
||||
func discard() {
|
||||
guard !isDiscarded else { return }
|
||||
if !authorized, app.deferPersistentClose(workspaces, action: { [weak self] in self?.discard(authorized: true) })
|
||||
{
|
||||
return
|
||||
}
|
||||
app.persistentClose.cancel()
|
||||
let states = orderedWorkspaceItems.map { $0.workspace?.reopenState ?? .init(descriptor: $0.descriptor) }
|
||||
app.rememberClosedWorkspaces(states, isWindow: true)
|
||||
closeAll(rememberForReopen: false, authorized: true)
|
||||
@@ -416,7 +413,8 @@ final class MobileWorkspaceSessionModel {
|
||||
usernameHint: state.usernameHint, connectionOrigin: state.connectionOrigin,
|
||||
externalSFTPResourcePath: state.externalSFTPResourcePath,
|
||||
externalSFTPMustBeDirectory: state.externalSFTPMustBeDirectory)
|
||||
workspace.restoreRemoteSession(state.remoteSession)
|
||||
workspace.restoreRemoteSession(state.descriptor.remoteSession)
|
||||
workspace.sessionPersistence.requiresSelection = state.descriptor.kind == .ssh
|
||||
addWorkspaces([workspace], persists: state.connectionOrigin.allowsPersistence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,8 @@ struct MobileWorkspaceConnectionServices {
|
||||
(@Sendable (String) -> Void)?
|
||||
) async throws -> any SFTPClientProtocol
|
||||
var writeSSH: (SSHPTYChannel, Data) async -> Void
|
||||
var submitInitialCommand: @MainActor (SSHPTYChannel, Data, SSHOrdinaryStartupTicket) async -> Void = {
|
||||
await $0.submitInitialCommand($1, ticket: $2)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -146,6 +146,7 @@ final class MobileWorkspace: Identifiable {
|
||||
private(set) var isShellIntegrationEnabled = true
|
||||
private var shellIntegrationAvailable = false
|
||||
private var isInitialCommandSubmissionReady = false
|
||||
private var initialCommandStartupTicket: SSHOrdinaryStartupTicket?
|
||||
private var pendingCommand: MobilePendingInitialCommand?
|
||||
/// Re-resolved from the Host and Snippet library for each physical terminal shell.
|
||||
private var pendingConnectionStartupCommand: MobilePendingInitialCommand?
|
||||
@@ -694,6 +695,7 @@ final class MobileWorkspace: Identifiable {
|
||||
isShellPromptReady = false
|
||||
shellIntegrationAvailable = false
|
||||
isInitialCommandSubmissionReady = false
|
||||
initialCommandStartupTicket = nil
|
||||
pendingConnectionStartupCommand = nil
|
||||
workingDirectory = nil
|
||||
commandExcludedFromHistory = nil
|
||||
@@ -711,6 +713,19 @@ final class MobileWorkspace: Identifiable {
|
||||
resetCommandGeneratorShellState()
|
||||
return
|
||||
}
|
||||
if case .ordinaryStartupUnavailable(let ticket) = event {
|
||||
guard sessionPersistence.attachment == nil else { return }
|
||||
guard let ticket else {
|
||||
pendingCommand = nil
|
||||
pendingConnectionStartupCommand = nil
|
||||
initialCommandStartupTicket = nil
|
||||
return
|
||||
}
|
||||
initialCommandStartupTicket = ticket
|
||||
isInitialCommandSubmissionReady = true
|
||||
submitPendingInitialCommandIfReady()
|
||||
return
|
||||
}
|
||||
if case .runtime(let update) = event {
|
||||
receiveRuntimeContext(update)
|
||||
return
|
||||
@@ -718,7 +733,7 @@ final class MobileWorkspace: Identifiable {
|
||||
guard !hasRuntimeContext else { return }
|
||||
commandMonitor.receiveShellEvent(event)
|
||||
switch event {
|
||||
case .runtime: break
|
||||
case .runtime, .ordinaryStartupUnavailable: break
|
||||
case .available(let shell, let systemInfo, _):
|
||||
integratedShell = shell
|
||||
remoteSystemInfo = systemInfo
|
||||
@@ -1476,9 +1491,15 @@ final class MobileWorkspace: Identifiable {
|
||||
else { return }
|
||||
let token = generation
|
||||
let data = Data((command + "\r").utf8)
|
||||
let startupTicket = initialCommandStartupTicket
|
||||
initialCommandStartupTicket = nil
|
||||
Task { [weak self] in
|
||||
guard let self, generation == token, connected, ssh === session else { return }
|
||||
await connectionServices.writeSSH(session, data)
|
||||
if let startupTicket {
|
||||
await connectionServices.submitInitialCommand(session, data, startupTicket)
|
||||
} else {
|
||||
await connectionServices.writeSSH(session, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1860,15 +1881,14 @@ final class MobileWorkspace: Identifiable {
|
||||
var connectionOrigin: RemoteConnectionOrigin = .library
|
||||
var externalSFTPResourcePath: String? = nil
|
||||
var externalSFTPMustBeDirectory = false
|
||||
var remoteSession: RemoteSessionReference? = nil
|
||||
}
|
||||
|
||||
var reopenState: ReopenState {
|
||||
ReopenState(
|
||||
descriptor: descriptor, transientHost: transientHost, connectionRoute: connectionRoute,
|
||||
descriptor: restorationDescriptor, transientHost: transientHost, connectionRoute: connectionRoute,
|
||||
usernameHint: usernameHint, connectionOrigin: connectionOrigin,
|
||||
externalSFTPResourcePath: externalSFTPResourcePath,
|
||||
externalSFTPMustBeDirectory: externalSFTPMustBeDirectory, remoteSession: sessionPersistence.attachment)
|
||||
externalSFTPMustBeDirectory: externalSFTPMustBeDirectory)
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
|
||||
@@ -68,6 +68,9 @@ private final class MobileSSHCommandConnectionBox {
|
||||
if data == Data("\u{1B}[777;1~".utf8) {
|
||||
shellIntegrations.last?(.draft(draft))
|
||||
}
|
||||
},
|
||||
submitInitialCommand: { [weak self] _, data, _ in
|
||||
self?.writes.append(data)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -94,6 +97,65 @@ private final class MobileSSHCommandConnectionBox {
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileCommandGeneratorTests {
|
||||
@Test func ordinaryStartupFallbackSubmitsOnceAndOldConnectionCannotReleaseANewSnippet() async throws {
|
||||
var (host, library) = try commandFixture()
|
||||
host.shellIntegrationMode = .enabled
|
||||
let snippet = Snippet(title: "Startup", body: "echo startup")
|
||||
host.startupSnippetID = snippet.id
|
||||
library.upsert(snippet)
|
||||
library.upsert(host)
|
||||
let connections = MobileSSHCommandConnectionBox()
|
||||
let workspace = MobileWorkspace(
|
||||
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library,
|
||||
connectionServices: connections.services, initialCommand: "echo explicit")
|
||||
defer { workspace.disconnect() }
|
||||
workspace.connect()
|
||||
#expect(await eventually { workspace.connected })
|
||||
await connections.sendShellEvents([.runtime(.init(status: .unavailable))])
|
||||
#expect(await eventually { workspace.terminalContext.status == .unavailable })
|
||||
#expect(connections.writes.isEmpty)
|
||||
let fallback = SSHShellIntegrationEvent.ordinaryStartupUnavailable(
|
||||
ticket: .init(generation: UUID(), inputRevision: 0))
|
||||
await connections.sendShellEvents([fallback, fallback])
|
||||
#expect(await eventually { connections.writes == [Data("echo explicit\r".utf8)] })
|
||||
#expect(workspace.terminalContext.active == nil)
|
||||
#expect(!workspace.canGenerateFromCurrentDraft)
|
||||
#expect(workspace.workingDirectory == nil)
|
||||
|
||||
await connections.finishSession(.failure(.socket("test disconnect")), session: 0)
|
||||
#expect(await eventually(attempts: 500) { connections.sessionCount == 2 && workspace.connected })
|
||||
await connections.sendShellEvents([fallback], session: 0)
|
||||
await connections.sendShellEvents([.runtime(.init(status: .unavailable))], session: 1)
|
||||
#expect(await eventually { workspace.terminalContext.status == .unavailable })
|
||||
#expect(connections.writes == [Data("echo explicit\r".utf8)])
|
||||
await connections.sendShellEvents([fallback, fallback], session: 1)
|
||||
#expect(
|
||||
await eventually {
|
||||
connections.writes == [Data("echo explicit\r".utf8), Data("echo startup\r".utf8)]
|
||||
})
|
||||
}
|
||||
|
||||
@Test func rejectedOrdinaryStartupDoesNotReplayAtALaterPrompt() async throws {
|
||||
var (host, library) = try commandFixture()
|
||||
host.shellIntegrationMode = .enabled
|
||||
library.upsert(host)
|
||||
let connections = MobileSSHCommandConnectionBox()
|
||||
let workspace = MobileWorkspace(
|
||||
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library,
|
||||
connectionServices: connections.services, initialCommand: "echo initial")
|
||||
defer { workspace.disconnect() }
|
||||
workspace.connect()
|
||||
#expect(await eventually { workspace.connected })
|
||||
await connections.sendShellEvents([
|
||||
.ordinaryStartupUnavailable(ticket: nil),
|
||||
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home"),
|
||||
.promptReady,
|
||||
])
|
||||
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
|
||||
#expect(workspace.pendingInitialCommand == nil)
|
||||
#expect(connections.writes.isEmpty)
|
||||
}
|
||||
|
||||
@Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"])
|
||||
func runningCommandsKeepTheDisplayedDirectoryUntilTheShellReportsAnother(backend: String) async throws {
|
||||
var (host, library) = try commandFixture()
|
||||
|
||||
@@ -8,6 +8,57 @@ import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileTerminalRestorationTests {
|
||||
@Test(arguments: [false, true])
|
||||
func unopenedManagedReferenceSurvivesTabAndWindowReopen(window: Bool) throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
defer { for workspace in app.workspaces { workspace.disconnect() } }
|
||||
let host = Host(name: "Server", host: "example.invalid")
|
||||
app.library.upsert(host)
|
||||
let reference = RemoteSessionReference(
|
||||
backend: .tmux, session: .init(id: "$4", name: "build", identity: Data("identity".utf8)),
|
||||
hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: UUID())
|
||||
let descriptor = MobileWorkspaceDescriptor(
|
||||
hostID: host.id, title: "Saved", kind: .ssh, remoteSession: reference)
|
||||
let source = MobileWorkspaceSessionModel(id: "source", app: app, descriptors: [descriptor])
|
||||
#expect(source.workspaces.isEmpty)
|
||||
if window { source.discard() } else { source.close(descriptor.id) }
|
||||
let record = try #require(app.closedWorkspaceRecords.first)
|
||||
#expect(record.isWindow == window)
|
||||
#expect(record.items.first?.remoteSession == reference)
|
||||
// Exercise the record-only reconstruction, not just retained in-process state.
|
||||
app.closedWorkspaceStates[record.id] = nil
|
||||
let destination = app.workspaceSession(for: "destination")
|
||||
#expect(app.restoreClosedRecord(record.id, into: destination) == descriptor.id)
|
||||
let reopened = try #require(destination.workspaces.first)
|
||||
#expect(reopened.sessionPersistence.attachment == reference)
|
||||
#expect(reopened.sessionPersistence.requiresSelection)
|
||||
#expect(reopened.pendingInitialCommand == nil)
|
||||
}
|
||||
|
||||
@Test func endedLiveSessionDoesNotResurrectItsDescriptorReference() throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
defer { for workspace in app.workspaces { workspace.disconnect() } }
|
||||
let host = Host(name: "Server", host: "example.invalid")
|
||||
app.library.upsert(host)
|
||||
let reference = RemoteSessionReference(
|
||||
backend: .tmux, session: .init(id: "$4", name: "build", identity: Data("identity".utf8)),
|
||||
hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: UUID())
|
||||
let workspace = MobileWorkspace(
|
||||
descriptor: .init(hostID: host.id, title: "Ended", kind: .ssh, remoteSession: reference),
|
||||
hosts: app.library)
|
||||
defer { workspace.disconnect() }
|
||||
workspace.restoreRemoteSession(reference)
|
||||
workspace.sessionPersistence.markEnded()
|
||||
app.rememberClosedWorkspaces([workspace.reopenState])
|
||||
let record = try #require(app.closedWorkspaceRecords.first)
|
||||
#expect(record.items.first?.remoteSession == nil)
|
||||
let destination = app.workspaceSession(for: "destination")
|
||||
#expect(app.restoreClosedRecord(record.id, into: destination) == workspace.id)
|
||||
let reopened = try #require(destination.workspaces.first)
|
||||
#expect(reopened.sessionPersistence.attachment == nil)
|
||||
#expect(reopened.sessionPersistence.requiresSelection)
|
||||
}
|
||||
|
||||
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
|
||||
func initialFailureRemainsRetryableAfterTransportCleanup(_ transport: HostTerminalProtocol) {
|
||||
let host = Host(name: "Server", host: "example.invalid")
|
||||
|
||||
@@ -790,8 +790,10 @@ struct MobileWorkspaceSessionTests {
|
||||
let first = MobileSceneModel(app: app, sessionID: "a")
|
||||
let second = MobileSceneModel(app: app, sessionID: "b")
|
||||
first.open(hostID: fixture.host.id, kind: .ssh)
|
||||
second.open(hostID: fixture.host.id, kind: .sftp)
|
||||
second.open(hostID: fixture.host.id, kind: .ssh)
|
||||
let original = try #require(second.workspaceSession.workspaces.first)
|
||||
let reference = managedReference(hostID: fixture.host.id)
|
||||
original.restoreRemoteSession(reference)
|
||||
let history = app.connectionHistory.entries()
|
||||
app.sceneChanged(first.id, phase: .active)
|
||||
app.sceneChanged(second.id, phase: .background)
|
||||
@@ -810,6 +812,7 @@ struct MobileWorkspaceSessionTests {
|
||||
#expect(app.workspaceOwner(original.id) == nil)
|
||||
#expect(app.closedWorkspaceRecords.first?.isWindow == true)
|
||||
#expect(app.closedWorkspaceRecords.first?.items.map(\.id) == [original.id])
|
||||
#expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == reference)
|
||||
#expect(second.workspaceSession.open(hostID: fixture.host.id, kind: .ssh) == nil)
|
||||
}
|
||||
|
||||
@@ -922,6 +925,8 @@ struct MobileWorkspaceSessionTests {
|
||||
scene.open(hostID: fixture.host.id, kind: .ssh)
|
||||
scene.activatePendingWorkspace()
|
||||
let workspace = try #require(scene.activeWorkspace)
|
||||
let reference = managedReference(hostID: fixture.host.id)
|
||||
workspace.restoreRemoteSession(reference)
|
||||
var requestedSessions: [String] = []
|
||||
app.closeWorkspaceSession = { id, _ in requestedSessions.append(id) }
|
||||
|
||||
@@ -929,10 +934,12 @@ struct MobileWorkspaceSessionTests {
|
||||
|
||||
#expect(requestedSessions == ["owner"])
|
||||
#expect(scene.activeWorkspace === workspace)
|
||||
#expect(workspace.sessionPersistence.attachment == reference)
|
||||
#expect(!scene.workspaceSession.isDiscarded)
|
||||
app.discardWorkspaceSessions(["owner"])
|
||||
#expect(scene.workspaceSession.workspaces.isEmpty)
|
||||
#expect(scene.workspaceSession.isDiscarded)
|
||||
#expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == reference)
|
||||
}
|
||||
|
||||
@Test func closeAllWindowsSnapshotsNativeSessionsAndRetainsRejectedWindows() throws {
|
||||
@@ -942,8 +949,12 @@ struct MobileWorkspaceSessionTests {
|
||||
let first = MobileSceneModel(app: app, sessionID: "first")
|
||||
let second = MobileSceneModel(app: app, sessionID: "second")
|
||||
first.open(hostID: fixture.host.id, kind: .ssh)
|
||||
second.open(hostID: fixture.host.id, kind: .sftp)
|
||||
second.open(hostID: fixture.host.id, kind: .ssh)
|
||||
let firstReference = managedReference(hostID: fixture.host.id)
|
||||
first.workspaceSession.workspaces.first?.restoreRemoteSession(firstReference)
|
||||
let retainedWorkspace = try #require(second.workspaceSession.workspaces.first)
|
||||
let retainedReference = managedReference(hostID: fixture.host.id)
|
||||
retainedWorkspace.restoreRemoteSession(retainedReference)
|
||||
var nativeSessions: Set<String> = ["first", "second", "background"]
|
||||
var requestedSessions: [String] = []
|
||||
app.openWorkspaceSessionIDs = { nativeSessions }
|
||||
@@ -962,6 +973,8 @@ struct MobileWorkspaceSessionTests {
|
||||
#expect(requestedSessions == ["background", "second", "first"])
|
||||
#expect(first.workspaceSession.isDiscarded)
|
||||
#expect(second.workspaceSession.workspaces.first === retainedWorkspace)
|
||||
#expect(retainedWorkspace.sessionPersistence.attachment == retainedReference)
|
||||
#expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == firstReference)
|
||||
#expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil)
|
||||
#expect(!second.workspaceSession.isDiscarded)
|
||||
#expect(app.error == nil)
|
||||
@@ -974,6 +987,8 @@ struct MobileWorkspaceSessionTests {
|
||||
let scene = MobileSceneModel(app: app, sessionID: "a")
|
||||
scene.open(hostID: fixture.host.id, kind: .ssh)
|
||||
let workspace = try #require(scene.workspaceSession.workspaces.first)
|
||||
let reference = managedReference(hostID: fixture.host.id)
|
||||
workspace.restoreRemoteSession(reference)
|
||||
var reportFailure: (@MainActor (any Error) -> Void)?
|
||||
app.closeWorkspaceSession = { _, failed in reportFailure = failed }
|
||||
scene.openSettings()
|
||||
@@ -983,6 +998,7 @@ struct MobileWorkspaceSessionTests {
|
||||
failed(NSError(domain: "WindowCloseTest", code: 1))
|
||||
|
||||
#expect(scene.workspaceSession.workspaces.first === workspace)
|
||||
#expect(workspace.sessionPersistence.attachment == reference)
|
||||
#expect(!scene.workspaceSession.isDiscarded)
|
||||
#expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil)
|
||||
#expect(scene.selectedDestination == .settings)
|
||||
@@ -1046,6 +1062,12 @@ struct MobileWorkspaceSessionTests {
|
||||
try #require(condition())
|
||||
}
|
||||
|
||||
private func managedReference(hostID: UUID) -> RemoteSessionReference {
|
||||
.init(
|
||||
backend: .tmux, session: .init(id: "$1", name: "build", identity: Data("identity".utf8)),
|
||||
hostID: hostID, endpoint: "test.invalid:22", username: "user", ownershipToken: UUID())
|
||||
}
|
||||
|
||||
private final class Fixture {
|
||||
let name = "WorkspaceSessions.\(UUID())"
|
||||
let defaults: UserDefaults
|
||||
|
||||
@@ -192,6 +192,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
private var pendingConnectionStartupCommand: PendingInitialCommand?
|
||||
private var commandExcludedFromHistory: String?
|
||||
private var isInitialCommandSubmissionReady = false
|
||||
private var initialCommandStartupTicket: SSHOrdinaryStartupTicket?
|
||||
|
||||
var reconnectState: ReconnectState? { connection.reconnectState }
|
||||
|
||||
@@ -462,6 +463,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
isShellPromptReady = false
|
||||
shellIntegrationAvailable = false
|
||||
isInitialCommandSubmissionReady = false
|
||||
initialCommandStartupTicket = nil
|
||||
pendingConnectionStartupCommand = nil
|
||||
activeShellCommand = nil
|
||||
commandDraft.cancel()
|
||||
@@ -1423,6 +1425,19 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
|
||||
func receiveShellIntegrationEvent(_ event: SSHShellIntegrationEvent) {
|
||||
guard isShellIntegrationEnabled else { return }
|
||||
if case .ordinaryStartupUnavailable(let ticket) = event {
|
||||
guard sessionPersistence.attachment == nil else { return }
|
||||
guard let ticket else {
|
||||
pendingInitialCommand = nil
|
||||
pendingConnectionStartupCommand = nil
|
||||
initialCommandStartupTicket = nil
|
||||
return
|
||||
}
|
||||
initialCommandStartupTicket = ticket
|
||||
isInitialCommandSubmissionReady = true
|
||||
submitPendingInitialCommandIfReady()
|
||||
return
|
||||
}
|
||||
if case .runtime(let update) = event {
|
||||
receiveRuntimeContext(update)
|
||||
return
|
||||
@@ -1430,7 +1445,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
guard !hasRuntimeContext else { return }
|
||||
commandMonitor.receiveShellEvent(event)
|
||||
switch event {
|
||||
case .runtime: break
|
||||
case .runtime, .ordinaryStartupUnavailable: break
|
||||
case .available(let shell, let systemInfo, let homeDirectory):
|
||||
if let homeDirectory, homeDirectory.hasPrefix("/") {
|
||||
homeResolution = .resolved(RemotePath.normalized(homeDirectory))
|
||||
@@ -1538,7 +1553,22 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
in: initialCommand.command
|
||||
)
|
||||
}
|
||||
insert(initialCommand.command, submit: true)
|
||||
if let ticket = initialCommandStartupTicket {
|
||||
initialCommandStartupTicket = nil
|
||||
if let statusStore, let host = statusStore.host(hostID),
|
||||
(try? statusStore.requireHostAccess(host)) == nil
|
||||
{
|
||||
return
|
||||
}
|
||||
let data = Data((initialCommand.command + "\r").utf8)
|
||||
if let inputSink {
|
||||
inputSink(data)
|
||||
} else if let ssh {
|
||||
Task { await ssh.submitInitialCommand(data, ticket: ticket) }
|
||||
}
|
||||
} else {
|
||||
insert(initialCommand.command, submit: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func submitPendingInitialCommandIfReady() {
|
||||
|
||||
@@ -9,6 +9,48 @@ import struct TermindDomain.Host
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct HostShellIntegrationSessionTests {
|
||||
@Test(arguments: [false, true])
|
||||
func ordinaryStartupFallbackSubmitsOnceButNeverIntoManagedAttachments(managed: Bool) {
|
||||
let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled)
|
||||
let session = SSHSessionModel(hostID: host.id, title: host.name, initialCommand: "echo initial")
|
||||
var submitted = Data()
|
||||
session.inputSink = { submitted.append($0) }
|
||||
defer { session.disconnect() }
|
||||
session.connect(using: HostLibraryModel(hosts: [host]))
|
||||
session.status = .connected
|
||||
if managed {
|
||||
session.sessionPersistence.attachment = .init(
|
||||
backend: .tmux, session: .init(id: "$1", name: "build", identity: Data()),
|
||||
hostID: host.id, endpoint: "server.invalid:22", username: "user", ownershipToken: UUID())
|
||||
}
|
||||
session.receiveShellIntegrationEvent(.runtime(.init(status: .unknown)))
|
||||
session.receiveShellIntegrationEvent(.runtime(.init(status: .unavailable)))
|
||||
#expect(submitted.isEmpty)
|
||||
let fallback = SSHShellIntegrationEvent.ordinaryStartupUnavailable(
|
||||
ticket: .init(generation: UUID(), inputRevision: 0))
|
||||
session.receiveShellIntegrationEvent(fallback)
|
||||
session.receiveShellIntegrationEvent(fallback)
|
||||
session.receiveShellIntegrationEvent(.promptReady)
|
||||
#expect(String(decoding: submitted, as: UTF8.self) == (managed ? "" : "echo initial\r"))
|
||||
#expect(!session.shellIntegrationAvailable)
|
||||
#expect(!session.canGenerateFromCurrentDraft)
|
||||
#expect(session.workingDirectory == nil)
|
||||
}
|
||||
|
||||
@Test func rejectedOrdinaryStartupDoesNotReplayAtALaterPrompt() {
|
||||
let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled)
|
||||
let session = SSHSessionModel(hostID: host.id, title: host.name, initialCommand: "echo initial")
|
||||
var submitted = Data()
|
||||
session.inputSink = { submitted.append($0) }
|
||||
defer { session.disconnect() }
|
||||
session.connect(using: HostLibraryModel(hosts: [host]))
|
||||
session.status = .connected
|
||||
session.receiveShellIntegrationEvent(.ordinaryStartupUnavailable(ticket: nil))
|
||||
session.receiveShellIntegrationEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: "/home"))
|
||||
session.receiveShellIntegrationEvent(.promptReady)
|
||||
#expect(submitted.isEmpty)
|
||||
}
|
||||
|
||||
@Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"])
|
||||
func runningCommandsKeepTheDisplayedDirectoryUntilTheShellReportsAnother(backend: String) throws {
|
||||
let session = SSHSessionModel(hostID: UUID(), title: "Cwd")
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@testable import TermindRemoteInfrastructure
|
||||
|
||||
@Suite
|
||||
nonisolated struct RemoteMultiplexerShellTests {
|
||||
private static let shells = ["/bin/bash", "/bin/zsh", "/opt/homebrew/bin/fish"]
|
||||
.filter { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
|
||||
@Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"], shells)
|
||||
func shellHooksKeepCwdInTheJournalDuringCommands(backend: String, shell: String) throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cwd-test-\(UUID())")
|
||||
try FileManager.default.createDirectory(
|
||||
at: root.appendingPathComponent("next"), withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let install = try RemoteShellStartup.install(
|
||||
token: UUID(), startupCommand: "cd \"$HOME\"", shellIntegration: true,
|
||||
backend: ["shell", "tmux"].contains(backend) ? nil : backend
|
||||
).replacingOccurrences(
|
||||
of: "/tmp/termind-shell.XXXXXXXX",
|
||||
with: root.appendingPathComponent("termind-shell.XXXXXXXX").path
|
||||
).replacingOccurrences(
|
||||
of: "exec \"$TERMIND_TMUX_HELPERS/shell\"",
|
||||
with: "exec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\"")
|
||||
let input = """
|
||||
cat "$__termind_context_dir/journal" > "$HOME/during"
|
||||
cd "$HOME/next"
|
||||
cat "$__termind_context_dir/journal" > "$HOME/after"
|
||||
exit
|
||||
"""
|
||||
let result = try Self.run(
|
||||
"""
|
||||
\(install)
|
||||
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
|
||||
unset TMUX_PANE ZELLIJ_PANE_ID HERDR_PANE_ID
|
||||
\(backend == "tmux" ? "export TMUX_PANE=%1" : "")
|
||||
export ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1
|
||||
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(input)) | /bin/sh "$helper/first"
|
||||
""", home: root.path)
|
||||
#expect(result.exitCode == 0, "\(result.stdout)")
|
||||
let during = try RemoteShellContextRecord(
|
||||
String(contentsOf: root.appendingPathComponent("during"), encoding: .utf8))
|
||||
let after = try RemoteShellContextRecord(
|
||||
String(contentsOf: root.appendingPathComponent("after"), encoding: .utf8))
|
||||
#expect(during.backend == backend)
|
||||
#expect(!during.ready)
|
||||
#expect(!after.ready)
|
||||
#expect(during.directory == root.resolvingSymlinksInPath().path)
|
||||
#expect(after.directory == root.appendingPathComponent("next").resolvingSymlinksInPath().path)
|
||||
#expect(after.sequence > during.sequence)
|
||||
}
|
||||
|
||||
@Test(arguments: SessionPersistenceBackendID.supported, shells)
|
||||
func startupSnippetRunsOnlyInFirstShellAndPreservesUserConfiguration(
|
||||
backend: SessionPersistenceBackendID, shell: String
|
||||
) throws {
|
||||
for integration in [false, true] {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-shell-test-\(UUID())")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let name = URL(fileURLWithPath: shell).lastPathComponent
|
||||
let userConfig = name == "zsh" ? ".zshrc" : ".bash_profile"
|
||||
try "export USER_CONFIGURATION=preserved\n".write(
|
||||
to: root.appendingPathComponent(userConfig), atomically: true, encoding: .utf8)
|
||||
if name == "fish" {
|
||||
let config = root.appendingPathComponent(".config/fish")
|
||||
try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true)
|
||||
try "set -gx USER_CONFIGURATION preserved\n".write(
|
||||
to: config.appendingPathComponent("config.fish"), atomically: true, encoding: .utf8)
|
||||
}
|
||||
let startup =
|
||||
name == "fish"
|
||||
? "cd \"$HOME\"; set -gx FROM_SNIPPET yes; printf once >> \"$HOME/once\""
|
||||
: "cd \"$HOME\"; export FROM_SNIPPET=yes; printf once >> \"$HOME/once\""
|
||||
let install = try RemoteShellStartup.install(
|
||||
token: UUID(), startupCommand: startup, shellIntegration: integration,
|
||||
backend: backend == .tmux ? nil : backend.rawValue,
|
||||
environment: [.init(variable: "SESSION_ONLY", value: "host-value")]
|
||||
)
|
||||
.replacingOccurrences(
|
||||
of: "/tmp/termind-shell.XXXXXXXX",
|
||||
with: root.appendingPathComponent("termind-shell.XXXXXXXX").path
|
||||
)
|
||||
.replacingOccurrences(
|
||||
of: "exec \"$TERMIND_TMUX_HELPERS/shell\"",
|
||||
with: "exec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\"")
|
||||
let firstInput = """
|
||||
printf '%s|%s|%s|%s\\n' "$PWD" "$FROM_SNIPPET" "$USER_CONFIGURATION" "$SESSION_ONLY" > "$HOME/first-result"
|
||||
exit
|
||||
"""
|
||||
let secondInput = """
|
||||
printf '%s\\n' "$USER_CONFIGURATION" > "$HOME/second-result"
|
||||
exit
|
||||
"""
|
||||
let script = """
|
||||
\(install)
|
||||
trap 'rm -rf "$helper"' EXIT
|
||||
[ -z "${SESSION_ONLY:-}" ] || exit 83
|
||||
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
|
||||
export TMUX_PANE=%1 ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1
|
||||
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(firstInput)) | /bin/sh "$helper/first"
|
||||
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(secondInput)) | /bin/sh "$helper/shell"
|
||||
"""
|
||||
let result = try Self.run(script, home: root.path)
|
||||
#expect(result.exitCode == 0, "\(result.stderr)\n\(result.stdout)")
|
||||
#expect(try String(contentsOf: root.appendingPathComponent("once"), encoding: .utf8) == "once")
|
||||
let first = try String(contentsOf: root.appendingPathComponent("first-result"), encoding: .utf8)
|
||||
#expect(first.hasSuffix("|yes|preserved|host-value\n"), "\(first)")
|
||||
#expect(
|
||||
try String(contentsOf: root.appendingPathComponent("second-result"), encoding: .utf8) == "preserved\n")
|
||||
#expect(
|
||||
try String(contentsOf: root.appendingPathComponent(userConfig), encoding: .utf8)
|
||||
== "export USER_CONFIGURATION=preserved\n")
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: shells)
|
||||
func cancelledStartupCannotRunLaterWhenUserConfigurationFinishes(shell: String) throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cancel-test-\(UUID())")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let install = try RemoteShellStartup.install(
|
||||
token: UUID(), startupCommand: "printf late > \"$HOME/late\"", shellIntegration: true, backend: "zmx"
|
||||
).replacingOccurrences(
|
||||
of: "/tmp/termind-shell.XXXXXXXX", with: root.appendingPathComponent("termind-shell.XXXXXXXX").path)
|
||||
let result = try Self.run(
|
||||
"""
|
||||
\(install)
|
||||
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
|
||||
mkdir "$helper/startup-decision"
|
||||
printf 'printf ready > "$HOME/ready"\\nexit\\n' | /bin/sh "$helper/shell" || exit $?
|
||||
[ ! -e "$helper/started" ]
|
||||
""", home: root.path)
|
||||
#expect(result.exitCode == 0, "\(result.stdout)")
|
||||
#expect(try String(contentsOf: root.appendingPathComponent("ready"), encoding: .utf8) == "ready")
|
||||
#expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("late").path))
|
||||
}
|
||||
|
||||
@Test func expiredAndMismatchedDraftRequestsAreConsumedWithoutReturningPayload() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-draft-test-\(UUID())")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let state = root.appendingPathComponent("state")
|
||||
let request = root.appendingPathComponent("request")
|
||||
let reader = root.appendingPathComponent("reader")
|
||||
try "R1|10|1|%2Ftmp\n".write(to: state, atomically: true, encoding: .utf8)
|
||||
try RemoteShellContextJournal.requestReader.write(to: reader, atomically: true, encoding: .utf8)
|
||||
for (sequence, deadline) in [("10", "1"), ("9", String(Int(Date().timeIntervalSince1970) + 60))] {
|
||||
try "\(UUID())|\(sequence)|replace|%65%63%68%6F|\(deadline)|-\n".write(
|
||||
to: request, atomically: true, encoding: .utf8)
|
||||
let result = try Self.run(
|
||||
"sh \(RemoteMultiplexerCommands.quote(reader.path)) \(RemoteMultiplexerCommands.quote(root.path)) replace",
|
||||
home: root.path)
|
||||
#expect(result.exitCode != 0)
|
||||
#expect(result.stdout.isEmpty)
|
||||
#expect(!FileManager.default.fileExists(atPath: request.path))
|
||||
}
|
||||
}
|
||||
|
||||
static func run(_ command: String, home: String) throws -> RemoteSessionCommandResult {
|
||||
let process = Process()
|
||||
let output = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
process.arguments = ["-c", command]
|
||||
process.environment = ProcessInfo.processInfo.environment.merging([
|
||||
"HOME": home, "XDG_CONFIG_HOME": home + "/.config", "XDG_DATA_HOME": home + "/.local/share",
|
||||
"ZDOTDIR": home, "LC_ALL": "C",
|
||||
]) { _, new in new }
|
||||
process.standardOutput = output
|
||||
process.standardError = output
|
||||
try process.run()
|
||||
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return .init(stdout: String(decoding: data, as: UTF8.self), stderr: "", exitCode: process.terminationStatus)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user