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.
Files changed (85) hide show
  1. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/PKG-INFO +15 -1
  2. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/README.md +14 -0
  3. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/capabilities.py +60 -11
  4. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/rpc.py +25 -3
  5. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/launch.py +98 -7
  6. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/runtime.py +6 -2
  7. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/version.py +1 -1
  8. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/pyproject.toml +1 -1
  9. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_codex_adapter.py +28 -1
  10. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_capabilities.py +35 -0
  11. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_runtime.py +1 -0
  12. agentlink_cli-0.1.5/tests/test_launch_command.py +332 -0
  13. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_version.py +1 -1
  14. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/uv.lock +1 -1
  15. agentlink_cli-0.1.4/tests/test_launch_command.py +0 -82
  16. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/.gitignore +0 -0
  17. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/__init__.py +0 -0
  18. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/__init__.py +0 -0
  19. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/adapter.py +0 -0
  20. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/config_options.py +0 -0
  21. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/discovery.py +0 -0
  22. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifest.py +0 -0
  23. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifests/__init__.py +0 -0
  24. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifests/codebuddy.json +0 -0
  25. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifests/cursor.json +0 -0
  26. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifests/gemini.json +0 -0
  27. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/manifests/grok_build.json +0 -0
  28. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/reducer.py +0 -0
  29. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/acp/rpc.py +0 -0
  30. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/adapter.py +0 -0
  31. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/attachments.py +0 -0
  32. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/__init__.py +0 -0
  33. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/history_adapter.py +0 -0
  34. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/normalized.py +0 -0
  35. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/normalizers.py +0 -0
  36. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/path_utils.py +0 -0
  37. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/preferences.py +0 -0
  38. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/sdk_adapter.py +0 -0
  39. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/timeline_identity.py +0 -0
  40. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/timeline_reducer.py +0 -0
  41. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/claude/trust.py +0 -0
  42. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/cli.py +0 -0
  43. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/__init__.py +0 -0
  44. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/adapter.py +0 -0
  45. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/collaboration.py +0 -0
  46. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/history.py +0 -0
  47. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/codex/reducer.py +0 -0
  48. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/control.py +0 -0
  49. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/json_rpc.py +0 -0
  50. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/__init__.py +0 -0
  51. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/common.py +0 -0
  52. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/file_ops.py +0 -0
  53. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/ops.py +0 -0
  54. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/shell.py +0 -0
  55. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local/terminal.py +0 -0
  56. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local_ops.py +0 -0
  57. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/local_runtime.py +0 -0
  58. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/logging.py +0 -0
  59. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/perf.py +0 -0
  60. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/protocol.py +0 -0
  61. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/registry.py +0 -0
  62. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/sync_state.py +0 -0
  63. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/connector/time.py +0 -0
  64. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/run.sh +0 -0
  65. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/__init__.py +0 -0
  66. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/__init__.py +0 -0
  67. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/fake_agent/__init__.py +0 -0
  68. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/fake_agent/__main__.py +0 -0
  69. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/test_acp_adapter_fake.py +0 -0
  70. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/test_acp_reducer_golden.py +0 -0
  71. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/acp/test_config_options.py +0 -0
  72. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/contract/__init__.py +0 -0
  73. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/contract/helpers.py +0 -0
  74. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_claude_history_adapter.py +0 -0
  75. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_claude_preferences.py +0 -0
  76. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_claude_sdk_adapter.py +0 -0
  77. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_claude_timeline_parity.py +0 -0
  78. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_claude_trust.py +0 -0
  79. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_cli.py +0 -0
  80. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_control.py +0 -0
  81. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_json_rpc.py +0 -0
  82. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_connector_logging.py +0 -0
  83. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_local_runtime.py +0 -0
  84. {agentlink_cli-0.1.4 → agentlink_cli-0.1.5}/tests/test_perf.py +0 -0
  85. {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.4
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 Path(path).is_file():
242
- return {**base, "status": "missing", "reason": "file not found"}
243
- if not path_exists_for_launch(path):
244
- return {**base, "status": "failed", "reason": "not executable"}
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 _run_version(command.command(["--version"]))
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 = JsonRpcStdioClient(command=command.command(["app-server", "--listen", "stdio://"]))
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 Path(target.path).is_file() or not path_exists_for_launch(target.path):
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 _run_version(command.command(["--version"]))
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 = JsonRpcStdioClient(command=command.command(["app-server", "--listen", "stdio://"]))
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 _run_version(command: list[str]) -> dict[str, Any]:
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__(self, command: list[str] | None = None) -> None:
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
- self.process.terminate()
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
- self.process.kill()
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
- resolved = shutil.which(executable)
112
- if resolved is None and Path(executable).is_file():
113
- resolved = executable
114
- if resolved is None:
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(command=command)
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,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from importlib.metadata import PackageNotFoundError, version
4
4
 
5
- FALLBACK_VERSION = "0.1.4"
5
+ FALLBACK_VERSION = "0.1.5"
6
6
 
7
7
 
8
8
  def connector_version() -> str:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentlink-cli"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  description = "Local runtime connector for AgentLink"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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 collaboration_mode_payload, normalize_collaboration_modes
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(
@@ -85,6 +85,7 @@ class FakeAdapter:
85
85
  class FakeCodexRpc:
86
86
  def __init__(self, command: list[str]) -> None:
87
87
  self.command = command
88
+ self.start_new_session = False
88
89
  self.closed = False
89
90
 
90
91
  async def close(self) -> None:
@@ -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)]
@@ -17,4 +17,4 @@ def test_connector_version_falls_back_in_source_tree(monkeypatch) -> None:
17
17
 
18
18
  monkeypatch.setattr(version_module, "version", missing)
19
19
 
20
- assert version_module.connector_version() == "0.1.4"
20
+ assert version_module.connector_version() == "0.1.5"
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "agentlink-cli"
11
- version = "0.1.4"
11
+ version = "0.1.5"
12
12
  source = { editable = "." }
13
13
  dependencies = [
14
14
  { name = "claude-agent-sdk" },
@@ -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