expect-pty 0.5.3 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/README.md +122 -130
  4. data/docs/API.md +113 -0
  5. data/docs/MIGRATION.md +68 -0
  6. data/lib/expect/cleanup.rb +34 -0
  7. data/lib/expect/interaction.rb +228 -210
  8. data/lib/expect/logging.rb +117 -162
  9. data/lib/expect/matcher.rb +42 -38
  10. data/lib/expect/pattern.rb +10 -3
  11. data/lib/expect/pattern_list.rb +39 -9
  12. data/lib/expect/redactor.rb +12 -5
  13. data/lib/expect/relay.rb +106 -81
  14. data/lib/expect/relay_writer.rb +7 -5
  15. data/lib/expect/result.rb +33 -9
  16. data/lib/expect/session.rb +554 -0
  17. data/lib/expect/session_resources.rb +19 -18
  18. data/lib/expect/version.rb +2 -2
  19. data/lib/expect.rb +68 -578
  20. data/sig/expect.rbs +279 -0
  21. metadata +14 -123
  22. data/.rubocop.yml +0 -66
  23. data/CONTRIBUTING.md +0 -29
  24. data/Gemfile +0 -28
  25. data/Rakefile +0 -38
  26. data/benchmark/matching.rb +0 -98
  27. data/benchmark/redactor.rb +0 -64
  28. data/benchmark/relay.rb +0 -48
  29. data/benchmark/scaling.rb +0 -70
  30. data/benchmark/send_slow.rb +0 -41
  31. data/benchmark/support.rb +0 -114
  32. data/docs/COMPATIBILITY.md +0 -98
  33. data/docs/INTERNAL_CONTRACTS.md +0 -154
  34. data/docs/PERFORMANCE.md +0 -165
  35. data/docs/RELEASING.md +0 -84
  36. data/docs/VERIFICATION.md +0 -456
  37. data/examples/dialogue.rb +0 -30
  38. data/examples/kibitz/README.md +0 -81
  39. data/examples/kibitz/kibitz.rb +0 -142
  40. data/examples/kibitz/test_kibitz.rb +0 -37
  41. data/examples/ssh_auto.rb +0 -94
  42. data/examples/ssh_interact.rb +0 -159
  43. data/examples/ssh_login.rb +0 -64
  44. data/expect-pty.gemspec +0 -33
  45. data/lib/expect/configuration.rb +0 -122
  46. data/lib/expect/terminal.rb +0 -102
  47. data/script/ci +0 -121
  48. data/script/release.rb +0 -319
  49. data/test/buffer_accounting_test.rb +0 -83
  50. data/test/cleanup_test.rb +0 -251
  51. data/test/compare_upstream.rb +0 -157
  52. data/test/configuration_test.rb +0 -133
  53. data/test/deadline_test.rb +0 -237
  54. data/test/diagnostics_test.rb +0 -353
  55. data/test/edge_case_test.rb +0 -262
  56. data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
  57. data/test/fixtures/ssh_scripts/02_output.sh +0 -5
  58. data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
  59. data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
  60. data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
  61. data/test/initialization_failure_test.rb +0 -92
  62. data/test/integration/README.md +0 -109
  63. data/test/integration/ssh_scripts.rb +0 -94
  64. data/test/interact_test.rb +0 -253
  65. data/test/interconnect_test.rb +0 -425
  66. data/test/io_test.rb +0 -321
  67. data/test/kibitz_test.rb +0 -45
  68. data/test/lifecycle_contract_test.rb +0 -71
  69. data/test/literal_scan_test.rb +0 -97
  70. data/test/matching_test.rb +0 -211
  71. data/test/multi_session_test.rb +0 -66
  72. data/test/ownership_sequence_test.rb +0 -208
  73. data/test/pattern_offset_test.rb +0 -43
  74. data/test/process_interruption_test.rb +0 -296
  75. data/test/process_test.rb +0 -291
  76. data/test/redactor_test.rb +0 -183
  77. data/test/relay_recovery_test.rb +0 -451
  78. data/test/relay_reentrancy_test.rb +0 -159
  79. data/test/release_test.rb +0 -375
  80. data/test/ruby_api_test.rb +0 -515
  81. data/test/scan_reuse_test.rb +0 -109
  82. data/test/script_logging_test.rb +0 -124
  83. data/test/support/interact_probe.rb +0 -125
  84. data/test/support/kibitz_probe.rb +0 -177
  85. data/test/support/script_probe.rb +0 -158
  86. data/test/terminal_cleanup_test.rb +0 -349
  87. data/test/test_helper.rb +0 -58
  88. data/test/timeout_test.rb +0 -289
  89. data/test/write_contract_test.rb +0 -105
