@agentsdock/server 1.0.9 → 1.0.10-beta.4
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/npm/cli.cjs +6 -2
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +1261 -2151
- package/server/claude_goals.py +16 -4
- package/server/codex_app_server.py +4 -0
- package/server/codex_auth.py +31 -0
- package/server/codex_history_repair.py +144 -39
- package/server/codex_provider.py +52 -2
- package/server/codex_response_stream.py +229 -0
- package/server/install.sh +3 -3
- package/server/server_instances.py +166 -2
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Keep nonstandard custom Responses errors visible to native Codex.
|
|
2
|
+
|
|
3
|
+
An owned loopback transport forwards requests unchanged to one immutable endpoint.
|
|
4
|
+
Only untyped SSE error packets are normalized; native Codex owns all retries.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from contextlib import contextmanager, suppress
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import secrets
|
|
13
|
+
import socket
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import uvicorn
|
|
18
|
+
from fastapi import FastAPI, Request
|
|
19
|
+
from fastapi.responses import Response, StreamingResponse
|
|
20
|
+
|
|
21
|
+
from codex_app_server import CodexAppServerManager
|
|
22
|
+
from codex_provider import ENV_KEY, PROVIDER_ID, config_args
|
|
23
|
+
|
|
24
|
+
# Larger frames pass through unchanged, rather than buffering arbitrary output.
|
|
25
|
+
# This is a parser memory bound, not a response or content size limit.
|
|
26
|
+
_ERROR_FRAME_BYTES = 64 * 1024
|
|
27
|
+
_HOP_HEADERS = {'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
28
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length'}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ErrorFrames:
|
|
32
|
+
def __init__(self, secret: str):
|
|
33
|
+
self.secret = secret
|
|
34
|
+
self.pending = bytearray()
|
|
35
|
+
self.passthrough = False
|
|
36
|
+
self.response_id = 'resp_custom_endpoint_error'
|
|
37
|
+
|
|
38
|
+
def frame(self, raw: bytes) -> bytes:
|
|
39
|
+
try:
|
|
40
|
+
data = b'\n'.join(line[5:].lstrip(b' ') for line in raw.splitlines() if line.startswith(b'data:'))
|
|
41
|
+
packet = json.loads(data)
|
|
42
|
+
except (ValueError, UnicodeError):
|
|
43
|
+
return raw
|
|
44
|
+
if not isinstance(packet, dict):
|
|
45
|
+
return raw
|
|
46
|
+
response = packet.get('response')
|
|
47
|
+
if isinstance(response, dict) and isinstance(response.get('id'), str):
|
|
48
|
+
self.response_id = response['id']
|
|
49
|
+
error = packet.get('error')
|
|
50
|
+
if packet.get('type') or not error:
|
|
51
|
+
return raw
|
|
52
|
+
if isinstance(error, dict):
|
|
53
|
+
message = error.get('message')
|
|
54
|
+
if not isinstance(message, str) or not message:
|
|
55
|
+
return raw
|
|
56
|
+
code = str(error.get('code') or error.get('type') or 'server_error')
|
|
57
|
+
elif isinstance(error, str):
|
|
58
|
+
message, code = error, 'server_error'
|
|
59
|
+
else:
|
|
60
|
+
return raw
|
|
61
|
+
code = {'429': 'rate_limit_exceeded', '401': 'invalid_api_key',
|
|
62
|
+
'403': 'permission_denied', '400': 'invalid_request_error',
|
|
63
|
+
'500': 'server_error', '502': 'server_error', '503': 'server_error'}.get(code, code)
|
|
64
|
+
for value in (self.secret, quote(self.secret, safe='')):
|
|
65
|
+
if value:
|
|
66
|
+
message = message.replace(value, '[redacted]')
|
|
67
|
+
failed = {'type': 'response.failed', 'response': {
|
|
68
|
+
'id': self.response_id, 'object': 'response', 'created_at': 0,
|
|
69
|
+
'status': 'failed', 'output': [], 'error': {'code': code, 'message': message}}}
|
|
70
|
+
return b'event: response.failed\ndata: ' + json.dumps(failed, ensure_ascii=False).encode() + b'\n\n'
|
|
71
|
+
|
|
72
|
+
async def stream(self, chunks):
|
|
73
|
+
# Split at SSE blank lines, including CRLF boundaries across chunks.
|
|
74
|
+
async for chunk in chunks:
|
|
75
|
+
self.pending.extend(chunk)
|
|
76
|
+
while self.pending:
|
|
77
|
+
lf = self.pending.find(b'\n\n')
|
|
78
|
+
crlf = self.pending.find(b'\r\n\r\n')
|
|
79
|
+
endings = [(i, n) for i, n in ((lf, 2), (crlf, 4)) if i >= 0]
|
|
80
|
+
if endings:
|
|
81
|
+
i, n = min(endings)
|
|
82
|
+
raw = bytes(self.pending[:i+n]); del self.pending[:i+n]
|
|
83
|
+
yield raw if self.passthrough or len(raw) > _ERROR_FRAME_BYTES else self.frame(raw)
|
|
84
|
+
self.passthrough = False
|
|
85
|
+
elif len(self.pending) > _ERROR_FRAME_BYTES:
|
|
86
|
+
# Retain only a delimiter tail; do not truncate a large frame.
|
|
87
|
+
yield bytes(self.pending[:-3]); del self.pending[:-3]
|
|
88
|
+
self.passthrough = True
|
|
89
|
+
else:
|
|
90
|
+
break
|
|
91
|
+
if self.pending:
|
|
92
|
+
raw = bytes(self.pending); self.pending.clear()
|
|
93
|
+
yield raw if self.passthrough else self.frame(raw)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _OwnedConfig(uvicorn.Config):
|
|
97
|
+
def configure_logging(self):
|
|
98
|
+
# Do not change the host server's process-wide logging configuration.
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _OwnedServer(uvicorn.Server):
|
|
103
|
+
@contextmanager
|
|
104
|
+
def capture_signals(self):
|
|
105
|
+
# This transport must never take over its host server's signal handlers.
|
|
106
|
+
yield
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ResponseStreamBridge:
|
|
110
|
+
def __init__(self, selected: dict):
|
|
111
|
+
self.upstream = selected['base_url'].rstrip('/')
|
|
112
|
+
self.key = selected['api_key']
|
|
113
|
+
self.token = secrets.token_urlsafe(32)
|
|
114
|
+
self.url = ''
|
|
115
|
+
self.task = self.server = self.client = self.socket = None
|
|
116
|
+
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
|
117
|
+
app.add_api_route('/v1/{path:path}', self.forward, methods=['POST', 'GET', 'DELETE'])
|
|
118
|
+
self.app = app
|
|
119
|
+
|
|
120
|
+
async def start(self):
|
|
121
|
+
if self.task is not None and not self.task.done():
|
|
122
|
+
return
|
|
123
|
+
self.client = httpx.AsyncClient(timeout=None, follow_redirects=False, trust_env=False)
|
|
124
|
+
self.socket = socket.socket()
|
|
125
|
+
self.socket.bind(('127.0.0.1', 0)); self.socket.listen(128); self.socket.setblocking(False)
|
|
126
|
+
self.url = f'http://127.0.0.1:{self.socket.getsockname()[1]}/v1'
|
|
127
|
+
self.server = _OwnedServer(_OwnedConfig(self.app, log_config=None, access_log=False, lifespan='off', log_level='critical'))
|
|
128
|
+
self.task = asyncio.create_task(self.server.serve(sockets=[self.socket]))
|
|
129
|
+
try:
|
|
130
|
+
while not self.server.started:
|
|
131
|
+
if self.task.done():
|
|
132
|
+
await self.task
|
|
133
|
+
raise RuntimeError('Custom response transport could not start')
|
|
134
|
+
await asyncio.sleep(0)
|
|
135
|
+
except BaseException:
|
|
136
|
+
await self.close()
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
async def forward(self, request: Request, path: str):
|
|
140
|
+
if not hmac.compare_digest(request.headers.get('authorization', ''), 'Bearer ' + self.token):
|
|
141
|
+
return Response(status_code=401)
|
|
142
|
+
# The destination is immutable. A request cannot name a different origin.
|
|
143
|
+
if not (path == 'responses' or path.startswith('responses/')) or '..' in path.split('/'):
|
|
144
|
+
return Response(status_code=404)
|
|
145
|
+
headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP_HEADERS | {'authorization', 'accept-encoding', 'cookie'}}
|
|
146
|
+
headers.update(authorization='Bearer ' + self.key, **{'accept-encoding': 'identity'})
|
|
147
|
+
if 'content-length' in request.headers:
|
|
148
|
+
headers['content-length'] = request.headers['content-length']
|
|
149
|
+
uploaded = asyncio.Event()
|
|
150
|
+
async def upload():
|
|
151
|
+
try:
|
|
152
|
+
async for chunk in request.stream(): yield chunk
|
|
153
|
+
finally:
|
|
154
|
+
uploaded.set()
|
|
155
|
+
async def disconnected():
|
|
156
|
+
# Wait for the upload reader to finish before reading disconnects.
|
|
157
|
+
# No polling, execution deadline, or competing request-body reader.
|
|
158
|
+
await uploaded.wait()
|
|
159
|
+
while (await request.receive()).get('type') != 'http.disconnect':
|
|
160
|
+
pass
|
|
161
|
+
req = self.client.build_request(request.method, self.upstream + '/' + path,
|
|
162
|
+
params=request.query_params.multi_items(), headers=headers, content=upload())
|
|
163
|
+
sending = asyncio.create_task(self.client.send(req, stream=True))
|
|
164
|
+
disconnect = asyncio.create_task(disconnected())
|
|
165
|
+
try:
|
|
166
|
+
done, _ = await asyncio.wait((sending, disconnect), return_when=asyncio.FIRST_COMPLETED)
|
|
167
|
+
if disconnect in done:
|
|
168
|
+
if sending.done() and not sending.cancelled() and sending.exception() is None:
|
|
169
|
+
await sending.result().aclose()
|
|
170
|
+
return Response(status_code=499)
|
|
171
|
+
upstream = sending.result()
|
|
172
|
+
except httpx.HTTPError:
|
|
173
|
+
return Response('Custom endpoint connection failed', status_code=502)
|
|
174
|
+
finally:
|
|
175
|
+
for task in (sending, disconnect):
|
|
176
|
+
if not task.done(): task.cancel()
|
|
177
|
+
await asyncio.gather(sending, disconnect, return_exceptions=True)
|
|
178
|
+
headers = {k: v for k, v in upstream.headers.items() if k.lower() not in _HOP_HEADERS | {'content-encoding', 'set-cookie'}}
|
|
179
|
+
async def body():
|
|
180
|
+
try:
|
|
181
|
+
chunks = upstream.aiter_bytes()
|
|
182
|
+
if upstream.status_code == 200 and 'text/event-stream' in upstream.headers.get('content-type', '').lower():
|
|
183
|
+
chunks = ErrorFrames(self.key).stream(chunks)
|
|
184
|
+
async for chunk in chunks:
|
|
185
|
+
yield chunk
|
|
186
|
+
finally:
|
|
187
|
+
await upstream.aclose()
|
|
188
|
+
return StreamingResponse(body(), status_code=upstream.status_code, headers=headers)
|
|
189
|
+
|
|
190
|
+
async def close(self):
|
|
191
|
+
if self.server is not None:
|
|
192
|
+
self.server.should_exit = True
|
|
193
|
+
# The owning native process has already closed; cancel only this
|
|
194
|
+
# listener's in-flight transports, including a silent upstream.
|
|
195
|
+
tasks = tuple(self.server.server_state.tasks)
|
|
196
|
+
for task in tasks: task.cancel()
|
|
197
|
+
if tasks: await asyncio.gather(*tasks, return_exceptions=True)
|
|
198
|
+
if self.task is not None:
|
|
199
|
+
# Let Uvicorn close its asyncio listeners and remove their readers.
|
|
200
|
+
# Cancelling serve() skips shutdown and can leave a stale selector
|
|
201
|
+
# entry when the next provider reuses the same socket descriptor.
|
|
202
|
+
with suppress(asyncio.CancelledError): await self.task
|
|
203
|
+
if self.client is not None: await self.client.aclose()
|
|
204
|
+
if self.socket is not None: self.socket.close()
|
|
205
|
+
self.task = self.server = self.client = self.socket = None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class CustomCodexAppServerManager(CodexAppServerManager):
|
|
209
|
+
"""Native manager with an owned, credential-scoped stream normalizer."""
|
|
210
|
+
def __init__(self, *args, selected: dict, **kwargs):
|
|
211
|
+
self.response_bridge = ResponseStreamBridge(selected)
|
|
212
|
+
prepare = kwargs.pop('before_start', None)
|
|
213
|
+
environment = kwargs.pop('env_factory')
|
|
214
|
+
original_args = tuple(kwargs.get('app_server_args', ()))
|
|
215
|
+
async def before_start():
|
|
216
|
+
if prepare is not None: await prepare()
|
|
217
|
+
await self.response_bridge.start()
|
|
218
|
+
self.client.app_server_args = (*original_args, *config_args({
|
|
219
|
+
f'model_providers.{PROVIDER_ID}.base_url': self.response_bridge.url}))
|
|
220
|
+
def env_factory():
|
|
221
|
+
return {**environment(), ENV_KEY: self.response_bridge.token}
|
|
222
|
+
kwargs['sensitive_values'] = (*kwargs.get('sensitive_values', ()), self.response_bridge.token)
|
|
223
|
+
super().__init__(*args, before_start=before_start, env_factory=env_factory, **kwargs)
|
|
224
|
+
|
|
225
|
+
async def close(self):
|
|
226
|
+
try:
|
|
227
|
+
await super().close()
|
|
228
|
+
finally:
|
|
229
|
+
await self.response_bridge.close()
|
package/server/install.sh
CHANGED
|
@@ -1839,7 +1839,7 @@ if [[ "$TEAM_HUB_MODE" == "host" && "$TEAM_HUB_OPERATION_PENDING" != "true" \
|
|
|
1839
1839
|
fi
|
|
1840
1840
|
fi
|
|
1841
1841
|
|
|
1842
|
-
RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py cursor_process_guard.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
|
|
1842
|
+
RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py codex_response_stream.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py cursor_process_guard.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
|
|
1843
1843
|
RELEASE_DIRECTORIES=(agentsdock_team_hub)
|
|
1844
1844
|
TEAM_HUB_RELEASE_FILES=(
|
|
1845
1845
|
__init__.py
|
|
@@ -3751,7 +3751,7 @@ validate_staged_release_runtime() (
|
|
|
3751
3751
|
"$STAGE_DIR/claude_background_reconciliation.py" \
|
|
3752
3752
|
"$STAGE_DIR/codex_app_server.py" \
|
|
3753
3753
|
"$STAGE_DIR/codex_auth.py" \
|
|
3754
|
-
"$STAGE_DIR/codex_provider.py" \
|
|
3754
|
+
"$STAGE_DIR/codex_provider.py" "$STAGE_DIR/codex_response_stream.py" \
|
|
3755
3755
|
"$STAGE_DIR/provider_connections.py" "$STAGE_DIR/cursor_api_key.py" \
|
|
3756
3756
|
"$STAGE_DIR/side_questions.py" \
|
|
3757
3757
|
"$STAGE_DIR/workspace_git.py" \
|
|
@@ -3781,7 +3781,7 @@ validate_staged_release_runtime() (
|
|
|
3781
3781
|
"$STAGE_DIR/update_runner.py"
|
|
3782
3782
|
"$STAGE_DIR/.venv/bin/python" -m py_compile "$STAGE_DIR/server_instances.py"
|
|
3783
3783
|
"$STAGE_DIR/.venv/bin/python" -m compileall -q "$STAGE_DIR/agentsdock_team_hub"
|
|
3784
|
-
PYTHONPATH="$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c 'import execution_activation, execution_preparation, update_preparation, update_handoff, update_recovery, execution_update_status, execution_uninstall, execution_recovery, execution_recovery_status, execution_http, execution_durability, execution_control, execution_install, execution_maintenance, execution_manage, execution_ownership, execution_service, execution_transport; import workspace_git, claude_model_catalog, server_instances, local_session_ownership, cursor_history; import codex_auth, codex_provider, provider_connections, cursor_api_key, side_questions, title_generation, codex_side_question, codex_side_chat_progress, claude_side_question, agentsdock_team_hub, cursor_agent_client, cursor_provider_mcp, opencode_agent_client, cursor_process_guard, secure_peer_delivery, secure_peer_runtime, team_mail_runtime, team_mail_websocket, team_mail_grants, team_hub_host, agentsdock_mail, agentsdock_team, claude_history_repair, claude_history_provenance, claude_goals, claude_background_reconciliation, codex_history_repair, public_chat_shares, public_chat_transcript, public_chat_share_routes, interactive_chat_shares, interactive_chat_share_routes, interactive_chat_share_web, interactive_chat_projection, interactive_chat_runtime, interactive_chat_native, shared_chat_videos, shared_chat_video_stream, interactive_chat_controls, chat_mailbox, provider_commands, provider_usage; from agentsdock_team_hub import secure_peer, secure_peer_hub' >/dev/null
|
|
3784
|
+
PYTHONPATH="$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c 'import execution_activation, execution_preparation, update_preparation, update_handoff, update_recovery, execution_update_status, execution_uninstall, execution_recovery, execution_recovery_status, execution_http, execution_durability, execution_control, execution_install, execution_maintenance, execution_manage, execution_ownership, execution_service, execution_transport; import workspace_git, claude_model_catalog, server_instances, local_session_ownership, cursor_history; import codex_auth, codex_provider, codex_response_stream, provider_connections, cursor_api_key, side_questions, title_generation, codex_side_question, codex_side_chat_progress, claude_side_question, agentsdock_team_hub, cursor_agent_client, cursor_provider_mcp, opencode_agent_client, cursor_process_guard, secure_peer_delivery, secure_peer_runtime, team_mail_runtime, team_mail_websocket, team_mail_grants, team_hub_host, agentsdock_mail, agentsdock_team, claude_history_repair, claude_history_provenance, claude_goals, claude_background_reconciliation, codex_history_repair, public_chat_shares, public_chat_transcript, public_chat_share_routes, interactive_chat_shares, interactive_chat_share_routes, interactive_chat_share_web, interactive_chat_projection, interactive_chat_runtime, interactive_chat_native, shared_chat_videos, shared_chat_video_stream, interactive_chat_controls, chat_mailbox, provider_commands, provider_usage; from agentsdock_team_hub import secure_peer, secure_peer_hub' >/dev/null
|
|
3785
3785
|
)
|
|
3786
3786
|
|
|
3787
3787
|
abort_unclaimed_team_hub_reactivation() {
|
|
@@ -17,6 +17,7 @@ import os
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
import plistlib
|
|
19
19
|
import re
|
|
20
|
+
import select
|
|
20
21
|
import shlex
|
|
21
22
|
import shutil
|
|
22
23
|
import socket
|
|
@@ -24,7 +25,9 @@ import stat
|
|
|
24
25
|
import subprocess
|
|
25
26
|
import sys
|
|
26
27
|
import tempfile
|
|
28
|
+
import termios
|
|
27
29
|
import time
|
|
30
|
+
import tty
|
|
28
31
|
from urllib.parse import urlsplit
|
|
29
32
|
|
|
30
33
|
INSTANCE_PROTOCOL = 1
|
|
@@ -611,6 +614,147 @@ def show(instance: Instance, *, network: dict[str, str] | None = None):
|
|
|
611
614
|
print()
|
|
612
615
|
|
|
613
616
|
|
|
617
|
+
def show_status(instance: Instance, *, network: dict[str, str]) -> None:
|
|
618
|
+
"""Read-only per-instance summary; never print configuration or tokens."""
|
|
619
|
+
item = describe(instance)
|
|
620
|
+
print(terminal_color(f"Server ({item['name']}):", "34"))
|
|
621
|
+
print(f" Status: {item['status']}")
|
|
622
|
+
addresses = []
|
|
623
|
+
if item["port"]:
|
|
624
|
+
choices = connection_choices(read_config(instance).get("AGENTSDOCK_AGENT_BIND", "0.0.0.0"),
|
|
625
|
+
item["port"], item["addresses"], network)
|
|
626
|
+
if choices["tailscale"]:
|
|
627
|
+
addresses.append(f"{choices['tailscale']} (Tailscale / other networks)")
|
|
628
|
+
for key, label in (("lan", "Same Wi-Fi / LAN"), ("local", "This machine only"),
|
|
629
|
+
("other", "Other / unverified")):
|
|
630
|
+
addresses.extend(f"{url} ({label})" for url in choices[key])
|
|
631
|
+
print(" Address: " + (addresses[0] if addresses else "Not configured"))
|
|
632
|
+
for address in addresses[1:]:
|
|
633
|
+
print(" " + address)
|
|
634
|
+
print(f" Version: {item['version'] or 'unknown'}")
|
|
635
|
+
print(f" Port: {item['port'] or 'Not configured'}")
|
|
636
|
+
print()
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def read_token_menu_key(fd: int) -> str:
|
|
640
|
+
key = os.read(fd, 1)
|
|
641
|
+
if key in (b"", b"\x03", b"\x04"):
|
|
642
|
+
return "cancel"
|
|
643
|
+
if key in (b"\r", b"\n"):
|
|
644
|
+
return "enter"
|
|
645
|
+
if key != b"\x1b":
|
|
646
|
+
return "other"
|
|
647
|
+
sequence = b""
|
|
648
|
+
while len(sequence) < 16 and select.select([fd], [], [], 0.15)[0]:
|
|
649
|
+
part = os.read(fd, 1)
|
|
650
|
+
if not part:
|
|
651
|
+
return "cancel"
|
|
652
|
+
sequence += part
|
|
653
|
+
if sequence in (b"[A", b"OA"):
|
|
654
|
+
return "up"
|
|
655
|
+
if sequence in (b"[B", b"OB"):
|
|
656
|
+
return "down"
|
|
657
|
+
if sequence[:1] not in (b"[", b"O") or (len(sequence) > 1 and 0x40 <= part[0] <= 0x7e):
|
|
658
|
+
return "other"
|
|
659
|
+
return "cancel" if not sequence else "other"
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def arrow_token_choice(rows: list[str], fd: int, settings: list) -> int | None:
|
|
663
|
+
"""Render a bounded terminal menu and restore input mode before token output."""
|
|
664
|
+
size = shutil.get_terminal_size(fallback=(80, 24))
|
|
665
|
+
visible = min(len(rows), max(1, size.lines - 6), 10)
|
|
666
|
+
width = max(1, size.columns - 1)
|
|
667
|
+
selected, start = 0, 0
|
|
668
|
+
hint = "Use ↑/↓ to choose; Enter to select; Esc to cancel."
|
|
669
|
+
|
|
670
|
+
def render(redraw=False):
|
|
671
|
+
if redraw:
|
|
672
|
+
sys.stdout.write(f"\x1b[{visible + 1}A")
|
|
673
|
+
for index in range(start, start + visible):
|
|
674
|
+
row = (" > " if index == selected else " ") + rows[index]
|
|
675
|
+
row = row[:width]
|
|
676
|
+
if index == selected:
|
|
677
|
+
row = terminal_color(row, "1;36")
|
|
678
|
+
sys.stdout.write("\r\x1b[2K" + row + "\n")
|
|
679
|
+
sys.stdout.write("\r\x1b[2K" + hint[:width] + "\n")
|
|
680
|
+
sys.stdout.flush()
|
|
681
|
+
|
|
682
|
+
try:
|
|
683
|
+
tty.setcbreak(fd, termios.TCSANOW)
|
|
684
|
+
sys.stdout.write("\x1b[?25l")
|
|
685
|
+
render()
|
|
686
|
+
while True:
|
|
687
|
+
key = read_token_menu_key(fd)
|
|
688
|
+
if key == "cancel":
|
|
689
|
+
return None
|
|
690
|
+
if key == "enter":
|
|
691
|
+
return selected
|
|
692
|
+
if key not in ("up", "down"):
|
|
693
|
+
continue
|
|
694
|
+
selected = (selected + (1 if key == "down" else -1)) % len(rows)
|
|
695
|
+
start = min(start, selected) if selected < start else max(start, selected - visible + 1)
|
|
696
|
+
render(redraw=True)
|
|
697
|
+
except KeyboardInterrupt:
|
|
698
|
+
return None
|
|
699
|
+
finally:
|
|
700
|
+
termios.tcsetattr(fd, termios.TCSANOW, settings)
|
|
701
|
+
sys.stdout.write("\x1b[?25h\n")
|
|
702
|
+
sys.stdout.flush()
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def choose_token_instance(registry: Registry) -> Instance | None:
|
|
706
|
+
"""Select by stable instance name; never default or expose unselected tokens."""
|
|
707
|
+
items = registry.instances()
|
|
708
|
+
if not items:
|
|
709
|
+
print("No installations found. Run agentsdock setup to create your first server.")
|
|
710
|
+
return None
|
|
711
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
712
|
+
raise ValueError("Choose a server in an interactive terminal, or run agentsdock token NAME "
|
|
713
|
+
"(for example: agentsdock token default). No token was shown.")
|
|
714
|
+
print("\nChoose a server to view its access token:\n")
|
|
715
|
+
rows = []
|
|
716
|
+
for index, instance in enumerate(items, 1):
|
|
717
|
+
config = read_config(instance)
|
|
718
|
+
port = config.get("AGENTSDOCK_AGENT_PORT") or "not configured"
|
|
719
|
+
rows.append(f"{instance.name:<20} {service_status(instance):<10} Port: {port}")
|
|
720
|
+
if os.environ.get("TERM", "dumb") not in ("", "dumb"):
|
|
721
|
+
try:
|
|
722
|
+
fd = sys.stdin.fileno()
|
|
723
|
+
settings = termios.tcgetattr(fd)
|
|
724
|
+
except (OSError, ValueError, termios.error):
|
|
725
|
+
pass # Basic terminals retain the explicit numbered/name prompt.
|
|
726
|
+
else:
|
|
727
|
+
index = arrow_token_choice(rows, fd, settings)
|
|
728
|
+
if index is None:
|
|
729
|
+
print("Cancelled; no token was shown.")
|
|
730
|
+
return None
|
|
731
|
+
selected = items[index]
|
|
732
|
+
if selected.name not in {item.name for item in registry.instances()}:
|
|
733
|
+
raise ValueError("That server is no longer available. Run agentsdock token again. No token was shown.")
|
|
734
|
+
return selected
|
|
735
|
+
for index, row in enumerate(rows, 1):
|
|
736
|
+
print(f" {index}. {row}")
|
|
737
|
+
print()
|
|
738
|
+
names = {item.name: item for item in items}
|
|
739
|
+
while True:
|
|
740
|
+
try:
|
|
741
|
+
answer = input("Enter a number or server name (Enter to cancel): ").strip()
|
|
742
|
+
except (EOFError, KeyboardInterrupt):
|
|
743
|
+
print()
|
|
744
|
+
answer = ""
|
|
745
|
+
if not answer:
|
|
746
|
+
print("Cancelled; no token was shown.")
|
|
747
|
+
return None
|
|
748
|
+
selected = names.get(answer)
|
|
749
|
+
if re.fullmatch(r"[1-9][0-9]{0,5}", answer) and int(answer) <= len(items):
|
|
750
|
+
selected = items[int(answer) - 1]
|
|
751
|
+
if selected is not None:
|
|
752
|
+
if selected.name not in {item.name for item in registry.instances()}:
|
|
753
|
+
raise ValueError("That server is no longer available. Run agentsdock token again. No token was shown.")
|
|
754
|
+
return selected
|
|
755
|
+
print(f"Please enter a number from 1 to {len(items)} or a listed server name (Enter cancels).")
|
|
756
|
+
|
|
757
|
+
|
|
614
758
|
def terminal_color(text: str, code: str) -> str:
|
|
615
759
|
color = sys.stdout.isatty() and os.environ.get("TERM") != "dumb" and "NO_COLOR" not in os.environ
|
|
616
760
|
return f"\033[{code}m{text}\033[0m" if color else text
|
|
@@ -747,8 +891,11 @@ def parser() -> argparse.ArgumentParser:
|
|
|
747
891
|
result = argparse.ArgumentParser(description="Independent AgentsServer instances for the current OS user. Defaults to list.")
|
|
748
892
|
commands = result.add_subparsers(dest="command")
|
|
749
893
|
commands.add_parser("list")
|
|
894
|
+
commands.add_parser("token", help="Choose an existing server and display only its access token")
|
|
750
895
|
info = commands.add_parser("info")
|
|
751
896
|
info.add_argument("name", type=instance_name)
|
|
897
|
+
status = commands.add_parser("status", help="Show readable details for all instances or one name")
|
|
898
|
+
status.add_argument("name", nargs="?", type=instance_name)
|
|
752
899
|
new = commands.add_parser("new")
|
|
753
900
|
new.add_argument("--name", type=instance_name)
|
|
754
901
|
new.add_argument("--port", type=int)
|
|
@@ -785,13 +932,30 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
785
932
|
validate_binding(instance)
|
|
786
933
|
print(instance.shell_bindings())
|
|
787
934
|
return 0
|
|
788
|
-
if args.command
|
|
935
|
+
if args.command == "token":
|
|
936
|
+
selected = choose_token_instance(registry)
|
|
937
|
+
if selected is None:
|
|
938
|
+
return 0
|
|
939
|
+
try:
|
|
940
|
+
run(["/bin/bash", str(ROOT / "install.sh"), "--instance", selected.name, "--show-token"],
|
|
941
|
+
env=clean_environment(selected), cwd=ROOT)
|
|
942
|
+
return 0
|
|
943
|
+
except subprocess.CalledProcessError as exc:
|
|
944
|
+
return exc.returncode if exc.returncode > 0 else 1
|
|
945
|
+
if args.command in {None, "list", "info", "status"}:
|
|
789
946
|
items = registry.instances()
|
|
790
|
-
if args.command
|
|
947
|
+
if args.command in {"info", "status"} and args.name:
|
|
791
948
|
items = [item for item in items if item.name == args.name]
|
|
792
949
|
if not items:
|
|
793
950
|
raise ValueError("Unknown instance.")
|
|
951
|
+
if args.command == "info":
|
|
794
952
|
print(json.dumps(describe(items[0]), indent=2))
|
|
953
|
+
elif args.command == "status":
|
|
954
|
+
network = tailscale_status() if items else None
|
|
955
|
+
for item in items:
|
|
956
|
+
show_status(item, network=network)
|
|
957
|
+
if not items:
|
|
958
|
+
print("No installations found. Run agentsdock setup to create your first server.")
|
|
795
959
|
else:
|
|
796
960
|
network = tailscale_status() if items else None
|
|
797
961
|
print("NAME STATUS PORT")
|