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,321 @@
1
+ """远端 Linux 持久 shell:asyncssh + PTY 通道,断线重连一次。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import binascii
8
+ import hashlib
9
+ import hmac
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+
13
+ from .base import BasePtySession, CommandResult
14
+
15
+
16
+ def known_hosts_path() -> Path:
17
+ """asyncssh 默认校验的 known_hosts 文件。"""
18
+ return Path.home() / ".ssh" / "known_hosts"
19
+
20
+
21
+ def _hashed_entry_matches(token: str, wanted: set[str]) -> bool:
22
+ """HashKnownHosts 条目(|1|盐b64|摘要b64,HMAC-SHA1)是否命中主机名。
23
+
24
+ OpenSSH 对盐做 HMAC-SHA1(主机名) 后与摘要比对;主机名候选含
25
+ host 与 [host]:port 两种形式(调用方拼好放进 wanted)。
26
+ 畸形条目(版本号不符/base64 非法)返回 False,不抛。
27
+ """
28
+ parts = token.split("|")
29
+ if len(parts) != 4 or parts[0] != "" or parts[1] != "1":
30
+ return False
31
+ try:
32
+ salt = base64.b64decode(parts[2], validate=True)
33
+ digest = base64.b64decode(parts[3], validate=True)
34
+ except (binascii.Error, ValueError):
35
+ return False
36
+ return any(
37
+ hmac.compare_digest(
38
+ hmac.new(salt, name.encode("utf-8"), hashlib.sha1).digest(),
39
+ digest)
40
+ for name in wanted)
41
+
42
+
43
+ def known_hosts_has_entry(path: Path, host: str, port: int | None = None) -> bool:
44
+ """known_hosts 中是否已有该主机([host]:port 形式也认)。
45
+
46
+ 支持哈希条目(HashKnownHosts=yes,|1|盐|摘要):此前一律视为「无条目」,
47
+ 会让 TOFU 的「已收录但不符 → 硬失败」防线被绕过(对哈希条目库的主机
48
+ 密钥变更误走 TOFU 询问),无头面预检同因假阴性直接 exit 69。
49
+ """
50
+ if not path.exists():
51
+ return False
52
+ wanted = {host}
53
+ if port and port != 22:
54
+ wanted.add(f"[{host}]:{port}")
55
+ for raw in path.read_text(encoding="utf-8").splitlines():
56
+ line = raw.strip()
57
+ if not line or line.startswith("#") or line.startswith("@"):
58
+ continue
59
+ token = line.split()[0]
60
+ if token.startswith("|"):
61
+ if _hashed_entry_matches(token, wanted):
62
+ return True
63
+ continue
64
+ if wanted & set(token.split(",")):
65
+ return True
66
+ return False
67
+
68
+
69
+ def format_known_host_line(host: str, port: int | None, key) -> str:
70
+ """按 OpenSSH known_hosts 格式拼一行(非标准端口用 [host]:port)。"""
71
+ name = f"[{host}]:{port}" if port and port != 22 else host
72
+ algorithm = key.algorithm.decode() if isinstance(key.algorithm, bytes) else key.algorithm
73
+ blob = binascii.b2a_base64(key.public_data).decode().strip()
74
+ return f"{name} {algorithm} {blob}\n"
75
+
76
+
77
+ def _default_host_key_prompt(message: str) -> bool:
78
+ return input(message).strip().lower() in {"y", "yes"}
79
+
80
+
81
+ class SshPtySession(BasePtySession):
82
+ def __init__(
83
+ self,
84
+ host: str,
85
+ *,
86
+ username: str | None = None,
87
+ port: int | None = None,
88
+ config_files: tuple[str, ...] = ("~/.ssh/config",),
89
+ rows: int = 40,
90
+ cols: int = 120,
91
+ host_key_prompt: Callable[[str], bool] | None = None,
92
+ password: str | None = None,
93
+ password_prompt: Callable[[str], str | None] | None = None,
94
+ **kwargs,
95
+ ) -> None:
96
+ super().__init__(**kwargs)
97
+ self.host = host
98
+ self.username = username
99
+ self.port = port
100
+ # password:系统凭据库里的记住密码,先于交互提示尝试;
101
+ # last_password:本次连接实际使用的密码(连接成功后供 CLI 存回凭据库)
102
+ self.password = password
103
+ self.last_password = password
104
+ self._ask_password = password_prompt or self._default_password_prompt
105
+ self.config_files = [str(Path(p).expanduser()) for p in config_files]
106
+ self.rows, self.cols = rows, cols
107
+ self._ask_host_key = host_key_prompt or _default_host_key_prompt
108
+ self._conn = None
109
+ self._proc = None
110
+ # SFTP 客户端缓存:懒打开;断线重连重建 _conn 后按对象比对自动重开
111
+ self._sftp = None
112
+ self._sftp_conn = None
113
+
114
+ @staticmethod
115
+ def _default_password_prompt(label: str) -> str:
116
+ import getpass
117
+
118
+ return getpass.getpass(label)
119
+
120
+ async def _maybe_await(self, fn, *args):
121
+ if asyncio.iscoroutinefunction(fn):
122
+ return await fn(*args)
123
+ loop = asyncio.get_running_loop()
124
+ return await loop.run_in_executor(None, fn, *args)
125
+
126
+ async def _connect_one(self, *, host, username, port, password,
127
+ record_last=None):
128
+ import getpass
129
+
130
+ import asyncssh
131
+
132
+ kwargs: dict = {"keepalive_interval": 30}
133
+ if self._config_paths:
134
+ kwargs["config"] = self._config_paths
135
+ if username:
136
+ kwargs["username"] = username
137
+ if port:
138
+ kwargs["port"] = port
139
+ if password:
140
+ kwargs["password"] = password
141
+ # 加密私钥口令:保持 getpass(Web 端为已知限制:走服务进程控制台)
142
+ kwargs["passphrase"] = lambda: getpass.getpass(f"密钥口令 ({host}): ")
143
+
144
+ # 密码重试上限:对齐 OpenSSH 默认 NumberOfPasswordPrompts=3——错密后
145
+ # 前端重讨提示符(Permission denied 行 + 新提示符),三次全错才判失败
146
+ async def _open(tofu_allowed: bool, tries: int):
147
+ try:
148
+ return await asyncssh.connect(host, **kwargs)
149
+ except asyncssh.PermissionDenied:
150
+ if tries >= 3:
151
+ raise
152
+ label = f"{(username + '@') if username else ''}{host} 密码: "
153
+ pw = await self._maybe_await(self._ask_password, label)
154
+ kwargs["password"] = pw
155
+ if record_last is not None:
156
+ record_last(pw)
157
+ return await _open(tofu_allowed=True, tries=tries + 1)
158
+ except asyncssh.HostKeyNotVerifiable:
159
+ if not tofu_allowed:
160
+ raise
161
+ return await self._trust_unknown_host(host, port, kwargs, _open)
162
+
163
+ return await _open(tofu_allowed=True, tries=0)
164
+
165
+ def _set_target_password(self, pw: str) -> None:
166
+ self.last_password = pw
167
+
168
+ async def start(self) -> None:
169
+ self._config_paths = [p for p in self.config_files if Path(p).exists()]
170
+ # 重连用 last_password:构造时的 password 是旧值/None,现场重输的
171
+ # 密码只落在 last_password;否则断线重连会重新要密码(见 _recover_connection)
172
+ self._conn = await self._connect_one(
173
+ host=self.host, username=self.username, port=self.port,
174
+ password=self.last_password,
175
+ record_last=self._set_target_password,
176
+ )
177
+ self._proc = await self._conn.create_process(
178
+ term_type="xterm-256color",
179
+ # asyncssh term_size 语义是 (width, height):传 (rows, cols) 会
180
+ # 把远端 tty 变成 120 行×40 列(stty size 打出 "120 40"),
181
+ # vim/less 等全屏程序按远端尺寸绘制后又被前端 xterm 折行
182
+ term_size=(self.cols, self.rows),
183
+ encoding=None, # 字节流:stdin/stdout 与 PTY 透传数据均按 bytes 处理
184
+ )
185
+ # pwd 探测走 _run_locked(不加锁):start() 会在 _recover_connection
186
+ # 内被调用,彼时 _run_lock 已被外层 run() 持有——asyncio.Lock 不可
187
+ # 重入,再走 run() 会与自身死锁。start() 串行调用,无并发竞争。
188
+ probe = await self._run_locked("pwd")
189
+ self.cwd = probe.cwd or ""
190
+
191
+ async def _trust_unknown_host(self, host, port, kwargs, open_conn) -> object:
192
+ """TOFU:未知主机 → 取指纹询问用户 → 接受则写入 known_hosts 并复用连接。
193
+
194
+ 已有条目却校验失败(主机密钥变更)时硬失败,绝不静默覆盖。
195
+ """
196
+ import asyncssh
197
+
198
+ kh = known_hosts_path()
199
+ if known_hosts_has_entry(kh, host, port):
200
+ raise asyncssh.HostKeyNotVerifiable(
201
+ f"主机 {host} 密钥与 {kh} 中记录不符(可能是重装系统或中间人),"
202
+ "请人工核实后手动更新 known_hosts"
203
+ )
204
+ # 临时不校验以取得服务器公钥(同时可能完成密码认证,连接直接复用)。
205
+ # _open 闭包按引用读取 kwargs,因此原地改写即可。
206
+ saved = dict(kwargs)
207
+ kwargs.update(known_hosts=None)
208
+ conn = await open_conn(tofu_allowed=False, tries=0)
209
+ key = conn.get_server_host_key()
210
+ algorithm = (
211
+ key.algorithm.decode() if isinstance(key.algorithm, bytes) else key.algorithm
212
+ )
213
+ fp = base64.b64encode(hashlib.sha256(key.public_data).digest()).decode().strip("=")
214
+ message = (
215
+ f"主机 {host} 的密钥未知({algorithm} SHA256:{fp})。\n"
216
+ "信任并写入 known_hosts?[y/N]: "
217
+ )
218
+ accepted = await self._maybe_await(self._ask_host_key, message)
219
+ if not accepted:
220
+ conn.close()
221
+ raise asyncssh.HostKeyNotVerifiable("用户拒绝信任未知主机密钥")
222
+ kh.parent.mkdir(parents=True, exist_ok=True)
223
+ with kh.open("a", encoding="utf-8") as f:
224
+ f.write(format_known_host_line(host, port, key))
225
+ # 后续本进程内重连沿用已建立的连接;下次 start() 会重新走默认校验。
226
+ kwargs.clear()
227
+ kwargs.update(saved)
228
+ return conn
229
+
230
+ async def _write_raw(self, data: bytes) -> None:
231
+ if self._proc is None:
232
+ raise ConnectionError("SSH 进程未启动")
233
+ self._proc.stdin.write(data)
234
+ await self._proc.stdin.drain()
235
+
236
+ async def _read_some(self, idle_timeout: float) -> bytes:
237
+ if self._proc is None:
238
+ raise ConnectionError("SSH 进程未启动")
239
+ data = await asyncio.wait_for(self._proc.stdout.read(65_536), idle_timeout)
240
+ if not data:
241
+ raise EOFError("SSH 通道 EOF")
242
+ return data
243
+
244
+ async def _interrupt(self) -> None:
245
+ if self._proc is not None:
246
+ self._proc.stdin.write(b"\x03")
247
+ await self._proc.stdin.drain()
248
+
249
+ async def _recover_connection(self) -> bool:
250
+ await self.close()
251
+ for _ in range(1): # 只自动重连一次
252
+ try:
253
+ await self.start()
254
+ await self._notify_reconnect()
255
+ return True
256
+ except Exception:
257
+ return False
258
+ return False
259
+
260
+ async def _handle_timeout(self) -> CommandResult:
261
+ # 与 LocalPtySession 的约定对齐(124/130):超时走重连路径成功时,
262
+ # 基类返回 _reconnected_result(exit 1)——但会话状态确已丢失、原
263
+ # 命令未完成,按超时语义改回 124;否则无头消费方会把 exit 1 当
264
+ # 普通远程失败(命令可能其实没跑完)。
265
+ result = await super()._handle_timeout()
266
+ if result.reconnected:
267
+ return CommandResult(
268
+ output=f"{result.output}\n\n"
269
+ f"[命令在 {self._default_timeout}s 后超时被中断]",
270
+ exit_code=124, truncated=False, cwd=result.cwd,
271
+ reconnected=True,
272
+ )
273
+ return result
274
+
275
+ async def resize(self, rows: int, cols: int) -> None:
276
+ self.rows, self.cols = rows, cols
277
+ if self._proc is not None:
278
+ # asyncssh change_terminal_size 是同步方法(发 window-change 请求)
279
+ self._proc.change_terminal_size(cols, rows)
280
+
281
+ async def send_raw(self, data: bytes) -> None:
282
+ await self._write_raw(data)
283
+
284
+ async def sftp_client(self):
285
+ """返回可用的 SFTP 客户端(懒打开、按连接缓存)。
286
+
287
+ 断线重连会重建 self._conn:这里按对象比对失效旧客户端,因此无需
288
+ 侵入 _recover_connection 主流程。文件传输走独立通道,不碰输出泵
289
+ 与哨兵捕获机制。
290
+ """
291
+ if self._conn is None:
292
+ raise RuntimeError("SSH 会话未连接,无法打开 SFTP")
293
+ if self._sftp is not None and self._sftp_conn is self._conn:
294
+ return self._sftp
295
+ if self._sftp is not None:
296
+ try:
297
+ self._sftp.exit()
298
+ except Exception:
299
+ pass
300
+ self._sftp = await self._conn.start_sftp_client()
301
+ self._sftp_conn = self._conn
302
+ return self._sftp
303
+
304
+ async def close(self) -> None:
305
+ if self._sftp is not None:
306
+ try:
307
+ self._sftp.exit()
308
+ except Exception:
309
+ pass
310
+ self._sftp = self._sftp_conn = None
311
+ if self._proc is not None:
312
+ try:
313
+ self._proc.close()
314
+ except Exception:
315
+ pass
316
+ if self._conn is not None:
317
+ try:
318
+ self._conn.close()
319
+ except Exception:
320
+ pass
321
+ self._proc = self._conn = None
@@ -0,0 +1,17 @@
1
+ """shell 域(阶段二 Task 13 重组)。
2
+
3
+ integration.py——shell 集成脚本(bash/zsh/powershell 注入)、exec/探针/
4
+ 内部行判别与 StreamRouter 输出分流;cmdset.py——连接后命令集的内联密码
5
+ 抽取与 @引用解析。
6
+ 本 __init__ 再导出两模块全部公开名,旧 import 路径
7
+ (``from openterminal.shell_integration import ...`` /
8
+ ``from openterminal.cmdset import ...``)不因拆包而变。
9
+ """
10
+
11
+ from .cmdset import extract_inline_secrets, resolve_answer # noqa: F401
12
+ from .integration import ( # noqa: F401
13
+ EXEC_PREFIX, KIND_AI, KIND_CMD, KIND_EXEC, PROBE_TAG, StreamRouter,
14
+ agent_exec_line, build_script, history_inject_line, history_quiet_line,
15
+ injection_line, injection_lines, is_internal_line, parse_probe,
16
+ probe_command, strip_ansi, toggle_line,
17
+ )
@@ -0,0 +1,49 @@
1
+ """连接后命令集:内联密码抽取与 @引用解析(纯函数,可单测)。
2
+
3
+ 密码永不明文落库:表单里写 `> @名称=密码`,保存时抽取入系统凭据库
4
+ (keyring,键 `cmdset:名称`),落库文本只留 `> @名称` 引用。凭据库
5
+ 不可用时静默降级——行照改写、密码丢弃,由 API 层上报 warning。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from ..connections import find_saved_target
12
+ from ..connections.secrets import load_password, store_password
13
+
14
+ _CMDSET_KEY_PREFIX = "cmdset:"
15
+ # `> @名称=密码`:名称限字母数字_.-,避免把 `> @a b=c` 之类误当引用
16
+ _INLINE_SECRET_RE = re.compile(r"^>\s*@([A-Za-z0-9_.\-]+)\s*=(.*)$")
17
+
18
+
19
+ def extract_inline_secrets(text: str) -> tuple[str, list[str]]:
20
+ """抽取内联密码入 keyring 并改写为引用,返回 (改写文本, 写失败的引用名)。
21
+
22
+ CRLF/CR 归一为 LF(Windows 表单粘贴);密码为空串视为纯引用不写库。
23
+ """
24
+ out: list[str] = []
25
+ failed: list[str] = []
26
+ for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
27
+ m = _INLINE_SECRET_RE.match(line.strip())
28
+ if m is None:
29
+ out.append(line)
30
+ continue
31
+ name, secret = m.group(1), m.group(2).strip()
32
+ if secret and not store_password(f"{_CMDSET_KEY_PREFIX}{name}",
33
+ None, None, secret):
34
+ failed.append(name) # 凭据库缺席:丢弃密码,行照改写
35
+ out.append(f"> @{name}")
36
+ return "\n".join(out), failed
37
+
38
+
39
+ def resolve_answer(name: str) -> str | None:
40
+ """@引用 → 应答内容:先命令集命名空间,再已保存主机的记住密码。"""
41
+ pw = load_password(f"{_CMDSET_KEY_PREFIX}{name}", None, None)
42
+ if pw:
43
+ return pw
44
+ t = find_saved_target(name)
45
+ if t is not None:
46
+ pw = load_password(t.host or t.name, t.user, t.port)
47
+ if pw:
48
+ return pw
49
+ return None