ot-cli 0.2.1__py3-none-any.whl

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 (91) hide show
  1. openterminal/__init__.py +3 -0
  2. openterminal/agent/__init__.py +39 -0
  3. openterminal/agent/backend.py +180 -0
  4. openterminal/agent/build.py +115 -0
  5. openterminal/agent/middleware.py +111 -0
  6. openterminal/agent/model.py +41 -0
  7. openterminal/agent/prompt.py +63 -0
  8. openterminal/agent/runner.py +340 -0
  9. openterminal/agent/sysprobe.py +164 -0
  10. openterminal/agent/token_est.py +49 -0
  11. openterminal/app.py +52 -0
  12. openterminal/approval.py +7 -0
  13. openterminal/backend.py +3 -0
  14. openterminal/banner.py +7 -0
  15. openterminal/cli.py +426 -0
  16. openterminal/cmdset.py +7 -0
  17. openterminal/config.py +322 -0
  18. openterminal/connections/__init__.py +20 -0
  19. openterminal/connections/db.py +240 -0
  20. openterminal/connections/models.py +289 -0
  21. openterminal/connections/secrets.py +68 -0
  22. openterminal/connections_db.py +6 -0
  23. openterminal/core/__init__.py +41 -0
  24. openterminal/core/ai_flow.py +781 -0
  25. openterminal/core/auth.py +499 -0
  26. openterminal/core/cmdset_runner.py +352 -0
  27. openterminal/core/exec_events.py +650 -0
  28. openterminal/core/history.py +79 -0
  29. openterminal/core/hook_gate.py +415 -0
  30. openterminal/core/messages.py +193 -0
  31. openterminal/core/nested_ssh.py +178 -0
  32. openterminal/core/pipeline.py +1187 -0
  33. openterminal/core/pump.py +190 -0
  34. openterminal/core/transcript.py +26 -0
  35. openterminal/frontend/__init__.py +21 -0
  36. openterminal/frontend/approval.py +40 -0
  37. openterminal/frontend/banner.py +40 -0
  38. openterminal/frontend/headless.py +290 -0
  39. openterminal/frontend/render.py +41 -0
  40. openterminal/frontend/taskview.py +184 -0
  41. openterminal/frontend/term.py +1417 -0
  42. openterminal/headless.py +7 -0
  43. openterminal/history_db.py +7 -0
  44. openterminal/local_pty.py +3 -0
  45. openterminal/local_win.py +3 -0
  46. openterminal/policy.py +362 -0
  47. openterminal/render.py +7 -0
  48. openterminal/secrets_store.py +5 -0
  49. openterminal/session/__init__.py +17 -0
  50. openterminal/session/base.py +328 -0
  51. openterminal/session/local_posix.py +184 -0
  52. openterminal/session/local_win.py +173 -0
  53. openterminal/session/ssh.py +321 -0
  54. openterminal/shell/__init__.py +17 -0
  55. openterminal/shell/cmdset.py +49 -0
  56. openterminal/shell/integration.py +1298 -0
  57. openterminal/shell_integration.py +7 -0
  58. openterminal/shell_session.py +8 -0
  59. openterminal/skills/__init__.py +45 -0
  60. openterminal/skills/markdown-tables/SKILL.md +247 -0
  61. openterminal/ssh_pty.py +6 -0
  62. openterminal/sysprobe.py +7 -0
  63. openterminal/taskview.py +7 -0
  64. openterminal/term_frontend.py +7 -0
  65. openterminal/token_est.py +3 -0
  66. openterminal/transcript.py +7 -0
  67. openterminal/web/__init__.py +1 -0
  68. openterminal/web/files_api.py +789 -0
  69. openterminal/web/frontend/index.html +37 -0
  70. openterminal/web/frontend/static/app.js +3632 -0
  71. openterminal/web/frontend/static/block_stream.js +274 -0
  72. openterminal/web/frontend/static/block_view.js +383 -0
  73. openterminal/web/frontend/static/files_panel.js +782 -0
  74. openterminal/web/frontend/static/md_lite.js +127 -0
  75. openterminal/web/frontend/static/modal.js +122 -0
  76. openterminal/web/frontend/static/pad_ledger.js +46 -0
  77. openterminal/web/frontend/static/style.css +915 -0
  78. openterminal/web/frontend/static/ui/ui.css +1 -0
  79. openterminal/web/frontend/static/ui/ui.js +6292 -0
  80. openterminal/web/frontend/vendor/addon-fit.js +2 -0
  81. openterminal/web/frontend/vendor/xterm.css +8 -0
  82. openterminal/web/frontend/vendor/xterm.js +8 -0
  83. openterminal/web/protocol.py +41 -0
  84. openterminal/web/server.py +322 -0
  85. openterminal/web/worker.py +62 -0
  86. ot_cli-0.2.1.dist-info/METADATA +439 -0
  87. ot_cli-0.2.1.dist-info/RECORD +91 -0
  88. ot_cli-0.2.1.dist-info/WHEEL +5 -0
  89. ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
  90. ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
  91. ot_cli-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,193 @@