@@ -1,142 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "socket"
4
- require "tmpdir"
5
- require "optparse"
6
- require "shellwords"
7
- require_relative "../../lib/expect/pty"
8
-
9
- # Local counterpart of jacoby/expect.pm examples/kibitz: both keyboards feed
10
- # one process, whose output is broadcast to both terminals using listeners.
11
- module Kibitz
12
- ESCAPE = "\x1d".b.freeze
13
-
14
- def self.relay(input:, output:, peer:, shell: nil, escape: ESCAPE, timeout: nil, log: nil)
15
- Expect.open(input, log_stdout: false, write_timeout: 5) do |keyboard|
16
- Expect.open(peer, log_stdout: false, write_timeout: 5) do |partner|
17
- keyboard.on_sequence(escape) if escape
18
- if shell
19
- keyboard.listeners = [shell]
20
- partner.listeners = [shell]
21
- shell.listeners = [output, partner]
22
- shell.log_stdout = false
23
- shell.log_output = log if log
24
- # Keep canonical input, echo and ISIG on the process's own terminal.
25
- shell.raw_terminal = false
26
- else
27
- keyboard.listeners = [partner]
28
- partner.listeners = [output]
29
- partner.log_output = log if log
30
- end
31
- stopped = Expect.interconnect(*[keyboard, partner, shell].compact, timeout: timeout)
32
- reason = if stopped.nil?
33
- :timeout
34
- elsif stopped.equal?(keyboard)
35
- :local
36
- elsif stopped.equal?(partner)
37
- :peer
38
- else
39
- :process
40
- end
41
- { reason: reason, tail: keyboard.buffer }
42
- end
43
- end
44
- end
45
-
46
- def self.escape(value)
47
- return (value.getbyte(1) & 31).chr if value.match?(/\A\^[@A-Z\[\\\]\^_]\z/)
48
-
49
- raise OptionParser::InvalidArgument, "escape must not be empty" if value.empty?
50
-
51
- value
52
- end
53
-
54
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- CLI 建连、终端接管和 ensure 清理共享一次生命周期。
55
- def self.run(argv, input: $stdin, output: $stdout)
56
- options = { escape: ESCAPE }
57
- parser = OptionParser.new do |opts|
58
- opts.banner = "Usage: ruby examples/kibitz/kibitz.rb [options] [-- program args...]"
59
- opts.on("--join SOCKET", "Join the local session printed by its host") { |value| options[:join] = value }
60
- opts.on("--noproc", "Exchange input directly, with no shared process or local echo") { options[:noproc] = true }
61
- opts.on("--escape TEXT", "Local exit sequence (default ^]; caret notation accepted)") do |value|
62
- options[:escape] = escape(value)
63
- end
64
- opts.on("--noescape", "Forward all keys; end by exiting the shared process") { options[:escape] = nil }
65
- opts.on("--timeout SECONDS", Float, "Limit connection wait and then relay duration") do |value|
66
- options[:timeout] = value
67
- end
68
- opts.on("--log FILE", "Create a new received-output log (0600)") { |value| options[:log] = value }
69
- opts.on("-h", "--help") do
70
- output.puts(opts)
71
- return 0
72
- end
73
- end
74
- parser.order!(argv)
75
- raise OptionParser::InvalidArgument, "a terminal is required" unless input.tty?
76
- if options[:timeout] && (!options[:timeout].finite? || options[:timeout] <= 0)
77
- raise OptionParser::InvalidArgument,
78
- "timeout must be positive and finite"
79
- end
80
- if options[:join] && (options[:noproc] || !argv.empty?)
81
- raise OptionParser::InvalidArgument,
82
- "--join cannot start a program or use --noproc"
83
- end
84
- raise OptionParser::InvalidArgument, "--noproc cannot start a program" if options[:noproc] && !argv.empty?
85
-
86
- output.sync = true
87
- # The ensure below closes the log after both terminal connections end.
88
- log = File.open(options[:log], File::WRONLY | File::CREAT | File::EXCL, 0o600) if options[:log] # rubocop:disable Style/FileOpen
89
-
90
- if options[:join]
91
- peer = UNIXSocket.new(options[:join])
92
- else
93
- # A short, private socket path works with macOS's sockaddr_un limit.
94
- directory = Dir.mktmpdir("expect-kibitz-", "/tmp")
95
- socket_path = File.join(directory, "peer.sock")
96
- server = UNIXServer.new(socket_path)
97
- File.chmod(0o600, socket_path)
98
- output.puts("Join from another terminal on this machine:")
99
- output.puts(Shellwords.join([RbConfig.ruby, File.expand_path(__FILE__), "--join", socket_path]))
100
- raise IOError, "timed out waiting for partner" unless server.wait_readable(options[:timeout])
101
-
102
- peer = server.accept
103
- server.close
104
- File.unlink(socket_path)
105
- unless options[:noproc]
106
- command = argv.empty? ? [ENV.fetch("SHELL", "/bin/sh")] : argv
107
- shell = Expect.spawn(*command, log_stdout: false, raw_terminal: false, write_timeout: 5)
108
- shell.winsize = input.winsize
109
- end
110
- end
111
- # Announce readiness only after local echo/canonical input are disabled.
112
- # A fast typist (or PTY driver) may send bytes immediately after the banner.
113
- result = input.raw do
114
- hint = options[:escape] ? "Escape #{options[:escape].inspect} ends this session." : "Escape disabled."
115
- output.write("Kibitz connected. #{hint}\r\n")
116
- relay(input: input, output: output, peer: peer, shell: shell,
117
- escape: options[:escape], timeout: options[:timeout], log: log)
118
- end
119
- if result[:reason] == :process
120
- shell.soft_close(timeout: 2)
121
- status = shell.exit_code || 1
122
- else
123
- status = result[:reason] == :timeout ? 1 : 0
124
- end
125
- output.puts("\nKibitz ended (#{result[:reason]}).")
126
- status
127
- rescue OptionParser::ParseError, IOError, SystemCallError, Expect::SpawnError => error
128
- warn "kibitz: #{error.message}"
129
- 1
130
- ensure
131
- shell&.close
132
- peer&.close
133
- server&.close unless server&.closed?
134
- log&.close
135
- File.unlink(socket_path) if socket_path && File.socket?(socket_path)
136
- Dir.rmdir(directory) if directory && Dir.exist?(directory)
137
- end
138
-
139
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
140
- end
141
-
142
- exit Kibitz.run(ARGV) if $PROGRAM_NAME == __FILE__
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "fileutils"
5
- require "tmpdir"
6
- require "time"
7
- require_relative "../../test/support/kibitz_probe"
8
-
9
- base = File.expand_path(ENV.fetch("EXPECT_LOG_DIR", "../../tmp/kibitz"), __dir__)
10
- FileUtils.mkdir_p(base)
11
- directory = Dir.mktmpdir("#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-", base)
12
- report = { passed: false, cases: [], started_at: Time.now.utc.iso8601 }
13
- begin
14
- {
15
- shared_shell: ->(dir) { KibitzProbe.shared_shell(dir) },
16
- host_escape: ->(dir) { KibitzProbe.direct(dir, ending: :host) },
17
- guest_escape: ->(dir) { KibitzProbe.direct(dir, ending: :guest) },
18
- noescape: ->(dir) { KibitzProbe.noescape(dir) },
19
- process_failure: ->(dir) { KibitzProbe.process_failure(dir) },
20
- relay_timeout: ->(dir) { KibitzProbe.relay_timeout(dir) }
21
- }.each do |name, probe|
22
- path = File.join(directory, name.to_s)
23
- Dir.mkdir(path, 0o700)
24
- result = probe.call(path)
25
- report[:cases] << result.merge(log_path: File.join(path, "session.log"))
26
- puts "PASS #{name}: #{result[:checks].join(", ")}"
27
- end
28
- report[:passed] = true
29
- rescue StandardError => error
30
- report[:error] = "#{error.class}: #{error.message}"
31
- ensure
32
- report[:finished_at] = Time.now.utc.iso8601
33
- path = File.join(directory, "report.json")
34
- File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(JSON.pretty_generate(report)) }
35
- puts "Report: #{path}"
36
- end
37
- abort(report[:error]) unless report[:passed]
data/examples/ssh_auto.rb DELETED
@@ -1,94 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "securerandom"
5
- require "tempfile"
6
- require "tmpdir"
7
- require_relative "../lib/expect/pty"
8
-
9
- # 在这里按顺序填写要执行的 macOS / POSIX shell 命令。
10
- COMMANDS = ["id", "uname -srm", "sw_vers", "df -h /", "uptime"].freeze
11
- PROMPT = "MACOS> "
12
-
13
- if ARGV.delete("--help")
14
- puts "用法:ruby examples/ssh_auto.rb [--no-interact]"
15
- puts "自动 SSH 登录,执行 COMMANDS 后进入 interact;--no-interact 执行后退出。"
16
- puts "环境变量:SSH_HOST、SSH_USER、SSH_PORT、SSH_KNOWN_HOSTS、EXPECT_PASSWORD、EXPECT_LOG_DIR。"
17
- exit
18
- end
19
- interactive = !ARGV.delete("--no-interact")
20
- abort "未知参数:#{ARGV.join(" ")}" unless ARGV.empty?
21
- abort "interact 需要终端;批量执行请加 --no-interact" if interactive && !$stdin.tty?
22
-
23
- host = ENV.fetch("SSH_HOST", "127.0.0.1")
24
- user = ENV.fetch("SSH_USER", ENV.fetch("USER", "crate"))
25
- port = Integer(ENV.fetch("SSH_PORT", "22"), 10)
26
- abort "SSH_PORT 应为 1..65535" unless (1..65_535).cover?(port)
27
- abort "SSH_HOST / SSH_USER 格式无效" if [host, user].any? do |value|
28
- value.empty? || value.start_with?("-") || value.match?(/[\s\x00]/)
29
- end
30
- known_hosts = ENV.fetch("SSH_KNOWN_HOSTS", nil)
31
- abort "连接其他主机时请设置 SSH_KNOWN_HOSTS" unless known_hosts || %w[127.0.0.1 ::1 localhost].include?(host)
32
-
33
- password = ENV.delete("EXPECT_PASSWORD")&.dup
34
- log = nil
35
- begin
36
- unless password
37
- $stderr.print("SSH password: ")
38
- password = ($stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets)&.chomp
39
- $stderr.puts
40
- end
41
- abort "请输入单行密码" if password.nil? || password.empty? || password.match?(/[\r\n\x00]/)
42
-
43
- Dir.mktmpdir("expect-ssh-") do |dir|
44
- ssh_args = ["ssh", "-F", "/dev/null", "-tt", "-p", port.to_s,
45
- "-o", "ConnectTimeout=5", "-o", "NumberOfPasswordPrompts=1",
46
- "-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
47
- "-o", "StrictHostKeyChecking=#{known_hosts ? "yes" : "accept-new"}",
48
- "-o", "UserKnownHostsFile=#{known_hosts || File.join(dir, "known_hosts")}",
49
- "-l", user, host, "env ENV= PS1=#{Shellwords.escape(PROMPT)} /bin/sh -i"]
50
-
51
- Expect.spawn(*ssh_args, raw_pty: true, log_stdout: false, write_timeout: 5) do |ssh|
52
- # 登录成功后才开启日志,密码不写入文件。
53
- ssh.expect(/password:\s*\z/i, timeout: 10) or abort "未收到密码提示:#{ssh.error}"
54
- ssh.write(password, "\n")
55
- ssh.expect(PROMPT, timeout: 10) or abort "SSH 登录失败:#{ssh.error}"
56
- password.replace("\0" * password.bytesize)
57
- ssh.write("stty sane -echo; set +o emacs; set +o vi\n")
58
- ssh.expect(PROMPT, timeout: 5) or abort "终端初始化失败:#{ssh.error}"
59
-
60
- log_dir = ENV.fetch("EXPECT_LOG_DIR", File.expand_path("../tmp/ssh-auto", __dir__))
61
- FileUtils.mkdir_p(log_dir)
62
- log = Tempfile.create(["session-", ".log"], log_dir) # 0600,退出后保留
63
- ssh.log_to(log)
64
- puts "已登录 #{user}@#{host};日志:#{log.path}"
65
-
66
- COMMANDS.each do |command|
67
- puts "\n$ #{command}"
68
- ssh.write_log("\n$ #{command}\n")
69
- token = SecureRandom.hex(8)
70
- # 远端拼接结束标记并带回退出码,避免把命令回显当成执行结果。
71
- ssh.write("#{command}; printf '\\n__DONE_%s:%s\\n' '#{token}' \"$?\"\n")
72
- ssh.expect(/\r?\n__DONE_#{token}:(\d+)\r?\n#{Regexp.escape(PROMPT)}/, timeout: 30) or abort "命令未完成:#{ssh.error}"
73
- print ssh.before.gsub("\r\n", "\n")
74
- abort "命令失败(退出码 #{ssh.captures.first}):#{command}" unless ssh.captures.first == "0"
75
- end
76
-
77
- if interactive
78
- ssh.write("stty echo\n")
79
- ssh.expect(PROMPT, timeout: 5) or abort "无法进入交互:#{ssh.error}"
80
- puts "\n检查完成,进入 interact。输入 exit 或按 Ctrl-] 结束。"
81
- $stdout.print(PROMPT)
82
- $stdout.flush
83
- next unless ssh.interact(input: $stdin, escape: "\x1d", output: $stdout).equal?(ssh)
84
- else
85
- ssh.write("exit\n")
86
- end
87
- ssh.soft_close(timeout: 3)
88
- abort "SSH 退出异常:#{ssh.exit_code.inspect}" unless ssh.exit_code&.zero?
89
- end
90
- end
91
- ensure
92
- log&.close
93
- password&.replace("\0" * password.bytesize)
94
- end
@@ -1,159 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Run from the checkout or the unpacked gem. Test helpers use only standard
4
- # libraries; Minitest is not needed for this live SSH example.
5
- require "io/console"
6
- require "json"
7
- require "fileutils"
8
- require "tmpdir"
9
- require "time"
10
- require_relative "../test/support/interact_probe"
11
-
12
- if ARGV.delete("--help")
13
- puts <<~HELP
14
- Usage: ruby examples/ssh_interact.rb [--auto]
15
-
16
- Default: SSH login with visible typing; Ctrl-] returns to expect, exit ends SSH.
17
- --auto: Drive a real local PTY to verify commands, Ctrl-C, Ctrl-] and reentry.
18
-
19
- Environment: SSH_HOST (127.0.0.1), SSH_USER (current user), SSH_PORT (22),
20
- SSH_KNOWN_HOSTS, EXPECT_PASSWORD, EXPECT_LOG_DIR.
21
- Password input is hidden. Non-loopback hosts require SSH_KNOWN_HOSTS.
22
- Logs and JSON reports default to tmp/ssh-interact/ in this project.
23
- HELP
24
- exit
25
- end
26
- automatic = !ARGV.delete("--auto").nil?
27
- abort "unknown arguments: #{ARGV.join(" ")} (use --help)" unless ARGV.empty?
28
- abort "manual interact requires a terminal; use --auto for unattended testing" unless automatic || $stdin.tty?
29
-
30
- host = ENV.fetch("SSH_HOST", "127.0.0.1")
31
- user = ENV.fetch("SSH_USER", ENV.fetch("USER", "crate"))
32
- port = Integer(ENV.fetch("SSH_PORT", "22"), 10)
33
- ScriptProbe.check((1..65_535).cover?(port), "SSH_PORT must be between 1 and 65535")
34
- ScriptProbe.check([host, user].none? do |value|
35
- value.empty? || value.start_with?("-") || value.match?(/[\s\x00]/)
36
- end, "invalid SSH host or user")
37
- known_hosts = ENV.fetch("SSH_KNOWN_HOSTS", nil)
38
- ScriptProbe.check(known_hosts || %w[127.0.0.1 ::1 localhost].include?(host),
39
- "SSH_KNOWN_HOSTS is required for remote hosts")
40
-
41
- password = ENV.delete("EXPECT_PASSWORD")&.dup
42
- unless password
43
- $stderr.print("SSH password: ")
44
- password = ($stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets)&.chomp
45
- $stderr.puts
46
- end
47
- ScriptProbe.check(password && !password.empty? && !password.match?(/[\r\n\x00]/), "a single-line password is required")
48
- base = File.expand_path(ENV.fetch("EXPECT_LOG_DIR", File.expand_path("../tmp/ssh-interact", __dir__)))
49
- FileUtils.mkdir_p(base)
50
- directory = Dir.mktmpdir("#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-", base)
51
- log_path = File.join(directory, "session.log")
52
- report_path = File.join(directory, "report.json")
53
- report = { mode: automatic ? "automatic" : "manual", host: host, port: port, user: user,
54
- started_at: Time.now.utc.iso8601, passed: false, cases: [], checks: [] }
55
- session = nil
56
- local_terminal = nil
57
-
58
- begin
59
- Dir.mktmpdir("expect-known-hosts-") do |temporary|
60
- policy = known_hosts ? "yes" : "accept-new"
61
- hosts_path = known_hosts || File.join(temporary, "known_hosts")
62
- args = ["ssh", "-F", "/dev/null", "-tt", "-p", port.to_s,
63
- "-o", "ConnectTimeout=5", "-o", "NumberOfPasswordPrompts=1",
64
- "-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
65
- "-o", "StrictHostKeyChecking=#{policy}", "-o", "UserKnownHostsFile=#{hosts_path}",
66
- "-l", user, host, "env ENV= PS1=#{Shellwords.escape(ScriptProbe::PROMPT)} /bin/sh -i"]
67
- session = Expect.spawn(*args, raw_pty: true, log_stdout: false, log_listeners: false,
68
- debug_level: 0, write_timeout: 5)
69
- login = session.expect(/password:\s*\z/i, /Permission denied/i, timeout: 10)
70
- ScriptProbe.check(login == 1, "SSH password prompt missing (#{session.error || "authentication rejected"})")
71
- session.write(password, "\n")
72
- runner = ScriptProbe::Runner.new(session, timeout: 10).ready!
73
- InteractProbe.prepare(session, echo: !automatic)
74
- File.open(log_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.truncate(0) }
75
- session.log_to(log_path)
76
-
77
- remote_exit = false
78
- if automatic
79
- report.merge!(InteractProbe.verify(session, user: user))
80
- else
81
- local_terminal = Expect.open($stdin)
82
- initial_mode = InteractProbe.configuration(local_terminal)
83
- puts "Logged in as #{user}@#{host}. Enter commands; press Ctrl-] to return to expect."
84
- puts "Log: #{log_path}"
85
- # prepare consumed the initial prompt while configuring remote echo.
86
- $stdout.print(ScriptProbe::PROMPT)
87
- $stdout.flush
88
- # This is the actual public API handoff: stdin -> SSH, SSH -> stdout.
89
- returned = session.interact(input: $stdin, escape: InteractProbe::ESCAPE, output: $stdout)
90
- ScriptProbe.check(InteractProbe.configuration(local_terminal) == initial_mode,
91
- "stdin terminal mode was not restored")
92
- report[:local_terminal_restored] = true
93
- report[:checks] += %w[real_stdin_stdout terminal_restored]
94
- if returned.equal?(session)
95
- remote_exit = true
96
- report[:completion] = "remote_exit"
97
- session.soft_close(timeout: 3)
98
- ScriptProbe.check(session.closed? && session.exit_code&.zero?,
99
- "SSH exited with status #{session.exit_code.inspect}")
100
- report[:checks] << "remote_eof"
101
- else
102
- ScriptProbe.check(returned.is_a?(Expect) && returned.to_io.equal?($stdin), "unexpected interact termination")
103
- ScriptProbe.check(session.alive?, "remote session ended during interaction")
104
- report[:completion] = "local_escape"
105
- report[:checks] += %w[ctrl_bracket_escape remote_alive]
106
- puts "\nReturned from interact; verifying automated expect resumes..."
107
- InteractProbe.prepare(session)
108
- runner.run(
109
- "expect_after_manual", "printf 'MANUAL_AUTOMATION_RESUMED\\n'",
110
- expected_status: 0, expected_output: "MANUAL_AUTOMATION_RESUMED\n"
111
- )
112
- report[:cases] = runner.results
113
- report[:checks] << "expect_resume"
114
- end
115
- end
116
-
117
- runner.finish! unless remote_exit
118
- report[:ssh_exit_code] = session.exit_code
119
- text = ScriptProbe.normalize(File.binread(log_path))
120
- report[:cases].each do |result|
121
- output = result.fetch(:output).b
122
- ScriptProbe.check(text.scan(Regexp.new(Regexp.escape(output), Regexp::NOENCODING)).length == 1,
123
- "#{result[:name]}: log output missing or duplicated")
124
- end
125
- unless remote_exit
126
- ScriptProbe.check(text.scan("SESSION_FINAL_TAIL\n").length == 1,
127
- "shutdown tail missing or duplicated")
128
- end
129
- ScriptProbe.check(!text.include?(InteractProbe::TAIL), "local escape tail leaked to SSH") if automatic
130
- ScriptProbe.check(!text.include?(password.b), "password detected in log")
131
- report[:checks] += %w[unique_log_output eof_drain password_absent clean_ssh_exit]
132
- report[:passed] = true
133
- end
134
- rescue StandardError => error
135
- report[:error] = "#{error.class}: #{error.message}".gsub(password, "[REDACTED]")
136
- ensure
137
- session&.close
138
- local_terminal&.close
139
- if File.file?(log_path)
140
- bytes = File.binread(log_path)
141
- if bytes.include?(password.b)
142
- File.binwrite(log_path, bytes.gsub(password.b, "[REDACTED]"))
143
- report[:passed] = false
144
- report[:error] = "password detected and removed from log"
145
- end
146
- report[:log_bytes] = File.size(log_path)
147
- report[:log_sha256] = Digest::SHA256.file(log_path).hexdigest
148
- end
149
- password.replace("\0" * password.bytesize)
150
- report[:finished_at] = Time.now.utc.iso8601
151
- report[:log_path] = log_path
152
- File.open(report_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(JSON.pretty_generate(report)) }
153
- end
154
-
155
- puts "Log: #{log_path}"
156
- puts "Report: #{report_path}"
157
- abort(report[:error]) unless report[:passed]
158
- puts "PASS #{report[:mode]} interact: #{report[:cases].length} command checks, " \
159
- "#{report[:checks].length} interaction/log checks; SSH exit=0"
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Optional real SSH integration check. Password input is hidden and never logged.
4
- require "io/console"
5
- require "tmpdir"
6
- require "securerandom"
7
- require_relative "../lib/expect/pty"
8
-
9
- host = ENV.fetch("SSH_HOST", "127.0.0.1")
10
- user = ENV.fetch("SSH_USER", ENV.fetch("USER", "crate"))
11
- abort "invalid SSH host or user" if [host, user].any? do |value|
12
- value.empty? || value.start_with?("-") || value.match?(/[\s\x00]/)
13
- end
14
- password = ENV.delete("EXPECT_PASSWORD")&.dup
15
- unless password
16
- $stderr.print("SSH password: ")
17
- password = $stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets
18
- $stderr.puts
19
- password = password&.chomp
20
- end
21
- abort "password is required" if password.nil? || password.empty?
22
-
23
- begin
24
- Dir.mktmpdir("expect-ssh-") do |dir|
25
- # Isolated known_hosts keeps this example independent of user SSH settings.
26
- # For non-loopback hosts, use an existing trusted known_hosts file.
27
- known_hosts = ENV.fetch("SSH_KNOWN_HOSTS", nil)
28
- abort "SSH_KNOWN_HOSTS is required for remote hosts" unless known_hosts || %w[127.0.0.1 ::1
29
- localhost].include?(host)
30
- policy = known_hosts ? "yes" : "accept-new"
31
- known_hosts ||= File.join(dir, "known_hosts")
32
- arguments = ["ssh", "-F", "/dev/null", "-tt", "-o", "ConnectTimeout=5",
33
- "-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
34
- "-o", "NumberOfPasswordPrompts=1", "-o", "StrictHostKeyChecking=#{policy}",
35
- "-o", "UserKnownHostsFile=#{known_hosts}", "-l", user, host,
36
- "env PS1='EXPECT_SHELL> ' /bin/sh -i"]
37
- Expect.spawn(*arguments, log_stdout: false, raw_pty: true) do |session|
38
- prompt = session.expect(/password:\s*\z/i, /Permission denied/i, timeout: 10)
39
- abort "SSH password prompt not received (#{session.error || "authentication rejected"})" unless prompt == 1
40
- session.write(password, "\n")
41
- password.replace("\0" * password.bytesize)
42
-
43
- # Start a command after authentication. The marker is assembled remotely
44
- # from two quoted arguments, so terminal command echo cannot satisfy it.
45
- login = session.expect("EXPECT_SHELL> ", /Permission denied/i, timeout: 10)
46
- abort "SSH login failed (#{session.error || "authentication rejected"})" unless login == 1
47
- token = SecureRandom.hex(12)
48
- session.write("printf '\\n%s%s\\n' 'EXPECT_OK_' '#{token}'; id -un; tty\n")
49
- marker = session.expect(/EXPECT_OK_#{Regexp.escape(token)}\r?\n/, timeout: 10)
50
- abort "remote command did not run (#{session.error})" unless marker
51
- identity = session.expect(%r{([^\r\n]+)\r?\n(/dev/[^\r\n]+)\r?\n}, timeout: 5)
52
- abort "remote identity/TTY response missing" unless identity
53
- abort "unexpected SSH user" unless session.captures.first == user.b
54
- puts "SSH password login verified: #{user}@#{host}"
55
- puts "Remote identity: #{session.captures.first}; TTY: #{session.captures.last}"
56
- session.write("exit\n")
57
- session.soft_close(timeout: 5)
58
- abort "SSH exit status #{session.exit_code.inspect}" unless session.exit_code&.zero?
59
- puts "SSH exited cleanly (0)"
60
- end
61
- end
62
- ensure
63
- password&.replace("\0" * password.bytesize)
64
- end
data/expect-pty.gemspec DELETED
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/expect/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "expect-pty"
7
- spec.version = Expect::VERSION
8
- spec.authors = ["expect-pty contributors"]
9
- spec.homepage = "https://github.com/gatework/expect-ruby"
10
- spec.summary = "Ruby PTY automation with the Expect.pm interaction model"
11
- spec.description = "Automate interactive programs with exact and regexp matching, " \
12
- "Ruby blocks, multi-session waits, logging and terminal interaction."
13
- spec.license = "MIT"
14
- spec.required_ruby_version = ">= 3.2"
15
- spec.files = Dir[
16
- "lib/**/*.rb", "examples/**/*.rb", "examples/**/*.md", "docs/**/*.md", "benchmark/**/*.rb",
17
- "test/**/*.rb", "test/**/*.sh", "test/**/*.md", "Gemfile", "Rakefile",
18
- ".rubocop.yml", "expect-pty.gemspec", "README.md", "CONTRIBUTING.md", "LICENSE", "CHANGELOG.md",
19
- "script/ci", "script/release.rb"
20
- ]
21
- spec.require_paths = ["lib"]
22
- # 显式声明可独立升级的标准库 gem,供应用的 Bundler 解析完整运行时依赖。
23
- # pty 是 Ruby 自带的 POSIX 扩展,不是独立 gem。
24
- spec.add_dependency "forwardable", "~> 1.3"
25
- spec.add_dependency "io-console", ">= 0.6", "< 1.0"
26
- spec.add_dependency "io-wait", ">= 0.3", "< 1.0"
27
- spec.add_dependency "shellwords", ">= 0.1", "< 1.0"
28
- spec.add_dependency "stringio", "~> 3.0"
29
- spec.metadata["rubygems_mfa_required"] = "true"
30
- spec.metadata["source_code_uri"] = spec.homepage
31
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
32
- spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
33
- end
@@ -1,122 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Expect
4
- # 集中校验会话配置。类级默认值以冻结快照发布,每个会话再构造独立副本。
5
- # 这里只保存策略值,不持有 IO、缓冲或日志对象;修改默认配置不会追溯影响已创建的会话。
6
- class Configuration
7
- # 会话通过 Forwardable 委托这些属性;to_h 使用同一清单生成配置副本。
8
- ATTRIBUTES = %i[
9
- timeout write_timeout buffer_limit debug_level raw_pty preserve_buffer
10
- log_stdout log_listeners raw_terminal reset_timeout_on_read graceful_close
11
- ].freeze
12
- # 布尔属性同时提供普通读方法和问号查询,写入统一采用 Ruby 真值规则。
13
- PREDICATES = %i[
14
- raw_pty? preserve_buffer? log_stdout? log_listeners? raw_terminal?
15
- reset_timeout_on_read? graceful_close?
16
- ].freeze
17
-
18
- attr_reader :timeout, :write_timeout, :buffer_limit, :debug_level, :raw_pty, :preserve_buffer, :log_stdout,
19
- :log_listeners, :raw_terminal, :reset_timeout_on_read, :graceful_close
20
-
21
- # 通过 setter 校验构造参数,确保默认值、构造覆盖和后续赋值遵守同一规则。
22
- def initialize(timeout: nil, write_timeout: nil, buffer_limit: nil, debug_level: 0,
23
- raw_pty: false, preserve_buffer: false, log_stdout: false,
24
- log_listeners: true, raw_terminal: true, reset_timeout_on_read: false,
25
- graceful_close: false)
26
- self.timeout = timeout
27
- self.write_timeout = write_timeout
28
- self.buffer_limit = buffer_limit
29
- self.debug_level = debug_level
30
- self.raw_pty = raw_pty
31
- self.preserve_buffer = preserve_buffer
32
- self.log_stdout = log_stdout
33
- self.log_listeners = log_listeners
34
- self.raw_terminal = raw_terminal
35
- self.reset_timeout_on_read = reset_timeout_on_read
36
- self.graceful_close = graceful_close
37
- end
38
-
39
- # 设置匹配等待的默认秒数;nil 表示无限等待,0 表示只轮询现有数据。
40
- def timeout=(value)
41
- @timeout = Expect.duration(value)
42
- end
43
-
44
- # 设置写入背压的等待期限;先转换和校验,失败时保留原配置。
45
- def write_timeout=(value)
46
- @write_timeout = Expect.duration(value)
47
- end
48
-
49
- # 限制接收缓冲保留的尾部字节数;正整数为上限,nil 为无限。
50
- def buffer_limit=(value)
51
- unless value.nil? || (value.is_a?(Integer) && value.positive?)
52
- raise ArgumentError, "buffer_limit must be a positive Integer or nil"
53
- end
54
-
55
- @buffer_limit = value
56
- end
57
-
58
- # 设置诊断详细程度:0 关闭,1 生命周期与匹配,2 收发内容,3 缓冲内容。
59
- def debug_level=(value)
60
- unless value.is_a?(Integer) && (0..3).cover?(value)
61
- raise ArgumentError, "debug_level must be an Integer between 0 and 3"
62
- end
63
-
64
- @debug_level = value
65
- end
66
-
67
- # 控制 spawn 前是否将子进程终端设为 raw,关闭回显和换行转换。
68
- def raw_pty=(value)
69
- @raw_pty = !!value
70
- end
71
-
72
- alias raw_pty? raw_pty
73
-
74
- # 控制匹配成功后是否保留完整缓冲;启用时由继续回调自行消费匹配内容。
75
- def preserve_buffer=(value)
76
- @preserve_buffer = !!value
77
- end
78
-
79
- alias preserve_buffer? preserve_buffer
80
-
81
- # 控制接收字节是否同步输出到当前 $stdout;默认关闭。
82
- def log_stdout=(value)
83
- @log_stdout = !!value
84
- end
85
-
86
- alias log_stdout? log_stdout
87
-
88
- # 控制接收字节是否转发给监听器,与 stdout 和日志目标分别管理。
89
- def log_listeners=(value)
90
- @log_listeners = !!value
91
- end
92
-
93
- alias log_listeners? log_listeners
94
-
95
- # 控制 interact 是否临时设置并恢复本地输入终端;通用 interconnect 不修改终端模式。
96
- def raw_terminal=(value)
97
- @raw_terminal = !!value
98
- end
99
-
100
- alias raw_terminal? raw_terminal
101
-
102
- # 控制收到任何新数据时是否刷新匹配期限,适用于按静默时长判断超时。
103
- # 只刷新相对 timeout;单次等待显式指定的绝对 deadline 仍是不可延长的上限。
104
- def reset_timeout_on_read=(value)
105
- @reset_timeout_on_read = !!value
106
- end
107
-
108
- alias reset_timeout_on_read? reset_timeout_on_read
109
-
110
- # 控制通用 close 是否先软关闭;最终资源清理仍由硬关闭兜底。
111
- def graceful_close=(value)
112
- @graceful_close = !!value
113
- end
114
-
115
- alias graceful_close? graceful_close
116
-
117
- # 导出新的属性 Hash,用于构造会话副本或发布下一份默认配置。
118
- def to_h
119
- ATTRIBUTES.to_h { |name| [name, public_send(name)] }
120
- end
121
- end
122
- end