aisc-cli 0.1.0__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.
- aisc/VERSION +1 -0
- aisc/__init__.py +49 -0
- aisc/__main__.py +6 -0
- aisc/adapters/__init__.py +1 -0
- aisc/adapters/config_reader.py +215 -0
- aisc/adapters/container_registry.py +718 -0
- aisc/adapters/data_root_store.py +296 -0
- aisc/adapters/docker_.py +1183 -0
- aisc/adapters/docker_gateway.py +1244 -0
- aisc/adapters/docker_sdk_backed.py +274 -0
- aisc/adapters/maintenance_lock.py +97 -0
- aisc/adapters/state_file.py +184 -0
- aisc/adapters/system.py +101 -0
- aisc/adapters/windows_config_reader.py +249 -0
- aisc/adapters/workspace_lease_store.py +189 -0
- aisc/application/__init__.py +1 -0
- aisc/application/artifact.py +265 -0
- aisc/application/bundle_fetch.py +836 -0
- aisc/application/cc_switch_provider.py +245 -0
- aisc/application/cc_switch_resolver.py +363 -0
- aisc/application/config_service.py +452 -0
- aisc/application/conversation.py +516 -0
- aisc/application/data_migration.py +440 -0
- aisc/application/data_root.py +275 -0
- aisc/application/docker_lifecycle.py +659 -0
- aisc/application/doctor.py +821 -0
- aisc/application/legacy_scan.py +252 -0
- aisc/application/network_subscription.py +642 -0
- aisc/application/profile_service.py +105 -0
- aisc/application/provider.py +92 -0
- aisc/application/repair.py +387 -0
- aisc/application/resources.py +270 -0
- aisc/application/runtime.py +1826 -0
- aisc/application/session.py +320 -0
- aisc/application/tool_versions.py +210 -0
- aisc/application/toolchain.py +91 -0
- aisc/application/update.py +286 -0
- aisc/application/usage.py +305 -0
- aisc/application/version.py +133 -0
- aisc/application/web_gateway.py +459 -0
- aisc/application/workspace_reconcile.py +318 -0
- aisc/applog.py +103 -0
- aisc/cli/__init__.py +1 -0
- aisc/cli/build_progress_parser.py +217 -0
- aisc/cli/commands/__init__.py +1 -0
- aisc/cli/commands/agents.py +117 -0
- aisc/cli/commands/artifact.py +210 -0
- aisc/cli/commands/build.py +566 -0
- aisc/cli/commands/bundle.py +107 -0
- aisc/cli/commands/cc_switch.py +158 -0
- aisc/cli/commands/config.py +104 -0
- aisc/cli/commands/container.py +787 -0
- aisc/cli/commands/conversation.py +72 -0
- aisc/cli/commands/data_root.py +84 -0
- aisc/cli/commands/logs.py +89 -0
- aisc/cli/commands/network.py +132 -0
- aisc/cli/commands/profile.py +52 -0
- aisc/cli/commands/provider.py +49 -0
- aisc/cli/commands/run.py +494 -0
- aisc/cli/commands/runs.py +125 -0
- aisc/cli/commands/runtime.py +450 -0
- aisc/cli/commands/serve.py +550 -0
- aisc/cli/commands/serve_fs.py +369 -0
- aisc/cli/commands/session.py +167 -0
- aisc/cli/commands/update.py +145 -0
- aisc/cli/commands/usage.py +74 -0
- aisc/cli/commands/wizard.py +130 -0
- aisc/cli/commands/workspaces.py +162 -0
- aisc/cli/help_i18n.py +385 -0
- aisc/cli/main.py +2926 -0
- aisc/cli/output.py +239 -0
- aisc/cli/tutorial.py +109 -0
- aisc/domain/__init__.py +1 -0
- aisc/domain/artifacts.py +279 -0
- aisc/domain/cc_switch_release.py +303 -0
- aisc/domain/config.py +64 -0
- aisc/domain/data_migration.py +259 -0
- aisc/domain/data_root.py +124 -0
- aisc/domain/docker_ownership.py +148 -0
- aisc/domain/gateway.py +168 -0
- aisc/domain/models.py +674 -0
- aisc/domain/toolchain.py +72 -0
- aisc/domain/web_services.py +355 -0
- aisc/domain/workspace_lease.py +139 -0
- aisc/schemas/__init__.py +1 -0
- aisc/schemas/config_schema.py +145 -0
- aisc_cli-0.1.0.dist-info/METADATA +420 -0
- aisc_cli-0.1.0.dist-info/RECORD +92 -0
- aisc_cli-0.1.0.dist-info/WHEEL +5 -0
- aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""``aisc serve`` — the long-lived remote channel (2.1.10 R1/R2, D-7/D-8).
|
|
2
|
+
|
|
3
|
+
The Workbench (or any client) reaches a remote machine's aisc CLI over SSH
|
|
4
|
+
by spawning ``ssh <alias> aisc serve --stdio``: the serve process then speaks
|
|
5
|
+
newline-delimited JSON frames on stdio — the same "authenticated transport
|
|
6
|
+
is the SSH session" model as VS Code Remote-SSH. There is deliberately NO
|
|
7
|
+
token and NO listening socket in stdio mode (D-7); a future TCP mode must
|
|
8
|
+
re-open that decision.
|
|
9
|
+
|
|
10
|
+
Frame protocol v1.1 (docs/plans/2.1.10-dev-plans/r2-remote-sessions.md §2):
|
|
11
|
+
|
|
12
|
+
serve -> client {"type":"ready","serve_protocol":1,"cli_version":...}
|
|
13
|
+
client -> serve {"id":"u1","op":"version","args":[...]}
|
|
14
|
+
serve -> client {"id":"u1","type":"result","ok":true,"envelope":{...}}
|
|
15
|
+
serve -> client {"type":"log","level":"info","line":"..."}
|
|
16
|
+
|
|
17
|
+
R2 PTY stream frames (D-8):
|
|
18
|
+
client -> serve {"id":"u2","op":"session.open","args":{...}}
|
|
19
|
+
serve -> client result frame, then stream frames for that sid:
|
|
20
|
+
client -> serve {"type":"pty.input","sid":...,"data":"<b64>"}
|
|
21
|
+
client -> serve {"type":"pty.resize","sid":...,"cols":80,"rows":24}
|
|
22
|
+
client -> serve {"type":"pty.kill","sid":...}
|
|
23
|
+
serve -> client {"type":"pty.output","sid":...,"data":"<b64>"}
|
|
24
|
+
serve -> client {"type":"pty.exit","sid":...,"exit_code":0}
|
|
25
|
+
|
|
26
|
+
Lifecycle: stdin EOF, SIGINT or SIGTERM ends the loop (in-flight request is
|
|
27
|
+
allowed up to 3s to finish, every live PTY is killed). Malformed lines and
|
|
28
|
+
unknown ops answer with an error result frame — the process never dies from
|
|
29
|
+
bad input.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import base64
|
|
36
|
+
import contextlib
|
|
37
|
+
import io
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import signal
|
|
41
|
+
import sys
|
|
42
|
+
import threading
|
|
43
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
44
|
+
|
|
45
|
+
from aisc.cli.output import build_envelope
|
|
46
|
+
from aisc.domain.models import CliError
|
|
47
|
+
|
|
48
|
+
#: Wire protocol version. Human labels v1.1 (PTY) / v1.2 (fs.*) were additive
|
|
49
|
+
#: while the field stayed 1; v1.3 (D-10: generic `cli` op + ready `home`)
|
|
50
|
+
#: BUMPS the field to 3 and drops cross-version compatibility by ruling —
|
|
51
|
+
#: both ends ship from one repo, a mismatch is a hard client-side error
|
|
52
|
+
#: ("upgrade the remote aisc"), never a silent per-op-ssh fallback.
|
|
53
|
+
SERVE_PROTOCOL = 3
|
|
54
|
+
|
|
55
|
+
#: Grace period for an in-flight op when the loop is asked to stop (s).
|
|
56
|
+
SHUTDOWN_GRACE_SECONDS = 3.0
|
|
57
|
+
|
|
58
|
+
#: Initial pty size for session.open when the client did not send one.
|
|
59
|
+
DEFAULT_COLS = 80
|
|
60
|
+
DEFAULT_ROWS = 24
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _frame(line: str) -> str:
|
|
64
|
+
# 2.1.11 step2 (field forensics): the wire is JSON-lines read by a Rust
|
|
65
|
+
# client with a STRICT UTF-8 reader. `ensure_ascii=False` wrote real CJK
|
|
66
|
+
# through the locale encoder — GBK on zh-CN Windows — and the resident
|
|
67
|
+
# reader died on the first invalid byte ("stream did not contain valid
|
|
68
|
+
# UTF-8"), tearing the session down (doctor's "Not Linux —" message was
|
|
69
|
+
# the trigger). ASCII-escape everything: pure-ASCII frames are immune to
|
|
70
|
+
# any locale, and \uXXXX escapes parse identically on every client (old
|
|
71
|
+
# and new interop in both directions). CJK-heavy frames grow ~3x —
|
|
72
|
+
# irrelevant at control-plane sizes. (One-shot envelope writers kept
|
|
73
|
+
# ensure_ascii=True all along; serve was the outlier.)
|
|
74
|
+
return json.dumps(line, ensure_ascii=True, separators=(",", ":"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# -- PTY registry (R2, D-8) -----------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PtyEntry:
|
|
81
|
+
"""One live exec TTY owned by this serve process."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, sid: str, handle: Any, runtime: "ServeRuntime") -> None:
|
|
84
|
+
self.sid = sid
|
|
85
|
+
self.handle = handle
|
|
86
|
+
self.runtime = runtime
|
|
87
|
+
self.waiter = threading.Thread(target=self._wait, daemon=True)
|
|
88
|
+
|
|
89
|
+
def start(self) -> None:
|
|
90
|
+
self.waiter.start()
|
|
91
|
+
|
|
92
|
+
def _wait(self) -> None:
|
|
93
|
+
try:
|
|
94
|
+
code = self.handle.wait_exit()
|
|
95
|
+
except Exception as exc: # noqa: BLE001 — exit frame carries the failure
|
|
96
|
+
self.runtime._emit({"type": "pty.exit", "sid": self.sid,
|
|
97
|
+
"error": f"{type(exc).__name__}: {exc}"})
|
|
98
|
+
return
|
|
99
|
+
self.runtime._emit({"type": "pty.exit", "sid": self.sid,
|
|
100
|
+
"exit_code": code})
|
|
101
|
+
self.runtime._forget_pty(self.sid)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ServeRuntime:
|
|
105
|
+
"""Everything the loop + pump threads share: the serialized stdout
|
|
106
|
+
writer (frames from the main loop AND from every PTY pump thread ride
|
|
107
|
+
one lock) and the live-PTY registry."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, stdout: Any) -> None:
|
|
110
|
+
self._stdout = stdout
|
|
111
|
+
self._write_lock = threading.Lock()
|
|
112
|
+
self._ptys: Dict[str, PtyEntry] = {}
|
|
113
|
+
self._pty_lock = threading.Lock()
|
|
114
|
+
# R3 (D-9): lazy-created watchdog registry; fs.watch fails cleanly
|
|
115
|
+
# where watchdog is unavailable.
|
|
116
|
+
from aisc.cli.commands.serve_fs import WatchRegistry
|
|
117
|
+
|
|
118
|
+
self.fs_watches = WatchRegistry(self._emit)
|
|
119
|
+
|
|
120
|
+
def _emit(self, frame: Dict[str, Any]) -> None:
|
|
121
|
+
line = _frame(frame) + "\n"
|
|
122
|
+
with self._write_lock:
|
|
123
|
+
self._stdout.write(line)
|
|
124
|
+
self._stdout.flush()
|
|
125
|
+
|
|
126
|
+
def _on_output(self, sid: str) -> Callable[[bytes], None]:
|
|
127
|
+
def push(chunk: bytes) -> None:
|
|
128
|
+
self._emit({
|
|
129
|
+
"type": "pty.output",
|
|
130
|
+
"sid": sid,
|
|
131
|
+
"data": base64.b64encode(chunk).decode("ascii"),
|
|
132
|
+
})
|
|
133
|
+
return push
|
|
134
|
+
|
|
135
|
+
def register_pty(self, sid: str, handle: Any) -> None:
|
|
136
|
+
entry = PtyEntry(sid, handle, self)
|
|
137
|
+
with self._pty_lock:
|
|
138
|
+
self._ptys[sid] = entry
|
|
139
|
+
entry.start()
|
|
140
|
+
|
|
141
|
+
def _forget_pty(self, sid: str) -> None:
|
|
142
|
+
with self._pty_lock:
|
|
143
|
+
self._ptys.pop(sid, None)
|
|
144
|
+
|
|
145
|
+
def pty(self, sid: str) -> Optional[Any]:
|
|
146
|
+
with self._pty_lock:
|
|
147
|
+
return self._ptys.get(sid)
|
|
148
|
+
|
|
149
|
+
def pty_input(self, sid: str, data_b64: str) -> Optional[str]:
|
|
150
|
+
entry = self.pty(sid)
|
|
151
|
+
if entry is None:
|
|
152
|
+
return f"unknown sid {sid!r}"
|
|
153
|
+
try:
|
|
154
|
+
entry.handle.write(base64.b64decode(data_b64))
|
|
155
|
+
except Exception as exc: # noqa: BLE001
|
|
156
|
+
return f"{type(exc).__name__}: {exc}"
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def pty_resize(self, sid: str, cols: int, rows: int) -> Optional[str]:
|
|
160
|
+
entry = self.pty(sid)
|
|
161
|
+
if entry is None:
|
|
162
|
+
return f"unknown sid {sid!r}"
|
|
163
|
+
try:
|
|
164
|
+
entry.handle.resize((int(cols), int(rows)))
|
|
165
|
+
except Exception as exc: # noqa: BLE001
|
|
166
|
+
return f"{type(exc).__name__}: {exc}"
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def pty_kill(self, sid: str) -> None:
|
|
170
|
+
entry = self.pty(sid)
|
|
171
|
+
if entry is not None:
|
|
172
|
+
entry.handle.kill()
|
|
173
|
+
self._forget_pty(sid)
|
|
174
|
+
|
|
175
|
+
def kill_all(self) -> None:
|
|
176
|
+
with self._pty_lock:
|
|
177
|
+
entries = list(self._ptys.values())
|
|
178
|
+
self._ptys.clear()
|
|
179
|
+
for entry in entries:
|
|
180
|
+
try:
|
|
181
|
+
entry.handle.kill()
|
|
182
|
+
except Exception: # noqa: BLE001
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# -- op dispatch ----------------------------------------------------------------
|
|
187
|
+
#
|
|
188
|
+
# Ops map straight onto the same application-layer code paths the one-shot
|
|
189
|
+
# CLI commands use (never argparse); each returns (data, exit_code, errors).
|
|
190
|
+
# serve ALWAYS answers with an envelope — stdout carries nothing else.
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _op_version(args: argparse.Namespace, payload: Dict[str, Any],
|
|
194
|
+
runtime: ServeRuntime) -> Tuple[Any, int, List[Dict[str, Any]]]:
|
|
195
|
+
from aisc.cli.main import _cmd_version
|
|
196
|
+
|
|
197
|
+
return _cmd_version(args).to_dict(), 0, []
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _op_doctor(args: argparse.Namespace, payload: Dict[str, Any],
|
|
201
|
+
runtime: ServeRuntime) -> Tuple[Any, int, List[Dict[str, Any]]]:
|
|
202
|
+
from aisc.cli.main import _cmd_doctor
|
|
203
|
+
|
|
204
|
+
data, report = _cmd_doctor(args, effective_format="json")
|
|
205
|
+
# doctor's own exit code (0 ok / 3 docker-unavailable) rides the envelope
|
|
206
|
+
# exactly as the one-shot CLI reports it.
|
|
207
|
+
return data, report.exit_code, []
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _op_ps(args: argparse.Namespace, payload: Dict[str, Any],
|
|
211
|
+
runtime: ServeRuntime) -> Tuple[Any, int, List[Dict[str, Any]]]:
|
|
212
|
+
from aisc.cli.main import _cmd_ps
|
|
213
|
+
|
|
214
|
+
return _cmd_ps(args, effective_format="json")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
#: Commands that must never run inside serve's `cli` op (D-10): interactive /
|
|
218
|
+
#: TUI / streaming / self-referential — they would block the serial op loop
|
|
219
|
+
#: or capture the transport itself. PTY entry rides its own session.open op;
|
|
220
|
+
#: build stays on its per-op ssh event stream.
|
|
221
|
+
_SERVE_CLI_DENY = frozenset({
|
|
222
|
+
"serve", # recursion
|
|
223
|
+
"run", # interactive exec
|
|
224
|
+
"shell", # docker exec -it
|
|
225
|
+
"switch", # TUI
|
|
226
|
+
"build", # long streaming op (own channel)
|
|
227
|
+
"wizard", # interactive
|
|
228
|
+
"session", # streams ride session.open / pty.* frames
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
#: 2.1.11 P1 手测 r6: the bare `cc-switch` IS a TUI, but the workbench
|
|
232
|
+
#: Provider tab drives the six ENVELOPE subcommands over serve for remote
|
|
233
|
+
#: workspaces — denying the whole command made provider management
|
|
234
|
+
#: remote-impossible ('cc-switch' cannot run over serve). Allowlist mirrors
|
|
235
|
+
#: application/cc_switch_provider._OPS; anything else (bare TUI, future
|
|
236
|
+
#: subcommands) stays denied — default-deny keeps the serial op loop safe.
|
|
237
|
+
_SERVE_CC_SWITCH_OPS = frozenset({
|
|
238
|
+
"list", "add", "edit", "switch", "delete", "fetch-models",
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _op_cli(args: argparse.Namespace, payload: Dict[str, Any],
|
|
243
|
+
runtime: ServeRuntime) -> Tuple[Any, int, List[Dict[str, Any]]]:
|
|
244
|
+
"""D-10 generic control-plane op: run one non-interactive CLI argv
|
|
245
|
+
in-process and return its envelope's (data, exit_code, errors).
|
|
246
|
+
|
|
247
|
+
Reuses the one-shot CLI's own dispatch (``main``) under captured
|
|
248
|
+
stdio — one op covers every envelope command, zero per-command
|
|
249
|
+
registration. Frame transport is unaffected by the capture: the loop
|
|
250
|
+
and PTY drains write through the ``ServeRuntime``'s cached stdout
|
|
251
|
+
handle, not the rebound ``sys.stdout``.
|
|
252
|
+
"""
|
|
253
|
+
from aisc.cli.main import main as _cli_main, _detect_json_format
|
|
254
|
+
|
|
255
|
+
argv = [str(a) for a in (payload.get("argv") or [])]
|
|
256
|
+
if not argv:
|
|
257
|
+
raise CliError(message="cli op requires a non-empty argv",
|
|
258
|
+
exit_code=2, error_code="AISC_ERR_USAGE")
|
|
259
|
+
if argv[0] in _SERVE_CLI_DENY or (
|
|
260
|
+
argv[0] == "cc-switch"
|
|
261
|
+
and (len(argv) < 2 or argv[1] not in _SERVE_CC_SWITCH_OPS)
|
|
262
|
+
):
|
|
263
|
+
raise CliError(
|
|
264
|
+
message=f"'{argv[0]}' cannot run over serve (interactive, "
|
|
265
|
+
f"streaming or self-referential)",
|
|
266
|
+
exit_code=2, error_code="AISC_ERR_USAGE",
|
|
267
|
+
hint="PTY sessions ride session.open; build keeps its ssh stream")
|
|
268
|
+
if "--events" in argv or "-h" in argv or "--help" in argv:
|
|
269
|
+
raise CliError(message="cli op rejects --events/-h/--help argv",
|
|
270
|
+
exit_code=2, error_code="AISC_ERR_USAGE")
|
|
271
|
+
# The op contract is the JSON envelope — force it for callers that
|
|
272
|
+
# omitted --format (same detector the one-shot CLI uses pre-parse).
|
|
273
|
+
if not _detect_json_format(argv):
|
|
274
|
+
argv = [*argv, "--format", "json"]
|
|
275
|
+
|
|
276
|
+
stdin_text = payload.get("stdin")
|
|
277
|
+
run_id = payload.get("run_id")
|
|
278
|
+
prev_run_id = os.environ.get("AISC_RUN_ID")
|
|
279
|
+
if isinstance(run_id, str) and run_id:
|
|
280
|
+
os.environ["AISC_RUN_ID"] = run_id
|
|
281
|
+
|
|
282
|
+
buf = io.StringIO()
|
|
283
|
+
code = 0
|
|
284
|
+
prev_stdin = sys.stdin
|
|
285
|
+
sys.stdin = io.StringIO(stdin_text if isinstance(stdin_text, str) else "")
|
|
286
|
+
try:
|
|
287
|
+
with contextlib.redirect_stdout(buf):
|
|
288
|
+
try:
|
|
289
|
+
_cli_main(argv)
|
|
290
|
+
except SystemExit as exc: # argparse errors print + exit
|
|
291
|
+
code = exc.code if isinstance(exc.code, int) else (0 if not exc.code else 2)
|
|
292
|
+
finally:
|
|
293
|
+
sys.stdin = prev_stdin
|
|
294
|
+
if prev_run_id is None:
|
|
295
|
+
os.environ.pop("AISC_RUN_ID", None)
|
|
296
|
+
else:
|
|
297
|
+
os.environ["AISC_RUN_ID"] = prev_run_id
|
|
298
|
+
|
|
299
|
+
text = buf.getvalue().strip()
|
|
300
|
+
if text:
|
|
301
|
+
try:
|
|
302
|
+
env = json.loads(text)
|
|
303
|
+
meta = env.get("meta") or {}
|
|
304
|
+
errors = list(env.get("errors") or [])
|
|
305
|
+
return (env.get("data"), int(meta.get("exit_code", code)), errors)
|
|
306
|
+
except ValueError:
|
|
307
|
+
pass
|
|
308
|
+
raise CliError(
|
|
309
|
+
message=f"cli op got non-envelope output: {text[:200]!r}",
|
|
310
|
+
exit_code=code or 2, error_code="AISC_ERR_SERVE_CLI_OUTPUT")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _op_session_open(args: argparse.Namespace, payload: Dict[str, Any],
|
|
314
|
+
runtime: ServeRuntime) -> Tuple[Any, int, List[Dict[str, Any]]]:
|
|
315
|
+
"""R2 (D-8): open one exec TTY as a stream; result frame reports the
|
|
316
|
+
exec establishment, then pty.output/exit stream frames follow."""
|
|
317
|
+
from aisc.adapters.docker_ import RealDockerExecutor
|
|
318
|
+
from aisc.adapters.docker_gateway import create_docker_gateway
|
|
319
|
+
from aisc.application.session import build_session_exec
|
|
320
|
+
from aisc.cli.commands.session import _resolve_workspace_and_registry
|
|
321
|
+
|
|
322
|
+
runtime_id = str(payload.get("runtime_id") or "")
|
|
323
|
+
session_id = str(payload.get("session_id") or "")
|
|
324
|
+
agent = str(payload.get("agent") or "")
|
|
325
|
+
workspace = payload.get("workspace")
|
|
326
|
+
resume = payload.get("resume_conversation_id")
|
|
327
|
+
|
|
328
|
+
# Resolution/validation speaks the DockerExecutor interface (run_captured
|
|
329
|
+
# etc. — RealDockerExecutor, docker CLI); the PTY stream is SDK-only
|
|
330
|
+
# (exec_resize, G-02). Two objects, two contracts.
|
|
331
|
+
executor = RealDockerExecutor()
|
|
332
|
+
gateway = create_docker_gateway("auto")
|
|
333
|
+
registry_root = _resolve_workspace_and_registry(
|
|
334
|
+
workspace if isinstance(workspace, str) and workspace else None
|
|
335
|
+
)[1]
|
|
336
|
+
|
|
337
|
+
container, docker_argv, env = build_session_exec(
|
|
338
|
+
runtime_id=runtime_id,
|
|
339
|
+
session_id=session_id,
|
|
340
|
+
agent=agent,
|
|
341
|
+
executor=executor,
|
|
342
|
+
registry_root=registry_root,
|
|
343
|
+
resume_conversation_id=resume if isinstance(resume, str) else None,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
cols = int(payload.get("cols") or DEFAULT_COLS)
|
|
347
|
+
rows = int(payload.get("rows") or DEFAULT_ROWS)
|
|
348
|
+
handle = gateway.open_pty_stream(
|
|
349
|
+
container, docker_argv, env=env,
|
|
350
|
+
on_output=runtime._on_output(session_id), initial_size=(cols, rows),
|
|
351
|
+
)
|
|
352
|
+
runtime.register_pty(session_id, handle)
|
|
353
|
+
return {"session_id": session_id, "agent": agent, "container": container}, 0, []
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
OPS: Dict[str, Callable[..., Tuple[Any, int, List[Dict[str, Any]]]]] = {
|
|
357
|
+
"version": _op_version,
|
|
358
|
+
"doctor": _op_doctor,
|
|
359
|
+
"ps": _op_ps,
|
|
360
|
+
"session.open": _op_session_open,
|
|
361
|
+
"cli": _op_cli,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
# R3 (D-9): the remote-authoritative file plane rides the same serve session.
|
|
365
|
+
from aisc.cli.commands.serve_fs import OPS as _FS_OPS # noqa: E402
|
|
366
|
+
|
|
367
|
+
OPS.update(_FS_OPS)
|
|
368
|
+
|
|
369
|
+
#: Ops that may not run while another op is executing — R1 is strictly
|
|
370
|
+
#: serial; the ``id`` field already exists so a concurrent executor can be
|
|
371
|
+
#: introduced later without a protocol change.
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _run_op(op: str, payload: Dict[str, Any], runtime: ServeRuntime) -> Dict[str, Any]:
|
|
375
|
+
"""Execute one op and wrap it in a result frame (never raises)."""
|
|
376
|
+
ns = argparse.Namespace(aisc_root=None)
|
|
377
|
+
handler = OPS.get(op)
|
|
378
|
+
if handler is None:
|
|
379
|
+
return {
|
|
380
|
+
"id": None,
|
|
381
|
+
"type": "result",
|
|
382
|
+
"ok": False,
|
|
383
|
+
"error": f"unknown op {op!r} (known: {', '.join(sorted(OPS))})",
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
def _command_name() -> str:
|
|
387
|
+
"""2.1.11 step2 (field fix): the ``cli`` op is a TRANSPARENT
|
|
388
|
+
passthrough — the envelope's command identity is the INNER command
|
|
389
|
+
(argv[0]), never the op name. Consumers validate ``meta.command``
|
|
390
|
+
(doctor/cache expect "doctor", "ps", ...); the transport must not
|
|
391
|
+
rewrite command identity. Symptom before: the doctor dialog died
|
|
392
|
+
with "unexpected command: cli"."""
|
|
393
|
+
if op == "cli":
|
|
394
|
+
argv = [str(a) for a in (payload.get("argv") or [])]
|
|
395
|
+
if argv:
|
|
396
|
+
return argv[0]
|
|
397
|
+
return op
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
# Uniform handler signature (ns, payload, runtime) — R2's stream ops
|
|
401
|
+
# need the payload dict and the PTY registry; plain ops ignore them.
|
|
402
|
+
data, exit_code, errors = handler(ns, payload, runtime)
|
|
403
|
+
envelope = build_envelope(command=_command_name(), exit_code=exit_code,
|
|
404
|
+
version=_cli_version(), data=data, errors=errors)
|
|
405
|
+
return {"id": None, "type": "result", "ok": True, "envelope": envelope}
|
|
406
|
+
except CliError as exc:
|
|
407
|
+
envelope = build_envelope(
|
|
408
|
+
command=_command_name(),
|
|
409
|
+
exit_code=exc.exit_code,
|
|
410
|
+
version=_cli_version(),
|
|
411
|
+
errors=[{
|
|
412
|
+
"code": exc.error_code,
|
|
413
|
+
"message": exc.message,
|
|
414
|
+
**({"hint": exc.hint} if exc.hint else {}),
|
|
415
|
+
}],
|
|
416
|
+
)
|
|
417
|
+
return {"id": None, "type": "result", "ok": True, "envelope": envelope}
|
|
418
|
+
except NotImplementedError as exc:
|
|
419
|
+
# PTY transport on a CLI-only backend: an honest refusal, not a crash.
|
|
420
|
+
return {
|
|
421
|
+
"id": None,
|
|
422
|
+
"type": "result",
|
|
423
|
+
"ok": False,
|
|
424
|
+
"error": str(exc),
|
|
425
|
+
}
|
|
426
|
+
except Exception as exc: # noqa: BLE001 — serve must survive bad ops
|
|
427
|
+
return {
|
|
428
|
+
"id": None,
|
|
429
|
+
"type": "result",
|
|
430
|
+
"ok": False,
|
|
431
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _cli_version() -> str:
|
|
436
|
+
from aisc import __version__
|
|
437
|
+
|
|
438
|
+
return __version__
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
442
|
+
"""Entry point. Only ``--stdio`` is supported — the TCP transport needs
|
|
443
|
+
its own security ruling (D-7) and does not exist."""
|
|
444
|
+
if not getattr(args, "stdio", False):
|
|
445
|
+
from aisc.domain.models import CliError as _CE
|
|
446
|
+
|
|
447
|
+
raise _CE(
|
|
448
|
+
message="serve requires --stdio (the only transport in this version)",
|
|
449
|
+
exit_code=2,
|
|
450
|
+
error_code="AISC_ERR_USAGE",
|
|
451
|
+
hint="Remote callers spawn: ssh <host> aisc serve --stdio",
|
|
452
|
+
)
|
|
453
|
+
# 2.1.11 step2: the wire contract is UTF-8 JSON-lines in BOTH
|
|
454
|
+
# directions. stdout is ASCII-safe via _frame's escaping, but the CLIENT
|
|
455
|
+
# writes unescaped UTF-8 (serde_json does not ASCII-escape) — a CJK
|
|
456
|
+
# workspace path in argv would hit a GBK stdin decoder on zh-CN Windows.
|
|
457
|
+
# Reconfigure the real handles where the wrapper allows it (PyInstaller
|
|
458
|
+
# wrappers may not expose reconfigure; the try/except is deliberate).
|
|
459
|
+
for handle in (sys.stdin, sys.stdout):
|
|
460
|
+
try:
|
|
461
|
+
handle.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
462
|
+
except (AttributeError, ValueError, OSError):
|
|
463
|
+
pass
|
|
464
|
+
return _serve_loop(sys.stdin, sys.stdout)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _serve_loop(stdin: Any, stdout: Any) -> int:
|
|
468
|
+
from aisc import __version__
|
|
469
|
+
|
|
470
|
+
stop = {"flag": False}
|
|
471
|
+
runtime = ServeRuntime(stdout)
|
|
472
|
+
|
|
473
|
+
def _stop(_sig: int, _frm: Any = None) -> None:
|
|
474
|
+
stop["flag"] = True
|
|
475
|
+
|
|
476
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
477
|
+
try:
|
|
478
|
+
signal.signal(sig, _stop)
|
|
479
|
+
except ValueError:
|
|
480
|
+
pass # non-main thread (tests): signals unavailable, EOF still ends us
|
|
481
|
+
|
|
482
|
+
# Ready banner — the client's version-pairing handshake (VS Code's
|
|
483
|
+
# commit-match equivalent): it can refuse the connection before any op.
|
|
484
|
+
# `home` (v1.3) anchors the remote-side directory browser (F2-B).
|
|
485
|
+
runtime._emit({
|
|
486
|
+
"type": "ready",
|
|
487
|
+
"serve_protocol": SERVE_PROTOCOL,
|
|
488
|
+
"cli_version": __version__,
|
|
489
|
+
"home": os.path.expanduser("~"),
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
while not stop["flag"]:
|
|
493
|
+
line = stdin.readline()
|
|
494
|
+
if not line: # EOF — the ssh session went away
|
|
495
|
+
break
|
|
496
|
+
line = line.strip()
|
|
497
|
+
if not line:
|
|
498
|
+
continue
|
|
499
|
+
frame_in: Dict[str, Any]
|
|
500
|
+
try:
|
|
501
|
+
frame_in = json.loads(line)
|
|
502
|
+
if not isinstance(frame_in, dict):
|
|
503
|
+
raise ValueError("frame must be a JSON object")
|
|
504
|
+
except ValueError as exc:
|
|
505
|
+
runtime._emit({"id": None, "type": "result", "ok": False,
|
|
506
|
+
"error": f"bad frame: {exc}"})
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
ftype = frame_in.get("type")
|
|
510
|
+
|
|
511
|
+
# Stream-control frames (R2): fast, no result frame.
|
|
512
|
+
if ftype == "pty.input":
|
|
513
|
+
err = runtime.pty_input(str(frame_in.get("sid") or ""),
|
|
514
|
+
str(frame_in.get("data") or ""))
|
|
515
|
+
if err:
|
|
516
|
+
runtime._emit({"type": "log", "level": "error", "line": err})
|
|
517
|
+
continue
|
|
518
|
+
if ftype == "pty.resize":
|
|
519
|
+
err = runtime.pty_resize(str(frame_in.get("sid") or ""),
|
|
520
|
+
frame_in.get("cols") or DEFAULT_COLS,
|
|
521
|
+
frame_in.get("rows") or DEFAULT_ROWS)
|
|
522
|
+
if err:
|
|
523
|
+
runtime._emit({"type": "log", "level": "error", "line": err})
|
|
524
|
+
continue
|
|
525
|
+
if ftype == "pty.kill":
|
|
526
|
+
runtime.pty_kill(str(frame_in.get("sid") or ""))
|
|
527
|
+
continue
|
|
528
|
+
|
|
529
|
+
# Request frame: {"id", "op", "args"} — args is the payload dict.
|
|
530
|
+
op = frame_in.get("op")
|
|
531
|
+
if not isinstance(op, str):
|
|
532
|
+
runtime._emit({"id": frame_in.get("id"), "type": "result", "ok": False,
|
|
533
|
+
"error": "frame requires a string 'op'"})
|
|
534
|
+
continue
|
|
535
|
+
payload = frame_in.get("args")
|
|
536
|
+
if not isinstance(payload, dict):
|
|
537
|
+
payload = {}
|
|
538
|
+
response = _run_op(op, payload, runtime)
|
|
539
|
+
response["id"] = frame_in.get("id")
|
|
540
|
+
runtime._emit(response)
|
|
541
|
+
|
|
542
|
+
runtime.kill_all()
|
|
543
|
+
runtime.fs_watches.shutdown()
|
|
544
|
+
return 0
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def print_serve_text(subcommand: str, data: Any, errors: list) -> None: # pragma: no cover
|
|
548
|
+
"""Text rendering for serve is intentionally minimal: serve is a machine
|
|
549
|
+
protocol (RFC-adjacent); the interactive CLI should not reach here."""
|
|
550
|
+
print(json.dumps({"data": data, "errors": errors}, ensure_ascii=False))
|