1
+ """编排层协议消息与交互式 AI 工具通道(Task 1 自 core.py 原样搬入)。
2
+
3
+ 搬移规则:函数体逐字节不变。_ANSI_RE 原模块有两处定义、后处覆盖前处——
4
+ 运行时生效的是连接后命令集段的富版正则,这里只保留富版(行为等价)。
5
+ InteractiveRunner 经 worker 回指 PipelineCore(鸭子类型,无 import 环)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import re
13
+ import time
14
+ from dataclasses import dataclass
15
+
16
+ from ..shell.integration import agent_exec_line
17
+ from ..session.base import CommandResult
18
+
19
+ _OT_DBG = bool(os.environ.get("OT_WEB_DEBUG"))
20
+ _OT_T0 = time.monotonic() # _dbg 相对时间戳基准(轨迹排障用时差分析)
21
+
22
+
23
+ def _dbg(*a) -> None:
24
+ """OT_WEB_DEBUG=1 时打印交互式状态机轨迹(排障用,默认静默)。"""
25
+ if _OT_DBG:
26
+ print(f"[otdbg +{time.monotonic() - _OT_T0:7.3f}s]", *a, flush=True)
27
+
28
+
29
+ # CSI / OSC / 字符集切换等转义:提示符重绘、PSReadLine 上色都会带上,剥掉才
30
+ # 判得出「提示符在行尾」(见 PipelineCore._cs_text)
31
+ _ANSI_RE = re.compile(
32
+ r"\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|"
33
+ r"\x1b[()#][0-9A-Za-z]|\x1b[=>78M]")
34
+
35
+
36
+
37
+ def _ansi_free(s: str) -> str:
38
+ return _ANSI_RE.sub("", s)
39
+
40
+
41
+ class PasswordCancelled(ConnectionError):
42
+ """终端内联密码输入的 Ctrl+C:用户主动中止连接尝试(区别于连接意外
43
+ 关闭——_run 捕获后向前端发「连接已取消」而非「连接失败:…」)。"""
44
+
45
+
46
+ _PROBE_TIMEOUT = 1.5
47
+ #: 工具执行通道不可用的退出码(hook 不在位 / 远端 shell 已退出)
48
+ CHANNEL_DEAD_EC = 126
49
+ #: 通道不可用时随 CommandResult 回给工具卡的可读文案
50
+ CHANNEL_DEAD_MSG = ("远端 shell 已退出或无 AI 集成通道,命令未执行;"
51
+ "请重新登录(或回到可集成的 shell)后重试")
52
+
53
+
54
+
55
+ @dataclass
56
+ class ServerMsg:
57
+ type: str # ready | event | approval | ask_password | ask_password_dismiss | ask_host_key | status | closed | usage | cmdset | stage
58
+ tab_id: str = ""
59
+ host: str = ""
60
+ user: str = ""
61
+ distro: str = ""
62
+ event: dict | None = None # 事件:task_start/ai_token/ai_think/ai_collapse/ai_tool/ai_card/final/denied/limit/error;
63
+ # term_open/term_close = 命令执行边界括号(输出归属,见 core/exec_events.py)
64
+ command: str = "" # approval:待审批命令
65
+ reasons: str = ""
66
+ label: str = ""
67
+ message: str = ""
68
+ text: str = ""
69
+ model: str = "" # 当前模型 id(ready)
70
+ models: list[str] | None = None # 可切换模型列表(ready)
71
+ prompt: str = "" # ready:真实提示符纯文本(OSC 133 B 捕获)
72
+ risk: str = "" # 审批风险级(approval):high 红框 | normal 蓝框
73
+ id: int = 0 # approval:关联命令卡 id(ai_tool start 同 id,无卡=0)
74
+ tokens_in: int = 0 # 会话累计输入 token(usage)
75
+ tokens_out: int = 0 # 会话累计输出 token(usage)
76
+ estimated: bool = False # usage:当前累计为 tiktoken 估算口径(前端加 ≈)
77
+ interactive: int = 0 # ready:hook 集成就绪(1 = AI 可用)
78
+ state: str = "" # cmdset:running | done
79
+ cmd: str = "" # cmdset:running 当前命令原文(应答行为空,防泄密)
80
+ index: int = 0 # cmdset 当前行号(1 起;encode_server 省略 0)
81
+ total: int = 0 # cmdset 总行数
82
+ auth_kind: str = "" # ask_password:省略=主机认证;"cmdset"=命令集密码弹窗
83
+
84
+
85
+ @dataclass
86
+ class ClientMsg:
87
+ type: str # raw(键盘字节) | decision | auth | interrupt | mode | resize | change_model | close | submit | pad | boundary_settled | new_session
88
+ text: str = "" # mode: "ssh" | "agent";auth: 密码文本;submit: 前端拦截的自然语言整行
89
+ data: bytes = b"" # raw 按键字节(二进制帧整帧直发 PTY)
90
+ decision: dict | None = None
91
+ auth_kind: str = "" # password | password_remember | password_cancel
92
+ # | nested_password(_cancel) | remember_yes/no
93
+ # | host_key | cmdset
94
+ remember: bool = False # auth 密码勾选「记住」→ 写入 keyring
95
+ model: str = "" # change_model:目标模型 id
96
+ cols: int = 0
97
+ rows: int = 0
98
+ pad: int = 0 # pad:请求 shell 打印 N 个空行(卡片占位行)
99
+ mirror: str | None = None # pad:前端可信的半行镜像("" = 空半行不必收纳;
100
+ # None = 镜像不可信,worker 走 \x15 丢弃旧路径)
101
+ dirty: bool = False # submit:半行缓冲被转义/控制键清过(历史召回
102
+ # 后镜像不可信)→ worker 走 \x03 安全路径不补 \r
103
+ accept: bool = False # rescue:失败救援卡决策(true = 交给 AI)
104
+
105
+
106
+ class InteractiveRunner:
107
+ """交互式模式的 AI 工具通道:为 PtyShellBackend 鸭子类型 ShellSession。
108
+
109
+ 工具命令经 ``__ot_exec__ `` 通道注入同一个 PTY(与用户共享 shell 状态:
110
+ cd/环境变量/别名全部生效);Ctrl+U / Ctrl+Y 括号保护用户已输入的半行。
111
+ 输出由泵按 EXEC 相位字节捕获并直写主 xterm,OSC 133 C…D 标记界定结束
112
+ 与退出码。超时发 Ctrl+C 中断再宽限 3s,仍无 D 标记则按 130 返回。
113
+ """
114
+
115
+ def __init__(self, worker: "PipelineCore") -> None:
116
+ self.w = worker
117
+ self.cwd = ""
118
+
119
+ async def run(self, command: str, *, timeout: int | None = None,
120
+ on_output=None) -> CommandResult:
121
+ # on_output:backend.run 的兼容参数。单管线后命令输出由 exec 状态机
122
+ # 直写主 xterm,不再经回调旁路转发,这里仅接受不使用。
123
+ w = self.w
124
+ to = timeout or w.cfg.shell.timeout_default
125
+ async with w._runner_lock:
126
+ if not await w.ai._wait_exec_idle(to):
127
+ return CommandResult(
128
+ output="[ot] 目标 shell 正忙(命令或 AI 执行中),请稍后重试",
129
+ exit_code=124, truncated=False, cwd=self.cwd)
130
+ if not await self._hook_channel_ready():
131
+ # hook 不在位(TMOUT 登出 / 换壳 / 陈旧):盲注入会把
132
+ # __ot_exec__、\x03、__ot_pad 全落进裸壳——command not found
133
+ # 刷屏,命令本体还被裸壳拆段照常执行(真机截图)。重注入救不
134
+ # 回就快速失败,工具卡显示可读原因而不是注垃圾进终端后超时 130
135
+ return CommandResult(
136
+ output=CHANNEL_DEAD_MSG, exit_code=CHANNEL_DEAD_EC,
137
+ truncated=False, cwd=self.cwd)
138
+ w._exec_buf = bytearray()
139
+ w._exec_truncated = False
140
+ fut = asyncio.get_running_loop().create_future()
141
+ w._exec_future = fut
142
+ try:
143
+ # 不吞回显:注入行的 tty 回显必须原样进 xterm——Windows ConPTY
144
+ # 按自己的缓冲区模型发绝对光标定位重绘,吞字节会让 xterm 光标
145
+ # 与 ConPTY 失步(实测提示符叠印/内容覆盖的根因)
146
+ await w.session.send_raw(b"\x15") # 收纳用户半行输入
147
+ await w.session.send_raw(
148
+ agent_exec_line(w._shell_kind, command, w._b64flag))
149
+ try:
150
+ res = await asyncio.wait_for(asyncio.shield(fut), to)
151
+ except asyncio.TimeoutError:
152
+ if w.hook._hook_ok():
153
+ # hook 不在位时 \x03 也是落进裸壳的垃圾(P2-5)
154
+ await w.session.send_raw(b"\x03") # 中断工具命令
155
+ try:
156
+ res = await asyncio.wait_for(asyncio.shield(fut), 3.0)
157
+ except asyncio.TimeoutError:
158
+ res = CommandResult(
159
+ output=bytes(w._exec_buf).decode("utf-8", "replace"),
160
+ exit_code=130, truncated=False, cwd=self.cwd)
161
+ finally:
162
+ w._exec_future = None
163
+ # \x19 接回 \x15 收走的半行:hook 已把 ^Y 重绑为「恢复注入前
164
+ # 半行」(存档非空才接回,空行零副作用,不经 kill ring)——
165
+ # 任务期用户打字与注入交错时打字内容原样接回行首(真机「任务
166
+ # 跑着打字只剩尾巴」根因)。原生 ^Y 会复活 kill ring 旧内容,
167
+ # hook 不在位(su - 重置)时不发,保持旧的原生丢弃语义。
168
+ if w.hook._hook_ok():
169
+ await w.session.send_raw(b"\x19")
170
+ if res.cwd:
171
+ self.cwd = res.cwd
172
+ return res
173
+
174
+ async def _hook_channel_ready(self) -> bool:
175
+ """工具注入前的 hook 活性门禁:不在位先试一次重注入自愈。
176
+
177
+ 须在 _runner_lock 内调用(run 已持有)——_ensure_integrated_locked
178
+ 不取锁,避免与 run / _shell_pad 的注入序列重入死锁。
179
+ 结构性不可集成(_hook_gone=探测判定换了壳/旧版 shell)不空转注入,
180
+ 保证该路径下 PTY 输入流一个字节都不发。
181
+ """
182
+ w = self.w
183
+ if w.hook._hook_ok():
184
+ w._hook_seen = True
185
+ return True
186
+ if w._hook_gone:
187
+ return False
188
+ try:
189
+ await w.hook._ensure_integrated_locked()
190
+ except Exception: # noqa: BLE001 - 重注入失败即通道不可用
191
+ return False
192
+ return w.hook._hook_ok()
193
+
@@ -0,0 +1,178 @@
1
+ """嵌套 SSH 帧解析:跳板机/嵌套登录的帧记账与目标解析(Task 2 自
2
+ core.py 原样搬入)。
3
+
4
+ 纯分析、无 I/O;跨域状态经 core 读:_open_cmds/_open_cmd_at(用户命令
5
+ 记账,Task 4 归 ExecEvents)、_last_mark_at(hook 活性时间戳,Task 5 归
6
+ HookGate)、_target_host(PipelineCore 自持)。方法名去前导下划线做
7
+ 组件公开接口,语义与原 PipelineCore 私有方法一致。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+
14
+ from ..connections import parse_user_at_host
15
+ from ..shell.integration import is_internal_line
16
+
17
+
18
+ class NestedSshTracker:
19
+ def __init__(self, core) -> None:
20
+ self.core = core
21
+
22
+ # --- 单行解析(静态方法) ---
23
+
24
+ @staticmethod
25
+ def parse_ssh_cmd_target(line: str
26
+ ) -> tuple[str | None, str | None, int | None] | None:
27
+ """单条命令行解析 ssh 族命令的目标;非 ssh 族返回 None。
28
+
29
+ ssh 族但目标解析不出(空行、`ssh` 无目标、别名目标读不到等)
30
+ 返回 (None, None, None)。调用方按各自语义处置:_nested_ssh_target
31
+ 据此向外层帧继续找(既有行为),_ssh_frame_host 跳过本帧找最近的
32
+ ssh 帧。端口取 dest 的 :port 或 -p/-P/--port= 选项。
33
+ """
34
+ import shlex
35
+
36
+ try:
37
+ parts = shlex.split(line)
38
+ except ValueError:
39
+ parts = line.split()
40
+ if not parts:
41
+ return None, None, None
42
+ cmd = os.path.basename(parts[0])
43
+ if cmd not in ("ssh", "scp", "rsync", "sftp"):
44
+ return None # 非 ssh 类(sudo/su 等):不算 ssh 帧
45
+ val_opts = {"b", "c", "D", "e", "F", "i", "J", "l", "L", "m",
46
+ "o", "p", "P", "R", "S", "W"}
47
+ argv = parts[1:]
48
+ i = 0
49
+ dest = None
50
+ bare_args: list[str] = []
51
+ while i < len(argv):
52
+ a = argv[i]
53
+ if a == "--":
54
+ i += 1
55
+ if i < len(argv):
56
+ bare_args.append(argv[i])
57
+ break
58
+ if a.startswith("-") and len(a) > 1:
59
+ if a[1] in val_opts and len(a) == 2:
60
+ i += 2 # 带值短选项:连值一起跳过
61
+ else:
62
+ i += 1 # 无值开关(-t/-A/-4…)或合写带值(-oVal)
63
+ continue
64
+ bare_args.append(a)
65
+ i += 1
66
+ if cmd in ("scp", "rsync"):
67
+ # 源/目的都可能带远端路径:挑带 @ 的(user@host:path),
68
+ # 其次带 : 的(host:path);纯本地路径跳过
69
+ remote = [a for a in bare_args if "@" in a] or \
70
+ [a for a in bare_args if ":" in a]
71
+ dest = remote[0] if remote else None
72
+ else:
73
+ dest = bare_args[0] if bare_args else None
74
+ if dest is None:
75
+ return None, None, None
76
+ if cmd in ("scp", "rsync") and ":" in dest:
77
+ dest = dest.rsplit(":", 1)[0] # 去掉远端路径
78
+ host, user, port = parse_user_at_host(dest)
79
+ if port is None:
80
+ # ssh -p/--port 端口在选项里
81
+ for j, a in enumerate(argv):
82
+ if a == "-p" and j + 1 < len(argv) and argv[j + 1].isdigit():
83
+ port = int(argv[j + 1])
84
+ elif a.startswith("--port=") and a[7:].isdigit():
85
+ port = int(a[7:])
86
+ elif a == "-P" and j + 1 < len(argv) and argv[j + 1].isdigit():
87
+ port = int(argv[j + 1])
88
+ return host, user, port
89
+
90
+
91
+ def innermost_ssh_frame(self) -> int | None:
92
+ """最内层未闭合用户帧若是 ssh 类返回其 inst(嵌套会话存续判定)。"""
93
+ if not self.core._open_cmds:
94
+ return None
95
+ inst = next(reversed(self.core._open_cmds))
96
+ line = (self.core._open_cmds[inst] or "").strip()
97
+ if not line or is_internal_line(line):
98
+ return None
99
+ if os.path.basename(line.split()[0]) not in ("ssh", "scp", "rsync", "sftp"):
100
+ return None
101
+ return inst
102
+
103
+
104
+ def inner_ssh_prompt_live(self) -> bool:
105
+ """最内层未闭合用户帧是 ssh 类且开帧后出现过提示符标记(= 内层 hook
106
+ 活在 readline 提示符上):嵌套会话的自然语言放行健康路径。
107
+
108
+ _open_cmds 门闩本防 \\r 落进前台命令 stdin(su - 整段会话),但 ssh
109
+ 跳板机帧同样整段开着——一刀切会让嵌套会话里每次自然语言都走降级
110
+ (\\x03 + 0.4s + 探测 + 分片重注入,秒级,真机「等几秒才出分析卡」
111
+ 根因之一)。su - 帧非 ssh 族仍锁;内层前台命令(top/mysql)会开自己
112
+ 的帧,最内层非 ssh 族亦仍锁;外族 PS1 假标记只随 su - 出现,进不来。"""
113
+ inst = self.innermost_ssh_frame()
114
+ if inst is None:
115
+ return False
116
+ return self.core._last_mark_at > self.core._open_cmd_at.get(inst, 0.0) + 0.05
117
+
118
+
119
+
120
+ def nested_ssh_target(self) -> tuple[str | None, str | None, int | None]:
121
+ """从**最内层**未闭合用户命令行解析 ssh 类命令的目标 (host, user, port)。
122
+
123
+ 只认最内层帧:嵌套会话里外层 `ssh 跳板机` 帧始终开着,若它兜底
124
+ 吸收了内层 sudo 的密码提示,就会把跳板机的凭据错填给 sudo。
125
+ 解析不出(sudo、ssh 目标是别名走 ~/.ssh/config 等)返回 (None,...)
126
+ ——密码仍可弹窗代答,只是「记住」没有可靠的键。
127
+ (单行解析抽在 _parse_ssh_cmd_target;本方法的最内层帧防线不得
128
+ 放宽——外层 ssh 凭据绝不与内层 sudo/嵌套混用。)"""
129
+ for line in reversed(list(self.core._open_cmds.values())):
130
+ parsed = self.parse_ssh_cmd_target(line)
131
+ if parsed is None:
132
+ return None, None, None # 最内层不是 ssh 类:不解析
133
+ host, user, port = parsed
134
+ if host:
135
+ return host, user, port
136
+ return None, None, None
137
+
138
+ def ssh_frame_target(self) -> tuple[str | None, str | None, int | None]:
139
+ """最近一层未闭合 ssh 帧的完整目标 (host, user, port)。
140
+
141
+ 与 ``nested_ssh_target`` 的「最内层非 ssh 即止」不同:这里**跳过
142
+ 非 ssh 帧向外找**——`ssh B` 之后再 `sudo su -`,终端仍停在 B 上,
143
+ 文件面板要连的就是 B。真机踩过:面板按 nested_ssh_target 取目标,
144
+ `sudo su -` 一开帧就解析成 None、退回跳板机,用户看到的是跳板机的
145
+ 家目录。凭据代答仍用 nested_ssh_target 的严格语义(防跳板机凭据
146
+ 错填给内层 sudo),两条语义不混用。
147
+ """
148
+ for line in reversed(list(self.core._open_cmds.values())):
149
+ parsed = self.parse_ssh_cmd_target(line)
150
+ if parsed is not None and parsed[0]:
151
+ return parsed
152
+ return None, None, None
153
+
154
+ def ssh_frame_host(self) -> str | None:
155
+ """最内层未闭合 ssh 族命令的目标主机(都解析不出返回 None)。
156
+
157
+ 跳过非 ssh 帧(sudo/su 自身)向外找最近的 ssh 帧——sudo 就跑在那台机
158
+ 上,是 sudo 凭据键的落名依据(P1-3)。"""
159
+ return self.ssh_frame_target()[0]
160
+
161
+ def sudo_pw_key(self) -> tuple[str, str, int | None] | None:
162
+ """sudo/su 类无主机提示符的凭据键:(host, "sudo", None) → sudo@host。
163
+
164
+ 提示符不带 user@host([sudo] password for user:),凭据归属只有
165
+ 两条线索:最近一层 ssh 帧的目标(sudo 就跑在那台机上;跳板拓扑
166
+ 里命令集 ssh 进内层机再 sudo,会话目标主机反而是跳板),无 ssh
167
+ 帧则会话目标主机(sudo 直接跑在目标机上)。
168
+
169
+ user 恒为 "sudo"——sudo 专用命名空间(display_name 拼出
170
+ ``sudo@host``),绝不与 ssh 登录凭据键(user@host)混用,外层
171
+ 跳板凭据也就不会被错填给内层 sudo(P1-3 不变量)。键按「sudo
172
+ 实际所在主机」命名:同跳板下多台内层机各记各的 sudo 密码,
173
+ 不互相覆盖误填。
174
+ """
175
+ host = self.ssh_frame_host() or self.core._target_host or None
176
+ if not host:
177
+ return None
178
+ return (host, "sudo", None)