agentlink-cli 0.1.4__tar.gz → 0.1.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/.gitignore +1 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/PKG-INFO +22 -1
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/README.md +21 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/capabilities.py +60 -11
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/cli.py +7 -1
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/adapter.py +18 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/rpc.py +25 -3
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/launch.py +98 -7
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/runtime.py +6 -2
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/version.py +1 -1
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/pyproject.toml +1 -1
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_codex_adapter.py +54 -1
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_capabilities.py +35 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_cli.py +13 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_runtime.py +1 -0
- agentlink_cli-0.1.6/tests/test_launch_command.py +332 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_version.py +1 -1
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/uv.lock +1 -1
- agentlink_cli-0.1.4/tests/test_launch_command.py +0 -82
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/config_options.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/discovery.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifest.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifests/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifests/codebuddy.json +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifests/cursor.json +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifests/gemini.json +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifests/grok_build.json +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/reducer.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/rpc.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/attachments.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/history_adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/normalized.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/normalizers.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/path_utils.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/preferences.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/sdk_adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/timeline_identity.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/timeline_reducer.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/trust.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/collaboration.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/history.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/reducer.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/control.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/json_rpc.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/common.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/file_ops.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/ops.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/shell.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/terminal.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local_ops.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local_runtime.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/logging.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/perf.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/protocol.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/registry.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/sync_state.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/time.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/run.sh +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/fake_agent/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/fake_agent/__main__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/test_acp_adapter_fake.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/test_acp_reducer_golden.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/test_config_options.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/contract/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/contract/helpers.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_claude_history_adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_claude_preferences.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_claude_sdk_adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_claude_timeline_parity.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_claude_trust.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_control.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_json_rpc.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_logging.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_local_runtime.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_perf.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_terminal_backend.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentlink-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Local runtime connector for AgentLink
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: claude-agent-sdk
|
|
@@ -44,6 +44,13 @@ Install dependencies:
|
|
|
44
44
|
uv sync
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
Check the installed CLI version:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
agentlink-cli -v
|
|
51
|
+
agentlink-cli --version
|
|
52
|
+
```
|
|
53
|
+
|
|
47
54
|
Start with explicit credentials from the web pairing flow:
|
|
48
55
|
|
|
49
56
|
```bash
|
|
@@ -84,6 +91,20 @@ CLAUDE_BIN=/path/to/claude
|
|
|
84
91
|
The connector uses local runtime credentials and local filesystem permissions.
|
|
85
92
|
AgentLink does not proxy Claude or Codex account credentials.
|
|
86
93
|
|
|
94
|
+
Custom Codex launch commands may name an executable or a bash/zsh alias or
|
|
95
|
+
function from the Connector user's interactive shell configuration. For
|
|
96
|
+
example, after defining this in `~/.bashrc` or `~/.zshrc`:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
alias codex-api='CODEX_HOME="$HOME/.codex-api" codex'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
enter `codex-api` as the custom launch command in AgentLink. Arguments are
|
|
103
|
+
passed without shell re-evaluation, and AgentLink still rejects pipelines,
|
|
104
|
+
redirects, command lists, and multiline commands. Shell aliases and functions
|
|
105
|
+
are supported on Linux and macOS with bash or zsh; Windows custom commands must
|
|
106
|
+
resolve to an executable or a supported script file.
|
|
107
|
+
|
|
87
108
|
## Local Operations
|
|
88
109
|
|
|
89
110
|
The server can ask an online connector to perform local work:
|
|
@@ -27,6 +27,13 @@ Install dependencies:
|
|
|
27
27
|
uv sync
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
Check the installed CLI version:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
agentlink-cli -v
|
|
34
|
+
agentlink-cli --version
|
|
35
|
+
```
|
|
36
|
+
|
|
30
37
|
Start with explicit credentials from the web pairing flow:
|
|
31
38
|
|
|
32
39
|
```bash
|
|
@@ -67,6 +74,20 @@ CLAUDE_BIN=/path/to/claude
|
|
|
67
74
|
The connector uses local runtime credentials and local filesystem permissions.
|
|
68
75
|
AgentLink does not proxy Claude or Codex account credentials.
|
|
69
76
|
|
|
77
|
+
Custom Codex launch commands may name an executable or a bash/zsh alias or
|
|
78
|
+
function from the Connector user's interactive shell configuration. For
|
|
79
|
+
example, after defining this in `~/.bashrc` or `~/.zshrc`:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
alias codex-api='CODEX_HOME="$HOME/.codex-api" codex'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
enter `codex-api` as the custom launch command in AgentLink. Arguments are
|
|
86
|
+
passed without shell re-evaluation, and AgentLink still rejects pipelines,
|
|
87
|
+
redirects, command lists, and multiline commands. Shell aliases and functions
|
|
88
|
+
are supported on Linux and macOS with bash or zsh; Windows custom commands must
|
|
89
|
+
resolve to an executable or a supported script file.
|
|
90
|
+
|
|
70
91
|
## Local Operations
|
|
71
92
|
|
|
72
93
|
The server can ask an online connector to perform local work:
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import importlib
|
|
5
5
|
import os
|
|
6
6
|
import shutil
|
|
7
|
+
import signal
|
|
7
8
|
import sys
|
|
8
9
|
import time
|
|
9
10
|
from collections.abc import Iterable
|
|
@@ -133,7 +134,7 @@ async def discover_codex_capability(
|
|
|
133
134
|
"""
|
|
134
135
|
if launch_command:
|
|
135
136
|
try:
|
|
136
|
-
parsed = parse_launch_command(launch_command)
|
|
137
|
+
parsed = await parse_launch_command(launch_command)
|
|
137
138
|
except ValueError as exc:
|
|
138
139
|
return (
|
|
139
140
|
{
|
|
@@ -238,16 +239,17 @@ async def _check_codex_candidate(candidate: dict[str, str] | LaunchTarget | Laun
|
|
|
238
239
|
path = target.path
|
|
239
240
|
source = target.source
|
|
240
241
|
base = {"source": source, "path": path}
|
|
241
|
-
if not
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
if not target.is_shell_command:
|
|
243
|
+
if not Path(path).is_file():
|
|
244
|
+
return {**base, "status": "missing", "reason": "file not found"}
|
|
245
|
+
if not path_exists_for_launch(path):
|
|
246
|
+
return {**base, "status": "failed", "reason": "not executable"}
|
|
245
247
|
|
|
246
|
-
version = await
|
|
248
|
+
version = await _run_launch_version(command)
|
|
247
249
|
if version["status"] != "ok":
|
|
248
250
|
return {**base, "status": "failed", "stage": "version", **version}
|
|
249
251
|
|
|
250
|
-
client =
|
|
252
|
+
client = _codex_stdio_client(command)
|
|
251
253
|
try:
|
|
252
254
|
await asyncio.wait_for(client.start(lambda _payload: _noop()), timeout=_CODEX_CHECK_TIMEOUT_S)
|
|
253
255
|
list_result = await asyncio.wait_for(
|
|
@@ -294,12 +296,14 @@ async def prepare_codex_launch(
|
|
|
294
296
|
"""Validate a command while keeping the initialized candidate alive."""
|
|
295
297
|
|
|
296
298
|
target = command.target
|
|
297
|
-
if not
|
|
299
|
+
if not target.is_shell_command and (
|
|
300
|
+
not Path(target.path).is_file() or not path_exists_for_launch(target.path)
|
|
301
|
+
):
|
|
298
302
|
raise ValueError(f"executable is unavailable: {target.path}")
|
|
299
|
-
version = await
|
|
303
|
+
version = await _run_launch_version(command)
|
|
300
304
|
if version["status"] != "ok":
|
|
301
305
|
raise RuntimeError(str(version.get("reason") or "Codex version check failed"))
|
|
302
|
-
client =
|
|
306
|
+
client = _codex_stdio_client(command)
|
|
303
307
|
try:
|
|
304
308
|
await asyncio.wait_for(client.start(handler), timeout=_CODEX_CHECK_TIMEOUT_S)
|
|
305
309
|
list_result = await asyncio.wait_for(
|
|
@@ -515,15 +519,36 @@ def _list_claude_sdk_sessions() -> list[Any]:
|
|
|
515
519
|
return list(list_sessions())
|
|
516
520
|
|
|
517
521
|
|
|
518
|
-
async def
|
|
522
|
+
async def _run_launch_version(command: LaunchCommand) -> dict[str, Any]:
|
|
523
|
+
kwargs = {"start_new_session": True} if command.target.starts_process_group else {}
|
|
524
|
+
return await _run_version(command.command(["--version"]), **kwargs)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _codex_stdio_client(command: LaunchCommand) -> JsonRpcStdioClient:
|
|
528
|
+
kwargs = {"start_new_session": True} if command.target.starts_process_group else {}
|
|
529
|
+
return JsonRpcStdioClient(
|
|
530
|
+
command=command.command(["app-server", "--listen", "stdio://"]),
|
|
531
|
+
**kwargs,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
async def _run_version(command: list[str], *, start_new_session: bool = False) -> dict[str, Any]:
|
|
536
|
+
proc: asyncio.subprocess.Process | None = None
|
|
519
537
|
try:
|
|
520
538
|
proc = await asyncio.create_subprocess_exec(
|
|
521
539
|
*command,
|
|
522
540
|
stdout=asyncio.subprocess.PIPE,
|
|
523
541
|
stderr=asyncio.subprocess.PIPE,
|
|
542
|
+
start_new_session=start_new_session,
|
|
524
543
|
)
|
|
525
544
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_COMMAND_CHECK_TIMEOUT_S)
|
|
545
|
+
except asyncio.CancelledError:
|
|
546
|
+
if proc is not None and proc.returncode is None:
|
|
547
|
+
await _terminate_process(proc, process_group=start_new_session)
|
|
548
|
+
raise
|
|
526
549
|
except Exception as exc:
|
|
550
|
+
if proc is not None and proc.returncode is None:
|
|
551
|
+
await _terminate_process(proc, process_group=start_new_session)
|
|
527
552
|
return {"status": "failed", "reason": _exception_reason(exc)}
|
|
528
553
|
out = stdout.decode(errors="replace").strip()
|
|
529
554
|
err = stderr.decode(errors="replace").strip()
|
|
@@ -537,6 +562,30 @@ async def _run_version(command: list[str]) -> dict[str, Any]:
|
|
|
537
562
|
return {"status": "ok", "stdout": out[:500], "stderr": err[:500]}
|
|
538
563
|
|
|
539
564
|
|
|
565
|
+
async def _terminate_process(
|
|
566
|
+
process: asyncio.subprocess.Process,
|
|
567
|
+
*,
|
|
568
|
+
process_group: bool,
|
|
569
|
+
) -> None:
|
|
570
|
+
try:
|
|
571
|
+
if process_group:
|
|
572
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
573
|
+
else:
|
|
574
|
+
process.terminate()
|
|
575
|
+
await asyncio.wait_for(process.wait(), timeout=5)
|
|
576
|
+
except ProcessLookupError:
|
|
577
|
+
return
|
|
578
|
+
except TimeoutError:
|
|
579
|
+
try:
|
|
580
|
+
if process_group:
|
|
581
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
582
|
+
else:
|
|
583
|
+
process.kill()
|
|
584
|
+
except ProcessLookupError:
|
|
585
|
+
return
|
|
586
|
+
await process.wait()
|
|
587
|
+
|
|
588
|
+
|
|
540
589
|
def _claude_candidate_paths() -> list[dict[str, str]]:
|
|
541
590
|
if sys.platform == "win32":
|
|
542
591
|
home = Path.home()
|
|
@@ -20,7 +20,7 @@ from connector.local_runtime import (
|
|
|
20
20
|
)
|
|
21
21
|
from connector.logging import install_rpc_log_sink
|
|
22
22
|
from connector.runtime import BackendRpcClient, ConnectorConfig
|
|
23
|
-
|
|
23
|
+
from connector.version import connector_version
|
|
24
24
|
|
|
25
25
|
SHELL_LIFETIME_WARNING = (
|
|
26
26
|
"Note: this connector runs in the current shell session. "
|
|
@@ -69,6 +69,12 @@ class CliError(RuntimeError):
|
|
|
69
69
|
|
|
70
70
|
def _build_parser() -> argparse.ArgumentParser:
|
|
71
71
|
parser = argparse.ArgumentParser(prog="agentlink-cli", description="Agent Server Codex connector CLI")
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"-v",
|
|
74
|
+
"--version",
|
|
75
|
+
action="version",
|
|
76
|
+
version=f"%(prog)s {connector_version()}",
|
|
77
|
+
)
|
|
72
78
|
subparsers = parser.add_subparsers(dest="command", metavar="{start,pair,configure,rpc}")
|
|
73
79
|
|
|
74
80
|
start = subparsers.add_parser("start", help="start the connector")
|
|
@@ -590,6 +590,24 @@ class CodexAdapter:
|
|
|
590
590
|
"target": target,
|
|
591
591
|
}
|
|
592
592
|
else:
|
|
593
|
+
# A connector restart clears the app-server's in-memory
|
|
594
|
+
# thread set. Resume the requested thread before reading its
|
|
595
|
+
# status so Codex resolves workspace-sensitive information
|
|
596
|
+
# (including Git state) against the thread's workspace.
|
|
597
|
+
try:
|
|
598
|
+
await self._ensure_thread_loaded(thread_id)
|
|
599
|
+
except Exception as exc:
|
|
600
|
+
# Archived/unresumable threads remain readable and status
|
|
601
|
+
# is intentionally a read-only command. Preserve that
|
|
602
|
+
# behavior when app-server refuses to resume them.
|
|
603
|
+
reason = _unresumable_thread_failure_reason(str(exc))
|
|
604
|
+
if reason not in {"archived", "unresumable"}:
|
|
605
|
+
raise
|
|
606
|
+
logger.info(
|
|
607
|
+
"codex status using read-only archived thread thread_id={} reason={}",
|
|
608
|
+
thread_id,
|
|
609
|
+
reason,
|
|
610
|
+
)
|
|
593
611
|
thread = await self.rpc.request(
|
|
594
612
|
"thread/read", {"threadId": thread_id, "includeTurns": False}
|
|
595
613
|
)
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import shutil
|
|
7
|
+
import signal
|
|
7
8
|
import sys
|
|
8
9
|
import time
|
|
9
10
|
from collections.abc import Awaitable, Callable
|
|
@@ -21,8 +22,14 @@ APP_SERVER_STREAM_LIMIT = 64 * 1024 * 1024
|
|
|
21
22
|
class JsonRpcStdioClient:
|
|
22
23
|
"""Line-delimited JSON-RPC client for `codex app-server --listen stdio://`."""
|
|
23
24
|
|
|
24
|
-
def __init__(
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
command: list[str] | None = None,
|
|
28
|
+
*,
|
|
29
|
+
start_new_session: bool = False,
|
|
30
|
+
) -> None:
|
|
25
31
|
self.command = command or _resolve_codex_command()
|
|
32
|
+
self.start_new_session = start_new_session
|
|
26
33
|
self.process: asyncio.subprocess.Process | None = None
|
|
27
34
|
self._start_lock = asyncio.Lock()
|
|
28
35
|
self._next_id = 1
|
|
@@ -46,6 +53,7 @@ class JsonRpcStdioClient:
|
|
|
46
53
|
stdout=asyncio.subprocess.PIPE,
|
|
47
54
|
stderr=asyncio.subprocess.PIPE,
|
|
48
55
|
limit=APP_SERVER_STREAM_LIMIT,
|
|
56
|
+
start_new_session=self.start_new_session,
|
|
49
57
|
)
|
|
50
58
|
self._track_reader(asyncio.create_task(self._read_stdout(self.process)), "stdout")
|
|
51
59
|
self._track_reader(asyncio.create_task(self._read_stderr(self.process)), "stderr")
|
|
@@ -112,11 +120,25 @@ class JsonRpcStdioClient:
|
|
|
112
120
|
async def close(self) -> None:
|
|
113
121
|
if self.process is None:
|
|
114
122
|
return
|
|
115
|
-
|
|
123
|
+
try:
|
|
124
|
+
if self.start_new_session:
|
|
125
|
+
os.killpg(self.process.pid, signal.SIGTERM)
|
|
126
|
+
else:
|
|
127
|
+
self.process.terminate()
|
|
128
|
+
except ProcessLookupError:
|
|
129
|
+
self.process = None
|
|
130
|
+
self._initialized = False
|
|
131
|
+
return
|
|
116
132
|
try:
|
|
117
133
|
await asyncio.wait_for(self.process.wait(), timeout=5)
|
|
118
134
|
except TimeoutError:
|
|
119
|
-
|
|
135
|
+
try:
|
|
136
|
+
if self.start_new_session:
|
|
137
|
+
os.killpg(self.process.pid, signal.SIGKILL)
|
|
138
|
+
else:
|
|
139
|
+
self.process.kill()
|
|
140
|
+
except ProcessLookupError:
|
|
141
|
+
pass
|
|
120
142
|
await self.process.wait()
|
|
121
143
|
finally:
|
|
122
144
|
self.process = None
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import hashlib
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
6
7
|
import re
|
|
7
8
|
import shlex
|
|
8
9
|
import shutil
|
|
10
|
+
import signal
|
|
9
11
|
import sys
|
|
10
12
|
from dataclasses import dataclass
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
from typing import Literal
|
|
13
15
|
|
|
14
|
-
Launcher = Literal["direct", "powershell", "cmd"]
|
|
16
|
+
Launcher = Literal["direct", "powershell", "cmd", "posix-shell"]
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
@dataclass(frozen=True, slots=True)
|
|
@@ -52,6 +54,14 @@ class LaunchTarget:
|
|
|
52
54
|
def report_path(self) -> str:
|
|
53
55
|
return self.path
|
|
54
56
|
|
|
57
|
+
@property
|
|
58
|
+
def is_shell_command(self) -> bool:
|
|
59
|
+
return self.launcher == "posix-shell"
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def starts_process_group(self) -> bool:
|
|
63
|
+
return self.is_shell_command
|
|
64
|
+
|
|
55
65
|
|
|
56
66
|
@dataclass(frozen=True, slots=True)
|
|
57
67
|
class LaunchCommand:
|
|
@@ -84,9 +94,10 @@ class LaunchCommand:
|
|
|
84
94
|
|
|
85
95
|
_UNSUPPORTED_SHELL_TOKENS = {"|", "||", "&", "&&", ";", ">", ">>", "<", "<<"}
|
|
86
96
|
_CMD_METACHARACTERS = frozenset('&|<>^()%!"')
|
|
97
|
+
_SHELL_PROBE_TIMEOUT_S = 5
|
|
87
98
|
|
|
88
99
|
|
|
89
|
-
def parse_launch_command(value: str) -> LaunchCommand:
|
|
100
|
+
async def parse_launch_command(value: str) -> LaunchCommand:
|
|
90
101
|
"""Parse a frontend command as argv and resolve its executable locally.
|
|
91
102
|
|
|
92
103
|
Shell operators are deliberately rejected. The resulting command is
|
|
@@ -108,12 +119,14 @@ def parse_launch_command(value: str) -> LaunchCommand:
|
|
|
108
119
|
raise ValueError("shell operators are not supported; enter an executable and arguments only")
|
|
109
120
|
|
|
110
121
|
executable = expand_vars(argv[0])
|
|
111
|
-
|
|
112
|
-
if
|
|
113
|
-
resolved = executable
|
|
114
|
-
|
|
122
|
+
target = await _resolve_posix_shell_command(executable)
|
|
123
|
+
if target is None:
|
|
124
|
+
resolved = shutil.which(executable)
|
|
125
|
+
if resolved is None and Path(executable).is_file():
|
|
126
|
+
resolved = executable
|
|
127
|
+
target = launch_target("custom", resolved) if resolved is not None else None
|
|
128
|
+
if target is None:
|
|
115
129
|
raise ValueError(f"executable not found: {argv[0]}")
|
|
116
|
-
target = launch_target("custom", resolved)
|
|
117
130
|
if target.launcher == "cmd" and any(
|
|
118
131
|
character in _CMD_METACHARACTERS
|
|
119
132
|
for token in argv[1:]
|
|
@@ -125,6 +138,84 @@ def parse_launch_command(value: str) -> LaunchCommand:
|
|
|
125
138
|
return LaunchCommand(raw=raw, target=target, args=tuple(argv[1:]))
|
|
126
139
|
|
|
127
140
|
|
|
141
|
+
async def _resolve_posix_shell_command(name: str) -> LaunchTarget | None:
|
|
142
|
+
if sys.platform == "win32" or not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.+-]*", name):
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
shell = _user_shell()
|
|
146
|
+
if shell is None:
|
|
147
|
+
return None
|
|
148
|
+
shell_name = Path(shell).name.lower()
|
|
149
|
+
if shell_name == "bash":
|
|
150
|
+
probe = (
|
|
151
|
+
'kind=$(type -t -- "$1" 2>/dev/null); '
|
|
152
|
+
'[ "$kind" = alias ] || [ "$kind" = function ]'
|
|
153
|
+
)
|
|
154
|
+
elif shell_name == "zsh":
|
|
155
|
+
probe = (
|
|
156
|
+
'kind=$(whence -w -- "$1" 2>/dev/null); kind=${kind##*: }; '
|
|
157
|
+
'[ "$kind" = alias ] || [ "$kind" = function ]'
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
process: asyncio.subprocess.Process | None = None
|
|
163
|
+
try:
|
|
164
|
+
process = await asyncio.create_subprocess_exec(
|
|
165
|
+
shell,
|
|
166
|
+
"-ic",
|
|
167
|
+
probe,
|
|
168
|
+
"agentlink-shell-probe",
|
|
169
|
+
name,
|
|
170
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
171
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
172
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
173
|
+
start_new_session=True,
|
|
174
|
+
)
|
|
175
|
+
await asyncio.wait_for(process.wait(), timeout=_SHELL_PROBE_TIMEOUT_S)
|
|
176
|
+
except asyncio.CancelledError:
|
|
177
|
+
if process is not None and process.returncode is None:
|
|
178
|
+
await _kill_shell_probe_group(process)
|
|
179
|
+
raise
|
|
180
|
+
except (OSError, TimeoutError):
|
|
181
|
+
if process is not None and process.returncode is None:
|
|
182
|
+
await _kill_shell_probe_group(process)
|
|
183
|
+
return None
|
|
184
|
+
if process.returncode != 0:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
script = f'{shlex.quote(name)} "$@"'
|
|
188
|
+
return LaunchTarget(
|
|
189
|
+
source="shell",
|
|
190
|
+
path=name,
|
|
191
|
+
launcher="posix-shell",
|
|
192
|
+
exec_argv=(shell, "-ic", script, "agentlink-codex"),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def _kill_shell_probe_group(process: asyncio.subprocess.Process) -> None:
|
|
197
|
+
try:
|
|
198
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
199
|
+
except ProcessLookupError:
|
|
200
|
+
pass
|
|
201
|
+
await process.wait()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _user_shell() -> str | None:
|
|
205
|
+
configured = expand_vars(os.environ.get("SHELL", ""))
|
|
206
|
+
if not configured and sys.platform != "win32":
|
|
207
|
+
try:
|
|
208
|
+
import pwd
|
|
209
|
+
|
|
210
|
+
configured = pwd.getpwuid(os.getuid()).pw_shell
|
|
211
|
+
except (ImportError, KeyError, OSError):
|
|
212
|
+
return None
|
|
213
|
+
resolved = shutil.which(configured) if configured else None
|
|
214
|
+
if resolved is None and configured and Path(configured).is_file():
|
|
215
|
+
resolved = configured
|
|
216
|
+
return resolved
|
|
217
|
+
|
|
218
|
+
|
|
128
219
|
def launch_command_from_target(target: LaunchTarget, *, raw: str | None = None) -> LaunchCommand:
|
|
129
220
|
return LaunchCommand(raw=raw or target.report_path(), target=target)
|
|
130
221
|
|
|
@@ -735,6 +735,7 @@ class BackendRpcClient:
|
|
|
735
735
|
if (
|
|
736
736
|
codex.rpc is not None
|
|
737
737
|
and codex.rpc.command == command
|
|
738
|
+
and codex.rpc.start_new_session == launch.target.starts_process_group
|
|
738
739
|
and getattr(codex, "_started", False)
|
|
739
740
|
):
|
|
740
741
|
self._blocked_runtimes.discard("codex")
|
|
@@ -744,7 +745,10 @@ class BackendRpcClient:
|
|
|
744
745
|
await codex.rpc.close()
|
|
745
746
|
except Exception:
|
|
746
747
|
logger.exception("closing previous codex app-server failed")
|
|
747
|
-
codex.rpc = JsonRpcStdioClient(
|
|
748
|
+
codex.rpc = JsonRpcStdioClient(
|
|
749
|
+
command=command,
|
|
750
|
+
start_new_session=launch.target.starts_process_group,
|
|
751
|
+
)
|
|
748
752
|
codex._started = False
|
|
749
753
|
self._blocked_runtimes.discard("codex")
|
|
750
754
|
|
|
@@ -756,7 +760,7 @@ class BackendRpcClient:
|
|
|
756
760
|
if mode == "command":
|
|
757
761
|
if not isinstance(raw, str):
|
|
758
762
|
raise ValueError("missing Codex launch command")
|
|
759
|
-
launch = parse_launch_command(raw)
|
|
763
|
+
launch = await parse_launch_command(raw)
|
|
760
764
|
else:
|
|
761
765
|
report, target = await discover_codex_capability()
|
|
762
766
|
if target is None:
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
|
+
import signal
|
|
5
6
|
from collections.abc import Awaitable, Callable
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
@@ -11,7 +12,10 @@ from connector.codex.adapter import (
|
|
|
11
12
|
CodexAdapter,
|
|
12
13
|
stable_session_id,
|
|
13
14
|
)
|
|
14
|
-
from connector.codex.collaboration import
|
|
15
|
+
from connector.codex.collaboration import (
|
|
16
|
+
collaboration_mode_payload,
|
|
17
|
+
normalize_collaboration_modes,
|
|
18
|
+
)
|
|
15
19
|
from connector.codex.history import read_timeline_history, read_tool_history
|
|
16
20
|
from connector.codex.reducer import TimelineReducer
|
|
17
21
|
from connector.codex.rpc import APP_SERVER_STREAM_LIMIT, JsonRpcStdioClient
|
|
@@ -121,6 +125,7 @@ class ArchivedThreadListRpc(FakeCodexRpc):
|
|
|
121
125
|
class ArchivedResumeRpc(FakeCodexRpc):
|
|
122
126
|
async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
123
127
|
if method == "thread/resume":
|
|
128
|
+
self.requests.append((method, params))
|
|
124
129
|
raise RuntimeError(json.dumps({"code": -32600, "message": "thread is archived"}))
|
|
125
130
|
return await super().request(method, params)
|
|
126
131
|
|
|
@@ -386,6 +391,29 @@ def test_stdio_client_start_is_serialized(monkeypatch) -> None:
|
|
|
386
391
|
asyncio.run(_exercise_stdio_client_start_is_serialized(monkeypatch))
|
|
387
392
|
|
|
388
393
|
|
|
394
|
+
def test_stdio_client_closes_shell_process_group(monkeypatch) -> None:
|
|
395
|
+
class GroupProcess:
|
|
396
|
+
pid = 4321
|
|
397
|
+
returncode: int | None = None
|
|
398
|
+
|
|
399
|
+
async def wait(self) -> int:
|
|
400
|
+
self.returncode = 0
|
|
401
|
+
return 0
|
|
402
|
+
|
|
403
|
+
signals: list[tuple[int, signal.Signals]] = []
|
|
404
|
+
monkeypatch.setattr(
|
|
405
|
+
"connector.codex.rpc.os.killpg",
|
|
406
|
+
lambda pid, sent_signal: signals.append((pid, sent_signal)),
|
|
407
|
+
)
|
|
408
|
+
client = JsonRpcStdioClient(command=["bash"], start_new_session=True)
|
|
409
|
+
client.process = GroupProcess() # type: ignore[assignment]
|
|
410
|
+
|
|
411
|
+
asyncio.run(client.close())
|
|
412
|
+
|
|
413
|
+
assert signals == [(4321, signal.SIGTERM)]
|
|
414
|
+
assert client.process is None
|
|
415
|
+
|
|
416
|
+
|
|
389
417
|
def test_stdio_client_ignores_response_for_cancelled_request() -> None:
|
|
390
418
|
client = JsonRpcStdioClient(command=["codex"])
|
|
391
419
|
loop = asyncio.new_event_loop()
|
|
@@ -1138,12 +1166,37 @@ def test_status_command_returns_thread_and_cached_token_usage() -> None:
|
|
|
1138
1166
|
assert result["status"] == "completed"
|
|
1139
1167
|
assert result["data"]["thread"]["id"] == "thr_1"
|
|
1140
1168
|
assert result["data"]["tokenUsage"]["total"]["totalTokens"] == 42
|
|
1169
|
+
assert ("thread/resume", {"threadId": "thr_1"}) in rpc.requests
|
|
1141
1170
|
assert ("thread/read", {"threadId": "thr_1", "includeTurns": False}) in rpc.requests
|
|
1142
1171
|
assert all(method != "turn/start" for method, _params in rpc.requests)
|
|
1143
1172
|
|
|
1144
1173
|
asyncio.run(exercise())
|
|
1145
1174
|
|
|
1146
1175
|
|
|
1176
|
+
def test_status_command_reads_archived_thread_when_resume_is_rejected() -> None:
|
|
1177
|
+
async def exercise() -> None:
|
|
1178
|
+
rpc = ArchivedResumeRpc()
|
|
1179
|
+
adapter = CodexAdapter(rpc=rpc) # type: ignore[arg-type]
|
|
1180
|
+
await adapter.start()
|
|
1181
|
+
adapter._loaded_thread_ids.clear() # noqa: SLF001
|
|
1182
|
+
|
|
1183
|
+
result = await adapter.execute_command(
|
|
1184
|
+
{
|
|
1185
|
+
"sessionId": "sess_1",
|
|
1186
|
+
"externalSessionId": "thr_1",
|
|
1187
|
+
"command": "status",
|
|
1188
|
+
"clientCommandId": "cmd_status_archived",
|
|
1189
|
+
}
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
assert result["status"] == "completed"
|
|
1193
|
+
assert result["data"]["thread"]["id"] == "thr_1"
|
|
1194
|
+
assert ("thread/resume", {"threadId": "thr_1"}) in rpc.requests
|
|
1195
|
+
assert ("thread/read", {"threadId": "thr_1", "includeTurns": False}) in rpc.requests
|
|
1196
|
+
|
|
1197
|
+
asyncio.run(exercise())
|
|
1198
|
+
|
|
1199
|
+
|
|
1147
1200
|
def test_reducer_normalizes_review_and_compaction_items() -> None:
|
|
1148
1201
|
reducer = TimelineReducer()
|
|
1149
1202
|
reducer.bind_session("sess_1", "thr_1")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import signal
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
@@ -14,6 +15,40 @@ def _write_executable(path: Path, content: str) -> None:
|
|
|
14
15
|
path.chmod(0o755)
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
def test_version_timeout_terminates_shell_process_group(monkeypatch) -> None:
|
|
19
|
+
class HangingProcess:
|
|
20
|
+
pid = 7654
|
|
21
|
+
returncode: int | None = None
|
|
22
|
+
|
|
23
|
+
async def communicate(self) -> tuple[bytes, bytes]:
|
|
24
|
+
await asyncio.Event().wait()
|
|
25
|
+
raise AssertionError("unreachable")
|
|
26
|
+
|
|
27
|
+
async def wait(self) -> int:
|
|
28
|
+
self.returncode = 0
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
process = HangingProcess()
|
|
32
|
+
signals: list[tuple[int, signal.Signals]] = []
|
|
33
|
+
|
|
34
|
+
async def fake_create_subprocess_exec(*_command: str, **_kwargs: object) -> HangingProcess:
|
|
35
|
+
return process
|
|
36
|
+
|
|
37
|
+
monkeypatch.setattr(capabilities, "_COMMAND_CHECK_TIMEOUT_S", 0.01)
|
|
38
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
|
39
|
+
monkeypatch.setattr(
|
|
40
|
+
"connector.capabilities.os.killpg",
|
|
41
|
+
lambda pid, sent_signal: signals.append((pid, sent_signal)),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
result = asyncio.run(
|
|
45
|
+
capabilities._run_version(["bash", "-ic", "codex-api"], start_new_session=True)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
assert result == {"status": "failed", "reason": "timeout"}
|
|
49
|
+
assert signals == [(7654, signal.SIGTERM)]
|
|
50
|
+
|
|
51
|
+
|
|
17
52
|
def test_claude_capability_checks_version_and_help_only(monkeypatch, tmp_path: Path) -> None:
|
|
18
53
|
claude_bin = tmp_path / "claude"
|
|
19
54
|
_write_executable(
|