agentlink-cli 0.1.4__tar.gz → 0.1.5__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.5}/PKG-INFO +15 -1
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/README.md +14 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/capabilities.py +60 -11
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/rpc.py +25 -3
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/launch.py +98 -7
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/runtime.py +6 -2
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/version.py +1 -1
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/pyproject.toml +1 -1
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_codex_adapter.py +28 -1
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_capabilities.py +35 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_runtime.py +1 -0
- agentlink_cli-0.1.5/tests/test_launch_command.py +332 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_version.py +1 -1
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/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.5}/.gitignore +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/config_options.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/discovery.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifest.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifests/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifests/codebuddy.json +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifests/cursor.json +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifests/gemini.json +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifests/grok_build.json +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/reducer.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/rpc.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/attachments.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/history_adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/normalized.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/normalizers.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/path_utils.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/preferences.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/sdk_adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/timeline_identity.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/timeline_reducer.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/trust.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/cli.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/collaboration.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/history.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/reducer.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/control.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/json_rpc.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/common.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/file_ops.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/ops.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/shell.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/terminal.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local_ops.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local_runtime.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/logging.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/perf.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/protocol.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/registry.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/sync_state.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/time.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/run.sh +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/fake_agent/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/fake_agent/__main__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/test_acp_adapter_fake.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/test_acp_reducer_golden.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/test_config_options.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/contract/__init__.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/contract/helpers.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_claude_history_adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_claude_preferences.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_claude_sdk_adapter.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_claude_timeline_parity.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_claude_trust.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_cli.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_control.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_json_rpc.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_logging.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_local_runtime.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_perf.py +0 -0
- {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/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.5
|
|
4
4
|
Summary: Local runtime connector for AgentLink
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: claude-agent-sdk
|
|
@@ -84,6 +84,20 @@ CLAUDE_BIN=/path/to/claude
|
|
|
84
84
|
The connector uses local runtime credentials and local filesystem permissions.
|
|
85
85
|
AgentLink does not proxy Claude or Codex account credentials.
|
|
86
86
|
|
|
87
|
+
Custom Codex launch commands may name an executable or a bash/zsh alias or
|
|
88
|
+
function from the Connector user's interactive shell configuration. For
|
|
89
|
+
example, after defining this in `~/.bashrc` or `~/.zshrc`:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
alias codex-api='CODEX_HOME="$HOME/.codex-api" codex'
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
enter `codex-api` as the custom launch command in AgentLink. Arguments are
|
|
96
|
+
passed without shell re-evaluation, and AgentLink still rejects pipelines,
|
|
97
|
+
redirects, command lists, and multiline commands. Shell aliases and functions
|
|
98
|
+
are supported on Linux and macOS with bash or zsh; Windows custom commands must
|
|
99
|
+
resolve to an executable or a supported script file.
|
|
100
|
+
|
|
87
101
|
## Local Operations
|
|
88
102
|
|
|
89
103
|
The server can ask an online connector to perform local work:
|
|
@@ -67,6 +67,20 @@ CLAUDE_BIN=/path/to/claude
|
|
|
67
67
|
The connector uses local runtime credentials and local filesystem permissions.
|
|
68
68
|
AgentLink does not proxy Claude or Codex account credentials.
|
|
69
69
|
|
|
70
|
+
Custom Codex launch commands may name an executable or a bash/zsh alias or
|
|
71
|
+
function from the Connector user's interactive shell configuration. For
|
|
72
|
+
example, after defining this in `~/.bashrc` or `~/.zshrc`:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
alias codex-api='CODEX_HOME="$HOME/.codex-api" codex'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
enter `codex-api` as the custom launch command in AgentLink. Arguments are
|
|
79
|
+
passed without shell re-evaluation, and AgentLink still rejects pipelines,
|
|
80
|
+
redirects, command lists, and multiline commands. Shell aliases and functions
|
|
81
|
+
are supported on Linux and macOS with bash or zsh; Windows custom commands must
|
|
82
|
+
resolve to an executable or a supported script file.
|
|
83
|
+
|
|
70
84
|
## Local Operations
|
|
71
85
|
|
|
72
86
|
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()
|
|
@@ -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
|
|
@@ -386,6 +390,29 @@ def test_stdio_client_start_is_serialized(monkeypatch) -> None:
|
|
|
386
390
|
asyncio.run(_exercise_stdio_client_start_is_serialized(monkeypatch))
|
|
387
391
|
|
|
388
392
|
|
|
393
|
+
def test_stdio_client_closes_shell_process_group(monkeypatch) -> None:
|
|
394
|
+
class GroupProcess:
|
|
395
|
+
pid = 4321
|
|
396
|
+
returncode: int | None = None
|
|
397
|
+
|
|
398
|
+
async def wait(self) -> int:
|
|
399
|
+
self.returncode = 0
|
|
400
|
+
return 0
|
|
401
|
+
|
|
402
|
+
signals: list[tuple[int, signal.Signals]] = []
|
|
403
|
+
monkeypatch.setattr(
|
|
404
|
+
"connector.codex.rpc.os.killpg",
|
|
405
|
+
lambda pid, sent_signal: signals.append((pid, sent_signal)),
|
|
406
|
+
)
|
|
407
|
+
client = JsonRpcStdioClient(command=["bash"], start_new_session=True)
|
|
408
|
+
client.process = GroupProcess() # type: ignore[assignment]
|
|
409
|
+
|
|
410
|
+
asyncio.run(client.close())
|
|
411
|
+
|
|
412
|
+
assert signals == [(4321, signal.SIGTERM)]
|
|
413
|
+
assert client.process is None
|
|
414
|
+
|
|
415
|
+
|
|
389
416
|
def test_stdio_client_ignores_response_for_cancelled_request() -> None:
|
|
390
417
|
client = JsonRpcStdioClient(command=["codex"])
|
|
391
418
|
loop = asyncio.new_event_loop()
|
|
@@ -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(
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from connector.launch import LaunchTarget, parse_launch_command
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _no_shell_command(_name: str) -> None:
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_parse_launch_command_preserves_prefix_arguments(
|
|
21
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
22
|
+
) -> None:
|
|
23
|
+
executable = tmp_path / "codex-team"
|
|
24
|
+
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
25
|
+
executable.chmod(0o755)
|
|
26
|
+
monkeypatch.setattr("connector.launch.shutil.which", lambda name: str(executable) if name == "codex-team" else None)
|
|
27
|
+
monkeypatch.setattr("connector.launch._resolve_posix_shell_command", _no_shell_command)
|
|
28
|
+
|
|
29
|
+
launch = asyncio.run(parse_launch_command('codex-team --yolo -c "model=team"'))
|
|
30
|
+
|
|
31
|
+
assert launch.command(["app-server", "--listen", "stdio://"]) == [
|
|
32
|
+
str(executable),
|
|
33
|
+
"--yolo",
|
|
34
|
+
"-c",
|
|
35
|
+
"model=team",
|
|
36
|
+
"app-server",
|
|
37
|
+
"--listen",
|
|
38
|
+
"stdio://",
|
|
39
|
+
]
|
|
40
|
+
assert launch.report()["launchId"].startswith("codex:")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("command", ["", "codex | other", "codex && other", "codex\nother"])
|
|
44
|
+
def test_parse_launch_command_rejects_empty_or_shell_syntax(command: str) -> None:
|
|
45
|
+
with pytest.raises(ValueError):
|
|
46
|
+
asyncio.run(parse_launch_command(command))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_parse_launch_command_reports_missing_executable(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
50
|
+
monkeypatch.setattr("connector.launch.shutil.which", lambda _name: None)
|
|
51
|
+
monkeypatch.setattr("connector.launch._resolve_posix_shell_command", _no_shell_command)
|
|
52
|
+
with pytest.raises(ValueError, match="executable not found"):
|
|
53
|
+
asyncio.run(parse_launch_command("not-a-real-codex --yolo"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize(
|
|
57
|
+
"argument",
|
|
58
|
+
[
|
|
59
|
+
"model=R&D",
|
|
60
|
+
"foo|bar",
|
|
61
|
+
"redirect>file",
|
|
62
|
+
"%PATH%",
|
|
63
|
+
"value^next",
|
|
64
|
+
"(group)",
|
|
65
|
+
"wow!",
|
|
66
|
+
'say\\"hello',
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
def test_parse_launch_command_rejects_embedded_cmd_metacharacters(
|
|
70
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
71
|
+
argument: str,
|
|
72
|
+
) -> None:
|
|
73
|
+
monkeypatch.setattr("connector.launch.shutil.which", lambda _name: "C:/tools/codex-team.cmd")
|
|
74
|
+
monkeypatch.setattr("connector.launch._resolve_posix_shell_command", _no_shell_command)
|
|
75
|
+
monkeypatch.setattr(
|
|
76
|
+
"connector.launch.launch_target",
|
|
77
|
+
lambda source, path: LaunchTarget(source=source, path=path, launcher="cmd"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
with pytest.raises(ValueError, match="shell metacharacters"):
|
|
81
|
+
asyncio.run(parse_launch_command(f"codex-team {argument}"))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_parse_launch_command_allows_metacharacters_for_direct_executables(
|
|
85
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
86
|
+
tmp_path: Path,
|
|
87
|
+
) -> None:
|
|
88
|
+
executable = tmp_path / "codex-team"
|
|
89
|
+
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
90
|
+
executable.chmod(0o755)
|
|
91
|
+
monkeypatch.setattr("connector.launch.shutil.which", lambda _name: str(executable))
|
|
92
|
+
monkeypatch.setattr("connector.launch._resolve_posix_shell_command", _no_shell_command)
|
|
93
|
+
|
|
94
|
+
launch = asyncio.run(parse_launch_command("codex-team model=R&D"))
|
|
95
|
+
|
|
96
|
+
assert launch.args == ("model=R&D",)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash is required")
|
|
100
|
+
@pytest.mark.parametrize("definition_kind", ["alias", "function"])
|
|
101
|
+
def test_parse_launch_command_resolves_bash_alias_and_function(
|
|
102
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
103
|
+
tmp_path: Path,
|
|
104
|
+
definition_kind: str,
|
|
105
|
+
) -> None:
|
|
106
|
+
fake_codex = tmp_path / "fake codex"
|
|
107
|
+
_script = """#!/usr/bin/env sh
|
|
108
|
+
printf '%s\\n' "$CODEX_HOME"
|
|
109
|
+
printf '<%s>\\n' "$@"
|
|
110
|
+
"""
|
|
111
|
+
fake_codex.write_text(_script, encoding="utf-8")
|
|
112
|
+
fake_codex.chmod(0o755)
|
|
113
|
+
command = f'CODEX_HOME="$HOME/.codex-api" {shlex.quote(str(fake_codex))}'
|
|
114
|
+
if definition_kind == "alias":
|
|
115
|
+
definition = f"alias codex-api={shlex.quote(command)}\n"
|
|
116
|
+
else:
|
|
117
|
+
definition = f"codex-api() {{ {command} \"$@\"; }}\n"
|
|
118
|
+
(tmp_path / ".bashrc").write_text(definition, encoding="utf-8")
|
|
119
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
120
|
+
monkeypatch.setenv("SHELL", shutil.which("bash") or "/bin/bash")
|
|
121
|
+
|
|
122
|
+
launch = asyncio.run(parse_launch_command("codex-api --profile team"))
|
|
123
|
+
result = subprocess.run(
|
|
124
|
+
launch.command(["app-server", "--listen", "stdio://", "$(touch nope)"]),
|
|
125
|
+
check=True,
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
assert launch.target.source == "shell"
|
|
131
|
+
assert launch.target.starts_process_group is True
|
|
132
|
+
assert result.stdout.splitlines() == [
|
|
133
|
+
str(tmp_path / ".codex-api"),
|
|
134
|
+
"<--profile>",
|
|
135
|
+
"<team>",
|
|
136
|
+
"<app-server>",
|
|
137
|
+
"<--listen>",
|
|
138
|
+
"<stdio://>",
|
|
139
|
+
"<$(touch nope)>",
|
|
140
|
+
]
|
|
141
|
+
assert not (tmp_path / "nope").exists()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash is required")
|
|
145
|
+
def test_shell_alias_takes_precedence_over_same_named_path_executable(
|
|
146
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
147
|
+
tmp_path: Path,
|
|
148
|
+
) -> None:
|
|
149
|
+
bin_dir = tmp_path / "bin"
|
|
150
|
+
bin_dir.mkdir()
|
|
151
|
+
codex = bin_dir / "codex"
|
|
152
|
+
codex.write_text(
|
|
153
|
+
"""#!/usr/bin/env sh
|
|
154
|
+
printf '%s\\n' "$CODEX_HOME"
|
|
155
|
+
printf '<%s>\\n' "$@"
|
|
156
|
+
""",
|
|
157
|
+
encoding="utf-8",
|
|
158
|
+
)
|
|
159
|
+
codex.chmod(0o755)
|
|
160
|
+
(tmp_path / ".bashrc").write_text(
|
|
161
|
+
'alias codex=\'CODEX_HOME="$HOME/.codex-api" codex\'\n',
|
|
162
|
+
encoding="utf-8",
|
|
163
|
+
)
|
|
164
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
165
|
+
monkeypatch.setenv("SHELL", shutil.which("bash") or "/bin/bash")
|
|
166
|
+
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}")
|
|
167
|
+
|
|
168
|
+
launch = asyncio.run(parse_launch_command("codex --profile api"))
|
|
169
|
+
explicit_launch = asyncio.run(parse_launch_command(str(codex)))
|
|
170
|
+
result = subprocess.run(
|
|
171
|
+
launch.command(["--version"]),
|
|
172
|
+
check=True,
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
assert launch.target.source == "shell"
|
|
178
|
+
assert explicit_launch.target.source == "custom"
|
|
179
|
+
assert explicit_launch.command(["--version"]) == [str(codex), "--version"]
|
|
180
|
+
assert result.stdout.splitlines() == [
|
|
181
|
+
str(tmp_path / ".codex-api"),
|
|
182
|
+
"<--profile>",
|
|
183
|
+
"<api>",
|
|
184
|
+
"<--version>",
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_parse_launch_command_does_not_use_unsupported_shell(
|
|
189
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
190
|
+
tmp_path: Path,
|
|
191
|
+
) -> None:
|
|
192
|
+
shell = tmp_path / "fish"
|
|
193
|
+
shell.write_text("#!/usr/bin/env sh\n", encoding="utf-8")
|
|
194
|
+
shell.chmod(0o755)
|
|
195
|
+
monkeypatch.setenv("SHELL", str(shell))
|
|
196
|
+
monkeypatch.setattr(
|
|
197
|
+
"connector.launch.shutil.which",
|
|
198
|
+
lambda name: str(shell) if name == str(shell) else None,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
with pytest.raises(ValueError, match="executable not found: codex-api"):
|
|
202
|
+
asyncio.run(parse_launch_command("codex-api"))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_parse_launch_command_builds_zsh_function_target(
|
|
206
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
207
|
+
) -> None:
|
|
208
|
+
class ProbeProcess:
|
|
209
|
+
returncode = 0
|
|
210
|
+
|
|
211
|
+
async def wait(self) -> int:
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
probe_commands: list[tuple[str, ...]] = []
|
|
215
|
+
|
|
216
|
+
async def fake_create_subprocess_exec(*command: str, **_kwargs: object) -> ProbeProcess:
|
|
217
|
+
probe_commands.append(command)
|
|
218
|
+
return ProbeProcess()
|
|
219
|
+
|
|
220
|
+
monkeypatch.setattr("connector.launch.shutil.which", lambda _name: None)
|
|
221
|
+
monkeypatch.setattr("connector.launch._user_shell", lambda: "/bin/zsh")
|
|
222
|
+
monkeypatch.setattr(
|
|
223
|
+
"connector.launch.asyncio.create_subprocess_exec",
|
|
224
|
+
fake_create_subprocess_exec,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
launch = asyncio.run(parse_launch_command("codex-api --profile team"))
|
|
228
|
+
|
|
229
|
+
assert "whence -w" in probe_commands[0][2]
|
|
230
|
+
assert launch.command(["--version"]) == [
|
|
231
|
+
"/bin/zsh",
|
|
232
|
+
"-ic",
|
|
233
|
+
'codex-api "$@"',
|
|
234
|
+
"agentlink-codex",
|
|
235
|
+
"--profile",
|
|
236
|
+
"team",
|
|
237
|
+
"--version",
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_shell_probe_timeout_kills_process_group(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
242
|
+
class HangingProbeProcess:
|
|
243
|
+
pid = 2468
|
|
244
|
+
returncode: int | None = None
|
|
245
|
+
|
|
246
|
+
def __init__(self) -> None:
|
|
247
|
+
self.stopped = asyncio.Event()
|
|
248
|
+
|
|
249
|
+
async def wait(self) -> int:
|
|
250
|
+
await self.stopped.wait()
|
|
251
|
+
return self.returncode or 0
|
|
252
|
+
|
|
253
|
+
process = HangingProbeProcess()
|
|
254
|
+
spawn_kwargs: list[dict[str, object]] = []
|
|
255
|
+
signals: list[tuple[int, signal.Signals]] = []
|
|
256
|
+
|
|
257
|
+
async def fake_create_subprocess_exec(
|
|
258
|
+
*_command: str,
|
|
259
|
+
**kwargs: object,
|
|
260
|
+
) -> HangingProbeProcess:
|
|
261
|
+
spawn_kwargs.append(kwargs)
|
|
262
|
+
return process
|
|
263
|
+
|
|
264
|
+
def fake_killpg(pid: int, sent_signal: signal.Signals) -> None:
|
|
265
|
+
signals.append((pid, sent_signal))
|
|
266
|
+
process.returncode = -int(sent_signal)
|
|
267
|
+
process.stopped.set()
|
|
268
|
+
|
|
269
|
+
monkeypatch.setattr("connector.launch._SHELL_PROBE_TIMEOUT_S", 0.01)
|
|
270
|
+
monkeypatch.setattr("connector.launch._user_shell", lambda: "/bin/bash")
|
|
271
|
+
monkeypatch.setattr("connector.launch.shutil.which", lambda _name: None)
|
|
272
|
+
monkeypatch.setattr(
|
|
273
|
+
"connector.launch.asyncio.create_subprocess_exec",
|
|
274
|
+
fake_create_subprocess_exec,
|
|
275
|
+
)
|
|
276
|
+
monkeypatch.setattr("connector.launch.os.killpg", fake_killpg)
|
|
277
|
+
|
|
278
|
+
with pytest.raises(ValueError, match="executable not found: codex-api"):
|
|
279
|
+
asyncio.run(parse_launch_command("codex-api"))
|
|
280
|
+
|
|
281
|
+
assert spawn_kwargs[0]["start_new_session"] is True
|
|
282
|
+
assert signals == [(2468, signal.SIGKILL)]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_cancelled_shell_probe_kills_process_group(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
286
|
+
signals: list[tuple[int, signal.Signals]] = []
|
|
287
|
+
|
|
288
|
+
async def exercise() -> None:
|
|
289
|
+
spawned = asyncio.Event()
|
|
290
|
+
|
|
291
|
+
class HangingProbeProcess:
|
|
292
|
+
pid = 1357
|
|
293
|
+
returncode: int | None = None
|
|
294
|
+
|
|
295
|
+
def __init__(self) -> None:
|
|
296
|
+
self.stopped = asyncio.Event()
|
|
297
|
+
|
|
298
|
+
async def wait(self) -> int:
|
|
299
|
+
await self.stopped.wait()
|
|
300
|
+
return self.returncode or 0
|
|
301
|
+
|
|
302
|
+
process = HangingProbeProcess()
|
|
303
|
+
|
|
304
|
+
async def fake_create_subprocess_exec(
|
|
305
|
+
*_command: str,
|
|
306
|
+
**_kwargs: object,
|
|
307
|
+
) -> HangingProbeProcess:
|
|
308
|
+
spawned.set()
|
|
309
|
+
return process
|
|
310
|
+
|
|
311
|
+
def fake_killpg(pid: int, sent_signal: signal.Signals) -> None:
|
|
312
|
+
signals.append((pid, sent_signal))
|
|
313
|
+
process.returncode = -int(sent_signal)
|
|
314
|
+
process.stopped.set()
|
|
315
|
+
|
|
316
|
+
monkeypatch.setattr("connector.launch._user_shell", lambda: "/bin/bash")
|
|
317
|
+
monkeypatch.setattr(
|
|
318
|
+
"connector.launch.asyncio.create_subprocess_exec",
|
|
319
|
+
fake_create_subprocess_exec,
|
|
320
|
+
)
|
|
321
|
+
monkeypatch.setattr("connector.launch.os.killpg", fake_killpg)
|
|
322
|
+
|
|
323
|
+
task = asyncio.create_task(parse_launch_command("codex-api"))
|
|
324
|
+
await spawned.wait()
|
|
325
|
+
await asyncio.sleep(0)
|
|
326
|
+
task.cancel()
|
|
327
|
+
with pytest.raises(asyncio.CancelledError):
|
|
328
|
+
await task
|
|
329
|
+
|
|
330
|
+
asyncio.run(exercise())
|
|
331
|
+
|
|
332
|
+
assert signals == [(1357, signal.SIGKILL)]
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from connector.launch import LaunchTarget, parse_launch_command
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_parse_launch_command_preserves_prefix_arguments(
|
|
11
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
12
|
-
) -> None:
|
|
13
|
-
executable = tmp_path / "codex-team"
|
|
14
|
-
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
15
|
-
executable.chmod(0o755)
|
|
16
|
-
monkeypatch.setattr("connector.launch.shutil.which", lambda name: str(executable) if name == "codex-team" else None)
|
|
17
|
-
|
|
18
|
-
launch = parse_launch_command('codex-team --yolo -c "model=team"')
|
|
19
|
-
|
|
20
|
-
assert launch.command(["app-server", "--listen", "stdio://"]) == [
|
|
21
|
-
str(executable),
|
|
22
|
-
"--yolo",
|
|
23
|
-
"-c",
|
|
24
|
-
"model=team",
|
|
25
|
-
"app-server",
|
|
26
|
-
"--listen",
|
|
27
|
-
"stdio://",
|
|
28
|
-
]
|
|
29
|
-
assert launch.report()["launchId"].startswith("codex:")
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@pytest.mark.parametrize("command", ["", "codex | other", "codex && other", "codex\nother"])
|
|
33
|
-
def test_parse_launch_command_rejects_empty_or_shell_syntax(command: str) -> None:
|
|
34
|
-
with pytest.raises(ValueError):
|
|
35
|
-
parse_launch_command(command)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def test_parse_launch_command_reports_missing_executable(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
39
|
-
monkeypatch.setattr("connector.launch.shutil.which", lambda _name: None)
|
|
40
|
-
with pytest.raises(ValueError, match="executable not found"):
|
|
41
|
-
parse_launch_command("not-a-real-codex --yolo")
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@pytest.mark.parametrize(
|
|
45
|
-
"argument",
|
|
46
|
-
[
|
|
47
|
-
"model=R&D",
|
|
48
|
-
"foo|bar",
|
|
49
|
-
"redirect>file",
|
|
50
|
-
"%PATH%",
|
|
51
|
-
"value^next",
|
|
52
|
-
"(group)",
|
|
53
|
-
"wow!",
|
|
54
|
-
'say\\"hello',
|
|
55
|
-
],
|
|
56
|
-
)
|
|
57
|
-
def test_parse_launch_command_rejects_embedded_cmd_metacharacters(
|
|
58
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
59
|
-
argument: str,
|
|
60
|
-
) -> None:
|
|
61
|
-
monkeypatch.setattr("connector.launch.shutil.which", lambda _name: "C:/tools/codex-team.cmd")
|
|
62
|
-
monkeypatch.setattr(
|
|
63
|
-
"connector.launch.launch_target",
|
|
64
|
-
lambda source, path: LaunchTarget(source=source, path=path, launcher="cmd"),
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
with pytest.raises(ValueError, match="shell metacharacters"):
|
|
68
|
-
parse_launch_command(f"codex-team {argument}")
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def test_parse_launch_command_allows_metacharacters_for_direct_executables(
|
|
72
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
73
|
-
tmp_path: Path,
|
|
74
|
-
) -> None:
|
|
75
|
-
executable = tmp_path / "codex-team"
|
|
76
|
-
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
77
|
-
executable.chmod(0o755)
|
|
78
|
-
monkeypatch.setattr("connector.launch.shutil.which", lambda _name: str(executable))
|
|
79
|
-
|
|
80
|
-
launch = parse_launch_command("codex-team model=R&D")
|
|
81
|
-
|
|
82
|
-
assert launch.args == ("model=R&D",)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|