pilot-workers 0.2.0__py3-none-any.whl
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.
- pilot_workers/__init__.py +1 -0
- pilot_workers/__main__.py +3 -0
- pilot_workers/cli/__init__.py +0 -0
- pilot_workers/cli/dispatch.py +577 -0
- pilot_workers/cli/install.py +259 -0
- pilot_workers/cli/main.py +115 -0
- pilot_workers/cli/run.py +191 -0
- pilot_workers/credentials.py +102 -0
- pilot_workers/data/permissions/README.md +47 -0
- pilot_workers/data/permissions/relaxed.yaml +10 -0
- pilot_workers/data/permissions/strict.yaml +9 -0
- pilot_workers/data/providers/README.md +24 -0
- pilot_workers/data/providers/ds.yaml +11 -0
- pilot_workers/data/providers/glm.yaml +11 -0
- pilot_workers/data/providers/kimi-k3.yaml +11 -0
- pilot_workers/data/templates/code.md +28 -0
- pilot_workers/data/templates/explore.md +17 -0
- pilot_workers/data/templates/review.md +18 -0
- pilot_workers/data/templates/test.md +17 -0
- pilot_workers/fmt_events.py +218 -0
- pilot_workers/integrations/README.md +25 -0
- pilot_workers/integrations/claude-host/agents/ds-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/ds-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/ds-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/ds-tester.md +46 -0
- pilot_workers/integrations/claude-host/agents/glm-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/glm-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/glm-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/glm-tester.md +46 -0
- pilot_workers/integrations/claude-host/agents/kimi-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/kimi-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/kimi-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/kimi-tester.md +50 -0
- pilot_workers/integrations/claude-host/commands/glm/code.md +48 -0
- pilot_workers/integrations/claude-host/commands/glm/explore.md +38 -0
- pilot_workers/integrations/claude-host/commands/glm/review.md +38 -0
- pilot_workers/integrations/claude-host/commands/glm/test.md +30 -0
- pilot_workers/integrations/claude-host/commands/kimi/code.md +47 -0
- pilot_workers/integrations/claude-host/commands/kimi/explore.md +38 -0
- pilot_workers/integrations/claude-host/commands/kimi/review.md +38 -0
- pilot_workers/integrations/claude-host/commands/kimi/test.md +32 -0
- pilot_workers/integrations/codex-host/ds/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/ds/openai.yaml +4 -0
- pilot_workers/integrations/codex-host/glm/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/glm/openai.yaml +4 -0
- pilot_workers/integrations/codex-host/kimi/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/kimi/openai.yaml +4 -0
- pilot_workers/maintain.py +170 -0
- pilot_workers/policy.py +295 -0
- pilot_workers/prompts/code.md +6 -0
- pilot_workers/prompts/common.md +24 -0
- pilot_workers/prompts/explore.md +7 -0
- pilot_workers/prompts/review.md +5 -0
- pilot_workers/prompts/test.md +6 -0
- pilot_workers/providers.py +137 -0
- pilot_workers/runners/__init__.py +28 -0
- pilot_workers/runners/base.py +123 -0
- pilot_workers/runners/opencode_runner.py +377 -0
- pilot_workers/runtime.py +314 -0
- pilot_workers/scripts/install_runtime.sh +51 -0
- pilot_workers-0.2.0.dist-info/METADATA +84 -0
- pilot_workers-0.2.0.dist-info/RECORD +66 -0
- pilot_workers-0.2.0.dist-info/WHEEL +5 -0
- pilot_workers-0.2.0.dist-info/entry_points.txt +2 -0
- pilot_workers-0.2.0.dist-info/licenses/LICENSE +21 -0
- pilot_workers-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""OpenCode runner adapter — concrete Runner for the opencode-ai CLI.
|
|
2
|
+
|
|
3
|
+
This module is the new home for the OpenCode-specific glue that used to live
|
|
4
|
+
inline in ``cli/run.py`` / ``runtime.py`` / ``cli/dispatch.py`` /
|
|
5
|
+
``fmt_events.py``. Behaviour is preserved verbatim from those sources.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pilot_workers import policy
|
|
17
|
+
from pilot_workers.providers import (
|
|
18
|
+
Provider,
|
|
19
|
+
pilot_home,
|
|
20
|
+
profile_paths,
|
|
21
|
+
profile_root,
|
|
22
|
+
)
|
|
23
|
+
from pilot_workers.runners.base import (
|
|
24
|
+
Runner,
|
|
25
|
+
TokenUsage,
|
|
26
|
+
ToolCall,
|
|
27
|
+
UnifiedEvent,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Pinned engine version. Single source of truth — install_runtime.sh and
|
|
32
|
+
# everything else reads it from here.
|
|
33
|
+
PINNED_OPENCODE_VERSION = "1.18.4"
|
|
34
|
+
|
|
35
|
+
# Substring in a tool error message that signals a permission-rule denial.
|
|
36
|
+
# Mirrors the check formerly inlined in ``cli/dispatch.py`` (parse_jsonl,
|
|
37
|
+
# tool_use branch).
|
|
38
|
+
_PERMISSION_DENIED_MARK = "rule which prevents"
|
|
39
|
+
|
|
40
|
+
# Tools whose output is a file dump or a plain confirmation — the input path
|
|
41
|
+
# already says everything; echoing the output only adds noise. Mirrors
|
|
42
|
+
# ``fmt_events.SILENT_OUTPUT_TOOLS`` (deleted from there).
|
|
43
|
+
SILENT_OUTPUT_TOOLS = {"read", "edit", "write", "list", "todowrite"}
|
|
44
|
+
|
|
45
|
+
# Renderer input/output limits (mirror fmt_events defaults verbatim).
|
|
46
|
+
_INPUT_LIMIT = 200
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OpenCodeRunner(Runner):
|
|
50
|
+
"""Adapter for the opencode-ai CLI (pinned to ``PINNED_OPENCODE_VERSION``)."""
|
|
51
|
+
|
|
52
|
+
name = "opencode"
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# config / command / env
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def build_config(
|
|
59
|
+
self, provider: Any, mode: str, permission_profile: str | None = None,
|
|
60
|
+
) -> dict:
|
|
61
|
+
# Thin pass-through to policy.build_config — single source of truth.
|
|
62
|
+
return policy.build_config(
|
|
63
|
+
provider, mode, permission_profile=permission_profile,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def build_command(
|
|
67
|
+
self, binary: Path, provider: Any, mode: str,
|
|
68
|
+
workdir: Path, run_id: str, session: str | None,
|
|
69
|
+
) -> list[str]:
|
|
70
|
+
# Mirrors cli/run.py:118-128 exactly.
|
|
71
|
+
command = [
|
|
72
|
+
str(binary), "--pure", "run",
|
|
73
|
+
"--model", provider.model,
|
|
74
|
+
"--agent", policy.MODE_TO_AGENT[mode],
|
|
75
|
+
"--format", "json",
|
|
76
|
+
"--thinking",
|
|
77
|
+
"--title", f"pilot-worker-{mode}-{run_id}",
|
|
78
|
+
"--dir", str(workdir),
|
|
79
|
+
]
|
|
80
|
+
if session:
|
|
81
|
+
command.extend(["--session", session])
|
|
82
|
+
return command
|
|
83
|
+
|
|
84
|
+
def runner_environment(self, provider: Any, config: dict) -> dict[str, str]:
|
|
85
|
+
# Mirrors the OpenCode-only slice of the former runtime.build_environment
|
|
86
|
+
# (runtime.py:47-56). Neutral keys (SAFE_ENV_KEYS, XDG_*, NO_COLOR,
|
|
87
|
+
# CI) are added by the runtime layer, not here.
|
|
88
|
+
paths = profile_paths(provider)
|
|
89
|
+
return {
|
|
90
|
+
"OPENCODE_CONFIG_DIR": str(paths["config"] / "opencode"),
|
|
91
|
+
"OPENCODE_CONFIG_CONTENT": json.dumps(config, separators=(",", ":")),
|
|
92
|
+
"OPENCODE_DISABLE_AUTOUPDATE": "1",
|
|
93
|
+
"OPENCODE_AUTO_SHARE": "false",
|
|
94
|
+
"OPENCODE_DISABLE_DEFAULT_PLUGINS": "1",
|
|
95
|
+
"OPENCODE_DISABLE_LSP_DOWNLOAD": "1",
|
|
96
|
+
"OPENCODE_DISABLE_MODELS_FETCH": "1",
|
|
97
|
+
"OPENCODE_DISABLE_CLAUDE_CODE": "1",
|
|
98
|
+
"OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "1",
|
|
99
|
+
"OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "1",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def format_task_input(self, task: str, mode: str) -> str:
|
|
103
|
+
# Mirrors cli/run.py:117.
|
|
104
|
+
return f'<worker-task mode="{mode}">\n{task}\n</worker-task>'
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# event translation
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def parse_events(self, raw: dict) -> list[UnifiedEvent]:
|
|
111
|
+
"""Translate one raw OpenCode event into 0..n UnifiedEvents.
|
|
112
|
+
|
|
113
|
+
Behaviour mirrors the union of the former inline translators:
|
|
114
|
+
- cli/dispatch.parse_jsonl (step/text/tool/error branches)
|
|
115
|
+
- fmt_events tool input/output brief extraction
|
|
116
|
+
- runtime.run_process recursive session-id scan
|
|
117
|
+
"""
|
|
118
|
+
events: list[UnifiedEvent] = []
|
|
119
|
+
|
|
120
|
+
# Top-level timestamp → ts (epoch ms). bool excluded.
|
|
121
|
+
ts_value = raw.get("timestamp")
|
|
122
|
+
ts: int | None = None
|
|
123
|
+
if isinstance(ts_value, (int, float)) and not isinstance(ts_value, bool):
|
|
124
|
+
ts = int(ts_value)
|
|
125
|
+
|
|
126
|
+
# Recursive session-id scan: emit one session event if any found.
|
|
127
|
+
# runtime.run_process keeps the LAST id (ids[-1]); do the same here.
|
|
128
|
+
ids = _session_ids(raw)
|
|
129
|
+
if ids:
|
|
130
|
+
events.append(UnifiedEvent(kind="session", ts=ts, session_id=ids[-1]))
|
|
131
|
+
|
|
132
|
+
event_type = raw.get("type")
|
|
133
|
+
|
|
134
|
+
if event_type == "step_finish":
|
|
135
|
+
events.append(UnifiedEvent(
|
|
136
|
+
kind="step",
|
|
137
|
+
ts=ts,
|
|
138
|
+
tokens=_extract_tokens(raw.get("part")),
|
|
139
|
+
))
|
|
140
|
+
elif event_type == "text":
|
|
141
|
+
events.append(UnifiedEvent(
|
|
142
|
+
kind="text",
|
|
143
|
+
ts=ts,
|
|
144
|
+
text=_part_text(raw.get("part")),
|
|
145
|
+
))
|
|
146
|
+
elif event_type == "reasoning":
|
|
147
|
+
events.append(UnifiedEvent(
|
|
148
|
+
kind="reasoning",
|
|
149
|
+
ts=ts,
|
|
150
|
+
text=_part_text(raw.get("part")),
|
|
151
|
+
))
|
|
152
|
+
elif event_type == "tool_use":
|
|
153
|
+
events.append(UnifiedEvent(
|
|
154
|
+
kind="tool",
|
|
155
|
+
ts=ts,
|
|
156
|
+
tool=_extract_tool_call(raw.get("part") or {}),
|
|
157
|
+
))
|
|
158
|
+
elif event_type == "error":
|
|
159
|
+
events.append(UnifiedEvent(kind="error", ts=ts))
|
|
160
|
+
# Anything else → emit nothing (the session event above, if any,
|
|
161
|
+
# already covers the session-id extraction side-effect).
|
|
162
|
+
|
|
163
|
+
return events
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# binary / credentials
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def binary_path(self) -> Path | None:
|
|
170
|
+
# Best-effort location used for dry-run display; not verified.
|
|
171
|
+
return (
|
|
172
|
+
pilot_home()
|
|
173
|
+
/ "worker-runtime"
|
|
174
|
+
/ "opencode"
|
|
175
|
+
/ PINNED_OPENCODE_VERSION
|
|
176
|
+
/ "node_modules"
|
|
177
|
+
/ ".bin"
|
|
178
|
+
/ "opencode"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def resolve_binary(self) -> Path:
|
|
182
|
+
# Absorbs runners/opencode.py:verify_binary behaviour, but builds the
|
|
183
|
+
# path locally so this module is the single source of truth for the
|
|
184
|
+
# runner's binary location.
|
|
185
|
+
binary = self.binary_path()
|
|
186
|
+
assert binary is not None
|
|
187
|
+
if not binary.is_file() or not os.access(binary, os.X_OK):
|
|
188
|
+
raise RuntimeError(
|
|
189
|
+
f"pinned OpenCode {PINNED_OPENCODE_VERSION} is missing; "
|
|
190
|
+
"run: bash scripts/install_runtime.sh"
|
|
191
|
+
)
|
|
192
|
+
version = subprocess.run(
|
|
193
|
+
[str(binary), "--version"], text=True, capture_output=True, check=False,
|
|
194
|
+
)
|
|
195
|
+
if (
|
|
196
|
+
version.returncode != 0
|
|
197
|
+
or version.stdout.strip() != PINNED_OPENCODE_VERSION
|
|
198
|
+
):
|
|
199
|
+
actual = (version.stdout or version.stderr).strip()
|
|
200
|
+
raise RuntimeError(
|
|
201
|
+
f"expected OpenCode {PINNED_OPENCODE_VERSION}, "
|
|
202
|
+
f"got {actual or 'unknown'}"
|
|
203
|
+
)
|
|
204
|
+
return binary
|
|
205
|
+
|
|
206
|
+
def credential_path(self, provider: Any) -> Path:
|
|
207
|
+
return profile_root(provider) / "data" / "opencode" / "auth.json"
|
|
208
|
+
|
|
209
|
+
def credential_payload(self, provider: Any, key: str) -> dict:
|
|
210
|
+
return {provider.provider_id: {"type": "api", "key": key}}
|
|
211
|
+
|
|
212
|
+
def parse_credential(self, provider: Any, payload: dict) -> str:
|
|
213
|
+
# Mirrors the validation in the former runtime.credential_key
|
|
214
|
+
# (runtime.py:73-79).
|
|
215
|
+
entry = payload.get(provider.provider_id)
|
|
216
|
+
if not isinstance(entry, dict) or entry.get("type") != "api":
|
|
217
|
+
raise RuntimeError(
|
|
218
|
+
f"credential file lacks API auth for {provider.provider_id}"
|
|
219
|
+
)
|
|
220
|
+
key = entry.get("key")
|
|
221
|
+
if not isinstance(key, str) or not key.strip():
|
|
222
|
+
raise RuntimeError(
|
|
223
|
+
f"credential is empty for {provider.provider_id}"
|
|
224
|
+
)
|
|
225
|
+
return key
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ----------------------------------------------------------------------
|
|
229
|
+
# parse_events helpers (mirror cli/dispatch.py:_safe_int semantics exactly)
|
|
230
|
+
# ----------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _safe_int(value: Any, default: int = 0) -> int:
|
|
234
|
+
if isinstance(value, bool):
|
|
235
|
+
return default
|
|
236
|
+
if isinstance(value, (int, float)):
|
|
237
|
+
return int(value)
|
|
238
|
+
return default
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _safe_int_cache(cache: Any, field: str) -> int:
|
|
242
|
+
if isinstance(cache, dict):
|
|
243
|
+
return _safe_int(cache.get(field))
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _extract_tokens(part: Any) -> TokenUsage:
|
|
248
|
+
if not isinstance(part, dict):
|
|
249
|
+
return TokenUsage()
|
|
250
|
+
tk = part.get("tokens")
|
|
251
|
+
if not isinstance(tk, dict):
|
|
252
|
+
return TokenUsage()
|
|
253
|
+
return TokenUsage(
|
|
254
|
+
input=_safe_int(tk.get("input")),
|
|
255
|
+
output=_safe_int(tk.get("output")),
|
|
256
|
+
reasoning=_safe_int(tk.get("reasoning")),
|
|
257
|
+
cache_read=_safe_int_cache(tk.get("cache"), "read"),
|
|
258
|
+
cache_write=_safe_int_cache(tk.get("cache"), "write"),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _part_text(part: Any) -> str | None:
|
|
263
|
+
if not isinstance(part, dict):
|
|
264
|
+
return None
|
|
265
|
+
text = part.get("text")
|
|
266
|
+
return text if isinstance(text, str) else None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _extract_tool_call(part: Any) -> ToolCall:
|
|
270
|
+
if not isinstance(part, dict):
|
|
271
|
+
part = {}
|
|
272
|
+
state = part.get("state") or {}
|
|
273
|
+
if not isinstance(state, dict):
|
|
274
|
+
state = {}
|
|
275
|
+
tool_name = part.get("tool", "?")
|
|
276
|
+
if not isinstance(tool_name, str):
|
|
277
|
+
tool_name = "?" if tool_name is None else str(tool_name)
|
|
278
|
+
|
|
279
|
+
status_value = state.get("status")
|
|
280
|
+
status = status_value if isinstance(status_value, str) else (
|
|
281
|
+
"" if status_value is None else str(status_value)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
input_brief = _tool_input_brief(tool_name, state.get("input") or {})
|
|
285
|
+
output_brief = _first_line(state.get("output") or "", _INPUT_LIMIT)
|
|
286
|
+
|
|
287
|
+
error = None
|
|
288
|
+
if status == "error":
|
|
289
|
+
error_value = state.get("error")
|
|
290
|
+
if isinstance(error_value, str):
|
|
291
|
+
error = _first_line(error_value, _INPUT_LIMIT)
|
|
292
|
+
elif error_value is not None:
|
|
293
|
+
error = _first_line(str(error_value), _INPUT_LIMIT)
|
|
294
|
+
|
|
295
|
+
is_permission_denied = (
|
|
296
|
+
isinstance(error, str) and _PERMISSION_DENIED_MARK in error
|
|
297
|
+
)
|
|
298
|
+
silent_output = tool_name in SILENT_OUTPUT_TOOLS
|
|
299
|
+
|
|
300
|
+
return ToolCall(
|
|
301
|
+
name=tool_name,
|
|
302
|
+
status=status,
|
|
303
|
+
input_brief=input_brief,
|
|
304
|
+
output_brief=output_brief,
|
|
305
|
+
error=error,
|
|
306
|
+
is_permission_denied=is_permission_denied,
|
|
307
|
+
silent_output=silent_output,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ----------------------------------------------------------------------
|
|
312
|
+
# Migrated from fmt_events.py — private, behaviour preserved verbatim.
|
|
313
|
+
# ----------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _trim(value: Any, limit: int) -> str:
|
|
317
|
+
text = str(value).strip()
|
|
318
|
+
if len(text) <= limit:
|
|
319
|
+
return text
|
|
320
|
+
return text[: limit - 1] + "…"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _short_path(value: str) -> str:
|
|
324
|
+
"""~ for home, and keep only the last 3 segments of long paths."""
|
|
325
|
+
home = str(Path.home())
|
|
326
|
+
if value.startswith(home):
|
|
327
|
+
value = "~" + value[len(home):]
|
|
328
|
+
if len(value) > 64 and "/" in value:
|
|
329
|
+
parts = value.split("/")
|
|
330
|
+
if len(parts) > 4:
|
|
331
|
+
value = "…/" + "/".join(parts[-3:])
|
|
332
|
+
return value
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _first_line(value: str, limit: int) -> str:
|
|
336
|
+
"""First informative line, never a raw newline. Skips XML-ish wrappers."""
|
|
337
|
+
for line in str(value).splitlines():
|
|
338
|
+
line = line.strip()
|
|
339
|
+
if not line:
|
|
340
|
+
continue
|
|
341
|
+
if line.startswith("<") and line.endswith(">"):
|
|
342
|
+
continue # <path>…</path> / <type>file</type> / <content> wrappers
|
|
343
|
+
if line.startswith("/") and " " not in line:
|
|
344
|
+
line = _short_path(line)
|
|
345
|
+
return _trim(line, limit)
|
|
346
|
+
return ""
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _tool_input_brief(tool: str, tool_input: dict[str, Any]) -> str:
|
|
350
|
+
for key in ("command", "filePath", "pattern", "path", "query", "url"):
|
|
351
|
+
value = tool_input.get(key)
|
|
352
|
+
if value:
|
|
353
|
+
text = str(value)
|
|
354
|
+
if key in ("filePath", "path"):
|
|
355
|
+
text = _short_path(text)
|
|
356
|
+
return _trim(text.replace("\n", " "), _INPUT_LIMIT)
|
|
357
|
+
return _trim(json.dumps(tool_input, ensure_ascii=False), _INPUT_LIMIT) if tool_input else ""
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ----------------------------------------------------------------------
|
|
361
|
+
# Migrated from runtime.session_ids — recursive session-id scan, used by
|
|
362
|
+
# parse_events. The neutral runtime no longer knows this shape.
|
|
363
|
+
# ----------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _session_ids(value: Any) -> list[str]:
|
|
367
|
+
found: list[str] = []
|
|
368
|
+
if isinstance(value, dict):
|
|
369
|
+
for key, item in value.items():
|
|
370
|
+
if key in {"sessionID", "sessionId", "session_id"} and isinstance(item, str):
|
|
371
|
+
found.append(item)
|
|
372
|
+
else:
|
|
373
|
+
found.extend(_session_ids(item))
|
|
374
|
+
elif isinstance(value, list):
|
|
375
|
+
for item in value:
|
|
376
|
+
found.extend(_session_ids(item))
|
|
377
|
+
return found
|
pilot_workers/runtime.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Isolated execution runtime: environment, credentials, worktrees, process I/O."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, TextIO
|
|
15
|
+
|
|
16
|
+
from pilot_workers.providers import Provider, profile_paths
|
|
17
|
+
from pilot_workers.runners.base import Runner, UnifiedEvent
|
|
18
|
+
|
|
19
|
+
SAFE_ENV_KEYS = (
|
|
20
|
+
"HOME", "USER", "LOGNAME", "SHELL", "PATH", "TMPDIR",
|
|
21
|
+
"LANG", "LC_ALL",
|
|
22
|
+
"JAVA_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT",
|
|
23
|
+
"FLUTTER_ROOT", "GOPATH", "GOROOT",
|
|
24
|
+
"CARGO_HOME", "RUSTUP_HOME",
|
|
25
|
+
"NVM_DIR", "PYENV_ROOT", "RBENV_ROOT",
|
|
26
|
+
"BUN_INSTALL", "PNPM_HOME",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Env keys a runner must never override: neutral SAFE_ENV_KEYS already owned
|
|
30
|
+
# by this layer, the XDG_*_HOME dirs (also owned here), plus the NO_COLOR / CI
|
|
31
|
+
# flags the runtime sets to keep worker output deterministic.
|
|
32
|
+
_PROTECTED_KEYS = frozenset(SAFE_ENV_KEYS) | frozenset(
|
|
33
|
+
k for k in ("NO_COLOR", "CI")
|
|
34
|
+
) | frozenset(
|
|
35
|
+
f"XDG_{d}_HOME" for d in ("CONFIG", "DATA", "STATE", "CACHE")
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
HEARTBEAT_SECONDS = 60
|
|
39
|
+
TERMINATE_GRACE_SECONDS = 10
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ensure_private_directory(path: Path) -> None:
|
|
43
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
44
|
+
path.chmod(0o700)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_environment(provider: Provider, runner_env: dict[str, str]) -> dict[str, str]:
|
|
48
|
+
"""Compose the child process environment.
|
|
49
|
+
|
|
50
|
+
Neutral concerns (SAFE_ENV_KEYS whitelist + XDG dirs) are owned here; the
|
|
51
|
+
runner-specific variables come from ``runner.runner_environment`` via the
|
|
52
|
+
``runner_env`` argument and are merged unchanged.
|
|
53
|
+
"""
|
|
54
|
+
paths = profile_paths(provider)
|
|
55
|
+
for name in ("root", "config", "data", "state", "cache"):
|
|
56
|
+
ensure_private_directory(paths[name])
|
|
57
|
+
env = {key: os.environ[key] for key in SAFE_ENV_KEYS if os.environ.get(key)}
|
|
58
|
+
env.update({
|
|
59
|
+
"XDG_CONFIG_HOME": str(paths["config"]),
|
|
60
|
+
"XDG_DATA_HOME": str(paths["data"]),
|
|
61
|
+
"XDG_STATE_HOME": str(paths["state"]),
|
|
62
|
+
"XDG_CACHE_HOME": str(paths["cache"]),
|
|
63
|
+
"NO_COLOR": "1",
|
|
64
|
+
"CI": "1",
|
|
65
|
+
})
|
|
66
|
+
# Drop any runner-supplied entry that would shadow a neutral key — those
|
|
67
|
+
# are owned by this layer and must remain deterministic.
|
|
68
|
+
filtered = {k: v for k, v in runner_env.items() if k not in _PROTECTED_KEYS}
|
|
69
|
+
env.update(filtered)
|
|
70
|
+
return env
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def credential_key(provider: Provider, runner: Runner) -> str:
|
|
74
|
+
path = runner.credential_path(provider)
|
|
75
|
+
if not path.is_file():
|
|
76
|
+
raise RuntimeError(f"credential missing for {provider.key}; run: pilot-workers configure {provider.key}")
|
|
77
|
+
if path.stat().st_mode & 0o077:
|
|
78
|
+
raise RuntimeError(f"credential file is not private (expected mode 0600): {path}")
|
|
79
|
+
try:
|
|
80
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
81
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
82
|
+
raise RuntimeError(f"cannot read credential from {path}: {exc}") from exc
|
|
83
|
+
return runner.parse_credential(provider, payload)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def credential_metadata(provider: Provider, runner: Runner) -> dict[str, Any]:
|
|
87
|
+
path = runner.credential_path(provider)
|
|
88
|
+
configured = False
|
|
89
|
+
secure_mode = False
|
|
90
|
+
if path.is_file():
|
|
91
|
+
secure_mode = (path.stat().st_mode & 0o077) == 0
|
|
92
|
+
try:
|
|
93
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
94
|
+
configured = _looks_configured(provider, runner, payload)
|
|
95
|
+
except (OSError, json.JSONDecodeError):
|
|
96
|
+
configured = False
|
|
97
|
+
return {"configured": configured, "secure_mode": secure_mode, "path": str(path)}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _looks_configured(provider: Provider, runner: Runner, payload: Any) -> bool:
|
|
101
|
+
"""Best-effort 'has a usable API key' check; never raises."""
|
|
102
|
+
try:
|
|
103
|
+
key = runner.parse_credential(provider, payload)
|
|
104
|
+
except (RuntimeError, TypeError, AttributeError):
|
|
105
|
+
return False
|
|
106
|
+
return bool(key.strip())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def create_detached_worktree(workdir: Path, worktree_parent: Path) -> Path:
|
|
110
|
+
root_result = subprocess.run(
|
|
111
|
+
["git", "-C", str(workdir), "rev-parse", "--show-toplevel"],
|
|
112
|
+
text=True, capture_output=True, check=False,
|
|
113
|
+
)
|
|
114
|
+
if root_result.returncode != 0:
|
|
115
|
+
raise RuntimeError(f"--worktree requires a Git repository: {root_result.stderr.strip()}")
|
|
116
|
+
repository_root = Path(root_result.stdout.strip()).resolve()
|
|
117
|
+
status = subprocess.run(
|
|
118
|
+
["git", "-C", str(repository_root), "status", "--porcelain"],
|
|
119
|
+
text=True, capture_output=True, check=False,
|
|
120
|
+
)
|
|
121
|
+
if status.returncode != 0:
|
|
122
|
+
raise RuntimeError(f"cannot inspect Git status: {status.stderr.strip()}")
|
|
123
|
+
if status.stdout.strip():
|
|
124
|
+
raise RuntimeError("--worktree requires a clean repository (commit or stash first)")
|
|
125
|
+
ensure_private_directory(worktree_parent)
|
|
126
|
+
from datetime import datetime, timezone
|
|
127
|
+
import secrets as secrets_module
|
|
128
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
129
|
+
target = worktree_parent / f"{repository_root.name}-{stamp}-{secrets_module.token_hex(3)}"
|
|
130
|
+
add_result = subprocess.run(
|
|
131
|
+
["git", "-C", str(repository_root), "worktree", "add", "--detach", str(target), "HEAD"],
|
|
132
|
+
text=True, capture_output=True, check=False,
|
|
133
|
+
)
|
|
134
|
+
if add_result.returncode != 0:
|
|
135
|
+
raise RuntimeError(f"cannot create detached worktree: {add_result.stderr.strip()}")
|
|
136
|
+
try:
|
|
137
|
+
relative = workdir.resolve().relative_to(repository_root)
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
raise RuntimeError(f"workdir {workdir} is not inside repository {repository_root}") from exc
|
|
140
|
+
return target / relative
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def open_private_text(path: Path) -> TextIO:
|
|
144
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
145
|
+
return os.fdopen(descriptor, "w", encoding="utf-8")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class RunResult:
|
|
150
|
+
exit_code: int
|
|
151
|
+
session_id: str | None
|
|
152
|
+
timed_out: bool = False
|
|
153
|
+
idle_timed_out: bool = False
|
|
154
|
+
interrupted: bool = False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _SafeRenderer:
|
|
158
|
+
def __init__(self, writer: Any) -> None:
|
|
159
|
+
self._writer = writer
|
|
160
|
+
self._broken = False
|
|
161
|
+
|
|
162
|
+
def _guard(self, action) -> None:
|
|
163
|
+
if self._broken or self._writer is None:
|
|
164
|
+
return
|
|
165
|
+
try:
|
|
166
|
+
action()
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
self._broken = True
|
|
169
|
+
print(f"note: live log rendering disabled ({exc})", file=sys.stderr)
|
|
170
|
+
|
|
171
|
+
def event(self, event: dict[str, Any]) -> None:
|
|
172
|
+
self._guard(lambda: self._writer.write_event(event))
|
|
173
|
+
|
|
174
|
+
def raw_line(self, line: str, runner: Runner | None) -> None:
|
|
175
|
+
def action() -> None:
|
|
176
|
+
try:
|
|
177
|
+
event = json.loads(line)
|
|
178
|
+
except json.JSONDecodeError:
|
|
179
|
+
return
|
|
180
|
+
if not isinstance(event, dict):
|
|
181
|
+
return
|
|
182
|
+
if runner is None:
|
|
183
|
+
# Without a runner we can only render self-owned events.
|
|
184
|
+
self._writer.write_event(event)
|
|
185
|
+
return
|
|
186
|
+
for ev in runner.parse_events(event):
|
|
187
|
+
self._writer.write_unified(ev)
|
|
188
|
+
# Self-owned events (worker_runner.*) live outside parse_events;
|
|
189
|
+
# also render them in case the line is one.
|
|
190
|
+
self._writer.write_event(event)
|
|
191
|
+
self._guard(action)
|
|
192
|
+
|
|
193
|
+
def finalize(self) -> None:
|
|
194
|
+
self._guard(lambda: self._writer.finalize())
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def run_process(
|
|
198
|
+
command: list[str], env: dict[str, str], task: str,
|
|
199
|
+
log_path: Path, stderr_path: Path, secret: str,
|
|
200
|
+
renderer: Any = None, timeout_s: int = 0, idle_timeout_s: int = 0,
|
|
201
|
+
runner: Runner | None = None,
|
|
202
|
+
) -> RunResult:
|
|
203
|
+
safe_renderer = _SafeRenderer(renderer)
|
|
204
|
+
result = RunResult(exit_code=1, session_id=None)
|
|
205
|
+
last_activity = time.monotonic()
|
|
206
|
+
started_at = last_activity
|
|
207
|
+
lock = threading.Lock()
|
|
208
|
+
|
|
209
|
+
def redact(value: str) -> str:
|
|
210
|
+
return value.replace(secret, "[REDACTED]") if secret else value
|
|
211
|
+
|
|
212
|
+
with open_private_text(log_path) as stdout_log, open_private_text(stderr_path) as stderr_log:
|
|
213
|
+
process = subprocess.Popen(
|
|
214
|
+
command, env=env, stdin=subprocess.PIPE,
|
|
215
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def feed_stdin() -> None:
|
|
219
|
+
try:
|
|
220
|
+
assert process.stdin is not None
|
|
221
|
+
process.stdin.write(task)
|
|
222
|
+
process.stdin.close()
|
|
223
|
+
except (BrokenPipeError, OSError):
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
def touch() -> None:
|
|
227
|
+
nonlocal last_activity
|
|
228
|
+
with lock:
|
|
229
|
+
last_activity = time.monotonic()
|
|
230
|
+
|
|
231
|
+
def consume_stdout() -> None:
|
|
232
|
+
assert process.stdout is not None
|
|
233
|
+
for raw_line in process.stdout:
|
|
234
|
+
touch()
|
|
235
|
+
line = redact(raw_line)
|
|
236
|
+
stdout_log.write(line); stdout_log.flush()
|
|
237
|
+
sys.stdout.write(line); sys.stdout.flush()
|
|
238
|
+
safe_renderer.raw_line(line, runner)
|
|
239
|
+
try:
|
|
240
|
+
event = json.loads(line)
|
|
241
|
+
except json.JSONDecodeError:
|
|
242
|
+
continue
|
|
243
|
+
if not isinstance(event, dict) or runner is None:
|
|
244
|
+
continue
|
|
245
|
+
try:
|
|
246
|
+
for ev in runner.parse_events(event):
|
|
247
|
+
if ev.kind == "session" and ev.session_id:
|
|
248
|
+
result.session_id = ev.session_id
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
def consume_stderr() -> None:
|
|
253
|
+
assert process.stderr is not None
|
|
254
|
+
for raw_line in process.stderr:
|
|
255
|
+
touch()
|
|
256
|
+
line = redact(raw_line)
|
|
257
|
+
stderr_log.write(line); stderr_log.flush()
|
|
258
|
+
sys.stderr.write(line); sys.stderr.flush()
|
|
259
|
+
|
|
260
|
+
threads = [
|
|
261
|
+
threading.Thread(target=feed_stdin, daemon=True),
|
|
262
|
+
threading.Thread(target=consume_stdout, daemon=True),
|
|
263
|
+
threading.Thread(target=consume_stderr, daemon=True),
|
|
264
|
+
]
|
|
265
|
+
for thread in threads:
|
|
266
|
+
thread.start()
|
|
267
|
+
|
|
268
|
+
def stop_child() -> None:
|
|
269
|
+
process.terminate()
|
|
270
|
+
try:
|
|
271
|
+
process.wait(timeout=TERMINATE_GRACE_SECONDS)
|
|
272
|
+
except subprocess.TimeoutExpired:
|
|
273
|
+
process.kill(); process.wait()
|
|
274
|
+
|
|
275
|
+
def emit_runner_event(event: dict[str, Any]) -> None:
|
|
276
|
+
payload = json.dumps(event)
|
|
277
|
+
stderr_log.write(payload + "\n"); stderr_log.flush()
|
|
278
|
+
sys.stderr.write(payload + "\n"); sys.stderr.flush()
|
|
279
|
+
safe_renderer.event(event)
|
|
280
|
+
|
|
281
|
+
next_heartbeat_silence = HEARTBEAT_SECONDS
|
|
282
|
+
try:
|
|
283
|
+
while True:
|
|
284
|
+
try:
|
|
285
|
+
result.exit_code = process.wait(timeout=1)
|
|
286
|
+
break
|
|
287
|
+
except subprocess.TimeoutExpired:
|
|
288
|
+
pass
|
|
289
|
+
now = time.monotonic()
|
|
290
|
+
with lock:
|
|
291
|
+
silent = now - last_activity
|
|
292
|
+
elapsed = now - started_at
|
|
293
|
+
if timeout_s and elapsed >= timeout_s:
|
|
294
|
+
result.timed_out = True; stop_child()
|
|
295
|
+
result.exit_code = process.returncode or 124; break
|
|
296
|
+
if idle_timeout_s and silent >= idle_timeout_s:
|
|
297
|
+
result.idle_timed_out = True; stop_child()
|
|
298
|
+
result.exit_code = process.returncode or 124; break
|
|
299
|
+
if silent >= next_heartbeat_silence:
|
|
300
|
+
emit_runner_event({"type": "worker_runner.heartbeat",
|
|
301
|
+
"elapsed_s": int(elapsed), "silent_s": int(silent)})
|
|
302
|
+
next_heartbeat_silence = silent + HEARTBEAT_SECONDS
|
|
303
|
+
else:
|
|
304
|
+
next_heartbeat_silence = max(next_heartbeat_silence, HEARTBEAT_SECONDS)
|
|
305
|
+
except KeyboardInterrupt:
|
|
306
|
+
result.interrupted = True; stop_child()
|
|
307
|
+
result.exit_code = process.returncode or 130
|
|
308
|
+
finally:
|
|
309
|
+
for thread in threads:
|
|
310
|
+
thread.join(timeout=5)
|
|
311
|
+
|
|
312
|
+
if (result.timed_out or result.idle_timed_out or result.interrupted) and result.exit_code == 0:
|
|
313
|
+
result.exit_code = 124
|
|
314
|
+
return result
|