@follenfang/fupload 0.0.0-bootstrap.0 → 0.0.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 (43) hide show
  1. package/README.md +208 -3
  2. package/fupload/SKILL.md +129 -0
  3. package/fupload/agents/openai.yaml +4 -0
  4. package/fupload/examples/dd-config-delete.json +5 -0
  5. package/fupload/examples/dd-config-update.json +25 -0
  6. package/fupload/examples/dd-plugin-delete.json +5 -0
  7. package/fupload/examples/dd-plugin-update.json +9 -0
  8. package/fupload/examples/dd-wa-delete.json +5 -0
  9. package/fupload/examples/dd-wa-edit.json +9 -0
  10. package/fupload/examples/newbee-config-delete.json +5 -0
  11. package/fupload/examples/newbee-config-update.json +10 -0
  12. package/fupload/examples/newbee-plugin-create.json +14 -0
  13. package/fupload/examples/newbee-plugin-delete.json +5 -0
  14. package/fupload/examples/newbee-wa-delete.json +5 -0
  15. package/fupload/examples/newbee-wa-update.json +8 -0
  16. package/fupload/references/dd.md +105 -0
  17. package/fupload/references/newbee-official-cli.md +288 -0
  18. package/fupload/references/newbee.md +80 -0
  19. package/fupload/references/workflow.md +67 -0
  20. package/fupload/scripts/fupload.py +17 -0
  21. package/fupload/scripts/fupload_cli/__init__.py +3 -0
  22. package/fupload/scripts/fupload_cli/cli.py +266 -0
  23. package/fupload/scripts/fupload_cli/dd.py +2406 -0
  24. package/fupload/scripts/fupload_cli/dd_broker.py +634 -0
  25. package/fupload/scripts/fupload_cli/dd_sidecar.py +860 -0
  26. package/fupload/scripts/fupload_cli/errors.py +94 -0
  27. package/fupload/scripts/fupload_cli/io.py +125 -0
  28. package/fupload/scripts/fupload_cli/newbee.py +1412 -0
  29. package/fupload/scripts/fupload_cli/newbee_auth.py +135 -0
  30. package/fupload/scripts/fupload_cli/schema.py +539 -0
  31. package/fupload/scripts/fupload_cli/transport.py +125 -0
  32. package/fupload/scripts/fupload_cli/trust.py +207 -0
  33. package/npm/bin/fupload.mjs +90 -0
  34. package/npm/lib/managed-install.mjs +86 -0
  35. package/npm/lib/options.mjs +38 -0
  36. package/npm/lib/python.mjs +45 -0
  37. package/npm/lib/skill-installer.mjs +228 -0
  38. package/npm/lib/uninstall.mjs +211 -0
  39. package/npm/lib/update.mjs +99 -0
  40. package/npm/lib/versions.mjs +63 -0
  41. package/npm/postinstall.mjs +18 -0
  42. package/npm/skill-manifest.json +164 -0
  43. package/package.json +50 -6
