ai-push-hooks 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -2
- package/README.md +72 -996
- package/SECURITY.md +21 -16
- package/bin/ai-push-hooks.js +6 -6
- package/package.json +3 -2
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +0 -13
- package/src/ai_push_hooks/config.py +19 -5
- package/src/ai_push_hooks/engine.py +2 -1
- package/src/ai_push_hooks/executors/apply.py +3 -3
- package/src/ai_push_hooks/executors/ask.py +0 -523
- package/src/ai_push_hooks/executors/exec.py +17 -801
- package/src/ai_push_hooks/executors/runner_workflow.py +4 -4
- package/src/ai_push_hooks/executors/runners/opencode.py +40 -11
- package/src/ai_push_hooks/executors/runners/opencode_support.py +276 -0
- package/src/ai_push_hooks/executors/runners/process.py +72 -11
- package/src/ai_push_hooks/executors/runners/registry.py +17 -4
- package/src/ai_push_hooks/executors/step_commands.py +7 -2
- package/src/ai_push_hooks/git_utils.py +834 -0
- package/src/ai_push_hooks/hook.py +1 -1
- package/src/ai_push_hooks/modules/beads.py +1 -1
- package/src/ai_push_hooks/modules/docs.py +129 -89
- package/src/ai_push_hooks/modules/pr.py +1 -1
- package/src/ai_push_hooks/plugin_loader.py +106 -82
- package/src/ai_push_hooks/types.py +1 -0
- package/vendor/README.md +15 -0
- package/vendor/requirements.txt +1 -0
- package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
|
@@ -54,15 +54,15 @@ def _safe_input_artifacts(
|
|
|
54
54
|
) -> tuple[RunnerArtifact, ...]:
|
|
55
55
|
"""Build the ordered logical artifact list shared by every adapter.
|
|
56
56
|
|
|
57
|
-
``
|
|
58
|
-
|
|
57
|
+
``validate_hook_owned_artifacts`` is the hook-owned artifact boundary used
|
|
58
|
+
before adapter dispatch. The logical names are the
|
|
59
59
|
configured input references, rather than filesystem basenames, so command,
|
|
60
60
|
Codex, Claude, and OpenCode receive the same ordered context.
|
|
61
61
|
"""
|
|
62
62
|
|
|
63
|
-
from .
|
|
63
|
+
from .runners.opencode_support import validate_hook_owned_artifacts
|
|
64
64
|
|
|
65
|
-
validated =
|
|
65
|
+
validated = validate_hook_owned_artifacts(context, input_paths)
|
|
66
66
|
if len(validated) != len(step.inputs):
|
|
67
67
|
raise HookError(
|
|
68
68
|
f"Runner input count does not match configured inputs for step `{step.id}`"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""OpenCode adapter for the internal runner boundary.
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The adapter deliberately retains the shipped ``opencode run`` shape. This
|
|
4
|
+
module owns the adapter-level protocol and lifecycle semantics; workflow
|
|
5
|
+
retries and JSON/schema handling remain in orchestration.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -14,7 +14,7 @@ import sys
|
|
|
14
14
|
import tempfile
|
|
15
15
|
from typing import Any
|
|
16
16
|
|
|
17
|
-
from
|
|
17
|
+
from .opencode_support import (
|
|
18
18
|
OPENCODE_APPLY_AGENT,
|
|
19
19
|
OPENCODE_READ_ONLY_AGENT,
|
|
20
20
|
_transcript_dir,
|
|
@@ -23,10 +23,9 @@ from ..ask import (
|
|
|
23
23
|
opencode_isolation_env,
|
|
24
24
|
resolve_opencode_executable,
|
|
25
25
|
sanitize_filename_component,
|
|
26
|
-
|
|
26
|
+
validate_hook_owned_artifacts,
|
|
27
27
|
)
|
|
28
|
-
from ...paths import resolve_contained_path, write_text_no_follow
|
|
29
|
-
from ...paths import ensure_private_directory
|
|
28
|
+
from ...paths import ensure_private_directory, resolve_contained_path, write_text_no_follow
|
|
30
29
|
from .process import ProcessResult
|
|
31
30
|
from .contracts import (
|
|
32
31
|
RunnerCapabilities,
|
|
@@ -159,7 +158,7 @@ class OpenCodeRunner:
|
|
|
159
158
|
# Validate source ownership and symlink traversal before making any
|
|
160
159
|
# copy. The source is then never reopened: its logical snapshot is
|
|
161
160
|
# the only content sent through OpenCode's native --file transport.
|
|
162
|
-
|
|
161
|
+
validate_hook_owned_artifacts(
|
|
163
162
|
context,
|
|
164
163
|
[path for path in original_paths if path is not None],
|
|
165
164
|
)
|
|
@@ -251,6 +250,27 @@ class OpenCodeRunner:
|
|
|
251
250
|
argv.extend(["--", prompt])
|
|
252
251
|
return argv
|
|
253
252
|
|
|
253
|
+
@staticmethod
|
|
254
|
+
def _require_staging_directory(context: Any, working_directory: pathlib.Path) -> None:
|
|
255
|
+
"""Reject repository worktrees as apply targets.
|
|
256
|
+
|
|
257
|
+
The apply workflow supplies a disposable, non-VCS staging projection.
|
|
258
|
+
A runner request can identify the repository root and the selected cwd,
|
|
259
|
+
but it cannot turn an arbitrary host directory into a sandbox. Refuse
|
|
260
|
+
the known unsafe cases at this boundary instead of granting edit
|
|
261
|
+
permissions to the checked-out repository.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
repository_root = context.repo_root.resolve(strict=True)
|
|
265
|
+
if working_directory == repository_root or working_directory.is_relative_to(repository_root):
|
|
266
|
+
raise RunnerContractError(
|
|
267
|
+
"OpenCode apply requests require an isolated staging directory"
|
|
268
|
+
)
|
|
269
|
+
if any((parent / ".git").exists() for parent in (working_directory, *working_directory.parents)):
|
|
270
|
+
raise RunnerContractError(
|
|
271
|
+
"OpenCode apply requests require a non-VCS isolated staging directory"
|
|
272
|
+
)
|
|
273
|
+
|
|
254
274
|
def run(self, request: RunnerRequest) -> RunnerResult:
|
|
255
275
|
if request.runner_type != "opencode":
|
|
256
276
|
raise RunnerContractError(
|
|
@@ -268,8 +288,17 @@ class OpenCodeRunner:
|
|
|
268
288
|
else:
|
|
269
289
|
working_directory = None
|
|
270
290
|
|
|
271
|
-
if working_directory is not None
|
|
272
|
-
|
|
291
|
+
if working_directory is not None:
|
|
292
|
+
try:
|
|
293
|
+
working_directory = working_directory.resolve(strict=True)
|
|
294
|
+
except (OSError, RuntimeError) as exc:
|
|
295
|
+
raise RunnerContractError(
|
|
296
|
+
"OpenCode request cwd must be an existing directory"
|
|
297
|
+
) from exc
|
|
298
|
+
if not working_directory.is_dir():
|
|
299
|
+
raise RunnerContractError("OpenCode request cwd must be an existing directory")
|
|
300
|
+
if request.mode == "apply":
|
|
301
|
+
self._require_staging_directory(context, working_directory)
|
|
273
302
|
|
|
274
303
|
agent = "apply" if request.mode == "apply" else "read-only"
|
|
275
304
|
_agent_name, security_config = build_opencode_security_config(
|
|
@@ -325,7 +354,7 @@ class OpenCodeRunner:
|
|
|
325
354
|
with tempfile.TemporaryDirectory(prefix="ai-push-hooks-readonly-") as directory:
|
|
326
355
|
completed = invoke(pathlib.Path(directory).resolve(strict=True))
|
|
327
356
|
else:
|
|
328
|
-
completed = invoke(working_directory
|
|
357
|
+
completed = invoke(working_directory)
|
|
329
358
|
except (RunnerError,) as error:
|
|
330
359
|
process_result = getattr(error, "_process_result", None)
|
|
331
360
|
partial_stdout = (
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Shared OpenCode isolation and hook-owned artifact policy helpers.
|
|
2
|
+
|
|
3
|
+
This module contains only the OpenCode-specific filesystem, environment, and
|
|
4
|
+
permission policy used by the active adapter. Workflow orchestration remains
|
|
5
|
+
runner-neutral and the adapter owns invocation and session lifecycle.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ...paths import (
|
|
18
|
+
ensure_private_directory,
|
|
19
|
+
is_path_within,
|
|
20
|
+
path_has_symlink,
|
|
21
|
+
resolve_contained_path,
|
|
22
|
+
)
|
|
23
|
+
from ...git_utils import ensure_dir, resolve_storage_path
|
|
24
|
+
from ...types import HookError, RuntimeContext
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
OPENCODE_READ_ONLY_AGENT = "ai-push-hooks-readonly"
|
|
28
|
+
OPENCODE_APPLY_AGENT = "ai-push-hooks-apply"
|
|
29
|
+
PROVIDER_ENV_PREFIXES = (
|
|
30
|
+
"ANTHROPIC_",
|
|
31
|
+
"AWS_",
|
|
32
|
+
"AZURE_",
|
|
33
|
+
"COHERE_",
|
|
34
|
+
"DEEPSEEK_",
|
|
35
|
+
"GEMINI_",
|
|
36
|
+
"GOOGLE_",
|
|
37
|
+
"GROQ_",
|
|
38
|
+
"MISTRAL_",
|
|
39
|
+
"OPENAI_",
|
|
40
|
+
"OPENROUTER_",
|
|
41
|
+
"VERTEX_",
|
|
42
|
+
"XAI_",
|
|
43
|
+
)
|
|
44
|
+
SAFE_PROCESS_ENV_NAMES = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"PATH",
|
|
47
|
+
"TMPDIR",
|
|
48
|
+
"TMP",
|
|
49
|
+
"TEMP",
|
|
50
|
+
"LANG",
|
|
51
|
+
"LC_ALL",
|
|
52
|
+
"LC_CTYPE",
|
|
53
|
+
"SYSTEMROOT",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _actual_xdg_data_home() -> pathlib.Path:
|
|
59
|
+
configured = os.environ.get("XDG_DATA_HOME", "").strip()
|
|
60
|
+
if configured:
|
|
61
|
+
return pathlib.Path(configured).expanduser().resolve(strict=False)
|
|
62
|
+
return (pathlib.Path.home() / ".local" / "share").resolve(strict=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def opencode_isolation_env(
|
|
66
|
+
context: RuntimeContext,
|
|
67
|
+
security_config: dict[str, Any],
|
|
68
|
+
stage_name: str,
|
|
69
|
+
) -> dict[str, str | None]:
|
|
70
|
+
lexical_isolation_root = (
|
|
71
|
+
context.run_dir / "opencode-isolation" / sanitize_filename_component(stage_name)
|
|
72
|
+
)
|
|
73
|
+
if path_has_symlink(context.run_dir, lexical_isolation_root):
|
|
74
|
+
raise HookError(f"OpenCode isolation directory must not traverse a symlink: {stage_name}")
|
|
75
|
+
isolation_root = resolve_contained_path(
|
|
76
|
+
context.run_dir,
|
|
77
|
+
f"opencode-isolation/{sanitize_filename_component(stage_name)}",
|
|
78
|
+
"OpenCode isolation directory",
|
|
79
|
+
)
|
|
80
|
+
home = isolation_root / "home"
|
|
81
|
+
config_home = isolation_root / "config"
|
|
82
|
+
cache_home = isolation_root / "cache"
|
|
83
|
+
state_home = isolation_root / "state"
|
|
84
|
+
ensure_private_directory(isolation_root)
|
|
85
|
+
for path in (home, config_home, cache_home, state_home):
|
|
86
|
+
ensure_private_directory(path, private_root=isolation_root)
|
|
87
|
+
|
|
88
|
+
isolated: dict[str, str | None] = {
|
|
89
|
+
name: value
|
|
90
|
+
for name, value in os.environ.items()
|
|
91
|
+
if name in SAFE_PROCESS_ENV_NAMES or name.startswith(PROVIDER_ENV_PREFIXES)
|
|
92
|
+
}
|
|
93
|
+
isolated.update(
|
|
94
|
+
{
|
|
95
|
+
"HOME": str(home),
|
|
96
|
+
"XDG_CONFIG_HOME": str(config_home),
|
|
97
|
+
"XDG_CACHE_HOME": str(cache_home),
|
|
98
|
+
"XDG_STATE_HOME": str(state_home),
|
|
99
|
+
"XDG_DATA_HOME": str(_actual_xdg_data_home()),
|
|
100
|
+
"OPENCODE_CONFIG_CONTENT": json.dumps(security_config, ensure_ascii=True),
|
|
101
|
+
"OPENCODE_CONFIG_DIR": str(config_home),
|
|
102
|
+
"OPENCODE_PURE": "true",
|
|
103
|
+
"OPENCODE_DISABLE_PROJECT_CONFIG": "true",
|
|
104
|
+
"OPENCODE_DISABLE_EXTERNAL_SKILLS": "true",
|
|
105
|
+
"OPENCODE_DISABLE_CLAUDE_CODE": "true",
|
|
106
|
+
"OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "true",
|
|
107
|
+
"OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "true",
|
|
108
|
+
"OPENCODE_DISABLE_LSP_DOWNLOAD": "true",
|
|
109
|
+
"OPENCODE_DISABLE_SHARE": "true",
|
|
110
|
+
"OPENCODE_DISABLE_AUTOUPDATE": "true",
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
return isolated
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def non_agent_opencode_config() -> dict[str, Any]:
|
|
117
|
+
return {
|
|
118
|
+
"$schema": "https://opencode.ai/config.json",
|
|
119
|
+
"plugin": [],
|
|
120
|
+
"mcp": {},
|
|
121
|
+
"share": "disabled",
|
|
122
|
+
"instructions": [],
|
|
123
|
+
"formatter": False,
|
|
124
|
+
"lsp": False,
|
|
125
|
+
"command": {},
|
|
126
|
+
"permission": {"*": "deny"},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def sanitize_filename_component(value: str) -> str:
|
|
131
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
|
|
132
|
+
return cleaned.strip("-") or "value"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def resolve_opencode_executable() -> str:
|
|
136
|
+
opencode_path = shutil.which("opencode")
|
|
137
|
+
if opencode_path:
|
|
138
|
+
return opencode_path
|
|
139
|
+
cli_path = shutil.which("opencode-cli")
|
|
140
|
+
if cli_path:
|
|
141
|
+
return cli_path
|
|
142
|
+
raise HookError("opencode is required but not installed")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _transcript_dir(context: RuntimeContext) -> pathlib.Path | None:
|
|
146
|
+
if not context.config.logging.capture_llm_transcript:
|
|
147
|
+
return None
|
|
148
|
+
return ensure_dir(
|
|
149
|
+
resolve_storage_path(
|
|
150
|
+
context.repo_root,
|
|
151
|
+
context.git_dir,
|
|
152
|
+
context.config.logging.transcript_dir,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_opencode_security_config(
|
|
158
|
+
agent_policy: str,
|
|
159
|
+
allow_paths: tuple[str, ...] = (),
|
|
160
|
+
*,
|
|
161
|
+
non_vcs_working_directory: pathlib.Path | None = None,
|
|
162
|
+
project_read_root: pathlib.Path | None = None,
|
|
163
|
+
) -> tuple[str, dict[str, Any]]:
|
|
164
|
+
permissions: dict[str, Any] = {
|
|
165
|
+
"*": "deny",
|
|
166
|
+
"read": "deny",
|
|
167
|
+
"glob": "deny",
|
|
168
|
+
"grep": "deny",
|
|
169
|
+
"list": "deny",
|
|
170
|
+
"edit": "deny",
|
|
171
|
+
"bash": "deny",
|
|
172
|
+
"task": "deny",
|
|
173
|
+
"external_directory": "deny",
|
|
174
|
+
"webfetch": "deny",
|
|
175
|
+
"websearch": "deny",
|
|
176
|
+
"lsp": "deny",
|
|
177
|
+
"skill": "deny",
|
|
178
|
+
"todowrite": "deny",
|
|
179
|
+
"question": "deny",
|
|
180
|
+
"doom_loop": "deny",
|
|
181
|
+
}
|
|
182
|
+
if agent_policy == "read-only":
|
|
183
|
+
agent_name = OPENCODE_READ_ONLY_AGENT
|
|
184
|
+
description = "Read-only ai-push-hooks analysis agent"
|
|
185
|
+
elif agent_policy == "apply":
|
|
186
|
+
if not allow_paths:
|
|
187
|
+
raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
|
|
188
|
+
agent_name = OPENCODE_APPLY_AGENT
|
|
189
|
+
description = "Path-restricted ai-push-hooks apply agent"
|
|
190
|
+
permissions["read"] = "allow"
|
|
191
|
+
permission_patterns: set[str] = set()
|
|
192
|
+
for pattern in allow_paths:
|
|
193
|
+
permission_patterns.add(pattern)
|
|
194
|
+
collapsed = pattern
|
|
195
|
+
while "**/" in collapsed:
|
|
196
|
+
collapsed = collapsed.replace("**/", "", 1)
|
|
197
|
+
permission_patterns.add(collapsed)
|
|
198
|
+
protected_patterns = {".git", ".git/**"}
|
|
199
|
+
if non_vcs_working_directory is not None:
|
|
200
|
+
# OpenCode assigns `/` as the worktree for a directory without VCS
|
|
201
|
+
# metadata, so qualify allowlisted paths with that filesystem root.
|
|
202
|
+
anchor = pathlib.Path(non_vcs_working_directory.anchor)
|
|
203
|
+
prefix = non_vcs_working_directory.relative_to(anchor).as_posix()
|
|
204
|
+
permission_patterns = {
|
|
205
|
+
f"{prefix}/{pattern}" if prefix else pattern
|
|
206
|
+
for pattern in permission_patterns
|
|
207
|
+
}
|
|
208
|
+
protected_patterns = {
|
|
209
|
+
f"{prefix}/{pattern}" if prefix else pattern
|
|
210
|
+
for pattern in protected_patterns
|
|
211
|
+
}
|
|
212
|
+
edit_permissions = {
|
|
213
|
+
"*": "deny",
|
|
214
|
+
**{pattern: "allow" for pattern in sorted(permission_patterns)},
|
|
215
|
+
}
|
|
216
|
+
for pattern in sorted(protected_patterns):
|
|
217
|
+
edit_permissions[pattern] = "deny"
|
|
218
|
+
permissions["edit"] = edit_permissions
|
|
219
|
+
else:
|
|
220
|
+
raise HookError(f"Unsupported OpenCode agent policy: {agent_policy}")
|
|
221
|
+
|
|
222
|
+
if project_read_root is not None:
|
|
223
|
+
root = project_read_root.resolve(strict=False)
|
|
224
|
+
if (root / ".git").exists():
|
|
225
|
+
rooted_permissions: str | dict[str, str] = "allow"
|
|
226
|
+
else:
|
|
227
|
+
anchor = pathlib.Path(root.anchor)
|
|
228
|
+
prefix = root.relative_to(anchor).as_posix()
|
|
229
|
+
rooted_permissions = {
|
|
230
|
+
"*": "deny",
|
|
231
|
+
prefix: "allow",
|
|
232
|
+
f"{prefix}/**": "allow",
|
|
233
|
+
}
|
|
234
|
+
for tool in ("read", "list", "glob", "grep"):
|
|
235
|
+
permissions[tool] = (
|
|
236
|
+
dict(rooted_permissions)
|
|
237
|
+
if isinstance(rooted_permissions, dict)
|
|
238
|
+
else rooted_permissions
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return agent_name, {
|
|
242
|
+
"$schema": "https://opencode.ai/config.json",
|
|
243
|
+
"plugin": [],
|
|
244
|
+
"mcp": {},
|
|
245
|
+
"share": "disabled",
|
|
246
|
+
"instructions": [],
|
|
247
|
+
"formatter": False,
|
|
248
|
+
"lsp": False,
|
|
249
|
+
"command": {},
|
|
250
|
+
"agent": {
|
|
251
|
+
agent_name: {
|
|
252
|
+
"mode": "primary",
|
|
253
|
+
"description": description,
|
|
254
|
+
"permission": permissions,
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def validate_hook_owned_artifacts(
|
|
261
|
+
context: RuntimeContext,
|
|
262
|
+
files: list[pathlib.Path],
|
|
263
|
+
) -> list[pathlib.Path]:
|
|
264
|
+
run_root = context.run_dir.resolve(strict=True)
|
|
265
|
+
validated: list[pathlib.Path] = []
|
|
266
|
+
for file_path in files:
|
|
267
|
+
lexical_path = pathlib.Path(os.path.abspath(file_path))
|
|
268
|
+
if not is_path_within(lexical_path, run_root):
|
|
269
|
+
raise HookError(f"OpenCode attachment is not a hook-owned artifact: {file_path}")
|
|
270
|
+
if path_has_symlink(run_root, lexical_path):
|
|
271
|
+
raise HookError(f"OpenCode attachment must not traverse a symlink: {file_path}")
|
|
272
|
+
resolved_path = lexical_path.resolve(strict=True)
|
|
273
|
+
if not is_path_within(resolved_path, run_root) or not resolved_path.is_file():
|
|
274
|
+
raise HookError(f"OpenCode attachment must be a regular hook-owned file: {file_path}")
|
|
275
|
+
validated.append(resolved_path)
|
|
276
|
+
return validated
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import math
|
|
5
6
|
import os
|
|
6
7
|
import pathlib
|
|
7
8
|
import signal
|
|
@@ -58,6 +59,7 @@ def _read_bounded(
|
|
|
58
59
|
output: bytearray,
|
|
59
60
|
truncated: list[bool],
|
|
60
61
|
output_lock: threading.Lock,
|
|
62
|
+
output_limit_reached: threading.Event,
|
|
61
63
|
) -> None:
|
|
62
64
|
read = getattr(stream, "read")
|
|
63
65
|
try:
|
|
@@ -74,6 +76,7 @@ def _read_bounded(
|
|
|
74
76
|
output.extend(chunk[:remaining])
|
|
75
77
|
if len(chunk) > remaining:
|
|
76
78
|
truncated[0] = True
|
|
79
|
+
output_limit_reached.set()
|
|
77
80
|
finally:
|
|
78
81
|
_close_pipe(stream)
|
|
79
82
|
|
|
@@ -213,6 +216,7 @@ def run_process(
|
|
|
213
216
|
timeout_seconds: float,
|
|
214
217
|
env: Mapping[str, str] | None = None,
|
|
215
218
|
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
219
|
+
max_stderr_bytes: int | None = None,
|
|
216
220
|
) -> ProcessResult:
|
|
217
221
|
"""Run an argv vector directly, with bounded concurrent stream capture.
|
|
218
222
|
|
|
@@ -230,10 +234,26 @@ def run_process(
|
|
|
230
234
|
cwd = pathlib.Path(cwd)
|
|
231
235
|
if not cwd.is_dir():
|
|
232
236
|
raise RunnerError("runner cwd must be an existing directory")
|
|
233
|
-
if
|
|
234
|
-
|
|
235
|
-
|
|
237
|
+
if (
|
|
238
|
+
isinstance(timeout_seconds, bool)
|
|
239
|
+
or not isinstance(timeout_seconds, (int, float))
|
|
240
|
+
or not math.isfinite(timeout_seconds)
|
|
241
|
+
or timeout_seconds <= 0
|
|
242
|
+
):
|
|
243
|
+
raise RunnerError("runner timeout must be a finite number greater than zero")
|
|
244
|
+
if (
|
|
245
|
+
isinstance(max_output_bytes, bool)
|
|
246
|
+
or not isinstance(max_output_bytes, int)
|
|
247
|
+
or max_output_bytes < 0
|
|
248
|
+
):
|
|
236
249
|
raise RunnerError("runner output bound must not be negative")
|
|
250
|
+
stderr_limit = max_output_bytes if max_stderr_bytes is None else max_stderr_bytes
|
|
251
|
+
if (
|
|
252
|
+
isinstance(stderr_limit, bool)
|
|
253
|
+
or not isinstance(stderr_limit, int)
|
|
254
|
+
or stderr_limit < 0
|
|
255
|
+
):
|
|
256
|
+
raise RunnerError("runner stderr output bound must not be negative")
|
|
237
257
|
if input_text is not None and input_path is not None:
|
|
238
258
|
raise RunnerError("runner input_text and input_path are mutually exclusive")
|
|
239
259
|
input_file = None
|
|
@@ -306,14 +326,29 @@ def run_process(
|
|
|
306
326
|
stderr_lock = threading.Lock()
|
|
307
327
|
stdout_truncated = [False]
|
|
308
328
|
stderr_truncated = [False]
|
|
329
|
+
output_limit_reached = threading.Event()
|
|
309
330
|
stdout_thread = threading.Thread(
|
|
310
331
|
target=_read_bounded,
|
|
311
|
-
args=(
|
|
332
|
+
args=(
|
|
333
|
+
process.stdout,
|
|
334
|
+
max_output_bytes,
|
|
335
|
+
stdout,
|
|
336
|
+
stdout_truncated,
|
|
337
|
+
stdout_lock,
|
|
338
|
+
output_limit_reached,
|
|
339
|
+
),
|
|
312
340
|
daemon=True,
|
|
313
341
|
)
|
|
314
342
|
stderr_thread = threading.Thread(
|
|
315
343
|
target=_read_bounded,
|
|
316
|
-
args=(
|
|
344
|
+
args=(
|
|
345
|
+
process.stderr,
|
|
346
|
+
stderr_limit,
|
|
347
|
+
stderr,
|
|
348
|
+
stderr_truncated,
|
|
349
|
+
stderr_lock,
|
|
350
|
+
output_limit_reached,
|
|
351
|
+
),
|
|
317
352
|
daemon=True,
|
|
318
353
|
)
|
|
319
354
|
stdout_thread.start()
|
|
@@ -350,23 +385,49 @@ def run_process(
|
|
|
350
385
|
|
|
351
386
|
input_thread = threading.Thread(target=write_input, daemon=True)
|
|
352
387
|
input_thread.start()
|
|
388
|
+
process_exited = threading.Event()
|
|
389
|
+
process_returncode: list[int | None] = [None]
|
|
390
|
+
|
|
391
|
+
def wait_for_process_exit() -> None:
|
|
392
|
+
process_returncode[0] = process.wait()
|
|
393
|
+
process_exited.set()
|
|
394
|
+
|
|
395
|
+
process_wait_thread = threading.Thread(target=wait_for_process_exit, daemon=True)
|
|
396
|
+
process_wait_thread.start()
|
|
353
397
|
returncode: int | None = None
|
|
354
398
|
timeout_cause: subprocess.TimeoutExpired | None = None
|
|
399
|
+
output_limit_cause = False
|
|
355
400
|
try:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
401
|
+
deadline = time.monotonic() + timeout_seconds
|
|
402
|
+
while True:
|
|
403
|
+
remaining = deadline - time.monotonic()
|
|
404
|
+
if remaining <= 0:
|
|
405
|
+
timeout_cause = subprocess.TimeoutExpired(list(argv), timeout_seconds)
|
|
406
|
+
break
|
|
407
|
+
if output_limit_reached.is_set():
|
|
408
|
+
# A short grace period preserves the natural exit status for
|
|
409
|
+
# commands that wrote their final bounded chunk and were
|
|
410
|
+
# already exiting. A still-live child is stopped below
|
|
411
|
+
# rather than allowed to drain discarded output until timeout.
|
|
412
|
+
if process_exited.wait(timeout=min(remaining, 0.05)):
|
|
413
|
+
returncode = process_returncode[0]
|
|
414
|
+
else:
|
|
415
|
+
output_limit_cause = True
|
|
416
|
+
break
|
|
417
|
+
if process_exited.wait(timeout=min(remaining, 0.05)):
|
|
418
|
+
returncode = process_returncode[0]
|
|
419
|
+
break
|
|
359
420
|
finally:
|
|
360
421
|
cleanup_deadline = time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS
|
|
361
422
|
_stop_process(process, cleanup_deadline)
|
|
362
423
|
_finish_capture(
|
|
363
424
|
(process.stdin, process.stdout, process.stderr),
|
|
364
|
-
(input_thread, stdout_thread, stderr_thread),
|
|
425
|
+
(input_thread, stdout_thread, stderr_thread, process_wait_thread),
|
|
365
426
|
cleanup_deadline,
|
|
366
427
|
)
|
|
367
428
|
|
|
368
429
|
if returncode is None:
|
|
369
|
-
returncode = process.poll()
|
|
430
|
+
returncode = process_returncode[0] if process_returncode[0] is not None else process.poll()
|
|
370
431
|
if timeout_cause is not None:
|
|
371
432
|
timeout_returncode = returncode if returncode is not None else -getattr(signal, "SIGKILL", 9)
|
|
372
433
|
process_result = _captured_result(
|
|
@@ -393,7 +454,7 @@ def run_process(
|
|
|
393
454
|
stdout_lock,
|
|
394
455
|
stderr_lock,
|
|
395
456
|
)
|
|
396
|
-
if returncode < 0:
|
|
457
|
+
if returncode < 0 and not output_limit_cause:
|
|
397
458
|
error = RunnerSignalError(
|
|
398
459
|
"runner process terminated by signal",
|
|
399
460
|
details=str(-returncode),
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import importlib
|
|
6
|
+
import threading
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from types import ModuleType
|
|
8
9
|
from typing import Callable, Mapping
|
|
@@ -78,6 +79,9 @@ class RunnerRegistry:
|
|
|
78
79
|
raise RunnerContractError("runner registry must contain all four known adapter types")
|
|
79
80
|
self._specs = dict(selected)
|
|
80
81
|
self._loaded: dict[str, Runner] = {}
|
|
82
|
+
self._load_locks = {
|
|
83
|
+
runner_type: threading.Lock() for runner_type in KNOWN_RUNNER_TYPES
|
|
84
|
+
}
|
|
81
85
|
|
|
82
86
|
@property
|
|
83
87
|
def known_types(self) -> tuple[str, ...]:
|
|
@@ -86,10 +90,19 @@ class RunnerRegistry:
|
|
|
86
90
|
def get(self, runner_type: str) -> Runner:
|
|
87
91
|
if runner_type not in self._specs:
|
|
88
92
|
raise RunnerContractError(f"unknown runner type: {runner_type!r}")
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
runner = self._loaded.get(runner_type)
|
|
94
|
+
if runner is not None:
|
|
95
|
+
return runner
|
|
96
|
+
# Initialization is serialized only for the selected adapter. Other
|
|
97
|
+
# runner types may load concurrently, which matters for capability
|
|
98
|
+
# probes such as Claude's --help check.
|
|
99
|
+
with self._load_locks[runner_type]:
|
|
100
|
+
runner = self._loaded.get(runner_type)
|
|
101
|
+
if runner is None:
|
|
102
|
+
spec = self._specs[runner_type]
|
|
103
|
+
runner = spec.load() if isinstance(spec, LazyRunnerSpec) else spec()
|
|
104
|
+
self._loaded[runner_type] = runner
|
|
105
|
+
return runner
|
|
93
106
|
|
|
94
107
|
def __contains__(self, runner_type: object) -> bool:
|
|
95
108
|
return runner_type in self._specs
|
|
@@ -19,7 +19,13 @@ from typing import Any
|
|
|
19
19
|
|
|
20
20
|
from ..artifacts import ArtifactStore
|
|
21
21
|
from ..paths import path_is_link_or_reparse
|
|
22
|
-
from ..types import
|
|
22
|
+
from ..types import (
|
|
23
|
+
DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS,
|
|
24
|
+
HookError,
|
|
25
|
+
ModuleRuntimeState,
|
|
26
|
+
RuntimeContext,
|
|
27
|
+
StepConfig,
|
|
28
|
+
)
|
|
23
29
|
from .runners.contracts import bounded_diagnostic, redact_diagnostic
|
|
24
30
|
from .runners.process import (
|
|
25
31
|
DEFAULT_MAX_OUTPUT_BYTES,
|
|
@@ -27,7 +33,6 @@ from .runners.process import (
|
|
|
27
33
|
run_process,
|
|
28
34
|
)
|
|
29
35
|
|
|
30
|
-
DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS = 60
|
|
31
36
|
STEP_COMMAND_STDOUT_ARTIFACT = "stdout.txt"
|
|
32
37
|
STEP_COMMAND_STDERR_ARTIFACT = "stderr.txt"
|
|
33
38
|
STEP_COMMAND_RESULT_ARTIFACT = "result.json"
|