agmx 0.1.0__tar.gz

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.
agmx-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: agmx
3
+ Version: 0.1.0
4
+ Summary: Chạy việc của AgentMatrix ngay trên máy bạn, bằng phiên đăng nhập CLI của chính bạn.
5
+ Author: lupca
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/lupca/AgentMatrix
8
+ Project-URL: Source, https://github.com/lupca/AgentMatrix/tree/main/runner
9
+ Keywords: agmx,agentmatrix,coding-agent,runner
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Build Tools
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: httpx>=0.24
18
+
19
+ # agmx
20
+
21
+ Chạy việc của AgentMatrix ngay trên **máy bạn**, bằng phiên đăng nhập CLI của
22
+ chính bạn. Gói này chỉ cần Python và `httpx`: không Postgres, không Redis,
23
+ không `DATABASE_URL` của control plane.
24
+
25
+ ## Cài
26
+
27
+ ```bash
28
+ pipx install ./runner # hoặc: pipx install agmx
29
+ ```
30
+
31
+ ## Dùng
32
+
33
+ ```bash
34
+ agmx login # hỏi email + mật khẩu, cất token
35
+ agmx runner start --project agmx=~/code/AgentMatrix
36
+ ```
37
+
38
+ `--project` lặp lại được nhiều lần. Server **chỉ** gửi `project_id`; đường dẫn
39
+ thật do máy này quyết định, và một `project_id` chưa đăng ký ở đây bị báo
40
+ `failed` chứ không bao giờ bị đoán thành một thư mục nào đó.
41
+
42
+ ## Cấu hình ở đâu
43
+
44
+ | File | Quyền | Chứa gì |
45
+ |---|---|---|
46
+ | `~/.agmx/.credentials.json` | `0600` | token đăng nhập |
47
+ | `~/.agmx/settings.json` | `0644` | `server`, `email`, `runner_id` |
48
+
49
+ `AGMX_CONFIG_DIR` đổi được thư mục. File `config.json` một-cục của bản cũ vẫn
50
+ đọc được, và được dọn đi ở lần ghi kế tiếp.
51
+
52
+ ## Một run chạy như thế nào
53
+
54
+ Mỗi run được cấp một `git worktree` riêng trên nhánh `ct-run/<run_id>`, cắt từ
55
+ HEAD hiện tại. Lệnh chạy trong worktree đó, output đẩy về server theo mẻ trong
56
+ lúc đang chạy. Xong thì `git add -A` + commit, gỡ worktree nhưng **giữ nhánh**:
57
+ commit sống tiếp trong repo chính.
58
+
59
+ Runner **không phán xét**: nó báo mã thoát, `base_sha`, `head_sha`, có quá giờ
60
+ hay không. Ai thành ai bại là việc của server.
agmx-0.1.0/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # agmx
2
+
3
+ Chạy việc của AgentMatrix ngay trên **máy bạn**, bằng phiên đăng nhập CLI của
4
+ chính bạn. Gói này chỉ cần Python và `httpx`: không Postgres, không Redis,
5
+ không `DATABASE_URL` của control plane.
6
+
7
+ ## Cài
8
+
9
+ ```bash
10
+ pipx install ./runner # hoặc: pipx install agmx
11
+ ```
12
+
13
+ ## Dùng
14
+
15
+ ```bash
16
+ agmx login # hỏi email + mật khẩu, cất token
17
+ agmx runner start --project agmx=~/code/AgentMatrix
18
+ ```
19
+
20
+ `--project` lặp lại được nhiều lần. Server **chỉ** gửi `project_id`; đường dẫn
21
+ thật do máy này quyết định, và một `project_id` chưa đăng ký ở đây bị báo
22
+ `failed` chứ không bao giờ bị đoán thành một thư mục nào đó.
23
+
24
+ ## Cấu hình ở đâu
25
+
26
+ | File | Quyền | Chứa gì |
27
+ |---|---|---|
28
+ | `~/.agmx/.credentials.json` | `0600` | token đăng nhập |
29
+ | `~/.agmx/settings.json` | `0644` | `server`, `email`, `runner_id` |
30
+
31
+ `AGMX_CONFIG_DIR` đổi được thư mục. File `config.json` một-cục của bản cũ vẫn
32
+ đọc được, và được dọn đi ở lần ghi kế tiếp.
33
+
34
+ ## Một run chạy như thế nào
35
+
36
+ Mỗi run được cấp một `git worktree` riêng trên nhánh `ct-run/<run_id>`, cắt từ
37
+ HEAD hiện tại. Lệnh chạy trong worktree đó, output đẩy về server theo mẻ trong
38
+ lúc đang chạy. Xong thì `git add -A` + commit, gỡ worktree nhưng **giữ nhánh**:
39
+ commit sống tiếp trong repo chính.
40
+
41
+ Runner **không phán xét**: nó báo mã thoát, `base_sha`, `head_sha`, có quá giờ
42
+ hay không. Ai thành ai bại là việc của server.
@@ -0,0 +1,7 @@
1
+ """``agmx`` -- chạy việc của AgentMatrix ngay trên máy bạn.
2
+
3
+ Gói này cài trên máy NGƯỜI DÙNG: chỉ stdlib + httpx, không Postgres, không
4
+ Redis, không config của server, và tuyệt đối không ``DATABASE_URL``.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,354 @@
1
+ """``agmx`` -- công cụ dòng lệnh chạy trên MÁY CỦA NGƯỜI DÙNG.
2
+
3
+ Hai việc duy nhất:
4
+
5
+ * ``agmx login`` -- đổi email/mật khẩu lấy token, cất vào ``~/.agmx``.
6
+ * ``agmx runner start`` -- nhận việc từ server rồi thực thi ngay tại đây, bằng
7
+ phiên đăng nhập CLI của chính người dùng này.
8
+
9
+ Điểm mấu chốt của thiết kế: **server không nói đường dẫn**. Nó chỉ nói
10
+ ``project_id``; máy này tự tra ra thư mục của mình từ danh sách ``--project``
11
+ đã đăng ký. Một ``project_id`` lạ bị báo ``failed`` chứ không bao giờ được đoán
12
+ thành một đường dẫn nào đó.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import getpass
19
+ import os
20
+ import socket
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+
25
+ import httpx
26
+
27
+ from . import config, executor
28
+ from .client import DEFAULT_SERVER, Client
29
+
30
+ DEFAULT_POLL_SECONDS = 5
31
+
32
+ #: Mã lỗi server trả về -> câu người đọc hiểu được.
33
+ _LOGIN_ERRORS = {
34
+ "invalid_credentials": "email hoặc mật khẩu không đúng.",
35
+ "locked": "tài khoản đang bị khoá (sai mật khẩu quá nhiều lần).",
36
+ "disabled": "tài khoản đã bị vô hiệu hoá.",
37
+ "invalid_request": "server từ chối yêu cầu (thiếu email hoặc mật khẩu).",
38
+ }
39
+
40
+
41
+ def _sleep(seconds: float) -> None:
42
+ """Chỗ duy nhất runner ngủ -- test thay nó để vòng lặp chạy tức thì."""
43
+ time.sleep(seconds)
44
+
45
+
46
+ # --------------------------------------------------------------------------- #
47
+ # agmx login
48
+ # --------------------------------------------------------------------------- #
49
+
50
+ def cmd_login(args: argparse.Namespace) -> int:
51
+ cfg = config.load()
52
+ server = args.server or cfg.get("server") or os.environ.get("AGMX_SERVER") or DEFAULT_SERVER
53
+
54
+ email = (args.email or input("Email: ")).strip()
55
+ if not email:
56
+ print("Chưa nhập email, dừng lại.", file=sys.stderr)
57
+ return 1
58
+ # Mật khẩu CHỈ đọc từ TTY: một cờ --password nằm lại trong lịch sử shell và
59
+ # trong argv của tiến trình, ai chạy `ps` cũng đọc được.
60
+ password = getpass.getpass("Mật khẩu: ")
61
+ if not password:
62
+ print("Chưa nhập mật khẩu, dừng lại.", file=sys.stderr)
63
+ return 1
64
+
65
+ try:
66
+ status, body = Client(server).login(email, password)
67
+ except httpx.HTTPError as exc:
68
+ print(f"Không gọi được server {server}: {exc}", file=sys.stderr)
69
+ return 1
70
+
71
+ if status != 200 or not body.get("ok") or not body.get("token"):
72
+ code = str(body.get("error") or f"HTTP {status}")
73
+ print(f"Đăng nhập thất bại: {_LOGIN_ERRORS.get(code, code)}", file=sys.stderr)
74
+ return 1
75
+
76
+ # Ghi cấu hình MỚI, không vá lên cái cũ: đăng nhập là một phiên khác, và
77
+ # runner_id của phiên trước có thể thuộc về người khác.
78
+ config.save({
79
+ "server": server,
80
+ "token": body["token"],
81
+ "expires_at": body.get("expires_at"),
82
+ "email": email,
83
+ })
84
+ name = (body.get("user") or {}).get("name") or email
85
+ print(f"Đã đăng nhập {name} <{email}> tại {server}.")
86
+ print(f"Token cất ở {config.credentials_path()} (quyền 0600).")
87
+ return 0
88
+
89
+
90
+ # --------------------------------------------------------------------------- #
91
+ # agmx runner start
92
+ # --------------------------------------------------------------------------- #
93
+
94
+ def parse_projects(pairs: list[str]) -> dict[str, str]:
95
+ """``["agmx=/repo"]`` -> ``{"agmx": "/repo"}``. Đây là bản đồ duy nhất từ
96
+ ``project_id`` ra đường dẫn thật, và nó nằm ở phía máy người dùng."""
97
+ projects: dict[str, str] = {}
98
+ for raw in pairs:
99
+ project_id, separator, local_path = raw.partition("=")
100
+ project_id, local_path = project_id.strip(), local_path.strip()
101
+ if not separator or not project_id or not local_path:
102
+ raise ValueError(
103
+ f"--project phải có dạng PROJECT_ID=/đường/dẫn, nhận được: {raw!r}"
104
+ )
105
+ projects[project_id] = str(Path(local_path).expanduser().resolve())
106
+ return projects
107
+
108
+
109
+ def _enroll(client: Client, projects: dict[str, str]) -> str | None:
110
+ """Đăng ký máy này với server. Trả ``runner_id``, hoặc ``None`` nếu hỏng."""
111
+ try:
112
+ status, body = client.enroll(socket.gethostname(), projects)
113
+ except httpx.HTTPError as exc:
114
+ print(f"Không đăng ký được runner với {client.server}: {exc}", file=sys.stderr)
115
+ return None
116
+ if status != 200 or not body.get("ok") or not body.get("runner_id"):
117
+ print(
118
+ f"Server từ chối đăng ký runner (HTTP {status}): {body.get('error') or body}",
119
+ file=sys.stderr,
120
+ )
121
+ return None
122
+
123
+ runner_id = str(body["runner_id"])
124
+ config.save({**config.load(), "runner_id": runner_id})
125
+ return runner_id
126
+
127
+
128
+ def _report(client: Client, runner_id: str, run_id: str, status: str, **facts) -> None:
129
+ try:
130
+ code, body = client.result(runner_id, run_id, status, **facts)
131
+ except httpx.HTTPError as exc:
132
+ # Mất kết quả của một run thì tiếc, nhưng giết cả runner thì tệ hơn.
133
+ print(f"Không báo được kết quả run {run_id} về server: {exc}", file=sys.stderr)
134
+ return
135
+ if code != 200 or not body.get("ok"):
136
+ # Im lặng ở đây nghĩa là server vẫn nghĩ run đang chạy, còn máy này thì
137
+ # đã quên nó -- nói ra để người dùng còn biết mà tra.
138
+ print(
139
+ f"Server không nhận kết quả run {run_id} (HTTP {code}): "
140
+ f"{body.get('error') or body}",
141
+ file=sys.stderr,
142
+ )
143
+
144
+
145
+ def _output_sink(client: Client, runner_id: str, run_id: str):
146
+ def send(seq: int, stream: str, content: str) -> None:
147
+ try:
148
+ client.output(runner_id, run_id, seq, stream, content)
149
+ except httpx.HTTPError as exc:
150
+ # Mất một mẻ log không phải lý do để bỏ dở việc đang chạy.
151
+ print(f"Không đẩy được output run {run_id} (mẻ {seq}): {exc}", file=sys.stderr)
152
+
153
+ return send
154
+
155
+
156
+ def _handle_run(client: Client, runner_id: str, projects: dict[str, str], run: dict) -> None:
157
+ run_id = str(run.get("run_id") or "")
158
+ task_id = str(run.get("task_id") or "")
159
+ project_id = run.get("project_id")
160
+
161
+ local_path = projects.get(project_id)
162
+ if local_path is None:
163
+ known = ", ".join(sorted(projects)) or "(chưa đăng ký project nào)"
164
+ reason = (
165
+ f"project_id {project_id!r} chưa đăng ký trên máy này; "
166
+ f"runner chỉ nhận việc cho: {known}"
167
+ )
168
+ print(f"Bỏ qua run {run_id}: {reason}", file=sys.stderr)
169
+ _report(client, runner_id, run_id, "failed", error=reason)
170
+ return
171
+
172
+ print(f"Nhận run {run_id} (task {task_id}), project {project_id} -> {local_path}")
173
+ try:
174
+ outcome = executor.execute(
175
+ local_path=local_path,
176
+ run_id=run_id,
177
+ command=str(run.get("command") or ""),
178
+ timeout_seconds=float(run.get("timeout_seconds") or 900),
179
+ on_output=_output_sink(client, runner_id, run_id),
180
+ )
181
+ except Exception as exc: # kể cả lỗi lạ: im lặng nuốt là mất hẳn một run
182
+ reason = f"thực thi thất bại: {type(exc).__name__}: {exc}"
183
+ print(f"Run {run_id} lỗi: {reason}", file=sys.stderr)
184
+ _report(client, runner_id, run_id, "failed", error=reason)
185
+ return
186
+
187
+ # ``status`` là bắt buộc trong hợp đồng, nên nó được nói lại một cách MÁY
188
+ # MÓC từ mã thoát: không đọc output, không suy diễn, không dựng result_ref.
189
+ # Sự việc thô đi kèm ngay bên cạnh để server tự phán.
190
+ if outcome["timed_out"]:
191
+ status = "timeout"
192
+ else:
193
+ status = "success" if outcome["exit_code"] == 0 else "failed"
194
+ print(f"Run {run_id} kết thúc: {status} (mã thoát {outcome['exit_code']}).")
195
+ _report(client, runner_id, run_id, status, **outcome)
196
+
197
+
198
+ def poll_loop(
199
+ client: Client,
200
+ runner_id: str,
201
+ projects: dict[str, str],
202
+ poll_seconds: float = DEFAULT_POLL_SECONDS,
203
+ max_iterations: int | None = None,
204
+ ) -> None:
205
+ """Vòng heartbeat -> lease -> thực thi -> result, tới khi Ctrl-C.
206
+
207
+ ``max_iterations`` chỉ để test không chạy vô hạn.
208
+ """
209
+ iterations = 0
210
+ while max_iterations is None or iterations < max_iterations:
211
+ iterations += 1
212
+ try:
213
+ status, body = client.heartbeat(runner_id)
214
+ if status == 404 and body.get("error") == "unknown_runner":
215
+ # Server không còn biết runner này. Đăng ký lại, chứ poll mãi
216
+ # vào một id đã chết thì runner sống mà không nhận được việc.
217
+ print("Server không còn nhớ runner này, đăng ký lại.", file=sys.stderr)
218
+ new_id = _enroll(client, projects)
219
+ if new_id is None:
220
+ _sleep(poll_seconds)
221
+ continue
222
+ runner_id = new_id
223
+
224
+ status, body = client.lease(runner_id)
225
+ if status != 200 or not body.get("ok"):
226
+ print(
227
+ f"Server từ chối cấp việc (HTTP {status}): {body.get('error') or body}",
228
+ file=sys.stderr,
229
+ )
230
+ _sleep(poll_seconds)
231
+ continue
232
+
233
+ run = body.get("run")
234
+ if not run:
235
+ _sleep(poll_seconds)
236
+ continue
237
+ except httpx.HTTPError as exc:
238
+ # Máy người dùng rớt mạng là chuyện thường ngày, không phải lý do
239
+ # để runner chết.
240
+ print(f"Lỗi mạng: {exc}. Thử lại sau {poll_seconds}s.", file=sys.stderr)
241
+ _sleep(poll_seconds)
242
+ continue
243
+
244
+ _handle_run(client, runner_id, projects, run)
245
+ # Vừa có việc thì nhiều khả năng còn việc nữa: lease tiếp, đừng ngủ.
246
+
247
+
248
+ def cmd_runner_start(args: argparse.Namespace) -> int:
249
+ cfg = config.load()
250
+ token = cfg.get("token")
251
+ if not token:
252
+ print("Chưa đăng nhập, chạy `agmx login` trước.", file=sys.stderr)
253
+ return 1
254
+ server = args.server or cfg.get("server") or DEFAULT_SERVER
255
+
256
+ try:
257
+ projects = parse_projects(args.project)
258
+ except ValueError as exc:
259
+ print(str(exc), file=sys.stderr)
260
+ return 1
261
+ for project_id, local_path in sorted(projects.items()):
262
+ if not Path(local_path).is_dir():
263
+ print(
264
+ f"Cảnh báo: {project_id} trỏ tới {local_path} nhưng đó không "
265
+ f"phải thư mục đang tồn tại.",
266
+ file=sys.stderr,
267
+ )
268
+
269
+ client = Client(server, token)
270
+ runner_id = _enroll(client, projects)
271
+ if runner_id is None:
272
+ return 1
273
+
274
+ print(
275
+ f"Runner {runner_id} chạy trên {socket.gethostname()}, "
276
+ f"hỏi việc {server} mỗi {args.poll_seconds}s. Ctrl-C để dừng."
277
+ )
278
+ poll_loop(client, runner_id, projects, args.poll_seconds)
279
+ return 0
280
+
281
+
282
+ # --------------------------------------------------------------------------- #
283
+ # Vào cửa
284
+ # --------------------------------------------------------------------------- #
285
+
286
+ def build_parser() -> argparse.ArgumentParser:
287
+ parser = argparse.ArgumentParser(
288
+ prog="agmx",
289
+ description=(
290
+ "agmx -- chạy việc của AgentMatrix ngay trên máy bạn, bằng phiên "
291
+ "đăng nhập CLI của chính bạn."
292
+ ),
293
+ )
294
+ subparsers = parser.add_subparsers(dest="command", metavar="LỆNH")
295
+
296
+ login = subparsers.add_parser(
297
+ "login",
298
+ help="đăng nhập vào server và cất token vào ~/.agmx/.credentials.json",
299
+ description="Đăng nhập. Mật khẩu chỉ nhập từ bàn phím, không có cờ --password.",
300
+ )
301
+ login.add_argument("--server", help=f"địa chỉ server (mặc định {DEFAULT_SERVER})")
302
+ login.add_argument("--email", help="email đăng nhập (bỏ trống thì sẽ hỏi)")
303
+ login.set_defaults(func=cmd_login)
304
+
305
+ runner = subparsers.add_parser(
306
+ "runner",
307
+ help="điều khiển runner trên máy này",
308
+ description="Runner: nhận việc từ server rồi chạy ngay tại máy này.",
309
+ )
310
+ runner_sub = runner.add_subparsers(dest="runner_command", metavar="LỆNH")
311
+ start = runner_sub.add_parser(
312
+ "start",
313
+ help="đăng ký máy này rồi nhận việc liên tục",
314
+ description=(
315
+ "Đăng ký máy này với server rồi lặp: xin việc, chạy, báo kết quả. "
316
+ "Server chỉ gửi project_id; đường dẫn thật do máy này quyết định."
317
+ ),
318
+ )
319
+ start.add_argument(
320
+ "--project",
321
+ action="append",
322
+ default=[],
323
+ required=True,
324
+ metavar="PROJECT_ID=/đường/dẫn",
325
+ help="project máy này nhận việc, lặp lại được nhiều lần",
326
+ )
327
+ start.add_argument(
328
+ "--poll-seconds",
329
+ type=float,
330
+ default=DEFAULT_POLL_SECONDS,
331
+ help=f"nghỉ bao lâu giữa hai lần hỏi việc (mặc định {DEFAULT_POLL_SECONDS})",
332
+ )
333
+ start.add_argument("--server", help="ghi đè địa chỉ server đã lưu khi đăng nhập")
334
+ start.set_defaults(func=cmd_runner_start)
335
+ runner.set_defaults(func=lambda _args: (runner.print_help(), 1)[1])
336
+
337
+ return parser
338
+
339
+
340
+ def main(argv: list[str] | None = None) -> int:
341
+ parser = build_parser()
342
+ args = parser.parse_args(argv)
343
+ if not getattr(args, "func", None):
344
+ parser.print_help()
345
+ return 1
346
+ try:
347
+ return args.func(args)
348
+ except KeyboardInterrupt:
349
+ print("\nĐã dừng. Tạm biệt.")
350
+ return 0
351
+
352
+
353
+ if __name__ == "__main__":
354
+ sys.exit(main())
@@ -0,0 +1,65 @@
1
+ """Các route HTTP máy này nói với control plane. Chỉ httpx, không gì khác."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ # Control plane công khai. HTTPS không phải trang trí: ``agmx login`` đặt mật
8
+ # khẩu vào thân yêu cầu, http trần trên internet là đưa nó cho cả đường dây.
9
+ DEFAULT_SERVER = "https://agmx.nothanagentic.vn"
10
+ HTTP_TIMEOUT = 30.0
11
+
12
+
13
+ class Client:
14
+ """Một phiên nói chuyện với server. ``token`` rỗng thì chỉ login được."""
15
+
16
+ def __init__(self, server: str | None, token: str | None = None, timeout: float = HTTP_TIMEOUT):
17
+ self.server = (server or DEFAULT_SERVER).rstrip("/")
18
+ self.token = token
19
+ self._http = httpx.Client(timeout=timeout)
20
+
21
+ def post(self, path: str, payload: dict) -> tuple[int, dict]:
22
+ """POST JSON, trả ``(status_code, body)``. Ném ``httpx.HTTPError`` khi mạng hỏng."""
23
+ headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
24
+ response = self._http.post(self.server + path, json=payload, headers=headers)
25
+ try:
26
+ body = response.json()
27
+ except ValueError:
28
+ body = {}
29
+ return response.status_code, body if isinstance(body, dict) else {}
30
+
31
+ # -- 5 route -------------------------------------------------------- #
32
+
33
+ def login(self, email: str, password: str) -> tuple[int, dict]:
34
+ return self.post("/auth/login", {"email": email, "password": password})
35
+
36
+ def enroll(self, hostname: str, projects: dict[str, str]) -> tuple[int, dict]:
37
+ return self.post("/runner/enroll", {
38
+ "hostname": hostname,
39
+ "projects": [
40
+ {"project_id": project_id, "local_path": local_path}
41
+ for project_id, local_path in sorted(projects.items())
42
+ ],
43
+ })
44
+
45
+ def heartbeat(self, runner_id: str) -> tuple[int, dict]:
46
+ return self.post("/runner/heartbeat", {"runner_id": runner_id})
47
+
48
+ def lease(self, runner_id: str) -> tuple[int, dict]:
49
+ return self.post("/runner/lease", {"runner_id": runner_id})
50
+
51
+ def output(self, runner_id: str, run_id: str, seq: int, stream: str, content: str):
52
+ return self.post("/runner/output", {
53
+ "runner_id": runner_id,
54
+ "run_id": run_id,
55
+ "seq": seq,
56
+ "stream": stream,
57
+ "content": content,
58
+ })
59
+
60
+ def result(self, runner_id: str, run_id: str, status: str, **facts) -> tuple[int, dict]:
61
+ """Báo run đã kết thúc. ``facts``: exit_code, base_sha, head_sha,
62
+ timed_out, error -- những gì máy này QUAN SÁT được, không phải kết luận."""
63
+ payload = {"runner_id": runner_id, "run_id": run_id, "status": status}
64
+ payload.update(facts)
65
+ return self.post("/runner/result", payload)
@@ -0,0 +1,82 @@
1
+ """Cấu hình của ``agmx``: bí mật một nơi, phần còn lại một nơi khác.
2
+
3
+ ``~/.agmx/.credentials.json`` (0600) giữ token; ``~/.agmx/settings.json``
4
+ (0644) giữ những thứ không phải bí mật (server, runner_id, email). Tách ra
5
+ theo đúng cách ``~/.claude`` làm: settings là thứ người ta mở ra xem, sửa, dán
6
+ vào issue; token thì không. Gộp chung một file nghĩa là mỗi lần khoe cấu hình
7
+ là một lần lộ phiên đăng nhập.
8
+
9
+ ``config.json`` kiểu cũ vẫn đọc được, để máy đã cài không phải đăng nhập lại.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+
18
+ #: Những khoá là BÍ MẬT. Chỉ chúng mới được vào file 0600, và mọi khoá khác
19
+ #: đều ra file 0644 -- danh sách này là ranh giới, không phải gợi ý.
20
+ SECRET_KEYS = ("token", "expires_at")
21
+
22
+
23
+ def config_dir() -> Path:
24
+ """Thư mục config. ``AGMX_CONFIG_DIR`` đổi được (test không đụng ``~`` thật)."""
25
+ return Path(os.environ.get("AGMX_CONFIG_DIR") or (Path.home() / ".agmx"))
26
+
27
+
28
+ def credentials_path() -> Path:
29
+ return config_dir() / ".credentials.json"
30
+
31
+
32
+ def settings_path() -> Path:
33
+ return config_dir() / "settings.json"
34
+
35
+
36
+ def legacy_path() -> Path:
37
+ """File một-cục của bản cũ. Chỉ còn được ĐỌC, không bao giờ được ghi nữa."""
38
+ return config_dir() / "config.json"
39
+
40
+
41
+ def _read(path: Path) -> dict:
42
+ try:
43
+ data = json.loads(path.read_text(encoding="utf-8"))
44
+ except (OSError, ValueError):
45
+ return {}
46
+ return data if isinstance(data, dict) else {}
47
+
48
+
49
+ def load() -> dict:
50
+ """Cấu hình đã lưu: file cũ làm nền, file mới đè lên."""
51
+ merged = _read(legacy_path())
52
+ merged.update(_read(settings_path()))
53
+ merged.update(_read(credentials_path()))
54
+ return merged
55
+
56
+
57
+ def _write(path: Path, data: dict, mode: int) -> None:
58
+ """Ghi với quyền đặt TRƯỚC khi có nội dung.
59
+
60
+ ``open()`` rồi ``chmod()`` để lại một khoảng thời gian token nằm trên đĩa
61
+ với quyền mặc định; ai đọc được trong khoảng đó thì đọc được phiên đăng
62
+ nhập. ``os.open`` mang sẵn mode, và ``fchmod`` ép đúng quyền kể cả khi file
63
+ đã tồn tại sẵn với quyền rộng hơn (``O_CREAT`` không sửa quyền file cũ) hay
64
+ khi umask của máy cắt bớt.
65
+ """
66
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
67
+ os.fchmod(fd, mode)
68
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
69
+ handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
70
+
71
+
72
+ def save(data: dict) -> None:
73
+ """Cất cấu hình, tự tách bí mật ra khỏi phần còn lại."""
74
+ directory = config_dir()
75
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
76
+ os.chmod(directory, 0o700) # mkdir(mode=) im lặng bỏ qua khi thư mục đã có
77
+
78
+ _write(credentials_path(), {k: data[k] for k in SECRET_KEYS if k in data}, 0o600)
79
+ _write(settings_path(), {k: v for k, v in data.items() if k not in SECRET_KEYS}, 0o644)
80
+ # Bỏ file cũ đi: token của nó không còn ai đọc nữa nhưng vẫn ai cũng đọc
81
+ # được. Một bản sao bí mật nằm lì trên đĩa là bí mật bị lộ, chỉ chậm hơn.
82
+ legacy_path().unlink(missing_ok=True)