@@ -0,0 +1,634 @@
1
+ """Task-scoped DD broker owning one native sidecar login."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import ctypes
7
+ import json
8
+ import os
9
+ import socket
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ import uuid
14
+ from pathlib import Path
15
+ from types import SimpleNamespace
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from .errors import FuploadError, redact
19
+ from .trust import verify_dd_executable
20
+
21
+
22
+ STATE_NAME = "broker.json"
23
+ STARTUP_NAME = "broker.starting.json"
24
+ MAX_REQUEST_BYTES = 64 * 1024 * 1024
25
+ IDLE_SECONDS = 10 * 60
26
+
27
+
28
+ def _dd_module():
29
+ from . import dd
30
+
31
+ return dd
32
+
33
+
34
+ def _state_dir() -> Path:
35
+ return _dd_module().state_dir()
36
+
37
+
38
+ def _atomic_json(path: Path, value: Dict[str, Any]) -> None:
39
+ temporary = path.with_name(path.name + ".tmp.%d" % os.getpid())
40
+ temporary.write_text(json.dumps(value, ensure_ascii=False, sort_keys=True), encoding="utf-8")
41
+ os.replace(str(temporary), str(path))
42
+
43
+
44
+ def _read_json(path: Path) -> Dict[str, Any]:
45
+ try:
46
+ value = json.loads(path.read_text(encoding="utf-8"))
47
+ except (OSError, ValueError) as exc:
48
+ raise FuploadError("DD broker state is unreadable", kind="session_error", stage="session") from exc
49
+ if not isinstance(value, dict):
50
+ raise FuploadError("DD broker state is invalid", kind="session_error", stage="session")
51
+ return value
52
+
53
+
54
+ def _pid_running(pid: int) -> bool:
55
+ if os.name != "nt" or pid <= 0:
56
+ return False
57
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
58
+ kernel32.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_bool, ctypes.c_ulong]
59
+ kernel32.OpenProcess.restype = ctypes.c_void_p
60
+ kernel32.GetExitCodeProcess.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
61
+ kernel32.GetExitCodeProcess.restype = ctypes.c_bool
62
+ kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
63
+ kernel32.CloseHandle.restype = ctypes.c_bool
64
+ handle = kernel32.OpenProcess(0x1000, False, pid)
65
+ if not handle:
66
+ return False
67
+ try:
68
+ code = ctypes.c_ulong()
69
+ return bool(kernel32.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259
70
+ finally:
71
+ kernel32.CloseHandle(handle)
72
+
73
+
74
+ def _top_level_windows(pid: int) -> List[int]:
75
+ if os.name != "nt":
76
+ return []
77
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
78
+ user32.GetWindowThreadProcessId.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
79
+ user32.GetWindowThreadProcessId.restype = ctypes.c_ulong
80
+ windows: List[int] = []
81
+ callback_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
82
+
83
+ def visit(hwnd: int, _param: int) -> bool:
84
+ owner = ctypes.c_ulong()
85
+ user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner))
86
+ if owner.value == pid:
87
+ windows.append(int(hwnd))
88
+ return True
89
+
90
+ callback = callback_type(visit)
91
+ user32.EnumWindows.argtypes = [callback_type, ctypes.c_void_p]
92
+ user32.EnumWindows.restype = ctypes.c_bool
93
+ user32.EnumWindows(callback, 0)
94
+ return windows
95
+
96
+
97
+ def running_dd_processes() -> List[Dict[str, Any]]:
98
+ if os.name != "nt":
99
+ return []
100
+ from ctypes import wintypes
101
+
102
+ class ProcessEntry(ctypes.Structure):
103
+ _fields_ = [
104
+ ("dwSize", wintypes.DWORD),
105
+ ("cntUsage", wintypes.DWORD),
106
+ ("th32ProcessID", wintypes.DWORD),
107
+ ("th32DefaultHeapID", ctypes.c_size_t),
108
+ ("th32ModuleID", wintypes.DWORD),
109
+ ("cntThreads", wintypes.DWORD),
110
+ ("th32ParentProcessID", wintypes.DWORD),
111
+ ("pcPriClassBase", ctypes.c_long),
112
+ ("dwFlags", wintypes.DWORD),
113
+ ("szExeFile", ctypes.c_wchar * 260),
114
+ ]
115
+
116
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
117
+ kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
118
+ kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
119
+ kernel32.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry)]
120
+ kernel32.Process32FirstW.restype = wintypes.BOOL
121
+ kernel32.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry)]
122
+ kernel32.Process32NextW.restype = wintypes.BOOL
123
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
124
+ kernel32.OpenProcess.restype = wintypes.HANDLE
125
+ kernel32.QueryFullProcessImageNameW.argtypes = [
126
+ wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD),
127
+ ]
128
+ kernel32.QueryFullProcessImageNameW.restype = wintypes.BOOL
129
+ kernel32.GetProcessTimes.argtypes = [
130
+ wintypes.HANDLE, ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME),
131
+ ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME),
132
+ ]
133
+ kernel32.GetProcessTimes.restype = wintypes.BOOL
134
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
135
+ kernel32.CloseHandle.restype = wintypes.BOOL
136
+
137
+ snapshot = kernel32.CreateToolhelp32Snapshot(0x00000002, 0)
138
+ if snapshot == wintypes.HANDLE(-1).value:
139
+ return []
140
+ processes: List[Dict[str, Any]] = []
141
+ entry = ProcessEntry()
142
+ entry.dwSize = ctypes.sizeof(entry)
143
+ try:
144
+ more = bool(kernel32.Process32FirstW(snapshot, ctypes.byref(entry)))
145
+ while more:
146
+ if entry.szExeFile.casefold() == "netease_dd.exe":
147
+ pid = int(entry.th32ProcessID)
148
+ handle = kernel32.OpenProcess(0x1000, False, pid)
149
+ if handle:
150
+ try:
151
+ size = wintypes.DWORD(32768)
152
+ buffer = ctypes.create_unicode_buffer(size.value)
153
+ creation = wintypes.FILETIME()
154
+ exit_time = wintypes.FILETIME()
155
+ kernel = wintypes.FILETIME()
156
+ user = wintypes.FILETIME()
157
+ if kernel32.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(size)):
158
+ started = 0
159
+ if kernel32.GetProcessTimes(
160
+ handle,
161
+ ctypes.byref(creation),
162
+ ctypes.byref(exit_time),
163
+ ctypes.byref(kernel),
164
+ ctypes.byref(user),
165
+ ):
166
+ started = (int(creation.dwHighDateTime) << 32) | int(creation.dwLowDateTime)
167
+ executable = Path(buffer.value).resolve()
168
+ signature: Optional[Dict[str, str]] = None
169
+ signature_error = ""
170
+ try:
171
+ signature = verify_dd_executable(executable)
172
+ except FuploadError as exc:
173
+ signature_error = str(exc)
174
+ process = {
175
+ "pid": pid,
176
+ "started": started,
177
+ "executable": str(executable),
178
+ "dd_dir": str(executable.parent),
179
+ "windows": _top_level_windows(pid),
180
+ "signature": signature,
181
+ "signature_error": signature_error,
182
+ }
183
+ if process["windows"]:
184
+ processes.append(process)
185
+ finally:
186
+ kernel32.CloseHandle(handle)
187
+ more = bool(kernel32.Process32NextW(snapshot, ctypes.byref(entry)))
188
+ finally:
189
+ kernel32.CloseHandle(snapshot)
190
+ return processes
191
+
192
+
193
+ def _same_process(expected: Dict[str, Any], actual: Dict[str, Any]) -> bool:
194
+ return (
195
+ int(expected.get("pid") or 0) == int(actual.get("pid") or 0)
196
+ and int(expected.get("started") or 0) == int(actual.get("started") or 0)
197
+ and os.path.normcase(str(expected.get("executable") or ""))
198
+ == os.path.normcase(str(actual.get("executable") or ""))
199
+ )
200
+
201
+
202
+ def _request_normal_close(processes: List[Dict[str, Any]]) -> None:
203
+ if os.name != "nt":
204
+ return
205
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
206
+ user32.PostMessageW.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p]
207
+ user32.PostMessageW.restype = ctypes.c_bool
208
+ for process in processes:
209
+ for hwnd in process.get("windows") or []:
210
+ user32.PostMessageW(int(hwnd), 0x0010, 0, 0)
211
+
212
+
213
+ def _terminate_process(process: Dict[str, Any]) -> None:
214
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
215
+ kernel32.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_bool, ctypes.c_ulong]
216
+ kernel32.OpenProcess.restype = ctypes.c_void_p
217
+ kernel32.TerminateProcess.argtypes = [ctypes.c_void_p, ctypes.c_uint]
218
+ kernel32.TerminateProcess.restype = ctypes.c_bool
219
+ kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
220
+ kernel32.WaitForSingleObject.restype = ctypes.c_ulong
221
+ kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
222
+ kernel32.CloseHandle.restype = ctypes.c_bool
223
+ handle = kernel32.OpenProcess(0x0001 | 0x00100000, False, int(process["pid"]))
224
+ if not handle:
225
+ raise FuploadError("verified DD GUI process could not be opened for termination", kind="gui_close_failed", stage="session")
226
+ try:
227
+ if not kernel32.TerminateProcess(handle, 1):
228
+ raise FuploadError("verified DD GUI process could not be terminated", kind="gui_close_failed", stage="session")
229
+ kernel32.WaitForSingleObject(handle, 5000)
230
+ finally:
231
+ kernel32.CloseHandle(handle)
232
+
233
+
234
+ def close_verified_gui(processes: List[Dict[str, Any]]) -> None:
235
+ if any(not process.get("signature") for process in processes):
236
+ raise FuploadError("an untrusted netease_dd.exe process is running", kind="trust_boundary", stage="session")
237
+ if not processes:
238
+ return
239
+ _request_normal_close(processes)
240
+ deadline = time.time() + 5
241
+ while time.time() < deadline:
242
+ live = running_dd_processes()
243
+ if not any(any(_same_process(expected, current) for current in live) for expected in processes):
244
+ break
245
+ time.sleep(0.1)
246
+ live = running_dd_processes()
247
+ original_pids = {int(process["pid"]) for process in processes}
248
+ if any(int(process["pid"]) not in original_pids for process in live):
249
+ raise FuploadError("a new DD GUI process appeared while closing the confirmed instances", kind="gui_identity_changed", stage="session")
250
+ for expected in processes:
251
+ matches = [current for current in live if int(current["pid"]) == int(expected["pid"])]
252
+ if not matches:
253
+ continue
254
+ current = matches[0]
255
+ if not _same_process(expected, current) or not current.get("signature"):
256
+ raise FuploadError("DD GUI process identity changed while closing", kind="gui_identity_changed", stage="session")
257
+ _terminate_process(current)
258
+ if running_dd_processes():
259
+ raise FuploadError("DD GUI did not fully exit", kind="gui_close_failed", stage="session")
260
+
261
+
262
+ def _public_process(process: Dict[str, Any]) -> Dict[str, Any]:
263
+ return {
264
+ "pid": process.get("pid"),
265
+ "dd_dir": process.get("dd_dir"),
266
+ "window_count": len(process.get("windows") or []),
267
+ "signature": process.get("signature"),
268
+ }
269
+
270
+
271
+ def _load_live_state() -> Optional[Dict[str, Any]]:
272
+ path = _state_dir() / STATE_NAME
273
+ if not path.exists():
274
+ return None
275
+ value = _read_json(path)
276
+ if not _pid_running(int(value.get("pid") or 0)):
277
+ try:
278
+ path.unlink()
279
+ except OSError:
280
+ pass
281
+ return None
282
+ return value
283
+
284
+
285
+ def doctor() -> Dict[str, Any]:
286
+ dd_dir, signature = _dd_module().discover_dd_info()
287
+ processes = running_dd_processes()
288
+ state = _load_live_state()
289
+ return {
290
+ "authenticated": False,
291
+ "login_performed": False,
292
+ "dd_dir": str(dd_dir),
293
+ "installation_source": "automatic-discovery",
294
+ "signature": signature,
295
+ "state_directory": str(_state_dir()),
296
+ "state_source": "windows-known-folder",
297
+ "api_origin": "https://uiapi.w.163.com",
298
+ "gui_running": bool(processes),
299
+ "gui_processes": [_public_process(process) for process in processes],
300
+ "untrusted_process_count": sum(1 for process in processes if not process.get("signature")),
301
+ "broker_running": bool(state),
302
+ "session_id": state.get("session_id") if state else None,
303
+ }
304
+
305
+
306
+ def _send(value: Dict[str, Any], timeout: float = 300) -> Dict[str, Any]:
307
+ state = _load_live_state()
308
+ if not state:
309
+ raise FuploadError("DD task session is not running", kind="session_not_running", stage="session")
310
+ payload = dict(value)
311
+ request = {
312
+ "session_id": payload.pop("session_id", None),
313
+ "auth_key": state.get("auth_key"),
314
+ **payload,
315
+ }
316
+ if request["session_id"] != state.get("session_id"):
317
+ raise FuploadError("DD session_id does not match the active task session", kind="session_mismatch", stage="session")
318
+ data = (json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
319
+ if len(data) > MAX_REQUEST_BYTES:
320
+ raise FuploadError("DD broker request is too large", kind="request_too_large", stage="session")
321
+ write_sent = False
322
+ try:
323
+ with socket.create_connection(("127.0.0.1", int(state["port"])), timeout=10) as connection:
324
+ connection.settimeout(timeout)
325
+ connection.sendall(data)
326
+ write_sent = request.get("command") == "write"
327
+ received = bytearray()
328
+ while b"\n" not in received:
329
+ chunk = connection.recv(65536)
330
+ if not chunk:
331
+ break
332
+ received.extend(chunk)
333
+ if len(received) > MAX_REQUEST_BYTES:
334
+ raise FuploadError(
335
+ "DD broker response is too large",
336
+ kind="response_too_large",
337
+ stage="session",
338
+ verification_required=write_sent,
339
+ )
340
+ except FuploadError as exc:
341
+ if write_sent and not exc.verification_required:
342
+ raise FuploadError(
343
+ str(exc),
344
+ kind=exc.kind,
345
+ stage=exc.stage or "session",
346
+ endpoint=exc.endpoint,
347
+ http_status=exc.http_status,
348
+ business_code=exc.business_code,
349
+ verification_required=True,
350
+ details=exc.details,
351
+ ) from exc
352
+ raise
353
+ except (OSError, ValueError) as exc:
354
+ raise FuploadError(
355
+ "DD broker connection failed",
356
+ kind="session_connection",
357
+ stage="session",
358
+ verification_required=write_sent,
359
+ ) from exc
360
+ if b"\n" not in received:
361
+ raise FuploadError(
362
+ "DD broker closed without a complete response",
363
+ kind="session_protocol",
364
+ stage="session",
365
+ verification_required=write_sent,
366
+ )
367
+ try:
368
+ response = json.loads(bytes(received).split(b"\n", 1)[0].decode("utf-8"))
369
+ except (UnicodeError, ValueError) as exc:
370
+ raise FuploadError(
371
+ "DD broker returned invalid JSON",
372
+ kind="session_protocol",
373
+ stage="session",
374
+ verification_required=write_sent,
375
+ ) from exc
376
+ if not isinstance(response, dict):
377
+ raise FuploadError("DD broker returned an invalid response", kind="session_protocol", stage="session")
378
+ if not response.get("ok"):
379
+ error = response.get("error") if isinstance(response.get("error"), dict) else {}
380
+ raise FuploadError.from_dict(error)
381
+ return response.get("data")
382
+
383
+
384
+ def start(confirm_close_gui: bool) -> Dict[str, Any]:
385
+ existing = _load_live_state()
386
+ if existing:
387
+ active = status(str(existing["session_id"]))
388
+ return {
389
+ "session_id": existing["session_id"],
390
+ "running": True,
391
+ "reused": True,
392
+ "login_count": active.get("login_count", 1),
393
+ }
394
+ processes = running_dd_processes()
395
+ if processes and not confirm_close_gui:
396
+ raise FuploadError(
397
+ "DD GUI is running; explicit close confirmation is required before native login",
398
+ kind="gui_close_confirmation_required",
399
+ stage="session",
400
+ details={"processes": [_public_process(process) for process in processes]},
401
+ )
402
+ close_verified_gui(processes)
403
+ root = _state_dir()
404
+ startup = root / STARTUP_NAME
405
+ startup_id = uuid.uuid4().hex
406
+ _atomic_json(startup, {"startup_id": startup_id, "auth_key": uuid.uuid4().hex + uuid.uuid4().hex})
407
+ scripts_root = Path(__file__).resolve().parents[1]
408
+ environment = os.environ.copy()
409
+ environment["PYTHONPATH"] = str(scripts_root) + os.pathsep + environment.get("PYTHONPATH", "")
410
+ flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
411
+ process = subprocess.Popen(
412
+ [sys.executable, "-m", "fupload_cli.dd_broker", "--serve", startup_id],
413
+ cwd=str(scripts_root),
414
+ env=environment,
415
+ stdin=subprocess.DEVNULL,
416
+ stdout=subprocess.DEVNULL,
417
+ stderr=subprocess.DEVNULL,
418
+ creationflags=flags,
419
+ )
420
+ deadline = time.time() + 90
421
+ last_error = ""
422
+ while time.time() < deadline:
423
+ state = _load_live_state()
424
+ if state and state.get("startup_id") == startup_id:
425
+ return {
426
+ "session_id": state["session_id"],
427
+ "running": True,
428
+ "reused": False,
429
+ "login_count": 1,
430
+ "closed_gui_processes": len(processes),
431
+ }
432
+ if startup.exists():
433
+ try:
434
+ pending = _read_json(startup)
435
+ last_error = str(pending.get("error") or "")
436
+ except FuploadError:
437
+ pass
438
+ if process.poll() is not None:
439
+ break
440
+ time.sleep(0.1)
441
+ if process.poll() is None:
442
+ subprocess.run(
443
+ ["taskkill", "/PID", str(process.pid), "/T", "/F"],
444
+ stdin=subprocess.DEVNULL,
445
+ stdout=subprocess.DEVNULL,
446
+ stderr=subprocess.DEVNULL,
447
+ check=False,
448
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
449
+ )
450
+ try:
451
+ process.wait(timeout=10)
452
+ except subprocess.TimeoutExpired:
453
+ pass
454
+ try:
455
+ pending = _read_json(startup)
456
+ if pending.get("startup_id") == startup_id:
457
+ startup.unlink()
458
+ except (FuploadError, OSError):
459
+ pass
460
+ raise FuploadError(last_error or "DD task session failed to start", kind="session_start_failed", stage="session")
461
+
462
+
463
+ def status(session_id: Optional[str] = None) -> Dict[str, Any]:
464
+ state = _load_live_state()
465
+ if not state:
466
+ return {"running": False, "login_performed": False}
467
+ chosen = session_id or str(state["session_id"])
468
+ data = _send({"session_id": chosen, "command": "ping"}, timeout=10)
469
+ return data
470
+
471
+
472
+ def stop(session_id: str) -> Dict[str, Any]:
473
+ data = _send({"session_id": session_id, "command": "stop"}, timeout=30)
474
+ deadline = time.time() + 30
475
+ while time.time() < deadline and _load_live_state():
476
+ time.sleep(0.05)
477
+ if _load_live_state():
478
+ raise FuploadError(
479
+ "DD task session acknowledged stop but did not finish cleanup",
480
+ kind="session_stop_failed",
481
+ stage="session",
482
+ )
483
+ result = dict(data or {})
484
+ result["cleanup_complete"] = True
485
+ return result
486
+
487
+
488
+ def execute(session_id: str, kind: str, resource: str, action: str, payload: Dict[str, Any]) -> Any:
489
+ return _send({
490
+ "session_id": session_id,
491
+ "command": kind,
492
+ "resource": resource,
493
+ "action": action,
494
+ "payload": payload,
495
+ })
496
+
497
+
498
+ def _read_request(connection: socket.socket) -> Dict[str, Any]:
499
+ value = bytearray()
500
+ while b"\n" not in value:
501
+ chunk = connection.recv(65536)
502
+ if not chunk:
503
+ break
504
+ value.extend(chunk)
505
+ if len(value) > MAX_REQUEST_BYTES:
506
+ raise FuploadError("DD broker request is too large", kind="request_too_large", stage="session")
507
+ decoded = json.loads(bytes(value).split(b"\n", 1)[0].decode("utf-8"))
508
+ if not isinstance(decoded, dict):
509
+ raise ValueError("request must be an object")
510
+ return decoded
511
+
512
+
513
+ def _serve(startup_id: str) -> int:
514
+ root = _state_dir()
515
+ startup_path = root / STARTUP_NAME
516
+ pending = _read_json(startup_path)
517
+ if pending.get("startup_id") != startup_id:
518
+ return 2
519
+ auth_key = str(pending.get("auth_key") or "")
520
+ session_id = uuid.uuid4().hex
521
+ state_path = root / STATE_NAME
522
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
523
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
524
+ listener.bind(("127.0.0.1", 0))
525
+ listener.listen(8)
526
+ listener.settimeout(1)
527
+ port = int(listener.getsockname()[1])
528
+ started_at = time.time()
529
+ last_activity = started_at
530
+ state = {
531
+ "schema": "fupload.dd-broker.v1",
532
+ "startup_id": startup_id,
533
+ "session_id": session_id,
534
+ "auth_key": auth_key,
535
+ "pid": os.getpid(),
536
+ "port": port,
537
+ "started_at": started_at,
538
+ "last_activity": last_activity,
539
+ }
540
+ sidecar = None
541
+ try:
542
+ sidecar = _dd_module().Sidecar().__enter__()
543
+ state["dd_dir"] = str(sidecar.dd_dir)
544
+ state["signature"] = sidecar.signature
545
+ _atomic_json(state_path, state)
546
+ try:
547
+ startup_path.unlink()
548
+ except OSError:
549
+ pass
550
+ should_stop = False
551
+ while not should_stop and time.time() - last_activity < IDLE_SECONDS:
552
+ try:
553
+ connection, _address = listener.accept()
554
+ except socket.timeout:
555
+ continue
556
+ with connection:
557
+ request: Dict[str, Any] = {}
558
+ try:
559
+ request = _read_request(connection)
560
+ if request.get("auth_key") != auth_key or request.get("session_id") != session_id:
561
+ raise FuploadError("DD broker authentication failed", kind="session_mismatch", stage="session")
562
+ last_activity = time.time()
563
+ state["last_activity"] = last_activity
564
+ _atomic_json(state_path, state)
565
+ command = request.get("command")
566
+ if command == "ping":
567
+ data = {
568
+ "running": True,
569
+ "session_id": session_id,
570
+ "started_at": started_at,
571
+ "last_activity": last_activity,
572
+ "login_count": 1,
573
+ "dd_dir": state.get("dd_dir"),
574
+ "signature": state.get("signature"),
575
+ }
576
+ elif command == "stop":
577
+ data = {"running": False, "session_id": session_id, "logout_requested": True}
578
+ should_stop = True
579
+ elif command == "write":
580
+ data = _dd_module().DD().execute_write_on(
581
+ sidecar,
582
+ str(request.get("resource") or ""),
583
+ str(request.get("action") or ""),
584
+ request.get("payload") if isinstance(request.get("payload"), dict) else {},
585
+ )
586
+ elif command == "read":
587
+ arguments = request.get("payload") if isinstance(request.get("payload"), dict) else {}
588
+ data = _dd_module().DD().execute_read_on(
589
+ sidecar,
590
+ str(request.get("resource") or ""),
591
+ str(request.get("action") or ""),
592
+ SimpleNamespace(**arguments),
593
+ )
594
+ else:
595
+ raise FuploadError("unsupported DD broker command", kind="unsupported_operation", stage="session")
596
+ response = {"ok": True, "data": data}
597
+ except FuploadError as exc:
598
+ response = {"ok": False, "error": exc.as_dict()}
599
+ except Exception as exc:
600
+ response = {"ok": False, "error": FuploadError(
601
+ "DD broker operation failed (%s)" % type(exc).__name__,
602
+ kind="broker_error",
603
+ stage="session",
604
+ verification_required=request.get("command") == "write",
605
+ ).as_dict()}
606
+ connection.sendall((json.dumps(response, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8"))
607
+ return 0
608
+ except Exception as exc:
609
+ _atomic_json(startup_path, {
610
+ "startup_id": startup_id,
611
+ "error": redact(str(exc))[:400],
612
+ })
613
+ return 1
614
+ finally:
615
+ listener.close()
616
+ if sidecar is not None:
617
+ sidecar.__exit__(None, None, None)
618
+ try:
619
+ current = _read_json(state_path)
620
+ if current.get("session_id") == session_id:
621
+ state_path.unlink()
622
+ except (FuploadError, OSError):
623
+ pass
624
+
625
+
626
+ def main(argv: Optional[List[str]] = None) -> int:
627
+ parser = argparse.ArgumentParser()
628
+ parser.add_argument("--serve", required=True)
629
+ args = parser.parse_args(argv)
630
+ return _serve(args.serve)
631
+
632
+
633
+ if __name__ == "__main__":
634
+ raise SystemExit(main())