@agentsdock/server 1.0.7-beta.5 → 1.0.8
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.
- package/README.md +20 -6
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +5283 -698
- package/server/agentsdock_chats.py +2 -1
- package/server/chat_mailbox.py +23 -6
- package/server/claude_history_repair.py +45 -4
- package/server/claude_model_catalog.py +200 -0
- package/server/claude_sdk_client.py +63 -24
- package/server/claude_side_question.py +1 -8
- package/server/codex_app_server.py +90 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_history_repair.py +79 -14
- package/server/codex_provider.py +160 -77
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- package/server/cursor_api_key.py +64 -0
- package/server/cursor_history.py +219 -0
- package/server/cursor_provider_mcp.py +290 -0
- package/server/execution_durability.py +10 -6
- package/server/execution_preparation.py +6 -2
- package/server/install.sh +282 -78
- package/server/instances.sh +15 -0
- package/server/interactive_chat_native.py +4 -3
- package/server/interactive_chat_projection.py +6 -19
- package/server/interactive_chat_share_routes.py +71 -45
- package/server/interactive_chat_share_web.py +2 -2
- package/server/interactive_chat_shares.py +6 -11
- package/server/local_session_ownership.py +232 -0
- package/server/opencode_agent_client.py +685 -0
- package/server/provider_commands.py +579 -0
- package/server/provider_connections.py +665 -0
- package/server/provider_usage.py +241 -0
- package/server/public_chat_shares.py +60 -62
- package/server/public_chat_transcript.py +9 -31
- package/server/pyproject.toml +1 -0
- package/server/server_instances.py +931 -0
- package/server/shared_chat_video_stream.py +21 -2
- package/server/shared_chat_videos.py +54 -24
- package/server/side_questions.py +493 -10
- package/server/title_generation.py +4 -1
- package/server/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- package/server/update_runner.py +16 -0
- package/server/uv.lock +3 -1
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Current-user instance bindings and manager. No server import or discovery writes.
|
|
3
|
+
|
|
4
|
+
The registry contains names, statuses and optional ports, not deletion paths or credentials.
|
|
5
|
+
Named roots are siblings of the legacy roots: removing default cannot remove them.
|
|
6
|
+
Services are independently configured, but are NOT a same-user security sandbox.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
import fcntl
|
|
14
|
+
import ipaddress
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import plistlib
|
|
19
|
+
import re
|
|
20
|
+
import shlex
|
|
21
|
+
import shutil
|
|
22
|
+
import socket
|
|
23
|
+
import stat
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
INSTANCE_PROTOCOL = 1
|
|
31
|
+
ROOT = Path(__file__).resolve().parent
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def instance_name(value: str) -> str:
|
|
35
|
+
if not isinstance(value, str) or not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", value):
|
|
36
|
+
raise ValueError("Instance names must be 1–32 lowercase letters, digits or dashes, starting with a letter.")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def service_name(name: str) -> str:
|
|
41
|
+
return "agents-server" + ("" if instance_name(name) == "default" else "-" + name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def launchd_label(name: str) -> str:
|
|
45
|
+
return "com.agentsdock.server" + ("" if instance_name(name) == "default" else "." + name)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Instance:
|
|
50
|
+
name: str
|
|
51
|
+
home: Path
|
|
52
|
+
|
|
53
|
+
def __post_init__(self):
|
|
54
|
+
instance_name(self.name)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def runtime(self) -> Path:
|
|
58
|
+
return self.home / ".local/share" / ("agents-server" if self.name == "default" else f"agents-server-instances/{self.name}")
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def config(self) -> Path:
|
|
62
|
+
return self.home / ".config" / ("agents-server" if self.name == "default" else f"agents-server-instances/{self.name}")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def state(self) -> Path:
|
|
66
|
+
return self.home / (".agentsdock" if self.name == "default" else f".agentsdock-instances/{self.name}")
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def logs(self) -> Path:
|
|
70
|
+
return self.home / "Library/Logs" / ("AgentsServer" if self.name == "default" else f"AgentsServer-instances/{self.name}")
|
|
71
|
+
|
|
72
|
+
def service_file(self, platform: str = sys.platform) -> Path:
|
|
73
|
+
if platform == "darwin":
|
|
74
|
+
return self.home / "Library/LaunchAgents" / (launchd_label(self.name) + ".plist")
|
|
75
|
+
if platform.startswith("linux"):
|
|
76
|
+
return self.home / ".config/systemd/user" / (service_name(self.name) + ".service")
|
|
77
|
+
raise ValueError("Only macOS and Linux user services are supported.")
|
|
78
|
+
|
|
79
|
+
def environment(self) -> dict[str, str]:
|
|
80
|
+
return {
|
|
81
|
+
"AGENTS_SERVER_INSTANCE": self.name,
|
|
82
|
+
"AGENTS_SERVER_INSTALL_DIR": str(self.runtime),
|
|
83
|
+
"AGENTS_SERVER_CONFIG_DIR": str(self.config),
|
|
84
|
+
"AGENTSDOCK_STATE_DIR": str(self.state),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def shell_bindings(self) -> str:
|
|
88
|
+
values = {
|
|
89
|
+
"INSTALL_ROOT": str(self.runtime), "CONFIG_ROOT": str(self.config),
|
|
90
|
+
"STATE_ROOT": str(self.state), "INSTANCE_LOG_DIR": str(self.logs),
|
|
91
|
+
"SERVICE_NAME": service_name(self.name), "LABEL": launchd_label(self.name),
|
|
92
|
+
}
|
|
93
|
+
return "\n".join(f"{key}={shlex.quote(value)}" for key, value in values.items())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_path(path: Path, home: Path) -> None:
|
|
97
|
+
"""Reject symlinks, foreign owners and unsafe writable parents before mutation."""
|
|
98
|
+
path.relative_to(home)
|
|
99
|
+
if path == home:
|
|
100
|
+
raise ValueError("Refusing a home-directory target.")
|
|
101
|
+
for parent in (path, *path.parents):
|
|
102
|
+
if parent == home.parent:
|
|
103
|
+
break
|
|
104
|
+
try:
|
|
105
|
+
info = parent.lstat()
|
|
106
|
+
except FileNotFoundError:
|
|
107
|
+
continue
|
|
108
|
+
if stat.S_ISLNK(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022:
|
|
109
|
+
raise ValueError(f"Unsafe managed path (link, owner or permissions): {parent}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_regular(path: Path, *, max_bytes: int = 1024 * 1024) -> bytes:
|
|
113
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
114
|
+
with os.fdopen(fd, "rb") as handle:
|
|
115
|
+
info = os.fstat(handle.fileno())
|
|
116
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022:
|
|
117
|
+
raise ValueError(f"Unsafe managed file: {path}")
|
|
118
|
+
data = handle.read(max_bytes + 1)
|
|
119
|
+
if len(data) > max_bytes:
|
|
120
|
+
raise ValueError(f"Managed file too large: {path}")
|
|
121
|
+
return data
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def read_config(instance: Instance) -> dict[str, str]:
|
|
125
|
+
try:
|
|
126
|
+
text = read_regular(instance.config / "env").decode()
|
|
127
|
+
except FileNotFoundError:
|
|
128
|
+
return {}
|
|
129
|
+
result = {}
|
|
130
|
+
for line in text.splitlines():
|
|
131
|
+
key, separator, value = line.partition("=")
|
|
132
|
+
if separator and re.fullmatch(r"[A-Z_]+", key):
|
|
133
|
+
values = shlex.split(value, comments=False)
|
|
134
|
+
result[key] = values[0] if len(values) == 1 else value
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def validate_binding(instance: Instance, platform: str = sys.platform) -> None:
|
|
139
|
+
for path in (instance.runtime, instance.config, instance.state, instance.logs, instance.service_file(platform)):
|
|
140
|
+
check_path(path, instance.home)
|
|
141
|
+
env = read_config(instance)
|
|
142
|
+
for key, value in instance.environment().items():
|
|
143
|
+
if key in env and env[key] != value:
|
|
144
|
+
raise ValueError(f"{instance.name}: {key} does not match this instance; refusing service changes.")
|
|
145
|
+
service = instance.service_file(platform)
|
|
146
|
+
if service.exists():
|
|
147
|
+
data = read_regular(service)
|
|
148
|
+
if platform == "darwin":
|
|
149
|
+
job = plistlib.loads(data)
|
|
150
|
+
if job.get("Label") != launchd_label(instance.name):
|
|
151
|
+
raise ValueError("Service label does not match instance.")
|
|
152
|
+
job_env = job.get("EnvironmentVariables", {})
|
|
153
|
+
# Legacy default plists did not set CONFIG_DIR / INSTANCE.
|
|
154
|
+
for key, value in instance.environment().items():
|
|
155
|
+
if job_env.get(key, value if instance.name == "default" else None) != value:
|
|
156
|
+
raise ValueError(f"Service {key} does not match instance.")
|
|
157
|
+
args = job.get("ProgramArguments", [])
|
|
158
|
+
if args[:2] != [str(instance.runtime / "current/.venv/bin/python"), str(instance.runtime / "current/agent_server.py")]:
|
|
159
|
+
raise ValueError("Service executable does not match instance.")
|
|
160
|
+
else:
|
|
161
|
+
lines = data.decode().splitlines()
|
|
162
|
+
if f"EnvironmentFile={instance.config / 'env'}" not in lines or not any(
|
|
163
|
+
line.startswith(f"ExecStart={instance.runtime / 'current/.venv/bin/python'} {instance.runtime / 'current/agent_server.py'} serve ")
|
|
164
|
+
for line in lines
|
|
165
|
+
):
|
|
166
|
+
raise ValueError("Service runtime/configuration does not match instance.")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Registry:
|
|
170
|
+
def __init__(self, home: Path | None = None):
|
|
171
|
+
self.home = (home or Path.home()).resolve()
|
|
172
|
+
self.root = self.home / ".config/agents-server-manager"
|
|
173
|
+
self.file = self.root / "instances.json"
|
|
174
|
+
|
|
175
|
+
def records(self) -> dict:
|
|
176
|
+
check_path(self.file, self.home)
|
|
177
|
+
try:
|
|
178
|
+
value = json.loads(read_regular(self.file))
|
|
179
|
+
except FileNotFoundError:
|
|
180
|
+
return {}
|
|
181
|
+
if not isinstance(value, dict) or value.get("version") != INSTANCE_PROTOCOL or not isinstance(value.get("instances"), dict):
|
|
182
|
+
raise ValueError("Invalid instance registry; refusing to guess removal targets.")
|
|
183
|
+
for name, record in value["instances"].items():
|
|
184
|
+
instance_name(name)
|
|
185
|
+
if not isinstance(record, dict) or not {"status"} <= set(record) <= {"status", "port"} or record["status"] not in {"pending", "installed", "removed", "failed"}:
|
|
186
|
+
raise ValueError("Invalid instance record.")
|
|
187
|
+
if "port" in record and (type(record["port"]) is not int or not 1 <= record["port"] <= 65535):
|
|
188
|
+
raise ValueError("Invalid saved instance port.")
|
|
189
|
+
return value["instances"]
|
|
190
|
+
|
|
191
|
+
def instances(self, include_removed: bool = False) -> list[Instance]:
|
|
192
|
+
records = self.records()
|
|
193
|
+
names = {name for name, record in records.items() if include_removed or record["status"] != "removed"}
|
|
194
|
+
default = Instance("default", self.home)
|
|
195
|
+
if default.service_file().exists() or (default.config / "env").exists():
|
|
196
|
+
names.add("default")
|
|
197
|
+
# Direct --instance installs are discovered only in fixed owned roots.
|
|
198
|
+
configs = self.home / ".config/agents-server-instances"
|
|
199
|
+
check_path(configs, self.home)
|
|
200
|
+
if configs.exists():
|
|
201
|
+
for child in configs.iterdir():
|
|
202
|
+
instance_name(child.name)
|
|
203
|
+
check_path(child, self.home)
|
|
204
|
+
if (child / "env").is_file():
|
|
205
|
+
names.add(child.name)
|
|
206
|
+
return [Instance(name, self.home) for name in sorted(names, key=lambda name: (name != "default", name))]
|
|
207
|
+
|
|
208
|
+
@contextmanager
|
|
209
|
+
def locked(self):
|
|
210
|
+
check_path(self.root, self.home)
|
|
211
|
+
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
212
|
+
with exclusive_lock(self.root / "operation.lock"):
|
|
213
|
+
yield
|
|
214
|
+
|
|
215
|
+
def save(self, instance: Instance, status: str, port: int | None = None):
|
|
216
|
+
if status not in {"pending", "installed", "removed", "failed"}:
|
|
217
|
+
raise ValueError("Invalid lifecycle status.")
|
|
218
|
+
records = self.records()
|
|
219
|
+
record = {**records.get(instance.name, {}), "status": status}
|
|
220
|
+
if port is not None:
|
|
221
|
+
if type(port) is not int or not 1 <= port <= 65535:
|
|
222
|
+
raise ValueError("Invalid saved instance port.")
|
|
223
|
+
record["port"] = port
|
|
224
|
+
records[instance.name] = record
|
|
225
|
+
self._write(records)
|
|
226
|
+
|
|
227
|
+
def forget(self, instance: Instance):
|
|
228
|
+
records = self.records()
|
|
229
|
+
records.pop(instance.name, None)
|
|
230
|
+
self._write(records)
|
|
231
|
+
|
|
232
|
+
def _write(self, records: dict):
|
|
233
|
+
fd, name = tempfile.mkstemp(prefix=".instances-", dir=self.root)
|
|
234
|
+
try:
|
|
235
|
+
with os.fdopen(fd, "w") as handle:
|
|
236
|
+
json.dump({"version": INSTANCE_PROTOCOL, "instances": records}, handle, indent=2)
|
|
237
|
+
handle.write("\n")
|
|
238
|
+
handle.flush()
|
|
239
|
+
os.fsync(handle.fileno())
|
|
240
|
+
os.replace(name, self.file)
|
|
241
|
+
finally:
|
|
242
|
+
if os.path.exists(name):
|
|
243
|
+
os.unlink(name)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@contextmanager
|
|
247
|
+
def exclusive_lock(path: Path):
|
|
248
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
249
|
+
try:
|
|
250
|
+
info = os.fstat(fd)
|
|
251
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077:
|
|
252
|
+
raise ValueError(f"Unsafe lock file: {path}")
|
|
253
|
+
try:
|
|
254
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
255
|
+
except BlockingIOError as exc:
|
|
256
|
+
raise ValueError(f"Another process owns {path}; no changes made.") from exc
|
|
257
|
+
yield
|
|
258
|
+
finally:
|
|
259
|
+
os.close(fd) # Never unlink: waiters must keep the same lock inode.
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def acquire_state_lock(state: Path):
|
|
263
|
+
state.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
264
|
+
lock = exclusive_lock(state / ".server-process.lock")
|
|
265
|
+
lock.__enter__()
|
|
266
|
+
return lock # The serving process retains this object until exit.
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
PROVIDER_ID_FIELDS = {
|
|
270
|
+
"claude": "claude_session_id", "codex": "codex_thread_id",
|
|
271
|
+
"cursor": "cursor_session_id", "opencode": "opencode_session_id",
|
|
272
|
+
}
|
|
273
|
+
MAX_IMPORT_INDEX_BYTES = 64 * 1024 * 1024
|
|
274
|
+
MAX_IMPORT_INDEX_TOTAL_BYTES = 128 * 1024 * 1024
|
|
275
|
+
MAX_IMPORT_INSTANCES = 256
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def provider_session_keys(session: dict, default_backend: str = "claude") -> set[tuple[str, str]]:
|
|
279
|
+
"""Include parked provider identities as well as the active legacy ID."""
|
|
280
|
+
backend = str(session.get("backend") or default_backend).strip().lower()
|
|
281
|
+
keys = set()
|
|
282
|
+
for provider, field in PROVIDER_ID_FIELDS.items():
|
|
283
|
+
value = session.get(field)
|
|
284
|
+
if not value and provider == backend:
|
|
285
|
+
value = session.get("provider_session_id") or session.get("session_id")
|
|
286
|
+
if isinstance(value, str) and value.strip():
|
|
287
|
+
keys.add((provider, value.strip()))
|
|
288
|
+
return keys
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def other_instance_provider_keys(current_state: Path, *, registry: Registry | None = None) -> set[tuple[str, str]]:
|
|
292
|
+
"""Read installed instances' indexes, never transcripts or another service API.
|
|
293
|
+
|
|
294
|
+
Stopped services and archived chats still own their provider IDs. Removed
|
|
295
|
+
instances with preserved history do not. No registry/configuration writes
|
|
296
|
+
occur during discovery. Bad or unsafe indexes fail closed, not silently open.
|
|
297
|
+
"""
|
|
298
|
+
registry = registry or Registry()
|
|
299
|
+
instances = registry.instances()
|
|
300
|
+
if len(instances) > MAX_IMPORT_INSTANCES:
|
|
301
|
+
raise ValueError("Too many local instances to verify import ownership.")
|
|
302
|
+
keys = set()
|
|
303
|
+
remaining = MAX_IMPORT_INDEX_TOTAL_BYTES
|
|
304
|
+
current_state = current_state.resolve()
|
|
305
|
+
for instance in instances:
|
|
306
|
+
check_path(instance.config, registry.home)
|
|
307
|
+
config = read_config(instance)
|
|
308
|
+
configured_state = (config.get("AGENTSDOCK_STATE_DIR")
|
|
309
|
+
or config.get("AGENTS_SERVER_STATE_DIR")
|
|
310
|
+
or config.get("ZENITHBOT_AGENT_DIR"))
|
|
311
|
+
state = Path(configured_state) if configured_state else instance.state
|
|
312
|
+
if instance.name != "default" and state != instance.state:
|
|
313
|
+
raise ValueError(f"{instance.name}: unexpected state binding.")
|
|
314
|
+
if not state.is_absolute() or ".." in state.parts:
|
|
315
|
+
raise ValueError(f"{instance.name}: unsafe state binding.")
|
|
316
|
+
if state == current_state:
|
|
317
|
+
continue # The current process has the authoritative in-memory map.
|
|
318
|
+
check_path(state / "sessions.json", registry.home)
|
|
319
|
+
try:
|
|
320
|
+
data = read_regular(state / "sessions.json", max_bytes=min(MAX_IMPORT_INDEX_BYTES, remaining))
|
|
321
|
+
except FileNotFoundError:
|
|
322
|
+
continue # A registered/new instance may not have any chats yet.
|
|
323
|
+
remaining -= len(data)
|
|
324
|
+
sessions = json.loads(data)
|
|
325
|
+
if not isinstance(sessions, dict) or any(not isinstance(row, dict) for row in sessions.values()):
|
|
326
|
+
raise ValueError(f"{instance.name}: invalid sessions index.")
|
|
327
|
+
default_backend = config.get("AGENTSDOCK_BACKEND") or config.get("ZENITHBOT_BACKEND") or "claude"
|
|
328
|
+
for session in sessions.values():
|
|
329
|
+
keys.update(provider_session_keys(session, default_backend))
|
|
330
|
+
return keys
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@contextmanager
|
|
334
|
+
def history_import_lock(*, registry: Registry | None = None):
|
|
335
|
+
"""Serialize cooperating local imports until their index writes have landed.
|
|
336
|
+
|
|
337
|
+
Separate from service-management locks; nonblocking and OS-released on exit
|
|
338
|
+
or crash. Older servers do not participate, so this is not a provider lock.
|
|
339
|
+
"""
|
|
340
|
+
registry = registry or Registry()
|
|
341
|
+
check_path(registry.root, registry.home)
|
|
342
|
+
registry.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
343
|
+
with exclusive_lock(registry.root / "history-import.lock"):
|
|
344
|
+
yield
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def validate_runtime_environment(environment: dict[str, str], home: Path) -> None:
|
|
348
|
+
name = instance_name(environment.get("AGENTS_SERVER_INSTANCE", "default"))
|
|
349
|
+
if name == "default":
|
|
350
|
+
return # Existing custom-root default deployments remain supported.
|
|
351
|
+
instance = Instance(name, home.resolve())
|
|
352
|
+
for key, expected in instance.environment().items():
|
|
353
|
+
if environment.get(key) != expected:
|
|
354
|
+
raise ValueError(f"Named instance {name}: {key} must match its isolated binding.")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def clean_environment(instance: Instance) -> dict[str, str]:
|
|
358
|
+
env = {key: value for key, value in os.environ.items() if not key.startswith(("AGENTSDOCK_", "AGENTS_SERVER_", "ZENITHBOT_AGENT_", "ZENITHDOCK_"))}
|
|
359
|
+
env.update(instance.environment())
|
|
360
|
+
if instance.name != "default":
|
|
361
|
+
env["AGENTSDOCK_SERVER_NAME"] = instance.name
|
|
362
|
+
return env
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def run(command: list[str], **kwargs):
|
|
366
|
+
return subprocess.run(command, check=True, **kwargs)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def service_status(instance: Instance, platform: str = sys.platform) -> str:
|
|
370
|
+
command = ["launchctl", "print", f"gui/{os.getuid()}/{launchd_label(instance.name)}"] if platform == "darwin" else ["systemctl", "--user", "show", service_name(instance.name) + ".service", "--property=ActiveState", "--value"]
|
|
371
|
+
try:
|
|
372
|
+
result = subprocess.run(command, capture_output=True, text=True, timeout=3, check=False)
|
|
373
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
374
|
+
return "unknown"
|
|
375
|
+
if platform == "darwin":
|
|
376
|
+
if result.returncode == 0:
|
|
377
|
+
return "running" if re.search(r"\bpid = [1-9][0-9]*", result.stdout) else "loaded"
|
|
378
|
+
return "stopped" if "Could not find service" in result.stderr else "unknown"
|
|
379
|
+
return "running" if result.returncode == 0 and result.stdout.strip() == "active" else "stopped" if result.returncode == 0 else "unknown"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def port_available(port: int) -> bool:
|
|
383
|
+
if type(port) is not int or not 1 <= port <= 65535:
|
|
384
|
+
raise ValueError("Port must be between 1 and 65535.")
|
|
385
|
+
for family, address in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")):
|
|
386
|
+
try:
|
|
387
|
+
with socket.socket(family, socket.SOCK_STREAM) as listener:
|
|
388
|
+
if family == socket.AF_INET6:
|
|
389
|
+
listener.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
|
390
|
+
listener.bind((address, port))
|
|
391
|
+
except OSError as exc:
|
|
392
|
+
import errno
|
|
393
|
+
if family == socket.AF_INET6 and exc.errno in {errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT, errno.EADDRNOTAVAIL}:
|
|
394
|
+
continue
|
|
395
|
+
return False
|
|
396
|
+
return True
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def select_port(registry: Registry, explicit: int | None, extra_reserved: set[int] | None = None) -> int:
|
|
400
|
+
reserved = {int(read_config(item).get("AGENTSDOCK_AGENT_PORT", "7850")) for item in registry.instances()}
|
|
401
|
+
reserved.update(extra_reserved or ())
|
|
402
|
+
candidates = [explicit] if explicit is not None else range(7851, 65536)
|
|
403
|
+
for port in candidates:
|
|
404
|
+
if port not in reserved and port_available(port):
|
|
405
|
+
return port
|
|
406
|
+
raise ValueError("Requested port is occupied/reserved, or no free port is available. No existing listener was stopped.")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def candidate_addresses(bind: str, port: int) -> list[str]:
|
|
410
|
+
try:
|
|
411
|
+
address = ipaddress.ip_address(bind.strip("[]"))
|
|
412
|
+
except ValueError:
|
|
413
|
+
if bind == "localhost":
|
|
414
|
+
return [f"http://localhost:{port} (This machine only)"]
|
|
415
|
+
return [f"http://{bind}:{port}"]
|
|
416
|
+
if not address.is_unspecified:
|
|
417
|
+
host = f"[{address}]" if address.version == 6 else str(address)
|
|
418
|
+
return [f"http://{host}:{port}" + (" (This machine only)" if address.is_loopback else "")]
|
|
419
|
+
hosts = set()
|
|
420
|
+
try:
|
|
421
|
+
hosts.update(row[4][0] for row in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET if address.version == 4 else socket.AF_INET6))
|
|
422
|
+
if sys.platform == "darwin":
|
|
423
|
+
output = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=3, check=False).stdout
|
|
424
|
+
hosts.update(re.findall(r"\binet " + r"(\d+\.\d+\.\d+\.\d+)", output))
|
|
425
|
+
elif sys.platform.startswith("linux"):
|
|
426
|
+
output = subprocess.run(["hostname", "-I"], capture_output=True, text=True, timeout=3, check=False).stdout
|
|
427
|
+
hosts.update(output.split())
|
|
428
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
429
|
+
pass
|
|
430
|
+
urls = [f"http://127.0.0.1:{port}" if address.version == 4 else f"http://[::1]:{port}"]
|
|
431
|
+
for host in sorted(hosts):
|
|
432
|
+
try:
|
|
433
|
+
item = ipaddress.ip_address(host)
|
|
434
|
+
except ValueError:
|
|
435
|
+
continue
|
|
436
|
+
if item.version != address.version or item.is_loopback or item.is_link_local or item.is_unspecified:
|
|
437
|
+
continue
|
|
438
|
+
urls.append(f"http://{'[' + host + ']' if item.version == 6 else host}:{port}")
|
|
439
|
+
return urls
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def tailscale_status(*, home: Path | None = None, platform: str | None = None) -> dict[str, str]:
|
|
443
|
+
"""Inspect existing Tailscale only; never launch the GUI, install or log in.
|
|
444
|
+
|
|
445
|
+
macOS app variants bundle the CLI without necessarily adding it to PATH.
|
|
446
|
+
Keep failures distinct from absence, and do not publish stale logged-out IPs.
|
|
447
|
+
"""
|
|
448
|
+
home = home or Path.home()
|
|
449
|
+
platform = platform or sys.platform
|
|
450
|
+
candidates = [shutil.which("tailscale"), "/usr/local/bin/tailscale", "/usr/bin/tailscale"]
|
|
451
|
+
app_roots = []
|
|
452
|
+
if platform == "darwin":
|
|
453
|
+
candidates.append("/opt/homebrew/bin/tailscale")
|
|
454
|
+
app_roots = [Path("/Applications/Tailscale.app"), home / "Applications/Tailscale.app"]
|
|
455
|
+
candidates.extend(str(root / "Contents/MacOS/Tailscale") for root in app_roots)
|
|
456
|
+
result = {"status": "unavailable" if any(root.is_dir() for root in app_roots) else "not-installed", "ipv4": ""}
|
|
457
|
+
inspected = set()
|
|
458
|
+
deadline = time.monotonic() + 5
|
|
459
|
+
environment = {key: value for key, value in os.environ.items()
|
|
460
|
+
if key in {"HOME", "PATH", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR"}}
|
|
461
|
+
environment["TAILSCALE_BE_CLI"] = "1"
|
|
462
|
+
for candidate in candidates:
|
|
463
|
+
if not candidate or not Path(candidate).is_file() or not os.access(candidate, os.X_OK):
|
|
464
|
+
continue
|
|
465
|
+
resolved = str(Path(candidate).resolve())
|
|
466
|
+
if resolved in inspected:
|
|
467
|
+
continue
|
|
468
|
+
inspected.add(resolved)
|
|
469
|
+
if result["status"] == "not-installed":
|
|
470
|
+
result["status"] = "unavailable"
|
|
471
|
+
remaining = deadline - time.monotonic()
|
|
472
|
+
if remaining <= 0:
|
|
473
|
+
break
|
|
474
|
+
try:
|
|
475
|
+
response = subprocess.run(
|
|
476
|
+
[candidate, "status", "--json"], stdin=subprocess.DEVNULL,
|
|
477
|
+
capture_output=True, text=True, check=False, timeout=min(3, remaining), env=environment,
|
|
478
|
+
)
|
|
479
|
+
if response.returncode or len(response.stdout) > 4 * 1024 * 1024:
|
|
480
|
+
continue
|
|
481
|
+
status = json.loads(response.stdout)
|
|
482
|
+
if not isinstance(status, dict) or not isinstance(status.get("BackendState"), str):
|
|
483
|
+
continue
|
|
484
|
+
if status["BackendState"] != "Running":
|
|
485
|
+
result = {"status": "disconnected", "ipv4": ""}
|
|
486
|
+
continue
|
|
487
|
+
self_status = status.get("Self")
|
|
488
|
+
if isinstance(self_status, dict) and self_status.get("Online") is False:
|
|
489
|
+
result = {"status": "disconnected", "ipv4": ""}
|
|
490
|
+
continue
|
|
491
|
+
addresses = status.get("TailscaleIPs")
|
|
492
|
+
if not isinstance(addresses, list):
|
|
493
|
+
continue
|
|
494
|
+
for value in addresses:
|
|
495
|
+
if not isinstance(value, str):
|
|
496
|
+
continue
|
|
497
|
+
try:
|
|
498
|
+
address = ipaddress.ip_address(value)
|
|
499
|
+
except ValueError:
|
|
500
|
+
continue
|
|
501
|
+
if (address.version == 4 and not address.is_unspecified
|
|
502
|
+
and not address.is_loopback and not address.is_multicast
|
|
503
|
+
and not address.is_link_local and str(address) == value):
|
|
504
|
+
return {"status": "connected", "ipv4": value}
|
|
505
|
+
result = {"status": "connected", "ipv4": ""}
|
|
506
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
507
|
+
continue # Do not echo peer details or CLI errors into setup output.
|
|
508
|
+
return result
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def setup_network_bindings(bind: str, port: int) -> str:
|
|
512
|
+
"""Shell-quoted, fixed-key installer summary. No credentials or mutations."""
|
|
513
|
+
if not 1 <= port <= 65535:
|
|
514
|
+
raise ValueError("Invalid port.")
|
|
515
|
+
address = ipaddress.ip_address("127.0.0.1" if bind == "localhost" else bind.strip("[]"))
|
|
516
|
+
loopback = address.is_loopback
|
|
517
|
+
host = "127.0.0.1" if bind == "localhost" or str(address) == "0.0.0.0" else "::1" if address.is_unspecified else str(address)
|
|
518
|
+
local_url = f"http://{'[' + host + ']' if ':' in host else host}:{port}"
|
|
519
|
+
tailscale = tailscale_status()
|
|
520
|
+
ip = tailscale["ipv4"]
|
|
521
|
+
tail_binding = bool(ip and (str(address) == "0.0.0.0" or str(address) == ip))
|
|
522
|
+
server_url = f"http://{ip}:{port}" if tail_binding else local_url
|
|
523
|
+
candidates = [url.split(" ", 1)[0] for url in candidate_addresses(bind, port)]
|
|
524
|
+
values = {
|
|
525
|
+
"TAILSCALE_STATUS": tailscale["status"], "TAILSCALE_IP": ip,
|
|
526
|
+
"TAILSCALE_BIND_MATCH": "true" if tail_binding else "false",
|
|
527
|
+
"SERVER_LOCAL_ONLY": "true" if loopback else "false", "SERVER_URL": server_url,
|
|
528
|
+
"NETWORK_URLS": "\n".join(url for url in candidates if url != server_url),
|
|
529
|
+
}
|
|
530
|
+
return "\n".join(f"{key}={shlex.quote(value)}" for key, value in values.items())
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def describe(instance: Instance) -> dict:
|
|
534
|
+
env = read_config(instance)
|
|
535
|
+
port = int(env.get("AGENTSDOCK_AGENT_PORT", "7850")) if env else None
|
|
536
|
+
try:
|
|
537
|
+
version = (instance.runtime / "current/VERSION").read_text().strip()[:80]
|
|
538
|
+
except OSError:
|
|
539
|
+
version = "not installed"
|
|
540
|
+
return {
|
|
541
|
+
"name": instance.name, "status": service_status(instance), "port": port,
|
|
542
|
+
"addresses": candidate_addresses(env.get("AGENTSDOCK_AGENT_BIND", "0.0.0.0"), port) if port else [],
|
|
543
|
+
"runtime": str(instance.runtime), "state": str(instance.state),
|
|
544
|
+
"service": str(instance.service_file()),
|
|
545
|
+
"version": version,
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def connection_choices(bind: str, port: int, addresses: list[str], network: dict[str, str]) -> dict:
|
|
550
|
+
"""Label candidates, never infer Tailscale connectivity from an IP range."""
|
|
551
|
+
choices = {"local": [], "lan": [], "other": [], "tailscale": "", "tailscale_note": ""}
|
|
552
|
+
state, tail_ip = network["status"], network["ipv4"]
|
|
553
|
+
try:
|
|
554
|
+
binding = ipaddress.ip_address("127.0.0.1" if bind == "localhost" else bind.strip("[]"))
|
|
555
|
+
except ValueError:
|
|
556
|
+
binding = None
|
|
557
|
+
if state != "connected":
|
|
558
|
+
choices["tailscale_note"] = {
|
|
559
|
+
"disconnected": "Unavailable: Tailscale is disconnected.",
|
|
560
|
+
"not-installed": "Unavailable: Tailscale was not found.",
|
|
561
|
+
}.get(state, "Not verified: could not read Tailscale status.")
|
|
562
|
+
elif not tail_ip:
|
|
563
|
+
choices["tailscale_note"] = "Not verified: Tailscale has no usable IPv4 address."
|
|
564
|
+
elif binding is not None and binding.is_loopback:
|
|
565
|
+
choices["tailscale_note"] = "Unavailable: this server is bound to this machine only."
|
|
566
|
+
elif binding is not None and (str(binding) == "0.0.0.0" or str(binding) == tail_ip):
|
|
567
|
+
choices["tailscale"] = f"http://{tail_ip}:{port}"
|
|
568
|
+
else:
|
|
569
|
+
choices["tailscale_note"] = f"Not verified: server binding {bind!r} does not confirm access via the Tailscale IPv4 address."
|
|
570
|
+
|
|
571
|
+
lan_ranges = tuple(ipaddress.ip_network(value) for value in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"))
|
|
572
|
+
for candidate in addresses:
|
|
573
|
+
url = candidate.split(" ", 1)[0]
|
|
574
|
+
if url == choices["tailscale"]:
|
|
575
|
+
continue
|
|
576
|
+
host = urlsplit(url).hostname
|
|
577
|
+
try:
|
|
578
|
+
address = ipaddress.ip_address(host)
|
|
579
|
+
except ValueError:
|
|
580
|
+
group = "local" if host == "localhost" else "other"
|
|
581
|
+
else:
|
|
582
|
+
if address.is_unspecified or address.is_multicast or address.is_link_local:
|
|
583
|
+
continue
|
|
584
|
+
# An unverified/stale VPN IP must not be presented as ordinary LAN.
|
|
585
|
+
group = "local" if address.is_loopback else "lan" if host != tail_ip and any(address in subnet for subnet in lan_ranges) else "other"
|
|
586
|
+
if url not in choices[group]:
|
|
587
|
+
choices[group].append(url)
|
|
588
|
+
return choices
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def show(instance: Instance, *, network: dict[str, str] | None = None):
|
|
592
|
+
item = describe(instance)
|
|
593
|
+
name = terminal_color(f"{item['name']:<20}", "34")
|
|
594
|
+
summary = f"{name} {item['status']:<10} {str(item['port'] or '—'):<6}"
|
|
595
|
+
if network is None:
|
|
596
|
+
print(summary + " " + " ".join(item["addresses"]))
|
|
597
|
+
return
|
|
598
|
+
print(summary.rstrip())
|
|
599
|
+
if not item["port"]:
|
|
600
|
+
print(" No saved network configuration.\n")
|
|
601
|
+
return
|
|
602
|
+
choices = connection_choices(read_config(instance).get("AGENTSDOCK_AGENT_BIND", "0.0.0.0"), item["port"], item["addresses"], network)
|
|
603
|
+
tail = choices["tailscale"]
|
|
604
|
+
if tail:
|
|
605
|
+
tail += " (recommended)" if item["status"] == "running" else f" (server status: {item['status']})"
|
|
606
|
+
print(f" Tailscale / other networks: {tail or choices['tailscale_note']}")
|
|
607
|
+
print(" Same Wi-Fi / LAN: " + (", ".join(choices["lan"]) or "No address detected for this binding."))
|
|
608
|
+
print(" This machine only: " + (", ".join(choices["local"]) or "No loopback address for this binding."))
|
|
609
|
+
if choices["other"]:
|
|
610
|
+
print(" Other / unverified: " + ", ".join(choices["other"]))
|
|
611
|
+
print()
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def terminal_color(text: str, code: str) -> str:
|
|
615
|
+
color = sys.stdout.isatty() and os.environ.get("TERM") != "dumb" and "NO_COLOR" not in os.environ
|
|
616
|
+
return f"\033[{code}m{text}\033[0m" if color else text
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def confirm_removal(instances: list[Instance], purge: bool, yes: bool) -> None:
|
|
620
|
+
print(terminal_color(f"WARNING: uninstall {len(instances)} AgentsServer instance(s)", "1;31"))
|
|
621
|
+
for instance in instances:
|
|
622
|
+
show(instance)
|
|
623
|
+
print(terminal_color(f" Remove runtime: {instance.runtime}", "31"))
|
|
624
|
+
print(terminal_color(f" Remove configuration/token: {instance.config}", "31"))
|
|
625
|
+
print(terminal_color(f" {'PERMANENTLY DELETE' if purge else 'PRESERVE'} history: {instance.state}", "31" if purge else "32"))
|
|
626
|
+
print("Services and tokens will be removed; reinstalling creates a new access token.")
|
|
627
|
+
if purge:
|
|
628
|
+
print(terminal_color("PERMANENT HISTORY DELETION CANNOT BE UNDONE. --yes cannot bypass confirmation.", "1;31"))
|
|
629
|
+
else:
|
|
630
|
+
print("Chat history and files are preserved. Service removal is reinstallable; deleted configuration is not restored automatically.")
|
|
631
|
+
if not yes and any(instance.name != "default" for instance in instances):
|
|
632
|
+
print("After this confirmation, you can also release each name while keeping its chat history in a local backup.")
|
|
633
|
+
target_names = " ".join(instance.name for instance in instances)
|
|
634
|
+
expected = f"{'delete history' if purge else 'uninstall'} {target_names}"
|
|
635
|
+
if purge or not yes:
|
|
636
|
+
if not sys.stdin.isatty() or input(f"Type {expected!r} to confirm: ") != expected:
|
|
637
|
+
raise ValueError("Not confirmed; nothing was uninstalled.")
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def install_instance(instance: Instance, port: int, bind: str):
|
|
641
|
+
validate_binding(instance)
|
|
642
|
+
command = ["/bin/bash", str(ROOT / "install.sh"), "--instance", instance.name,
|
|
643
|
+
"--no-port-fallback", "--port", str(port), "--bind", bind]
|
|
644
|
+
# Local terminal users get the installer's optional token-copy prompt;
|
|
645
|
+
# redirected/app-driven installs retain the unattended contract.
|
|
646
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
647
|
+
command.append("--non-interactive")
|
|
648
|
+
run(command, env=clean_environment(instance), cwd=ROOT)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def validate_released_instance(instance: Instance) -> None:
|
|
652
|
+
"""Never reset default, an installed service, or a running state owner."""
|
|
653
|
+
if instance.name == "default":
|
|
654
|
+
raise ValueError("The default instance cannot be released.")
|
|
655
|
+
validate_binding(instance)
|
|
656
|
+
if any(path.exists() or path.is_symlink() for path in (instance.runtime, instance.config, instance.service_file())):
|
|
657
|
+
raise ValueError(f"{instance.name}: still installed or has a partial installation. Remove it before reusing its name.")
|
|
658
|
+
if service_status(instance) != "stopped":
|
|
659
|
+
raise ValueError(f"{instance.name}: cannot confirm the old service is stopped; nothing released.")
|
|
660
|
+
if instance.state.exists() and not instance.state.is_dir():
|
|
661
|
+
raise ValueError(f"{instance.name}: preserved state is not a directory.")
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def confirm_name_release(instance: Instance, port: int) -> None:
|
|
665
|
+
print(f"This name was used before: {instance.name}.")
|
|
666
|
+
print(terminal_color(f"The new instance will start with an empty AgentsDock chat list on port {port}.", "31"))
|
|
667
|
+
print(f"Old AgentsDock history, uploads, jobs and credentials at {instance.state} will be moved to a private backup, not erased.")
|
|
668
|
+
print("Original provider chats stored on this machine and project files are not deleted. AgentsDock-only content remains in the backup.")
|
|
669
|
+
print("To keep using the existing history instead, cancel and run:")
|
|
670
|
+
print(f" ./install.sh --instance {instance.name} --port {port}")
|
|
671
|
+
try:
|
|
672
|
+
confirmed = sys.stdin.isatty() and input(f"Release {instance.name!r} and create a fresh instance? [y/N] ").strip().lower() in {"y", "yes"}
|
|
673
|
+
except EOFError:
|
|
674
|
+
confirmed = False
|
|
675
|
+
if not confirmed:
|
|
676
|
+
raise ValueError("Name release not confirmed; existing history was not changed.")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def release_instance_name(instance: Instance, registry: Registry) -> Path | None:
|
|
680
|
+
validate_released_instance(instance) # Recheck after the user prompt.
|
|
681
|
+
if not instance.state.exists():
|
|
682
|
+
return None
|
|
683
|
+
backups = registry.root / "history-backups"
|
|
684
|
+
check_path(backups, registry.home)
|
|
685
|
+
# Holding the old state's lock prevents archiving a running server. Rename
|
|
686
|
+
# the directory itself; never traverse/delete its contents or follow links.
|
|
687
|
+
with exclusive_lock(instance.state / ".server-process.lock"):
|
|
688
|
+
validate_released_instance(instance)
|
|
689
|
+
backups.mkdir(mode=0o700, exist_ok=True)
|
|
690
|
+
destination = Path(tempfile.mkdtemp(prefix=f"{instance.name}-", dir=backups)) / "state"
|
|
691
|
+
instance.state.rename(destination)
|
|
692
|
+
print(terminal_color(f"Preserved old instance data in: {destination}", "32"), flush=True)
|
|
693
|
+
return destination
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def confirm_uninstall_name_release(instance: Instance) -> bool:
|
|
697
|
+
if not sys.stdin.isatty():
|
|
698
|
+
return False
|
|
699
|
+
print(f"\nOptional name release: {instance.name}")
|
|
700
|
+
print("Releasing the name clears this server's saved chat list from active use and makes the name available again.")
|
|
701
|
+
print(terminal_color("No chat history is deleted. Original provider chats stay on your computer.", "32"))
|
|
702
|
+
print(f"This server's saved AgentsDock history, uploads, jobs and credentials at {instance.state} will be moved to a private local backup; its location will be printed.")
|
|
703
|
+
print("A new server using this name starts with an empty chat list; the old AgentsDock-only content remains in the backup.")
|
|
704
|
+
print("Project files, earlier backups and independent terminal sessions are kept. Enter keeps the name and saved data in place.")
|
|
705
|
+
try:
|
|
706
|
+
return input("Do you want to release this name as well? [y/N] ").strip().lower() in {"y", "yes"}
|
|
707
|
+
except EOFError:
|
|
708
|
+
return False
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def release_uninstalled_name(instance: Instance, registry: Registry) -> None:
|
|
712
|
+
# Called only after a successful uninstall and separate affirmative answer.
|
|
713
|
+
# Preserve even AgentsDock-only content before freeing the name. Reuse the
|
|
714
|
+
# guarded archive path used by `new`, not the explicit --purge-state action.
|
|
715
|
+
release_instance_name(instance, registry)
|
|
716
|
+
registry.forget(instance)
|
|
717
|
+
print(terminal_color(f"Released name {instance.name!r}; it can be used for a new instance.", "32"), flush=True)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def control(instance: Instance, action: str, platform: str = sys.platform):
|
|
721
|
+
validate_binding(instance, platform)
|
|
722
|
+
if not instance.service_file(platform).exists():
|
|
723
|
+
raise ValueError(f"{instance.name}: no installed service.")
|
|
724
|
+
if platform == "darwin":
|
|
725
|
+
target = f"gui/{os.getuid()}/{launchd_label(instance.name)}"
|
|
726
|
+
status = service_status(instance, platform)
|
|
727
|
+
if status == "unknown":
|
|
728
|
+
raise ValueError("Cannot establish service state; no service changes made.")
|
|
729
|
+
if action == "stop":
|
|
730
|
+
if status != "stopped":
|
|
731
|
+
run(["launchctl", "bootout", target])
|
|
732
|
+
elif status == "stopped":
|
|
733
|
+
run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(instance.service_file(platform))])
|
|
734
|
+
elif action == "restart":
|
|
735
|
+
run(["launchctl", "bootout", target])
|
|
736
|
+
deadline = time.monotonic() + 180
|
|
737
|
+
while service_status(instance, platform) != "stopped":
|
|
738
|
+
if time.monotonic() >= deadline:
|
|
739
|
+
raise ValueError("Service did not stop; refusing to start a second process.")
|
|
740
|
+
time.sleep(0.1)
|
|
741
|
+
run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(instance.service_file(platform))])
|
|
742
|
+
else:
|
|
743
|
+
run(["systemctl", "--user", action, service_name(instance.name) + ".service"])
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def parser() -> argparse.ArgumentParser:
|
|
747
|
+
result = argparse.ArgumentParser(description="Independent AgentsServer instances for the current OS user. Defaults to list.")
|
|
748
|
+
commands = result.add_subparsers(dest="command")
|
|
749
|
+
commands.add_parser("list")
|
|
750
|
+
info = commands.add_parser("info")
|
|
751
|
+
info.add_argument("name", type=instance_name)
|
|
752
|
+
new = commands.add_parser("new")
|
|
753
|
+
new.add_argument("--name", type=instance_name)
|
|
754
|
+
new.add_argument("--port", type=int)
|
|
755
|
+
new.add_argument("--bind", default="0.0.0.0")
|
|
756
|
+
for action in ("start", "stop", "restart", "update", "remove"):
|
|
757
|
+
command = commands.add_parser(action)
|
|
758
|
+
command.add_argument("name", nargs="?", type=instance_name)
|
|
759
|
+
command.add_argument("--instance", dest="named", type=instance_name)
|
|
760
|
+
command.add_argument("--all", action="store_true")
|
|
761
|
+
command.add_argument("--exclude", action="append", default=[], type=instance_name)
|
|
762
|
+
if action == "remove":
|
|
763
|
+
command.add_argument("--yes", action="store_true")
|
|
764
|
+
command.add_argument("--purge-state", action="store_true")
|
|
765
|
+
install = commands.add_parser("install")
|
|
766
|
+
install.add_argument("--manifest", type=Path, required=True)
|
|
767
|
+
# Internal shared bindings; pure read-only output, safely shell-quoted.
|
|
768
|
+
bindings = commands.add_parser("_bindings", help=argparse.SUPPRESS)
|
|
769
|
+
bindings.add_argument("name", type=instance_name)
|
|
770
|
+
network = commands.add_parser("_setup-network", help=argparse.SUPPRESS)
|
|
771
|
+
network.add_argument("--bind", required=True)
|
|
772
|
+
network.add_argument("--port", type=int, required=True)
|
|
773
|
+
return result
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def main(argv: list[str] | None = None) -> int:
|
|
777
|
+
args = parser().parse_args(argv)
|
|
778
|
+
registry = Registry()
|
|
779
|
+
try:
|
|
780
|
+
if args.command == "_setup-network":
|
|
781
|
+
print(setup_network_bindings(args.bind, args.port))
|
|
782
|
+
return 0
|
|
783
|
+
if args.command == "_bindings":
|
|
784
|
+
instance = Instance(args.name, registry.home)
|
|
785
|
+
validate_binding(instance)
|
|
786
|
+
print(instance.shell_bindings())
|
|
787
|
+
return 0
|
|
788
|
+
if args.command in {None, "list", "info"}:
|
|
789
|
+
items = registry.instances()
|
|
790
|
+
if args.command == "info":
|
|
791
|
+
items = [item for item in items if item.name == args.name]
|
|
792
|
+
if not items:
|
|
793
|
+
raise ValueError("Unknown instance.")
|
|
794
|
+
print(json.dumps(describe(items[0]), indent=2))
|
|
795
|
+
else:
|
|
796
|
+
network = tailscale_status() if items else None
|
|
797
|
+
print("NAME STATUS PORT")
|
|
798
|
+
for item in items:
|
|
799
|
+
show(item, network=network)
|
|
800
|
+
if not items:
|
|
801
|
+
print("No installations found. Run ./install.sh for default, or ./instances.sh new.")
|
|
802
|
+
return 0
|
|
803
|
+
with registry.locked():
|
|
804
|
+
existing = {item.name: item for item in registry.instances(include_removed=True)}
|
|
805
|
+
if args.command in {"new", "install"}:
|
|
806
|
+
entries = [{"name": args.name, "port": args.port, "bind": args.bind}] if args.command == "new" else json.loads(args.manifest.read_text())
|
|
807
|
+
if not isinstance(entries, list) or not entries:
|
|
808
|
+
raise ValueError("Manifest must be a nonempty JSON array of {name, port?, bind?} objects.")
|
|
809
|
+
plan = []
|
|
810
|
+
records = registry.records()
|
|
811
|
+
names, ports, planned_names = set(existing), set(), set()
|
|
812
|
+
for entry in entries:
|
|
813
|
+
if not isinstance(entry, dict) or set(entry) - {"name", "port", "bind"}:
|
|
814
|
+
raise ValueError("Invalid manifest entry.")
|
|
815
|
+
name = entry.get("name")
|
|
816
|
+
if name is None:
|
|
817
|
+
index = 1
|
|
818
|
+
while f"instance-{index}" in names:
|
|
819
|
+
index += 1
|
|
820
|
+
name = f"instance-{index}"
|
|
821
|
+
if name == "default" or name in planned_names:
|
|
822
|
+
raise ValueError(f"Instance {name!r} already exists/reserved.")
|
|
823
|
+
instance = Instance(instance_name(name), registry.home)
|
|
824
|
+
record = records.get(name)
|
|
825
|
+
reuse = record is not None and record["status"] in {"removed", "failed"}
|
|
826
|
+
if name in names and not reuse:
|
|
827
|
+
raise ValueError(f"Instance {name!r} already exists/reserved. Use update.")
|
|
828
|
+
if reuse:
|
|
829
|
+
validate_released_instance(instance)
|
|
830
|
+
elif any(item.exists() or item.is_symlink() for item in (instance.runtime, instance.config, instance.state, instance.service_file())):
|
|
831
|
+
raise ValueError(f"{name}: existing unmanaged files; refusing to adopt or overwrite.")
|
|
832
|
+
validate_binding(instance)
|
|
833
|
+
requested_port = entry.get("port")
|
|
834
|
+
if reuse and requested_port is None:
|
|
835
|
+
requested_port = record.get("port")
|
|
836
|
+
if requested_port is None and instance.state.exists():
|
|
837
|
+
raise ValueError(f"{name}: the previous port was not recorded. Specify --port to reuse this name.")
|
|
838
|
+
port = select_port(registry, requested_port, ports)
|
|
839
|
+
bind = entry.get("bind", "0.0.0.0")
|
|
840
|
+
ipaddress.ip_address(bind) # Literal bind addresses only; no shell/XML injection.
|
|
841
|
+
names.add(name)
|
|
842
|
+
planned_names.add(name)
|
|
843
|
+
ports.add(port)
|
|
844
|
+
# A failed preflight with no state has nothing to release;
|
|
845
|
+
# retry it directly. Removed names always require consent.
|
|
846
|
+
release = reuse and (record["status"] == "removed" or instance.state.exists())
|
|
847
|
+
plan.append((instance, port, bind, release))
|
|
848
|
+
# Validate the entire manifest and collect all confirmations
|
|
849
|
+
# before moving any history or installing any instance.
|
|
850
|
+
for item, port, _, release in plan:
|
|
851
|
+
if release:
|
|
852
|
+
confirm_name_release(item, port)
|
|
853
|
+
print("Create: " + ", ".join(f"{item.name}:{port}" for item, port, _, _ in plan), flush=True)
|
|
854
|
+
failures = 0
|
|
855
|
+
for item, port, bind, release in plan:
|
|
856
|
+
backup = None
|
|
857
|
+
try:
|
|
858
|
+
if not port_available(port):
|
|
859
|
+
raise ValueError(f"Port {port} became occupied; no instance history was moved.")
|
|
860
|
+
if release:
|
|
861
|
+
backup = release_instance_name(item, registry)
|
|
862
|
+
registry.save(item, "pending", port)
|
|
863
|
+
install_instance(item, port, bind)
|
|
864
|
+
registry.save(item, "installed", port)
|
|
865
|
+
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
|
|
866
|
+
registry.save(item, "failed", port)
|
|
867
|
+
print(f"{item.name}: failed ({exc}); other instances were not rolled back.", file=sys.stderr)
|
|
868
|
+
if backup is not None:
|
|
869
|
+
print(f"Original instance data is safe in {backup}; it was not deleted.", file=sys.stderr)
|
|
870
|
+
failures += 1
|
|
871
|
+
return int(bool(failures))
|
|
872
|
+
name = args.name or args.named
|
|
873
|
+
if (args.name and args.named) or bool(name) == bool(args.all) or (args.exclude and not args.all):
|
|
874
|
+
raise ValueError("Select exactly one instance or --all; --exclude requires --all.")
|
|
875
|
+
unknown_exclusions = set(args.exclude) - set(existing)
|
|
876
|
+
if unknown_exclusions:
|
|
877
|
+
raise ValueError("Unknown excluded instance(s): " + ", ".join(sorted(unknown_exclusions)))
|
|
878
|
+
selected = registry.instances() if args.all else [existing[name]] if name in existing else []
|
|
879
|
+
selected = [item for item in selected if item.name not in args.exclude]
|
|
880
|
+
if not selected:
|
|
881
|
+
raise ValueError("No matching instances; nothing changed.")
|
|
882
|
+
for item in selected:
|
|
883
|
+
validate_binding(item)
|
|
884
|
+
release_names = set()
|
|
885
|
+
if args.command == "remove":
|
|
886
|
+
confirm_removal(selected, args.purge_state, args.yes)
|
|
887
|
+
if not args.purge_state and not args.yes:
|
|
888
|
+
for item in selected:
|
|
889
|
+
if item.name != "default" and confirm_uninstall_name_release(item):
|
|
890
|
+
release_names.add(item.name)
|
|
891
|
+
failures = 0
|
|
892
|
+
for item in selected:
|
|
893
|
+
try:
|
|
894
|
+
if args.command == "remove":
|
|
895
|
+
old_port = read_config(item).get("AGENTSDOCK_AGENT_PORT")
|
|
896
|
+
command = ["/bin/bash", str(ROOT / "uninstall.sh"), "--managed-instance", item.name, "--yes"]
|
|
897
|
+
if args.purge_state:
|
|
898
|
+
command.append("--purge-state") # Still asks for each exact state path.
|
|
899
|
+
elif item.name in release_names:
|
|
900
|
+
# The manager will print the backup location; direct
|
|
901
|
+
# reinstall advice would incorrectly imply reuse.
|
|
902
|
+
command.append("--managed-release-name")
|
|
903
|
+
run(command, env=clean_environment(item), cwd=ROOT)
|
|
904
|
+
registry.save(item, "removed", int(old_port) if old_port else None)
|
|
905
|
+
if args.purge_state and item.name != "default" and not item.state.exists():
|
|
906
|
+
registry.forget(item)
|
|
907
|
+
elif item.name in release_names:
|
|
908
|
+
release_uninstalled_name(item, registry)
|
|
909
|
+
elif not args.purge_state and not args.yes and item.name != "default":
|
|
910
|
+
print(f"Kept name {item.name!r} and its saved data.", flush=True)
|
|
911
|
+
elif args.command == "update":
|
|
912
|
+
env = read_config(item)
|
|
913
|
+
install_instance(item, int(env["AGENTSDOCK_AGENT_PORT"]), env["AGENTSDOCK_AGENT_BIND"])
|
|
914
|
+
registry.save(item, "installed")
|
|
915
|
+
else:
|
|
916
|
+
control(item, args.command)
|
|
917
|
+
if args.command != "remove":
|
|
918
|
+
print(f"{item.name}: {args.command} completed", flush=True)
|
|
919
|
+
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as exc:
|
|
920
|
+
print(f"{item.name}: failed ({exc})", file=sys.stderr)
|
|
921
|
+
failures += 1
|
|
922
|
+
if args.command == "remove" and not failures:
|
|
923
|
+
print("\n" + terminal_color("Successful!", "32"), flush=True)
|
|
924
|
+
return int(bool(failures))
|
|
925
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
926
|
+
print(f"Instance manager: {exc}", file=sys.stderr)
|
|
927
|
+
return 1
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
if __name__ == "__main__":
|
|
931
|
+
raise SystemExit(main())
|